@sigloch/graph-api-core 3.0.0 → 3.2.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.
package/dist/audit.d.ts CHANGED
@@ -40,14 +40,64 @@ export interface AuditEntry {
40
40
  * at exactly the records it affected.
41
41
  */
42
42
  rulesetVersion?: string;
43
+ /**
44
+ * PROVENANCE (CR-GC-354) — the four fields that make a record answer the base question
45
+ * of an audit trail: WHO, with WHICH PROMPT, reached WHICH RESULT. The result half was
46
+ * always there; these are the other two.
47
+ *
48
+ * All four are DERIVED at the recording site, never self-declared by the consumer.
49
+ * `consumerId` is the counter-example that motivated this: it IS a self-declared field
50
+ * and 40% of the records carry its anonymous default. A prompt a model writes about
51
+ * itself is a paraphrase — it already contains the interpretation a later consumer
52
+ * would want to predict — so it is worthless as provenance.
53
+ *
54
+ * Session this record was written in — groups the records of ONE conversation.
55
+ * Assigned by the recording host, not accepted from the caller.
56
+ */
57
+ sessionId?: string;
58
+ /**
59
+ * The LLM that emitted the commands (e.g. the executor's configured model). The
60
+ * dimension rule calibration breaks down by ("R-01 dominated rejections: Haiku 26/29,
61
+ * Opus 17/18, devstral 10/23") and the one a client-side transcript cannot supply for
62
+ * a local or third-party model, because no transcript exists there.
63
+ */
64
+ model?: string;
65
+ /**
66
+ * The triggering prompt VERBATIM, truncated by the recording host's policy (the
67
+ * contract states that it is truncated, the host states where).
68
+ *
69
+ * Same absence asymmetry as `rulesPassed`: a missing field means NOT RECORDED, never
70
+ * "empty prompt". A consumer must not read absence as "no prompt was given".
71
+ */
72
+ intent?: string;
73
+ /**
74
+ * `true` when `intent` was truncated. Present only in that case — no silent cut, and
75
+ * no `false` noise on the overwhelming majority of records that fit.
76
+ */
77
+ intentTruncated?: boolean;
78
+ }
79
+ /** The read horizon of a query (CR-GC-349). */
80
+ export interface AuditQueryFilter {
81
+ consumerId?: string;
82
+ since?: string;
83
+ limit?: number;
84
+ /**
85
+ * Read ARCHIVED segments too, not just the active log (CR-GC-349).
86
+ *
87
+ * Compaction archives by rename and deletes nothing, but a plain `query()` reads only the
88
+ * active file — so the day after the first compaction an aggregation over the trail reports
89
+ * a near-empty window while the full evidence sits untouched beside it. That is worse than a
90
+ * missing measurement: it is a WRONG one shaped like a result.
91
+ *
92
+ * Off by default on purpose. OCC calls `query({})` on the WRITE path and only needs the
93
+ * batches since a fork point, so pulling archives in there would be a slowdown with no
94
+ * consumer. Analysis surfaces (audit_trail / audit_stats) turn it on.
95
+ */
96
+ includeArchived?: boolean;
43
97
  }
44
98
  export interface AuditLog {
45
99
  record(entry: AuditEntry): Promise<void>;
46
- query(filter: {
47
- consumerId?: string;
48
- since?: string;
49
- limit?: number;
50
- }): Promise<AuditEntry[]>;
100
+ query(filter: AuditQueryFilter): Promise<AuditEntry[]>;
51
101
  }
52
102
  /**
53
103
  * The durable operations-log surface: append/query (AuditLog) plus the event-
@@ -68,15 +118,18 @@ export interface OperationsLog extends AuditLog {
68
118
  archivedTo: string | null;
69
119
  checkpointVersion: number;
70
120
  };
121
+ /**
122
+ * The archived segments beside the active log, oldest first (CR-GC-349).
123
+ *
124
+ * So an answer can SAY what it did not read instead of silently omitting it — the same rule
125
+ * as "no silent cap" on the projection side.
126
+ */
127
+ archives(): string[];
71
128
  }
