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
@@ -0,0 +1,69 @@
1
+ // The optional branded confirmation email: an HTML template an operator
2
+ // hosts, fetched at send time. Hecks ships no brand; with no template (or
3
+ // any trouble with one) the caller keeps the plain-text body.
4
+ use super::super::newsletter_send::personalize;
5
+ use std::time::Duration;
6
+
7
+ pub(super) const CONFIRM_TOKEN: &str = "{{CONFIRM_URL}}";
8
+ const FETCH_TIMEOUT: Duration = Duration::from_secs(5);
9
+ pub(super) const MAX_TEMPLATE_BYTES: usize = 256 * 1024;
10
+
11
+ // Escapes a URL for an HTML attribute or text position.
12
+ fn escape_html(value: &str) -> String {
13
+ value.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;").replace('\'', "&#39;")
14
+ }
15
+
16
+ // Fills the placeholders with HTML-escaped URLs. `Err` names why the
17
+ // template is unusable: without {{CONFIRM_URL}} the email would carry no
18
+ // link. {{UNSUBSCRIBE_URL}} is optional. The result starts at its first
19
+ // non-blank character so the mailer sends it as HTML.
20
+ pub(super) fn fill_template(template: &str, confirm_url: &str, unsubscribe_url: &str) -> Result<String, &'static str> {
21
+ if !template.contains(CONFIRM_TOKEN) {
22
+ return Err("the template has no {{CONFIRM_URL}} placeholder");
23
+ }
24
+ let filled = template.replace(CONFIRM_TOKEN, &escape_html(confirm_url));
25
+ Ok(personalize(&filled, &escape_html(unsubscribe_url)).trim_start().to_string())
26
+ }
27
+
28
+ // Reads a response body, stopping as soon as it passes the cap.
29
+ async fn read_capped(mut response: reqwest::Response) -> Result<String, String> {
30
+ let mut bytes: Vec<u8> = Vec::new();
31
+ while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
32
+ bytes.extend_from_slice(&chunk);
33
+ if bytes.len() > MAX_TEMPLATE_BYTES {
34
+ return Err(format!("the template is larger than {MAX_TEMPLATE_BYTES} bytes"));
35
+ }
36
+ }
37
+ String::from_utf8(bytes).map_err(|_| "the template is not UTF-8".to_string())
38
+ }
39
+
40
+ // GETs the template (5 second timeout, size capped); a non-2xx answer or
41
+ // any transport trouble is an `Err` with the reason.
42
+ async fn fetch_template(url: &str) -> Result<String, String> {
43
+ let http = reqwest::Client::builder().timeout(FETCH_TIMEOUT).build().map_err(|e| e.to_string())?;
44
+ let response = http.get(url).send().await.map_err(|e| e.to_string())?;
45
+ if !response.status().is_success() {
46
+ return Err(format!("the template answered {}", response.status()));
47
+ }
48
+ read_capped(response).await
49
+ }
50
+
51
+ // The HTML body when NEWSLETTER_CONFIRMATION_TEMPLATE_URL names a usable
52
+ // template; None (after logging why) when the caller should send plain text.
53
+ pub(super) async fn branded_body(confirm_url: &str, unsubscribe_url: &str) -> Option<String> {
54
+ let url = std::env::var("NEWSLETTER_CONFIRMATION_TEMPLATE_URL").ok().filter(|u| !u.trim().is_empty())?;
55
+ let template = match fetch_template(url.trim()).await {
56
+ Ok(t) => t,
57
+ Err(e) => {
58
+ eprintln!("newsletter: the confirmation template could not be fetched ({e}), so the plain-text confirmation was sent");
59
+ return None;
60
+ }
61
+ };
62
+ match fill_template(&template, confirm_url, unsubscribe_url) {
63
+ Ok(body) => Some(body),
64
+ Err(e) => {
65
+ eprintln!("newsletter: the confirmation template was rejected ({e}), so the plain-text confirmation was sent");
66
+ None
67
+ }
68
+ }
69
+ }
@@ -102,3 +102,46 @@ fn a_confirm_token_is_not_an_unsubscribe_token_and_the_reverse() {
102
102
  assert!(!unsubscribe_token_matches("s3cret-value", &confirm, "a@example.com"));
103
103
  assert!(!confirm_token_matches("s3cret-value", &unsubscribe, "a@example.com"));
104
104
  }
