/**
 * CoherenceJournal — per-machine, per-kind append-only event streams
 * (P1.1 of multi-machine coherence).
 *
 * Spec: docs/specs/COHERENCE-JOURNAL-SPEC.md §3.1 (writer rules), §3.2 (typed
 * schemas), §3.7 (per-kind retention).
 *
 * This class is ONLY the writer + a minimal tolerant reader for the read API
 * (built later). It performs its OWN file I/O — it is not a StateManager
 * method — so the standby read-only guard is an injected seam
 * (`guardWrite?: (path) => void`); another workstream wires the real
 * `StateManager.guardJournalWrite` entrypoint into it.
 *
 * The load-bearing safety rule (§3.1): emit() is a NON-BLOCKING memory
 * operation. It validates + enqueues + returns in microseconds; a background
 * flusher drains the queue with single-line O_APPEND writes and batched
 * fdatasync on a cadence (default 250ms). NO synchronous I/O ever runs in a
 * caller's stack — the host has a documented event-loop-starvation history and
 * a blocking fsync at a placement/session/autonomous code path would reproduce
 * the originating incident class.
 *
 * Crash-safe meta ordering (§3.4 rule 3): data lines are appended +
 * fdatasync'd FIRST, then `meta.highWaterSeq` is advanced via atomic temp-file
 * rename — so `durable_tail >= highWaterSeq` holds at every instant, including
 * a kill-9 between the two steps. The incarnation token is re-minted IFF on
 * open the file's last seq is strictly below `meta.highWaterSeq` (a genuine
 * rewind / restore-from-backup); a trailing-partial-line repair is NEVER a
 * re-mint.
 *
 * The journal subsystem never emits journal events about itself.
 */
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { coerceHlc, serializeHlcKey } from './HybridLogicalClock.js';
import { validateReplicatedEnvelope, } from './ReplicatedRecordEnvelope.js';
import { SafeFsExecutor } from './SafeFsExecutor.js';
export const JOURNAL_KINDS = ['topic-placement', 'session-lifecycle', 'autonomous-run', 'threadline-conversation', 'guard-latch', 'pref-record', 'relationship-record', 'learning-record', 'knowledge-record', 'evolution-action-record', 'user-record', 'topic-operator-record', 'threadline-pairing-record', 'subscription-account-meta'];
export const DEFAULT_RETENTION = {
    'topic-placement': { maxFileBytes: 8 * 1024 * 1024, rotateKeep: 0 },
    'session-lifecycle': { maxFileBytes: 16 * 1024 * 1024, rotateKeep: 4 },
    'autonomous-run': { maxFileBytes: 8 * 1024 * 1024, rotateKeep: 8 },
    'threadline-conversation': { maxFileBytes: 8 * 1024 * 1024, rotateKeep: 8 },
    // guard-latch: rare operator-initiated transitions; keep full history bounded.
    'guard-latch': { maxFileBytes: 4 * 1024 * 1024, rotateKeep: 4 },
    // pref-record (WS2.1): preferences are FEW (a tight per-store cap — the
    // PreferencesSync precedent's DEFAULT_MAX_REPLICATED_PREFERENCES=500). A small
    // window with a few archives bounds a runaway edit-churn stream. The store's own
    // ReplicatedKindBounds (PreferencesReplicatedStore.PREF_RECORD_BOUNDS) override
    // these for the replicated stream; this is the journal-level fallback.
    'pref-record': { maxFileBytes: 2 * 1024 * 1024, rotateKeep: 4 },
    // relationship-record (WS2.3): a PII store — NEVER rotateKeep:0 (rotate-but-never-
    // delete would be a compliance defect, REQ-D1). The chatty relationship stream
    // (recordInteraction fires every message) is coalesced by the store's rate cap
    // (RelationshipsReplicatedStore.RELATIONSHIP_RECORD_BOUNDS); this is the journal-
    // level fallback. The per-entry size cap is RAISED to 64KB on the relationship-
    // record applier path (RELATIONSHIP_MAX_ENTRY_BYTES) so a fat relationship replicates.
    'relationship-record': { maxFileBytes: 8 * 1024 * 1024, rotateKeep: 4 },
    // learning-record (WS2.2): a memory-family store — NEVER rotateKeep:0 (rotate-but-
    // never-delete would be a compliance defect). Learnings are FEW + bounded (the
    // EvolutionManager prunes to maxLearnings=500), so a small window with a few archives
    // mirrors the pref-record sibling. The churny apply/markApplied loop is coalesced by
    // the store's rate cap (LearningsReplicatedStore.LEARNING_RECORD_BOUNDS); this is the
    // journal-level fallback. The per-entry size cap is RAISED to 64KB on the
    // learning-record applier path (LEARNING_MAX_ENTRY_BYTES) so a fat learning replicates.
    'learning-record': { maxFileBytes: 4 * 1024 * 1024, rotateKeep: 4 },
    // knowledge-record (WS2.4): a memory-family store — NEVER rotateKeep:0 (rotate-but-
    // never-delete would be a compliance defect). Knowledge sources are FEW + bounded (a
    // catalog of ingested pointers), so a small window with a few archives mirrors the
    // learning-record sibling. The churny re-ingest loop is coalesced by the store's rate
    // cap (KnowledgeReplicatedStore.KNOWLEDGE_RECORD_BOUNDS); this is the journal-level
    // fallback. The per-entry size cap is RAISED to 64KB on the knowledge-record applier
    // path (KNOWLEDGE_MAX_ENTRY_BYTES) so a fat summary replicates (the file BODY is never
    // replicated — only the catalog metadata).
    'knowledge-record': { maxFileBytes: 4 * 1024 * 1024, rotateKeep: 4 },
    // evolution-action-record (WS2.5): a memory-family store — NEVER rotateKeep:0 (rotate-but-
    // never-delete would be a compliance defect). Actions are FEW + bounded (the
    // EvolutionManager prunes to maxActions=300), so a small window with a few archives mirrors
    // the learning-record sibling. The churny add/updateAction loop is coalesced by the store's
    // rate cap (EvolutionActionsReplicatedStore.EVOLUTION_ACTION_RECORD_BOUNDS); this is the
    // journal-level fallback. The per-entry size cap is RAISED to 64KB on the
    // evolution-action-record applier path (EVOLUTION_ACTION_MAX_ENTRY_BYTES) so a fat action
    // description replicates.
    'evolution-action-record': { maxFileBytes: 4 * 1024 * 1024, rotateKeep: 4 },
    // user-record (WS2.6): a PII store — NEVER rotateKeep:0 (rotate-but-never-delete would be a
    // compliance defect). Registered principals are FEW + bounded, so a small window with a few
    // archives mirrors the relationship-record sibling. The churny upsert loop is coalesced by the
    // store's rate cap (UserRegistryReplicatedStore.USER_RECORD_BOUNDS); this is the journal-level
    // fallback. The per-entry size cap is RAISED to 64KB on the user-record applier path
    // (USER_MAX_ENTRY_BYTES) so a fat profile replicates (the local userId is never replicated).
    'user-record': { maxFileBytes: 4 * 1024 * 1024, rotateKeep: 4 },
    // topic-operator-record (WS2.6): a PII store — NEVER rotateKeep:0. One binding per topic, so a
    // small window mirrors the user-record sibling. The per-message re-bind loop is coalesced by the
    // store's rate cap (TopicOperatorReplicatedStore.TOPIC_OPERATOR_RECORD_BOUNDS). The per-entry size
    // cap is RAISED to 64KB on the applier path (TOPIC_OPERATOR_MAX_ENTRY_BYTES). NOTE: this kind is
    // ADVISORY-only — a replicated topic-operator record is NEVER the authoritative principal.
    'topic-operator-record': { maxFileBytes: 2 * 1024 * 1024, rotateKeep: 4 },
    // threadline-pairing-record (Secure A2A Verified Pairing §3.8): the verified-IDENTITY
    // RESULT store — FEW records (one per verified peer), bounded. NEVER the SAS/secret/token
    // (those never enter the journal). The store's own bounds
    // (ThreadlinePairingReplicatedStore.THREADLINE_PAIRING_RECORD_BOUNDS) override the
    // replicated stream; this is the journal-level fallback.
    'threadline-pairing-record': { maxFileBytes: 1 * 1024 * 1024, rotateKeep: 4 },
    // subscription-account-meta (WS5.2 §6.1a): a metadata-only projection — NEVER rotateKeep:0
    // (rotate-but-never-delete parity with the other replicated kinds). Accounts are FEW + bounded
    // (a handful per operator), so a small window with a few archives suffices; the churny quota-
    // refresh loop is coalesced upstream. Carries NO credential and NO PII beyond email (the
    // configHome + every credential field are stripped at the projector).
    'subscription-account-meta': { maxFileBytes: 2 * 1024 * 1024, rotateKeep: 4 },
};
export const DEFAULT_FLUSH_INTERVAL_MS = 250;
export const DEFAULT_MAX_ENTRY_BYTES = 8 * 1024;
/**
 * Per-entry byte cap for a REPLICATED `*-record` kind (WS2 send-side). A
 * disclosure-minimized record's `data` is capped at 64 KB by its store builder
 * (e.g. LEARNING_MAX_ENTRY_BYTES); the serialized journal LINE adds the entry
 * envelope (seq/ts/machine/kind), so the line cap must sit a margin ABOVE the
 * data cap or a legal-but-fat record would be dropped as oversize. 80 KB clears
 * the 64 KB data ceiling with comfortable headroom while staying bounded. The
 * applier's receive-side cap (JournalSyncApplier) uses the SAME constant so a
 * record the writer emits can never be rejected as oversize on receive. */
