@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/dist/attestor.d.ts +81 -0
- package/dist/attestor.d.ts.map +1 -0
- package/dist/attestor.js +211 -0
- package/dist/attestor.js.map +1 -0
- package/dist/constants.d.ts +54 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +14 -0
- package/dist/constants.js.map +1 -0
- package/dist/dar.d.ts +55 -0
- package/dist/dar.d.ts.map +1 -0
- package/dist/dar.js +118 -0
- package/dist/dar.js.map +1 -0
- package/dist/hash.d.ts +11 -0
- package/dist/hash.d.ts.map +1 -0
- package/dist/hash.js +25 -0
- package/dist/hash.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/jcs.d.ts +18 -0
- package/dist/jcs.d.ts.map +1 -0
- package/dist/jcs.js +101 -0
- package/dist/jcs.js.map +1 -0
- package/dist/spool.d.ts +58 -0
- package/dist/spool.d.ts.map +1 -0
- package/dist/spool.js +210 -0
- package/dist/spool.js.map +1 -0
- package/dist/transport.d.ts +33 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +52 -0
- package/dist/transport.js.map +1 -0
- package/dist/ulid.d.ts +9 -0
- package/dist/ulid.d.ts.map +1 -0
- package/dist/ulid.js +71 -0
- package/dist/ulid.js.map +1 -0
- package/package.json +28 -0
- package/src/attestor.ts +249 -0
- package/src/constants.ts +64 -0
- package/src/dar.ts +168 -0
- package/src/hash.ts +27 -0
- package/src/index.ts +42 -0
- package/src/jcs.ts +116 -0
- package/src/spool.ts +244 -0
- package/src/transport.ts +76 -0
- package/src/ulid.ts +75 -0
package/src/attestor.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attestor — the fire-and-forget façade (tasks/P1.md).
|
|
3
|
+
*
|
|
4
|
+
* `attest()` builds a DAR, appends it durably to the spool, enqueues it, and
|
|
5
|
+
* returns immediately (adds <1 ms to the caller). It NEVER throws into app code;
|
|
6
|
+
* any failure is reported to the optional `onError` sink and swallowed.
|
|
7
|
+
*
|
|
8
|
+
* The batcher flushes when the queue reaches `maxBatch` (64) or `maxWaitMs`
|
|
9
|
+
* (5000 ms) elapses, whichever comes first — one POST per flush. A delivered
|
|
10
|
+
* batch is acked in the spool; a failed batch is returned to the front of the
|
|
11
|
+
* queue and retried, so it is never lost. On construction the spool is drained:
|
|
12
|
+
* anything left by a previous run is re-queued and per-agent chain heads reseeded.
|
|
13
|
+
*/
|
|
14
|
+
import type { DarCore, PayloadRecord, TransmitMode } from "./constants.js";
|
|
15
|
+
import { DarBuilder, toPayload, type DarBuildInput } from "./dar.js";
|
|
16
|
+
import { Spool } from "./spool.js";
|
|
17
|
+
import type { Transport } from "./transport.js";
|
|
18
|
+
|
|
19
|
+
export interface AttestorOptions {
|
|
20
|
+
transport: Transport;
|
|
21
|
+
spoolPath: string;
|
|
22
|
+
maxBatch?: number;
|
|
23
|
+
maxWaitMs?: number;
|
|
24
|
+
maxSpoolBytes?: number;
|
|
25
|
+
retryMs?: number;
|
|
26
|
+
/** Max re-send attempts per batch during close() before leaving it spooled. */
|
|
27
|
+
closeRetries?: number;
|
|
28
|
+
/** Cap on in-memory queued (un-acked) records; oldest are dropped past it. Default 100000. */
|
|
29
|
+
maxQueue?: number;
|
|
30
|
+
/** Reject a decision whose canonical form exceeds this many bytes. Default 256 KiB. */
|
|
31
|
+
maxDecisionBytes?: number;
|
|
32
|
+
/** `hash-only` (default) sends DAR cores only; `payload` also carries raw content in the envelope. */
|
|
33
|
+
mode?: TransmitMode;
|
|
34
|
+
now?: () => number;
|
|
35
|
+
newDecisionId?: () => string;
|
|
36
|
+
onError?: (err: unknown) => void;
|
|
37
|
+
/** Replay leftover spool records on construction. Default true. */
|
|
38
|
+
autoRecover?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface QueueItem {
|
|
42
|
+
seq: number;
|
|
43
|
+
dar: DarCore;
|
|
44
|
+
payload?: PayloadRecord;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const sleep0 = (): Promise<void> => new Promise((r) => setImmediate(r));
|
|
48
|
+
const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
49
|
+
|
|
50
|
+
export class Attestor {
|
|
51
|
+
private readonly builder: DarBuilder;
|
|
52
|
+
private readonly spool: Spool;
|
|
53
|
+
private readonly transport: Transport;
|
|
54
|
+
private readonly maxBatch: number;
|
|
55
|
+
private readonly maxWaitMs: number;
|
|
56
|
+
private readonly retryMs: number;
|
|
57
|
+
private readonly closeRetries: number;
|
|
58
|
+
private readonly maxQueue: number;
|
|
59
|
+
private readonly mode: TransmitMode;
|
|
60
|
+
private readonly onError?: (err: unknown) => void;
|
|
61
|
+
|
|
62
|
+
private queue: QueueItem[] = [];
|
|
63
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
64
|
+
private flushing = false;
|
|
65
|
+
private closed = false;
|
|
66
|
+
|
|
67
|
+
constructor(options: AttestorOptions) {
|
|
68
|
+
this.transport = options.transport;
|
|
69
|
+
this.onError = options.onError;
|
|
70
|
+
this.spool = new Spool(options.spoolPath, {
|
|
71
|
+
maxBytes: options.maxSpoolBytes,
|
|
72
|
+
onDrop: (n) => this.reportError(new Error(`spool dropped ${n} oldest record(s) at the size cap`)),
|
|
73
|
+
});
|
|
74
|
+
this.builder = new DarBuilder({
|
|
75
|
+
newDecisionId: options.newDecisionId,
|
|
76
|
+
now: options.now,
|
|
77
|
+
maxDecisionBytes: options.maxDecisionBytes,
|
|
78
|
+
});
|
|
79
|
+
this.maxBatch = options.maxBatch ?? 64;
|
|
80
|
+
this.maxWaitMs = options.maxWaitMs ?? 5000;
|
|
81
|
+
this.retryMs = options.retryMs ?? 1000;
|
|
82
|
+
this.closeRetries = options.closeRetries ?? 3;
|
|
83
|
+
this.maxQueue = options.maxQueue ?? 100_000;
|
|
84
|
+
this.mode = options.mode ?? "hash-only";
|
|
85
|
+
|
|
86
|
+
if (options.autoRecover !== false) this.recover();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Fire-and-forget. Returns the minted `decisionId`, or `null` if the record
|
|
91
|
+
* could not be built/spooled (never throws). Adds <1 ms to the caller.
|
|
92
|
+
*/
|
|
93
|
+
attest(input: DarBuildInput): string | null {
|
|
94
|
+
if (this.closed) return null;
|
|
95
|
+
try {
|
|
96
|
+
const dar = this.builder.build(input);
|
|
97
|
+
const payload = this.mode === "payload" ? toPayload(dar.decisionId, input) : undefined;
|
|
98
|
+
const seq = this.spool.append(dar, payload);
|
|
99
|
+
this.queue.push({ seq, dar, payload });
|
|
100
|
+
// Bound in-memory growth under sustained transport failure: drop oldest
|
|
101
|
+
// queued records past the cap (they are surfaced, not silently lost).
|
|
102
|
+
while (this.queue.length > this.maxQueue) {
|
|
103
|
+
this.queue.shift();
|
|
104
|
+
this.reportError(new Error("attest queue cap exceeded; dropped oldest queued record"));
|
|
105
|
+
}
|
|
106
|
+
if (this.queue.length >= this.maxBatch) this.scheduleFlush();
|
|
107
|
+
else this.armTimer();
|
|
108
|
+
return dar.decisionId;
|
|
109
|
+
} catch (err) {
|
|
110
|
+
this.reportError(err);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Flush at most one batch. Safe to call directly; concurrency-guarded. */
|
|
116
|
+
async flush(): Promise<void> {
|
|
117
|
+
if (this.flushing || this.closed) return;
|
|
118
|
+
if (this.queue.length === 0) return;
|
|
119
|
+
this.flushing = true;
|
|
120
|
+
this.clearTimer();
|
|
121
|
+
const batch = this.queue.splice(0, this.maxBatch);
|
|
122
|
+
try {
|
|
123
|
+
this.spool.compactIfNeeded(); // enforce the 50 MB cap off the caller path
|
|
124
|
+
this.spool.fsync();
|
|
125
|
+
await this.transport.send(batch.map((b) => b.dar), this.payloadsOf(batch));
|
|
126
|
+
this.spool.ack(batch[batch.length - 1]!.seq);
|
|
127
|
+
this.flushing = false;
|
|
128
|
+
this.scheduleNext();
|
|
129
|
+
} catch (err) {
|
|
130
|
+
// Retain: return the batch to the front (order preserved) and retry.
|
|
131
|
+
this.queue.unshift(...batch);
|
|
132
|
+
this.flushing = false;
|
|
133
|
+
this.reportError(err);
|
|
134
|
+
this.scheduleRetry();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Flush repeatedly until the queue is empty (used by tests and shutdown). */
|
|
139
|
+
async drain(): Promise<void> {
|
|
140
|
+
for (;;) {
|
|
141
|
+
while (this.flushing) await sleep0();
|
|
142
|
+
if (this.queue.length === 0 || this.closed) return;
|
|
143
|
+
await this.flush();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Number of records still queued (not yet acknowledged). */
|
|
148
|
+
pendingCount(): number {
|
|
149
|
+
return this.queue.length;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Stop accepting records, drain the queue (retrying each batch up to
|
|
154
|
+
* `closeRetries` times), and close the spool. Anything still undelivered after
|
|
155
|
+
* the retries remains durably spooled for the next run's recovery.
|
|
156
|
+
*/
|
|
157
|
+
async close(): Promise<void> {
|
|
158
|
+
this.closed = true;
|
|
159
|
+
this.clearTimer();
|
|
160
|
+
while (this.flushing) await sleep0();
|
|
161
|
+
this.spool.compactIfNeeded();
|
|
162
|
+
while (this.queue.length > 0) {
|
|
163
|
+
const batch = this.queue.splice(0, this.maxBatch);
|
|
164
|
+
let delivered = false;
|
|
165
|
+
for (let attempt = 0; attempt <= this.closeRetries; attempt++) {
|
|
166
|
+
try {
|
|
167
|
+
this.spool.fsync();
|
|
168
|
+
await this.transport.send(batch.map((b) => b.dar), this.payloadsOf(batch));
|
|
169
|
+
this.spool.ack(batch[batch.length - 1]!.seq);
|
|
170
|
+
delivered = true;
|
|
171
|
+
break;
|
|
172
|
+
} catch (err) {
|
|
173
|
+
this.reportError(err);
|
|
174
|
+
if (attempt < this.closeRetries) await delay(this.retryMs);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (!delivered) {
|
|
178
|
+
// Give up: leave this batch (and the rest) durably spooled for recovery.
|
|
179
|
+
this.queue.unshift(...batch);
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
this.spool.close();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// --- internals ---
|
|
187
|
+
|
|
188
|
+
/** Raw payloads for a batch — only in `payload` mode; ride in the envelope. */
|
|
189
|
+
private payloadsOf(batch: QueueItem[]): PayloadRecord[] | undefined {
|
|
190
|
+
if (this.mode !== "payload") return undefined;
|
|
191
|
+
const payloads = batch
|
|
192
|
+
.map((b) => b.payload)
|
|
193
|
+
.filter((p): p is PayloadRecord => p !== undefined);
|
|
194
|
+
return payloads.length > 0 ? payloads : undefined;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private recover(): void {
|
|
198
|
+
const pending = this.spool.pending();
|
|
199
|
+
for (const { dar } of pending) this.builder.seedHead(dar.agentId, dar.decisionId);
|
|
200
|
+
for (const item of pending) this.queue.push(item);
|
|
201
|
+
if (this.queue.length > 0) this.scheduleFlush();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private scheduleNext(): void {
|
|
205
|
+
if (this.queue.length >= this.maxBatch) this.scheduleFlush();
|
|
206
|
+
else if (this.queue.length > 0) this.armTimer();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private armTimer(): void {
|
|
210
|
+
if (this.timer || this.flushing || this.closed) return;
|
|
211
|
+
this.timer = setTimeout(() => {
|
|
212
|
+
this.timer = null;
|
|
213
|
+
this.scheduleFlush();
|
|
214
|
+
}, this.maxWaitMs);
|
|
215
|
+
this.timer.unref?.();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private scheduleRetry(): void {
|
|
219
|
+
if (this.timer || this.flushing || this.closed) return;
|
|
220
|
+
this.timer = setTimeout(() => {
|
|
221
|
+
this.timer = null;
|
|
222
|
+
this.scheduleFlush();
|
|
223
|
+
}, this.retryMs);
|
|
224
|
+
this.timer.unref?.();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private scheduleFlush(): void {
|
|
228
|
+
this.clearTimer();
|
|
229
|
+
if (this.flushing || this.closed) return;
|
|
230
|
+
setImmediate(() => {
|
|
231
|
+
void this.flush();
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private clearTimer(): void {
|
|
236
|
+
if (this.timer) {
|
|
237
|
+
clearTimeout(this.timer);
|
|
238
|
+
this.timer = null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private reportError(err: unknown): void {
|
|
243
|
+
try {
|
|
244
|
+
this.onError?.(err);
|
|
245
|
+
} catch {
|
|
246
|
+
// An onError that throws must not escape attest().
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frozen DAR/0.1 constants and types (spec/dar-0.1.md §2, §4).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Record format tag for the frozen DAR/0.1 field set. */
|
|
6
|
+
export const DAR_VERSION = "DAR/0.1" as const;
|
|
7
|
+
|
|
8
|
+
/** Hash algorithm invariant (spec §4.1). SHA3-256 everywhere. */
|
|
9
|
+
export const HASH_ALGORITHM = "sha3-256" as const;
|
|
10
|
+
|
|
11
|
+
/** Prefix on hash-valued fields (spec §4.2), i.e. `sha3-256:`. */
|
|
12
|
+
export const HASH_PREFIX = `${HASH_ALGORITHM}:` as const;
|
|
13
|
+
|
|
14
|
+
/** Attestation endpoint. Test + standard traffic both flush here (CLAUDE.md). */
|
|
15
|
+
export const TIERED_ATTEST_PATH = "/v1/tiered-attest" as const;
|
|
16
|
+
|
|
17
|
+
/** The single credential env var the SDK reads (CLAUDE.md invariant). */
|
|
18
|
+
export const API_KEY_ENV = "RUBRIC_API_KEY" as const;
|
|
19
|
+
|
|
20
|
+
/** Leaf discriminant values (spec §2, `leafType`). */
|
|
21
|
+
export type LeafType = "decision" | "schema-change" | "checkpoint";
|
|
22
|
+
|
|
23
|
+
/** A `sha3-256:<hex>` encoded hash string. */
|
|
24
|
+
export type HashString = `${typeof HASH_ALGORITHM}:${string}`;
|
|
25
|
+
|
|
26
|
+
/** Identifies the adapter that produced a decision's inputs (spec §2). */
|
|
27
|
+
export interface AdapterInfo {
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly version: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Transmit mode: `hash-only` (default) sends DAR cores; `payload` also carries raw content in the envelope. */
|
|
33
|
+
export type TransmitMode = "hash-only" | "payload";
|
|
34
|
+
|
|
35
|
+
/** Raw content transmitted ONLY in `payload` mode, in the transport envelope — never in the DAR core. */
|
|
36
|
+
export interface PayloadRecord {
|
|
37
|
+
decisionId: string;
|
|
38
|
+
schema?: unknown;
|
|
39
|
+
input: unknown;
|
|
40
|
+
output: unknown;
|
|
41
|
+
meta?: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The DAR core: the frozen, HASHES-ONLY field set (spec §2). It never carries
|
|
46
|
+
* raw content — only commitments. The Merkle leaf hash is computed over the JCS
|
|
47
|
+
* canonicalization of this object.
|
|
48
|
+
*/
|
|
49
|
+
export interface DarCore {
|
|
50
|
+
readonly v: typeof DAR_VERSION;
|
|
51
|
+
readonly decisionId: string;
|
|
52
|
+
readonly agentId: string;
|
|
53
|
+
/** Client-claimed decision time; trusted time is the HCS consensus timestamp. */
|
|
54
|
+
readonly ts: string;
|
|
55
|
+
readonly prev: string | null;
|
|
56
|
+
readonly leafType: LeafType;
|
|
57
|
+
readonly schemaHash: HashString;
|
|
58
|
+
readonly inputHash: HashString;
|
|
59
|
+
readonly outputHash: HashString;
|
|
60
|
+
/** SHA3-256 over JCS of { schemaHash, inputHash, outputHash }. */
|
|
61
|
+
readonly decisionHash: HashString;
|
|
62
|
+
readonly schemaRef?: string;
|
|
63
|
+
readonly adapter?: AdapterInfo;
|
|
64
|
+
}
|
package/src/dar.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DAR builder (spec/dar-0.1.md §2). Assembles a HASHES-ONLY DAR core from
|
|
3
|
+
* adapter inputs: hashes the schema, input, and output (JCS+SHA3-256), derives
|
|
4
|
+
* `decisionHash` over those three commitments, mints a ULID `decisionId`, and
|
|
5
|
+
* maintains the per-agent `prev` chain. The core never carries raw content.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
DAR_VERSION,
|
|
9
|
+
type AdapterInfo,
|
|
10
|
+
type DarCore,
|
|
11
|
+
type HashString,
|
|
12
|
+
type LeafType,
|
|
13
|
+
type PayloadRecord,
|
|
14
|
+
} from "./constants.js";
|
|
15
|
+
import { hashJson, sha3_256 } from "./hash.js";
|
|
16
|
+
import { canonicalize, canonicalizeToBytes } from "./jcs.js";
|
|
17
|
+
import { ulid as defaultUlid } from "./ulid.js";
|
|
18
|
+
|
|
19
|
+
const LEAF_TYPES: ReadonlySet<string> = new Set(["decision", "schema-change", "checkpoint"]);
|
|
20
|
+
const HASH_STRING = /^sha3-256:[0-9a-f]{64}$/;
|
|
21
|
+
const DEFAULT_MAX_DECISION_BYTES = 256 * 1024; // bounds attest() latency + payload size
|
|
22
|
+
|
|
23
|
+
/** Non-content metadata an adapter may attach to a decision. */
|
|
24
|
+
export interface DarMeta {
|
|
25
|
+
schemaRef?: string;
|
|
26
|
+
adapter?: AdapterInfo;
|
|
27
|
+
leafType?: LeafType;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DarBuildInput {
|
|
31
|
+
agentId: string;
|
|
32
|
+
/** Adapter-supplied decision input; hashed to `inputHash`. */
|
|
33
|
+
input: unknown;
|
|
34
|
+
/** Adapter-supplied decision output; hashed to `outputHash`. */
|
|
35
|
+
output: unknown;
|
|
36
|
+
/** Schema descriptor to hash, or a precomputed `schemaHash`. One is required. */
|
|
37
|
+
schema?: unknown;
|
|
38
|
+
schemaHash?: HashString;
|
|
39
|
+
meta?: DarMeta;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DarBuilderDeps {
|
|
43
|
+
/** Injectable id generator (default: monotonic ULID). */
|
|
44
|
+
newDecisionId?: () => string;
|
|
45
|
+
/** Injectable clock in epoch ms (default: Date.now). */
|
|
46
|
+
now?: () => number;
|
|
47
|
+
/** Reject a decision whose canonical input+output exceeds this many bytes. Default 256 KiB. */
|
|
48
|
+
maxDecisionBytes?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Derive `decisionHash` from the three content commitments (spec §4.2). */
|
|
52
|
+
export function decisionHashOf(schemaHash: HashString, inputHash: HashString, outputHash: HashString): HashString {
|
|
53
|
+
return hashJson({ schemaHash, inputHash, outputHash });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class DarBuilder {
|
|
57
|
+
private readonly newDecisionId: () => string;
|
|
58
|
+
private readonly now: () => number;
|
|
59
|
+
private readonly maxDecisionBytes: number;
|
|
60
|
+
private readonly heads = new Map<string, string>();
|
|
61
|
+
// Keyed by object identity: reusing the same schema object across build()
|
|
62
|
+
// calls skips re-hashing. CONTRACT: schema objects must be treated as
|
|
63
|
+
// immutable — mutating one in place after its first build() would return the
|
|
64
|
+
// stale cached hash. Distinct objects with identical contents are re-hashed.
|
|
65
|
+
private readonly schemaCache = new WeakMap<object, HashString>();
|
|
66
|
+
|
|
67
|
+
constructor(deps: DarBuilderDeps = {}) {
|
|
68
|
+
this.newDecisionId = deps.newDecisionId ?? defaultUlid;
|
|
69
|
+
this.now = deps.now ?? Date.now;
|
|
70
|
+
this.maxDecisionBytes = deps.maxDecisionBytes ?? DEFAULT_MAX_DECISION_BYTES;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
build(input: DarBuildInput): DarCore {
|
|
74
|
+
const { agentId } = input;
|
|
75
|
+
if (typeof agentId !== "string" || agentId.length === 0) {
|
|
76
|
+
throw new Error("DAR: agentId must be a non-empty string");
|
|
77
|
+
}
|
|
78
|
+
const leafType: LeafType = input.meta?.leafType ?? "decision";
|
|
79
|
+
if (!LEAF_TYPES.has(leafType)) {
|
|
80
|
+
throw new Error(`DAR: unknown leafType '${leafType}'`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const schemaHash = this.resolveSchemaHash(input);
|
|
84
|
+
|
|
85
|
+
// Canonicalize input/output once, cap the combined size, then hash.
|
|
86
|
+
const inputBytes = canonicalizeToBytes(input.input);
|
|
87
|
+
const outputBytes = canonicalizeToBytes(input.output);
|
|
88
|
+
if (inputBytes.length + outputBytes.length > this.maxDecisionBytes) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`DAR: input+output is ${inputBytes.length + outputBytes.length} bytes; exceeds maxDecisionBytes (${this.maxDecisionBytes})`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
const inputHash = sha3_256(inputBytes);
|
|
94
|
+
const outputHash = sha3_256(outputBytes);
|
|
95
|
+
const decisionHash = decisionHashOf(schemaHash, inputHash, outputHash);
|
|
96
|
+
|
|
97
|
+
const decisionId = this.newDecisionId();
|
|
98
|
+
const prev = this.heads.get(agentId) ?? null;
|
|
99
|
+
const ts = new Date(this.now()).toISOString();
|
|
100
|
+
|
|
101
|
+
const dar: DarCore = {
|
|
102
|
+
v: DAR_VERSION,
|
|
103
|
+
decisionId,
|
|
104
|
+
agentId,
|
|
105
|
+
ts,
|
|
106
|
+
prev,
|
|
107
|
+
leafType,
|
|
108
|
+
schemaHash,
|
|
109
|
+
inputHash,
|
|
110
|
+
outputHash,
|
|
111
|
+
decisionHash,
|
|
112
|
+
...(input.meta?.schemaRef !== undefined ? { schemaRef: input.meta.schemaRef } : {}),
|
|
113
|
+
...(input.meta?.adapter !== undefined ? { adapter: input.meta.adapter } : {}),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
this.heads.set(agentId, decisionId);
|
|
117
|
+
return dar;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private resolveSchemaHash(input: DarBuildInput): HashString {
|
|
121
|
+
if (input.schemaHash) {
|
|
122
|
+
if (!HASH_STRING.test(input.schemaHash)) {
|
|
123
|
+
throw new Error(`DAR: schemaHash must be 'sha3-256:<64 hex>', got '${input.schemaHash}'`);
|
|
124
|
+
}
|
|
125
|
+
return input.schemaHash;
|
|
126
|
+
}
|
|
127
|
+
if (input.schema === undefined) throw new Error("DAR: schema or schemaHash is required");
|
|
128
|
+
if (typeof input.schema === "object" && input.schema !== null) {
|
|
129
|
+
const cached = this.schemaCache.get(input.schema);
|
|
130
|
+
if (cached) return cached;
|
|
131
|
+
const h = hashJson(input.schema);
|
|
132
|
+
this.schemaCache.set(input.schema, h);
|
|
133
|
+
return h;
|
|
134
|
+
}
|
|
135
|
+
return hashJson(input.schema);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Current chain head (last minted decisionId) for an agent, if any. */
|
|
139
|
+
getHead(agentId: string): string | undefined {
|
|
140
|
+
return this.heads.get(agentId);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Seed a chain head — used to continue chains after spool recovery. */
|
|
144
|
+
seedHead(agentId: string, decisionId: string): void {
|
|
145
|
+
this.heads.set(agentId, decisionId);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Build the raw payload record for `payload`-mode transmission (never in the core). */
|
|
150
|
+
export function toPayload(decisionId: string, input: DarBuildInput): PayloadRecord {
|
|
151
|
+
return {
|
|
152
|
+
decisionId,
|
|
153
|
+
...(input.schema !== undefined ? { schema: input.schema } : {}),
|
|
154
|
+
input: input.input,
|
|
155
|
+
output: input.output,
|
|
156
|
+
...(input.meta !== undefined ? { meta: input.meta } : {}),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The Merkle leaf hash of a DAR core: SHA3-256 over its JCS bytes (spec §4.3). */
|
|
161
|
+
export function leafHash(dar: DarCore): HashString {
|
|
162
|
+
return hashJson(dar);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** JCS canonicalization of a DAR core — the exact leaf-hash preimage. */
|
|
166
|
+
export function canonicalDar(dar: DarCore): string {
|
|
167
|
+
return canonicalize(dar);
|
|
168
|
+
}
|
package/src/hash.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SHA3-256 hashing (spec/dar-0.1.md §4). SHA3-256 everywhere — an invariant,
|
|
3
|
+
* not a per-call choice. Hash strings are `sha3-256:<64 lowercase hex>`.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { HASH_ALGORITHM, HASH_PREFIX, type HashString } from "./constants.js";
|
|
7
|
+
import { canonicalizeToBytes } from "./jcs.js";
|
|
8
|
+
|
|
9
|
+
/** Raw SHA3-256 as lowercase hex (no prefix). */
|
|
10
|
+
export function sha3_256Hex(input: string | Uint8Array): string {
|
|
11
|
+
const h = createHash(HASH_ALGORITHM);
|
|
12
|
+
h.update(typeof input === "string" ? Buffer.from(input, "utf8") : input);
|
|
13
|
+
return h.digest("hex");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** SHA3-256 as a prefixed `sha3-256:<hex>` HashString. */
|
|
17
|
+
export function sha3_256(input: string | Uint8Array): HashString {
|
|
18
|
+
return `${HASH_PREFIX}${sha3_256Hex(input)}` as HashString;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Canonicalize (JCS) then hash — the one hashing path (spec §3.1).
|
|
23
|
+
* Returns a prefixed HashString over the RFC 8785 bytes of `value`.
|
|
24
|
+
*/
|
|
25
|
+
export function hashJson(value: unknown): HashString {
|
|
26
|
+
return sha3_256(canonicalizeToBytes(value));
|
|
27
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @rubric-protocol/attest-decision — core SDK (DAR/0.1).
|
|
3
|
+
*
|
|
4
|
+
* Public surface: fire-and-forget attestation (`Attestor`), the DAR builder,
|
|
5
|
+
* JCS canonicalization, SHA3-256 hashing, ULID minting, the durable spool, and
|
|
6
|
+
* transports. See spec/dar-0.1.md and tasks/P1.md.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
DAR_VERSION,
|
|
11
|
+
HASH_ALGORITHM,
|
|
12
|
+
HASH_PREFIX,
|
|
13
|
+
TIERED_ATTEST_PATH,
|
|
14
|
+
API_KEY_ENV,
|
|
15
|
+
type LeafType,
|
|
16
|
+
type HashString,
|
|
17
|
+
type DarCore,
|
|
18
|
+
type AdapterInfo,
|
|
19
|
+
type TransmitMode,
|
|
20
|
+
type PayloadRecord,
|
|
21
|
+
} from "./constants.js";
|
|
22
|
+
|
|
23
|
+
export { canonicalize, canonicalizeToBytes, JcsError } from "./jcs.js";
|
|
24
|
+
export { sha3_256, sha3_256Hex, hashJson } from "./hash.js";
|
|
25
|
+
export { ulid, monotonicUlidFactory } from "./ulid.js";
|
|
26
|
+
export {
|
|
27
|
+
DarBuilder,
|
|
28
|
+
leafHash,
|
|
29
|
+
canonicalDar,
|
|
30
|
+
decisionHashOf,
|
|
31
|
+
toPayload,
|
|
32
|
+
type DarBuildInput,
|
|
33
|
+
type DarBuilderDeps,
|
|
34
|
+
type DarMeta,
|
|
35
|
+
} from "./dar.js";
|
|
36
|
+
export { Spool, DEFAULT_MAX_BYTES, type SpoolRecord, type SpoolOptions } from "./spool.js";
|
|
37
|
+
export {
|
|
38
|
+
HttpTransport,
|
|
39
|
+
type Transport,
|
|
40
|
+
type HttpTransportOptions,
|
|
41
|
+
} from "./transport.js";
|
|
42
|
+
export { Attestor, type AttestorOptions } from "./attestor.js";
|
package/src/jcs.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JCS canonicalization — RFC 8785 (spec/dar-0.1.md §3).
|
|
3
|
+
*
|
|
4
|
+
* We lean on the platform: ECMAScript's Number-to-String and `JSON.stringify`
|
|
5
|
+
* string escaping are byte-for-byte what RFC 8785 mandates (§3.2.2.2/§3.2.2.3),
|
|
6
|
+
* and the default string sort compares by UTF-16 code unit — exactly JCS's key
|
|
7
|
+
* ordering rule. So canonicalization reduces to: recurse, sort object keys, and
|
|
8
|
+
* defer scalar serialization to `JSON.stringify`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
class JcsError extends Error {
|
|
12
|
+
constructor(message: string) {
|
|
13
|
+
super(`JCS: ${message}`);
|
|
14
|
+
this.name = "JcsError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function serialize(v: unknown): string {
|
|
19
|
+
if (v === null) return "null";
|
|
20
|
+
|
|
21
|
+
const t = typeof v;
|
|
22
|
+
|
|
23
|
+
if (t === "number") {
|
|
24
|
+
const n = v as number;
|
|
25
|
+
if (!Number.isFinite(n)) {
|
|
26
|
+
throw new JcsError("non-finite number (NaN/Infinity) is not representable");
|
|
27
|
+
}
|
|
28
|
+
// Spec §3.3: integers beyond the safe range MUST be carried as strings — a
|
|
29
|
+
// JS number cannot represent them exactly, so we reject rather than hash a
|
|
30
|
+
// silently-rounded value.
|
|
31
|
+
if (Number.isInteger(n) && !Number.isSafeInteger(n)) {
|
|
32
|
+
throw new JcsError(`integer ${n} exceeds Number.MAX_SAFE_INTEGER; carry it as a string (spec §3.3)`);
|
|
33
|
+
}
|
|
34
|
+
// ES Number::toString === RFC 8785 §3.2.2.3. JSON.stringify(-0) === "0".
|
|
35
|
+
return JSON.stringify(n);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (t === "boolean") return v ? "true" : "false";
|
|
39
|
+
|
|
40
|
+
// RFC 8785 §3.2.2.2 escaping is exactly JSON.stringify's minimal escaping.
|
|
41
|
+
if (t === "string") return escapeString(v as string);
|
|
42
|
+
|
|
43
|
+
if (t === "bigint") {
|
|
44
|
+
throw new JcsError("bigint is not JSON; carry large integers as strings");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (Array.isArray(v)) {
|
|
48
|
+
return "[" + v.map(serializeElement).join(",") + "]";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (t === "object") {
|
|
52
|
+
const proto = Object.getPrototypeOf(v);
|
|
53
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
54
|
+
// Date, Map, Set, class instances, etc. are not part of the JSON model.
|
|
55
|
+
throw new JcsError("non-plain object is not JSON-canonicalizable");
|
|
56
|
+
}
|
|
57
|
+
const obj = v as Record<string, unknown>;
|
|
58
|
+
// Default sort compares by UTF-16 code unit — the RFC 8785 ordering.
|
|
59
|
+
const keys = Object.keys(obj).sort();
|
|
60
|
+
return (
|
|
61
|
+
"{" +
|
|
62
|
+
keys
|
|
63
|
+
.map((k) => {
|
|
64
|
+
const val = obj[k];
|
|
65
|
+
rejectNonJson(val, `property '${k}'`);
|
|
66
|
+
return escapeString(k) + ":" + serialize(val);
|
|
67
|
+
})
|
|
68
|
+
.join(",") +
|
|
69
|
+
"}"
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// undefined, function, symbol at the top level.
|
|
74
|
+
throw new JcsError(`unsupported value of type '${t}'`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Spec §3.3: `undefined`, functions, and symbols MUST NOT appear in a payload —
|
|
79
|
+
* we reject rather than silently stripping them, so a malformed producer input
|
|
80
|
+
* cannot canonicalize to bytes that differ from the producer's intent.
|
|
81
|
+
*/
|
|
82
|
+
function rejectNonJson(value: unknown, where: string): void {
|
|
83
|
+
const t = typeof value;
|
|
84
|
+
if (value === undefined || t === "function" || t === "symbol") {
|
|
85
|
+
throw new JcsError(`${where} has non-JSON value of type '${value === undefined ? "undefined" : t}'`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function serializeElement(el: unknown): string {
|
|
90
|
+
rejectNonJson(el, "array element");
|
|
91
|
+
return serialize(el);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Unpaired UTF-16 surrogate: a high surrogate not followed by a low, or a low
|
|
95
|
+
// not preceded by a high — i.e. invalid Unicode that RFC 8785 does not admit.
|
|
96
|
+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
97
|
+
|
|
98
|
+
/** JSON.stringify a string after rejecting invalid Unicode (unpaired surrogates). */
|
|
99
|
+
function escapeString(s: string): string {
|
|
100
|
+
if (LONE_SURROGATE.test(s)) {
|
|
101
|
+
throw new JcsError("string contains an unpaired surrogate (invalid Unicode)");
|
|
102
|
+
}
|
|
103
|
+
return JSON.stringify(s);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Canonicalize a JSON value to its RFC 8785 (JCS) string form. */
|
|
107
|
+
export function canonicalize(value: unknown): string {
|
|
108
|
+
return serialize(value);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Canonicalize a JSON value to its RFC 8785 (JCS) UTF-8 bytes. */
|
|
112
|
+
export function canonicalizeToBytes(value: unknown): Buffer {
|
|
113
|
+
return Buffer.from(serialize(value), "utf8");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export { JcsError };
|