Land Cut 12: gate the answer path, recover pre-Cut-11 play stores (PA.f169, PA.f172, PA.f173, PA.f175)

PA.f169 (blocker): two more `client_intents` callers
(`world_create_seed_activate_and_play_round_trip_through_the_real_client`,
`a_create_form_filled_only_by_its_labelled_identity_fields_is_still_accepted`)
ended in `.expect("node must be on PATH ...")` with no skip guard, so
`cargo test --locked -p ghostlight-dungeon --bin ghostlight-dungeon` itself
failed in a `node`-less container -- Idunn's own gate. Both now carry the
same `eve_client_bridge_is_available()` skip guard the third caller already
had, printing a plain SKIPPED line naming themselves. Verified with `node`
scrubbed from PATH: all three skip and the full bin suite is still
156 passed, 0 failed.

PA.f172: `question_surface_version` (added in Cut 11 with no
`#[serde(default)]`) made a pre-Cut-11 `PlayTurnStore` row fail to decode,
which `PlayTurnStore::open` reported as "corrupt" and `runtime.rs` turned
into a silently missing play card. `STORE_SCHEMA` moves to
`ghostlight.play_turn_store.v2`; `question_surface_version` now carries
`#[serde(default)]`, so the same struct is a compatible read of a genuine
v1 row (`LEGACY_STORE_SCHEMA_V1`). `PlayTurnStore::open` recognizes that
schema id as legacy, not corrupt, recovers the turn, and upgrades the row
to v2 in place and durably. The strict canonical re-encode check still
applies to current-schema rows; a v1 row's bytes were never canonical
under v2 and the legacy branch does not attempt the comparison. A row that
matches the current schema id but is genuinely undecodable is still
refused as corrupt. `runtime.rs::runtime_readiness` now reports
`playStatus` ("ok"/"unavailable") on `/health`, the same
`Option -> ok/unavailable` shape `projectionStatus`/`controllerStatus`
already use, so a play table that failed to open is visible there instead
of only in a startup `tracing::warn!`.

PA.f173: `close_with_fault` left `question`/`question_surface_version` set
on the turn it closed. Unobservable through `open_question_id` only
because it gates on `state == AwaitingPlayer`, which closing always
leaves behind in the same write. `close_with_fault` now clears both,
matching `answer_turn`.

PA.f175: `execute_round` took one snapshot at round entry and reused it
for every arm in that round, so a round that authors (a `PLAY_TOOLS` run)
and then asks in the same round recorded `question_surface_version` from
before its own commit. The `ASK_PLAYER_TOOL` arm now fetches a fresh
snapshot at the moment the question actually opens.

Tests added: a_legacy_v1_row_is_recognized_and_upgraded_not_refused_as_corrupt,
a_row_with_the_current_schema_id_and_undecodable_bytes_is_still_refused_as_corrupt,
close_with_fault_clears_the_open_question_off_the_persisted_row,
a_round_that_authors_and_then_asks_records_the_version_as_of_the_question_not_the_round,
health_reports_play_status_from_state_play.

Dungeon bin: 156 passed, 0 failed (bridge available); 156 passed, 0 failed
with `node` scrubbed from PATH (3 SKIPPED).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-23 02:27:44 +02:00
commit 7052644467
2 changed files with 452 additions and 14 deletions

View file

