bynk_emit/emitter/contracts.rs
1//! `bynk-contracts.json` generation per Worker (v0.177, #643).
2//!
3//! The contract hash a context's Worker entry compares an incoming
4//! `X-Bynk-Contract` against, per `on call` service — the same constants the
5//! entry stamps, written where the **driver** can read them.
6//!
7//! Why the driver needs them: the runtime check fails closed, but it does so in
8//! *production*, on live traffic, after the skewed pair is already deployed.
9//! `deploy` can know sooner. It records each context's hashes when it pushes,
10//! so a later `deploy --context A` can compare A's compiled view of its
11//! dependencies against what those dependencies actually have live, and refuse
12//! before the push rather than let requests 409 (ADR 0193's D4 gate already
13//! checks a dependency *exists*; this extends it to *matches*).
14//!
15//! Why a file rather than an API: the driver has two compile paths, and under a
16//! `bynkc` override the compiler is a child process handing back an exit status
17//! — there is no in-memory model to consult. A fact the compiler knows must
18//! reach the driver in the build output, or not at all (ADR 0195 D5, the same
19//! reasoning as `bynk-secrets.json`).
20
21use std::collections::BTreeMap;
22
23use bynk_project::json_string;
24
25/// The file the driver reads, beside each Worker's `wrangler.toml`.
26pub const CONTRACTS_MANIFEST: &str = "bynk-contracts.json";
27
28/// The manifest schema version. Bumped only by a breaking shape change; the
29/// driver refuses a version it does not know rather than guessing, as the
30/// deploy ledger and the secrets manifest do.
31pub(crate) const MANIFEST_VERSION: u32 = 1;
32
33/// Render a context's contract manifest: what it **provides**, and what it
34/// **expects** of each context it consumes.
35///
36/// Both halves are needed, and they are not the same fact:
37///
38/// - `provides` is this context's own constant per `on call` service — what its
39/// entry enforces. `deploy` records it when the Worker is pushed, so the
40/// ledger knows what is *live*.
41/// - `expects` is this context's compiled view of each dependency's contract —
42/// the constant it stamps at each call site. `deploy` compares it against the
43/// ledger's `provides` for that dependency, which is exactly the runtime check
44/// moved earlier.
45///
46/// A gate built on `provides` alone could not work: it would compare a context
47/// against itself.
48///
49/// Absent rather than empty when a context neither exposes nor calls an `on
50/// call` service: a project that never crosses a context boundary must not grow
51/// a file into every worker directory for a feature it does not use — and "no
52/// file" is the same answer as "empty" to a driver that must tolerate a build
53/// tree from a compiler predating this file anyway.
54///
55/// I4 (#1970): `foreign` is the third half, **what each declared foreign
56/// caller's generated client embeds** — actor → service → hash, the services
57/// that actor may call. A foreign caller has no `bynk-contracts.json` of its
58/// own to state its `expects`, so `deploy` holds this against the pin the
59/// callee's project records for it (`bynk.foreign.toml`) and refuses a build
60/// that would break it (`design/archive/retired-tracks.md`, interop track D3, Q6). Additive
61/// within version 1, and absent when empty, so every manifest of a context
62/// with no foreign caller is byte-identical to before.
63pub(crate) fn emit_contracts_manifest(
64 provides: &BTreeMap<String, String>,
65 expects: &BTreeMap<String, BTreeMap<String, String>>,
66 foreign: &BTreeMap<String, BTreeMap<String, String>>,
67) -> Option<String> {
68 if provides.is_empty() && expects.is_empty() {
69 return None;
70 }
71 // Hand-rendered rather than via serde: this crate does not depend on
72 // serde_json, and the shape is three fields.
73 let foreign_part = if foreign.is_empty() {
74 String::new()
75 } else {
76 [",\n \"foreign\": ", &nested_map(foreign)].concat()
77 };
78 Some(format!(
79 "{{\n \"version\": {MANIFEST_VERSION},\n \"provides\": {},\n \"expects\": {}{foreign_part}\n}}\n",
80 json_map(provides, 2),
81 nested_map(expects),
82 ))
83}
84
85/// A two-level map at the manifest's top level, `{}` when empty.
86fn nested_map(m: &BTreeMap<String, BTreeMap<String, String>>) -> String {
87 if m.is_empty() {
88 return "{}".to_string();
89 }
90 let body: Vec<String> = m
91 .iter()
92 .map(|(outer, inner)| [" ", &json_string(outer), ": ", &json_map(inner, 4)].concat())
93 .collect();
94 ["{\n", &body.join(",\n"), "\n }"].concat()
95}
96
97fn json_map(m: &BTreeMap<String, String>, indent: usize) -> String {
98 if m.is_empty() {
99 return "{}".to_string();
100 }
101 let pad = " ".repeat(indent + 2);
102 let close = " ".repeat(indent);
103 let entries: Vec<String> = m
104 .iter()
105 .map(|(k, v)| format!("{pad}{}: {}", json_string(k), json_string(v)))
106 .collect();
107 format!("{{\n{}\n{close}}}", entries.join(",\n"))
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 #[test]
115 fn absent_when_a_context_neither_provides_nor_consumes() {
116 assert_eq!(
117 emit_contracts_manifest(&BTreeMap::new(), &BTreeMap::new(), &BTreeMap::new()),
118 None
119 );
120 }
121
122 #[test]
123 fn renders_sorted_service_hashes() {
124 let mut h = BTreeMap::new();
125 h.insert("whoami".to_string(), "317bdd3de84d2176".to_string());
126 h.insert("ask".to_string(), "0011223344556677".to_string());
127 let out = emit_contracts_manifest(&h, &BTreeMap::new(), &BTreeMap::new()).unwrap();
128 assert!(out.contains("\"version\": 1"));
129 // BTreeMap ordering: the file is byte-stable across builds, which the
130 // golden fixtures depend on.
131 let ask = out.find("\"ask\"").unwrap();
132 let whoami = out.find("\"whoami\"").unwrap();
133 assert!(ask < whoami, "services render in sorted order:\n{out}");
134 }
135
136 #[test]
137 fn foreign_callers_are_recorded_only_when_declared() {
138 let mut provides = BTreeMap::new();
139 provides.insert("status".to_string(), "226b9939f85e69ea".to_string());
140 let none = emit_contracts_manifest(&provides, &BTreeMap::new(), &BTreeMap::new()).unwrap();
141 assert!(!none.contains("foreign"), "{none}");
142 let mut legacy = BTreeMap::new();
143 legacy.insert("status".to_string(), "226b9939f85e69ea".to_string());
144 let mut foreign = BTreeMap::new();
145 foreign.insert("Legacy".to_string(), legacy);
146 let out = emit_contracts_manifest(&provides, &BTreeMap::new(), &foreign).unwrap();
147 assert!(
148 out.contains("\"foreign\": {\n \"Legacy\": {\n \"status\": \"226b9939f85e69ea\"\n }\n }"),
149 "{out}"
150 );
151 }
152
153 #[test]
154 fn a_consumer_records_what_it_expects_of_each_dependency() {
155 let mut inner = BTreeMap::new();
156 inner.insert("whoami".to_string(), "317bdd3de84d2176".to_string());
157 let mut expects = BTreeMap::new();
158 expects.insert("app.b".to_string(), inner);
159 let out = emit_contracts_manifest(&BTreeMap::new(), &expects, &BTreeMap::new()).unwrap();
160 assert!(out.contains("\"app.b\""), "{out}");
161 assert!(out.contains("\"whoami\": \"317bdd3de84d2176\""), "{out}");
162 assert!(out.contains("\"provides\": {}"), "{out}");
163 }
164}