// plan-chat-service — host-cp sidecar serving two HTTP surfaces on one port:
//
//   POST /v1/chunks         — bearer-auth write API; server-injects actor_id +
//                             actor_type from the bearer principal, validates the
//                             payload, INSERTs into a source Postgres.
//   GET  /v1/shape          — bearer-auth read API; requires (world_id,
//                             session_id) query params; rewrites the upstream
//                             Electric `where` clause server-side so a caller
//                             can never enumerate cross-world chunks (PB2).
//                             Streams the upstream response without buffering
//                             so Electric's `live=true` long-poll round-trips
//                             stay reactive (PB1). Phase B B1 closes both
//                             A2-CP3 audit HIGHs.
//   GET  /livez             — liveness probe (200 on healthy; matches the
//                             agent-memory-service shape so host-cp uses one
//                             probe pattern for both sidecars).
//
// The service is started by `olam plan-chat start` (deferred follow-up task)
// and managed by host-cp's process supervisor in the long run. For now it is
// runnable standalone via `node plan-chat-service.mjs`.
//
// Configuration is environment-driven so a single binary works in laptop
// (the K3 container-spike), in a devbox container, and on a host-cp Mac:
//
//   OLAM_PLAN_CHAT_PORT          (default 3200; moved off 3112 — see
//                                 olam-chunks-subscriber-long-poll Phase A.
//                                 agentmemory iii/node claim 3111-3113.)
//   OLAM_PLAN_CHAT_DATABASE_URL  (default postgres://postgres:spike@localhost:54321/chunks)
//   OLAM_PLAN_CHAT_ELECTRIC_URL  (default http://localhost:30001)
//   OLAM_PLAN_CHAT_SECRET_PATH   (default ~/.olam/plan-chat-secret)

import http from 'node:http';
import { performance } from 'node:perf_hooks';
import { Readable } from 'node:stream';
import { URL } from 'node:url';
import pg from 'pg';
import { SCHEMA_SQL } from '@olam/chunks/schema';
import { ensureSecret, timingSafeEqual, SECRET_PATH } from './plan-chat-secret.mjs';
import {
  listPlanningSessions,
  createDispatchSession,
  claimDispatchTurnLock,
  clearDispatchTurnLock,
  getDispatchSession,
  haltDispatchSession,
  reactivateDispatchSession,
  listDispatchSessions,
} from './planning-sessions.mjs';
import { crystallizePlanningSession } from './crystallize-planning.mjs';
import { resolveId, RESOLVE_ID_RE, createRateLimiter } from './resolver.mjs';

const DEFAULT_PORT = 3200;
const DEFAULT_DB_URL = 'postgres://postgres:spike@localhost:54321/chunks';
const DEFAULT_ELECTRIC_URL = 'http://localhost:30001';

const ACTOR_TYPES = new Set(['agent', 'operator', 'codex', 'system']);
const ROLES = new Set(['user', 'assistant', 'tool', 'system']);
// B3 schema-v2: expanded chunk_type enum mirrors @olam/chunks/src/schema.ts
// CHUNK_TYPES. host-cp duplicates the Set rather than importing because
// this file is plain JS .mjs and the chunks package ships as TS — keeping
// them in sync is the audit:do-schema-parity-style discipline (cross-
// package check queued as a B3 follow-up; for now the parity is by
// inspection + the integration test in __tests__/chunk-type-validator).
const CHUNK_TYPES = new Set([
  'text',
  'tool_use',
  'goal_mode_assumption',
  'dispatch_overflow',
]);

// PB2 — scope-ID shape. world_id + session_id query params are interpolated
// into the upstream Electric `where` clause; the regex IS the SQL-injection
// defence (single-quotes, semicolons, comments can't pass). Same shape as
// @olam/chunks schema accepts and as measure-in-world.mjs validates. Bump
// only if the chunks schema bumps; the two must move together.
const SCOPE_ID_RE = /^[A-Za-z0-9_.-]+$/;

// B5 (plan-chat-context-window-display Phase B): tables allowlisted for
// /v1/shape. Only these tables have server-side where-rewrite support; any
// other table=... param gets a 400. Guards against a client enumerating
// tables the service doesn't own.
// planning_sessions added in Phase C C-S1 so useDispatchSessions can subscribe
// via Electric long-poll. The handleGetShape `where` rewriter uses world_id +
// session_id; planning_sessions rows carry world_id (added in Phase A A1).
const ALLOWED_SHAPE_TABLES = new Set(['chunks', 'message_usage', 'planning_artifacts', 'planning_sessions']);

// B6 (plan-chat-context-window-display Phase B): context-window caps per
// model. Mirrors CONTEXT_CAPS from @olam/intelligence/src/llm-router/providers/claude.ts.
// Host-cp doesn't depend on intelligence, so this is a local copy. Keep in
// sync with the canonical table (audit:pricing-coverage checks coverage).
// Default: 200k. 1M only for claude-opus-4-7[1m].
const CONTEXT_CAPS_LOCAL = {
  'claude-sonnet-4-6': 200_000,
  'claude-haiku-4-5-20251001': 200_000,
  'claude-opus-4-7': 200_000,
  'claude-opus-4-7[1m]': 1_000_000,
};
const DEFAULT_CONTEXT_CAP = 200_000;
const THRESHOLD_PCT = 80; // emit dedup'd system chunk when ctx ≥ this %

// Sentinel prefix on threshold system chunks so the dedup query can find them.
// Keep this stable — changing it makes old dedup rows invisible to the guard.
const THRESHOLD_CHUNK_PREFIX = 'Context approaching compaction threshold:';

// PB2 — query params the server OWNS on /v1/shape. All three are stripped
// from what we forward to upstream Electric:
//   - `world_id` + `session_id` are SEEDS for the server-derived `where`
//     clause; Electric doesn't read them natively, so dropping them from
//     the upstream URL is a no-op for Electric semantics. (Per CP3-B1-M2:
//     this drop is intentional — if a future Electric version adopts these
//     as native scope params, split this set into SEEDS vs DROPPED.)
//   - `where` is the actual SQL predicate; we drop the client's value and
//     replace with the server-derived one. Operator can't bypass scope by
//     passing their own.
// Everything else (`table`, `offset`, `handle`, `live`, future Electric
// params) forwards through verbatim.
const SCOPED_QUERY_PARAMS = new Set(['world_id', 'session_id', 'where']);

/**
 * Resolve the bearer principal to (actor_id, actor_type). The minimal-A2
 * implementation treats every authenticated bearer as the same shared
 * principal — refinement to multi-principal mapping lands when host-cp's
 * auth-service integrates (Phase A follow-up, tracked at A6.1 / A4 wiring).
 *
 * Callers MUST never use client-supplied actor_id / actor_type; this
 * function is the single source of authority. T3 mitigation depends on it.
 *
 * IMPORTANT — silent-collapse safeguard (audit finding A2.M3, 2026-05-13):
 * when A4 begins configuring multiple secrets (e.g., agent + operator +
 * codex bearers), this single-principal collapse becomes a foot-gun: every
 * caller's writes are attributed to 'agent', regardless of which secret
 * they authenticated with, and S3 audit-replay attributes operator actions
 * to agent. The check below fails LOUD when more than one secret is
 * configured, so an A4 multi-secret rollout cannot land until principal
 * resolution is wired here. Remove the throw when this function actually
 * inspects the bearer.
 */
/**
 * G1-demo lift (2026-05-16): trust the client-supplied `actor_type` when
 * present. The original A2 design hardcoded ('agent','agent') with T3
 * mitigation explicitly requiring server-injection — but that collapsed
 * the operator + driver + codex roles into one and broke the driver
 * pickup loop (which filters on `actor_type === 'operator'`).
 *
 * Single-operator local mode has no multi-tenant threat, so trusting the
 * client field is safe pragmatic. The original silent-collapse safeguard
 * for OLAM_PLAN_CHAT_SECRETS stays — multi-secret mode still needs A4
 * proper bearer→principal resolution before it can land.
 */
/**
 * F3 (T9) — operator-chunk broker gate. The `actor_type='operator'` write
 * capability is a session-STEERING authority: an operator chunk feeds the
 * driver's next reasoning iteration (operator-subscription.ts filters on
 * `actor_type === 'operator'`). Before this gate, principalFromBearer trusted
 * the client-supplied actor_type, so ANY plan-chat bearer holder — INCLUDING a
 * world process running inside a container — could POST `actor_type:'operator'`
 * and steer a session directly, bypassing the broker's auth + single-use state
 * machine + audit (the "most subtle" threat, plan T9).
 *
 * The gate: an `operator` write must additionally present the broker secret
 * (`X-Olam-Broker-Secret`, timing-safe-compared against the configured
 * `operatorChunkSecret`). Only the broker's answer-return path (and the
 * operator's own trusted BFF, which the operator configures with the secret)
 * holds it; world processes never do, so they can no longer forge operator
 * steering. Agent/codex/system writes are unaffected (no secret required).
 *
 * Backward-compatibility (NO silent hole): when `operatorChunkSecret` is
 * UNCONFIGURED, operator writes are still allowed BUT every such write emits a
 * LOUD structured warning so the ungated state is visible — never silent. This
 * mirrors the existing OLAM_PLAN_CHAT_SECRETS silent-collapse safeguard in this
 * same file. Configure OLAM_OPERATOR_CHUNK_SECRET to enforce the gate.
 *
 * Returns:
 *   { allowed: true }                       — write may proceed
 *   { allowed: true, ungated: true }        — proceeded but secret unconfigured (warn)
 *   { allowed: false, reason: 'forbidden' } — operator write without the secret (reject 403)
 *
 * @param {string} actorType — the resolved actor_type for the write.
 * @param {import('node:http').IncomingMessage} req — for the header read.
 * @param {string|undefined} operatorChunkSecret — the configured broker secret.
 */
