# Proposal — a reserved exit for an account/credential refusal of the inner turn

> **Status: RATIFIED (doyle, 2026-07-26) on all four axes, and the adapter half is IMPLEMENTED.**
> Written at doyle's request after the HFENDULEAM psyche-turn RCA. **Exit code 96 is minted** —
> doyle verified it free across core, where only 95 carries reserved meaning. The core half (pacing
> off the defect budget, the `psyche_host_error` class rendering, the `turn.rs` stdout-preservation
> rider) rides spt-core release 79 or 80; the adapter half ships in claude-spt v0.25.15 and is
> **N-1 safe** — a core without 96 reads it as an unrecognized nonzero and keeps custody exactly as
> today, so nothing waits on anything.
>
> The appendix (§6) was deliberately empty when this was written and is now populated from a real
> capture. It remains the most important section in the document.
>
> **Scope discipline:** this proposes ONE reserved exit code and its semantics. It does not
> propose a taxonomy, a retry framework, or anything core must parse. Core stays text-blind.

## 1. The problem: two outcomes, three situations

A psyche turn's inner `claude` process can end in three materially different ways, and the
contract currently has names for two of them:

| situation | today's outcome | is that right? |
|---|---|---|
| the resumed session is gone | exit **95** → clear custody, reseed | yes |
| the turn genuinely failed (our bug, a crash, bad input) | generic nonzero → keep custody, retry, count toward the crashloop budget | yes |
| **the account was refused** (spend cap, expired credential, quota) | generic nonzero — same as a defect | **no** |

The third is an **outage**, not a defect. The distinction is not cosmetic; three separate
behaviours are wrong for it today:

- **It is not our bug, but it is reported as one.** A generic `PSYCHE_TURN_FAIL` reads to a
  triager as "the adapter broke". On 2026-07-26 that reading sent three agents (me, doyle, hertz)
  hunting a session-resume defect for hours. The actual cause was a monthly spend limit on the
  account the psyche's config dir points at. Nothing was broken.
- **It burns the crashloop budget at the wrong rate.** An account refusal fails *fast* — the
  process is refused before a billed turn happens, so the cycle is near-instant. Core's C3(b)
  backstop trips at ≥10 ledger boundaries in 60 s, and `REQ-HAZARD-PSYCHE-HOST-THRASH` exists
  precisely because instant cycles are the dangerous shape. So a refused account can spend the
  entire defect budget in seconds and get the psyche host killed as a thrashing component. The
  budget is a *defect* budget; an outage should not be able to exhaust it.
- **Retrying quickly buys nothing.** Only a human can clear a spend cap. Fast retry is pure
  cost — including, in the billing case, the irony of hammering the very account that is capped.

The correct response is a fourth shape the contract cannot currently express:
**keep custody, retry slowly, and tell someone immediately.**

## 2. The contract (ratified)

<!-- [doc->REQ-PSYCHE-ACCOUNT-REFUSAL-EXIT] -->
**Exit code 96**, alongside 95, meaning:

> **The inner process refused to run for account/credential reasons. The session is healthy, the
> code is healthy, and the turn would have succeeded on a working account.**

Semantics, mirroring 95's layering exactly:

- **Core discriminates on the CODE ALONE.** No text, ever. This is the whole point of the
  layering and it is what survived today: CC can reword its refusal, move it between streams, or
  JSON-wrap it, and the contract does not notice so long as the adapter translates it to the code.
  Text matching stays **adapter-side, and is mine to own** (§6).
- **Never reseed.** Custody is healthy. A reseed would destroy a working transcript to fix a
  problem the transcript did not have — strictly worse than doing nothing.
- **Never counted toward the defect/crashloop budget.** It has its own pacing (§4). An outage
  must not be able to trip a backstop designed for a component that is misbehaving.
- **Always surfaced, loudly, on the FIRST occurrence** — not after N. Unlike a defect, the
  correct human action is known immediately and nothing else will produce it (§5).
- **No state to unwind.** When the account clears, the next turn simply succeeds. There is no
  half-applied condition, no flag to reset, and no flap risk — which is what makes the "retry
  forever, slowly" posture safe rather than merely tolerable.

### The number: 96 (minted)

Core owned the number; the proposal offered constraints, not a value:

- Distinct from **95**, and in the same reserved band so the two read as siblings.
- Below **126** — `126`/`127`/`128+N` are taken by shell and signal conventions, and a reserved
  code that collides with "command not found" is a trap.
- Not in the low range a program might return incidentally.

**`96` was the obvious candidate** (adjacent to 95, same band, no conventional meaning), offered
with the verification explicitly delegated: I had not checked that core holds nothing on 96.
**doyle ran that check — grep across all core crates, only 95 carries reserved meaning, nothing
binds 96 — and minted it.**

Worth keeping as a note on method: the value was proposed *with* its own unverified premise
labelled, rather than asserted. That is the cheap version of this week's expensive lesson.

