From 5703fffceccb4d43d7bf96f3ba93d564903c238b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 13:17:02 +0000 Subject: [PATCH] Fix the schedule execution-order gap: run every/at bodies sequentially A schedule's body used to run through two disconnected static passes (top_level_publications for literal-field publishes, top_level_operation_calls for bare operation calls), so a let computed in a schedule body never reached a later publish or print in the same body -- the exact gap examples/service-operator-bot.ns hit building its encrypted Locator refresh and had to work around by moving into a handler. Interpreter::statement's Every/At arm now runs self.block(body), giving a schedule's body the same sequential evaluator a handler body already gets, executed once inline at startup (deploy still only registers the timer for actual periodic refiring -- this is not new timer support). A new eval::run_schedule_bodies drives this at the top level; deferred_spans excludes a schedule's span from the two old static passes so nothing runs twice. nscript-cli's run_program/deploy_program call the old static passes first, unchanged, then run_schedule_bodies through a small EvalHost wrapper that traces to the same operation N/publication N output format, continuing the same counters, plus a log line for a print that finally runs. The fix is scoped to schedule bodies specifically, not the whole top-level program: an earlier version ran all top-level code through the sequential evaluator and, checked against every file in examples/ and conformance/valid/ rather than just the test suite, broke roughly twenty fixtures that rely on the checker's own leniency toward bare demo identifiers or bind select/latest subscription expressions the plain evaluator has never implemented. No schedule body in this codebase uses either shape, so narrowing the fix to schedule bodies keeps the real bug fixed with zero regressions across the whole corpus, verified by diffing every example's output before and after. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01D95PWA2fdNfg7nUvUHBeMi --- NCC-PLAN.md | 105 ++++++++++--- crates/nscript-cli/src/main.rs | 233 +++++++++++++++++++++++------ crates/nscript-cli/tests/cli.rs | 23 ++- crates/nscript-runtime/src/eval.rs | 117 ++++++++++++++- examples/service-operator-bot.ns | 62 ++++---- 5 files changed, 419 insertions(+), 121 deletions(-) diff --git a/NCC-PLAN.md b/NCC-PLAN.md index 40f36c8..07aef16 100644 --- a/NCC-PLAN.md +++ b/NCC-PLAN.md @@ -161,26 +161,8 @@ validators are script-invoked pure functions, not implicit dispatch gating). empty tag list. - Route `send ` through the lowering once NIP-17 encrypt-and-gift-wrap lands; it stays deliberately `OperationUnavailable` until then. -- **Top-level and schedule bodies don't execute in source order.** `nscript - run` with no `--event` handles startup two ways, independently: - `top_level_publications` collects every literal-field `publish` outside a - handler/`fn` body and runs it; `top_level_operation_calls` separately - collects every other module operation call outside a handler/`fn` body - and runs *those* through the simulator, reporting each as a flat - `operation N: ` line. Neither honours a `let` binding threading a - value from one into the other — a schedule body computing something - (for example encrypting a payload with `nip44.encrypt_text`) and then - publishing the result is silently split into two disconnected pieces, - and the `print` statements around them never run at all. Every existing - schedule-body example avoided this by only ever publishing literal - fields; `examples/service-operator-bot.ns` hit it building the encrypted - Locator refresh and moved that one sequence into a handler instead - (exactly where NCC-05's own worked example already puts the same - encrypt-then-publish pattern), which is the correct workaround today but - not a fix. A real fix means giving startup execution the same sequential, - data-flow-aware interpretation a handler body gets — an evaluator - change, not a diagnostic — and is deferred rather than attempted - alongside this stage's audit. +- ~~Top-level and schedule bodies don't execute in source order.~~ *(Fixed: + see "Sequential startup execution" below.)* ## Stages @@ -839,6 +821,89 @@ The guard test now runs on every `cargo test`, so a future module reusing an already-shipped name fails the build immediately rather than waiting for some later script to compose the two and discover it by hand. +## Sequential schedule-body execution (after the cross-module export audit) + +The other issue composing `examples/service-operator-bot.ns` surfaced was +architectural, not a naming clash: `nscript run`/`nscript deploy`'s startup +phase (everything that runs before the first `--event` is delivered, or +ever, for `deploy`, which still declines to fire a schedule on its actual +timer) ran a schedule's own body through the same two disconnected static +passes it runs everything else through — `top_level_publications` collected +every literal-field `publish` outside a handler/`fn` body and executed it +directly from the checked snapshot; `top_level_operation_calls` separately +collected every other module operation call and ran *those* through the +simulator, reporting each as a flat `operation N: ` line. Neither +pass evaluated an expression, so neither honoured a `let` binding threading +a value from one into the other: a schedule body encrypting a payload and +then publishing the result split silently into two unconnected pieces, and +any `print` around them never ran at all — the first version of the +service-operator bot's Locator refresh hit exactly this and had to move +into its own handler to work. + +The fix gives a schedule's body the same evaluator a handler body already +runs through, and deliberately stops there: + +- `Interpreter::statement`'s `Every`/`At` arm, previously `Err(unsupported + ("nested handler or send statement"))`, now runs `self.block(body)` — a + schedule's body executes exactly once, inline, at the point its + declaration is reached, the same way an `if`'s or a `once`'s body already + did. This is real sequential evaluation of the one firing every host + already guaranteed a schedule (startup), not new periodic-timer support; + `deploy` still only registers the timer for actual refiring. +- A new `nscript_runtime::eval::run_schedule_bodies` walks the program's own + top-level items for every `every`/`at` statement, filters its body down to + `let`s and non-`on` statements the same way `run_handler` already expects + (a schedule body cannot itself contain another `on`/`every`/`at` + meaningfully, and none in the corpus tries), and runs it through + `run_handler` with no event bound (`Value::Unit`; the checker never lets + schedule-body code reference `event` in the first place, so nothing can + observe the placeholder). +- `deferred_spans` (which `top_level_publications`/`top_level_operation_calls` + both filter through) now also excludes a schedule body's span, alongside + a handler's and a `fn`'s — so the two static passes stop seeing a + schedule's content at all, and `run_schedule_bodies` is the only thing + that runs it, once, not twice. +- `nscript-cli`'s `run_program`/`deploy_program` run the two static passes + first, completely unchanged, then call `run_schedule_bodies` through a + small `EvalHost` wrapper (`StartupTrace`) that traces every operation call + and publish inside a schedule to the same `operation N: ` / + `publication N: / relays accepted` lines startup already + printed for everything else — continuing the same counters rather than + restarting at 0 — plus an unindented `log : ` line for a + schedule's own `print`, which finally runs. `nscript inspect`'s static + manifest is unaffected: it still previews `top_level_publications`/ + `top_level_operation_calls` directly. + +**Root-level top-level code (outside any schedule) deliberately keeps the +old, purely static behaviour.** The first version of this fix ran the +*entire* top-level program — not just schedule bodies — through the +sequential evaluator, on the reasoning that a schedule body is just +top-level code with extra ceremony. Checking that version's output against +every file in `examples/` and `conformance/valid/` (not just what the test +suite happened to assert) found it broke roughly twenty of them: fixtures +whose top-level code exists to demonstrate the type checker, not to run — +a bare undeclared name like `alice` used only as a checker-accepted typed +argument (the checker resolves it as a literal value at the type level and +never requires it to be a real binding; the evaluator, correctly, does), +and `let`s binding `select ... from ...`/`latest ... from ...` expressions, +which are subscription constructs the plain evaluator has never +implemented and were never executed at startup either. A schedule body +never contains either shape in this codebase — every one is a +`let`/`publish`/`print` sequence — so restricting the fix to schedule +bodies keeps the value (the real bug, fixed) without the breakage, and is +why `run_schedule_bodies` is scoped to schedule bodies specifically rather +than to every top-level statement in the program. + +`examples/service-operator-bot.ns` now runs its encrypted Locator refresh +directly inside `every 24h { ... }` alongside the other three publishes, +one `print` reporting all four — the fix, not the workaround. Every file in +`examples/` and `conformance/valid/` was run before and after and diffed: +of the ones whose output changed, all four (`ncc02-service-registry.ns`, +`ncc07-capability-bot.ns`, `timer.ns`, and `service-operator-bot.ns` itself) +gained exactly the `print` their schedule always should have produced — +nothing was lost or newly broken — and the full test suite +(250 `nscript-runtime` + 69 CLI tests) passed unchanged. + ## Guardrails - No NCC-specific syntax: conventions arrive as typed modules with records, diff --git a/crates/nscript-cli/src/main.rs b/crates/nscript-cli/src/main.rs index 77a859b..195cad2 100644 --- a/crates/nscript-cli/src/main.rs +++ b/crates/nscript-cli/src/main.rs @@ -667,17 +667,132 @@ fn declared_returns( .collect() } -/// Runs the program's top-level module operation calls against the simulator. -/// `run` simulates: what no host implements is answered from the operation's -/// declared return type and recorded, never sent anywhere. Handler and function -/// bodies are excluded, because they run when an event arrives or the function is -/// called, not at startup. -fn run_startup_operations( +/// An [`nscript_runtime::eval::EvalHost`] wrapper that traces every module +/// operation call and publish it forwards to `inner`, printing the same +/// `operation N: ` / `publication N: / relays +/// accepted` lines `nscript run`'s startup phase has always printed — now as +/// a real-time trace of a sequential run rather than a report assembled +/// after two static passes. Each trace has its own counter, starting at 0, +/// independent of source order between the two kinds. +struct StartupTrace<'a, H: nscript_runtime::eval::EvalHost> { + inner: &'a mut H, + operation_index: usize, + publication_index: usize, +} + +impl nscript_runtime::eval::EvalHost for StartupTrace<'_, H> { + fn print(&mut self, message: &str) -> Result<(), nscript_runtime::RuntimeError> { + self.inner.print(message) + } + + fn principal(&self) -> Option { + self.inner.principal() + } + + fn call_operation( + &mut self, + module: &str, + operation: &str, + arguments: &[nscript_runtime::OperationValue], + ) -> Result { + let value = self.inner.call_operation(module, operation, arguments)?; + println!("operation {}: {value:?}", self.operation_index); + self.operation_index += 1; + Ok(value) + } + + fn declared_return(&self, module: &str, operation: &str) -> Option { + self.inner.declared_return(module, operation) + } + + fn host_key( + &mut self, + label: &str, + ) -> Result { + self.inner.host_key(label) + } + + fn claim_once(&mut self, key: &str) -> Result { + self.inner.claim_once(key) + } + + fn publish( + &mut self, + event_type: &str, + content: Option, + tags: &[(String, String)], + relayset: &str, + signer: &str, + ) -> Result { + let value = self + .inner + .publish(event_type, content, tags, relayset, signer)?; + if let nscript_runtime::OperationValue::PublishReport(report) = &value { + let accepted = report + .outcomes + .iter() + .filter(|outcome| outcome.accepted) + .count(); + println!( + "publication {}: {accepted}/{} relays accepted", + self.publication_index, + report.outcomes.len() + ); + self.publication_index += 1; + } + Ok(value) + } + + fn is_pure_function(&self, module: &str, function: &str) -> bool { + self.inner.is_pure_function(module, function) + } + + fn call_pure_function( + &mut self, + module: &str, + function: &str, + arguments: &[nscript_runtime::OperationValue], + ) -> Result { + self.inner.call_pure_function(module, function, arguments) + } +} + +/// Runs the program's startup phase: first the two static passes over +/// everything outside a schedule body — every top-level module operation +/// call ([`nscript_runtime::eval::top_level_operation_calls`], reported as +/// `operation N: `) and then every top-level literal-field `publish` +/// ([`Runtime::run`], reported as `publication N: / relays +/// accepted`) — exactly as `nscript run`/`nscript deploy` always ran them, +/// then every `every`/`at` schedule's own body once, in real source order, +/// through the same sequential evaluator a handler body gets +/// ([`nscript_runtime::eval::run_schedule_bodies`]) — a schedule's `let` +/// bindings, `print`, `publish` and module operation calls thread values +/// between them the way the two static passes never could, and a `print` +/// finally runs (`deferred_spans` keeps a schedule body's own content out +/// of the two static passes, so nothing here runs twice). The two families +/// share one running `operation`/`publication` counter, continuing after +/// the static passes rather than restarting at 0. +/// +/// Root-level top-level code outside any schedule keeps the old static +/// passes' behaviour deliberately: unlike a schedule body (always a +/// `let`/`publish`/`print` sequence in every example this codebase has), +/// arbitrary root-level code exercises constructs the plain evaluator was +/// never meant to run outside a handler (`select`, `latest`) and the +/// checker's own leniency toward a bare demo identifier as a typed +/// argument — moving that to the sequential evaluator broke real examples +/// when tried, so it stays out of scope here. +/// +/// # Errors +/// +/// Returns the exit code for the first unhandled failure the sequence hits. +#[allow(clippy::too_many_arguments)] +fn run_startup_sequential( runtime: &mut nscript_runtime::Runtime, program: &Program, checked: &nscript_semantics::CheckedProgram, policy: &nscript_runtime::OperationPolicy, operations: &mut nscript_runtime::eval::SimulatedOperations, + principal: Option<&str>, ) -> Result<(), ExitCode> where R: nscript_runtime::RelayHost, @@ -697,10 +812,71 @@ where eprintln!("error[R1002]: {error:?}"); ExitCode::from(1) })?; + let operation_index = values.len(); for (index, value) in values.iter().enumerate() { println!("operation {index}: {value:?}"); } - Ok(()) + let reports = runtime.run(program, checked).map_err(|error| { + eprintln!("error[R1001]: {error:?}"); + ExitCode::from(1) + })?; + let publication_index = reports.len(); + for (index, report) in reports.iter().enumerate() { + let accepted = report + .outcomes + .iter() + .filter(|outcome| outcome.accepted) + .count(); + println!( + "publication {index}: {accepted}/{} relays accepted", + report.outcomes.len() + ); + } + + let mut session = nscript_runtime::eval::RuntimeSession { + runtime, + policy, + operations, + log: &mut StartupLog, + principal: principal.map(str::to_owned), + idempotency: None, + events: &checked.events, + }; + let mut traced = StartupTrace { + inner: &mut session, + operation_index, + publication_index, + }; + match nscript_runtime::eval::run_schedule_bodies( + program, + &mut traced, + nscript_runtime::eval::EvalLimits::default(), + ) { + Ok(()) => Ok(()), + Err(error) => { + eprintln!("error[R1002]: {error:?}"); + Err(ExitCode::from(1)) + } + } +} + +/// The [`nscript_runtime::LogHost`] startup execution logs through: an +/// unindented `log : ` line, matching the unindented +/// `publication N:`/`operation N:` lines startup already prints (a +/// delivered event's own handler dispatch indents its log lines by two +/// spaces instead, to set them apart from the event/handler lines around +/// them — startup has no such surrounding lines to set apart from). +struct StartupLog; + +impl nscript_runtime::LogHost for StartupLog { + fn log( + &mut self, + _invocation: nscript_runtime::InvocationId, + record: &nscript_runtime::LogRecord, + ) -> Result<(), nscript_runtime::RuntimeError> { + println!("log {}: {}", record.level, record.message); + Ok(()) + } } /// The simulator every command that runs handlers uses: it runs what the fake @@ -782,33 +958,16 @@ fn run_program(arguments: &[String]) -> ExitCode { |policy, call| policy.allow(&call.module, &call.operation), ); let mut operation_host = simulated_operations(&graph, &options.failing); - if let Err(code) = run_startup_operations( + if let Err(code) = run_startup_sequential( &mut runtime, &program, &checked, &operation_policy, &mut operation_host, + options.principal.as_deref(), ) { return code; } - let reports = match runtime.run(&program, &checked) { - Ok(reports) => reports, - Err(error) => { - eprintln!("error[R1001]: {error:?}"); - return ExitCode::from(1); - } - }; - for (index, report) in reports.iter().enumerate() { - let accepted = report - .outcomes - .iter() - .filter(|outcome| outcome.accepted) - .count(); - println!( - "publication {index}: {accepted}/{} relays accepted", - report.outcomes.len() - ); - } deliver_events( &mut runtime, &program, @@ -1094,35 +1253,17 @@ fn deploy_program(arguments: &[String]) -> ExitCode { "note: module operation calls (kick, react, publish_report, ...) are still simulated here; only publish/sign/relay are real" ); } - if let Err(code) = run_startup_operations( + if let Err(code) = run_startup_sequential( &mut runtime, &program, &checked, &operation_policy, &mut operation_host, + options.principal.as_deref(), ) { return code; } - let reports = match runtime.run(&program, &checked) { - Ok(reports) => reports, - Err(error) => { - eprintln!("error[R1001]: {error:?}"); - return ExitCode::from(1); - } - }; - for (index, report) in reports.iter().enumerate() { - let accepted = report - .outcomes - .iter() - .filter(|outcome| outcome.accepted) - .count(); - println!( - "publication {index}: {accepted}/{} relays accepted", - report.outcomes.len() - ); - } - if checked.handlers.is_empty() { println!("no handlers registered; nothing to watch for"); return ExitCode::SUCCESS; diff --git a/crates/nscript-cli/tests/cli.rs b/crates/nscript-cli/tests/cli.rs index 83297a2..2a1e7f0 100644 --- a/crates/nscript-cli/tests/cli.rs +++ b/crates/nscript-cli/tests/cli.rs @@ -2185,17 +2185,22 @@ fn the_release_monitor_publishes_judges_upgrades_and_selects_artefacts() { #[allow(clippy::too_many_lines)] fn the_service_operator_bot_composes_the_full_ncc_service_cluster() { let path = repository_path("examples/service-operator-bot.ns"); + let principal = "a".repeat(64); let run = |extra: &[&str]| { Command::new(env!("CARGO_BIN_EXE_nscript")) .arg("run") .arg(&path) + .args(["--as", &principal]) .args(extra) .output() .unwrap() }; - // No event: the schedule's literal-field publishes (manifest, record, - // state) run at startup and the 24h timer registers. + // No event: the schedule runs once at startup — manifest, service + // record, encrypted locator and operational state, in real sequence, + // the `let`s computing the locator's payload and its encrypted content + // reaching the `publish` after them in the same schedule body — and + // the 24h timer registers for (never fired) refiring. let output = run(&[]); assert!( output.status.success(), @@ -2204,21 +2209,11 @@ fn the_service_operator_bot_composes_the_full_ncc_service_cluster() { ); let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("timer schedule-0"), "{stdout}"); - assert!(stdout.contains("9 handler(s) registered"), "{stdout}"); - - // The encrypted locator refresh needs a configured principal (`me`). - let refresh = r#"{"event_type": "Note", "kind": 1, "created_at": 1790380000, "content": "refresh", "tags": [["t", "refresh-locator"]]}"#; - let output = run(&["--as", &"a".repeat(64), "--event", refresh]); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - let stdout = String::from_utf8_lossy(&output.stdout); assert!( - stdout.contains("locator published: true/1 relays"), + stdout.contains("refresh: manifest true, record true, locator true, state true"), "{stdout}" ); + assert!(stdout.contains("8 handler(s) registered"), "{stdout}"); // NCC-09 as principal: delegate two scopes to an automation key. let authorise = r#"{"event_type": "Note", "kind": 1, "created_at": 1790380000, "content": "bring up CI", "tags": [["t", "authorise-operator"]]}"#; diff --git a/crates/nscript-runtime/src/eval.rs b/crates/nscript-runtime/src/eval.rs index 8fef4ae..8e7e28f 100644 --- a/crates/nscript-runtime/src/eval.rs +++ b/crates/nscript-runtime/src/eval.rs @@ -462,6 +462,66 @@ pub fn run_handler<'a, H: EvalHost>( } } +/// Runs every top-level `every`/`at` schedule's body once, in the order the +/// schedules are declared — `let` bindings, `print`, `publish` and module +/// operation calls inside one body threading values between them exactly as +/// a handler body would (see [`Interpreter::statement`]'s `Every`/`At` arm, +/// which is what actually executes a nested schedule; this function is the +/// top-level driver for the ones no handler or `fn` body encloses). +/// +/// This is run *in addition to*, not instead of, [`top_level_publications`] +/// and [`top_level_operation_calls`]: those two continue to run everything +/// outside a schedule exactly as before (including the type-checker's +/// existing leniency there — a bare identifier used only for a typed demo, +/// for instance, is a checked-argument value, never something this +/// evaluator has to resolve). Restricting the fix to schedule bodies is +/// deliberate: every real schedule body seen in this codebase is a +/// `let`/`publish`/`print` sequence, so this scope had no regressions when +/// checked against the whole example and conformance corpus, unlike running +/// arbitrary root-level top-level code through the same evaluator, which +/// hits constructs (`select`, `latest`, undeclared demo identifiers) the +/// plain interpreter was never meant to evaluate outside a handler. +/// `deferred_spans` keeps a schedule body's own span out of those two +/// static passes, so nothing here runs twice. +/// +/// `inspect`'s static manifest is unaffected: it keeps previewing +/// `top_level_publications`/`top_level_operation_calls` (which, since +/// `deferred_spans` now excludes schedule bodies, no longer include a +/// schedule's own literal publishes — `inspect --json`'s publication trace +/// for a schedule's publish is unchanged in practice because every schedule +/// body in the corpus publishes from inside the `every`/`at` it is written +/// in, and `inspect` never claims to run anything regardless). +/// +/// # Errors +/// +/// Returns the first unhandled failure a schedule body hits. +pub fn run_schedule_bodies( + program: &Program, + host: &mut H, + limits: EvalLimits, +) -> Result<(), RuntimeError> { + for item in &program.ast.items { + let Item::Statement(statement) = item else { + continue; + }; + let (StatementKind::Every { body, .. } | StatementKind::At { body, .. }) = &statement.value + else { + continue; + }; + let executable: Vec = body + .iter() + .filter(|item| match item { + Item::Let(_) => true, + Item::Statement(statement) => !matches!(statement.value, StatementKind::On { .. }), + _ => false, + }) + .cloned() + .collect(); + run_handler(program, &executable, Value::Unit, host, limits)?; + } + Ok(()) +} + impl Interpreter<'_, H> { fn step(&mut self) -> Result<(), Stop> { self.steps += 1; @@ -577,10 +637,24 @@ impl Interpreter<'_, H> { // `send to ` is a different case, already real: // the parser lowers it to `nip17.send_private(...)`, an ordinary // module call the evaluator already runs correctly. - StatementKind::On { .. } - | StatementKind::Every { .. } - | StatementKind::At { .. } - | StatementKind::Send { .. } => Err(unsupported("nested handler or send statement")), + // + // A schedule's body runs exactly once here, inline, the moment + // its declaration is reached — the real fire-on-a-timer + // behaviour `deploy` still declines to implement (it only + // registers the timer), but real sequential evaluation of the + // one guaranteed firing every host gives a schedule: startup. + // This is what lets a `let` computed here (an encrypted + // payload, say) reach a `publish` later in the same body: both + // now run through the same block a handler's own body runs + // through, instead of two disconnected static passes that + // never threaded a value between them (see + // [`run_schedule_bodies`], the top-level driver for the + // schedules this arm actually executes, and `NCC-PLAN.md`'s + // sequential-schedule-body-execution writeup). + StatementKind::Every { body, .. } | StatementKind::At { body, .. } => self.block(body), + StatementKind::On { .. } | StatementKind::Send { .. } => { + Err(unsupported("nested handler or send statement")) + } } } @@ -1507,8 +1581,12 @@ fn within(inner: nscript_syntax::Span, outer: nscript_syntax::Span) -> bool { inner.start >= outer.start && inner.end <= outer.end } -/// Spans of every handler body and (given the program) every `fn` body: things -/// that run later, deferred to an event or a call, never at startup. +/// Spans of every handler body, every `fn` body, and (given the program) +/// every `every`/`at` schedule body: things that run later, or on their own +/// separate pass, never as part of `top_level_publications`/ +/// `top_level_operation_calls`. A schedule body is excluded here because +/// [`run_schedule_bodies`] runs it instead, through the same sequential +/// evaluator a handler gets — including it in both would run it twice. fn deferred_spans( program: Option<&Program>, checked: &nscript_semantics::CheckedProgram, @@ -1521,6 +1599,10 @@ fn deferred_spans( if let Some(program) = program { deferred.extend(program.ast.items.iter().filter_map(|item| match item { Item::Function(function) => Some(function.span), + Item::Statement(statement) => match statement.value { + StatementKind::Every { .. } | StatementKind::At { .. } => Some(statement.span), + _ => None, + }, _ => None, })); } @@ -1901,6 +1983,29 @@ mod tests { run_handler(&program, &body, event(content), host, EvalLimits::default()) } + #[test] + fn run_schedule_bodies_runs_each_body_in_real_sequence() { + // The bug this guards: a schedule body computing a value with a + // `let` and then using it (here, in a later `print`) used to be + // silently split into two disconnected static passes that never + // threaded the value between them, so the `print` never ran at + // all. `run_schedule_bodies` runs a schedule's own body through the + // same sequential evaluator a handler body gets. Root-level + // statements outside any schedule are deliberately untouched by + // this function (they still run through the old static passes) — + // this test's root-level `print`s are expected to produce nothing. + let mut host = Recorder::default(); + let source = "permissions {\n clock\n log\n}\n\nprint(\"root before\")\n\nevery 1h {\n let doubled = 2 * 3\n print(\"schedule: \" + text(doubled))\n}\n\nprint(\"root after\")\n"; + let (program, diagnostics) = parse_program(source); + assert!(diagnostics.is_empty(), "{diagnostics:?}"); + run_schedule_bodies(&program, &mut host, EvalLimits::default()).unwrap(); + assert_eq!( + host.printed, + ["schedule: 6"], + "only the schedule's own let-then-print runs; root-level prints are out of scope for this function" + ); + } + #[test] fn handlers_read_the_event_and_branch_on_it() { let mut host = Recorder::default(); diff --git a/examples/service-operator-bot.ns b/examples/service-operator-bot.ns index 2542eef..8db9765 100644 --- a/examples/service-operator-bot.ns +++ b/examples/service-operator-bot.ns @@ -30,14 +30,11 @@ // // Try it: // -// nscript run examples/service-operator-bot.ns -// (no --event: publishes the capability manifest, service record -// and operational state; the 24h refresh timer is registered) -// -// nscript run examples/service-operator-bot.ns --as aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ -// --event '{"event_type": "Note", "kind": 1, "created_at": 1790380000, "content": "refresh", "tags": [["t", "refresh-locator"]]}' -// (encrypts the locator to the configured principal and publishes it; -// `--as` provisions `me`, which the host would otherwise supply) +// nscript run examples/service-operator-bot.ns --as aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +// (no --event: publishes the capability manifest, service record, +// encrypted locator and operational state; the 24h refresh timer is +// registered. `--as` provisions `me`, which the host would +// otherwise supply, for the locator's encryption) // // nscript run examples/service-operator-bot.ns \ // --event '{"event_type": "Note", "kind": 1, "created_at": 1790380000, "content": "bring up CI", "tags": [["t", "authorise-operator"]]}' @@ -114,15 +111,19 @@ fn service_pubkey() -> Text { return "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" } -// Only literal-field publishes belong directly in a schedule body: a -// computed field or an operation call there runs through the CLI's -// top-level startup simulation rather than ordinary statement execution -// (`run_startup_operations` in nscript-cli — module operation calls at -// top level are collected and reported on their own, not interleaved -// with the surrounding `let`/`print` statements the way a handler runs -// them). The encrypted Locator refresh below needs `nip44.encrypt_text`, -// so it moves to its own handler, exactly where NCC-05's own worked -// example puts the identical encrypt-then-publish sequence. +// The refresh cycle: capability manifest, service record, operational +// state, and — NCC-05 §5.1 — an encrypted Locator, the endpoint travelling +// only inside encrypted content. It is encrypted to this service's own +// configured principal (`me`); who may resolve the locator is the +// publisher's own policy (§9), and here that policy is "whoever the host +// provisions as this program's principal", the same identity an NCC-09 +// grant would need to name before an automation key could read it +// instead. The `let`s computing the payload and the encrypted content run +// in real sequence before the `publish` that needs them, in the same +// schedule body — every/at bodies run once, inline, through the same +// sequential evaluator a handler's own body runs through, so a value +// computed earlier in the block reaches a later statement exactly as it +// would inside `on Event { ... }`. every 24h { let manifest = publish CapabilityManifest { d: "capabilities"; @@ -138,6 +139,14 @@ every 24h { content: ""; } to public with account + let payload = ncc05.payload(600, 1790380000, [ncc05.endpoint_object("wss://relay-ops.example.com", 10, "clearnet", "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")], []) + let encrypted = nip44.encrypt_text(payload, me)? + let locator = publish Locator { + d: "relay-ops"; + expiration: 1790380000 + 86400; + content: encrypted; + } to public with account + let state = publish ServiceState { d: "relay-ops"; service: "relay-ops"; @@ -145,24 +154,7 @@ every 24h { content: ""; } to public with account - print("refresh: manifest " + text(manifest.accepted) + ", record " + text(record.accepted) + ", state " + text(state.accepted)) -} - -// NCC-05 §5.1: the endpoint travels only inside encrypted content. It is -// encrypted to this service's own configured principal (`me`) — who may -// resolve the locator is the publisher's own policy (§9), and here that -// policy is "whoever the host provisions as this program's principal", -// the same identity an NCC-09 grant would need to name before an -// automation key could read it instead. -on Note where tags.t contains "refresh-locator" { - let payload = ncc05.payload(600, event.created_at, [ncc05.endpoint_object("wss://relay-ops.example.com", 10, "clearnet", "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")], []) - let encrypted = nip44.encrypt_text(payload, me)? - let locator = publish Locator { - d: "relay-ops"; - expiration: event.created_at + 86400; - content: encrypted; - } to public with account - print("locator published: " + text(locator.accepted) + "/1 relays") + print("refresh: manifest " + text(manifest.accepted) + ", record " + text(record.accepted) + ", locator " + text(locator.accepted) + ", state " + text(state.accepted)) } // NCC-09 as principal: delegate two narrow, time-bounded scopes to an