hecks 3.0.3 → 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.
- checksums.yaml +4 -4
- data/exe/hecks +6 -2
- data/lib/hecks/bluebook/assembly/contracts.rb +3 -1
- data/lib/hecks/bluebook/assembly/marks.rb +3 -0
- data/lib/hecks/bluebook/command.rb +7 -5
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +1 -0
- data/lib/hecks/bluebook/dsl/command_builder.rb +42 -0
- data/lib/hecks/bluebook/expression/canonical_form.rb +12 -0
- data/lib/hecks/bluebook/expression/projection.json +21 -0
- data/lib/hecks/bluebook/meta_validator/shapes.rb +3 -0
- data/lib/hecks/cli/project_cli.rb +6 -2
- data/lib/hecks/deploy/bluebook/deploy.bluebook +34 -0
- data/lib/hecks/doors/cli_runner.rb +57 -12
- data/lib/hecks/doors/launcher_options.rb +23 -0
- data/lib/hecks/fuzzing/properties.rb +1 -1
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +11 -3
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +9 -7
- data/lib/hecks/gate/stages.yml +51 -0
- data/lib/hecks/grammar/expression.bluebook +6 -1
- data/lib/hecks/grammar/expression_operators.json +135 -0
- data/lib/hecks/hecks/adapters/codebase/gate.rb +37 -0
- data/lib/hecks/hecks/adapters/codebase/regeneration.rb +24 -4
- data/lib/hecks/hecks/adapters/codebase/source_tree.rb +3 -1
- data/lib/hecks/hecks/codebase.behaviors +12 -0
- data/lib/hecks/hecks/codebase.bluebook +194 -0
- data/lib/hecks/hecks/custodian.bluebook +2 -0
- data/lib/hecks/hecks/hecks.hecksagon +14 -0
- data/lib/hecks/hecks/hecks.world +3 -2
- data/lib/hecks/language/bluebook/command.bluebook +26 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +29 -1
- data/lib/hecks/language/oidc.json +5 -0
- data/lib/hecks/ports/clock.rb +3 -1
- data/lib/hecks/projections/deploy/lambda.rb +3 -4
- data/lib/hecks/quality_control/quality_control.bluebook +6 -0
- data/lib/hecks/runtime/command_interpreter.rb +6 -4
- data/lib/hecks/runtime/entity_interpreter.rb +5 -2
- data/lib/hecks/runtime/interpreting.rb +29 -0
- data/lib/hecks/runtime/invocation.rb +5 -1
- data/lib/hecks/runtime/loader.rb +16 -13
- data/lib/hecks/runtime.rb +11 -0
- data/lib/hecks/three_zero/forms.yml +1 -1
- data/lib/hecks/tools/ci_gate_decision.rb +109 -0
- data/lib/hecks/tools/ci_gates.rb +124 -0
- data/lib/hecks/tools/gate.rb +117 -0
- data/lib/hecks/tools.rb +3 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +6 -1
- data/lib/hecks.rb +12 -2
- data/rust/codegen/src/naming.rs +13 -2
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/dispatch.rs +207 -1
- data/rust/host/src/main.rs +1 -0
- data/rust/host/src/needs.rs +243 -0
- data/rust/host/src/resend.rs +52 -10
- data/rust/host/src/server.rs +7 -3
- data/rust/parser/src/canonical.rs +137 -2
- data/rust/parser/src/emit.rs +25 -19
- data/rust/parser/src/ir.rs +2 -0
- data/rust/parser/src/keywords.rs +2 -0
- data/rust/parser/src/main.rs +1 -0
- data/rust/parser/src/parse/command.rs +44 -0
- data/rust/project/naming.rb +4 -2
- data/rust/src/kernel/cli.rs +3 -0
- data/rust/src/kernel/mod.rs +1 -0
- data/rust/src/kernel/needs.rs +194 -0
- data/rust/src/kernel/orchestrate.rs +84 -0
- data/rust/src/kernel/repository.rs +43 -2
- metadata +9 -2
data/lib/hecks/tools.rb
CHANGED
|
@@ -19,7 +19,10 @@ module Hecks
|
|
|
19
19
|
"standardize_comments" => ["tools/comment_style", "CommentStyle"],
|
|
20
20
|
"standardize_comments_rust" => ["tools/rust_comment_style", "RustCommentStyle"],
|
|
21
21
|
"regen_codegen_domains" => ["tools/regeneration_run", "RegenerationRun"],
|
|
22
|
+
"gate" => ["tools/gate", "Gate"],
|
|
22
23
|
"argument_gate_matrix" => ["tools/argument_gate_matrix", "ArgumentGateMatrix"],
|
|
24
|
+
"project_ci_gates" => ["tools/ci_gates", "CiGates"],
|
|
25
|
+
"decide_ci_gate" => ["tools/ci_gate_decision", "CiGateDecision"],
|
|
23
26
|
"project_deploy" => ["tools/deploy_recipe", "DeployRecipe"],
|
|
24
27
|
"lint_deploy_recipes" => ["tools/deploy_recipe_lint", "DeployRecipeLint"],
|
|
25
28
|
"project_oidc" => ["tools/oidc_manifests", "OidcManifests"],
|
data/lib/hecks/version.rb
CHANGED
data/lib/hecks/vocabulary.rb
CHANGED
|
@@ -49,6 +49,10 @@ module Hecks
|
|
|
49
49
|
{"name"=>"build-dependencies"}.freeze,
|
|
50
50
|
{"name"=>"workspace"}.freeze
|
|
51
51
|
].freeze,
|
|
52
|
+
"CiGate" => [
|
|
53
|
+
{"name"=>"runtime_changed", "workflow"=>"ci.yml", "mode"=>"touches", "pattern"=>"^lib/hecks/runtime/", "push"=>"skip", "label"=>"does this change touch lib/hecks/runtime/**?"}.freeze,
|
|
54
|
+
{"name"=>"postgres_io_relevant_changed", "workflow"=>"ci-postgres-io-parallel.yml", "mode"=>"skips_unless", "pattern"=>"^(docs/|editors/|release/|deploy/|\\.claude/|\\.githooks/|rust/(parser|codegen|host|build|lsp|web|tests|project)/|rust/project\\.rb$|rust/project_rust_pipeline\\.rb$|\\.rubocop\\.yml$|\\.rubocop_todo\\.yml$|\\.mcp\\.json$|\\.rspec-local\\.example$|README\\.md$|CHANGELOG\\.md$|CONTRIBUTING\\.md$|SECURITY\\.md$|LICENSE$|\\.gitignore$)", "push"=>"before_sha", "label"=>"does this change touch anything rspec_postgres_io_parallel covers?"}.freeze
|
|
55
|
+
].freeze,
|
|
52
56
|
"Comparison" => [
|
|
53
57
|
{"symbol"=>">=", "compares_less_than"=>"true", "compares_equal"=>"false", "negated"=>"true"}.freeze,
|
|
54
58
|
{"symbol"=>"<=", "compares_less_than"=>"true", "compares_equal"=>"true", "negated"=>"false"}.freeze,
|
|
@@ -123,7 +127,8 @@ module Hecks
|
|
|
123
127
|
].freeze,
|
|
124
128
|
"NormalisationStrategy" => [
|
|
125
129
|
{"name"=>"collapse_whitespace"}.freeze,
|
|
126
|
-
{"name"=>"replace"}.freeze
|
|
130
|
+
{"name"=>"replace"}.freeze,
|
|
131
|
+
{"name"=>"scale_call"}.freeze
|
|
127
132
|
].freeze,
|
|
128
133
|
"Primitive" => [
|
|
129
134
|
{"name"=>"String"}.freeze,
|
data/lib/hecks.rb
CHANGED
|
@@ -27,8 +27,7 @@ require_relative "hecks/projector"
|
|
|
27
27
|
# every target registers itself as it loads, so this require is the
|
|
28
28
|
# installation of them.
|
|
29
29
|
require_relative "hecks/projections"
|
|
30
|
-
# After `Projector
|
|
31
|
-
# `Ports::Clock` (fills a staleness rule's `now` at the door) both exist.
|
|
30
|
+
# After `Projector`, which dispatches against the `:cli` projection.
|
|
32
31
|
require_relative "hecks/doors/cli_door"
|
|
33
32
|
require_relative "hecks/doors/cli_runner"
|
|
34
33
|
require_relative "hecks/storehouse"
|
|
@@ -76,6 +75,17 @@ module Hecks
|
|
|
76
75
|
Runtime.describe(path, shared: shared, environment: environment)
|
|
77
76
|
end
|
|
78
77
|
|
|
78
|
+
# Finishes a boot from declarations `describe` already loaded, so the domain's files are read
|
|
79
|
+
# once. See `Runtime::Loader.boot_described`.
|
|
80
|
+
#
|
|
81
|
+
# @param described [Runtime::Loader::Described] what `describe` answered
|
|
82
|
+
# @param install_doors [Boolean] install the `Widget::Item.Add`-style facade
|
|
83
|
+
# @param install_facade [Boolean, nil] the deprecated spelling of `install_doors`; warns
|
|
84
|
+
# @return [Runtime::Dispatcher, Runtime::RemoteDispatcher] dispatcher bound to the domain
|
|
85
|
+
def boot_described(described, install_doors: true, install_facade: nil)
|
|
86
|
+
Runtime.boot_described(described, install_doors: install_doors, install_facade: install_facade)
|
|
87
|
+
end
|
|
88
|
+
|
|
79
89
|
# Boots a domain from an explicit list of files (a `.bluebook`, its
|
|
80
90
|
# `.hecksagon`, optionally a `.world`) instead of a whole directory —
|
|
81
91
|
# see `Runtime::Loader.boot_files` for why this exists beside `boot`.
|
data/rust/codegen/src/naming.rs
CHANGED
|
@@ -125,13 +125,15 @@ fn plain_identifier(name: &str) -> bool {
|
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
/// Every declared `name` in the IR, in document order. The `fields` maps under `mutations` are
|
|
128
|
-
/// skipped: their keys are attribute names and their values are source text, not declarations.
|
|
128
|
+
/// skipped: their keys are attribute names and their values are source text, not declarations. So is
|
|
129
|
+
/// `translations`: an era edge names data paths (`attendee.first_name`, a backfill into a nested
|
|
130
|
+
/// value object), which the host applies to stored rows and which are never written into Rust.
|
|
129
131
|
fn declared_names<'a>(ir: &'a Json, out: &mut Vec<&'a str>) {
|
|
130
132
|
match ir {
|
|
131
133
|
Json::Object(pairs) => {
|
|
132
134
|
for (key, value) in pairs {
|
|
133
135
|
match (key.as_str(), value) {
|
|
134
|
-
("fields", _) => {}
|
|
136
|
+
("fields" | "translations", _) => {}
|
|
135
137
|
("name", Json::String(name)) => out.push(name),
|
|
136
138
|
_ => declared_names(value, out),
|
|
137
139
|
}
|
|
@@ -335,6 +337,15 @@ mod tests {
|
|
|
335
337
|
assert_eq!(unsafe_name_refusal("shop", &ir), None);
|
|
336
338
|
}
|
|
337
339
|
|
|
340
|
+
#[test]
|
|
341
|
+
fn ignores_data_paths_named_by_a_translation_edge() {
|
|
342
|
+
let ir = Json::parse(
|
|
343
|
+
r#"{"name":"Shop","aggregates":[{"name":"Pizza"}],"translations":[{"aggregates":[{"name":"Pizza","backfills":[{"name":"attendee.first_name"}]}]}]}"#,
|
|
344
|
+
)
|
|
345
|
+
.unwrap();
|
|
346
|
+
assert_eq!(unsafe_name_refusal("shop", &ir), None);
|
|
347
|
+
}
|
|
348
|
+
|
|
338
349
|
#[test]
|
|
339
350
|
fn refuses_an_aggregate_name_that_is_not_a_plain_identifier() {
|
|
340
351
|
assert!(reserved_name_refusal("shop", "shop", &["My-App"]).is_some());
|
data/rust/host/HECKS_RELEASE
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.0.
|
|
1
|
+
3.0.4
|
data/rust/host/src/dispatch.rs
CHANGED
|
@@ -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 })
|
|
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
|
data/rust/host/src/main.rs
CHANGED
|
@@ -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
|
+
}
|