@crewhaus/provability 0.0.0-stage → 0.8.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/store.js ADDED
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Where bundles and proof artifacts live (design §5.3, §6.4):
3
+ *
4
+ * <cwd>/.crewhaus/proofs/<spec>/<sessionId>/turn-<n>.json one bundle per graded pass
5
+ * <cwd>/.crewhaus/proofs/<spec>/<sessionId>/turn-<n>.<k>.json …the k-th pass of a turn
6
+ * <cwd>/.crewhaus/proofs/<spec>/artifacts/<prf_id>.json one artifact per ProofCheck
7
+ *
8
+ * Files mode 0600 and directories 0700, written to a randomly named temp and renamed into place
9
+ * through `@crewhaus/tool-safety/fs` — never through a link planted at the
10
+ * name (the C070 discipline the continuity store follows). Every name that
11
+ * reaches a path is validated first: a spec name, a session id, a turn
12
+ * number and a proof id each have one shape, and a tampered one is refused
13
+ * before any path is built from it (the citation-tracker sha256 precedent).
14
+ * Bundles carry no TTL — they are the audit artifact; `prune` manages size.
15
+ */
16
+ import { lstatSync, mkdirSync, readdirSync, rmdirSync, unlinkSync } from "node:fs";
17
+ import { join, resolve } from "node:path";
18
+ import { ensureDirContained, openForRead, writeFileSafe } from "@crewhaus/tool-safety/fs";
19
+ import { isProofArtifact, isProofId } from "./artifact";
20
+ import { ProvabilityError } from "./evidence";
21
+ /** continuity-store's spec-name rule: letters, digits, `_ - .`, and not dots alone. */
22
+ const SPEC_NAME_REGEX = /^[a-zA-Z0-9_\-.]+$/;
23
+ const SESSION_ID_REGEX = /^sess_[0-9a-f]{16}$/;
24
+ /** The directory bundles of a run with no session go under. */
25
+ export const NO_SESSION = "no-session";
26
+ /** `turn-<n>.json`, the first pass of a turn; `turn-<n>.<k>.json`, its k-th. */
27
+ const TURN_FILE = /^turn-(\d{1,9})(?:\.(\d{1,4}))?\.json$/;
28
+ /** The most passes one turn's bundles are kept under (retries, and turns a resumed session numbers again). */
29
+ const MAX_PASSES = 1_000;
30
+ /** Largest bundle or artifact read back. */
31
+ const MAX_READ_BYTES = 16 * 1024 * 1024;
32
+ function assertSpecName(specName) {
33
+ if (!SPEC_NAME_REGEX.test(specName) || /^\.+$/.test(specName)) {
34
+ throw new ProvabilityError("config", `provability: invalid spec name "${specName}" — must match [a-zA-Z0-9_\\-.]+ and not be dots only`);
35
+ }
36
+ }
37
+ /** `<cwd>/.crewhaus/proofs/<specName>`: one spec's bundles and artifacts. */
38
+ export function proofsRoot(cwd, specName) {
39
+ assertSpecName(specName);
40
+ return join(resolve(cwd), ".crewhaus", "proofs", specName);
41
+ }
42
+ function sessionDir(sessionId) {
43
+ const dir = sessionId ?? NO_SESSION;
44
+ if (dir !== NO_SESSION && !SESSION_ID_REGEX.test(dir)) {
45
+ throw new ProvabilityError("config", `provability: invalid sessionId "${dir}" — expected sess_<16 hex> or "${NO_SESSION}"`);
46
+ }
47
+ return dir;
48
+ }
49
+ function assertTurn(turnNumber) {
50
+ if (!Number.isInteger(turnNumber) || turnNumber < 0 || turnNumber > 999_999_999) {
51
+ throw new ProvabilityError("config", `provability: invalid turn number ${String(turnNumber)} — expected a non-negative integer`);
52
+ }
53
+ }
54
+ /** Write `data` to `rel` under `root` atomically, mode 0600, never through a link. */
55
+ function writeAtomic(root, rel, data, overwrite) {
56
+ mkdirSync(root, { recursive: true, mode: 0o700 });
57
+ const written = writeFileSafe(root, rel, data, {
58
+ overwrite,
59
+ createParents: true,
60
+ mode: 0o600,
61
+ });
62
+ if (!written.ok) {
63
+ throw new ProvabilityError("runtime", `provability: refusing to write ${rel}: ${written.reason} (code ${written.code})`);
64
+ }
65
+ }
66
+ /** The JSON at `rel` under `root`, or `undefined` when nothing is there. */
67
+ async function readJson(root, rel) {
68
+ const read = await openForRead(root, rel, { maxBytes: MAX_READ_BYTES, followLeafSymlink: false });
69
+ if (!read.ok) {
70
+ if (read.code === "not-found")
71
+ return undefined;
72
+ throw new ProvabilityError("runtime", `provability: refusing to read ${rel}: ${read.reason} (code ${read.code})`);
73
+ }
74
+ if (read.truncated) {
75
+ throw new ProvabilityError("runtime", `provability: ${rel} is larger than ${MAX_READ_BYTES} bytes`);
76
+ }
77
+ try {
78
+ return JSON.parse(read.text);
79
+ }
80
+ catch (err) {
81
+ throw new ProvabilityError("runtime", `provability: ${rel} is not valid JSON`, err);
82
+ }
83
+ }
84
+ /** Entries of a directory that are real directories / regular files (no links followed). */
85
+ function entries(dir, kind) {
86
+ let names;
87
+ try {
88
+ names = readdirSync(dir);
89
+ }
90
+ catch {
91
+ return [];
92
+ }
93
+ return names.filter((name) => {
94
+ try {
95
+ const stat = lstatSync(join(dir, name));
96
+ return kind === "dir" ? stat.isDirectory() : stat.isFile();
97
+ }
98
+ catch {
99
+ return false;
100
+ }
101
+ });
102
+ }
103
+ function isBundle(value) {
104
+ if (typeof value !== "object" || value === null)
105
+ return false;
106
+ const v = value;
107
+ return (v["version"] === 1 &&
108
+ typeof v["level"] === "string" &&
109
+ typeof v["verdict"] === "string" &&
110
+ Array.isArray(v["claims"]) &&
111
+ typeof v["bundleHash"] === "string");
112
+ }
113
+ /** A turn's pass `k` as its file name under the session directory. */
114
+ function passFile(turnNumber, pass) {
115
+ return pass === 1 ? `turn-${turnNumber}.json` : `turn-${turnNumber}.${pass}.json`;
116
+ }
117
+ /** The pass numbers stored for one turn in `dir`, read off the names (links included: a read refuses those). */
118
+ function passesOf(dir, turnNumber) {
119
+ let names;
120
+ try {
121
+ names = readdirSync(dir);
122
+ }
123
+ catch {
124
+ return [];
125
+ }
126
+ const out = [];
127
+ for (const name of names) {
128
+ const m = TURN_FILE.exec(name);
129
+ if (m === null || Number(m[1]) !== turnNumber)
130
+ continue;
131
+ out.push(m[2] === undefined ? 1 : Number(m[2]));
132
+ }
133
+ return out;
134
+ }
135
+ /**
136
+ * The bundle store over a proofs root (`proofsRoot(cwd, specName)`).
137
+ * Tenant fencing is the caller's: on managed, pass the tenant's root.
138
+ */
139
+ export function createBundleStore(opts) {
140
+ const rootDir = resolve(opts.rootDir);
141
+ const relOf = (sessionId, turnNumber, pass = 1) => {
142
+ assertTurn(turnNumber);
143
+ if (!Number.isInteger(pass) || pass < 1 || pass > MAX_PASSES) {
144
+ throw new ProvabilityError("config", `provability: invalid pass ${String(pass)} — expected 1 to ${MAX_PASSES}`);
145
+ }
146
+ return `${sessionDir(sessionId)}/${passFile(turnNumber, pass)}`;
147
+ };
148
+ const list = async (listOpts = {}) => {
149
+ const out = [];
150
+ for (const session of entries(rootDir, "dir")) {
151
+ if (session !== NO_SESSION && !SESSION_ID_REGEX.test(session))
152
+ continue;
153
+ const dir = join(rootDir, session);
154
+ const last = new Map();
155
+ for (const file of entries(dir, "file")) {
156
+ const turn = TURN_FILE.exec(file);
157
+ if (turn === null)
158
+ continue;
159
+ const path = join(dir, file);
160
+ let mtimeMs;
161
+ try {
162
+ mtimeMs = lstatSync(path).mtimeMs;
163
+ }
164
+ catch {
165
+ continue;
166
+ }
167
+ const listing = {
168
+ sessionId: session,
169
+ turnNumber: Number(turn[1]),
170
+ pass: turn[2] === undefined ? 1 : Number(turn[2]),
171
+ path,
172
+ mtimeMs,
173
+ };
174
+ if (listOpts.allPasses === true) {
175
+ out.push(listing);
176
+ continue;
177
+ }
178
+ const prior = last.get(listing.turnNumber);
179
+ if (prior === undefined || listing.pass > prior.pass)
180
+ last.set(listing.turnNumber, listing);
181
+ }
182
+ out.push(...last.values());
183
+ }
184
+ return out.sort((a, b) => b.mtimeMs - a.mtimeMs ||
185
+ b.turnNumber - a.turnNumber ||
186
+ b.pass - a.pass ||
187
+ a.sessionId.localeCompare(b.sessionId));
188
+ };
189
+ return {
190
+ rootDir,
191
+ async put(bundle, at) {
192
+ relOf(at.sessionId, at.turnNumber);
193
+ const session = sessionDir(at.sessionId);
194
+ mkdirSync(rootDir, { recursive: true, mode: 0o700 });
195
+ // The session directory is as private as the root and the files.
196
+ const dir = ensureDirContained(rootDir, session, { mode: 0o700 });
197
+ if (!dir.ok) {
198
+ throw new ProvabilityError("runtime", `provability: refusing to write under ${session}: ${dir.reason} (code ${dir.code})`);
199
+ }
200
+ const data = `${JSON.stringify(bundle, null, 2)}\n`;
201
+ for (let pass = 1; pass <= MAX_PASSES; pass++) {
202
+ const rel = relOf(at.sessionId, at.turnNumber, pass);
203
+ const written = writeFileSafe(rootDir, rel, data, {
204
+ overwrite: false,
205
+ createParents: true,
206
+ mode: 0o600,
207
+ });
208
+ if (written.ok)
209
+ return join(rootDir, rel);
210
+ // Taken by an earlier pass: the next name. Anything else — a link
211
+ // planted at the name, a directory there — is refused, not stepped over.
212
+ if (written.code !== "exists") {
213
+ throw new ProvabilityError("runtime", `provability: refusing to write ${rel}: ${written.reason} (code ${written.code})`);
214
+ }
215
+ }
216
+ throw new ProvabilityError("runtime", `provability: ${session} already holds ${MAX_PASSES} passes of turn ${at.turnNumber}`);
217
+ },
218
+ async get(sessionId, turnNumber, pass) {
219
+ let which = pass;
220
+ if (which === undefined) {
221
+ relOf(sessionId, turnNumber);
222
+ const stored = passesOf(join(rootDir, sessionDir(sessionId)), turnNumber);
223
+ which = stored.length > 0 ? Math.max(...stored) : 1;
224
+ }
225
+ const rel = relOf(sessionId, turnNumber, which);
226
+ const value = await readJson(rootDir, rel);
227
+ if (value === undefined)
228
+ return undefined;
229
+ if (!isBundle(value)) {
230
+ throw new ProvabilityError("runtime", `provability: ${rel} is not a proof bundle`);
231
+ }
232
+ return value;
233
+ },
234
+ list,
235
+ async prune(olderThanMs, now = new Date()) {
236
+ if (!Number.isFinite(olderThanMs) || olderThanMs < 0) {
237
+ throw new ProvabilityError("config", `provability: prune needs a non-negative age, got ${String(olderThanMs)}`);
238
+ }
239
+ const cutoff = now.getTime() - olderThanMs;
240
+ let removed = 0;
241
+ const touched = new Set();
242
+ for (const listing of await list({ allPasses: true })) {
243
+ if (listing.mtimeMs >= cutoff)
244
+ continue;
245
+ try {
246
+ unlinkSync(listing.path);
247
+ removed += 1;
248
+ touched.add(join(rootDir, listing.sessionId));
249
+ }
250
+ catch {
251
+ // Gone already, or not ours to remove: leave it.
252
+ }
253
+ }
254
+ for (const dir of touched) {
255
+ try {
256
+ rmdirSync(dir); // only succeeds when empty
257
+ }
258
+ catch {
259
+ // Still holds bundles (or something else): keep it.
260
+ }
261
+ }
262
+ return removed;
263
+ },
264
+ };
265
+ }
266
+ const ARTIFACTS_DIR = "artifacts";
267
+ /**
268
+ * Proof artifacts under `<rootDir>/artifacts/<id>.json`, where `rootDir` is
269
+ * the proofs root (`proofsRoot(cwd, specName)`). An id is checked against
270
+ * `prf_<16 hex>` BEFORE it is put into a path: `get` answers `undefined` for
271
+ * any other string (a `proof:../../x` citation reads nothing), and `put`
272
+ * refuses one. Artifacts are immutable: `put` never replaces an existing id
273
+ * — a later check is a new artifact, so the one a bundle cited stays the
274
+ * one it was graded against.
275
+ */
276
+ export function createFileArtifactStore(opts) {
277
+ const rootDir = resolve(opts.rootDir);
278
+ const get = async (id) => {
279
+ if (!isProofId(id))
280
+ return undefined;
281
+ const value = await readJson(rootDir, `${ARTIFACTS_DIR}/${id}.json`);
282
+ if (value === undefined)
283
+ return undefined;
284
+ if (!isProofArtifact(value)) {
285
+ throw new ProvabilityError("runtime", `provability: artifact ${id} is malformed`);
286
+ }
287
+ return value;
288
+ };
289
+ return {
290
+ rootDir,
291
+ get,
292
+ async put(artifact) {
293
+ if (!isProofId(artifact.id)) {
294
+ throw new ProvabilityError("config", `provability: invalid proof id "${String(artifact.id).slice(0, 40)}" — expected prf_<16 hex>`);
295
+ }
296
+ if (!isProofArtifact(artifact)) {
297
+ throw new ProvabilityError("config", `provability: artifact ${artifact.id} is malformed`);
298
+ }
299
+ writeAtomic(rootDir, `${ARTIFACTS_DIR}/${artifact.id}.json`, `${JSON.stringify(artifact, null, 2)}\n`, false);
300
+ },
301
+ async list() {
302
+ const out = [];
303
+ for (const file of entries(join(rootDir, ARTIFACTS_DIR), "file")) {
304
+ const id = file.endsWith(".json") ? file.slice(0, -5) : "";
305
+ if (!isProofId(id))
306
+ continue;
307
+ try {
308
+ const artifact = await get(id);
309
+ if (artifact !== undefined)
310
+ out.push(artifact);
311
+ }
312
+ catch {
313
+ // A malformed artifact is not listed; `get` reports it by id.
314
+ }
315
+ }
316
+ return out.sort((a, b) => b.checkedAt.localeCompare(a.checkedAt) || a.id.localeCompare(b.id));
317
+ },
318
+ };
319
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The bundle's summary: what the `provability_graded` trace and event-log
3
+ * line and the `provability_evaluated` audit record carry instead of the
4
+ * whole bundle (design §5.3). `bundleHash` ties the summary to the bundle
5
+ * persisted under `.crewhaus/proofs/`.
6
+ */
7
+ import type { ProofBundle, ProvabilityGradedSummary } from "./types";
8
+ export declare function summarizeBundle(bundle: ProofBundle): ProvabilityGradedSummary;
@@ -0,0 +1,22 @@
1
+ export function summarizeBundle(bundle) {
2
+ return {
3
+ level: bundle.level,
4
+ verdict: bundle.verdict,
5
+ score: bundle.score,
6
+ threshold: bundle.threshold,
7
+ achieved: bundle.achieved,
8
+ claims: bundle.claimCount,
9
+ met: bundle.met,
10
+ unmet: bundle.unmet,
11
+ assumptions: bundle.assumptions,
12
+ ...(bundle.assumptionsRefused !== undefined
13
+ ? { assumptionsRefused: bundle.assumptionsRefused }
14
+ : {}),
15
+ ...(bundle.hypotheses !== undefined ? { hypotheses: bundle.hypotheses } : {}),
16
+ ...(bundle.selfWritten !== undefined ? { selfWritten: bundle.selfWritten } : {}),
17
+ retryIndex: bundle.retryIndex,
18
+ bundleHash: bundle.bundleHash,
19
+ ...(bundle.judgeModel !== undefined ? { judgeModel: bundle.judgeModel } : {}),
20
+ ...(bundle.spentUsdMicros !== undefined ? { judgeCostUsdMicros: bundle.spentUsdMicros } : {}),
21
+ };
22
+ }