@ -43,7 +43,20 @@ use sha2::{Digest, Sha256};
use tokio::sync::{Mutex, Semaphore};
const STORE_TYPE: &str = "ghostlight.play_turn_store.v1";
const STORE_SCHEMA: &str = "ghostlight.play_turn_store.v1";
/// PA.f172: bumped from `ghostlight.play_turn_store.v1` when
/// `question_surface_version` was added to `PlayTurn` without
/// `#[serde(default)]` — the row's own shape moved, so its schema id moves
/// with it. `question_surface_version` now carries `#[serde(default)]`, so
/// this v2 reader is a compatible read of a genuine v1 row too: `open` still
/// gates strictly on the row's own declared `schema_id` (a fresh legacy
/// branch recognizes `LEGACY_STORE_SCHEMA_V1` by name, never silently), but
/// once recognized, decodes it with exactly the same struct and upgrades it
/// in place rather than discarding the turn it names as unreadable.
const STORE_SCHEMA: &str = "ghostlight.play_turn_store.v2";
/// The schema id every row written before `question_surface_version` existed
/// carries. Recognized by `PlayTurnStore::open` as legacy, not corrupt, and
/// upgraded to `STORE_SCHEMA` in place (PA.f172).
const LEGACY_STORE_SCHEMA_V1: &str = "ghostlight.play_turn_store.v1";
const STORE_KEY: &str = "primary";
/// The one round budget: exhausting it closes the turn and narrates, rather
@ -245,10 +258,16 @@ struct PlayTurn {
/// world's current revision happens to be when a later request arrives.
/// This is what lets an answer be refused as stale by "older than the
/// version the question first appeared on" rather than by "not exactly
/// today's version," the comparison PA.f161 replaces. No
/// `#[serde(default)]`, matching `question`'s own row-schema neighbors
/// above (PA.f95): the store has never been deployed, so no row anywhere
/// is missing it.
/// today's version," the comparison PA.f161 replaces. `#[serde(default)]`
/// (PA.f172, reversing the earlier PA.f95 reasoning here): a row written
/// before this field existed — a real shape on a workstation that ran
/// Dungeon before Cut 11, not a hypothetical — decodes to `None`, exactly
/// what "no question ever recorded a surface version" means for a turn
/// this old. `STORE_SCHEMA` moved to v2 alongside this default so the
/// canonical re-encode check below still holds current-schema rows to
/// their exact bytes; only the legacy v1 read path in
/// `PlayTurnStore::open` relies on this default to recover an older row.
#[serde(default)]
question_surface_version: Option<u64>,
refusal: Option<String>,
narration: Option<String>,
@ -341,8 +360,9 @@ impl PlayTurn {
/// `open_question_id` is, since `turn.question` and
/// `turn.question_surface_version` are only ever set alongside it
/// (`execute_round`'s `ASK_PLAYER_TOOL` arm sets all three in the same
/// round, and `answer_turn` clears `question`/`question_surface_version`
/// together).
/// round, and `answer_turn` and `close_with_fault` both clear
/// `question`/`question_surface_version` on their own way out of
/// `AwaitingPlayer`, PA.f173).
fn open_question_of(turn: &PlayTurn) -> Option<OpenQuestion> {
let id = turn.open_question_id()?;
Some(OpenQuestion {
@ -463,7 +483,38 @@ impl PlayTurnStore {
}
(row.clone(), state)
}
_ => bail!("play-turn v1 store contains foreign or legacy records; start with a fresh store"),
// PA.f172: a row written before `question_surface_version`
// existed — named legacy, not corrupt, because it is neither: it
// is a real, previously-canonical shape this process itself
// wrote, under the schema id that was current when it did.
// `question_surface_version` now carries `#[serde(default)]`, so
// the very same `PlayTurnStoreState` decodes it correctly (the
// field reads back `None`, matching what "no question ever
// recorded a surface version" means for a turn this old). This
// branch upgrades the row to `STORE_SCHEMA` (v2) in place and
// persists that upgrade immediately, so the legacy path is only
// ever taken once per row; the canonical re-encode check does
// not apply here — a v1 row's bytes were never canonical under
// v2 and were never meant to be — it resumes for every write
// this store makes from here on.
[row]
if row.r#type == STORE_TYPE
&& row.key == STORE_KEY
&& row.schema_id.as_deref() == Some(LEGACY_STORE_SCHEMA_V1) =>
{
let mut state: PlayTurnStoreState = rmp_serde::from_slice(&row.payload)
.context("play-turn legacy v1 state is corrupt")?;
if state.schema != LEGACY_STORE_SCHEMA_V1 {
bail!("play-turn legacy v1 state schema is invalid");
}
state.schema = STORE_SCHEMA.into();
let upgraded_row = envelope(&state)?;
if !store.compare_and_swap_batch(std::slice::from_ref(row), vec![upgraded_row.clone()])? {
bail!("play-turn store changed while upgrading a legacy v1 row");
}
(upgraded_row, state)
}
_ => bail!("play-turn store contains foreign or otherwise-unreadable records; start with a fresh store"),
};
Ok(Self {
store,
@ -1150,6 +1201,15 @@ impl PlayTable {
async fn close_with_fault(&self, turn: &mut PlayTurn, detail: String) -> Result<(), PlayError> {
turn.state = PlayTurnState::Closed;
turn.fault = Some(detail);
// PA.f173: a fault can close a turn that was `AwaitingPlayer`, and
// `answer_turn` is not the only door out of that state — clear the
// open question here too, the same way `answer_turn` does, so a
// closed turn never carries a question record alongside it. This was
// unobservable before this fix only because `open_question_id` gates
// on `state == AwaitingPlayer`, which a fault always leaves behind by
// setting `state = Closed` in the same write.
turn.question = None;
turn.question_surface_version = None;
self.persist(turn).await
}
@ -1425,12 +1485,25 @@ impl PlayTable {
None,
);
turn.question = Some(question);
// PA.f161: the world's own surface version at the instant
// this question opens, so a later answer is judged against
// the version the question actually first appeared on, never
// whatever the world's version happens to be when the
// answer arrives.
turn.question_surface_version = Some(crate::eve::surface_version(Some(&snapshot)));
// PA.f161/PA.f175: the world's own surface version at the
// instant this question opens, so a later answer is judged
// against the version the question actually first appeared
// on, never whatever the world's version happens to be when
// the answer arrives. This must be a fresh snapshot taken
// right here, not the round-entry `snapshot` above: a round
// that authors (a `PLAY_TOOLS` run, committed earlier in this
// same `while` loop) and then asks would otherwise record the
// version as of before its own commits, not the version the
// question actually opened on.
let question_snapshot = match self.fetch_round_snapshot().await {
Ok(snapshot) => snapshot,
Err(detail) => {
self.close_with_fault(turn, detail).await?;
return Ok(RoundOutcome::Closed);
}
};
turn.question_surface_version =
Some(crate::eve::surface_version(Some(&question_snapshot)));
turn.state = PlayTurnState::AwaitingPlayer;
return Ok(RoundOutcome::AwaitingPlayer);
}
@ -3219,6 +3292,317 @@ pub(crate) mod tests {
);
}
/// PA.f175: `execute_round` takes one snapshot at round entry
/// (`round_snapshot`) and, before this fix, reused it for every arm in
/// that same round — including `ASK_PLAYER_TOOL` — so a round that
/// commits an authoring call (a `PLAY_TOOLS` run) and then asks in the
/// same round recorded `question_surface_version` from before its own
/// commit. A later "is this answer stale" comparison
/// (`runtime.rs::an_answer_naming_a_version_older_than_the_question_is_refused_as_stale`)
/// judges a version the question's own commit had already passed, so an
/// answer naming the version the commit actually landed at — the version
/// a real client would have just been served — could be wrongly refused
/// as older than the question.
///
/// Mutation: revert `execute_round`'s `ASK_PLAYER_TOOL` arm to record
/// `crate::eve::surface_version(Some(&snapshot))` (the round-entry
/// snapshot) instead of fetching a fresh one at the moment the question
/// opens — this test's asserted version would then equal the version
/// from before the round's own `declare_fact` commit, one behind the
/// version this test expects.
#[tokio::test]
async fn a_round_that_authors_and_then_asks_records_the_version_as_of_the_question_not_the_round() {
let fixture = play_world(None, "player-author-then-ask").await;
let directory = tempfile::tempdir().unwrap();
let store_path = directory.path().join("play-turn-v1.cc");
let world_before = fixture.world.snapshot().await.unwrap();
let version_before = crate::eve::surface_version(Some(&world_before));
let round = output(
"r0",
vec![
call_event(
"c0",
"declare_fact",
serde_json::json!({
"handle": "ruling",
"label": "The Ruling",
"statement": "The gate creaks open on its own.",
"standing": {"standing": "ruled"},
}),
),
call_event(
"c1",
ASK_PLAYER_TOOL,
serde_json::json!({"question": "Which way?"}),
),
],
);
let personas = PersonaLane::new(
ControllerPort::new(fixture.world.clone()),
ScriptedPort::new(vec![]),
"gpt-5.6-sol".into(),
"gpt-5.6-sol".into(),
)
.unwrap();
let table = PlayTable::new(
fixture.world.clone(),
personas,
ScriptedPort::new(vec![round]),
"gpt-5.6-terra".into(),
Arc::new(Semaphore::new(2)),
&store_path,
)
.unwrap();
table
.run(&fixture.principal, test_turn_id(91), "I stand at a crossroads.".into())
.await
.unwrap();
let asked_view = table.current_turn_view().await.unwrap();
let question = asked_view
.question
.as_ref()
.expect("the scripted ask_player call must open a question");
let world_after = fixture.world.snapshot().await.unwrap();
let version_after = crate::eve::surface_version(Some(&world_after));
assert!(
version_after > version_before,
"declare_fact must have committed and moved the world's own revision \
({version_before} -> {version_after})"
);
assert_eq!(
question.surface_version, version_after,
"the question's own recorded version must be the version as of when it opened \
(after this round's own declare_fact commit, {version_after}), not the \
round-entry version {version_before}"
);
}
/// PA.f173: `close_with_fault` used to leave `question` and
/// `question_surface_version` set on the turn it closed, though a
/// `Closed` turn should never still carry an open question. This was
/// unobservable through `current_turn_view`/`open_question_id` only
/// because both gate on `state == AwaitingPlayer`, which closing always
/// leaves behind in the same write — so this test reaches past that
/// gate, at the raw persisted row.
///
/// Mutation: remove the `turn.question = None; turn.question_surface_version
/// = None;` lines from `close_with_fault` — the persisted row would then
/// still carry the question's own text and version after the turn
/// closed, and this test's assertions would fail.
#[tokio::test]
async fn close_with_fault_clears_the_open_question_off_the_persisted_row() {
let fixture = play_world(None, "player-fault-clears-question").await;
let directory = tempfile::tempdir().unwrap();
let store_path = directory.path().join("play-turn-v1.cc");
let ask = output(
"r0",
vec![call_event(
"c0",
ASK_PLAYER_TOOL,
serde_json::json!({"question": "Which way?"}),
)],
);
let personas = PersonaLane::new(
ControllerPort::new(fixture.world.clone()),
ScriptedPort::new(vec![]),
"gpt-5.6-sol".into(),
"gpt-5.6-sol".into(),
)
.unwrap();
let table = PlayTable::new(
fixture.world.clone(),
personas,
ScriptedPort::new(vec![ask]),
"gpt-5.6-terra".into(),
Arc::new(Semaphore::new(2)),
&store_path,
)
.unwrap();
table
.run(&fixture.principal, test_turn_id(91), "I stand at a crossroads.".into())
.await
.unwrap();
let asked_view = table.current_turn_view().await.unwrap();
assert_eq!(asked_view.state, PlayTurnState::AwaitingPlayer);
let mut turn = table.store.lock().await.current().unwrap().clone();
assert!(turn.question.is_some());
assert!(turn.question_surface_version.is_some());
table
.close_with_fault(&mut turn, "manufactured fault for PA.f173".into())
.await
.unwrap();
let persisted = table.store.lock().await.current().unwrap().clone();
assert_eq!(persisted.state, PlayTurnState::Closed);
assert_eq!(
persisted.question, None,
"a closed turn must not still carry the question it closed on top of"
);
assert_eq!(
persisted.question_surface_version, None,
"a closed turn must not still carry the question's own surface version"
);
}
/// A pre-Cut-11 process's own `PlayTurn`, replicated field-for-field
/// minus `question_surface_version` — the shape every row actually
/// written under `LEGACY_STORE_SCHEMA_V1` carries (PA.f172). Test-only:
/// no production writer ever mints this shape again.
#[derive(Clone, Serialize)]
struct LegacyPlayTurnV1 {
turn_id: String,
applied_keys: Vec<String>,
opening_prompt: String,
player_prose: Vec<String>,
rounds: Vec<InferenceOutput>,
calls: Vec<CallRecord>,
persona_turns: Vec<(SubjectId, PersonaTurn)>,
question: Option<String>,
refusal: Option<String>,
narration: Option<String>,
fault: Option<String>,
state: PlayTurnState,
}
#[derive(Clone, Serialize)]
struct LegacyPlayTurnStoreStateV1 {
schema: String,
turn: Option<LegacyPlayTurnV1>,
ledger: KeyLedger,
revision: u64,
}
/// PA.f172: a row written before `question_surface_version` existed —
/// under `LEGACY_STORE_SCHEMA_V1`, the schema id every pre-Cut-11 process
/// actually wrote (it is exactly what `STORE_SCHEMA` used to be) — is
/// recognized by `PlayTurnStore::open` as legacy, not corrupt, and its
/// turn recovered rather than discarded: the missing field decodes to
/// `None`, exactly what "no question ever recorded a surface version"
/// means for a turn this old. The row is also upgraded to `STORE_SCHEMA`
/// in place, durably, so the legacy path is only ever taken once.
///
/// Mutation: remove the `schema_id.as_deref() ==
/// Some(LEGACY_STORE_SCHEMA_V1)` arm from `PlayTurnStore::open` — this
/// row would then fall through to the catch-all and `open` would return
/// `Err`, exactly PA.f172's own bug: a workstation's pre-Cut-11 row
/// silently disables play with "the play table is unavailable".
#[tokio::test]
async fn a_legacy_v1_row_is_recognized_and_upgraded_not_refused_as_corrupt() {
let directory = tempfile::tempdir().unwrap();
let store_path = directory.path().join("play-turn-v1.cc");
let legacy_turn = LegacyPlayTurnV1 {
turn_id: "11111111-1111-1111-1111-111111111111".into(),
applied_keys: vec!["key-a".into()],
opening_prompt: "You stand at the gate.".into(),
player_prose: vec!["I knock.".into()],
rounds: Vec::new(),
calls: Vec::new(),
persona_turns: Vec::new(),
question: None,
refusal: None,
narration: None,
fault: None,
state: PlayTurnState::Running,
};
let legacy_state = LegacyPlayTurnStoreStateV1 {
schema: LEGACY_STORE_SCHEMA_V1.into(),
turn: Some(legacy_turn),
ledger: KeyLedger::default(),
revision: 3,
};
let legacy_row = CultCacheEnvelope {
key: STORE_KEY.into(),
r#type: STORE_TYPE.into(),
payload: rmp_serde::to_vec_named(&legacy_state).unwrap(),
stored_at: Utc::now().to_rfc3339(),
schema_id: Some(LEGACY_STORE_SCHEMA_V1.into()),
};
{
let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap();
assert!(
raw_store
.compare_and_swap_batch(&[], vec![legacy_row])
.unwrap(),
"seeding the legacy row must succeed against an empty store"
);
}
let store = PlayTurnStore::open(&store_path)
.expect("a genuine pre-Cut-11 row must open, not be refused as corrupt");
let recovered = store.current().expect("the legacy row's own turn must be recovered");
assert_eq!(recovered.turn_id, "11111111-1111-1111-1111-111111111111");
assert_eq!(recovered.opening_prompt, "You stand at the gate.");
assert_eq!(
recovered.question_surface_version, None,
"a pre-Cut-11 turn never recorded a surface version; the missing field must decode to None"
);
assert_eq!(
store.row.schema_id.as_deref(),
Some(STORE_SCHEMA),
"the legacy row must be upgraded to the current schema in place, not left at v1"
);
// Reopening must now take the strict current-schema path, proving
// the upgrade was actually durably persisted, not only held in
// memory on the first `open`. `store` must be dropped first: CultCache's
// own single-owner lock refuses a second `OwnedRedbMessagePackBackingStore::new`
// on the same path while the first is still held (the same custody
// rule `pull_current_from_disk_for_test`'s own doc comment names).
let recovered_turn_id = recovered.turn_id.clone();
drop(store);
let reopened = PlayTurnStore::open(&store_path).unwrap();
assert_eq!(reopened.current().unwrap().turn_id, recovered_turn_id);
}
/// PA.f172's own negative: a row that matches the current schema id but
/// whose bytes do not decode at all is still refused as corrupt — the
/// legacy path recognizes only the one schema id every pre-Cut-11
/// process actually wrote, never a general-purpose "try to read it
/// anyway" escape hatch for a row that is genuinely damaged.
#[tokio::test]
async fn a_row_with_the_current_schema_id_and_undecodable_bytes_is_still_refused_as_corrupt() {
let directory = tempfile::tempdir().unwrap();
let store_path = directory.path().join("play-turn-v1.cc");
let corrupt_row = CultCacheEnvelope {
key: STORE_KEY.into(),
r#type: STORE_TYPE.into(),
payload: vec![0xC1], // msgpack's own reserved "never used" marker byte
stored_at: Utc::now().to_rfc3339(),
schema_id: Some(STORE_SCHEMA.into()),
};
{
let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap();
assert!(
raw_store
.compare_and_swap_batch(&[], vec![corrupt_row])
.unwrap(),
"seeding the corrupt row must succeed against an empty store"
);
}
// `.expect_err` needs `T: Debug`; `PlayTurnStore` does not derive it
// (its own row holds turn text nobody wants dumped by a failed
// assertion), so this matches directly instead.
let error = match PlayTurnStore::open(&store_path) {
Ok(_) => panic!("undecodable bytes must still refuse to open"),
Err(error) => error,
};
assert!(
error.to_string().contains("corrupt"),
"a genuinely undecodable row must still be named corrupt, not legacy: {error}"
);
}
/// The fork Self ruled on, isolated: a commit that changes only
/// `turn.refusal` — exactly the shape `execute_actor_call`'s
/// `ActionRejected` arm leaves a row in (the call's own record is

View file

@ -1571,8 +1571,21 @@ async fn runtime_readiness(state: &AppState) -> anyhow::Result<Value> {
}
None => "unavailable",
};
// PA.f172: the play table can fail to open independently of
// `controllers` (a `PlayTurnStore` a prior session's row makes unreadable),
// and before this the only trace was `tracing::warn!("the play table is
// unavailable; world authority remains online")` at startup — visible in
// a log an operator has to go looking for, never on the readiness
// surface a client or operator actually watches. Same `Option` ->
// "ok"/"unavailable" shape as `projectionStatus`/`controllerStatus`
// above; this does not invent a new channel.
let play_status = match &state.play {
Some(_) => "ok",
None => "unavailable",
};
health["projectionStatus"] = Value::String(projection_status.into());
health["controllerStatus"] = Value::String(controller_status.into());
health["playStatus"] = Value::String(play_status.into());
Ok(health)
}
@ -2179,6 +2192,27 @@ mod tests {
}
}
/// PA.f172: `state.play` being `None` — a play table that failed to
/// open, for any reason, `PlayTurnStore::open` returning `Err` among
/// them — must be visible on `/health`, the same operator-facing surface
/// `projectionStatus`/`controllerStatus` already report through, not
/// only in a `tracing::warn!` at startup.
///
/// Mutation: delete the `health["playStatus"] = ...` line from
/// `runtime_readiness` — `/health`'s own JSON would then carry no
/// `playStatus` key at all, and both assertions below would fail.
#[tokio::test]
async fn health_reports_play_status_from_state_play() {
let fixture = fixture().await;
let available = get(&fixture.state, &fixture.cookie, "/health").await;
assert_eq!(available["playStatus"], "ok", "{available}");
let mut degraded_state = fixture.state.clone();
degraded_state.play = None;
let unavailable = get(&degraded_state, &fixture.cookie, "/health").await;
assert_eq!(unavailable["playStatus"], "unavailable", "{unavailable}");
}
fn route_snapshot_request(
message_id: &str,
schema_ids: Option<Vec<String>>,
@ -2518,6 +2552,16 @@ mod tests {
/// `play::tests::a_question_is_answered_through_the_cards_own_binding_and_the_turn_closes_with_narration`.
#[tokio::test]
async fn world_create_seed_activate_and_play_round_trip_through_the_real_client() {
if !eve_client_bridge_is_available() {
eprintln!(
"SKIPPED world_create_seed_activate_and_play_round_trip_through_the_real_client: \
`node` or the vendored lowering's built `dist/index.js` is not available in this \
environment, so this test did not drive the real Eve client bridge. Run this where \
`node` is on PATH and `vendor/eve/packages/eve-browser-lowering` has been built \
(`npm install && npm run build` in that package) to exercise this check."
);
return;
}
let fixture = fixture().await;
let provider = get(&fixture.state, &fixture.cookie, "/api/eve/provider").await;
@ -2853,6 +2897,16 @@ mod tests {
/// accepted.
#[tokio::test]
async fn a_create_form_filled_only_by_its_labelled_identity_fields_is_still_accepted() {
if !eve_client_bridge_is_available() {
eprintln!(
"SKIPPED a_create_form_filled_only_by_its_labelled_identity_fields_is_still_accepted: \
`node` or the vendored lowering's built `dist/index.js` is not available in this \
environment, so this test did not drive the real Eve client bridge. Run this where \
`node` is on PATH and `vendor/eve/packages/eve-browser-lowering` has been built \
(`npm install && npm run build` in that package) to exercise this check."
);
return;
}
let fixture = fixture().await;
let provider = get(&fixture.state, &fixture.cookie, "/api/eve/provider").await;
let empty_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await;