hecks 3.0.3 → 3.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +12 -2
  3. data/lib/hecks/bluebook/assembly/contracts.rb +3 -1
  4. data/lib/hecks/bluebook/assembly/marks.rb +3 -0
  5. data/lib/hecks/bluebook/command.rb +7 -5
  6. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +1 -0
  7. data/lib/hecks/bluebook/dsl/command_builder.rb +42 -0
  8. data/lib/hecks/bluebook/expression/canonical_form.rb +12 -0
  9. data/lib/hecks/bluebook/expression/projection.json +21 -0
  10. data/lib/hecks/bluebook/meta_validator/shapes.rb +3 -0
  11. data/lib/hecks/cli/console.rb +24 -8
  12. data/lib/hecks/cli/project_cli.rb +42 -9
  13. data/lib/hecks/cli/seed_semantics_corpus.rb +35 -6
  14. data/lib/hecks/deploy/bluebook/deploy.bluebook +34 -0
  15. data/lib/hecks/doors/cli_runner.rb +57 -12
  16. data/lib/hecks/doors/launcher_options.rb +41 -0
  17. data/lib/hecks/fuzzing/properties.rb +1 -1
  18. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +11 -3
  19. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +9 -7
  20. data/lib/hecks/gate/stages.yml +55 -0
  21. data/lib/hecks/grammar/expression.bluebook +6 -1
  22. data/lib/hecks/grammar/expression_operators.json +135 -0
  23. data/lib/hecks/hecks/adapters/codebase/gate.rb +37 -0
  24. data/lib/hecks/hecks/adapters/codebase/regeneration.rb +24 -4
  25. data/lib/hecks/hecks/adapters/codebase/source_tree.rb +3 -1
  26. data/lib/hecks/hecks/codebase.behaviors +12 -0
  27. data/lib/hecks/hecks/codebase.bluebook +194 -0
  28. data/lib/hecks/hecks/custodian.bluebook +2 -0
  29. data/lib/hecks/hecks/hecks.hecksagon +14 -0
  30. data/lib/hecks/hecks/hecks.world +5 -1
  31. data/lib/hecks/language/bluebook/command.bluebook +26 -0
  32. data/lib/hecks/language/bluebook/vocabulary.bluebook +29 -1
  33. data/lib/hecks/language/oidc.json +5 -0
  34. data/lib/hecks/ports/clock.rb +3 -1
  35. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +2 -1
  36. data/lib/hecks/projections/deploy/lambda.rb +3 -4
  37. data/lib/hecks/projector/cli_projector.rb +63 -4
  38. data/lib/hecks/quality_control/quality_control.bluebook +6 -0
  39. data/lib/hecks/runtime/command_interpreter.rb +8 -4
  40. data/lib/hecks/runtime/entity_interpreter.rb +5 -2
  41. data/lib/hecks/runtime/interpreting.rb +50 -0
  42. data/lib/hecks/runtime/invocation.rb +7 -1
  43. data/lib/hecks/runtime/loader.rb +16 -13
  44. data/lib/hecks/runtime.rb +11 -0
  45. data/lib/hecks/three_zero/forms.yml +1 -1
  46. data/lib/hecks/tools/ci_gate_decision.rb +109 -0
  47. data/lib/hecks/tools/ci_gates.rb +124 -0
  48. data/lib/hecks/tools/gate.rb +117 -0
  49. data/lib/hecks/tools.rb +3 -0
  50. data/lib/hecks/version.rb +1 -1
  51. data/lib/hecks/vocabulary.rb +6 -1
  52. data/lib/hecks.rb +12 -2
  53. data/rust/codegen/src/naming.rs +13 -2
  54. data/rust/host/HECKS_RELEASE +1 -1
  55. data/rust/host/src/dispatch.rs +207 -1
  56. data/rust/host/src/main.rs +1 -0
  57. data/rust/host/src/needs.rs +243 -0
  58. data/rust/host/src/resend.rs +52 -10
  59. data/rust/host/src/server.rs +7 -3
  60. data/rust/host/src/web/newsletter/template.rs +69 -0
  61. data/rust/host/src/web/newsletter/tests.rs +43 -0
  62. data/rust/host/src/web/newsletter.rs +6 -1
  63. data/rust/host/src/web/newsletter_send.rs +1 -1
  64. data/rust/parser/src/canonical.rs +137 -2
  65. data/rust/parser/src/emit.rs +25 -19
  66. data/rust/parser/src/ir.rs +2 -0
  67. data/rust/parser/src/keywords.rs +2 -0
  68. data/rust/parser/src/main.rs +1 -0
  69. data/rust/parser/src/parse/command.rs +44 -0
  70. data/rust/project/naming.rb +4 -2
  71. data/rust/src/kernel/cli.rs +3 -0
  72. data/rust/src/kernel/mod.rs +1 -0
  73. data/rust/src/kernel/needs.rs +194 -0
  74. data/rust/src/kernel/orchestrate.rs +84 -0
  75. data/rust/src/kernel/repository.rs +43 -2
  76. metadata +10 -2
@@ -14,6 +14,61 @@ pub struct Outcome {
14
14
  pub accepted: bool,
15
15
  }
16
16
 
