@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 +82 -15
- package/dist/audit.js +69 -3
- package/package.json +3 -3
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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",
|