hecks 3.0.2 → 3.0.4

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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +6 -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/project_cli.rb +6 -2
  12. data/lib/hecks/deploy/bluebook/deploy.bluebook +34 -0
  13. data/lib/hecks/doors/cli_runner.rb +57 -12
  14. data/lib/hecks/doors/launcher_options.rb +23 -0
  15. data/lib/hecks/embryonaut_bluebook.rb +10 -0
  16. data/lib/hecks/fuzzing/properties.rb +1 -1
  17. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +11 -3
  18. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +9 -7
  19. data/lib/hecks/gate/stages.yml +51 -0
  20. data/lib/hecks/grammar/evolve.rb +22 -1
  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 +3 -2
  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/projections/deploy/lambda.rb +3 -4
  36. data/lib/hecks/quality_control/quality_control.bluebook +6 -0
  37. data/lib/hecks/runtime/command_interpreter.rb +6 -4
  38. data/lib/hecks/runtime/entity_interpreter.rb +5 -2
  39. data/lib/hecks/runtime/interpreting.rb +29 -0
  40. data/lib/hecks/runtime/invocation.rb +5 -1
  41. data/lib/hecks/runtime/loader.rb +16 -13
  42. data/lib/hecks/runtime.rb +11 -0
  43. data/lib/hecks/three_zero/forms.yml +1 -1
  44. data/lib/hecks/tools/ci_gate_decision.rb +109 -0
  45. data/lib/hecks/tools/ci_gates.rb +124 -0
  46. data/lib/hecks/tools/gate.rb +117 -0
  47. data/lib/hecks/tools.rb +3 -0
  48. data/lib/hecks/version.rb +1 -1
  49. data/lib/hecks/vocabulary.rb +6 -1
  50. data/lib/hecks.rb +12 -2
  51. data/rust/build/src/resolve.rs +25 -0
  52. data/rust/codegen/src/naming.rs +13 -2
  53. data/rust/host/HECKS_RELEASE +1 -1
  54. data/rust/host/src/dispatch.rs +207 -1
  55. data/rust/host/src/main.rs +1 -0
  56. data/rust/host/src/needs.rs +243 -0
  57. data/rust/host/src/resend.rs +52 -10
  58. data/rust/host/src/server.rs +7 -3
  59. data/rust/host/src/web.rs +38 -5
  60. data/rust/parser/src/canonical.rs +137 -2
  61. data/rust/parser/src/emit.rs +25 -19
  62. data/rust/parser/src/ir.rs +2 -0
  63. data/rust/parser/src/keywords.rs +2 -0
  64. data/rust/parser/src/main.rs +1 -0
  65. data/rust/parser/src/parse/command.rs +44 -0
  66. data/rust/project/naming.rb +4 -2
  67. data/rust/project_rust_pipeline.rb +4 -0
  68. data/rust/src/kernel/cli.rs +3 -0
  69. data/rust/src/kernel/mod.rs +1 -0
  70. data/rust/src/kernel/needs.rs +194 -0
  71. data/rust/src/kernel/orchestrate.rs +84 -0
  72. data/rust/src/kernel/repository.rs +43 -2
  73. metadata +9 -2
@@ -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
@@ -70,6 +70,10 @@ module RustProjectPipeline
70
70
  # vendor/embryonaut_bluebooks/<name>/bluebook/, sorted. A vendored
71
71
  # package has no .hecksagon of its own either, just its .bluebook(s).
72
72
  chapters += uses_embryonaut_bluebook_names.map do |pkg_name|
73
+ unless pkg_name.to_s.match?(Hecks::EmbryonautBluebook::PACKAGE_NAME)
74
+ abort "hecks project_rust (Rust path): uses_embryonaut_bluebook #{pkg_name.inspect} is not a package " \
75
+ "name — it must match [a-z][a-z0-9_]* (the name `hecks vendor` accepts)"
76
+ end
73
77
  pkg_dir = File.join(domain, "vendor", "embryonaut_bluebooks", pkg_name.to_s, "bluebook")