105
+
106
+ use template::fill_template;
107
+
108
+ const CONFIRM: &str = "https://example.com/newsletter-confirmed.html?email=a%40b.c&token=t";
109
+ const UNSUBSCRIBE: &str = "https://example.com/newsletter-unsubscribed.html?email=a%40b.c&token=u";
110
+
111
+ #[test]
112
+ fn a_template_gets_the_confirm_link_in_place_of_its_placeholder() {
113
+ let body = fill_template("<p><a href=\"{{CONFIRM_URL}}\">Confirm</a></p>", CONFIRM, UNSUBSCRIBE).unwrap();
114
+ assert_eq!(body, "<p><a href=\"https://example.com/newsletter-confirmed.html?email=a%40b.c&amp;token=t\">Confirm</a></p>");
115
+ }
116
+
117
+ #[test]
118
+ fn a_template_without_the_confirm_placeholder_is_rejected() {
119
+ assert!(fill_template("<p>{{UNSUBSCRIBE_URL}}</p>", CONFIRM, UNSUBSCRIBE).is_err());
120
+ assert!(fill_template("", CONFIRM, UNSUBSCRIBE).is_err());
121
+ }
122
+
123
+ #[test]
124
+ fn the_unsubscribe_placeholder_is_optional_and_replaced_wherever_present() {
125
+ assert!(fill_template("<a href=\"{{CONFIRM_URL}}\">x</a>", CONFIRM, UNSUBSCRIBE).is_ok());
126
+ let body = fill_template("<a href=\"{{CONFIRM_URL}}\">x</a>{{UNSUBSCRIBE_URL}}|{{UNSUBSCRIBE_URL}}", CONFIRM, UNSUBSCRIBE).unwrap();
127
+ assert!(!body.contains("{{"));
128
+ assert_eq!(body.matches("newsletter-unsubscribed.html").count(), 2);
129
+ }
130
+
131
+ #[test]
132
+ fn ampersands_and_quotes_in_a_url_are_escaped_inside_the_html() {
133
+ let body = fill_template("<a href=\"{{CONFIRM_URL}}\">x</a>", "https://e.com/?a=1&b=\"2\"<>'", UNSUBSCRIBE).unwrap();
134
+ assert_eq!(body, "<a href=\"https://e.com/?a=1&amp;b=&quot;2&quot;&lt;&gt;&#39;\">x</a>");
135
+ }
136
+
137
+ #[test]
138
+ fn a_filled_template_starts_at_its_first_tag_so_the_mailer_sends_html() {
139
+ let body = fill_template("\n <p>{{CONFIRM_URL}}</p>", CONFIRM, UNSUBSCRIBE).unwrap();
140
+ assert!(body.starts_with('<'));
141
+ }
142
+
143
+ #[test]
144
+ fn without_a_template_the_plain_text_confirmation_is_unchanged() {
145
+ let body = confirmation_body(CONFIRM);
146
+ assert_eq!(body, format!("Please confirm your newsletter subscription by opening this link:\n\n{CONFIRM}\n\nIf you didn't ask for this, you can ignore this email and nothing will be sent to you.\n"));
147
+ }
@@ -14,6 +14,7 @@ use tokio::sync::Mutex;
14
14
  use tokio_postgres::Client;
15
15
 
16
16
  mod cooldown;
17
+ mod template;
17
18
  #[cfg(test)]
18
19
  mod tests;
19
20
 