17
+ /// Who a command is dispatched for: the role they state and, when the host knows who they are, the
18
+ /// actor Governance holds assignments for. Both absent is an unidentified caller.
19
+ #[derive(Clone, Copy, Debug, Default, PartialEq)]
20
+ pub struct Caller<'a> {
21
+ pub role: Option<&'a str>,
22
+ pub actor_id: Option<&'a str>,
23
+ }
24
+
25
+ /// The role an unidentified caller is dispatched under when roles are enforced: no command declares
26
+ /// it, so a command that declares any role refuses it.
27
+ pub const ANONYMOUS_ROLE: &str = "Anonymous";
28
+
29
+ /// What the host does about the role a command declares (`HECKS_ROLE_ENFORCEMENT`).
30
+ #[derive(Clone, Copy, Debug, PartialEq)]
31
+ pub enum Enforcement {
32
+ /// A caller with no role is unchecked, as before.
33
+ Off,
34
+ /// An unidentified or unassigned caller is let through and logged as `would_refuse_role`.
35
+ Shadow,
36
+ /// An unidentified or unassigned caller is refused.
37
+ Enforce,
38
+ }
39
+
40
+ impl Enforcement {
41
+ /// Reads `off` (the default), `shadow` or `enforce`; anything else is `Off`.
42
+ pub fn parse(value: Option<&str>) -> Self {
43
+ match value.map(|v| v.trim().to_ascii_lowercase()).as_deref() {
44
+ Some("shadow") => Self::Shadow,
45
+ Some("enforce") => Self::Enforce,
46
+ _ => Self::Off,
47
+ }
48
+ }
49
+
50
+ pub fn from_env() -> Self {
51
+ static MODE: std::sync::OnceLock<Enforcement> = std::sync::OnceLock::new();
52
+ *MODE.get_or_init(|| Self::parse(std::env::var("HECKS_ROLE_ENFORCEMENT").ok().as_deref()))
53
+ }
54
+
55
+ /// The caller the kernel is asked to check: an unidentified caller becomes the anonymous role
56
+ /// unless enforcement is off.
57
+ pub fn effective<'a>(self, caller: Caller<'a>) -> Caller<'a> {
58
+ if self != Self::Off && caller.role.is_none() && caller.actor_id.is_none() {
59
+ return Caller { role: Some(ANONYMOUS_ROLE), actor_id: None };
60
+ }
61
+ caller
62
+ }
63
+ }
64
+
65
+ /// Whether the kernel refused the command for the caller's role.
66
+ fn refused_for_role(result: &serde_json::Value) -> bool {
67
+ result.get("refusals").and_then(|r| r.as_array()).is_some_and(|all| {
68
+ all.iter().any(|r| r.get("kind").and_then(|k| k.as_str()) == Some("Unauthorized"))
69
+ })
70
+ }
71
+
17
72
  // Distinguishes a module refusal from a kernel failure to run at all.
18
73
  #[derive(Debug, PartialEq)]
