# Side-Effects Review — Subscription-first InputGuard

**Version / slug:** `0.28.51`
**Date:** `2026-04-17`
**Author:** `echo`
**Second-pass reviewer:** converged via `/spec-converge` (2 iterations, 4 internal reviewers × 2 rounds)

## Summary of the change

Closes the "subscription-first intelligence" loop (Telegram topic 6655, continuation of watchdog topic 6269). The principle: every LLM-powered decision in instar defaults to the Claude CLI subscription; the Anthropic API is opt-in only, selected at the single provider-construction layer rather than by each consumer.

An audit of direct `ANTHROPIC_API_KEY` runtime reads across `src/` found one real bypass: `InputGuard` was constructed with a raw `apiKey` and called `fetch('https://api.anthropic.com/v1/messages', …)` directly for its Layer 2 topic-coherence review. On machines with Claude CLI but no API key, Layer 2 silently no-opped. Three other hits were already OK (`CoherenceGate`, `StallTriageNurse`, and the `AnthropicIntelligenceProvider` itself); the remaining grep matches were doc strings, CLI help text, Docker env clearing, and redaction regex.

The initial fix routed InputGuard through the shared `IntelligenceProvider` but kept the direct-fetch/apiKey path as a legacy fallback. Multi-angle spec review (security, scalability, adversarial, integration) surfaced 12 material findings against that design, with two HIGH — the most important being that an attacker who could influence the LLM's output could deterministically produce malformed JSON to trigger the guard's existing "fail-open on parse error" path, bypassing Layer 2. Convergence iteration 2 reshaped the design:

- **The direct-fetch / apiKey fallback is removed entirely.** InputGuard has exactly one transport path: the shared `IntelligenceProvider`. No direct `fetch('https://api.anthropic.com/...')` call remains in InputGuard.
- **Malformed-JSON path is hardened** — returns `verdict: 'suspicious'` with `confidence: 0.3` and a degradation log, not silent `coherent`. Under the default `action: 'warn'`, this surfaces a non-blocking system-reminder rather than passing silently.
- **Effective review timeout is floored at 8 seconds** — the CLI subprocess cold-start p99 can exceed the pre-change 3000ms budget; a below-floor config is clamped up.
- **Startup degradation event uses a generic external impact string** — the detailed "which defenses are down" enumeration stays in local logs; external channels (Telegram/feedback) get a summary only.

Files touched:

- `src/core/InputGuard.ts` — constructor accepts only `intelligence?: IntelligenceProvider`; the direct Anthropic fetch path is removed entirely; `parseReviewResponse()` extracted as a shared helper with fail-closed-to-warn semantics on malformed JSON; `reviewTimeout` JSDoc documents the 8s floor; inline comments explain the rationale for each change.
- `src/commands/server.ts` — moves `InputGuard` construction to after `sharedIntelligence` is initialized; no longer reads `ANTHROPIC_API_KEY` for the guard; startup log reports either `via shared IntelligenceProvider` or `provenance + patterns only (no LLM review)` (the "via Anthropic API (direct)" third line is gone); startup `DegradationReporter.report(...)` uses a generic external impact string.
- `tests/unit/InputGuard.test.ts` — 6 regression tests in a `topic coherence review — IntelligenceProvider routing` describe block: provider-routing happy path (fetch asserted never called), suspicious verdict pass-through, markdown-fenced responses, malformed JSON → suspicious with degradation log, empty response → coherent, provider throw → coherent with degradation log, no-provider → coherent with degradation log.

## Decision-point inventory

- `InputGuard.reviewTopicCoherence()` — **modify** — Layer 2 authority (the LLM-backed topic-coherence reviewer). Transport layer reshaped (direct-fetch path gone); authority role and prompt unchanged.
- `InputGuard.parseReviewResponse()` — **modify** — fail-mode behavior change on malformed JSON: previously `coherent` (silent fail-open), now `suspicious` with confidence 0.3 and a degradation log (fail-closed-to-warn). This is an authority-behavior change on an edge case, compliant with signal-vs-authority (see §4).
- `startServer()` InputGuard construction — **modify** — reorders so the guard is wired with the shared provider; no longer reads `ANTHROPIC_API_KEY` for guard construction; reports chosen backend.
- `startServer()` no-intelligence branch — **modify** — adds a `DegradationReporter.report(...)` emission alongside the pre-existing yellow console log. Pure observability signal.

---

## 1. Over-block

**What legitimate inputs does this change reject that it shouldn't?**

The action default is `warn`, not `block` — so "reject" isn't quite the right word even for the verdict changes. Under `warn`, a `suspicious` verdict produces a non-blocking `<system-reminder>` appended to the message; the message still reaches the session.

The two verdict-behavior changes in this release:

1. **Fail-closed-to-warn on malformed JSON.** Previously produced `coherent` (no warning); now produces `suspicious` with `confidence: 0.3` (warning emitted). Under `warn` action this adds a system-reminder to a small class of messages — those where the LLM's output genuinely failed to parse due to non-adversarial causes (provider flakiness, upstream rate-limit retries producing truncated output, etc.). Rate-limit: the existing `trackErrors()` counter escalates to an attention-queue event after 3 degradations in 10 minutes, so sustained parse-failure noise would surface as an operator signal. Legitimate over-warn is bounded by provider-output stability — not expected to be meaningful in normal operation.

