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