Ghostlight/notes/ghostlight-verse-map.md
Meta Vens 0e9def0ebc Verse map: name the Eve submodule without a path the tree no longer has
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 00:00:05 +02:00

24 KiB

Ghostlight Verse: Map

Status: Dungeon, eve.rs and the bridge-test references below are parked at tag parked/ghostlight-eve-surface; line refs are to that tag.

Campaign ghostlight-verse, target ghostlight-verse:target:r3 (notes/ghostlight-verse-target.md). This page holds body facts, the model page and rationale only. Cut specs, questions, rulings and follow-ups live in the Eureka mind; query them there, never copy them here.

Phase 1 map: Imagination, session self-2026-10-09-morning, 2026-10-09. Base for every phase-1 anchor: Ghostlight origin/main 4fee97f. Times are UTC unless marked local (UTC+2).

Cut sequence

Cuts without a cut_spec, withdrawn 2026-10-12 under map-only-what-runs: a spec is admitted when Self is about to dispatch the cut. The live run waits on the operator's DeepSeek key. Re-map a line by a fresh Imagination pass against the Body when the frontier reaches it.

  • model-asleep: The play card says the storyteller is waking while Bonsai reloads, and refuses a turn with an asleep line when the. Waits on: play-faults (live run waits on the operator DeepSeek key).
  • raven-bonsai-scripts: Raven's Bonsai scripts enter gamecult-ops; the server stays up and sleeps its model on idle, the reaper is gone, and. Waits on: nothing in the mind (not queued; Ghostlight live run waits on the operator DeepSeek key).
  • deploy-main: Main deploys on Yggdrasil through Idunn, stays up, and a human plays a session end to end. Waits on: play-faults, play-surface, session-refresh, acceptance-gate, raven-bonsai-scripts, model-asleep, create-flow (blocked on the DeepSeek key and the Ghostlight live-run chain).
  • v1-retire: The retired v1 Ghostlight body leaves Yggdrasil and gamecult-ops. Waits on: nothing in the mind (not queued; Ghostlight frontier is the live run).

Body facts (probed 2026-10-09)

Does main run on Yggdrasil?

  • The code on main is running. The live unit idunn-ghostlight-dda9de02… (PID 818485, up since 2026-09-23 20:02:48) runs release sha256-51b014be…, whose binary embeds commit 454fc33 (grep of the binary for the stamped commit: 454fc33 5 hits, every later commit 0). git diff --stat 454fc33 origin/main touches only docs/architecture/ghostlight-play-agent-cut.md, notes/fresh-workspace-handoff.md and state/map.yaml. So main's code is live; only its provenance stamp is a week old.
  • Why the 2026-09-30 deploy died. Unit idunn-ghostlight-0e4f20f5… (release sha256-ec17cae9…, binary stamped 4fee97f) logged publishing initial Warming runtime presence failed; retrying error=timed out connecting runtime-presence publisher to 10.77.0.1:17871 every ~2.5 s from 10:58:35, then at 11:00:36 Error: publishing initial Warming runtime presence timed out and exit 1 (journalctl -u on the unit). The bound is WRITE_LEASE_WAIT_TIMEOUT = 120 s (crates/ghostlight-dungeon/src/runtime.rs:1939, used at :1985-1992). 10.77.0.1:17871 is nginx's UDP stream route to Odin's catalog (/etc/nginx/idunn-stream-routes/odin.conf: listen 10.77.0.1:17871, upstream 127.0.0.1:17973, the odin-daemon socket). Idunn itself could not reach it in the same minutes: Idunn rejected admitted odin route continuity: timed out connecting CultMesh RUDP client idunn-route-observer to 10.77.0.1:17871 (idunn-yggdrasil journal, 10:57-11:08). Idunn recorded the transaction up-855ddf0c… as failed and kept the 09-23 generation: daemon survival worked as designed.
  • The Odin route outage window. Idunn's "odin route continuity" rejections per hour run from 2026-09-28 01:19 to 2026-09-30 12:xx, peaking at 1,326 in 2026-09-29 23:00, and stop after that. The live Ghostlight's failed presence republications per day: 3,186 (09-28), 3,793 (09-29), 2,512 (09-30), then 1-7 per day from 10-01. The peak matches the scar in Eureka's tools/stopgap/ygg-verify.sh header: at 8 verify slots on 2026-09-30 01:50 local, /proc/pressure/io full avg60 was 41% and Odin's fsyncs stalled long enough for Idunn's route challenges to time out. That explains the peak; the start on 09-28 is not proven to share the cause. Odin's unit failures in the window include RUDP packet connection id … does not match and timed out waiting for reliable acknowledgement from CultMesh RUDP catalog 127.0.0.1:17973 (journal of idunn-odin-*).
  • Consequence for deploys. Ghostlight cannot start without Odin: the Warming presence and the process write lease both go through Odin (runtime.rs:1974-2003). That coupling is the lease design, not a defect in this campaign's scope. A deploy is preflighted on Odin's route health and on Yggdrasil's IO pressure.

