{
  "summary": "## Verdict\n\nIssue A is a **missing terminal transition that leaves a stale raw wake intent**, not an honest statement that perri is alive and not a general “desired always-on” policy.\n\n`rest_state=\"active\"` has two uses in the current design:\n\n1. For a warm instance it means the instance is actively driven (`crates/spt-daemon/src/resting.rs:1-21,74-103`; `CONTEXT.md:279-291`).\n2. Immediately after a cold `Wake` edge it is also the durable **pending-wake intent** consumed asynchronously by livehost (`docs/adr/0033-wake-resume-via-reconcile-intent.md:15-34`; `crates/spt-daemon/src/livehost.rs:199-204,304-405`).\n\nThat second use is valid only during the accepted-not-bound wake window. When the broker authoritatively reports that a previously online spt-hosted session is gone, `reconcile_hosted_liveness` changes only `status` to `offline`; it does not land the rest machine in `Suspended`. The leftover raw `Active` is then mistaken for a fresh pending wake by `resume_woken_endpoint`, causing repeated unwanted resumes.\n\n## Observed perri state — read-only\n\n- `C:/Users/decid/AppData/Local/spt-core/owlery/perri/info.json:1`: `state=\"live_agent\"`, `status=\"offline\"`, `rest_state=\"active\"`, `controllable=true`, `controlled=false`, `adapter=\"claude-spt:ccs\"`, old bound `session_id=\"79205491-…\"`.\n- The perri directory contains no `ready` marker. Therefore listener/message readiness is false independently of the rest field.\n- `resume.pid` contains `76408`. Per `crates/spt-daemon/src/livehost.rs:290-301`, this file is custody-only and is explicitly not a liveness input. I did not process-probe or mutate it.\n- `sessions.log:20-24` ends at ordinal 24, `session_id=\"anchor-int-proof\"`, trigger `boot`, adapter `claude-spt:ccs`.\n- Because daemon-hosted `status` is authoritative, `is_perch_alive` returns false for `status=\"offline\"`, regardless of the old `info.pid` (`crates/spt-store/src/liveness.rs:123-145`). `is_perch_unbound` is also false because status is not `unbound` (`:110-119`).\n- The exact historical “truly down” observation is the broker session-set miss that produced `LIVENESS_RECONCILE_OFFLINE:perri` in `daemon.stderr.log.1:934`; that path runs only after the broker returned a session set without perri (`livehost.rs:649-753`).\n- The raw field does **not** make effective state Active: `effective_rest_state(alive=false, unbound=false, Some(Active)) == Suspended` (`resting.rs:210-229`). Registry self-gossip therefore reports cold perri as `Status::Suspended`, never `Active` or self-gossiped `Offline` (`registryhost.rs:809-860`).\n\n## Causal chain and earliest incorrect transition\n\n1. A fresh brain starts (`daemon.stderr.log.1:899-903`). At this point perri had a prior online record, raw Active rest intent, a ledger row, and a live custody PID.\n2. Restart survival runs first. It sees no broker session but a live custody PID and refuses to duplicate the harness: `DAEMON_RESTART_RESUME_REFUSED:perri` at `daemon.stderr.log.1:928`. This is the intended duplicate guard implemented by `restart_resume_gate` / `resume_restart_orphaned_endpoints` (`livehost.rs:490-620`).\n3. The boot liveness pass then receives a broker session set with no perri. It clears stale control stamps and writes `status=offline`: `CONTROL_REAP_NO_SESSION` / `DRIVEN_BY_SELFHEAL_OFFLINE` / `LIVENESS_RECONCILE_OFFLINE` at `daemon.stderr.log.1:932-934`.\n4. **Earliest incorrect/incomplete transition:** `reconcile_hosted_liveness` at `livehost.rs:673-744`, specifically `BrainLifecycle::mark_offline` at `:727`, calls `set_status(offline)` only (`lifecycle.rs:566-569`). It does not write `rest_state=suspended` or clear `dormant_since_ms`. Thus the authoritative session-loss edge lands only half of the durable state change: local liveness becomes offline while raw rest intent remains active.\n5. The host loop deliberately orders liveness reconciliation before `reconcile_once` (`livehost.rs:997-1048`). `reconcile_once` sees `status != online` and calls `resume_woken_endpoint` (`:199-204`). That reader intentionally bypasses `effective_rest_state` and reads the raw rest intent (`:359-370`); `decide_resume` treats raw `Active` plus a dead custody PID plus ledger/adapter material as `Resume` (`:322-348`).\n6. Once the old custody process is no longer alive, livehost resumes `anchor-int-proof`: `WAKE_RESUME:perri` at `daemon.stderr.log.1:1026`, then again at `:1305` after another daemon cycle.\n7. The current log shows the loop continuing against the same ledger session: `daemon.stderr.log:82,1335,2410,2733,2933`. Each launch creates a broker session/controller connection; later controller/session detach sequences occur, followed by another resume (for example `:1309-1311 -> :1332-1337`, `:2385-2387 -> :2407-2413`, `:2708-2710 -> :2730-2735`, `:2922-2924 -> :2930-2935`). No later successful bind updates the durable record shown above, so the record remains offline/raw-active.\n8. On later boots, because perri is already offline, `reconcile_hosted_liveness` only performs the unconditional stale-control reap and then skips the liveness transition at `livehost.rs:705-710`; `reconcile_once` still sees raw Active and resumes. Current log `:62` then `:82` exhibits exactly that already-offline path.\n\n## Minimal source fix\n\nThe smallest fix I would accept is to make a **definitive spt-hosted session-loss edge atomically land both facts**:\n\n- `status = offline`\n- `rest_state = suspended`\n- `dormant_since_ms = None`\n\nAdd a single locked RMW helper—preferably a domain-named `mark_session_lost` / `mark_offline_suspended` implemented through `spt_store::info::mutate_info`—and use it in `reconcile_hosted_liveness` instead of `BrainLifecycle::mark_offline`. Do not perform two independent setter writes; the store explicitly requires one stable-sentinel RMW for concurrent writer safety (`crates/spt-store/src/info.rs:285-368`). Do not route this through `daemon_rest_event(Suspend)` after stamping offline: once liveness is cold, `effective_rest_state` is already Suspended, so `Suspend` returns `NO_EDGE` and never repairs the raw field. Do not fire a transition echo after the harness is already gone; there is no outgoing live runtime to summarize, and arming an echo gate would defer a stale echo into a future session.\n\nApply the same atomic terminal-state helper to `spt endpoint stop`: `crates/spt/src/cli.rs:6935-6958` currently removes ready/address and stamps only `status=offline`, so stopping an Active live agent can create the same offline/raw-active shape. For endpoint types outside the live-agent resting model, retain the existing status-only behavior or have the helper gate on `state == \"live_agent\"`.\n\nThe normalization must occur on the **observed transition from `online` or `unbound` to offline**, not as a blanket rule for every already-offline/raw-active record. A real explicit Wake starts from an already-cold record: `apply_event` derives Suspended, persists raw Active, and expects livehost to consume it asynchronously. Clearing every offline/raw-active row in the liveness pass would erase legitimate wakes because the loop runs liveness reconciliation before resume.\n\nRestart survival remains ordered ahead of the liveness pass (`livehost.rs:969-1048`). A successful `resume_restart_orphaned_endpoints` call inserts the new broker session synchronously before the subsequent session query, so it is not normalized offline. The focused restart integration below must pin this invariant. The one compatibility risk needing an explicit Doyle ruling/test is the `RefuseLivePid` boot race: today a live-but-sessionless custody process is offlined, and raw Active permits a later retry after that PID dies. Normalizing that edge to Suspended stops that implicit retry. This retry is not what the dedicated endpoint-survival integration currently proves—it proves a dead-custody orphan is re-run by the one-shot pass—but if retry-after-refusal is required, the raw `rest_state` field cannot safely distinguish “stale previous Active” from “explicit pending Wake.” In that case the smallest semantically complete extension is an explicit pending-wake/restart claim bit or marker, with livehost gated on that claim rather than raw Active alone. A blanket effective-state read is not viable because every cold explicit Wake derives Suspended and would never resume.\n\n## Focused regression tests\n\n1. Extend `livehost::tests::pull_liveness_marks_sessionless_spt_hosted_offline_only` (`livehost.rs:1110-1205`): seed the dead spt-hosted perch with raw Active; after an empty broker-session result assert one atomic record has `status=offline`, `rest_state=suspended`, and no dormant anchor. Assert live-session, relay, legacy, and ready-agent records are unchanged.\n2. Extend `pull_reconcile_offlines_perch_when_broker_session_dies` (`livehost.rs:1500-1552`): start with a real broker session and raw Active, kill/reap the session, reconcile, assert offline+suspended. Then run `reconcile_once` with ledger/adapter material and assert no new session / no new `resume.pid` / no `WAKE_RESUME` attempt.\n3. Add a direct perri-shape unit: online + controllable + raw Active + ledger `anchor-int-proof` + dead custody + empty live set; liveness reconcile then start reconcile. Assert the first phase lands Suspended and the second phase does not resume.\n4. Extend `cli::tests::endpoint_stop_marks_offline` (`crates/spt/src/cli.rs:13401-13420`): seed raw Active, call `cmd_stop`, assert ready absent, status offline, raw rest Suspended, anchor absent; then ensure the livehost resume decision is Skip.\n5. Preserve `shutdown_soft_stops_and_suspends` (`cli.rs:16031-16058`): shutdown already removes ready first and applies a real Suspend edge, so it must continue landing Suspended idempotently.\n6. Add an explicit-wake precedence test beside the A-2 tests (`livehost.rs:1900-2065`): start already offline+suspended, apply Wake, run the liveness pass, and verify raw Active is not cleared merely because the record was already offline; `reconcile_once` still reaches the resume leg. This prevents the tempting blanket-offline normalization regression.\n7. Keep/extend `crates/spt-daemon/tests/endpoint_survival.rs:1-170`: seed raw Active on the previously-online orphan, run the one-shot restart resume, then the liveness pass; assert the broker session survives and the record is not normalized offline/suspended.\n8. Extend `crates/spt/tests/livehost_bootgate_e2e.rs:1-193`: seed the cold-start phantom with raw Active and assert both status offline and raw rest Suspended, plus no phantom host. This covers the exact boot ordering that exposed perri.\n9. Add the `RefuseLivePid` matrix case: previously online + Active + no broker session + live custody PID. Pin the chosen contract—either defer terminal normalization while custody is genuinely in-flight, or normalize to Suspended and require an explicit later Wake. Do not leave this behavior implicit because it is the exact first leg of perri’s observed chain.\n\nNo endpoint, daemon, durable record, session, or file was mutated; no tests/builds were run, per the read-only assignment.",
  "files": [
    {
      "path": "CONTEXT.md",
      "description": "Lines 279-291 define active/dormant/suspended/offline and require effective instance state to derive from liveness plus rest intent; cold raw Active is not effective Active."
    },
    {
      "path": "docs/KNOWN-HAZARDS.md",
      "description": "Lines 599-602 record the shared effective-state invariant and forbid independent liveness-blind rest derivations."
    },
    {
      "path": "docs/adr/0033-wake-resume-via-reconcile-intent.md",
      "description": "Lines 15-34 establish thin Wake, reconcile-driven resume, status-online-only-at-bind, retryability, and raw Active as pending wake intent."
    },
    {
      "path": "crates/spt-store/src/info.rs",
      "description": "Lines 123-133 define rest_state as distinct from status; lines 285-368 provide serialized locked mutation and the sole set_rest_state writer. This is the correct atomic seam for offline+suspended."
    },
    {
      "path": "crates/spt-store/src/liveness.rs",
      "description": "Lines 32-45 and 110-150 define status/online/offline/unbound and daemon-authoritative is_perch_alive; old PID and rest_state do not make an offline daemon-hosted perch alive."
    },
    {
      "path": "crates/spt-daemon/src/resting.rs",
      "description": "Canonical machine: RestState/Event and transition table at 74-163; effective derivation at 210-229; event host/persistence at 258-390; read/write/deferred gate at 449-500."
    },
    {
      "path": "crates/spt-daemon/src/attach.rs",
      "description": "Rest event feeds: helper 61-63; controller Wake at 500-501; explicit controller Detach/error/exit paths at 400-405 and 550-600. Broker/session loss outside a completed served viewport still needs liveness reconciliation as a terminal backstop."
    },
    {
      "path": "crates/spt-daemon/src/dispatch.rs",
      "description": "Lines 704-732 feed AttentionShift for locally hosted instances when registry attention flips."
    },
    {
      "path": "crates/spt-daemon/src/resthost.rs",
      "description": "Lines 52-135 parse and serve remote Suspend/Wake through the same daemon_rest_event host."
    },
    {
      "path": "crates/spt-daemon/src/lifecycle.rs",
      "description": "Lines 566-569 contain the incomplete status-only mark_offline; 570-615 read rest state for pulse recency/auto-suspend; 771-823 host runtime-owned rest events."
    },
    {
      "path": "crates/spt-daemon/src/livehost.rs",
      "description": "Core defect and action chain: raw Active resume at 199-204 and 304-405; shared launch and restart pass at 417-620; status-only broker-session liveness reconcile at 649-753; boot ordering at 969-1048; relevant unit/A-2 tests at 1110-1205, 1500-1552, 1900-2065."
    },
    {
      "path": "crates/spt-daemon/src/registryhost.rs",
      "description": "Lines 809-860 derive advertised state through effective_rest_state; a cold live-node perch self-gossips Suspended, not raw Active and never Offline."
    },
    {
      "path": "crates/spt/src/cli.rs",
      "description": "Suspend/Wake routing at 1366-1370 and 3657-3703; graceful shutdown removes ready then suspends at 3833-3867; hard endpoint stop removes ready/address but stamps only offline at 6935-6958; focused tests at 13401-13420 and 16031-16058."
    },
    {
      "path": "crates/spt/src/api/startup.rs",
      "description": "Lines 389-430 preserve rest_state/dormant anchor across re-bind and create ready; status becomes online only at the real live bind paths (637-640, 758-761)."
    },
    {
      "path": "crates/spt/src/api/delivery.rs",
      "description": "Line 218 is a production raw rest reader through deferred_held: Dormant/Suspended hold deferred delivery, Active releases it."
    },
    {
      "path": "crates/spt/src/api/worker.rs",
      "description": "Line 130 applies the same deferred-held raw rest gate for worker polling."
    },
    {
      "path": "crates/spt-daemon/src/inject.rs",
      "description": "Line 116 applies deferred_held to the idle-inject drain, completing the production deferred-reader inventory."
    },
    {
      "path": "crates/spt-daemon/src/shellwake.rs",
      "description": "Lines 306-315 read owner raw rest state; Suspended owners receive a Wake before shell relaunch, Dormant owners stay warm."
    },
    {
      "path": "crates/spt-daemon/tests/endpoint_survival.rs",
      "description": "Lines 1-170 prove the separate one-shot daemon-restart invariant for a previously-online dead-custody orphan; should be extended to guard interaction with normalization."
    },
    {
      "path": "crates/spt/tests/livehost_bootgate_e2e.rs",
      "description": "Lines 1-193 exercise the exact cold-start empty-broker-session boot gate; currently asserts only offline/no phantom, not raw rest normalization."
    },
    {
      "path": "C:/Users/decid/AppData/Local/spt-core/owlery/perri/info.json",
      "description": "Observed current durable record: status offline, raw rest active, controllable live_agent, old bound session."
    },
    {
      "path": "C:/Users/decid/AppData/Local/spt-core/owlery/perri/sessions.log",
      "description": "Observed ledger tail: ordinal 24, anchor-int-proof, boot, claude-spt:ccs."
    },
    {
      "path": "C:/Users/decid/AppData/Local/spt-core/owlery/perri/resume.pid",
      "description": "Observed custody marker 76408; source explicitly says custody-only, not liveness."
    },
    {
      "path": "C:/Users/decid/AppData/Local/spt-core/logs/daemon.stderr.log.1",
      "description": "Earliest exact chain: restart refusal line 928, status-only liveness transition lines 932-934, unwanted resumes lines 1026 and 1305."
    },
    {
      "path": "C:/Users/decid/AppData/Local/spt-core/logs/daemon.stderr.log",
      "description": "Repeated present-generation resumes of the same anchor at lines 82, 1335, 2410, 2733, 2933 with intervening controller/session detach sequences."
    }
  ],
  "architecture": "## Rest-state writer/reader map\n\n```mermaid\nflowchart LR\n  A[Attach controller] -->|Wake| E[daemon_rest_event/apply_event]\n  D[Attach end/error] -->|Detach| E\n  R[Registry attention flip] -->|AttentionShift| E\n  C[CLI / remote rest] -->|Suspend or Wake| E\n  P[Pulse] -->|AutoSuspendTick| E\n  E --> W[write_rest]\n  W --> S[spt_store::info::set_rest_state]\n  S --> I[info.json rest_state + dormant_since_ms]\n\n  I --> X[effective_rest_state]\n  L[status / is_alive / unbound] --> X\n  X --> G[registry advertised state]\n  X --> E\n\n  I --> H[deferred_held]\n  I --> U[pulse recency/auto-suspend]\n  I --> Q[shell wake resolution]\n  I --> V[livehost raw Active resume gate]\n\n  B[Broker session set] --> O[reconcile_hosted_liveness]\n  O -->|currently status only| L\n  O -. missing .->|Suspended terminal state| I\n  V -->|raw Active + cold + dead custody| Z[WAKE_RESUME]\n```\n\n### Production writers\n\n- The only low-level field-pair writer is `spt_store::info::set_rest_state` (`info.rs:359-368`), which atomically writes `rest_state` and `dormant_since_ms` under `mutate_info`.\n- Normal state changes all funnel through `resting::apply_event -> write_rest -> set_rest_state` (`resting.rs:258-309,498-500`). Event producers are controller attach/detach (`attach.rs:61-63,400-405,500-501,550-600`), registry attention shift (`dispatch.rs:720-725`), local CLI Suspend/Wake (`cli.rs:1366-1370,3657-3703`), remote rest serving (`resthost.rs:60-63,120-135`), shell owner wake/shutdown (`shellwake.rs:306-310`; `api/reporting.rs:647-660`), and lifecycle auto-suspend ticks (`lifecycle.rs:595-604,791-823`).\n- `api/startup::establish_perch` does not author a transition, but because it replaces the whole record it deliberately carries prior `rest_state` and `dormant_since_ms` forward (`startup.rs:389-425`). This prevents a bind from wiping accepted wake intent.\n- Production direct `write_rest`/`set_rest_state` calls outside the transition host were not found; remaining grep hits are tests/fixtures.\n\n### Production readers\n\n- Correct effective readers: `apply_event` and `registryhost::advertised_status` both combine `is_perch_alive`, `is_perch_unbound`, and parsed intent through `effective_rest_state` (`resting.rs:271-285`; `registryhost.rs:850-860`).\n- Raw-intent readers with narrower purposes: lifecycle pulse uses Active/Dormant/Suspended to decide recency versus auto-suspend (`lifecycle.rs:595-604`); `deferred_held` gates deferred messages and is consumed by `api/delivery.rs:218`, `api/worker.rs:130`, and `daemon/inject.rs:116`; `shellwake.rs:306-315` decides whether an owner needs Wake; startup carries the raw pair across record replacement.\n- The load-bearing exception is `livehost::resume_woken_endpoint`: it must inspect raw Active rather than effective state because a cold explicit Wake remains effectively Suspended until bind. This is also why stale raw Active is actionable rather than cosmetic (`livehost.rs:322-405`).\n\n### Lifecycle distinctions\n\n- **Ready:** the `ready` marker is listener/message readiness. Bind creates it (`startup.rs:430`); shutdown/stop remove it (`cli.rs:3862,6939`). It is not rest state and not sufficient daemon-hosted liveness.\n- **Local status:** `info.status=online|unbound|offline` is daemon/broker lifecycle truth. `is_perch_alive` trusts it over PID (`liveness.rs:123-145`).\n- **Broker session:** for controllable spt-hosted endpoints, membership in the broker session set is the definitive runtime seat signal used by `reconcile_hosted_liveness` (`livehost.rs:649-753`). A broker session may exist before bind (`unbound`) and can exist while the old durable status remains offline.\n- **Effective instance state:** Active/Dormant/Suspended is derived from liveness plus raw intent. Cold always derives Suspended even when raw intent is Active (`resting.rs:210-229`).\n- **Registry Offline:** per `CONTEXT.md:279-291` and `registryhost.rs:809-860`, registry `Offline` means the node is down/unroutable and is remotely inferred. A live node self-gossips a cold endpoint as Suspended. This differs from local `info.status=\"offline\"`, which means the local hosted seat is torn down.\n- **Raw rest intent:** refines warm attention and temporarily carries an accepted cold Wake. It is not proof of readiness, broker-session existence, local alive status, or a durable “keep this service running forever” goal.\n\n### Stop/shutdown/suspend/wake/reconcile behavior\n\n- `endpoint suspend`: routes `RestEvent::Suspend`; Active or Dormant lands Suspended, idempotent on Suspended.\n- `endpoint wake`: cold effective Suspended lands raw Active, fires wake effects, then returns accepted-not-bound; livehost later resumes and only a real bind may stamp online.\n- `shutdown`: removes ready, then runs the Suspend transition. It already aligns raw rest state with graceful self-shutdown.\n- `endpoint stop`: removes ready and address and stamps only local status offline. It currently omits the rest transition and is a sibling producer of offline/raw-active.\n- Controller detach/exit: explicit served-attach paths emit Detach, which lands Dormant while the session remains warm. A dead broker session must subsequently land Suspended, not remain Active/Dormant.\n- `reconcile_hosted_liveness`: correctly detects no-session, clears stale control stamps, and changes status to offline, but omits the raw terminal rest landing. That is Issue A’s source.\n- `reconcile_once`: intentionally uses offline + raw Active as the asynchronous wake-resume trigger; therefore the omitted landing becomes an active retry loop.\n\nThe proposed atomic terminal write repairs the source transition while preserving the designed separation: status remains liveness, raw rest remains attention/wake intent, and effective state remains derived."
}