72
129
  export declare class InMemoryAuditLog implements AuditLog {
73
130
  private entries;
74
131
  record(entry: AuditEntry): Promise<void>;
75
- query(filter: {
76
- consumerId?: string;
77
- since?: string;
78
- limit?: number;
79
- }): Promise<AuditEntry[]>;
132
+ query(filter: AuditQueryFilter): Promise<AuditEntry[]>;
80
133
  }
81
134
  /** Auto-compaction size threshold (bytes). */
82
135
  export declare const DEFAULT_COMPACT_BYTES: number;
@@ -96,11 +149,7 @@ export declare class FileOperationsLog implements OperationsLog {
96
149
  });
97
150
  record(entry: AuditEntry): Promise<void>;
98
151
  /** Mirrors InMemoryAuditLog semantics: `since` inclusive, `limit` = last N. */
99
- query(filter: {
100
- consumerId?: string;
101
- since?: string;
102
- limit?: number;
103
- }): Promise<AuditEntry[]>;
152
+ query(filter: AuditQueryFilter): Promise<AuditEntry[]>;
104
153
  /** The compaction anchor: checkpoint version, or 0 on a never-compacted log. */
105
154
  baseVersion(): number;
106
155
  /**
@@ -122,4 +171,22 @@ export declare class FileOperationsLog implements OperationsLog {
122
171
  /** Read all parseable lines; skip a torn tail (crash mid-append) with a warning. */
123
172
  private readLines;
124
173
  private readEntries;
174
+ /**
175
+ * Archived segments beside the active log, OLDEST FIRST (CR-GC-349).
176
+ *
177
+ * Sorted by (stamp, collision counter) and NOT lexically. Lexical order looks right and is
178
+ * wrong exactly where it matters: `-` (0x2D) sorts before `.` (0x2E), so a collision-suffixed
179
+ * `…-329Z-1.jsonl` would come out BEFORE the `…-329Z.jsonl` it followed, silently inverting
180
+ * the replay order of two adjacent segments. The unsuffixed name is counter 0 because it was
181
+ * written first.
182
+ */
183
+ archives(): string[];
184
+ /**
185
+ * Every entry across the archives AND the active log, in log order (CR-GC-349).
186
+ *
187
+ * An unreadable archive is SKIPPED, not fatal: the same torn-tail tolerance the active log
188
+ * already has. An analysis must not die on a years-old segment — losing part of the horizon
189
+ * is a gap, losing the answer is a defect.
190
+ */
191
+ private readAllEntries;
125
192
  }
package/dist/audit.js CHANGED
@@ -16,7 +16,7 @@
16
16
  * survives every compaction, and torn-tail tolerant so a crash mid-append is a
17
17
  * skipped line, never a read failure.
18
18
  */
19
- import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
19
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
20
20
  import { dirname, join } from 'node:path';