Does main build and pass?

Two jobs through ygg-verify.sh at 4fee97f, with the Eve submodule initialised:

  • Rust (image eureka-verify-rust), the recipe's own steps: ghostlight-persona-projection 35 passed; ghostlight lib 621 passed, 1 ignored (real_local_model_cognition_modes_commit_speech, needs a model); tests/external_admission.rs 13 passed; doc-tests 10 passed; ghostlight-dungeon --bin 198 passed, 4 ignored; release build green in 3 m 08 s. Total 877 passed, 0 failed. Warnings: unused imports (RECORD_GAP_PATCH_TOOL, WorldPhase, DEFAULT_LOCAL_MODEL_PREFIX, DateTime).
  • Node (image eureka-verify-kotlin, Node 24.21): sidecar/claude-sdk 27 passed, 3 skipped ("run npm run build first"); web 3 passed; web build green.
  • The four ignored Dungeon tests are the Eve client-bridge tests (runtime.rs:3054, :3677, :3798, :3904), the only tests that drive the real vendored browser lowering. They need Node beside cargo. The rust verify image has no node (probe: command -v node exit 127), and the recipe runs them in no step, so the release gate never runs them. PA.f214 is the class of defect they exist to catch.

The play-session findings against main today

No commit after 454fc33 touches code, so every PA.f211-PA.f216 is open on main. Mechanisms, probed:

  • PA.f211 and the tunnel. Bonsai's endpoint on Yggdrasil is the reverse tunnel 127.0.0.1:18080. With Bonsai down (as now), curl to it returns an empty reply (exit 52): sshd accepts, then closes. local_inference.rs:166-181 classifies only error.is_connect() as retryable, so a Bonsai restart reaches the play loop as InferenceFault::new (not retryable) and closes the turn on the first call. Even a retryable fault gets backoff_delay (play.rs:2970-2973, base 100 ms, cap 5 s) times ROUND_RETRY_BUDGET 12 (play.rs:89), about 36 s in all, against a reload the Raven runbook puts at about 30 s plus load. Raven's /opt/gamecult/bonsai/rss-watchdog.sh (systemd bonsai-rss.timer, every 2 min) restarts past 4.5 GiB RSS unless /slots shows is_processing; between the calls of a turn nothing is processing. Raven's idle-reaper.sh (bonsai-idle.timer) stops Bonsai and SillyTavern after 30 minutes with no launch_slot_ in the container log. Neither script is in version control: gamecult-ops has only the runbook describing them.
  • PA.f212. A closed turn records its fault (PlayTurn.fault, play.rs:298; close_with_fault, play.rs:1753-1765), but PlayTurnView (play.rs:1224-1246) has no fault field, the card (eve.rs:455-543) renders narration, question, refusal, running and resolving rows and nothing for a fault, and close_with_fault logs nothing. A local call can also wait RESPONSE_TIMEOUT = 900 s (controllers.rs:68, used by local_inference.rs:157) with "Resolving your turn" on the card the whole time.
  • PA.f213. The owner's Draft card always offers "Seed the world" (eve.rs:366-399); the handler refuses when GHOSTLIGHT_SEED_VAULT_ROOT is unset (runtime.rs:1701, :1739-1745). Yggdrasil sets none.
  • PA.f214. world.play.text (eve.rs:588-594) carries no authored value. The vendored lowering captures only value, never placeholder (the PA.f164 comment at eve.rs:288-296), so Play clicked on an untouched box sends no text, and PlayPayload.text (runtime.rs:288-292) is required. The "submit an empty message to continue" hint (eve.rs:514-520) asks the player for exactly that payload.
  • PA.f215. The acceptance step runs controllers::tests::real_local_model_cognition_modes_commit_speech (controllers.rs:11231): NarrativePersona and OperationalAgent each must commit speech in one shot against Bonsai. It exercises the library's controller path, not the Dungeon play loop. Failing runs' prose is written to GHOSTLIGHT_ACCEPTANCE_PERSONA_PROSE_LOG; Idunn's staging dirs on Yggdrasil hold .runner-rust-acceptance workspaces per transaction.
  • PA.f216. Creation is one form of eight fields, three of them raw JSON (targets {}, jurisdictions [], lens weights; eve.rs:244-331), then a Draft card with Approve, Seed and Activate as separate buttons (eve.rs:343-415).