function checkOperatorChunkGate(actorType, req, operatorChunkSecret) {
  if (actorType !== 'operator') {
    // Non-operator writes (agent / codex / system) are never gated.
    return { allowed: true };
  }
  if (typeof operatorChunkSecret !== 'string' || operatorChunkSecret.length === 0) {
    // Gate not configured — allow but surface LOUD so the ungated authority
    // path is never silent. Operators close this by setting the secret.
    return { allowed: true, ungated: true };
  }
  const supplied = req.headers['x-olam-broker-secret'];
  if (typeof supplied === 'string' && timingSafeEqual(supplied, operatorChunkSecret)) {
    return { allowed: true };
  }
  return { allowed: false, reason: 'forbidden' };
}

function principalFromBearer(bearer, body) {
  if (process.env.OLAM_PLAN_CHAT_SECRETS) {
    throw new Error(
      'plan-chat-service: OLAM_PLAN_CHAT_SECRETS is set but principalFromBearer ' +
        "still trusts client-supplied actor_type. Wire bearer→principal " +
        'resolution before enabling multi-secret mode (see plan A4 / A6.1).',
    );
  }
  if (typeof bearer !== 'string' || bearer.length === 0) {
    throw new Error('plan-chat-service: principalFromBearer called with empty bearer');
  }
  // Trust client field if supplied (SPA sends 'operator'; SDK adapter omits
  // and inherits 'agent' default; codex-runner sends 'codex').
  const claimed = body && typeof body.actor_type === 'string' && body.actor_type.length > 0
    ? body.actor_type
    : 'agent';
  const claimedId = body && typeof body.actor_id === 'string' && body.actor_id.length > 0
    ? body.actor_id
    : claimed;
  return { actorId: claimedId, actorType: claimed };
}

function readJson(req, limit = 65536) {
  return new Promise((resolve, reject) => {
    const chunks = [];
    let length = 0;
    req.on('data', (chunk) => {
      length += chunk.length;
      if (length > limit) {
        reject(Object.assign(new Error('payload-too-large'), { status: 413 }));
        req.destroy();
        return;
      }
      chunks.push(chunk);
    });
    req.on('end', () => {
      try {
        const body = Buffer.concat(chunks).toString('utf8');
        if (!body) return resolve({});
        resolve(JSON.parse(body));
      } catch (_err) {
        reject(Object.assign(new Error('invalid-json'), { status: 400 }));
      }
    });
    req.on('error', reject);
  });
}

function send(res, status, body) {
  const json = typeof body === 'string' ? body : JSON.stringify(body);
  res.writeHead(status, {
    'content-type': typeof body === 'string' ? 'text/plain; charset=utf-8' : 'application/json',
    'content-length': Buffer.byteLength(json),
  });
  res.end(json);
}

function unauthorized(res) {
  send(res, 401, { error: 'unauthorized' });
}

function badRequest(res, message) {
  send(res, 400, { error: 'bad-request', message });
}

function validateChunkInput(body) {
  if (!body || typeof body !== 'object') return 'body must be an object';
  const required = ['world_id', 'session_id', 'message_id', 'seq', 'role', 'chunk'];
  for (const key of required) {
    if (!(key in body)) return `missing field: ${key}`;
  }
  if (typeof body.world_id !== 'string' || body.world_id.length === 0) return 'world_id must be a non-empty string';
  if (typeof body.session_id !== 'string' || body.session_id.length === 0) return 'session_id must be a non-empty string';
  if (typeof body.message_id !== 'string' || body.message_id.length === 0) return 'message_id must be a non-empty string';
  if (!Number.isInteger(body.seq) || body.seq < 0) return 'seq must be a non-negative integer';
  if (typeof body.chunk !== 'string') return 'chunk must be a string';
  if (!ROLES.has(body.role)) return `role must be one of ${[...ROLES].join(', ')}`;
  // Optional chunk_type field — defaults to 'text' when absent.
  if ('chunk_type' in body && !CHUNK_TYPES.has(body.chunk_type)) {
    return `chunk_type must be one of ${[...CHUNK_TYPES].join(', ')}`;
  }
  // actor_id and actor_type from the client are IGNORED (server-derived).
  return null;
}

// P1 — promote idempotency window. Keyed by chunk_id; value is
// { worldId, specPath, promoteId, createdAt }. Entries expire after
// PROMOTE_DEDUPE_TTL_MS so a genuine re-promote (after operator intent)
// is allowed rather than silently re-routing to the original world.
// In-memory only: a process restart clears the map. For v1 this is
// sufficient — the same Linear-retry window (seconds) is far shorter
// than the TTL (30s). Persistent dedup (across restarts / multi-replica)
// is deferred to U2.
const PROMOTE_DEDUPE_TTL_MS = 30_000;
const _promoteDedupe = new Map(); // chunk_id → { worldId, specPath, promoteId, createdAt }

/**
 * Build the HTTP request handler. Pure factory — easy to test against a
 * stubbed pool. Production callers pass a real pg.Pool.
 *
 * Phase A A2 (olam-spa-electric-subscription-staleness): when `shapeDebug`
 * is true OR `OLAM_PLAN_CHAT_SHAPE_DEBUG=1` is set in the environment,
 * `handleGetShape` emits two structured log lines per long-poll cycle —
 * one BEFORE the upstream fetch (with the rewritten `where` predicate +
 * forwarded params) and one AFTER (with upstream status + electric-*
 * response headers). Log lines go to `shapeDebugLog` (default
 * `console.error`); tests inject a spy. Flag defaults OFF — production
 * shapes incur a single boolean check per cycle and zero log I/O.
 */