19
74
  enum KernelAnswer {
@@ -80,6 +135,21 @@ pub async fn handle_routed(
80
135
  handle(client, wasm_path, verb, routed_invocation(to, facts)?, role, config, invoker).await
81
136
  }
82
137
 
138
+ /// `handle_routed` for a caller the host can name.
139
+ #[allow(clippy::too_many_arguments)]
140
+ pub async fn handle_routed_as(
141
+ client: &Mutex<Client>,
142
+ wasm_path: &Path,
143
+ verb: &str,
144
+ to: serde_json::Value,
145
+ facts: serde_json::Value,
146
+ caller: Caller<'_>,
147
+ config: &LineageConfig,
148
+ invoker: &dyn LambdaInvoker,
149
+ ) -> anyhow::Result<Outcome> {
150
+ handle_as(client, wasm_path, verb, routed_invocation(to, facts)?, caller, config, invoker).await
151
+ }
152
+
83
153
  // Facts-only counterpart to `handle_routed`.
84
154
  #[allow(clippy::too_many_arguments)]
85
155
  pub async fn handle_facts(
@@ -94,6 +164,20 @@ pub async fn handle_facts(
94
164
  handle(client, wasm_path, verb, facts_invocation(facts)?, role, config, invoker).await
95
165
  }
96
166
 
167
+ /// `handle_facts` for a caller the host can name.
168
+ #[allow(clippy::too_many_arguments)]
169
+ pub async fn handle_facts_as(
170
+ client: &Mutex<Client>,
171
+ wasm_path: &Path,
172
+ verb: &str,
173
+ facts: serde_json::Value,
174
+ caller: Caller<'_>,
175
+ config: &LineageConfig,
176
+ invoker: &dyn LambdaInvoker,
177
+ ) -> anyhow::Result<Outcome> {
178
+ handle_as(client, wasm_path, verb, facts_invocation(facts)?, caller, config, invoker).await
179
+ }
180
+
97
181
  // A Mutex, not a bare Arc<Client>: `handle` needs `Client::transaction`,
98
182
  // which takes `&mut Client`. Locking also means concurrent callers on
99
183
  // this connection can't interleave statements.
@@ -114,6 +198,57 @@ pub async fn handle(
114
198
  config: &LineageConfig,
115
199
  invoker: &dyn LambdaInvoker,
116
200
  ) -> anyhow::Result<Outcome> {
201
+ handle_as(client, wasm_path, verb, args, Caller { role, actor_id: None }, config, invoker).await
202
+ }
203
+
204
+ /// Dispatches for a caller, applying `HECKS_ROLE_ENFORCEMENT`: unidentified callers are refused (or,
205
+ /// in shadow, logged as would-be refusals and let through).
206
+ pub async fn handle_as(
207
+ client: &Mutex<Client>,
208
+ wasm_path: &Path,
209
+ verb: &str,
210
+ args: serde_json::Value,
211
+ caller: Caller<'_>,
212
+ config: &LineageConfig,
213
+ invoker: &dyn LambdaInvoker,
214
+ ) -> anyhow::Result<Outcome> {
215
+ handle_with(Enforcement::from_env(), client, wasm_path, verb, args, caller, config, invoker).await
216
+ }
217
+
218
+ #[allow(clippy::too_many_arguments)]
219
+ async fn handle_with(
220
+ mode: Enforcement,
221
+ client: &Mutex<Client>,
222
+ wasm_path: &Path,
223
+ verb: &str,
224
+ args: serde_json::Value,
225
+ caller: Caller<'_>,
226
+ config: &LineageConfig,
227
+ invoker: &dyn LambdaInvoker,
228
+ ) -> anyhow::Result<Outcome> {
229
+ let checked = mode.effective(caller);
230
+ let outcome = run(client, wasm_path, verb, args.clone(), checked, config, invoker).await?;
231
+ if mode == Enforcement::Shadow && !outcome.accepted && refused_for_role(&outcome.result) {
232
+ crate::log::error("would_refuse_role", serde_json::json!({
233
+ "verb": verb, "role": checked.role, "actor_id": checked.actor_id,
234
+ "refusals": outcome.result.get("refusals"),
235
+ }));
236
+ return run(client, wasm_path, verb, args, Caller::default(), config, invoker).await;
237
+ }
238
+ Ok(outcome)
239
+ }
240
+
241
+ #[allow(clippy::too_many_arguments)]
242
+ async fn run(
243
+ client: &Mutex<Client>,
244
+ wasm_path: &Path,
245
+ verb: &str,
246
+ args: serde_json::Value,
247
+ caller: Caller<'_>,
248
+ config: &LineageConfig,
249
+ invoker: &dyn LambdaInvoker,
250
+ ) -> anyhow::Result<Outcome> {
251
+ let role = caller.role;
117
252
  let mut guard = client.lock().await;
118
253
  let txn = guard.transaction().await?;
119
254
 
@@ -147,10 +282,19 @@ pub async fn handle(
147
282
  // carries whatever role it was dispatched with. Built as a mutable
148
283
  // step, not inlined into the `json!` literal, so a roleless call
149
284
  // still omits the `"role"` key entirely rather than sending null.
285
+ // A fact the command `needs` is answered here, before the kernel's gates read the arguments and
286
+ // before the step is journaled, so a replay re-dispatches the recorded answer (ADR 0081).
287
+ let mut args = args;
288
+ if let Some(domain_ir) = crate::ir::ir() {
289
+ crate::needs::fill_needs(domain_ir, verb, &mut args, &crate::needs::ProcessClock);
290
+ }
150
291
  let mut step = serde_json::json!({ "verb": verb, "args": args.clone() });
151
292
  if let Some(role) = role {
152
293
  step["role"] = serde_json::Value::String(role.to_string());
153
294
  }
295
+ if let Some(actor_id) = caller.actor_id {
296
+ step["actor_id"] = serde_json::Value::String(actor_id.to_string());
297
+ }
154
298
  // Stamped only on this live step; replayed history already carries
155
299
  // its own real timestamp, and re-stamping today's time onto a
156
300
  // replay would be wrong, not just redundant.
@@ -179,7 +323,15 @@ pub async fn handle(
179
323
  }))
180
324
  .collect::<Vec<_>>());
181
325
 
182
- let input = serde_json::json!({ "seed": seed, "steps": steps, "sagas": sagas_seed }).to_string();
326
+ let mut input = serde_json::json!({ "seed": seed, "steps": steps, "sagas": sagas_seed });
327
+ // Lets the kernel answer a reaction's command that needs a fact the same way (ADR 0081).
328
+ if let Some(domain_ir) = crate::ir::ir() {
329
+ let table = crate::needs::table(domain_ir);
330
+ if table.as_object().is_some_and(|t| !t.is_empty()) {
331
+ input["needs"] = table;
332
+ }
333
+ }
334
+ let input = input.to_string();
183
335
  // `wasm_runner::run` is sync, and wasmtime-wasi's sync bridge spins up
184
336
  // its own tokio runtime internally — fatal on a thread already
185
337
  // driving one. `spawn_blocking` moves it off this async runtime.
@@ -1169,6 +1321,60 @@ pub(crate) mod tests {
1169
1321
  assert_eq!(steps.len(), 1, "only the correctly-authorized registration should be persisted");
1170
1322
  }
1171
1323
 
1324
+ #[test]
1325
+ fn enforcement_reads_off_shadow_and_enforce_and_treats_anything_else_as_off() {
1326
+ assert_eq!(Enforcement::parse(None), Enforcement::Off);
1327
+ assert_eq!(Enforcement::parse(Some("")), Enforcement::Off);
1328
+ assert_eq!(Enforcement::parse(Some("nonsense")), Enforcement::Off);
1329
+ assert_eq!(Enforcement::parse(Some("Shadow")), Enforcement::Shadow);
1330
+ assert_eq!(Enforcement::parse(Some(" enforce ")), Enforcement::Enforce);
1331
+ }
1332
+
1333
+ #[test]
1334
+ fn only_an_unidentified_caller_becomes_anonymous_and_only_when_roles_are_checked() {
1335
+ let nobody = Caller::default();
1336
+ assert_eq!(Enforcement::Off.effective(nobody), nobody);
1337
+ for mode in [Enforcement::Shadow, Enforcement::Enforce] {
1338
+ assert_eq!(mode.effective(nobody).role, Some(ANONYMOUS_ROLE));
1339
+ let stated = Caller { role: Some("Teller"), actor_id: None };
1340
+ assert_eq!(mode.effective(stated), stated, "a stated role is kept");
1341
+ let identified = Caller { role: None, actor_id: Some("u1") };
1342
+ assert_eq!(mode.effective(identified), identified, "an identified caller is kept");
1343
+ }
1344
+ }
1345
+
1346
+ // The unidentified caller is the case the host never refused: no role at all.
1347
+ #[tokio::test]
1348
+ async fn an_unidentified_caller_is_refused_when_enforced_and_let_through_when_off_or_shadowed() {
1349
+ let client = scratch_db("rust_host_dispatch_test_enforce").await;
1350
+ provision_lineage(&*client.lock().await, "Banking", 1, &["Customer"]).await;
1351
+ let config = test_config("Banking", 1);
1352
+ let go = |mode: Enforcement, id: &'static str| {
1353
+ let client = &client;
1354
+ let config = &config;
1355
+ async move {
1356
+ handle_with(mode, client, &wasm_path(), "Banking::Customer.Register", register(id), Caller::default(), config, &lambda_client::NeverInvoker)
1357
+ .await
1358
+ .unwrap()
1359
+ }
1360
+ };
1361
+
1362
+ let off = go(Enforcement::Off, "CUST-0021").await;
1363
+ assert!(off.accepted, "off keeps the old behaviour: {:?}", off.result);
1364
+
1365
+ let enforced = go(Enforcement::Enforce, "CUST-0022").await;
1366
+ assert!(!enforced.accepted, "an unidentified caller must be refused: {:?}", enforced.result);
1367
+ assert!(refused_for_role(&enforced.result), "{:?}", enforced.result);
1368
+ let error = enforced.result["refusals"][0]["error"].as_str().unwrap();
1369
+ assert!(error.contains("Branch clerk") && error.contains(ANONYMOUS_ROLE), "{error}");
1370
+
1371
+ let shadowed = go(Enforcement::Shadow, "CUST-0023").await;
1372
+ assert!(shadowed.accepted, "shadow lets the command through: {:?}", shadowed.result);
1373
+
1374
+ let steps = journal::load_steps(&*client.lock().await).await.unwrap();
1375
+ assert_eq!(steps.len(), 2, "the enforced refusal was not persisted; off and shadow were");
1376
+ }
1377
+
1172
1378
  #[tokio::test]
