Skip to content

feat(slack): Block Kit support for clarify prompts and rich messages - #6

Open
jarvisxyz wants to merge 1 commit into
mainfrom
2025-07-11.eizus.slack-block-kit
Open

jarvisxyz wants to merge 1 commit into
mainfrom
2025-07-11.eizus.slack-block-kit

Conversation

@jarvisxyz

Copy link
Copy Markdown
Owner

feat(slack): add Block Kit support for clarify prompts and rich messages

Add robust Slack Block Kit rendering to the Hermes Slack adapter,
replacing plain-text-only clarify prompts with interactive button
interfaces and enabling rich section-based message layout.

Changes:

  1. Block Kit Clarify Prompts (send_clarify override)

    • Override base-class send_clarify() with Block Kit buttons
    • Multi-choice (5 or fewer options): one button per choice + Other
    • Multi-choice (more than 5 options): numbered text list + Other
    • Open-ended (no choices): plain text fallback (same as base)
    • Buttons encode clarify_id:choice_index in the value field
    • Other button triggers text-capture via mark_awaiting_text()
  2. Clarify Action Handlers

    • _handle_clarify_choice_action: resolves clarify with chosen text,
      updates message to show selection (removes buttons)
    • _handle_clarify_other_action: switches to text-capture mode,
      updates message to show waiting indicator
    • Both handlers include authorization checks (SLACK_ALLOWED_USERS)
    • Registered in connect() alongside existing approval/confirm handlers
  3. Rich Message Rendering (_markdown_to_blocks)

    • New module-level utility converts markdown to Block Kit sections
    • Splits on headers (## Title) into separate section blocks
    • Interleaves dividers between sections for visual hierarchy
    • H1/H2 bold, H3+ italic styling
    • Enforces Slack limits (50 blocks, 3000 chars/section)
    • Conservative: does NOT convert lists/tables/code (mrkdwn handles these)
  4. Enhanced send() with Block Kit

    • When rich_messages config is enabled (default: true), messages
      with 2+ header sections are sent as Block Kit blocks
    • Simple messages stay as plain mrkdwn text (preserves URL unfurling)
    • Configurable via platforms.slack.extra.rich_messages: false
  5. Enhanced edit_message() with Block Kit

    • Same rich rendering applied to message edits
    • Progress updates with headers render as sections
  6. Progress Block Builder (_build_progress_blocks)

    • Static method to render tool-call progress as Block Kit
    • Section block with progress text + context block with indicator

Config Options:

  • platforms.slack.extra.rich_messages (default: true)

Tests:

  • 23 new tests in test_slack_clarify_buttons.py
  • All 287 existing Slack/clarify tests continue to pass

Add robust Slack Block Kit rendering to the Hermes Slack adapter,
replacing plain-text-only clarify prompts with interactive button
interfaces and enabling rich section-based message layout.

Changes:

1. Block Kit Clarify Prompts (send_clarify override)
   - Override base-class send_clarify() with Block Kit buttons
   - Multi-choice (5 or fewer options): one button per choice + Other
   - Multi-choice (more than 5 options): numbered text list + Other
   - Open-ended (no choices): plain text fallback (same as base)
   - Buttons encode clarify_id:choice_index in the value field
   - Other button triggers text-capture via mark_awaiting_text()

2. Clarify Action Handlers
   - _handle_clarify_choice_action: resolves clarify with chosen text,
     updates message to show selection (removes buttons)
   - _handle_clarify_other_action: switches to text-capture mode,
     updates message to show waiting indicator
   - Both handlers include authorization checks (SLACK_ALLOWED_USERS)
   - Registered in connect() alongside existing approval/confirm handlers

3. Rich Message Rendering (_markdown_to_blocks)
   - New module-level utility converts markdown to Block Kit sections
   - Splits on headers (## Title) into separate section blocks
   - Interleaves dividers between sections for visual hierarchy
   - H1/H2 bold, H3+ italic styling
   - Enforces Slack limits (50 blocks, 3000 chars/section)
   - Conservative: does NOT convert lists/tables/code (mrkdwn handles these)

4. Enhanced send() with Block Kit
   - When rich_messages config is enabled (default: true), messages
     with 2+ header sections are sent as Block Kit blocks
   - Simple messages stay as plain mrkdwn text (preserves URL unfurling)
   - Configurable via platforms.slack.extra.rich_messages: false

5. Enhanced edit_message() with Block Kit
   - Same rich rendering applied to message edits
   - Progress updates with headers render as sections

6. Progress Block Builder (_build_progress_blocks)
   - Static method to render tool-call progress as Block Kit
   - Section block with progress text + context block with indicator

Config Options:
- platforms.slack.extra.rich_messages (default: true)

Tests:
- 23 new tests in test_slack_clarify_buttons.py
- All 287 existing Slack/clarify tests continue to pass
@github-actions

github-actions Bot commented Jun 3, 2026

Copy link
Copy Markdown

🔎 Lint report: 2025-07-11.eizus.slack-block-kit vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 9712 on HEAD, 9709 on base (🆕 +3)

🆕 New issues (2):

Rule Count
invalid-argument-type 1
unresolved-import 1
First entries
gateway/platforms/slack.py:3081: [invalid-argument-type] invalid-argument-type: Argument to bound method `list.append` is incorrect: Expected `dict[str, str | dict[str, str]]`, found `dict[str, str | dict[str, str] | list[dict[str, str | dict[str, str]]]]`
tests/gateway/test_slack_clarify_buttons.py:13: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`

✅ Fixed issues: none

Unchanged: 5031 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

jarvisxyz pushed a commit that referenced this pull request Jul 19, 2026
…, /topup, terminal-billing UX) (NousResearch#51639)

* feat(tui): rename /billing slash command to /topup

Behavior-preserving rename of the /billing command surface to /topup.
Changes: billing.ts → topup.ts (export topupCommands, name 'topup', new
help string), registry.ts import+spread updated, billingOverlay.tsx
overview header 'Usage credits' → 'Top up credits', billingCommand.test.ts
→ topupCommand.test.ts with import/lookup/call updated. RPC method names
(billing.state, billing.charge, etc.) and component/symbol names unchanged.

* refactor(tui): extract overlay primitives to shared module

Lift MenuRow, ActionRow, footer, and barCells() out of billingOverlay.tsx
into overlayPrimitives.tsx so the upcoming subscriptionOverlay.tsx can
import them instead of duplicating. spendBar now calls barCells() —
output is byte-identical. Pure behavior-preserving refactor.

* feat(tui): add /subscription + /topup CTAs to /usage output

Every /usage render now ends with 'Run /subscription to change plan
· /topup to add credits' — both the healthy (with-calls) and depleted
(no-calls) paths. Strings-only change, no WS1 dependency.

* feat(tui): add subscription wire types

Add SubscriptionTierOption, SubscriptionStateResponse, and
SubscriptionManageLinkResponse to gatewayTypes.ts. Type-only — no
usages yet. Mirrors the BillingStateResponse conventions (snake_case,
Decimals as strings) and reuses BillingErrorPayload for error mapping.

* feat(gateway): add subscription.state + subscription.manage_link RPCs

- agent/subscription_view.py: SubscriptionState dataclass + fail-open
  build_subscription_state() (mirrors billing_view pattern) +
  get_subscription_manage_link() for the Stripe deep-link.
- hermes_cli/nous_billing.py: get_subscription_state() +
  post_subscription_manage_link() HTTP helpers for the two NAS endpoints
  (WS1 Phase A/C). The manage-link endpoint raises BillingScopeRequired
  when Remote-Spending is missing (Phase 4 step-up trigger).
- tui_gateway/server.py: _serialize_subscription_state() +
  subscription.state RPC (fail-open) + subscription.manage_link RPC
  (returns {ok,kind,url} or typed error envelope via
  _serialize_billing_error). NOT added to _LONG_HANDLERS — synchronous
  HTTP round-trip, not a device flow.

* feat(tui): add subscription overlay state types + store slot

Add SubscriptionScreen, SubscriptionOverlayCtx, SubscriptionOverlayState
to interfaces.ts and a 'subscription' slot to OverlayState. Wire it into
overlayStore.ts (buildOverlayState + $isBlocked). NOT added to
resetFlowOverlays preserve list — flow-scoped like billing, drops on
turn end.

* feat(tui): build SubscriptionOverlay — overview + confirm + handoff

Pure-render Ink component mirroring billingOverlay.tsx's structure.
Overview screen covers all 5 states (free-upgradeable, mid-tier,
top-tier, not-admin, downgrade-pending) + dunning. Confirm screen is
y/n deep-link to Stripe (NO in-terminal charge). Handoff is the
transient 'Opening Stripe' screen. Imports shared primitives from
overlayPrimitives.tsx. 8 render tests via renderSync covering every
state.

* feat(tui): add /subscription command + overlay wiring

- subscription.ts: SubscriptionOverlayCtx closure (openManageLink,
  refreshState, requestRemoteSpending) + run handler that fetches
  subscription.state and opens the overlay. Alias /upgrade.
- registry.ts: spread subscriptionCommands into SLASH_COMMANDS.
- appOverlays.tsx: render SubscriptionOverlay when overlay.subscription set.
- useInputHandlers.ts: Esc closes subscription overlay; promptOverlay OR
  includes subscription so input is intercepted while open.
- subscriptionCommand.test.ts: 4 tests (fetch+open, logged-out sys line,
  /upgrade alias, /subscription resolves).

* fix(tui/subscription): stop saying Stripe in deep-link copy + fix manage link kind type

Replace all user-facing 'Stripe' mentions in the /subscription overlay and
sys messages with 'your subscription page' — the deep-link target is NAS's
own /manage-subscription page, not the Stripe hosted portal. Stripe only
legitimately appears later at actual Checkout. Also add 'manage' to the
SubscriptionManageLinkResponse.kind union (NAS emits kind:'manage'; was
previously missing from the TypeScript type causing silent narrowing errors).

* feat(tui/subscription): render cancellation-scheduled note with headline precedence

Parse cancelAtPeriodEnd + cancellationEffectiveAt from the NAS contract
(camelCase) in the agent parser (_parse_current), emit cancel_at_period_end
+ cancellation_effective_at from the gateway serializer, extend the
SubscriptionStateResponse type, and render a warn note in OverviewScreen:
'Cancels on {date} — your plan stays active until then.'

Headline precedence when multiple flags co-occur:
  past-due > cancel-scheduled > downgrade-pending > active
The downgradeNote guard is tightened to suppress when cancel is scheduled,
so at most one status line renders at a time.

* feat(tui/subscription): team-context screen — redirect to /topup for team orgs

Parse the NAS context:'personal'|'team' field (defaults to 'personal' for
unknown/missing values), emit it on the gateway wire, add it to
SubscriptionStateResponse. When context is 'team', SubscriptionOverlay
renders a dedicated read-only screen instead of the tier picker:

  'This terminal is connected to {org_name}. Teams run on shared
   credits — use /topup to add funds. Personal subscriptions live
   on your personal account.'

The screen closes on Enter or Esc. The personal/tier-picker path is
unchanged.

* fix(subscription): drop manage-link gateway RPC, build URL locally

The NAS POST /api/billing/subscription/manage-link endpoint was dropped
(it added no server work — the target is the static /manage-subscription
page, not a Stripe-minted secret). Build the URL client-side instead:
{portal_base}/manage-subscription?org_id=<org.id>.

- Remove subscription.manage_link gateway RPC (server.py)
- Remove get_subscription_manage_link helper (subscription_view.py)
- Remove post_subscription_manage_link (nous_billing.py)
- Remove SubscriptionManageLinkResponse type (gatewayTypes.ts)
- Add org_id to SubscriptionState + wire through serializer + TS type
- openManageLink() builds the URL locally via buildManageUrl(), opens
  it with the existing openExternalUrl(), no gateway round-trip
- Drop targetTierId param from openManageLink (v1 sends everyone to
  /manage-subscription; no tier deep-link needed)
- Fix stale test expectations (Stripe copy → subscription page copy)

* chore(subscription): drop unused format_money import

* feat(cli): /subscription + /upgrade, /billing→/topup rename, /usage CTAs

Add the classic-CLI half of the terminal billing surface to match the TUI:
- /subscription (alias /upgrade) command + /topup (renamed /billing, keeps
  'billing' as a back-compat alias) in the command registry.
- Drop the stale 'billing' entry from _SLACK_VIA_HERMES_ONLY (now cli_only).

* feat(subscription): CLI /subscription handler, drop dunning, current:null no-plan

- CLI _show_subscription mirrors the TUI overlay (plan read + tier list + usage
  bar + browser deep-link via subscription_manage_url); credits render as counts.
- Adapt to the updated NAS read contract: remove is_past_due/dunning everywhere
  (a card-failing subscriber returns as a normal plan now), and treat no-plan as
  current:null (parser returns None) rather than an all-null object.
- HERMES_DEV_SUBSCRIPTION_FIXTURE env-driven fixtures + ui-tui fixture harness
  drive every state (CLI + live TUI) with no portal.

Verified against handoff 2026-06-24_subscription-tui-handoff.md.

* feat(billing): CF-4 Remote-Spending revoked-terminal UX (NAS PR NousResearch#481)

Wire the Remote-Spending gate denial contract end to end:
- nous_billing: BillingRemoteSpendingRevoked (403 remote_spending_revoked →
  reconnect) + BillingSessionRevoked (401 session_revoked → re-login), distinct
  from insufficient_scope; capture actor/code/recovery; 503 stays transient.
- gateway _serialize_billing_error threads the new typed kinds + actor/code/
  recovery to the TUI.
- TUI renderBillingError: actor-aware revoke copy, kills the spend overlay
  immediately (no 15-min zombie button), handles session_revoked, the dual-
  emitted cli_billing_disabled/remote_spending_disabled, role_required,
  idempotency_conflict; poll treats a mid-poll revoke as ambiguous (check
  balance before retry), not a failure.
- CLI _billing_render_charge_error: same denial matrix, actor-aware copy.

Tests: gate-contract mapping + envelope (py) and revoke/session/disabled (TUI).
Per handoff 2026-06-24_remote-spending-TUI-contract-handoff.md.

* refactor(subscription): remove dead step-up scaffolding from /subscription

/subscription only opens a browser deep-link to manage-subscription — that needs
no billing scope, so it can never hit insufficient_scope. Drop the never-fired
'stepup' screen type, requestRemoteSpending ctx fn, and resumeScreen bookkeeping
(leftovers from a superseded plan). The resumable step-up lives on /topup, where
the charge actually gets gated.

* feat(tui/topup): resumable 'Allow Remote Spending' step-up on the charge path

Phase 4: when a charge returns insufficient_scope, the /topup modal no longer
tears down with a 'run /billing again' ConfirmReq. Instead it stays MOUNTED and
switches to a step-up screen:
- charge() is now awaitable, returning a discriminated outcome (submitted |
  needs_remote_spending | error) so the overlay can route without closing.
- StepUpScreen: 'Allow Remote Spending' → await the device-flow grant (browser
  opens via the existing out-of-band billing.step_up.verification event) →
  replay the held charge (pendingCharge.amount) and settle, with no command
  re-run. Never surfaces the raw billing:manage scope.
- armStepUp's fire-and-forget ConfirmReq replaced by requestRemoteSpending();
  the leaky 'billing:manage' / 'Re-authorize' / 'run /billing again' copy is gone.

Tests: charge-outcome routing, step-up grant/deny, and a render test asserting
the step-up copy holds the amount and never leaks billing:manage.
Per handoff 2026-06-24_remote-spending-TUI-contract-handoff.md §2 (Grady #6).

* feat(billing): shared dollar usage model + two-bar view (drop "credits")

Single source of truth for the /usage and /subscription usage bars across
TUI + CLI. Reads the NAS account-info dollar fields (subscription/top-up/total
remaining, monthly allowance, renewal) and produces a surface-agnostic model:
two full-resolution bars (plan allowance + purchased top-up), a status
classification (free | healthy | low | depleted), and a human renewal date.

- agent/billing_usage.py: UsageModel/UsageBar, usage_model_from_account
  (fail-open), build_usage_model (HERMES_DEV_CREDITS_FIXTURE-aware),
  format_renews (ISO -> "Jul 24, 2026", Windows-safe), $5 low-balance threshold.
- tui_gateway/server.py: _serialize_usage_model/_serialize_usage_bar, a
  usage.bars RPC, and the model embedded into subscription.state so the overlay
  renders the same bars from its single fetch.
- Dollars only, never "credits"; two separate bars (not a crammed
  three-segment one) for legibility at terminal widths.
- tests/agent/test_billing_usage.py: status classification, bar math
  (clamp/over-cap), NaN/Inf rejection, fail-open invariants.

* feat(tui): dollar usage bars on /usage + /subscription, drop tier picker

Render the shared two-bar dollar model in both overlays; strip "credits" and
the in-terminal tier selection per UX feedback.

- overlayPrimitives.tsx: UsageBars (themed plan/top-up bars — gold allowance,
  green top-up) + usageBarsText for the /usage panel. Plan name labels the
  bar; "$X left of $Y · N% used" (disambiguated so the % matches); top-up
  "never expires".
- subscriptionOverlay.tsx: status line dedupes ($X left once; bar carries the
  breakdown), human renewal date, state-matched nudges (free upsell / <$5
  low alert) with box-safe ASCII markers (! / >) instead of the width-unstable
  emoji that broke the border. Tier picker removed — overview shows usage +
  plan, then "Manage on portal" / "Close" (free users get "Start a
  subscription"). No "credits" anywhere.
- session.ts: /usage renders the dollar bars + balance summary, falling back
  to the legacy credits lines only when the model is unavailable; CTA reworded.
- gatewayTypes.ts: UsageModelData/UsageBarData wire types + usage on
  SessionUsageResponse/SubscriptionStateResponse.
- Tests updated to the new contract (no "credits", "left of", dedup, markers).

* feat(cli): mirror dollar usage bars on /usage + /subscription

CLI parity with the TUI billing rework, from the same shared usage model.

- _print_nous_credits_block (/usage) and _subscription_overview render the
  two-bar dollar view (plan name on the bar, "$X left of $Y · N% used",
  top-up "never expires", total spendable) instead of the credits-worded block.
- Dollars only — dropped the tier catalog (no more "$N/mo (… credits)") and
  every user-facing "credits"; team copy says "shared balance".
- Human renewal date via the shared format_renews; status line dedupes the
  "$X left"; free upsell + <$5 low alert with ASCII markers.
- /subscription manage modal no longer dumps the raw manage-subscription URL
  in its detail — the [1] Open / [2] Copy link / [3] Cancel options carry it.
  Title is "Manage your subscription" (no in-terminal plan change). The raw URL
  stays only in the non-interactive / not-admin fallbacks, which have no menu.
- /usage token-usage panel (model, tokens, cost, context) left untouched.

* feat(billing): embed dollar usage model into billing.state for /topup

The /topup overview renders the same two-bar dollar usage (plan + top-up) as
/usage and /subscription. Embed the shared usage model into the billing.state
RPC payload (mirrors subscription.state) so the overlay gets the bars from its
single fetch, and add the `usage` field to BillingStateResponse.

* feat(tui/topup): reorder overview + in-flight reauth with press-Enter resume

Reworks the /topup overlay per the Jun 19 review and the no-preflight decision.

Overview:
- Balance leads in the title ("Top up · balance $X"); the shared two-bar dollar
  usage (plan + top-up) renders below. Dropped the old monthly-cap spend bar.
- "Add funds" is the first action (was "Buy credits"); auto-reload / monthly
  limit / manage-on-portal follow. Dollars only — no "credits" anywhere.
- No "Enable terminal billing" menu item and NO scope preflight: whether the
  terminal can charge is discovered reactively at pay time. (We deliberately do
  not read/refresh the OAuth token to gate UI.)

Step-up (reached only on a charge's insufficient_scope 403):
- New 4-phase flow that keeps the modal mounted: prompt (one-time-setup
  heads-up) → waiting (browser authorize) → granted (explicit "Press Enter to
  resume") → replay the held charge → settle. The press-Enter beat is the
  reassuring "you're back, finish your purchase" moment.
- Renamed user copy "Allow Remote Spending" → "Enable terminal billing"; never
  leaks the raw billing:manage scope (guarded by the render test).
- topup.ts error copy de-crufted to terminal-billing wording, emoji removed.

Tests: step-up prompt copy, the no-raw-scope invariant, and new overview tests
(balance-in-title, Add-funds-first, two-bar usage, no "credits").

* feat(cli/topup): mirror overview reorder + in-flight reauth resume

CLI parity with the TUI /topup rehaul, from the same shared usage model.

- _billing_overview: balance in the title, the two-bar dollar usage (plan name
  on the plan bar, top-up "never expires") in place of the old cap spend bar,
  "Add funds" first, dollars throughout — no "credits", no scope preflight.
- _billing_handle_scope_required: now takes the held amount + idempotency key
  and runs the in-flight flow — "Enable terminal billing" → browser device-flow
  → re-check the org kill-switch → press-Enter to resume → replay the held
  charge (reusing the key so a double-submit collapses to one). Stops leaking
  the raw billing:manage scope.
- Charge-error + buy/auto-reload copy de-crufted to terminal-billing/dollars.
- Tests updated to the new overview + buy copy.

* fix(billing): guard non-JSON 2xx responses in the billing HTTP client

A 2xx response with a non-JSON body — e.g. a reverse-proxy / SPA fallback HTML
page served when a billing route isn't actually mounted on a deployment — hit
json.loads() on the success path of _request() and raised a raw
json.JSONDecodeError. That escaped the typed-BillingError contract, so callers'
`except BillingError` missed it and fell through to a generic fail-open that
rendered as a misleading "not logged in" (observed when /api/billing/subscription
was briefly unshipped on staging: 200 text/html, x-matched-path /[...notFound]).

Now a non-JSON 2xx body raises a typed BillingError(error="endpoint_unavailable")
so surfaces degrade gracefully ("could not load …") instead of crashing or
mislabeling a valid session as logged-out. The 4xx/5xx path already guarded its
.json(); this closes the same hole on the success path.

Test: tests/hermes_cli/test_nous_billing_request.py — non-JSON 2xx → typed
error (not JSONDecodeError, not BillingAuthError), empty body → {}, valid JSON
parses.

* feat(billing/dev): add HERMES_DEV_BILLING_FIXTURE for offline card/scope testing

build_billing_state short-circuits to a fixture when HERMES_DEV_BILLING_FIXTURE
is set (mirrors HERMES_DEV_CREDITS_FIXTURE for the usage model). States:
nocard | card | card-autoreload | notadmin | billing-off | logged-out — so the
card-on-file gate, admin role, and kill-switch paths are exercisable offline
without a live portal. Env-var gated; returns None when unset (no prod leak).

Adds 8 behavior tests asserting the card/admin/billing-on contract per state.

* refactor(billing): fold /credits into /topup

/credits is redundant now that /topup shows the dollar balance + portal handoff.
Make 'credits' (and 'billing') aliases of /topup so typing /credits still works,
resolving to topup everywhere (CLI, gateway, Slack, TUI, autocomplete, help).

Remove the standalone /credits surface across 6 places:
- CLI _show_credits handler + dispatch
- gateway _handle_credits_command -> renamed _handle_topup_command, copy softened
  to 'Manage billing on the portal' (the messaging billing surface; /topup is now
  gateway-available so messaging keeps billing — credits was the only one before)
- TUI commands/credits.ts + creditsCommand.test.ts (deleted), registry entry
- tui_gateway credits.view RPC + the CreditsViewResponse type
- Slack _SLACK_VIA_HERMES_ONLY: credits -> topup

Sweep user-facing /credits -> /topup (usage-block hint, depletion notice) and
stale doc-comments. OpenRouter's /credits endpoint URL left untouched. Tests
updated (test_credits_folds_into_topup) or pruned for the removed symbols.

* fix(billing): card-on-file heads-up, no-card portal gate, /usage bar ordering, modal glyph

In-terminal charge (POST /charge against the org's server-held card, no card ref
leaves the client):
- card present: confirm screen shows 'Your card saved on the portal will be
  charged' + a 'Manage on portal' escape option (CLI); heads-up line (TUI)
- no card on file: /topup overview + buy flow detect it and route to the portal
  to add a card, instead of offering a charge that 403s no_payment_method

/usage bar ordering: route the dollar block through _cprint consistently. The
Plan: line (_cprint) and the bar (raw print) flushed to different buffers under
patch_stdout and interleaved nondeterministically; now Plan: -> bar -> status/CTA
is stable across all states.

Modal glyph: strip the leading emoji from bordered _prompt_text_input_modal
titles — it measures 1 char but renders 2 columns, shifting the box's right
border (the stray '|'). Includes the f-string 'Pay $X?' title.

Small /credits -> /topup string bits in cli.py ride along with the surrounding
charge edits (the fold lives in the sibling refactor commit).

* refactor(billing): apply safe simplify-pass fixes

Three low-risk cleanups from a parallel simplify review (reuse/quality/efficiency):
- dev fixture portal URL: reuse the prod host (was drifted to staging-* — a real
  mismatch vs subscription_view's _DEV_FIXTURE_PORTAL)
- TUI billingOverlay choose(): collapse two byte-identical branches (needsCard +
  the not-full else both = portal-or-close at index 0) into one tail; the only
  divergent path (full && !needsCard → buy/auto/limit) stays explicit
- /topup overview comment: correct the stale 'buy_flow detects no_payment_method'
  note (the overview's no-card gate fires first, so reaching Add funds implies a
  card on file)

Skipped (judgment): the orphaned CreditsView.depleted field (harmless, on a live
dataclass), the defensive card gates in _billing_buy_flow/_confirm_and_charge
(cheap correct defense on the money path), and folding the no-card handoff into a
shared helper (touches 4 money-path sites for tidiness — not worth the risk here).

* fix(billing): reactive charge gating — drop card preflight, react to 403 (scope→reauth, no-card→portal)

* refactor(billing): drop the /credits alias entirely

The /credits fold made it an alias of /topup; now remove that too. Typing
/credits is an unknown command, not a silent redirect — billing lives only on
/topup (with /billing kept as the old command's back-compat name). Dropped the
alias from the registry CommandDef and the TUI topup.ts; updated the test to
assert /credits resolves to nothing (no command, no alias).

* docs(billing): fix stale comment in _billing_overview — describe reactive no-card path

The comment still described the removed overview-level card gate ('no-card case
handled above'). Corrected to: the buy flow reacts to the server's
no_payment_method 403 and hands off to the portal at charge time (no preflight).

* refactor(billing): simplify-pass — share usage-payload helper, drop dead bar wire fields + redundant admin gate

* refactor(billing): drop the /billing alias too — /topup is the only billing command

Following /credits removal, retire the old /billing name as well. /topup now has
NO aliases — both /credits and /billing are unknown commands. Dropped the alias
from the registry CommandDef and TUI topup.ts; fixed the one live user-facing
straggler (the not-logged-in message said 'then /billing' → /topup) and the
_show_billing docstring/default-arg references. Test asserts /topup carries no
aliases and neither old name resolves.

* fix(billing): code-review fixes — money-path + parity bugs

Money path (TUI):
- auto-reload "Turn off" now echoes current threshold/top_up_amount so the
  PATCH succeeds (was sending {enabled:false} → invalid_request → stayed ON)
- charge poll honors the 5-min cap on the 429/503 throttle branch too (was
  rescheduling forever); cap folded into one timedOut() helper
- step-up resume reacts to the replay outcome instead of unconditionally
  closing on a reassuring line with no charge made
- synchronous submit guard on Confirm so two key events can't double-charge

Gateway:
- billing.step_up routes typed errors through _serialize_billing_error (was a
  raw {error:'error'} dict → generic copy for session_revoked)
- billing.state / subscription.state / usage.bars / session.usage moved to
  _LONG_HANDLERS (blocking portal HTTP no longer stalls the main stdin loop)

CLI:
- _billing_render_charge_error handles insufficient_scope without leaking the
  raw billing:manage scope name on a post-grant replay re-raise

Python model:
- subscription_view tier parse None-coalesces tierOrder/dollarsPerMonth so a
  free tier's 0 survives ($0, not "—"; correct sort order)

TUI parity/robustness:
- /usage shows formatted renews_display, not raw ISO renews_at
- subscription overview guards a null pending_downgrade_at (was "on null.")
- subscription overview surfaces a message instead of silently closing when
  portal_url is missing
- buildManageUrl wraps new URL() so a malformed portal_url can't throw out of
  the Ink key handler

* fix(billing): cross-surface bar direction, formatted cancel/downgrade dates, Slack alias gating

- CLI plan bar now fills by REMAINING (fuel-gauge), matching the shared model's
  fill_fraction, the top-up bar, and the TUI — same account renders identically
  on both surfaces (#8)
- subscription serializer emits cancellation_effective_display /
  pending_downgrade_display (format_renews); TUI shows 'Jul 1, 2026' not raw ISO (#14b)
- _SLACK_VIA_HERMES_ONLY now includes the 'billing' alias so it follows its
  canonical /topup via /hermes instead of leaking a native Slack slot (#9)

* fix(billing): thread idempotency key through the TUI step-up replay (#2)

Mint a stable idempotency key when the purchase amount is chosen; it rides
pendingCharge into both the Confirm charge and the post-grant step-up replay,
so a retried charge dedups server-side (the gateway already echoes the key).
A fresh amount selection gets a fresh key. Combined with the sync submit guard,
a double-submit now collapses to one charge.

* refactor(billing): remove dead /subscription tier-picker scaffolding (NousResearch#18)

The in-terminal plan picker was cut (deep-link only), leaving a whole unreached
state machine. Removed end-to-end:
- TUI: ConfirmScreen, HandoffScreen, the 'confirm'/'handoff' screen types,
  pendingTargetTierId, and the now-dead onPatch threading (collapsed the dispatch
  to a single overview screen + folded the duplicate Box wrapper)
- gateway: the tiers serialization + SubscriptionTierOption wire type
- model: SubscriptionTier, _parse_tier, _coalesce, _dev_tiers and the tiers field
  (never displayed on either surface, so this supersedes the tier-parse fix)
- tests: dropped the confirm/handoff/tier-passthrough tests; slimmed the overview
  render tests

Net: a large dead-code cull (no behavior change — the picker never ran).

* test(billing): parametrize usage-model tests; drop dead is_low/is_free props

Collapse the fail-open + status-classification cases into parametrized tables
(same coverage, ~80 fewer lines) and remove the now-unused UsageModel.is_low /
is_free properties (only a test pinned them).

* fix(billing): revert dead 'billing' Slack-via-hermes entry — the alias was dropped

#9 was based on a stale review diff: /billing is no longer an alias of /topup
(dropped earlier), so routing it via /hermes filtered a name that doesn't exist.

* test(billing): cull redundant TUI billing tests (parametrize, merge dupes)

usageCommand: collapse 3 CTA tests into one + a panel helper.
billingStepUp: merge the two step-up render asserts.
topupCommand: parametrize requestRemoteSpending + the revoked-actor pair, drop
the redundant happy-path-submitted test. Money-path + error-mapping coverage
preserved.

* refactor(billing): extract _usage_bar_lines — one source of truth for the CLI bars

The plan + top-up bar format was copy-pasted across _print_nous_credits_block,
_subscription_overview, and _billing_overview. Extract a helper returning the
ready-to-print lines; each caller keeps its own print fn (the _cprint-ordering
constraint stays) and resolves its plan-name label. Centralizes the format so
the three surfaces can't drift.

* feat(billing): NAS V3 subscription-change HTTP client wrappers

Add the four write-side wrappers for the V3 subscription contract to nous_billing,
each a thin _request() call (reusing auth, JSON, 401-retry, typed errors):
- post_subscription_preview      → POST  /subscription/preview      (chargeless quote)
- put_subscription_pending_change→ PUT   /subscription/pending-change (downgrade/cancel)
- delete_subscription_pending_change → DELETE .../pending-change      (resume/undo)
- post_subscription_upgrade      → POST  /subscription/upgrade        (the money route)

pending-change takes a discriminated body (tier_change | cancellation); upgrade
requires an Idempotency-Key (mandatory, validated client-side before any I/O).
Tests assert the exact method/path/body/header each wrapper puts on the wire.

* feat(billing): subscription tier catalog + change-preview models

Reinstate the catalog the in-terminal picker needs (was culled when /subscription
was deep-link-only): SubscriptionTier + SubscriptionState.tiers + _parse_tier, with
_coalesce so the free tier's 0 tierOrder/price survives a falsy-or. Parse the
catalog from GET /subscription's tiers and seed _dev_tiers into every fixture.

Add SubscriptionChangePreview + subscription_change_preview_from_payload for the
POST /preview quote (effect/amountDueNowCents/effectiveAt/reason + tier delta); a
malformed/missing effect fails safe to 'blocked' so a bad quote never reads as a
charge. Module docstring updated: the overlay is no longer deep-link-only.

* feat(billing): gateway RPCs for the V3 subscription change flow

Add subscription.preview / .change / .resume / .upgrade RPCs, each wrapping its
nous_billing call and reusing _serialize_billing_error for the typed envelope
(so a 403 still drives the device step-up). upgrade mints + echoes the
idempotency key and surfaces status + recovery_url so the TUI can route an
SCA/decline to the portal. Re-add the tier catalog to _serialize_subscription_state
(price pre-formatted) for the picker. All four are pool-routed (_LONG_HANDLERS) —
preview + upgrade hit Stripe and must not stall the main stdin loop.

* feat(billing): in-terminal subscription change flow (TUI)

/subscription is no longer deep-link-only: it drives the change in-terminal
against the V3 contract via the new gateway RPCs. The overlay is a state machine
overview → picker → confirm → result:
- picker lists the tier catalog with upgrade/downgrade hints (current + free
  excluded; free=cancel, on the overview);
- confirm shows the previewed effect — pay $X now (upgrade) / scheduled at date
  (downgrade) / cancel at period end / blocked-with-reason — then applies it;
- an upgrade's SCA/decline routes to the portal via the result screen's recovery
  link; resume/cancel/downgrade are chargeless.

Starting a NEW subscription still deep-links (needs a fresh card). insufficient_scope
points to /topup (the step-up stays there, not duplicated here). Adds the wire
types (tiers + preview/upgrade responses), widens the overlay ctx + screen state,
and threads onPatch. Render tests cover every screen.

* feat(billing): in-terminal step-up + clearer scheduled-change UX (TUI)

Two improvements to the /subscription overlay:

Step-up re-auth in place. When a mutation (preview/change/upgrade/resume) returns
insufficient_scope, route to a new 'stepup' screen that grants terminal billing
via billing.step_up and AUTO-REPLAYS the held action on grant — no bounce to
/topup. Scope routing is centralized in previewAndRoute/applyPendingAndRoute/
resumeAndRoute (shared by the picker, confirm, overview + the step-up replay). The
browser opens via the shared global verification handler; copy never leaks the raw
billing:manage scope.

Make a scheduled change unmissable. A downgrade/cancel was one buried warn line
that read as 'nothing happened'. Now the overview leads with a banner
(⏳ Scheduled change · Ultra ──▶ Plus · <date> · you keep Ultra until then), the
status line echoes the transition (Plan: Ultra → Plus), 'Keep <tier> (undo)' is
promoted to the first olive action, the result screen says 'your plan doesn't
change today', and confirm gets a charged-now / scheduled chip.

* feat(billing): full in-terminal subscription change flow in the classic CLI

Bring the CLI to parity with the TUI overlay — /subscription is no longer
deep-link-only. A paid admin/owner gets picker → preview → confirm → apply,
mirroring the /topup buy flow's modal idioms:
- _subscription_change_menu (change / undo-or-cancel / manage-on-portal),
- _subscription_pick_tier (catalog with upgrade/downgrade hints),
- _subscription_preview_and_confirm (POST /preview → effect-aware confirm),
- _subscription_apply (schedule / cancel / resume chargeless; upgrade charges
  the sub's card, SCA/decline → portal),
- _subscription_handle_scope_required (insufficient_scope → step_up_nous_billing_scope
  inline, then replays the held preview/mutation — reusing the upgrade idempotency key).

Also the scheduled-change UX fix: the overview leads with a prominent banner
(⏳ Scheduled change · Super ──▶ Plus · <date> · you keep Super until then) and the
status line echoes the transition, matching the TUI. Members / non-interactive /
free still deep-link. Tests drive every branch via a mocked modal + nous_billing.

* fix(billing): close TUI subscription money-path holes (ultracode review)

- Un-consented charge (P1): the step-up now HOLDS at a 'granted' phase requiring
  an explicit Continue, and an abortedRef gates the grant's late .then — a cancel
  during the browser flow can no longer replay the held upgrade + charge.
- Missing idempotency key (P2): mint it when building an upgrade 'pending' so it
  rides into confirm AND the step-up replay (was always undefined → gateway minted
  a fresh key per call, defeating dedup).
- Navigate-away re-charge (P2): confirm 'back' is guarded by submittingRef while an
  apply is in flight.
- Ambiguous charge (P2): a transport-null upgrade is reported as 'may or may not
  have charged — re-check', never a flat failure that invites a blind retry.
- Typed step-up denial (P2): requestRemoteSpending returns {granted,error,message};
  the screen maps session_revoked / remote_spending_revoked / rate_limited to the
  right recovery instead of always 'an admin must allow it'.

* fix(billing): close CLI subscription money-path holes (ultracode review)

- Bounded step-up (P2): bust the 30s token cache after a grant (it held the
  pre-grant unscoped token; _request only busts on 401, not 403) and replay ONCE
  with allow_stepup=False so a still-denied scope can't re-prompt/re-open in a loop.
- Stray-keystroke charge (P3→near-P2): the upgrade confirm defaults to 'Go back',
  not 'Pay ' — a bare Enter can't move money.
- Fail-open on unknown effect (P3→near-P2): an unrecognized preview effect now
  fails SAFE (portal hand-off) instead of scheduling a real PUT.
- 'cancel' word collision (P3): the Close row uses value 'close' so typing 'cancel'
  can't hit it and falsely report 'Cancelled'.
- blocked effect re-offers the portal; undo is promoted to the first row when a
  change is pending (TUI parity).

* fix(billing): guard the step-up resume against double-fire (2nd ultracode pass, BUG A)

The P1 fix split the auto-replay into a user-triggered resume() on the granted
screen, where the default row is the charging action — but resume() had no
re-entrancy guard, so a double-Enter fired two replays (the upgrade dedups on the
shared key, but schedule/cancel/resume replays carry none → duplicate PUT/DELETEs).
Mirror billingOverlay.resume(): flip to a 'resuming' phase + a resumingRef so it
fires at most once, and block 'back' once resuming (no re-mount → no second submit).

* fix(billing): CLI charge-route ambiguous-charge caveat (2nd ultracode pass, BUG B)

The TUI hardened upgradeResult(null) but the CLI charging route did not: a
transport/timeout/500 (or unknown 2xx status) on post_subscription_upgrade — after
NAS may have already prorated + charged — printed a flat failure, and a manual
re-run mints a FRESH idempotency key the server can't dedup → a real second charge.
Now the charge route reports 'your card may or may not have been charged — re-run
/subscription to check before trying again' and steers away from a blind retry
(the CLI can't persist the key across a command re-run). Also thread allow_stepup
through the preview→apply replay (BUG C.1) and route the requires_action/
payment_failed portal lines through _cprint for deterministic ordering.

* fix(billing): cap the TUI step-up replay to avoid a resume-deadlock (final pass, R1)

The round-2 resume guard ('resuming' phase + resumingRef) could deadlock: on a
REPEAT insufficient_scope during the post-grant replay, the route helpers did
onPatch({screen:'stepup'}) — a no-op since we're already mounted on stepup (no key
→ no remount) — leaving phase='resuming'/resumingRef=true frozen on 'Applying your
change…'. Thread allowStepUp through previewAndRoute/applyPendingAndRoute/
resumeAndRoute; the resume() replay passes false, so a repeat scope denial surfaces
a 'still isn't enabled' result instead (mirrors the CLI's allow_stepup=False cap).
Also: applyPendingAndRoute(pending=null) now routes to overview, not a stranded
Promise.resolve().

* fix(billing): narrow the CLI ambiguous-charge catch to indeterminate outcomes (final pass, R2)

The round-2 fix caught EVERY non-scope BillingError as 'may or may not have been
charged' — but typed pre-charge rejections (BillingRateLimited 429, BillingSessionRevoked
401, BillingRemoteSpendingRevoked 403, role_required/no_payment_method 4xx) never
reached Stripe, so the ambiguity copy was wrong and dropped their real recovery hints.
Now route those to _subscription_render_error, and reserve the ambiguous copy for
genuinely indeterminate outcomes (network_error / endpoint_unavailable / status None /
5xx). Tests: rate-limit stays deterministic; a real transport failure stays ambiguous.

* feat(billing): card visibility + guided add-card path in /topup and /subscription

Consume the NAS card-resolver contract (card.resolvedVia + chargeability) across
both surfaces, degrading cleanly on today's NAS (fields absent → prior behavior):

- WHICH card: the payment lines render provenance — 'Visa ····4242 — the card on
  your subscription' (resolvedVia → label; unknown rung/older NAS → masked card +
  the old generic line). Link payment methods render the brand alone (last4 is
  empty — never 'Link ····').
- Presence at a glance: the /topup overview now shows 'Card: …' or 'No saved
  card on file' for the full-menu case, plus a warning when the resolver marks
  the card needs_repair (failing auto-reloads) on overview/buy/confirm.
- Add-card path: with no card on file, 'Add funds' becomes a guided screen —
  open the portal billing page, then 'I've added it — check again' re-fetches
  billing state and continues straight into the purchase (also recovers a
  transient display miss). Cards are never entered in-terminal.
- /subscription upgrade confirm names the exact card ('Visa ····4242 — the card
  on your subscription — will be charged'), best-effort via billing.state and
  only when the resolution rung matches what a subscription charge actually
  uses (subPin/customerDefault, mirroring Stripe's precedence); otherwise the
  generic line stands. Fail-soft: any lookup error keeps the generic line.
- Gateway serializes display/resolved_via/needs_repair; TUI ctx gains
  refreshState (topup) + fetchCard (subscription); new offline fixtures
  card-sub / card-repair.

Tests: TUI ctx mocks extended; CLI suites cover provenance + repair-warning
render, the Link guard, the add-card path (continue-after-recheck + abandon),
the sub-confirm card line, and keep the confirm-time lookup offline in tests.

* fix(billing): consume server canChangePlan, preserve distinct refusal codes, drop dead chargeability

- Parse canChangePlan verbatim from NAS payloads into BillingState and
  SubscriptionState; fall back to the legacy OWNER/ADMIN check only when the
  server omits the field (FINANCE_ADMIN stops being locked out where NAS
  authorizes it). Role model updated to the 5-role enum.
- Add the autoReload.card union (canonical | distinct | none) end-to-end:
  parse + gateway serialization, distinct carries payment_method_id/brand/last4
  with nullable display fields.
- stripe_unavailable (503, transient) and upgrade_cap_exceeded (429, daily cap)
  now survive to the wire as their own codes instead of collapsing into
  rate_limited; new exception types subclass BillingRateLimited so existing
  backoff call sites keep working.
- Remove card.chargeability / needs_repair parsing, serialization, fixtures and
  the cli warning blocks: NAS NousResearch#670 removed the field, so the repair path was
  permanently dead. The future card-health signal belongs to the NAS W1/W3 work.
- Tests: five-role fixtures, canChangePlan override/fallback, all three
  auto-reload card variants, 429-vs-503 code preservation end-to-end.

* feat(tui): render the full NAS billing refusal surface

- billingOverlay: divergence notice when auto-refill charges a distinct card
  (portal deep-link to reconcile); needs_repair warnings removed with the field.
- topup: explicit copy for consent_required, org_access_denied,
  upgrade_cap_exceeded, auto_top_up_disabled_failures and stripe_unavailable
  (honors retry_after); processing_error is an explicit charge-failure case;
  transport loss during charge polling now reads as an unconfirmed outcome
  (check balance before retrying), matching the revocation path.
- subscriptionOverlay: branch on upgrade reason, not status, so an SCA-needing
  upgrade routes to portal verification even while NAS pre-NousResearch#711 labels it
  payment_failed; after an upgrade, poll subscription state until the tier
  flips (bounded), rendering applying/still-applying rather than assuming
  immediacy.
- Capability-neutral refusal copy (owner, admin, or finance admin) replaces
  the stale org admin/owner wording.
- gatewayTypes: BillingAutoReload.card union added, needs_repair removed.

* docs(billing): client-side billing state and refusal lifecycle table

Enumerates, from the code, every billing.state shape and typed refusal the
gateway serves and the exact TUI copy + recovery each renders. Acceptance from
the billing-integration handoff: no NAS billing state or typed refusal falls
through to a generic toast; unknown codes still degrade to the default branch
that surfaces the server message.
jarvisxyz pushed a commit that referenced this pull request Jul 19, 2026
* feat(tui): rename /billing slash command to /topup

Behavior-preserving rename of the /billing command surface to /topup.
Changes: billing.ts → topup.ts (export topupCommands, name 'topup', new
help string), registry.ts import+spread updated, billingOverlay.tsx
overview header 'Usage credits' → 'Top up credits', billingCommand.test.ts
→ topupCommand.test.ts with import/lookup/call updated. RPC method names
(billing.state, billing.charge, etc.) and component/symbol names unchanged.

* refactor(tui): extract overlay primitives to shared module

Lift MenuRow, ActionRow, footer, and barCells() out of billingOverlay.tsx
into overlayPrimitives.tsx so the upcoming subscriptionOverlay.tsx can
import them instead of duplicating. spendBar now calls barCells() —
output is byte-identical. Pure behavior-preserving refactor.

* feat(tui): add /subscription + /topup CTAs to /usage output

Every /usage render now ends with 'Run /subscription to change plan
· /topup to add credits' — both the healthy (with-calls) and depleted
(no-calls) paths. Strings-only change, no WS1 dependency.

* feat(tui): add subscription wire types

Add SubscriptionTierOption, SubscriptionStateResponse, and
SubscriptionManageLinkResponse to gatewayTypes.ts. Type-only — no
usages yet. Mirrors the BillingStateResponse conventions (snake_case,
Decimals as strings) and reuses BillingErrorPayload for error mapping.

* feat(gateway): add subscription.state + subscription.manage_link RPCs

- agent/subscription_view.py: SubscriptionState dataclass + fail-open
  build_subscription_state() (mirrors billing_view pattern) +
  get_subscription_manage_link() for the Stripe deep-link.
- hermes_cli/nous_billing.py: get_subscription_state() +
  post_subscription_manage_link() HTTP helpers for the two NAS endpoints
  (WS1 Phase A/C). The manage-link endpoint raises BillingScopeRequired
  when Remote-Spending is missing (Phase 4 step-up trigger).
- tui_gateway/server.py: _serialize_subscription_state() +
  subscription.state RPC (fail-open) + subscription.manage_link RPC
  (returns {ok,kind,url} or typed error envelope via
  _serialize_billing_error). NOT added to _LONG_HANDLERS — synchronous
  HTTP round-trip, not a device flow.

* feat(tui): add subscription overlay state types + store slot

Add SubscriptionScreen, SubscriptionOverlayCtx, SubscriptionOverlayState
to interfaces.ts and a 'subscription' slot to OverlayState. Wire it into
overlayStore.ts (buildOverlayState + $isBlocked). NOT added to
resetFlowOverlays preserve list — flow-scoped like billing, drops on
turn end.

* feat(tui): build SubscriptionOverlay — overview + confirm + handoff

Pure-render Ink component mirroring billingOverlay.tsx's structure.
Overview screen covers all 5 states (free-upgradeable, mid-tier,
top-tier, not-admin, downgrade-pending) + dunning. Confirm screen is
y/n deep-link to Stripe (NO in-terminal charge). Handoff is the
transient 'Opening Stripe' screen. Imports shared primitives from
overlayPrimitives.tsx. 8 render tests via renderSync covering every
state.

* feat(tui): add /subscription command + overlay wiring

- subscription.ts: SubscriptionOverlayCtx closure (openManageLink,
  refreshState, requestRemoteSpending) + run handler that fetches
  subscription.state and opens the overlay. Alias /upgrade.
- registry.ts: spread subscriptionCommands into SLASH_COMMANDS.
- appOverlays.tsx: render SubscriptionOverlay when overlay.subscription set.
- useInputHandlers.ts: Esc closes subscription overlay; promptOverlay OR
  includes subscription so input is intercepted while open.
- subscriptionCommand.test.ts: 4 tests (fetch+open, logged-out sys line,
  /upgrade alias, /subscription resolves).

* fix(tui/subscription): stop saying Stripe in deep-link copy + fix manage link kind type

Replace all user-facing 'Stripe' mentions in the /subscription overlay and
sys messages with 'your subscription page' — the deep-link target is NAS's
own /manage-subscription page, not the Stripe hosted portal. Stripe only
legitimately appears later at actual Checkout. Also add 'manage' to the
SubscriptionManageLinkResponse.kind union (NAS emits kind:'manage'; was
previously missing from the TypeScript type causing silent narrowing errors).

* feat(tui/subscription): render cancellation-scheduled note with headline precedence

Parse cancelAtPeriodEnd + cancellationEffectiveAt from the NAS contract
(camelCase) in the agent parser (_parse_current), emit cancel_at_period_end
+ cancellation_effective_at from the gateway serializer, extend the
SubscriptionStateResponse type, and render a warn note in OverviewScreen:
'Cancels on {date} — your plan stays active until then.'

Headline precedence when multiple flags co-occur:
  past-due > cancel-scheduled > downgrade-pending > active
The downgradeNote guard is tightened to suppress when cancel is scheduled,
so at most one status line renders at a time.

* feat(tui/subscription): team-context screen — redirect to /topup for team orgs

Parse the NAS context:'personal'|'team' field (defaults to 'personal' for
unknown/missing values), emit it on the gateway wire, add it to
SubscriptionStateResponse. When context is 'team', SubscriptionOverlay
renders a dedicated read-only screen instead of the tier picker:

  'This terminal is connected to {org_name}. Teams run on shared
   credits — use /topup to add funds. Personal subscriptions live
   on your personal account.'

The screen closes on Enter or Esc. The personal/tier-picker path is
unchanged.

* fix(subscription): drop manage-link gateway RPC, build URL locally

The NAS POST /api/billing/subscription/manage-link endpoint was dropped
(it added no server work — the target is the static /manage-subscription
page, not a Stripe-minted secret). Build the URL client-side instead:
{portal_base}/manage-subscription?org_id=<org.id>.

- Remove subscription.manage_link gateway RPC (server.py)
- Remove get_subscription_manage_link helper (subscription_view.py)
- Remove post_subscription_manage_link (nous_billing.py)
- Remove SubscriptionManageLinkResponse type (gatewayTypes.ts)
- Add org_id to SubscriptionState + wire through serializer + TS type
- openManageLink() builds the URL locally via buildManageUrl(), opens
  it with the existing openExternalUrl(), no gateway round-trip
- Drop targetTierId param from openManageLink (v1 sends everyone to
  /manage-subscription; no tier deep-link needed)
- Fix stale test expectations (Stripe copy → subscription page copy)

* chore(subscription): drop unused format_money import

* feat(cli): /subscription + /upgrade, /billing→/topup rename, /usage CTAs

Add the classic-CLI half of the terminal billing surface to match the TUI:
- /subscription (alias /upgrade) command + /topup (renamed /billing, keeps
  'billing' as a back-compat alias) in the command registry.
- Drop the stale 'billing' entry from _SLACK_VIA_HERMES_ONLY (now cli_only).

* feat(subscription): CLI /subscription handler, drop dunning, current:null no-plan

- CLI _show_subscription mirrors the TUI overlay (plan read + tier list + usage
  bar + browser deep-link via subscription_manage_url); credits render as counts.
- Adapt to the updated NAS read contract: remove is_past_due/dunning everywhere
  (a card-failing subscriber returns as a normal plan now), and treat no-plan as
  current:null (parser returns None) rather than an all-null object.
- HERMES_DEV_SUBSCRIPTION_FIXTURE env-driven fixtures + ui-tui fixture harness
  drive every state (CLI + live TUI) with no portal.

Verified against handoff 2026-06-24_subscription-tui-handoff.md.

* feat(billing): CF-4 Remote-Spending revoked-terminal UX (NAS PR #481)

Wire the Remote-Spending gate denial contract end to end:
- nous_billing: BillingRemoteSpendingRevoked (403 remote_spending_revoked →
  reconnect) + BillingSessionRevoked (401 session_revoked → re-login), distinct
  from insufficient_scope; capture actor/code/recovery; 503 stays transient.
- gateway _serialize_billing_error threads the new typed kinds + actor/code/
  recovery to the TUI.
- TUI renderBillingError: actor-aware revoke copy, kills the spend overlay
  immediately (no 15-min zombie button), handles session_revoked, the dual-
  emitted cli_billing_disabled/remote_spending_disabled, role_required,
  idempotency_conflict; poll treats a mid-poll revoke as ambiguous (check
  balance before retry), not a failure.
- CLI _billing_render_charge_error: same denial matrix, actor-aware copy.

Tests: gate-contract mapping + envelope (py) and revoke/session/disabled (TUI).
Per handoff 2026-06-24_remote-spending-TUI-contract-handoff.md.

* refactor(subscription): remove dead step-up scaffolding from /subscription

/subscription only opens a browser deep-link to manage-subscription — that needs
no billing scope, so it can never hit insufficient_scope. Drop the never-fired
'stepup' screen type, requestRemoteSpending ctx fn, and resumeScreen bookkeeping
(leftovers from a superseded plan). The resumable step-up lives on /topup, where
the charge actually gets gated.

* feat(tui/topup): resumable 'Allow Remote Spending' step-up on the charge path

Phase 4: when a charge returns insufficient_scope, the /topup modal no longer
tears down with a 'run /billing again' ConfirmReq. Instead it stays MOUNTED and
switches to a step-up screen:
- charge() is now awaitable, returning a discriminated outcome (submitted |
  needs_remote_spending | error) so the overlay can route without closing.
- StepUpScreen: 'Allow Remote Spending' → await the device-flow grant (browser
  opens via the existing out-of-band billing.step_up.verification event) →
  replay the held charge (pendingCharge.amount) and settle, with no command
  re-run. Never surfaces the raw billing:manage scope.
- armStepUp's fire-and-forget ConfirmReq replaced by requestRemoteSpending();
  the leaky 'billing:manage' / 'Re-authorize' / 'run /billing again' copy is gone.

Tests: charge-outcome routing, step-up grant/deny, and a render test asserting
the step-up copy holds the amount and never leaks billing:manage.
Per handoff 2026-06-24_remote-spending-TUI-contract-handoff.md §2 (Grady #6).

* feat(billing): shared dollar usage model + two-bar view (drop "credits")

Single source of truth for the /usage and /subscription usage bars across
TUI + CLI. Reads the NAS account-info dollar fields (subscription/top-up/total
remaining, monthly allowance, renewal) and produces a surface-agnostic model:
two full-resolution bars (plan allowance + purchased top-up), a status
classification (free | healthy | low | depleted), and a human renewal date.

- agent/billing_usage.py: UsageModel/UsageBar, usage_model_from_account
  (fail-open), build_usage_model (HERMES_DEV_CREDITS_FIXTURE-aware),
  format_renews (ISO -> "Jul 24, 2026", Windows-safe), $5 low-balance threshold.
- tui_gateway/server.py: _serialize_usage_model/_serialize_usage_bar, a
  usage.bars RPC, and the model embedded into subscription.state so the overlay
  renders the same bars from its single fetch.
- Dollars only, never "credits"; two separate bars (not a crammed
  three-segment one) for legibility at terminal widths.
- tests/agent/test_billing_usage.py: status classification, bar math
  (clamp/over-cap), NaN/Inf rejection, fail-open invariants.

* feat(tui): dollar usage bars on /usage + /subscription, drop tier picker

Render the shared two-bar dollar model in both overlays; strip "credits" and
the in-terminal tier selection per UX feedback.

- overlayPrimitives.tsx: UsageBars (themed plan/top-up bars — gold allowance,
  green top-up) + usageBarsText for the /usage panel. Plan name labels the
  bar; "$X left of $Y · N% used" (disambiguated so the % matches); top-up
  "never expires".
- subscriptionOverlay.tsx: status line dedupes ($X left once; bar carries the
  breakdown), human renewal date, state-matched nudges (free upsell / <$5
  low alert) with box-safe ASCII markers (! / >) instead of the width-unstable
  emoji that broke the border. Tier picker removed — overview shows usage +
  plan, then "Manage on portal" / "Close" (free users get "Start a
  subscription"). No "credits" anywhere.
- session.ts: /usage renders the dollar bars + balance summary, falling back
  to the legacy credits lines only when the model is unavailable; CTA reworded.
- gatewayTypes.ts: UsageModelData/UsageBarData wire types + usage on
  SessionUsageResponse/SubscriptionStateResponse.
- Tests updated to the new contract (no "credits", "left of", dedup, markers).

* feat(cli): mirror dollar usage bars on /usage + /subscription

CLI parity with the TUI billing rework, from the same shared usage model.

- _print_nous_credits_block (/usage) and _subscription_overview render the
  two-bar dollar view (plan name on the bar, "$X left of $Y · N% used",
  top-up "never expires", total spendable) instead of the credits-worded block.
- Dollars only — dropped the tier catalog (no more "$N/mo (… credits)") and
  every user-facing "credits"; team copy says "shared balance".
- Human renewal date via the shared format_renews; status line dedupes the
  "$X left"; free upsell + <$5 low alert with ASCII markers.
- /subscription manage modal no longer dumps the raw manage-subscription URL
  in its detail — the [1] Open / [2] Copy link / [3] Cancel options carry it.
  Title is "Manage your subscription" (no in-terminal plan change). The raw URL
  stays only in the non-interactive / not-admin fallbacks, which have no menu.
- /usage token-usage panel (model, tokens, cost, context) left untouched.

* feat(billing): embed dollar usage model into billing.state for /topup

The /topup overview renders the same two-bar dollar usage (plan + top-up) as
/usage and /subscription. Embed the shared usage model into the billing.state
RPC payload (mirrors subscription.state) so the overlay gets the bars from its
single fetch, and add the `usage` field to BillingStateResponse.

* feat(tui/topup): reorder overview + in-flight reauth with press-Enter resume

Reworks the /topup overlay per the Jun 19 review and the no-preflight decision.

Overview:
- Balance leads in the title ("Top up · balance $X"); the shared two-bar dollar
  usage (plan + top-up) renders below. Dropped the old monthly-cap spend bar.
- "Add funds" is the first action (was "Buy credits"); auto-reload / monthly
  limit / manage-on-portal follow. Dollars only — no "credits" anywhere.
- No "Enable terminal billing" menu item and NO scope preflight: whether the
  terminal can charge is discovered reactively at pay time. (We deliberately do
  not read/refresh the OAuth token to gate UI.)

Step-up (reached only on a charge's insufficient_scope 403):
- New 4-phase flow that keeps the modal mounted: prompt (one-time-setup
  heads-up) → waiting (browser authorize) → granted (explicit "Press Enter to
  resume") → replay the held charge → settle. The press-Enter beat is the
  reassuring "you're back, finish your purchase" moment.
- Renamed user copy "Allow Remote Spending" → "Enable terminal billing"; never
  leaks the raw billing:manage scope (guarded by the render test).
- topup.ts error copy de-crufted to terminal-billing wording, emoji removed.

Tests: step-up prompt copy, the no-raw-scope invariant, and new overview tests
(balance-in-title, Add-funds-first, two-bar usage, no "credits").

* feat(cli/topup): mirror overview reorder + in-flight reauth resume

CLI parity with the TUI /topup rehaul, from the same shared usage model.

- _billing_overview: balance in the title, the two-bar dollar usage (plan name
  on the plan bar, top-up "never expires") in place of the old cap spend bar,
  "Add funds" first, dollars throughout — no "credits", no scope preflight.
- _billing_handle_scope_required: now takes the held amount + idempotency key
  and runs the in-flight flow — "Enable terminal billing" → browser device-flow
  → re-check the org kill-switch → press-Enter to resume → replay the held
  charge (reusing the key so a double-submit collapses to one). Stops leaking
  the raw billing:manage scope.
- Charge-error + buy/auto-reload copy de-crufted to terminal-billing/dollars.
- Tests updated to the new overview + buy copy.

* fix(billing): guard non-JSON 2xx responses in the billing HTTP client

A 2xx response with a non-JSON body — e.g. a reverse-proxy / SPA fallback HTML
page served when a billing route isn't actually mounted on a deployment — hit
json.loads() on the success path of _request() and raised a raw
json.JSONDecodeError. That escaped the typed-BillingError contract, so callers'
`except BillingError` missed it and fell through to a generic fail-open that
rendered as a misleading "not logged in" (observed when /api/billing/subscription
was briefly unshipped on staging: 200 text/html, x-matched-path /[...notFound]).

Now a non-JSON 2xx body raises a typed BillingError(error="endpoint_unavailable")
so surfaces degrade gracefully ("could not load …") instead of crashing or
mislabeling a valid session as logged-out. The 4xx/5xx path already guarded its
.json(); this closes the same hole on the success path.

Test: tests/hermes_cli/test_nous_billing_request.py — non-JSON 2xx → typed
error (not JSONDecodeError, not BillingAuthError), empty body → {}, valid JSON
parses.

* feat(billing/dev): add HERMES_DEV_BILLING_FIXTURE for offline card/scope testing

build_billing_state short-circuits to a fixture when HERMES_DEV_BILLING_FIXTURE
is set (mirrors HERMES_DEV_CREDITS_FIXTURE for the usage model). States:
nocard | card | card-autoreload | notadmin | billing-off | logged-out — so the
card-on-file gate, admin role, and kill-switch paths are exercisable offline
without a live portal. Env-var gated; returns None when unset (no prod leak).

Adds 8 behavior tests asserting the card/admin/billing-on contract per state.

* refactor(billing): fold /credits into /topup

/credits is redundant now that /topup shows the dollar balance + portal handoff.
Make 'credits' (and 'billing') aliases of /topup so typing /credits still works,
resolving to topup everywhere (CLI, gateway, Slack, TUI, autocomplete, help).

Remove the standalone /credits surface across 6 places:
- CLI _show_credits handler + dispatch
- gateway _handle_credits_command -> renamed _handle_topup_command, copy softened
  to 'Manage billing on the portal' (the messaging billing surface; /topup is now
  gateway-available so messaging keeps billing — credits was the only one before)
- TUI commands/credits.ts + creditsCommand.test.ts (deleted), registry entry
- tui_gateway credits.view RPC + the CreditsViewResponse type
- Slack _SLACK_VIA_HERMES_ONLY: credits -> topup

Sweep user-facing /credits -> /topup (usage-block hint, depletion notice) and
stale doc-comments. OpenRouter's /credits endpoint URL left untouched. Tests
updated (test_credits_folds_into_topup) or pruned for the removed symbols.

* fix(billing): card-on-file heads-up, no-card portal gate, /usage bar ordering, modal glyph

In-terminal charge (POST /charge against the org's server-held card, no card ref
leaves the client):
- card present: confirm screen shows 'Your card saved on the portal will be
  charged' + a 'Manage on portal' escape option (CLI); heads-up line (TUI)
- no card on file: /topup overview + buy flow detect it and route to the portal
  to add a card, instead of offering a charge that 403s no_payment_method

/usage bar ordering: route the dollar block through _cprint consistently. The
Plan: line (_cprint) and the bar (raw print) flushed to different buffers under
patch_stdout and interleaved nondeterministically; now Plan: -> bar -> status/CTA
is stable across all states.

Modal glyph: strip the leading emoji from bordered _prompt_text_input_modal
titles — it measures 1 char but renders 2 columns, shifting the box's right
border (the stray '|'). Includes the f-string 'Pay $X?' title.

Small /credits -> /topup string bits in cli.py ride along with the surrounding
charge edits (the fold lives in the sibling refactor commit).

* refactor(billing): apply safe simplify-pass fixes

Three low-risk cleanups from a parallel simplify review (reuse/quality/efficiency):
- dev fixture portal URL: reuse the prod host (was drifted to staging-* — a real
  mismatch vs subscription_view's _DEV_FIXTURE_PORTAL)
- TUI billingOverlay choose(): collapse two byte-identical branches (needsCard +
  the not-full else both = portal-or-close at index 0) into one tail; the only
  divergent path (full && !needsCard → buy/auto/limit) stays explicit
- /topup overview comment: correct the stale 'buy_flow detects no_payment_method'
  note (the overview's no-card gate fires first, so reaching Add funds implies a
  card on file)

Skipped (judgment): the orphaned CreditsView.depleted field (harmless, on a live
dataclass), the defensive card gates in _billing_buy_flow/_confirm_and_charge
(cheap correct defense on the money path), and folding the no-card handoff into a
shared helper (touches 4 money-path sites for tidiness — not worth the risk here).

* fix(billing): reactive charge gating — drop card preflight, react to 403 (scope→reauth, no-card→portal)

* refactor(billing): drop the /credits alias entirely

The /credits fold made it an alias of /topup; now remove that too. Typing
/credits is an unknown command, not a silent redirect — billing lives only on
/topup (with /billing kept as the old command's back-compat name). Dropped the
alias from the registry CommandDef and the TUI topup.ts; updated the test to
assert /credits resolves to nothing (no command, no alias).

* docs(billing): fix stale comment in _billing_overview — describe reactive no-card path

The comment still described the removed overview-level card gate ('no-card case
handled above'). Corrected to: the buy flow reacts to the server's
no_payment_method 403 and hands off to the portal at charge time (no preflight).

* refactor(billing): simplify-pass — share usage-payload helper, drop dead bar wire fields + redundant admin gate

* refactor(billing): drop the /billing alias too — /topup is the only billing command

Following /credits removal, retire the old /billing name as well. /topup now has
NO aliases — both /credits and /billing are unknown commands. Dropped the alias
from the registry CommandDef and TUI topup.ts; fixed the one live user-facing
straggler (the not-logged-in message said 'then /billing' → /topup) and the
_show_billing docstring/default-arg references. Test asserts /topup carries no
aliases and neither old name resolves.

* fix(billing): code-review fixes — money-path + parity bugs

Money path (TUI):
- auto-reload "Turn off" now echoes current threshold/top_up_amount so the
  PATCH succeeds (was sending {enabled:false} → invalid_request → stayed ON)
- charge poll honors the 5-min cap on the 429/503 throttle branch too (was
  rescheduling forever); cap folded into one timedOut() helper
- step-up resume reacts to the replay outcome instead of unconditionally
  closing on a reassuring line with no charge made
- synchronous submit guard on Confirm so two key events can't double-charge

Gateway:
- billing.step_up routes typed errors through _serialize_billing_error (was a
  raw {error:'error'} dict → generic copy for session_revoked)
- billing.state / subscription.state / usage.bars / session.usage moved to
  _LONG_HANDLERS (blocking portal HTTP no longer stalls the main stdin loop)

CLI:
- _billing_render_charge_error handles insufficient_scope without leaking the
  raw billing:manage scope name on a post-grant replay re-raise

Python model:
- subscription_view tier parse None-coalesces tierOrder/dollarsPerMonth so a
  free tier's 0 survives ($0, not "—"; correct sort order)

TUI parity/robustness:
- /usage shows formatted renews_display, not raw ISO renews_at
- subscription overview guards a null pending_downgrade_at (was "on null.")
- subscription overview surfaces a message instead of silently closing when
  portal_url is missing
- buildManageUrl wraps new URL() so a malformed portal_url can't throw out of
  the Ink key handler

* fix(billing): cross-surface bar direction, formatted cancel/downgrade dates, Slack alias gating

- CLI plan bar now fills by REMAINING (fuel-gauge), matching the shared model's
  fill_fraction, the top-up bar, and the TUI — same account renders identically
  on both surfaces (#8)
- subscription serializer emits cancellation_effective_display /
  pending_downgrade_display (format_renews); TUI shows 'Jul 1, 2026' not raw ISO (#14b)
- _SLACK_VIA_HERMES_ONLY now includes the 'billing' alias so it follows its
  canonical /topup via /hermes instead of leaking a native Slack slot (#9)

* fix(billing): thread idempotency key through the TUI step-up replay (#2)

Mint a stable idempotency key when the purchase amount is chosen; it rides
pendingCharge into both the Confirm charge and the post-grant step-up replay,
so a retried charge dedups server-side (the gateway already echoes the key).
A fresh amount selection gets a fresh key. Combined with the sync submit guard,
a double-submit now collapses to one charge.

* refactor(billing): remove dead /subscription tier-picker scaffolding (#18)

The in-terminal plan picker was cut (deep-link only), leaving a whole unreached
state machine. Removed end-to-end:
- TUI: ConfirmScreen, HandoffScreen, the 'confirm'/'handoff' screen types,
  pendingTargetTierId, and the now-dead onPatch threading (collapsed the dispatch
  to a single overview screen + folded the duplicate Box wrapper)
- gateway: the tiers serialization + SubscriptionTierOption wire type
- model: SubscriptionTier, _parse_tier, _coalesce, _dev_tiers and the tiers field
  (never displayed on either surface, so this supersedes the tier-parse fix)
- tests: dropped the confirm/handoff/tier-passthrough tests; slimmed the overview
  render tests

Net: a large dead-code cull (no behavior change — the picker never ran).

* test(billing): parametrize usage-model tests; drop dead is_low/is_free props

Collapse the fail-open + status-classification cases into parametrized tables
(same coverage, ~80 fewer lines) and remove the now-unused UsageModel.is_low /
is_free properties (only a test pinned them).

* fix(billing): revert dead 'billing' Slack-via-hermes entry — the alias was dropped

#9 was based on a stale review diff: /billing is no longer an alias of /topup
(dropped earlier), so routing it via /hermes filtered a name that doesn't exist.

* test(billing): cull redundant TUI billing tests (parametrize, merge dupes)

usageCommand: collapse 3 CTA tests into one + a panel helper.
billingStepUp: merge the two step-up render asserts.
topupCommand: parametrize requestRemoteSpending + the revoked-actor pair, drop
the redundant happy-path-submitted test. Money-path + error-mapping coverage
preserved.

* refactor(billing): extract _usage_bar_lines — one source of truth for the CLI bars

The plan + top-up bar format was copy-pasted across _print_nous_credits_block,
_subscription_overview, and _billing_overview. Extract a helper returning the
ready-to-print lines; each caller keeps its own print fn (the _cprint-ordering
constraint stays) and resolves its plan-name label. Centralizes the format so
the three surfaces can't drift.

* feat(billing): NAS V3 subscription-change HTTP client wrappers

Add the four write-side wrappers for the V3 subscription contract to nous_billing,
each a thin _request() call (reusing auth, JSON, 401-retry, typed errors):
- post_subscription_preview      → POST  /subscription/preview      (chargeless quote)
- put_subscription_pending_change→ PUT   /subscription/pending-change (downgrade/cancel)
- delete_subscription_pending_change → DELETE .../pending-change      (resume/undo)
- post_subscription_upgrade      → POST  /subscription/upgrade        (the money route)

pending-change takes a discriminated body (tier_change | cancellation); upgrade
requires an Idempotency-Key (mandatory, validated client-side before any I/O).
Tests assert the exact method/path/body/header each wrapper puts on the wire.

* feat(billing): subscription tier catalog + change-preview models

Reinstate the catalog the in-terminal picker needs (was culled when /subscription
was deep-link-only): SubscriptionTier + SubscriptionState.tiers + _parse_tier, with
_coalesce so the free tier's 0 tierOrder/price survives a falsy-or. Parse the
catalog from GET /subscription's tiers and seed _dev_tiers into every fixture.

Add SubscriptionChangePreview + subscription_change_preview_from_payload for the
POST /preview quote (effect/amountDueNowCents/effectiveAt/reason + tier delta); a
malformed/missing effect fails safe to 'blocked' so a bad quote never reads as a
charge. Module docstring updated: the overlay is no longer deep-link-only.

* feat(billing): gateway RPCs for the V3 subscription change flow

Add subscription.preview / .change / .resume / .upgrade RPCs, each wrapping its
nous_billing call and reusing _serialize_billing_error for the typed envelope
(so a 403 still drives the device step-up). upgrade mints + echoes the
idempotency key and surfaces status + recovery_url so the TUI can route an
SCA/decline to the portal. Re-add the tier catalog to _serialize_subscription_state
(price pre-formatted) for the picker. All four are pool-routed (_LONG_HANDLERS) —
preview + upgrade hit Stripe and must not stall the main stdin loop.

* feat(billing): in-terminal subscription change flow (TUI)

/subscription is no longer deep-link-only: it drives the change in-terminal
against the V3 contract via the new gateway RPCs. The overlay is a state machine
overview → picker → confirm → result:
- picker lists the tier catalog with upgrade/downgrade hints (current + free
  excluded; free=cancel, on the overview);
- confirm shows the previewed effect — pay $X now (upgrade) / scheduled at date
  (downgrade) / cancel at period end / blocked-with-reason — then applies it;
- an upgrade's SCA/decline routes to the portal via the result screen's recovery
  link; resume/cancel/downgrade are chargeless.

Starting a NEW subscription still deep-links (needs a fresh card). insufficient_scope
points to /topup (the step-up stays there, not duplicated here). Adds the wire
types (tiers + preview/upgrade responses), widens the overlay ctx + screen state,
and threads onPatch. Render tests cover every screen.

* feat(billing): in-terminal step-up + clearer scheduled-change UX (TUI)

Two improvements to the /subscription overlay:

Step-up re-auth in place. When a mutation (preview/change/upgrade/resume) returns
insufficient_scope, route to a new 'stepup' screen that grants terminal billing
via billing.step_up and AUTO-REPLAYS the held action on grant — no bounce to
/topup. Scope routing is centralized in previewAndRoute/applyPendingAndRoute/
resumeAndRoute (shared by the picker, confirm, overview + the step-up replay). The
browser opens via the shared global verification handler; copy never leaks the raw
billing:manage scope.

Make a scheduled change unmissable. A downgrade/cancel was one buried warn line
that read as 'nothing happened'. Now the overview leads with a banner
(⏳ Scheduled change · Ultra ──▶ Plus · <date> · you keep Ultra until then), the
status line echoes the transition (Plan: Ultra → Plus), 'Keep <tier> (undo)' is
promoted to the first olive action, the result screen says 'your plan doesn't
change today', and confirm gets a charged-now / scheduled chip.

* feat(billing): full in-terminal subscription change flow in the classic CLI

Bring the CLI to parity with the TUI overlay — /subscription is no longer
deep-link-only. A paid admin/owner gets picker → preview → confirm → apply,
mirroring the /topup buy flow's modal idioms:
- _subscription_change_menu (change / undo-or-cancel / manage-on-portal),
- _subscription_pick_tier (catalog with upgrade/downgrade hints),
- _subscription_preview_and_confirm (POST /preview → effect-aware confirm),
- _subscription_apply (schedule / cancel / resume chargeless; upgrade charges
  the sub's card, SCA/decline → portal),
- _subscription_handle_scope_required (insufficient_scope → step_up_nous_billing_scope
  inline, then replays the held preview/mutation — reusing the upgrade idempotency key).

Also the scheduled-change UX fix: the overview leads with a prominent banner
(⏳ Scheduled change · Super ──▶ Plus · <date> · you keep Super until then) and the
status line echoes the transition, matching the TUI. Members / non-interactive /
free still deep-link. Tests drive every branch via a mocked modal + nous_billing.

* fix(billing): close TUI subscription money-path holes (ultracode review)

- Un-consented charge (P1): the step-up now HOLDS at a 'granted' phase requiring
  an explicit Continue, and an abortedRef gates the grant's late .then — a cancel
  during the browser flow can no longer replay the held upgrade + charge.
- Missing idempotency key (P2): mint it when building an upgrade 'pending' so it
  rides into confirm AND the step-up replay (was always undefined → gateway minted
  a fresh key per call, defeating dedup).
- Navigate-away re-charge (P2): confirm 'back' is guarded by submittingRef while an
  apply is in flight.
- Ambiguous charge (P2): a transport-null upgrade is reported as 'may or may not
  have charged — re-check', never a flat failure that invites a blind retry.
- Typed step-up denial (P2): requestRemoteSpending returns {granted,error,message};
  the screen maps session_revoked / remote_spending_revoked / rate_limited to the
  right recovery instead of always 'an admin must allow it'.

* fix(billing): close CLI subscription money-path holes (ultracode review)

- Bounded step-up (P2): bust the 30s token cache after a grant (it held the
  pre-grant unscoped token; _request only busts on 401, not 403) and replay ONCE
  with allow_stepup=False so a still-denied scope can't re-prompt/re-open in a loop.
- Stray-keystroke charge (P3→near-P2): the upgrade confirm defaults to 'Go back',
  not 'Pay ' — a bare Enter can't move money.
- Fail-open on unknown effect (P3→near-P2): an unrecognized preview effect now
  fails SAFE (portal hand-off) instead of scheduling a real PUT.
- 'cancel' word collision (P3): the Close row uses value 'close' so typing 'cancel'
  can't hit it and falsely report 'Cancelled'.
- blocked effect re-offers the portal; undo is promoted to the first row when a
  change is pending (TUI parity).

* fix(billing): guard the step-up resume against double-fire (2nd ultracode pass, BUG A)

The P1 fix split the auto-replay into a user-triggered resume() on the granted
screen, where the default row is the charging action — but resume() had no
re-entrancy guard, so a double-Enter fired two replays (the upgrade dedups on the
shared key, but schedule/cancel/resume replays carry none → duplicate PUT/DELETEs).
Mirror billingOverlay.resume(): flip to a 'resuming' phase + a resumingRef so it
fires at most once, and block 'back' once resuming (no re-mount → no second submit).

* fix(billing): CLI charge-route ambiguous-charge caveat (2nd ultracode pass, BUG B)

The TUI hardened upgradeResult(null) but the CLI charging route did not: a
transport/timeout/500 (or unknown 2xx status) on post_subscription_upgrade — after
NAS may have already prorated + charged — printed a flat failure, and a manual
re-run mints a FRESH idempotency key the server can't dedup → a real second charge.
Now the charge route reports 'your card may or may not have been charged — re-run
/subscription to check before trying again' and steers away from a blind retry
(the CLI can't persist the key across a command re-run). Also thread allow_stepup
through the preview→apply replay (BUG C.1) and route the requires_action/
payment_failed portal lines through _cprint for deterministic ordering.

* fix(billing): cap the TUI step-up replay to avoid a resume-deadlock (final pass, R1)

The round-2 resume guard ('resuming' phase + resumingRef) could deadlock: on a
REPEAT insufficient_scope during the post-grant replay, the route helpers did
onPatch({screen:'stepup'}) — a no-op since we're already mounted on stepup (no key
→ no remount) — leaving phase='resuming'/resumingRef=true frozen on 'Applying your
change…'. Thread allowStepUp through previewAndRoute/applyPendingAndRoute/
resumeAndRoute; the resume() replay passes false, so a repeat scope denial surfaces
a 'still isn't enabled' result instead (mirrors the CLI's allow_stepup=False cap).
Also: applyPendingAndRoute(pending=null) now routes to overview, not a stranded
Promise.resolve().

* fix(billing): narrow the CLI ambiguous-charge catch to indeterminate outcomes (final pass, R2)

The round-2 fix caught EVERY non-scope BillingError as 'may or may not have been
charged' — but typed pre-charge rejections (BillingRateLimited 429, BillingSessionRevoked
401, BillingRemoteSpendingRevoked 403, role_required/no_payment_method 4xx) never
reached Stripe, so the ambiguity copy was wrong and dropped their real recovery hints.
Now route those to _subscription_render_error, and reserve the ambiguous copy for
genuinely indeterminate outcomes (network_error / endpoint_unavailable / status None /
5xx). Tests: rate-limit stays deterministic; a real transport failure stays ambiguous.

* feat(billing): card visibility + guided add-card path in /topup and /subscription

Consume the NAS card-resolver contract (card.resolvedVia + chargeability) across
both surfaces, degrading cleanly on today's NAS (fields absent → prior behavior):

- WHICH card: the payment lines render provenance — 'Visa ····4242 — the card on
  your subscription' (resolvedVia → label; unknown rung/older NAS → masked card +
  the old generic line). Link payment methods render the brand alone (last4 is
  empty — never 'Link ····').
- Presence at a glance: the /topup overview now shows 'Card: …' or 'No saved
  card on file' for the full-menu case, plus a warning when the resolver marks
  the card needs_repair (failing auto-reloads) on overview/buy/confirm.
- Add-card path: with no card on file, 'Add funds' becomes a guided screen —
  open the portal billing page, then 'I've added it — check again' re-fetches
  billing state and continues straight into the purchase (also recovers a
  transient display miss). Cards are never entered in-terminal.
- /subscription upgrade confirm names the exact card ('Visa ····4242 — the card
  on your subscription — will be charged'), best-effort via billing.state and
  only when the resolution rung matches what a subscription charge actually
  uses (subPin/customerDefault, mirroring Stripe's precedence); otherwise the
  generic line stands. Fail-soft: any lookup error keeps the generic line.
- Gateway serializes display/resolved_via/needs_repair; TUI ctx gains
  refreshState (topup) + fetchCard (subscription); new offline fixtures
  card-sub / card-repair.

Tests: TUI ctx mocks extended; CLI suites cover provenance + repair-warning
render, the Link guard, the add-card path (continue-after-recheck + abandon),
the sub-confirm card line, and keep the confirm-time lookup offline in tests.

* feat(desktop): add desktop-local billing wire types

* feat(desktop): billing gateway API client and refusal taxonomy

* feat(desktop): register billing settings tab with skeleton view

* feat(desktop): wire billing tab to live gateway reads with fail-open states

* feat(desktop): buy-credits charge flow with settlement poller

* fix(desktop): keep About last in settings nav, billing above it

* feat(desktop): auto-refill editing and billing step-up verification flow

* fix(desktop): clamp overdrawn subscription credits and pin USD symbol formatting

* fix(desktop): move billing next to notifications in settings nav

* feat(desktop): usage-bar state colors and dev fixture simulator

* feat(desktop): wide usage bars with top-up bar and refresh affordance

* fix(desktop): disable buy controls without a card, neutral tracks for bar-less usage rows

* polish(desktop): usage-grid alignment, tabular numerals, legible tracks and danger states

* polish(desktop): dithered empty and depleted usage-bar tracks per app bar idiom

* fix(billing): consume server canChangePlan, preserve distinct refusal codes, drop dead chargeability

- Parse canChangePlan verbatim from NAS payloads into BillingState and
  SubscriptionState; fall back to the legacy OWNER/ADMIN check only when the
  server omits the field (FINANCE_ADMIN stops being locked out where NAS
  authorizes it). Role model updated to the 5-role enum.
- Add the autoReload.card union (canonical | distinct | none) end-to-end:
  parse + gateway serialization, distinct carries payment_method_id/brand/last4
  with nullable display fields.
- stripe_unavailable (503, transient) and upgrade_cap_exceeded (429, daily cap)
  now survive to the wire as their own codes instead of collapsing into
  rate_limited; new exception types subclass BillingRateLimited so existing
  backoff call sites keep working.
- Remove card.chargeability / needs_repair parsing, serialization, fixtures and
  the cli warning blocks: NAS #670 removed the field, so the repair path was
  permanently dead. The future card-health signal belongs to the NAS W1/W3 work.
- Tests: five-role fixtures, canChangePlan override/fallback, all three
  auto-reload card variants, 429-vs-503 code preservation end-to-end.

* feat(tui): render the full NAS billing refusal surface

- billingOverlay: divergence notice when auto-refill charges a distinct card
  (portal deep-link to reconcile); needs_repair warnings removed with the field.
- topup: explicit copy for consent_required, org_access_denied,
  upgrade_cap_exceeded, auto_top_up_disabled_failures and stripe_unavailable
  (honors retry_after); processing_error is an explicit charge-failure case;
  transport loss during charge polling now reads as an unconfirmed outcome
  (check balance before retrying), matching the revocation path.
- subscriptionOverlay: branch on upgrade reason, not status, so an SCA-needing
  upgrade routes to portal verification even while NAS pre-#711 labels it
  payment_failed; after an upgrade, poll subscription state until the tier
  flips (bounded), rendering applying/still-applying rather than assuming
  immediacy.
- Capability-neutral refusal copy (owner, admin, or finance admin) replaces
  the stale org admin/owner wording.
- gatewayTypes: BillingAutoReload.card union added, needs_repair removed.

* refactor(shared): move terminal-billing wire types to @hermes/shared

The billing/subscription wire shapes (plus UsageBarData/UsageModelData,
which they reference) move verbatim from ui-tui/src/gatewayTypes.ts into
apps/shared/src/billing-types.ts so the desktop app can share the same
gateway contract. gatewayTypes.ts re-exports every moved name from the
new @hermes/shared/billing subpath, so no ui-tui consumer changes.

The subpath export keeps DOM-less ui-tui from pulling the barrel (whose
WebSocket helpers need the DOM lib). ui-tui also now declares its
@hermes/shared dependency explicitly instead of relying on workspace
hoisting.

* test(cli): pin nous_billing wire-layer status-to-exception mapping

The HTTP layer's error handling had zero coverage through _request:
only 2xx parsing and request shaping were tested, and the mapping cases
in test_remote_spending_gate_contract.py hit _raise_for_error directly.

Adds 19 tests driving _request via a monkeypatched urlopen: the
401-refresh-retry path (success, terminal plain/session_revoked,
idempotency-key preservation, base re-resolution), 403 variants through
the wire, 429/503 retry-after, non-JSON error bodies, 404/502
fallbacks, and URLError normalization.

Two behaviors are pinned as findings rather than fixed: a JSON-body
retryAfter hint is ignored unless the Retry-After header is present,
and a bare socket.timeout propagates uncaught (real urllib wraps
timeouts in URLError before this layer).

* fix(tui_gateway): delete dead credits.view RPC

The handler assigns into an undefined `usage` variable, so any call
would raise NameError (the except swallows the first hit, then the
return re-raises it uncaught). Nothing can reach it: the TUI command
registry removed /credits (pinned by test_credits_command_fully_removed)
and no client sends the RPC. The live credit view is
agent/account_usage.py::build_credits_view via the remote gateway's
/topup command, which is untouched.

* fix(cli): normalize read-phase timeouts to the typed billing error

urlopen wraps connect-phase timeouts in URLError (already mapped to
network_error), but a timeout during resp.read() raises a bare
TimeoutError that escaped the typed-BillingError contract and reached
callers as an unhandled exception. Catch it narrowly and normalize.
The boundary test now asserts normalization instead of documenting the
leak.

* fix(shared): stop typing mutation success payloads as error payloads

BillingMutationResponse.payload was declared BillingErrorPayload, but on
ok:true the gateway passes through the raw NAS success body (rail,
changeType, cancelAtPeriodEnd, ...). The TUI never reads it so nothing
broke, but the shared contract now feeds the desktop app too — widen the
field deliberately and document both shapes.

* feat(shared): typed billing refusal and charge-failure unions

- BillingRefusalCode covers every code the gateway serializes today, with a
  (string & {}) arm so unknown future codes (the NAS W3 card-health family)
  stay assignable — consumers keep their unknown-code fallback.
- ChargeFailureReason models the four NAS terminal reasons plus the raw
  subscription_payment_intent_requires_action code NAS leaks pre-#711.
- billing.state now carries the server-derived can_change_plan the gateway
  emits; capability comments updated (canChangePlan is capability-based, not
  an OWNER/ADMIN role gate).

* docs(billing): client-side billing state and refusal lifecycle table

Enumerates, from the code, every billing.state shape and typed refusal the
gateway serves and the exact TUI copy + recovery each renders. Acceptance from
the billing-integration handoff: no NAS billing state or typed refusal falls
through to a generic toast; unknown codes still degrade to the default branch
that surfaces the server message.

* refactor(desktop): consume @hermes/shared billing types, full refusal copy, divergence notice

- billing/types.ts becomes a re-export shim over @hermes/shared/billing (keeps
  the desktop-only bounds field via a local BillingAutoReload extension);
  needs_repair is gone with the shared type.
- resolveRefusal gains specific copy for consent_required, org_access_denied,
  upgrade_cap_exceeded, stripe_unavailable (transient, honors retry_after) and
  processing_error; BillingErrorKind now IS the shared BillingRefusalCode.
  Default fallback unchanged.
- Auto-refill row surfaces the distinct-card divergence: caption naming the
  charging card (or 'a different card' when brand/last4 are null) and a
  Reconcile portal deep-link instead of the inline edit form.
- Fixtures/tests updated for the required auto_reload.card union; new
  auto-refill-divergent dev fixture.

* fix(desktop): auto-refill-divergent fixture must be enabled to exercise the divergence row

* refactor(billing): explicit BillingTransient trait, drop broken credits.view, public token-cache invalidation

- BillingRateLimited / BillingStripeUnavailable / BillingUpgradeCapExceeded
  become siblings under a new BillingTransient trait (deterministic non-charge
  outcome, safe to retry) instead of the false is-a chain that made a Stripe
  outage 'a kind of rate limiting'. Catch sites that meant 'any deterministic
  pre-charge transient' now say so explicitly; the gateway serializer
  dispatches on the trait and emits the preserved raw code.
- Delete the credits.view RPC handler left broken by the /topup rename (its
  body referenced an undefined variable; no caller remains).
- invalidate_cached_token() replaces the CLI's reach into the private
  _token_cache global after a billing step-up.

* refactor(cli): extract CLIBillingMixin; charge gates follow the server capability

- Move the ~1,400-line billing/subscription handler family out of cli.py into
  hermes_cli/cli_billing_mixin.py, following the existing HermesCLI mixin
  pattern (lazy cli imports, verbatim bodies).
- can_charge and the CLI billing-action gates now route through
  can_change_plan (server capability with legacy role fallback) instead of the
  deprecated 3-role is_admin — a FINANCE_ADMIN the server authorizes can now
  add funds, matching the plan-change path.
- Render the spend bar from the UsageBar model's fill_fraction instead of the
  deleted _billing_spend_bar re-derivation; fix a stale docstring.

* refactor(tui): promote useMenu to overlay primitives, type pendingTierId end-to-end

- useMenu (arrow/number/Enter/Esc menu hook) moves to overlayPrimitives with
  an onKey escape hatch; billingOverlay's Overview and Limit screens drop
  their verbatim copies. BuyScreen keeps its bespoke handler (typing mode +
  stale-selection clamp don't fit the shared contract cleanly).
- SubscriptionResult carries pendingTierId directly; the shadow
  SubscriptionResultWithPending interface and the ResultScreen cast are gone,
  so the apply-poll field is type-tracked through finish().

* docs(billing): correct the CLI-parity row — the CLI has the full in-terminal change flow

* refactor(shared): move terminal-billing wire types to @hermes/shared

The billing/subscription wire shapes (plus UsageBarData/UsageModelData,
which they reference) move verbatim from ui-tui/src/gatewayTypes.ts into
apps/shared/src/billing-types.ts so the desktop app can share the same
gateway contract. gatewayTypes.ts re-exports every moved name from the
new @hermes/shared/billing subpath, so no ui-tui consumer changes.

The subpath export keeps DOM-less ui-tui from pulling the barrel (whose
WebSocket helpers need the DOM lib). ui-tui also now declares its
@hermes/shared dependency explicitly instead of relying on workspace
hoisting.

* test(cli): pin nous_billing wire-layer status-to-exception mapping

The HTTP layer's error handling had zero coverage through _request:
only 2xx parsing and request shaping were tested, and the mapping cases
in test_remote_spending_gate_contract.py hit _raise_for_error directly.

Adds 19 tests driving _request via a monkeypatched urlopen: the
401-refresh-retry path (success, terminal plain/session_revoked,
idempotency-key preservation, base re-resolution), 403 variants through
the wire, 429/503 retry-after, non-JSON error bodies, 404/502
fallbacks, and URLError normalization.

Two behaviors are pinned as findings rather than fixed: a JSON-body
retryAfter hint is ignored unless the Retry-After header is present,
and a bare socket.timeout propagates uncaught (real urllib wraps
timeouts in URLError before this layer).

* fix(cli): normalize read-phase timeouts to the typed billing error

urlopen wraps connect-phase timeouts in URLError (already mapped to
network_error), but a timeout during resp.read() raises a bare
TimeoutError that escaped the typed-BillingError contract and reached
callers as an unhandled exception. Catch it narrowly and normalize.
The boundary test now asserts normalization instead of documenting the
leak.

* fix(shared): stop typing mutation success payloads as error payloads

BillingMutationResponse.payload was declared BillingErrorPayload, but on
ok:true the gateway passes through the raw NAS success body (rail,
changeType, cancelAtPeriodEnd, ...). The TUI never reads it so nothing
broke, but the shared contract now feeds the desktop app too — widen the
field deliberately and document both shapes.

* feat(shared): typed billing refusal and charge-failure unions

- BillingRefusalCode covers every code the gateway serializes today, with a
  (string & {}) arm so unknown future codes (the NAS W3 card-health family)
  stay assignable — consumers keep their unknown-code fallback.
- ChargeFailureReason models the four NAS terminal reasons plus the raw
  subscription_payment_intent_requires_action code NAS leaks pre-#711.
- billing.state now carries the server-derived can_change_plan the gateway
  emits; capability comments updated (canChangePlan is capability-based, not
  an OWNER/ADMIN role gate).

* feat(shared): closed Known* halves for the refusal and charge-failure unions

- KnownBillingRefusalCode / KnownChargeFailureReason are closed literal sets,
  so classification tables, copy maps and tests can be Record-exhaustive and
  break at compile time when a code is added but not mapped. The wire types
  keep the (string & {}) open arm for unknown future codes.
- Add network_error (client-originated transport code the gateway already
  serializes) to the known set.
- Export the union types from the root barrel alongside the other billing
  names.

* feat(shared): canonical billing refusal policy and charge-settlement driver

- billing-policy.ts: one exhaustive Record<KnownBillingRefusalCode,
  BillingRefusalPolicy> classifying every known code (recovery kind,
  mid-poll ambiguity, idempotency-key reuse) with a documented unknown-code
  fallback. Surfaces keep their own copy; the behavior classification now
  has a single home that breaks the build when a new code goes unmapped.
- charge-settlement.ts: the settlement poll state machine (2s cadence,
  5-minute cap, bounded retry-after backoff, ambiguous-on-revocation) as a
  pure dependency-injected driver returning a discriminated outcome.
- The TUI's pollCharge becomes a thin renderer over the shared driver —
  byte-identical output, and the desktop poller can now share the same
  machine instead of a drifting copy.

* fix(desktop): real auto-reload bounds, shared refusal policy and settlement driver

- Delete the phantom BillingAutoReload.bounds plumbing: nothing ever populated
  it, so the auto-reload amount validation it fed was silently dead. The
  editor and validators now enforce the gateway's real top-level
  min_usd/max_usd (new test pins the $10 minimum actually rejecting), and
  types.ts collapses to a plain re-export shim over @hermes/shared/billing.
- Delete the test-only BillingRpcResponse envelope family; BillingResult is
  the one response model.
- Refusal copy speaks desktop: reconnect/sign-in route to Settings → Gateway
  instead of the TUI's /portal command; the dead processing_error refusal
  case is gone (it is a charge-failure reason, already rendered by the
  poller).
- Adopt @hermes/shared billing-policy + charge-settlement: the poll loop is
  the shared driver, revocation-ambiguity comes from the policy table
  (insufficient_scope mid-poll now counts, per the ruling), and all
  policy-retry codes back off during polling instead of failing hard.
  errors.test.ts is Record-exhaustive over KnownBillingRefusalCode again.

* refactor(shared): move terminal-billing wire types to @hermes/shared

The billing/subscription wire shapes (plus UsageBarData/UsageModelData,
which they reference) move verbatim from ui-tui/src/gatewayTypes.ts into
apps/shared/src/billing-types.ts so the desktop app can share the same
gateway contract. gatewayTypes.ts re-exports every moved name from the
new @hermes/shared/billing subpath, so no ui-tui consumer changes.

The subpath export keeps DOM-less ui-tui from pulling the barrel (whose
WebSocket helpers need the DOM lib). ui-tui also now declares its
@hermes/shared dependency explicitly instead of relying on workspace
hoisting.

* test(cli): pin nous_billing wire-layer status-to-exception mapping

The HTTP layer's error handling had zero coverage through _request:
only 2xx parsing and request shaping were tested, and the mapping cases
in test_remote_spending_gate_contract.py hit _raise_for_error directly.

Adds 19 tests driving _request via a monkeypatched urlopen: the
401-refresh-retry path (success, terminal plain/session_revoked,
idempotency-key preservation, base re-resolution), 403 variants through
the wire, 429/503 retry-after, non-JSON error bodies, 404/502
fallbacks, and URLError normalization.

Two behaviors are pinned as findings rather than fixed: a JSON-body
retryAfter hint is ignored unless the Retry-After header is present,
and a bare socket.timeout propagates uncaught (real urllib wraps
timeouts in URLError before this layer).

* fix(cli): normalize read-phase timeouts to the typed billing error

urlopen wraps connect-phase timeouts in URLError (already mapped to
network_error), but a timeout during resp.read() raises a bare
TimeoutError that escaped the typed-BillingError contract and reached
callers as an unhandled exception. Catch it narrowly and normalize.
The boundary test now asserts normalization instead of documenting the
leak.

* fix(shared): stop typing mutation success payloads as error payloads

BillingMutationResponse.payload was declared BillingErrorPayload, but on
ok:true the gateway passes through the raw NAS success body (rail,
changeType, cancelAtPeriodEnd, ...). The TUI never reads it so nothing
broke, but the shared contract now feeds the desktop app too — widen the
field deliberately and document both shapes.

* feat(shared): typed billing refusal and charge-failure unions

- BillingRefusalCode covers every code the gateway serializes today, with a
  (string & {}) arm so unknown future codes (the NAS W3 card-health family)
  stay assignable — consumers keep their unknown-code fallback.
- ChargeFailureReason models the four NAS terminal reasons plus the raw
  subscription_payment_intent_requires_action code NAS leaks pre-#711.
- billing.state now carries the server-derived can_change_plan the gateway
  emits; capability comments updated (canChangePlan is capability-based, not
  an OWNER/ADMIN role gate).

* feat(shared): closed Known* halves for the refusal and charge-failure unions

- KnownBillingRefusalCode / KnownChargeFailureReason are closed literal sets,
  so classification tables, copy maps and tests can be Record-exhaustive and
  break at compile time when a code is added but not mapped. The wire types
  keep the (string & {}) open arm for unknown future codes.
- Add network_error (client-originated transport code the gateway already
  serializes) to the known set.
- Export the union types from the root barrel alongside the other billing
  names.

* feat(shared): canonical billing refusal policy and charge-settlement driver

- billing-policy.ts: one exhaustive Record<KnownBillingRefusalCode,
  BillingRefusalPolicy> classifying every known code (recovery kind,
  mid-poll ambiguity, idempotency-key reuse) with a documented unknown-code
  fallback. Surfaces keep their own copy; the behavior classification now
  has a single home that breaks the build when a new code goes unmapped.
- charge-settlement.ts: the settlement poll state machine (2s cadence,
  5-minute cap, bounded retry-after backoff, ambiguous-on-revocation) as a
  pure dependency-injected driver returning a discriminated outcome.
- The TUI's pollCharge becomes a thin renderer over the shared driver —
  byte-identical output, and the desktop poller can now share the same
  machine instead of a drifting copy.

* refactor(shared): move terminal-billing wire types to @hermes/shared

The billing/subscription wire shapes (plus UsageBarData/UsageModelData,
which they reference) move verbatim from ui-tui/src/gatewayTypes.ts into
apps/shared/src/billing-types.ts so the desktop app can share the same
gateway contract. gatewayTypes.ts re-exports every moved name from the
new @hermes/shared/billing subpath, so no ui-tui consumer changes.

The subpath export keeps DOM-less ui-tui from pulling the barrel (whose
WebSocket helpers need the DOM lib). ui-tui also now declares its
@hermes/shared dependency explicitly instead of relying on workspace
hoisting.

* test(cli): pin nous_billing wire-layer status-to-exception mapping

The HTTP layer's error handling had zero coverage through _request:
only 2xx parsing and request shaping were tested, and the mapping cases
in test_remote_spending_gate_contract.py hit _raise_for_error directly.

Adds 19 tests driving _request via a monkeypatched urlopen: the
401-refresh-retry path (success, terminal plain/session_revoked,
idempotency-key preservation, base re-resolution), 403 variants through
the wire, 429/503 retry-after, non-JSON error bodies, 404/502
fallbacks, and URLError normalization.

Two behaviors are pinned as findings rather than fixed: a JSON-body
retryAfter hint is ignored unless the Retry-After header is present,
and a bare socket.timeout propagates uncaught (real urllib wraps
timeouts in URLError before this layer).

* fix(cli): normalize read-phase timeouts to the typed billing error

urlopen wraps connect-phase timeouts in URLError (already mapped to
network_error), but a timeout during resp.read() raises a bare
TimeoutError that escaped the typed-BillingError contract and reached
callers as an unhandled exception. Catch it narrowly and normalize.
The boundary test now asserts normalization instead of documenting the
leak.

* fix(shared): stop typing mutation success payloads as error payloads

BillingMutationResponse.payload was declared BillingErrorPayload, but on
ok:true the gateway passes through the raw NAS success body (rail,
changeType, cancelAtPeriodEnd, ...). The TUI never reads it so nothing
broke, but the shared contract now feeds the desktop app too — widen the
field deliberately and document both shapes.

* feat(shared): typed billing refusal and charge-failure unions

- BillingRefusalCode covers every code the gateway serializes today, with a
  (string & {}) arm so unknown future codes (the NAS W3 card-health family)
  stay assignable — consumers keep their unknown-code fallback.
- ChargeFailureReason models the four NAS terminal reasons plus the raw
  subscription_payment_intent_requires_action code NAS leaks pre-#711.
- billing.state now carries the server-derived can_change_plan the gateway
  emits; capability comments updated (canChangePlan is capability-based, not
  an OWNER/ADMIN role gate).

* feat(shared): closed Known* halves for the refusal and charge-failure unions

- KnownBillingRefusalCode / KnownChargeFailureReason are closed literal sets,
  so classification tables, copy maps and tests can be Record-exhaustive and
  break at compile time when a code is added but not mapped. The wire types
  keep the (string & {}) open arm for unknown future codes.
- Add network_error (client-originated transport code the gateway already
  serializes) to the known set.
- Export the union types from the root barrel alongside the other billing
  names.

* feat(shared): canonical billing refusal policy and charge-settlement driver

- billing-policy.ts: one exhaustive Record<KnownBillingRefusalCode,
  BillingRefusalPolicy> classifying every known code (recovery kind,
  mid-poll ambiguity, idempotency-key reuse) with a documented unknown-code
  fallback. Surfaces keep their own copy; the behavior classification now
  has a single home that breaks the build when a new code goes unmapped.
- charge-settlement.ts: the settlement poll state machine (2s cadence,
  5-minute cap, bounded retry-after backoff, ambiguous-on-revocation) as a
  pure dependency-injected driver returning a discriminated outcome.
- The TUI's pollCharge becomes a thin renderer over the shared driver —
  byte-identical output, and the desktop poller can now share the same
  machine instead of a drifting copy.

* chore: retrigger CI with the current base SHA (stale base pin flagged a false CI-sensitive change)

* refactor(shared): move terminal-billing wire types to @hermes/shared

The billing/subscription wire shapes (plus UsageBarData/UsageModelData,
which they reference) move verbatim from ui-tui/src/gatewayTypes.ts into
apps/shared/src/billing-types.ts so the desktop app can share the same
gateway contract. gatewayTypes.ts re-exports every moved name from the
new @hermes/shared/billing subpath, so no ui-tui consumer changes.

The subpath export keeps DOM-less ui-tui from pulling the barrel (whose
WebSocket helpers need the DOM lib). ui-tui also now declares its
@hermes/shared dependency explicitly instead of relying on workspace
hoisting.

* test(cli): pin nous_billing wire-layer status-to-exception mapping

The HTTP layer's error handling had zero coverage through _request:
only 2xx parsing and request shaping were tested, and the mapping cases
in test_remote_spending_gate_contract.py hit _raise_for_error directly.

Adds 19 tests driving _request via a monkeypatched urlopen: the
401-refresh-retry path (success, terminal plain/session_revoked,
idempotency-key preservation, base re-resolution), 403 variants through
the wire, 429/503 retry-after, non-JSON error bodies, 404/502
fallbacks, and URLError normalization.

Two behaviors are pinned as findings rather than fixed: a JSON-body
retryAfter hint is ignored unless the Retry-After header is present,
and a bare socket.timeout propagates uncaught (real urllib wraps
timeouts in URLError before this layer).

* fix(cli): normalize read-phase timeouts to the typed billing error

urlopen wraps connect-phase timeouts in URLError (already mapped to
network_error), but a timeout during resp.read() raises a bare
TimeoutError that escaped the typed-BillingError contract and reached
callers as an unhandled exception. Catch it narrowly and normalize.
The boundary test now asserts normalization instead of documenting the
leak.

* …
jarvisxyz pushed a commit that referenced this pull request Aug 3, 2026
…view #6)

The fail-fast admission path (bounded compress pool, F6) only logged a
WARNING; in the compression-attempt telemetry stream a wedged pool
looked like compression simply stopped being attempted. Emit the
existing attempt telemetry with failure_class='pool_saturated'
(commit_status=aborted, split_status=aborted) on refusal, following
_emit_compression_attempt_telemetry's existing call shape. Regression
extends the F6 saturation test (sabotage-verified).
jarvisxyz pushed a commit that referenced this pull request Aug 21, 2026
… the relay (gateway half) (NousResearch#85796)

* feat(relay): live-card ops — native draft streaming + task cards over the relay (gateway half)

NS-658. Three additive ops within contract v1, emitted only when the
connector's negotiated descriptor advertises them:

  {op: draft, chat_id, draft_id, content, final, metadata}
  {op: task_card, chat_id, card_id, chunks, metadata}
  {op: task_card_stop, chat_id, card_id, metadata}

The gateway side is deliberately dumb: no platform API knowledge, no new
config keys. Slack mechanics (chat.startStream/appendStream/stopStream,
per-workspace feature-gate cache, send+edit fallback) live connector-side
where the platform adapter lives in the relay model.

Semantic bridge: base send_draft is Telegram-shaped (draft clears; final
is a separate send). Slack native streaming makes the stream THE message.
The adapter tracks the open draft per chat and converts the turn-final
send() into draft(final=true) so the connector seals the stream instead
of posting a duplicate; the stream ts returns as the message identity.
A failed frame disarms interception so the edit-based fallback's real
send goes through untouched.

BEHAVIOR CHANGE (deliberate): relay supports_draft_streaming() now
requires the descriptor flag AND the draft op. Flag-only was a latent
lie — send_draft inherited NotImplementedError, so a connector setting
the flag without the op would have crashed the stream consumer's draft
path. supported_ops stays fail-open for legacy (pre-contract) ops;
draft/task_card did not exist pre-contract and must not fail open.

Task cards ride NousResearch#85476's adapter-agnostic TurnRunner seam (hasattr on
send_native_task_card_progress); supports_native_task_cards() is the
descriptor probe. Connector half + E2E harness pair follow in the gg
repo.

* fix(relay): expose native_task_cards_enabled() on the relay adapter

Live-canary finding (Alice, staging): the TurnRunner's task-card lane
probes adapter.native_task_cards_enabled() (the native Slack adapter's
opt-in contract). The relay adapter only offered
supports_native_task_cards(), so the hasattr gate failed silently and
tool progress stayed on the text path — draft streaming worked, cards
never rendered. Alias it to the descriptor probe.

* fix(relay): match task-card methods to the TurnRunner's native keyword contract

Live-canary finding #2 (Alice, staging): gateway/run.py's card lane calls
send/stop_native_task_card_progress with the NATIVE Slack adapter's
signature (tasks/title/reply_to/metadata/fallback_text, keyword-only) —
PR 85796's relay methods took a positional card_id, so every call raised
TypeError('unexpected keyword argument reply_to') in the progress task,
repeatedly killing the card publisher (and the retry loop resent the
final delivery 4-5x). Card id now derives per turn thread
(turn:<reply_to>), thread_ts anchored like draft; title/fallback_text
accepted for parity, not forwarded (plan-mode stream renders chunks).

* fix(relay): one draft stream per turn for stream-is-the-message adapters

Live-canary finding #4 (Alice, staging): the stream consumer bumps
draft_id at every tool boundary so Telegram-shaped drafts animate each
text segment as a fresh preview. On relay Slack NATIVE streaming a new
draft_id opens a brand-new chat.startStream — the user saw one frozen
message per segment (stuck streaming cursor ▉, never sealed: only the
LAST stream gets the final=true seal) plus the real final; 5-6 cumulative
snapshots per turn. Adapters that mark draft_stream_is_message keep ONE
stream per turn: tool progress lives in the native task card, and the
connector's suffix-delta falls back to whole-text append on prefix
mismatch, so segments append cleanly. Telegram-shaped drafts keep the
per-segment bump.

* fix(relay): don't seal the native stream at tool boundaries — only the turn-final does

Live-canary finding #5 (Alice; supersedes the incomplete #4 which was
necessary but not sufficient). Root cause CONFIRMED by integration trace
(test_live_cards_flow_trace.py, real consumer semantics + real adapter +
stub transport): at every tool boundary the consumer calls
_send_or_edit(finalize=True), which skips the draft path and issues a
real send(); the relay adapter's seal-interception converts THAT into
draft(final=true) — sealing the stream once per segment. Timeline showed
3 seals for a 3-segment turn: exactly the frozen cumulative ▉ snapshots
seen live (the replaced stream never gets stopStream, keeping its cursor).

Fix: for draft_stream_is_message adapters, a segment-break finalize
(finalize=True, is_turn_final=False) stays ON the draft path as another
cumulative frame; only got_done (is_turn_final=True) falls through to
send() and seals. Telegram-shaped platforms unchanged. Trace test now
pins the invariant: ONE user-visible message per turn.

* fix(relay): strip the text cursor from native draft frames

Live-canary finding #6 (Alice) — the ACTUAL duplicate-content mechanism,
confirmed by full-flow scan of both sides' code + logs. The consumer
appends its text cursor (▉) to every non-final display_text tick. The
connector's stream sender diffs CUMULATIVE frames via prefix check:
'abc▉'.startsWith → 'abc def▉' is NEVER a prefix match (the cursor sits
mid-string), so deltaFor falls back to whole-text append on EVERY tick —
chat.appendStream stacks each full cumulative snapshot (cursor included)
into the ONE stream message. Exactly the observed thread: repeated
blocks, each ending in a frozen ▉, growing per tick.

Fixes #4/#5 were real (one stream per turn now) but this was the last
mechanism standing. Native streams render their own typing indicator, so
the text cursor is pure noise on this path: strip it from draft frames.
Prefix check now holds; every tick appends only its true suffix delta.

* fix(relay): seal-interception covers EVERY egress door, not just send()

Live-canary finding #7 (Alice): one duplication remained after #6 — the
stream froze mid-word with the live indicator (never sealed) and the
final posted as a separate message. Log receipt: 'Queued follow-up:
final text delivery confirmed; delivering explicit media before
continuing' — the turn's final went out via the DELIVERY RESOLVER lane
(gateway/delivery.py), which calls send_for_platform() DIRECTLY,
bypassing send() and its seal-interception. The open stream never
absorbed the final; it arrived as a plain 'send' op → chat.postMessage.

Fix: hoist the open-draft check to the top of send() (ahead of the
explicit-platform branch) AND add it to send_for_platform() — an open
native stream absorbs the turn-final regardless of which egress door it
arrives through. The stream IS the message.

* fix(relay): failed seal falls back to plain send (PR 85796 AI-review point 1)

A turn-final seal that fails at the transport must never swallow the
final answer: the stream consumer has already disabled the draft
transport for the run, so a failed _seal_open_draft returning
success=False meant the user got NOTHING. Both seal-interception sites
(send + send_for_platform) now fall through to the regular plain-send
path on seal failure, with a warning receipt. Also mitigates AI-review
point 2 (sticky _open_draft_by_chat after an abandoned turn): a stale
entry's failed seal no longer blocks the next turn's delivery.

* fix(relay): arm seal-interception optimistically; never disarm on ambiguous failure (audit G-D1)

Deep-audit defect G-D1 (HIGH): the outbound leg is at-most-once on the
wire but its ack channel is lossy — send_outbound timeout (30s) and
WS-drop 'failures' frequently mean the frame WAS delivered and the
connector stream is open. send_draft popped _open_draft_by_chat on any
failure, disarming seal-interception while the connector stream lived:
the turn-final went out as a plain send → orphaned mid-word stream +
complete duplicate final (intermittent; needs a drop/timeout inside the
draft window).

Fix: arm the entry BEFORE the transport call and keep it armed on
failure/exception. Safe in every case: sealing a non-existent stream
opens+seals a single complete message connector-side, and a truly failed
seal already falls back to plain send at both interception sites.
Stale-entry damage is self-healing (one warning + plain send).

* fix(relay): gateway-side sealed-draft tombstone — G-D1 arming must not resurrect sealed streams

Regression fix on G-D1 (live: 'worse than before' — escalating frozen
prefixes). Optimistic arming had no seal-awareness: a straggler frame
arriving AFTER the seal re-armed _open_draft_by_chat for the already-
sealed draft_id; the next send was converted to draft(final=true) on the
tombstoned connector key, which CLEARED the connector tombstone (final
frame = new-turn signal), re-opened a stream with cumulative content,
and left it frozen — repeating per straggler: 4-5 escalating frozen
snapshots. Mirror the connector: _sealed_draft_by_chat records the
sealed draft_id per chat (tombstoned BEFORE the seal's transport call);
send_draft for a sealed draft_id is a success no-op (content already in
the sealed message) and never arms. A new turn's fresh draft_id arms
normally.

* fix(relay): key stream/card state per (chat, turn anchor) — parallel turns must not collide (finding #10)

Live finding #10 (Alice; three concurrent turns in one flat DM): all
coordination state was keyed per CHAT on a one-active-turn assumption.
Three parallel turns produced: turn B's task card merged into turn A's
(both were card 'turn:root' — reply_to is None in flat DMs), B left
cardless, and _open/_sealed_draft_by_chat clobbered across writers (3x
duplicate finals on the last turn). Per-turn machinery was correct;
the keys were not.

Fix: _draft_key(chat, metadata) = chat + the turn's thread anchor
(inbound stamps thread_ts = event.thread_ts or ts on every top-level
message, so each turn has one even in flat DMs). draft arming, seal
tombstones, both interception sites, and the task-card id all derive
from the same anchor. New trace test pins two interleaved turns:
distinct cards, own-stream seals, no leaked plain send, no cross-turn
tombstone drops (289 tests green).

* fix(gateway): preserve cumulative native stream across tools

* fix(gateway): consumer-declared final — the seal carries the true final

Three composed fixes for the Slack live-cards duplicate-final class:

1. finish(final_text): TurnRunner passes the completed final_response
   (verifier footer, completion explainer included) as the authoritative
   finalize payload. The native-stream seal delivers the TRUE final, so
   post-stream mutation no longer forks a corrective plain send (#11).

2. Interim-send contract: commentary and segment-tail sends carry a
   gateway-internal _interim_send marker; relay seal-interception skips
   them at both egress doors. A mid-turn interim send can no longer seal
   the live stream and orphan the real final into a duplicate.

3. Queued-follow-up lane reconciles an unconfirmed final by EDITING the
   consumer's delivered message in place (sealed stream = regular
   message, chat.update live-verified); plain send only as fallback.
   This was the actual duplicate lane in the parallel canaries — every
   duplicated turn logged 'final stream delivery not confirmed; sending
   first response' (subagent-completion queued inbound), not parallelism.

Also: draft frames stay prefix-stable gateway-side (no fence-closing, no
segment state reset, no commentary reset for stream-is-the-message
adapters; MagicMock-safe 'is True' guards).

* test+docs: streaming-contract coverage completeness + maintenance guidelines

Coverage: two gaps closed on the consumer-declared-final contract —
(1) send_for_platform (the delivery-resolver egress door) honors the
_interim_send contract: no seal, marker stripped before the wire;
(2) finish(final_text) on a turn that never streamed does not adopt the
final (delivery ownership stays with the gateway's normal send path for
non-streaming models / tool-only turns).

Docs: AGENTS.md 'Known Pitfalls' gains the streaming delivery contract —
the four invariants of stream-is-the-message adapters (prefix-stable
frames, consumer-declared final, interim-send marker, reconcile-by-edit),
each traced to its live incident, plus the live-probed Slack streaming
API ground truth and the MagicMock 'is True' guard-style note.

* fix(relay): seal transport failure must never silently lose the final (review B1)

Two halves of one silent-loss path, live-probed on the review branch:

1. adapter: _seal_open_draft did not catch transport exceptions. A socket
   drop at seal time raised out of send(), skipping the fail-open plain
   send entirely. Now: retry the SAME idempotent final frame once (the
   connector's sealed-key tombstone returns the original stream ts for a
   repeated final — a retry can never open a second stream or duplicate),
   then report failure so the caller's fail-open path runs.

2. consumer: the turn-final retry (elif not _already_sent) called
   _send_or_edit with finalize=False, which re-entered the DRAFT-FRAME
   branch. Its no-op dedupe compared the adopted final against the last
   unsealed frame, matched, and returned True with ZERO transport calls —
   final_response_sent went green, delivered_final_matches reconciled,
   the gateway suppressed its fallback, and the user never received the
   answer. finalize=True keeps this retry out of the draft branch.

Regression suite: tests/gateway/test_relay_seal_failure.py (3 tests).
Mutation evidence in follow-up verification: reverting either half sends
the suite red.

* fix(relay): draft ids unique across gateway incarnations (review B3)

The relay connector tombstones sealed streams by (channel, draft_id) and
keeps up to 512 of them; they outlive the gateway process. Relay gateways
are disposable BY DESIGN (scale-to-zero), and _draft_id_counter restarted
at zero every incarnation — so the first turns after every scale-from-zero
in a recently-active channel replayed already-sealed wire identities. The
connector answered those frames straight out of the old tombstone: zero
Slack API calls, the OLD message ts returned as the new turn's identity,
the new answer silently dropped while gateway-side flags recorded success.

Seed the counter from wall-clock milliseconds at process start. Ids stay
plain ints within the existing contract op; incarnations cannot overlap
for realistic turn counts and restart gaps.

Regression: tests/gateway/test_draft_id_restart_uniqueness.py — the seed
test fails on the old code (seed 0 is not epoch-scale).

* fix(relay): stream/card state keyed per TURN, not per thread anchor (review B2)

The thread anchor is the wrong coordination identity — simultaneously:

- too coarse: two parallel turns replying INSIDE ONE Slack thread share
  thread_ts. Live-probed on the review branch: turn A's final sealed turn
  B's stream with A's content while A's own stream stayed open, and B's
  final degraded to a plain send.
- too fragile: a flat DM with no thread metadata degraded to the bare
  chat id, re-creating the original finding-#10 collision the anchor was
  meant to fix.

_draft_key now prefers the triggering inbound message id (message_id /
reply_to_message_id — per-turn by construction; the gateway's Slack
thread metadata and the consumer's send path both stamp it), falling back
to the thread anchor, then the bare chat. The consumer stamps the same
reply_to_message_id on draft frames so frames and the turn-final resolve
to one key. Task-card ids share the derivation via _card_key (one helper
for send AND stop, so the stop always hits the stream the send opened).

Legacy resolver-lane callers with placement-only metadata still seal via
_match_open_draft's fallback — but ONLY when exactly one stream is open.
With several open, an identity-less send stays a plain send: a duplicate
message is recoverable, sealing someone else's stream is not.

Regression: tests/gateway/relay/test_relay_turn_keying.py (7 tests).

* fix(relay): stream-is-the-message is a Slack semantic, gate it on the descriptor (review B4)

draft_stream_is_message was hardcoded True on the relay adapter class,
i.e. for EVERY relay platform. The base send_draft contract is
Telegram-shaped — the draft clears client-side and the final arrives as
a separate real send that becomes the history message. With the flag
forced on, any non-Slack connector advertising the draft op had its
turn-final intercepted into draft(final=true): probed on the review
branch with a telegram descriptor, the op stream was
[draft(final=false), draft(final=true)] and NO send — no history message
would ever be posted.

Gate the flag on the negotiated descriptor platform (slack), and skip
arming seal-interception entirely when it is off. A future platform with
genuine stream-is-the-message native streaming should advertise it via
the descriptor rather than widening the platform check by guesswork.

Regression: tests/gateway/relay/test_relay_stream_semantics_gating.py
(4 tests: gating both ways, telegram final is a real send, slack final
still seals).

* fix(gateway): mark every mid-turn status lane interim — heartbeats must not seal the stream (review B5)

Seal-interception treats the first unmarked send to an armed (chat, turn)
key as the turn-final. The consumer's own interim lanes (commentary, tail
flush) carry _interim_send, but four gateway-side lanes that fire DURING
a streaming turn did not:

- long-running heartbeat (default every 180s — probed live: at 3 minutes
  it sealed the live stream with '⏳ Working — 3 min', the real final
  posted as a duplicate, and later frames were silently swallowed by the
  seal tombstone)
- inactivity warning
- plain-text approval fallback (button lane failed)
- background-review notice

Add _interim_metadata() beside _non_conversational_metadata and wrap all
four call sites. The marker is gateway-internal; the relay adapter strips
it before the wire (existing behavior, pinned by test).

Note for follow-up: the opt-out shape remains fragile — any FUTURE
unmarked mid-turn send lane re-creates this bug. Inverting the contract
(explicitly mark the one turn-final send) is the durable fix but touches
every adapter's final-delivery path; deliberately kept out of this
review-fix series.

Regression: tests/gateway/test_interim_send_lanes.py (4 tests).

* fix(gateway): interrupted/incomplete turns must not adopt the diagnostic as the stream final (review B6)

The finish(final_text) adoption gate checked only 'not failed', but the
interrupt/abort returns in agent/conversation_loop.py are
{completed: False, interrupted: True, final_response: 'Operation
interrupted during …'} with NO failed key. Adopting that diagnostic:

1. sealed the user's streamed partial answer over with the interrupt
   text (stream-is-the-message: the seal rewrites the whole message), and
2. recorded the diagnostic as the turn-final payload, so
   delivered_final_matches reconciled and the gateway suppressed its own
   error-delivery path — the diagnostic became the ONLY thing delivered.

Enumerated all 27 final_response-bearing return shapes in
conversation_loop.py: every non-happy-path shape carries completed:
False (several with a diagnostic final_response and neither failed nor
interrupted — retry exhaustion, truncation, codex-incomplete); the happy
path routes through turn_finalizer.finalize_turn (completed=True). Gate
is therefore: not failed AND not interrupted AND completed is not False.
Results lacking the completed key entirely (older callers/test doubles)
keep the previous behavior.

Regression: tests/gateway/test_stream_final_adoption_gate.py (6 tests,
incl. a source-level pin on the run.py call site).

* fix(relay): task-card transport failures degrade to failed SendResults (review B7)

send_native_task_card_progress and stop_native_task_card_progress let
transport exceptions escape. The stop runs inside the progress loop's
finally block on the turn-cleanup path, and the post-cancel awaits in
gateway/run.py caught only CancelledError — a socket drop during a card
publish/stop therefore aborted cleanup BEFORE the final-delivery
bookkeeping ran.

Three layers, outermost defends any adapter:
- both adapter methods catch transport exceptions and return failed
  SendResults (progress is advisory; the TurnRunner's text fallback
  already handles failure results)
- the progress loop's finally wraps the stop (best-effort; the connector
  seals orphaned card streams on its own via recycling/eviction)
- the cleanup awaits log-and-continue on non-cancellation errors so
  final-delivery bookkeeping always runs

Regression: tests/gateway/relay/test_relay_task_card_failures.py.

* fix(relay): a dying turn seals its native stream instead of orphaning it (review B8)

Stale-generation exits (/new, /stop mid-stream) and cancellations
returned from the consumer's run() with the native stream still open:

- the Slack message kept its live streaming indicator forever (the
  cancellation best-effort edit only runs when _message_id exists, and
  the native draft path deliberately keeps it None);
- the adapter's armed interception state survived the turn, so the next
  turn on the same key could inherit it and seal a dead draft_id.

New adapter op abandon_open_draft(chat, content): seals in place with
the text already on screen (the consumer passes its last delivered
frame) — the seal adds nothing and claims nothing; delivery flags are
never set, so the gateway's normal paths still own whatever happens
next. Best-effort by contract (failure reported, never raised); the
connector reaps truly orphaned streams via recycling/eviction.

The consumer calls it from both death paths: the stale-generation early
return and the CancelledError handler.

Regression: tests/gateway/test_stream_abandon_on_turn_death.py (4 tests,
incl. the next-turn-inheritance hazard).

* fix(relay): bound the draft/seal coordination dicts (review M1)

_sealed_draft_by_chat's key embeds a per-turn identity, so every
completed turn wrote a permanent entry — unbounded growth for the life
of a long-running gateway process (the docstring said 'one entry per
chat', which stopped being true when the key gained the turn anchor).
_open_draft_by_chat could grow the same way via abandoned entries.

FIFO-evict both at 512 entries — the same idiom as the sibling bounded
cache (_auto_thread_by_chat, capped at 256) and the same size as the
connector's own tombstone store. The straggler window the tombstone
exists for is seconds long; FIFO is more than enough.

Regression: tests/gateway/relay/test_relay_state_bounds.py.

* fix(relay): explicit connector rejection disarms interception; exceptions stay armed (review P3)

The G-D1 optimistic-arming change silently dropped disarm-on-failure
entirely: after an EXPLICIT connector rejection (success=False result —
not a transport ambiguity), interception stayed armed even though the
stream consumer disables the draft transport on that failure and falls
back to edit-based streaming. Its turn-final would then be converted
into a seal on a stream the connector just told us is unusable.
test_draft_failure_result_propagates claimed to cover this ('must NOT
leave seal-interception armed') but passed for an unrelated reason: the
stub's canned failure also failed the SEAL, whose fail-open path did the
plain send.

Split the two semantics and pin each honestly:
- explicit rejection (result success=False): disarm — turn-final is a
  real send (test_draft_failure_result_propagates, now testing what its
  comment says)
- transport exception: ambiguous, stay armed — turn-final still seals
  (test_draft_transport_exception_keeps_interception_armed, the G-D1
  contract)

Also corrects commit ba3a24a's claim ('a failed frame disarms
interception so the edit-based fallback's real send goes through
untouched') to hold again for the rejection case it described.

* fix(relay): lost acks are ambiguous, not rejections — on the RESULT channel too (review r2, finding 1)

The production ws transport does not raise on ack timeout — it returns
{"success": False, "error": "relay outbound timed out"}. The round-1
ambiguity handling keyed entirely on the exception channel, so the shape
production actually produces was misclassified as a definite connector
rejection. Probed on the head:

- lost SEAL ack: skipped the idempotent retry, fell straight to a plain
  send — duplicate final whenever the seal had actually applied;
- lost FRAME ack: the round-1 disarm-on-rejection fired — interception
  disarmed, frozen native stream beside a plain final. This re-created
  the original G-D1 ambiguous-ack defect on the result channel.

Contract now spans both channels:

- transport: the ack-timeout branch tags ambiguous=True. The fail-fast
  branches (closing / not connected) never sent anything and stay
  unmarked — they are definite non-delivery.
- adapter frame path: ambiguous results keep interception armed (same
  as exceptions); only definite rejections disarm.
- adapter seal path: one shared _attempt() classifier — exception and
  ambiguous result both mean "unknown"; the SAME idempotent frame is
  retried once (connector tombstone returns the original stream ts for
  a repeated final). Only after both attempts stay ambiguous does the
  caller's fail-open plain send run: a possible duplicate after double
  ack loss beats a silent loss, and double ack loss on one socket
  almost always means the transport is down for the plain send too.

Regression: tests/gateway/relay/test_relay_ack_ambiguity.py (6 tests,
incl. a source-of-truth check that the transport tags the timeout branch
and leaves fail-fast branches unmarked).

* fix(relay): stream semantics + draft capability resolve per CHAT, not per primary (review r2, finding 2)

One RelayAdapter fronts N platforms (Phase 1.5): descriptors accumulate
per platform on the transport and egress is tagged per chat — but the
round-1 gate keyed draft_stream_is_message and supports_draft_streaming()
off the PRIMARY scalar descriptor. Probed on the head:

- Slack primary + Telegram chat: the Telegram chat's turn-final was
  intercepted into draft(final=true) — no real Telegram history message;
- Telegram primary + Slack chat: the Slack chat was denied native
  streaming entirely.

Resolve both through _descriptor_for_chat — the same per-chat machinery
max_message_length already uses (added for the identical class of bug:
the primary's 39000-char cap over-sending into Discord 400s):

- new stream_is_message_for_chat(chat_id) on the adapter; arming and
  NotImplementedError gating use it. The class attribute remains as the
  single-platform value and legacy-probe fallback.
- supports_draft_streaming() gains an optional chat_id kwarg (base
  signature updated; single-platform adapters ignore it). The consumer
  passes chat_id with a TypeError fallback for out-of-tree adapters.
- the consumer's four draft_stream_is_message reads collapse into one
  _stream_is_message() helper that prefers the per-chat probe
  (class-resolved, MagicMock-safe) over the attribute.

Platform-name inference ("slack") stays deliberate: a descriptor-level
semantic field is the right eventual contract but is a cross-repo wire
change — noted for the gg follow-up so future platforms advertise the
semantic explicitly.

Regression: tests/gateway/relay/test_relay_multiplatform_semantics.py
(5 tests: both starvation directions, scalar fallback, per-chat
capability gate).

* fix(gateway): split delivery + authoritative footer reconciles by suffix, not full resend (review r2, finding 3)

The _FINAL_TEXT adoption guard refuses wholesale adoption on split turns
— correct (NousResearch#78541: sealed heads would repeat inside the tail) but it was
absolute: a post-split verifier footer never entered the ledger,
delivered_final_matches() reported a mismatch, and the gateway resent
the ENTIRE body+footer after the split chunks (the #11 duplicate class,
one level up).

When the authoritative final strictly prefix-extends the split ledger,
the missing suffix is the only undelivered content: append it to the
live tail and the ledger, so the finalize carries it and the recorded
payload reconciles. Non-prefix rewrites keep the full-resend fallback —
a rewrite cannot be patched onto sealed heads.

Regression: tests/gateway/test_split_final_suffix_reconcile.py (3 tests:
suffix rides the tail + reconciles, rewrite still mismatches, unsplit
adoption unchanged).

* fix(relay): cancellation mid-seal restores open state so abandon can close the stream (review r2, finding 4)

_seal_open_draft pops the open entry and writes the local tombstone
BEFORE awaiting transport I/O — correct ordering for the straggler race,
but CancelledError is not an Exception: a cancel during the await
bypassed all failure handling, leaving the remote stream live (visible
streaming indicator until connector eviction) while the local state said
'nothing open'. The consumer's abandon pass — added for exactly this
turn-death case — found nothing to close and no-oped.

On CancelledError: restore the open entry, drop the premature tombstone
(only if it is still ours), re-raise. The abandon path then seals the
stream in place with the on-screen text.

Regression: tests/gateway/relay/test_relay_seal_cancellation.py (2
tests: state restoration, and end-to-end cancel→abandon→remote seal).

* fix(relay): thread anchors are placement, not turn identity — revive the placement-only fallback (review r2, finding 5)

_match_open_draft's single-open-stream fallback was dead for its primary
intended callers: metadata carrying thread_ts/thread_id (placement-only
resolver lanes) was classified as having 'turn identity', so those sends
never reached the fallback — probed: a plain final posted beside the
still-open turn-keyed stream.

Only per-turn MESSAGE ids are identity now. Thread-anchored and bare
callers share the fallback: absorb into the chat's open stream when
EXACTLY one is open; stay a plain send when several are (duplicate is
recoverable, wrong-stream seal is not). Callers WITH a message id whose
key misses never fall back — their identity is authoritative and a miss
means the stream belongs to a different turn.

Regression: 4 new tests in test_relay_turn_keying.py (thread-anchored
seal, both ambiguous-stay-plain shapes, id-mismatch never steals).

* fix(relay): random process nonce for draft-id seeding (review r2, follow-up 6)

The epoch-millisecond seed (round-1 B3 fix) mitigates the restart-replay
class but is not a uniqueness guarantee: two gateways starting in the
same millisecond, a forked process inheriting the class state, or a
clock step backwards can all mint colliding wire identities against the
connector's per-(channel, draft_id) tombstone store.

Seed from secrets.randbits(49) instead: collision probability negligible,
no clock dependence, and ids + realistic per-process turn counts stay
comfortably inside the connector's JS number range (draft_id?: number,
2^53). Regression test now spawns two real interpreters and asserts
their seeds differ — the exact scale-to-zero restart shape, and both
start within the same second so a clock-locked seed would fail it.

* fix(relay): stamp per-turn Slack egress identity — cache is fallback only (R3-5)

The connector (gateway-gateway#210) fills chat.startStream's
recipient_user_id / recipient_team_id — required by Slack when
streaming to a channel — from metadata.user_id / metadata.scope_id.
The gateway stamped only slack_team_id per-turn and left user_id (and
scope_id) to RelayAdapter._with_scope, whose per-chat caches are keyed
on chat_id alone and overwritten by every inbound message: with users
U1 and U2 running overlapping turns in one channel, U2's arrival
overwrote the cache before U1's stream opened, and U1's stream carried
U2 as recipient_user_id.

_thread_metadata_for_source now stamps scope_id and user_id from the
turn's OWN source (setdefault — explicit values win), so identity is
turn-scoped data on the wire. _with_scope is unchanged and fill-only:
the caches keep serving restart/synthetic sends that carry no per-turn
identity, which is all they were ever safe for.

Mutation evidence: reverting the run.py hunk sends
test_thread_metadata_stamps_per_turn_user_and_scope and
test_concurrent_turns_carry_their_own_identity red; restore returns
green. The _with_scope fill-only tests pass on both trees (existing
correct behavior, now pinned against regression).

---------

Co-authored-by: Ben Barclay <ben@nousresearch.com>
jarvisxyz pushed a commit that referenced this pull request Aug 24, 2026
… loop

When the Python interpreter begins teardown (user closes hermes, SIGTERM,
OOM-kill), every executor-backed operation raises 'cannot schedule new
futures after interpreter shutdown'. The outer except handler in
run_conversation caught this error but did not recognize it as fatal —
it kept retrying (API calls #4, #5, #6) until max_iterations, each time
hitting the same dead executor and printing another traceback.

The fix adds an early check: if sys.is_finalizing() or the error matches
the 'cannot schedule new futures' pattern, break immediately with a clean
interpreter_shutdown exit reason instead of retrying. The codebase already
had this pattern in cron/scheduler.py and agent/tool_executor.py — the
conversation loop just wasn't using it.
jarvisxyz pushed a commit that referenced this pull request Aug 24, 2026
…he shell

When the TUI exits while the post-turn background review fork is still
mid-request, every further API attempt raises 'cannot schedule new
futures after interpreter shutdown'. The conversation loop treated this
as a retryable API error: un-gated ❌ prints leaked onto the user's
shell AFTER the TUI exited (call #4, #5, #6...) and the loop retried a
doomed request until the interpreter froze the thread.

Fix the class, not the site:
- tools/interpreter_shutdown.py: single shared shutdown predicate
  (matches both CPython message variants + sys.is_finalizing()).
- cron/scheduler.py, agent/tool_executor.py: existing per-site
  predicates now delegate to the shared home (tool_executor previously
  matched only the fuller variant).
- agent/conversation_loop.py: inner retry handler recognizes the
  shutdown signal and abandons the turn — one log warning, no print,
  no traceback, no debug dump, no retry; outer handler gets the same
  guard for shutdown errors raised outside the API call.
- The outer handler's bare print() now honors suppress_status_output
  (set by the background-review fork) instead of bypassing it.

Refs NousResearch#55924 NousResearch#58720 (same class in cron delivery), adjacent to NousResearch#90683.
jarvisxyz pushed a commit that referenced this pull request Sep 8, 2026
…s (P5) (NousResearch#99220)

* fix(relay): authorize send_message targets and surface egress declines

P5 of the relay egress-authorization workstream. The relay path
authenticated the SENDER but never authorized the DESTINATION, and the
gateway compounded it from both ends.

(a) send_message could silently name an arbitrary relay target. Its
`target` parameter is free-form ('platform:chat_id'), so a model could
name ANY chat id and the gateway would emit an outbound frame for it.
gateway/relay/egress.py adds an attestation floor: a relay-routed
destination must have a provenance this gateway can show -- the
operator's home channel, the channel directory, or its own gateway
session origins. Anything else is refused HERE, with a visible tool
error naming the target, before a frame is written. Non-relay platforms
and platforms served by a live native adapter in this process are
untouched (same precedence resolve_delivery_transport applies).

(b) Connector declines were swallowed into apparent successes. The
connector's egress floor answers an unauthorized destination with a
DEFINITE failure whose text is deliberately uniform (F-005). Several
relay lanes degrade a *transport drop* by design and were degrading an
*authorization refusal* the same way:

  - _send_media returned None, sending the caller into
    BasePlatformAdapter's text fallback -- a DIFFERENT op re-addressed at
    the very chat the connector had just refused.
  - _send_prompt returned None, so exec-approval / slash-confirm /
    clarify reported "relay prompt op unavailable" (a wrong reason) and
    ran their numbered-text fallbacks into the refused chat.
  - task_card_stop discarded the error entirely.
  - typing / delete / react / thread ops degraded silently at debug.

is_egress_decline() classifies THAT a decline happened (never why --
the uniform text is not parsed for reasons) and requires a definite,
non-ambiguous failure, so a lost-ack retry is still a transport
outcome. Lanes with an error-carrying contract now report the decline
verbatim; cosmetic bool/None lanes still degrade but log it at WARNING.

Advisory progress drops that legitimately degrade are unchanged: the
task_card send lane, the draft ambiguous/except branches, and every
transport-exception path keep their existing fail-open behaviour.

Tests: 21 mutations of the production source, all KILLED.

* fix(relay): authorize the RESOLVED target; declines must not fall back

Review round 1 (independently confirmed by a second reviewer) found three
blockers. Two are fixed here; the third (B-2, Telegram @username) is a policy
decision left open deliberately.

B-1 — THE FIX CAUSED THE OUTAGE IT PREVENTED (tools/send_message_tool.py)

The P5(a) guard ran ABOVE Slack user->DM resolution, so it authorized the
internal pseudo-id `_parse_target_ref` emits (`user_name:ben`, `user:U...`).
Provenances only ever hold RESOLVED conversation ids, so a fully attested DM
was compared as a handle against a set of `D...` ids and refused:

  base  slack:@ben  SENT        head(before)  slack:@ben  REFUSED

Every Slack DM by handle was broken. Moved the guard below resolution; it now
authorizes the destination that is actually sent to, and the refusal names the
resolved id. Position is load-bearing, so it is commented as such and pinned:
reverting the move turns exactly the four new cases red.

B-3 — A DECLINE IS NOT A LANE FAILURE (gateway/run.py)

`_approval_send_outcome` had only sent/failed/ambiguous, so a connector
decline collapsed into `failed` — which is the cue to run the plain-text
fallback into the chat the connector had just refused. The adapter fix in the
previous commit improved the error STRING while user-visible behaviour stayed
identical to base; the commit message overstated it. Fixed properly:

  - new `declined` verdict, recognised via the shared `is_egress_decline`
    contract (not string sniffing at the call site)
  - exec-approval returns without the text fallback
  - slash-confirm suppresses the text reply AND clears the registration, so a
    card that never rendered cannot capture the user's next message

`send_clarify` was already correct (returns early inside the adapter).

MUTATIONS (production source; both directions)
  classifier never returns 'declined'        -> KILLED (4 cases)
  ALL failures classified as 'declined'      -> KILLED (2 cases)
  guard moved back above Slack resolution    -> KILLED (4 cases)
  decline CODE changed (review M05)          -> KILLED
  marker match made case-sensitive (M10)     -> KILLED

M05 was a tautology: the test asserted the imported constant against itself,
so changing the constant could not fail it. The wire contract is now pinned as
a literal, because the connector stamps that exact string and a one-sided
change is a silent cross-repo break.

REGRESSION CHECK: the 12 failures + 1 collection error in this test selection
are PRE-EXISTING cross-test contamination — the identical set fails at
7cf86188ac. Verified by diffing the failing sets: no new failures, 363 -> 374
passed.

NOT FIXED (deliberate): B-2, Telegram `@username`. The Bot API resolves handles
at send time, so there is no id to compare and no canonicalization exists yet.
That is a policy decision, not a code move.

* fix(relay): fail CLOSED on guard faults; classify the structured decline

Third independent review. Two more blockers, both reproduced before fixing.

1. THE GUARD ITSELF FAILED OPEN (tools/send_message_tool.py:158)

`_authorize_relay_target` wrapped BOTH the import and the call in one
`except Exception: return None` — and None means AUTHORIZED at every call site.
So any runtime bug inside the guard silently switched the entire P5(a) boundary
off. Reproduced: with the guard raising, an unattested target sent.

The docstring already stated the correct intent ("must not fail closed on its
own IMPORT error") and the code did something broader. The two failures are not
the same: a missing gateway package means there is no relay egress to
authorize; a fault inside the guard means authorization did not happen. The
import is tolerated, the call is not — a guard that cannot answer refuses.

2. THE STRUCTURED DECLINE WAS THROWN AWAY (gateway/run.py)

The adapter preserves the connector's dict in `SendResult.raw_response`. My
previous commit rebuilt a dict from the error STRING, which loses two
contracts:

  * a decline carrying `code: egress_declined` and NO text renders as
    "relay egress declined" — no marker colon — so it classified as `failed`,
    which is exactly the cue to run the fallback into the refused chat;
  * `ambiguous: True` (lost ack) was flattened into a DEFINITE failure,
    re-sending a card that may already be on the user's screen. That is the
    duplicate-card bug the ambiguous verdict exists to prevent, reintroduced
    by the fix meant to harden the same path.

Both call sites now classify `raw_response` when present, ambiguity first, and
fall back to the wire sentence only for connectors that send no structured
response.

I had fixed the text-marker path and tested only the text-marker path. Worth
naming: the review's probe was a shape my tests never produced.

MUTATIONS (production source)
  guard fault returns None (fail open again)   -> KILLED
  classifier ignores raw_response              -> KILLED (3 cases)
  ambiguous treated as a definite failure      -> KILLED (2 cases)

40 focused tests pass. Regression check vs be321faf27: identical 13-item
failing set (pre-existing cross-test contamination), no new failures.

STILL OPEN: B-2 / finding 3, Telegram `@username`. The reviewer is right that
this is a REGRESSION of an existing contract (#53573 added Bot API username
support), not merely an unspecified input, since relay provenance stores the
numeric chat id. Fixing it means resolving the handle before authorization, or
explicitly revoking the contract. That is a policy decision, not a code move,
and it is Ben's call.

* test(relay): pin M21 and M25, the survivors whose comments called them load-bearing

Round-2 review reported six unpinned survivors from round 1. Two guard real
behaviour and are now covered; the other four are cosmetic-lane warnings and
fail-open branches I am leaving documented rather than pretending to close.

M25 — thread-qualified session ids. `_session_ids` adds BOTH "chat:thread" and
the bare chat, because the connector authorizes the CHAT. Without the split a
gateway whose session origin is `-100999:77` cannot send to `-100999`, the chat
it is demonstrably already talking in. KILLED.

M21 — the generic `relay` plane must union every fronted platform, since a
relay session is filed under its LOGICAL platform. KILLED.

MY FIRST M21 TEST WAS THE DEFECT IT WAS TESTING FOR. I patched `_relay_fronted`
— the very function the mutation empties — so emptying it changed nothing the
test could see, and the mutation SURVIVED against a green test. Rewritten to
drive the real `relay_fronted_platforms()` through its env source
(`GATEWAY_RELAY_PLATFORMS`), which is how production learns it.

That is the same "the test verifies my stand-in" failure I have spent this
workstream removing from the connector harnesses, reproduced here in three
lines of Python. The tell was identical: a mutation that survives a test
written specifically to kill it.

334 tests pass.

NOT PINNED, deliberately: M03 (success-guard on a malformed dict), M24
(empty-target allowance — the one fail-open branch, reachable only when the
bare-platform path already resolved a home channel), M35/M36 (decline WARNINGs
on cosmetic lanes). All four are observability or defence-in-depth rather than
authorization, and the review agrees they are non-blocking.

* fix(relay): defer Telegram @username authorization to the connector (B-2)

Closes the last blocker. Two reviewers independently called this a REGRESSION
of the public-channel username support added in #53573, not an unspecified
input, and they were right: provenance stores RESOLVED numeric chat ids, so
comparing `@channel` against them could only ever refuse.

WHY THE GATEWAY CANNOT ANSWER IT. The guard fires only when there is no live
native adapter — i.e. relay-fronted deployments — and on exactly those the
CONNECTOR holds the bot token, not this process. There is no local way to turn
a handle into the numeric id. Refusing here is not "fail closed", it is "fail
always".

WHY DEFERRING IS SAFE. The destination is still authorized one layer out: the
connector's Telegram egress floor (gg#238, merged 743a7c2) classifies and
refuses unauthorized destinations after ITS resolution — the layer that closed
the reported vulnerability in the first place. Handles go from two guards to
one, the authoritative one, not to zero.

The carve-out is deliberately narrow and its EDGES are pinned, because the
failure mode of an exemption is silent widening:

  telegram `@handle`        -> deferred            (the regression case)
  telegram numeric id       -> still guarded
  matrix `@user:server`     -> still guarded       (telegram-only)
  bare name, no `@`         -> still guarded
  attested handle           -> normal path, attestation still consulted

MUTATIONS
  carve-out widened to all platforms   -> KILLED
  carve-out widened to every target    -> KILLED
  carve-out removed (regression back)  -> KILLED
  carve-out checked BEFORE attestation -> KILLED

THE ORDERING MUTANT SURVIVED MY FIRST TEST. Both orderings return None, so
asserting the verdict could not tell them apart — the test asserted the claim
instead of the mechanism. Rewritten to observe that attestation is actually
consulted. Same defect class as the M21 test earlier in this branch: a
mutation surviving a test written specifically to kill it means the test is
measuring the wrong thing.

341 tests pass.

FOLLOW-UP (option 2, Ben's call, deliberately NOT done here): resolve the
handle before authorizing so BOTH layers apply. That needs a resolution
round-trip through the connector — new wire surface — so it belongs in its own
phase rather than bolted onto this one. Recorded in the code comment at the
carve-out, not just here.

* fix(relay): close two fail-open boundaries; test the code-only decline for real

Both blockers from review, each REPRODUCED before fixing.

1. STRUCTURED DECLINE HAD NO GUARD. Deleting `raw_response=result` from both
   `_send_prompt` return branches left all 34 tests green — a surviving,
   non-equivalent security mutant. The `code` field is the documented
   PREFERRED signal precisely because a connector may send no prose, and a
   caller rebuilding `{"success": False, "error": ...}` cannot see it.

   Cause: every existing case declines with marker TEXT. The evidence for the
   code-only path was a hand-built SimpleNamespace in a different file — a
   stand-in for the adapter, so it verified my fixture instead of production.

   Fixed with a CodeOnlyDecliningConnector driving the real
   `send_exec_approval` -> `_send_prompt`, feeding the REAL SendResult to the
   REAL `_approval_send_outcome`, plus the same shape on the media lane.
       drop raw_response  SURVIVED (34 passed) -> KILLED

2. TWO FAIL-OPEN BOUNDARIES, both "absence" and "fault" sharing a return.

   `_relay_fronted` swallowed EVERY exception and returned an empty set, which
   `relay_routed_platform` reads as "not relay-routed" — skipping the guard.
   Probe, with a positive control in the same run:
       positive_control_denied   = True
       discovery_fault_denied    = False   <- unattested target AUTHORIZED

   `_authorize_relay_target` caught every exception during IMPORT as "no
   gateway package". A module that exists and fails to initialize is a fault,
   not an absence, and returning None there means authorized.

   Now: ImportError alone is absence; anything else raises RelayRouteUnknown
   and `authorize_relay_target` converts it to a REFUSAL STRING (not a raised
   exception — every caller treats the return value as the verdict, so raising
   would trade a fail-open for a crash).

   Kept the converse under test so "fail closed" does not silently become
   "refuse everything in CLI/cron", which is the outage the broad except
   existed to prevent.
       discovery fault -> empty set        KILLED
       RelayRouteUnknown -> authorized     KILLED
       import fault -> authorized          KILLED

397 passed (was 392, +5 new cases), zero failures.

* fix(relay): close all seven review-round-3 blockers

Every finding reproduced before fixing; every fix mutation-checked after.

CONTENT LEAKS (the decline was laundered into a different op, same chat)

#1 A declined DRAFT SEAL replayed as a plain send. On stream-is-the-message
   platforms the turn-final becomes draft(final=True); `_seal_open_draft`
   dropped the structured body, so `_absorb_into_open_draft` read a REFUSAL as
   a lane failure and fell through. Probe, Slack descriptor:
       before: draft(partial) -> draft(final,SECRET) -> send(SECRET)
       after:  draft(partial) -> draft(final,SECRET)
   My first probe of this used a discord descriptor and showed no seal at all —
   the leak is real, my probe was wrong (streams only arm for Slack).

#6 Task-card PROGRESS had the same defect one lane over: a bare failed
   SendResult reads as "card lane unavailable", and TurnRunner then sends the
   task text to the same chat. Both card methods now carry raw_response and
   the caller suppresses the fallback on a decline.

AUTHORIZATION BYPASSES

#2 `except ImportError` was NOT the fix I claimed last round. ImportError also
   covers a broken dependency inside an INSTALLED gateway; review probed
   `ImportError.name = "gateway.relay.dependency"` and got an authorized
   verdict. Now only a name identifying the gateway relay module itself is
   absence. An ImportError with NO name stays absence — refusing on a fault we
   cannot attribute would trade an unidentifiable bug for a real CLI/cron
   outage, and an existing test caught exactly that when I first got it wrong.

#3 `relay_routed_platform` lowercases the requested platform; `_relay_fronted`
   returned configured names verbatim. A platform configured as "Discord"
   missed the membership test, looked native, and skipped the guard:
       'discord' => refused    'Discord' => ALLOWED    'DISCORD' => ALLOWED
   An attestation bypass on a string comparison.

UNDELIVERABLE PROMPTS THAT HUNG

#4 `_clarify_send_disposition` handled `failed` and `ambiguous` but not
   `declined`, so a REFUSED clarify card fell through to wait_for_response and
   blocked until clarify_timeout — indefinitely when configured non-positive.
   A decline is more definitive than a failure, not less.

#5 The exec-approval decline branch returned quietly, which suppressed the text
   fallback (right) but left the CENTRAL approval entry pending (wrong) — the
   dangerous command stayed blocked until the approval timeout. My comment
   claimed the registration was torn down; only RelayAdapter's private map was.
   It now raises `_ExecApprovalDeclined`, which propagates to
   `_await_gateway_decision`'s existing notify-failure path (drops the entry,
   unblocks the tool). A dedicated type, re-raised past the local
   `except Exception` that would otherwise have restored the leak.

#7 THE GAP THAT LET ALL OF THIS SHIP. Both caller-level suppressions were
   unfalsifiable: deleting either branch left 36/38 tests green. The suites
   drove `_approval_send_outcome` and `RelayAdapter` but never the real
   TurnRunner / busy-session callers, so nothing observed whether a text send
   FOLLOWED a decline — which is the whole property.
   tests/gateway/test_decline_fallback_suppression.py drives both real callers
   and records every send. Each decline case is paired with an ordinary-FAILURE
   control, because without one a caller that never falls back would also pass.

MUTATIONS (all on production source, anchors count-checked, restored after)

  #1  seal decline -> plain send                KILLED
  #1b seal drops raw_response                   KILLED
  #2  nested ImportError -> authorized          KILLED
  #3  fronted set not normalized                KILLED
  #4  clarify declined branch removed           KILLED
  #5  approval decline returns not raises       KILLED
  #6  task_card drops raw_response              KILLED
  #7  slash-confirm suppression removed         KILLED

#7's two were the reviewer's SURVIVORS (36/38 passing); both now die.

425 passed, zero failures.

* fix(relay): close the three round-4 blockers

Round 4 confirmed six of seven round-3 fixes and found three more. Each
reproduced before fixing, each mutation-checked after.

1. A NAMELESS ImportError still authorized. Last round I admitted it as
   "absence" to protect the CLI/cron path. That reasoning was WRONG and the
   interpreter says so:

       import gateway.relay.nope  -> ModuleNotFoundError, name="gateway.relay.nope"
       import totally_absent_pkg  -> ModuleNotFoundError, name="totally_absent_pkg"

   Genuine absence is ALWAYS ModuleNotFoundError with `.name` set, so the
   CLI/cron path never produces a bare ImportError and nothing legitimate was
   being protected. A plain or nameless ImportError comes from an import hook
   or a module that failed while initializing — an unattributable FAULT.
   Now: absence is ModuleNotFoundError naming gateway / gateway.relay /
   gateway.relay.egress; everything else refuses. Two existing tests raised a
   bare ImportError to simulate absence and were corrected to the real shape.

2. SESSION ATTESTATION INVENTED IDS. `_session_ids` split every id on the first
   colon to recover "chat" from "chat:thread". Matrix ids contain a colon
   natively, so `!room:server.org` attested a bare `!room` — the guard
   vouching for a destination on its own fabrication. The split now applies
   only to platforms whose ids genuinely carry a `:thread` suffix (allow-list;
   unknown platforms are treated as un-splittable, which can only refuse more).
   Kept a Slack control: dropping the split entirely would refuse legitimate
   thread replies, which is the outage the split exists to prevent.

3. THE TASK-CARD FIX WAS UNFALSIFIABLE — my own round-3 mistake, and the same
   one round 3 caught me making. I added the production branch AND a test, but
   the test stopped at RelayAdapter: it proved `raw_response` is carried and
   never called `TurnRunner._task_card_publish`, which owns the property.
   Deleting the real branch left 30 tests green. Now driven through the real
   caller, with an ordinary-failure control.

   The lesson generalises: proving the DATA reaches the boundary is not proving
   the CALLER acts on it. Every one of these decline fixes has two halves and
   the second half is where the security lives.

Also closed the round-4 non-blocking finding: `gateway/relay/egress.py` has its
OWN import boundary, and the existing test intercepted the earlier import in
tools/send_message_tool.py, so it was never exercised. Mutating that classifier
to treat every ImportError as absence now dies.

MUTATIONS (production source, anchors count-checked, restored after)

  R4-1 nameless ImportError -> authorized        KILLED
  R4-2 session split unconditional               KILLED
  R4-3 task-card caller branch removed           KILLED  (was SURVIVED)
  egress classifier: any ImportError = absence   KILLED

Also probed and found NOT a leak: a refused OPENING draft frame disarms the
stream and the turn-final goes out via `send`. That send is itself guarded and
the connector refuses it too, so no content is delivered — unlike the seal case
(round 3, #1) where the seal was the only check on that path.

452 passed, zero failures.

* fix(relay): recover the thread parent from thread_id, not a colon split

Round 4 blocker 2 was closed with an allow-list of platforms whose ids have no
native colon. Reviewing my own fix while round 5 ran, the allow-list is the
wrong mechanism: it NARROWS a guess instead of removing it, and it still gets
Matrix wrong the moment a Matrix session is thread-qualified
(`!room:server.org:$thr` -> split yields `!room`).

The structured field was there all along. `_session_entry_id` composes the id
as f"{chat_id}:{thread_id}" and the entry still carries `thread_id`
separately, so the parent is knowable EXACTLY: strip the known suffix, or add
nothing. No platform list, no guessing, correct for ids that contain colons.

Mutations:
  back to splitting on the first colon        KILLED
  thread parent never recovered (over-refuse) KILLED

Both directions matter: the first invents attestations, the second refuses
legitimate thread replies.

One existing test (M25) asserted the right PROPERTY with a fixture that omitted
`thread_id` — a shape real entries never have. Fixture corrected, assertions
untouched.

453 passed.

* fix(relay): close the four round-5 blockers

Each reproduced before fixing, each mutation-checked after.

R5-1 A DISABLED NATIVE ADAPTER BYPASSED AUTHORIZATION. `_has_live_native_adapter`
     treated any entry in the adapter map as native; `resolve_delivery_transport`
     ignores a native adapter whose config is disabled and routes over Relay.
     Two independent routing classifiers, disagreeing:
         guard says native: True   delivery routes relay: True
     So the guard skipped authorization for a send that went over the relay.
     The guard now applies the router's enabled-state rule; probed both
     configurations and they agree.

R5-2 THREAD IDS WERE NEVER AUTHORIZED. The parser splits chat_id and thread_id;
     only chat_id reached the guard. On Discord the thread IS the destination —
     `POST /channels/{thread_id}/messages` — so an attested parent channel
     authorized an arbitrary caller-supplied thread. `authorize_relay_target`
     now takes thread_id and requires its own attestation (bare id or the
     `chat:thread` form a session origin produces); both call sites forward it.

R5-3 A DECLINED **INITIAL** DRAFT WAS RETRIED AS A PLAIN SEND. Round 3 fixed the
     declined SEAL; the declined OPEN was a different path. `send_draft`
     returned a bare failure, so the stream consumer read "draft transport
     unusable", disabled drafts and fell through to `_first_send`. Measured
     through the real adapter and real StreamTransportMixin:
         before: ops ['draft', 'send']      after: ops ['draft']
     send_draft now carries raw_response; a decline is terminal for the run and
     the guard sits in `_first_send`, where every fallback path converges.

R5-4 MY ROUND-4 TASK-CARD FIX SUPPRESSED EXACTLY ONE UPDATE. It set
     `native_failed`, which the entry gate already uses for an ordinary broken
     lane, so the next progress event skipped the decline branch and went
     straight to the text fallback:
         after first publish: []      after second: ['send']
     Terminal declines are now a separate `egress_declined` state checked at the
     entry gate. A refusal does not expire after one tick.

MUTATIONS

  R5-1  disabled native counts as native        KILLED
  R5-2  thread_id not authorized                KILLED
  R5-2b tool does not forward thread_id         KILLED (was SURVIVED)
  R5-3  initial-draft decline not terminal      KILLED
  R5-3b _first_send guard removed               KILLED
  R5-4  declined state not persistent           KILLED

R5-2b is the same gap that produced findings 3 and 4 of the last two rounds, a
third time: every test called `authorize_relay_target` directly, so dropping the
argument from the TOOL WRAPPER changed nothing. Testing the callee never proves
the caller uses it — now pinned explicitly.

Each fix ships with an ordinary-failure control, because every one of these
makes the guard refuse MORE, and over-refusal is now the larger risk.

474 passed, zero failures.

* refactor(relay): declare the terminal-decline state where it lives

Both terminal-decline flags were set dynamically. They worked (neither class is
frozen or slotted) but an undeclared attribute hides the state from anyone
reading the class, and this one is security-relevant.

  _TaskCardState.egress_declined  — declared dataclass field
  StreamConsumer._egress_declined — initialised in __init__

Lifetime verified while checking whether a refusal can leak ACROSS turns and
mute a healthy destination: it cannot. _TaskCardState is constructed per
progress-drain (run_turn_runner.py:420) and the consumer's flags per run
(stream_consumer.py:163), so both are fresh each turn.

Also verified the guard's blast radius after adding thread authorization: the
ONLY callers of authorize_relay_target are the two model-facing send_message
call sites. Gateway-internal sends — notably the handoff path, which creates a
thread and immediately posts to it with no session provenance yet — go through
transport.adapter directly and are unaffected. That was the most plausible
over-refusal, and it does not reach this guard.

461 passed.

* fix(relay): close the four round-6 blockers — the edit lane

R6-1 MY OWN R5-1 FIX REINTRODUCED THE BYPASS IT CLOSED. I wrote
     `except Exception: return True` around the config lookup, so a config read
     fault declared the platform native while the ROUTER, reading the real
     config, sends over the relay:
         guard_has_live_native True   guard_verdict None   router relay
     Routing we cannot determine is UNKNOWN. It now raises RelayRouteUnknown,
     which the outer handler must re-raise rather than flatten to False, and
     `authorize_relay_target` turns into a refusal. This is the second time a
     convenience `except` in this function created a bypass; there is now no
     permissive return left in it.

R6-2/3/4 THE NINTH LANE: `edit`. ONE dropped field, THREE leaks.
     `RelayAdapter.edit_message` discarded the connector response, and three
     independent callers read a bare edit failure as "editing is unavailable"
     and re-send the content as a NEW message to the same chat:

       stream edit fallback   ['edit', 'edit', 'send']  the unseen tail
       queued reconciliation  ['edit', 'send']          the WHOLE response
       task-card fallback     ['edit', 'send']          the task text again

     Fixed at the source (edit_message carries raw_response) plus each caller:
     `_on_edit_failure` — the single funnel for stream edit failures — makes a
     decline terminal for the run, `_send_fallback_final` refuses to deliver a
     continuation after one, the queued reconciler returns instead of sending,
     and the task-card fallback sets the same terminal state R5-4 introduced.

     R5-4 fixed the native task-card op and I did not check its sibling
     fallback path. The pattern across rounds 3-6 is consistent: the fix goes
     where the decline is OBSERVED, and the leak lives wherever someone else
     later decides to retry.

MUTATIONS

  R6-1  config fault -> assume native            KILLED
  R6-1b RelayRouteUnknown swallowed as False     KILLED
  R6-2  edit drops raw_response                  KILLED
  R6-2b edit-failure decline not terminal        KILLED
  R6-3  queued reconcile falls back on decline   KILLED
  R6-4  task-card fallback edit decline          KILLED

Each with an ordinary-failure control: a genuinely un-editable message must
still be delivered, and a broken card lane must still reach the user.

481 passed, zero failures.

* fix(relay): add a terminal-decline latch at the adapter choke point

THE STRUCTURAL FIX, not a twelfth local check.

Rounds 3-6 of review found ONE defect in eleven lanes: the connector refuses an
op, and some caller downstream reads that as 'this lane is unavailable' and
retries the same content through a DIFFERENT op against the SAME chat. Media,
prompt, draft-open, draft-seal, native task card, task-card fallback edit,
slash-confirm, exec-approval, clarify, stream edit, queued reconciliation.

Each was closed by adding a check at one more call site. That approach cannot
converge: gateway/ has ~60 outbound call sites, every one of them a place a
future change can reintroduce this, and four consecutive review rounds each
found another. The reviewer's own count of lanes is the argument against the
per-site design.

Every relay frame from every one of those callers passes through
_transport.send_outbound. One latch there covers them all: once the connector
refuses a chat, this adapter stops emitting CONTENT frames for that chat.

Proven to subsume the local checks: with the stream-edit per-site check
DISABLED, the leak probe still reports blocked=true — the frame never reaches
the wire. The local checks stay as defence in depth and for their better error
messages, but they are no longer the only thing standing between a decline and
a re-addressed send.

Scope is deliberately narrow, and each limit is mutation-pinned:
  per CHAT       - a refusal must not mute other conversations
  CONTENT ops    - typing/delete carry nothing; latching them would leave a
                   stuck typing indicator for no security gain
  self-healing   - cleared when the connector accepts that chat again, so a
                   transient policy change does not need a restart

Mutations:
  latch never set                KILLED
  latch never consulted          KILLED
  latch is global, not per-chat  KILLED
  latch never clears             KILLED

485 passed.

* fix(relay): one route source; the latch already covered round 7's lanes

Round 7 reviewed 573e41e294 — one commit BEFORE the terminal-decline latch —
and independently reached the same conclusion I had: 'The per-call-site
approach is structurally wrong. Use one turn-scoped choke point.' That is the
latch in 6dbc004594.

Its four 'still broken' lanes (tool-progress edit, progress-overflow edit,
long-running heartbeat edit, stale streamed-final reconciliation) all share the
shape edit_message->declined->adapter.send(same chat, same content), and NONE
has a local check. Probed all four against the latch:

  tool_progress      ops ['edit']  blocked
  progress_overflow  ops ['edit']  blocked
  heartbeat          ops ['edit']  blocked
  stale_final        ops ['edit']  blocked

That is the argument for the choke point, measured: lanes nobody patched are
safe anyway. Pinned by a parametrized test named for those four lanes.

R7-1 IS A REAL BYPASS THE LATCH DOES NOT COVER, and it is fixed here. The guard
rebuilt routing from GATEWAY_RELAY_PLATFORMS while resolve_delivery_transport
asks the CONNECTED adapter (fronts_platform, from the handshake identity set).
Different snapshots: with env discovery stale or momentarily empty, the guard
said 'native' and the router sent over the relay, skipping authorization.

  before: guard_relay_routed False / delivery relay
  after:  guard_relay_routed True  / delivery relay / unattested target refused

The guard now asks the live adapter first and falls back to config only when
there is no runner (CLI/cron) — pinned in both directions.

R7-5 (non-blocking, and a fair hit): my stream-fallback test asserted
_egress_declined and never drove _send_fallback_final, so removing that early
return SURVIVED. The test now calls the real fallback and asserts the wire is
untouched; the mutation dies.

Mutations:
  R7-1 guard ignores the live adapter    KILLED (was SURVIVED)
  R7-5 fallback early return removed     KILLED (was SURVIVED)
  latch not consulted                    KILLED

491 passed.

* fix(relay): close three holes found by attacking my own latch

Round 8's brief told the reviewer to attack the latch. I did the same in
parallel and found three real holes in it before the review returned.

1. send_for_platform BYPASSED THE LATCH ENTIRELY. It builds and posts its frame
   directly rather than through _outbound — and it is the delivery resolver's
   OWN entry point, so it is the single most important caller.
       before: ops ['edit', 'send']   after: ops ['edit']
   gateway/AGENTS.md states the rule I had just broken: 'Seal-interception
   exists at BOTH egress doors (send() and send_for_platform()); a new egress
   door needs the same two checks.' The latch is a third such check and I had
   wired it to one door.

2. A COSMETIC SUCCESS CLEARED THE LATCH. Clearing on ANY success meant a
   typing indicator — routinely allowed for a chat whose content is refused —
   re-opened the door for the very next send:
       ops ['edit', 'typing', 'send']
   Only a CONTENT op the connector accepted may clear it now.

3. A THREAD INSIDE A REFUSED CHAT WAS NOT COVERED. A thread lives inside its
   parent, so the same content reached the same conversation one level down:
       ops ['edit', 'send']
   The latch key now strips the thread suffix.

Also normalised int/str chat ids (callers pass both; a type mismatch would
silently unlatch).

MUTATIONS
  send_for_platform not latched          KILLED
  cosmetic success clears the latch      KILLED
  thread suffix not stripped             KILLED
  draft-seal retry not latched           SURVIVED — EQUIVALENT, proven:
       is unreachable while latched (a declined edit before the seal
      produces ZERO seal frames, measured). Kept as defence in depth because it
      posts directly, and documented at the site rather than covered by a
      test that could not fail.

One self-inflicted bug on the way: a blanket replace put 1Password CLI brings 1Password to your terminal.

Turn on the 1Password app integration and sign in to get started. Run
'op signin --help' to learn more.

For more help, read our documentation:
https://www.1password.dev/cli

1Password CLI is built using open-source software. View our credits and
licenses:
https://downloads.1password.com/op/credits/stable/credits.html

Usage:  op [command] [flags]

Management Commands:
  account         Manage your locally configured 1Password accounts
  connect         Manage Connect server instances and tokens in your 1Password account
  document        Perform CRUD operations on Document items in your vaults
  events-api      Manage Events API integrations in your 1Password account
  group           Manage the groups in your 1Password account
  item            Perform CRUD operations on the 1Password items in your vaults
  plugin          Manage the shell plugins you use to authenticate third-party CLIs
  service-account Manage service accounts
  user            Manage users within this 1Password account
  vault           Manage permissions and perform CRUD operations on your 1Password vaults

Commands:
  completion      Generate shell completion information
  inject          Inject secrets into a config file
  read            Read a secret reference
  run             Pass secrets as environment variables to a process
  signin          Sign in to a 1Password account
  signout         Sign out of a 1Password account
  update          Check for and download updates.
  whoami          Get information about a signed-in account

Global Flags:
      --account account    Select the account to execute the command by account shorthand, sign-in address, account ID, or user ID. For a list
                           of available accounts, run 'op account list'. Can be set as the OP_ACCOUNT environment variable.
      --cache              Store and use cached information. Caching is enabled by default on UNIX-like systems. Caching is not available on
                           Windows. Options: true, false. Can also be set with the OP_CACHE environment variable. (default true)
      --config directory   Use this configuration directory.
      --debug              Enable debug mode. Can also be enabled by setting the OP_DEBUG environment variable to true.
      --encoding type      Use this character encoding type. Default: UTF-8. Supported: SHIFT_JIS, gbk.
      --format string      Use this output format. Can be 'human-readable' or 'json'. Can be set as the OP_FORMAT environment variable.
                           (default "human-readable")
  -h, --help               Get help for op.
      --iso-timestamps     Format timestamps according to ISO 8601 / RFC 3339. Can be set as the OP_ISO_TIMESTAMPS environment variable.
      --no-color           Print output without color.
      --session token      Authenticate with this session token. 1Password CLI outputs session tokens for successful 'op signin' commands when
                           1Password app integration is not enabled.
  -v, --version            version for op

Run 'op [command] --help' for more information on the command. into
send_for_platform, which has no such variable. Two existing unfurl tests caught
it — NameError at adapter.py:1407.

504 passed.

* fix(relay): Telegram handle exemption + a turn boundary for the latch

Round 8 blockers. Two of its four were already closed by 93750e351a (it
reviewed the commit before it); these two are real and both are mine.

B1 — THE TELEGRAM @HANDLE EXEMPTION COVERED A NATIVE SEND.

_is_unresolved_handle exempts telegram @handles from attestation because
"the connector resolves and authorizes it". That justification is FALSE
whenever the gateway holds its own token: _send_to_platform calls
_send_telegram(pconfig.token, ...) directly and no connector is involved.
So an unattested @handle went out under the gateway's own credential
while the numeric control was correctly refused.

The exemption now requires that no native credential exists. A probe
fault WITHDRAWS the exemption (falls back to the ordinary attestation
check) rather than granting it.

Shipped with the converse control: relay-only config still exempts
@handles, and numeric targets stay guarded in both modes.

B4 — THE LATCH HAD NO BOUNDARY, SO IT WAS AN OUTAGE MECHANISM.

My own regression, and worse than reported. Removing "clear on cosmetic
success" (correctly) removed the ONLY way the latch could ever clear: a
content op can never reach the connector to succeed, because the latch
blocks it locally first. A refusal at 09:00 muted that chat forever.

A new inbound message for a chat is the generation marker — the natural
teardown point. Suppression still holds for the whole turn.

    same_turn_blocked: true     next_turn_delivered: true

MUTATIONS (all killed)
  handle exemption ignores native credential
  native-credential fault GRANTS the exemption
  no turn boundary (latch never clears)
  teardown clears ALL chats not just this one
  teardown ignores the chat

The last two SURVIVED first: I tested _clear_declined_for_turn directly
and never proved _on_inbound calls it — the caller-level gap that has now
produced four blockers on this branch. Added a test driving the real
inbound entry point.

One self-inflicted bug, caught by my own fault test: the probe imported
load_config, which does not exist (it is load_gateway_config), so it
always threw and returned the fault default. The test that pinned fault
behaviour is what exposed it.

510 passed.

* fix(relay): correct latch identity and boundary; one config snapshot

Round 9, four blockers, all reproduced.

B1+B4 — THE TEARDOWN WAS AT THE WRONG PLACE, twice over.

It sat on the adapter's raw _on_inbound, which runs BEFORE profile
routing, the ignored-channel guard, plugin hooks and user authorization.
An unauthorized or dropped event could therefore clear a refusal
belonging to an active turn, and stale content then went out as a
different op. The same placement missed Discord interaction passthrough,
which builds its own MessageEvent and calls handle_message directly, so
slash commands and modal submits stayed muted after an earlier decline.

Both are one mistake: I picked a lane instead of a boundary. Teardown now
runs immediately after _hm_admit_event, the single admission gate every
entry path shares.

  dropped event  -> latch survives, stale send blocked
  admitted event -> latch clears

B2 — THE LATCH KEY SPLIT ON ':', WHICH IS A MISTAKE I ALREADY FIXED ONCE.

_latch_key did str(chat_id).split(":", 1)[0], so !room:tenant-a and
!room:tenant-b both keyed !room: a decline in one Matrix room muted
another, and inbound from one cleared the other's refusal. egress.py
::_session_ids stopped doing exactly this in round 4 and I reintroduced
it three rounds later.

Parent identity is never recoverable from identifier TEXT. Thread
coverage is now structural: _thread_parent looks the relationship up in
the recorded auto-thread map.

B3 — AUTHORIZATION AND DISPATCH USED DIFFERENT CONFIG SNAPSHOTS.

_handle_send retains one pconfig; the guard independently reloaded
config. Across a transition the authorization snapshot could see a
connector-only setup (exemption granted) while dispatch still held the
native token and sent the unattested @handle itself. The guard now takes
native_token from the SAME snapshot dispatch will use. A caller that
omits it does not silently look like "no token".

NB-1/2/3 also closed: real-object snapshot tests, an exception shield
that faces a real exception, and send_follow_up no longer discards the
connector's verdict (that discard is exactly how the edit lane laundered
declines).

MUTATIONS (all killed)
  latch key splits on colon again
  thread parent lookup disabled
  dispatch token ignored by guard
  tool drops the snapshot token
  admission teardown removed
  teardown moved BEFORE admission
  exception shield removed
  follow_up drops raw_response

"admission teardown removed" SURVIVED first: I had tested the helper, not
_handle_message. Added a test driving production _handle_message with
admission stubbed both ways. Fifth caller-level gap on this branch.

One self-inflicted bug caught before commit: I passed pconfig.token in
_handle_react, which has no pconfig — a NameError on every reaction.

516 passed.

* docs(relay): pin the latch's thread coverage limit as a deliberate trade

_thread_parent only sees connector auto-threads, and that map is capped at
256 entries, so a user-created or evicted thread does not inherit its
parent's latch. Documented at the site and asserted by a test, because the
alternative - deriving parents from identifier text - is exactly what muted
unrelated Matrix rooms in round 9.

The primary control is unaffected: authorize_relay_target takes thread_id as
part of the destination and attests it on every send (6 thread tests).

* refactor(relay): one SendResult decline classifier for all 8 gateway lanes

The extraction found a DEFECT, not just repetition.

Eight gateway lanes each hand-rolled the unwrapping of a decline from a
SendResult, and they did not agree. Six checked only raw_response. Two
also checked the error text. A connector that answers with the uniform
decline SENTENCE and no structured code - the documented contract for
older connectors, per _approval_send_outcome - was therefore classified
as an ordinary failure by those six lanes, so each treated a refusal as
"editing unavailable" and retried through another op.

Measured:

    text-only decline    six-site check False    two-site check True
    structured decline   six-site check True     two-site check True

No content leaked, because the adapter latch classifies the transport
dict directly and catches both shapes (verified: text-only decline still
latches C1 and keeps SECRET off the wire). The cost was wrong verdicts
and futile retries, not disclosure.

declined_send(result) in gateway/relay/egress.py now owns this. It checks
raw_response when structured, else the error text, and preserves the
ambiguous exclusion - an ambiguous result is a transport outcome, so it
must never read as a refusal.

run.py keeps its own shape deliberately: that lane has three verdicts
(ambiguous / declined / failed), so it checks ambiguous first and then
delegates the boolean.

MUTATIONS (all killed)
  helper drops the text-only branch
  helper drops the structured branch
  ambiguous no longer excluded
  draft lane decline check removed
  edit-failure lane decline check removed
  prompt verdict lane check removed
  slash-confirm lane check removed
  draft lane goes terminal on ANY failure   (over-refusal direction)

"draft lane decline check removed" SURVIVED first: _send_draft_frame had
no test driving an unsuccessful send_draft at all. Added one, with an
ordinary-failure control so the fix cannot silently become "one flaky
frame mutes the chat". A non-unique anchor also masked the edit-failure
lane on the first pass - the trap my own skill warns about.

This closes the duplication that caused four of nine rounds of blockers:
a new lane now calls one classifier instead of copying three lines.

519 passed.

* fix(relay): latch identity, new-turn boundary, seal arming, ambiguity

Round 10, four blockers, each reproduced before fixing. Two are my own
regressions from the previous two rounds.

B1 - ADMISSION IS NOT A NEW-TURN BOUNDARY.

Round 9 moved teardown to just after _hm_admit_event. That is only an
ADMISSION gate: an authorized message can be steered into a running
session, answer a pending prompt, run a busy slash command, or be refused
by the pause/drain gates - all without starting a turn. Each of those
cleared the ACTIVE turn's refusal, and a later fallback from that turn
reached the wire (probe: latch emptied, wire ops ['edit', 'send']).

Teardown now runs after _claim_active_session_slot, the first point the
runner OWNS a new turn. The new test drives production _handle_message
through all four non-turn lanes plus the real new-turn path.

B2 - LATCH IDENTITY OMITTED THE LOGICAL PLATFORM.

One relay adapter fronts several platforms, so native ids collide. A
Discord refusal for chat 42 was cleared by clear_egress_latch("telegram",
"42") - the method took a platform and ignored it - and the Discord
fallback then reached the connector. Keyed by normalized platform plus
exact chat id; thread-parent expansion keeps the platform component.

B3 - THE DIRECT DRAFT-SEAL PATH DID NOT ARM THE LATCH.

_seal_open_draft posts through _attempt directly rather than _outbound,
so a definite decline logged and returned but never latched. The
immediate plain-send fallback was suppressed by the caller's own check;
later same-turn sends were not (wire ['draft', 'draft', 'send'], the
third frame carrying refused content).

B4 - MY OWN REFACTOR MADE AMBIGUOUS RESULTS TERMINAL.

send_draft's ambiguous projection discarded raw_response, so
declined_send fell through to the error-text branch - and an ambiguous
result whose text carries the decline marker ("... egress declined: ack
lost") read as a DEFINITE refusal and terminated the run. Ambiguous means
the frame may well have been delivered: a transport outcome, never an
authorization one.

Fixed on both layers: the projection carries the body (and the seal's
ambiguous return is now explicit too), and declined_send's text-only
branch - which cannot see the ambiguous flag - treats ack-lost text as
transport ambiguity. Audited every SendResult projection in adapter.py
for the same shape.

MUTATIONS (all killed)
  latch key drops the platform
  clear_egress_latch ignores platform
  draft seal does not arm the latch
  ambiguous projection drops raw body
  declined_send infers decline from ack-lost text
  teardown back at admission

523 passed.

* refactor(relay): split the terminal-decline latch out of the guard PR

The latch moves to feat/p5-egress-decline-latch (pushed at 3cf45736d7,
which retains the full history) for redesign. This PR keeps the
authorization guard and the per-site decline checks.

WHY. Across eleven review rounds the two halves behaved very differently.
The guard is a PURE FUNCTION of the destination - its blockers were all
"you asked the wrong question" (case sensitivity, nested ImportError,
missing thread_id, config snapshot skew), each a one-line correction that
then stayed fixed. Rounds 7-10 found nothing new in it.

The latch is MUTABLE STATE WITH A LIFETIME living on RelayAdapter - an
object registered once per process that holds the WebSocket and has no
concept of a turn. Nine of its blockers reduce to three questions the
adapter cannot answer: when does it end, who arms it, what is it keyed
on. Every answer so far has been a proxy (a successful op, an inbound
message, an admitted event, a claimed session slot) and every proxy was
wrong in a lane found later.

The per-site checks hold identical information on `st` - a PER-TURN
object - and have produced zero blockers, because the state dies with the
turn and nobody has to decide when it ends.

The no-relaunder property does NOT depend on the latch. Measured on the
real consumer path with the latch absent: a declined draft frame sets
_egress_declined and puts nothing on the wire.

Removal verified structurally rather than by eye: an AST diff of every
symbol between HEAD and this tree reports only latch symbols gone,
nothing added. That check caught two over-deletions my strip made -
_on_inbound (consumed by a "next def" boundary) and _SEEN_INBOUND_MAX
(a class constant inside the removed span). Both restored; 19 failures
went to 0.

ALSO: RESTORED A TEST I WRONGLY REPORTED AS PASSING.

test_tool_guard_forwards_thread_id never made it into the repo - `git log
-S` finds it in no commit - though round 5 recorded its mutant as killed.
Dropping thread_id from the guard call therefore survived the entire
tests/tools suite (146 passed). Written properly this time, driving the
real _handle_send far enough to reach the guard. It now KILLS that
mutant.

MUTATIONS on this tree
  guard fault authorizes instead of refusing      KILLED
  thread_id dropped from the guard call           KILLED  (was SURVIVED)
  handle exemption ignores native credential      KILLED
  draft lane decline check removed                KILLED
  prompt verdict lane check removed               KILLED
  slash-confirm lane check removed                KILLED

503 passed.

* test(relay): close the phantom-coverage gaps the guard audit found

The thread_id test that was reported as killing a round-5 mutant turned
out never to have been committed. That is a reason to distrust the other
claimed kills, so I re-ran every guard mutation against the COMMITTED
tree instead of trusting the earlier reports.

Result: 9 of 11 killed, and the two "SKIPPED" ones had non-unique
anchors hiding SIX separate sites. Mutating those individually found
three real survivors.

CASE NORMALISATION (round 3, finding 3) WAS HALF-COVERED.

test_relay_fronted_matching_is_case_insensitive varies the CONFIGURED
name but always requests lowercase "discord", so it pins _relay_fronted's
normalisation and nothing else. The REQUESTED name's `.lower()` was
covered by nothing at all. Probe with it removed:

    relay_routed("Discord") -> False
    authorize("Discord", unattested) -> AUTHORIZED

which is exactly the bypass round 3 reported, alive again and untested.

Two further sites were untested in the OVER-REFUSAL direction: the
attested store is keyed lowercase, so a mixed-case request missed its own
attested set and refused legitimate traffic. attested_relay_targets' own
normalisation was invisible to every existing test because they all
monkeypatch that function away; it is now asserted against the real
function with only its leaf sources stubbed.

Three tests added. All six case sites now die when mutated.

I also re-did the three fail-closed RelayRouteUnknown mutations properly.
The first pass swapped whole lines and produced IndentationErrors, so
"KILLED" there proved nothing but a syntax error. Neutralising each raise
at correct indentation: all three genuinely KILLED.

FINAL AUDIT ON THIS TREE — 17 mutations, zero survivors
  guard: thread_id dropped at the call site
  guard: react path unguarded
  guard: handle exemption ignores native credential
  guard: 3x fail-closed raise neutralised
  guard: 6x case-normalisation site
  classifier: ambiguous treated as a decline
  classifier: text-only decline branch removed
  lane: draft / stream-edit / prompt / slash-confirm checks removed

511 passed.

* test(relay): make the stream-edit test fail for the right reason

Review of 45835a282d raised one blocking issue and three non-blocking
ones. All four are addressed; none was a production defect.

BLOCKING — the stream-edit test failed on the double, not on a leak.

test_declined_stream_edit_does_not_send_the_unseen_tail implemented only
the GUARDED path in its consumer double. Removing either guard therefore
raised AttributeError inside the fake before any send could be observed:

  guard 1 removed -> AttributeError: no attribute '_is_flood_error'
  guard 2 removed -> AttributeError: no attribute '_clean_for_display'

Red, but for the wrong reason — the test could not have caught the leak
it is named for. My own docstring claimed it drove the fallback and
checked the wire; it did neither.

The double now implements everything the UNGUARDED path reaches
(_is_flood_error, _flood_strikes, _current_edit_interval, _last_edit_time,
_notify_new_message, _try_strip_cursor, _clean_for_display,
_fallback_prefix, _metadata_for_send). Both mutations now fail on real
assertions:

  guard 1 removed -> assert consumer._egress_declined is True
  guard 2 removed -> AssertionError: the unseen tail reached the wire:
                     ['send']

NON-BLOCKING 1 — a docstring claimed more than the test exercises.

test_requested_platform_name_is_also_normalised described a mixed-case
send_message(target="Discord:999") bypass. That entry point cannot reach
it: _resolve_tool_target lowercases the platform at
tools/send_message_tool.py:47 before the guard runs. The test still pins
a real contract — the helpers must not assume a lowercased argument, for
the gateway lanes and any future non-normalising caller — so the claim is
narrowed to that rather than the test removed.

NON-BLOCKING 2 — the module docstring said "every lane drives the REAL
RelayAdapter". The stream tests drive mixin doubles by design, because
the behaviour under test belongs to the adapter's CALLER. Docstring now
distinguishes the two kinds.

NON-BLOCKING 3 — latch-deletion residue in gateway/relay/adapter.py:418:

      return None
      return latched if surface_declines else None

The second line was unreachable and referenced a name deleted with the
latch. Removed, along with the 20-line comment block describing the latch
as "the structural fix" — that mechanism now lives on
feat/p5-egress-decline-latch, not here.

The reviewer independently confirmed the large deletion: an AST census
between 3cf45736d7 and f57a2298fa reports only latch symbols removed and
nothing added.

511 passed.

* docs(relay): correct three claims that outran the code

Review of 41ce3cc765 found no new production defect but three overstated
claims, one of them in my own commit message.

1. THE LATCH COMMENTARY WAS STILL THERE. My previous commit message said
   it removed "the 20-line comment block describing the latch as the
   structural fix". It removed only the unreachable statement. Twenty
   lines at adapter.py:361-380 still described a per-chat latch, a choke
   point and its scope rules - none of which exist on this branch. In a
   refusal-sensitive module that reads as coverage this branch does not
   have. Now removed for real.

   This is the same defect class as the tests: a claim that outran what
   the code does. I made it while fixing that class.

2. THE STREAM-TEST DOCSTRING OVERSTATED BOTH MUTANTS. It said the
   mutation "now fails on the assertion that a send reached the wire" -
   true of one guard, not both. Verified separately:

     remove the _on_edit_failure check  -> dies on _egress_declined,
                                           never reaches the fallback
     remove the fallback early return   -> dies on the wire: ['send']

   Both are valid behavioural failures, which is what the blocker asked
   for; they are different observables and the docstring now says so.

3. Duplicate `from types import SimpleNamespace` from an earlier scripted
   insert; imports reordered.

112 tests pass in the four focused files.

* fix(relay): close two authorization defects found in review

Both were reproduced before fixing and both mutants are pinned.

1. A LIVE relay adapter whose fronts_platform() raised degraded into the
   config fallback. `_live_relay_fronted` returned None for every failure,
   and None means "no live adapter, use the config snapshot" — so a faulting
   adapter plus an empty/stale snapshot made the guard conclude "not
   relay-routed" and authorize an unattested destination, while
   resolve_delivery_transport asks that same adapter and still routes over
   the relay. Measured: relay_routed=False, verdict None for chat 999.

   Absence and fault now have separate return values: None only when there
   is no runner or no relay adapter; a live adapter that cannot answer
   raises RelayRouteUnknown. This is the third instance of this bug class in
   this file, and the first two were also mine.

2. An attested chat whose id equalled the requested THREAD id vouched for
   that thread. The `thread in attested` arm proved nothing about parentage.
   Measured: attested {"-100A", "7"} authorized (-100A, thread 7).

   Only the bound `parent:thread` form is accepted now. Nothing legitimate
   needed the bare arm — _session_entry_id records a threaded origin as
   f"{chat_id}:{thread_id}", and a thread addressed as its own channel
   arrives as chat_id and passes the parent check.

The existing test blessed the bare form via parametrize, so it PINNED the
defect. Corrected, plus negative controls for the sibling-chat and
other-parent cases and a positive control proving genuine absence still
takes the config path (otherwise fix 1 would break native-only deploys).

Merged origin/main (was 22 behind). 428 passed via scripts/run_tests.sh;
full 10-row mutation ledger re-killed on the merged tree, none dying on an
exception rather than an assertion.

* fix(relay): only a missing adapter is absence; everything else is a fault

Reviewer BLOCKER, reproduced before fixing. Two more paths where a PRESENT
relay adapter still degraded into the config snapshot:

1. `fronts_platform` may be a property or descriptor, so the ATTRIBUTE
   LOOKUP can raise — and the lookup sat inside the absence handler. Probed
   with a raising property plus an empty snapshot: live=None, routed=False,
   verdict=None, i.e. an unattested target authorized. The previous test made
   an already-retrieved METHOD raise, so it could not reach this.

2. A present adapter with no usable `fronts_platform` returned None for the
   same reason. An adapter that cannot say what it fronts is broken, not
   absent, so it now raises too.

Also found by my own spot-check while the review ran: the nested imports of
`gateway.config` / `gateway.run` inside the live probe shared the broad
handler, so a broken installation degraded to the snapshot as well. Probed
with a healthy-adapter positive control in the same run — healthy refused
the unattested target, faulted authorized it. `_relay_fronted` one function
below already drew this exact distinction for its own import.

The boundary is now: `relay is None` is the ONLY absence. Everything about a
present adapter — attribute access, callability, the call itself, and the
imports needed to reach it — is a fault and raises RelayRouteUnknown.

This is the fourth variant of absence-vs-fault in this file and all four
were mine. The lesson is in the code as a comment rather than in a commit
message nobody re-reads.

Four controls keep genuine absence benign: no runner, no relay adapter in
the runner, a real ModuleNotFoundError naming the gateway package, and the
configured-attested-target-still-sends case.

434 passed via scripts/run_tests.sh; 9-row mutation ledger re-killed
including both new guards, none dying on an exception.

* fix(relay): invert the live probe to fail closed by default

Reviewer BLOCKER round 2, reproduced: reading the adapter registry can also
raise. A runner whose `adapters.get()` raised gave relay_present=True,
live=None, routed=False, verdict=None — unattested discord:999 authorized.

That was the FIFTH boundary in one function with the same defect: the call,
the attribute lookup, a non-callable attribute, the nested imports, and now
the registry lookup. Each round I patched the reported boundary and the
defect moved one statement up. The cause was the shape, not the statements:
the function asked "did something go wrong?" and answered None, and None
MEANS "no live adapter, use the config snapshot" — so every statement was a
new chance to fail open, and every new statement would have been too.

Inverted rather than patched a sixth time. Each `return None` now sits
behind an explicit narrow check that cannot itself be the fault (no runner,
no adapters, no relay key, gateway package genuinely absent), and one outer
handler turns anything else into RelayRouteUnknown. A statement added inside
this function is now fail-CLOSED by default.

Verified all six fault shapes raise (call, attribute, missing method,
registry .get, .adapters property, runner ref) and all five absence shapes
stay benign, plus a liveness control where the config snapshot disagrees
with a healthy adapter and the adapter still wins.

Four new tests, including the two absence controls that keep native-only and
CLI deployments working. 438 passed via scripts/run_tests.sh. Mutation
ledger: 8 killed. One survivor recorded as a proven equivalent mutant —
widening `if not registry` to `or {}` is behaviourally identical because
`{}.get()` returns None, i.e. the same absence; it is a readability guard.
jarvisxyz pushed a commit that referenced this pull request Sep 20, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants