@avee1234/selfpatch 0.1.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/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@avee1234/selfpatch",
3
+ "version": "0.1.0",
4
+ "description": "The safe self-modification layer for AI agent harnesses. Every self-edit becomes a verifiable, gated, revertible artifact — a pull request your agent opens against its own brain. Zero dependencies.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "selfpatch": "bin/selfpatch.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "scripts": {
20
+ "test": "node --test",
21
+ "build:site-lib": "node scripts/build-site-lib.mjs"
22
+ },
23
+ "keywords": [
24
+ "ai",
25
+ "agent",
26
+ "harness",
27
+ "self-improvement",
28
+ "rsi",
29
+ "governance",
30
+ "reward-hacking",
31
+ "safety",
32
+ "audit",
33
+ "rollback",
34
+ "open-format",
35
+ "zero-dependency"
36
+ ],
37
+ "license": "MIT",
38
+ "homepage": "https://selfpatch.vercel.app",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/abhid1234/selfpatch.git"
42
+ },
43
+ "bugs": {
44
+ "url": "https://github.com/abhid1234/selfpatch/issues"
45
+ }
46
+ }
@@ -0,0 +1,144 @@
1
+ // selfpatch — Claude Code skills adapter (v0.1). The Foundry dogfood.
2
+ //
3
+ // Treat a `.agents/skills` tree as an editable *surface* and map a before/after
4
+ // tree diff into a list of validated self-patches — one per changed skill file —
5
+ // by composing the shipped `proposeSelfPatch`. These are the harness's OWN
6
+ // orchestration skills (the factory editing itself), i.e. vision layer 4, so the
7
+ // layer is `scaffolding`, not `skill`.
8
+ //
9
+ // Pure and synchronous, zero-dependency: the module does NO filesystem, process,
10
+ // or network I/O. It operates on in-memory `{ relPath → content }` maps the CLI
11
+ // injects (the CLI does the disk walk), mirroring the core/seam split every other
12
+ // module uses — so it is fully unit-testable without disk. Its only import is
13
+ // `proposeSelfPatch` / `ProposeError`.
14
+
15
+ import { proposeSelfPatch, ProposeError } from "../propose.js";
16
+
17
+ export const SKILL_LAYER = "scaffolding";
18
+ export const DEFAULT_SKILLS_ROOT = ".agents/skills";
19
+ export const DEFAULT_EVAL_COMMAND = "npm test";
20
+
21
+ // Join a skills root and a repo-relative skill path into a stable POSIX
22
+ // repo-relative `target`, independent of the host path separator. The target is
23
+ // a logical repo path, not a filesystem path, so we normalize slashes and trim
24
+ // redundant separators by hand rather than reach for the platform path module
25
+ // (which would also make this module non-pure across OSes).
26
+ export function skillTargetPath(skillsRoot, relPath) {
27
+ const root = String(skillsRoot).replace(/\\/g, "/").replace(/\/+$/, "");
28
+ const rel = String(relPath).replace(/\\/g, "/").replace(/^\/+/, "");
29
+ return root === "" ? rel : `${root}/${rel}`;
30
+ }
31
+
32
+ // Collect the keys of a plain object or a Map into a Set, so callers may pass
33
+ // either shape for a tree.
34
+ function treeKeys(tree) {
35
+ if (tree instanceof Map) return new Set(tree.keys());
36
+ return new Set(Object.keys(tree ?? {}));
37
+ }
38
+
39
+ // Read a rel's content from a plain object or a Map; `undefined` when absent.
40
+ function treeGet(tree, rel) {
41
+ if (tree instanceof Map) return tree.get(rel);
42
+ return tree ? tree[rel] : undefined;
43
+ }
44
+
45
+ /**
46
+ * Classify a before/after skills-tree diff purely by string content.
47
+ *
48
+ * @param {object|Map} before `{ relPath → content:string }` (or a Map).
49
+ * @param {object|Map} after `{ relPath → content:string }` (or a Map).
50
+ * @returns {{ added: string[], modified: string[], removed: string[], unchanged: string[] }}
51
+ * A rel present only in `after` ⇒ added; only in `before` ⇒ removed; in both
52
+ * and differing ⇒ modified; in both and equal ⇒ unchanged. Each list is sorted
53
+ * for deterministic output.
54
+ */
55
+ export function diffSkillTrees(before, after) {
56
+ const beforeKeys = treeKeys(before);
57
+ const afterKeys = treeKeys(after);
58
+ const all = new Set([...beforeKeys, ...afterKeys]);
59
+
60
+ const added = [];
61
+ const modified = [];
62
+ const removed = [];
63
+ const unchanged = [];
64
+
65
+ for (const rel of all) {
66
+ const inBefore = beforeKeys.has(rel);
67
+ const inAfter = afterKeys.has(rel);
68
+ if (inAfter && !inBefore) {
69
+ added.push(rel);
70
+ } else if (inBefore && !inAfter) {
71
+ removed.push(rel);
72
+ } else if (treeGet(before, rel) === treeGet(after, rel)) {
73
+ unchanged.push(rel);
74
+ } else {
75
+ modified.push(rel);
76
+ }
77
+ }
78
+
79
+ added.sort();
80
+ modified.sort();
81
+ removed.sort();
82
+ unchanged.sort();
83
+ return { added, modified, removed, unchanged };
84
+ }
85
+
86
+ /**
87
+ * Diff two skills trees and build a validated self-patch for each changed file.
88
+ *
89
+ * Every added/modified skill becomes one `layer: "scaffolding"` self-patch with
90
+ * a `before_after` diff (an added file has `before: ""`), a per-target blast
91
+ * radius, and an operator-supplied `eval_contract`. Removed skills are surfaced
92
+ * in `changes.removed` but produce NO patch (a `before_after` patch with
93
+ * `after: ""` would write an empty file, not delete it — the honest bound of the
94
+ * shipped `apply`; the CLI routes removals to a human).
95
+ *
96
+ * @param {object|Map} before `{ relPath → content:string }` (or a Map).
97
+ * @param {object|Map} after `{ relPath → content:string }` (or a Map).
98
+ * @param {object} [opts] `{ skillsRoot, author, rationale, evalCommand,
99
+ * requires }`. `skillsRoot` defaults to `.agents/skills`, `evalCommand` to
100
+ * `npm test`, `requires` to `human-gate` (fail-closed). `rationale` defaults
101
+ * to a per-target string.
102
+ * @returns {{ patches: object[], changes: object }} `patches` in `added` then
103
+ * `modified` order (each sorted); `changes` is the raw `diffSkillTrees` result.
104
+ * @throws {ProposeError} Re-thrown from `proposeSelfPatch` if an assembled patch
105
+ * fails the #1 schema, carrying `.errors` so the CLI can exit 2.
106
+ */
107
+ export function proposeSkillEdits(before, after, opts = {}) {
108
+ const {
109
+ skillsRoot = DEFAULT_SKILLS_ROOT,
110
+ author,
111
+ rationale,
112
+ evalCommand = DEFAULT_EVAL_COMMAND,
113
+ requires = "human-gate",
114
+ } = opts;
115
+
116
+ const changes = diffSkillTrees(before, after);
117
+ const patches = [];
118
+
119
+ // Added first, then modified — both already sorted — so patch order is stable.
120
+ for (const rel of [...changes.added, ...changes.modified]) {
121
+ const target = skillTargetPath(skillsRoot, rel);
122
+ const patch = proposeSelfPatch(
123
+ {
124
+ format: "before_after",
125
+ before: treeGet(before, rel) ?? "",
126
+ after: treeGet(after, rel),
127
+ },
128
+ {
129
+ author,
130
+ layer: SKILL_LAYER,
131
+ target,
132
+ rationale: rationale ?? `factory retro: skill edit to ${target}`,
133
+ command: evalCommand,
134
+ requires,
135
+ surfaces: [target],
136
+ }
137
+ );
138
+ patches.push(patch);
139
+ }
140
+
141
+ return { patches, changes };
142
+ }
143
+
144
+ export { ProposeError };
@@ -0,0 +1,24 @@
1
+ // selfpatch — canonical serialization (shared).
2
+ //
3
+ // Deterministic, key-order-independent serialization for hashing. Objects emit
4
+ // their keys sorted; arrays keep order; `undefined` collapses to `null` so a
5
+ // missing field hashes identically to an explicit null.
6
+ //
7
+ // Extracted verbatim from `propose.js` so the self-patch content-hash `id` and
8
+ // the ledger's hash chain share one definition. Zero-dependency, pure.
9
+
10
+ export function canonicalize(v) {
11
+ if (v === null || v === undefined) return "null";
12
+ if (Array.isArray(v)) return "[" + v.map(canonicalize).join(",") + "]";
13
+ if (typeof v === "object") {
14
+ return (
15
+ "{" +
16
+ Object.keys(v)
17
+ .sort()
18
+ .map((k) => JSON.stringify(k) + ":" + canonicalize(v[k]))
19
+ .join(",") +
20
+ "}"
21
+ );
22
+ }
23
+ return JSON.stringify(v);
24
+ }
package/src/gate.js ADDED
@@ -0,0 +1,232 @@
1
+ // selfpatch — `gate` (v0.1). The policy engine.
2
+ //
3
+ // Given a self-patch (#1) and a protected-surface policy, decide
4
+ // `approve | human-gate | block` with an explicit human-readable reason. The
5
+ // decision is a *declarative* read of the policy (protected surfaces, blast
6
+ // radius limits, per-target approval rules) — no predicate/escape-hatch code
7
+ // yet (roadmap: "declarative core + optional escape hatch"; the escape hatch is
8
+ // deferred).
9
+ //
10
+ // Pure and synchronous, zero-dependency. This module does NO process,
11
+ // filesystem, or network I/O — the CLI reads/validates the files and prints the
12
+ // verdict. So the whole gate logic is unit-testable with plain objects.
13
+ //
14
+ // Decision, in fail-closed precedence (strongest first):
15
+ // 1. block — the patch touches a **protected surface**, or its declared
16
+ // **blast radius** exceeds a policy limit. Hard policy violations
17
+ // win over everything, regardless of the patch's `requires`.
18
+ // 2. else, resolve the effective `requires` for this patch as the STRICTEST of
19
+ // the patch's own `requires`, every matching `approvals` rule, and (only
20
+ // when no rule matches) the policy's `default_requires` (itself defaulting
21
+ // to `human-gate`). Then map on the strictness lattice auto < human-gate <
22
+ // forbidden:
23
+ // forbidden ⇒ block
24
+ // human-gate ⇒ human-gate
25
+ // auto ⇒ approve
26
+
27
+ // Thrown when handed inputs that cannot be gated: a non-object patch or policy.
28
+ // Fail closed — an ungateable self-patch is not silently "approved". Named/
29
+ // exported to mirror `ProposeError` (#2) and `VerifyError` (#3) so callers can
30
+ // rely on the type. The CLI validates both documents first, so this is a guard.
31
+ export class GateError extends Error {
32
+ constructor(message) {
33
+ super(message);
34
+ this.name = "GateError";
35
+ }
36
+ }
37
+
38
+ export const DECISIONS = ["approve", "human-gate", "block"];
39
+
40
+ // Strictness lattice for `requires`. Higher rank = more restrictive (fail-
41
+ // closed). Index in RANK_TO_DECISION maps the resolved rank to a decision.
42
+ const REQUIRES_RANK = { auto: 0, "human-gate": 1, forbidden: 2 };
43
+ const RANK_TO_DECISION = ["approve", "human-gate", "block"];
44
+
45
+ // An unknown `requires` string is treated as the strictest level (fail closed);
46
+ // the CLI validates first, so this only bites malformed direct callers.
47
+ function requiresRank(r) {
48
+ return Object.prototype.hasOwnProperty.call(REQUIRES_RANK, r)
49
+ ? REQUIRES_RANK[r]
50
+ : REQUIRES_RANK.forbidden;
51
+ }
52
+
53
+ function isPlainObject(v) {
54
+ return v !== null && typeof v === "object" && !Array.isArray(v);
55
+ }
56
+
57
+ function isString(v) {
58
+ return typeof v === "string";
59
+ }
60
+
61
+ function nonEmptyString(v) {
62
+ return typeof v === "string" && v.trim().length > 0;
63
+ }
64
+
65
+ function isNonNegativeNumber(v) {
66
+ return typeof v === "number" && Number.isFinite(v) && v >= 0;
67
+ }
68
+
69
+ function isAbsent(v) {
70
+ return v === undefined || v === null;
71
+ }
72
+
73
+ // Glob matcher: `*` matches any run of characters (including `/`); a pattern
74
+ // with no `*` is an exact match. So `src/schema.js` matches only itself, `src/*`
75
+ // matches everything under `src/`, and `*` matches any surface. Anchored full
76
+ // match; all other regex metacharacters are matched literally.
77
+ function matchSurface(pattern, value) {
78
+ if (!isString(pattern) || !isString(value)) return false;
79
+ const escaped = pattern
80
+ .replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
81
+ .replace(/\\\*/g, ".*");
82
+ return new RegExp("^" + escaped + "$").test(value);
83
+ }
84
+
85
+ // The concrete surfaces a patch touches: its `target` plus every declared
86
+ // `blast_radius.surfaces` entry. Matched against `protected_surfaces` as the
87
+ // union so a patch can't slip a protected file past by declaring it in only one
88
+ // place.
89
+ function touchedSurfaces(patch) {
90
+ const out = [];
91
+ if (nonEmptyString(patch.target)) out.push(patch.target);
92
+ const br = patch.blast_radius;
93
+ if (isPlainObject(br) && Array.isArray(br.surfaces)) {
94
+ for (const s of br.surfaces) if (isString(s)) out.push(s);
95
+ }
96
+ return out;
97
+ }
98
+
99
+ // First protected surface the patch touches, or null. Returns the matching
100
+ // `{ surface, pattern }` so the reason can name both.
101
+ function firstProtectedHit(touched, protectedSurfaces) {
102
+ for (const pattern of protectedSurfaces) {
103
+ for (const surface of touched) {
104
+ if (matchSurface(pattern, surface)) return { surface, pattern };
105
+ }
106
+ }
107
+ return null;
108
+ }
109
+
110
+ // First blast-radius limit the patch's declared blast radius exceeds, as a
111
+ // reason string, or null. `surfaces` is always declared (schema-required), so
112
+ // `max_surfaces` is always checkable; `files_changed`/`lines_changed` are only
113
+ // compared when the patch declares them (nothing to "exceed" otherwise).
114
+ function firstBlastViolation(patch, limits) {
115
+ const br = isPlainObject(patch.blast_radius) ? patch.blast_radius : {};
116
+ const surfaces = Array.isArray(br.surfaces) ? br.surfaces : [];
117
+ if (isNonNegativeNumber(limits.max_surfaces) && surfaces.length > limits.max_surfaces) {
118
+ return `declared blast radius of ${surfaces.length} surface(s) exceeds max_surfaces ${limits.max_surfaces}`;
119
+ }
120
+ if (
121
+ isNonNegativeNumber(limits.max_files_changed) &&
122
+ isNonNegativeNumber(br.files_changed) &&
123
+ br.files_changed > limits.max_files_changed
124
+ ) {
125
+ return `declared files_changed ${br.files_changed} exceeds max_files_changed ${limits.max_files_changed}`;
126
+ }
127
+ if (
128
+ isNonNegativeNumber(limits.max_lines_changed) &&
129
+ isNonNegativeNumber(br.lines_changed) &&
130
+ br.lines_changed > limits.max_lines_changed
131
+ ) {
132
+ return `declared lines_changed ${br.lines_changed} exceeds max_lines_changed ${limits.max_lines_changed}`;
133
+ }
134
+ return null;
135
+ }
136
+
137
+ // Does an `approvals` rule apply to this patch? A rule constrains by `layer`
138
+ // and/or `target` (schema guarantees at least one). Every present constraint
139
+ // must match; `target` is matched with the same glob as protected surfaces.
140
+ function ruleMatches(rule, patch) {
141
+ if (!isPlainObject(rule)) return false;
142
+ if (isAbsent(rule.layer) && isAbsent(rule.target)) return false;
143
+ if (!isAbsent(rule.layer) && rule.layer !== patch.layer) return false;
144
+ if (!isAbsent(rule.target) && !matchSurface(rule.target, patch.target)) return false;
145
+ return true;
146
+ }
147
+
148
+ // Resolve the effective `requires` level: the strictest of the patch's own
149
+ // `requires` and every matching approval rule; when no rule matches, the
150
+ // policy's `default_requires` (defaulting to `human-gate`) applies instead.
151
+ // Returns `{ level, rank, matched }`.
152
+ function effectiveRequires(patch, policy) {
153
+ const levels = [patch.requires];
154
+ const approvals = Array.isArray(policy.approvals) ? policy.approvals : [];
155
+ let matched = false;
156
+ for (const rule of approvals) {
157
+ if (ruleMatches(rule, patch)) {
158
+ levels.push(rule.requires);
159
+ matched = true;
160
+ }
161
+ }
162
+ if (!matched) {
163
+ levels.push(isString(policy.default_requires) ? policy.default_requires : "human-gate");
164
+ }
165
+
166
+ let level = "auto";
167
+ let rank = 0;
168
+ for (const l of levels) {
169
+ const r = requiresRank(l);
170
+ if (r >= rank) {
171
+ rank = r;
172
+ level = l;
173
+ }
174
+ }
175
+ return { level, rank, matched };
176
+ }
177
+
178
+ /**
179
+ * Decide whether a self-patch may be applied under a policy.
180
+ *
181
+ * @param {object} patch A self-patch object (valid per #1). Reads `target`,
182
+ * `layer`, `blast_radius`, and `requires`.
183
+ * @param {object} policy A protected-surface policy (valid per #1). Reads
184
+ * `protected_surfaces`, `blast_radius_limits`, `approvals`, `default_requires`.
185
+ * @returns {{ decision: "approve"|"human-gate"|"block", reason: string }}
186
+ * @throws {GateError} If `patch` or `policy` is not a plain object.
187
+ */
188
+ export function gate(patch, policy) {
189
+ if (!isPlainObject(patch)) {
190
+ throw new GateError("gate: `patch` must be an object");
191
+ }
192
+ if (!isPlainObject(policy)) {
193
+ throw new GateError("gate: `policy` must be an object");
194
+ }
195
+
196
+ // 1. Protected surface — the hardest violation; wins over any `requires`.
197
+ const protectedSurfaces = Array.isArray(policy.protected_surfaces)
198
+ ? policy.protected_surfaces
199
+ : [];
200
+ const hit = firstProtectedHit(touchedSurfaces(patch), protectedSurfaces);
201
+ if (hit) {
202
+ const via = hit.pattern === hit.surface ? "" : ` (matched by rule "${hit.pattern}")`;
203
+ return {
204
+ decision: "block",
205
+ reason: `touches protected surface "${hit.surface}"${via}`,
206
+ };
207
+ }
208
+
209
+ // 2. Blast radius over a declared limit.
210
+ const limits = isPlainObject(policy.blast_radius_limits)
211
+ ? policy.blast_radius_limits
212
+ : {};
213
+ const blast = firstBlastViolation(patch, limits);
214
+ if (blast) {
215
+ return { decision: "block", reason: blast };
216
+ }
217
+
218
+ // 3. Effective approval level.
219
+ const eff = effectiveRequires(patch, policy);
220
+ const decision = RANK_TO_DECISION[eff.rank] || "block";
221
+ const via = eff.matched ? "a policy approval rule" : "the patch/default level";
222
+ if (decision === "block") {
223
+ return { decision, reason: `effective requires "forbidden" (via ${via})` };
224
+ }
225
+ if (decision === "human-gate") {
226
+ return { decision, reason: `effective requires "human-gate" (via ${via})` };
227
+ }
228
+ return {
229
+ decision: "approve",
230
+ reason: `effective requires "auto"; no protected surface touched and within blast-radius limits`,
231
+ };
232
+ }
package/src/index.js ADDED
@@ -0,0 +1,36 @@
1
+ export {
2
+ validateSelfPatch,
3
+ validatePolicy,
4
+ LAYERS,
5
+ REQUIRES,
6
+ DIFF_FORMATS,
7
+ ERROR_CODES,
8
+ } from "./schema.js";
9
+
10
+ export { sha256hex } from "./sha256.js";
11
+
12
+ export { proposeSelfPatch, ProposeError } from "./propose.js";
13
+
14
+ export { verifySelfPatch, VerifyError } from "./verify.js";
15
+
16
+ export { gate, GateError, DECISIONS } from "./gate.js";
17
+
18
+ export {
19
+ LedgerError,
20
+ LEDGER_EVENTS,
21
+ hashEntry,
22
+ blobRef,
23
+ buildAppliedEntry,
24
+ buildRevertedEntry,
25
+ verifyChain,
26
+ findRevertable,
27
+ } from "./ledger.js";
28
+
29
+ export {
30
+ proposeSkillEdits,
31
+ diffSkillTrees,
32
+ skillTargetPath,
33
+ SKILL_LAYER,
34
+ DEFAULT_SKILLS_ROOT,
35
+ DEFAULT_EVAL_COMMAND,
36
+ } from "./adapters/claude-code-skills.js";
package/src/ledger.js ADDED
@@ -0,0 +1,202 @@
1
+ // selfpatch — `ledger` (v0.1). The hash-chained, tamper-evident record.
2
+ //
3
+ // The fourth verb: `apply` writes a surface and records the event, `revert <id>`
4
+ // restores the pre-patch surface, and `log` prints the history. This module is
5
+ // the **pure core** — chain construction, hashing, integrity verification, and
6
+ // entry selection. It performs NO filesystem, process, or network I/O; the CLI
7
+ // injects every read/write (the same core/seam split `verify` uses for
8
+ // `runCheck`). Canonical hashing reuses `propose`'s key-order-independent
9
+ // `canonicalize`, so a re-serialized entry hashes identically.
10
+ //
11
+ // Integrity is a **hash chain, not signatures**: each entry stores `prev` (the
12
+ // hash of the preceding entry) and its own `hash` = sha256 over the entry's
13
+ // canonical content excluding `hash` itself. Because the hash covers `prev`, any
14
+ // edit, reorder, insertion, or deletion of a past entry breaks the chain and is
15
+ // detected on read. This is tamper-*evidence* (detection), not prevention or
16
+ // authorship — signatures are a deferred later milestone.
17
+
18
+ import { sha256hex } from "./sha256.js";
19
+ import { canonicalize } from "./canonical.js";
20
+
21
+ // Thrown for un-chainable input (a non-array chain, an entry that isn't a plain
22
+ // object) and for an ambiguous revert id-prefix. Named/exported to mirror
23
+ // `ProposeError`/`VerifyError`/`GateError` so callers can rely on the type.
24
+ export class LedgerError extends Error {
25
+ constructor(message) {
26
+ super(message);
27
+ this.name = "LedgerError";
28
+ }
29
+ }
30
+
31
+ // The event vocabulary the two mutating verbs write. `log` renders any event
32
+ // type it finds, so a future pipeline can append `proposed`/`gated`/`blocked`
33
+ // lifecycle entries without a format change (a non-goal here).
34
+ export const LEDGER_EVENTS = ["applied", "reverted"];
35
+
36
+ function isPlainObject(v) {
37
+ return v !== null && typeof v === "object" && !Array.isArray(v);
38
+ }
39
+
40
+ function sha256(content) {
41
+ return sha256hex(content);
42
+ }
43
+
44
+ /**
45
+ * The hash of an entry: `"sha256:" + sha256(canonical(entry without hash))`.
46
+ * `prev` IS included in the canonical content, so the hash covers the link to
47
+ * the predecessor — an attacker cannot reorder entries without breaking a hash.
48
+ * @throws {LedgerError} If `entry` is not a plain object.
49
+ */
50
+ export function hashEntry(entry) {
51
+ if (!isPlainObject(entry)) {
52
+ throw new LedgerError("hashEntry: entry must be a plain object");
53
+ }
54
+ const hashable = {};
55
+ for (const k of Object.keys(entry)) {
56
+ if (k === "hash") continue;
57
+ hashable[k] = entry[k];
58
+ }
59
+ return "sha256:" + sha256(canonicalize(hashable));
60
+ }
61
+
62
+ /**
63
+ * A content-addressed reference to a surface snapshot:
64
+ * `"sha256:" + sha256(content)`. `null` content ⇒ `null` ref (an absent
65
+ * surface — a file `apply` created or that `revert` will delete). The
66
+ * object-store key is the hex after the `sha256:` prefix.
67
+ */
68
+ export function blobRef(content) {
69
+ if (content === null || content === undefined) return null;
70
+ return "sha256:" + sha256(content);
71
+ }
72
+
73
+ // Attach `prev`/`seq`, then the `hash` over everything else. Pure. `partial`
74
+ // carries every semantic field; the caller supplies `seq` and `prev`.
75
+ function linkEntry(prevHash, seq, partial) {
76
+ const entry = { ...partial, seq, prev: prevHash ?? null };
77
+ entry.hash = hashEntry(entry);
78
+ return entry;
79
+ }
80
+
81
+ /**
82
+ * Assemble a hash-linked `applied` entry from a self-patch and its snapshots.
83
+ * `pre`/`post` are blob refs (or `null`); `reason` is omitted when absent.
84
+ */
85
+ export function buildAppliedEntry({ patch, target, pre, post, ts, seq, prev, reason }) {
86
+ const partial = {
87
+ ts,
88
+ event: "applied",
89
+ patch_id: patch.id,
90
+ layer: patch.layer,
91
+ target,
92
+ rationale: patch.rationale,
93
+ pre,
94
+ post,
95
+ };
96
+ if (reason !== undefined && reason !== null) partial.reason = reason;
97
+ return linkEntry(prev, seq, partial);
98
+ }
99
+
100
+ /**
101
+ * Assemble a hash-linked `reverted` entry. `reverts` is the `hash` of the
102
+ * `applied` entry being undone; `pre`/`post` snapshot the surface around the
103
+ * restore.
104
+ */
105
+ export function buildRevertedEntry({ patchId, target, reverts, pre, post, ts, seq, prev }) {
106
+ return linkEntry(prev, seq, {
107
+ ts,
108
+ event: "reverted",
109
+ patch_id: patchId,
110
+ target,
111
+ reverts,
112
+ pre,
113
+ post,
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Verify a parsed chain. For each entry checks `prev` links to the previous
119
+ * entry's `hash` (`null` at genesis) AND `hash === hashEntry(entry)`. Detects
120
+ * mutation, reorder, insertion, and deletion. Returns `{ valid, brokenAt }`
121
+ * where `brokenAt` is the first failing index (`-1` if intact).
122
+ * @throws {LedgerError} If `entries` is not an array.
123
+ */
124
+ export function verifyChain(entries) {
125
+ if (!Array.isArray(entries)) {
126
+ throw new LedgerError("verifyChain: entries must be an array");
127
+ }
128
+ for (let i = 0; i < entries.length; i++) {
129
+ const entry = entries[i];
130
+ if (!isPlainObject(entry)) return { valid: false, brokenAt: i };
131
+ const expectedPrev = i === 0 ? null : entries[i - 1].hash;
132
+ if (entry.prev !== expectedPrev) return { valid: false, brokenAt: i };
133
+ let recomputed;
134
+ try {
135
+ recomputed = hashEntry(entry);
136
+ } catch {
137
+ return { valid: false, brokenAt: i };
138
+ }
139
+ if (entry.hash !== recomputed) return { valid: false, brokenAt: i };
140
+ }
141
+ return { valid: true, brokenAt: -1 };
142
+ }
143
+
144
+ // Resolve an `id` to a single concrete `patch_id`. An **exact** full-id match
145
+ // always wins (never ambiguous, even when it is also a prefix of a longer id).
146
+ // Otherwise `id` is treated as a prefix: unique ⇒ that id, none ⇒ null, more
147
+ // than one distinct id ⇒ throw. Returns `{ patchId }` / `{ patchId: null }`.
148
+ function resolvePatchId(entries, id) {
149
+ const prefixIds = new Set();
150
+ let exact = false;
151
+ for (const e of entries) {
152
+ if (typeof e.patch_id !== "string") continue;
153
+ if (e.patch_id === id) exact = true;
154
+ if (e.patch_id.startsWith(id)) prefixIds.add(e.patch_id);
155
+ }
156
+ if (exact) return { patchId: id };
157
+ if (prefixIds.size > 1) {
158
+ throw new LedgerError(
159
+ `findRevertable: id prefix "${id}" is ambiguous (matches ${prefixIds.size} patches)`
160
+ );
161
+ }
162
+ if (prefixIds.size === 0) return { patchId: null };
163
+ return { patchId: [...prefixIds][0] };
164
+ }
165
+
166
+ /**
167
+ * Find the most recent `applied` entry for `id` that has not already been
168
+ * reverted — i.e. whose `hash` is not the `reverts` target of any later
169
+ * `reverted` entry. `id` is a full `sha256:…` or a unique prefix (an exact full
170
+ * id always resolves, even if it is also a prefix of a longer id).
171
+ *
172
+ * @returns {{ entry: object } | { entry: null, reason: "not-found" | "already-reverted" }}
173
+ * @throws {LedgerError} If the prefix matches more than one distinct patch id.
174
+ */
175
+ export function findRevertable(entries, id) {
176
+ if (!Array.isArray(entries)) {
177
+ throw new LedgerError("findRevertable: entries must be an array");
178
+ }
179
+ if (typeof id !== "string" || id.length === 0) {
180
+ throw new LedgerError("findRevertable: id must be a non-empty string");
181
+ }
182
+ const { patchId } = resolvePatchId(entries, id);
183
+ if (patchId === null) return { entry: null, reason: "not-found" };
184
+
185
+ // The set of applied-entry hashes already undone by a `reverted` entry.
186
+ const revertedHashes = new Set();
187
+ for (const e of entries) {
188
+ if (e.event === "reverted" && typeof e.reverts === "string") {
189
+ revertedHashes.add(e.reverts);
190
+ }
191
+ }
192
+
193
+ // Walk newest-first; return the latest un-reverted apply for this id.
194
+ for (let i = entries.length - 1; i >= 0; i--) {
195
+ const e = entries[i];
196
+ if (e.event !== "applied") continue;
197
+ if (e.patch_id !== patchId) continue;
198
+ if (revertedHashes.has(e.hash)) continue;
199
+ return { entry: e };
200
+ }
201
+ return { entry: null, reason: "already-reverted" };
202
+ }