A live defect not yet in the findings

The live process has logged Heimdall refresh transport unavailable error=Heimdall denied the private command: Idempotency key was reused with different command content. once a minute since 2026-09-23 21:04 (1,440 a day from 10-01). Sequence: 21:02:03 discovery timed out; 21:03:00 local app session rejected Heimdall refresh error=local session changed during refresh; from 21:04 the denial forever. Mechanism:

  • Ghostlight's refresh key is refresh:{heimdall_session_id}:{access_revision} (runtime.rs:1877-1880), stable until the local session advances.
  • Every attempt is resealed with a fresh nonce, IV and issue time (heimdall.rs:880-895).
  • Heimdall fingerprints the sealed request bytes, not the command (Heimdall/src/private-command-plane.ts:88, createHash("sha256").update(request.payload)), and refuses a known key with a different fingerprint (:93).
  • So once Heimdall has executed one refresh (it rotated the token at 21:03) and the local commit refused it (app_session.rs:293-300), no retry under that key can ever succeed, and sessions_due_for_refresh (app_session.rs:247-268) keeps offering the same candidate while refresh_expires_at > now. Which clause of app_session.rs:293-299 fired at 21:03 is not logged, but only one is reachable: the session-id and account clauses are pre-checked and revoked at runtime.rs:1904-1916, a revoked session is never offered, and nothing else moves access_revision. That leaves input.access_revision() <= expected (app_session.rs:295). Heimdall's refresh reissues the claim with the refresh claim's access_revision unchanged (Heimdall/src/app.ts:716-724; only logout bumps it, private-command-plane.ts:197), so every real refresh is executed upstream and refused locally, and the key refresh:{sid}:{rev} never changes within a session. Question ghostlight-verse:question:refresh-revision-contract.

The host

  • ghostlight-dungeon.service (the v1 body) is enabled, inactive since 2026-09-23 14:31. It binds 127.0.0.1:8831, runs /srv/ghostlight/current, targets CodexConnector, and would start at the next boot beside Idunn's generation. /srv/ghostlight holds 46 GiB (226 releases, acceptance build scripts), /var/lib/gamecult/ghostlight-dungeon 17 GiB of v1 world state (the v2 kernel refuses earlier schemas; nothing migrates it), ghostlight-dungeon-quarantine 44 KiB, ghostlight-import-20260821T1858Z 8 KiB. Disk / is 20% of 2 TB used.
  • The v1 actuator and its tests live in gamecult-ops: scripts/deploy-ghostlight-yggdrasil.sh, scripts/test-ghostlight-yggdrasil-wiring.sh; the runbook's "One-time state cut" section (runbooks/ghostlight-dungeon-yggdrasil.md:456-499) describes that path.
  • Idunn's leftovers: 12 failed idunn-ghostlight-* transient units (11 from the 09-23 bring-up, 1 from 09-30), 15 release dirs under /srv/ghostlight-idunn/releases (403 MiB), per-transaction staging dirs under /var/lib/gamecult/idunn/staging. These are Idunn's: it launches the units and owns release retention. Phase 1 does not hand-clean them.
  • Hostname is yggdrasil-candidate. Heimdall still runs as the legacy heimdall.service; every Idunn v2 deploy of it has failed (idunn status).