export const REPLICATED_RECORD_MAX_ENTRY_BYTES = 80 * 1024;
/** Tail-scan window for op-key dedupe reconstruction (§3.1). */
export const DEDUPE_WINDOW = 200;
/** Token-bucket defaults per kind (emits/sec sustained; burst = capacity). */
export const DEFAULT_RATE_CAP = { capacity: 100, refillPerSec: 50 };
/** Reverse-tail read byte ceiling for the tolerant reader. */
export const DEFAULT_READ_BYTE_CEILING = 1 * 1024 * 1024;
function realFs() {
    return {
        futimesSync: fs.futimesSync,
        openSync: fs.openSync,
        writeSync: fs.writeSync,
        fdatasyncSync: fs.fdatasyncSync,
        closeSync: fs.closeSync,
        existsSync: fs.existsSync,
        statSync: fs.statSync,
        renameSync: fs.renameSync,
        writeFileSync: fs.writeFileSync,
        readFileSync: fs.readFileSync,
        readdirSync: fs.readdirSync,
        truncateSync: fs.truncateSync,
        mkdirSync: fs.mkdirSync,
        readSync: fs.readSync,
    };
}
/**
 * Sanitize a machine id for use in a file name. Mirrors MachineHeartbeat:
 * percent-encode anything outside [A-Za-z0-9_-] so a stray slash or `..`
 * cannot escape, and the mapping is injective + traversal-safe.
 */
