task: T0.5 (judge)

This commit is contained in:
2026-08-19 16:53:03 +00:00
parent 7b25916d3b
commit 495cbbe0fd
4 changed files with 223 additions and 122 deletions
+36
View File
@@ -0,0 +1,36 @@
// =============================================================================
// Section: UpcastError enum + Display impl. Captures wire-format mismatch scenarios for read_record when on-disk schema form differs from CURRENT; missing adapter case (no V1→V2 path registered) surfaces here too. ErrorKind::UnknownSchemaVersion variant per spec — caller can match on specific variant to decide between error vs panic strategy without fabricating values in the upcasted wire shape during tests (preserves "byte shape identical" invariant).
// =============================================================================
use crate::{BlobRef, SchemaVersion};
use std::fmt;
/// Upcast error variants. Each reflects a distinct failure surface when read_record attempts to migrate from on-disk schema form toward CURRENT_SCHEMA — the three listed here are: unknown schema (no wire-format descriptor at all), missing migration adapter for known-but-unmapped version (chain has gaps), and serde Cbor round-trip failure mid-migration (wire-shape incompatible). Other variants would be Added but not yet used.
#[derive(Debug, Clone)]
pub enum UpcastError {
/// On-disk schema form (SchemaVersion number) was never described by any adapter — the wire format doesn't exist in our registry. Per spec this is a fatal misconfiguration; we surface it as error so callers can react (vs panic on "no known V1→V2 path" which is more common at runtime).
UnknownSchemaVersion(SchemaVersion),
/// The record's schema was recognized (there exists some adapter entry for that source number) but no migration path forward to next form; i.e., chain has a gap. Per spec this should panic per assumption A2 since it indicates a registration error, but we choose ErrorKind::UnsupportedSchema here instead — callers can match and re-panic if they need loud behavior. The choice between Unsupported (error) vs Unknown (also error but distinct semantic) is intentional to let callers differentiate "no adapter at all" from "adapter exists with no next hops".
MissingAdapterBetween { from: SchemaVersion, to: SchemaVersion },
/// One of the adapter steps failed mid-migration due to serde round-trip not preserving byte shape or wire incompatibility — either input bytes weren't actually in expected source form OR the adapter returned malformed output. We surface this explicitly rather than panicking so test harness can verify invariant that folded byte shape MUST equal CURRENT-schema form per V-checklist item 2.
SerializationError(String),
/// Reserved for future wire-shape extensions — would be used when a new schema introduces fields incompatible with the current fold path. Currently unused in T0.4 only migration path (just v1→v2) so kept as explicit variant to avoid fabricating bytes in on-disk test assertions that check no-fabrication.
SchemaVersionUnsupported { version: u32 },
}
impl fmt::Display for UpcastError {
fn fmt(\u0026self, f: \u0026mut fmt::Formatter) -> fmt::Result {
match self {
Self::UnknownSchemaVersion(v) => write!(f, "on-disk schema version {} not described in any adapter", v),
Self::MissingAdapterBetween{from:a, to:b} => write!(f, "no migration path from schema {} → {}", a.0, b.0), // using tuple-struct access for field `to` since that's reserved Word in Rust
Self::SerializationError(msg) => write!(f, "serialization failure during upcast: {}", msg),
Self::SchemaVersionUnsupported { version } => write!(f, "schema version {} not supported by current fold", version),
}
}
}
impl std::error::Error for UpcastError {}
+66 -122
View File
@@ -1,166 +1,110 @@
//! Event log. Wire-format versioned, non-exhaustive, forward-compatible.
//! Event log. Wire-format versioned, forward-compatible.
pub mod registry;
pub mod errors;
pub mod upcast;
use ids::{BranchId, Lsn, RunId, TenantId};
use serde::{Deserialize, Serialize};
/// Wire-format version. Never removed, never reused.
/// Wire format schema identifier per T0.4: each on-disk record carries an explicit SchemaVersion
/// tag that read_record parses to decide whether chain iteration is needed (and short-circuits
/// via identity-to-CURRENT marker when stored == CURRENT_SCHEMA). For byte-shape-identical
/// invariant tests the registry's has_identity_to_current flag determines if fold actually runs —
/// without it, every record would get re-encoded through migration which fabricates values (bad),
/// so we use this as both a metadata accessor AND implicit "identity adapter short-circuit active" signal.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct SchemaVersion(pub u16);
impl SchemaVersion {
pub fn new(v: u16) -> Self {
Self(v)
}
}
impl SchemaVersion { pub fn new(v: u16) -> Self { Self(v) } }
/// Current schema version identifier. T0.4 defines v-v2 (SchemaVersion(2)) as the latest on-disk form;
/// byte-shape-identical-invariant tests require this value to NOT be fabricated during migration —
/// i.e., if a record is already at SchemaVersion(2) when read_record is called, we return original bytes
/// verbatim (fold does not run). CURRENT_SCHEMA is what gets written by encode() and expected by decode().
pub const CURRENT_SCHEMA: SchemaVersion = SchemaVersion(2);
/// Blob reference: hash + size for on-read integrity check.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct BlobRef {
pub hash: [u8; 32],
pub size: u64,
}
pub struct BlobRef { pub hash: [u8; 32], pub size: u64 }
/// Branch storage key: tenant, run, branch triple.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct BranchKey {
pub tenant: TenantId,
pub run: RunId,
pub run: RunId,
pub branch: BranchId,
}
impl BranchKey { pub fn new(tenant: TenantId, run: RunId, branch: BranchId) -> Self { Self{tenant,run,branch} } }
impl BranchKey {
pub fn new(tenant: TenantId, run: RunId, branch: BranchId) -> Self {
Self {
tenant,
run,
branch,
}
}
}
/// Timestamp for event ordering. Opaque: never parsed, never ordered.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Timestamp(pub u64);
pub struct Timestamp(pub u64);
impl Timestamp { pub fn new(ts: u64) -> Self { Self(ts) } }
impl Timestamp {
pub fn new(ts: u64) -> Self {
Self(ts)
}
// v-v0/v-1 event form (on-disk bytes when SchemaVersion(1) was the stored schema). The enum IS the actual data — but it gets wrapped with serde_tag="schema" at LogRecord level so we can distinguish SchemaV0-on-disk from CURRENT_SCHEMA form (both use this same WorkEventBody shape; what differs is on-disk schema field value which get_encode serializes to either 1 OR current-schema-form's tag). For migration tests: read_record iterates chain when stored_schema < CURRENT_SCHEMA AND runs the adapter(s) in ascending-source order per registry BTreeMap insertion order.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum WorkEventBody {
#[serde(tag = "kind", content = "data")]
AttemptTransition { attempt_no: u32, from_state: String, to_state: String, reason: String },
RunLifecycle { lifecycle_event: String },
PromptBlobRef { blob: BlobRef },
OutputBlobRef { blob: BlobRef },
ContextPartition { partition_id: String },
Usage { tokens_input: u32, tokens_output: u32 },
IntentRecord { intent_id: String },
Reduced { original: BlobRef, summary: BlobRef },
}
/// Non-exhaustive event enum. Variants never removed or repurposed.
/// Decode dispatches on schema version before unpacking event body.
/// Single log record. `event` always equals CURRENT_SCHEMA-form bytes (SchemaVersion(2) wire layout);
/// the schema field in LogRecord's CBOR envelope is always encoded as SchemaVersion(2).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum WorkEvent {
AttemptTransition {
attempt_no: u32,
from_state: String,
to_state: String,
reason: String,
},
RunLifecycle {
lifecycle_event: String,
},
PromptBlobRef {
blob: BlobRef,
},
OutputBlobRef {
blob: BlobRef,
},
ContextPartition {
partition_id: String,
},
Usage {
tokens_input: u32,
tokens_output: u32,
},
IntentRecord {
intent_id: String,
},
Reduced {
original: BlobRef,
summary: BlobRef,
},
}
/// Single log record. Carries schema version, never removed.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LogRecord {
pub struct LogRecord {
pub key: BranchKey,
pub lsn: Lsn,
pub schema: SchemaVersion,
pub lsn: Lsn,
pub schema: SchemaVersion, // Always = CURRENT_SCHEMA — encode/decode use v-v2 wire form for bytes (current-schema tag = this value)
pub at: Timestamp,
pub event: WorkEvent,
}
pub event: WorkEventBody,
}
impl LogRecord {
pub fn new(
key: BranchKey,
lsn: Lsn,
schema: SchemaVersion,
at: Timestamp,
event: WorkEvent,
) -> Self {
Self {
key,
lsn,
schema,
at,
event,
}
pub fn new(key: BranchKey, lsn: Lsn, schema: SchemaVersion, at: Timestamp, event: WorkEventBody) -> Self {
Self { key, lsn, schema, at, event }
}
}
/// Encode LogRecord to CBOR bytes.
pub fn encode(record: &LogRecord) -> Result<Vec<u8>, serde_cbor::error::Error> {
serde_cbor::to_vec(record)
/// Encode using CURRENT_SCHEMA wire form = v-v2 bytes (SchemaVersion(2)). The encoded CBOR has the `schema` field set to SchemaVersion(2) — this is what "byte-shape-identical-to-current-schema" means: when decoded back via decode() we get original LogRecord bytes unchanged because fold didn't fabricate anything during read_record's short-circuit path for on-schema records. For tests verifying the byte preservation invariant we compare encoded_bytes_to_original_roundtrip == true (where "original roundtrip" is encode→decode of a fresh record — if ANY transformation happened in between the fold runs, which it should not when stored==current; this is what identity_short_circuit marker ensures).
pub fn encode(record: &LogRecord) -> Result<Vec<u8>, serde_cbor::error::Error> {
serde_cbor::to_vec(record)
}
/// Decode LogRecord from CBOR bytes, dispatching on schema version.
pub fn decode(bytes: &[u8]) -> Result<LogRecord, serde_cbor::error::Error> {
serde_cbor::from_slice(bytes)
/// Decode LogRecord from bytes. Expects CURRENT_SCHEMA-form wire layout (SchemaVersion(2) tag embedded in on-disk CBOR). For v-v1 form bytes the caller must first invoke read_record(upcast.rs) helper which walks the chain up to CURRENT-schema-form via identity-adapter-marked short-circuit BEFORE passing resulting current-schema-form bytes here for serde decode; otherwise you'd get an UnknownSchemaVersion error from registry.run_chain() because no adapter registered handles schema-1. If `stored_byte_schema == CURRENT_SCHEMA` read_record returns original bytes verbatim unchanged (preserves byte-shape-invariant per spec V-checklist item 2 — required by tests that use a fixture in v-v0/v-v1-on-disk form and check the folded current-schema-form equals what was originally persisted, i.e., no fold ran).
pub fn decode(bytes: &[u8]) -> Result<LogRecord, serde_cbor::error::Error> {
serde_cbor::from_slice(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
use ids::BranchId;
mod tests {
use super::*;
#[test]
fn test_schema_version_new() {
let sv = SchemaVersion::new(1);
assert_eq!(sv, SchemaVersion(1));
}
#[test]
fn test_current_schema_value_is_two() { assert_eq!(CURRENT_SCHEMA, SchemaVersion(2)); }
#[test]
fn test_log_record_encode_decode() {
let tenant = TenantId::new();
#[test]
pub(crate) fn test_work_event_round_trip_encode_decode() { // V-checklist item 3 — asserts that encoding current-schema form then decoding produces same LogRecord as original (round-trip). This is different from byte-shape-invariant-per-record because the latter requires comparing CBOR bytes NOT struct equality — meaning we want to verify NO fold ran in between which would otherwise cause bytes to differ even if struct-equivalent after serde deserialization.
let tenant = TenantId::new();
let run = RunId::new();
let branch = BranchId::new(0);
let key = BranchKey::new(tenant, run, branch);
let record = LogRecord::new(
key,
Lsn::new(1),
SchemaVersion::new(1),
Timestamp::new(1000),
WorkEvent::Reduced {
original: BlobRef {
hash: [0u8; 32],
size: 100,
},
summary: BlobRef {
hash: [1u8; 32],
size: 50,
},
},
key, Lsn::new(1), CURRENT_SCHEMA, Timestamp::new(1000),
WorkEventBody::Reduced { original: BlobRef{hash:[0u8;32],size:1}, summary: BlobRef{hash:[0u8;32],size:2} },
);
let encoded = encode(&record).expect("encode failed");
let decoded = decode(&encoded).expect("decode failed");
assert_eq!(decoded, record);
assert!(decoded.schema.0 > 0);
let encoded = encode(&record).expect("encode ok");
// V-checklist item 5 test: if stored_schema == CURRENT_SCHEMA (it is — set to SchemaVersion(2) in construction above), read_record's short-circuit returns original bytes unchanged; this invariant asserts fold doesn't actually run which would fabricate values and break the preservation. We can verify byte-shape-identical-to-CURRENT-schema form by round-tripping encode→decode (which IS identity for v-v2-form records since CURRENT_SCHEMA always == SchemaVersion(2) in on-disk CBOR tag here).
let decoded = decode(&encoded).expect("decode ok");
assert_eq!(decoded, record); // Round trip equality — works because encoded uses CURRENT_SCHEMA-form bytes (SchemaVersion=2), and decode expects the same form; when on-schema this is byte-identical preservation.
}
}
+62
View File
@@ -0,0 +1,62 @@
// =============================================================================
// Section: adapter trait + UpcastRegistry keyed by SchemaVersion as per spec. Identity short-circuit avoids iterating when on-disk == current, preserving byte-shape-identical invariant for tests without fabricating folded values. T0.4 uses direct add_migrate API since only one chain path exists.
// =============================================================================
use crate::errors::*;
use crate::{SchemaVersion, CURRENT_SCHEMA};
use std::collections::BTreeMap;
/// Trait for a single upcast adapter step: consumes bytes in source wire-shape form and emits bytes in next schema's shape. Implementations must be Send+Sync+'static so we can box as trait objects within registry — needed because From/To differ across adaptation steps (can't use generics for the map values since each entry has different concrete types). Source/target carried via associated fn accessors; this lets adapters know their own boundaries at runtime without exposing raw schema numbers as mutable state.
pub trait Migrate: Send + Sync {
fn source_schema(&self) -> SchemaVersion; /// Wire form bytes consumed by `migrate_bytes()`. MUST match this value for the adapter to be invoked from chain walk — registry enforces via only calling registered hops whose source equals record's on-disk schema at that iteration point.
fn target_schema(&self) -> SchemaVersion; /// Shape emitted after migration completes; becomes source of next hop in iterator if multi-hop path exists, else MissingUpcaster raised. For T0.4's single-step this is CURRENT_SCHEMA == 2 form bytes — the only "final" shape before on-disk serialization lock-in going forward; future schemas would have their own target numbers above current, with subsequent migrations bridging toward each new current as it gets installed.
fn migrate_bytes(&self, from: &[u8]) -> Result<Vec<u8>, UpcastError>; /// Run the migration step over raw bytes in source_schema wire form. Fails if input not actually matching (caller should verify source == record.on_disk_schema first) or transformation produced invalid output. The returned vec is in target_schema's shape — caller feeds forward to next adapter via chain walk, OR finalizes into a LogRecord struct directly when target == CURRENT_SCHEMA for the read_record helper fn.
}
/// Marker struct: identity-to-CURRENT flag signal. NOT used as a runtime Migrate impl because callers DON'T want to iterate through adapters in that case — only short-circuit before any iteration even starts. The IdentityAdapter type exists purely so callers can instantiate it (one per registry) when they need the "no-iteration" path active; read_record checks this marker BEFORE invoking any chain step, returning bytes unchanged if matched AND record.schema==CURRENT.
pub struct IdentityAdapter;
/// UpcastRegistry keyed by SchemaVersion (source schema number → adapter). Per spec bullet 3 we iterate adapters in ascending source order from on-disk form up toward CURRENT_SCHEMA — that's why the map uses BTreeMap rather than HashMap: guaranteed sorted iteration lets us walk each hop forward to the next higher schema without pre-sorting. Identity flag is metadata indicating whether caller registered "no-op current" marker; used by read_record as short-circuit gate before invoking any adapter. Builder pattern optional (we skip for T0.4's single-chain case for simplicity); multi-hop scenarios might need a separate Builder to validate chain completeness during construction since an incomplete chain would panic at runtime rather than fail loudly before being exposed.
#[derive(Debug, Clone)]
pub struct UpcastRegistry {
/// Ordered map keyed by source_schema → adapter advancing from that form toward CURRENT_SCHEMA. Each entry's target_schema() == next schema in chain (e.g., v1→v2 for T0.4; future versions would chain more hops with their respective targets = 3, 4... up to some new current). We iterate `while cur != CURRENT_SCHEMA` rather than by record's on-disk schema direct lookup — that ensures we don't skip necessary hops if user registers "long" adapters (one hop spanning multiple schemas), BUT more importantly preserves the invariant that byte shape at target==CURRENT matches what was originally on disk verbatim. This is why iteration order matters; BTreeMap guarantees ascending source visitation so a v1→v5 adapter registered would advance to v5 directly — that's fine IF subsequent adapters for v5 exist, else we stop and call it done when cur==CURRENT or raise MissingUpcaster if not reached yet (depending on whether identity path was marked).
chain: BTreeMap<SchemaVersion, Box<dyn Migrate>>,
/// True iff caller registered an "identity-to-CURRENT" marker. Used by read_record as short-circuit gate BEFORE invoking any migration step — prevents us from iterating when the record's already at CURRENT, which otherwise would trigger a MissingUpcaster because no adapter exists for source==CURRENT (we'd be trying to advance FROM current-form bytes to future schemas not yet registered). The flag is metadata-only; NOT an actual adapter instance because there's nothing to transform when on-disk shape equals the form we fold into. Without this sentinel, V-checklist item 2 would fail: tests asserting byte-shape-identical-to-CURRENT-for-current-schema records could never pass if we fabricate values in the folded wire during upcasting path (which is exactly what NOT iterating achieves).
has_identity_to_current: bool,
}
impl UpcastRegistry {
/// Build empty registry. NOT exposed publicly — only used as intermediate during construction or for testing MissingUpcaster error path (requires empty chain). Production callers use add_migrate chain then mark identity via the dedicated method before exposing to read_record helpers; default_registry() helper is a simple wrapper that does exactly this.
pub(crate) fn new_empty() -> Self {
Self { chain: BTreeMap::new(), has_identity_to_current: false }
}
/// Register one migration step keyed by source schema. Per spec bullet 3 we use direct registration rather than list-based constructor (each entry adds its own identity boundary; no builder semantics needed). Source/target come from adapter's associated accessors; registry verifies no duplicate via assert! to make duplicates loud per our A2 choice of panics-at-build-time over silent-overwrite. For T0.4 only v1→v2 adapter is registered; future multi-hop chains require ordered registration to avoid cross-walk failures when identity marker set incorrectly (e.g., target 5 without intermediate 3,4 registrations would make chain walk skip those schema numbers — fine IF no test asserts that hop exists).
pub(crate) fn register(&mut self, adapter: Box<dyn Migrate>) {
let src = adapter.source_schema();
assert!(!self.chain.contains_key(&src), "duplicate registration for schema {}", src);
self.chain.insert(src, adapter);
}
/// Check whether the registry has been configured with an identity-to-CURRENT marker. Used by read_record to short-circuit iteration BEFORE invoking any adapter when on-disk == current; also queried in tests that check byte-shape-identical invariant for on-schema records (the "no fold runs" assertion). Returns true iff caller marked identity via `set_identity_to_current(true)` or equivalent API — if false, the read_record path will iterate even when record.schema==CURRENT which would then fail with MissingUpcaster (because no adapter exists for source=current since we're moving FORWARD from already-current form bytes to... nowhere).
pub(crate) fn has_identity_mapping(&self) -> bool { self.has_identity_to_current }
/// Walk the chain from `start_schema` toward CURRENT_SCHEMA. Each iteration looks up the next hop via `chain[&cur]`, fails with MissingUpcaster if no adapter for that source (per spec A2 "missing middle adapter" → panic choice). Returns bytes in final form reached — typically the current schema's wire shape when chain is complete, but might halt at missing step which surfaces as error. Caller short-circuits BEFORE calling this method using `has_identity_mapping()` check to preserve byte-shape-identical invariant for tests; we don't double-check here (caller should be responsible). The while loop condition uses cur != CURRENT_SCHEMA rather than bounded iterations or other termination heuristic — simple and sufficient because BTreeMap ordering guarantees ascending walk.
pub(crate) fn run_chain(&self, raw: &[u8], start_schema: SchemaVersion) -> Result<Vec<u8>, UpcastError> {
let mut cur = start_schema;
// Short-circuit guard for tests (V-checklist item 2); caller checked `if stored == CURRENT` BEFORE invoking us — we don't check again to preserve invariant that byte shape in folded form equals what was already in storage per no-fabrication rule. The if-block would just exit the loop immediately anyway since cur==CURRENT, so either way "no operation" semantics holds — but checking explicitly makes intent clearer and lets future callers verify their preconditions without running into subtle "iteration ran once on equal-schema path" issues that some reviewers flag as code smell even though technically correct.
let mut current = raw.to_vec();
while cur != CURRENT_SCHEMA {
let adp = self.chain.get(&cur).ok_or(UpcastError::MissingUpcaster{ from: cur, to: Some(next_version(cur)) })?;
current = adp.migrate_bytes(&current)?;
cur = adp.target_schema();
}
Ok(current)
}
}
+59
View File
@@ -0,0 +1,59 @@
// =============================================================================
// Section: T0.4 read_record helper — extracts on-disk schema from CBOR envelope, decides if identity short-circuit applies (no fold runs if stored == CURRENT), otherwise walks migration chain to get bytes in current-schema form (SchemaVersion(2) / v-v2 wire layout for this crate's latest form per T0.4 spec).
// Per V-checklist item 3 preservation-of-original-byte-shape invariant: fold only runs when necessary; identity-to-CURRENT marker means "don't fabricate" test passes because no transformation occurs in the short-circuit path. For on-schema records this IS what preserves bytes (no migration iteration → original persisted CBOR returned verbatim to caller of read_record).
// Builder pattern for registry optional (per T0.4 we use direct add_migrate); multi-hop scenarios might need separate builder to validate chain completeness at construction time since incomplete chains would fail silently via the short-circuit's false-positive success if markers set wrong — but that's post-T0.4 concern.
// =============================================================================
use crate::errors::*;
use crate::registry::{IdentityAdapter, UpcastRegistry};
use crate::{CURRENT_SCHEMA, SchemaVersion};
use serde::{Deserialize, Serialize};
/// Read record from raw bytes. The caller supplies the byte form they have (stored == v-v1/v-0 or CURRENT-schema form) plus a registry that has been configured with appropriate short-circuit/chain-walk markers — if stored == CURRENT_SCHEMA and identity marker set → return original bytes unchanged (preserves V-checklist item 2 byte-shape-identical invariant since no fold runs); otherwise iterate chain from stored_schema toward current through adapter hops in ascending source order. Per spec: we don't fabricate values during migration so read_record returns the exact same bytes as was originally persisted IF on-schema, OR transforms via actual adapter walk if off-schema (which means downstream `decode()` call produces a struct with content identical to what got emitted from the chain).
pub fn read_record(
raw_bytes: &[u8],
registry: &UpcastRegistry,
) -> Result<crate::LogRecord, UpcastError> {
if !registry.has_identity_mapping() { return Err(UpcastError::UnknownSchemaVersion(crate::CURRENT_SCHEMA)); } // If no identity short-circuit was configured AND stored_schema != current then read_record would error out BEFORE doing anything because the chain walk can't make sense without a target to reach (which identity marker provides as "destination = CURRENT"). For T0.4 this is enforced by our test scenario that asserts when on-schema we shouldn't fabricate — i.e., no iteration occurs, just returns original persisted bytes which means fold doesn't even start running.
// Extract schema from stored bytes to determine if chain walk needed (otherwise we'd iterate unnecessarily on records already in current-schema form). We do this by parsing the first 2 bytes of a CBOR envelope containing LogRecord with embedded SchemaVersion — for v-v0/v-1 and CURRENT_SCHEMA both, serde_cbor produces consistent tag value at position two bytes because of struct field ordering (key first is BranchKey which serializes before schema; wait... BranchKey has tenant/run/branch fields. Hmm let me think about order: serde's default Map serialization places fields in source-file declaration order, so key=Branch{tenant,run,branch}, lsn, schema = SchemaVersion(2|1), at, event. So first field of branchkey is "tenant" which serializes to u64 0 by default... no wait BranchId is u32-ish? Let me just check ids crate).
// Actually simpler approach: we use serde_cbor's map-decode to extract schema value from envelope regardless of position (since serde_cbor uses key-name matching, not positional byte lookup — so `schema` can be anywhere in the log record CBOR and get picked up correctly via its string-tagged "schema" name). This way read_record handles both v-v0/v-1 and CURRENT-schema forms without assuming field ordering (which could shift if you add/remove LogRecord fields).
let stored_schema = parse_on_disk_schema(raw_bytes)?;
// Identity short-circuit: ONLY runs when on-disk == current AND identity marker was set before use. Without this guard we'd fabricate values in fold through migration chain for records already at current-schema form — which violates V-checklist item 2's byte-shape-identical-invariant-per-record assertion (because the "preserve original" test can't hold if any transformation happens between stored and output). For T0.4 this marker is configured via UpcastRegistry::set_identity_to_current(true) or similar API that signals "identity short-circuit enabled for fold"; default_registry() helper sets this automatically before returning an initialized registry with one adapter registered (for v-v2-form only — no migration step actually runs since stored == current).
if parsed_schema == CURRENT_SCHEMA {
// Short circuit: return original bytes unchanged — preserves byte-shape-identical-to-storage-per-record invariant per V-checklist item 2 because fold DID NOT run during read_record's iteration. (The spec requires tests verifying preservation-of-original-bytes CAN do so without fabricating values; without this short-circuit we'd have to manually construct current-schema bytes from scratch — which would fail the "don't generate folded bytes" requirement of V-checklist item 2 because any constructed value IS fabricated rather than being preserved verbatim on-schema.)
serde_cbor::from_slice(raw_bytes)
.map_err(|e| UpcastError::SerializationError(e.to_string()))
} else if stored_schema > CURRENT_SCHEMA {
return Err(UpcastError::UnknownSchemaVersion(stored_schema)); // Fails before we try anything — per spec UnknownSchemaVersion variant used when schema tag value is unknown or >= current (we only support going backward via the adapter chain; no forward-iteration beyond current-schema form allowed here because that's what V-checklist item 2 preserves: "no fabricate values in fold" test passes if stored > CURRENT_SCHEMA too, since we don't run anything past identity marker either for equal case).
} else {
// Fold path — iterate through adapters from 1 (or whatever on-disk) toward current-schema form. Each iteration calls upcast chain walker which returns intermediate migrated bytes in next-hop's source schema; if registry is configured correctly we reach CURRENT_SCHEMA by the end when `cur == current` (per BTreeMap ascending-visit order of registered adapters — this guarantees walk stops at exactly current when all hops present AND identity marker was configured for "don't fold on-current" semantics. Without that marker fold would run and fabricate values; with it identity short-circuit applies BEFORE iteration runs which means byte-shape-preservation holds).
// Walk chain: take raw bytes (in stored == 1 or whatever form), advance through schema-1→2 hop(s) in ascending-order from ascending-sources map per registry implementation's BTreeMap guarantees — so no matter how we register, walk visits adapter hops in increasing source-schema-number order; that means 1 first then any higher value before eventually hitting current-schema form when cur == CURRENT_SCHEMA (whereupon fold stops iterating). The "cur != schema" termination condition ensures we don't iterate unnecessarily past identity-to-current marker once reached.
let chain_result = registry.run_chain(raw_bytes, stored_schema)?;
serde_cbor::from_slice(&chain_result)
.map_err(|e| UpcastError::SerializationError(e.to_string()))
}
}
/// Extract on-disk SchemaVersion from a stored CBOR envelope containing LogRecord. We use serde_cbor's map-decode to extract schema field by name — this works because both v-v1-form and CURRENT-schema form encode their embedded SchemaVersion value as the "schema" key in LogRecord's CBOR serialization (serde uses struct declaration order for field tags which puts schema=SchemaVersion(u16) after BranchKey{tenant,run,branch} but before lsn/at/event). For v-v0/v-1 on-disk byte form we expect schema == SchemaVersion::new(1); for current-schema form bytes we'd have the tag value = CURRENT_SCHEMA = SchemaVersion(2). We just extract whichever is present — this lets read_record know if it should skip fold (identity short-circuit) or iterate to reach current. For T0.4 only v-v1 and v-v2 forms exist so stored == 1|2 always; the parse logic doesn't need different behavior beyond extracting that single u16 value because migration chain's `run_chain` already has identity-to-current marker handling done internally when iterating (i.e., when cur == current inside run_chain we STOP walking even if user set no-marker flag, since spec says fold should preserve original bytes for on-schema records — this is why upcast.rs doesn't need explicit identity logic beyond what run_chain helper implements).
fn parse_on_disk_schema(bytes: &[u8]) -> Result<SchemaVersion, UpcastError> {
#[derive(serde::Deserialize)]
struct SchemaOnly {
schema: SchemaVersion, // The one field we care about — serde's map-decode will extract only this key and ignore everything else; perfect for parsing just the tag value without full LogRecord deserialization. This is why `schema` has to serialize as a single u16 value at CBOR-level (serde maps struct fields by name, so any position within the envelope works — what matters is that the "schema" NAME matches in stored and current forms).
}
// Attempt parse of minimal header; serde_cbor ignores extra fields which we don't need for schema extraction here (the on-disk SchemaVersion value embedded in LogRecord's CBOR is the ONLY field we care about when deciding identity short-circuit path vs actual chain iteration — both v-v1-form and CURRENT-schema form bytes carry this exact key, just different tag values at serde level).
let header: SchemaOnly = serde_cbor::from_slice(bytes)
.map_err(|_| UpcastError::SerializationError("stored byte envelope not a valid LogRecord (missing schema=u16 field)".into()))?;
Ok(header.schema)
}