@davesheffer/hunch 1.32.8 → 1.33.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 +9 -3
- package/dist/cli/index.js +2 -0
- package/dist/cli/serve.js +28 -2
- package/dist/cli/state.d.ts +3 -0
- package/dist/cli/state.js +150 -0
- package/dist/client/state.d.ts +82 -14
- package/dist/client/state.js +16 -2
- package/dist/client/stateProof.d.ts +4 -0
- package/dist/client/stateProof.js +17 -0
- package/dist/constitution/behaviorEvaluator.js +1 -1
- package/dist/constitution/schema.d.ts +2 -2
- package/dist/core/automaticReviewMemory.d.ts +5 -0
- package/dist/core/conventionDelivery.d.ts +8 -0
- package/dist/core/conventionDelivery.js +52 -0
- package/dist/core/fieldProvenance.d.ts +8 -0
- package/dist/core/fieldProvenance.js +72 -0
- package/dist/core/recordVisibility.d.ts +9 -0
- package/dist/core/recordVisibility.js +25 -0
- package/dist/core/stateCanonical.d.ts +3 -0
- package/dist/core/stateCanonical.js +34 -0
- package/dist/core/stateContract.d.ts +122 -7
- package/dist/core/stateContract.js +26 -31
- package/dist/core/stateDelivery.d.ts +3 -3
- package/dist/core/stateDelivery.js +10 -1
- package/dist/core/stateHttp.d.ts +280 -0
- package/dist/core/stateHttp.js +17 -0
- package/dist/core/stateProof.d.ts +13 -0
- package/dist/core/stateProof.js +34 -0
- package/dist/core/stateRecords.d.ts +127 -0
- package/dist/core/stateRecords.js +48 -0
- package/dist/core/types.d.ts +146 -4
- package/dist/core/types.js +8 -2
- package/dist/extractors/git.js +3 -10
- package/dist/mcp/server.js +10 -4
- package/dist/serve/app.d.ts +2 -0
- package/dist/serve/app.js +71 -30
- package/dist/serve/config.d.ts +16 -0
- package/dist/serve/config.js +27 -7
- package/dist/serve/operator.d.ts +4 -0
- package/dist/serve/operator.js +223 -0
- package/dist/serve/stateProof.d.ts +15 -0
- package/dist/serve/stateProof.js +105 -0
- package/dist/store/changeLedger.d.ts +6 -0
- package/dist/store/hunchStore.d.ts +4 -2
- package/dist/store/hunchStore.js +18 -19
- package/dist/store/stateAccess.d.ts +13 -0
- package/dist/store/stateAccess.js +85 -0
- package/dist/store/stateBinding.d.ts +13 -18
- package/dist/store/stateBinding.js +161 -52
- package/dist/store/stateCapture.js +10 -2
- package/dist/store/stateError.d.ts +12 -0
- package/dist/store/stateError.js +12 -0
- package/dist/store/statePartition.d.ts +9 -0
- package/dist/store/statePartition.js +30 -0
- package/package.json +5 -1
- package/server.json +2 -2
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import { conventionDelivery, conventionSupplements } from '../core/conventionDelivery.js';
|
|
2
|
+
import { StateRefusal } from "./stateError.js";
|
|
3
|
+
import { partitionOf, recordScope } from "./statePartition.js";
|
|
4
|
+
import { createStateAccess } from "./stateAccess.js";
|
|
5
|
+
import { visibilityAllows } from "../core/recordVisibility.js";
|
|
6
|
+
import { writeFileAtomic } from "../core/io.js";
|
|
1
7
|
/**
|
|
2
8
|
* nuryel.state/1 bound to the store — the ONE implementation of read / write / subscribe
|
|
3
9
|
* that every transport (MCP today; HTTP, CLI, typed client next) calls. Transport-free:
|
|
@@ -17,8 +23,7 @@
|
|
|
17
23
|
* dependencies, external-truth-stays-external (schema refinements), never-in-request-path
|
|
18
24
|
* (there is no proxy verb — this module never fetches anything).
|
|
19
25
|
*/
|
|
20
|
-
import {
|
|
21
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
22
27
|
import { z } from "zod";
|
|
23
28
|
import { appendChanges, latestSeqFor, readLedger } from "./changeLedger.js";
|
|
24
29
|
import { hunchPaths } from "../core/paths.js";
|
|
@@ -26,18 +31,9 @@ import { decisionId } from "../core/ids.js";
|
|
|
26
31
|
import { ENTITY_KINDS, SCHEMAS } from "../core/types.js";
|
|
27
32
|
import { captureConflicts, isLive } from "../core/topics.js";
|
|
28
33
|
import { buildDeliveryEnvelope } from "../core/delivery.js";
|
|
29
|
-
import { STATE_CAPABILITIES, STATE_CONTRACT_VERSION, STATE_FACETS, STATE_READ_VERSION, STATE_SUBSCRIBE_VERSION, STATE_WRITE_VERSION, ReadRequestSchema, ReadResponseSchema, WriteRequestSchema, WriteResultSchema, SubscribeRequestSchema, ChangeEventSchema, RecordsRequestSchema, RecordsResponseSchema, STATE_RECORDS_VERSION, ScopeSchema, scopePath, stateHash, actionReceiptId, commitmentId, derivedId, relationshipId, externalKey, subjectOfRef, assertReadWithinGrants, assertWriteWellFormed, assertDerivedState, isHumanConfirmed, } from "../core/stateContract.js";
|
|
34
|
+
import { STATE_CAPABILITIES, STATE_CONTRACT_VERSION, STATE_RECORD_VISIBILITY_VERSION, STATE_FACETS, STATE_READ_VERSION, STATE_SUBSCRIBE_VERSION, STATE_WRITE_VERSION, ReadRequestSchema, ReadResponseSchema, WriteRequestSchema, WriteResultSchema, SubscribeRequestSchema, ChangeEventSchema, RecordsRequestSchema, RecordsResponseSchema, STATE_RECORDS_VERSION, ScopeSchema, scopePath, stateHash, conventionId, actionReceiptId, commitmentId, derivedId, relationshipId, externalKey, subjectOfRef, assertReadWithinGrants, assertWriteWellFormed, assertDerivedState, isHumanConfirmed, } from "../core/stateContract.js";
|
|
30
35
|
/** A typed refusal. `code` is stable for bindings; `conflict` names the incumbent when one exists. */
|
|
31
|
-
export
|
|
32
|
-
code;
|
|
33
|
-
conflict;
|
|
34
|
-
constructor(code, message, conflict = null) {
|
|
35
|
-
super(message);
|
|
36
|
-
this.code = code;
|
|
37
|
-
this.conflict = conflict;
|
|
38
|
-
this.name = "StateRefusal";
|
|
39
|
-
}
|
|
40
|
-
}
|
|
36
|
+
export { StateRefusal } from "./stateError.js";
|
|
41
37
|
const LEGACY_FACETS = new Set(["decisions", "constraints", "bugs", "findings"]);
|
|
42
38
|
// Explicit classes, no `i` flag: the pattern must survive zod → JSON schema for MCP output validation.
|
|
43
39
|
const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,199}$/;
|
|
@@ -45,20 +41,7 @@ const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,199}$/;
|
|
|
45
41
|
* (`{ kind, id }`, committed with the store); a plain checkout is the repository partition
|
|
46
42
|
* named after its directory, sanitized to the contract's token grammar — stable per clone,
|
|
47
43
|
* discoverable through `capabilities`, and the scope every legacy record defaults to. */
|
|
48
|
-
export
|
|
49
|
-
const declared = join(hunchPaths(store.publicRoot).hunch, "partition.json");
|
|
50
|
-
if (existsSync(declared)) {
|
|
51
|
-
const parsed = ScopeSchema.safeParse(JSON.parse(readFileSync(declared, "utf8")));
|
|
52
|
-
if (!parsed.success)
|
|
53
|
-
throw new StateRefusal("unsupported", `${declared} does not declare a valid partition scope`);
|
|
54
|
-
return parsed.data;
|
|
55
|
-
}
|
|
56
|
-
const raw = basename(store.publicRoot).replace(/[^A-Za-z0-9._:@+-]/g, "-").replace(/^[^A-Za-z0-9]+/, "");
|
|
57
|
-
const id = TOKEN.test(raw) ? raw : "repository";
|
|
58
|
-
return { kind: "repository", id };
|
|
59
|
-
}
|
|
60
|
-
/** @deprecated name kept for callers written before served partitions; same value as partitionOf. */
|
|
61
|
-
export const repositoryScope = partitionOf;
|
|
44
|
+
export { partitionOf, repositoryScope } from "./statePartition.js";
|
|
62
45
|
export const SubscribeResponseSchema = z.object({
|
|
63
46
|
schema: z.literal(STATE_SUBSCRIBE_VERSION),
|
|
64
47
|
scope: ScopeSchema,
|
|
@@ -97,11 +80,6 @@ export function stateHomeFor(store, scope) {
|
|
|
97
80
|
}
|
|
98
81
|
return { home: "private", hunchDir: store.privateDir, isPrivate: true };
|
|
99
82
|
}
|
|
100
|
-
const recordScope = (record, repo) => {
|
|
101
|
-
const s = record.scope;
|
|
102
|
-
const parsed = ScopeSchema.safeParse(s);
|
|
103
|
-
return parsed.success ? parsed.data : repo;
|
|
104
|
-
};
|
|
105
83
|
// ---- read ----------------------------------------------------------------------------------
|
|
106
84
|
function refOf(facet, record, scope) {
|
|
107
85
|
return { facet, id: record.id, record_hash: stateHash(record), scope };
|
|
@@ -121,10 +99,10 @@ function survivorOf(byId, entity) {
|
|
|
121
99
|
}
|
|
122
100
|
/** The entities in the principal's grants that stand for an external key or an entity id — active
|
|
123
101
|
* ones directly, retired-and-merged ones through the survivor they name. */
|
|
124
|
-
function entityIndex(store, principal, repo) {
|
|
102
|
+
function entityIndex(store, principal, repo, canRead = () => true) {
|
|
125
103
|
const byId = new Map();
|
|
126
104
|
for (const e of store.recs("entities"))
|
|
127
|
-
if (granted(principal, recordScope(e, repo)))
|
|
105
|
+
if (granted(principal, recordScope(e, repo)) && canRead(e))
|
|
128
106
|
byId.set(e.id, e);
|
|
129
107
|
const survivor = (e) => survivorOf(byId, e);
|
|
130
108
|
const byKey = new Map();
|
|
@@ -144,9 +122,9 @@ function entityIndex(store, principal, repo) {
|
|
|
144
122
|
}
|
|
145
123
|
/** The names one subject is filed under: itself, the entity that stands for it (through merges),
|
|
146
124
|
* every entity merged into that one, and every key any of them carries. Explicit refs only. */
|
|
147
|
-
function subjectAliases(store, principal, repo, subject) {
|
|
125
|
+
function subjectAliases(store, principal, repo, subject, canRead) {
|
|
148
126
|
const aliases = new Set([subject]);
|
|
149
|
-
const { bySubject, byId, survivor } = entityIndex(store, principal, repo);
|
|
127
|
+
const { bySubject, byId, survivor } = entityIndex(store, principal, repo, canRead);
|
|
150
128
|
const named = bySubject.get(subject) ?? byId.get(subject);
|
|
151
129
|
if (!named)
|
|
152
130
|
return aliases;
|
|
@@ -165,7 +143,7 @@ function subjectAliases(store, principal, repo, subject) {
|
|
|
165
143
|
/** read — the system-of-record answer for a subject, under the delivery envelope's receipt.
|
|
166
144
|
* Grants are the first predicate on every candidate; a matching record in a scope the
|
|
167
145
|
* principal lacks is NAMED in denied_scopes and never described. */
|
|
168
|
-
export function readState(store, input) {
|
|
146
|
+
export function readState(store, input, options = {}) {
|
|
169
147
|
const request = ReadRequestSchema.parse(input);
|
|
170
148
|
if (!granted(request.principal, request.scope))
|
|
171
149
|
throw new StateRefusal("outside-grants", `scope ${scopePath(request.scope)} is outside the principal's grants`);
|
|
@@ -175,16 +153,30 @@ export function readState(store, input) {
|
|
|
175
153
|
const repo = partitionOf(store);
|
|
176
154
|
const facets = new Set(request.facets ?? STATE_FACETS);
|
|
177
155
|
const target = request.task ?? request.subject ?? scopePath(request.scope);
|
|
178
|
-
const
|
|
156
|
+
const access = createStateAccess(store, request.principal, options);
|
|
157
|
+
const conventions = facets.has('conventions') ? store.recs('conventions').filter(r => access.canRead(r) && scopePath(r.scope) === scopePath(request.scope)) : [];
|
|
158
|
+
const resolveConventionSource = (id, scope) => {
|
|
159
|
+
for (const source of [store, ...(options.additionalStores ?? [])]) {
|
|
160
|
+
const record = findRecord(source, id)?.record;
|
|
161
|
+
if (record && scopePath(recordScope(record, partitionOf(source))) === scopePath(scope) && access.canRead(record))
|
|
162
|
+
return record;
|
|
163
|
+
}
|
|
164
|
+
return undefined;
|
|
165
|
+
};
|
|
166
|
+
const conventionView = conventionDelivery(conventions, resolveConventionSource);
|
|
167
|
+
const ctx = store.assembleContext(target, request.budget_tokens ?? 1500, { canRead: access.canRead });
|
|
179
168
|
const envelope = buildDeliveryEnvelope(ctx, {
|
|
180
169
|
root: store.publicRoot,
|
|
181
|
-
symbols: store.recs("symbols"),
|
|
182
|
-
components: store.recs("components"),
|
|
183
|
-
decisionCorpus: store.recs("decisions"),
|
|
170
|
+
symbols: store.recs("symbols").filter(access.canRead),
|
|
171
|
+
components: store.recs("components").filter(access.canRead),
|
|
172
|
+
decisionCorpus: store.recs("decisions").filter(access.canRead),
|
|
184
173
|
profile: request.profile ?? "builder",
|
|
174
|
+
supplements: conventionSupplements(conventions, resolveConventionSource),
|
|
185
175
|
});
|
|
186
176
|
let stateOfRecord = null;
|
|
187
177
|
const records = {};
|
|
178
|
+
for (const item of conventionView?.items ?? [])
|
|
179
|
+
records[item.ref.id] = conventions.find(r => r.id === item.ref.id);
|
|
188
180
|
const denied = new Map();
|
|
189
181
|
// Union read against ONE store: every requested scope the principal lacks is named up front;
|
|
190
182
|
// the partitions actually read are declared so a caller never mistakes this for the union
|
|
@@ -197,13 +189,13 @@ export function readState(store, input) {
|
|
|
197
189
|
// Subject identity by external reference: a read for an external record's key (`event:26904`,
|
|
198
190
|
// `customer:Site:7`) also finds what is filed under the entity that carries that ref, and a
|
|
199
191
|
// read for the entity id finds what was filed under its keys — one explicit hop, grants first.
|
|
200
|
-
const aliases = subjectAliases(store, request.principal, repo, subject);
|
|
192
|
+
const aliases = subjectAliases(store, request.principal, repo, subject, access.canRead);
|
|
201
193
|
const isSubject = (s) => s !== undefined && aliases.has(s);
|
|
202
194
|
// One hop only. Do not broaden aliases: a linked observation does not merge subjects,
|
|
203
195
|
// bring unrelated facts, receipts or commitments, or traverse another relationship.
|
|
204
196
|
const linkedObservations = new Map();
|
|
205
197
|
for (const r of store.recs("relationships")) {
|
|
206
|
-
if (!granted(request.principal, r.scope) || scopePath(r.scope) !== scopePath(request.scope))
|
|
198
|
+
if (!access.canRead(r) || !granted(request.principal, r.scope) || scopePath(r.scope) !== scopePath(request.scope))
|
|
207
199
|
continue;
|
|
208
200
|
if (r.type !== "observation_about" || r.lifecycle === "retired" || !isSubject(r.to) || !r.observation_hash)
|
|
209
201
|
continue;
|
|
@@ -225,7 +217,7 @@ export function readState(store, input) {
|
|
|
225
217
|
denied.set(scopePath(scope), scope);
|
|
226
218
|
return null;
|
|
227
219
|
}
|
|
228
|
-
return scope;
|
|
220
|
+
return access.canRead(record) ? scope : null;
|
|
229
221
|
};
|
|
230
222
|
const keep = (facet, record, scope) => {
|
|
231
223
|
records[record.id] = record;
|
|
@@ -355,7 +347,8 @@ export function readState(store, input) {
|
|
|
355
347
|
scope: request.scope,
|
|
356
348
|
state_of_record: stateOfRecord,
|
|
357
349
|
denied_scopes: [...denied.values()],
|
|
358
|
-
...(
|
|
350
|
+
...(conventionView ? { conventions: conventionView } : {}),
|
|
351
|
+
...(stateOfRecord || conventionView ? { records } : {}),
|
|
359
352
|
...(request.scopes ? { scopes: [request.scope], receipts: [{ scope: request.scope, receipt_id: envelope.receipt_id }] } : {}),
|
|
360
353
|
});
|
|
361
354
|
assertReadWithinGrants(request.principal, response);
|
|
@@ -428,13 +421,31 @@ export function mergeReadResponses(primary, others, extraDenied = []) {
|
|
|
428
421
|
if (!(id in records))
|
|
429
422
|
records[id] = record;
|
|
430
423
|
}
|
|
424
|
+
// Recompute cross-scope conflicts without inventing an organization/team/user winner.
|
|
425
|
+
const conventionRecords = all.flatMap(r => (r.conventions?.items ?? []).map(item => r.records?.[item.ref.id])).filter((r) => !!r);
|
|
426
|
+
const deliveredConventions = all.flatMap(r => r.conventions?.items ?? []);
|
|
427
|
+
const conventionView = conventionDelivery(conventionRecords, undefined, new Map(deliveredConventions.map(item => [item.ref.id, item.currentness])));
|
|
428
|
+
if (conventionView) {
|
|
429
|
+
conventionView.truncated ||= all.some(r => r.conventions?.truncated);
|
|
430
|
+
for (const item of conventionView.items) {
|
|
431
|
+
const prior = deliveredConventions.find(x => x.ref.id === item.ref.id);
|
|
432
|
+
item.currentness = prior.currentness;
|
|
433
|
+
item.conflict ||= prior.conflict;
|
|
434
|
+
records[item.ref.id] = conventionRecords.find(r => r.id === item.ref.id);
|
|
435
|
+
}
|
|
436
|
+
const selected = new Set(conventionView.items.map(item => item.ref.id));
|
|
437
|
+
for (const record of conventionRecords)
|
|
438
|
+
if (!selected.has(String(record.id)))
|
|
439
|
+
delete records[String(record.id)];
|
|
440
|
+
}
|
|
431
441
|
return ReadResponseSchema.parse({
|
|
432
442
|
schema: STATE_READ_VERSION,
|
|
433
443
|
receipt_id: primary.receipt_id,
|
|
434
444
|
scope: primary.scope,
|
|
435
445
|
state_of_record: stateOfRecord,
|
|
436
446
|
denied_scopes: [...denied.values()],
|
|
437
|
-
...(
|
|
447
|
+
...(conventionView ? { conventions: conventionView } : {}),
|
|
448
|
+
...(stateOfRecord || conventionView ? { records } : {}),
|
|
438
449
|
scopes: [...scopes.values()],
|
|
439
450
|
receipts: [...receipts.values()],
|
|
440
451
|
});
|
|
@@ -443,6 +454,7 @@ export function mergeReadResponses(primary, others, extraDenied = []) {
|
|
|
443
454
|
function subjectOf(facet, record) {
|
|
444
455
|
const r = record;
|
|
445
456
|
switch (facet) {
|
|
457
|
+
case "conventions": return typeof r.key === "string" ? r.key : undefined;
|
|
446
458
|
case "commitments":
|
|
447
459
|
case "derived": return typeof r.subject === "string" ? r.subject : undefined;
|
|
448
460
|
case "entities": return typeof r.id === "string" ? r.id : undefined;
|
|
@@ -464,6 +476,7 @@ function facetOfId(id) {
|
|
|
464
476
|
case "con": return "constraints";
|
|
465
477
|
case "bug": return "bugs";
|
|
466
478
|
case "fnd": return "findings";
|
|
479
|
+
case "ncv": return "conventions";
|
|
467
480
|
case "nrc": return "receipts";
|
|
468
481
|
case "ncm": return "commitments";
|
|
469
482
|
case "nds": return "derived";
|
|
@@ -615,7 +628,9 @@ function normalizeRecord(facet, scope, raw, principal) {
|
|
|
615
628
|
}
|
|
616
629
|
let expectedId = null;
|
|
617
630
|
try {
|
|
618
|
-
if (facet === "
|
|
631
|
+
if (facet === "conventions")
|
|
632
|
+
expectedId = conventionId(record);
|
|
633
|
+
else if (facet === "receipts")
|
|
619
634
|
expectedId = actionReceiptId(record);
|
|
620
635
|
else if (facet === "commitments")
|
|
621
636
|
expectedId = commitmentId(record);
|
|
@@ -650,6 +665,25 @@ function normalizeRecord(facet, scope, raw, principal) {
|
|
|
650
665
|
/** write — provenance + idempotency in, durability out. A replay returns the original;
|
|
651
666
|
* a conflict names the incumbent; nothing is ever silently overwritten or duplicated. */
|
|
652
667
|
export function writeState(store, input, opts = {}) {
|
|
668
|
+
const request = WriteRequestSchema.parse(input);
|
|
669
|
+
if (!granted(request.principal, request.scope))
|
|
670
|
+
throw new StateRefusal("outside-grants", "scope is outside the principal grants");
|
|
671
|
+
const access = createStateAccess(store, request.principal, { ...opts, requireVisibility: request.record.visibility !== undefined });
|
|
672
|
+
try {
|
|
673
|
+
return writeStateAuthorized(store, request, opts, access);
|
|
674
|
+
}
|
|
675
|
+
catch (error) {
|
|
676
|
+
// Collision detection remains global, but inaccessible incumbents never become
|
|
677
|
+
// refusal details. Do not filter the collision corpus and permit duplicates.
|
|
678
|
+
if (error instanceof StateRefusal && error.conflict) {
|
|
679
|
+
const incumbent = findRecord(store, error.conflict.incumbent_id)?.record;
|
|
680
|
+
if (incumbent && !access.canRead(incumbent))
|
|
681
|
+
throw new StateRefusal('outside-grants', 'record unavailable or operation not permitted');
|
|
682
|
+
}
|
|
683
|
+
throw error;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
function writeStateAuthorized(store, input, opts, access) {
|
|
653
687
|
const request = WriteRequestSchema.parse(input);
|
|
654
688
|
try {
|
|
655
689
|
assertWriteWellFormed(request);
|
|
@@ -666,10 +700,50 @@ export function writeState(store, input, opts = {}) {
|
|
|
666
700
|
if (!ENTITY_KINDS.includes(facet))
|
|
667
701
|
throw new StateRefusal("unsupported", `facet ${facet} is not a store kind`);
|
|
668
702
|
const record = normalizeRecord(facet, request.scope, request.record, request.principal);
|
|
703
|
+
const deny = () => { throw new StateRefusal('outside-grants', 'record unavailable or operation not permitted'); };
|
|
704
|
+
const incumbent = getHere(String(record.id));
|
|
705
|
+
if (incumbent && !access.canWrite(incumbent))
|
|
706
|
+
deny();
|
|
707
|
+
const previousVisibility = incumbent?.visibility;
|
|
708
|
+
const nextVisibility = record.visibility;
|
|
709
|
+
if (nextVisibility && home === 'private')
|
|
710
|
+
throw new StateRefusal('unsupported', 'record visibility requires a dedicated partition home; shared/private overlays cannot safely gate every older reader');
|
|
711
|
+
const supersededRecord = request.supersedes ? findRecord(store, request.supersedes)?.record : undefined;
|
|
712
|
+
const supersededVisibility = supersededRecord?.visibility;
|
|
713
|
+
if (supersededRecord) {
|
|
714
|
+
if (!access.canWrite(supersededRecord))
|
|
715
|
+
deny();
|
|
716
|
+
if (stateHash(supersededVisibility ?? null) !== stateHash(nextVisibility ?? null)) {
|
|
717
|
+
if (supersededVisibility ? supersededVisibility.owner !== request.principal.id : request.principal.kind !== 'human')
|
|
718
|
+
deny();
|
|
719
|
+
const matches = typeof request.expected_version === 'number'
|
|
720
|
+
? latestSeqFor(readLedger(hunchDir, request.scope), String(supersededRecord.id)) === request.expected_version
|
|
721
|
+
: request.expected_version === stateHash(supersededRecord);
|
|
722
|
+
if (!matches)
|
|
723
|
+
throw new StateRefusal('malformed', 'changing visibility during supersession requires the predecessor expected_version');
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
if (!incumbent && !supersededRecord && nextVisibility?.owner !== undefined && nextVisibility.owner !== request.principal.id)
|
|
727
|
+
deny();
|
|
728
|
+
if (incumbent && stateHash(previousVisibility ?? null) !== stateHash(nextVisibility ?? null)) {
|
|
729
|
+
if (previousVisibility ? previousVisibility.owner !== request.principal.id : request.principal.kind !== 'human' || nextVisibility?.owner !== request.principal.id)
|
|
730
|
+
deny();
|
|
731
|
+
if (request.expected_version === null)
|
|
732
|
+
throw new StateRefusal('malformed', 'changing visibility requires an explicit expected_version');
|
|
733
|
+
}
|
|
734
|
+
if (!visibilityAllows(record, request.principal.id) || !access.referencesVisible(record) || (request.cause && !access.referencesVisible(request.cause)))
|
|
735
|
+
deny();
|
|
736
|
+
if (request.supersedes) {
|
|
737
|
+
const prior = findRecord(store, request.supersedes)?.record;
|
|
738
|
+
if (prior && !access.canWrite(prior))
|
|
739
|
+
deny();
|
|
740
|
+
}
|
|
669
741
|
let replayLink;
|
|
670
742
|
if (facet === "relationships" && record.type === "observation_about") {
|
|
671
743
|
const link = record;
|
|
672
744
|
const observation = store.getStateDirect("derived", link.from, home);
|
|
745
|
+
if (observation && !access.canRead(observation))
|
|
746
|
+
deny();
|
|
673
747
|
if (!observation || scopePath(observation.scope) !== scopePath(request.scope) || !granted(request.principal, observation.scope))
|
|
674
748
|
throw new StateRefusal("conflict", "observation is absent from the granted partition");
|
|
675
749
|
if (!observation.transform_version.startsWith("agent-capture/1:"))
|
|
@@ -706,6 +780,9 @@ export function writeState(store, input, opts = {}) {
|
|
|
706
780
|
// is a refusal, never a second record.
|
|
707
781
|
const seen = ledger.idempotency[request.idempotency_key];
|
|
708
782
|
if (seen) {
|
|
783
|
+
const prior = findRecord(store, seen.record_id)?.record;
|
|
784
|
+
if (prior && !access.canRead(prior))
|
|
785
|
+
deny();
|
|
709
786
|
if (seen.record_id === id && (seen.record_hash === hash || seen.payload_hash === hash))
|
|
710
787
|
return result("replayed");
|
|
711
788
|
// Say WHAT differs and what to do: a stable key with a varying payload (a timestamp, new
|
|
@@ -772,6 +849,24 @@ export function writeState(store, input, opts = {}) {
|
|
|
772
849
|
if (supersedes && supersedes !== id)
|
|
773
850
|
guard(store.getRec(facet, supersedes), "supersede");
|
|
774
851
|
}
|
|
852
|
+
if (facet === 'conventions') {
|
|
853
|
+
const convention = record;
|
|
854
|
+
if (existing && request.expected_version === null)
|
|
855
|
+
throw new StateRefusal('conflict', 'changing a convention requires expected_version');
|
|
856
|
+
if (supersedes) {
|
|
857
|
+
const prior = store.getRec('conventions', supersedes);
|
|
858
|
+
if (!prior || prior.key !== convention.key || scopePath(prior.scope) !== scopePath(convention.scope))
|
|
859
|
+
throw new StateRefusal('conflict', 'convention supersession must preserve scope and key');
|
|
860
|
+
if (request.expected_version !== stateHash(prior))
|
|
861
|
+
throw new StateRefusal('conflict', 'convention supersession requires the predecessor expected_version hash');
|
|
862
|
+
}
|
|
863
|
+
if (convention.status === 'accepted' && convention.valid_to === null) {
|
|
864
|
+
const incumbent = store.recsInHome('conventions', home).find(r => r.key === convention.key && scopePath(r.scope) === scopePath(convention.scope) && r.status === 'accepted' && r.valid_to === null && r.id !== id && r.id !== supersedes);
|
|
865
|
+
if (incumbent)
|
|
866
|
+
throw new StateRefusal('conflict', `${convention.key} already has an accepted convention; pass supersedes`, { incumbent_id: incumbent.id, reason: 'one accepted convention per scope and key' });
|
|
867
|
+
}
|
|
868
|
+
assertRestsOn(store, request.principal, request.scope, convention.sources);
|
|
869
|
+
}
|
|
775
870
|
// one-live-decision-per-topic — refuse with the incumbent named; supersession is explicit.
|
|
776
871
|
if (facet === "decisions") {
|
|
777
872
|
const d = record;
|
|
@@ -827,6 +922,12 @@ export function writeState(store, input, opts = {}) {
|
|
|
827
922
|
if (facet === "receipts")
|
|
828
923
|
assertRestsOn(store, request.principal, request.scope, record.rests_on ?? []);
|
|
829
924
|
const closedBy = facet === "commitments" ? assertClosedBy(store, request.principal, record) : null;
|
|
925
|
+
if (nextVisibility) {
|
|
926
|
+
// Publish the fail-closed old-reader gate BEFORE protected bytes. An interrupted
|
|
927
|
+
// write can leave a gate without a record, never a record without the gate.
|
|
928
|
+
const declaration = join(hunchPaths(store.publicRoot).hunch, 'partition.json');
|
|
929
|
+
writeFileAtomic(declaration, JSON.stringify({ ...partitionOf(store), required_capabilities: [STATE_RECORD_VISIBILITY_VERSION] }, null, 2) + '\n');
|
|
930
|
+
}
|
|
830
931
|
store.putCapture(facet, record, isPrivate);
|
|
831
932
|
/** What is on file now — the hash every event, ref and result carries. */
|
|
832
933
|
const onFileHash = stateHash(getHere(id) ?? record);
|
|
@@ -843,10 +944,10 @@ export function writeState(store, input, opts = {}) {
|
|
|
843
944
|
const closed = closeWindow(store, facet, supersedes, id, now, isPrivate);
|
|
844
945
|
if (closed) {
|
|
845
946
|
const old = store.getRec(facet, supersedes);
|
|
846
|
-
changes.push({ facet, record_id: supersedes, record_hash: stateHash(old), change: "superseded", subject: subjectOf(facet, old), invalidates: [], cause });
|
|
947
|
+
changes.push({ ...("visibility" in old && old.visibility ? { visibility: old.visibility } : {}), facet, record_id: supersedes, record_hash: stateHash(old), change: "superseded", subject: subjectOf(facet, old), invalidates: [], cause });
|
|
847
948
|
}
|
|
848
949
|
}
|
|
849
|
-
changes.push({ facet, record_id: id, record_hash: onFileHash, change: invalidated ? "invalidated" : retired ? "retired" : existing ? "updated" : "created", subject, invalidates: invalidated && subject ? [subject] : invalidates, cause });
|
|
950
|
+
changes.push({ ...(nextVisibility ? { visibility: nextVisibility } : {}), facet, record_id: id, record_hash: onFileHash, change: invalidated ? "invalidated" : retired ? "retired" : existing ? "updated" : "created", subject, invalidates: invalidated && subject ? [subject] : invalidates, cause });
|
|
850
951
|
appendChanges(hunchDir, request.scope, changes, { key: request.idempotency_key, entry: { record_id: id, record_hash: onFileHash, payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
|
|
851
952
|
if (!opts.deferReindex)
|
|
852
953
|
store.reindex();
|
|
@@ -855,7 +956,7 @@ export function writeState(store, input, opts = {}) {
|
|
|
855
956
|
// ---- subscribe -----------------------------------------------------------------------------
|
|
856
957
|
/** subscribe — the scope's ordered change stream after a cursor. Unfiltered, the events are
|
|
857
958
|
* contiguous and assertChangeSequence holds; filtered, `head_seq` is still the cursor. */
|
|
858
|
-
export function subscribeState(store, input) {
|
|
959
|
+
export function subscribeState(store, input, options = {}) {
|
|
859
960
|
const request = SubscribeRequestSchema.parse(input);
|
|
860
961
|
if (!granted(request.principal, request.scope))
|
|
861
962
|
throw new StateRefusal("outside-grants", `scope ${scopePath(request.scope)} is outside the principal's grants`);
|
|
@@ -863,10 +964,13 @@ export function subscribeState(store, input) {
|
|
|
863
964
|
const ledger = readLedger(hunchDir, request.scope);
|
|
864
965
|
const facets = request.facets ? new Set(request.facets) : null;
|
|
865
966
|
const subjects = request.subjects ? new Set(request.subjects) : null;
|
|
866
|
-
const
|
|
967
|
+
const access = createStateAccess(store, request.principal, options);
|
|
968
|
+
const visibilityFiltered = ledger.events.some(e => e.visibility !== undefined) || access.restricted;
|
|
969
|
+
const filtered = !!(facets || subjects || visibilityFiltered);
|
|
867
970
|
const resync = request.after_seq < ledger.floor_seq;
|
|
868
971
|
const after = resync ? ledger.floor_seq : request.after_seq;
|
|
869
972
|
const events = ledger.events.filter((e) => e.seq > after
|
|
973
|
+
&& (!visibilityFiltered || (visibilityAllows(e, request.principal.id) && access.canRead(findRecord(store, e.record_id)?.record) && access.referencesVisible(e)))
|
|
870
974
|
&& (!facets || facets.has(e.facet))
|
|
871
975
|
&& (!subjects || subjects.has(e.record_id) || (e.subject !== undefined && subjects.has(e.subject)) || e.invalidates.some((s) => subjects.has(s))));
|
|
872
976
|
return SubscribeResponseSchema.parse({ schema: STATE_SUBSCRIBE_VERSION, scope: request.scope, head_seq: ledger.head_seq, events, filtered, floor_seq: ledger.floor_seq, resync });
|
|
@@ -874,11 +978,12 @@ export function subscribeState(store, input) {
|
|
|
874
978
|
// ---- records ------------------------------------------------------------------------------
|
|
875
979
|
/** records — fetch by id, grants first. Every id is accounted for: found, denied (its scope is
|
|
876
980
|
* outside the grants — named, never described) or missing. */
|
|
877
|
-
export function recordsState(store, input) {
|
|
981
|
+
export function recordsState(store, input, options = {}) {
|
|
878
982
|
const request = RecordsRequestSchema.parse(input);
|
|
879
983
|
if (!granted(request.principal, request.scope))
|
|
880
984
|
throw new StateRefusal("outside-grants", `scope ${scopePath(request.scope)} is outside the principal's grants`);
|
|
881
985
|
const repo = partitionOf(store);
|
|
986
|
+
const access = createStateAccess(store, request.principal, options);
|
|
882
987
|
const records = {};
|
|
883
988
|
const facets = {};
|
|
884
989
|
const denied = [];
|
|
@@ -901,6 +1006,10 @@ export function recordsState(store, input) {
|
|
|
901
1006
|
denied.push(id);
|
|
902
1007
|
continue;
|
|
903
1008
|
}
|
|
1009
|
+
if (!access.canRead(found.record)) {
|
|
1010
|
+
missing.push(id);
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
904
1013
|
records[id] = found.record;
|
|
905
1014
|
facets[id] = found.facet;
|
|
906
1015
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createStateAccess } from "./stateAccess.js";
|
|
1
2
|
import { CaptureRequestSchema, CaptureBatchRequestSchema, STATE_CAPTURE_VERSION, STATE_CAPTURE_BATCH_VERSION, STATE_WRITE_VERSION, assertDerivedState, captureTransform, derivedId, normalizeAssertion, canonicalObjectKey, externalKey, scopePath, stateHash } from "../core/stateContract.js";
|
|
2
3
|
import { isCredentialFreeValue } from "../core/provenance.js";
|
|
3
4
|
import { StateRefusal, stateHomeFor, writeState } from "./stateBinding.js";
|
|
@@ -27,6 +28,10 @@ export function captureState(store, input, opts = {}) {
|
|
|
27
28
|
const id = derivedId({ scope: request.scope, subject: request.subject, transform_version: transform, dependencies: [] });
|
|
28
29
|
const incumbent = store.getStateDirect("derived", id, home);
|
|
29
30
|
if (incumbent) {
|
|
31
|
+
if (!createStateAccess(store, request.principal, opts).canRead(incumbent))
|
|
32
|
+
throw new StateRefusal("outside-grants", "record unavailable or operation not permitted");
|
|
33
|
+
if (request.visibility && stateHash(request.visibility) !== stateHash(incumbent.visibility ?? null))
|
|
34
|
+
throw new StateRefusal("conflict", "capture already exists with different visibility; use write with expected_version to change its audience");
|
|
30
35
|
assertDerivedState(incumbent);
|
|
31
36
|
if (incumbent.transform_version !== transform || scopePath(incumbent.scope) !== scopePath(request.scope) || incumbent.subject !== request.subject)
|
|
32
37
|
throw new StateRefusal("conflict", "capture identity collision; incumbent preserved");
|
|
@@ -38,6 +43,7 @@ export function captureState(store, input, opts = {}) {
|
|
|
38
43
|
const content = JSON.stringify({ schema: "nuryel.observation-content/1", statement, relevance: request.relevance,
|
|
39
44
|
evidence: evidence.map(e => ({ source: externalKey(e.ref), excerpt: e.excerpt })), captured_by: request.principal.id });
|
|
40
45
|
const record = {
|
|
46
|
+
...(request.visibility ? { visibility: request.visibility } : {}),
|
|
41
47
|
schema: "nuryel.derived/1", scope: request.scope, subject: request.subject, content, content_hash: stateHash(content),
|
|
42
48
|
dependencies: refs.map(ref => ({ kind: "external", ref })), transform_version: transform,
|
|
43
49
|
computed_at: (opts.now ?? (() => new Date()))().toISOString(), valid_to: null, state: "unknown",
|
|
@@ -65,6 +71,8 @@ export function captureBatchState(store, input, opts = {}) {
|
|
|
65
71
|
for (const [index, review] of (request.reviews ?? []).entries()) {
|
|
66
72
|
try {
|
|
67
73
|
const record = store.getStateDirect("derived", review.record_id, home);
|
|
74
|
+
if (record && !createStateAccess(store, request.principal, opts).canWrite(record))
|
|
75
|
+
throw new StateRefusal("outside-grants", "record unavailable or operation not permitted");
|
|
68
76
|
if (!record || scopePath(record.scope) !== scopePath(request.scope) || !record.transform_version.startsWith('agent-capture/1:'))
|
|
69
77
|
throw new StateRefusal('conflict', 'captured observation is absent from this partition');
|
|
70
78
|
if (!isCredentialFreeValue(review.reason))
|
|
@@ -95,7 +103,7 @@ export function captureBatchState(store, input, opts = {}) {
|
|
|
95
103
|
const at = (opts.now ?? (() => new Date()))().toISOString();
|
|
96
104
|
const result = writeState(store, { schema: STATE_WRITE_VERSION, principal: request.principal, scope: request.scope, facet: 'derived',
|
|
97
105
|
record: { ...record, state: 'stale', review: { by: request.principal.id, at, previous_hash: review.expected_hash, reason: review.reason, evidence } },
|
|
98
|
-
expected_version: review.expected_hash, idempotency_key: `observation-review:${identity}`, cause: { kind: 'external', ref: evidence[0].ref } }, { now: opts.now, ledgerCache, deferReindex: true });
|
|
106
|
+
expected_version: review.expected_hash, idempotency_key: `observation-review:${identity}`, cause: { kind: 'external', ref: evidence[0].ref } }, { now: opts.now, additionalStores: opts.additionalStores, ledgerCache, deferReindex: true });
|
|
99
107
|
changed ||= result.outcome !== 'replayed';
|
|
100
108
|
reviews.push({ index, status: 'saved', result });
|
|
101
109
|
}
|
|
@@ -113,7 +121,7 @@ export function captureBatchState(store, input, opts = {}) {
|
|
|
113
121
|
throw new StateRefusal("malformed", `evidence source index ${e.source} is absent`);
|
|
114
122
|
return { ...source, excerpt: e.excerpt };
|
|
115
123
|
});
|
|
116
|
-
const result = captureState(store, { schema: STATE_CAPTURE_VERSION, principal: request.principal, scope: request.scope, ...observation, evidence }, { now: opts.now, sourceHashes, ledgerCache, deferReindex: true });
|
|
124
|
+
const result = captureState(store, { schema: STATE_CAPTURE_VERSION, principal: request.principal, scope: request.scope, ...observation, evidence }, { now: opts.now, additionalStores: opts.additionalStores, sourceHashes, ledgerCache, deferReindex: true });
|
|
117
125
|
changed ||= result.outcome !== "replayed";
|
|
118
126
|
results.push({ index, status: "saved", result });
|
|
119
127
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Stable typed refusals shared by bindings and partition validation. */
|
|
2
|
+
export declare class StateRefusal extends Error {
|
|
3
|
+
readonly code: "outside-grants" | "unsupported" | "malformed" | "identity" | "conflict" | "no-partition-home" | "idempotency";
|
|
4
|
+
readonly conflict: {
|
|
5
|
+
incumbent_id: string;
|
|
6
|
+
reason: string;
|
|
7
|
+
} | null;
|
|
8
|
+
constructor(code: "outside-grants" | "unsupported" | "malformed" | "identity" | "conflict" | "no-partition-home" | "idempotency", message: string, conflict?: {
|
|
9
|
+
incumbent_id: string;
|
|
10
|
+
reason: string;
|
|
11
|
+
} | null);
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Stable typed refusals shared by bindings and partition validation. */
|
|
2
|
+
export class StateRefusal extends Error {
|
|
3
|
+
code;
|
|
4
|
+
conflict;
|
|
5
|
+
constructor(code, message, conflict = null) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.conflict = conflict;
|
|
9
|
+
this.name = "StateRefusal";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=stateError.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { HunchStore } from './hunchStore.js';
|
|
2
|
+
import { type Scope } from '../core/stateContract.js';
|
|
3
|
+
export declare function partitionDeclarationOf(store: HunchStore): Scope & {
|
|
4
|
+
required_capabilities?: string[];
|
|
5
|
+
};
|
|
6
|
+
export declare function partitionOf(store: HunchStore): Scope;
|
|
7
|
+
/** @deprecated name kept for callers written before served partitions; same value as partitionOf. */
|
|
8
|
+
export declare const repositoryScope: typeof partitionOf;
|
|
9
|
+
export declare const recordScope: (record: unknown, repo: Scope) => Scope;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { StateRefusal } from "./stateError.js";
|
|
2
|
+
/** Partition identity and upgrade gate, re-read for every state operation. */
|
|
3
|
+
import { basename, join } from 'node:path';
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { hunchPaths } from '../core/paths.js';
|
|
6
|
+
import { ScopeSchema, PartitionDeclarationSchema } from '../core/stateContract.js';
|
|
7
|
+
const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,199}$/;
|
|
8
|
+
export function partitionDeclarationOf(store) {
|
|
9
|
+
const declared = join(hunchPaths(store.publicRoot).hunch, "partition.json");
|
|
10
|
+
if (existsSync(declared)) {
|
|
11
|
+
const parsed = PartitionDeclarationSchema.safeParse(JSON.parse(readFileSync(declared, "utf8")));
|
|
12
|
+
if (!parsed.success)
|
|
13
|
+
throw new StateRefusal("unsupported", `${declared} does not declare a supported partition scope or capabilities`);
|
|
14
|
+
return parsed.data;
|
|
15
|
+
}
|
|
16
|
+
const raw = basename(store.publicRoot).replace(/[^A-Za-z0-9._:@+-]/g, "-").replace(/^[^A-Za-z0-9]+/, "");
|
|
17
|
+
const id = TOKEN.test(raw) ? raw : "repository";
|
|
18
|
+
return { kind: "repository", id };
|
|
19
|
+
}
|
|
20
|
+
export function partitionOf(store) {
|
|
21
|
+
const declaration = partitionDeclarationOf(store);
|
|
22
|
+
return { kind: declaration.kind, id: declaration.id };
|
|
23
|
+
}
|
|
24
|
+
/** @deprecated name kept for callers written before served partitions; same value as partitionOf. */
|
|
25
|
+
export const repositoryScope = partitionOf;
|
|
26
|
+
export const recordScope = (record, repo) => {
|
|
27
|
+
const parsed = ScopeSchema.safeParse(record.scope);
|
|
28
|
+
return parsed.success ? parsed.data : repo;
|
|
29
|
+
};
|
|
30
|
+
//# sourceMappingURL=statePartition.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davesheffer/hunch",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.33.0",
|
|
4
4
|
"mcpName": "io.github.davesheffer/hunch",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Dave Sheffer <dave.sheffer1@gmail.com>",
|
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
"default": "./dist/projectDna.js"
|
|
28
28
|
},
|
|
29
29
|
"./state": "./dist/client/state.js",
|
|
30
|
+
"./state-proof": {
|
|
31
|
+
"types": "./dist/client/stateProof.d.ts",
|
|
32
|
+
"default": "./dist/client/stateProof.js"
|
|
33
|
+
},
|
|
30
34
|
"./reports": {
|
|
31
35
|
"types": "./dist/taskReports.d.ts",
|
|
32
36
|
"default": "./dist/taskReports.js"
|
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.33.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.33.0",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|