export function sanitizeMachineId(machineId) {
    return machineId.replace(/[^A-Za-z0-9_-]/g, (c) => `%${c.charCodeAt(0).toString(16).padStart(2, '0')}`);
}
export class CoherenceJournal {
    stateDir;
    machineId;
    safeMachineId;
    flushIntervalMs;
    maxEntryBytes;
    retention;
    rateCapCfg;
    artifactRoots;
    guardWrite;
    now;
    logger;
    io;
    /**
     * The replicated-kind registry (WS2 send-side). Injected via
     * setReplicatedKindRegistry so the journal can validate + accept a registered
     * `*-record` kind's envelope. ABSENT ⇒ unchanged behavior (only the 5 hardcoded
     * lifecycle kinds validate; a `*-record` kind schema-rejects exactly as before
     * this seam landed). The journal never depends on the registry being present.
     */
    replicatedRegistry;
    state = 'closed';
    incarnation = '';
    /** Next seq to assign at enqueue, per kind (in-memory counter seeded at open). */
    nextSeq = { 'topic-placement': 1, 'session-lifecycle': 1, 'autonomous-run': 1, 'threadline-conversation': 1, 'guard-latch': 1, 'pref-record': 1, 'relationship-record': 1, 'learning-record': 1, 'knowledge-record': 1, 'evolution-action-record': 1, 'user-record': 1, 'topic-operator-record': 1, 'threadline-pairing-record': 1, 'subscription-account-meta': 1 };
    /** Durable highWaterSeq per kind (advanced after data fdatasync). */
    highWaterSeq = {
        'topic-placement': 0,
        'session-lifecycle': 0,
        'autonomous-run': 0,
        'threadline-conversation': 0,
        'guard-latch': 0,
        'pref-record': 0,
        'relationship-record': 0,
        'learning-record': 0,
        'knowledge-record': 0,
        'evolution-action-record': 0,
        'user-record': 0,
        'topic-operator-record': 0,
        'threadline-pairing-record': 0,
        'subscription-account-meta': 0,
    };
    /** In-memory enqueue order; drained by the flusher in seq order per kind. */
    queue = [];
    /** Recent-window op-key set per kind (reconstructed on open by tail-scan). */
    opKeys = {
        'topic-placement': new Set(),
        'session-lifecycle': new Set(),
        'autonomous-run': new Set(),
        'threadline-conversation': new Set(),
        'guard-latch': new Set(),
        'pref-record': new Set(),
        'relationship-record': new Set(),
        'learning-record': new Set(),
        'knowledge-record': new Set(),
        'evolution-action-record': new Set(),
        'user-record': new Set(),
        'topic-operator-record': new Set(),
        'threadline-pairing-record': new Set(),
        'subscription-account-meta': new Set(),
    };
    rateBuckets;
    timer = null;
    /** Lock-retry timer while writer-locked-out (issue #925). */
    retryTimer = null;
    /** Flush counter for the lock mtime heartbeat (pid-reuse defense, #925). */
    flushCount = 0;
    lockFd = null;
    flushing = false;
    /** Monotonic suffix so rapid rotations within one ms get unique archive names. */
    rotationCounter = 0;
    degradation = {
        repairs: 0,
        remints: 0,
        schemaRejects: 0,
        droppedFields: 0,
        oversize: 0,
        rateLimited: 0,
        jailRejects: 0,
        guardSkips: 0,
        flushErrors: 0,
        dedupeHits: 0,
    };
    /** Has the one-time repair signal already fired this lifetime? */
    repairSignalled = false;
    loggedClasses = new Set();
    constructor(config) {
        this.stateDir = config.stateDir;
        this.machineId = config.machineId;
        this.safeMachineId = sanitizeMachineId(config.machineId);
        this.flushIntervalMs = config.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
        this.maxEntryBytes = config.maxEntryBytes ?? DEFAULT_MAX_ENTRY_BYTES;
        this.retention = {
            'topic-placement': config.retention?.['topic-placement'] ?? DEFAULT_RETENTION['topic-placement'],
            'session-lifecycle': config.retention?.['session-lifecycle'] ?? DEFAULT_RETENTION['session-lifecycle'],
            'autonomous-run': config.retention?.['autonomous-run'] ?? DEFAULT_RETENTION['autonomous-run'],
            'threadline-conversation': config.retention?.['threadline-conversation'] ?? DEFAULT_RETENTION['threadline-conversation'],
            'guard-latch': config.retention?.['guard-latch'] ?? DEFAULT_RETENTION['guard-latch'],
            'pref-record': config.retention?.['pref-record'] ?? DEFAULT_RETENTION['pref-record'],
            'relationship-record': config.retention?.['relationship-record'] ?? DEFAULT_RETENTION['relationship-record'],
            'learning-record': config.retention?.['learning-record'] ?? DEFAULT_RETENTION['learning-record'],
            'knowledge-record': config.retention?.['knowledge-record'] ?? DEFAULT_RETENTION['knowledge-record'],
            'evolution-action-record': config.retention?.['evolution-action-record'] ?? DEFAULT_RETENTION['evolution-action-record'],
            'user-record': config.retention?.['user-record'] ?? DEFAULT_RETENTION['user-record'],
            'topic-operator-record': config.retention?.['topic-operator-record'] ?? DEFAULT_RETENTION['topic-operator-record'],
            'threadline-pairing-record': config.retention?.['threadline-pairing-record'] ?? DEFAULT_RETENTION['threadline-pairing-record'],
            'subscription-account-meta': config.retention?.['subscription-account-meta'] ?? DEFAULT_RETENTION['subscription-account-meta'],
        };
        this.rateCapCfg = config.rateCap ?? DEFAULT_RATE_CAP;
        this.artifactRoots = (config.artifactRoots ?? [path.join(this.stateDir, 'autonomous'), this.stateDir]).map((r) => {
            const resolved = path.resolve(r);
            // Realpath the root too, so a realpath'd candidate (e.g. macOS
            // /var → /private/var) compares against a realpath'd root, not a raw one.
            try {
                return fs.realpathSync(resolved);
            }
            catch {
                return resolved;
            }
        });
        this.guardWrite = config.guardWrite;
        this.now = config.now ?? (() => new Date());
        this.logger = config.logger;
        this.io = config.fsImpl ?? realFs();
        const initMs = this.now().getTime();
        this.rateBuckets = {
            'topic-placement': { tokens: this.rateCapCfg.capacity, lastRefillMs: initMs },
            'session-lifecycle': { tokens: this.rateCapCfg.capacity, lastRefillMs: initMs },
            'autonomous-run': { tokens: this.rateCapCfg.capacity, lastRefillMs: initMs },
            'threadline-conversation': { tokens: this.rateCapCfg.capacity, lastRefillMs: initMs },
            'guard-latch': { tokens: this.rateCapCfg.capacity, lastRefillMs: initMs },
            'pref-record': { tokens: this.rateCapCfg.capacity, lastRefillMs: initMs },
            'relationship-record': { tokens: this.rateCapCfg.capacity, lastRefillMs: initMs },
            'learning-record': { tokens: this.rateCapCfg.capacity, lastRefillMs: initMs },
            'knowledge-record': { tokens: this.rateCapCfg.capacity, lastRefillMs: initMs },
            'evolution-action-record': { tokens: this.rateCapCfg.capacity, lastRefillMs: initMs },
            'user-record': { tokens: this.rateCapCfg.capacity, lastRefillMs: initMs },
            'topic-operator-record': { tokens: this.rateCapCfg.capacity, lastRefillMs: initMs },
            'threadline-pairing-record': { tokens: this.rateCapCfg.capacity, lastRefillMs: initMs },
            'subscription-account-meta': { tokens: this.rateCapCfg.capacity, lastRefillMs: initMs },
        };
    }
    // ---- lifecycle ----------------------------------------------------------
    /**
     * Acquire the single-process lock, recover any torn trailing lines, seed the
     * seq counters + op-key index, and start the background flusher. Idempotent —
     * a second `open()` on an already-open instance is a no-op.
     */
    open() {
        if (this.state !== 'closed')
            return;
        this.ensureDirs();
        if (!this.tryActivate()) {
            // Loser: disable the writer — but KEEP TRYING on a bounded cadence.
            // Issue #925 (live 2026-06-06): a restart cascade left a stale lock the
            // boot-time reclaim couldn't take (the old process lingered through the
            // handoff, then died) — with no retry, the writer stayed silently
            // read-only until the NEXT restart. The retry timer closes that hole:
            // the moment the holder exits (or its lock goes mtime-stale), this
            // process takes over and the writer recovers in place.
            this.state = 'writer-locked-out';
            this.log('lock', `[coherence-journal] writer locked out for ${this.machineId} — another process holds the lock; retrying in background`);
            const retryMs = Math.max(this.flushIntervalMs * 40, 10_000);
            this.retryTimer = setInterval(() => {
                if (this.state !== 'writer-locked-out')
                    return;
                if (this.tryActivate()) {
                    this.log('lock', `[coherence-journal] writer lock RECOVERED for ${this.machineId} — emissions resume`);
                    if (this.retryTimer) {
                        clearInterval(this.retryTimer);
                        this.retryTimer = null;
                    }
                }
            }, retryMs);
            if (typeof this.retryTimer.unref === 'function')
                this.retryTimer.unref();
            return;
        }
    }
    /** Acquire + recover + start the flusher. Returns false when the lock is held. */
    tryActivate() {
        if (!this.acquireLock())
            return false;
        this.loadOrInitMeta();
        for (const kind of JOURNAL_KINDS) {
            this.recoverKind(kind);
        }
        this.state = 'active';
        this.timer = setInterval(() => this.flush(), this.flushIntervalMs);
        if (typeof this.timer.unref === 'function')
            this.timer.unref();
        return true;
    }
    /** Stop the flusher, drain once synchronously (best-effort), release the lock. */
    close() {
        if (this.retryTimer) {
            clearInterval(this.retryTimer);
            this.retryTimer = null;
        }
        if (this.timer) {
            clearInterval(this.timer);
            this.timer = null;
        }
        if (this.state === 'active') {
            this.flush(); // final best-effort drain
        }
        this.releaseLock();
        this.state = 'closed';
    }
    /** Writer status (queryable from the instance per §3.1). */
    get status() {
        return this.state;
    }
    get isLockedOut() {
        return this.state === 'writer-locked-out';
    }
    getDegradation() {
        return { ...this.degradation };
    }
    /** Per-stream status (incarnation, lastSeq, highWaterSeq) for the read API. */
    streamStatuses() {
        return JOURNAL_KINDS.map((kind) => ({
            kind,
            machine: this.machineId,
            incarnation: this.incarnation,
            lastSeq: Math.max(this.highWaterSeq[kind], this.nextSeq[kind] - 1),
            highWaterSeq: this.highWaterSeq[kind],
        }));
    }
    /**
     * The OWN-stream replication advert for the journal-sync transport (§3.4 rule
     * 5): per kind, the incarnation + the highest DURABLY-FLUSHED seq this writer
     * can serve. Unlike `streamStatuses().lastSeq`, this advertises `highWaterSeq`
     * (advanced only after data fdatasync) — NEVER an enqueued-but-unflushed seq —
     * so a peer never requests a delta we cannot serve from the file. Returns `{}`
     * with a zeroed entry per kind when nothing has been flushed yet; callers may
     * forward it verbatim (old peers ignore unknown fields).
     */
    getOwnAdvert() {
        const out = {};
        for (const kind of JOURNAL_KINDS) {
            out[kind] = { incarnation: this.incarnation, lastSeq: this.highWaterSeq[kind] };
        }
        return out;
    }
    /** Number of entries enqueued but not yet flushed (tests / observability). */
    get pendingCount() {
        return this.queue.length;
    }
    // ---- emit (the hot path — NON-BLOCKING) --------------------------------
    /** topic-placement (§3.2). Op key: (topic, epoch). */
    emitPlacement(topic, data) {
        this.emit('topic-placement', topic, data, `${topic}:${data?.epoch}`);
    }
    /** session-lifecycle (§3.2). Op key: (sessionId, status). topic optional. */
    emitLifecycle(data, topic) {
        this.emit('session-lifecycle', topic, data, `${data?.sessionId}:${data?.status}`);
    }
    /** autonomous-run (§3.2). Op key: (topic, runId, action). */
    emitAutonomousRun(topic, data) {
        this.emit('autonomous-run', topic, data, `${topic}:${data?.runId}:${data?.action}`);
    }
    /** threadline-conversation (P3 §3.1). Op key: (conversationId, action, topicId?). */
    emitThreadlineConversation(data) {
        this.emit('threadline-conversation', data?.topicId, data, `${data?.conversationId}:${data?.action}:${typeof data?.topicId === 'number' ? data.topicId : ''}`);
    }
    /**
     * guard-latch (green-pr-automerge R9/R7). Op key: (latchKind, latchId, seq) —
     * each transition carries a fresh monotonic `seq`, so distinct transitions are
     * never deduped away while a genuine retry of the SAME (kind,id,seq) collapses.
     */
    emitGuardLatch(data) {
        this.emit('guard-latch', undefined, data, `${data?.latchKind}:${data?.latchId}:${data?.seq}`);
    }
    /**
     * Inject the replicated-kind registry (WS2 send-side). Idempotent + optional:
     * the journal validates a registered `*-record` kind's envelope via the store
     * schema the registry carries; absent ⇒ unchanged behavior. Called once at
     * wiring time (server.ts), after the concrete stores register their kinds.
     */
    setReplicatedKindRegistry(registry) {
        this.replicatedRegistry = registry;
    }
    /**
     * Emit a replicated-store record (WS2 send-side). `data` MUST be a built,
     * disclosure-minimized envelope `data` (the store's `build*RecordData` output)
     * carrying `recordKey`/`hlc`/`op`/`origin` (+ optional `observed`). The op-key
     * is `recordKey:serializeHlcKey(hlc)` so a retry of the EXACT logical event
     * (same key + same HLC) dedupes, while a new HLC is a distinct event — the §4
     * idempotency rule. Validation runs in the shared `emit()` path (which delegates
     * a registered replicated kind to `validateReplicatedEnvelope`), so a malformed
     * record is a counted schema-reject, never a throw. Non-blocking; never throws
     * into the caller (the manager emit hooks are best-effort). A no-op when the
     * kind is not a registered replicated kind (the registry is the authority).
     */
    emitReplicatedRecord(kind, data) {
        try {
            if (!this.replicatedRegistry?.isReplicatedKind(kind))
                return; // not a registered replicated kind ⇒ no-op
            const opKey = this.replicatedOpKey(data);
            this.emit(kind, undefined, data, opKey);
        }
        catch (e) { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
            this.log('emit-record', `[coherence-journal] emitReplicatedRecord failed (swallowed): ${e?.message}`);
        }
    }
    /** Derive the restart-proof op-key for a replicated record: `recordKey:hlcKey`.
     *  Best-effort — a malformed key/hlc still yields a stable string (the record is
     *  then rejected by validate(), so the op-key only matters for well-formed ones). */
    replicatedOpKey(data) {
        const recordKey = typeof data.recordKey === 'string' ? data.recordKey : '';
        let hlcKey = '';
        try {
            hlcKey = serializeHlcKey(coerceHlc(data.hlc));
        }
        catch { /* @silent-fallback-ok: a malformed hlc yields an empty hlcKey; validate() then rejects the whole record (schemaRejects) — the op-key never gates a malformed record's acceptance. */
            hlcKey = '';
        }
        return `${recordKey}:${hlcKey}`;
    }
    /** The per-entry byte cap for a kind — RAISED for replicated `*-record` kinds
     *  (a disclosure-minimized record can exceed the 8 KB lifecycle cap; §4). */
    maxEntryBytesForKind(kind) {
        return this.replicatedRegistry?.isReplicatedKind(kind)
            ? REPLICATED_RECORD_MAX_ENTRY_BYTES
            : this.maxEntryBytes;
    }
    /**
     * Generic non-blocking emit: validate + jail + dedupe + rate-cap + serialize,
     * assign a seq, enqueue, and RETURN. No synchronous file I/O on this stack —
     * the flusher does all of it. Never throws into the caller.
     */
    emit(kind, topic, rawData, opKey) {
        try {
            if (this.state !== 'active')
                return; // locked-out or closed: silent no-op (never block)
            // Rate cap (token bucket) — over-cap emits drop + count.
            if (!this.takeToken(kind)) {
                this.degradation.rateLimited++;
                return;
            }
            // Typed-schema validation: rejects free text, coerces enums, DROPS unknown
            // fields (counted), jails artifactPaths.
            const validated = this.validate(kind, rawData);
            if (!validated) {
                this.degradation.schemaRejects++;
                return;
            }
            // Op-key idempotency (restart-proof — the index is reconstructed on open).
            if (this.opKeys[kind].has(opKey)) {
                this.degradation.dedupeHits++;
                return;
            }
            const seq = this.nextSeq[kind];
            const entry = {
                seq,
                ts: this.now().toISOString(),
                machine: this.machineId,
                kind,
                ...(typeof topic === 'number' ? { topic } : {}),
                data: validated,
            };
            const line = JSON.stringify(entry);
            // Per-entry size cap (8KB lifecycle / 80KB replicated record) — over-cap drop + count.
            if (Buffer.byteLength(line, 'utf-8') > this.maxEntryBytesForKind(kind)) {
                this.degradation.oversize++;
                return;
            }
            // Commit to in-memory state: bump the counter, mark the op key, enqueue.
            this.nextSeq[kind] = seq + 1;
            this.opKeys[kind].add(opKey);
            this.trimOpKeys(kind);
            this.queue.push({ kind, line, seq });
        }
        catch (e) { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
            // The journal never throws into its caller (§3.1).
            this.log('emit', `[coherence-journal] emit failed (swallowed): ${e?.message}`);
        }
    }
    // ---- validation + jail --------------------------------------------------
    /**
     * §3.2 typed schema. Returns the validated `data` object (only known fields,
     * enums enforced) or null to reject. Unknown fields are dropped + counted.
     */
    validate(kind, raw) {
        if (!raw || typeof raw !== 'object')
            return null;
        // WS2 send-side: a registered replicated `*-record` kind validates through the
        // GENERIC envelope validator + its store-specific schema (the same strict
        // discipline as the lifecycle kinds — reject free text, drop unknown fields,
        // jail path-shaped fields, validate hlc/observed). The reconstructed `data`
        // (validated envelope fields authoritative) is what enqueues. Absent registry
        // ⇒ falls through to the lifecycle switch (which rejects an unknown kind).
        const reg = this.replicatedRegistry?.getByKind(kind);
        if (reg) {
            const counters = {
                // emit() bumps schemaRejects ONCE when validate() returns null (the
                // lifecycle path's contract) — so the validator's schema-reject is a no-op
                // here to avoid double-counting; the single emit()-side bump stands.
                bumpSchemaReject: () => { },
                bumpDroppedField: () => { this.degradation.droppedFields++; },
                bumpJailReject: () => { this.degradation.jailRejects++; },
            };
            const result = validateReplicatedEnvelope(raw, reg.schema, counters);
            return result.ok ? result.data : null;
        }
        const seen = Object.keys(raw);
        let out = null;
        let known = [];
        if (kind === 'topic-placement') {
            const owner = raw.owner;
            const epoch = raw.epoch;
            const reason = raw.reason;
            if (typeof owner !== 'string' || !owner)
                return null;
            if (typeof epoch !== 'number' || !Number.isFinite(epoch))
                return null;
            // KEEP IN SYNC with the PlacementReason union above — the type annotation
            // does NOT enforce completeness (a subset is type-legal), so extending the
            // union without this list silently schema-rejects the new reason at the
            // source (caught by the WS1.3 second-pass review, 2026-06-12).
            const reasons = ['user-move', 'placed', 'failover', 'released', 'quota-block-move', 'reconcile'];
            if (typeof reason !== 'string' || !reasons.includes(reason))
                return null;
            out = { owner, epoch, reason };
            known = ['owner', 'epoch', 'reason', 'prevOwner'];
            if (raw.prevOwner !== undefined) {
                if (typeof raw.prevOwner !== 'string')
                    return null;
                out.prevOwner = raw.prevOwner;
            }
        }
        else if (kind === 'session-lifecycle') {
            const sessionId = raw.sessionId;
            const status = raw.status;
            if (typeof sessionId !== 'string' || !sessionId)
                return null;
            const statuses = ['created', 'completed', 'killed', 'reaped', 'failed'];
            if (typeof status !== 'string' || !statuses.includes(status))
                return null;
            out = { sessionId, status };
            known = ['sessionId', 'status', 'reapReason', 'reapLogRef'];
            if (raw.reapReason !== undefined) {
                if (typeof raw.reapReason !== 'string')
                    return null;
                out.reapReason = raw.reapReason;
            }
            if (raw.reapLogRef !== undefined) {
                if (typeof raw.reapLogRef !== 'string')
                    return null;
                out.reapLogRef = raw.reapLogRef;
            }
        }
        else if (kind === 'autonomous-run') {
            const action = raw.action;
            const runId = raw.runId;
            const actions = ['started', 'stopped'];
            if (typeof action !== 'string' || !actions.includes(action))
                return null;
            if (typeof runId !== 'string' || !runId)
                return null;
            const paths = raw.artifactPaths;
            if (!Array.isArray(paths))
                return null;
            const jailed = this.jailArtifactPaths(paths);
            if (jailed === null)
                return null; // any path failed the jail → reject the entry
            out = { action, runId, artifactPaths: jailed };
            known = ['action', 'runId', 'artifactPaths'];
        }
        else if (kind === 'threadline-conversation') {
            // P3 §3.1 — content-free lifecycle: ids + fingerprint only, free text
            // structurally excluded (the P1 typed-schema invariant).
            const action = raw.action;
            const conversationId = raw.conversationId;
            const peerFingerprint = raw.peerFingerprint;
            const actions = ['started', 'bound', 'unbound', 'closed'];
            if (typeof action !== 'string' || !actions.includes(action))
                return null;
            if (typeof conversationId !== 'string' || !conversationId || conversationId.length > 256)
                return null;
            if (typeof peerFingerprint !== 'string' || !peerFingerprint || peerFingerprint.length > 256)
                return null;
            const topicId = raw.topicId;
            if (topicId !== undefined && (typeof topicId !== 'number' || !Number.isFinite(topicId)))
                return null;
            out = { action, conversationId, peerFingerprint, ...(topicId !== undefined ? { topicId } : {}) };
            known = ['action', 'conversationId', 'peerFingerprint', 'topicId'];
        }
        else if (kind === 'guard-latch') {
            // green-pr-automerge R9/R7 — content-free latch transition. Free text
            // structurally excluded; `reason` is a short slug, length-capped.
            const latchKind = raw.latchKind;
            const latchId = raw.latchId;
            const action = raw.action;
            const epoch = raw.epoch;
            const seq = raw.seq;
            const actions = ['set', 'clear'];
            if (typeof latchKind !== 'string' || !latchKind || latchKind.length > 64)
                return null;
            if (typeof latchId !== 'string' || !latchId || latchId.length > 128)
                return null;
            if (typeof action !== 'string' || !actions.includes(action))
                return null;
            if (typeof epoch !== 'number' || !Number.isFinite(epoch))
                return null;
            if (typeof seq !== 'number' || !Number.isFinite(seq))
                return null;
            out = { latchKind, latchId, action, epoch, seq };
            known = ['latchKind', 'latchId', 'action', 'epoch', 'seq', 'reason'];
            if (raw.reason !== undefined) {
                if (typeof raw.reason !== 'string')
                    return null;
                out.reason = raw.reason.slice(0, 80);
            }
        }
        else {
            return null;
        }
        // Count dropped unknown fields (defense-in-depth against free text /
        // secret-shaped fields entering the stream — they structurally cannot).
        for (const k of seen) {
            if (!known.includes(k))
                this.degradation.droppedFields++;
        }
        return out;
    }
    /**
     * §3.1 write-time jail. Each path must resolve (and, if it exists, realpath)
     * to a location contained under an allowlisted root. `..` segments, absolute
     * paths outside the jail, and symlink escapes are rejected. Returns the
     * canonicalized path list, or null if ANY path escapes (the whole entry is
     * then rejected + counted by the caller).
     */
    jailArtifactPaths(paths) {
        const out = [];
        for (const p of paths) {
            if (typeof p !== 'string' || !p) {
                this.degradation.jailRejects++;
                return null;
            }
            const resolved = path.isAbsolute(p) ? path.resolve(p) : path.resolve(this.stateDir, p);
            // realpath the deepest existing ancestor to defeat symlink escapes.
            const real = this.realpathContained(resolved);
            if (real === null) {
                this.degradation.jailRejects++;
                return null;
            }
            out.push(real);
        }
        return out;
    }
    /**
     * Returns the realpath-resolved candidate IF it is contained under an
     * allowlisted root (symlink-safe), else null. We realpath the deepest
     * existing ancestor and re-append the non-existent tail so a path that does
     * not exist yet still gets symlink-escape protection on its existing prefix.
     */
    realpathContained(candidate) {
        let existing = candidate;
        const tail = [];
        while (!this.io.existsSync(existing)) {
            const parent = path.dirname(existing);
            if (parent === existing)
                break; // reached fs root
            tail.unshift(path.basename(existing));
            existing = parent;
        }
        let realExisting;
        try {
            realExisting = fs.realpathSync(existing);
        }
        catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
            realExisting = existing;
        }
        const finalPath = tail.length ? path.join(realExisting, ...tail) : realExisting;
        for (const root of this.artifactRoots) {
            if (this.isContained(root, realExisting) && this.isContained(root, finalPath)) {
                return finalPath;
            }
        }
        return null;
    }
    isContained(root, p) {
        const rel = path.relative(root, p);
        return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
    }
    // ---- rate cap (token bucket) -------------------------------------------
    takeToken(kind) {
        const b = this.rateBuckets[kind];
        const nowMs = this.now().getTime();
        const elapsedSec = Math.max(0, (nowMs - b.lastRefillMs) / 1000);
        if (elapsedSec > 0) {
            b.tokens = Math.min(this.rateCapCfg.capacity, b.tokens + elapsedSec * this.rateCapCfg.refillPerSec);
            b.lastRefillMs = nowMs;
        }
        if (b.tokens >= 1) {
            b.tokens -= 1;
            return true;
        }
        return false;
    }
    // ---- flusher (background — the ONLY thing that touches stream files) ----
    /**
     * Drain the queue: per kind, append data lines (single-line O_APPEND writes),
     * batch fdatasync, THEN advance meta.highWaterSeq via atomic temp rename.
     * Crash-safe ordering: data durable BEFORE meta. Never throws.
     */
    flush() {
        // Lock mtime heartbeat (issue #925, pid-reuse defense): refresh the lock
        // file's mtime on a slow cadence so a LIVE holder is distinguishable from
        // a dead one whose pid was reused — reclaim treats an mtime-stale lock as
        // dead even when kill(pid, 0) succeeds.
        if (this.state === 'active' && this.lockFd !== null && ++this.flushCount % 40 === 0) {
            try {
                this.io.futimesSync?.(this.lockFd, new Date(), new Date());
            }
            catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
            }
        }
        if (this.state !== 'active')
            return;
        if (this.flushing)
            return; // re-entrancy guard (the timer can overlap a slow flush)
        if (this.queue.length === 0)
            return;
        this.flushing = true;
        try {
            // Group the drained queue by kind, preserving seq order.
            const batch = this.queue;
            this.queue = [];
            const byKind = new Map();
            for (const q of batch) {
                const arr = byKind.get(q.kind);
                if (arr)
                    arr.push(q);
                else
                    byKind.set(q.kind, [q]);
            }
            for (const [kind, items] of byKind) {
                items.sort((a, b) => a.seq - b.seq);
                // Rotate-if-full BEFORE appending, so a current file always exists after
                // a flush and seq continues into a fresh file (§3.7). Seq is untouched.
                this.maybeRotate(kind);
                const filePath = this.currentFilePath(kind);
                let appended = [];
                try {
                    // Injected standby guard — throwing skips this kind's batch + counts.
                    if (this.guardWrite)
                        this.guardWrite(filePath);
                }
                catch (e) { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
                    this.degradation.guardSkips++;
                    this.log('guard', `[coherence-journal] guardWrite refused ${kind}: ${e?.message}`);
                    // Re-queue this kind's items so a transient guard refusal doesn't lose them.
                    this.queue.push(...items);
                    continue;
                }
                let fd = null;
                let bytesWritten = false; // any byte appended to the O_APPEND file?
                try {
                    fd = this.io.openSync(filePath, 'a');
                    for (const it of items) {
                        const buf = Buffer.from(it.line + '\n', 'utf-8');
                        this.io.writeSync(fd, buf, 0, buf.length);
                        bytesWritten = true;
                        appended.push(it);
                    }
                    this.io.fdatasyncSync(fd);
                }
                catch (e) {
                    this.degradation.flushErrors++;
                    this.log('flush', `[coherence-journal] append/fsync failed for ${kind}: ${e?.message}`);
                    if (!bytesWritten) {
                        // Open failed (or threw before any write): zero bytes hit the file,
                        // so re-queue ALL items — no torn line, no duplicate seq on disk.
                        this.queue.push(...items);
                    }
                    else {
                        // Some bytes were appended to the O_APPEND file but fsync did not
                        // confirm durability. Re-queuing would risk duplicate seqs on disk
                        // (the bytes may already be persisted). Per §3.1 the flush window is
                        // the accepted loss bound, so we DROP these (counted) rather than
                        // fork the on-disk seq line. Recovery-on-open repairs any torn tail.
                    }
                    appended = [];
                }
                finally {
                    if (fd !== null) {
                        try {
                            this.io.closeSync(fd);
                        }
                        catch {
                            /* best-effort */
                        }
                    }
                }
                if (appended.length > 0) {
                    // Data is durable — NOW advance meta.highWaterSeq (atomic rename).
                    const top = appended[appended.length - 1].seq;
                    this.highWaterSeq[kind] = Math.max(this.highWaterSeq[kind], top);
                    this.persistMeta();
                }
            }
        }
        catch (e) { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
            this.degradation.flushErrors++;
            this.log('flush', `[coherence-journal] flush failed (swallowed): ${e?.message}`);
        }
        finally {
            this.flushing = false;
        }
    }
    // ---- meta (incarnation + highWaterSeq) ---------------------------------
    loadOrInitMeta() {
        const metaPath = this.metaPath();
        let meta = null;
        if (this.io.existsSync(metaPath)) {
            try {
                const raw = this.io.readFileSync(metaPath, 'utf-8');
                const obj = JSON.parse(raw);
                if (obj && typeof obj.incarnation === 'string' && obj.kinds && typeof obj.kinds === 'object') {
                    meta = obj;
                }
            }
            catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
                meta = null; // malformed meta → re-init below
            }
        }
        if (meta) {
            this.incarnation = meta.incarnation;
            for (const kind of JOURNAL_KINDS) {
                this.highWaterSeq[kind] = Math.max(0, Number(meta.kinds[kind]?.highWaterSeq ?? 0) | 0);
            }
        }
        else {
            this.incarnation = this.mintIncarnation();
            for (const kind of JOURNAL_KINDS)
                this.highWaterSeq[kind] = 0;
            this.persistMeta();
        }
    }
    persistMeta() {
        const meta = {
            incarnation: this.incarnation,
            kinds: {
                'topic-placement': { highWaterSeq: this.highWaterSeq['topic-placement'] },
                'session-lifecycle': { highWaterSeq: this.highWaterSeq['session-lifecycle'] },
                'autonomous-run': { highWaterSeq: this.highWaterSeq['autonomous-run'] },
                'threadline-conversation': { highWaterSeq: this.highWaterSeq['threadline-conversation'] },
                'guard-latch': { highWaterSeq: this.highWaterSeq['guard-latch'] },
            },
        };
        const metaPath = this.metaPath();
        const tmp = metaPath + '.tmp';
        this.io.writeFileSync(tmp, JSON.stringify(meta, null, 2), { mode: 0o644 });
        this.io.renameSync(tmp, metaPath);
    }
    mintIncarnation() {
        return crypto.randomBytes(12).toString('hex');
    }
    // ---- open-time recovery (per kind) -------------------------------------
    /**
     * Open a kind's current stream file: repair any torn trailing line, seed
     * `nextSeq` from the durable tail, reconstruct the op-key window, and apply
     * the incarnation re-mint rule (re-mint IFF last seq < highWaterSeq).
     */
    recoverKind(kind) {
        const filePath = this.currentFilePath(kind);
        if (!this.io.existsSync(filePath)) {
            // Fresh stream: nextSeq seeds from highWaterSeq+1 (handles a brand-new
            // stream where highWaterSeq is 0, and a meta-ahead-of-missing-file case).
            this.nextSeq[kind] = this.highWaterSeq[kind] + 1;
            return;
        }
        let content;
        try {
            content = this.io.readFileSync(filePath, 'utf-8');
        }
        catch (e) {
            this.log('recover', `[coherence-journal] could not read ${kind} on open: ${e?.message}`);
            this.nextSeq[kind] = this.highWaterSeq[kind] + 1;
            return;
        }
        // Repair a torn trailing line: a file that does NOT end in '\n' has a
        // partial final line from a crash mid-append. Truncate to the last newline.
        if (content.length > 0 && !content.endsWith('\n')) {
            const lastNl = content.lastIndexOf('\n');
            const keepLen = lastNl + 1; // bytes up to and including the last newline (0 if none)
            try {
                this.io.truncateSync(filePath, Buffer.byteLength(content.slice(0, keepLen), 'utf-8'));
            }
            catch (e) {
                this.log('repair', `[coherence-journal] truncate repair failed for ${kind}: ${e?.message}`);
            }
            content = content.slice(0, keepLen);
            this.degradation.repairs++;
            if (!this.repairSignalled) {
                this.repairSignalled = true;
                this.log('repair-signal', `[coherence-journal] repaired a torn trailing line in ${kind} (one-time signal)`);
            }
        }
        // Find the last well-formed entry's seq (the durable tail).
        const lines = content.length > 0 ? content.split('\n') : [];
        let lastSeq = 0;
        for (let i = lines.length - 1; i >= 0; i--) {
            const ln = lines[i];
            if (!ln)
                continue;
            try {
                const obj = JSON.parse(ln);
                if (typeof obj.seq === 'number') {
                    lastSeq = obj.seq;
                    break;
                }
            }
            catch {
                // interior corruption — keep scanning back for the last good seq
            }
        }
        // §3.4 rule 3: re-mint IFF the file's last seq is STRICTLY below the durable
        // highWaterSeq (a genuine rewind / restore-from-backup). A trailing-partial
        // repair is NOT a re-mint: the repaired tail still has lastSeq == highWater
        // because the meta is only advanced AFTER data is fsync'd.
        if (lastSeq < this.highWaterSeq[kind]) {
            this.incarnation = this.mintIncarnation();
            this.degradation.remints++;
            this.log('remint', `[coherence-journal] re-minted incarnation for ${kind} (rewind: last ${lastSeq} < hw ${this.highWaterSeq[kind]})`);
            // Adopt the file tail as the new durable truth and persist the new meta.
            this.highWaterSeq[kind] = lastSeq;
            this.persistMeta();
        }
        else if (lastSeq > this.highWaterSeq[kind]) {
            // Durable data ahead of meta (kill-9 AFTER data fsync, BEFORE meta write).
            // Adopt the file tail; this is NOT a rewind. Advance meta to match.
            this.highWaterSeq[kind] = lastSeq;
            this.persistMeta();
        }
        this.nextSeq[kind] = Math.max(lastSeq, this.highWaterSeq[kind]) + 1;
        this.reconstructOpKeys(kind, lines);
    }
    /** Tail-scan the last DEDUPE_WINDOW lines to rebuild the op-key index. */
    reconstructOpKeys(kind, lines) {
        const start = Math.max(0, lines.length - DEDUPE_WINDOW - 1);
        for (let i = start; i < lines.length; i++) {
            const ln = lines[i];
            if (!ln)
                continue;
            try {
                const obj = JSON.parse(ln);
                const key = this.opKeyOf(kind, obj);
                if (key)
                    this.opKeys[kind].add(key);
            }
            catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
                // skip corrupt lines
            }
        }
        this.trimOpKeys(kind);
    }
    opKeyOf(kind, entry) {
        const d = entry.data;
        if (kind === 'topic-placement') {
            if (typeof entry.topic !== 'number' || typeof d.epoch !== 'number')
                return null;
            return `${entry.topic}:${d.epoch}`;
        }
        if (kind === 'session-lifecycle') {
            if (typeof d.sessionId !== 'string' || typeof d.status !== 'string')
                return null;
            return `${d.sessionId}:${d.status}`;
        }
        if (kind === 'autonomous-run') {
            if (typeof entry.topic !== 'number' || typeof d.runId !== 'string' || typeof d.action !== 'string')
                return null;
            return `${entry.topic}:${d.runId}:${d.action}`;
        }
        if (kind === 'threadline-conversation') {
            // P3 §3.1: (conversationId, action, topicId?) — conversationId lives in
            // data, not entry.topic (round-1 integration finding).
            if (typeof d.conversationId !== 'string' || typeof d.action !== 'string')
                return null;
            return `${d.conversationId}:${d.action}:${typeof d.topicId === 'number' ? d.topicId : ''}`;
        }
        if (kind === 'guard-latch') {
            if (typeof d.latchKind !== 'string' || typeof d.latchId !== 'string' || typeof d.seq !== 'number')
                return null;
            return `${d.latchKind}:${d.latchId}:${d.seq}`;
        }
        return null;
    }
    /** Keep the op-key window bounded (No Unbounded Loops). */
    trimOpKeys(kind) {
        const set = this.opKeys[kind];
        const cap = DEDUPE_WINDOW * 2;
        if (set.size <= cap)
            return;
        // Sets preserve insertion order; drop the oldest down to DEDUPE_WINDOW.
        const drop = set.size - DEDUPE_WINDOW;
        let i = 0;
        for (const k of set) {
            if (i++ >= drop)
                break;
            set.delete(k);
        }
    }
    // ---- rotation (§3.7) ----------------------------------------------------
    /**
     * Rotate the current file when it exceeds maxFileBytes. rotateKeep N>0 keeps
     * N archives + deletes older; rotateKeep 0 rotates but NEVER deletes. Seq
     * continues across rotation (the in-memory counter is untouched).
     */
    maybeRotate(kind) {
        const ret = this.retention[kind];
        const filePath = this.currentFilePath(kind);
        let size = 0;
        try {
            size = this.io.statSync(filePath).size;
        }
        catch {
            return;
        }
        if (size < ret.maxFileBytes)
            return;
        // Archive name: <safeMachineId>.<kind>.<ts><counter>.jsonl. The numeric
        // suffix is (ts * 1000 + counter), so it stays in the `\d+` archive regex,
        // sorts oldest→newest, AND is unique even for multiple rotations in one ms.
        const stamp = `${this.now().getTime() * 1000 + (this.rotationCounter++ % 1000)}`;
        const archive = path.join(this.dirPath(), `${this.safeMachineId}.${kind}.${stamp}.jsonl`);
        try {
            // Guard the rename target too (own-stream prefix).
            if (this.guardWrite)
                this.guardWrite(archive);
            this.io.renameSync(filePath, archive);
        }
        catch (e) {
            this.log('rotate', `[coherence-journal] rotate failed for ${kind}: ${e?.message}`);
            return;
        }
        if (ret.rotateKeep > 0) {
            this.pruneArchives(kind, ret.rotateKeep);
        }
        // rotateKeep === 0 → never delete (history forever in bounded files).
    }
    pruneArchives(kind, keep) {
        const archives = this.listArchives(kind);
        if (archives.length <= keep)
            return;
        // archives is sorted oldest→newest; delete the oldest beyond `keep`.
        const toDelete = archives.slice(0, archives.length - keep);
        for (const a of toDelete) {
            try {
                SafeFsExecutor.safeRmSync(path.join(this.dirPath(), a), { force: true, operation: 'coherence-journal:prune-archive' });
            }
            catch (e) { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
                this.log('prune', `[coherence-journal] prune failed for ${a}: ${e?.message}`);
            }
        }
    }
    /** Archive file names for a kind, sorted oldest→newest by the embedded ts. */
    listArchives(kind) {
        const prefix = `${this.safeMachineId}.${kind}.`;
        let names;
        try {
            names = this.io.readdirSync(this.dirPath());
        }
        catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
            return [];
        }
        const re = new RegExp(`^${escapeRegExp(prefix)}(\\d+)\\.jsonl$`);
        const matched = [];
        for (const n of names) {
            const m = re.exec(n);
            if (m)
                matched.push({ name: n, ts: Number(m[1]) });
        }
        matched.sort((a, b) => a.ts - b.ts);
        return matched.map((m) => m.name);
    }
    // ---- minimal tolerant reader (for the read API, built later) -----------
    /** Enumerate this writer's own stream files present on disk (current + archives). */
    enumerateStreams() {
        const out = [];
        for (const kind of JOURNAL_KINDS) {
            const cur = this.currentFilePath(kind);
            if (this.io.existsSync(cur))
                out.push({ kind, file: cur, isArchive: false });
            for (const a of this.listArchives(kind)) {
                out.push({ kind, file: path.join(this.dirPath(), a), isArchive: true });
            }
        }
        return out;
    }
    /**
     * Tolerantly read up to `limit` most-recent entries from a kind's CURRENT
     * file via a bounded reverse tail read (per-line try/catch; corrupt lines are
     * skipped + counted). Reads at most `byteCeiling` bytes from the file end.
     */
    readTail(kind, limit = 100, byteCeiling = DEFAULT_READ_BYTE_CEILING) {
        const filePath = this.currentFilePath(kind);
        return readTailTolerant(this.io, filePath, limit, byteCeiling);
    }
    // ---- single-process lock ------------------------------------------------
    acquireLock() {
        const lockPath = this.lockPath();
        try {
            // 'wx' fails if the file exists → advisory lock. Stale locks from a crash
            // are reclaimed if the recorded pid is no longer alive.
            const fd = this.io.openSync(lockPath, 'wx');
            this.lockFd = fd;
            try {
                const buf = Buffer.from(JSON.stringify({ pid: process.pid, at: this.now().toISOString() }), 'utf-8');
                this.io.writeSync(fd, buf, 0, buf.length);
            }
            catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
                /* best-effort lock annotation */
            }
            return true;
        }
        catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
            // Lock exists — check whether the holder is still alive (stale reclaim).
            if (this.reclaimStaleLock(lockPath)) {
                try {
                    const fd = this.io.openSync(lockPath, 'wx');
                    this.lockFd = fd;
                    const buf = Buffer.from(JSON.stringify({ pid: process.pid, at: this.now().toISOString() }), 'utf-8');
                    try {
                        this.io.writeSync(fd, buf, 0, buf.length);
                    }
                    catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
                        /* best-effort */
                    }
                    return true;
                }
                catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
                    return false;
                }
            }
            return false;
        }
    }
    /** Returns true (and removes the lock) if the recorded pid is dead. */
    reclaimStaleLock(lockPath) {
        try {
            const raw = this.io.readFileSync(lockPath, 'utf-8');
            const obj = JSON.parse(raw);
            const pid = Number(obj?.pid);
            if (!Number.isFinite(pid) || pid <= 0)
                return false;
            if (pid === process.pid)
                return false;
            // pid-reuse defense (#925): a LIVE holder heartbeats the lock mtime
            // every ~10s; a lock untouched for 5+ minutes is dead regardless of
            // whether some unrelated process now wears its pid.
            try {
                const st = this.io.statSync(lockPath);
                const mt = st.mtimeMs;
                if (typeof mt === 'number' && this.now().getTime() - mt > 5 * 60 * 1000) {
                    SafeFsExecutor.safeRmSync(lockPath, { force: true, operation: 'coherence-journal:reclaim-mtime-stale-lock (#925)' });
                    return true;
                }
            }
            catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
            }
            try {
                process.kill(pid, 0); // throws if the process does not exist
                return false; // still alive — do NOT reclaim
            }
            catch (err) { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
                if (err?.code === 'ESRCH') {
                    SafeFsExecutor.safeRmSync(lockPath, { force: true, operation: 'coherence-journal:reclaim-stale-lock' });
                    return true;
                }
                return false; // EPERM etc. — be conservative, don't reclaim
            }
        }
        catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
            return false;
        }
    }
    releaseLock() {
        if (this.lockFd !== null) {
            try {
                this.io.closeSync(this.lockFd);
            }
            catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
                /* best-effort */
            }
            this.lockFd = null;
        }
        try {
            SafeFsExecutor.safeRmSync(this.lockPath(), { force: true, operation: 'coherence-journal:release-lock' });
        }
        catch {
            /* best-effort */
        }
    }
    // ---- paths --------------------------------------------------------------
    dirPath() {
        return path.join(this.stateDir, 'state', 'coherence-journal');
    }
    currentFilePath(kind) {
        return path.join(this.dirPath(), `${this.safeMachineId}.${kind}.jsonl`);
    }
    metaPath() {
        return path.join(this.dirPath(), `${this.safeMachineId}.meta.json`);
    }
    lockPath() {
        return path.join(this.dirPath(), `${this.safeMachineId}.lock`);
    }
    ensureDirs() {
        const p = this.dirPath();
        if (!this.io.existsSync(p))
            this.io.mkdirSync(p, { recursive: true });
    }
    log(cls, msg) {
        // One log line per failure class per lifetime (avoid log floods).
        if (this.loggedClasses.has(cls))
            return;
        this.loggedClasses.add(cls);
        this.logger?.(msg);
    }
}
/** Escape a string for use inside a RegExp. */
function escapeRegExp(s) {
    return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
 * Standalone tolerant reverse-tail reader — shared by the writer's `readTail`
 * and (later) the read API's direct-file path. Reads at most `byteCeiling`
 * bytes from the end of `filePath`, parses lines newest-first, skips + counts
 * corrupt lines, and returns up to `limit` entries (newest first).
 */
export function readTailTolerant(io, filePath, limit, byteCeiling) {
    const result = { entries: [], skippedCorrupt: 0, truncated: false };
    if (!io.existsSync(filePath))
        return result;
    let size;
    try {
        size = io.statSync(filePath).size;
    }
    catch { /* @silent-fallback-ok: journal observability must never endanger the observed operation (COHERENCE-JOURNAL-SPEC §3.1) */
        return result;
    }
    if (size === 0)
        return result;
    const readBytes = Math.min(size, byteCeiling);
    if (readBytes < size)
        result.truncated = true;
    const start = size - readBytes;
    let buf;
    let fd = null;
    try {
        fd = io.openSync(filePath, 'r');
        buf = Buffer.alloc(readBytes);
        io.readSync(fd, buf, 0, readBytes, start);
    }
    catch {
        return result;
    }
    finally {
        if (fd !== null) {
            try {
                io.closeSync(fd);
            }
            catch {
                /* best-effort */
            }
        }
    }
    let text = buf.toString('utf-8');
    // If we started mid-file, the first (partial) line is not a whole record.
    if (start > 0) {
        const nl = text.indexOf('\n');
        if (nl >= 0)
            text = text.slice(nl + 1);
        result.truncated = true;
    }
    const lines = text.split('\n');
    for (let i = lines.length - 1; i >= 0 && result.entries.length < limit; i--) {
        const ln = lines[i];
        if (!ln)
            continue;
        try {
            const obj = JSON.parse(ln);
            if (typeof obj.seq === 'number' && typeof obj.kind === 'string') {
                result.entries.push(obj);
            }
            else {
                result.skippedCorrupt++;
            }
        }
        catch {
            result.skippedCorrupt++;
        }
    }
    return result;
}
//# sourceMappingURL=CoherenceJournal.js.map