/**
 * PendingRelayStore — SQLite-backed durable queue of telegram-reply
 * delivery attempts that the script-side detector classified as
 * recoverable.
 *
 * Spec: docs/specs/telegram-delivery-robustness.md § Layer 2a.
 *
 * Path: `<stateDir>/state/pending-relay.<agentId>.sqlite` (mode 0600).
 *
 * The agentId infix exists because instar supports two install layouts:
 *   - per-agent: `~/.instar/agents/<id>/.instar/`
 *   - per-project (shared worktrees): `<project>/.instar/`
 * Two agents that share a project `.instar/` would otherwise collide
 * on a single queue file. The infix prevents that.
 *
 * Concurrency model:
 *   - WAL journaling — sentinel UPDATEs do not block script INSERTs.
 *   - synchronous=NORMAL — durable enough; we accept that the most
 *     recent few inserts MAY be lost on a power cut, which is fine
 *     because the sender's exit-1 keeps the agent-visible failure
 *     semantics (the agent can still see its send failed).
 *   - busy_timeout=5000 — handles transient contention without raising.
 *
 * Layer 2 owns ENQUEUE and lookup primitives. The actual claim/lease
 * lifecycle (Layer 3) layers on top of `transition()` — the store does
 * not opinion-ate on what counts as a legal state transition; it just
 * persists what its caller writes.
 */
import type { Database as BetterSqliteDatabase } from 'better-sqlite3';
export type DeliveryState = 'queued' | 'claimed' | 'delivered-recovered' | 'delivered-tone-gated' | 'delivered-ambiguous' | 'escalated' | 'dead-letter';
export interface PendingRelayRow {
    delivery_id: string;
    topic_id: number;
    text_hash: string;
    text: Buffer;
    format: string | null;
    http_code: number | null;
    error_body: string | null;
    attempted_port: number | null;
    attempted_at: string;
    attempts: number;
    next_attempt_at: string | null;
    state: DeliveryState;
    claimed_by: string | null;
    status_history: string;
    truncated: 0 | 1;
    /** Serialized kind metadata (messageKind/senderClass/jobSlug/advisoryAck/
     *  advisoryCodes) — the redrive forwards it whole so a queued automated
     *  send is never mis-kinded and an acked-then-queued send still lands its
     *  'acked' audit row (spec outbound-jargon-filepath-gap §2.5). Null on
     *  legacy rows (they ride the delivery-id breadcrumb exemption). */
    message_metadata: string | null;
}
export interface EnqueueInput {
    delivery_id: string;
    topic_id: number;
    text_hash: string;
    text: Buffer | string;
    format?: string | null;
    http_code?: number | null;
    error_body?: string | null;
    attempted_port?: number | null;
    attempted_at?: string;
    truncated?: boolean;
    message_metadata?: string | null;
    /**
     * Release hold (reap-notify spec R1.3): a row enqueued with a future
     * `next_attempt_at` is not claimable until that instant. The hold rides
     * the EXISTING column — the claim queries already honor it, so a
     * rolled-back binary keeps honoring holds. No schema change.
     */
    next_attempt_at?: string | null;
}
/**
 * Resolve the SQLite path. `stateDir` is the agent's `.instar/` directory.
 * The store lives under `<stateDir>/state/`, alongside other per-agent
 * runtime files.
 */
