session: a restored boundary is an installed one, and the revision follows the composition

A group restore re-establishes a committed boundary this epoch did not run a
transition into, so its snapshot carries no decision and no controls for any
agent, and a fresh epoch is a new compositionDigest, so the descriptor takes the
next revision rather than republishing revision 1 with different contents.

CommittedSnapshot's rule becomes: null at boundary 0 and at an installed
boundary, always together, and for every agent or none -- a snapshot where one
fly acted and another did not would be two boundaries in one value. Dated
amendment to publishing-v1 section 3, with the schema set, the fixtures and the
TypeScript package moved together and the digest regenerated.
This commit is contained in:
dev 2026-09-22 19:24:44 +00:00
parent 4f2d4a5848
commit 7ce645f1dc
10 changed files with 393 additions and 16 deletions

View file

@ -91,6 +91,16 @@ interface CommittedSnapshot {
} }
``` ```
**Amendment, 2026-09-22 (PUBLISH-01).** "Null at initial boundary 0" is the rule for a
boundary this epoch *produced*. A group restore ([state/media](state-media-v1.md) section 5)
re-establishes a committed boundary `k > 0` that this epoch did not run a transition into, and
the abandoned epoch's decisions are not this session's to republish under a new epoch. So the
rule is: `selectedDecision` and `appliedControls` are null at boundary 0 and at a boundary
*installed* by a restore, present otherwise, and always **together** and for **every agent or
none**. A snapshot where one fly carries an action and another does not would be two different
boundaries in one value, and is refused. Without this, the section 6 requirement to publish the
recovery could not be met at all: the restored boundary's snapshot would be unrepresentable.
Publish only after all agent commits establish Ready(k). Decisions/controls describe the Publish only after all agent commits establish Ready(k). Decisions/controls describe the
transition ending at that boundary, null at initial boundary 0. Health updates are separate transition ending at that boundary, null at initial boundary 0. Health updates are separate
and never claim an uncommitted future boundary. Every transient media reference is a declared and never claim an uncommitted future boundary. Every transient media reference is a declared

View file

