Skip to main content

bynk_project/
schema_registry.rs

1//! Events track, slice 3c (#980): the cross-build schema registry's document
2//! shape and (de)serialisation.
3//!
4//! P4.0 (#1113, [DECISION A]): this module used to also hold `reconcile` —
5//! the `bynk_check`-coupled half that diffs a project's live event shapes
6//! against this document (it reads `UnitTable`, which stays in `bynk-emit`
7//! pending P4.1's `bynk-check` entry point). Only the pure, disk-free
8//! document shape and its parse/serialize moved here; `reconcile` and its
9//! helpers (`snapshot`, `canon_type`, `Reconciled`, …) stay in `bynk-emit`'s
10//! own `project/schema_registry.rs`, and reach `SchemaRegistry`'s otherwise-
11//! private `version`/`events` only through [`SchemaRegistry::new`],
12//! [`SchemaRegistry::get`], and [`SchemaRegistry::insert`] — never raw field
13//! access across the crate boundary.
14//!
15//! #1078: this module touches no disk. `parse`/`serialize` are pure —
16//! `bynk.schema.lock`'s content comes in through
17//! `CompileOptions::schema_registry`'s `SchemaLock::On { existing }` and goes
18//! out through `ProjectOutput::schema_lock`; `bynk-driver`'s `schema_lock`
19//! module owns the actual read/atomic-write. `parse` takes a
20//! `project_root: &Path` (#1085 review) purely to name the file in a
21//! corruption message — never for I/O.
22//!
23//! State-migrations S0 (#2204): the lock is `version = 2` and also records
24//! each agent class's stored state shape under `[agents]` (the retired
25//! track's summary in `design/archive/retired-tracks.md`, §3.1). A v1 lock
26//! reads as v2 with no agents; the version bump exists so an older toolchain
27//! *refuses* a v2 lock instead of parsing it, dropping the unknown `[agents]`
28//! table (there is no `deny_unknown_fields`) and writing the lock back
29//! without it.
30
31use std::collections::BTreeMap;
32use std::path::Path;
33
34use serde::{Deserialize, Serialize};
35
36pub fn lock_version() -> u32 {
37    2
38}
39
40/// The last lock version without `[agents]`. [`parse`] reads it as the
41/// current version with an empty agent table; the next build writes it back
42/// at [`lock_version`]. `check`/`test` read it and never write (ADR 0493).
43const LEGACY_EVENTS_ONLY_VERSION: u32 = 1;
44
45#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
46pub struct SchemaRegistry {
47    // No serde `default`: a registry with no `version` is not a fresh
48    // project, it is corruption (a truncated write), and must fail the read
49    // rather than silently re-baseline every event's history — the same
50    // argument `bynk.deploy.lock`'s `DeployLock` makes for the identical
51    // field.
52    version: u32,
53    #[serde(default)]
54    events: BTreeMap<String, EventEntry>,
55    /// Keyed `<unit>.<Agent>`. Skipped when empty, so a project with no
56    /// agents writes exactly the events-only document it always did, apart
57    /// from the version line.
58    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
59    agents: BTreeMap<String, AgentEntry>,
60}
61
62impl SchemaRegistry {
63    /// A fresh, empty registry at the current lock version — what
64    /// `bynk-emit`'s `reconcile` starts from and fills in per-event via
65    /// [`Self::insert`] as it walks the project's units. [DECISION A]: kept
66    /// private-fielded so `version`/`events` stay encapsulated across the
67    /// `bynk-emit` ↔ `bynk-project` boundary the way they always were
68    /// in-crate.
69    pub fn new() -> Self {
70        SchemaRegistry {
71            version: lock_version(),
72            events: BTreeMap::new(),
73            agents: BTreeMap::new(),
74        }
75    }
76
77    /// This registry's entry for `key` (`<unit>.<EventName>`), when one
78    /// exists — what `reconcile` diffs a unit's live event shape against.
79    pub fn get(&self, key: &str) -> Option<&EventEntry> {
80        self.events.get(key)
81    }
82
83    /// Record `entry` under `key`, overwriting any prior entry — what
84    /// `reconcile` calls once per event as it rebuilds the updated document.
85    pub fn insert(&mut self, key: String, entry: EventEntry) {
86        self.events.insert(key, entry);
87    }
88
89    /// This registry's entry for agent class `key` (`<unit>.<Agent>`).
90    pub fn get_agent(&self, key: &str) -> Option<&AgentEntry> {
91        self.agents.get(key)
92    }
93
94    /// Record agent class `entry` under `key`, overwriting any prior entry.
95    pub fn insert_agent(&mut self, key: String, entry: AgentEntry) {
96        self.agents.insert(key, entry);
97    }
98
99    /// Raise agent class `key`'s floor to `floor`, dropping the snapshots
100    /// below it (state-migrations S4, #2259 Decision F). The floor only rises:
101    /// a `floor` at or below the recorded one changes nothing and returns
102    /// `Ok(false)`. A `floor` above the class's current version, or a class the
103    /// registry does not record, is refused.
104    pub fn raise_floor(&mut self, key: &str, floor: i64) -> Result<bool, String> {
105        let Some(entry) = self.agents.get_mut(key) else {
106            return Err(format!("`bynk.schema.lock` records no agent `{key}`"));
107        };
108        if floor > entry.version {
109            return Err(format!(
110                "cannot raise agent `{key}`'s floor to {floor}: its current version is {}",
111                entry.version
112            ));
113        }
114        if floor <= entry.floor {
115            return Ok(false);
116        }
117        entry.floor = floor;
118        entry.shapes.retain(|s| s.version >= floor);
119        Ok(true)
120    }
121
122    /// Every recorded agent class, in key order — what the agent reconcile
123    /// walks to find classes that disappeared from source.
124    pub fn agents(&self) -> impl Iterator<Item = (&String, &AgentEntry)> {
125        self.agents.iter()
126    }
127}
128
129/// One agent class's recorded state history (track §3.1).
130///
131/// `version` is the class's current shape version: every accepted change
132/// bumps it, additive or not, so the S1 `__v` marker always names exactly one
133/// entry in `shapes`. `floor` is the oldest version any deployed instance may
134/// still hold; only a migration sweep (S4) raises it, and `shapes` keeps one
135/// snapshot per version from `floor` to `version`.
136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
137pub struct AgentEntry {
138    pub version: i64,
139    pub floor: i64,
140    pub shapes: Vec<AgentShape>,
141    /// State-migrations S3 (#2248, #2253 review): the old names this class was
142    /// renamed from (`agent New replaces Old`), once a build moved their
143    /// entries here. While the declaration stays in source, it tells an
144    /// already-applied rename from a mistyped one. Additive and omitted when
145    /// empty, so the lock format is unchanged for every class never renamed.
146    #[serde(default, skip_serializing_if = "Vec::is_empty")]
147    pub renamed_from: Vec<String>,
148}
149
150impl AgentEntry {
151    /// The entry's own invariants: `1 <= floor <= version`, and exactly one
152    /// snapshot for every version from `floor` to `version`, in order. A
153    /// hand-edit or a bad merge of two branches that each bumped the class can
154    /// break them; reconciling against such an entry would compare with the
155    /// wrong shape (#2204 review), so [`parse`] refuses it as corruption.
156    fn validate(&self) -> Result<(), String> {
157        if self.floor < 1 || self.floor > self.version {
158            return Err(format!(
159                "has floor {} outside 1..={}",
160                self.floor, self.version
161            ));
162        }
163        let versions: Vec<i64> = self.shapes.iter().map(|s| s.version).collect();
164        let expected: Vec<i64> = (self.floor..=self.version).collect();
165        if versions != expected {
166            return Err(format!(
167                "records shapes for versions {versions:?}, not one per version {}..={}",
168                self.floor, self.version
169            ));
170        }
171        Ok(())
172    }
173
174    /// The oldest stored version a build at this entry's `version` can read
175    /// forward without a migration step: the smallest `m >= floor` such that
176    /// every snapshot after `m`, up to `version`, was an additive change
177    /// (state-migrations S1, #2211 Decision B). A record at `m` or later loads
178    /// by zero-merge (ADR 0124 D4); one below it has no path.
179    pub fn oldest_readable(&self) -> i64 {
180        self.oldest_readable_to(self.version)
181    }
182
183    /// [`Self::oldest_readable`] measured against `target` instead of the
184    /// current version: the oldest version whose path to `target` is entirely
185    /// additive. A `migrate from target` step covers every version from there
186    /// up to `target` (state-migrations S2, #2224 Decision C).
187    pub fn oldest_readable_to(&self, target: i64) -> i64 {
188        let mut m = target;
189        while m > self.floor && self.shapes.iter().any(|s| s.version == m && s.additive) {
190            m -= 1;
191        }
192        m
193    }
194
195    /// The snapshot at the entry's current `version`.
196    pub fn current(&self) -> Option<&AgentShape> {
197        self.shape(self.version)
198    }
199
200    /// The snapshot recorded at `version`, if the entry still holds it.
201    pub fn shape(&self, version: i64) -> Option<&AgentShape> {
202        self.shapes.iter().find(|s| s.version == version)
203    }
204}
205
206/// The stored shape of one agent class at one version.
207#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
208pub struct AgentShape {
209    pub version: i64,
210    /// Whether the change *into* this version was additive (a baseline is
211    /// not: nothing preceded it).
212    pub additive: bool,
213    pub fields: Vec<StoreShape>,
214}
215
216/// One `store` field's stored shape. `ty` is the structural rendering of the
217/// field's type with every refinement predicate stripped; `refinement` is
218/// the same walk with predicates kept (an opaque type's included). A change
219/// to `ty` is non-additive; a change to `refinement` alone is not.
220#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
221pub struct StoreShape {
222    pub name: String,
223    pub kind: String,
224    #[serde(rename = "type")]
225    pub ty: String,
226    pub refinement: String,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
230pub struct EventEntry {
231    pub schema: i64,
232    pub fields: Vec<FieldShape>,
233}
234
235/// A shallow, per-field snapshot of an event's current shape — deliberately
236/// **not** `bynk-check/src/contract.rs`'s `canon_named_in`: that renders a
237/// field as `name: type` with no signal for default-presence, so an additive
238/// change (new field, has a default) and a breaking one (new field, no
239/// default) perturb it identically. This snapshot exists to tell those two
240/// apart, one field at a time.
241#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
242pub struct FieldShape {
243    pub name: String,
244    #[serde(rename = "type")]
245    pub ty: String,
246    pub default: bool,
247}
248
249/// Parse `bynk.schema.lock`'s content. `existing: None` means the project
250/// has no lock file yet — a fresh project's first reconciliation, baselined
251/// rather than compared, exactly as a missing file always meant back when
252/// this function read the file itself. It must mean *verified absent*, never
253/// "content unavailable" for some other reason — see `SchemaLock::On`'s doc
254/// comment, which states that invariant on the type the caller constructs.
255///
256/// `Some(text)` that is empty/unparseable/a different lock version is
257/// corruption, not a fresh project, and fails hard (`ledger.rs`'s own
258/// argument for the identical case) — never silently re-baselines.
259///
260/// `project_root` is used only to name the file in an error message — #1078
261/// made this function disk-free (and so path-blind) for the content itself;
262/// #1085 review restored the location context a corruption diagnostic lost
263/// as a result, without reintroducing any `fs`/`Path` I/O here.
264pub fn parse(existing: Option<&str>, project_root: &Path) -> Result<SchemaRegistry, String> {
265    let Some(text) = existing else {
266        return Ok(SchemaRegistry::new());
267    };
268    let path = project_root.join("bynk.schema.lock");
269    if text.trim().is_empty() {
270        return Err(format!(
271            "schema registry `{}` is empty or truncated (corrupt); refusing \
272             to treat it as a fresh project — restore it from version control",
273            path.display()
274        ));
275    }
276    let reg: SchemaRegistry = toml::from_str(text).map_err(|e| {
277        format!(
278            "schema registry `{}` is corrupt ({e}) — restore it from version control",
279            path.display()
280        )
281    })?;
282    let mut reg = reg;
283    if reg.version == LEGACY_EVENTS_ONLY_VERSION && reg.agents.is_empty() {
284        reg.version = lock_version();
285    }
286    if reg.version != lock_version() {
287        return Err(format!(
288            "unsupported schema registry version {} (`{}`)",
289            reg.version,
290            path.display()
291        ));
292    }
293    for (key, entry) in &reg.agents {
294        if let Err(why) = entry.validate() {
295            return Err(format!(
296                "schema registry `{}` is corrupt: agent `{key}` {why} — restore it from \
297                 version control",
298                path.display()
299            ));
300        }
301    }
302    Ok(reg)
303}
304
305/// Serialize a reconciled registry to `bynk.schema.lock`'s TOML form —
306/// what `bynk-driver` writes to disk, atomically, only on a fully clean
307/// build (`compile_project`'s own gate). No disk access here; see this
308/// module's doc comment for why. Cannot fail for this data shape (string
309/// keys, no floats) — `toml::to_string_pretty` only errors on inputs this
310/// struct never produces.
311pub fn serialize(reg: &SchemaRegistry) -> String {
312    toml::to_string_pretty(reg).expect("SchemaRegistry always serializes")
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    fn shape(name: &str, ty: &str, default: bool) -> FieldShape {
320        FieldShape {
321            name: name.to_string(),
322            ty: ty.to_string(),
323            default,
324        }
325    }
326
327    fn test_root() -> &'static Path {
328        Path::new("/project")
329    }
330
331    #[test]
332    fn new_registry_is_empty_at_the_current_lock_version() {
333        let reg = SchemaRegistry::new();
334        assert_eq!(reg.version, lock_version());
335        assert!(reg.events.is_empty());
336    }
337
338    #[test]
339    fn insert_then_get_round_trips_an_entry() {
340        let mut reg = SchemaRegistry::new();
341        let entry = EventEntry {
342            schema: 2,
343            fields: vec![shape("orderId", "String", false)],
344        };
345        reg.insert("commerce.order.PaymentConfirmed".to_string(), entry.clone());
346        assert_eq!(reg.get("commerce.order.PaymentConfirmed"), Some(&entry));
347        assert_eq!(reg.get("no.such.key"), None);
348    }
349
350    #[test]
351    fn parse_of_absent_content_is_an_empty_registry() {
352        let reg = parse(None, test_root()).unwrap();
353        assert!(reg.events.is_empty());
354    }
355
356    #[test]
357    fn parse_round_trips_serialized_content() {
358        let mut reg = SchemaRegistry::new();
359        reg.insert(
360            "commerce.order.PaymentConfirmed".to_string(),
361            EventEntry {
362                schema: 2,
363                fields: vec![shape("orderId", "String", false)],
364            },
365        );
366        let body = serialize(&reg);
367        assert_eq!(parse(Some(&body), test_root()).unwrap(), reg);
368    }
369
370    #[test]
371    fn a_truncated_file_is_corruption_not_a_fresh_project() {
372        let err = parse(Some("   \n"), test_root()).unwrap_err();
373        assert!(err.contains("restore it from version control"));
374        // Not a full path match (`\` vs `/` makes that Windows-fragile) —
375        // just proving the filename made it into the message at all.
376        assert!(err.contains("bynk.schema.lock"));
377    }
378
379    #[test]
380    fn an_unparseable_file_is_corruption() {
381        let err = parse(Some("not valid toml {{{"), test_root()).unwrap_err();
382        assert!(err.contains("corrupt"));
383        assert!(err.contains("bynk.schema.lock"));
384    }
385
386    #[test]
387    fn a_v1_lock_reads_as_the_current_version_with_no_agents() {
388        let v1 = "version = 1\n\n[events]\n";
389        let reg = parse(Some(v1), test_root()).unwrap();
390        assert_eq!(reg.version, lock_version());
391        assert!(reg.agents.is_empty());
392        assert!(serialize(&reg).starts_with("version = 2"));
393    }
394
395    #[test]
396    fn a_v1_lock_that_somehow_carries_agents_is_refused() {
397        let text = "version = 1\n\n[agents.\"u.A\"]\nversion = 1\nfloor = 1\nshapes = []\n";
398        let err = parse(Some(text), test_root()).unwrap_err();
399        assert!(err.contains("unsupported schema registry version 1"));
400    }
401
402    #[test]
403    fn agent_entries_round_trip() {
404        let mut reg = SchemaRegistry::new();
405        reg.insert_agent(
406            "shop.Cart".to_string(),
407            AgentEntry {
408                version: 2,
409                floor: 1,
410                shapes: vec![
411                    AgentShape {
412                        version: 1,
413                        additive: false,
414                        fields: vec![StoreShape {
415                            name: "owner".to_string(),
416                            kind: "Cell".to_string(),
417                            ty: "String".to_string(),
418                            refinement: "String".to_string(),
419                        }],
420                    },
421                    AgentShape {
422                        version: 2,
423                        additive: true,
424                        fields: vec![],
425                    },
426                ],
427                renamed_from: Vec::new(),
428            },
429        );
430        let body = serialize(&reg);
431        assert_eq!(parse(Some(&body), test_root()).unwrap(), reg);
432        assert_eq!(
433            reg.get_agent("shop.Cart")
434                .unwrap()
435                .current()
436                .unwrap()
437                .version,
438            2
439        );
440    }
441
442    #[test]
443    fn an_agent_entry_with_no_snapshot_for_its_version_is_corruption() {
444        let text = "version = 2\n\n[agents.\"u.A\"]\nversion = 2\nfloor = 1\n\n\
445            [[agents.\"u.A\".shapes]]\nversion = 1\nadditive = false\nfields = []\n";
446        let err = parse(Some(text), test_root()).unwrap_err();
447        assert!(err.contains("corrupt"), "{err}");
448        assert!(err.contains("agent `u.A`"), "{err}");
449    }
450
451    #[test]
452    fn an_agent_entry_with_its_floor_above_its_version_is_corruption() {
453        let text = "version = 2\n\n[agents.\"u.A\"]\nversion = 1\nfloor = 2\nshapes = []\n";
454        let err = parse(Some(text), test_root()).unwrap_err();
455        assert!(err.contains("floor 2"), "{err}");
456    }
457
458    #[test]
459    fn oldest_readable_walks_back_over_additive_snapshots_only() {
460        let shape = |version, additive| AgentShape {
461            version,
462            additive,
463            fields: vec![],
464        };
465        let entry = |version, floor, shapes| AgentEntry {
466            version,
467            floor,
468            shapes,
469            renamed_from: Vec::new(),
470        };
471        // Baseline only.
472        assert_eq!(entry(1, 1, vec![shape(1, false)]).oldest_readable(), 1);
473        // 1 -> 2 -> 3, all additive: everything back to the floor reads.
474        let all = entry(3, 1, vec![shape(1, false), shape(2, true), shape(3, true)]);
475        assert_eq!(all.oldest_readable(), 1);
476        // 2 -> 3 non-additive (an S2 step): only 3 reads without one.
477        let broken = entry(3, 1, vec![shape(1, false), shape(2, true), shape(3, false)]);
478        assert_eq!(broken.oldest_readable(), 3);
479        // 1 -> 2 non-additive, 2 -> 3 additive: 2 and 3 read.
480        let mid = entry(3, 1, vec![shape(1, false), shape(2, false), shape(3, true)]);
481        assert_eq!(mid.oldest_readable(), 2);
482        // A raised floor bounds the walk.
483        let floored = entry(3, 2, vec![shape(2, true), shape(3, true)]);
484        assert_eq!(floored.oldest_readable(), 2);
485    }
486
487    #[test]
488    fn raising_the_floor_drops_older_snapshots_and_stays_valid() {
489        let snap = |version| AgentShape {
490            version,
491            additive: false,
492            fields: Vec::new(),
493        };
494        let mut reg = SchemaRegistry::new();
495        reg.insert_agent(
496            "u.A".to_string(),
497            AgentEntry {
498                version: 3,
499                floor: 1,
500                shapes: vec![snap(1), snap(2), snap(3)],
501                renamed_from: Vec::new(),
502            },
503        );
504        assert_eq!(reg.raise_floor("u.A", 3), Ok(true));
505        let entry = reg.get_agent("u.A").unwrap();
506        assert_eq!(entry.floor, 3);
507        assert_eq!(entry.shapes, vec![snap(3)]);
508        // The edited lock reads back as valid.
509        let text = serialize(&reg);
510        assert_eq!(parse(Some(&text), test_root()).unwrap(), reg);
511        // The floor never falls, and never passes the current version.
512        assert_eq!(reg.raise_floor("u.A", 2), Ok(false));
513        assert!(
514            reg.raise_floor("u.A", 4)
515                .unwrap_err()
516                .contains("current version is 3")
517        );
518        assert!(reg.raise_floor("u.B", 1).is_err());
519    }
520
521    #[test]
522    fn a_registry_with_no_agents_writes_no_agents_table() {
523        assert!(!serialize(&SchemaRegistry::new()).contains("agents"));
524    }
525
526    #[test]
527    fn an_unsupported_lock_version_is_corruption() {
528        let err = parse(Some("version = 99\n"), test_root()).unwrap_err();
529        assert!(err.contains("unsupported schema registry version 99"));
530    }
531}