@bookedsolid/rea 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Shared audit filesystem primitives (G1). Both the gateway audit middleware
3
+ * (`src/gateway/middleware/audit.ts`) and the public append helper
4
+ * (`src/audit/append.ts`) funnel through this module so locking, partial-write
5
+ * recovery, and rotation semantics stay in lockstep.
6
+ *
7
+ * ## Locking
8
+ *
9
+ * Every append acquires a `proper-lockfile` lock on the audit file's parent
10
+ * directory (`.rea/`) — NOT on `audit.jsonl` directly, because `proper-lockfile`
11
+ * refuses to lock a file that does not yet exist. The lock is taken BEFORE the
12
+ * read-last-record → compute-hash → append → fsync sequence, so two processes
13
+ * on the same filesystem can append concurrently without interleaving.
14
+ *
15
+ * Stale-lock detection: `proper-lockfile` handles `EEXIST` with `stale: 10000`
16
+ * (10s). A crashed writer that leaves a stale lockfile frees itself on the
17
+ * next append attempt.
18
+ *
19
+ * ## Partial-write recovery
20
+ *
21
+ * An append that crashes mid-write leaves a trailing line WITHOUT a newline.
22
+ * `readLastRecord()` detects this signal (file doesn't end with `\n`) and
23
+ * truncates the partial line before returning the previous record's hash.
24
+ * This recovery is idempotent and runs on every read.
25
+ *
26
+ * ## Locking contract
27
+ *
28
+ * Callers invoke `withAuditLock(auditFile, async () => { ... })`. The callback
29
+ * MUST perform its read → compute → append → fsync inside the lock scope. On
30
+ * lock acquisition failure the callback does NOT run — the caller receives
31
+ * the error and decides whether to fall back (middleware logs and continues;
32
+ * the public helper propagates).
33
+ */
34
+ import fs from 'node:fs/promises';
35
+ import path from 'node:path';
36
+ import crypto from 'node:crypto';
37
+ import properLockfile from 'proper-lockfile';
38
+ export const GENESIS_HASH = '0'.repeat(64);
39
+ /**
40
+ * Lock-file retry envelope. `stale: 10000` lets a crashed holder's lock be
41
+ * reclaimed after 10s of inactivity. Retries are generous because individual
42
+ * appends are cheap (single-digit milliseconds) and under heavy cross-process
43
+ * contention we would rather wait than surface EEXIST to the caller. The
44
+ * budget is bounded (`retries: 40`, max ~300ms per retry) so a truly
45
+ * compromised lockfile still surfaces quickly.
46
+ */
47
+ const LOCK_OPTIONS = {
48
+ stale: 10_000,
49
+ retries: {
50
+ retries: 40,
51
+ factor: 1.3,
52
+ minTimeout: 15,
53
+ maxTimeout: 300,
54
+ randomize: true,
55
+ },
56
+ // Lock the parent directory (the audit file may not exist yet on first
57
+ // write). proper-lockfile's `realpath: false` avoids a symlink check that
58
+ // fails when the file itself hasn't been created.
59
+ realpath: false,
60
+ };
61
+ /**
62
+ * Acquire an exclusive lock on the audit file's parent directory and run
63
+ * `fn` inside it. The parent directory must exist before calling this.
64
+ *
65
+ * The lock is released even if `fn` throws. Lock-acquisition failures
66
+ * surface as the caller's rejection — middleware catches and logs, the
67
+ * public helper propagates.
68
+ */
69
+ export async function withAuditLock(auditFile, fn) {
70
+ const lockTarget = path.dirname(auditFile);
71
+ const release = await properLockfile.lock(lockTarget, LOCK_OPTIONS);
72
+ try {
73
+ return await fn();
74
+ }
75
+ finally {
76
+ try {
77
+ await release();
78
+ }
79
+ catch {
80
+ // Releasing a lock can fail if the lockfile was already cleaned up
81
+ // by stale-detection. That's not a correctness problem for the caller
82
+ // — the work already completed. Swallow.
83
+ }
84
+ }
85
+ }
86
+ export function computeHash(record) {
87
+ return crypto.createHash('sha256').update(JSON.stringify(record)).digest('hex');
88
+ }
89
+ /**
90
+ * Read the last complete JSON record from the audit file. Returns the parsed
91
+ * record plus its hash (the value a new append should use for `prev_hash`).
92
+ *
93
+ * Recovers from three tail states:
94
+ * - File does not exist → genesis.
95
+ * - File exists but is empty (or only whitespace) → genesis.
96
+ * - File tail does not end in `\n` → treat the trailing partial line as a
97
+ * crash signal, truncate it, and return the record before it.
98
+ *
99
+ * Never throws on read-side issues except raw I/O errors (permission, ENOSPC,
100
+ * etc.) that the caller should surface.
101
+ */
102
+ export async function readLastRecord(auditFile) {
103
+ let data;
104
+ try {
105
+ data = await fs.readFile(auditFile, 'utf8');
106
+ }
107
+ catch (err) {
108
+ if (err.code === 'ENOENT') {
109
+ return { record: null, hash: GENESIS_HASH };
110
+ }
111
+ throw err;
112
+ }
113
+ if (data.length === 0) {
114
+ return { record: null, hash: GENESIS_HASH };
115
+ }
116
+ // Partial-write recovery: a crash mid-append leaves the file without a
117
+ // trailing newline. Truncate the unterminated tail before consulting the
118
+ // chain. This is the only way a partial line can reach disk — every clean
119
+ // append writes `JSON.stringify(record) + '\n'`.
120
+ const endsWithNewline = data.endsWith('\n');
121
+ if (!endsWithNewline) {
122
+ const lastNewline = data.lastIndexOf('\n');
123
+ if (lastNewline === -1) {
124
+ // Whole file is a partial write — truncate to empty.
125
+ await fs.truncate(auditFile, 0);
126
+ return { record: null, hash: GENESIS_HASH };
127
+ }
128
+ // Keep everything through the last newline (inclusive); drop the partial
129
+ // tail. +1 to include the newline itself.
130
+ const keepLength = Buffer.byteLength(data.slice(0, lastNewline + 1), 'utf8');
131
+ await fs.truncate(auditFile, keepLength);
132
+ data = data.slice(0, lastNewline + 1);
133
+ }
134
+ const trimmed = data.replace(/\n+$/, '');
135
+ if (trimmed.length === 0) {
136
+ return { record: null, hash: GENESIS_HASH };
137
+ }
138
+ const lastNewline = trimmed.lastIndexOf('\n');
139
+ const lastLine = lastNewline === -1 ? trimmed : trimmed.slice(lastNewline + 1);
140
+ try {
141
+ const parsed = JSON.parse(lastLine);
142
+ if (typeof parsed.hash === 'string' && parsed.hash.length === 64) {
143
+ return { record: parsed, hash: parsed.hash };
144
+ }
145
+ }
146
+ catch {
147
+ // Corrupt tail — fall through. We do NOT throw: refusing to append would
148
+ // mask every subsequent event. The chain-verify command (`rea audit
149
+ // verify`) will flag the break point for the operator.
150
+ }
151
+ return { record: null, hash: GENESIS_HASH };
152
+ }
153
+ /**
154
+ * Open-and-fsync the audit file. Called after an append to flush the write
155
+ * to durable storage. fsync failure is not fatal — the append itself
156
+ * already succeeded; durability is best-effort.
157
+ */
158
+ export async function fsyncFile(filePath) {
159
+ let fh;
160
+ try {
161
+ fh = await fs.open(filePath, 'r');
162
+ await fh.sync();
163
+ }
164
+ catch {
165
+ // fsync failure is not fatal — the write itself already succeeded.
166
+ }
167
+ finally {
168
+ if (fh)
169
+ await fh.close();
170
+ }
171
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * `rea audit` — operator-facing subcommands for the durability story (G1).
3
+ *
4
+ * Two verbs:
5
+ * - `rotate` force-rotate the current `.rea/audit.jsonl`.
6
+ * - `verify [--since <file>]` re-hash the chain and exit 0 on clean, 1
7
+ * naming the first tampered record.
8
+ *
9
+ * Neither command reads policy defaults for thresholds — force rotation is
10
+ * explicit by definition, and verify operates on existing files regardless
11
+ * of policy.
12
+ */
13
+ /**
14
+ * Reserved for future rotate knobs (e.g. `--retain N` to prune old rotated
15
+ * files). Empty today — kept as a typed record so the call site's option
16
+ * object stays self-documenting.
17
+ */
18
+ export type AuditRotateOptions = Record<string, never>;
19
+ export interface AuditVerifyOptions {
20
+ /**
21
+ * Optional rotated-file basename (e.g. `audit-20260418-193200.jsonl`).
22
+ * When set, verification walks forward through all rotated files in
23
+ * timestamp order starting at this one, then through the current
24
+ * `audit.jsonl`. When unset, verification runs over the current file
25
+ * only.
26
+ */
27
+ since?: string | undefined;
28
+ }
29
+ /**
30
+ * `rea audit rotate`. Forces a rotation now regardless of thresholds.
31
+ * Empty audit files are a no-op — rotating an empty chain would produce a
32
+ * rotation marker with no meaningful predecessor.
33
+ */
34
+ export declare function runAuditRotate(_options: AuditRotateOptions): Promise<void>;
35
+ /**
36
+ * `rea audit verify [--since <rotated-file>]`. Exits 0 on clean chain, 1 on
37
+ * first tampered record. All diagnostic output goes to stderr so the
38
+ * exit code is the primary signal.
39
+ */
40
+ export declare function runAuditVerify(options: AuditVerifyOptions): Promise<void>;
@@ -0,0 +1,205 @@
1
+ /**
2
+ * `rea audit` — operator-facing subcommands for the durability story (G1).
3
+ *
4
+ * Two verbs:
5
+ * - `rotate` force-rotate the current `.rea/audit.jsonl`.
6
+ * - `verify [--since <file>]` re-hash the chain and exit 0 on clean, 1
7
+ * naming the first tampered record.
8
+ *
9
+ * Neither command reads policy defaults for thresholds — force rotation is
10
+ * explicit by definition, and verify operates on existing files regardless
11
+ * of policy.
12
+ */
13
+ import fs from 'node:fs/promises';
14
+ import path from 'node:path';
15
+ import { forceRotate } from '../gateway/audit/rotator.js';
16
+ import { computeHash, GENESIS_HASH } from '../audit/fs.js';
17
+ import { AUDIT_FILE, REA_DIR, err, log, reaPath } from './utils.js';
18
+ /**
19
+ * `rea audit rotate`. Forces a rotation now regardless of thresholds.
20
+ * Empty audit files are a no-op — rotating an empty chain would produce a
21
+ * rotation marker with no meaningful predecessor.
22
+ */
23
+ export async function runAuditRotate(_options) {
24
+ const baseDir = process.cwd();
25
+ const auditFile = reaPath(baseDir, AUDIT_FILE);
26
+ let exists = true;
27
+ try {
28
+ const stat = await fs.stat(auditFile);
29
+ if (!stat.isFile() || stat.size === 0)
30
+ exists = false;
31
+ }
32
+ catch (e) {
33
+ if (e.code === 'ENOENT') {
34
+ exists = false;
35
+ }
36
+ else {
37
+ err(`Cannot stat ${auditFile}: ${e.message}`);
38
+ process.exit(1);
39
+ }
40
+ }
41
+ if (!exists) {
42
+ log('Audit log empty or missing — nothing to rotate.');
43
+ console.log(` File: ${path.relative(baseDir, auditFile)}`);
44
+ return;
45
+ }
46
+ const result = await forceRotate(auditFile);
47
+ if (!result.rotated) {
48
+ log('Audit log empty — nothing to rotate.');
49
+ return;
50
+ }
51
+ const rotated = result.rotatedTo;
52
+ log('Audit log rotated.');
53
+ console.log(` Rotated to: ${path.relative(baseDir, rotated)}`);
54
+ console.log(` Fresh file: ${path.relative(baseDir, auditFile)}`);
55
+ console.log(` A rotation marker anchors the new chain on the old tail's hash.`);
56
+ }
57
+ /**
58
+ * Load a JSONL audit file as a record array + per-line raw text, so we can
59
+ * re-hash against the exact serialization that was written. Throws on read
60
+ * errors; returns an empty array for an empty file.
61
+ */
62
+ async function loadRecords(filePath) {
63
+ const raw = await fs.readFile(filePath, 'utf8');
64
+ // Drop a single trailing newline but preserve blank lines inside the file
65
+ // so index numbers line up with real record positions.
66
+ const trimmedTail = raw.replace(/\n$/, '');
67
+ if (trimmedTail.length === 0)
68
+ return { records: [], rawLines: [] };
69
+ const rawLines = trimmedTail.split('\n');
70
+ const records = rawLines.map((line, i) => {
71
+ try {
72
+ return JSON.parse(line);
73
+ }
74
+ catch (e) {
75
+ throw new Error(`Cannot parse JSON at ${path.basename(filePath)} line ${i + 1}: ${e.message}`);
76
+ }
77
+ });
78
+ return { records, rawLines };
79
+ }
80
+ function verifyChain(fileBasename, records, expectedStartPrev) {
81
+ let prev = expectedStartPrev;
82
+ for (let i = 0; i < records.length; i++) {
83
+ const r = records[i];
84
+ if (r.prev_hash !== prev) {
85
+ return {
86
+ file: fileBasename,
87
+ lineIndex: i,
88
+ reason: 'prev_hash does not match previous record',
89
+ expected: prev,
90
+ actual: r.prev_hash,
91
+ };
92
+ }
93
+ // Recompute hash across the canonical serialization of the record
94
+ // minus the `hash` field.
95
+ const { hash, ...rest } = r;
96
+ const recomputed = computeHash(rest);
97
+ if (recomputed !== hash) {
98
+ return {
99
+ file: fileBasename,
100
+ lineIndex: i,
101
+ reason: 'stored hash does not match recomputed hash over record body',
102
+ expected: recomputed,
103
+ actual: hash,
104
+ };
105
+ }
106
+ prev = hash;
107
+ }
108
+ return null;
109
+ }
110
+ /**
111
+ * Find all rotated audit files in `reaDir`, in timestamp-ascending order.
112
+ * Filenames follow `audit-YYYYMMDD-HHMMSS.jsonl` (with optional `-N` suffix
113
+ * for intra-second collisions). Lexicographic sort handles both cases.
114
+ */
115
+ async function listRotatedFiles(reaDir) {
116
+ let entries;
117
+ try {
118
+ entries = await fs.readdir(reaDir);
119
+ }
120
+ catch {
121
+ return [];
122
+ }
123
+ const rotated = entries.filter((n) => /^audit-\d{8}-\d{6}(-\d+)?\.jsonl$/.test(n));
124
+ rotated.sort();
125
+ return rotated;
126
+ }
127
+ /**
128
+ * `rea audit verify [--since <rotated-file>]`. Exits 0 on clean chain, 1 on
129
+ * first tampered record. All diagnostic output goes to stderr so the
130
+ * exit code is the primary signal.
131
+ */
132
+ export async function runAuditVerify(options) {
133
+ const baseDir = process.cwd();
134
+ const reaDir = path.join(baseDir, REA_DIR);
135
+ const currentAudit = path.join(reaDir, AUDIT_FILE);
136
+ // Assemble the file walk.
137
+ const filesToVerify = [];
138
+ if (options.since !== undefined && options.since.length > 0) {
139
+ const sinceName = path.basename(options.since);
140
+ if (!/^audit-\d{8}-\d{6}(-\d+)?\.jsonl$/.test(sinceName)) {
141
+ err(`--since must name a rotated audit file (audit-YYYYMMDD-HHMMSS.jsonl); got ${JSON.stringify(options.since)}`);
142
+ process.exit(1);
143
+ }
144
+ const allRotated = await listRotatedFiles(reaDir);
145
+ const startIdx = allRotated.indexOf(sinceName);
146
+ if (startIdx === -1) {
147
+ err(`Rotated file not found: ${path.join(REA_DIR, sinceName)}`);
148
+ process.exit(1);
149
+ }
150
+ for (const name of allRotated.slice(startIdx)) {
151
+ filesToVerify.push(path.join(reaDir, name));
152
+ }
153
+ }
154
+ // The current audit.jsonl is ALWAYS the tail of the walk (unless it
155
+ // doesn't exist — then the caller either asked for --since only or has
156
+ // a fresh install).
157
+ try {
158
+ const stat = await fs.stat(currentAudit);
159
+ if (stat.isFile())
160
+ filesToVerify.push(currentAudit);
161
+ }
162
+ catch (e) {
163
+ if (e.code !== 'ENOENT') {
164
+ err(`Cannot stat ${currentAudit}: ${e.message}`);
165
+ process.exit(1);
166
+ }
167
+ }
168
+ if (filesToVerify.length === 0) {
169
+ err('No audit files to verify.');
170
+ console.error(` Expected: ${path.relative(baseDir, currentAudit)}`);
171
+ process.exit(1);
172
+ }
173
+ let expectedPrev = GENESIS_HASH;
174
+ let totalRecords = 0;
175
+ for (const filePath of filesToVerify) {
176
+ let records;
177
+ try {
178
+ ({ records } = await loadRecords(filePath));
179
+ }
180
+ catch (e) {
181
+ err(`${e.message}`);
182
+ process.exit(1);
183
+ }
184
+ const basename = path.basename(filePath);
185
+ const failure = verifyChain(basename, records, expectedPrev);
186
+ if (failure !== null) {
187
+ err(`Audit chain TAMPER DETECTED in ${failure.file}`);
188
+ console.error(` Record index: ${failure.lineIndex} (0-based within file)`);
189
+ console.error(` Reason: ${failure.reason}`);
190
+ if (failure.expected !== undefined) {
191
+ console.error(` Expected: ${failure.expected}`);
192
+ }
193
+ if (failure.actual !== undefined) {
194
+ console.error(` Actual: ${failure.actual}`);
195
+ }
196
+ process.exit(1);
197
+ }
198
+ // Advance the cross-file anchor for the next file.
199
+ if (records.length > 0) {
200
+ expectedPrev = records[records.length - 1].hash;
201
+ }
202
+ totalRecords += records.length;
203
+ }
204
+ log(`Audit chain verified: ${totalRecords} records across ${filesToVerify.length} file(s) — clean.`);
205
+ }
package/dist/cli/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
+ import { runAuditRotate, runAuditVerify } from './audit.js';
3
4
  import { runCheck } from './check.js';
4
5
  import { runDoctor } from './doctor.js';
5
6
  import { runFreeze, runUnfreeze } from './freeze.js';
@@ -76,6 +77,22 @@ async function main() {
76
77
  .action(() => {
77
78
  runCheck();
78
79
  });
80
+ const audit = program
81
+ .command('audit')
82
+ .description('Audit log operations — rotate and verify .rea/audit.jsonl (G1).');
83
+ audit
84
+ .command('rotate')
85
+ .description('Force-rotate .rea/audit.jsonl now. Preserves hash-chain via a marker record.')
86
+ .action(async () => {
87
+ await runAuditRotate({});
88
+ });
89
+ audit
90
+ .command('verify')
91
+ .description('Re-hash the audit chain; exit 0 on clean, 1 on the first tampered record.')
92
+ .option('--since <file>', 'verify starting at a rotated file (e.g. audit-YYYYMMDD-HHMMSS.jsonl), walking forward through the chain')
93
+ .action(async (opts) => {
94
+ await runAuditVerify({ ...(opts.since !== undefined ? { since: opts.since } : {}) });
95
+ });
79
96
  program
80
97
  .command('doctor')
81
98
  .description('Validate the install: policy parses, .rea/ layout, hooks, Codex plugin.')
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Audit rotation (G1). Size- and age-based rotation for `.rea/audit.jsonl`
3
+ * that preserves hash-chain continuity across the rotation boundary.
4
+ *
5
+ * ## Triggers
6
+ *
7
+ * Rotation fires when EITHER threshold is crossed:
8
+ *
9
+ * - `max_bytes` — the current `audit.jsonl` is at or above this many bytes.
10
+ * Default when the policy block is present but `max_bytes` is unset:
11
+ * `DEFAULT_MAX_BYTES` (50 MiB).
12
+ * - `max_age_days` — the first record's `timestamp` is older than this many
13
+ * days. Default when unset: `DEFAULT_MAX_AGE_DAYS` (30).
14
+ *
15
+ * Back-compat: if the `audit.rotation` policy block is ABSENT entirely,
16
+ * rotation is DISABLED. Defaults only apply when the operator has opted in
17
+ * by declaring the block (even empty). This is deliberate — we do not want
18
+ * a 0.2.x install to observe new file-movement behavior on 0.3.0 upgrade
19
+ * without being asked.
20
+ *
21
+ * ## Rotation marker
22
+ *
23
+ * On rotation, the current file is renamed to `audit-YYYYMMDD-HHMMSS.jsonl`
24
+ * in the same directory. A fresh `audit.jsonl` is created containing EXACTLY
25
+ * one record: a rotation marker.
26
+ *
27
+ * tool_name: 'audit.rotation'
28
+ * server_name: 'rea'
29
+ * status: 'allowed'
30
+ * tier: 'read'
31
+ * autonomy_level: 'system'
32
+ * prev_hash: hash of the LAST record in the rotated file
33
+ * metadata.rotated_from: the rotated filename (basename)
34
+ * metadata.rotated_at: ISO-8601 instant of rotation
35
+ *
36
+ * The marker's `prev_hash` is the chain bridge — an operator verifying the
37
+ * chain with `rea audit verify --since <rotated-file>` walks rotated →
38
+ * marker → current and every transition must line up.
39
+ *
40
+ * ## Concurrency
41
+ *
42
+ * `maybeRotate` is called BEFORE the per-append lock is acquired. It takes
43
+ * its own short-lived lock on `.rea/` to perform the rename + marker write
44
+ * atomically. Callers that beat the rotator to the lock simply append to
45
+ * the (now fresh) file — correctness is preserved because the rotation
46
+ * marker is a legitimate chain anchor.
47
+ */
48
+ import type { Policy, AuditRotationPolicy } from '../../policy/types.js';
49
+ /** 50 MiB. Only applied when the operator has declared `audit.rotation`. */
50
+ export declare const DEFAULT_MAX_BYTES: number;
51
+ /** 30 days. Only applied when the operator has declared `audit.rotation`. */
52
+ export declare const DEFAULT_MAX_AGE_DAYS = 30;
53
+ export declare const ROTATION_TOOL_NAME = "audit.rotation";
54
+ export declare const ROTATION_SERVER_NAME = "rea";
55
+ export interface RotationResult {
56
+ rotated: boolean;
57
+ /** Absolute path of the rotated file (the `audit-TIMESTAMP.jsonl` file). */
58
+ rotatedTo?: string;
59
+ }
60
+ /** Resolve effective thresholds from policy. `undefined` thresholds disable that trigger. */
61
+ interface EffectiveThresholds {
62
+ maxBytes: number | undefined;
63
+ maxAgeMs: number | undefined;
64
+ }
65
+ /**
66
+ * Compute the effective rotation thresholds from policy. If the operator has
67
+ * NOT declared an `audit.rotation` block, BOTH thresholds are undefined and
68
+ * rotation is disabled (back-compat with 0.2.x).
69
+ *
70
+ * If the block IS declared but individual knobs are missing, apply the
71
+ * documented defaults.
72
+ */
73
+ declare function effectiveThresholds(policy: Policy | undefined): EffectiveThresholds;
74
+ /**
75
+ * Build the rotation timestamp filename. UTC for sortability.
76
+ * Format: `audit-YYYYMMDD-HHMMSS.jsonl`. Collisions (two rotations in the
77
+ * same second) are resolved by appending `-1`, `-2`, etc.
78
+ */
79
+ export declare function rotationFilename(at: Date): string;
80
+ /**
81
+ * Decide whether the current audit file has crossed any rotation threshold.
82
+ * Exported for testing.
83
+ */
84
+ export declare function shouldRotate(auditFile: string, thresholds: EffectiveThresholds, now?: Date): Promise<boolean>;
85
+ /**
86
+ * Perform the rotation unconditionally. Assumes the caller has already
87
+ * determined rotation is warranted and holds (or is about to acquire) any
88
+ * outer locks. `performRotation` takes its own lock on `.rea/` to make the
89
+ * rename + marker write atomic w.r.t. other append-path lockers.
90
+ *
91
+ * Returns `{ rotated: false }` if the audit file is empty or missing — an
92
+ * empty file is a no-op by design (see `rea audit rotate` empty-case).
93
+ */
94
+ export declare function performRotation(auditFile: string, now?: Date): Promise<RotationResult>;
95
+ /**
96
+ * Called by the append path BEFORE acquiring its own lock. Cheap when no
97
+ * rotation is due (one stat, maybe one 64 KiB read for age check); idempotent
98
+ * when rotation IS due (performRotation re-checks under the lock).
99
+ *
100
+ * Never throws. On any error, logs to stderr and returns `rotated: false`
101
+ * — a broken rotator must NOT break the audit append.
102
+ */
103
+ export declare function maybeRotate(auditFile: string, policy: Policy | undefined, now?: Date): Promise<RotationResult>;
104
+ /**
105
+ * CLI-invoked force rotation (`rea audit rotate`). Unlike `maybeRotate` this
106
+ * DOES ignore thresholds — the operator asked explicitly — but empty files
107
+ * are still a no-op because rotating an empty chain produces a marker with
108
+ * no predecessor.
109
+ */
110
+ export declare function forceRotate(auditFile: string, now?: Date): Promise<RotationResult>;
111
+ /**
112
+ * Exposed for tests/callers that already know the policy shape. Tests that
113
+ * want to stub thresholds can call `performRotation` directly.
114
+ */
115
+ export { effectiveThresholds as _effectiveThresholds };
116
+ export type { EffectiveThresholds, AuditRotationPolicy };