@ -168,16 +168,24 @@ export function readCommittedSnapshot(value: unknown): CommittedSnapshot {
const atBoundaryZero = u64(scope.step) === 0n; const atBoundaryZero = u64(scope.step) === 0n;
for (const agent of agents) { for (const agent of agents) {
// "Decisions/controls describe the transition ending at that boundary, null at initial // "Decisions/controls describe the transition ending at that boundary, null at initial
// boundary 0." (publishing-v1 section 3) // boundary 0." (publishing-v1 section 3, and its 2026-09-22 amendment for a boundary that
// was installed rather than produced.)
if (atBoundaryZero && (agent.selectedDecision !== null || agent.appliedControls !== null)) { if (atBoundaryZero && (agent.selectedDecision !== null || agent.appliedControls !== null)) {
fail('CommittedSnapshot: at boundary 0 selectedDecision and appliedControls are null'); fail('CommittedSnapshot: at boundary 0 selectedDecision and appliedControls are null');
} }
if (!atBoundaryZero && (agent.selectedDecision === null || agent.appliedControls === null)) { if ((agent.selectedDecision === null) !== (agent.appliedControls === null)) {
fail( fail(
'CommittedSnapshot: past boundary 0 every agent has a decision and applied controls', 'CommittedSnapshot: selectedDecision and appliedControls are null together or present together',
); );
} }
} }
// A boundary is produced by a transition or installed by one, and the whole snapshot says
// which: every agent carries the transition that ended here, or none does.
if (agents.some((a) => (a.selectedDecision === null) !== (agents[0].selectedDecision === null))) {
fail(
'CommittedSnapshot: either every agent carries the transition that ended here, or none does',
);
}
return { return {
descriptorRevision, descriptorRevision,
publisherIncarnation, publisherIncarnation,

View file

@ -1,8 +1,8 @@
{ {
"description": "contractDigest is the SHA-256 of the canonical schema set in schema-set.json.", "description": "contractDigest is the SHA-256 of the canonical schema set in schema-set.json.",
"contractDigest": "fa1f9671c098e0e5fe2d2ad9642d10eae39c6e751760eb1db7b51d5575898509", "contractDigest": "7f4b11d6737e5097c6889657479527174ddf496e50b60322b281e6c7490bcb4a",
"schemaSetVersion": 1, "schemaSetVersion": 1,
"schemaSetBytes": 27407, "schemaSetBytes": 27470,
"types": 54, "types": 54,
"enums": 11, "enums": 11,
"limits": 26 "limits": 26

View file

@ -4168,7 +4168,179 @@
}, },
"eventIds": [] "eventIds": []
}, },
"reason": "a committed transition has applied controls" "reason": "selectedDecision and appliedControls are null together or present together"
},
{
"name": "snapshot where one agent carries the transition and another does not",
"type": "CommittedSnapshot",
"value": {
"descriptorRevision": "7",
"publisherIncarnation": "pub-1",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "42"
},
"episodeId": "episode-1",
"sequence": "42",
"worldTime": {
"numerator": "700000000",
"denominator": "1"
},
"agents": [
{
"agentId": "fly-a",
"telemetry": {
"brainTicks": "2534",
"populationRateHz": 12.5,
"rates": [
{
"roleId": "kenyon",
"hz": 3.25
},
{
"roleId": "mbon",
"hz": 0.0
}
],
"learning": {
"enabled": true,
"updates": "4",
"changed": "2",
"signal": 0.5
}
},
"selectedDecision": {
"schema": {
"id": "gameboy.intent.v1",
"version": 1,
"digest": "e70451b26fb5462ec63729d8355912dad01c3963a95bec0e6a8452375717ad9d"
},
"value": {
"press": "a"
}
},
"appliedControls": {
"portId": "port-1",
"buttons": [
{
"id": "a",
"down": true
},
{
"id": "b",
"down": false
},
{
"id": "start",
"down": false
},
{
"id": "select",
"down": false
},
{
"id": "up",
"down": false
},
{
"id": "down",
"down": false
},
{
"id": "left",
"down": false
},
{
"id": "right",
"down": false
}
],
"axes": [
{
"id": "stick-x",
"value": 0.0
},
{
"id": "trigger",
"value": 0.0
}
]
}
},
{
"agentId": "fly-b",
"telemetry": {
"brainTicks": "2534",
"populationRateHz": 12.5,
"rates": [
{
"roleId": "kenyon",
"hz": 3.25
},
{
"roleId": "mbon",
"hz": 0.0
}
],
"learning": {
"enabled": true,
"updates": "4",
"changed": "2",
"signal": 0.5
}
},
"selectedDecision": null,
"appliedControls": null
}
],
"progress": {
"schema": {
"id": "pokemon.progress.v1",
"version": 1,
"digest": "80fa973157f334bb3208dfff6f7a2f298e79f73f07c72c33f5725975c6cff8e1"
},
"value": {
"rank": 10
}
},
"media": {
"views": [
{
"viewId": "screen",
"producedStep": "42",
"pixels": {
"storeId": "store-1",
"artifactId": "frame-1",
"generation": "1",
"byteLength": "92160",
"contentType": "image/x-rgba8",
"digest": null
}
}
],
"audio": [
{
"streamId": "mix",
"firstSample": "33600",
"sampleFrames": 800,
"samples": {
"storeId": "store-1",
"artifactId": "audio-1",
"generation": "1",
"byteLength": "6400",
"contentType": "audio/x-f32le",
"digest": null
},
"discontinuity": false
}
]
},
"eventIds": [
"evt-1"
]
},
"reason": "a boundary is produced or installed for the whole composition, never per agent"
}, },
{ {
"name": "trace whose commit acknowledgment is the old boundary", "name": "trace whose commit acknowledgment is the old boundary",

File diff suppressed because one or more lines are too long

View file

@ -2611,6 +2611,100 @@
"canonical": "{\"agents\":[{\"agentId\":\"fly-a\",\"appliedControls\":{\"axes\":[{\"id\":\"stick-x\",\"value\":0},{\"id\":\"trigger\",\"value\":0}],\"buttons\":[{\"down\":true,\"id\":\"a\"},{\"down\":false,\"id\":\"b\"},{\"down\":false,\"id\":\"start\"},{\"down\":false,\"id\":\"select\"},{\"down\":false,\"id\":\"up\"},{\"down\":false,\"id\":\"down\"},{\"down\":false,\"id\":\"left\"},{\"down\":false,\"id\":\"right\"}],\"portId\":\"port-1\"},\"selectedDecision\":{\"schema\":{\"digest\":\"e70451b26fb5462ec63729d8355912dad01c3963a95bec0e6a8452375717ad9d\",\"id\":\"gameboy.intent.v1\",\"version\":1},\"value\":{\"press\":\"a\"}},\"telemetry\":{\"brainTicks\":\"2534\",\"learning\":{\"changed\":\"2\",\"enabled\":true,\"signal\":0.5,\"updates\":\"4\"},\"populationRateHz\":12.5,\"rates\":[{\"hz\":3.25,\"roleId\":\"kenyon\"},{\"hz\":0,\"roleId\":\"mbon\"}]}}],\"descriptorRevision\":\"7\",\"episodeId\":\"episode-1\",\"eventIds\":[\"evt-1\"],\"media\":{\"audio\":[{\"discontinuity\":false,\"firstSample\":\"33600\",\"sampleFrames\":800,\"samples\":{\"artifactId\":\"audio-1\",\"byteLength\":\"6400\",\"contentType\":\"audio/x-f32le\",\"digest\":null,\"generation\":\"1\",\"storeId\":\"store-1\"},\"streamId\":\"mix\"}],\"views\":[{\"pixels\":{\"artifactId\":\"frame-1\",\"byteLength\":\"92160\",\"contentType\":\"image/x-rgba8\",\"digest\":null,\"generation\":\"1\",\"storeId\":\"store-1\"},\"producedStep\":\"42\",\"viewId\":\"screen\"}]},\"progress\":{\"schema\":{\"digest\":\"80fa973157f334bb3208dfff6f7a2f298e79f73f07c72c33f5725975c6cff8e1\",\"id\":\"pokemon.progress.v1\",\"version\":1},\"value\":{\"rank\":10}},\"publisherIncarnation\":\"pub-1\",\"scope\":{\"epoch\":\"epoch-1\",\"sessionId\":\"demo\",\"step\":\"42\"},\"sequence\":\"42\",\"worldTime\":{\"denominator\":\"1\",\"numerator\":\"700000000\"}}", "canonical": "{\"agents\":[{\"agentId\":\"fly-a\",\"appliedControls\":{\"axes\":[{\"id\":\"stick-x\",\"value\":0},{\"id\":\"trigger\",\"value\":0}],\"buttons\":[{\"down\":true,\"id\":\"a\"},{\"down\":false,\"id\":\"b\"},{\"down\":false,\"id\":\"start\"},{\"down\":false,\"id\":\"select\"},{\"down\":false,\"id\":\"up\"},{\"down\":false,\"id\":\"down\"},{\"down\":false,\"id\":\"left\"},{\"down\":false,\"id\":\"right\"}],\"portId\":\"port-1\"},\"selectedDecision\":{\"schema\":{\"digest\":\"e70451b26fb5462ec63729d8355912dad01c3963a95bec0e6a8452375717ad9d\",\"id\":\"gameboy.intent.v1\",\"version\":1},\"value\":{\"press\":\"a\"}},\"telemetry\":{\"brainTicks\":\"2534\",\"learning\":{\"changed\":\"2\",\"enabled\":true,\"signal\":0.5,\"updates\":\"4\"},\"populationRateHz\":12.5,\"rates\":[{\"hz\":3.25,\"roleId\":\"kenyon\"},{\"hz\":0,\"roleId\":\"mbon\"}]}}],\"descriptorRevision\":\"7\",\"episodeId\":\"episode-1\",\"eventIds\":[\"evt-1\"],\"media\":{\"audio\":[{\"discontinuity\":false,\"firstSample\":\"33600\",\"sampleFrames\":800,\"samples\":{\"artifactId\":\"audio-1\",\"byteLength\":\"6400\",\"contentType\":\"audio/x-f32le\",\"digest\":null,\"generation\":\"1\",\"storeId\":\"store-1\"},\"streamId\":\"mix\"}],\"views\":[{\"pixels\":{\"artifactId\":\"frame-1\",\"byteLength\":\"92160\",\"contentType\":\"image/x-rgba8\",\"digest\":null,\"generation\":\"1\",\"storeId\":\"store-1\"},\"producedStep\":\"42\",\"viewId\":\"screen\"}]},\"progress\":{\"schema\":{\"digest\":\"80fa973157f334bb3208dfff6f7a2f298e79f73f07c72c33f5725975c6cff8e1\",\"id\":\"pokemon.progress.v1\",\"version\":1},\"value\":{\"rank\":10}},\"publisherIncarnation\":\"pub-1\",\"scope\":{\"epoch\":\"epoch-1\",\"sessionId\":\"demo\",\"step\":\"42\"},\"sequence\":\"42\",\"worldTime\":{\"denominator\":\"1\",\"numerator\":\"700000000\"}}",
"digest": "d5ed83ccb866318b3cf17b53c8a5b1dea2f404b10649f6b640bf5f2b88a31435" "digest": "d5ed83ccb866318b3cf17b53c8a5b1dea2f404b10649f6b640bf5f2b88a31435"
}, },
{
"name": "committed snapshot at a boundary installed by a restore",
"type": "CommittedSnapshot",
"value": {
"descriptorRevision": "7",
"publisherIncarnation": "pub-1",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "42"
},
"episodeId": "episode-1",
"sequence": "42",
"worldTime": {
"numerator": "700000000",
"denominator": "1"
},
"agents": [
{
"agentId": "fly-a",
"telemetry": {
"brainTicks": "2534",
"populationRateHz": 12.5,
"rates": [
{
"roleId": "kenyon",
"hz": 3.25
},
{
"roleId": "mbon",
"hz": 0.0
}
],
"learning": {
"enabled": true,
"updates": "4",
"changed": "2",
"signal": 0.5
}
},
"selectedDecision": null,
"appliedControls": null
}
],
"progress": {
"schema": {
"id": "pokemon.progress.v1",
"version": 1,
"digest": "80fa973157f334bb3208dfff6f7a2f298e79f73f07c72c33f5725975c6cff8e1"
},
"value": {
"rank": 10
}
},
"media": {
"views": [
{
"viewId": "screen",
"producedStep": "42",
"pixels": {
"storeId": "store-1",
"artifactId": "frame-1",
"generation": "1",
"byteLength": "92160",
"contentType": "image/x-rgba8",
"digest": null
}
}
],
"audio": [
{
"streamId": "mix",
"firstSample": "33600",
"sampleFrames": 800,
"samples": {
"storeId": "store-1",
"artifactId": "audio-1",
"generation": "1",
"byteLength": "6400",
"contentType": "audio/x-f32le",
"digest": null
},
"discontinuity": false
}
]
},
"eventIds": [
"evt-1"
]
},
"note": "a restore re-establishes a committed boundary this epoch did not run a transition into",
"canonical": "{\"agents\":[{\"agentId\":\"fly-a\",\"appliedControls\":null,\"selectedDecision\":null,\"telemetry\":{\"brainTicks\":\"2534\",\"learning\":{\"changed\":\"2\",\"enabled\":true,\"signal\":0.5,\"updates\":\"4\"},\"populationRateHz\":12.5,\"rates\":[{\"hz\":3.25,\"roleId\":\"kenyon\"},{\"hz\":0,\"roleId\":\"mbon\"}]}}],\"descriptorRevision\":\"7\",\"episodeId\":\"episode-1\",\"eventIds\":[\"evt-1\"],\"media\":{\"audio\":[{\"discontinuity\":false,\"firstSample\":\"33600\",\"sampleFrames\":800,\"samples\":{\"artifactId\":\"audio-1\",\"byteLength\":\"6400\",\"contentType\":\"audio/x-f32le\",\"digest\":null,\"generation\":\"1\",\"storeId\":\"store-1\"},\"streamId\":\"mix\"}],\"views\":[{\"pixels\":{\"artifactId\":\"frame-1\",\"byteLength\":\"92160\",\"contentType\":\"image/x-rgba8\",\"digest\":null,\"generation\":\"1\",\"storeId\":\"store-1\"},\"producedStep\":\"42\",\"viewId\":\"screen\"}]},\"progress\":{\"schema\":{\"digest\":\"80fa973157f334bb3208dfff6f7a2f298e79f73f07c72c33f5725975c6cff8e1\",\"id\":\"pokemon.progress.v1\",\"version\":1},\"value\":{\"rank\":10}},\"publisherIncarnation\":\"pub-1\",\"scope\":{\"epoch\":\"epoch-1\",\"sessionId\":\"demo\",\"step\":\"42\"},\"sequence\":\"42\",\"worldTime\":{\"denominator\":\"1\",\"numerator\":\"700000000\"}}",
"digest": "79a0ef10ed65c0720c34c7d99de1fe87ec2f0c26028994562cc4655b0f6acd1f"
},
{ {
"name": "transition trace", "name": "transition trace",
"type": "TransitionTrace", "type": "TransitionTrace",

View file

@ -414,7 +414,8 @@ impl DomainType for CommittedSnapshot {
controls.validate()?; controls.validate()?;
} }
// "Decisions/controls describe the transition ending at that boundary, null at // "Decisions/controls describe the transition ending at that boundary, null at
// initial boundary 0." (publishing-v1 section 3) // initial boundary 0." (publishing-v1 section 3, and its 2026-09-22 amendment for
// a boundary that was installed rather than produced.)
if self.scope.step == 0 if self.scope.step == 0
&& (agent.selected_decision.is_some() || agent.applied_controls.is_some()) && (agent.selected_decision.is_some() || agent.applied_controls.is_some())
{ {
@ -422,14 +423,24 @@ impl DomainType for CommittedSnapshot {
"CommittedSnapshot: at boundary 0 selectedDecision and appliedControls are null", "CommittedSnapshot: at boundary 0 selectedDecision and appliedControls are null",
); );
} }
if self.scope.step > 0 if agent.selected_decision.is_some() != agent.applied_controls.is_some() {
&& (agent.selected_decision.is_none() || agent.applied_controls.is_none())
{
return err( return err(
"CommittedSnapshot: past boundary 0 every agent has a decision and applied controls", "CommittedSnapshot: selectedDecision and appliedControls are null together or present together",
); );
} }
} }
// A boundary is produced by a transition or installed by one, and the whole snapshot
// says which: every agent carries the transition that ended here, or none does. A
// mixture would be one fly's action beside another fly's silence at the same boundary.
if self
.agents
.iter()
.any(|a| a.selected_decision.is_some() != self.agents[0].selected_decision.is_some())
{
return err(
"CommittedSnapshot: either every agent carries the transition that ended here, or none does",
);
}
self.progress.validate()?; self.progress.validate()?;
if self.views.len() > MAX_VIEWS { if self.views.len() > MAX_VIEWS {
return err("CommittedSnapshot: at most 8 views"); return err("CommittedSnapshot: at most 8 views");

View file

@ -940,12 +940,12 @@ pub const SCHEMAS: &[TypeSchema] = &[
opt( opt(
"selectedDecision", "selectedDecision",
"TypedValue|null", "TypedValue|null",
"null exactly at boundary 0", "null at boundary 0 and at an installed boundary; null or present for every agent together",
), ),
opt( opt(
"appliedControls", "appliedControls",
"PortControl|null", "PortControl|null",
"null exactly at boundary 0; the agent's assigned port", "null with selectedDecision; the agent's assigned port",
), ),
], ],
}, },