74
78
  pkg_files = Dir.glob(File.join(pkg_dir, "*.bluebook")).sort
75
79
  if pkg_files.empty?
@@ -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;
@@ -0,0 +1,194 @@
1
+ //! Facts a command `needs` (ADR 0081), answered before the argument gates read the arguments.
2
+ //!
3
+ //! The Ruby interpreter fills them in its `decode_arguments` step; here the same fill runs at the
4
+ //! head of `orchestrate`, ahead of `decode_aggregate_arguments`' own `decode_arguments` slot (the
5
+ //! first thing a dispatch does), so no step is added to the vocabulary. A fact is filled only
6
+ //! when the caller left the key out, so a supplied value wins.
7
+ //!
8
+ //! Which commands need what comes from the host's input, not from generated code: the input's
9
+ //! top-level `"needs"` is `{ "Domain::Aggregate.Command": [{"fact": "now", "type": "Integer"}] }`
10
+ //! (see `cli::run`). With no table installed nothing is filled and behavior is unchanged.
11
+ //!
12
+ //! The clock answers the step's `occurred_at` (the host stamps it once per step and journals it,
13
+ //! so a replay answers the same moment) and, with none, the system clock.
14
+
15
+ use super::Json;
16
+ use std::cell::{Cell, RefCell};
17
+ use std::collections::HashMap;
18
+
19
+ /// One needed fact of a command: its name and the type of the command's attribute of that name.
20
+ #[derive(Debug, Clone, PartialEq)]
21
+ pub struct Need {
22
+ pub fact: String,
23
+ pub attribute_type: String,
24
+ }
25
+
26
+ thread_local! {
27
+ static TABLE: RefCell<HashMap<String, Vec<Need>>> = RefCell::new(HashMap::new());
28
+ static FIXED_CLOCK: Cell<Option<i64>> = const { Cell::new(None) };
29
+ }
30
+
31
+ /// Installs the table the host passed (an absent or malformed `"needs"` installs an empty one).
32
+ pub fn install(table: Option<&Json>) {
33
+ let parsed = match table {
34
+ Some(Json::Object(entries)) => entries
35
+ .iter()
36
+ .map(|(verb, needs)| {
37
+ let needs = needs
38
+ .as_array()
39
+ .unwrap_or(&[])
40
+ .iter()
41
+ .filter_map(|n| {
42
+ let fact = n.get("fact")?.as_str()?.to_string();
43
+ let attribute_type = n.get("type").and_then(Json::as_str).unwrap_or("").to_string();
44
+ Some(Need { fact, attribute_type })
45
+ })
46
+ .collect();
47
+ (verb.clone(), needs)
48
+ })
49
+ .collect(),
50
+ _ => HashMap::new(),
51
+ };
52
+ TABLE.with(|t| *t.borrow_mut() = parsed);
53
+ }
54
+
55
+ /// Makes the kernel clock answer `secs` on this thread until cleared; the test seam.
56
+ pub fn fix_clock(secs: Option<i64>) {
57
+ FIXED_CLOCK.with(|c| c.set(secs));
58
+ }
59
+
60
+ /// The clock's answer: epoch seconds, UTC. The step's `occurred_at` when it parses, else the
61
+ /// system clock (unless fixed).
62
+ pub fn now_secs(occurred_at: Option<&str>) -> i64 {
63
+ if let Some(fixed) = FIXED_CLOCK.with(Cell::get) {
64
+ return fixed;
65
+ }
66
+ occurred_at.and_then(parse_iso8601_utc).unwrap_or_else(|| {
67
+ std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or_default()
68
+ })
69
+ }
70
+
71
+ /// `YYYY-MM-DDTHH:MM:SSZ` to epoch seconds (the format the host stamps).
72
+ fn parse_iso8601_utc(text: &str) -> Option<i64> {
73
+ let text = text.strip_suffix('Z')?;
74
+ let (date, time) = text.split_once('T')?;
75
+ let mut d = date.split('-');
76
+ let (y, m, day): (i64, i64, i64) = (d.next()?.parse().ok()?, d.next()?.parse().ok()?, d.next()?.parse().ok()?);
77
+ let mut t = time.split(':');
78
+ let (h, mi, s): (i64, i64, i64) = (t.next()?.parse().ok()?, t.next()?.parse().ok()?, t.next()?.split('.').next()?.parse().ok()?);
79
+ // Howard Hinnant's days-from-civil.
80
+ let y = if m <= 2 { y - 1 } else { y };
81
+ let era = if y >= 0 { y } else { y - 399 } / 400;
82
+ let yoe = y - era * 400;
83
+ let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + day - 1;
84
+ let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
85
+ let days = era * 146_097 + doe - 719_468;
86
+ Some(days * 86_400 + h * 3600 + mi * 60 + s)
87
+ }
88
+
89
+ fn answer(need: &Need, occurred_at: Option<&str>) -> Option<Json> {
90
+ match need.fact.as_str() {
91
+ "now" => {
92
+ let secs = now_secs(occurred_at);
93
+ Some(if need.attribute_type == "Integer" { Json::int(secs) } else { Json::obj(vec![("value", Json::int(secs))]) })
94
+ }
95
+ _ => None,
96
+ }
97
+ }
98
+
99
+ /// `args` with each fact `verb` needs and the caller left out answered, or `None` when nothing
100
+ /// needed filling (no needs, or every fact supplied). Reads `with` when the call carries one,
101
+ /// else the flat object, as `CommandInvocation` does.
102
+ pub fn enrich(verb: &str, args: &Json, occurred_at: Option<&str>) -> Option<Json> {
103
+ let needs = TABLE.with(|t| t.borrow().get(verb).cloned())?;
104
+ let explicit = matches!(args.get("with"), Some(Json::Object(_)));
105
+ let facts = if explicit { args.get("with")? } else { args };
106
+ let Json::Object(held) = facts else { return None };
107
+ let mut filled = held.clone();
108
+ let before = filled.len();
109
+ for need in &needs {
110
+ if held.iter().any(|(k, _)| k == &need.fact) {
111
+ continue;
112
+ }
113
+ if let Some(value) = answer(need, occurred_at) {
114
+ filled.push((need.fact.clone(), value));
115
+ }
116
+ }
117
+ if filled.len() == before {
118
+ return None;
119
+ }
120
+ if !explicit {
121
+ return Some(Json::Object(filled));
122
+ }
123
+ let Json::Object(envelope) = args else { return None };
124
+ Some(Json::Object(envelope.iter().map(|(k, v)| if k == "with" { (k.clone(), Json::Object(filled.clone())) } else { (k.clone(), v.clone()) }).collect()))
125
+ }
126
+
127
+ #[cfg(test)]
128
+ mod tests {
129
+ use super::*;
130
+
131
+ fn table() -> Json {
132
+ Json::parse(
133
+ r#"{"D::A.Instant": [{"fact": "now", "type": "LeaseInstant"}],
134
+ "D::A.Seconds": [{"fact": "now", "type": "Integer"}]}"#,
135
+ )
136
+ .unwrap()
137
+ }
138
+
139
+ fn with(fields: Vec<(&str, Json)>) -> Json {
140
+ Json::obj(vec![("with", Json::obj(fields))])
141
+ }
142
+
143
+ #[test]
144
+ fn a_needed_fact_left_out_is_answered_by_the_clock() {
145
+ install(Some(&table()));
146
+ fix_clock(Some(1_700_000_000));
147
+ let filled = enrich("D::A.Instant", &with(vec![("x", Json::int(1))]), None).unwrap();
148
+ assert_eq!(filled.get("with").unwrap().get("now"), Some(&Json::obj(vec![("value", Json::int(1_700_000_000))])));
149
+ assert_eq!(filled.get("with").unwrap().get("x"), Some(&Json::int(1)));
150
+ }
151
+
152
+ #[test]
153
+ fn a_supplied_value_is_kept() {
154
+ install(Some(&table()));
155
+ fix_clock(Some(1));
156
+ let args = with(vec![("now", Json::obj(vec![("value", Json::int(42))]))]);
157
+ assert_eq!(enrich("D::A.Instant", &args, None), None);
158
+ }
159
+
160
+ #[test]
161
+ fn a_command_with_no_needs_is_untouched() {
162
+ install(Some(&table()));
163
+ assert_eq!(enrich("D::A.Plain", &with(vec![]), None), None);
164
+ install(None);
165
+ assert_eq!(enrich("D::A.Instant", &with(vec![]), None), None);
166
+ }
167
+
168
+ #[test]
169
+ fn an_integer_attribute_gets_a_bare_integer_and_an_instant_a_value_object() {
170
+ install(Some(&table()));
171
+ fix_clock(Some(9));
172
+ assert_eq!(enrich("D::A.Seconds", &with(vec![]), None).unwrap().get("with").unwrap().get("now"), Some(&Json::int(9)));
173
+ assert_eq!(
174
+ enrich("D::A.Instant", &with(vec![]), None).unwrap().get("with").unwrap().get("now"),
175
+ Some(&Json::obj(vec![("value", Json::int(9))]))
176
+ );
177
+ }
178
+
179
+ #[test]
180
+ fn a_flat_invocation_is_filled_at_the_top_level() {
181
+ install(Some(&table()));
182
+ fix_clock(Some(5));
183
+ let filled = enrich("D::A.Seconds", &Json::obj(vec![("x", Json::int(1))]), None).unwrap();
184
+ assert_eq!(filled.get("now"), Some(&Json::int(5)));
185
+ }
186
+
187
+ #[test]
188
+ fn the_step_s_occurred_at_is_the_answer_when_no_clock_is_fixed() {
189
+ fix_clock(None);
190
+ assert_eq!(now_secs(Some("2023-11-14T22:13:20Z")), 1_700_000_000);
191
+ assert_eq!(now_secs(Some("1970-01-01T00:00:00Z")), 0);
192
+ assert!(now_secs(None) > 1_700_000_000);
193
+ }
194
+ }
@@ -226,6 +226,10 @@ pub fn orchestrate<S: AggregateScan>(
226
226
  reaction_log: &mut Vec<Json>,
227
227
  saga_log: &mut Vec<Json>,
228
228
  ) -> Result<(), Refusal> {
229
+ // A fact the command `needs` is answered ahead of every gate (ADR 0081), for a reaction's
230
+ // dispatch as for the outermost one.
231
+ let enriched = super::needs::enrich(verb, args, occurred_at);
232
+ let args = enriched.as_ref().unwrap_or(args);
229
233
  let mut events = dispatch_fn(store, verb, args, caller_role, caller_actor_id, mutations)?;
230
234
 
231
235
  if let Some(stamp) = saga_correlation {
@@ -1271,6 +1275,86 @@ mod tests {
1271
1275
  );
1272
1276
  }
1273
1277
 
1278
+ // Stands in for a command whose generated `refuse_absent_arguments` lists `now` as declared
1279
+ // and non-optional: it refuses when `now` is missing and records what the gates saw.
1280
+ thread_local! { static SEEN: std::cell::RefCell<Option<Json>> = const { std::cell::RefCell::new(None) }; }
1281
+ fn strict_now_dispatch(
1282
+ _store: &mut MultiLegTestStore,
1283
+ _verb: &str,
1284
+ args: &Json,
1285
+ _caller_role: Option<&str>,
1286
+ _caller_actor_id: Option<&str>,
1287
+ _mutations: &mut Vec<MutationRecord>,
1288
+ ) -> Result<Vec<Event>, Refusal> {
1289
+ let facts = args.get("with").unwrap_or(args);
1290
+ if facts.get("now").is_none() {
1291
+ return Err(Refusal::GivenNotMet("absent argument: now".to_string()));
1292
+ }
1293
+ SEEN.with(|s| *s.borrow_mut() = Some(facts.clone()));
1294
+ Ok(vec![])
1295
+ }
1296
+
1297
+ fn run_strict_now(verb: &str, args: &Json, occurred_at: Option<&str>) -> Result<(), Refusal> {
1298
+ static NONE_P: &[PolicyRule] = &[];
1299
+ static NONE_X: &[CrossDomainPolicyRule] = &[];
1300
+ static NONE_M: &[ProcessManagerDef] = &[];
1301
+ static NONE_Q: &[crate::kernel::QueryDef] = &[];
1302
+ let tables = Tables {
1303
+ policies: NONE_P,
1304
+ cross_domain_policies: NONE_X,
1305
+ process_managers: NONE_M,
1306
+ reference_key_fn: multi_leg_no_reference_key,
1307
+ queries: NONE_Q,
1308
+ command_creates_fn: multi_leg_always_creates,
1309
+ identity_head_fn: multi_leg_no_identity_head,
1310
+ command_attributes_fn: multi_leg_no_declared_attributes,
1311
+ entity_identity_head_fn: multi_leg_no_entity_identity_head,
1312
+ };
1313
+ SEEN.with(|s| *s.borrow_mut() = None);
1314
+ orchestrate(
1315
+ &mut MultiLegTestStore,
1316
+ strict_now_dispatch,
1317
+ tables,
1318
+ &mut HashMap::new(),
1319
+ verb,
1320
+ args,
1321
+ None,
1322
+ None,
1323
+ None,
1324
+ occurred_at,
1325
+ 0,
1326
+ &mut Vec::new(),
1327
+ &mut Vec::new(),
1328
+ &mut Vec::new(),
1329
+ &mut Vec::new(),
1330
+ &mut Vec::new(),
1331
+ )
1332
+ }
1333
+
1334
+ // Both kernel-path guarantees at once: the fill lands before the absent-argument gate runs, so
1335
+ // the strict refusal does not fire for a needed fact, and a supplied value is the one the
1336
+ // gates see.
1337
+ #[test]
1338
+ fn a_needed_fact_is_answered_before_the_absent_argument_gate_and_a_supplied_one_is_kept() {
1339
+ let table = Json::parse(r#"{"T::Slot.Lease": [{"fact": "now", "type": "Integer"}]}"#).unwrap();
1340
+ crate::kernel::needs::install(Some(&table));
1341
+ crate::kernel::needs::fix_clock(Some(77));
1342
+
1343
+ let args = Json::obj(vec![("with", Json::obj(vec![("holder", Json::str("a"))]))]);
1344
+ assert!(run_strict_now("T::Slot.Lease", &args, None).is_ok());
1345
+ SEEN.with(|s| assert_eq!(s.borrow().as_ref().unwrap().get("now"), Some(&Json::int(77))));
1346
+
1347
+ let supplied = Json::obj(vec![("with", Json::obj(vec![("now", Json::int(5))]))]);
1348
+ assert!(run_strict_now("T::Slot.Lease", &supplied, None).is_ok());
1349
+ SEEN.with(|s| assert_eq!(s.borrow().as_ref().unwrap().get("now"), Some(&Json::int(5))));
1350
+
1351
+ // A command that declares no need keeps the strict refusal.
1352
+ assert!(run_strict_now("T::Slot.Other", &args, None).is_err());
1353
+
1354
+ crate::kernel::needs::fix_clock(None);
1355
+ crate::kernel::needs::install(None);
1356
+ }
1357
+
1274
1358
  // Pins domain-qualifying a same-domain entity command (`Manifest::Slot.Fill`): its leftover
1275
1359
  // `::` looks cross-domain, and an unqualified name would fail as an unknown command. The fake
1276
1360
  // dispatch panics on any verb but the qualified, dot-joined form.
@@ -198,7 +198,8 @@ pub fn holds_role_via(
198
198
 
199
199
  /// Refuses with `Unauthorized` when the caller's role does not satisfy `command_role`.
200
200
  ///
201
- /// Unchecked unless both a caller and a command role are present. With `caller_actor_id` and an
201
+ /// Unchecked unless both a caller and a command role are present, except that an identified caller
202
+ /// (`caller_actor_id`) with no stated role is held to what Governance assigned them. With `caller_actor_id` and an
202
203
  /// authorization provider compiled in (`assignments` names a query in `queries`), the role must be
203
204
  /// held through `holds_role_via`; otherwise the caller's stated role is compared as a string.
204
205
  /// Governance's own commands get no exemption: the running Ruby checks them like any other,
@@ -213,9 +214,19 @@ pub fn check_role_via(
213
214
  queries: &[super::QueryDef],
214
215
  assignments: Option<&str>,
215
216
  ) -> Result<(), super::Refusal> {
217
+ let attached = assignments.and_then(|verb| super::named_query::find(queries, verb)).is_some();
218
+ // An identified caller with no stated role is checked against what Governance assigned them: the
219
+ // caller need not claim a role it may not hold. Unchecked without a Governance provider, as ever.
220
+ if let (None, Some(role), Some(actor_id)) = (caller_role, command_role, caller_actor_id) {
221
+ if !attached || holds_role_via(store, queries, assignments, actor_id, role) {
222
+ return Ok(());
223
+ }
224
+ return Err(super::Refusal::Unauthorized(
225
+ super::refusal_wording::UnauthorizedRoleMismatchArgs { command: command_name, role, caller_role: "no assigned role" }.render_args(),
226
+ ));
227
+ }
216
228
  let (Some(caller), Some(role)) = (caller_role, command_role) else { return Ok(()) };
217
229
 
218
- let attached = assignments.and_then(|verb| super::named_query::find(queries, verb)).is_some();
219
230
  let authorized = match caller_actor_id {
220
231
  Some(actor_id) if attached => holds_role_via(store, queries, assignments, actor_id, role),
221
232
  _ => caller == role,
@@ -389,6 +400,36 @@ mod check_role_actor_id_tests {
389
400
  assert!(result.is_err(), "a revoked grant must not authorize: {result:?}");
390
401
  }
391
402
 
403
+ // An identified caller that states no role is held to what Governance assigned them.
404
+ #[test]
405
+ fn an_identified_caller_with_no_stated_role_needs_a_live_assignment_of_the_commands_role() {
406
+ let store = FakeStore { role_assignments: vec![("ra1".to_string(), role_assignment("u1", "Chef", None))] };
407
+ let queries = [assignments_for_actor_query()];
408
+
409
+ assert!(check_role(Some("Chef"), "Prepare", None, Some("u1"), &store, &queries).is_ok(), "an assigned actor needs no stated role");
410
+ assert!(check_role(Some("Waiter"), "Serve", None, Some("u1"), &store, &queries).is_err(), "an actor not assigned the role is refused");
411
+ assert!(check_role(Some("Chef"), "Prepare", None, Some("u2"), &store, &queries).is_err(), "an unassigned actor is refused");
412
+ }
413
+
414
+ // Revoked assignments do not count for a caller with no stated role either.
415
+ #[test]
416
+ fn an_identified_caller_with_no_stated_role_is_refused_on_a_revoked_assignment() {
417
+ let store = FakeStore { role_assignments: vec![("ra1".to_string(), role_assignment("u1", "Chef", Some("2026-06-01")))] };
418
+ let queries = [assignments_for_actor_query()];
419
+
420
+ assert!(check_role(Some("Chef"), "Prepare", None, Some("u1"), &store, &queries).is_err());
421
+ }
422
+
423
+ // No Governance provider attached: an actor with no stated role stays unchecked, as before.
424
+ #[test]
425
+ fn an_identified_caller_with_no_stated_role_is_unchecked_when_governance_is_not_attached() {
426
+ let store = FakeStore { role_assignments: vec![] };
427
+
428
+ let result = check_role_via(Some("Chef"), "Prepare", None, Some("u1"), &store, &[], None);
429
+
430
+ assert!(result.is_ok(), "no provider, nothing to check against: {result:?}");
431
+ }
432
+
392
433
  // A caller with only `role:` keeps the plain string comparison, whatever grants exist.
393
434
  #[test]
394
435
  fn a_bare_role_only_caller_is_unaffected_by_any_real_grant_data() {
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hecks
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.2
4
+ version: 3.0.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Young
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-01 00:00:00.000000000 Z
11
+ date: 2026-10-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: prism
@@ -336,6 +336,7 @@ files:
336
336
  - lib/hecks/fuzzing/sweep_depth.rb
337
337
  - lib/hecks/fuzzing/target_capabilities.rb
338
338
  - lib/hecks/fuzzing/value_generator.rb
339
+ - lib/hecks/gate/stages.yml
339
340
  - lib/hecks/grammar.rb
340
341
  - lib/hecks/grammar/evolve.rb
341
342
  - lib/hecks/grammar/expression.bluebook
@@ -346,6 +347,7 @@ files:
346
347
  - lib/hecks/hecks/adapters/codebase/codemods.rb
347
348
  - lib/hecks/hecks/adapters/codebase/conformance.rb
348
349
  - lib/hecks/hecks/adapters/codebase/corpus_tasks.rb
350
+ - lib/hecks/hecks/adapters/codebase/gate.rb
349
351
  - lib/hecks/hecks/adapters/codebase/gem_registry.rb
350
352
  - lib/hecks/hecks/adapters/codebase/kernel_tables.rb
351
353
  - lib/hecks/hecks/adapters/codebase/language.rb
@@ -728,6 +730,8 @@ files:
728
730
  - lib/hecks/three_zero/forms.yml
729
731
  - lib/hecks/tools.rb
730
732
  - lib/hecks/tools/argument_gate_matrix.rb
733
+ - lib/hecks/tools/ci_gate_decision.rb
734
+ - lib/hecks/tools/ci_gates.rb
731
735
  - lib/hecks/tools/comment_style.rb
732
736
  - lib/hecks/tools/corpus_report.rb
733
737
  - lib/hecks/tools/deploy_recipe.rb
@@ -737,6 +741,7 @@ files:
737
741
  - lib/hecks/tools/era_reattest.rb
738
742
  - lib/hecks/tools/evolve_run.rb
739
743
  - lib/hecks/tools/fuzz_sweep.rb
744
+ - lib/hecks/tools/gate.rb
740
745
  - lib/hecks/tools/heki_compaction.rb
741
746
  - lib/hecks/tools/hoist_local_givens.rb
742
747
  - lib/hecks/tools/journal_compaction.rb
@@ -833,6 +838,7 @@ files:
833
838
  - rust/host/src/log.rs
834
839
  - rust/host/src/main.rs
835
840
  - rust/host/src/mint.rs
841
+ - rust/host/src/needs.rs
836
842
  - rust/host/src/payments.rs
837
843
  - rust/host/src/payments/keystore.rs
838
844
  - rust/host/src/payments/tests.rs
@@ -961,6 +967,7 @@ files:
961
967
  - rust/src/kernel/json.rs
962
968
  - rust/src/kernel/mod.rs
963
969
  - rust/src/kernel/named_query.rs
970
+ - rust/src/kernel/needs.rs
964
971
  - rust/src/kernel/orchestrate.rs
965
972
  - rust/src/kernel/pattern.rs
966
973
  - rust/src/kernel/query_comparators.rs