# Side-Effects Review — Correction & Preference Learning Sentinel, Slice 2

**Version / slug:** `correction-preference-learning-sentinel-slice2`
**Date:** `2026-05-30`
**Author:** `echo`
**Second-pass reviewer:** `not required` (signal-only, ships dark; read-only dashboard surface; see §7)

## Summary of the change

Slice 2 adds the human read surface and the scalability/closed-loop hardening on top of the shipped Slice 1a/1b loop:

- **Dashboard "Preferences" tab** — a calm, plain-language read-only surface (`dashboard/preferences-learning.js` + tab wiring in `dashboard/index.html`) mirroring the Process Health tab precedent. It shows the active learned preferences (from `/preferences/session-context`) and the recent scrubbed corrections (from `/corrections`). No new server route — it consumes the two existing Slice-1a/1b endpoints.
- **`/corrections` `?since` pagination** — a lower-bound (`detected_at >= since`) alongside the existing `?before` keyset cursor + `?limit`.
- **`CorrectionLoopDriver` hardening** — a per-tick add ceiling (`maxRoutesPerTick`, default 5; overflow stays `open`, re-routes next run), batched + rate-limit-aware loopback feedback (`feedbackPostDelayMs`, default 7000; stops on the first 429 and carries the rest), and the infra-gap closed-loop verify (reopen-on-recurrence with the 14-day window; silence → inconclusive, never verified).
- **`CorrectionLedger` composite index** `idx_corr_dedupe_day` backing the distinct-calendar-days recurrence count.
- **Drift-canary sub-budget** — a dedicated `LlmQueue` instance with its own small daily cap (`driftCanaryDailyCents`, default 5) so the canary can never starve the main 25¢ distill cap.

The loop is still **SIGNAL-ONLY** (it never blocks or rewrites an outbound message) and still ships **dark** behind `monitoring.correctionLearning.enabled`. The new dashboard tab is read-only.

**Files touched:**
- `dashboard/preferences-learning.js` (new — pure renderers + polling controller), `dashboard/index.html` (tab button + panel + registry entry + lazy-load wiring).
- `src/monitoring/CorrectionLedger.ts` (composite index + `listOccurrenceIndexes()`), `src/monitoring/CorrectionLoopDriver.ts` (per-tick ceiling, batched/429-retry, infra-gap verify + audit).
- `src/server/routes.ts` (`?since`; structured `feedbackLoopbackPost`; `maxRoutesPerTick`/`feedbackPostDelayMs`/audit wiring; `routed.overflow`/`rateLimited` response), `src/commands/server.ts` (drift-canary sub-budget LlmQueue).
- `src/core/types.ts` + `src/config/ConfigDefaults.ts` (`driftCanaryDailyCents`, `maxRoutesPerTick`, `feedbackPostDelayMs` + defaults).
- `src/server/CapabilityIndex.ts` (`?since` + dashboard tab), `src/scaffold/templates.ts` + `src/core/PostUpdateMigrator.ts` (dashboard tab awareness + idempotent backfill), `src/data/builtin-manifest.json` (regenerated by build).
- `site/src/content/docs/features/correction-preference-learning.md` (Preferences tab + API + Scalability bounds + infra-gap verify).
- `tests/unit/CorrectionLedger.test.ts`, `tests/unit/CorrectionLoopDriver.test.ts`, `tests/unit/preferences-learning-render.test.ts` (new), `tests/integration/corrections-routes.test.ts`, `tests/e2e/preferences-learning-tab-lifecycle.test.ts` (new).
- `upgrades/NEXT.md`, this side-effects artifact.

## Decision-point inventory

- **Dashboard tab** — *read-only render*. Pure data→DOM with `sanitizeForDisplay` + textContent-only writes. No authority; cannot mutate anything.
- **Per-tick add ceiling** — *bounded throughput, code-determined*. Caps routed records/run; overflow stays `open` and re-routes next run. Cannot drop a learning.
- **429-retry batch control** — *carry, not drop*. A rate-limited feedback POST leaves the record `open` for the next run. Cannot silently lose a learning.
- **Drift-canary sub-budget** — *resource isolation*. A separate `LlmQueue` instance; cannot affect routing or authority.
- **Infra-gap verify verdict** — *signal*. Silence → `inconclusive` (never a self-attributed "verified"); recurrence → reopen (capped). No authority change.

## 1. Over-block

No block/allow surface — over-block not applicable. The dashboard tab is read-only; the driver still never blocks a message, tool call, or user. The per-tick ceiling bounds throughput but never blocks a learning — overflow is carried, not dropped.

## 2. Under-block

No block/allow surface — under-block not applicable. The closest analog is a learning deferred to the next run by the per-tick ceiling or a 429; in both cases the record stays `open` and re-routes — it is delayed, never lost, never an unsafe action.

## 3. Level-of-abstraction fit

