Skip to main content

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}