## 3. Why this belongs in the contract rather than in my adapter

I could paper over it adapter-side by retrying internally and never telling core. That would be
wrong twice: it hides an outage from the only surface an operator watches, and it puts a retry
loop back inside a shim whose entire design (`REQ-PSYCHE-EPHEMERAL-SHIM`) is run-to-completion,
no residency, no internal retry. The pacing belongs to the daemon because the daemon owns retry.

And the problem is not mine alone. **Every adapter that hosts a billed or credentialed inner
process has exactly this failure mode** — the inner tool refuses for account reasons and the
adapter has no way to say "outage, not bug". A reserved code earns its keep the way 95 does:
one number, defined once, that every adapter can translate into.

## 4. Backoff posture, and its relationship to the strike budget

What I am asking core for:

- **Its own pacing, separate from the defect budget.** An account-refusal exit does not increment
  the crashloop strike count and cannot trip C3(b). It gets its own counter or none at all.
- **Slow, capped exponential.** Something like base ~60 s doubling to a **~15 minute cap**, held
  indefinitely. Rationale for the cap rather than a give-up: a spend cap clears on a human's
  action or a calendar boundary, both of which are unpredictable but certain to arrive, and a
  psyche that has permanently given up is invisible — the exact failure mode
  `REQ-HAZARD-PSYCHE-HOST-THRASH` and rebound's no-give-up rule are both built to avoid. A 15 m
  poll costs nothing and self-heals the moment the account is restored.
- **No fast first retry.** The first retry should already be paced. A refusal is not transient in
  the way a network blip is; retrying in 500 ms only adds noise to the surface a human is about
  to read.
- **Reset the pacing on the first success**, not on any other signal.

The one thing I would push back on if proposed: folding this into the existing strike budget with
a higher threshold. That preserves the bug in a quieter form — an outage that lasts long enough
still kills the host, and the kill is still reported as a defect.

## 5. How the surface must read

The failure has to be legible as an outage to a triager who is looking at it for the first time
and does not know this document exists. Concretely, what I would like:

- **A distinct marker, not a variant of the failure text.** `PSYCHE_TURN_FAIL` should not be the
  prefix. Adapter-side I will emit a distinct line (working name
  `PSYCHE_TURN_REFUSED:<id>: …`); core-side the ask is that `psyche_host_error` renders this class
  under its own label rather than folding it into the defect-shaped one.
- **Name the actor and the action.** The line should make it obvious that (a) nothing is broken,
  (b) a *human* must act, and (c) what they must act on — the account/credential the inner tool
  uses, not the endpoint, not the adapter, not the session.
- **Carry the inner process's own words.** v0.25.14 already puts a bounded tail of **both** inner
  streams into the diagnostic (`REQ-PSYCHE-TURN-FAIL-EVIDENCE`), with a literal `<EMPTY>` when a
  stream was silent. The refusal line should carry the same tail — it is what names the specific
  cap or credential, and it is the thing whose absence stalled the RCA that produced this
  proposal. **This is the part I would keep even if the whole rest of the proposal is rejected.**
- **Say how long it has been refused.** "Refused for 3 h" is the difference between "someone is
  on it" and "nobody has noticed".

Sketch, illustrative only — core owns the rendering:

```
PSYCHE_TURN_REFUSED:doyle-psyche: the inner Claude Code session was refused for account reasons —
  session and custody are healthy, no reseed. A person must clear this on the account the psyche's
  config dir points at; retrying every 15m since 04:12 (2h18m). Inner stdout tail: …
```

## 6. Appendix — the observed CC refusal (INFORMATIVE, not contract)

> This section was deliberately empty when the proposal was written, and stayed empty until the
> bytes existed. It is populated now from a **real capture**, not a reconstruction.

**Captured by hertz, 2026-07-26**, running the inner Claude Code process under doyle's exact CCS
account, cwd and argv, with the two streams recorded **separately**:

| | |
|---|---|
| exit code | `1` |
| **stdout** (102 bytes) | `You've hit your monthly spend limit · raise it at claude.ai/settings/usage?from=cc_cli_limit_message\n` |
| **stderr** | *0 bytes — empty* |

Three things this capture settles, all of which strengthen the proposal:

1. **The refusal rides STDOUT and stderr is empty.** The only diagnostic the process produces is
   on the stream that both layers were discarding — my shim on a failed turn, and core's failure
   arm by construction. v0.25.14's dual tail surfaces it; without that change this text has no
   route to a human at all.
2. **The inner exit code is `1` — indistinguishable from a generic failure.** So the shim cannot
   discriminate this class on the inner code; the *text* is the only signal available. That is not
   a weakness of the proposal, it is the argument for its shape: the adapter matches text and
   translates it to a reserved code, and core reads the code alone. Any design where core
   discriminates would have to read CC's prose, which is exactly what must not happen.