2. **Layer 2 now runs on CLI-only machines** (pre-existing bug, not a new over-block). The same LLM authority that runs on API-key machines is doing the review, so the over-block behavior matches what API-key users have already been experiencing in production.

Under `block` action (non-default, off by default), a `suspicious` verdict would block. The `action: 'block'` mode is opt-in and rare. The acceptance criteria do not cover block-mode fail-closed semantics; an operator who enables block-mode should separately evaluate whether the 0.3-confidence parse-error path should stay warn-only regardless of action. The spec calls out that this nuance is handled in the SessionManager verdict-consumption layer rather than in InputGuard — and SessionManager's action-routing is unchanged by this release.

---

## 2. Under-block

**What failure modes does this still miss?**

1. **Pre-change under-block (the one this fixes):** CLI-only machines had no Layer 2 review. This release closes that.
2. **Transport-flake fail-open (intentional):** if the provider throws or times out, Layer 2 returns `coherent` with `confidence: 0` and fires a degradation log. Rationale: routine network flakes shouldn't produce warn-spam. Authority-level dissent (suspicious verdict) would have come through the parse path with a proper verdict. The `trackErrors()` attention-queue escalation keeps this from being silent.
3. **Consecutive-failure → sticky-suspicious state (deferred):** a motivated attacker who can sustain CLI disruption could keep the transport fail-open path active indefinitely. This is acknowledged in the spec's Deferred #1 — consecutive-failure escalation to a sticky suspicious default needs its own design pass to balance safety against warn-spam. The `trackErrors()` observability floor (3-in-10min → attention queue) is the current mitigation.
4. **Provider-layer trust (deferred):** a local attacker who compromises the Claude CLI binary controls every verdict. Spec Deferred #5 — provider-level trust is scoped to `ClaudeCliIntelligenceProvider`, not to guard routing.

---

## 3. Level-of-abstraction fit

This change operates at the *transport* layer beneath the existing Layer 2 authority. It does not invent a new authority or a new detector — it routes an existing authority through the already-blessed `IntelligenceProvider` abstraction, same as `CoherenceReviewer` (`3d4240a`) and `StallTriageNurse`.

The fail-closed-to-warn change on malformed JSON is at the boundary between transport and authority: when the authority's *output* is malformed, we treat that as weak "suspicious" evidence rather than "allow." This is explicitly a parser-layer policy (what a parse-failed response means), not a new detector with independent blocking authority.

The pattern is the established one in the repo. Wrong alternatives rejected:

- **Bypass sharedIntelligence and spawn a per-InputGuard provider** — would duplicate config-resolution logic and produce a second source of truth for the backend choice. Rejected.
- **Keep direct `fetch` and add an `if (sharedIntelligence) skipFetch` branch at the call site** — brittle, would need duplication for every consumer. Rejected; the initial design's legacy-fallback version was a softer form of this and was rejected at iteration 2.
- **Make `IntelligenceProvider` mandatory at the type level** — would produce TypeScript noise in test fixtures for no real-world gain. The guard degrades loudly when no provider is available, which is the correct runtime behavior.

---

## 4. Signal vs authority compliance

**Required reference:** [docs/signal-vs-authority.md](../../docs/signal-vs-authority.md)

**Does this change hold blocking authority with brittle logic?**

- [x] No — the existing Layer 2 authority (LLM-backed topic-coherence reviewer) is preserved verbatim at the prompt level. The change modifies the transport used to reach it and the interpretation of a parse-failed response. No brittle check is acquiring blocking authority.

Narrative: InputGuard Layer 2 *is* an authority in the signal-vs-authority sense — it's the LLM-backed gate that evaluates topic coherence with full context. That authority is unchanged. Layers 1 (provenance tags) and 1.5 (injection-pattern regex) are detectors that operate in warn-only mode and feed signals; they're untouched by this change. The new `DegradationReporter.report(...)` call is a pure observability signal with no blocking surface.

The fail-closed-to-warn change on parse errors is authority-adjacent, not a new authority: we are defining what a parse-failed response means. Under warn-only action, this produces a non-blocking warning; the session model remains the decision-maker. A brittle parser is not acquiring blocking authority; it is mapping authority-absence to the safer of the two warn-only outcomes.

No brittle check acquires blocking authority. No existing authority is being replaced with a brittle check. The principle holds.

---

## 5. Interactions