View file

@ -321,9 +321,11 @@ pub struct Coordinator {
/// The publication boundary. Everything this session publishes goes through it, and /// The publication boundary. Everything this session publishes goes through it, and
/// every outcome it returns is a named one. /// every outcome it returns is a named one.
publisher: crate::publish::Publisher, publisher: crate::publish::Publisher,
/// The composition as published. Built once from what the live participants attested to, /// The composition as published. Built from what the live participants attested to,
/// never restated from the configuration that asked for them. /// never restated from the configuration that asked for them.
session_descriptor: Option<SessionDescriptor>, session_descriptor: Option<SessionDescriptor>,
/// The revision the next descriptor publication carries.
descriptor_revision: u64,
/// The read-only repair service. Held so it stops with the session. /// The read-only repair service. Held so it stops with the session.
query: Option<crate::publish::QueryService>, query: Option<crate::publish::QueryService>,
pacing: Option<Pacing>, pacing: Option<Pacing>,
@ -417,6 +419,7 @@ impl Coordinator {
serials: Serials::default(), serials: Serials::default(),
publisher, publisher,
session_descriptor: None, session_descriptor: None,
descriptor_revision: DESCRIPTOR_REVISION,
query: None, query: None,
topics, topics,
pacing: None, pacing: None,
@ -463,6 +466,11 @@ impl Coordinator {
self.session_descriptor.as_ref() self.session_descriptor.as_ref()
} }
/// The revision the last published descriptor carried.
pub fn descriptor_revision(&self) -> u64 {
self.descriptor_revision
}
/// What this session published and what became of it: accepted, refused by an observer, /// What this session published and what became of it: accepted, refused by an observer,
/// or faulted, per topic. /// or faulted, per topic.
pub fn ledger(&self) -> &crate::publish::Ledger { pub fn ledger(&self) -> &crate::publish::Ledger {
@ -2651,11 +2659,28 @@ impl Coordinator {
} }
/// Publishes the composition and starts the read-only repair service beside it. /// Publishes the composition and starts the read-only repair service beside it.
/// Publishes the composition, advancing the revision when the composition changed.
///
/// A revision identifies a composition, so republishing an unchanged one keeps its number
/// and a changed one takes the next: a group restore establishes a fresh epoch, which is a
/// new `compositionDigest`, and a consumer that held the old revision has to be told rather
/// than handed the same number with different contents. The publisher refuses the second
/// case outright, so this is where the number moves.
async fn publish_descriptor(&mut self) -> Outcome<()> { async fn publish_descriptor(&mut self) -> Outcome<()> {
let descriptor = match self.build_descriptor(DESCRIPTOR_REVISION) { let mut descriptor = match self.build_descriptor(self.descriptor_revision) {
Ok(descriptor) => descriptor, Ok(descriptor) => descriptor,
Err(e) => return Err(self.fail_now(e, "descriptor")), Err(e) => return Err(self.fail_now(e, "descriptor")),
}; };
if let Some(published) = &self.session_descriptor {
let mut same = descriptor.clone();
same.revision = published.revision;
if same != *published {
self.descriptor_revision += 1;
descriptor.revision = self.descriptor_revision;
self.audit
.push(format!("descriptor-revision:{}", self.descriptor_revision));
}
}
let outcome = match self.publisher.publish_descriptor(&descriptor).await { let outcome = match self.publisher.publish_descriptor(&descriptor).await {
Ok(outcome) => outcome, Ok(outcome) => outcome,
Err(e) => return Err(self.fail_now(e, "descriptor")), Err(e) => return Err(self.fail_now(e, "descriptor")),

View file

@ -38,6 +38,7 @@ both_transports!(
the_query_service_answers_reads_and_nothing_else, the_query_service_answers_reads_and_nothing_else,
the_published_descriptor_is_what_the_workers_attested_to, the_published_descriptor_is_what_the_workers_attested_to,
a_stimulus_kind_the_descriptor_does_not_declare_is_refused, a_stimulus_kind_the_descriptor_does_not_declare_is_refused,
a_restored_boundary_publishes_a_new_revision_and_no_transition,
); );
all_modes!( all_modes!(
@ -787,6 +788,62 @@ async fn one_snapshot_carries_every_agent_in_the_composition(via: Via) {
f.shutdown().await; f.shutdown().await;
} }
/// A group restore re-establishes a committed boundary this epoch did not run a transition
/// into. Two things follow, and both are published rather than inferred: the composition is a
/// new one, because a fresh epoch is a new `compositionDigest`, so the descriptor takes the
/// next revision; and the restored boundary carries no decision and no controls for any agent,
/// because the abandoned epoch's actions are not this session's to republish.
async fn a_restored_boundary_publishes_a_new_revision_and_no_transition(via: Via) {
let mut f = started(via).await;
let checkpoint = id("ck-1");
f.harness.coordinator.run(1).await.expect("one transition");
let outcome = within("checkpoint", f.harness.coordinator.checkpoint(&checkpoint))
.await
.expect("a committed checkpoint");
assert!(matches!(outcome, fly_session::state::SaveOutcome::Committed { .. }), "{outcome:?}");
let first = f.harness.coordinator.descriptor_revision();
// The snapshot of a boundary this epoch produced does carry the transition.
let produced = {
let state = f.harness.coordinator.published_state();
let state = state.lock().expect("not poisoned");
state.latest_snapshot().expect("boundary 1").clone()
};
assert_eq!(produced.scope.step, 1);
assert!(produced.agents.iter().all(|a| a.selected_decision.is_some()));
// Fail the epoch and restore into a fresh one.
f.harness.kill(&fly_a()).await;
f.harness.coordinator.step().await.expect_err("a dead participant fails the epoch");
within("replace", f.harness.replace_all_participants())
.await
.expect("replacements");
within(
"restore",
f.harness.coordinator.restore(Some(&checkpoint), &id("e2")),
)
.await
.expect("a coherent group restore");
assert_eq!(
f.harness.coordinator.descriptor_revision(),
first + 1,
"a fresh epoch is a new composition, so the revision advanced"
);
let restored = {
let state = f.harness.coordinator.published_state();
let state = state.lock().expect("not poisoned");
state.latest_snapshot().expect("the restored boundary").clone()
};
assert_eq!(restored.scope.step, 1, "the same committed boundary");
assert_eq!(restored.descriptor_revision, first + 1);
assert!(
restored.agents.iter().all(|a| a.selected_decision.is_none() && a.applied_controls.is_none()),
"an installed boundary carries no transition for any agent"
);
f.shutdown().await;
}
// ------------------------------------------------------------------------------------------ // ------------------------------------------------------------------------------------------
// Application-owned state and cues, bounded events, and the read-only repair service. // Application-owned state and cues, bounded events, and the read-only repair service.