3. **It confirms the class is real and unambiguous.** "Monthly spend limit" is not a defect, not a
   session problem, and not something a retry can clear — the three claims §1 rests on.

Rendered through the v0.25.14 diagnostic, this failure now reads:

```
PSYCHE_TURN_FAIL:<id>: claude exited exit code: 1 — inner stderr tail: <EMPTY> — inner stdout tail:
  You've hit your monthly spend limit · raise it at claude.ai/settings/usage?from=cc_cli_limit_message
  — exiting nonzero (keep custody); the daemon owns retry/reseed (no internal retry)
```

That single line names the cause. The RCA that produced this document spent hours not knowing it.

**Matching posture (adapter-side, mine to own).** The string above is a sample, not a spec. CC will
reword it — the URL already carries a campaign-ish `from=cc_cli_limit_message` parameter, and the
`·` is a multi-byte separator that a future build may well change. So the matcher targets the
stable semantic core rather than the sentence, is case-insensitive, reads **both** streams, and is
asserted in tests against this verbatim capture (`account_refusal_capture_renders_as_an_outage`) so
a regression is caught against real bytes rather than a paraphrase. **This appendix is informative.
It must never become the contract**, and core must never read it.

**Still unknown, and deliberately not guessed:** the strings for the *other* members of this class
— expired credential, revoked key, org-level quota. Only the spend-limit case has been observed.
Those are added the same way, when captured; the matcher covers what has been seen and nothing
more.

## 7. Split of work

| | |
|---|---|
| **core (doyle)** | mint the reserved code + its REQ; pacing separate from the strike budget; the `psyche_host_error` rendering; the public doc amendment. Rides with the `turn.rs` stdout-preservation change (same domain). |
| **adapter (me)** | ~~observe the strings~~ **DONE** (§6, hertz 2026-07-26); the matcher that translates them to the code; the `PSYCHE_TURN_REFUSED` line with both stream tails; tests, including one asserting the code is never returned for a session-gone or a plain failure. |

**Ordering:** the core code can be minted before the strings are observed — the contract does not
depend on them. The adapter half waited on §6, which is now **cleared**: the spend-limit capture
is in hand, so the only remaining blocker on my side is the reserved code itself. Say the number
and the adapter half follows.

---

## 8. Why the harness's own `StopFailure` signal cannot replace the string match (checked 2026-07-26)

Claude Code fires a **`StopFailure`** hook when a turn ends with an API error, and its matcher
vocabulary includes exactly the class this document is about:
`rate_limit | authentication_failed | billing_error | invalid_request | server_error |
max_output_tokens | unknown`, with the payload carrying `{"error_type": …}`
(`~/.claude/reference_docs/claude-code-hooks.md`, the per-event contract source). On its face that
is a *typed* signal for the refusal we currently detect by matching text on the inner turn's
stdout — strictly better, if it were reachable.

**It is not reachable inside a psyche turn, by our own deliberate design — two independent blocks:**

1. **The inner session loads no hooks at all.** Every internal claude spawn is config-isolated
   (`REQ-HAZARD-PSYCHE-FOREIGN-CONFIG`): `psyche.rs::turn_cmd` appends
   `isolation::isolation_flags`, i.e. `--safe-mode` when the installed binary supports it, else
   `--settings {"disableAllHooks":true}`. Both disable hooks from *all* sources, plugins included —
   so the sptc `StopFailure` handler cannot fire in the very session whose refusal we need to
   classify.
2. **Even if it fired, the binary bails first.** `hook::run()` returns `ExitCode::SUCCESS` with zero
   api calls whenever `SPT_PSYCHE_TURN` or `SPT_ECHO_COMMUNE` is set (`shim_guarded`, the third belt
   of `REQ-HAZARD-PSYCHE-IDENTITY-ENV`).

Removing either block to gain the signal would trade a *documented, field-paid* hazard (user hooks
firing inside adapter-spawned sessions and corrupting psyche output; a psyche rebinding a foreign
perch) for a tidier classifier. Not a trade worth making: the string match's false-positive cost is
bounded and asymmetric by construction (§2), while both hazards above are silent-corruption class.

**The operator-session leg is already covered and needs nothing here.** In a session the *user*
owns, `StopFailure` does fire, and the adapter has consumed it since v0.16.x —
`handle_stop_failure` marks the perch idle through the hardened receive-heal and logs `error_type`
(`REQ-HAZARD-STOPFAILURE-STUCK-BUSY`). A `billing_error` there is therefore already both healed and
observable; nothing about the psyche path improves by touching it.

**Status:** closed — the harness signal is unavailable where the refusal lives, and redundant where
it is available. Re-open only if internal sessions ever stop being config-isolated.
<!-- [doc->REQ-PSYCHE-ACCOUNT-REFUSAL-EXIT] -->
