Compare commits

...
Sign in to create a new pull request.

231 commits

Author SHA1 Message Date
acamilo
862e343e1c docs: v0.6.2 status
Some checks failed
ci / node 22 (test + typecheck) (push) Has been cancelled
ci / rust stable (cargo test --workspace --release) (push) Has been cancelled
ci / infra/tests/lint.sh (push) Has been cancelled
ci / playwright apps/stage (allowed to fail) (push) Has been cancelled
2026-09-23 23:48:20 +00:00
acamilo
d0ce10e3c9 Merge fix/loop-row61: a trainer's challenge is the cartridge's until its battle is over 2026-09-23 23:48:20 +00:00
acamilo
9b117dc4b2 docs: 12.25 and the row 61 audit on top of row 59, the bubble, game.scene in macros mode 2026-09-23 23:47:36 +00:00
acamilo
cff15804fb state, tests: the challenge covers the "!" bubble too, the doc block back above its impl, and the ROM test asserts no pad inside a challenge 2026-09-23 23:47:36 +00:00
acamilo
d6597b3e98 docs: the row 61 audit, a trainer's challenge text walled the forest's corridor 2026-09-23 23:28:17 +00:00
acamilo
0117a85354 docs: macros.md 12.24, a trainer's challenge is the cartridge's until its battle is over 2026-09-23 23:28:12 +00:00
acamilo
c78ca352f6 tests: the ROM proof from the forest's south gate checkpoint 2026-09-23 23:28:05 +00:00
acamilo
0e366cd4e4 state: a trainer's challenge is the cartridge's until its battle is over, in the macros' own scene 2026-09-23 23:27:54 +00:00
acamilo
16bc65b5a8 survey: FLY_PROBE_CATCH_FRAME reads the first free frame from a given one, FLY_PROBE_WHOLE prints a big map whole 2026-09-23 23:27:54 +00:00
acamilo
510727ccc0 docs: v0.6.1 status
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-23 23:08:10 +00:00
acamilo
f32352bd5e Merge fix/loop-row59: the map graph as the cartridge's headers and warps draw it, pieces and all 2026-09-23 23:08:09 +00:00
acamilo
9ccc0592b1 docs: macros.md 12.24 and the row 59 audit 2026-09-23 22:33:47 +00:00
acamilo
437dfa84a9 tests: from the live Route 4 checkpoint, the fly goes into Mt. Moon and Route 4's west doors are not a ring 2026-09-23 22:33:47 +00:00
acamilo
d181f952a7 tests: from the badge, the road to Mt. Moon is not a ring at the Pewter end, and Route 4's doors and sides are the cartridge's 2026-09-23 22:23:27 +00:00
acamilo
5147ab1a87 geography: a header connection no step on foot crosses is named and is not a way out; Pallet's shore, the sea routes, the League fence 2026-09-23 22:23:01 +00:00
acamilo
97a97a501d geography: a split map is pieces with their own doors and neighbours; Route 4 and Mt. Moon's lower floors, the hop matched on where an exit lands 2026-09-23 22:23:01 +00:00
acamilo
4108fec5ce macros: a push-back is a refusal once the overworld has stayed the fly's for half a second, not on the first frame of it 2026-09-23 22:23:01 +00:00
acamilo
d85ee5026f geography: Route 3/4, 14/15 and 24/25 as the headers connect them, and Mt. Moon's doors on Route 4 2026-09-23 22:23:01 +00:00
acamilo
288e958d19 survey: FLY_PROBE_SAVE_RANK writes the first safe overworld frame at a rung as a checkpoint 2026-09-23 22:23:01 +00:00
acamilo
4d82f7db74 Merge refactor/legacy-frame: FND-01, one LegacyFrame for the service and every harness, FLY_TRACE and the sugar journal 2026-09-23 21:43:45 +00:00
acamilo
2f7b7cebd0 docs: v0.6.0 status
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-23 21:23:57 +00:00
acamilo
ff49b56e2c docs: the explore row stays "new place" 2026-09-23 21:23:40 +00:00
acamilo
b484a077d4 stage: the explore row reads "new place" again (the operator, 2026-09-23) 2026-09-23 21:23:05 +00:00
acamilo
9301e395e8 docs: the trap hunt and the bench run the service's frame since FND-01, and hunts across the change do not compare 2026-09-23 20:15:20 +00:00
acamilo
9e2f90749f flysim: FLY_TRACE's boundary half read back with fly-session-types, the journal held to the service test, rustfmt on the new files 2026-09-23 20:15:20 +00:00
acamilo
60f09b79a3 flysim: every harness runs the legacy frame: the trap hunt, the benches, the ROM tests' and the probe's stub drivers
trap_hunt, palette_bench and room_escape ran the frame through NeuralAgent::tick, which installs
a frame and its rewards after the next ticks: one frame behind the stream, with the ratchet
observed without the objective signal and a rollback that never re-observed the scene. They now
restore the way the stream restores and run LegacyFrame::transition and ::boundary, looking in
through FrameObserver; FLY_TRACE works in each of them. The stub-readout drivers of
rom_macros_mode, rom_catch and scene_probe run LegacyFrame::execute and ::stub_advance, which are
the same calls they made, in the same order.
2026-09-23 20:15:20 +00:00
acamilo
ad1c0e3693 flysim: a frame-stamped sugar journal in the hot directory
Every admitted sugar and operator reward pulse is appended to sugar-journal.jsonl with the frame
counter it was applied at, which is the step of the next transition in FLY_TRACE, so a shadow run
can replay the audience's input. Append-only, not checkpointed, never read back into the fly.
2026-09-23 20:14:01 +00:00
acamilo
94cc91369a flysim: the legacy frame is one type, flysim::frame::LegacyFrame, and the sim loop runs it
prepare, execute, advance, evaluate, commit, then the boundary's ratchet capture and rollback,
in the order the stream runs them. The frame owns the remainder, the frame counter, the frame on
screen, the mask and the blocked-direction window; the sim loop keeps the feed, the event log,
the milestone archive between commit and boundary, and the checkpoints. Two moves between
disjoint state change no byte: the visual frame is installed at commit, and the stimulations
follow the scene's observation. The FLY_TRACE of a run from the rung-10 checkpoint is
byte-identical to the one the inline hooks wrote.
2026-09-23 20:14:00 +00:00
acamilo
ff74c07622 flysim: FLY_TRACE, a per-frame trace of the legacy loop in the step trace's field names
One JSON line per transition: ticks and the exact remainder, digests of the rates, of the
transition's spikes, of the frame and of work RAM, the decision and the mask, macro and reward
events in order, the rank, and the boundary's slot saves, rollback and captures in the shape of
TraceBehaviour.boundaryActions and TraceOperational.captures. Off unless FLY_TRACE names a file.
Recorded inline in step_frame first, so the frame's extraction can be held to it.
2026-09-23 20:14:00 +00:00
acamilo
bc117ad470 flybrain-gb: say what a move with power does not promise 2026-09-23 19:23:48 +00:00
acamilo
fa021918b4 Merge fix/loop-row60: a move the cartridge would refuse is not dealt beside one that works 2026-09-23 19:23:43 +00:00
acamilo
4591e7907a docs: macros.md 12.23, macros-wram.md section 13, the row 60 audit 2026-09-23 17:59:29 +00:00
acamilo
07f88c443a tests: the ROM proof from the Route 1 checkpoint 2026-09-23 17:23:05 +00:00
acamilo
b33af9e772 scene_probe: the route survey prints the battle bytes a MOVE n button's effect rests on 2026-09-23 17:23:05 +00:00
acamilo
a02d5f7995 trap_hunt: payouts by kind, battle lengths and wins, and MOVE n on a move without effect 2026-09-23 17:23:05 +00:00
acamilo
f3e3e9cf98 palette: a move the cartridge answers with nothing is not dealt beside one it does not
Row 60: TAIL WHIP against a Pidgey at DEFENSE -6 was MOVE 2 183 times. The
rule is PP's: a refused move leaves the pad while another move is usable, and
with none usable the moves stay as PP deals them, so an open list never
comes down to BACK alone.
2026-09-23 17:23:05 +00:00
acamilo
438b2d8540 state: the move table, and whether the battle engine will answer a move with nothing
move_data reads a row of Moves from the cartridge image ($0E:$4000, each row
checked against its own id); move_without_effect answers the refusals the
effect routines make on bytes already in WRAM: a stat stage at its limit or a
stat at 1/999, Mist or a substitute against a stat-lowering move, a status
move against a statused, Poison-type or (Electric) Ground-type target.
MacroState::move_without_effect defaults to false.
2026-09-23 17:23:05 +00:00
acamilo
c8ecf06db5 symbols: the stat stages and the enemy's status, type and stats, resolved from wram.asm
resolve_wram.py now follows the decomp's const and _RS counters and a struct
macro's field labels, so battle_struct fields resolve and 77 of 81 pinned
addresses are re-derived with no disagreement.
2026-09-23 17:23:05 +00:00
acamilo
37adeb9417 docs: a map script's text can pay talk when it names the thing in front, and where it can 2026-09-23 17:22:11 +00:00
acamilo
5d527f0910 Merge feat/engage-reward: the fly is paid for talking to people indoors and picking up items, not for leaving buildings (adapter v7) 2026-09-23 17:20:02 +00:00
acamilo
56b02ff758 rewards: a frame on which no item bit moved reads nothing more 2026-09-23 14:34:10 +00:00
acamilo
8d1d5c44f5 docs: the engagement rewards, and the v6 migration in deploy examples
rewards-learning.md carries the v7 table, the definition of indoors, the four
conditions a conversation pays under, the item and hidden-item reads, the seed,
boundary's indoor rule with the warp-transition frame it was measured on, the
feed-kind choice and the ticker word, and the honesty note on paying for a
conversation. flysim.md records the v6 -> v7 migration; macros-wram.md the six
new reads; the architecture tour and integration.md point at the live catalog.
The deploy comments, example.env and the runbook name FLY_ACCEPT_ADAPTERS=
pokered-unique8-v6 for the v0.6.0 deploy.
2026-09-23 14:34:10 +00:00
acamilo
3cf706c96a rewards: read the conversation's argument once the cartridge has written it, and a ROM test
The first cartridge run found what the synthetic trace could not. In the
Viridian Forest north gate the font bit rose on one frame and DisplayTextID's
argument reached wSpriteIndex twenty frames later: DisplayTextIDInit loads the
font's tiles into VRAM first. Until then the byte still names the previous
text's subject, which may be the person in front of the fly from a conversation
that did not pay. So the watch now remembers the byte from the last ready
frame and reads it once, when it changes or after 45 samples, and only while
the bottom dialogue box is drawn (the start menu draws its own elsewhere); a
box that closes first is read on the closing sample if the dialogue box was up.

tests/rom_engage.rs, gated on FLY_ROM and FLY_ENGAGE_CHECKPOINT: from the rung-10
Pewter checkpoint, with the checkpoint's own v6 ledger restored (the migration,
on real game state), a scripted walker goes down Route 2 into the forest's north
gate, talks to the old man three times (one payout, 0.10) and picks up the
Antidote ball at (25, 11) (one payout, 0.15, "FOUND ITEM #11"); pressing A at
the empty tile pays nothing, and after rolling the emulator back to before the
pickup, taking it again pays nothing. No boundary payout lands while an indoor
header is loaded.

It also measured the warp transition: for about thirty frames of
PlayMapChangeSound wCurMap already names the destination while the tileset and
warp table are still the map being left, so the exit the fly stands on is
classified by the map it belongs to -- a town door still pays its on-exit half,
keyed under the building's id as before, and a building's door does not. A unit
test pins both directions.
2026-09-23 14:34:10 +00:00
acamilo
2d288b0146 stage: the explore row reads "new find"
Since pokered-unique8-v7 the explore feed kind carries four adapter rules --
new ground, an exit, a conversation indoors, an item picked up -- and "new
place" was true of the first two only. The per-game copy now names what all
four are; the feed protocol and the kind set are unchanged.
2026-09-23 14:34:10 +00:00
acamilo
52811bf512 session types: the legacy composition names pokered-unique8-v7
The example composition's adapter and FLYSIM01 string move to v7 with the live
adapter, and the fixtures are regenerated by update_fixtures; valid.json and
invalid.json's hand-written cases follow, with the one deliberately mismatched
string moving from v5 to v6 so it is still the previous adapter.
2026-09-23 14:34:10 +00:00
acamilo
2e7eed0a78 rewards: talk and item, boundary indoors pays nothing, adapter v7 with a v6 migration
The operator's decision of 2026-09-23: pay the fly for engaging with what is
inside a building, and stop paying it for walking back out of one. Chosen over
a pad rule and over weighting the choice, and like v0.5.0's catch reward it is
a catalog change, not a loop-review fix.

Indoors. engage::indoor is two of the cartridge's own tables: not outside by
CheckIfInOutsideMap (tileset OVERWORLD or PLATEAU; WarpFound2 labels the other
branch .indoorMaps) and not a place BikeRidingTilesets lets the bike be ridden
(OVERWORLD, FOREST, UNDERGROUND, SHIP_PORT, CAVERN). That is every house, mart,
center, gym, gate, lab, museum, ship, tower, mansion and hideout, and not
Viridian Forest, a cave, the Underground Path or the dock.

`talk`, +0.10, 100 ms, the catalog's tenth kind. Paid when the text box closes
on a conversation that (1) opened on the sample after one where the fly had the
joypad (state::controllable) and was standing still (wWalkCounter zero, the only
state the overworld reads A in) on the same tile; (2) is with the thing in
front: DisplayTextID copies its argument into wSpriteIndex, a sprite slot up to
wNumSprites whose sprite stands on the tile the player faces (or one further
across a counter, on a tileset with counter tiles), or a text id matching the
sign on that tile; an item ball is not a person; (3) opened indoors; (4) closed
on the same map. Keyed talk:<map>:sprite:<slot> / talk:<map>:sign:<id> in the
lifetime `seen` ledger, which is checkpointed and survives a rollback -- not the
macros' session `talked` ledger.

`item`, +0.15, 120 ms, the eleventh. An item ball is a toggleable sprite of this
map (wToggleableObjectList) whose wMapSpriteExtraData is (item id, 0), the shape
LoadMapHeader writes for an ITEM object_event and nothing else; PickUpItem sets
its global bit in wToggleableObjectFlags after GiveItem succeeds. A hidden item
is a bit of wObtainedHiddenItemsFlags, set by FoundHiddenItemText after GiveItem
and by nothing else. Either pays when its bit rises between two playable
samples, once per item (item:<global> / hidden:<index>) for the ledger's life.

`boundary` still writes every key indoors, so exit_visited answers what it did
and the macros see no change, but emits nothing on an indoor map. Outdoor,
forest and cave exits pay as before.

v6 -> v7. STATE_VERSION stays 4 and no field is added: the new ledgers are keys
in `seen`. A v6 state restores with no talk: keys, and the first playable sample
that finds `items:seeded` absent writes one key per item the game already shows
as taken, pays for none, and marks the seed -- so a rollback that un-takes a v6
pickup cannot pay for it. The two item balls a script reveals (the Rocket
Hideout's Silph Scope and Lift Key, toggles $87 and $88, the only ITEM entries
toggleable_objects.asm starts OFF) are left out of the seed. MIGRATES_FROM is
["pokered-unique8-v6"]; v5 is no longer migrated.

Feed kinds: both publish on `explore`, the family of new ground and a door found,
at the same quiet scale; not `area` (maps, notable), `story` or `wildwin`. No
feed-protocol change.

The compatibility string differs from main's in exactly one segment:
pokered-unique8-v6 -> pokered-unique8-v7.

Tests: catalog values and order; indoor over all 24 tilesets; a talk pays once
per (map, object), not while the box is open, not re-talked, not outdoors/in the
forest/in a cave, not for text opened with the joypad taken, simulated, scripted,
mid-step, about someone not in front, or the start menu; not across a warp or a
rollback; counter reach only with counter tiles; item balls, people, trainers,
hidden items, the seed and the script-shown balls; boundary indoors records and
pays nothing; a v6 state and a v6 FLYSIM01 envelope migrate with the new ledgers
empty and the items seeded. rom.rs's bedroom walk now proves the stairs are
recorded and unpaid on the cartridge.
2026-09-23 14:34:10 +00:00
acamilo
ed0080ab2c symbols: the six WRAM names the engagement rewards read
wSpriteIndex, wWalkCounter, wMapSpriteExtraData, wToggleableObjectFlags,
wToggleableObjectList and wObtainedHiddenItemsFlags, resolved by
tools/resolve_wram.py from ram/wram.asm at the pinned commit and bracketed by
addresses symbols.rs already carries. Two of the brackets needed the tool to
count NUM_STATS and NUM_CITY_MAPS, which the decomp defines as const_value over
an enumeration; both are now counted up to the line that defines them.
2026-09-23 14:34:10 +00:00
acamilo
7784a9d172 docs: v0.5.5 status
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-23 13:43:21 +00:00
acamilo
6fd6d840dc Merge fix/loop-row58: the gym's people are there when the screen does not draw them, and the game's own moves write no ledger 2026-09-23 13:43:18 +00:00
acamilo
0f510c93aa infra(05-deploy): refuse a bad FLY_FEED_VIA before the release is flipped, not half way through 2026-09-23 13:13:59 +00:00
acamilo
0ca5f000d6 Merge feat/fly-edge-feed: EDGE-01, the feed over flybus behind FLY_FEED_VIA, served by fly-edge 2026-09-23 13:13:18 +00:00
acamilo
21701579a3 docs: the row 58 audit 2026-09-23 13:08:40 +00:00
acamilo
fc7fdffa6c docs: flybus.md, the reachable worst case and the known limits of the feed on the bus
Review round 1. The sizing amendment's worst case is one socket client with
all four subscriptions (15 snapshots, about 1.8 MB, 472,061 bytes measured
because fan-out shares artifacts), not seven stuck subscribers. The lifecycle
amendment gains deploy-time validation, the flyedge cpuset and the absolute
bus dir; a known-limits list records what review round 1 left as notes: feed
counters off the container, store quota per router, rollback while in bus
mode, and the old fixtures.
2026-09-23 11:59:40 +00:00
acamilo
4b1559b45a fly-edge, flysim: a taken feed port is named as such; FLY_BUS_DIR must be absolute; worst case restated
Review round 1. A bind failure after subscribing was logged once as
"waiting for the feed bus". session() now ends as Unreachable, BindFailed or
BusLost, each logged as what it is (once per streak), and
fly_edge_bind_failures_total counts the second; a new parity test holds the
port, sees the edge fail to bind without claiming to serve, frees it and gets
served.

feed.bus_dir (FLY_BUS_DIR) must be absolute and non-empty, checked in either
mode, since flysim and the edge each resolve it and a relative path would
let them disagree.

The sizing worst case was 7 stuck subscribers; that seat does not exist,
since edge.sock admits one client. It is that client with all 4
subscriptions it may open: 4*3+1+2 = 15 snapshots, about 1.8 MB. feedbus's
comment and unit test say so, and stall.rs's hoarding scenario now takes all
four subscriptions, checks a fifth and a second connection are refused, and
bounds the store at 15 snapshots.
2026-09-23 11:59:26 +00:00
acamilo
2da7f688a8 infra: FLY_FEED_VIA is validated at deploy and read case-blind by the watchdog; flyedge gets the page's CPUs
Review round 1. flysim reads FLY_FEED_VIA case-insensitively, so check 2 must
too: with Bus in fly.env it read flysim's zeroed counters and would have
escalated to restarting flystage and flycast every pass. It now lowercases.
05-deploy.sh runs the value through feed_via_normalize (lib/common.sh) and
dies on anything but direct|bus, writing the lowercased word, so a typo is a
deploy refusal instead of a flysim boot loop.

lint's fly.target check read only the first physical Wants=/Requires= line;
target_pulls joins backslash continuations and drops comments, with a
fixture that names a unit only on a continuation line. The cpuset loop writes
a flyedge drop-in on the page CPUs, and lint holds it.
2026-09-23 11:50:11 +00:00
acamilo
ae3b15ab70 tests: the ROM proof from the gym-door checkpoint
From the row-58 checkpoint, 30 brain minutes on the stub rotation: at
most three gym arrivals end in the fly walking straight back out inside
ten seconds, and the fly goes up the room to row 6 or above, where the
Jr. Trainer stands. Base: one arrival, back out in 309 frames, highest
row 11 -- fails. Branch: 4 arrivals, 1 back out, 30,879 frames in the
gym, highest row 2, beside the leader. Rung 11 is printed, not asserted.
2026-09-23 11:48:57 +00:00
acamilo
04657d3324 docs: macros.md 12.22, macros-wram.md section 12 2026-09-23 11:48:57 +00:00
acamilo
2b1a6c0dea watchdog: check 10 reads the reward events, and flags a window busy going nowhere
Row 58's pad was GO OBJECTIVE into the Pewter Gym and GO OUT straight
back out for 25 minutes, diluted by eight other names, every macro done:
ten distinct names, so the four-name sequence rule could not fire, no
macro near 95%, nothing refused or blocked, and the exploration count
flat. What the window did not have was a reward event.

The stream carries the reward events beside the macros, and one more
rule reads them behind the same no-new-ground gate: WD_LOOP_BUSY_MIN
(100) decisions and no reward in the window, on two probes running, is
'unrewarded'. fly_loop_rewards is exported and loop.json carries
window.rewards. Run against the live row-58 log it flags (211 decisions,
0 rewards, 10 names) where the rules before it did not. Still never
acts: the fixture's two new cases restart nothing.
2026-09-23 11:48:57 +00:00
acamilo
28bd980e65 survey: the route probe prints the room whole, its people, and the rung at the end
The catch dump prints a small map whole with its people drawn and off
the screen, each person's ledger entries and whether a route reaches
them; outcomes are keyed by the map they finished on, the trace line
carries the seam's bytes (wCurOpponent included), and the drive ends
with the rank. How row 58's mechanism was read.
2026-09-23 11:48:57 +00:00
acamilo
ddf0743395 a trainer's challenge is not the cartridge refusing a step
Section 12.4 and row 37 read a macro the cartridge ended by taking the
joypad as a refusal and wrote the target blocked and the tile pushed on
the spot. A trainer who sees the fly takes the joypad the same way. In
the Pewter Gym the walk toward the leader crossed the Jr. Trainer's
sight line, and BROCK went into the blocked ledger for ten brain minutes
while the fly lost, blacked out and walked back to a room whose way out
was the pad again.

The entries now wait for the cartridge to give the joypad back: back in
the overworld is a refusal, written as before; a battle is a battle and
teaches the ledgers nothing. Tests for the rung's people off the screen,
facing one of them, and the challenge; the two push-back tests now hand
the joypad back before they read the ledgers.
2026-09-23 11:48:57 +00:00
acamilo
1ba5c80053 tests: stall.rs gates only the pacing claim; the rates move to an ignored perf test
Review round 1, B1. The p99 sleep-overshoot bound measured the OS scheduler
and the published*2 bound failed whenever a starved debug publisher
coalesced, which it is designed to do, so the workspace gate went red on a
loaded box. The three gated tests keep what the slice claims: pacer lag 0,
no watch send held by a consumer (50 ms bound), no refused publication,
something reaches the bus, a bounded store, and a healthy client that reaches
the newest snapshot. The overshoot and throughput bounds are in
the_three_scenarios_keep_their_rates, #[ignore]d.
2026-09-23 11:46:45 +00:00
acamilo
3c614c87f4 docs: flybus.md amendments for the feed on the bus, its sizing and its store lifecycle
The two pending decisions, taken for the feed with EDGE-01 and dated:
sizing from the measured 122,367-byte snapshot (the frame is 92,160 bytes,
not the 1.2 MB the list assumed) with the worst case of seven stuck latest
subscribers at about 3 MB inside a 32 MiB tmpfs store; and the lifecycle,
flysim owning the router under /run/fly/bus and starting first, the edge
After= and Requires= it and reconnecting by itself across a crash. Plus the
design as built, the tour's pointer, the example config and the flybus
README's no-longer-true line.
2026-09-23 11:41:28 +00:00
acamilo
d3f98ae4f1 flysim: stop the bus runtime under the publisher at shutdown, not the router first
Shutting the router down first raced the last publish and logged a refusal
on every clean stop. The publisher ends on its own when the watch sender goes;
the edge sees the socket close either way.
2026-09-23 11:41:28 +00:00
acamilo
3d9a08d0be infra: flyedge.service, off by default, and the feed bus through build, deploy and watchdog
flyedge.service runs /opt/fly/current/fly-edge After= and Requires=
flysim.service, with its metrics on loopback :9102 and a
ConditionPathExists so a release without the binary leaves it inactive. It is
in no target and 07-enable.sh does not enable it; the header has the switch
and the way back.

build-flysim.sh also builds fly-edge beside the flysim binary and
package-release.sh ships it when present. 05-deploy.sh writes
FLY_FEED_VIA (default direct) into fly.env, flysim.service names
FLY_BUS_DIR=/run/fly/bus and tmpfiles creates it. Watchdog check 2 reads the
feed counters from whoever serves the feed: flyedge when fly.env says bus.
lint.sh holds all of that, and drives check 2's choice against a fixture.
2026-09-23 11:41:28 +00:00
acamilo
34c7a56b25 tests: the edge writes the direct feed's bytes, and a stalled consumer never lags the loop
parity.rs replays the four committed stage fixtures whose headers the Rust
producer can read (macros, shop, center, bigpad; 400 snapshots each, all of
them with FLY_EDGE_PARITY_ALL=1) into one watch slot served both ways at once,
recorded by a stage, a bridge and a frame-only client per path: headers equal
without wall times, attachments byte-equal and equal to the fixture's, and
the whole messages byte-equal. FLY_EDGE_PARITY_OUT writes the recordings as
.flyfeed files. Also: a header past the envelope limit, and the edge dropping
its clients, unbinding and coming back across a router restart.

stall.rs runs flysim's Pacer at 60 Hz publishing full-size snapshots at 30 Hz
against three stages that stopped reading, a bus subscriber that hoards every
delivery, and no subscriber at all: pacer lag 0, no slow watch send, no
refused publication, a bounded store, and a healthy client that stays current.
2026-09-23 11:41:28 +00:00
acamilo
d324ec825a fly-edge: serve the feed WebSocket from the feed bus
A new workspace binary. It reads flysim's own configuration (same env file,
same FLY_FEED_BIND, FLY_BUS_DIR and idle cadence), subscribes to
fly.feed.snapshots as fly-edge with one latest slot and one delivery in
flight, turns each publication back into a Snapshot with feedbus::receive and
serves it through flysim's feed::router, so hello, wants, drop-oldest and the
2 Hz idle header are flysim's code and the bytes are flysim's bytes.

The port is bound only once the first snapshot has arrived, and when the bus
goes away every client is dropped and the port unbound, which is what a
stopped flysim looks like to the stage; then it reconnects every 500 ms.
FLY_EDGE_METRICS_ADDR serves /metrics (fly_frames_sent_total and
fly_feed_clients under their flysim names, plus fly_edge_*) and /healthz.
2026-09-23 11:41:28 +00:00
acamilo
d3fa7908ec flysim: FLY_FEED_VIA=bus publishes every snapshot on an embedded flybus router
feed.via (FLY_FEED_VIA, default direct) and feed.bus_dir (FLY_BUS_DIR,
default /run/fly/bus). In bus mode flysim does not bind the feed port: it
starts a router on its own two-thread runtime with a closed policy (flysim
may publish fly.feed.snapshots, fly-edge may only subscribe to it), listens
for the edge on <bus_dir>/edge.sock, and a publisher task copies each snapshot
out of the watch slot onto the latest-retained topic: frame, audio and spikes
as sealed artifacts, the header as the envelope payload, or as a header
artifact past 48 KiB. The sim thread still only writes its watch slot, so a
slow bus costs snapshots on the bus and never a frame of the loop.

feedbus holds both halves of the encoding, publish and receive, and the
limits sized for the real 122,367-byte snapshot. Two counters are new:
fly_bus_published_total and fly_bus_publish_failures_total.
2026-09-23 11:41:28 +00:00
acamilo
acf7c2ebb8 flysim: the feed server takes a FeedState, so another process can run it
feed::router needed the whole AppState for three things: the watch slot, the
feed counters and the idle cadence. It now takes exactly those, and Shared's
Metrics sits behind an Arc so the counters can be handed over. Nothing about
what the feed writes changes.
2026-09-23 11:41:28 +00:00
acamilo
3eb82d7144 a warp's tear deals no pad
wCurMap names the new map thirty-two frames before the header, the
coordinates and the warp table follow it, while the screen fades, and
nothing sets the joypad bits until the fade ends. The seam read
"map 54 at (16, 17)" -- Pewter City's doormat under the gym's id -- as
an overworld, dealt a pad, and a walk started there planned over the
wrong map; what it aimed at and the tile it left went into the ledgers
under the new map's id. Live, GO OUT started and finished in 0.05 s.

The driver reads a tear as the map byte having changed while the fly
still stands on a warp of the loaded table that leads to the map the
byte names (a doormat's LAST_MAP under a town's id included), deals it
as Unknown with an empty pad, and records no ground from it. Teleport
pads do not change the map byte, so they are never a tear; a tear is
bounded at TEAR_FRAMES all the same.
2026-09-23 11:26:49 +00:00
acamilo
5fd16536db a battle decided is the cartridge's
Between a trainer's challenge closing and the battle screen the
transition runs 219 frames with every joypad and script bit clear, so
the scene read overworld and a pad was dealt: a walk toward the leader
pressed into the animation, gave up after three refused steps and put
him in the blocked ledger, and the trainer's conversation read as over.

wCurOpponent is set when a battle is decided and cleared by EndOfBattle
with wIsInBattle. It is not in the generated table; it is the byte
between wIsInBattle's flag byte and wBattleType, both neighbours
checked against the table, and controllable() reads it.
2026-09-23 11:26:49 +00:00
acamilo
484cc075cc the rung's people are in the room when the screen does not show them
CheckSpriteAvailability writes $ff into the image index of a sprite
outside its window, and state::npcs reports what is drawn. From the
Pewter Gym's doormat that is the guide alone, already talked to, so the
rung's list was empty: GO OBJECTIVE had nothing to aim at and GO OUT,
withheld only while the rung's person is in the room, was the pad.
Outside, GO OBJECTIVE walked back in. BROCK was twelve rows up.

state::offscreen_npcs reports the sprites the cartridge hides only for
being outside the window, read from bytes the seam already has, and
objective_targets reads them for a person. Nothing else does: a sprite
out of the window may be a toggleable object switched off, and GO NPC,
TALK and objects keep what is drawn.

Facing any of the rung's people is the arrival: with three in a gym,
leaving out only the one ahead walked GO OBJECTIVE between the leader
and the trainer.
2026-09-23 11:26:49 +00:00
acamilo
552428a7be survey: the route probe reads the room on the fly's Nth arrival on a map
FLY_PROBE_CATCH_MAP and FLY_PROBE_CATCH_ENTRIES stop the route survey
forty frames after the fly's Nth arrival on a map (the first frames on a
new map byte still carry the old map's warps), and the dump lists every
person the macros can see with its talked, blocked and reached entries.
Row 58's pad was one door in and one door out, and what it needed read
was the room on the far side of the door.
2026-09-23 11:26:49 +00:00
acamilo
174dc7eabd docs: v0.5.4 status, and the unstick rule
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-23 11:26:32 +00:00
acamilo
6d5827716f Merge fix/loop-row57: an escort walls the tile it reached, and a refusal that teaches nothing is not dealt again 2026-09-23 11:26:29 +00:00
flybrain
6c7ee5476c docs: macros.md 12.21 and the row 57 audit 2026-09-23 10:10:03 +00:00
acamilo
bbb25e1afc docs(design): legacy-gameboy-v1 16, where the rung slot sits is the whole archive difference 2026-09-23 09:33:10 +00:00
acamilo
4b6624757b Merge docs/port-contracts: PROF-02a and RT-01a, the legacy Game Boy profile and environment boundary, per the operator's 2026-09-23 decisions 2026-09-23 09:32:09 +00:00
acamilo
3c4bb30bed docs: review round 1 on the port contracts
B1: a slot save due at a boundary completes before any State.Capture or FLYSIM01 export there;
the ported milestone archive holds the post-capture ratchet and slot where legacy holds the
pre-capture ones, declared in legacy-gameboy-v1 sections 4 and 16 and step-v1 sections 3 and 6.
N1 the exact restore transient (blocked window from 0 ms, restored winner reported blocked on the
first decode); N2 the decoder-config form; N3 worker status and lost-reply resolution for the
extension methods; N4 boundary actions and captures in the step-v1 section 8 trace; N5 a warm-up
override is another profile; N6 sugar refused until the first commit after a restore.
2026-09-23 09:00:12 +00:00
acamilo
2f8ad4914a session types: shared decoderConfigDigest vectors from both decoder presets
Review round 1, N2. The gameboy-decoder-config-v1 form (channels as ordered arrays), vectors for
raw mode and the Pokemon Red macro group computed by flysim's legacy_profile_identity test from
gameboy_decoder_config_with_macros and reproduced by @flybrain/session-types from the oracle's
gameboyDecoderConfig. The example composition now carries the real macros-mode digest and
channel set.
2026-09-23 09:00:12 +00:00
acamilo
a6e1623698 session types: boundary actions and captures in the step trace, a capture never precedes a slot save
Review round 1, B1 and N4. TraceBehaviour.boundaryActions records the slot saves and the rollback
at the reached boundary in application order; TraceOperational.captures records each capture with
the number of boundary actions before it; TransitionTrace refuses a capture taken before the
boundary's slot saves. Both languages, fixtures for the rule, synthetic coordinator records both
lists empty. contractDigest moves to a56e25e6.
2026-09-23 09:00:11 +00:00
acamilo
24ba933500 docs: PROF-02a and RT-01a, the legacy Game Boy composition on the session framework
The operator decided on 2026-09-23 to port the live fly onto the session framework in full.
New contract legacy-gameboy-v1: the profile gameboy-legacy-fafb-v783-v1, the proof that the
legacy f64 frame clock equals the rational one, the step-by-step placement of step_frame in
lockstep-v1, the readout context (location allowed and declared), the channels decision, the
memory-image inspection and ROM AssetRef, the environment (one no-button setup frame, u8->f32
audio, DC blocker at the edge, gameboy-slots-v1), the pokered-macros-v1 executor as one object
with its task, the legacy-ratchet-rollback-v1 policy, the composition digest carrying decoder
and macro-channel configuration, legacy-transient-reset restore semantics, sugar admission with
a one-commit lag, and FLYSIM01 as format of record until RETIRE-01. PROF-02b is a stub.

Dated amendments, each citing the decision, where earlier text kept the legacy loop outside
lockstep or had no place for it: workers-v1 (telemetry, Initialize, executor, episode request
kind, admission, new section 7 extension methods), step-v1 (rollback edge, Phase B/C, clock,
episode policy, sugar lag), state-media-v1 (audio, memory-image retention, restore semantics,
format of record, section 7 ratchet), README section 4, implementation.md (AGENT-01 and ENV-01
unblocked), the MaleCNS backlog (FOUNDATION-02 split, RUNTIME-01 contract) and analysis (5.2,
5.4), and readout.md (where the location comes from).
2026-09-23 08:32:05 +00:00
acamilo
bbf71bfead session types: the legacy Game Boy profile and the RT-01a extension methods
PROF-02a and RT-01a, machine-readable half, per the operator's port decisions of 2026-09-23.

Generic (in the session schema set, so contractDigest moves):
- AgentTelemetry.stimulusRemainingMs (number|null): sugar admission reads the pulse from the
  last commit.
- EpisodeRequest.kind is terminal | rollback.
- Environment.SaveSlot / Environment.RestoreSlot (capability gameboy-slots-v1) and
  Agent.Rollback (capability legacy-ratchet-rollback-v1) payloads, with their scope checks;
  maxSlots 4.

Legacy Game Boy (module gameboy, digested apart from contractDigest):
- registered payload schemas gameboy-readout-context-v1, gameboy-channels-v1,
  gameboy-joypad-v1, gameboy-memory-inspection-v1, legacy-ratchet-rollback-v1, each SchemaRef
  digest over its canonical declaration;
- the one legacy profile gameboy-legacy-fafb-v783-v1 embedding today's schema-1 fingerprint,
  lif-1ms-f64-v2 and fly-kc-mbon-rstdp-v2, with its AssetRef digest;
- the composition declaration carrying the decoder and macro-channel configuration, the
  executor pokered-macros-v1, gameboy-slots-v1, legacy-ratchet-rollback-v1,
  legacy-transient-reset and FLYSIM01 as format of record, cross-checked against the FLYSIM01
  compatibility string.

Fixtures regenerated by update_fixtures (new derived gameboy-legacy.json); valid/invalid cases
for every new type in both languages; the frame clock proven identical to the legacy f64
accumulator. flysim gains only a test (and a dev-dependency) that recomputes the pinned
fingerprint, versions, frame size, warm-up, clock and button order. The synthetic fly-session
agent reports stimulusRemainingMs null and its task names kind terminal; no runtime change.
2026-09-23 08:31:58 +00:00
flybrain
73b160c2f8 clippy 2026-09-23 07:27:34 +00:00
flybrain
cc6f06ed2a a refusal that teaches the ledger something is not held against the tile
Measured with the real brain on the seeded pocket: it pressed GO ROUTE
first, while the gym's door was still the second tier's answer. That
refusal wrote the door to the blocked ledger and also withheld GO ROUTE
on the tile, so the next deal -- the last resort, whose walk now goes
where it can -- was never made, and the fly waited out the window.

Only a last resort deals goals the ledger is already resting, so the
refusal worth remembering where the fly stands is the one that taught
the ledger nothing new. A refusal that writes a new exclusion changes
the next deal by itself.
2026-09-23 07:25:19 +00:00
flybrain
ef98b04547 a last resort that cannot reach the objective's door takes a way out it can
Row 57's pocket had a way out: the road east, three tiles from the fly
and resting in the blocked window. The last resort ignores that window
but narrows to the ways toward the objective, and the only one was the
gym's door beyond the fence, so the route search refused and the road
was never tried. With the refusal withheld the pad went empty and the
fly waited out the road's window, ten brain minutes.

The narrowing is a preference and the dealer cannot search. When start's
route search cannot reach the preferred ways, it tries the rest of the
last resort, nearest reachable first, exactly as every walk chooses. The
pad is unchanged; only where the pressed macro walks.

The ROM proof now asserts the fly leaves the pocket inside a brain
minute: frame 517 (0.14 minutes), against 36,325 (10.1) on the base.
2026-09-23 07:00:07 +00:00
flybrain
2d04abaf52 survey: the hunt can start inside a live session's ledgers, and counts refusals
The seeding moves from scene_probe into examples/support/ledgers.rs and
trap_hunt reads it as FLY_TRAP_SEED_*, so both arms of a hunt can start
inside the state the live session was in: row 57's pad was dealt by
ledgers a restore starts empty, and a hunt from the bare checkpoint
never meets it. The report gains refusals by macro and the longest run
of one macro refused with the fly on one tile.

scene_probe's own choice seed is FLY_PROBE_RNG now, so it does not read
as one of the FLY_PROBE_SEED_* ledgers.
2026-09-23 06:23:13 +00:00
flybrain
b8a5c8fc52 watchdog: check 10 counts outcomes, not only starts
Row 57's pad was one button refused every hold for two hours: one start
and one name in ten brain minutes, so neither the sequence rule nor the
dominance rule could fire, and the coordinator's own watcher saw it
first. Check 10 now reads every macro outcome in the window. The fly's
decisions are its starts and its refusals; `stalled` flags 90% of 20+
decisions ending refused, blocked or timed out, and `zero-progress`
flags decisions with no `done` among them on two probes in a row. Both
sit behind the same no-new-ground gate as the old rules.

fly_loop_refused, fly_loop_blocked and fly_loop_done are exported, and
loop.json carries window.decisions and window.outcomes. It still never
acts: the lint fixture asserts no unit is restarted across six cases,
two of them new (the row-57 log, and a zero-progress log).
2026-09-23 05:48:01 +00:00
flybrain
6f05b96334 tests: the ROM proof from the Pewter checkpoint
the_pewter_east_pad_is_never_one_dead_button_from_the_rung_ten_checkpoint,
gated on FLY_ROM and FLY_PEWTER_EAST_CHECKPOINT. Part one, from the
ratchet's rollback snapshot: GO ROUTE walks east, meets the youngster,
and the pushed ledger walls a tile he fires on, not the south entrance
the walk set out from. Part two, from the live frame with the pocket's
session ledgers rebuilt: twelve brain minutes, no button refused twice
running on one tile, and the fly leaves the pocket. On the base the same
run refuses GO ROUTE 746 holds running on one tile.
2026-09-23 05:47:43 +00:00
flybrain
acbc655a23 a button refused from here is not dealt again from here
Row 57, live on rung 10 for two hours: the Pewter City pad was GO ROUTE
and nothing else, refused `no route` every 800 brain ms, no button
pressed. The dealer asks the cheap question and start asks the real
one; the blocked ledger closes the gap for every list except a last
resort, which ignores that ledger by design. So GO ROUTE's refusal,
which wrote the gym door to the ledger, could not take the button off
the pad, and re-stamped the door's window every hold, which kept GO
OBJECTIVE's only goal excluded for ever.

A `no route` or `precondition` refusal is now recorded with the tile
the fly stood on, and the dealer does not deal that button from that
tile for the blocked window. It is dealt again the moment the fly
stands anywhere else or the window closes. Nothing presses; a pad with
nothing runnable is empty and the fly waits.
2026-09-23 05:47:38 +00:00
flybrain
ab3d4cb6fb an escorted walk walls the tile it reached, not the one it set out from
Row 57. The pushed ledger (row 37, no window) recorded the tile a macro
set out from when the cartridge moved the fly. For a TALK that is the
tile the script fired on; for a walk it can be the far side of the map.
Pewter City's youngster takes the joypad on four tiles by the road east,
GO ROUTE aims east at Route 3 every time, and one walk from the town's
south entrance walled the south entrance, twenty-six tiles from the
script. Walks start wherever the last one ended, so the walls fenced the
fly into a pocket no route could leave.

A walk now records the tile it last stood the fly on, which is where the
cartridge took over. Every other macro keeps the tile it started on.
2026-09-23 05:47:32 +00:00
flybrain
c9212e5498 survey: the route probe, and a seam to rebuild a session's ledgers
FLY_PROBE_CATCH=route drives the real PokemonPalette from a checkpoint,
one uniform choice per hold, prints every pad it deals and every refusal
with its reason, and reads the frame the pad comes down to one refusing
button on: which list emptied why, the no-window ledgers, and whether the
route search reaches each way out. FLY_PROBE_SEED_* rebuild the session
ledgers a restore starts empty; FLY_PROBE_RATCHET starts from the
ratchet's rollback snapshot; FLY_PROBE_HOLD presses raw directions first.

PokemonPalette gains inspect(), fences() and a doc(hidden) ledgers_mut()
for the survey. The loop never calls them.
2026-09-23 05:46:56 +00:00
acamilo
d5d9249ea9 docs: v0.5.3 status
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-23 01:37:28 +00:00
acamilo
b903e738d5 Merge fix/loop-row56: a two-option box is found around the cursor the game parked in it 2026-09-23 01:37:26 +00:00
flybrain
9f47e7fd9c tests: the ROM proof from the gym checkpoint, and answers counted per person
Row 41 counted `YES` starts per map, which cannot tell one conversation from
another; the Pewter Gym stall was 64 `YES` and 62 `NO` in ten brain minutes at
one person. So the harness counts an answer to a box it can read as a choice,
charged to whatever the fly is facing -- `palette::facing_target`, which is
`TALK`'s own precondition and the same reading its ledger entry uses.

`the_fly_leaves_the_pewter_gym_guides_ring_from_the_rung_ten_checkpoint`: the
fly leaves map 54 on frame 1,337 against never in twenty brain minutes, spends
27 macros in the gym (one lap of the ring), answers the guide's prompt **once**
against a bound of four, re-opens the ring **never**, and deals `NEXT` on no pad
while a readable prompt is open.

The rung is printed and not asserted: neither this run nor either arm of the
trap hunt reaches rung 11 from this checkpoint, and why the fly does not walk
back through the gym door is row 12.5's ground and row 54's rather than this
row's. Named in the residuals.
2026-09-23 01:22:01 +00:00
flybrain
a22edacbc8 docs: macros.md 12.20, macros-wram.md section 11, the row 56 audit
The survey whole, both arms of the trap hunt, the ROM run, and the residuals --
including the two this row does not work: neither arm reaches rung 11 from this
checkpoint, and the watchdog's check 10 cannot see a loop of exactly four
distinct macros.
2026-09-23 01:17:33 +00:00
flybrain
886509b3b5 a NO inside a conversation declines nothing, so it does not un-arm the talk
The other half of row 56. The Pewter Gym guide's conversation is fifty-two boxes
long and a third of the presses that walk it are `NO`, whose B advances a plain
box exactly as `NEXT`'s A does. Every one of them cleared the pending `TALK`, so
the talked ledger never learned the conversation had happened, `TALK` stayed on
the overworld pad, and an A press at him reopened the whole ring: thirty brain
minutes of scene `dialog` with no walk macro dealt.

12.4's rule -- "the fly said no, so the thing is still on offer" -- is about a
declined *offer*, and which of the two a `NO` was is decided where it can be
seen: by whether the box closes on it. So the decision moves to the frame the
text goes away, which is where the talked entry is written anyway, and the
reading is `pending_answer`: armed only by an answer to a prompt this crate can
read, alive for one hold. A declining `NO` still standing there is a `NO` the
box closed on; anything else is a conversation walked through to its end.
2026-09-23 00:37:44 +00:00
flybrain
5f9237efcd a two-option box is the one the cartridge drew, not the one row 41 pinned
Row 41 read the border at (11, 6)-(19, 11) because that is where the Pokemon
Center's script puts it, and named the limit in its own residual: "Red places a
two-option menu where the script asking for it says, so a prompt drawn elsewhere
reads false and keeps the pad it had". The Pewter Gym guide draws the same menu
at (14, 7)-(19, 11) with the cursor at column 15. Surveyed over 260 presses from
the live checkpoint: the box is drawn on 10 frames, `yes_no_prompt` answered
false on all 260, and `wTextBoxID` read `TWO_OPTION_MENU` on exactly the 10.

So the pad was `NEXT, YES, NO` on a box that was a choice -- two channels for
one A press, which is 12.10's forbidden pair -- and the reopened-prompt
exclusion never armed, because it only judges an answer to a prompt this crate
can read.

The screen half is now the border drawn **around the cursor the game parked in
it**. One fact about `DisplayTwoOptionMenu` rather than about any script: the
cursor goes in the box's first interior column, so the left edge is one column
to its left, in both boxes surveyed. The top is not fixed -- the nurse's box
begins two rows above the first item and the guide's one -- so the top is found
and the figure is then read whole, as `waiting` and the move list are.
2026-09-23 00:37:44 +00:00
flybrain
a6a8f797ff survey: the conversation probe, and every rectangle the cartridge drew
Row 56 is a gym's dialog for thirty brain minutes with YES, NO and NEXT dealt
in equal thirds, so the question is which box each press is answering and
whether the seam can see it at all. `state::drawn_boxes` asks the screen
instead of a pinned rectangle: every complete `TextBoxBorder` on the frame.
`FLY_PROBE_CATCH=dialog` walks the conversation one raw pulse at a time and
prints both readings of every frame side by side, with the pad the palette
deals for it, and tallies how the candidate readings separate the frames.

`FLY_PROBE_ANSWER` picks the button a frame with a box on it is answered with,
so the two arms of a choice can be walked separately.
2026-09-23 00:30:35 +00:00
acamilo
2de2dced0a tests: the service integration test expects the v6 adapter
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-22 23:00:14 +00:00
acamilo
cfb3c1c506 docs: v0.5.2 status
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-22 22:49:33 +00:00
acamilo
c24cbe0171 Merge fix/loop-row55: the shop pad is read from the drawn screen and offers only what can run 2026-09-22 22:49:30 +00:00
acamilo
6342adba39 docs: row 55 gates, as run on the merged tree 2026-09-22 22:48:11 +00:00
acamilo
e5fe6c46ac Merge main into fix/loop-row55: row 50's battle seam beside row 55's counter
Three conflicts, all of them two reviews appending in the same place, all
resolved by keeping both sides: `macros.md` (row 50 keeps section 12.18, row 55
becomes 12.19), `macros-traps.md` (both trap rows, both residual pairs, both arm
sections) and `scene_probe.rs` (both survey modes, `accept` and `shop`). Every
source file auto-merged.

The two rows compose on the cartridge: from the mart checkpoint the fly leaves
the counter, walks the town, and no `MOVE n` blocks any more, so the longest
chain of one macro blocked on an unchanged frame is 1 in any scene.
2026-09-22 22:38:10 +00:00
acamilo
549df6c323 docs: row 55, and the two claims about a mart the survey narrowed
`macros.md` section 12.18 is the review; `macros-wram.md` section 7.1 corrects
the address table, where `wListMenuID` was said to be cleared by every text
display and an item's position in the stock was said to be its cursor index;
`macros-traps.md` carries row 55, its two arms and its ROM run, and two new
residuals -- `wListScrollOffset` is not a pinned address, and a mart that has
never drawn a buy list reads `Unknown` rather than `Shop`.
2026-09-22 22:29:07 +00:00
acamilo
01c199fe18 probe: clippy's three readings of the counter survey 2026-09-22 22:29:07 +00:00
acamilo
351338038a docs: v0.5.1 status
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-22 22:13:09 +00:00
acamilo
349a1fd28c Merge fix/loop-row50: a battle turn is the fly's only while the move list is drawn 2026-09-22 22:13:08 +00:00
dev
e307b7dde4 docs: row 50's survey, its two trap hunts and its ROM run
The press survey, twenty brain minutes before and after on both checkpoints,
and the ROM-gated forest run.

The ethos check's 'fewer flagged windows, more distinct tiles' holds on the
tiles on both arms (296 to 430, 163 to 184) and does NOT hold on the windows
(33/73 to 70/73, 68/73 to 73/73). After the fix the fly spends three quarters
of each run inside battles it is actually fighting, and the hunt's tile rule
flags a fighting fly exactly as hard as a stuck one. Reported rather than
smoothed.
2026-09-22 22:11:01 +00:00
acamilo
01494a104e docs: the session framework slices that landed, and the two that are blocked
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-22 21:11:55 +00:00
acamilo
954b9f4db5 Merge fix/sf-resolution-bound-flake: a short acknowledgement is the contract, and two timing bets become claims 2026-09-22 21:11:49 +00:00
acamilo
b2afce1fce Merge feat/sf-publish-01: the publication boundary, committed snapshots and observer isolation over the same bus 2026-09-22 21:04:40 +00:00
dev
5e61c50728 session: an unreadable event batch is not the end of the stream
take_events kept returning Option<EventBatchView> and defaulting through the
question-mark operator, so a batch missing a field read as end of stream and the
ConsumerEvents enum added in the previous round described nothing. It returns
Batch or Unreadable now, and a test publishes a batch with no droppedBefore,
asserts it is reported as unreadable naming the field, and asserts the next real
batch still reads.

The checkpoint-envelope-v1 amendment cites the rule that lets a required
manifest field land with envelopeVersion still 1 while no production file
exists.
2026-09-22 21:03:59 +00:00
acamilo
50ab3d47ba session: hold an Acknowledge to the request it answered
Dropping the length check left nothing checking the reply against the
request at all: AcknowledgeResult::validate_against existed with no
caller, so a worker could acknowledge ids this session never asked
about. That is the other half of the rule the README states. A short
list is the worker reporting what it released and is accepted; an id
from outside the request is the worker reporting about someone else's
cache and is refused, named, before any mutation.

The bootstrap-path regression is now covered too. The earlier test calls
acknowledge_replies directly, which guards the check where it lives but
not where it lived, so a length check put back into acknowledge_lifecycle
left it green. The duplicate_lifecycle_acknowledge injection releases the
ids first, out of sight, so the call that method makes and checks is
already the second one -- the shape the section 6 resolution produces.
Verified by putting the old check back: three tests fail with it, none
without.

Also: last_resolution_attempts is cleared with last_resolution, so a
resolution ending before its first attempt no longer reports the
previous count; the guard half of the bound test asserts the fence like
the budget half; the attempts assertion checks a real bound rather than
u32::MAX; and two dead Instant bindings are gone.
2026-09-22 21:03:09 +00:00
dev
305a50d8df session: a graph identity does not cross a recovery
The index an agent attested to at Agent.Initialize joins its compatibility
identity and its checkpoint manifest row, so a replacement fly that built
another graph -- the same dataset, the same neuron count, another index --
cannot install a checkpoint taken under the first one. It was accepted before,
because agent_compatibility digested a dataset digest recomputed from a free
function rather than what the worker attested to, and the restored composition
was then published under its predecessor's indexDigest.

The refusal names what the worker is, not just that two digests differ. Dated
amendment to checkpoint-envelope-v1's agents row; no wire type and no schema
text change, so the contract digest is unchanged.
2026-09-22 20:36:51 +00:00
dev
6bf5687aca session: a snapshot publishes the telemetry of the transition that just ended
AgentSlot.telemetry was written only by the Agent.Initialize handler, so every
CommittedSnapshot carried warm-up telemetry labelled as boundary k while each
AgentCommitResult.telemetry was validated and dropped. The commit result is now
stored on the slot beside the committed step, and the media test asserts that
published telemetry advances across boundaries instead of merely being nonzero.

A refused snapshot is recorded and sequenced like a refused descriptor revision,
because the repair path exists for the consumer that did not receive it; the
sequence advances with the value rather than with the delivery, so two snapshots
can never share one. The query service counts an answer it could not deliver
rather than discarding the result, and an unreadable event batch is distinct
from the end of the stream.
2026-09-22 20:18:49 +00:00
acamilo
0cec73704c docs: v0.5.0 deployed, the run restarted from rung 8
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-22 20:00:43 +00:00
acamilo
270d4cb435 tests: the fly leaves the mart counter, and a blocked chain is measured
From the checkpoint the stream looped in: the fly is out of the shop scene and
off the map inside twenty brain minutes, no macro reports `blocked` more than
three times in a row in the shop with the map, the tile, the wallet and the scene
unchanged, and the shop never deals an empty pad.

The chain is recorded per scene as well as overall, because the battle scene has
one of its own and it belongs to another review.
2026-09-22 19:56:38 +00:00
acamilo
8523daf745 macros: a purchase the counter's cursor cannot reach is not on the pad
A mart's buy list scrolls. The cursor walks rows 0, 1, 2 and then the window
moves under it, so an item's position in the counter's stock is its cursor index
only for the first three entries; the offset that would name the rest is not a
pinned address. Pewter's counter carries seven items and the Antidote is its
fourth, so `BUY ANTIDOTE` there aimed a cursor step above the list's own max and
reported `blocked` on its first frame, having pressed nothing -- and a purchase
has no walk target, so nothing was recorded and the button came back on the next
hold.

One accessor answers "which index, if any" for both the pad and the plan, and the
clerk talking is refused through the seam rather than here.
2026-09-22 19:56:38 +00:00
acamilo
4a1df7c3be macros: the mart's list byte says the counter is open, not which screen is up
`wListMenuID` keeps PRICEDITEMLISTMENU for a whole mart visit: the clerk's text
is printed from inside the mart's own routine and never goes through the display
that clears it. So every frame of the Pewter mart read "the priced buy list",
including the "Here you are! Thank you!" box the stream was looking at, whose
leftover cursor bytes belong to a two-option box.

Which screen is up is now read from the figure the game draws, the same
construction the dialogue box's `waiting` test and the YES/NO prompt already
make: a full-width box waiting for a press is the clerk (`ShopScreen::Talking`),
the item window drawn is the buy list, and the item window blank is the counter
menu.
2026-09-22 19:56:38 +00:00
acamilo
da2cde176c probe: a counter survey that reads what the mart actually drew
`FLY_PROBE_CATCH=shop` drives a `BUY ...` from a checkpoint the stream looped in
and prints, per frame, what the seam reads at the counter, the halves of every
purchase's precondition, the outcome of the macro three times over, and what an
A press at the counter really opens. `screen_text` decodes the tile buffer,
because the question is which figure the game drew and the bytes that would name
it are not rewritten between two of the mart's screens.
2026-09-22 19:56:38 +00:00
acamilo
e3132362cf Merge main at 5512900: STATE-01, and the flybus coalescing fix
The flybus session_over_one_router failure my workspace runs were
counting is already fixed on main: the coalescing branch forces the
coalescing deterministically instead of asserting that a slow consumer
must skip. Merging before the runs so they measure a tree that exists.

One conflict, in the crate README, and it was two sections both newly
added at the same anchor rather than two versions of one thing. Both are
kept: STATE-01's checkpoint and recovery section, then the guidance on
which replies permit a subset, which stays immediately above the
contract-narrowing section where someone adding a check will meet it.

coordinator.rs and the process tests auto-merged. Checked rather than
assumed: the acknowledge equality check is still gone, acknowledge_replies
and last_resolution_attempts are present, STATE-01's capture, durable
and rebase surfaces are present, and both test changes survived.
2026-09-22 19:49:14 +00:00
acamilo
c1a972548b session: the death rows kill once the victim provably has the work
Both death rows killed after a fixed sleep, so under load the kill could
land before the call was dispatched. The bus then reports not-dispatched
and MutationCertainty::None, which is correct -- the participant never
received anything -- while the test demanded unknown. A full-workspace
run caught it: "left: None, right: None" at the certainty assertion.

kill_once_it_is_working polls the victim's own Worker.Status until it is
provably inside the operation before killing: the agent until it is
Preparing with an active request id, the world until it has recorded the
batch, which the arena does before its injected delay. Dispatch has then
demonstrably happened and unknown is the only correct certainty.

The wall-clock boundedness assertions are gone with them. The suite's
own `within` is the bound, and the victim is five seconds slow against
its twenty, so returning at all is the claim.
2026-09-22 19:48:10 +00:00
dev
7ce645f1dc 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.
2026-09-22 19:24:44 +00:00
dev
9501a5a17c macros: the survey numbers in the accessor are the corrected pulse's
The first pass of the press survey counted 187 refusals that were its own held
button: JoypadLowSensitivity acts on a key's edge, so a direction the fly was
already holding produced no press. With the pulse releasing first, the reading
is 264 honoured of 3,102 by the cursor bytes alone and 231 of 231 by the bytes
and the box.
2026-09-22 19:09:42 +00:00
acamilo
ef82a05a3a docs: which replies permit a subset, and which do not
The sweep behind this branch found one check demanding an exact match
where the contract permits a short answer, and four that were right to
demand one. The question that separates them belongs where the next
check gets written, not only in a run report: is the far side reporting
what it did, or being held to a requirement?

Worker.Acknowledge is the only reply of the first kind here, because
ipc-v1 section 5 makes it idempotent. The commit and batch checks are
the second kind and are named so nobody loosens them later in the name
of tolerance; they are what make a partial commit and an incomplete
batch fail.
2026-09-22 19:05:58 +00:00
acamilo
f6baeb5cfa session: a test for the acknowledge rule, not only for the flake
The short-list acknowledgment is a contract rule, so it gets a test that
says so rather than one that depends on a worker being slow.
acknowledge_replies carries the ipc-v1 section 5 sentence in its doc
comment and returns what the worker actually released;
acknowledge_lifecycle calls it, so bootstrap and the test exercise the
same path.

an_acknowledge_that_releases_nothing_is_not_a_failure drives the case
directly, once per execution mode: bootstrap releases every lifecycle
reply, the test asks for those ids again, the worker ignores them and
releases nothing, and the coordinator must accept the empty list, stay
unfenced, stay at its boundary and still play the next transition. It
fails if the equality check returns.
2026-09-22 19:03:18 +00:00
acamilo
4f2d4a5848 Merge main into feat/sf-publish-01 2026-09-22 19:03:01 +00:00
dev
e27306f171 Merge main into feat/sf-publish-01
# Conflicts:
#	services/flysim/crates/fly-session/README.md
#	services/flysim/crates/fly-session/src/coordinator.rs
#	services/flysim/crates/fly-session/src/harness.rs
#	services/flysim/crates/fly-session/src/launcher.rs
2026-09-22 19:02:57 +00:00
acamilo
55129002a4 tests: the placeholder fingerprint in the migration test no longer reads as a MAC address
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-22 18:59:17 +00:00
dev
c25c28efe3 test: hold the rung-nine forest run to a MOVE n that finishes, and a median battle
Row 50's two numbers, ROM-gated from the forest checkpoint. Before, on main:
MOVE n 940 starts and 838 blocked, 11 battles entered and 10 ended, worst 503
macros, median 48. After: 51 starts and 0 blocked, 13 entered and 13 ended,
worst 283, median 43, and the fly leaves the forest north through the gate.

The blocked share is the assertion the row is about; the median is what it
buys, and the worst battle is a tail rather than the run.
2026-09-22 18:57:18 +00:00
acamilo
083abe5f1f Merge feat/sf-state-01: a coherent all-participant checkpoint, group restore and a liftable fence 2026-09-22 18:57:18 +00:00
acamilo
2237e9a1a2 Merge main into feat/sf-publish-01 2026-09-22 18:54:26 +00:00
acamilo
6d67fa7ed2 session: a retried Acknowledge is not a failed epoch
The bound test failed two runs in thirty under load, and not on the
bound it was testing. Both failures were bootstrap: "a worker did not
acknowledge every lifecycle reply".

ipc-v1 section 5 says already released or unknown ids are ignored, and
the contract type already holds the acknowledged list to a subset of
the request. So the second Acknowledge of the same ids answers with an
empty list by design -- and the section 6 resolution produces exactly
that second Acknowledge whenever the first reply is slower than the
probe. Demanding the whole list back turned a safe, contract-sanctioned
retry into a failed epoch, which is a defect in the coordinator rather
than in the test: a slow lifecycle reply would do it to a real session
too.

The test made itself easy to hit by installing a fifty-millisecond
probe before bootstrap, so bootstrap's own lifecycle calls ran under a
budget meant for the step under test. It now bootstraps at ordinary
deadlines and tightens them afterwards.

The two bounds are also separated by construction rather than by clock.
Each half puts the bound it is not testing out of reach -- u32::MAX
attempts against a fifth of a second, three attempts against an hour --
so no scheduling delay can flip which one fires, and the silent
participant is ten minutes slow against a twenty-second test timeout,
so returning at all proves a bound ended it. The wall-clock assertion
is gone and the attempt count is asserted instead, which
last_resolution_attempts now records. One agent per composition, so the
participant the failure names is not a race either.
2026-09-22 18:47:42 +00:00
dev
f43fd4d9ff 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.
2026-09-22 18:47:10 +00:00
dev
388e112ad5 Merge main into feat/sf-state-01 2026-09-22 18:45:56 +00:00
dev
45621903be session: name the two ways a durable wait ends without an acknowledgment
Review follow-up on the checkpoint store.

A dropped reply channel and an expired caller budget were both reported as
ReplyLost. They are different facts -- the first means the write is over and its
outcome did not reach here, the second means the save is still going -- so they are
now separate outcomes, and the durable wait has its own budget rather than borrowing
the one that bounds a call to a participant. Both still leave durable metadata where
it was, and for both the resolution asks the store about the same checkpoint.

The writer's two bounds refuse at different moments and the comment claimed
otherwise: the outstanding-capture bound is taken before a capture is requested, and
the byte budget cannot be, because a capture's size is not known until it exists. The
byte check, the decision and the change to the byte total are now one critical
section, the peak is sampled after a superseded job's bytes are gone, and the writer's
own bookkeeping is over a type that holds only the outcomes a writer can produce.

The manifest's coordinator.eventWatermarks is {lastSourceStep, issued}; the fixture
illustrated {lastEventId, lastOrdinal}, and the illustration is what changed, because
an event id is derived from the epoch and cannot be compared across the restore that
gives the session a new one.

checkpoint-envelope-v1 section 3 also now says, under the same dated amendment, that
a required-manifest-field change must bump envelopeVersion once production files
exist: contractDigest is taken over the schema set and does not cover this manifest,
so the envelope version is the only thing that can carry such a change.
2026-09-22 18:45:51 +00:00
dev
6588897ab3 docs: a menu is up while its box is on screen (section 12.18, row 50)
macros.md gains 12.18 and macros-wram.md a section 10 for the accessor and the
survey that found it: press at every battle frame with a rollback pulse, and
ask every byte of WRAM and HRAM which of them separates a honoured press from
a refused one.

The half of row 50 that was wrong is where the fix is. The move list was not
drawn: MoveSelectionMenu's cursor bytes are never cleared and SelectMenuItem
decrements wCurrentMenuItem back into the one-based range on its way out, so a
turn's whole text and animation read as an open list with a placeable cursor.

The battle bag is the same trap on wListMenuID and is named rather than fixed,
because the figure that tells its list from the frame after it closes has not
been surveyed yet.
2026-09-22 18:43:05 +00:00
acamilo
55221a7c77 docs: v0.5.0 status 2026-09-22 18:41:17 +00:00
acamilo
a8afd8f448 Merge feat/catch-reward: a catch reward, adapter v6 with a v5 migration, and a rung restart 2026-09-22 18:41:15 +00:00
dev
e2bf4c0305 macros: the move list is the box on screen, not the cursor bytes it left behind
Row 50: MOVE n reported blocked 890 times in 1,431 macros on the cartridge,
every one of them on a frame the seam read as an open move list with a
placeable cursor.

MoveSelectionMenu writes wTopMenuItemY 12 and wTopMenuItemX 5 and nothing in
the game clears them, exactly as the two-option box's geometry outlives its
box. SelectMenuItem then decrements wCurrentMenuItem back to the 0-based slot
on its way out, which lands straight back inside the one-based range the
accessor reads. So the whole of a turn -- the text, the animation, the
enemy's reply -- read as the fly's own turn on an open list, the pad dealt
the four move buttons on it, and the cursor step pressed at a list nobody was
reading until its budget ran out.

The reading is the figure the menu draws, the same construction text_box's
waiting and yes_no_prompt already make: a box at (4, 12) fourteen wide with a
horizontal run over its top-left corner and the junction tile at (10, 12).
Surveyed with one rollback pulse per battle frame over 3,102 frames at the
rung-9 forest checkpoint: by the cursor bytes alone a real directional press
was honoured on 264 of them, and by the cursor bytes and the box on 231 of
231.

A frame whose list is not on screen is between turns, whose pad is the one
NEXT that advances text.
2026-09-22 18:37:36 +00:00
dev
2987b243f4 probe: press at every battle menu to see which reading says it is accepting input
Row 50 asks which WRAM reading tells a battle menu that is accepting input
from one that is only drawn, and the honest way to answer it is to press.
FLY_PROBE_CATCH=accept drives real battles, and on every battle frame it
exports the emulator state, issues one directional pulse, reads
wCurrentMenuItem and puts the state straight back -- so the run is not
perturbed by the measurement and every frame gets a ground truth.

Beside that it asks every byte of WRAM and HRAM whether its values on
accepting frames are disjoint from its values on refusing ones, so a
reading is found rather than nominated.

The pulse releases the buttons before it presses: JoypadLowSensitivity acts
on a key's edge, so a direction the fly is already holding reads as refused
for the measurement's reason and not the cartridge's.
2026-09-22 18:37:26 +00:00
acamilo
537cdd8ad9 Merge fix/flybus-conformance-rows: the conformance rows name the tests that now prove them
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-22 18:29:12 +00:00
dev
2cc8d0294d docs: the two sweep flakes are fixed, and one assertion that could not fail
bus-conformance.md still listed the example_demo root count and
session_over_one_router as not fixed here. Both are fixed on main now, so
the rows say what each test does instead: the example waits for the
producer's hold release before reading the counts, with its printed
output unchanged, and the integration renderer is held until the
publisher's twentieth receipt has returned, so its coalescing is forced
and the assertions are order, freshness, acceptance under a stalled
spectator and the replacement accounting, with the before and after
counts.

The delivery check in that test compared (step, sequence) against
(step, step), whose first element can never fail. It compares sequence
against step.
2026-09-22 18:28:54 +00:00
acamilo
882980823c Merge fix/flybus-coalescing-flake: the flybus suite asserts guarantees, not the machine's timing 2026-09-22 18:23:06 +00:00
acamilo
ed4cffcf59 Merge main into feat/sf-publish-01 2026-09-22 17:59:43 +00:00
dev
db30708c3f Merge main into feat/sf-state-01 2026-09-22 17:55:03 +00:00
acamilo
e76b3d1063 docs: v0.4.7 status 2026-09-22 17:46:03 +00:00
acamilo
f223702085 Merge fix/chat-ring-persist: the on-screen chat ring survives a sim restart via a hot-dir sidecar 2026-09-22 17:45:59 +00:00
acamilo
629aff8537 Merge fix/loop-row54: the walk anchor is measured from the screen, landing tiles retire, errands arrive inside, the errand ledger survives a restore 2026-09-22 17:45:59 +00:00
acamilo
ad632621c1 macros: say when the step reading narrows to None 2026-09-22 17:44:53 +00:00
dev
6655a1b1c6 session: coherent all-participant checkpoint and recovery
STATE-01 over the FLYSESS1 envelope CONTRACT-01 specified.

fly-session gains a `state` module: the durable store with its generations, its
rotation and the commit order of checkpoint-envelope-v1 section 5, where the store
manifest rename is the durable commit point; a compatibility block whose comparison
names the identity that differs rather than one opaque digest; and a bounded writer
that owns its payload handles until the bytes are committed or the job fails.

The writer's queue slot is taken before the first State.Capture, so a saturated
writer refuses a capture rather than queueing it without bound, and the refusal is a
BUSY the stepping session survives. Capture and durability are two events: a capture
completes when an immutable capture exists, and only the store manifest rename moves
the durable mark. A lost save reply is an outcome, and the resolution asks the store
about the same checkpoint instead of saving again.

Both worker roles implement State.Capture, State.StageRestore and
State.ActivateRestore, with once-only restore tokens bound to checkpoint, scope,
payload and incarnation. A restore selects a complete compatible generation, imports
every payload as a fresh artifact, stages the group, validates the coordinator's own
ledgers, and only then activates; a failure anywhere leaves the fence closed and
records every participant that staged as one that must be replaced. The fence lifts
at Failed -> Restoring(k) -> Paused(k) and nowhere else.

The task and the action executor gain the capture/validate_restore/install_restore
interfaces workers-v1 section 4 lists, and the ledger can re-derive the event
identities it issued under another epoch, which is what lets a resumed run's
behaviour trace be compared with an uninterrupted one.

media: check_required_audio now takes the observation's provenance instead of
exempting boundary 0. A chunk is the audio of an interval, and the observation
ActivateRestore installs covers none.

checkpoint-envelope-v1 section 3 gains a dated amendment adding `environment` to the
manifest, the holder of the world's own payload, which the table named for every
other participant; `helperState`, which that table already listed, joins the
required-field set in Rust and TypeScript. The fixture was regenerated by the
existing example; the schema set and contractDigest are unchanged.

state-media-v1 section 5 gains a dated amendment for three readings this slice
enforces: the State RPCs' compatibilityDigest is the participant's, not the
manifest's composition-level block; a restored observation carries no audio chunk;
and a participant that staged into an abandoned install must be replaced.
2026-09-22 17:43:43 +00:00
dev
4effc6020c session: the publication boundary, observer isolation and the repair path
publishing-v1 over the same bus: a declared delivery policy per topic, named
publication outcomes, the bounded event batch, application-owned state and cues,
a read-only descriptor query service and a fake multi-agent consumer.

The session publishes the contract types rather than an ad-hoc payload, so a
descriptor says what the workers attested to and a snapshot is checked against it
before it is published and again when it is read: every frame comes from the
boundary its declared delay implies, every handle is the artifact its reference
names, and an observer's refusal takes no world step and fences no epoch.

AgentInitializeResult gains graph (datasetDigest, indexDigest, neuronCount,
rateRoles, supportedStimuli), without which no AgentDescriptor field in
publishing-v1 section 3 had a source. Dated amendments to workers-v1 section 2,
publishing-v1 section 2 and state-media-v1 section 3.
2026-09-22 17:41:07 +00:00
alex
23a4d7379b rewards: a catch reward, adapter v6 with a v5 migration, and a rung restart
The operator's decision of 2026-09-22: pay the fly for keeping a wild Pokemon,
bump the adapter properly, and restart the live run from an early checkpoint
rather than from scratch.

The rule. `catch` is the catalog's ninth kind, appended so the key order
`counts` serializes in does not move. 0.30 for a species this run had never
owned, 0.10 for a repeat, three payouts per species for the lifetime of the
ledger; the `species` rule is untouched, so a first catch of a new species pays
0.80 across two kinds. The catch is read from `wCapturedMonSpecies` ($d11c),
whose comment in ram/wram.asm is "0 if no mon was captured": ItemUseBall zeroes
it before every throw and writes wEnemyMonSpecies into it only on the branch
that keeps the Pokemon, and UseBagItem's `.returnAfterCapturingMon` zeroes it
again and sets wBattleResult to 2 -- a value written on exactly two paths in
the game, that one and a link battle whose opponent ran. Both are required, so
a byte read out of a half-initialised battle cannot pay. Not wPartyCount: a
catch with a full party raises wBoxCount instead, and wPartyCount also rises
for a gift, a trade and a PC withdrawal.

"Never owned this run" is the `species` payout inside the same battle, because
nothing else can set a Pokedex bit during one. It is not read off the captured
species byte: that is the cartridge's internal index while the owned bitset is
by Pokedex number, and nothing in WRAM converts between them.

The address was resolved by tools/resolve_wram.py, not written by hand. The
tool needed NUM_TMS and NUM_HMS, which the decomp defines through its `const`
enumeration, so it now counts them from the file's own add_tm/add_hm
definitions and cross-checks NUM_TMS against the literal the same file
declares.

The feed's kinds are closed, so `catch` publishes on `wildwin` and nothing in
packages/feed or apps/stage changed. Deliberately not `pokedex`: the `species`
rule already pays for the bit the same catch sets. The stage's ticker copy is
keyed on the feed kind, so a catch row reads "wild win" -- stated in
docs/rewards-learning.md rather than left to be discovered.

v5 -> v6. STATE_VERSION stays 4: the rule adds one counter, `catchCounts`, and
changes nothing else, so a v5 state restores with it empty. That migration is
opt-in and needs all three of: the adapter segment being the only difference
between the two compatibility strings, the running adapter listing the
checkpoint's adapter in `migrates_from()`, and the deploy naming it in
FLY_ACCEPT_ADAPTERS. flysim applies the rule at restore and 05-deploy's gate
applies the same rule before it flips the symlink, writing the variable into
fly.env so the two cannot disagree.

The restart. infra/bin/fly-reset-to-milestone <N> archives both stores to a
dated directory, rewrites milestone-<N>.checkpoint with the ratchet's attempts
and recoveries at zero, installs it as the newest generation of both stores,
clears the milestone archives above N and the event log, and prints what it
did. It refuses while flysim is running and refuses a rung the run never
reached. The envelope work is in flysim::reset (`flysim
--reset-to-milestone N`); the shell script is the operator's wrapper.

Tests: catalog values and order; a synthetic WRAM trace of a catch (new,
repeat, cap, already-owned species, trainer/Safari/old-man/missed-ball
negatives, rollback replay); a v5 state restoring with the counter at zero; a
v5 checkpoint fixture accepted with the opt-in and refused without it; the
reset tool against copies of a state dir in temp directories; and a ROM-gated
catch from a rung-9 forest checkpoint, driven by the shipping THROW BALL macro.

The compatibility string differs from main's in exactly one segment, checked by
splitting both on `/`: pokered-unique8-v5 -> pokered-unique8-v6.
2026-09-22 17:30:00 +00:00
acamilo
95d3d56d3f docs: the rung-eleven ROM run, measured 2026-09-22 17:24:27 +00:00
acamilo
bffbaca394 docs: row 54, the mid-step survey and both arms of the hunt
macros.md gains section 12.17; macros-wram.md section 9 gains the frame-by-frame trace of a step,
the three readings that come out of it and the one byte that tracks a step exactly and is recorded
unused; macros-traps.md closes rows 54 and 54b, strikes the two residuals they worked, and prints
both checkpoints' before and after whole -- including the arm where the ethos check's "fewer
flagged windows, more distinct tiles" does not hold, and why.
2026-09-22 17:19:25 +00:00
acamilo
d637b8f4c9 macros: a second ROM run, from the checkpoint the stream fell into after the badge
Forty minutes after the Boulder Badge the overworld pad had shrunk to `GO ROUTE` alone and it was
completing every 420 ms at a net of zero tiles. The trap hunt from that checkpoint on main is row
54 verbatim, one town further on: 14 distinct tiles in six brain minutes, 17 of 17 windows flagged,
`GO FRONTIER` 122 / `GO HEAL` 129 / `GO ROUTE` 126, every one done at a mean net of 0.0.

The run claims the fly leaves Pewter City for Route 3, that no chain of walks completes at a net of
zero tiles more than three times in a row, and that neither errand is offered in a town the run has
already shopped and healed in.
2026-09-22 17:19:25 +00:00
acamilo
c42255e199 macros: an edge the map graph cannot name stops being somewhere new once it is stood on
The rung-11 reading of row 54. Route 3's connections read north and west off the cartridge
(`wCurMapConnections`, and it has no warps at all) while the geography table carries west and east,
so the seven walkable tiles of its north edge had an unnameable destination -- and an unnameable
destination counted as unvisited, which made them first-tier for `GO ROUTE` on every hold for ever,
with `GO OBJECTIVE` off the pad beside them because nothing on that map leads to the objective.

A warp's destination is a byte the cartridge publishes, so `None` there is the `LAST_MAP` case
already handled. An edge's comes only from `geography::connected`, so `None` there means the table
cannot name the map on the other side and never will, and the only record left is the adapter's own
boundary ledger. It narrows: a genuinely new edge is still first-tier until the fly reaches it.

Which map is north of Route 3 is not guessed at here. That is a survey -- walk the fly off the edge
with real presses and read the map id back -- and it is recorded as a residual instead.
2026-09-22 17:19:25 +00:00
dev
9bed780cb9 flybus: wait for the pending-connection events instead of timing them
pending_connections_are_bounded_and_hello_expires gave the router 20 ms
to register a connection and then slept 80 ms past a 40 ms hello timeout
before reading the count once. Both are claims about how fast this box
is, not about the router: the first failed once in 20 whole-crate runs
beside four busy loops. Both now wait for the event they are about, under
the suite's ordinary ten-second bound, so the test still fails if a
pending connection never registers or a pending Hello never expires.
2026-09-22 17:12:46 +00:00
dev
2af1189ac3 Merge branch 'main' into fix/flybus-coalescing-flake 2026-09-22 17:05:29 +00:00
dev
f2dc5d434a flybus: assert the latest-mode guarantee, not the machine's timing
session_over_one_router asserted that a latest subscriber must drop
snapshots. bus-v1 section 7 says a latest subscription replaces an
undelivered value; it lets a consumer miss values, it does not oblige it
to. Under contention the publisher was slow enough that the renderer kept
up, saw all twenty snapshots and the test failed on conforming behaviour:
16 of 40 runs beside four busy loops, and 9 of 20 whole-crate runs.

The renderer is now held until the publisher's completion is observed
rather than until a timer expires, so the coalescing is forced instead of
raced for: the subscription keeps the one delivery in flight and one
replaceable queued value, and the renderer receives snapshots 1 and 20 of
20. The assertions are the guarantees that hold -- what arrives is in
publication order, the last value is the latest published, the stalled
spectator never refuses a publication or drops out of the fan-out, and
every snapshot the renderer missed is counted as a replacement to the
publisher at admission and to the renderer on delivery, so nothing is
lost silently.

Three more tests in the crate asserted the same kind of race:

- latest_replay_is_ordered_ahead_of_a_racing_publish demanded the
  non-coalesced outcome of a race section 7 allows either way ("bounded
  mode preserves that order, while latest mode may coalesce it"). It now
  puts one racing publication to a bounded and a latest subscription at
  once: bounded must deliver the replay and then the publication, and the
  latest branch is chosen by that publication's own replaced count.

- collection_waits_for_every_retained_owner, and the two disconnect
  cleanup tests beside it, read the store directory for the unlink that
  follows the registry update outside the router lock. They use
  settle_files, like every other unlink check in the suite.

- The demo example printed a root count taken before the producer's own
  release had reached the router, so the line the guide quotes was a
  race. It waits for the release, the same way it already waits for
  collection; the printed output is unchanged.

Nothing under flybus/src is touched: no routing defect was found. The
conformance rows for the credit/queue split, the replaced count, the
replay ordering and the latest spectator that cannot refuse a publication
now cite what each rewritten test actually varies.
2026-09-22 17:05:25 +00:00
acamilo
56db91cd9b docs: record the session framework wave 2
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-22 16:42:17 +00:00
acamilo
a301527b09 Merge feat/sf-media-01: native view and audio observations, the shared frame, and the spectator path 2026-09-22 16:30:04 +00:00
acamilo
66ec2e5c6b session: an unknown launch option is an error, not a no-op
The command line between a launcher and a participant in its own process
was coupled by string convention on both sides and checked by neither: the
parser accepted any --flag it did not know, so a renamed or dropped option
would have become a fault that never fires or a delay that is never
applied, with nothing failing.

Every flag now has one name, in launcher::flags, written by the launcher's
argv and read by the parser. Options::parse takes the sets its command
allows and refuses anything outside them by name, so a mismatch fails the
launch. measure and measure-row use the same constants for their own
flags.

Three tests hold the two sides together: every flag a launch writes is one
its command accepts, every media option is written for a separate process,
and an unknown or wrong-command flag is refused by name. The four media
faults now also run end to end in all three execution modes, each failing
its own transition, so the wiring across a process boundary is proved
rather than assumed.
2026-09-22 16:28:59 +00:00
acamilo
533cf82ac8 infra(05-deploy): FLY_RESET_STATE=1 clears the chat ring too
The deliberate reset already clears the tmpfs hot ring; the on-screen chat
ring's sidecar lives there now, so it goes with it.
2026-09-22 16:26:29 +00:00
acamilo
403038aa42 docs(control-api): the chat ring's sidecar, in the [chat] section
Dated 2026-09-22: where the file lives, what is dropped on load, what an
unreadable one does, that it is not in the checkpoint envelope or the
compatibility string, that FLY_RESET_STATE=1 clears it, and that the bridge
resends nothing on reconnect.
2026-09-22 16:26:29 +00:00
acamilo
01088d9479 feat(flysim): the on-screen chat ring survives a restart
Six deploys in a day left the CHAT panel empty every time, because the ring
was in-memory session state and nothing on disk held it.

Every accepted line now rewrites <hot_dir>/chat-ring.json through the same
atomic sequence a checkpoint commit uses (tmp, fsync, rename, directory
fsync), and Sim::boot reads it back before the first publish. Lines older
than 24 h are dropped on load, only the newest [chat] ring of them are kept,
and an absent, unreadable, unparseable or unknown-version file is an empty
panel and a logged warning rather than a startup failure -- which is exactly
what a restart gave before this existed.

The sidecar is deliberately not part of the checkpoint: no new chunk in the
FLYSIM01 envelope, nothing added to the compatibility string, so
--print-compatibility is byte-identical and a build that refuses every
checkpoint in a directory still brings the panel back. It sits beside the hot
checkpoints because it has their lifetime, and it is written from the sim
thread on the command path, so it cannot refresh that directory's mtime --
the watchdog's liveness signal -- while the loop is wedged.

Tests: the ring round-trips through the sidecar, a corrupt or future-version
file is ignored, lines older than a day are dropped, and the integration
test's SIGKILL restart now proves the panel comes back with the lines it had,
that the durable store holds no copy, and that no chat text is in the
envelope.
2026-09-22 16:26:28 +00:00
acamilo
f456fe9522 Merge main: the per-fly processes, the launcher and the thread budgets
Keep-both everywhere the two slices met. lib.rs takes both module sets.
AgentConfig keeps worker_threads and the sensor log; EnvironmentConfig
keeps worker_threads, the render delay and the render counter.
coordinator.rs keeps the two-stage resolution and its blame() beside the
media split of the Advance reply's attachments, and its imports take both.
harness.rs is main's launcher-based file with this slice's media
instrumentation re-applied on top.

The media instrumentation is shared memory, so it now follows the
launcher's own rule for the progress counter: sensor_log and renders
return None for a participant with a process of its own rather than a
misleading zero. The launcher carries the sensor log and the render
counter to a participant in this process and the render delay and the four
media faults on the command line to one in another process, where the
child builds its own log and counter.

The media path itself is mode-agnostic and is now tested as such: one
image per boundary, forwarded to every agent and published once, asserted
over the bus in all three execution modes, with the shared-memory
assertions made only where those participants live.
2026-09-22 16:07:30 +00:00
acamilo
ba545d9225 Merge feat/sf-session-02: per-fly processes, a launcher with thread budgets, and the fault behaviour 2026-09-22 16:00:57 +00:00
acamilo
a3c1c125cd session: negative fixtures for the allocation, and honest resolution bounds
Two review notes.

The launcher allocation became a required field of HelloResult.limits
with no negative fixture behind it. Three rows now cover it: missing,
above maxWorkerThreads, and zero. Both readers reject all three, and no
derived fixture moved, because invalid.json is not one of them.

The resolution's two bounds disagreed. 512 attempts at a 2 ms pause
give up near 1.5 s, so the attempt count silently pre-empted the 8 s
budget the doc comment advertised. The budget is now the working limit
and says so: the guard is 8192 attempts, over sixteen seconds of pauses
against an eight-second budget, so at the default values the budget is
always what fires. Which one did is no longer arithmetic either --
ResolutionEnd records it, the failure message names the bound and its
size, and the code, the doc comment and the README all state the same
numbers. a_resolution_says_which_of_its_two_bounds_ended_it drives each
bound to the end in every execution mode.
2026-09-22 16:00:13 +00:00
acamilo
77c8ee4558 session: resolve uncertain calls, and measure each mode in its own process
Review fixes for SESSION-02.

An expired caller deadline was becoming a failed epoch without the
ipc-v1 section 6 resolution. That procedure existed and was correct and
had exactly one caller, a test injection, so the deadline this slice
introduced bypassed it and a merely slow participant lost its epoch.
Deadlines is now the two-stage shape section 6 describes -- a probe,
then a bounded resolve budget and attempt count -- call_owned returns a
typed CallOutcome so an expiry is distinguishable from a refusal, and
Prepare, Commit, Advance and the lifecycle calls all query the same
request id against the same incarnation before the epoch can fail. This
is also step-v1 section 7's Advance row, which was imperative about it.

The coordinator peak-RSS column was measuring the measuring process.
VmHWM never falls and every row shared one process, so the column was
cumulative and the mode ranking reversed when the rows were reordered.
Each row now runs in a measure-row child of its own. The corrected
numbers say the opposite of what the first report claimed: the
coordinator's own peak is roughly flat across the modes and lowest in
process mode, and the cost of the split is the children.

workers-v1 section 2 bounded Agent.Initialize's workerThreads by
"within launcher allocation" and named no wire for it. Dated amendment:
HelloResult.limits gains workerThreads, the worker reports what its
launcher gave it, and the launcher refuses one that disagrees. The
schema set, the shared fixtures and the TypeScript package move
together; contractDigest changes, which ipc-v1 section 4 provides for.

Also: the stale-epoch row now reaches the stale-epoch path against a
live agent process and asserts exact codes on both halves; the
router-restart row asserts the handle drop it claimed; frames are
counted from the behaviour trace instead of calculated; the README says
which suites run over which transports; bootstrap is fence-guarded; the
shutdown reason is an Id rather than a silent fallback; and
agent_mutations returns None rather than zero where the counter lives
in another process.
2026-09-22 15:41:42 +00:00
acamilo
a48e0ace4c docs: the fresh-epoch discontinuity reason rests on section 6 alone 2026-09-22 15:18:47 +00:00
acamilo
079842f818 media: no best-effort defaults in the audio path
Review round two.

A restored timeline refuses a declared stream with no recorded sample
position instead of resuming it at zero, which would have restarted the
episode's audio clock silently.

A declared stream that produces no chunk for a transition now fails the
step. The contract does not say a chunk must exist; this slice requires
one, because a silently missing chunk cannot be told from a lost one.
Boundary 0 is exempt: no interval precedes it.

The fresh-epoch discontinuity refusal is dropped rather than written into
the amendment. Only the restore direction is stated, and sections 6 and 7
both have a fresh timeline publishing a discontinuity after a recovery or
a reset, so refusing the flag at an origin contradicted them. The dated
amendment now says the requirement is one-directional.

A snapshot with an unreadable chunk, or one whose handle is not attached,
is now unreadable as a whole rather than quietly carrying less audio than
was published. ViewPipeline::is_bootstrap_repeat is removed.

New tests: a missing chunk fails the step, restored timelines need every
declared stream's position, and a 7 Hz world's seven chunks sum to exactly
one second of 48 kHz samples. The bus-conformance reconnect row also cites
the test that actually replaces an incarnation.
2026-09-22 15:15:06 +00:00
acamilo
8ef5dc321d macros: the Pewter run bounds the walks that complete without moving
Section 12.2's rule as a number on the cartridge: the longest chain of `GO ...` macros that
completed at a net of zero tiles. One is ordinary -- a walk that arrives by turning, a walk cut
short by a battle, a walk that finds its goal underfoot -- and a chain of them is the loop the
v0.4.5 after arm spent brain minutes 1.0 to 8.5 in. Three is the bound.
2026-09-22 14:57:34 +00:00
acamilo
611fe9e108 macros: an errand arrives inside the building, and a building already entered has none
Two rules for section 13's errands, both from row 54's `GO HEAL`: 204 starts at a mean net of 0.0
tiles and a mean reach of 0.0, cycling with `GO ROUTE` and `GO FRONTIER` over five tiles for seven
and a half brain minutes.

An errand's aim at a door carries no press -- the warp fires when it is stepped on -- so an aim on
the tile the fly is already standing on settles for `SETTLE_FRAMES` and reports `done` with the
world exactly as it was. A completed errand walk writes the reached ledger, which `goals_toward`
does not filter, so the same button was dealt on the next hold and the same nothing happened again.
`exit_goals` has excluded a settled goal underfoot since row 13; this is the one walk that did not
have the rule.

And the errand ledger is session state, so a restore re-armed every errand in the town and walked
the fly back to a counter it had already used. `map_visited` is the adapter's lifetime answer to
the same question and it does survive, so both are asked and either pays the errand.
2026-09-22 14:57:34 +00:00
acamilo
895fb52cda macros: the tile a step is landing on is ground the run has covered
The other half of the same measurement. `wXCoord` and `wYCoord` are the tile the step began on
until the frame it ends, so the stood ledger recorded ground the fly had already left and the tile
under it stayed unstood: `path::frontier` kept offering it, `GO FRONTIER` was dealt aiming one tile
away, and `Arrival::Step` reported `done` the instant the step it did not make landed. A macro that
completes without changing anything, which is section 12.2's trap.

A step that has begun always finishes -- the cartridge owns the animation and no press stops it --
and the screen has already centred on the tile, so it is ground this run has covered. It clears the
map's frontier mark on the same rule the coordinates do: only ground never stood on before.
2026-09-22 14:57:34 +00:00
acamilo
31f35c6f2f macros: the whole-map grid is read from the tile the screen is centred on
Measured on the cartridge from the rung-10 checkpoint: `wXCoord` and `wYCoord` change at the *end*
of a sixteen-frame step while the background scrolls throughout it, so for fifteen frames of every
sixteen the screen buffer is centred one tile ahead of the coordinates. The cross-check compared
the decode of the fly's own tile with the screen's reading of the tile ahead and refused. Pewter
City decoded on 118 of 120 standing frames and on none of the moving ones, so every walk the fly
actually took was re-planned over the ten-by-nine window, which is the oscillation of row 23.

Nothing in the pinned symbol table says "a step is in progress" and a new address cannot be pinned
without the disassembly `gen_symbols.py` reads, so the anchor is measured rather than named: the
screen is centred on the fly's tile or on one of its four neighbours, and the one it is centred on
is the one whose whole neighbourhood agrees with the decode. A decode with a wrong stride, a wrong
quadrant or a half-loaded map agrees with none of the five, and neither does the mid-warp tear the
per-serve check was added for, so both refusals stand.

`state::step_destination` names the tile the step is landing on, for the ledger the next commit
writes. The town fixture gains two landmarks beside the fly, because a neighbourhood that is the
same tile id in every direction cannot tell one anchor from another.
2026-09-22 14:57:34 +00:00
acamilo
ed1d321b02 probes: a mid-step survey, and the errands on the pad dump
`FLY_PROBE_CATCH=step` holds one direction from a checkpoint and prints, per frame, the
coordinates, the grid's verdict, the tiles the cross-check disagreed on and every candidate for
"a step is in progress". It is what row 54 turns on: whether the coordinates change at the start
of a step or at its end.

The pad dump gains section 13's two errands -- which building each names, whether the ledgers have
paid it, and what the walk would aim at -- because an aim with no press settles where it stands.
2026-09-22 14:57:33 +00:00
acamilo
903629db00 docs: what MEDIA-01 enforces in state-media-v1 section 2
Two readings of the section that are now checked rather than assumed: the
bootstrap window is exactly the boundaries where max(0, boundary - delay)
is zero and its repeated O[0] is the same artifact, and a chunk that
continues the previous one exactly cannot claim a discontinuity, which is
what keeps the restore rule checkable instead of advisory.
2026-09-22 14:54:48 +00:00
acamilo
438eaf3130 Merge main at cb9a88c: the Pewter macro work, which this slice does not touch 2026-09-22 14:33:30 +00:00
acamilo
7b7ebcdf28 session: parallel processes, a launcher and the fault behaviour
SESSION-02: one agent process per fly and one environment process under
the coordinator over the Unix-socket transport, compared against the
in-process composition and a dedicated-thread variant. The mode is the
only thing that changes; the composition, the coordinator, the workers
and the router are the same code in all three.

The launcher is the configured supervisor. It owns a total thread budget
with one allocation per participant, refused as BUSY before anything
starts when the total cannot cover it; the configured client, service,
worker and port identities, proved in Worker.Hello before the
coordinator pins a registration; Worker.Status health on the
supervisor's own monotonic clock at the ipc-v1 section 6 budgets; and
reaping, where Worker.Shutdown is the request and the operating system
is the guarantee.

The worker executable is a subcommand of this crate's one binary, which
is what implementation.md section 2 allows in place of a separate
worker crate.

The coordinator's fault behaviour: every failure names the participant
it is attributed to, every domain call has a caller-side deadline so a
dead participant is a diagnosed outcome rather than a hang, and failing
fences the epoch -- the boundary stops, the handles drop, and no further
transition or publication is allowed. Agent.Initialize now carries the
launcher's allocation, and an agent refuses one asking for more.

tests/processes.rs proves every acceptance bullet once per execution
mode, and the two section 4 rows SESSION-01 could not reach in one
process: a router restart during a world advance, and an old worker's
reply after a restart. measure compares the three modes at one, two and
four agents; its table is in the crate README, and it is not a capacity
claim.
2026-09-22 14:33:25 +00:00
acamilo
8d999b2881 Merge branch 'main' into feat/sf-media-01 2026-09-22 14:30:34 +00:00
acamilo
8afeaf6c92 bus: wait for the teardown the disconnect test asserts
caller_disconnect_cleanup_works_before_and_after_consumption failed 37 times
in 240 runs under load: the reply raced the router's own teardown. op_reply
reports routed:false only once disconnect has marked the call detached, and
the router runs that when its connection task reads EOF, while Client::close
waits for this client's reader only. The reply could therefore reach a
connection that was still closing and be routed. Nothing escaped -- teardown
releases those roots -- but the flag was read one step early.

The test now settles on the caller's connection being gone before asserting
the section 6 sentence, which is the poll the rest of the file already uses.
360 runs after the fix, 0 failures.

bus-conformance.md records the mechanism, the counts and the two other
intermittent failures seen in the same sweep, which are left to the bus slice.
2026-09-22 14:29:57 +00:00
acamilo
40c6a88949 media: native observation schemas and the presentation handoff
Puts MEDIA-01 on top of the bus ArtifactRef rather than beside it.

fly-session-types gains the two validators a descriptor and a reference
cannot carry on their own: AudioTimeline, which holds one stream chunk
sequence for one epoch (no overlap, no backwards, the first chunk after a
restore marks the discontinuity), and check_imported_asset, which checks
that a transient artifact carries an installed asset content without ever
converting one identity into the other.

fly-session gains a media module: a ViewPipeline that renders one native
frame per boundary and serves the frame the declared observationDelaySteps
requires (so bootstrap repeats O[0] exactly while the pipeline fills), an
AudioSource with an exact rational sample budget and a deterministic
integer-phase waveform, the Phase C acceptance checks, a latest-subscription
Spectator with finite credits, a SensorLog recording what each agent read,
and an AssetRegistry for installed persistent content.

The counter arena now emits a real 32x24 RGBA8 pattern per boundary and one
audio chunk per transition; the coordinator forwards the one owned view
handle to every agent Commit and publishes the same handle, with the audio
handles, for presentation.

36 new tests: 12 shape rules against the contract crate and 24 session-level
acceptance tests over both transports.
2026-09-22 14:28:58 +00:00
acamilo
cb9a88c7a3 docs: v0.4.6 status
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-22 14:25:23 +00:00
acamilo
d2e2d2e51d Merge fix/loop-20260922T1217: readable YES/NO prompts deal their answers, the nurse enters the talked ledger, a prompt that reopens unchanged is excluded 2026-09-22 14:25:21 +00:00
acamilo
de0eed7da9 traps: both hunt arms, and row 54 -- the cycle that replaced the one this branch closed
BACK in a text box goes 291 to 0 and the museum is left in the first window and never returned
to, which is rows 51 to 53. The hunt's own two measures do not improve: 193 distinct tiles become
175 and 59 flagged windows become 69, because a new three-macro cycle takes the old one's five
tiles for seven and a half brain minutes and then the fly spends four minutes inside one battle,
which the tile rule flags as hard as a stall. Battle frames go 6,948 to 20,894.

Row 54 names the new cycle with the two readings that fit it, and the residual under both is the
whole-map grid being refused on every frame the fly is mid-step. Reported rather than smoothed:
the ethos check's 'the trap hunt improves' does not hold for this branch.
2026-09-22 14:23:00 +00:00
acamilo
ab18d75db9 docs: session framework, the contract and session slices
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-22 13:48:09 +00:00
acamilo
f48cab5258 Merge feat/sf-session-01: the session-types contract crate and package, and the synthetic lockstep session over the bus
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-22 13:41:29 +00:00
acamilo
e80df561dd session: take Worker.Acknowledge's bound from the contract, not a local copy
The CONTRACT-01 amendment narrows Worker.Acknowledge ids to the req-<U64> form, which this
crate already read as DomainRequestId. The 1..=16 bound now comes from the contract's
MAX_ACKNOWLEDGE too, so there is one definition of it rather than two.
2026-09-22 13:36:42 +00:00
acamilo
93925673eb tests: print the rank the ROM run ends on
It ends on rank 10 with no badges: the fly reaches the gym on 15 macros and presses TALK fifteen
times inside it, and 55 brain minutes of the stub rotation do not beat the leader. The road is
what this branch is about; winning the fight is the fly's.
2026-09-22 13:18:24 +00:00
acamilo
ea4935ade2 Merge feat/sf-contract-01 at 57de812: the checked rational addition, the schema set held to the readers list and the Worker.Acknowledge id amendment 2026-09-22 13:17:35 +00:00
acamilo
0ace07c57c Merge main: the bus slice, whose write-gate fix removes the flybus teardown flake under parallel load 2026-09-22 13:17:30 +00:00
acamilo
d5a21e9a5d traps: rows 51 to 53, the museum's missing rows and a BACK with no box under it
The reproduction, the three surveys and the before/after for the rung-10 Pewter stall, with the
ROM-gated run's table. Rows 51 (UNKNOWN's two states), 52 (a building with no row on the map
graph) and 53 (a frontier excluded by a window that lapses) each with their tests.
2026-09-22 13:14:47 +00:00
acamilo
57de8126d5 fix(session-types): check the rational addition, and hold the schema set to the readers list
Two fixes from the independent review of f043cd7.

RationalNs::checked_add cross-multiplied into u128 and then added without a check. Each
product fits, but their sum does not: two reduced fractions near the U64 maximum add to about
2^129, which panics in debug and wraps in release, after which the reduction returns a
confidently wrong rational. Every multiplication and the addition now go through a checked
path, as ipc-v1 section 2 requires, with a fixture row and a test that reaches the overflow
rather than the "does not fit U64" reduction limit.

The schema-set test checked fourteen hardcoded type names, so a new payload type could have
shipped outside contractDigest. It now iterates the readers own list, all fifty of them.

Also amends ipc-v1 section 5, dated: Worker.Acknowledge carries req-<U64> request ids, not
arbitrary Ids, because the serial watermark rule in that same section cannot reject reuse
after acknowledgment unless the acknowledged id carries its serial.
2026-09-22 13:12:59 +00:00
acamilo
e9f5d693bb docs: sections 12.13 to 12.16, and the stall window's second signal
12.13 the two states UNKNOWN holds and why one of them presses nothing; 12.14 a frontier no walk
can reach, remembered per map rather than windowed; 12.15 what the ratchet's stall window cannot
see; 12.16 what is still in the way of the badge, measured rather than fixed -- the town errands
being session state, GO FRONTIER still being most of the run in Pewter City, and the whole-map
grid being refused on every frame the fly is mid-step (118 of 120 standing still, and the
disagreement is exactly one tile row in the direction of travel).

13.1's pad-empty table gains the row for the one empty pad that ends itself, and the ladder's
recovery budgets carry the amendment to the 2026-09-17 progress rule.
2026-09-22 13:12:31 +00:00
acamilo
4edc4c9bcf tests: ROM-gated, the fly gets out of the museum and into the gym
From the live rung-10 checkpoint with the stub rotation: map 0x34 to map 0x36 on frame 2423, on
15 macros, against never in five and a half live hours. TALK is on the pad inside the gym -- the
fly standing in front of one of the people rung 11's place names -- and it presses it 15 times
there. BACK on a dialog or unknown frame is 0 against the 189 the hunt measured on Pewter City
alone, no frame deals a pad on an unknown with nothing drawn on it, and GO FRONTIER starts 0
times on the museum's two floors.

The unit test beside it is the other half of "the objective reaches the leader": on the gym's
own map the rung's place resolves to the person standing in it, the walk aims at the four tiles
around them, the door is not a candidate while they are there, and talking to them empties the
list. It also pins what the ROM run showed: the town's errands come first for every map in the
area, the gym included, so both have to be paid before the objective is the leader.
2026-09-22 13:12:31 +00:00
acamilo
574e9fec0c Merge feat/sf-contract-01 at f043cd7: packages/session-types, the canonical number rule and the seed/envelope specifications 2026-09-22 13:03:56 +00:00
acamilo
0d098ee408 session: both transports for every integration test, and the mid-step pause written into step-v1
Review found two Memory-only tests behind a README claim that every integration test runs
twice. Both are now generated by both_transports!: the one-agent composition, and the
old-epoch refusal, which is one of the three tests the old-worker-replies row cites. The
README says which single test walks both transports inside itself instead, because it
compares their traces against each other.

step-v1 section 2 gains the mid-step pause line and a dated amendment saying why it is not
a new edge: a pause requested during a transition is the ordinary
Committing(k) -> Ready(k+1) edge followed by Ready(k+1) -> Paused(k+1), because section 6
requires the transition to finish first, so the only boundary such a pause can land on is
the one the transition just committed.
2026-09-22 13:00:14 +00:00
acamilo
e0285016aa ratchet: getting nearer the objective is progress the coverage figure cannot carry
Two Stuck rollbacks fired on rung 10 inside half an hour, both on a fly that was walking, and
both were the ratchet working to contract: the stall window is reset by exploration -- one tile
the run has never stood on -- and a fly crossing a town it has already covered to reach the
rung's own door earns none of it. Entering a map for the first time already counts, because a
new map is ground nobody has stood on; re-entering one does not, which is what the museum was.

So the window gains a second signal, passed in by the caller and meaningless to the ratchet
itself, exactly as coverage is: the Pokemon loop answers with "nearer the objective, in map
hops, than this run has ever been", over the same map graph GO OBJECTIVE walks. It can fire at
most once per step of the road, it spends no budget, it captures nothing and it skips no
trigger. Nothing in the macro layer reads it back and no button is bound on it.

The checkpointed ratchet state is untouched: the signal is a level on one sample, not a counter.
2026-09-22 12:55:39 +00:00
acamilo
26f957e084 macros: a frontier no walk can reach is a fact about the map, not a window
GO FRONTIER was 1,235 of the rung-10 run's macro starts in 47 minutes, over two museum floors
and a town whose ground the fly had already covered. The tiles it aimed at were real and
unreachable: 98 walkable tiles on the museum's ground floor, 62 of them reachable from the door,
39 never stood on and almost all of those behind the admission desk. A walk that can reach none
of its goals refuses no route and writes every goal to the blocked ledger -- which is a ten
brain minute window, so all of it came back and the refusal happened again, once per hold.

A window is right for a target somebody is standing in front of and wrong for ground the map has
fenced off. So the refusal is remembered per map instead, with no window, and it is cleared by
the one event that can change the answer: the fly standing somewhere on that map it had not
stood on before -- a door opened, a script carried it through, somebody moved out of a doorway.
Re-entering the map clears nothing, which is the loop the window made.
2026-09-22 12:48:09 +00:00
acamilo
f043cd79c0 docs(session-framework): seed derivation v1 and the FLYSESS1 checkpoint envelope
Two specifications CONTRACT-01 owes the slices that cannot be built without them.

seed-derivation-v1: the versioned derivation of independent per-agent seeds from a recorded
master seed and stable agent ids, the exact hashed material, the lane rule that keeps a seed
away from zero (the pinned kernel RNG is an xorshift, whose state must not be zero), the
properties a composition may rely on, and the test vectors both languages reproduce.

checkpoint-envelope-v1: the exact byte layout of the new envelope per state-media-v1 section
4, with the header, canonical-JSON manifest, 112-byte payload table entries, eight-byte
aligned payloads and the digest footer; the manifest field set; what a reader enforces and in
what order; and the durable commit sequence of section 6, whose commit point is the store
manifest rename. FLYSIM01 is unchanged, refused at the magic, and stays separately readable.

Both are specifications with fixtures, not the store: generations, rotation, the capture
queue and the restore flow remain STATE-01.
2026-09-22 12:47:48 +00:00
acamilo
e183718e7a docs: session framework, the bus slice
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-22 12:44:38 +00:00
acamilo
84ed0410bf macros: an UNKNOWN frame with nothing drawn on it deals no buttons
BACK was 678 macro starts in 47 minutes on rung 10, 189 of them on Pewter City with no text box
on screen at all. BACK is on no overworld and no dialog pad, so every one of them was dealt by
Scene::Unknown -- which holds two states under one name. One is a screen this crate cannot name,
the Pokedex or the trainer card or OPTION, where A and B are what leave it. The other is a frame
of the overworld where the cartridge is driving -- a warp in flight, a scripted push-back, the
museum guide walking the fly through the door -- which the detector calls Unknown because the
buttons are not reaching the player.

On the second, NEXT and BACK are an A and a B pressed into somebody else's script: they change
nothing and they complete where the fly stands, which is section 12.2's trap with no box to
advance. So Unknown's pad is dealt on whether a box is drawn, and a scripted overworld frame is
an empty pad the fly waits out -- the cartridge gives the buttons back by itself.
2026-09-22 12:43:53 +00:00
acamilo
a389734201 geography: the Pewter museum is two rows on the map graph
Rung 10 spent five and a half hours in Pewter City with the objective two doors away, and the
fly kept ending up inside the museum, where GO OBJECTIVE, GO SHOP and GO HEAL were all off the
pad: a map with no row in the graph has no neighbours, so next_hop answers nothing and area_of
answers nothing. Named as a residual by the previous review and worked here.

Both ids come from the cartridge's own warp table, surveyed from the rung-10 checkpoint rather
than counted: Pewter City names 0x34 at (14, 7) and at (19, 5), which are the museum's two
doors, and 0x34 names 0x35 at (7, 7), which is the staircase. The upper floor has no area, for
the same reason a bedroom has none -- no front door of its own -- and the road out of it is the
staircase, which is what the fly needs there.
2026-09-22 12:43:52 +00:00
acamilo
99a6a08bad probes: say which tile the map decode and the screen disagree about
The rung-10 review needed to tell "the whole-map grid is wrong on this map" from "this frame
was mid-step", and the refusal label cannot: both read "screen disagrees". So the decode is
split from its own cross-check, the disagreement is readable tile by tile, and the map survey
stands still for a while and counts how many of those frames decode.

On Pewter City, measured from the rung-10 checkpoint: standing still it decodes on 118 of 120
frames, and the frame the survey caught disagrees on three tiles by exactly one tile row in the
direction the fly was walking. The map is fine; the reading is refused while the fly is moving.
2026-09-22 12:43:41 +00:00
acamilo
431f67baa0 Merge feat/sf-bus-conformance: bus-v1 conformance audit, the teardown-poll race, BUS-01..03 acceptance tests and the measured example
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-22 12:40:29 +00:00
acamilo
c3bda90b82 test(flybus): prove the two section 9 rows a review found cited but unproven
An independent review of 4f44894 accepted the audit with two required evidence
changes, neither of which needed a code change.

"Classification is not a topic-name heuristic" cited a lane-order test that
never varies a topic name. The row is now conforms-by-construction, naming the
four scheduler functions that read only the queue an item sits in, and
a_topic_named_like_a_notice_is_still_classified_as_topic_data publishes on
topics called call.failed, route.removed and subscription.closed and asserts
each arrives as a topic.message delivery with its payload intact.

"A latest subscriber never causes BACKPRESSURE" cited a bounded-mode test and
the ignored measurement. a_latest_subscriber_never_refuses_a_publication floods
one unconsumed latest slot with 100 publications of 60 KB, six times the
per-client bounded byte pool, and asserts none is refused, 98 coalesce, the
queue stays at one and the credit never comes back. That row also underwrites
the byte-budget deviation and the first contradiction.

Both tests run over both transports.

The coordinator resolved both contradictions in the spec rather than the code,
so bus-v1 carries them as dated amendments with one line of reason each: the
section 9 table row becomes "Per-client ordinary bounded queued envelope bytes"
with a new "Latest subscription slots | subscriptions x 64 KiB" row, and the
section 2 sketch drops the budget argument and shows the deadline at the caller.
With the amended wording the byte-budget row conforms, so the report's counts
are 178 / 9 / 1 fixed / 7, and the crate README's differences 4 and 8 say they
no longer differ.
2026-09-22 12:39:07 +00:00
acamilo
2ef842ed90 session: one object helper, in the types facade where the rest of the shims live 2026-09-22 12:33:01 +00:00
acamilo
52ad46ecb0 refactor(session): build the session slice on the shared fly-session-types crate
CONTRACT-01 landed its crate, so the local stand-in module goes away and the domain
scalars, method payloads, their validation, the canonical digests and the trace format
all come from the contract. `src/types.rs` is a facade over that crate plus the few
things a coordinator needs that are not part of the type contract: a session-side
DomainError, the synthetic composition's schema and event-id derivations, and the
coordinator-local ControllerIntent, PortBinding and AgentOutcome that never cross the
bus.

Consequences worth naming:

- Payloads are read and written through DomainType::from_json / to_json instead of
  serde derives, so a misspelled required field fails where the contract says it
  should. serde, sha2 and ryu-js leave this crate's dependencies with them.
- The step-v1 section 8 trace is the contract's TransitionTrace, with behaviour and
  operational metadata already separated; the dispatch-order comparison now runs over
  the contract's own behaviour encoding.
- Two things the migration found. IN_PROGRESS is raised strictly before any mutation,
  so its certainty is "none", not "unknown"; the local module had it wrong.
  WorldObservation::validate_against checks the views a result carries but does not
  require every declared view to be present, so requiring them is made explicit in the
  coordinator's phase C check, where step-v1 puts it.
- The lost-Advance-result injection now waits for the worker to report the operation
  before abandoning the call, so the case it injects really is a loss after dispatch
  rather than a cancellation before it.

Gates: cargo test -p fly-session (61 tests, both transports), cargo clippy
--all-targets clean, and the runnable example produces the same behaviour trace over
both transports.
2026-09-22 12:30:41 +00:00
acamilo
825b584320 Merge feat/sf-contract-01: the fly-session-types crate, so the session slice builds on the shared contract types 2026-09-22 12:30:03 +00:00
acamilo
4f44894ede docs(design): a bus-v1 conformance report, and the amendments it asks for
bus-conformance.md audits the flybus crate against bus-v1 sections 2 to 11, one
row per normative sentence: requirement, status, code location, the test that
proves it. 195 rows, 177 conforming, 10 deviating with the sentence that allows
each, 1 must-fix (the teardown starvation, fixed in this branch) and 7 not
implemented with an owner named. It also maps every BUS-01/02/03 acceptance
bullet to its test, reconciles all ten of the crate's own differences from the
draft, records the frame measurement with the reasons it is not a capacity
claim, and lists the two contradictions inside the draft it will not guess at:
the per-client queued byte budget against the latest-mode guarantee, and
section 2's call budget against section 6's client-owned deadline. Neither
changed any code.

bus-v1 gains section 12: dated amendments for the three error codes the
implementation needs and the draft left unnamed (CONFLICT, NO_TOPIC,
ARTIFACT_MISMATCH), one line of reason each, and for the one operation it adds
(rpc.responder.release), which is how bounded call correlation ends when a
handler keeps reply authority after releasing the request delivery. Section 9's
error list is inclusive, and section 4 already provides for a digest change.

The crate README points at the report, carries the new measurement summary in
place of the old numbers, and describes the two new test files; the design index
and the flybus index point at the report too.
2026-09-22 12:07:11 +00:00
acamilo
cf757af510 test(flybus): assert the example's output, and split the frame measurement up
The guide's first deliverable is one small program with a counter RPC, a pub/sub
observer and a frame artifact held past its message object. examples/demo.rs was
already that; its body moves into run(), which returns the lines it prints, so
tests/example_demo.rs can assert all seven of them. The counter now uses the
spec's own example.counter / Counter.Increment / {"amount": 1} and pins the
registration it discovered, and the example prints the router's root count while
the frame is held and after the last handle goes, so the lifetime it
demonstrates is visible rather than implied. cargo run -p flybus --example demo
prints the same lines.

tests/perf.rs reports what BUS-03 and bus-v1 section 11.7 actually ask for.
Allocate, the producer's copy into staging, the router's seal copy, publish
admission, the RPC round trip and a consumer's readback are six separate
percentile lines instead of one. The router gets its own two-thread runtime
whose threads carry a distinct name, and per-thread CPU is sampled from /proc by
that name, so router CPU is separable from the clients' in the same process.
Store bytes, outstanding roots and queue lengths are reported live as well as
peak.
2026-09-22 12:01:43 +00:00
acamilo
148dbb2fae test(flybus): the BUS acceptance bullets that were unproven, and transport traces
One named test per implementation-guide bullet that the existing suites did not
already cover: a lost result that leaks no roots while the endpoint's own cache
still replays it, a retransmission of one domain body under a fresh call id, no
failover onto a replacement registration, a status RPC answered while another
handler is delayed, and a disconnect that reclaims logical ownership without
touching a file the consumer still has open. Each runs over both transports.

BUS-01 also asks for equivalent behaviour traces from the two transports, so
tests/common/mod.rs gains a Trace recorder whose record() panics on any
router-issued id: a trace can hold methods, payload fields, counts, sequences,
credits, cancel states and error codes, and nothing operational. One RPC
scenario and one pub/sub-with-artifacts scenario produce 29 events, identical in
memory and over a Unix socket. FLYBUS_TRACE=1 prints them.

The sixteen bullets the older suites already prove are cited in the conformance
report rather than duplicated here.
2026-09-22 12:01:34 +00:00
acamilo
7708bf12cd fix(flybus): stop connection teardown being starved by the frame it waits for
The write gate was one mutex the writer held across every synchronous transport
poll, and close_conn took that same mutex before reclaiming a connection's
owners. Under a transport that accepts a byte per poll the writer re-acquired it
hundreds of times while teardown queued behind it, so teardown could be starved
for a whole frame and the delivery completed just before its owner was
reclaimed. bus-v1 section 9 puts release and route-health control out of reach of
telemetry starvation; tests/sol_rereview_regressions.rs's poll-gate regression
failed 6 release runs out of 6 alone, and about one debug run in five.

The gate now separates "teardown has begun" from "a poll is in progress": a
mutex plus a condvar. begin_close sets closing once without waiting, then waits
for the poll already in progress and records whether it left a frame half
written. The writer brackets each poll with enter_poll (refused once closing)
and leave_poll(bytes) instead of holding a lock across it, so teardown's window
is one poll rather than a frame, and no byte follows it. close_conn no longer
holds the gate across reclamation. Every item is pub(crate): no public
signature changed.

The regression test no longer synchronises with a sleep. The shutdown thread
announces itself on a channel, and what follows is structural: teardown cannot
pass the gate until the held poll returns, which only release() allows. Its
delivery is 50 KB now, which a writer resuming a byte per poll cannot finish
inside the window, so the test observes the ordering instead of a coin toss and
asserts the two legal shapes of the stream: cut short with nothing appended, or
never begun with exactly the closing notices following.
2026-09-22 12:01:24 +00:00
acamilo
c567727351 feat(session-types): the TypeScript half of the contracts, over the same fixtures
packages/session-types mirrors the Rust crate: canonical JSON (RFC 8785), strict parsing,
canonical digests, readers with a validate step for every payload of ipc-v1, workers-v1,
state-media-v1 and publishing-v1, the trace comparator, seed derivation and the FLYSESS1
envelope. It loads the crate fixtures/ directory rather than keeping a copy.

43 tests with node test runner, as in packages/feed. Both languages assert the same recorded
canonical bytes and digests, so agreement is checked rather than assumed; the envelope test
goes further and requires the bytes this package writes to equal the fixture the Rust crate
wrote.

Also narrows the canonical number rule to a value-based one (finite, and within the exact
integer range when integral), because JSON.parse cannot tell 1e21 from the same digits
written out, and two implementations that disagree about one number agree about no digest.
2026-09-22 11:54:13 +00:00
acamilo
5defb6766d feat(session): fly-session, the SESSION-01 synthetic sequential transaction over flybus
The lockstep coordinator, its phase machine and a synthetic composition, as a new
workspace member. Every worker method is a Flybus RPC to an incarnation-pinned
service; the domain request ids and result caches of ipc-v1 section 5 sit in front of
every mutation.

- coordinator: the step-v1 section 3 transaction in order -- prepare every agent
  concurrently, run one executor per agent in sorted agent-id order, assemble all port
  controls in descriptor order, send exactly one Environment.Advance, evaluate the task
  once, commit every agent concurrently, and move the committed boundary only when all
  of them succeeded. Sequential, concurrent and reversed dispatch are selectable and
  must agree.
- phase: the section 2 machine as an explicit edge table, Paused and Failed included,
  with a committed-boundary predicate that gates pauses, captures and publication.
- clock: checked rational accumulation. A 60 Hz world with a 1 ms model tick runs
  16, 17, 17 ticks and comes back to a remainder of exactly zero.
- dedup: operation keys, the cached reply with its own artifact holds, CONFLICT,
  IN_PROGRESS, RESULT_EXPIRED, STALE_STEP, the lifecycle bound and Worker.Acknowledge.
- worker: the dispatch shell. Admission order and the result cache live here; the
  endpoint mutex is the worker's simulation lock, so one mutation runs at a time while
  Worker.Status answers from a separate cell.
- agent, environment, task: a fake model with an explicit seed and a mutation counter
  the tests read, a fixed readout stub, a counter arena that seals one immutable frame
  per boundary, a deterministic task and the identity executor.
- fly_session_types: the CONTRACT-01 domain types as a local stand-in, reconciled with
  the shared crate in a following commit.

Tests run twice, over the in-memory transport and over a Unix socket, through the same
router: the SESSION-01 acceptance bullets and every section 4 failure-injection row
that applies to this slice.
2026-09-22 11:46:57 +00:00
acamilo
1652afad11 test(session-types): the CONTRACT-01 fixtures, cross-checks and their Rust tests
fixtures/ is the shared contract corpus: accepted payloads with their canonical JSON and
digest, refused payloads with the rule each breaks, raw byte sequences that must not even
parse, recipes for the payloads too large to store, the U64 and double boundaries, rational
arithmetic vectors, the identity matrix, descriptor cross-checks, operation keys and bodies,
trace comparison variants, the schema set and its digest, seed vectors and one FLYSESS1
envelope. packages/session-types loads the same files.

Tests: 45 across nine files. Round trips preserve every field and reproduce the recorded
canonical bytes; duplicate keys, invalid UTF-8, NaN, trailing data, oversize typed values and
oversize envelopes fail; U64 and double boundaries reject on the right side; the four
identities never accept one another spellings; the accumulator produces 16, 17, 17 and a zero
remainder; the trace comparator ignores wall time, request ids, callIds and delivery ids and
notices every behaviour change; the contract digest survives reformatting and moves when a
field, bound, enum member or type changes.

Also: a crate README, and the update_fixtures example that regenerates the derived files.
2026-09-22 11:41:04 +00:00
acamilo
d2b916602e feat(session-types): the fly-session-types crate: domain scalars, closed enums, method payloads, canonical JSON and digests
The CONTRACT-01 type definitions, committed early so the session slice can build on
them while the fixtures and cross-language tests are still being written.

- scalar: Scope, RationalNs (reduced, positive denominator, 0/1 zero, checked
  arithmetic and the step-v1 accumulator), SchemaRef, TypedValue with its 32-KiB
  canonical-JSON cap, and the four identities that must not be confused (bus callId,
  domain requestId, artifact identity, delivery/hold owner token) as distinct types.
  Id, U64 and Digest call into flybus::wire instead of forking the encodings.
- canonical: RFC 8785 canonical JSON, SHA-256 digests, the operation key and the
  canonical body rules of ipc-v1 section 5, with bus identities refused in a body.
- rpc, workers, media, publishing: the payloads of ipc-v1, workers-v1, state-media-v1
  and publishing-v1, each with a validate step for the documented ranges, uniqueness,
  ordering and bounds.
- schema: the canonical schema set, whose digest is contractDigest, declared as data
  so source formatting cannot change it.
- trace: the step-v1 section 8 record, behaviour separated from operational metadata.
- seed, checkpoint: the seed derivation and FLYSESS1 envelope layout.
2026-09-22 11:27:06 +00:00
acamilo
b085622a60 docs: v0.4.5 status
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-22 11:18:36 +00:00
acamilo
e65e712cee Merge fix/loop-20260922T1012: readable YES/NO prompts deal their answers, the nurse enters the talked ledger, a prompt that reopens unchanged is excluded 2026-09-22 11:18:34 +00:00
acamilo
9280cfac2e macros: count the centre's presses, not the whole run's
The first run of the new ROM test failed on YES 134, and every one past
the sixth was in Pewter City or the gym the fly had walked to -- which is
the fly playing the game, not the ring. Row 41 is about the presses spent
in the room, so the harness records starts by name while the fly is still
on the map it resumed on: 6 macros there, YES 2.
2026-09-22 11:17:30 +00:00
acamilo
e1b4819ebe docs: row 41 worked, with the survey and the before/after
Where the fly was standing, the conversation press by press, why TALK
retired nothing, the four rows this branch closes and the one it leaves,
the ROM-gated run, and the hunt: 1 distinct tile to 437, 73/73 flagged
windows to 54/73, YES in a text box on map 0x3a 1424 to 4, dialog frames
69,469 to 5,492.

The 54 that still flag are a different trap on ground the before arm
never reached -- GO FRONTIER runs and a GO OBJECTIVE/BACK/GO FRONTIER
cycle in Pewter City -- and that is the next brief.
2026-09-22 11:16:18 +00:00
acamilo
af7a009395 Merge feat/flybus: the flybus crate (Flybus v1 draft) replayed from the archive, with a design index
Some checks are pending
ci / node 22 (test + typecheck) (push) Waiting to run
ci / rust stable (cargo test --workspace --release) (push) Waiting to run
ci / infra/tests/lint.sh (push) Waiting to run
ci / playwright apps/stage (allowed to fail) (push) Waiting to run
2026-09-22 11:02:31 +00:00
acamilo
5ad9b49e03 Merge docs/session-framework: the application and session framework contracts (bus, ipc, step, workers, state-media, publishing) and implementation guide 2026-09-22 11:02:31 +00:00
acamilo
5b9f5a54ca docs: section 12.12, the nurse's box
What was live, what the survey read, and what the three candidate
readings in the brief turned out to be: not the prompt, not HEAL, but the
dialog pad dealt unconditionally with TALK to get back into it. The
scene-by-scene table splits the dialog row in two and the pad-empty sweep
gains the reopen ledger's own residual. macros-wram.md narrows its "no
observable for a choice" claim rather than withdrawing it.
2026-09-22 10:39:15 +00:00
acamilo
0bf4b965fb macros: tests for the nurse's box
Unit: HEAL is off a full party's pad, including the rung-10 party's own
numbers, which is the assertion that the loop was never the heal's; TALK
is off a rested nurse's pad and on a hurt one's, and an ordinary
villager is unaffected; her prompt deals one answer and a plain box still
deals three; a readable prompt that is not hers deals both answers and no
NEXT; an answer whose prompt reopens is excluded and one that settled the
box is not; a completed heal and a declined prompt both retire her.

ROM-gated from the live checkpoint (FLY_CENTER_CHECKPOINT): the fly
leaves map 0x3a on a bounded number of macros, YES starts stay under
five, NEXT is on no pad while a prompt is readable, and TALK is on no pad
at a rested nurse. The run also counts dialog and prompt frames, which is
what makes the forty-six-to-one ratio visible.
2026-09-22 10:39:15 +00:00
acamilo
70a2433e25 macros: the nurse is talked to once, and a reopened box excludes its answer
TALK's precondition reaches over a counter because the cartridge does;
its talked-ledger entry was read one tile ahead. So TALK was bound at the
counter and recorded nothing, 107 times, and the nurse was never retired.
The entry now comes from the same reading the precondition makes.

A completed HEAL writes her into the talked ledger: the macro has had the
conversation with its own presses, and the reached window would otherwise
expire in ten brain minutes and offer the ring again. A declined prompt
writes it too -- 12.4 inverted for one person, because the pad only ever
offers NO at her prompt when the party is already full.

And the general rule: a YES or NO whose prompt is up again on the same
map and tile within one hold did nothing, so that answer joins the
blocked ledger for its window.
2026-09-22 10:39:04 +00:00
acamilo
ce7644e2e8 macros: a choice's pad is its answers, and TALK is not a nurse's errand
On a box that is a readable YES/NO prompt, NEXT is YES under another
name -- an A press at a two-option menu confirms the option the cursor is
on -- so it is off that pad and the pad is the box's two answers. That is
12.10's pair rule in a dialog.

At the nurse's own prompt only the answer that changes something is
bound: YES with a hurt or statused party, NO with a full one, read from
the same byte HEAL's precondition has read since section 13.

And TALK is off the pad at a nurse the party has no use for. Her
conversation is a service whose need the cartridge publishes, and a ring
of text that ends where it began is section 12.2's trap. Nobody else is
narrowed.

An answer the reopened-prompt ledger excludes leaves the pad, and the
exclusion never empties one: a box nothing can answer cannot be left.
2026-09-22 10:39:04 +00:00
acamilo
86f0e7018d macros: read the nurse's YES/NO box, and survey her conversation
Row 41 is the Pokemon Center nurse: 2,142 YES starts on one tile of map
0x3a with a party at 70/70. The survey that names it is a new probe mode
(FLY_PROBE_CATCH=nurse) that leaves the box with B and then pulses A,
printing every state the conversation passes through with the two-option
menu's own geometry beside it. It is a ring of forty-six presses and the
box is a choice on exactly one of them.

So the reading: wFontLoaded plus the border DisplayTwoOptionMenu draws,
plus the cursor it parks inside it. Both halves are needed -- the cursor
bytes survive the box closing, so all forty-six frames carry the
geometry. It claims that box and not "a choice is open" in general.

TargetKey gains Answer { at, yes } for the reopened-prompt exclusion.
2026-09-22 10:38:52 +00:00
acamilo
75375fa799 docs(design): index the flybus communications bus 2026-09-22 03:47:44 +00:00
acamilo
093db147eb feat(flybus): add standalone IPC bus and conformance tests
(cherry picked from commit 95ac8d1bdbc866a7c49fad3ddba468d945267650)
2026-09-22 03:46:27 +00:00
240 changed files with 94215 additions and 1398 deletions

View file

@ -89,7 +89,9 @@ decoder preset to use.
disassembly at commit 0cd19d3 (`symbols.rs`), gates rewards on a playable state, baselines already disassembly at commit 0cd19d3 (`symbols.rs`), gates rewards on a playable state, baselines already
achieved flags on the first sample so a restore never replays them, and pays only positive achieved flags on the first sample so a restore never replays them, and pays only positive
rewards: story flags, exploration coverage (capped per map), new areas, Pokédex entries, trainer rewards: story flags, exploration coverage (capped per map), new areas, Pokédex entries, trainer
flags, decaying wild wins, badges. Version `pokered-unique8-v5`. flags, decaying wild wins, badges, exits found outdoors, catches, conversations indoors and items
picked up (`docs/rewards-learning.md`). Version `pokered-unique8-v7`; a deploy that names the
previous version in `FLY_ACCEPT_ADAPTERS` migrates its checkpoints instead of refusing them.
- Ratchet (`ratchet.rs`): a 38-rung ladder (boot, bedroom, Pallet Town, Oak's lab, starter, parcel, - Ratchet (`ratchet.rs`): a 38-rung ladder (boot, bedroom, Pallet Town, Oak's lab, starter, parcel,
Pokédex, each town, each badge, the Elite Four, Champion). On first reaching a higher rung in a Pokédex, each town, each badge, the Elite Four, Champion). On first reaching a higher rung in a
safe state it archives the emulator snapshot; on a stall (120 s without new exploration) or a game safe state it archives the emulator snapshot; on a stall (120 s without new exploration) or a game
@ -131,6 +133,9 @@ publish. Pacing uses absolute deadlines at 1.0x by default; it never skips frame
snapshot at 30 Hz: a JSON header (status, rates, learning stats, game mode, milestone rank and snapshot at 30 Hz: a JSON header (status, rates, learning stats, game mode, milestone rank and
total, sugar state, events, chat ring) followed by attachments: RGBA frame, f32 stereo 48 kHz total, sugar state, events, chat ring) followed by attachments: RGBA frame, f32 stereo 48 kHz
audio (binjgb's unipolar u8 converted and DC-blocked), and a 17,407-byte spike bitset. audio (binjgb's unipolar u8 converted and DC-blocked), and a 17,407-byte spike bitset.
flysim serves it itself by default; with `FLY_FEED_VIA=bus` it publishes each snapshot on an
embedded flybus router and the `fly-edge` process serves the same bytes
(`docs/design/flybus.md`, "Feed over the bus").
- Control API (`docs/control-api.md`): loopback HTTP :7401. `POST /stimulate` (sugar: a timed PAM - Control API (`docs/control-api.md`): loopback HTTP :7401. `POST /stimulate` (sugar: a timed PAM
pulse, rate-limited server side), `POST /reward` (present, disabled by config), `POST /chat` pulse, rate-limited server side), `POST /reward` (present, disabled by config), `POST /chat`
(sanitized, deny-listed, ring of 12), `/status`, `/checkpoint`, `/pause`, `/resume`, (sanitized, deny-listed, ring of 12), `/status`, `/checkpoint`, `/pause`, `/resume`,
@ -159,8 +164,8 @@ sequenceDiagram
S->>S: every 5 s hot copy, every 300 s durable checkpoint S->>S: every 5 s hot copy, every 300 s durable checkpoint
``` ```
Where: `services/flysim/crates/flysim/src/{main,config,simloop,pacing,snapshot,feed,api,chat,store,eventlog,metrics}.rs`, Where: `services/flysim/crates/flysim/src/{main,config,simloop,pacing,snapshot,feed,feedbus,api,chat,store,eventlog,metrics}.rs`,
`docs/design/flysim.md`. `services/flysim/crates/fly-edge`, `docs/design/flysim.md`.
## 4. Stage page ## 4. Stage page

View file

@ -100,6 +100,22 @@ mode = "raw" # "raw" or "macros"; FLY_MACRO_MODE overr
- Refusals are counted as `fly_chat_rejected_total{reason}`, one series per rule: `control`, - Refusals are counted as `fly_chat_rejected_total{reason}`, one series per rule: `control`,
`charset`, `empty`, `too_long`, `url`, `name`, `deny_list`, `rate_limited`, `malformed`. `charset`, `empty`, `too_long`, `url`, `name`, `deny_list`, `rate_limited`, `malformed`.
Acceptances are `fly_chat_accepted_total`, and the ring depth is `fly_chat_ring_lines`. Acceptances are `fly_chat_accepted_total`, and the ring depth is `fly_chat_ring_lines`.
- **The ring survives a restart (2026-09-22).** Every accepted line rewrites a sidecar,
`<hot_dir>/chat-ring.json` (`[paths] hot_dir`, the tmpfs the hot checkpoints use), by the same
atomic sequence a checkpoint commit uses: tmp file, fsync, rename over. At startup, before the
first publish, the file is read back; lines older than 24 hours are dropped, only the newest
`ring` of them are kept, and a missing file is silence. An unreadable, unparseable or
unknown-version file is ignored with a logged warning and an empty panel — which is what a
restart gave before this existed — never a startup failure. Writing it is best-effort too: a
failure is a warning, and the line is still accepted and still on screen.
The sidecar is **not** part of the checkpoint: it is session state, it adds no chunk to the
`FLYSIM01` envelope and nothing about it enters the compatibility string, so `--print-compatibility`
is unchanged and a build that refuses every checkpoint in a directory still restores the panel.
It lives beside the hot checkpoints because it has their lifetime — a reboot clears the tmpfs —
and `FLY_RESET_STATE=1` clears it along with them (`infra/05-deploy.sh`). The bridge resends
nothing on reconnect: the lines the page shows after a restart are the ones the service already
accepted, with their original event ids and timestamps.
`POST /chat` status codes: `202 { eventId }` accepted, `400` malformed body, `403` chat disabled, `POST /chat` status codes: `202 { eventId }` accepted, `400` malformed body, `403` chat disabled,
`422 { error }` a rule refused the line (the error names the rule), `429 { retryAfterMs }` a rate `422 { error }` a rule refused the line (the error names the rule), `429 { retryAfterMs }` a rate

190
docs/design/flybus.md Normal file
View file

@ -0,0 +1,190 @@
# flybus: the communications bus
Status: **crate landed; the feed rides it behind `FLY_FEED_VIA=bus`, off by default**.
Written 2026-09-22, amended 2026-09-23 (EDGE-01, below). Index only; the
authority for the API and the wire format is the crate's own
[README](../../services/flysim/crates/flybus/README.md), and the audit of the crate against
the draft is the [conformance report](session-framework/bus-conformance.md).
## What it is
`flybus` is a local RPC and pub/sub bus for Tokio processes on one host, with immutable
file-backed artifacts for large payloads. One library, one router, one wire protocol:
messages are small strict-JSON envelopes carrying metadata and artifact references, while
bulk bytes (frames, audio, spike bitsets) live in a store directory the router owns.
Ownership follows deliveries and explicit holds; the router moves messages and tracks
ownership and does not interpret them. It implements the Flybus v1 draft (`bus-v1`, draft 1
of 2026-09-18) over the `ipc-v1` scalar encodings.
Transports are an in-memory pair, an accepted `AsyncRead + AsyncWrite` stream, or a Unix
socket. Identity is bound by the launcher before Hello and checked against a `Policy` of
per-client grants; open/unbound mode is for trusted tests only and is not authentication.
Nothing in the crate is specific to a game, a brain or a stream.
## What it is meant to replace
Today the three processes talk over two ad-hoc loopback surfaces:
| Today | Under the bus |
| --- | --- |
| Feed: WebSocket `127.0.0.1:7400/feed`, one binary message per snapshot at 30 Hz, header plus RGBA frame, audio and spike attachments, re-serialized per consumer | One `latest`-mode topic per stream, with the frame as a sealed artifact shared by fan-out instead of copied per subscriber |
| Control: loopback HTTP `127.0.0.1:7401` (`/stimulate`, `/chat`, `/checkpoint`, `/pause`, `/status`, ...) | RPC services with per-client grants, FIFO dispatch, explicit cancel states and backpressure |
| Per-surface limits, rate limits and timeouts written twice | `Limits` and `Policy` in one place, negotiated at Hello |
The feed and control contracts in [feed-protocol.md](../feed-protocol.md) and
[control-api.md](../control-api.md) stay binding until a migration replaces them. The bus
does not change any published contract by existing.
## Crate layout
`services/flysim/crates/flybus`, a workspace member of the flysim workspace. `flysim` depends
on it for the feed publisher (`src/feedbus.rs`) and `fly-edge` for the subscriber.
| Module | Contents |
| --- | --- |
| `wire` | Scalars, strict JSON, `Envelope`, `ArtifactRef`, `Attachment`, `Location`, framing, `CONTRACT` and `contract_digest()` |
| `error` | `ErrorCode`, `Dispatch`, `BusError` |
| `limits` | `Limits` and its hello encoding |
| `policy` | `Policy`, `Grants`, `Pattern` |
| `router` | `Router`, `RouterConfig`, `RouterStats`, `UnixListenerHandle`; the state machine in `router/state.rs` |
| `client` | `Client` and the handle types |
| `store` | The router-side file store and client-side location resolution |
| `transport` | `Transport` and the `Stream` trait |
Dependencies are already in the workspace lockfile: `tokio`, `serde`, `serde_json`, `sha2`,
`libc`. Tests run every integration case twice, once in memory and once over a Unix socket:
wire negotiation, RPC authority and cancellation, pub/sub credits and retention, artifact
allocate/seal/read with quotas and router restarts, plus the conformance suites and an
`--ignored` perf measurement.
## Wiring still pending
- ~~**flysim publisher.**~~ Done 2026-09-23 behind `FLY_FEED_VIA=bus`: see "Feed over the bus".
- **flysim control services.** The control endpoints as RPC services with grants, so the
"no button endpoint" structural guarantee is expressed as a grant table.
- **Stage and bridge clients.** Both are TypeScript/Node; the crate is Rust only, so either
a binding or a thin translating edge process is required before they leave the WebSocket
and HTTP surfaces. The operator chose the edge process (port decisions, 2026-09-23); for
the feed it exists (`fly-edge`), and they keep the WebSocket contract unchanged. The
control API (:7401) is the next slice and stays in flysim until then.
- ~~**Sizing.**~~ Decided 2026-09-23: amendment "Feed sizing" below.
- ~~**Lifecycle.**~~ Decided 2026-09-23: amendment "Feed store lifecycle" below.
- **Migration order.** The feed is the cheaper first move; control should follow only once
the bus carries the feed in production for a full session.
## Feed over the bus (2026-09-23, EDGE-01)
`feed.via` (`FLY_FEED_VIA`) picks who serves `ws://127.0.0.1:7400/feed`. `direct` is the
default and is the behaviour that predates the bus. With `bus`:
```text
sim thread --watch<Snapshot>--> publisher task --flybus (in memory)--> Router
(unchanged) (flysim-bus runtime) | <bus_dir>/edge.sock
v (bound to "fly-edge")
fly-edge: Subscription -> watch -> flysim::feed :7400
```
- flysim does not bind `feed.bind`. It starts a `Router` on a runtime of its own (two
threads, `flysim-bus`), store root `<bus_dir>/store`, closed policy: `flysim` may declare
and publish `fly.feed.snapshots`, `fly-edge` may only subscribe to it, and the Unix socket
`<bus_dir>/edge.sock` is launcher-bound to `fly-edge`.
- The topic is `retained: latest`. Each publication is one snapshot: the attachments the
header lists as sealed artifacts named `frame` (`image/x-rgba`), `audio`
(`audio/x-f32le`), `spikes` (`application/x-spike-bitset`), and the header as the payload
`{"header": {...}}`. A header over 48 KiB of JSON goes as a `header` artifact instead, so
the 65,536-byte envelope limit can never make a snapshot unpublishable.
- The sim thread is untouched. The publisher reads the same `watch` slot the direct server
reads, so a slow bus skips snapshots the way a slow WebSocket client does, and nothing on
the bus can hold the loop's publish. `fly_bus_published_total` and
`fly_bus_publish_failures_total` count it.
- `fly-edge` subscribes `latest`, one in flight, with replay, rebuilds each `Snapshot` with
`feedbus::receive` and serves it with flysim's own `feed::router`. `hello`, `wants`,
drop-oldest, the idle header and the framing are therefore the same code, and the bytes are
the same bytes: `crates/fly-edge/tests/parity.rs` replays the committed stage fixtures
through both paths at once and requires byte-equal messages per client flavour.
- `fly_frames_sent_total` and `fly_feed_clients` move to the edge with the clients; it exports
them under the same names on `FLY_EDGE_METRICS_ADDR` (`127.0.0.1:9102` in
`infra/units/flyedge.service`), and watchdog check 2 follows `FLY_FEED_VIA` in `fly.env` to
them. flysim's own copies read 0 in bus mode; `/status` is otherwise unchanged. The edge also
exports `fly_edge_bus_connected`, `fly_edge_bus_lost_total`, `fly_edge_bind_failures_total`
(the bus answered but :7400 was taken, most likely by a flysim still in direct mode) and
`fly_edge_decode_failures_total`.
- Nothing about the fly changes: the readout, the reward catalog, the adapter version and the
compatibility string are byte-identical in both modes (`--print-compatibility`).
### Amendment 2026-09-23: feed sizing
Measured on the live fly (release build, the real cartridge): a running snapshot is a
**92,160-byte** frame (160x144 RGBA; not the 640x480 "1.2 MB" the pending list assumed), a
**17,407-byte** spike bitset (139,255 neurons), about **12,800 bytes** of audio at realtime
(1,600 stereo f32 frames per 30 Hz snapshot at 48 kHz) and a 2 to 3 KB header: **122,367
bytes** of artifacts, about 3.7 MB/s at 30 Hz. `flysim::feedbus::limits()`:
| Limit | Value | Why |
| --- | --- | --- |
| `max_clients` | 8 | connections, pending handshakes included: the in-process publisher and the edge's one socket seat |
| `max_subscriptions_per_client` | 4 | the edge needs 1; this is what bounds the worst case |
| `max_latest_in_flight` | 2 | the default; the edge asks for 1 |
| `max_artifact_bytes` | 4 MiB | ten seconds of audio that piled up behind a late publish |
| `max_store_bytes` | 32 MiB | tmpfs, so RAM; ten times the worst case below |
| `max_retained_bytes` | 8 MiB | one retained snapshot, plus a large header artifact |
| `max_owners_per_client` / reserved | 64 / 8 | three artifacts per delivery, a few deliveries |
| others | small counts | one topic, no services |
A `latest` subscriber that never consumes pins at most its queued slot plus its in-flight
credits (3 snapshots); the topic pins one retained value; the publisher holds one snapshot of
staging plus the sealed copy while sealing. Only one client can subscribe at all: the publisher
is in process, and `edge.sock` is launcher-bound to `fly-edge`, which the router admits once at
a time (a second connection is refused as already connected). The worst case is therefore that
one client holding all 4 subscriptions it may open, none consuming: 4 x 3 + 1 + 2 = **15
snapshots, about 1.8 MB**, and publication never waits on any of them (a latest subscriber is
never a reason to refuse a publication, bus-v1 section 9). `crates/fly-edge/tests/stall.rs`
measures exactly that seat: four hoarding subscriptions, a fifth refused, a second connection
refused, pacer lag 0, no publication refused. The 15 is an upper bound; the measured store
was 472,061 bytes (under 4 snapshots), because fan-out adds roots and never copies, so four
subscriptions stuck on the same publications pin the same artifacts.
### Amendment 2026-09-23: feed store lifecycle
- **Location.** `feed.bus_dir` (`FLY_BUS_DIR`), `/run/fly/bus` on the containers: tmpfs,
0700, owned by `fly`, created by tmpfiles and again by flysim. The store root is
`<bus_dir>/store`, the socket `<bus_dir>/edge.sock`. A reboot empties it.
- **Owner.** The router lives in flysim; its lifetime is flysim's. flysim removes a stale
socket file at start, and `Router::new` removes any store directory whose `flock` is free,
i.e. one a crashed flysim left behind. A clean stop removes its own directory. The edge owns
nothing on disk.
- **Order.** flysim first, the edge after it: `flyedge.service` is `After=` and
`Requires=flysim.service`, so an explicit stop or restart of flysim (the unstick rule's
restart included) takes the edge with it. A crash-restart of flysim needs nothing: the edge
sees the connection close, drops every WebSocket client, unbinds :7400 and reconnects every
500 ms, binding :7400 again only when the first snapshot of the new router arrives. To the
stage that is exactly a flysim restart in direct mode: refused, then back.
- **Default.** `flyedge.service` is in no target and `07-enable.sh` does not enable it;
`05-deploy.sh` writes `FLY_FEED_VIA=direct` unless the env file says otherwise, and refuses
anything but `direct` or `bus` (any case, written lowercased). The switch and the way back
are in the unit's header. The edge gets a cpuset drop-in on the page's CPUs with the other
units, so once enabled it never runs on flysim's.
- **Paths.** `feed.bus_dir` must be absolute and non-empty (checked in both modes), since
flysim and the edge each resolve it.
- **Migration order** is unchanged: the feed first; control only after the bus has carried
the feed in production for a full session.
### Known limits (review round 1, 2026-09-23)
Accepted for now and written down rather than fixed:
- **Feed counters off the container.** In bus mode flysim's `:9101` reports
`fly_feed_clients` and `fly_frames_sent_total` as 0, and the edge's copies are on loopback
`:9102` only. The watchdog follows `FLY_FEED_VIA`; anything that scrapes `:9101` from off
the container (the metrics dashboard) goes blind to the feed until it also scrapes the edge.
- **Store quota is per router, not per client.** Any client on `edge.sock` may allocate
artifacts up to the store cap; a hostile process running as the same user could fill the
store and make flysim's publications fail. The loop is unaffected (a refusal is counted, never
waited on), but the feed would stall. Same-user processes are inside the trust boundary
(crate README, "Limitations").
- **Rollback while in bus mode.** Rolling back to a release without `fly-edge` while `fly.env`
still says `bus` leaves no one on :7400, and check 2 then reads the edge's absent `:9102` and
escalates. Switch back to `direct` first (the unit header's way back), then roll back.
- **Old fixtures.** `cold-open`, `steady` and `big-moment` predate `game.scene` and cannot be a
Rust `FeedHeader`, so fixture parity covers `macros`, `shop`, `center` and `bigpad`.

View file

@ -355,6 +355,61 @@ on-screen ticker cannot disagree with what the sim did.
against the prototype's WASM size and diffs a known save. If they match, prototype checkpoints against the prototype's WASM size and diffs a known save. If they match, prototype checkpoints
import and the segment records the shared tag; if not, milestone saves must be re-earned and that import and the segment records the shared tag; if not, milestone saves must be re-earned and that
is a stated M3 finding. is a stated M3 finding.
- **Restoring across an adapter version** (2026-09-22). The compatibility string is compared
whole, so bumping the reward adapter refuses every checkpoint the previous one wrote -- which is
the right default and was, until now, the only behaviour. It is the wrong default for a change
that only *adds* a rule: `pokered-unique8-v6` adds the catch reward and one counter,
`catchCounts`, and means the same thing as `v5` for every other field, so a `v5` run is
resumable and throwing it away would be a choice nobody made deliberately.
So there is one narrow, opt-in migration, `flybrain_gb::compatibility::decide`, and it requires
**all three** of:
1. the two compatibility strings differ in the adapter segment (segment 1) and **nowhere else**.
A dataset, kernel, plasticity, emulator-revision, symbol-provenance or state-format
difference is still a refusal: none of those has a migration, and a fly restored across one
is a different fly;
2. the running adapter's `migrates_from()` lists the checkpoint's adapter, so the code that will
read that state says out loud that it can. Pokémon Red's list is `["pokered-unique8-v5"]` and
nothing else -- `v4` is excluded because its ledger holds no `boundary:` keys and resuming it
would pay a second time for every exit already found, and `v3` because its stored rank is a
rung on a different ladder;
3. the deploy names the same adapter id in **`FLY_ACCEPT_ADAPTERS`** (comma- or
space-separated). Unset or empty migrates nothing, which is what every deploy before this one
did.
Condition 2 without 3 would make the migration silent; condition 3 without 2 would let an
operator wave through a pair nobody wrote a migration for. `infra/05-deploy.sh`'s compatibility
gate applies the same rule before it flips the `current` symlink, and writes the variable into
`/etc/fly/fly.env` so flysim applies it at restore -- the two must agree, or a deploy would pass
a gate that flysim then fails, which is the black stream the gate exists to prevent. The
migration itself is `PokemonRedReward::import_state` doing what it already did: `catchCounts` is
absent from a `v5` state and restores empty, which is the truth about a run that was never paid
for a catch. `STATE_VERSION` does not move, because the schema did not.
**`v6` -> `v7`** (2026-09-23, the engagement rewards) is the same migration for the next pair,
and `pokered-unique8-v7`'s `migrates_from()` is `["pokered-unique8-v6"]` and nothing else -- `v5`
is no longer migrated, because the live run is `v6`. The deploy that ships it sets
`FLY_ACCEPT_ADAPTERS=pokered-unique8-v6`. No field is added this time: `talk` and `item` key
their ledgers into the existing `seen` array, as `boundary` did, so a `v6` state restores
unchanged with no `talk:` keys. The one step is at the first sample after the restore, not in
`import_state`: a ledger without the `items:seeded` key writes an `item:`/`hidden:` key for every
item the cartridge already shows as taken, pays for none, and marks the seed, so a rollback to a
slot from before a `v6`-era pickup cannot pay for it (`docs/rewards-learning.md`, "The seed").
`STATE_VERSION` stays 4.
- **Restarting a run from an earlier rung** (2026-09-22). `FLY_RESET_STATE=1` throws the run away;
`infra/bin/fly-reset-to-milestone <N>` keeps it and rewinds it. It archives both stores to a
dated directory, rewrites `milestone-<N>.checkpoint` with the ratchet's `attempts` and
`recoveries` at zero (so the restarted run does not begin with its recovery budget already
spent), installs it as the newest generation of the hot and durable stores, removes the
milestone archives above N, and clears the event log -- whose id sequence the restored
checkpoint's `lastEventId` rewinds. `best` is not touched: the archive's own `best` is the rung
it was taken at, and the rank the stream shows is recomputed by the adapter from the restored
game state. The implementation is `flysim::reset` (`flysim --reset-to-milestone N`) rather than
the shell script, because two of those steps are inside the envelope. The sequence around it is
in `infra/docs/runbook.md`.
- **A running macro is not checkpointed** (2026-09-16, `docs/design/macros.md`). Palette mode's - **A running macro is not checkpointed** (2026-09-16, `docs/design/macros.md`). Palette mode's
state — the scene, the palette, the running macro, its plan and its frame count — is transient, state — the scene, the palette, the running macro, its plan and its frame count — is transient,
like the readout's blocked-direction cooldown and for the same reason: a restore that resumed a like the readout's blocked-direction cooldown and for the same reason: a restore that resumed a

View file

@ -125,7 +125,18 @@ condition the plan wanted, at the cost of no new state.
## Recovery budgets ## Recovery budgets
Attempts per rung stay 3; lifetime budget scales with the ladder (36 instead of 12). Stall window Attempts per rung stay 3; lifetime budget scales with the ladder (36 instead of 12). Stall window
unchanged (120 s without new exploration, 180 s since last recovery). The ratchet's rank bound is unchanged (120 s without new exploration, 180 s since last recovery).
**Amended 2026-09-22 (rung 10).** "New exploration" is the adapter's lifetime tile ledger, so a map
entered for the first time resets the window -- a new map is a map's worth of tiles nobody has stood
on -- and a map *re-entered* does not. Two "Stuck" rollbacks fired inside half an hour on a fly that
was walking a town it had already covered toward the rung's own door, and both were this rule
working. The window now takes a second signal beside the tile count: **the fly being nearer its
objective, in map hops, than this run has ever been** (`docs/design/macros.md` section 12.15). The
ratchet treats it exactly as it treats new exploration -- it restarts the window and does nothing
else -- it can fire at most once per step of the road, and the ratchet itself knows no more about
what it means than it knows what a tile is. Nothing else about the budgets, the triggers or the
checkpointed state changes. The ratchet's rank bound is
now the running adapter's ladder length, passed in rather than a constant, because the bound belongs now the running adapter's ladder length, passed in rather than a constant, because the bound belongs
to the adapter: Pokémon's ladder is 38 rungs and the platformer's is 16 to the adapter: Pokémon's ladder is 38 rungs and the platformer's is 16
(`docs/design/platformer.md` §3). Only the *budgets* are per-game, from the adapter's (`docs/design/platformer.md` §3). Only the *budgets* are per-game, from the adapter's

View file

@ -118,6 +118,42 @@ Addresses are at the pinned commit. "Verified" is one of:
| which slot is out | `wPlayerMonNumber` | `$cc2f` | 0-based party slot | ROM, trace | | which slot is out | `wPlayerMonNumber` | `$cc2f` | 0-based party slot | ROM, trace |
| the enemy | `wEnemyMonSpecies`, `wEnemyMonHP`, `wEnemyMonLevel`, `wEnemyMonMaxHP` | `$cfe5`, `$cfe6`, `$cff3`, `$cff4` | HP big-endian. Not written on the frame a battle starts — the reward adapter's own comment says the same — so the enemy is `None` for the first few hundred frames of a battle. | ROM (the rival's Squirtle, level 5, 20/20, and `None` on the first frame), trace | | the enemy | `wEnemyMonSpecies`, `wEnemyMonHP`, `wEnemyMonLevel`, `wEnemyMonMaxHP` | `$cfe5`, `$cfe6`, `$cff3`, `$cff4` | HP big-endian. Not written on the frame a battle starts — the reward adapter's own comment says the same — so the enemy is `None` for the first few hundred frames of a battle. | ROM (the rival's Squirtle, level 5, 20/20, and `None` on the first frame), trace |
| how many moves | `wNumMovesMinusOne` | `$cd6c` | the move count minus one, valid in a battle | trace | | how many moves | `wNumMovesMinusOne` | `$cd6c` | the move count minus one, valid in a battle | trace |
| **a ball kept this one** | `wCapturedMonSpecies` | `$d11c` | **new 2026-09-22** (the catch reward, `docs/rewards-learning.md`). `ram/wram.asm`'s own comment is "0 if no mon was captured". `ItemUseBall` zeroes it before every throw (`.canUseBall`) and writes `wEnemyMonSpecies` into it only on the branch that keeps the Pokémon; `UseBagItem`'s `.returnAfterCapturingMon` zeroes it again and sets `wBattleResult` to 2 on the way out of the battle. It is therefore non-zero for the hundreds of frames the catch's text and Pokédex screen take, and zero everywhere else. The value is the **internal** species index, like `wEnemyMonSpecies` and unlike `wPokedexOwned`'s bit index. Address resolved by `services/flysim/tools/resolve_wram.py`, bracketed by `wFontLoaded` and `wForcePlayerToChooseMon`. | survey (`tests/rom_catch.rs`: a real wild battle from a rung-9 checkpoint, balls thrown by the `THROW BALL` macro, the byte read out of the running game), trace (`pokemon_red/tests.rs`) |
`wBattleResult` (`$cf0b`) is the second half of that row and is worth its own sentence: it is 0
for a win, 1 for a loss, and 2 on exactly two paths in the whole game -- `.returnAfterCapturingMon`
and a *link* battle whose opponent ran (`engine/battle/core.asm`), which this cartridge never has.
So "the captured-species byte was non-zero during the battle **and** the result is 2" is a catch
and nothing else. `InitBattleVariables`, `ResetStatusAndHalveMoneyOnBlackout` and
`HandleFlyWarpOrDungeonWarp` all clear it, so a stale 2 cannot survive into the next battle.
Not used for the catch, and why: `wPartyCount` (`$d163`) rises on a catch **only** when the party
has room -- a full party sends the Pokémon to `wBoxCount` instead -- and it also rises for a gift,
a trade and a Pokémon withdrawn from the PC. Reading a catch off it would need a second rule to
tell those apart. The cartridge's own flag needs none, which is why the row above is the one the
adapter reads.
### The engagement rewards' reads (2026-09-23)
**New 2026-09-23** (`talk` and `item`, `docs/rewards-learning.md`, "Engagement rewards"). All six
were resolved by `services/flysim/tools/resolve_wram.py` from `ram/wram.asm` at the pinned commit
and are bracketed by addresses `symbols.rs` already carried; two of the brackets needed the tool
to count `NUM_STATS` and `NUM_CITY_MAPS`, which the decomp defines as `const_value` over an
enumeration.
| what | symbol | address | notes | verified |
| --- | --- | --- | --- | --- |
| the text's subject | `wSpriteIndex` | `$cf13` | `DisplayTextID` copies its argument here: a sprite slot up to `wNumSprites`, else a text id. It arrives **about twenty frames after** `wFontLoaded` bit 0 rises, because `DisplayTextIDInit` loads the font's tiles first; until then it still holds the previous text's subject. | survey (`tests/rom_engage.rs`: the Viridian Forest north gate, the old man at slot 2, font bit on frame 820 and the argument on frame 840) |
| mid-step | `wWalkCounter` | `$cfc5` | non-zero for the frames of a step; the overworld only reads A at zero. Right after `wFontLoaded` in `ram/wram.asm`. | ROM, trace |
| an item ball's item | `wMapSpriteExtraData` | `$d504` | two bytes per sprite slot (slot 1 first): `(item id, 0)` for an `ITEM` `object_event`, `(trainer class, trainer number)` for a `TRAINER` one, zeroes otherwise -- `LoadMapHeader`'s `.itemBallSprite` / `.trainerSprite` / `.regularSprite` | survey (the forest's Antidote ball read `(11, 0)`) |
| taken or hidden, per object | `wToggleableObjectFlags` | `$d5a6` | `flag_array $100`, one bit per global toggleable index (`constants/toggle_constants.asm`); `PickUpItem`'s `HideObject` sets an item ball's bit after `GiveItem` succeeded | survey (the forest's Antidote ball's bit rose on the pickup frame) |
| this map's toggleables | `wToggleableObjectList` | `$d5ce` | up to sixteen `(sprite slot, global index)` pairs, `$ff`-terminated, written by `MarkTownVisitedAndLoadToggleableObjects` | survey |
| hidden items found | `wObtainedHiddenItemsFlags` | `$d6f0` | `flag_array MAX_HIDDEN_ITEMS` (112); `FoundHiddenItemText` sets the bit after `GiveItem` succeeded, and nothing else writes it | ROM (disassembly), trace |
Not used, and why: `hJoyPressed`/`hJoyHeld` would say "A was pressed" directly, but they are HRAM,
which neither `gen_symbols.py` nor `resolve_wram.py` resolves, and a hand-written address is the one
thing those tools exist to refuse. "The fly had the joypad and was standing still on the frame
before the box opened, and the box is about the thing it faces" is the same fact read out of WRAM.
### Battle menu and cursor, own turn against forced switch ### Battle menu and cursor, own turn against forced switch
@ -128,7 +164,7 @@ parked its cursor. All five bytes are contiguous: `wTopMenuItemY` `$cc24`, `wTop
| menu | signature | cursor | verified | | menu | signature | cursor | verified |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| the top-level battle menu | `wTextBoxID` = `$0b`, `wTopMenuItemY` = 14, `wTopMenuItemX` = 9 with watched keys `PAD_RIGHT\|PAD_A` (left column) or 15 with `PAD_LEFT\|PAD_A` (right), `wMaxMenuItem` = 1 (`DisplayBattleMenu`, `engine/battle/core.asm:2081` and `:2114`) | reported 0 FIGHT, 1 PKMN, 2 ITEM, 3 RUN: the game keeps the index *within* the column and `.rightColumn` adds two on selection | ROM (a fresh menu is FIGHT; RIGHT is ITEM; DOWN from there is RUN), trace | | the top-level battle menu | `wTextBoxID` = `$0b`, `wTopMenuItemY` = 14, `wTopMenuItemX` = 9 with watched keys `PAD_RIGHT\|PAD_A` (left column) or 15 with `PAD_LEFT\|PAD_A` (right), `wMaxMenuItem` = 1 (`DisplayBattleMenu`, `engine/battle/core.asm:2081` and `:2114`) | reported 0 FIGHT, 1 PKMN, 2 ITEM, 3 RUN: the game keeps the index *within* the column and `.rightColumn` adds two on selection | ROM (a fresh menu is FIGHT; RIGHT is ITEM; DOWN from there is RUN), trace |
| the move list | `wTopMenuItemY` = 12, `wTopMenuItemX` = 5 (`MoveSelectionMenu`'s regular menu, `:2492`) | the game's list is **one-based** — `wCurrentMenuItem` is `wPlayerMoveListIndex + 1` and `wMaxMenuItem` is the move count plus one — so the accessor reports the 0-based slot, and `None` for an index that names no move | trace | | the move list | `wTopMenuItemY` = 12, `wTopMenuItemX` = 5 (`MoveSelectionMenu`'s regular menu, `:2492`) **and the box it draws** — section 10, because nothing clears the cursor bytes and `SelectMenuItem` decrements `wCurrentMenuItem` back into range on its way out | the game's list is **one-based** — `wCurrentMenuItem` is `wPlayerMoveListIndex + 1` and `wMaxMenuItem` is the move count plus one — so the accessor reports the 0-based slot, and `None` for an index that names no move | trace, and the press survey of section 10 |
| the party list | `wTopMenuItemY` = 1, `wTopMenuItemX` = 0, `wMaxMenuItem` = `wPartyCount - 1`, watched keys `PAD_A\|PAD_B` or `PAD_A` alone (`PartyMenuInit`, `home/pokemon.asm:201`) | 0-based party slot | trace | | the party list | `wTopMenuItemY` = 1, `wTopMenuItemX` = 0, `wMaxMenuItem` = `wPartyCount - 1`, watched keys `PAD_A\|PAD_B` or `PAD_A` alone (`PartyMenuInit`, `home/pokemon.asm:201`) | 0-based party slot | trace |
| **a forced switch** | the party list, in a battle, with `wPartyMenuTypeOrMessageID` = `BATTLE_PARTY_MENU` (`$02`) at `$d07d`. `ChooseNextMon` is the battle path that sets it (`engine/battle/core.asm:1088`, and `:1389` for the "use next mon?" branch); choosing PKMN from the menu sets `NORMAL_PARTY_MENU` (`$00`, `:2316`), which is why the two are distinguishable. `wForcePlayerToChooseMon` (`$d11f`) is the byte `PartyMenuInit` turns into "A only, no way out". | — | trace | | **a forced switch** | the party list, in a battle, with `wPartyMenuTypeOrMessageID` = `BATTLE_PARTY_MENU` (`$02`) at `$d07d`. `ChooseNextMon` is the battle path that sets it (`engine/battle/core.asm:1088`, and `:1389` for the "use next mon?" branch); choosing PKMN from the menu sets `NORMAL_PARTY_MENU` (`$00`, `:2316`), which is why the two are distinguishable. `wForcePlayerToChooseMon` (`$d11f`) is the byte `PartyMenuInit` turns into "A only, no way out". | — | trace |
@ -145,6 +181,7 @@ forces. A battle that is neither — text, an animation, the turn resolving —
| a submenu | the weakest rule here, and the reason `Unknown` exists: `wListMenuID` is the bag or an elevator list, or the party list geometry outside a battle. A submenu none of those catch reads as `Unknown`, never as `Overworld`. | trace | | a submenu | the weakest rule here, and the reason `Unknown` exists: `wListMenuID` is the bag or an elevator list, or the party list geometry outside a battle. A submenu none of those catch reads as `Unknown`, never as `Overworld`. | trace |
| mart | `wTextBoxID` = `BUY_SELL_QUIT_MENU` (`$15`) for the BUY / SELL / QUIT choice, and `engine/events/pokemart.asm:17` is its only user in the game; the buy list is `wListMenuID` = `$02` and the sell list is the bag's own `$03`, recognised only while the mart's template is still the last one drawn. | trace | | mart | `wTextBoxID` = `BUY_SELL_QUIT_MENU` (`$15`) for the BUY / SELL / QUIT choice, and `engine/events/pokemart.asm:17` is its only user in the game; the buy list is `wListMenuID` = `$02` and the sell list is the bag's own `$03`, recognised only while the mart's template is still the last one drawn. | trace |
| PC | `wMiscFlags` bit 3, above. | trace | | PC | `wMiscFlags` bit 3, above. | trace |
| a two-option YES/NO box | **new 2026-09-22, generalised 2026-09-23** (`docs/design/macros.md` sections 12.12 and 12.20). `wFontLoaded` bit 0, plus a two-option cursor (`wMaxMenuItem` 1, `wMenuWatchedKeys` = A\|B), plus the border `DisplayTwoOptionMenu` drew **around the cursor it parked** -- see section 11. **Both halves are load-bearing**: the cursor bytes survive the box closing, so all forty-six frames of a nurse's conversation and all fifty-two of a gym guide's carry that geometry while the box is drawn on a handful of them. The border is no longer pinned to one rectangle, because Red places the menu where the script asking for it says and two of those places are surveyed. | ROM (the rung-10 Pokemon Center and the rung-10 Pewter Gym checkpoints, each surveyed one raw pulse at a time: `examples/scene_probe.rs`, `FLY_PROBE_CATCH=nurse` and `=dialog`) |
### Money and bag ### Money and bag
@ -474,6 +511,13 @@ Two facts about the emulator came out of building these and are worth keeping:
which is two battles away from anything a fixed-seed walk reaches quickly. The trace is built which is two battles away from anything a fixed-seed walk reaches quickly. The trace is built
from `ChooseNextMon` and `PartyMenuInit`, and the distinguishing byte from `ChooseNextMon` and `PartyMenuInit`, and the distinguishing byte
(`wPartyMenuTypeOrMessageID` = `BATTLE_PARTY_MENU`) is asserted both ways. (`wPartyMenuTypeOrMessageID` = `BATTLE_PARTY_MENU`) is asserted both ways.
- **`yes_no_prompt` is "*this* two-option box is drawn", not "a choice is open".** The claim in
earlier revisions of this file — that pokered has no observable for a choice — stands for the
general question and is now narrowed rather than withdrawn: the box the Pokémon Center nurse's
offer is drawn in has been surveyed on the cartridge and is readable, and every other
two-option menu in the game is not, because `DisplayTwoOptionMenu` takes its coordinates from
the script that calls it. A prompt this reading misses is a plain dialog, which is the pad it
had before the reading existed.
- **`text_box().waiting` is "the dialogue box is drawn", not "the game wants a button".** Pokered - **`text_box().waiting` is "the dialogue box is drawn", not "the game wants a button".** Pokered
has no flag for the second thing; A is the right press either way, so the distinction has no has no flag for the second thing; A is the right press either way, so the distinction has no
consequence for the palette, but it is not what the field's name might suggest. consequence for the palette, but it is not what the field's name might suggest.
@ -506,9 +550,46 @@ change, 648 bytes, checked on the WSL box.
| state | symbol | address | encoding | verified | | state | symbol | address | encoding | verified |
| --- | --- | ---: | --- | --- | | --- | --- | ---: | --- | --- |
| the open mart's stock | `wItemList` | `$cf7b` | `ds 16`. `LoadItemList` (`home/text_script.asm:156`) copies the clerk's `script_mart` list out of its text script the moment the counter opens: **a count byte** (the macro's `_NARG`), then the item ids, then `$ff`. `DisplayPokemartDialogue_` points the buy list's `wListPointer` at the same buffer for `PRICEDITEMLISTMENU`, so **an item's position in this list is its cursor index** in the buy menu — which is what lets a purchase be navigated by reading the cursor rather than by counting presses. The terminator wins over the count, as it does for the bag, and the sixteen-byte buffer bounds both. | ROM (Viridian's counter reads POKE BALL, ANTIDOTE, PARLYZ HEAL, BURN HEAL, in that order, and no Potion), trace | | the open mart's stock | `wItemList` | `$cf7b` | `ds 16`. `LoadItemList` (`home/text_script.asm:156`) copies the clerk's `script_mart` list out of its text script the moment the counter opens: **a count byte** (the macro's `_NARG`), then the item ids, then `$ff`. `DisplayPokemartDialogue_` points the buy list's `wListPointer` at the same buffer for `PRICEDITEMLISTMENU`, so an item's position in this list is its cursor index in the buy menu **for the first three entries only** — see the correction below. | ROM (Viridian's counter reads POKE BALL, ANTIDOTE, PARLYZ HEAL, BURN HEAL, in that order, and no Potion), trace |
| the tileset's counter tiles | `wTilesetTalkingOverTiles` | `$d532` | three tile ids from the tileset header (`data/tilesets/tileset_headers.asm`), `$ff` for a tileset with fewer. Mart and Pokecenter are both `$18 $19 $1e`; the overworld and an ordinary house have none. `IsSpriteOrSignInFrontOfPlayer`'s `.extendRangeOverCounter` branch (`home/overworld.asm:1115`) walks exactly this list and doubles the talking range from `$10` to `$20` pixels — one tile to two — when the tile in front of the player is one of them. | ROM (the Viridian mart's column 1 and the centre's (3, 2) read as counters and the floor either side does not), trace | | the tileset's counter tiles | `wTilesetTalkingOverTiles` | `$d532` | three tile ids from the tileset header (`data/tilesets/tileset_headers.asm`), `$ff` for a tileset with fewer. Mart and Pokecenter are both `$18 $19 $1e`; the overworld and an ordinary house have none. `IsSpriteOrSignInFrontOfPlayer`'s `.extendRangeOverCounter` branch (`home/overworld.asm:1115`) walks exactly this list and doubles the talking range from `$10` to `$20` pixels — one tile to two — when the tile in front of the player is one of them. | ROM (the Viridian mart's column 1 and the centre's (3, 2) read as counters and the floor either side does not), trace |
### 7.1 Correction, 2026-09-22 (row 55): the list byte and the cursor index both say less than this
Two claims in the table above were surveyed again from the live checkpoint the stream looped in
(`examples/scene_probe.rs`, `FLY_PROBE_CATCH=shop`, in the Pewter mart), and both are narrower
than they were written.
**`wListMenuID` says the counter is open, not which of its screens is up.** Section 2's row for it
says it is "zeroed by `DisplayTextIDInit` at the start of every text display, so a stale value
cannot outlive one". That holds for text the overworld displays and not for the mart's own: the
clerk's "Here you are! Thank you!" is printed from inside `DisplayPokemartDialogue_`, which never
calls the routine that clears it. Measured: **every frame of a mart visit read `PRICEDITEMLISTMENU`
`$02`** — the counter menu and each of the clerk's text boxes included — and on the counter menu
`wTextBoxID` reads `MONEY_BOX` `$0d` rather than `BUY_SELL_QUIT_MENU` `$15`, because the money box
is the last template drawn. So `ShopScreen::BuySellQuit` and `Selling` were unreachable in a mart
that had ever drawn a buy list, and the frame the stream sat on — a dialogue box waiting for a
press, with a two-option box's leftover cursor bytes (`wTopMenuItemY` 8, `wTopMenuItemX` 15,
`wMaxMenuItem` 1, `wMenuWatchedKeys` `$03`) — read as "the priced buy list is open".
The screen is now read from the figure the game draws, the construction [`text_box`]'s `waiting`
test and [`yes_no_prompt`] already use: the full-width box drawn and waiting is the clerk
(`ShopScreen::Talking`, new), the item window drawn is the buy list, and the item window blank is
the counter menu. The item window's own figure is its first name cell, screen (6, 4), which held
`$7f` on the counter menu and `P` of `POKE BALL` on every frame the list was drawn.
**The buy list scrolls, so a position in `wItemList` is a cursor index only for the first three
entries.** Walked one DOWN pulse at a time on the live list: the cursor went `0, 1, 2` and then
**stopped moving while the window scrolled under it** — the fourth drawn row is a look-ahead the
cursor never occupies. The absolute position of the item under the cursor is that index plus
`wListScrollOffset`, which is **not** in the reviewed address list and cannot be pinned without the
disassembly `gen_symbols.py` reads (the same refusal `$cfc5` met in section 9). Viridian's
four-item counter hid it: POKE BALL is 0 and ANTIDOTE is 1, both in reach. Pewter's seven-item
counter did not: its ANTIDOTE is **3**.
So the macros carry `MART_CURSOR_ROWS = 3` and a purchase past it is not on the pad
(`infra/docs/macros-traps.md` row 55). Pinning `wListScrollOffset` would widen that, and it is a
residual rather than a guess.
**Why the second one is load-bearing.** A mart clerk is `object_event 0, 5, SPRITE_CLERK` behind a **Why the second one is load-bearing.** A mart clerk is `object_event 0, 5, SPRITE_CLERK` behind a
counter running down column 1; a Pokémon Center nurse is `object_event 3, 1, SPRITE_NURSE` behind counter running down column 1; a Pokémon Center nurse is `object_event 3, 1, SPRITE_NURSE` behind
the counter tile at (3, 2). **None of the four tiles around either of them is standable** — they are the counter tile at (3, 2). **None of the four tiles around either of them is standable** — they are
@ -641,6 +722,47 @@ is exactly the shape of mistake the cross-check below exists for.
both are the old map — so only the *id* is wrong, and the check that catches it is one byte: does both are the old map — so only the *id* is wrong, and the check that catches it is one byte: does
the cached grid still agree with the screen about the tile the fly is standing on. the cached grid still agree with the screen about the tile the fly is standing on.
### The frame mid-step, which the check refused (2026-09-22, row 54)
The cross-check above refused on **every frame the fly was moving**, and the reason is a fact about
when the cartridge writes the coordinates. `FLY_PROBE_CATCH=step` in
`services/flysim/crates/flysim/examples/scene_probe.rs` holds one direction from a checkpoint and
prints, per frame, the coordinates, the grid's verdict, the tiles the two readings disagree on, and
every plausible candidate for "a step is in progress". Holding UP out of the Pewter museum:
| frame | `wYCoord` | the grid | the disagreement |
| ---: | ---: | --- | --- |
| 0 | 7 | ok | -- |
| 1 | 7 | ok | -- |
| 2 .. 15 | **7** | screen disagrees | (10, 7) decoded `$20`, screen `$01`; (11, 7) `$20` against `$01` |
| 16 | **6** | ok | -- |
| 19 .. 32 | 6 | screen disagrees | (10, 7) `$20`/`$01`; (11, 6) `$01`/`$50` |
| 33 | 5 | ok | -- |
Three readings come out of it, and the first is the one everything else follows from:
1. **The coordinates change at the *end* of a step.** A step is sixteen frames; `wYCoord` reads the
tile it began on for all of them but the last. The background scrolls throughout, so from the
second frame the screen buffer is already centred one tile ahead.
2. **`map_tile_id(x, y)` therefore answers for `(x + dx, y + dy)` mid-step**, where `(dx, dy)` is
the step. Verified on both arms of the trace: at frame 19 the screen's reading for (10, 7) is
the decode of (10, 6) and its reading for (11, 6) is the decode of (11, 5), exactly.
3. **No pinned address says a step is in flight.** The player sprite's Y and X step deltas
(`wSpriteStateData1 + 3` and `+ 5`) keep their last value after the step ends -- `$ff, $00` on
every frame of the trace after the first -- so they cannot tell a step from the one before it.
`wStatusFlags5` stayed `$00`, `wMovementFlags` tracked the warp tile the fly was standing on and
not the step, and `rSCY` lags the coordinates by a frame of its own. The one byte that does
track it exactly -- counting `$07 $07 $06 $06 … $01 $01` down to `$00` on the frame the
coordinates catch up -- is **`$cfc5`**, and `gen_symbols.py` refuses a hand-written address while
the checkout `resolve_wram.py` reads is not on this box. So it is recorded here and **not used**.
What the reader does instead is measure the anchor: the screen is centred on the fly's own tile or
on one of its four neighbours, and the anchor it is centred on is the one whose **whole**
neighbourhood agrees with the decode. `(0, 0)` is tried first, so a standing frame costs exactly
what it did before. The refusals the check exists for all survive, because a wrong stride, a wrong
quadrant, a half-loaded map and the mid-warp tear each disagree under every one of the five: the
neighbourhood has to agree as a unit rather than tile by tile.
### The survey, on two maps ### The survey, on two maps
`services/flysim/crates/flysim/tests/rom_map_grid.rs`, the method of `services/flysim/crates/flysim/tests/rom_map_grid.rs`, the method of
@ -665,3 +787,211 @@ answers that, and the executor's per-step moved check covers the rest), a warp t
step onto it, and a script that pushes the fly off a tile (a session ledger answers that). The step onto it, and a script that pushes the fly off a tile (a session ledger answers that). The
water half of the tile-pair lists is deliberately absent: it is the list water half of the tile-pair lists is deliberately absent: it is the list
`CheckForJumpingAndTilePairCollisions` uses while surfing, and the palette cannot surf. `CheckForJumpingAndTilePairCollisions` uses while surfing, and the palette cannot surf.
## 10. A menu that is accepting input, against one that is only remembered (2026-09-22, row 50)
`HandleMenuInput` is shared by every menu in the game (section 2) and so are the five bytes it
parks a cursor in. Section 2's table reads those bytes to say *which* menu is up; it does not say
whether anybody is reading them. The difference is the whole of row 50: `MOVE n` reported `blocked`
**890 times in 1,431 macros** on the cartridge, every one of them on a frame the seam called an
open move list with a placeable cursor.
**Nothing in the game clears the cursor bytes.** `MoveSelectionMenu` writes `wTopMenuItemY` 12 and
`wTopMenuItemX` 5 once, and the whole of the turn that follows — the text, the animation, the
damage, the enemy's reply — reads them back unchanged. It is the same fact section 7's YES/NO box
rests on ("the cursor bytes survive the box closing"), and the reason the battle's *top-level* menu
never had this problem is that it carries `wTextBoxID` = `$0b` beside its geometry.
`SelectMenuItem` makes it worse rather than better: on its way out of `HandleMenuInput` it does
`ld a, [wCurrentMenuItem] / dec a / ld [wCurrentMenuItem], a`, turning the menu's one-based index
back into a 0-based move slot. That lands straight back inside the range the accessor reads as a
valid one-based slot, so a turn spent on move 2, 3 or 4 leaves a *placeable* cursor behind it.
### The accessor
| state | how | verified |
| --- | --- | --- |
| the move list is **accepting input** | the cursor at `wTopMenuItemY` 12 / `wTopMenuItemX` 5 **and** the figure `MoveSelectionMenu` draws: a `TextBoxBorder` at (4, 12) fourteen wide and four tall, with a horizontal run written over its top-left corner and the `┘` junction written over (10, 12) (`engine/battle/core.asm`, `.regularmenu`). Read whole — both verticals, both horizontal runs, all four corners — because a single frame tile id is an ordinary character. The mimic and relearn menus draw at row 7 and never reach a battle's own turn. | survey (below) |
`Scene::Battle { own_turn }` follows it: a frame whose move list is not on screen reads
`BattleMenu::None`, which is nobody's turn, which is the between-turns row and its one `NEXT`
(`docs/design/macros.md` 12.10). Nothing else moves — the top-level menu, the party list and the
bag keep the readings they had.
### The survey
`examples/scene_probe.rs`, `FLY_PROBE_CATCH=accept`, from the rung-9 forest checkpoint. The
question "is this menu accepting input" is answered by **pressing at it**, not by nominating a
flag: on every battle frame the emulator exports its state, one directional pulse is issued,
`wCurrentMenuItem` is read, and the state goes straight back — `HandleMenuInput` moves the cursor
on UP and DOWN before it even looks at `wMenuWatchedKeys`, so a cursor that moves is a menu running
its input loop. The pulse *releases* the buttons first, because `JoypadLowSensitivity` acts on a
key's edge and a direction the fly is already holding would read as refused for the measurement's
reason rather than the cartridge's.
| the reading | press refused | press honoured |
| --- | ---: | ---: |
| the cursor bytes alone (what the seam read before row 50) | 2,838 | 264 |
| the cursor bytes **and** the box on screen | **0** | **231** |
| the cursor bytes with no box drawn | 2,838 | 33 |
So 91.5% of the frames the old reading called an open move list were frames no press reached, and
the reading that survives is exact on the 231 it keeps. (The 33 are frames where the pulse's own
thirty frames were long enough for the cartridge to open something by itself; the pulse is a
measurement and not a claim about one frame.)
Beside the press, the probe asks **every byte of WRAM and HRAM** whether its values on accepting
frames are disjoint from its values on refusing ones, so a reading is found rather than guessed.
Over the move list, once the box is in the reading, no byte separates the two classes at all —
there is nothing left to separate. Over the whole class before the fix, the only separators were
the HRAM joypad bytes, which is the measurement seeing its own held button.
### What the same survey found and this section did not fix
- **The top-level battle menu is already exact**: 413 frames, 0 refused. `wTextBoxID` is why.
- **The bag is the same trap, unfixed and named.** `wListMenuID` = `ITEMLISTMENU` outlives the bag
exactly as the cursor bytes outlive the move list: 449 refused against 36 honoured over the
frames the seam calls an open battle bag. The bag list is drawn in the top half of the screen and
the survey has not yet found the figure that tells it from the frame after it closes, so it is
reported rather than guessed — `docs/design/ladder.md`'s rule. `ITEM` and `THROW BALL` are the
two macros it costs.
- **The party list, likewise**: `PartyMenuInit`'s geometry outlives its list.
## 11. A two-option box is the one the cartridge drew (2026-09-23, `docs/design/macros.md` 12.20)
Section 10 read one menu by the figure it draws; row 41 read the YES/NO box the same way but at a
**pinned** rectangle, (11, 6)-(19, 11), and named the limit in its own residual: Red places a
two-option menu where the script asking for it says, so a prompt drawn elsewhere read `false`.
Row 56 is that residual, live: the Pewter Gym guide's "Let me take you to the top!" draws the same
menu at **(14, 7)-(19, 11)** with the shared cursor at row 8, column **15**. Over 260 surveyed
presses of his conversation the box was drawn on **10** frames and `yes_no_prompt` answered `false`
on **all 260** -- so the dialog pad was `NEXT, YES, NO` on a box that was a choice, and the whole of
12.12 (no `NEXT` on a prompt, the nurse's one bound answer, the reopened-prompt exclusion) was
inert wherever the box was not the centre's.
### The accessor
Nothing in the reviewed symbol list says "a choice is open" and nothing can be added by hand
(`gen_symbols.py` refuses a hand-written address, and the disassembly is not built on this box), so
the reading is the construction `text_box`'s `waiting` already makes -- a WRAM flag plus the figure
-- with the figure **found** rather than pinned:
1. `wFontLoaded` bit 0, as for every text display;
2. the cursor is a two-option menu's: `wMaxMenuItem` 1 and `wMenuWatchedKeys` = A\|B;
3. the border's **left edge is one column to the left of `wTopMenuItemX`**, because
`DisplayTwoOptionMenu` writes the cursor into the box's first interior column. This holds in
both surveyed boxes -- left 11 with the cursor at 12, left 14 with the cursor at 15 -- and it is
the only geometric relation that does;
4. the **top** is looked up from the cursor's row for the border's own top-left corner, at most
three rows, because the nurse's box begins two rows above the first item and the guide's one:
one menu carries a caption line and the other does not;
5. and the rest of the figure is then read **whole** by `border_drawn` -- both verticals, both
horizontal runs and all four corners -- because a single frame tile id is an ordinary character.
### The survey
`examples/scene_probe.rs`, `FLY_PROBE_CATCH=dialog`, which walks a conversation one raw pulse at a
time and prints, per frame, the seam's reading beside **every complete `TextBoxBorder` on screen**
(`state::drawn_boxes`, a diagnostic that tries every rectangle rather than one). Two checkpoints,
400 frames:
| checkpoint | a box drawn above the dialogue box | `wTextBoxID` = `TWO_OPTION_MENU` (`$14`) | the new reading | frames |
| --- | --- | --- | --- | ---: |
| rung-10 Pewter Gym | false | false | false | 250 |
| rung-10 Pewter Gym | **true** | **true** | **true** | 10 |
| rung-10 Pokémon Center | false | false | false | 136 |
| rung-10 Pokémon Center | **true** | **true** | **true** | 4 |
The three agree exactly on all 400 frames. **`wTextBoxID` is recorded and not used**: it would be a
tighter reading still, and row 41's own note says the nurse's *plain* boxes read `$01` — which is
confirmed here — but no survey on this branch covers Red's other two-option menus, and a reading
this crate has not verified does not go in (`docs/design/ladder.md`). It is the named strengthening.
### What it does not claim
A frame whose two-option cursor bytes have outlived their box reads `false`, which is the whole
point of reading the figure; a border drawn somewhere the cursor is not parked is not the cursor's
box; and a menu of more than two options is not this menu. What the pad makes of a readable prompt
is `pokemon_red::macros::palette`'s business (`docs/design/macros.md` 12.12 and 12.20), not this
accessor's.
## 12. The people off the screen, and a battle decided (2026-09-23, `docs/design/macros.md` 12.22)
Two readings row 58 added, both out of bytes the seam already had or a byte bracketed by two it had.
### `state::offscreen_npcs`
`CheckSpriteAvailability` (`engine/overworld/movement.asm`) writes `$ff` into
`SPRITESTATEDATA1_IMAGEINDEX` (offset 2) for a sprite that is a toggleable object switched off, that
is outside its window, or that stands on a text box's tiles. The window, for a sprite whose
`SPRITESTATEDATA2_MOVEMENTBYTE1` (offset 6) is `WALK` (`$fe`) or `STAY` (`$ff`), compares the
sprite's biased `MAPY` / `MAPX` (offsets 4 and 5) with `wYCoord` / `wXCoord`: drawn when equal, or
when the sprite's is greater by at most `SCREEN_HEIGHT / 2 - 1` (8) rows and
`SCREEN_WIDTH / 2 - 1` (9) columns. A scripted mover (movement byte below `WALK`) skips the test.
So a `$ff` sprite of the loaded map whose coordinates fall **outside** the window, and whose
movement byte is `WALK` or above, is reported with those coordinates: the cartridge would hide it
for being off the screen whatever else were true, and a sprite it is not updating does not move.
Everything else `$ff` is not reported. Measured from the row-58 checkpoint at the Pewter Gym's
doormat: BROCK at (4, 1) and the Jr. Trainer at (3, 6) reported, the guide at (7, 10) drawn.
What it cannot tell is a toggleable object switched off from one out of sight, because both are
`$ff` outside the window. Its one reader is the rung's own list of people; the ladder's person
places are Oak's lab and the gyms, and only the lab and Viridian Gym carry toggleable people
(`data/maps/toggleable_objects.asm`).
### `poke::CUR_OPPONENT`, `wCurOpponent`
Written when a battle is decided (`home/trainers.asm` for a trainer, the encounter check for a wild
one), cleared by `EndOfBattle` in the same block that clears `wIsInBattle`. Not in the generated
table: `ram/wram.asm` at the pinned commit declares `wIsInBattle:: db`,
`wPartyGainExpFlags:: flag_array PARTY_LENGTH` (one byte), `wCurOpponent:: db`,
`wBattleType:: db`, `wDamageMultipliers:: db`, `wGymLeaderNo:: db`, `wTrainerNo:: db`, and the
table's `wIsInBattle` (`$d057`), `wBattleType` (`$d05a`) and `wTrainerNo` (`$d05d`) are exactly
where that layout puts them, so the byte is `wBattleType - 1` = `$d059`, asserted against both
neighbours in `scene/tests.rs`. Measured on the cartridge in the Pewter Gym: `$00` in the
overworld, `$cd` (`OPP_JR_TRAINER_M`) from the last box of the trainer's challenge through the
219-frame transition and the battle. `controllable` reads it as zero.
## 13. What a move will do: the move table and the bytes its effect reads (2026-09-23, `docs/design/macros.md` 12.23)
Row 60. Seven addresses, resolved by `tools/resolve_wram.py` and emitted into `symbols.rs`; the
tool now follows the decomp's `const` and `_RS` counters and a struct macro's field labels, which
is what reaches the `battle_struct` fields, and it re-derives 77 of 81 pinned addresses with no
disagreement.
| symbol | address | what reads it |
| --- | --- | --- |
| `wPlayerMonStatMods` | `$cd1a` | six stages, ATTACK DEFENSE SPEED SPECIAL ACCURACY EVASION; 1 is -6, 7 normal, 13 is +6 |
| `wEnemyMonStatMods` | `$cd2e` | the same for the enemy |
| `wEnemyMonStatus` | `$cfe9` | the enemy's status byte (`battle_struct` +4) |
| `wEnemyMonType1` | `$cfea` | and `Type2` after it |
| `wEnemyMonAttack` | `$cff6` | the enemy's modified ATTACK DEFENSE SPEED SPECIAL, big-endian words |
| `wBattleMonAttack` | `$d025` | the same for the fly's Pokémon |
| `wEnemyBattleStatus2` | `$d068` | bit 1 Mist, 4 substitute, 5 must recharge |
**The move table is ROM.** `Moves` opens `SECTION "Battle Engine 7"`, which `layout.link` places
first in bank `$0E`, so it is `$0E:$4000`, six bytes a row in move-id order from `POUND`:
animation (the move id itself), effect, power, type, accuracy, PP. `state::move_data` reads a row
through `MemoryReader::read_rom`, the cartridge image, and refuses a row whose first byte is not the
id asked for. Measured from the row-60 checkpoint: TACKLE (`$21`) effect `$00` power 35, TAIL
WHIP (`$27`) effect `$13` (`DEFENSE_DOWN1_EFFECT`) power 0.
**`state::move_without_effect`** answers only refusals decided before the roll, for a move with no
power, from `engine/battle/effects.asm` and `MoveHitTest`:
- `*_UP1` / `*_UP2`: the user's stage is 13, or (ATTACK..SPECIAL) the stat is 999;
- `*_DOWN1` / `*_DOWN2`: the target has Mist or a substitute, its stage is 1, or (ATTACK..SPECIAL)
the stat is 1 -- `StatModifierDownEffect` restores the stage and prints "Nothing happened!"
then, so a low-level target reaches it before -6;
- `SLEEP_EFFECT`: any status, unless the target must recharge;
- `POISON_EFFECT`: a substitute, any status, or a Poison type;
- `PARALYZE_EFFECT`: any status, or an Electric move against a Ground type.
`None` outside a battle, without a cartridge, or with a stage byte outside 1..13.
**Not covered** (the reading would be the same kind, and nothing early in the game reaches it):
Confuse Ray and Supersonic on a confused target, Leech Seed on a seeded or Grass target, Focus
Energy, Mist, Reflect and Light Screen already up, Disable on a disabled target, and a damaging
move the type chart makes "doesn't affect" (the chart is another ROM table).

View file

@ -1003,6 +1003,653 @@ cursor inverted and it needs a WRAM reading rather than a pad change.
The decoder, the reward catalog, the adapter version, the roles and the compatibility string are The decoder, the reward catalog, the adapter version, the roles and the compatibility string are
untouched. untouched.
### 12.12 The nurse's box is a ring, and `YES` and `NEXT` are one press (2026-09-22, rung 10, row 41)
Rank 10 (PEWTER CITY), the fly in the Pewter Pokémon Center, and since the v0.4.4 restart the
macro starts were `YES` **2,142**, `TALK` 107, `GO FRONTIER` 26, `BACK` 24, with the event log's
tail `YES start/done` for ever. This is **row 41**, first measured in the rung-9 trap hunt (`YES`
1,278 of 1,295 starts on one tile of map `0x3a`) and named by 12.11 as the next trap. Reproduced
from the live checkpoint with the real cartridge and surveyed press by press
(`examples/scene_probe.rs`, `FLY_PROBE_CATCH=nurse`); `infra/docs/macros-traps.md` has the survey
whole.
- **The conversation is a ring of forty-six A presses, and the box is a *choice* on one of them.**
Welcome, "We heal your POKéMON back to perfect health!", the **YES/NO box**, "OK. We'll need your
POKéMON.", the machine, "Your POKéMON are fighting fit!", "We hope to see you again!", the box
closes for a single frame, and the next A press at a nurse two tiles away over her counter opens
the whole thing again. The party read **70/70 and healthy** on every frame of it, so not one of
those presses changed anything. That is section 12.2's rule at conversation scale: a macro whose
precondition is already satisfied where the fly stands is a trap.
- **The brief allowed three readings and the survey settles it as none of them.** The box open at
the checkpoint is not the prompt, it is the closing line, and `YES` there is an A press on plain
text -- forty-five of the forty-six frames are like that. `HEAL` is not in the loop at all: its
precondition already reads the live party, `party_needs_rest` answers `false`, and the button was
off the pad the whole time. And `HEAL`'s own wait is a *read* of the party, not a loop waiting
for a timer, so it cannot spin on a party that is already full. What was in the loop was the
**dialog pad**, dealt unconditionally, and `TALK` to get back into it.
- **On a box that is a choice, `NEXT` is `YES` under another name.** An A press at a two-option menu
confirms the option the cursor is on, and the cursor opens on YES, so `NEXT` and `YES` are one
press with two channels -- 12.10's rule about a pair of buttons, in a dialog rather than a
battle. `NEXT` is off any pad dealt for a readable YES/NO prompt; the pad is the box's own two
answers.
- **At the nurse's prompt the answer that changes something is the only one bound.** Hurt or
statused, `YES`; full and healthy, `NO`. Section 13 has read that byte for `HEAL` since the
errands existed; this is the same byte read for the box `HEAL` opens. Knowledge inside the macro
as a precondition, and nothing ranks the two: one of them simply is not there.
- **`TALK` is not offered at a nurse the party has no use for.** The nurse is an object with a
purpose rather than a person to chat with, and she is the one person in Red whose conversation
has a precondition the cartridge publishes. This is the door into the ring, and closing it is
what makes the rest of the section a backstop rather than the fix. Nobody else is narrowed: an
ordinary villager is `TALK`'s whatever the party reads.
- **The nurse enters the *talked* ledger on a completed heal or a declined prompt.** A completed
`HEAL` has had the conversation with its own presses, so `TALK` has nothing left to open, and the
reached window would otherwise expire in ten brain minutes and offer the ring again. A declined
prompt is 12.4's rule deliberately inverted, for one person: "the thing it said no to is still on
offer" is true of a villager with something to say and false of a service the party does not
need -- and the pad only ever offers `NO` at her prompt when the party is already full.
- **`TALK`'s precondition and `TALK`'s ledger entry were two different questions, which is why she
was never retired.** The precondition reaches over a counter, because
`IsSpriteOrSignInFrontOfPlayer` does; the entry was read one tile ahead. So `TALK` was *bound* at
the counter and *recorded* nothing at all, 107 times. A precondition and the ledger that answers
it have to be the same reading, and they are now.
- **The general rule: an answer that brings the same prompt straight back is excluded for the
blocked window.** Same map, same tile, same readable prompt, within one hold of the answer
finishing -- nothing moved and nothing was settled, so the press did nothing the next press will
not undo. It is 12.1's ledger doing for an answer what it does for a walk, with the same ten
brain minutes, keyed `TargetKey::Answer { at, yes }`. The exclusion *narrows* a pad and never
empties one: with both answers excluded both come back, because a box nothing can answer is a
screen nothing can leave.
- **What the reading rests on, and what it does not claim.** `docs/design/macros-wram.md` said
outright that there is no "a choice is open" flag, and there is not -- so `yes_no_prompt` is the
construction `text_box`'s `waiting` already makes: `wFontLoaded` plus the figure the game draws,
a border at (11, 6)-(19, 11) with the shared cursor parked at row 8, column 12, one item below
the first, watching A and B. **Both halves are needed**: the cursor bytes are not cleared when
the box closes, so all forty-six of the nurse's frames carry that geometry while the box itself
is drawn on exactly one. Red places a two-option menu where the script asking for it says, so a
prompt drawn somewhere else reads `false` and its dialog keeps the pad it has always had. That is
a named limit, not a gap being papered over.
**What the harness holds.** Unit: `HEAL` is off a full party's pad, including the rung-10 party's
own numbers; `TALK` is off a rested nurse's pad and on a hurt one's; the nurse's prompt deals one
answer and a plain box still deals three; a readable prompt that is not hers deals both answers and
no `NEXT`; an answer whose prompt reopens is excluded and an answer that settled the box is not; a
completed heal and a declined prompt both write the nurse into the talked ledger. ROM-gated from
the live checkpoint: the fly leaves map `0x3a`, `YES` starts stay under five, `NEXT` is on no pad
while a prompt is readable, and `TALK` is on no pad at a rested nurse.
The decoder, the reward catalog, the adapter version, the roles and the compatibility string are
untouched.
### 12.13 `UNKNOWN` is two states, and one of them has nothing to press (2026-09-22, rung 10, five and a half hours)
Live on v0.4.5, rank 10, the fly inside the Pewter museum with rung 11's BOULDER BADGE two doors
away: since the 11:30 restart the macro starts were `GO FRONTIER` **1,235**, `BACK` **678**,
`GO OBJECTIVE` 267, `GO OUT` 242, `YES` 169, and the previous review had already named the shape --
`BACK` pressed 189 times "in a text box" on map `0x02`, surrounded by `GO OBJECTIVE` and
`GO FRONTIER`. `infra/docs/macros-traps.md` has the reproduction; three things were wrong and all
three are here, and the first of them is not a text box at all.
- **`BACK` was dealt by `Scene::Unknown`, on frames with nothing drawn.** `BACK` is on no overworld
pad and on no dialog pad, so every one of those presses came from `Unknown` — and `Unknown` holds
two states under one name. One is a screen this crate cannot name: the Pokédex, the trainer card,
OPTION, where A and B are what leave it and 13.1 put them there on purpose. The other is a frame
of the **overworld** where the buttons are not reaching the player — a warp in flight, a scripted
push-back, the museum guide walking the fly through the door — which `scene::detect` calls
`Unknown` because its overworld branch needs `controllable`. On the second, `NEXT` and `BACK` are
an A and a B pressed into somebody else's script: they change nothing, they complete where the fly
stands, and that is 12.2's trap with no box to advance. So the pad is dealt on **whether a box is
drawn** (`wFontLoaded`, the reading `text_box` already makes), and a scripted overworld frame is an
empty pad the fly waits out. Nothing else can wait it out: the cartridge gives the buttons back by
itself, which is the difference between this and every other empty pad 13.1 enumerates.
### 12.14 A frontier no walk can reach is a fact about the map (2026-09-22, the same run)
The museum's ground floor is 98 walkable tiles, **62** of them reachable from the door and **39**
never stood on — and almost all of those 39 are behind the admission desk. `GO FRONTIER` aims at
ground the run has not stood on, the route search cannot reach any of it, so the macro refuses
`no route`, presses nothing and writes every goal to the blocked ledger (12.1). That ledger is a
**ten brain minute window**: it lapsed, all of it was a candidate again, and the refusal happened
again, once per hold, for hours.
A window is the right shape for a target somebody is standing in front of and the wrong shape for
ground the map has fenced off. So the refusal is remembered **per map** instead, with no window,
beside the pushed-tile ledger of row 37 — and it is *cleared* by the one event that can change the
answer: the fly standing somewhere on that map it has not stood on before, because a door opened, a
script carried it through, or somebody moved out of a doorway. Re-entering the map clears nothing;
that was the loop the window made. Session state like every other ledger, never checkpointed.
### 12.15 The ratchet's stall window cannot see a fly walking a road it has already covered
Two "Stuck" rollbacks fired on this rung inside half an hour, attempts 0 → 2, each one putting the
fly back where it had started. Both were the ratchet working exactly to contract: its stall window
is restarted by *exploration* — `progress.unique_locations`, ground the run has never stood on
(`docs/design/ladder.md`, and row 22) — and 120 brain seconds of safe overworld samples without one
new tile is a stall by definition. Entering a map for the first time already counts, because a new
map is a map's worth of tiles nobody has stood on; **re-entering** one does not, and two museum
floors and a covered town are exactly that.
What is plainly progress and is not ground: **being nearer the objective than this run has ever
been**, counted in hops over the same map graph `GO OBJECTIVE` walks (`geography::hops`). The macro
layer answers it, the sim loop passes it to the ratchet beside the coverage figure, and the ratchet
treats it exactly as it treats a rise in coverage — it restarts the window and does nothing else: no
budget spent, no snapshot taken, no trigger skipped. It can fire at most once per step of the road,
and nothing in the macro layer reads it back: no macro is ranked by it and no button is bound on it.
The checkpointed ratchet state does not move. The signal is a level on one sample, not a counter, so
there is nothing to serialise and nothing to drift across a restore.
### 12.16 What is still in the way of the badge, measured rather than fixed
With 12.13, 12.14 and the museum's two rows on the map graph, the ROM-gated run from the live
checkpoint reaches the gym's own interior on **15 macros** — against never, in five and a half live
hours. Two things it then does are worth naming, because neither is a bug and both cost the rung:
- **the town's errands come first, and they are session state.** Section 13 puts an unvisited mart
or Pokémon Center ahead of the rung's place for every map in that area, and the gym is in Pewter's
area like everything else. The ledger does not survive a restart, so the 11:30 restart re-armed
both errands and `GO OBJECTIVE` aimed at them before the leader. They are paid once and the run
goes on; the cost is minutes, not hours.
- **`GO FRONTIER` is still most of the run** — 1,247 starts in 55 brain minutes, 649 of them in
Pewter City itself. There the frontier is genuinely reachable, one tile at a time, because the
whole-map grid is refused on a walking fly (below) and the windowed frontier is the nearest
unstood tile on screen. It is covering ground rather than standing still, which is why it is a
residual and not a trap.
**The trap hunt does not improve.** Distinct tiles 193 -> 175 and flagged windows 59 -> 69, with
`BACK` in a text box 295 -> 0 and battle frames 6,948 -> 20,894. What the old cycle is replaced by
is a new one on the same five tiles -- `GO FRONTIER`, `GO HEAL`, `GO ROUTE`, x42, for seven and a
half brain minutes -- and then four brain minutes inside one battle, which the hunt's tile rule
flags as hard as it flags a stall. `infra/docs/macros-traps.md` has both arms whole and row 54 is
the next brief. The ethos check's "the trap hunt improves" does not hold for this branch; the
ROM-gated run does, and both are reported rather than one of them.
**The whole-map grid is refused while the fly is moving.** `pokemon_red::state::map_grid` checks its
decode against the screen buffer over the fly's own tile and its four neighbours, and on a frame
mid-step the two are a tile apart. Measured on Pewter City from the rung-10 checkpoint: standing
still it decodes on **118 of 120** frames, and the frame the survey caught disagreed on three tiles
by exactly one row in the direction of travel. A walk planned on such a frame is planned over the
ten-by-nine window of section 15's "before".
*Worked in 12.17*, and the guess above was the wrong way round: the survey found the coordinates
change at the **end** of the step, so it is the screen that is a tile ahead of `wYCoord` rather
than `wYCoord` ahead of the screen.
### 12.17 The coordinates change at the end of a step, and an errand arrives inside (2026-09-22, row 54)
Section 12.16's two residuals turned out to be one fact and one old rule that had been left off one
walk. Both were measured from the same rung-10 checkpoint, with
`FLY_PROBE_CATCH=step` in `examples/scene_probe.rs`; the bytes are in
`docs/design/macros-wram.md` section 9.
- **`wXCoord` and `wYCoord` change at the *end* of a step, not at its start.** Holding UP out of the
Pewter museum, `wYCoord` read 7 for frames 0 to 15 of a sixteen-frame step and 6 from frame 16,
while from frame 2 the screen buffer already held the view centred on (10, 6). The grid's
cross-check compared the decode of (10, 7) with the screen's reading of (10, 6) -- `$20` against
`$01` -- and refused, on fourteen frames of every sixteen. Pewter City decoded on **118 of 120**
standing frames and on **none** of the moving ones, so every walk the fly actually took was
re-planned over the ten-by-nine window: section 15's "before", and row 23's oscillation with it.
- **So the decode is read from the tile the screen is centred on.** Nothing in the pinned symbol
table says "a step is in progress" and a new address cannot be pinned without the disassembly
`gen_symbols.py` reads, so the anchor is *measured* rather than named: the screen is centred on
the fly's tile or on one of its four neighbours, and the one it is centred on is the one whose
whole neighbourhood agrees with the decode. The check keeps the property it exists for -- a wrong
stride, a wrong quadrant, a half-loaded map or the mid-warp tear agrees with **none** of the five,
because the whole neighbourhood has to agree under one anchor rather than each tile finding an
anchor of its own.
- **The tile a step is landing on is ground the run has covered.** The other half of the same fact:
for fifteen frames of every sixteen the stood ledger recorded the tile the fly had already left,
so the ground under it stayed *unstood*, `path::frontier` kept offering it, and `GO FRONTIER` was
dealt aiming one tile away -- a walk that reports `done` the instant the step it did not make
lands. A step that has begun always finishes, and the screen has already centred on it.
- **An errand arrives inside the building, facing the counter, never on the doormat outside it.**
`GO SHOP` and `GO HEAL` aim at a door, and a door's aim carries no press because the warp fires
when it is stepped on -- so an aim on the tile the fly is already standing on settles for
`SETTLE_FRAMES` and reports `done` with the world exactly as it was. Section 12.2's trap in its
own words, and `exit_goals` has excluded a settled goal underfoot since row 13: this was the one
walk that did not have the rule. A completed errand walk also writes the reached ledger, which
`goals_toward` does not filter, so the same button came back every hold: `GO HEAL` **204** starts
at a mean net of 0.0 tiles and a mean reach of 0.0.
- **An errand is paid by a building this run has already been inside.** `areaVisited` is session
state, so a restore re-armed every errand in the town and walked the fly back to a counter it had
already used -- section 13's own residual. `MacroState::map_visited` is the adapter's lifetime
answer to the same question and it does survive a restore, so both are asked and either pays.
Nothing here changes which button the fly presses. The decoder, the reward catalog, the adapter
version and the compatibility string are untouched.
### 12.18 A menu is up while its box is on screen, not while its cursor bytes say so (2026-09-22, row 50)
The largest thing left inside a battle after 12.17: `MOVE n` reported `blocked` **890 times in
1,431 macros** from the rung-9 forest checkpoint, `MOVE 4` **222 of 224**, and 82% of a fixed run's
frames were battle time with one battle running 30,809 of them. Row 50 called it "the move list
drawn and its cursor placeable but not accepting input". Half of that turned out to be wrong, and
finding out which half is the whole fix.
- **The cursor bytes outlive the list, so the list was not drawn at all.** `MoveSelectionMenu`
writes `wTopMenuItemY` 12 and `wTopMenuItemX` 5 and **nothing in the game clears them**. That is
the same fact 12.12 rested the YES/NO box on — "the cursor bytes survive the box closing" — and
the reason the *top-level* battle menu never had it is that `wTextBoxID` = `$0b` sits beside its
geometry and is written by somebody else. `SelectMenuItem` then decrements `wCurrentMenuItem` back
into a 0-based move slot on its way out, which lands inside the one-based range the accessor reads
as valid, so a turn spent on move 2, 3 or 4 leaves a *placeable* cursor behind it. Every frame of
the text, the animation, the damage and the enemy's reply read as the fly's own turn on an open
move list. The pad dealt `MOVE 1..4` and `BACK` on all of them, the roll landed on one, and the
cursor step pressed at a list nobody was reading until its budget ran out. That is section 12.2's
trap wearing 12.6's clothes: a macro whose precondition is satisfied where the fly stands.
- **So a menu is up while its box is on screen.** The reading is the figure `MoveSelectionMenu`
draws — a box at (4, 12) fourteen wide, with a horizontal run over its top-left corner and the
`┘` junction over (10, 12) — read whole, exactly as `text_box`'s `waiting` and `yes_no_prompt`
are. `docs/design/macros-wram.md` section 10 has the accessor.
- **It was surveyed by pressing, not by nominating a flag.** `examples/scene_probe.rs`,
`FLY_PROBE_CATCH=accept`: on every battle frame the emulator exports its state, one directional
pulse is issued, `wCurrentMenuItem` is read and the state goes straight back, so every frame has a
ground truth and the run is not perturbed by the measurement. By the cursor bytes alone a press
was honoured on **264 frames of 3,102**; by the cursor bytes and the box on **231 of 231**. Beside
it every byte of WRAM and HRAM was asked whether it separates the two classes, so a reading was
found rather than guessed — nothing separates once the box is in it, which is what "exact" means
here.
- **A frame whose list is not on screen is between turns**, whose pad is the one `NEXT` that
advances text (12.10). No pad gains or loses a button anywhere else: the top-level menu, the party
list, the bag and the forced switch keep exactly the rows 13.1 gives them, and which move the fly
uses is still the fly's.
- **The bag is the same trap and it is named rather than fixed.** `wListMenuID` = `ITEMLISTMENU`
outlives the bag as surely as the cursor bytes outlive the move list: over the frames the seam
calls an open battle bag, the same survey refused **449** presses against 36 honoured. The bag's
list is drawn in the top half of the screen and the survey has not yet found the figure that tells
it from the frame after it closes, so `ITEM` and `THROW BALL` still pay for it and that is
reported. A reading this crate cannot verify does not go in (`docs/design/ladder.md`).
The decoder, the reward catalog, the adapter version, the roles and the compatibility string are
untouched.
### 12.19 A mart's counter is four screens, and one of them is the clerk talking (2026-09-22, row 55)
Minutes after v0.4.7 went live the watchdog flagged the narrowest loop yet: map `0x38`, scene
`shop`, `sequence: [BUY ANTIDOTE], period 1, repeats 747, distinctMacros 1` over ten brain
minutes, with `BUY ANTIDOTE start` then `BUY ANTIDOTE blocked` every 0.8 s and **nothing else
starting at all**. Surveyed from the live checkpoint with a new probe mode
(`FLY_PROBE_CATCH=shop` in `examples/scene_probe.rs`); the bytes are in
`docs/design/macros-wram.md` section 7.1.
- **`wListMenuID` says the counter is open, not which of its screens is up.** The mart prints its
own text from inside `DisplayPokemartDialogue_`, which never calls the routine that clears that
byte, so it holds `PRICEDITEMLISTMENU` for the **whole visit**. The frame the stream sat on was
the clerk's "Here you are! Thank you!" box waiting for a press, and the seam called it the buy
list; the cursor bytes on it belong to a two-option box (`max` 1). So which screen is up is now
read from the figure the game draws -- the construction the dialogue box's `waiting` test and the
YES/NO prompt already use. The clerk talking is `ShopScreen::Talking`, it reports **no listing at
all**, and no purchase starts on it: a scene whose menu is not open offers what actually opens it,
which here is `CONFIRM` and `LEAVE`, and both are already on the shop's pad.
- **A mart's buy list scrolls, so an item's position in the stock is its cursor index only for the
first three entries.** Walked one pulse at a time on the live list, the cursor went `0, 1, 2` and
then stopped moving while the window scrolled under it. The offset that names the scrolled
position is not a pinned address, so the fourth item of a counter and after have no index this
seam can aim at. Pewter's counter carries seven items and its ANTIDOTE is the **fourth**:
`BUY ANTIDOTE` aimed a cursor step at index 3 in a list reporting a max of 1 and returned
`Blocked` **on its own first frame, having pressed nothing** -- three of three attempts, zero
frames. A macro that cannot run is not on the pad, so the four purchases are bound on the rows
the cursor can reach, and Viridian's four-item counter is why this went unseen: its ANTIDOTE is
index 1.
- **A purchase has no target, so a blocked purchase records nothing.** Section 12.1's ledger is
keyed by what a walk set out for and a press sets out for nothing, so the button was dealt again
on the very next hold, for ever -- row 6's shape in a scene with no walk in it. The fix is the
precondition rather than a new ledger: the question the ledger would have answered is a fact about
the counter, and the pad can ask it before the fly presses.
The loop was also **the fly's own choice landing on the one button it could afford**: the wallet
read 104, so of Pewter's stock only the Antidote was under it, and `BUY ANTIDOTE` was the only
purchase bound. Nothing about the choice changes here. What changes is that the button is not
offered, and the two presses that leave a counter are.
Nothing here changes which button the fly presses. The decoder, the reward catalog, the adapter
version and the compatibility string are untouched.
### 12.20 A two-option box is the one the cartridge drew, and a `NO` inside a conversation declines nothing (2026-09-23, row 56)
Live on the release box: map 54 (**Pewter Gym**), scene `dialog`, rank 10, **thirty-plus brain
minutes of zero progress** with the explore and wild-win counters frozen, **747 macro starts in ten
brain minutes**, and a mix of `YES` 64 / `NO` 62 / `NEXT` 59 / `TALK` 6 with **no walk macro dealt
at all**. The watchdog did not flag it: four distinct macros is exactly its threshold. Surveyed
from the live checkpoint with a new probe mode (`examples/scene_probe.rs`,
`FLY_PROBE_CATCH=dialog`), which walks the conversation one raw pulse at a time and prints, per
frame, what the seam makes of it beside **every complete `TextBoxBorder` the cartridge actually
drew**. `infra/docs/macros-traps.md` has the survey whole.
The shape is **row 41's ring one town over**: the gym guide's conversation is fifty-two presses --
"Hiya! I can tell you have what it takes to become a POKeMON champ!", "Let me take you to the
top!" with a YES/NO box, the type-matchup tutorial, "matches could be made easier!" -- the box
closes for a frame, and the next A press at the guide two tiles away opens the whole thing again.
Nothing in it changes the world. Two things kept the fly walking it, and both are readings rather
than pads.
- **The box was drawn where this crate was not looking.** 12.12 read the border at
(11, 6)-(19, 11), because that is where a Pokemon Center's script puts it, and said so in its own
residual: "Red places a two-option menu where the script asking for it says, so a prompt drawn
elsewhere reads `false` and its dialog keeps the pad it has always had". The guide's box is at
**(14, 7)-(19, 11)** with the cursor at column 15. Over 260 surveyed presses the box was drawn on
**10 frames** and `yes_no_prompt` answered `false` on **all 260** -- so the pad was
`NEXT, YES, NO` on a frame that was a *choice*, which is 12.10's forbidden pair (an A press at a
two-option menu confirms the option the cursor is on, and that is what `YES` is), and the
reopened-prompt exclusion of 12.12 never armed, because it only judges an answer to a prompt this
crate can read. **The whole of 12.12 was inert in that gym.**
- **So the figure is found rather than pinned.** One fact about `DisplayTwoOptionMenu` rather than
about any one script: the cursor goes in the box's **first interior column**, so the border's
left edge is one column to the left of `wTopMenuItemX` -- true of both boxes surveyed. The *top*
obeys no such rule, because the nurse's box begins two rows above the first item and the guide's
one, so the top is found by looking up for the border's own corner and the figure is then read
**whole**, exactly as `waiting` and the move list are. `docs/design/macros-wram.md` section 11
has the accessor. Measured over both checkpoints, 400 frames: the reading is true on the 14
frames a two-option box is drawn and false on the other 386, and `wTextBoxID` = `TWO_OPTION_MENU`
agrees with it exactly -- which is recorded as a third reading and **not** put in the accessor,
because no survey here covers Red's other two-option menus.
- **And a `NO` pressed inside a conversation declines nothing.** 12.4's rule -- "the fly said no, so
whatever it said no to is still on offer" -- took the pending `TALK` off the moment any `NO`
finished. A `NO`'s B press advances a plain text box exactly as `NEXT`'s A does, about a third of
the fifty-two presses that walk the guide's ring are `NO`, so the talked ledger **never learned
the conversation had happened**: `TALK` was on the overworld pad every hold and was the ring's
own door. In the twenty-brain-minute reproduction `TALK` started **25** times on that one map.
- **Which of the two a `NO` was is decided where it can be seen: by whether the box closes on it.**
The decision moves to the frame the text goes away, which is where the talked entry is written
anyway, and the reading is the answer still standing there -- `pending_answer`, armed only by an
answer to a prompt this crate can read and alive for one hold (12.12). A declining `NO` still
standing when the box closes is a `NO` the box closed *on*, and the offer stands; anything else
is a conversation walked through to its end, and the person is retired. The nurse's own declined
heal is unchanged: it writes her into the ledger by 12.12's named inversion, one person wide.
- **What the pad does instead, which is the point.** With the guide retired, the gym's overworld pad
is `GO OBJECTIVE`, `GO OUT`, `GO FRONTIER`, `GO HEAL` -- the walks -- and the fly is out of the
room in 0.37 brain minutes against never in twenty. Nothing new is on any pad and nothing is
ranked: `TALK` goes off a person this run has already had the conversation with, which is the
ledger 9.2 added doing exactly what it was added for, and `NEXT` goes off a readable prompt,
which is 12.10.
**What the harness holds.** Unit: a two-option box reads as a prompt at both surveyed geometries
and at neither without its border, its font flag or its two-option cursor; a box the cursor is not
parked in is not the cursor's box; the pads the two frames are dealt (`YES, NO` against
`NEXT, YES, NO`); a declined offer leaves the thing on offer; and a `NO` deeper inside a
conversation leaves the conversation counted and `TALK` off the pad. ROM-gated from the live
checkpoint: the fly leaves map 54, `NEXT` is on no pad while a readable prompt is open, and **no
readable prompt is answered more than four times for one person in a session**, against 439
answers at one person in the twenty-minute reproduction.
The decoder, the reward catalog, the adapter version, the roles and the compatibility string are
untouched.
### 12.21 A button refused from here is not dealt again from here, a last resort walks where it can, and an escort walls the tile it fired on (2026-09-23, row 57)
Live on v0.5.3, rank 10, map 2 (**Pewter City**), scene `overworld`: the pad was **`GO ROUTE` and
nothing else**, and it was refused about **740 times per ten brain minutes for more than two
hours**, with one `GO ROUTE start` / `blocked` pair every ten brain minutes, no button pressed and
the exploration count frozen. The watchdog read one start and one name, and never flagged it. The
`refused` event's `value 3.0` is the slot, not a reason (row 55); the feed carries no reason.
The ledgers that dealt that pad are session state and a restore starts them empty, so the trap
does not come back from the checkpoint by itself: a twenty-brain-minute hunt with the real brain
from the live frame covers 356 tiles. It was reproduced by **earning** them -- a new probe mode
(`examples/scene_probe.rs`, `FLY_PROBE_CATCH=route`) drives the real palette from the checkpoint and
can seed each ledger -- and three facts came out, each measured on the cartridge.
- **The pushed ledger walled the tile a walk set out from, not the tile the script fired on.**
Pewter City's youngster takes the joypad on four tiles by the road east
(`PewterCityPlayerLeavingEastCoords`) and walks the fly to the gym until Brock is beaten. `GO
ROUTE` aims east every time, because Route 3 is the one connection the run has not crossed, so it
is escorted every time -- and row 37's ledger, which has **no window**, recorded the macro's
starting tile. Measured from the ratchet's rollback snapshot: one `GO ROUTE` from the town's
south entrance walked 26 tiles to (37, 18), was escorted, and walled **(18, 35)**, the south
entrance. Row 37's rule is right for a press (the fly is standing on the tile the script fires
on) and wrong for a walk. Walks start wherever the last one ended, so the walls accumulate
until the fly stands in a pocket no route leaves.
- **In the pocket every walk refuses `no route`**, and each refusal is recorded where it belongs:
`GO FRONTIER`'s marks the map exhausted (12.14, no window, cleared only by new ground, and there
was none for hours), and `GO OBJECTIVE`'s only goal, the gym's door, goes to the blocked ledger.
With every person and sign already talked to and the errands paid, nothing else is left.
- **The last resort is the one list that ignores the blocked ledger**, by design ("a target the
ledger is resting is still the only place to go", 13.1). So `ways` dealt `GO ROUTE` at the gym's
door, the route search refused it, the refusal wrote the door to a ledger the dealer does not
read, and the button was dealt again on the next hold. The same refusal **re-stamped the door's
window every hold**, which is why `GO OBJECTIVE` never came back either. Once per window the
road east lapsed, `GO ROUTE` walked it, and it was excluded again.
Seeded with that pocket -- three pushed tiles sealing the strip by the road from the town, the
frontier mark, everything talked to, the road east resting -- the cartridge deals exactly the live
pad: `GO ROUTE`, refused `no route` **746 holds running** on one tile over ten brain minutes.
**The fix, all three parts inside the macros:**
- **A walk walls the tile it last stood the fly on**, which is where the cartridge took over; every
other macro keeps the tile it started on. The same walk now walls (37, 18), one of the four
tiles the youngster fires on.
- **A refusal that taught the blocked ledger nothing is recorded with the tile the fly stood on,
and the dealer does not deal that button from that tile for the blocked window.** The dealer
asks the cheap question and `start` the real one, and the blocked ledger closes that gap for
every list but a last resort; this closes it for all of them without a route search in the
dealer, which runs every frame. Only a last resort deals goals the ledger is already resting, so
a `no route` whose every goal was resting already -- and any `precondition` refusal, which writes
nothing -- is the one remembered; a refusal that writes a new exclusion changes the next deal
by itself. (Measured: holding *every* refusal against the tile kept the real brain, which
pressed `GO ROUTE` first while the door was still the second tier's answer, from ever reaching
the last resort below.) It is a fact about *here*: the button is dealt again the moment the fly stands
on any other tile, or when the window closes. A pad with nothing left that can run is empty and
the fly waits, which is section 13.1's honest answer.
- **A last resort that cannot reach the objective's door takes a way out it can reach.** The
narrowing to "the ways toward the objective" is a preference the dealer cannot check, and in the
pocket it chose the gym's door beyond the fence while the road east was three tiles away,
resting in its window (which a last resort ignores). When `start`'s route search cannot reach
the preferred ways it tries the rest of the last resort, nearest reachable first, as every walk
chooses. The pad does not change; only where the pressed macro walks.
In the rebuilt pocket the fly now leaves on **frame 517** (0.14 brain minutes) -- walked east, met
by the youngster, carried to the gym -- against frame 36,325 on the base, when the road's window
lapsed; `GO ROUTE` is refused **once** there against 746 holds running.
Nothing presses for the fly and nothing is ranked: one button leaves a pad it could not run from,
one wall moves to the tile that earned it, and one walk goes where it can. The decoder, the reward catalog, the adapter
version, the roles and the compatibility string are untouched.
### 12.22 The rung's people are in the room when the screen does not show them (2026-09-23, row 58)
Live on v0.5.3, rank 10, for twenty-five minutes: `GO OBJECTIVE` into the Pewter Gym, `GO OUT`
straight back out, with `GO ITEM`, `GO FRONTIER`, `YES` and `NO` mixed in. Per ten brain minutes
about 93 `GO OUT`, 47 `GO OBJECTIVE`, 200 starts in all, every one `done`, **no reward event of any
kind**, the exploration count frozen at 1,892. Check 10 saw ten distinct names and said nothing.
Surveyed from the live checkpoint with the route probe (`FLY_PROBE_CATCH=route`,
`FLY_PROBE_CATCH_MAP=54`), which reads the room on the fly's Nth arrival.
- **The objective saw the room through the screen.** `objective_targets` read `npcs`, which is
what the cartridge *draws*, and `CheckSpriteAvailability` writes `$ff` into the image index of
every sprite outside a window of the player's coordinate. From the doormat at (4, 13) that window
holds the guide at (7, 10) and nobody else: BROCK at (4, 1) and the Jr. Trainer at (3, 6) are not
drawn. With the guide talked to (12.20), the rung's list was empty, so `GO OBJECTIVE` had nothing
to aim at inside and 12.5's rule -- the ways out are withheld while the rung's person is in the
room -- let `GO OUT` onto the pad. Outside, `GO OBJECTIVE` aimed at the gym's door. The pair
undoes itself in about a second, and nothing on either side of the door earns anything.
- **So the rung reads the people the cartridge hides only for being off the screen.** The window
is a function of `wYCoord`, `wXCoord` and the sprite's own biased coordinates, all already read,
so a sprite whose `$ff` falls outside it is one the cartridge would hide for that reason whatever
else were true, and a sprite the cartridge is not updating does not move
(`state::offscreen_npcs`). A `$ff` *inside* the window, or on a scripted mover, is not the
screen's and is not reported. Only the rung reads the list: a sprite outside the window may also
be a toggleable object switched off, which reads the same, so `GO NPC`, `TALK` and objects keep
what is drawn.
- **Facing any of the rung's people is the arrival.** 12.5 left out only the one ahead, which was
enough for one target; a gym names three, and in front of BROCK `GO OBJECTIVE` still had the
trainer to walk to. A fly facing a person the rung is waiting on has nothing left for a walk to
do, and `TALK` is the press.
Three frames the seam read as the fly's own were the cartridge's, and each wrote a ledger entry that
emptied the room again once the first fix let the fly into it:
- **A warp's tear.** `wCurMap` changes thirty-two frames before the header, the coordinates and the
warp table follow it, while the screen fades, and no joypad bit is set until the fade is over.
The seam read "map 54 at (16, 17)" -- Pewter City's doormat under the gym's id -- as an overworld
and dealt it a pad; a walk started there planned over the wrong map, and what it aimed at went
into the blocked ledger under the gym's id (live: `GO OUT` started and finished in 0.05 s). The
driver now reads a tear as the map byte having changed while the fly still stands on a warp of
the loaded table that leads to the map the byte names, deals it as `Unknown` with an empty pad,
and records no ground from it. Teleport pads -- Saffron Gym, two Silph Co. floors -- do not change
the map byte, so they are never a tear; a tear is bounded at ninety frames all the same.
- **A battle's transition.** Between a trainer's challenge closing and the battle screen there are
219 frames with every joypad and script bit clear. The pad was dealt, a walk toward BROCK pressed
into the animation and gave up after three refused steps -- BROCK blocked for ten brain minutes --
and the Jr. Trainer's conversation read as over, so the trainer the fly then lost to was
"talked to" for the session. `wCurOpponent` is set when a battle is decided and cleared by
`EndOfBattle` with `wIsInBattle`; it is not in the generated table and is derived as the byte
between two that are, both neighbours checked in a test (`macros-wram.md` section 12), and
`controllable` reads it.
- **A trainer walking up.** 12.4 reads a macro the cartridge ended by taking the joypad as a
refusal and wrote the target blocked and the tile pushed at once. A trainer who sees the fly
takes the joypad the same way. The entries now wait until the cartridge gives the joypad back:
in the overworld it was a refusal and is written as before; a battle teaches the ledgers nothing.
Nothing is ranked, nothing presses for the fly, and no button is added to any pad: `GO OBJECTIVE`
has a person to walk to where it had none, `GO OUT` is withheld by 12.5's own rule, and three
frames that were never the fly's deal nothing. The decoder, the reward catalog, the adapter
version, the roles and the compatibility string are untouched.
### 12.23 A move the cartridge answers with nothing is not dealt beside one it does not (2026-09-23, row 60)
Live on v0.5.5, early game after the reset to milestone 1: Route 1, Squirtle L5 (TACKLE, TAIL
WHIP) against a wild Pidgey, "Nothing happened!" on the screen. Since the reset `MOVE 2` 183
times and `MOVE 1` once; the last reward 25 brain minutes before the checkpoint, one wild win in
the whole run. Check 10 flagged `unrewarded` (1,600 decisions, no reward event, no new ground on
two probes), which is right, and it is unchanged.
- **The pad was at fault, not only the choice.** `MOVE n`'s precondition was "the slot holds a
move with PP", so TAIL WHIP stayed on the pad after it had walked the Pidgey's DEFENSE to the
point the cartridge refuses it. `StatModifierDownEffect` answers "Nothing happened!" when the
stage is already -6 **or the stat itself is already 1**, restoring the stage. The checkpoint is
the frame Squirtle fainted to a Pidgey L3 at DEFENSE -6 (stat 2); in the next battle the stat
reached 1 at -5. From there the pad dealt `MOVE 1, MOVE 2, RUN` and the fly pressed `MOVE 2`
until Squirtle fainted, woke at home and walked back: every battle lost, one wild win in the
run. The readout's favourite being `MOVE 2` is the fly's; a button that can do nothing at all
being on the pad is a macro that knows nothing about its own effect, section 12.2's trap.
- **What a move does is the cartridge's, read the same way for every move.** `state::move_data`
reads the move's row of `Moves` from the cartridge image (`$0E:$4000`, each row checked against
its own id) and `state::move_without_effect` answers the refusals the effect routines make on
bytes already in WRAM: a stat stage at its limit or a stat at 1 or 999, Mist or a substitute in
front of a stat-lowering move, a sleep, poison or paralysis move against a target that already has
a status, is Poison type, or is Ground type to an Electric move. No move is named; a miss is a
roll and is not answered. `macros-wram.md` section 13 has the bytes.
- **It is PP's rule.** A move the cartridge answers with nothing is not dealt beside one it does
not, exactly as a spent move is not (12.6, 12.8), and `MOVE 1` over the menu stops being FIGHT's
backstop only in that case. When no move would do anything the moves stay as PP deals them:
taking the last ones away would leave an open list whose only button is `BACK`, 12.11's pair,
and a turn that ends on "Nothing happened!" still ends. RUN, ITEM and SWITCH are untouched.
Nothing is ranked, weighted or pressed for the fly: a button leaves the pad while it cannot change
anything and comes back when it can (a new battle resets the stages). The decoder, the reward
catalog, the adapter version, the roles and the compatibility string are untouched.
### 12.24 The map graph is the disassembly's, piece by piece (2026-09-23, row 59)
Opened pre-emptively: the row-58 review carried the route survey past the Boulder Badge and the
fly walked Pewter City (39, 17) to Route 3 (0, 9) and back from about frame 68,000, `GO OBJECTIVE`
done on Route 3 538 times and `GO ROUTE` done on Pewter 537. Reproduced from a rank-11 checkpoint
the survey writes (`FLY_PROBE_SAVE_RANK=11`): 509 and 508, never on Route 4. The live fly got
through Route 3 anyway and met the next half on v0.6.0 at 22:20 UTC: rank 12, MT. MOON, the
objective Cerulean, on Route 4 per ten minutes `GO ROUTE` 215, `GO OBJECTIVE` 113, `GO OUT` 103,
two new tiles, in and out of the Pokécenter and the cave mouth. Route 4 was one node with Cerulean
off its east edge, which the mountain cuts off from the cave mouth's side. From the live
checkpoint the route survey on `main` walks it 930 times in 72,000 frames.
**The geography table disagreed with the headers.** Checked row by row against
`data/maps/headers/*.asm` and `data/maps/objects/*.asm` at the pinned commit:
- Route 4 is **north** of Route 3, not east (`Route3.asm`: `connection north, Route4`); Route 3's
top edge is the road to Mt. Moon's Pokécenter. Mt. Moon's doors are both **on Route 4**:
(18, 5) into the first floor and (24, 5) into B1F, whose (27, 3) is the way out. Route 3 has no
warps. So Route 3's north edge named no map, and nothing on Route 3 was the way to the rung.
- Route 14 / 15 and Route 24 / 25 had the right neighbour in the wrong column (west and east,
not south and north). Route 24's east edge is Nugget Bridge's far end, rung 16's road.
**Four maps are pieces the player cannot walk between** (12.7's rule, measured by flooding every
tile of every map on the graph from the blocks, blockset, collision list, tile-pair walls and
ledges): Route 2 as before; **Route 4**, cut by the mountain into the cave mouth's side and
Cerulean's side; **Mt. Moon B1F**, four chambers of two ladders each; **B2F**, one large piece and
two small ones. The one road through is 1F (5, 5), B1F (21, 17), B2F (5, 7), B1F (27, 3); the
other two ladders on 1F lead to dead ends.
A split row is now any number of pieces, each with its doors (the warp index and tile) and what is
one step from it, a whole map or another map's piece. Three rules keep it honest:
- **Which piece the fly is in** is what its walk can reach on the decoded grid (section 15): the
piece whose doors it reaches, when exactly one piece's are. The grid has no ledges, so where it
reaches none (Route 4 below the ledges) the nearest door answers. Flooded over every tile of
the four maps' ground in the disassembly, the rule names the right piece for all of them.
- **Which piece a door lands in** is the cartridge's own answer: a warp names the destination
warp it arrives at (`wWarpEntries` byte 2), and each piece lists its warps. An edge lands in
the piece that lists the map it is stepped off.
- **The hop is a piece**, and an exit is toward the objective only if it lands in that piece. On
1F three ladders go down to B1F and one of them is the road.
**A connection nobody can walk across is not a road.** Four header connections have no tile where
both sides are land: Pallet Town / Route 21, Cinnabar / Route 20, Route 20 / 19 (sea) and Route 22 /
23 (the League's fence). They keep their name and offer no exit and no hop. Without this the road
from Pallet Town to Cerulean was by sea, and a fly that whited out in Mt. Moon, which the survey's
did, walked into Pallet's shore every two seconds.
**One seam frame, found on the way.** Route 3's first trainer closes his challenge onto five frames
of plain overworld before `StartTrainerBattle` decides the battle (`home/trainers.asm`: it runs
after `DisplayTextID`'s close-down). Row 58's pending push-back was decided on the first of them
and walled (11, 6), the one gap between Route 3's west end and the rest of the road, for the
session. A push-back is now a refusal only once the overworld has been the fly's for thirty frames
running; a battle inside them drops it.
From the badge, the route survey reaches Route 4 at frame 70,356 and Mt. Moon at 70,707 (rung 12),
no pushed tile; after whiting out in the cave it walks the land road back from Pallet Town. The
ROM test on the stub rotation reaches Mt. Moon in 56.7 brain minutes with 4 Pewter / Route 3
crossings; the base makes 3,391 in 80.4 and never stands on Route 4. From the live 2026-09-22
rank-11 checkpoint the branch reaches Mt. Moon too, where the base ends fenced on Route 3. From
the live Route 4 checkpoint the branch is in the cave on frame 279 and stays on the road; on the
stub rotation Route 4's west doors are crossed 13 times in twenty brain minutes (whiteouts and
walks back included) against 56 on `main`.
Nothing is ranked and nothing presses for the fly: a table of maps says what the headers say, a
split map has the pieces its ground has, and a frame that was the cartridge's is not read as the
fly's. The decoder, the reward catalog, the adapter version, the roles and the compatibility
string are untouched.
### 12.25 A trainer's challenge is the cartridge's until its battle is over (2026-09-23, row 61)
Live on v0.5.5, rung 9, for twenty minutes: `GO OBJECTIVE` into Viridian Forest's south gate (map
50, `$32`), `GO OUT` straight back onto Route 2, `GO WARP` back from the forest, `GO OBJECTIVE
blocked` in the forest, no reward. Reproduced with the route survey from the live checkpoint
(uniform choice per hold, xorshift seed 7), which walks the same ring for twenty brain minutes.
- **The ring's cause was a wall, not the gate.** The forest's only road to its north gate is a
two-wide corridor at x = 1-2; a Bug Catcher stands on (2, 18) facing west. A walk up the
corridor steps onto (1, 18), the trainer takes the joypad, and row 58's held push-back waits for
the joypad to come back. `DisplayEnemyTrainerTextAndStartBattle` clears `wJoyIgnore` before the
challenge text and `StartTrainerBattle` writes `wCurOpponent` only after that text's close-down:
**five frames** with no box, no script bit and `wCurOpponent` zero, which the seam read as the
fly's overworld. The push was written there; (1, 18) went into the pushed ledger, which has no
window, and from then on every walk to the north gate had no road. `GO OBJECTIVE` walked to the
nearest reachable tile, a dead end at (6, 1), and was blocked; `GO WARP`'s last tier took the
south gate, whose `GO OUT` is Route 2, whose `GO OBJECTIVE` is the gate.
- **The fact is `wStatusFlags7` bit 3, `BIT_TRAINER_BATTLE`**: set by `CheckFightingMapTrainers`
on the "!", cleared at `.battleOccurred` after every battle (before the blackout check). It
covers more than the five frames: the "!" bubble runs about sixty frames before `wJoyIgnore` is
set, and they read as the fly's overworld too -- about sixty-six free-looking frames per
engagement, measured. An overworld frame with the bit set is `Unknown` in the macros' own
scene, with no text box, so the pad is empty, no ground is recorded and no held entry is
decided on it. A trainer talked to by the fly never sets it; its `wCurOpponent` is written
inside the text.
- **On main the five frames are already row 59's** (12.24: a held push-back is written only after
thirty frames of overworld), and that alone keeps (1, 18) clear. This row is the cartridge-fact
layer under it: the pad is empty through the bubble as well, no ground is recorded, and it does
not depend on the gap staying under thirty frames.
- **The macros' reading only.** `controllable` and `scene::detect` are shared with the reward
adapter and do not change; `PokeState`'s `scene` and `scripted` read the bit beside them. In
macros mode the feed's `game.scene` is the palette's, so it reads `unknown` on those frames,
which is what the contract says of a frame the cartridge is driving.
- **The gates were modelled right.** Both forest gates are on the graph and `next_hop` answers
the forest from the south gate and Route 2 from the north one. The south gate's `GO OUT` is the
"a room has to be leavable" tier, a way back that is the fly's choice: with the corridor open,
the survey seed that walked into the gate twelve times still earned the badge, on both arms.
Nothing is ranked or pressed for the fly: frames that were never the fly's deal nothing. The
decoder, the reward catalog, the adapter version, the roles and the compatibility string are
untouched.
## 13. Shops and Pokémon Centers (the operator, 2026-09-17: "refactor the shop macros. make it a ## 13. Shops and Pokémon Centers (the operator, 2026-09-17: "refactor the shop macros. make it a
## priority to visit the shop at least once per area; make shop macros item purchases. same ## priority to visit the shop at least once per area; make shop macros item purchases. same
## for the Pokécenter. heal should be a macro.") ## for the Pokécenter. heal should be a macro.")
@ -1025,7 +1672,9 @@ Knowledge inside macros, never in the choice; the pad still lists buttons and th
marks nothing visited; the errand was paid on entering. marks nothing visited; the errand was paid on entering.
- **Center scene.** `HEAL` is a macro: walk to the counter, face the nurse, talk, answer YES, - **Center scene.** `HEAL` is a macro: walk to the counter, face the nurse, talk, answer YES,
wait for the heal animation to end (read the party HP back to full), close the box. On the wait for the heal animation to end (read the party HP back to full), close the box. On the
pad only when at least one party member is not at full HP or has a status. `LEAVE` walks out. pad only when at least one party member is not at full HP or has a status — verified against the
live party on the cartridge (**12.12**), which is also what takes `TALK` at the nurse off the pad
and what picks the one answer her YES/NO box is dealt. `LEAVE` walks out.
- **Ladder.** Unchanged; no reward for shopping or healing (rewards are the adapter's, - **Ladder.** Unchanged; no reward for shopping or healing (rewards are the adapter's,
untouched). untouched).
- **Screen.** Two new channel tags; the cells and the MACROS rate row take them as they come. - **Screen.** Two new channel tags; the cells and the MACROS rate row take them as they come.
@ -1075,17 +1724,18 @@ observe is not a precondition, it is a guess.
| Overworld, inside a Pokémon Center | the indoor pad plus HEAL | new. A centre is a sub-state of the overworld, not a `Scene`: pokered has no "a Pokémon Center is open" byte, so the only honest observable is the map id, and a new `Scene` would be a new `game.scene` on the wire | | Overworld, inside a Pokémon Center | the indoor pad plus HEAL | new. A centre is a sub-state of the overworld, not a `Scene`: pokered has no "a Pokémon Center is open" byte, so the only honest observable is the map id, and a new `Scene` would be a new `game.scene` on the wire |
| Overworld, inside a mart | the indoor pad | the counter is a `Shop`; the mart's *floor* is an ordinary interior, with the one exception below | | Overworld, inside a mart | the indoor pad | the counter is a `Shop`; the mart's *floor* is an ordinary interior, with the one exception below |
| Overworld, inside a mart or a centre, counter unfaced | GO SHOP or GO HEAL, TALK when facing the counter | new, and it is the one place a pad is deliberately *narrow*. The errand is paid on entering and never offered again, so a walk that leaves the building spends the one visit the area gets — measured: the fly reached the mart in 1.7 brain minutes and `GO OBJECTIVE` walked it straight back out over the doormat. While the counter is unfaced nothing on the pad leaves (row 34b) | | Overworld, inside a mart or a centre, counter unfaced | GO SHOP or GO HEAL, TALK when facing the counter | new, and it is the one place a pad is deliberately *narrow*. The errand is paid on entering and never offered again, so a walk that leaves the building spends the one visit the area gets — measured: the fly reached the mart in 1.7 brain minutes and `GO OBJECTIVE` walked it straight back out over the doormat. While the counter is unfaced nothing on the pad leaves (row 34b) |
| Overworld, inside a mart or a centre, counter faced | the indoor pad, plus HEAL in a centre | the suppression is released by facing the counter, by talking to it, or by a walk to it failing | | Overworld, inside a mart or a centre, counter faced | the indoor pad, plus HEAL in a centre | the suppression is released by facing the counter, by talking to it, or by a walk to it failing. Since **12.12** `TALK` is not on it at a *nurse* the party has no use for: her conversation is a service whose need the cartridge publishes, and a ring of text that ends where it began is section 12.2's trap |
| Dialog | NEXT, YES, NO | unchanged. There is no "a choice is open" flag (`macros-wram.md`), and A and B both advance a plain box, so all three are dealt for every box — what it buys is the fly being able to answer *no* | | Dialog, a plain text box | NEXT, YES, NO | A and B both advance a plain box, so all three are dealt for one — what it buys is the fly being able to answer *no*. Forty-five of the nurse's forty-six frames are this row (**12.12**) |
| Dialog, a readable YES/NO box (**the box the cartridge drew**, 12.20) | YES, NO — or **one of them** at a Pokémon Center's nurse | **new, 12.12.** `NEXT` is off it: an A press at a two-option menu confirms the option the cursor is on, which is what `YES` is, so the two are one press under two names (12.10). At the nurse's own prompt the bound answer is the one that changes something — `YES` with a hurt or statused party, `NO` with a full one. An answer whose prompt comes straight back is excluded for the blocked window, and the exclusion never empties the pad |
| Menu (the start menu) | CLOSE, CONFIRM, BACK | unchanged as a *scene*, and since **12.11** nothing on any other pad opens it: the fly reaches it with the **raw** START button, which still reaches the cartridge in macros mode, and moves its cursor with the raw D-pad. A SAVE or a POKéDEX button would be a macro per start-menu entry and is not asked for -- which is precisely why `MENU` had nothing behind it | | Menu (the start menu) | CLOSE, CONFIRM, BACK | unchanged as a *scene*, and since **12.11** nothing on any other pad opens it: the fly reaches it with the **raw** START button, which still reaches the cartridge in macros mode, and moves its cursor with the raw D-pad. A SAVE or a POKéDEX button would be a macro per start-menu entry and is not asked for -- which is precisely why `MENU` had nothing behind it |
| Menu (the bag, an elevator, the party list outside a battle) | CLOSE, CONFIRM, BACK | unchanged | | Menu (the bag, an elevator, the party list outside a battle) | CLOSE, CONFIRM, BACK | unchanged |
| Unknown (the Pokédex, the trainer card, OPTION, a naming screen, a mid-warp frame) | NEXT, **BACK** | **BACK added** (row 9): B is what leaves the first three, and A leaves none of them | | Unknown (the Pokédex, the trainer card, OPTION, a naming screen, a mid-warp frame) | NEXT, **BACK** | **BACK added** (row 9): B is what leaves the first three, and A leaves none of them |
| Battle, own turn, main menu | MOVE 1..4, SWITCH, ITEM, THROW BALL, RUN (whose cursor indices are FIGHT 0, **ITEM 1, PKMN 2**, RUN 3 -- two columns, 12.11) | four move buttons for `ATTACK` (section 14); THROW BALL added, and gated on the species since 12.9; **RUN gated**, below. No `BACK`: the four entries are the answers to this menu. **`NEXT` removed by 12.10** — an A press here confirms FIGHT and reopens the list the move list's `BACK` just closed, and `MOVE 1` is the backstop instead, bound here whatever the battler reads as | | Battle, own turn, main menu | MOVE 1..4, SWITCH, ITEM, THROW BALL, RUN (whose cursor indices are FIGHT 0, **ITEM 1, PKMN 2**, RUN 3 -- two columns, 12.11) | four move buttons for `ATTACK` (section 14); THROW BALL added, and gated on the species since 12.9; **RUN gated**, below. No `BACK`: the four entries are the answers to this menu. **`NEXT` removed by 12.10** — an A press here confirms FIGHT and reopens the list the move list's `BACK` just closed, and `MOVE 1` is the backstop instead, bound here whatever the battler reads as |
| Battle, own turn, move list | MOVE 1..4, BACK -- or **MOVE 1 alone** | as above, plus **12.11**: `BACK` is dealt here only while `wBattleMon*` reads, because a list that binds no `MOVE n` has a pad whose one button closes the list `MOVE 1` underneath had just opened. With nothing readable the pad is `MOVE 1` and its script confirms where the cursor stands | | Battle, own turn, move list (**the box on screen**, 12.18) | MOVE 1..4, BACK -- or **MOVE 1 alone** | as above, plus **12.11**: `BACK` is dealt here only while `wBattleMon*` reads, because a list that binds no `MOVE n` has a pad whose one button closes the list `MOVE 1` underneath had just opened. With nothing readable the pad is `MOVE 1` and its script confirms where the cursor stands |
| Battle, own turn, party list | SWITCH, BACK | unchanged | | Battle, own turn, party list | SWITCH, BACK | unchanged |
| Battle, own turn, the bag | ITEM, THROW BALL, **BACK** | the bag reports a *cursor* (`macros-wram.md` 7.1), and since **12.10** it is the own turn, because a cursor accepting input is one. Its pad is the list's own answers; `NEXT` and `CONFIRM` are both off it, being the same blind A press that *uses* whatever the cursor holds | | Battle, own turn, the bag | ITEM, THROW BALL, **BACK** | the bag reports a *cursor* (`macros-wram.md` 7.1), and since **12.10** it is the own turn, because a cursor accepting input is one. Its pad is the list's own answers; `NEXT` and `CONFIRM` are both off it, being the same blind A press that *uses* whatever the cursor holds |
| Battle, forced switch | SWITCH, NEXT | unchanged (row 8). The one arm that keeps `NEXT` with a cursor up, because it cannot be cancelled and has no `BACK` to undo it | | Battle, forced switch | SWITCH, NEXT | unchanged (row 8). The one arm that keeps `NEXT` with a cursor up, because it cannot be cancelled and has no `BACK` to undo it |
| Battle, between turns | NEXT | `BACK` was added here for the bag and **taken back out by 12.9**: on a frame of battle text there is no list to leave, and a `BACK` that changes nothing is the trap of section 12.2. Since **12.10** the bag is not on this row at all, so `NEXT` here is only ever the A that advances text | | Battle, between turns | NEXT | since **12.18** this row is most of a battle, and correctly so: a frame whose move list is remembered rather than drawn lands here. `BACK` was added here for the bag and **taken back out by 12.9**: on a frame of battle text there is no list to leave, and a `BACK` that changes nothing is the trap of section 12.2. Since **12.10** the bag is not on this row at all, so `NEXT` here is only ever the A that advances text |
| Shop | BUY POTION, BUY BALL, BUY ANTIDOTE, BUY REPEL, CONFIRM, LEAVE | two purchases to four; CONFIRM added | | Shop | BUY POTION, BUY BALL, BUY ANTIDOTE, BUY REPEL, CONFIRM, LEAVE | two purchases to four; CONFIRM added |
| PC | **CONFIRM**, LEAVE | **CONFIRM added**: a list the fly opened is one it can answer rather than only close. Depositing and withdrawing are still not in the vocabulary (row 17) | | PC | **CONFIRM**, LEAVE | **CONFIRM added**: a list the fly opened is one it can answer rather than only close. Depositing and withdrawing are still not in the vocabulary (row 17) |
| Title | nothing | unchanged, by contract: the readout's boot variant applies | | Title | nothing | unchanged, by contract: the readout's boot variant applies |
@ -1120,6 +1770,7 @@ measured where it cannot.
| cause | closed by | | cause | closed by |
| --- | --- | | --- | --- |
| a dialog whose one answer the reopen ledger excludes | **not a cause** (12.12): the exclusion narrows a pad and never empties one, so a box with both answers excluded is dealt both again. A box nothing can answer is a screen nothing can leave |
| a scene that binds nothing at all | the table above: every playable scene but the overworld has at least one unconditional button (`NEXT` in a dialog and in a battle, `CLOSE` in a menu, `LEAVE` in a shop and a PC). The overworld's was `MENU`, and **12.11** took it off rather than keep a button whose only effect is a screen its own scene closes again; what stands in its place is the row below | | a scene that binds nothing at all | the table above: every playable scene but the overworld has at least one unconditional button (`NEXT` in a dialog and in a battle, `CLOSE` in a menu, `LEAVE` in a shop and a PC). The overworld's was `MENU`, and **12.11** took it off rather than keep a button whose only effect is a screen its own scene closes again; what stands in its place is the row below |
| an overworld map with no way out at all | **not closed, and named**: with `MENU` gone this is a genuinely empty pad. No map in Red is that -- an interior has its front door or its staircase, an outdoor map has its connections -- so it is asserted as a residual in the pad-empty sweep rather than covered, and `game.padEmptyMs` reports it | | an overworld map with no way out at all | **not closed, and named**: with `MENU` gone this is a genuinely empty pad. No map in Red is that -- an interior has its front door or its staircase, an outdoor map has its connections -- so it is asserted as a residual in the pad-empty sweep rather than covered, and `game.padEmptyMs` reports it |
| an *indoors* overworld where every ledger excludes everything and the blocked window is resting the one door | **fixed** (12.11): `ways`' last resort is a room's too, not only `GO ROUTE`'s. With nothing else on this map worth walking to, the exits of that kind come back ignoring the blocked window, the one toward the objective preferred. This is the rung-10 museum: its only way out is a *passage*, so tier 3 was built out of the excluded list and emptied with it | | an *indoors* overworld where every ledger excludes everything and the blocked window is resting the one door | **fixed** (12.11): `ways`' last resort is a room's too, not only `GO ROUTE`'s. With nothing else on this map worth walking to, the exits of that kind come back ignoring the blocked window, the one toward the objective preferred. This is the rung-10 museum: its only way out is a *passage*, so tier 3 was built out of the excluded list and emptied with it |
@ -1129,6 +1780,7 @@ measured where it cannot.
| a restore, before the first `observe` | not a cause: the loop calls `observe` once at the end of boot and once after a ratchet recovery, so the first frame is decided on a real palette | | a restore, before the first `observe` | not a cause: the loop calls `observe` once at the end of boot and once after a ratchet recovery, so the first frame is decided on a real palette |
| the title screen, and raw mode | not an empty pad by contract: the readout's boot variant applies and no palette is dealt | | the title screen, and raw mode | not an empty pad by contract: the readout's boot variant applies and no palette is dealt |
| a scene the detector cannot name | `Unknown` deals `NEXT` and `BACK`; a screen neither press leaves (the naming screen, row 14) is a genuine stall and still needs START, which is a contract change | | a scene the detector cannot name | `Unknown` deals `NEXT` and `BACK`; a screen neither press leaves (the naming screen, row 14) is a genuine stall and still needs START, which is a contract change |
| an `Unknown` frame that is the **overworld with the cartridge driving** -- a warp in flight, a scripted push-back, a guide walking the fly through a door | **empty on purpose** (12.13). There is no box to advance and no screen to leave, so an A or a B press is a press into somebody else's script: it changes nothing and completes where the fly stands. This is the one empty pad that ends itself -- the cartridge gives the buttons back within a few frames -- and `game.padEmptyMs` reports it like any other |
| the fly's own turn where the seam cannot read the battler, now that `NEXT` is off that row (12.10) | **fixed**: `MOVE 1` is bound over the top-level menu whatever `wBattleMon*` reads as, because FIGHT is one of that menu's four entries and always opens | | the fly's own turn where the seam cannot read the battler, now that `NEXT` is off that row (12.10) | **fixed**: `MOVE 1` is bound over the top-level menu whatever `wBattleMon*` reads as, because FIGHT is one of that menu's four entries and always opens |
And because "closed where a macro can close it" is not "closed": And because "closed where a macro can close it" is not "closed":

View file

@ -68,6 +68,15 @@ already created. Builders use separate worktrees; the coordinator reviews contra
- **Done:** empty required populations, malformed CSR and incorrect profile restores fail; - **Done:** empty required populations, malformed CSR and incorrect profile restores fail;
legacy FAFB artifacts and default numerical version strings remain unchanged. legacy FAFB artifacts and default numerical version strings remain unchanged.
**2026-09-23: split by operator decision.** PROF-02a, the legacy profile, is done as a contract:
[legacy Game Boy composition v1](session-framework/legacy-gameboy-v1.md) defines
`gameboy-legacy-fafb-v783-v1` (today's schema-1 fingerprint embedded, `lif-1ms-f64-v2` and
`fly-kc-mbon-rstdp-v2` unchanged, the macro-role exception declared), the readout context
`gameboy-readout-context-v1`, the decision `gameboy-channels-v1`, and a composition declaration
whose digest carries the decoder and macro-channel configuration instead of the legacy
compatibility string. PROF-02b -- the bundle manifests, role mapping, strict graph validation and
profile-mismatch fixtures above -- is later and gates DATA-01, not the session port.
### DATA-01 — Acquire and normalize MaleCNS ### DATA-01 — Acquire and normalize MaleCNS
- **Branch:** `feat/malecns-import` - **Branch:** `feat/malecns-import`
@ -108,6 +117,16 @@ already created. Builders use separate worktrees; the coordinator reviews contra
- **Done:** existing single-agent action/reward traces match and a fake environment can be - **Done:** existing single-agent action/reward traces match and a fake environment can be
driven through the same boundary without importing binjgb or task-specific addresses. driven through the same boundary without importing binjgb or task-specific addresses.
**2026-09-23: contract written (RT-01a), implementation pending.** The operator decided the
boundary: macros run in the coordinator's action executor over a per-boundary 64-KiB memory
image carried as an inspection artifact plus the ROM as an `AssetRef`; the emulator shim gains
one read-only bulk read and the joypad stays its only write; the Pokémon task and executor are
one object (`pokered-macros-v1`); the ratchet is the `legacy-ratchet-rollback-v1` episode policy
over the environment extension `gameboy-slots-v1`. The dated amendments are in
[workers-v1](session-framework/workers-v1.md), [step-v1](session-framework/step-v1.md) and
[state-media-v1](session-framework/state-media-v1.md); the session implementation guide's
ENV-01 carries the build.
### RUNTIME-02 — Extract the single-agent session ### RUNTIME-02 — Extract the single-agent session
- **Branch:** `refactor/session-runtime` - **Branch:** `refactor/session-runtime`

View file

@ -548,6 +548,14 @@ clock may lead environment time by warm-up; persist that offset instead of prete
clocks start at zero. Rendering, physics and decision cadence may differ, but the backend clocks start at zero. Rendering, physics and decision cadence may differ, but the backend
must define their relationship. must define their relationship.
**Amendment, 2026-09-23 (operator decision of 2026-09-23).** "Preserve the detailed legacy
ordering inside the legacy single-agent composition" now means inside a legacy composition that
runs on the session framework's `lockstep-v1`, not beside it: the operator decided on a full port.
The detailed ordering is preserved because it is already the lockstep transaction order with one
agent ([legacy-gameboy-v1](session-framework/legacy-gameboy-v1.md) section 4), and "keep the
legacy floating remainder arithmetic" costs nothing, because the legacy `f64` frame constant is
exactly the rational Game Boy frame and the two accumulators are identical (section 3 there).
Start with sequential agent evaluation for reproducibility. Then compare parallel agent Start with sequential agent evaluation for reproducibility. Then compare parallel agent
evaluation against the same action trace. Cap total worker budget: `agents × brain_threads` evaluation against the same action trace. Cap total worker budget: `agents × brain_threads`
can otherwise oversubscribe the machine. Use private pools for concurrent agents or serialize can otherwise oversubscribe the machine. Use private pools for concurrent agents or serialize
@ -605,7 +613,9 @@ jobs so repeated copies cannot exhaust memory under slow storage.
Exact replay requires action-executor and admission state, not just the neural envelope. Exact replay requires action-executor and admission state, not just the neural envelope.
Legacy macros intentionally discard transient execution on restart; preserve that behavior Legacy macros intentionally discard transient execution on restart; preserve that behavior
for v1 and label it as legacy continuation semantics, not exact session replay. New sessions for v1 and label it as legacy continuation semantics, not exact session replay.
(*2026-09-23:* the label is `restore: legacy-transient-reset`, declared by the legacy
composition, [legacy-gameboy-v1](session-framework/legacy-gameboy-v1.md) section 14.) New sessions
persist all behavior-affecting state or explicitly restart an episode under a documented rule. persist all behavior-affecting state or explicitly restart an episode under a documented rule.
Keep `FLYSIM01` readable through a legacy adapter. Never silently rewrite a checkpoint on Keep `FLYSIM01` readable through a legacy adapter. Never silently rewrite a checkpoint on

View file

@ -33,6 +33,27 @@ retroactively to existing [public feed](../../feed-protocol.md),
5. [Session media/state](state-media-v1.md) — observation timing and coherent recovery. 5. [Session media/state](state-media-v1.md) — observation timing and coherent recovery.
6. [Application/presentation boundary](publishing-v1.md) — snapshots, flexible data and effects. 6. [Application/presentation boundary](publishing-v1.md) — snapshots, flexible data and effects.
7. [Implementation guide](implementation.md) — sequenced build tasks and acceptance tests. 7. [Implementation guide](implementation.md) — sequenced build tasks and acceptance tests.
8. [Flybus conformance report](bus-conformance.md) — the `flybus` crate audited sentence by
sentence against bus-v1, with the test that proves each row, the measurements and the
draft's own contradictions. A review artifact, not a contract.
Two derived specifications, written by CONTRACT-01 because the slices that need them cannot
be built without them:
- [Seed derivation v1](seed-derivation-v1.md) — independent per-agent seeds from one recorded
master seed and stable agent ids, with test vectors in both languages.
- [Checkpoint envelope v1](checkpoint-envelope-v1.md) — the exact bytes of the new `FLYSESS1`
envelope and the durable commit sequence. `FLYSIM01` is unchanged and stays separately
readable.
One contract added by the 2026-09-23 amendments (PROF-02a, RT-01a), after the operator decided
to port the live fly onto this framework:
- [Legacy Game Boy composition v1](legacy-gameboy-v1.md) — the legacy profile
`gameboy-legacy-fafb-v783-v1`, its readout context and decision schemas, the memory-image
inspection, the `pokered-macros-v1` executor, the `gameboy-slots-v1` environment extension,
the `legacy-ratchet-rollback-v1` episode policy, `legacy-transient-reset` restore semantics and
the composition digest. MaleCNS bundles (PROF-02b) come later.
For context: [modular-session analysis](../malecns-modular-sessions.md) and For context: [modular-session analysis](../malecns-modular-sessions.md) and
[Melee audit](../melee-framework-audit.md). Each contract owns its named subject; step ordering [Melee audit](../melee-framework-audit.md). Each contract owns its named subject; step ordering
@ -114,6 +135,19 @@ versions, historical arithmetic/fingerprints and FLYSIM01 reader. New identities
readout/executor/task/scheduler semantics. New public feed v2 is an application/presentation readout/executor/task/scheduler semantics. New public feed v2 is an application/presentation
gateway contract built on the same internal bus; it does not replace the bus or expose it raw. gateway contract built on the same internal bus; it does not replace the bus or expose it raw.
**Amendment, 2026-09-23 (operator decision of 2026-09-23).** "Keep legacy-gameboy-v1 distinct
from lockstep-v1" is superseded for scheduling. The operator decided on a **full port** of the
live fly onto the session framework: the legacy composition runs under `lockstep-v1` with one
agent, one port and one world, declared as [legacy-gameboy-v1](legacy-gameboy-v1.md). What the
sentence protected is kept, and now written down rather than implied: the legacy frame order is
this framework's transaction order (legacy-gameboy-v1 section 4); the historical fingerprint,
the default version strings and the compatibility string are unchanged and embedded, not
recomputed; the legacy clock is proven identical to the rational one (section 3); `FLYSIM01`
stays the format of record and its reader untouched until RETIRE-01; and the legacy restore and
rollback semantics are declared (`legacy-transient-reset`, `legacy-ratchet-rollback-v1`) rather
than approximated. The new identity covers what the old string never did: readout context,
decision, decoder configuration and macro channels, executor, slots and restore semantics.
## 5. Reuse criterion ## 5. Reuse criterion
Adding a third environment/application requires a backend, task/profile, composition and Adding a third environment/application requires a backend, task/profile, composition and

View file

@ -0,0 +1,516 @@
# Flybus v1 conformance report: the `flybus` crate against `bus-v1`
Status: **audit, 2026-09-22**. Subject: `services/flysim/crates/flybus`, the replayed
implementation of [Flybus v1](bus-v1.md) (draft 1, 2026-09-18), with no consumers yet. Scalar
encodings come from [ipc-v1](ipc-v1.md) sections 1 to 3. Acceptance lists come from the
[implementation guide](implementation.md) slices BUS-01, BUS-02 and BUS-03.
Every normative sentence of bus-v1 sections 2 to 11 gets a row. Four statuses:
| Status | Meaning |
| --- | --- |
| conforms | The implementation does what the sentence requires, and a test proves it. |
| deviates-allowed | It differs, and a quoted sentence of the draft permits the difference. |
| deviates-must-fix | It differs and the draft requires otherwise. |
| not-implemented | Not built yet; the row names who owns it. |
Counts over 195 rows: **conforms 178, deviates-allowed 9, deviates-must-fix 1 (fixed),
not-implemented 7**. The audit found the one deviates-must-fix — connection teardown could be
starved for the length of a whole frame by the writer it was waiting for — and it is fixed on
this branch, so the row for it now reads conforms and records the fix (section 9, "cannot be
starved"). Two contradictions inside the draft are recorded at the end and left alone.
Test names below are the functions in `services/flysim/crates/flybus/tests`, 239 of them in
this branch (`cargo test -p flybus`), plus the ignored measurement. Everything marked
"(both)" is generated twice by the `both_transports!` macro, once over the in-memory transport
and once over a Unix socket, so `tests/rpc.rs::request_reply_roundtrip` means
`in_memory::request_reply_roundtrip` and `unix_socket::request_reply_roundtrip`.
## 2. Client API
| Requirement | Status | Code | Test |
| --- | --- | --- | --- |
| One client/connection serves RPC, pub/sub and artifacts | conforms | `client/mod.rs::Client` | `tests/integration.rs::session_over_one_router` (both) |
| The illustrative surface (`connect`, `register`, `call`, `subscribe`, `publish`, `artifacts().allocate`, `seal`, `message.artifact`) | deviates-allowed: "Illustrative Rust surface (not yet implemented)"; `connect` takes the transport, `call` takes no `budget`, `next()` yields `Option` | `client/mod.rs`, `client/handles.rs` | `tests/rpc.rs`, `tests/pubsub.rs`, `tests/artifacts.rs` |
| The artifact store is a storage backend of the bus, not another messaging service | conforms | `store.rs`, reached only through `Artifacts`/`Artifact` | `tests/artifacts.rs::allocate_write_seal_read` (both) |
| Bulk data does not pass through router socket payloads; no separate data-transfer API | conforms: attachments carry `ArtifactRef` only; envelopes are capped at 64 KiB | `wire.rs::ArtifactRef`, `wire.rs::MAX_ENVELOPE_BYTES` | `tests/wire.rs::envelope_size_limits` (both), `tests/perf.rs` |
| `Artifact` is a read-only, cloneable handle | conforms | `client/handles.rs::Artifact` (no write API, `#[derive(Clone)]`) | `tests/conformance_artifacts.rs::extracted_artifact_outlives_the_message_it_came_from` (both) |
| `ArtifactWriter` is unique, not cloneable; sealing consumes its writable lifetime | conforms | `client/handles.rs::ArtifactWriter::seal_with_digest(mut self)` | `tests/artifacts.rs::seal_is_immune_to_live_writable_handles` (both) |
| Mapped slices cannot outlive their handle | conforms by construction: there is no mapping API; `ArtifactFile` owns its `Artifact` | `client/handles.rs::ArtifactFile` | `tests/bus_acceptance.rs::disconnect_releases_logical_ownership_without_mutating_open_bytes` (both) |
| Rust RAII automates releases | conforms | `client/handles.rs::OwnerGuard::drop` | `tests/artifacts.rs::fan_out_shares_one_object_and_the_last_consumer_collects` (both) |
| Other language bindings provide equivalent explicit close/context-manager behaviour | not-implemented (Rust only; section 1 says a binding "may" exist). Owner: a future binding | — | — |
| Garbage collection means reclaiming an unowned artifact, not inspecting game state | conforms | `router/state.rs::drop_roots` | `tests/conformance_artifacts.rs::collection_waits_for_every_retained_owner` (both) |
## 3. Addressing and identities
| Requirement | Status | Code | Test |
| --- | --- | --- | --- |
| Every identity of the table exists: `routerId`, `clientId`/`clientIncarnation`, `connectionId`, `service`/`serviceIncarnation`, `callId`, `topic`/`topicIncarnation`/`topicSequence`, `deliveryId`, `artifactId`/`generation`, `ownerId` | conforms | `router/mod.rs::fresh_tag`, `router/state.rs` (`serial_id` for `conn`/`svc`/`top`/`sub`/`dlv`/`own`/`a`) | `tests/conformance_wire.rs::hello_reports_the_contract_digest_and_valid_limits` (both), `tests/rpc.rs::registration_is_exclusive_and_pinned` (both) |
| A fresh `routerId`/`storeId` per incarnation; old handles fail after restart | conforms | `router/mod.rs::Router::new` | `tests/artifacts.rs::router_restart_invalidates_old_handles` (both) |
| Reconnect creates a new `clientIncarnation`; v1 does not resume a connection's queues or delivery owners | conforms | `router/state.rs::hello` refuses a reused incarnation; `disconnect` releases everything | `tests/sol_review_races.rs::caller_disconnect_cleanup_works_before_and_after_consumption` |
| Identifiers are bounded ASCII; `Id` and `U64` match ipc-v1 | conforms: `^[a-z0-9][a-z0-9._-]{0,63}$`, `"0"\|[1-9][0-9]*` | `wire.rs::is_id`, `wire.rs::parse_u64` | `wire.rs::tests::scalars`, `tests/conformance_wire.rs::malformed_envelope_scalars_are_rejected` (both) |
| Service/topic names are 1..192 of `[a-z0-9._-]` with no empty dot-separated segment | conforms | `wire.rs::is_name` | `wire.rs::tests::scalars` |
| Exact names only; wildcard routing and queue groups deferred | conforms: routing is `HashMap` lookup by exact name. `Pattern::Prefix` is a launcher grant, never a route | `router/state.rs::services`/`topics`, `policy.rs::Pattern` | `tests/pubsub.rs::subscription_and_topic_validation` (both), `tests/rpc.rs::authority_is_enforced` (both) |
| One live registration owns a service name; duplicate registration fails; no implicit round-robin or replacement | conforms (`CONFLICT`) | `router/state.rs::op_register` | `tests/rpc.rs::registration_is_exclusive_and_pinned` (both), `tests/conformance_routing.rs::duplicate_registration_by_owner_itself_is_rejected` (both) |
| Registration returns the incarnation; callers pin it; a change fails with `TARGET_CHANGED` | conforms | `router/state.rs::op_call` | `tests/rpc.rs::registration_is_exclusive_and_pinned` (both), `tests/bus_acceptance.rs::no_automatic_retry_or_failover_onto_a_replacement_registration` (both) |
| An unpinned call reaches whoever holds the name now | conforms | `router/state.rs::op_call` (`expectedIncarnation: null`) | `tests/conformance_routing.rs::unpinned_call_after_incarnation_replacement_reaches_the_new_holder` (both) |
| `Worker.Hello` stays a domain RPC, distinct from transport negotiation | conforms by absence: `bus.hello` carries no role, capability or session field | `router/state.rs::hello` | `tests/wire.rs::hello_negotiation` (both) |
| Service/topic access is configured per participant by the launcher; naming a target is not authority | conforms | `policy.rs::{Policy, Grants}`, checked in every `op_*` | `tests/rpc.rs::authority_is_enforced` (both), `tests/pubsub.rs::subscription_and_topic_validation` (both) |
| Presentation subscribes without gaining authority to invoke Advance | conforms: `subscribe` and `call` are separate grants | `policy.rs::Grants` | `tests/rpc.rs::authority_is_enforced` (both) |
| One live connection per client id, and a client id's last incarnation may not be reused | deviates-allowed (narrowing): section 3 makes `callId` "unique ... for this client incarnation" and section 6 keeps serials "per connected client", which two live connections for one id would make ambiguous. Cost: one small record per client id ever seen | `router/state.rs::{ClientRecord, hello}` | `tests/sol_review_races.rs::pending_connections_are_bounded_and_hello_expires` |
## 4. Wire envelope and framing
| Requirement | Status | Code | Test |
| --- | --- | --- | --- |
| Framing is `u32` little-endian JSON length, then UTF-8 JSON | conforms | `wire.rs::{read_frame, write_frame}` | `tests/conformance_wire.rs::length_prefix_is_little_endian` (both) |
| Envelope fields exactly `protocol`/`major`/`minor`/`id`/`replyTo`/`kind`/`op`/`body`/`attachments` | conforms | `wire.rs::Envelope::{decode, to_value}` | `tests/conformance_wire.rs::unknown_top_level_field_closes_the_connection` (both) |
| `ArtifactRef` fields `storeId`/`artifactId`/`generation`/`byteLength`/`contentType`/`digest` | conforms (`generation`/`byteLength` as U64 strings) | `wire.rs::ArtifactRef` | `tests/conformance_artifacts.rs::stale_store_incarnation_and_generation_are_rejected` (both) |
| `Attachment` is `{name, ref, ownerId}` | conforms | `wire.rs::Attachment` | `tests/conformance_artifacts.rs::allocate_write_seal_open_roundtrip_and_mismatches` (both) |
| Maximum total JSON envelope 65,536 bytes | conforms | `wire.rs::MAX_ENVELOPE_BYTES`, checked on decode and encode | `tests/conformance_wire.rs::frame_at_the_size_ceiling_is_accepted_one_byte_over_is_not` (both) |
| A delivery the router would build over the limit is refused at admission | conforms, and required by "Message length includes this wrapper" (section 5): admission sizes the delivery with the longest ids | `router/state.rs::frame_len` in `op_call`/`op_reply`/`op_publish` | `tests/sol_review_races.rs::unsent_oversized_call_rolls_back_its_local_slot`, `tests/wire.rs::envelope_size_limits` (both) |
| Up to 32 attachments, unique names; `contentType` nonempty ASCII <=127 | conforms | `wire.rs::{MAX_ATTACHMENTS, is_content_type}`, `Envelope::decode` | `tests/conformance_wire.rs::malformed_envelope_scalars_are_rejected` (both), `tests/artifacts.rs::quotas_are_enforced` (both) |
| No pixel/base64/checkpoint bytes in JSON; artifact sizes are independent of envelope size | conforms: bulk bytes only reach the store; `byteLength` is a string in the reference | `store.rs`, `wire.rs::ArtifactRef` | `tests/perf.rs` (1.2 MB frames, envelopes under 1 KiB) |
| Domain schemas enumerate every referenced artifact; generated bindings enforce it | not-implemented for domain schemas (the bus enforces its own attachment list). Owner: CONTRACT-01 / `fly-session-types` | `router/state.rs::check_attachments` enforces the bus half | `tests/conformance_artifacts.rs::forward_requires_the_source_owner_still_live` (both) |
| The router validates attachment declarations/ownership, not domain payload contents | conforms | `router/state.rs::{check_owned, check_attachments}`; `payload`/`outcome` stay opaque | `tests/conformance_artifacts.rs::owner_ids_are_scoped_to_their_connection` (both) |
| Commands have unique monotonically issued `msg-<U64>` ids per connection | conforms (strictly increasing) | `router/state.rs::handle` | `tests/conformance_wire.rs::{non_canonical_command_ids_are_rejected, non_increasing_command_ids_are_rejected}` (both) |
| Replies correlate with `replyTo`; notices and deliveries carry router-generated ids | conforms | `router/state.rs::{reply, outbound}` | `tests/conformance_wire.rs::non_null_reply_to_on_a_command_is_rejected` (both) |
| The router supplies authenticated sender/target metadata; senders cannot forge it in `body` | conforms on launcher-bound transports; the sender's `body` never supplies identity | `router/state.rs::{identity, Call::request_body}` | `tests/rpc.rs::raw_call_ids_and_forged_replies` (both), `tests/sol_review_races.rs::responder_survives_cancel_then_request_drop` |
| Identity is bound out of band before Hello; a mismatching Hello is refused before registration | conforms | `router/mod.rs::{serve_as, listen_unix_as}`, `state.rs::hello` | `tests/wire.rs::hello_refusals` (both) |
| Open/unbound transports are self-asserted, not authentication | deviates-allowed (explicit narrowing): section 1's "one trusted local deployment" and section 4's "The launcher provides expected client/registration privileges". `Policy::open()` is documented as test/trusted-only | `policy.rs::permits_unbound_transport`, `router/state.rs::add_conn` | `tests/sol_review_races.rs::pending_connections_are_bounded_and_hello_expires` |
| Reject duplicate JSON keys, invalid UTF-8, NaN/Infinity, unknown envelope fields, zero/oversize frames, invalid ranges | conforms | `wire.rs::{parse_json_strict, StrictVisitor, Fields::finish}`, `read_frame` | `tests/conformance_wire.rs::{duplicate_json_keys_are_rejected_at_every_depth, invalid_utf8_is_rejected, zero_length_frame_closes_the_connection, oversize_length_prefix_is_rejected_before_reading_body}` (both) |
| Read length before allocating | conforms | `wire.rs::read_frame` checks the prefix before `vec![0u8; len]` | `tests/conformance_wire.rs::oversize_length_prefix_is_rejected_before_reading_body` (both) |
| Handle partial reads/writes; serialise one writer per connection | conforms | `wire.rs::read_frame`, `router/mod.rs::{write_selected, write_loop}` (one writer task) | `tests/conformance_wire.rs::{a_frame_written_one_byte_at_a_time_still_decodes, a_reply_read_one_byte_at_a_time_still_decodes, truncated_frame_disconnects_cleanly}` (both) |
| No ancillary-FD tricks in the first file-backed implementation | conforms | `transport.rs` carries bytes only | — |
| A future memory backend keeps the same client/ownership API | not-implemented (future). Owner: a later storage backend | — | — |
| `bus.hello` body `{clientId, clientIncarnation, supportedMajors}`, no attachments; reply `{routerId, connectionId, selectedMajor, selectedMinor, contractDigest, limits}` | conforms | `router/state.rs::hello`, `limits.rs::Limits::to_json` | `tests/wire.rs::hello_negotiation` (both), `tests/conformance_wire.rs::{hello_refuses_attachments, hello_reports_the_contract_digest_and_valid_limits}` (both) |
| Refuse incompatible majors or identity mismatch before registration | conforms (`VERSION_MISMATCH`, `NOT_AUTHORIZED`) | `router/state.rs::hello` | `tests/conformance_wire.rs::hello_refuses_an_unsupported_major` (both), `tests/wire.rs::hello_refusals` (both) |
| Schema changes change `contractDigest` | conforms: the digest is the SHA-256 of `wire::CONTRACT`, which lists every operation, delivery and notice; the client refuses a router whose digest differs | `wire.rs::{CONTRACT, contract_digest}`, `client/mod.rs::connect` | `tests/conformance_wire.rs::memory_and_unix_negotiate_the_identical_contract` |
| Bodies reject unknown fields and `minor` must be 0 after hello | deviates-allowed (stricter than "unknown envelope fields", forbidden nowhere; section 4's envelope fixes `minor: 0`) | `wire.rs::Fields::finish`, `router/state.rs::handle` | `tests/wire.rs::body_errors_keep_the_connection` (both), `tests/sol_review_races.rs::client_rejects_unknown_reply_fields_and_invalid_direction_rules` |
| The connection reader dispatches replies and requests without blocking on user handlers | conforms: the reader hands `Request`/`Message` to unbounded per-handle channels and returns | `client/reactor.rs::{read_loop, on_delivery}` | `tests/pubsub.rs::saturated_subscriber_does_not_block_control` (both) |
| Artifact I/O and hashing run outside the routing critical section; no routing lock across slow I/O | conforms: `Outcome::Allocate`/`Seal` leave the lock, run on the blocking pool and re-enter; unlinks happen after the guard drops | `router/mod.rs::{read_loop, Inner::with_state}`, `store.rs::seal` | `tests/artifacts.rs::quotas_are_enforced` (both), `tests/perf.rs` (seal p50 1.3 ms, publish admission p50 0.4 ms) |
## 5. Operation registry
| Requirement | Status | Code | Test |
| --- | --- | --- | --- |
| Replies are `{ok:true, value}` or `{ok:false, error:{code, message, dispatch}}` | conforms | `router/state.rs::reply_body`, `client/reactor.rs::parse_reply` | `tests/wire.rs::body_errors_keep_the_connection` (both) |
| All 19 commands of the table exist with the listed bodies and reply values | conforms | `router/state.rs::handle` dispatch table; `wire.rs::CONTRACT` | `tests/conformance_wire.rs` + `tests/{rpc,pubsub,artifacts}.rs` (both) |
| `rpc.responder.release {callId, requestDeliveryId} -> {released}` is added to the registry | deviates-allowed: section 4's "Changes to these draft schemas change contractDigest" (this is a draft), and it is what keeps section 6's "bounded call correlation metadata" bounded when a handler keeps a responder after dropping the request. Recorded as an amendment in bus-v1 section 12 | `router/state.rs::op_responder_release`, `client/handles.rs::ReplyGuard` | `tests/sol_rereview_regressions.rs::{dropping_last_attached_responder_settles_the_call, dropping_last_responder_clone_settles_the_call}` |
| Release batches carry 1..64 ids | conforms | `wire.rs::MAX_BATCH`, `Fields::array` | `tests/artifacts.rs::release_ids_are_watermarked` (both) |
| No attachments on management commands except `rpc.call`, `rpc.reply`, `publish` | conforms | `router/state.rs::handle` | `tests/wire.rs::body_errors_keep_the_connection` (both) |
| `accepted`/`routed`/`removed`/`declared`/`cleared`/`deleted`/`replayLatest` are booleans; `subscribers`/`replaced`/`released` are U64 counts | conforms | `router/state.rs` (`.into()` for bools, `to_string()` for counts) | `tests/pubsub.rs::bounded_fifo_and_atomic_backpressure` (both), `client/reactor.rs::validate_reply_value` |
| Released counts count newly released roots, so an idempotent repeat may report zero | conforms | `router/state.rs::{op_consumed, op_release}` | `tests/artifacts.rs::release_ids_are_watermarked` (both) |
| Queue/credit requests are integers 1..65535 and cannot exceed configured limits | conforms | `wire.rs::MAX_CREDIT`, `op_register`/`op_subscribe` quota checks | `tests/pubsub.rs::subscription_and_topic_validation` (both), `tests/rpc.rs::service_queue_backpressure` (both) |
| Method strings are 1..128 printable ASCII | conforms | `wire.rs::is_method` | `wire.rs::tests::scalars` |
| The call target is a service name; `expectedIncarnation` is the registration id | conforms | `router/state.rs::op_call` (`f.name`, `f.nullable_id`) | `tests/rpc.rs::registration_is_exclusive_and_pinned` (both) |
| Location grants are `{storeId, relativePath}` resolved under the configured root; absolute paths, parent traversal and symlink escapes are rejected | conforms | `store.rs::resolve`, opened `O_NOFOLLOW` | `store.rs::tests::resolve_refuses_escapes`, `tests/conformance_artifacts.rs::issued_locations_are_relative_and_contained` (both) |
| Locations are SDK-private and do not appear in an application's `ArtifactRef`; runtime paths are not committed into schemas | conforms: `Location` only ever appears in `artifact.allocate`/`artifact.open` replies | `wire.rs::Location`, `client/handles.rs::Artifact::open` | `tests/conformance_artifacts.rs::issued_locations_are_relative_and_contained` (both) |
| Deliveries carry exactly the listed bodies (`rpc.request`, `rpc.result`, `topic.message`) | conforms | `router/state.rs::{Call::request_body, Call::result_body, TopicMsg::body}` | `tests/conformance_wire.rs` + `client/reactor.rs::on_delivery` strict parse |
| `caller`/`responder` include clientId and clientIncarnation | conforms | `wire.rs::Identity` | `tests/rpc.rs::request_reply_roundtrip` (both) |
| Delivery attachment ownerIds are replaced by the recipient's deliveryId; source tokens are never delegated | conforms, and the client refuses a delivery whose attachment owner is not its delivery | `router/state.rs::attachments_with_owner`, `client/reactor.rs::attachments` | `tests/conformance_artifacts.rs::owner_ids_are_scoped_to_their_connection` (both) |
| `topicSequence` and counters are U64 strings; the router assigns the ids; the SDK exposes typed payloads plus handles | conforms | `router/state.rs::TopicMsg::body`, `client/handles.rs::Message` | `tests/pubsub.rs::retained_replay_clear_delete_and_incarnations` (both) |
| Required bounded notices: route removal, subscription closure, call failure | conforms | `router/state.rs::{remove_service, shutdown, op_responder_release}` | `tests/pubsub.rs::router_shutdown_closes_subscriptions_with_notices` (both), `tests/rpc.rs::service_disconnect_fails_calls` (both) |
| Who gets those notices, and when | deviates-allowed: the draft names the notices but not their audience. `route.removed` goes to callers with open calls on the removed registration, `subscription.closed` only at router shutdown (nothing else ends a subscription without the client's own act), and `connection.closing` is an added notice before every router-initiated close | `router/state.rs::{remove_service, shutdown, violation}` | `tests/wire.rs::malformed_frames_close_the_connection` (both) |
| If notice capacity is exhausted, close the connection rather than lose control-plane correctness | conforms | `router/state.rs::push_control` | `tests/wire.rs::control_lane_exhaustion_closes` (both) |
## 6. RPC behaviour
| Requirement | Status | Code | Test |
| --- | --- | --- | --- |
| `call-<U64>` with increasing serials per connected client; reused or retired ids are rejected, never executed again | conforms: a syntactically valid id advances the watermark even when admission is refused | `router/state.rs::op_call` (`call_watermark`) | `tests/sol_review_races.rs::rejected_call_id_still_advances_monotonic_watermark`, `tests/rpc.rs::raw_call_ids_and_forged_replies` (both) |
| Reconnecting creates a new incarnation rather than reviving old calls | conforms | `router/state.rs::{hello, disconnect}` | `tests/conformance_routing.rs::unpinned_call_after_incarnation_replacement_reaches_the_new_holder` (both), `tests/sol_review_races.rs::caller_disconnect_cleanup_works_before_and_after_consumption` for the old calls half (its synchronisation was fixed on 2026-09-22; see "A flaky test and what it was measuring") |
| An RPC targets one registered service, not a broadcast subject | conforms | `router/state.rs::op_call` | `tests/rpc.rs::request_reply_roundtrip` (both) |
| First-dispatch FIFO per caller and service; responses may complete out of order and correlate by callId | conforms | `router/state.rs::{Svc::queue, dispatch_rpc}` | `tests/rpc.rs::fifo_dispatch_and_out_of_order_completion` (both), `tests/conformance_routing.rs::out_of_order_replies_correlate_across_concurrent_callers` (both) |
| A service dispatcher can answer status concurrently with a long mutation | conforms | `router/state.rs::dispatch_rpc` (in-flight credits, not one-at-a-time) | `tests/bus_acceptance.rs::a_status_rpc_responds_while_another_handler_is_delayed` (both) |
| The router implements no frame barriers or numerical ordering | conforms by absence | `router/state.rs` (no domain fields) | `tests/integration.rs::session_over_one_router` (both) |
| Admission validates route, pinned incarnation, size, quotas and every source artifact owner, and establishes request-delivery roots atomically before accepting | conforms: every check precedes the first mutation, then roots, queue entry and reply | `router/state.rs::op_call` | `tests/conformance_artifacts.rs::rejected_call_creates_no_roots` (both), `tests/artifacts.rs::failed_admission_is_atomic` (both) |
| Rejection establishes no delivery and drops provisional roots | conforms | `router/state.rs::op_call` (validate-then-mutate) | `tests/conformance_artifacts.rs::rejected_call_creates_no_roots` (both) |
| An accepted call is not proof that its handler ran | conforms: `accepted` is admission only; the terminal outcome arrives as `rpc.result` or `call.failed` | `client/mod.rs::call`, `client/handles.rs::PendingCall` | `tests/rpc.rs::service_disconnect_fails_calls` (both) |
| Mark dispatched before any request bytes can reach the target; later transport loss is an unknown outcome | conforms: `Phase::Dispatched` and the delivery id are set when the frame is selected, before a byte is written | `router/state.rs::{next_frame, dispatch_rpc}` | `tests/sol_rereview_regressions.rs::shutdown_cancels_partial_rpc_request_before_reclaiming_attachment` |
| The service replies with its own owned handles; the router establishes caller-result ownership before accepting the reply | conforms | `router/state.rs::op_reply` (`check_attachments`, then `add_roots`, then the phase change) | `tests/rpc.rs::endpoint_cache_replays_artifact_results` (both) |
| Only bounded call correlation metadata is kept until the result is consumed or the caller detaches; not an indefinite result cache | conforms: the record dies with consumption, detachment, disconnect or the last responder release; reply capabilities share the per-client owner bound | `router/state.rs::{remove_call, dispatch_rpc, op_responder_release}` | `tests/sol_review_races.rs::{cancel_after_request_consumption_retires_correlation, dropping_service_with_buffered_requests_retires_every_call}` |
| A second `rpc.reply` for the same call is rejected, not routed twice | conforms (`CALL_GONE`) | `router/state.rs::op_reply` | `tests/rpc.rs::replies_are_single_and_independent_of_the_request_guard` (both) |
| Responding does not release the request's delivery guard | conforms | `client/handles.rs::{Request, Responder}` (separate guards) | `tests/rpc.rs::replies_are_single_and_independent_of_the_request_guard` (both) |
| No automatic retry or failover; never route a retry automatically to a restarted worker | conforms | `router/state.rs::{remove_service, disconnect}` fail calls instead of re-queueing | `tests/bus_acceptance.rs::no_automatic_retry_or_failover_onto_a_replacement_registration` (both) |
| A deadline belongs to the calling client; on timeout it may cancel | conforms: no budget on the wire; `result()` is cancel-safe under `tokio::time::timeout` | `client/handles.rs::PendingCall::{result, cancel}` | `tests/rpc.rs::cancellation_states` (both) |
| Domain retries use a fresh callId with the same domain requestId/body, pinned to the same incarnation; endpoint dedup supplies safe replay; the router does not infer it from method names | conforms | `client/mod.rs::call`; the router carries `payload` opaquely | `tests/bus_acceptance.rs::a_retransmission_repeats_the_domain_request_under_a_fresh_call_id` (both), `tests/rpc.rs::endpoint_cache_replays_artifact_results` (both) |
| Queued cancellation releases its queued artifact roots and returns `cancelled-before-dispatch` | conforms | `router/state.rs::op_cancel` -> `remove_call` -> `drop_roots` | `tests/conformance_routing.rs::cancel_before_dispatch_releases_queued_artifact_roots` (both) |
| After dispatch, return `execution-unknown` and keep the recipient's delivery alive until consumed or disconnected | conforms | `router/state.rs::op_cancel` (`detached = true`, delivery untouched) | `tests/rpc.rs::cancellation_states` (both), `tests/conformance_routing.rs::caller_disconnect_detaches_dispatched_call_but_service_keeps_serving` (both) |
| A later reply to a detached call returns `routed:false` with no caller-result roots; the service still owns any retained result | conforms | `router/state.rs::op_reply` (`call.detached` branch, before `add_roots`) | `tests/conformance_routing.rs::cancel_after_dispatch_then_late_reply_with_artifact_is_not_routed` (both) |
| A terminal result already admitted makes cancellation report `completed`; the client drains and consumes it | conforms | `router/state.rs::op_cancel` (`Phase::Replied`), `client/reactor.rs::on_delivery` consumes an abandoned result | `tests/rpc.rs::{cancellation_states, dropped_call_is_cancelled_and_late_result_consumed}` (both) |
| A retired or unknown correlation reports `call-gone`; those four strings are the complete enum | conforms | `router/state.rs::op_cancel`, `client/handles.rs::CancelState` | `tests/rpc.rs::cancellation_states` (both), `client/reactor.rs::validate_reply_value` |
| No cancel state authorises re-execution; cancelling a future does not abandon incoming delivery ownership | conforms | `client/handles.rs::CallGuard::drop` (best-effort cancel, result still consumed) | `tests/rpc.rs::dropped_call_is_cancelled_and_late_result_consumed` (both), `tests/sol_review_races.rs::reply_racing_cancel_has_only_the_two_contract_outcomes` |
| Endpoint replay caches Artifact handles plus payload, not bare references, and owns holds until eviction | conforms (SDK support; the discipline is the endpoint's) | `client/handles.rs::Artifact::retain`, `ArtifactWriter::seal` returns a hold | `tests/rpc.rs::endpoint_cache_replays_artifact_results` (both), `tests/bus_acceptance.rs::a_lost_result_leaks_no_roots_and_the_endpoint_cache_still_replays` (both) |
| Re-delivery gets new delivery ids pointing to the same immutable bytes | conforms | `router/state.rs::dispatch_rpc` (fresh `dlv-<n>` per delivery) | `tests/rpc.rs::endpoint_cache_replays_artifact_results` (both) |
| An expired domain cache returns `RESULT_EXPIRED` | not-implemented: a domain code, not a transport code. Owner: `fly-session-rpc` (ipc-v1) | — | — |
## 7. Pub/sub semantics
| Requirement | Status | Code | Test |
| --- | --- | --- | --- |
| Topic declaration teaches the router nothing about meaning; no hardcoded frame/brain topics | conforms | `router/state.rs::{Topic, op_declare}` | `tests/pubsub.rs::retained_replay_clear_delete_and_incarnations` (both) |
| `latest`: one queued value, replacing only an undelivered one; replacement releases that entry's roots; delivered or in-use messages are never reclaimed early; maxQueued is exactly 1 | conforms | `router/state.rs::{op_publish (latest branch), op_subscribe}` | `tests/pubsub.rs::latest_coalesces_only_undelivered_values` (both), `tests/conformance_artifacts.rs::latest_mode_holds_at_most_two_roots_delivered_plus_queued` (both) |
| `bounded`: FIFO, no coalescing or silent loss; when capacity is unavailable, reject with `BACKPRESSURE` before admitting any delivery | conforms | `router/state.rs::op_publish` (pre-checks every subscriber) | `tests/pubsub.rs::bounded_fifo_and_atomic_backpressure` (both), `tests/conformance_routing.rs::bounded_overflow_rolls_back_all_artifact_roots` (both) |
| maxInFlight credits return only on `delivery.consumed`, not on socket write completion | conforms | `router/state.rs::release_owner` (credit returned when the owner is released) | `tests/pubsub.rs::credits_return_only_on_consume` (both), `tests/conformance_routing.rs::bounded_credit_waits_for_every_extracted_artifact` (both) |
| A latest subscriber with all credits in use still has one replaceable queued value | conforms | `router/state.rs::{Sub::queue, dispatch_topic}` (queue and credits are separate) | `tests/bus_acceptance.rs::both_transports_produce_equivalent_behaviour_traces` (events 21 and 24: `publish replaced=1`, then `latest seq=3 replaced=1`), `tests/integration.rs::session_over_one_router` (both: a renderer held for the whole run keeps one delivery in flight and one replaceable value, and receives snapshots 1 and 20 of 20) |
| Atomic subscriber/retention snapshot at admission; validate and reserve every queue entry and owner budget before accepting | conforms: one mutex, validate-then-mutate | `router/state.rs::op_publish` | `tests/artifacts.rs::failed_admission_is_atomic` (both) |
| A bounded overflow rejects the whole publish: no partial fan-out, no retained-latest update | conforms | `router/state.rs::op_publish` | `tests/conformance_routing.rs::bounded_overflow_rolls_back_all_artifact_roots` (both) |
| On acceptance, one `topicSequence` and roots for every delivery and the optional retained value | conforms; a refused publication spends no sequence number | `router/state.rs::op_publish` (`t.sequence += 1` after the checks) | `tests/pubsub.rs::bounded_fifo_and_atomic_backpressure` (both) |
| Different topics have no total ordering; multiple publishers follow router acceptance order | conforms: per-topic sequence only | `router/state.rs::Topic::sequence` | `tests/pubsub.rs::retained_replay_clear_delete_and_incarnations` (both) |
| The publication reply counts accepted subscriptions and replaced queue entries, not consumers that processed data | conforms | `router/state.rs::op_publish` reply | `tests/pubsub.rs::latest_coalesces_only_undelivered_values` (both) |
| `replaced` on a delivery reports how many undelivered messages were coalesced since that subscription's preceding delivery | conforms | `router/state.rs::{Sub::replaced, dispatch_topic}` (taken at dispatch) | `tests/pubsub.rs::latest_coalesces_only_undelivered_values` (both), `tests/integration.rs::session_over_one_router` (both: the 18 replacements the publisher was told about at admission are the same 18 the renderer is told about on delivery, and are exactly the snapshots it did not receive) |
| Optional `retained:latest` holds one last message and its artifacts independent of subscribers | conforms | `router/state.rs::op_publish` (retain branch) | `tests/conformance_artifacts.rs::retained_topic_value_holds_a_root_independent_of_subscribers` (both) |
| `replayLatest` enqueues the retained value before subsequent accepted publications; bounded preserves the order, latest may coalesce it | conforms | `router/state.rs::op_subscribe` (replay is enqueued under the subscribe lock) | `tests/conformance_routing.rs::latest_replay_is_ordered_ahead_of_a_racing_publish` (both: one racing publication to a bounded and a latest subscription at once; bounded must deliver replay then publication, and the latest branch is chosen by that publication's own `replaced` count, never by which side of the race the dispatcher won), `tests/pubsub.rs::retained_replay_clear_delete_and_incarnations` (both) |
| Replay uses the original topicSequence, a fresh deliveryId and explicit roots | conforms | `router/state.rs::op_subscribe` (`add_roots`, the same `Arc<TopicMsg>`) | `tests/pubsub.rs::retained_replay_clear_delete_and_incarnations` (both) |
| Without retention, a zero-subscriber publication retains no ownership after admission | conforms | `router/state.rs::op_publish` | `tests/pubsub.rs::zero_subscriber_publish_retains_nothing` (both) |
| Clearing a topic releases only its retained root, not active consumers | conforms | `router/state.rs::op_clear` | `tests/conformance_routing.rs::cleared_topic_gives_no_replay_until_a_fresh_publish` (both) |
| Topic count and retained bytes are capped | conforms; `max_retained_bytes` is added to the draft's table because this sentence requires it | `limits.rs::{max_topics, max_retained_bytes}`, `router/state.rs::{op_declare, op_publish}` | `tests/pubsub.rs::topic_and_retention_quotas` (both) |
| No durable replay, automatic redelivery or exactly-once claim | conforms by absence | `router/state.rs` (queues are in memory and die with the connection) | `tests/artifacts.rs::router_restart_invalidates_old_handles` (both) |
| Deleting or redeclaring a topic creates a fresh topicIncarnation; a reset sequence cannot be read as continuation | conforms | `router/state.rs::{op_delete, op_declare}` | `tests/pubsub.rs::retained_replay_clear_delete_and_incarnations` (both) |
| Old subscription deliveries keep their original incarnation and ownership until consumed | conforms | `router/state.rs::drop_subscription` (matches on the incarnation), delivery bodies carry it | `tests/pubsub.rs::unsubscribe_discards_queue_but_not_deliveries` (both) |
| `topic.delete` only with no subscribers | conforms (`CONFLICT`) | `router/state.rs::op_delete` | `tests/pubsub.rs::subscription_and_topic_validation` (both) |
| Bus admission, message consumption and durable storage acknowledgment are three different events | conforms: `publish` returns admission counts, `delivery.consumed` is separate, and there is no storage ack in the bus | `router/state.rs::{op_publish, op_consumed}` | `tests/pubsub.rs::credits_return_only_on_consume` (both) |
| A topic must be declared before publish or subscribe | deviates-allowed: the draft is silent on undeclared topics, while "Topic count and retained bytes are capped" and `topic.declare`'s "conflicting settings fail" both imply a registry a publication cannot create by accident. `NO_TOPIC` names the refusal (amendment, section 12); `topic.delete` of an unknown topic still answers `deleted:false` | `router/state.rs::{op_publish, op_subscribe, op_clear}` | `tests/pubsub.rs::subscription_and_topic_validation` (both) |
## 8. Artifact lifecycle and garbage collection
### 8.1 Immutable object lifecycle
| Requirement | Status | Code | Test |
| --- | --- | --- | --- |
| `ALLOCATED/WRITING -> SEALED -> owned -> COLLECTED`, and an abandoned or disconnected writer goes straight to COLLECTED | conforms | `router/state.rs::{ArtState, abandon_writer, finish_seal}` | `tests/artifacts.rs::{writer_drop_releases_staging, disconnect_releases_all_but_retained}` (both), `tests/conformance_artifacts.rs::disconnect_abandons_an_unsealed_writer` (both) |
| `ArtifactRef` is an identity, not an address or authority; opening needs a current root on that connection | conforms | `router/state.rs::{check_owned, op_open}` | `tests/conformance_artifacts.rs::owner_ids_are_scoped_to_their_connection` (both) |
| storeId is the store incarnation; old handles fail after restart | conforms (`ARTIFACT_GONE`) | `router/state.rs::check_owned` | `tests/artifacts.rs::router_restart_invalidates_old_handles` (both) |
| No artifact id or inode reuse; `generation` is 1 | conforms | `wire.rs::GENERATION`, `router/state.rs::next_artifact` (monotonic) | `tests/conformance_artifacts.rs::stale_store_incarnation_and_generation_are_rejected` (both) |
| Content hashes optional for live frames, mandatory where a domain contract says so | conforms: both paths exist and the router verifies what it is given | `client/handles.rs::ArtifactWriter::seal_with_digest`, `store.rs::copy_exact` | `tests/artifacts.rs::seal_checks_length_and_digest` (both) |
| The first backend is runtime-configured local files, optionally on tmpfs | conforms | `store.rs::Store::create` under `RouterConfig::store_root` | `store.rs::tests::orphans_are_removed_and_live_stores_kept` |
| The producer writes staging storage outside the message stream | conforms | `store.rs::create_staging`, `client/mod.rs::Artifacts::allocate` | `tests/artifacts.rs::allocate_write_seal_read` (both) |
| Seal closes writable handles in the SDK, checks length and digest, then finishes an immutable store-owned object before acknowledging | conforms: a writable descriptor kept after sealing reaches only the unlinked staging inode | `client/handles.rs::seal_with_digest` (drops the file first), `store.rs::seal` (fresh 0444 inode) | `tests/artifacts.rs::seal_is_immune_to_live_writable_handles` (both), `tests/conformance_artifacts.rs::seal_is_immutable_despite_a_stale_writable_handle` (both) |
| A copy into a fresh sealed inode is allowed; account for both allocations during sealing | conforms | `router/state.rs::op_seal` (`store_bytes += len` for the copy, released in `finish_seal`) | `tests/artifacts.rs::quotas_are_enforced` (both) |
| No per-frame fsync for transient media | conforms by absence | `store.rs` | `tests/perf.rs` (seal p50 1.3 ms for 1.2 MB) |
| Consumers resolve a readLocation through `artifact.open` and read it read-only | conforms | `router/state.rs::op_open`, `store.rs::open_read` | `tests/conformance_artifacts.rs::allocate_write_seal_open_roundtrip_and_mismatches` (both) |
| Locations are private grants, not placed in application bodies or public feeds | conforms | `router/state.rs::op_open` reply only | `tests/conformance_artifacts.rs::issued_locations_are_relative_and_contained` (both) |
| All filesystem access stays behind the client Artifact API; no second bulk-transfer server | conforms in the API. The store is only as private as the OS user, which the crate's Limitations section states | `client/handles.rs`, `store.rs` | `store.rs::tests::resolve_refuses_escapes` |
### 8.2 What owns an artifact
| Requirement | Status | Code | Test |
| --- | --- | --- | --- |
| Roots: producer hold/active writer, accepted queued delivery, in-flight delivery, retained latest, explicit hold | conforms, all five | `router/state.rs::{Owner, add_roots, drop_roots}` | `tests/conformance_artifacts.rs::{queued_deliveries_hold_roots_before_dispatch, collection_waits_for_every_retained_owner, retained_topic_value_holds_a_root_independent_of_subscribers}` (both) |
| Seal transfers the unique writer into a hold | conforms; the seal reply reuses the writer's own ownerId to express exactly that transfer | `router/state.rs::finish_seal` | `tests/artifacts.rs::allocate_write_seal_read` (both) |
| Admission creates destination roots before the sender may relinquish source roots | conforms: the SDK holds every source `OwnerGuard` until the router has answered | `client/reactor.rs::OutCommand::keep`, `router/state.rs::{op_call, op_reply, op_publish}` | `tests/conformance_artifacts.rs::forward_requires_the_source_owner_still_live` (both) |
| A timeout must not drop a source guard while an unsent operation might still be admitted; the client keeps the guard until the transport outcome is known | conforms: an unsent command's guards travel with it and are released only when it fails or is answered | `client/reactor.rs::{next_outgoing, fail_all}` | `tests/sol_review_races.rs::unsent_oversized_call_rolls_back_its_local_slot`, `tests/artifacts.rs::abandoned_futures_do_not_leak_owners` (both) |
| Every envelope lists its complete artifact set; duplicates in one delivery count once | conforms | `router/state.rs::{check_attachments, dedup}` | `tests/conformance_artifacts.rs::allocate_write_seal_open_roundtrip_and_mismatches` (both) |
| A retained topic and several consumers can reference the same bytes; the router updates metadata only and never copies bytes for fan-out | conforms | `router/state.rs::op_publish` (`Arc<TopicMsg>` plus root counts) | `tests/artifacts.rs::fan_out_shares_one_object_and_the_last_consumer_collects` (both), `tests/perf.rs` (store peak 3.7 MB for three consumers) |
### 8.3 Consumed means no remaining use
| Requirement | Status | Code | Test |
| --- | --- | --- | --- |
| The incoming message owns a shared DeliveryGuard; extracting an Artifact clones it; dropping the message alone does not consume the delivery | conforms | `client/handles.rs::{OwnerGuard, Message::artifact}` | `tests/artifacts.rs::extracted_artifacts_outlive_their_message` (both), `tests/conformance_artifacts.rs::extracted_artifact_outlives_the_message_it_came_from` (both) |
| Local handle clones need no bus round trip; dropping the last guard queues `delivery.consumed` on a bounded control lane | conforms | `client/handles.rs::OwnerGuard::drop`, `client/reactor.rs::push_control` | `tests/pubsub.rs::credits_return_only_on_consume` (both) |
| Ownership is at delivery granularity; independent retention needs `artifact.retain` before the guard is dropped | conforms | `client/handles.rs::Artifact::retain` | `tests/conformance_artifacts.rs::explicit_retain_outlives_the_original_hold` (both) |
| A domain acknowledgment implicitly drops nothing | conforms by absence: only a guard drop or an explicit release ends ownership | `client/handles.rs::OwnerGuard` | `tests/rpc.rs::endpoint_cache_replays_artifact_results` (both) |
| An allocation or seal grant that arrives after its caller abandoned the future is still processed and released; no owner the application never saw is leaked | conforms: the reactor builds the handle, so an undelivered reply drops it | `client/reactor.rs::{complete, Hook::Owner}` | `tests/artifacts.rs::abandoned_futures_do_not_leak_owners` (both) |
| An in-progress seal has a bounded I/O hold; on producer disconnect it cleans up and never publishes an ownerless object | conforms | `router/state.rs::finish_seal` (`owner_live` check), `router/mod.rs::read_loop` seal task | `tests/conformance_artifacts.rs::disconnect_abandons_an_unsealed_writer` (both) |
| Dropping a response future is not consumption: the client owns queued results until surfaced, discarded or disconnected | conforms | `client/reactor.rs::{CallSlot, on_delivery}` | `tests/rpc.rs::dropped_call_is_cancelled_and_late_result_consumed` (both) |
| Receivers await asynchronous CPU/GPU use before releasing the guard | conforms as far as the API can enforce: the guard lives as long as any `Artifact`/`ArtifactFile` clone | `client/handles.rs::{Artifact, ArtifactFile}` | `tests/conformance_routing.rs::bounded_credit_waits_for_every_extracted_artifact` (both) |
| A pointer from a mapping cannot outlive its Artifact; FFI wrappers enforce it | not-implemented: no mapping and no FFI surface exists. Owner: a future mmap or binding | — | — |
| Release commands are batched, idempotent and scoped to the owning connection | conforms | `router/state.rs::{op_release, op_consumed}`, `client/reactor.rs::take_batch` | `tests/artifacts.rs::owners_are_scoped_to_their_connection` (both) |
| Delivery and hold ids use monotonic per-connection serials with separate watermarks; a retired id is a no-op, a never-issued one an error; no tombstone per frame | conforms | `router/state.rs::{Conn::delivery_issued, Conn::hold_issued, op_consumed, op_release}` | `tests/artifacts.rs::release_ids_are_watermarked` (both) |
| Control-lane exhaustion closes the connection instead of losing releases | conforms on both sides | `router/state.rs::push_control`, `client/reactor.rs::push_control` | `tests/wire.rs::control_lane_exhaustion_closes` (both) |
### 8.4 Crash, disconnect and safe physical reclamation
| Requirement | Status | Code | Test |
| --- | --- | --- | --- |
| On disconnect: unregister services and subscriptions, cancel queued deliveries, release that connection's writers, holds and delivery roots | conforms | `router/state.rs::disconnect` | `tests/artifacts.rs::disconnect_releases_all_but_retained` (both), `tests/conformance_artifacts.rs::{disconnect_releases_an_explicit_hold, disconnect_releases_queued_and_dispatched_deliveries}` (both) |
| Retained topic roots stay router-owned | conforms | `router/state.rs::disconnect` (topics untouched) | `tests/conformance_routing.rs::subscriber_disconnect_releases_queued_and_delivered_artifacts_but_not_retention` (both) |
| Late replies and releases cannot attach to a new connection or service incarnation | conforms: owners are per connection and calls remember their service incarnation | `router/state.rs::{check_owned, op_reply, remove_service}` | `tests/artifacts.rs::owners_are_scoped_to_their_connection` (both), `tests/rpc.rs::raw_call_ids_and_forged_replies` (both) |
| Teardown reclaims a connection's roots without racing the frame it is writing: either a complete frame precedes the reclamation or a partial one is cut and nothing more is appended | conforms, **fixed on this branch** (see the section 9 row on starvation). Teardown now marks the stream closing once, before waiting for the poll already in progress, so only that one poll can still write and every later one is refused | `router/state.rs::WriteGate`, `router/mod.rs::write_selected`, `router/state.rs::close_conn` | `tests/sol_rereview_regressions.rs::{teardown_waits_for_an_active_transport_poll_before_reclaiming, shutdown_does_not_deliver_a_frame_after_releasing_its_owner, protocol_close_cancels_partial_topic_frame_before_reclaiming_attachment, shutdown_cancels_partial_rpc_request_before_reclaiming_attachment, shutdown_cancels_partial_rpc_result_before_reclaiming_attachment}` |
| GC removes the registry entry and unlinks the sealed object after its final root is gone | conforms; the unlink happens after the routing lock is released | `router/state.rs::drop_roots`, `router/mod.rs::Inner::with_state` | `tests/artifacts.rs::fan_out_shares_one_object_and_the_last_consumer_collects` (both) |
| Existing mappings stay valid until the OS closes them; never overwrite the inode or reuse its bytes | conforms: sealed files are 0444, written once and only unlinked | `store.rs::{seal, remove}` | `tests/bus_acceptance.rs::disconnect_releases_logical_ownership_without_mutating_open_bytes` (both) |
| Logical reclamation is not proof of physical release; measurements include OS mappings and client memory | conforms: `RouterStats` is documented as logical, and the measurement reports process RSS | `router/state.rs::RouterStats`, `tests/perf.rs` | `tests/perf.rs` (RSS 11 to 16 MB) |
| No TTL may reclaim a live owned artifact | conforms by absence: nothing in the router expires an owned root | `router/state.rs` | `tests/conformance_artifacts.rs::collection_waits_for_every_retained_owner` (both) |
| Limits may disconnect a consumer but cannot overwrite memory under a renderer | conforms: exhaustion closes the connection, which releases roots; bytes are never rewritten | `router/state.rs::push_control`, `store.rs` | `tests/wire.rs::control_lane_exhaustion_closes` (both) |
| Future pooled shared memory must prove equivalent lifetime/generation safety | not-implemented (deferred by the draft). Owner: a later storage backend | — | — |
| Router restart makes a new routerId/storeId, loses routes, queues and retention, and invalidates old handles | conforms | `router/mod.rs::Router::new`, `store.rs::Store::create` | `tests/artifacts.rs::router_restart_invalidates_old_handles` (both) |
| Orphan files from a stopped router are cleaned without being treated as durable checkpoints | conforms, at the next router start on the same root (a `flock`-free marked directory) | `store.rs::clean_orphans` | `store.rs::tests::orphans_are_removed_and_live_stores_kept` |
| Live sessions fail their epoch and use coherent recovery | not-implemented: a session responsibility. Owner: STATE-01 / step-v1 | — | — |
## 9. Bounds, scheduling and failure reporting
| Requirement | Status | Code | Test |
| --- | --- | --- | --- |
| Every default of the table, exactly: clients/services/topics 64/256/512; subscriptions 128 per client and 1024 total; control envelope 64 KiB; active calls 64; service queued/in-flight 16/16; latest 1/2; bounded 64/16; active owners 256; store 512 MiB and 128 MiB per object; per-client queued envelope bytes 1 MiB; reserved lane 128 frames and 1 MiB | conforms | `limits.rs::Limits::default`, `wire.rs::MAX_ENVELOPE_BYTES` | `tests/conformance_wire.rs::hello_reports_the_contract_digest_and_valid_limits` (both), `tests/pubsub.rs::topic_and_retention_quotas` (both) |
| Limits are configured explicitly and a configuration the router could not honour is refused | conforms | `limits.rs::Limits::validate`, `router/mod.rs::Router::new` | `tests/rpc.rs::active_call_limit` (both), `tests/artifacts.rs::quotas_are_enforced` (both) |
| The per-client queued envelope byte budget counts `bounded` subscriptions only, and latest slots are bounded at subscriptions x 64 KiB instead | conforms to the amended table. The draft's single row was the audit's contradiction 1: this section also forbids a latest spectator from being the reason a publication is refused, so its slot cannot sit in a budget whose overflow rejects one. The coordinator amended the row and gave latest slots their own (bus-v1 section 12, 2026-09-22) | `limits.rs::max_queued_bytes_per_client`, `router/state.rs::op_publish` | `tests/bus_acceptance.rs::a_latest_subscriber_never_refuses_a_publication` (both), `tests/sol_review_races.rs::{retained_replay_obeys_bounded_queue_byte_quota, latest_replay_remains_bounded_outside_the_bounded_byte_pool}` |
| Reserve an owner allowance for lifecycle and results separately from ordinary telemetry | conforms | `limits.rs::reserved_owners_per_client`, `router/state.rs::{ordinary_budget_left, dispatch_rpc}` | `tests/conformance_artifacts.rs::artifact_bounds_and_owner_budget_are_enforced` (both) |
| Memory quotas account for staging, seal copies, queued deliveries and caches | conforms | `router/state.rs::{op_allocate, op_seal, Conn::queued_bytes}` | `tests/artifacts.rs::quotas_are_enforced` (both) |
| Ownership metadata is bounded even when many roots share one artifact | conforms: a root is a counter, and owners are bounded per client | `router/state.rs::{Art::roots, ordinary_budget_left}` | `tests/conformance_artifacts.rs::artifact_bounds_and_owner_budget_are_enforced` (both) |
| Disk-full, allocation failure or hash mismatch returns a typed artifact error and cleans provisional storage and roots | conforms (`QUOTA_EXCEEDED`, `STORE_FAILURE`, `ARTIFACT_MISMATCH`) | `router/state.rs::{op_allocate, finish_allocate, op_seal, finish_seal}`, `store.rs::seal` | `tests/artifacts.rs::seal_checks_length_and_digest` (both), `tests/conformance_artifacts.rs::allocate_write_seal_open_roundtrip_and_mismatches` (both) |
| The router fairly services clients | deviates-allowed: fairness is Tokio's scheduling plus a yield every 32 commands from one connection, and round robin between a connection's services and subscriptions. The draft sets no fairness metric; the crate's Limitations section calls this modest | `router/mod.rs::read_loop`, `router/state.rs::{dispatch_rpc, dispatch_topic}` | `tests/pubsub.rs::saturated_subscriber_does_not_block_control` (both) |
| Replies, release, cancellation and route-health control cannot be starved by telemetry | conforms: control first, then RPC, then topic data, with topic data given a turn after 16 higher-priority frames | `router/state.rs::{next_frame, TOPIC_STARVATION_LIMIT}` | `tests/sol_rereview_regressions.rs::{fair_topic_insertion_preserves_router_envelope_order, sdk_accepts_fair_topic_insertion_through_saturated_control_backlog}`, `tests/pubsub.rs::saturated_subscriber_does_not_block_control` (both) |
| ... and neither can connection teardown be starved by the frame it is waiting for | **was deviates-must-fix, fixed on this branch**. The write gate was a plain mutex held across each synchronous transport poll, so a writer sending a frame one byte per poll re-acquired it hundreds of times while teardown waited for it, and could finish a whole delivery before teardown got in: `teardown_waits_for_an_active_transport_poll_before_reclaiming` failed 6 runs out of 6 in release and about 1 in 5 in debug. The gate now separates "teardown has begun" (a flag set once, without waiting) from "a poll is in progress" (a condvar teardown waits on), so teardown's window is one poll instead of a whole frame, and no byte can follow it. No public signature changed | `router/state.rs::WriteGate`, `router/mod.rs::write_selected` | `tests/sol_rereview_regressions.rs::teardown_waits_for_an_active_transport_poll_before_reclaiming` (rewritten: a 50 KB delivery the resumed writer cannot finish, and a channel instead of a sleep) |
| Preserve FIFO for calls to a target despite lane scheduling | conforms: the service queue is FIFO and lane choice never reorders it | `router/state.rs::dispatch_rpc` | `tests/rpc.rs::fifo_dispatch_and_out_of_order_completion` (both) |
| Classification is an explicit generic envelope operation or policy, not a topic-name heuristic | conforms by construction: `next_frame` chooses a lane from the queue an item sits in (control, then RPC, then topic), and neither it nor `pop_control`/`dispatch_rpc`/`dispatch_topic` reads a service or topic name. A name reaches the scheduler only as opaque bytes inside an already-classified frame | `router/state.rs::{next_frame, pop_control, dispatch_rpc, dispatch_topic}` | `tests/bus_acceptance.rs::a_topic_named_like_a_notice_is_still_classified_as_topic_data` (both), and `tests/sol_rereview_regressions.rs::fair_topic_insertion_preserves_router_envelope_order` for the lane order itself |
| No indefinite wait inside the router on subscriber readiness or artifact I/O | conforms: a slow reader stalls only its own writer task; I/O leaves the lock | `router/mod.rs::{write_loop, read_loop}` | `tests/pubsub.rs::saturated_subscriber_does_not_block_control` (both) |
| Admission is bounded; rejected callers choose their own policy | conforms | `router/state.rs::{op_call, op_publish}` | `tests/rpc.rs::service_queue_backpressure` (both) |
| The thirteen transport error codes exist with those names | conforms | `error.rs::ErrorCode` | `tests/wire.rs::body_errors_keep_the_connection` (both) |
| Three more codes: `CONFLICT`, `NO_TOPIC`, `ARTIFACT_MISMATCH` | deviates-allowed: "Transport errors **include** ..." is not an exhaustive list, and each names a refusal the draft requires but leaves unnamed. Recorded as an amendment in bus-v1 section 12 | `error.rs::ErrorCode` | `tests/pubsub.rs::subscription_and_topic_validation` (both), `tests/artifacts.rs::seal_checks_length_and_digest` (both) |
| Before admission report `not-dispatched`; once dispatch might have occurred report `dispatched` or `unknown` conservatively | conforms | `error.rs::BusError::new` (not-dispatched by default), `router/state.rs` dispatched notices, `client/reactor.rs::fail_all` (unknown) | `tests/rpc.rs::{cancellation_states, service_disconnect_fails_calls}` (both), `tests/sol_review_races.rs::writer_failure_terminates_reader_and_pending_work` |
| Bounded subscriptions can reject a publication; latest spectators cannot hold a session transaction indefinitely | conforms: a latest subscriber never causes `BACKPRESSURE` | `router/state.rs::op_publish` (the latest branch skips every capacity check) | `tests/bus_acceptance.rs::a_latest_subscriber_never_refuses_a_publication` (both: 100 publications of 60 KB into one unconsumed slot, six times the bounded pool, none refused, 98 coalesced), with `tests/pubsub.rs::{bounded_fifo_and_atomic_backpressure, saturated_subscriber_does_not_block_control}` (both) for the bounded half, and `tests/integration.rs::session_over_one_router` (both: 20 snapshot publications accepted by both subscriptions while the presentation consumer reads nothing) |
| Sustained pinned-artifact quota exhaustion is surfaced as pressure, not solved by freeing live data | conforms: `QUOTA_EXCEEDED`, never eviction | `router/state.rs::{op_allocate, op_seal}` | `tests/artifacts.rs::quotas_are_enforced` (both) |
| Session and application policies choose disconnect, pause or fail; the router does not know which | conforms by absence | `router/state.rs` | `tests/pubsub.rs::saturated_subscriber_does_not_block_control` (both) |
## 10. Native-frame bandwidth check
| Requirement | Status | Code | Test |
| --- | --- | --- | --- |
| 640x480 RGBA is 1,228,800 bytes; 73.728 MB/s at 60 fps is the planning dimension | conforms as measured, not as a claim | `tests/perf.rs::{W, H, HZ}` | `tests/perf.rs` (120 frames in 2.00 s, 0 late) |
| Two agent workers plus one presentation consumer read the same immutable frame object | conforms | `router/state.rs::op_publish` (roots, not copies) | `tests/perf.rs` (three consumers, store peak 3.7 MB = three 1.2 MB objects in flight, not nine) |
| Bus messages contain only references; no 3x byte fan-out through the router | conforms | `wire.rs::Attachment` | `tests/artifacts.rs::fan_out_shares_one_object_and_the_last_consumer_collects` (both), `tests/perf.rs` |
| Readers still incur memory traffic; renderer readback and seal copying remain real costs | conforms, and both are now measured separately | `tests/perf.rs` (`producer copy into staging`, `seal`, `consumer readback`) | `tests/perf.rs` |
| No claim of zero-copy capture or measured host capacity | conforms: the measurement section below says so in those words | `tests/perf.rs` header | — |
| Nothing game-specific, no rendering, publishing or sampling logic in Flybus | conforms: no crate in the workspace depends on flybus yet, and the crate names no game, brain or stream concept | `crates/flybus/**` | `tests/integration.rs::session_over_one_router` (both; the domain lives in the test) |
## 11. Acceptance tests and implementation sequence
| bus-v1 item | Status | Test |
| --- | --- | --- |
| 1. Wire/router: schema, framing, Hello, exclusive routes, pinned incarnations, request/reply, disconnect, bounds; in-memory passes the same tests as Unix sockets | conforms | `tests/conformance_wire.rs` (45), `tests/wire.rs` (12), `tests/rpc.rs` (26), all `both_transports!`; `tests/bus_acceptance.rs::both_transports_produce_equivalent_behaviour_traces` |
| 2. Pub/sub: exact topics, FIFO/bounded rejection, latest coalescing, retained replay and clear, atomic fan-out, fair control/reply delivery under a saturated subscriber | conforms | `tests/pubsub.rs` (20), `tests/conformance_routing.rs` (24) |
| 3. Artifacts: allocate/seal/read; publication before seal fails; fan-out owns one object; the last consumer releases; a retained extracted frame survives a message drop | conforms | `tests/artifacts.rs` (28), `tests/conformance_artifacts.rs` (36) |
| 4. Faults: sender drops after admission, consumer dies mid-read, reply lost, queued frame replaced, subscription closes with in-use deliveries, router restarts, old release arrives; no double-free, use-after-reuse, unbounded tombstones or hidden replay | conforms | `tests/conformance_routing.rs::{caller_disconnect_detaches_dispatched_call_but_service_keeps_serving, subscriber_disconnect_releases_queued_and_delivered_artifacts_but_not_retention}`, `tests/artifacts.rs::{release_ids_are_watermarked, router_restart_invalidates_old_handles}`, `tests/bus_acceptance.rs::{a_lost_result_leaks_no_roots_and_the_endpoint_cache_still_replays, disconnect_releases_logical_ownership_without_mutating_open_bytes}`, `tests/sol_rereview_regressions.rs` (11) |
| 5. RPC cache: an endpoint retains an artifact-bearing result, the original caller consumes it, a domain retry still returns valid bytes, eviction drops the last hold | conforms | `tests/rpc.rs::endpoint_cache_replays_artifact_results` (both), `tests/bus_acceptance.rs::a_retransmission_repeats_the_domain_request_under_a_fresh_call_id` (both) |
| 6. Integration: two parallel fake agents, a complete-batch environment RPC, committed snapshot publication and a deliberately slow presentation consumer on one router | conforms; the consumer is slow by construction, held until the publisher's completion is observed, so its coalescing is forced rather than raced for | `tests/integration.rs::session_over_one_router` (both) |
| 7. Performance: 640x480x60 with three consumers, one delayed; p50/p95/p99 RPC latency, router CPU, copy and readback cost separately, RSS, store live and peak bytes, outstanding roots, collection lag, queue lengths, for one, two and four agents | conforms | `tests/perf.rs::frames_at_60hz_with_three_consumers` (`--ignored`); numbers below |
| The first executable example: a counter RPC, a pub/sub observer and a frame artifact held past message consumption, in one small Rust program, no game or browser | conforms | `examples/demo.rs` (`cargo run -p flybus --example demo`), asserted by `tests/example_demo.rs::the_example_shows_a_counter_rpc_an_observer_and_a_held_frame` |
## The implementation guide's acceptance bullets
Every bullet of BUS-01, BUS-02 and BUS-03, with the named test that proves it. A bullet a
pre-existing suite already covered is cited here rather than duplicated; the rest are the
tests in `tests/bus_acceptance.rs`, named after their bullet.
### BUS-01 — router and RPC, in-memory and Unix socket parity
| Bullet | Test |
| --- | --- |
| Partial frames and writes | `tests/conformance_wire.rs::{a_frame_written_one_byte_at_a_time_still_decodes, a_reply_read_one_byte_at_a_time_still_decodes, truncated_frame_disconnects_cleanly, length_prefix_is_little_endian}` (both), `tests/sol_rereview_regressions.rs::{shutdown_cancels_partial_rpc_request_before_reclaiming_attachment, protocol_close_cancels_partial_topic_frame_before_reclaiming_attachment}` |
| Disconnect after request | `tests/rpc.rs::service_disconnect_fails_calls` (both), `tests/conformance_routing.rs::caller_disconnect_detaches_dispatched_call_but_service_keeps_serving` (both), `tests/sol_review_races.rs::caller_disconnect_cleanup_works_before_and_after_consumption` |
| Lost result | **new** `tests/bus_acceptance.rs::a_lost_result_leaks_no_roots_and_the_endpoint_cache_still_replays` (both) |
| Retransmission fixtures | **new** `tests/bus_acceptance.rs::a_retransmission_repeats_the_domain_request_under_a_fresh_call_id` (both), with `tests/rpc.rs::endpoint_cache_replays_artifact_results` (both) |
| No automatic retry | **new** `tests/bus_acceptance.rs::no_automatic_retry_or_failover_onto_a_replacement_registration` (both) |
| Cancel after dispatch reports execution-unknown | `tests/rpc.rs::cancellation_states` (both), `tests/sol_review_races.rs::reply_racing_cancel_has_only_the_two_contract_outcomes` |
| Incarnation replacement is visible | `tests/rpc.rs::registration_is_exclusive_and_pinned` (both), `tests/conformance_routing.rs::{duplicate_registration_by_owner_itself_is_rejected, unpinned_call_after_incarnation_replacement_reaches_the_new_holder}` (both) |
| Out-of-order replies | `tests/rpc.rs::fifo_dispatch_and_out_of_order_completion` (both), `tests/conformance_routing.rs::out_of_order_replies_correlate_across_concurrent_callers` (both) |
| Status RPC responds while another handler is delayed | **new** `tests/bus_acceptance.rs::a_status_rpc_responds_while_another_handler_is_delayed` (both) |
| Saturation is bounded | `tests/rpc.rs::{service_queue_backpressure, active_call_limit}` (both), `tests/wire.rs::control_lane_exhaustion_closes` (both), `tests/sol_review_races.rs::pending_connections_are_bounded_and_hello_expires` |
| Both transports produce equivalent behaviour traces | **new** `tests/bus_acceptance.rs::both_transports_produce_equivalent_behaviour_traces`, over the trace recorder in `tests/common/mod.rs::Trace` |
The recorder keeps behaviour and refuses operational identity: `Trace::record` panics on any
router-issued id, so a trace holds methods, payload fields, counts, sequences, credits, cancel
states and error codes only. The two scenarios (an RPC one and a pub/sub-plus-artifact one)
produce 29 events, identical over both transports; `FLYBUS_TRACE=1` prints them.
### BUS-02 — pub/sub, retention and backpressure
| Bullet | Test |
| --- | --- |
| Overflow rejects a bounded publication before partial fan-out | `tests/pubsub.rs::bounded_fifo_and_atomic_backpressure` (both), `tests/conformance_routing.rs::bounded_overflow_rolls_back_all_artifact_roots` (both) |
| Latest replaces only queued messages | `tests/pubsub.rs::latest_coalesces_only_undelivered_values` (both), `tests/conformance_artifacts.rs::latest_mode_holds_at_most_two_roots_delivered_plus_queued` (both) |
| Delivery consumption returns credits | `tests/pubsub.rs::credits_return_only_on_consume` (both), `tests/conformance_routing.rs::bounded_credit_waits_for_every_extracted_artifact` (both) |
| Unsubscribe preserves already-delivered ownership | `tests/pubsub.rs::unsubscribe_discards_queue_but_not_deliveries` (both), `tests/conformance_routing.rs::unsubscribe_cannot_reach_another_connections_subscription_id` (both) |
| Retained replay is ordered | `tests/pubsub.rs::retained_replay_clear_delete_and_incarnations` (both), `tests/conformance_routing.rs::{latest_replay_is_ordered_ahead_of_a_racing_publish, cleared_topic_gives_no_replay_until_a_fresh_publish}` (both), `tests/sol_review_races.rs::{retained_replay_obeys_bounded_queue_byte_quota, latest_replay_remains_bounded_outside_the_bounded_byte_pool}` |
| Stalled observers cannot starve RPC replies | `tests/pubsub.rs::saturated_subscriber_does_not_block_control` (both), `tests/sol_rereview_regressions.rs::{fair_topic_insertion_preserves_router_envelope_order, sdk_accepts_fair_topic_insertion_through_saturated_control_backlog}` |
All six were already covered, so BUS-02 added no test of its own. The equivalence trace above
carries a pub/sub and artifact scenario, so BUS-02's behaviour is in the transport comparison
too, and `a_latest_subscriber_never_refuses_a_publication` (both) proves the rule that sits
behind "latest replaces only queued messages": the replacement never turns into a refusal.
### BUS-03 — artifact-backed messages and automatic lifetimes
| Bullet | Test |
| --- | --- |
| Last owner collects | `tests/artifacts.rs::fan_out_shares_one_object_and_the_last_consumer_collects` (both), `tests/conformance_artifacts.rs::collection_waits_for_every_retained_owner` (both) |
| Extracted handle survives message drop | `tests/artifacts.rs::extracted_artifacts_outlive_their_message` (both), `tests/conformance_artifacts.rs::{extracted_artifact_outlives_the_message_it_came_from, explicit_retain_outlives_the_original_hold}` (both) |
| Forward before release is safe | `tests/conformance_artifacts.rs::forward_requires_the_source_owner_still_live` (both), `tests/sol_review_races.rs::unsent_oversized_call_rolls_back_its_local_slot`, `tests/integration.rs::session_over_one_router` (both; the coordinator forwards a frame to two agents) |
| Lost replies and cache replay remain valid | **new** `tests/bus_acceptance.rs::a_lost_result_leaks_no_roots_and_the_endpoint_cache_still_replays` (both), with `tests/rpc.rs::endpoint_cache_replays_artifact_results` (both) |
| Disconnect releases logical ownership without mutating still-mapped bytes | **new** `tests/bus_acceptance.rs::disconnect_releases_logical_ownership_without_mutating_open_bytes` (both), with `tests/artifacts.rs::{disconnect_releases_all_but_retained, seal_is_immune_to_live_writable_handles}` (both) |
| Retained latest and queue replacement release the correct roots | `tests/conformance_artifacts.rs::{latest_mode_holds_at_most_two_roots_delivered_plus_queued, retained_topic_value_holds_a_root_independent_of_subscribers, queued_deliveries_hold_roots_before_dispatch}` (both), `tests/conformance_routing.rs::subscriber_disconnect_releases_queued_and_delivered_artifacts_but_not_retention` (both) |
| Measure 640x480 RGBA x 60 with three readers: one stored image, no raw pixels in router messages, bounded CPU/RSS/owners/queues, reader and copy costs recorded | `tests/perf.rs::frames_at_60hz_with_three_consumers`; see the measurement below |
## The crate README's differences from the draft
Each of the ten differences the crate lists, kept with the sentence that allows it or fixed.
| README difference | Verdict |
| --- | --- |
| 1. Extra error codes `CONFLICT`, `NO_TOPIC`, `ARTIFACT_MISMATCH` | Kept. Allowed by section 9: "Transport errors **include** `INVALID_ENVELOPE`, ..." — an inclusive list. Each names a refusal the draft requires without naming its code, so all three are now in the bus-v1 amendment (section 12) |
| 2. Topics must be declared; `topic.clear` of an unknown topic is `NO_TOPIC` while `topic.delete` answers `deleted:false` | Kept. The draft is silent; section 7's "Topic count and retained bytes are capped" and `topic.declare`'s "conflicting settings fail" both presuppose a registry. See the section 7 row |
| 3. When notices are sent | Kept. Section 5 requires the three notices but says nothing about audience or timing: "Required bounded notices are route removal, subscription closure and call failure" |
| 4. Byte budgets: `max_queued_bytes_per_client` counts bounded subscriptions only; `max_retained_bytes` added | Kept, and no longer a difference: the section 9 table now names the bounded pool and bounds latest slots separately (amendment, contradiction 1), and section 7's "Topic count and retained bytes are capped" requires the retained cap |
| 5. Admission sizes a delivery with the router-added ids at their longest, so an inbound envelope near 65,536 bytes can be refused although it fits | Kept, and required: section 5's "Message length includes this wrapper" plus section 4's fixed maximum mean the delivery the router would build must also fit. ipc-v1 section 2's "must not silently truncate a payload" forbids the alternative |
| 6. One live connection per client id; a client id's last incarnation may not be reused; only `*_as` endpoints authenticate the Hello id | Kept. See the section 3 and 4 rows: section 3's per-incarnation callId uniqueness and section 4's launcher-provided privileges |
| 7. The seal reply's ownerId is the writer's own id, now a hold | Kept. Section 8.2: "seal transfers its unique writer" — one owner token, transferred |
| 8. No `budget` argument on calls, and no router executable | Kept. Section 1 calls the executable "optional"; section 6 puts the deadline in the calling client, and the section 2 sketch no longer shows a budget either (amendment, contradiction 2) |
| 9. Wire strictness: unknown fields in management bodies refused, `minor` must be 0 after hello | Kept. Stricter than section 4's "unknown envelope fields", forbidden nowhere, and section 4's envelope literally fixes `minor: 0` |
| 10. `rpc.responder.release` and its terminal `call.failed` | Kept. Section 4 allows draft schema changes ("Changes to these draft schemas change contractDigest"), and it is how section 6's "bounded call correlation metadata" stays bounded when a handler outlives its request delivery. Added to the bus-v1 amendment (section 12) |
## Measured on the dev VM
Two complete release runs of `cargo test --release -p flybus --test perf -- --ignored
--nocapture` on the development VM (4 CPUs), 640x480 RGBA at 60 Hz for 2 s per schedule, three
latest-mode consumers with the third delayed 40 ms per frame, and one, two and four agent
services called every frame. The router runs on its own two-thread Tokio runtime whose threads
carry a distinct name, so its CPU (routing plus the seal copies on its blocking pool) is
measured apart from the clients' four threads in the same process. Each cell is the range over
the two runs.
| Metric | 1 agent | 2 agents | 4 agents |
| --- | --- | --- | --- |
| Frames produced (late) | 120 (0) | 120 (0) | 120 (0 and 3) |
| RPC round trip ms p50/p95/p99 | 0.51-0.60 / 0.84-1.47 / 1.12-2.05 | 0.76-0.78 / 1.34-2.21 / 2.14-6.18 | 1.04-1.08 / 1.67-5.01 / 2.06-10.02 |
| allocate (quota + staging file) ms | 0.96-1.01 / 1.13-1.28 / 1.34-1.49 | 0.98-1.02 / 1.22-3.50 / 1.82-5.61 | 0.80-0.94 / 1.15-5.32 / 1.31-6.60 |
| producer copy into staging ms | 0.37-0.38 / 0.45-0.49 / 0.67-0.75 | 0.37 / 0.45 / 0.51-0.55 | 0.36-0.39 / 0.41-0.49 / 0.48-0.77 |
| seal, router copy to a sealed inode, ms | 1.31-1.35 / 1.65-1.73 / 1.89-1.90 | 1.27-1.42 / 1.69-4.97 / 1.87-7.41 | 1.14-1.30 / 1.64-5.33 / 1.72-7.00 |
| publish admission ms | 0.41-0.42 / 0.61-0.85 / 0.68-1.11 | 0.41-0.43 / 0.83-1.07 / 1.17-3.10 | 0.42-0.49 / 0.73-2.75 / 1.52-4.54 |
| consumer readback of 1.2 MB ms | 0.65-0.75 / 1.19-1.53 / 1.45-1.92 | 0.76-0.91 / 1.45-2.20 / 1.76-6.02 | 0.89-0.99 / 1.67-4.85 / 2.04-6.38 |
| Router CPU (cores, of 2 threads) | 0.175-0.185 | 0.200 | 0.175-0.215 |
| Whole process CPU (cores, of 6 threads) | 0.325-0.335 | 0.410-0.425 | 0.370-0.460 |
| VmRSS / VmHWM | 11.2-13.6 / 13.6-14.4 MB | 11.8-14.1 / 15.0 MB | 15.7-15.8 / 16.4-16.6 MB |
| Store bytes peak / live after drain | 3.7 MB / 0 | 3.7 MB / 0 | 3.7 MB / 0 |
| Outstanding roots peak / live | 5-6 / 0 | 6 / 0 | 5-6 / 0 |
| Queue length peak / live | 1 / 0 | 1 / 0 | 1 / 0 |
| Collection lag after the last frame | 63.5-74.7 ms | 73.6-83.0 ms | 44.1-68.2 ms |
| Frames seen by the three consumers (coalesced) | 120, 120, 49 (0, 0, 70-71) | 120, 120, 49 (0, 0, 71) | 120, 120, 47-49 (0, 0, 70-71) |
What the numbers do and do not say:
- **These are not capacity claims.** Two runs of a synthetic two-second schedule, in one
process, on one shared virtual machine with fewer CPUs than the process has threads, over
temporary local storage, with no capture device, encoder or renderer in the path. They are a
floor on cost, not a ceiling on throughput, and nothing here licenses a sizing decision.
- The second run's tails are three to five times the first run's (RPC p99 10.02 ms against
2.06 ms at four agents, three late frames against none) because other work shared the VM's
four CPUs during it. The p50s barely moved. That spread is the honest width of a measurement
on a shared host, not a property of the bus.
- No byte fan-out: three consumers of a 1.2 MB frame kept the store's peak at 3.7 MB, which is
one sealed object plus the staging and sealing copies of the next frame, not one object per
consumer. Outstanding roots peaked at six.
- Copy costs are real and dominate routing: the producer's copy into staging (p50 0.4 ms), the
router's seal copy into a fresh inode (p50 1.1 to 1.4 ms) and a consumer's readback (p50 0.7
to 1.0 ms) each cost as much as or more than publish admission (p50 0.4 ms).
- The delayed consumer coalesced 70 or 71 of 120 frames and never caused a rejected
publication, which is section 9's rule about latest spectators in one number.
- Every schedule drained completely: live store bytes, roots and queue lengths are zero
afterwards, 44 to 83 ms behind the last frame.
- Router CPU grows with agent count much more slowly than the whole process (0.18 to 0.22
cores against 0.33 to 0.46), because the clients own the copies. A thread that exits between
two samples takes its CPU with it, so the router figure is a floor.
## A flaky test and what it was measuring, 2026-09-22 (MEDIA-01)
`tests/sol_review_races.rs::caller_disconnect_cleanup_works_before_and_after_consumption`
failed intermittently on `main` after the bus slice merged. Reproduced here at
**37 failures in 240 runs** (four parallel loops of 60, debug, on the loaded dev VM), always
on the same line and always the same way: `responder.reply(...)` returned `routed:true` where
the test asserted `false`.
The mechanism is a synchronisation gap in the test, not a routing defect.
`router/state.rs::op_reply` returns `routed:false` only when `call.detached` is set, and for a
disconnected caller that flag is set by `router/state.rs::disconnect`, which the router runs
when **its** connection task reads EOF. `Client::close` documents what it waits for — "flushes
queued releases, closes the connection and waits until the reader has stopped ... the router
releases what they owned" — which is the client side only. So after `close()` returns, the
router may not have torn the caller's connection down yet, and a reply that reaches it first is
routed to a connection that is already closing. Nothing escapes: `disconnect` then releases
that connection's roots along with the queued result, which is why the test's own later
`settle` calls always passed. Only the `routed` flag, read one step too early, was wrong.
The fix is in the test: it now waits for the teardown it is talking about
(`e.settle("caller-a disconnected", |s| s.connections == 1)`) before asserting the
reply-to-a-detached-call sentence of section 6. That is the same bounded
poll-until-the-router-settles the rest of the file already uses for router-side consequences;
no sleep, no timing constant, and the assertion now has the precondition its contract sentence
names. **360 runs after the fix, 0 failures** (240 debug, 120 release).
Two other intermittent failures were seen in the same sweep. They belonged to the bus slice
rather than to this one and were fixed there, in the same way and for the same reason:
- `tests/example_demo.rs::the_example_shows_a_counter_rpc_an_observer_and_a_held_frame`,
2 failures in 40 standalone runs plus 1 in 12 full-suite runs. It printed
"while the frame is held: 1 artifact(s), 2 root(s)" instead of 1 root: the producer's hold
release is queued on the control lane and had not been applied when the example read the
counts. `examples/demo.rs` now waits for that release before reading the counts, the same
bounded poll it already used for collection eight lines below, so the line the guide quotes
is an observation rather than a race. The printed output is unchanged.
- `tests/integration.rs::session_over_one_router`, 1 failure in 12 full-suite runs and 0 in 40
standalone runs, at the assertion that the deliberately slow consumer skipped snapshots.
Under load it kept up, so the assertion was a timing claim about the machine: section 7
permits a latest subscriber to miss values, it does not oblige it to. The renderer is now
held until the publisher's twentieth receipt has returned -- the publisher's completion
observed, not timed -- so the coalescing is forced by construction, and the test asserts the
guarantees that do hold: each delivery carries the frame of the snapshot it announces,
deliveries arrive in publication order, the last value received is the latest published, the
renderer receives snapshots 1 and 20 of 20, both subscriptions accept all twenty publications
while the spectator reads nothing, and the eighteen replacements reported to the publisher at
admission are the same eighteen reported to the renderer on delivery and are exactly the
snapshots it did not receive. 16 failures in 40 runs beside four busy loops before, 0 in 40
after; the whole crate went from 10 failed runs in 20 to 0, and the workspace suite from 2
in 5 to 0.
## Contradictions
Two, both inside bus-v1, both minor, neither resolved by changing code. Both were referred to
the coordinator rather than guessed at, and both now carry a dated amendment in bus-v1
section 12; the rows above cite the amended wording. The original reading is kept here because
it is the reason for the amendment.
1. **The per-client queued envelope byte budget versus the latest-mode guarantee.** Section 9's
table has "Per-client ordinary queued envelope bytes | 1 MiB". Section 7 requires that a
latest subscriber always has one replaceable queued value, and section 9 itself says "latest
spectator subscriptions cannot hold a required session transaction indefinitely" — that is, a
latest subscriber must never be the reason a publication is rejected. A byte budget that
covered latest slots and rejected on overflow would violate the second statement; a budget
that excludes them is not the sentence in the table. The crate excludes them, which keeps the
normative sentence and loosens the table row: the worst case becomes subscriptions x 64 KiB
(8 MiB at the default 128 subscriptions per client) instead of 1 MiB. **Resolved in the
spec, not the code** (2026-09-22): the table row now names the bounded pool and latest slots
have their own row, so the implementation conforms as written. No behaviour changed, and
`a_latest_subscriber_never_refuses_a_publication` now proves the guarantee directly.
2. **A call `budget` versus a client-owned deadline.** Section 2's illustrative surface passes a
`budget` into `bus.call(...)`, while section 6 states "A deadline belongs to the calling
client" and gives the router no timeout behaviour, and section 5's `rpc.call` body has no
budget field. The crate follows sections 5 and 6 and has no budget argument, which is safe
because section 2 is labelled illustrative. **Resolved in the spec, not the code**
(2026-09-22): the sketch drops `budget` and shows the deadline at the caller, so the
illustrative surface and the wire contract now agree.
Ambiguities resolved without treating them as contradictions, for the record:
- Section 5's "Reply only by the registered recipient" is read as "by the connection the request
was delivered to", so that section 6's "dispatched calls can still be answered" after
`service.unregister` remains possible. The crate correlates replies by request delivery id on
that connection, not by the live registration.
- Section 9's "Active calls per client 64" is read as calls the caller still awaits: a call
detached by a post-dispatch cancel frees its caller slot while the router keeps the bounded
correlation record until the service consumes or answers it.
## Not implemented, and who owns it
- A standalone router executable (section 1 calls it optional): embed `Router`.
- Bindings in other languages (section 1: a binding "may" exist), and the mapping/FFI pointer
lifetime rules that section 8.3 writes for them.
- A memory storage backend (section 4) and pooled shared memory with generation reuse
(section 8.4), both deferred by the draft.
- Domain-schema enforcement that every referenced artifact is listed in attachments (section 4),
and the domain `RESULT_EXPIRED` outcome (section 6): CONTRACT-01 and `fly-session-rpc`.
- Epoch failure and coherent recovery after a router restart (section 8.4): the session
contract, STATE-01.
- Any consumer at all: no other crate depends on flybus yet, so the feed and control surfaces of
[feed-protocol](../../feed-protocol.md) and [control-api](../../control-api.md) are untouched
and the crate's "Wiring still pending" list still stands.

View file

@ -43,7 +43,7 @@ Illustrative Rust surface (not yet implemented):
```rust ```rust
let bus = Client::connect(config).await?; let bus = Client::connect(config).await?;
let service = bus.register("agent.fly-a", service_config).await?; let service = bus.register("agent.fly-a", service_config).await?;
let reply = bus.call(target, "Agent.Prepare", payload, attachments, budget).await?; let reply = timeout(deadline, bus.call(target, "Agent.Prepare", payload, attachments)).await?;
let subscription = bus.subscribe("session.demo.snapshots", subscription_config).await?; let subscription = bus.subscribe("session.demo.snapshots", subscription_config).await?;
bus.publish("session.demo.snapshots", payload, attachments).await?; bus.publish("session.demo.snapshots", payload, attachments).await?;
@ -422,7 +422,8 @@ Configure limits explicitly; these defaults are a prototype starting point, not
| Bounded subscription queued / in-flight deliveries | 64 / 16 | | Bounded subscription queued / in-flight deliveries | 64 / 16 |
| Active owners per client | 256 | | Active owners per client | 256 |
| Total artifact storage / per object | 512 MiB / 128 MiB | | Total artifact storage / per object | 512 MiB / 128 MiB |
| Per-client ordinary queued envelope bytes | 1 MiB | | Per-client ordinary bounded queued envelope bytes | 1 MiB |
| Latest subscription slots | subscriptions × 64 KiB |
| Reserved management/reply lane | 128 frames and 1 MiB per client | | Reserved management/reply lane | 128 frames and 1 MiB per client |
Reserve an owner allowance for lifecycle/results separately from ordinary telemetry; memory Reserve an owner allowance for lifecycle/results separately from ordinary telemetry; memory
@ -438,7 +439,8 @@ bounded; rejected callers choose their own retry/fail/pause policy.
Transport errors include `INVALID_ENVELOPE`, `VERSION_MISMATCH`, `NOT_AUTHORIZED`, Transport errors include `INVALID_ENVELOPE`, `VERSION_MISMATCH`, `NOT_AUTHORIZED`,
`NO_SERVICE`, `TARGET_CHANGED`, `BACKPRESSURE`, `CALL_GONE`, `ARTIFACT_UNSEALED`, `NO_SERVICE`, `TARGET_CHANGED`, `BACKPRESSURE`, `CALL_GONE`, `ARTIFACT_UNSEALED`,
`ARTIFACT_GONE`, `OWNER_INVALID`, `QUOTA_EXCEEDED`, `STORE_FAILURE`, `ROUTER_LOST`. `ARTIFACT_GONE`, `OWNER_INVALID`, `QUOTA_EXCEEDED`, `STORE_FAILURE`, `ROUTER_LOST`, and the
three of section 12.
Before admission use dispatch:not-dispatched. Once dispatch might have occurred, report Before admission use dispatch:not-dispatched. Once dispatch might have occurred, report
unknown/dispatched conservatively; a caller-side timeout must not imply no mutation. unknown/dispatched conservatively; a caller-side timeout must not imply no mutation.
@ -484,3 +486,40 @@ pipeline may itself exchange large artifacts through this same bus if useful.
The first executable example should show a counter RPC, a pub/sub observer, and a frame The first executable example should show a counter RPC, a pub/sub observer, and a frame
artifact held past message consumption in one small Rust program. No game or browser required. artifact held past message consumption in one small Rust program. No game or browser required.
Distributed simulation ordering remains the [session contract's](step-v1.md) responsibility. Distributed simulation ordering remains the [session contract's](step-v1.md) responsibility.
## 12. Amendments
Draft 1 stands as written above. Each amendment below names something the draft requires but
left unnamed, and is dated. The implementation and the sentence-by-sentence audit behind these
entries are in the [conformance report](bus-conformance.md).
**2026-09-22, from the flybus conformance audit.** Three error codes, because section 9's list
is inclusive and these three refusals had no name:
| Code | Reason |
| --- | --- |
| `CONFLICT` | Section 3's duplicate registration, section 5's conflicting topic redeclaration and section 5's `topic.delete` with subscribers are refusals of a live claim, not a missing route, a quota or a bad envelope. |
| `NO_TOPIC` | Publishing to or subscribing to a name nobody declared is a missing topic, and `NO_SERVICE` names the service case only. |
| `ARTIFACT_MISMATCH` | Section 9's "hash mismatch returns a typed artifact error", plus a sealed length that disagrees with the allocation and a reference that disagrees with the artifact it names; `STORE_FAILURE` would blame the store for the caller's claim. |
**2026-09-22, same audit.** One added operation, because section 6 requires bounded call
correlation and gives no way to end it when a handler keeps reply authority after releasing the
request delivery:
| Command | Body / reply value | Semantics |
| --- | --- | --- |
| `rpc.responder.release` | `{callId, requestDeliveryId}` -> `{released}` | The recipient gives up reply authority for a dispatched call. The final release for an attached call retires the correlation and emits `call.failed` with dispatch `dispatched`: `CALL_GONE` while the route is live, `NO_SERVICE` after route loss. Request consumption (section 8.3) stays independent of it. |
Both amendments change `contractDigest`, which section 4 already provides for.
**2026-09-22, coordinator decision on the audit's contradiction 1.** Section 9's table row
"Per-client ordinary queued envelope bytes | 1 MiB" now reads "Per-client ordinary **bounded**
queued envelope bytes", and latest slots get their own row, "subscriptions × 64 KiB", because
section 7's unconditional one-slot guarantee and the structural 1/2 cap outweigh one imprecise
table row: a budget whose overflow rejects a publication cannot contain a subscription that
this same section forbids to reject one.
**2026-09-22, coordinator decision on the audit's contradiction 2.** Section 2's sketch no
longer passes a `budget` into `bus.call` and shows the deadline at the caller instead, because
section 5's wire contract for `rpc.call` has no budget field and section 2 is self-labelled
illustrative.

View file

@ -0,0 +1,216 @@
# Checkpoint envelope v1: `FLYSESS1`
Status: **draft 1**, 2026-09-22. Specified by CONTRACT-01 of the
[implementation guide](implementation.md); required by
[session artifacts, native media and recovery](state-media-v1.md) section 4, which says to
"use a new envelope version; specify exact byte layout before production files". Reference
implementations of the layout: `services/flysim/crates/fly-session-types/src/checkpoint.rs`
and `packages/session-types/src/checkpoint.ts`; fixture:
`.../fly-session-types/fixtures/checkpoint-envelope.json`.
This is the byte layout and the durable commit sequence. The store itself, generations,
rotation, the writer thread and the capture RPC flow are the STATE-01 slice.
## 1. Why a new format
The historical envelope (`FLYSIM01`, `crates/flybrain-core/src/envelope.rs`) is a magic, a
`u32` manifest length, a JSON manifest, `u32`-prefixed chunks in manifest order and a CRC32
footer, with chunk names restricted to ASCII letters so the TypeScript reader can never name a
prototype key. It stays exactly as it is, and its reader stays separately readable: nothing in
this document changes a byte of it, and a `FLYSIM01` file is refused by a `FLYSESS1` reader at
the magic.
A coherent all-participant session checkpoint needs what that format does not have:
- payload names that are `Id`s (`agent-fly-a`, `executor-fly-a`), so the letters-only
constraint is widened **deliberately, in a new version**, rather than quietly;
- a per-payload content digest, because state-media-v1 section 1 makes digests mandatory on
checkpoint payloads and a group install must be able to fail one participant's bytes;
- a payload table with explicit offsets and lengths, so a reader can map one participant's
payload without walking every preceding chunk;
- SHA-256 over the whole prefix instead of CRC32, matching the `Digest` type these contracts
already use everywhere else.
## 2. Byte layout
All integers are unsigned little-endian. All digests are raw 32-byte SHA-256 (the manifest
records the same digests as lowercase hex `Digest` strings).
### Header, 32 bytes
| Offset | Size | Field |
| ---: | ---: | --- |
| 0 | 8 | Magic, ASCII `FLYSESS1` |
| 8 | 4 | `envelopeVersion`, `1` |
| 12 | 4 | `headerBytes`, `32` |
| 16 | 4 | `manifestBytes` |
| 20 | 4 | `payloadCount`, at most 64 |
| 24 | 4 | `tableOffset` |
| 28 | 4 | Reserved, must be zero |
### Manifest
`manifestBytes` bytes of canonical JSON (RFC 8785) at offset 32, no trailing newline. It is
canonical so the envelope's own digest is stable under reserialization, and a reader rejects a
manifest that is not already canonical rather than silently accepting a second spelling.
### Payload table
At `tableOffset`, which is `32 + manifestBytes` rounded up to a multiple of 8.
`payloadCount` entries of 112 bytes each, in write order:
| Offset in entry | Size | Field |
| ---: | ---: | --- |
| 0 | 64 | Name: an `Id` in ASCII, NUL-padded, no bytes after the terminator |
| 64 | 8 | `offset` |
| 72 | 8 | `byteLength` |
| 80 | 32 | SHA-256 of exactly `byteLength` bytes at `offset` |
### Payloads
Each payload starts at its declared offset. The first starts at the end of the table rounded
up to a multiple of 8; each subsequent one starts at the previous payload's end rounded up the
same way. Padding bytes are zero. Offsets are ascending and non-overlapping, which a reader
checks rather than assumes.
### Footer, 48 bytes
| Offset from end | Size | Field |
| ---: | ---: | --- |
| 48 | 8 | `fileBytes`, the total length including the footer |
| 40 | 32 | SHA-256 of every byte before the footer |
| 8 | 8 | Magic, ASCII `FLYSESSF` |
A truncated file therefore fails at the footer magic or the recorded length, not at an
arbitrary payload.
## 3. Manifest fields
State-media-v1 section 4 lists what the manifest records. The names below are the JSON field
names; a manifest missing any of them is not a complete checkpoint.
| Field | Contents |
| --- | --- |
| `envelopeVersion` | `1` |
| `checkpointId` | `Id`, the identity every participant's capture shares |
| `sourceScope` | `Scope`: session, epoch and the committed step |
| `episodeId` | `Id` |
| `worldTime` | `RationalNs`, the environment's logical time at that boundary |
| `schedulerId` | The coordinator's scheduler identity, `lockstep-v1` in v1 |
| `compositionDigest` | Coordinator scheduler and configuration identity |
| `portMap` | The exact port-to-agent map, `[{portId, agentId}]` |
| `compatibility` | Backend, content, patch, controller, parser and state-format identities |
| `agents` | Per agent: profile, dataset, **index** and model identities, resolved seed, tick count, remainder and the payload name holding its state |
| `coordinator` | Task ledger, prior world inspection, per-agent executor state, admission state and event watermarks, each as a payload name or an inline value |
| `helperState` | External-helper state required for exact resume, as payload names |
| `payloads` | `[{name, byteLength, digest}]`, mirroring the payload table |
**Amendment, 2026-09-22 (PUBLISH-01).** The `agents` row gains `indexDigest`, the index the
agent attested to at `Agent.Initialize`, and it joins that agent's compatibility identity.
Without it a replacement fly that built another graph -- the same dataset, the same neuron
count, another index -- passed the group check and was then published under its predecessor's
`indexDigest`, which is a graph identity crossing a recovery and exactly what section 5's rules
exist to prevent. It is recorded from the worker's attestation rather than recomputed from the
dataset, because the point is that the two can disagree. `envelopeVersion` stays `1`, which the
required-manifest-field rule below allows only while no production `FLYSESS1` file exists; once
one does, adding a required manifest field must bump it.
**Amendment, 2026-09-22 (STATE-01).** The table above names a holder for every payload except
the environment's own, although section 6's fixture has one (`world`) and a group install has
to map it by name like any other participant's. The manifest therefore also records:
| Field | Contents |
| --- | --- |
| `environment` | `{workerId, payload}`: which worker the world belonged to and the payload name holding its state |
The reference implementations' required-field set was also missing `helperState`, which this
section has listed from the start. Both are now in `REQUIRED_MANIFEST_FIELDS` in Rust and in
TypeScript, and the fixture was regenerated by the existing example. The schema set is
untouched, so `contractDigest` is unchanged.
`coordinator.eventWatermarks` is `{lastSourceStep, issued}`. The fixture illustrated
`{lastEventId, lastOrdinal}`, and it is the illustration that changed: an event id is derived
from the epoch, so a watermark spelled as one cannot be compared across the restore that
gives the session a new epoch, while a source step and an issued count can.
**A required-manifest-field change is compatibility-relevant and `contractDigest` does not
cover it.** The digest is taken over the schema set, and this manifest is not in it, so
`envelopeVersion` is the only thing that can carry such a change. It stays `1` here only
because no production `FLYSESS1` file exists yet: once one does, adding or removing a required
manifest field **must** bump `envelopeVersion`, because a reader of the older version would
otherwise accept a file it cannot completely read, or refuse one it could.
`payloads` is redundant with the table on purpose: the table is what a reader needs to map
bytes, and the manifest is what a store lists, compares and reports without opening the
payload area. A reader checks that the two agree.
What the manifest must **not** contain (state-media-v1 section 4): a transient bus `storeId`,
artifact ID, owner token, mapping or pointer. Payload bytes and durable content identity are
the only things that survive; on restore the durable store imports fresh bus artifacts, and
`sourceScope` is provenance, not a claim on the current router.
## 4. What a reader enforces
In this order, so a corrupt file fails on its own terms rather than on a derived value:
1. Length at least header plus footer; magic; version; `headerBytes`; reserved word zero.
2. Footer magic, `fileBytes` equal to the actual length, and the prefix digest.
3. Manifest inside the payload area, valid strict JSON (duplicate keys, invalid UTF-8 and
non-finite numbers refused) and already canonical.
4. `tableOffset` exactly at the laid-out position; the table inside the payload area.
5. Per entry: an `Id` name with no bytes after its terminator, names unique, the declared
offset exactly at the aligned end of the previous payload, the payload inside the payload
area, and its digest matching its bytes.
6. No padding between the last payload and the footer.
7. The required manifest field set, `envelopeVersion` of 1, and a `payloads` list that matches
the table name for name, length for length and digest for digest.
Failing any of these is a corrupt or foreign file. The group install rule of state-media-v1
section 5 then applies: corrupt any participant and installation fails as a group.
## 5. Durable commit
State-media-v1 section 6, in the order the writer performs it:
1. Write the envelope to a temporary generation file in the store directory.
2. `fsync` the file.
3. `rename` it to its final generation name.
4. `fsync` the store directory.
5. Write the store manifest to its own temporary file, `fsync`, `rename`, `fsync` the
directory.
**The store manifest rename is the durable commit point.** Before it, the generation file is
an unreferenced temporary that is never a restore candidate. After it, and only after it, the
writer reports a saved acknowledgment and moves the high-water mark.
Consequences the writer must respect rather than reinterpret:
- Bus publications for `captured`, `queued`, `committed`, `failed` and `superseded` are
distinct events; only durable completion produces the saved acknowledgment.
- A lost save reply never advances durable metadata: the coordinator resolves the same
operation or fails the epoch, and an unreferenced generation stays unreferenced.
- A failed write releases its owned ephemeral captures under the configured retry policy and
reports the failure. It never reports false durability.
- The writer owns the bus artifact handles until the bytes are committed or the job fails, and
drops them afterwards; durable files are outside the bus's ephemeral collection.
- No per-payload `fsync` inside one envelope: the single file `fsync` in step 2 covers it.
## 6. Fixture
`fixtures/checkpoint-envelope.json` holds one complete envelope: the manifest, five payloads
(one agent, one executor, the task ledger, the prior inspection and a world payload), the
envelope's base64 bytes, its exact layout (header size, manifest offset and length, table
offset, every entry's offset, length and digest, footer offset, total length) and six
corruptions a reader must refuse, each naming the byte to flip.
The two implementations are held to it from both directions: each parses the fixture and
checks every recorded offset, and the TypeScript side re-encodes the same manifest and
payloads and requires the bytes to be identical to the fixture. A layout change that only one
language makes therefore fails on the next test run.
## 7. Out of scope
Generations, rotation, hot versus durable copies, the capture queue and its bounds, the
`State.Capture` / `State.StageRestore` / `State.ActivateRestore` flow, compatibility
comparison rules and group fencing. Those are STATE-01, over this layout. `FLYSIM01` and the
legacy composition keep their own format and their own reader, unchanged.

View file

@ -160,6 +160,22 @@ delayed rendering retains its handle. Distinguish AssetRef from transient Artifa
**Depends on:** SESSION-02, MEDIA-01 and the profile/identity foundation in the broader backlog. **Depends on:** SESSION-02, MEDIA-01 and the profile/identity foundation in the broader backlog.
**2026-09-22:** blocked. The profile/identity foundation is FOUNDATION-02
(`feat/brain-profile-contract`) in the [MaleCNS backlog](../malecns-modular-implementation.md),
which has not been built. Not started.
**2026-09-23:** unblocked. The operator split FOUNDATION-02 (decision of 2026-09-23): its legacy
half, PROF-02a, is the [legacy Game Boy composition](legacy-gameboy-v1.md) contract with
machine-readable profile, readout-context and decision schemas in `fly-session-types` and
`@flybrain/session-types`; the MaleCNS half (PROF-02b) is later and does not gate this slice.
AGENT-01 builds the legacy profile `gameboy-legacy-fafb-v783-v1` first. It must: report
`brainTicks` equal to the legacy `network.ms` (legacy-gameboy-v1 section 3); consume
`gameboy-readout-context-v1` and return `gameboy-channels-v1`; keep the held channel, blocked
window and last location as private readout state; report `stimulusRemainingMs`; answer
`Agent.Rollback` under capability `legacy-ratchet-rollback-v1`; and choose the mapping from the
legacy rate-role names (`command_0`, `macro_*`, which are not `Id`s) to `AgentGraph.rateRoles`,
which that contract leaves open.
**Implement:** adapter over existing LIF, plasticity, retina and fixed readout primitives; **Implement:** adapter over existing LIF, plasticity, retina and fixed readout primitives;
reference-first composition/goldens; independently seeded agent state and shared immutable data. reference-first composition/goldens; independently seeded agent state and shared immutable data.
Avoid using the old whole-frame `tick` wrapper if it changes the specified phase ordering. Avoid using the old whole-frame `tick` wrapper if it changes the specified phase ordering.
@ -172,6 +188,31 @@ dispatch order and varying worker count preserves results. Keep 64-role limits e
**Depends on:** AGENT-01 and environment/task extraction in the broader backlog. **Depends on:** AGENT-01 and environment/task extraction in the broader backlog.
**2026-09-22:** blocked. AGENT-01 is blocked, and environment/task extraction is
RUNTIME-01 (`refactor/environment-task-boundary`) in the same backlog, which has not been
built. Not started.
**2026-09-23:** unblocked. RUNTIME-01's contract is the RT-01a amendments of 2026-09-23 to
[workers-v1](workers-v1.md) (sections 1, 3, 4, 5 and the new section 7),
[step-v1](step-v1.md) (sections 2, 3, 5 and 6) and [state-media-v1](state-media-v1.md)
(sections 2, 3, 4, 5 and 7), with the Game Boy specifics in
[legacy-gameboy-v1](legacy-gameboy-v1.md), all implementing the operator's decisions of
2026-09-23. ENV-01 and AGENT-01 may proceed in parallel against the schemas and fixtures; ENV-01
needs FND-01's trace harness for its "legacy fixtures unchanged" acceptance. ENV-01 must also:
add the one read-only bulk memory read to the shim and prove it mutates nothing; publish the
memory image per boundary; declare the one-frame setup scaffold; convert audio to f32 and leave
the DC blocker to the edge; implement `gameboy-slots-v1`; and make the coordinator refuse
`episodeRequest.kind = "rollback"` in any composition that declares no rollback policy (the
synthetic coordinator today pauses on every episode request, which is safe but not the rule).
**Amendment, 2026-09-23 (operator decision of 2026-09-23).** The "Implement" paragraph below said
to keep `legacy-gameboy-v1` separately routed. The operator decided on a full port instead: the
legacy composition runs on `lockstep-v1` as declared in legacy-gameboy-v1, and "exact old
ordering/hash semantics" is kept by construction and by test rather than by a separate route --
the frame order maps one to one (section 4), the legacy clock equals the rational one
(section 3), and the fingerprint and compatibility string are embedded unchanged (sections 2
and 12). The acceptance criteria below stand.
**Implement:** binjgb environment, task-local memory inspector and identity/existing action **Implement:** binjgb environment, task-local memory inspector and identity/existing action
adapter. Keep `legacy-gameboy-v1` separately routed with exact old ordering/hash semantics. adapter. Keep `legacy-gameboy-v1` separately routed with exact old ordering/hash semantics.

View file

@ -94,10 +94,14 @@ interface HelloResult {
workerId: Id; incarnationId: Id; role: "agent" | "environment" | "coordinator"; workerId: Id; incarnationId: Id; role: "agent" | "environment" | "coordinator";
buildDigest: Digest; contractDigest: Digest; buildDigest: Digest; contractDigest: Digest;
capabilities: Id[]; capabilities: Id[];
limits: { maxAgents: number; maxPorts: number }; limits: { maxAgents: number; maxPorts: number; workerThreads: number };
} }
``` ```
`limits.workerThreads` is the thread allocation the worker's launcher started it within; it
is an integer >=1 and its rule belongs to [worker interfaces](workers-v1.md) section 2, whose
2026-09-22 amendment added it.
The bus supplies caller identity; do not accept a forged caller in params. Bind a worker's The bus supplies caller identity; do not accept a forged caller in params. Bind a worker's
session authority to the expected coordinator identity/incarnation during negotiation and session authority to the expected coordinator identity/incarnation during negotiation and
initialization. Wrong worker/role, no common major or missing required capability refuses initialization. Wrong worker/role, no common major or missing required capability refuses
@ -154,6 +158,11 @@ Lifecycle/capture replies are retained until Worker.Acknowledge:
not another consumer's bus delivery. Already released/unknown IDs are ignored. Serial not another consumer's bus delivery. Already released/unknown IDs are ignored. Serial
watermarks reject reuse after acknowledgment without an unbounded tombstone list. watermarks reject reuse after acknowledgment without an unbounded tombstone list.
**Amendment, 2026-09-22 (CONTRACT-01):** those ids are domain request ids in the `req-<U64>`
serial form, not arbitrary `Id`s. The serial watermark rule in the sentence above cannot reject
reuse after acknowledgment unless the acknowledged id carries its serial, so a bus callId or a
bare `Id` is refused there.
Bound unacknowledged lifecycle replies at 16, then BUSY before application. Status and Bound unacknowledged lifecycle replies at 16, then BUSY before application. Status and
Acknowledge use a cache of their last 16 replies; current/previous step records have their Acknowledge use a cache of their last 16 replies; current/previous step records have their
separate finite retention. Caches containing big artifacts consume bus owner/byte budgets; separate finite retention. Caches containing big artifacts consume bus owner/byte budgets;

View file

@ -0,0 +1,437 @@
# Legacy Game Boy composition v1
Status: **contract**, 2026-09-23. It covers PROF-02a, the legacy half of the FOUNDATION-02 split,
and the Game Boy parts of RT-01a. The generic parts of RT-01a are dated amendments to
[worker interfaces](workers-v1.md), [step protocol](step-v1.md) and
[session media/state](state-media-v1.md), and they point back here. This document changes no
runtime behaviour: `flysim` is unchanged and so is its compatibility string, 648 bytes, sha256
`4929f3409b591ae21cf4a6d53e8e758b975c70f424eabb0b37db75c658b9ebd9`.
## 1. The decision and what it replaces
On 2026-09-23 the operator decided that the live fly gets a **full port** onto the session
framework. It is not kept outside the framework as a separately routed legacy loop. The
decisions this document implements are:
| Subject | Decision |
| --- | --- |
| FOUNDATION-02 | Split. The legacy profile ships now; MaleCNS bundles ship later (02b, section 17) |
| Profile | `gameboy-legacy-fafb-v783-v1`. It embeds today's schema-1 fingerprint, and the kernel `lif-1ms-f64-v2` and plasticity `fly-kc-mbon-rstdp-v2` are unchanged |
| Readout context | Location is allowed and declared: `gameboy-readout-context-v1 {boot, bound[], location\|null}` |
| Decision | `gameboy-channels-v1`: the eight buttons plus the macro group's winner |
| Decoder identity | The decoder and macro-channel configuration go in the composition digest, not the legacy compatibility string |
| Environment boundary | Macros run in the coordinator's ActionExecutor. It reads a 64 KiB memory image each boundary, carried as an artifact in `inspection`, plus the ROM as an AssetRef |
| Emulator shim | One read-only bulk memory read is added. The joypad stays the only write |
| Task and executor | One object, declared as the extension `executor: pokered-macros-v1` |
| Audio | The environment converts u8 samples to f32, and the edge applies the DC blocker |
| Initialize | `Environment.Initialize` runs one frame with no button pressed |
| Rollback | `legacy-ratchet-rollback-v1` plus the environment extension `gameboy-slots-v1` |
| Restore | `restore: legacy-transient-reset`, which clears the ledgers, the location and the held channel |
| Sugar | Admission reads `reward_remaining` from the last commit's telemetry. A lag of one commit is accepted |
| Checkpoint | FLYSIM01 stays the format of record until RETIRE-01 |
Several earlier statements said the legacy composition stays outside `lockstep-v1`:
[README](README.md) section 4, [implementation guide](implementation.md) ENV-01,
[state-media-v1](state-media-v1.md) section 7 and the
[modular-session analysis](../malecns-modular-sessions.md) section 5.2. Each now carries a
dated amendment that cites this decision. None was silently rewritten. The reason for the change is in
section 4: the legacy frame order already *is* the lockstep order, with one agent, one port
and one world. Moving it onto the framework therefore keeps every ordering fact those
statements protected.
## 2. The profile `gameboy-legacy-fafb-v783-v1`
Exactly one document exists. Every field of it is fixed, so both the document and its digest are constants of
this contract. Any other value is another profile and needs another id. The document is canonical
JSON (RFC 8785), its `AssetRef` is `{id: "gameboy-legacy-fafb-v783-v1", format:
"fly-profile-v1", digest, byteLength}`, and the digest and length are taken over the canonical
bytes. The current values are in `fixtures/gameboy-legacy.json`: digest `41e5d1ac…c60878`, length 1137.
| Field | Value | Why it is fixed |
| --- | --- | --- |
| `profileId` | `gameboy-legacy-fafb-v783-v1` | |
| `datasetId`, `fingerprintSchema` | `fafb-v783`, `1` | The schema-1 fingerprint is the seven SHA-256 digests joined with `:` |
| `datasetFingerprint` | Today's value, byte for byte the compatibility string's segment 2 | This is the "embeds today's schema-1 fingerprint" of the decision. `flysim`'s `legacy_profile_identity` test recomputes it from `data/fafb-v783` |
| `kernelVersion`, `plasticityVersion` | `lif-1ms-f64-v2`, `fly-kc-mbon-rstdp-v2` | Pinned defaults (CLAUDE.md). The same test compares them with the built network |
| `tickDuration` | `1000000/1` ns | One model tick |
| `warmupMs` | `2500` | Fresh-start warm-up with learning disabled, `DEFAULT_WARMUP_MS`. A service configured with another warm-up (`loop.warmup_ms`, `FLYSIM_LOOP_WARMUP_MS`) is **another profile** and needs its own id; the legacy composition refuses to start this profile with any other value. It matters only on a fresh start, because a restore never warms up, but it is identity all the same |
| `view` | `lcd`, 160 x 144 | The retina's native frame |
| `supportedStimuli` | `["reward-pulse"]` | Sugar and task reward events both drive `stimulate(durationMs)` |
| `readoutContextSchema`, `decisionSchema` | The registered references of sections 5 and 6 | |
| `legacyExceptions` | `["macro-roles-outside-fingerprint"]` | The `macro_*` roles are merged after the fingerprint is taken ([modular analysis](../malecns-modular-sessions.md) 2.2). This profile declares the gap instead of repairing it |
The profile does not name the decoder timings or the macro channels. Those belong to the
composition (section 12), because the legacy compatibility string never covered them and the
decision puts them in the composition digest.
## 3. Clock
`stepDuration` is one Game Boy frame: 70224 cycles of a 4194304 Hz clock, which is
**`8572265625/512` ns** exactly. The legacy loop accumulates the `f64` constant
`1000 / (4194304 / 70224)`. That constant is exactly `548625/32768` ms, because it is dyadic and
the division rounds to the true value. Every remainder of the loop's `remainder += ms_per_frame`
is therefore a multiple of 2^-15 ms below 32, which is exact in `f64`. As a result, the legacy accumulator and the rational
accumulator of [step-v1](step-v1.md) section 5 produce **identical** tick counts and remainders.
Both languages assert this over the first 100,000 (Rust) and 20,000 (TypeScript) frames, and
the fixture records the first twelve frames: 16, 17, 17, 16, and so on. No separate legacy
arithmetic is needed, and step-v1's rule that the clock must not accumulate rounded time holds unchanged. A
FLYSIM01 remainder (`f64` ms) converts exactly to a `RationalNs`.
The task's clock is the agent's brain time. `PreparedDecision.brainTicks` times 1 ms is the
legacy `network.ms`, and it counts warm-up. AGENT-01 must make `brainTicks` equal to that value,
because the ratchet windows and the reward adapter's timing read it.
## 4. Placement in `lockstep-v1`
The legacy `Sim::step_frame` order maps onto the transaction phases one to one:
| Legacy step | Lockstep phase |
| --- | --- |
| Drain commands (sugar) | Admission cut at `Ready(k)`. The sugar enters `Prepare.preStepStimulations` (section 15) |
| `network.step(ticks)` | Phase A: `Agent.Prepare` advances the ticks |
| `decode_bound(rates, ms, boot, blocked, bound)` | Phase A: readout with the context of section 5. The blocked rule stays inside the agent |
| `MacroLayer::decide` | Phase B: the `pokered-macros-v1` executor reads O[k]'s memory image (section 10) |
| `set_buttons`, `run_frame` | Phase B: one `Environment.Advance` with the complete joypad batch |
| Framebuffer, `take_audio_u8` | The environment returns O[k+1]: view, audio chunk, memory image |
| `adapter.sample` | Phase C: the task, which is the same object, evaluates old/new inspection once |
| `stimulate` per event, `reinforce(sum)` | Phase D: `Agent.Commit` installs the input, then the stimulations in event order, then one reinforcement |
| `MacroLayer::observe`, `location()` | Phase C: this produces the next context's `bound` and `location` |
| Ratchet observe, capture, recover | Phase C decides. `Environment.SaveSlot` and the rollback run at `Ready(k+1)` (section 11) |
| Milestone archive | A durable save at `Ready(k+1)`, exported as FLYSIM01, **after** that boundary's slot save (section 16) |
**Amended 2026-09-23, review round 1.** One order differs from the legacy loop and is declared.
Legacy `track_rank` archives the milestone *before* the ratchet captures, in the same frame, so a
legacy archive holds the pre-capture ratchet (`best` = the previous rung) with the previous
snapshot. Here the ratchet ledger commits `best = r` in Phase C, and the slot is only filled by
`Environment.SaveSlot` at `Ready(k+1)`. A capture ordered before that save would pair `best = r`
with the previous slot's contents -- or an empty slot on the first climb -- on every rank climb.
Section 16 therefore orders the save first, and a ported archive holds the **post-capture**
ratchet and slot. Both are internally consistent; they are not the same bytes.
The input installed at Commit and ticked at the next Prepare is the frame the legacy loop
hands `set_visual_frame` before it samples rewards. Rewards are sampled from the frame just
produced. This is the ordering that [modular analysis](../malecns-modular-sessions.md) 2.1 says must not
move, and it does not. FND-01's trace harness is where this mapping is proved against the running
loop.
## 5. Readout context `gameboy-readout-context-v1`
```ts
interface GameboyReadoutContext {
boot: boolean; // the adapter's boot gate after the last transition
bound: ChannelName[]; // the executor's bound macro channels, composition order; [] in raw mode
location: { area: number; x: number; y: number } | null; // the adapter's location, or no information
}
```
`ChannelName` is `^[a-z][a-z0-9_]{0,63}$`. Decoder channel and rate-role names carry `_`, so
they are not `Id`s. The context is what the task hands the decoder with each Prepare (the
`initialDecisionContext`, then every `nextDecisionContext`). `bound` is an ordered subset of the
composition's `macroChannels`.
**Location is allowed and declared.** It is task inspection data, and it reaches exactly one
place: the readout's blocked-direction window ([readout](../../readout.md), "Blocked-direction
cooldown"), which restarts when the location changes. It never reaches the network, it
changes no score and it is not neural input. Declaring it here satisfies
[workers-v1](workers-v1.md) section 4: the context is typed, bounded, versioned and
allowlisted by the profile. The blocked direction itself is **not** in the context. The agent
computes it from its own held channel, its own clock, `blockedMs` and the location history.
The held channel, the start of the blocked window and the last location are private readout
state of the agent.
## 6. Decision `gameboy-channels-v1`
```ts
interface GameboyChannelsDecision {
buttons: { id: "up"|"down"|"left"|"right"|"a"|"b"|"start"|"select"; down: boolean }[]; // all eight, this order
macro: ChannelName | null; // the macro-group channel active in this decode
}
```
The `buttons` array is the decoder's active set packed in `GAMEBOY_BUTTON_BITS` order, so bit *i* is
`buttons[i]`. `macro` is the macro group's channel in the active set, which is always one of the
context's `bound` channels. Together they are everything `MacroLayer::decide` reads: the raw
mask and the active macro. No port assignment or inspection field is in the decision.
## 7. Controller
One port, `ControllerSchema {schema: gameboy-joypad-v1, buttons: [up, down, left, right, a, b,
start, select], axes: []}`. The executor's `ControllerIntent` is the joypad mask, and it
becomes the port's `PortControl`. It is the only input the environment applies to a running game.
## 8. Inspection `gameboy-memory-inspection-v1`
```ts
interface GameboyMemoryInspection {
memory: ArtifactRef; // 65,536 bytes: $0000..=$FFFF as the CPU sees it at this boundary
romDigest: Digest; // == EnvironmentDescriptor.contentDigest == the executor's rom AssetRef digest
}
```
- **The image.** Byte *i* is what `fly_gb_read_mem(i)` returns at this boundary, which is
what every task and executor read in the legacy loop sees through the per-frame read cache.
It is a listed bus attachment, content type `application/octet-stream`. Its digest is optional,
because it is a transient live artifact ([state-media-v1](state-media-v1.md) section 1). At
59.73 frames per second it is about 3.9 MB/s.
- **The shim.** The emulator shim gains one function that fills a 65,536-byte buffer from
`emulator_read_mem` in address order. It is read-only. The implementing slice proves this by
exporting the emulator state before and after the call, comparing the bytes, and comparing
the buffer with 65,536 single reads. Nothing else in the shim changes. `fly_gb_set_buttons` stays
the only write, and there is no memory-write path. `read_uncached` is a probe tool and is
not available to a task or executor.
- **The ROM.** Macros read ROM banks (`MemoryReader::read_rom(bank, address)`) that the image
does not map. The executor gets the cartridge as a persistent `AssetRef` in the composition
(section 12), and it is refused unless its digest is the environment's `contentDigest`. The
image never carries ROM banks beyond the ones the CPU has mapped.
- **Retention.** The coordinator keeps O[k]'s image until transition k→k+1 has been evaluated.
The executor reads it in Phase B, and the task reads old and new images in Phase C.
## 9. Environment
- **Initialize.** The backend configuration declares a setup scaffold of **one frame with no
button down**, as the legacy fresh start runs. O[0] follows it, with `engineFrame` `"1"` and
`worldTime` `0/1`. That frame's audio is not published: O[0] carries no chunk
([state-media-v1](state-media-v1.md) 2), and the audio origin is the first sample of
transition 0→1.
- **Advance.** Apply the mask, run one frame, and return O[k+1]. The view is `lcd` 160 x 144 `rgba8`
(row stride 640) with `observationDelaySteps` 0. `engineFrame` is the legacy frame counter as a
decimal string.
- **Audio.** The environment converts binjgb's unsigned 8-bit interleaved stereo to f32 with
binjgb's host rule, `sample / 255`. The result is unipolar in [0, 1] with silence at 0.0. The
stream is `f32le-interleaved`, 2 channels, at the configured rate (48,000 by default). The
environment does not filter. The **edge** applies the DC blocker (pole 0.995, per channel)
before presentation. It is presentation state: it is reset only when the edge restarts, it
is never in a checkpoint, and it never reaches an agent.
- **Descriptor.** `stepDuration` is `8572265625/512`, `inspectionSchema` is section 8, `recovery` is
`exact-checkpoint`, and `determinism` is `fixed-build`.
- **Slots, `gameboy-slots-v1`.** This is an environment capability for `Environment.SaveSlot` and
`Environment.RestoreSlot` ([workers-v1](workers-v1.md) section 7). A slot holds the emulator's
exported state and the framebuffer that was on screen, as the ratchet's `Snapshot` does. Slot
ids are declared by the composition (the legacy composition declares one, `best`). A save
replaces the slot. A restore imports the state, releases the buttons and returns the
archived framebuffer as a fresh view artifact, with a memory image read after the import.
It runs no frame. Every slot is part of the environment's `State.Capture` payload, as
FLYSIM01's `ratchet_game` and `ratchet_frame` are today. A slot save due at a boundary
completes before any `State.Capture` or FLYSIM01 export at that boundary (section 16).
## 10. Executor `pokered-macros-v1`
The Pokémon Red task (reward adapter, ladder, ratchet ledger) and its action executor (the
macro layer) are **one object**. It implements both [workers-v1](workers-v1.md) section 4
interfaces and is declared as the extension `executor: pokered-macros-v1`. They cannot be
split without an undeclared channel between them, for three reasons. The executor's scene
observation produces the `bound` set the task hands the decoder. The macros read the adapter's
exploration and boundary ledgers. The macro layer's "nearer the objective" is a ratchet progress
signal. The object serves exactly one agent on one port.
- **Phase B** (`ActionExecutor.apply`): inputs are the decision (section 6), O[k]'s memory
image, the ROM and the brain clock. The output is a joypad mask plus the macro start and finish
events. In raw mode it passes the decision's mask through. While a macro runs, the macro owns the pad.
- **Phase C** (`Task.evaluate_transition`): the adapter samples O[k+1]. Each reward event
becomes one `Reward` (its value) and one `Stimulus` of kind `reward-pulse` (its
`stimulation_ms`), in event order. The macro layer observes O[k+1]. The location is read. The
ratchet observes, and may request `SaveSlot` at `Ready(k+1)` or a rollback (section 11). Then
the next contexts `{boot, bound, location}` are produced.
- **Capture.** The task ledger is the adapter state (FLYSIM01's `reward` chunk) and the
ratchet state. The executor's ledgers (blocked, reached, talked, errand) and a running macro
are session state. They are **not** captured (section 14).
- **Cancel.** `cancel(ms)` abandons a running macro. It is followed by `observe` on the world
the fly now stands in.
## 11. Episode policy `legacy-ratchet-rollback-v1`
The ratchet's game-only rollback is a declared episode policy. When the ratchet fires during
transition k→k+1, the task returns `episodeRequest {kind: "rollback", reason, outcome}`. The
outcome is `legacy-ratchet-rollback-v1 {slotId, trigger: "stall" | "game-over"}`. The
transition's rewards commit first. The coordinator then applies the policy **at `Ready(k+1)`,
before the next Prepare, without pausing**:
1. If this boundary also has a slot save due, `Environment.SaveSlot` runs first.
2. It picks a new epoch e'. `Environment.RestoreSlot(scope e',k+1; slotId, priorEpoch e)` restores
the slot and returns O'[k+1]. The boundary number stays the same. `worldTime` continues,
`engineFrame` continues, the view is the slot's archived frame, and there is no audio chunk.
3. Coordinator-local: the task clears the adapter's transient reward observations. The
executor runs `cancel`, then `observe(O'[k+1])`, which gives the next context.
4. `Agent.Rollback(scope e',k+1; priorEpoch e, input O'[k+1], context)` runs on every agent.
It clears the decoder holds (`clearHolds(now)`: holds, winners, fatigue, lockout) and the
plastic eligibility. It installs the slot frame as the next input, drops the held channel,
restarts the blocked window at now, and takes the location from the context. It runs **no
tick**, no reinforcement, no stimulation and no calibration.
5. With every reply in hand, the session is `Ready(e', k+1)`. Any failure fails the epoch and the
group restores from the last durable checkpoint. No participant resets alone.
6. A durable save follows, as the legacy loop checkpoints after a recovery. Any capture at this
boundary -- before or after the rollback -- is taken after step 1's slot save.
**Worker status and lost replies (amended 2026-09-23, review round 1).** While it executes
`Environment.SaveSlot` a worker's `Worker.Status` reports `capturing`; while it executes
`Environment.RestoreSlot` or `Agent.Rollback` it reports `restoring`, with `currentScope` still
the prior `(e, k+1)`. After the reply it reports `ready` at `(e', k+1)`. These are mutations
under the [session RPC](ipc-v1.md) section 5 operation key `(session, e', k+1, method, worker)`
(`SaveSlot`: `(session, e, k+1, …)`), so a lost reply goes through ipc-v1 section 6 first: stop
dispatch, query `Worker.Status` or retransmit the same request id and body to the same
incarnation, and resolve only the matching cached result. Only an unresolvable outcome -- a
changed incarnation, lost routes or ownership, `RESULT_EXPIRED` -- fails the epoch, and then the
group restores from the last durable checkpoint. No participant is ever left in `e'` while
another continues in `e`: the coordinator issues no Prepare until every rollback reply is
resolved.
The following continue through the rollback: brain clock, membrane, RNG, learned gains, rates and
reward history, the ratchet ledger (its attempt and lifetime budgets were spent in Phase C), and
the adapter's persistent state. The first audio chunk after the rollback marks a
discontinuity. This is the legacy `recover_game` sequence with the neural half moved into the agent.
Each half touches only its own state, so the order between the halves does not matter.
## 12. Composition declaration and digest
```ts
interface LegacyGameboyComposition {
compositionId: Id;
scheduler: "lockstep-v1";
profile: AssetRef; // the section 2 document
executor: { id: "pokered-macros-v1"; rom: AssetRef; adapter: Id; symbolProvenance: string;
mode: "raw" | "macros"; macroChannels: ChannelName[] }; // [] iff raw
decoderConfigDigest: Digest; // SHA-256 of the gameboy-decoder-config-v1 form
environment: { extensions: ["gameboy-slots-v1"]; slots: Id[]; stepDuration: RationalNs;
inspectionSchema: SchemaRef; controllerSchema: SchemaRef; setupFrames: 1;
audio: { sampleRate: number; channels: 2 } };
episodePolicy: "legacy-ratchet-rollback-v1";
restore: "legacy-transient-reset";
checkpointFormatOfRecord: "FLYSIM01";
flysimCompatibility: string; // the FLYSIM01 string, recorded, not reinterpreted
}
```
- `decoderConfigDigest` is the SHA-256 of the canonical JSON of the form
`gameboy-decoder-config-v1` of the effective decoder configuration (amended 2026-09-23,
review round 1): `{form, exclusive, macros, pulses, clearLockoutMs}`, each group
`{channels: [{channel, role}], decisionMs, holdMs, hysteresis, fatigueGain, fatigueDecay,
blockedFatigue, blockedMs}` or `null`, each pulse `{channel, role, holdMs, cooldownMs,
threshold, boot: {cooldownMs, threshold} | null, throttleGroup: string | null}`. Channels are
an array because their order breaks argmax ties and canonical JSON sorts object keys. The
shared vectors are `fixtures/gameboy-decoder-config.json` (raw mode and the 31-channel
Pokémon Red group): `flysim`'s `legacy_profile_identity` test computes them from
`gameboy_decoder_config_with_macros` (and rewrites them under `FLY_UPDATE_FIXTURES=1`), and
`@flybrain/session-types` reproduces them from the oracle's `gameboyDecoderConfig`. The
example composition carries the real macros-mode digest. A change to a decoder
timing, a threshold or the macro channel set therefore changes the composition digest.
None of those changes touches the legacy compatibility string, which never covered them.
- The declaration's digest is the SHA-256 of its canonical JSON. The coordinator's
`compositionDigest` recipe (`fly-session/composition-v1`: session, epoch, contract, one line
per agent) gains one final line, `declaration=<digest>`, for a composition that has a
declaration. The synthetic composition has none and gains no line.
- `flysimCompatibility` must agree with the declaration in its kernel, adapter, fingerprint,
plasticity and `pokered:` segments, so the two cannot describe two different flies. Until RETIRE-01
the **restore gate** is still that string and the legacy decision rules
(`flybrain_gb::compatibility::decide`, `FLY_ACCEPT_ADAPTERS`). The composition digest is the identity
for publication, traces and descriptor revisions, not a restore gate. This matches today's
behaviour, where a decoder change does not refuse a checkpoint.
## 13. Machine-readable parts
| Where | What |
| --- | --- |
| `fly-session-types/src/gameboy.rs`, `packages/session-types/src/gameboy.ts` | The five registered payload schemas, the profile, the composition declaration and their readers and cross-checks |
| `fly-session-types/src/extensions.rs`, `packages/session-types/src/extensions.ts` | `SaveSlot*`, `RestoreSlot*` and `AgentRollback*` payloads, which are in the session schema set |
| `fixtures/gameboy-legacy.json` (derived) | The extension set and its digest, every `SchemaRef`, the profile document with its canonical bytes and `AssetRef`, the frame clock, and an example composition with its digest and the digest recipe |
| `fixtures/gameboy-decoder-config.json` | The `decoderConfigDigest` vectors (raw and Pokémon Red macros), written and checked by `flysim`'s `legacy_profile_identity` test, reproduced by the TypeScript oracle |
| `TraceBehaviour.boundaryActions`, `TraceOperational.captures` | The section 16 order rule, refused by `TransitionTrace` validation in both languages |
| `fixtures/valid.json`, `invalid.json` | Accepted and refused cases for every new type, held to both languages |
| `flysim/tests/legacy_profile_identity.rs` | Recomputes the fingerprint, versions, frame size, warm-up, clock and button order from the committed dataset and the service defaults |
A payload schema's `SchemaRef.digest` is the SHA-256 of its canonical declaration
`{registry, id, version, source, fields}`. The legacy schemas are digested by their own
extension set, not by `contractDigest`: the session contract stays free of console state.
The generic changes of RT-01a (`stimulusRemainingMs`, `EpisodeRequestKind.rollback`, the six
extension payloads, `maxSlots`) *are* in the session schema set, and they moved
`contractDigest` to the value in `fixtures/contract-digest.json`. Regenerate with
`cargo run -p fly-session-types --example update_fixtures`. `tests/schema_set.rs` refuses
stale files.
**Open for AGENT-01:** the legacy rate roles (`command_0`, `macro_go_item`, and so on) are not
valid `Id`s, while `AgentGraph.rateRoles` and `AgentTelemetry.rates[].roleId` are `Id`s. The
mapping belongs to the agent adapter. This contract does not choose it.
## 14. Restore `legacy-transient-reset`
The legacy composition declares that a restore (a FLYSIM01 load today, and any group restore
under this composition) is **not** an exact replay. The restored parts are those FLYSIM01
carries: agent state, emulator, framebuffer, adapter state, ratchet state and slots, frame
counter, remainder, buttons and event watermark. The rest starts cleared, as it does in a fresh
process:
- the executor's ledgers (blocked, reached, talked, errand) are empty, with no macro running;
- the agent's readout transient starts exactly as a fresh legacy process has it (amended
2026-09-23, review round 1): no held channel, no last location, and the blocked window
starting at brain time **0 ms**, not at the restored clock. The decoder state itself
(`DecoderState`: holds, winners, fatigue) *is* restored. The consequence AGENT-01 must
reproduce: on the first decode after a restore, `now - 0 >= blockedMs`, so the restored
direction winner, if any, is passed as `blocked` and its fatigue is raised to
`blockedFatigue`. Only after that decode does the window restart, because the held channel
changed from none to the winner, and again when the first location is observed. A rollback
(section 11) differs: it clears the holds and winners and restarts the window at the
current brain time;
- the adapter's transient observations are cleared, as they are on a rollback.
Resume tests compare against the legacy restore outcome, not against an uninterrupted trace
([state-media-v1](state-media-v1.md) section 4 amendment). This is the property the operator's
unstick procedure depends on: restarting the service clears the ledger-shaped traps and keeps
the rung.
## 15. Sugar admission
The coordinator owns admission, with the legacy rules: the per-minute limiter, and "no overlap
with an active pulse". The pulse is read from `AgentTelemetry.stimulusRemainingMs` of the **last
completed commit** (an `Agent.Rollback` reply counts as one). The value can therefore be one
commit old, and the operator accepted this lag. The consequences, each bounded to one frame:
- a pulse that ended inside the in-flight transition still reads as running, so the request is
refused and retried;
- a reward pulse added by the in-flight transition is not yet visible, so a sugar can be
admitted over it where the legacy loop would have refused;
- after a restore, and until the first commit of the new epoch, the pulse is unknown and every
request is refused with a retry, where the legacy loop admits against the restored pulse at
once (amended 2026-09-23, review round 1).
The duration is clamped to `[1, sugar_max_ms]`. An admitted sugar is a `reward-pulse`
`Stimulus` in the next Prepare's `preStepStimulations`, which is the position of the legacy
drain at the top of a frame. Legacy admission *is* application. Here, the epoch can fail
between the two, so each admission record carries its interaction id. If the Prepare that
applies it never commits, the admission is reported aborted and the edge refunds it. The
bridge's fulfil path and refund path both get tests in the slice that wires them
([workers-v1](workers-v1.md) section 5 amendment).
## 16. Checkpoint format of record
**Order at a boundary (amended 2026-09-23, review round 1).** A slot save due at `Ready(k)`
completes -- its `Environment.SaveSlot` reply in hand -- before any `State.Capture` or FLYSIM01
export at `Ready(k)`, whether that export is periodic, a milestone archive or the post-rollback
save. So a checkpoint whose ratchet ledger names `best = r` always carries the slot saved for
rung `r`. This is a **declared difference** from the legacy loop, which archives a milestone
before capturing the ratchet snapshot. A legacy milestone archive holds `best = r-1` and the
rung `r-1` snapshot; a ported one holds `best = r` and the rung `r` snapshot. In practice the
two converge: after `fly-reset-to-milestone` onto a legacy archive, the ratchet captures rung
`r` again on the first safe frame (the rung is above the recorded best) and resets the attempt
counter. The difference is only where the rung `r` slot sits -- on the exact frame of the climb
(ported) or on the first safe frame after the reset (legacy) -- and it is visible only if the
fly stalls or hits game over before any safe frame, when legacy falls back to rung `r-1`'s save
and the ported loop to rung `r`'s (amended 2026-09-23, review round 2). The operator's confirmation of this difference is requested with the
CUT-01 shadow run. The rule is machine-checked in the step trace: `TraceBehaviour.boundaryActions`
records the saves and the rollback in order, `TraceOperational.captures` records each capture
with the number of boundary actions before it, and a `TransitionTrace` in which a capture
precedes a slot save is refused ([step-v1](step-v1.md) section 8 amendment; fixtures in
`valid.json` and `invalid.json`).
FLYSIM01 stays the format of record until RETIRE-01. Every durable save of this composition
(periodic, milestone archive, after a rollback) **exports a FLYSIM01 envelope** that the
current `flysim` reads, under the unchanged compatibility string. The deploy gate
(`--print-compatibility`) and `fly-reset-to-milestone` keep working on those files. A FLYSESS1
checkpoint may be written beside it, but it is not what a restore selects until RETIRE-01
says so.
## 17. PROF-02b: MaleCNS bundles (later)
Dataset manifests, original-ID mapping, anatomical roles, sensory and readout bindings, strict
graph validation for new bundles, and the composite behaviour identity of FOUNDATION-02 are
**not** in this document. They ship with PROF-02b, before DATA-01, as their own contract.
Nothing here constrains them, except that a new profile never reuses this profile's id or its
legacy exception.

View file

@ -42,6 +42,20 @@ Example addresses (chosen by composition, not recognized by router code):
| `app.pokemon.cues` | Pub/sub: application narrative/presentation events under declared delivery policy | | `app.pokemon.cues` | Pub/sub: application narrative/presentation events under declared delivery policy |
| `app.pokemon` | RPC: application queries/admission, e.g. restore UI state or request a supported effect | | `app.pokemon` | RPC: application queries/admission, e.g. restore UI state or request a supported effect |
**Amendment, 2026-09-22 (PUBLISH-01).** The repair path above needs exact methods, and
"exact methods require session API schemas" left the row unbuildable. The session registers
one **read-only** service, `session.<id>.query`, with exactly two methods, both ordinary
[session RPCs](ipc-v1.md) answering from what the session already published:
`Session.GetDescriptor` takes an optional `{revision: U64}` and returns that `SessionDescriptor`
or, with no revision, the newest; `Session.GetSnapshot` takes no parameters and returns the
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 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 guaranteed; a subscriber receiving an unknown descriptor revision must fetch it through the
application/session query contract or buffer a bounded number of snapshots, not infer shape. application/session query contract or buffer a bounded number of snapshots, not infer shape.
@ -77,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

@ -0,0 +1,101 @@
# Seed derivation v1
Status: **draft 1**, 2026-09-22. Specified by CONTRACT-01 of the
[implementation guide](implementation.md), required by
[worker interfaces](workers-v1.md) section 2 before the real-agent slice. Reference
implementations: `services/flysim/crates/fly-session-types/src/seed.rs` and
`packages/session-types/src/seed.ts`; test vectors:
`services/flysim/crates/fly-session-types/fixtures/seed-vectors.json`.
## 1. What this is for
`Agent.Initialize` takes `seed`, a signed 32-bit integer, matching the current RNG input.
Workers-v1 section 2 requires that the coordinator derive independent per-agent seeds from
**its recorded master seed and stable agent IDs** under a versioned algorithm, and that the
algorithm be specified and tested before the real agent slice. This is that algorithm.
It is a reproducibility rule, not a secret: a run manifest records the master seed in the
clear, and anyone with the manifest can recompute every agent's seed. It is not a key
derivation function and must not be used as one.
`seed-derivation-v1` is part of composition identity. Changing any byte of it requires a new
identifier (`seed-derivation-v2`), because two runs that agree on every other identity but
disagree here are not the same experiment.
## 2. Inputs
| Input | Type | Source |
| --- | --- | --- |
| `masterSeed` | `U64` decimal string | Recorded once per run by the application/supervisor |
| `agentId` | `Id` | The configured agent identity, stable across restarts and epochs |
Both are the ipc-v1 section 2 scalars. An `agentId` that is not an `Id` is an error, not
something to normalize. The master seed is the whole 64-bit range: a 32-bit master seed would
be no wider than the seed it derives.
## 3. Derivation
```text
material = "flybrain/seed-derivation-v1" LF masterSeed LF agentId LF
digest = SHA-256(material)
lanes = digest read as eight big-endian uint32 values, in order
seed = the first nonzero lane, reinterpreted as a two's-complement int32
```
`LF` is one `0x0a` byte. `masterSeed` is its canonical decimal form: `"0"`, or no leading
zero. The prefix is a domain separator, so a digest from this algorithm can never collide with
one taken over some other pair of strings.
Zero lanes are skipped because the pinned kernel's RNG is an xorshift generator, whose state
must not be zero: a derivation that could hand out `0` would silently produce a stalled
generator. If every one of the eight lanes were zero, the material is rehashed with a counter
suffix (`material || "1" LF`, then `"2" LF`, then `"3" LF`) and the search repeats; no input
has ever needed it, and four rounds exhausted is an error rather than a fallback seed.
The seed is the *negative* number when the lane's high bit is set. That is deliberate: the
existing RNG input is a signed 32-bit integer, and half the range is negative.
## 4. Properties
- **Deterministic.** The seed is a function of the two recorded inputs and nothing else: not
of wall time, agent order, port assignment, worker process or thread count.
- **Independent per agent.** Distinct agent IDs give unrelated seeds; there is no arithmetic
relationship between `fly-a` and `fly-b` for a caller to exploit or accidentally rely on.
- **Stable across recovery.** Restore, episode reset and a new epoch do not re-derive a
different seed for the same agent ID under the same master seed. The seed is persisted as run
configuration and state, and the capture compatibility digest covers the resolved seed
(workers-v1 section 2), so a checkpoint cannot be installed into a differently seeded
instance.
- **Equal IDs give equal seeds.** That is the only way to get identical seeds, and workers-v1
allows identical seeds only when an experiment declares them. A composition therefore
refuses a repeated agent ID rather than quietly sharing a seed between two agents.
Non-properties, stated so nobody assumes them: this is not uniform over the int32 range beyond
what SHA-256 gives, it is not a stream (one seed per agent per run, not per step), and it says
nothing about how a model consumes its seed.
## 5. Test vectors
`fixtures/seed-vectors.json` carries the full table: five master seeds (`0`, `1`, `42`, `2^63`
and the `U64` maximum) across four agent IDs, each with the exact material string, its SHA-256
and the derived seed, plus one four-agent composition and the inputs that must be refused.
Both implementations reproduce every row, and each records the material as well as the seed so
a third implementation can find where it diverges.
The first two rows:
| masterSeed | agentId | material | seed |
| --- | --- | --- | ---: |
| `0` | `fly-a` | `flybrain/seed-derivation-v1\n0\nfly-a\n` | 1828176714 |
| `0` | `fly-b` | `flybrain/seed-derivation-v1\n0\nfly-b\n` | 1218785088 |
Refused: an agent ID that is not an `Id` (uppercase, empty, over 64 characters), a master seed
that is not a canonical `U64`, and a composition with a repeated agent ID.
## 6. Out of scope
Choosing the master seed, recording it in the run manifest, and the hand-selected explicit
seeds that workers-v1 allows for the first synthetic composition. This document defines only
the derivation. A profile that needs several independent streams inside one agent derives them
from the agent's own seed under its own documented rule; that is a profile concern, not a
session one.

View file

@ -65,11 +65,38 @@ the sample position relative to the episode's configured audio origin, with inte
firstSample/sampleRate. Crash restore preserves sample position under a new epoch; first firstSample/sampleRate. Crash restore preserves sample position under a new epoch; first
chunk marks discontinuity. Within an epoch, chunks cannot overlap or go backwards. chunk marks discontinuity. Within an epoch, chunks cannot overlap or go backwards.
**Amendment, 2026-09-22 (MEDIA-01).** Two readings of the paragraphs above, made explicit
because they are now enforced:
- The bootstrap window is exactly the boundaries where `max(0, boundary - observationDelaySteps)`
is zero, that is `boundary <= observationDelaySteps`. Inside it the repeated `O[0]` is the
**same artifact**, not a fresh render of the same scene; outside it the producing boundary
advances one per step, and a frame from any other boundary -- older or newer -- is a step
failure. A producer therefore keeps a queue of `observationDelaySteps + 1` frames and nothing
more, so there is no older frame available to substitute.
- Within an epoch, `discontinuity` marks a range the stream actually skipped. The first chunk
after a restore marks it, and a later chunk may mark it when it starts past where the previous
chunk ended; a chunk that continues the previous one exactly is continuous by construction and
its flag is refused. Without that reading the restore rule is advisory, because a stream could
set the flag on every chunk and satisfy it by accident. The requirement is one-directional: a
fresh epoch's first chunk **may** mark a discontinuity, because section 6's recovery
establishes a fresh timeline and publishes one.
The environment provides **native game output**. Sensor transformations belong to the agent The environment provides **native game output**. Sensor transformations belong to the agent
profile. Resizing for viewers, overlays, composition, audio mixing/resampling, encoding, profile. Resizing for viewers, overlays, composition, audio mixing/resampling, encoding,
browser delivery and streaming belong to the application/presentation layer. No bus or browser delivery and streaming belong to the application/presentation layer. No bus or
generic session configuration assumes a 1080p show or Twitch output. generic session configuration assumes a 1080p show or Twitch output.
**Amendment, 2026-09-23 (RT-01a; operator decision of 2026-09-23).** For the Game Boy
environment of the legacy composition ([legacy-gameboy-v1](legacy-gameboy-v1.md) section 9):
the environment converts binjgb's unsigned 8-bit interleaved stereo to the declared
`f32le-interleaved` with binjgb's host rule `sample / 255` (unipolar, silence at 0.0), and does
not filter. The DC blocker the running service applies before publishing (pole 0.995, per
channel) moves to the **edge**, as presentation: its state is the edge's, never checkpointed,
reset only when the edge restarts, and never seen by an agent. The observations a slot restore
returns carry no audio chunk, like the ones `ActivateRestore` returns (section 5 amendment of
2026-09-22), and the next chunk marks the discontinuity.
640×480 RGBA at 60 fps produces 73.728 MB/s of raw image data. Artifact fan-out references 640×480 RGBA at 60 fps produces 73.728 MB/s of raw image data. Artifact fan-out references
one stored object; reads and any staging/seal copy still consume memory bandwidth. This is one stored object; reads and any staging/seal copy still consume memory bandwidth. This is
reasonable to measure before introducing codecs or pooled GPU buffers. Native dimensions reasonable to measure before introducing codecs or pooled GPU buffers. Native dimensions
@ -100,6 +127,28 @@ If it violates configured resource policy, disconnect/restart that observer inst
live data or silently skipping simulation input. Global store exhaustion is an explicit fault live data or silently skipping simulation input. Global store exhaustion is an explicit fault
or pause condition; the router cannot guess that a particular live object is disposable. or pause condition; the router cannot guess that a particular live object is disposable.
**Amendment, 2026-09-23 (RT-01a).** An artifact-backed inspection -- the legacy composition's
64-KiB memory image per boundary -- is required coordinator input, not spectator data: the
coordinator retains `O[k]`'s image through the executor's use in the next transition's Phase B
and the task's old/new evaluation in its Phase C, and drops it after. It is never coalesced and
never published to observers by the session; about 3.9 MB/s at the Game Boy's cadence, budgeted
with the cached step observations above.
**Amendment, 2026-09-22 (PUBLISH-01).** "Disconnect/restart that observer" names an action
no participant can take under [Flybus v1](bus-v1.md). Section 5 there makes publish admission
all or nothing -- "for a bounded subscriber overflow, reject the **whole** publish; no partial
fan-out or retained-latest update" -- and the router exposes no per-subscriber eviction, so a
session meeting a full bounded queue cannot drop that one subscriber and deliver to the rest.
The realisable reading, which the session now implements, is three-part: observation topics
are published `latest`, and a latest subscriber can never refuse a publication (it loses its
own queued value and is told how many by `replaced`); a bounded subscriber's refusal, which
`bus-v1` section 6 explicitly permits, is a named and counted publication outcome that takes
no world step, stalls nothing and fences no epoch, and the exact value stays recoverable
through the [publishing-v1](publishing-v1.md) section 2 query path; and disconnecting the
offender is an operator action against the topic the ledger names, not something the session
performs. A per-subscriber drop would need a router operation Flybus v1 does not have, and
inventing one here would be a transport change written into the wrong document.
No coordinator tracks per-reader socket acknowledgments or calls a producer's reclaim method. No coordinator tracks per-reader socket acknowledgments or calls a producer's reclaim method.
The SDK and bus perform that bookkeeping. File-backed immutable mappings are safe after The SDK and bus perform that bookkeeping. File-backed immutable mappings are safe after
unlink; physical pages disappear when all OS mappings close. Pooled reuse is deferred until unlink; physical pages disappear when all OS mappings close. Pooled reuse is deferred until
@ -120,6 +169,19 @@ The manifest records:
or reproducible reconstruction inputs, admission state and event watermarks. or reproducible reconstruction inputs, admission state and event watermarks.
- Payload names, lengths and hashes, including external-helper state required for exact resume. - Payload names, lengths and hashes, including external-helper state required for exact resume.
**Amendment, 2026-09-23 (RT-01a; operator decision of 2026-09-23).** A composition may declare
**restore semantics** other than exact. The legacy composition declares
`restore: legacy-transient-reset` ([legacy-gameboy-v1](legacy-gameboy-v1.md) section 14): its
executor's ledgers and running macro, the agent's readout transient (held channel, blocked
window, last location) and the task's transient observations are **not** captured and start
cleared on every restore, exactly as the running service does on a restart. The
[modular analysis](../malecns-modular-sessions.md) section 5.4 already asked for this to be
labelled "legacy continuation semantics, not exact session replay"; the declaration is that
label, in the composition digest. The consequence is explicit: for this composition the
uninterrupted-versus-resumed trace equality of STATE-01 does not apply; its resume tests
compare against the legacy restore outcome. The operator's unstick procedure depends on the
reset. An environment's slots (`gameboy-slots-v1`) **are** world state and are captured.
Use a new envelope version; specify exact byte layout before production files. The historical Use a new envelope version; specify exact byte layout before production files. The historical
letter-only chunk-name constraint is not silently widened, and FLYSIM01 remains separately letter-only chunk-name constraint is not silently widened, and FLYSIM01 remains separately
readable. Persist payload **bytes and durable content identity**, not transient bus storeId, readable. Persist payload **bytes and durable content identity**, not transient bus storeId,
@ -173,11 +235,40 @@ restored time. It cannot advance gameplay to manufacture it. Capture/reconstruct
covers render/inspection state and any pending sensor pipeline. Agent state agrees with it; covers render/inspection state and any pending sensor pipeline. Agent state agrees with it;
do not replay reward or recalibrate merely to fill missing cached data. do not replay reward or recalibrate merely to fill missing cached data.
**Amendment, 2026-09-22 (STATE-01).** Three readings of this section, made explicit because
they are now enforced:
- `compatibilityDigest` on `CaptureResult` and `StageRestoreParams` is the **participant's**
capture compatibility digest of [worker interfaces](workers-v1.md) section 2 -- profile,
resolved seed, numerical model version and effective instance configuration for an agent;
backend, content, patch, controller and parser identity for an environment. It is not the
manifest's `compatibility` block of section 4, which is the composition's and which the
coordinator compares before anything is asked to stage. Both exist because they answer
different questions, and a restore that passed the second could still be handing an agent
another agent's brain.
- The observation `ActivateRestore` returns ran no transition, so it carries **no audio
chunk**, and one in it is refused. Section 2's chunk is the audio of an interval and this
observation covers none; MEDIA-01 implemented that rule as "boundary 0 carries no chunk",
which is true of the only such observation that slice could produce and false of this one.
The rule is about provenance, not about the boundary number.
- A participant that staged into a group install the coordinator then abandoned must be
**replaced** before another restore, exactly as one that activated must. It is holding a
validated replacement state that nothing installed, and [session RPC](ipc-v1.md) section 6
already refuses to silently reattach such a participant to an active epoch. Without this the
group's second attempt meets its own leftovers and calls them a conflict.
If emulator validation requires mutation, stage a stopped replacement emulator. If that cannot If emulator validation requires mutation, stage a stopped replacement emulator. If that cannot
provide externally atomic resume, advertise episode-restart, not exact-checkpoint. After all provide externally atomic resume, advertise episode-restart, not exact-checkpoint. After all
activation acknowledgments, install the coordinator's staged task/executor/admission state activation acknowledgments, install the coordinator's staged task/executor/admission state
and establish Paused(new epoch,k). Failure during activation never permits half a group to run. and establish Paused(new epoch,k). Failure during activation never permits half a group to run.
**Amendment, 2026-09-23 (operator decision of 2026-09-23).** `FLYSIM01` remains the **format of
record** for the legacy composition until RETIRE-01: every durable save exports a `FLYSIM01`
envelope the current service reads under its unchanged compatibility string, and restore
selects from those files through the legacy compatibility decision. A `FLYSESS1` checkpoint
may be written beside it; it is not a restore candidate for this composition until RETIRE-01
says so ([legacy-gameboy-v1](legacy-gameboy-v1.md) section 16).
## 6. Durable commit, router failure and recovery ## 6. Durable commit, router failure and recovery
Write payload/envelope temporary generation, fsync, rename, fsync directory, then atomically Write payload/envelope temporary generation, fsync, rename, fsync directory, then atomically
@ -210,3 +301,14 @@ state and retained/fresh brain components are explicit. Gain retention, eligibil
clearing, calibration and first sensory input are part of the policy, tested independently. clearing, calibration and first sensory input are part of the policy, tested independently.
Legacy Pokémon ratchet behavior remains in the legacy composition. Shared competitive worlds Legacy Pokémon ratchet behavior remains in the legacy composition. Shared competitive worlds
never restore one player's environment independently of the other players. never restore one player's environment independently of the other players.
**Amendment, 2026-09-23 (RT-01a; operator decision of 2026-09-23).** The sentence above kept the
ratchet outside the framework. The operator decided on a full port instead, so the ratchet's
game-only rollback is now a declared episode policy of the legacy composition,
`legacy-ratchet-rollback-v1`, with the environment capability `gameboy-slots-v1`
([workers-v1](workers-v1.md) section 7, [step-v1](step-v1.md) section 6 amendment,
[legacy-gameboy-v1](legacy-gameboy-v1.md) section 11). It is a rollback, not a reset: a new
epoch at the same boundary, the episode and the brain continuing, the environment restoring a
slot, agents clearing holds and eligibility and installing the slot frame without a tick, the
executor cancelling then observing. What the original sentence protected still holds: the
policy is single-agent and a shared competitive world must not declare it.

View file

@ -5,6 +5,13 @@ arrows below are RPCs through the same [Flybus router](bus-v1.md); the router it
implements the barrier. Read [architecture](README.md) and [session RPC](ipc-v1.md) first. Method payloads are in implements the barrier. Read [architecture](README.md) and [session RPC](ipc-v1.md) first. Method payloads are in
[worker interfaces](workers-v1.md). [worker interfaces](workers-v1.md).
**Amendment, 2026-09-23 (operator decision of 2026-09-23).** The live Game Boy fly is ported
onto this protocol as the legacy composition of [legacy-gameboy-v1](legacy-gameboy-v1.md),
scheduled by `lockstep-v1` with one agent, one port and one world; it is no longer a separate
ordering. Its frame order is this document's transaction order (legacy-gameboy-v1 section 4
maps it step by step), so the amendments below add capabilities to the protocol and change
none of its ordering rules.
## 1. Committed boundary ## 1. Committed boundary
At `Ready(epoch, k)`: At `Ready(epoch, k)`:
@ -30,6 +37,7 @@ Starting → Ready(k) → Preparing(k) → Applying(k) → Observing(k+1)
Ready(k) → Paused(k) → Ready(k) Ready(k) → Paused(k) → Ready(k)
Ready(k) / Paused(k) → Capturing(k) → same boundary Ready(k) / Paused(k) → Capturing(k) → same boundary
pause requested mid-step → Committing(k) → Ready(k+1) → Paused(k+1)
any unresolved partial failure → Failed → Restoring(new epoch) → Paused(k) any unresolved partial failure → Failed → Restoring(new epoch) → Paused(k)
terminal episode → Paused(k) → Resetting(new epoch) → Ready(0) terminal episode → Paused(k) → Resetting(new epoch) → Ready(0)
``` ```
@ -38,6 +46,25 @@ terminal episode → Paused(k) → Resetting(new epoch) → Ready(0)
transition carry `scope.step=k`; result fields identify `nextStep=k+1` where applicable. transition carry `scope.step=k`; result fields identify `nextStep=k+1` where applicable.
Do not send Agent.Commit with step `k+1` merely because the observation is newer. Do not send Agent.Commit with step `k+1` merely because the observation is newer.
**Amendment, 2026-09-22.** The mid-step pause line above adds no new edge: a pause requested
during a transition is served by the ordinary `Committing(k) → Ready(k+1)` edge followed by
`Ready(k+1) → Paused(k+1)`. It is written into the machine because section 6 requires the
transition to finish first, so the only boundary such a pause can land on is the one the
transition just committed.
**Amendment, 2026-09-23 (RT-01a; operator decision of 2026-09-23).** One edge is added, for a
composition that declares a rollback policy:
```text
Ready(e, k) → RollingBack(e', k) → Ready(e', k)
```
It is taken only when the transition that reached `k` returned `episodeRequest.kind =
"rollback"`, after that transition fully committed and before the next Prepare (section 6
amendment). The boundary number does not change; the epoch does. A pause requested during it
lands on `Ready(e', k)`; any failure inside it is `Failed → Restoring(new epoch)`. Capture is
not allowed in `RollingBack`.
## 3. Transaction sequence ## 3. Transaction sequence
### Phase A: prepare all agents concurrently ### Phase A: prepare all agents concurrently
@ -69,6 +96,12 @@ After every PreparedDecision arrives:
2. Run each task-local action executor once, in sorted agent-ID order, against coherent current 2. Run each task-local action executor once, in sorted agent-ID order, against coherent current
game state, task progress/objectives and clock from this boundary. game state, task progress/objectives and clock from this boundary.
Direct-control profiles use an identity executor. Macro profiles are explicit extensions. Direct-control profiles use an identity executor. Macro profiles are explicit extensions.
*Amendment, 2026-09-23 (RT-01a):* the legacy composition's extension is
`pokered-macros-v1`. "Coherent current game state" is the boundary's 64-KiB memory image
from `O[k].inspection` plus the ROM `AssetRef` -- never a live emulator read -- and "clock"
is the agent's brain time after its Prepare ([legacy-gameboy-v1](legacy-gameboy-v1.md)
sections 8 and 10).
3. Assemble all configured port controls in descriptor port order; reject duplicates/missing 3. Assemble all configured port controls in descriptor port order; reject duplicates/missing
ports. Uncontrolled ports are configured neutral before the epoch, not supplied ad hoc. ports. Uncontrolled ports are configured neutral before the epoch, not supplied ad hoc.
4. Send exactly one `Environment.Advance(scope=k, batchId, controls)`. 4. Send exactly one `Environment.Advance(scope=k, batchId, controls)`.
@ -89,6 +122,17 @@ controls. It returns scoped rewards/stimulation, next decision contexts, progres
an optional episode request. Commit its ledger update in memory and retain the result for an optional episode request. Commit its ledger update in memory and retain the result for
this transition. No task output directly writes controllers or neural state. this transition. No task output directly writes controllers or neural state.
**Amendment, 2026-09-23 (RT-01a).** The task may also ask for two things that happen at the
boundary this transition reaches, after Phase D, never inside it: a slot save
(`Environment.SaveSlot`, composition capability `gameboy-slots-v1`) and a rollback
(`episodeRequest.kind = "rollback"`). Both are recorded with the transition's result and
applied by the coordinator in the order *save, then rollback* (section 6 amendment). A slot
save due at a boundary completes before any `State.Capture` or FLYSIM01 export at that boundary
(amended 2026-09-23, review round 1; [legacy-gameboy-v1](legacy-gameboy-v1.md) section 16). The
retained old inspection is what makes "evaluate once against old/new inspection" possible when
the inspection is artifact-backed: the coordinator keeps `O[k]`'s image until this evaluation
finishes.
### Phase D: commit all agent outcomes concurrently ### Phase D: commit all agent outcomes concurrently
Send `Agent.Commit(scope=k)` with that agent's next sensory observation and routed outcomes. Send `Agent.Commit(scope=k)` with that agent's next sensory observation and routed outcomes.
@ -164,6 +208,15 @@ Example: a synthetic 60-Hz environment with a 1-ms model tick produces 16,17,17
over three steps, totaling 50. A real backend's measured/declared emulated cadence may over three steps, totaling 50. A real backend's measured/declared emulated cadence may
differ; never substitute this example's duration for Game Boy or Dolphin clocks. differ; never substitute this example's duration for Game Boy or Dolphin clocks.
**Amendment, 2026-09-23 (PROF-02a).** The Game Boy's declared cadence is one frame of 70224
cycles at 4194304 Hz, `stepDuration = 8572265625/512` ns. The legacy service accumulates the
`f64` constant `1000 / (4194304 / 70224)` ms, which is exactly `548625/32768` ms, and every
remainder it produces is a multiple of 2^-15 ms below 32 -- exact in `f64`. The legacy
"floating remainder arithmetic" and this section's rational accumulator therefore give identical
ticks and remainders for every frame; both implementations assert it and
`fixtures/gameboy-legacy.json` records the first twelve frames (16, 17, 17, 16, ...). No legacy
exception to this section is needed ([legacy-gameboy-v1](legacy-gameboy-v1.md) section 3).
Wall time is only for pacing, health and presentation. The coordinator schedules absolute Wall time is only for pacing, health and presentation. The coordinator schedules absolute
deadlines after committed boundaries; when behind, it omits sleep and reports lag. It does deadlines after committed boundaries; when behind, it omits sleep and reports lag. It does
not skip world steps, drop neural ticks, or let one agent advance more slowly than another. not skip world steps, drop neural ticks, or let one agent advance more slowly than another.
@ -193,6 +246,39 @@ exactly what is retained, cleared, warmed or recalibrated. No worker independent
Changing port assignment, agent membership, model/profile, cadence or task schema requires Changing port assignment, agent membership, model/profile, cadence or task schema requires
a new composition/epoch. Hot-join and hot-swap during an active match are not v1 capabilities. a new composition/epoch. Hot-join and hot-swap during an active match are not v1 capabilities.
**Amendment, 2026-09-23 (RT-01a; operator decision of 2026-09-23).** A declared rollback policy
is an episode policy that does **not** pass through Paused and does **not** start a new episode.
For `legacy-ratchet-rollback-v1` ([legacy-gameboy-v1](legacy-gameboy-v1.md) section 11), once
every Commit of the transition that reached `k` has succeeded:
1. If the same transition asked for a slot save, `Environment.SaveSlot(scope e,k)` first.
2. Choose a new epoch `e'`. `Environment.RestoreSlot(scope e',k; priorEpoch e)` returns the
restored `O'[k]`: same boundary, `worldTime` and `engineFrame` continue, no audio chunk.
3. Coordinator-local: the task clears its transient observations; the executor cancels any
running action and observes `O'[k]`, which yields the next decision contexts.
4. `Agent.Rollback(scope e',k; priorEpoch e)` on every agent concurrently: holds and
eligibility cleared, `O'[k]`'s view installed, no tick.
5. With every reply in hand: `Ready(e', k)`, then the usual durable save. Every capture at
this boundary, before or after the rollback, follows step 1.
A worker reports `capturing` during `SaveSlot` and `restoring` during `RestoreSlot` or
`Agent.Rollback`. A lost reply is resolved by [session RPC](ipc-v1.md) section 6 against the
same operation key before anything fails the epoch (legacy-gameboy-v1 section 11).
Exactly what is retained, cleared and installed is named by the policy, as this section already
requires; nothing is reset by a worker on its own initiative, and a failure at any step fails
the epoch and restores the group coherently. "Reset uses a new epoch/episode and step 0"
remains the rule for terminal episodes; a rollback keeps the episode and the step numbering
because the brain, the audience's history and the frame counter all continue across it, as they
do in the running service. The policy is single-agent: a shared competitive world must not
declare it, because it would rewind one world under every player on one player's stall, which
[state-media-v1](state-media-v1.md) section 7 forbids.
**Amendment, 2026-09-23 (RT-01a).** Sugar admission in the legacy composition reads the pulse
from the last completed commit's telemetry, so an admission decided while a transition is in
flight is at most one commit stale; the admitted stimulus still enters only at the next
admission cut of section 3, Phase A ([workers-v1](workers-v1.md) section 5 amendment).
## 7. Failure rules ## 7. Failure rules
| Failure point | Required response | | Failure point | Required response |
@ -220,6 +306,19 @@ The synthetic integration test must record, for every transition:
- Observation producing boundaries and task event/outcome IDs in order. - Observation producing boundaries and task event/outcome IDs in order.
- All Commit acknowledgments and published committed boundary. - All Commit acknowledgments and published committed boundary.
**Amendment, 2026-09-23 (RT-01a, review round 1).** For a composition with boundary actions the
record also carries, for the boundary the transition reached:
- in behaviour, `boundaryActions`: every `Environment.SaveSlot` (slot id and saved state
digest) and a rollback (slot id), in the order applied -- saves first, each slot once, at most
one rollback, last. FND-01's harness compares it like any other behaviour field;
- in operational metadata, `captures`: every checkpoint capture or FLYSIM01 export at that
boundary, in the order taken, each with the number of boundary actions already applied.
Captures are operational because their schedule is wall-clock policy.
A trace whose capture precedes one of the boundary's slot saves, or counts more actions than
were applied, is refused. The synthetic composition records both lists empty.
Evaluate agents sequentially, concurrently, and in reversed dispatch/completion order. All Evaluate agents sequentially, concurrently, and in reversed dispatch/completion order. All
committed state/action/reward results must match, excluding wall time, request IDs and other committed state/action/reward results must match, excluding wall time, request IDs and other
explicitly operational metadata. Delayed/lost/duplicate messages must not add a neural tick, explicitly operational metadata. Delayed/lost/duplicate messages must not add a neural tick,

View file

@ -22,6 +22,9 @@ rpc.result. Large inputs/outputs use owned bus attachments, never another worker
| `State.Capture` | Coordinator → agent/environment | Immutable snapshot of committed boundary | | `State.Capture` | Coordinator → agent/environment | Immutable snapshot of committed boundary |
| `State.StageRestore` | Coordinator → agent/environment | Validate replacement state under new epoch | | `State.StageRestore` | Coordinator → agent/environment | Validate replacement state under new epoch |
| `State.ActivateRestore` | Coordinator → agent/environment | Install staged state; remain quiescent | | `State.ActivateRestore` | Coordinator → agent/environment | Install staged state; remain quiescent |
| `Environment.SaveSlot` | Coordinator → environment | Capability `gameboy-slots-v1`; record a slot at the committed boundary (section 7) |
| `Environment.RestoreSlot` | Coordinator → environment | Capability `gameboy-slots-v1`; boundary k under a new epoch (section 7) |
| `Agent.Rollback` | Coordinator → agent | Capability `legacy-ratchet-rollback-v1`; Ready(e,k) → Ready(e',k), no tick (section 7) |
State methods have payloads in [state and media](state-media-v1.md). Artifact lifetime and State methods have payloads in [state and media](state-media-v1.md). Artifact lifetime and
message consumption are bus operations managed by the SDK, not Worker/Coordinator methods. message consumption are bus operations managed by the SDK, not Worker/Coordinator methods.
@ -51,6 +54,8 @@ interface AgentTelemetry {
rates: { roleId: Id; hz: number }[]; rates: { roleId: Id; hz: number }[];
learning: { enabled: boolean; updates: U64; changed: U64; signal: number }; learning: { enabled: boolean; updates: U64; changed: U64; signal: number };
} }
// Amendment 2026-09-23: AgentTelemetry also carries
// stimulusRemainingMs: number | null; // pulse still running after the operation; null = reports none
``` ```
`AssetRef` names persistent content in a preprovisioned local registry; it is not an arbitrary path or `AssetRef` names persistent content in a preprovisioned local registry; it is not an arbitrary path or
@ -75,6 +80,15 @@ finite; shipped positive-only task profiles reject negatives. Empty rewards do n
different numerical rule. `id`/`eventId` is unique within its outcome or command namespace; different numerical rule. `id`/`eventId` is unique within its outcome or command namespace;
the coordinator assigns stable IDs before sending a mutating request. the coordinator assigns stable IDs before sending a mutating request.
**Amendment, 2026-09-23 (RT-01a; operator decision of 2026-09-23).** `AgentTelemetry` gains
`stimulusRemainingMs: number | null`: the milliseconds of stimulation pulse still running when
the operation that reports the telemetry completed, or `null` for an agent that has no pulse to
report. The operator decided that sugar admission reads the legacy `reward_remaining` from the
last commit's telemetry (section 5 amendment), and no field carried it. It is finite and
nonnegative; it is a report, never an input. The field is required-and-nullable like every
optional field in these contracts, so every existing producer now writes `null`. It changes
`contractDigest`, which [session RPC](ipc-v1.md) section 4 already provides for.
## 2. Agent methods ## 2. Agent methods
### Agent.Initialize ### Agent.Initialize
@ -96,9 +110,39 @@ interface AgentInitializeResult {
warmupTicks: U64; committedStep: U64; // committedStep == "0" warmupTicks: U64; committedStep: U64; // committedStep == "0"
decisionContextDigest: Digest; decisionContextDigest: Digest;
telemetry: AgentTelemetry; telemetry: AgentTelemetry;
graph: AgentGraph;
}
interface AgentGraph {
datasetDigest: Digest; indexDigest: Digest; neuronCount: U64;
rateRoles: Id[]; // <=64, unique; AgentTelemetry.rates is in this order
supportedStimuli: Id[]; // <=64, unique; an undeclared kind is UNSUPPORTED
} }
``` ```
**Amendment, 2026-09-22 (PUBLISH-01).** `AgentInitializeResult` gains `graph`, because
[publishing-v1](publishing-v1.md) section 3 requires `datasetDigest`, `indexDigest`,
`neuronCount`, `rateRoles` and `supportedStimuli` in every published `AgentDescriptor` and no
worker method carried any of them. Without this the only available source is the composition
that asked for the agent, so a descriptor could only ever agree with itself and the section 3
rule that "geometry/spike mapping requires indexDigest, not merely the same number of neurons"
would have nothing to compare. Initialize is where the agent has just loaded its dataset and
built its index, so the attestation belongs there. `rateRoles` is the "profile-defined order"
section 1 already requires `AgentTelemetry.rates` to be in, and the result is refused when the
two disagree; `supportedStimuli` is the profile capability section 1 already requires a
stimulus kind to resolve through, and a kind outside it is refused with `UNSUPPORTED` before
the model is touched. It changes `contractDigest`, which [session RPC](ipc-v1.md) section 4
already provides for.
**Amendment, 2026-09-22 (SESSION-02).** `HelloResult.limits` gains `workerThreads`, an
integer >=1 reporting the allocation the launcher started that worker within, because
"within launcher allocation" above had no wire-level proof: the launcher passes the number to
the worker out of band, and a coordinator that is not also its own launcher had no contract
path to it. Hello is where a worker already proves its identity and reports its limits, so the
allocation belongs there. A caller asking for more than the worker reports is refused with
`BUSY` before the model is constructed, which this section already required; the amendment
only makes the number visible to whoever must respect it. It changes `contractDigest`, which
[session RPC](ipc-v1.md) section 4 already provides for.
The profile fixes warm-up/calibration behavior and supported schema versions. Validate inputs The profile fixes warm-up/calibration behavior and supported schema versions. Validate inputs
and required roles before model construction. Install the initial sensory input, warm the and required roles before model construction. Install the initial sensory input, warm the
brain with learning disabled, calibrate the fixed readout and establish Ready(0). Do not brain with learning disabled, calibrate the fixed readout and establish Ready(0). Do not
@ -253,6 +297,25 @@ The environment only needs backend-relevant portions of task setup, not reward r
neural policies. `taskConfig` resolves a declared setup configuration; the complete task neural policies. `taskConfig` resolves a declared setup configuration; the complete task
implementation and ledger stay in the coordinator. implementation and ledger stay in the coordinator.
**Amendment, 2026-09-23 (RT-01a; operator decision of 2026-09-23).** Three readings for the
Game Boy environment of the legacy composition ([legacy-gameboy-v1](legacy-gameboy-v1.md)
sections 8 and 9), stated here because they are about this method's shape:
- *Setup scaffold.* The backend configuration may declare setup frames the environment runs
with every control neutral before it returns O[0]. The legacy composition declares exactly
**one frame with no button down**, which is what its fresh start does; O[0] then has
`engineFrame` `"1"`, `worldTime` `0/1` and no audio chunk. These frames are declared
scaffold, attributed to no fly, and never a transition.
- *Inspection may be artifact-backed.* `inspection` is a `TypedValue` under the descriptor's
schema; the legacy schema `gameboy-memory-inspection-v1` carries a 64-KiB memory image as an
`ArtifactRef` (listed in the bus attachments) plus the ROM's digest. This is the "explicit
artifact-backed schema" section 1 requires for typed state over 32 KiB, and it is the one
bulk transfer per boundary the section 4 rule against per-byte remote reads asks for. The
environment's only write to a running game remains the controller batch.
- *Audio.* The environment publishes native samples in the declared f32 format; converting a
backend's integer samples to f32 is the environment's job (binjgb: `sample / 255`), and any
filtering for listening -- the legacy DC blocker -- is presentation, applied by the edge.
### Environment.Advance ### Environment.Advance
```ts ```ts
@ -319,6 +382,18 @@ but not a port assignment. The coordinator supplies the port. Per-agent executor
private; a running macro may emit controls according to its declared policy, but only after private; a running macro may emit controls according to its declared policy, but only after
neural selection. The first implementation supports the stateless identity executor only. neural selection. The first implementation supports the stateless identity executor only.
**Amendment, 2026-09-23 (RT-01a; operator decision of 2026-09-23).** The legacy composition
declares the extension **`executor: pokered-macros-v1`**: its task and its action executor are
**one object** implementing both interfaces above, serving one agent on one port
([legacy-gameboy-v1](legacy-gameboy-v1.md) section 10). They share state that neither could
reach through a declared channel if split: the executor's scene observation is the `bound` set
the task hands the decoder, the macros read the task's exploration ledgers, and the macro layer's
progress signal feeds the ratchet. The executor reads the boundary's memory image and the ROM
`AssetRef`, never the emulator. "Stateless identity executor only" remains true of the
synthetic composition; a stateful executor is permitted exactly where a composition declares
one by name, and its state is captured or declared transient by that composition's restore
semantics (state-media-v1 section 4 amendment).
The executor's currentGameState is a coherent read-only inspector view at this boundary; The executor's currentGameState is a coherent read-only inspector view at this boundary;
progressView supplies task history/objectives. It updates its selected action every step progressView supplies task history/objectives. It updates its selected action every step
(movement, path replanning, interaction, completion), not merely replaying a blind button (movement, path replanning, interaction, completion), not merely replaying a blind button
@ -342,6 +417,15 @@ not arbitrary raw inspector memory or incoming chat.
It requests a coordinator-owned policy transition after final reward commit; it cannot reset It requests a coordinator-owned policy transition after final reward commit; it cannot reset
the environment directly. Generic progress is a TypedValue, not mandatory Pokémon ladder data. the environment directly. Generic progress is a TypedValue, not mandatory Pokémon ladder data.
**Amendment, 2026-09-23 (RT-01a; operator decision of 2026-09-23).** `episodeRequest.kind` is
`"terminal"` or **`"rollback"`**. A rollback request asks for the composition's declared
rollback policy; its `outcome` is that policy's registered schema. The only policy defined is
`legacy-ratchet-rollback-v1` (outcome `{slotId, trigger}`), applied at the boundary the
transition just committed without a pause ([step-v1](step-v1.md) section 6 amendment). A
composition that declares no rollback policy treats the request as a task failure. It still
"cannot reset the environment directly": the coordinator applies the policy through the
section 7 methods.
## 5. Admission and audience boundary ## 5. Admission and audience boundary
The first synthetic implementation has no audience input. Later integration maps permitted The first synthetic implementation has no audience input. Later integration maps permitted
@ -356,6 +440,19 @@ v2 contract must specify accepted/applied/rolled-back/aborted states and reconci
paid interactions are enabled. Do not inherit a claim of durable exactly-once stimulation paid interactions are enabled. Do not inherit a claim of durable exactly-once stimulation
from these in-memory worker request caches. from these in-memory worker request caches.
**Amendment, 2026-09-23 (RT-01a; operator decision of 2026-09-23).** Sugar in the legacy
composition is admitted by the coordinator with the legacy rules -- the rate limiter and "no
overlap with an active pulse" -- reading the pulse from `stimulusRemainingMs` of the **last
completed commit**. The operator accepted that this value can be one commit old; both
consequences are bounded to one frame and are listed in
[legacy-gameboy-v1](legacy-gameboy-v1.md) section 15. Admitted sugar is a profile-supported
`reward-pulse` stimulus in the next Prepare's `preStepStimulations`. Because admission and
application are now separate, each admission record carries its interaction id and ends
`applied` or, if the epoch fails before that Prepare commits, `aborted`; the edge fulfils the
first and refunds the second, and the slice wiring the bridge tests both. This is the
accepted/applied/aborted distinction the paragraph above asks for, for this one interaction
kind; paid interactions in general still need the public v2 contract.
## 6. Health, shutdown and extensions ## 6. Health, shutdown and extensions
All workers implement the common Hello/Status/Shutdown/Acknowledge methods. Capture/restore All workers implement the common Hello/Status/Shutdown/Acknowledge methods. Capture/restore
@ -365,3 +462,56 @@ advertised. Unsupported methods return `UNSUPPORTED`, mutation none.
New task-specific fields belong in registered TypedValue schemas. New worker capabilities, New task-specific fields belong in registered TypedValue schemas. New worker capabilities,
variable-duration stepping, subscriptions or additional sensor modalities require a contract variable-duration stepping, subscriptions or additional sensor modalities require a contract
change and shared fixtures. An unconstrained plugin dictionary is not a substitute for that. change and shared fixtures. An unconstrained plugin dictionary is not a substitute for that.
## 7. Extension methods (amendment, 2026-09-23)
**Amendment, 2026-09-23 (RT-01a; operator decision of 2026-09-23).** The operator decided on a
full port of the live fly, whose ratchet rolls the *game* back to a saved state while the brain
continues. Neither half of that is expressible with sections 2 and 3: there is no method that
saves or restores world state outside a coherent group checkpoint, and none that installs a new
input in an agent without a transition. These three methods are that, generically shaped, each
behind a capability a worker advertises in `Worker.Hello`; a worker without it answers
`UNSUPPORTED`, mutation none. Payloads are in `fly-session-types` (`extensions`) and
`@flybrain/session-types`, in the session schema set.
```ts
// Environment.SaveSlot -- capability gameboy-slots-v1. Scope: the committed boundary (e, k).
interface SaveSlotParams { slotId: Id } // a slot the composition declares
interface SaveSlotResult { slotId: Id; boundary: U64; // == scope.step
stateDigest: Digest; byteLength: U64 }
// Environment.RestoreSlot -- capability gameboy-slots-v1. Scope: (e', k), a NEW epoch.
interface RestoreSlotParams { slotId: Id; priorEpoch: Id; // the environment is Ready(e, k)
policy: "legacy-ratchet-rollback-v1" }
interface RestoreSlotResult { slotId: Id; committedStep: U64; // == k: no transition ran
observation: WorldObservation } // boundary k, no audio chunk
// Agent.Rollback -- capability legacy-ratchet-rollback-v1. Scope: (e', k), the same new epoch.
interface AgentRollbackParams { agentId: Id; priorEpoch: Id; // the agent is Ready(e, k)
policy: "legacy-ratchet-rollback-v1";
input: SensoryInput; // boundary k: the restored world
decisionContext: TypedValue } // for the next Prepare
interface AgentRollbackResult { agentId: Id; committedStep: U64; // == k: no tick ran
decisionContextDigest: Digest; telemetry: AgentTelemetry }
```
- Both environment methods run only at a committed boundary with no Advance outstanding.
`SaveSlot` replaces the slot's contents with the world state and the frame on screen at that
boundary; slots are environment state and belong to its `State.Capture` payload. It is one
operation per boundary under the section-5 operation key of [session RPC](ipc-v1.md).
- `RestoreSlot` and `Agent.Rollback` move a participant from `(priorEpoch, k)` to
`(scope.epoch, k)`; `priorEpoch` must differ from the scoped epoch, and after the move every
request scoped to the prior epoch is `STALE_EPOCH`. The restored observation keeps the
boundary number, `worldTime` and `engineFrame`, and returns fresh artifacts; it carries no
audio chunk, and the next chunk marks a discontinuity (state-media-v1 section 2).
- `Agent.Rollback` applies the policy's agent half and nothing else: for
`legacy-ratchet-rollback-v1`, clear decoder holds and plastic eligibility, install `input`
without a tick, reset the readout's transient (held channel, blocked window, location from
the context), keep the brain clock, membrane, RNG, rates and gains. No reward, stimulation or
calibration. It is the only way to install an input outside a Commit.
- A failure of any of these methods mid-policy fails the epoch; recovery is the coherent group
restore of [state-media-v1](state-media-v1.md) section 6. There is no partial rollback.
- `maxSlots` is 4 (a crate-chosen bound, published in the schema set).
The sequence that uses them is [step-v1](step-v1.md) section 6's amendment and
[legacy-gameboy-v1](legacy-gameboy-v1.md) section 11.

View file

@ -177,6 +177,10 @@ achievements.
| Wild win | +0.1, +0.05, +0.0333 | at most three observed wild KOs per `(map,species,level)` | | Wild win | +0.1, +0.05, +0.0333 | at most three observed wild KOs per `(map,species,level)` |
| Badge | +3 | each newly set badge bit | | Badge | +3 | each newly set badge bit |
That is the prototype's catalog as it shipped, kept here as history. The live one is
`docs/rewards-learning.md`: `pokered-unique8-v7` adds `boundary` (v5), `catch` (v6), and `talk` and
`item` with no `boundary` payout indoors (v7, the operator's decision of 2026-09-23).
Every value is positive; there were no loss or blackout penalties. Values in a frame summed into Every value is positive; there were no loss or blackout penalties. Values in a frame summed into
`R`, then `m = tanh(R)`. PAM stimulation ran 80 to 400 ms depending on reward kind, with `R`, then `m = tanh(R)`. PAM stimulation ran 80 to 400 ms depending on reward kind, with
overlapping pulses taking their maximum. overlapping pulses taking their maximum.

View file

@ -19,6 +19,16 @@ the project."
on the encoder output and `/status`, and records the release in `docs/stream-mvp-plan.md`. on the encoder output and `/status`, and records the release in `docs/stream-mvp-plan.md`.
No approval round trip with the operator; he sees the record. No approval round trip with the operator; he sees the record.
## Unstick while a fix cooks (the operator, 2026-09-23)
"lets unstick the fly so stream stays interesting. lets make that a rule. while a fix cooks,
revert." When the watcher confirms a trap and its review agent is dispatched, the coordinator
restarts flysim only (claimed in the host log): a restore starts with empty macro ledgers and
keeps the rung. A recurrence gets another restart; only a fly still trapped about ten brain
minutes after a restart is rolled back with `fly-reset-to-milestone`. This is an action outside
the fly: it presses no button and changes no ROM, readout, catalog or adapter, and it never
replaces the fix, which still ships through the loop above.
## The ethos check (every one must hold, or the fix is not shipped) ## The ethos check (every one must hold, or the fix is not shipped)
- The ROM is never modified. Game memory is read, never written. The only write into the - The ROM is never modified. Game memory is read, never written. The only write into the

View file

@ -99,6 +99,17 @@ argmax of the same normalized scores over the same rates. It is the same kind of
the retina already sees, arriving through a much narrower channel, and it is disclosed on the the retina already sees, arriving through a much narrower channel, and it is disclosed on the
honesty panel with the rest of the readout. honesty panel with the rest of the readout.
**Where the location comes from on the session framework (2026-09-23).** When the live fly runs
on the session framework (the operator's port decision of 2026-09-23), the sim loop that owned
the position is split: the task reads the location, the agent owns the decoder. The location
then reaches the decoder as a **declared** field of its decision context,
`gameboy-readout-context-v1 {boot, bound, location}`
([legacy Game Boy composition](design/session-framework/legacy-gameboy-v1.md) section 5). The rule is
unchanged: the location only restarts the blocked window, `null` is still no information, the
held channel and the window stay the readout's own state, and the blocked channel is still
computed here, never handed in. The profile allowlists the field, and the task cannot put
anything else in it.
## Macro group (2026-09-16) ## Macro group (2026-09-16)
`DecoderConfig.macros` is a second `ExclusiveGroup` with the same fields and the same decision rules `DecoderConfig.macros` is a second `ExclusiveGroup` with the same fields and the same decision rules

View file

@ -1,6 +1,6 @@
# Rewards and learning # Rewards and learning
The live reward catalog of the Pokémon Red adapter, `pokered-unique8-v5`. The code of record is The live reward catalog of the Pokémon Red adapter, `pokered-unique8-v7`. The code of record is
`services/flysim/crates/flybrain-gb/src/pokemon_red/` (`catalog.rs` holds the values, `mod.rs` the `services/flysim/crates/flybrain-gb/src/pokemon_red/` (`catalog.rs` holds the values, `mod.rs` the
gates and the rules); this page says what each rule pays for and why it is allowed to. The gates and the rules); this page says what each rule pays for and why it is allowed to. The
prototype's own `docs/rewards-learning.md` in `fly-plays-pokemon` is where the first seven rules prototype's own `docs/rewards-learning.md` in `fly-plays-pokemon` is where the first seven rules
@ -23,7 +23,10 @@ change what the fly can do.
| `trainer` | `trainer` | +0.5 | 200 ms | Each named `EVENT_BEAT_*` flag once, except the flags classified as story milestones | | `trainer` | `trainer` | +0.5 | 200 ms | Each named `EVENT_BEAT_*` flag once, except the flags classified as story milestones |
| `battle` | `wildwin` | +0.1, +0.05, +0.0333 | 100 ms | At most three observed wild KOs per `(map, species, level)` | | `battle` | `wildwin` | +0.1, +0.05, +0.0333 | 100 ms | At most three observed wild KOs per `(map, species, level)` |
| `badge` | `badge` | +3 | 400 ms | Each newly set badge bit | | `badge` | `badge` | +3 | 400 ms | Each newly set badge bit |
| `boundary` | `explore` | +0.05, +0.10 | 100 ms | First tile adjacent to one of the map's exits, and the exit tile itself; once per `(map, exit)` for the lifetime of the ledger | | `boundary` | `explore` | +0.05, +0.10 | 100 ms | First tile adjacent to one of the map's exits, and the exit tile itself; once per `(map, exit)` for the lifetime of the ledger. **Nothing on an indoor map** (since v7): the exit is still recorded, and pays 0 |
| `catch` | `wildwin` | +0.30, +0.10 | 150 ms | A wild Pokémon kept by a ball: +0.30 for a species this run had never owned, +0.10 for a repeat; at most three payouts per species for the lifetime of the ledger |
| `talk` | `explore` | +0.10 | 100 ms | A conversation the fly opened with a person or a sign **indoors**, paid when its box closes; once per `(map, sprite slot or sign text id)` for the lifetime of the ledger |
| `item` | `explore` | +0.15 | 120 ms | An item ball or a hidden item picked up, on any map; once per item for the lifetime of the ledger |
Every value is positive: there are no loss or blackout penalties, and `catalog::rule("blackout")` Every value is positive: there are no loss or blackout penalties, and `catalog::rule("blackout")`
is `None` by test. The values in one frame sum into `R`, and the network reinforces once with is `None` by test. The values in one frame sum into `R`, and the network reinforces once with
@ -33,6 +36,147 @@ The feed-kind column is `RewardKind::from_adapter` in `services/flysim/crates/fl
`docs/feed-protocol.md` publishes seven counters, and an adapter kind that has no counter of its `docs/feed-protocol.md` publishes seven counters, and an adapter kind that has no counter of its
own shares the nearest one. It still reaches the page as an event with its own label. own shares the nearest one. It still reaches the page as an event with its own label.
Two consequences of that sharing are worth stating rather than discovering. `catch` publishes on
`wildwin` because a catch is a wild battle the fly won by keeping the Pokémon, and *not* on
`pokedex` because the `species` rule already pays for the Pokédex bit the same catch sets --
counting it twice would be the dishonest option. And the stage's ticker copy is keyed on the feed
kind, not on the catalog kind (`apps/stage/src/games/pokemon-red.ts`), so the row for a catch
currently reads "wild win". The event's own label, `CAUGHT #<species>`, is what reaches the event
log, `/status` and the checkpoint. Changing the ticker copy means opening the feed's closed kind
set, which this rule deliberately did not do.
`talk` and `item` publish on `explore`, for the same reason `boundary` does: each is the fly
finding what is in a place -- new ground, a door, a person or sign it opened, an item it picked up
-- at the same quiet scale (0.05 to 0.15). Not `area`, which counts maps and is a notable row; not
`story`, which is the plot; not `wildwin`, which is a battle. No feed kind was added, so
`docs/feed-protocol.md` and the stage's switch statements did not move. The Pokémon Red ticker's
`explore` row still says "new place" ("3 new places" collapsed) for all four: "new find" was
proposed and the operator kept "new place" (2026-09-23), so a conversation or an item reads as a
new place on screen. The event labels -- `TALKED TO #<slot> IN AREA <map>`, `READ SIGN #<id> IN
AREA <map>`, `FOUND ITEM #<item>`, `FOUND A HIDDEN ITEM` -- reach the event log, `/status` and the
checkpoint. Both also reset the stage's stall meter, which counts `explore`: engaging with a
building is progress in the sense the operator asked for.
## Catch rewards
The operator's decision of 2026-09-22: the fly is paid for *keeping* a wild Pokémon, not only for
knocking one out. The rule is one kind with two payouts, the way `boundary` is.
**How a catch is read.** From `wCapturedMonSpecies` (`$d11c`), whose comment in `ram/wram.asm` at
the pinned commit is "0 if no mon was captured". `ItemUseBall` zeroes it before every throw
(`.canUseBall`) and writes `wEnemyMonSpecies` into it only on the branch that keeps the Pokémon;
`UseBagItem`'s `.returnAfterCapturingMon` zeroes it again and sets `wBattleResult` to 2 on the way
out of the battle. `wBattleResult` is 2 on exactly two paths in the whole game -- that one, and a
link battle whose opponent ran -- so requiring both the species and the result means a byte read
out of a half-initialised battle cannot pay. The adapter records the species during the battle and
pays on the way out, where the wild-KO payout already lives.
Not from `wPartyCount`. A catch with a full party raises `wBoxCount` instead, and `wPartyCount`
also rises for a gift, a trade and a Pokémon taken out of the PC, so it would need a second rule
to mean anything. The cartridge's own flag needs none.
**What counts as a new species.** The `species` payout inside the same battle. Nothing but a catch
can set a `wPokedexOwned` bit during a wild battle, so a `species` payout between the battle
starting and the ball keeping the Pokémon *is* that Pokémon being new to the run. It is read this
way rather than off `wCapturedMonSpecies` because that byte is the cartridge's **internal** species
index while the owned bitset is by **Pokédex number**, and nothing in WRAM converts between the two
(`docs/design/macros-wram.md` section 2, "species numbering"). A battle restored from a checkpoint
written before this rule existed carries no "species payouts when it started", which reads as
"cannot tell" and pays the repeat amount: the conservative half, and at most 0.20 once.
**The budget.** Three payouts per species for the lifetime of the ledger, the same cap and the
same reason as the wild-KO rule's three: a species the fly can find over and over is a farm, and
three is enough for the behaviour to be learned. A rollback blocks every species already paid,
exactly as it blocks every wild-KO key already paid, so the same catch cannot be replayed for
reward. A Safari Zone or old-man battle pays nothing, because the whole sample is dropped a step
earlier with a visible mode; a trainer battle pays nothing, because balls cannot be thrown in one.
**The scale.** 0.30 on its own is below a new Pokédex entry (0.50), below a story flag (1.0) and
well below a badge (3.0). A catch of a new species pays 0.80 across two kinds, which sits between
a story flag and a badge -- deliberately, because it is the one event that is both a discovery and
a thing the fly had to do on purpose.
## Engagement rewards
The operator's decision of 2026-09-23, recorded with the port decisions: reward the fly for
engaging *inside* buildings and stop paying it for leaving them. It was chosen over a pad rule and
over weighting the choice, and it is a catalog change -- an operator decision, like the catch
reward -- not a loop-review fix (`docs/loop-review.md`). It answers a shape the loop reviews kept
finding in Pewter: `GO OBJECTIVE` into a building and `GO OUT` straight back, paid for the door on
the way out and for nothing inside.
**Indoors** is two of the cartridge's own tables, and nothing hand-classified
(`pokemon_red/engage.rs`, `indoor`). `CheckIfInOutsideMap` (`home/overworld.asm`) is the game's
outdoor test -- tileset `OVERWORLD` or `PLATEAU` -- and `WarpFound2` labels its other branch
`.indoorMaps`; on its own that would call Viridian Forest and every cave indoors, and their exits
are how the fly gets anywhere. `BikeRidingTilesets` (`data/tilesets/bike_riding_tilesets.asm`) is
the list of places the bicycle may be ridden -- `OVERWORLD`, `FOREST`, `UNDERGROUND`, `SHIP_PORT`,
`CAVERN` -- and the bike is the one thing the cartridge refuses inside a building by rule. A map is
indoors when its `wCurMapTileset` is in neither: every house, mart, Pokémon Center, gym, gate, lab
and museum, the S.S. Anne, Silph Co., the Pokémon Tower, the Mansion, the Rocket Hideout and the
Indigo Plateau's rooms. Not the forest, a cave, the Underground Path or Vermilion's dock.
**`talk`, +0.10.** Paid on the sample the text box closes, for a conversation that
1. *the fly opened*: on the last sample before the font bit (`wFontLoaded` bit 0) rose, the fly had
the joypad -- no `wJoyIgnore`, no simulated input, no scripted movement -- was standing still
(`wWalkCounter` zero, the only state the overworld reads A in) and stood where it stands now. A
script's text usually opens with the joypad taken; one opened by a map script the frame after a
step ends can still look opened by the fly, and is then held to rule 2 (below);
2. *is with the thing in front of it*: `DisplayTextID` copies its argument into `wSpriteIndex` --
a sprite slot up to `wNumSprites`, or a text id -- and the sprite must stand on the tile the
player faces (or one further, across a counter, on a tileset that has counter tiles, which is
`IsSpriteOrSignInFrontOfPlayer`'s own long reach), or the text id must be the sign's on that
tile. The byte arrives about **twenty frames after** the font bit (measured on the cartridge:
`DisplayTextIDInit` loads the font's tiles first) and until then still names the previous
text's subject, so it is read once it has changed or 45 samples have passed, and only while the
bottom dialogue box is drawn -- the start menu draws its own box elsewhere. An item ball is a
sprite but not a person, and pays `item`;
3. *opened indoors*, on the map the box opened on;
4. *finished*: the box closed on the same map. A conversation that ends in a warp, a rollback or a
restore pays nothing.
The ledger is the adapter's lifetime `seen` set, keyed `talk:<map>:sprite:<slot>` or
`talk:<map>:sign:<text id>` -- the same "map and object index" the macros' `talked` ledger uses,
but **not** that ledger: the macros' ledger is session state and is thrown away on a restore; this
one is checkpointed and survives a rollback, so talking to the same person again, after a restore
or not, pays nothing. The trainer the fly speaks to before a battle is a person and pays once; the
nurse, a clerk and a sign each pay once per map.
**`item`, +0.15.** Read from the cartridge's own "this one has been taken" bits, on any map.
An item ball is one of the map's toggleable sprites (`wToggleableObjectList`, sprite slot and
global index) whose `wMapSpriteExtraData` is `(item id, 0)` -- the shape `LoadMapHeader` writes for
an `ITEM` `object_event` and for nothing else (a trainer's is `(class, number)` with numbers from
1, a person's two zeroes); `PickUpItem` sets its global bit in `wToggleableObjectFlags` through
`HideObject`, and only after `GiveItem` succeeded, so a full bag pays nothing. A hidden item is a
bit of `wObtainedHiddenItemsFlags`, set by `FoundHiddenItemText` after `GiveItem` and by nothing
else; hidden *coins* have their own bitset and are not items. Either pays when its bit rises
between two playable samples, keyed `item:<global index>` or `hidden:<index>`, once for the life
of the ledger. A gift item from a script (the Old Amber, a TM from a person) is not an item ball:
the conversation pays `talk`, and the item nothing.
**The seed.** The first playable sample that finds the key `items:seeded` absent -- a fresh
adapter, or a `v6` ledger restored under `v7` -- writes a key for every bit the game already shows
as taken and pays for none of them, so a rollback to a slot from before a `v6`-era pickup cannot
pay for taking it again. The two item balls a script *reveals* are left out of the seed, because
their bits are set from a new game until Giovanni's defeat clears them: the Rocket Hideout's Silph
Scope and Lift Key (`$87`, `$88`), the only `ITEM` entries `data/maps/toggleable_objects.asm` starts
`OFF`.
**`boundary` indoors.** Every exit on an indoor map is still written to the ledger, so
`exit_visited` answers exactly what it did and the macros see no change, but nothing is paid. A
town's doors, a route's edges, the forest's gates and a cave's ladders pay as before. One
consequence, measured on the cartridge (`tests/rom_engage.rs`): for the thirty-odd frames of
`PlayMapChangeSound` the cartridge has written the destination into `wCurMap` while the tileset and
the warp table are still the map being left, so the exit the fly is standing on is classified by
the map it belongs to. Walking into a building through a town door still pays that door's on-exit
half, 0.10, once, keyed under the building's id as it always was; walking out pays nothing.
**The scale.** A building's worth of engagement -- a few people, a sign, perhaps a ball -- is
0.3 to 0.6: more than the 0.15 its door paid for being left, less than a new Pokédex entry per
person, far below a badge. Everything is once per thing for the lifetime of the ledger, so no
building can be farmed.
## Gates ## Gates
Semantic rewards are enabled for exactly one cartridge, the SHA-256 in `SUPPORTED_ROM`. Any other Semantic rewards are enabled for exactly one cartridge, the SHA-256 in `SUPPORTED_ROM`. Any other
@ -61,6 +205,10 @@ therefore replays none of it.
## Boundary rewards ## Boundary rewards
Since `pokered-unique8-v7` everything below holds **outdoors** -- towns, routes, the forest,
caves -- and on an indoor map the same keys are written and nothing is paid ("Engagement rewards"
above has the definition of indoors and the one transition frame worth knowing about).
`docs/design/room-escape.md` section 2. The rule pays 0.05 the first time the fly stands on a tile `docs/design/room-escape.md` section 2. The rule pays 0.05 the first time the fly stands on a tile
orthogonally adjacent to one of the current map's exits, and 0.10 the first time it stands on the orthogonally adjacent to one of the current map's exits, and 0.10 the first time it stands on the
exit tile itself. Both are keyed into the adapter's lifetime `seen` ledger as exit tile itself. Both are keyed into the adapter's lifetime `seen` ledger as
@ -128,7 +276,32 @@ body picks the macro; the descending neurons press the buttons.**
## Honesty ## Honesty
The catalog now includes exits. That is worth saying plainly on the honesty panel, because paying The catalog now includes conversations and items (v7). Paying for a conversation is the closest
the catalog has come to paying for a *button*: A is what opens one. It is still a reward, not a
press. Nothing in the adapter presses A, chooses when, or tells the fly who is there; the payout is
read out of WRAM after a conversation the fly's own buttons -- or the macro the mushroom body chose
-- opened and finished, and it is once per person or sign for the life of the run, so the thing
that is learned is "the people in a building are worth a visit", not "press A". It is also why the
rule demands evidence that the fly opened the box. That evidence is not proof: a map script runs
one frame after a step ends and may open text while the fly is still, controllable and on the same
tile. Such text pays only if it names the person or sign the fly is facing, once per key; in the
early game none can (checked: the museum ticket man, the Route 22 and Route 5 guards, Viridian
Mart, Oak's Lab), and a few late ones can once each (the Fighting Dojo master, the Elite Four
after their battles). Review of 2026-09-23.
The catalog also includes catches. The honesty panel's copy is not data-driven from the catalog --
`apps/stage/src/lib/schedule.ts`'s rotating card is four written lines and lists no kinds -- so
there was nothing to regenerate and the copy is unchanged. The sentences below are where the
argument lives.
Paying for a catch does not move the fly: the ball is thrown by a macro the mushroom body chose
among the ones the battle scene put on the pad, and the payout is read out of WRAM after the
frame. What it does do is make one of the palette's existing macros worth choosing, which is the
same kind of pressure every other rule applies. The cap is what keeps it from becoming a farm: a
run that finds one patch of grass and throws balls at the same species all night earns 0.50 from
it and then nothing.
The catalog also includes exits. That is worth saying plainly on the honesty panel, because paying
for a door is closer to telling the fly where to go than paying for a badge is: for a door is closer to telling the fly where to go than paying for a badge is:
- **still no button path.** Nothing in the adapter chooses or biases a button. The reward is read - **still no button path.** Nothing in the adapter chooses or biases a button. The reward is read

View file

@ -640,3 +640,241 @@ then purge; then the stale-doc pass.
the party list and SWITCH the bag since v0.4.0; fixed, THROW BALL now 15/0 in the forest run. the party list and SWITCH the bag since v0.4.0; fixed, THROW BALL now 15/0 in the forest run.
ROM test fails on v0.4.3 and passes here. Ethos check held. Row 41 (a nurse box answered YES ROM test fails on v0.4.3 and passes here. Ethos check held. Row 41 (a nurse box answered YES
1,278 times) is next. 1,278 times) is next.
- 2026-09-22 (v0.4.5, loop review, auto): row 41. In the Pewter center the nurse's conversation is a
ring of 46 A presses with one YES/NO choice; the dialog pad dealt NEXT and YES unconditionally
(one press, two names) and TALK was bound over the counter but recorded one tile ahead, so the
nurse never entered the talked ledger: YES x2,142. Fixed: a readable prompt deals its answers with
NEXT off it, only the answer that changes something is bound, TALK is off at a rested nurse, the
nurse is talked after a heal or a decline, a prompt that reopens unchanged is excluded. ROM test:
leaves the center on frame 326. Hunt: 1 -> 437 tiles, YES 1,424 -> 4. Ethos check held.
- 2026-09-22 (session framework, wave 1): BUS slice merged. The flybus crate is audited section
by section against bus-v1 (195 rows: 178 conform, 9 allowed deviations each quoting the
sentence that permits it, 7 not implemented and owned), the teardown-versus-in-flight-poll
race is fixed (the write gate now separates "closing" from "a poll is in progress", so
teardown waits one poll instead of a frame), the BUS-01 to BUS-03 acceptance bullets are
named tests over both transports with a 29-event trace equivalence, and the guide's first
deliverable exists: one example with a counter RPC, a latest observer and a frame artifact
held past its message. Measured on the dev VM, not capacity claims: 640x480 RGBA at 60 Hz to
three consumers, one delayed, RPC p50 0.5-1.1 ms, seal p50 1.1-1.4 ms, router 0.18-0.22 cores,
RSS 11-17 MB. Two spec contradictions were resolved in bus-v1 rather than in the code (the
per-client byte budget now names bounded queues only, with latest slots capped separately;
the illustrative client sketch drops its budget argument for a caller-side deadline).
- 2026-09-22 (session framework, wave 1 complete): the CONTRACT and SESSION slices merged. There
is now one executable contract for the new architecture: a types crate and a matching
TypeScript package that read the same fixtures, RFC 8785 canonical JSON, a contract digest
generated from the schema set rather than from source formatting, the four identities typed so
they cannot be mistaken for one another, and two new specifications with test vectors for seed
derivation and the checkpoint envelope. On top of it a synthetic lockstep session runs over the
bus: fake agents, a counter world, identity executors and a deterministic task, stepping
prepare, advance, evaluate, commit in that order, with the rational clock proving 16, 17 and 17
ticks and a zero remainder, request deduplication that refuses a changed body and replays a
cached one, and the guide's failure injections as tests that compare an injected run against a
clean one on behaviour, mutation count and world counter. Reviews caught two defects worth
naming: a merge that stopped short of the TypeScript half, whose absence silently took its own
test gate with it, and an unchecked addition that would have wrapped in release. Both fixed
before merge.
- 2026-09-22 (v0.4.6, loop review, auto): two rollbacks on rung 10. The "BACK in a text box" was
a scripted overworld frame with no box (the cartridge holding the joypad) dealt NEXT/BACK; the
museum had no map-graph row so no hop reached the gym; a frontier behind the admission desk
never retired; the ratchet counted covered ground as no progress. Fixed: a scripted frame with
nothing drawn deals nothing; the museum rows are on the graph; an unreachable frontier is a
per-map mark; nearer the objective in map hops counts as progress. ROM test: museum to the gym
interior on 15 macros, TALK on the pad at the leader. The trap hunt did NOT improve on the
reviewer's criterion (tiles 193 -> 175, windows 59 -> 69) because the after arm reaches new
ground with a new trap (row 54: GO FRONTIER, GO HEAL, GO ROUTE cycling, GO HEAL x204 at net 0);
Fable shipped it anyway: the hunt criterion compares within ground both arms reach, and a trap on
newly opened ground is a new row, not a regression. Row 54 review started at once.
## 2026-09-22 - session framework wave 2
Per-fly processes and native observations landed on top of the wave-1 contract and
lockstep session.
- The session runs in three execution modes that share one coordinator, one worker and
one router: in process, one thread per participant, and one process per fly plus one
for the environment over Unix sockets. The worker is a subcommand of the existing
binary, not a new crate. A launcher owns the thread budget, proves each participant's
configured identity and allocation on the wire before the coordinator pins anything,
polls health on its own clock, and reaps its children.
- A caller deadline that expires no longer fails the epoch on its own. It runs the
contract's resolution procedure against the same request and the same incarnation, so
a merely slow participant completes its step, and the epoch fails only on a definite
refusal, a lost incarnation or an exhausted budget. Which of the two bounds ended a
resolution is recorded and named in the failure rather than inferred.
- Failures name the participant, and a failed session fences its epoch: the boundary
stops, handles are dropped, and no further transition or publication is possible.
Worker death, helper death, a router restart mid-advance and stale replies after a
restart all have bounded, diagnosed outcomes, each proved in every mode.
- The environment now emits native output: one shared RGBA frame per boundary reaching
both flies through owned attachments, and one audio chunk per transition on an exact
rational sample budget. Observation delay is a real queue, so nothing stale can be
substituted. Spectators watch a latest subscription with finite credits and cannot
perturb what the flies sense. Audio never enters sensory input.
- Every media rule is enforced rather than assumed: strides, dimensions, formats,
lengths, producing step, timeline continuity, and the distinction between a persistent
asset and a transient artifact. A missing or malformed frame or chunk fails its step.
- Three contract silences were closed by dated amendments rather than by convention: the
launcher's thread allocation is now on the wire in the worker's hello, the audio
discontinuity rule is one-directional, and a mid-step pause is defined.
Measured on the development box, not capacity claims: with two flies the critical path
per transition sat near 10 to 12 ms at the median across all three modes, so a process
boundary costs little at the median and shows in the tail. What the split costs is
memory, roughly 5.7 MiB per participant process, while the coordinator's own footprint
is flat and lowest once the workers leave it.
Two flaky bus tests predating this work assert timing rather than contract and are being
rewritten separately.
- 2026-09-22 (v0.4.7, loop review, auto): row 54 in Pewter and on Route 3. The player's coordinates
change at the END of a sixteen-frame step, so the whole-map grid refused every moving frame and
walks fell back to the window; a landing tile went unrecorded for fifteen frames and stayed a
frontier; an errand aimed at a door underfoot settled where it stood; the errand ledger was
session state and re-armed on restore. Fixed: the walk anchor is measured from the screen
neighbourhood, landing tiles retire, errands arrive inside facing the counter, the errand ledger
persists. Route 3's north edge is a survey item (table says west+east). From the Pewter checkpoint
the fly wins the Boulder Badge at 10.78 brain minutes. The hunt's flagged windows did not fall
(73/73) because 82% of the fixed run is battle time; Fable shipped it on the same judgement as
v0.4.6 and started row 50 (MOVE n blocked on an unresponsive move list). The on-screen chat ring
now survives a sim restart (sidecar in the hot dir, never in the checkpoint).
- 2026-09-22 (v0.5.0, the operator's decision): the fly is paid for keeping a wild Pokémon. New
catalog kind `catch` (0.30 for a species this run never caught, 0.10 for a repeat, three payouts
per species), read from the captured-species byte and the battle result together; the existing
species rule still pays on top. Adapter `pokered-unique8-v6`; the compatibility string differs
in the adapter segment only, and a deploy with `FLY_ACCEPT_ADAPTERS=pokered-unique8-v5` migrates
a v5 checkpoint instead of refusing it. `fly-reset-to-milestone <N>` restarts the run from a
ladder rung (archives both stores first). The live run restarts from rung 7 with this release, so
the ladder is climbed again with the catch reward and the row-54 walks in place.
- 2026-09-22 19:59 UTC (v0.5.0 deployed): rung 7 had no milestone archive (the ratchet passed it
inside one commit), so the run restarted from rung 8, VIRIDIAN CITY, with
`fly-reset-to-milestone 8`; the v5 checkpoint migrated to v6 as designed. The previous state is
archived beside the store.
## 2026-09-22 - session framework: what landed and what stopped
The session framework slices from docs/design/session-framework/implementation.md were built
in ordered waves, each on its own branch with an independent review before merge.
Landed on main: CONTRACT-01, BUS-01 through BUS-03, SESSION-01, SESSION-02, MEDIA-01,
STATE-01 and PUBLISH-01. Together they give the repo an executable session contract with a
TypeScript oracle, a conforming bus with a written conformance table, a lockstep session that
runs in process, on threads or as one process per fly, native frame and audio observations
with spectator isolation, a coherent all-participant checkpoint with group restore and a
liftable fence, and an internal publication boundary with committed snapshots over the same
bus. Every contract silence met on the way was closed by a dated amendment in the affected
document rather than by convention; none touched doctrine.
Also landed: the bus test suite asserts guarantees rather than the machine's timing, and a
coordinator defect found through one of those flakes is fixed, where a lifecycle
acknowledgement that legitimately releases nothing was treated as a fault.
Stopped: AGENT-01 and ENV-01 are blocked on FOUNDATION-02 and RUNTIME-01 from the MaleCNS
backlog, which do not exist yet. The profile contract and the environment boundary are
decisions for the operator, so the swarm stopped here. DOLPHIN-01 was never in scope.
Measured on the development box, not capacity claims: bus RPC near one millisecond at the
median; a two-fly transition near 10 to 12 ms at the median in every execution mode; about
5.7 MiB per participant process when split.
- 2026-09-22 (v0.5.1, loop review, auto): row 50. The cartridge never clears the move-list cursor
bytes after a turn, so every frame of a turn's text, animation and reply read as the fly's own
turn on an open list; the pad dealt MOVE 1-4 and BACK on all of them and the cursor step pressed
at a list nobody was reading. Surveyed by pressing: a press was honoured on 231 of 231 frames
where the menu box is drawn and on none where it is not. Fix: one gate on the drawn box in the
battle seam; a frame with no box is between turns, NEXT only. From the forest checkpoint MOVE n
blocked starts 838 -> 0, every battle entered is ended, worst battle 503 -> 283 macros; hunt
tiles up on both arms (296 -> 430 forest, 163 -> 184 Route 3), flagged windows again rise
because the fly is inside battles it is fighting (same judgement as v0.4.6). Next row: the
battle bag's list id outlives the bag the same way.
- 2026-09-22 (v0.5.2, loop review, auto): row 55, opened by the v0.4.7 errand change. A mart's
list id says the counter is open, not which screen is up, so the clerk's closing text box read
as an open buy list, and the buy list scrolls, so a stock position past the third row is never a
cursor index. BUY ANTIDOTE aimed at row 3 of a list reporting one row, blocked on frame 0 with
no button pressed, and was dealt again every hold. Fix: the shop screen is read from what the
game draws (a clerk's text box lists nothing, so a cursor step waits), and a purchase past the
three cursor rows is not on the pad. From the live checkpoint the fly leaves the mart in 0.19
brain minutes where it never left before; hunt tiles 1 -> 62, blocked starts 1,494 -> 82.
- 2026-09-23 (v0.5.3, loop review, auto): row 56, the Pewter Gym guide. His YES/NO box is drawn
at a different place from the one row 41 pinned, so the seam read no prompt on any of the
frames it was up, dealt NEXT beside YES and NO on a choice, and every NO un-armed the pending
TALK, so the guide never entered the talked ledger and his fifty-two-press ring ran for hours.
Fix: the two-option border is found around the cursor the game parks in it, read whole; which
kind a NO was is decided when the box closes. From the live checkpoint the fly leaves the gym
on frame 1,337 where it never left in twenty brain minutes; hunt tiles 83 -> 441, dialog frames
32,496 -> 2,849. Rung 11 is not reached on either hunt arm: GO OBJECTIVE is on the Pewter pad
and the fly picks the frontier instead, which is the fly's choice, not a trap. The watchdog's
distinct-macro rule cannot see a four-macro ring; the watcher's zero-progress check covers it.
- 2026-09-23 (v0.5.4, loop review, auto): row 57, Pewter City. The pad was GO ROUTE alone,
refused `no route` every 0.8 s for over two hours with no button pressed; the watchdog counted
starts only and saw one. Mechanism: the youngster's escort walls the tile each escorted walk
set out from, up to 26 tiles away, until the fly stands in a pocket no route leaves; every
refusal re-stamped the gym door's blocked window, so GO OBJECTIVE never came back. Fix: an
escort walls the tile where the script took over; a refusal that teaches the ledger nothing is
not dealt again from that tile for the window; a last resort that cannot reach the objective's
door takes a way out it can. ROM test: refusals in a row 746 -> 1, out of the pocket on frame
517 instead of 36,325; seeded hunt tiles 303 -> 439, flagged windows 70 -> 66 of 73 counting
refusals as decisions. Check 10 now counts outcomes (refused, blocked, done) and flags
`stalled` and `zero-progress`, still never acting. The GO OBJECTIVE / GO OUT ring at the gym
door is row 58. Ethos check held. While it cooked the operator's new unstick rule kept the
stream moving with flysim restarts (loop-review.md).
- 2026-09-23 (v0.5.5, loop review, auto): row 58, the Pewter Gym door. GO OBJECTIVE walked in,
GO OUT walked out, for hours, with no reward; restarts bought twenty to forty minutes each.
Mechanism: the objective's people were read from the sprites the cartridge draws, and from the
doormat Brock and the Jr. Trainer are off screen, so with the guide talked the rung had nobody
and the gym dealt its ways out. Three moments the game holds the joypad (a warp's first frames,
a trainer's challenge before the battle screen, a trainer walking up) wrote the fly's ledgers.
Fix: the objective reads off-screen people by the cartridge's own availability rule; facing any
of them is arrival; a warp's tear deals an empty pad (at most 90 frames); `controllable` reads
`wCurOpponent`; ledger writes wait for the joypad to come back and drop if a battle starts.
ROM test: base 40 arrivals, 38 straight back out; branch reaches Brock. Route survey from the
live checkpoint, 33 brain minutes: GO OUT 1,118 -> 0, rung 10 -> 11, BOULDER BADGE. The trap
hunt's stub readout never walks the ring on either arm (flagged 17 -> 23, all one-tile windows
in the gym's dialogs and battles; tiles 329 -> 437), so the proof is the ROM test and the
survey: the ethos check held in spirit, recorded as a deviation from the hunt's letter. Check
10 gains `unrewarded` (100+ decisions, no reward, no new ground, two probes), still never
acting. Also shipped: EDGE-01, the feed over flybus behind FLY_FEED_VIA, off (`direct`)
everywhere. Next: row 59, Route 3's neighbours and Mt. Moon's doors in the geography table.
- 2026-09-23 (the operator's reset): the live run restarted from milestone 1, the bedroom, with the
brain as it was then (65 brain seconds), to watch the macros in the early game. The v5 archive
migrated to v6. It reached rung 9, Viridian Forest, 31 minutes later.
- 2026-09-23 (v0.6.0, the operator's decision, plus loop review row 60): the fly is paid for
engaging inside buildings and not for leaving them. New catalog kinds `talk` (+0.10, the first
conversation the fly opens with each person or sign on an indoor map, once per key for the run)
and `item` (+0.15, each item ball or hidden item, once); `boundary` pays nothing for an indoor
exit. Indoor is the cartridge's own tables (not CheckIfInOutsideMap's outdoor tilesets and not a
bike tileset). Both publish on `explore`; the ticker still reads "new place" (the operator kept
the wording). Adapter `pokered-unique8-v7`; a deploy with FLY_ACCEPT_ADAPTERS=pokered-unique8-v6
migrates the run and seeds every item the game already shows taken without paying it. Row 60:
from the fresh run, TAIL WHIP (MOVE 2) was dealt beside TACKLE after the cartridge had begun
refusing it ("Nothing happened!": the stage at -6 or the stat at 1), and the brain's MOVE 2
preference drew battles out until Squirtle fainted. A move the cartridge would refuse -- read
from its move table and the target's stages, status, types, Mist and substitute -- is not dealt
beside one that works, as a move out of PP is not. ROM test: 2,657 of 2,657 own-turn frames dealt
the refused move before, 0 of 568 after; battles won 0 -> 3 of 12 -> 14. The real-brain hunt did
not finish at this load; recorded as a deviation. Ethos check held.
- 2026-09-23 (v0.6.1, loop review, auto): row 59, Mt. Moon. With the Boulder Badge the live fly
reached rung 12 and rang on Route 4 (GO ROUTE 233, GO OBJECTIVE 122, GO OUT 111 per ten minutes,
one new tile), and restarts did not hold: the map graph was wrong, not a ledger. The geography
table is now the cartridge's own headers and warps: Route 3 connects north to Route 4, Mt.
Moon's doors are on Route 4 (1F at (18,5), B1F's exit at (24,5)); Routes 14/15 and 24/25 were
in the wrong compass columns and Routes 22/23 were missing; a map can be several pieces (Route
4 two, Mt. Moon B1F four chambers, B2F three), the fly's piece read from the decoded grid and a
warp landing in the piece that holds its destination; sea and fence connections (Pallet/21,
Cinnabar/20, 20/19, 22/23) are no longer exits. A trainer's challenge leaves five frames of
plain overworld before the battle, and a push-back is now written only after thirty frames of
the fly's own. From the live Route 4 checkpoint: crossings of the west doors 56 -> 13, into Mt.
Moon at frame 278; from the badge checkpoint the fly reaches Mt. Moon and rung 12 where the base
rang at Pewter. The reviewer checked all 36 connection rows and every piece against the
disassembly. The trap hunt was not run at this load (recorded deviation). Also shipped: FND-01,
one LegacyFrame for the service and every harness (no live behaviour change), FLY_TRACE and the
sugar journal. Ethos check held.
- 2026-09-24 (v0.6.2, loop review, auto): row 61, the Viridian Forest corridor. In the fresh run the
fly rang between the forest, its south gate and Route 2 (GO OBJECTIVE / GO OUT / GO ROUTE) for
twenty minutes at rung 9. A Bug Catcher stands on the forest's only corridor north; when he saw
the fly, the frames of his "!" bubble and the five frames between his text and the battle read as
the fly's own overworld, and a held push-back walled (1,18) for good, so GO OBJECTIVE had no road
north. The macros now read the cartridge's BIT_TRAINER_BATTLE (wStatusFlags7 bit 3, set at the
"!", cleared after every battle, a lost one too) and treat those frames as the cartridge's: no
pad, no ground recorded, no push decided. Row 59's 30-frame debounce already closed the five-frame
gap; this is the cartridge-fact layer under it. ROM test: base walls (1,18) and stays at rung 9;
branch reaches Pewter on frame 23,755, and offers no button on any of the 67 challenge frames
(67 of 67 before). Survey seed 7: rung 9 -> BOULDER BADGE. The trap hunt's stub cannot see this
trap; the ROM test and survey are the proof (recorded deviation). Ethos check held.

95
infra/05-deploy.sh Executable file → Normal file
View file

@ -112,6 +112,11 @@ fi
require_pve_host require_pve_host
need pct need pct
# Who serves the feed (docs/design/flybus.md): refused here, before section 1 flips
# /opt/fly/current, rather than half way through the deploy or at flysim's boot.
FLY_FEED_VIA_EFFECTIVE="$(feed_via_normalize "${FLY_FEED_VIA:-}")" \
|| die "05-deploy: FLY_FEED_VIA must be 'direct' or 'bus', got '${FLY_FEED_VIA}'"
# CHROMIUM_PROFILE is validated here, not left to the launcher: a typo or a # CHROMIUM_PROFILE is validated here, not left to the launcher: a typo or a
# `vgl` on a container that never had VirtualGL installed would only show up as # `vgl` on a container that never had VirtualGL installed would only show up as
# flystage refusing to start, i.e. a black stream, minutes after the deploy # flystage refusing to start, i.e. a black stream, minutes after the deploy
@ -186,6 +191,36 @@ else
log "05-deploy: CPUSET unset — heavy in-container steps run unpinned (no partition configured)" log "05-deploy: CPUSET unset — heavy in-container steps run unpinned (no partition configured)"
fi fi
# Whether the only difference between two compatibility strings is the adapter
# segment, and FLY_ACCEPT_ADAPTERS names the adapter the live checkpoints carry.
#
# The bash half of flybrain_gb::compatibility::decide, which is what flysim
# itself applies at restore. Both have to agree: a gate that let a deploy
# through and a flysim that then refused every checkpoint would be the black
# stream this whole section exists to prevent. The string is
# {kernel}/{adapter}/{fingerprint}/{plasticity}/binjgb:{rev}/pokered:{commit}/statefmt:{id},
# so the adapter is segment 1 and nothing else may move.
adapter_migration_accepted() {
local live="$1" new="$2" accepted="$3"
local -a live_parts new_parts
IFS='/' read -r -a live_parts <<< "$live"
IFS='/' read -r -a new_parts <<< "$new"
[ "${#live_parts[@]}" -eq "${#new_parts[@]}" ] || return 1
local i differing=0 index=-1
for ((i = 0; i < ${#live_parts[@]}; i++)); do
if [ "${live_parts[$i]}" != "${new_parts[$i]}" ]; then
differing=$((differing + 1))
index=$i
fi
done
[ "$differing" -eq 1 ] && [ "$index" -eq 1 ] || return 1
local entry
for entry in ${accepted//,/ }; do
[ "$entry" = "${live_parts[1]}" ] && return 0
done
return 1
}
# cpu_pin CMD [ARGS...] — run CMD inside the container on the page cpus. # cpu_pin CMD [ARGS...] — run CMD inside the container on the page cpus.
# Falls through to a plain ct_exec when no partition is configured, so this is # Falls through to a plain ct_exec when no partition is configured, so this is
# a no-op on an unpartitioned container rather than a new failure mode (a # a no-op on an unpartitioned container rather than a new failure mode (a
@ -258,9 +293,21 @@ if [ -n "$RELEASE_TARBALL" ]; then
# BEFORE the symlink moves. Cost: one dataset load, a second or two. # BEFORE the symlink moves. Cost: one dataset load, a second or two.
# #
# FLY_RESET_STATE=1 is the deliberate override: it archives the durable # FLY_RESET_STATE=1 is the deliberate override: it archives the durable
# checkpoints (kept, never deleted) and clears the tmpfs hot ring, so the # checkpoints (kept, never deleted) and clears the tmpfs hot ring — the hot
# new build warms up fresh. Everything learned so far is thrown away, which # checkpoints and the on-screen chat ring's sidecar — so the new build warms
# is why it is not the default. # up fresh. Everything learned so far is thrown away, which is why it is not
# the default.
#
# FLY_ACCEPT_ADAPTERS is the *other* override, and the opposite one: it keeps
# the run. It names adapter version strings whose checkpoints the new build
# may migrate — e.g. FLY_ACCEPT_ADAPTERS=pokered-unique8-v6 for the deploy
# that adds the engagement rewards (v7; v5 -> v6 was the catch reward's).
# It only applies when the adapter segment is the
# ONLY difference between the two strings and the new build's adapter says it
# can read that one; a dataset, kernel, emulator or state-format change is
# still a refusal, because none of those has a migration. The same variable is
# written into /etc/fly/fly.env below, so flysim applies the same rule at
# restore that this gate applied at deploy.
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
state_dir="${FLY_STATE_DIR:-/srv/fly/state}" state_dir="${FLY_STATE_DIR:-/srv/fly/state}"
hot_dir="${FLY_STATE_HOT_DIR:-/run/fly/state}" hot_dir="${FLY_STATE_HOT_DIR:-/run/fly/state}"
@ -286,6 +333,11 @@ if [ -n "$RELEASE_TARBALL" ]; then
log "05-deploy: no decodable checkpoint in ${state_dir} — nothing to compare, continuing" log "05-deploy: no decodable checkpoint in ${state_dir} — nothing to compare, continuing"
elif [ "$new_compat" = "$live_compat" ]; then elif [ "$new_compat" = "$live_compat" ]; then
log "05-deploy: checkpoint compatibility matches the live state, the new build will restore it" log "05-deploy: checkpoint compatibility matches the live state, the new build will restore it"
elif [ -n "${FLY_ACCEPT_ADAPTERS:-}" ] \
&& adapter_migration_accepted "$live_compat" "$new_compat" "$FLY_ACCEPT_ADAPTERS"; then
log "05-deploy: FLY_ACCEPT_ADAPTERS=${FLY_ACCEPT_ADAPTERS} — the adapter version is the only difference, and it is named; the run is KEPT and migrated"
log "05-deploy: live: $live_compat"
log "05-deploy: new: $new_compat"
elif [ "${FLY_RESET_STATE:-0}" = 1 ]; then elif [ "${FLY_RESET_STATE:-0}" = 1 ]; then
archive="${state_dir}.$(date -u +%Y%m%d%H%M%S)" archive="${state_dir}.$(date -u +%Y%m%d%H%M%S)"
log "05-deploy: FLY_RESET_STATE=1 — compatibility CHANGED, archiving the durable state to ${archive} and clearing the hot ring" log "05-deploy: FLY_RESET_STATE=1 — compatibility CHANGED, archiving the durable state to ${archive} and clearing the hot ring"
@ -294,13 +346,17 @@ if [ -n "$RELEASE_TARBALL" ]; then
# state_dir is its own mountpoint, so the directory itself cannot be # state_dir is its own mountpoint, so the directory itself cannot be
# renamed; its contents move instead. # renamed; its contents move instead.
ct_exec "$CTID" -- sh -c "mkdir -p '$archive' && mv '${state_dir}'/*.checkpoint '${state_dir}/manifest.json' '$archive'/ 2>/dev/null; chown -R fly:fly '$archive'" ct_exec "$CTID" -- sh -c "mkdir -p '$archive' && mv '${state_dir}'/*.checkpoint '${state_dir}/manifest.json' '$archive'/ 2>/dev/null; chown -R fly:fly '$archive'"
ct_exec "$CTID" -- sh -c "rm -f '${hot_dir}'/*.checkpoint '${hot_dir}/manifest.json' 2>/dev/null; true" ct_exec "$CTID" -- sh -c "rm -f '${hot_dir}'/*.checkpoint '${hot_dir}/manifest.json' '${hot_dir}/chat-ring.json' 2>/dev/null; true"
else else
die "05-deploy: REFUSING to deploy release ${version}: its checkpoint compatibility string does not match the live state in ${state_dir}, so flysim would refuse every checkpoint there and then refuse to start at all — a black stream. die "05-deploy: REFUSING to deploy release ${version}: its checkpoint compatibility string does not match the live state in ${state_dir}, so flysim would refuse every checkpoint there and then refuse to start at all — a black stream.
live state: ${live_compat} live state: ${live_compat}
new build: ${new_compat} new build: ${new_compat}
The difference is usually an adapter/ladder or dataset version bump. Two ways forward: The difference is usually an adapter/ladder or dataset version bump. Three ways forward:
* deploy a build whose string matches (check out the commit the running release was built from), or * deploy a build whose string matches (check out the commit the running release was built from), or
* if the ADAPTER VERSION is the only segment that differs and the new build documents a
migration from the old one, re-run with FLY_ACCEPT_ADAPTERS set to the adapter id in the live
string (e.g. FLY_ACCEPT_ADAPTERS=pokered-unique8-v6). The run is kept; flysim applies the same
rule at restore. See docs/design/flysim.md, \"Restoring across an adapter version\", or
* accept losing everything the brain has learned and re-run with FLY_RESET_STATE=1, which * accept losing everything the brain has learned and re-run with FLY_RESET_STATE=1, which
archives ${state_dir}'s checkpoints to ${state_dir}.<timestamp> (kept, not deleted) and archives ${state_dir}'s checkpoints to ${state_dir}.<timestamp> (kept, not deleted) and
clears ${hot_dir} so the new build warms up fresh. clears ${hot_dir} so the new build warms up fresh.
@ -363,6 +419,7 @@ log "05-deploy: non-secret env files"
# never drift apart (see cpuset_partition's own header comment). They are # never drift apart (see cpuset_partition's own header comment). They are
# assigned in section 0b, which needs them earlier than this for the # assigned in section 0b, which needs them earlier than this for the
# deploy-time cpu pinning; nothing between here and there changes them. # deploy-time cpu pinning; nothing between here and there changes them.
tmp_fly_env="$(mktemp)" tmp_fly_env="$(mktemp)"
tmp_flypush_env="$(mktemp)" tmp_flypush_env="$(mktemp)"
trap 'rm -f "$tmp_fly_env" "$tmp_flypush_env"' EXIT trap 'rm -f "$tmp_fly_env" "$tmp_flypush_env"' EXIT
@ -449,6 +506,16 @@ trap 'rm -f "$tmp_fly_env" "$tmp_flypush_env"' EXIT
# "palette"/"plan" as "macros" with a warning, and refuses an unrecognised # "palette"/"plan" as "macros" with a warning, and refuses an unrecognised
# value outright. # value outright.
echo "FLY_MACRO_MODE=${FLY_MACRO_MODE:-raw}" echo "FLY_MACRO_MODE=${FLY_MACRO_MODE:-raw}"
# Who serves the feed WebSocket (docs/design/flybus.md, "Feed over the
# bus"). "direct" is the default and is flysim binding :7400 itself, as
# every release before this knob. "bus" makes flysim publish on its
# embedded feed bus and leave :7400 to flyedge.service, which this script
# never enables: see that unit's header for the switch. Written
# unconditionally, like FLY_MACRO_MODE, so one grep says which a box runs.
# Watchdog check 2 reads this line to know whose /metrics carries the
# feed counters (flysim's :9101, or flyedge's loopback :9102).
# Validated and lowercased above (feed_via_normalize).
echo "FLY_FEED_VIA=${FLY_FEED_VIA_EFFECTIVE}"
# How long a macro leaves a target alone after a walk to it aborted # How long a macro leaves a target alone after a walk to it aborted
# (macros.md section 12.1, the Viridian stall). Only written when it is set, # (macros.md section 12.1, the Viridian stall). Only written when it is set,
# because the default lives in the crate and a box that has not tuned it # because the default lives in the crate and a box that has not tuned it
@ -456,6 +523,16 @@ trap 'rm -f "$tmp_fly_env" "$tmp_flypush_env"' EXIT
if [[ -n "${FLY_MACRO_BLOCKED_MINUTES:-}" ]]; then if [[ -n "${FLY_MACRO_BLOCKED_MINUTES:-}" ]]; then
echo "FLY_MACRO_BLOCKED_MINUTES=${FLY_MACRO_BLOCKED_MINUTES}" echo "FLY_MACRO_BLOCKED_MINUTES=${FLY_MACRO_BLOCKED_MINUTES}"
fi fi
# Adapter versions whose checkpoints this build may migrate
# (flybrain_gb::compatibility, docs/design/flysim.md "Restoring across an
# adapter version"). Only written when it is set, because the safe state is
# absent: an empty or missing variable migrates nothing, which is what every
# deploy before 2026-09-22 did. It stays in fly.env for as long as the
# operator leaves it on the deploy command line, so removing the opt-in is
# one deploy without it.
if [[ -n "${FLY_ACCEPT_ADAPTERS:-}" ]]; then
echo "FLY_ACCEPT_ADAPTERS=${FLY_ACCEPT_ADAPTERS}"
fi
# flybridge (services/bridge/src/config.ts). Nothing wrote these before, so # flybridge (services/bridge/src/config.ts). Nothing wrote these before, so
# flybridge.service had no EnvironmentFile= at all and the service refused to # flybridge.service had no EnvironmentFile= at all and the service refused to
# start with "CHANNEL is required / BOT_USER is required / GAME_TITLE is # start with "CHANNEL is required / BOT_USER is required / GAME_TITLE is
@ -573,9 +650,11 @@ if [ -n "${CPUSET:-}" ]; then
"leaves cpuset.cpus.effective empty and the unit unstartable." "leaves cpuset.cpus.effective empty and the unit unstartable."
else else
read -r sim_cpus page_cpus encoder_cpus <<< "$(cpuset_partition "$CPUSET" "$RAYON_THREADS_EFFECTIVE" "$ENCODER_CORES_EFFECTIVE")" read -r sim_cpus page_cpus encoder_cpus <<< "$(cpuset_partition "$CPUSET" "$RAYON_THREADS_EFFECTIVE" "$ENCODER_CORES_EFFECTIVE")"
log "05-deploy: cpuset partition — flysim=$sim_cpus, xvfb/flystage/flystage-web/pulse/mediamtx=$page_cpus, flycast=$encoder_cpus" log "05-deploy: cpuset partition — flysim=$sim_cpus, xvfb/flystage/flystage-web/pulse/mediamtx/flyedge=$page_cpus, flycast=$encoder_cpus"
tmp_dropin="$(mktemp)" tmp_dropin="$(mktemp)"
for u in flysim xvfb flystage flystage-web flycast pulse mediamtx; do # flyedge is off by default, but its drop-in is written with the rest so that the day
# it is enabled it serves the page from the page's CPUs, never from flysim's.
for u in flysim xvfb flystage flystage-web flycast pulse mediamtx flyedge; do
case "$u" in case "$u" in
flysim) cpus="$sim_cpus" ;; flysim) cpus="$sim_cpus" ;;
flycast) cpus="$encoder_cpus" ;; flycast) cpus="$encoder_cpus" ;;
@ -606,7 +685,7 @@ fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
log "05-deploy: converging bin/ helpers to /opt/fly/bin" log "05-deploy: converging bin/ helpers to /opt/fly/bin"
ct_exec "$CTID" -- mkdir -p /opt/fly/bin ct_exec "$CTID" -- mkdir -p /opt/fly/bin
for name in fly-watchdog fly-recap fly-retention flypush flystage-launch flycast-launch wait-for-x wait-for-stage wait-for-health; do for name in fly-watchdog fly-recap fly-retention fly-reset-to-milestone flypush flystage-launch flycast-launch wait-for-x wait-for-stage wait-for-health; do
converge_file "$CTID" "$INFRA_DIR/bin/$name" "/opt/fly/bin/$name" 0755 root:root >/dev/null converge_file "$CTID" "$INFRA_DIR/bin/$name" "/opt/fly/bin/$name" 0755 root:root >/dev/null
done done

View file

@ -0,0 +1,77 @@
#!/usr/bin/env bash
# infra/bin/fly-reset-to-milestone — restart the run from an earlier ladder rung,
# instead of from scratch.
#
# The operator's decision of 2026-09-22: "restart the live run from an early
# checkpoint instead of from scratch". 05-deploy's FLY_RESET_STATE=1 cannot do
# that — it archives the durable state and the next start warms up a fresh fly,
# losing everything the brain has learned. This promotes one milestone archive
# (milestone-<N>.checkpoint, written at the first commit at a new best rank and
# never rotated away) to being what both stores restore.
#
# Usage: fly-reset-to-milestone <N>
# Run INSIDE the container, as root, with flysim STOPPED. It refuses
# otherwise, and it refuses a rung this run never reached.
#
# The whole sequence — stop, reset, deploy with the adapter opt-in, start,
# verify the rank — is in infra/docs/runbook.md, "Restart the run from a rung".
# Nothing here is destructive on its own: every file in both stores is copied to
# a dated directory next to the durable one before anything is rewritten.
set -euo pipefail
: "${FLY_STATE_DIR:=/srv/fly/state}"
: "${FLY_STATE_HOT_DIR:=/run/fly/state}"
: "${FLY_RELEASE_DIR:=/opt/fly/current}"
: "${FLY_SERVICE:=flysim.service}"
: "${FLY_USER:=fly}"
FLYSIM="${FLY_BIN:-${FLY_RELEASE_DIR}/flysim}"
log() { echo "fly-reset-to-milestone: $*" >&2; }
die() { log "$*"; exit 1; }
RANK="${1:-}"
if [ "$#" -ne 1 ] || ! [[ "$RANK" =~ ^[0-9]+$ ]]; then
die "usage: fly-reset-to-milestone <rung> (e.g. fly-reset-to-milestone 9)"
fi
# --- refusals ----------------------------------------------------------------
# A running flysim owns both stores: it commits a hot checkpoint every few
# seconds and a durable one every few minutes, so a reset underneath it would be
# overwritten within the minute and the tool would have lied.
if command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet "$FLY_SERVICE"; then
die "$FLY_SERVICE is running. Stop it first: systemctl stop $FLY_SERVICE"
fi
[ -x "$FLYSIM" ] || die "no flysim binary at $FLYSIM (set FLY_BIN to point at one)"
milestone="${FLY_STATE_DIR}/milestone-${RANK}.checkpoint"
# The binary refuses this too, and refuses before it copies anything; checking
# here as well is what makes the message name the rungs that do exist.
if [ ! -f "$milestone" ]; then
log "no milestone archive for rung ${RANK}: $milestone does not exist."
log "rungs this run reached:"
ls -1 "${FLY_STATE_DIR}"/milestone-*.checkpoint 2>/dev/null \
| sed 's|.*/milestone-||; s|\.checkpoint$||' | sort -n | tr '\n' ' ' >&2 || true
echo >&2
exit 1
fi
# --- the reset ---------------------------------------------------------------
log "resetting to rung ${RANK} (durable ${FLY_STATE_DIR}, hot ${FLY_STATE_HOT_DIR})"
FLY_STATE="$FLY_STATE_DIR" FLY_STATE_HOT="$FLY_STATE_HOT_DIR" \
"$FLYSIM" --reset-to-milestone "$RANK"
# flysim runs unprivileged; this tool runs as root, so everything it wrote and
# everything it archived has to go back to the service account.
if command -v chown >/dev/null 2>&1 && id "$FLY_USER" >/dev/null 2>&1; then
chown -R "${FLY_USER}:${FLY_USER}" "$FLY_STATE_DIR" "$FLY_STATE_HOT_DIR" 2>/dev/null || true
for dir in "${FLY_STATE_DIR}".reset-*; do
[ -d "$dir" ] && chown -R "${FLY_USER}:${FLY_USER}" "$dir"
done
fi
log "done. Next, per infra/docs/runbook.md:"
log " 1. deploy the build whose adapter wrote that checkpoint, or deploy the new"
log " one with FLY_ACCEPT_ADAPTERS set to the checkpoint's adapter id"
log " 2. systemctl start $FLY_SERVICE"
log " 3. curl -s localhost:7401/status | grep -o '\"rank\":[0-9]*'"

View file

@ -32,6 +32,9 @@ log_info() {
: "${FLY_CONTROL_URL:=http://127.0.0.1:7401}" : "${FLY_CONTROL_URL:=http://127.0.0.1:7401}"
: "${FLY_METRICS_URL:=http://127.0.0.1:9101}" : "${FLY_METRICS_URL:=http://127.0.0.1:9101}"
# flyedge's loopback /metrics (units/flyedge.service), read by check 2 when
# fly.env says FLY_FEED_VIA=bus.
: "${FLY_EDGE_METRICS_URL:=http://127.0.0.1:9102}"
: "${FLY_STATE_HOT:=/run/fly/state}" : "${FLY_STATE_HOT:=/run/fly/state}"
: "${FLY_MEDIA_DIR:=/srv/fly/media}" : "${FLY_MEDIA_DIR:=/srv/fly/media}"
: "${MEDIAMTX_API:=http://127.0.0.1:9997}" : "${MEDIAMTX_API:=http://127.0.0.1:9997}"
@ -67,6 +70,8 @@ log_info() {
: "${WD_LOOP_MIN_REPEATS:=20}" # ... repeating at least this many times : "${WD_LOOP_MIN_REPEATS:=20}" # ... repeating at least this many times
: "${WD_LOOP_DOMINANCE_PCT:=95}" # or one macro being this share of the window : "${WD_LOOP_DOMINANCE_PCT:=95}" # or one macro being this share of the window
: "${WD_LOOP_MIN_EVENTS:=20}" # floor under the dominance rule (see check 10) : "${WD_LOOP_MIN_EVENTS:=20}" # floor under the dominance rule (see check 10)
: "${WD_LOOP_STALL_PCT:=90}" # decisions that ended refused/blocked/timeout: this share is a stall (row 57)
: "${WD_LOOP_BUSY_MIN:=100}" # this many decisions with no reward and no new ground, two probes running, is busy going nowhere (row 58)
: "${WD_LOOP_REPORT:=${WD_RUN_DIR}/loop.json}" : "${WD_LOOP_REPORT:=${WD_RUN_DIR}/loop.json}"
mkdir -p "$WD_RUN_DIR" "$WD_STATE_DIR" mkdir -p "$WD_RUN_DIR" "$WD_STATE_DIR"
@ -187,7 +192,7 @@ write_textfile_metrics() {
# convention as fly_watchdog_encoder_degraded), while the flag itself # convention as fly_watchdog_encoder_degraded), while the flag itself
# starts at 0, because a 0/1 flag carrying -1 reads as a loop to every # starts at 0, because a 0/1 flag carrying -1 reads as a loop to every
# alert expression that would ever use it. # alert expression that would ever use it.
echo "# HELP fly_loop_suspected 1 when the last check-10 probe found a short macro cycle repeating with no growth in the exploration count. Report only: the watchdog never restarts or presses anything for this." echo "# HELP fly_loop_suspected 1 when the last check-10 probe found a short macro cycle repeating, one macro dominating, or the decisions stalled (refused/blocked) or completing nothing, with no growth in the exploration count. Report only: the watchdog never restarts or presses anything for this."
echo "# TYPE fly_loop_suspected gauge" echo "# TYPE fly_loop_suspected gauge"
echo "fly_loop_suspected $(cat "${WD_RUN_DIR}/loop.suspected" 2>/dev/null || echo 0)" echo "fly_loop_suspected $(cat "${WD_RUN_DIR}/loop.suspected" 2>/dev/null || echo 0)"
echo "# HELP fly_loop_period Length in macro labels of the shortest repeating block found at the end of the window (0 when nothing repeats, -1 before the first probe)." echo "# HELP fly_loop_period Length in macro labels of the shortest repeating block found at the end of the window (0 when nothing repeats, -1 before the first probe)."
@ -199,6 +204,23 @@ write_textfile_metrics() {
echo "# HELP fly_loop_distinct_macros Distinct macro names started in the window (-1 before the first probe)." echo "# HELP fly_loop_distinct_macros Distinct macro names started in the window (-1 before the first probe)."
echo "# TYPE fly_loop_distinct_macros gauge" echo "# TYPE fly_loop_distinct_macros gauge"
echo "fly_loop_distinct_macros $(cat "${WD_RUN_DIR}/loop.distinct" 2>/dev/null || echo -1)" echo "fly_loop_distinct_macros $(cat "${WD_RUN_DIR}/loop.distinct" 2>/dev/null || echo -1)"
# Row 57: outcomes, not only starts. A pad whose one button refuses
# every hold reads as one start and one name; these say what the
# decisions came to (-1 before the first probe).
echo "# HELP fly_loop_refused Macro presses refused (nothing pressed) in the last check-10 window (-1 before the first probe)."
echo "# TYPE fly_loop_refused gauge"
echo "fly_loop_refused $(cat "${WD_RUN_DIR}/loop.refused" 2>/dev/null || echo -1)"
echo "# HELP fly_loop_blocked Macros that ended blocked or timed out in the last check-10 window (-1 before the first probe)."
echo "# TYPE fly_loop_blocked gauge"
echo "fly_loop_blocked $(cat "${WD_RUN_DIR}/loop.blocked" 2>/dev/null || echo -1)"
echo "# HELP fly_loop_done Macros that ended done in the last check-10 window (-1 before the first probe)."
echo "# TYPE fly_loop_done gauge"
echo "fly_loop_done $(cat "${WD_RUN_DIR}/loop.done" 2>/dev/null || echo -1)"
# Row 58: an undo pair diluted by other macros completes everything and
# earns nothing; the reward events in the window are what say so.
echo "# HELP fly_loop_rewards Reward events in the last check-10 window (-1 before the first probe)."
echo "# TYPE fly_loop_rewards gauge"
echo "fly_loop_rewards $(cat "${WD_RUN_DIR}/loop.rewards" 2>/dev/null || echo -1)"
echo "# HELP fly_places_delta Growth in game.uniqueLocations (the exploration count) since the previous check-10 probe. -1 when there is no previous probe to compare against." echo "# HELP fly_places_delta Growth in game.uniqueLocations (the exploration count) since the previous check-10 probe. -1 when there is no previous probe to compare against."
echo "# TYPE fly_places_delta gauge" echo "# TYPE fly_places_delta gauge"
echo "fly_places_delta $(cat "${WD_RUN_DIR}/loop.places_delta" 2>/dev/null || echo -1)" echo "fly_places_delta $(cat "${WD_RUN_DIR}/loop.places_delta" 2>/dev/null || echo -1)"
@ -322,10 +344,32 @@ check_flysim() {
# read-only metrics listener (infra.md section 5; not superseded by the # read-only metrics listener (infra.md section 5; not superseded by the
# feed/control contracts). Flat frames counter across two passes, or zero # feed/control contracts). Flat frames counter across two passes, or zero
# clients, means the page is dead/frozen even though Chromium is alive. # clients, means the page is dead/frozen even though Chromium is alive.
#
# The two counters belong to whoever serves the feed: flysim itself, or with
# FLY_FEED_VIA=bus in fly.env, flyedge (docs/design/flybus.md, "Feed over the
# bus"), which exports them under the same names. FLY_FEED_METRICS_URL in the
# watchdog's own environment overrides both.
# ============================================================================ # ============================================================================
feed_metrics_url() {
if [ -n "${FLY_FEED_METRICS_URL:-}" ]; then
echo "$FLY_FEED_METRICS_URL"
return
fi
local via=""
if [ -f "$FLY_ENV_FILE" ]; then
# Lowercased: flysim reads the value case-insensitively, so `Bus` is bus mode.
via="$(awk -F= '/^FLY_FEED_VIA=/ { print $2; exit }' "$FLY_ENV_FILE" 2>/dev/null | tr -d ' \r"' | tr '[:upper:]' '[:lower:]' || true)"
fi
if [ "$via" = "bus" ]; then
echo "$FLY_EDGE_METRICS_URL"
else
echo "$FLY_METRICS_URL"
fi
}
check_flystage() { check_flystage() {
local metrics frames clients ok=1 local metrics frames clients ok=1
metrics="$(curl -fsS "${FLY_METRICS_URL}/metrics" 2>/dev/null || true)" metrics="$(curl -fsS "$(feed_metrics_url)/metrics" 2>/dev/null || true)"
if [ -z "$metrics" ]; then if [ -z "$metrics" ]; then
ok=0 ok=0
else else
@ -736,10 +780,32 @@ check_capture_freeze() {
# time, not wall time — a box running under 1.0x realtime would otherwise get # time, not wall time — a box running under 1.0x realtime would otherwise get
# a shorter window than the thresholds were measured over), the distinct macro # a shorter window than the thresholds were measured over), the distinct macro
# names in it, and the shortest block up to WD_LOOP_MAX_PERIOD that repeats at # names in it, and the shortest block up to WD_LOOP_MAX_PERIOD that repeats at
# the end of the sequence. Start events only: a start is one decision, and # the end of the sequence. The sequence is the fly's DECISIONS: a `start`, or a
# counting the `done` beside it would double every sequence and halve every # `refused` (a bound button that was pressed and did nothing, with no start
# period. They are the same names trap_hunt prints, so its numbers and these # beside it). Counting the `done` beside a start would double every sequence
# are comparable. # and halve every period. They are the same names trap_hunt prints, so its
# numbers and these are comparable.
#
# Row 57 (macros-traps.md): counting starts alone was blind to a pad whose
# one button refused. `GO ROUTE refused` ~740 times per 10 brain minutes for
# two hours read as 1 start, 1 distinct name, nothing flagged. So the window
# also counts every OUTCOME (done/blocked/timeout/refused), and two rules read
# them, both behind the same "no new ground" gate as the others:
# stalled — WD_LOOP_MIN_EVENTS+ decisions and WD_LOOP_STALL_PCT% of
# them ended refused, blocked or timed out;
# zero-progress — decisions in the window, not one `done` among them, on
# this probe AND the previous one (two probes, so a single
# unlucky window never flags).
#
# Row 58: `GO OBJECTIVE` into the Pewter Gym, `GO OUT` straight back out, for
# 25 minutes, with GO ITEM / GO FRONTIER / YES / NO mixed in — ten distinct
# names, every macro `done`, so neither the sequence rule (four names at
# most) nor zero-progress could fire, and the exploration count sat still.
# What the window did not have was a single reward event. So the stream also
# carries the `reward` events, and one more rule reads them, behind the same
# gate:
# unrewarded — WD_LOOP_BUSY_MIN+ decisions and no reward event in the
# window, on this probe AND the previous one.
# #
# The tile rule is what separates a loop from a legitimately repeating # The tile rule is what separates a loop from a legitimately repeating
# explorer (macros-traps.md: `GO FRONTIER` x19 over 93 tiles is a walk longer # explorer (macros-traps.md: `GO FRONTIER` x19 over 93 tiles is a walk longer
@ -791,31 +857,45 @@ loop_status_fields() {
loop_macro_stream() { loop_macro_stream() {
[ -f "$FLY_EVENT_LOG" ] || return 0 [ -f "$FLY_EVENT_LOG" ] || return 0
tail -n "$WD_LOOP_TAIL_LINES" "$FLY_EVENT_LOG" 2>/dev/null \ tail -n "$WD_LOOP_TAIL_LINES" "$FLY_EVENT_LOG" 2>/dev/null \
| jq -r 'fromjson? // empty | select(.kind == "macro") | "\(.brainMs)\t\(.label)"' -R 2>/dev/null \ | jq -r 'fromjson? // empty | select(.kind == "macro" or .kind == "reward") | "\(.brainMs)\t\(.label)\t\(.kind)"' -R 2>/dev/null \
|| true || true
} }
# loop_analyze — reads that stream on stdin and echoes one tab-separated line: # loop_analyze — reads that stream on stdin and echoes one tab-separated line:
# #
# total US distinct US topCount US period US repeats US windowFrom US # total US distinct US topCount US period US repeats US windowFrom US
# windowTo US topName US block US tail # windowTo US topName US block US tail US decisions US refused US blocked US
# timeout US done US rewards
# #
# `total` counts start events inside the window, `period`/`repeats` describe # `total` counts start events inside the window, `decisions` starts plus
# refusals (the sequence, `distinct` and `topCount` are over decisions), and
# the next four count each outcome in the window, and `rewards` the reward
# events in it (row 58). `period`/`repeats` describe
# the shortest repeating block at the END of the sequence (0/0 when nothing # the shortest repeating block at the END of the sequence (0/0 when nothing
# repeats), `block` is that block comma-joined, and `tail` is the last 12 # repeats), `block` is that block comma-joined, and `tail` is the last 12
# names for context. The window ends at the newest macro event's own brain # names for context. The window ends at the newest macro event's own brain
# clock, not at `now`: brain time is the only clock the event log carries. # clock, not at `now`: brain time is the only clock the event log carries.
loop_analyze() { loop_analyze() {
awk -F'\t' -v win="$WD_LOOP_WINDOW_MS" -v maxp="$WD_LOOP_MAX_PERIOD" ' awk -F'\t' -v win="$WD_LOOP_WINDOW_MS" -v maxp="$WD_LOOP_MAX_PERIOD" '
{ ms[NR] = $1 + 0; lbl[NR] = $2; n = NR } # Reward lines are counted and nothing else: the window still ends at the
# newest macro event, as it always has.
$3 == "reward" { rms[++nr] = $1 + 0; next }
{ ms[++n] = $1 + 0; lbl[n] = $2 }
END { END {
if (n == 0) { printf "0\0370\0370\0370\0370\0370\0370\037\037\037\n"; exit } if (n == 0) { printf "0\0370\0370\0370\0370\0370\0370\037\037\037\0370\0370\0370\0370\0370\0370\n"; exit }
to = ms[n]; from = to - win to = ms[n]; from = to - win
k = 0; distinct = 0; topn = 0; top = "" k = 0; distinct = 0; topn = 0; top = ""
starts = 0; refused = 0; blocked = 0; timeout = 0; done = 0; rewards = 0
for (q = 1; q <= nr; q++) if (rms[q] >= from) rewards++
for (i = 1; i <= n; i++) { for (i = 1; i <= n; i++) {
if (ms[i] < from) continue if (ms[i] < from) continue
if (lbl[i] !~ / start$/) continue if (lbl[i] ~ / blocked$/) { blocked++; continue }
name = lbl[i]; sub(/ start$/, "", name) if (lbl[i] ~ / timeout$/) { timeout++; continue }
if (lbl[i] ~ / done$/) { done++; continue }
if (lbl[i] ~ / refused$/) refused++
else if (lbl[i] ~ / start$/) starts++
else continue
name = lbl[i]; sub(/ (start|refused)$/, "", name)
seq[++k] = name seq[++k] = name
if (!(name in cnt)) { cnt[name] = 0; distinct++ } if (!(name in cnt)) { cnt[name] = 0; distinct++ }
cnt[name]++ cnt[name]++
@ -839,8 +919,9 @@ loop_analyze() {
tailstr = "" tailstr = ""
start = k - 11; if (start < 1) start = 1 start = k - 11; if (start < 1) start = 1
for (j = start; j <= k; j++) tailstr = tailstr (tailstr == "" ? "" : ", ") seq[j] for (j = start; j <= k; j++) tailstr = tailstr (tailstr == "" ? "" : ", ") seq[j]
printf "%d\037%d\037%d\037%d\037%d\037%d\037%d\037%s\037%s\037%s\n", \ printf "%d\037%d\037%d\037%d\037%d\037%d\037%d\037%s\037%s\037%s\037%d\037%d\037%d\037%d\037%d\037%d\n", \
k, distinct, topn, period, repeats, from, to, top, block, tailstr starts, distinct, topn, period, repeats, from, to, top, block, tailstr, \
k, refused, blocked, timeout, done, rewards
}' }'
} }
@ -898,13 +979,21 @@ check_loop() {
echo "$delta" > "${WD_RUN_DIR}/loop.places_delta" echo "$delta" > "${WD_RUN_DIR}/loop.places_delta"
local analysis total distinct topn period repeats win_from win_to top block tailstr local analysis total distinct topn period repeats win_from win_to top block tailstr
local decisions refused blocked timeouts completed rewards
analysis="$(loop_macro_stream | loop_analyze)" analysis="$(loop_macro_stream | loop_analyze)"
IFS=$'\037' read -r total distinct topn period repeats win_from win_to top block tailstr <<< "$analysis" IFS=$'\037' read -r total distinct topn period repeats win_from win_to top block tailstr \
decisions refused blocked timeouts completed rewards <<< "$analysis"
total="${total:-0}"; distinct="${distinct:-0}"; topn="${topn:-0}" total="${total:-0}"; distinct="${distinct:-0}"; topn="${topn:-0}"
period="${period:-0}"; repeats="${repeats:-0}" period="${period:-0}"; repeats="${repeats:-0}"
decisions="${decisions:-0}"; refused="${refused:-0}"; blocked="${blocked:-0}"
timeouts="${timeouts:-0}"; completed="${completed:-0}"; rewards="${rewards:-0}"
echo "$distinct" > "${WD_RUN_DIR}/loop.distinct" echo "$distinct" > "${WD_RUN_DIR}/loop.distinct"
echo "$period" > "${WD_RUN_DIR}/loop.period" echo "$period" > "${WD_RUN_DIR}/loop.period"
echo "$repeats" > "${WD_RUN_DIR}/loop.repeats" echo "$repeats" > "${WD_RUN_DIR}/loop.repeats"
echo "$refused" > "${WD_RUN_DIR}/loop.refused"
echo "$(( blocked + timeouts ))" > "${WD_RUN_DIR}/loop.blocked"
echo "$completed" > "${WD_RUN_DIR}/loop.done"
echo "$rewards" > "${WD_RUN_DIR}/loop.rewards"
local prev_suspected=0 local prev_suspected=0
[ -f "${WD_RUN_DIR}/loop.suspected" ] && prev_suspected="$(cat "${WD_RUN_DIR}/loop.suspected" 2>/dev/null || echo 0)" [ -f "${WD_RUN_DIR}/loop.suspected" ] && prev_suspected="$(cat "${WD_RUN_DIR}/loop.suspected" 2>/dev/null || echo 0)"
@ -917,25 +1006,57 @@ check_loop() {
local grown=1 local grown=1
[ "$delta_known" -eq 1 ] && [ "$delta" -le 0 ] && grown=0 [ "$delta_known" -eq 1 ] && [ "$delta" -le 0 ] && grown=0
# Zero progress is judged over two probes: a window with decisions and no
# `done` among them is remembered, and only a second one in a row flags.
local idle_file="${WD_RUN_DIR}/loop.prev_idle" prev_idle=0 idle=0
[ -f "$idle_file" ] && prev_idle="$(cat "$idle_file" 2>/dev/null || echo 0)"
case "${prev_idle:-}" in ''|*[!0-9]*) prev_idle=0 ;; esac
[ "$decisions" -gt 0 ] && [ "$completed" -eq 0 ] && [ "$grown" -eq 0 ] && idle=1
echo "$idle" > "$idle_file"
# Busy going nowhere (row 58), judged over two probes like zero progress:
# many decisions, not one reward event, no new ground.
local busy_file="${WD_RUN_DIR}/loop.prev_unrewarded" prev_busy=0 busy=0
[ -f "$busy_file" ] && prev_busy="$(cat "$busy_file" 2>/dev/null || echo 0)"
case "${prev_busy:-}" in ''|*[!0-9]*) prev_busy=0 ;; esac
[ "$decisions" -ge "$WD_LOOP_BUSY_MIN" ] && [ "$rewards" -eq 0 ] && [ "$grown" -eq 0 ] && busy=1
echo "$busy" > "$busy_file"
local failed=$(( refused + blocked + timeouts ))
local suspected=0 reason="" local suspected=0 reason=""
if [ "$total" -gt 0 ] && [ "$grown" -eq 0 ]; then if [ "$decisions" -gt 0 ] && [ "$grown" -eq 0 ]; then
if [ "$distinct" -le "$WD_LOOP_MAX_DISTINCT" ] && [ "$repeats" -ge "$WD_LOOP_MIN_REPEATS" ]; then if [ "$decisions" -ge "$WD_LOOP_MIN_EVENTS" ] \
&& [ $(( failed * 100 )) -ge $(( decisions * WD_LOOP_STALL_PCT )) ]; then
# Row 57: the fly keeps deciding and nothing it decides runs. A
# refused press is a decision with no start, which is why the
# rules below were blind to a pad of one button that refused.
suspected=1
reason="stalled"
elif [ "$idle" -eq 1 ] && [ "$prev_idle" -eq 1 ]; then
suspected=1
reason="zero-progress"
elif [ "$distinct" -le "$WD_LOOP_MAX_DISTINCT" ] && [ "$repeats" -ge "$WD_LOOP_MIN_REPEATS" ]; then
suspected=1 suspected=1
reason="sequence" reason="sequence"
elif [ "$total" -ge "$WD_LOOP_MIN_EVENTS" ] \ elif [ "$decisions" -ge "$WD_LOOP_MIN_EVENTS" ] \
&& [ $(( topn * 100 )) -ge $(( total * WD_LOOP_DOMINANCE_PCT )) ]; then && [ $(( topn * 100 )) -ge $(( decisions * WD_LOOP_DOMINANCE_PCT )) ]; then
# One macro and almost nothing else. The floor under it is not in # One macro and almost nothing else. The floor under it is not in
# the spec but is load-bearing: two starts in ten brain minutes are # the spec but is load-bearing: two starts in ten brain minutes are
# 100% of a window and mean the fly is barely deciding at all, # 100% of a window and mean the fly is barely deciding at all,
# which is silence (above), not a loop. # which is silence (above), not a loop.
suspected=1 suspected=1
reason="dominant" reason="dominant"
elif [ "$busy" -eq 1 ] && [ "$prev_busy" -eq 1 ]; then
# Row 58: an undo pair diluted by other macros. Every one of them
# completes, none of them earns anything, and the ground stays put.
suspected=1
reason="unrewarded"
fi fi
fi fi
echo "$suspected" > "${WD_RUN_DIR}/loop.suspected" echo "$suspected" > "${WD_RUN_DIR}/loop.suspected"
local dom_pct=0 local dom_pct=0
[ "$total" -gt 0 ] && dom_pct=$(( topn * 100 / total )) [ "$decisions" -gt 0 ] && dom_pct=$(( topn * 100 / decisions ))
# The sequence a reader wants: the repeating block when there is one, the # The sequence a reader wants: the repeating block when there is one, the
# single dominant name when the dominance rule is what fired. # single dominant name when the dominance rule is what fired.
local shown="$block" local shown="$block"
@ -956,6 +1077,12 @@ check_loop() {
--argjson repeats "$repeats" \ --argjson repeats "$repeats" \
--argjson distinct "$distinct" \ --argjson distinct "$distinct" \
--argjson total "$total" \ --argjson total "$total" \
--argjson decisions "$decisions" \
--argjson refused "$refused" \
--argjson blocked "$blocked" \
--argjson timeouts "$timeouts" \
--argjson completed "$completed" \
--argjson rewards "$rewards" \
--argjson windowFrom "${win_from:-0}" \ --argjson windowFrom "${win_from:-0}" \
--argjson windowTo "${win_to:-0}" \ --argjson windowTo "${win_to:-0}" \
--argjson windowMs "$WD_LOOP_WINDOW_MS" \ --argjson windowMs "$WD_LOOP_WINDOW_MS" \
@ -982,6 +1109,9 @@ check_loop() {
window: { window: {
brainMsFrom: $windowFrom, brainMsTo: $windowTo, brainMs: $windowMs, brainMsFrom: $windowFrom, brainMsTo: $windowTo, brainMs: $windowMs,
macroStarts: $total, macroStarts: $total,
decisions: $decisions,
outcomes: { done: $completed, blocked: $blocked, timeout: $timeouts, refused: $refused },
rewards: $rewards,
tail: (if $tail == "" then [] else ($tail | split(", ")) end) tail: (if $tail == "" then [] else ($tail | split(", ")) end)
}, },
dominant: { name: (if $dominant == "" then null else $dominant end), dominant: { name: (if $dominant == "" then null else $dominant end),
@ -1005,13 +1135,17 @@ check_loop() {
local mins=$(( WD_LOOP_WINDOW_MS / 60000 )) local mins=$(( WD_LOOP_WINDOW_MS / 60000 ))
if [ "$suspected" -eq 1 ]; then if [ "$suspected" -eq 1 ]; then
if [ "$reason" = "sequence" ]; then if [ "$reason" = "unrewarded" ]; then
log_err "loop suspected (unrewarded): ${decisions} decisions and no reward event in the last ${mins} brain minutes, two probes running — ${completed} done, top [${top}] x${topn}, ${distinct} distinct name(s), tail [${tailstr}], exploration count ${places:-?} unchanged (delta ${delta}), rung ${rank:-?} '${mlabel:-?}' for ${since:-?}s. NOT acting: nothing restarted, nothing pressed, the game untouched — macros that complete and earn nothing are an undo pair or a ring. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'."
elif [ "$reason" = "stalled" ] || [ "$reason" = "zero-progress" ]; then
log_err "loop suspected (${reason}): [${shown}] — ${decisions} decisions in the last ${mins} brain minutes, ${total} started, ${completed} done, ${refused} refused, $(( blocked + timeouts )) blocked or timed out, exploration count ${places:-?} unchanged (delta ${delta}), rung ${rank:-?} '${mlabel:-?}' for ${since:-?}s. NOT acting: nothing restarted, nothing pressed, the game untouched — a pad whose buttons cannot run is a macro bug. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'."
elif [ "$reason" = "sequence" ]; then
log_err "loop suspected: [${shown}] x${repeats} (period ${period}) in the last ${mins} brain minutes — ${total} macro starts, ${distinct} distinct name(s), exploration count ${places:-?} unchanged (delta ${delta}), rung ${rank:-?} '${mlabel:-?}' for ${since:-?}s. NOT acting: nothing restarted, nothing pressed, the game untouched — a loop is a macro target-choice bug and a bounce would only restore the same loop. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'." log_err "loop suspected: [${shown}] x${repeats} (period ${period}) in the last ${mins} brain minutes — ${total} macro starts, ${distinct} distinct name(s), exploration count ${places:-?} unchanged (delta ${delta}), rung ${rank:-?} '${mlabel:-?}' for ${since:-?}s. NOT acting: nothing restarted, nothing pressed, the game untouched — a loop is a macro target-choice bug and a bounce would only restore the same loop. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'."
else else
log_err "loop suspected: [${shown}] is ${dom_pct}% of ${total} macro starts in the last ${mins} brain minutes (${distinct} distinct name(s)), exploration count ${places:-?} unchanged (delta ${delta}), rung ${rank:-?} '${mlabel:-?}' for ${since:-?}s. NOT acting: nothing restarted, nothing pressed, the game untouched. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'." log_err "loop suspected: [${shown}] is ${dom_pct}% of ${total} macro starts in the last ${mins} brain minutes (${distinct} distinct name(s)), exploration count ${places:-?} unchanged (delta ${delta}), rung ${rank:-?} '${mlabel:-?}' for ${since:-?}s. NOT acting: nothing restarted, nothing pressed, the game untouched. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'."
fi fi
elif [ "$prev_suspected" -eq 1 ]; then elif [ "$prev_suspected" -eq 1 ]; then
log_info "loop cleared: ${total} macro starts over ${distinct} distinct name(s) in the last ${mins} brain minutes, longest repeat x${repeats} (period ${period}), exploration count ${places:-?} delta ${delta}. Nothing was ever done about the loop; if a fix went in, this is it landing." log_info "loop cleared: ${total} macro starts (${decisions} decisions, ${completed} done, ${refused} refused) over ${distinct} distinct name(s) in the last ${mins} brain minutes, longest repeat x${repeats} (period ${period}), exploration count ${places:-?} delta ${delta}. Nothing was ever done about the loop; if a fix went in, this is it landing."
fi fi
} }

View file

@ -79,6 +79,12 @@ log "building in $crate_dir for target-cpu=haswell (the host is E5-2660 v3, Hasw
( (
cd "$crate_dir" cd "$crate_dir"
RUSTFLAGS="-C target-cpu=haswell" cargo build --release --target "$CARGO_TARGET" --bin flysim "${features_args[@]}" RUSTFLAGS="-C target-cpu=haswell" cargo build --release --target "$CARGO_TARGET" --bin flysim "${features_args[@]}"
# fly-edge (FLY_FEED_VIA=bus, docs/design/flybus.md): the feed WebSocket
# served from flysim's feed bus. Small, and no cargo features of its own;
# built every time so a release can switch a container onto the bus
# without a rebuild. It lands next to OUT_PATH, where package-release.sh
# looks for it.
RUSTFLAGS="-C target-cpu=haswell" cargo build --release --target "$CARGO_TARGET" --bin fly-edge
) )
built="${crate_dir}/target/${CARGO_TARGET}/release/flysim" built="${crate_dir}/target/${CARGO_TARGET}/release/flysim"
@ -106,4 +112,10 @@ fi
cp "$built" "$OUT_PATH" cp "$built" "$OUT_PATH"
chmod 0755 "$OUT_PATH" chmod 0755 "$OUT_PATH"
log "built $OUT_PATH ($(du -h "$OUT_PATH" | cut -f1))" log "built $OUT_PATH ($(du -h "$OUT_PATH" | cut -f1))"
edge_built="${crate_dir}/target/${CARGO_TARGET}/release/fly-edge"
[ -x "$edge_built" ] || die "expected binary not found after build: $edge_built"
edge_out="$(dirname "$OUT_PATH")/fly-edge"
cp "$edge_built" "$edge_out"
chmod 0755 "$edge_out"
log "built $edge_out ($(du -h "$edge_out" | cut -f1))"
log "next: infra/build/package-release.sh VERSION $OUT_PATH <stage-dir> <bridge-dir> <out-dir>" log "next: infra/build/package-release.sh VERSION $OUT_PATH <stage-dir> <bridge-dir> <out-dir>"

View file

@ -11,6 +11,9 @@
# #
# Output: OUT_DIR/flybrain-<version>.tar.gz, laid out as # Output: OUT_DIR/flybrain-<version>.tar.gz, laid out as
# flysim (the binary, mode 0755) # flysim (the binary, mode 0755)
# fly-edge (the feed-bus edge, mode 0755, when build-flysim.sh
# left one beside FLYSIM_BIN; flyedge.service stays
# inactive on a release without it)
# stage/... (apps/stage's build output) # stage/... (apps/stage's build output)
# bridge/... (services/bridge + node_modules) # bridge/... (services/bridge + node_modules)
# data/fafb-v783/... (the connectome, from the repo; FLY_DATASET points here) # data/fafb-v783/... (the connectome, from the repo; FLY_DATASET points here)
@ -72,6 +75,13 @@ mkdir -p "$release_dir"
cp "$FLYSIM_BIN" "${release_dir}/flysim" cp "$FLYSIM_BIN" "${release_dir}/flysim"
chmod 0755 "${release_dir}/flysim" chmod 0755 "${release_dir}/flysim"
EDGE_BIN="$(dirname "$FLYSIM_BIN")/fly-edge"
if [ -x "$EDGE_BIN" ]; then
cp "$EDGE_BIN" "${release_dir}/fly-edge"
chmod 0755 "${release_dir}/fly-edge"
else
log "no fly-edge beside $FLYSIM_BIN; packaging without it (FLY_FEED_VIA=bus unavailable in this release)"
fi
cp -a "$STAGE_DIR" "${release_dir}/stage" cp -a "$STAGE_DIR" "${release_dir}/stage"
cp -a "$BRIDGE_DIR" "${release_dir}/bridge" cp -a "$BRIDGE_DIR" "${release_dir}/bridge"

View file

@ -6,6 +6,9 @@ d /run/fly 0750 fly fly -
d /run/fly/pulse 0750 fly fly - d /run/fly/pulse 0750 fly fly -
d /run/fly/state 0750 fly fly - d /run/fly/state 0750 fly fly -
d /run/fly/wd 0750 fly fly - d /run/fly/wd 0750 fly fly -
# ADDED: the feed bus (FLY_FEED_VIA=bus, docs/design/flybus.md): flysim's
# router socket and artifact store. flysim also creates it, 0700, on start.
d /run/fly/bus 0700 fly fly -
d /var/lib/fly 0750 fly fly - d /var/lib/fly 0750 fly fly -
d /var/lib/fly/chrome 0700 fly fly - d /var/lib/fly/chrome 0700 fly fly -
d /srv/fly/state 0750 fly fly - d /srv/fly/state 0750 fly fly -

View file

@ -20,7 +20,8 @@ FLY_ROM=".../Pokemon Red (U) [S][BF].gb" FLY_MACRO_BRAIN=data/fafb-v783 \
cargo run --release -p flysim --example palette_bench cargo run --release -p flysim --example palette_bench
``` ```
Both arms are the sim loop's own frame order over the real connectome (`data/fafb-v783`), the real Both arms are the sim loop's own frame order (`flysim::frame::LegacyFrame` since 2026-09-23;
before that `NeuralAgent::tick`'s, one frame behind the stream) over the real connectome (`data/fafb-v783`), the real
Game Boy readout preset with nothing overridden, the real Pokémon adapter paying the real reward Game Boy readout preset with nothing overridden, the real Pokémon adapter paying the real reward
catalog, and the real ratchet on the adapter's own recovery policy. The only difference between catalog, and the real ratchet on the adapter's own recovery policy. The only difference between
them is `flysim::macros::MacroLayer`, built from the configuration the way `Sim::boot` builds it, them is `flysim::macros::MacroLayer`, built from the configuration the way `Sim::boot` builds it,

File diff suppressed because it is too large Load diff

View file

@ -231,6 +231,59 @@ auto-reset (`docs/design/flysim.md` section 8: "no automatic fresh start, ever")
is deliberate — a silent reset would be indistinguishable from real progress on stream. is deliberate — a silent reset would be indistinguishable from real progress on stream.
A deliberate reset means moving `/srv/fly/state` aside by hand. A deliberate reset means moving `/srv/fly/state` aside by hand.
## Restart the run from a rung
When the run has to go back to an earlier milestone rather than start over — the operator's
decision of 2026-09-22 was "restart the live run from an early checkpoint instead of from
scratch". `FLY_RESET_STATE=1` is the wrong tool: it archives the durable state and the next start
warms up a fresh fly, losing everything the brain has learned.
`infra/bin/fly-reset-to-milestone <N>` promotes `milestone-<N>.checkpoint` to being what both
stores restore, with the ratchet's attempts and recoveries back at zero. It copies every file in
both stores to `/srv/fly/state.reset-<UTC>` first, so it is reversible by hand. It refuses while
flysim is running, and refuses a rung this run never reached.
The whole sequence, in order. Claim the container in the host's agent claim log first, like any
other work on it.
```
CTID=<release-ctid>
N=9 # the rung to restart from
# 1. what rungs exist at all
pct exec $CTID -- ls -1 /srv/fly/state/milestone-*.checkpoint
# 2. stop flysim (it owns both stores; a reset underneath it is overwritten within the minute)
pct exec $CTID -- systemctl stop flysim.service
# 3. the reset. Prints what it did, one line per step.
pct exec $CTID -- /opt/fly/bin/fly-reset-to-milestone $N
# 4. deploy. Two cases:
# (a) the running release already wrote that checkpoint -> nothing to deploy, skip to 5.
# (b) the new build bumps the ADAPTER VERSION and nothing else -> name the checkpoint's
# adapter so the gate and flysim both migrate instead of refusing:
FLY_ACCEPT_ADAPTERS=pokered-unique8-v6 infra/05-deploy.sh <release-env> <release-tarball>
# The gate logs "the adapter version is the only difference, and it is named; the run is KEPT
# and migrated", and writes FLY_ACCEPT_ADAPTERS into /etc/fly/fly.env so flysim applies the
# same rule at restore. Anything else about the string differing is still a refusal.
# 5. start
pct exec $CTID -- systemctl start flysim.service
# 6. verify: the rank is the rung, and the restore came from the generation the tool wrote
pct exec $CTID -- curl -s http://127.0.0.1:7401/status | jq '.milestone.rank, .game.badges, .checkpoint'
pct exec $CTID -- journalctl -u flysim -n 40 --no-pager | grep -E 'restored|migration|compatibility'
```
Step 6 is the one that must be read rather than assumed. The rank is recomputed by the adapter
from the restored game state, not taken from the ratchet, so a rank that is *not* N means the
milestone archive was taken somewhere other than where its name says — stop and look before
starting a stream on it.
To undo: stop flysim, move the contents of `/srv/fly/state.reset-<UTC>/durable` back into
`/srv/fly/state`, delete the generation the tool wrote, and start again.
## Restore from the backup host ## Restore from the backup host
``` ```
@ -546,9 +599,20 @@ pct exec <ctid> -- cat /run/fly/wd/loop.json | jq .
| `fly_loop_repeats` | how many times that block repeats at the end of the 10-brain-minute window | | `fly_loop_repeats` | how many times that block repeats at the end of the 10-brain-minute window |
| `fly_loop_distinct_macros` | distinct macro names started in the window | | `fly_loop_distinct_macros` | distinct macro names started in the window |
| `fly_places_delta` | growth in `game.uniqueLocations` since the previous probe (`-1` = no previous probe) | | `fly_places_delta` | growth in `game.uniqueLocations` since the previous probe (`-1` = no previous probe) |
| `fly_loop_refused` | macro presses refused in the window: a bound button pressed, nothing run |
| `fly_loop_blocked` | macros that ended `blocked` or `timeout` in the window |
| `fly_loop_done` | macros that ended `done` in the window |
| `fly_loop_rewards` | reward events in the window |
The flag needs **both** halves: at most 3 distinct macro names with the block repeating 20+ The flag needs **both** halves: at most 3 distinct macro names with the block repeating 20+
times, or one macro at 95%+ of the window — **and** no growth in the exploration count. A times, one macro at 95%+ of the window's decisions, 90%+ of 20+ decisions ending refused,
blocked or timed out (`stalled`), or decisions with no `done` among them on two probes in a row
(`zero-progress`), or 100+ decisions with no reward event among them on two probes in a row
(`unrewarded`, row 58: `GO OBJECTIVE` in and `GO OUT` out of one door, diluted by eight other
names, every macro `done`) — **and** no growth in the exploration count. A decision is a `start` or a
`refused`: a refused press starts nothing, which is why counting starts alone read row 57's
pad (`GO ROUTE refused` ~740 times in ten brain minutes, `macros-traps.md`) as one start and
one name. A
repeating macro over ground that keeps growing is a walk longer than the 600-frame cap, not a repeating macro over ground that keeps growing is a walk longer than the 600-frame cap, not a
trap (`macros-traps.md`: `GO FRONTIER` x19 across 93 tiles), and the watchdog is deliberately trap (`macros-traps.md`: `GO FRONTIER` x19 across 93 tiles), and the watchdog is deliberately
quiet about it. The thresholds are `WD_LOOP_*` in `infra/bin/fly-watchdog`; the 3-name ceiling quiet about it. The thresholds are `WD_LOOP_*` in `infra/bin/fly-watchdog`; the 3-name ceiling

22
infra/env/example.env vendored
View file

@ -312,11 +312,33 @@ CHAT_DENY_LIST=/srv/fly/chat-deny.txt
# "palette" and "plan" are the two modes section 12 replaced; flysim still reads # "palette" and "plan" are the two modes section 12 replaced; flysim still reads
# either as "macros", with a warning, for one release. # either as "macros", with a warning, for one release.
FLY_MACRO_MODE=raw FLY_MACRO_MODE=raw
# --- feed path ----------------------------------------------------------------
# Who serves ws://127.0.0.1:7400/feed (docs/design/flybus.md, "Feed over the
# bus"). direct: flysim binds it, as always. bus: flysim publishes every
# snapshot on its embedded feed bus (/run/fly/bus) and flyedge.service serves
# the same bytes; enable that unit by hand (its header has the steps).
# Watchdog check 2 follows this setting to the edge's counters by itself.
FLY_FEED_VIA=direct
# How long a macro leaves a target alone after a walk to it aborted "blocked" or # How long a macro leaves a target alone after a walk to it aborted "blocked" or
# "timeout" (macros.md section 12.1). Session state, so a restart offers every # "timeout" (macros.md section 12.1). Session state, so a restart offers every
# target once more. Unset means the default, 10. # target once more. Unset means the default, 10.
# FLY_MACRO_BLOCKED_MINUTES=10 # FLY_MACRO_BLOCKED_MINUTES=10
# --- restoring across an adapter version ------------------------------------
# Adapter version strings whose checkpoints this build may migrate, comma- or
# space-separated (docs/design/flysim.md, "Restoring across an adapter
# version"). Unset -- the default, and what every deploy before 2026-09-22 did
# -- migrates nothing: a build whose compatibility string differs from the live
# state's is refused by 05-deploy's gate and by flysim at restore.
#
# It applies only when the ADAPTER segment is the only difference between the
# two strings AND the new build's adapter declares a migration from that one. A
# dataset, kernel, plasticity, emulator or state-format difference is still a
# refusal. Set it for the one deploy that needs it and leave it out afterwards;
# 05-deploy writes it into /etc/fly/fly.env only while it is set. The v0.6.0
# deploy (pokered-unique8-v7, the engagement rewards) is the one that needs:
# FLY_ACCEPT_ADAPTERS=pokered-unique8-v6
# --- push mode -------------------------------------------------------------- # --- push mode --------------------------------------------------------------
# local: flypush.service stays disabled, everything else identical to prod. # local: flypush.service stays disabled, everything else identical to prod.
# twitch: flypush.service is enabled by 07-enable.sh. # twitch: flypush.service is enabled by 07-enable.sh.

View file

@ -154,6 +154,20 @@ require_release_tag() {
# (An earlier, eight-cpu version of this same live hotfix — CPUSET= # (An earlier, eight-cpu version of this same live hotfix — CPUSET=
# 1,3,5,7,9,11,13,15, ENCODER_CORES=2 (the default) — gave flysim=1,3,5,7, # 1,3,5,7,9,11,13,15, ENCODER_CORES=2 (the default) — gave flysim=1,3,5,7,
# page=9,11, flycast=13,15; infra/tests/lint.sh checks both shapes.) # page=9,11, flycast=13,15; infra/tests/lint.sh checks both shapes.)
# feed_via_normalize VALUE — print FLY_FEED_VIA lowercased (empty means "direct"), or
# return 1 for anything but direct|bus. flysim itself reads the value case-insensitively
# and refuses anything else at boot, which on a container is a restart loop; 05-deploy.sh
# refuses it at deploy instead and writes the lowercased word, so watchdog check 2 and
# flysim can never read the same line two ways (docs/design/flybus.md).
feed_via_normalize() {
local via
via="$(printf '%s' "${1:-direct}" | tr '[:upper:]' '[:lower:]')"
case "$via" in
direct|bus) printf '%s\n' "$via" ;;
*) return 1 ;;
esac
}
cpuset_partition() { cpuset_partition() {
local cpuset="$1" rayon_threads="$2" encoder_cores="${3:-2}" local cpuset="$1" rayon_threads="$2" encoder_cores="${3:-2}"
local sim_cpus remainder remainder_count page_count page_cpus encoder_cpus local sim_cpus remainder remainder_count page_count page_cpus encoder_cpus

View file

@ -429,6 +429,140 @@ else
fi fi
rm -rf "$lint_tmp" rm -rf "$lint_tmp"
# ---------------------------------------------------------------------------
# 3b2. The feed bus edge (docs/design/flybus.md, "Feed over the bus").
#
# flyedge.service is off unless the operator switches a container to
# FLY_FEED_VIA=bus by hand, and when it is on it must follow flysim, which
# owns the router. What would break that is statically visible: the unit
# ending up in fly.target or 07-enable's list, losing its ordering on
# flysim, or the deploy no longer writing the default. Watchdog check 2's
# choice of /metrics is driven for real against a fixture fly.env.
# ---------------------------------------------------------------------------
echo "--- flyedge.service: off by default, after and bound to flysim ---"
EDGE_UNIT="$INFRA_DIR/units/flyedge.service"
if [ ! -f "$EDGE_UNIT" ]; then
fail "units/flyedge.service is missing"
else
grep -qE '^After=.*\bflysim\.service\b' "$EDGE_UNIT" \
&& pass "flyedge.service orders itself After=flysim.service" \
|| fail "flyedge.service must be After=flysim.service: flysim owns the feed router"
grep -qE '^Requires=.*\bflysim\.service\b' "$EDGE_UNIT" \
&& pass "flyedge.service Requires=flysim.service" \
|| fail "flyedge.service must Require flysim.service, so a stop or restart of flysim takes the edge with it"
grep -qE '^ExecStart=/opt/fly/current/fly-edge$' "$EDGE_UNIT" \
&& pass "flyedge.service runs the release's fly-edge" \
|| fail "flyedge.service ExecStart must be /opt/fly/current/fly-edge"
grep -qE '^ConditionPathExists=/opt/fly/current/fly-edge$' "$EDGE_UNIT" \
&& pass "flyedge.service stays inactive on a release without fly-edge" \
|| fail "flyedge.service needs ConditionPathExists=/opt/fly/current/fly-edge (a release before it has none)"
grep -qE '^Environment=FLY_EDGE_METRICS_ADDR=127\.0\.0\.1:' "$EDGE_UNIT" \
&& pass "flyedge.service keeps its metrics on loopback" \
|| fail "flyedge.service FLY_EDGE_METRICS_ADDR must be a 127.0.0.1 address"
fi
# Every unit a target's Wants=/Requires= names, with backslash continuations joined and
# comments dropped: fly.target spreads both lists over several physical lines, and the
# continuation line is exactly where a new unit would be added.
target_pulls() {
awk '
/^[[:space:]]*[#;]/ { next }
{
line = $0
cont = sub(/\\[[:space:]]*$/, "", line)
buf = buf line
if (cont) next
if (buf ~ /^[[:space:]]*(Wants|Requires)=/) { sub(/^[^=]*=/, "", buf); print buf }
buf = ""
}
' "$1" | tr -s ' \t' '\n' | grep -v '^$' || true
}
if target_pulls "$INFRA_DIR/units/fly.target" | grep -qx 'flyedge.service'; then
fail "fly.target pulls flyedge.service in; it must stay off until the operator enables it"
else
pass "fly.target does not pull flyedge.service in"
fi
# The parser itself: a unit named only on a continuation line must be found, a commented one
# must not, and the real fly.target must still yield flysim.service.
tp_fixture="$(mktemp "${TMPDIR:-/tmp}/fly-lint-target.XXXXXX")"
cat > "$tp_fixture" <<'TPTARGET'
[Unit]
Wants=network-online.target xvfb.service \
flysim.service flyedge.service
# Requires=commented.service
Requires=xvfb.service \
pulse.service
TPTARGET
tp_units="$(target_pulls "$tp_fixture")"
if printf '%s\n' "$tp_units" | grep -qx 'flyedge.service' \
&& printf '%s\n' "$tp_units" | grep -qx 'pulse.service' \
&& ! printf '%s\n' "$tp_units" | grep -qx 'commented.service' \
&& target_pulls "$INFRA_DIR/units/fly.target" | grep -qx 'flysim.service'; then
pass "target_pulls reads continuation lines and skips comments (fixture + fly.target)"
else
fail "target_pulls missed a continuation line or read a comment: $(echo "$tp_units" | tr '\n' ' ')"
fi
rm -f "$tp_fixture"
if grep -E '^(ALWAYS_ON_UNITS|APP_UNITS)=' "$INFRA_DIR/07-enable.sh" "$INFRA_DIR/verify.sh" | grep -q 'flyedge'; then
fail "07-enable.sh or verify.sh lists flyedge.service as always-on"
else
pass "07-enable.sh and verify.sh leave flyedge.service alone"
fi
if grep -qF 'FLY_FEED_VIA_EFFECTIVE="$(feed_via_normalize "${FLY_FEED_VIA:-}")"' "$INFRA_DIR/05-deploy.sh" \
&& grep -qF 'echo "FLY_FEED_VIA=${FLY_FEED_VIA_EFFECTIVE}"' "$INFRA_DIR/05-deploy.sh"; then
pass "05-deploy.sh validates FLY_FEED_VIA and writes the normalized value"
else
fail "05-deploy.sh must run FLY_FEED_VIA through feed_via_normalize and write FLY_FEED_VIA_EFFECTIVE"
fi
# shellcheck source=../lib/common.sh
fv_out="$(bash -c '. "$1/lib/common.sh"
for v in "" direct DIRECT bus Bus BUS; do printf "%s=%s " "${v:-empty}" "$(feed_via_normalize "$v")"; done
for v in buss "bus " direct,bus; do feed_via_normalize "$v" >/dev/null && printf "ACCEPTED:%s " "$v"; done; true' _ "$INFRA_DIR" 2>&1)"
if [ "$fv_out" = "empty=direct direct=direct DIRECT=direct bus=bus Bus=bus BUS=bus " ]; then
pass "feed_via_normalize: direct|bus in any case, empty is direct, anything else refused"
else
fail "feed_via_normalize: got '$fv_out'"
fi
if grep -qE '^[[:space:]]*for u in flysim .*\bflyedge\b.*; do$' "$INFRA_DIR/05-deploy.sh"; then
pass "05-deploy.sh writes a cpuset drop-in for flyedge.service"
else
fail "05-deploy.sh cpuset loop must include flyedge (the page's CPUs, never flysim's)"
fi
if grep -qE '^Environment=FLY_FEED_VIA' "$INFRA_DIR/units/flysim.service"; then
fail "flysim.service pins FLY_FEED_VIA; it belongs to fly.env so a box can be switched by deploy"
else
pass "flysim.service leaves FLY_FEED_VIA to fly.env"
fi
echo "--- fly-watchdog check 2: the feed counters follow FLY_FEED_VIA ---"
if ! tail -n1 "$INFRA_DIR/bin/fly-watchdog" | grep -qE '^main "\$@"$'; then
fail "fly-watchdog: expected the last line to be 'main \"\$@\"' — the check-2 fixture strips it"
else
fe_fixture="$(mktemp -d "${TMPDIR:-/tmp}/fly-lint-edge.XXXXXX")"
sed '$d' "$INFRA_DIR/bin/fly-watchdog" > "$fe_fixture/wd.sh"
feed_url_case() {
local label="$1" env_line="$2" override="$3" want="$4" got
printf '%s\n' "$env_line" > "$fe_fixture/fly.env"
got="$(FLY_ENV_FILE="$fe_fixture/fly.env" FLY_FEED_METRICS_URL="$override" \
FLY_METRICS_URL=http://sim FLY_EDGE_METRICS_URL=http://edge \
WD_RUN_DIR="$fe_fixture/run" WD_STATE_DIR="$fe_fixture/state" \
TEXTFILE_DIR="$fe_fixture/textfile" \
bash -c "source '$fe_fixture/wd.sh'; feed_metrics_url" 2>&1 || true)"
if [ "$got" = "$want" ]; then
pass "check 2 feed metrics: $label -> $got"
else
fail "check 2 feed metrics: $label: got '$got', want '$want'"
fi
}
feed_url_case "direct" "FLY_FEED_VIA=direct" "" "http://sim"
feed_url_case "no FLY_FEED_VIA line (a fly.env before it)" "FLY_GAME=pokemon-red" "" "http://sim"
feed_url_case "bus" "FLY_FEED_VIA=bus" "" "http://edge"
feed_url_case "Bus (flysim lowercases)" "FLY_FEED_VIA=Bus" "" "http://edge"
feed_url_case "BUS" "FLY_FEED_VIA=BUS" "" "http://edge"
feed_url_case "quoted bus" 'FLY_FEED_VIA="bus"' "" "http://edge"
feed_url_case "explicit override wins" "FLY_FEED_VIA=bus" "http://other" "http://other"
rm -rf "$fe_fixture"
fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 3c. lib/common.sh cpuset_partition — the three-way cpuset split used by # 3c. lib/common.sh cpuset_partition — the three-way cpuset split used by
# 05-deploy.sh section 3b (flysim / page-capture / flycast). Run as its own # 05-deploy.sh section 3b (flysim / page-capture / flycast). Run as its own
@ -889,6 +1023,26 @@ LPCAT
printf '{"id":999999,"wallMs":1758' printf '{"id":999999,"wallMs":1758'
} > "$out" } > "$out"
} }
# lp_outcomes FILE OUTCOME — like lp_events, but each name on stdin is one
# decision that ended OUTCOME: `refused` writes the refusal alone (nothing
# started, which is what a refused press is), anything else a start and
# that outcome. Row 57's shape is `GO ROUTE refused` every 800 brain ms.
lp_outcomes() {
local out="$1" outcome="$2" id=0 ms=0 nm
{
while IFS= read -r nm; do
if [ "$outcome" != "refused" ]; then
id=$(( id + 1 ))
printf '{"id":%d,"wallMs":%d,"brainMs":%d,"kind":"macro","label":"%s start","value":3}\n' \
"$id" "$(( 1758000000000 + id ))" "$ms" "$nm"
fi
id=$(( id + 1 ))
printf '{"id":%d,"wallMs":%d,"brainMs":%d,"kind":"macro","label":"%s %s","value":3}\n' \
"$id" "$(( 1758000000000 + id ))" "$ms" "$nm" "$outcome"
ms=$(( ms + 800 ))
done
} > "$out"
}
# lp_status FILE PLACES — the /status.json fields check 10 reads. `places` # lp_status FILE PLACES — the /status.json fields check 10 reads. `places`
# is `game.uniqueLocations`; there is no `places` field in the contract. # is `game.uniqueLocations`; there is no `places` field in the contract.
lp_status() { lp_status() {
@ -1001,10 +1155,95 @@ LPCAT
fail "check 10: expected the flag to clear on places growth, got flagged=${lp_flagged} then suspected=$(lp_metric fly_loop_suspected) places_delta=$(lp_metric fly_places_delta), journal: $(cat "$lp_fixture/journal.log")" fail "check 10: expected the flag to clear on places growth, got flagged=${lp_flagged} then suspected=$(lp_metric fly_loop_suspected) places_delta=$(lp_metric fly_places_delta), journal: $(cat "$lp_fixture/journal.log")"
fi fi
# (5) Row 57: a pad of one button that refuses every hold. One start in the
# window and one name, so the sequence and dominance rules over starts
# alone never fired; the outcomes say it is a stall.
lp_reset
{ lp_cycle 700 "GO ROUTE" | lp_outcomes "$lp_fixture/refused.jsonl" refused
echo "GO ROUTE" | lp_outcomes "$lp_fixture/blocked.jsonl" blocked
cat "$lp_fixture/refused.jsonl" "$lp_fixture/blocked.jsonl"; } > "$lp_fixture/events.jsonl"
lp_status "$lp_fixture/status.json" 1846
lp_pass
lp_first="$(lp_metric fly_loop_suspected)"
lp_pass
if [ "$lp_first" = "0" ] \
&& [ "$(lp_metric fly_loop_suspected)" = "1" ] \
&& [ "$(lp_metric fly_loop_refused)" = "700" ] \
&& [ "$(lp_metric fly_loop_blocked)" = "1" ] \
&& [ "$(lp_metric fly_loop_done)" = "0" ] \
&& grep -q 'loop suspected (stalled): \[GO ROUTE\]' "$lp_fixture/journal.log"; then
pass "check 10: a pad whose one button is refused every hold flags as stalled (700 refused, 1 blocked, 0 done)"
else
fail "check 10: the row-57 refusal log gave first=${lp_first} suspected=$(lp_metric fly_loop_suspected) refused=$(lp_metric fly_loop_refused) blocked=$(lp_metric fly_loop_blocked) done=$(lp_metric fly_loop_done), journal: $(cat "$lp_fixture/journal.log")"
fi
if lp_report="$(jq -e -r '[.reason, (.window.macroStarts|tostring), (.window.decisions|tostring), (.window.outcomes.refused|tostring), (.window.outcomes.done|tostring), .action] | join(" ")' "$lp_fixture/run/loop.json" 2>/dev/null)" \
&& [ "$lp_report" = "stalled 1 701 700 0 none" ]; then
pass "check 10: loop.json carries the decisions and every outcome, not only the starts"
else
fail "check 10: loop.json read back as '${lp_report:-UNREADABLE}' — expected 'stalled 1 701 700 0 none'"
fi
# (6) Zero progress: a handful of decisions, every one blocked, too few for
# the stall rule's floor. One probe of it is not enough; two in a row are.
lp_reset
lp_cycle 3 "GO OBJECTIVE" "GO FRONTIER" | lp_outcomes "$lp_fixture/events.jsonl" blocked
lp_status "$lp_fixture/status.json" 1846
lp_pass
lp_pass
lp_first="$(lp_metric fly_loop_suspected)"
lp_pass
if [ "$lp_first" = "0" ] && [ "$(lp_metric fly_loop_suspected)" = "1" ] \
&& grep -q 'loop suspected (zero-progress)' "$lp_fixture/journal.log"; then
pass "check 10: decisions that complete nothing over two probes with no new ground flag as zero-progress"
else
fail "check 10: the zero-progress case gave first=${lp_first} then suspected=$(lp_metric fly_loop_suspected), journal: $(cat "$lp_fixture/journal.log")"
fi
# (7) Row 58: the gym door, in and out. GO OBJECTIVE / GO OUT diluted by
# eight other names, every macro `done`, no reward event, no new ground --
# neither the four-name sequence rule, dominance nor zero-progress fires.
# Two probes of it flag; the same window with one reward in it does not.
lp_reset
lp_cycle 20 "GO OBJECTIVE" "GO OUT" "GO OUT" "GO ITEM" "GO OBJECTIVE" "GO OUT" \
"GO FRONTIER" "YES" "NO" "GO ROUTE" "NEXT" "TALK" "GO SHOP" \
| lp_outcomes "$lp_fixture/events.jsonl" "done"
lp_status "$lp_fixture/status.json" 1892
lp_pass
lp_pass
lp_first="$(lp_metric fly_loop_suspected)"
lp_pass
if [ "$lp_first" = "0" ] && [ "$(lp_metric fly_loop_suspected)" = "1" ] \
&& [ "$(lp_metric fly_loop_rewards)" = "0" ] \
&& [ "$(lp_metric fly_loop_distinct_macros)" = "10" ] \
&& grep -q 'loop suspected (unrewarded): 260 decisions and no reward event' "$lp_fixture/journal.log"; then
pass "check 10: an undo pair diluted by eight other names, all done, no reward over two probes flags as unrewarded"
else
fail "check 10: the row-58 log gave first=${lp_first} then suspected=$(lp_metric fly_loop_suspected) rewards=$(lp_metric fly_loop_rewards) distinct=$(lp_metric fly_loop_distinct_macros), journal: $(cat "$lp_fixture/journal.log")"
fi
if lp_report="$(jq -e -r '[.reason, (.window.decisions|tostring), (.window.rewards|tostring), .action] | join(" ")' "$lp_fixture/run/loop.json" 2>/dev/null)" \
&& [ "$lp_report" = "unrewarded 260 0 none" ]; then
pass "check 10: loop.json carries the reward events in the window"
else
fail "check 10: loop.json read back as '${lp_report:-UNREADABLE}' — expected 'unrewarded 260 0 none'"
fi
lp_reset
{ cat "$lp_fixture/events.jsonl"
printf '{"id":999998,"wallMs":1758000999998,"brainMs":150000,"kind":"reward","label":"WILD KO 54:1:1","value":0.1,"rewardKind":"wildwin"}\n'; } \
> "$lp_fixture/events-rewarded.jsonl"
mv -f "$lp_fixture/events-rewarded.jsonl" "$lp_fixture/events.jsonl"
lp_pass
lp_pass
lp_pass
if [ "$(lp_metric fly_loop_suspected)" = "0" ] && [ "$(lp_metric fly_loop_rewards)" = "1" ]; then
pass "check 10: the same busy window with one reward in it does not flag"
else
fail "check 10: a rewarded busy window gave suspected=$(lp_metric fly_loop_suspected) rewards=$(lp_metric fly_loop_rewards)"
fi
# The ethos, asserted rather than reviewed: over every case above, check 10 # The ethos, asserted rather than reviewed: over every case above, check 10
# restarted nothing. It reports; a human or a review agent decides. # restarted nothing. It reports; a human or a review agent decides.
if [ ! -s "$lp_fixture/systemctl.log" ]; then if [ ! -s "$lp_fixture/systemctl.log" ]; then
pass "check 10: never acts — no unit was restarted across any of the four cases" pass "check 10: never acts — no unit was restarted across any of the eight cases"
else else
fail "check 10 ACTED, which it must never do: $(cat "$lp_fixture/systemctl.log")" fail "check 10 ACTED, which it must never do: $(cat "$lp_fixture/systemctl.log")"
fi fi

View file

@ -0,0 +1,56 @@
# infra/units/flyedge.service — pushed to /etc/systemd/system/flyedge.service.
#
# The feed WebSocket served from flysim's feed bus (docs/design/flybus.md,
# "Feed over the bus"; services/flysim/crates/fly-edge). DISABLED BY DEFAULT:
# it is in no target's Wants=/Requires= and 07-enable.sh does not enable it.
# With FLY_FEED_VIA=direct (the default, written into /etc/fly/fly.env by
# 05-deploy.sh) flysim binds 127.0.0.1:7400 itself and this unit has nothing
# to do. To move the feed onto the bus on one container:
#
# 1. FLY_FEED_VIA=bus in the env file, then 05-deploy.sh (rewrites fly.env);
# 2. systemctl enable --now flyedge.service; systemctl restart flysim.service
# (flysim stops binding :7400, the edge binds it once the first snapshot
# is on the bus);
# 3. nothing for the watchdog: check 2 reads FLY_FEED_VIA from fly.env and
# follows the feed counters to this unit's loopback /metrics.
#
# Back: FLY_FEED_VIA=direct, deploy, systemctl disable --now flyedge.service,
# restart flysim.
#
# Ordering (docs/design/flybus.md, amendment "Feed store lifecycle"): flysim
# owns the router and its store under /run/fly/bus, so it starts first and
# the edge follows it. Requires= makes an explicit stop or restart of flysim
# (the unstick rule's `systemctl restart flysim.service` included) stop or
# restart the edge with it. A crash-restart of flysim is covered by the edge
# itself: it drops its clients, unbinds :7400 and reconnects every 500 ms,
# so nothing here has to be restarted by hand. The edge holds no state; the
# store is flysim's and a new router removes the previous one's directory.
[Unit]
Description=flyedge: the feed WebSocket served from flysim's feed bus
After=flysim.service
Requires=flysim.service
# A release that predates fly-edge has no binary; stay cleanly inactive
# rather than restart-looping (the flybridge.service header explains why a
# Condition, not a start limit).
ConditionPathExists=/opt/fly/current/fly-edge
[Service]
Type=simple
User=fly
# FLY_FEED_VIA, FLY_BUS_DIR and the rest of flysim's configuration: the edge
# reads the same file so the two cannot disagree about the port or the bus.
EnvironmentFile=/etc/fly/fly.env
Environment=FLY_FEED_BIND=127.0.0.1:7400
Environment=FLY_BUS_DIR=/run/fly/bus
# Its own read-only /metrics and /healthz, for watchdog check 2 in bus mode.
# Loopback only: nothing off the container needs the edge's counters.
Environment=FLY_EDGE_METRICS_ADDR=127.0.0.1:9102
ExecStart=/opt/fly/current/fly-edge
Restart=always
RestartSec=2
# A few snapshots in flight and a WebSocket per client; the store itself is
# flysim's (tmpfs, bounded by feedbus::limits at 32 MiB).
MemoryMax=256M
[Install]
WantedBy=fly.target

View file

@ -30,6 +30,10 @@ WatchdogSec=30
User=fly User=fly
EnvironmentFile=/etc/fly/fly.env EnvironmentFile=/etc/fly/fly.env
Environment=FLY_FEED_BIND=127.0.0.1:7400 Environment=FLY_FEED_BIND=127.0.0.1:7400
# Used only with FLY_FEED_VIA=bus (fly.env; default direct): the embedded
# feed router's socket and artifact store, on tmpfs. flyedge.service names
# the same directory. docs/design/flybus.md, "Feed over the bus".
Environment=FLY_BUS_DIR=/run/fly/bus
Environment=FLY_CONTROL_BIND=127.0.0.1:7401 Environment=FLY_CONTROL_BIND=127.0.0.1:7401
Environment=FLY_METRICS_ADDR=0.0.0.0:9101 Environment=FLY_METRICS_ADDR=0.0.0.0:9101
Environment=FLY_STATE_HOT=/run/fly/state Environment=FLY_STATE_HOT=/run/fly/state

19
package-lock.json generated
View file

@ -14,6 +14,7 @@
"apps/stage": { "apps/stage": {
"name": "@flybrain/stage", "name": "@flybrain/stage",
"version": "0.1.1", "version": "0.1.1",
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"@flybrain/brain": "*", "@flybrain/brain": "*",
"@flybrain/feed": "*", "@flybrain/feed": "*",
@ -892,6 +893,10 @@
"resolved": "packages/feed", "resolved": "packages/feed",
"link": true "link": true
}, },
"node_modules/@flybrain/session-types": {
"resolved": "packages/session-types",
"link": true
},
"node_modules/@flybrain/stage": { "node_modules/@flybrain/stage": {
"resolved": "apps/stage", "resolved": "apps/stage",
"link": true "link": true
@ -3666,6 +3671,7 @@
"packages/brain": { "packages/brain": {
"name": "@flybrain/brain", "name": "@flybrain/brain",
"version": "0.1.1", "version": "0.1.1",
"license": "Apache-2.0",
"devDependencies": { "devDependencies": {
"@types/node": "22.17.0", "@types/node": "22.17.0",
"@types/three": "0.178.1", "@types/three": "0.178.1",
@ -3685,6 +3691,7 @@
"packages/feed": { "packages/feed": {
"name": "@flybrain/feed", "name": "@flybrain/feed",
"version": "0.1.0", "version": "0.1.0",
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"ws": "8.21.3" "ws": "8.21.3"
}, },
@ -3696,9 +3703,21 @@
"typescript": "5.9.2" "typescript": "5.9.2"
} }
}, },
"packages/session-types": {
"name": "@flybrain/session-types",
"version": "0.1.0",
"license": "Apache-2.0",
"devDependencies": {
"@flybrain/brain": "*",
"@types/node": "22.17.0",
"tsx": "4.20.3",
"typescript": "5.9.2"
}
},
"services/bridge": { "services/bridge": {
"name": "@flybrain/bridge", "name": "@flybrain/bridge",
"version": "0.1.0", "version": "0.1.0",
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"@flybrain/feed": "0.1.0", "@flybrain/feed": "0.1.0",
"@twurple/api": "8.1.4", "@twurple/api": "8.1.4",

View file

@ -0,0 +1,78 @@
# @flybrain/session-types
The session framework contracts in TypeScript: types, validation, canonical JSON (RFC 8785)
and canonical digests.
The other half of [`services/flysim/crates/fly-session-types`](../../services/flysim/crates/fly-session-types).
Same rules, same canonical bytes, same digests, and the same fixture corpus: this package
loads the crate's `fixtures/` directory rather than keeping a copy, so a case written once
holds both languages to it. Nothing here opens a socket; it reads, validates and hashes.
This is the internal session path (`docs/design/session-framework/`). The public feed and
control contracts are unchanged and still live in [`@flybrain/feed`](../feed).
## Modules
| Module | Contents |
| --- | --- |
| `canonical` | `canonicalize`, `digestOf`, `parseStrict`, `requireEnvelopeFit`, `rejectBusIdentities` |
| `scalar` | `Id`, `U64`, `Digest`, `Scope`, `RationalNs` with checked arithmetic, and the four identities as branded types |
| `reader` | `Reader`, which reads one object field by field and then refuses any field it did not read |
| `common` | `readScope`, `readSchemaRef`, `readTypedValue`, `operationKeyDigest`, `bodyDigest` |
| `media` | View and audio descriptors and refs, and the `State.*` payloads |
| `workers` | The closed enums and every Agent/Environment/Worker method payload |
| `rpc` | `SessionRpcRequest`, the success and failure replies, `ErrorCode`, `MutationCertainty` |
| `publishing` | `SessionDescriptor`, `CommittedSnapshot` |
| `trace` | The step-v1 section 8 record and the behaviour-only comparator |
| `seed` | `seed-derivation-v1` |
| `checkpoint` | The `FLYSESS1` envelope layout |
| `extensions` | `Environment.SaveSlot`/`RestoreSlot` and `Agent.Rollback` payloads (2026-09-23) |
| `gameboy` | The legacy Game Boy composition: registered schemas, profile, composition declaration |
| `fixtures` | Loading the shared corpus |
## Reading a payload
Every reader takes `unknown`, validates, and hands back a value whose fields are exactly the
ones it read. A payload with an unknown or misspelled field fails instead of silently
defaulting, and a round trip through a reader is the test that no field is dropped.
```ts
import { canonicalize, digestOf, readScope, readPrepareParams, bodyDigest } from '@flybrain/session-types';
const scope = readScope(payload.scope);
const params = readPrepareParams(payload.params);
const digest = bodyDigest('Agent.Prepare', scope, params); // the ipc-v1 section 5 comparison
```
Rules that need another value in hand are separate functions, because a payload cannot check
them alone: `validatePortControlAgainst`, `validateBatch`, `validateSensoryInputAgainst`,
`validateObservationAgainst`, `validateStepResultAgainst`, `validateSnapshotAgainst`,
`validateTelemetryRoles`, `validateRemainder`, `validateCommitAgainstScope`.
## Canonical JSON
Three rules make the two implementations agree byte for byte:
- object keys sort by UTF-16 code unit, which is what comparing JavaScript strings does;
- numbers print with `String(number)`, the ECMAScript algorithm RFC 8785 requires;
- a number is canonicalizable when it is finite and, if integral, no larger in magnitude than
`Number.MAX_SAFE_INTEGER`. Larger integers are refused rather than rounded: every counter
and clock in these contracts is a `U64` decimal string. The rule is on the value, not on how
it was written, because `JSON.parse` cannot tell `1e21` from the same digits written out.
`parseStrict` is a small recursive-descent parser rather than a wrapper around `JSON.parse`,
which keeps the last of two duplicate keys instead of failing.
Digests use `node:crypto`. This package is contract tooling for services and tests, not
browser code; the presentation layer consumes the public feed package instead.
## Tests
```sh
npm test --workspace @flybrain/session-types
npm run typecheck --workspace @flybrain/session-types
```
Nine files, all fixture-driven. The one that says the most about the two implementations is in
`tests/checkpoint.test.ts`: a `FLYSESS1` envelope written here is byte-identical to the one the
Rust crate wrote into the fixture.

View file

@ -0,0 +1,23 @@
{
"name": "@flybrain/session-types",
"version": "0.1.0",
"description": "TypeScript types, validation, canonical JSON (RFC 8785) and canonical digests for the session framework contracts, sharing the fixture corpus of services/flysim/crates/fly-session-types.",
"license": "Apache-2.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "node --import tsx --test tests/**/*.test.ts",
"typecheck": "tsc -p tsconfig.json --pretty false"
},
"devDependencies": {
"@flybrain/brain": "*",
"@types/node": "22.17.0",
"tsx": "4.20.3",
"typescript": "5.9.2"
}
}

View file

@ -0,0 +1,381 @@
/**
* Canonical JSON (RFC 8785), strict parsing and canonical digests.
*
* The Rust crate `services/flysim/crates/fly-session-types` is the other half of this
* contract; `fixtures/valid.json` records the canonical bytes and digest of every accepted
* payload, and both languages assert against it.
*
* Three rules make the two agree:
*
* - object keys sort by UTF-16 code unit, which is what comparing JavaScript strings does;
* - numbers print with `String(number)`, the ECMAScript algorithm RFC 8785 requires;
* - a number is canonicalizable when it is finite and, if integral, no larger in magnitude
* than `Number.MAX_SAFE_INTEGER`. Larger integers are refused rather than rounded: every
* counter and clock in these contracts is a `U64` decimal string. The rule is on the value,
* not on how it was written, because `JSON.parse` cannot tell `1e21` from the same digits
* written out.
*/
import { createHash } from 'node:crypto';
/** The largest JSON envelope, in bytes (bus-v1 section 4). */
export const MAX_ENVELOPE_BYTES = 65_536;
/** Thrown by everything in this package. One error type, like the bus's `WireError`. */
export class ContractError extends Error {
constructor(message: string) {
super(message);
this.name = 'ContractError';
}
}
export function fail(message: string): never {
throw new ContractError(message);
}
/** A JSON value, as strictly parsed. */
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
/** `String(number)` for a canonicalizable number. */
function numberToString(value: number): string {
if (!Number.isFinite(value)) {
fail(`canonical JSON: ${String(value)} is not a finite number`);
}
if (Number.isInteger(value) && Math.abs(value) > Number.MAX_SAFE_INTEGER) {
fail(`canonical JSON: ${String(value)} is an integral value outside the exact double range`);
}
// String(-0) is already "0".
return String(value);
}
function writeString(out: string[], value: string): void {
out.push('"');
for (const character of value) {
switch (character) {
case '"':
out.push('\\"');
break;
case '\\':
out.push('\\\\');
break;
case '\b':
out.push('\\b');
break;
case '\t':
out.push('\\t');
break;
case '\n':
out.push('\\n');
break;
case '\f':
out.push('\\f');
break;
case '\r':
out.push('\\r');
break;
default: {
const point = character.codePointAt(0) ?? 0;
if (point < 0x20) {
out.push(`\\u${point.toString(16).padStart(4, '0')}`);
} else {
out.push(character);
}
}
}
}
out.push('"');
}
function write(out: string[], value: unknown): void {
if (value === null) {
out.push('null');
return;
}
switch (typeof value) {
case 'boolean':
out.push(value ? 'true' : 'false');
return;
case 'number':
out.push(numberToString(value));
return;
case 'string':
writeString(out, value);
return;
case 'object':
break;
default:
fail(`canonical JSON: ${typeof value} is not a JSON value`);
}
if (Array.isArray(value)) {
out.push('[');
value.forEach((item, index) => {
if (index > 0) out.push(',');
write(out, item);
});
out.push(']');
return;
}
const entries = Object.entries(value as Record<string, unknown>);
for (const [key, item] of entries) {
if (item === undefined) fail(`canonical JSON: ${key} is undefined, which is not a JSON value`);
}
// Comparing JavaScript strings compares UTF-16 code units, which is the order RFC 8785
// section 3.2.3 specifies.
entries.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
out.push('{');
entries.forEach(([key, item], index) => {
if (index > 0) out.push(',');
writeString(out, key);
out.push(':');
write(out, item);
});
out.push('}');
}
/** The canonical JSON text of `value`. */
export function canonicalize(value: unknown): string {
const out: string[] = [];
write(out, value);
return out.join('');
}
/** Lowercase hex SHA-256 of `bytes`. */
export function sha256Hex(bytes: Uint8Array | string): string {
return createHash('sha256')
.update(typeof bytes === 'string' ? Buffer.from(bytes, 'utf8') : bytes)
.digest('hex');
}
/** The canonical digest of a JSON value: SHA-256 over its canonical JSON bytes. */
export function digestOf(value: unknown): string {
return sha256Hex(canonicalize(value));
}
/**
* Parses JSON strictly: duplicate keys at any depth, invalid UTF-8, `NaN`/`Infinity`,
* trailing bytes and control characters inside strings are all refused.
*
* `JSON.parse` keeps the last of two duplicate keys instead of failing, so this is a small
* recursive-descent parser rather than a wrapper around it.
*/
export function parseStrict(input: Uint8Array | string): Json {
let text: string;
if (typeof input === 'string') {
text = input;
} else {
try {
text = new TextDecoder('utf-8', { fatal: true }).decode(input);
} catch {
return fail('invalid UTF-8');
}
}
const parser = new Parser(text);
const value = parser.value();
parser.skipWhitespace();
if (!parser.atEnd()) fail('invalid JSON: trailing data');
return value;
}
class Parser {
private index = 0;
constructor(private readonly text: string) {}
atEnd(): boolean {
return this.index >= this.text.length;
}
skipWhitespace(): void {
while (this.index < this.text.length && ' \t\n\r'.includes(this.text[this.index] as string)) {
this.index += 1;
}
}
value(): Json {
this.skipWhitespace();
const character = this.text[this.index];
if (character === undefined) fail('invalid JSON: unexpected end of input');
switch (character) {
case '{':
return this.object();
case '[':
return this.array();
case '"':
return this.string();
case 't':
this.literal('true');
return true;
case 'f':
this.literal('false');
return false;
case 'n':
this.literal('null');
return null;
default:
return this.number();
}
}
private literal(word: string): void {
if (!this.text.startsWith(word, this.index)) fail(`invalid JSON: expected ${word}`);
this.index += word.length;
}
private object(): Json {
this.index += 1;
const out: { [key: string]: Json } = {};
this.skipWhitespace();
if (this.text[this.index] === '}') {
this.index += 1;
return out;
}
for (;;) {
this.skipWhitespace();
if (this.text[this.index] !== '"') fail('invalid JSON: expected a key');
const key = this.string();
if (Object.prototype.hasOwnProperty.call(out, key)) {
fail(`invalid JSON: duplicate key ${JSON.stringify(key)}`);
}
this.skipWhitespace();
if (this.text[this.index] !== ':') fail('invalid JSON: expected :');
this.index += 1;
out[key] = this.value();
this.skipWhitespace();
const next = this.text[this.index];
if (next === ',') {
this.index += 1;
continue;
}
if (next === '}') {
this.index += 1;
return out;
}
fail('invalid JSON: expected , or }');
}
}
private array(): Json {
this.index += 1;
const out: Json[] = [];
this.skipWhitespace();
if (this.text[this.index] === ']') {
this.index += 1;
return out;
}
for (;;) {
out.push(this.value());
this.skipWhitespace();
const next = this.text[this.index];
if (next === ',') {
this.index += 1;
continue;
}
if (next === ']') {
this.index += 1;
return out;
}
fail('invalid JSON: expected , or ]');
}
}
private string(): string {
this.index += 1;
let out = '';
for (;;) {
const character = this.text[this.index];
if (character === undefined) fail('invalid JSON: unterminated string');
this.index += 1;
if (character === '"') return out;
if (character === '\\') {
const escape = this.text[this.index];
this.index += 1;
switch (escape) {
case '"':
case '\\':
case '/':
out += escape;
break;
case 'b':
out += '\b';
break;
case 'f':
out += '\f';
break;
case 'n':
out += '\n';
break;
case 'r':
out += '\r';
break;
case 't':
out += '\t';
break;
case 'u': {
const hex = this.text.slice(this.index, this.index + 4);
if (!/^[0-9a-fA-F]{4}$/.test(hex)) fail('invalid JSON: bad \\u escape');
out += String.fromCharCode(Number.parseInt(hex, 16));
this.index += 4;
break;
}
default:
fail('invalid JSON: bad escape');
}
continue;
}
if (character.charCodeAt(0) < 0x20) fail('invalid JSON: control character in a string');
out += character;
}
}
private number(): number {
const match = /^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][-+]?[0-9]+)?/.exec(
this.text.slice(this.index),
);
if (!match) fail('invalid JSON: expected a value');
this.index += match[0].length;
const value = Number(match[0]);
if (!Number.isFinite(value)) fail('invalid JSON: non-finite number');
return value;
}
}
/**
* Refuses a domain payload that does not fit the bus envelope ceiling. `envelopeOverhead` is
* what the surrounding envelope adds, so a payload that only fits without its envelope fails.
*/
export function requireEnvelopeFit(value: unknown, envelopeOverhead: number): number {
const total = canonicalize(value).length + envelopeOverhead;
if (total > MAX_ENVELOPE_BYTES) {
fail(`envelope: ${total} bytes exceeds the ${MAX_ENVELOPE_BYTES}-byte maximum`);
}
return total;
}
/** Keys that belong to the bus and never to a domain body (ipc-v1 section 5). */
export const BUS_ONLY_KEYS = [
'callId',
'deliveryId',
'ownerId',
'ownerIds',
'deliveryIds',
'requestDeliveryId',
'expectedIncarnation',
'serviceIncarnation',
'connectionId',
'topicSequence',
'subscriptionId',
] as const;
/** Fails if any bus-only key appears anywhere in `value`. */
export function rejectBusIdentities(value: unknown): void {
if (Array.isArray(value)) {
for (const item of value) rejectBusIdentities(item);
return;
}
if (value === null || typeof value !== 'object') return;
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
if ((BUS_ONLY_KEYS as readonly string[]).includes(key)) {
fail(`canonical body: "${key}" is a bus identity and never part of a domain body`);
}
rejectBusIdentities(item);
}
}

View file

@ -0,0 +1,280 @@
/**
* `FLYSESS1`: the envelope layout of
* `docs/design/session-framework/checkpoint-envelope-v1.md`.
*
* The layout half of the specification, not the store: writing generations, fsyncing and
* committing a manifest belong to the STATE-01 slice. `FLYSIM01` is a different format with a
* different magic and is not touched by any of this.
*/
import { createHash } from 'node:crypto';
import { type Json, canonicalize, fail, parseStrict } from './canonical';
import { requireUnique } from './reader';
import { isId } from './scalar';
export const MAGIC = 'FLYSESS1';
export const FOOTER_MAGIC = 'FLYSESSF';
export const VERSION = 1;
export const HEADER_BYTES = 32;
export const TABLE_ENTRY_BYTES = 112;
export const NAME_BYTES = 64;
export const FOOTER_BYTES = 48;
export const ALIGNMENT = 8;
export const MAX_PAYLOADS = 64;
export interface PayloadEntry {
name: string;
offset: number;
byteLength: number;
digest: string;
}
export interface Layout {
manifestOffset: number;
manifestBytes: number;
tableOffset: number;
entries: PayloadEntry[];
footerOffset: number;
totalBytes: number;
}
export interface Envelope {
manifest: Json;
payloads: { name: string; bytes: Uint8Array }[];
layout: Layout;
}
function alignUp(value: number): number {
return Math.ceil(value / ALIGNMENT) * ALIGNMENT;
}
function sha256(bytes: Uint8Array): string {
return createHash('sha256').update(bytes).digest('hex');
}
export function layoutOf(
manifest: unknown,
payloads: readonly { name: string; bytes: Uint8Array }[],
): Layout {
if (payloads.length > MAX_PAYLOADS) fail('checkpoint envelope: at most 64 payloads');
requireUnique(
payloads.map((payload) => payload.name),
'checkpoint envelope: payload names',
);
for (const payload of payloads) {
if (!isId(payload.name)) {
fail(`checkpoint envelope: payload name "${payload.name}" is not an Id`);
}
}
const manifestBytes = new TextEncoder().encode(canonicalize(manifest)).length;
const manifestOffset = HEADER_BYTES;
const tableOffset = alignUp(manifestOffset + manifestBytes);
let offset = alignUp(tableOffset + payloads.length * TABLE_ENTRY_BYTES);
const entries: PayloadEntry[] = [];
for (const payload of payloads) {
entries.push({
name: payload.name,
offset,
byteLength: payload.bytes.length,
digest: sha256(payload.bytes),
});
offset = alignUp(offset + payload.bytes.length);
}
return {
manifestOffset,
manifestBytes,
tableOffset,
entries,
footerOffset: offset,
totalBytes: offset + FOOTER_BYTES,
};
}
export function encode(
manifest: unknown,
payloads: readonly { name: string; bytes: Uint8Array }[],
): Uint8Array {
const layout = layoutOf(manifest, payloads);
const manifestText = new TextEncoder().encode(canonicalize(manifest));
const out = Buffer.alloc(layout.footerOffset);
out.write(MAGIC, 0, 'ascii');
out.writeUInt32LE(VERSION, 8);
out.writeUInt32LE(HEADER_BYTES, 12);
out.writeUInt32LE(layout.manifestBytes, 16);
out.writeUInt32LE(payloads.length, 20);
out.writeUInt32LE(layout.tableOffset, 24);
out.writeUInt32LE(0, 28);
Buffer.from(manifestText).copy(out, layout.manifestOffset);
layout.entries.forEach((entry, index) => {
const base = layout.tableOffset + index * TABLE_ENTRY_BYTES;
out.write(entry.name, base, 'ascii');
out.writeBigUInt64LE(BigInt(entry.offset), base + NAME_BYTES);
out.writeBigUInt64LE(BigInt(entry.byteLength), base + NAME_BYTES + 8);
Buffer.from(entry.digest, 'hex').copy(out, base + NAME_BYTES + 16);
});
layout.entries.forEach((entry, index) => {
Buffer.from((payloads[index] as { bytes: Uint8Array }).bytes).copy(out, entry.offset);
});
const footer = Buffer.alloc(FOOTER_BYTES);
footer.writeBigUInt64LE(BigInt(layout.totalBytes), 0);
Buffer.from(sha256(out), 'hex').copy(footer, 8);
footer.write(FOOTER_MAGIC, 40, 'ascii');
return Buffer.concat([out, footer]);
}
/** Reads and fully validates one envelope. */
export function decode(input: Uint8Array): Envelope {
const bytes = Buffer.from(input);
if (bytes.length < HEADER_BYTES + FOOTER_BYTES) {
fail('checkpoint envelope: shorter than a header plus a footer');
}
if (bytes.subarray(0, 8).toString('ascii') !== MAGIC) {
fail('checkpoint envelope: wrong magic (FLYSIM01 is a different format)');
}
if (bytes.readUInt32LE(8) !== VERSION) fail('checkpoint envelope: unsupported version');
if (bytes.readUInt32LE(12) !== HEADER_BYTES) {
fail('checkpoint envelope: headerBytes must be 32');
}
if (bytes.readUInt32LE(28) !== 0) {
fail('checkpoint envelope: reserved header word must be zero');
}
const manifestBytes = bytes.readUInt32LE(16);
const payloadCount = bytes.readUInt32LE(20);
const tableOffset = bytes.readUInt32LE(24);
if (payloadCount > MAX_PAYLOADS) fail('checkpoint envelope: at most 64 payloads');
const footerOffset = bytes.length - FOOTER_BYTES;
if (bytes.subarray(footerOffset + 40).toString('ascii') !== FOOTER_MAGIC) {
fail('checkpoint envelope: missing footer magic');
}
if (bytes.readBigUInt64LE(footerOffset) !== BigInt(bytes.length)) {
fail('checkpoint envelope: footer length does not match the file');
}
const recorded = bytes.subarray(footerOffset + 8, footerOffset + 40).toString('hex');
if (recorded !== sha256(bytes.subarray(0, footerOffset))) {
fail('checkpoint envelope: footer digest does not match the contents');
}
const manifestEnd = HEADER_BYTES + manifestBytes;
if (manifestEnd > footerOffset) {
fail('checkpoint envelope: manifest runs past the payload area');
}
const manifestSlice = bytes.subarray(HEADER_BYTES, manifestEnd);
const manifest = parseStrict(manifestSlice);
if (canonicalize(manifest) !== manifestSlice.toString('utf8')) {
fail('checkpoint envelope: the manifest is not canonical JSON');
}
if (tableOffset !== alignUp(manifestEnd)) {
fail('checkpoint envelope: the payload table is not at its laid-out offset');
}
const tableEnd = tableOffset + payloadCount * TABLE_ENTRY_BYTES;
if (tableEnd > footerOffset) {
fail('checkpoint envelope: the payload table runs past the payload area');
}
const entries: PayloadEntry[] = [];
const payloads: { name: string; bytes: Uint8Array }[] = [];
let previousEnd = alignUp(tableEnd);
for (let index = 0; index < payloadCount; index += 1) {
const base = tableOffset + index * TABLE_ENTRY_BYTES;
const nameField = bytes.subarray(base, base + NAME_BYTES);
const terminator = nameField.indexOf(0);
const length = terminator === -1 ? NAME_BYTES : terminator;
if (nameField.subarray(length).some((byte) => byte !== 0)) {
fail('checkpoint envelope: a payload name has bytes after its terminator');
}
const name = nameField.subarray(0, length).toString('utf8');
if (!isId(name)) fail(`checkpoint envelope: payload name "${name}" is not an Id`);
const offset = Number(bytes.readBigUInt64LE(base + NAME_BYTES));
const byteLength = Number(bytes.readBigUInt64LE(base + NAME_BYTES + 8));
const digest = bytes.subarray(base + NAME_BYTES + 16, base + NAME_BYTES + 48).toString('hex');
if (offset !== previousEnd) {
fail(
`checkpoint envelope: payload "${name}" starts at ${offset}, not at its aligned ${previousEnd}`,
);
}
const end = offset + byteLength;
if (end > footerOffset) {
fail(`checkpoint envelope: payload "${name}" runs past the payload area`);
}
const payload = bytes.subarray(offset, end);
if (sha256(payload) !== digest) {
fail(`checkpoint envelope: payload "${name}" fails its digest`);
}
previousEnd = alignUp(end);
entries.push({ name, offset, byteLength, digest });
payloads.push({ name, bytes: Uint8Array.from(payload) });
}
requireUnique(
entries.map((entry) => entry.name),
'checkpoint envelope: payload names',
);
if (previousEnd !== footerOffset) {
fail('checkpoint envelope: padding between the last payload and the footer');
}
return {
manifest,
payloads,
layout: {
manifestOffset: HEADER_BYTES,
manifestBytes,
tableOffset,
entries,
footerOffset,
totalBytes: bytes.length,
},
};
}
/**
* The manifest fields state-media-v1 section 4 requires.
*
* `helperState` and `environment` join the list under the 2026-09-22 amendment to
* checkpoint-envelope-v1 section 3.
*/
export const REQUIRED_MANIFEST_FIELDS = [
'envelopeVersion',
'checkpointId',
'sourceScope',
'episodeId',
'worldTime',
'schedulerId',
'compositionDigest',
'portMap',
'compatibility',
'agents',
'coordinator',
'environment',
'helperState',
'payloads',
] as const;
/** Checks the required field set and that the manifest's payload table mirrors the envelope's. */
export function validateManifest(envelope: Envelope): void {
const manifest = envelope.manifest;
if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) {
fail('checkpoint manifest: must be an object');
}
const map = manifest as Record<string, Json>;
for (const field of REQUIRED_MANIFEST_FIELDS) {
if (!Object.prototype.hasOwnProperty.call(map, field)) {
fail(`checkpoint manifest: missing "${field}"`);
}
}
if (map.envelopeVersion !== VERSION) fail('checkpoint manifest: envelopeVersion must be 1');
const listed = map.payloads;
if (!Array.isArray(listed)) fail('checkpoint manifest: payloads must be an array');
if (listed.length !== envelope.layout.entries.length) {
fail('checkpoint manifest: payloads does not match the payload table');
}
listed.forEach((declared, index) => {
const entry = envelope.layout.entries[index] as PayloadEntry;
const record = declared as Record<string, Json>;
if (record.name !== entry.name) {
fail('checkpoint manifest: payload name does not match the table');
}
if (record.byteLength !== String(entry.byteLength)) {
fail(`checkpoint manifest: payload "${entry.name}" byteLength does not match the table`);
}
if (record.digest !== entry.digest) {
fail(`checkpoint manifest: payload "${entry.name}" digest does not match the table`);
}
});
}

View file

@ -0,0 +1,114 @@
/**
* `Scope`, `SchemaRef`, `TypedValue`, the operation key and the canonical body
* (ipc-v1 sections 2 and 5).
*/
import { canonicalize, digestOf, fail, rejectBusIdentities } from './canonical';
import { Reader } from './reader';
import {
MAX_TYPED_VALUE_BYTES,
type RationalNs,
type Scope,
type SchemaRef,
type TypedValue,
isId,
isMethod,
validateRational,
} from './scalar';
export function readScope(value: unknown): Scope {
const reader = new Reader(value, 'Scope');
const scope: Scope = {
sessionId: reader.id('sessionId'),
epoch: reader.id('epoch'),
step: reader.u64('step'),
};
reader.finish();
return scope;
}
export function readNullableScope(value: unknown): Scope | null {
return value === null ? null : readScope(value);
}
export function readSchemaRef(value: unknown): SchemaRef {
const reader = new Reader(value, 'SchemaRef');
const schema: SchemaRef = {
id: reader.id('id'),
version: reader.int('version', 1, 65_535),
digest: reader.digest('digest'),
};
reader.finish();
return schema;
}
export function readTypedValue(value: unknown): TypedValue {
const reader = new Reader(value, 'TypedValue');
const typed: TypedValue = {
schema: readSchemaRef(reader.value('schema')),
value: reader.object('value'),
};
reader.finish();
const length = canonicalize(typed).length;
if (length > MAX_TYPED_VALUE_BYTES) {
fail(
`TypedValue: ${length} bytes of canonical JSON exceeds the ${MAX_TYPED_VALUE_BYTES}-byte limit`,
);
}
return typed;
}
export function readNullableTypedValue(value: unknown): TypedValue | null {
return value === null ? null : readTypedValue(value);
}
export { validateRational };
/** `(sessionId, epoch, step, method, workerId)`: the operation key of a step mutation. */
export interface OperationKey {
scope: Scope;
method: string;
workerId: string;
}
export function operationKeyJson(key: OperationKey): Record<string, unknown> {
if (!isMethod(key.method)) {
fail('OperationKey: method must be 1..=128 printable ASCII characters');
}
if (!isId(key.workerId)) fail('OperationKey: workerId is not a valid id');
return { scope: key.scope, method: key.method, workerId: key.workerId };
}
export function operationKeyDigest(key: OperationKey): string {
return digestOf(operationKeyJson(key));
}
/** The canonical body of a domain operation: method, scope and validated params. */
export function canonicalBody(
method: string,
scope: Scope | null,
params: unknown,
): Record<string, unknown> {
if (!isMethod(method)) {
fail('canonical body: method must be 1..=128 printable ASCII characters');
}
if (params === null || typeof params !== 'object' || Array.isArray(params)) {
fail('canonical body: params must be an object');
}
rejectBusIdentities(params);
return { method, scope, params };
}
export function bodyDigest(method: string, scope: Scope | null, params: unknown): string {
return digestOf(canonicalBody(method, scope, params));
}
export function readRational(value: unknown): RationalNs {
const reader = new Reader(value, 'RationalNs');
const rational: RationalNs = {
numerator: reader.u64('numerator'),
denominator: reader.u64('denominator'),
};
reader.finish();
validateRational(rational);
return rational;
}

View file

@ -0,0 +1,184 @@
/**
* The extension methods of the 2026-09-23 amendments (RT-01a, workers-v1 section 7):
* `Environment.SaveSlot`, `Environment.RestoreSlot` and `Agent.Rollback`.
*
* The Rust twin is `fly-session-types/src/extensions.rs`. The shapes are generic; which
* composition may use them is a capability negotiated by `Worker.Hello`. Nothing console
* specific is in these payloads: the Game Boy lives in the registered schemas of `gameboy`.
*/
import { fail } from './canonical';
import { readTypedValue } from './common';
import { Reader, u64 } from './reader';
import type { Digest, Id, Scope, TypedValue, U64 } from './scalar';
import {
type AgentTelemetry,
type SensoryInput,
type WorldObservation,
readAgentTelemetry,
readSensoryInput,
readWorldObservation,
} from './workers';
export const SLOTS_CAPABILITY = 'gameboy-slots-v1';
export const ROLLBACK_CAPABILITY = 'legacy-ratchet-rollback-v1';
export const ROLLBACK_POLICY = 'legacy-ratchet-rollback-v1';
export const METHOD_SAVE_SLOT = 'Environment.SaveSlot';
export const METHOD_RESTORE_SLOT = 'Environment.RestoreSlot';
export const METHOD_AGENT_ROLLBACK = 'Agent.Rollback';
/** Slots one environment may hold. Not a stated bound; recorded in the schema set. */
export const MAX_SLOTS = 4;
function requirePolicy(policy: string, what: string): void {
if (policy !== ROLLBACK_POLICY) {
fail(`${what}: policy must be ${ROLLBACK_POLICY}, the only rollback policy defined`);
}
}
export interface SaveSlotParams {
slotId: Id;
}
export function readSaveSlotParams(value: unknown): SaveSlotParams {
const reader = new Reader(value, 'SaveSlotParams');
const params: SaveSlotParams = { slotId: reader.id('slotId') };
reader.finish();
return params;
}
export interface SaveSlotResult {
slotId: Id;
boundary: U64;
stateDigest: Digest;
byteLength: U64;
}
export function readSaveSlotResult(value: unknown): SaveSlotResult {
const reader = new Reader(value, 'SaveSlotResult');
const result: SaveSlotResult = {
slotId: reader.id('slotId'),
boundary: reader.u64('boundary'),
stateDigest: reader.digest('stateDigest'),
byteLength: reader.u64('byteLength'),
};
reader.finish();
if (u64(result.byteLength) === 0n) fail('SaveSlotResult: byteLength must be positive');
return result;
}
/** The slot records the committed boundary the call was scoped to. */
export function validateSaveSlotAgainstScope(result: SaveSlotResult, scope: Scope): void {
if (result.boundary !== scope.step) {
fail(`SaveSlotResult: boundary ${result.boundary} must be the scoped committed step ${scope.step}`);
}
}
export interface RestoreSlotParams {
slotId: Id;
priorEpoch: Id;
policy: Id;
}
export function readRestoreSlotParams(value: unknown): RestoreSlotParams {
const reader = new Reader(value, 'RestoreSlotParams');
const params: RestoreSlotParams = {
slotId: reader.id('slotId'),
priorEpoch: reader.id('priorEpoch'),
policy: reader.id('policy'),
};
reader.finish();
requirePolicy(params.policy, 'RestoreSlotParams');
return params;
}
/** A rollback always moves to a new epoch. */
export function validateRestoreSlotAgainstScope(params: RestoreSlotParams, scope: Scope): void {
if (params.priorEpoch === scope.epoch) {
fail('RestoreSlotParams: priorEpoch must differ from the scoped (new) epoch');
}
}
export interface RestoreSlotResult {
slotId: Id;
committedStep: U64;
observation: WorldObservation;
}
export function readRestoreSlotResult(value: unknown): RestoreSlotResult {
const reader = new Reader(value, 'RestoreSlotResult');
const result: RestoreSlotResult = {
slotId: reader.id('slotId'),
committedStep: reader.u64('committedStep'),
observation: readWorldObservation(reader.value('observation')),
};
reader.finish();
if (result.observation.boundary !== result.committedStep) {
fail('RestoreSlotResult: the observation boundary must be the committed step');
}
if (result.observation.audio.length !== 0) {
fail('RestoreSlotResult: a restored slot ran no transition and carries no audio chunk');
}
return result;
}
export interface AgentRollbackParams {
agentId: Id;
priorEpoch: Id;
policy: Id;
input: SensoryInput;
decisionContext: TypedValue;
}
export function readAgentRollbackParams(value: unknown): AgentRollbackParams {
const reader = new Reader(value, 'AgentRollbackParams');
const params: AgentRollbackParams = {
agentId: reader.id('agentId'),
priorEpoch: reader.id('priorEpoch'),
policy: reader.id('policy'),
input: readSensoryInput(reader.value('input')),
decisionContext: readTypedValue(reader.value('decisionContext')),
};
reader.finish();
requirePolicy(params.policy, 'AgentRollbackParams');
return params;
}
/** A new epoch, and the installed input is the restored boundary: the scoped step. */
export function validateAgentRollbackAgainstScope(params: AgentRollbackParams, scope: Scope): void {
if (params.priorEpoch === scope.epoch) {
fail('AgentRollbackParams: priorEpoch must differ from the scoped (new) epoch');
}
if (params.input.boundary !== scope.step) {
fail(`AgentRollbackParams: input.boundary ${params.input.boundary} must be the scoped step ${scope.step}`);
}
}
export interface AgentRollbackResult {
agentId: Id;
committedStep: U64;
decisionContextDigest: Digest;
telemetry: AgentTelemetry;
}
export function readAgentRollbackResult(value: unknown): AgentRollbackResult {
const reader = new Reader(value, 'AgentRollbackResult');
const result: AgentRollbackResult = {
agentId: reader.id('agentId'),
committedStep: reader.u64('committedStep'),
decisionContextDigest: reader.digest('decisionContextDigest'),
telemetry: readAgentTelemetry(reader.value('telemetry')),
};
reader.finish();
return result;
}
/** A rollback runs no tick: the acknowledged step is the scoped one. */
export function validateAgentRollbackResultAgainstScope(
result: AgentRollbackResult,
scope: Scope,
): void {
if (result.committedStep !== scope.step) {
fail(
`AgentRollbackResult: committedStep ${result.committedStep} must be the scoped step ${scope.step}; a rollback runs no tick`,
);
}
}

View file

@ -0,0 +1,62 @@
/**
* Loading the fixture corpus, which lives with the Rust crate:
* `services/flysim/crates/fly-session-types/fixtures`.
*
* One corpus, two implementations. A case written once holds both languages to it.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { type Json, fail, parseStrict } from './canonical';
const here = fileURLToPath(new URL('.', import.meta.url));
/** The fixture directory. */
export const FIXTURE_DIR = join(
here,
'../../../services/flysim/crates/fly-session-types/fixtures',
);
export function loadBytes(name: string): Uint8Array {
return new Uint8Array(readFileSync(join(FIXTURE_DIR, name)));
}
/** Reads one fixture file, parsed strictly. */
export function load(name: string): Json {
return parseStrict(loadBytes(name));
}
export function section(file: Json, key: string): Json[] {
const value = (file as Record<string, Json>)[key];
if (!Array.isArray(value) || value.length === 0) {
fail(`fixture: ${key} must be a nonempty array`);
}
return value;
}
export function cases(file: Json): Json[] {
return section(file, 'cases');
}
/** A string field of one case. */
export function field(value: Json, key: string): string {
const found = (value as Record<string, Json>)[key];
if (typeof found !== 'string') fail(`fixture case: missing string field "${key}"`);
return found;
}
export function optionalField(value: Json, key: string): string | undefined {
const found = (value as Record<string, Json>)[key];
return typeof found === 'string' ? found : undefined;
}
export function member(value: Json, key: string): Json {
const found = (value as Record<string, Json>)[key];
if (found === undefined) fail(`fixture case: missing field "${key}"`);
return found;
}
export function decodeBase64(text: string): Uint8Array {
return new Uint8Array(Buffer.from(text, 'base64'));
}

View file

@ -0,0 +1,560 @@
/**
* The legacy Game Boy composition's registered schemas and declarations (PROF-02a,
* `docs/design/session-framework/legacy-gameboy-v1.md`).
*
* The Rust twin is `fly-session-types/src/gameboy.rs`, which renders every declaration and
* digest into `fixtures/gameboy-legacy.json`. This side keeps the references as constants and
* its tests recompute each digest from that file with this package's own canonical JSON, so a
* drift in either language fails.
*
* Nothing here is a generic session type: these values travel inside `TypedValue`s or are
* documents an `AssetRef` or the composition digest names.
*/
import { digestOf, fail } from './canonical';
import { readRational, readSchemaRef } from './common';
import { Reader, readArtifactRef, requireUnique, u64 } from './reader';
import type { ArtifactRef, Digest, Id, RationalNs, SchemaRef, TypedValue } from './scalar';
import { isDigest } from './scalar';
import { type AssetRef, readAssetRef } from './workers';
import { MAX_SLOTS, ROLLBACK_POLICY, SLOTS_CAPABILITY } from './extensions';
export const PROFILE_ID = 'gameboy-legacy-fafb-v783-v1';
export const DATASET_ID = 'fafb-v783';
export const FINGERPRINT_SCHEMA = 1;
/** Today's schema-1 fingerprint of `data/fafb-v783`: seven SHA-256 digests joined with ':'. */
export const FAFB_V783_FINGERPRINT = [
'75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3',
'1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e',
'63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5',
'f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7',
'ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62',
'b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634',
'dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc',
].join(':');
export const KERNEL_VERSION = 'lif-1ms-f64-v2';
export const PLASTICITY_VERSION = 'fly-kc-mbon-rstdp-v2';
export const WARMUP_MS = 2500;
export const STIMULUS_REWARD_PULSE = 'reward-pulse';
export const EXCEPTION_MACRO_ROLES = 'macro-roles-outside-fingerprint';
export const PROFILE_FORMAT = 'fly-profile-v1';
export const VIEW_ID = 'lcd';
export const VIEW_WIDTH = 160;
export const VIEW_HEIGHT = 144;
export const GAMEBOY_BUTTONS = ['up', 'down', 'left', 'right', 'a', 'b', 'start', 'select'] as const;
export const MEMORY_IMAGE_BYTES = 65_536n;
export const EXECUTOR_ID = 'pokered-macros-v1';
export const RESTORE_SEMANTICS = 'legacy-transient-reset';
export const CHECKPOINT_FORMAT_OF_RECORD = 'FLYSIM01';
export const SCHEDULER = 'lockstep-v1';
export const SETUP_FRAMES = 1;
export const MAX_MACRO_CHANNELS = 64;
export const MAX_COMPATIBILITY_BYTES = 1024;
export const MACRO_MODES = ['raw', 'macros'] as const;
export const ROLLBACK_TRIGGERS = ['stall', 'game-over'] as const;
/** One Game Boy frame, 70224 cycles at 4194304 Hz: `8572265625/512` ns exactly. */
export const STEP_DURATION: RationalNs = { numerator: '8572265625', denominator: '512' };
/** One model tick, 1 ms. */
export const TICK_DURATION: RationalNs = { numerator: '1000000', denominator: '1' };
/** The registered payload schema references, digests over their canonical declarations. */
export const READOUT_CONTEXT_SCHEMA: SchemaRef = {
id: 'gameboy-readout-context-v1',
version: 1,
digest: '78a5312f8608a1399e7a16549a1b95a43b87137618d59815b9b06c1bb184014d',
};
export const CHANNELS_SCHEMA: SchemaRef = {
id: 'gameboy-channels-v1',
version: 1,
digest: '28bd89bfa41ec73fb2785959a64d9975b4a932e10cd9e51dc4bc29020c823595',
};
export const JOYPAD_SCHEMA: SchemaRef = {
id: 'gameboy-joypad-v1',
version: 1,
digest: '1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e',
};
export const MEMORY_INSPECTION_SCHEMA: SchemaRef = {
id: 'gameboy-memory-inspection-v1',
version: 1,
digest: 'd6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6',
};
export const ROLLBACK_REQUEST_SCHEMA: SchemaRef = {
id: 'legacy-ratchet-rollback-v1',
version: 1,
digest: '0610f899a4746e5991a07dbc3c847ada15d7c68cedf0664244a3c1d87c175c70',
};
export const PAYLOAD_SCHEMAS: readonly SchemaRef[] = [
READOUT_CONTEXT_SCHEMA,
CHANNELS_SCHEMA,
JOYPAD_SCHEMA,
MEMORY_INSPECTION_SCHEMA,
ROLLBACK_REQUEST_SCHEMA,
];
/** The legacy profile document's AssetRef digest: SHA-256 of its canonical JSON. */
export const PROFILE_DIGEST = '41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878';
/** `ChannelName`: a decoder channel or rate-role name. Not an Id: legacy names carry '_'. */
export function isChannelName(value: unknown): value is string {
return typeof value === 'string' && /^[a-z][a-z0-9_]{0,63}$/.test(value);
}
function sameSchema(found: SchemaRef, expected: SchemaRef, what: string): void {
if (found.id !== expected.id || found.version !== expected.version || found.digest !== expected.digest) {
fail(`${what} must be the registered ${expected.id} reference`);
}
}
function exact(found: unknown, expected: unknown, what: string): void {
if (found !== expected) fail(`${what} must be ${JSON.stringify(expected)}`);
}
function channelList(reader: Reader, key: string, high: number): string[] {
const items = reader.list(key, 0, high, (item) => {
if (!isChannelName(item)) fail('every entry must be a channel name');
return item;
});
requireUnique(items, key);
return items;
}
function uniqueIds(reader: Reader, key: string, low: number, high: number): Id[] {
const ids = reader.idList(key, low, high);
requireUnique(ids, key);
return ids;
}
function typedAs<T>(value: TypedValue, schema: SchemaRef, read: (v: unknown) => T): T {
sameSchema(value.schema, schema, 'TypedValue.schema');
return read(value.value);
}
// gameboy-readout-context-v1 ---------------------------------------------------------------
export interface GameboyLocation {
area: number;
x: number;
y: number;
}
export interface GameboyReadoutContext {
boot: boolean;
bound: string[];
location: GameboyLocation | null;
}
export function readGameboyReadoutContext(value: unknown): GameboyReadoutContext {
const reader = new Reader(value, 'GameboyReadoutContext');
const boot = reader.boolean('boot');
const bound = channelList(reader, 'bound', MAX_MACRO_CHANNELS);
const raw = reader.value('location');
let location: GameboyLocation | null = null;
if (raw !== null) {
const l = new Reader(raw, 'GameboyReadoutContext.location');
location = {
area: l.int('area', 0, 4_294_967_295),
x: l.int('x', 0, 4_294_967_295),
y: l.int('y', 0, 4_294_967_295),
};
l.finish();
}
reader.finish();
return { boot, bound, location };
}
export function readGameboyReadoutContextTyped(value: TypedValue): GameboyReadoutContext {
return typedAs(value, READOUT_CONTEXT_SCHEMA, readGameboyReadoutContext);
}
/** `bound` is a subset of the composition's macro channels, in their order. */
export function validateReadoutContextAgainst(
context: GameboyReadoutContext,
macroChannels: readonly string[],
): void {
let cursor = 0;
for (const channel of context.bound) {
const offset = macroChannels.slice(cursor).indexOf(channel);
if (offset < 0) {
fail(
`GameboyReadoutContext: bound channel "${channel}" is not a macro channel of the composition, or is out of order`,
);
}
cursor += offset + 1;
}
}
// gameboy-channels-v1 ----------------------------------------------------------------------
export interface GameboyChannelsDecision {
buttons: { id: (typeof GAMEBOY_BUTTONS)[number]; down: boolean }[];
macro: string | null;
}
export function readGameboyChannelsDecision(value: unknown): GameboyChannelsDecision {
const reader = new Reader(value, 'GameboyChannelsDecision');
const buttons = reader.list('buttons', 8, 8, (item) => {
const b = new Reader(item, 'GameboyChannelsDecision.buttons');
const entry = { id: b.id('id'), down: b.boolean('down') };
b.finish();
return entry;
});
buttons.forEach((button, index) => {
if (button.id !== GAMEBOY_BUTTONS[index]) {
fail(`GameboyChannelsDecision: buttons must list ${GAMEBOY_BUTTONS.join(',')} in that order`);
}
});
const macro = reader.value('macro');
if (macro !== null && !isChannelName(macro)) {
fail('GameboyChannelsDecision: macro must be null or a channel name');
}
reader.finish();
return {
buttons: buttons as GameboyChannelsDecision['buttons'],
macro: macro as string | null,
};
}
export function readGameboyChannelsDecisionTyped(value: TypedValue): GameboyChannelsDecision {
return typedAs(value, CHANNELS_SCHEMA, readGameboyChannelsDecision);
}
/** The joypad mask, bit `i` for `GAMEBOY_BUTTONS[i]`. */
export function channelsMask(decision: GameboyChannelsDecision): number {
return decision.buttons.reduce((mask, button, bit) => (button.down ? mask | (1 << bit) : mask), 0);
}
/** The macro group only ever activates a bound channel. */
export function validateDecisionAgainst(
decision: GameboyChannelsDecision,
context: GameboyReadoutContext,
): void {
if (decision.macro !== null && !context.bound.includes(decision.macro)) {
fail(`GameboyChannelsDecision: macro "${decision.macro}" is not bound in the decision context`);
}
}
// gameboy-memory-inspection-v1 -------------------------------------------------------------
export interface GameboyMemoryInspection {
memory: ArtifactRef;
romDigest: Digest;
}
export function readGameboyMemoryInspection(value: unknown): GameboyMemoryInspection {
const reader = new Reader(value, 'GameboyMemoryInspection');
const inspection: GameboyMemoryInspection = {
memory: readArtifactRef(reader.value('memory')),
romDigest: reader.digest('romDigest'),
};
reader.finish();
if (u64(inspection.memory.byteLength) !== MEMORY_IMAGE_BYTES) {
fail(`GameboyMemoryInspection: the memory image is exactly ${MEMORY_IMAGE_BYTES} bytes`);
}
return inspection;
}
export function readGameboyMemoryInspectionTyped(value: TypedValue): GameboyMemoryInspection {
return typedAs(value, MEMORY_INSPECTION_SCHEMA, readGameboyMemoryInspection);
}
/** The ROM the executor reads is the content the environment runs. */
export function validateInspectionAgainst(
inspection: GameboyMemoryInspection,
contentDigest: Digest,
rom: AssetRef,
): void {
if (inspection.romDigest !== contentDigest || rom.digest !== contentDigest) {
fail(
'GameboyMemoryInspection: romDigest, the environment contentDigest and the executor rom must agree',
);
}
}
// legacy-ratchet-rollback-v1 ---------------------------------------------------------------
export interface LegacyRatchetRollbackRequest {
slotId: Id;
trigger: (typeof ROLLBACK_TRIGGERS)[number];
}
export function readLegacyRatchetRollbackRequest(value: unknown): LegacyRatchetRollbackRequest {
const reader = new Reader(value, 'LegacyRatchetRollbackRequest');
const request: LegacyRatchetRollbackRequest = {
slotId: reader.id('slotId'),
trigger: reader.enumeration('trigger', ROLLBACK_TRIGGERS),
};
reader.finish();
return request;
}
export function readLegacyRatchetRollbackRequestTyped(
value: TypedValue,
): LegacyRatchetRollbackRequest {
return typedAs(value, ROLLBACK_REQUEST_SCHEMA, readLegacyRatchetRollbackRequest);
}
// The legacy profile ------------------------------------------------------------------------
export interface LegacyGameboyProfile {
profileId: string;
datasetId: string;
fingerprintSchema: number;
datasetFingerprint: string;
kernelVersion: string;
plasticityVersion: string;
tickDuration: RationalNs;
warmupMs: number;
view: { viewId: Id; width: number; height: number };
supportedStimuli: Id[];
readoutContextSchema: SchemaRef;
decisionSchema: SchemaRef;
legacyExceptions: Id[];
}
function sameRational(found: RationalNs, expected: RationalNs, what: string): void {
if (found.numerator !== expected.numerator || found.denominator !== expected.denominator) {
fail(`${what} must be ${expected.numerator}/${expected.denominator}`);
}
}
export function readLegacyGameboyProfile(value: unknown): LegacyGameboyProfile {
const reader = new Reader(value, 'LegacyGameboyProfile');
const viewReader = (raw: unknown) => {
const v = new Reader(raw, 'LegacyGameboyProfile.view');
const view = {
viewId: v.id('viewId'),
width: v.int('width', VIEW_WIDTH, VIEW_WIDTH),
height: v.int('height', VIEW_HEIGHT, VIEW_HEIGHT),
};
v.finish();
return view;
};
const profile: LegacyGameboyProfile = {
profileId: reader.string('profileId'),
datasetId: reader.string('datasetId'),
fingerprintSchema: reader.int('fingerprintSchema', FINGERPRINT_SCHEMA, FINGERPRINT_SCHEMA),
datasetFingerprint: reader.string('datasetFingerprint'),
kernelVersion: reader.string('kernelVersion'),
plasticityVersion: reader.string('plasticityVersion'),
tickDuration: readRational(reader.value('tickDuration')),
warmupMs: reader.int('warmupMs', WARMUP_MS, WARMUP_MS),
view: viewReader(reader.value('view')),
supportedStimuli: uniqueIds(reader, 'supportedStimuli', 1, 1),
readoutContextSchema: readSchemaRef(reader.value('readoutContextSchema')),
decisionSchema: readSchemaRef(reader.value('decisionSchema')),
legacyExceptions: uniqueIds(reader, 'legacyExceptions', 1, 1),
};
reader.finish();
exact(profile.profileId, PROFILE_ID, 'profileId');
exact(profile.datasetId, DATASET_ID, 'datasetId');
exact(profile.kernelVersion, KERNEL_VERSION, 'kernelVersion');
exact(profile.plasticityVersion, PLASTICITY_VERSION, 'plasticityVersion');
sameRational(profile.tickDuration, TICK_DURATION, 'LegacyGameboyProfile: tickDuration');
exact(profile.view.viewId, VIEW_ID, 'view.viewId');
exact(profile.supportedStimuli[0], STIMULUS_REWARD_PULSE, 'supportedStimuli[0]');
exact(profile.legacyExceptions[0], EXCEPTION_MACRO_ROLES, 'legacyExceptions[0]');
if (profile.datasetFingerprint !== FAFB_V783_FINGERPRINT) {
fail(
"LegacyGameboyProfile: datasetFingerprint must be today's fafb-v783 schema-1 fingerprint; another fingerprint is another profile",
);
}
sameSchema(profile.readoutContextSchema, READOUT_CONTEXT_SCHEMA, 'readoutContextSchema');
sameSchema(profile.decisionSchema, CHANNELS_SCHEMA, 'decisionSchema');
return profile;
}
// The legacy composition --------------------------------------------------------------------
export interface LegacyGameboyComposition {
compositionId: Id;
scheduler: string;
profile: AssetRef;
executor: {
id: string;
rom: AssetRef;
adapter: Id;
symbolProvenance: string;
mode: (typeof MACRO_MODES)[number];
macroChannels: string[];
};
decoderConfigDigest: Digest;
environment: {
extensions: Id[];
slots: Id[];
stepDuration: RationalNs;
inspectionSchema: SchemaRef;
controllerSchema: SchemaRef;
setupFrames: number;
audio: { sampleRate: number; channels: number };
};
episodePolicy: string;
restore: string;
checkpointFormatOfRecord: string;
flysimCompatibility: string;
}
export function readLegacyGameboyComposition(value: unknown): LegacyGameboyComposition {
const reader = new Reader(value, 'LegacyGameboyComposition');
const compositionId = reader.id('compositionId');
const scheduler = reader.string('scheduler');
const profile = readAssetRef(reader.value('profile'));
const e = new Reader(reader.value('executor'), 'LegacyGameboyComposition.executor');
const executor = {
id: e.string('id'),
rom: readAssetRef(e.value('rom')),
adapter: e.id('adapter'),
symbolProvenance: e.boundedString('symbolProvenance', 64),
mode: e.enumeration('mode', MACRO_MODES),
macroChannels: channelList(e, 'macroChannels', MAX_MACRO_CHANNELS),
};
e.finish();
const decoderConfigDigest = reader.digest('decoderConfigDigest');
const n = new Reader(reader.value('environment'), 'LegacyGameboyComposition.environment');
const extensions = uniqueIds(n, 'extensions', 1, 1);
const slots = uniqueIds(n, 'slots', 1, MAX_SLOTS);
const stepDuration = readRational(n.value('stepDuration'));
const inspectionSchema = readSchemaRef(n.value('inspectionSchema'));
const controllerSchema = readSchemaRef(n.value('controllerSchema'));
const setupFrames = n.int('setupFrames', SETUP_FRAMES, SETUP_FRAMES);
const a = new Reader(n.value('audio'), 'environment.audio');
const audio = { sampleRate: a.int('sampleRate', 8000, 192_000), channels: a.int('channels', 2, 2) };
a.finish();
n.finish();
const composition: LegacyGameboyComposition = {
compositionId,
scheduler,
profile,
executor,
decoderConfigDigest,
environment: {
extensions,
slots,
stepDuration,
inspectionSchema,
controllerSchema,
setupFrames,
audio,
},
episodePolicy: reader.string('episodePolicy'),
restore: reader.string('restore'),
checkpointFormatOfRecord: reader.string('checkpointFormatOfRecord'),
flysimCompatibility: reader.string('flysimCompatibility'),
};
reader.finish();
exact(scheduler, SCHEDULER, 'scheduler');
exact(executor.id, EXECUTOR_ID, 'executor.id');
exact(extensions[0], SLOTS_CAPABILITY, 'environment.extensions[0]');
sameRational(stepDuration, STEP_DURATION, 'LegacyGameboyComposition: environment.stepDuration');
sameSchema(inspectionSchema, MEMORY_INSPECTION_SCHEMA, 'environment.inspectionSchema');
sameSchema(controllerSchema, JOYPAD_SCHEMA, 'environment.controllerSchema');
exact(composition.episodePolicy, ROLLBACK_POLICY, 'episodePolicy');
exact(composition.restore, RESTORE_SEMANTICS, 'restore');
exact(composition.checkpointFormatOfRecord, CHECKPOINT_FORMAT_OF_RECORD, 'checkpointFormatOfRecord');
if (profile.format !== PROFILE_FORMAT || profile.digest !== PROFILE_DIGEST) {
fail(
'LegacyGameboyComposition: profile must name the legacy profile document (format fly-profile-v1, its digest)',
);
}
if (executor.mode === 'raw' && executor.macroChannels.length !== 0) {
fail('LegacyGameboyComposition: raw mode deals no macro channels');
}
if (executor.mode === 'macros' && executor.macroChannels.length === 0) {
fail('LegacyGameboyComposition: macros mode needs its macro channels');
}
const text = composition.flysimCompatibility;
if (text.length === 0 || Buffer.byteLength(text, 'utf8') > MAX_COMPATIBILITY_BYTES) {
fail('LegacyGameboyComposition: flysimCompatibility must be 1..=1024 bytes');
}
const segments = text.split('/');
const agrees =
segments.length >= 6 &&
segments[0] === KERNEL_VERSION &&
segments[1] === executor.adapter &&
segments[2] === FAFB_V783_FINGERPRINT &&
segments[3] === PLASTICITY_VERSION &&
segments[5] === `pokered:${executor.symbolProvenance}`;
if (!agrees) {
fail(
"LegacyGameboyComposition: flysimCompatibility's kernel, adapter, fingerprint, plasticity and pokered segments must agree with the declaration",
);
}
return composition;
}
/** SHA-256 of the canonical declaration: the `declaration=` line of the composition digest. */
export function compositionDeclarationDigest(composition: LegacyGameboyComposition): Digest {
const digest = digestOf(composition);
if (!isDigest(digest)) fail('digest');
return digest;
}
// The decoder configuration digest ------------------------------------------------------------
/** The name of the canonical decoder-configuration form (legacy-gameboy-v1 section 12). */
export const DECODER_CONFIG_FORM = 'gameboy-decoder-config-v1';
interface GroupLike {
channels: Record<string, string>;
decisionMs: number;
holdMs: number;
hysteresis: number;
fatigueGain: number;
fatigueDecay: number;
blockedFatigue: number;
blockedMs: number;
}
/** Structurally the oracle's `DecoderConfig` (`packages/brain/src/readout/decoder.ts`). */
export interface DecoderConfigLike {
exclusive?: GroupLike;
macros?: GroupLike;
pulses: {
channel: string;
role: string;
holdMs: number;
cooldownMs: number;
threshold: number;
boot?: { cooldownMs: number; threshold: number };
throttleGroup?: string;
}[];
clearLockoutMs: number;
}
function groupForm(group: GroupLike | undefined): unknown {
if (!group) return null;
return {
// Channel order breaks argmax ties, so it is part of the identity: an array, not a map,
// because canonical JSON sorts object keys.
channels: Object.entries(group.channels).map(([channel, role]) => ({ channel, role })),
decisionMs: group.decisionMs,
holdMs: group.holdMs,
hysteresis: group.hysteresis,
fatigueGain: group.fatigueGain,
fatigueDecay: group.fatigueDecay,
blockedFatigue: group.blockedFatigue,
blockedMs: group.blockedMs,
};
}
/** The canonical form `decoderConfigDigest` is taken over. */
export function decoderConfigForm(config: DecoderConfigLike): unknown {
return {
form: DECODER_CONFIG_FORM,
exclusive: groupForm(config.exclusive),
macros: groupForm(config.macros),
pulses: config.pulses.map((pulse) => ({
channel: pulse.channel,
role: pulse.role,
holdMs: pulse.holdMs,
cooldownMs: pulse.cooldownMs,
threshold: pulse.threshold,
boot: pulse.boot ? { cooldownMs: pulse.boot.cooldownMs, threshold: pulse.boot.threshold } : null,
throttleGroup: pulse.throttleGroup ?? null,
})),
clearLockoutMs: config.clearLockoutMs,
};
}
/** `LegacyGameboyComposition.decoderConfigDigest`: SHA-256 of the canonical form. */
export function decoderConfigDigest(config: DecoderConfigLike): Digest {
return digestOf(decoderConfigForm(config));
}

View file

@ -0,0 +1,21 @@
/**
* `@flybrain/session-types`: the session framework contracts in TypeScript.
*
* The other half of `services/flysim/crates/fly-session-types`. Same rules, same canonical
* JSON, same digests, same fixtures. Nothing here opens a socket: it reads, validates and
* hashes payloads.
*/
export * from './canonical';
export * from './scalar';
export * from './reader';
export * from './common';
export * from './media';
export * from './workers';
export * from './rpc';
export * from './publishing';
export * from './trace';
export * from './extensions';
export * as seed from './seed';
export * as checkpoint from './checkpoint';
export * as gameboy from './gameboy';
export * as fixtures from './fixtures';

View file

@ -0,0 +1,264 @@
/** Native observation media (state-media-v1 section 2) and the `State.*` payloads (section 5). */
import { fail } from './canonical';
import { readRational, readScope } from './common';
import { Reader, readArtifactRef, requireUnique, u64 } from './reader';
import { type ArtifactRef, type Digest, type Id, type Scope, type U64, isDigest } from './scalar';
/** Max views per sensory input (workers-v1 section 1), and per descriptor list. */
export const MAX_VIEWS = 8;
export const MAX_VIEW_DIMENSION = 4096;
export const MAX_PIXEL_ASPECT = 65_535;
export const MAX_OBSERVATION_DELAY_STEPS = 8;
export const MAX_SAMPLE_FRAMES = 192_000;
/** Not a stated bound; this crate's choice, published in the schema set. */
export const MAX_AUDIO_STREAMS = 8;
export interface ViewDescriptor {
viewId: Id;
width: number;
height: number;
format: 'rgba8';
rowStride: number;
pixelAspect: { numerator: number; denominator: number };
observationDelaySteps: number;
}
export interface ViewRef {
viewId: Id;
producedStep: U64;
pixels: ArtifactRef;
}
export interface AudioDescriptor {
streamId: Id;
sampleRate: number;
channels: number;
format: 'f32le-interleaved';
}
export interface AudioRef {
streamId: Id;
firstSample: U64;
sampleFrames: number;
samples: ArtifactRef;
discontinuity: boolean;
}
export function readViewDescriptor(value: unknown): ViewDescriptor {
const reader = new Reader(value, 'ViewDescriptor');
const viewId = reader.id('viewId');
const width = reader.int('width', 1, MAX_VIEW_DIMENSION);
const height = reader.int('height', 1, MAX_VIEW_DIMENSION);
const format = reader.constant('format', 'rgba8');
const rowStride = reader.int('rowStride', 1, MAX_VIEW_DIMENSION * 4);
const aspectReader = new Reader(reader.value('pixelAspect'), 'ViewDescriptor.pixelAspect');
const pixelAspect = {
numerator: aspectReader.int('numerator', 1, MAX_PIXEL_ASPECT),
denominator: aspectReader.int('denominator', 1, MAX_PIXEL_ASPECT),
};
aspectReader.finish();
const observationDelaySteps = reader.int(
'observationDelaySteps',
0,
MAX_OBSERVATION_DELAY_STEPS,
);
reader.finish();
if (rowStride !== width * 4) {
fail('ViewDescriptor: rowStride must be exactly 4 x width (no padded rows in v1)');
}
return { viewId, width, height, format, rowStride, pixelAspect, observationDelaySteps };
}
/** The exact byte length of one frame of this view. */
export function frameBytes(descriptor: ViewDescriptor): number {
return descriptor.rowStride * descriptor.height;
}
/** `max(0, boundary - observationDelaySteps)` (state-media-v1 section 2). */
export function requiredProducedStep(descriptor: ViewDescriptor, boundary: bigint): bigint {
const delay = BigInt(descriptor.observationDelaySteps);
return boundary > delay ? boundary - delay : 0n;
}
export function readViewRef(value: unknown): ViewRef {
const reader = new Reader(value, 'ViewRef');
const view: ViewRef = {
viewId: reader.id('viewId'),
producedStep: reader.u64('producedStep'),
pixels: readArtifactRef(reader.value('pixels')),
};
reader.finish();
if (u64(view.pixels.byteLength) === 0n) {
fail('ViewRef: pixels must have a positive byte length');
}
return view;
}
export function readViewList(reader: Reader, key: string): ViewRef[] {
const views = reader.list(key, 0, MAX_VIEWS, readViewRef);
requireUnique(
views.map((view) => view.viewId),
key,
);
return views;
}
export function readAudioDescriptor(value: unknown): AudioDescriptor {
const reader = new Reader(value, 'AudioDescriptor');
const descriptor: AudioDescriptor = {
streamId: reader.id('streamId'),
sampleRate: reader.int('sampleRate', 8_000, 192_000),
channels: reader.int('channels', 1, 8),
format: reader.constant('format', 'f32le-interleaved'),
};
reader.finish();
return descriptor;
}
export function readAudioRef(value: unknown): AudioRef {
const reader = new Reader(value, 'AudioRef');
const chunk: AudioRef = {
streamId: reader.id('streamId'),
firstSample: reader.u64('firstSample'),
sampleFrames: reader.int('sampleFrames', 0, MAX_SAMPLE_FRAMES),
samples: readArtifactRef(reader.value('samples')),
discontinuity: reader.boolean('discontinuity'),
};
reader.finish();
if (u64(chunk.firstSample) + BigInt(chunk.sampleFrames) > 18446744073709551615n) {
fail('AudioRef: firstSample + sampleFrames overflows U64');
}
return chunk;
}
export function readAudioList(reader: Reader, key: string): AudioRef[] {
const audio = reader.list(key, 0, MAX_AUDIO_STREAMS, readAudioRef);
requireUnique(
audio.map((chunk) => chunk.streamId),
key,
);
return audio;
}
/** Byte shape and producing boundary against the descriptor that declared this view. */
export function validateViewAgainst(
view: ViewRef,
descriptor: ViewDescriptor,
boundary: bigint | null,
): void {
if (view.viewId !== descriptor.viewId) {
fail(`ViewRef: viewId "${view.viewId}" does not match descriptor "${descriptor.viewId}"`);
}
if (u64(view.pixels.byteLength) !== BigInt(frameBytes(descriptor))) {
fail(
`ViewRef ${view.viewId}: artifact is ${view.pixels.byteLength} bytes, rowStride x height is ${frameBytes(descriptor)}`,
);
}
if (boundary !== null) {
const expected = requiredProducedStep(descriptor, boundary);
if (u64(view.producedStep) !== expected) {
fail(
`ViewRef ${view.viewId}: producedStep ${view.producedStep} must be max(0, ${boundary} - ${descriptor.observationDelaySteps}) = ${expected}`,
);
}
}
}
export function validateAudioAgainst(chunk: AudioRef, descriptor: AudioDescriptor): void {
if (chunk.streamId !== descriptor.streamId) {
fail(`AudioRef: streamId "${chunk.streamId}" does not match the descriptor`);
}
const expected = BigInt(chunk.sampleFrames) * BigInt(descriptor.channels) * 4n;
if (u64(chunk.samples.byteLength) !== expected) {
fail(
`AudioRef ${chunk.streamId}: artifact is ${chunk.samples.byteLength} bytes, sampleFrames x channels x 4 is ${expected}`,
);
}
}
// ---------------------------------------------------------------------------------- State.*
export interface CaptureParams {
checkpointId: Id;
}
export interface CaptureResult {
checkpointId: Id;
boundary: U64;
compatibilityDigest: Digest;
payload: ArtifactRef;
}
export interface StageRestoreParams {
checkpointId: Id;
sourceScope: Scope;
compatibilityDigest: Digest;
payload: ArtifactRef;
}
export interface StageRestoreResult {
checkpointId: Id;
restoreToken: Id;
}
export interface ActivateRestoreParams {
restoreToken: Id;
}
function checkpointPayload(reader: Reader, key: string): ArtifactRef {
const reference = readArtifactRef(reader.value(key));
if (!isDigest(reference.digest)) {
fail(`${key}: a checkpoint payload must carry a content digest`);
}
return reference;
}
export function readCaptureParams(value: unknown): CaptureParams {
const reader = new Reader(value, 'CaptureParams');
const params = { checkpointId: reader.id('checkpointId') };
reader.finish();
return params;
}
export function readCaptureResult(value: unknown): CaptureResult {
const reader = new Reader(value, 'CaptureResult');
const result: CaptureResult = {
checkpointId: reader.id('checkpointId'),
boundary: reader.u64('boundary'),
compatibilityDigest: reader.digest('compatibilityDigest'),
payload: checkpointPayload(reader, 'payload'),
};
reader.finish();
return result;
}
export function readStageRestoreParams(value: unknown): StageRestoreParams {
const reader = new Reader(value, 'StageRestoreParams');
const params: StageRestoreParams = {
checkpointId: reader.id('checkpointId'),
sourceScope: readScope(reader.value('sourceScope')),
compatibilityDigest: reader.digest('compatibilityDigest'),
payload: checkpointPayload(reader, 'payload'),
};
reader.finish();
return params;
}
export function readStageRestoreResult(value: unknown): StageRestoreResult {
const reader = new Reader(value, 'StageRestoreResult');
const result: StageRestoreResult = {
checkpointId: reader.id('checkpointId'),
restoreToken: reader.id('restoreToken'),
};
reader.finish();
return result;
}
export function readActivateRestoreParams(value: unknown): ActivateRestoreParams {
const reader = new Reader(value, 'ActivateRestoreParams');
const params = { restoreToken: reader.id('restoreToken') };
reader.finish();
return params;
}
export { readRational };

View file

@ -0,0 +1,231 @@
/** The publication types of publishing-v1 section 3. */
import { fail } from './canonical';
import { readRational, readScope, readSchemaRef, readTypedValue, readNullableTypedValue } from './common';
import {
type AudioRef,
MAX_VIEWS,
type ViewRef,
readAudioList,
readViewList,
} from './media';
import { Reader, requireUnique, u64 } from './reader';
import { type Digest, type Id, type RationalNs, type SchemaRef, type Scope, type TypedValue, type U64 } from './scalar';
import {
type AgentTelemetry,
type AssetRef,
type EnvironmentDescriptor,
MAX_AGENTS,
MAX_RATE_ROLES,
MAX_SUPPORTED_STIMULI,
type PortControl,
findPort,
readAgentTelemetry,
readAssetRef,
readEnvironmentDescriptor,
readPortControl,
validatePortControlAgainst,
validateTelemetryRoles,
} from './workers';
export { MAX_SUPPORTED_STIMULI } from './workers';
/** Not stated by a document; this crate's choices, published in the schema set. */
export const MAX_ASSETS = 64;
export const MAX_SNAPSHOT_EVENTS = 64;
export interface AgentDescriptor {
agentId: Id;
portId: Id;
profileDigest: Digest;
datasetDigest: Digest;
indexDigest: Digest;
neuronCount: U64;
rateRoles: Id[];
supportedStimuli: Id[];
}
export interface SessionDescriptor {
sessionId: Id;
revision: U64;
compositionDigest: Digest;
schedulerId: 'lockstep-v1';
environment: EnvironmentDescriptor;
taskSchema: SchemaRef;
agents: AgentDescriptor[];
assets: AssetRef[];
}
export interface SnapshotAgent {
agentId: Id;
telemetry: AgentTelemetry;
selectedDecision: TypedValue | null;
appliedControls: PortControl | null;
}
export interface CommittedSnapshot {
descriptorRevision: U64;
publisherIncarnation: Id;
scope: Scope;
episodeId: Id;
sequence: U64;
worldTime: RationalNs;
agents: SnapshotAgent[];
progress: TypedValue;
media: { views: ViewRef[]; audio: AudioRef[] };
eventIds: Id[];
}
export function readSessionDescriptor(value: unknown): SessionDescriptor {
const reader = new Reader(value, 'SessionDescriptor');
const descriptor: SessionDescriptor = {
sessionId: reader.id('sessionId'),
revision: reader.u64('revision'),
compositionDigest: reader.digest('compositionDigest'),
schedulerId: reader.constant('schedulerId', 'lockstep-v1'),
environment: readEnvironmentDescriptor(reader.value('environment')),
taskSchema: readSchemaRef(reader.value('taskSchema')),
agents: reader.list('agents', 1, MAX_AGENTS, (item) => {
const agent = new Reader(item, 'SessionDescriptor.agents');
const entry: AgentDescriptor = {
agentId: agent.id('agentId'),
portId: agent.id('portId'),
profileDigest: agent.digest('profileDigest'),
datasetDigest: agent.digest('datasetDigest'),
indexDigest: agent.digest('indexDigest'),
neuronCount: agent.u64('neuronCount'),
rateRoles: agent.idList('rateRoles', 0, MAX_RATE_ROLES),
supportedStimuli: agent.idList('supportedStimuli', 0, MAX_SUPPORTED_STIMULI),
};
agent.finish();
requireUnique(entry.rateRoles, 'SessionDescriptor.agents rateRoles');
requireUnique(entry.supportedStimuli, 'SessionDescriptor.agents supportedStimuli');
return entry;
}),
assets: reader.list('assets', 0, MAX_ASSETS, readAssetRef),
};
reader.finish();
requireUnique(
descriptor.agents.map((agent) => agent.agentId),
'SessionDescriptor.agents agentId',
);
requireUnique(
descriptor.agents.map((agent) => agent.portId),
'SessionDescriptor.agents portId',
);
requireUnique(
descriptor.assets.map((asset) => asset.id),
'SessionDescriptor.assets',
);
for (const agent of descriptor.agents) {
if (!findPort(descriptor.environment, agent.portId)) {
fail(
`SessionDescriptor: agent "${agent.agentId}" is bound to port "${agent.portId}", which the environment does not declare`,
);
}
}
return descriptor;
}
export function readCommittedSnapshot(value: unknown): CommittedSnapshot {
const reader = new Reader(value, 'CommittedSnapshot');
const descriptorRevision = reader.u64('descriptorRevision');
const publisherIncarnation = reader.id('publisherIncarnation');
const scope = readScope(reader.value('scope'));
const episodeId = reader.id('episodeId');
const sequence = reader.u64('sequence');
const worldTime = readRational(reader.value('worldTime'));
const agents = reader.list('agents', 1, MAX_AGENTS, (item) => {
const agent = new Reader(item, 'CommittedSnapshot.agents');
const controls = agent.value('appliedControls');
const entry: SnapshotAgent = {
agentId: agent.id('agentId'),
telemetry: readAgentTelemetry(agent.value('telemetry')),
selectedDecision: readNullableTypedValue(agent.value('selectedDecision')),
appliedControls: controls === null ? null : readPortControl(controls),
};
agent.finish();
return entry;
});
const progress = readTypedValue(reader.value('progress'));
const mediaReader = new Reader(reader.value('media'), 'CommittedSnapshot.media');
const media = {
views: readViewList(mediaReader, 'views'),
audio: readAudioList(mediaReader, 'audio'),
};
mediaReader.finish();
const eventIds = reader.idList('eventIds', 0, MAX_SNAPSHOT_EVENTS);
reader.finish();
requireUnique(
agents.map((agent) => agent.agentId),
'CommittedSnapshot.agents',
);
requireUnique(
media.views.map((view) => view.viewId),
'CommittedSnapshot.media.views',
);
requireUnique(eventIds, 'CommittedSnapshot.eventIds');
if (media.views.length > MAX_VIEWS) fail('CommittedSnapshot: at most 8 views');
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, 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 ((agent.selectedDecision === null) !== (agent.appliedControls === null)) {
fail(
'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,
scope,
episodeId,
sequence,
worldTime,
agents,
progress,
media,
eventIds,
};
}
/** Descriptor agreement: revision, session, agent set and each agent's assigned port. */
export function validateSnapshotAgainst(
snapshot: CommittedSnapshot,
descriptor: SessionDescriptor,
): void {
if (snapshot.descriptorRevision !== descriptor.revision) {
fail('CommittedSnapshot: descriptorRevision does not match the descriptor');
}
if (snapshot.scope.sessionId !== descriptor.sessionId) {
fail('CommittedSnapshot: sessionId does not match the descriptor');
}
for (const agent of snapshot.agents) {
const declared = descriptor.agents.find((candidate) => candidate.agentId === agent.agentId);
if (!declared) {
fail(`CommittedSnapshot: agent "${agent.agentId}" is not in the descriptor`);
}
validateTelemetryRoles(agent.telemetry, declared.rateRoles);
if (agent.appliedControls !== null) {
if (agent.appliedControls.portId !== declared.portId) {
fail(
`CommittedSnapshot: agent "${agent.agentId}" controls port "${agent.appliedControls.portId}", not its assigned "${declared.portId}"`,
);
}
const port = findPort(descriptor.environment, declared.portId);
if (!port) fail('CommittedSnapshot: assigned port is not declared');
validatePortControlAgainst(agent.appliedControls, port.controls);
}
}
}

View file

@ -0,0 +1,231 @@
/**
* Reading one JSON object field by field, then refusing any field that was not read.
*
* The Rust crate's `flybus::wire::Fields` does the same job; keeping the two shaped alike is
* what lets the fixture corpus hold both languages to the same rules.
*/
import { ContractError, canonicalize, fail } from './canonical';
import {
type Digest,
type Id,
type U64,
type ArtifactRef,
isDigest,
isId,
parseU64,
requireU64,
} from './scalar';
export class Reader {
private readonly map: Record<string, unknown>;
private readonly seen = new Set<string>();
constructor(
value: unknown,
private readonly what: string,
) {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
fail(`${what} must be an object`);
}
this.map = value as Record<string, unknown>;
}
value(key: string): unknown {
this.seen.add(key);
if (!Object.prototype.hasOwnProperty.call(this.map, key)) {
fail(`${this.what}: missing field "${key}"`);
}
return this.map[key];
}
string(key: string): string {
const value = this.value(key);
if (typeof value !== 'string') fail(`${this.what}: ${key} must be a string`);
return value;
}
id(key: string): Id {
const value = this.string(key);
if (!isId(value)) fail(`${this.what}: ${key} is not a valid id`);
return value;
}
nullableId(key: string): Id | null {
const value = this.value(key);
if (value === null) return null;
return this.id(key);
}
digest(key: string): Digest {
const value = this.string(key);
if (!isDigest(value)) fail(`${this.what}: ${key} must be 64 lowercase hex digits`);
return value;
}
u64(key: string): U64 {
return requireU64(this.value(key), `${this.what}: ${key}`);
}
int(key: string, low: number, high: number): number {
const value = this.value(key);
if (typeof value !== 'number' || !Number.isInteger(value) || value < low || value > high) {
fail(`${this.what}: ${key} must be an integer in ${low}..=${high}`);
}
return value;
}
finite(key: string): number {
const value = this.value(key);
if (typeof value !== 'number' || !Number.isFinite(value)) {
fail(`${this.what}: ${key} must be a finite JSON number`);
}
if (Number.isInteger(value) && Math.abs(value) > Number.MAX_SAFE_INTEGER) {
fail(`${this.what}: ${key} is outside the exact double range`);
}
return value;
}
finiteIn(key: string, low: number, high: number): number {
const value = this.finite(key);
if (value < low || value > high) fail(`${this.what}: ${key} must be in [${low}, ${high}]`);
return value;
}
boolean(key: string): boolean {
const value = this.value(key);
if (typeof value !== 'boolean') fail(`${this.what}: ${key} must be a boolean`);
return value;
}
constantTrue(key: string): true {
if (!this.boolean(key)) fail(`${this.what}: ${key} must be true`);
return true;
}
object(key: string): Record<string, unknown> {
const value = this.value(key);
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
fail(`${this.what}: ${key} must be an object`);
}
return value as Record<string, unknown>;
}
array(key: string, low: number, high: number): unknown[] {
const value = this.value(key);
if (!Array.isArray(value) || value.length < low || value.length > high) {
fail(`${this.what}: ${key} must be an array of ${low}..=${high} items`);
}
return value;
}
list<T>(key: string, low: number, high: number, read: (item: unknown) => T): T[] {
return this.array(key, low, high).map((item, index) => {
try {
return read(item);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new ContractError(`${this.what}: ${key}[${index}]: ${message}`);
}
});
}
idList(key: string, low: number, high: number): Id[] {
return this.list(key, low, high, (item) => {
if (!isId(item)) fail('every entry must be an id');
return item;
});
}
enumeration<T extends string>(key: string, allowed: readonly T[]): T {
const value = this.string(key);
if (!(allowed as readonly string[]).includes(value)) {
fail(`${this.what}: ${key} must be one of ${allowed.join(', ')}`);
}
return value as T;
}
constant<T extends string>(key: string, expected: T): T {
const value = this.string(key);
if (value !== expected) fail(`${this.what}: ${key} must be "${expected}"`);
return expected;
}
boundedString(key: string, maxCodePoints: number): string {
const value = this.string(key);
if ([...value].length > maxCodePoints) {
fail(`${this.what}: ${key} must be at most ${maxCodePoints} code points`);
}
return value;
}
nullableBoundedString(key: string, maxCodePoints: number): string | null {
return this.value(key) === null ? null : this.boundedString(key, maxCodePoints);
}
/** Refuses fields that were not read. */
finish(): void {
for (const key of Object.keys(this.map)) {
if (!this.seen.has(key)) fail(`${this.what}: unknown field "${key}"`);
}
}
}
/** Fails on the first repeated key, naming it. */
export function requireUnique(keys: readonly string[], what: string): void {
const seen = new Set<string>();
for (const key of keys) {
if (seen.has(key)) fail(`${what}: duplicate "${key}"`);
seen.add(key);
}
}
/** Fails unless `actual` is exactly `expected`, in that order. */
export function requireSameOrder(
actual: readonly string[],
expected: readonly string[],
what: string,
): void {
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
fail(
`${what}: must list [${expected.join(', ')}] in that order, found [${actual.join(', ')}]`,
);
}
}
/** A bus `ArtifactRef`, read with the bus's own rules. */
export function readArtifactRef(value: unknown): ArtifactRef {
const reader = new Reader(value, 'ArtifactRef');
const storeId = reader.id('storeId');
const artifactId = reader.id('artifactId');
const generation = reader.u64('generation');
const byteLength = reader.u64('byteLength');
const contentType = reader.string('contentType');
const digestValue = reader.value('digest');
reader.finish();
if (contentType.length < 1 || contentType.length > 127) {
fail('ArtifactRef: contentType must be 1..=127 printable ASCII characters');
}
if (digestValue !== null && !isDigest(digestValue)) {
fail('ArtifactRef: digest must be null or 64 lowercase hex digits');
}
return {
storeId,
artifactId,
generation,
byteLength,
contentType,
digest: digestValue as ArtifactRef['digest'],
};
}
/** The canonical JSON byte length of a value. */
export function canonicalLength(value: unknown): number {
return canonicalize(value).length;
}
/** `parseU64` that throws, for places that have already validated the string. */
export function u64(value: U64): bigint {
const parsed = parseU64(value);
if (parsed === undefined) fail(`${value} is not a canonical U64 string`);
return parsed;
}

View file

@ -0,0 +1,140 @@
/** The domain request/reply envelope of ipc-v1 section 3 and the error codes of section 7. */
import { fail, rejectBusIdentities } from './canonical';
import { bodyDigest, readNullableScope } from './common';
import { Reader } from './reader';
import { type DomainRequestId, type Id, type Scope, domainRequestId } from './scalar';
import { MAX_MESSAGE_CODE_POINTS } from './workers';
export const ERROR_CODES = [
'INVALID_ARGUMENT',
'UNSUPPORTED',
'IDENTITY_MISMATCH',
'STALE_EPOCH',
'STALE_STEP',
'FUTURE_STEP',
'INVALID_PHASE',
'CONFLICT',
'IN_PROGRESS',
'BUSY',
'BUFFER_INVALID',
'RESULT_EXPIRED',
'INCOMPATIBLE_STATE',
'BACKEND_FAILURE',
'INTERNAL',
] as const;
export type ErrorCode = (typeof ERROR_CODES)[number];
export const MUTATION_CERTAINTIES = ['none', 'applied', 'unknown'] as const;
export type MutationCertainty = (typeof MUTATION_CERTAINTIES)[number];
/** The codes raised strictly before any mutation, so their certainty is `none`. */
export const BEFORE_MUTATION: readonly ErrorCode[] = [
'INVALID_ARGUMENT',
'UNSUPPORTED',
'IDENTITY_MISMATCH',
'STALE_EPOCH',
'STALE_STEP',
'FUTURE_STEP',
'INVALID_PHASE',
'CONFLICT',
'IN_PROGRESS',
'BUSY',
'BUFFER_INVALID',
'INCOMPATIBLE_STATE',
];
export interface SessionRpcRequest {
requestId: DomainRequestId;
scope: Scope | null;
params: Record<string, unknown>;
}
export interface SessionRpcSuccess {
type: 'result';
requestId: DomainRequestId;
workerId: Id;
incarnationId: Id;
scope: Scope | null;
result: Record<string, unknown>;
}
export interface SessionRpcFailure {
type: 'error';
requestId: DomainRequestId;
workerId: Id;
incarnationId: Id;
scope: Scope | null;
error: { code: ErrorCode; message: string; mutation: MutationCertainty };
}
export type SessionRpcOutcome = SessionRpcSuccess | SessionRpcFailure;
export function readSessionRpcRequest(value: unknown): SessionRpcRequest {
const reader = new Reader(value, 'SessionRpcRequest');
const request: SessionRpcRequest = {
requestId: domainRequestId(reader.string('requestId')),
scope: readNullableScope(reader.value('scope')),
params: reader.object('params'),
};
reader.finish();
rejectBusIdentities(request.params);
return request;
}
/** The canonical body digest of this request under `method` (ipc-v1 section 5). */
export function requestBodyDigest(request: SessionRpcRequest, method: string): string {
return bodyDigest(method, request.scope, request.params);
}
export function readSessionRpcSuccess(value: unknown): SessionRpcSuccess {
const reader = new Reader(value, 'SessionRpcSuccess');
const success: SessionRpcSuccess = {
type: reader.constant('type', 'result'),
requestId: domainRequestId(reader.string('requestId')),
workerId: reader.id('workerId'),
incarnationId: reader.id('incarnationId'),
scope: readNullableScope(reader.value('scope')),
result: reader.object('result'),
};
reader.finish();
return success;
}
export function readSessionRpcFailure(value: unknown): SessionRpcFailure {
const reader = new Reader(value, 'SessionRpcFailure');
const type = reader.constant('type', 'error');
const requestId = domainRequestId(reader.string('requestId'));
const workerId = reader.id('workerId');
const incarnationId = reader.id('incarnationId');
const scope = readNullableScope(reader.value('scope'));
const errorReader = new Reader(reader.value('error'), 'SessionRpcFailure.error');
const error = {
code: errorReader.enumeration('code', ERROR_CODES),
message: errorReader.boundedString('message', MAX_MESSAGE_CODE_POINTS),
mutation: errorReader.enumeration('mutation', MUTATION_CERTAINTIES),
};
errorReader.finish();
reader.finish();
if (BEFORE_MUTATION.includes(error.code) && error.mutation !== 'none') {
fail(`SessionRpcFailure: ${error.code} is raised before mutation, so mutation is "none"`);
}
return { type, requestId, workerId, incarnationId, scope, error };
}
export function readSessionRpcOutcome(value: unknown): SessionRpcOutcome {
const type = (value as { type?: unknown } | null)?.type;
if (type === 'result') return readSessionRpcSuccess(value);
if (type === 'error') return readSessionRpcFailure(value);
return fail('SessionRpcOutcome: type must be "result" or "error"');
}
/** Replies echo the original scope (ipc-v1 section 3). */
export function echoes(outcome: SessionRpcOutcome, request: SessionRpcRequest): boolean {
const sameScope =
outcome.scope === null || request.scope === null
? outcome.scope === request.scope
: outcome.scope.sessionId === request.scope.sessionId &&
outcome.scope.epoch === request.scope.epoch &&
outcome.scope.step === request.scope.step;
return outcome.requestId === request.requestId && sameScope;
}

View file

@ -0,0 +1,262 @@
/**
* The domain scalars of ipc-v1 section 2, and the four identities that must never be confused.
*
* `Id`, `U64` and `Digest` are the bus encodings (bus-v1 section 3 defers to ipc-v1 for them),
* and `tests/encodings.test.ts` pins the same edge cases the Rust crate pins.
*/
import { fail } from './canonical';
/** `^[a-z0-9][a-z0-9._-]{0,63}$`. */
export type Id = string;
/** `"0"` or `[1-9][0-9]*`, at most 18446744073709551615. A counter, never a JSON number. */
export type U64 = string;
/** 64 lowercase hexadecimal digits (SHA-256). */
export type Digest = string;
const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const U64_TEXT = /^(0|[1-9][0-9]*)$/;
const DIGEST = /^[0-9a-f]{64}$/;
/** The largest U64, as a bigint. */
export const U64_MAX = 18446744073709551615n;
export function isId(value: unknown): value is Id {
return typeof value === 'string' && ID.test(value);
}
export function isDigest(value: unknown): value is Digest {
return typeof value === 'string' && DIGEST.test(value);
}
/** The value a `U64` string denotes, or `undefined` if it is not canonical. */
export function parseU64(value: unknown): bigint | undefined {
if (typeof value !== 'string' || !U64_TEXT.test(value)) return undefined;
const parsed = BigInt(value);
return parsed <= U64_MAX ? parsed : undefined;
}
export function requireU64(value: unknown, what: string): U64 {
if (parseU64(value) === undefined) fail(`${what} is not a canonical U64 string`);
return value as U64;
}
/** RPC method: 1..=128 printable ASCII characters (bus-v1 section 5). */
export function isMethod(value: unknown): boolean {
return (
typeof value === 'string' &&
value.length >= 1 &&
value.length <= 128 &&
[...value].every((character) => {
const point = character.codePointAt(0) ?? 0;
return point >= 0x20 && point <= 0x7e;
})
);
}
// ---------------------------------------------------------------------------------------------
// The four identities
declare const brand: unique symbol;
/** A branded string: assignable only through its own parser. */
type Branded<Name extends string> = string & { readonly [brand]: Name };
/** A bus RPC correlation id, `call-<U64>`. Not a domain operation id. */
export type BusCallId = Branded<'BusCallId'>;
/** A domain operation id, `req-<U64>`. A safe retry keeps it and gets a new `BusCallId`. */
export type DomainRequestId = Branded<'DomainRequestId'>;
/** A delivery (`dlv-<U64>`) or explicit-hold (`own-<U64>`) owner token. Connection-private. */
export type OwnerToken = Branded<'OwnerToken'>;
export type OwnerKind = 'delivery' | 'hold';
function serial(prefix: string, value: unknown): bigint | undefined {
if (typeof value !== 'string' || !value.startsWith(`${prefix}-`)) return undefined;
return parseU64(value.slice(prefix.length + 1));
}
export function isBusCallId(value: unknown): value is BusCallId {
return serial('call', value) !== undefined;
}
export function isDomainRequestId(value: unknown): value is DomainRequestId {
return serial('req', value) !== undefined;
}
export function ownerTokenKind(value: unknown): OwnerKind | undefined {
if (serial('dlv', value) !== undefined) return 'delivery';
if (serial('own', value) !== undefined) return 'hold';
return undefined;
}
export function busCallId(value: unknown): BusCallId {
if (!isBusCallId(value)) fail('a bus callId must be canonical call-<U64>');
return value;
}
export function domainRequestId(value: unknown): DomainRequestId {
if (!isDomainRequestId(value)) fail('a domain requestId must be canonical req-<U64>');
return value;
}
export function ownerToken(value: unknown): OwnerToken {
if (ownerTokenKind(value) === undefined) {
fail('an owner token must be canonical dlv-<U64> or own-<U64>');
}
return value as OwnerToken;
}
/** The naming half of a bus `ArtifactRef`: what identifies the bytes. */
export interface ArtifactIdentity {
storeId: Id;
artifactId: Id;
generation: U64;
}
/** A transient bus artifact reference (bus-v1 section 4). Never an `AssetRef`. */
export interface ArtifactRef {
storeId: Id;
artifactId: Id;
generation: U64;
byteLength: U64;
contentType: string;
digest: Digest | null;
}
export function artifactIdentity(reference: ArtifactRef): ArtifactIdentity {
return {
storeId: reference.storeId,
artifactId: reference.artifactId,
generation: reference.generation,
};
}
// ---------------------------------------------------------------------------------------------
// RationalNs
/** A nanosecond rational: reduced, positive denominator, zero encoded `0/1`. */
export interface RationalNs {
numerator: U64;
denominator: U64;
}
export const RATIONAL_ZERO: RationalNs = { numerator: '0', denominator: '1' };
function gcd(a: bigint, b: bigint): bigint {
let left = a;
let right = b;
while (right !== 0n) {
const rest = left % right;
left = right;
right = rest;
}
return left;
}
function parts(value: RationalNs, what: string): [bigint, bigint] {
const numerator = parseU64(value.numerator);
const denominator = parseU64(value.denominator);
if (numerator === undefined || denominator === undefined) {
fail(`${what}: numerator and denominator are U64 strings`);
}
return [numerator, denominator];
}
/** The canonical-form rules: positive denominator, `0/1` zero, reduced fraction. */
export function validateRational(value: RationalNs, what = 'RationalNs'): void {
const [numerator, denominator] = parts(value, what);
if (denominator === 0n) fail(`${what}: denominator must be positive`);
if (numerator === 0n && denominator !== 1n) fail(`${what}: zero is encoded 0/1`);
if (numerator !== 0n && gcd(numerator, denominator) !== 1n) {
fail(`${what}: fraction must be reduced`);
}
}
/** Reduces, then validates: the constructor for arithmetic results. */
export function reduced(numerator: bigint, denominator: bigint): RationalNs {
if (denominator <= 0n) fail('RationalNs: denominator must be positive');
let n = numerator;
let d = denominator;
if (n === 0n) {
d = 1n;
} else {
const divisor = gcd(n, d);
n /= divisor;
d /= divisor;
}
if (n > U64_MAX || d > U64_MAX) fail('RationalNs: reduced value does not fit U64');
return { numerator: n.toString(), denominator: d.toString() };
}
export function isRationalZero(value: RationalNs): boolean {
return parseU64(value.numerator) === 0n;
}
export function requirePositiveRational(value: RationalNs, what: string): void {
if (isRationalZero(value)) fail(`${what}: duration must be positive`);
}
export function addRational(left: RationalNs, right: RationalNs): RationalNs {
const [ln, ld] = parts(left, 'RationalNs');
const [rn, rd] = parts(right, 'RationalNs');
return reduced(ln * rd + rn * ld, ld * rd);
}
export function subtractRational(left: RationalNs, right: RationalNs): RationalNs {
const [ln, ld] = parts(left, 'RationalNs');
const [rn, rd] = parts(right, 'RationalNs');
const a = ln * rd;
const b = rn * ld;
if (b > a) fail('RationalNs: subtraction would be negative');
return reduced(a - b, ld * rd);
}
export function multiplyRational(value: RationalNs, factor: bigint): RationalNs {
const [numerator, denominator] = parts(value, 'RationalNs');
return reduced(numerator * factor, denominator);
}
export function compareRational(left: RationalNs, right: RationalNs): -1 | 0 | 1 {
const [ln, ld] = parts(left, 'RationalNs');
const [rn, rd] = parts(right, 'RationalNs');
const a = ln * rd;
const b = rn * ld;
return a < b ? -1 : a > b ? 1 : 0;
}
/**
* The step-v1 section 5 accumulator: `ticks = floor(value / tick)` and the remainder
* `value - ticks * tick`, which is always `>= 0` and `< tick`.
*/
export function divideFloor(value: RationalNs, tick: RationalNs): { ticks: U64; remainder: RationalNs } {
requirePositiveRational(tick, 'RationalNs divideFloor tick');
const [vn, vd] = parts(value, 'RationalNs');
const [tn, td] = parts(tick, 'RationalNs');
const ticks = (vn * td) / (vd * tn);
if (ticks > U64_MAX) fail('RationalNs: tick count does not fit U64');
const remainder = subtractRational(value, multiplyRational(tick, ticks));
return { ticks: ticks.toString(), remainder };
}
// ---------------------------------------------------------------------------------------------
// Scope, SchemaRef, TypedValue
/** The simulation timeline identity. Never a bus route or store incarnation. */
export interface Scope {
sessionId: Id;
epoch: Id;
step: U64;
}
export interface SchemaRef {
id: Id;
version: number;
digest: Digest;
}
/** A schema identity plus an object, capped at 32 KiB of canonical JSON. */
export interface TypedValue {
schema: SchemaRef;
value: Record<string, unknown>;
}
/** The canonical-JSON size limit of one `TypedValue`. */
export const MAX_TYPED_VALUE_BYTES = 32 * 1024;

View file

@ -0,0 +1,57 @@
/**
* `seed-derivation-v1`: independent per-agent seeds from one recorded master seed.
*
* The specification is `docs/design/session-framework/seed-derivation-v1.md`, and
* `fixtures/seed-vectors.json` its test vectors, which the Rust crate reproduces.
*/
import { createHash } from 'node:crypto';
import { fail } from './canonical';
import { requireUnique } from './reader';
import { isId, parseU64 } from './scalar';
export const ALGORITHM = 'seed-derivation-v1';
export const PREFIX = 'flybrain/seed-derivation-v1';
/** The exact bytes hashed: prefix, master seed and agent id, each followed by one newline. */
export function material(masterSeed: bigint, agentId: string): Uint8Array {
if (!isId(agentId)) fail('seed derivation: agentId is not a valid id');
if (masterSeed < 0n || masterSeed > 18446744073709551615n) {
fail('seed derivation: the master seed is a U64');
}
return new TextEncoder().encode(`${PREFIX}\n${masterSeed.toString()}\n${agentId}\n`);
}
export function materialDigest(masterSeed: bigint, agentId: string): string {
return createHash('sha256').update(material(masterSeed, agentId)).digest('hex');
}
/**
* The signed 32-bit seed `Agent.Initialize` takes for `agentId`: the first nonzero big-endian
* `u32` lane of the digest, as a two's-complement `i32`.
*/
export function agentSeed(masterSeed: bigint, agentId: string): number {
let bytes = Buffer.from(material(masterSeed, agentId));
for (let round = 0; round < 4; round += 1) {
if (round > 0) bytes = Buffer.concat([bytes, Buffer.from(`${round}\n`, 'utf8')]);
const digest = createHash('sha256').update(bytes).digest();
for (let offset = 0; offset < digest.length; offset += 4) {
const word = digest.readUInt32BE(offset);
if (word !== 0) return word | 0;
}
}
return fail('seed derivation: every lane of four digests was zero');
}
/** The seeds of a whole composition, in the order the agent ids are given. */
export function compositionSeeds(masterSeed: bigint, agentIds: readonly string[]): number[] {
requireUnique(agentIds, 'seed derivation: agentIds');
return agentIds.map((agentId) => agentSeed(masterSeed, agentId));
}
/** Parses a master seed from its `U64` decimal string. */
export function masterSeed(text: string): bigint {
const parsed = parseU64(text);
if (parsed === undefined) fail('seed derivation: the master seed is a canonical U64 string');
return parsed;
}

View file

@ -0,0 +1,365 @@
/**
* The trace format of step-v1 section 8, split into behaviour and operational metadata.
*
* `behaviourEquals` compares the first half only, which is the comparison section 8 asks for:
* sequential, concurrent and reversed runs must agree "excluding wall time, request ids and
* other explicitly operational metadata".
*/
import { canonicalize, digestOf, fail } from './canonical';
import { readRational, readScope } from './common';
import { MAX_VIEWS } from './media';
import { Reader, requireUnique, u64 } from './reader';
import {
type BusCallId,
type Digest,
type DomainRequestId,
type Id,
type OwnerToken,
type RationalNs,
type Scope,
type U64,
busCallId,
domainRequestId,
ownerToken,
} from './scalar';
import { MAX_AGENTS, MAX_RATE_ROLES } from './workers';
import { MAX_SLOTS } from './extensions';
/** Boundary actions at the reached boundary (amendment of 2026-09-23, RT-01a). */
export const BOUNDARY_ACTION_KINDS = ['save-slot', 'rollback'] as const;
export type BoundaryActionKind = (typeof BOUNDARY_ACTION_KINDS)[number];
/** Every slot at most once, plus one rollback. */
export const MAX_BOUNDARY_ACTIONS = MAX_SLOTS + 1;
export interface BoundaryAction {
kind: BoundaryActionKind;
slotId: Id;
stateDigest: Digest | null;
}
/** A checkpoint capture at the reached boundary and how many boundary actions preceded it. */
export interface TraceCapture {
checkpointId: Id;
afterActions: number;
}
export interface TraceAgent {
agentId: Id;
profileDigest: Digest;
ticksAdvanced: U64;
brainTicks: U64;
remainder: RationalNs;
decisionDigest: Digest;
committedStep: U64;
}
export interface TraceObservation {
viewId: Id;
producedStep: U64;
}
export interface TraceBehaviour {
scope: Scope;
agents: TraceAgent[];
batchId: Id;
controlDigest: Digest;
acknowledgedBoundary: U64;
observationBoundaries: TraceObservation[];
outcomeIds: Id[];
eventIds: Id[];
publishedBoundary: U64;
boundaryActions: BoundaryAction[];
}
export interface TraceRequest {
agentId: Id;
requestId: DomainRequestId;
}
export interface TraceOperational {
wallTimeNs: U64;
prepareRequestIds: TraceRequest[];
advanceRequestId: DomainRequestId;
commitRequestIds: TraceRequest[];
busCallIds: BusCallId[];
deliveryIds: OwnerToken[];
captures: TraceCapture[];
}
export interface TransitionTrace {
behaviour: TraceBehaviour;
operational: TraceOperational;
}
export function readTraceBehaviour(value: unknown): TraceBehaviour {
const reader = new Reader(value, 'TraceBehaviour');
const scope = readScope(reader.value('scope'));
const agents = reader.list('agents', 1, MAX_AGENTS, (item) => {
const agent = new Reader(item, 'TraceBehaviour.agents');
const entry: TraceAgent = {
agentId: agent.id('agentId'),
profileDigest: agent.digest('profileDigest'),
ticksAdvanced: agent.u64('ticksAdvanced'),
brainTicks: agent.u64('brainTicks'),
remainder: readRational(agent.value('remainder')),
decisionDigest: agent.digest('decisionDigest'),
committedStep: agent.u64('committedStep'),
};
agent.finish();
return entry;
});
const batchId = reader.id('batchId');
const controlDigest = reader.digest('controlDigest');
const acknowledgedBoundary = reader.u64('acknowledgedBoundary');
const observationBoundaries = reader.list(
'observationBoundaries',
0,
MAX_VIEWS * 2,
(item) => {
const observation = new Reader(item, 'TraceBehaviour.observationBoundaries');
const entry: TraceObservation = {
viewId: observation.id('viewId'),
producedStep: observation.u64('producedStep'),
};
observation.finish();
return entry;
},
);
const outcomeIds = reader.idList('outcomeIds', 0, MAX_RATE_ROLES);
const eventIds = reader.idList('eventIds', 0, MAX_RATE_ROLES);
const publishedBoundary = reader.u64('publishedBoundary');
const boundaryActions = reader.list('boundaryActions', 0, MAX_BOUNDARY_ACTIONS, (item) => {
const action = new Reader(item, 'TraceBehaviour.boundaryActions');
const entry: BoundaryAction = {
kind: action.enumeration('kind', BOUNDARY_ACTION_KINDS),
slotId: action.id('slotId'),
stateDigest: action.value('stateDigest') === null ? null : action.digest('stateDigest'),
};
action.finish();
return entry;
});
reader.finish();
validateBoundaryActions(boundaryActions);
requireUnique(
agents.map((agent) => agent.agentId),
'TraceBehaviour.agents',
);
requireUnique(
observationBoundaries.map((observation) => observation.viewId),
'TraceBehaviour.observationBoundaries',
);
requireUnique(eventIds, 'TraceBehaviour.eventIds');
requireUnique(outcomeIds, 'TraceBehaviour.outcomeIds');
const next = u64(scope.step) + 1n;
for (const agent of agents) {
if (u64(agent.committedStep) !== next) {
fail("TraceBehaviour: every commit acknowledgment is the transition's next boundary");
}
}
if (u64(acknowledgedBoundary) !== next) {
fail('TraceBehaviour: the acknowledged boundary is scope.step + 1');
}
if (u64(publishedBoundary) !== u64(acknowledgedBoundary)) {
fail('TraceBehaviour: the published boundary is the boundary every agent committed');
}
return {
scope,
agents,
batchId,
controlDigest,
acknowledgedBoundary,
observationBoundaries,
outcomeIds,
eventIds,
publishedBoundary,
boundaryActions,
};
}
/** Slot saves first, each slot once with its digest; at most one rollback, last, no digest. */
function validateBoundaryActions(actions: readonly BoundaryAction[]): void {
let rolledBack = false;
const saved: string[] = [];
for (const action of actions) {
if (rolledBack) fail('TraceBehaviour: nothing follows a rollback at the same boundary');
if (action.kind === 'save-slot') {
if (action.stateDigest === null) fail('TraceBehaviour: a slot save records its state digest');
if (saved.includes(action.slotId)) {
fail('TraceBehaviour: a slot is saved at most once per boundary');
}
saved.push(action.slotId);
} else {
if (action.stateDigest !== null) fail('TraceBehaviour: a rollback records no state digest');
rolledBack = true;
}
}
}
/** How many leading boundary actions are slot saves. */
export function slotSaves(behaviour: TraceBehaviour): number {
let count = 0;
for (const action of behaviour.boundaryActions) {
if (action.kind !== 'save-slot') break;
count += 1;
}
return count;
}
export function readTraceOperational(value: unknown): TraceOperational {
const reader = new Reader(value, 'TraceOperational');
const readRequests = (item: unknown): TraceRequest => {
const request = new Reader(item, 'TraceOperational request');
const entry: TraceRequest = {
agentId: request.id('agentId'),
requestId: domainRequestId(request.string('requestId')),
};
request.finish();
return entry;
};
const operational: TraceOperational = {
wallTimeNs: reader.u64('wallTimeNs'),
prepareRequestIds: reader.list('prepareRequestIds', 1, MAX_AGENTS, readRequests),
advanceRequestId: domainRequestId(reader.string('advanceRequestId')),
commitRequestIds: reader.list('commitRequestIds', 1, MAX_AGENTS, readRequests),
busCallIds: reader.list('busCallIds', 0, 64, busCallId),
deliveryIds: reader.list('deliveryIds', 0, 64, ownerToken),
captures: reader.list('captures', 0, MAX_BOUNDARY_ACTIONS + 1, (item) => {
const capture = new Reader(item, 'TraceOperational.captures');
const entry: TraceCapture = {
checkpointId: capture.id('checkpointId'),
afterActions: capture.int('afterActions', 0, MAX_BOUNDARY_ACTIONS),
};
capture.finish();
return entry;
}),
};
reader.finish();
requireUnique(
operational.prepareRequestIds.map((request) => request.agentId),
'TraceOperational.prepareRequestIds',
);
requireUnique(
operational.commitRequestIds.map((request) => request.agentId),
'TraceOperational.commitRequestIds',
);
requireUnique(operational.busCallIds, 'TraceOperational.busCallIds');
requireUnique(operational.deliveryIds, 'TraceOperational.deliveryIds');
requireUnique(
operational.captures.map((capture) => capture.checkpointId),
'TraceOperational.captures',
);
let last = 0;
for (const capture of operational.captures) {
if (capture.afterActions < last) {
fail('TraceOperational: captures are recorded in the order they were taken');
}
last = capture.afterActions;
}
return operational;
}
export function readTransitionTrace(value: unknown): TransitionTrace {
const reader = new Reader(value, 'TransitionTrace');
const trace: TransitionTrace = {
behaviour: readTraceBehaviour(reader.value('behaviour')),
operational: readTraceOperational(reader.value('operational')),
};
reader.finish();
const agents = trace.behaviour.agents.map((agent) => agent.agentId);
for (const phase of [trace.operational.prepareRequestIds, trace.operational.commitRequestIds]) {
for (const request of phase) {
if (!agents.includes(request.agentId)) {
fail(
`TransitionTrace: request recorded for "${request.agentId}", which is not in the transition`,
);
}
}
}
// A slot save due at a boundary completes before any capture there (legacy-gameboy-v1 16).
const saves = slotSaves(trace.behaviour);
for (const capture of trace.operational.captures) {
if (capture.afterActions < saves) {
fail(
`TransitionTrace: capture "${capture.checkpointId}" was taken before this boundary's slot saves completed`,
);
}
if (capture.afterActions > trace.behaviour.boundaryActions.length) {
fail(
`TransitionTrace: capture "${capture.checkpointId}" counts more boundary actions than were applied`,
);
}
}
return trace;
}
/** Sorts the order-free collections, so completion order cannot change the comparison. */
export function normalizeBehaviour(behaviour: TraceBehaviour): TraceBehaviour {
return {
...behaviour,
agents: [...behaviour.agents].sort((left, right) =>
left.agentId < right.agentId ? -1 : left.agentId > right.agentId ? 1 : 0,
),
observationBoundaries: [...behaviour.observationBoundaries].sort((left, right) =>
left.viewId < right.viewId ? -1 : left.viewId > right.viewId ? 1 : 0,
),
};
}
export function behaviourDigest(behaviour: TraceBehaviour): string {
return digestOf(normalizeBehaviour(behaviour));
}
export function behaviourEquals(left: TransitionTrace, right: TransitionTrace): boolean {
return (
canonicalize(normalizeBehaviour(left.behaviour)) ===
canonicalize(normalizeBehaviour(right.behaviour))
);
}
/** The behaviour fields that differ, named. Empty when `behaviourEquals` holds. */
export function behaviourDiff(left: TransitionTrace, right: TransitionTrace): string[] {
const a = normalizeBehaviour(left.behaviour);
const b = normalizeBehaviour(right.behaviour);
const out: string[] = [];
const differs = (first: unknown, second: unknown): boolean =>
canonicalize(first) !== canonicalize(second);
if (differs(a.scope, b.scope)) out.push(`scope: ${canonicalize(a.scope)} vs ${canonicalize(b.scope)}`);
if (a.batchId !== b.batchId) out.push(`batchId: ${a.batchId} vs ${b.batchId}`);
if (a.controlDigest !== b.controlDigest) out.push('controlDigest differs');
if (a.acknowledgedBoundary !== b.acknowledgedBoundary) {
out.push(`acknowledgedBoundary: ${a.acknowledgedBoundary} vs ${b.acknowledgedBoundary}`);
}
if (a.publishedBoundary !== b.publishedBoundary) {
out.push(`publishedBoundary: ${a.publishedBoundary} vs ${b.publishedBoundary}`);
}
if (differs(a.observationBoundaries, b.observationBoundaries)) {
out.push('observationBoundaries differ');
}
if (differs(a.outcomeIds, b.outcomeIds)) out.push('outcomeIds differ');
if (differs(a.eventIds, b.eventIds)) out.push('eventIds differ');
if (differs(a.boundaryActions, b.boundaryActions)) out.push('boundaryActions differ');
const idsA = a.agents.map((agent) => agent.agentId);
const idsB = b.agents.map((agent) => agent.agentId);
if (differs(idsA, idsB)) {
out.push(`agents: [${idsA.join(', ')}] vs [${idsB.join(', ')}]`);
} else {
a.agents.forEach((agent, index) => {
if (differs(agent, b.agents[index])) {
out.push(`agent ${agent.agentId}: behaviour differs`);
}
});
}
return out;
}
/** Two whole runs agree on behaviour, transition by transition. */
export function runsEqual(
left: readonly TransitionTrace[],
right: readonly TransitionTrace[],
): boolean {
return (
left.length === right.length &&
left.every((trace, index) => behaviourEquals(trace, right[index] as TransitionTrace))
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,150 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
MAX_ENVELOPE_BYTES,
canonicalize,
digestOf,
parseStrict,
requireEnvelopeFit,
} from '../src/canonical';
import { bodyDigest, operationKeyDigest, readScope } from '../src/common';
import * as fixtures from '../src/fixtures';
const ASTRAL = String.fromCodePoint(0x10400);
const FULLWIDTH_A = String.fromCodePoint(0xff21);
const E_ACUTE = String.fromCodePoint(0xe9);
test('object keys are sorted by UTF-16 code unit', () => {
const value: Record<string, number> = { b: 1, a: 2, A: 3 };
value[E_ACUTE] = 4;
value[ASTRAL] = 5;
value[FULLWIDTH_A] = 6;
assert.equal(
canonicalize(value),
`{"A":3,"a":2,"b":1,"${E_ACUTE}":4,"${ASTRAL}":5,"${FULLWIDTH_A}":6}`,
'an astral key, whose leading surrogate is D801, sorts before U+FF21',
);
});
test('numbers print the way ECMAScript prints them', () => {
const file = fixtures.load('boundaries.json');
for (const item of fixtures.section(file, 'doubles')) {
const record = item as Record<string, unknown>;
const value = record.value as number;
if (record.accept === true) {
assert.equal(canonicalize(value), record.canonical, `${value} prints as its canonical form`);
} else {
assert.throws(() => canonicalize(value), `${value} must be refused`);
}
}
});
test('strings are escaped the way JSON.stringify escapes them', () => {
const bell = String.fromCharCode(7);
const del = String.fromCharCode(0x7f);
const value = { s: ['q"', 'b\\', 't\t', 'n\n', bell, del].join(' ') };
assert.equal(
canonicalize(value),
JSON.stringify(value),
'for a single-key object the two agree exactly, escape for escape',
);
assert.ok(canonicalize(value).includes('\\u0007'), 'a control character uses lowercase \\u');
assert.ok(canonicalize(value).includes(del), 'DEL is not an escape in JSON');
});
test('canonical form does not depend on the input formatting', () => {
const compact = '{"b":[1,2,{"y":true,"x":null}],"a":"z"}';
const pretty = '{\n "a" : "z",\n "b": [ 1, 2, { "x": null, "y": true } ]\n}';
assert.equal(canonicalize(parseStrict(compact)), canonicalize(parseStrict(pretty)));
assert.equal(digestOf(parseStrict(compact)), digestOf(parseStrict(pretty)));
});
test('duplicate keys and invalid UTF-8 never parse', () => {
assert.throws(() => parseStrict('{"a":1,"a":2}'), /duplicate key/);
assert.throws(() => parseStrict('{"a":{"b":1,"b":2}}'), /duplicate key/);
assert.throws(() => parseStrict(new Uint8Array([0x7b, 0x22, 0xff, 0x22, 0x7d])), /UTF-8/);
assert.throws(() => parseStrict('{"a":1} {"b":2}'), /trailing data/);
assert.throws(() => parseStrict('{"a":NaN}'));
assert.throws(() => parseStrict('{"a":Infinity}'));
assert.throws(() => parseStrict('{"a":1'));
assert.throws(() => parseStrict(''));
});
test('an envelope over 64 KiB is refused', () => {
assert.throws(() => requireEnvelopeFit({ pad: 'a'.repeat(MAX_ENVELOPE_BYTES) }, 0));
const small = { pad: 'a' };
const length = canonicalize(small).length;
assert.equal(requireEnvelopeFit(small, MAX_ENVELOPE_BYTES - length), MAX_ENVELOPE_BYTES);
assert.throws(() => requireEnvelopeFit(small, MAX_ENVELOPE_BYTES - length + 1));
});
test('operation keys match the fixture and separate the operations they should', () => {
const file = fixtures.load('operations.json');
const digests: [string, string][] = [];
for (const item of fixtures.section(file, 'keys')) {
const name = fixtures.field(item, 'name');
const digest = operationKeyDigest({
scope: readScope(fixtures.member(item, 'scope')),
method: fixtures.field(item, 'method'),
workerId: fixtures.field(item, 'workerId'),
});
assert.equal(digest, fixtures.field(item, 'digest'), `${name}: operation key digest`);
digests.push([name, digest]);
}
digests.forEach(([name, digest], index) => {
for (const [otherName, other] of digests.slice(index + 1)) {
assert.notEqual(digest, other, `${name} and ${otherName} are different operations`);
}
});
});
test('canonical bodies match the fixture', () => {
const file = fixtures.load('operations.json');
for (const item of fixtures.section(file, 'bodies')) {
const scopeValue = fixtures.member(item, 'scope');
const digest = bodyDigest(
fixtures.field(item, 'method'),
scopeValue === null ? null : readScope(scopeValue),
fixtures.member(item, 'params'),
);
assert.equal(digest, fixtures.field(item, 'digest'), fixtures.field(item, 'name'));
}
});
test('operation pairs agree with the fixture about sameness', () => {
const file = fixtures.load('operations.json');
for (const item of fixtures.section(file, 'pairs')) {
const name = fixtures.field(item, 'name');
const reason = fixtures.field(item, 'reason');
const worker = fixtures.field(item, 'workerId');
const rightWorker = fixtures.optionalField(item, 'rightWorkerId') ?? worker;
const side = (key: string, workerId: string): [string, string] => {
const value = fixtures.member(item, key);
const method = fixtures.field(value, 'method');
const scope = readScope(fixtures.member(value, 'scope'));
const params = fixtures.member(value, 'params');
return [operationKeyDigest({ scope, method, workerId }), bodyDigest(method, scope, params)];
};
const [leftKey, leftBody] = side('left', worker);
const [rightKey, rightBody] = side('right', rightWorker);
const record = item as Record<string, unknown>;
assert.equal(leftKey === rightKey, record.sameKey, `${name}: key sameness. ${reason}`);
assert.equal(leftBody === rightBody, record.sameBody, `${name}: body sameness. ${reason}`);
}
});
test('a domain body can never carry a bus identity', () => {
const file = fixtures.load('operations.json');
for (const item of fixtures.section(file, 'rejected')) {
assert.throws(
() =>
bodyDigest(
fixtures.field(item, 'method'),
readScope(fixtures.member(item, 'scope')),
fixtures.member(item, 'params'),
),
`${fixtures.field(item, 'name')} must be refused`,
);
}
});

View file

@ -0,0 +1,126 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { canonicalize, parseStrict } from '../src/canonical';
import * as checkpoint from '../src/checkpoint';
import * as fixtures from '../src/fixtures';
function envelopeBytes(): Uint8Array {
const file = fixtures.load('checkpoint-envelope.json');
const envelope = fixtures.member(file, 'envelope');
return fixtures.decodeBase64(fixtures.field(envelope, 'base64'));
}
test('the fixture envelope decodes to its recorded layout', () => {
const file = fixtures.load('checkpoint-envelope.json');
const bytes = envelopeBytes();
const envelope = checkpoint.decode(bytes);
checkpoint.validateManifest(envelope);
const layout = fixtures.member(fixtures.member(file, 'envelope'), 'layout') as Record<
string,
unknown
>;
assert.equal(Buffer.from(bytes.subarray(0, 8)).toString('ascii'), checkpoint.MAGIC);
assert.equal(String(bytes.length), layout.totalBytes);
assert.equal(String(envelope.layout.tableOffset), layout.tableOffset);
assert.equal(envelope.layout.manifestBytes, layout.manifestBytes);
const entries = layout.entries as Record<string, unknown>[];
assert.equal(envelope.layout.entries.length, entries.length);
envelope.layout.entries.forEach((entry, index) => {
const recorded = entries[index]!;
assert.equal(entry.name, recorded.name);
assert.equal(String(entry.offset), recorded.offset);
assert.equal(String(entry.byteLength), recorded.byteLength);
assert.equal(entry.digest, recorded.digest);
assert.equal(entry.offset % 8, 0, 'payloads start on an eight-byte boundary');
});
for (const payload of fixtures.section(file, 'payloads')) {
const name = fixtures.field(payload, 'name');
const expected = fixtures.decodeBase64(fixtures.field(payload, 'base64'));
const found = envelope.payloads.find((candidate) => candidate.name === name);
assert.ok(found, `payload ${name} must be present`);
assert.deepEqual(found.bytes, expected, `payload ${name} must come back byte for byte`);
}
assert.equal(canonicalize(envelope.manifest), canonicalize(fixtures.member(file, 'manifest')));
});
test('every recorded corruption is refused', () => {
const file = fixtures.load('checkpoint-envelope.json');
const bytes = envelopeBytes();
for (const item of fixtures.section(file, 'corruption')) {
const name = fixtures.field(item, 'name');
const offset = (item as Record<string, unknown>).offset as number;
const corrupted = Uint8Array.from(bytes);
corrupted[offset] = (corrupted[offset]! ^ 0x01) & 0xff;
assert.throws(() => checkpoint.decode(corrupted), `${name} must be refused`);
}
assert.throws(() => checkpoint.decode(bytes.subarray(0, bytes.length - 1)));
assert.throws(() => checkpoint.decode(bytes.subarray(0, 8)));
});
test('a FLYSIM01 envelope is not read as a session checkpoint', () => {
const manifest = Buffer.from('{"schemaVersion":2,"chunks":["agent"]}', 'utf8');
const length = Buffer.alloc(4);
length.writeUInt32LE(manifest.length, 0);
const chunkLength = Buffer.alloc(4);
chunkLength.writeUInt32LE(64, 0);
const legacy = Buffer.concat([
Buffer.from('FLYSIM01', 'ascii'),
length,
manifest,
chunkLength,
Buffer.alloc(64),
Buffer.alloc(4), // the CRC32 footer
]);
assert.throws(() => checkpoint.decode(legacy), /magic/);
});
test('the layout is deterministic and the manifest is canonical', () => {
const manifest = parseStrict('{"b":2,"a":1}');
const payloads = [
{ name: 'one', bytes: new TextEncoder().encode('first') },
{ name: 'two', bytes: new Uint8Array(9) },
];
const bytes = checkpoint.encode(manifest, payloads);
assert.deepEqual(bytes, checkpoint.encode(manifest, payloads));
const envelope = checkpoint.decode(bytes);
const start = checkpoint.HEADER_BYTES;
const end = start + envelope.layout.manifestBytes;
assert.equal(Buffer.from(bytes.subarray(start, end)).toString('utf8'), '{"a":1,"b":2}');
assert.throws(() =>
checkpoint.encode(manifest, [
{ name: 'one', bytes: new Uint8Array() },
{ name: 'one', bytes: new Uint8Array() },
]),
);
assert.throws(() => checkpoint.encode(manifest, [{ name: 'One', bytes: new Uint8Array() }]));
});
test('an envelope written here is read by the same rules the Rust crate wrote its fixture with', () => {
const file = fixtures.load('checkpoint-envelope.json');
const manifest = fixtures.member(file, 'manifest');
const payloads = fixtures
.section(file, 'payloads')
.map((payload) => ({
name: fixtures.field(payload, 'name'),
bytes: fixtures.decodeBase64(fixtures.field(payload, 'base64')),
}));
assert.deepEqual(
Uint8Array.from(checkpoint.encode(manifest, payloads)),
Uint8Array.from(envelopeBytes()),
'the two implementations produce the same bytes for the same inputs',
);
});
test('a manifest missing a required field is not a complete checkpoint', () => {
const file = fixtures.load('checkpoint-envelope.json');
const full = fixtures.member(file, 'manifest') as Record<string, unknown>;
for (const field of checkpoint.REQUIRED_MANIFEST_FIELDS) {
const manifest = { ...full };
delete manifest[field];
const envelope = checkpoint.decode(checkpoint.encode(manifest, []));
assert.throws(() => checkpoint.validateManifest(envelope), `without ${field}`);
}
});

View file

@ -0,0 +1,85 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as fixtures from '../src/fixtures';
import { readCommittedSnapshot, readSessionDescriptor, validateSnapshotAgainst } from '../src/publishing';
import {
findPort,
readEnvironmentDescriptor,
readPortControl,
readSensoryInput,
readStepResult,
readWorldObservation,
validateBatch,
validateObservationAgainst,
validatePortControlAgainst,
validateSensoryInputAgainst,
validateStepResultAgainst,
} from '../src/workers';
import { requiredProducedStep, frameBytes } from '../src/media';
test('every descriptor check lands the way the fixture says', () => {
const file = fixtures.load('descriptor-checks.json');
const descriptor = readEnvironmentDescriptor(fixtures.member(file, 'descriptor'));
const delayed = readEnvironmentDescriptor(fixtures.member(file, 'delayedDescriptor'));
const session = readSessionDescriptor(fixtures.member(file, 'sessionDescriptor'));
const previous = readWorldObservation(fixtures.member(file, 'stepResultPrevious'));
for (const item of fixtures.cases(file)) {
const name = fixtures.field(item, 'name');
const kind = fixtures.field(item, 'kind');
const reason = fixtures.optionalField(item, 'reason') ?? '';
const expectAccept = fixtures.field(item, 'expect') === 'accept';
const value = fixtures.member(item, 'value');
const attempt = () => {
switch (kind) {
case 'portControl': {
const control = readPortControl(value);
const port = findPort(descriptor, control.portId);
if (!port) throw new Error('no such port');
validatePortControlAgainst(control, port.controls);
return;
}
case 'advanceControls': {
const controls = (value as unknown[]).map(readPortControl);
validateBatch(descriptor, controls);
return;
}
case 'sensoryInput':
validateSensoryInputAgainst(readSensoryInput(value), descriptor.views);
return;
case 'sensoryInputDelayed':
validateSensoryInputAgainst(readSensoryInput(value), delayed.views);
return;
case 'worldObservation':
validateObservationAgainst(readWorldObservation(value), descriptor);
return;
case 'stepResult':
validateStepResultAgainst(readStepResult(value), descriptor, previous);
return;
case 'snapshot':
validateSnapshotAgainst(readCommittedSnapshot(value), session);
return;
default:
throw new Error(`unknown descriptor check kind "${kind}"`);
}
};
if (expectAccept) {
assert.doesNotThrow(attempt, `${name} must be accepted. ${reason}`);
} else {
assert.throws(attempt, `${name} must be refused. ${reason}`);
}
}
});
test('the required producing boundary saturates at zero', () => {
const file = fixtures.load('descriptor-checks.json');
const delayed = readEnvironmentDescriptor(fixtures.member(file, 'delayedDescriptor'));
const view = delayed.views[0]!;
assert.equal(view.observationDelaySteps, 2);
assert.equal(requiredProducedStep(view, 0n), 0n);
assert.equal(requiredProducedStep(view, 1n), 0n);
assert.equal(requiredProducedStep(view, 2n), 0n);
assert.equal(requiredProducedStep(view, 3n), 1n);
assert.equal(frameBytes(view), 160 * 4 * 144);
});

View file

@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as fixtures from '../src/fixtures';
import { readArtifactRef } from '../src/reader';
import {
artifactIdentity,
isBusCallId,
isDigest,
isDomainRequestId,
isId,
ownerTokenKind,
parseU64,
} from '../src/scalar';
import { readAssetRef } from '../src/workers';
test('U64 boundaries reject from the fixture', () => {
const file = fixtures.load('boundaries.json');
for (const item of fixtures.section(file, 'u64')) {
const record = item as Record<string, unknown>;
assert.equal(
parseU64(record.text) !== undefined,
record.accept,
`${String(record.text)}: ${String(record.reason)}`,
);
}
});
test('the Id and Digest encodings are the ones the bus uses', () => {
for (const id of ['a', 'fly-a', '0', 'a.b_c-d', 'a'.repeat(64)]) {
assert.ok(isId(id), `${id} is an Id`);
}
for (const id of ['', 'A', '-a', '.a', 'a b', 'fly/a', 'a'.repeat(65)]) {
assert.ok(!isId(id), `${id} is not an Id`);
}
assert.ok(isDigest('a'.repeat(64)));
assert.ok(!isDigest('A'.repeat(64)), 'digests are lowercase');
assert.ok(!isDigest('a'.repeat(63)), 'digests are 64 hex digits');
assert.ok(!isDigest('g'.repeat(64)), 'digests are hexadecimal');
});
test('the four identities never accept each other spellings', () => {
const file = fixtures.load('identities.json');
for (const item of fixtures.cases(file)) {
const record = item as Record<string, unknown>;
const text = record.text as string;
assert.equal(isBusCallId(text), record.busCallId, `busCallId ${text}`);
assert.equal(isDomainRequestId(text), record.domainRequestId, `domainRequestId ${text}`);
assert.equal(ownerTokenKind(text) ?? null, record.ownerToken, `owner token ${text}`);
const accepted = [
isBusCallId(text),
isDomainRequestId(text),
ownerTokenKind(text) !== undefined,
].filter(Boolean).length;
assert.ok(accepted <= 1, `${text} is accepted by more than one identity type`);
}
});
test('an artifact identity is the naming half of an ArtifactRef, and an asset is neither', () => {
const file = fixtures.load('identities.json');
const artifact = fixtures.member(file, 'artifact');
const reference = readArtifactRef(fixtures.member(artifact, 'ref'));
assert.deepEqual(artifactIdentity(reference), fixtures.member(artifact, 'identity'));
const asset = readAssetRef(fixtures.member(file, 'asset'));
assert.notEqual(
asset.id,
reference.artifactId,
'the fixture asset and artifact are deliberately different things',
);
});

View file

@ -0,0 +1,176 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { canonicalize, digestOf, sha256Hex } from '../src/canonical';
import {
readAgentRollbackParams,
readAgentRollbackResult,
readRestoreSlotParams,
validateAgentRollbackAgainstScope,
validateAgentRollbackResultAgainstScope,
validateRestoreSlotAgainstScope,
} from '../src/extensions';
import * as fixtures from '../src/fixtures';
import * as gameboy from '../src/gameboy';
import { addRational, divideFloor, RATIONAL_ZERO, type Scope } from '../src/scalar';
import { EPISODE_REQUEST_KINDS, readEpisodeRequest } from '../src/workers';
const legacy = () => fixtures.load('gameboy-legacy.json') as Record<string, any>;
test('every registered schema reference is the digest of its declaration, in this language', () => {
const file = legacy();
const declared = new Map<string, unknown>(
(file.extensionSet.payloadSchemas as Record<string, any>[]).map((entry) => [
entry.declaration.id as string,
entry.declaration,
]),
);
for (const schema of gameboy.PAYLOAD_SCHEMAS) {
assert.deepEqual(file.schemaRefs[schema.id], schema, `${schema.id}: the constant is the fixture`);
assert.equal(digestOf(declared.get(schema.id)), schema.digest, `${schema.id}: recomputed here`);
}
assert.equal(digestOf(file.extensionSet), file.extensionSetDigest);
});
test('the legacy profile document is the one profile and its digest is the constant', () => {
const file = legacy();
const document = file.profile.document;
const profile = gameboy.readLegacyGameboyProfile(document);
assert.equal(canonicalize(profile), file.profile.canonical, 'reading keeps every field');
assert.equal(sha256Hex(file.profile.canonical), gameboy.PROFILE_DIGEST);
assert.equal(file.profile.assetRef.digest, gameboy.PROFILE_DIGEST);
assert.equal(file.profile.assetRef.byteLength, String(file.profile.canonical.length));
assert.equal(profile.kernelVersion, 'lif-1ms-f64-v2');
assert.equal(profile.plasticityVersion, 'fly-kc-mbon-rstdp-v2');
assert.equal(profile.datasetFingerprint.split(':').length, 7);
});
test('the frame clock is exact and matches the legacy f64 accumulator', () => {
const legacyMsPerFrame = 1000 / (4_194_304 / 70_224);
assert.equal(legacyMsPerFrame, 548_625 / 32_768, 'the legacy constant is dyadic, so exact');
const frames = legacy().clock.frames as { ticks: string; remainder: unknown }[];
let exact = RATIONAL_ZERO;
let float = 0;
for (let frame = 0; frame < 20_000; frame += 1) {
exact = addRational(exact, gameboy.STEP_DURATION);
const { ticks, remainder } = divideFloor(exact, gameboy.TICK_DURATION);
exact = remainder;
float += legacyMsPerFrame;
const steps = Math.floor(float);
float -= steps;
assert.equal(ticks, String(steps), `frame ${frame}: tick counts agree`);
// The f64 remainder is a multiple of 2^-15 ms; compare it as an exact fraction of ns.
const scaled = float * 32_768;
assert.equal(scaled, Math.floor(scaled), `frame ${frame}: dyadic`);
assert.equal(
BigInt(remainder.numerator) * 32_768n,
BigInt(scaled) * 1_000_000n * BigInt(remainder.denominator),
`frame ${frame}: remainders agree`,
);
if (frame < frames.length) {
assert.equal(frames[frame]!.ticks, ticks);
assert.deepEqual(frames[frame]!.remainder, remainder);
}
}
});
test('the example composition digest is the recorded one, and the decoder moves it', () => {
const file = legacy();
const composition = gameboy.readLegacyGameboyComposition(file.composition.example);
assert.equal(gameboy.compositionDeclarationDigest(composition), file.composition.digest);
const other = structuredClone(composition);
other.decoderConfigDigest = sha256Hex('another decoder');
assert.notEqual(gameboy.compositionDeclarationDigest(other), file.composition.digest);
assert.equal(other.flysimCompatibility, composition.flysimCompatibility);
});
test('bound is an ordered subset, and the macro winner is always bound', () => {
const channels = ['macro_go_objective', 'macro_talk', 'macro_next'];
const context = gameboy.readGameboyReadoutContext({
boot: false,
bound: ['macro_go_objective', 'macro_next'],
location: null,
});
gameboy.validateReadoutContextAgainst(context, channels);
assert.throws(() =>
gameboy.validateReadoutContextAgainst({ ...context, bound: ['macro_next', 'macro_go_objective'] }, channels),
);
const decision = gameboy.readGameboyChannelsDecision({
buttons: gameboy.GAMEBOY_BUTTONS.map((id) => ({ id, down: id === 'up' || id === 'a' })),
macro: 'macro_next',
});
assert.equal(gameboy.channelsMask(decision), 0x11);
gameboy.validateDecisionAgainst(decision, context);
assert.throws(() => gameboy.validateDecisionAgainst({ ...decision, macro: 'macro_talk' }, context));
});
test('typed values are read only under their registered schema', () => {
const value = { boot: true, bound: [], location: null };
gameboy.readGameboyReadoutContextTyped({ schema: gameboy.READOUT_CONTEXT_SCHEMA, value });
assert.throws(() => gameboy.readGameboyReadoutContextTyped({ schema: gameboy.CHANNELS_SCHEMA, value }));
assert.throws(() =>
gameboy.readGameboyReadoutContextTyped({
schema: { ...gameboy.READOUT_CONTEXT_SCHEMA, digest: sha256Hex('x') },
value,
}),
);
});
test('a rollback request and the extension methods check what they must', () => {
assert.deepEqual([...EPISODE_REQUEST_KINDS], ['terminal', 'rollback']);
const request = readEpisodeRequest({
kind: 'rollback',
reason: 'stall',
outcome: { schema: gameboy.ROLLBACK_REQUEST_SCHEMA, value: { slotId: 'best', trigger: 'stall' } },
});
assert.equal(gameboy.readLegacyRatchetRollbackRequestTyped(request.outcome).slotId, 'best');
const newEpoch: Scope = { sessionId: 'live', epoch: 'epoch-8', step: '4101' };
const sameEpoch: Scope = { sessionId: 'live', epoch: 'epoch-7', step: '4101' };
const restore = readRestoreSlotParams({
slotId: 'best',
priorEpoch: 'epoch-7',
policy: 'legacy-ratchet-rollback-v1',
});
validateRestoreSlotAgainstScope(restore, newEpoch);
assert.throws(() => validateRestoreSlotAgainstScope(restore, sameEpoch));
const cases = fixtures.cases(fixtures.load('valid.json')) as Record<string, any>[];
const find = (name: string) => cases.find((item) => item.name === name)!.value;
const rollback = readAgentRollbackParams(find('agent rollback params'));
validateAgentRollbackAgainstScope(rollback, newEpoch);
assert.throws(() => validateAgentRollbackAgainstScope(rollback, { ...newEpoch, step: '4102' }));
const result = readAgentRollbackResult(find('agent rollback result'));
validateAgentRollbackResultAgainstScope(result, newEpoch);
assert.throws(() => validateAgentRollbackResultAgainstScope(result, { ...newEpoch, step: '4100' }));
});
test('the decoderConfigDigest vectors are what the TypeScript oracle preset computes', async () => {
const { gameboyDecoderConfig } = await import('@flybrain/brain');
const file = fixtures.load('gameboy-decoder-config.json') as Record<string, any>;
const cases = file.cases as Record<string, any>[];
assert.deepEqual(
cases.map((item) => item.name),
['raw', 'macros'],
);
for (const item of cases) {
const form = gameboy.decoderConfigForm(gameboyDecoderConfig(item.macroChannels as string[]));
assert.deepEqual(form, item.form, `${item.name}: the oracle's form is the Rust twin's`);
assert.equal(canonicalize(form), item.canonical, `${item.name}: canonical bytes`);
assert.equal(
gameboy.decoderConfigDigest(gameboyDecoderConfig(item.macroChannels as string[])),
item.digest,
`${item.name}: digest`,
);
}
const reversed = [...(cases[1]!.macroChannels as string[])].reverse();
assert.notEqual(
gameboy.decoderConfigDigest(gameboyDecoderConfig(reversed)),
cases[1]!.digest,
'channel order is identity',
);
// The example composition declares the real macros-mode digest and channel set.
const example = legacy().composition.example;
assert.equal(example.decoderConfigDigest, cases[1]!.digest);
assert.deepEqual(example.executor.macroChannels, cases[1]!.macroChannels);
});

View file

@ -0,0 +1,136 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { canonicalize, parseStrict, requireEnvelopeFit, sha256Hex } from '../src/canonical';
import { MAX_TYPED_VALUE_BYTES } from '../src/scalar';
import { readTypedValue } from '../src/common';
import * as fixtures from '../src/fixtures';
import { READERS, roundTrip } from './readers';
test('every valid case round trips and canonicalizes to its recorded bytes', () => {
const file = fixtures.load('valid.json');
const cases = fixtures.cases(file);
for (const item of cases) {
const name = fixtures.field(item, 'name');
const typeName = fixtures.field(item, 'type');
const value = fixtures.member(item, 'value');
let written: unknown;
try {
written = roundTrip(typeName, value);
} catch (error) {
assert.fail(`${name} (${typeName}) must be accepted: ${String(error)}`);
}
const canonicalIn = canonicalize(value);
assert.equal(
canonicalize(written),
canonicalIn,
`${name}: reading and writing must preserve every field`,
);
assert.equal(canonicalIn, fixtures.field(item, 'canonical'), `${name}: canonical JSON`);
assert.equal(sha256Hex(canonicalIn), fixtures.field(item, 'digest'), `${name}: digest`);
}
assert.ok(cases.length >= 70, 'the valid fixture should stay broad');
});
test('every type this package reads appears in the valid fixture', () => {
const covered = fixtures
.cases(fixtures.load('valid.json'))
.map((item) => fixtures.field(item, 'type'));
const missing = Object.keys(READERS).filter((typeName) => !covered.includes(typeName));
assert.deepEqual(missing, [], 'every readable type needs at least one accepted fixture');
});
test('every invalid case is refused', () => {
const file = fixtures.load('invalid.json');
const cases = fixtures.cases(file);
for (const item of cases) {
const name = fixtures.field(item, 'name');
const typeName = fixtures.field(item, 'type');
const reason = fixtures.field(item, 'reason');
assert.throws(
() => roundTrip(typeName, fixtures.member(item, 'value')),
`${name} (${typeName}) must be refused: ${reason}`,
);
}
assert.ok(cases.length >= 80, 'the invalid fixture should stay broad');
});
test('every raw byte case is refused before or during validation', () => {
for (const item of fixtures.cases(fixtures.load('raw.json'))) {
const name = fixtures.field(item, 'name');
const typeName = fixtures.field(item, 'type');
const reason = fixtures.field(item, 'reason');
const bytes = fixtures.decodeBase64(fixtures.field(item, 'base64'));
// parseStrict is the only door into the readers, so a byte sequence that does not parse
// never reaches validation.
assert.throws(
() => roundTrip(typeName, parseStrict(bytes)),
`${name} must be refused: ${reason}`,
);
}
});
test('generated boundary cases land on the right side of every limit', () => {
const file = fixtures.load('generated.json');
const padSchema = fixtures.member(file, 'padSchema');
for (const item of fixtures.cases(file)) {
const name = fixtures.field(item, 'name');
const kind = fixtures.field(item, 'kind');
const expectAccept = fixtures.field(item, 'expect') === 'accept';
const record = item as Record<string, unknown>;
const attempt = () => {
switch (kind) {
case 'padded-typed-value': {
const pad = 'a'.repeat(record.padCharacters as number);
readTypedValue({ schema: padSchema, value: { pad } });
return;
}
case 'padded-request': {
const pad = 'a'.repeat(record.padCharacters as number);
const total = record.envelopeTotal as number;
const body = roundTrip('SessionRpcRequest', {
requestId: 'req-1',
scope: null,
params: { pad },
});
requireEnvelopeFit(body, total - canonicalize(body).length);
return;
}
case 'error-message':
case 'error-message-astral': {
const character = kind === 'error-message' ? 'x' : '\u{10400}';
const message = character.repeat(record.codePoints as number);
roundTrip('SessionRpcFailure', {
type: 'error',
requestId: 'req-41',
workerId: 'fly-a',
incarnationId: 'inc-1',
scope: null,
error: { code: 'INTERNAL', message, mutation: 'unknown' },
});
return;
}
default:
throw new Error(`unknown generated case kind "${kind}"`);
}
};
if (expectAccept) {
assert.doesNotThrow(attempt, `${name} must be accepted`);
} else {
assert.throws(attempt, `${name} must be refused`);
}
}
});
test('a typed value at the cap is accepted and one byte more is not', () => {
const schema = { id: 'pad.v1', version: 1, digest: sha256Hex('pad.v1') };
const overhead = canonicalize({ schema, value: { pad: '' } }).length;
const atCap = readTypedValue({
schema,
value: { pad: 'a'.repeat(MAX_TYPED_VALUE_BYTES - overhead) },
});
assert.equal(canonicalize(atCap).length, MAX_TYPED_VALUE_BYTES);
assert.throws(() =>
readTypedValue({ schema, value: { pad: 'a'.repeat(MAX_TYPED_VALUE_BYTES - overhead + 1) } }),
);
});

View file

@ -0,0 +1,98 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readRational } from '../src/common';
import * as fixtures from '../src/fixtures';
import {
RATIONAL_ZERO,
U64_MAX,
addRational,
compareRational,
divideFloor,
multiplyRational,
reduced,
requirePositiveRational,
subtractRational,
validateRational,
} from '../src/scalar';
test('the accumulator produces the fixture tick counts and remainders', () => {
const file = fixtures.load('rational.json');
for (const item of fixtures.section(file, 'accumulator')) {
const name = fixtures.field(item, 'name');
const step = readRational(fixtures.member(item, 'stepDuration'));
const tick = readRational(fixtures.member(item, 'tickDuration'));
let accumulator = RATIONAL_ZERO;
let total = 0n;
fixtures.section(item, 'steps').forEach((expected, index) => {
accumulator = addRational(accumulator, step);
const { ticks, remainder } = divideFloor(accumulator, tick);
accumulator = remainder;
total += BigInt(ticks);
assert.equal(ticks, fixtures.field(expected, 'ticks'), `${name}: ticks at step ${index}`);
assert.deepEqual(
remainder,
readRational(fixtures.member(expected, 'remainder')),
`${name}: remainder at step ${index}`,
);
assert.ok(compareRational(remainder, tick) < 0, `${name}: remainder below one tick`);
});
assert.equal(total.toString(), fixtures.field(item, 'totalTicks'), `${name}: total ticks`);
}
});
test('checked arithmetic reduces or refuses', () => {
const file = fixtures.load('rational.json');
for (const item of fixtures.section(file, 'add')) {
const left = readRational(fixtures.member(item, 'a'));
const right = readRational(fixtures.member(item, 'b'));
const record = item as Record<string, unknown>;
if (record.sum !== undefined) {
assert.deepEqual(addRational(left, right), readRational(record.sum as never));
} else {
assert.throws(() => addRational(left, right));
}
}
for (const item of fixtures.section(file, 'subtract')) {
const left = readRational(fixtures.member(item, 'a'));
const right = readRational(fixtures.member(item, 'b'));
const record = item as Record<string, unknown>;
if (record.difference !== undefined) {
assert.deepEqual(subtractRational(left, right), readRational(record.difference as never));
} else {
assert.throws(() => subtractRational(left, right));
}
}
for (const item of fixtures.section(file, 'multiply')) {
const value = readRational(fixtures.member(item, 'a'));
const factor = BigInt(fixtures.field(item, 'k'));
const record = item as Record<string, unknown>;
if (record.product !== undefined) {
assert.deepEqual(multiplyRational(value, factor), readRational(record.product as never));
} else {
assert.throws(() => multiplyRational(value, factor));
}
}
for (const item of fixtures.section(file, 'compare')) {
const left = readRational(fixtures.member(item, 'a'));
const right = readRational(fixtures.member(item, 'b'));
const expected = { less: -1, equal: 0, greater: 1 }[fixtures.field(item, 'ordering')];
assert.equal(compareRational(left, right), expected);
}
});
test('zero has exactly one encoding and durations must be positive', () => {
validateRational(RATIONAL_ZERO);
assert.throws(() => validateRational({ numerator: '0', denominator: '2' }), /0\/1/);
assert.throws(() => validateRational({ numerator: '1', denominator: '0' }), /positive/);
assert.throws(() => validateRational({ numerator: '2', denominator: '4' }), /reduced/);
assert.throws(() => requirePositiveRational(RATIONAL_ZERO, 'worldTime'));
assert.throws(() => divideFloor(RATIONAL_ZERO, RATIONAL_ZERO), /positive/);
});
test('reduction refuses a result that does not fit U64', () => {
const big = { numerator: U64_MAX.toString(), denominator: '1' };
assert.throws(() => multiplyRational(big, 2n), /does not fit U64/);
assert.throws(() => addRational(big, big), /does not fit U64/);
assert.deepEqual(reduced(U64_MAX * 2n, 2n), big);
});

View file

@ -0,0 +1,150 @@
/** One place that knows how to read every type named by a fixture. */
import type { Json } from '../src/canonical';
import {
readRational,
readSchemaRef,
readScope,
readTypedValue,
} from '../src/common';
import {
readActivateRestoreParams,
readAudioDescriptor,
readAudioRef,
readCaptureParams,
readCaptureResult,
readStageRestoreParams,
readStageRestoreResult,
readViewDescriptor,
readViewRef,
} from '../src/media';
import {
readAgentRollbackParams,
readAgentRollbackResult,
readRestoreSlotParams,
readRestoreSlotResult,
readSaveSlotParams,
readSaveSlotResult,
} from '../src/extensions';
import {
readGameboyChannelsDecision,
readGameboyMemoryInspection,
readGameboyReadoutContext,
readLegacyGameboyComposition,
readLegacyGameboyProfile,
readLegacyRatchetRollbackRequest,
} from '../src/gameboy';
import { readCommittedSnapshot, readSessionDescriptor } from '../src/publishing';
import {
readSessionRpcFailure,
readSessionRpcRequest,
readSessionRpcSuccess,
} from '../src/rpc';
import {
readTraceBehaviour,
readTraceOperational,
readTransitionTrace,
} from '../src/trace';
import {
readAcknowledgeParams,
readAcknowledgeResult,
readActivateRestoreResult,
readAdvanceParams,
readAgentCommitResult,
readAgentInitializeParams,
readAgentInitializeResult,
readAgentTelemetry,
readAssetRef,
readCommitParams,
readControllerSchema,
readEnvironmentDescriptor,
readEnvironmentInitializeParams,
readEnvironmentInitializeResult,
readEpisodeRequest,
readHelloParams,
readHelloResult,
readPortControl,
readPrepareParams,
readPreparedDecision,
readReward,
readSensoryInput,
readShutdownParams,
readShutdownResult,
readStatusResult,
readStepResult,
readStimulus,
readTaskEvent,
readWorldObservation,
} from '../src/workers';
/** Every type the fixtures name, and the reader that validates it. */
export const READERS: Record<string, (value: unknown) => unknown> = {
Scope: readScope,
RationalNs: readRational,
SchemaRef: readSchemaRef,
TypedValue: readTypedValue,
SessionRpcRequest: readSessionRpcRequest,
SessionRpcSuccess: readSessionRpcSuccess,
SessionRpcFailure: readSessionRpcFailure,
AssetRef: readAssetRef,
SensoryInput: readSensoryInput,
Stimulus: readStimulus,
Reward: readReward,
AgentTelemetry: readAgentTelemetry,
AgentInitializeParams: readAgentInitializeParams,
AgentInitializeResult: readAgentInitializeResult,
PrepareParams: readPrepareParams,
PreparedDecision: readPreparedDecision,
CommitParams: readCommitParams,
AgentCommitResult: readAgentCommitResult,
ControllerSchema: readControllerSchema,
PortControl: readPortControl,
EnvironmentDescriptor: readEnvironmentDescriptor,
EnvironmentInitializeParams: readEnvironmentInitializeParams,
EnvironmentInitializeResult: readEnvironmentInitializeResult,
WorldObservation: readWorldObservation,
AdvanceParams: readAdvanceParams,
StepResult: readStepResult,
HelloParams: readHelloParams,
HelloResult: readHelloResult,
StatusResult: readStatusResult,
AcknowledgeParams: readAcknowledgeParams,
AcknowledgeResult: readAcknowledgeResult,
ShutdownParams: readShutdownParams,
ShutdownResult: readShutdownResult,
TaskEvent: readTaskEvent,
EpisodeRequest: readEpisodeRequest,
ViewDescriptor: readViewDescriptor,
ViewRef: readViewRef,
AudioDescriptor: readAudioDescriptor,
AudioRef: readAudioRef,
CaptureParams: readCaptureParams,
CaptureResult: readCaptureResult,
StageRestoreParams: readStageRestoreParams,
StageRestoreResult: readStageRestoreResult,
ActivateRestoreParams: readActivateRestoreParams,
ActivateRestoreResult: readActivateRestoreResult,
SessionDescriptor: readSessionDescriptor,
CommittedSnapshot: readCommittedSnapshot,
TraceBehaviour: readTraceBehaviour,
TraceOperational: readTraceOperational,
TransitionTrace: readTransitionTrace,
SaveSlotParams: readSaveSlotParams,
SaveSlotResult: readSaveSlotResult,
RestoreSlotParams: readRestoreSlotParams,
RestoreSlotResult: readRestoreSlotResult,
AgentRollbackParams: readAgentRollbackParams,
AgentRollbackResult: readAgentRollbackResult,
GameboyReadoutContext: readGameboyReadoutContext,
GameboyChannelsDecision: readGameboyChannelsDecision,
GameboyMemoryInspection: readGameboyMemoryInspection,
LegacyRatchetRollbackRequest: readLegacyRatchetRollbackRequest,
LegacyGameboyProfile: readLegacyGameboyProfile,
LegacyGameboyComposition: readLegacyGameboyComposition,
};
/** Reads the value as `typeName` and hands back what the reader reconstructed. */
export function roundTrip(typeName: string, value: Json): unknown {
const reader = READERS[typeName];
if (!reader) throw new Error(`no fixture reader for type "${typeName}"`);
return reader(value);
}

View file

@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { canonicalize, digestOf, parseStrict, sha256Hex } from '../src/canonical';
import * as fixtures from '../src/fixtures';
/**
* The schema set is generated by the Rust crate; this side hashes the checked-in file with its
* own canonical JSON and digest, which is the cross-language half of `contractDigest`.
*/
test('the contract digest is the digest of the checked-in schema set', () => {
const bytes = fixtures.loadBytes('schema-set.json');
const text = Buffer.from(bytes).toString('utf8');
assert.ok(text.endsWith('\n'), 'the file is the canonical set plus one newline');
const canonical = text.slice(0, -1);
const recorded = fixtures.load('contract-digest.json') as Record<string, unknown>;
assert.equal(sha256Hex(canonical), recorded.contractDigest);
assert.equal(canonical.length, recorded.schemaSetBytes);
// ... and the file really is canonical JSON: reserializing it changes nothing.
assert.equal(canonicalize(parseStrict(canonical)), canonical);
assert.equal(digestOf(parseStrict(canonical)), recorded.contractDigest);
});
test('the contract digest survives reformatting and changes when a schema changes', () => {
const set = parseStrict(
Buffer.from(fixtures.loadBytes('schema-set.json')).toString('utf8').slice(0, -1),
) as Record<string, unknown>;
const recorded = fixtures.load('contract-digest.json') as Record<string, unknown>;
const pretty = parseStrict(JSON.stringify(set, null, 4));
assert.equal(digestOf(pretty), recorded.contractDigest, 'pretty printing is not a change');
const types = set.types as Record<string, unknown>[];
const renamed = structuredClone(set);
((renamed.types as Record<string, unknown>[])[0]!.fields as Record<string, unknown>[])[0]!.name =
'sessionIdentifier';
assert.notEqual(digestOf(renamed), recorded.contractDigest, 'a renamed field is a change');
const widened = structuredClone(set);
for (const limit of widened.limits as Record<string, unknown>[]) {
if (limit.name === 'maxAgents') limit.value = 8;
}
assert.notEqual(digestOf(widened), recorded.contractDigest, 'a widened bound is a change');
const dropped = structuredClone(set);
(dropped.types as unknown[]).pop();
assert.notEqual(digestOf(dropped), recorded.contractDigest, 'a dropped type is a change');
assert.ok(types.length >= 50, 'the schema set should stay broad');
});
test('the schema set publishes the limits this package enforces', async () => {
const set = parseStrict(
Buffer.from(fixtures.loadBytes('schema-set.json')).toString('utf8').slice(0, -1),
) as Record<string, unknown>;
const limits = new Map(
(set.limits as Record<string, unknown>[]).map((limit) => [
limit.name as string,
limit.value as number,
]),
);
const workers = await import('../src/workers');
const media = await import('../src/media');
const scalar = await import('../src/scalar');
const canonical = await import('../src/canonical');
assert.equal(limits.get('maxAgents'), workers.MAX_AGENTS);
assert.equal(limits.get('maxPorts'), workers.MAX_PORTS);
assert.equal(limits.get('maxRateRoles'), workers.MAX_RATE_ROLES);
assert.equal(limits.get('maxStimuliPerOperation'), workers.MAX_STIMULI);
assert.equal(limits.get('maxRewardsPerOperation'), workers.MAX_REWARDS);
assert.equal(limits.get('maxButtons'), workers.MAX_BUTTONS);
assert.equal(limits.get('maxAxes'), workers.MAX_AXES);
assert.equal(limits.get('maxAcknowledge'), workers.MAX_ACKNOWLEDGE);
assert.equal(limits.get('maxMessageCodePoints'), workers.MAX_MESSAGE_CODE_POINTS);
assert.equal(limits.get('maxViews'), media.MAX_VIEWS);
assert.equal(limits.get('maxViewDimension'), media.MAX_VIEW_DIMENSION);
assert.equal(limits.get('maxSampleFrames'), media.MAX_SAMPLE_FRAMES);
assert.equal(limits.get('maxAudioStreams'), media.MAX_AUDIO_STREAMS);
assert.equal(limits.get('maxTypedValueBytes'), scalar.MAX_TYPED_VALUE_BYTES);
assert.equal(limits.get('maxEnvelopeBytes'), canonical.MAX_ENVELOPE_BYTES);
const extensions = await import('../src/extensions');
assert.equal(limits.get('maxSlots'), extensions.MAX_SLOTS);
});
test('the closed enums this package knows are the ones the schema set declares', async () => {
const set = parseStrict(
Buffer.from(fixtures.loadBytes('schema-set.json')).toString('utf8').slice(0, -1),
) as Record<string, unknown>;
const enums = new Map(
(set.enums as Record<string, unknown>[]).map((entry) => [
entry.name as string,
entry.members as string[],
]),
);
const workers = await import('../src/workers');
const rpc = await import('../src/rpc');
assert.deepEqual(enums.get('ErrorCode'), [...rpc.ERROR_CODES]);
assert.deepEqual(enums.get('MutationCertainty'), [...rpc.MUTATION_CERTAINTIES]);
assert.deepEqual(enums.get('Role'), [...workers.ROLES]);
assert.deepEqual(enums.get('WorkerState'), [...workers.WORKER_STATES]);
assert.deepEqual(enums.get('Recovery'), [...workers.RECOVERY]);
assert.deepEqual(enums.get('Determinism'), [...workers.DETERMINISM]);
assert.deepEqual(enums.get('AxisRange'), [...workers.AXIS_RANGES]);
assert.deepEqual(enums.get('EpisodeRequestKind'), [...workers.EPISODE_REQUEST_KINDS]);
});

View file

@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as fixtures from '../src/fixtures';
import * as seed from '../src/seed';
test('every vector derives its recorded seed', () => {
const file = fixtures.load('seed-vectors.json');
assert.equal((file as Record<string, unknown>).algorithm, seed.ALGORITHM);
const vectors = fixtures.section(file, 'vectors');
for (const item of vectors) {
const master = seed.masterSeed(fixtures.field(item, 'masterSeed'));
const agentId = fixtures.field(item, 'agentId');
assert.equal(
Buffer.from(seed.material(master, agentId)).toString('utf8'),
fixtures.field(item, 'material'),
'the hashed material is part of the specification',
);
assert.equal(seed.materialDigest(master, agentId), fixtures.field(item, 'materialDigest'));
assert.equal(
seed.agentSeed(master, agentId),
(item as Record<string, unknown>).seed,
`seed for ${agentId} under master ${master}`,
);
}
assert.ok(vectors.length >= 20, 'keep the vector table broad');
});
test('one composition gets independent seeds', () => {
const file = fixtures.load('seed-vectors.json');
const composition = fixtures.member(file, 'composition');
const master = seed.masterSeed(fixtures.field(composition, 'masterSeed'));
const ids = fixtures.section(composition, 'agentIds') as string[];
const seeds = seed.compositionSeeds(master, ids);
assert.deepEqual(seeds, fixtures.section(composition, 'seeds'));
assert.equal(new Set(seeds).size, seeds.length, 'per-agent seeds are independent');
assert.ok(
seeds.every((value) => value !== 0),
'a zero seed would stall an xorshift generator',
);
});
test('a different master seed or agent id derives a different seed', () => {
assert.notEqual(seed.agentSeed(0n, 'fly-a'), seed.agentSeed(1n, 'fly-a'));
assert.notEqual(seed.agentSeed(0n, 'fly-a'), seed.agentSeed(0n, 'fly-b'));
assert.equal(seed.agentSeed(7n, 'fly-a'), seed.agentSeed(7n, 'fly-a'));
});
test('invalid inputs are refused rather than normalized', () => {
const file = fixtures.load('seed-vectors.json');
for (const item of fixtures.section(file, 'invalid')) {
const master = seed.masterSeed(fixtures.field(item, 'masterSeed'));
const agentId = fixtures.optionalField(item, 'agentId');
if (agentId !== undefined) {
assert.throws(() => seed.agentSeed(master, agentId), `${agentId} must be refused`);
} else {
const ids = fixtures.section(item, 'agentIds') as string[];
assert.throws(() => seed.compositionSeeds(master, ids));
}
}
});

View file

@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as fixtures from '../src/fixtures';
import {
behaviourDiff,
behaviourDigest,
behaviourEquals,
readTransitionTrace,
runsEqual,
} from '../src/trace';
test('every variant compares the way the fixture says', () => {
const file = fixtures.load('traces.json');
const baseline = readTransitionTrace(fixtures.member(file, 'baseline'));
for (const item of fixtures.section(file, 'variants')) {
const name = fixtures.field(item, 'name');
const variant = readTransitionTrace(fixtures.member(item, 'trace'));
const expected = (item as Record<string, unknown>).behaviourEquals as boolean;
const diff = behaviourDiff(baseline, variant);
assert.equal(
behaviourEquals(baseline, variant),
expected,
`${name}: behaviour equality. differences: ${JSON.stringify(diff)}`,
);
assert.equal(diff.length === 0, expected, `${name}: the diff is empty exactly when equal`);
const needle = fixtures.optionalField(item, 'diffContains');
if (needle !== undefined) {
assert.ok(
diff.some((line) => line.includes(needle)),
`${name}: the diff should name ${needle}, got ${JSON.stringify(diff)}`,
);
}
if (expected) {
assert.equal(
behaviourDigest(baseline.behaviour),
behaviourDigest(variant.behaviour),
`${name}: equal behaviour has one digest`,
);
}
}
});
test('a whole run compares transition by transition', () => {
const file = fixtures.load('traces.json');
const baseline = readTransitionTrace(fixtures.member(file, 'baseline'));
const variants = fixtures.section(file, 'variants');
const reversed = readTransitionTrace(fixtures.member(variants[0] as never, 'trace'));
const changed = readTransitionTrace(
fixtures.member(
variants.find((item) => fixtures.field(item, 'name') === 'one extra neural tick') as never,
'trace',
),
);
assert.ok(runsEqual([baseline, baseline], [reversed, baseline]));
assert.ok(!runsEqual([baseline], [changed]));
assert.ok(!runsEqual([baseline], [baseline, baseline]));
});
test('operational metadata is recorded and excluded', () => {
const file = fixtures.load('traces.json');
const baseline = readTransitionTrace(fixtures.member(file, 'baseline'));
assert.equal(baseline.operational.busCallIds.length, 3);
assert.equal(baseline.operational.prepareRequestIds.length, 2);
assert.equal(baseline.operational.deliveryIds.length, 2);
const retried = readTransitionTrace(
fixtures.member(
fixtures
.section(file, 'variants')
.find(
(item) =>
fixtures.field(item, 'name') ===
'a safe retry with fresh bus callIds, delivery ids and wall time',
) as never,
'trace',
),
);
assert.notDeepEqual(baseline.operational, retried.operational);
assert.ok(behaviourEquals(baseline, retried));
});

View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
"types": ["node"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true
},
"include": ["src", "tests"]
}

View file

@ -416,6 +416,46 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "fly-edge"
version = "0.1.1"
dependencies = [
"anyhow",
"axum",
"clap",
"flate2",
"flybus",
"flysim",
"futures-util",
"serde_json",
"tempfile",
"tokio",
"tokio-tungstenite",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "fly-session"
version = "0.1.1"
dependencies = [
"fly-session-types",
"flybus",
"serde_json",
"tempfile",
"tokio",
]
[[package]]
name = "fly-session-types"
version = "0.1.1"
dependencies = [
"flybus",
"ryu-js",
"serde_json",
"sha2",
]
[[package]] [[package]]
name = "flybrain-core" name = "flybrain-core"
version = "0.1.1" version = "0.1.1"
@ -441,6 +481,18 @@ dependencies = [
"sha2", "sha2",
] ]
[[package]]
name = "flybus"
version = "0.1.1"
dependencies = [
"libc",
"serde",
"serde_json",
"sha2",
"tempfile",
"tokio",
]
[[package]] [[package]]
name = "flysim" name = "flysim"
version = "0.1.1" version = "0.1.1"
@ -448,12 +500,15 @@ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
"clap", "clap",
"fly-session-types",
"flybrain-core", "flybrain-core",
"flybrain-gb", "flybrain-gb",
"flybus",
"futures-util", "futures-util",
"jsonschema", "jsonschema",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"tempfile", "tempfile",
"tokio", "tokio",
"tokio-tungstenite", "tokio-tungstenite",

View file

@ -1,6 +1,14 @@
[workspace] [workspace]
resolver = "3" resolver = "3"
members = ["crates/flybrain-core", "crates/flybrain-gb", "crates/flysim"] members = [
"crates/fly-edge",
"crates/fly-session",
"crates/fly-session-types",
"crates/flybrain-core",
"crates/flybrain-gb",
"crates/flybus",
"crates/flysim",
]
[workspace.package] [workspace.package]
version = "0.1.1" version = "0.1.1"

View file

@ -0,0 +1,36 @@
[package]
name = "fly-edge"
version.workspace = true
edition = "2024"
rust-version.workspace = true
license.workspace = true
publish = false
description = "The feed WebSocket (:7400) served from flysim's feed bus (FLY_FEED_VIA=bus)."
[lib]
name = "fly_edge"
path = "src/lib.rs"
[[bin]]
name = "fly-edge"
path = "src/main.rs"
[dependencies]
# The feed server and the bus encoding are flysim's own modules (`feed`, `feedbus`,
# `snapshot`), so the edge writes the WebSocket bytes with the code flysim uses in direct mode.
flysim = { path = "../flysim" }
flybus = { path = "../flybus" }
anyhow = "1.0"
axum = { version = "0.8", features = ["ws"] }
clap = { version = "4.5", features = ["derive"] }
tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "time", "signal", "macros"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[dev-dependencies]
flate2 = { workspace = true }
futures-util = "0.3"
serde_json = { workspace = true }
tempfile = "3"
tokio-tungstenite = "0.29"

View file

@ -0,0 +1,344 @@
//! `fly-edge`: the feed WebSocket, served from flysim's feed bus.
//!
//! With `FLY_FEED_VIA=bus` flysim does not bind the feed port. It publishes every snapshot on an
//! embedded flybus router (`flysim::feedbus`), and this process subscribes and serves
//! `ws://<feed.bind>/feed` to the stage, the bridge and tests. The contract is still
//! `docs/feed-protocol.md`, byte for byte: the snapshots come off the bus as the same
//! `flysim::snapshot::Snapshot` values and are written by the same `flysim::feed` server, so the
//! per-client `hello`, `wants`, drop-oldest and idle cadence are flysim's own code.
//!
//! Lifecycle (`docs/design/flybus.md`, amendment "Feed store lifecycle"):
//!
//! - the feed port is bound only once the first snapshot has arrived, so before that a client
//! is refused exactly as it would be by a flysim that has not started;
//! - when the bus goes away (flysim stopped or restarted) the edge drops every client and unbinds
//! the port, again exactly what a stopped flysim looks like to the stage, then reconnects every
//! `retry` until a router answers. It never serves a stale snapshot as if it were live.
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use anyhow::{Context, Result, anyhow};
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::get;
use flybus::{Client, ClientConfig, SubscriptionConfig};
use flysim::feed::{self, FeedState};
use flysim::feedbus;
use flysim::metrics::{Metrics, metric};
use tokio::sync::{oneshot, watch};
/// What the edge needs to know. Built from flysim's own configuration, so both processes read
/// one environment file and cannot disagree about the port, the bus directory or the cadence.
#[derive(Debug, Clone)]
pub struct EdgeConfig {
/// `feed.bus_dir`: the router's socket and store root.
pub bus_dir: PathBuf,
/// `feed.bind`, the port flysim leaves alone in bus mode.
pub feed_bind: SocketAddr,
/// `1 / loop.idle_snapshot_hz`, the protocol's header-only cadence.
pub idle_period: Duration,
/// `FLY_EDGE_METRICS_ADDR`: `/metrics` and `/healthz` for the watchdog, when set.
pub metrics_bind: Option<SocketAddr>,
/// Delay between attempts to reach the bus.
pub retry: Duration,
}
impl EdgeConfig {
pub fn from_flysim(config: &flysim::config::Config, metrics_bind: Option<SocketAddr>) -> Self {
Self {
bus_dir: config.feed.bus_dir.clone(),
feed_bind: config.feed.bind,
idle_period: config.publish_periods().1,
metrics_bind,
retry: Duration::from_millis(500),
}
}
}
/// The edge's counters. `feed` is the same `Metrics` type flysim uses, so
/// `fly_frames_sent_total` and `fly_feed_clients` mean exactly what they mean there.
#[derive(Debug, Default)]
pub struct EdgeMetrics {
pub feed: Arc<Metrics>,
/// Snapshots taken off the bus and handed to the feed server.
pub snapshots: AtomicU64,
/// 1 while subscribed and serving.
pub connected: AtomicU64,
/// Times a serving session ended because the bus went away.
pub bus_lost: AtomicU64,
/// Publications that could not be turned back into a snapshot.
pub decode_failures: AtomicU64,
/// Sessions that reached the bus but could not bind the feed port.
pub bind_failures: AtomicU64,
}
impl EdgeMetrics {
pub fn render(&self) -> String {
let mut out = String::with_capacity(1_024);
let feed = &self.feed;
metric(
&mut out,
"fly_frames_sent_total",
"counter",
"Feed snapshots written to a client socket.",
Metrics::get(&feed.frames_sent),
);
metric(
&mut out,
"fly_feed_clients",
"gauge",
"Feed clients currently subscribed.",
feed.clients(),
);
metric(
&mut out,
"fly_feed_dropped_total",
"counter",
"Snapshots superseded before a slow client could be sent them.",
Metrics::get(&feed.feed_dropped),
);
metric(
&mut out,
"fly_edge_snapshots_total",
"counter",
"Snapshots taken off the feed bus.",
self.snapshots.load(Ordering::Relaxed),
);
metric(
&mut out,
"fly_edge_bus_connected",
"gauge",
"1 while the edge is subscribed to the feed bus and serving.",
self.connected.load(Ordering::Relaxed),
);
metric(
&mut out,
"fly_edge_bus_lost_total",
"counter",
"Serving sessions ended by the feed bus going away.",
self.bus_lost.load(Ordering::Relaxed),
);
metric(
&mut out,
"fly_edge_decode_failures_total",
"counter",
"Feed bus publications that did not decode to a snapshot.",
self.decode_failures.load(Ordering::Relaxed),
);
metric(
&mut out,
"fly_edge_bind_failures_total",
"counter",
"Times the bus was reachable but the feed port could not be bound.",
self.bind_failures.load(Ordering::Relaxed),
);
out
}
}
/// Serve until the process is stopped. Only a metrics listener that cannot bind is fatal;
/// everything about the bus is retried.
pub async fn run(config: EdgeConfig, metrics: Arc<EdgeMetrics>) -> Result<()> {
if let Some(addr) = config.metrics_bind {
let listener = tokio::net::TcpListener::bind(addr)
.await
.with_context(|| format!("binding the edge metrics listener on {addr}"))?;
let app = axum::Router::new()
.route("/metrics", get(prometheus))
.route("/healthz", get(healthz))
.with_state(Arc::clone(&metrics));
tokio::spawn(async move {
if let Err(error) = axum::serve(listener, app).await {
tracing::error!(%error, "the edge metrics listener stopped");
}
});
}
// One line per outage of each kind, not one per retry.
let mut last: Option<&'static str> = None;
loop {
let end = session(&config, &metrics).await;
match &end {
SessionEnd::BusLost => {
tracing::warn!("the feed bus went away; clients dropped, reconnecting");
}
SessionEnd::Unreachable(error) if last != Some(end.kind()) => {
tracing::info!(error = format!("{error:#}"), "waiting for the feed bus");
}
SessionEnd::BindFailed(error) if last != Some(end.kind()) => {
// The bus is fine; the port is not ours. Most likely flysim is still in direct
// mode and holds it (FLY_FEED_VIA is not bus), or another process does.
tracing::warn!(
error = format!("{error:#}"),
"the bus is up but the feed port cannot be bound; retrying"
);
}
_ => {}
}
last = match end {
SessionEnd::BusLost => None,
other => Some(other.kind()),
};
tokio::time::sleep(config.retry).await;
}
}
/// Why a [`session`] ended.
enum SessionEnd {
/// No router answered, or it closed before the first snapshot. Nothing was served.
Unreachable(anyhow::Error),
/// Subscribed and holding a snapshot, but the feed port could not be bound.
BindFailed(anyhow::Error),
/// A session that served has ended because the bus went away.
BusLost,
}
impl SessionEnd {
fn kind(&self) -> &'static str {
match self {
Self::Unreachable(_) => "unreachable",
Self::BindFailed(_) => "bind",
Self::BusLost => "lost",
}
}
}
async fn prometheus(State(metrics): State<Arc<EdgeMetrics>>) -> impl IntoResponse {
(
[(
axum::http::header::CONTENT_TYPE,
"text/plain; version=0.0.4",
)],
metrics.render(),
)
}
async fn healthz(State(metrics): State<Arc<EdgeMetrics>>) -> impl IntoResponse {
if metrics.connected.load(Ordering::Relaxed) == 1 {
(StatusCode::OK, "ok")
} else {
(StatusCode::SERVICE_UNAVAILABLE, "waiting for the feed bus")
}
}
/// One subscription's lifetime.
async fn session(config: &EdgeConfig, metrics: &EdgeMetrics) -> SessionEnd {
let (subscription, client, first) = match subscribe(config, metrics).await {
Ok(subscribed) => subscribed,
Err(error) => return SessionEnd::Unreachable(error),
};
let listener = match tokio::net::TcpListener::bind(config.feed_bind).await {
Ok(listener) => listener,
Err(error) => {
metrics.bind_failures.fetch_add(1, Ordering::Relaxed);
return SessionEnd::BindFailed(
anyhow::Error::new(error)
.context(format!("binding the feed listener on {}", config.feed_bind)),
);
}
};
serve(config, metrics, client, subscription, first, listener).await;
SessionEnd::BusLost
}
/// Connect, subscribe and wait for the first snapshot that decodes.
async fn subscribe(
config: &EdgeConfig,
metrics: &EdgeMetrics,
) -> Result<(flybus::Subscription, Client, flysim::snapshot::Snapshot)> {
let client = Client::connect_unix(
feedbus::socket_path(&config.bus_dir),
ClientConfig::new(feedbus::EDGE, feedbus::store_root(&config.bus_dir)),
)
.await
.map_err(|error| anyhow!("connecting to the feed bus: {error}"))?;
// One in flight: while a snapshot is being copied out, the next one waits in the single
// latest slot and anything newer replaces it. The edge is never more than one behind.
let mut subscription = client
.subscribe(
feedbus::TOPIC,
SubscriptionConfig::latest().in_flight(1).replay(true),
)
.await
.map_err(|error| anyhow!("subscribing to {}: {error}", feedbus::TOPIC))?;
let first = loop {
let message = subscription
.next()
.await
.ok_or_else(|| anyhow!("the feed bus closed before the first snapshot"))?;
match feedbus::receive(&message).await {
Ok(snapshot) => break snapshot,
Err(error) => {
metrics.decode_failures.fetch_add(1, Ordering::Relaxed);
tracing::warn!(%error, "a feed bus publication did not decode");
}
}
};
Ok((subscription, client, first))
}
/// Serve `listener` from `subscription` until the bus goes away.
async fn serve(
config: &EdgeConfig,
metrics: &EdgeMetrics,
client: Client,
mut subscription: flybus::Subscription,
first: flysim::snapshot::Snapshot,
listener: tokio::net::TcpListener,
) {
let (snapshots, receiver) = watch::channel(Arc::new(first));
metrics.snapshots.fetch_add(1, Ordering::Relaxed);
tracing::info!(feed = %config.feed_bind, bus = %config.bus_dir.display(), "serving the feed from the bus");
metrics.connected.store(1, Ordering::Relaxed);
let state = FeedState {
snapshots: receiver,
metrics: Arc::clone(&metrics.feed),
idle_period: config.idle_period,
};
let (stop, stopped) = oneshot::channel::<()>();
let server = tokio::spawn(async move {
let result = axum::serve(listener, feed::router(state))
.with_graceful_shutdown(async move {
let _ = stopped.await;
})
.await;
if let Err(error) = result {
tracing::error!(%error, "the feed listener stopped");
}
});
while let Some(message) = subscription.next().await {
match feedbus::receive(&message).await {
Ok(snapshot) => {
drop(message);
snapshots.send_replace(Arc::new(snapshot));
metrics.snapshots.fetch_add(1, Ordering::Relaxed);
}
Err(error) => {
metrics.decode_failures.fetch_add(1, Ordering::Relaxed);
tracing::warn!(%error, "a feed bus publication did not decode");
if client.closed().is_some() {
break;
}
}
}
}
// The bus is gone. Dropping the sender ends every client's pump (a closed stream, as when
// flysim itself stops), and the graceful shutdown unbinds the port.
metrics.connected.store(0, Ordering::Relaxed);
metrics.bus_lost.fetch_add(1, Ordering::Relaxed);
drop(snapshots);
let _ = stop.send(());
if tokio::time::timeout(Duration::from_secs(5), server)
.await
.is_err()
{
tracing::warn!("the feed listener took more than 5 s to stop");
}
}

View file

@ -0,0 +1,81 @@
//! `fly-edge`: serve the feed WebSocket from flysim's feed bus.
//!
//! ```sh
//! FLY_FEED_VIA=bus flysim &
//! fly-edge
//! ```
//!
//! Configured through the same environment as flysim (`FLY_FEED_BIND`, `FLY_BUS_DIR`,
//! `FLYSIM_LOOP_IDLE_SNAPSHOT_HZ`, or `--config flysim.toml`), plus `FLY_EDGE_METRICS_ADDR` for
//! its own `/metrics` and `/healthz`. `infra/units/flyedge.service` runs it with no arguments.
use std::sync::Arc;
use anyhow::{Context, Result};
use clap::Parser;
use fly_edge::{EdgeConfig, EdgeMetrics};
#[derive(Debug, Parser)]
#[command(
name = "fly-edge",
about = "The feed WebSocket, served from flysim's feed bus.",
version
)]
struct Args {
/// Path to `flysim.toml`, read for `[feed]` and `[loop]`. Environment overrides apply as
/// they do for flysim.
#[arg(long, value_name = "PATH")]
config: Option<std::path::PathBuf>,
}
fn main() -> Result<()> {
let args = Args::parse();
init_tracing();
let config = flysim::config::Config::load(args.config.as_deref())?;
let metrics_bind = match std::env::var("FLY_EDGE_METRICS_ADDR") {
Ok(value) if !value.is_empty() => Some(value.parse().with_context(|| {
format!("FLY_EDGE_METRICS_ADDR: {value:?} is not a host:port address")
})?),
_ => None,
};
let edge = EdgeConfig::from_flysim(&config, metrics_bind);
if config.feed.via != flysim::config::FeedVia::Bus {
tracing::warn!(
"FLY_FEED_VIA is not \"bus\": flysim serves the feed itself and binds {}; \
this edge will wait for a bus that is not there",
edge.feed_bind
);
}
tracing::info!(config = ?edge, "fly-edge starting");
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.thread_name("fly-edge")
.enable_all()
.build()
.context("building the tokio runtime")?;
runtime.block_on(async move {
let metrics = Arc::new(EdgeMetrics::default());
let mut terminate =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.context("installing the SIGTERM handler")?;
tokio::select! {
result = fly_edge::run(edge, metrics) => result,
_ = tokio::signal::ctrl_c() => { tracing::info!("SIGINT: shutting down"); Ok(()) }
_ = terminate.recv() => { tracing::info!("SIGTERM: shutting down"); Ok(()) }
}
})
}
/// Logs to stderr, like flysim, under `FLY_EDGE_LOG` (or `RUST_LOG`).
fn init_tracing() {
use tracing_subscriber::EnvFilter;
let filter = EnvFilter::try_from_env("FLY_EDGE_LOG")
.or_else(|_| EnvFilter::try_from_default_env())
.unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.with_target(false)
.init();
}

View file

@ -0,0 +1,246 @@
#![allow(dead_code)]
//! Shared pieces of the edge tests: the committed `.flyfeed` fixtures as snapshots, a feed
//! client, the two serving paths side by side, and a `.flyfeed` writer.
use std::io::Read as _;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use fly_edge::{EdgeConfig, EdgeMetrics};
use flysim::feed::FeedState;
use flysim::feedbus;
use flysim::metrics::Metrics;
use flysim::snapshot::{AttachmentKind, FeedHeader, Snapshot};
use futures_util::{SinkExt as _, StreamExt as _};
use tokio::sync::watch;
use tokio_tungstenite::tungstenite::Message as WsMessage;
pub fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../../..")
.canonicalize()
.expect("the repository root is above services/flysim/crates/fly-edge")
}
/// `u32 LE headerLength | header JSON | (u32 LE length | bytes)*`, split.
pub fn split(message: &[u8]) -> (&[u8], Vec<&[u8]>) {
let read = |at: usize| u32::from_le_bytes(message[at..at + 4].try_into().unwrap()) as usize;
let header_len = read(0);
let header = &message[4..4 + header_len];
let mut at = 4 + header_len;
let mut attachments = Vec::new();
while at < message.len() {
let len = read(at);
attachments.push(&message[at + 4..at + 4 + len]);
at += 4 + len;
}
(header, attachments)
}
/// A wire message back into the snapshot that produced it, or `None` when its header predates
/// fields the Rust producer always writes (the three oldest fixtures lack `game.scene`).
pub fn snapshot_of(message: &[u8]) -> Option<Snapshot> {
let (header, attachments) = split(message);
let header: FeedHeader = serde_json::from_slice(header).ok()?;
let mut snapshot = Snapshot {
header,
frame: Arc::new(Vec::new()),
audio: Arc::new(Vec::new()),
spikes: Arc::new(Vec::new()),
};
for (kind, bytes) in snapshot
.header
.attachments
.clone()
.into_iter()
.zip(attachments)
{
let bytes = Arc::new(bytes.to_vec());
match kind {
AttachmentKind::Frame => snapshot.frame = bytes,
AttachmentKind::Audio => snapshot.audio = bytes,
AttachmentKind::Spikes => snapshot.spikes = bytes,
}
}
Some(snapshot)
}
/// Every record of `apps/stage/public/fixtures/<name>.flyfeed.gz`, as wire messages.
pub fn fixture_messages(name: &str) -> Vec<Vec<u8>> {
let path = repo_root().join(format!("apps/stage/public/fixtures/{name}.flyfeed.gz"));
let gz = std::fs::read(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display()));
let mut bytes = Vec::new();
flate2::read::GzDecoder::new(gz.as_slice())
.read_to_end(&mut bytes)
.unwrap();
assert_eq!(&bytes[..8], b"FLYFEED\0", "{name}");
let read = |at: usize| u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap()) as usize;
assert_eq!(read(8), 1, "{name}: container version");
let mut at = 16 + read(12);
let mut out = Vec::new();
while at < bytes.len() {
let len = read(at);
out.push(bytes[at + 4..at + 4 + len].to_vec());
at += 4 + len;
}
out
}
/// A `.flyfeed` file of `messages` (`packages/feed/src/fixture.ts`).
pub fn encode_flyfeed(name: &str, source: &str, messages: &[Vec<u8>]) -> Vec<u8> {
let wall = |message: &Vec<u8>| -> u64 {
let header: serde_json::Value = serde_json::from_slice(split(message).0).unwrap();
header["wallMs"].as_u64().unwrap_or(0)
};
let duration = match (messages.first(), messages.last()) {
(Some(first), Some(last)) => wall(last).saturating_sub(wall(first)),
_ => 0,
};
let manifest = serde_json::json!({
"name": name,
"protocol": 1,
"recordedAt": "1970-01-01T00:00:00.000Z",
"source": source,
"snapshotCount": messages.len(),
"durationMs": duration,
"hz": 30,
"attachmentPolicy": {
"frame": { "stride": 1 },
"audio": { "stride": 1 },
"spikes": { "stride": 1 }
},
});
let manifest = serde_json::to_vec(&manifest).unwrap();
let mut out = b"FLYFEED\0".to_vec();
out.extend_from_slice(&1u32.to_le_bytes());
out.extend_from_slice(&(manifest.len() as u32).to_le_bytes());
out.extend_from_slice(&manifest);
for message in messages {
out.extend_from_slice(&(message.len() as u32).to_le_bytes());
out.extend_from_slice(message);
}
out
}
pub fn free_port() -> SocketAddr {
std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
}
pub type Ws =
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
/// Connect and say `hello`, retrying while the port is not bound yet (the edge binds only once
/// its first snapshot has arrived).
pub async fn connect(addr: SocketAddr, wants: &[&str]) -> Ws {
let url = format!("ws://{addr}/feed");
let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
loop {
match tokio_tungstenite::connect_async(&url).await {
Ok((mut ws, _)) => {
let hello = serde_json::json!({ "protocol": 1, "client": "test", "wants": wants });
ws.send(WsMessage::Text(hello.to_string().into()))
.await
.unwrap();
return ws;
}
Err(error) => {
assert!(tokio::time::Instant::now() < deadline, "{url}: {error}");
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
}
}
/// The next binary message, within `within`.
pub async fn next_binary(ws: &mut Ws, within: Duration) -> Vec<u8> {
let deadline = tokio::time::Instant::now() + within;
loop {
let message = tokio::time::timeout_at(deadline, ws.next())
.await
.expect("a snapshot in time")
.expect("the feed stays open")
.expect("a well-formed frame");
if let WsMessage::Binary(bytes) = message {
return bytes.to_vec();
}
}
}
pub fn seq_of(message: &[u8]) -> u64 {
let header: serde_json::Value = serde_json::from_slice(split(message).0).unwrap();
header["seq"].as_u64().unwrap()
}
/// The same watch slot served both ways at once: flysim's direct server on `direct`, and the
/// bus (router, publisher, edge) on `edge`. Owns its runtime-side tasks through the handles.
pub struct Paths {
pub snapshots: watch::Sender<Arc<Snapshot>>,
pub direct: SocketAddr,
pub edge: SocketAddr,
pub publisher_metrics: Arc<Metrics>,
pub edge_metrics: Arc<EdgeMetrics>,
pub bus_dir: tempfile::TempDir,
pub bus: feedbus::BusFeed,
}
/// Idle cadence long enough that no test sees a header repeated for idleness.
pub const NO_IDLE: Duration = Duration::from_secs(3_600);
/// Start both paths over `first`. `edge` false leaves the edge out (a test then plays its part).
pub async fn start(first: Snapshot, with_edge: bool) -> Paths {
let bus_dir = tempfile::tempdir().unwrap();
let (snapshots, receiver) = watch::channel(Arc::new(first));
let bus = feedbus::start_router(bus_dir.path()).await.unwrap();
let publisher_metrics = Arc::new(Metrics::default());
tokio::spawn(feedbus::run_publisher(
bus.router.clone(),
receiver.clone(),
Arc::clone(&publisher_metrics),
));
let direct = free_port();
let listener = tokio::net::TcpListener::bind(direct).await.unwrap();
let state = FeedState {
snapshots: receiver,
metrics: Arc::new(Metrics::default()),
idle_period: NO_IDLE,
};
tokio::spawn(async move { axum::serve(listener, flysim::feed::router(state)).await });
let edge = free_port();
let edge_metrics = Arc::new(EdgeMetrics::default());
if with_edge {
let config = EdgeConfig {
bus_dir: bus_dir.path().to_path_buf(),
feed_bind: edge,
idle_period: NO_IDLE,
metrics_bind: None,
retry: Duration::from_millis(50),
};
tokio::spawn(fly_edge::run(config, Arc::clone(&edge_metrics)));
}
Paths {
snapshots,
direct,
edge,
publisher_metrics,
edge_metrics,
bus_dir,
bus,
}
}
pub fn out_dir() -> Option<PathBuf> {
std::env::var_os("FLY_EDGE_PARITY_OUT").map(PathBuf::from)
}
pub fn write(path: &Path, bytes: &[u8]) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
}

View file

@ -0,0 +1,301 @@
//! Parity: a feed served through the bus and `fly-edge` is the feed flysim serves directly.
//!
//! The committed stage fixtures (`apps/stage/public/fixtures/*.flyfeed.gz`, the recordings the
//! stage's e2e suite plays) are fed snapshot by snapshot into one watch slot, served both ways at
//! once, and recorded by one client per path and per `wants` flavour: the stage's (everything),
//! the bridge's (nothing) and a frame-only one. The two recordings must match: headers equal
//! apart from wall-time fields and attachments byte-equal -- and in fact the whole messages are
//! byte-equal, because both are written by `Snapshot::encode` from equal snapshots. The edge's
//! attachments must also equal the fixture's own.
//!
//! `FLY_EDGE_PARITY_OUT=<dir>` also writes each pair of recordings as `.flyfeed` files, which
//! `packages/feed`'s `decodeFlyfeed` reads. `FLY_EDGE_PARITY_ALL=1` replays whole fixtures
//! instead of their first 400 snapshots.
mod common;
use std::time::Duration;
use common::*;
use serde_json::Value;
/// The fixtures whose headers carry every field the Rust producer writes. The three older ones
/// (`cold-open`, `steady`, `big-moment`) predate `game.scene` and cannot be a Rust `Snapshot`.
const FIXTURES: [&str; 4] = ["macros", "shop", "center", "bigpad"];
const WANTS: [(&str, &[&str]); 3] = [
("all", &["frame", "audio", "spikes"]),
("none", &[]),
("frame", &["frame"]),
];
/// The header with every `wallMs` removed, at any depth.
fn without_wall_time(header: &[u8]) -> Value {
fn strip(value: &mut Value) {
match value {
Value::Object(map) => {
map.remove("wallMs");
map.values_mut().for_each(strip);
}
Value::Array(items) => items.iter_mut().for_each(strip),
_ => {}
}
}
let mut value: Value = serde_json::from_slice(header).unwrap();
strip(&mut value);
value
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_edge_writes_the_bytes_the_direct_feed_writes_for_every_committed_fixture() {
let limit = if std::env::var_os("FLY_EDGE_PARITY_ALL").is_some() {
usize::MAX
} else {
400
};
for name in FIXTURES {
let snapshots: Vec<_> = fixture_messages(name)
.iter()
.take(limit)
.map(|message| {
(
message.clone(),
snapshot_of(message).unwrap_or_else(|| panic!("{name}")),
)
})
.collect();
assert!(
snapshots.len() >= 100,
"{name}: {} snapshots",
snapshots.len()
);
let paths = start(snapshots[0].1.clone(), true).await;
let mut clients = Vec::new();
for (flavour, wants) in WANTS {
let direct = connect(paths.direct, wants).await;
let edge = connect(paths.edge, wants).await;
clients.push((flavour, direct, edge, Vec::new(), Vec::new()));
}
// Lockstep: publish one snapshot, wait until every client has it. Nothing is superseded,
// so both recordings are complete and comparable message by message.
for (index, (_, snapshot)) in snapshots.iter().enumerate() {
if index > 0 {
paths
.snapshots
.send_replace(std::sync::Arc::new(snapshot.clone()));
}
for (_, direct, edge, direct_log, edge_log) in &mut clients {
for (ws, log) in [
(&mut *direct, &mut *direct_log),
(&mut *edge, &mut *edge_log),
] {
let message = next_binary(ws, Duration::from_secs(20)).await;
assert_eq!(seq_of(&message), snapshot.header.seq, "{name} #{index}");
log.push(message);
}
}
}
for (flavour, _, _, direct_log, edge_log) in &clients {
assert_eq!(direct_log.len(), snapshots.len());
assert_eq!(edge_log.len(), direct_log.len());
for (index, (direct, edge)) in direct_log.iter().zip(edge_log).enumerate() {
let (direct_header, direct_attachments) = split(direct);
let (edge_header, edge_attachments) = split(edge);
assert_eq!(
without_wall_time(direct_header),
without_wall_time(edge_header),
"{name}/{flavour} #{index}: headers"
);
assert_eq!(
direct_attachments, edge_attachments,
"{name}/{flavour} #{index}: attachments"
);
// The stronger fact: the whole message, wall times included, is the same bytes.
assert!(direct == edge, "{name}/{flavour} #{index}: messages differ");
if *flavour == "all" {
let (_, fixture_attachments) = split(&snapshots[index].0);
assert_eq!(
edge_attachments, fixture_attachments,
"{name} #{index}: vs the fixture"
);
}
}
if let Some(dir) = out_dir() {
write(
&dir.join(format!("{name}-{flavour}-direct.flyfeed")),
&encode_flyfeed(name, "flysim direct", direct_log),
);
write(
&dir.join(format!("{name}-{flavour}-edge.flyfeed")),
&encode_flyfeed(name, "flysim bus + fly-edge", edge_log),
);
}
}
let published = flysim::metrics::Metrics::get(&paths.publisher_metrics.bus_published);
assert!(
published >= snapshots.len() as u64,
"{name}: {published} published"
);
assert_eq!(
flysim::metrics::Metrics::get(&paths.publisher_metrics.bus_publish_failures),
0
);
eprintln!(
"{name}: {} snapshots x {} flavours identical on both paths",
snapshots.len(),
WANTS.len()
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_header_too_large_for_an_envelope_travels_as_an_artifact_and_arrives_intact() {
let mut snapshot = snapshot_of(&fixture_messages("macros")[10]).unwrap();
// Far past flybus's 65,536-byte envelope: 400 events of 200 characters.
for id in 0..400u64 {
snapshot.header.events.push(flysim::snapshot::FeedEvent {
id: 10_000 + id,
wall_ms: 1_757_000_000_000 + id,
brain_ms: 5.0,
kind: flysim::snapshot::FeedEventKind::System,
label: "x".repeat(200),
value: None,
reward_kind: None,
by: None,
});
}
assert!(serde_json::to_vec(&snapshot.header).unwrap().len() > 65_536);
let paths = start(snapshot.clone(), true).await;
let mut direct = connect(paths.direct, &["frame", "audio", "spikes"]).await;
let mut edge = connect(paths.edge, &["frame", "audio", "spikes"]).await;
let direct = next_binary(&mut direct, Duration::from_secs(20)).await;
let edge = next_binary(&mut edge, Duration::from_secs(20)).await;
assert!(direct == edge);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_edge_drops_its_clients_and_unbinds_when_the_bus_goes_away_then_comes_back() {
let snapshot = snapshot_of(&fixture_messages("shop")[5]).unwrap();
let paths = start(snapshot.clone(), true).await;
let mut edge = connect(paths.edge, &[]).await;
next_binary(&mut edge, Duration::from_secs(20)).await;
// flysim stopping is its router stopping.
let Paths {
snapshots,
edge: edge_addr,
edge_metrics,
bus_dir,
bus,
..
} = paths;
bus.router.shutdown();
drop(bus);
drop(snapshots);
let closed = tokio::time::timeout(Duration::from_secs(10), async {
use futures_util::StreamExt as _;
loop {
match edge.next().await {
None | Some(Err(_)) => break,
Some(Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => break,
Some(Ok(_)) => continue,
}
}
})
.await;
assert!(closed.is_ok(), "the client was not dropped");
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while tokio::net::TcpStream::connect(edge_addr).await.is_ok() {
assert!(
std::time::Instant::now() < deadline,
"the feed port stayed bound"
);
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert_eq!(
edge_metrics
.connected
.load(std::sync::atomic::Ordering::Relaxed),
0
);
// A new flysim on the same directory: the edge finds it and serves again.
let (snapshots, receiver) = tokio::sync::watch::channel(std::sync::Arc::new(snapshot.clone()));
let bus = flysim::feedbus::start_router(bus_dir.path()).await.unwrap();
tokio::spawn(flysim::feedbus::run_publisher(
bus.router.clone(),
receiver,
std::sync::Arc::new(flysim::metrics::Metrics::default()),
));
let mut edge = connect(edge_addr, &[]).await;
let message = next_binary(&mut edge, Duration::from_secs(20)).await;
assert_eq!(seq_of(&message), snapshot.header.seq);
assert_eq!(
edge_metrics
.bus_lost
.load(std::sync::atomic::Ordering::Relaxed),
1
);
drop(snapshots);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn an_edge_whose_port_is_taken_keeps_retrying_and_serves_once_it_is_free() {
let snapshot = snapshot_of(&fixture_messages("center")[7]).unwrap();
// Someone else (flysim still in direct mode, say) holds the feed port before the edge starts.
let squatter = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = squatter.local_addr().unwrap();
let bus_dir = tempfile::tempdir().unwrap();
let (snapshots, receiver) = tokio::sync::watch::channel(std::sync::Arc::new(snapshot.clone()));
let bus = flysim::feedbus::start_router(bus_dir.path()).await.unwrap();
tokio::spawn(flysim::feedbus::run_publisher(
bus.router.clone(),
receiver,
std::sync::Arc::new(flysim::metrics::Metrics::default()),
));
let metrics = std::sync::Arc::new(fly_edge::EdgeMetrics::default());
tokio::spawn(fly_edge::run(
fly_edge::EdgeConfig {
bus_dir: bus_dir.path().to_path_buf(),
feed_bind: port,
idle_period: NO_IDLE,
metrics_bind: None,
retry: Duration::from_millis(50),
},
std::sync::Arc::clone(&metrics),
));
// It reaches the bus, fails to bind, and says so rather than claiming to wait for the bus.
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while metrics
.bind_failures
.load(std::sync::atomic::Ordering::Relaxed)
< 3
{
assert!(
std::time::Instant::now() < deadline,
"the edge never reached the bus"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert_eq!(
metrics.connected.load(std::sync::atomic::Ordering::Relaxed),
0
);
assert_eq!(
metrics.bus_lost.load(std::sync::atomic::Ordering::Relaxed),
0
);
drop(squatter);
let mut edge = connect(port, &[]).await;
let message = next_binary(&mut edge, Duration::from_secs(20)).await;
assert_eq!(seq_of(&message), snapshot.header.seq);
assert_eq!(
metrics.connected.load(std::sync::atomic::Ordering::Relaxed),
1
);
drop(snapshots);
drop(bus);
}

View file

@ -0,0 +1,354 @@
//! A slow or absent edge never slows the loop.
//!
//! A thread stands in for the sim loop: flysim's own `Pacer` at realtime speed and 60 Hz Game
//! Boy frames, publishing full-size snapshots (a real 92,160-byte frame, a 17,407-byte spike
//! bitset for 139,255 neurons, 12,800 bytes of audio) into the watch slot every second frame,
//! with `watch::Sender::send`, exactly as `Sim::publish` does. Around it, three kinds of bad
//! consumer:
//!
//! - the edge is up but three of its WebSocket clients never read, so their sockets fill;
//! - the edge's place on the bus is held by a client that opens every subscription it may
//! (4) and never releases a delivery on any of them (the "slow edge", at its worst);
//! - nobody is subscribed at all (the "absent edge").
//!
//! The gated tests assert the claim, and only the claim: the pacer reports no lag, no watch send
//! waits on a consumer, no publication is refused, the store stays bounded, and a healthy client
//! still reaches the newest snapshot. Those hold on a box at any load, because none of them is a
//! rate.
//!
//! How fast the loop's sleeps come back and how many snapshots a debug-build publisher gets
//! through measure the OS scheduler and the CPU left over, not the bus: a starved publisher
//! coalesces by design. Those bounds are in `the_three_scenarios_keep_their_rates`, which is
//! `#[ignore]`d; run it on a quiet box with
//! `cargo test --release -p fly-edge --test stall -- --ignored --nocapture`.
mod common;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use common::*;
use flybus::{Client, ClientConfig, SubscriptionConfig};
use flysim::feedbus;
use flysim::metrics::Metrics;
use flysim::pacing::Pacer;
use flysim::snapshot::{AttachmentKind, FeedStatus, Snapshot};
/// A full-size running snapshot from a real fixture frame.
fn full_snapshot() -> Snapshot {
let mut snapshot = snapshot_of(&fixture_messages("macros")[3]).unwrap();
assert_eq!(snapshot.frame.len(), flysim::snapshot::FRAME_BYTES);
snapshot.header.status = FeedStatus::Running;
snapshot.header.attachments = AttachmentKind::ALL.to_vec();
snapshot.spikes = Arc::new(vec![0b1010_0101; 139_255usize.div_ceil(8)]);
snapshot.audio = Arc::new(vec![7; 12_800]);
snapshot
}
struct LoopReport {
frames: u64,
published: u64,
lag_seconds: f64,
worst_send: Duration,
worst_shortfall: f64,
p99_shortfall: f64,
}
/// Run the stand-in loop for `seconds` on its own thread.
fn run_loop(
snapshots: tokio::sync::watch::Sender<Arc<Snapshot>>,
template: Snapshot,
seconds: f64,
) -> LoopReport {
std::thread::spawn(move || {
let frame_ms = flysim::config::GAMEBOY_MS_PER_FRAME;
let mut pacer = Pacer::new(frame_ms, 1.0, Instant::now());
let frames = (seconds * 1000.0 / frame_ms) as u64;
let mut worst_send = Duration::ZERO;
let mut shortfalls = Vec::with_capacity(frames as usize);
let mut seq = template.header.seq;
let mut published = 0;
for frame in 0..frames {
if frame % 2 == 0 {
let mut snapshot = template.clone();
seq += 1;
snapshot.header.seq = seq;
snapshot.header.frame = frame;
let started = Instant::now();
let _ = snapshots.send(Arc::new(snapshot));
worst_send = worst_send.max(started.elapsed());
published += 1;
}
let now = Instant::now();
shortfalls.push(pacer.shortfall_seconds(now));
let sleep = pacer.next_sleep(now);
if !sleep.is_zero() {
std::thread::sleep(sleep);
}
}
shortfalls.sort_by(f64::total_cmp);
LoopReport {
frames,
published,
lag_seconds: pacer.lag_seconds(),
worst_send,
worst_shortfall: *shortfalls.last().unwrap(),
p99_shortfall: shortfalls[shortfalls.len() * 99 / 100],
}
})
.join()
.unwrap()
}
fn print_report(report: &LoopReport, publisher: &Metrics, what: &str) {
eprintln!(
"{what}: {} frames, {} snapshots, lag {:.3} s, worst send {:?}, shortfall p99 {:.2} ms worst {:.2} ms, bus published {} failed {}",
report.frames,
report.published,
report.lag_seconds,
report.worst_send,
report.p99_shortfall * 1e3,
report.worst_shortfall * 1e3,
Metrics::get(&publisher.bus_published),
Metrics::get(&publisher.bus_publish_failures),
);
}
/// The claim: the loop is never held by the bus, whatever the load.
fn assert_unharmed(report: &LoopReport, publisher: &Metrics, what: &str) {
assert_eq!(report.lag_seconds, 0.0, "{what}: the pacer fell behind");
// A watch send is a lock and a swap. A send that waited on a consumer would be a whole
// stall, seconds; 50 ms leaves room for a preempted thread on a loaded box.
assert!(
report.worst_send < Duration::from_millis(50),
"{what}: a send took {:?}",
report.worst_send
);
// A slow or absent consumer is never a reason to refuse a latest publication.
assert_eq!(
Metrics::get(&publisher.bus_publish_failures),
0,
"{what}: a publication was refused"
);
// Coalescing is allowed, stopping is not.
assert!(
Metrics::get(&publisher.bus_published) >= 1,
"{what}: nothing reached the bus"
);
}
/// Rates: meaningful only on a quiet box (see the module comment).
fn assert_rates(report: &LoopReport, publisher: &Metrics, what: &str) {
// Sleep overshoot is absorbed by the next frame; under one frame at p99 means the loop kept
// its absolute deadlines.
assert!(
report.p99_shortfall < 0.016,
"{what}: p99 shortfall {:.2} ms",
report.p99_shortfall * 1e3
);
let published = Metrics::get(&publisher.bus_published);
assert!(
published * 2 >= report.published,
"{what}: only {published} of {} reached the bus",
report.published
);
}
const SECONDS: f64 = 6.0;
/// What a scenario leaves for the rate checks.
struct Outcome {
report: LoopReport,
publisher: Arc<Metrics>,
/// Snapshots the healthy client received, where there is one.
healthy_received: Option<u64>,
}
async fn stalled_clients() -> Outcome {
let template = full_snapshot();
let paths = start(template.clone(), true).await;
// Three stages that said hello and then stopped reading: their sockets fill and stay full.
let mut stalled = Vec::new();
for _ in 0..3 {
stalled.push(connect(paths.edge, &["frame", "audio", "spikes"]).await);
}
// One healthy stage, read continuously.
let mut healthy = connect(paths.edge, &["frame", "audio", "spikes"]).await;
let newest = Arc::new(AtomicU64::new(0));
let received = Arc::new(AtomicU64::new(0));
let reader = {
let (newest, received) = (Arc::clone(&newest), Arc::clone(&received));
tokio::spawn(async move {
loop {
let message = next_binary(&mut healthy, Duration::from_secs(120)).await;
newest.store(seq_of(&message), Ordering::Relaxed);
received.fetch_add(1, Ordering::Relaxed);
}
})
};
let snapshots = paths.snapshots.clone();
let report = tokio::task::spawn_blocking(move || run_loop(snapshots, template, SECONDS))
.await
.unwrap();
print_report(&report, &paths.publisher_metrics, "stalled clients");
assert_unharmed(&report, &paths.publisher_metrics, "stalled clients");
// The healthy client reaches the last snapshot published: the newest one always gets
// through, however many in between were coalesced.
let last = paths.snapshots.borrow().header.seq;
let deadline = Instant::now() + Duration::from_secs(60);
while newest.load(Ordering::Relaxed) < last {
assert!(
Instant::now() < deadline,
"healthy client stuck at {} of {last}",
newest.load(Ordering::Relaxed)
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
// The stalled ones are still connected, not dropped for being slow.
assert_eq!(paths.edge_metrics.feed.clients(), 4);
reader.abort();
drop(stalled);
Outcome {
report,
publisher: Arc::clone(&paths.publisher_metrics),
healthy_received: Some(received.load(Ordering::Relaxed)),
}
}
async fn hoarding_subscriber() -> Outcome {
let template = full_snapshot();
let paths = start(template.clone(), false).await;
// The edge's seat, taken by a subscriber that keeps every delivery it gets.
let client = Client::connect_unix(
feedbus::socket_path(paths.bus_dir.path()),
ClientConfig::new(feedbus::EDGE, feedbus::store_root(paths.bus_dir.path())),
)
.await
.unwrap();
// Every subscription the seat may open, each keeping every delivery at the in-flight cap:
// the worst case the store has to hold (`feedbus::limits`, flybus.md "Feed sizing").
let seats = feedbus::limits().max_subscriptions_per_client;
let mut hoards = Vec::new();
for _ in 0..seats {
let mut subscription = client
.subscribe(
feedbus::TOPIC,
SubscriptionConfig::latest().in_flight(2).replay(true),
)
.await
.unwrap();
hoards.push(tokio::spawn(async move {
let mut kept = Vec::new();
while let Some(message) = subscription.next().await {
kept.push(message);
}
kept.len()
}));
}
assert!(
client
.subscribe(feedbus::TOPIC, SubscriptionConfig::latest())
.await
.is_err(),
"a subscription past max_subscriptions_per_client was admitted"
);
// And the seat is the only one: a second connection as the edge is refused.
assert!(
Client::connect_unix(
feedbus::socket_path(paths.bus_dir.path()),
ClientConfig::new(feedbus::EDGE, feedbus::store_root(paths.bus_dir.path())),
)
.await
.is_err(),
"a second client was admitted on edge.sock"
);
let snapshots = paths.snapshots.clone();
let report = tokio::task::spawn_blocking(move || run_loop(snapshots, template, SECONDS))
.await
.unwrap();
print_report(&report, &paths.publisher_metrics, "hoarding subscriber");
assert_unharmed(&report, &paths.publisher_metrics, "hoarding subscriber");
let stats = paths.bus.router.stats();
eprintln!(
"hoarding subscriber: store {} bytes, retained {} bytes",
stats.store_bytes, stats.retained_bytes
);
// Held: per subscription two in flight and one queued, plus one retained and whatever is
// mid-seal: 4 * 3 + 1 + 2 = 15 snapshots at most. Bounded, not growing with the number
// published.
assert!(
stats.store_bytes <= 15 * 122_367,
"store holds {} bytes",
stats.store_bytes
);
for hoard in hoards {
hoard.abort();
}
Outcome {
report,
publisher: Arc::clone(&paths.publisher_metrics),
healthy_received: None,
}
}
async fn absent_edge() -> Outcome {
let template = full_snapshot();
let paths = start(template.clone(), false).await;
let snapshots = paths.snapshots.clone();
let report = tokio::task::spawn_blocking(move || run_loop(snapshots, template, SECONDS))
.await
.unwrap();
print_report(&report, &paths.publisher_metrics, "absent edge");
assert_unharmed(&report, &paths.publisher_metrics, "absent edge");
let stats = paths.bus.router.stats();
assert!(
stats.store_bytes <= 3 * 122_367,
"store holds {} bytes",
stats.store_bytes
);
Outcome {
report,
publisher: Arc::clone(&paths.publisher_metrics),
healthy_received: None,
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn clients_of_the_edge_that_never_read_do_not_lag_the_loop_or_the_healthy_client() {
stalled_clients().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_bus_subscriber_that_never_releases_does_not_lag_the_loop_or_fill_the_store() {
hoarding_subscriber().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn an_absent_edge_costs_the_loop_nothing() {
absent_edge().await;
}
/// The same three scenarios, plus the rates. A measurement of the box as much as of the bus,
/// so not part of the workspace gate.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "timing: needs a quiet box; run with --release -- --ignored"]
async fn the_three_scenarios_keep_their_rates() {
for (what, outcome) in [
("stalled clients", stalled_clients().await),
("hoarding subscriber", hoarding_subscriber().await),
("absent edge", absent_edge().await),
] {
assert_rates(&outcome.report, &outcome.publisher, what);
if let Some(got) = outcome.healthy_received {
assert!(
got * 2 >= outcome.report.published,
"{what}: the healthy client got only {got} of {}",
outcome.report.published
);
}
}
}

View file

@ -0,0 +1,16 @@
[package]
name = "fly-session-types"
version.workspace = true
edition = "2024"
rust-version.workspace = true
license.workspace = true
publish = false
description = "Session domain scalars, closed enums, method payloads, canonical JSON digests and the step trace format (session-framework CONTRACT-01)."
[dependencies]
# The bus owns the Id/U64/Digest encodings, strict JSON and ArtifactRef; this crate reuses
# them rather than forking their semantics.
flybus = { path = "../flybus" }
ryu-js.workspace = true
serde_json = { workspace = true }
sha2 = { workspace = true }

View file

@ -0,0 +1,107 @@
# fly-session-types
The executable schemas of the session framework: domain scalars, closed enums, method
payloads, canonical JSON, canonical digests and the step trace format.
This crate is CONTRACT-01 of
[`docs/design/session-framework/implementation.md`](../../../../docs/design/session-framework/implementation.md).
It holds no transport, no worker, no coordinator and no store; it never opens a socket or a
file other than its own fixtures. The bus owns the wire
([`flybus`](../flybus)), and this crate owns what the messages mean.
## Layout
| Module | Contents |
| --- | --- |
| `scalar` | `Scope`, `RationalNs`, `SchemaRef`, `TypedValue`, the `DomainType` trait, and `BusCallId` / `DomainRequestId` / `ArtifactIdentity` / `OwnerToken` |
| `canonical` | RFC 8785 canonical JSON, SHA-256 digests, `OperationKey`, canonical bodies, the 64-KiB envelope check |
| `rpc` | `SessionRpcRequest`, `SessionRpcSuccess`, `SessionRpcFailure`, `ErrorCode`, `MutationCertainty` |
| `workers` | The closed enums and every Agent/Environment/Worker method payload of workers-v1 |
| `media` | `ViewDescriptor`, `ViewRef`, `AudioDescriptor`, `AudioRef` and the `State.*` payloads |
| `publishing` | `SessionDescriptor` and `CommittedSnapshot` |
| `trace` | `TraceBehaviour`, `TraceOperational`, `TransitionTrace` and the behaviour comparator |
| `schema` | The canonical schema set and `contract_digest()` |
| `seed` | `seed-derivation-v1` |
| `checkpoint` | The `FLYSESS1` envelope layout |
| `extensions` | The 2026-09-23 extension methods: `Environment.SaveSlot`/`RestoreSlot` (`gameboy-slots-v1`) and `Agent.Rollback` (`legacy-ratchet-rollback-v1`) |
| `gameboy` | The legacy Game Boy composition ([`legacy-gameboy-v1`](../../../../docs/design/session-framework/legacy-gameboy-v1.md)): registered payload schemas, the legacy profile, the composition declaration. Not part of `contractDigest` |
| `fixtures` | Loading `fixtures/`, shared with `packages/session-types` |
`Id`, `U64` and `Digest` are the bus encodings: `scalar` calls into `flybus::wire` instead of
restating them, and `tests/encodings.rs` pins that the two agree for every edge case.
## Reading and validating
Every type implements `DomainType`:
```rust
use fly_session_types::scalar::{DomainType, Scope};
let scope = Scope::from_json(&value)?; // reads, refusing unknown fields, then validates
scope.validate()?; // the cross-field rules, re-runnable
let json = scope.to_json(); // the canonical shape
```
Rules that need another value in hand are separate, because a payload cannot check them alone:
```rust
control.validate_against(&port.controls)?; // complete batch, descriptor order, ranges
input.validate_against(&descriptor.views)?; // max(0, boundary - observationDelaySteps)
result.validate_against(&descriptor, &previous)?; // exactly one stepDuration of world time
snapshot.validate_against(&session_descriptor)?; // revision, agent set, assigned ports
telemetry.validate_against_roles(&profile_roles)?; // rates in profile-defined order
```
## Digests
- `contract_digest()` is the SHA-256 of the canonical schema set (`schema::schema_set()`),
which is a declaration: type names, JSON field names, kinds, bounds and closed enums.
Reformatting this crate cannot change it; changing a field or a bound does.
- `canonical::body_digest(method, scope, params)` is the comparison ipc-v1 section 5 uses to
tell a safe replay from a `CONFLICT`. It refuses a body that carries a bus identity.
- `OperationKey` is `(sessionId, epoch, step, method, workerId)`, and deliberately not the
request id: a changed id for an existing key is the conflict to detect.
## Fixtures
`fixtures/` is loaded by these tests and by `packages/session-types`, so a case is written
once and holds both languages to it.
| File | Contents |
| --- | --- |
| `valid.json` | Payloads every implementation accepts, with their canonical JSON and digest |
| `invalid.json` | Payloads every implementation refuses, each with the rule it breaks |
| `raw.json` | Byte sequences refused before validation: duplicate keys, invalid UTF-8, `NaN`, trailing data |
| `generated.json` | Recipes for payloads too large to store: the 32-KiB and 64-KiB boundaries, 512-code-point messages |
| `boundaries.json` | The `U64` decimal-string and double boundaries |
| `rational.json` | Checked rational arithmetic and the 16, 17, 17 tick accumulator |
| `identities.json` | Which of the four identity types accepts which spelling |
| `descriptor-checks.json` | Rules that need a descriptor: batches, delays, byte shapes, descriptor agreement |
| `operations.json` | Operation keys, canonical bodies and the pairs that are or are not the same operation |
| `traces.json` | A baseline transition and the variants that must or must not compare equal |
| `schema-set.json`, `contract-digest.json` | The canonical schema set and its digest |
| `seed-vectors.json` | `seed-derivation-v1` test vectors |
| `checkpoint-envelope.json` | One `FLYSESS1` envelope, its layout and the corruptions a reader refuses |
| `gameboy-decoder-config.json` | The `decoderConfigDigest` vectors; written and checked by `flysim`'s `legacy_profile_identity` test (`FLY_UPDATE_FIXTURES=1` rewrites), reproduced by `@flybrain/session-types` from the oracle preset |
| `gameboy-legacy.json` | The legacy Game Boy extension set and digest, every registered `SchemaRef`, the legacy profile and its `AssetRef`, the frame clock, an example composition and its digest |
The derived files (`schema-set.json`, `contract-digest.json`, the `canonical`/`digest` fields
of `valid.json`, the digests in `operations.json`, `seed-vectors.json`,
`checkpoint-envelope.json` and `gameboy-legacy.json`) come from
`cargo run -p fly-session-types --example update_fixtures`;
`tests/schema_set.rs` fails if the checked-in files are stale.
## Tests
```sh
cargo test -p fly-session-types
cargo clippy -p fly-session-types --all-targets
```
## Bounds this crate chose
Every bound in the schema set names its source. Seven are marked `crate` because no document
states them: `maxAudioStreams` (8), `maxCapabilities` (32), `maxSupportedMajors` (8),
`maxSupportedStimuli` (64), `maxAssets` (64), `maxSnapshotEvents` (64) and `maxSlots` (4). They exist so an
unbounded array cannot fill an envelope, and they are in the digest, so widening one is a
contract change rather than a quiet edit.

View file

@ -0,0 +1,402 @@
//! Regenerates the derived fixture files.
//!
//! `cargo run -p fly-session-types --example update_fixtures`. `tests/fixtures_current.rs`
//! fails if the checked-in files differ from what this writes, so the digests in the
//! fixtures can never drift from the code that produced them.
use std::collections::BTreeMap;
use fly_session_types::gameboy::{self, LegacyComposition};
use fly_session_types::scalar::{DomainType, RationalNs, Scope};
use fly_session_types::workers::AssetRef;
use fly_session_types::{canonical, checkpoint, fixtures, schema, seed};
use serde_json::{Map, Value, json};
fn main() {
let dir = fixtures::dir();
for (name, contents) in derived() {
let path = dir.join(&name);
std::fs::write(&path, contents).expect("write fixture");
println!("wrote {}", path.display());
}
}
/// Every derived fixture, as `(file name, exact bytes)`.
pub fn derived() -> Vec<(String, String)> {
vec![
("schema-set.json".to_owned(), schema_set()),
("contract-digest.json".to_owned(), contract_digest()),
("valid.json".to_owned(), valid()),
("operations.json".to_owned(), operations()),
("seed-vectors.json".to_owned(), seed_vectors()),
("checkpoint-envelope.json".to_owned(), checkpoint_envelope()),
("gameboy-legacy.json".to_owned(), gameboy_legacy()),
]
}
/// An example legacy composition. The ROM digest is a placeholder -- the real one is computed
/// by the composition that runs, and no ROM identity belongs in a fixture. The macro channels and
/// the decoder digest are the real macros-mode vector of `gameboy-decoder-config.json`, which
/// `flysim`'s `legacy_profile_identity` test computes from `gameboy_decoder_config_with_macros`
/// and the TypeScript test from the oracle preset. The compatibility string is today's, byte for
/// byte, because its segments must agree with the declaration.
pub fn example_composition() -> LegacyComposition {
let pokered = "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b";
LegacyComposition {
composition_id: "pokered-live".to_owned(),
profile: gameboy::profile_asset_ref(),
executor: gameboy::ExecutorDeclaration {
rom: AssetRef {
id: "pokered-rom".to_owned(),
digest: canonical::sha256_hex(b"placeholder: the cartridge digest is the operator's"),
byte_length: 1_048_576,
format: "gb-rom".to_owned(),
},
adapter: "pokered-unique8-v7".to_owned(),
symbol_provenance: pokered.to_owned(),
mode: "macros".to_owned(),
macro_channels: decoder_vector("macros")["macroChannels"]
.as_array()
.expect("macroChannels")
.iter()
.map(|c| c.as_str().expect("channel").to_owned())
.collect(),
},
decoder_config_digest: decoder_vector("macros")["digest"]
.as_str()
.expect("digest")
.to_owned(),
environment: gameboy::EnvironmentDeclaration {
slots: vec!["best".to_owned()],
audio_sample_rate: 48_000,
},
flysim_compatibility: format!(
"{}/pokered-unique8-v7/{}/{}/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:{pokered}/statefmt:199616-x86_64-unknown-linux-gnu",
gameboy::KERNEL_VERSION,
gameboy::FAFB_V783_FINGERPRINT,
gameboy::PLASTICITY_VERSION,
),
}
}
/// One case of the (flysim-written) decoder-config vectors.
fn decoder_vector(name: &str) -> Value {
let file = fixtures::load("gameboy-decoder-config.json").expect("gameboy-decoder-config.json");
fixtures::cases(&file)
.expect("cases")
.iter()
.find(|c| c["name"] == Value::String(name.to_owned()))
.unwrap_or_else(|| panic!("decoder vector {name}"))
.clone()
}
/// The legacy Game Boy extension set, the profile document and its AssetRef, the clock
/// vector and an example composition with its digest.
fn gameboy_legacy() -> String {
let profile = gameboy::legacy_profile().to_json();
let schema_refs: Map<String, Value> = gameboy::PAYLOAD_SCHEMAS
.iter()
.map(|p| (p.id.to_owned(), p.schema_ref().to_json()))
.collect();
// The first frames from a zero remainder: the rational accumulator of step-v1 section 5,
// which the legacy f64 accumulator equals exactly (legacy-gameboy-v1 section 3).
let step = gameboy::step_duration();
let tick = gameboy::tick_duration();
let mut accumulator = RationalNs::ZERO;
let mut frames = Vec::new();
for _ in 0..12 {
accumulator = accumulator.checked_add(&step).expect("no overflow");
let (ticks, remainder) = accumulator.divide_floor(&tick).expect("positive tick");
accumulator = remainder;
frames.push(json!({"ticks": ticks.to_string(), "remainder": remainder.to_json()}));
}
let composition = example_composition();
write(&json!({
"description": "The legacy Game Boy composition (legacy-gameboy-v1): registered payload schemas with their SchemaRef digests, the one legacy profile document and its AssetRef, the frame clock, and an example composition declaration with its digest.",
"extensionSetDigest": gameboy::extension_set_digest(),
"extensionSet": gameboy::extension_set(),
"schemaRefs": schema_refs,
"profile": {
"document": profile,
"canonical": canonical::canonicalize(&profile).expect("canonicalizable"),
"assetRef": gameboy::profile_asset_ref().to_json(),
},
"clock": {
"stepDuration": step.to_json(),
"tickDuration": tick.to_json(),
"legacyMsPerFrame": "1000 / (4194304 / 70224) == 548625/32768 exactly",
"frames": frames,
},
"composition": {
"example": composition.to_json(),
"digest": composition.digest().expect("digest"),
"recipeLines": [
"fly-session/composition-v1",
"session=<sessionId>",
"epoch=<epoch>",
"contract=<contractDigest>",
"agent=<agentId> port=<portId> profile=<profileDigest> (one line per agent)",
"declaration=<this digest> (added by the 2026-09-23 amendment)",
],
},
}))
}
fn write(value: &Value) -> String {
let mut text = serde_json::to_string_pretty(value).expect("serializable");
text.push('\n');
text
}
fn schema_set() -> String {
// The rendered set is itself canonical JSON, so the file the TypeScript package hashes is
// byte for byte what the digest was taken over.
let mut text = schema::schema_set_json().expect("canonicalizable");
text.push('\n');
text
}
fn contract_digest() -> String {
let set = schema::schema_set_json().expect("canonicalizable");
write(&json!({
"description": "contractDigest is the SHA-256 of the canonical schema set in schema-set.json.",
"contractDigest": schema::contract_digest(),
"schemaSetVersion": schema::SCHEMA_SET_VERSION,
"schemaSetBytes": set.len(),
"types": schema::SCHEMAS.len(),
"enums": schema::ENUMS.len(),
"limits": schema::LIMITS.len(),
}))
}
fn valid() -> String {
let mut file = fixtures::load("valid.json").expect("valid.json");
let cases = file
.get_mut("cases")
.and_then(Value::as_array_mut)
.expect("cases");
for case in cases.iter_mut() {
let value = case.get("value").expect("value").clone();
let canonical = canonical::canonicalize(&value).expect("canonicalizable");
let digest = canonical::sha256_hex(canonical.as_bytes());
let map = case.as_object_mut().expect("case object");
map.insert("canonical".to_owned(), Value::String(canonical));
map.insert("digest".to_owned(), Value::String(digest));
}
write(&file)
}
fn operations() -> String {
let mut file = fixtures::load("operations.json").expect("operations.json");
let scope_of = |case: &Value| -> Option<Scope> {
match case.get("scope") {
Some(Value::Null) | None => None,
Some(v) => Some(Scope::from_json(v).expect("scope")),
}
};
for key in file
.get_mut("keys")
.and_then(Value::as_array_mut)
.expect("keys")
{
let scope = scope_of(key).expect("an operation key has a scope");
let method = key.get("method").and_then(Value::as_str).expect("method");
let worker = key.get("workerId").and_then(Value::as_str).expect("workerId");
let digest = canonical::OperationKey::new(scope, method, worker)
.expect("valid key")
.digest()
.expect("digest");
key.as_object_mut()
.expect("object")
.insert("digest".to_owned(), Value::String(digest));
}
for body in file
.get_mut("bodies")
.and_then(Value::as_array_mut)
.expect("bodies")
{
let scope = scope_of(body);
let method = body.get("method").and_then(Value::as_str).expect("method");
let params = body.get("params").expect("params").clone();
let digest =
canonical::body_digest(method, scope.as_ref(), &params).expect("canonical body");
body.as_object_mut()
.expect("object")
.insert("digest".to_owned(), Value::String(digest));
}
write(&file)
}
fn seed_vectors() -> String {
let master_seeds: [u64; 5] = [0, 1, 42, 9_223_372_036_854_775_808, u64::MAX];
let agents = ["fly-a", "fly-b", "fly-c", "fly-d"];
let mut vectors = Vec::new();
for master in master_seeds {
for agent in agents {
let material = seed::material(master, agent).expect("material");
vectors.push(json!({
"masterSeed": master.to_string(),
"agentId": agent,
"material": String::from_utf8(material).expect("utf-8"),
"materialDigest": seed::material_digest(master, agent).expect("digest"),
"seed": seed::agent_seed(master, agent).expect("seed"),
}));
}
}
let composition: Vec<Value> = seed::composition_seeds(
42,
&agents.iter().map(|a| (*a).to_owned()).collect::<Vec<_>>(),
)
.expect("composition")
.into_iter()
.map(Value::from)
.collect();
write(&json!({
"description": "seed-derivation-v1 test vectors. Both languages must reproduce every seed.",
"algorithm": seed::ALGORITHM,
"prefix": seed::PREFIX,
"materialTemplate": "<prefix>\\n<masterSeed>\\n<agentId>\\n",
"rule": "SHA-256 of the material, read as eight big-endian u32 lanes; the first nonzero lane is the seed as a two's-complement i32.",
"vectors": vectors,
"composition": {
"masterSeed": "42",
"agentIds": agents,
"seeds": composition,
"reason": "independent per-agent seeds from one recorded master seed and stable agent ids",
},
"invalid": [
{"masterSeed": "0", "agentId": "Fly-A", "reason": "an agent id is an Id: lowercase"},
{"masterSeed": "0", "agentId": "", "reason": "an agent id is 1..=64 characters"},
{"masterSeed": "0", "agentIds": ["fly-a", "fly-a"],
"reason": "a composition with a repeated agent id is refused rather than silently sharing a seed"},
],
}))
}
fn checkpoint_envelope() -> String {
let scope = Scope::new("demo", "epoch-1", 42).expect("scope");
let manifest = json!({
"envelopeVersion": checkpoint::VERSION,
"checkpointId": "ckpt-1",
"sourceScope": scope.to_json(),
"episodeId": "episode-1",
"worldTime": {"numerator": "700000000", "denominator": "1"},
"schedulerId": "lockstep-v1",
"compositionDigest": canonical::sha256_hex(b"composition"),
"portMap": [{"portId": "port-1", "agentId": "fly-a"}],
"compatibility": {
"backendDigest": canonical::sha256_hex(b"backend"),
"contentDigest": canonical::sha256_hex(b"content"),
"patchDigest": canonical::sha256_hex(b"patch"),
"controllerDigest": canonical::sha256_hex(b"controller"),
"parserDigest": canonical::sha256_hex(b"parser"),
"stateFormatId": "flysess-1",
},
"agents": [{
"agentId": "fly-a",
"profileDigest": canonical::sha256_hex(b"profile"),
"datasetDigest": canonical::sha256_hex(b"fafb-v783"),
"modelVersion": "lif-1ms-f64-v2",
"plasticityVersion": "fly-kc-mbon-rstdp-v2",
"seed": seed::agent_seed(42, "fly-a").expect("seed"),
"brainTicks": "2534",
"remainder": {"numerator": "1000000", "denominator": "3"},
"payload": "agent-fly-a",
}],
"coordinator": {
"taskLedger": "task-ledger",
"priorInspection": "prior-inspection",
"executorState": [{"agentId": "fly-a", "payload": "executor-fly-a"}],
"admissionState": null,
"eventWatermarks": {"lastSourceStep": "42", "issued": "7"},
},
"environment": {"workerId": "arena", "payload": "world"},
"helperState": [],
"payloads": payload_table(),
});
let bytes = checkpoint::encode(&manifest, &payloads()).expect("encode");
let envelope = checkpoint::decode(&bytes).expect("decode");
let entries: Vec<Value> = envelope
.layout
.entries
.iter()
.map(|entry| {
json!({
"name": entry.name,
"offset": entry.offset.to_string(),
"byteLength": entry.byte_length.to_string(),
"digest": checkpoint::hex(&entry.digest),
})
})
.collect();
let first_payload = envelope.layout.entries[0].offset;
write(&json!({
"description": "One FLYSESS1 envelope, its layout and the corruptions a reader must refuse.",
"magic": "FLYSESS1",
"footerMagic": "FLYSESSF",
"version": checkpoint::VERSION,
"manifest": manifest,
"payloads": payloads()
.iter()
.map(|(name, bytes)| json!({"name": name, "base64": fixtures::encode_base64(bytes)}))
.collect::<Vec<_>>(),
"envelope": {
"base64": fixtures::encode_base64(&bytes),
"byteLength": bytes.len(),
"layout": {
"headerBytes": checkpoint::HEADER_BYTES,
"manifestOffset": envelope.layout.manifest_offset.to_string(),
"manifestBytes": envelope.layout.manifest_bytes,
"tableOffset": envelope.layout.table_offset.to_string(),
"tableEntryBytes": checkpoint::TABLE_ENTRY_BYTES,
"entries": entries,
"footerOffset": envelope.layout.footer_offset.to_string(),
"footerBytes": checkpoint::FOOTER_BYTES,
"totalBytes": envelope.layout.total_bytes.to_string(),
},
},
"corruption": [
{"name": "a flipped magic byte", "offset": 0, "reason": "wrong magic"},
{"name": "an unsupported version", "offset": 8, "reason": "unsupported version"},
{"name": "a flipped manifest byte", "offset": checkpoint::HEADER_BYTES,
"reason": "the footer digest covers the manifest"},
{"name": "a flipped payload byte", "offset": first_payload,
"reason": "every payload carries its own digest"},
{"name": "a flipped footer digest byte", "offset": bytes.len() - 40,
"reason": "the footer digest must match the contents"},
{"name": "a flipped footer magic byte", "offset": bytes.len() - 8,
"reason": "a truncated file cannot look complete"},
],
}))
}
fn payloads() -> Vec<(String, Vec<u8>)> {
vec![
("agent-fly-a".to_owned(), b"agent state bytes".to_vec()),
("executor-fly-a".to_owned(), b"executor state".to_vec()),
("task-ledger".to_owned(), b"{\"rank\":10}".to_vec()),
("prior-inspection".to_owned(), b"{\"map\":40}".to_vec()),
("world".to_owned(), vec![0u8; 64]),
]
}
fn payload_table() -> Value {
let mut out = Vec::new();
for (name, bytes) in payloads() {
let mut entry = Map::new();
entry.insert("name".to_owned(), Value::String(name));
entry.insert(
"byteLength".to_owned(),
Value::String(bytes.len().to_string()),
);
entry.insert(
"digest".to_owned(),
Value::String(canonical::sha256_hex(&bytes)),
);
out.push(Value::Object(entry));
}
// A BTreeMap would sort the payload names; the table order is the write order, which is
// what the envelope records.
let _: BTreeMap<(), ()> = BTreeMap::new();
Value::Array(out)
}

View file

@ -0,0 +1,177 @@
{
"description": "The U64 decimal-string and double boundaries, shared by both languages.",
"u64": [
{
"text": "0",
"accept": true,
"reason": "zero is \"0\""
},
{
"text": "1",
"accept": true,
"reason": ""
},
{
"text": "18446744073709551615",
"accept": true,
"reason": "the U64 maximum"
},
{
"text": "18446744073709551616",
"accept": false,
"reason": "one past the maximum"
},
{
"text": "184467440737095516150",
"accept": false,
"reason": "far past the maximum"
},
{
"text": "00",
"accept": false,
"reason": "no leading zeros"
},
{
"text": "01",
"accept": false,
"reason": "no leading zeros"
},
{
"text": "",
"accept": false,
"reason": "empty"
},
{
"text": "-1",
"accept": false,
"reason": "unsigned"
},
{
"text": "+1",
"accept": false,
"reason": "no sign"
},
{
"text": "1.0",
"accept": false,
"reason": "integers only"
},
{
"text": "1e3",
"accept": false,
"reason": "decimal digits only"
},
{
"text": " 1",
"accept": false,
"reason": "no whitespace"
},
{
"text": "1 ",
"accept": false,
"reason": "no whitespace"
},
{
"text": "0x10",
"accept": false,
"reason": "decimal only"
},
{
"text": "9007199254740993",
"accept": true,
"reason": "a U64 string keeps precision a double would lose"
}
],
"doubles": [
{
"value": 0.0,
"canonical": "0",
"accept": true,
"reason": ""
},
{
"value": -0.0,
"canonical": "0",
"accept": true,
"reason": "JSON.stringify prints negative zero as 0"
},
{
"value": 1.0,
"canonical": "1",
"accept": true,
"reason": "an integral double prints without a fraction"
},
{
"value": -17.0,
"canonical": "-17",
"accept": true,
"reason": ""
},
{
"value": 0.1,
"canonical": "0.1",
"accept": true,
"reason": "the shortest round-tripping form"
},
{
"value": 0.30000000000000004,
"canonical": "0.30000000000000004",
"accept": true,
"reason": "shortest round-tripping form, not a rounded one"
},
{
"value": 1e-07,
"canonical": "1e-7",
"accept": true,
"reason": "ECMAScript switches to exponent notation below 1e-6"
},
{
"value": 5e-324,
"canonical": "5e-324",
"accept": true,
"reason": "the smallest subnormal double"
},
{
"value": 1234.5678,
"canonical": "1234.5678",
"accept": true,
"reason": "a fractional value of any magnitude is canonicalizable"
},
{
"value": 9007199254740991,
"canonical": "9007199254740991",
"accept": true,
"reason": "the largest exactly representable integer"
},
{
"value": -9007199254740991,
"canonical": "-9007199254740991",
"accept": true,
"reason": ""
},
{
"value": 9007199254740993,
"canonical": null,
"accept": false,
"reason": "an integral value past the exact range; counters are U64 strings"
},
{
"value": 1e+21,
"canonical": null,
"accept": false,
"reason": "integral and far past the exact range; JSON.parse cannot tell it from the same digits written out"
},
{
"value": 1.7976931348623157e+308,
"canonical": null,
"accept": false,
"reason": "integral and far past the exact range"
},
{
"value": 1.5e+20,
"canonical": null,
"accept": false,
"reason": "1.5e20 is integral as a double, so it falls under the same rule as 1e21"
}
]
}

View file

@ -0,0 +1,199 @@
{
"description": "One FLYSESS1 envelope, its layout and the corruptions a reader must refuse.",
"magic": "FLYSESS1",
"footerMagic": "FLYSESSF",
"version": 1,
"manifest": {
"envelopeVersion": 1,
"checkpointId": "ckpt-1",
"sourceScope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "42"
},
"episodeId": "episode-1",
"worldTime": {
"numerator": "700000000",
"denominator": "1"
},
"schedulerId": "lockstep-v1",
"compositionDigest": "730d725c8a59d3a7303def2bed041a577edb4255aabd4889ce12918311d952f0",
"portMap": [
{
"portId": "port-1",
"agentId": "fly-a"
}
],
"compatibility": {
"backendDigest": "10e08a419e850eba1ebba18fdd28eb7ec1b7e8baa9bcc3b973e2b8891ec726be",
"contentDigest": "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73",
"patchDigest": "a4895eb44afc336fecbba6e520cd67e178dace0276655d102fceffa8e5f70570",
"controllerDigest": "c1472135b14c77c8bef98e73f70208325fa0dcf1e6bd668ae9b31a9cea295fe7",
"parserDigest": "b17d45121150928f2146af49e195eff1eef5d67325be273a733fb74acadaa342",
"stateFormatId": "flysess-1"
},
"agents": [
{
"agentId": "fly-a",
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
"datasetDigest": "6c0af1f0784ef63a393ee77d614e8246c625051360f3f1a48838374c5d355b52",
"modelVersion": "lif-1ms-f64-v2",
"plasticityVersion": "fly-kc-mbon-rstdp-v2",
"seed": -184946063,
"brainTicks": "2534",
"remainder": {
"numerator": "1000000",
"denominator": "3"
},
"payload": "agent-fly-a"
}
],
"coordinator": {
"taskLedger": "task-ledger",
"priorInspection": "prior-inspection",
"executorState": [
{
"agentId": "fly-a",
"payload": "executor-fly-a"
}
],
"admissionState": null,
"eventWatermarks": {
"lastSourceStep": "42",
"issued": "7"
}
},
"environment": {
"workerId": "arena",
"payload": "world"
},
"helperState": [],
"payloads": [
{
"name": "agent-fly-a",
"byteLength": "17",
"digest": "1321dffb0cdc6f9092cbf7fa2a5fc68bbed12c993d5ad398264012810ce9bf93"
},
{
"name": "executor-fly-a",
"byteLength": "14",
"digest": "3aee60df7e29efeba7f5f99fc5867647b36aebff1d5d3c838dbff323122e6462"
},
{
"name": "task-ledger",
"byteLength": "11",
"digest": "40b00ed2bbba901d68205ff71b04a44b9ee53c51cb3109aa2ceaa44f1c45727e"
},
{
"name": "prior-inspection",
"byteLength": "10",
"digest": "2c13b7b4d9a9916801ab9191c314f31b045e9b9c5b669a6c0474f0217ef75bf5"
},
{
"name": "world",
"byteLength": "64",
"digest": "f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a92759fb4b"
}
]
},
"payloads": [
{
"name": "agent-fly-a",
"base64": "YWdlbnQgc3RhdGUgYnl0ZXM="
},
{
"name": "executor-fly-a",
"base64": "ZXhlY3V0b3Igc3RhdGU="
},
{
"name": "task-ledger",
"base64": "eyJyYW5rIjoxMH0="
},
{
"name": "prior-inspection",
"base64": "eyJtYXAiOjQwfQ=="
},
{
"name": "world",
"base64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
}
],
"envelope": {
"base64": "RkxZU0VTUzEBAAAAIAAAADAIAAAFAAAAUAgAAAAAAAB7ImFnZW50cyI6W3siYWdlbnRJZCI6ImZseS1hIiwiYnJhaW5UaWNrcyI6IjI1MzQiLCJkYXRhc2V0RGlnZXN0IjoiNmMwYWYxZjA3ODRlZjYzYTM5M2VlNzdkNjE0ZTgyNDZjNjI1MDUxMzYwZjNmMWE0ODgzODM3NGM1ZDM1NWI1MiIsIm1vZGVsVmVyc2lvbiI6ImxpZi0xbXMtZjY0LXYyIiwicGF5bG9hZCI6ImFnZW50LWZseS1hIiwicGxhc3RpY2l0eVZlcnNpb24iOiJmbHkta2MtbWJvbi1yc3RkcC12MiIsInByb2ZpbGVEaWdlc3QiOiIxOTAwZWFiNmMwMjg0ODNkNzEyNjU5OWVlNmY1MGRlMGQyNzkwN2I1YzY1ZmE5MDUyNDU4MGI0YjBmOTg1MmIwIiwicmVtYWluZGVyIjp7ImRlbm9taW5hdG9yIjoiMyIsIm51bWVyYXRvciI6IjEwMDAwMDAifSwic2VlZCI6LTE4NDk0NjA2M31dLCJjaGVja3BvaW50SWQiOiJja3B0LTEiLCJjb21wYXRpYmlsaXR5Ijp7ImJhY2tlbmREaWdlc3QiOiIxMGUwOGE0MTllODUwZWJhMWViYmExOGZkZDI4ZWI3ZWMxYjdlOGJhYTliY2MzYjk3M2UyYjg4OTFlYzcyNmJlIiwiY29udGVudERpZ2VzdCI6ImVkNzAwMmI0MzllOWFjODQ1ZjIyMzU3ZDgyMmJhYzE0NDQ3MzBmYmRiNjAxNmQzZWM5NDMyMjk3YjllYzlmNzMiLCJjb250cm9sbGVyRGlnZXN0IjoiYzE0NzIxMzViMTRjNzdjOGJlZjk4ZTczZjcwMjA4MzI1ZmEwZGNmMWU2YmQ2NjhhZTliMzFhOWNlYTI5NWZlNyIsInBhcnNlckRpZ2VzdCI6ImIxN2Q0NTEyMTE1MDkyOGYyMTQ2YWY0OWUxOTVlZmYxZWVmNWQ2NzMyNWJlMjczYTczM2ZiNzRhY2FkYWEzNDIiLCJwYXRjaERpZ2VzdCI6ImE0ODk1ZWI0NGFmYzMzNmZlY2JiYTZlNTIwY2Q2N2UxNzhkYWNlMDI3NjY1NWQxMDJmY2VmZmE4ZTVmNzA1NzAiLCJzdGF0ZUZvcm1hdElkIjoiZmx5c2Vzcy0xIn0sImNvbXBvc2l0aW9uRGlnZXN0IjoiNzMwZDcyNWM4YTU5ZDNhNzMwM2RlZjJiZWQwNDFhNTc3ZWRiNDI1NWFhYmQ0ODg5Y2UxMjkxODMxMWQ5NTJmMCIsImNvb3JkaW5hdG9yIjp7ImFkbWlzc2lvblN0YXRlIjpudWxsLCJldmVudFdhdGVybWFya3MiOnsiaXNzdWVkIjoiNyIsImxhc3RTb3VyY2VTdGVwIjoiNDIifSwiZXhlY3V0b3JTdGF0ZSI6W3siYWdlbnRJZCI6ImZseS1hIiwicGF5bG9hZCI6ImV4ZWN1dG9yLWZseS1hIn1dLCJwcmlvckluc3BlY3Rpb24iOiJwcmlvci1pbnNwZWN0aW9uIiwidGFza0xlZGdlciI6InRhc2stbGVkZ2VyIn0sImVudmVsb3BlVmVyc2lvbiI6MSwiZW52aXJvbm1lbnQiOnsicGF5bG9hZCI6IndvcmxkIiwid29ya2VySWQiOiJhcmVuYSJ9LCJlcGlzb2RlSWQiOiJlcGlzb2RlLTEiLCJoZWxwZXJTdGF0ZSI6W10sInBheWxvYWRzIjpbeyJieXRlTGVuZ3RoIjoiMTciLCJkaWdlc3QiOiIxMzIxZGZmYjBjZGM2ZjkwOTJjYmY3ZmEyYTVmYzY4YmJlZDEyYzk5M2Q1YWQzOTgyNjQwMTI4MTBjZTliZjkzIiwibmFtZSI6ImFnZW50LWZseS1hIn0seyJieXRlTGVuZ3RoIjoiMTQiLCJkaWdlc3QiOiIzYWVlNjBkZjdlMjllZmViYTdmNWY5OWZjNTg2NzY0N2IzNmFlYmZmMWQ1ZDNjODM4ZGJmZjMyMzEyMmU2NDYyIiwibmFtZSI6ImV4ZWN1dG9yLWZseS1hIn0seyJieXRlTGVuZ3RoIjoiMTEiLCJkaWdlc3QiOiI0MGIwMGVkMmJiYmE5MDFkNjgyMDVmZjcxYjA0YTQ0YjllZTUzYzUxY2IzMTA5YWEyY2VhYTQ0ZjFjNDU3MjdlIiwibmFtZSI6InRhc2stbGVkZ2VyIn0seyJieXRlTGVuZ3RoIjoiMTAiLCJkaWdlc3QiOiIyYzEzYjdiNGQ5YTk5MTY4MDFhYjkxOTFjMzE0ZjMxYjA0NWU5YjljNWI2NjlhNmMwNDc0ZjAyMTdlZjc1YmY1IiwibmFtZSI6InByaW9yLWluc3BlY3Rpb24ifSx7ImJ5dGVMZW5ndGgiOiI2NCIsImRpZ2VzdCI6ImY1YTVmZDQyZDE2YTIwMzAyNzk4ZWY2ZWQzMDk5NzliNDMwMDNkMjMyMGQ5ZjBlOGVhOTgzMWE5Mjc1OWZiNGIiLCJuYW1lIjoid29ybGQifV0sInBvcnRNYXAiOlt7ImFnZW50SWQiOiJmbHktYSIsInBvcnRJZCI6InBvcnQtMSJ9XSwic2NoZWR1bGVySWQiOiJsb2Nrc3RlcC12MSIsInNvdXJjZVNjb3BlIjp7ImVwb2NoIjoiZXBvY2gtMSIsInNlc3Npb25JZCI6ImRlbW8iLCJzdGVwIjoiNDIifSwid29ybGRUaW1lIjp7ImRlbm9taW5hdG9yIjoiMSIsIm51bWVyYXRvciI6IjcwMDAwMDAwMCJ9fWFnZW50LWZseS1hAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACACgAAAAAAABEAAAAAAAAAEyHf+wzcb5CSy/f6Kl/Gi77RLJk9WtOYJkASgQzpv5NleGVjdXRvci1mbHktYQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAmAoAAAAAAAAOAAAAAAAAADruYN9+Ke/rp/X5n8WGdkezauv/HV08g42/8yMSLmRidGFzay1sZWRnZXIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKgKAAAAAAAACwAAAAAAAABAsA7Su7qQHWggX/cbBKRLnuU8UcsxCaos6qRPHEVyfnByaW9yLWluc3BlY3Rpb24AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC4CgAAAAAAAAoAAAAAAAAALBO3tNmpkWgBq5GRwxTzGwRem5xbZppsBHTwIX73W/V3b3JsZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAyAoAAAAAAABAAAAAAAAAAPWl/ULRaiAwJ5jvbtMJl5tDAD0jINnw6OqYMaknWftLYWdlbnQgc3RhdGUgYnl0ZXMAAAAAAAAAZXhlY3V0b3Igc3RhdGUAAHsicmFuayI6MTB9AAAAAAB7Im1hcCI6NDB9AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgLAAAAAAAAX4L9WkdX0MViD3h5YJgf7VQgwocWU4XJoKX6MUwl+8hGTFlTRVNTRg==",
"byteLength": 2872,
"layout": {
"headerBytes": 32,
"manifestOffset": "32",
"manifestBytes": 2096,
"tableOffset": "2128",
"tableEntryBytes": 112,
"entries": [
{
"name": "agent-fly-a",
"offset": "2688",
"byteLength": "17",
"digest": "1321dffb0cdc6f9092cbf7fa2a5fc68bbed12c993d5ad398264012810ce9bf93"
},
{
"name": "executor-fly-a",
"offset": "2712",
"byteLength": "14",
"digest": "3aee60df7e29efeba7f5f99fc5867647b36aebff1d5d3c838dbff323122e6462"
},
{
"name": "task-ledger",
"offset": "2728",
"byteLength": "11",
"digest": "40b00ed2bbba901d68205ff71b04a44b9ee53c51cb3109aa2ceaa44f1c45727e"
},
{
"name": "prior-inspection",
"offset": "2744",
"byteLength": "10",
"digest": "2c13b7b4d9a9916801ab9191c314f31b045e9b9c5b669a6c0474f0217ef75bf5"
},
{
"name": "world",
"offset": "2760",
"byteLength": "64",
"digest": "f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a92759fb4b"
}
],
"footerOffset": "2824",
"footerBytes": 48,
"totalBytes": "2872"
}
},
"corruption": [
{
"name": "a flipped magic byte",
"offset": 0,
"reason": "wrong magic"
},
{
"name": "an unsupported version",
"offset": 8,
"reason": "unsupported version"
},
{
"name": "a flipped manifest byte",
"offset": 32,
"reason": "the footer digest covers the manifest"
},
{
"name": "a flipped payload byte",
"offset": 2688,
"reason": "every payload carries its own digest"
},
{
"name": "a flipped footer digest byte",
"offset": 2832,
"reason": "the footer digest must match the contents"
},
{
"name": "a flipped footer magic byte",
"offset": 2864,
"reason": "a truncated file cannot look complete"
}
]
}

View file

@ -0,0 +1,9 @@
{
"description": "contractDigest is the SHA-256 of the canonical schema set in schema-set.json.",
"contractDigest": "a56e25e6289a6f9e6720ba5f727651e9e4e2c5cf7a68990fc1f8d37fe12f2006",
"schemaSetVersion": 1,
"schemaSetBytes": 30711,
"types": 60,
"enums": 12,
"limits": 27
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,335 @@
{
"description": "decoderConfigDigest vectors (legacy-gameboy-v1 section 12): the canonical form of gameboy_decoder_config_with_macros / gameboyDecoderConfig for raw mode and the Pokemon Red macro group. Written by FLY_UPDATE_FIXTURES=1 cargo test -p flysim --test legacy_profile_identity; both languages must reproduce every form and digest.",
"cases": [
{
"name": "raw",
"macroChannels": [],
"form": {
"form": "gameboy-decoder-config-v1",
"exclusive": {
"channels": [
{
"channel": "up",
"role": "command_0"
},
{
"channel": "down",
"role": "command_1"
},
{
"channel": "left",
"role": "command_2"
},
{
"channel": "right",
"role": "command_3"
}
],
"decisionMs": 800.0,
"holdMs": 800.0,
"hysteresis": 1.05,
"fatigueGain": 0.08,
"fatigueDecay": 0.8,
"blockedFatigue": 0.35,
"blockedMs": 800.0
},
"macros": null,
"pulses": [
{
"channel": "a",
"role": "command_4",
"holdMs": 85.0,
"cooldownMs": 480.0,
"threshold": 1.0,
"boot": null,
"throttleGroup": null
},
{
"channel": "b",
"role": "command_5",
"holdMs": 85.0,
"cooldownMs": 480.0,
"threshold": 1.0,
"boot": null,
"throttleGroup": null
},
{
"channel": "start",
"role": "command_6",
"holdMs": 55.0,
"cooldownMs": 30000.0,
"threshold": 1.35,
"boot": {
"cooldownMs": 2500.0,
"threshold": 1.0
},
"throttleGroup": "system"
},
{
"channel": "select",
"role": "command_7",
"holdMs": 55.0,
"cooldownMs": 30000.0,
"threshold": 1.35,
"boot": {
"cooldownMs": 2500.0,
"threshold": 1.0
},
"throttleGroup": "system"
}
],
"clearLockoutMs": 480.0
},
"canonical": "{\"clearLockoutMs\":480,\"exclusive\":{\"blockedFatigue\":0.35,\"blockedMs\":800,\"channels\":[{\"channel\":\"up\",\"role\":\"command_0\"},{\"channel\":\"down\",\"role\":\"command_1\"},{\"channel\":\"left\",\"role\":\"command_2\"},{\"channel\":\"right\",\"role\":\"command_3\"}],\"decisionMs\":800,\"fatigueDecay\":0.8,\"fatigueGain\":0.08,\"holdMs\":800,\"hysteresis\":1.05},\"form\":\"gameboy-decoder-config-v1\",\"macros\":null,\"pulses\":[{\"boot\":null,\"channel\":\"a\",\"cooldownMs\":480,\"holdMs\":85,\"role\":\"command_4\",\"threshold\":1,\"throttleGroup\":null},{\"boot\":null,\"channel\":\"b\",\"cooldownMs\":480,\"holdMs\":85,\"role\":\"command_5\",\"threshold\":1,\"throttleGroup\":null},{\"boot\":{\"cooldownMs\":2500,\"threshold\":1},\"channel\":\"start\",\"cooldownMs\":30000,\"holdMs\":55,\"role\":\"command_6\",\"threshold\":1.35,\"throttleGroup\":\"system\"},{\"boot\":{\"cooldownMs\":2500,\"threshold\":1},\"channel\":\"select\",\"cooldownMs\":30000,\"holdMs\":55,\"role\":\"command_7\",\"threshold\":1.35,\"throttleGroup\":\"system\"}]}",
"digest": "6234e4a0363cfe82b8d515943c4f645a3960eda8fa5bf9465009061f80d50812"
},
{
"name": "macros",
"macroChannels": [
"macro_go_objective",
"macro_go_out",
"macro_go_warp",
"macro_go_route",
"macro_go_item",
"macro_go_npc",
"macro_go_frontier",
"macro_go_shop",
"macro_go_heal",
"macro_talk",
"macro_menu",
"macro_next",
"macro_yes",
"macro_no",
"macro_close",
"macro_confirm",
"macro_back",
"macro_move_1",
"macro_move_2",
"macro_move_3",
"macro_move_4",
"macro_switch",
"macro_item",
"macro_throw_ball",
"macro_run",
"macro_buy_potion",
"macro_buy_ball",
"macro_buy_antidote",
"macro_buy_repel",
"macro_heal",
"macro_leave"
],
"form": {
"form": "gameboy-decoder-config-v1",
"exclusive": {
"channels": [
{
"channel": "up",
"role": "command_0"
},
{
"channel": "down",
"role": "command_1"
},
{
"channel": "left",
"role": "command_2"
},
{
"channel": "right",
"role": "command_3"
}
],
"decisionMs": 800.0,
"holdMs": 800.0,
"hysteresis": 1.05,
"fatigueGain": 0.08,
"fatigueDecay": 0.8,
"blockedFatigue": 0.35,
"blockedMs": 800.0
},
"macros": {
"channels": [
{
"channel": "macro_go_objective",
"role": "macro_go_objective"
},
{
"channel": "macro_go_out",
"role": "macro_go_out"
},
{
"channel": "macro_go_warp",
"role": "macro_go_warp"
},
{
"channel": "macro_go_route",
"role": "macro_go_route"
},
{
"channel": "macro_go_item",
"role": "macro_go_item"
},
{
"channel": "macro_go_npc",
"role": "macro_go_npc"
},
{
"channel": "macro_go_frontier",
"role": "macro_go_frontier"
},
{
"channel": "macro_go_shop",
"role": "macro_go_shop"
},
{
"channel": "macro_go_heal",
"role": "macro_go_heal"
},
{
"channel": "macro_talk",
"role": "macro_talk"
},
{
"channel": "macro_menu",
"role": "macro_menu"
},
{
"channel": "macro_next",
"role": "macro_next"
},
{
"channel": "macro_yes",
"role": "macro_yes"
},
{
"channel": "macro_no",
"role": "macro_no"
},
{
"channel": "macro_close",
"role": "macro_close"
},
{
"channel": "macro_confirm",
"role": "macro_confirm"
},
{
"channel": "macro_back",
"role": "macro_back"
},
{
"channel": "macro_move_1",
"role": "macro_move_1"
},
{
"channel": "macro_move_2",
"role": "macro_move_2"
},
{
"channel": "macro_move_3",
"role": "macro_move_3"
},
{
"channel": "macro_move_4",
"role": "macro_move_4"
},
{
"channel": "macro_switch",
"role": "macro_switch"
},
{
"channel": "macro_item",
"role": "macro_item"
},
{
"channel": "macro_throw_ball",
"role": "macro_throw_ball"
},
{
"channel": "macro_run",
"role": "macro_run"
},
{
"channel": "macro_buy_potion",
"role": "macro_buy_potion"
},
{
"channel": "macro_buy_ball",
"role": "macro_buy_ball"
},
{
"channel": "macro_buy_antidote",
"role": "macro_buy_antidote"
},
{
"channel": "macro_buy_repel",
"role": "macro_buy_repel"
},
{
"channel": "macro_heal",
"role": "macro_heal"
},
{
"channel": "macro_leave",
"role": "macro_leave"
}
],
"decisionMs": 800.0,
"holdMs": 800.0,
"hysteresis": 1.05,
"fatigueGain": 0.08,
"fatigueDecay": 0.8,
"blockedFatigue": 0.35,
"blockedMs": 800.0
},
"pulses": [
{
"channel": "a",
"role": "command_4",
"holdMs": 85.0,
"cooldownMs": 480.0,
"threshold": 1.0,
"boot": null,
"throttleGroup": null
},
{
"channel": "b",
"role": "command_5",
"holdMs": 85.0,
"cooldownMs": 480.0,
"threshold": 1.0,
"boot": null,
"throttleGroup": null
},
{
"channel": "start",
"role": "command_6",
"holdMs": 55.0,
"cooldownMs": 30000.0,
"threshold": 1.35,
"boot": {
"cooldownMs": 2500.0,
"threshold": 1.0
},
"throttleGroup": "system"
},
{
"channel": "select",
"role": "command_7",
"holdMs": 55.0,
"cooldownMs": 30000.0,
"threshold": 1.35,
"boot": {
"cooldownMs": 2500.0,
"threshold": 1.0
},
"throttleGroup": "system"
}
],
"clearLockoutMs": 480.0
},
"canonical": "{\"clearLockoutMs\":480,\"exclusive\":{\"blockedFatigue\":0.35,\"blockedMs\":800,\"channels\":[{\"channel\":\"up\",\"role\":\"command_0\"},{\"channel\":\"down\",\"role\":\"command_1\"},{\"channel\":\"left\",\"role\":\"command_2\"},{\"channel\":\"right\",\"role\":\"command_3\"}],\"decisionMs\":800,\"fatigueDecay\":0.8,\"fatigueGain\":0.08,\"holdMs\":800,\"hysteresis\":1.05},\"form\":\"gameboy-decoder-config-v1\",\"macros\":{\"blockedFatigue\":0.35,\"blockedMs\":800,\"channels\":[{\"channel\":\"macro_go_objective\",\"role\":\"macro_go_objective\"},{\"channel\":\"macro_go_out\",\"role\":\"macro_go_out\"},{\"channel\":\"macro_go_warp\",\"role\":\"macro_go_warp\"},{\"channel\":\"macro_go_route\",\"role\":\"macro_go_route\"},{\"channel\":\"macro_go_item\",\"role\":\"macro_go_item\"},{\"channel\":\"macro_go_npc\",\"role\":\"macro_go_npc\"},{\"channel\":\"macro_go_frontier\",\"role\":\"macro_go_frontier\"},{\"channel\":\"macro_go_shop\",\"role\":\"macro_go_shop\"},{\"channel\":\"macro_go_heal\",\"role\":\"macro_go_heal\"},{\"channel\":\"macro_talk\",\"role\":\"macro_talk\"},{\"channel\":\"macro_menu\",\"role\":\"macro_menu\"},{\"channel\":\"macro_next\",\"role\":\"macro_next\"},{\"channel\":\"macro_yes\",\"role\":\"macro_yes\"},{\"channel\":\"macro_no\",\"role\":\"macro_no\"},{\"channel\":\"macro_close\",\"role\":\"macro_close\"},{\"channel\":\"macro_confirm\",\"role\":\"macro_confirm\"},{\"channel\":\"macro_back\",\"role\":\"macro_back\"},{\"channel\":\"macro_move_1\",\"role\":\"macro_move_1\"},{\"channel\":\"macro_move_2\",\"role\":\"macro_move_2\"},{\"channel\":\"macro_move_3\",\"role\":\"macro_move_3\"},{\"channel\":\"macro_move_4\",\"role\":\"macro_move_4\"},{\"channel\":\"macro_switch\",\"role\":\"macro_switch\"},{\"channel\":\"macro_item\",\"role\":\"macro_item\"},{\"channel\":\"macro_throw_ball\",\"role\":\"macro_throw_ball\"},{\"channel\":\"macro_run\",\"role\":\"macro_run\"},{\"channel\":\"macro_buy_potion\",\"role\":\"macro_buy_potion\"},{\"channel\":\"macro_buy_ball\",\"role\":\"macro_buy_ball\"},{\"channel\":\"macro_buy_antidote\",\"role\":\"macro_buy_antidote\"},{\"channel\":\"macro_buy_repel\",\"role\":\"macro_buy_repel\"},{\"channel\":\"macro_heal\",\"role\":\"macro_heal\"},{\"channel\":\"macro_leave\",\"role\":\"macro_leave\"}],\"decisionMs\":800,\"fatigueDecay\":0.8,\"fatigueGain\":0.08,\"holdMs\":800,\"hysteresis\":1.05},\"pulses\":[{\"boot\":null,\"channel\":\"a\",\"cooldownMs\":480,\"holdMs\":85,\"role\":\"command_4\",\"threshold\":1,\"throttleGroup\":null},{\"boot\":null,\"channel\":\"b\",\"cooldownMs\":480,\"holdMs\":85,\"role\":\"command_5\",\"threshold\":1,\"throttleGroup\":null},{\"boot\":{\"cooldownMs\":2500,\"threshold\":1},\"channel\":\"start\",\"cooldownMs\":30000,\"holdMs\":55,\"role\":\"command_6\",\"threshold\":1.35,\"throttleGroup\":\"system\"},{\"boot\":{\"cooldownMs\":2500,\"threshold\":1},\"channel\":\"select\",\"cooldownMs\":30000,\"holdMs\":55,\"role\":\"command_7\",\"threshold\":1.35,\"throttleGroup\":\"system\"}]}",
"digest": "82b6601f0390b742a67eca44ef935b49e16a73315db70f5644f1cbd21f7c8a52"
}
]
}

View file

@ -0,0 +1,575 @@
{
"description": "The legacy Game Boy composition (legacy-gameboy-v1): registered payload schemas with their SchemaRef digests, the one legacy profile document and its AssetRef, the frame clock, and an example composition declaration with its digest.",
"extensionSetDigest": "a35823ecf607a218b0c9f9e7ec86585d355cb311f8d7a3ada8aa793e1c9fe47c",
"extensionSet": {
"contract": "fly-session-types/legacy-gameboy",
"version": 1,
"scalars": {
"ChannelName": "^[a-z][a-z0-9_]{0,63}$: a decoder channel or rate-role name; not an Id, because the legacy role names carry '_'"
},
"enums": {
"MacroMode": [
"raw",
"macros"
],
"RollbackTrigger": [
"stall",
"game-over"
]
},
"payloadSchemas": [
{
"declaration": {
"registry": "fly-session-payload-schema-v1",
"id": "gameboy-channels-v1",
"version": 1,
"source": "legacy-gameboy-v1 6",
"fields": [
{
"name": "buttons",
"kind": "array<{id:Id,down:bool}>",
"required": true,
"constraint": "exactly up,down,left,right,a,b,start,select in that order"
},
{
"name": "macro",
"kind": "ChannelName|null",
"required": false,
"constraint": "the macro-group channel active in this decode; one of the context's bound"
}
]
},
"schemaRef": {
"id": "gameboy-channels-v1",
"version": 1,
"digest": "28bd89bfa41ec73fb2785959a64d9975b4a932e10cd9e51dc4bc29020c823595"
}
},
{
"declaration": {
"registry": "fly-session-payload-schema-v1",
"id": "gameboy-joypad-v1",
"version": 1,
"source": "legacy-gameboy-v1 7",
"fields": [
{
"name": "buttons",
"kind": "const",
"required": true,
"constraint": "up,down,left,right,a,b,start,select; no axes; one port"
}
]
},
"schemaRef": {
"id": "gameboy-joypad-v1",
"version": 1,
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
}
},
{
"declaration": {
"registry": "fly-session-payload-schema-v1",
"id": "gameboy-memory-inspection-v1",
"version": 1,
"source": "legacy-gameboy-v1 8",
"fields": [
{
"name": "memory",
"kind": "ArtifactRef",
"required": true,
"constraint": "listed attachment; byteLength 65536; the CPU address space $0000..=$FFFF at this boundary, read-only"
},
{
"name": "romDigest",
"kind": "Digest",
"required": true,
"constraint": "== EnvironmentDescriptor.contentDigest == the executor's rom AssetRef digest"
}
]
},
"schemaRef": {
"id": "gameboy-memory-inspection-v1",
"version": 1,
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
}
},
{
"declaration": {
"registry": "fly-session-payload-schema-v1",
"id": "gameboy-readout-context-v1",
"version": 1,
"source": "legacy-gameboy-v1 5",
"fields": [
{
"name": "boot",
"kind": "bool",
"required": true,
"constraint": "the adapter's boot gate after the last transition: permissive Start/Select variant"
},
{
"name": "bound",
"kind": "array<ChannelName>",
"required": true,
"constraint": "<= 64, unique, a subset of the composition's macroChannels in their order; [] in raw mode"
},
{
"name": "location",
"kind": "{area:int,x:int,y:int}|null",
"required": false,
"constraint": "each 0..=4294967295; the adapter's location after the last transition; null is no information"
}
]
},
"schemaRef": {
"id": "gameboy-readout-context-v1",
"version": 1,
"digest": "78a5312f8608a1399e7a16549a1b95a43b87137618d59815b9b06c1bb184014d"
}
},
{
"declaration": {
"registry": "fly-session-payload-schema-v1",
"id": "legacy-ratchet-rollback-v1",
"version": 1,
"source": "legacy-gameboy-v1 11",
"fields": [
{
"name": "slotId",
"kind": "Id",
"required": true,
"constraint": "one of the composition's declared slots, saved earlier"
},
{
"name": "trigger",
"kind": "RollbackTrigger",
"required": true,
"constraint": "stall or game-over"
}
]
},
"schemaRef": {
"id": "legacy-ratchet-rollback-v1",
"version": 1,
"digest": "0610f899a4746e5991a07dbc3c847ada15d7c68cedf0664244a3c1d87c175c70"
}
}
],
"declarations": [
{
"name": "LegacyGameboyComposition",
"source": "legacy-gameboy-v1 12",
"fields": [
{
"name": "compositionId",
"kind": "Id",
"required": true,
"constraint": ""
},
{
"name": "scheduler",
"kind": "const",
"required": true,
"constraint": "\"lockstep-v1\""
},
{
"name": "profile",
"kind": "AssetRef",
"required": true,
"constraint": "format fly-profile-v1; digest == the legacy profile's"
},
{
"name": "executor",
"kind": "{id:const,rom:AssetRef,adapter:Id,symbolProvenance:string,mode:MacroMode,macroChannels:array<ChannelName>}",
"required": true,
"constraint": "pokered-macros-v1; task and executor one object; macroChannels [] iff mode raw, else <= 64 unique in decoder order"
},
{
"name": "decoderConfigDigest",
"kind": "Digest",
"required": true,
"constraint": "SHA-256 of the canonical gameboy-decoder-config-v1 form of the effective decoder configuration"
},
{
"name": "environment",
"kind": "{extensions:array<Id>,slots:array<Id>,stepDuration:RationalNs,inspectionSchema:SchemaRef,controllerSchema:SchemaRef,setupFrames:const,audio:{sampleRate:int,channels:const}}",
"required": true,
"constraint": "[\"gameboy-slots-v1\"]; 1..=4 unique slots; 8572265625/512; memory inspection; joypad; 1 setup frame; 2 channels"
},
{
"name": "episodePolicy",
"kind": "const",
"required": true,
"constraint": "\"legacy-ratchet-rollback-v1\""
},
{
"name": "restore",
"kind": "const",
"required": true,
"constraint": "\"legacy-transient-reset\""
},
{
"name": "checkpointFormatOfRecord",
"kind": "const",
"required": true,
"constraint": "\"FLYSIM01\""
},
{
"name": "flysimCompatibility",
"kind": "string",
"required": true,
"constraint": "<= 1024 bytes; the FLYSIM01 string; its kernel, adapter, fingerprint, plasticity and pokered segments agree with this declaration"
}
]
},
{
"name": "LegacyGameboyProfile",
"source": "legacy-gameboy-v1 2",
"fields": [
{
"name": "profileId",
"kind": "const",
"required": true,
"constraint": "\"gameboy-legacy-fafb-v783-v1\""
},
{
"name": "datasetId",
"kind": "const",
"required": true,
"constraint": "\"fafb-v783\""
},
{
"name": "fingerprintSchema",
"kind": "const",
"required": true,
"constraint": "1"
},
{
"name": "datasetFingerprint",
"kind": "string",
"required": true,
"constraint": "today's schema-1 fingerprint, seven digests joined with ':'"
},
{
"name": "kernelVersion",
"kind": "const",
"required": true,
"constraint": "\"lif-1ms-f64-v2\""
},
{
"name": "plasticityVersion",
"kind": "const",
"required": true,
"constraint": "\"fly-kc-mbon-rstdp-v2\""
},
{
"name": "tickDuration",
"kind": "RationalNs",
"required": true,
"constraint": "1000000/1"
},
{
"name": "warmupMs",
"kind": "const",
"required": true,
"constraint": "2500"
},
{
"name": "view",
"kind": "{viewId:Id,width:int,height:int}",
"required": true,
"constraint": "lcd, 160, 144"
},
{
"name": "supportedStimuli",
"kind": "array<Id>",
"required": true,
"constraint": "[\"reward-pulse\"]"
},
{
"name": "readoutContextSchema",
"kind": "SchemaRef",
"required": true,
"constraint": "gameboy-readout-context-v1"
},
{
"name": "decisionSchema",
"kind": "SchemaRef",
"required": true,
"constraint": "gameboy-channels-v1"
},
{
"name": "legacyExceptions",
"kind": "array<Id>",
"required": true,
"constraint": "[\"macro-roles-outside-fingerprint\"]"
}
]
}
]
},
"schemaRefs": {
"gameboy-readout-context-v1": {
"id": "gameboy-readout-context-v1",
"version": 1,
"digest": "78a5312f8608a1399e7a16549a1b95a43b87137618d59815b9b06c1bb184014d"
},
"gameboy-channels-v1": {
"id": "gameboy-channels-v1",
"version": 1,
"digest": "28bd89bfa41ec73fb2785959a64d9975b4a932e10cd9e51dc4bc29020c823595"
},
"gameboy-joypad-v1": {
"id": "gameboy-joypad-v1",
"version": 1,
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
},
"gameboy-memory-inspection-v1": {
"id": "gameboy-memory-inspection-v1",
"version": 1,
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
},
"legacy-ratchet-rollback-v1": {
"id": "legacy-ratchet-rollback-v1",
"version": 1,
"digest": "0610f899a4746e5991a07dbc3c847ada15d7c68cedf0664244a3c1d87c175c70"
}
},
"profile": {
"document": {
"profileId": "gameboy-legacy-fafb-v783-v1",
"datasetId": "fafb-v783",
"fingerprintSchema": 1,
"datasetFingerprint": "75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc",
"kernelVersion": "lif-1ms-f64-v2",
"plasticityVersion": "fly-kc-mbon-rstdp-v2",
"tickDuration": {
"numerator": "1000000",
"denominator": "1"
},
"warmupMs": 2500,
"view": {
"viewId": "lcd",
"width": 160,
"height": 144
},
"supportedStimuli": [
"reward-pulse"
],
"readoutContextSchema": {
"id": "gameboy-readout-context-v1",
"version": 1,
"digest": "78a5312f8608a1399e7a16549a1b95a43b87137618d59815b9b06c1bb184014d"
},
"decisionSchema": {
"id": "gameboy-channels-v1",
"version": 1,
"digest": "28bd89bfa41ec73fb2785959a64d9975b4a932e10cd9e51dc4bc29020c823595"
},
"legacyExceptions": [
"macro-roles-outside-fingerprint"
]
},
"canonical": "{\"datasetFingerprint\":\"75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc\",\"datasetId\":\"fafb-v783\",\"decisionSchema\":{\"digest\":\"28bd89bfa41ec73fb2785959a64d9975b4a932e10cd9e51dc4bc29020c823595\",\"id\":\"gameboy-channels-v1\",\"version\":1},\"fingerprintSchema\":1,\"kernelVersion\":\"lif-1ms-f64-v2\",\"legacyExceptions\":[\"macro-roles-outside-fingerprint\"],\"plasticityVersion\":\"fly-kc-mbon-rstdp-v2\",\"profileId\":\"gameboy-legacy-fafb-v783-v1\",\"readoutContextSchema\":{\"digest\":\"78a5312f8608a1399e7a16549a1b95a43b87137618d59815b9b06c1bb184014d\",\"id\":\"gameboy-readout-context-v1\",\"version\":1},\"supportedStimuli\":[\"reward-pulse\"],\"tickDuration\":{\"denominator\":\"1\",\"numerator\":\"1000000\"},\"view\":{\"height\":144,\"viewId\":\"lcd\",\"width\":160},\"warmupMs\":2500}",
"assetRef": {
"id": "gameboy-legacy-fafb-v783-v1",
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
"byteLength": "1137",
"format": "fly-profile-v1"
}
},
"clock": {
"stepDuration": {
"numerator": "8572265625",
"denominator": "512"
},
"tickDuration": {
"numerator": "1000000",
"denominator": "1"
},
"legacyMsPerFrame": "1000 / (4194304 / 70224) == 548625/32768 exactly",
"frames": [
{
"ticks": "16",
"remainder": {
"numerator": "380265625",
"denominator": "512"
}
},
{
"ticks": "17",
"remainder": {
"numerator": "124265625",
"denominator": "256"
}
},
{
"ticks": "17",
"remainder": {
"numerator": "116796875",
"denominator": "512"
}
},
{
"ticks": "16",
"remainder": {
"numerator": "124265625",
"denominator": "128"
}
},
{
"ticks": "17",
"remainder": {
"numerator": "365328125",
"denominator": "512"
}
},
{
"ticks": "17",
"remainder": {
"numerator": "116796875",
"denominator": "256"
}
},
{
"ticks": "17",
"remainder": {
"numerator": "101859375",
"denominator": "512"
}
},
{
"ticks": "16",
"remainder": {
"numerator": "60265625",
"denominator": "64"
}
},
{
"ticks": "17",
"remainder": {
"numerator": "350390625",
"denominator": "512"
}
},
{
"ticks": "17",
"remainder": {
"numerator": "109328125",
"denominator": "256"
}
},
{
"ticks": "17",
"remainder": {
"numerator": "86921875",
"denominator": "512"
}
},
{
"ticks": "16",
"remainder": {
"numerator": "116796875",
"denominator": "128"
}
}
]
},
"composition": {
"example": {
"compositionId": "pokered-live",
"scheduler": "lockstep-v1",
"profile": {
"id": "gameboy-legacy-fafb-v783-v1",
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
"byteLength": "1137",
"format": "fly-profile-v1"
},
"executor": {
"id": "pokered-macros-v1",
"rom": {
"id": "pokered-rom",
"digest": "c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20",
"byteLength": "1048576",
"format": "gb-rom"
},
"adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros",
"macroChannels": [
"macro_go_objective",
"macro_go_out",
"macro_go_warp",
"macro_go_route",
"macro_go_item",
"macro_go_npc",
"macro_go_frontier",
"macro_go_shop",
"macro_go_heal",
"macro_talk",
"macro_menu",
"macro_next",
"macro_yes",
"macro_no",
"macro_close",
"macro_confirm",
"macro_back",
"macro_move_1",
"macro_move_2",
"macro_move_3",
"macro_move_4",
"macro_switch",
"macro_item",
"macro_throw_ball",
"macro_run",
"macro_buy_potion",
"macro_buy_ball",
"macro_buy_antidote",
"macro_buy_repel",
"macro_heal",
"macro_leave"
]
},
"decoderConfigDigest": "82b6601f0390b742a67eca44ef935b49e16a73315db70f5644f1cbd21f7c8a52",
"environment": {
"extensions": [
"gameboy-slots-v1"
],
"slots": [
"best"
],
"stepDuration": {
"numerator": "8572265625",
"denominator": "512"
},
"inspectionSchema": {
"id": "gameboy-memory-inspection-v1",
"version": 1,
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
},
"controllerSchema": {
"id": "gameboy-joypad-v1",
"version": 1,
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
},
"setupFrames": 1,
"audio": {
"sampleRate": 48000,
"channels": 2
}
},
"episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
},
"digest": "44916db0a0846d3338f50d4f24d9d4e0214fa239af7d15e4174213cd93b07e28",
"recipeLines": [
"fly-session/composition-v1",
"session=<sessionId>",
"epoch=<epoch>",
"contract=<contractDigest>",
"agent=<agentId> port=<portId> profile=<profileDigest> (one line per agent)",
"declaration=<this digest> (added by the 2026-09-23 amendment)"
]
}
}

View file

@ -0,0 +1,74 @@
{
"description": "Boundary cases both languages build from a recipe, because the payload is too large to store.",
"padSchema": {
"id": "pad.v1",
"version": 1,
"digest": "018689202f154300eeda48ccee8cc021c36672df03f7404097613f5898fdfdcc"
},
"cases": [
{
"name": "typed value exactly at the 32 KiB cap",
"kind": "padded-typed-value",
"padCharacters": 32635,
"expect": "accept",
"reason": "32768 bytes of canonical JSON is the limit, not one byte less"
},
{
"name": "typed value one byte over the cap",
"kind": "padded-typed-value",
"padCharacters": 32636,
"expect": "reject",
"reason": "a TypedValue is at most 32 KiB of canonical JSON"
},
{
"name": "request that fills the 64 KiB envelope exactly",
"kind": "padded-request",
"padCharacters": 60000,
"expect": "accept",
"reason": "65536 bytes including the envelope wrapper is admissible",
"envelopeTotal": 65536
},
{
"name": "request one byte past the envelope ceiling",
"kind": "padded-request",
"padCharacters": 60000,
"expect": "reject",
"reason": "the complete envelope must fit Flybus's 64-KiB maximum",
"envelopeTotal": 65537
},
{
"name": "error message of 512 code points",
"kind": "error-message",
"codePoints": 512,
"expect": "accept",
"reason": "messages are <= 512 code points"
},
{
"name": "error message of 513 code points",
"kind": "error-message",
"codePoints": 513,
"expect": "reject",
"reason": "messages are <= 512 code points"
},
{
"name": "error message of 512 astral code points",
"kind": "error-message-astral",
"codePoints": 512,
"expect": "accept",
"reason": "the bound counts code points, not UTF-16 units or bytes"
},
{
"name": "error message of 513 astral code points",
"kind": "error-message-astral",
"codePoints": 513,
"expect": "reject",
"reason": "the bound counts code points, not UTF-16 units or bytes"
}
],
"recipes": {
"padded-typed-value": "a TypedValue whose schema is padSchema and whose value is {\"pad\": <padCharacters> 'a' characters}; validate it",
"padded-request": "a SessionRpcRequest req-1 with scope null and params {\"pad\": <padCharacters> 'a' characters}; canonicalize it, then require the envelope to fit with an overhead of envelopeTotal minus that canonical length",
"error-message": "a SessionRpcFailure with code INTERNAL, mutation unknown and a message of <codePoints> 'x' characters",
"error-message-astral": "the same failure with a message of <codePoints> repetitions of U+10400, one code point and two UTF-16 units each"
}
}

View file

@ -0,0 +1,111 @@
{
"description": "Bus callId, domain requestId, artifact identity and delivery/hold owner tokens are four types, not four spellings of one string.",
"cases": [
{
"text": "call-0",
"busCallId": true,
"domainRequestId": false,
"ownerToken": null
},
{
"text": "call-102",
"busCallId": true,
"domainRequestId": false,
"ownerToken": null
},
{
"text": "req-41",
"busCallId": false,
"domainRequestId": true,
"ownerToken": null
},
{
"text": "dlv-7",
"busCallId": false,
"domainRequestId": false,
"ownerToken": "delivery"
},
{
"text": "own-9",
"busCallId": false,
"domainRequestId": false,
"ownerToken": "hold"
},
{
"text": "req-041",
"busCallId": false,
"domainRequestId": false,
"ownerToken": null
},
{
"text": "call-",
"busCallId": false,
"domainRequestId": false,
"ownerToken": null
},
{
"text": "call",
"busCallId": false,
"domainRequestId": false,
"ownerToken": null
},
{
"text": "callid-1",
"busCallId": false,
"domainRequestId": false,
"ownerToken": null
},
{
"text": "request-1",
"busCallId": false,
"domainRequestId": false,
"ownerToken": null
},
{
"text": "REQ-1",
"busCallId": false,
"domainRequestId": false,
"ownerToken": null
},
{
"text": "dlv-07",
"busCallId": false,
"domainRequestId": false,
"ownerToken": null
},
{
"text": "sub-1",
"busCallId": false,
"domainRequestId": false,
"ownerToken": null
},
{
"text": "svc-1",
"busCallId": false,
"domainRequestId": false,
"ownerToken": null
}
],
"artifact": {
"ref": {
"storeId": "store-1",
"artifactId": "frame-1",
"generation": "1",
"byteLength": "92160",
"contentType": "image/x-rgba8",
"digest": null
},
"identity": {
"storeId": "store-1",
"artifactId": "frame-1",
"generation": "1"
},
"reason": "the identity is the naming half of an ArtifactRef; byteLength, contentType and digest are not identity, and an AssetRef is not an artifact at all"
},
"asset": {
"id": "profile-fly-a",
"digest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
"byteLength": "4096",
"format": "flyprofile"
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,667 @@
{
"description": "ipc-v1 section 5: the operation key of a step mutation and the canonical body a duplicate is compared against.",
"keys": [
{
"name": "Agent.Prepare on fly-a at step 41",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"method": "Agent.Prepare",
"workerId": "fly-a",
"digest": "eda73d62bb5d616052997ab5e9d2c4ba5872fdefb44cf706910a18a24d39225f"
},
{
"name": "Agent.Prepare on fly-b at step 41",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"method": "Agent.Prepare",
"workerId": "fly-b",
"digest": "5a7fd15da236f3fd6343698a8cc5c4f14fa7d84683a819d5a28904d90f700c4e"
},
{
"name": "Agent.Commit on fly-a at step 41",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"method": "Agent.Commit",
"workerId": "fly-a",
"digest": "2a89a120e5594895d024b4ffc2a7d225bd27322cfd345ebd78e80ed7b271c680"
},
{
"name": "Agent.Prepare on fly-a at step 42",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "42"
},
"method": "Agent.Prepare",
"workerId": "fly-a",
"digest": "5bb184cab6c3c2d7567a7979d860a7e9b26ea52bdc92b139053b0a2f81a9ed4d"
},
{
"name": "Agent.Prepare on fly-a in another epoch",
"scope": {
"sessionId": "demo",
"epoch": "epoch-2",
"step": "41"
},
"method": "Agent.Prepare",
"workerId": "fly-a",
"digest": "fa1d9cf3936aaffe3cedd0e586d7763efca2d250e89dd85b849cf35fbd91a477"
},
{
"name": "Environment.Advance at step 41",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"method": "Environment.Advance",
"workerId": "world",
"digest": "c89c390aa78ef2895d9e2b86a6000160923dc54e3be75529e0289cd18b9088b1"
}
],
"bodies": [
{
"name": "Agent.Prepare body",
"method": "Agent.Prepare",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"agentId": "fly-a",
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
"interval": {
"numerator": "50000000",
"denominator": "3"
},
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
"preStepStimulations": [
{
"id": "stim-1",
"kindId": "sugar",
"durationMs": 50.0
}
]
},
"digest": "74c9d5f27b2cb06922cc3ca13d87c67faea0a6c73aefbbdb21ba8f52c6d33f60"
},
{
"name": "Environment.Advance body",
"method": "Environment.Advance",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"batchId": "batch-41",
"controls": [
{
"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
}
]
}
]
},
"digest": "5aaae9c083336460a2ce34fb50e191f7108444a953f09b952e97a48b7962ee79"
},
{
"name": "Worker.Hello body with no scope",
"method": "Worker.Hello",
"scope": null,
"params": {
"sessionId": "demo",
"expectedWorkerId": "fly-a",
"role": "agent",
"supportedMajors": [
1
]
},
"digest": "0c07677a45178d8680dddbeec56d4e09f6ccbfe5fe77d9d4ff2fb07b369febaf"
}
],
"pairs": [
{
"name": "the same operation retried on a new bus call",
"left": {
"method": "Environment.Advance",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"batchId": "batch-41",
"controls": [
{
"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
}
]
}
]
}
},
"right": {
"method": "Environment.Advance",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"batchId": "batch-41",
"controls": [
{
"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
}
]
}
]
}
},
"workerId": "world",
"sameKey": true,
"sameBody": true,
"reason": "a retry reuses the requestId and body; only the callId changes, and the callId is not in either digest"
},
{
"name": "the same batch id with altered controls",
"left": {
"method": "Environment.Advance",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"batchId": "batch-41",
"controls": [
{
"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
}
]
}
]
}
},
"right": {
"method": "Environment.Advance",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"batchId": "batch-41",
"controls": [
{
"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": 1.0
},
{
"id": "trigger",
"value": 0.0
}
]
}
]
}
},
"workerId": "world",
"sameKey": true,
"sameBody": false,
"reason": "same key, changed body: CONFLICT, never a second world mutation"
},
{
"name": "params written with their keys in another order",
"left": {
"method": "Agent.Prepare",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"agentId": "fly-a",
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
"interval": {
"numerator": "50000000",
"denominator": "3"
},
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
"preStepStimulations": [
{
"id": "stim-1",
"kindId": "sugar",
"durationMs": 50.0
}
]
}
},
"right": {
"method": "Agent.Prepare",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"preStepStimulations": [
{
"id": "stim-1",
"kindId": "sugar",
"durationMs": 50.0
}
],
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
"interval": {
"numerator": "50000000",
"denominator": "3"
},
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
"agentId": "fly-a"
}
},
"workerId": "fly-a",
"sameKey": true,
"sameBody": true,
"reason": "RFC 8785 sorts keys, so serialization order is not a body change"
},
{
"name": "the same body one step later",
"left": {
"method": "Agent.Prepare",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"agentId": "fly-a",
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
"interval": {
"numerator": "50000000",
"denominator": "3"
},
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
"preStepStimulations": [
{
"id": "stim-1",
"kindId": "sugar",
"durationMs": 50.0
}
]
}
},
"right": {
"method": "Agent.Prepare",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "42"
},
"params": {
"agentId": "fly-a",
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
"interval": {
"numerator": "50000000",
"denominator": "3"
},
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
"preStepStimulations": [
{
"id": "stim-1",
"kindId": "sugar",
"durationMs": 50.0
}
]
}
},
"workerId": "fly-a",
"sameKey": false,
"sameBody": false,
"reason": "the step is part of both the key and the body"
},
{
"name": "the same body on another worker",
"left": {
"method": "Agent.Prepare",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"agentId": "fly-a",
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
"interval": {
"numerator": "50000000",
"denominator": "3"
},
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
"preStepStimulations": [
{
"id": "stim-1",
"kindId": "sugar",
"durationMs": 50.0
}
]
}
},
"right": {
"method": "Agent.Prepare",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"agentId": "fly-a",
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
"interval": {
"numerator": "50000000",
"denominator": "3"
},
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
"preStepStimulations": [
{
"id": "stim-1",
"kindId": "sugar",
"durationMs": 50.0
}
]
}
},
"workerId": "fly-a",
"rightWorkerId": "fly-b",
"sameKey": false,
"sameBody": true,
"reason": "the worker is part of the key, not of the body"
}
],
"rejected": [
{
"name": "a body carrying a bus callId",
"method": "Agent.Prepare",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"callId": "call-2",
"agentId": "fly-a"
},
"reason": "the canonical body excludes bus callIds"
},
{
"name": "a body carrying a delivery id",
"method": "Agent.Prepare",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"deliveryId": "dlv-7"
},
"reason": "the canonical body excludes deliveryIds"
},
{
"name": "a body carrying an owner token",
"method": "Agent.Commit",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"nextInput": {
"frame": {
"ownerId": "own-3"
}
}
},
"reason": "the canonical body excludes owner tokens"
},
{
"name": "a body carrying a pinned service incarnation",
"method": "Agent.Prepare",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"expectedIncarnation": "svc-1"
},
"reason": "route pinning is transport state, not domain state"
},
{
"name": "an empty method",
"method": "",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {},
"reason": "methods are 1..=128 printable ASCII characters"
}
]
}

View file

@ -0,0 +1,273 @@
{
"description": "Checked rational arithmetic and the step-v1 section 5 tick accumulator.",
"accumulator": [
{
"name": "a synthetic 60 Hz environment on a 1 ms model tick",
"stepDuration": {
"numerator": "50000000",
"denominator": "3"
},
"tickDuration": {
"numerator": "1000000",
"denominator": "1"
},
"steps": [
{
"ticks": "16",
"remainder": {
"numerator": "2000000",
"denominator": "3"
}
},
{
"ticks": "17",
"remainder": {
"numerator": "1000000",
"denominator": "3"
}
},
{
"ticks": "17",
"remainder": {
"numerator": "0",
"denominator": "1"
}
}
],
"totalTicks": "50",
"reason": "16, 17, 17 and a remainder of zero after three steps"
},
{
"name": "a whole millisecond cadence never accumulates a remainder",
"stepDuration": {
"numerator": "16000000",
"denominator": "1"
},
"tickDuration": {
"numerator": "1000000",
"denominator": "1"
},
"steps": [
{
"ticks": "16",
"remainder": {
"numerator": "0",
"denominator": "1"
}
},
{
"ticks": "16",
"remainder": {
"numerator": "0",
"denominator": "1"
}
}
],
"totalTicks": "32",
"reason": ""
},
{
"name": "a step shorter than one tick advances nothing and keeps the remainder",
"stepDuration": {
"numerator": "500000",
"denominator": "1"
},
"tickDuration": {
"numerator": "1000000",
"denominator": "1"
},
"steps": [
{
"ticks": "0",
"remainder": {
"numerator": "500000",
"denominator": "1"
}
},
{
"ticks": "1",
"remainder": {
"numerator": "0",
"denominator": "1"
}
}
],
"totalTicks": "1",
"reason": "the fractional period is carried, not rounded"
}
],
"add": [
{
"a": {
"numerator": "0",
"denominator": "1"
},
"b": {
"numerator": "50000000",
"denominator": "3"
},
"sum": {
"numerator": "50000000",
"denominator": "3"
}
},
{
"a": {
"numerator": "1",
"denominator": "3"
},
"b": {
"numerator": "1",
"denominator": "6"
},
"sum": {
"numerator": "1",
"denominator": "2"
}
},
{
"a": {
"numerator": "18446744073709551615",
"denominator": "1"
},
"b": {
"numerator": "1",
"denominator": "2"
},
"error": "reduced value does not fit U64"
},
{
"a": {
"numerator": "18446744073709551615",
"denominator": "18446744073709551614"
},
"b": {
"numerator": "18446744073709551613",
"denominator": "18446744073709551611"
},
"error": "the cross-multiplied numerators sum past 2^128",
"reason": "two reduced fractions near the U64 maximum: every multiplication fits u128, their sum does not, and the arithmetic must refuse rather than wrap"
}
],
"subtract": [
{
"a": {
"numerator": "50000000",
"denominator": "3"
},
"b": {
"numerator": "50000000",
"denominator": "3"
},
"difference": {
"numerator": "0",
"denominator": "1"
}
},
{
"a": {
"numerator": "1",
"denominator": "2"
},
"b": {
"numerator": "1",
"denominator": "3"
},
"difference": {
"numerator": "1",
"denominator": "6"
}
},
{
"a": {
"numerator": "0",
"denominator": "1"
},
"b": {
"numerator": "1",
"denominator": "2"
},
"error": "subtraction would be negative"
},
{
"a": {
"numerator": "18446744073709551615",
"denominator": "18446744073709551614"
},
"b": {
"numerator": "1",
"denominator": "18446744073709551611"
},
"error": "the reduced difference does not fit U64",
"reason": "large denominators reach the reduction limit rather than the subtraction one"
}
],
"multiply": [
{
"a": {
"numerator": "1000000",
"denominator": "1"
},
"k": "17",
"product": {
"numerator": "17000000",
"denominator": "1"
}
},
{
"a": {
"numerator": "0",
"denominator": "1"
},
"k": "1000",
"product": {
"numerator": "0",
"denominator": "1"
}
},
{
"a": {
"numerator": "18446744073709551615",
"denominator": "1"
},
"k": "2",
"error": "reduced value does not fit U64"
}
],
"compare": [
{
"a": {
"numerator": "0",
"denominator": "1"
},
"b": {
"numerator": "1000000",
"denominator": "1"
},
"ordering": "less"
},
{
"a": {
"numerator": "1",
"denominator": "3"
},
"b": {
"numerator": "1",
"denominator": "3"
},
"ordering": "equal",
"reason": "equal values compare equal; an unreduced 2/6 never reaches a comparison, because it never parses"
},
{
"a": {
"numerator": "50000000",
"denominator": "3"
},
"b": {
"numerator": "1000000",
"denominator": "1"
},
"ordering": "greater"
}
]
}

View file

@ -0,0 +1,77 @@
{
"description": "Byte sequences every implementation must refuse before validation.",
"cases": [
{
"name": "duplicate key at the top level",
"type": "Scope",
"base64": "eyJzZXNzaW9uSWQiOiJkZW1vIiwiZXBvY2giOiJlcG9jaC0xIiwic3RlcCI6IjEiLCJzdGVwIjoiMiJ9",
"reason": "duplicate JSON keys are refused at any depth"
},
{
"name": "duplicate key inside a nested object",
"type": "TypedValue",
"base64": "eyJzY2hlbWEiOnsiaWQiOiJhLnYxIiwidmVyc2lvbiI6MSwiZGlnZXN0IjoiYmJhNzk1MWMwNDY2NGNkNDliYjZlZWQxZGE2ZGMxYTRlMTdlOTg5ZTc4N2JmMmU3MmY1OTMzYTVjNjE3MTM3MiJ9LCJ2YWx1ZSI6eyJ4IjoxLCJ4IjoyfX0=",
"reason": "duplicate JSON keys are refused at any depth"
},
{
"name": "invalid UTF-8 in a string",
"type": "Scope",
"base64": "eyJzZXNzaW9uSWQiOiJkZf9tbyIsImVwb2NoIjoiZXBvY2gtMSIsInN0ZXAiOiIxIn0=",
"reason": "the envelope is UTF-8"
},
{
"name": "invalid UTF-8 in a key",
"type": "Scope",
"base64": "eyJzZXNzaW9u/0lkIjoiZGVtbyIsImVwb2NoIjoiZXBvY2gtMSIsInN0ZXAiOiIxIn0=",
"reason": "the envelope is UTF-8"
},
{
"name": "NaN literal",
"type": "Stimulus",
"base64": "eyJpZCI6InN0aW0tMSIsImtpbmRJZCI6InN1Z2FyIiwiZHVyYXRpb25NcyI6TmFOfQ==",
"reason": "NaN and Infinity are not JSON"
},
{
"name": "Infinity literal",
"type": "Stimulus",
"base64": "eyJpZCI6InN0aW0tMSIsImtpbmRJZCI6InN1Z2FyIiwiZHVyYXRpb25NcyI6SW5maW5pdHl9",
"reason": "NaN and Infinity are not JSON"
},
{
"name": "number that overflows a double",
"type": "Stimulus",
"base64": "eyJpZCI6InN0aW0tMSIsImtpbmRJZCI6InN1Z2FyIiwiZHVyYXRpb25NcyI6MWU5OTl9",
"reason": "a non-finite number never survives parsing"
},
{
"name": "integer past the exact double range",
"type": "Stimulus",
"base64": "eyJpZCI6InN0aW0tMSIsImtpbmRJZCI6InN1Z2FyIiwiZHVyYXRpb25NcyI6OTAwNzE5OTI1NDc0MDk5M30=",
"reason": "canonical JSON cannot encode it exactly; counters are U64 strings"
},
{
"name": "trailing data after the object",
"type": "Scope",
"base64": "eyJzZXNzaW9uSWQiOiJkZW1vIiwiZXBvY2giOiJlcG9jaC0xIiwic3RlcCI6IjEifSB7fQ==",
"reason": "one value per payload"
},
{
"name": "truncated object",
"type": "Scope",
"base64": "eyJzZXNzaW9uSWQiOiJkZW1vIiwiZXBvY2giOiJlcG9jaC0xIg==",
"reason": "partial JSON is refused"
},
{
"name": "empty payload",
"type": "Scope",
"base64": "",
"reason": "an empty payload is not a JSON object"
},
{
"name": "a bare array",
"type": "Scope",
"base64": "W10=",
"reason": "a payload is an object"
}
]
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,185 @@
{
"description": "seed-derivation-v1 test vectors. Both languages must reproduce every seed.",
"algorithm": "seed-derivation-v1",
"prefix": "flybrain/seed-derivation-v1",
"materialTemplate": "<prefix>\\n<masterSeed>\\n<agentId>\\n",
"rule": "SHA-256 of the material, read as eight big-endian u32 lanes; the first nonzero lane is the seed as a two's-complement i32.",
"vectors": [
{
"masterSeed": "0",
"agentId": "fly-a",
"material": "flybrain/seed-derivation-v1\n0\nfly-a\n",
"materialDigest": "6cf7c34a422f4cdd890c64d0ef5a072e4f6ddc2ee18e83ee24a5f2214f4960d1",
"seed": 1828176714
},
{
"masterSeed": "0",
"agentId": "fly-b",
"material": "flybrain/seed-derivation-v1\n0\nfly-b\n",
"materialDigest": "48a52f4069cfca001ba5ee6ae870dccb5a5bd59d61babf333a01749b6a8f4025",
"seed": 1218785088
},
{
"masterSeed": "0",
"agentId": "fly-c",
"material": "flybrain/seed-derivation-v1\n0\nfly-c\n",
"materialDigest": "53a83c2d0cfce5a3356aab3b0b73a270201a2dff7816168a4167c71a32d863fe",
"seed": 1403534381
},
{
"masterSeed": "0",
"agentId": "fly-d",
"material": "flybrain/seed-derivation-v1\n0\nfly-d\n",
"materialDigest": "f202513f32fe6070eee65ad72027490f9218b86dc8cf74e4e3563f02d532ea94",
"seed": -234729153
},
{
"masterSeed": "1",
"agentId": "fly-a",
"material": "flybrain/seed-derivation-v1\n1\nfly-a\n",
"materialDigest": "6eab9d6d6d002ffc0e2cdf2d64dd8226f90c391e45641512d14edbb244225698",
"seed": 1856740717
},
{
"masterSeed": "1",
"agentId": "fly-b",
"material": "flybrain/seed-derivation-v1\n1\nfly-b\n",
"materialDigest": "00115cb341d839490c5485462fd7eedd8b6baeda3748054fcba0d1558e471a6c",
"seed": 1137843
},
{
"masterSeed": "1",
"agentId": "fly-c",
"material": "flybrain/seed-derivation-v1\n1\nfly-c\n",
"materialDigest": "af97482074d6d36e5f5920aaa1d87f02365045f3712300dbd243584f2cb4a64c",
"seed": -1349040096
},
{
"masterSeed": "1",
"agentId": "fly-d",
"material": "flybrain/seed-derivation-v1\n1\nfly-d\n",
"materialDigest": "1081b24880040dcc4d442f88451513e4fbcf8dae9ccb79329dd289fabbc7938b",
"seed": 276935240
},
{
"masterSeed": "42",
"agentId": "fly-a",
"material": "flybrain/seed-derivation-v1\n42\nfly-a\n",
"materialDigest": "f4f9f271d53e94c38c6260b99840513f9eb70e5bb442d63345e217185e6ba9f6",
"seed": -184946063
},
{
"masterSeed": "42",
"agentId": "fly-b",
"material": "flybrain/seed-derivation-v1\n42\nfly-b\n",
"materialDigest": "1feb285e834138ec823ffea696fbe5fb3c211f349ce17140e25286d06c56ece8",
"seed": 535504990
},
{
"masterSeed": "42",
"agentId": "fly-c",
"material": "flybrain/seed-derivation-v1\n42\nfly-c\n",
"materialDigest": "b5a251ed4521f91eab1d8a6813880794c5c6706eafc137d8ebc169f743c60eed",
"seed": -1247653395
},
{
"masterSeed": "42",
"agentId": "fly-d",
"material": "flybrain/seed-derivation-v1\n42\nfly-d\n",
"materialDigest": "e36d6e40f27a4ffe473351d4bd01154da2efb3c3038d88f3aa48f77963d465c6",
"seed": -479367616
},
{
"masterSeed": "9223372036854775808",
"agentId": "fly-a",
"material": "flybrain/seed-derivation-v1\n9223372036854775808\nfly-a\n",
"materialDigest": "d59f513b6fdce1646ed13907302e0cb3ca4696738dd7df38e9581397508ec933",
"seed": -710979269
},
{
"masterSeed": "9223372036854775808",
"agentId": "fly-b",
"material": "flybrain/seed-derivation-v1\n9223372036854775808\nfly-b\n",
"materialDigest": "c8de8ca0279829c438fa19c07f05768cfe050e8c21bdaa0fe3c68f8048b4e1a3",
"seed": -924939104
},
{
"masterSeed": "9223372036854775808",
"agentId": "fly-c",
"material": "flybrain/seed-derivation-v1\n9223372036854775808\nfly-c\n",
"materialDigest": "354ad1ec388aa7be6136151d0f6281cc17279b62e95d458c96f392aaac40ee62",
"seed": 894095852
},
{
"masterSeed": "9223372036854775808",
"agentId": "fly-d",
"material": "flybrain/seed-derivation-v1\n9223372036854775808\nfly-d\n",
"materialDigest": "58e61deb4777b7702bdcd7edf5a5bcd0d51273699cc1051c7fbc9a5ffedd9e15",
"seed": 1491475947
},
{
"masterSeed": "18446744073709551615",
"agentId": "fly-a",
"material": "flybrain/seed-derivation-v1\n18446744073709551615\nfly-a\n",
"materialDigest": "f888eb95bcab276936fce5f72df3a0741e36da26722f43cc2a974932dfd42cd1",
"seed": -125244523
},
{
"masterSeed": "18446744073709551615",
"agentId": "fly-b",
"material": "flybrain/seed-derivation-v1\n18446744073709551615\nfly-b\n",
"materialDigest": "d343df049e8ef71617e9af7a321cf498d61083c50229f2d6852077670c86acbd",
"seed": -750526716
},
{
"masterSeed": "18446744073709551615",
"agentId": "fly-c",
"material": "flybrain/seed-derivation-v1\n18446744073709551615\nfly-c\n",
"materialDigest": "88016b220245b431a4eb510d31b643475bb2496130b65ae81b165acd5a899292",
"seed": -2013172958
},
{
"masterSeed": "18446744073709551615",
"agentId": "fly-d",
"material": "flybrain/seed-derivation-v1\n18446744073709551615\nfly-d\n",
"materialDigest": "d9b5bee718c87599c9ef0b3c16f361dadf714a33f7dd1209a21108f4df700b16",
"seed": -642400537
}
],
"composition": {
"masterSeed": "42",
"agentIds": [
"fly-a",
"fly-b",
"fly-c",
"fly-d"
],
"seeds": [
-184946063,
535504990,
-1247653395,
-479367616
],
"reason": "independent per-agent seeds from one recorded master seed and stable agent ids"
},
"invalid": [
{
"masterSeed": "0",
"agentId": "Fly-A",
"reason": "an agent id is an Id: lowercase"
},
{
"masterSeed": "0",
"agentId": "",
"reason": "an agent id is 1..=64 characters"
},
{
"masterSeed": "0",
"agentIds": [
"fly-a",
"fly-a"
],
"reason": "a composition with a repeated agent id is refused rather than silently sharing a seed"
}
]
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,275 @@
//! Canonical JSON (RFC 8785) and the digest rules of ipc-v1 section 5.
//!
//! One serialization, two languages: keys sorted by UTF-16 code unit, numbers printed by the
//! ECMAScript `Number::toString` algorithm (so a JavaScript `JSON.stringify` over the same
//! sorted tree produces the same bytes), strings escaped the way `JSON.stringify` escapes
//! them, no insignificant whitespace. A digest is the SHA-256 of those bytes, lowercase hex.
//!
//! A JSON number is canonicalizable when it is finite and, if it is integral, no larger in
//! magnitude than 2^53-1. Integers past that range are refused rather than rounded: every
//! counter and clock in these contracts is a `U64` decimal string, so a large JSON number is
//! a schema error. The rule is stated on the value, not on how it was written, because
//! `JSON.parse` cannot tell `1e21` from `1000000000000000000000`, and two implementations
//! that disagree about one number do not agree about any digest.
use serde_json::{Number, Value};
use sha2::{Digest as _, Sha256};
use crate::scalar::{Result, Scope, err, wire_err};
/// The largest integer a double represents exactly.
pub const MAX_EXACT_INTEGER: i64 = 9_007_199_254_740_991;
/// The bus envelope ceiling every domain message must also fit (bus-v1 section 4).
pub const MAX_ENVELOPE_BYTES: usize = flybus::wire::MAX_ENVELOPE_BYTES;
/// The `f64` a JSON number denotes, or `None` if it is not canonicalizable: not finite, or an
/// integral value outside the exactly representable integer range.
pub fn finite_double(n: &Number) -> Option<f64> {
let value = n.as_f64().filter(|v| v.is_finite())?;
if value.fract() == 0.0 && value.abs() > MAX_EXACT_INTEGER as f64 {
return None;
}
Some(value)
}
/// `String(number)` for a finite double, the ECMAScript algorithm RFC 8785 requires.
fn number_to_string(value: f64) -> String {
if value == 0.0 {
// Covers -0.0, which `JSON.stringify` prints as "0".
return "0".to_owned();
}
let mut buffer = ryu_js::Buffer::new();
buffer.format(value).to_owned()
}
/// Escapes one string the way `JSON.stringify` does.
fn write_string(out: &mut String, s: &str) {
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\u{08}' => out.push_str("\\b"),
'\u{09}' => out.push_str("\\t"),
'\u{0a}' => out.push_str("\\n"),
'\u{0c}' => out.push_str("\\f"),
'\u{0d}' => out.push_str("\\r"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
}
/// Sorts object keys by UTF-16 code unit, as RFC 8785 section 3.2.3 specifies.
fn utf16_key(key: &str) -> Vec<u16> {
key.encode_utf16().collect()
}
/// The canonical JSON text of `value`.
pub fn canonicalize(value: &Value) -> Result<String> {
let mut out = String::new();
write_value(&mut out, value)?;
Ok(out)
}
/// The canonical JSON bytes of `value`.
pub fn canonical_bytes(value: &Value) -> Result<Vec<u8>> {
canonicalize(value).map(String::into_bytes)
}
fn write_value(out: &mut String, value: &Value) -> Result<()> {
match value {
Value::Null => out.push_str("null"),
Value::Bool(true) => out.push_str("true"),
Value::Bool(false) => out.push_str("false"),
Value::Number(n) => {
let d = finite_double(n).ok_or_else(|| {
wire_err(format!(
"canonical JSON: {n} is not a finite number in the exact double range"
))
})?;
out.push_str(&number_to_string(d));
}
Value::String(s) => write_string(out, s),
Value::Array(items) => {
out.push('[');
for (i, item) in items.iter().enumerate() {
if i > 0 {
out.push(',');
}
write_value(out, item)?;
}
out.push(']');
}
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort_by_cached_key(|k| utf16_key(k));
out.push('{');
for (i, key) in keys.iter().enumerate() {
if i > 0 {
out.push(',');
}
write_string(out, key);
out.push(':');
write_value(out, &map[key.as_str()])?;
}
out.push('}');
}
}
Ok(())
}
/// Lowercase hex SHA-256.
pub fn sha256_hex(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let mut out = String::with_capacity(64);
for byte in digest {
out.push_str(&format!("{byte:02x}"));
}
out
}
/// The canonical digest of a JSON value: SHA-256 over its canonical JSON bytes.
pub fn digest_of(value: &Value) -> Result<String> {
canonical_bytes(value).map(|bytes| sha256_hex(&bytes))
}
/// Parses JSON strictly: duplicate keys at any depth, invalid UTF-8, non-finite numbers and
/// trailing bytes are refused. The bus reader, reused so both layers agree byte for byte.
pub fn parse_strict(bytes: &[u8]) -> Result<Value> {
flybus::wire::parse_json_strict(bytes).map_err(|e| wire_err(e.0))
}
/// Refuses a domain payload that does not fit the bus envelope ceiling.
///
/// The check is on canonical bytes, and the caller passes the overhead the surrounding
/// envelope adds, so a payload that only fits without its envelope still fails.
pub fn require_envelope_fit(value: &Value, envelope_overhead: usize) -> Result<usize> {
let len = canonicalize(value)?.len();
let total = len + envelope_overhead;
if total > MAX_ENVELOPE_BYTES {
return err(format!(
"envelope: {total} bytes exceeds the {MAX_ENVELOPE_BYTES}-byte maximum"
));
}
Ok(total)
}
// ---------------------------------------------------------------------------------------------
// Operation keys and canonical bodies
/// The keys that belong to the bus, never to a domain body (ipc-v1 section 5: the canonical
/// body "excludes changing bus callIds, deliveryIds and owner tokens").
pub const BUS_ONLY_KEYS: &[&str] = &[
"callId",
"deliveryId",
"ownerId",
"ownerIds",
"deliveryIds",
"requestDeliveryId",
"expectedIncarnation",
"serviceIncarnation",
"connectionId",
"topicSequence",
"subscriptionId",
];
/// Fails if any bus-only key appears anywhere in `value`.
pub fn reject_bus_identities(value: &Value) -> Result<()> {
match value {
Value::Object(map) => {
for (key, inner) in map {
if BUS_ONLY_KEYS.contains(&key.as_str()) {
return err(format!(
"canonical body: {key:?} is a bus identity and never part of a domain body"
));
}
reject_bus_identities(inner)?;
}
Ok(())
}
Value::Array(items) => {
for item in items {
reject_bus_identities(item)?;
}
Ok(())
}
_ => Ok(()),
}
}
/// `(sessionId, epoch, step, method, workerId)`: the operation key of a step mutation.
///
/// There is at most one Prepare, Commit or Advance for one key (ipc-v1 section 5). The key
/// deliberately does not contain the requestId: a changed id for an existing key is CONFLICT,
/// which can only be detected if the key is the same.
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct OperationKey {
pub scope: Scope,
pub method: String,
pub worker_id: String,
}
impl OperationKey {
pub fn new(scope: Scope, method: &str, worker_id: &str) -> Result<OperationKey> {
let key = OperationKey {
scope,
method: method.to_owned(),
worker_id: worker_id.to_owned(),
};
key.validate()?;
Ok(key)
}
pub fn validate(&self) -> Result<()> {
use crate::scalar::DomainType;
self.scope.validate()?;
if !flybus::wire::is_method(&self.method) {
return err("OperationKey: method must be 1..=128 printable ASCII characters");
}
if !crate::scalar::is_id(&self.worker_id) {
return err("OperationKey: workerId is not a valid id");
}
Ok(())
}
pub fn to_json(&self) -> Value {
use crate::scalar::DomainType;
crate::scalar::obj(vec![
("scope", self.scope.to_json()),
("method", self.method.clone().into()),
("workerId", self.worker_id.clone().into()),
])
}
/// The canonical digest of the key, for a deduplication table that stores digests.
pub fn digest(&self) -> Result<String> {
digest_of(&self.to_json())
}
}
/// The canonical body of a domain operation: method, scope and validated params.
///
/// Two calls of the same operation key whose body digests differ are CONFLICT; two calls with
/// the same digest are the same operation, whatever bus callId carried them.
pub fn canonical_body(method: &str, scope: Option<&Scope>, params: &Value) -> Result<Value> {
if !flybus::wire::is_method(method) {
return err("canonical body: method must be 1..=128 printable ASCII characters");
}
if !params.is_object() {
return err("canonical body: params must be an object");
}
reject_bus_identities(params)?;
Ok(crate::scalar::obj(vec![
("method", method.into()),
("scope", Scope::nullable_to_json(scope)),
("params", params.clone()),
]))
}
/// The canonical body digest of a domain operation.
pub fn body_digest(method: &str, scope: Option<&Scope>, params: &Value) -> Result<String> {
digest_of(&canonical_body(method, scope, params)?)
}

View file

@ -0,0 +1,375 @@
//! `FLYSESS1`: the envelope layout of `docs/design/session-framework/checkpoint-envelope-v1.md`.
//!
//! This is the layout half of the specification, not the store: it lays out a header, a
//! canonical-JSON manifest, a payload table and the payload bytes, and it reads one back.
//! Writing generations, fsyncing and committing a manifest belong to the STATE-01 store slice.
//! `FLYSIM01` is a different format with a different magic and is not touched by any of this.
use serde_json::Value;
use sha2::{Digest as _, Sha256};
use crate::canonical;
use crate::scalar::{Result, err, is_id};
/// Envelope magic. Eight ASCII bytes, distinct from `FLYSIM01`.
pub const MAGIC: &[u8; 8] = b"FLYSESS1";
/// Footer magic, so a truncated file cannot look complete.
pub const FOOTER_MAGIC: &[u8; 8] = b"FLYSESSF";
/// Envelope version, in the header and in the manifest.
pub const VERSION: u32 = 1;
/// Fixed header size in bytes.
pub const HEADER_BYTES: usize = 32;
/// One payload table entry: a 64-byte name field, offset, length and a 32-byte digest.
pub const TABLE_ENTRY_BYTES: usize = 112;
/// Payload name field width.
pub const NAME_BYTES: usize = 64;
/// Footer size in bytes: total length, whole-prefix digest and the footer magic.
pub const FOOTER_BYTES: usize = 48;
/// Payloads start on an eight-byte boundary.
pub const ALIGNMENT: u64 = 8;
/// Payloads per envelope.
pub const MAX_PAYLOADS: usize = 64;
/// One payload's table entry.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PayloadEntry {
/// An `Id`: the new envelope widens the historical letters-only chunk name deliberately,
/// which is why it is a new version and not an extension of `FLYSIM01`.
pub name: String,
pub offset: u64,
pub byte_length: u64,
/// SHA-256 of exactly `byte_length` bytes at `offset`.
pub digest: [u8; 32],
}
/// A laid-out envelope: where everything is, before any bytes are written.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Layout {
pub manifest_offset: u64,
pub manifest_bytes: u32,
pub table_offset: u64,
pub entries: Vec<PayloadEntry>,
pub footer_offset: u64,
pub total_bytes: u64,
}
fn align_up(value: u64) -> u64 {
value.div_ceil(ALIGNMENT) * ALIGNMENT
}
/// Lays out the envelope for one manifest and a list of `(name, bytes)` payloads.
pub fn layout(manifest: &Value, payloads: &[(String, Vec<u8>)]) -> Result<Layout> {
if payloads.len() > MAX_PAYLOADS {
return err("checkpoint envelope: at most 64 payloads");
}
crate::scalar::require_unique(
payloads.iter().map(|(name, _)| name.as_str()),
"checkpoint envelope: payload names",
)?;
for (name, _) in payloads {
if !is_id(name) {
return err(format!(
"checkpoint envelope: payload name {name:?} is not an Id"
));
}
}
let manifest_text = canonical::canonicalize(manifest)?;
let manifest_bytes = u32::try_from(manifest_text.len())
.map_err(|_| crate::scalar::wire_err("checkpoint envelope: manifest is too large"))?;
let manifest_offset = HEADER_BYTES as u64;
let table_offset = align_up(manifest_offset + u64::from(manifest_bytes));
let mut offset = align_up(table_offset + (payloads.len() * TABLE_ENTRY_BYTES) as u64);
let mut entries = Vec::with_capacity(payloads.len());
for (name, bytes) in payloads {
entries.push(PayloadEntry {
name: name.clone(),
offset,
byte_length: bytes.len() as u64,
digest: Sha256::digest(bytes).into(),
});
offset = align_up(offset + bytes.len() as u64);
}
Ok(Layout {
manifest_offset,
manifest_bytes,
table_offset,
entries,
footer_offset: offset,
total_bytes: offset + FOOTER_BYTES as u64,
})
}
/// Writes one envelope: header, manifest, payload table, payloads, footer.
pub fn encode(manifest: &Value, payloads: &[(String, Vec<u8>)]) -> Result<Vec<u8>> {
let layout = layout(manifest, payloads)?;
let manifest_text = canonical::canonicalize(manifest)?;
let mut out = vec![0u8; layout.footer_offset as usize];
out[0..8].copy_from_slice(MAGIC);
out[8..12].copy_from_slice(&VERSION.to_le_bytes());
out[12..16].copy_from_slice(&(HEADER_BYTES as u32).to_le_bytes());
out[16..20].copy_from_slice(&layout.manifest_bytes.to_le_bytes());
out[20..24].copy_from_slice(&(payloads.len() as u32).to_le_bytes());
out[24..28].copy_from_slice(&(layout.table_offset as u32).to_le_bytes());
out[28..32].copy_from_slice(&0u32.to_le_bytes());
let manifest_start = layout.manifest_offset as usize;
out[manifest_start..manifest_start + manifest_text.len()]
.copy_from_slice(manifest_text.as_bytes());
for (index, entry) in layout.entries.iter().enumerate() {
let base = layout.table_offset as usize + index * TABLE_ENTRY_BYTES;
out[base..base + entry.name.len()].copy_from_slice(entry.name.as_bytes());
let numbers = base + NAME_BYTES;
out[numbers..numbers + 8].copy_from_slice(&entry.offset.to_le_bytes());
out[numbers + 8..numbers + 16].copy_from_slice(&entry.byte_length.to_le_bytes());
out[numbers + 16..numbers + 48].copy_from_slice(&entry.digest);
}
for (entry, (_, bytes)) in layout.entries.iter().zip(payloads) {
let start = entry.offset as usize;
out[start..start + bytes.len()].copy_from_slice(bytes);
}
let digest: [u8; 32] = Sha256::digest(&out).into();
out.extend_from_slice(&layout.total_bytes.to_le_bytes());
out.extend_from_slice(&digest);
out.extend_from_slice(FOOTER_MAGIC);
Ok(out)
}
/// A decoded envelope.
#[derive(Clone, Debug, PartialEq)]
pub struct Envelope {
pub manifest: Value,
pub payloads: Vec<(String, Vec<u8>)>,
pub layout: Layout,
}
impl Envelope {
pub fn payload(&self, name: &str) -> Option<&[u8]> {
self.payloads
.iter()
.find(|(key, _)| key == name)
.map(|(_, bytes)| bytes.as_slice())
}
}
fn u32_at(bytes: &[u8], offset: usize) -> u32 {
u32::from_le_bytes([
bytes[offset],
bytes[offset + 1],
bytes[offset + 2],
bytes[offset + 3],
])
}
fn u64_at(bytes: &[u8], offset: usize) -> u64 {
let mut buf = [0u8; 8];
buf.copy_from_slice(&bytes[offset..offset + 8]);
u64::from_le_bytes(buf)
}
/// Reads and fully validates one envelope: magic, version, footer digest, table ordering,
/// alignment, bounds and every payload digest.
pub fn decode(bytes: &[u8]) -> Result<Envelope> {
if bytes.len() < HEADER_BYTES + FOOTER_BYTES {
return err("checkpoint envelope: shorter than a header plus a footer");
}
if &bytes[0..8] != MAGIC {
return err("checkpoint envelope: wrong magic (FLYSIM01 is a different format)");
}
if u32_at(bytes, 8) != VERSION {
return err("checkpoint envelope: unsupported version");
}
if u32_at(bytes, 12) as usize != HEADER_BYTES {
return err("checkpoint envelope: headerBytes must be 32");
}
if u32_at(bytes, 28) != 0 {
return err("checkpoint envelope: reserved header word must be zero");
}
let manifest_bytes = u32_at(bytes, 16) as usize;
let payload_count = u32_at(bytes, 20) as usize;
let table_offset = u32_at(bytes, 24) as u64;
if payload_count > MAX_PAYLOADS {
return err("checkpoint envelope: at most 64 payloads");
}
let footer_offset = bytes.len() - FOOTER_BYTES;
if &bytes[footer_offset + 40..] != FOOTER_MAGIC {
return err("checkpoint envelope: missing footer magic");
}
if u64_at(bytes, footer_offset) != bytes.len() as u64 {
return err("checkpoint envelope: footer length does not match the file");
}
let recorded = &bytes[footer_offset + 8..footer_offset + 40];
let computed: [u8; 32] = Sha256::digest(&bytes[..footer_offset]).into();
if recorded != computed {
return err("checkpoint envelope: footer digest does not match the contents");
}
let manifest_start = HEADER_BYTES;
let manifest_end = manifest_start + manifest_bytes;
if manifest_end > footer_offset {
return err("checkpoint envelope: manifest runs past the payload area");
}
let manifest = canonical::parse_strict(&bytes[manifest_start..manifest_end])?;
let canonical_manifest = canonical::canonicalize(&manifest)?;
if canonical_manifest.as_bytes() != &bytes[manifest_start..manifest_end] {
return err("checkpoint envelope: the manifest is not canonical JSON");
}
if table_offset != align_up(manifest_end as u64) {
return err("checkpoint envelope: the payload table is not at its laid-out offset");
}
let table_end = table_offset as usize + payload_count * TABLE_ENTRY_BYTES;
if table_end > footer_offset {
return err("checkpoint envelope: the payload table runs past the payload area");
}
let mut entries = Vec::with_capacity(payload_count);
let mut payloads = Vec::with_capacity(payload_count);
let mut previous_end = align_up(table_end as u64);
for index in 0..payload_count {
let base = table_offset as usize + index * TABLE_ENTRY_BYTES;
let name_field = &bytes[base..base + NAME_BYTES];
let length = name_field
.iter()
.position(|b| *b == 0)
.unwrap_or(NAME_BYTES);
if name_field[length..].iter().any(|b| *b != 0) {
return err("checkpoint envelope: a payload name has bytes after its terminator");
}
let name = std::str::from_utf8(&name_field[..length])
.map_err(|_| crate::scalar::wire_err("checkpoint envelope: payload name is not UTF-8"))?
.to_owned();
if !is_id(&name) {
return err(format!(
"checkpoint envelope: payload name {name:?} is not an Id"
));
}
let numbers = base + NAME_BYTES;
let offset = u64_at(bytes, numbers);
let byte_length = u64_at(bytes, numbers + 8);
let mut digest = [0u8; 32];
digest.copy_from_slice(&bytes[numbers + 16..numbers + 48]);
if offset != previous_end {
return err(format!(
"checkpoint envelope: payload {name:?} starts at {offset}, not at its aligned {previous_end}"
));
}
let end = offset
.checked_add(byte_length)
.ok_or_else(|| crate::scalar::wire_err("checkpoint envelope: payload overflows"))?;
if end > footer_offset as u64 {
return err(format!(
"checkpoint envelope: payload {name:?} runs past the payload area"
));
}
let payload = bytes[offset as usize..end as usize].to_vec();
let computed: [u8; 32] = Sha256::digest(&payload).into();
if computed != digest {
return err(format!(
"checkpoint envelope: payload {name:?} fails its digest"
));
}
previous_end = align_up(end);
entries.push(PayloadEntry {
name: name.clone(),
offset,
byte_length,
digest,
});
payloads.push((name, payload));
}
crate::scalar::require_unique(
entries.iter().map(|e| e.name.as_str()),
"checkpoint envelope: payload names",
)?;
if previous_end != footer_offset as u64 {
return err("checkpoint envelope: padding between the last payload and the footer");
}
Ok(Envelope {
manifest,
layout: Layout {
manifest_offset: manifest_start as u64,
manifest_bytes: manifest_bytes as u32,
table_offset,
entries,
footer_offset: footer_offset as u64,
total_bytes: bytes.len() as u64,
},
payloads,
})
}
/// The manifest fields state-media-v1 section 4 requires, checked as a set: a manifest that
/// omits one of them is not a complete checkpoint.
///
/// `helperState` and `environment` join the list under the 2026-09-22 amendment to
/// checkpoint-envelope-v1 section 3: the first has been in that section's table from the
/// start and was missing here, and the second is the holder of the world's own payload, which
/// the table named for every other participant and not for the environment.
pub const REQUIRED_MANIFEST_FIELDS: &[&str] = &[
"envelopeVersion",
"checkpointId",
"sourceScope",
"episodeId",
"worldTime",
"schedulerId",
"compositionDigest",
"portMap",
"compatibility",
"agents",
"coordinator",
"environment",
"helperState",
"payloads",
];
/// Checks the manifest's required field set and that its payload table mirrors the envelope's.
pub fn validate_manifest(envelope: &Envelope) -> Result<()> {
let map = envelope
.manifest
.as_object()
.ok_or_else(|| crate::scalar::wire_err("checkpoint manifest: must be an object"))?;
for field in REQUIRED_MANIFEST_FIELDS {
if !map.contains_key(*field) {
return err(format!("checkpoint manifest: missing {field:?}"));
}
}
if map.get("envelopeVersion").and_then(Value::as_u64) != Some(u64::from(VERSION)) {
return err("checkpoint manifest: envelopeVersion must be 1");
}
let listed = map
.get("payloads")
.and_then(Value::as_array)
.ok_or_else(|| crate::scalar::wire_err("checkpoint manifest: payloads must be an array"))?;
if listed.len() != envelope.layout.entries.len() {
return err("checkpoint manifest: payloads does not match the payload table");
}
for (declared, entry) in listed.iter().zip(&envelope.layout.entries) {
let name = declared.get("name").and_then(Value::as_str);
let length = declared
.get("byteLength")
.and_then(Value::as_str)
.and_then(crate::scalar::parse_u64);
let digest = declared.get("digest").and_then(Value::as_str);
if name != Some(entry.name.as_str()) {
return err("checkpoint manifest: payload name does not match the table");
}
if length != Some(entry.byte_length) {
return err(format!(
"checkpoint manifest: payload {:?} byteLength does not match the table",
entry.name
));
}
if digest != Some(hex(&entry.digest).as_str()) {
return err(format!(
"checkpoint manifest: payload {:?} digest does not match the table",
entry.name
));
}
}
Ok(())
}
/// Lowercase hex of a raw digest, the form the manifest records.
pub fn hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push_str(&format!("{byte:02x}"));
}
out
}

View file

@ -0,0 +1,414 @@
//! The extension methods of the 2026-09-23 amendments (RT-01a): environment slots and the
//! agent side of a declared rollback policy.
//!
//! `workers-v1` section 7 specifies them. They exist because the operator decided (2026-09-23)
//! that the live Game Boy fly runs on this framework rather than beside it, and its ratchet
//! rolls the game back to a saved slot while the brain continues. The shapes are generic: a
//! slot is an environment-held saved state named by an id, and a rollback is an agent
//! installing a new input and context under a new epoch without a tick. Which composition may
//! use them is a capability question, answered by `Worker.Hello`:
//!
//! - an environment that answers `Environment.SaveSlot` / `Environment.RestoreSlot` advertises
//! [`SLOTS_CAPABILITY`];
//! - an agent that answers `Agent.Rollback` advertises [`ROLLBACK_CAPABILITY`].
//!
//! A worker that does not advertise the capability answers `UNSUPPORTED`, mutation none. Nothing
//! Game Boy specific is in these payloads: the console lives in the registered schemas of
//! [`crate::gameboy`], carried inside `TypedValue`s.
use flybus::wire::Fields;
use serde_json::Value;
use crate::scalar::{DomainType, Result, Scope, TypedValue, err, is_digest, is_id, obj, u64_json};
use crate::workers::{AgentTelemetry, SensoryInput, WorldObservation};
/// The environment capability that carries `Environment.SaveSlot` and `Environment.RestoreSlot`.
pub const SLOTS_CAPABILITY: &str = "gameboy-slots-v1";
/// The agent capability that carries `Agent.Rollback`, and the policy id it applies.
pub const ROLLBACK_CAPABILITY: &str = "legacy-ratchet-rollback-v1";
/// The only rollback policy this contract defines.
pub const ROLLBACK_POLICY: &str = "legacy-ratchet-rollback-v1";
pub const METHOD_SAVE_SLOT: &str = "Environment.SaveSlot";
pub const METHOD_RESTORE_SLOT: &str = "Environment.RestoreSlot";
pub const METHOD_AGENT_ROLLBACK: &str = "Agent.Rollback";
/// Slots one environment may hold. Not a stated bound; recorded in the schema set.
pub const MAX_SLOTS: usize = 4;
fn policy_ok(policy: &str, what: &str) -> Result<()> {
if policy != ROLLBACK_POLICY {
return err(format!(
"{what}: policy must be {ROLLBACK_POLICY}, the only rollback policy defined"
));
}
Ok(())
}
// ---------------------------------------------------------------------------------------------
// Environment.SaveSlot
/// `Environment.SaveSlot` params. Scope is the committed boundary the slot records.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SaveSlotParams {
pub slot_id: String,
}
impl DomainType for SaveSlotParams {
const TYPE_NAME: &'static str = "SaveSlotParams";
fn from_json(value: &Value) -> Result<SaveSlotParams> {
let mut f = Fields::new(value, "SaveSlotParams")?;
let slot_id = f.id("slotId")?;
f.finish()?;
let p = SaveSlotParams { slot_id };
p.validate()?;
Ok(p)
}
fn to_json(&self) -> Value {
obj(vec![("slotId", self.slot_id.clone().into())])
}
fn validate(&self) -> Result<()> {
if !is_id(&self.slot_id) {
return err("SaveSlotParams: slotId is not a valid id");
}
Ok(())
}
}
/// `Environment.SaveSlot` result: which boundary the slot now holds, and the digest and length
/// of the saved state bytes, so a later restore can be tied to exactly this save.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SaveSlotResult {
pub slot_id: String,
pub boundary: u64,
pub state_digest: String,
pub byte_length: u64,
}
impl SaveSlotResult {
/// The slot records the committed boundary the call was scoped to.
pub fn validate_against_scope(&self, scope: &Scope) -> Result<()> {
self.validate()?;
if self.boundary != scope.step {
return err(format!(
"SaveSlotResult: boundary {} must be the scoped committed step {}",
self.boundary, scope.step
));
}
Ok(())
}
}
impl DomainType for SaveSlotResult {
const TYPE_NAME: &'static str = "SaveSlotResult";
fn from_json(value: &Value) -> Result<SaveSlotResult> {
let mut f = Fields::new(value, "SaveSlotResult")?;
let slot_id = f.id("slotId")?;
let boundary = f.u64_string("boundary")?;
let state_digest = f.string("stateDigest")?.to_owned();
let byte_length = f.u64_string("byteLength")?;
f.finish()?;
let r = SaveSlotResult {
slot_id,
boundary,
state_digest,
byte_length,
};
r.validate()?;
Ok(r)
}
fn to_json(&self) -> Value {
obj(vec![
("slotId", self.slot_id.clone().into()),
("boundary", u64_json(self.boundary)),
("stateDigest", self.state_digest.clone().into()),
("byteLength", u64_json(self.byte_length)),
])
}
fn validate(&self) -> Result<()> {
if !is_id(&self.slot_id) {
return err("SaveSlotResult: slotId is not a valid id");
}
if !is_digest(&self.state_digest) {
return err("SaveSlotResult: stateDigest must be 64 lowercase hex digits");
}
if self.byte_length == 0 {
return err("SaveSlotResult: byteLength must be positive");
}
Ok(())
}
}
// ---------------------------------------------------------------------------------------------
// Environment.RestoreSlot
/// `Environment.RestoreSlot` params. Scope is the NEW epoch at the committed boundary the
/// rollback is applied at; `priorEpoch` names the epoch the environment must currently be in.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RestoreSlotParams {
pub slot_id: String,
pub prior_epoch: String,
pub policy: String,
}
impl RestoreSlotParams {
/// A rollback moves to a new epoch: the prior epoch cannot be the scoped one.
pub fn validate_against_scope(&self, scope: &Scope) -> Result<()> {
self.validate()?;
if self.prior_epoch == scope.epoch {
return err("RestoreSlotParams: priorEpoch must differ from the scoped (new) epoch");
}
Ok(())
}
}
impl DomainType for RestoreSlotParams {
const TYPE_NAME: &'static str = "RestoreSlotParams";
fn from_json(value: &Value) -> Result<RestoreSlotParams> {
let mut f = Fields::new(value, "RestoreSlotParams")?;
let slot_id = f.id("slotId")?;
let prior_epoch = f.id("priorEpoch")?;
let policy = f.id("policy")?;
f.finish()?;
let p = RestoreSlotParams {
slot_id,
prior_epoch,
policy,
};
p.validate()?;
Ok(p)
}
fn to_json(&self) -> Value {
obj(vec![
("slotId", self.slot_id.clone().into()),
("priorEpoch", self.prior_epoch.clone().into()),
("policy", self.policy.clone().into()),
])
}
fn validate(&self) -> Result<()> {
if !is_id(&self.slot_id) {
return err("RestoreSlotParams: slotId is not a valid id");
}
if !is_id(&self.prior_epoch) {
return err("RestoreSlotParams: priorEpoch is not a valid id");
}
policy_ok(&self.policy, "RestoreSlotParams")
}
}
/// `Environment.RestoreSlot` result: the restored world at the same boundary number under the
/// new epoch. It ran no transition, so it carries no audio chunk.
#[derive(Clone, Debug, PartialEq)]
pub struct RestoreSlotResult {
pub slot_id: String,
pub committed_step: u64,
pub observation: WorldObservation,
}
impl RestoreSlotResult {
pub fn validate_against_scope(&self, scope: &Scope) -> Result<()> {
self.validate()?;
if self.committed_step != scope.step {
return err(format!(
"RestoreSlotResult: committedStep {} must be the scoped step {}",
self.committed_step, scope.step
));
}
Ok(())
}
}
impl DomainType for RestoreSlotResult {
const TYPE_NAME: &'static str = "RestoreSlotResult";
fn from_json(value: &Value) -> Result<RestoreSlotResult> {
let mut f = Fields::new(value, "RestoreSlotResult")?;
let slot_id = f.id("slotId")?;
let committed_step = f.u64_string("committedStep")?;
let observation = WorldObservation::from_json(f.value("observation")?)?;
f.finish()?;
let r = RestoreSlotResult {
slot_id,
committed_step,
observation,
};
r.validate()?;
Ok(r)
}
fn to_json(&self) -> Value {
obj(vec![
("slotId", self.slot_id.clone().into()),
("committedStep", u64_json(self.committed_step)),
("observation", self.observation.to_json()),
])
}
fn validate(&self) -> Result<()> {
if !is_id(&self.slot_id) {
return err("RestoreSlotResult: slotId is not a valid id");
}
self.observation.validate()?;
if self.observation.boundary != self.committed_step {
return err("RestoreSlotResult: the observation boundary must be the committed step");
}
if !self.observation.audio.is_empty() {
return err(
"RestoreSlotResult: a restored slot ran no transition and carries no audio chunk",
);
}
Ok(())
}
}
// ---------------------------------------------------------------------------------------------
// Agent.Rollback
/// `Agent.Rollback` params. Scope is the new epoch at the committed boundary; the agent must be
/// Ready at `priorEpoch` and that same step.
#[derive(Clone, Debug, PartialEq)]
pub struct AgentRollbackParams {
pub agent_id: String,
pub prior_epoch: String,
pub policy: String,
pub input: SensoryInput,
pub decision_context: TypedValue,
}
impl AgentRollbackParams {
/// The installed input is the restored boundary, which is the scoped step.
pub fn validate_against_scope(&self, scope: &Scope) -> Result<()> {
self.validate()?;
if self.prior_epoch == scope.epoch {
return err("AgentRollbackParams: priorEpoch must differ from the scoped (new) epoch");
}
if self.input.boundary != scope.step {
return err(format!(
"AgentRollbackParams: input.boundary {} must be the scoped step {}",
self.input.boundary, scope.step
));
}
Ok(())
}
}
impl DomainType for AgentRollbackParams {
const TYPE_NAME: &'static str = "AgentRollbackParams";
fn from_json(value: &Value) -> Result<AgentRollbackParams> {
let mut f = Fields::new(value, "AgentRollbackParams")?;
let agent_id = f.id("agentId")?;
let prior_epoch = f.id("priorEpoch")?;
let policy = f.id("policy")?;
let input = SensoryInput::from_json(f.value("input")?)?;
let decision_context = TypedValue::from_json(f.value("decisionContext")?)?;
f.finish()?;
let p = AgentRollbackParams {
agent_id,
prior_epoch,
policy,
input,
decision_context,
};
p.validate()?;
Ok(p)
}
fn to_json(&self) -> Value {
obj(vec![
("agentId", self.agent_id.clone().into()),
("priorEpoch", self.prior_epoch.clone().into()),
("policy", self.policy.clone().into()),
("input", self.input.to_json()),
("decisionContext", self.decision_context.to_json()),
])
}
fn validate(&self) -> Result<()> {
if !is_id(&self.agent_id) {
return err("AgentRollbackParams: agentId is not a valid id");
}
if !is_id(&self.prior_epoch) {
return err("AgentRollbackParams: priorEpoch is not a valid id");
}
policy_ok(&self.policy, "AgentRollbackParams")?;
self.input.validate()?;
self.decision_context.validate()
}
}
/// `Agent.Rollback` result: the same acknowledgment shape as a commit, at the same boundary.
#[derive(Clone, Debug, PartialEq)]
pub struct AgentRollbackResult {
pub agent_id: String,
pub committed_step: u64,
pub decision_context_digest: String,
pub telemetry: AgentTelemetry,
}
impl AgentRollbackResult {
pub fn validate_against_scope(&self, scope: &Scope) -> Result<()> {
self.validate()?;
if self.committed_step != scope.step {
return err(format!(
"AgentRollbackResult: committedStep {} must be the scoped step {}; a rollback runs no tick",
self.committed_step, scope.step
));
}
Ok(())
}
}
impl DomainType for AgentRollbackResult {
const TYPE_NAME: &'static str = "AgentRollbackResult";
fn from_json(value: &Value) -> Result<AgentRollbackResult> {
let mut f = Fields::new(value, "AgentRollbackResult")?;
let agent_id = f.id("agentId")?;
let committed_step = f.u64_string("committedStep")?;
let decision_context_digest = f.string("decisionContextDigest")?.to_owned();
let telemetry = AgentTelemetry::from_json(f.value("telemetry")?)?;
f.finish()?;
let r = AgentRollbackResult {
agent_id,
committed_step,
decision_context_digest,
telemetry,
};
r.validate()?;
Ok(r)
}
fn to_json(&self) -> Value {
obj(vec![
("agentId", self.agent_id.clone().into()),
("committedStep", u64_json(self.committed_step)),
(
"decisionContextDigest",
self.decision_context_digest.clone().into(),
),
("telemetry", self.telemetry.to_json()),
])
}
fn validate(&self) -> Result<()> {
if !is_id(&self.agent_id) {
return err("AgentRollbackResult: agentId is not a valid id");
}
if !is_digest(&self.decision_context_digest) {
return err(
"AgentRollbackResult: decisionContextDigest must be 64 lowercase hex digits",
);
}
self.telemetry.validate()
}
}

Some files were not shown because too many files have changed in this diff Show more