1173
1379
  async fn a_mid_transaction_failure_rolls_back_any_saga_state_already_written() {
1174
1380
  // Deliberately doesn't provision "Transfer": its mutation fails
@@ -13,6 +13,7 @@ mod journal;
13
13
  mod lambda_client;
14
14
  mod log;
15
15
  mod mint;
16
+ mod needs;
16
17
  mod payments;
17
18
  mod presentation;
18
19
  mod presentation_write;
@@ -0,0 +1,243 @@
1
+ // Facts a command `needs` (ADR 0081): the host answers them before the kernel reads the arguments,
2
+ // the way the Ruby interpreter does in `decode_arguments`. The command's own IR carries
3
+ // `"needs": [{"fact": "now"}]`; a needed fact the caller left out is filled with the clock's
4
+ // answer, a supplied one is kept. The answer is written into the arguments before they are
5
+ // journaled, so a replay re-dispatches the recorded value rather than asking the clock again.
6
+
7
+ use serde_json::{json, Value};
8
+ use std::sync::atomic::{AtomicI64, Ordering};
9
+
10
+ /// What answers the `now` fact: epoch seconds, UTC.
11
+ pub trait Clock {
12
+ fn now_secs(&self) -> i64;
13
+ }
14
+
15
+ /// The wall clock.
16
+ pub struct SystemClock;
17
+
18
+ impl Clock for SystemClock {
19
+ fn now_secs(&self) -> i64 {
20
+ std::time::SystemTime::now()
21
+ .duration_since(std::time::UNIX_EPOCH)
22
+ .map(|d| d.as_secs() as i64)
23
+ .unwrap_or_default()
24
+ }
25
+ }
26
+
27
+ /// A clock that always answers one moment, for tests.
28
+ #[cfg(test)]
29
+ pub struct FixedClock(pub i64);
30
+
31
+ #[cfg(test)]
32
+ impl Clock for FixedClock {
33
+ fn now_secs(&self) -> i64 {
34
+ self.0
35
+ }
36
+ }
37
+
38
+ const UNSET: i64 = i64::MIN;
39
+ static OVERRIDE: AtomicI64 = AtomicI64::new(UNSET);
40
+
41
+ /// Makes the process clock answer `secs` until `clear_fixed_clock`; for tests only.
42
+ #[cfg(test)]
43
+ pub fn set_fixed_clock(secs: i64) {
44
+ OVERRIDE.store(secs, Ordering::SeqCst);
45
+ }
46
+
47
+ #[cfg(test)]
48
+ pub fn clear_fixed_clock() {
49
+ OVERRIDE.store(UNSET, Ordering::SeqCst);
50
+ }
51
+
52
+ /// The process clock the dispatcher uses: the wall clock unless a test has fixed it.
53
+ pub struct ProcessClock;
54
+
55
+ impl Clock for ProcessClock {
56
+ fn now_secs(&self) -> i64 {
57
+ match OVERRIDE.load(Ordering::SeqCst) {
58
+ UNSET => SystemClock.now_secs(),
59
+ fixed => fixed,
60
+ }
61
+ }
62
+ }
63
+
64
+ /// The command's declared needs, each with the type of the attribute of the same name.
65
+ fn declared_needs<'a>(domain_ir: &'a Value, verb: &str) -> Vec<(&'a str, Option<&'a str>)> {
66
+ let Some((qualified_aggregate, command_name)) = verb.rsplit_once('.') else { return Vec::new() };
67
+ let aggregate = qualified_aggregate.rsplit("::").next().unwrap_or(qualified_aggregate);
68
+ let named = |value: &Value, name: &str| value.get("name").and_then(Value::as_str) == Some(name);
69
+ let list = |value: &'a Value, key: &str| -> Vec<&'a Value> {
70
+ value.get(key).and_then(Value::as_array).map(|a| a.iter().collect()).unwrap_or_default()
71
+ };
72
+ let mut needs = Vec::new();
73
+ for command in list(domain_ir, "aggregates")
74
+ .into_iter()
75
+ .filter(|a| named(a, aggregate))
76
+ .flat_map(|a| list(a, "commands"))
77
+ .filter(|c| named(c, command_name))
78
+ {
79
+ let attributes = list(command, "attributes");
80
+ for fact in list(command, "needs").into_iter().filter_map(|n| n.get("fact").and_then(Value::as_str)) {
81
+ let attribute_type =
82
+ attributes.iter().find(|a| named(a, fact)).and_then(|a| a.get("type")).and_then(Value::as_str);
83
+ needs.push((fact, attribute_type));
84
+ }
85
+ }
86
+ needs
87
+ }
88
+
89
+ /// Every command's needs as the kernel's input `"needs"` reads them: `{ verb: [{fact, type}] }`.
90
+ /// The kernel answers a reaction's command from this (it has no IR of its own), and the same
91
+ /// step's `occurred_at` keeps the answer the same on replay.
92
+ pub fn table(domain_ir: &Value) -> Value {
93
+ let domain = domain_ir.get("name").and_then(Value::as_str).unwrap_or_default();
94
+ let mut verbs = serde_json::Map::new();
95
+ let aggregates = domain_ir.get("aggregates").and_then(Value::as_array).into_iter().flatten();
96
+ for aggregate in aggregates {
97
+ let aggregate_name = aggregate.get("name").and_then(Value::as_str).unwrap_or_default();
98
+ for command in aggregate.get("commands").and_then(Value::as_array).into_iter().flatten() {
99
+ let command_name = command.get("name").and_then(Value::as_str).unwrap_or_default();
100
+ let verb = format!("{domain}::{aggregate_name}.{command_name}");
101
+ let needs: Vec<Value> = declared_needs(domain_ir, &verb)
102
+ .into_iter()
103
+ .map(|(fact, attribute_type)| json!({"fact": fact, "type": attribute_type.unwrap_or("")}))
104
+ .collect();
105
+ if !needs.is_empty() {
106
+ verbs.insert(verb, Value::Array(needs));
107
+ }
108
+ }
109
+ }
110
+ Value::Object(verbs)
111
+ }
112
+
113
+ /// The facts of a command invocation: the `with` object when the call carries one, else the
114
+ /// flat object itself (the kernel's `CommandInvocation` reads both shapes).
115
+ fn facts_mut(args: &mut Value) -> Option<&mut serde_json::Map<String, Value>> {
116
+ if args.get("with").is_some_and(Value::is_object) {
117
+ return args.get_mut("with").and_then(Value::as_object_mut);
118
+ }
119
+ args.as_object_mut()
120
+ }
121
+
122
+ fn answer(fact: &str, attribute_type: Option<&str>, clock: &dyn Clock) -> Option<Value> {
123
+ match fact {
124
+ "now" => {
125
+ let secs = clock.now_secs();
126
+ Some(if attribute_type == Some("Integer") { json!(secs) } else { json!({ "value": secs }) })
127
+ }
128
+ _ => None,
129
+ }
130
+ }
131
+
132
+ /// Fills each fact `verb` needs that `args` leaves out, before any gate reads the arguments. A key
133
+ /// the caller supplied (even a null) is kept; a command that needs nothing is untouched.
134
+ pub fn fill_needs(domain_ir: &Value, verb: &str, args: &mut Value, clock: &dyn Clock) {
135
+ let needs = declared_needs(domain_ir, verb);
136
+ if needs.is_empty() {
137
+ return;
138
+ }
139
+ let Some(facts) = facts_mut(args) else { return };
140
+ for (fact, attribute_type) in needs {
141
+ if facts.contains_key(fact) {
142
+ continue;
143
+ }
144
+ if let Some(value) = answer(fact, attribute_type, clock) {
145
+ facts.insert(fact.to_string(), value);
146
+ }
147
+ }
148
+ }
149
+
150
+ #[cfg(test)]
151
+ mod tests {
152
+ use super::*;
153
+
154
+ const LEASE_IR: &str = include_str!("../../src/generated/lease_clock/ir.json");
155
+
156
+ fn lease_ir() -> Value {
157
+ serde_json::from_str(LEASE_IR).unwrap()
158
+ }
159
+
160
+ // A command whose `now` is an Integer attribute, as QualityControl's are.
161
+ fn integer_ir() -> Value {
162
+ json!({"name": "Qc", "aggregates": [{"name": "Check", "commands": [
163
+ {"name": "Run", "attributes": [{"name": "now", "type": "Integer"}], "needs": [{"fact": "now"}]},
164
+ {"name": "Plain", "attributes": [{"name": "now", "type": "Integer"}], "needs": []}
165
+ ]}]})
166
+ }
167
+
168
+ fn first_needing(ir: &Value) -> String {
169
+ ir["aggregates"]
170
+ .as_array()
171
+ .unwrap()
172
+ .iter()
173
+ .flat_map(|a| {
174
+ a["commands"].as_array().unwrap().iter().filter(|c| !c["needs"].as_array().unwrap().is_empty()).map(
175
+ move |c| format!("{}::{}.{}", ir["name"].as_str().unwrap(), a["name"].as_str().unwrap(), c["name"].as_str().unwrap()),
176
+ )
177
+ })
178
+ .next()
179
+ .expect("the lease_clock domain declares a command that needs :now")
180
+ }
181
+
182
+ #[test]
183
+ fn a_needed_fact_the_caller_left_out_is_filled_from_the_clock() {
184
+ let ir = lease_ir();
185
+ let mut args = json!({"with": {"holder": "a"}});
186
+ fill_needs(&ir, &first_needing(&ir), &mut args, &FixedClock(1_700_000_000));
187
+ assert_eq!(args["with"]["now"], json!({"value": 1_700_000_000}));
188
+ assert_eq!(args["with"]["holder"], "a");
189
+ }
190
+
191
+ #[test]
192
+ fn a_flat_invocation_is_filled_at_its_top_level() {
193
+ let ir = lease_ir();
194
+ let mut args = json!({"holder": "a"});
195
+ fill_needs(&ir, &first_needing(&ir), &mut args, &FixedClock(5));
196
+ assert_eq!(args["now"], json!({"value": 5}));
197
+ }
198
+
199
+ #[test]
200
+ fn a_value_the_caller_supplied_is_kept() {
201
+ let ir = lease_ir();
202
+ let mut args = json!({"with": {"now": {"value": 42}}});
203
+ fill_needs(&ir, &first_needing(&ir), &mut args, &FixedClock(7));
204
+ assert_eq!(args["with"]["now"], json!({"value": 42}));
205
+ }
206
+
207
+ #[test]
208
+ fn a_command_with_no_needs_is_untouched() {
209
+ let ir = integer_ir();
210
+ let mut args = json!({"with": {"x": 1}});
211
+ fill_needs(&ir, "Qc::Check.Plain", &mut args, &FixedClock(7));
212
+ assert_eq!(args, json!({"with": {"x": 1}}));
213
+ let mut unknown = json!({"with": {}});
214
+ fill_needs(&ir, "Qc::Check.Nope", &mut unknown, &FixedClock(7));
215
+ assert_eq!(unknown, json!({"with": {}}));
216
+ }
217
+
218
+ #[test]
219
+ fn an_integer_attribute_gets_a_bare_integer_and_an_instant_a_value_object() {
220
+ let mut integer = json!({"with": {}});
221
+ fill_needs(&integer_ir(), "Qc::Check.Run", &mut integer, &FixedClock(9));
222
+ assert_eq!(integer["with"]["now"], json!(9));
223
+ let ir = lease_ir();
224
+ let mut instant = json!({"with": {}});
225
+ fill_needs(&ir, &first_needing(&ir), &mut instant, &FixedClock(9));
226
+ assert_eq!(instant["with"]["now"], json!({"value": 9}));
227
+ }
228
+
229
+ #[test]
230
+ fn the_kernel_table_lists_only_commands_that_need_something() {
231
+ let table = table(&integer_ir());
232
+ assert_eq!(table, json!({"Qc::Check.Run": [{"fact": "now", "type": "Integer"}]}));
233
+ assert_eq!(super::table(&json!({"name": "D"})), json!({}));
234
+ }
235
+
236
+ #[test]
237
+ fn the_process_clock_answers_a_fixed_moment_and_otherwise_the_wall_clock() {
238
+ set_fixed_clock(123);
239
+ assert_eq!(ProcessClock.now_secs(), 123);
240
+ clear_fixed_clock();
241
+ assert!(ProcessClock.now_secs() > 1_700_000_000);
242
+ }
243
+ }
@@ -43,7 +43,7 @@ impl Delivery {
43
43
 
44
44
  /// How email leaves the host: really, through Resend, or logged and dropped.
45
45
  pub enum Mailer {
46
- Live { http: reqwest::Client, api_key: String, from: String, base_url: String },
46
+ Live { http: reqwest::Client, api_key: String, from: String, reply_to: Option<String>, base_url: String },
47
47
  Mock,
48
48
  }
49
49
 
@@ -53,6 +53,7 @@ impl Mailer {
53
53
  pub fn from_env() -> Result<Option<Mailer>, String> {
54
54
  let var = |name: &str| std::env::var(name).unwrap_or_default();
55
55
  Self::configured(&var("RESEND_API_KEY"), &var("RESEND_FROM"), &var("RESEND_MOCK"))
56
+ .map(|mailer| mailer.map(|mailer| mailer.replying_to(&var("RESEND_REPLY_TO"))))
56
57
  }
57
58
 
58
59
  fn configured(api_key: &str, from: &str, mock: &str) -> Result<Option<Mailer>, String> {
@@ -69,7 +70,19 @@ impl Mailer {
69
70
  /// A mailer that posts to `base_url` (Resend's API, or a stand-in in tests).
70
71
  pub fn live(api_key: &str, from: &str, base_url: &str) -> Mailer {
71
72
  let http = reqwest::Client::builder().timeout(TIMEOUT).build().expect("a reqwest client with a timeout always builds");
72
- Mailer::Live { http, api_key: api_key.to_string(), from: from.to_string(), base_url: base_url.to_string() }
73
+ Mailer::Live { http, api_key: api_key.to_string(), from: from.to_string(), reply_to: None, base_url: base_url.to_string() }
74
+ }
75
+
76
+ /// Sets where replies go (`Reply-To`), when the address people reply to is not the one the mail
77
+ /// is sent from. Blank leaves replies going to the From address. A mock mailer has no replies.
78
+ pub fn replying_to(self, reply_to: &str) -> Mailer {
79
+ match self {
80
+ Mailer::Live { http, api_key, from, base_url, .. } => {
81
+ let reply_to = (!reply_to.trim().is_empty()).then(|| reply_to.trim().to_string());
82
+ Mailer::Live { http, api_key, from, reply_to, base_url }
83
+ }
84
+ mock => mock,
85
+ }
73
86
  }
74
87
 
75
88
  #[cfg(test)]
@@ -80,7 +93,7 @@ impl Mailer {
80
93
  pub async fn deliver(&self, email: &Email<'_>) -> Delivery {
81
94
  match self {
82
95
  Mailer::Mock => mock_deliver(email),
83
- Mailer::Live { http, api_key, from, base_url } => live_deliver(http, api_key, from, base_url, email).await,
96
+ Mailer::Live { http, api_key, from, reply_to, base_url } => live_deliver(http, api_key, from, reply_to.as_deref(), base_url, email).await,
84
97
  }
85
98
  }
86
99
  }
@@ -93,8 +106,11 @@ fn mock_deliver(email: &Email<'_>) -> Delivery {
93
106
  Delivery::sent(format!("re_{}", uuid::Uuid::new_v4().simple()))
94
107
  }
95
108
 
96
- fn payload(from: &str, email: &Email<'_>) -> Value {
109
+ fn payload(from: &str, reply_to: Option<&str>, email: &Email<'_>) -> Value {
97
110
  let mut payload = json!({ "from": from, "to": [email.to], "subject": email.subject });
111
+ if let Some(reply_to) = reply_to {
112
+ payload["reply_to"] = json!(reply_to);
113
+ }
98
114
  let content_key = if email.body.trim_start().starts_with('<') { "html" } else { "text" };
99
115
  payload[content_key] = json!(email.body);
100
116
  if let Some(url) = email.unsubscribe_url {
@@ -113,8 +129,8 @@ fn retry_delay(response: &reqwest::Response) -> Duration {
113
129
  Duration::from_secs(secs.min(MAX_RETRY_SECS))
114
130
  }
115
131
 
116
- async fn live_deliver(http: &reqwest::Client, api_key: &str, from: &str, base_url: &str, email: &Email<'_>) -> Delivery {
117
- let body = payload(from, email);
132
+ async fn live_deliver(http: &reqwest::Client, api_key: &str, from: &str, reply_to: Option<&str>, base_url: &str, email: &Email<'_>) -> Delivery {
133
+ let body = payload(from, reply_to, email);
118
134
  for attempt in 1..=MAX_ATTEMPTS {
119
135
  let response = match http.post(format!("{base_url}/emails")).bearer_auth(api_key).json(&body).send().await {
120
136
  Ok(r) => r,
@@ -225,19 +241,45 @@ mod tests {
225
241
 
226
242
  #[test]
227
243
  fn an_html_body_goes_as_html_and_a_plain_one_as_text() {
228
- assert!(payload("f", &email("a@b.com", " <p>hi</p>")).get("html").is_some());
229
- let plain = payload("f", &email("a@b.com", "just a link"));
244
+ assert!(payload("f", None, &email("a@b.com", " <p>hi</p>")).get("html").is_some());
245
+ let plain = payload("f", None, &email("a@b.com", "just a link"));
230
246
  assert!(plain.get("text").is_some() && plain.get("html").is_none());
231
247
  }
232
248
 
233
249
  #[test]
234
250
  fn the_unsubscribe_url_rides_as_a_list_unsubscribe_header() {
235
- let with = payload("f", &email("a@b.com", "x"));
251
+ let with = payload("f", None, &email("a@b.com", "x"));
236
252
  assert_eq!(with["headers"]["List-Unsubscribe"], "<https://example.com/newsletter-unsubscribed.html?email=a%40b.com>");
237
- let without = payload("f", &Email { to: "a@b.com", subject: "s", body: "x", unsubscribe_url: None });
253
+ let without = payload("f", None, &Email { to: "a@b.com", subject: "s", body: "x", unsubscribe_url: None });
238
254
  assert!(without.get("headers").is_none());
239
255
  }
240
256
 
257
+ #[test]
258
+ fn a_reply_to_address_rides_in_the_payload_and_is_absent_without_one() {
259
+ let with = payload("f", Some("lifeadelics@gmail.com"), &email("a@b.com", "x"));
260
+ assert_eq!(with["reply_to"], "lifeadelics@gmail.com");
261
+ assert!(payload("f", None, &email("a@b.com", "x")).get("reply_to").is_none());
262
+ }
263
+
264
+ #[test]
265
+ fn replying_to_trims_the_address_and_a_blank_one_sets_none() {
266
+ let set = Mailer::live("re_x", "n@mail.example.com", "http://x").replying_to(" me@example.com ");
267
+ assert!(matches!(&set, Mailer::Live { reply_to: Some(r), .. } if r == "me@example.com"));
268
+ let blank = Mailer::live("re_x", "n@mail.example.com", "http://x").replying_to(" ");
269
+ assert!(matches!(&blank, Mailer::Live { reply_to: None, .. }));
270
+ assert!(Mailer::Mock.replying_to("me@example.com").is_mock(), "a mock mailer has no replies");
271
+ }
272
+
273
+ #[tokio::test]
274
+ async fn a_live_send_carries_the_reply_to_address_to_resend() {
275
+ let fake = FakeResend::start(vec![("200 OK", "", r#"{"id":"em_9"}"#)]);
276
+ let mailer = Mailer::live("re_x", "News <n@mail.example.com>", &fake.base).replying_to("lifeadelics@gmail.com");
277
+
278
+ mailer.deliver(&email("a@b.com", "x")).await;
279
+
280
+ assert!(fake.requests()[0].contains(r#""reply_to":"lifeadelics@gmail.com""#), "{}", fake.requests()[0]);
281
+ }
282
+
241
283
  #[test]
242
284
  fn the_mock_logs_instead_of_sending_and_bounces_the_test_address() {
243
285
  let sent = mock_deliver(&email("a@b.com", "x"));
@@ -53,25 +53,29 @@ pub async fn dispatch_body(
53
53
  .ok_or("event missing \"verb\"")?
54
54
  .to_string();
55
55
  let role = body.get("role").and_then(|v| v.as_str()).map(|s| s.to_string());
56
+ // Who the caller is, for Governance. Honored only where the internal protocol is: from this
57
+ // host's own peers (`trusts_internal_dispatch`), never from a public caller.
58
+ let actor_id = body.get("actor_id").and_then(|v| v.as_str()).map(|s| s.to_string());
59
+ let caller = dispatch::Caller { role: role.as_deref(), actor_id: actor_id.as_deref() };
56
60
 
57
61
  let outcome = if let Some(to) = body.get("to").cloned() {
58
62
  if body.get("args").is_some() {
59
63
  return Err("cannot combine to/with with legacy args".into());
60
64
  }
61
65
  let facts = body.get("with").cloned().unwrap_or_else(|| serde_json::json!({}));
62
- dispatch::handle_routed(client, wasm_path, &verb, to, facts, role.as_deref(), config, invoker)
66
+ dispatch::handle_routed_as(client, wasm_path, &verb, to, facts, caller, config, invoker)
63
67
  .await
64
68
  .map_err(|e| format!("{e:#}"))?
65
69
  } else if let Some(facts) = body.get("with").cloned() {
66
70
  if body.get("args").is_some() {
67
71
  return Err("cannot combine \"with\" with legacy args".into());
68
72
  }
69
- dispatch::handle_facts(client, wasm_path, &verb, facts, role.as_deref(), config, invoker)
73
+ dispatch::handle_facts_as(client, wasm_path, &verb, facts, caller, config, invoker)
70
74
  .await
71
75
  .map_err(|e| format!("{e:#}"))?
72
76
  } else {
73
77
  let args = body.get("args").cloned().unwrap_or_else(|| serde_json::json!({}));
74
- dispatch::handle(client, wasm_path, &verb, args, role.as_deref(), config, invoker)
78
+ dispatch::handle_as(client, wasm_path, &verb, args, caller, config, invoker)
75
79
  .await
76
80
  .map_err(|e| format!("{e:#}"))?
77
81
  };