Repository navigation
Playback: the capability answer is fixed at boot; each play start applies the stored refusals itself - #1062
Merged
Conversation
… media follows) Refs #1044 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… recovery flight read it (wip, tests next) PlaybackSession holds an Arc<Caps> read once where an attempt starts (request_play_with_queue, and the native-audio conversion's dispatch); ResolveEnv, the rebase/recovery/retranscode/ABR captures and TranscodeSpec carry it, so the route's gates and the profile string PMS reads come from the same value instead of two reads of the process-wide answer. Refs #1044 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…d refusal reaching its flight) Caps A (a DTS row) and B (= A.without(Audio dts)) pick differently and profile differently with no global installed; the native-audio recovery flight's replacement /decision carries the ceiling learned mid-attempt. Refs #1044 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ost-lesson caps Refs #1044 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… none The app's play start (request_movie / request_episode / the content and queue paths) reads devcaps::caps() ONCE and hands it to the route's request_play* entry points, which begin the attempt on it. The planner, the resolve worker and every mid-film rebuild plan from that value (PlaybackSession::caps, ResolveEnv::caps); route no longer reads the process-wide answer to plan, and plan.rs holds no devcaps::caps( and no zero-argument is_dp_audio( / is_dp_audio_track( (the cfg(test) pick wrappers take the &Caps too). The pump's ConvertNativeAudio no longer re-reads the global after the lesson: dispatch_native_audio_conversion narrows the playing attempt by what the demuxer fed (Caps::without), so the replacement /decision carries the learned ceiling from the attempt's own value. The #1034 pin test (the refusal reaching its recovery flight) is unchanged. ci/check-deps.sh: the `globals` rule is widened to devcaps::caps( and the zero-argument is_dp_audio( / is_dp_audio_track( (qualified, or bare after an import; the _with forms do not match). It is a coverage expansion, not a shrink: no machine reads them, so zero ledger lines were seeded and PINNED_LEDGER_TOTALS is unchanged. A self-test covers the shapes. Refs #1044 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…, not only the conversion's The failed-open recovery chose the Original trial's rollback over the conversion when both applied, and only the conversion narrowed the attempt's Caps. The refusal was learned (the process-wide answer lost the audio), but the attempt kept the value the play started on, so the rollback's rebase profile, a later retry and a quality change offered native Opus again. The pipeline refused it again, `learn` returned false (known combination), and a manual Original ended on the read-out where main converted. recover_failed_source_route now narrows the attempt (PlaybackSession::narrow_attempt) before it picks the recovery, once per learned refusal; dispatch_native_audio_conversion no longer repeats it. The pump rig test a_native_audio_refusal_learned_during_the_original_trial_reaches_the_rollbacks_profile failed on the rollback's /decision profile before this and passes after. Also: the docs the change made false (learn_native_audio_refusal, devcaps::learn_refusal, the Caps struct, plex/mod.rs's allow list, webos5-port.md's codec-capability bullet, sans-io.md), and FlightRig now begins its attempt on the thread's devcaps::caps() so rig tests plan from the table they install rather than the pre-probe fallback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… does without HEVC The previous wording said "H.264 alone" for a set whose table lists no HEVC decoder, which drops the VP9 / AV1 copy lanes a table may still list. The direct-play list omits hevc (direct_play_video_codecs) and the transcode target's encode head is h264 (Caps::encode_vcodec). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…very lands Step 0 of the playback-refusal S1 lane. Two hostsim rig tests drive the real foreground actuator and the pump -> poll_foreground_load order of playback_tick: - an asynchronous type=18 after the Load call returned (route Started, then load_failed): the lifecycle reads Started and lets go (Idle); the pump's failed-open ladder fails the Engine. This is the case OpenRecovery will own. - a synchronous refusal (StartFailed together with load_failed, as threads.rs publishes it): the pump fails the Engine (read-out) AND the lifecycle then settles the same refusal as #1018's retry (Prepared, Engine retired). Two machines act on one refusal today; the test names say so, and the split that fixes it (the lifecycle keeps the Load call's own result) follows. launch_restore is the first half of refuse_restore, extracted unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng main's four arms
`open_failure_action` was a pure function over four booleans the pump gathered after it
had already run the install's learner and narrowed the attempt. It is now `OpenRecovery`
(`player/recovery.rs`): a tested `State + Event -> Effects` machine owned per request by
`PlaybackSession::recovery`, driven by `pump.rs` (`drive_open_recovery`) the way
`ForegroundLifecycle` is driven by `foreground.rs`.
Characterization only: the ladder is main's, in main's order (pending HLS->Original rollback,
the conversion of native audio the install just learned the pipeline refuses, a cold Auto
Original's HLS contingency, the read-out), and a native-audio STALL of a combination the
install already knew still only logs (that is the next change's bug, pinned by a test).
- Events `Refused{refusal, facts}`, `RefusalConfirmed{newly}`, `FlightLanded`, `FlightRefused`,
`FirstPicture`; effects `ConfirmRefusal`, `Rollback`, `StartFlight`, `StartAutoHls`,
`ShowError{refusal}`. The learner is IO, so the machine asks for it (`ConfirmRefusal`) and the
pump answers with whether the combination was new, in the same drain; the rung is picked on
that answer. `LoadRefusal` is the typed payload: `Refused{num}`, `TimedOut`, plus `Stalled` and
`SourceFailed` for the watchdog and the demuxer/transport branches that share the ladder.
- The attempt's capabilities moved into the machine (`start` plus the audio facets excluded, the
attempt always re-derived from `start`): `PlaybackSession::caps` and `narrow_attempt` are gone,
`attempt_caps()` delegates, and the narrowing happens inside `step` on a newly learned refusal,
on every arm, as `recover_failed_source_route` did.
- `FirstPicture` is stepped from the pump's decoded-frame branch; the machine is idempotent there.
- `ci/machines.ini` registers the machine; `player/recovery.rs` joins the workers/globals scope
of `ci/check-deps.sh` with no ledger line. `open_failure_action`'s unit test moved into the
machine's table tests with the same seven expectations.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…w ends on the read-out, not on "Buffering..."
TV run G (2026-10-26): a stored {opus, 6ch} refusal and a transcode fixture serving 1600 B
6-channel Opus again. `native_audio_stall_fired` returned false for a combination the install
already knew ("only logs"), so the pump neither failed the Engine nor showed a read-out, the
watchdog was spent, and the viewer stayed on the buffering spinner for good.
The stall is a refusal of the same class as `type=18` (`LoadRefusal::Stalled`). OpenRecovery
now runs the ladder on the learner's answer whatever the refusal: a known combination is a
refusal with no conversion left, so it takes the next rung (a pending Original trial's rollback,
a cold Auto Original's HLS contingency) and else `ShowError`.
Red first: the rig test (a stored {opus,6ch}, both AU queues holding data, nothing accepted for
NATIVE_AUDIO_STALL_MS, the watchdog's STALL line asserted as its precondition) failed with
"a stall of a combination the install already knew left the viewer on Connecting" before the
change and passes after it. player/CLAUDE.md's "a known refusal fails as before" held only for
the `type=18` path; it now says what both paths do.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…, owned by OpenRecovery Comment-only. `PlaybackSession::caps` no longer exists (the value lives in the request's `OpenRecovery`); three doc comments in plan.rs, devcaps.rs and session.rs still named the field or the route as the narrower. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ecycle's alone; the ladder owns refusals observed after the call returned The pump's failed-open ladder and the foreground lifecycle both acted on a synchronous refusal of a foreground-resume Load (StartFailed + load_failed): the pump set Error, then the lifecycle settled the same refusal as #1018's retry and retired the Engine. Split by who observes the refusal. The foreground actuator marks the Engine whose exact Load attempt it follows (Engine::foreground_follows_load, set where it starts or takes over following an attempt). The pump decides before stepping the machine: a failed Load on a followed Engine whose route_start_status is Failed is the Load call's own result, so it returns and the lifecycle's Failed settlement owns it. A refusal seen after the call returned, and a fresh play's synchronous refusal (no lifecycle behind it), stay the ladder's. The step-0 SYNC test is flipped to the desired behaviour (red first: the pump left Error), the ASYNC test is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-facets rung and a 2-flight cap A pipeline refusal before the first picture now converts the suspect audio (a codec outside aac/ac3/eac3, named by recovery::suspect from the stream the pipeline was fed) in one recovery flight, then takes the HLS contingency of a cold Auto Original, then the read-out; MAX_RECOVERY_FLIGHTS bounds the flights of one request. The learner's answer (newly) no longer selects a rung: a facet the attempt's start already lacks narrows nothing and gets no flight. Forced Direct Play ends on the read-out after a pending rollback. StartFlight is ConvertFacet and dispatch_native_audio_conversion is dispatch_refusal_conversion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…efusalRecovery The Retranscode landing of a recovery flight notes the delivery (and the app's own recovery as the open's origin) through report::note_refusal_recovery_delivery; the reason joins the closed list, the consent legend and PRIVACY.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y classes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… landing re-grades the Original candidate's audio; the #695 rig tests plxnative-refuse601 (dev builds) publishes type=18 num=601 through sf_on_event once the Load returned and before a picture, once per playback trace generation, so the converted reload of the same request plays. The flag is read only inside that open window (a process-wide latch cannot be armed per test). The refusal-recovery landing calls regrade_original_audio, so the stored Original candidate's audio is graded against the narrowed attempt and the ABR climb plans the remux, not the refused audio's Part. Hostsim tests: a DTS 601 converts in one flight (#695, with and without a sibling), an Auto Original flies the conversion not HLS, a refused conversion ends on the read-out, the climb after a rescue plans no DTS direct play, the landing notes its delivery reason and counts the attempt once, refuse601 publishes once per request. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…1a ladder made false is rewritten
ConvertFacet carries the facet and the flight ordinal, so the driver logs 'converting {facet}
(flight n/2)'. The learner's doc, the pump's refusal comments, dispatch_refusal_conversion's doc,
the type=18 callback comment and the player guide describe the ladder as it is: the facet rung is
chosen by suspect() and the loop guard, not by the learner's answer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the ladder did not ask for re-opens it; an unhandled refusal fails the Engine The ladder lives per request but a picture is per Engine: the facet rung is gated on the refusal's own Engine having no decoded frame (facts.no_frames), not on the request's history, so a native-audio refusal on a reloaded Engine converts again. RecoveryEvent::LoadStarted, raised by engine::start_bufferfeed_for (the funnel every Engine start passes), takes a Flight or Failed machine back to Opening when a park and restore re-Loaded the route without a landing; the recovery landing's own reload finds the machine already in Opening and changes nothing. recover_failed_open fails the Engine on a refusal the machine does not handle, as before the machine. The stall branch gets the preview check the other failure branches have; the DTS rig tests are renamed and tightened. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ladder change made false is corrected The pure table now walks the full cap as a fixpoint over (state, flights, excluded) with FirstPicture, a free RefusalConfirmed, FlightRefused, LoadStarted and a Refused of an Engine that did show a picture, and pins every (state, event) pair once. The CLAUDE.md ladder paragraph, the Sans-IO scope notes, the check-deps header, the pump and decision docs and parity-gaps item 5 now describe the per-Engine facet rung, the LoadStarted re-entry, and the driver failing the Engine for a refusal the machine does not handle. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… proof (S2a, #1046) A DTS 601 names no codec, so a refusal of it was never learned and a refusal that does name one was applied from a single event. The OpenRecovery machine now separates the two kinds of evidence: - Named (num=200, the stall): ConfirmRefusal{Named} is emitted in the same step as the ladder's rung; there is no Confirming state and no RefusalConfirmed event. The row applies at once (no `proofs`). - Proof: a facet flight whose /decision lanes are audio=transcode and video=copy|directplay (FlightLanded{lanes}) enters Proving; the first picture of the converted reload emits ConfirmRefusal{Proof}. Both-facets (HLS), rollback, timeout and source-failure rescues prove nothing. The stored NativeAudioRefusal gains `proofs: Option<u32>` (absent = named or pre-S2a row = applied). A counted row applies at LEARNED_REFUSAL_PROOFS (2); named wins on merge, proofs add capped, a proof of unknown width is not stored. The writers (record_named_refusal / record_refusal_proof) only read devcaps::learning_firmware() and queue the merge on the storage worker; devcaps no longer carries a write door (learn_refusal is gone), the row reaches the capability answer through the record's projection of applied rows. Lanes are parsed from /decision by route/plan.rs::decision_lanes and carried PreparedEncode -> AppliedRetranscode -> RecoveryLanding -> FlightLanded. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ed proof, not devcaps::learn_refusal Prose the S2a change made false: player/CLAUDE.md's learned-refusal section, docs/sans-io.md and a test_harness docstring still named learn_refusal and described a refusal as applying at once from the pump. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…rves it The non-hostsim test build compiled decision_test_support.rs without the rig, so MDE_TRANSCODE_BOTH was dead code there (-D warnings). Defined where it is used. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…e its picture, proves nothing (S2a, #1046) A facet flight proved its conversion whenever PMS answered the lanes clean, even when the request had already played the facet natively: the picture is per Engine, so the facet rung is reachable from Playing (a Home, a return, a restore Load refused before its picture), and two such restores would persist a wrong row. OpenRecovery now remembers that a picture showed (pictured) and enters Proving only for a request that showed none. A Load between the clean landing and the first picture (a track switch, a restore after Home, a seek reload) read as the landing's own reload and kept the pending proof for a route whose lanes were never read. FlightLanded moves the machine BEFORE the driver reloads, so Proving carries `loaded`: the first LoadStarted is the landing's own reload, a second one drops to Opening and loses the proof. Tests: the first_picture helper asserts its refusal flag arrived (5 s, not a silent 500 ms fall-through); confirm_refusal's recorder test runs the real recorder under a temporary session and reads the answer before the queued write lands; the proof and named-opus rig tests project the real record before their capability assert; decision_lanes and Proving x named Refused get direct pins; the debug-flag test goes through project_stored_refusals (nativeaudioforget applies NONE, so a row recorded during the run reaches caps() on the next launch). Prose corrected where it described the old landing and the old forget behaviour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… assertion can fail Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ow reads the start value R1 fails on the base (the record's projection swaps the Arc: opus ceiling None -> Some(2)); M2 fails on the base (the preview reads the boot snapshot and promises Direct Play for a refused DTS 5.1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d its test doors go Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d on Session Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ds the start value; pump tests read the record at the start edge Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sals (start_caps), at every play-start site Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…alue from the stored refusals Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fusal of their Load before the next frame
The rig's Engine is never built by a real sf_load (the rig forces the host object ready), so its teardown cannot prove
the release and quarantines the host sink; every Load a test then starts (the foreground restore, the facet landing's
reload) is refused by the stub from the MEDIA thread (ok=0, no type=18 callback, so a refusal with num=0). The tests
assert "not Error" after a frame that follows the start; they pass only while that frame beats the media thread. When
the media thread wins, the frame reads a second refusal of an open whose facet is spent and the ladder ends on the
read-out ("no rung left for type=18 num=0"). The CI hostsim job failed on S3 (#1063) in two of these tests with no
S3 code involved; a loop of only the pump tests failed the same way (1 of 60 and 4 of 250 runs under load).
pipeline_takes_the_load awaits the stub's refusal of the Load and takes it down, and is called after each Load these
tests go on to grade: park_and_restore (Scenario B), Scenario A's restore, and refuse601's reload. The refusal is now
always absorbed before the frame, so every run takes the ordering that used to lose; the test's own
pipeline_refuses_601 still ends the open. No production change: on a set the restore's Load returns ok=1 and the
pipeline then refuses it with 601.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
first_picture takes the hostsim sink's refusal through pipeline_takes_the_load (S1a's helper) instead of repeating its wait-and-clear body. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
audio_converted_by_server reads the attempt since S2b; the merged #1049 test still used the three-argument form. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GLinnik21
added a commit
that referenced
this pull request
Oct 26, 2026
GLinnik21
added a commit
that referenced
this pull request
Oct 26, 2026
GLinnik21
added a commit
that referenced
this pull request
Oct 26, 2026
GLinnik21
added a commit
that referenced
this pull request
Oct 26, 2026
…1063) Tests pin that Force Direct Play refuses audio the install has learned the TV refuses before any Load, naming the codec and offering "Switch to Auto and play". No production change: the Forced gate reads the attempt's start value (#1054, #1062), and main has refused this case since #1034. - `forced_direct_play_refuses_a_learned_refusal_before_the_load`: Forced plus a stored `dca 6` row gives `ForcedFailure::Audio { codec: "DTS" }` and no part URL. - `forced_direct_play_plays_the_ac3_sibling_of_a_learned_dts`: with an AC3 sibling, Forced plays the sibling. - `the_play_start_value_lacks_an_applied_refusal`: the first test of the app's `start_caps()`. - Red observed by mutation (the wrapper returning the boot value; the Forced pick reading the global; the Forced arm using the codec-level check). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fourth PR of the playback-refusal series (#918). It is stacked on #1061 (S2a), so the diff shown is S2b only.
The device's capability answer is fixed once more after boot. Stored refusals no longer change it in the background. Each play start now computes its own value: the boot snapshot less the install's stored refusals that apply.
Closes #1047
What changed
devcaps (
platform/src/devcaps.rs)Layers(anRwLock<Arc<Caps>>plus flags) becomesBoot, held in aOnceLockthat onlyprobesets.caps()returns the sameArcon every call.fold,apply_learned,project_stored_refusals,Refusal.firmware(the codename filter's only reader; the build string already identifies the firmware) and the test doorcaps_on_this_thread_for_test.Caps::minus(rows, build)is pure. It narrows each row watched onbuildbyrefusal_ceiling, the same ruleCaps::withoutuses.devcaps::start_caps(stored)applies the debug flags to its input and returns the bootArcitself when no row applies.Session (
plex/src/plex/session.rs)replace_cacheno longer projects onto devcaps.project_native_audio_refusalsis deleted.Session::applied_refusals(&self)is a pure public method that keeps S2a's rule: named rows, plus rows provedLEARNED_REFUSAL_PROOFStimes.The play-start edge (
src/app/playback.rs)start_caps()readssession::peek()once and feeds every production play start:request_play,request_play_movie,request_previewandrequest_play_queue_item, at the 9 sites inapp/playback.rs,app/content.rs,app/playback/queue_transport.rsanddev/scenarios.rs.Readers that must see learned rows
audio_converted_by_server(ps, ..), withpsthreaded throughtrack_menu.rs.start_caps(&peek().applied_refusals()).Behaviour changes
caps()changing any more. A refusal recorded in this run applies from the next play start once its queued write has landed.plxnative-nativeaudioforgetignores every stored row, including one recorded this run.-widewithout-learn, or a set that reports no codename, records nothing and applies nothing.peek()is empty, so a play started in that window plans without stored rows. It is refused once, then converted.devcaps: play start less learned refusals (N stored) -> audio=… channels=…. That line replaces the bootfoldline.start_capson each detail-hero paint. With no stored rows this is onepeek()and the bootArc. With a row in force, each paint also pays oneCapsclone andminus.Tests
New or rewritten:
caps_is_one_arc_across_a_recorded_refusal(plx_plex). Red on the base:the stored row moved caps(): opus ceiling None -> Some(2). Itssave(&peek())runs the projection path that production ran.applied_refusals_hands_over_named_and_twice_proved_rows_only(plx_plex).devcaps:the_start_lacks_an_applied_row_and_keeps_the_boot;the_debug_flags_decide_which_stored_rows_the_start_applies(five flag cases);a_build_change_clears_the_rows_from_the_start;the_boot_readers_see_one_unchanged_value.apply_learnedtominus.the_converted_mark_reads_the_attempt_not_the_boot(plx_media).the_facts_row_reads_the_start_value(plx_media).the facts row promised Direct Play for a combination this install watched the pipeline refuse.folddid narrow the global once the write landed.caps()and it fails.pump.rs):the_next_play_starts_without_a_twice_proved_dts_surroundasserts throughstart_capsand checks thatcaps()is untouched;Arc;caps().a_dca_track_direct_plays_on_a_set_with_a_dts_rowdrops the thread door and passes, so nothing on that plan path readscaps().The remaining new tests use new APIs, so their red was a compile failure.
Crate suites on the committed tree:
make test-crate C=plx_platform:test result: ok. 308 passed; 0 failed; 0 ignored; 0 measured; 0 filtered outmake test-crate C=plx_plex:test result: ok. 743 passed; 0 failedmake test-crate C=plx_media:test result: ok. 1804 passed; 0 failed; 64 ignoredmake test-hostsim C=plx_media:test result: ok. 1990 passed; 0 failed; 64 ignoredmake test-crate C=plx_appkit T=track_menu:test result: ok. 151 passed; 0 failed; 199 filtered outmake test-crate C=app:test result: ok. 1059 passed; 0 failedmake check-nodef: clean.check-depsandcheck-module-layers: green.ci/allow/*,ci/machines.iniandci/list-caps.iniare unchanged.make checkand the ARM build are left to this PR's CI.Docs
player/CLAUDE.md: where a stored refusal reaches a play, the new log line, and the forget semantics.docs/sans-io.md:caps()is write-once; the facts row reads the start value and the track menu reads the attempt.recovery.rscomments.tests/test_harness.py.🤖 Generated with Claude Code