Right layer. The dashboard tab is a self-contained browser-native ESM module + index.html wiring, mirroring the Process Health tab precedent exactly (lazy dynamic-import, registry entry with onActivate/onDeactivate, reused `ph-*` calm-type CSS, `sanitizeForDisplay` + textContent-only safety). The driver/ledger/config changes sit in the same `src/monitoring/` + `src/config/` modules as the rest of the loop. No new server route was added — the tab consumes the existing Slice-1a/1b endpoints (the discoverability-lint allowlist is unchanged).

## 4. Signal vs authority compliance

**Required reference:** docs/signal-vs-authority.md

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

- [x] **No — every output is a signal consumed by an existing smart gate or a human, or a read-only render.** The dashboard tab is read-only. The driver's authority surface is unchanged from Slice 1b: `CorrectionLoopDeps` still carries exactly `addAction`, `createInitiative`, `feedbackLoopbackPost`, `recordPreference`, `attentionRoute` — Slice 2 adds only bounding parameters (`maxRoutesPerTick`, `feedbackPostDelayMs`, `sleep`, `audit`) and a richer return type on `feedbackLoopbackPost` (`{posted, rateLimited}`), none of which add a mutation capability. The existing by-construction authority test still holds (no proposal-mint, no memory-write field). The infra-gap verify verdict is signal-only and deliberately refuses to self-attribute a "verified" the agent cannot prove.

## 5. Interactions

- **Shadowing:** the dashboard tab registry entry chains after Process Health; it has its own panel ids (`pl*`) so it cannot shadow the Process Health tab (`ph*`). The lazy-load controller is independent.
- **Double-fire:** the per-tick ceiling + the analyzer's `status:'open'` filter mean a record routes once; a record left as overflow stays `open` and is re-evaluated next run (idempotent — the second-run test pins it routes the carried-over overflow exactly once). The 429 path leaves the record `open` (never marked `acted-on`), so it re-routes next run without duplicating.
- **Races:** the driver is single-threaded per `route()` call. The composite index does not change `distinctCounts` results (a test pins identical day counts) — it only speeds the query. The drift-canary `LlmQueue` is a separate instance; it cannot race the main distill queue's cap.
- **Feedback loops:** the batched feedback POSTs serialize with a delay and stop on the first 429, so the loop cannot hammer its own `/feedback` route. The dashboard tab polls read-only (`/preferences/session-context` + `/corrections`) on a 60s cadence, visibility-gated and abort-safe — it cannot re-enter the capture path.
- **Startup ordering:** the drift-canary sub-budget LlmQueue is constructed inside the existing capture-loop boot block, only when `driftCanary` is on (it ships dark, so normally null). The dashboard files ship with the package and are served by `express.static` — no boot wiring.

## 6. External surfaces

- **Other agents on the same machine:** none. No change to the cross-agent (Rising Tide) path beyond the batched/429 serialization, which only makes the agent a politer client of its own `/feedback` route.
- **Other users of the install base:** purely additive and dark. Existing agents that don't flip `monitoring.correctionLearning.enabled` see byte-identical behavior. The new config keys (`driftCanaryDailyCents`, `maxRoutesPerTick`, `feedbackPostDelayMs`) backfill via ConfigDefaults deep-merge with safe defaults. The dashboard tab appears on the next update (package-served) but renders the friendly disabled state until the flag is on.
- **External systems:** the drift-canary sub-budget provisions an additional small daily LLM spend cap (default 5¢, only when the canary is enabled — dark by default). No new egress beyond what Slice 1b disclosed; the canary is not yet wired to fire (its budget is provisioned ahead of the sampler).
- **Persistent state:** new composite index in the existing `correction-ledger.db` (no new file; created idempotently via `CREATE INDEX IF NOT EXISTS`). The driver's audit hook appends route/verify decisions to the existing `<stateDir>/logs/correction-learning-audit.jsonl` (metadata only, `origin: correction-loop`). No new persistent state.
- **Timing:** the per-tick ceiling and the inter-POST delay are bounded and only apply during the off-hot-path weekly analyzer run. The dashboard polling is read-only and visibility-gated.

## 7. Rollback cost

- **Code:** flip `monitoring.correctionLearning.enabled` back to false (or revert the commits). With the flag off, the `/corrections` + `/preferences/session-context` routes 503, the dashboard tab renders the disabled state, and the driver is not wired.
- **Persistent state:** the composite index is harmless when the feature is off (read by nothing). The audit JSONL stays on disk, read by nothing. No new state file to remove.
- **Dashboard:** the tab is package-served; reverting the `dashboard/` changes removes it. There is no per-agent dashboard migration to undo.
- **Agent state repair:** none required.
- **User visibility during rollback:** none — the feature is dark by default and the tab shows the disabled state.

Total rollback time: under 1 minute (flip one config flag, or revert the commits).

## Conclusion

Read-only dashboard surface + bounding parameters + an index + a sub-budget — no new blocking authority, no new mutation capability, no new egress beyond the (dark) canary budget. The per-tick ceiling and 429-retry carry learnings rather than dropping them; the infra-gap verify refuses to self-attribute a "verified". The dashboard tab mirrors the established Process Health precedent (sanitize + textContent-only) so raw text can never reach the DOM. Signal-only, dark-by-default, and fully bounded.