@@ -194,8 +195,12 @@ pub(super) async fn send_confirmation(email: &str) {
194
195
  let site = site_url();
195
196
  let link = confirm_url(&site, email, &confirm_token(&secret, email));
196
197
  let unsubscribe = unsubscribe_url(&site, email, &unsubscribe_token(&secret, email));
198
+ let body = match template::branded_body(&link, &unsubscribe).await {
199
+ Some(html) => html,
200
+ None => confirmation_body(&link),
201
+ };
197
202
  let delivery = mailer
198
- .deliver(&Email { to: email, subject: "Confirm your newsletter subscription", body: &confirmation_body(&link), unsubscribe_url: Some(&unsubscribe) })
203
+ .deliver(&Email { to: email, subject: "Confirm your newsletter subscription", body: &body, unsubscribe_url: Some(&unsubscribe) })
199
204
  .await;
200
205
  if !delivery.ok {
201
206
  eprintln!("newsletter: the confirmation email was not delivered: {}", delivery.reason.unwrap_or_default());
@@ -132,7 +132,7 @@ pub(super) fn unsubscribe_url(site_url: &str, email: &str, token: &str) -> Strin
132
132
  url.to_string()
133
133
  }
134
134
 
135
- fn personalize(body: &str, unsubscribe_url: &str) -> String {
135
+ pub(super) fn personalize(body: &str, unsubscribe_url: &str) -> String {
136
136
  body.replace(UNSUBSCRIBE_TOKEN, unsubscribe_url)
137
137
  }
138
138
 
@@ -1,5 +1,5 @@
1
1
  //! Source-body slicing and whitespace collapse, mirroring Ruby's `CanonicalForm`.
2
- //! Hand-mirrored from projection.json: a third rule there needs a matching rule here.
2
+ //! Hand-mirrored from projection.json: a rule there (collapse_whitespace, replace, scale_call) needs a matching rule here.
3
3
 
4
4
  /// Slices the raw source between two byte offsets, before any normalisation.
5
5
  pub fn slice(source: &str, start: usize, end: usize) -> &str {
@@ -11,7 +11,87 @@ pub fn slice(source: &str, start: usize, end: usize) -> &str {
11
11
  pub fn apply(source: &str) -> String {
12
12
  let collapsed = map_outside_strings(source, collapse_whitespace);
13
13
  let replaced = map_outside_strings(&collapsed, |run| replace_word_boundary(run, ".length", ".size"));
14
- replaced.trim().to_string()
14
+ let scaled = SCALE_CALLS.iter().fold(replaced, |text, (token, seconds)| {
15
+ map_outside_strings(&text, |run| scale_call(run, token, seconds))
16
+ });
17
+ scaled.trim().to_string()
18
+ }
19
+
20
+ /// The `scale_call` rows of projection.json, in position order: (call name, seconds per unit).
21
+ const SCALE_CALLS: [(&str, &str); 3] = [("days", "86400"), ("hours", "3600"), ("minutes", "60")];
22
+
23
+ /// Every normalisation row of projection.json as (strategy, source_token, replacement,
24
+ /// boundary, position), in position order; emitted into the IR's expression table.
25
+ pub const TABLE: [(&str, &str, &str, &str, &str); 5] = [
26
+ ("collapse_whitespace", "", "", "none", "1"),
27
+ ("replace", ".length", ".size", "word", "2"),
28
+ ("scale_call", "days", "86400", "word", "3"),
29
+ ("scale_call", "hours", "3600", "word", "4"),
30
+ ("scale_call", "minutes", "60", "word", "5"),
31
+ ];
32
+
33
+ /// Folds `token(<whole number>)` into the number times `seconds`, as Ruby's `scale_call` does.
34
+ /// The name must not follow a word character, digit, underscore or dot; spaces inside the
35
+ /// parentheses are allowed; any other argument leaves the call as written.
36
+ fn scale_call(text: &str, token: &str, seconds: &str) -> String {
37
+ let mut out = String::with_capacity(text.len());
38
+ let mut i = 0;
39
+ while i < text.len() {
40
+ if text[i..].starts_with(token) {
41
+ let preceded = text[..i]
42
+ .chars()
43
+ .next_back()
44
+ .map_or(false, |c| c.is_alphanumeric() || c == '_' || c == '.');
45
+ if !preceded {
46
+ if let Some((digits, len)) = whole_number_call(&text[i + token.len()..]) {
47
+ out.push_str(&multiply(digits, seconds));
48
+ i += token.len() + len;
49
+ continue;
50
+ }
51
+ }
52
+ }
53
+ let ch = text[i..].chars().next().unwrap();
54
+ out.push(ch);
55
+ i += ch.len_utf8();
56
+ }
57
+ out
58
+ }
59
+
60
+ /// Matches `(` spaces digits spaces `)` at the start of `rest`; returns the digits and the
61
+ /// matched length.
62
+ fn whole_number_call(rest: &str) -> Option<(&str, usize)> {
63
+ let inner = rest.strip_prefix('(')?;
64
+ let digits_at = inner.len() - inner.trim_start_matches(char::is_whitespace).len();
65
+ let body = &inner[digits_at..];
66
+ let digit_len = body.bytes().take_while(|b| b.is_ascii_digit()).count();
67
+ if digit_len == 0 {
68
+ return None;
69
+ }
70
+ let tail = &body[digit_len..];
71
+ let close = tail.len() - tail.trim_start_matches(char::is_whitespace).len();
72
+ tail[close..].strip_prefix(')')?;
73
+ Some((&body[..digit_len], 1 + digits_at + digit_len + close + 1))
74
+ }
75
+
76
+ /// Decimal `digits` times the decimal `factor`, as text with no leading zeros (any length).
77
+ fn multiply(digits: &str, factor: &str) -> String {
78
+ let factor: u32 = factor.parse().expect("scale factor is a whole number");
79
+ let mut carry = 0u32;
80
+ let mut out: Vec<u8> = Vec::with_capacity(digits.len() + 6);
81
+ for d in digits.bytes().rev() {
82
+ let v = (d - b'0') as u32 * factor + carry;
83
+ out.push(b'0' + (v % 10) as u8);
84
+ carry = v / 10;
85
+ }
86
+ while carry > 0 {
87
+ out.push(b'0' + (carry % 10) as u8);
88
+ carry /= 10;
89
+ }
90
+ while out.len() > 1 && out.last() == Some(&b'0') {
91
+ out.pop();
92
+ }
93
+ out.reverse();
94
+ String::from_utf8(out).unwrap()
15
95
  }
16
96
 
17
97
  /// Applies `transform` to the unquoted runs of `text` and copies quoted runs verbatim;
@@ -104,6 +184,61 @@ mod tests {
104
184
  assert_eq!(apply("lengthy"), "lengthy");
105
185
  }
106
186
 
187
+ #[test]
188
+ fn folds_duration_calls_into_seconds() {
189
+ assert_eq!(apply("days(730)"), "63072000");
190
+ assert_eq!(apply("days( 1 ) + hours(2) + minutes(15)"), "86400 + 7200 + 900");
191
+ assert_eq!(
192
+ apply("x.days(3) workdays(3) days(n) days(1.5)"),
193
+ "x.days(3) workdays(3) days(n) days(1.5)"
194
+ );
195
+ assert_eq!(apply("'days(2)' == days(0)"), "'days(2)' == 0");
196
+ }
197
+
198
+ /// Splits JSON text into its string literals, decoding escapes.
199
+ fn json_strings(text: &str) -> Vec<String> {
200
+ let mut out = Vec::new();
201
+ let mut chars = text.chars();
202
+ while let Some(c) = chars.next() {
203
+ if c != '"' {
204
+ continue;
205
+ }
206
+ let mut s = String::new();
207
+ while let Some(c) = chars.next() {
208
+ match c {
209
+ '"' => break,
210
+ '\\' => match chars.next().unwrap() {
211
+ 'n' => s.push('\n'),
212
+ 't' => s.push('\t'),
213
+ 'r' => s.push('\r'),
214
+ 'u' => {
215
+ let hex: String = chars.by_ref().take(4).collect();
216
+ s.push(char::from_u32(u32::from_str_radix(&hex, 16).unwrap()).unwrap());
217
+ }
218
+ other => s.push(other),
219
+ },
220
+ c => s.push(c),
221
+ }
222
+ }
223
+ out.push(s);
224
+ }
225
+ out
226
+ }
227
+
228
+ #[test]
229
+ fn matches_the_shared_canonical_form_cases() {
230
+ let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../spec/fixtures/canonical_form_cases.json");
231
+ let text = std::fs::read_to_string(path).expect("shared cases file");
232
+ let strings = json_strings(&text);
233
+ let start = strings.iter().position(|s| s == "cases").expect("cases key") + 1;
234
+ let rows: Vec<&[String]> = strings[start..].chunks(4).collect();
235
+ assert!(!rows.is_empty());
236
+ for row in rows {
237
+ assert_eq!((row[0].as_str(), row[2].as_str()), ("source", "canonical"));
238
+ assert_eq!(apply(&row[1]), row[3], "source {:?}", row[1]);
239
+ }
240
+ }
241
+
107
242
  #[test]
108
243
  fn slices_the_exact_source_span() {
109
244
  let source = "given(\"ok\") { balance >= amount }";
@@ -43,25 +43,20 @@ pub fn write(value: &JsonValue) -> String {
43
43
  /// The pinned `Expression::CanonicalForm.table` rules from projection.json, in Ruby's field
44
44
  /// order; `position` is a string.
45
45
  fn canonical_form_table() -> JsonValue {
46
- JsonValue::Array(vec![
47
- JsonValue::Object(vec![
48
- (
49
- "strategy".to_string(),
50
- JsonValue::str("collapse_whitespace"),
51
- ),
52
- ("source_token".to_string(), JsonValue::str("")),
53
- ("replacement".to_string(), JsonValue::str("")),
54
- ("boundary".to_string(), JsonValue::str("none")),
55
- ("position".to_string(), JsonValue::str("1")),
56
- ]),
57
- JsonValue::Object(vec![
58
- ("strategy".to_string(), JsonValue::str("replace")),
59
- ("source_token".to_string(), JsonValue::str(".length")),
60
- ("replacement".to_string(), JsonValue::str(".size")),
61
- ("boundary".to_string(), JsonValue::str("word")),
62
- ("position".to_string(), JsonValue::str("2")),
63
- ]),
64
- ])
46
+ JsonValue::Array(
47
+ crate::canonical::TABLE
48
+ .iter()
49
+ .map(|(strategy, token, replacement, boundary, position)| {
50
+ JsonValue::Object(vec![
51
+ ("strategy".to_string(), JsonValue::str(*strategy)),
52
+ ("source_token".to_string(), JsonValue::str(*token)),
53
+ ("replacement".to_string(), JsonValue::str(*replacement)),
54
+ ("boundary".to_string(), JsonValue::str(*boundary)),
55
+ ("position".to_string(), JsonValue::str(*position)),
56
+ ])
57
+ })
58
+ .collect(),
59
+ )
65
60
  }
66
61
 
67
62
  /// `IR::Bluebook#to_h`; field order is pinned there.
@@ -304,6 +299,17 @@ fn command_json(c: &ir::Command) -> JsonValue {
304
299
  "ensures".to_string(),
305
300
  JsonValue::Array(c.ensures.iter().map(given_json).collect()),
306
301
  ),
302
+ (
303
+ "needs".to_string(),
304
+ JsonValue::Array(
305
+ c.needs
306
+ .iter()
307
+ .map(|fact| {
308
+ JsonValue::Object(vec![("fact".to_string(), JsonValue::str(fact.clone()))])
309
+ })
310
+ .collect(),
311
+ ),
312
+ ),
307
313
  (
308
314
  "mutations".to_string(),
309
315
  JsonValue::Array(c.mutations.iter().map(mutation_json).collect()),
@@ -98,6 +98,8 @@ pub struct Command {
98
98
  pub attributes: Vec<Attribute>,
99
99
  pub givens: Vec<Given>,
100
100
  pub ensures: Vec<Ensures>,
101
+ // Outside facts the runtime supplies before any given runs (`needs :now`); only `now` today.
102
+ pub needs: Vec<String>,
101
103
  pub mutations: Vec<Mutation>,
102
104
  pub emits: Vec<String>,
103
105
  // The lifecycle state this command is admissible from; see `CommandFrom`.
@@ -107,6 +107,7 @@ pub static KEYWORDS: &[KeywordRow] = &[
107
107
  KeywordRow { word: "state", context: "Command", body: "none", inner: "", opens: "", fills: "mutations", status: "admitted", was: "", resolves_via: "", disambiguator: "" },
108
108
  KeywordRow { word: "attribute", context: "Command", body: "none", inner: "", opens: "", fills: "attributes", status: "admitted", was: "", resolves_via: "", disambiguator: "" },
109
109
  KeywordRow { word: "ensures", context: "Command", body: "source", inner: "", opens: "", fills: "ensures", status: "admitted", was: "", resolves_via: "", disambiguator: "" },
110
+ KeywordRow { word: "needs", context: "Command", body: "none", inner: "", opens: "", fills: "needs", status: "admitted", was: "", resolves_via: "", disambiguator: "" },
110
111
  KeywordRow { word: "corrects", context: "Command", body: "none", inner: "", opens: "", fills: "mutations", status: "admitted", was: "", resolves_via: "", disambiguator: "" },
111
112
  KeywordRow { word: "description", context: "Entity", body: "none", inner: "", opens: "", fills: "description", status: "admitted", was: "", resolves_via: "", disambiguator: "" },
112
113
  KeywordRow { word: "identified_by", context: "Entity", body: "keywords", inner: "ValueObject", opens: "ValueObject", fills: "identified_by", status: "admitted", was: "", resolves_via: "", disambiguator: "" },
@@ -308,6 +309,7 @@ pub static ARGUMENTS: &[ArgumentRow] = &[
308
309
  ArgumentRow { keyword: "reference_to", context: "Command", at: "", named: "optional", kind: "flag", required: "false", fills: "optional", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
309
310
  ArgumentRow { keyword: "given", context: "Command", at: "1", named: "", kind: "text", required: "true", fills: "description", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
310
311
  ArgumentRow { keyword: "ensures", context: "Command", at: "1", named: "", kind: "text", required: "true", fills: "description", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
312
+ ArgumentRow { keyword: "needs", context: "Command", at: "1", named: "", kind: "symbol", required: "true", fills: "fact", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
311
313
  ArgumentRow { keyword: "sets", context: "Command", at: "1", named: "", kind: "symbol", required: "true", fills: "target", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
312
314
  ArgumentRow { keyword: "sets", context: "Command", at: "", named: "to", kind: "literal", required: "false", fills: "source", selects: "op=set", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
313
315
  ArgumentRow { keyword: "sets", context: "Command", at: "", named: "append", kind: "literal", required: "false", fills: "source", selects: "op=append", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
@@ -179,6 +179,7 @@ const COVERED_PAIRS: &[(&str, &str)] = &[
179
179
  ("reference_to", "Command"),
180
180
  ("given", "Command"),
181
181
  ("ensures", "Command"),
182
+ ("needs", "Command"),
182
183
  ("sets", "Command"),
183
184
  ("corrects", "Command"),
184
185
  ("delegates_to", "Command"),
@@ -164,6 +164,7 @@ pub fn parse_body(
164
164
  owner_entities,
165
165
  );
166
166
  refuse_duplicate_targets(file, *pos, &command)?;
167
+ refuse_undeclared_needs(file, *pos, &command)?;
167
168
  return Ok((command, pending));
168
169
  };
169
170
  let line = gated.line.number;
@@ -205,6 +206,27 @@ pub fn parse_body(
205
206
  canonical: canonical::apply(&raw),
206
207
  });
207
208
  }
209
+ "needs" => {
210
+ let fact = super::positional_symbol(file, line, "needs", &gated.args, 1)?;
211
+ if !NEEDABLE_FACTS.contains(&fact.as_str()) {
212
+ return Err(Diagnostic::new(
213
+ file,
214
+ line,
215
+ format!(
216
+ "{} needs :{fact}, which the runtime cannot supply — it supplies :now",
217
+ command.name
218
+ ),
219
+ ));
220
+ }
221
+ if command.needs.contains(&fact) {
222
+ return Err(Diagnostic::new(
223
+ file,
224
+ line,
225
+ format!("{} declares needs :{fact} twice", command.name),
226
+ ));
227
+ }
228
+ command.needs.push(fact);
229
+ }
208
230
  "provenance" => {
209
231
  let raw = super::named_raw(&gated.args, "from")
210
232
  .ok_or_else(|| Diagnostic::new(file, line, "'provenance' requires a from:"))?;
@@ -297,6 +319,28 @@ fn state_ref(raw: &str) -> Option<String> {
297
319
  Some(inner.to_string())
298
320
  }
299
321
 
322
+ /// The outside facts a command may declare it needs; mirrors `CommandBuilder::NEEDABLE_FACTS`.
323
+ const NEEDABLE_FACTS: &[&str] = &["now"];
324
+
325
+ /// A need names the attribute the runtime fills, so the command must declare it.
326
+ fn refuse_undeclared_needs(file: &str, line: usize, command: &ir::Command) -> ParseResult<()> {
327
+ match command
328
+ .needs
329
+ .iter()
330
+ .find(|fact| !command.attributes.iter().any(|a| &a.name == *fact))
331
+ {
332
+ Some(fact) => Err(Diagnostic::new(
333
+ file,
334
+ line,
335
+ format!(
336
+ "{} needs :{fact} but declares no attribute :{fact} for the runtime to fill — add `attribute :{fact}, <type>`",
337
+ command.name
338
+ ),
339
+ )),
340
+ None => Ok(()),
341
+ }
342
+ }
343
+
300
344
  /// Refuses a field written twice: a command's effects are one update set (C4.2).
301
345
  /// `delegates_to`/`corrects` name no field and are not counted.
302
346
  fn refuse_duplicate_targets(file: &str, line: usize, command: &ir::Command) -> ParseResult<()> {
@@ -100,12 +100,14 @@ module RustProjection
100
100
 
101
101
  # Every declared `name` in the IR, in document order. The `fields` maps under
102
102
  # `mutations` are skipped: their keys are attribute names and their values
103
- # are source text, not declarations.
103
+ # are source text, not declarations. So is `translations`: an era edge names
104
+ # data paths (`attendee.first_name`, a backfill into a nested value object)
105
+ # that the host applies to stored rows and never writes into Rust.
104
106
  def declared_names(node, out = [])
105
107
  case node
106
108
  when Hash
107
109
  node.each do |key, value|
108
- if key.to_s == "fields"
110
+ if %w[fields translations].include?(key.to_s)
109
111
  next
110
112
  elsif key.to_s == "name" && value.is_a?(String)
111
113
  out << value
@@ -16,6 +16,9 @@ pub fn run(input: &str) -> String {
16
16
  Err(e) => return error_output(&format!("invalid JSON on stdin: {e}")),
17
17
  };
18
18
 
19
+ // Optional `"needs"`: which commands need which outside facts (ADR 0081), passed by the host.
20
+ super::needs::install(parsed.get("needs"));
21
+
19
22
  let steps = match parsed.get("steps").and_then(Json::as_array) {
20
23
  Some(s) => s,
21
24
  None => return error_output("expected a top-level {\"steps\": [...]} object"),
@@ -7,6 +7,7 @@ pub mod expr;
7
7
  pub mod expression_operators;
8
8
  pub mod json;
9
9
  pub mod named_query;
10
+ pub mod needs;
10
11
  pub mod orchestrate;
11
12
  pub mod pattern;
12
13
  pub mod query_ordering;