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 ®.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(®);
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(®).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(®);
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(®);
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}