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

View file

@ -168,16 +168,24 @@ export function readCommittedSnapshot(value: unknown): CommittedSnapshot {
const atBoundaryZero = u64(scope.step) === 0n;
for (const agent of agents) {
// "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)) {
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(
'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 {
descriptorRevision,
publisherIncarnation,

View file

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

View file

@ -4168,7 +4168,179 @@
},
"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",

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\"}}",
"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",
"type": "TransitionTrace",

View file

@ -414,7 +414,8 @@ impl DomainType for CommittedSnapshot {
controls.validate()?;
}
// "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
&& (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",
);
}
if self.scope.step > 0
&& (agent.selected_decision.is_none() || agent.applied_controls.is_none())
{
if agent.selected_decision.is_some() != agent.applied_controls.is_some() {
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()?;
if self.views.len() > MAX_VIEWS {
return err("CommittedSnapshot: at most 8 views");

View file

@ -940,12 +940,12 @@ pub const SCHEMAS: &[TypeSchema] = &[
opt(
"selectedDecision",
"TypedValue|null",
"null exactly at boundary 0",
"null at boundary 0 and at an installed boundary; null or present for every agent together",
),
opt(
"appliedControls",
"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
/// every outcome it returns is a named one.
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.
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.
query: Option<crate::publish::QueryService>,
pacing: Option<Pacing>,
@ -417,6 +419,7 @@ impl Coordinator {
serials: Serials::default(),
publisher,
session_descriptor: None,
descriptor_revision: DESCRIPTOR_REVISION,
query: None,
topics,
pacing: None,
@ -463,6 +466,11 @@ impl Coordinator {
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,
/// or faulted, per topic.
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, 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<()> {
let descriptor = match self.build_descriptor(DESCRIPTOR_REVISION) {
let mut descriptor = match self.build_descriptor(self.descriptor_revision) {
Ok(descriptor) => 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 {
Ok(outcome) => outcome,
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_published_descriptor_is_what_the_workers_attested_to,
a_stimulus_kind_the_descriptor_does_not_declare_is_refused,
a_restored_boundary_publishes_a_new_revision_and_no_transition,
);
all_modes!(
@ -787,6 +788,62 @@ async fn one_snapshot_carries_every_agent_in_the_composition(via: Via) {
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.