export function createHandler({
  pool,
  bearer,
  electricUrl,
  shapeDebug,
  shapeDebugLog,
  createWorld,
  destroyWorld,
  /**
   * P1 — injectable dispatchTask callback. Accepts { worldId, containerName,
   * task, tier } and dispatches the task to the newly-created world.
   * Production callers wire in autoDispatchTask from @olam/core; tests inject
   * a stub. When omitted, the promote endpoint returns 501.
   *
   * Tier routing: the caller is responsible for reading compute.default
   * from the workspace config and passing it as opts.tier when applicable.
   * This keeps plan-chat-service free from workspace-config coupling.
   */
  dispatchTask,
  /** B4 — optional override for tests. When supplied, replaces principalFromBearer
   *  so the test harness can inject a hardcoded server-resolved actor_id and verify
   *  the mismatch guard. Production callers omit this. */
  resolveActor,
  /** Phase A A1 — optional override for tests. When supplied, replaces the
   *  default per-bearer rate limiter (60 req/min). Tests inject a stub with
   *  lower capacity to exercise the 429 path quickly. Production callers omit. */
  rateLimiter,
  /**
   * F3 (T9) — broker secret that gates `actor_type='operator'` chunk writes.
   * When set (string), an operator-chunk write MUST present a matching
   * `X-Olam-Broker-Secret` header; a world process holding only the plan-chat
   * bearer cannot forge operator steering. When omitted, defaults to
   * `process.env.OLAM_OPERATOR_CHUNK_SECRET`. When neither is set, operator
   * writes are allowed but every one emits a LOUD warning (no silent hole).
   * Tests inject an explicit value to exercise the enforced path.
   */
  operatorChunkSecret = process.env.OLAM_OPERATOR_CHUNK_SECRET,
  /**
   * P1 — override the dedupe map for tests. Allows tests to pre-populate
   * or inspect the idempotency store. Production callers omit this.
   */
  _promoteDedupe: promoteDedupe = _promoteDedupe,
  /** multi-turn-cloud-sandbox-dispatch Phase A3 — optional override for tests.
   *  When supplied, replaces the default plan-DO forward path so tests can
   *  inject a stub returning a fake sandbox_session_id without exercising the
   *  real cloud-dispatch path. Production callers omit; A6 wires the real
   *  forward. */
  dispatchTurnForward,
}) {
  if (!pool) throw new Error('createHandler: { pool } required');
  if (typeof bearer !== 'string' || bearer.length === 0) {
    throw new Error('createHandler: { bearer } required');
  }
  const electricBase = electricUrl ?? DEFAULT_ELECTRIC_URL;
  const shapeDebugEnabled =
    typeof shapeDebug === 'boolean'
      ? shapeDebug
      : process.env.OLAM_PLAN_CHAT_SHAPE_DEBUG === '1';
  const shapeLog =
    typeof shapeDebugLog === 'function'
      ? shapeDebugLog
      : (msg, details) => console.error(msg, details);

  // Phase A A1 — per-bearer rate limiter for /v1/resolve. Single token
  // bucket keyed by the bearer string; capacity 60, refilled at 60/min.
  // Closes the brute-force enumeration vector (bearer auth alone keeps
  // unauthenticated probes out, but an authenticated caller could grind
  // through ids at line-rate without the bucket).
  const resolveLimiter = rateLimiter ?? createRateLimiter({ capacity: 60, windowMs: 60_000 });

  /**
   * Default plan-DO forward — used by handlePostDispatchTurn when no
   * test-injected stub is provided.
   *
   * Phase B follow-up (operator E2E directive 2026-05-27: full SPA→plan-DO→CF
   * chain test): when OLAM_CLOUD_URL + OLAM_SHOWCASE_PASSWORD are set, this
   * function POSTs the turn payload to plan-DO at
   * `${OLAM_CLOUD_URL}/v1/dispatch?plan_id=<session_id>` with showcase Basic
   * auth (mirrors server.mjs /api/cloud-dispatch's existing forward shape).
   * Plan-DO then spawns the CF Sandbox container; chunks flow back to host-cp
   * via the cloudflared tunnel + the in-container chunk-poster pattern
   * (PR #1165 substrate).
   *
   * When the envs are absent (typical for unit-test setups), the function
   * throws — operators MUST set both envs to enable real CF dispatch. The
   * existing `dispatchTurnForward` opt remains available for tests that
   * want to inject a stub returning a fake sandbox_session_id.
   *
   * @param {object} ctx
   * @param {{ session_id: string, world_id: string|null, actor_id: string }} ctx.session
   * @param {string} ctx.turn_id
   * @param {string} ctx.prompt
   * @param {string} ctx.conversation_preamble — Phase B fills this; empty in Phase A
   * @returns {Promise<{ sandbox_session_id: string | null }>}
   */
  async function defaultDispatchTurnForward(ctx) {
    const cloudUrl = process.env.OLAM_CLOUD_URL;
    const showcasePw = process.env.OLAM_SHOWCASE_PASSWORD;
    if (!cloudUrl || !showcasePw) {
      throw new Error(
        'dispatch-turn forward not wired: set OLAM_CLOUD_URL + OLAM_SHOWCASE_PASSWORD ' +
          'env vars (see /api/cloud-dispatch pattern in server.mjs) OR inject ' +
          '`dispatchTurnForward` via createHandler() opts for test stubs.',
      );
    }
    const planId = ctx.session.session_id;
    const basicAuth = Buffer.from(`operator:${showcasePw}`).toString('base64');
    // Mirror /api/cloud-dispatch's enriched body: include the operator's
    // anthropicBaseUrl (so spawned CF Sandbox child worlds can reach the
    // auth-Worker), the operator's repoUrl (for the in-container clone),
    // plus the new multi-turn fields (turn_id, conversation_preamble).
    // anthropicBaseUrl + repoUrl are read from ~/.olam/* by the existing
    // host-cp pattern; here we just forward whatever the SPA passed (the
    // SPA may have included them) — and rely on plan-DO's own enrichment
    // for absent fields.
    const body = JSON.stringify({
      world_id: ctx.session.world_id,
      session_id: ctx.session.session_id,
      actor_id: ctx.session.actor_id,
      turn_id: ctx.turn_id,
      prompt: ctx.prompt,
      conversation_preamble: ctx.conversation_preamble ?? '',
    });
    const upstream = await fetch(
      `${cloudUrl.replace(/\/+$/, '')}/v1/dispatch?plan_id=${encodeURIComponent(planId)}`,
      {
        method: 'POST',
        headers: {
          'Authorization': `Basic ${basicAuth}`,
          'content-type': 'application/json',
        },
        body,
      },
    );
    if (!upstream.ok) {
      const errBody = await upstream.text().catch(() => '');
      throw new Error(
        `plan-DO /v1/dispatch returned ${upstream.status}: ${errBody.slice(0, 200)}`,
      );
    }
    // plan-DO returns { sandbox_session_id, ... } on success per PR #1165
    // (see packages/plan-agent-do/src/dispatch-sandbox-agent.ts). We pluck
    // sandbox_session_id for the SPA's session-thread render; everything
    // else flows back via the in-container chunk-poster → host-cp
    // /api/plan-chat/v1/chunks path (NOT through this return).
    let parsed;
    try {
      parsed = await upstream.json();
    } catch {
      parsed = {};
    }
    return { sandbox_session_id: parsed.sandbox_session_id ?? null };
  }

  function checkAuth(req) {
    const header = req.headers.authorization;
    if (typeof header !== 'string' || !header.startsWith('Bearer ')) return false;
    const supplied = header.slice('Bearer '.length).trim();
    return timingSafeEqual(supplied, bearer);
  }

  async function handlePostChunks(req, res) {
    if (!checkAuth(req)) return unauthorized(res);
    let body;
    try {
      body = await readJson(req);
    } catch (err) {
      return send(res, err?.status ?? 400, { error: err?.message ?? 'bad-request' });
    }
    const invalid = validateChunkInput(body);
    if (invalid) return badRequest(res, invalid);
    const principal = resolveActor
      ? resolveActor(bearer, body)
      : principalFromBearer(bearer, body);

    // F3 (T9) — operator-chunk broker gate. The plan-chat bearer authenticates
    // the connection, but writing an `operator` chunk is a session-STEERING
    // authority a world process must not hold. Require the broker secret for
    // operator writes (or reject); agent/codex/system writes pass untouched.
    const gate = checkOperatorChunkGate(
      principal.actorType,
      req,
      operatorChunkSecret,
    );
    if (!gate.allowed) {
      // LOUD: a caller tried to write an operator chunk without the broker
      // secret — a forgery attempt (T9). Reject 403; the session is NOT steered.
      console.error(
        JSON.stringify({
          event: 'chunk.operator_write_rejected',
          reason: 'broker_secret_required',
          world_id: body.world_id,
          session_id: body.session_id,
        }),
      );
      return send(res, 403, {
        error: 'forbidden',
        message:
          'operator chunk writes require the broker secret (X-Olam-Broker-Secret)',
      });
    }
    if (gate.ungated) {
      // Operator write allowed because no secret is configured — surface LOUD
      // so the ungated authority path is never silent. Operators close this by
      // setting OLAM_OPERATOR_CHUNK_SECRET.
      console.error(
        JSON.stringify({
          event: 'chunk.operator_write_ungated',
          hint: 'set OLAM_OPERATOR_CHUNK_SECRET to gate operator-chunk writes (F3/T9)',
          world_id: body.world_id,
          session_id: body.session_id,
        }),
      );
    }

    // B4 — mismatch guard: if the client explicitly supplied body.actor_id
    // and it differs from the server-resolved actor_id, reject with 400.
    // In single-secret G1-demo mode this never fires because principalFromBearer
    // echoes back body.actor_id. When Phase A4 lands multi-secret resolution,
    // this guard prevents a caller from spoofing a different actor.
    if (
      typeof body.actor_id === 'string' &&
      body.actor_id.length > 0 &&
      body.actor_id !== principal.actorId
    ) {
      return badRequest(
        res,
        `actor_id mismatch: client supplied '${body.actor_id}' but bearer resolves to '${principal.actorId}'`,
      );
    }

    try {
      await pool.query(
        `INSERT INTO chunks
           (world_id, session_id, message_id, seq, actor_id, actor_type, role, chunk, chunk_type)
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
        [
          body.world_id,
          body.session_id,
          body.message_id,
          body.seq,
          principal.actorId,
          principal.actorType,
          body.role,
          body.chunk,
          body.chunk_type ?? 'text',
        ],
      );

      // B4 — capture message_usage when the adapter forwarded token usage.
      // body.usage is optional (only present on assistant turns with SDK usage).
      //
      // perf: SQL-2 through SQL-5 (INSERT message_usage, SUM, dedup check,
      // INSERT threshold chunk) are batched inside a single BEGIN/COMMIT so
      // the 4 sequential round-trips share one connection checkout and one
      // server-side transaction flush instead of 4 independent pool checkouts.
      // SQL-1 (INSERT chunks above) stays outside the txn so its 23505 dedup
      // guard reaches the caller independently as a 409 — folding it in would
      // cause a threshold-chunk PK conflict to silently roll back the primary
      // chunk write.
      if (body.usage && typeof body.usage === 'object') {
        const usage = body.usage;
        const model = typeof body.model === 'string' && body.model.length > 0
          ? body.model
          : 'claude-sonnet-4-6';
        const client = await pool.connect();
        try {
          await client.query('BEGIN');

          // SQL-2 — INSERT message_usage
          await client.query(
            `INSERT INTO message_usage
               (world_id, session_id, message_id, actor_id, model,
                input_tokens, output_tokens, cache_read_tokens, cache_create_tokens)
             VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
             ON CONFLICT (message_id, actor_id) DO NOTHING`,
            [
              body.world_id,
              body.session_id,
              body.message_id,
              principal.actorId,
              model,
              Number(usage.input_tokens ?? 0),
              Number(usage.output_tokens ?? 0),
              Number(usage.cache_read_input_tokens ?? 0),
              Number(usage.cache_creation_input_tokens ?? 0),
            ],
          );

          // B6 — 80% threshold dedup'd system chunk.
          // After inserting message_usage, check if the per-actor cumulative
          // token count for this session has crossed 80% of the model's context
          // cap. If so, and no prior threshold chunk exists for (session_id,
          // actor_id), emit exactly one dedup'd system chunk.
          //
          // Token math: sum(input_tokens + cache_read_tokens + cache_create_tokens)
          // per (session_id, actor_id), matching Claude Code statusline.
          //
          // SQL-3 — SUM cumulative tokens (reads within the same txn so the
          // just-inserted usage row is visible without waiting for COMMIT).
          const contextCap = CONTEXT_CAPS_LOCAL[model] ?? DEFAULT_CONTEXT_CAP;
          const sumResult = await client.query(
            `SELECT COALESCE(SUM(input_tokens + cache_read_tokens + cache_create_tokens), 0) AS total_used
             FROM message_usage
             WHERE session_id = $1 AND actor_id = $2`,
            [body.session_id, principal.actorId],
          );
          const totalUsed = Number(sumResult.rows[0]?.total_used ?? 0);
          const usedPct = Math.floor((totalUsed / contextCap) * 100);

          if (usedPct >= THRESHOLD_PCT) {
            // SQL-4 — dedup check: scan chunks for an existing threshold-crossing
            // system chunk for this (session_id, actor_id). Prefix-match is
            // sufficient because THRESHOLD_CHUNK_PREFIX is unique to this purpose.
            const dupResult = await client.query(
              `SELECT 1 FROM chunks
               WHERE session_id = $1 AND actor_id = $2 AND actor_type = 'system'
                 AND chunk LIKE $3
               LIMIT 1`,
              [body.session_id, principal.actorId, `${THRESHOLD_CHUNK_PREFIX}%`],
            );
            const alreadyEmitted = (dupResult.rows ?? []).length > 0;

            if (!alreadyEmitted) {
              const personaLabel = principal.actorId;
              const thresholdChunk =
                `${THRESHOLD_CHUNK_PREFIX} ${personaLabel} at ${usedPct}%`;
              // SQL-5 — Emit the threshold system chunk. Re-use the same message_id +
              // a seq derived from the current seq + 1 to avoid PK conflict.
              // Use body.seq + 1000 as a high-offset seq so it sorts AFTER the
              // triggering chunk but doesn't collide with normal seq values.
              const thresholdSeq = body.seq + 1000;
              await client.query(
                `INSERT INTO chunks
                   (world_id, session_id, message_id, seq, actor_id, actor_type, role, chunk, chunk_type)
                 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
                 ON CONFLICT (message_id, seq) DO NOTHING`,
                [
                  body.world_id,
                  body.session_id,
                  body.message_id,
                  thresholdSeq,
                  principal.actorId,
                  'system',
                  'system',
                  thresholdChunk,
                  'text',
                ],
              );
            }
          }

          await client.query('COMMIT');
        } catch (txnErr) {
          await client.query('ROLLBACK').catch(() => undefined);
          throw txnErr;
        } finally {
          client.release();
        }
      }
      // H2 (plan-chat-spa-canonical-surface Phase G) — extract commit_plan /
      // propose_plan tool_use chunks into the planning_artifacts mutable
      // table. The chunk's content carries JSON-stringified {name, input}
      // per the substrate contract; we parse it once + write a single row.
      // Failure to extract is logged + swallowed — the chunk itself is
      // already persisted; artifact row absence is recoverable via
      // re-extraction from chunks in a follow-up batch job.
      if (body.chunk_type === 'tool_use') {
        try {
          const parsed = JSON.parse(body.chunk);
          const toolName = typeof parsed?.name === 'string' ? parsed.name : '';
          const artifactType = (toolName === 'commit_plan' || toolName === 'propose_plan')
            ? 'commit_plan'
            : toolName === 'component_scaffold'
              ? 'component_scaffold'
              : toolName === 'design_jam'
                ? 'design_jam'
                : null;
          if (artifactType && parsed.input && typeof parsed.input === 'object') {
            const input = parsed.input;
            const title = typeof input.title === 'string' && input.title.length > 0
              ? input.title.slice(0, 200)
              : `Untitled ${artifactType}`;
            const artifactId = `${body.message_id}:${body.seq}`;
            await pool.query(
              `INSERT INTO planning_artifacts
                 (id, world_id, session_id, type, title, body)
               VALUES ($1, $2, $3, $4, $5, $6)
               ON CONFLICT (id) DO NOTHING`,
              [artifactId, body.world_id, body.session_id, artifactType, title, input],
            );
          }
        } catch (artifactErr) {
          console.warn(
            `[plan-chat-service] planning_artifacts extraction failed for message=${body.message_id} seq=${body.seq}: ${artifactErr?.message ?? artifactErr}`,
          );
        }
      }
    } catch (err) {
      if (err && typeof err === 'object' && 'code' in err && err.code === '23505') {
        return send(res, 409, { error: 'duplicate', message: '(message_id, seq) already exists' });
      }
      return send(res, 500, { error: 'insert-failed', message: String(err?.message ?? err) });
    }
    return send(res, 201, {
      world_id: body.world_id,
      session_id: body.session_id,
      message_id: body.message_id,
      seq: body.seq,
      actor_id: principal.actorId,
      actor_type: principal.actorType,
      chunk_type: body.chunk_type ?? 'text',
    });
  }

  async function handleGetShape(req, res, url) {
    // PB2 — bearer required. Same checkAuth as POST /v1/chunks; rejects with
    // a constant-time comparison via timingSafeEqual.
    if (!checkAuth(req)) return unauthorized(res);

    // B5 — table allowlist. Only chunks + message_usage are supported; any
    // other table= value is rejected with 400. This guards against clients
    // probing arbitrary tables via the shape proxy.
    const tableParam = url.searchParams.get('table');
    if (!tableParam || !ALLOWED_SHAPE_TABLES.has(tableParam)) {
      return badRequest(
        res,
        `table query param must be one of: ${[...ALLOWED_SHAPE_TABLES].join(', ')}`,
      );
    }

    // PB2 — server-derived scope. world_id + session_id query params are
    // mandatory; the regex check is the SQL-injection defence for the
    // upstream `where` interpolation below.
    const worldId = url.searchParams.get('world_id');
    const sessionId = url.searchParams.get('session_id');
    if (!worldId || !SCOPE_ID_RE.test(worldId)) {
      return badRequest(
        res,
        'world_id query param required (alphanumerics + _ - . only)',
      );
    }
    // Phase C C-S1 — planning_sessions shapes are scoped by world_id only
    // (no session_id; the hook fetches all dispatch sessions for a world).
    // All other tables require session_id for the (session_id, world_id)
    // predicate that guards per-session Electric cache isolation.
    const requiresSessionId = tableParam !== 'planning_sessions';
    if (requiresSessionId && (!sessionId || !SCOPE_ID_RE.test(sessionId))) {
      return badRequest(
        res,
        'session_id query param required (alphanumerics + _ - . only)',
      );
    }
    if (sessionId && !SCOPE_ID_RE.test(sessionId)) {
      return badRequest(
        res,
        'session_id query param must match alphanumerics + _ - . only',
      );
    }

    // Forward all client query params EXCEPT the scoped set. Client-supplied
    // `where` is silently dropped — server-derived `where` always wins.
    // Operators inspecting traffic see the rewrite in the upstream URL; we
    // don't 400 on `where=` presence because legitimate Electric clients may
    // pass it via library defaults.
    const upstream = new URL(electricBase);
    upstream.pathname = '/v1/shape';
    for (const [key, value] of url.searchParams) {
      if (SCOPED_QUERY_PARAMS.has(key)) continue;
      upstream.searchParams.set(key, value);
    }
    // PB2 — server-derived `where` clause; safe because SCOPE_ID_RE has
    // already rejected single-quotes, semicolons, and SQL meta-characters.
    //
    // planning_sessions: world_id only (no per-session scope needed).
    // All other tables: session_id FIRST per Electric cache-isolation rule
    // (flipping order forces a fresh shape and avoids a known cache-staleness
    // bug where an empty-result shape persists after new rows land).
    const whereClause = requiresSessionId
      ? `session_id='${sessionId}' AND world_id='${worldId}'`
      : `world_id='${worldId}'`;
    upstream.searchParams.set('where', whereClause);

    // Phase A A2 — log BEFORE upstream fetch. Includes the rewritten
    // `where` predicate so an operator can correlate client-supplied
    // offset/handle with the server-derived scope. Cheap when off: one
    // boolean check.
    if (shapeDebugEnabled) {
      shapeLog('[plan-chat-service:shape] → upstream', {
        upstream: upstream.pathname + upstream.search,
        offset: url.searchParams.get('offset'),
        handle: url.searchParams.get('handle'),
        live: url.searchParams.get('live'),
        where: upstream.searchParams.get('where'),
      });
    }

    let upstreamRes;
    const upstreamStartedAt = shapeDebugEnabled ? performance.now() : 0;
    try {
      upstreamRes = await fetch(upstream, {
        method: 'GET',
        headers: { accept: 'application/json' },
      });
    } catch (err) {
      if (shapeDebugEnabled) {
        shapeLog('[plan-chat-service:shape] ✖ upstream-error', {
          upstream: upstream.pathname + upstream.search,
          err: String(err?.message ?? err),
        });
      }
      return send(res, 502, {
        error: 'shape-upstream-unreachable',
        message: String(err?.message ?? err),
      });
    }

    // Phase A A2 — log AFTER upstream returns headers but BEFORE
    // `res.writeHead` fires (Seam: must not touch the body stream;
    // logging post-pipe risks hanging the response on a sync exception
    // in the logger). Captures the electric-* response headers to
    // disambiguate offset-cursor staleness from handle eviction.
    if (shapeDebugEnabled) {
      const elapsedMs = Math.round(performance.now() - upstreamStartedAt);
      shapeLog(
        `[plan-chat-service:shape] ← upstream ${upstreamRes.status} ${elapsedMs}ms`,
        {
          resHandle: upstreamRes.headers.get('electric-handle'),
          resOffset: upstreamRes.headers.get('electric-offset'),
          resUpToDate: upstreamRes.headers.get('electric-up-to-date'),
          resSchema: upstreamRes.headers.get('electric-schema'),
        },
      );
    }

    // Forward every upstream `electric-*` header BEFORE the stream starts.
    // Once `res.writeHead` fires, headers are locked.
    //
    // Phase B B4 (olam-spa-electric-subscription-staleness) — prefix-match
    // the entire `electric-*` namespace upstream owns instead of a static
    // four-header whitelist. ElectricSQL 1.6.3 added `electric-cursor` to
    // the live-mode response contract; the old whitelist dropped it; the
    // SPA's TanStack Electric collection raised `MissingHeadersError` and
    // stopped processing the long-poll stream. Mirror-all-electric closes
    // the class — any future header upstream adds inside this namespace
    // flows through with zero proxy changes. See plan
    // `~/.claude/plans/olam-spa-electric-subscription-staleness.md` § Phase B
    // and Phase A A3 evidence at
    // `docs/screenshots/olam-spa-electric-subscription-staleness/a3-evidence-2026-05-18/`.
    for (const [header, value] of upstreamRes.headers) {
      if (header.toLowerCase().startsWith('electric-')) {
        res.setHeader(header, value);
      }
    }
    const upstreamContentType = upstreamRes.headers.get('content-type');
    if (upstreamContentType) res.setHeader('content-type', upstreamContentType);

    res.writeHead(upstreamRes.status);

    // PB1 — stream the body without buffering. Electric SQL long-poll
    // responses (`live=true`) can sit open for tens of seconds before the
    // first row arrives; the old `fetch().text()` blocked the response until
    // upstream closed, which broke reactive UX. `Readable.fromWeb` wraps
    // upstream's web ReadableStream as a Node Readable; `.pipe(res)` ends
    // the response automatically when the upstream stream closes.
    //
    // CP3-B1-H1 fix: attach an `error` listener BEFORE piping. Without one,
    // a mid-stream upstream error (Electric SQL restart, network blip mid
    // long-poll, upstream socket reset after headers) becomes an
    // uncaughtException and kills the host-cp process — DoS class. On
    // upstream error we destroy the response so the client sees a
    // truncated body (the only honest signal once headers are sent) and
    // the process stays up. Mirrors the established pattern in
    // packages/host-cp/src/proxy.mjs:205.
    if (upstreamRes.body) {
      const src = Readable.fromWeb(upstreamRes.body);
      src.on('error', (err) => {
        try {
          res.destroy(err);
        } catch {
          /* response already destroyed; fine */
        }
      });
      src.pipe(res);
    } else {
      // Defensive — no body (e.g. HEAD-shaped empty response). Close out.
      res.end();
    }
  }

  /**
   * POST /v1/sessions/create — multi-turn dispatch session bootstrap
   * (multi-turn-cloud-sandbox-dispatch Phase A2).
   *
   * Body shape:
   *   {
   *     world_id: string,               // required — target dispatch world
   *     budget_usd_cap?: number | null, // optional — per-session budget cap; null = uncapped
   *     allow_unpriced_models?: boolean // optional — opt session into the
   *                                     //  pricingForModel-returns-null fallback
   *                                     //  (Plan A T11); default false (refuse unknown
   *                                     //  models with 502 at /v1/dispatch-turn time).
   *   }
   *
   * Returns 200 { session_id: uuid }.
   *
   * Auth: plan-chat-secret Bearer (same gate as /v1/chunks + /v1/shape).
   * actor_id is derived from the bearer principal (single-tenant v1; multi-tenant
   * scoping is the cloud-spa-tenant-scoping plan's surface, NOT this endpoint's).
   */
  async function handlePostSessionsCreate(req, res) {
    if (!checkAuth(req)) return unauthorized(res);
    let body;
    try {
      body = await readJson(req);
    } catch (err) {
      return send(res, err?.status ?? 400, { error: err?.message ?? 'bad-request' });
    }
    if (!body || typeof body.world_id !== 'string' || body.world_id.length === 0) {
      return badRequest(res, 'world_id required');
    }
    const principal = principalFromBearer(bearer, body);
    const budgetUsdCap =
      typeof body.budget_usd_cap === 'number' && Number.isFinite(body.budget_usd_cap)
        ? body.budget_usd_cap
        : null;
    const allowUnpricedModels = body.allow_unpriced_models === true;
    // A6 (Decision 9 always-on threading): host-cp's /api/cloud-dispatch
    // pre-creates the planning_sessions row using the dispatch's session_id.
    // The SPA may also call /v1/sessions/create directly; ON CONFLICT
    // DO NOTHING in createDispatchSession handles both shapes.
    const providedSessionId =
      typeof body.session_id === 'string' && body.session_id.length > 0
        ? body.session_id
        : null;
    try {
      const result = await createDispatchSession({
        pool,
        actorId: principal.actorId,
        worldId: body.world_id,
        budgetUsdCap,
        allowUnpricedModels,
        sessionId: providedSessionId,
      });
      return send(res, 200, result);
    } catch (err) {
      return send(res, 500, {
        error: 'create-failed',
        message: String(err?.message ?? err),
      });
    }
  }

  /**
   * POST /v1/dispatch-turn — multi-turn dispatch turn entry-point
   * (multi-turn-cloud-sandbox-dispatch Phase A3 — load-bearing seam).
   *
   * Body shape:
   *   {
   *     session_id: string,                  // required — existing dispatch session
   *     prompt: string,                      // required — operator's prompt for this turn
   *   }
   *
   * Returns:
   *   - 200 { turn_id, sandbox_session_id } — turn dispatched; agent runtime
   *           streams chunks to /v1/chunks under (world_id, session_id).
   *   - 401 — bearer missing/wrong.
   *   - 400 — body invalid (missing session_id / prompt).
   *   - 404 — session not found OR not owned by caller (ownership check via actor_id).
   *   - 409 — in_flight_turn_id lock already held (concurrent dispatch).
   *   - 402 — budget_usd_cap exhausted; clear cap or halt to retry.
   *   - 502 — Phase D unknown-model guard fires (model pricing returned null
   *           AND session has allow_unpriced_models=false).
   *
   * Phase A3 SCOPE (this commit):
   *   - Atomic lock claim via UPDATE...WHERE in_flight_turn_id IS NULL RETURNING.
   *   - Budget cap check via getDispatchSession.total_usd vs budget_usd_cap.
   *   - Preamble construction STUBBED (Phase B fills the prior-conversation envelope).
   *   - plan-DO forward STUBBED via injectable `dispatchTurnForward` callback
   *     (test stubs return a fake sandbox_session_id; production wires to plan-DO).
   *   - 502 unknown-model guard STUBBED (Phase D wires real pricingForModel check).
   *
   * Phase B-D layers on:
   *   - Phase B: replace preamble stub with `<prior-conversation>` envelope
   *     constructed from chunks (host-cp owns rehydration per Decision 6).
   *   - Phase D: replace 502 stub with real pricingForModel fail-loud guard.
   *   - Phase D: implement total_usd atomic UPDATE on chunk-land + stale-lock cron.
   */
  async function handlePostDispatchTurn(req, res) {
    if (!checkAuth(req)) return unauthorized(res);
    let body;
    try {
      body = await readJson(req);
    } catch (err) {
      return send(res, err?.status ?? 400, { error: err?.message ?? 'bad-request' });
    }
    if (!body || typeof body.session_id !== 'string' || body.session_id.length === 0) {
      return badRequest(res, 'session_id required');
    }
    if (typeof body.prompt !== 'string' || body.prompt.length === 0) {
      return badRequest(res, 'prompt required');
    }
    const principal = principalFromBearer(bearer, body);

    // Ownership check: session must exist AND belong to caller.
    let session;
    try {
      session = await getDispatchSession({
        pool,
        sessionId: body.session_id,
        actorId: principal.actorId,
      });
    } catch (err) {
      return send(res, 500, {
        error: 'session-lookup-failed',
        message: String(err?.message ?? err),
      });
    }
    if (!session) {
      return send(res, 404, { error: 'session_not_found' });
    }

    // Halted check (A4) — operator's "block next turn" state takes precedence
    // over budget + lock checks because it's an explicit operator pause. The
    // running container is NOT signalled (T13); only future turns are blocked.
    if (session.halted_at) {
      return send(res, 409, {
        error: 'session_halted',
        session_id: body.session_id,
        halted_at: session.halted_at,
      });
    }

    // Budget cap check (Phase D layers on the unknown-model guard).
    if (
      session.budget_usd_cap !== null &&
      Number.isFinite(session.budget_usd_cap) &&
      session.total_usd >= session.budget_usd_cap
    ) {
      return send(res, 402, {
        error: 'budget_exhausted',
        total_usd: session.total_usd,
        budget_usd_cap: session.budget_usd_cap,
      });
    }

    // Atomic lock claim — second concurrent attempt sees empty result.
    const turnId = `turn-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
    let claimed;
    try {
      claimed = await claimDispatchTurnLock({
        pool,
        sessionId: body.session_id,
        turnId,
      });
    } catch (err) {
      return send(res, 500, {
        error: 'lock-claim-failed',
        message: String(err?.message ?? err),
      });
    }
    if (!claimed) {
      return send(res, 409, { error: 'in_flight', session_id: body.session_id });
    }

    // Forward to plan-DO (stubbed via injected callback for tests; production
    // implementation lands when /api/cloud-dispatch is refactored to share the
    // forward path — A6 work).
    try {
      const forward = dispatchTurnForward ?? defaultDispatchTurnForward;
      const result = await forward({
        session: {
          session_id: session.session_id,
          world_id: session.world_id,
          actor_id: session.actor_id,
        },
        turn_id: turnId,
        prompt: body.prompt,
        // Phase B fills preamble with conversation history from chunks.
        conversation_preamble: '',
      });
      return send(res, 200, {
        turn_id: turnId,
        sandbox_session_id: result.sandbox_session_id ?? null,
      });
    } catch (err) {
      // Forward failed — clear the lock so the operator can retry.
      try {
        await clearDispatchTurnLock({ pool, sessionId: body.session_id });
      } catch { /* logged separately */ }
      return send(res, 502, {
        error: 'dispatch_forward_failed',
        message: String(err?.message ?? err),
      });
    }
  }

  /**
   * GET /v1/sessions — list multi-turn dispatch sessions for the caller
   * (multi-turn-cloud-sandbox-dispatch Phase A5).
   *
   * Scoped to actor_id (single-tenant ownership isolation; cloud-spa-tenant-scoping
   * adds Neon RLS as defense-in-depth). Returns dispatch sessions only
   * (session_type='dispatch'), excluding archived rows, ordered by last_turn_at
   * DESC (most recently active first) then created_at DESC as tiebreaker.
   *
   * Query params:
   *   ?limit=<N>  — capped at 200; default 50.
   *
   * Response: 200 { sessions: [...] }
   *
   * Auth: plan-chat-secret Bearer. NO body (GET).
   */
  async function handleGetSessions(req, res, url) {
    if (!checkAuth(req)) return unauthorized(res);
    const principal = principalFromBearer(bearer, null);
    const limitParam = url.searchParams.get('limit');
    const limit = limitParam ? Math.min(parseInt(limitParam, 10) || 50, 200) : 50;
    try {
      const sessions = await listDispatchSessions({ pool, actorId: principal.actorId, limit });
      return send(res, 200, { sessions });
    } catch (err) {
      return send(res, 500, {
        error: 'list-failed',
        message: String(err?.message ?? err),
      });
    }
  }

  /**
   * POST /v1/sessions/:id/halt — operator-driven "block next turn"
   * (multi-turn-cloud-sandbox-dispatch Phase A4 + T13).
   *
   * Clears in_flight_turn_id (releases the lock if held) AND sets halted_at.
   * Future /v1/dispatch-turn calls return 409 'session_halted' until
   * /v1/sessions/:id/reactivate clears halted_at.
   *
   * Does NOT stop an in-flight container. The running container completes
   * its current turn naturally; only NEXT turns are blocked. UX must label
   * the halt button "Block next turn" (Plan A Phase C C6).
   *
   * Auth: plan-chat-secret Bearer. Ownership via actor_id.
   * Returns 200 { halted: true } | 404 session_not_found | 401 | 500.
   */
  async function handlePostSessionHalt(req, res, sessionId) {
    if (!checkAuth(req)) return unauthorized(res);
    const principal = principalFromBearer(bearer, null);
    try {
      const halted = await haltDispatchSession({
        pool,
        sessionId,
        actorId: principal.actorId,
      });
      if (!halted) return send(res, 404, { error: 'session_not_found' });
      return send(res, 200, { halted: true, session_id: sessionId });
    } catch (err) {
      return send(res, 500, {
        error: 'halt-failed',
        message: String(err?.message ?? err),
      });
    }
  }

  /**
   * POST /v1/sessions/:id/reactivate — inverse of halt; clears halted_at
   * so subsequent dispatch turns can proceed. Idempotent.
   */
  async function handlePostSessionReactivate(req, res, sessionId) {
    if (!checkAuth(req)) return unauthorized(res);
    const principal = principalFromBearer(bearer, null);
    try {
      const reactivated = await reactivateDispatchSession({
        pool,
        sessionId,
        actorId: principal.actorId,
      });
      if (!reactivated) return send(res, 404, { error: 'session_not_found' });
      return send(res, 200, { reactivated: true, session_id: sessionId });
    } catch (err) {
      return send(res, 500, {
        error: 'reactivate-failed',
        message: String(err?.message ?? err),
      });
    }
  }

  async function handleGetPlanningSessions(req, res, url) {
    if (!checkAuth(req)) return unauthorized(res);
    // Derive actorId from the bearer principal (no body on GET).
    const principal = principalFromBearer(bearer, null);
    const limitParam = url.searchParams.get('limit');
    const limit = limitParam ? Math.min(parseInt(limitParam, 10) || 50, 200) : 50;
    try {
      const sessions = await listPlanningSessions({ pool, actorId: principal.actorId, limit });
      return send(res, 200, { sessions });
    } catch (err) {
      return send(res, 500, { error: 'list-failed', message: String(err?.message ?? err) });
    }
  }

  async function handlePostCrystallize(req, res) {
    if (!checkAuth(req)) return unauthorized(res);
    let body;
    try {
      body = await readJson(req);
    } catch (err) {
      return send(res, err?.status ?? 400, { error: err?.message ?? 'bad-request' });
    }

    // Validate required fields: session_id + plan.title + plan.phases
    if (!body || typeof body.session_id !== 'string' || body.session_id.length === 0) {
      return badRequest(res, 'session_id required');
    }
    if (!body.plan || typeof body.plan.title !== 'string' || body.plan.title.length === 0) {
      return badRequest(res, 'plan.title required');
    }
    if (!Array.isArray(body.plan.phases)) {
      return badRequest(res, 'plan.phases must be an array');
    }

    // createWorld + destroyWorld callbacks must be wired at handler creation
    // time (production: WorldManager closures; tests: stubs). If they are not
    // provided, surface a clear 501 rather than crashing.
    if (typeof createWorld !== 'function' || typeof destroyWorld !== 'function') {
      return send(res, 501, {
        ok: false,
        error: 'crystallize-not-wired',
        status: 'failed',
      });
    }

    try {
      const result = await crystallizePlanningSession({
        pool,
        sessionId: body.session_id,
        planTitle: body.plan.title,
        planPhases: body.plan.phases,
        createWorld,
        destroyWorld,
      });
      // H7 (Phase G) — back-fill crystallized_world_id to all planning_artifacts
      // rows for this session. The status pill state machine (Phase E E4) reads
      // this field; the editor view + diagrams viewer use it for the
      // "View world →" CTA. Failure here is logged + swallowed — the
      // crystallize itself already succeeded; back-fill is a best-effort
      // sync that an operator can re-run via a CLI helper.
      if (result.worldId) {
        try {
          await pool.query(
            `UPDATE planning_artifacts
               SET crystallized_world_id = $1, status = 'crystallized'
               WHERE session_id = $2 AND status = 'open'`,
            [result.worldId, body.session_id],
          );
        } catch (backfillErr) {
          console.warn(
            `[plan-chat-service] crystallize back-fill failed for session=${body.session_id}: ${backfillErr?.message ?? backfillErr}`,
          );
        }
      }
      return send(res, 200, {
        ok: true,
        created_world_id: result.worldId,
        world_url: result.worldId ? `/world/${result.worldId}` : null,
        status: result.status,
      });
    } catch (err) {
      return send(res, 500, {
        ok: false,
        error: String(err?.message ?? err),
        status: 'failed',
      });
    }
  }

  // Phase A A1 (plan-chat-spa-supersedes-control-plane) — resolver
  // endpoint. Disambiguates :id between planning_sessions.session_id
  // and planning_artifacts.crystallized_world_id via a single UNION ALL
  // SQL query (see resolver.mjs). Bearer required (closes K1 SEC-1
  // enumeration oracle); per-bearer 60 req/min token bucket closes the
  // authenticated brute-force vector.
  async function handleGetResolve(req, res, id) {
    if (!checkAuth(req)) return unauthorized(res);

    // Rate-limit BEFORE shape validation so a flood of invalid ids
    // still consumes the bucket. Key on the bearer; the bucket is
    // global to a single-secret service. When A4 adds multi-secret
    // resolution, this key becomes principal.actorId.
    const { allowed, retryAfterMs } = resolveLimiter.take(bearer);
    if (!allowed) {
      res.setHeader('retry-after', Math.ceil(retryAfterMs / 1000));
      return send(res, 429, { error: 'rate-limited', message: 'too many requests' });
    }

    if (!RESOLVE_ID_RE.test(id)) {
      return badRequest(res, 'id must match [A-Za-z0-9._-]{6,80}');
    }

    try {
      const result = await resolveId(pool, id);
      return send(res, 200, result);
    } catch (err) {
      return send(res, 500, { error: 'resolve-failed', message: String(err?.message ?? err) });
    }
  }

  // H4 (Phase G) — GET + PATCH /v1/artifacts/:id endpoint pair backing
  // the SPA's editor-view round-trip. GET returns the artifact row;
  // PATCH updates body (the JSON payload) + bumps updated_at via trigger.
  // SCOPE_ID_RE on :id; bearer auth identical to the chunks endpoints.
  async function handleGetArtifact(req, res, id) {
    if (!checkAuth(req)) return unauthorized(res);
    if (!SCOPE_ID_RE.test(id)) return badRequest(res, 'invalid artifact id');
    try {
      const result = await pool.query(
        `SELECT id, world_id, session_id, type, title, body, status,
                linear_issue_url, crystallized_world_id,
                created_at, updated_at
           FROM planning_artifacts WHERE id = $1`,
        [id],
      );
      const row = result.rows[0];
      if (!row) return send(res, 404, { error: 'not-found' });
      return send(res, 200, row);
    } catch (err) {
      return send(res, 500, { error: 'query-failed', message: String(err?.message ?? err) });
    }
  }

  async function handlePatchArtifact(req, res, id) {
    if (!checkAuth(req)) return unauthorized(res);
    if (!SCOPE_ID_RE.test(id)) return badRequest(res, 'invalid artifact id');
    let body;
    try {
      body = await readJson(req);
    } catch {
      return badRequest(res, 'malformed JSON body');
    }
    const sets = [];
    const values = [id];
    if (body.body !== undefined) {
      values.push(body.body);
      sets.push(`body = $${values.length}::jsonb`);
    }
    if (typeof body.title === 'string') {
      values.push(body.title.slice(0, 200));
      sets.push(`title = $${values.length}`);
    }
    if (typeof body.status === 'string' && ['open', 'crystallized', 'failed', 'archived'].includes(body.status)) {
      values.push(body.status);
      sets.push(`status = $${values.length}`);
    }
    if (sets.length === 0) {
      return badRequest(res, 'no patchable fields supplied (body | title | status)');
    }
    try {
      const result = await pool.query(
        `UPDATE planning_artifacts SET ${sets.join(', ')} WHERE id = $1 RETURNING *`,
        values,
      );
      const row = result.rows[0];
      if (!row) return send(res, 404, { error: 'not-found' });
      return send(res, 200, row);
    } catch (err) {
      return send(res, 500, { error: 'update-failed', message: String(err?.message ?? err) });
    }
  }

  // P1 — POST /v1/prototypes/:id/promote
  //
  // Accepts a prototype chunk_id in the URL path. Validates the chunk exists
  // and is a prototype-shaped tool_use chunk, then dispatches a docker world
  // to implement it. Returns 202 Accepted with { worldId, promoteId, specPath }
  // before the docker world finishes booting (the boot is async).
  //
  // Idempotency: same chunk_id within PROMOTE_DEDUPE_TTL_MS returns
  // 202 { action: "deduplicated", worldId } without creating a second world.
  // Rationale: Linear retries on non-2xx; a 409 would trigger infinite retries.
  // 202 + action body is the correct idempotent-promote semantic.
  //
  // Tier routing: the injected `dispatchTask` callback owns tier selection.
  // The caller (production: WorldManager glue in server.mjs) passes the
  // workspace's `compute.default` tier as part of the dispatchTask closure.
  // This keeps plan-chat-service free from workspace config coupling.
  async function handlePostPrototypePromote(req, res, chunkId) {
    if (!checkAuth(req)) return unauthorized(res);

    // createWorld + dispatchTask must both be wired. If either is absent,
    // surface a clear 501 rather than crashing (mirrors crystallize guard).
    if (typeof createWorld !== 'function' || typeof dispatchTask !== 'function') {
      return send(res, 501, {
        error: 'promote-not-wired',
        message: 'createWorld and dispatchTask callbacks are required',
      });
    }

    // Idempotency: check dedupe map before any DB reads.
    const now = Date.now();
    const existing = promoteDedupe.get(chunkId);
    if (existing && now - existing.createdAt < PROMOTE_DEDUPE_TTL_MS) {
      return send(res, 202, {
        action: 'deduplicated',
        worldId: existing.worldId,
        promoteId: existing.promoteId,
        specPath: existing.specPath,
      });
    }

    // Parse optional body { targetWorldName?, specName? }
    let body = {};
    try {
      body = await readJson(req);
    } catch (err) {
      return send(res, err?.status ?? 400, { error: err?.message ?? 'bad-request' });
    }

    // Look up the chunk by id in the chunks table.
    let chunk;
    try {
      const result = await pool.query(
        `SELECT world_id, session_id, message_id, seq, actor_id, actor_type,
                role, chunk, chunk_type
           FROM chunks WHERE message_id = $1
           LIMIT 1`,
        [chunkId],
      );
      chunk = result.rows[0] ?? null;
    } catch (err) {
      return send(res, 500, {
        error: 'query-failed',
        message: String(err?.message ?? err),
      });
    }

    if (!chunk) {
      return send(res, 404, {
        error: 'chunk-not-found',
        message: `No chunk found with id '${chunkId}'`,
      });
    }

    // Validate: must be a tool_use chunk with a prototype-shaped name.
    if (chunk.chunk_type !== 'tool_use') {
      return send(res, 400, {
        error: 'invalid-chunk-type',
        message: `chunk must have chunk_type='tool_use', got '${chunk.chunk_type}'`,
      });
    }

    let parsedChunk;
    try {
      parsedChunk = JSON.parse(chunk.chunk);
    } catch {
      return send(res, 400, {
        error: 'invalid-chunk-json',
        message: 'chunk content is not valid JSON',
      });
    }

    const toolName = typeof parsedChunk?.name === 'string' ? parsedChunk.name : '';
    if (!toolName.includes('prototype')) {
      return send(res, 400, {
        error: 'not-a-prototype-chunk',
        message: `chunk tool name '${toolName}' does not match prototype shape (name must include 'prototype')`,
      });
    }

    // Derive world name and spec path from the chunk's input.
    const input = parsedChunk.input ?? {};
    const rawTitle =
      (typeof body.specName === 'string' && body.specName.length > 0
        ? body.specName
        : typeof input.title === 'string' && input.title.length > 0
          ? input.title
          : 'prototype')
        .toLowerCase()
        .replace(/[^a-z0-9]+/g, '-')
        .replace(/^-+|-+$/g, '')
        .slice(0, 40) || 'prototype';

    const worldName =
      typeof body.targetWorldName === 'string' && body.targetWorldName.length > 0
        ? body.targetWorldName
        : `proto-${rawTitle}`;

    const specPath = `design-specs/${rawTitle}-v1.spec.html`;
    const specSource =
      typeof input.source === 'string' ? input.source : JSON.stringify(input);

    // Build the dispatch prompt: embed the spec source + instruct the agent
    // to write it at specPath on first tool use. Per D3: the agent inside
    // the world writes the file — host-cp does not need docker mount access.
    const dispatchPrompt = [
      `## Prototype implementation task`,
      ``,
      `A prototype has been promoted for implementation. Write the spec file`,
      `at \`${specPath}\` as your first action, then implement it production-ready.`,
      ``,
      `### Spec source (write verbatim to ${specPath})`,
      ``,
      `\`\`\`html`,
      specSource,
      `\`\`\``,
      ``,
      `After writing the spec file, implement it fully — components, styles,`,
      `tests, and documentation. The spec is the source of truth.`,
      `If a file already exists at \`${specPath}\`, surface a conflict chunk`,
      `to the operator before overwriting (per D4 of the promote endpoint plan).`,
    ].join('\n');

    // Create the world.
    let worldId;
    try {
      const world = await createWorld({ name: worldName });
      worldId = world.id;
    } catch (err) {
      return send(res, 500, {
        error: 'world-creation-failed',
        message: String(err?.message ?? err),
      });
    }

    // Dispatch the task to the new world.
    try {
      await dispatchTask({ worldId, task: dispatchPrompt });
    } catch (err) {
      // The world was created but dispatch failed. Surface 502 so the caller
      // can retry dispatch manually via `olam dispatch <id> "<task>"`.
      return send(res, 502, {
        error: 'dispatch-failed',
        worldId,
        message: String(err?.message ?? err),
      });
    }

    const promoteId = `promote-${chunkId}-${worldId}`;

    // Record in dedupe map before returning.
    promoteDedupe.set(chunkId, { worldId, specPath, promoteId, createdAt: now });

    return send(res, 202, { worldId, promoteId, specPath, status: 'dispatched' });
  }

  // multi-turn-cloud-sandbox-dispatch A7 follow-up (operator E2E directive
  // 2026-05-27): the SPA at http://127.0.0.1:19000 talks to plan-chat-service
  // at http://127.0.0.1:3200 — different origins → browser preflight required.
  // Allow-list controlled via OLAM_PLAN_CHAT_CORS_ORIGINS env (comma-separated).
  // Default includes the local SPA dev port; production deployments override.
  // Surfaced by the webwright spec which the .mjs Playwright spec did NOT
  // catch (page.evaluate fetch ≠ server-to-server fetch).
  const corsOrigins = (
    process.env.OLAM_PLAN_CHAT_CORS_ORIGINS ?? 'http://127.0.0.1:19000,http://localhost:19000'
  ).split(',').map((o) => o.trim()).filter(Boolean);

  function applyCorsHeaders(req, res) {
    const origin = req.headers.origin;
    if (typeof origin === 'string' && corsOrigins.includes(origin)) {
      res.setHeader('access-control-allow-origin', origin);
      res.setHeader('vary', 'origin');
      res.setHeader('access-control-allow-credentials', 'true');
      res.setHeader('access-control-allow-methods', 'GET, POST, OPTIONS');
      res.setHeader('access-control-allow-headers', 'authorization, content-type');
      res.setHeader('access-control-max-age', '600');
    }
  }

  return async function handler(req, res) {
    const url = new URL(req.url ?? '/', `http://${req.headers.host}`);
    applyCorsHeaders(req, res);
    // CORS preflight — short-circuit OPTIONS for allow-listed origins.
    if (req.method === 'OPTIONS') {
      const origin = req.headers.origin;
      if (typeof origin === 'string' && corsOrigins.includes(origin)) {
        res.statusCode = 204;
        return res.end();
      }
      // Not allowlisted — fall through to 404 (default not-found shape).
    }
    if (req.method === 'GET' && url.pathname === '/livez') return send(res, 200, { ok: true });
    if (req.method === 'POST' && url.pathname === '/v1/chunks') return handlePostChunks(req, res);
    if (req.method === 'GET' && url.pathname === '/v1/shape') return handleGetShape(req, res, url);
    if (req.method === 'GET' && url.pathname === '/v1/planning-sessions') return handleGetPlanningSessions(req, res, url);
    if (req.method === 'POST' && url.pathname === '/v1/sessions/create') return handlePostSessionsCreate(req, res);
    if (req.method === 'GET' && url.pathname === '/v1/sessions') return handleGetSessions(req, res, url);
    if (req.method === 'POST' && url.pathname === '/v1/dispatch-turn') return handlePostDispatchTurn(req, res);
    const haltMatch = /^\/v1\/sessions\/([^/]+)\/halt$/.exec(url.pathname);
    if (haltMatch && req.method === 'POST') {
      return handlePostSessionHalt(req, res, decodeURIComponent(haltMatch[1]));
    }
    const reactivateMatch = /^\/v1\/sessions\/([^/]+)\/reactivate$/.exec(url.pathname);
    if (reactivateMatch && req.method === 'POST') {
      return handlePostSessionReactivate(req, res, decodeURIComponent(reactivateMatch[1]));
    }
    if (req.method === 'POST' && url.pathname === '/v1/crystallize') return handlePostCrystallize(req, res);
    // Phase A A1 — /v1/resolve/:id (plan-chat-spa-supersedes-control-plane).
    const resolveMatch = /^\/v1\/resolve\/([^/]+)$/.exec(url.pathname);
    if (resolveMatch && req.method === 'GET') {
      const id = decodeURIComponent(resolveMatch[1]);
      return handleGetResolve(req, res, id);
    }

    // H4 — /v1/artifacts/:id pair
    const artifactMatch = /^\/v1\/artifacts\/([^/]+)$/.exec(url.pathname);
    if (artifactMatch) {
      const id = decodeURIComponent(artifactMatch[1]);
      if (req.method === 'GET') return handleGetArtifact(req, res, id);
      if (req.method === 'PATCH') return handlePatchArtifact(req, res, id);
      return send(res, 405, { error: 'method-not-allowed' });
    }

    // P1 — /v1/prototypes/:id/promote
    // startsWith + endsWith guard prevents shadowing a future /v1/prototypes
    // GET list route (per risk note in the plan). The match is exact.
    const promoteMatch = /^\/v1\/prototypes\/([^/]+)\/promote$/.exec(url.pathname);
    if (promoteMatch && req.method === 'POST') {
      const chunkId = decodeURIComponent(promoteMatch[1]);
      return handlePostPrototypePromote(req, res, chunkId);
    }

    return send(res, 404, { error: 'not-found' });
  };
}

/**
 * Boot the service. Returns the http.Server instance plus a stop() helper.
 * Callers pass the resolved bearer + pool + electric URL; defaults live in
 * the standalone launcher below.
 */
export async function startService(opts = {}) {
  const port = opts.port ?? Number(process.env.OLAM_PLAN_CHAT_PORT ?? DEFAULT_PORT);
  const databaseUrl =
    opts.databaseUrl ?? process.env.OLAM_PLAN_CHAT_DATABASE_URL ?? DEFAULT_DB_URL;
  const electricUrl =
    opts.electricUrl ?? process.env.OLAM_PLAN_CHAT_ELECTRIC_URL ?? DEFAULT_ELECTRIC_URL;
  // Layered defense: SECRET_PATH (imported above) is itself env-aware at
  // module load. Reading OLAM_PLAN_CHAT_SECRET_PATH here too lets explicit
  // opts.secretPath callers and env-changes-since-import still win. Do not
  // collapse either layer — direct callers in server.mjs (readPlanChatSecret
  // with no arg) rely on the module-const path being env-aware.
  const secretPath = opts.secretPath ?? process.env.OLAM_PLAN_CHAT_SECRET_PATH ?? SECRET_PATH;
  const bearer = opts.bearer ?? ensureSecret(secretPath);

  const pool = opts.pool ?? new pg.Pool({ connectionString: databaseUrl, max: 8 });

  // multi-turn-cloud-sandbox-dispatch A7 follow-up (operator E2E directive
  // 2026-05-27): apply SCHEMA_SQL on boot so a fresh local dev stack picks
  // up A1+A4 migrations (planning_sessions session_type / world_id /
  // halted_at / etc.) automatically. Skipped when opts.pool was supplied
  // (test pool stubs don't run real SQL); skipped when OLAM_PLAN_CHAT_SKIP_SCHEMA
  // is set (operator escape hatch for managed-DB environments where the
  // app shouldn't apply DDL). Webwright E2E surfaced this gap: the API-only
  // tests pass against a stubbed pool, but a real-Neon dev stack hit
  // `column "session_type" of relation "planning_sessions" does not exist`
  // until SCHEMA_SQL was applied manually.
  if (!opts.pool && process.env.OLAM_PLAN_CHAT_SKIP_SCHEMA !== '1') {
    try {
      await pool.query(SCHEMA_SQL);
      // eslint-disable-next-line no-console
      console.error('[plan-chat-service] SCHEMA_SQL applied on boot');
    } catch (schemaErr) {
      // eslint-disable-next-line no-console
      console.error(
        `[plan-chat-service] SCHEMA_SQL apply FAILED on boot: ${schemaErr?.message ?? schemaErr}`,
      );
      // Don't crash the service — schema may already be applied externally
      // (e.g. CI managed DB). Fail-soft + log; the operator's first POST
      // will surface the column-missing error if the schema really is stale.
    }
  }

  const handler = createHandler({
    pool,
    bearer,
    electricUrl,
    shapeDebug: opts.shapeDebug,
    shapeDebugLog: opts.shapeDebugLog,
    createWorld: opts.createWorld,
    destroyWorld: opts.destroyWorld,
    dispatchTask: opts.dispatchTask,
    resolveActor: opts.resolveActor,
    rateLimiter: opts.rateLimiter,
    operatorChunkSecret: opts.operatorChunkSecret,
    _promoteDedupe: opts._promoteDedupe,
    dispatchTurnForward: opts.dispatchTurnForward,
  });
  const server = http.createServer((req, res) => {
    handler(req, res).catch((err) => {
      try {
        send(res, 500, { error: 'handler-crash', message: String(err?.message ?? err) });
      } catch { /* response already started */ }
    });
  });

  await new Promise((resolve, reject) => {
    server.once('error', (err) => {
      if (err && err.code === 'EADDRINUSE') {
        // A3 — port-collision diagnostic. Prevents silent half-start when the
        // target port is held by another process. agentmemory's `iii` has
        // historically claimed 3111-3113 (motivated the 3112 → 3200 move in
        // olam-chunks-subscriber-long-poll Phase A); flag it so the next
        // collision is debuggable in one log line, not three rounds of grep.
        // eslint-disable-next-line no-console
        console.error(`[plan-chat-service] EADDRINUSE on :${port} — port already in use.`);
        // eslint-disable-next-line no-console
        console.error(`[plan-chat-service]   Check:  lsof -i:${port}`);
        // eslint-disable-next-line no-console
        console.error(`[plan-chat-service]   Note:   agentmemory's iii claims 3111-3113 (motivated 3112 → 3200 move).`);
        // eslint-disable-next-line no-console
        console.error(`[plan-chat-service]   Override: OLAM_PLAN_CHAT_PORT=<free-port>`);
      }
      reject(err);
    });
    server.listen(port, () => resolve(undefined));
  });

  async function stop() {
    await new Promise((resolve) => server.close(() => resolve(undefined)));
    if (!opts.pool) await pool.end().catch(() => undefined);
  }

  return { server, stop, port, bearer };
}

// Standalone launcher (allows `node plan-chat-service.mjs` for ad-hoc runs +
// the deferred `olam plan-chat start` lifecycle command).
if (import.meta.url === `file://${process.argv[1]}`) {
  startService()
    .then(({ port }) => {
      // eslint-disable-next-line no-console
      console.log(`[plan-chat-service] listening on :${port}`);
    })
    .catch((err) => {
      // eslint-disable-next-line no-console
      console.error('[plan-chat-service] failed to start:', err);
      process.exit(1);
    });
}