21
21
  export class InMemoryAuditLog {
22
22
  entries = [];
@@ -63,7 +63,7 @@ export class FileOperationsLog {
63
63
  }
64
64
  /** Mirrors InMemoryAuditLog semantics: `since` inclusive, `limit` = last N. */
65
65
  async query(filter) {
66
- let result = this.readEntries();
66
+ let result = filter.includeArchived ? this.readAllEntries() : this.readEntries();
67
67
  if (filter.consumerId)
68
68
  result = result.filter(e => e.consumerId === filter.consumerId);
69
69
  if (filter.since)
@@ -102,7 +102,16 @@ export class FileOperationsLog {
102
102
  return { archivedTo: null, checkpointVersion: 0 };
103
103
  const version = this.latestVersion();
104
104
  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
105
- const archive = this.path.replace(/\.jsonl$/, `-${stamp}.jsonl`);
105
+ // COLLISION GUARD (CR-GC-349). The stamp resolves to milliseconds, and `renameSync`
106
+ // overwrites its target silently — so two compactions inside the same millisecond used to
107
+ // destroy the earlier archive. Measured before the guard: 30 records written, 14 left on
108
+ // disk. That is data loss inside the one function whose contract is "deletes nothing";
109
+ // unreachable at the 10 MB default, reachable with a small `maxBytes` or concurrent binds,
110
+ // and exactly the shape of bug that stays invisible because the promise reads as kept.
111
+ let archive = this.path.replace(/\.jsonl$/, `-${stamp}.jsonl`);
112
+ for (let n = 1; existsSync(archive); n++) {
113
+ archive = this.path.replace(/\.jsonl$/, `-${stamp}-${n}.jsonl`);
114
+ }
106
115
  renameSync(this.path, archive);
107
116
  const cp = { checkpoint: true, version, timestamp: new Date().toISOString(), reason };
108
117
  writeFileSync(this.path, JSON.stringify(cp) + '\n', 'utf8');
@@ -142,4 +151,61 @@ export class FileOperationsLog {
142
151
  readEntries() {
143
152
  return this.readLines().filter((l) => !isCheckpoint(l));
144
153
  }
154
+ /**
155
+ * Archived segments beside the active log, OLDEST FIRST (CR-GC-349).
156
+ *
157
+ * Sorted by (stamp, collision counter) and NOT lexically. Lexical order looks right and is
158
+ * wrong exactly where it matters: `-` (0x2D) sorts before `.` (0x2E), so a collision-suffixed
159
+ * `…-329Z-1.jsonl` would come out BEFORE the `…-329Z.jsonl` it followed, silently inverting
160
+ * the replay order of two adjacent segments. The unsuffixed name is counter 0 because it was
161
+ * written first.
162
+ */
163
+ archives() {
164
+ const dir = dirname(this.path);
165
+ const base = AUDIT_BASENAME.replace(/\.jsonl$/, '');
166
+ const pattern = new RegExp(`^${base}-(.+?)(?:-(\\d+))?\\.jsonl$`);
167
+ try {
168
+ return readdirSync(dir)
169
+ .flatMap(f => {
170
+ const m = pattern.exec(f);
171
+ return m ? [{ file: f, stamp: m[1], n: m[2] ? Number(m[2]) : 0 }] : [];
172
+ })
173
+ .sort((a, b) => (a.stamp < b.stamp ? -1 : a.stamp > b.stamp ? 1 : a.n - b.n))
174
+ .map(a => join(dir, a.file));
175
+ }
176
+ catch {
177
+ return [];
178
+ }
179
+ }
180
+ /**
181
+ * Every entry across the archives AND the active log, in log order (CR-GC-349).
182
+ *
183
+ * An unreadable archive is SKIPPED, not fatal: the same torn-tail tolerance the active log
184
+ * already has. An analysis must not die on a years-old segment — losing part of the horizon
185
+ * is a gap, losing the answer is a defect.
186
+ */
187
+ readAllEntries() {
188
+ const out = [];
189
+ for (const archive of this.archives()) {
190
+ try {
191
+ for (const line of readFileSync(archive, 'utf8').split('\n')) {
192
+ if (!line.trim())
193
+ continue;
194
+ try {
195
+ const parsed = JSON.parse(line);
196
+ if (!isCheckpoint(parsed))
197
+ out.push(parsed);
198
+ }
199
+ catch {
200
+ // torn line in an archive — skip, same as the active log
201
+ }
202
+ }
203
+ }
204
+ catch {
205
+ // unreadable archive — skip the segment, keep the answer
206
+ }
207
+ }
208
+ out.push(...this.readEntries());
209
+ return out;
210
+ }
145
211
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sigloch/graph-api-core",
3
- "version": "3.0.0",
3
+ "version": "3.2.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -21,11 +21,11 @@
21
21
  "prepublishOnly": "npm run build && npm run test"
22
22
  },
23
23
  "dependencies": {
24
- "@sigloch/contracts": "^4.0.0",
24
+ "@sigloch/contracts": "^4.1.0",
25
25
  "zod": "^4.3.6"
26
26
  },
27
27
  "license": "MIT",
28
- "description": "Framework-agnostic graph engine core \u2014 ontology-typed nodes/traces, rule evaluation, views",
28
+ "description": "Framework-agnostic graph engine core ontology-typed nodes/traces, rule evaluation, views",
29
29
  "author": "sigloch-consulting",
30
30
  "repository": {
31
31
  "type": "git",