diff --git a/docs/design/session-framework/publishing-v1.md b/docs/design/session-framework/publishing-v1.md index c1ebce2..c438ac4 100644 --- a/docs/design/session-framework/publishing-v1.md +++ b/docs/design/session-framework/publishing-v1.md @@ -51,6 +51,10 @@ or, with no revision, the newest; `Session.GetSnapshot` takes no parameters and latest `CommittedSnapshot`. A revision the session never published is `IDENTITY_MISMATCH`, not an empty answer. Nothing on this service mutates, selects a participant or reaches a worker, so it is not the controller API section 7 rules out; adding a third method that did would be. +These two names are **internal and provisional**: they are what the internal boundary needs in +order to be buildable now, and the later public v2 step is free to rename them, supersede them +or expose a different repair surface entirely. Nothing about them is browser-facing, and the +public step does not inherit them by default merely because they landed first. Descriptor revisions and scope link observations to schemas. Cross-topic ordering is not guaranteed; a subscriber receiving an unknown descriptor revision must fetch it through the diff --git a/services/flysim/crates/fly-session/src/coordinator.rs b/services/flysim/crates/fly-session/src/coordinator.rs index aa21e8e..fb5ddaa 100644 --- a/services/flysim/crates/fly-session/src/coordinator.rs +++ b/services/flysim/crates/fly-session/src/coordinator.rs @@ -61,6 +61,8 @@ pub struct Injections { /// Publish this boundary's snapshot with a handle that is not the artifact the snapshot /// references: the same name, the same shape, another object. pub substituted_published_handle: bool, + /// Ask an agent to apply a stimulus kind its published descriptor does not declare. + pub undeclared_stimulus: bool, } /// What an injection produced, for a test to assert on. @@ -2246,13 +2248,23 @@ impl Coordinator { .expect("every agent prepared"); let outcome = outcomes.get(&agent_id).cloned().unwrap_or_default(); let next_context = next_contexts.get(&agent_id).cloned().expect("checked"); + let mut task_stimulations = outcome.stimulations.clone(); + if self.injections.undeclared_stimulus && self.injections.at_step == k { + // A kind outside the agent's published `supportedStimuli`. The declaration is + // only worth publishing if the worker enforces it. + task_stimulations.push(Stimulus { + id: parse_id(&format!("stim-undeclared-{k}")).expect("a serial makes an Id"), + kind_id: id("arena.undeclared"), + duration_ms: 1.0, + }); + } let params = CommitParams { agent_id: agent_id.clone(), prepared_request_id: prepared_request.clone(), next_input: self.sensory_input(observation, k + 1), next_decision_context: next_context, rewards: outcome.rewards.clone(), - task_stimulations: outcome.stimulations.clone(), + task_stimulations, }; let params = match params.to_json() { Value::Object(m) => m, diff --git a/services/flysim/crates/fly-session/tests/publishing.rs b/services/flysim/crates/fly-session/tests/publishing.rs index 52ca5a9..a600717 100644 --- a/services/flysim/crates/fly-session/tests/publishing.rs +++ b/services/flysim/crates/fly-session/tests/publishing.rs @@ -37,6 +37,7 @@ both_transports!( a_refused_event_batch_is_held_and_counted_not_lost, the_query_service_answers_reads_and_nothing_else, the_published_descriptor_is_what_the_workers_attested_to, + a_stimulus_kind_the_descriptor_does_not_declare_is_refused, ); all_modes!( @@ -608,6 +609,61 @@ async fn the_published_descriptor_is_what_the_workers_attested_to(via: Via) { f.shutdown().await; } +/// `supportedStimuli` is enforced, not advertised: a kind outside the list the descriptor +/// publishes is refused before the model is touched, so the declaration is worth reading. +async fn a_stimulus_kind_the_descriptor_does_not_declare_is_refused(via: Via) { + let mut f = started(via).await; + let declared = f + .harness + .coordinator + .session_descriptor() + .expect("a descriptor") + .agents + .iter() + .find(|a| a.agent_id == fly_a()) + .expect("fly-a") + .supported_stimuli + .clone(); + assert!(!declared.contains(&id("arena.undeclared")), "{declared:?}"); + + // A clean transition first, so the refusal is the injection and not the composition. + f.harness.coordinator.run(1).await.expect("one clean transition"); + let before = f.harness.coordinator.stats().advances; + f.harness.coordinator.injections = Injections { + at_step: 1, + undeclared_stimulus: true, + ..Injections::default() + }; + let failure = f + .harness + .coordinator + .run(1) + .await + .expect_err("an undeclared stimulus kind is refused"); + assert_eq!(failure.error.code, ErrorCode::Unsupported, "{failure:?}"); + assert_eq!( + failure.error.mutation, + MutationCertainty::None, + "refused before the model is touched" + ); + assert!( + failure.error.message.contains("arena.undeclared"), + "the refusal names the kind: {}", + failure.error.message + ); + assert!(failure.participant.is_some(), "and the participant it came from"); + assert_eq!( + f.harness.coordinator.stats().advances, + before, + "the refused transition never completed, so no boundary was added" + ); + assert!( + f.harness.coordinator.is_fenced(), + "a commit that refused after the world moved fences the epoch" + ); + f.shutdown().await; +} + // ------------------------------------------------------------------------------------------ // "A committed action is labelled as the transition that just ended, not the one about to // start."