session: supportedStimuli is enforced, and the query method names are provisional

An undeclared stimulus kind is refused before the model is touched, proved by an
injection through Agent.Commit rather than by a unit call, so the declaration a
descriptor publishes is the thing the worker enforces.

The publishing-v1 section 2 amendment now says in its own words that
Session.GetDescriptor and Session.GetSnapshot are internal and provisional names,
which the later public v2 step may rename or supersede.
This commit is contained in:
dev 2026-09-22 18:47:10 +00:00
parent ed4cffcf59
commit f43fd4d9ff
3 changed files with 73 additions and 1 deletions

View file

@ -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

View file

@ -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,

View file

@ -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."