@davesheffer/hunch 1.24.0 → 1.25.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/README.md +117 -133
- package/dist/core/provenance.js +43 -0
- package/dist/core/stateContract.js +250 -0
- package/dist/core/stateRecords.js +150 -0
- package/dist/core/types.js +16 -29
- package/dist/integrations/gitignore.js +8 -0
- package/dist/mcp/server.js +75 -0
- package/dist/store/changeLedger.js +96 -0
- package/dist/store/jsonStore.js +5 -0
- package/dist/store/stateBinding.js +385 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-scope change ledger behind `subscribe` — nuryel.ledger/1.
|
|
3
|
+
*
|
|
4
|
+
* One JSON file per scope partition under `<hunch dir>/changes/`, git-native like every
|
|
5
|
+
* other record, appended atomically (con_902759b3dc). It holds the strictly ordered
|
|
6
|
+
* ChangeEvent stream for that scope (seq 1, 2, 3 … with no gaps) plus the idempotency
|
|
7
|
+
* table the write verb replays from. Seq is per scope, assigned by the writer in the
|
|
8
|
+
* home the scope lives in; a scope has exactly ONE ledger, so there is never a second
|
|
9
|
+
* sequence to reconcile. Merging two clones' ledgers for the same scope is not decided
|
|
10
|
+
* here (see docs/nuryel-state-contract.md, "Not decided here").
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import { writeFileAtomic } from "../core/io.js";
|
|
17
|
+
import { ChangeEventSchema, ScopeSchema, scopePath } from "../core/stateContract.js";
|
|
18
|
+
export const LEDGER_SCHEMA_VERSION = "nuryel.ledger/1";
|
|
19
|
+
export const CHANGES_DIR = "changes";
|
|
20
|
+
const IdempotencyEntrySchema = z.object({
|
|
21
|
+
record_id: z.string().min(1),
|
|
22
|
+
record_hash: z.string(),
|
|
23
|
+
facet: z.string(),
|
|
24
|
+
seq: z.number().int().nonnegative(),
|
|
25
|
+
at: z.string(),
|
|
26
|
+
}).strict();
|
|
27
|
+
export const LedgerSchema = z.object({
|
|
28
|
+
schema: z.literal(LEDGER_SCHEMA_VERSION),
|
|
29
|
+
scope: ScopeSchema,
|
|
30
|
+
head_seq: z.number().int().nonnegative(),
|
|
31
|
+
events: z.array(ChangeEventSchema),
|
|
32
|
+
idempotency: z.record(z.string(), IdempotencyEntrySchema).default({}),
|
|
33
|
+
}).strict();
|
|
34
|
+
/** Scope ids may carry `:` `@` `+` (safe in the contract, not in every file system), so
|
|
35
|
+
* the file name is the sanitized id plus a short hash of the exact id — readable AND
|
|
36
|
+
* collision-free. The scope inside the file is authoritative, the name is a locator. */
|
|
37
|
+
export function ledgerFile(hunchDir, scope) {
|
|
38
|
+
const safe = scope.id.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 80);
|
|
39
|
+
const tag = createHash("sha256").update(scopePath(scope)).digest("hex").slice(0, 8);
|
|
40
|
+
return join(hunchDir, CHANGES_DIR, `${scope.kind}-${safe}-${tag}.json`);
|
|
41
|
+
}
|
|
42
|
+
export function emptyLedger(scope) {
|
|
43
|
+
return { schema: LEDGER_SCHEMA_VERSION, scope, head_seq: 0, events: [], idempotency: {} };
|
|
44
|
+
}
|
|
45
|
+
/** Read the ledger for a scope; a missing file is an empty ledger, a corrupt one is an
|
|
46
|
+
* error (never silently treated as empty — that would restart the sequence). */
|
|
47
|
+
export function readLedger(hunchDir, scope) {
|
|
48
|
+
const file = ledgerFile(hunchDir, scope);
|
|
49
|
+
if (!existsSync(file))
|
|
50
|
+
return emptyLedger(scope);
|
|
51
|
+
const raw = JSON.parse(readFileSync(file, "utf8"));
|
|
52
|
+
const ledger = LedgerSchema.parse(raw);
|
|
53
|
+
if (scopePath(ledger.scope) !== scopePath(scope))
|
|
54
|
+
throw new Error(`ledger ${file} belongs to scope ${scopePath(ledger.scope)}, not ${scopePath(scope)}`);
|
|
55
|
+
let expected = 1;
|
|
56
|
+
for (const event of ledger.events) {
|
|
57
|
+
if (event.seq !== expected)
|
|
58
|
+
throw new Error(`ledger ${file} is not contiguous at seq ${event.seq} (expected ${expected})`);
|
|
59
|
+
expected += 1;
|
|
60
|
+
}
|
|
61
|
+
if (ledger.head_seq !== ledger.events.length)
|
|
62
|
+
throw new Error(`ledger ${file} head_seq ${ledger.head_seq} disagrees with ${ledger.events.length} events`);
|
|
63
|
+
return ledger;
|
|
64
|
+
}
|
|
65
|
+
export function writeLedger(hunchDir, ledger) {
|
|
66
|
+
const file = ledgerFile(hunchDir, ledger.scope);
|
|
67
|
+
mkdirSync(join(hunchDir, CHANGES_DIR), { recursive: true });
|
|
68
|
+
writeFileAtomic(file, JSON.stringify(LedgerSchema.parse(ledger), null, 2) + "\n");
|
|
69
|
+
}
|
|
70
|
+
/** Append events (in order) and remember an idempotency key in ONE atomic write, so a
|
|
71
|
+
* crash between "record written" and "event appended" can be detected by the next
|
|
72
|
+
* writer (record present, ledger silent) rather than producing a half-applied write. */
|
|
73
|
+
export function appendChanges(hunchDir, scope, changes, idempotency, at = new Date().toISOString()) {
|
|
74
|
+
const ledger = readLedger(hunchDir, scope);
|
|
75
|
+
const appended = [];
|
|
76
|
+
for (const change of changes) {
|
|
77
|
+
const event = ChangeEventSchema.parse({ schema: "nuryel.state.subscribe/1", seq: ledger.head_seq + 1, at, scope, ...change });
|
|
78
|
+
ledger.events.push(event);
|
|
79
|
+
ledger.head_seq = event.seq;
|
|
80
|
+
appended.push(event);
|
|
81
|
+
}
|
|
82
|
+
if (idempotency) {
|
|
83
|
+
ledger.idempotency[idempotency.key] = { ...idempotency.entry, seq: ledger.head_seq, at };
|
|
84
|
+
}
|
|
85
|
+
writeLedger(hunchDir, ledger);
|
|
86
|
+
return appended;
|
|
87
|
+
}
|
|
88
|
+
/** The latest seq that touched a record in this scope, or 0 when the ledger never saw it. */
|
|
89
|
+
export function latestSeqFor(ledger, recordId) {
|
|
90
|
+
for (let i = ledger.events.length - 1; i >= 0; i--) {
|
|
91
|
+
if (ledger.events[i].record_id === recordId)
|
|
92
|
+
return ledger.events[i].seq;
|
|
93
|
+
}
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=changeLedger.js.map
|
package/dist/store/jsonStore.js
CHANGED
|
@@ -19,6 +19,11 @@ const SINGLE_FILE = {
|
|
|
19
19
|
// so the canonical array avoids lossy filename encoding while keeping Git diffs
|
|
20
20
|
// deterministic through id sorting.
|
|
21
21
|
resources: "index.json",
|
|
22
|
+
// nuryel.state/1 entities carry the same kind-qualified ids (customer:<name>), and
|
|
23
|
+
// relationships share edge identity; both are index-file stored for the same reason.
|
|
24
|
+
// Layout only: migration-before-validation (con_947c578b2c) is untouched.
|
|
25
|
+
entities: "index.json",
|
|
26
|
+
relationships: "index.json",
|
|
22
27
|
};
|
|
23
28
|
const encode = (v) => JSON.stringify(v, null, 2) + "\n";
|
|
24
29
|
// Sleep primitive for the single-file RMW lock's bounded spin (issue #35);
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nuryel.state/1 bound to the store — the ONE implementation of read / write / subscribe
|
|
3
|
+
* that every transport (MCP today; HTTP, CLI, typed client next) calls. Transport-free:
|
|
4
|
+
* takes a HunchStore and a validated request, returns a validated response, throws a
|
|
5
|
+
* StateRefusal for every typed refusal. No transport may re-implement any rule here.
|
|
6
|
+
*
|
|
7
|
+
* Homing follows the store's routing, decided by SCOPE, never by a flag:
|
|
8
|
+
* repository scope → the repository's capture home (public `.hunch/`, or the overlay in
|
|
9
|
+
* shared mode) — today's git-native store, unchanged;
|
|
10
|
+
* organization / team / user scopes → the overlay ONLY. They never ride a repository
|
|
11
|
+
* (privacy rule of the contract); without an overlay the write is refused.
|
|
12
|
+
* The change ledger for a scope lives in that same home, so a scope has one sequence.
|
|
13
|
+
*
|
|
14
|
+
* Invariants enforced here (exported from stateContract as assertions, not prose):
|
|
15
|
+
* authorization-before-retrieval (grant check is the FIRST predicate on every path),
|
|
16
|
+
* provenance-on-every-write, one-live-decision-per-topic, derived-state-carries-
|
|
17
|
+
* dependencies, external-truth-stays-external (schema refinements), never-in-request-path
|
|
18
|
+
* (there is no proxy verb — this module never fetches anything).
|
|
19
|
+
*/
|
|
20
|
+
import { basename } from "node:path";
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
import { appendChanges, latestSeqFor, readLedger } from "./changeLedger.js";
|
|
23
|
+
import { hunchPaths } from "../core/paths.js";
|
|
24
|
+
import { decisionId } from "../core/ids.js";
|
|
25
|
+
import { ENTITY_KINDS, SCHEMAS } from "../core/types.js";
|
|
26
|
+
import { captureConflicts, isLive } from "../core/topics.js";
|
|
27
|
+
import { buildDeliveryEnvelope } from "../core/delivery.js";
|
|
28
|
+
import { STATE_CAPABILITIES, STATE_CONTRACT_VERSION, STATE_FACETS, STATE_READ_VERSION, STATE_SUBSCRIBE_VERSION, STATE_WRITE_VERSION, ReadRequestSchema, ReadResponseSchema, WriteRequestSchema, WriteResultSchema, SubscribeRequestSchema, ChangeEventSchema, ScopeSchema, scopePath, stateHash, actionReceiptId, commitmentId, derivedId, assertReadWithinGrants, assertWriteWellFormed, assertDerivedState, } from "../core/stateContract.js";
|
|
29
|
+
/** A typed refusal. `code` is stable for bindings; `conflict` names the incumbent when one exists. */
|
|
30
|
+
export class StateRefusal extends Error {
|
|
31
|
+
code;
|
|
32
|
+
conflict;
|
|
33
|
+
constructor(code, message, conflict = null) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.code = code;
|
|
36
|
+
this.conflict = conflict;
|
|
37
|
+
this.name = "StateRefusal";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const LEGACY_FACETS = new Set(["decisions", "constraints", "bugs", "findings"]);
|
|
41
|
+
// Explicit classes, no `i` flag: the pattern must survive zod → JSON schema for MCP output validation.
|
|
42
|
+
const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,199}$/;
|
|
43
|
+
/** The repository partition this store serves. The id is the checkout's directory name,
|
|
44
|
+
* sanitized to the contract's token grammar — stable per clone, discoverable through
|
|
45
|
+
* `capabilities`, and the scope every legacy record defaults to. */
|
|
46
|
+
export function repositoryScope(store) {
|
|
47
|
+
const raw = basename(store.publicRoot).replace(/[^A-Za-z0-9._:@+-]/g, "-").replace(/^[^A-Za-z0-9]+/, "");
|
|
48
|
+
const id = TOKEN.test(raw) ? raw : "repository";
|
|
49
|
+
return { kind: "repository", id };
|
|
50
|
+
}
|
|
51
|
+
export const SubscribeResponseSchema = z.object({
|
|
52
|
+
schema: z.literal(STATE_SUBSCRIBE_VERSION),
|
|
53
|
+
scope: ScopeSchema,
|
|
54
|
+
/** The scope's newest seq — the caller's cursor for the next call, whatever filters applied. */
|
|
55
|
+
head_seq: z.number().int().nonnegative(),
|
|
56
|
+
events: z.array(ChangeEventSchema),
|
|
57
|
+
/** True when facet / subject filters were applied: `events` is then a subsequence and
|
|
58
|
+
* assertChangeSequence does not apply; `head_seq` remains the cursor. */
|
|
59
|
+
filtered: z.boolean(),
|
|
60
|
+
}).strict();
|
|
61
|
+
export function capabilities(store) {
|
|
62
|
+
const partitions = store.hasPrivate ? ["organization", "team", "user", "repository"] : ["repository"];
|
|
63
|
+
return { protocol: STATE_CONTRACT_VERSION, capabilities: [...STATE_CAPABILITIES], repository: repositoryScope(store), partitions };
|
|
64
|
+
}
|
|
65
|
+
// ---- homing --------------------------------------------------------------------------------
|
|
66
|
+
const granted = (principal, scope) => principal.grants.some((g) => scopePath(g) === scopePath(scope));
|
|
67
|
+
function homeFor(store, scope) {
|
|
68
|
+
const repo = repositoryScope(store);
|
|
69
|
+
if (scope.kind === "repository") {
|
|
70
|
+
if (scope.id !== repo.id)
|
|
71
|
+
throw new StateRefusal("unsupported", `this store serves repository ${repo.id}, not ${scope.id}`);
|
|
72
|
+
const home = store.captureHome(false);
|
|
73
|
+
return { home, hunchDir: home === "private" ? store.privateDir : hunchPaths(store.publicRoot).hunch, isPrivate: false };
|
|
74
|
+
}
|
|
75
|
+
if (!store.hasPrivate || !store.privateDir) {
|
|
76
|
+
throw new StateRefusal("no-partition-home", `${scope.kind} partitions never ride a repository; configure an overlay (hunch private / hunch shared) to hold ${scopePath(scope)}`);
|
|
77
|
+
}
|
|
78
|
+
return { home: "private", hunchDir: store.privateDir, isPrivate: true };
|
|
79
|
+
}
|
|
80
|
+
const recordScope = (record, repo) => {
|
|
81
|
+
const s = record.scope;
|
|
82
|
+
const parsed = ScopeSchema.safeParse(s);
|
|
83
|
+
return parsed.success ? parsed.data : repo;
|
|
84
|
+
};
|
|
85
|
+
// ---- read ----------------------------------------------------------------------------------
|
|
86
|
+
function refOf(facet, record, scope) {
|
|
87
|
+
return { facet, id: record.id, record_hash: stateHash(record), scope };
|
|
88
|
+
}
|
|
89
|
+
/** read — the system-of-record answer for a subject, under the delivery envelope's receipt.
|
|
90
|
+
* Grants are the first predicate on every candidate; a matching record in a scope the
|
|
91
|
+
* principal lacks is NAMED in denied_scopes and never described. */
|
|
92
|
+
export function readState(store, input) {
|
|
93
|
+
const request = ReadRequestSchema.parse(input);
|
|
94
|
+
if (!granted(request.principal, request.scope))
|
|
95
|
+
throw new StateRefusal("outside-grants", `scope ${scopePath(request.scope)} is outside the principal's grants`);
|
|
96
|
+
const repo = repositoryScope(store);
|
|
97
|
+
const facets = new Set(request.facets ?? STATE_FACETS);
|
|
98
|
+
const target = request.task ?? request.subject ?? scopePath(request.scope);
|
|
99
|
+
const ctx = store.assembleContext(target, request.budget_tokens ?? 1500);
|
|
100
|
+
const envelope = buildDeliveryEnvelope(ctx, {
|
|
101
|
+
root: store.publicRoot,
|
|
102
|
+
symbols: store.recs("symbols"),
|
|
103
|
+
components: store.recs("components"),
|
|
104
|
+
decisionCorpus: store.recs("decisions"),
|
|
105
|
+
profile: request.profile ?? "builder",
|
|
106
|
+
});
|
|
107
|
+
let stateOfRecord = null;
|
|
108
|
+
const denied = new Map();
|
|
109
|
+
if (request.subject !== undefined) {
|
|
110
|
+
const subject = request.subject;
|
|
111
|
+
const current = [];
|
|
112
|
+
const inForce = [];
|
|
113
|
+
const done = [];
|
|
114
|
+
const dependsOn = [];
|
|
115
|
+
const invalidatedBy = new Set();
|
|
116
|
+
/** authorization-before-retrieval: the grant check runs before the record is examined. */
|
|
117
|
+
const admit = (facet, record) => {
|
|
118
|
+
const scope = recordScope(record, repo);
|
|
119
|
+
if (!granted(request.principal, scope)) {
|
|
120
|
+
denied.set(scopePath(scope), scope);
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
return scope;
|
|
124
|
+
};
|
|
125
|
+
if (facets.has("decisions"))
|
|
126
|
+
for (const d of store.recs("decisions")) {
|
|
127
|
+
if (d.topic !== subject && d.id !== subject)
|
|
128
|
+
continue;
|
|
129
|
+
const scope = admit("decisions", d);
|
|
130
|
+
if (!scope)
|
|
131
|
+
continue;
|
|
132
|
+
if (isLive(d))
|
|
133
|
+
current.push(refOf("decisions", d, scope));
|
|
134
|
+
}
|
|
135
|
+
if (facets.has("constraints"))
|
|
136
|
+
for (const c of store.recs("constraints")) {
|
|
137
|
+
if (c.id !== subject && !c.scope.includes(subject))
|
|
138
|
+
continue;
|
|
139
|
+
const scope = admit("constraints", c);
|
|
140
|
+
if (!scope)
|
|
141
|
+
continue;
|
|
142
|
+
if (c.status === "active" && c.valid_to == null)
|
|
143
|
+
inForce.push(refOf("constraints", c, scope));
|
|
144
|
+
}
|
|
145
|
+
if (facets.has("receipts"))
|
|
146
|
+
for (const r of store.recs("receipts")) {
|
|
147
|
+
const targets = r.id === subject || r.invalidates.includes(subject) || `${r.target.object_type}:${r.target.object_key}` === subject;
|
|
148
|
+
if (!targets)
|
|
149
|
+
continue;
|
|
150
|
+
const scope = admit("receipts", r);
|
|
151
|
+
if (!scope)
|
|
152
|
+
continue;
|
|
153
|
+
if (r.state === "succeeded" || r.state === "verified")
|
|
154
|
+
done.push(refOf("receipts", r, scope));
|
|
155
|
+
if (r.invalidates.includes(subject))
|
|
156
|
+
invalidatedBy.add(r.id);
|
|
157
|
+
}
|
|
158
|
+
if (facets.has("commitments"))
|
|
159
|
+
for (const c of store.recs("commitments")) {
|
|
160
|
+
if (c.subject !== subject && c.id !== subject)
|
|
161
|
+
continue;
|
|
162
|
+
const scope = admit("commitments", c);
|
|
163
|
+
if (!scope)
|
|
164
|
+
continue;
|
|
165
|
+
if ((c.status === "open" || c.status === "waiting") && c.valid_to == null)
|
|
166
|
+
inForce.push(refOf("commitments", c, scope));
|
|
167
|
+
}
|
|
168
|
+
if (facets.has("derived"))
|
|
169
|
+
for (const d of store.recs("derived")) {
|
|
170
|
+
if (d.subject !== subject && d.id !== subject)
|
|
171
|
+
continue;
|
|
172
|
+
const scope = admit("derived", d);
|
|
173
|
+
if (!scope)
|
|
174
|
+
continue;
|
|
175
|
+
if (d.state === "current" && d.valid_to == null) {
|
|
176
|
+
current.push(refOf("derived", d, scope));
|
|
177
|
+
dependsOn.push(...d.dependencies);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (facets.has("entities"))
|
|
181
|
+
for (const e of store.recs("entities")) {
|
|
182
|
+
if (e.id !== subject)
|
|
183
|
+
continue;
|
|
184
|
+
const scope = admit("entities", e);
|
|
185
|
+
if (!scope)
|
|
186
|
+
continue;
|
|
187
|
+
if (e.lifecycle === "active")
|
|
188
|
+
current.push(refOf("entities", e, scope));
|
|
189
|
+
}
|
|
190
|
+
if (facets.has("relationships"))
|
|
191
|
+
for (const r of store.recs("relationships")) {
|
|
192
|
+
if (r.from !== subject && r.to !== subject)
|
|
193
|
+
continue;
|
|
194
|
+
const scope = admit("relationships", r);
|
|
195
|
+
if (!scope)
|
|
196
|
+
continue;
|
|
197
|
+
current.push(refOf("relationships", r, scope));
|
|
198
|
+
}
|
|
199
|
+
stateOfRecord = { subject, current, in_force: inForce, done, depends_on: dependsOn, invalidated_by: [...invalidatedBy].sort() };
|
|
200
|
+
}
|
|
201
|
+
const response = ReadResponseSchema.parse({
|
|
202
|
+
schema: STATE_READ_VERSION,
|
|
203
|
+
receipt_id: envelope.receipt_id,
|
|
204
|
+
scope: request.scope,
|
|
205
|
+
state_of_record: stateOfRecord,
|
|
206
|
+
denied_scopes: [...denied.values()],
|
|
207
|
+
});
|
|
208
|
+
assertReadWithinGrants(request.principal, response);
|
|
209
|
+
return { response, envelope };
|
|
210
|
+
}
|
|
211
|
+
/** What a record is ABOUT, for subscribers filtering by subject. Mirrors the read verb's matching. */
|
|
212
|
+
function subjectOf(facet, record) {
|
|
213
|
+
const r = record;
|
|
214
|
+
switch (facet) {
|
|
215
|
+
case "commitments":
|
|
216
|
+
case "derived": return typeof r.subject === "string" ? r.subject : undefined;
|
|
217
|
+
case "entities": return typeof r.id === "string" ? r.id : undefined;
|
|
218
|
+
case "relationships": return typeof r.from === "string" ? r.from : undefined;
|
|
219
|
+
case "receipts": {
|
|
220
|
+
const t = r.target;
|
|
221
|
+
return t?.object_type && t.object_key ? `${t.object_type}:${t.object_key}` : undefined;
|
|
222
|
+
}
|
|
223
|
+
case "decisions": return typeof r.topic === "string" ? r.topic : undefined;
|
|
224
|
+
default: return undefined;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/** Records the store can close a valid-time window on when superseded. */
|
|
228
|
+
function closeWindow(store, facet, incumbentId, byId, at, isPrivate) {
|
|
229
|
+
if (facet === "decisions") {
|
|
230
|
+
const by = store.getRec("decisions", byId);
|
|
231
|
+
if (!by)
|
|
232
|
+
return false;
|
|
233
|
+
return (isPrivate ? store.supersedePrivate(incumbentId, by) : store.supersede(incumbentId, by)) !== null;
|
|
234
|
+
}
|
|
235
|
+
const old = store.getRec(facet, incumbentId);
|
|
236
|
+
if (!old || !("valid_to" in old))
|
|
237
|
+
return false;
|
|
238
|
+
const closed = { ...old, valid_to: old.valid_to ?? at, ...(facet === "derived" ? { state: "stale" } : {}) };
|
|
239
|
+
store.putCapture(facet, closed, isPrivate);
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
/** Normalize + validate the record for its facet; enforce the identity rule (an id, when
|
|
243
|
+
* given, must be the one the record's facts derive). Returns the canonical record. */
|
|
244
|
+
function normalizeRecord(facet, scope, raw, principal) {
|
|
245
|
+
const record = { ...raw };
|
|
246
|
+
if (LEGACY_FACETS.has(facet)) {
|
|
247
|
+
// Legacy records carry no partition scope (a constraint's `scope` is its path globs);
|
|
248
|
+
// the repository scope is implied, so an echoed partition is dropped, not stored.
|
|
249
|
+
if (ScopeSchema.safeParse(record.scope).success)
|
|
250
|
+
delete record.scope;
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
record.scope = scope;
|
|
254
|
+
}
|
|
255
|
+
// Authorship tier (memory supply chain): only a human principal may sign as
|
|
256
|
+
// human_confirmed through this path; an agent's write is agent testimony.
|
|
257
|
+
const prov = record.provenance;
|
|
258
|
+
if (prov && typeof prov.source === "string" && principal.kind !== "human" && prov.source.split("+").includes("human_confirmed")) {
|
|
259
|
+
record.provenance = { ...prov, source: prov.source.split("+").map((t) => (t === "human_confirmed" ? "agent_recorded" : t)).join("+") };
|
|
260
|
+
}
|
|
261
|
+
let expectedId = null;
|
|
262
|
+
try {
|
|
263
|
+
if (facet === "receipts")
|
|
264
|
+
expectedId = actionReceiptId(record);
|
|
265
|
+
else if (facet === "commitments")
|
|
266
|
+
expectedId = commitmentId(record);
|
|
267
|
+
else if (facet === "derived")
|
|
268
|
+
expectedId = derivedId(record);
|
|
269
|
+
else if (facet === "decisions" && typeof record.id !== "string")
|
|
270
|
+
expectedId = decisionId(String(record.topic ?? record.title ?? ""));
|
|
271
|
+
}
|
|
272
|
+
catch (e) {
|
|
273
|
+
throw new StateRefusal("malformed", `cannot derive ${facet} identity: ${e.message}`);
|
|
274
|
+
}
|
|
275
|
+
if (expectedId) {
|
|
276
|
+
if (typeof record.id === "string" && record.id !== expectedId)
|
|
277
|
+
throw new StateRefusal("identity", `${facet} id ${record.id} is not the identity its facts derive (${expectedId}); ids are derived, never chosen`);
|
|
278
|
+
record.id = expectedId;
|
|
279
|
+
}
|
|
280
|
+
const parsed = SCHEMAS[facet].safeParse(record);
|
|
281
|
+
if (!parsed.success)
|
|
282
|
+
throw new StateRefusal("malformed", `${facet} record is malformed: ${parsed.error.issues.map((i) => `${i.path.join(".") || "record"}: ${i.message}`).join("; ")}`);
|
|
283
|
+
if (facet === "derived") {
|
|
284
|
+
try {
|
|
285
|
+
assertDerivedState(parsed.data);
|
|
286
|
+
}
|
|
287
|
+
catch (e) {
|
|
288
|
+
throw new StateRefusal("malformed", e.message);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return parsed.data;
|
|
292
|
+
}
|
|
293
|
+
/** write — provenance + idempotency in, durability out. A replay returns the original;
|
|
294
|
+
* a conflict names the incumbent; nothing is ever silently overwritten or duplicated. */
|
|
295
|
+
export function writeState(store, input, opts = {}) {
|
|
296
|
+
const request = WriteRequestSchema.parse(input);
|
|
297
|
+
try {
|
|
298
|
+
assertWriteWellFormed(request);
|
|
299
|
+
}
|
|
300
|
+
catch (e) {
|
|
301
|
+
throw new StateRefusal(/grants/.test(e.message) ? "outside-grants" : "malformed", e.message);
|
|
302
|
+
}
|
|
303
|
+
const { home, hunchDir, isPrivate } = homeFor(store, request.scope);
|
|
304
|
+
const now = (opts.now ?? (() => new Date()))().toISOString();
|
|
305
|
+
const facet = request.facet;
|
|
306
|
+
if (!ENTITY_KINDS.includes(facet))
|
|
307
|
+
throw new StateRefusal("unsupported", `facet ${facet} is not a store kind`);
|
|
308
|
+
const record = normalizeRecord(facet, request.scope, request.record, request.principal);
|
|
309
|
+
const id = record.id;
|
|
310
|
+
const hash = stateHash(record);
|
|
311
|
+
const ledger = readLedger(hunchDir, request.scope);
|
|
312
|
+
const durability = () => opts.flush?.(isPrivate, `nuryel: write ${id}`) ?? "local";
|
|
313
|
+
const result = (outcome, conflict = null, rid = id, rhash = hash) => WriteResultSchema.parse({ schema: STATE_WRITE_VERSION, record_id: rid, record_hash: rhash, durability: durability(), outcome, conflict });
|
|
314
|
+
// Idempotency: the same key replays the original; the same key with a different payload
|
|
315
|
+
// is a refusal, never a second record.
|
|
316
|
+
const seen = ledger.idempotency[request.idempotency_key];
|
|
317
|
+
if (seen) {
|
|
318
|
+
if (seen.record_hash === hash && seen.record_id === id)
|
|
319
|
+
return result("replayed");
|
|
320
|
+
throw new StateRefusal("idempotency", `idempotency key was already used for ${seen.record_id} with a different payload`, { incumbent_id: seen.record_id, reason: "idempotency key reused with a different payload" });
|
|
321
|
+
}
|
|
322
|
+
const existing = store.recsInHome(facet, home).find((r) => r.id === id);
|
|
323
|
+
if (existing && stateHash(existing) === hash) {
|
|
324
|
+
appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: id, record_hash: hash, facet } }, now);
|
|
325
|
+
return result("replayed");
|
|
326
|
+
}
|
|
327
|
+
if (existing && request.expected_version !== null) {
|
|
328
|
+
const ok = typeof request.expected_version === "number"
|
|
329
|
+
? latestSeqFor(ledger, id) === request.expected_version
|
|
330
|
+
: stateHash(existing) === request.expected_version;
|
|
331
|
+
if (!ok)
|
|
332
|
+
throw new StateRefusal("conflict", `expected_version does not match the incumbent ${id}`, { incumbent_id: id, reason: "expected_version mismatch" });
|
|
333
|
+
}
|
|
334
|
+
// one-live-decision-per-topic — refuse with the incumbent named; supersession is explicit.
|
|
335
|
+
let supersedes = request.supersedes ?? null;
|
|
336
|
+
if (facet === "decisions") {
|
|
337
|
+
const d = record;
|
|
338
|
+
if (d.topic && d.status === "accepted") {
|
|
339
|
+
const willClose = supersedes && store.recsInHome("decisions", home).some((x) => x.id === supersedes) ? supersedes : null;
|
|
340
|
+
const conflicts = captureConflicts(store.recsInHome("decisions", home), d.topic, d.id, willClose);
|
|
341
|
+
if (conflicts.length) {
|
|
342
|
+
throw new StateRefusal("conflict", `topic ${d.topic} already has a live decision ${conflicts[0].id}; pass supersedes to replace it`, { incumbent_id: conflicts[0].id, reason: "one-live-decision-per-topic" });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (supersedes && !store.recsInHome(facet, home).some((r) => r.id === supersedes)) {
|
|
347
|
+
throw new StateRefusal("conflict", `supersedes ${supersedes} is not a ${facet} record in this partition`, { incumbent_id: supersedes, reason: "supersede target absent" });
|
|
348
|
+
}
|
|
349
|
+
if (supersedes === id)
|
|
350
|
+
supersedes = null;
|
|
351
|
+
store.putCapture(facet, record, isPrivate);
|
|
352
|
+
const changes = [];
|
|
353
|
+
const cause = { kind: "write", principal: request.principal.id };
|
|
354
|
+
const invalidates = facet === "receipts" ? record.invalidates : [];
|
|
355
|
+
const subject = subjectOf(facet, record);
|
|
356
|
+
if (supersedes) {
|
|
357
|
+
const closed = closeWindow(store, facet, supersedes, id, now, isPrivate);
|
|
358
|
+
if (closed) {
|
|
359
|
+
const old = store.getRec(facet, supersedes);
|
|
360
|
+
changes.push({ facet, record_id: supersedes, record_hash: stateHash(old), change: "superseded", subject: subjectOf(facet, old), invalidates: [], cause });
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
changes.push({ facet, record_id: id, record_hash: hash, change: existing ? "updated" : "created", subject, invalidates, cause });
|
|
364
|
+
appendChanges(hunchDir, request.scope, changes, { key: request.idempotency_key, entry: { record_id: id, record_hash: hash, facet } }, now);
|
|
365
|
+
store.reindex();
|
|
366
|
+
return result(supersedes ? "superseded" : existing ? "updated" : "created");
|
|
367
|
+
}
|
|
368
|
+
// ---- subscribe -----------------------------------------------------------------------------
|
|
369
|
+
/** subscribe — the scope's ordered change stream after a cursor. Unfiltered, the events are
|
|
370
|
+
* contiguous and assertChangeSequence holds; filtered, `head_seq` is still the cursor. */
|
|
371
|
+
export function subscribeState(store, input) {
|
|
372
|
+
const request = SubscribeRequestSchema.parse(input);
|
|
373
|
+
if (!granted(request.principal, request.scope))
|
|
374
|
+
throw new StateRefusal("outside-grants", `scope ${scopePath(request.scope)} is outside the principal's grants`);
|
|
375
|
+
const { hunchDir } = homeFor(store, request.scope);
|
|
376
|
+
const ledger = readLedger(hunchDir, request.scope);
|
|
377
|
+
const facets = request.facets ? new Set(request.facets) : null;
|
|
378
|
+
const subjects = request.subjects ? new Set(request.subjects) : null;
|
|
379
|
+
const filtered = !!(facets || subjects);
|
|
380
|
+
const events = ledger.events.filter((e) => e.seq > request.after_seq
|
|
381
|
+
&& (!facets || facets.has(e.facet))
|
|
382
|
+
&& (!subjects || subjects.has(e.record_id) || (e.subject !== undefined && subjects.has(e.subject)) || e.invalidates.some((s) => subjects.has(s))));
|
|
383
|
+
return SubscribeResponseSchema.parse({ schema: STATE_SUBSCRIBE_VERSION, scope: request.scope, head_seq: ledger.head_seq, events, filtered });
|
|
384
|
+
}
|
|
385
|
+
//# sourceMappingURL=stateBinding.js.map
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
9
|
"websiteUrl": "https://www.hunchmemory.com",
|
|
10
|
-
"version": "1.
|
|
10
|
+
"version": "1.25.0",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
15
15
|
"identifier": "@davesheffer/hunch",
|
|
16
|
-
"version": "1.
|
|
16
|
+
"version": "1.25.0",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|