From 9a8ac228af686f4d1c09ec5fb557acee6c58297c Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 8 Sep 2026 16:56:03 +0200 Subject: [PATCH 01/54] Draft the execplan for the stdlib clock provider seam (7.1.1) Plan the injectable `ClockProvider` seam specified in the Netsukefile testing framework technical design section 5.2, so `now()` can be made deterministic without changing behaviour for manifest authors. The plan records the port shape, its ownership by `StdlibConfig`, the verification obligations with their negative controls, and the seam classification work ADR-008 and roadmap 7.1.1 require. Co-Authored-By: Claude Opus 5 (1M context) --- docs/execplans/7-1-1-clock-provider-seam.md | 1581 +++++++++++++++++++ 1 file changed, 1581 insertions(+) create mode 100644 docs/execplans/7-1-1-clock-provider-seam.md diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md new file mode 100644 index 000000000..614c43d35 --- /dev/null +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -0,0 +1,1581 @@ +# Add the clock provider seam to the stdlib time module (7.1.1) + +This ExecPlan (execution plan) is a living document. The sections +`Constraints`, `Tolerances (exception triggers)`, `Risks`, `Progress`, +`Surprises & discoveries`, `Decision log`, `Outcomes & retrospective`, +`Conformance basis`, and `Verification plan` must be kept up to date as work +proceeds. + +Status: DRAFT + +## Purpose / big picture + +Netsuke manifests may call the Jinja function `now()` to obtain the current +UTC timestamp. Today that function reads the host wall clock directly, so +nothing that renders a manifest containing `now()` can be made repeatable: a +test can only assert that the rendered value lies within a few seconds of the +real clock. + +After this change the wall-clock read becomes an *injectable seam*. A caller +that builds a `StdlibConfig` may supply a **clock provider** — a shared +closure returning the instant that `now()` should report — and every `now()` +call in that Jinja environment will return exactly that instant. A caller that +supplies nothing keeps today's behaviour precisely: `now()` reads the host +clock. + +Concretely, after this change a novice can observe the following. Given a test +that builds a `StdlibConfig` with a clock provider fixed at +`2026-06-08T12:00:00Z` and renders the template `{{ now().iso8601 }}`, the +rendered output is the exact string `2026-06-08T12:00:00Z`, every time, on +every machine. Rendering `{{ now(offset='+02:30').iso8601 }}` under the same +fixed clock yields exactly `2026-06-08T14:30:00+02:30` — the same instant, +re-expressed in another offset. + +This is a prerequisite refactor for the Netsukefile testing framework (roadmap +phase 7), which needs a deterministic clock before it can run manifest tests. +It is deliberately scoped as a deliverable in its own right: nothing in this +plan adds a `netsuke test` command, a test dialect, or a `src/testing` module. + +There is no user-visible change. A manifest author sees identical `now()` +behaviour before and after. + +## Definitions + +Terms used throughout, defined here so no prior reading is required. + +- **Seam.** A place where behaviour that would otherwise read the ambient + process environment (variables, clock, filesystem, network) is instead + supplied by the caller as a value or closure. Netsuke's seam vocabulary is + fixed by `docs/adr-008-environment-seam-taxonomy.md`. +- **Clock provider.** The seam introduced here: a shared, thread-safe closure + returning a `time::OffsetDateTime` representing "now". +- **Ambient / ambient fallback.** Reading the real host clock, which is what + happens when no provider is explicitly supplied. +- **MiniJinja.** The template engine Netsuke embeds (crate `minijinja`, + version 2.12.0). Manifest bodies are Jinja templates rendered by it. +- **Stdlib.** Netsuke's library of Jinja filters and functions registered into + a MiniJinja `Environment`; it lives under `src/stdlib/`. +- **`StdlibConfig`.** The struct at `src/stdlib/config/mod.rs:21` that carries + every knob the stdlib registration needs (workspace root, network policy, + `PATH` overrides, home directory, and — after this change — the clock). +- **Manifest-query mode.** A restricted registration used by + `netsuke help targets` that deliberately refuses host-dependent helpers, + including `now()`. See `src/stdlib/register.rs:135`. +- **Driven port / adapter.** Hexagonal-architecture vocabulary. A *driven + port* is an interface the domain requires from infrastructure; an *adapter* + implements it. `ClockProvider` is a driven port; the host-clock closure is + its production adapter. + +## Context and orientation + +Assume no prior knowledge of this repository. The relevant code is small and +concentrated. + +### Where the clock is read today + +`src/stdlib/time/mod.rs` owns the `now()` and `timedelta()` Jinja functions. +The current implementation reads the clock inline: + +```rust +/// Register time helpers with the environment. +pub(crate) fn register_functions(env: &mut Environment<'_>) { + env.add_function("now", |kwargs: Kwargs| now(&kwargs)); + register_query_functions(env); +} + +/// Register time helpers whose output does not depend on the current clock. +pub(crate) fn register_query_functions(env: &mut Environment<'_>) { + env.add_function("timedelta", |kwargs: Kwargs| timedelta(&kwargs)); +} + +fn now(kwargs: &Kwargs) -> Result { + let offset_spec: Option = kwargs.get("offset")?; + kwargs.assert_all_used()?; + + let mut timestamp = OffsetDateTime::now_utc(); + if let Some(raw) = offset_spec { + let parsed = parse_offset(&raw)?; + timestamp = timestamp.to_offset(parsed); + } + + Ok(Value::from_object(TimestampValue::new(timestamp))) +} +``` + +`OffsetDateTime::now_utc()` at `src/stdlib/time/mod.rs:62` is the single +wall-clock read for `now()`. It comes from the `time` crate (version 0.3.44); +the repository depends on neither `chrono` nor `jiff`. + +Note that `register_functions` currently takes no configuration at all. Every +other stdlib submodule already receives some: `path::register_filters(env, +config.home_directory().clone())`, `which::register(env, which_config)`, +`network::register_functions(env, impure, network_config)`, and +`command::register(env, impure, command_config)`. The time module is the +outlier, which is precisely the ADR-008 gap this work closes. + +### How registration is wired + +`src/stdlib/register.rs` orchestrates registration. The relevant function: + +```rust +pub fn register_with_config( + env: &mut Environment<'_>, + config: StdlibConfig, +) -> anyhow::Result { + register_legacy_boolean_formatter(env); + let state = StdlibState::default(); + register_read_only_helpers(env, &config); + time::register_functions(env); + let impure = state.impure_flag(); + let (network_config, command_config) = config.into_components(); + network::register_functions(env, Arc::clone(&impure), network_config); + command::register(env, impure, command_config); + Ok(state) +} +``` + +Two details matter. First, `time::register_functions(env)` is called *before* +`config.into_components()` consumes the configuration, so the clock can be +read from `&config` by reference and cloned, exactly as `home_directory()` +already is. Second, `register()` (the no-argument entry point at +`src/stdlib/register.rs:60`) builds a default `StdlibConfig` and delegates to +`register_with_config`, so it inherits whatever default the clock field takes. + +A separate, restricted path exists for manifest queries: + +```rust +pub(crate) fn register_manifest_query(env: &mut Environment<'_>) -> StdlibState { + let state = StdlibState::default(); + register_query_helpers(env); + time::register_query_functions(env); + register_disabled_query_helpers(env); + state +} +``` + +That path registers only the clock-independent `timedelta()`, and separately +installs a refusing stub for `now()` at `src/stdlib/register.rs:231`: + +```rust +env.add_function("now", |_kwargs: Kwargs| -> Result { + Err(manifest_query_operation_error("now")) +}); +``` + +This refusal is user-documented at `docs/users-guide.md:1178`, which states +that queries reject "the clock-dependent `now()` function". **The seam must +not leak a clock into manifest-query mode.** That is a hard constraint below, +and currently it has no regression test — this plan adds one. + +### The configuration struct + +`src/stdlib/config/mod.rs:19-48` defines: + +```rust +/// Configuration for registering Netsuke's standard library helpers. +#[derive(Debug, Clone)] +pub struct StdlibConfig { + workspace_root: Arc, + workspace_root_path: Option, + fetch_cache_relative: Utf8PathBuf, + network_policy: NetworkPolicy, + fetch_max_response_bytes: u64, + command_max_output_bytes: u64, + command_max_stream_bytes: u64, + which_cache_capacity: NonZeroUsize, + workspace_skip_dirs: Vec, + path_override: Option, + pathext_override: Option, + command_path_override: Option, + home_directory: HomeDirectory, +} +``` + +There is no `Default` implementation, derived or manual. Construction goes +through the fallible `StdlibConfig::new(Dir)` or +`StdlibConfig::from_current_dir()`. Every knob is then set by a consuming +builder taking `self` by value and returning `Self` or +`anyhow::Result`, for example `with_home_override` at +`src/stdlib/config/mod.rs:239` and `with_command_path_override` at +`src/stdlib/config/mod.rs:203`. New knobs follow that idiom. + +`#[derive(Debug, Clone)]` on this struct is load-bearing for this plan; see +`Decision log` entry D2. + +### The seam precedent to copy + +`src/manifest/env_reader.rs` is the closest existing analogue and should be +read before implementing. It defines the type alias, its production supplier, +and a disabled variant: + +```rust +/// Thread-safe environment reader supplied to the `env()` Jinja helper. +pub type EnvReader = Arc Result + Send + Sync>; + +/// Construct the process-backed environment reader used by production loads. +#[must_use] +pub fn process_env_reader() -> EnvReader { + let env = DefaultEnv; + Arc::new(move |key| env.raw(key).map_err(EnvReadError::from)) +} +``` + +Both the type alias and the supplier carry rustdoc examples that run as +doctests. The clock seam mirrors this shape, naming, and documentation +density. + +### Files a novice will touch + +| Path | Role | +| --- | --- | +| `src/stdlib/time/clock.rs` | New. The `ClockProvider` port, `system_clock()` adapter, and the `Clock` container. | +| `src/stdlib/time/mod.rs` | Declares `mod clock;`, re-exports the port, threads the clock into `now()`. | +| `src/stdlib/time/tests.rs` | Existing unit tests; fixture updated and new cases added. | +| `src/stdlib/config/mod.rs` | `StdlibConfig` gains the `clock` field, `with_clock`, and `clock()`. | +| `src/stdlib/config_tests.rs` | Unit coverage for the new builder and accessor. | +| `src/stdlib/mod.rs` | Re-exports `ClockProvider` and `system_clock` on the public stdlib surface. | +| `src/stdlib/register.rs` | Passes the clock to `time::register_functions`. | +| `tests/std_filter_tests/time_functions.rs` | New. Integration coverage through real `StdlibConfig` registration. | +| `tests/std_filter_tests/support.rs` | Gains a `stdlib_env_with_clock` helper. | +| `tests/std_filter_tests.rs` | Wires the new integration module (see the wiring contract below). | +| `tests/features/stdlib_time.feature` | New deterministic scenarios. | +| `tests/bdd/steps/stdlib/rendering.rs` | New `Given` step; `RenderConfig` gains a clock. | +| `docs/adr-008-environment-seam-taxonomy.md` | Addendum recording the seam classification. | +| `docs/developers-guide.md` | Seam ownership rules and module boundary entry. | +| `docs/netsuke-test-framework-technical-design.md` | §5.2 updated to record implemented state. | +| `docs/roadmap.md` | Mark 7.1.1 done, at the very end. | + +### The integration-test wiring contract + +`tests/integration_test_wiring_tests.rs` enforces that every module tree under +`tests/` is reachable from a Cargo test target, and that Cargo's discovered +test targets exactly match the `tests/*.rs` files on disk. For this plan the +practical consequence is narrow and mechanical: the new file +`tests/std_filter_tests/time_functions.rs` must be declared in +`tests/std_filter_tests.rs`, whose existing contents are a sorted list of +`#[path]` declarations: + +```rust +#[path = "std_filter_tests/support.rs"] +mod support; +#[path = "std_filter_tests/which_filter_common.rs"] +mod which_filter_common; +``` + +Add, in sorted position: + +```rust +#[path = "std_filter_tests/time_functions.rs"] +mod time_functions; +``` + +No new top-level `tests/*.rs` file is created by this plan, so the Cargo +target-discovery half of the contract is unaffected. + +### Relevant documentation and skills + +Read these before starting; each is cited where it bears on a specific step. + +- `docs/adr-008-environment-seam-taxonomy.md` — the three sanctioned seam + shapes and the rule for choosing between them. Mandatory. +- `docs/netsuke-test-framework-technical-design.md` §5.2 — the specification + this plan implements, including the exact `ClockProvider` type. Mandatory. +- `docs/reliable-testing-in-rust-via-dependency-injection.md` — the project's + rationale for injected seams over ambient reads. +- `docs/rust-testing-with-rstest-fixtures.md` — fixture and `#[case]` idioms. +- `docs/rust-doctest-dry-guide.md` — doctest conventions; the new public type + alias and supplier both carry runnable examples. +- `docs/developers-guide.md`, sections "Environment and template ports" and + "Internal support module boundaries" — where the new prose belongs. +- `docs/rstest-bdd-users-guide.md` — step-definition conventions for the new + `Given` step. +- `docs/documentation-style-guide.md` — ADR and prose conventions. +- Skills: `rust-router` (routing), `hexagonal-architecture` (port/adapter + boundary), `rust-unit-testing` (assertion shape), `proptest` (the offset + property), `rust-types-and-apis` (the newtype and alias decision), + `arch-decision-records` (the ADR addendum), `execplans` (this document). + +`docs/ortho-config-users-guide.md` is *not* applicable here and no +`ortho_config` change is required; see `Decision log` entry D6. + +## Constraints + +Hard invariants. Violating any of these requires escalation, not a workaround. + +1. **C1 — no behaviour change without a provider.** A `StdlibConfig` on which + `with_clock` was never called must produce a `now()` that reads the host + clock, with identical output shape, offset handling, and error messages to + the current implementation. +2. **C2 — manifest-query mode keeps refusing `now()`.** The restricted + registration at `src/stdlib/register.rs:135` must not receive, construct, + or consult a clock. The refusing stub at `src/stdlib/register.rs:231` and + its diagnostic must be unchanged. `docs/users-guide.md:1178` must remain + true without edit. +3. **C3 — the seam type is as specified.** The technical design fixes the + port as `Arc OffsetDateTime + Send + Sync>`. Do not substitute + a trait object, a generic parameter, or a different time type. `Send + + Sync` is required because MiniJinja's `add_function` demands it; this is a + compiler-enforced constraint, not a preference. +4. **C4 — `StdlibConfig` remains `Debug` and `Clone`.** The derive at + `src/stdlib/config/mod.rs:20` must survive. Widening it to a hand-written + thirteen-field `Debug` is not acceptable; see D2. +5. **C5 — no new external dependencies.** `time`, `minijinja`, `rstest`, + `googletest`, `pretty_assertions`, `proptest`, and `rstest-bdd` are all + already present. Adding a crate is an escalation trigger. +6. **C6 — the clock has exactly one owner.** `StdlibConfig` is the sole place + a clock is stored, per technical design §5.2. Do not add a parallel clock + parameter to `register()`, to manifest loading, or to any other entry + point. +7. **C7 — no ambient-state mutation in tests.** Per ADR-008's 2026-09-01 + addendum, `EnvLock`, `CwdGuard`, and `EnvVarGuard` are retired and no + sanctioned test mutates the harness process. Determinism comes from + injection; no new test may use `#[serial]` or mutate process globals. +8. **C8 — scope.** This plan delivers roadmap item 7.1.1 only. It must not + add `src/testing/`, a `netsuke test` command, `ManifestLoadOptions`, + `TemplateOverlays`, or a `StdlibRegistration::Test` variant. Those are + 7.1.2 and later. + +## Tolerances (exception triggers) + +Stop and escalate — do not improvise — when any threshold is reached. + +- **Scope.** More than 20 files changed, or more than 600 net lines added + across the whole change. This is a small, well-understood seam; substantial + overrun means the design was wrong. +- **Interface.** Any change to a *public* signature other than the three + additions this plan sanctions (`stdlib::ClockProvider`, + `stdlib::system_clock`, `StdlibConfig::with_clock` plus its accessor). In + particular, if `StdlibConfig::new` or `into_components` must change shape, + stop. +- **Dependencies.** Any new entry in `Cargo.toml`. Stop. +- **Iterations.** A given gate still failing after 3 fix attempts. Stop and + report the log path. +- **Lint thresholds.** If satisfying `clippy.toml`'s + `cognitive-complexity-threshold = 9`, `too-many-lines-threshold = 70`, or + `too-many-arguments-threshold = 4` requires restructuring code outside + `src/stdlib/time/` and `src/stdlib/config/`, stop. +- **Determinism.** If any newly added test proves flaky — passing and failing + across repeated runs of the same commit — stop. A flaky test in a plan whose + entire purpose is determinism indicates the seam is not actually threaded + through the path under test. +- **Ambiguity.** If the technical design and the observed code disagree in a + way that changes the seam's shape or ownership, stop and present options. + +## Risks + +- **Risk: `Arc` is not `Debug`, breaking `StdlibConfig`'s derive.** + Severity: high. Likelihood: certain (this *will* happen on first compile). + Mitigation: this is anticipated and designed for — the `Clock` newtype with + a hand-written `Debug` absorbs it, so `StdlibConfig` keeps its derive. See + D2. The precedent is `CommandEnv` at `src/runner/process/command_env.rs:73`. + +- **Risk: the clock is captured once at registration rather than consulted per + call.** Severity: high. Likelihood: medium. A natural but wrong + implementation reads the provider while building the closure and bakes the + resulting instant in. Under the fixed-clock tests this defect is invisible, + because a fixed provider returns the same value either way. Mitigation: the + sequenced-provider test (`OBL-3` below) exists specifically to catch it, and + is the designated negative control. + +- **Risk: the seam accidentally reaches manifest-query mode.** Severity: + medium. Likelihood: low. Mitigation: C2, plus a new regression test + (`OBL-5`) asserting `now()` still errors under `register_manifest_query`. No + such test exists today, so this risk is currently unguarded in the + repository. + +- **Risk: `time::register_functions` also registers `timedelta` via + `register_query_functions`.** Severity: low. Likelihood: medium. Threading a + clock naively could push the clock into the query-function path. Mitigation: + change only the `now` registration; leave `register_query_functions` + signature untouched. + +- **Risk: the existing `now_defaults_to_utc` unit test and the BDD step + `assert_stdlib_output_is_utc_timestamp` compare against the real clock with + 3-second and 5-second tolerances.** Severity: low. Likelihood: low. + Mitigation: keep both. They are the ambient-fallback coverage C1 requires, + and their tolerances remain appropriate *because* those paths have no + injected clock. Do not "tighten" them; see D5. + +- **Risk: doctest breakage.** Severity: low. Likelihood: medium. The new + public alias and supplier carry rustdoc examples, and `make test` runs + doctests via a separate `doctest` target that nextest cannot execute. + Mitigation: run `make test` (not just `cargo nextest run`) at each + milestone. + +- **Risk: Markdown gate rejects the documentation edits.** Severity: low. + Likelihood: medium. `make check-fmt` enforces canonical Markdown formatting + over all `.md` files, including tables. Mitigation: run `make fmt` before + `make check-fmt`, and compare table *cells* rather than rendered rows. + +## Conformance basis + +Upstream artefacts governing this work, at the revisions present in the +working tree at branch point `924cb215`: + +- `docs/rfcs/0006-ansible-inspired-template-standard-library.md` §3.3 — + records the original gap: "**`now` has no injected clock seam.** It calls + `OffsetDateTime::now_utc()` directly. The time helpers proposed here are pure + and do not need the seam, but the gap is recorded because it bounds how far + time behaviour can be tested deterministically." +- `docs/rfcs/0006-ansible-inspired-template-standard-library.md` §16 open + question 7 — "**Does `now` need an injected clock seam?** … a future slice + that wants deterministic time tests will have to answer it." This plan is + that slice, and closing it answers the question affirmatively. +- `docs/rfcs/0007-netsukefile-testing-framework.md` — records the gap: + "a clock seam for `now()` (it calls the system clock directly)". +- `docs/netsuke-test-framework-technical-design.md` §5.2 "Clock (new seam)" — + the normative specification, including the exact port type and the statement + that the seam "lives in `StdlibConfig` … and is a prerequisite refactor + deliverable in its own right". +- `docs/netsuke-test-framework-technical-design.md` §13 — names + `src/stdlib/time/` (clock seam) and `src/stdlib/config/` (clock in + `StdlibConfig`) as the modules touched. +- `docs/netsuke-test-framework-technical-design.md` §14 phase 1 — places this + work in the first phase, alongside the overlay spike and the loader refactor. +- `docs/netsuke-test-framework-technical-design.md` §15 — requires this design + document to be updated in step when its decisions are implemented. +- `docs/adr-008-environment-seam-taxonomy.md` — the seam taxonomy and the + requirement to record a new seam's classification. +- `docs/roadmap.md` item 7.1.1 — the four acceptance bullets. +- `docs/users-guide.md:1178` — the user-facing statement that manifest queries + reject `now()`, which must remain true. + +There is no separate Terms of Reference document for this work; the roadmap +item and technical design §5.2 together serve that role. Do not invent one. + +Trace chain: + +```plaintext +RFC0006-3.3-GAP-clock -> RFC0006-Q7 -> TDD-5.2-ClockSeam -> EP-M4 -> docs/rfcs/0006 update +RFC0007-GAP-clock -> TDD-5.2-ClockSeam -> ROADMAP-7.1.1 -> EP-M1 -> OBL-1, OBL-2, OBL-3, OBL-4 +RFC0007-GAP-clock -> TDD-5.2-ClockSeam -> ROADMAP-7.1.1 -> EP-M2 -> OBL-6, OBL-7 +TDD-5.2-ClockSeam(single owner) -> ROADMAP-7.1.1(bullet 1) -> EP-M2 -> tests::std_filter_tests::time_functions +ADR008-TAXONOMY -> ROADMAP-7.1.1(bullet 4) -> EP-M4 -> docs/adr-008 addendum +USERGUIDE-1178(query refuses now) -> C2 -> EP-M2 -> OBL-5 +ROADMAP-7.1.1(bullet 2, preserve behaviour) -> C1 -> EP-M1 -> OBL-4 +``` + +Roadmap 7.1.1's four bullets map as follows. Bullet 1 (register `now()` +through an injected `ClockProvider` held in `StdlibConfig`) is discharged by +EP-M1 and EP-M2. Bullet 2 (preserve behaviour with no provider) is OBL-4. +Bullet 3 (test injected value, repeated calls, and ambient fallback, all +registered through `StdlibConfig`) is OBL-1, OBL-2, and OBL-4, exercised +through `StdlibConfig` in EP-M2. Bullet 4 (record the classification per +ADR-008) is EP-M4. + +## Verification plan + +The change is small but it introduces genuine invariants, and two of them have +plausible implementations that satisfy the obvious tests while being wrong. +The obligations below are chosen so that each can fail when the implementation +is wrong, and each names the mutation it must reject. + +### Axioms (assumed, not verified) + +These are third-party or platform behaviours treated as given. Do not write +tests for them. + +- **AX-1.** `time::OffsetDateTime::now_utc()` returns the host wall clock in + UTC. The `time` crate's correctness is assumed. +- **AX-2.** `OffsetDateTime::to_offset(o)` preserves the absolute instant and + changes only the representation, so `t.to_offset(o).unix_timestamp() == + t.unix_timestamp()` for every valid `o`. This is the documented contract of + the `time` crate. +- **AX-3.** MiniJinja's `Environment::add_function` requires its closure to be + `Send + Sync + 'static` and may invoke it from any thread. This is what + forces the `Arc` shape (ADR-008 states the same for `EnvReader`). +- **AX-4.** `Arc T + Send + Sync>` is `Clone` and `Send + Sync`, + and cloning shares one underlying closure rather than duplicating it. +- **AX-5.** `minijinja::Environment::compile_expression(..).eval(..)` invokes + a registered function once per textual occurrence of a call in the + expression. + +Repository-owned logic that builds on AX-2 and AX-3 *is* verified: OBL-6 +exercises offset preservation against the real `time` interface through the +real registration path rather than against a stub. + +### Invariants and lemmas + +**OBL-1 — Injected instant is reported verbatim.** + +- Obligation: with a provider returning fixed instant `T`, evaluating `now()` + yields a timestamp equal to `T`, including its UTC offset. +- Method: parameterized unit test (`rstest`) plus an integration test through + `StdlibConfig`. +- Rationale: a finite, fully enumerable partition — there is one behaviour to + pin. Property testing would add nothing. +- Domain: at least three distinct fixed instants, including one far from the + present day so the assertion cannot accidentally pass against the real + clock. +- Artefact: `src/stdlib/time/tests.rs::now_uses_injected_clock`; + `tests/std_filter_tests/time_functions.rs::now_uses_configured_clock`. +- Evidence: fails before the change with a compile error (no `with_clock` + exists), passes after. Assertion is exact equality via + `googletest::assert_that!(captured, eq(fixed))`. +- Non-vacuity: the fixed instant is `2026-06-08T12:00:00Z`, which differs from + the real clock by far more than any plausible test-runtime skew. The + designated mutation is "ignore the provider and call + `OffsetDateTime::now_utc()`"; that mutation makes the assertion fail by + years, not milliseconds. + +**OBL-2 — Repeated calls under a fixed provider agree.** + +- Obligation: within one Jinja environment, two `now()` evaluations under a + fixed provider return the same instant. +- Method: parameterized unit test plus integration test. +- Rationale: this is roadmap bullet 3's explicit "repeated `now()` calls + returning it" requirement, and it is the property a manifest author actually + relies on. +- Domain: two sequential evaluations in one environment, and one expression + containing two `now()` calls. +- Artefact: `src/stdlib/time/tests.rs::now_repeats_the_injected_instant`. +- Evidence: `assert_that!(first, eq(second))` and both equal to the fixed + instant. +- Non-vacuity: the mutation "read the ambient clock" is rejected because two + ambient reads separated by template evaluation may differ, and both differ + from the fixed instant regardless. Note this obligation alone is weak — it + is satisfied by the *wrong* implementation described in OBL-3 — which is why + OBL-3 exists. + +**OBL-3 — The provider is consulted on every call, not captured once.** + +- Obligation: the registered `now` function invokes the provider closure once + per `now()` evaluation. It must not read the provider while building the + closure and cache the resulting instant. +- Method: unit test with a *sequenced* provider — a closure over a shared + counter that returns a different, predetermined instant on each invocation. +- Rationale: this is the designated negative control for the highest-risk + defect in the change. No fixed-clock test can detect it, because a fixed + provider returns the same value whether it is called once or a thousand + times. Only a provider whose output varies distinguishes the two + implementations. +- Domain: three successive `now()` evaluations against a provider yielding + `T1`, `T2`, `T3` in order; plus an invocation-count assertion. +- Artefact: `src/stdlib/time/tests.rs::now_reads_the_provider_on_every_call` + and `src/stdlib/time/tests.rs::now_invokes_the_provider_once_per_call`. +- Evidence: the three evaluations return `T1`, `T2`, `T3` respectively, and + the recorded invocation count is exactly 3. +- Non-vacuity: the mutation "capture the instant at registration time" makes + all three evaluations return `T1` and the count 1 — rejected on both + assertions. The mutation "read the provider twice per call" (plausible if + the offset branch re-reads) makes the count 6 — rejected by the count + assertion. Both mutations are concrete and should be tried by hand once, in + a scratch commit that is then discarded, to confirm the tests actually fail. + +**OBL-4 — Ambient fallback preserves current behaviour.** + +- Obligation: a `StdlibConfig` on which `with_clock` was never called yields a + `now()` that reads the host clock and returns a UTC-offset timestamp. +- Method: parameterized unit test retained from the current suite, plus an + integration test constructing a `StdlibConfig` without `with_clock`. +- Rationale: this is constraint C1 and roadmap bullet 2. A tolerance-based + comparison against the real clock is the only available oracle for an + ambient read, and it is adequate: the failure mode being guarded against is + "the default is a frozen or wrong instant", which a seconds-scale tolerance + detects immediately. +- Domain: the default-constructed configuration; assertion that the rendered + instant is within 3 seconds of `OffsetDateTime::now_utc()` and carries + `UtcOffset::UTC`. +- Artefact: `src/stdlib/time/tests.rs::now_defaults_to_utc` (existing, + retained with its fixture updated); + `tests/std_filter_tests/time_functions.rs::now_without_a_clock_reads_the_host`. +- Evidence: passes before and after the change, unchanged in substance. +- Non-vacuity: the mutation "default the clock to a fixed epoch instant" + (a real hazard, since `Default` must be hand-written for a closure-bearing + type) is rejected — such a default is decades from now. This is the specific + reason the tolerance is seconds and not, say, a day. + +**OBL-5 — Manifest-query mode still refuses `now()`.** + +- Obligation: under `register_manifest_query`, evaluating `now()` returns the + manifest-query operation error, and no clock is constructed or consulted on + that path. +- Method: parameterized unit test asserting the error, plus a compile-time + argument (the query registration function takes no clock parameter, so it + cannot consult one). +- Rationale: this is constraint C2 and it is currently *unguarded* — no test + in the repository asserts the refusal today. The seam is exactly the kind of + change that could silently enable a helper the user guide promises is + disabled. +- Domain: `now()` and `now(offset='+02:00')` under query registration. +- Artefact: a new case in `src/stdlib/time/tests.rs`, or a new test beside the + existing manifest-query coverage in `src/manifest/render_tests.rs` if the + registration entry point is more naturally reachable there. Choose whichever + compiles without widening visibility; record the choice in `Decision log`. +- Evidence: the evaluation returns `Err`, and the error message matches the + `manifest_query_operation_error("now")` shape. +- Non-vacuity: the mutation "register the real clock-backed `now` in query + mode" makes the evaluation succeed — rejected. A witness that the test is + reaching real code: the *same* test asserts `timedelta()` still succeeds in + query mode, proving the environment is populated and the failure is specific + to `now`. + +**OBL-6 — Offset application preserves the injected instant.** + +- Obligation: for every valid UTC offset `o`, `now(offset=o)` under a provider + fixed at `T` yields a timestamp whose absolute instant equals `T` and whose + offset equals `o`. Formally: `render(now(offset=o)).unix_timestamp() == + T.unix_timestamp()` and `render(now(offset=o)).offset() == o`. +- Method: property test (`proptest`) over generated offsets, plus explicit + `#[case]` boundaries. +- Rationale: this is an invariant over a range of inputs — the offset domain — + rather than a finite partition, so a property test is the proportionate + method. It is also the only obligation that distinguishes "re-express the + instant in another offset" (correct) from "shift the instant by the offset" + (wrong, and an easy mistake). The repository already uses `proptest` for + offset parsing in this very file + (`parse_offset_accepts_hours_below_a_civil_day`), so the generator domain is + established precedent. +- Domain: offsets generated over the valid civil range, strictly less than one + day in magnitude — hours in `-23..=23`, minutes and seconds in `0..=59` — + matching the existing `parse_offset` property's domain. Explicit boundary + cases: `Z`, `+00:00`, `+02:30`, `-05:00`, `+23:59:59`, `-23:59:59`. +- Artefact: `src/stdlib/time/tests.rs::now_offset_preserves_the_instant` + (property) and `now_applies_offset_to_the_injected_instant` (cases). +- Evidence: `proptest` reports the configured case count with no shrunk + counterexample; the regression file under `proptest-regressions/` gains no + new entry. +- Non-vacuity: the generator must be shown to reach both signs and a non-zero + minute component — assert this by including the explicit boundary cases + above as ordinary `#[case]` tests alongside the property, so a + degenerate generator producing only `+00:00` cannot leave the invariant + untested. The designated mutation is replacing `timestamp.to_offset(parsed)` + with an arithmetic shift such as `timestamp + Duration::seconds(offset)`; + that mutation preserves `offset()` but breaks `unix_timestamp()`, and is + rejected by the first conjunct. A second mutation — dropping the offset + application entirely — preserves `unix_timestamp()` but breaks `offset()`, + and is rejected by the second conjunct. Both conjuncts are therefore + load-bearing; neither may be dropped. + +**OBL-7 — The seam reaches `now()` through real registration.** + +- Obligation: a clock supplied via `StdlibConfig::with_clock` is observable in + a template rendered through `stdlib::register_with_config` — that is, the + wiring from configuration to registered function actually connects. +- Method: integration test through the public API, plus a behavioural + (`rstest-bdd`) scenario through the stdlib rendering steps. +- Rationale: roadmap bullet 3 requires the tests be "registered through + `StdlibConfig`". This mirrors the two-layer argument the developers' guide + makes for `EnvReader`: unit tests cover the leaf function, but only an + integration test proves the provider actually *reaches* the registered + Jinja function. Covering the leaf alone would leave the wiring untested, + which is the whole point of the seam. +- Domain: one fixed instant rendered as `{{ now().iso8601 }}` and as + `{{ now(offset='+02:30').iso8601 }}`. +- Artefact: `tests/std_filter_tests/time_functions.rs`; + `tests/features/stdlib_time.feature` scenarios "A fixed clock makes now() + deterministic" and "A fixed clock renders now() with an offset at the same + instant". +- Evidence: exact string equality — `2026-06-08T12:00:00Z` and + `2026-06-08T14:30:00+02:30` respectively. +- Non-vacuity: the mutation "store the clock in `StdlibConfig` but never pass + it to `time::register_functions`" compiles cleanly and passes every unit + test in `src/stdlib/time/tests.rs` (which registers the clock directly), yet + fails these tests. That is precisely the wiring gap this obligation exists + to close, and it is the reason the integration layer is mandatory rather + than optional. + +### Methods deliberately not used + +- **Bounded model checking (Kani).** Rejected. The change introduces no + `unsafe` code, no arithmetic-overflow surface of its own, and no bounded + state machine. The only arithmetic is `to_offset`, which belongs to the + `time` crate and is AX-2. A Kani harness here would either restate AX-2 or + verify third-party internals, both of which this project's plans forbid. +- **Formal proof (Verus).** Rejected. No lemma is introduced whose guarantee + must hold over all admissible inputs beyond what OBL-6's property test + covers within the closed, finite offset domain. The offset domain is + finite and small (fewer than 2×86400 values); a property test over it is + not meaningfully weaker than a proof, and a proof would rest entirely on + AX-2 anyway. +- **State-machine model checking.** Rejected. There is no protocol, + concurrency, or ordering property. The provider is `Send + Sync` and pure + from the seam's perspective; OBL-3's counter is the only stateful fixture + and its assertions are direct. +- **Snapshot testing (`insta`).** Rejected for this change. Snapshots earn + their keep when output format is multivariant. `now()` renders one ISO-8601 + form, already covered by exact string equality in OBL-7, which is more + legible than a snapshot file. + +## Milestones and plateaus + +Each milestone ends in a repository state that compiles, passes all gates, and +is safe to stop at. + +### EP-M0 — Red tests (prototyping/red stage) + +- Outcome: failing tests that specify the seam, committed before any + production change. +- Requirements: none discharged; establishes the Red stage for OBL-1, OBL-2, + OBL-3, OBL-6. +- Acceptance evidence: `make test` fails, and it fails *for the intended + reason* — a compile error naming `with_clock` / `ClockProvider` as not + found, not an unrelated failure. +- Conformance check: no production code touched; no public interface changed. +- Recovery: `git revert` the single commit. +- Remaining gaps: everything. +- Compatibility decision: none required. + +Note on the Red stage: because the missing API causes a *compile* failure +rather than a test failure, the whole test target fails to build. That is an +acceptable Red signal here — the failure is unambiguous and names the missing +symbol. Record the exact compiler error in `Artefacts and notes`. Do not use +`#[ignore]` to sidestep it, and do not stub the API just to get a runtime +failure; that would weaken the Red evidence. + +### EP-M1 — The port, its adapter, and the leaf function + +- Outcome: `src/stdlib/time/clock.rs` exists with `ClockProvider`, + `system_clock()`, and `Clock`; `now()` reads through a `Clock` rather than + calling `OffsetDateTime::now_utc()` directly; + `time::register_functions` accepts a `Clock`. `StdlibConfig` is not yet + involved, so registration passes `Clock::default()`. +- Requirements: advances ROADMAP-7.1.1 bullets 1 and 2; discharges OBL-1, + OBL-2, OBL-3, OBL-4 (unit layer), OBL-6. +- Acceptance evidence: `make test` passes; the unit tests in + `src/stdlib/time/tests.rs` named in the obligations above all pass; the + pre-existing `now_defaults_to_utc`, `now_applies_custom_offset`, + `now_accepts_utc_shorthand`, and `now_rejects_invalid_offset` still pass + unmodified in substance. +- Conformance check: the port type matches technical design §5.2 verbatim + (C3); `StdlibConfig` still derives `Debug` and `Clone` (C4); no new + dependency (C5); manifest-query registration untouched (C2). +- Recovery: the milestone is one or two commits; revert to return to EP-M0. +- Remaining gaps: the clock is not yet configurable by callers — `StdlibConfig` + has no `with_clock`, so OBL-7 is not yet dischargeable. +- Compatibility decision: none. `time::register_functions` is `pub(crate)`; + its callers are updated in the same commit. No shim, no defaulted overload. + +### EP-M2 — Configuration ownership and integration coverage + +- Outcome: `StdlibConfig` owns the clock; `with_clock` and the accessor exist; + `register_with_config` threads it through; integration and behavioural tests + prove the wiring. +- Requirements: discharges ROADMAP-7.1.1 bullets 1, 2, and 3; discharges + OBL-4 (integration layer), OBL-5, OBL-7. +- Acceptance evidence: `make test` passes; the new + `tests/std_filter_tests/time_functions.rs` cases pass; the two new + `stdlib_time.feature` scenarios pass with exact string equality; the new + query-refusal test passes. +- Conformance check: the clock has exactly one owner (C6); manifest-query mode + still refuses `now()`, now with a regression test (C2); the users' guide + statement at line 1178 remains accurate without edit; no test mutates + ambient state or uses `#[serial]` (C7); scope has not crept into 7.1.2 + territory (C8). +- Recovery: revert to EP-M1, which is itself a coherent plateau (the seam + works; only caller configurability is absent). +- Remaining gaps: documentation. +- Compatibility decision: none. `StdlibConfig` gains a field and a builder; + both are additive and every construction site continues to compile because + the field is populated by `new()`. No external consumer exists — `netsuke` + is a binary crate whose library surface is pre-1.0 and consumed only by its + own tests. + +### EP-M3 — Gate hardening + +- Outcome: all four gates green with no warnings. +- Requirements: none new; protects everything already discharged. +- Acceptance evidence: `make check-fmt`, `make typecheck`, `make lint`, and + `make test` each exit zero, with logs captured under `/tmp`. +- Conformance check: `missing_docs`, `missing_docs_in_private_items`, + `must_use_candidate`, and `needless_pass_by_value` are all `deny` at + workspace level, so every new item — the alias, the supplier, the newtype, + its fields, the builder, and the accessor — carries rustdoc and, where + applicable, `#[must_use]`. +- Recovery: fixes are local; re-run the failing gate only. +- Remaining gaps: documentation. +- Compatibility decision: none. + +### EP-M4 — Documentation and seam classification + +- Outcome: the seam is recorded where ADR-008 and the technical design require. +- Requirements: discharges ROADMAP-7.1.1 bullet 4. +- Acceptance evidence: `docs/adr-008-environment-seam-taxonomy.md` has a dated + addendum classifying the clock seam and an `Implementation references` entry + for `src/stdlib/time/clock.rs`; `docs/developers-guide.md` documents + ownership and the module boundary; technical design §5.2 records the + implemented state; RFC 0006 §16 open question 7 is marked resolved with a + pointer to the ADR-008 addendum; `make check-fmt` passes over the Markdown. +- Conformance check: technical design §15's synchronization requirement is + satisfied; ADR-008's `Consequences` section — which requires the ADR and the + developers' guide sections to stay consistent — is honoured by editing both + in one commit; `docs/contents.md` needs no new entry because no new document + file is created; `docs/users-guide.md` is deliberately unchanged (D4). + RFC 0006 §3.3's recorded gap is now closed, and §16 question 7 answered. +- Recovery: documentation-only; revert freely. +- Remaining gaps: the roadmap checkbox, which is EP-M5. +- Compatibility decision: none. + +### EP-M5 — Roadmap closure + +- Outcome: `docs/roadmap.md` item 7.1.1 and its four sub-bullets marked `[x]`. +- Requirements: closes ROADMAP-7.1.1. +- Acceptance evidence: the roadmap entry reads `- [x] 7.1.1.` with all four + nested boxes ticked; `make check-fmt` passes. +- Conformance check: every bullet is genuinely satisfied by a named artefact + from `Verification plan`; no bullet is ticked on intent alone. +- Recovery: documentation-only. +- Remaining gaps: none. Plan status becomes `COMPLETE` after the reconciliation + described in `Outcomes & retrospective`. +- Compatibility decision: none. + +## Interfaces and dependencies + +Prescriptive. These signatures must exist at the end of EP-M2. + +In a new file `src/stdlib/time/clock.rs`: + +```rust +//! Wall-clock seam for the stdlib `now()` helper. + +use std::{fmt, sync::Arc}; + +use time::OffsetDateTime; + +/// Thread-safe wall-clock source supplied to the `now()` Jinja helper. +/// +/// `minijinja` requires registered functions to be `Send + Sync`, so the +/// provider is an `Arc` captured by the registered closure rather than a +/// borrowed parameter. See [ADR-008](../../../docs/adr-008-environment-seam-taxonomy.md). +pub type ClockProvider = Arc OffsetDateTime + Send + Sync>; + +/// Construct the host-backed clock provider used by production renders. +#[must_use] +pub fn system_clock() -> ClockProvider { + Arc::new(OffsetDateTime::now_utc) +} + +/// Clock source held by `StdlibConfig` and captured at registration. +#[derive(Clone)] +pub struct Clock(ClockProvider); + +impl Clock { + /// Wrap `provider` as the clock backing `now()`. + #[must_use] + pub fn new(provider: ClockProvider) -> Self { + Self(provider) + } + + /// Read the current instant from the provider. + pub(crate) fn read(&self) -> OffsetDateTime { + (self.0)() + } +} + +impl Default for Clock { + fn default() -> Self { + Self(system_clock()) + } +} + +/// Opaque `Debug` output: a closure has no meaningful representation. +impl fmt::Debug for Clock { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Clock").finish_non_exhaustive() + } +} +``` + +Both `ClockProvider` and `system_clock` carry runnable rustdoc examples, +mirroring `src/manifest/env_reader.rs`. A suitable example for the alias: + +```rust +/// # Examples +/// +/// ```rust +/// use netsuke::stdlib::ClockProvider; +/// use std::sync::Arc; +/// use time::macros::datetime; +/// +/// let fixed = datetime!(2026-06-08 12:00:00 UTC); +/// let clock: ClockProvider = Arc::new(move || fixed); +/// assert_eq!(clock(), fixed); +/// ``` +``` + +In `src/stdlib/time/mod.rs`: + +```rust +mod clock; +pub use self::clock::{Clock, ClockProvider, system_clock}; + +/// Register time helpers with the environment. +pub(crate) fn register_functions(env: &mut Environment<'_>, clock: Clock) { + env.add_function("now", move |kwargs: Kwargs| now(&kwargs, &clock)); + register_query_functions(env); +} + +fn now(kwargs: &Kwargs, clock: &Clock) -> Result { + let offset_spec: Option = kwargs.get("offset")?; + kwargs.assert_all_used()?; + + let mut timestamp = clock.read(); + if let Some(raw) = offset_spec { + let parsed = parse_offset(&raw)?; + timestamp = timestamp.to_offset(parsed); + } + + Ok(Value::from_object(TimestampValue::new(timestamp))) +} +``` + +`register_query_functions` keeps its existing signature and body. That is not +an oversight — it is C2. + +In `src/stdlib/config/mod.rs`, `StdlibConfig` gains one field and two methods: + +```rust + /// Wall-clock source backing the `now()` helper. + clock: Clock, +``` + +```rust + /// Replace the wall-clock source backing `now()`. + #[must_use] + pub fn with_clock(mut self, provider: ClockProvider) -> Self { + self.clock = Clock::new(provider); + self + } + + /// Wall-clock source backing the `now()` helper. + #[must_use] + pub(crate) fn clock(&self) -> &Clock { + &self.clock + } +``` + +`StdlibConfig::new` initializes the field with `Clock::default()`. +`into_components` is **not** changed: the clock is read by reference before +that call consumes the configuration, exactly as `home_directory()` is. + +In `src/stdlib/register.rs`, one line changes: + +```rust + time::register_functions(env, config.clock().clone()); +``` + +In `src/stdlib/mod.rs`, extend the existing re-export list so callers outside +the crate can build a provider: + +```rust +pub use self::time::{ClockProvider, system_clock}; +``` + +In `tests/std_filter_tests/support.rs`, beside `stdlib_env_with_home`: + +```rust + pub(crate) fn stdlib_env_with_clock( + provider: netsuke::stdlib::ClockProvider, + ) -> Result> { + let config = StdlibConfig::from_current_dir()?.with_clock(provider); + stdlib_env_with_config(config).map(|(env, _)| env) + } +``` + +No `Cargo.toml` change is permitted (C5). + +## Plan of work + +### Stage A — orientation (no code changes) + +Read, in order: technical design §5.2; ADR-008 in full, including its +addendum sections; `src/stdlib/time/mod.rs`; `src/manifest/env_reader.rs`; +`src/stdlib/config/mod.rs` lines 1–120; `src/stdlib/register.rs` lines 55–145; +`src/stdlib/time/tests.rs`; `src/runner/process/command_env.rs` lines 55–85 +for the manual-`Debug` precedent. + +Confirm three facts against the working tree before writing code, because the +whole design rests on them: that `StdlibConfig` derives `Debug`; that +`time::register_functions` is called before `config.into_components()`; and +that `register_manifest_query` installs a refusing `now` stub. If any has +changed, stop — the seam's shape may need revisiting. + +Validation for this stage: no build required; you have simply read the code. + +### Stage B — red tests + +Write the failing tests first. In `src/stdlib/time/tests.rs`, add the +fixtures and cases for OBL-1, OBL-2, OBL-3, and OBL-6, referencing the +not-yet-existing `Clock`, `ClockProvider`, and the two-argument +`register_functions`. Update the existing `env` fixture to the new signature. + +The existing fixture is: + +```rust +#[fixture] +fn env() -> Environment<'static> { + let mut env = Environment::new(); + register_functions(&mut env); + env +} +``` + +It becomes: + +```rust +#[fixture] +fn env() -> Environment<'static> { + let mut env = Environment::new(); + register_functions(&mut env, Clock::default()); + env +} + +/// Environment whose `now()` always reports `instant`. +fn env_with_fixed_clock(instant: OffsetDateTime) -> Environment<'static> { + let mut env = Environment::new(); + register_functions(&mut env, Clock::new(Arc::new(move || instant))); + env +} + +/// Environment whose `now()` reports each element of `instants` in turn, +/// recording how many times the provider was consulted. +fn env_with_sequenced_clock( + instants: Vec, +) -> (Environment<'static>, Arc) { + let calls = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&calls); + let provider: ClockProvider = Arc::new(move || { + let index = counter.fetch_add(1, Ordering::SeqCst); + instants[index.min(instants.len() - 1)] + }); + let mut env = Environment::new(); + register_functions(&mut env, Clock::new(provider)); + (env, calls) +} +``` + +The sequenced fixture is the OBL-3 negative control; it is deliberately +saturating rather than panicking past the end so an over-reading +implementation fails on the *count* assertion with a legible message rather +than on a panic. + +Use `googletest::prelude::*` with `assert_that!` for the equality assertions +and `pretty_assertions::assert_eq` where a plain equality diff is clearer, per +the conventions in `src/cli/discovery_layer_tests.rs`. Keep `#[rstest]` as the +outermost test attribute, followed by `#[case]` attributes, matching that +file. + +Validation: `make test 2>&1 | tee /tmp/test-netsuke-7-1-1-clock-provider-seam.out` +must fail with a compile error naming the missing items. Record the error text +in `Artefacts and notes`. Commit. + +### Stage C — implementation + +Create `src/stdlib/time/clock.rs` exactly as specified in `Interfaces and +dependencies`. Declare and re-export it from `src/stdlib/time/mod.rs`. Change +`register_functions` and `now` to take the clock. Update +`src/stdlib/register.rs` to pass `Clock::default()` for now — `StdlibConfig` +is not yet involved. This completes EP-M1. + +Then add the `clock` field, `with_clock`, and `clock()` to `StdlibConfig`; +initialize the field in `new()`; re-export `ClockProvider` and `system_clock` +from `src/stdlib/mod.rs`; change `src/stdlib/register.rs` to pass +`config.clock().clone()`. Add the integration tests and their support helper, +wire the new module into `tests/std_filter_tests.rs`, and add the BDD +scenarios and step. This completes EP-M2. + +For the BDD work specifically: `RenderConfig` and `extract_render_config` in +`tests/bdd/steps/stdlib/rendering.rs:28` gain a `clock: Option` +member sourced from a new `TestWorld` field, and +`render_template_with_context` applies it alongside the existing policy, home, +and limit overrides: + +```rust + if let Some(instant) = render_cfg.clock { + config = config.with_clock(Arc::new(move || instant)); + } +``` + +The new step follows the file's existing attribute conventions: + +```rust +#[given("the stdlib clock is fixed at {instant:string}")] +pub(crate) fn given_stdlib_clock_fixed_at(world: &TestWorld, instant: &str) -> Result<()> { + let parsed = parse_iso_timestamp(instant)?; + world.stdlib_clock.set_value(parsed); + Ok(()) +} +``` + +Add to `tests/features/stdlib_time.feature`: + +```gherkin + Scenario: A fixed clock makes now() deterministic + Given a stdlib workspace + And the stdlib clock is fixed at "2026-06-08T12:00:00Z" + When I render the stdlib template "{{ now().iso8601 }}" without context + Then the stdlib output equals "2026-06-08T12:00:00Z" + + Scenario: A fixed clock renders now() with an offset at the same instant + Given a stdlib workspace + And the stdlib clock is fixed at "2026-06-08T12:00:00Z" + When I render the stdlib template "{{ now(offset='+02:30').iso8601 }}" without context + Then the stdlib output equals "2026-06-08T14:30:00+02:30" +``` + +Both reuse the existing `Then the stdlib output equals` step, so no new +assertion step is needed. Scenarios are auto-discovered by +`scenarios!("tests/features", ...)` in `tests/bdd_tests.rs`; no registration +is required. + +Leave the existing scenario "Rendering now() yields a UTC timestamp" and its +5-second-tolerance step untouched: it is the ambient-fallback behavioural +coverage, and its tolerance is correct for a path with no injected clock. + +Validation after each of EP-M1 and EP-M2: `make test`, teeing to +`/tmp/test-netsuke-7-1-1-clock-provider-seam.out`. Commit at each milestone. + +### Stage D — gates, documentation, and closure + +Run the full gate sequence (EP-M3), then write the documentation (EP-M4), then +mark the roadmap (EP-M5). + +For EP-M4, the ADR-008 addendum entry should read along these lines, matching +the file's existing dated-addendum format and its plain, decision-first prose: + +> ### 2026-09-08: Stdlib clock seam +> +> The stdlib `now()` helper reads its instant through an injected +> `ClockProvider`, an `Arc OffsetDateTime + Send + Sync>` held by +> `StdlibConfig` and captured by the registered Jinja function. It takes the +> `EnvReader` shape, not a narrow closure and not `mockable::Env`, for the +> same reason `EnvReader` does: `minijinja` requires registered functions to +> be `Send + Sync`, so a borrowed closure parameter cannot satisfy the bound. +> `StdlibConfig` is the clock's single owner; `system_clock()` is the sole +> production supplier and the only place `OffsetDateTime::now_utc` is called +> for `now()`. Manifest-query registration receives no clock and keeps its +> refusing `now` stub. +> +> `mockable::Clock` was not used: it is typed in `chrono`, which this +> workspace does not depend on, so adopting it would add a second date-time +> crate to render one timestamp. `monotony`, already a dependency, abstracts +> only monotonic elapsed time and has no wall-clock type. + +Note the ADR's `Implementation references` list should gain an entry for +`src/stdlib/time/clock.rs`, and its `Consequences` section already requires +that the developers' guide sections stay consistent — so update +"Environment and template ports" in the same commit. + +Validation: `make check-fmt` after every Markdown edit; run `make fmt` first +if it complains. + +## Concrete steps + +All commands run from the repository root, +`/home/leynos/.lody/repos/github---leynos---netsuke/worktrees/351bfe82-830e-43c4-843d-afc411661788`. + +Confirm the branch before starting: + +```bash +git branch --show-current +``` + +```plaintext +7-1-1-clock-provider-seam +``` + +Gate commands, always teed so long output can be reviewed after the fact: + +```bash +make check-fmt 2>&1 | tee /tmp/check-fmt-netsuke-7-1-1-clock-provider-seam.out +make typecheck 2>&1 | tee /tmp/typecheck-netsuke-7-1-1-clock-provider-seam.out +make lint 2>&1 | tee /tmp/lint-netsuke-7-1-1-clock-provider-seam.out +make test 2>&1 | tee /tmp/test-netsuke-7-1-1-clock-provider-seam.out +``` + +Run them sequentially, never in parallel — the build cache is shared and +concurrent cargo invocations contend on the package-cache lock. Do not create +an isolated Cargo cache; if another job holds the lock, wait. + +To run only the time tests while iterating: + +```bash +cargo nextest run --all-features -E 'test(/stdlib::time/)' 2>&1 \ + | tee /tmp/test-netsuke-7-1-1-clock-provider-seam.out +``` + +Expected on success, approximately: + +```plaintext + Summary [ 0.412s] 18 tests run: 18 passed, 0 skipped +``` + +To run only the new integration and behavioural coverage: + +```bash +cargo nextest run --all-features -E 'binary(std_filter_tests) or binary(bdd_tests)' +``` + +Doctests are not executed by nextest and must be run through `make test`, +which chains `test-nextest` and `doctest`. The new rustdoc examples on +`ClockProvider` and `system_clock` are only exercised there. + +Commit after each milestone, using the repository's file-based commit-message +convention rather than `-m`. + +## Validation and acceptance + +Acceptance is behavioural, not structural. + +**Red evidence.** Before any production change, `make test` fails to compile +`src/stdlib/time/tests.rs` with errors naming `Clock`, `ClockProvider`, and +the arity of `register_functions`. Capture the first three compiler errors +verbatim in `Artefacts and notes`. + +**Green evidence, unit layer.** After EP-M1, `make test` passes. +`now_uses_injected_clock` renders a timestamp exactly equal to +`2026-06-08T12:00:00Z`. `now_reads_the_provider_on_every_call` observes three +distinct instants from three evaluations and an invocation count of exactly 3. +`now_defaults_to_utc` still passes against the real clock. + +**Green evidence, integration layer.** After EP-M2, rendering +`{{ now().iso8601 }}` in an environment built by +`StdlibConfig::from_current_dir()?.with_clock(fixed)` and registered through +`stdlib::register_with_config` produces the exact string +`2026-06-08T12:00:00Z`. Rendering `{{ now(offset='+02:30').iso8601 }}` under +the same configuration produces exactly `2026-06-08T14:30:00+02:30`. The two +new Gherkin scenarios pass. + +**Regression evidence.** `now()` under `register_manifest_query` still returns +the manifest-query operation error, while `timedelta()` under the same +registration still succeeds. + +**Mutation evidence (required, run once and discarded).** In a scratch commit +that is never pushed, apply each of the four designated mutations in turn and +confirm the named test fails for the intended reason: + +1. Replace `clock.read()` with `OffsetDateTime::now_utc()` → + `now_uses_injected_clock` fails. +2. Hoist the clock read out of `now` into `register_functions`, baking the + instant into the closure → `now_reads_the_provider_on_every_call` fails. +3. Replace `timestamp.to_offset(parsed)` with an arithmetic shift → + `now_offset_preserves_the_instant` fails on the `unix_timestamp` conjunct. +4. Store the clock on `StdlibConfig` but pass `Clock::default()` in + `register_with_config` → the integration and BDD tests fail while every + unit test still passes. + +Then `git reset --hard` back to the real commit. Record the four outcomes in +`Artefacts and notes`. Mutation 4 is the most important: it is the only one +that demonstrates the integration layer is load-bearing. + +**Quality criteria — what "done" means.** + +- Tests: `make test` exits zero, covering nextest and doctests. +- Verification: OBL-1 through OBL-7 discharged, each by its named artefact, + with the four mutations rejected. +- Lint and typecheck: `make lint` and `make typecheck` exit zero with warnings + denied. +- Formatting: `make check-fmt` exits zero, including the Markdown gate. +- Performance: no benchmark threshold applies. One `Arc` clone per stdlib + registration and one virtual call per `now()` evaluation are immaterial + against template rendering; do not add a benchmark. +- Security: none applicable. The seam removes an ambient read rather than + adding one, and exposes no new host state. + +**Quality method.** The four `make` gates, run sequentially, plus the one-off +mutation exercise above. Prefer delegating the full gate run to the +`scrutineer` subagent, which runs the gates sequentially, captures each log +under `/tmp`, and returns a bounded report. + +## Idempotence and recovery + +Every step is re-runnable. The `make` gates are read-only with respect to +tracked files except `make fmt`, which rewrites formatting deterministically — +running it twice changes nothing the second time. + +No step is destructive. There is no migration, no persisted format, and no +data to back up. Recovery at any point is `git revert` of the milestone commit +or `git reset --hard` to the previous milestone; each milestone is a coherent, +gate-passing plateau. + +The mutation exercise in `Validation and acceptance` is the only step that +deliberately breaks the tree. Perform it on a scratch commit and discard it +with `git reset --hard`; never push it. If interrupted mid-exercise, `git +status` will show the mutation, and `git checkout -- ` restores it. + +Working-tree cleanliness: this plan adds no build artefacts, no temporary +files inside the repository, and no `/tmp` output other than the gate logs +named above, which are disposable. + +## Progress + +- [ ] EP-M0 — Red tests committed; compile failure captured. +- [ ] EP-M1 — `clock.rs`, threaded `now()`, unit obligations discharged. +- [ ] EP-M2 — `StdlibConfig` ownership, integration and BDD coverage. +- [ ] EP-M3 — All four gates green. +- [ ] EP-M4 — ADR-008 addendum, developers' guide, technical design §5.2. +- [ ] EP-M5 — Roadmap 7.1.1 marked done. + +## Surprises & discoveries + +Recorded during planning; extend during implementation. + +- Observation: `mockable`, already a dependency and already the source of the + `Env` seam trait, does export a `Clock` trait with a `MockClock`. + Evidence: `https://docs.rs/mockable/3.0.0/mockable/trait.Clock.html` declares + `pub trait Clock: Send + Sync { fn local(&self) -> DateTime; fn utc(&self) + -> DateTime; }`. + Impact: it is unusable here — it is typed in `chrono`, and `chrono` appears + nowhere in `Cargo.lock`. Adopting it would add a second date-time crate + purely to render one timestamp, and would require converting to + `time::OffsetDateTime` at the boundary. Recorded so a future reviewer does + not re-raise it. See D3. + +- Observation: `monotony` (a dependency, used for elapsed-time telemetry) has + a `test-util` feature with deterministic clocks, which looks like a + ready-made answer. + Evidence: `https://docs.rs/monotony/1.0.0/monotony/` exports + `MonotonicClock`, `MonotonicClockExt`, `StdMonotonicClock`, and `test_util`; + its own summary is "Monotonic clock abstractions for deterministic + elapsed-time measurement". + Impact: not applicable — it abstracts monotonic elapsed time, not wall-clock + calendar instants. `now()` needs an `OffsetDateTime`. See D3. + +- Observation: no test anywhere in the repository asserts that `now()` is + refused in manifest-query mode, despite `docs/users-guide.md:1178` promising + it. + Evidence: the refusing stub at `src/stdlib/register.rs:231` has no + corresponding test; searching the test tree for query-mode `now()` coverage + returns nothing. + Impact: this plan adds that regression test (OBL-5). It is a pre-existing + gap, not one introduced here, but the clock seam is exactly the change that + could breach it silently. + +- Observation: RFC 0006 already recorded this exact gap and left it as a + numbered open question, which no roadmap item or design document + cross-references. + Evidence: `docs/rfcs/0006-ansible-inspired-template-standard-library.md` + §3.3 records "`now` has no injected clock seam", and §16 question 7 asks + "Does `now` need an injected clock seam? … a future slice that wants + deterministic time tests will have to answer it." + Impact: RFC 0006 joins the conformance basis, and closing its open question + becomes an EP-M4 deliverable. Without this, the work would ship leaving a + stale open question in an upstream artefact. + +- Observation: ADR-008's jurisdiction is narrower than its title implies — it + is scoped to environment *variables*, and no lint forbids reading the clock. + Evidence: the ADR's context section is written entirely about `clippy.toml`'s + ban on `std::env::var` and friends; `clippy.toml`'s `disallowed-methods` + list names only `std::env::*` and `std::env::set_current_dir`. + Impact: applying the taxonomy to a clock is an extension that must be stated + rather than assumed. See D11. + +- Observation: `StdlibConfig` derives `Debug`, which no closure-bearing field + can satisfy. + Evidence: `src/stdlib/config/mod.rs:20`. + Impact: forced the `Clock` newtype rather than a bare + `Option` field. See D2. + +## Decision log + +- **D1 — Port shape follows the `EnvReader` precedent verbatim.** + Decided: `ClockProvider = Arc OffsetDateTime + Send + Sync>`. + Rationale: fixed by technical design §5.2, and independently correct under + ADR-008's taxonomy. The taxonomy offers three shapes — a narrow closure + parameter, the `mockable::Env` trait, or an `Arc` closure — chosen by + call-site count and by whether the registration point requires `Send + + Sync`. MiniJinja's `add_function` requires `Send + Sync`, which ADR-008 + itself calls "a real constraint, not a preference", so the `Arc` closure is + the only admissible shape. A narrow closure cannot be captured by a + registered function; a trait object would add indirection with no extra + test-surface benefit for a single-call-site boundary. + Date/Author: 2026-09-08, planning. + +- **D2 — Wrap the provider in a `Clock` newtype with a hand-written `Debug`.** + Decided: `StdlibConfig` holds `clock: Clock`, not `clock: + Option`. + Rationale: `StdlibConfig` derives `Debug` (C4), and `Arc` is not + `Debug`, so a bare field would force a hand-written thirteen-field `Debug` + on `StdlibConfig` that would drift every time a knob is added. A one-field + newtype confines the hand-written impl to the one type that needs it. The + repository already does exactly this for `CommandEnv` + (`src/runner/process/command_env.rs:73`), which uses + `debug_struct(..).finish_non_exhaustive()`. Choosing a non-`Option` field + whose `Default` is `system_clock()` also mirrors `EnvReader` — which has no + `Option` either, using `process_env_reader()` as the production supplier — + and avoids an `Option` branch in `now()`, which the workspace's + `option_if_let_else = "deny"` lint would scrutinize. The public vocabulary + remains `ClockProvider` exactly as the design specifies; `Clock` is the + container, not a replacement for the port type. + Date/Author: 2026-09-08, planning. + +- **D3 — Do not use `mockable::Clock` or `monotony`.** + Decided: hand-roll the provider on the `time` crate. + Rationale: evidenced in `Surprises & discoveries`. `mockable::Clock` sits + behind that crate's `clock` feature, which this workspace does not enable, + and it is `chrono`-typed — `chrono` appears nowhere in `Cargo.lock`. Adopting + it would mean enabling a new feature, adding a second date-time crate, and + converting `chrono::DateTime` to `time::OffsetDateTime` at the boundary, + all to render one timestamp. That breaches C5. `monotony` covers monotonic + elapsed time only (`MonotonicClock::now() -> Instant`) and has no wall-clock + type, so it cannot express a UTC calendar instant at all. Both were checked + against their published API documentation rather than assumed. + Date/Author: 2026-09-08, planning. + +- **D4 — `docs/users-guide.md` is deliberately not modified.** + Decided: no users' guide change. + Rationale: the change is invisible to manifest authors. `now()` accepts the + same arguments, returns the same values, and fails the same way. The one + users' guide sentence that mentions `now()` — line 1178, stating that + manifest queries reject "the clock-dependent `now()` function" — remains + true and is protected by C2 and OBL-5. Editing it would imply a behaviour + change that has not occurred. If a reviewer disagrees, the correct response + is to add the users' guide entry when 7.2 exposes `given.clock.now` to + authors, not now. + Date/Author: 2026-09-08, planning. + +- **D5 — Keep the existing tolerance-based ambient assertions.** + Decided: `now_defaults_to_utc` (3-second tolerance) and the BDD step + `assert_stdlib_output_is_utc_timestamp` (5-second tolerance) are retained + unchanged. + Rationale: they are the ambient-fallback coverage C1 and OBL-4 require. A + tolerance is not a defect on a path that deliberately reads the real clock; + it is the only available oracle. Tightening them would make them flaky + without testing anything new. The determinism this plan delivers belongs to + the *injected* path, which gets exact-equality assertions instead. + Date/Author: 2026-09-08, planning. + +- **D6 — No `ortho_config` involvement.** + Decided: the clock is not a layered configuration option. + Rationale: `ortho_config` governs user-facing, layered CLI and file + configuration. The clock provider is an internal test seam — a closure, not + a serializable value — with no command-line flag, no configuration-file key, + and no localized help. Technical design §5.2 places it in `StdlibConfig`, + which is registration wiring, not the `ortho_config`-derived CLI + configuration struct. Exposing a clock override to end users would be a + behaviour change nobody has asked for and would breach C8. + Date/Author: 2026-09-08, planning. + +- **D7 — Two test layers, both mandatory.** + Decided: unit tests in `src/stdlib/time/tests.rs` *and* integration tests + through `StdlibConfig`. + Rationale: the developers' guide makes this argument explicitly for + `EnvReader`, and it applies unchanged: unit tests at the leaf cannot prove + the provider reaches the registered function, and an implementation that + stores the clock but never passes it to `time::register_functions` passes + every unit test. Roadmap bullet 3 independently requires the tests be + "registered through `StdlibConfig`". OBL-7's non-vacuity argument is built + on precisely this mutation. + Date/Author: 2026-09-08, planning. + +- **D8 — Verification stops at property tests; no Kani or Verus.** + Decided: `proptest` for the offset invariant, parameterized tests + elsewhere. + Rationale: recorded in full under "Methods deliberately not used". The + change adds no `unsafe`, no bounded state machine, and no lemma independent + of the `time` crate's documented `to_offset` contract (AX-2). A Kani harness + or Verus proof here would restate an axiom, which this project's plan + standard explicitly calls a vacuous discharge. + Date/Author: 2026-09-08, planning. + +- **D9 — Placement of the query-refusal regression test is left to the + implementor.** + Decided: put it wherever it compiles without widening visibility — either + `src/stdlib/time/tests.rs` or beside the existing manifest-query coverage in + `src/manifest/render_tests.rs` — and record the choice here. + Rationale: `register_manifest_query` is `pub(crate)`, so both locations are + reachable, but which is more natural depends on whether the test needs the + full manifest-render path or only the stdlib environment. Forcing the choice + from outside the code would risk prescribing a visibility widening, which + the tolerances forbid. This is a genuinely local judgement; make it and note + it. + Date/Author: 2026-09-08, planning. + +- **D10 — Rejected: a resolved-value enum in the `HomeDirectory` shape.** + Considered: `enum Clock { Ambient, Fixed(OffsetDateTime) }`, mirroring + `HomeDirectory` (`src/stdlib/config_types.rs:24`), which is the sibling seam + in this very module and which ADR-008 describes as the pattern that "injects + a resolved *value* rather than a closure". It would derive `Debug` and + `Clone` for free, removing the need for D2's hand-written impl entirely. + This is the strongest alternative and a reviewer should expect it to have + been weighed. + Decided: rejected, in favour of the `Arc` closure. + Rationale: three reasons, in decreasing order of weight. First, technical + design §5.2 specifies the `Arc` closure normatively and by exact type; + substituting an enum is an architecture deviation that would require + amending the design document and obtaining acceptance before implementation, + not a free local choice. Second — and this is the substantive objection — a + `Fixed(T)` enum makes OBL-3 *unwriteable*. The highest-risk defect in this + change is an implementation that reads the clock once at registration and + bakes the instant into the closure; the only way to detect it is a provider + whose successive calls return different values, which a resolved value + cannot express by construction. Choosing the enum would trade a hand-written + ten-line `Debug` impl for the loss of the plan's most important negative + control. Third, a closure keeps a future advancing or scripted clock + expressible without another redesign, whereas the enum would need a new + variant and a new match arm at every use site. The `Debug` objection the + enum answers is real but cheap to solve: the repository already has the + newtype-with-manual-`Debug` idiom in `CommandEnv` + (`src/runner/process/command_env.rs:73`). + If a reviewer nonetheless prefers the enum, that is an upstream change to + technical design §5.2 and must be settled before EP-M1, not during it. + Date/Author: 2026-09-08, planning. + +- **D11 — Extending ADR-008's jurisdiction from environment variables to the + clock is itself a decision, and is recorded as one.** + Decided: classify the clock seam under ADR-008 via a dated addendum, rather + than writing a separate ADR. + Rationale: ADR-008's stated problem is narrower than its title suggests. Its + context section is written entirely about `clippy.toml`'s ban on + `std::env::var` and friends, and no lint currently forbids + `OffsetDateTime::now_utc()`. The taxonomy therefore does not automatically + claim jurisdiction over clocks, and this plan must not silently assume it + does. An addendum is nonetheless the right vehicle: the ADR's decision text + is a rubric about *seam shapes chosen by call-site count and `Send + Sync` + need*, which transfers to any ambient input, and the file already accretes + dated addenda for exactly this kind of extension — see its 2026-08-30 + "Manifest glob base seam" entry, which likewise covers a + non-environment-variable boundary. Roadmap 7.1.1 bullet 4 also directs the + classification to ADR-008 by name. A separate ADR would fragment one rubric + across two documents. + The addendum must therefore say explicitly that the taxonomy is being + applied to a non-environment-variable ambient input, so a later reader is + not misled about the original scope. Whether `clippy.toml` should also + disallow `OffsetDateTime::now_utc` outside the seam is deliberately **out of + scope** here: such a lint would fail the build at sites this plan does not + touch, including `src/stdlib/time/tests.rs` and + `tests/bdd/steps/stdlib/assertions.rs`, both of which legitimately read the + real clock as a test oracle. Record it as a follow-up rather than doing it. + Date/Author: 2026-09-08, planning. + +## Outcomes & retrospective + +To be completed at EP-M5. + +Before setting this plan to `COMPLETE`, reconcile discoveries against the +conformance basis: + +- Update `docs/netsuke-test-framework-technical-design.md` §5.2 so it records + the implemented state rather than a proposal — in particular, the `Clock` + container (D2) is a mechanical addition the design did not name, and §15 + requires the document be kept in step. +- Confirm ADR-008's addendum records the classification, states that the + taxonomy is being applied to a non-environment-variable ambient input (D11), + and that its `Implementation references` list names + `src/stdlib/time/clock.rs`. +- Answer RFC 0006 §16 open question 7 ("Does `now` need an injected clock + seam?") in that document, and update its §3.3 gap entry to record that the + gap is closed. This plan is the "future slice" that question anticipated, so + leaving the question open after merging would be a stale upstream artefact. +- Confirm `docs/developers-guide.md`'s "Environment and template ports" + section and ADR-008 remain mutually consistent — ADR-008's own + `Consequences` section requires this. +- Confirm every roadmap 7.1.1 bullet maps to a passing named artefact. +- If any discovery falsified an assumption in the technical design or RFC + 0007, update that document rather than working around it, and record the + change here. + +Do not mark `COMPLETE` while any upstream change or deviation is unrecorded. + +## Artefacts and notes + +To be populated during implementation. Required entries: + +1. The Red-stage compiler error from Stage B (first three errors, verbatim). +2. The `make test` summary line at EP-M1 and EP-M2. +3. The four mutation outcomes from `Validation and acceptance`. +4. The final gate transcript tails for `check-fmt`, `typecheck`, `lint`, and + `test`. + +Keep each excerpt short — the summary line and the failing assertion, not the +whole log. The full logs live at the `/tmp` paths named in `Concrete steps`. From 95fd0c2df98d0371b918d1e122ed61de03feb7ca Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 8 Sep 2026 16:58:34 +0200 Subject: [PATCH 02/54] Apply canonical Markdown formatting to the 7.1.1 execplan Run the repository's Markdown formatter over the new plan and split an over-long trait declaration onto separate lines so the line-length lint passes. Co-Authored-By: Claude Opus 5 (1M context) --- docs/execplans/7-1-1-clock-provider-seam.md | 642 ++++++++++---------- 1 file changed, 306 insertions(+), 336 deletions(-) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index 614c43d35..af0e88446 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -1,7 +1,7 @@ # Add the clock provider seam to the stdlib time module (7.1.1) -This ExecPlan (execution plan) is a living document. The sections -`Constraints`, `Tolerances (exception triggers)`, `Risks`, `Progress`, +This ExecPlan (execution plan) is a living document. The sections `Constraints`, +`Tolerances (exception triggers)`, `Risks`, `Progress`, `Surprises & discoveries`, `Decision log`, `Outcomes & retrospective`, `Conformance basis`, and `Verification plan` must be kept up to date as work proceeds. @@ -10,18 +10,16 @@ Status: DRAFT ## Purpose / big picture -Netsuke manifests may call the Jinja function `now()` to obtain the current -UTC timestamp. Today that function reads the host wall clock directly, so -nothing that renders a manifest containing `now()` can be made repeatable: a -test can only assert that the rendered value lies within a few seconds of the -real clock. +Netsuke manifests may call the Jinja function `now()` to obtain the current UTC +timestamp. Today that function reads the host wall clock directly, so nothing +that renders a manifest containing `now()` can be made repeatable: a test can +only assert that the rendered value lies within a few seconds of the real clock. After this change the wall-clock read becomes an *injectable seam*. A caller -that builds a `StdlibConfig` may supply a **clock provider** — a shared -closure returning the instant that `now()` should report — and every `now()` -call in that Jinja environment will return exactly that instant. A caller that -supplies nothing keeps today's behaviour precisely: `now()` reads the host -clock. +that builds a `StdlibConfig` may supply a **clock provider** — a shared closure +returning the instant that `now()` should report — and every `now()` call in +that Jinja environment will return exactly that instant. A caller that supplies +nothing keeps today's behaviour precisely: `now()` reads the host clock. Concretely, after this change a novice can observe the following. Given a test that builds a `StdlibConfig` with a clock provider fixed at @@ -107,8 +105,9 @@ wall-clock read for `now()`. It comes from the `time` crate (version 0.3.44); the repository depends on neither `chrono` nor `jiff`. Note that `register_functions` currently takes no configuration at all. Every -other stdlib submodule already receives some: `path::register_filters(env, -config.home_directory().clone())`, `which::register(env, which_config)`, +other stdlib submodule already receives some: +`path::register_filters(env, config.home_directory().clone())`, +`which::register(env, which_config)`, `network::register_functions(env, impure, network_config)`, and `command::register(env, impure, command_config)`. The time module is the outlier, which is precisely the ADR-008 gap this work closes. @@ -135,9 +134,9 @@ pub fn register_with_config( ``` Two details matter. First, `time::register_functions(env)` is called *before* -`config.into_components()` consumes the configuration, so the clock can be -read from `&config` by reference and cloned, exactly as `home_directory()` -already is. Second, `register()` (the no-argument entry point at +`config.into_components()` consumes the configuration, so the clock can be read +from `&config` by reference and cloned, exactly as `home_directory()` already +is. Second, `register()` (the no-argument entry point at `src/stdlib/register.rs:60`) builds a default `StdlibConfig` and delegates to `register_with_config`, so it inherits whatever default the clock field takes. @@ -163,9 +162,9 @@ env.add_function("now", |_kwargs: Kwargs| -> Result { ``` This refusal is user-documented at `docs/users-guide.md:1178`, which states -that queries reject "the clock-dependent `now()` function". **The seam must -not leak a clock into manifest-query mode.** That is a hard constraint below, -and currently it has no regression test — this plan adds one. +that queries reject "the clock-dependent `now()` function". **The seam must not +leak a clock into manifest-query mode.** That is a hard constraint below, and +currently it has no regression test — this plan adds one. ### The configuration struct @@ -194,10 +193,10 @@ pub struct StdlibConfig { There is no `Default` implementation, derived or manual. Construction goes through the fallible `StdlibConfig::new(Dir)` or `StdlibConfig::from_current_dir()`. Every knob is then set by a consuming -builder taking `self` by value and returning `Self` or -`anyhow::Result`, for example `with_home_override` at -`src/stdlib/config/mod.rs:239` and `with_command_path_override` at -`src/stdlib/config/mod.rs:203`. New knobs follow that idiom. +builder taking `self` by value and returning `Self` or `anyhow::Result`, +for example `with_home_override` at `src/stdlib/config/mod.rs:239` and +`with_command_path_override` at `src/stdlib/config/mod.rs:203`. New knobs +follow that idiom. `#[derive(Debug, Clone)]` on this struct is load-bearing for this plan; see `Decision log` entry D2. @@ -221,29 +220,28 @@ pub fn process_env_reader() -> EnvReader { ``` Both the type alias and the supplier carry rustdoc examples that run as -doctests. The clock seam mirrors this shape, naming, and documentation -density. +doctests. The clock seam mirrors this shape, naming, and documentation density. ### Files a novice will touch -| Path | Role | -| --- | --- | -| `src/stdlib/time/clock.rs` | New. The `ClockProvider` port, `system_clock()` adapter, and the `Clock` container. | -| `src/stdlib/time/mod.rs` | Declares `mod clock;`, re-exports the port, threads the clock into `now()`. | -| `src/stdlib/time/tests.rs` | Existing unit tests; fixture updated and new cases added. | -| `src/stdlib/config/mod.rs` | `StdlibConfig` gains the `clock` field, `with_clock`, and `clock()`. | -| `src/stdlib/config_tests.rs` | Unit coverage for the new builder and accessor. | -| `src/stdlib/mod.rs` | Re-exports `ClockProvider` and `system_clock` on the public stdlib surface. | -| `src/stdlib/register.rs` | Passes the clock to `time::register_functions`. | -| `tests/std_filter_tests/time_functions.rs` | New. Integration coverage through real `StdlibConfig` registration. | -| `tests/std_filter_tests/support.rs` | Gains a `stdlib_env_with_clock` helper. | -| `tests/std_filter_tests.rs` | Wires the new integration module (see the wiring contract below). | -| `tests/features/stdlib_time.feature` | New deterministic scenarios. | -| `tests/bdd/steps/stdlib/rendering.rs` | New `Given` step; `RenderConfig` gains a clock. | -| `docs/adr-008-environment-seam-taxonomy.md` | Addendum recording the seam classification. | -| `docs/developers-guide.md` | Seam ownership rules and module boundary entry. | -| `docs/netsuke-test-framework-technical-design.md` | §5.2 updated to record implemented state. | -| `docs/roadmap.md` | Mark 7.1.1 done, at the very end. | +| Path | Role | +| ------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `src/stdlib/time/clock.rs` | New. The `ClockProvider` port, `system_clock()` adapter, and the `Clock` container. | +| `src/stdlib/time/mod.rs` | Declares `mod clock;`, re-exports the port, threads the clock into `now()`. | +| `src/stdlib/time/tests.rs` | Existing unit tests; fixture updated and new cases added. | +| `src/stdlib/config/mod.rs` | `StdlibConfig` gains the `clock` field, `with_clock`, and `clock()`. | +| `src/stdlib/config_tests.rs` | Unit coverage for the new builder and accessor. | +| `src/stdlib/mod.rs` | Re-exports `ClockProvider` and `system_clock` on the public stdlib surface. | +| `src/stdlib/register.rs` | Passes the clock to `time::register_functions`. | +| `tests/std_filter_tests/time_functions.rs` | New. Integration coverage through real `StdlibConfig` registration. | +| `tests/std_filter_tests/support.rs` | Gains a `stdlib_env_with_clock` helper. | +| `tests/std_filter_tests.rs` | Wires the new integration module (see the wiring contract below). | +| `tests/features/stdlib_time.feature` | New deterministic scenarios. | +| `tests/bdd/steps/stdlib/rendering.rs` | New `Given` step; `RenderConfig` gains a clock. | +| `docs/adr-008-environment-seam-taxonomy.md` | Addendum recording the seam classification. | +| `docs/developers-guide.md` | Seam ownership rules and module boundary entry. | +| `docs/netsuke-test-framework-technical-design.md` | §5.2 updated to record implemented state. | +| `docs/roadmap.md` | Mark 7.1.1 done, at the very end. | ### The integration-test wiring contract @@ -307,14 +305,14 @@ Hard invariants. Violating any of these requires escalation, not a workaround. clock, with identical output shape, offset handling, and error messages to the current implementation. 2. **C2 — manifest-query mode keeps refusing `now()`.** The restricted - registration at `src/stdlib/register.rs:135` must not receive, construct, - or consult a clock. The refusing stub at `src/stdlib/register.rs:231` and - its diagnostic must be unchanged. `docs/users-guide.md:1178` must remain - true without edit. + registration at `src/stdlib/register.rs:135` must not receive, construct, or + consult a clock. The refusing stub at `src/stdlib/register.rs:231` and its + diagnostic must be unchanged. `docs/users-guide.md:1178` must remain true + without edit. 3. **C3 — the seam type is as specified.** The technical design fixes the - port as `Arc OffsetDateTime + Send + Sync>`. Do not substitute - a trait object, a generic parameter, or a different time type. `Send + - Sync` is required because MiniJinja's `add_function` demands it; this is a + port as `Arc OffsetDateTime + Send + Sync>`. Do not substitute a + trait object, a generic parameter, or a different time type. `Send + Sync` + is required because MiniJinja's `add_function` demands it; this is a compiler-enforced constraint, not a preference. 4. **C4 — `StdlibConfig` remains `Debug` and `Clone`.** The derive at `src/stdlib/config/mod.rs:20` must survive. Widening it to a hand-written @@ -324,16 +322,15 @@ Hard invariants. Violating any of these requires escalation, not a workaround. already present. Adding a crate is an escalation trigger. 6. **C6 — the clock has exactly one owner.** `StdlibConfig` is the sole place a clock is stored, per technical design §5.2. Do not add a parallel clock - parameter to `register()`, to manifest loading, or to any other entry - point. + parameter to `register()`, to manifest loading, or to any other entry point. 7. **C7 — no ambient-state mutation in tests.** Per ADR-008's 2026-09-01 addendum, `EnvLock`, `CwdGuard`, and `EnvVarGuard` are retired and no sanctioned test mutates the harness process. Determinism comes from injection; no new test may use `#[serial]` or mutate process globals. 8. **C8 — scope.** This plan delivers roadmap item 7.1.1 only. It must not add `src/testing/`, a `netsuke test` command, `ManifestLoadOptions`, - `TemplateOverlays`, or a `StdlibRegistration::Test` variant. Those are - 7.1.2 and later. + `TemplateOverlays`, or a `StdlibRegistration::Test` variant. Those are 7.1.2 + and later. ## Tolerances (exception triggers) @@ -365,9 +362,9 @@ Stop and escalate — do not improvise — when any threshold is reached. - **Risk: `Arc` is not `Debug`, breaking `StdlibConfig`'s derive.** Severity: high. Likelihood: certain (this *will* happen on first compile). - Mitigation: this is anticipated and designed for — the `Clock` newtype with - a hand-written `Debug` absorbs it, so `StdlibConfig` keeps its derive. See - D2. The precedent is `CommandEnv` at `src/runner/process/command_env.rs:73`. + Mitigation: this is anticipated and designed for — the `Clock` newtype with a + hand-written `Debug` absorbs it, so `StdlibConfig` keeps its derive. See D2. + The precedent is `CommandEnv` at `src/runner/process/command_env.rs:73`. - **Risk: the clock is captured once at registration rather than consulted per call.** Severity: high. Likelihood: medium. A natural but wrong @@ -378,10 +375,9 @@ Stop and escalate — do not improvise — when any threshold is reached. is the designated negative control. - **Risk: the seam accidentally reaches manifest-query mode.** Severity: - medium. Likelihood: low. Mitigation: C2, plus a new regression test - (`OBL-5`) asserting `now()` still errors under `register_manifest_query`. No - such test exists today, so this risk is currently unguarded in the - repository. + medium. Likelihood: low. Mitigation: C2, plus a new regression test (`OBL-5`) + asserting `now()` still errors under `register_manifest_query`. No such test + exists today, so this risk is currently unguarded in the repository. - **Risk: `time::register_functions` also registers `timedelta` via `register_query_functions`.** Severity: low. Likelihood: medium. Threading a @@ -399,8 +395,7 @@ Stop and escalate — do not improvise — when any threshold is reached. - **Risk: doctest breakage.** Severity: low. Likelihood: medium. The new public alias and supplier carry rustdoc examples, and `make test` runs doctests via a separate `doctest` target that nextest cannot execute. - Mitigation: run `make test` (not just `cargo nextest run`) at each - milestone. + Mitigation: run `make test` (not just `cargo nextest run`) at each milestone. - **Risk: Markdown gate rejects the documentation edits.** Severity: low. Likelihood: medium. `make check-fmt` enforces canonical Markdown formatting @@ -409,8 +404,8 @@ Stop and escalate — do not improvise — when any threshold is reached. ## Conformance basis -Upstream artefacts governing this work, at the revisions present in the -working tree at branch point `924cb215`: +Upstream artefacts governing this work, at the revisions present in the working +tree at branch point `924cb215`: - `docs/rfcs/0006-ansible-inspired-template-standard-library.md` §3.3 — records the original gap: "**`now` has no injected clock seam.** It calls @@ -455,20 +450,19 @@ USERGUIDE-1178(query refuses now) -> C2 -> EP-M2 -> OBL-5 ROADMAP-7.1.1(bullet 2, preserve behaviour) -> C1 -> EP-M1 -> OBL-4 ``` -Roadmap 7.1.1's four bullets map as follows. Bullet 1 (register `now()` -through an injected `ClockProvider` held in `StdlibConfig`) is discharged by -EP-M1 and EP-M2. Bullet 2 (preserve behaviour with no provider) is OBL-4. -Bullet 3 (test injected value, repeated calls, and ambient fallback, all -registered through `StdlibConfig`) is OBL-1, OBL-2, and OBL-4, exercised -through `StdlibConfig` in EP-M2. Bullet 4 (record the classification per -ADR-008) is EP-M4. +Roadmap 7.1.1's four bullets map as follows. Bullet 1 (register `now()` through +an injected `ClockProvider` held in `StdlibConfig`) is discharged by EP-M1 and +EP-M2. Bullet 2 (preserve behaviour with no provider) is OBL-4. Bullet 3 (test +injected value, repeated calls, and ambient fallback, all registered through +`StdlibConfig`) is OBL-1, OBL-2, and OBL-4, exercised through `StdlibConfig` in +EP-M2. Bullet 4 (record the classification per ADR-008) is EP-M4. ## Verification plan The change is small but it introduces genuine invariants, and two of them have -plausible implementations that satisfy the obvious tests while being wrong. -The obligations below are chosen so that each can fail when the implementation -is wrong, and each names the mutation it must reject. +plausible implementations that satisfy the obvious tests while being wrong. The +obligations below are chosen so that each can fail when the implementation is +wrong, and each names the mutation it must reject. ### Axioms (assumed, not verified) @@ -478,17 +472,16 @@ tests for them. - **AX-1.** `time::OffsetDateTime::now_utc()` returns the host wall clock in UTC. The `time` crate's correctness is assumed. - **AX-2.** `OffsetDateTime::to_offset(o)` preserves the absolute instant and - changes only the representation, so `t.to_offset(o).unix_timestamp() == - t.unix_timestamp()` for every valid `o`. This is the documented contract of - the `time` crate. + changes only the representation, so + `t.to_offset(o).unix_timestamp() == t.unix_timestamp()` for every valid `o`. + This is the documented contract of the `time` crate. - **AX-3.** MiniJinja's `Environment::add_function` requires its closure to be `Send + Sync + 'static` and may invoke it from any thread. This is what forces the `Arc` shape (ADR-008 states the same for `EnvReader`). - **AX-4.** `Arc T + Send + Sync>` is `Clone` and `Send + Sync`, and cloning shares one underlying closure rather than duplicating it. - **AX-5.** `minijinja::Environment::compile_expression(..).eval(..)` invokes - a registered function once per textual occurrence of a call in the - expression. + a registered function once per textual occurrence of a call in the expression. Repository-owned logic that builds on AX-2 and AX-3 *is* verified: OBL-6 exercises offset preservation against the real `time` interface through the @@ -505,8 +498,7 @@ real registration path rather than against a stub. - Rationale: a finite, fully enumerable partition — there is one behaviour to pin. Property testing would add nothing. - Domain: at least three distinct fixed instants, including one far from the - present day so the assertion cannot accidentally pass against the real - clock. + present day so the assertion cannot accidentally pass against the real clock. - Artefact: `src/stdlib/time/tests.rs::now_uses_injected_clock`; `tests/std_filter_tests/time_functions.rs::now_uses_configured_clock`. - Evidence: fails before the change with a compile error (no `with_clock` @@ -533,8 +525,8 @@ real registration path rather than against a stub. instant. - Non-vacuity: the mutation "read the ambient clock" is rejected because two ambient reads separated by template evaluation may differ, and both differ - from the fixed instant regardless. Note this obligation alone is weak — it - is satisfied by the *wrong* implementation described in OBL-3 — which is why + from the fixed instant regardless. Note this obligation alone is weak — it is + satisfied by the *wrong* implementation described in OBL-3 — which is why OBL-3 exists. **OBL-3 — The provider is consulted on every call, not captured once.** @@ -557,10 +549,10 @@ real registration path rather than against a stub. the recorded invocation count is exactly 3. - Non-vacuity: the mutation "capture the instant at registration time" makes all three evaluations return `T1` and the count 1 — rejected on both - assertions. The mutation "read the provider twice per call" (plausible if - the offset branch re-reads) makes the count 6 — rejected by the count - assertion. Both mutations are concrete and should be tried by hand once, in - a scratch commit that is then discarded, to confirm the tests actually fail. + assertions. The mutation "read the provider twice per call" (plausible if the + offset branch re-reads) makes the count 6 — rejected by the count assertion. + Both mutations are concrete and should be tried by hand once, in a scratch + commit that is then discarded, to confirm the tests actually fail. **OBL-4 — Ambient fallback preserves current behaviour.** @@ -569,9 +561,9 @@ real registration path rather than against a stub. - Method: parameterized unit test retained from the current suite, plus an integration test constructing a `StdlibConfig` without `with_clock`. - Rationale: this is constraint C1 and roadmap bullet 2. A tolerance-based - comparison against the real clock is the only available oracle for an - ambient read, and it is adequate: the failure mode being guarded against is - "the default is a frozen or wrong instant", which a seconds-scale tolerance + comparison against the real clock is the only available oracle for an ambient + read, and it is adequate: the failure mode being guarded against is "the + default is a frozen or wrong instant", which a seconds-scale tolerance detects immediately. - Domain: the default-constructed configuration; assertion that the rendered instant is within 3 seconds of `OffsetDateTime::now_utc()` and carries @@ -614,8 +606,9 @@ real registration path rather than against a stub. - Obligation: for every valid UTC offset `o`, `now(offset=o)` under a provider fixed at `T` yields a timestamp whose absolute instant equals `T` and whose - offset equals `o`. Formally: `render(now(offset=o)).unix_timestamp() == - T.unix_timestamp()` and `render(now(offset=o)).offset() == o`. + offset equals `o`. Formally: + `render(now(offset=o)).unix_timestamp() == T.unix_timestamp()` and + `render(now(offset=o)).offset() == o`. - Method: property test (`proptest`) over generated offsets, plus explicit `#[case]` boundaries. - Rationale: this is an invariant over a range of inputs — the offset domain — @@ -636,16 +629,16 @@ real registration path rather than against a stub. counterexample; the regression file under `proptest-regressions/` gains no new entry. - Non-vacuity: the generator must be shown to reach both signs and a non-zero - minute component — assert this by including the explicit boundary cases - above as ordinary `#[case]` tests alongside the property, so a - degenerate generator producing only `+00:00` cannot leave the invariant - untested. The designated mutation is replacing `timestamp.to_offset(parsed)` - with an arithmetic shift such as `timestamp + Duration::seconds(offset)`; - that mutation preserves `offset()` but breaks `unix_timestamp()`, and is - rejected by the first conjunct. A second mutation — dropping the offset - application entirely — preserves `unix_timestamp()` but breaks `offset()`, - and is rejected by the second conjunct. Both conjuncts are therefore - load-bearing; neither may be dropped. + minute component — assert this by including the explicit boundary cases above + as ordinary `#[case]` tests alongside the property, so a degenerate generator + producing only `+00:00` cannot leave the invariant untested. The designated + mutation is replacing `timestamp.to_offset(parsed)` with an arithmetic shift + such as `timestamp + Duration::seconds(offset)`; that mutation preserves + `offset()` but breaks `unix_timestamp()`, and is rejected by the first + conjunct. A second mutation — dropping the offset application entirely — + preserves `unix_timestamp()` but breaks `offset()`, and is rejected by the + second conjunct. Both conjuncts are therefore load-bearing; neither may be + dropped. **OBL-7 — The seam reaches `now()` through real registration.** @@ -657,9 +650,9 @@ real registration path rather than against a stub. - Rationale: roadmap bullet 3 requires the tests be "registered through `StdlibConfig`". This mirrors the two-layer argument the developers' guide makes for `EnvReader`: unit tests cover the leaf function, but only an - integration test proves the provider actually *reaches* the registered - Jinja function. Covering the leaf alone would leave the wiring untested, - which is the whole point of the seam. + integration test proves the provider actually *reaches* the registered Jinja + function. Covering the leaf alone would leave the wiring untested, which is + the whole point of the seam. - Domain: one fixed instant rendered as `{{ now().iso8601 }}` and as `{{ now(offset='+02:30').iso8601 }}`. - Artefact: `tests/std_filter_tests/time_functions.rs`; @@ -669,11 +662,11 @@ real registration path rather than against a stub. - Evidence: exact string equality — `2026-06-08T12:00:00Z` and `2026-06-08T14:30:00+02:30` respectively. - Non-vacuity: the mutation "store the clock in `StdlibConfig` but never pass - it to `time::register_functions`" compiles cleanly and passes every unit - test in `src/stdlib/time/tests.rs` (which registers the clock directly), yet - fails these tests. That is precisely the wiring gap this obligation exists - to close, and it is the reason the integration layer is mandatory rather - than optional. + it to `time::register_functions`" compiles cleanly and passes every unit test + in `src/stdlib/time/tests.rs` (which registers the clock directly), yet fails + these tests. That is precisely the wiring gap this obligation exists to + close, and it is the reason the integration layer is mandatory rather than + optional. ### Methods deliberately not used @@ -683,15 +676,15 @@ real registration path rather than against a stub. `time` crate and is AX-2. A Kani harness here would either restate AX-2 or verify third-party internals, both of which this project's plans forbid. - **Formal proof (Verus).** Rejected. No lemma is introduced whose guarantee - must hold over all admissible inputs beyond what OBL-6's property test - covers within the closed, finite offset domain. The offset domain is - finite and small (fewer than 2×86400 values); a property test over it is - not meaningfully weaker than a proof, and a proof would rest entirely on - AX-2 anyway. + must hold over all admissible inputs beyond what OBL-6's property test covers + within the closed, finite offset domain. The offset domain is finite and + small (fewer than 2×86400 values); a property test over it is not + meaningfully weaker than a proof, and a proof would rest entirely on AX-2 + anyway. - **State-machine model checking.** Rejected. There is no protocol, concurrency, or ordering property. The provider is `Send + Sync` and pure - from the seam's perspective; OBL-3's counter is the only stateful fixture - and its assertions are direct. + from the seam's perspective; OBL-3's counter is the only stateful fixture and + its assertions are direct. - **Snapshot testing (`insta`).** Rejected for this change. Snapshots earn their keep when output format is multivariant. `now()` renders one ISO-8601 form, already covered by exact string equality in OBL-7, which is more @@ -709,8 +702,8 @@ is safe to stop at. - Requirements: none discharged; establishes the Red stage for OBL-1, OBL-2, OBL-3, OBL-6. - Acceptance evidence: `make test` fails, and it fails *for the intended - reason* — a compile error naming `with_clock` / `ClockProvider` as not - found, not an unrelated failure. + reason* — a compile error naming `with_clock` / `ClockProvider` as not found, + not an unrelated failure. - Conformance check: no production code touched; no public interface changed. - Recovery: `git revert` the single commit. - Remaining gaps: everything. @@ -727,9 +720,9 @@ failure; that would weaken the Red evidence. - Outcome: `src/stdlib/time/clock.rs` exists with `ClockProvider`, `system_clock()`, and `Clock`; `now()` reads through a `Clock` rather than - calling `OffsetDateTime::now_utc()` directly; - `time::register_functions` accepts a `Clock`. `StdlibConfig` is not yet - involved, so registration passes `Clock::default()`. + calling `OffsetDateTime::now_utc()` directly; `time::register_functions` + accepts a `Clock`. `StdlibConfig` is not yet involved, so registration passes + `Clock::default()`. - Requirements: advances ROADMAP-7.1.1 bullets 1 and 2; discharges OBL-1, OBL-2, OBL-3, OBL-4 (unit layer), OBL-6. - Acceptance evidence: `make test` passes; the unit tests in @@ -759,17 +752,16 @@ failure; that would weaken the Red evidence. query-refusal test passes. - Conformance check: the clock has exactly one owner (C6); manifest-query mode still refuses `now()`, now with a regression test (C2); the users' guide - statement at line 1178 remains accurate without edit; no test mutates - ambient state or uses `#[serial]` (C7); scope has not crept into 7.1.2 - territory (C8). + statement at line 1178 remains accurate without edit; no test mutates ambient + state or uses `#[serial]` (C7); scope has not crept into 7.1.2 territory (C8). - Recovery: revert to EP-M1, which is itself a coherent plateau (the seam works; only caller configurability is absent). - Remaining gaps: documentation. - Compatibility decision: none. `StdlibConfig` gains a field and a builder; both are additive and every construction site continues to compile because - the field is populated by `new()`. No external consumer exists — `netsuke` - is a binary crate whose library surface is pre-1.0 and consumed only by its - own tests. + the field is populated by `new()`. No external consumer exists — `netsuke` is + a binary crate whose library surface is pre-1.0 and consumed only by its own + tests. ### EP-M3 — Gate hardening @@ -800,8 +792,8 @@ failure; that would weaken the Red evidence. satisfied; ADR-008's `Consequences` section — which requires the ADR and the developers' guide sections to stay consistent — is honoured by editing both in one commit; `docs/contents.md` needs no new entry because no new document - file is created; `docs/users-guide.md` is deliberately unchanged (D4). - RFC 0006 §3.3's recorded gap is now closed, and §16 question 7 answered. + file is created; `docs/users-guide.md` is deliberately unchanged (D4). RFC + 0006 §3.3's recorded gap is now closed, and §16 question 7 answered. - Recovery: documentation-only; revert freely. - Remaining gaps: the roadmap checkbox, which is EP-M5. - Compatibility decision: none. @@ -978,26 +970,26 @@ No `Cargo.toml` change is permitted (C5). ### Stage A — orientation (no code changes) -Read, in order: technical design §5.2; ADR-008 in full, including its -addendum sections; `src/stdlib/time/mod.rs`; `src/manifest/env_reader.rs`; +Read, in order: technical design §5.2; ADR-008 in full, including its addendum +sections; `src/stdlib/time/mod.rs`; `src/manifest/env_reader.rs`; `src/stdlib/config/mod.rs` lines 1–120; `src/stdlib/register.rs` lines 55–145; -`src/stdlib/time/tests.rs`; `src/runner/process/command_env.rs` lines 55–85 -for the manual-`Debug` precedent. +`src/stdlib/time/tests.rs`; `src/runner/process/command_env.rs` lines 55–85 for +the manual-`Debug` precedent. Confirm three facts against the working tree before writing code, because the whole design rests on them: that `StdlibConfig` derives `Debug`; that -`time::register_functions` is called before `config.into_components()`; and -that `register_manifest_query` installs a refusing `now` stub. If any has -changed, stop — the seam's shape may need revisiting. +`time::register_functions` is called before `config.into_components()`; and that +`register_manifest_query` installs a refusing `now` stub. If any has changed, +stop — the seam's shape may need revisiting. Validation for this stage: no build required; you have simply read the code. ### Stage B — red tests -Write the failing tests first. In `src/stdlib/time/tests.rs`, add the -fixtures and cases for OBL-1, OBL-2, OBL-3, and OBL-6, referencing the -not-yet-existing `Clock`, `ClockProvider`, and the two-argument -`register_functions`. Update the existing `env` fixture to the new signature. +Write the failing tests first. In `src/stdlib/time/tests.rs`, add the fixtures +and cases for OBL-1, OBL-2, OBL-3, and OBL-6, referencing the not-yet-existing +`Clock`, `ClockProvider`, and the two-argument `register_functions`. Update the +existing `env` fixture to the new signature. The existing fixture is: @@ -1045,40 +1037,38 @@ fn env_with_sequenced_clock( ``` The sequenced fixture is the OBL-3 negative control; it is deliberately -saturating rather than panicking past the end so an over-reading -implementation fails on the *count* assertion with a legible message rather -than on a panic. +saturating rather than panicking past the end so an over-reading implementation +fails on the *count* assertion with a legible message rather than on a panic. -Use `googletest::prelude::*` with `assert_that!` for the equality assertions -and `pretty_assertions::assert_eq` where a plain equality diff is clearer, per -the conventions in `src/cli/discovery_layer_tests.rs`. Keep `#[rstest]` as the -outermost test attribute, followed by `#[case]` attributes, matching that -file. +Use `googletest::prelude::*` with `assert_that!` for the equality assertions and +`pretty_assertions::assert_eq` where a plain equality diff is clearer, per the +conventions in `src/cli/discovery_layer_tests.rs`. Keep `#[rstest]` as the +outermost test attribute, followed by `#[case]` attributes, matching that file. -Validation: `make test 2>&1 | tee /tmp/test-netsuke-7-1-1-clock-provider-seam.out` -must fail with a compile error naming the missing items. Record the error text -in `Artefacts and notes`. Commit. +Validation: +`make test 2>&1 | tee /tmp/test-netsuke-7-1-1-clock-provider-seam.out` must +fail with a compile error naming the missing items. Record the error text in +`Artefacts and notes`. Commit. ### Stage C — implementation -Create `src/stdlib/time/clock.rs` exactly as specified in `Interfaces and -dependencies`. Declare and re-export it from `src/stdlib/time/mod.rs`. Change -`register_functions` and `now` to take the clock. Update -`src/stdlib/register.rs` to pass `Clock::default()` for now — `StdlibConfig` -is not yet involved. This completes EP-M1. +Create `src/stdlib/time/clock.rs` exactly as specified in +`Interfaces and dependencies`. Declare and re-export it from +`src/stdlib/time/mod.rs`. Change `register_functions` and `now` to take the +clock. Update `src/stdlib/register.rs` to pass `Clock::default()` for now — +`StdlibConfig` is not yet involved. This completes EP-M1. Then add the `clock` field, `with_clock`, and `clock()` to `StdlibConfig`; initialize the field in `new()`; re-export `ClockProvider` and `system_clock` from `src/stdlib/mod.rs`; change `src/stdlib/register.rs` to pass `config.clock().clone()`. Add the integration tests and their support helper, -wire the new module into `tests/std_filter_tests.rs`, and add the BDD -scenarios and step. This completes EP-M2. +wire the new module into `tests/std_filter_tests.rs`, and add the BDD scenarios +and step. This completes EP-M2. For the BDD work specifically: `RenderConfig` and `extract_render_config` in `tests/bdd/steps/stdlib/rendering.rs:28` gain a `clock: Option` -member sourced from a new `TestWorld` field, and -`render_template_with_context` applies it alongside the existing policy, home, -and limit overrides: +member sourced from a new `TestWorld` field, and `render_template_with_context` +applies it alongside the existing policy, home, and limit overrides: ```rust if let Some(instant) = render_cfg.clock { @@ -1115,8 +1105,8 @@ Add to `tests/features/stdlib_time.feature`: Both reuse the existing `Then the stdlib output equals` step, so no new assertion step is needed. Scenarios are auto-discovered by -`scenarios!("tests/features", ...)` in `tests/bdd_tests.rs`; no registration -is required. +`scenarios!("tests/features", ...)` in `tests/bdd_tests.rs`; no registration is +required. Leave the existing scenario "Rendering now() yields a UTC timestamp" and its 5-second-tolerance step untouched: it is the ambient-fallback behavioural @@ -1153,11 +1143,11 @@ the file's existing dated-addendum format and its plain, decision-first prose: Note the ADR's `Implementation references` list should gain an entry for `src/stdlib/time/clock.rs`, and its `Consequences` section already requires -that the developers' guide sections stay consistent — so update -"Environment and template ports" in the same commit. +that the developers' guide sections stay consistent — so update "Environment +and template ports" in the same commit. -Validation: `make check-fmt` after every Markdown edit; run `make fmt` first -if it complains. +Validation: `make check-fmt` after every Markdown edit; run `make fmt` first if +it complains. ## Concrete steps @@ -1206,8 +1196,8 @@ To run only the new integration and behavioural coverage: cargo nextest run --all-features -E 'binary(std_filter_tests) or binary(bdd_tests)' ``` -Doctests are not executed by nextest and must be run through `make test`, -which chains `test-nextest` and `doctest`. The new rustdoc examples on +Doctests are not executed by nextest and must be run through `make test`, which +chains `test-nextest` and `doctest`. The new rustdoc examples on `ClockProvider` and `system_clock` are only exercised there. Commit after each milestone, using the repository's file-based commit-message @@ -1218,9 +1208,9 @@ convention rather than `-m`. Acceptance is behavioural, not structural. **Red evidence.** Before any production change, `make test` fails to compile -`src/stdlib/time/tests.rs` with errors naming `Clock`, `ClockProvider`, and -the arity of `register_functions`. Capture the first three compiler errors -verbatim in `Artefacts and notes`. +`src/stdlib/time/tests.rs` with errors naming `Clock`, `ClockProvider`, and the +arity of `register_functions`. Capture the first three compiler errors verbatim +in `Artefacts and notes`. **Green evidence, unit layer.** After EP-M1, `make test` passes. `now_uses_injected_clock` renders a timestamp exactly equal to @@ -1251,8 +1241,8 @@ confirm the named test fails for the intended reason: 3. Replace `timestamp.to_offset(parsed)` with an arithmetic shift → `now_offset_preserves_the_instant` fails on the `unix_timestamp` conjunct. 4. Store the clock on `StdlibConfig` but pass `Clock::default()` in - `register_with_config` → the integration and BDD tests fail while every - unit test still passes. + `register_with_config` → the integration and BDD tests fail while every unit + test still passes. Then `git reset --hard` back to the real commit. Record the four outcomes in `Artefacts and notes`. Mutation 4 is the most important: it is the only one @@ -1283,19 +1273,19 @@ Every step is re-runnable. The `make` gates are read-only with respect to tracked files except `make fmt`, which rewrites formatting deterministically — running it twice changes nothing the second time. -No step is destructive. There is no migration, no persisted format, and no -data to back up. Recovery at any point is `git revert` of the milestone commit -or `git reset --hard` to the previous milestone; each milestone is a coherent, +No step is destructive. There is no migration, no persisted format, and no data +to back up. Recovery at any point is `git revert` of the milestone commit or +`git reset --hard` to the previous milestone; each milestone is a coherent, gate-passing plateau. The mutation exercise in `Validation and acceptance` is the only step that -deliberately breaks the tree. Perform it on a scratch commit and discard it -with `git reset --hard`; never push it. If interrupted mid-exercise, `git -status` will show the mutation, and `git checkout -- ` restores it. +deliberately breaks the tree. Perform it on a scratch commit and discard it with +`git reset --hard`; never push it. If interrupted mid-exercise, `git status` +will show the mutation, and `git checkout -- ` restores it. -Working-tree cleanliness: this plan adds no build artefacts, no temporary -files inside the repository, and no `/tmp` output other than the gate logs -named above, which are disposable. +Working-tree cleanliness: this plan adds no build artefacts, no temporary files +inside the repository, and no `/tmp` output other than the gate logs named +above, which are disposable. ## Progress @@ -1311,10 +1301,10 @@ named above, which are disposable. Recorded during planning; extend during implementation. - Observation: `mockable`, already a dependency and already the source of the - `Env` seam trait, does export a `Clock` trait with a `MockClock`. - Evidence: `https://docs.rs/mockable/3.0.0/mockable/trait.Clock.html` declares - `pub trait Clock: Send + Sync { fn local(&self) -> DateTime; fn utc(&self) - -> DateTime; }`. + `Env` seam trait, does export a `Clock` trait with a `MockClock`. Evidence: + `https://docs.rs/mockable/3.0.0/mockable/trait.Clock.html` declares + `pub trait Clock: Send + Sync`, with required methods + `fn local(&self) -> DateTime` and `fn utc(&self) -> DateTime`. Impact: it is unusable here — it is typed in `chrono`, and `chrono` appears nowhere in `Cargo.lock`. Adopting it would add a second date-time crate purely to render one timestamp, and would require converting to @@ -1323,48 +1313,42 @@ Recorded during planning; extend during implementation. - Observation: `monotony` (a dependency, used for elapsed-time telemetry) has a `test-util` feature with deterministic clocks, which looks like a - ready-made answer. - Evidence: `https://docs.rs/monotony/1.0.0/monotony/` exports - `MonotonicClock`, `MonotonicClockExt`, `StdMonotonicClock`, and `test_util`; - its own summary is "Monotonic clock abstractions for deterministic - elapsed-time measurement". - Impact: not applicable — it abstracts monotonic elapsed time, not wall-clock - calendar instants. `now()` needs an `OffsetDateTime`. See D3. + ready-made answer. Evidence: `https://docs.rs/monotony/1.0.0/monotony/` + exports `MonotonicClock`, `MonotonicClockExt`, `StdMonotonicClock`, and + `test_util`; its own summary is "Monotonic clock abstractions for + deterministic elapsed-time measurement". Impact: not applicable — it + abstracts monotonic elapsed time, not wall-clock calendar instants. `now()` + needs an `OffsetDateTime`. See D3. - Observation: no test anywhere in the repository asserts that `now()` is refused in manifest-query mode, despite `docs/users-guide.md:1178` promising - it. - Evidence: the refusing stub at `src/stdlib/register.rs:231` has no + it. Evidence: the refusing stub at `src/stdlib/register.rs:231` has no corresponding test; searching the test tree for query-mode `now()` coverage - returns nothing. - Impact: this plan adds that regression test (OBL-5). It is a pre-existing - gap, not one introduced here, but the clock seam is exactly the change that - could breach it silently. + returns nothing. Impact: this plan adds that regression test (OBL-5). It is a + pre-existing gap, not one introduced here, but the clock seam is exactly the + change that could breach it silently. - Observation: RFC 0006 already recorded this exact gap and left it as a numbered open question, which no roadmap item or design document - cross-references. - Evidence: `docs/rfcs/0006-ansible-inspired-template-standard-library.md` - §3.3 records "`now` has no injected clock seam", and §16 question 7 asks - "Does `now` need an injected clock seam? … a future slice that wants - deterministic time tests will have to answer it." - Impact: RFC 0006 joins the conformance basis, and closing its open question - becomes an EP-M4 deliverable. Without this, the work would ship leaving a - stale open question in an upstream artefact. + cross-references. Evidence: + `docs/rfcs/0006-ansible-inspired-template-standard-library.md` §3.3 records + "`now` has no injected clock seam", and §16 question 7 asks "Does `now` need + an injected clock seam? … a future slice that wants deterministic time tests + will have to answer it." Impact: RFC 0006 joins the conformance basis, and + closing its open question becomes an EP-M4 deliverable. Without this, the + work would ship leaving a stale open question in an upstream artefact. - Observation: ADR-008's jurisdiction is narrower than its title implies — it is scoped to environment *variables*, and no lint forbids reading the clock. Evidence: the ADR's context section is written entirely about `clippy.toml`'s - ban on `std::env::var` and friends; `clippy.toml`'s `disallowed-methods` - list names only `std::env::*` and `std::env::set_current_dir`. - Impact: applying the taxonomy to a clock is an extension that must be stated - rather than assumed. See D11. + ban on `std::env::var` and friends; `clippy.toml`'s `disallowed-methods` list + names only `std::env::*` and `std::env::set_current_dir`. Impact: applying + the taxonomy to a clock is an extension that must be stated rather than + assumed. See D11. - Observation: `StdlibConfig` derives `Debug`, which no closure-bearing field - can satisfy. - Evidence: `src/stdlib/config/mod.rs:20`. - Impact: forced the `Clock` newtype rather than a bare - `Option` field. See D2. + can satisfy. Evidence: `src/stdlib/config/mod.rs:20`. Impact: forced the + `Clock` newtype rather than a bare `Option` field. See D2. ## Decision log @@ -1373,150 +1357,137 @@ Recorded during planning; extend during implementation. Rationale: fixed by technical design §5.2, and independently correct under ADR-008's taxonomy. The taxonomy offers three shapes — a narrow closure parameter, the `mockable::Env` trait, or an `Arc` closure — chosen by - call-site count and by whether the registration point requires `Send + - Sync`. MiniJinja's `add_function` requires `Send + Sync`, which ADR-008 - itself calls "a real constraint, not a preference", so the `Arc` closure is - the only admissible shape. A narrow closure cannot be captured by a - registered function; a trait object would add indirection with no extra - test-surface benefit for a single-call-site boundary. - Date/Author: 2026-09-08, planning. + call-site count and by whether the registration point requires `Send + Sync`. + MiniJinja's `add_function` requires `Send + Sync`, which ADR-008 itself calls + "a real constraint, not a preference", so the `Arc` closure is the only + admissible shape. A narrow closure cannot be captured by a registered + function; a trait object would add indirection with no extra test-surface + benefit for a single-call-site boundary. Date/Author: 2026-09-08, planning. - **D2 — Wrap the provider in a `Clock` newtype with a hand-written `Debug`.** - Decided: `StdlibConfig` holds `clock: Clock`, not `clock: - Option`. - Rationale: `StdlibConfig` derives `Debug` (C4), and `Arc` is not - `Debug`, so a bare field would force a hand-written thirteen-field `Debug` - on `StdlibConfig` that would drift every time a knob is added. A one-field - newtype confines the hand-written impl to the one type that needs it. The - repository already does exactly this for `CommandEnv` - (`src/runner/process/command_env.rs:73`), which uses + Decided: `StdlibConfig` holds `clock: Clock`, not + `clock: Option`. Rationale: `StdlibConfig` derives `Debug` + (C4), and `Arc` is not `Debug`, so a bare field would force a + hand-written thirteen-field `Debug` on `StdlibConfig` that would drift every + time a knob is added. A one-field newtype confines the hand-written impl to + the one type that needs it. The repository already does exactly this for + `CommandEnv` (`src/runner/process/command_env.rs:73`), which uses `debug_struct(..).finish_non_exhaustive()`. Choosing a non-`Option` field whose `Default` is `system_clock()` also mirrors `EnvReader` — which has no `Option` either, using `process_env_reader()` as the production supplier — and avoids an `Option` branch in `now()`, which the workspace's `option_if_let_else = "deny"` lint would scrutinize. The public vocabulary remains `ClockProvider` exactly as the design specifies; `Clock` is the - container, not a replacement for the port type. - Date/Author: 2026-09-08, planning. + container, not a replacement for the port type. Date/Author: 2026-09-08, + planning. - **D3 — Do not use `mockable::Clock` or `monotony`.** - Decided: hand-roll the provider on the `time` crate. - Rationale: evidenced in `Surprises & discoveries`. `mockable::Clock` sits - behind that crate's `clock` feature, which this workspace does not enable, - and it is `chrono`-typed — `chrono` appears nowhere in `Cargo.lock`. Adopting - it would mean enabling a new feature, adding a second date-time crate, and - converting `chrono::DateTime` to `time::OffsetDateTime` at the boundary, - all to render one timestamp. That breaches C5. `monotony` covers monotonic - elapsed time only (`MonotonicClock::now() -> Instant`) and has no wall-clock - type, so it cannot express a UTC calendar instant at all. Both were checked - against their published API documentation rather than assumed. - Date/Author: 2026-09-08, planning. + Decided: hand-roll the provider on the `time` crate. Rationale: evidenced in + `Surprises & discoveries`. `mockable::Clock` sits behind that crate's `clock` + feature, which this workspace does not enable, and it is `chrono`-typed — + `chrono` appears nowhere in `Cargo.lock`. Adopting it would mean enabling a + new feature, adding a second date-time crate, and converting + `chrono::DateTime` to `time::OffsetDateTime` at the boundary, all to + render one timestamp. That breaches C5. `monotony` covers monotonic elapsed + time only (`MonotonicClock::now() -> Instant`) and has no wall-clock type, so + it cannot express a UTC calendar instant at all. Both were checked against + their published API documentation rather than assumed. Date/Author: + 2026-09-08, planning. - **D4 — `docs/users-guide.md` is deliberately not modified.** - Decided: no users' guide change. - Rationale: the change is invisible to manifest authors. `now()` accepts the - same arguments, returns the same values, and fails the same way. The one - users' guide sentence that mentions `now()` — line 1178, stating that - manifest queries reject "the clock-dependent `now()` function" — remains - true and is protected by C2 and OBL-5. Editing it would imply a behaviour - change that has not occurred. If a reviewer disagrees, the correct response - is to add the users' guide entry when 7.2 exposes `given.clock.now` to - authors, not now. - Date/Author: 2026-09-08, planning. + Decided: no users' guide change. Rationale: the change is invisible to + manifest authors. `now()` accepts the same arguments, returns the same + values, and fails the same way. The one users' guide sentence that mentions + `now()` — line 1178, stating that manifest queries reject "the clock-dependent + `now()` function" — remains true and is protected by C2 and OBL-5. Editing + it would imply a behaviour change that has not occurred. If a reviewer + disagrees, the correct response is to add the users' guide entry when 7.2 + exposes `given.clock.now` to authors, not now. Date/Author: 2026-09-08, + planning. - **D5 — Keep the existing tolerance-based ambient assertions.** Decided: `now_defaults_to_utc` (3-second tolerance) and the BDD step `assert_stdlib_output_is_utc_timestamp` (5-second tolerance) are retained - unchanged. - Rationale: they are the ambient-fallback coverage C1 and OBL-4 require. A - tolerance is not a defect on a path that deliberately reads the real clock; - it is the only available oracle. Tightening them would make them flaky - without testing anything new. The determinism this plan delivers belongs to - the *injected* path, which gets exact-equality assertions instead. + unchanged. Rationale: they are the ambient-fallback coverage C1 and OBL-4 + require. A tolerance is not a defect on a path that deliberately reads the + real clock; it is the only available oracle. Tightening them would make them + flaky without testing anything new. The determinism this plan delivers + belongs to the *injected* path, which gets exact-equality assertions instead. Date/Author: 2026-09-08, planning. - **D6 — No `ortho_config` involvement.** - Decided: the clock is not a layered configuration option. - Rationale: `ortho_config` governs user-facing, layered CLI and file - configuration. The clock provider is an internal test seam — a closure, not - a serializable value — with no command-line flag, no configuration-file key, - and no localized help. Technical design §5.2 places it in `StdlibConfig`, - which is registration wiring, not the `ortho_config`-derived CLI - configuration struct. Exposing a clock override to end users would be a - behaviour change nobody has asked for and would breach C8. - Date/Author: 2026-09-08, planning. + Decided: the clock is not a layered configuration option. Rationale: + `ortho_config` governs user-facing, layered CLI and file configuration. The + clock provider is an internal test seam — a closure, not a serializable value + — with no command-line flag, no configuration-file key, and no localized + help. Technical design §5.2 places it in `StdlibConfig`, which is + registration wiring, not the `ortho_config`-derived CLI configuration struct. + Exposing a clock override to end users would be a behaviour change nobody has + asked for and would breach C8. Date/Author: 2026-09-08, planning. - **D7 — Two test layers, both mandatory.** Decided: unit tests in `src/stdlib/time/tests.rs` *and* integration tests - through `StdlibConfig`. - Rationale: the developers' guide makes this argument explicitly for - `EnvReader`, and it applies unchanged: unit tests at the leaf cannot prove - the provider reaches the registered function, and an implementation that - stores the clock but never passes it to `time::register_functions` passes - every unit test. Roadmap bullet 3 independently requires the tests be - "registered through `StdlibConfig`". OBL-7's non-vacuity argument is built - on precisely this mutation. + through `StdlibConfig`. Rationale: the developers' guide makes this argument + explicitly for `EnvReader`, and it applies unchanged: unit tests at the leaf + cannot prove the provider reaches the registered function, and an + implementation that stores the clock but never passes it to + `time::register_functions` passes every unit test. Roadmap bullet 3 + independently requires the tests be "registered through `StdlibConfig`". + OBL-7's non-vacuity argument is built on precisely this mutation. Date/Author: 2026-09-08, planning. - **D8 — Verification stops at property tests; no Kani or Verus.** - Decided: `proptest` for the offset invariant, parameterized tests - elsewhere. - Rationale: recorded in full under "Methods deliberately not used". The - change adds no `unsafe`, no bounded state machine, and no lemma independent - of the `time` crate's documented `to_offset` contract (AX-2). A Kani harness - or Verus proof here would restate an axiom, which this project's plan - standard explicitly calls a vacuous discharge. - Date/Author: 2026-09-08, planning. + Decided: `proptest` for the offset invariant, parameterized tests elsewhere. + Rationale: recorded in full under "Methods deliberately not used". The change + adds no `unsafe`, no bounded state machine, and no lemma independent of the + `time` crate's documented `to_offset` contract (AX-2). A Kani harness or + Verus proof here would restate an axiom, which this project's plan standard + explicitly calls a vacuous discharge. Date/Author: 2026-09-08, planning. - **D9 — Placement of the query-refusal regression test is left to the - implementor.** - Decided: put it wherever it compiles without widening visibility — either - `src/stdlib/time/tests.rs` or beside the existing manifest-query coverage in - `src/manifest/render_tests.rs` — and record the choice here. - Rationale: `register_manifest_query` is `pub(crate)`, so both locations are - reachable, but which is more natural depends on whether the test needs the - full manifest-render path or only the stdlib environment. Forcing the choice - from outside the code would risk prescribing a visibility widening, which - the tolerances forbid. This is a genuinely local judgement; make it and note - it. - Date/Author: 2026-09-08, planning. + implementor.** Decided: put it wherever it compiles without widening + visibility — either `src/stdlib/time/tests.rs` or beside the existing + manifest-query coverage in `src/manifest/render_tests.rs` — and record the + choice here. Rationale: `register_manifest_query` is `pub(crate)`, so both + locations are reachable, but which is more natural depends on whether the + test needs the full manifest-render path or only the stdlib environment. + Forcing the choice from outside the code would risk prescribing a visibility + widening, which the tolerances forbid. This is a genuinely local judgement; + make it and note it. Date/Author: 2026-09-08, planning. - **D10 — Rejected: a resolved-value enum in the `HomeDirectory` shape.** Considered: `enum Clock { Ambient, Fixed(OffsetDateTime) }`, mirroring `HomeDirectory` (`src/stdlib/config_types.rs:24`), which is the sibling seam in this very module and which ADR-008 describes as the pattern that "injects a resolved *value* rather than a closure". It would derive `Debug` and - `Clone` for free, removing the need for D2's hand-written impl entirely. - This is the strongest alternative and a reviewer should expect it to have - been weighed. - Decided: rejected, in favour of the `Arc` closure. - Rationale: three reasons, in decreasing order of weight. First, technical - design §5.2 specifies the `Arc` closure normatively and by exact type; - substituting an enum is an architecture deviation that would require - amending the design document and obtaining acceptance before implementation, - not a free local choice. Second — and this is the substantive objection — a - `Fixed(T)` enum makes OBL-3 *unwriteable*. The highest-risk defect in this - change is an implementation that reads the clock once at registration and - bakes the instant into the closure; the only way to detect it is a provider - whose successive calls return different values, which a resolved value - cannot express by construction. Choosing the enum would trade a hand-written - ten-line `Debug` impl for the loss of the plan's most important negative - control. Third, a closure keeps a future advancing or scripted clock - expressible without another redesign, whereas the enum would need a new - variant and a new match arm at every use site. The `Debug` objection the - enum answers is real but cheap to solve: the repository already has the + `Clone` for free, removing the need for D2's hand-written impl entirely. This + is the strongest alternative and a reviewer should expect it to have been + weighed. Decided: rejected, in favour of the `Arc` closure. Rationale: three + reasons, in decreasing order of weight. First, technical design §5.2 + specifies the `Arc` closure normatively and by exact type; substituting an + enum is an architecture deviation that would require amending the design + document and obtaining acceptance before implementation, not a free local + choice. Second — and this is the substantive objection — a `Fixed(T)` enum + makes OBL-3 *unwriteable*. The highest-risk defect in this change is an + implementation that reads the clock once at registration and bakes the + instant into the closure; the only way to detect it is a provider whose + successive calls return different values, which a resolved value cannot + express by construction. Choosing the enum would trade a hand-written ten-line + `Debug` impl for the loss of the plan's most important negative control. + Third, a closure keeps a future advancing or scripted clock expressible + without another redesign, whereas the enum would need a new variant and a new + match arm at every use site. The `Debug` objection the enum answers is real + but cheap to solve: the repository already has the newtype-with-manual-`Debug` idiom in `CommandEnv` - (`src/runner/process/command_env.rs:73`). - If a reviewer nonetheless prefers the enum, that is an upstream change to - technical design §5.2 and must be settled before EP-M1, not during it. - Date/Author: 2026-09-08, planning. + (`src/runner/process/command_env.rs:73`). If a reviewer nonetheless prefers + the enum, that is an upstream change to technical design §5.2 and must be + settled before EP-M1, not during it. Date/Author: 2026-09-08, planning. - **D11 — Extending ADR-008's jurisdiction from environment variables to the - clock is itself a decision, and is recorded as one.** - Decided: classify the clock seam under ADR-008 via a dated addendum, rather - than writing a separate ADR. - Rationale: ADR-008's stated problem is narrower than its title suggests. Its - context section is written entirely about `clippy.toml`'s ban on + clock is itself a decision, and is recorded as one.** Decided: classify the + clock seam under ADR-008 via a dated addendum, rather than writing a separate + ADR. Rationale: ADR-008's stated problem is narrower than its title suggests. + Its context section is written entirely about `clippy.toml`'s ban on `std::env::var` and friends, and no lint currently forbids `OffsetDateTime::now_utc()`. The taxonomy therefore does not automatically claim jurisdiction over clocks, and this plan must not silently assume it @@ -1527,13 +1498,12 @@ Recorded during planning; extend during implementation. "Manifest glob base seam" entry, which likewise covers a non-environment-variable boundary. Roadmap 7.1.1 bullet 4 also directs the classification to ADR-008 by name. A separate ADR would fragment one rubric - across two documents. - The addendum must therefore say explicitly that the taxonomy is being - applied to a non-environment-variable ambient input, so a later reader is - not misled about the original scope. Whether `clippy.toml` should also - disallow `OffsetDateTime::now_utc` outside the seam is deliberately **out of - scope** here: such a lint would fail the build at sites this plan does not - touch, including `src/stdlib/time/tests.rs` and + across two documents. The addendum must therefore say explicitly that the + taxonomy is being applied to a non-environment-variable ambient input, so a + later reader is not misled about the original scope. Whether `clippy.toml` + should also disallow `OffsetDateTime::now_utc` outside the seam is + deliberately **out of scope** here: such a lint would fail the build at sites + this plan does not touch, including `src/stdlib/time/tests.rs` and `tests/bdd/steps/stdlib/assertions.rs`, both of which legitimately read the real clock as a test oracle. Record it as a follow-up rather than doing it. Date/Author: 2026-09-08, planning. @@ -1558,8 +1528,8 @@ conformance basis: gap is closed. This plan is the "future slice" that question anticipated, so leaving the question open after merging would be a stale upstream artefact. - Confirm `docs/developers-guide.md`'s "Environment and template ports" - section and ADR-008 remain mutually consistent — ADR-008's own - `Consequences` section requires this. + section and ADR-008 remain mutually consistent — ADR-008's own `Consequences` + section requires this. - Confirm every roadmap 7.1.1 bullet maps to a passing named artefact. - If any discovery falsified an assumption in the technical design or RFC 0007, update that document rather than working around it, and record the From 49a6e26d5ba238c98590a26aa5f32c461f29ce07 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 8 Sep 2026 17:01:40 +0200 Subject: [PATCH 03/54] Satisfy the spelling and Markdown gates in the 7.1.1 execplan Use "handwritten" rather than "hand-written" as the typos gate requires, and rename the axiom identifiers from AX-n to AXIOM-n so the gate stops reading the prefix as a misspelling. The longer identifier reflows one paragraph. Co-Authored-By: Claude Opus 5 (1M context) --- docs/execplans/7-1-1-clock-provider-seam.md | 37 +++++++++++---------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index af0e88446..db8839d3e 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -315,7 +315,7 @@ Hard invariants. Violating any of these requires escalation, not a workaround. is required because MiniJinja's `add_function` demands it; this is a compiler-enforced constraint, not a preference. 4. **C4 — `StdlibConfig` remains `Debug` and `Clone`.** The derive at - `src/stdlib/config/mod.rs:20` must survive. Widening it to a hand-written + `src/stdlib/config/mod.rs:20` must survive. Widening it to a handwritten thirteen-field `Debug` is not acceptable; see D2. 5. **C5 — no new external dependencies.** `time`, `minijinja`, `rstest`, `googletest`, `pretty_assertions`, `proptest`, and `rstest-bdd` are all @@ -363,7 +363,7 @@ Stop and escalate — do not improvise — when any threshold is reached. - **Risk: `Arc` is not `Debug`, breaking `StdlibConfig`'s derive.** Severity: high. Likelihood: certain (this *will* happen on first compile). Mitigation: this is anticipated and designed for — the `Clock` newtype with a - hand-written `Debug` absorbs it, so `StdlibConfig` keeps its derive. See D2. + handwritten `Debug` absorbs it, so `StdlibConfig` keeps its derive. See D2. The precedent is `CommandEnv` at `src/runner/process/command_env.rs:73`. - **Risk: the clock is captured once at registration rather than consulted per @@ -469,21 +469,22 @@ wrong, and each names the mutation it must reject. These are third-party or platform behaviours treated as given. Do not write tests for them. -- **AX-1.** `time::OffsetDateTime::now_utc()` returns the host wall clock in +- **AXIOM-1.** `time::OffsetDateTime::now_utc()` returns the host wall clock in UTC. The `time` crate's correctness is assumed. -- **AX-2.** `OffsetDateTime::to_offset(o)` preserves the absolute instant and +- **AXIOM-2.** `OffsetDateTime::to_offset(o)` preserves the absolute instant and changes only the representation, so `t.to_offset(o).unix_timestamp() == t.unix_timestamp()` for every valid `o`. This is the documented contract of the `time` crate. -- **AX-3.** MiniJinja's `Environment::add_function` requires its closure to be +- **AXIOM-3.** MiniJinja's `Environment::add_function` requires its closure to + be `Send + Sync + 'static` and may invoke it from any thread. This is what forces the `Arc` shape (ADR-008 states the same for `EnvReader`). -- **AX-4.** `Arc T + Send + Sync>` is `Clone` and `Send + Sync`, +- **AXIOM-4.** `Arc T + Send + Sync>` is `Clone` and `Send + Sync`, and cloning shares one underlying closure rather than duplicating it. -- **AX-5.** `minijinja::Environment::compile_expression(..).eval(..)` invokes +- **AXIOM-5.** `minijinja::Environment::compile_expression(..).eval(..)` invokes a registered function once per textual occurrence of a call in the expression. -Repository-owned logic that builds on AX-2 and AX-3 *is* verified: OBL-6 +Repository-owned logic that builds on AXIOM-2 and AXIOM-3 *is* verified: OBL-6 exercises offset preservation against the real `time` interface through the real registration path rather than against a stub. @@ -573,7 +574,7 @@ real registration path rather than against a stub. `tests/std_filter_tests/time_functions.rs::now_without_a_clock_reads_the_host`. - Evidence: passes before and after the change, unchanged in substance. - Non-vacuity: the mutation "default the clock to a fixed epoch instant" - (a real hazard, since `Default` must be hand-written for a closure-bearing + (a real hazard, since `Default` must be handwritten for a closure-bearing type) is rejected — such a default is decades from now. This is the specific reason the tolerance is seconds and not, say, a day. @@ -673,13 +674,13 @@ real registration path rather than against a stub. - **Bounded model checking (Kani).** Rejected. The change introduces no `unsafe` code, no arithmetic-overflow surface of its own, and no bounded state machine. The only arithmetic is `to_offset`, which belongs to the - `time` crate and is AX-2. A Kani harness here would either restate AX-2 or - verify third-party internals, both of which this project's plans forbid. + `time` crate and is AXIOM-2. A Kani harness here would either restate AXIOM-2 + or verify third-party internals, both of which this project's plans forbid. - **Formal proof (Verus).** Rejected. No lemma is introduced whose guarantee must hold over all admissible inputs beyond what OBL-6's property test covers within the closed, finite offset domain. The offset domain is finite and small (fewer than 2×86400 values); a property test over it is not - meaningfully weaker than a proof, and a proof would rest entirely on AX-2 + meaningfully weaker than a proof, and a proof would rest entirely on AXIOM-2 anyway. - **State-machine model checking.** Rejected. There is no protocol, concurrency, or ordering property. The provider is `Send + Sync` and pure @@ -1364,12 +1365,12 @@ Recorded during planning; extend during implementation. function; a trait object would add indirection with no extra test-surface benefit for a single-call-site boundary. Date/Author: 2026-09-08, planning. -- **D2 — Wrap the provider in a `Clock` newtype with a hand-written `Debug`.** +- **D2 — Wrap the provider in a `Clock` newtype with a handwritten `Debug`.** Decided: `StdlibConfig` holds `clock: Clock`, not `clock: Option`. Rationale: `StdlibConfig` derives `Debug` (C4), and `Arc` is not `Debug`, so a bare field would force a - hand-written thirteen-field `Debug` on `StdlibConfig` that would drift every - time a knob is added. A one-field newtype confines the hand-written impl to + handwritten thirteen-field `Debug` on `StdlibConfig` that would drift every + time a knob is added. A one-field newtype confines the handwritten impl to the one type that needs it. The repository already does exactly this for `CommandEnv` (`src/runner/process/command_env.rs:73`), which uses `debug_struct(..).finish_non_exhaustive()`. Choosing a non-`Option` field @@ -1440,7 +1441,7 @@ Recorded during planning; extend during implementation. Decided: `proptest` for the offset invariant, parameterized tests elsewhere. Rationale: recorded in full under "Methods deliberately not used". The change adds no `unsafe`, no bounded state machine, and no lemma independent of the - `time` crate's documented `to_offset` contract (AX-2). A Kani harness or + `time` crate's documented `to_offset` contract (AXIOM-2). A Kani harness or Verus proof here would restate an axiom, which this project's plan standard explicitly calls a vacuous discharge. Date/Author: 2026-09-08, planning. @@ -1460,7 +1461,7 @@ Recorded during planning; extend during implementation. `HomeDirectory` (`src/stdlib/config_types.rs:24`), which is the sibling seam in this very module and which ADR-008 describes as the pattern that "injects a resolved *value* rather than a closure". It would derive `Debug` and - `Clone` for free, removing the need for D2's hand-written impl entirely. This + `Clone` for free, removing the need for D2's handwritten impl entirely. This is the strongest alternative and a reviewer should expect it to have been weighed. Decided: rejected, in favour of the `Arc` closure. Rationale: three reasons, in decreasing order of weight. First, technical design §5.2 @@ -1472,7 +1473,7 @@ Recorded during planning; extend during implementation. implementation that reads the clock once at registration and bakes the instant into the closure; the only way to detect it is a provider whose successive calls return different values, which a resolved value cannot - express by construction. Choosing the enum would trade a hand-written ten-line + express by construction. Choosing the enum would trade a handwritten ten-line `Debug` impl for the loss of the plan's most important negative control. Third, a closure keeps a future advancing or scripted clock expressible without another redesign, whereas the enum would need a new variant and a new From b32068094d8c29f419bb6b56dfd182040c5efbf5 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 8 Sep 2026 17:09:16 +0200 Subject: [PATCH 04/54] Revise the 7.1.1 execplan after the design review Six-lens design review found two errors of substance and three build-blockers in the prescribed code. Corrections of substance: - OBL-5's non-vacuity argument was false. The refusing `now` stub is registered after the permissive query helpers, and MiniJinja's `add_function` is last-write-wins, so a clock leaked into `register_query_functions` would be masked by the stub and the obligation would still pass. The obligation now needs two tests, the second asserting `now` is undefined after the permissive half alone. - Nothing pinned the offset of the injected path. An arbitrary provider may return a non-UTC instant, which would make the harness assert behaviour production never exhibits. `WallClock::read` now normalizes to UTC and OBL-1 gains a non-UTC-provider case. D10's rejection of the resolved-value enum rested on a circular claim that the enum makes the per-call negative control unwriteable; it does not. The withdrawn claim is replaced by the cohesion argument, and the design document's normativity is demoted to a tiebreak because D2 adds a container the design does not name. Rename the container to `WallClock`: `Clock` already names a monotonic clock generic in `src/runner/process/mod.rs` alongside two other `MonotonicClock` spellings. Build-blockers fixed: the accessor must be `const fn` without `#[must_use]`; the sequenced fixture violated the denied `indexing_slicing` lint and underflowed on an empty vector; and the `src/stdlib/mod.rs` re-export must land in EP-M1 or its doctests leave the milestone failing to compile. Also add `fixed_clock()`, a `ClockInstant` re-export, and an `is_system()` discriminant so a leaked clock is observable. Co-Authored-By: Claude Opus 5 (1M context) --- docs/execplans/7-1-1-clock-provider-seam.md | 503 +++++++++++++------- 1 file changed, 342 insertions(+), 161 deletions(-) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index db8839d3e..55124e018 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -50,7 +50,8 @@ Terms used throughout, defined here so no prior reading is required. - **Ambient / ambient fallback.** Reading the real host clock, which is what happens when no provider is explicitly supplied. - **MiniJinja.** The template engine Netsuke embeds (crate `minijinja`, - version 2.12.0). Manifest bodies are Jinja templates rendered by it. + the lockfile resolves 2.24.0). Manifest bodies are Jinja templates rendered + by it. - **Stdlib.** Netsuke's library of Jinja filters and functions registered into a MiniJinja `Environment`; it lives under `src/stdlib/`. - **`StdlibConfig`.** The struct at `src/stdlib/config/mod.rs:21` that carries @@ -224,24 +225,24 @@ doctests. The clock seam mirrors this shape, naming, and documentation density. ### Files a novice will touch -| Path | Role | -| ------------------------------------------------- | ----------------------------------------------------------------------------------- | -| `src/stdlib/time/clock.rs` | New. The `ClockProvider` port, `system_clock()` adapter, and the `Clock` container. | -| `src/stdlib/time/mod.rs` | Declares `mod clock;`, re-exports the port, threads the clock into `now()`. | -| `src/stdlib/time/tests.rs` | Existing unit tests; fixture updated and new cases added. | -| `src/stdlib/config/mod.rs` | `StdlibConfig` gains the `clock` field, `with_clock`, and `clock()`. | -| `src/stdlib/config_tests.rs` | Unit coverage for the new builder and accessor. | -| `src/stdlib/mod.rs` | Re-exports `ClockProvider` and `system_clock` on the public stdlib surface. | -| `src/stdlib/register.rs` | Passes the clock to `time::register_functions`. | -| `tests/std_filter_tests/time_functions.rs` | New. Integration coverage through real `StdlibConfig` registration. | -| `tests/std_filter_tests/support.rs` | Gains a `stdlib_env_with_clock` helper. | -| `tests/std_filter_tests.rs` | Wires the new integration module (see the wiring contract below). | -| `tests/features/stdlib_time.feature` | New deterministic scenarios. | -| `tests/bdd/steps/stdlib/rendering.rs` | New `Given` step; `RenderConfig` gains a clock. | -| `docs/adr-008-environment-seam-taxonomy.md` | Addendum recording the seam classification. | -| `docs/developers-guide.md` | Seam ownership rules and module boundary entry. | -| `docs/netsuke-test-framework-technical-design.md` | §5.2 updated to record implemented state. | -| `docs/roadmap.md` | Mark 7.1.1 done, at the very end. | +| Path | Role | +| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `src/stdlib/time/clock.rs` | New. The `ClockProvider` port, its `system_clock()` and `fixed_clock()` adapters, and the `WallClock` container. | +| `src/stdlib/time/mod.rs` | Declares `mod clock;`, re-exports the port, threads the clock into `now()`. | +| `src/stdlib/time/tests.rs` | Existing unit tests; fixture updated and new cases added. | +| `src/stdlib/config/mod.rs` | `StdlibConfig` gains the `clock` field, `with_clock`, and `clock()`. | +| `src/stdlib/config_tests.rs` | Unit coverage for the new builder and accessor. | +| `src/stdlib/mod.rs` | Re-exports the port, its adapters, and `ClockInstant` on the public stdlib surface. | +| `src/stdlib/register.rs` | Passes the clock to `time::register_functions`. | +| `tests/std_filter_tests/time_functions.rs` | New. Integration coverage through real `StdlibConfig` registration. | +| `tests/std_filter_tests/support.rs` | Gains a `stdlib_env_with_clock` helper. | +| `tests/std_filter_tests.rs` | Wires the new integration module (see the wiring contract below). | +| `tests/features/stdlib_time.feature` | New deterministic scenarios. | +| `tests/bdd/steps/stdlib/rendering.rs` | New `Given` step; `RenderConfig` gains a clock. | +| `docs/adr-008-environment-seam-taxonomy.md` | Addendum recording the seam classification. | +| `docs/developers-guide.md` | Seam ownership rules and module boundary entry. | +| `docs/netsuke-test-framework-technical-design.md` | §5.2 updated to record implemented state. | +| `docs/roadmap.md` | Mark 7.1.1 done, at the very end. | ### The integration-test wiring contract @@ -323,6 +324,12 @@ Hard invariants. Violating any of these requires escalation, not a workaround. 6. **C6 — the clock has exactly one owner.** `StdlibConfig` is the sole place a clock is stored, per technical design §5.2. Do not add a parallel clock parameter to `register()`, to manifest loading, or to any other entry point. + Note honestly that this is a *convention over a single call site*, not a + type-enforced property: `time::register_functions` is `pub(crate)` and takes + an owned `WallClock`, and EP-M1 deliberately calls it with + `WallClock::default()` before `StdlibConfig` is involved. What the compiler + does enforce is the crate boundary — no external caller can construct a + `WallClock` at all. 7. **C7 — no ambient-state mutation in tests.** Per ADR-008's 2026-09-01 addendum, `EnvLock`, `CwdGuard`, and `EnvVarGuard` are retired and no sanctioned test mutates the harness process. Determinism comes from @@ -341,9 +348,9 @@ Stop and escalate — do not improvise — when any threshold is reached. overrun means the design was wrong. - **Interface.** Any change to a *public* signature other than the three additions this plan sanctions (`stdlib::ClockProvider`, - `stdlib::system_clock`, `StdlibConfig::with_clock` plus its accessor). In - particular, if `StdlibConfig::new` or `into_components` must change shape, - stop. + `stdlib::ClockInstant`, `stdlib::system_clock`, `stdlib::fixed_clock`, and + `StdlibConfig::with_clock` plus its crate-private accessor). In particular, if + `StdlibConfig::new` or `into_components` must change shape, stop. - **Dependencies.** Any new entry in `Cargo.toml`. Stop. - **Iterations.** A given gate still failing after 3 fix attempts. Stop and report the log path. @@ -362,22 +369,49 @@ Stop and escalate — do not improvise — when any threshold is reached. - **Risk: `Arc` is not `Debug`, breaking `StdlibConfig`'s derive.** Severity: high. Likelihood: certain (this *will* happen on first compile). - Mitigation: this is anticipated and designed for — the `Clock` newtype with a - handwritten `Debug` absorbs it, so `StdlibConfig` keeps its derive. See D2. - The precedent is `CommandEnv` at `src/runner/process/command_env.rs:73`. + Mitigation: this is anticipated and designed for — the `WallClock` newtype + with a handwritten `Debug` absorbs it, so `StdlibConfig` keeps its derive. + See D2. The precedent is `CommandEnv` at + `src/runner/process/command_env.rs:73`. - **Risk: the clock is captured once at registration rather than consulted per - call.** Severity: high. Likelihood: medium. A natural but wrong - implementation reads the provider while building the closure and bakes the - resulting instant in. Under the fixed-clock tests this defect is invisible, - because a fixed provider returns the same value either way. Mitigation: the - sequenced-provider test (`OBL-3` below) exists specifically to catch it, and - is the designated negative control. + call.** Severity: high. Likelihood: low, because + `Interfaces and dependencies` hands the implementor the correct body + outright. A natural but wrong implementation reads the provider while + building the closure and bakes the resulting instant in. Under the + fixed-clock tests this defect is invisible, because a fixed provider returns + the same value either way. Mitigation: the sequenced-provider test (`OBL-3`) + is the designated negative control. Keep it even though the immediate + likelihood is low — its value is as a regression guard for later refactors, + when the correct body is no longer sitting in front of whoever is editing it. + +- **Risk: an injected clock reaches a real build path.** Severity: high. + Likelihood: low now, rising at 7.1.2. `StdlibConfig` is `Clone` and the + provider is a shared `Arc`, so once the test framework threads a + harness-built configuration into code shared with `netsuke build`, a leaked + clock would stamp a frozen instant into generated outputs. The worst form is + silent: a build file that renders byte-identically every time stops + triggering Ninja rebuilds, so CI goes green on stale artefacts. Mitigation: + `WallClock::is_system()` and the labelled `Debug` make the installed clock + observable rather than invisible; the default path is additionally protected + because `StdlibConfig::new` enumerates every field, so the compiler forces + `WallClock::default()` there. Revisit this risk explicitly at 7.1.2. + +- **Risk: an injected provider returns a non-UTC instant.** Severity: medium. + Likelihood: medium. `now()` is documented to yield UTC and `system_clock()` + does, but an arbitrary provider need not — a fixture built from a local-time + literal would make the harness assert behaviour production never exhibits. + Mitigation: `WallClock::read` normalizes with `to_offset(UtcOffset::UTC)`, + and OBL-1 carries a non-UTC-provider case that mutation 6 must break. - **Risk: the seam accidentally reaches manifest-query mode.** Severity: - medium. Likelihood: low. Mitigation: C2, plus a new regression test (`OBL-5`) - asserting `now()` still errors under `register_manifest_query`. No such test - exists today, so this risk is currently unguarded in the repository. + medium. Likelihood: low. Mitigation: C2, plus the new regression tests + (OBL-5). Note the subtlety recorded there: because the refusing stub is + registered *after* the permissive helpers and MiniJinja's `add_function` is + last-write-wins, a leak into `register_query_functions` would be masked by + the stub. The mitigation is therefore two tests, not one — the second + asserting `now` is undefined after the permissive half alone. A single "query + mode refuses `now`" test would give false assurance. - **Risk: `time::register_functions` also registers `timedelta` via `register_query_functions`.** Severity: low. Likelihood: medium. Threading a @@ -476,8 +510,7 @@ tests for them. `t.to_offset(o).unix_timestamp() == t.unix_timestamp()` for every valid `o`. This is the documented contract of the `time` crate. - **AXIOM-3.** MiniJinja's `Environment::add_function` requires its closure to - be - `Send + Sync + 'static` and may invoke it from any thread. This is what + be `Send + Sync + 'static` and may invoke it from any thread. This is what forces the `Arc` shape (ADR-008 states the same for `EnvReader`). - **AXIOM-4.** `Arc T + Send + Sync>` is `Clone` and `Send + Sync`, and cloning shares one underlying closure rather than duplicating it. @@ -499,7 +532,10 @@ real registration path rather than against a stub. - Rationale: a finite, fully enumerable partition — there is one behaviour to pin. Property testing would add nothing. - Domain: at least three distinct fixed instants, including one far from the - present day so the assertion cannot accidentally pass against the real clock. + present day so the assertion cannot accidentally pass against the real clock, + **and one provider returning a non-UTC instant** such as + `datetime!(2026-06-08 17:30:00 +05:30)`, whose rendered result must still + carry `UtcOffset::UTC` and the same absolute instant. - Artefact: `src/stdlib/time/tests.rs::now_uses_injected_clock`; `tests/std_filter_tests/time_functions.rs::now_uses_configured_clock`. - Evidence: fails before the change with a compile error (no `with_clock` @@ -509,7 +545,13 @@ real registration path rather than against a stub. the real clock by far more than any plausible test-runtime skew. The designated mutation is "ignore the provider and call `OffsetDateTime::now_utc()`"; that mutation makes the assertion fail by - years, not milliseconds. + years, not milliseconds. The non-UTC case guards a distinct hazard: nothing + else in the plan pins the *offset* of the injected path. `system_clock()` + yields UTC, but an arbitrary provider need not, and without normalization a + fixture built from a local-time literal would make `now()` render a non-`Z` + timestamp — harness and production diverging precisely where the seam exists + to make them agree. Mutation 6 removes the normalization and this case + rejects it. **OBL-2 — Repeated calls under a fixed provider agree.** @@ -578,30 +620,39 @@ real registration path rather than against a stub. type) is rejected — such a default is decades from now. This is the specific reason the tolerance is seconds and not, say, a day. -**OBL-5 — Manifest-query mode still refuses `now()`.** - -- Obligation: under `register_manifest_query`, evaluating `now()` returns the - manifest-query operation error, and no clock is constructed or consulted on - that path. -- Method: parameterized unit test asserting the error, plus a compile-time - argument (the query registration function takes no clock parameter, so it - cannot consult one). -- Rationale: this is constraint C2 and it is currently *unguarded* — no test - in the repository asserts the refusal today. The seam is exactly the kind of - change that could silently enable a helper the user guide promises is - disabled. -- Domain: `now()` and `now(offset='+02:00')` under query registration. -- Artefact: a new case in `src/stdlib/time/tests.rs`, or a new test beside the - existing manifest-query coverage in `src/manifest/render_tests.rs` if the - registration entry point is more naturally reachable there. Choose whichever - compiles without widening visibility; record the choice in `Decision log`. -- Evidence: the evaluation returns `Err`, and the error message matches the - `manifest_query_operation_error("now")` shape. -- Non-vacuity: the mutation "register the real clock-backed `now` in query - mode" makes the evaluation succeed — rejected. A witness that the test is - reaching real code: the *same* test asserts `timedelta()` still succeeds in - query mode, proving the environment is populated and the failure is specific - to `now`. +**OBL-5 — Manifest-query mode never acquires a clock-backed `now()`.** + +- Obligation: under manifest-query registration, `now()` returns the + manifest-query operation error; and the permissive half of that registration, + `time::register_query_functions`, does not define `now` at all. +- Method: two parameterized unit tests — one against the full + `register_manifest_query` environment, one against a bare `Environment` to + which only `register_query_functions` has been applied. +- Rationale: constraint C2, currently unguarded — no test in the repository + asserts the refusal today. +- Domain: `now()` and `now(offset='+02:00')` under query registration; `now` + absent under `register_query_functions` alone. +- Artefact: new cases beside the existing `manifest_query_environment` fixture + at `src/manifest/expand_tests.rs:34`, which is `pub(super)` and already + builds the restricted environment, so no visibility widening is needed. +- Evidence: the query-mode evaluation returns `Err` matching the + `manifest_query_operation_error("now")` shape; the bare-environment + evaluation fails as an *unknown function*, not as a refusal. +- Non-vacuity: **the obvious formulation of this obligation is vacuous, and + the second test exists because of it.** `register_manifest_query` + (`src/stdlib/register.rs:135`) calls `time::register_query_functions` and + *then* `register_disabled_query_helpers`, which installs the refusing stub + (`src/stdlib/register.rs:231`). MiniJinja's `add_function` is last-write-wins + over the globals map, so a leaked clock-backed `now` registered in the + earlier call would be silently overwritten by the stub — and a test that only + asserts "query mode refuses `now`" would still pass while the leak existed. + Asserting that `now` is *undefined* after the permissive half alone is what + actually detects it. The implementor must confirm the last-write-wins reading + against MiniJinja 2.24 before relying on it; if writes are not last-wins, + record the finding and keep both assertions regardless. A witness that the + tests reach real code: the same cases assert `timedelta()` still succeeds in + query mode, so the environment is populated and the failure is specific to + `now`. **OBL-6 — Offset application preserves the injected instant.** @@ -720,10 +771,16 @@ failure; that would weaken the Red evidence. ### EP-M1 — The port, its adapter, and the leaf function - Outcome: `src/stdlib/time/clock.rs` exists with `ClockProvider`, - `system_clock()`, and `Clock`; `now()` reads through a `Clock` rather than - calling `OffsetDateTime::now_utc()` directly; `time::register_functions` - accepts a `Clock`. `StdlibConfig` is not yet involved, so registration passes - `Clock::default()`. + `ClockInstant`, `system_clock()`, `fixed_clock()`, and `WallClock`, **and + `src/stdlib/mod.rs` already re-exports the public items**; `now()` reads + through a `WallClock` rather than calling `OffsetDateTime::now_utc()` + directly; `time::register_functions` accepts a `WallClock`. `StdlibConfig` is + not yet involved, so registration passes `WallClock::default()`. + + The `src/stdlib/mod.rs` re-export belongs to this milestone, not the next one: + `clock.rs` carries rustdoc examples that `use netsuke::stdlib::…`, and + `make test` runs doctests. Deferring the re-export would leave EP-M1 failing + on an unresolved import, so it would not be a plateau at all. - Requirements: advances ROADMAP-7.1.1 bullets 1 and 2; discharges OBL-1, OBL-2, OBL-3, OBL-4 (unit layer), OBL-6. - Acceptance evidence: `make test` passes; the unit tests in @@ -825,11 +882,20 @@ use std::{fmt, sync::Arc}; use time::OffsetDateTime; +use std::{fmt, sync::Arc}; + +use time::{OffsetDateTime, UtcOffset}; + +/// Re-exported so an external caller can name a provider's return type +/// without adding its own `time` dependency. +pub use time::OffsetDateTime as ClockInstant; + /// Thread-safe wall-clock source supplied to the `now()` Jinja helper. /// -/// `minijinja` requires registered functions to be `Send + Sync`, so the -/// provider is an `Arc` captured by the registered closure rather than a -/// borrowed parameter. See [ADR-008](../../../docs/adr-008-environment-seam-taxonomy.md). +/// The provider is an `Arc` rather than a `Box` for two reasons, both +/// binding: `minijinja` requires registered functions to be `Send + Sync`, +/// and `StdlibConfig` derives `Clone`, which `Box` cannot satisfy. +/// ADR-008 records the same shape for the manifest environment reader. pub type ClockProvider = Arc OffsetDateTime + Send + Sync>; /// Construct the host-backed clock provider used by production renders. @@ -838,37 +904,72 @@ pub fn system_clock() -> ClockProvider { Arc::new(OffsetDateTime::now_utc) } -/// Clock source held by `StdlibConfig` and captured at registration. +/// Construct a provider that always reports `instant`. +#[must_use] +pub fn fixed_clock(instant: OffsetDateTime) -> ClockProvider { + Arc::new(move || instant) +} + +/// Wall-clock source held by `StdlibConfig` and captured at registration. +/// +/// Named `WallClock` to keep it distinct from the monotonic-clock vocabulary +/// already in the crate: `monotony::MonotonicClock`, the `Clock` generic +/// parameter in `src/runner/process/mod.rs`, and the private +/// `type MonotonicClock` in `src/status_timing.rs`. #[derive(Clone)] -pub struct Clock(ClockProvider); +pub(crate) struct WallClock { + /// Provider consulted on every `now()` evaluation. + provider: ClockProvider, + /// Whether this is the ambient host clock, recorded for diagnostics. + is_system: bool, +} -impl Clock { +impl WallClock { /// Wrap `provider` as the clock backing `now()`. - #[must_use] - pub fn new(provider: ClockProvider) -> Self { - Self(provider) + pub(crate) fn new(provider: ClockProvider) -> Self { + Self { provider, is_system: false } } - /// Read the current instant from the provider. + /// Read the current instant, normalized to UTC. + /// + /// Normalization is part of the contract, not a convenience: `now()` is + /// documented to yield a UTC timestamp, and an injected provider is free + /// to return any offset. Without this the harness could assert behaviour + /// production never exhibits. pub(crate) fn read(&self) -> OffsetDateTime { - (self.0)() + (self.provider)().to_offset(UtcOffset::UTC) + } + + /// Whether the ambient host clock is installed. + pub(crate) const fn is_system(&self) -> bool { + self.is_system } } -impl Default for Clock { +impl Default for WallClock { fn default() -> Self { - Self(system_clock()) + Self { provider: system_clock(), is_system: true } } } -/// Opaque `Debug` output: a closure has no meaningful representation. -impl fmt::Debug for Clock { +/// Report the clock's provenance without pretending a closure is printable. +/// +/// The label makes a mis-wired clock self-diagnosing: an injected clock that +/// never reached registration, or an ambient clock where a test expected an +/// injected one, is visible in any `{:?}` of the surrounding config. +impl fmt::Debug for WallClock { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Clock").finish_non_exhaustive() + f.debug_struct("WallClock") + .field("source", &if self.is_system { "system" } else { "injected" }) + .finish_non_exhaustive() } } ``` +`is_system` is not decoration. It is the only signal that distinguishes a +production clock from an injected one at runtime; without it a leaked test +clock on a build path is undetectable (see the pre-mortem in `Risks`). + Both `ClockProvider` and `system_clock` carry runnable rustdoc examples, mirroring `src/manifest/env_reader.rs`. A suitable example for the alias: @@ -876,13 +977,12 @@ mirroring `src/manifest/env_reader.rs`. A suitable example for the alias: /// # Examples /// /// ```rust -/// use netsuke::stdlib::ClockProvider; -/// use std::sync::Arc; +/// use netsuke::stdlib::{ClockProvider, fixed_clock}; /// use time::macros::datetime; /// -/// let fixed = datetime!(2026-06-08 12:00:00 UTC); -/// let clock: ClockProvider = Arc::new(move || fixed); -/// assert_eq!(clock(), fixed); +/// let instant = datetime!(2026-06-08 12:00:00 UTC); +/// let clock: ClockProvider = fixed_clock(instant); +/// assert_eq!(clock(), instant); /// ``` ``` @@ -890,15 +990,16 @@ In `src/stdlib/time/mod.rs`: ```rust mod clock; -pub use self::clock::{Clock, ClockProvider, system_clock}; +pub(crate) use self::clock::WallClock; +pub use self::clock::{ClockInstant, ClockProvider, fixed_clock, system_clock}; /// Register time helpers with the environment. -pub(crate) fn register_functions(env: &mut Environment<'_>, clock: Clock) { +pub(crate) fn register_functions(env: &mut Environment<'_>, clock: WallClock) { env.add_function("now", move |kwargs: Kwargs| now(&kwargs, &clock)); register_query_functions(env); } -fn now(kwargs: &Kwargs, clock: &Clock) -> Result { +fn now(kwargs: &Kwargs, clock: &WallClock) -> Result { let offset_spec: Option = kwargs.get("offset")?; kwargs.assert_all_used()?; @@ -919,25 +1020,28 @@ In `src/stdlib/config/mod.rs`, `StdlibConfig` gains one field and two methods: ```rust /// Wall-clock source backing the `now()` helper. - clock: Clock, + clock: WallClock, ``` ```rust /// Replace the wall-clock source backing `now()`. #[must_use] pub fn with_clock(mut self, provider: ClockProvider) -> Self { - self.clock = Clock::new(provider); + self.clock = WallClock::new(provider); self } /// Wall-clock source backing the `now()` helper. - #[must_use] - pub(crate) fn clock(&self) -> &Clock { + pub(crate) const fn clock(&self) -> &WallClock { &self.clock } ``` -`StdlibConfig::new` initializes the field with `Clock::default()`. +`StdlibConfig::new` initializes the field with `WallClock::default()`. The +accessor is `const fn` and carries no `#[must_use]`, matching the sibling +`home_directory` accessor at `src/stdlib/config/mod.rs:245`: the workspace +denies `missing_const_for_fn`, and `must_use_candidate` fires only on exported +items, so `#[must_use]` on a `pub(crate)` getter is inert noise. `into_components` is **not** changed: the clock is read by reference before that call consumes the configuration, exactly as `home_directory()` is. @@ -951,7 +1055,7 @@ In `src/stdlib/mod.rs`, extend the existing re-export list so callers outside the crate can build a provider: ```rust -pub use self::time::{ClockProvider, system_clock}; +pub use self::time::{ClockInstant, ClockProvider, fixed_clock, system_clock}; ``` In `tests/std_filter_tests/support.rs`, beside `stdlib_env_with_home`: @@ -989,8 +1093,8 @@ Validation for this stage: no build required; you have simply read the code. Write the failing tests first. In `src/stdlib/time/tests.rs`, add the fixtures and cases for OBL-1, OBL-2, OBL-3, and OBL-6, referencing the not-yet-existing -`Clock`, `ClockProvider`, and the two-argument `register_functions`. Update the -existing `env` fixture to the new signature. +`WallClock`, `ClockProvider`, and the two-argument `register_functions`. Update +the existing `env` fixture to the new signature. The existing fixture is: @@ -1009,37 +1113,60 @@ It becomes: #[fixture] fn env() -> Environment<'static> { let mut env = Environment::new(); - register_functions(&mut env, Clock::default()); + register_functions(&mut env, WallClock::default()); env } /// Environment whose `now()` always reports `instant`. fn env_with_fixed_clock(instant: OffsetDateTime) -> Environment<'static> { let mut env = Environment::new(); - register_functions(&mut env, Clock::new(Arc::new(move || instant))); + register_functions(&mut env, WallClock::new(fixed_clock(instant))); env } -/// Environment whose `now()` reports each element of `instants` in turn, -/// recording how many times the provider was consulted. +/// Environment whose `now()` reports `first`, then each element of `rest` in +/// turn, saturating at the last, and recording how many times the provider was +/// consulted. +/// +/// Taking `first` separately makes non-emptiness a type-level precondition. fn env_with_sequenced_clock( - instants: Vec, + first: OffsetDateTime, + rest: &[OffsetDateTime], ) -> (Environment<'static>, Arc) { + let mut instants = vec![first]; + instants.extend_from_slice(rest); let calls = Arc::new(AtomicUsize::new(0)); let counter = Arc::clone(&calls); let provider: ClockProvider = Arc::new(move || { let index = counter.fetch_add(1, Ordering::SeqCst); - instants[index.min(instants.len() - 1)] + instants + .get(index) + .or_else(|| instants.last()) + .copied() + .unwrap_or(first) }); let mut env = Environment::new(); - register_functions(&mut env, Clock::new(provider)); + register_functions(&mut env, WallClock::new(provider)); (env, calls) } ``` -The sequenced fixture is the OBL-3 negative control; it is deliberately -saturating rather than panicking past the end so an over-reading implementation -fails on the *count* assertion with a legible message rather than on a panic. +The sequenced fixture is the OBL-3 negative control. Three details are +deliberate. It saturates rather than panicking past the end, so an over-reading +implementation fails on the *count* assertion with a legible message rather +than panicking opaquely inside MiniJinja evaluation. It takes `first` plus +`rest` rather than a `Vec`, which makes non-emptiness a type-level precondition +and removes the `instants.len() - 1` underflow an empty vector would cause. And +it reads with `get(..).or_else(..).copied()` rather than `instants[..]`, +because the workspace denies `clippy::indexing_slicing` (`Cargo.toml:215`), and +`clippy.toml`'s `allow-expect-in-tests` keys on `#[test]`-attributed functions, +which a plain helper is not. + +Assert the invocation count against the number of evaluations the test actually +performs, not a hardcoded literal. Hardcoding `3` silently makes AXIOM-5 +load-bearing, so a wrong assumption about MiniJinja's evaluation behaviour +would surface as an apparent implementation bug rather than as the axiom +violation it is. Use `googletest::prelude::*` with `assert_that!` for the equality assertions and `pretty_assertions::assert_eq` where a plain equality diff is clearer, per the @@ -1056,7 +1183,7 @@ fail with a compile error naming the missing items. Record the error text in Create `src/stdlib/time/clock.rs` exactly as specified in `Interfaces and dependencies`. Declare and re-export it from `src/stdlib/time/mod.rs`. Change `register_functions` and `now` to take the -clock. Update `src/stdlib/register.rs` to pass `Clock::default()` for now — +clock. Update `src/stdlib/register.rs` to pass `WallClock::default()` for now — `StdlibConfig` is not yet involved. This completes EP-M1. Then add the `clock` field, `with_clock`, and `clock()` to `StdlibConfig`; @@ -1137,6 +1264,18 @@ the file's existing dated-addendum format and its plain, decision-first prose: > for `now()`. Manifest-query registration receives no clock and keeps its > refusing `now` stub. > +> The taxonomy is being applied here to an ambient input that is *not* an +> environment variable. This ADR's context section is written about +> `clippy.toml`'s ban on `std::env::var` and friends, and no lint forbids +> reading the clock; the shape rubric transfers, the original scope does not. +> Note also that `StdlibConfig` now holds two ambient seams in two shapes — +> `home_directory: HomeDirectory`, a resolved value, and the clock, a closure. +> The clock's shape is the one this rubric prescribes for a `Send + Sync` +> registration point; `HomeDirectory` is the outlier, and the two should not be +> "harmonized" without revisiting this entry. `ClockProvider` also puts +> `time::OffsetDateTime` on netsuke's public surface, so a `time` 0.4 bump is a +> breaking library-API change. +> > `mockable::Clock` was not used: it is typed in `chrono`, which this > workspace does not depend on, so adopting it would add a second date-time > crate to render one timestamp. `monotony`, already a dependency, abstracts @@ -1209,9 +1348,9 @@ convention rather than `-m`. Acceptance is behavioural, not structural. **Red evidence.** Before any production change, `make test` fails to compile -`src/stdlib/time/tests.rs` with errors naming `Clock`, `ClockProvider`, and the -arity of `register_functions`. Capture the first three compiler errors verbatim -in `Artefacts and notes`. +`src/stdlib/time/tests.rs` with errors naming `WallClock`, `ClockProvider`, and +the arity of `register_functions`. Capture the first three compiler errors +verbatim in `Artefacts and notes`. **Green evidence, unit layer.** After EP-M1, `make test` passes. `now_uses_injected_clock` renders a timestamp exactly equal to @@ -1232,7 +1371,7 @@ the manifest-query operation error, while `timedelta()` under the same registration still succeeds. **Mutation evidence (required, run once and discarded).** In a scratch commit -that is never pushed, apply each of the four designated mutations in turn and +that is never pushed, apply each of the six designated mutations in turn and confirm the named test fails for the intended reason: 1. Replace `clock.read()` with `OffsetDateTime::now_utc()` → @@ -1241,11 +1380,16 @@ confirm the named test fails for the intended reason: instant into the closure → `now_reads_the_provider_on_every_call` fails. 3. Replace `timestamp.to_offset(parsed)` with an arithmetic shift → `now_offset_preserves_the_instant` fails on the `unix_timestamp` conjunct. -4. Store the clock on `StdlibConfig` but pass `Clock::default()` in +4. Store the clock on `StdlibConfig` but pass `WallClock::default()` in `register_with_config` → the integration and BDD tests fail while every unit test still passes. +5. Delete the refusing `now` stub at `src/stdlib/register.rs:231` → the + query-mode refusal case in OBL-5 fails. This is the mutation OBL-5's *first* + test catches; its second test exists for the leak this one hides. +6. Remove the UTC normalization from `WallClock::read` → the + non-UTC-provider case in OBL-1 fails on the offset assertion. -Then `git reset --hard` back to the real commit. Record the four outcomes in +Then `git reset --hard` back to the real commit. Record the six outcomes in `Artefacts and notes`. Mutation 4 is the most important: it is the only one that demonstrates the integration layer is load-bearing. @@ -1349,7 +1493,7 @@ Recorded during planning; extend during implementation. - Observation: `StdlibConfig` derives `Debug`, which no closure-bearing field can satisfy. Evidence: `src/stdlib/config/mod.rs:20`. Impact: forced the - `Clock` newtype rather than a bare `Option` field. See D2. + `WallClock` newtype rather than a bare `Option` field. See D2. ## Decision log @@ -1360,13 +1504,19 @@ Recorded during planning; extend during implementation. parameter, the `mockable::Env` trait, or an `Arc` closure — chosen by call-site count and by whether the registration point requires `Send + Sync`. MiniJinja's `add_function` requires `Send + Sync`, which ADR-008 itself calls - "a real constraint, not a preference", so the `Arc` closure is the only - admissible shape. A narrow closure cannot be captured by a registered - function; a trait object would add indirection with no extra test-surface - benefit for a single-call-site boundary. Date/Author: 2026-09-08, planning. - -- **D2 — Wrap the provider in a `Clock` newtype with a handwritten `Debug`.** - Decided: `StdlibConfig` holds `clock: Clock`, not + "a real constraint, not a preference". That argument is necessary but **not + sufficient**, and this plan should not rest on it alone: + `Box OffsetDateTime + Send + Sync>` satisfies MiniJinja's bound + too. The decisive constraint is `Clone`. `StdlibConfig` derives it + (`src/stdlib/config/mod.rs:20`), a `Box` cannot provide it, and C4 forbids + dropping the derive — so `Arc` is forced by two independent constraints + rather than the one ADR-008 names. A narrow closure parameter cannot be + captured by a registered function at all; a trait object would add + indirection with no extra test-surface benefit for a single-call-site + boundary. Date/Author: 2026-09-08, planning; strengthened after design review. + +- **D2 — Wrap the provider in a `WallClock` newtype with a handwritten `Debug` + .** Decided: `StdlibConfig` holds `clock: WallClock`, not `clock: Option`. Rationale: `StdlibConfig` derives `Debug` (C4), and `Arc` is not `Debug`, so a bare field would force a handwritten thirteen-field `Debug` on `StdlibConfig` that would drift every @@ -1378,7 +1528,7 @@ Recorded during planning; extend during implementation. `Option` either, using `process_env_reader()` as the production supplier — and avoids an `Option` branch in `now()`, which the workspace's `option_if_let_else = "deny"` lint would scrutinize. The public vocabulary - remains `ClockProvider` exactly as the design specifies; `Clock` is the + remains `ClockProvider` exactly as the design specifies; `WallClock` is the container, not a replacement for the port type. Date/Author: 2026-09-08, planning. @@ -1445,44 +1595,50 @@ Recorded during planning; extend during implementation. Verus proof here would restate an axiom, which this project's plan standard explicitly calls a vacuous discharge. Date/Author: 2026-09-08, planning. -- **D9 — Placement of the query-refusal regression test is left to the - implementor.** Decided: put it wherever it compiles without widening - visibility — either `src/stdlib/time/tests.rs` or beside the existing - manifest-query coverage in `src/manifest/render_tests.rs` — and record the - choice here. Rationale: `register_manifest_query` is `pub(crate)`, so both - locations are reachable, but which is more natural depends on whether the - test needs the full manifest-render path or only the stdlib environment. - Forcing the choice from outside the code would risk prescribing a visibility - widening, which the tolerances forbid. This is a genuinely local judgement; - make it and note it. Date/Author: 2026-09-08, planning. +- **D9 — The query-refusal regression tests live beside the existing + manifest-query fixture.** Decided: put them next to + `manifest_query_environment` in `src/manifest/expand_tests.rs:34`. Rationale: + an earlier draft left this to the implementor, which was a mistake. OBL-5 is + the sole guard for C2 and is exactly the item that gets dropped when a + milestone runs long, so leaving its location under-specified put the plan's + most fragile obligation at the greatest risk. The fixture already exists, is + `pub(super)`, and already calls `register_manifest_query`, so no visibility + widening is needed and there is no real judgement call to delegate. + Date/Author: 2026-09-08, planning; revised after design review. - **D10 — Rejected: a resolved-value enum in the `HomeDirectory` shape.** - Considered: `enum Clock { Ambient, Fixed(OffsetDateTime) }`, mirroring - `HomeDirectory` (`src/stdlib/config_types.rs:24`), which is the sibling seam - in this very module and which ADR-008 describes as the pattern that "injects - a resolved *value* rather than a closure". It would derive `Debug` and - `Clone` for free, removing the need for D2's handwritten impl entirely. This - is the strongest alternative and a reviewer should expect it to have been - weighed. Decided: rejected, in favour of the `Arc` closure. Rationale: three - reasons, in decreasing order of weight. First, technical design §5.2 - specifies the `Arc` closure normatively and by exact type; substituting an - enum is an architecture deviation that would require amending the design - document and obtaining acceptance before implementation, not a free local - choice. Second — and this is the substantive objection — a `Fixed(T)` enum - makes OBL-3 *unwriteable*. The highest-risk defect in this change is an - implementation that reads the clock once at registration and bakes the - instant into the closure; the only way to detect it is a provider whose - successive calls return different values, which a resolved value cannot - express by construction. Choosing the enum would trade a handwritten ten-line - `Debug` impl for the loss of the plan's most important negative control. - Third, a closure keeps a future advancing or scripted clock expressible - without another redesign, whereas the enum would need a new variant and a new - match arm at every use site. The `Debug` objection the enum answers is real - but cheap to solve: the repository already has the - newtype-with-manual-`Debug` idiom in `CommandEnv` - (`src/runner/process/command_env.rs:73`). If a reviewer nonetheless prefers - the enum, that is an upstream change to technical design §5.2 and must be - settled before EP-M1, not during it. Date/Author: 2026-09-08, planning. + Considered: `enum WallClock { Ambient, Fixed(OffsetDateTime) }`, mirroring + `HomeDirectory` (`src/stdlib/config_types.rs:24`), the sibling seam in this + very module, which ADR-008 describes as the pattern that "injects a resolved + *value* rather than a closure". It would derive `Debug` and `Clone` for free, + removing D2's handwritten impl entirely. This is the strongest alternative + and a reviewer should expect it to have been weighed. Decided: rejected, in + favour of the `Arc` closure. Rationale: the decisive reason is cohesion, not + testability. An earlier draft of this plan claimed the enum makes OBL-3 + *unwriteable*; **that claim was wrong and has been withdrawn.** An enum could + carry a `Sequence(Arc>)` variant and preserve the negative control + exactly. The real objection is narrower and survives scrutiny: preserving + OBL-3 under the enum requires adding a *test-only variant to a production + type*, which re-implements a closure badly and forces every `match` site to + grow an arm servicing a case production never takes. `HomeDirectory` earns + its enum because all three of its variants — `Ambient`, `Missing`, + `Explicit` — are production states; a `Sequence` variant would not be. + Secondarily, a closure keeps a future advancing or scripted clock expressible + without another redesign. Technical design §5.2's normativity is a **tiebreak + here, not the argument**. It cannot be decisive on its own while D2 + simultaneously adds a `WallClock` container the design does not name and + resolves that by amending the design at EP-M4; invoking "normative by exact + type" against the enum while treating the newtype as a mechanical addition + would be applying the same rule two ways. Independent corroboration: every + comparable library models a clock as a *callable*, never a resolved value — + Java's `java.time.Clock` (with `Clock.fixed` beside `Clock.systemUTC`), Go's + `clockwork`, and Rust's `quanta` and `mock_instant`. The `Debug` objection + the enum answers is real but cheap: the repository already has the + newtype-with-handwritten-`Debug` idiom in `CommandEnv` + (`src/runner/process/command_env.rs:73`). If a reviewer still prefers the + enum, that is an upstream change to technical design §5.2 and must be settled + before EP-M1, not during it. Date/Author: 2026-09-08, planning; rationale + repaired after design review. - **D11 — Extending ADR-008's jurisdiction from environment variables to the clock is itself a decision, and is recorded as one.** Decided: classify the @@ -1508,6 +1664,31 @@ Recorded during planning; extend during implementation. `tests/bdd/steps/stdlib/assertions.rs`, both of which legitimately read the real clock as a test oracle. Record it as a follow-up rather than doing it. Date/Author: 2026-09-08, planning. +- **D12 — `fixed_clock()` ships beside `system_clock()`.** + Decided: add a second public adapter constructing a constant provider. + Rationale: `Arc::new(move || instant)` would otherwise be handwritten at five + sites in this plan alone — the unit fixture, the sequenced fixture, the + integration support helper, the BDD step, and the doctest. Every comparable + library pairs a fixed constructor with the system one (D10). It costs three + lines and removes the most-repeated incantation in the change. Date/Author: + 2026-09-08, planning; added after design review. + +- **D13 — `time::OffsetDateTime` enters netsuke's public API, and `time` is + re-exported so callers need not depend on it directly.** Decided: export + `ClockInstant` as an alias for `time::OffsetDateTime` alongside + `ClockProvider`. Rationale: this is the first `time` type on netsuke's public + surface — `grep -rn "pub .*OffsetDateTime" src/` currently returns nothing — + and it is a deliberate departure from the `EnvReader` precedent D1 otherwise + follows. `EnvReader` owns `EnvReadError` precisely so it does not expose the + adapter's `VarError`, a property ADR-008 calls out. The clock cannot do the + same: its whole purpose is to yield a calendar instant, and inventing a + netsuke-owned timestamp type to wrap one would be ceremony with no + beneficiary. The consequence must be stated rather than inherited silently: a + `time` 0.4 bump becomes a breaking change to netsuke's library API. That is + acceptable — the crate is pre-1.0, ships as a binary, and its only library + consumers are its own tests — but it belongs in the ADR-008 addendum so a + later reader is not surprised. Date/Author: 2026-09-08, planning; added after + design review. ## Outcomes & retrospective @@ -1517,7 +1698,7 @@ Before setting this plan to `COMPLETE`, reconcile discoveries against the conformance basis: - Update `docs/netsuke-test-framework-technical-design.md` §5.2 so it records - the implemented state rather than a proposal — in particular, the `Clock` + the implemented state rather than a proposal — in particular, the `WallClock` container (D2) is a mechanical addition the design did not name, and §15 requires the document be kept in step. - Confirm ADR-008's addendum records the classification, states that the From 1020008d12853b53085600307a2c442606b83b1d Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 11 Sep 2026 00:39:52 +0200 Subject: [PATCH 05/54] Add failing tests for the stdlib clock seam (7.1.1, EP-M0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Red stage for the clock provider seam. The tests reference items that do not exist yet — `WallClock`, `ClockProvider`, `fixed_clock`, and the two-argument `register_functions` — so the test target fails to compile, which is the intended signal for this milestone. `make test-nextest` reports `could not compile netsuke-build (lib test) due to 8 previous errors`, and all eight name the missing seam items or the changed arity. Coverage added to `src/stdlib/time/tests.rs`: - OBL-1: an injected instant is reported verbatim, including a non-UTC provider whose result must still render with a UTC offset. - OBL-2: repeated evaluations under one fixed provider agree, including two `now()` calls within a single expression. - OBL-3: a sequenced provider separates "consulted per call" from "captured at registration", with the invocation count derived from the evaluations performed rather than a hardcoded literal. - OBL-6: offset application preserves the injected instant, as explicit boundary cases (`Z`, `+00:00`, `+02:30`, `-05:00`, `+23:59:59`, `-23:59:59`) and as a proptest over the valid civil range. `now_defaults_to_utc` and the other existing cases are retained unchanged: they are the ambient-fallback coverage constraint C1 requires. The sequenced fixture saturates rather than panicking past the end, so an over-reading implementation fails on the count assertion with a legible message instead of panicking inside MiniJinja evaluation, and it takes `first` plus `rest` so non-emptiness is a type-level precondition. `make check-fmt` passes; `make lint` and `make typecheck` cannot pass until EP-M1 supplies the seam, as the plan records. Co-Authored-By: Claude Opus 5 (1M context) --- docs/execplans/7-1-1-clock-provider-seam.md | 37 ++++- src/stdlib/time/tests.rs | 169 +++++++++++++++++++- 2 files changed, 203 insertions(+), 3 deletions(-) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index 55124e018..ce2372c78 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -6,7 +6,7 @@ This ExecPlan (execution plan) is a living document. The sections `Constraints`, `Conformance basis`, and `Verification plan` must be kept up to date as work proceeds. -Status: DRAFT +Status: IN PROGRESS (EP-M0 complete) ## Purpose / big picture @@ -1434,7 +1434,7 @@ above, which are disposable. ## Progress -- [ ] EP-M0 — Red tests committed; compile failure captured. +- [x] EP-M0 — Red tests committed; compile failure captured. - [ ] EP-M1 — `clock.rs`, threaded `now()`, unit obligations discharged. - [ ] EP-M2 — `StdlibConfig` ownership, integration and BDD coverage. - [ ] EP-M3 — All four gates green. @@ -1724,6 +1724,39 @@ Do not mark `COMPLETE` while any upstream change or deviation is unrecorded. To be populated during implementation. Required entries: 1. The Red-stage compiler error from Stage B (first three errors, verbatim). + + ```plaintext + error[E0425]: cannot find type `ClockProvider` in this scope + --> src/stdlib/time/tests.rs:54:19 + | + 54 | let provider: ClockProvider = Arc::new(move || { + | ^^^^^^^^^^^^^ not found in this scope + + error[E0433]: cannot find type `WallClock` in this scope + --> src/stdlib/time/tests.rs:30:34 + | + 30 | register_functions(&mut env, WallClock::default()); + | ^^^^^^^^^ use of undeclared type `WallClock` + + error[E0061]: this function takes 1 argument but 2 arguments were supplied + --> src/stdlib/time/tests.rs:30:5 + | + 30 | register_functions(&mut env, WallClock::default()); + | ^^^^^^^^^^^^^^^^^^ -------------------- unexpected argument #2 + | + note: function defined here + --> src/stdlib/time/mod.rs:43:15 + | + 43 | pub(crate) fn register_functions(env: &mut Environment<'_>) { + | ^^^^^^^^^^^^^^^^^^ + help: remove the extra argument + ``` + + `make test-nextest` then reported `could not compile`netsuke-build + `(lib test) due to 8 previous errors`, exit code 101. All eight name the + missing seam items or the changed arity; no unrelated failure appeared. Full + log: `/tmp/test-netsuke-7-1-1-clock-provider-seam-m0.out`. + 2. The `make test` summary line at EP-M1 and EP-M2. 3. The four mutation outcomes from `Validation and acceptance`. 4. The final gate transcript tails for `check-fmt`, `typecheck`, `lint`, and diff --git a/src/stdlib/time/tests.rs b/src/stdlib/time/tests.rs index 0125ce86f..52e90c504 100644 --- a/src/stdlib/time/tests.rs +++ b/src/stdlib/time/tests.rs @@ -5,9 +5,14 @@ //! downstream template evaluation. use super::*; use anyhow::{Context, Result, anyhow, ensure}; +use googletest::prelude::*; use minijinja::{Environment, ErrorKind, context, value::Value}; use proptest::prelude::*; use rstest::{fixture, rstest}; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; use time::{Duration, OffsetDateTime, UtcOffset, macros::datetime}; fn eval_expression(env: &Environment<'_>, expr: &str) -> Result { @@ -22,10 +27,43 @@ fn eval_expression(env: &Environment<'_>, expr: &str) -> Result { #[fixture] fn env() -> Environment<'static> { let mut env = Environment::new(); - register_functions(&mut env); + register_functions(&mut env, WallClock::default()); env } +/// Build an environment whose `now()` always reports `instant`. +fn env_with_fixed_clock(instant: OffsetDateTime) -> Environment<'static> { + let mut env = Environment::new(); + register_functions(&mut env, WallClock::new(fixed_clock(instant))); + env +} + +/// Build an environment whose `now()` reports `first`, then each element of +/// `rest` in turn, saturating at the last, and recording how many times the +/// provider was consulted. +/// +/// Taking `first` separately makes non-emptiness a type-level precondition. +fn env_with_sequenced_clock( + first: OffsetDateTime, + rest: &[OffsetDateTime], +) -> (Environment<'static>, Arc) { + let mut instants = vec![first]; + instants.extend_from_slice(rest); + let calls = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&calls); + let provider: ClockProvider = Arc::new(move || { + let index = counter.fetch_add(1, Ordering::SeqCst); + instants + .get(index) + .or_else(|| instants.last()) + .copied() + .unwrap_or(first) + }); + let mut env = Environment::new(); + register_functions(&mut env, WallClock::new(provider)); + (env, calls) +} + fn value_as_timestamp(value: &Value) -> Result { value .as_object() @@ -102,6 +140,135 @@ fn now_rejects_invalid_offset(env: Environment<'static>, #[case] offset: &str) - } } +/// An injected instant is reported verbatim, whatever offset its provider +/// carries. The non-UTC case is load-bearing: [`WallClock::read`] normalizes +/// to UTC, and without that normalization a fixture built from a local-time +/// literal would make `now()` render a timestamp production never produces. +#[rstest] +#[case::utc(datetime!(2026-06-08 12:00:00 UTC))] +#[case::epoch(datetime!(1970-01-01 00:00:00 UTC))] +#[case::far_future(datetime!(2099-12-31 23:59:59 UTC))] +#[case::non_utc(datetime!(2026-06-08 17:30:00 +05:30))] +fn now_uses_injected_clock(#[case] instant: OffsetDateTime) -> Result<()> { + let env = env_with_fixed_clock(instant); + let value = eval_expression(&env, "now()")?; + let captured = value_as_timestamp(&value)?; + + assert_that!(captured, eq(instant)); + assert_that!(captured.offset(), eq(UtcOffset::UTC)); + Ok(()) +} + +/// Two evaluations under one fixed provider agree, including two calls within +/// a single expression — the property a manifest author actually relies on. +#[rstest] +fn now_repeats_the_injected_instant() -> Result<()> { + let fixed = datetime!(2026-06-08 12:00:00 UTC); + let env = env_with_fixed_clock(fixed); + + let first = value_as_timestamp(&eval_expression(&env, "now()")?)?; + let second = value_as_timestamp(&eval_expression(&env, "now()")?)?; + assert_that!(first, eq(fixed)); + assert_that!(second, eq(fixed)); + + let joined = eval_expression(&env, "now().iso8601 ~ '|' ~ now().iso8601")?; + assert_that!( + joined.as_str(), + eq(Some("2026-06-08T12:00:00Z|2026-06-08T12:00:00Z")) + ); + Ok(()) +} + +/// The provider is consulted afresh on every call rather than read once while +/// the registered closure is built. A fixed provider cannot distinguish the +/// two, so the negative control needs a provider whose output varies. +#[rstest] +fn now_reads_the_provider_on_every_call() -> Result<()> { + let (env, _calls) = env_with_sequenced_clock( + datetime!(2026-06-08 12:00:00 UTC), + &[ + datetime!(2026-06-08 13:00:00 UTC), + datetime!(2026-06-08 14:00:00 UTC), + ], + ); + + let first = value_as_timestamp(&eval_expression(&env, "now()")?)?; + let second = value_as_timestamp(&eval_expression(&env, "now()")?)?; + let third = value_as_timestamp(&eval_expression(&env, "now()")?)?; + + assert_that!(first, eq(datetime!(2026-06-08 12:00:00 UTC))); + assert_that!(second, eq(datetime!(2026-06-08 13:00:00 UTC))); + assert_that!(third, eq(datetime!(2026-06-08 14:00:00 UTC))); + Ok(()) +} + +/// Each `now()` evaluation consults the provider exactly once. The expected +/// count is derived from the evaluations performed, so a re-reading +/// implementation fails on the count rather than on a stale literal. +#[rstest] +fn now_invokes_the_provider_once_per_call() -> Result<()> { + let (env, calls) = env_with_sequenced_clock( + datetime!(2026-06-08 12:00:00 UTC), + &[datetime!(2026-06-08 13:00:00 UTC)], + ); + + let expressions = ["now()", "now(offset='+02:00')"]; + for expression in expressions { + eval_expression(&env, expression)?; + } + + assert_that!(calls.load(Ordering::SeqCst), eq(expressions.len())); + Ok(()) +} + +/// Applying an offset re-expresses the injected instant, preserving it. Both +/// conjuncts are load-bearing: an arithmetic shift keeps the offset but moves +/// the instant, and dropping the offset keeps the instant but loses the +/// requested representation. +#[rstest] +#[case("Z")] +#[case("+00:00")] +#[case("+02:30")] +#[case("-05:00")] +#[case("+23:59:59")] +#[case("-23:59:59")] +fn now_applies_offset_to_the_injected_instant(#[case] offset_spec: &str) -> Result<()> { + let fixed = datetime!(2026-06-08 12:00:00 UTC); + let env = env_with_fixed_clock(fixed); + let value = eval_expression(&env, &format!("now(offset='{offset_spec}')"))?; + let captured = value_as_timestamp(&value)?; + let expected_offset = parse_offset(offset_spec)?; + + assert_that!(captured.unix_timestamp(), eq(fixed.unix_timestamp())); + assert_that!(captured.offset(), eq(expected_offset)); + Ok(()) +} + +proptest! { + /// Re-expressing the injected instant in any valid offset preserves it. + #[test] + fn now_offset_preserves_the_instant( + sign in prop_oneof![Just("+"), Just("-")], + hour in 0_u8..24, + minute in 0_u8..60, + second in 0_u8..60, + ) { + let offset = format!("{sign}{hour:02}:{minute:02}:{second:02}"); + let fixed = datetime!(2026-06-08 12:00:00 UTC); + let env = env_with_fixed_clock(fixed); + let expression = format!("now(offset='{offset}')"); + let value = eval_expression(&env, &expression) + .map_err(|err| TestCaseError::fail(format!("evaluating {expression}: {err}")))?; + let captured = value_as_timestamp(&value) + .map_err(|err| TestCaseError::fail(format!("reading {expression}: {err}")))?; + let expected_offset = parse_offset(&offset) + .map_err(|err| TestCaseError::fail(format!("parsing {offset}: {err}")))?; + + prop_assert_eq!(captured.unix_timestamp(), fixed.unix_timestamp()); + prop_assert_eq!(captured.offset(), expected_offset); + } +} + proptest! { /// Accept signed offsets whose hour component stays within one civil day. #[test] From 5a3bc5df730ce958742fcd197f272608eb337f84 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 11 Sep 2026 00:45:41 +0200 Subject: [PATCH 06/54] Thread the stdlib clock provider seam through now() (7.1.1, EP-M1) Green stage for the clock seam. `now()` no longer reads the host clock directly; it reads a `ClockProvider` supplied by `StdlibConfig`, so a caller that installs a provider gets a repeatable render. `src/stdlib/time/clock.rs` is new and holds the port, both adapters, and the container: - `ClockProvider` is the `Arc OffsetDateTime + Send + Sync>` alias fixed by the technical design (section 5.2, constraint C3). The `Arc` is binding rather than stylistic: minijinja requires registered functions to be `Send + Sync`, and `StdlibConfig` derives `Clone`, which `Box` cannot satisfy. ADR-008 records the same shape for the manifest environment reader. - `system_clock()` returns the ambient adapter and `fixed_clock(t)` the deterministic one. - `WallClock` is the container `StdlibConfig` stores. It is named to stay distinct from the monotonic-clock vocabulary already in the crate (`monotony::MonotonicClock`, the `Clock` generic in `src/runner/process/mod.rs`, and `status_timing`'s private alias). `read()` normalizes to UTC, which is part of the contract rather than a convenience: `now()` is documented to yield UTC and a provider is free to return any offset, so without it a test could assert behaviour production never exhibits. `WallClock` hand-writes `Debug` because `StdlibConfig` derives it and a closure is not printable. The label is `system` or `injected`, which makes a mis-wired clock self-diagnosing in any `{:?}` of the configuration. The impl routes through `is_system()` rather than reading the field so the accessor has a non-test caller. `StdlibConfig` gains the `clock` field, a `with_clock` builder, and a crate-private `clock()` accessor; `register_with_config` passes `config.clock().clone()`. `into_components` is unchanged, since the clock is read by reference before that call consumes the configuration. The public re-exports let an out-of-crate caller name a provider and its return type without adding a `time` dependency. Coverage added to `src/stdlib/time/tests.rs`: - OBL-5: two parameterized cases guarding C2. One asserts that manifest-query registration still refuses `now()` and that the refusal names `now`, which rejects a copy-pasted stub registered under the wrong helper name; the other asserts that the permissive half, `register_query_functions`, does not define `now` at all. The two are distinguished by error kind (`UnknownFunction` versus the refusal marker), so "absent" cannot pass as "refused". - A raw-error helper was needed for these: the marker is inspected through `minijinja::Error`, and the existing `anyhow`-wrapping helper would have hidden the type. The OBL-5 cases live in `src/stdlib/time/tests.rs` rather than beside the `manifest_query_environment` fixture as the plan proposed. That placement assumed `register_query_functions` was reachable from `src/manifest/`; it is not, because `stdlib::time` is private to `stdlib`. Both halves of query registration are in scope in the time module's own test module, so the intent -- two paired cases with no visibility widening -- is met without exporting a seam item for a test's benefit. One implementation change beyond the plan's split: EP-M1 and EP-M2's configuration ownership land together. `WallClock::new` has no production caller until `StdlibConfig` owns the clock, so EP-M1 alone fails the workspace's `-D warnings` dead-code gate on `WallClock::new` and `is_system`. Adding the field and its builder in the same commit restores a compiling plateau; EP-M2 is now the integration and behavioural layer. Evidence: `RUSTFLAGS="-D warnings" cargo check --workspace --all-targets --all-features` is clean, `make check-fmt` passes, and `cargo nextest run -E 'test(stdlib::time)'` reports 49/49 passing (up from 45, the four new OBL-5 cases). The `with_clock` doctest passes and asserts the exact rendering `2026-06-08T12:00:00Z`, which exercises the seam through real registration. Co-Authored-By: Claude Opus 5 (1M context) --- src/stdlib/config/mod.rs | 40 ++++++++++- src/stdlib/mod.rs | 1 + src/stdlib/register.rs | 2 +- src/stdlib/time/clock.rs | 150 +++++++++++++++++++++++++++++++++++++++ src/stdlib/time/mod.rs | 23 ++++-- src/stdlib/time/tests.rs | 66 ++++++++++++++++- 6 files changed, 272 insertions(+), 10 deletions(-) create mode 100644 src/stdlib/time/clock.rs diff --git a/src/stdlib/config/mod.rs b/src/stdlib/config/mod.rs index 7d1fbb573..a9e83a659 100644 --- a/src/stdlib/config/mod.rs +++ b/src/stdlib/config/mod.rs @@ -9,7 +9,12 @@ pub use super::config_types::{ DEFAULT_FETCH_CACHE_DIR, DEFAULT_FETCH_MAX_RESPONSE_BYTES, DEFAULT_FILE_MAX_READ_BYTES, DEFAULT_WHICH_CACHE_CAPACITY, FileConfig, NetworkConfig, }; -use super::{command, network::NetworkPolicy, which::WORKSPACE_SKIP_DIRS}; +use super::{ + command, + network::NetworkPolicy, + time::{ClockProvider, WallClock}, + which::WORKSPACE_SKIP_DIRS, +}; use crate::localization::{self, keys}; use anyhow::{anyhow, bail, ensure}; use camino::{Utf8Path, Utf8PathBuf}; @@ -47,6 +52,8 @@ pub struct StdlibConfig { command_path_override: Option, /// Home directory source used by the `expanduser` filter. home_directory: HomeDirectory, + /// Wall-clock source backing the `now()` helper. + clock: WallClock, } impl StdlibConfig { @@ -93,6 +100,7 @@ impl StdlibConfig { pathext_override: None, command_path_override: None, home_directory: HomeDirectory::Ambient, + clock: WallClock::default(), }) } @@ -269,11 +277,41 @@ impl StdlibConfig { self } + /// Replace the wall-clock source backing `now()`. + /// + /// # Examples + /// + /// ```rust + /// use minijinja::Environment; + /// use netsuke::stdlib::{self, StdlibConfig, fixed_clock}; + /// use time::macros::datetime; + /// + /// let instant = datetime!(2026-06-08 12:00:00 UTC); + /// let config = StdlibConfig::from_current_dir() + /// .expect("open workspace") + /// .with_clock(fixed_clock(instant)); + /// + /// let mut env = Environment::new(); + /// stdlib::register_with_config(&mut env, config).expect("register stdlib"); + /// let rendered = env.render_str("{{ now() }}", ()).expect("render"); + /// assert_eq!(rendered, "2026-06-08T12:00:00Z"); + /// ``` + #[must_use] + pub fn with_clock(mut self, provider: ClockProvider) -> Self { + self.clock = WallClock::new(provider); + self + } + /// Return the configured home directory source. pub(crate) const fn home_directory(&self) -> &HomeDirectory { &self.home_directory } + /// Return the wall-clock source backing the `now()` helper. + pub(crate) const fn clock(&self) -> &WallClock { + &self.clock + } + /// The configured fetch cache directory relative to the workspace root. #[must_use] pub fn fetch_cache_relative(&self) -> &Utf8Path { diff --git a/src/stdlib/mod.rs b/src/stdlib/mod.rs index a8e205832..0a0deb5e3 100644 --- a/src/stdlib/mod.rs +++ b/src/stdlib/mod.rs @@ -31,6 +31,7 @@ pub use network::{ pub use path::{FILE_READ_FILTER_VALUES, FILE_READ_OUTCOME_VALUES, FILE_READ_TOTAL}; pub(crate) use register::{is_manifest_query_disabled_error, register_manifest_query}; pub use register::{register, register_with_config, value_from_bytes}; +pub use time::{ClockInstant, ClockProvider, fixed_clock, system_clock}; use std::{ sync::Arc, diff --git a/src/stdlib/register.rs b/src/stdlib/register.rs index 4e4ebadce..58b90c182 100644 --- a/src/stdlib/register.rs +++ b/src/stdlib/register.rs @@ -105,7 +105,7 @@ pub fn register_with_config( register_legacy_boolean_formatter(env); let state = StdlibState::default(); register_read_only_helpers(env, &config); - time::register_functions(env); + time::register_functions(env, config.clock().clone()); let impure = state.impure_flag(); let (network_config, file_config, command_config) = config.into_components(); network::register_functions(env, Arc::clone(&impure), network_config); diff --git a/src/stdlib/time/clock.rs b/src/stdlib/time/clock.rs new file mode 100644 index 000000000..265b78642 --- /dev/null +++ b/src/stdlib/time/clock.rs @@ -0,0 +1,150 @@ +//! Wall-clock seam for the stdlib `now()` helper. +//! +//! The `now()` helper reports the current instant. Reading it from the host +//! clock directly makes every render that calls `now()` unrepeatable, so the +//! read is instead supplied by the caller as a [`ClockProvider`]. A caller +//! that supplies nothing keeps the ambient behaviour: [`WallClock::default`] +//! installs [`system_clock`], the production adapter. + +use std::{fmt, sync::Arc}; + +use time::{OffsetDateTime, UtcOffset}; + +/// Re-exported so an external caller can name a provider's return type +/// without adding its own `time` dependency. +/// +/// # Examples +/// +/// ``` +/// use netsuke::stdlib::ClockInstant; +/// use time::{UtcOffset, macros::datetime}; +/// +/// let instant: ClockInstant = datetime!(2026-06-08 12:00:00 UTC); +/// assert_eq!(instant.offset(), UtcOffset::UTC); +/// ``` +pub use time::OffsetDateTime as ClockInstant; + +/// Thread-safe wall-clock source supplied to the `now()` Jinja helper. +/// +/// The provider is an `Arc` rather than a `Box` for two reasons, both binding: +/// `minijinja` requires registered functions to be `Send + Sync`, and +/// `StdlibConfig` derives `Clone`, which `Box` cannot satisfy. +/// ADR-008 records the same shape for the manifest environment reader. +/// +/// # Examples +/// +/// A caller may hand-write a provider rather than use one of the supplied +/// adapters: +/// +/// ```rust +/// use netsuke::stdlib::{ClockInstant, ClockProvider}; +/// use std::sync::Arc; +/// use time::macros::datetime; +/// +/// let instant = datetime!(2026-06-08 12:00:00 UTC); +/// let clock: ClockProvider = Arc::new(move || instant); +/// let read: ClockInstant = clock(); +/// assert_eq!(read, instant); +/// ``` +pub type ClockProvider = Arc OffsetDateTime + Send + Sync>; + +/// Construct the host-backed clock provider used by production renders. +/// +/// # Examples +/// +/// ```rust +/// use netsuke::stdlib::{ClockProvider, system_clock}; +/// use time::UtcOffset; +/// +/// let clock: ClockProvider = system_clock(); +/// assert_eq!(clock().offset(), UtcOffset::UTC); +/// ``` +#[must_use] +pub fn system_clock() -> ClockProvider { + Arc::new(OffsetDateTime::now_utc) +} + +/// Construct a provider that always reports `instant`. +/// +/// # Examples +/// +/// ```rust +/// use netsuke::stdlib::{ClockProvider, fixed_clock}; +/// use time::macros::datetime; +/// +/// let instant = datetime!(2026-06-08 12:00:00 UTC); +/// let clock: ClockProvider = fixed_clock(instant); +/// assert_eq!(clock(), clock()); +/// ``` +#[must_use] +pub fn fixed_clock(instant: OffsetDateTime) -> ClockProvider { + Arc::new(move || instant) +} + +/// Wall-clock source held by `StdlibConfig` and captured at registration. +/// +/// Named `WallClock` to keep it distinct from the monotonic-clock vocabulary +/// already in the crate: `monotony::MonotonicClock`, the `Clock` generic +/// parameter in `src/runner/process/mod.rs`, and the private +/// `type MonotonicClock` in `src/status_timing.rs`. +#[derive(Clone)] +pub(crate) struct WallClock { + /// Provider consulted on every `now()` evaluation. + provider: ClockProvider, + /// Whether this is the ambient host clock, recorded for diagnostics. + is_system: bool, +} + +impl WallClock { + /// Wrap `provider` as the clock backing `now()`. + pub(crate) const fn new(provider: ClockProvider) -> Self { + Self { + provider, + is_system: false, + } + } + + /// Read the current instant, normalized to UTC. + /// + /// Normalization is part of the contract, not a convenience: `now()` is + /// documented to yield a UTC timestamp, and an injected provider is free + /// to return any offset. Without this the harness could assert behaviour + /// production never exhibits. + pub(crate) fn read(&self) -> OffsetDateTime { + (self.provider)().to_offset(UtcOffset::UTC) + } + + /// Whether the ambient host clock is installed. + pub(crate) const fn is_system(&self) -> bool { + self.is_system + } +} + +impl Default for WallClock { + fn default() -> Self { + Self { + provider: system_clock(), + is_system: true, + } + } +} + +/// Report the clock's provenance without pretending a closure is printable. +/// +/// The label makes a mis-wired clock self-diagnosing: an injected clock that +/// never reached registration, or an ambient clock where a test expected an +/// injected one, is visible in any `{:?}` of the surrounding configuration. +impl fmt::Debug for WallClock { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("WallClock") + .field( + "source", + &if self.is_system() { + "system" + } else { + "injected" + }, + ) + .finish_non_exhaustive() + } +} diff --git a/src/stdlib/time/mod.rs b/src/stdlib/time/mod.rs index 2e2672b15..1644d975b 100644 --- a/src/stdlib/time/mod.rs +++ b/src/stdlib/time/mod.rs @@ -9,12 +9,14 @@ use minijinja::{ Environment, Error, ErrorKind, value::{Kwargs, Value}, }; -use time::{ - Duration, OffsetDateTime, UtcOffset, format_description::FormatItem, macros::format_description, -}; +use time::{Duration, UtcOffset, format_description::FormatItem, macros::format_description}; use crate::localization::{self, keys}; +mod clock; +pub(crate) use self::clock::WallClock; +pub use self::clock::{ClockInstant, ClockProvider, fixed_clock, system_clock}; + mod format; use self::format::{TimeDeltaValue, TimestampValue}; @@ -40,8 +42,12 @@ const OFFSET_FMT: &[FormatItem<'static>] = format_description!("[offset_hour]:[offset_minute][optional [:[offset_second]]]"); /// Register time helpers with the environment. -pub(crate) fn register_functions(env: &mut Environment<'_>) { - env.add_function("now", |kwargs: Kwargs| now(&kwargs)); +/// +/// The clock is captured by the registered `now` function and consulted on +/// every evaluation, so a caller that injects a provider observes it on each +/// call rather than once at registration. +pub(crate) fn register_functions(env: &mut Environment<'_>, clock: WallClock) { + env.add_function("now", move |kwargs: Kwargs| now(&kwargs, &clock)); register_query_functions(env); } @@ -52,14 +58,17 @@ pub(crate) fn register_query_functions(env: &mut Environment<'_>) { /// Resolve the `now` helper: the current UTC time shifted by a given offset. /// +/// The instant comes from `clock`, which already normalizes to UTC; the +/// offset argument is applied on top as a re-expression of that same instant. +/// /// # Errors /// /// Returns an invalid-operation error when the offset string does not parse. -fn now(kwargs: &Kwargs) -> Result { +fn now(kwargs: &Kwargs, clock: &WallClock) -> Result { let offset_spec: Option = kwargs.get("offset")?; kwargs.assert_all_used()?; - let mut timestamp = OffsetDateTime::now_utc(); + let mut timestamp = clock.read(); if let Some(raw) = offset_spec { let parsed = parse_offset(&raw)?; timestamp = timestamp.to_offset(parsed); diff --git a/src/stdlib/time/tests.rs b/src/stdlib/time/tests.rs index 52e90c504..0b12f0761 100644 --- a/src/stdlib/time/tests.rs +++ b/src/stdlib/time/tests.rs @@ -4,7 +4,7 @@ //! offsets, and that helper functions expose consistent object wrappers for //! downstream template evaluation. use super::*; -use anyhow::{Context, Result, anyhow, ensure}; +use anyhow::{Context, Result, anyhow, bail, ensure}; use googletest::prelude::*; use minijinja::{Environment, ErrorKind, context, value::Value}; use proptest::prelude::*; @@ -295,6 +295,70 @@ proptest! { } } +/// Evaluate `expr`, returning the raw `MiniJinja` error it raised. +/// +/// The typed error is returned rather than an `anyhow` one because the +/// manifest-query marker is inspected through `minijinja::Error`; wrapping it +/// first would hide the type the assertion needs. +fn eval_expression_error(env: &Environment<'_>, expr: &str) -> Result { + let compiled = env + .compile_expression(expr) + .with_context(|| format!("compiling expression: {expr}"))?; + match compiled.eval(context! {}) { + Ok(value) => bail!("expected {expr} to fail, but it produced {value:?}"), + Err(error) => Ok(error), + } +} + +/// Constraint C2: manifest-query registration must keep refusing `now()`, so +/// discovery metadata can never disclose host time. The refusal is asserted to +/// name `now` rather than merely carry the marker, which rejects a copy-pasted +/// stub registered under the wrong helper name. +#[rstest] +#[case::bare("now()")] +#[case::offset("now(offset='+02:00')")] +fn manifest_query_registration_refuses_now(#[case] expression: &str) -> Result<()> { + let mut env = Environment::new(); + let _state = crate::stdlib::register_manifest_query(&mut env); + + let error = eval_expression_error(&env, expression)?; + + assert_that!( + crate::stdlib::is_manifest_query_disabled_error(&error), + eq(true) + ); + ensure!( + error + .detail() + .unwrap_or_default() + .starts_with("now is disabled while rendering"), + "refusal should name the `now` helper: {error}" + ); + Ok(()) +} + +/// The permissive half of query registration stays clock-free: it does not +/// define `now` at all. This is the sharper half of C2 — a helper that was +/// never declared cannot acquire a clock by having one threaded into it — and +/// it is distinguished from the refusal above by the error kind rather than by +/// the mere absence of a value. +#[rstest] +#[case::bare("now()")] +#[case::offset("now(offset='+02:00')")] +fn query_functions_do_not_define_now(#[case] expression: &str) -> Result<()> { + let mut env = Environment::new(); + register_query_functions(&mut env); + + let error = eval_expression_error(&env, expression)?; + + assert_that!(error.kind(), eq(ErrorKind::UnknownFunction)); + assert_that!( + crate::stdlib::is_manifest_query_disabled_error(&error), + eq(false) + ); + Ok(()) +} + #[rstest] fn timedelta_defaults_to_zero(env: Environment<'static>) -> Result<()> { let value = eval_expression(&env, "timedelta()")?; From fd1202e57244927c0fa9842d8ce84bc72f68812b Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 11 Sep 2026 00:49:55 +0200 Subject: [PATCH 07/54] Add integration and BDD coverage for the stdlib clock seam EP-M2 of the 7.1.1 clock provider seam. EP-M1 already threaded the clock through `StdlibConfig` and `time::register_functions` (the two milestones landed as one commit because neither compiles alone under `-D warnings`), so this commit supplies the coverage that exercises the seam through the real registration path rather than through `time`'s internals. Integration coverage in `tests/std_filter_tests/time_functions.rs` renders through `stdlib::register_with_config` under a fixed clock: - the configured instant is rendered verbatim, including from a fixture at `1970-01-01T00:00:00Z`; - a provider holding `+05:30` renders as `Z`, proving `WallClock::read` normalizes to UTC (`Z` is only emitted for a UTC offset); - `offset='...'` re-expresses the configured instant instead of shifting it, asserted on both the rendered string and the `unix_timestamp`; - with no clock configured, `now()` still reads the host clock, within a three-second tolerance and reporting UTC. Rendered strings are taken from `time`'s documented `Iso8601::DEFAULT` contract rather than from captured output. Behavioural coverage adds two `stdlib_time.feature` scenarios and the `Given the stdlib clock is fixed at {instant:string}` step, which parses the instant with the existing `parse_iso_timestamp` helper and stores a provider in a new `TestWorld::stdlib_clock` slot. `RenderConfig` carries the provider through to `render_template_with_context`, where it is applied via `StdlibConfig::with_clock`. Also add the two OBL-5 unit cases in `src/stdlib/time/tests.rs`: manifest-query mode refuses `now()` (naming the helper in the error detail), while the clock-independent `register_query_functions` leaves `now()` undefined. Both registration halves are in scope there; `stdlib::time` is private, so the plan's proposed home in `src/manifest/expand_tests.rs` was not reachable. Co-Authored-By: Claude Code --- docs/execplans/7-1-1-clock-provider-seam.md | 110 ++++++++++++++++++-- tests/bdd/fixtures/mod.rs | 4 +- tests/bdd/steps/stdlib/config.rs | 18 +++- tests/bdd/steps/stdlib/rendering.rs | 7 +- tests/features/stdlib_time.feature | 12 +++ tests/std_filter_tests.rs | 2 + tests/std_filter_tests/support.rs | 7 ++ tests/std_filter_tests/time_functions.rs | 107 +++++++++++++++++++ 8 files changed, 256 insertions(+), 11 deletions(-) create mode 100644 tests/std_filter_tests/time_functions.rs diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index ce2372c78..f505ee4b5 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -6,7 +6,7 @@ This ExecPlan (execution plan) is a living document. The sections `Constraints`, `Conformance basis`, and `Verification plan` must be kept up to date as work proceeds. -Status: IN PROGRESS (EP-M0 complete) +Status: IN PROGRESS (EP-M0, EP-M1 and EP-M2 complete; EP-M3 in progress) ## Purpose / big picture @@ -1435,8 +1435,10 @@ above, which are disposable. ## Progress - [x] EP-M0 — Red tests committed; compile failure captured. -- [ ] EP-M1 — `clock.rs`, threaded `now()`, unit obligations discharged. -- [ ] EP-M2 — `StdlibConfig` ownership, integration and BDD coverage. +- [x] EP-M1 — `clock.rs`, threaded `now()`, unit obligations discharged; + merged with EP-M2's configuration ownership (see Surprises & discoveries), + and OBL-5 added. +- [x] EP-M2 — integration and BDD coverage over the real registration path. - [ ] EP-M3 — All four gates green. - [ ] EP-M4 — ADR-008 addendum, developers' guide, technical design §5.2. - [ ] EP-M5 — Roadmap 7.1.1 marked done. @@ -1495,6 +1497,80 @@ Recorded during planning; extend during implementation. can satisfy. Evidence: `src/stdlib/config/mod.rs:20`. Impact: forced the `WallClock` newtype rather than a bare `Option` field. See D2. +- Observation: the EP-M1/EP-M2 split is not a compiling plateau, so the two + milestones landed as one commit. Evidence: `WallClock::new` is called only + from `#[cfg(test)]` code until `StdlibConfig` owns the clock, and + `is_system()` had a `Debug` impl that read the field directly, so + `RUSTFLAGS="-D warnings" cargo check --workspace --all-targets --all-features` + failed with: + + ```plaintext + error: associated items `new` and `is_system` are never used + --> src/stdlib/time/clock.rs:100:25 + | + 98 | impl WallClock { + | -------------- associated items in this implementation + 99 | /// Wrap `provider` as the clock backing `now()`. + 100 | pub(crate) const fn new(provider: ClockProvider) -> Self { + | ^^^ + ... + 118 | pub(crate) const fn is_system(&self) -> bool { + | ^^^^^^^^^ + = note: `-D dead-code` implied by `-D warnings` + ``` + + Impact: EP-M1 carries EP-M2's configuration ownership, and EP-M2 is now the + integration and behavioural layer. Silencing the lint was rejected: the + workspace denies `allow_attributes`, and a suppressive attribute would have + hidden the real signal — that the seam had no production consumer yet. + Routing the `Debug` impl through `self.is_system()` rather than through the + field supplies the accessor's caller, so what the label reports and what the + accessor returns cannot drift. + +- Observation: OBL-5's second case cannot be written where the plan placed it. + Evidence: `time::register_query_functions` is `pub(crate)` inside + `src/stdlib/time/mod.rs` and `stdlib::time` is a private module, so + `crate::stdlib::time::register_query_functions` is unreachable from + `src/manifest/expand_tests.rs`. Re-exporting it at `stdlib` would itself be + an unused import in non-test builds, which the workspace also denies. Impact: + both OBL-5 cases live in `src/stdlib/time/tests.rs`, where both registration + halves are already in scope. The plan's intent — two paired cases, no + visibility widening — is met; only the file changed. + +- Observation: the `Given` step for the fixed clock belongs in + `tests/bdd/steps/stdlib/config.rs`, not `rendering.rs` as the plan proposed. + Evidence: every other stdlib `given` step that sets a `RenderConfig` field + (`the stdlib fetch response limit is ...`, + `the stdlib command output limit is ...`) is in `config.rs`, while + `rendering.rs` holds only `when` steps. Impact: the step follows the + established convention; `RenderConfig.clock` and its consumption stay in + `rendering.rs`. + +- Observation: `now()`'s rendered spelling is fixed by `time`'s well-known + format, so the new string assertions were written from the format contract + rather than fitted to captured output. Evidence: `format_offset_datetime` + formats through `Iso8601::DEFAULT`, documented as "separators (such as `-` and + `:`) are included" and "the UTC offset has precision to the minute"; + `format_offset` writes a bare `Z` when `offset.is_utc()`; and the formatter + strips a zero fractional part. Impact: the expectations are + `2026-06-08T12:00:00Z` for UTC and `2026-06-08T14:00:00+02:00` for an instant + re-expressed at `+02:00`. + +- Observation: a manifest-query refusal assertion cannot use + `Error::to_string()`. Evidence: `Display for Error` writes the kind and the + detail joined by a colon, so the refusal renders as + `invalid operation: now is disabled while rendering ...`, which fails a + `starts_with("now is disabled")` assertion *against correct behaviour*. + Impact: the OBL-5 cases assert on `Error::detail()`, which returns the + message without the kind prefix. + +- Observation: `now()` normalizing its provider is observable only through the + rendered string, because `Z` is emitted only for a UTC offset. Evidence: + `format_offset` returns early with `b"Z"` when `offset.is_utc()`. Impact: the + integration case for a provider carrying `+05:30` proves normalization by + rendering `2026-06-08T12:00:00Z`; a separate offset-attribute assertion would + have been redundant, so none was added. + ## Decision log - **D1 — Port shape follows the `EnvReader` precedent verbatim.** @@ -1752,12 +1828,30 @@ To be populated during implementation. Required entries: help: remove the extra argument ``` - `make test-nextest` then reported `could not compile`netsuke-build - `(lib test) due to 8 previous errors`, exit code 101. All eight name the - missing seam items or the changed arity; no unrelated failure appeared. Full - log: `/tmp/test-netsuke-7-1-1-clock-provider-seam-m0.out`. + `make test-nextest` then reported the following, with exit code 101: + + ```plaintext + error: could not compile `netsuke-build` (lib test) due to 8 previous errors + ``` + + All eight name the missing seam items or the changed arity; no unrelated + failure appeared. Full log: + `/tmp/test-netsuke-7-1-1-clock-provider-seam-m0.out`. + +2. The `make test` summary line at EP-M1 and EP-M2. At EP-M1 the seam's own + suites were run directly, since EP-M1's commit is a plateau rather than the + gate milestone: + + ```plaintext + # cargo nextest run -E 'test(stdlib::time)' + Summary [0.040s] 49 tests run: 49 passed, 2688 skipped + + # cargo nextest run -E 'binary(std_filter_tests)' + Summary [5.041s] 72 tests run: 72 passed, 0 skipped + ``` + + The full-suite `make test` summary is recorded at EP-M3. -2. The `make test` summary line at EP-M1 and EP-M2. 3. The four mutation outcomes from `Validation and acceptance`. 4. The final gate transcript tails for `check-fmt`, `typecheck`, `lint`, and `test`. diff --git a/tests/bdd/fixtures/mod.rs b/tests/bdd/fixtures/mod.rs index 58884879a..1c1a3c347 100644 --- a/tests/bdd/fixtures/mod.rs +++ b/tests/bdd/fixtures/mod.rs @@ -13,7 +13,7 @@ use netsuke::manifest::EnvAccessPolicy; use netsuke::output_mode::OutputMode; use netsuke::output_prefs::OutputPrefs; use netsuke::runner::CommandEnv; -use netsuke::stdlib::{NetworkPolicy, StdlibState as NetsukeStdlibState}; +use netsuke::stdlib::{ClockProvider, NetworkPolicy, StdlibState as NetsukeStdlibState}; use rstest::fixture; use rstest_bdd::Slot; use std::cell::RefCell; @@ -96,6 +96,8 @@ pub struct TestWorld { pub stdlib_policy: RefCell>, /// Override for the PATH environment variable used by the `which` resolver. pub stdlib_path_override: RefCell>, + /// Fixed wall-clock source installed for stdlib `now()` scenarios. + pub stdlib_clock: Slot, /// Maximum fetch response size configured for the active scenario. pub stdlib_fetch_max_bytes: Slot, /// Maximum captured command output size configured for the scenario. diff --git a/tests/bdd/steps/stdlib/config.rs b/tests/bdd/steps/stdlib/config.rs index 905337d76..6914151d7 100644 --- a/tests/bdd/steps/stdlib/config.rs +++ b/tests/bdd/steps/stdlib/config.rs @@ -1,11 +1,27 @@ //! Configuration-related BDD steps for stdlib scenarios. use crate::bdd::fixtures::{RefCellOptionExt, TestWorld}; -use netsuke::{cli_localization, localization}; +use anyhow::Result; +use netsuke::{cli_localization, localization, stdlib}; use rstest_bdd_macros::given; use std::sync::Arc; use test_support::localizer_test_lock; +use super::parsing::parse_iso_timestamp; + +/// Fix the stdlib wall clock at `instant`, given as an ISO 8601 timestamp. +/// +/// The instant is parsed rather than taken as an opaque string so a malformed +/// scenario value fails here with a parse diagnostic instead of surfacing as an +/// unexplained render mismatch. A provider carrying a non-UTC offset is +/// accepted, and exercises the normalization `now()` is documented to perform. +#[given("the stdlib clock is fixed at {instant:string}")] +pub(crate) fn configure_stdlib_clock(world: &TestWorld, instant: &str) -> Result<()> { + let instant = parse_iso_timestamp(instant)?; + world.stdlib_clock.set(stdlib::fixed_clock(instant)); + Ok(()) +} + #[given("the stdlib fetch response limit is {limit:u64} bytes")] pub(crate) fn configure_fetch_limit(world: &TestWorld, limit: u64) { world.stdlib_fetch_max_bytes.set(limit); diff --git a/tests/bdd/steps/stdlib/rendering.rs b/tests/bdd/steps/stdlib/rendering.rs index fc181e9b9..9344eaf47 100644 --- a/tests/bdd/steps/stdlib/rendering.rs +++ b/tests/bdd/steps/stdlib/rendering.rs @@ -5,7 +5,7 @@ use crate::bdd::types::{ContextKey, ContextValue, TemplateContent}; use anyhow::{Context, Result}; use cap_std::{ambient_authority, fs_utf8::Dir}; use minijinja::{Environment, context, value::Value}; -use netsuke::stdlib::{self, NetworkPolicy, StdlibConfig}; +use netsuke::stdlib::{self, ClockProvider, NetworkPolicy, StdlibConfig}; use rstest_bdd_macros::when; use test_support::{localizer_test_lock, set_en_localizer}; @@ -20,6 +20,7 @@ use super::workspace::{ensure_workspace, resolve_template_path}; struct RenderConfig { policy: Option, home: Option, + clock: Option, fetch_max_bytes: Option, command_max_output_bytes: Option, command_stream_max_bytes: Option, @@ -33,6 +34,7 @@ fn extract_render_config(world: &TestWorld) -> RenderConfig { .borrow() .get("HOME") .and_then(|value| value.to_str().map(str::to_owned)), + clock: world.stdlib_clock.get(), fetch_max_bytes: world.stdlib_fetch_max_bytes.get(), command_max_output_bytes: world.stdlib_command_max_output_bytes.get(), command_stream_max_bytes: world.stdlib_command_stream_max_bytes.get(), @@ -76,6 +78,9 @@ pub(crate) fn render_template_with_context( if let Some(policy) = render_cfg.policy { config = config.with_network_policy(policy); } + if let Some(clock) = render_cfg.clock { + config = config.with_clock(clock); + } if let Some(home) = render_cfg.home { config = config.with_home_override(Some(home)); } diff --git a/tests/features/stdlib_time.feature b/tests/features/stdlib_time.feature index 1aed44680..5a57c824e 100644 --- a/tests/features/stdlib_time.feature +++ b/tests/features/stdlib_time.feature @@ -7,6 +7,18 @@ Feature: Template time helpers When I render the stdlib template "{{ now() }}" without context Then the stdlib output is an ISO8601 UTC timestamp + Scenario: A fixed clock makes now() deterministic + Given a stdlib workspace + And the stdlib clock is fixed at "2026-06-08T12:00:00Z" + When I render the stdlib template "{{ now() }}" without context + Then the stdlib output equals "2026-06-08T12:00:00Z" + + Scenario: A fixed clock renders now() with an offset at the same instant + Given a stdlib workspace + And the stdlib clock is fixed at "2026-06-08T17:30:00+05:30" + When I render the stdlib template "{{ now(offset='+02:00') }}" without context + Then the stdlib output equals "2026-06-08T14:00:00+02:00" + Scenario: Rendering now() with an offset preserves the offset Given a stdlib workspace When I render the stdlib template "{{ now(offset='+02:00').iso8601 }}" without context diff --git a/tests/std_filter_tests.rs b/tests/std_filter_tests.rs index c90f3c881..a877346e2 100644 --- a/tests/std_filter_tests.rs +++ b/tests/std_filter_tests.rs @@ -21,6 +21,8 @@ mod path_filters; mod read_policy_filters; #[path = "std_filter_tests/support.rs"] mod support; +#[path = "std_filter_tests/time_functions.rs"] +mod time_functions; #[path = "std_filter_tests/which_filter_common.rs"] mod which_filter_common; #[path = "std_filter_tests/which_filter_tests.rs"] diff --git a/tests/std_filter_tests/support.rs b/tests/std_filter_tests/support.rs index 6e66117c2..886420b0d 100644 --- a/tests/std_filter_tests/support.rs +++ b/tests/std_filter_tests/support.rs @@ -89,6 +89,13 @@ pub(crate) mod fallible { stdlib_env_with_config(config).map(|(env, _)| env) } + pub(crate) fn stdlib_env_with_clock( + provider: stdlib::ClockProvider, + ) -> Result> { + let config = StdlibConfig::from_current_dir()?.with_clock(provider); + stdlib_env_with_config(config).map(|(env, _)| env) + } + pub(crate) fn stdlib_env() -> Result> { stdlib_env_with_state().map(|(env, _)| env) } diff --git a/tests/std_filter_tests/time_functions.rs b/tests/std_filter_tests/time_functions.rs new file mode 100644 index 000000000..003433447 --- /dev/null +++ b/tests/std_filter_tests/time_functions.rs @@ -0,0 +1,107 @@ +//! Integration coverage for the stdlib `now()` clock seam. +//! +//! These cases render through the real `stdlib::register_with_config` +//! entrypoint, so they exercise the whole path a caller takes: configuration, +//! registration, and template evaluation. The unit cases in +//! `src/stdlib/time/tests.rs` assert the seam's internals; these assert that the +//! wiring between a caller's configuration and the registered helper is intact. +//! +//! Rendered strings are compared against the documented rendering rather than +//! against output captured from a run. `now()` renders through +//! `Iso8601::DEFAULT`, which includes separators, resolves the offset to the +//! minute, and writes UTC as `Z`; the zero fractional part is stripped by the +//! formatter. + +use anyhow::{Context, Result, ensure}; +use netsuke::stdlib::fixed_clock; +use rstest::rstest; +use time::{ + Duration, OffsetDateTime, UtcOffset, format_description::well_known::Iso8601, macros::datetime, +}; + +use super::support::fallible; + +/// Render `template` in an environment whose clock is fixed at `instant`. +fn render_with_fixed_clock(instant: OffsetDateTime, template: &str) -> Result { + let env = fallible::stdlib_env_with_clock(fixed_clock(instant))?; + env.render_str(template, ()) + .with_context(|| format!("render `{template}`")) +} + +/// Parse a timestamp rendered by `now()`. +fn parse_rendered(rendered: &str) -> Result { + OffsetDateTime::parse(rendered, &Iso8601::DEFAULT) + .with_context(|| format!("parse rendered timestamp `{rendered}`")) +} + +#[rstest] +#[case::utc(datetime!(2026-06-08 12:00:00 UTC), "2026-06-08T12:00:00Z")] +#[case::epoch(datetime!(1970-01-01 00:00:00 UTC), "1970-01-01T00:00:00Z")] +#[case::non_utc(datetime!(2026-06-08 17:30:00 +05:30), "2026-06-08T12:00:00Z")] +fn now_uses_configured_clock( + #[case] instant: OffsetDateTime, + #[case] expected: &str, +) -> Result<()> { + let rendered = render_with_fixed_clock(instant, "{{ now() }}")?; + + ensure!( + rendered == expected, + "expected `{expected}` but rendered `{rendered}`" + ); + Ok(()) +} + +/// Without `with_clock`, `now()` still reads the host clock and reports UTC. +/// +/// A tolerance against the real clock is the only oracle available for an +/// ambient read, and it is sufficient: the failure this guards against is a +/// default that is frozen or offset rather than live, which a seconds-scale +/// window detects immediately. +#[rstest] +fn now_without_a_clock_reads_the_host() -> Result<()> { + let env = fallible::stdlib_env()?; + let rendered = env + .render_str("{{ now() }}", ()) + .context("render `now()`")?; + let parsed = parse_rendered(&rendered)?; + + let delta = (OffsetDateTime::now_utc() - parsed).abs(); + ensure!( + delta <= Duration::seconds(3), + "ambient now() rendered `{rendered}`, which is {delta:?} from the host clock" + ); + ensure!( + parsed.offset() == UtcOffset::UTC, + "ambient now() rendered `{rendered}` without a UTC offset" + ); + Ok(()) +} + +/// An offset re-expresses the configured instant rather than shifting it. +/// +/// Both assertions are load-bearing: the string comparison fixes the rendering, +/// and the instant comparison rejects an implementation that applies the offset +/// by moving the timestamp instead of by changing its representation. +#[rstest] +#[case::forward("+02:00", "2026-06-08T14:00:00+02:00")] +#[case::backward("-05:00", "2026-06-08T07:00:00-05:00")] +#[case::utc("Z", "2026-06-08T12:00:00Z")] +fn now_applies_offset_to_the_configured_clock( + #[case] offset_spec: &str, + #[case] expected: &str, +) -> Result<()> { + let instant = datetime!(2026-06-08 12:00:00 UTC); + let rendered = + render_with_fixed_clock(instant, &format!("{{{{ now(offset='{offset_spec}') }}}}"))?; + let parsed = parse_rendered(&rendered)?; + + ensure!( + rendered == expected, + "expected `{expected}` but rendered `{rendered}`" + ); + ensure!( + parsed.unix_timestamp() == instant.unix_timestamp(), + "offset `{offset_spec}` moved the instant: `{rendered}`" + ); + Ok(()) +} From d484643cc415399fd122785b7380ba9558bf93e0 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 11 Sep 2026 00:59:05 +0200 Subject: [PATCH 08/54] Split the time test module and unshadow the BDD clock step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gate failures surfaced while running the 7.1.1 clock-seam commit gates. Neither is behavioural; both are lint-gate violations in test code. `clippy::shadow_reuse` is denied workspace-wide, and the new BDD step bound its parsed instant back over the `&str` capture it came from. Rename the binding to `parsed`, matching the convention already used in `tests/bdd/steps/stdlib/assertions.rs`. Note that `RUSTFLAGS="-D warnings" cargo check --all-targets` does not catch this, so a clean check was not evidence the lint gate would pass. The Whitaker suite caps a module at 400 lines. `src/stdlib/time/tests.rs` had reached 472, so split it along the seam it already had: - `clock_tests.rs` — where `now()` reads its instant from: the ambient fallback (C1), the injected provider, per-call provider consultation, offset application to an injected instant, and the C2 query-mode guarantees; - `tests.rs` — clock-independent behaviour: offset parsing, `timedelta` arithmetic, and ISO 8601 formatting; - `tests_support.rs` — the evaluation and value-inspection helpers both modules need. All three are declared under `#[cfg(test)]` in `src/stdlib/time/mod.rs`, matching the existing `network` and `command` test layout. The lint measures each file separately rather than recursively, so siblings are what relieve the pressure. The test count is unchanged at 49. Record the three findings in the exec plan, including that `make lint` on this branch needs `PATH="$HOME/go/bin:$PATH"` because the base commit predates the Makefile's curated `GO_BIN` lookup. Co-Authored-By: Claude Code --- docs/execplans/7-1-1-clock-provider-seam.md | 43 +++ src/stdlib/time/clock_tests.rs | 260 +++++++++++++++++ src/stdlib/time/mod.rs | 4 + src/stdlib/time/tests.rs | 299 +------------------- src/stdlib/time/tests_support.rs | 58 ++++ tests/bdd/steps/stdlib/config.rs | 4 +- 6 files changed, 377 insertions(+), 291 deletions(-) create mode 100644 src/stdlib/time/clock_tests.rs create mode 100644 src/stdlib/time/tests_support.rs diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index f505ee4b5..ca19656f4 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -1571,6 +1571,49 @@ Recorded during planning; extend during implementation. rendering `2026-06-08T12:00:00Z`; a separate offset-attribute assertion would have been redundant, so none was added. +- Observation: `clippy::shadow_reuse` is denied workspace-wide, so a step + function may not reuse its capture name for a parsed binding. Evidence: + `make lint` failed with + + ```plaintext + error: `instant` is shadowed + --> tests/bdd/steps/stdlib/config.rs:20:9 + | + 20 | let instant = parse_iso_timestamp(instant)?; + | ^^^^^^^ + = note: requested on the command line with `-D clippy::shadow-reuse` + ``` + + Impact: the parsed binding is named `parsed`, which also matches the existing + convention in `tests/bdd/steps/stdlib/assertions.rs`. Note that + `RUSTFLAGS="-D warnings" cargo check --all-targets` does *not* catch this; + only Clippy does, so a clean `cargo check` is not evidence that the lint gate + will pass. + +- Observation: the Whitaker suite caps a module at 400 lines, and the cap + applies per file rather than recursively. Evidence: `make lint` failed with + `error: Module tests spans 472 lines, exceeding the allowed 400.` at + `src/stdlib/time/mod.rs:240:5`. Reading the lint's implementation confirms + `module_max_lines` spans each out-of-line module across its own file only, so + sibling modules declared in `mod.rs` each get their own budget. Impact: + `src/stdlib/time/tests.rs` was split into `clock_tests.rs` (the seam: where + `now()` reads its instant, plus the C2 query-mode guarantees), `tests.rs` + (clock-independent behaviour: offset parsing, formatting, `timedelta`), and + `tests_support.rs` (the fixtures and helpers both need), all three declared + under `#[cfg(test)]` in `src/stdlib/time/mod.rs`. This mirrors the existing + `network` and `command` test layout. The test count is unchanged at 49, so + the split is behaviour-preserving. + +- Observation: this branch predates the Makefile's `GO_BIN` curation, so + `make lint` cannot find `actionlint`. Evidence: + `make: actionlint: No such file or directory` / + `Makefile:204: github-actions-lint` while `/home/leynos/go/bin/actionlint` + exists. Impact: `make lint` must be run as + `PATH="$HOME/go/bin:$PATH" make lint` on this branch; the other gates are + unaffected. This is a branch-state artefact of the base commit, not something + this change introduced, and it disappears once the branch is rebased past the + `GO_BIN` commit. + ## Decision log - **D1 — Port shape follows the `EnvReader` precedent verbatim.** diff --git a/src/stdlib/time/clock_tests.rs b/src/stdlib/time/clock_tests.rs new file mode 100644 index 000000000..1e6a13e26 --- /dev/null +++ b/src/stdlib/time/clock_tests.rs @@ -0,0 +1,260 @@ +//! Tests for the stdlib clock seam. +//! +//! These pin *where* `now()` reads its instant from: the ambient host clock +//! when no provider is configured, and the injected provider otherwise; plus +//! the guarantee that manifest-query registration never acquires a clock. +//! Behaviour that is independent of the clock's source — offset parsing, +//! formatting, `timedelta` — lives in `super::tests`. + +use super::*; +use anyhow::{Context, Result, bail, ensure}; +use googletest::prelude::*; +use minijinja::{Environment, ErrorKind, context}; +use proptest::prelude::*; +use rstest::rstest; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; +use time::{OffsetDateTime, macros::datetime}; + +use super::tests_support::{env, eval_expression, value_as_timestamp}; + +/// Build an environment whose `now()` always reports `instant`. +fn env_with_fixed_clock(instant: OffsetDateTime) -> Environment<'static> { + let mut env = Environment::new(); + register_functions(&mut env, WallClock::new(fixed_clock(instant))); + env +} + +/// Build an environment whose `now()` reports `first`, then each element of +/// `rest` in turn, saturating at the last, and recording how many times the +/// provider was consulted. +/// +/// Taking `first` separately makes non-emptiness a type-level precondition. +fn env_with_sequenced_clock( + first: OffsetDateTime, + rest: &[OffsetDateTime], +) -> (Environment<'static>, Arc) { + let mut instants = vec![first]; + instants.extend_from_slice(rest); + let calls = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&calls); + let provider: ClockProvider = Arc::new(move || { + let index = counter.fetch_add(1, Ordering::SeqCst); + instants + .get(index) + .or_else(|| instants.last()) + .copied() + .unwrap_or(first) + }); + let mut env = Environment::new(); + register_functions(&mut env, WallClock::new(provider)); + (env, calls) +} + +/// Constraint C1: with no provider configured, `now()` still reads the host +/// clock and reports UTC. +#[rstest] +fn now_defaults_to_utc(env: Environment<'static>) -> Result<()> { + let value = eval_expression(&env, "now()")?; + let captured = value_as_timestamp(&value)?; + let now = OffsetDateTime::now_utc(); + let delta = (now - captured).abs(); + ensure!(delta <= Duration::seconds(3), "delta {delta:?} too large"); + ensure!(captured.offset() == UtcOffset::UTC); + Ok(()) +} + +/// An injected instant is reported verbatim, whatever offset its provider +/// carries. The non-UTC case is load-bearing: [`WallClock::read`] normalizes +/// to UTC, and without that normalization a fixture built from a local-time +/// literal would make `now()` render a timestamp production never produces. +#[rstest] +#[case::utc(datetime!(2026-06-08 12:00:00 UTC))] +#[case::epoch(datetime!(1970-01-01 00:00:00 UTC))] +#[case::far_future(datetime!(2099-12-31 23:59:59 UTC))] +#[case::non_utc(datetime!(2026-06-08 17:30:00 +05:30))] +fn now_uses_injected_clock(#[case] instant: OffsetDateTime) -> Result<()> { + let env = env_with_fixed_clock(instant); + let value = eval_expression(&env, "now()")?; + let captured = value_as_timestamp(&value)?; + + assert_that!(captured, eq(instant)); + assert_that!(captured.offset(), eq(UtcOffset::UTC)); + Ok(()) +} + +/// Two evaluations under one fixed provider agree, including two calls within +/// a single expression — the property a manifest author actually relies on. +#[rstest] +fn now_repeats_the_injected_instant() -> Result<()> { + let fixed = datetime!(2026-06-08 12:00:00 UTC); + let env = env_with_fixed_clock(fixed); + + let first = value_as_timestamp(&eval_expression(&env, "now()")?)?; + let second = value_as_timestamp(&eval_expression(&env, "now()")?)?; + assert_that!(first, eq(fixed)); + assert_that!(second, eq(fixed)); + + let joined = eval_expression(&env, "now().iso8601 ~ '|' ~ now().iso8601")?; + assert_that!( + joined.as_str(), + eq(Some("2026-06-08T12:00:00Z|2026-06-08T12:00:00Z")) + ); + Ok(()) +} + +/// The provider is consulted afresh on every call rather than read once while +/// the registered closure is built. A fixed provider cannot distinguish the +/// two, so the negative control needs a provider whose output varies. +#[rstest] +fn now_reads_the_provider_on_every_call() -> Result<()> { + let (env, _calls) = env_with_sequenced_clock( + datetime!(2026-06-08 12:00:00 UTC), + &[ + datetime!(2026-06-08 13:00:00 UTC), + datetime!(2026-06-08 14:00:00 UTC), + ], + ); + + let first = value_as_timestamp(&eval_expression(&env, "now()")?)?; + let second = value_as_timestamp(&eval_expression(&env, "now()")?)?; + let third = value_as_timestamp(&eval_expression(&env, "now()")?)?; + + assert_that!(first, eq(datetime!(2026-06-08 12:00:00 UTC))); + assert_that!(second, eq(datetime!(2026-06-08 13:00:00 UTC))); + assert_that!(third, eq(datetime!(2026-06-08 14:00:00 UTC))); + Ok(()) +} + +/// Each `now()` evaluation consults the provider exactly once. The expected +/// count is derived from the evaluations performed, so a re-reading +/// implementation fails on the count rather than on a stale literal. +#[rstest] +fn now_invokes_the_provider_once_per_call() -> Result<()> { + let (env, calls) = env_with_sequenced_clock( + datetime!(2026-06-08 12:00:00 UTC), + &[datetime!(2026-06-08 13:00:00 UTC)], + ); + + let expressions = ["now()", "now(offset='+02:00')"]; + for expression in expressions { + eval_expression(&env, expression)?; + } + + assert_that!(calls.load(Ordering::SeqCst), eq(expressions.len())); + Ok(()) +} + +/// Applying an offset re-expresses the injected instant, preserving it. Both +/// conjuncts are load-bearing: an arithmetic shift keeps the offset but moves +/// the instant, and dropping the offset keeps the instant but loses the +/// requested representation. +#[rstest] +#[case("Z")] +#[case("+00:00")] +#[case("+02:30")] +#[case("-05:00")] +#[case("+23:59:59")] +#[case("-23:59:59")] +fn now_applies_offset_to_the_injected_instant(#[case] offset_spec: &str) -> Result<()> { + let fixed = datetime!(2026-06-08 12:00:00 UTC); + let env = env_with_fixed_clock(fixed); + let value = eval_expression(&env, &format!("now(offset='{offset_spec}')"))?; + let captured = value_as_timestamp(&value)?; + let expected_offset = parse_offset(offset_spec)?; + + assert_that!(captured.unix_timestamp(), eq(fixed.unix_timestamp())); + assert_that!(captured.offset(), eq(expected_offset)); + Ok(()) +} + +proptest! { + /// Re-expressing the injected instant in any valid offset preserves it. + #[test] + fn now_offset_preserves_the_instant( + sign in prop_oneof![Just("+"), Just("-")], + hour in 0_u8..24, + minute in 0_u8..60, + second in 0_u8..60, + ) { + let offset = format!("{sign}{hour:02}:{minute:02}:{second:02}"); + let fixed = datetime!(2026-06-08 12:00:00 UTC); + let env = env_with_fixed_clock(fixed); + let expression = format!("now(offset='{offset}')"); + let value = eval_expression(&env, &expression) + .map_err(|err| TestCaseError::fail(format!("evaluating {expression}: {err}")))?; + let captured = value_as_timestamp(&value) + .map_err(|err| TestCaseError::fail(format!("reading {expression}: {err}")))?; + let expected_offset = parse_offset(&offset) + .map_err(|err| TestCaseError::fail(format!("parsing {offset}: {err}")))?; + + prop_assert_eq!(captured.unix_timestamp(), fixed.unix_timestamp()); + prop_assert_eq!(captured.offset(), expected_offset); + } +} + +/// Evaluate `expr`, returning the raw `MiniJinja` error it raised. +/// +/// The typed error is returned rather than an `anyhow` one because the +/// manifest-query marker is inspected through `minijinja::Error`; wrapping it +/// first would hide the type the assertion needs. +fn eval_expression_error(env: &Environment<'_>, expr: &str) -> Result { + let compiled = env + .compile_expression(expr) + .with_context(|| format!("compiling expression: {expr}"))?; + match compiled.eval(context! {}) { + Ok(value) => bail!("expected {expr} to fail, but it produced {value:?}"), + Err(error) => Ok(error), + } +} + +/// Constraint C2: manifest-query registration must keep refusing `now()`, so +/// discovery metadata can never disclose host time. The refusal is asserted to +/// name `now` rather than merely carry the marker, which rejects a copy-pasted +/// stub registered under the wrong helper name. +#[rstest] +#[case::bare("now()")] +#[case::offset("now(offset='+02:00')")] +fn manifest_query_registration_refuses_now(#[case] expression: &str) -> Result<()> { + let mut env = Environment::new(); + let _state = crate::stdlib::register_manifest_query(&mut env); + + let error = eval_expression_error(&env, expression)?; + + assert_that!( + crate::stdlib::is_manifest_query_disabled_error(&error), + eq(true) + ); + ensure!( + error + .detail() + .unwrap_or_default() + .starts_with("now is disabled while rendering"), + "refusal should name the `now` helper: {error}" + ); + Ok(()) +} + +/// The permissive half of query registration stays clock-free: it does not +/// define `now` at all. This is the sharper half of C2 — a helper that was +/// never declared cannot acquire a clock by having one threaded into it — and +/// it is distinguished from the refusal above by the error kind rather than by +/// the mere absence of a value. +#[rstest] +#[case::bare("now()")] +#[case::offset("now(offset='+02:00')")] +fn query_functions_do_not_define_now(#[case] expression: &str) -> Result<()> { + let mut env = Environment::new(); + register_query_functions(&mut env); + + let error = eval_expression_error(&env, expression)?; + + assert_that!(error.kind(), eq(ErrorKind::UnknownFunction)); + assert_that!( + crate::stdlib::is_manifest_query_disabled_error(&error), + eq(false) + ); + Ok(()) +} diff --git a/src/stdlib/time/mod.rs b/src/stdlib/time/mod.rs index 1644d975b..33dfdceec 100644 --- a/src/stdlib/time/mod.rs +++ b/src/stdlib/time/mod.rs @@ -236,5 +236,9 @@ fn timedelta(kwargs: &Kwargs) -> Result { Ok(Value::from_object(TimeDeltaValue::new(total))) } +#[cfg(test)] +mod clock_tests; #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_support; diff --git a/src/stdlib/time/tests.rs b/src/stdlib/time/tests.rs index 0b12f0761..ba627d5e6 100644 --- a/src/stdlib/time/tests.rs +++ b/src/stdlib/time/tests.rs @@ -1,105 +1,19 @@ //! Tests for the stdlib time helpers, validating timestamp and duration //! conversions alongside ISO 8601 formatting. The cases assert that `now` -//! respects UTC defaults, applies caller-provided offsets, rejects malformed -//! offsets, and that helper functions expose consistent object wrappers for -//! downstream template evaluation. +//! applies caller-provided offsets, rejects malformed offsets, and that helper +//! functions expose consistent object wrappers for downstream template +//! evaluation. Tests that pin *where* `now()` reads its instant from live in +//! `super::clock_tests`. use super::*; -use anyhow::{Context, Result, anyhow, bail, ensure}; -use googletest::prelude::*; -use minijinja::{Environment, ErrorKind, context, value::Value}; +use anyhow::{Result, anyhow, ensure}; +use minijinja::{ErrorKind, context}; use proptest::prelude::*; -use rstest::{fixture, rstest}; -use std::sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, -}; +use rstest::rstest; use time::{Duration, OffsetDateTime, UtcOffset, macros::datetime}; -fn eval_expression(env: &Environment<'_>, expr: &str) -> Result { - let compiled = env - .compile_expression(expr) - .with_context(|| format!("compiling expression: {expr}"))?; - compiled - .eval(context! {}) - .with_context(|| format!("evaluating expression: {expr}")) -} - -#[fixture] -fn env() -> Environment<'static> { - let mut env = Environment::new(); - register_functions(&mut env, WallClock::default()); - env -} - -/// Build an environment whose `now()` always reports `instant`. -fn env_with_fixed_clock(instant: OffsetDateTime) -> Environment<'static> { - let mut env = Environment::new(); - register_functions(&mut env, WallClock::new(fixed_clock(instant))); - env -} - -/// Build an environment whose `now()` reports `first`, then each element of -/// `rest` in turn, saturating at the last, and recording how many times the -/// provider was consulted. -/// -/// Taking `first` separately makes non-emptiness a type-level precondition. -fn env_with_sequenced_clock( - first: OffsetDateTime, - rest: &[OffsetDateTime], -) -> (Environment<'static>, Arc) { - let mut instants = vec![first]; - instants.extend_from_slice(rest); - let calls = Arc::new(AtomicUsize::new(0)); - let counter = Arc::clone(&calls); - let provider: ClockProvider = Arc::new(move || { - let index = counter.fetch_add(1, Ordering::SeqCst); - instants - .get(index) - .or_else(|| instants.last()) - .copied() - .unwrap_or(first) - }); - let mut env = Environment::new(); - register_functions(&mut env, WallClock::new(provider)); - (env, calls) -} - -fn value_as_timestamp(value: &Value) -> Result { - value - .as_object() - .and_then(|obj| obj.downcast_ref::()) - .map(|stored| stored.datetime) - .ok_or_else(|| anyhow!("value is not a timestamp object: {value:?}")) -} - -fn value_as_duration(value: &Value) -> Result { - value - .as_object() - .and_then(|obj| obj.downcast_ref::()) - .map(|stored| stored.duration) - .ok_or_else(|| anyhow!("value is not a duration object: {value:?}")) -} - -fn get_iso8601_property(value: &Value) -> Result { - let obj = value.as_object().context("value is not an object")?; - let iso = obj - .get_value(&Value::from("iso8601")) - .context("iso8601 attribute missing")?; - iso.as_str() - .map(ToOwned::to_owned) - .context("iso8601 attribute is not a string") -} - -#[rstest] -fn now_defaults_to_utc(env: Environment<'static>) -> Result<()> { - let value = eval_expression(&env, "now()")?; - let captured = value_as_timestamp(&value)?; - let now = OffsetDateTime::now_utc(); - let delta = (now - captured).abs(); - ensure!(delta <= Duration::seconds(3), "delta {delta:?} too large"); - ensure!(captured.offset() == UtcOffset::UTC); - Ok(()) -} +use super::tests_support::{ + env, eval_expression, get_iso8601_property, value_as_duration, value_as_timestamp, +}; #[rstest] fn now_applies_custom_offset(env: Environment<'static>) -> Result<()> { @@ -140,135 +54,6 @@ fn now_rejects_invalid_offset(env: Environment<'static>, #[case] offset: &str) - } } -/// An injected instant is reported verbatim, whatever offset its provider -/// carries. The non-UTC case is load-bearing: [`WallClock::read`] normalizes -/// to UTC, and without that normalization a fixture built from a local-time -/// literal would make `now()` render a timestamp production never produces. -#[rstest] -#[case::utc(datetime!(2026-06-08 12:00:00 UTC))] -#[case::epoch(datetime!(1970-01-01 00:00:00 UTC))] -#[case::far_future(datetime!(2099-12-31 23:59:59 UTC))] -#[case::non_utc(datetime!(2026-06-08 17:30:00 +05:30))] -fn now_uses_injected_clock(#[case] instant: OffsetDateTime) -> Result<()> { - let env = env_with_fixed_clock(instant); - let value = eval_expression(&env, "now()")?; - let captured = value_as_timestamp(&value)?; - - assert_that!(captured, eq(instant)); - assert_that!(captured.offset(), eq(UtcOffset::UTC)); - Ok(()) -} - -/// Two evaluations under one fixed provider agree, including two calls within -/// a single expression — the property a manifest author actually relies on. -#[rstest] -fn now_repeats_the_injected_instant() -> Result<()> { - let fixed = datetime!(2026-06-08 12:00:00 UTC); - let env = env_with_fixed_clock(fixed); - - let first = value_as_timestamp(&eval_expression(&env, "now()")?)?; - let second = value_as_timestamp(&eval_expression(&env, "now()")?)?; - assert_that!(first, eq(fixed)); - assert_that!(second, eq(fixed)); - - let joined = eval_expression(&env, "now().iso8601 ~ '|' ~ now().iso8601")?; - assert_that!( - joined.as_str(), - eq(Some("2026-06-08T12:00:00Z|2026-06-08T12:00:00Z")) - ); - Ok(()) -} - -/// The provider is consulted afresh on every call rather than read once while -/// the registered closure is built. A fixed provider cannot distinguish the -/// two, so the negative control needs a provider whose output varies. -#[rstest] -fn now_reads_the_provider_on_every_call() -> Result<()> { - let (env, _calls) = env_with_sequenced_clock( - datetime!(2026-06-08 12:00:00 UTC), - &[ - datetime!(2026-06-08 13:00:00 UTC), - datetime!(2026-06-08 14:00:00 UTC), - ], - ); - - let first = value_as_timestamp(&eval_expression(&env, "now()")?)?; - let second = value_as_timestamp(&eval_expression(&env, "now()")?)?; - let third = value_as_timestamp(&eval_expression(&env, "now()")?)?; - - assert_that!(first, eq(datetime!(2026-06-08 12:00:00 UTC))); - assert_that!(second, eq(datetime!(2026-06-08 13:00:00 UTC))); - assert_that!(third, eq(datetime!(2026-06-08 14:00:00 UTC))); - Ok(()) -} - -/// Each `now()` evaluation consults the provider exactly once. The expected -/// count is derived from the evaluations performed, so a re-reading -/// implementation fails on the count rather than on a stale literal. -#[rstest] -fn now_invokes_the_provider_once_per_call() -> Result<()> { - let (env, calls) = env_with_sequenced_clock( - datetime!(2026-06-08 12:00:00 UTC), - &[datetime!(2026-06-08 13:00:00 UTC)], - ); - - let expressions = ["now()", "now(offset='+02:00')"]; - for expression in expressions { - eval_expression(&env, expression)?; - } - - assert_that!(calls.load(Ordering::SeqCst), eq(expressions.len())); - Ok(()) -} - -/// Applying an offset re-expresses the injected instant, preserving it. Both -/// conjuncts are load-bearing: an arithmetic shift keeps the offset but moves -/// the instant, and dropping the offset keeps the instant but loses the -/// requested representation. -#[rstest] -#[case("Z")] -#[case("+00:00")] -#[case("+02:30")] -#[case("-05:00")] -#[case("+23:59:59")] -#[case("-23:59:59")] -fn now_applies_offset_to_the_injected_instant(#[case] offset_spec: &str) -> Result<()> { - let fixed = datetime!(2026-06-08 12:00:00 UTC); - let env = env_with_fixed_clock(fixed); - let value = eval_expression(&env, &format!("now(offset='{offset_spec}')"))?; - let captured = value_as_timestamp(&value)?; - let expected_offset = parse_offset(offset_spec)?; - - assert_that!(captured.unix_timestamp(), eq(fixed.unix_timestamp())); - assert_that!(captured.offset(), eq(expected_offset)); - Ok(()) -} - -proptest! { - /// Re-expressing the injected instant in any valid offset preserves it. - #[test] - fn now_offset_preserves_the_instant( - sign in prop_oneof![Just("+"), Just("-")], - hour in 0_u8..24, - minute in 0_u8..60, - second in 0_u8..60, - ) { - let offset = format!("{sign}{hour:02}:{minute:02}:{second:02}"); - let fixed = datetime!(2026-06-08 12:00:00 UTC); - let env = env_with_fixed_clock(fixed); - let expression = format!("now(offset='{offset}')"); - let value = eval_expression(&env, &expression) - .map_err(|err| TestCaseError::fail(format!("evaluating {expression}: {err}")))?; - let captured = value_as_timestamp(&value) - .map_err(|err| TestCaseError::fail(format!("reading {expression}: {err}")))?; - let expected_offset = parse_offset(&offset) - .map_err(|err| TestCaseError::fail(format!("parsing {offset}: {err}")))?; - - prop_assert_eq!(captured.unix_timestamp(), fixed.unix_timestamp()); - prop_assert_eq!(captured.offset(), expected_offset); - } -} - proptest! { /// Accept signed offsets whose hour component stays within one civil day. #[test] @@ -295,70 +80,6 @@ proptest! { } } -/// Evaluate `expr`, returning the raw `MiniJinja` error it raised. -/// -/// The typed error is returned rather than an `anyhow` one because the -/// manifest-query marker is inspected through `minijinja::Error`; wrapping it -/// first would hide the type the assertion needs. -fn eval_expression_error(env: &Environment<'_>, expr: &str) -> Result { - let compiled = env - .compile_expression(expr) - .with_context(|| format!("compiling expression: {expr}"))?; - match compiled.eval(context! {}) { - Ok(value) => bail!("expected {expr} to fail, but it produced {value:?}"), - Err(error) => Ok(error), - } -} - -/// Constraint C2: manifest-query registration must keep refusing `now()`, so -/// discovery metadata can never disclose host time. The refusal is asserted to -/// name `now` rather than merely carry the marker, which rejects a copy-pasted -/// stub registered under the wrong helper name. -#[rstest] -#[case::bare("now()")] -#[case::offset("now(offset='+02:00')")] -fn manifest_query_registration_refuses_now(#[case] expression: &str) -> Result<()> { - let mut env = Environment::new(); - let _state = crate::stdlib::register_manifest_query(&mut env); - - let error = eval_expression_error(&env, expression)?; - - assert_that!( - crate::stdlib::is_manifest_query_disabled_error(&error), - eq(true) - ); - ensure!( - error - .detail() - .unwrap_or_default() - .starts_with("now is disabled while rendering"), - "refusal should name the `now` helper: {error}" - ); - Ok(()) -} - -/// The permissive half of query registration stays clock-free: it does not -/// define `now` at all. This is the sharper half of C2 — a helper that was -/// never declared cannot acquire a clock by having one threaded into it — and -/// it is distinguished from the refusal above by the error kind rather than by -/// the mere absence of a value. -#[rstest] -#[case::bare("now()")] -#[case::offset("now(offset='+02:00')")] -fn query_functions_do_not_define_now(#[case] expression: &str) -> Result<()> { - let mut env = Environment::new(); - register_query_functions(&mut env); - - let error = eval_expression_error(&env, expression)?; - - assert_that!(error.kind(), eq(ErrorKind::UnknownFunction)); - assert_that!( - crate::stdlib::is_manifest_query_disabled_error(&error), - eq(false) - ); - Ok(()) -} - #[rstest] fn timedelta_defaults_to_zero(env: Environment<'static>) -> Result<()> { let value = eval_expression(&env, "timedelta()")?; diff --git a/src/stdlib/time/tests_support.rs b/src/stdlib/time/tests_support.rs new file mode 100644 index 000000000..31d8d66d0 --- /dev/null +++ b/src/stdlib/time/tests_support.rs @@ -0,0 +1,58 @@ +//! Shared fixtures and helpers for the stdlib time tests. +//! +//! Evaluation and value-inspection boilerplate lives here so the behaviour +//! tests in the `tests` module and the clock-seam tests in the `clock_tests` +//! module can both stay focused on what they assert. + +use super::*; +use anyhow::{Context, Result, anyhow}; +use minijinja::{Environment, context, value::Value}; +use rstest::fixture; +use time::{Duration, OffsetDateTime}; + +/// Evaluate `expr` against `env`, contextualizing compile and run errors. +pub(super) fn eval_expression(env: &Environment<'_>, expr: &str) -> Result { + let compiled = env + .compile_expression(expr) + .with_context(|| format!("compiling expression: {expr}"))?; + compiled + .eval(context! {}) + .with_context(|| format!("evaluating expression: {expr}")) +} + +/// Build an environment whose `now()` reads the ambient host clock. +#[fixture] +pub(super) fn env() -> Environment<'static> { + let mut env = Environment::new(); + register_functions(&mut env, WallClock::default()); + env +} + +/// Downcast `value` to the timestamp object `now()` produces. +pub(super) fn value_as_timestamp(value: &Value) -> Result { + value + .as_object() + .and_then(|obj| obj.downcast_ref::()) + .map(|stored| stored.datetime) + .ok_or_else(|| anyhow!("value is not a timestamp object: {value:?}")) +} + +/// Downcast `value` to the duration object `timedelta()` produces. +pub(super) fn value_as_duration(value: &Value) -> Result { + value + .as_object() + .and_then(|obj| obj.downcast_ref::()) + .map(|stored| stored.duration) + .ok_or_else(|| anyhow!("value is not a duration object: {value:?}")) +} + +/// Read the `iso8601` attribute of `value` as a string. +pub(super) fn get_iso8601_property(value: &Value) -> Result { + let obj = value.as_object().context("value is not an object")?; + let iso = obj + .get_value(&Value::from("iso8601")) + .context("iso8601 attribute missing")?; + iso.as_str() + .map(ToOwned::to_owned) + .context("iso8601 attribute is not a string") +} diff --git a/tests/bdd/steps/stdlib/config.rs b/tests/bdd/steps/stdlib/config.rs index 6914151d7..136908339 100644 --- a/tests/bdd/steps/stdlib/config.rs +++ b/tests/bdd/steps/stdlib/config.rs @@ -17,8 +17,8 @@ use super::parsing::parse_iso_timestamp; /// accepted, and exercises the normalization `now()` is documented to perform. #[given("the stdlib clock is fixed at {instant:string}")] pub(crate) fn configure_stdlib_clock(world: &TestWorld, instant: &str) -> Result<()> { - let instant = parse_iso_timestamp(instant)?; - world.stdlib_clock.set(stdlib::fixed_clock(instant)); + let parsed = parse_iso_timestamp(instant)?; + world.stdlib_clock.set(stdlib::fixed_clock(parsed)); Ok(()) } From 1b5ffee96dd060ff885a56868237ed8dabbbc278 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 11 Sep 2026 01:03:38 +0200 Subject: [PATCH 09/54] Record EP-M3 gate results in the 7.1.1 exec plan All four commit gates pass, plus the repository's Markdown and diagram gates and `make doc-coverage`: - `make test`: 2830 tests run, 2830 passed, 3 skipped. - `make doc-coverage`: aggregate 99.15% against an 80% threshold. - `make lint`: needed `PATH="$HOME/go/bin:$PATH"` on this branch; see the Surprises entry for why. - `make markdownlint` and `make nixie`: clean. The first `coderabbit review --agent` pass over `e682ac98` and `ccf63eb0` completed without rate limiting, reviewing all 16 changed files with zero findings, so the milestone can close. Co-Authored-By: Claude Code --- docs/execplans/7-1-1-clock-provider-seam.md | 41 +++++++++++++++++++-- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index ca19656f4..0f1db37be 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -6,7 +6,7 @@ This ExecPlan (execution plan) is a living document. The sections `Constraints`, `Conformance basis`, and `Verification plan` must be kept up to date as work proceeds. -Status: IN PROGRESS (EP-M0, EP-M1 and EP-M2 complete; EP-M3 in progress) +Status: IN PROGRESS (EP-M0 through EP-M3 complete; EP-M4 in progress) ## Purpose / big picture @@ -1439,7 +1439,8 @@ above, which are disposable. merged with EP-M2's configuration ownership (see Surprises & discoveries), and OBL-5 added. - [x] EP-M2 — integration and BDD coverage over the real registration path. -- [ ] EP-M3 — All four gates green. +- [x] EP-M3 — All four gates green, plus the repository's Markdown and diagram + gates and the first CodeRabbit pass (zero findings). - [ ] EP-M4 — ADR-008 addendum, developers' guide, technical design §5.2. - [ ] EP-M5 — Roadmap 7.1.1 marked done. @@ -1895,8 +1896,40 @@ To be populated during implementation. Required entries: The full-suite `make test` summary is recorded at EP-M3. -3. The four mutation outcomes from `Validation and acceptance`. -4. The final gate transcript tails for `check-fmt`, `typecheck`, `lint`, and +3. The full-suite `make test` summary at EP-M3, verbatim: + + ```plaintext + Summary [ 59.616s] 2830 tests run: 2830 passed, 3 skipped + test result: ok. 86 passed; 0 failed; 25 ignored; 0 measured; 0 filtered out; finished in 0.02s + test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.13s + test result: ok. 32 passed; 0 failed; 6 ignored; 0 measured; 0 filtered out; finished in 0.01s + ``` + + Log: `/tmp/test-netsuke-7-1-1-clock-provider-seam.out`. + +4. Public API coverage at EP-M3, since the seam adds exported items. Verbatim: + + ```plaintext + test_support lib 403/403 100.00% + netsuke-build lib 3541/3576 99.02% + netsuke-build bin (netsuke) 115/115 100.00% + aggregate 4059/4094 99.15% + ok: doc-comment coverage 99.15% meets the 80.00% threshold. + ``` + + Log: `/tmp/doc-coverage-netsuke-7-1-1-clock-provider-seam.out`. + +5. First CodeRabbit pass, run at EP-M3 over `e682ac98` and `ccf63eb0`: + `coderabbit review --agent` completed normally (no rate limiting) with 16 + changed files reviewed and zero findings. Log: + `/tmp/coderabbit-netsuke-7-1-1-clock-provider-seam.out`. + + The other gates in the same run were green: `make check-fmt`, + `make typecheck`, `make lint` (with `PATH="$HOME/go/bin:$PATH"`), + `make markdownlint` (133 files, 0 errors) and `make nixie`. + +6. The four mutation outcomes from `Validation and acceptance`. +7. The final gate transcript tails for `check-fmt`, `typecheck`, `lint`, and `test`. Keep each excerpt short — the summary line and the failing assertion, not the From c0f3d47da0e0b71f189622e020ff6c98ba7f4a9e Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 11 Sep 2026 01:11:42 +0200 Subject: [PATCH 10/54] Record the six mutation outcomes in the 7.1.1 exec plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All six designated mutations were applied to the working tree in turn, the named test run, and the file reverted. Each was rejected by the test the plan nominated: 1. `clock.read()` replaced by the ambient read — all four `now_uses_injected_clock` cases fail. 2. Instant baked in at registration — `now_reads_the_provider_on_every_call` fails. 3. Offset applied as an arithmetic shift — `now_offset_preserves_the_instant` fails at `+00:00:01`. 4. Registration passes `WallClock::default()` — all 49 unit tests still pass while the integration and BDD suites fail, confirming the integration layer is load-bearing. 5. Refusing `now` stub deleted — both refusal cases fail, while the sibling absence case still passes, so the two OBL-5 cases are not redundant. 6. UTC normalization removed — only the non-UTC case fails. Two findings are recorded. The plan's mutation 3, taken literally as `timestamp + Duration::seconds(offset)`, is a no-op: `replace_offset` preserves the wall-clock time rather than the instant, so the offset shift is exactly cancelled by the added duration. The mutation was re-run in a form that moves the instant while setting the offset. The `to_offset` / `replace_offset` near-miss is now documented, since the seam's contract depends on the former. Applying that mutation also produced a genuine proptest shrink, which is committed to `proptest-regressions/stdlib/time/clock_tests.txt` following the precedent of the existing `home_tests.txt` seed. It passes against the unmutated code. Co-Authored-By: Claude Code --- docs/execplans/7-1-1-clock-provider-seam.md | 56 ++++++++++++++++++- .../stdlib/time/clock_tests.txt | 11 ++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 proptest-regressions/stdlib/time/clock_tests.txt diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index 0f1db37be..438e4da93 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -1615,6 +1615,23 @@ Recorded during planning; extend during implementation. this change introduced, and it disappears once the branch is rebased past the `GO_BIN` commit. +- Observation: the plan's mutation 3, written literally as + `timestamp + Duration::seconds(offset)`, is a *no-op* and therefore does not + kill the test it names. Evidence: `OffsetDateTime::replace_offset` preserves + the **wall-clock** time, not the instant — measured directly, + `datetime!(2026-06-08 12:00:00 UTC).replace_offset(+05:30)` yields + `12:00:00 +05:30` with `unix_timestamp` 1780900200, which is 19 800 s + *before* the original. Adding `Duration::seconds(19_800)` back moves the + instant forward by exactly that much, so the two errors cancel and + `now_offset_preserves_the_instant` passes. Impact: the mutation was re-run as + `(timestamp + Duration::seconds(offset_seconds)).to_offset(parsed)`, which + moves the instant while still setting the requested offset; that version is + rejected with minimal failing input `+00:00:01`. The distinction matters + beyond the exercise: `to_offset` (preserve the instant) and `replace_offset` + (preserve the wall time) are near-miss names, and the seam's contract is the + former. `WallClock::read` and `now()` both use `to_offset`, and the property + test now pins that choice. + ## Decision log - **D1 — Port shape follows the `EnvReader` precedent verbatim.** @@ -1928,7 +1945,44 @@ To be populated during implementation. Required entries: `make typecheck`, `make lint` (with `PATH="$HOME/go/bin:$PATH"`), `make markdownlint` (133 files, 0 errors) and `make nixie`. -6. The four mutation outcomes from `Validation and acceptance`. +6. The six mutation outcomes from `Validation and acceptance`. Each mutation was + applied to the working tree, the named test run, and the file reverted with + `git checkout -- `; the tree at HEAD is unmutated. Per-mutation revert + was used in place of one scratched-up commit because the mutations are not + mutually compatible — M1 and M2 both rewrite `register_functions`, and M3 + and M6 both edit the read path — so a single commit containing "all six" is + not constructible. Log: + `/tmp/mutations-netsuke-7-1-1-clock-provider-seam.log`. + + | # | Mutation | Named test | Outcome | + | --- | -------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------- | + | 1 | `clock.read()` → `time::OffsetDateTime::now_utc()` | `now_uses_injected_clock` | rejected: all 4 cases fail | + | 2 | Instant baked in at registration | `now_reads_the_provider_on_every_call` | rejected: 1/1 fails | + | 3 | Offset applied as an arithmetic shift | `now_offset_preserves_the_instant` | rejected: minimal failing input `sign="+", hour=0, minute=0, second=1` | + | 4 | Registration passes `WallClock::default()` | unit / integration / BDD | rejected by the integration layer only | + | 5 | Refusing `now` stub deleted | `manifest_query_registration_refuses_now` | rejected: both cases fail | + | 6 | UTC normalization removed from `WallClock::read` | `now_uses_injected_clock` | rejected: `case_4_non_utc` fails, the 3 UTC cases pass | + + Mutation 4 is the load-bearing one, and it behaved exactly as predicted: + `cargo nextest run -E 'test(stdlib::time)'` reported + `Summary [0.084s] 49 tests run: 49 passed, 2697 skipped` — every unit test + still green — while `binary(std_filter_tests)` failed and both + `stdlib_time_a_fixed_clock*` scenarios failed. Unit coverage alone cannot + detect a mis-wired registration. + + Mutation 5 also confirms the two OBL-5 cases are not redundant: with the + stub gone, `query_functions_do_not_define_now` still passes + (`Summary [0.010s] 2 tests run: 2 passed`) because the permissive half was + never registering `now` in the first place. Only the refusal case catches + the deletion, and only the absence case catches a leak of `now` into the + permissive half. + + Applying mutation 3 left a genuine `proptest` shrink + (`sign = "+", hour = 0, minute = 0, second = 1`) that was committed to + `proptest-regressions/stdlib/time/clock_tests.txt`, following the precedent + of `proptest-regressions/stdlib/path/home_tests.txt`, which records a + mutation-derived seed the same way. It passes against the unmutated code. + 7. The final gate transcript tails for `check-fmt`, `typecheck`, `lint`, and `test`. diff --git a/proptest-regressions/stdlib/time/clock_tests.txt b/proptest-regressions/stdlib/time/clock_tests.txt new file mode 100644 index 000000000..9e25dde02 --- /dev/null +++ b/proptest-regressions/stdlib/time/clock_tests.txt @@ -0,0 +1,11 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +# Recorded while mutation-testing the property (applying the offset as an +# arithmetic shift on the instant instead of re-expressing it), not from a +# defect in the seam. It pins the smallest non-zero offset, which is the +# boundary a shift-based implementation trips on first. +cc 30381cb6b812ccc26d72c16cd14a060a5488890e5171d218053b0739f3269cdf # shrinks to sign = "+", hour = 0, minute = 0, second = 1 From dcdd2738776f2dc5a2b38f8c025a5dd2d0698337 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 11 Sep 2026 01:15:58 +0200 Subject: [PATCH 11/54] Classify the stdlib clock seam in the documentation (7.1.1, EP-M4) Four documents move together, because ADR-008's `Consequences` section requires the ADR and the developers' guide sections to stay consistent: - ADR-008 gains the dated addendum "2026-09-11: Stdlib clock seam", classifying the seam in the `EnvReader` shape, recording that the taxonomy is applied to an ambient input that is not an environment variable, and warning that `StdlibConfig` now holds two ambient seams in two shapes that should not be "harmonized" without revisiting the entry. Its `Implementation references` list gains `src/stdlib/time/clock.rs` and the config and registration call sites. - `docs/developers-guide.md`'s "Environment and template ports" section documents the clock's ownership, its module boundary, and the fact that manifest-query registration keeps refusing `now`. - Technical design section 5.2 moves from proposal to implemented state and names `WallClock`, the mechanical container the design did not name: it confines a hand-written `Debug` and normalizes each read to UTC. - RFC 0006 section 3.3 records the `now` clock gap as closed, and section 16's question 7 as resolved, both pointing at the addendum. RFC 0006 section 3.3's first recorded gap was also stale, independently of this change: nine of the sixteen names it reported as absent from the manifest-query environment have since gained refusing stubs, leaving the seven file tests. The count, and section 14.1's slice-0 deliverable that was built on it, are corrected in place to match `register_disabled_query_helpers`. `make check-fmt` is green over the result; the plan records the edits and both discoveries. Co-Authored-By: Claude Code --- docs/adr-008-environment-seam-taxonomy.md | 34 +++++++++++ docs/developers-guide.md | 13 ++++ docs/execplans/7-1-1-clock-provider-seam.md | 47 ++++++++++++++- ...netsuke-test-framework-technical-design.md | 32 ++++++---- ...ible-inspired-template-standard-library.md | 60 +++++++++---------- 5 files changed, 143 insertions(+), 43 deletions(-) diff --git a/docs/adr-008-environment-seam-taxonomy.md b/docs/adr-008-environment-seam-taxonomy.md index 273ae13ad..584bee0ea 100644 --- a/docs/adr-008-environment-seam-taxonomy.md +++ b/docs/adr-008-environment-seam-taxonomy.md @@ -188,6 +188,11 @@ resolution entirely rather than setting the variable for a child to read. going through `NETSUKE_NINJA` resolution at all - `EnvReader`: [`src/manifest/env_reader.rs`](../src/manifest/env_reader.rs) (manifest `env()` Jinja helper) +- Clock seam: [`src/stdlib/time/clock.rs`](../src/stdlib/time/clock.rs) + (`ClockProvider`, `system_clock`, `fixed_clock`); `StdlibConfig::with_clock` + in [`src/stdlib/config/mod.rs`](../src/stdlib/config/mod.rs) is the injection + point, and [`src/stdlib/register.rs`](../src/stdlib/register.rs) captures the + provider when it registers `now()` - Child-environment composition: [`test_support/src/netsuke.rs`](../test_support/src/netsuke.rs) (`run_netsuke_in_with_env`) and `tests/bdd/steps/manifest_command_helpers.rs` @@ -234,3 +239,32 @@ process-global environment or working-directory changes. Route B avoids CWD changes by passing absolute paths or preserving `-C/--directory` for automatic project discovery. Explicit relative `--config` and `NETSUKE_CONFIG` selectors remain anchored to the child process CWD; they are not rebased beneath `-C`. + +### 2026-09-11: Stdlib clock seam + +The stdlib `now()` helper reads its instant through an injected +`ClockProvider`, an `Arc OffsetDateTime + Send + Sync>` held by +`StdlibConfig` and captured by the registered Jinja function. It takes the +`EnvReader` shape, not a narrow closure and not `mockable::Env`, for the same +reason `EnvReader` does: `minijinja` requires registered functions to be +`Send + Sync`, so a borrowed closure parameter cannot satisfy the bound. +`StdlibConfig` is the clock's single owner; `system_clock()` is the sole +production supplier and the only place `OffsetDateTime::now_utc` is called for +`now()`. Manifest-query registration receives no clock and keeps its refusing +`now` stub. + +The taxonomy is applied here to an ambient input that is *not* an environment +variable. This ADR's context section is written about `clippy.toml`'s ban on +`std::env::var` and friends, and no lint forbids reading the clock; the shape +rubric transfers, the original scope does not. Note also that `StdlibConfig` +now holds two ambient seams in two shapes — `home_directory: HomeDirectory`, a +resolved value, and the clock, a closure. The clock's shape is the one this +rubric prescribes for a `Send + Sync` registration point; `HomeDirectory` is +the outlier, and the two should not be "harmonized" without revisiting this +entry. `ClockProvider` also puts `time::OffsetDateTime` on netsuke's public +surface, so a `time` 0.4 bump is a breaking library-API change. + +`mockable::Clock` was not used: it is typed in `chrono`, which this workspace +does not depend on, so adopting it would add a second date-time crate to render +one timestamp. `monotony`, already a dependency, abstracts only monotonic +elapsed time and has no wall-clock type. diff --git a/docs/developers-guide.md b/docs/developers-guide.md index aaa292fca..3251788ef 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -5025,6 +5025,19 @@ pure collection filters without environment state. Keep these registration functions as feature-local wiring points rather than calling them independently from manifest code. +The stdlib's `now()` helper reads through a `ClockProvider` +(`src/stdlib/time/clock.rs`), an `Arc`-wrapped `Fn() -> OffsetDateTime` in the +`EnvReader` shape and for the same reason: registration requires `Send + Sync`. +`StdlibConfig` is the clock's single owner — `with_clock` replaces the provider, +`system_clock()` is the production adapter and the only place the helper reads +the host clock, and `fixed_clock` supplies a deterministic instant to tests. +Keep the seam confined to the `stdlib::time` registration path: manifest-query +registration installs the clock-independent helpers only and keeps refusing +`now`, and the provider is not a general time service for the crate. The clock +is not an environment variable and no lint polices it, so +[ADR-008](adr-008-environment-seam-taxonomy.md) supplies the shape rubric here +but not its original scope. + `CommandConfigInit` is the internal hand-off from `StdlibConfig` to command helpers. It carries the capability-scoped workspace root, output limits, and an optional `PATH` override. `CommandConfig::new` consumes the owned bundle, and diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index 438e4da93..88eb696a4 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -6,7 +6,7 @@ This ExecPlan (execution plan) is a living document. The sections `Constraints`, `Conformance basis`, and `Verification plan` must be kept up to date as work proceeds. -Status: IN PROGRESS (EP-M0 through EP-M3 complete; EP-M4 in progress) +Status: IN PROGRESS (EP-M0 through EP-M4 complete; EP-M5 in progress) ## Purpose / big picture @@ -1441,7 +1441,9 @@ above, which are disposable. - [x] EP-M2 — integration and BDD coverage over the real registration path. - [x] EP-M3 — All four gates green, plus the repository's Markdown and diagram gates and the first CodeRabbit pass (zero findings). -- [ ] EP-M4 — ADR-008 addendum, developers' guide, technical design §5.2. +- [x] EP-M4 — ADR-008 addendum plus `Implementation references` entry, + developers' guide "Environment and template ports", technical design §5.2, + RFC 0006 §3.3 and §16 question 7; `make check-fmt` green. - [ ] EP-M5 — Roadmap 7.1.1 marked done. ## Surprises & discoveries @@ -1632,6 +1634,34 @@ Recorded during planning; extend during implementation. former. `WallClock::read` and `now()` both use `to_offset`, and the property test now pins that choice. +- Observation: RFC 0006 §3.3's first recorded gap — "sixteen names registered in + the full environment are absent from the manifest-query environment + altogether" — is stale, independently of this change. Evidence: + `register_disabled_query_helpers` (`src/stdlib/register.rs`) now stubs + fifteen names: the original six (`env`, `glob`, `fetch`, `shell`, `grep`, + `contents`) plus nine added by the `netsuke help targets` work (`realpath`, + `expanduser`, `size`, `linecount`, `hash`, `digest`, `which` in both + namespaces, `command_available`, `now`). Only the seven file tests (`dir`, + `file`, `symlink`, `pipe`, `block_device`, `char_device`, `device`) are still + absent, because `register_manifest_query` never calls `register_file_tests`. + The totals reconcile exactly: 6 + 16 = 22 = 15 stubs + 7 absent. Impact: the + same count also bound RFC 0006 §14.1's slice-0 deliverable, which promised a + stub for "every one of the sixteen"; both were corrected in place rather than + worked around, per `Outcomes & retrospective`. Slice 0's remaining stub work + is the seven file tests. Out of scope for 7.1.1 but recorded here because + leaving a known-false claim beside the edit would have been worse; no runtime + behaviour changes. + +- Observation: the ADR-008 addendum heading date is 2026-09-11, not the + `2026-09-08` written in Stage D. Evidence: the plan's text prescribed the + heading "along these lines" and the file's convention is that each addendum + is dated when it is written (`2026-08-30`, `2026-09-01`, `2026-08-25` are all + landing dates, and the ADR was untouched between planning and this commit). + Impact: the entry records the date the classification was actually added, so + a later reader comparing it with `git log` sees the same day. The prescribed + body text is otherwise used verbatim, with `time::OffsetDateTime` on the + public surface noted as prescribed. + ## Decision log - **D1 — Port shape follows the `EnvReader` precedent verbatim.** @@ -1983,7 +2013,18 @@ To be populated during implementation. Required entries: of `proptest-regressions/stdlib/path/home_tests.txt`, which records a mutation-derived seed the same way. It passes against the unmutated code. -7. The final gate transcript tails for `check-fmt`, `typecheck`, `lint`, and +7. The EP-M4 documentation edit, one commit over four files. ADR-008 gains the + dated addendum `### 2026-09-11: Stdlib clock seam` and an + `Implementation references` entry for `src/stdlib/time/clock.rs`; + `docs/developers-guide.md` gains the clock paragraph in "Environment and + template ports" (same commit, because ADR-008's `Consequences` requires the + two to stay consistent); technical design §5.2 moves from proposal to + implemented state and names `WallClock`; RFC 0006 §3.3 records the `now` gap + as closed and §16 question 7 as resolved, both pointing at the addendum. + `make check-fmt` was red on the first pass, `make fmt` was run and touched + only those four files, and the re-run was green. + +8. The final gate transcript tails for `check-fmt`, `typecheck`, `lint`, and `test`. Keep each excerpt short — the summary line and the failing assertion, not the diff --git a/docs/netsuke-test-framework-technical-design.md b/docs/netsuke-test-framework-technical-design.md index ff3a51dea..679196934 100644 --- a/docs/netsuke-test-framework-technical-design.md +++ b/docs/netsuke-test-framework-technical-design.md @@ -259,22 +259,34 @@ builds one from the case's `given.env` map: declared names return their values, environment is reachable only through an explicit future opt-in; the default reader never consults it (C2, C4). -### 5.2. Clock (new seam) +### 5.2. Clock -`now()` currently calls `OffsetDateTime::now_utc()` directly -(`src/stdlib/time/mod.rs:62`) — a gap relative to ADR-008. The stdlib time -module gains a clock provider in the `EnvReader` shape (an `Arc` closure, -because MiniJinja registration requires `Send + Sync`): +Implemented. The stdlib time module owns a clock provider in the `EnvReader` +shape (an `Arc` closure, because MiniJinja registration requires `Send + Sync`): ```rust pub type ClockProvider = Arc OffsetDateTime + Send + Sync>; ``` -Production registration wraps `OffsetDateTime::now_utc`; the test runner -supplies a fixed instant parsed from `given.clock.now`. The seam lives in -`StdlibConfig` alongside the existing `path_override` and `home_directory` -knobs — the clock's single owner — and is a prerequisite refactor deliverable -in its own right. +It lives in `src/stdlib/time/clock.rs` with `system_clock()`, the production +adapter wrapping `OffsetDateTime::now_utc`, and `fixed_clock(instant)`, the +deterministic adapter. The seam is held in `StdlibConfig` alongside the existing +`path_override` and `home_directory` knobs — the clock's single owner — and +`with_clock` is the injection point; `register_functions` captures the provider +when it installs `now()`, so each evaluation reads the provider again, and no +provider-less call path can bypass it. + +`StdlibConfig` stores the provider in a private `WallClock` container, a +mechanical addition this design did not name. It confines a hand-written +`Debug` (a closure is not printable, so `StdlibConfig` could not have derived +one) and normalizes every read to UTC, because an injected provider is free to +return any offset while `now()` is documented to yield UTC. Manifest-query +registration receives no clock and keeps its refusing `now` stub. + +The test runner's `given.clock.now` input +([UX design §7](netsuke-test-framework-ux-design.md)) is not yet wired to this +seam; the BDD suite drives it with an explicit clock fixture step instead. That +wiring belongs to the runner slices, for which this seam is the prerequisite. ### 5.3. Network (policy, not transport) diff --git a/docs/rfcs/0006-ansible-inspired-template-standard-library.md b/docs/rfcs/0006-ansible-inspired-template-standard-library.md index 75fef29fc..ff1115f0a 100644 --- a/docs/rfcs/0006-ansible-inspired-template-standard-library.md +++ b/docs/rfcs/0006-ansible-inspired-template-standard-library.md @@ -150,26 +150,29 @@ Three existing mechanisms matter to this proposal. ### 3.3. Known weaknesses in the current surface -Three existing gaps constrain this design and are called out so the follow-up -work does not silently inherit them. +Three gaps constrained this design at the time of writing and are called out so +the follow-up work does not silently inherit them. The first two remain open; +the third has since been closed. - **Excluded helpers do not all fail explicitly.** `register_manifest_query` - stubs six helpers: `env`, `glob`, `fetch`, `shell`, `grep`, and `contents`. A - further sixteen names registered in the full environment are absent from the - manifest-query environment altogether, so a manifest query reports "unknown - filter" or "unknown test" rather than explaining the restriction. They are - the filters `realpath`, `expanduser`, `size`, `linecount`, `hash`, and - `digest`; `which`, which is registered as both a filter and a function; the - functions `command_available` and `now`; and the file tests `dir`, `file`, - `symlink`, `pipe`, `block_device`, `char_device`, and `device`. Section 6.2 - makes explicit failure normative, and section 14.1 schedules the repair - across that whole set rather than the path filters alone. + stubs fifteen helpers: `env`, `glob`, `fetch`, `shell`, `grep`, and + `contents`, then `realpath`, `expanduser`, `size`, `linecount`, `hash`, + `digest`, `which`, `command_available`, and `now`. Each raises a restriction + diagnostic rather than "unknown filter" or "unknown test". A further seven + names registered in the full environment are absent from the manifest-query + environment altogether: the file tests `dir`, `file`, `symlink`, `pipe`, + `block_device`, `char_device`, and `device`. Section 6.2 makes explicit + failure normative, and section 14.1 schedules the repair of that remaining + set. - **`manifest_query_operation_error` is not localized.** It builds its message with `format!` rather than a Fluent key, unlike the rest of the stdlib. -- **`now` has no injected clock seam.** It calls `OffsetDateTime::now_utc()` - directly. The time helpers proposed here are pure and do not need the seam, - but the gap is recorded because it bounds how far time behaviour can be - tested deterministically. +- **`now` had no injected clock seam.** It called `OffsetDateTime::now_utc()` + directly. The time helpers proposed here are pure and did not need the seam, + but the gap was recorded because it bounded how far time behaviour could be + tested deterministically. Roadmap item 7.1.1 has since closed it: `now()` + reads through a `ClockProvider` held by `StdlibConfig`, classified in the + [ADR-008](../adr-008-environment-seam-taxonomy.md) addendum for 2026-09-11 + and answered as question 7 in section 16. ## 4. Goals and non-goals @@ -1917,17 +1920,13 @@ each invent their own version of the same shared machinery. once clause 2 of section 6.2 is satisfied. - The repair of the two existing gaps recorded in section 3.3. This slice localizes `manifest_query_operation_error` through a Fluent key, and it adds - an explicit stub for every one of the sixteen names that section 3.3 records - as absent from the manifest-query environment, so no helper silently + an explicit stub for each of the seven names that section 3.3 records as + still absent from the manifest-query environment, so no helper silently disappears from a manifest query. Every stub raises the same localized - manifest-query restriction diagnostic. The names are: - - the filters `realpath`, `expanduser`, `size`, `linecount`, `hash`, and - `digest`; - - `which`, which needs a stub in both its filter form and its function form, - because filters and functions occupy separate namespaces; - - the functions `command_available` and `now`; and - - the tests `dir`, `file`, `symlink`, `pipe`, `block_device`, `char_device`, - and `device`. + manifest-query restriction diagnostic. The remaining names are the tests + `dir`, `file`, `symlink`, `pipe`, `block_device`, `char_device`, and + `device`; the other nine of the original sixteen are already stubbed (section + 3.3). - The **maintained inventory** in [the standard-library guide](../stdlib-yaml-and-jinja-guide.md): one table distinguishing MiniJinja built-ins, existing Netsuke extensions, adopted @@ -2098,10 +2097,11 @@ Windows host needs when generating paths for a Unix target. 6. **Should `text_hash` gain a truncating sibling?** The existing `digest` filter is `hash` plus a length. If `text_hash` proves useful, `text_digest` is the obvious follow-on. It is not proposed here for want of a use case. -7. **Does `now` need an injected clock seam?** Section 3.3 records the gap. - Nothing in this RFC requires it, because `to_datetime` and `strftime` are - pure, but a future slice that wants deterministic time tests will have to - answer it. +7. **Does `now` need an injected clock seam?** Resolved. Section 3.3 records + the gap, and nothing in this RFC required the seam, because `to_datetime` and + `strftime` are pure. Roadmap item 7.1.1 supplied it: `now()` reads through a + `ClockProvider` held by `StdlibConfig`, classified in the + [ADR-008](../adr-008-environment-seam-taxonomy.md) addendum for 2026-09-11. ## 17. Recommendation From 9d85390bbe28c881cb93189949e5a38fabae1daf Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 11 Sep 2026 01:16:50 +0200 Subject: [PATCH 12/54] Close roadmap 7.1.1 and complete the exec plan (EP-M5) Every roadmap 7.1.1 sub-bullet now maps to a named artefact, so all five boxes are ticked: registration through `StdlibConfig`, ambient behaviour when no provider is supplied, the injected-value, repeated-call and ambient-fallback coverage, and the ADR-008 classification. The plan's `Outcomes & retrospective` records the reconciliation, the two accepted deviations (the ADR addendum dated 2026-09-11, and the RFC 0006 count correction that sits beside this change), the branch-local `GO_BIN` PATH workaround, and the follow-on work: runner wiring of `given.clock.now` under 7.1.2, and RFC 0006 slice 0's seven remaining file-test stubs. Status is `COMPLETE`. Co-Authored-By: Claude Code --- docs/execplans/7-1-1-clock-provider-seam.md | 101 ++++++++++++++------ docs/roadmap.md | 10 +- 2 files changed, 78 insertions(+), 33 deletions(-) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index 88eb696a4..96c80cb85 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -6,7 +6,7 @@ This ExecPlan (execution plan) is a living document. The sections `Constraints`, `Conformance basis`, and `Verification plan` must be kept up to date as work proceeds. -Status: IN PROGRESS (EP-M0 through EP-M4 complete; EP-M5 in progress) +Status: COMPLETE ## Purpose / big picture @@ -1444,7 +1444,9 @@ above, which are disposable. - [x] EP-M4 — ADR-008 addendum plus `Implementation references` entry, developers' guide "Environment and template ports", technical design §5.2, RFC 0006 §3.3 and §16 question 7; `make check-fmt` green. -- [ ] EP-M5 — Roadmap 7.1.1 marked done. +- [x] EP-M5 — Roadmap 7.1.1 and its four sub-bullets marked done, each mapped + to a named artefact; `Outcomes & retrospective` completed and the plan set to + `COMPLETE`. ## Surprises & discoveries @@ -1859,32 +1861,75 @@ Recorded during planning; extend during implementation. ## Outcomes & retrospective -To be completed at EP-M5. - -Before setting this plan to `COMPLETE`, reconcile discoveries against the -conformance basis: - -- Update `docs/netsuke-test-framework-technical-design.md` §5.2 so it records - the implemented state rather than a proposal — in particular, the `WallClock` - container (D2) is a mechanical addition the design did not name, and §15 - requires the document be kept in step. -- Confirm ADR-008's addendum records the classification, states that the - taxonomy is being applied to a non-environment-variable ambient input (D11), - and that its `Implementation references` list names - `src/stdlib/time/clock.rs`. -- Answer RFC 0006 §16 open question 7 ("Does `now` need an injected clock - seam?") in that document, and update its §3.3 gap entry to record that the - gap is closed. This plan is the "future slice" that question anticipated, so - leaving the question open after merging would be a stale upstream artefact. -- Confirm `docs/developers-guide.md`'s "Environment and template ports" - section and ADR-008 remain mutually consistent — ADR-008's own `Consequences` - section requires this. -- Confirm every roadmap 7.1.1 bullet maps to a passing named artefact. -- If any discovery falsified an assumption in the technical design or RFC - 0007, update that document rather than working around it, and record the - change here. - -Do not mark `COMPLETE` while any upstream change or deviation is unrecorded. +Complete. The stdlib `now()` helper reads its instant through an injected +`ClockProvider` owned by `StdlibConfig`, every evaluation reads the provider +again, and the no-provider path is behaviourally unchanged: +`WallClock::default` installs `system_clock()`, the ambient adapter. +Manifest-query registration still refuses `now`. The change lands in +`src/stdlib/time/clock.rs`, `src/stdlib/time/mod.rs`, +`src/stdlib/config/mod.rs`, the registration path in `src/stdlib/register.rs`, +one integration module, and one BDD feature; unit tests stayed at 49 across the +test-module split. + +Reconciliation against the conformance basis: + +- Technical design §5.2 now records the implemented state, including the + `WallClock` container (D2) the design did not name; §15's synchronization + requirement is satisfied in the same change set as the governing ADR. +- ADR-008's addendum records the classification, states that the taxonomy is + applied to a non-environment-variable ambient input (D11), and its + `Implementation references` list names `src/stdlib/time/clock.rs` along with + the config and registration call sites. +- RFC 0006 §16 question 7 is answered and its §3.3 gap entry records the gap + closed, both pointing at the addendum. This plan was the "future slice" the + question anticipated, so leaving it open would have been a stale upstream + artefact. +- `docs/developers-guide.md`'s "Environment and template ports" section and + ADR-008 were edited in one commit, as ADR-008's `Consequences` requires. +- Every roadmap 7.1.1 bullet maps to a named artefact: registration through + `StdlibConfig` to `config/mod.rs` and `register.rs`; ambient preservation to + `WallClock::default` plus the integration and BDD ambient coverage; the + injected-value, repeated-call, and ambient-fallback tests to `clock_tests.rs`, + `tests/std_filter_tests/time_functions.rs`, and `stdlib_time.feature`; the + classification to the ADR-008 addendum. +- No discovery falsified an assumption in the technical design or RFC 0007, so + neither needed a correction beyond §5.2's proposal-to-implemented rewrite. + +Upstream changes and deviations, all recorded above or in +`Surprises & discoveries`: + +- The ADR-008 addendum is dated 2026-09-11 rather than the plan's + `2026-09-08`, matching the file's convention of dating each entry when it is + written. +- RFC 0006 §3.3's first gap and §14.1's slice-0 deliverable were corrected: + nine of the sixteen names recorded as absent from manifest-query registration + have since been stubbed, leaving the seven file tests. This is a + documentation correction only, with no runtime change; it was made in place + because the stale claim sits in the same bullet as the edit this plan + required. +- `make lint` on this branch requires `PATH="$HOME/go/bin:$PATH"`, because the + base commit predates the Makefile's `GO_BIN` curation. The workaround + disappears once the branch is rebased past that commit. + +Follow-on work, not part of this plan: + +- Roadmap item 7.1.2 wires the runner's `given.clock.now` input to this seam; + the BDD suite uses an explicit clock fixture step until then. +- RFC 0006 slice 0's remaining stub work is the seven file tests (`dir`, + `file`, `symlink`, `pipe`, `block_device`, `char_device`, `device`). + +Retrospective: + +- The seam's contract turned on a near-miss API distinction + (`to_offset` versus `replace_offset`) that only measurement settled. The + mutation exercise earned its keep by surfacing it; a plan that had trusted + the first mutation's pass/fail reading would have recorded the wrong lesson. +- Denied lints are not uniformly visible. `clippy::shadow_reuse` failed the + gate but not `cargo check --all-targets` with `-D warnings`, and the + module-size cap is per file rather than recursive. Both are recorded so later + milestones budget for the real gate, not a proxy. +- Test-module splits that preserve the test count are a cheap way to satisfy + the per-file cap without weakening coverage. ## Artefacts and notes diff --git a/docs/roadmap.md b/docs/roadmap.md index e4a3ea5fb..058fe80a8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1341,15 +1341,15 @@ Objective: deliver the `netsuke test` command and YAML test dialect specified in ### 7.1. Seams and loader options -- [ ] 7.1.1. Add the clock provider seam to the stdlib time module. See +- [x] 7.1.1. Add the clock provider seam to the stdlib time module. See [technical design §5.2](netsuke-test-framework-technical-design.md). - - [ ] Register `now()` through an injected `ClockProvider` closure held in + - [x] Register `now()` through an injected `ClockProvider` closure held in `StdlibConfig`. - - [ ] Preserve current behaviour when no provider is supplied. - - [ ] Test an injected provider value, repeated `now()` calls returning + - [x] Preserve current behaviour when no provider is supplied. + - [x] Test an injected provider value, repeated `now()` calls returning it, and the ambient fallback when no provider is configured, all registered through `StdlibConfig`. - - [ ] Record the seam classification per + - [x] Record the seam classification per [ADR-008](adr-008-environment-seam-taxonomy.md). - [ ] 7.1.2. Introduce the options-carrying manifest loader entry point. See From d6ce762addff55a50cea860f790ca492e7bbeb71 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 11 Sep 2026 01:20:41 +0200 Subject: [PATCH 13/54] Record the implemented clock seam in RFC 0007 and fix the spelling gate RFC 0007's "what is missing" list and one sentence in its architecture section both recorded the clock seam as absent. Both now record it as supplied by roadmap item 7.1.1, for the same reason RFC 0006's gap entry was corrected: a governing document that contradicts the code outlives the change that closed the gap. The spelling gate ("markdownlint: spelling", which `make check-fmt` does not run) rejected two hyphenated compounds introduced by the documentation milestone. `typos` splits on the hyphen, so `mis-wired` reads as a misspelling of `miss`; the fix is to avoid the compound, not to widen the ignore list: - `hand-written` -> `handwritten` in the technical design; - `mis-wired` -> `wrongly wired` in the WallClock doc comment (whose behaviour is unchanged), in the plan's sketch of it, and in the mutation-record entry that described it. The plan records both findings and the gate's membership, since the distinction between `make check-fmt` and `make markdownlint` is easy to misread. Co-Authored-By: Claude Code --- docs/execplans/7-1-1-clock-provider-seam.md | 49 ++++++++++++++----- ...netsuke-test-framework-technical-design.md | 8 +-- .../0007-netsukefile-testing-framework.md | 17 ++++--- src/stdlib/time/clock.rs | 2 +- 4 files changed, 52 insertions(+), 24 deletions(-) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index 96c80cb85..630227197 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -954,7 +954,7 @@ impl Default for WallClock { /// Report the clock's provenance without pretending a closure is printable. /// -/// The label makes a mis-wired clock self-diagnosing: an injected clock that +/// The label makes a wrongly wired clock self-diagnosing: an injected clock that /// never reached registration, or an ambient clock where a test expected an /// injected one, is visible in any `{:?}` of the surrounding config. impl fmt::Debug for WallClock { @@ -1664,6 +1664,18 @@ Recorded during planning; extend during implementation. body text is otherwise used verbatim, with `time::OffsetDateTime` on the public surface noted as prescribed. +- Observation: the spelling gate is not part of `make check-fmt`. Evidence: the + Makefile declares `markdownlint: spelling`, and `spelling` runs + `scripts/typos_rollout_check.py` plus `typos` over every Markdown file; a + branch can therefore be `check-fmt`-green and still fail on prose. Two words + this change added were rejected: `hand-written` (flagged at + `docs/netsuke-test-framework-technical-design.md:280`) and `mis-wired` + (`docs/execplans/7-1-1-clock-provider-seam.md`). Impact: `typos` splits on + the hyphen, so `mis` is read as a standalone word and reported as a + misspelling of `miss`; the fix is to avoid the hyphen, not to add an ignore. + Both are now `handwritten` and `wrongly wired`. Markdown gates must be run + through `make markdownlint`, not `make check-fmt` alone. + ## Decision log - **D1 — Port shape follows the `EnvReader` precedent verbatim.** @@ -1910,6 +1922,10 @@ Upstream changes and deviations, all recorded above or in - `make lint` on this branch requires `PATH="$HOME/go/bin:$PATH"`, because the base commit predates the Makefile's `GO_BIN` curation. The workaround disappears once the branch is rebased past that commit. +- RFC 0007's "what is missing" list, and one sentence in its architecture + section, also recorded the clock seam as absent; both now record it as + supplied by 7.1.1. Same rationale as the RFC 0006 correction: leaving a + known-false claim in the governing RFC would outlive the change. Follow-on work, not part of this plan: @@ -2043,7 +2059,7 @@ To be populated during implementation. Required entries: `Summary [0.084s] 49 tests run: 49 passed, 2697 skipped` — every unit test still green — while `binary(std_filter_tests)` failed and both `stdlib_time_a_fixed_clock*` scenarios failed. Unit coverage alone cannot - detect a mis-wired registration. + detect an incorrectly wired registration. Mutation 5 also confirms the two OBL-5 cases are not redundant: with the stub gone, `query_functions_do_not_define_now` still passes @@ -2058,16 +2074,25 @@ To be populated during implementation. Required entries: of `proptest-regressions/stdlib/path/home_tests.txt`, which records a mutation-derived seed the same way. It passes against the unmutated code. -7. The EP-M4 documentation edit, one commit over four files. ADR-008 gains the - dated addendum `### 2026-09-11: Stdlib clock seam` and an - `Implementation references` entry for `src/stdlib/time/clock.rs`; - `docs/developers-guide.md` gains the clock paragraph in "Environment and - template ports" (same commit, because ADR-008's `Consequences` requires the - two to stay consistent); technical design §5.2 moves from proposal to - implemented state and names `WallClock`; RFC 0006 §3.3 records the `now` gap - as closed and §16 question 7 as resolved, both pointing at the addendum. - `make check-fmt` was red on the first pass, `make fmt` was run and touched - only those four files, and the re-run was green. +7. The EP-M4 documentation edit, one commit over four files, plus a follow-up + commit for RFC 0007 and the spelling gate. ADR-008 gains the dated addendum + `### 2026-09-11: Stdlib clock seam` and an `Implementation references` entry + for `src/stdlib/time/clock.rs`; `docs/developers-guide.md` gains the clock + paragraph in "Environment and template ports" (same commit, because ADR-008's + `Consequences` requires the two to stay consistent); technical design §5.2 + moves from proposal to implemented state and names `WallClock`; RFC 0006 + §3.3 records the `now` gap as closed and §16 question 7 as resolved, both + pointing at the addendum. `make check-fmt` was red on the first pass, + `make fmt` was run and touched only those four files, and the re-run was + green. + + The follow-up commit corrects RFC 0007's gap list and its "two seams are + added" sentence, and applies the spelling gate's two findings: + `hand-written` and `mis-wired` are both rejected by `typos`, which splits on + the hyphen and then reads `mis` as a misspelling. The `mis-wired` wording + also lived in the `WallClock` doc comment and in this plan's sketch of it, + so all three were changed together. That commit touches + `src/stdlib/time/clock.rs`, so the code gates were re-run over it. 8. The final gate transcript tails for `check-fmt`, `typecheck`, `lint`, and `test`. diff --git a/docs/netsuke-test-framework-technical-design.md b/docs/netsuke-test-framework-technical-design.md index 679196934..af523adc5 100644 --- a/docs/netsuke-test-framework-technical-design.md +++ b/docs/netsuke-test-framework-technical-design.md @@ -277,10 +277,10 @@ when it installs `now()`, so each evaluation reads the provider again, and no provider-less call path can bypass it. `StdlibConfig` stores the provider in a private `WallClock` container, a -mechanical addition this design did not name. It confines a hand-written -`Debug` (a closure is not printable, so `StdlibConfig` could not have derived -one) and normalizes every read to UTC, because an injected provider is free to -return any offset while `now()` is documented to yield UTC. Manifest-query +mechanical addition this design did not name. It confines a handwritten `Debug` +(a closure is not printable, so `StdlibConfig` could not have derived one) and +normalizes every read to UTC, because an injected provider is free to return +any offset while `now()` is documented to yield UTC. Manifest-query registration receives no clock and keeps its refusing `now` stub. The test runner's `given.clock.now` input diff --git a/docs/rfcs/0007-netsukefile-testing-framework.md b/docs/rfcs/0007-netsukefile-testing-framework.md index e78a3db5e..b095edf7c 100644 --- a/docs/rfcs/0007-netsukefile-testing-framework.md +++ b/docs/rfcs/0007-netsukefile-testing-framework.md @@ -56,10 +56,12 @@ of capability-scoped non-build loading. The test runner is a third mode of that same shape, so it extends the established pattern instead of introducing a parallel one; the technical design records the consequences. -What is missing: a clock seam for `now()` (it calls the system clock directly), -a mechanism to substitute manifest macros, any test dialect, discovery, mock -engine, fixture lifecycle, or `test` subcommand. The manifest schema rejects -unknown top-level keys, so the proposed `tests` configuration block is a schema +What is missing: a mechanism to substitute manifest macros, any test dialect, +discovery, mock engine, fixture lifecycle, or `test` subcommand. (The clock +seam for `now()` that this section originally listed was supplied by roadmap +item 7.1.1; the [technical design](netsuke-test-framework-technical-design.md) +§5.2 records the implemented shape.) The manifest schema rejects unknown +top-level keys, so the proposed `tests` configuration block is a schema addition with compatibility consequences (see below). ## Goals and non-goals @@ -101,9 +103,10 @@ helpers, and carries results over length-prefixed `serde_json` frames versioned like the existing JSON envelope, so it adds no new dependency. The same stream carries incremental journal checkpoints, so a case killed on the deadline still reports the calls it had already made rather than an empty journal. Two seams -are added (clock provider; macro substitution overlay); network mocking needs -no transport seam because the deny-all policy plus function-level doubles make -the real network code unreachable under test. +are added (the clock provider, supplied by roadmap item 7.1.1, and the macro +substitution overlay); network mocking needs no transport seam because the +deny-all policy plus function-level doubles make the real network code +unreachable under test. Positioning within the product: phase 3 of the roadmap makes Netsuke predictable for humans and automation; phase 4 verifies the compiler itself; diff --git a/src/stdlib/time/clock.rs b/src/stdlib/time/clock.rs index 265b78642..55b18a8af 100644 --- a/src/stdlib/time/clock.rs +++ b/src/stdlib/time/clock.rs @@ -131,7 +131,7 @@ impl Default for WallClock { /// Report the clock's provenance without pretending a closure is printable. /// -/// The label makes a mis-wired clock self-diagnosing: an injected clock that +/// The label makes a wrongly wired clock self-diagnosing: an injected clock that /// never reached registration, or an ambient clock where a test expected an /// injected one, is visible in any `{:?}` of the surrounding configuration. impl fmt::Debug for WallClock { From b81cbcc4d859b8ba286b974129cea76b71152d8e Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 11 Sep 2026 01:28:23 +0200 Subject: [PATCH 14/54] Record the final gate and CodeRabbit evidence in the 7.1.1 exec plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Artefacts entry 8 carried a placeholder promising the final gate transcript tails; entry 9 did not exist. Both are now filled from the seven-gate run over d71e18ed and the CodeRabbit pass that followed it. The transcript is included because the EP-M3 evidence went stale twice — once when the EP-M4 documentation commits landed, and again when the spelling fix touched src/stdlib/time/clock.rs. Gate logs are named per branch, so the second run over a branch overwrites the first run's transcript, and a green result asserted rather than re-taken is not evidence. The entry records that lesson alongside the numbers. Also records that make test-podman was not run: no ansible/ path appears in the change surface. Co-Authored-By: Claude Code --- docs/execplans/7-1-1-clock-provider-seam.md | 60 +++++++++++++++++++-- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index 630227197..09cbb013b 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -2094,8 +2094,60 @@ To be populated during implementation. Required entries: so all three were changed together. That commit touches `src/stdlib/time/clock.rs`, so the code gates were re-run over it. -8. The final gate transcript tails for `check-fmt`, `typecheck`, `lint`, and - `test`. +8. The final gate transcript at `d71e18ed`, the branch's closing commit. All + seven gates were re-run over it because the EP-M3 evidence had been + invalidated by the commits that followed. Verbatim tails: -Keep each excerpt short — the summary line and the failing assertion, not the -whole log. The full logs live at the `/tmp` paths named in `Concrete steps`. + ```plaintext + # make check-fmt + 56 files already formatted + + # make typecheck + All checks passed! + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.24s + + # make lint (PATH="$HOME/go/bin:$PATH") + Your code has been rated at 10.00/10 (previous run: 10.00/10, +0.00) + All checks passed! + + # make test + Summary [ 89.709s] 2830 tests run: 2830 passed (2 slow), 3 skipped + test result: ok. 86 passed; 0 failed; 25 ignored; 0 measured; 0 filtered out; finished in 0.04s + test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.25s + test result: ok. 32 passed; 0 failed; 6 ignored; 0 measured; 0 filtered out; finished in 0.02s + + # make doc-coverage + aggregate 4059/4094 99.15% + ok: doc-comment coverage 99.15% meets the 80.00% threshold. + + # make markdownlint + Linting: 133 file(s) + Summary: 0 error(s) + + # make nixie + 🧜‍♀️✨ All diagrams validated successfully! + ``` + + The `make test` counts are identical to EP-M3's, as they must be: the + intervening commits changed no test and no code path, only doc comments and + Markdown. `make test-podman` was not run — no `ansible/` path is in the + change surface — and the Ansible gates are unchanged by this work. + +9. Second CodeRabbit pass, run over `d71e18ed` after the seven gates above all + exited 0. `coderabbit review --agent` completed on the first attempt with no + rate limiting: 23 files reviewed, zero findings + (`{"type":"complete","status":"review_completed","findings":0,…}`). Log: + `/tmp/coderabbit-netsuke-7-1-1-clock-provider-seam.out`. + + Both CodeRabbit passes were clean, which is the point of running the second: + the first covered `e682ac98` and `ccf63eb0`, and had nothing to say about + the documentation and reconciliation commits that followed. A finding there + would have been invisible to a single early pass. + +One lesson about evidence discipline, recorded because it cost a re-run: gate +logs are named per branch, so a second run over the same branch silently +overwrites the first run's transcript. Evidence is only as fresh as the HEAD it +was taken at, and this plan's EP-M3 evidence went stale twice — once when the +EP-M4 commits landed, and again when the spelling fix touched +`src/stdlib/time/clock.rs`. Re-running is cheap; asserting that a green result +still applies is not the same as knowing it. From b26541303cebacf8f0a6b6e5509d783a471e36df Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 11 Sep 2026 01:29:24 +0200 Subject: [PATCH 15/54] Record the pull request description replacement in the 7.1.1 exec plan PR #696 still described itself as plan-only. The description now covers the delivered seam, and the plan records that swap plus the fact that the draft flag was left alone on purpose: whether to mark the PR ready before or after CodeRabbit's PR-level review is the maintainer's call. Co-Authored-By: Claude Code --- docs/execplans/7-1-1-clock-provider-seam.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index 09cbb013b..b7d19e37f 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -2144,6 +2144,20 @@ To be populated during implementation. Required entries: the documentation and reconciliation commits that followed. A finding there would have been invisible to a single early pass. +10. The pull request description was replaced once the implementation landed. + PR #696 originally carried a body asserting that the PR "contains the plan + only — no implementation", which stopped being true at `b3052d95` and + would have misled the first reviewer to open it. The replacement body + describes the delivered seam, the five design decisions with the arguments + against each alternative, the six-mutation table, the two pre-existing + gaps now closed, and the documentation changes. It points at this plan for + the full delivery record. + + The PR was still a draft at that point. Flipping it to ready-for-review is + a human decision about timing — in particular whether to see the branch + reviewed before CodeRabbit's own PR-level review runs — and was + deliberately left to the maintainer rather than taken here. + One lesson about evidence discipline, recorded because it cost a re-run: gate logs are named per branch, so a second run over the same branch silently overwrites the first run's transcript. Evidence is only as fresh as the HEAD it From ea6c4dbfce32754c69d701571ca597a0907150c1 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 11 Sep 2026 01:37:59 +0200 Subject: [PATCH 16/54] Validate the 7.1.1 branch tip and close its evidence chain The Artefacts entries evidenced d71e18ed, but the tip had moved two commits past it. All seven gates were re-run at 9ebb539f and CodeRabbit reviewed the branch again: green, 23 files, zero findings. The entry also states why the chain terminates rather than recursing. Recording a validation moves the tip past what it records, so a stricter reading demands another run for ever. What stops it is that the code surface has been frozen since d71e18ed; every later commit edits this plan alone, so a fresh run would exercise the same tree. The entry says that argument lapses if any commit touches anything outside this file. Co-Authored-By: Claude Code --- docs/execplans/7-1-1-clock-provider-seam.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index b7d19e37f..df616cb83 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -2158,6 +2158,23 @@ To be populated during implementation. Required entries: reviewed before CodeRabbit's own PR-level review runs — and was deliberately left to the maintainer rather than taken here. +11. Branch-tip validation at `9ebb539f`, taken because entries 8 and 9 + evidence `d71e18ed` and two commits had landed since. All seven gates + exited 0; `make test` reported + `Summary [ 56.738s] 2830 tests run: 2830 passed, 3 skipped`, doc-comment + coverage held at 99.15%, and CodeRabbit completed on the first attempt with + 23 files reviewed and zero findings. + + This closes the evidence chain, with one property worth stating plainly so + a later reader does not chase it: each commit that records a validation + moves the tip past the thing it records. Entry 11 evidences `9ebb539f`, and + the commit carrying entry 11 is itself past that. The recursion terminates + because the code surface has been frozen since `d71e18ed` — every later + commit touches only this file — so what a fresh run over the new tip would + exercise is identical to what `9ebb539f` exercised, minus the record of it. + If a future commit changes anything outside this plan, that argument lapses + and the gates must be re-run. + One lesson about evidence discipline, recorded because it cost a re-run: gate logs are named per branch, so a second run over the same branch silently overwrites the first run's transcript. Evidence is only as fresh as the HEAD it From 71a7b71135a46ffcae1d6d48911e7329961848cf Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 11 Sep 2026 01:47:56 +0200 Subject: [PATCH 17/54] Extract configure_stdlib from render_template_with_context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The BDD rendering step built and configured StdlibConfig inline, which put render_template_with_context at a cyclomatic complexity of 10 — the method CodeScene's advisory code-health gate names when it fails this file (10.00 -> 9.69). Move the construction and the seven ordered option applications into a private configure_stdlib helper returning Result. The application order is unchanged — network policy, clock, home override, the fetch, command output and command stream byte limits, then the PATH override — as are the four error-context strings, so rendering behaviour is identical. The function now reads as the sequence a scenario performs: localize, open the workspace, build the environment, register, reset impure state, render, and record the outcome. Complexity drops 10 -> 3. with_workspace_root_path takes impl AsRef, so passing the root by reference removes the clone the inline version needed. The root type is the Utf8PathBuf already returned by ensure_workspace, so only the Utf8Path import is new. Co-Authored-By: Claude Code --- tests/bdd/steps/stdlib/rendering.rs | 34 +++++++++++++++++++---------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/tests/bdd/steps/stdlib/rendering.rs b/tests/bdd/steps/stdlib/rendering.rs index 9344eaf47..041f5c628 100644 --- a/tests/bdd/steps/stdlib/rendering.rs +++ b/tests/bdd/steps/stdlib/rendering.rs @@ -3,6 +3,7 @@ use crate::bdd::fixtures::{RefCellOptionExt, TestWorld}; use crate::bdd::types::{ContextKey, ContextValue, TemplateContent}; use anyhow::{Context, Result}; +use camino::Utf8Path; use cap_std::{ambient_authority, fs_utf8::Dir}; use minijinja::{Environment, context, value::Value}; use netsuke::stdlib::{self, ClockProvider, NetworkPolicy, StdlibConfig}; @@ -60,17 +61,13 @@ fn ensure_stdlib_localizer(world: &TestWorld) -> Result<()> { Ok(()) } -pub(crate) fn render_template_with_context( - world: &TestWorld, - template: &TemplateContent, - ctx: Value, -) -> Result<()> { - ensure_stdlib_localizer(world)?; - let root = ensure_workspace(world)?; - let mut env = Environment::new(); - let workspace = Dir::open_ambient_dir(&root, ambient_authority()) - .context("open stdlib workspace directory")?; - let mut config = StdlibConfig::new(workspace)?.with_workspace_root_path(root.clone())?; +/// Build the stdlib configuration a rendering step runs with. +/// +/// The options are applied in a fixed order — network policy, clock, home +/// override, the response and command byte limits, then the `PATH` override — +/// so a scenario that sets several of them renders the same way every run. +fn configure_stdlib(world: &TestWorld, workspace: Dir, root: &Utf8Path) -> Result { + let mut config = StdlibConfig::new(workspace)?.with_workspace_root_path(root)?; // Extract config from world before applying let render_cfg = extract_render_config(world); @@ -103,6 +100,21 @@ pub(crate) fn render_template_with_context( config = config.with_path_override(path.clone()); } + Ok(config) +} + +pub(crate) fn render_template_with_context( + world: &TestWorld, + template: &TemplateContent, + ctx: Value, +) -> Result<()> { + ensure_stdlib_localizer(world)?; + let root = ensure_workspace(world)?; + let mut env = Environment::new(); + let workspace = Dir::open_ambient_dir(&root, ambient_authority()) + .context("open stdlib workspace directory")?; + let config = configure_stdlib(world, workspace, &root)?; + let state = stdlib::register_with_config(&mut env, config)?; state.reset_impure(); From 559a80ca82bc926c7c0324ce82d8292e9353702f Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 11 Sep 2026 01:52:21 +0200 Subject: [PATCH 18/54] Record the rebase and the configure_stdlib extraction in the 7.1.1 exec plan Two post-completion events are now recorded in `Artefacts and notes`: - Entry 12: the rebase onto `origin/main` at `3348cc0a`, why the weave merge driver was bypassed for the replay, the arithmetic check that the replay was clean, and the four gates re-run over the rebased tip. - Entry 13: the CodeScene-triggered extraction of `configure_stdlib`, what makes it behaviour-preserving, and the five gates re-run over `e1568a1b`. `Surprises & discoveries` gains the generalizable lesson: a branch inherits the complexity bill for the decision points it adds to a function it did not write, and no local gate mirrors CodeScene's check. Docs only; no code, test, or build surface changes. --- docs/execplans/7-1-1-clock-provider-seam.md | 80 +++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index df616cb83..1a0a9edfe 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -1447,6 +1447,10 @@ above, which are disposable. - [x] EP-M5 — Roadmap 7.1.1 and its four sub-bullets marked done, each mapped to a named artefact; `Outcomes & retrospective` completed and the plan set to `COMPLETE`. +- [x] Post-completion: rebased onto `origin/main` (`3348cc0a`) with the weave + merge driver bypassed, then extracted `configure_stdlib` from the BDD render + helper in response to a CodeScene finding — evidence in + `Artefacts and notes` entries 12 and 13. ## Surprises & discoveries @@ -1676,6 +1680,19 @@ Recorded during planning; extend during implementation. Both are now `handwritten` and `wrongly wired`. Markdown gates must be run through `make markdownlint`, not `make check-fmt` alone. +- Observation: a branch inherits the complexity bill for the decision points it + adds, even to a function it did not write. Evidence: "CodeScene Code Health + Review (main)" failed on `1b87a1e5` with a `Complex Method` finding against + `tests/bdd/steps/stdlib/rendering.rs::render_template_with_context`, scoring + the file 10.00 at the base and 9.69 on the branch. The method predates this + work, but the branch's own edit — the three-line clock application added by + `fdffbd4e` — is what carried it past CodeScene's threshold, and the check + reports its delta against `main` as the base. Every local gate was green + throughout, because Clippy has no complexity ceiling and no repository lint + enumerates BDD step helpers. Impact: a green local gate set is not a green + pull request, and the maintainer's request to split the method (entry 13) is + the right response rather than an unrelated tidy-up. + ## Decision log - **D1 — Port shape follows the `EnvReader` precedent verbatim.** @@ -2175,6 +2192,69 @@ To be populated during implementation. Required entries: If a future commit changes anything outside this plan, that argument lapses and the gates must be re-run. +12. The branch was rebased onto `origin/main` at `3348cc0a` (the merge of PR + #644/#663), because the PR's base had moved 20 commits since it opened. All + 16 branch commits replayed and the tip became `1b87a1e5`. + + The weave merge driver was bypassed for the whole replay with + `git -c core.attributesFile=/dev/null rebase origin/main`, rather than + trusted to three-way-merge the branch. Weave is configured globally for + `*.rs` and `*.md` in this environment, and its failure mode *in this + repository* is a clean exit reporting "very_high confidence" while silently + duplicating whole sections of long Markdown — recorded against this + repository's design documents, where it turned 21 task IDs into 42 and grew + a file from 1369 to 1571 lines. A tool that reports success while corrupting + its input cannot be audited cheaply, and this plan is 2100 lines of + Markdown. Bypassing it costs nothing when the replay is clean and removes + the one failure mode that would be invisible in review. + + Only two files overlapped between the branch and `origin/main`: + `docs/developers-guide.md` and `src/stdlib/mod.rs`. Correctness of the + replay was checked arithmetically rather than by reading a diff: for every + changed file the rebased result decomposes exactly as base + branch delta + + main delta; the changed-file sets agree; this plan's structural counts are + unchanged; and no conflict marker survives anywhere in the tree. + + The four requested gates were re-run over `1b87a1e5` and all exited 0: + `make check-fmt` (`56 files already formatted`), `make typecheck` + (`All checks passed!`), `make lint` (`10.00/10`), and `make test` + (`Summary [ 60.673s] 2879 tests run: 2879 passed, 3 skipped`, doctests + `86 passed / 2 passed / 32 passed`). The count moved from EP-M3's 2830 to + 2879 because `origin/main` brought 49 tests of its own; none of this + branch's tests changed. + +13. `configure_stdlib` was extracted from `render_template_with_context` at + `e1568a1b`, in response to the one pull-request gate that was red: CodeScene + "Code Health Review (main)" failed with a `Complex Method` finding against + `tests/bdd/steps/stdlib/rendering.rs::render_template_with_context`, docking + the file from 10.00 to 9.69. The method predates this work, but the branch + edited it in `fdffbd4e` — adding the clock application — and CodeScene + scores the delta against `main`, so the finding belongs to this change. No + local gate can see it: Clippy has no complexity ceiling, and no repository + lint enumerates BDD step helpers. + + The extraction is behaviour-preserving by construction and by test. The + helper receives the already-open `Dir` and the `Utf8PathBuf` that + `ensure_workspace` returns (borrowed, which also retired the `root.clone()` + the old call required), applies the seven options in their existing order — + network policy, clock, home override, response limit, command output limit, + command stream limit, `PATH` override — and returns + `Result`. The four error-context strings are unchanged and + still attached to the same calls. What remains in + `render_template_with_context` is exactly its half of the job: localizer, + workspace, environment, stdlib registration, impure-state reset, render, + and storing either the output or the error in `TestWorld`. + + `render_template_with_context` fell from cyclomatic complexity 10 to 3, and + the helper measures 8 — under CodeScene's threshold. All five gates were + re-run over the exact bytes committed as `e1568a1b`, because the helper runs + on the BDD path and a green full suite would hide a step that stopped + matching: `make check-fmt`, `make typecheck`, `make lint` (10.00/10), + `cargo nextest run -E 'binary(bdd_tests)'` + (`Summary [ 5.322s] 260 tests run: 260 passed, 0 skipped`) and `make test` + (`Summary [ 67.265s] 2879 tests run: 2879 passed, 3 skipped`). CodeScene's + Code Health Review then passed on `e1568a1b`, 50 seconds. + One lesson about evidence discipline, recorded because it cost a re-run: gate logs are named per branch, so a second run over the same branch silently overwrites the first run's transcript. Evidence is only as fresh as the HEAD it From 50b5671f485993bf3303d012222ca198b9aa9f1d Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 11 Sep 2026 01:53:49 +0200 Subject: [PATCH 19/54] Apply canonical Markdown formatting to the 7.1.1 exec plan `make check-fmt` rejected the wrapped Progress bullet added by the previous commit; `make fmt` rewrapped it and touched no other file. Docs only. --- docs/execplans/7-1-1-clock-provider-seam.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index 1a0a9edfe..32d694707 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -1449,8 +1449,8 @@ above, which are disposable. `COMPLETE`. - [x] Post-completion: rebased onto `origin/main` (`3348cc0a`) with the weave merge driver bypassed, then extracted `configure_stdlib` from the BDD render - helper in response to a CodeScene finding — evidence in - `Artefacts and notes` entries 12 and 13. + helper in response to a CodeScene finding — evidence in `Artefacts and notes` + entries 12 and 13. ## Surprises & discoveries From 5ee77b4d1d8334f5f2a506d1ad3fc0c4b64205ae Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 11 Sep 2026 02:12:24 +0200 Subject: [PATCH 20/54] Record the ready-for-review flip in the 7.1.1 exec plan PR #696 was marked ready for review at `be6858fb` once all seventeen verdict-reporting checks were green, CodeScene Code Health included. Entry 14 records that, notes that it supersedes entry 10s timing note, and states the consequence: CodeRabbit had been skipping the branch as a draft, so the flip hands it the branch for its own PR-level review. Docs only; `make check-fmt` and `make markdownlint` are green over it. --- docs/execplans/7-1-1-clock-provider-seam.md | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index 32d694707..cd5e416f9 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -2255,6 +2255,28 @@ To be populated during implementation. Required entries: (`Summary [ 67.265s] 2879 tests run: 2879 passed, 3 skipped`). CodeScene's Code Health Review then passed on `e1568a1b`, 50 seconds. +14. PR #696 was marked ready for review at `be6858fb`, on explicit instruction, + once every pull-request check that reports a verdict was green: the + seventeen are `build-test`, `kani-smoke`, `netsukefile`, both Windows jobs, + the six release builds, the release metadata, admission-canary and + Windows-native-recipe jobs, both CodeScene checks, and CodeRabbit — the last + reporting a pass on a draft-skip rather than a review. `automerge`, + `Kody Code Review` and `release / release` skip by design and were not + waited on. + + CodeScene's Code Health Review — the check that was red at `1b87a1e5` and + prompted the extraction in entry 13 — passed on the same commit as + everything else. That is the evidence the extraction was the right fix + rather than a lucky one: the gate that objected to complexity 10 accepted + complexity 3 plus a helper at 8. + + This supersedes entry 10's timing note. The flip was left to the maintainer + at the time, and the maintainer then asked for it. One consequence is worth + stating: CodeRabbit's application-level check had been skipping this branch + as a draft, so making the PR ready hands the full branch to CodeRabbit's own + PR review for the first time. The local `coderabbit review --agent` passes + in entries 9 and 13 are independent of that and do not pre-empt it. + One lesson about evidence discipline, recorded because it cost a re-run: gate logs are named per branch, so a second run over the same branch silently overwrites the first run's transcript. Evidence is only as fresh as the HEAD it From 46dd557aa4ca362607992a88227184829d4ab477 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 11 Sep 2026 02:31:30 +0200 Subject: [PATCH 21/54] Record the withdrawn CodeRabbit finding in the 7.1.1 exec plan CodeRabbit requested changes over `Iso8601::DEFAULT` allegedly emitting `+00:00`; the finding described `time` 0.3.44 while `Cargo.lock` pins 0.3.55, whose ISO-8601 formatter writes `Z` for a UTC offset. The exact-equality test it cited passes on `Z`, so the suggested edit would have turned a green test red. The finding was withdrawn and the thread resolved. Entry 15 records the rebuttal and its three evidence lines; Surprises gains the generalizable observation that a version-sensitive review finding is cheapest to settle with the lock file plus an executed assertion. Docs only; `make check-fmt` and `make markdownlint` are green over it. --- docs/execplans/7-1-1-clock-provider-seam.md | 45 +++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index cd5e416f9..72f5b1ea8 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -1693,6 +1693,17 @@ Recorded during planning; extend during implementation. pull request, and the maintainer's request to split the method (entry 13) is the right response rather than an unrelated tidy-up. +- Observation: a review finding can cite a dependency's documentation while + describing a different version of that dependency, and the difference is + invisible in the finding's prose. Evidence: CodeRabbit requested changes at + `02caf3ee` over `Iso8601::DEFAULT` allegedly writing `+00:00`; its research + queried `time` 0.3.44 and itself returned contradictory answers, while + `Cargo.lock` pins 0.3.55, whose `formatting/iso8601.rs:185-187` writes `Z` + for a UTC offset — and the exact-equality test it cited passes on `Z`. + Impact: the suggested edit would have broken a green test. Confirm the + resolved version and execute the assertion before applying any + version-sensitive finding; the withdrawal is recorded in entry 15. + ## Decision log - **D1 — Port shape follows the `EnvReader` precedent verbatim.** @@ -2277,6 +2288,40 @@ To be populated during implementation. Required entries: PR review for the first time. The local `coderabbit review --agent` passes in entries 9 and 13 are independent of that and do not pre-empt it. +15. CodeRabbit's application-level review ran for the first time at `02caf3ee`, + because it had been skipping the branch as a draft, and requested changes + over a single finding: that `Iso8601::DEFAULT` emits a minute-precision + `+00:00` rather than `Z`, so the expected literals in + `tests/std_filter_tests/time_functions.rs` were wrong. The finding was + falsified and withdrawn rather than applied; the suggested edit would have + turned a green test red. + + The rebuttal rested on three independent pieces of evidence. The ordering + matters, because the first two alone are an argument from reading, and the + third is the one that settles it: + + - the pin: `Cargo.lock` resolves `time` to **0.3.55**, while the finding's + own research queried 0.3.44 — and its two web results contradicted each + other, one of them reporting that `DEFAULT` "does not format UTC as + `+00:00` by default"; + - the source: `time-0.3.55/src/formatting/iso8601.rs:185-187` returns + `write(output, "Z")` when `offset_is_utc`, and `Iso8601::DEFAULT` is + `Config::DEFAULT`, whose `formatted_components` is `DateTimeOffset`; + - the execution: `now_uses_configured_clock::case_1_utc` asserts + `rendered == "2026-06-08T12:00:00Z"` by exact equality, and + `cargo nextest run -E 'binary(std_filter_tests) & test(now_uses_configured_clock)'` + reports `Summary [ 0.021s] 3 tests run: 3 passed, 66 skipped`. A case + that passes on that literal is not passing against `+00:00` output. + + CodeRabbit re-ran its analysis against the resolved version, replied "My + finding used an incorrect dependency-version assumption. I withdraw it", + and approved; the thread closed as resolved and the merge state returned to + `CLEAN`. The episode is recorded because the failure mode generalizes: a + finding derived from the published documentation of a *different* version + of a dependency is indistinguishable, at a glance, from one derived from + the code. The cheapest discriminator is the lock file plus an executed + assertion, not a careful reading of the diff. + One lesson about evidence discipline, recorded because it cost a re-run: gate logs are named per branch, so a second run over the same branch silently overwrites the first run's transcript. Evidence is only as fresh as the HEAD it From f4ffa99449e8fd08ec5805dbba2f3ac0de2912ec Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 02:31:43 +0200 Subject: [PATCH 22/54] Record the scope-tolerance exception in the 7.1.1 exec plan The pull request exceeds both limbs of the plan's scope tolerance: 23 changed files against a limit of 20, and 3,039 net added lines against a limit of 600 (708 net even with this plan's 2,331 lines excluded). The tolerance says a substantial overrun "means the design was wrong", and the plan must not be read as conformant while its own scope check fails. Records the escalation and its acceptance rather than a silent waiver: - D14 states the measured scope per head, when each limb first fired (net lines at the plan's first commit, 1,581 net in one file; file count at 3fdd8260, 21 files), the attribution of the 3,039 net lines, and why the overrun does not bear out the tolerance's own inference: the production seam is 150 net lines, and the excess is dominated by the execution record plus the coverage the plan itself mandated. - `Outcomes & retrospective` gains an explicit conformance exception, so the delivery is marked as not fully conformant to this plan. - `Tolerances` and `Progress` point at D14 rather than restating it. - `Artefacts and notes` entry 16 records the reproduce commands and the durable lesson: no gate reads a plan's Tolerances section, so a breach is invisible to machine verification. Docs only; no code, test, or build surface changes. --- docs/execplans/7-1-1-clock-provider-seam.md | 100 +++++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index 72f5b1ea8..68b111feb 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -345,7 +345,8 @@ Stop and escalate — do not improvise — when any threshold is reached. - **Scope.** More than 20 files changed, or more than 600 net lines added across the whole change. This is a small, well-understood seam; substantial - overrun means the design was wrong. + overrun means the design was wrong. Fired post-completion: the escalation and + its acceptance are recorded in D14, not renegotiated here. - **Interface.** Any change to a *public* signature other than the three additions this plan sanctions (`stdlib::ClockProvider`, `stdlib::ClockInstant`, `stdlib::system_clock`, `stdlib::fixed_clock`, and @@ -1451,6 +1452,9 @@ above, which are disposable. merge driver bypassed, then extracted `configure_stdlib` from the BDD render helper in response to a CodeScene finding — evidence in `Artefacts and notes` entries 12 and 13. +- [x] Post-completion: the scope tolerance fired and was escalated; accepted by + the maintainer on 2026-09-19 and recorded as this plan's one conformance + exception in D14. The delivery is not scope-conformant to the plan. ## Surprises & discoveries @@ -1899,6 +1903,65 @@ Recorded during planning; extend during implementation. later reader is not surprised. Date/Author: 2026-09-08, planning; added after design review. +- **D14 — Scope tolerance: escalated and accepted, not silently breached; the + PR is not scope-conformant.** Decided: accept the deviation, record it as an + escalation rather than a waiver, and stop claiming the delivery is fully + conformant to this plan. This is the one conformance exception against an + otherwise-complete plan, and it stands as an exception. + + *Measured scope*, reported by GitHub for #696 against base `3348cc0a` and + reproduced locally with `git diff --numstat`: + + | Head | Files | Additions | Deletions | Net | Net excl. plan | + | ---------- | ----- | --------- | --------- | ----- | -------------- | + | `02caf3ee` | 23 | 3,127 | 133 | 2,994 | 708 | + | `e401f4d7` | 23 | 3,172 | 133 | 3,039 | 708 | + + Both limbs fire at either head: 23 > 20 files, and 2,994 > 600 net — with 708 + net even if this living exec plan (2,286 or 2,331 lines, by head) is excluded + entirely. The non-plan remainder is **invariant at 708 net across 22 files** + under plan-maintenance commits, which is the figure to quote when the plan's + own growth is held to one side; the totals rise with each such commit because + the plan is inside the diff. + + *When it fired.* The net-lines limb fired on this plan's **own first commit** + (`9042fe66`, the draft exec plan, 1,581 net in one file) — the threshold was + exceeded before any production code existed. The file-count limb fired at + `3fdd8260` (21 files). Neither was noticed at the time, because the tolerance + was written to guard a *code* change and its principal consumer turned out to + be the execution record documenting that change. That is the process lesson: + a scope tolerance that counts every changed file and line will fire on the + plan itself unless the plan's own artefacts are excluded by construction. + + *Attribution of the 3,039 net lines* (so a later reader can audit rather than + take this on trust): exec plan 2,331; tests 163; production `src/` 472 (of + which `clock.rs` 150, `clock_tests.rs` 260, the `tests.rs`/ + `tests_support.rs` split 10 net after a 260-line move); governing docs 62; + the recorded `proptest` regression seed 11. + + *Assessment against the tolerance's own reasoning.* The tolerance says a + substantial overrun "means the design was wrong". That inference does not + hold here, and the evidence is the shape of the overrun rather than an appeal + to intent: the production seam is 150 net lines with a 39-line config delta, + and the excess is dominated by the execution record plus the coverage the + plan's own verification requirements mandated (OBL-1 through OBL-7, the + six-mutation exercise, the two test layers D7 makes mandatory, and the BDD + scenarios). The requirement to keep this plan current *is* the source of the + largest single file. No roadmap requirement went unmet and no obligation was + discharged by volume: the seam's interface stayed inside the `Interface` + tolerance (D13's five sanctioned additions, no `StdlibConfig::new` change), + `Cargo.toml` gained nothing, and all gates are green. Severity: low for + correctness, medium for process — the exception is recorded so it cannot be + misread as conformance. + + *Accepted by:* the maintainer, on 2026-09-19, on the explicit instruction to + record it as an escalation and accepted deviation. If the disposition is ever + revisited, the honest options are to split the execution record out of the + deliverable and upload it as a PR attachment, or to amend the tolerance so + that plan artefacts are excluded from its count — not to describe the + delivery as within tolerance. Date/Author: 2026-09-19, post-completion, after + review. + ## Outcomes & retrospective Complete. The stdlib `now()` helper reads its instant through an injected @@ -1938,6 +2001,14 @@ Reconciliation against the conformance basis: Upstream changes and deviations, all recorded above or in `Surprises & discoveries`: +- **Conformance exception — scope (D14).** The pull request exceeds both limbs + of this plan's scope tolerance: 23 changed files against a limit of 20, and + 3,039 net added lines against a limit of 600 (708 net even with this exec + plan's 2,331 lines excluded). Escalated and accepted by the maintainer on + 2026-09-19; the full attribution and assessment are in D14. This delivery is + therefore **not fully conformant to this plan**: every other tolerance held, + but this one did not, and it is recorded as an accepted deviation rather than + a waiver. - The ADR-008 addendum is dated 2026-09-11 rather than the plan's `2026-09-08`, matching the file's convention of dating each entry when it is written. @@ -2322,6 +2393,33 @@ To be populated during implementation. Required entries: the code. The cheapest discriminator is the lock file plus an executed assertion, not a careful reading of the diff. +16. The scope tolerance was escalated and accepted post-completion (D14). The + per-head numbers are tabulated in D14; they were reported by GitHub and + independently reproduced from the local graph. Reproduce with: + + ```plaintext + gh api repos/leynos/netsuke/pulls/696 \ + --jq '{changed_files, additions, deletions, head: .head.sha, base: .base.sha}' + git diff --numstat 3348cc0a..HEAD + ``` + + The figure that survives the most favourable reading is **708 net across 22 + files**, with this plan's own lines excluded — the net-lines limb fired at + this plan's first commit (`9042fe66`, 1,581 net), the file-count limb at + `3fdd8260` (21 files); neither was caught then, because the tolerance was + written to bound a code change and its largest consumer proved to be the + record of that change. + + The escalation was raised by the maintainer rather than by the gates, which + is the durable point: nothing in `make check-fmt`, `make test`, + `make typecheck`, `make lint`, `make doc-coverage`, `make markdownlint`, or + `make nixie` reads a plan's `Tolerances` section, so a breach is invisible + to machine verification and depends on a human or an agent re-reading the + plan against the PR. Both limbs had been breached for days before the + review that surfaced them — eleven from the first commit (`9042fe66`, dated + 2026-09-08) to this escalation (2026-09-19), eight from the file-count + breach (`3fdd8260`, 2026-09-11). + One lesson about evidence discipline, recorded because it cost a re-run: gate logs are named per branch, so a second run over the same branch silently overwrites the first run's transcript. Evidence is only as fresh as the HEAD it From 6edd2b56ca778e433f28536c1b458b76d51173e5 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 02:57:30 +0200 Subject: [PATCH 23/54] Sharpen the non-plan remainder figure in D14 The 708 net non-plan figure holds at both tabulated heads, but it is not invariant for the branch's whole life: it was 696 across 22 files until the configure_stdlib extraction (e1568a1b) added 12 net of real code. State that derivation, so the figure reads as measured rather than assumed. Verified against the local graph as part of the conformance gate run: all six tabulated figures reproduce exactly, the attribution of the 3,039 net lines sums correctly, and the two breach commits are the ones named. Docs only; no code, test, or build surface changes. --- docs/execplans/7-1-1-clock-provider-seam.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index 68b111feb..9392709a7 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -1919,10 +1919,12 @@ Recorded during planning; extend during implementation. Both limbs fire at either head: 23 > 20 files, and 2,994 > 600 net — with 708 net even if this living exec plan (2,286 or 2,331 lines, by head) is excluded - entirely. The non-plan remainder is **invariant at 708 net across 22 files** - under plan-maintenance commits, which is the figure to quote when the plan's - own growth is held to one side; the totals rise with each such commit because - the plan is inside the diff. + entirely. The non-plan remainder is **708 net across 22 files** at both + tabulated heads, where it moved from 696 at the `configure_stdlib` extraction + (`e1568a1b`, +12 net of real code), and plan-maintenance commits leave it + untouched; 708 is therefore the figure to quote when the plan's own growth is + held to one side. The totals rise with each such commit because the plan is + inside the diff. *When it fired.* The net-lines limb fired on this plan's **own first commit** (`9042fe66`, the draft exec plan, 1,581 net in one file) — the threshold was From 0ff685cbd1c9c95496fe125524227c2fb000666a Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 04:21:49 +0200 Subject: [PATCH 24/54] Extract the clock seam from config/mod.rs into a sibling module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI lints the pull request's merge tree, and in that tree Whitaker's `module_max_lines` cap fires: "Module config spans 421 lines, exceeding the allowed 400", at src/stdlib/mod.rs:12:5. The lint counts the whole file behind a file-backed module, and the merged file is a purely additive sum: 351 lines at the base, +32 from main's 5c19b8c8 (the file-read budget), +38 from this branch (the clock seam). Each parent passes the cap alone (383 and 389); only the combination reaches 421. `git merge-tree --write-tree` reproduces the count locally, so the fix is measured against the same artifact CI lints. Move `StdlibConfig::with_clock` and the `clock()` accessor into `config/clock.rs`, mirroring the existing `ambient.rs` and `which.rs` sibling modules — the grouping `which.rs` documents, where feature-specific configuration leaves `config/mod.rs` as the shared surface. The merged file drops to 387 lines. No public API or behaviour change: the builder keeps its doctest (now at clock.rs:17), the accessor keeps its `pub(crate)` visibility, and the field, its default, and the registration path are untouched. Co-Authored-By: Claude Code --- src/stdlib/config/clock.rs | 42 ++++++++++++++++++++++++++++++++++++++ src/stdlib/config/mod.rs | 38 ++-------------------------------- 2 files changed, 44 insertions(+), 36 deletions(-) create mode 100644 src/stdlib/config/clock.rs diff --git a/src/stdlib/config/clock.rs b/src/stdlib/config/clock.rs new file mode 100644 index 000000000..1195f85c6 --- /dev/null +++ b/src/stdlib/config/clock.rs @@ -0,0 +1,42 @@ +//! Wall-clock configuration on [`StdlibConfig`]. +//! +//! The `now()` helper reports the current instant, which makes any render that +//! calls it unrepeatable. The builder here swaps the host clock for a caller +//! supplied [`ClockProvider`] so tests and other callers can pin the instant. +//! Grouping it with the configuration surface keeps `config/mod.rs` to the +//! shared settings rather than one module per layer. + +use super::StdlibConfig; +use crate::stdlib::time::{ClockProvider, WallClock}; + +impl StdlibConfig { + /// Replace the wall-clock source backing `now()`. + /// + /// # Examples + /// + /// ```rust + /// use minijinja::Environment; + /// use netsuke::stdlib::{self, StdlibConfig, fixed_clock}; + /// use time::macros::datetime; + /// + /// let instant = datetime!(2026-06-08 12:00:00 UTC); + /// let config = StdlibConfig::from_current_dir() + /// .expect("open workspace") + /// .with_clock(fixed_clock(instant)); + /// + /// let mut env = Environment::new(); + /// stdlib::register_with_config(&mut env, config).expect("register stdlib"); + /// let rendered = env.render_str("{{ now() }}", ()).expect("render"); + /// assert_eq!(rendered, "2026-06-08T12:00:00Z"); + /// ``` + #[must_use] + pub fn with_clock(mut self, provider: ClockProvider) -> Self { + self.clock = WallClock::new(provider); + self + } + + /// Return the wall-clock source backing the `now()` helper. + pub(crate) const fn clock(&self) -> &WallClock { + &self.clock + } +} diff --git a/src/stdlib/config/mod.rs b/src/stdlib/config/mod.rs index a9e83a659..52078db7d 100644 --- a/src/stdlib/config/mod.rs +++ b/src/stdlib/config/mod.rs @@ -1,6 +1,7 @@ //! Configuration types and defaults for wiring the stdlib into `MiniJinja`. mod ambient; +mod clock; mod which; use super::config_types::HomeDirectory; @@ -9,12 +10,7 @@ pub use super::config_types::{ DEFAULT_FETCH_CACHE_DIR, DEFAULT_FETCH_MAX_RESPONSE_BYTES, DEFAULT_FILE_MAX_READ_BYTES, DEFAULT_WHICH_CACHE_CAPACITY, FileConfig, NetworkConfig, }; -use super::{ - command, - network::NetworkPolicy, - time::{ClockProvider, WallClock}, - which::WORKSPACE_SKIP_DIRS, -}; +use super::{command, network::NetworkPolicy, time::WallClock, which::WORKSPACE_SKIP_DIRS}; use crate::localization::{self, keys}; use anyhow::{anyhow, bail, ensure}; use camino::{Utf8Path, Utf8PathBuf}; @@ -277,41 +273,11 @@ impl StdlibConfig { self } - /// Replace the wall-clock source backing `now()`. - /// - /// # Examples - /// - /// ```rust - /// use minijinja::Environment; - /// use netsuke::stdlib::{self, StdlibConfig, fixed_clock}; - /// use time::macros::datetime; - /// - /// let instant = datetime!(2026-06-08 12:00:00 UTC); - /// let config = StdlibConfig::from_current_dir() - /// .expect("open workspace") - /// .with_clock(fixed_clock(instant)); - /// - /// let mut env = Environment::new(); - /// stdlib::register_with_config(&mut env, config).expect("register stdlib"); - /// let rendered = env.render_str("{{ now() }}", ()).expect("render"); - /// assert_eq!(rendered, "2026-06-08T12:00:00Z"); - /// ``` - #[must_use] - pub fn with_clock(mut self, provider: ClockProvider) -> Self { - self.clock = WallClock::new(provider); - self - } - /// Return the configured home directory source. pub(crate) const fn home_directory(&self) -> &HomeDirectory { &self.home_directory } - /// Return the wall-clock source backing the `now()` helper. - pub(crate) const fn clock(&self) -> &WallClock { - &self.clock - } - /// The configured fetch cache directory relative to the workspace root. #[must_use] pub fn fetch_cache_relative(&self) -> &Utf8Path { From ca3d6e96c42b3c3b92bb164a4511d1112e6b9e3d Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 05:59:58 +0200 Subject: [PATCH 25/54] Re-anchor D14's figures to the rebased branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second rebase orphaned the six SHAs D14's scope table cited and changed the merge base GitHub reports, so the entry described a head that no longer exists and numbers that no longer apply. Replace the two-row table with three rows: the two pre-rebase heads (`02caf3ee`, `e401f4d7`) kept for their measurements, plus the current `f994800c` against base `a273fad3` — 24 files, +3,280 / -133, 3,147 net, 716 net excluding this plan. Rewrite the prose that quoted the old figures, including "When it fired", in terms that survive a further rebase, and add a note that the pre-rebase identifiers now exist only as unreachable objects. Recompute the attribution for the current head: this plan 2,431; src/ 480 net (547 added, 67 removed) — clock.rs 150, clock_tests.rs 260 and config/clock.rs 42 as new files, the tests.rs/tests_support.rs split 10 net after a 58-line move, 18 lines of balance from the mod.rs files and register.rs; tests 163 net; governing docs 62 net; the proptest seed 11. The assessment's "39-line config delta" becomes 40 lines. Co-Authored-By: Claude Code --- docs/execplans/7-1-1-clock-provider-seam.md | 149 ++++++++++++++++---- 1 file changed, 119 insertions(+), 30 deletions(-) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index 9392709a7..bbee35e61 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -1455,6 +1455,10 @@ above, which are disposable. - [x] Post-completion: the scope tolerance fired and was escalated; accepted by the maintainer on 2026-09-19 and recorded as this plan's one conformance exception in D14. The delivery is not scope-conformant to the plan. +- [x] Post-completion: rebased onto `origin/main` (`a273fad3`) and the clock + seam extracted from `src/stdlib/config/mod.rs` into a sibling `config/clock.rs`, + clearing a `module_max_lines` failure that only the merge tree exhibited — + the episode is D15 and `Artefacts and notes` entry 17. ## Surprises & discoveries @@ -1708,6 +1712,30 @@ Recorded during planning; extend during implementation. resolved version and execute the assertion before applying any version-sensitive finding; the withdrawal is recorded in entry 15. +- Observation: CI lints the pull request's *merge tree*, so a lint breach can + exist that neither parent exhibits and that no local run over the branch can + reproduce. Evidence: pushing `8056a4da` turned CI red in both `build-test` + (step "Lint") and `Windows / lint-windows` with `error: Module config spans + 421 lines, exceeding the allowed 400.` at `src/stdlib/mod.rs:12:5`, under + `-D module-max-lines` implied by `-D warnings`. The branch's own + `src/` was byte-identical to the last green head (`e401f4d7` apart from the + exec plan), so the lint target was the GitHub merge ref: + `src/stdlib/config/mod.rs` measures 351 lines at the base `3348cc0a`, 383 on + `origin/main` after `5c19b8c8`, 389 on the branch, and **421 in + `refs/pull/696/merge`** — each parent passes the 400-line cap alone and only + the combination exceeds it, because Whitaker's `module_max_lines` counts the + whole file behind a file-backed module and the merge is purely additive + (351 + 32 + 38 = 421). Impact: "green locally, red in CI" was not a + flake and not a stale-runner artefact; it is structural. `git merge-tree + --write-tree origin/main HEAD` prints the merged tree's root as an object ID, + so the exact lint input is obtainable without a scratch checkout — + `git cat-file blob :src/stdlib/config/mod.rs | wc -l` reproduced the + 421 from the CI log. The fix was to shrink this branch's contribution to the + shared file: the clock seam moved into a sibling `config/clock.rs` (the + grouping `which.rs` documents), taking the merged file to 387 lines. The + durable lesson is to measure the merge tree, not the branch, whenever a + touched file sits near a whole-file threshold. + ## Decision log - **D1 — Port shape follows the `EnvReader` precedent verbatim.** @@ -1909,42 +1937,62 @@ Recorded during planning; extend during implementation. conformant to this plan. This is the one conformance exception against an otherwise-complete plan, and it stands as an exception. - *Measured scope*, reported by GitHub for #696 against base `3348cc0a` and - reproduced locally with `git diff --numstat`: - - | Head | Files | Additions | Deletions | Net | Net excl. plan | - | ---------- | ----- | --------- | --------- | ----- | -------------- | - | `02caf3ee` | 23 | 3,127 | 133 | 2,994 | 708 | - | `e401f4d7` | 23 | 3,172 | 133 | 3,039 | 708 | - - Both limbs fire at either head: 23 > 20 files, and 2,994 > 600 net — with 708 - net even if this living exec plan (2,286 or 2,331 lines, by head) is excluded - entirely. The non-plan remainder is **708 net across 22 files** at both - tabulated heads, where it moved from 696 at the `configure_stdlib` extraction - (`e1568a1b`, +12 net of real code), and plan-maintenance commits leave it - untouched; 708 is therefore the figure to quote when the plan's own growth is - held to one side. The totals rise with each such commit because the plan is - inside the diff. + *Measured scope*, reported by GitHub for #696 and reproduced locally with + `git diff --numstat`. Each row is a head at which the limbs were measured, + against the merge base in force at that time; the pre-rebase rows come from + before this branch was rebased a second time, and every base agrees on the + conclusion: + + | Base / head | Files | Additions | Deletions | Net | Net excl. plan | + | ----------- | ----- | --------- | --------- | ----- | -------------- | + | `3348cc0a` / `02caf3ee` | 23 | 3,127 | 133 | 2,994 | 708 | + | `3348cc0a` / `e401f4d7` | 23 | 3,172 | 133 | 3,039 | 708 | + | `a273fad3` / `f994800c` | 24 | 3,280 | 133 | 3,147 | 716 | + + Both limbs fire at every head: more than 20 files, and net far above 600 — + with 716 net across 23 files even if this living exec plan (2,431 lines) is + excluded entirely. The non-plan remainder moved from 696 to 708 at the + `configure_stdlib` extraction (+12 net of real code) and to 716 with the + clock-seam extraction into `config/clock.rs` (+42 added, with nothing + removed elsewhere because the move relocated code rather than deleting it); + plan-maintenance commits leave it untouched, so the non-plan figure is the + one to quote when the plan's own growth is held to one side. The totals rise + with each such commit because the plan is inside the diff. *When it fired.* The net-lines limb fired on this plan's **own first commit** - (`9042fe66`, the draft exec plan, 1,581 net in one file) — the threshold was - exceeded before any production code existed. The file-count limb fired at - `3fdd8260` (21 files). Neither was noticed at the time, because the tolerance - was written to guard a *code* change and its principal consumer turned out to - be the execution record documenting that change. That is the process lesson: - a scope tolerance that counts every changed file and line will fire on the - plan itself unless the plan's own artefacts are excluded by construction. - - *Attribution of the 3,039 net lines* (so a later reader can audit rather than - take this on trust): exec plan 2,331; tests 163; production `src/` 472 (of - which `clock.rs` 150, `clock_tests.rs` 260, the `tests.rs`/ - `tests_support.rs` split 10 net after a 260-line move); governing docs 62; - the recorded `proptest` regression seed 11. + (the draft exec plan, 1,581 net in one file) — the threshold was exceeded + before any production code existed. The file-count limb was first exceeded + once the implementation commits landed alongside the plan; across the branch + as it now stands, the count crosses 20 at `032684a7` (21 files). Neither was + noticed at the time, because the tolerance was written to guard a *code* + change and its principal consumer turned out to be the execution record + documenting that change. That is the process lesson: a scope tolerance that + counts every changed file and line will fire on the plan itself unless the + plan's own artefacts are excluded by construction. + + *Note on commit identifiers.* The identifiers above name commits as they + stand on the rebased branch. The rebase rewrote every branch commit, so the + original pre-rebase identifiers (`9042fe66`, `3fdd8260`, `02caf3ee`, + `e401f4d7`, `e1568a1b`, `8056a4da`) now exist only as unreachable objects; + the numbers they carried are preserved in the table because they are the + measurements those heads produced. + + *Attribution of the 3,147 net lines at the current head* (so a later reader + can audit rather than take this on trust): exec plan 2,431; production + `src/` 480 net (547 added, 67 removed), which is `clock.rs` 150, + `clock_tests.rs` 260 and `config/clock.rs` 42 as new files, the + `tests.rs`/`tests_support.rs` split 10 net after a 58-line move, and the + 18-line balance from the `mod.rs` files and `register.rs`; tests 163 net + (177 added, 14 removed); governing docs 62 net outside the plan; the recorded + `proptest` regression seed 11. The pre-rebase + attribution of 3,039 was exec plan 2,331, `src/` 472, tests 163, docs 62 and + the seed 11; the shift is the extraction (`config/clock.rs` plus the + re-export) and the plan's own growth. *Assessment against the tolerance's own reasoning.* The tolerance says a substantial overrun "means the design was wrong". That inference does not hold here, and the evidence is the shape of the overrun rather than an appeal - to intent: the production seam is 150 net lines with a 39-line config delta, + to intent: the production seam is 150 net lines with a 40-line config delta, and the excess is dominated by the execution record plus the coverage the plan's own verification requirements mandated (OBL-1 through OBL-7, the six-mutation exercise, the two test layers D7 makes mandatory, and the BDD @@ -1964,6 +2012,31 @@ Recorded during planning; extend during implementation. delivery as within tolerance. Date/Author: 2026-09-19, post-completion, after review. +- **D15 — The clock seam moves to a sibling `config/clock.rs`; the merged file + is the unit of measure.** Decided: extract `StdlibConfig::with_clock` and the + `clock()` accessor from `src/stdlib/config/mod.rs` into + `src/stdlib/config/clock.rs`, exactly as `ambient.rs` and `which.rs` already + group their own configuration surface. Rationale: CI lints the merge tree, + and in that tree the file exceeded Whitaker's 400-line `module_max_lines` cap + at 421 lines while each parent passed alone (389 branch, 383 main). The + failure is real but not a defect in the seam: the merge is purely additive + (351 base + 38 branch + 32 main), and `src/` was byte-identical to the last + green head apart from this plan. Options considered: (a) leave it, since the + file passes on the branch and the breach is an artefact of main's growth — + rejected, because the pull request is what must merge and CI will keep + blocking it; (b) trim doc comments to fit — rejected, as it trades + documentation the repository measures (`make doc-coverage`) for a line + budget; (c) split by feature into a sibling module — chosen, because it + follows the precedent already in the directory and the reason `which.rs` + gives for existing ("Grouping them by feature keeps `config/mod.rs` to the + shared configuration surface rather than one module per layer"). The only + changes are a `mod clock;` declaration, a narrowed import, and the moved + block with its doctest intact; the field, its default, the registration + path, and the public signatures are unchanged. Result: the merged file drops + to 387 lines, all three local gates pass at `f994800c`, and CI returns to + green with `mergeStateStatus: CLEAN`. Date/Author: 2026-09-19, + post-completion, in response to the CI failure on `8056a4da`. + ## Outcomes & retrospective Complete. The stdlib `now()` helper reads its instant through an injected @@ -2422,6 +2495,22 @@ To be populated during implementation. Required entries: 2026-09-08) to this escalation (2026-09-19), eight from the file-count breach (`3fdd8260`, 2026-09-11). +17. The merge-tree module-size breach (D15) is reproducible without a scratch + checkout. The failing count, the lint's own arithmetic, and the fix: + + ```plaintext + git fetch origin pull/696/merge:refs/tmp/pr696-merge + git cat-file blob refs/tmp/pr696-merge:src/stdlib/config/mod.rs | wc -l # 421 before the split + git show 3348cc0a:src/stdlib/config/mod.rs | wc -l # 351 at the base + git show origin/main:src/stdlib/config/mod.rs | wc -l # 383 after 5c19b8c8 + git merge-tree --write-tree origin/main HEAD # merged root, no checkout + ``` + + The branch and `main` each sat under the cap; only the sum crossed it, so + the local gate set could not see the failure the merge ref exposed. After + the split the merged file is 387 lines, and `make check-fmt`, `make lint`, + and `make typecheck` were all green at `f994800c` over the committed bytes. + One lesson about evidence discipline, recorded because it cost a re-run: gate logs are named per branch, so a second run over the same branch silently overwrites the first run's transcript. Evidence is only as fresh as the HEAD it From 28683dc48e260f88fa71ae7981e468eafceea896 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 06:01:32 +0200 Subject: [PATCH 26/54] Apply end-of-line table reflow to the plan's D14 table mdtablefix does not wrap lines, so the wrapped rows need re-emitting before its check passes. Content is unchanged. Co-Authored-By: Claude Code --- docs/execplans/7-1-1-clock-provider-seam.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index bbee35e61..2b6bf51a9 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -1943,11 +1943,11 @@ Recorded during planning; extend during implementation. before this branch was rebased a second time, and every base agrees on the conclusion: - | Base / head | Files | Additions | Deletions | Net | Net excl. plan | - | ----------- | ----- | --------- | --------- | ----- | -------------- | - | `3348cc0a` / `02caf3ee` | 23 | 3,127 | 133 | 2,994 | 708 | - | `3348cc0a` / `e401f4d7` | 23 | 3,172 | 133 | 3,039 | 708 | - | `a273fad3` / `f994800c` | 24 | 3,280 | 133 | 3,147 | 716 | + | Base / head | Files | Additions | Deletions | Net | Net excl. plan | + | ----------------------- | ----- | --------- | --------- | ----- | -------------- | + | `3348cc0a` / `02caf3ee` | 23 | 3,127 | 133 | 2,994 | 708 | + | `3348cc0a` / `e401f4d7` | 23 | 3,172 | 133 | 3,039 | 708 | + | `a273fad3` / `f994800c` | 24 | 3,280 | 133 | 3,147 | 716 | Both limbs fire at every head: more than 20 files, and net far above 600 — with 716 net across 23 files even if this living exec plan (2,431 lines) is From 283b8e6bc974092eabd990fc81c42355721f92c7 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 06:06:52 +0200 Subject: [PATCH 27/54] Re-quote D14's totals at the head that carries the record The re-anchoring commit is itself a plan commit, so it moved the figures it had just written: 24 files, +3,369 / -133, 3,236 net, and a 2,520-line plan. Refresh the table's third row to `25960909` and update the two prose figures that name the plan's line count. Add a closing sentence saying each row is a snapshot, so a later reader re-measures rather than re-quotes. Co-Authored-By: Claude Code --- docs/execplans/7-1-1-clock-provider-seam.md | 22 +++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index 2b6bf51a9..110ee382a 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -1947,10 +1947,10 @@ Recorded during planning; extend during implementation. | ----------------------- | ----- | --------- | --------- | ----- | -------------- | | `3348cc0a` / `02caf3ee` | 23 | 3,127 | 133 | 2,994 | 708 | | `3348cc0a` / `e401f4d7` | 23 | 3,172 | 133 | 3,039 | 708 | - | `a273fad3` / `f994800c` | 24 | 3,280 | 133 | 3,147 | 716 | + | `a273fad3` / `25960909` | 24 | 3,369 | 133 | 3,236 | 716 | Both limbs fire at every head: more than 20 files, and net far above 600 — - with 716 net across 23 files even if this living exec plan (2,431 lines) is + with 716 net across 23 files even if this living exec plan (2,520 lines) is excluded entirely. The non-plan remainder moved from 696 to 708 at the `configure_stdlib` extraction (+12 net of real code) and to 716 with the clock-seam extraction into `config/clock.rs` (+42 added, with nothing @@ -1977,17 +1977,19 @@ Recorded during planning; extend during implementation. the numbers they carried are preserved in the table because they are the measurements those heads produced. - *Attribution of the 3,147 net lines at the current head* (so a later reader - can audit rather than take this on trust): exec plan 2,431; production + *Attribution of the 3,236 net lines at the current head* (so a later reader + can audit rather than take this on trust): exec plan 2,520; production `src/` 480 net (547 added, 67 removed), which is `clock.rs` 150, `clock_tests.rs` 260 and `config/clock.rs` 42 as new files, the `tests.rs`/`tests_support.rs` split 10 net after a 58-line move, and the - 18-line balance from the `mod.rs` files and `register.rs`; tests 163 net - (177 added, 14 removed); governing docs 62 net outside the plan; the recorded - `proptest` regression seed 11. The pre-rebase - attribution of 3,039 was exec plan 2,331, `src/` 472, tests 163, docs 62 and - the seed 11; the shift is the extraction (`config/clock.rs` plus the - re-export) and the plan's own growth. + 18-line balance from the `mod.rs` files and `register.rs`; tests 163 net (177 + added, 14 removed); governing docs 62 net outside the plan; the recorded + `proptest` regression seed 11. The pre-rebase attribution of 3,039 was exec + plan 2,331, `src/` 472, tests 163, docs 62 and the seed 11; the shift is the + extraction (`config/clock.rs` plus the re-export) and the plan's own growth. + Every row above is a snapshot: each plan-maintenance commit raises the total + by its own length, so a later reader should re-measure rather than re-quote + these figures. *Assessment against the tolerance's own reasoning.* The tolerance says a substantial overrun "means the design was wrong". That inference does not From 84e36ec0a732b64e7a4c804f38dd3b4cc51eeda1 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 06:09:12 +0200 Subject: [PATCH 28/54] Qualify the plan's note on unreachable commit identifiers The six pre-rebase SHAs still resolve in this checkout because the reflog names them, so "exist only as unreachable objects" overstates their disappearance. State it precisely: no branch reaches them, a pruning gc would drop them, and the figures rather than the identifiers are what a later reader can rely on. Co-Authored-By: Claude Code --- docs/execplans/7-1-1-clock-provider-seam.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index 110ee382a..45f8521f9 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -1973,9 +1973,11 @@ Recorded during planning; extend during implementation. *Note on commit identifiers.* The identifiers above name commits as they stand on the rebased branch. The rebase rewrote every branch commit, so the original pre-rebase identifiers (`9042fe66`, `3fdd8260`, `02caf3ee`, - `e401f4d7`, `e1568a1b`, `8056a4da`) now exist only as unreachable objects; + `e401f4d7`, `e1568a1b`, `8056a4da`) are no longer reachable from any branch; the numbers they carried are preserved in the table because they are the - measurements those heads produced. + measurements those heads produced. They still resolve in this checkout + because the reflog names them, but a pruning `git gc` would drop them, so + they are not a citation a later reader can rely on — only the figures are. *Attribution of the 3,236 net lines at the current head* (so a later reader can audit rather than take this on trust): exec plan 2,520; production From e9b0c05988dd323d8ff45bfd34244d7c63dc89cb Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 06:18:58 +0200 Subject: [PATCH 29/54] Rewrap the plan to mdtablefix's CI flag set `make check-fmt` runs mdtablefix with `--wrap --renumber --breaks --ellipsis --fences`, but the earlier in-place pass over this plan used the flagless default, so headings and prose edits landed wrapped to a different width than CI enforces. The next push turned `build-test` and `Windows / lint-windows` red with: docs/execplans/7-1-1-clock-provider-seam.md +41 -42 1 file would be reformatted, 141 files left unchanged. Re-emit the whole file with the CI flag set. Only line wrapping changes; `mdtablefix --check` with the CI flags now reports every file unchanged. The durable lesson (added to the plan separately if it recurs) is to reproduce the gate's own invocation rather than a bare `--check FILE`. Co-Authored-By: Claude Code --- docs/execplans/7-1-1-clock-provider-seam.md | 83 ++++++++++----------- 1 file changed, 41 insertions(+), 42 deletions(-) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index 45f8521f9..6a365c6d4 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -1456,9 +1456,9 @@ above, which are disposable. the maintainer on 2026-09-19 and recorded as this plan's one conformance exception in D14. The delivery is not scope-conformant to the plan. - [x] Post-completion: rebased onto `origin/main` (`a273fad3`) and the clock - seam extracted from `src/stdlib/config/mod.rs` into a sibling `config/clock.rs`, - clearing a `module_max_lines` failure that only the merge tree exhibited — - the episode is D15 and `Artefacts and notes` entry 17. + seam extracted from `src/stdlib/config/mod.rs` into a sibling + `config/clock.rs`, clearing a `module_max_lines` failure that only the merge + tree exhibited — the episode is D15 and `Artefacts and notes` entry 17. ## Surprises & discoveries @@ -1715,26 +1715,26 @@ Recorded during planning; extend during implementation. - Observation: CI lints the pull request's *merge tree*, so a lint breach can exist that neither parent exhibits and that no local run over the branch can reproduce. Evidence: pushing `8056a4da` turned CI red in both `build-test` - (step "Lint") and `Windows / lint-windows` with `error: Module config spans - 421 lines, exceeding the allowed 400.` at `src/stdlib/mod.rs:12:5`, under - `-D module-max-lines` implied by `-D warnings`. The branch's own - `src/` was byte-identical to the last green head (`e401f4d7` apart from the - exec plan), so the lint target was the GitHub merge ref: - `src/stdlib/config/mod.rs` measures 351 lines at the base `3348cc0a`, 383 on - `origin/main` after `5c19b8c8`, 389 on the branch, and **421 in - `refs/pull/696/merge`** — each parent passes the 400-line cap alone and only - the combination exceeds it, because Whitaker's `module_max_lines` counts the - whole file behind a file-backed module and the merge is purely additive - (351 + 32 + 38 = 421). Impact: "green locally, red in CI" was not a - flake and not a stale-runner artefact; it is structural. `git merge-tree - --write-tree origin/main HEAD` prints the merged tree's root as an object ID, - so the exact lint input is obtainable without a scratch checkout — - `git cat-file blob :src/stdlib/config/mod.rs | wc -l` reproduced the - 421 from the CI log. The fix was to shrink this branch's contribution to the - shared file: the clock seam moved into a sibling `config/clock.rs` (the - grouping `which.rs` documents), taking the merged file to 387 lines. The - durable lesson is to measure the merge tree, not the branch, whenever a - touched file sits near a whole-file threshold. + (step "Lint") and `Windows / lint-windows` with + `error: Module config spans 421 lines, exceeding the allowed 400.` at + `src/stdlib/mod.rs:12:5`, under `-D module-max-lines` implied by + `-D warnings`. The branch's own `src/` was byte-identical to the last green + head (`e401f4d7` apart from the exec plan), so the lint target was the GitHub + merge ref: `src/stdlib/config/mod.rs` measures 351 lines at the base + `3348cc0a`, 383 on `origin/main` after `5c19b8c8`, 389 on the branch, and + **421 in `refs/pull/696/merge`** — each parent passes the 400-line cap alone + and only the combination exceeds it, because Whitaker's `module_max_lines` + counts the whole file behind a file-backed module and the merge is purely + additive (351 + 32 + 38 = 421). Impact: "green locally, red in CI" was not a + flake and not a stale-runner artefact; it is structural. + `git merge-tree --write-tree origin/main HEAD` prints the merged tree's root + as an object ID, so the exact lint input is obtainable without a scratch + checkout — `git cat-file blob :src/stdlib/config/mod.rs | wc -l` + reproduced the 421 from the CI log. The fix was to shrink this branch's + contribution to the shared file: the clock seam moved into a sibling + `config/clock.rs` (the grouping `which.rs` documents), taking the merged file + to 387 lines. The durable lesson is to measure the merge tree, not the + branch, whenever a touched file sits near a whole-file threshold. ## Decision log @@ -1953,8 +1953,8 @@ Recorded during planning; extend during implementation. with 716 net across 23 files even if this living exec plan (2,520 lines) is excluded entirely. The non-plan remainder moved from 696 to 708 at the `configure_stdlib` extraction (+12 net of real code) and to 716 with the - clock-seam extraction into `config/clock.rs` (+42 added, with nothing - removed elsewhere because the move relocated code rather than deleting it); + clock-seam extraction into `config/clock.rs` (+42 added, with nothing removed + elsewhere because the move relocated code rather than deleting it); plan-maintenance commits leave it untouched, so the non-plan figure is the one to quote when the plan's own growth is held to one side. The totals rise with each such commit because the plan is inside the diff. @@ -1980,18 +1980,17 @@ Recorded during planning; extend during implementation. they are not a citation a later reader can rely on — only the figures are. *Attribution of the 3,236 net lines at the current head* (so a later reader - can audit rather than take this on trust): exec plan 2,520; production - `src/` 480 net (547 added, 67 removed), which is `clock.rs` 150, - `clock_tests.rs` 260 and `config/clock.rs` 42 as new files, the - `tests.rs`/`tests_support.rs` split 10 net after a 58-line move, and the - 18-line balance from the `mod.rs` files and `register.rs`; tests 163 net (177 - added, 14 removed); governing docs 62 net outside the plan; the recorded - `proptest` regression seed 11. The pre-rebase attribution of 3,039 was exec - plan 2,331, `src/` 472, tests 163, docs 62 and the seed 11; the shift is the - extraction (`config/clock.rs` plus the re-export) and the plan's own growth. - Every row above is a snapshot: each plan-maintenance commit raises the total - by its own length, so a later reader should re-measure rather than re-quote - these figures. + can audit rather than take this on trust): exec plan 2,520; production `src/` + 480 net (547 added, 67 removed), which is `clock.rs` 150, `clock_tests.rs` + 260 and `config/clock.rs` 42 as new files, the `tests.rs`/`tests_support.rs` + split 10 net after a 58-line move, and the 18-line balance from the `mod.rs` + files and `register.rs`; tests 163 net (177 added, 14 removed); governing + docs 62 net outside the plan; the recorded `proptest` regression seed 11. The + pre-rebase attribution of 3,039 was exec plan 2,331, `src/` 472, tests 163, + docs 62 and the seed 11; the shift is the extraction (`config/clock.rs` plus + the re-export) and the plan's own growth. Every row above is a snapshot: each + plan-maintenance commit raises the total by its own length, so a later reader + should re-measure rather than re-quote these figures. *Assessment against the tolerance's own reasoning.* The tolerance says a substantial overrun "means the design was wrong". That inference does not @@ -2035,11 +2034,11 @@ Recorded during planning; extend during implementation. gives for existing ("Grouping them by feature keeps `config/mod.rs` to the shared configuration surface rather than one module per layer"). The only changes are a `mod clock;` declaration, a narrowed import, and the moved - block with its doctest intact; the field, its default, the registration - path, and the public signatures are unchanged. Result: the merged file drops - to 387 lines, all three local gates pass at `f994800c`, and CI returns to - green with `mergeStateStatus: CLEAN`. Date/Author: 2026-09-19, - post-completion, in response to the CI failure on `8056a4da`. + block with its doctest intact; the field, its default, the registration path, + and the public signatures are unchanged. Result: the merged file drops to 387 + lines, all three local gates pass at `f994800c`, and CI returns to green with + `mergeStateStatus: CLEAN`. Date/Author: 2026-09-19, post-completion, in + response to the CI failure on `8056a4da`. ## Outcomes & retrospective From 69385f5b617cc9cb12b3fe041f684635861876ae Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 06:26:38 +0200 Subject: [PATCH 30/54] Record the mdtablefix flag-set mistake as artefact entry 18 The rewrap failure was worth keeping: `mdtablefix --in-place ` without the Makefile's flag set rewraps to a different column, and a bare `mdtablefix --check ` then agrees with itself and disagrees with the gate. Record the tell, the CI output, and the fix (copy the invocation out of Makefile:314) alongside the other post-completion episodes. Co-Authored-By: Claude Code --- docs/execplans/7-1-1-clock-provider-seam.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index 6a365c6d4..d77d242bf 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -2514,6 +2514,26 @@ To be populated during implementation. Required entries: the split the merged file is 387 lines, and `make check-fmt`, `make lint`, and `make typecheck` were all green at `f994800c` over the committed bytes. +18. A gate must be reproduced with the gate's *own* invocation. While rewriting + this plan, `mdtablefix --in-place ` was run without the flag set the + Makefile passes, and the result was pushed; `make check-fmt` then failed in + both `build-test` and `Windows / lint-windows` with + + ```plaintext + docs/execplans/7-1-1-clock-provider-seam.md +41 -42 + 1 file would be reformatted, 141 files left unchanged. + ``` + + `mdtablefix`'s wrap width depends on `--wrap` being present, so the bare + form rewraps to a different column than CI enforces, and a local + `mdtablefix --check ` — also bare — reports the file as already + canonical when the CI invocation does not. The tell was that `--check + --git --include-untracked --wrap --renumber --breaks --ellipsis --fences` + disagreed with the plain `--check ` over the same bytes; the fix is + to copy the command out of `Makefile:314` rather than reconstruct it from + memory. This is the same class of error as the stale-evidence lesson + recorded below: a green reading that was never the reading the gate takes. + One lesson about evidence discipline, recorded because it cost a re-run: gate logs are named per branch, so a second run over the same branch silently overwrites the first run's transcript. Evidence is only as fresh as the HEAD it From 5180475c3486fad4b875370221f93673d988e1f0 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 06:42:20 +0200 Subject: [PATCH 31/54] Bring the plan's Progress checklist up to the current head Record the D14 re-anchoring and the mdtablefix flag-set fix as done, and mark the pending documentation warning as in progress with the precedent it follows. Adds the third checkbox the plan's own "update frequently" requirement asks for. Co-Authored-By: Claude Code --- docs/execplans/7-1-1-clock-provider-seam.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index d77d242bf..36c354fe2 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -1459,6 +1459,14 @@ above, which are disposable. seam extracted from `src/stdlib/config/mod.rs` into a sibling `config/clock.rs`, clearing a `module_max_lines` failure that only the merge tree exhibited — the episode is D15 and `Artefacts and notes` entry 17. +- [x] Post-completion: D14's figures re-anchored to the rebased branch, and a + second CI failure (`make check-fmt`, from running `mdtablefix` without the + Makefile's flag set) fixed and recorded as entry 18. Both jobs back to green + at `de0a9ed9` with `mergeStateStatus: CLEAN`. +- [ ] Post-completion, in progress: the "User-Facing Documentation" warning is + being actioned — a users' guide section for `with_clock` and a matching + row/section in the v0.1.0 migration guide, following the #578/#666/#669 + precedent that an additive public Rust API gets both. ## Surprises & discoveries From a8b2137580afa983be1a439238ea2aa3fa171489 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 07:18:27 +0200 Subject: [PATCH 32/54] Document the clock seam in the users' guide and migration guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "User-Facing Documentation" warning was correct: `with_clock` is new public API and the guides did not mention it. Precedent says an additive public Rust API gets both artefacts — #578, #666 and #669 all added a users-guide section and a v0.1.0 migration-guide entry. The env-seam commit (#501) is not counter-evidence: it predates the migration guide. Users' guide: a "Inject the clock for deterministic tests" section under "Use Jinja safely", beside the sibling env-reader section it mirrors. It names `with_clock`, `fixed_clock`, `system_clock` and `ClockInstant`, records that the provider is consulted per call, that readings are normalized to UTC, and that manifest-query registration still refuses `now()`. Its Rust fence carries the `guide-clock-snippet` marker. Migration guide: an at-a-glance row and a short section modelled on "Configure file reading limits". Tests: register `guide-clock-snippet` in `EXPECTED_EXAMPLE_IDS`, without which the registry contract test fails, and pin the snippet to the entry points it documents, as the env-reader snippet already is. The snippet is copied from the doctest on `with_clock`, so it cannot drift from the API it advertises without the doctest failing too. Co-Authored-By: Claude Code --- docs/users-guide.md | 45 +++++++++++++++++++++++++++ docs/v0-1-0-migration-guide.md | 20 ++++++++++++ tests/documentation_examples_tests.rs | 21 +++++++++++++ 3 files changed, 86 insertions(+) diff --git a/docs/users-guide.md b/docs/users-guide.md index 5902dbe97..8d8deeeda 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -978,6 +978,51 @@ are ignored, matching the existing accessible reporter contract; applications can observe them through the bounded timing sink telemetry emitted by their configured metrics and tracing backends. +### Inject the clock for deterministic tests + +`now()` does not read the host clock directly. It reads through an injectable +`ClockProvider` seam held by `StdlibConfig`, so tests and other callers can pin +the instant instead of racing a real clock. The default remains the ambient +host clock, so existing templates and manifests are unaffected. + +- `StdlibConfig::with_clock` accepts a `ClockProvider`, replacing the wall-clock + source that `now()` reads. +- `fixed_clock(instant)` builds a provider that always reports `instant`. +- `system_clock()` builds the host-backed provider that the default + configuration uses. +- `ClockInstant` re-exports the provider's timestamp type, so a caller can name + that type without adding its own `time` dependency. + +The provider is consulted on every `now()` call rather than captured at +registration, so a provider that yields a different instant on each call is +observed by successive `now()` evaluations. Readings are normalized to UTC, and +an explicit `offset=` argument re-expresses the same instant in the requested +offset rather than changing it. + +Manifest-query registration still refuses `now()`, so the seam does not widen +what a manifest query may evaluate. + + + +```rust +use minijinja::Environment; +use netsuke::stdlib::{self, StdlibConfig, fixed_clock}; +use time::macros::datetime; + +let instant = datetime!(2026-06-08 12:00:00 UTC); +let config = StdlibConfig::from_current_dir() + .expect("open workspace") + .with_clock(fixed_clock(instant)); + +let mut env = Environment::new(); +stdlib::register_with_config(&mut env, config).expect("register stdlib"); +let rendered = env.render_str("{{ now() }}", ()).expect("render"); +assert_eq!(rendered, "2026-06-08T12:00:00Z"); +``` + +This snippet mirrors the executable doctest on `with_clock` in the API +documentation, rather than the YAML-only examples elsewhere in this guide. + ### Use the canonical build graph `BuildGraph` stores each logical build edge once. Every output alias, explicit diff --git a/docs/v0-1-0-migration-guide.md b/docs/v0-1-0-migration-guide.md index 032f6c135..aaa2ba146 100644 --- a/docs/v0-1-0-migration-guide.md +++ b/docs/v0-1-0-migration-guide.md @@ -108,6 +108,7 @@ impact | Fetch redirects | Every redirect destination is now evaluated against the network policy before it is requested, so a redirect can no longer reach a host, scheme, or address the policy refuses. Chains stop after five redirects, a repeated destination is refused as a loop, and URL credentials are removed when the origin changes. | [Users' guide](users-guide.md#network-fetch-policy) and [ADR-023](adr-023-revalidate-fetch-redirects.md) | | Manifest environment access | New optional exact-name `env()` allow and block lists. Existing manifests retain default-allow behaviour when neither list is configured; an active allowlist enables default-deny and a block always wins. | [Users' guide](users-guide.md#control-manifest-environment-access) | | File-reading filters | The `contents`, `linecount`, `hash`, and `digest` filters now read under one 8 MiB default byte budget; a symlink final component is rejected unless `follow_symlinks=true` opts in, while FIFOs and devices are rejected outright, and per-call `max_bytes` can only narrow the budget. | [Configure file reading limits](users-guide.md#configure-file-reading-limits) | +| Clock provider | The stdlib `now()` helper reads through an injectable `ClockProvider`; `StdlibConfig::with_clock` pins the instant for tests, while the default remains the ambient system clock. | [Users' guide](users-guide.md#inject-the-clock-for-deterministic-tests) | ## Bound manifest evaluation @@ -516,6 +517,25 @@ for other byte sequences; `hash` and `digest` stay byte-oriented and accept any content. See the [users' guide](users-guide.md#configure-file-reading-limits) for the full policy and its diagnostics. +## Inject the clock for deterministic tests + +The stdlib `now()` helper reads the current instant through an injectable +provider rather than the host clock directly. `StdlibConfig::with_clock` +accepts a `ClockProvider`, and `fixed_clock(instant)` builds one that always +reports a given instant, so a render calling `now()` can be asserted exactly +instead of racing a real clock. + +The addition is opt-in. The default remains the ambient host clock, which +`system_clock()` names explicitly, so existing templates and manifests are +unaffected, and the provider is consulted on every `now()` call rather than +captured at registration. Readings are normalized to UTC before the helper's +`offset=` argument re-expresses the same instant in the requested offset. +Manifest-query evaluation still refuses `now()`, so the seam does not widen +what a query may call. + +See the [users' guide](users-guide.md#inject-the-clock-for-deterministic-tests) +for the worked example. + ## Diagnostics Ninja subprocess spans and warn events carry two bounded fields, diff --git a/tests/documentation_examples_tests.rs b/tests/documentation_examples_tests.rs index ec04b2d9b..fdfa918ae 100644 --- a/tests/documentation_examples_tests.rs +++ b/tests/documentation_examples_tests.rs @@ -20,6 +20,7 @@ const EXPECTED_EXAMPLE_IDS: &[&str] = &[ "guide-binstall-install", "guide-boolean-string-interpolation", "guide-cli-usage", + "guide-clock-snippet", "guide-command-available-manifest", "guide-command-list", "guide-complete-manifest", @@ -88,6 +89,26 @@ fn env_reader_snippet_mirrors_the_doctest() -> Result<()> { } Ok(()) } +/// The guide's clock snippet must stay in step with the API it mirrors. +/// +/// The snippet is Rust and is executed as the doctest on `with_clock`; this +/// pins the guide copy to the same entry points so the two cannot drift +/// silently. +#[test] +fn clock_snippet_mirrors_the_doctest() -> Result<()> { + let example = documented_example("guide-clock-snippet")?; + ensure!( + example.language == "rust", + "the clock snippet should be a Rust fence" + ); + for needle in ["with_clock", "fixed_clock", "StdlibConfig"] { + ensure!( + example.body.contains(needle), + "the clock snippet should mention {needle}" + ); + } + Ok(()) +} /// The guide's Ninja-request snippet must name the API it documents. /// /// The snippet is the only place the guide constructs the request bundles, so From b303c1ac976b694b6ed786bf4d65aeaf3865a331 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 07:33:54 +0200 Subject: [PATCH 33/54] Flip the documentation bullet to done in the plan's Progress checklist The 'User-Facing Documentation' warning is now actioned: the users' guide gained a `with_clock` section whose Rust fence is registered and pinned to the doctest, and the migration guide gained a matching row and section. Record the landing commit and the two tests observed passing on it. --- docs/execplans/7-1-1-clock-provider-seam.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index 36c354fe2..c1c5de9f1 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -1463,10 +1463,15 @@ above, which are disposable. second CI failure (`make check-fmt`, from running `mdtablefix` without the Makefile's flag set) fixed and recorded as entry 18. Both jobs back to green at `de0a9ed9` with `mergeStateStatus: CLEAN`. -- [ ] Post-completion, in progress: the "User-Facing Documentation" warning is - being actioned — a users' guide section for `with_clock` and a matching - row/section in the v0.1.0 migration guide, following the #578/#666/#669 - precedent that an additive public Rust API gets both. +- [x] Post-completion: the "User-Facing Documentation" warning actioned — a + users' guide section for `with_clock` and a matching row and section in the + v0.1.0 migration guide, following the #578/#666/#669 precedent that an + additive public Rust API gets both. The guide fence is registered in + `EXPECTED_EXAMPLE_IDS` as `guide-clock-snippet` and pinned to the doctest's + entry points by `clock_snippet_mirrors_the_doctest`, so the prose copy cannot + drift from the executable one. Landed as `c422727d`; all five gates green on + that commit, with the anchor doctest on `StdlibConfig::with_clock` and the + new pinning test both observed passing in the `make test` log. ## Surprises & discoveries From 2c2a16b602e53312e7d14d82557af2d9e76580a0 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 07:48:21 +0200 Subject: [PATCH 34/54] Re-anchor D14's figures to the head that carries the record The documentation fix moved the branch on by three files and 86 net lines, and plan-maintenance commits keep adding their own length to every total. Record the head that carries the record beside its figures, state plainly that the non-plan remainder is the durable quantity and each total a lower bound, and update the retrospective and artefact 16 to match. --- docs/execplans/7-1-1-clock-provider-seam.md | 69 ++++++++++++--------- 1 file changed, 41 insertions(+), 28 deletions(-) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index c1c5de9f1..489647c15 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -1961,13 +1961,17 @@ Recorded during planning; extend during implementation. | `3348cc0a` / `02caf3ee` | 23 | 3,127 | 133 | 2,994 | 708 | | `3348cc0a` / `e401f4d7` | 23 | 3,172 | 133 | 3,039 | 708 | | `a273fad3` / `25960909` | 24 | 3,369 | 133 | 3,236 | 716 | + | `a273fad3` / `ed674fc9` | 27 | 3,491 | 133 | 3,358 | 802 | Both limbs fire at every head: more than 20 files, and net far above 600 — - with 716 net across 23 files even if this living exec plan (2,520 lines) is - excluded entirely. The non-plan remainder moved from 696 to 708 at the - `configure_stdlib` extraction (+12 net of real code) and to 716 with the - clock-seam extraction into `config/clock.rs` (+42 added, with nothing removed - elsewhere because the move relocated code rather than deleting it); + with 802 net across 26 files besides the plan even if this living exec plan + (2,556 lines) is excluded entirely. The non-plan remainder moved from 696 to + 708 at the `configure_stdlib` extraction (+12 net of real code), to 716 with + the clock-seam extraction into `config/clock.rs` (+42 added, with nothing + removed elsewhere because the move relocated code rather than deleting it), + and to 802 with the documentation fix the first CodeRabbit pass asked for + (+86: the users'-guide section, the migration-guide row and section, and the + registry entry plus pinning test that hold the guide fence to the doctest); plan-maintenance commits leave it untouched, so the non-plan figure is the one to quote when the plan's own growth is held to one side. The totals rise with each such commit because the plan is inside the diff. @@ -1992,18 +1996,25 @@ Recorded during planning; extend during implementation. because the reflog names them, but a pruning `git gc` would drop them, so they are not a citation a later reader can rely on — only the figures are. - *Attribution of the 3,236 net lines at the current head* (so a later reader - can audit rather than take this on trust): exec plan 2,520; production `src/` - 480 net (547 added, 67 removed), which is `clock.rs` 150, `clock_tests.rs` - 260 and `config/clock.rs` 42 as new files, the `tests.rs`/`tests_support.rs` - split 10 net after a 58-line move, and the 18-line balance from the `mod.rs` - files and `register.rs`; tests 163 net (177 added, 14 removed); governing - docs 62 net outside the plan; the recorded `proptest` regression seed 11. The + *Attribution of the 3,358 net lines measured at `ed674fc9`* (so a later + reader can audit rather than take this on trust): exec plan 2,556; production + `src/` 480 net (547 added, 67 removed), which is `clock.rs` 150, + `clock_tests.rs` 260 and `config/clock.rs` 42 as new files, the `tests.rs`/ + `tests_support.rs` split 10 net after a 58-line move, and the 18-line balance + from the `mod.rs` files and `register.rs`; tests 184 net (198 added, 14 + removed); governing docs 127 net outside the plan, of which the guide and + migration-guide pairs are 65; the recorded `proptest` regression seed 11. The pre-rebase attribution of 3,039 was exec plan 2,331, `src/` 472, tests 163, docs 62 and the seed 11; the shift is the extraction (`config/clock.rs` plus - the re-export) and the plan's own growth. Every row above is a snapshot: each - plan-maintenance commit raises the total by its own length, so a later reader - should re-measure rather than re-quote these figures. + the re-export), the required documentation, and the plan's own growth. Every + row above is a snapshot, and the snapshots are not interchangeable: each + plan-maintenance commit raises the total by its own length while leaving the + non-plan remainder fixed, so the remainder is the durable quantity and every + total is a lower bound that grows as this section is maintained. A later + reader should re-measure rather than re-quote; the conclusion does not move, + because the non-plan remainder alone exceeds the 600-line limb by more than a + third (802 against 600) with the file count likewise over (26 against 20) + even excluding this plan entirely. *Assessment against the tolerance's own reasoning.* The tolerance says a substantial overrun "means the design was wrong". That inference does not @@ -2093,13 +2104,14 @@ Upstream changes and deviations, all recorded above or in `Surprises & discoveries`: - **Conformance exception — scope (D14).** The pull request exceeds both limbs - of this plan's scope tolerance: 23 changed files against a limit of 20, and - 3,039 net added lines against a limit of 600 (708 net even with this exec - plan's 2,331 lines excluded). Escalated and accepted by the maintainer on - 2026-09-19; the full attribution and assessment are in D14. This delivery is - therefore **not fully conformant to this plan**: every other tolerance held, - but this one did not, and it is recorded as an accepted deviation rather than - a waiver. + of this plan's scope tolerance. It did so from its earliest measured head, + and it still does at the head that carries this record: 27 changed files + against a limit of 20, and 3,358 net added lines against a limit of 600 (802 + net even with this exec plan's 2,556 lines excluded). Escalated and accepted + by the maintainer on 2026-09-19; the full attribution and assessment are in + D14. This delivery is therefore **not fully conformant to this plan**: every + other tolerance held, but this one did not, and it is recorded as an accepted + deviation rather than a waiver. - The ADR-008 addendum is dated 2026-09-11 rather than the plan's `2026-09-08`, matching the file's convention of dating each entry when it is written. @@ -2494,12 +2506,13 @@ To be populated during implementation. Required entries: git diff --numstat 3348cc0a..HEAD ``` - The figure that survives the most favourable reading is **708 net across 22 - files**, with this plan's own lines excluded — the net-lines limb fired at - this plan's first commit (`9042fe66`, 1,581 net), the file-count limb at - `3fdd8260` (21 files); neither was caught then, because the tolerance was - written to bound a code change and its largest consumer proved to be the - record of that change. + The figure that survives the most favourable reading was **708 net across + 22 files** when the escalation was raised, and is **802 net across 26 + files** at the head carrying this record, with this plan's own lines + excluded in both — the net-lines limb fired at this plan's first commit + (`9042fe66`, 1,581 net), the file-count limb at `3fdd8260` (21 files); + neither was caught then, because the tolerance was written to bound a code + change and its largest consumer proved to be the record of that change. The escalation was raised by the maintainer rather than by the gates, which is the durable point: nothing in `make check-fmt`, `make test`, From f0e984aec619666e6116859f67237193f347353f Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 07:55:40 +0200 Subject: [PATCH 35/54] Add the row for the head that carries the record to D14's table A plan-only commit moves the total by exactly its own length and the non-plan remainder not at all, so state that invariant directly rather than letting a later reader infer it from two rows, and give the current head its own row. --- docs/execplans/7-1-1-clock-provider-seam.md | 66 +++++++++++---------- 1 file changed, 36 insertions(+), 30 deletions(-) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index 489647c15..b319f71fc 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -1962,19 +1962,25 @@ Recorded during planning; extend during implementation. | `3348cc0a` / `e401f4d7` | 23 | 3,172 | 133 | 3,039 | 708 | | `a273fad3` / `25960909` | 24 | 3,369 | 133 | 3,236 | 716 | | `a273fad3` / `ed674fc9` | 27 | 3,491 | 133 | 3,358 | 802 | + | `a273fad3` / `fc39b269` | 27 | 3,504 | 133 | 3,371 | 802 | + + Each row is measured *at* the head it names, not after it, and a later + plan-only commit moves the total by exactly its own length and the remainder + not at all — which is why the remainder, and not the total, is the figure a + later reader should compare against the tolerance. Both limbs fire at every head: more than 20 files, and net far above 600 — with 802 net across 26 files besides the plan even if this living exec plan - (2,556 lines) is excluded entirely. The non-plan remainder moved from 696 to - 708 at the `configure_stdlib` extraction (+12 net of real code), to 716 with - the clock-seam extraction into `config/clock.rs` (+42 added, with nothing - removed elsewhere because the move relocated code rather than deleting it), - and to 802 with the documentation fix the first CodeRabbit pass asked for - (+86: the users'-guide section, the migration-guide row and section, and the - registry entry plus pinning test that hold the guide fence to the doctest); - plan-maintenance commits leave it untouched, so the non-plan figure is the - one to quote when the plan's own growth is held to one side. The totals rise - with each such commit because the plan is inside the diff. + (2,569 lines at `fc39b269`) is excluded entirely. The non-plan remainder + moved from 696 to 708 at the `configure_stdlib` extraction (+12 net of real + code), to 716 with the clock-seam extraction into `config/clock.rs` (+42 + added, with nothing removed elsewhere because the move relocated code rather + than deleting it), and to 802 with the documentation fix the first CodeRabbit + pass asked for (+86: the users'-guide section, the migration-guide row and + section, and the registry entry plus pinning test that hold the guide fence + to the doctest); plan-maintenance commits leave it untouched, so the non-plan + figure is the one to quote when the plan's own growth is held to one side. + The totals rise with each such commit because the plan is inside the diff. *When it fired.* The net-lines limb fired on this plan's **own first commit** (the draft exec plan, 1,581 net in one file) — the threshold was exceeded @@ -1997,24 +2003,24 @@ Recorded during planning; extend during implementation. they are not a citation a later reader can rely on — only the figures are. *Attribution of the 3,358 net lines measured at `ed674fc9`* (so a later - reader can audit rather than take this on trust): exec plan 2,556; production - `src/` 480 net (547 added, 67 removed), which is `clock.rs` 150, - `clock_tests.rs` 260 and `config/clock.rs` 42 as new files, the `tests.rs`/ - `tests_support.rs` split 10 net after a 58-line move, and the 18-line balance - from the `mod.rs` files and `register.rs`; tests 184 net (198 added, 14 - removed); governing docs 127 net outside the plan, of which the guide and - migration-guide pairs are 65; the recorded `proptest` regression seed 11. The - pre-rebase attribution of 3,039 was exec plan 2,331, `src/` 472, tests 163, - docs 62 and the seed 11; the shift is the extraction (`config/clock.rs` plus - the re-export), the required documentation, and the plan's own growth. Every - row above is a snapshot, and the snapshots are not interchangeable: each - plan-maintenance commit raises the total by its own length while leaving the - non-plan remainder fixed, so the remainder is the durable quantity and every - total is a lower bound that grows as this section is maintained. A later - reader should re-measure rather than re-quote; the conclusion does not move, - because the non-plan remainder alone exceeds the 600-line limb by more than a - third (802 against 600) with the file count likewise over (26 against 20) - even excluding this plan entirely. + reader can audit rather than take this on trust): exec plan 2,556 at that + head, 2,569 at `fc39b269`; production `src/` 480 net (547 added, 67 removed), + which is `clock.rs` 150, `clock_tests.rs` 260 and `config/clock.rs` 42 as new + files, the `tests.rs`/ `tests_support.rs` split 10 net after a 58-line move, + and the 18-line balance from the `mod.rs` files and `register.rs`; tests 184 + net (198 added, 14 removed); governing docs 127 net outside the plan, of + which the guide and migration-guide pairs are 65; the recorded `proptest` + regression seed 11. The pre-rebase attribution of 3,039 was exec plan 2,331, + `src/` 472, tests 163, docs 62 and the seed 11; the shift is the extraction + (`config/clock.rs` plus the re-export), the required documentation, and the + plan's own growth. Every row above is a snapshot, and the snapshots are not + interchangeable: each plan-maintenance commit raises the total by its own + length while leaving the non-plan remainder fixed, so the remainder is the + durable quantity and every total is a lower bound that grows as this section + is maintained. A later reader should re-measure rather than re-quote; the + conclusion does not move, because the non-plan remainder alone exceeds the + 600-line limb by more than a third (802 against 600) with the file count + likewise over (26 against 20) even excluding this plan entirely. *Assessment against the tolerance's own reasoning.* The tolerance says a substantial overrun "means the design was wrong". That inference does not @@ -2106,8 +2112,8 @@ Upstream changes and deviations, all recorded above or in - **Conformance exception — scope (D14).** The pull request exceeds both limbs of this plan's scope tolerance. It did so from its earliest measured head, and it still does at the head that carries this record: 27 changed files - against a limit of 20, and 3,358 net added lines against a limit of 600 (802 - net even with this exec plan's 2,556 lines excluded). Escalated and accepted + against a limit of 20, and 3,371 net added lines against a limit of 600 (802 + net even with this exec plan's 2,569 lines excluded). Escalated and accepted by the maintainer on 2026-09-19; the full attribution and assessment are in D14. This delivery is therefore **not fully conformant to this plan**: every other tolerance held, but this one did not, and it is recorded as an accepted From 009004e731bd3d83b89e4ceaa889851dd1287139 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 08:01:32 +0200 Subject: [PATCH 36/54] Quote only durable figures in the scope-escalation retrospective MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recording a total changes it: every plan-only commit that maintains this record adds its own length to the diff, so a head-specific total is stale the moment it is written. Replace the quoted total with the two quantities that do not move — changed-file count and the non-plan remainder — and make the attribution section attribute that remainder rather than a total. --- docs/execplans/7-1-1-clock-provider-seam.md | 84 +++++++++++---------- 1 file changed, 45 insertions(+), 39 deletions(-) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index b319f71fc..9d1ceb428 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -1967,20 +1967,23 @@ Recorded during planning; extend during implementation. Each row is measured *at* the head it names, not after it, and a later plan-only commit moves the total by exactly its own length and the remainder not at all — which is why the remainder, and not the total, is the figure a - later reader should compare against the tolerance. + later reader should compare against the tolerance. The final row is the head + that carries this record; heads after it add only the length of whatever + maintenance produced them, so read the two rightmost columns and treat the + totals as historical. Both limbs fire at every head: more than 20 files, and net far above 600 — - with 802 net across 26 files besides the plan even if this living exec plan - (2,569 lines at `fc39b269`) is excluded entirely. The non-plan remainder - moved from 696 to 708 at the `configure_stdlib` extraction (+12 net of real - code), to 716 with the clock-seam extraction into `config/clock.rs` (+42 - added, with nothing removed elsewhere because the move relocated code rather - than deleting it), and to 802 with the documentation fix the first CodeRabbit - pass asked for (+86: the users'-guide section, the migration-guide row and - section, and the registry entry plus pinning test that hold the guide fence - to the doctest); plan-maintenance commits leave it untouched, so the non-plan - figure is the one to quote when the plan's own growth is held to one side. - The totals rise with each such commit because the plan is inside the diff. + with 802 net across 26 files besides the plan, and the non-plan remainder + unchanged whether or not the plan is excluded — moved from 696 to 708 at the + `configure_stdlib` extraction (+12 net of real code), to 716 with the + clock-seam extraction into `config/clock.rs` (+42 added, with nothing removed + elsewhere because the move relocated code rather than deleting it), and to + 802 with the documentation fix the first CodeRabbit pass asked for (+86: the + users'-guide section, the migration-guide row and section, and the registry + entry plus pinning test that hold the guide fence to the doctest); + plan-maintenance commits leave it untouched, so the non-plan figure is the + one to quote when the plan's own growth is held to one side. The totals rise + with each such commit because the plan is inside the diff. *When it fired.* The net-lines limb fired on this plan's **own first commit** (the draft exec plan, 1,581 net in one file) — the threshold was exceeded @@ -2002,25 +2005,25 @@ Recorded during planning; extend during implementation. because the reflog names them, but a pruning `git gc` would drop them, so they are not a citation a later reader can rely on — only the figures are. - *Attribution of the 3,358 net lines measured at `ed674fc9`* (so a later - reader can audit rather than take this on trust): exec plan 2,556 at that - head, 2,569 at `fc39b269`; production `src/` 480 net (547 added, 67 removed), - which is `clock.rs` 150, `clock_tests.rs` 260 and `config/clock.rs` 42 as new - files, the `tests.rs`/ `tests_support.rs` split 10 net after a 58-line move, - and the 18-line balance from the `mod.rs` files and `register.rs`; tests 184 - net (198 added, 14 removed); governing docs 127 net outside the plan, of - which the guide and migration-guide pairs are 65; the recorded `proptest` - regression seed 11. The pre-rebase attribution of 3,039 was exec plan 2,331, - `src/` 472, tests 163, docs 62 and the seed 11; the shift is the extraction - (`config/clock.rs` plus the re-export), the required documentation, and the - plan's own growth. Every row above is a snapshot, and the snapshots are not - interchangeable: each plan-maintenance commit raises the total by its own - length while leaving the non-plan remainder fixed, so the remainder is the - durable quantity and every total is a lower bound that grows as this section - is maintained. A later reader should re-measure rather than re-quote; the - conclusion does not move, because the non-plan remainder alone exceeds the - 600-line limb by more than a third (802 against 600) with the file count - likewise over (26 against 20) even excluding this plan entirely. + *Attribution of the 802-line non-plan remainder* (so a later reader can audit + rather than take this on trust): production `src/` 480 net (547 added, 67 + removed), which is `clock.rs` 150, `clock_tests.rs` 260 and `config/clock.rs` + 42 as new files, the `tests.rs`/ `tests_support.rs` split 10 net after a + 58-line move, and the 18-line balance from the `mod.rs` files and + `register.rs`; tests 184 net (198 added, 14 removed); governing docs 127 net + outside the plan, of which the guide and migration-guide pairs are 65; the + recorded `proptest` regression seed 11. The pre-rebase attribution of the + 708-line remainder was `src/` 472, tests 163, docs 62 and the seed 11; the + shift is the extraction (`config/clock.rs` plus the re-export) and the + required documentation. Every row above is a snapshot taken at the head it + names, and the snapshots are not interchangeable: each plan-maintenance + commit raises the total by its own length while leaving this remainder fixed, + so the remainder is the durable quantity and every total is a lower bound + that grows as this section is maintained. A later reader should re-measure + rather than re-quote; the conclusion does not move, because the non-plan + remainder alone exceeds the 600-line limb by more than a third (802 against + 600) with the file count likewise over (26 against 20) even excluding this + plan entirely. *Assessment against the tolerance's own reasoning.* The tolerance says a substantial overrun "means the design was wrong". That inference does not @@ -2110,14 +2113,17 @@ Upstream changes and deviations, all recorded above or in `Surprises & discoveries`: - **Conformance exception — scope (D14).** The pull request exceeds both limbs - of this plan's scope tolerance. It did so from its earliest measured head, - and it still does at the head that carries this record: 27 changed files - against a limit of 20, and 3,371 net added lines against a limit of 600 (802 - net even with this exec plan's 2,569 lines excluded). Escalated and accepted - by the maintainer on 2026-09-19; the full attribution and assessment are in - D14. This delivery is therefore **not fully conformant to this plan**: every - other tolerance held, but this one did not, and it is recorded as an accepted - deviation rather than a waiver. + of this plan's scope tolerance, and did so from its earliest measured head. + The figures quoted here are the durable ones: 27 changed files against a + limit of 20, and a non-plan remainder of 802 net added lines against a limit + of 600, with the plan itself excluded entirely. The *total* is not quoted, + because it is not durable — every plan-only commit that maintains this record + adds its own length to it, so a head-specific total is stale the moment it is + written. D14 tabulates the per-head totals with the head each was measured + at. Escalated and accepted by the maintainer on 2026-09-19; the full + attribution and assessment are in D14. This delivery is therefore **not fully + conformant to this plan**: every other tolerance held, but this one did not, + and it is recorded as an accepted deviation rather than a waiver. - The ADR-008 addendum is dated 2026-09-11 rather than the plan's `2026-09-08`, matching the file's convention of dating each entry when it is written. From b140beb5e12f09f4786ea84c01da761944ff2fab Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 08:20:50 +0200 Subject: [PATCH 37/54] Record the verified CI state of the final head in the Progress checklist Every substantive check passes and the pull request is approved; the only red check is the non-required CodeScene review of the base branch. Name the four checks the ruleset actually requires, so the distinction is on the record rather than left to be re-derived. --- docs/execplans/7-1-1-clock-provider-seam.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index 9d1ceb428..cb0a53757 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -1472,6 +1472,17 @@ above, which are disposable. drift from the executable one. Landed as `c422727d`; all five gates green on that commit, with the anchor doctest on `StdlibConfig::with_clock` and the new pinning test both observed passing in the `make test` log. +- [x] Post-completion: D14 re-anchored once more after the documentation fix, + and its self-reference removed — recording a total changes it, so the + retrospective now quotes only the two durable figures (changed-file count and + the non-plan remainder) and the table carries head-labelled totals. CI + verified on the resulting head: every substantive check `SUCCESS`, including + `Windows / lint-windows` and `Windows / build-test-windows`, with + `mergeStateStatus: UNSTABLE` and `reviewDecision: APPROVED`. The single + failure is the non-required `CodeScene Code Health Review (main)`, which + measures the base branch rather than this pull request: the ruleset + `main-required-checks` requires only `build-test`, `kani-smoke`, + `netsukefile` and `release / metadata`, and all four pass. ## Surprises & discoveries From 96c88dcf81a7ca2f2d7fe8f87c05c591307c1a4c Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 15:03:43 +0200 Subject: [PATCH 38/54] Dispose of the second review round's verified findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three edits, each verified against current source before repair. `tests/documentation_examples_tests.rs`: the CodeScene duplication thread on `clock_snippet_mirrors_the_doctest` was valid — that test, `env_reader_snippet_mirrors_the_doctest` and `ninja_request_snippet_names_both_request_types` were three copies of one shape. Extract `assert_snippet_names`, which carries the shared "Rust fence, then each needle" contract and takes the example id, the label used in failure messages, and the needles. Behaviour is unchanged: all 32 tests in the target still pass. `docs/adr-008-environment-seam-taxonomy.md`: the `with_clock` injection-point link still named `src/stdlib/config/mod.rs`. That is stale because the clock seam was split into its own `src/stdlib/config/clock.rs` earlier in this branch, so the ADR pointed at a file that no longer holds the function. `docs/execplans/7-1-1-clock-provider-seam.md`: three corrections to the living document — remove the duplicated `use std::{fmt, sync::Arc};` and `time::OffsetDateTime` import lines from the implementation sketch; record in D4 that the first review upheld a "User-Facing Documentation" warning against the decision's rationale, since `with_clock` is a public Rust API and not only a manifest-author concern; and correct the recovery instructions, which described `git reset --hard` and `git checkout --` as though they were scoped to the mutation when both discard uncommitted work more broadly. Gates: `make check-fmt` (including mdtablefix under the Makefile's own flag set), `make markdownlint`, `make lint`, and the `documentation_examples_tests` target. --- docs/adr-008-environment-seam-taxonomy.md | 6 +- docs/execplans/7-1-1-clock-provider-seam.md | 37 ++++++--- tests/documentation_examples_tests.rs | 83 ++++++++++----------- 3 files changed, 69 insertions(+), 57 deletions(-) diff --git a/docs/adr-008-environment-seam-taxonomy.md b/docs/adr-008-environment-seam-taxonomy.md index 584bee0ea..88e6e583c 100644 --- a/docs/adr-008-environment-seam-taxonomy.md +++ b/docs/adr-008-environment-seam-taxonomy.md @@ -190,9 +190,9 @@ resolution entirely rather than setting the variable for a child to read. (manifest `env()` Jinja helper) - Clock seam: [`src/stdlib/time/clock.rs`](../src/stdlib/time/clock.rs) (`ClockProvider`, `system_clock`, `fixed_clock`); `StdlibConfig::with_clock` - in [`src/stdlib/config/mod.rs`](../src/stdlib/config/mod.rs) is the injection - point, and [`src/stdlib/register.rs`](../src/stdlib/register.rs) captures the - provider when it registers `now()` + in [`src/stdlib/config/clock.rs`](../src/stdlib/config/clock.rs) is the + injection point, and [`src/stdlib/register.rs`](../src/stdlib/register.rs) + captures the provider when it registers `now()` - Child-environment composition: [`test_support/src/netsuke.rs`](../test_support/src/netsuke.rs) (`run_netsuke_in_with_env`) and `tests/bdd/steps/manifest_command_helpers.rs` diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index cb0a53757..178b8cab2 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -881,10 +881,6 @@ In a new file `src/stdlib/time/clock.rs`: use std::{fmt, sync::Arc}; -use time::OffsetDateTime; - -use std::{fmt, sync::Arc}; - use time::{OffsetDateTime, UtcOffset}; /// Re-exported so an external caller can name a provider's return type @@ -1419,15 +1415,21 @@ Every step is re-runnable. The `make` gates are read-only with respect to tracked files except `make fmt`, which rewrites formatting deterministically — running it twice changes nothing the second time. -No step is destructive. There is no migration, no persisted format, and no data -to back up. Recovery at any point is `git revert` of the milestone commit or -`git reset --hard` to the previous milestone; each milestone is a coherent, -gate-passing plateau. +No step destroys anything that cannot be rebuilt from Git. There is no +migration, no persisted format, and no data to back up. Recovery at any point is +`git revert` of the milestone commit or `git reset --hard` to the previous +milestone; each milestone is a coherent, gate-passing plateau. Both commands +discard uncommitted tracked edits, which is why the exercise below is confined +to a scratch commit. The mutation exercise in `Validation and acceptance` is the only step that -deliberately breaks the tree. Perform it on a scratch commit and discard it with -`git reset --hard`; never push it. If interrupted mid-exercise, `git status` -will show the mutation, and `git checkout -- ` restores it. +deliberately breaks the tree, and it does edit tracked source. Run it on a +scratch commit that you are willing to lose — never on a head you intend to +keep, and never push it. Discard it with `git reset --hard`, which drops all +uncommitted changes in the worktree, not just the mutation. If interrupted +mid-exercise, `git status` will show the mutation, and `git checkout -- ` +restores that one file. If any uncommitted work you care about is present, +commit it first, or run the exercise in a separate scratch worktree. Working-tree cleanliness: this plan adds no build artefacts, no temporary files inside the repository, and no `/tmp` output other than the gate logs named @@ -1821,6 +1823,19 @@ Recorded during planning; extend during implementation. exposes `given.clock.now` to authors, not now. Date/Author: 2026-09-08, planning. + **Superseded, post-completion.** The first CodeRabbit review raised this as a + "User-Facing Documentation" warning, and it was upheld: `with_clock` is an + additive *public Rust API*, and the repository's convention (#578, #666, + #669) is that such an addition gets both a users' guide section and a + migration-guide entry. The rationale above reasons only about *manifest + authors*, which is why it under-read the requirement — the audience for + `with_clock` is Rust callers embedding the stdlib, not template writers. The + guide now carries a section and a worked fence, and the migration guide a row + and section; the fence is registered as `guide-clock-snippet` and pinned to + the `with_clock` doctest by `clock_snippet_mirrors_the_doctest`. The sentence + at line 1178 is untouched and still true. Date/Author: 2026-09-19, + post-completion, after review. + - **D5 — Keep the existing tolerance-based ambient assertions.** Decided: `now_defaults_to_utc` (3-second tolerance) and the BDD step `assert_stdlib_output_is_utc_timestamp` (5-second tolerance) are retained diff --git a/tests/documentation_examples_tests.rs b/tests/documentation_examples_tests.rs index fdfa918ae..957bb3797 100644 --- a/tests/documentation_examples_tests.rs +++ b/tests/documentation_examples_tests.rs @@ -69,46 +69,50 @@ const EXPECTED_EXAMPLE_IDS: &[&str] = &[ "stdlib-yaml-syntax-manifest", ]; -/// The guide's env-reader snippet must stay in step with the API it mirrors. +/// Assert that a guide snippet is a Rust fence naming the given API entry points. /// -/// The snippet is Rust and is executed as the doctest on `from_str_with_env`; -/// this pins the guide copy to the same entry points so the two cannot drift -/// silently. -#[test] -fn env_reader_snippet_mirrors_the_doctest() -> Result<()> { - let example = documented_example("guide-env-reader-snippet")?; +/// Several guide snippets duplicate an executable doctest, and pinning the copy +/// to the same identifiers is what keeps the two from drifting silently. The +/// label names the snippet so a failure reads in the guide's own vocabulary. +fn assert_snippet_names(example_id: &str, label: &str, needles: &[&str]) -> Result<()> { + let example = documented_example(example_id)?; ensure!( example.language == "rust", - "the env-reader snippet should be a Rust fence" + "the {label} snippet should be a Rust fence" ); - for needle in ["from_str_with_env", "EnvReader", "env('PROFILE')"] { + for needle in needles { ensure!( example.body.contains(needle), - "the env-reader snippet should mention {needle}" + "the {label} snippet should mention {needle}" ); } Ok(()) } + +/// The guide's env-reader snippet must stay in step with the API it mirrors. +/// +/// The snippet is Rust and is executed as the doctest on `from_str_with_env`. +#[test] +fn env_reader_snippet_mirrors_the_doctest() -> Result<()> { + assert_snippet_names( + "guide-env-reader-snippet", + "env-reader", + &["from_str_with_env", "EnvReader", "env('PROFILE')"], + ) +} + /// The guide's clock snippet must stay in step with the API it mirrors. /// -/// The snippet is Rust and is executed as the doctest on `with_clock`; this -/// pins the guide copy to the same entry points so the two cannot drift -/// silently. +/// The snippet is Rust and is executed as the doctest on `with_clock`. #[test] fn clock_snippet_mirrors_the_doctest() -> Result<()> { - let example = documented_example("guide-clock-snippet")?; - ensure!( - example.language == "rust", - "the clock snippet should be a Rust fence" - ); - for needle in ["with_clock", "fixed_clock", "StdlibConfig"] { - ensure!( - example.body.contains(needle), - "the clock snippet should mention {needle}" - ); - } - Ok(()) + assert_snippet_names( + "guide-clock-snippet", + "clock", + &["with_clock", "fixed_clock", "StdlibConfig"], + ) } + /// The guide's Ninja-request snippet must name the API it documents. /// /// The snippet is the only place the guide constructs the request bundles, so @@ -116,24 +120,17 @@ fn clock_snippet_mirrors_the_doctest() -> Result<()> { /// crate no longer exports. #[test] fn ninja_request_snippet_names_both_request_types() -> Result<()> { - let example = documented_example("guide-ninja-request-snippet")?; - ensure!( - example.language == "rust", - "the Ninja-request snippet should be a Rust fence" - ); - for needle in [ - "NinjaBuildRequest", - "NinjaToolRequest", - "run_ninja_with", - "run_ninja_tool_with", - "CommandEnv::inherit", - ] { - ensure!( - example.body.contains(needle), - "the Ninja-request snippet should mention {needle}" - ); - } - Ok(()) + assert_snippet_names( + "guide-ninja-request-snippet", + "Ninja-request", + &[ + "NinjaBuildRequest", + "NinjaToolRequest", + "run_ninja_with", + "run_ninja_tool_with", + "CommandEnv::inherit", + ], + ) } fn assert_default_edges_exist(ninja: &str, context: &str) -> Result<()> { From eadbf9f47ec4cd03e15f0ad4b38bdd050727b432 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 15:28:16 +0200 Subject: [PATCH 39/54] Record why the Windows gate fails, and that it is not this branch's `Windows / build-test-windows` fails at this head, which looks alarming next to a green branch history. It is an estate-wide breakage: the same job fails on `origin/main` (`ef7ed760`) and on every unrelated branch tested, and it last passed anywhere at 09:38Z on `36e03c7f`. The failing case is `stdlib::network::redirect::error_tests::protocol_failures_are_classified_from_a_live_response`, which lives on `origin/main` in `src/stdlib/network/redirect_error_tests.rs` (via #667). This branch's diff against its merge base `a273fad3` adds zero bytes under `src/stdlib/network/`. The error is a Windows socket race (`WSAECONNABORTED`, `os error 10053`) against the test's own loopback listener. Also recorded: the job share is not a required check. The ruleset `main-required-checks` requires only `build-test`, `kani-smoke`, `netsukefile` and `release / metadata`; `build-test` is a different job and passes at this head, so the required set is green. Gates: `make check-fmt` and `make markdownlint`. --- docs/execplans/7-1-1-clock-provider-seam.md | 29 +++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index 178b8cab2..90f53c82c 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -1485,6 +1485,35 @@ above, which are disposable. measures the base branch rather than this pull request: the ruleset `main-required-checks` requires only `build-test`, `kani-smoke`, `netsukefile` and `release / metadata`, and all four pass. +- [x] Post-completion: the second review round's verified findings disposed of. + The CodeScene duplication thread was valid — three near-identical "snippet + mirrors the doctest" tests in `tests/documentation_examples_tests.rs` — and + was repaired by extracting `assert_snippet_names`, which keeps every needle + assertion and only removes the repetition; all 32 tests in that target still + pass. The ADR-008 link naming `src/stdlib/config/mod.rs` as the `with_clock` + injection point was genuinely stale, a consequence of this branch's own split + of the seam into `config/clock.rs`. A third set of edits corrected the plan + itself: duplicated import lines in the implementation sketch, D4's rationale + (now recording that the first review upheld a User-Facing Documentation + warning because `with_clock` is a public *Rust* API and not only a + manifest-author concern), and the recovery instructions, which had described + `git reset --hard` and `git checkout --` as though either were scoped to the + mutation when both discard uncommitted work more broadly. Landed as + `dbd90956`. +- [x] Post-completion: `Windows / build-test-windows` fails at `dbd90956` **and + on `main`**, so this is an estate-wide breakage and not a defect of the clock + seam. Evidence: the failing case is + `stdlib::network::redirect::error_tests::protocol_failures_are_classified_from_a_live_response`, + which lives at `src/stdlib/network/redirect_error_tests.rs:79` on + `origin/main` (from #667); this branch's diff against its merge base + `a273fad3` adds **zero bytes** to `src/stdlib/network/`. The error is + `WSAECONNABORTED` reported as `os error 10053` on a connection the host + software aborted: a Windows socket race against the test's own loopback + listener, which writes a malformed status line and races the client's read. + The job passed at 09:38Z on `36e03c7f` and has failed on every branch since + 10:09Z, `main` (`ef7ed760`, 10:24Z) included. The required `build-test` check + is a *different* job and passes, as do `kani-smoke`, `netsukefile` and + `release / metadata`. ## Surprises & discoveries From 7b309556896509834b69a0890cab2850686eaa06 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 15:29:41 +0200 Subject: [PATCH 40/54] Record the pending review request against the head it targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review asked for in this round is a posted *request*, not a completed review, and the plan should not read as though the two are the same. The entry names the queued head, the queue id, the quoted delay, and the fact that a comment body does not pin a revision — so whichever commit CodeRabbit inspects has to be read back afterwards. Gates: `make check-fmt` and `make markdownlint`. --- docs/execplans/7-1-1-clock-provider-seam.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index 90f53c82c..5fec2c1de 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -1514,6 +1514,13 @@ above, which are disposable. 10:09Z, `main` (`ef7ed760`, 10:24Z) included. The required `build-test` check is a *different* job and passes, as do `kani-smoke`, `netsukefile` and `release / metadata`. +- [ ] Post-completion: a full review requested on `bb1d7f9b` and **pending** as + queue entry `cfcb03b5`, quoted at roughly 22 minutes. The branch is under + CodeRabbit's auto-pause ("active development"), and the walkthrough states + that `@coderabbitai review` triggers a single review despite the pause, which + is the command queued. This is a posted *request*, not a completed review: + the commit CodeRabbit actually inspects must be read back afterwards, because + the comment body does not pin a revision. ## Surprises & discoveries From c5fb3479bb82adcffef5b2734c9d0be7719f81c6 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 16:12:40 +0200 Subject: [PATCH 41/54] Re-target the branch onto the current origin/main tip The first rebase landed at 07248a34; origin/main has since advanced to 79545e12 (the 19-update GitHub-actions group bump). Replay the 40 branch-owned commits above the new merge base with the same explicit options used before. The re-target is byte-for-byte identity-preserving: every commit is `=` under range-diff, there are no merges and no conflicts, and the net diff is unchanged at 27 files / 3603 insertions / 161 deletions. Cargo.toml and Cargo.lock are byte-identical to origin/main, so no regeneration was needed. The new commit is a workflows-and-contract-test delta with zero file overlap with this branch, and `make test` runs only Rust targets, so it lies outside this branch's gate surface. It does not move the Windows job's line anchors, so the recorded Windows diagnosis still holds. Weave again did not participate: the driver is registered globally but merge attributes are `unspecified` for every branch-owned path. --- docs/execplans/7-1-1-clock-provider-seam.md | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index 5fec2c1de..fa561fcaf 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -1521,6 +1521,28 @@ above, which are disposable. is the command queued. This is a posted *request*, not a completed review: the commit CodeRabbit actually inspects must be read back afterwards, because the comment body does not pin a revision. +- [x] Post-completion: re-targeted onto the current `origin/main` tip + (`79545e12`, the 19-update GitHub-actions group bump) after the first rebase + had landed at `07248a34`. The boundary for this re-target is the merge base + `07248a34`, not the earlier `a273fad3`: `merge-base 8de3c963 origin/main` is + `a273fad3`, so the earlier boundary was correct *for the commit series that + existed then*, while `a273fad3..99f242de` counts 44 because four inherited + `main` commits (`024a9014` among them) had entered the range since. The + branch-owned series above `07248a34` is 40 commits. Replay was byte-for-byte + identity-preserving — 40 commits, every one `=`, no merges, no conflicts, net + diff unchanged at 27 files / 3603 insertions / 161 deletions, + `git diff --check` clean. `Cargo.toml` and `Cargo.lock` are byte-identical to + `origin/main`, so there was nothing to regenerate. The new commit is a + workflows-and-contract-test delta with **zero** file overlap with this + branch's 27 files, and `make test` is `test-nextest doctest` — Rust only — so + the delta lies outside this branch's gate surface. It does not touch the + Windows job's line anchors: `build-test-windows` is still at + `.github/workflows/ci-windows.yml:217` and `runs-on: windows-latest` at 223, + so the Windows diagnosis recorded above stands. Weave again did not + participate: the driver is registered globally but `git check-attr merge` + reports `unspecified` for every branch-owned path, the global attributes file + is unset, and there is no tracked `.gitattributes` and no + `$GIT_DIR/info/attributes`. ## Surprises & discoveries From 9e9098fbe84a65f31d755187eb89097996021541 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 16:31:57 +0200 Subject: [PATCH 42/54] Dispose of the review findings on the clock seam CodeRabbit reviewed 8de3c963 and raised one inline finding plus an Observability pre-merge warning. Both are valid against the current source; neither was present when the branch was last reviewed. The inline finding is a real wording defect in both guides. They said the provider is read "rather than captured at registration", but `register_functions` moves a `WallClock` into the registered closure, so the clock *is* captured while the *instant* is not. The crate's own docs, ADR-008 and the technical design all state this correctly, which leaves the two guides as the outliers. Both passages now say that registration captures the adapter and each call invokes it afresh. The Observability warning asks for a bounded debug field at the clock-registration decision point. PR #669 added exactly such an event for the file filters one line below, so the gap is genuine and the shape is settled. `WallClock::source_label` now names the provenance from a closed set, and `register_with_config` records `clock_source` alongside "registered stdlib time helpers". `Debug` reuses the same accessor, so the label has one definition. `registration_reports_the_clock_source` covers both provenances and asserts the event never carries a provider's instant. It lives in the integration suite because `register_with_config` is public and the event is emitted there, not in `time::register_functions`. Removing the label mutation turns the injected case red, so the assertion has teeth. --- docs/users-guide.md | 10 ++--- docs/v0-1-0-migration-guide.md | 10 ++--- src/stdlib/register.rs | 7 +++- src/stdlib/time/clock.rs | 18 +++++---- tests/std_filter_tests/time_functions.rs | 50 ++++++++++++++++++++++++ 5 files changed, 76 insertions(+), 19 deletions(-) diff --git a/docs/users-guide.md b/docs/users-guide.md index 8d8deeeda..2817c26dd 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -993,11 +993,11 @@ host clock, so existing templates and manifests are unaffected. - `ClockInstant` re-exports the provider's timestamp type, so a caller can name that type without adding its own `time` dependency. -The provider is consulted on every `now()` call rather than captured at -registration, so a provider that yields a different instant on each call is -observed by successive `now()` evaluations. Readings are normalized to UTC, and -an explicit `offset=` argument re-expresses the same instant in the requested -offset rather than changing it. +Registration captures the adapter that holds the provider, and each `now()` +call invokes it to read the instant afresh, so a provider that yields a +different instant on each call is observed by successive `now()` evaluations. +Readings are normalized to UTC, and an explicit `offset=` argument re-expresses +the same instant in the requested offset rather than changing it. Manifest-query registration still refuses `now()`, so the seam does not widen what a manifest query may evaluate. diff --git a/docs/v0-1-0-migration-guide.md b/docs/v0-1-0-migration-guide.md index aaa2ba146..471f0f7bd 100644 --- a/docs/v0-1-0-migration-guide.md +++ b/docs/v0-1-0-migration-guide.md @@ -527,11 +527,11 @@ instead of racing a real clock. The addition is opt-in. The default remains the ambient host clock, which `system_clock()` names explicitly, so existing templates and manifests are -unaffected, and the provider is consulted on every `now()` call rather than -captured at registration. Readings are normalized to UTC before the helper's -`offset=` argument re-expresses the same instant in the requested offset. -Manifest-query evaluation still refuses `now()`, so the seam does not widen -what a query may call. +unaffected. Registration captures the adapter that holds the provider, and each +`now()` call invokes it to read the instant afresh. Readings are normalized to +UTC before the helper's `offset=` argument re-expresses the same instant in the +requested offset. Manifest-query evaluation still refuses `now()`, so the seam +does not widen what a query may call. See the [users' guide](users-guide.md#inject-the-clock-for-deterministic-tests) for the worked example. diff --git a/src/stdlib/register.rs b/src/stdlib/register.rs index 58b90c182..f56660556 100644 --- a/src/stdlib/register.rs +++ b/src/stdlib/register.rs @@ -105,7 +105,12 @@ pub fn register_with_config( register_legacy_boolean_formatter(env); let state = StdlibState::default(); register_read_only_helpers(env, &config); - time::register_functions(env, config.clock().clone()); + let clock = config.clock().clone(); + tracing::debug!( + clock_source = clock.source_label(), + "registered stdlib time helpers" + ); + time::register_functions(env, clock); let impure = state.impure_flag(); let (network_config, file_config, command_config) = config.into_components(); network::register_functions(env, Arc::clone(&impure), network_config); diff --git a/src/stdlib/time/clock.rs b/src/stdlib/time/clock.rs index 55b18a8af..21433dab2 100644 --- a/src/stdlib/time/clock.rs +++ b/src/stdlib/time/clock.rs @@ -118,6 +118,15 @@ impl WallClock { pub(crate) const fn is_system(&self) -> bool { self.is_system } + + /// Name the clock's provenance for logs and `Debug` output. + /// + /// The result is drawn from a closed set, so it is safe to record in a + /// telemetry field: registration can report which clock it installed + /// without revealing the provider behind it. + pub(crate) const fn source_label(&self) -> &'static str { + if self.is_system() { "system" } else { "injected" } + } } impl Default for WallClock { @@ -137,14 +146,7 @@ impl Default for WallClock { impl fmt::Debug for WallClock { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("WallClock") - .field( - "source", - &if self.is_system() { - "system" - } else { - "injected" - }, - ) + .field("source", &self.source_label()) .finish_non_exhaustive() } } diff --git a/tests/std_filter_tests/time_functions.rs b/tests/std_filter_tests/time_functions.rs index 003433447..7527938f9 100644 --- a/tests/std_filter_tests/time_functions.rs +++ b/tests/std_filter_tests/time_functions.rs @@ -15,9 +15,11 @@ use anyhow::{Context, Result, ensure}; use netsuke::stdlib::fixed_clock; use rstest::rstest; +use test_support::tracing_capture::with_test_subscriber; use time::{ Duration, OffsetDateTime, UtcOffset, format_description::well_known::Iso8601, macros::datetime, }; +use tracing_subscriber::filter::LevelFilter; use super::support::fallible; @@ -105,3 +107,51 @@ fn now_applies_offset_to_the_configured_clock( ); Ok(()) } + +/// Registration reports which clock it installed, by provenance and nothing +/// else. +/// +/// A wrongly wired clock is otherwise invisible: an injected clock that never +/// reached registration renders exactly as an ambient one would, so a test +/// asserting a pinned instant fails without saying why. The closed set is what +/// makes the label safe to record — it names the clock's source, never the +/// instant that clock would report. +#[rstest] +#[case::system(false, "system")] +#[case::injected(true, "injected")] +fn registration_reports_the_clock_source( + #[case] inject: bool, + #[case] expected: &str, +) -> Result<()> { + let captured = with_test_subscriber(LevelFilter::DEBUG, |events| { + let installed = if inject { + fallible::stdlib_env_with_clock(fixed_clock(datetime!(2026-06-08 12:00:00 UTC))) + } else { + fallible::stdlib_env() + }; + assert!(installed.is_ok(), "registration should succeed: {installed:?}"); + events.snapshot() + }); + + let registration_events: Vec<&String> = captured + .iter() + .filter(|event| event.contains("registered stdlib time helpers")) + .collect(); + ensure!( + registration_events.len() == 1, + "expected one registration event but captured {registration_events:?}" + ); + let event = registration_events + .first() + .copied() + .context("the length check above leaves one event")?; + ensure!( + event.contains(&format!("clock_source=\"{expected}\"")), + "the event should label the {expected} clock: {event}" + ); + ensure!( + !event.contains("2026-06-08"), + "the event must not carry the instant a provider would report: {event}" + ); + Ok(()) +} From 3137e13474b230d82f91ec2bc3bf58f003045699 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 16:37:35 +0200 Subject: [PATCH 43/54] Apply rustfmt to the review-disposition commit `make check-fmt` rejected two spots: the `source_label` if-else on one line, and an over-long `assert!` in the new integration test. Both are formatting only; no behaviour changed. --- src/stdlib/time/clock.rs | 6 +++++- tests/std_filter_tests/time_functions.rs | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/stdlib/time/clock.rs b/src/stdlib/time/clock.rs index 21433dab2..17afa628d 100644 --- a/src/stdlib/time/clock.rs +++ b/src/stdlib/time/clock.rs @@ -125,7 +125,11 @@ impl WallClock { /// telemetry field: registration can report which clock it installed /// without revealing the provider behind it. pub(crate) const fn source_label(&self) -> &'static str { - if self.is_system() { "system" } else { "injected" } + if self.is_system() { + "system" + } else { + "injected" + } } } diff --git a/tests/std_filter_tests/time_functions.rs b/tests/std_filter_tests/time_functions.rs index 7527938f9..c7bedc19f 100644 --- a/tests/std_filter_tests/time_functions.rs +++ b/tests/std_filter_tests/time_functions.rs @@ -129,7 +129,10 @@ fn registration_reports_the_clock_source( } else { fallible::stdlib_env() }; - assert!(installed.is_ok(), "registration should succeed: {installed:?}"); + assert!( + installed.is_ok(), + "registration should succeed: {installed:?}" + ); events.snapshot() }); From bfb6165a42d0935cb04cc257a7bc35f986fd9a34 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 16:46:29 +0200 Subject: [PATCH 44/54] Record the completed review and both findings' dispositions in the plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The queued review is closed as a *completed* review rather than a pending request: its read-back shows CodeRabbit inspected 8de3c963, not the pre-rebase head the queue comment named, and returned CHANGES_REQUESTED with one inline finding and an Observability pre-merge warning. Both findings are disposed of with evidence. The inline wording finding is valid — register_functions moves a WallClock into the registered closure, so the clock is captured while the instant is not — and both guides now say so. The Observability warning is valid and answered with source_label plus the clock_source debug field, covered by a mutation-tested integration case. Adds two evidence entries: check-fmt is two gates behind one name, and the re-target boundary is the current merge base rather than an earlier one. Co-Authored-By: Claude Code --- docs/execplans/7-1-1-clock-provider-seam.md | 78 +++++++++++++++++++-- 1 file changed, 71 insertions(+), 7 deletions(-) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index fa561fcaf..e0203b8ee 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -1514,13 +1514,41 @@ above, which are disposable. 10:09Z, `main` (`ef7ed760`, 10:24Z) included. The required `build-test` check is a *different* job and passes, as do `kani-smoke`, `netsukefile` and `release / metadata`. -- [ ] Post-completion: a full review requested on `bb1d7f9b` and **pending** as - queue entry `cfcb03b5`, quoted at roughly 22 minutes. The branch is under - CodeRabbit's auto-pause ("active development"), and the walkthrough states - that `@coderabbitai review` triggers a single review despite the pause, which - is the command queued. This is a posted *request*, not a completed review: - the commit CodeRabbit actually inspects must be read back afterwards, because - the comment body does not pin a revision. +- [x] Post-completion: the full review requested as queue entry `cfcb03b5` + **completed**. The request was posted against `bb1d7f9b` — a pre-rebase twin + of `b75575b9` — but the read-back shows CodeRabbit inspected `8de3c963`, the + remote head at the time it ran, returning `CHANGES_REQUESTED` at 14:03:48 and + re-editing the walkthrough to re-anchor both its `change_assessment_commit` + and its `final_review_risk_coverage` to `8de3c963`. The read-back is the + point: the queued comment body pins no revision, so only the review object's + own `commit_id` says what was actually inspected. This closes the first of + this branch's two outstanding review surfaces; the dispositions below close + the other. +- [x] Post-completion: both of that review's findings disposed of, each + verified against the current source before any edit. The inline finding — + that both guides wrongly say the provider is read "rather than captured at + registration" — is **valid**: `register_functions` moves a `WallClock` into + the registered closure, so the clock *is* captured while the *instant* is not. + `src/stdlib/time/mod.rs:44-48`, `clock.rs:84`, ADR-008 and the technical + design all state this correctly, leaving the two guides as the outliers; both + now say registration captures the adapter and each call invokes it afresh. + The Observability pre-merge warning — a new one, absent from the first pass, + and not the User-Facing Documentation warning that the previous round + resolved — is also **valid**: it asks for a bounded debug field at the + clock-registration decision point, and PR #669 added exactly such an event + for the file filters one line below. `WallClock::source_label` now names the + provenance from a closed set, `register_with_config` records `clock_source` + alongside `registered stdlib time helpers`, and `Debug` reuses the same + accessor so the label has one definition. + `registration_reports_the_clock_source` covers both provenances and asserts + the event never carries a provider's instant; it lives in the integration + suite because `register_with_config` is public and emits the event there, not + in `time::register_functions`. The first attempt put it in `clock_tests.rs`, + where it could not see the event at all and failed with + `expected one registration event but captured []` — a reminder that the unit + module is the wrong side of the publicity boundary for a public entrypoint's + behaviour. Mutating `source_label` to report `system` for both turns the + injected case red, so the assertion has teeth rather than merely passing. - [x] Post-completion: re-targeted onto the current `origin/main` tip (`79545e12`, the 19-update GitHub-actions group bump) after the first rebase had landed at `07248a34`. The boundary for this re-target is the merge base @@ -2656,6 +2684,42 @@ To be populated during implementation. Required entries: memory. This is the same class of error as the stale-evidence lesson recorded below: a green reading that was never the reading the gate takes. +19. `make check-fmt` is two gates behind one name, and passing the Markdown half + says nothing about the Rust half. The review-disposition commit was + canonicalized with `mdtablefix` and checked with `make markdownlint` and + `make spelling` — all green — and then failed `make check-fmt` anyway: + + ```plaintext + cargo fmt --all -- --check + Diff in .../src/stdlib/time/clock.rs:125: + - if self.is_system() { "system" } else { "injected" } + + if self.is_system() { + + "system" + + } else { + + "injected" + + } + ``` + + Two spots, both handwritten one-liners that `rustfmt` would have split. The + same commit's new integration test hit the identical trap with an over-long + `assert!`. `Makefile:312` runs `cargo fmt --all -- --check` and then + `mdtablefix --check`, so a Rust change needs `cargo fmt --all` in the same + breath as the Markdown canonicalization; running either alone leaves the + other half unverified. The fix is one command, `cargo fmt --all`, and it + cost a full gate cycle to discover — the third re-run this branch has spent + on a check that was available locally and not run. + +20. The re-target boundary is the *current* merge base, not the one an earlier + rebase recorded. `git merge-base 8de3c963 origin/main` is `a273fad3`, which + is why the first rebase used that boundary; by the time of the re-target + `merge-base 99f242de origin/main` had moved to `07248a34`, and + `a273fad3..99f242de` counted 44 commits rather than the 40 the branch owns, + because four inherited `main` commits had entered the range in between. + Counting commits in a range is therefore not a way to size a branch: the + count is only the branch's when the lower bound is the exclusive replay + boundary. Recomputing `merge-base` before each replay, rather than reusing + the previous boundary, is what keeps the two from drifting. + One lesson about evidence discipline, recorded because it cost a re-run: gate logs are named per branch, so a second run over the same branch silently overwrites the first run's transcript. Evidence is only as fresh as the HEAD it From 341aba637141ee14ad91f718ed1d82cc13103082 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 17:08:30 +0200 Subject: [PATCH 45/54] Propagate the registration failure instead of asserting in a Result test The registration event case returned Result while asserting with assert!, which clippy::panic_in_result_fn rejects under -D warnings; make lint aborted at lint-clippy on the std_filter_tests target. The assertion is replaced by a contextual `?`, so a registration failure propagates as the error the signature already promises. The test still fails on the same condition and the closed-set assertion is untouched: mutating source_label to report "system" for both provenances turns case_2_injected red, and the source is restored byte-identically. Co-Authored-By: Claude Code --- tests/std_filter_tests/time_functions.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/tests/std_filter_tests/time_functions.rs b/tests/std_filter_tests/time_functions.rs index c7bedc19f..05692d155 100644 --- a/tests/std_filter_tests/time_functions.rs +++ b/tests/std_filter_tests/time_functions.rs @@ -123,18 +123,15 @@ fn registration_reports_the_clock_source( #[case] inject: bool, #[case] expected: &str, ) -> Result<()> { - let captured = with_test_subscriber(LevelFilter::DEBUG, |events| { + let captured = with_test_subscriber(LevelFilter::DEBUG, |events| -> Result> { let installed = if inject { fallible::stdlib_env_with_clock(fixed_clock(datetime!(2026-06-08 12:00:00 UTC))) } else { fallible::stdlib_env() }; - assert!( - installed.is_ok(), - "registration should succeed: {installed:?}" - ); - events.snapshot() - }); + installed.context("registration should succeed")?; + Ok(events.snapshot()) + })?; let registration_events: Vec<&String> = captured .iter() From 62708c6ca21b00d1621c98b3f17bdd9d01c06c30 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 17:47:20 +0200 Subject: [PATCH 46/54] Record why the local make test timeout is not this branch's defect The -8 gate run failed on one test outside the change surface that timed out at the 300 s per-test allowance. Recorded as artefact entry 21 with the measurement that settles it: raising the ceiling shows the test completing in 292.1 s, of which 291.6 s is its nested cold cargo build, matching the 688.6 s figure the developers' guide already records for that build under contention. Also records that my first explanation -- heavy load -- was refuted by two isolated re-runs that timed out at load 9.3 and 7.0, while its conclusion was right. The mechanism is a cold build behind a shared package-cache lock, which bites at moderate load; the entry keeps the measurement and drops the story. Co-Authored-By: Claude Code --- docs/execplans/7-1-1-clock-provider-seam.md | 41 +++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index e0203b8ee..e2ee0a0fe 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -2727,3 +2727,44 @@ was taken at, and this plan's EP-M3 evidence went stale twice — once when the EP-M4 commits landed, and again when the spelling fix touched `src/stdlib/time/clock.rs`. Re-running is cheap; asserting that a green result still applies is not the same as knowing it. + +1. A local `make test` timeout is not automatically a defect, and the way to + tell the two apart is to measure rather than to repeat the first guess. The + `-8` gate run on `14651fc0` failed on exactly one test: + `harness_compiles_under_a_split_build_dir` timed out at the 300 s per-test + allowance, with 3245 of 3250 passing and neither the other three gates nor + any test in the change surface implicated. My first explanation was + contention, and I stated it with more confidence than the evidence carried: + the test had passed at 157.9 s and 184.4 s in the two preceding runs, other + worktrees were building concurrently, and the load average was 12-24 on a + 6-core host. Two isolated re-runs at load 9.3 and 7.0 both timed out as + well, which falsifies a mechanism that needs *heavy* load — so the guess was + wrong even though the conclusion it reached was right, and repeating it + would have been repeating an unverified claim. + + The useful measurement was to raise the ceiling and read the real figure: + `--config 'profile.default.slow-timeout.terminate-after=20'` ran the test to + completion in **292.1 s**, of which 291.6 s was its nested + `cargo build test_support`. The test is not slow-but-failing; it is + slow-but-passing, sitting close to a 300 s allowance by construction. + `docs/developers-guide.md` already documents this: the test spawns its own + `cargo` into private `CARGO_TARGET_DIR` and `CARGO_BUILD_BUILD_DIR` + tempdirs, so it pays a cold dependency build every run and queues behind the + package-cache lock of every other build on the host, and the guide records a + measurement of **688.6 s** for that same nested build under contention, + against "seconds unloaded". + + Three things make this environmental rather than this branch's: the guide + documents the effect at more than twice the allowance; `build-test` + succeeded in CI on `8de3c963` with this test inside it, on the same commit + whose Linux timeout I had just reproduced locally four times; and the + test's own file and `.config/nextest.toml` are untouched by this branch. + What I had wrong in the first pass was the *reason*, not the disposition: + the mechanism the guide describes is a cold build behind a shared + package-cache lock, which bites at moderate load because a single blocked + nested `cargo` is enough, not only under a load average of 20. + + The lesson worth keeping is the one this plan keeps rediscovering: record + the measurement, not the story about it. "It is flaky under load" is a + hypothesis with a testable mechanism, and here the mechanism as I first + stated it was refuted by the very experiment that supported its conclusion. From a29e066ce72d14fb321a31b8148032fb028081b5 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 18:06:25 +0200 Subject: [PATCH 47/54] Record the tracker for the local make test timeout The `-9` gate run on `25787722` reproduced the single-test signature of `-8`: `harness_compiles_under_a_split_build_dir` timed out at its 300 s allowance with 3245 of 3250 passing, and no other test failed. Issue #732 already describes this mechanism, names this test, and states that the harnesses' repeated compilation "is what puts these tests near the 300 s per-test allowance". Recording it turns "not this branch's defect" from an assertion into a citation, and keeps the plan from re-deriving the diagnosis a third time. Folded into entry 21's body rather than added as a sibling list item: a new marker at that position restarts markdown's ordered list, and mdtablefix's `--renumber` rewrites it to `1.`, which is not canonical. Co-Authored-By: Claude Code --- docs/execplans/7-1-1-clock-provider-seam.md | 23 +++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index e2ee0a0fe..9481f6ba2 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -2768,3 +2768,26 @@ still applies is not the same as knowing it. the measurement, not the story about it. "It is flaky under load" is a hypothesis with a testable mechanism, and here the mechanism as I first stated it was refuted by the very experiment that supported its conclusion. + + The timeout is also already tracked, and the tracker names this very test. + [Issue 732](https://github.com/leynos/netsuke/issues/732), "Nested fixture + builds recompile the workspace on every test run", was opened on 2026-09-18 + and states the consequence directly: the fixture harnesses' inability to + reuse the gate's compilation "is what puts these tests near the 300 s + per-test allowance". It identifies two causes — the nested build takes + default features where `make test-nextest` passes `--all-features`, so the + fingerprints differ and the workspace is compiled twice; and this test + points both `CARGO_TARGET_DIR` and `CARGO_BUILD_BUILD_DIR` at private + tempdirs, so it can never reuse anything at all. The issue carries its own + measurement: 65.8 s for the first nested build after the gate's own build on + an idle 32-core host, against 9.3 s for the same test on a second run. + + That is the right disposition for this branch's local failure — a live, + pre-existing issue that already describes the mechanism, so there is nothing + to file and nothing to fix here. `docs/developers-guide.md` sizes the whole + timeout tier system deliberately and links its own related gap to issue 715, + so raising the allowance is a change to a documented contract with its own + arithmetic, not a fix to be smuggled in beside a clock seam. The branch's + obligation is to show the failure is not its own, which the CI `build-test` + pass on `8de3c963` does: the same test ran inside that job and the job + succeeded. From f70e0fd6d03115710a97b942e2b5c480e9ee9bb0 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 18:15:11 +0200 Subject: [PATCH 48/54] Record the published re-target and the reply heads The re-target push advanced the branch to `f81f2f98` on base `79545e12`, under a lease bound to the previously recorded remote head `8de3c963` so a concurrent rewrite would fail the push rather than be overwritten. Records the gate state at that head and, specifically, why the `lint` re-run mattered: the `panic_in_result_fn` error was only confirmed fixed by running the gate at a head containing the fix, since the earlier `-8` log predates it. Also records that both review replies were posted against this head, and that the CI run the push triggered is not yet claimed as green. Co-Authored-By: Claude Code --- docs/execplans/7-1-1-clock-provider-seam.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index 9481f6ba2..68a10fd29 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -1571,6 +1571,22 @@ above, which are disposable. reports `unspecified` for every branch-owned path, the global attributes file is unset, and there is no tracked `.gitattributes` and no `$GIT_DIR/info/attributes`. +- [x] Post-completion: re-target published. Force-pushed with lease bound to + the previously recorded remote head `8de3c963` + (`--force-with-lease=7-1-1-clock-provider-seam:8de3c963…`), which advanced + the branch to `f81f2f98` on base `79545e12`. The lease was bound to the + read-back value rather than refreshed blindly, so a concurrent rewrite of the + branch would have failed the push instead of being overwritten. Gate state at + that head: `check-fmt`, `markdownlint`, `typecheck` and `lint` all exit 0; + `make test` is 3245/3250 with the single timeout that entry 21 and issue 732 + cover. The `lint` re-run is the one that matters most, because the + `panic_in_result_fn` error recorded at `14651fc0` is only confirmed fixed by + running the gate at a head that contains the fix — the earlier `-8` log + predates it. The push triggered a fresh CI run on `f81f2f98`; its verdict is + not claimed here. Both review replies were then posted on this head: an + inline reply to the guides-lifecycle finding, and a pre-merge reconciliation + for the Observability row, each citing `f81f2f98` and quoting the table row + live at the time of posting rather than from memory. ## Surprises & discoveries From 4e4f78c97e467b6d253b9f6c42226c4baba0fb36 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 18:49:05 +0200 Subject: [PATCH 49/54] Record the CI verdict for the published head MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All four required checks pass on `74822cc2`: `build-test` and `kani-smoke` (CI run 35454416505), `netsukefile` (35454416354), and `release / metadata` (35454416662). The sole red job is the non-required `Windows / build-test-windows`, failing for the already-recorded pre-existing reason, re-verified here against `main` at `ef7ed760` rather than assumed. Also corrects a wrong premise I supplied while briefing the monitor: I described that job as failing in `git submodule` before project code runs. It does not — those lines are post-job cleanup from a successful checkout, and the real failure is the loopback race at the test step. The conclusion held, which is why the premise had to be checked rather than inherited. Co-Authored-By: Claude Code --- docs/execplans/7-1-1-clock-provider-seam.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index 68a10fd29..172c4f69c 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -1587,6 +1587,23 @@ above, which are disposable. inline reply to the guides-lifecycle finding, and a pre-merge reconciliation for the Observability row, each citing `f81f2f98` and quoting the table row live at the time of posting rather than from memory. +- [x] Post-completion: CI is green on the published head `74822cc2` — the + `f81f2f98` push plus its docs-only follow-up. All four required checks pass: + `build-test` and `kani-smoke` (CI run `35454416505`), `netsukefile` + (`35454416354`), `release / metadata` (`35454416662`). The one red job is + `Windows / build-test-windows`, which is not a required check and fails for + the pre-existing reason already recorded above: `os error 10053` on the + loopback race in `stdlib::network::redirect`. That pre-existence was + re-verified rather than assumed — the identical test and identical error text + appear on `main` at `ef7ed760` (run `35437324950`) and on branch commits + `dbd90956` and `bb1d7f9b`. The branch's change surface contains no + `src/stdlib/network/` files, so the failure cannot be this branch's. One + correction worth recording: briefing the monitor, I described this job as + failing in `git submodule` before project code runs, which is **wrong** — the + `submodule` lines are ordinary post-job cleanup from a *successful* checkout, + and the real failure is at the test step. The conclusion survived the + corrected premise, which is exactly why the premise had to be checked instead + of inherited; a right answer for the wrong reason is not evidence. ## Surprises & discoveries From e3953a7454e4618fad6f2814df4ea0b95fcf5b53 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 18:53:39 +0200 Subject: [PATCH 50/54] Record the reconciled review surfaces and the open decision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit's separate answer to the pre-merge reconciliation confirms the implementation by static inspection and instructs that the observability warning be marked resolved, with no follow-up issue and no further code work. Records two facts separately rather than merging them: both review threads are resolved and the queue is empty, while the `CHANGES_REQUESTED` decision persists and is anchored to `8de3c963`, which is no longer an ancestor of the branch. A stale anchor is not an approval, and clearing it would mean dismissing a review or approving on the bot's behalf — so the decision is left with its designated owner, and the four required checks are recorded as `SUCCESS` on the published head. Co-Authored-By: Claude Code --- docs/execplans/7-1-1-clock-provider-seam.md | 23 +++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index 172c4f69c..cabcc9cf6 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -1604,6 +1604,29 @@ above, which are disposable. and the real failure is at the test step. The conclusion survived the corrected premise, which is exactly why the premise had to be checked instead of inherited; a right answer for the wrong reason is not evidence. +- [x] Post-completion: the review surfaces reconciled at the published head, + with the remaining decision left where it belongs. CodeRabbit's review + `5256390041` on `f81f2f98` returned `COMMENTED` with an empty body, and its + separate answer to the pre-merge reconciliation states that static inspection + **confirms** the implementation: `source_label()` is a closed two-value + result, `Debug` reuses it so the two labels cannot diverge, the event precedes + `register_functions` and carries neither instant nor provider output, and + the test covers both provenances and rejects leakage of the fixed instant. It + closes with "Mark the observability warning as resolved… Do not create a + follow-up issue. Do not perform further code work for this warning." Both + review threads are `isResolved: true`, and the `comenq` queue is empty. + + Two facts are recorded separately rather than merged, because they are not + the same fact. The `CHANGES_REQUESTED` review decision **persists**, and it + is anchored to `8de3c963`, which is no longer an ancestor of the branch. That + anchor is stale, but a stale anchor is not an approval: clearing it would + mean dismissing a review or approving on the bot's behalf, which this plan + does not do. The four required checks (`build-test`, `kani-smoke`, + `netsukefile`, `release / metadata`) are all `SUCCESS` on `74822cc2`, and the + walkthrough's Observability row still renders as a warning because the + comment has not been re-edited. The dispositions above are therefore + complete; the decision is not mine to make, and the designated owner retains + it. ## Surprises & discoveries From 6895d8943273ddc897048762d93a6114e13a0662 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 19:24:55 +0200 Subject: [PATCH 51/54] Name the verified head and stop re-verifying it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recording "CI is green at head H" is itself a commit, and that commit moves the head, invalidating the verdict it records. The plan is not a passive record here: `build-test` runs `make check-fmt`, markdownlint over `**/*.md`, `make spelling`, and the workspace test suite, and `tests/execplan_status_contract_tests.rs` reads `docs/execplans/` — so an edit to this document is an input to the same required checks whose result it reports. Three pushes were spent rediscovering that. The fix is not to keep re-verifying but to name the head that was verified rather than implying the newest one is, so this is the last plan commit for the re-target. Co-Authored-By: Claude Code --- docs/execplans/7-1-1-clock-provider-seam.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index cabcc9cf6..988c54e5f 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -1628,6 +1628,23 @@ above, which are disposable. complete; the decision is not mine to make, and the designated owner retains it. + One property of this branch is worth naming, because it produced a loop I had + to break deliberately. Recording "CI is green at head H" is itself a commit, + and that commit moves the head, invalidating the verdict it records. The plan + file is not a passive record here: `build-test` runs `make check-fmt`, + markdownlint over `**/*.md`, `make spelling`, and the workspace test suite, + and `tests/execplan_status_contract_tests.rs` reads `docs/execplans/` — so an + edit to this document is an *input* to the same required checks whose result + the edit reports. Three pushes went on rediscovering this, each re-triggering + the checks it had just confirmed. The resolution is not to keep re-verifying + but to accept that the plan's last commit cannot be covered by a green run it + describes, and to name the head that was verified rather than implying the + newest one is. The verified head of record is `6eacd07b`: `netsukefile` and + `release / metadata` both `success`, and in the `CI` run the required + `build-test` and `kani-smoke` both `success`. The caveat is that a + `pull_request` run builds a synthetic merge of head into `main`, so `headSha` + identifies the PR head rather than proving that commit was built in isolation. + ## Surprises & discoveries Recorded during planning; extend during implementation. From 0de36a0d23f21c3d44beb24e44444227e7e62bb9 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 19 Sep 2026 20:24:41 +0200 Subject: [PATCH 52/54] Correct the reason the Observability row still renders The reconciliation entry recorded that the walkthrough's Observability row persists because the comment "has not been re-edited". That is wrong: the comment's updated_at moved to 2026-09-19T17:25:12Z, after CodeRabbit's confirmation at 16:13:51Z, so it was touched. What did not happen is any change to what it says. Three captures of the body taken after that edit (17:55, 18:51, 20:06) are byte-identical to the live body, so the touch was content-preserving and left the row standing. The distinction is load-bearing. "The table is stale" would justify asking for another pass; "the table was refreshed and the finding still stands" would not. Only the first reading fits the evidence, and the second is the one the recorded reason implied. Dispositions are unchanged: both threads isResolved, both fixes verified present at current source, all four required checks SUCCESS on 74822cc2. The row remains CodeRabbit's to flip; ticking its Ignore checkbox or dismissing the review to force the table green is not done here. Gates: make check-fmt, make markdownlint, make spelling all exit 0, and execplan_status_contract_tests 9/9 passed -- the one test reading docs/execplans/. Co-Authored-By: Claude Code --- docs/execplans/7-1-1-clock-provider-seam.md | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index 988c54e5f..6f0ccc742 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -1623,10 +1623,22 @@ above, which are disposable. mean dismissing a review or approving on the bot's behalf, which this plan does not do. The four required checks (`build-test`, `kani-smoke`, `netsukefile`, `release / metadata`) are all `SUCCESS` on `74822cc2`, and the - walkthrough's Observability row still renders as a warning because the - comment has not been re-edited. The dispositions above are therefore - complete; the decision is not mine to make, and the designated owner retains - it. + walkthrough's Observability row still renders as a warning. + + The reason first recorded for that row — that the walkthrough had not been + re-edited — is wrong, and is corrected here on the same principle the rest of + this plan applies to CodeRabbit's rows. The comment's `updated_at` moved to + `2026-09-19T17:25:12Z`, after CodeRabbit's confirmation, so it *was* touched; + what did not happen is any change to what it says. Three captures of the body + taken after that edit (17:55, 18:51, 20:06) are byte-identical to the live + body, so the touch was content-preserving and left the row standing. The + distinction matters because "the table is stale" and "the table was refreshed + and the finding still stands" call for different responses, and only the + first would justify asking for another pass. Flipping the row is CodeRabbit's + to do; ticking its Ignore checkbox or dismissing the review to force the + table green is not something this plan does. The dispositions above are + therefore complete; the decision is not mine to make, and the designated + owner retains it. One property of this branch is worth naming, because it produced a loop I had to break deliberately. Recording "CI is green at head H" is itself a commit, From cabb7c63cb64feaa75938b675b2f4a18a1827a08 Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 24 Sep 2026 22:58:03 +0200 Subject: [PATCH 53/54] Record the re-rebase and correct the superseded timeout disposition Two entries, both about replacing a record that no longer describes reality. The Progress section gains the re-rebase onto the current origin/main: boundary 79545e12, target 397fb589, 52 commits replayed, exactly one conflict, in docs/v0-1-0-migration-guide.md where main's #737 rewrote the at-a-glance table while this branch appended a row to it. Both intents were additive, so the resolution keeps main's table and appends the row; range-diff reports 51 of 52 pairs identical and the one divergence is padding. The substantive fixes are shown to survive by whole-file patch-id comparison rather than by reading subjects. The timeout disposition in entry 21 is marked superseded rather than left to read as current. It cited issue 732 as a live tracker and concluded there was nothing to fix here. The rebase adopted #752, which closed 732 and replaced that test's live private rebuild with a recorded Cargo JSON fixture, so the test no longer spawns Cargo and was dropped from the nested-cargo-builds group. The first rebased gate run confirms it: make test is 3394 of 3394 passing, 0 failed, 5 skipped in 276 s, with the formerly-timing-out test green and no longer slow. Co-Authored-By: Claude Code --- docs/execplans/7-1-1-clock-provider-seam.md | 47 +++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index 6f0ccc742..b59e5fd47 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -1657,6 +1657,32 @@ above, which are disposable. `pull_request` run builds a synthetic merge of head into `main`, so `headSha` identifies the PR head rather than proving that commit was built in isolation. +- [x] Post-completion: the branch re-rebased onto the current `origin/main`, + which had advanced 20 commits past the previous boundary. The replay boundary + was `79545e12`, the merge base at the last re-target and the last commit the + branch had inherited; the target was `397fb589`. Fifty-two branch commits + replayed, and exactly one needed a resolution — + `docs/v0-1-0-migration-guide.md`, where main's #737 had rewritten the "Ninja + text escaping" row of the at-a-glance table while this branch appended a + "Clock provider" row to the same table. Both intents were additive and + independent, so the resolution keeps main's table wholesale and appends the + branch's row; `range-diff` reports 51 of 52 pairs `=` and only that commit + `!`, and the divergence is confined to the one line's padding. The + substantive fixes were confirmed to survive by comparing whole-file + `patch-id`s across the rebase, not by reading commit subjects: + `src/stdlib/time/clock.rs`, `src/stdlib/register.rs`, `docs/users-guide.md`, + `tests/std_filter_tests/time_functions.rs` and + `src/stdlib/time/clock_tests.rs` are all byte-identical, so CodeRabbit's + static confirmation of the Observability fix still describes the new head. + + One process point is worth keeping. The previously-cited SHAs — `f81f2f98`, + `7d0b3f15`, `c167fb5a`, `14651fc0`, `74822cc2` — are all still objects in the + repository but are no longer ancestors of the tip, because a replay rewrites + every commit it replays. Quoting one in a review reply would therefore have + been false evidence that the reply described the current head. + `git range-diff` supplies the remapping, and the replies use the new SHAs + together with the run identifiers rather than the old ones alone. + ## Surprises & discoveries Recorded during planning; extend during implementation. @@ -2876,3 +2902,24 @@ still applies is not the same as knowing it. obligation is to show the failure is not its own, which the CI `build-test` pass on `8de3c963` does: the same test ran inside that job and the job succeeded. + + That disposition was correct when written and is superseded now, so it is + recorded here rather than left to read as current. The rebase onto the + current `origin/main` adopted 20 commits, one of which is the fix: #752, + "Reuse nested Cargo build artefacts (#732)", merged 2026-09-20, closed issue + 732, and replaced this very test's live private rebuild with a recorded + Cargo JSON fixture. `harness_compiles_under_a_split_build_dir` still exists + and still holds its parser contract, but it now reads + `tests/ui/split_build_dir_cargo_messages.jsonl` through `include_str!` and + spawns no Cargo at all; it was also removed from the `nested-cargo-builds` + nextest group, whose filter list no longer names it. Three consequences are + worth stating plainly. The timeout cause this entry diagnosed is fixed + upstream, not merely tracked. Issue 732 is closed, so citing it as a live + tracker would be citing a superseded record — the reason the disposition + read "nothing to fix here" no longer holds, because the fix has landed. And + the first rebased gate run confirms the new state rather than assuming it: + `make test` on the rebased head is 3394 of 3394 passing, 0 failed, 5 + skipped in 276 s, up from 3250 tests, with the formerly-timing-out test + green and no longer among the slow ones. The four slow markers that remain + are genuine nested-Cargo builds in other tests, which is what the + `nested-cargo-builds` group and its single-slot policy exist to serialize. From c4e2c29634d0d78e0d1bf2b45263ed29fa4c950f Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 24 Sep 2026 23:21:24 +0200 Subject: [PATCH 54/54] Record publication and the disposition of the two declined checks The rebased head was published with a force-with-lease bound to the previously recorded remote head, and the PR base now reads the same SHA as the replay target. Records how CodeRabbit's two explicitly-unresolved checks were disposed of. The Windows close-abort failure is superseded by this rebase, proved by ancestry: the fix was absent from the head that failed and is present afterwards. The CodeScene coverage timeout is trunk-only by design and not in the required set. Also records that the new head's green CodeRabbit status carries the description "Review paused" rather than "Review completed", so it is a pause stamp and not evidence of a review. Co-Authored-By: Claude Code --- docs/execplans/7-1-1-clock-provider-seam.md | 47 +++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/docs/execplans/7-1-1-clock-provider-seam.md b/docs/execplans/7-1-1-clock-provider-seam.md index b59e5fd47..c5480dc2b 100644 --- a/docs/execplans/7-1-1-clock-provider-seam.md +++ b/docs/execplans/7-1-1-clock-provider-seam.md @@ -1683,6 +1683,53 @@ above, which are disposable. `git range-diff` supplies the remapping, and the replies use the new SHAs together with the run identifiers rather than the old ones alone. +- [x] Post-completion: publication, and the disposition of the two checks + CodeRabbit declined to call resolved. + + The rebased head `cabb7c63` was published with a force-with-lease bound to + the previously recorded remote head `1ef98593`; the lease matched and the + remote branch is now `cabb7c63`. The PR base reads `397fb589`, identical to + the replay target, so the branch is aligned with the base the PR targets. + + CodeRabbit's freshest confirmation of the Observability warning + (`2026-09-24T20:42:22Z`) names `1ef98593`, which the rebase superseded. + Rather than let that read as current, the pre-merge row was reconciled + against the new head with the byte-identical `patch-id` evidence above. The + top-level follow-up is comment `5822412433`; the guides-thread reply is + comment `4098582850`. + + That same reply carried a caveat worth recording as a lesson: CodeRabbit + stated its assessment "does not treat this observability assessment as a + resolution" of a failed Windows build-test or a timed-out CodeScene coverage + check. Both were then disposed of independently, and neither disposition is + "it was not required". + + - Windows `build-test-windows` failed at `1ef98593` on exactly one test, + `stdlib::network::redirect::error_tests::protocol_failures_are_classified_from_a_live_response`, + with `os error 10053` — the Windows close-abort fixture defect, where the + fixture's naked listener close reset the connection so the client reported + an aborted connection instead of the parse failure under test. That is not + this branch's code: no commit of ours in `397fb589..HEAD` touches + `src/stdlib/network/`. The decisive fact is ancestry, not adjacency: + `git merge-base --is-ancestor 061182b1 1ef98593` **fails**, so #749's fix + was absent from the head that failed, while the same check against + `cabb7c63` **succeeds**, because the rebase now inherits it. The failure is + therefore superseded by this rebase rather than argued away. + - `CodeScene Code Coverage (main)` timed out at `1ef98593`. It writes + trunk-only coverage metrics and is known to time out on pull requests. It is + not in the required set, which the ruleset confirms is exactly `build-test`, + `kani-smoke`, `netsukefile` and `release / metadata` + (`main-required-checks`, id 18427981). The CodeScene check that does grade + this branch, `CodeScene Code Health Review (main)`, is `success`. + + One more tooling fact matters for anyone reading a green status here. The + `CodeRabbit` commit status on `cabb7c63` reaches `success` about five seconds + after the push, but its description reads **"Review paused"**, not "Review + completed" as it did on `1ef98593`. CodeRabbit has auto-paused this branch, + so that green status is a pause stamp and is not evidence of any review of + the new head. This is exactly why the status latency and its description, not + the colour alone, are what the record should cite. + ## Surprises & discoveries Recorded during planning; extend during implementation.