diff --git a/Cargo.lock b/Cargo.lock index 5a5a8b03..5ae69742 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1311,6 +1311,7 @@ dependencies = [ "kiln-runtime", "kiln-sync", "log", + "serial_test", "wat", ] diff --git a/kiln-component/Cargo.toml b/kiln-component/Cargo.toml index d0bf9860..afb29af8 100644 --- a/kiln-component/Cargo.toml +++ b/kiln-component/Cargo.toml @@ -33,6 +33,9 @@ criterion = { version = "0.8", features = ["html_reports"] } # Build + decode synthetic components in unit tests (E5DC2 / #382). wat = "1.244" kiln-decoder = { workspace = true, features = ["std"] } +# Engine-driving tests contend on the global capability memory budget +# (see CLAUDE.md — such tests use serial_test). +serial_test = "3.4" [features] # By default, enable std to match kiln-runtime's default behavior diff --git a/kiln-component/src/components/component_instantiation.rs b/kiln-component/src/components/component_instantiation.rs index dbdaad63..786ee324 100644 --- a/kiln-component/src/components/component_instantiation.rs +++ b/kiln-component/src/components/component_instantiation.rs @@ -2449,6 +2449,43 @@ impl ComponentInstance { tracing::info!(?main_handle, "Main instance selected (exports _start)"); } + // SR-53 / #443: validate each `canon lift`'s declared param arity + // against the backing core function in the LIVE instantiated + // module — the exact function `call_direct_export` would execute + // on `main_handle`. A spec-invalid component whose lift declares + // a different param arity than its core function is REJECTED at + // load, matching `wasm-tools validate` / wasmtime ("lowered + // parameter types do not match parameter types of core + // function"). On this scalar-only direct-hosting path every + // scalar param lowers to exactly one core param, so the counts + // must be equal; lifts with non-scalar params cannot be invoked + // on this path at all (argument lowering fails loud), so no + // mismatch can execute either way. + for target in instance.direct_export_targets.values() { + let all_params_scalar = target + .params + .iter() + .all(|vt| Self::format_val_type_to_scalar_component_type(vt).is_ok()); + if !all_params_scalar { + continue; + } + let module = engine.get_instance(main_handle)?.module().clone(); + if let Some(func_idx) = module.find_function_by_name(&target.core_export_name) { + let signature = module.get_function_signature(func_idx).ok_or_else(|| { + Error::validation_error( + "lifted core export has no resolvable function signature", + ) + })?; + if signature.params.len() != target.params.len() { + return Err(Error::validation_error( + "canon lift parameter arity does not match the backing \ + core function's parameter count (spec-invalid \ + component; wasm-tools validate rejects this)", + )); + } + } + } + // Store the engine AFTER main_handle search (which may have swapped // the engine with a nested component's engine for P3 components) instance.runtime_engine = Some(Box::new(engine)); @@ -2892,8 +2929,20 @@ impl ComponentInstance { _ => None, }; - // Convert the resolved component-level result types into the canonical - // ABI `ComponentType` used by the callable function signature. + // Convert the resolved component-level param/result types into the + // canonical ABI `ComponentType` used by the callable function + // signature. Params were previously NOT resolved (always empty), + // which made `validate_function_args`'s arity check pass trivially + // and let a param-taking export run with zero-filled core + // parameters (SR-53, #443). + let resolved_params: Vec = match &direct_target { + Some(t) => t + .params + .iter() + .map(Self::format_val_type_to_scalar_component_type) + .collect::>>()?, + None => Vec::new(), + }; let resolved_returns: Vec = match &direct_target { Some(t) => t .returns @@ -2968,12 +3017,21 @@ impl ComponentInstance { handle: export.idx as FunctionHandle, signature: FunctionSignature { name: export_name.clone(), + // Real resolved param types so `validate_function_args` + // enforces argument arity instead of passing trivially + // against an always-empty param list (SR-53, #443). #[cfg(feature = "std")] - params: Vec::new(), + params: resolved_params.clone(), #[cfg(not(feature = "std"))] params: { use kiln_foundation::bounded::BoundedVec; - BoundedVec::new() + let mut pv = BoundedVec::new(); + for param in &resolved_params { + pv.push(param.clone()).map_err(|_| { + Error::validation_error("Too many function parameters") + })?; + } + pv }, // Real resolved result types so invocation knows how to // lift the core result (#344). @@ -3121,13 +3179,17 @@ impl ComponentInstance { if component_func_idx == export_func_idx { // Found the lift backing this export. Resolve its core export // name via the `core func` alias for `core_func_idx`, and its - // result types via the component function type at `type_idx`. + // param/result types via the component function type at + // `type_idx`. let core_export_name = Self::resolve_core_func_export_name(parsed, *core_func_idx)?; - let returns = match parsed.types.get(*type_idx as usize) { + let (params, returns) = match parsed.types.get(*type_idx as usize) { Some(ty) => match &ty.definition { - ComponentTypeDefinition::Function { results, .. } => results.clone(), + ComponentTypeDefinition::Function { params, results } => ( + params.iter().map(|(_, vt)| vt.clone()).collect::>(), + results.clone(), + ), _ => { return Err(Error::component_resource_lifecycle_error( "canon lift type index does not refer to a function type", @@ -3143,6 +3205,7 @@ impl ComponentInstance { return Ok(Some(crate::types::DirectExportTarget { core_export_name, + params, returns, })); } @@ -4514,6 +4577,19 @@ impl ComponentInstance { })? .clone(); + // SR-53 / #443: the supplied argument count must match the lift's + // declared parameter arity. This mirrors the result-arity check below; + // without it a param-taking export invoked with no args ran with + // zero-filled core parameters and its wrong result was reported as + // success. (`validate_function_args` performs the same check on the + // `call_function` path; this guards direct calls too.) + if args.len() != target.params.len() { + return Err(Error::runtime_type_mismatch( + "direct-hosting argument count does not match the lifted \ + component function parameter count", + )); + } + // Lower scalar component arguments to core WASM values. FAIL LOUD on // non-scalar arguments — only scalars are supported on this path. let wasm_args: Vec = args diff --git a/kiln-component/src/types.rs b/kiln-component/src/types.rs index 358d8fcd..507b8986 100644 --- a/kiln-component/src/types.rs +++ b/kiln-component/src/types.rs @@ -206,6 +206,9 @@ pub struct CommandEntry { pub struct DirectExportTarget { /// Export name of the backing core function (callable on the engine). pub core_export_name: String, + /// Component-level parameter types declared by the `canon lift`'s function + /// type. Supplied arguments must match this arity (SR-53, #443). + pub params: Vec, /// Component-level result types to lift the core result into. pub returns: Vec, } diff --git a/kiln-component/tests/direct_export_arity_tests.rs b/kiln-component/tests/direct_export_arity_tests.rs new file mode 100644 index 00000000..585560ca --- /dev/null +++ b/kiln-component/tests/direct_export_arity_tests.rs @@ -0,0 +1,112 @@ +//! Direct-hosting argument-arity enforcement tests (SR-53, #443). +//! +//! Two manifestations of the same missing check: +//! +//! 1. Invocation: `call_direct_export` validated only RESULT arity, and the +//! export's registered `FunctionSignature.params` was always empty — so +//! `validate_function_args` passed trivially and a param-taking lifted +//! export invoked with no args ran with ZERO-FILLED core params, its wrong +//! result reported as success. +//! 2. Load: a spec-invalid component whose `canon lift` declares a different +//! param arity than the backing core function was ACCEPTED and run, though +//! `wasm-tools validate` and wasmtime reject it. + +#![cfg(all(feature = "std", feature = "kiln-execution"))] + +use kiln_component::canonical_abi::ComponentValue; +use kiln_component::components::component_instantiation::ComponentInstance; + +/// A valid component: `add: func(a: u32, b: u32) -> u32` lifted from a core +/// `add (param i32 i32) (result i32)`. +fn valid_add_component() -> Vec { + wat::parse_str( + r#" + (component + (core module $m + (func (export "add") (param i32 i32) (result i32) + (i32.add (local.get 0) (local.get 1)))) + (core instance $i (instantiate $m)) + (func $add (param "a" u32) (param "b" u32) (result u32) + (canon lift (core func $i "add"))) + (export "add" (func $add))) + "#, + ) + .expect("valid fixture must assemble as a component") +} + +/// The #443 second manifestation: the lift declares ONE param but the backing +/// core function takes TWO. `wasm-tools validate` rejects this ("lowered +/// parameter types [I32] do not match parameter types [I32, I32] of core +/// function 0"); kilnd accepted and ran it. +fn param_arity_mismatch_component() -> Vec { + wat::parse_str( + r#" + (component + (core module $m + (func (export "add") (param i32 i32) (result i32) + (i32.add (local.get 0) (local.get 1)))) + (core instance $i (instantiate $m)) + (func $add (param "a" u32) (result u32) + (canon lift (core func $i "add"))) + (export "add" (func $add))) + "#, + ) + .expect("the invalid fixture is still syntactically well-formed WAT and must assemble") +} + +fn instantiate(bytes: &[u8]) -> kiln_error::Result { + let mut parsed = + Box::new(kiln_decoder::component::decode_component(bytes).expect("fixture must decode")); + ComponentInstance::from_parsed_with_handler(0, &mut parsed, None, None) +} + +/// SR-53 / #443: invoking a 2-param lifted export with NO arguments must be +/// an ERROR via the existing `validate_function_args` arity check — not a +/// zero-filled core invocation reported as success. Before the fix the +/// export's registered signature had empty params, so `&[]` passed +/// validation and the core `add` ran as add(0, 0). +#[test] +#[serial_test::serial] +fn direct_export_invoked_with_missing_args_errors() { + let mut instance = + instantiate(&valid_add_component()).expect("valid component must instantiate"); + assert!( + instance.call_function("add", &[], None).is_err(), + "invoking 'add' (2 params) with 0 arguments must fail loud, not run \ + with zero-filled core parameters" + ); +} + +/// Matching arity must still work AND compute the correct answer: +/// add(5, 3) = 8. Before the fix this direction was ALSO broken — the empty +/// registered param signature made `validate_function_args` reject the +/// correctly-supplied arguments (2 args vs 0 declared params). +#[test] +#[serial_test::serial] +fn direct_export_with_matching_args_computes_correct_answer() { + let mut instance = + instantiate(&valid_add_component()).expect("valid component must instantiate"); + let results = instance + .call_function( + "add", + &[ComponentValue::U32(5), ComponentValue::U32(3)], + None, + ) + .expect("matching-arity invocation must succeed"); + assert_eq!(results, vec![ComponentValue::U32(8)], "add(5, 3) must be 8"); +} + +/// SR-53 / #443 (load-time): a `canon lift` whose declared param arity +/// differs from the backing core function must be REJECTED at +/// decode/instantiation — matching wasm-tools and wasmtime — not accepted +/// and run. Before the fix `from_parsed_with_handler` succeeded and +/// `--invoke add` reported `✓ Execution completed successfully`. +#[test] +#[serial_test::serial] +fn canon_lift_param_arity_mismatch_rejected_at_load() { + assert!( + instantiate(¶m_arity_mismatch_component()).is_err(), + "a canon lift declaring 1 param over a 2-param core function must be \ + rejected at load, as wasm-tools validate and wasmtime do" + ); +} diff --git a/kiln-runtime/src/stackless/engine.rs b/kiln-runtime/src/stackless/engine.rs index 5599907b..f9f13b22 100644 --- a/kiln-runtime/src/stackless/engine.rs +++ b/kiln-runtime/src/stackless/engine.rs @@ -1794,40 +1794,22 @@ impl StacklessEngine { "[EXEC] Function parameter info" ); - // Add provided arguments - for (i, arg) in args.iter().enumerate() { - if i < expected_param_count { - locals.push(arg.clone()); - } + // SR-53 / #443: the argument count MUST match the function's + // declared parameter arity. The previous code zero-filled + // missing parameters (and silently truncated extras) — a + // banned masking fallback that turned wrong answers into + // reported successes (the #412 fabricated-reporting family). + // FAIL LOUD instead. + if args.len() != expected_param_count { + return Err(kiln_error::Error::runtime_type_mismatch( + "function invoked with wrong number of arguments: the \ + argument count must match the function's declared \ + parameter count (missing parameters are not zero-filled)", + )); } - // Pad with default values for missing parameters - if args.len() < expected_param_count { - for i in args.len()..expected_param_count { - let param_type = func_type.params.get(i) - .ok_or_else(|| kiln_error::Error::runtime_error( - "Parameter index out of bounds - type corrupted" - ))?; - let default_value = match param_type { - kiln_foundation::ValueType::I32 => Value::I32(0), - kiln_foundation::ValueType::I64 => Value::I64(0), - kiln_foundation::ValueType::F32 => Value::F32(FloatBits32(0)), - kiln_foundation::ValueType::F64 => Value::F64(FloatBits64(0)), - kiln_foundation::ValueType::V128 => Value::V128(V128 { bytes: [0u8; 16] }), - kiln_foundation::ValueType::FuncRef => Value::FuncRef(None), - kiln_foundation::ValueType::NullFuncRef => Value::FuncRef(None), - kiln_foundation::ValueType::TypedFuncRef(_, _) => Value::FuncRef(None), - kiln_foundation::ValueType::ExternRef => Value::ExternRef(None), - kiln_foundation::ValueType::ExnRef => Value::ExnRef(None), - kiln_foundation::ValueType::AnyRef => Value::ExternRef(None), - kiln_foundation::ValueType::EqRef => Value::I31Ref(None), - kiln_foundation::ValueType::I31Ref => Value::I31Ref(None), - kiln_foundation::ValueType::StructRef(_) => Value::StructRef(None), - kiln_foundation::ValueType::ArrayRef(_) => Value::ArrayRef(None), - _ => Value::I32(0), - }; - locals.push(default_value); - } + for arg in &args { + locals.push(arg.clone()); } #[cfg(feature = "tracing")] diff --git a/kilnd/src/lib.rs b/kilnd/src/lib.rs index 79d754c0..eb3e50b5 100644 --- a/kilnd/src/lib.rs +++ b/kilnd/src/lib.rs @@ -895,6 +895,44 @@ impl KilndEngine { let has_main = engine.has_function(instance, function_name).unwrap_or(false); if has_main { + // SR-53 / #443: reject an argument-arity mismatch BEFORE + // invoking. kilnd has no CLI mechanism to pass wasm function + // parameters (--wasi-arg is WASI argv, not wasm params), so a + // function with >= 1 declared parameter can only ever be + // called with missing arguments. The engine used to zero-fill + // them and the wrong result was reported as success (the #412 + // fabricated-reporting family). FAIL LOUD with an actionable + // message instead. + let declared_params = { + let inst = engine.get_instance(instance)?; + let func_idx = + inst.module().find_function_by_name(function_name).ok_or_else(|| { + Error::runtime_function_not_found("Function not found in exports") + })?; + inst.module() + .get_function_signature(func_idx) + .ok_or_else(|| { + Error::runtime_function_not_found( + "Function signature not found for export", + ) + })? + .params + .len() + }; + if declared_params != 0 { + // User-facing diagnostic on stderr: says WHY the call is + // rejected and that kilnd cannot supply wasm params yet. + eprintln!( + "Error: function '{}' expects {} argument(s) but none were \ + supplied; kilnd cannot yet pass wasm function parameters", + function_name, declared_params + ); + return Err(Error::runtime_type_mismatch( + "exported function expects arguments but none were supplied; \ + kilnd cannot yet pass wasm function parameters", + )); + } + // Propagate the engine's error verbatim so the actual cause // (e.g. "fuel exhausted", a trap message) reaches the user // instead of a generic "Function execution failed". diff --git a/kilnd/tests/arg_arity_tests.rs b/kilnd/tests/arg_arity_tests.rs new file mode 100644 index 00000000..9dd3a5e3 --- /dev/null +++ b/kilnd/tests/arg_arity_tests.rs @@ -0,0 +1,117 @@ +//! Argument-arity enforcement tests for kilnd function invocation (SR-53, #443). +//! +//! Bug repro (#412 fabricated-reporting family, function-invocation surface): +//! kilnd invoked a param-taking export with ZERO-FILLED arguments when params +//! could not be supplied and reported the resulting wrong value as SUCCESS +//! (`kilnd mod.wasm --function addone` printed `✓ returned I32(1)` — the +//! zero-filled `addone(0)` — where wasmtime computes `addone(5) = 6`). +//! +//! The fix is fail-loud: a param-taking function invoked without matching +//! arguments must return `Err`, never a zero-filled result reported as +//! success. Zero-param entry points must STILL run — the check is an +//! arity match, not "has params → always error". + +#![cfg(all(feature = "std", feature = "kiln-execution"))] + +use kilnd::{KilndConfig, KilndEngine}; + +/// Build a KilndEngine for an inline WAT module invoking `function_name`. +fn engine_for(wat_src: &str, function_name: Option<&str>) -> KilndEngine { + let wasm = wat::parse_str(wat_src).expect("test WAT must assemble"); + let mut config = KilndConfig::default(); + // KilndConfig::module_data is &'static [u8]; leak the test fixture. + config.module_data = Some(Box::leak(wasm.into_boxed_slice())); + config.function_name = function_name.map(str::to_owned); + KilndEngine::new(config).expect("KilndEngine construction must succeed") +} + +/// The exact module shape from issue #443. +const PARAM_MODULE: &str = r#"(module + (func (export "dbl") (param i32) (result i32) + (i32.mul (local.get 0) (i32.const 2))) + (func (export "addone") (param i32) (result i32) + (i32.add (local.get 0) (i32.const 1))) + (func (export "_start")))"#; + +/// SR-53 / #443: invoking a `(param i32) (result i32)` export with no +/// arguments must be an ERROR, not a zero-filled `Ok` reported as success. +/// Before the fix this returned Ok and printed `✓ returned I32(1)` +/// (= addone(0)) with exit 0. +#[test] +#[serial_test::serial] +fn param_taking_export_invoked_with_no_args_errors() { + let mut engine = engine_for(PARAM_MODULE, Some("addone")); + assert!( + engine.execute_module().is_err(), + "invoking 'addone' (1 param) with no supplied arguments must fail loud, \ + not run with a zero-filled parameter and report success" + ); +} + +/// A zero-param entry point must STILL run — the check is arity match, not +/// "has params → always error". +#[test] +#[serial_test::serial] +fn zero_param_start_still_runs() { + let mut engine = engine_for(PARAM_MODULE, None); + engine + .execute_module() + .expect("zero-param _start must still execute successfully"); +} + +/// A zero-param export WITH a result must still run and succeed. +#[test] +#[serial_test::serial] +fn zero_param_result_export_still_runs() { + let mut engine = engine_for( + r#"(module (func (export "answer") (result i32) (i32.const 42)) + (func (export "_start")))"#, + Some("answer"), + ); + engine + .execute_module() + .expect("zero-param result-returning export must still execute successfully"); +} + +/// Engine-level contract: the interpreter itself must reject an +/// argument-count mismatch instead of zero-filling missing params, and must +/// still compute the CORRECT answer when the right arguments are supplied. +/// Before the fix `execute(.., "dbl", &[])` returned Ok([I32(0)]) — the +/// zero-fill masking fallback in the stackless engine's locals init. +#[test] +#[serial_test::serial] +fn engine_rejects_missing_args_and_computes_correct_answer_with_args() { + use kiln_foundation::values::Value; + use kiln_runtime::engine::{CapabilityAwareEngine, CapabilityEngine, EnginePreset}; + + kiln_foundation::memory_init::MemoryInitializer::initialize() + .expect("memory system must initialize"); + + let wasm = wat::parse_str(PARAM_MODULE).expect("test WAT must assemble"); + let mut engine = + CapabilityAwareEngine::with_preset(EnginePreset::QM).expect("engine must construct"); + let module = engine.load_module(&wasm).expect("module must load"); + let instance = engine.instantiate(module).expect("module must instantiate"); + + // Missing argument: must fail loud, not zero-fill. + assert!( + engine.execute(instance, "dbl", &[]).is_err(), + "engine must reject a 1-param function invoked with 0 arguments" + ); + + // Extra argument: must also fail loud, not silently truncate. + assert!( + engine.execute(instance, "dbl", &[Value::I32(5), Value::I32(7)]).is_err(), + "engine must reject a 1-param function invoked with 2 arguments" + ); + + // Correct arity: must compute the CORRECT answer (dbl(5) = 10). + let results = engine + .execute(instance, "dbl", &[Value::I32(5)]) + .expect("matching-arity invocation must succeed"); + assert_eq!( + results, + vec![Value::I32(10)], + "dbl(5) must be 10 — the wasmtime-verified answer" + ); +} diff --git a/safety/requirements/functional-requirements/SR-53.yaml b/safety/requirements/functional-requirements/SR-53.yaml index 6fb7c38a..933ed5e9 100644 --- a/safety/requirements/functional-requirements/SR-53.yaml +++ b/safety/requirements/functional-requirements/SR-53.yaml @@ -2,12 +2,11 @@ artifacts: - id: SR-53 type: requirement title: kilnd rejects an arg-arity mismatch instead of zero-filling params and reporting a wrong result as success - status: proposed - description: 'kilnd invokes an exported function with ZERO-FILLED arguments when params cannot be supplied, and reports the resulting wrong value as success (exit 0) — argument arity is never checked. Measured (HEAD cab5b3a0): a (func (export "addone") (param i32) (result i32)) invoked as `kilnd mod.wasm --function addone` returns I32(1) (= addone(0), proving the missing param was zero-filled, not trapped) and prints "✓ completed successfully"; wasmtime --invoke addone mod.wasm 5 correctly returns 6. There is no CLI mechanism to pass wasm function params at all (--wasi-arg is WASI argv, not wasm params), so ANY function with >=1 param can only ever be called with zeroed args and its wrong result printed as a definite answer. Same "fabricated success" family as #412. Second manifestation of the same missing check: a spec-invalid component (a canon lift whose declared param arity differs from the backing core func) that wasm-tools validate + wasmtime REJECT is accepted and run by kilnd (rejects a result-type mismatch but not a param-arity mismatch). Root cause: execute_traditional_module (kilnd/src/lib.rs:913) always calls engine.execute(instance, fn, &[]) (empty args); the component path call_direct_export (kiln-component/src/components/component_instantiation.rs:4498) validates only result arity (:4541) and never calls the existing validate_function_args (:4447); the engine zero-fills missing params instead of trapping. Fix: reject at invocation when the target export param arity/types do not match the supplied args (call validate_function_args on BOTH paths) so a param-taking fn invoked with no args errors instead of returning a zero-filled result as success; and validate canon lift param arity against the backing core function during component decode. Optionally add a CLI flag to supply wasm params. Relates to SR-42. Issue #443.' + status: implemented + description: 'IMPLEMENTED (PR #460): argument arity is now enforced fail-loud on every invocation surface instead of zero-filling missing params and reporting the wrong result as success (the #412 fabricated-reporting family on the function-invocation surface). (1) The stackless engine''s locals init (kiln-runtime stackless/engine.rs execute_function_body) no longer pads missing params with type-default zeros nor truncates extras — args.len() != declared param count is an Err. (2) kilnd''s execute_traditional_module checks the target export''s declared param count BEFORE engine.execute and errors with an actionable message ("function ''X'' expects N argument(s) but none were supplied; kilnd cannot yet pass wasm function parameters") — no CLI arg-passing flag was added; zero-param entry points still run. (3) The component direct-hosting path resolves the canon lift''s REAL param types into DirectExportTarget.params and the export''s FunctionSignature.params, so the existing validate_function_args arity check fires, and call_direct_export checks param arity alongside its existing result-arity check. (4) A spec-invalid component whose canon lift declares a different param arity than the backing core function is REJECTED at load (from_parsed_internal validates each lift against the live instantiated core module''s signature), matching wasm-tools validate / wasmtime. Verified: param_taking_export_invoked_with_no_args_errors, zero_param_start_still_runs, zero_param_result_export_still_runs, engine_rejects_missing_args_and_computes_correct_answer_with_args (kilnd/tests/arg_arity_tests.rs); direct_export_invoked_with_missing_args_errors, direct_export_with_matching_args_computes_correct_answer, canon_lift_param_arity_mismatch_rejected_at_load (kiln-component/tests/direct_export_arity_tests.rs). Real-binary repro re-run: kilnd mod.wasm --function addone now exits 1 with the actionable message (was "✓ returned I32(1)", exit 0); kilnd --invoke add pc.wasm (mismatched lift) now rejected at load, exit 1. --- kilnd invokes an exported function with ZERO-FILLED arguments when params cannot be supplied and reports the resulting wrong value as SUCCESS (exit 0); argument arity is never checked on either the core-module or component path, and a canon lift with param arity differing from its backing core function is accepted though wasm-tools/wasmtime reject it. Issue #443.' tags: - kilnd - kiln-component - - invocation - arity - fabricated-success - correctness @@ -17,5 +16,5 @@ artifacts: provenance: created-by: ai model: claude-fable-5 - timestamp: 2026-07-21T09:00:00Z + timestamp: 2026-07-21T00:00:00Z release: v0.4.3