- **Shadowing:** `InputGuard` construction moved in `startServer()` from ~line 1988 to ~line 2069 (after `sharedIntelligence` is initialized). Manually inspected the intervening 80 lines: none of them invoke the input guard. `sessionManager.setInputGuard()` is the only consumer, and `SessionManager` guards every access with `if (this.inputGuard)` — so even if a message arrived pre-wiring, it would pass through (prior behavior), not crash.
- **Double-fire:** `reviewTopicCoherence()` runs exactly once per incoming cross-topic message, as before. There is no longer a second branch (the fetch path is gone), so double-fire is structurally impossible.
- **Races:** Pure synchronous construction during startup. `sharedIntelligence` is created-and-stored before `InputGuard` is constructed. No lifecycle races.
- **Timeout interaction:** the effective timeout is `max(config.reviewTimeout ?? 0, 8000ms)`. An operator who explicitly sets `reviewTimeout: 3000` in `instar.json` will get 8000ms at runtime — this is documented in the `InputGuardConfig.reviewTimeout` JSDoc with rationale. The floor cannot be overridden downward by design; the direct-HTTPS path that could meaningfully use a 3000ms budget no longer exists.
- **Feedback loops:** The new `DegradationReporter.report()` event feeds console + disk + Telegram + feedback. None of those sinks feed back into the IntelligenceProvider initialization. No loop.

---

## 6. External surfaces

- **Other agents on the same machine:** None. InputGuard is per-server.
- **Other users of the install base:** On first upgrade, machines with Claude CLI will begin seeing Layer 2 topic-coherence warnings they weren't seeing before. This is the *intended* behavior — the previous silent no-op was the bug. Machines with `ANTHROPIC_API_KEY` but no provider will degrade to provenance+patterns only (no LLM review), with a startup `DegradationReporter` event — these machines were previously taking the direct-fetch path that is now removed. The practical impact is narrow: server startup already tries to construct `AnthropicIntelligenceProvider.fromEnv()` as a last-resort in the sharedIntelligence init (server.ts:2031), so a machine with just an API key gets `sharedIntelligence` populated there, and the InputGuard gets the provider. The only case where the new "no review" path is reached is machines with neither CLI nor a reachable API key — the same machines that previously had silent no-op.
- **External systems:** No new egress. When routed via `ClaudeCliIntelligenceProvider`, Layer 2 uses the local Claude CLI subprocess (which calls Anthropic with the user's subscription credentials, no billing impact). Direct `api.anthropic.com` egress from InputGuard is removed.
- **Persistent state:** No schema changes. `security.jsonl` format unchanged. `topic-session-registry.json` unchanged.
- **Startup log format:** The `Input Guard: enabled (...)` line gained a backend suffix: `via shared IntelligenceProvider` | `provenance + patterns only (no LLM review)` (the `via Anthropic API (direct)` line is gone). Any log-scraper parsing this line for an exact string would need to accept the suffix. (No known scraper depends on it.)
- **Timing/runtime:** Review timeout on the CLI path is floored at 8s — longer than the pre-change 3000ms default on machines that had API keys. Under `warn` action (default) this is benign; no user-visible latency regression because the review is on the incoming-message path, not the agent's response path.

---

## 7. Rollback cost

Pure code change. Revert the commit, ship as a patch. No persistent state migration. No agent state repair. After rollback, CLI-only machines return to the pre-change silent no-op; API-key-only machines return to the direct-fetch path (no behavioral regression). Effort: one revert commit + one release bump, <10 minutes.

---

## Conclusion

This review produced substantive design changes via the `/spec-converge` iterative review:

- Iteration 1 surfaced 12 material findings across 4 reviewer perspectives. The most consequential were the deterministic malformed-JSON bypass (adversarial HIGH #1) and the subscription→billed-API silent downgrade (adversarial MEDIUM #3). Both are closed in the converged design by dropping the apiKey fallback entirely and hardening the parse-failure path.
- Iteration 2 surfaced 5 LOW findings, all addressed or explicitly acknowledged. The actionable ones (timeout semantics) were resolved via code + spec alignment and a JSDoc on `InputGuardConfig.reviewTimeout`.

Tests: 6 regression cases in `InputGuard.test.ts`, all 35 unit + 36 e2e tests green. TypeScript clean. Full convergence report at `docs/specs/reports/subscription-first-input-guard-convergence.md`.

Clear to ship.

---

## Second-pass review

Converged via `/spec-converge` multi-reviewer process (2 iterations, 4 internal reviewers × 2 rounds = 8 independent audits). Full finding-by-finding resolution in the convergence report. Final verdict: zero material findings in iteration 2; remaining LOW items addressed or explicitly deferred with rationale.

---

## Evidence pointers

- Pre-fix behavior: commit `bacf7fc` on `src/core/InputGuard.ts` shows `if (!this.config.topicCoherenceReview || !this.apiKey) return { verdict: 'coherent', ... }` — the silent no-op, and the direct fetch path to `api.anthropic.com` below it.
- Post-fix behavior: `src/core/InputGuard.ts` has no `apiKey` field, no `fetch` call, and `parseReviewResponse()` returns suspicious on malformed JSON.
- Audit evidence: `grep -n "ANTHROPIC_API_KEY" src/` — 4 runtime reads walked; only `server.ts:1990` (now removed) was a real bypass.
- Convergence report: `docs/specs/reports/subscription-first-input-guard-convergence.md` (full iteration log and finding resolutions).
- Spec: `docs/specs/subscription-first-input-guard.md` (tagged `review-convergence` + `approved: true` post-convergence).
