@rubric-protocol/attest-decision 1.0.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/src/spool.ts ADDED
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Durable append-only spool (tasks/P1.md).
3
+ *
4
+ * Durability model: `attest()` must add <1 ms to the caller, so we CANNOT fsync
5
+ * on the append path. Instead each record is appended with a single `writeSync`.
6
+ * A `writeSync` hands the bytes to the kernel, so they survive process death
7
+ * (`kill -9`) — only a machine/OS crash could lose them, and that is what the
8
+ * background `fsync()` (called off the caller path, before each network flush)
9
+ * defends against. On restart, `pending()` replays everything not yet acked, so
10
+ * zero spooled records are lost.
11
+ *
12
+ * Format: one JSON line per record, `{"seq":N,"dar":{...}}\n`. Acknowledgement
13
+ * is a monotonic high-water mark `ackedThrough` persisted in a `<path>.ack`
14
+ * sidecar; records are FIFO so a single watermark suffices. When the file
15
+ * exceeds `maxBytes` it is compacted (acked records dropped; then oldest pending
16
+ * dropped — "drop-oldest" — until under cap).
17
+ */
18
+ import {
19
+ closeSync,
20
+ fstatSync,
21
+ fsyncSync,
22
+ ftruncateSync,
23
+ mkdirSync,
24
+ openSync,
25
+ readFileSync,
26
+ renameSync,
27
+ writeSync,
28
+ } from "node:fs";
29
+ import { dirname } from "node:path";
30
+ import type { DarCore, PayloadRecord } from "./constants.js";
31
+
32
+ const DEFAULT_MAX_BYTES = 50 * 1024 * 1024; // 50 MB (tasks/P1.md)
33
+
34
+ export interface SpoolRecord {
35
+ seq: number;
36
+ dar: DarCore;
37
+ /** Raw content, present only in `payload` mode (spec §4). */
38
+ payload?: PayloadRecord;
39
+ }
40
+
41
+ export interface SpoolOptions {
42
+ maxBytes?: number;
43
+ /** Called when the drop-oldest cap policy discards records (data loss). */
44
+ onDrop?: (count: number) => void;
45
+ }
46
+
47
+ /** Write an entire buffer, looping over short writes (write(2) may be partial). */
48
+ function writeFully(fd: number, buf: Buffer): void {
49
+ let offset = 0;
50
+ while (offset < buf.length) {
51
+ offset += writeSync(fd, buf, offset, buf.length - offset);
52
+ }
53
+ }
54
+
55
+ /** Durably write a small file (write + fsync). */
56
+ function writeFileDurable(path: string, contents: string): void {
57
+ const fd = openSync(path, "w");
58
+ try {
59
+ writeFully(fd, Buffer.from(contents, "utf8"));
60
+ fsyncSync(fd);
61
+ } finally {
62
+ closeSync(fd);
63
+ }
64
+ }
65
+
66
+ /** fsync a directory so a rename/create within it is durable. */
67
+ function fsyncDir(path: string): void {
68
+ const fd = openSync(path, "r");
69
+ try {
70
+ fsyncSync(fd);
71
+ } finally {
72
+ closeSync(fd);
73
+ }
74
+ }
75
+
76
+ export class Spool {
77
+ private readonly path: string;
78
+ private readonly dir: string;
79
+ private readonly ackPath: string;
80
+ private readonly maxBytes: number;
81
+ private readonly onDrop?: (count: number) => void;
82
+
83
+ private fd: number;
84
+ private seq = 0;
85
+ private ackedThrough = 0;
86
+ private bytes = 0;
87
+ private droppedForCap = 0;
88
+ private compactionPending = false;
89
+ private pendingRecords: SpoolRecord[] = [];
90
+
91
+ constructor(path: string, options: SpoolOptions = {}) {
92
+ this.path = path;
93
+ this.dir = dirname(path);
94
+ this.ackPath = `${path}.ack`;
95
+ this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
96
+ this.onDrop = options.onDrop;
97
+ // Create the spool directory if missing so a documented path like
98
+ // /var/lib/rubric/attest.spool works without a separate mkdir.
99
+ mkdirSync(this.dir, { recursive: true });
100
+ this.ackedThrough = this.readAck();
101
+ this.recover();
102
+ // Open the append fd after recovery has read the current contents.
103
+ this.fd = openSync(this.path, "a");
104
+ this.bytes = fstatSync(this.fd).size;
105
+ }
106
+
107
+ /** Records written but not yet acknowledged, in FIFO order. */
108
+ pending(): SpoolRecord[] {
109
+ return this.pendingRecords.slice();
110
+ }
111
+
112
+ /** Count of records dropped by the drop-oldest cap policy so far. */
113
+ droppedCount(): number {
114
+ return this.droppedForCap;
115
+ }
116
+
117
+ currentSeq(): number {
118
+ return this.seq;
119
+ }
120
+
121
+ /**
122
+ * Append a record durably (single `writeSync`, no fsync). Returns its seq.
123
+ * Never compacts inline — that would put a multi-MB `writeFileSync`+`fsync` on
124
+ * the caller's `attest()` path. Crossing the cap only flags compaction, which
125
+ * `compactIfNeeded()` performs off the caller path (see the Attestor flush).
126
+ */
127
+ append(dar: DarCore, payload?: PayloadRecord): number {
128
+ const seq = ++this.seq;
129
+ const record: SpoolRecord = payload ? { seq, dar, payload } : { seq, dar };
130
+ const line = JSON.stringify(record) + "\n";
131
+ const buf = Buffer.from(line, "utf8");
132
+ writeFully(this.fd, buf); // loop over short writes so a line is never torn
133
+ this.bytes += buf.byteLength;
134
+ this.pendingRecords.push(record);
135
+ if (this.bytes > this.maxBytes) this.compactionPending = true;
136
+ return seq;
137
+ }
138
+
139
+ /** Whether the file has grown past the cap and awaits compaction. */
140
+ needsCompaction(): boolean {
141
+ return this.compactionPending;
142
+ }
143
+
144
+ /** Reclaim acked space and enforce the cap (drop-oldest). Off the caller path. */
145
+ compactIfNeeded(): void {
146
+ if (!this.compactionPending) return;
147
+ this.compact();
148
+ this.compactionPending = false;
149
+ }
150
+
151
+ /**
152
+ * Acknowledge every record with seq <= `throughSeq` (they were delivered).
153
+ * Advances the durable watermark; truncates the file once fully drained.
154
+ */
155
+ ack(throughSeq: number): void {
156
+ if (throughSeq <= this.ackedThrough) return;
157
+ this.ackedThrough = throughSeq;
158
+ this.pendingRecords = this.pendingRecords.filter((r) => r.seq > throughSeq);
159
+ this.writeAck();
160
+ if (this.pendingRecords.length === 0) {
161
+ // Steady state: everything delivered. Reclaim the file entirely.
162
+ ftruncateSync(this.fd, 0);
163
+ this.bytes = 0;
164
+ }
165
+ }
166
+
167
+ /** Flush OS buffers to disk. Called off the caller path, before network I/O. */
168
+ fsync(): void {
169
+ fsyncSync(this.fd);
170
+ }
171
+
172
+ close(): void {
173
+ closeSync(this.fd);
174
+ }
175
+
176
+ // --- internals ---
177
+
178
+ private recover(): void {
179
+ let raw: string;
180
+ try {
181
+ raw = readFileSync(this.path, "utf8");
182
+ } catch {
183
+ return; // no spool file yet
184
+ }
185
+ let maxSeq = 0;
186
+ for (const line of raw.split("\n")) {
187
+ if (line.length === 0) continue;
188
+ let record: SpoolRecord;
189
+ try {
190
+ record = JSON.parse(line) as SpoolRecord;
191
+ } catch {
192
+ continue; // tolerate a torn trailing line from a crash mid-write
193
+ }
194
+ if (typeof record.seq !== "number") continue;
195
+ if (record.seq > maxSeq) maxSeq = record.seq;
196
+ if (record.seq > this.ackedThrough) this.pendingRecords.push(record);
197
+ }
198
+ this.pendingRecords.sort((a, b) => a.seq - b.seq);
199
+ this.seq = maxSeq;
200
+ }
201
+
202
+ private compact(): void {
203
+ // Drop oldest pending until the surviving set fits under the cap.
204
+ const kept = this.pendingRecords.slice();
205
+ let size = kept.reduce((n, r) => n + Buffer.byteLength(JSON.stringify(r) + "\n"), 0);
206
+ let droppedNow = 0;
207
+ while (size > this.maxBytes && kept.length > 0) {
208
+ const dropped = kept.shift()!;
209
+ size -= Buffer.byteLength(JSON.stringify(dropped) + "\n");
210
+ this.droppedForCap++;
211
+ droppedNow++;
212
+ }
213
+
214
+ const tmp = `${this.path}.compact`;
215
+ const body = kept.map((r) => JSON.stringify(r) + "\n").join("");
216
+ writeFileDurable(tmp, body);
217
+ closeSync(this.fd);
218
+ renameSync(tmp, this.path);
219
+ fsyncDir(this.dir); // make the rename durable across a power loss
220
+ this.fd = openSync(this.path, "a");
221
+ this.bytes = fstatSync(this.fd).size;
222
+ this.pendingRecords = kept;
223
+
224
+ // Surface the drop-oldest data loss rather than discarding silently.
225
+ if (droppedNow > 0) this.onDrop?.(droppedNow);
226
+ }
227
+
228
+ private readAck(): number {
229
+ try {
230
+ const n = Number.parseInt(readFileSync(this.ackPath, "utf8").trim(), 10);
231
+ return Number.isFinite(n) && n >= 0 ? n : 0;
232
+ } catch {
233
+ return 0;
234
+ }
235
+ }
236
+
237
+ private writeAck(): void {
238
+ // fsync the watermark so a crash cannot resurrect already-acked records
239
+ // (bounding duplicate re-delivery on recovery).
240
+ writeFileDurable(this.ackPath, String(this.ackedThrough));
241
+ }
242
+ }
243
+
244
+ export { DEFAULT_MAX_BYTES };
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Batch transport. One POST per flush to /v1/tiered-attest (tasks/P1.md).
3
+ * The SDK reads exactly one credential env var: RUBRIC_API_KEY (CLAUDE.md).
4
+ */
5
+ import { API_KEY_ENV, TIERED_ATTEST_PATH, type DarCore, type PayloadRecord } from "./constants.js";
6
+
7
+ export interface Transport {
8
+ /**
9
+ * Deliver one batch. `records` are hashes-only DAR cores; `payloads` (raw
10
+ * content) is present only in `payload` mode and rides in the envelope, never
11
+ * in a core. Must reject on failure so the spool retains the batch.
12
+ */
13
+ send(records: DarCore[], payloads?: PayloadRecord[]): Promise<void>;
14
+ }
15
+
16
+ export interface HttpTransportOptions {
17
+ baseUrl: string;
18
+ /** Defaults to reading process.env.RUBRIC_API_KEY at send time. */
19
+ apiKey?: () => string | undefined;
20
+ fetchImpl?: typeof fetch;
21
+ /** Abort a POST after this many ms so a hung endpoint can't stall flushing. Default 30000. */
22
+ timeoutMs?: number;
23
+ /** Permit a non-HTTPS baseUrl (localhost is always allowed). Default false. */
24
+ allowInsecure?: boolean;
25
+ }
26
+
27
+ function isLocalhost(u: URL): boolean {
28
+ return u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname === "::1";
29
+ }
30
+
31
+ /** Default transport: one JSON POST of the batch to `${baseUrl}/v1/tiered-attest`. */
32
+ export class HttpTransport implements Transport {
33
+ private readonly baseUrl: string;
34
+ private readonly apiKey: () => string | undefined;
35
+ private readonly fetchImpl: typeof fetch;
36
+ private readonly timeoutMs: number;
37
+
38
+ constructor(options: HttpTransportOptions) {
39
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
40
+ // Refuse to send the bearer credential over cleartext unless explicitly allowed.
41
+ const parsed = new URL(this.baseUrl);
42
+ if (parsed.protocol !== "https:" && !isLocalhost(parsed) && !options.allowInsecure) {
43
+ throw new Error(
44
+ `HttpTransport: refusing non-HTTPS baseUrl '${this.baseUrl}' (set allowInsecure to override)`,
45
+ );
46
+ }
47
+ this.apiKey = options.apiKey ?? (() => process.env[API_KEY_ENV]);
48
+ this.fetchImpl = options.fetchImpl ?? fetch;
49
+ this.timeoutMs = options.timeoutMs ?? 30_000;
50
+ }
51
+
52
+ async send(records: DarCore[], payloads?: PayloadRecord[]): Promise<void> {
53
+ const key = this.apiKey();
54
+ if (!key) throw new Error(`${API_KEY_ENV} is not set`);
55
+
56
+ const body = payloads && payloads.length > 0 ? { records, payloads } : { records };
57
+ const controller = new AbortController();
58
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
59
+ try {
60
+ const res = await this.fetchImpl(`${this.baseUrl}${TIERED_ATTEST_PATH}`, {
61
+ method: "POST",
62
+ headers: {
63
+ "content-type": "application/json",
64
+ authorization: `Bearer ${key}`,
65
+ },
66
+ body: JSON.stringify(body),
67
+ signal: controller.signal,
68
+ });
69
+ if (!res.ok) {
70
+ throw new Error(`tiered-attest POST failed: ${res.status} ${res.statusText}`);
71
+ }
72
+ } finally {
73
+ clearTimeout(timer);
74
+ }
75
+ }
76
+ }
package/src/ulid.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * ULID (https://github.com/ulid/spec): 48-bit millisecond timestamp + 80 bits
3
+ * of randomness, Crockford base32, 26 chars. Used for `decisionId` (spec §2).
4
+ *
5
+ * A monotonic factory guarantees strictly increasing ids even within the same
6
+ * millisecond (randomness is incremented), so per-agent `prev` chains built from
7
+ * mint order are well-defined.
8
+ */
9
+ import { randomFillSync } from "node:crypto";
10
+
11
+ const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; // Crockford base32 (no I,L,O,U)
12
+ const TIME_LEN = 10;
13
+ const RAND_LEN = 16;
14
+
15
+ function encodeTime(ms: number): string {
16
+ if (!Number.isInteger(ms) || ms < 0 || ms > 0xffffffffffff) {
17
+ throw new Error(`ulid: time ${ms} out of 48-bit range`);
18
+ }
19
+ let out = "";
20
+ let t = ms;
21
+ for (let i = 0; i < TIME_LEN; i++) {
22
+ out = ENCODING[t % 32] + out;
23
+ t = Math.floor(t / 32);
24
+ }
25
+ return out;
26
+ }
27
+
28
+ function randomDigits(): number[] {
29
+ // Each byte's low 5 bits are uniform over 0..31 (256 is a multiple of 32).
30
+ const bytes = randomFillSync(new Uint8Array(RAND_LEN));
31
+ return Array.from(bytes, (b) => b & 31);
32
+ }
33
+
34
+ function incrementDigits(digits: number[]): number[] {
35
+ const next = digits.slice();
36
+ for (let i = RAND_LEN - 1; i >= 0; i--) {
37
+ if (next[i]! < 31) {
38
+ next[i]!++;
39
+ return next;
40
+ }
41
+ next[i] = 0;
42
+ }
43
+ // Overflow within one millisecond (2^80 ids) — astronomically unlikely.
44
+ return randomDigits();
45
+ }
46
+
47
+ function encodeDigits(digits: number[]): string {
48
+ let out = "";
49
+ for (const d of digits) out += ENCODING[d];
50
+ return out;
51
+ }
52
+
53
+ /**
54
+ * Create a monotonic ULID generator. `now` returns epoch milliseconds
55
+ * (injectable for deterministic tests). If the clock does not advance (or goes
56
+ * backwards) the randomness is incremented to keep ids strictly increasing.
57
+ */
58
+ export function monotonicUlidFactory(now: () => number = Date.now): () => string {
59
+ let lastTime = -1;
60
+ let lastRand: number[] = [];
61
+ return function ulid(): string {
62
+ let t = Math.trunc(now());
63
+ if (t <= lastTime) {
64
+ t = lastTime;
65
+ lastRand = incrementDigits(lastRand);
66
+ } else {
67
+ lastTime = t;
68
+ lastRand = randomDigits();
69
+ }
70
+ return encodeTime(t) + encodeDigits(lastRand);
71
+ };
72
+ }
73
+
74
+ /** Default process-wide monotonic ULID generator. */
75
+ export const ulid = monotonicUlidFactory();