The repo

  • The pre-rebuild research machine is still on main: tools/ (17 files, 4,146 lines, including the Python state store), schemas/ (14, 4,057), examples/ (194, 37,421), experiments/ (172, 12,325), acceptance/, prompts/, scripts/, output/, assets/newspaper/, state/ (map.yaml, evidence ledger and archive, a CultCache JSONL, corpus coverage, branches), docs/aetheria/, root package.json scripts pointing at a Codex Python runtime, and about 30 pre-rebuild architecture and note pages. No file outside Ghostlight references Ghostlight/schemas, ghostlight_state_store or ghostlight.agent_state (grep over F:\Projects, excluding build output).
  • AGENTS.md (234 lines) instructs agents to treat state/ and the Python store as canonical state and the implementation plan as current.
  • Live docs that cite the old world/*.rs paths: ghostlight-world-consumer-api.md:19,31,33,38,70; ghostlight-world-ontology.md:53,74,167,202,203,215,232,277; notes/fresh-workspace-handoff.md:253; notes/local-live-smoke.md:56. The code is flat under crates/ghostlight/src/. Dated cut documents and postmortems keep their paths: they are history.

Who refuses an operational in-flight row whose scope moved? (probed 2026-10-17, 4741d50)

  • Three paths answer the same fact (the subject still holds one opportunity on the bound scope, at a new digest), with two errors. A probe test run on Yggdrasil returned these, with one provider call in total:
    • The scope moves during round 0 of a fresh run. Round 1's ensure_scope_unchanged returns NoOpportunity.
    • A resume passing the row's own stale opportunity returns NoOpportunity (same gate).
    • A resume passing the fresh opportunity returns OpportunityMismatch, from the digest clause of row_answers_caller at lookup.
  • The driver's classify (verse/run.rs 641-651) labels the first two OpportunityGone and the third ScopeMoved. That makes the first two mislabels: the label's doc says OpportunityGone means the subject holds no opportunity. Cause: when select_scope's digest clause empties the match list, it answers NoOpportunity (controllers.rs ~1552).
  • In the narrative lane, the lookup digest clause is the only gate. A driver resume with the fresh opportunity is refused there. Fork A's kernel-refusal re-lowering (1ef973a) runs only when a caller passes the stale opportunity.
  • Grouped lane (probed 2026-10-17, 771a79d, by reading; select_one now returns ScopeMoved): run_group maps every select_one error to LeftBeforeSelection, which the driver records as OpportunityGone. A member whose scope moved is dropped before any constituent, inference or submission exists, so only the label is wrong; spend is identical and nothing branches on the reason (grouped-record-and-carry keys on membership in refused). Own cut: cut-grouped-scope-moved-label.r1.

Who refuses a resumed narrative row whose scope moved? (probed 2026-10-17, 73f26a5)

  • Today, at lookup, by the caller's digest. run_narrative's Confirmed and CustodyUncertain arms (controllers.rs 3144, 3152) call row_answers_caller, the only callers it has since in-flight-gate (771a79d). A fresh caller is refused OpportunityMismatch; a stale caller passes to Fork A. Nothing in Projector, Persona or Interpreter rounds reads the world (source read: self.select is called only at fresh run, round checks of the operational lane, and the other lanes' fresh paths).
  • The kernel path is already whole. submit_narrative's ScopeChanged arm calls interrupted, which re-lowers once. A second move reads interrupted_from.is_some() and ends the turn overtaken, with nothing submitted and no further inference. The progression arm (~2375) refuses to persist a second re-lowering even if the runner tried. Pinned by a_second_scope_change_after_the_re_lowering_spends_nothing.
  • Probe (Yggdrasil, ygg-verify rust, clone at 73f26a5, sed in the job; script probe1.sh in the imagination-ag5-narrative scratchpad): both lookup arms put on same_decision, and OpportunityMismatch moved from ScopeMoved to OpportunityGone in classify. cargo test -p ghostlight --lib: 803 passed, 3 failed. The failures were a_narrative_row_in_flight_whose_scope_moved_is_refused_before_inference (the resumed row now called the provider and ran the test port dry), a_narrative_row_in_flight_in_uncertain_custody_keeps_the_digest_gate, and an_error_ends_a_cell_as_the_run_must_treat_it (the classify table). No verse driver test reached ScopeMoved through OpportunityMismatch.
  • OpportunityMismatch is no longer a moved scope anywhere. After the cut it arises from same_decision (another decision), select_scope's controller-id check, and the Persona lane's forged-field refusals. The driver cannot reach a same_decision mismatch, because run_cell derives the command id from world, cell, subject and tick.
  • Dead code. Both awaits_world methods have no production caller once row_answers_caller goes. The operational one has been test-only since 771a79d. The only other reader is the NeverSentStore test fixture.

Model page

One row per persistent kind phase 1 touches. No cell may be empty.

Kind Named by Life over time Who decides
World state and commit journal (.cc, redb) WorldId; revision; digest chain created Draft, approved, activated; one commit row per admitted command; earlier schemas refused, never migrated the kernel, through WorldMailbox's closed operation set; untouched in phase 1
Play turn row (play-turn store .cc) turn_id (UUID); per-turn question_token Running → AwaitingPlayer ↔ Running → Closed (narration or fault); an unreadable row is retired to a sidecar and play starts fresh PlayTable (play.rs); the card is a projection of PlayTurnView, which phase 1 extends with the fault
App session (app-sessions-v2.cc) cookie hash; heimdall_session_id + access_revision issued at sign-in; refreshed when access is near expiry; revoked on logout, custody change, invalid receipt, and (phase 1) on a refresh that can no longer succeed AppSessionOwner; Heimdall decides the upstream session and rotates refresh tokens
Heimdall private command receipt (Heimdall Postgres) (app_slug, idempotency_key) + request fingerprint written once per executed command; replayed for an identical request; a different request under the key is refused Heimdall; its fingerprint rule is a Heimdall follow-up, not a phase-1 cut
Idunn generation (transient unit, release dir) sha256-<release> staged → started → Warming → lease → Active → superseded or failed; the previous Active stays until a new one is Ready Idunn; retention and failed-unit collection are Idunn's follow-up
v1 body (ghostlight-dungeon.service, /srv/ghostlight, v1 state) unit name; paths retired in fact 2026-09-23; unit still enabled nobody today; phase 1 assigns its removal to a gamecult-ops cut, data by operator ruling
Bonsai lifecycle (Raven containers) container bonsai; scripts under /opt/gamecult/bonsai started by hand ("Start AI Chat"); restarted by the RSS watchdog; stopped by the idle reaper gamecult-ops/Raven; Dungeon only reads the endpoint; availability policy is an operator question
Repository docs (README, AGENTS, live architecture pages, handoff) path on main rewritten to the live machine; pre-rebuild material leaves main by operator ruling the Ghostlight repo; history in git, dated cut documents and postmortems
Controller-work row (controller-work.cc, narrative and operational checkpoints) CommandId (driver: derived from world, cell, subject, tick) persisted before each spend; advanced only by the progression rule; a narrative row re-lowers at most once (interrupted_from), then commits, declines or ends overtaken which decision a row answers: same_decision, both lanes, the caller naming identity only. Whether its scope moved: the world (operational: ensure_scope_unchanged before each round; narrative: the kernel's ScopeChanged at submit, answered by interrupted). Labels: classify alone

Where phase 2's verb sets plug in

Ruling verse-verbs-two-render-paths and invariant every-event-renderable bound what the kernel may admit to two render paths. Today:

  • The thirty operations (ghostlight-world-ontology.md:451-478) are state transitions, not verbs. They stay the closed substrate.
  • What an actor does is an affordance: a world-authored catalog entry whose kind is AffordanceKindName (patch.rs:247-254), free canonical text that "the kernel carries and branches on nowhere", plus the kernel-built speak (controllers.rs:94). Admitted acts surface as DecisionEvents on the commit.

So phase 2's two verb sets plug in at the affordance kind: a closed vocabulary of kinds, each tagged with its one render path, with effects that still compile to the thirty operations. Today the Play authority can declare any affordance kind, so nothing on main enforces the invariant yet. Phase 1 neither widens nor narrows that, and its docs must not describe open-ended verbs as the target.

Rationale

  • "main dies" was a misreading. The deploy that died carried no code change, and it died on Odin's route, not on Ghostlight. So phase 1's deploy work is mostly preflight and proof: redeploy main after the play fixes, with Odin's route and IO pressure checked first. The Odin outage is recorded against its owner.
  • Subtraction goes first and alone. The pre-rebuild tree is about 60,000 lines that the live machine never reads. It is cut before the docs are rewritten, so the rewrite describes only what is left. Parking it at a tag rather than deleting it outright is the operator's call (question).
  • Faults belong to the card, not the log alone. PA.f212 is an authority gap: the turn store knew the fault and the projection dropped it. The fix extends the projection; it adds no second record.
  • Retrying inference is safe. A model call is a proposal the kernel admits or refuses, so a transport fault before any reply is retryable. That is the Ghostlight half of PA.f211. The Raven half (the watchdog's quiet window) belongs to gamecult-ops, which also takes the scripts into version control.
  • The refresh loop is a contract split. Ghostlight assumes a stable key makes a retry safe; Heimdall's fingerprint makes every retry new. Ghostlight stops looping (a refresh that can no longer succeed revokes the local session). Heimdall's fingerprint rule goes to Heimdall as a follow-up.
  • The bridge tests are the gate's blind spot. They are the only Dungeon tests that drive the real client, and nothing runs them. Fixing the gate means a runner with both Rust and Node, which is Idunn's recipe and runner surface: a follow-up, with each phase-1 Hands run doing them by hand.
  • A narrative resume has one detector, and it is the kernel (ruling narrative-resume-relowers). The caller's opportunity names the decision and nothing more, in both lanes, through the one same_decision. The lanes differ only in when the world is asked. The operational lane asks before each round, because it has nothing to preserve. The narrative lane asks at submit, because its prose and receipts survive to be lowered again. The price is the remaining rounds plus at most one Interpreter call. OpportunityMismatch then stops meaning a moved scope, so classify labels it OpportunityGone, as the grouped lane already does. Keeping it on ScopeMoved would leave a second, wrong answer to "did the scope move". The grouped selection label becomes a pure function, because AmbiguousOpportunity cannot be built through a live world, which offers one opportunity per scope. Pinning the function is what closes follow-up grouped-left-arms-pin.