export declare function resolvePendingRelayPath(stateDir: string, agentId: string): string;
export declare function resolvePendingRelayLockPath(stateDir: string, agentId: string): string;
export declare class PendingRelayStore {
    private db;
    private path;
    private constructor();
    static open(agentId: string, stateDir: string): PendingRelayStore;
    /**
     * Enqueue a recoverable delivery failure.
     *
     * Idempotent on `delivery_id` — INSERT OR IGNORE so a script that
     * accidentally re-runs with the same delivery_id (e.g. a bash retry
     * loop in a misbehaving session) doesn't duplicate the row.
     *
     * Returns true if the row was newly inserted, false if it was already
     * present (idempotent no-op).
     */
    enqueue(input: EnqueueInput): boolean;
    findByDeliveryId(deliveryId: string): PendingRelayRow | null;
    /**
     * Dedup-window query (spec § 2b step 2). Returns the most-recently
     * inserted row whose (topic_id, text_hash) matches and whose
     * attempted_at is within `windowMs` of `now`. Used by the script to
     * suppress a tight-loop flood from a misbehaving session.
     */
    findByTopicAndHashWithin(topicId: number, textHash: string, windowMs: number, now?: Date): PendingRelayRow | null;
    /**
     * Update an entry's state and any additional fields. The spec calls
     * for "atomic state transitions" — we run the UPDATE in a single
     * statement and append a status-history row in the same transaction
     * so a sentinel crash mid-transition cannot leave a row in a torn
     * state.
     *
     * Returns true if a row was actually updated, false if no row matched
     * the delivery_id (caller race or stale id).
     */
    transition(deliveryId: string, newState: DeliveryState, additionalFields?: Partial<{
        claimed_by: string | null;
        next_attempt_at: string | null;
        attempts: number;
        http_code: number | null;
        error_body: string | null;
    }>): boolean;
    /** Diagnostic helper used by tests. */
    count(): number;
    /**
     * Layer 3 sentinel selector — returns rows whose `state` is in
     * (`queued`, `claimed`) and whose `next_attempt_at` (when set) is at
     * or before `nowIso`. Sorted by `attempted_at` ascending so the
     * sentinel can drain oldest-first within per-topic rate caps.
     *
     * Origin scoping (reap-notify spec R1.3): rows whose delivery_id carries
     * the `reap-notify:` prefix belong to ReapNoticeDrain and are EXCLUDED
     * here — the single-owner contract lives in the queries, not in drain
     * etiquette. The complement is `selectClaimableReapNotices`. Both filters
     * are index-compatible range predicates on the PK.
     *
     * The query is bounded at `limit` to prevent a runaway tick scan.
     * The sentinel handles its own batching above this layer.
     */
    selectClaimable(nowIso: string, limit?: number): PendingRelayRow[];
    /**
     * ReapNoticeDrain selector (reap-notify spec R1.3) — the complement of
     * `selectClaimable`: due rows INSIDE the `reap-notify:` PK range only.
     * Range predicate (`>= lower AND < upper`) so SQLite serves it from the
     * PK index — the 30s always-on drain must not be a latent table scan.
     * Idle cost on an empty store is one indexed probe.
     */
    selectClaimableReapNotices(nowIso: string, limit?: number): PendingRelayRow[];
    /**
     * Compare-and-swap claim (reap-notify spec R1.3): atomically move a row
     * to `claimed`/`claimed_by` ONLY if it still looks exactly like the row
     * the caller selected (same state + same claimed_by). Two drains racing
     * the same row can never both succeed — the loser's UPDATE matches zero
     * rows. Returns true when this caller won the claim.
     */
    claimCas(deliveryId: string, newClaimedBy: string, expected: {
        state: DeliveryState;
        claimed_by: string | null;
    }): boolean;
    /**
     * List the rows a restore-purge at `cutoffIso` WOULD delete — so the
     * caller can make the loss traceable (per-row log + degradation report)
     * before purging. A restore-purge deletes queued-undelivered outbound
     * messages; until 2026-06-05 it was the delivery stack's only silent
     * deletion path.
     *
     * Predicate matches `purgeStaleClaimable` exactly (the loud-loss logging
     * must cover exactly what the purge deletes). `farFutureClamp` marks rows
     * purged by the 7-day corruption clamp rather than ordinary staleness.
     */
    listStaleClaimable(cutoffIso: string, nowIso?: string): Array<{
        delivery_id: string;
        topic_id: number;
        attempted_at: string;
        text: string;
        farFutureClamp: boolean;
    }>;
    /**
     * Layer 3 restore-purge — drops queued/claimed rows that are genuinely
     * stale. Called once at sentinel startup (DFS spec §3h, semantics updated
     * by reap-notify spec R1.6).
     *
     * Staleness cutoff is `max(attempted_at, next_attempt_at)`: a row HELD
     * for future release (its `next_attempt_at` is still ahead of the cutoff)
     * is NOT stale — purging it was the mechanism behind the documented
     * 2026-06-05 silent-deletion incident (a quiet-hours-held notice crossing
     * a routine restart was eaten at boot). In SQL: a row is purged only when
     * `attempted_at < cutoff` AND its `next_attempt_at` is null, also past,
     * or beyond the 7-day corruption clamp (`farFutureClampIso`) — no
     * legitimate writer holds that long; without the clamp a malformed row
     * would live forever.
     *
     * Returns the number of rows deleted so the caller can emit a one-line
     * restore log.
     */
    purgeStaleClaimable(cutoffIso: string, nowIso?: string): number;
    /**
     * Bounded cleanup for the reap-notify lane (P19 — the always-on drain must
     * not grow the store unboundedly when DFS's retention pass is off): deletes
     * TERMINAL-state rows inside the reap-notify PK range older than `beforeIso`.
     * Queued/claimed rows are never touched.
     */
    purgeTerminalReapNotices(beforeIso: string): number;
    pathOnDisk(): string;
    /** Direct DB handle for contract tests only. */
    rawDb(): BetterSqliteDatabase;
    close(): void;
}
/**
 * Verify the SQLite runtime substrate is usable.
 *
 * Spec § 2a "Runtime dependency": the `sqlite3` CLI is not universally
 * pre-installed (Alpine, minimal Debian). On boot, probe for it; if
 * absent, raise a `sqlite3-cli-missing` degradation event. The script
 * has a `node:sqlite` / `better-sqlite3` fallback path — this is purely
 * a "tell the operator" signal, not a fatal startup failure.
 *
 * We also confirm `better-sqlite3` itself can open an in-memory DB; if
 * that fails (e.g. node-gyp build was skipped on this platform), we
 * emit a `sqlite-runtime-broken` degradation. The server keeps running
 * — Layer 2 features are gracefully degraded; everything else continues
 * to work.
 *
 * Returns `{ok: true}` when the in-process driver works (independent of
 * CLI presence), so AgentServer can decide whether to stand up the
 * /events/delivery-failed endpoint at all.
 */
export declare function assertSqliteAvailable(): {
    ok: boolean;
    cliPresent: boolean;
    reason?: string;
};
//# sourceMappingURL=pending-relay-store.d.ts.map