@davesheffer/hunch 1.31.0 → 1.31.1
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 +2 -6
- package/dist/client/state.js +2 -0
- package/dist/core/stateContract.js +87 -4
- package/dist/core/stateRecords.js +16 -1
- package/dist/mcp/server.js +40 -3
- package/dist/serve/app.js +20 -0
- package/dist/store/changeLedger.js +40 -9
- package/dist/store/hunchStore.js +7 -2
- package/dist/store/jsonStore.js +15 -0
- package/dist/store/stateBinding.js +110 -15
- package/dist/store/stateCapture.js +145 -0
- package/package.json +1 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -207,11 +207,7 @@ Hunch Memory service into Hunch.
|
|
|
207
207
|
|
|
208
208
|
As of 1.27.0 a fourth verb, `records`, lists a subject's records for the first writers, and the per-scope ledger compacts and merges across clones. As of 1.28.0 reads are a union across writers, a supersede target must still be open (two racing writers can no longer leave two current records), state records are searchable and delivered by subject, and subjects are keyed by the external record rather than by the agent. Proven on an emulated organization: three agents over ten clinics and a generated year of mail, chat and CRM, one organization drawer, 96 cited summaries, 24 verified receipts, 24 commitments, zero contradictions.
|
|
209
209
|
|
|
210
|
-
|
|
211
|
-
As of 1.30.0 subject identity is by external reference: one active entity per external record per partition, a subject written as an entity's external key refused with the entity id named, reads resolving one explicit hop — so two agents over one CRM record land on one subject. Replay determinism is a check, not a claim: `hunch serve replay --partition <kind:id>` (or `--root <dir>`) folds a partition's ledger into the state it implies and compares it hash for hash to the records on file, exits 1 on any divergence, and runs on every agent-farm run; and a human correction outranks later agent writes — a record a human confirmed is never overwritten or superseded by an agent or service principal (replay, stale-with-cause and closure by receipt are the only agent moves, each keeping the human's provenance).
|
|
212
|
-
=======
|
|
213
|
-
As of 1.30.0 replay determinism is a check, not a claim: `hunch serve replay --partition <kind:id>` (or `--root <dir>`) folds a partition's ledger into the state it implies and compares it hash for hash to the records on file, exits 1 on any divergence, runs inside `hunch drift` when the partition has a ledger, and runs on every agent-farm run; and a human correction outranks later agent writes — a record a human confirmed is never overwritten or superseded by an agent or service principal (replay, stale-with-cause and closure by receipt are the only agent moves, each keeping the human's provenance).
|
|
214
|
-
>>>>>>> feat/replay-determinism
|
|
210
|
+
As of 1.30.0 subject identity is by external reference: one active entity per external record per partition, a subject written as an entity's external key refused with the entity id named, reads resolving one explicit hop — so two agents over one CRM record land on one subject. Replay determinism is a check, not a claim: `hunch serve replay --partition <kind:id>` (or `--root <dir>`) folds a partition's ledger into the state it implies and compares it hash for hash to the records on file, exits 1 on any divergence, runs inside `hunch drift` when the partition has a ledger, and runs on every agent-farm run; and a human correction outranks later agent writes — a record a human confirmed is never overwritten or superseded by an agent or service principal (replay, stale-with-cause and closure by receipt are the only agent moves, each keeping the human's provenance).
|
|
215
211
|
|
|
216
212
|
Read [Deterministic organizational state](docs/deterministic-state.md), the [roadmap](ROADMAP.md) and the dated [competitive landscape](docs/competitive-landscape.md).
|
|
217
213
|
|
|
@@ -286,4 +282,4 @@ See the [changelog](CHANGELOG.md) for release detail and the [roadmap](ROADMAP.m
|
|
|
286
282
|
- [Architecture benchmark](bench/architectural-conformance.md)
|
|
287
283
|
- [Contributing](CONTRIBUTING.md)
|
|
288
284
|
|
|
289
|
-
Apache-2.0
|
|
285
|
+
Apache-2.0
|
package/dist/client/state.js
CHANGED
|
@@ -41,6 +41,8 @@ export function createStateClient(opts) {
|
|
|
41
41
|
capabilities: (scope) => call("GET", `/nuryel/v1/capabilities${scope ? `?scope=${encodeURIComponent(`${scope.kind}:${scope.id}`)}` : ""}`),
|
|
42
42
|
read: (request) => call("POST", "/nuryel/v1/read", request),
|
|
43
43
|
write: (request) => call("POST", "/nuryel/v1/write", request),
|
|
44
|
+
capture: (request) => call("POST", "/nuryel/v1/capture", request),
|
|
45
|
+
captureBatch: (request) => call("POST", "/nuryel/v1/capture-batch", request),
|
|
44
46
|
subscribe: (request) => call("POST", "/nuryel/v1/subscribe", request),
|
|
45
47
|
records: (request) => call("POST", "/nuryel/v1/records", request),
|
|
46
48
|
health: () => call("GET", "/nuryel/v1/health"),
|
|
@@ -29,17 +29,22 @@ import { z } from "zod";
|
|
|
29
29
|
import { compareCodeUnits } from "./canonicalOrder.js";
|
|
30
30
|
import { DELIVERY_PROFILES } from "./delivery.js";
|
|
31
31
|
import { isHumanConfirmed as sourceIsHumanConfirmed } from "./strictgate.js";
|
|
32
|
-
import { ScopeSchema, scopePath, DependencyRefSchema, ExternalRefSchema, RECEIPT_SCHEMA_VERSION, COMMITMENT_SCHEMA_VERSION, DERIVED_SCHEMA_VERSION, ENTITY_SCHEMA_VERSION, RELATIONSHIP_SCHEMA_VERSION, } from "./stateRecords.js";
|
|
32
|
+
import { ScopeSchema, scopePath, externalKey, DependencyRefSchema, ExternalRefSchema, RECEIPT_SCHEMA_VERSION, COMMITMENT_SCHEMA_VERSION, DERIVED_SCHEMA_VERSION, ENTITY_SCHEMA_VERSION, RELATIONSHIP_SCHEMA_VERSION, } from "./stateRecords.js";
|
|
33
33
|
export * from "./stateRecords.js";
|
|
34
34
|
export const STATE_CONTRACT_VERSION = "nuryel.state/1";
|
|
35
35
|
export const STATE_READ_VERSION = "nuryel.state.read/1";
|
|
36
36
|
export const STATE_WRITE_VERSION = "nuryel.state.write/1";
|
|
37
37
|
export const STATE_SUBSCRIBE_VERSION = "nuryel.state.subscribe/1";
|
|
38
38
|
export const STATE_RECORDS_VERSION = "nuryel.state.records/1";
|
|
39
|
+
export const STATE_CAPTURE_VERSION = "nuryel.state.capture/1";
|
|
40
|
+
export const STATE_CAPTURE_BATCH_VERSION = "nuryel.state.capture-batch/1";
|
|
41
|
+
export const STATE_OBSERVATION_LINKS_VERSION = "nuryel.observation-links/1";
|
|
42
|
+
export const STATE_OBSERVATION_REVIEW_VERSION = "nuryel.observation-review/1";
|
|
43
|
+
export const STATE_OBSERVATION_PAGES_VERSION = "nuryel.observation-pages/1";
|
|
39
44
|
/** Capabilities a server advertises; a client that needs one the server lacks gets a typed
|
|
40
45
|
* `unsupported`, never a compatible-looking degraded answer. */
|
|
41
46
|
export const STATE_CAPABILITIES = [
|
|
42
|
-
STATE_READ_VERSION, STATE_WRITE_VERSION, STATE_SUBSCRIBE_VERSION, STATE_RECORDS_VERSION,
|
|
47
|
+
STATE_READ_VERSION, STATE_WRITE_VERSION, STATE_SUBSCRIBE_VERSION, STATE_RECORDS_VERSION, STATE_CAPTURE_VERSION, STATE_CAPTURE_BATCH_VERSION, STATE_OBSERVATION_LINKS_VERSION, STATE_OBSERVATION_REVIEW_VERSION, STATE_OBSERVATION_PAGES_VERSION,
|
|
43
48
|
RECEIPT_SCHEMA_VERSION, COMMITMENT_SCHEMA_VERSION, DERIVED_SCHEMA_VERSION, ENTITY_SCHEMA_VERSION, RELATIONSHIP_SCHEMA_VERSION,
|
|
44
49
|
];
|
|
45
50
|
const SHA256 = /^sha256:[a-f0-9]{64}$/;
|
|
@@ -55,12 +60,52 @@ export const PrincipalSchema = z.object({
|
|
|
55
60
|
display: z.string().max(256).optional(),
|
|
56
61
|
grants: z.array(ScopeSchema).min(1).max(64),
|
|
57
62
|
}).strict();
|
|
63
|
+
/** One relevant assertion, never a whole conversation. Source text is transient input:
|
|
64
|
+
* only its exact supporting excerpt and a hashed external pointer may reach the store. */
|
|
65
|
+
export const CaptureRequestSchema = z.object({
|
|
66
|
+
schema: z.literal(STATE_CAPTURE_VERSION),
|
|
67
|
+
principal: PrincipalSchema,
|
|
68
|
+
scope: ScopeSchema,
|
|
69
|
+
subject: z.string().min(1).max(512),
|
|
70
|
+
statement: z.string().trim().min(1).max(1200),
|
|
71
|
+
relevance: z.object({
|
|
72
|
+
use: z.enum(["decision", "constraint", "preference", "operational_fact", "ongoing_issue"]),
|
|
73
|
+
reason: z.string().trim().min(1).max(600),
|
|
74
|
+
}).strict(),
|
|
75
|
+
evidence: z.array(z.object({
|
|
76
|
+
ref: ExternalRefSchema,
|
|
77
|
+
source_text: z.string().min(1).max(64000),
|
|
78
|
+
excerpt: z.string().trim().min(1).max(1200),
|
|
79
|
+
}).strict()).min(1).max(8),
|
|
80
|
+
}).strict();
|
|
81
|
+
/** Sources cross the transport once; assertions name only their supporting excerpts. */
|
|
82
|
+
export const CaptureBatchRequestSchema = z.object({
|
|
83
|
+
schema: z.literal(STATE_CAPTURE_BATCH_VERSION), principal: PrincipalSchema, scope: ScopeSchema,
|
|
84
|
+
sources: z.array(CaptureRequestSchema.shape.evidence.element.omit({ excerpt: true })).min(1).max(8),
|
|
85
|
+
observations: z.array(CaptureRequestSchema.pick({ subject: true, statement: true, relevance: true }).extend({
|
|
86
|
+
evidence: z.array(z.object({ source: z.number().int().min(0).max(7), excerpt: z.string().trim().min(1).max(1200) }).strict()).min(1).max(8),
|
|
87
|
+
})).min(0).max(32),
|
|
88
|
+
reviews: z.array(z.object({
|
|
89
|
+
record_id: z.string().regex(/^nds_[a-f0-9]{24}$/), expected_hash: z.string().regex(SHA256),
|
|
90
|
+
reason: z.string().trim().min(1).max(600),
|
|
91
|
+
evidence: z.array(z.object({ source: z.number().int().min(0).max(7), excerpt: z.string().trim().min(1).max(1200) }).strict()).min(1).max(8),
|
|
92
|
+
}).strict()).min(1).max(32).optional(),
|
|
93
|
+
}).strict();
|
|
94
|
+
export const CAPTURE_TRANSFORM = "agent-capture/1:";
|
|
95
|
+
export const normalizeAssertion = (text) => text.normalize("NFC").replace(/\r\n?/g, "\n").trim();
|
|
96
|
+
export function captureTransform(scope, subject, statement, evidence) {
|
|
97
|
+
const identities = [...new Set(evidence.map(e => stateHash({ source: e.source, excerpt: normalizeAssertion(e.excerpt) })))].sort();
|
|
98
|
+
return CAPTURE_TRANSFORM + stateHash({ scope, subject, statement: normalizeAssertion(statement), evidence: identities }).slice(7);
|
|
99
|
+
}
|
|
58
100
|
export const STATE_FACETS = ["decisions", "constraints", "bugs", "findings", "receipts", "commitments", "derived", "entities", "relationships"];
|
|
59
101
|
// ---- verbs ------------------------------------------------------------------------------
|
|
60
102
|
/** Union read: the partitions a principal wants in ONE answer. `scope` stays required (it is the
|
|
61
103
|
* primary partition; its envelope and receipt lead the response). An entry the principal is not
|
|
62
104
|
* granted is NAMED in `denied_scopes` — it never refuses the whole call, and is never described. */
|
|
63
105
|
export const ReadScopesSchema = z.array(ScopeSchema).min(1).max(64);
|
|
106
|
+
export const ObservationCursorSchema = z.object({
|
|
107
|
+
snapshot_hash: z.string().regex(SHA256), offset: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
|
|
108
|
+
}).strict();
|
|
64
109
|
export const ReadRequestSchema = z.object({
|
|
65
110
|
schema: z.literal(STATE_READ_VERSION),
|
|
66
111
|
principal: PrincipalSchema,
|
|
@@ -71,6 +116,8 @@ export const ReadRequestSchema = z.object({
|
|
|
71
116
|
profile: z.enum(DELIVERY_PROFILES).optional(),
|
|
72
117
|
budget_tokens: z.number().int().min(200).max(200_000).optional(),
|
|
73
118
|
facets: z.array(z.enum(STATE_FACETS)).max(STATE_FACETS.length).optional(),
|
|
119
|
+
/** Explicit single-partition pagination; default subject reads keep their existing bound. */
|
|
120
|
+
observed_page: z.object({ cursor: ObservationCursorSchema.optional() }).strict().optional(),
|
|
74
121
|
}).strict();
|
|
75
122
|
const StateRefSchema = z.object({
|
|
76
123
|
facet: z.enum(STATE_FACETS),
|
|
@@ -85,6 +132,11 @@ export const StateOfRecordSchema = z.object({
|
|
|
85
132
|
current: z.array(StateRefSchema).max(256),
|
|
86
133
|
in_force: z.array(StateRefSchema).max(256),
|
|
87
134
|
done: z.array(StateRefSchema).max(256),
|
|
135
|
+
/** Source-backed observations, not a claim of currentness. Additive; absent on old hosts. */
|
|
136
|
+
observed: z.array(StateRefSchema).max(64).optional(),
|
|
137
|
+
observed_truncated: z.boolean().optional(),
|
|
138
|
+
observed_page: z.object({ snapshot_hash: z.string().regex(SHA256), total: z.number().int().nonnegative(), next_cursor: ObservationCursorSchema.nullable() }).strict().optional(),
|
|
139
|
+
relationships_truncated: z.boolean().optional(),
|
|
88
140
|
depends_on: z.array(DependencyRefSchema).max(1024),
|
|
89
141
|
invalidated_by: z.array(z.string().max(512)).max(256),
|
|
90
142
|
}).strict();
|
|
@@ -131,6 +183,17 @@ export const WriteResultSchema = z.object({
|
|
|
131
183
|
* what landed without a second lookup. Additive. */
|
|
132
184
|
record: z.record(z.string(), z.unknown()).optional(),
|
|
133
185
|
}).strict();
|
|
186
|
+
export const CaptureBatchResultSchema = z.object({
|
|
187
|
+
schema: z.literal(STATE_CAPTURE_BATCH_VERSION),
|
|
188
|
+
results: z.array(z.discriminatedUnion("status", [
|
|
189
|
+
z.object({ index: z.number().int(), status: z.literal("saved"), result: WriteResultSchema }).strict(),
|
|
190
|
+
z.object({ index: z.number().int(), status: z.literal("refused"), code: z.string(), message: z.string() }).strict(),
|
|
191
|
+
])).max(32),
|
|
192
|
+
reviews: z.array(z.discriminatedUnion("status", [
|
|
193
|
+
z.object({ index: z.number().int(), status: z.literal("saved"), result: WriteResultSchema }).strict(),
|
|
194
|
+
z.object({ index: z.number().int(), status: z.literal("refused"), code: z.string(), message: z.string() }).strict(),
|
|
195
|
+
])).max(32).optional(),
|
|
196
|
+
}).strict();
|
|
134
197
|
export const SubscribeRequestSchema = z.object({
|
|
135
198
|
schema: z.literal(STATE_SUBSCRIBE_VERSION),
|
|
136
199
|
principal: PrincipalSchema,
|
|
@@ -224,6 +287,11 @@ export function commitmentId(c) {
|
|
|
224
287
|
return idFrom("ncm", { scope: c.scope, subject: c.subject, title: c.title.trim(), owner: c.owner, due: c.due });
|
|
225
288
|
}
|
|
226
289
|
export function derivedId(d) {
|
|
290
|
+
// Capture's reserved transform includes assertion/evidence identity, independent of
|
|
291
|
+
// read time and unrelated source edits. Ordinary summary identity is unchanged.
|
|
292
|
+
if (/^agent-capture\/1:[a-f0-9]{64}$/.test(d.transform_version)) {
|
|
293
|
+
return idFrom("nds", { scope: d.scope, subject: d.subject, transform_version: d.transform_version });
|
|
294
|
+
}
|
|
227
295
|
return idFrom("nds", { scope: d.scope, subject: d.subject, transform_version: d.transform_version, dependencies: d.dependencies.map((dep) => stateHash(dep)).sort(compareCodeUnits) });
|
|
228
296
|
}
|
|
229
297
|
// ---- invariants --------------------------------------------------------------------------
|
|
@@ -237,7 +305,7 @@ export const STATE_INVARIANTS = [
|
|
|
237
305
|
{ id: "derived-state-carries-dependencies", statement: "A derived statement without dependencies cannot be invalidated and is therefore not state." },
|
|
238
306
|
{ id: "one-entity-per-external-ref", statement: "One external record is one entity in a partition: a second active entity carrying an external key an incumbent already carries is refused with the incumbent named, and a subject written as that record's external key is refused with the entity's id named. Identity is explicit refs, never similarity; merge is explicit — a retired entity names the survivor in `merged_into`, the ledger holds the `retired` event, nothing under the old id is rewritten and reads resolve to the survivor — and split is the explicit reverse; never a silent rewrite." },
|
|
239
307
|
{ id: "human-correction-outranks-agent-writes", statement: "A record a human confirmed is never overwritten or superseded by an agent or service principal: the agent may replay it, write derived state back stale with the external cause that moved, or close a commitment with a receipt on record. Changing what the human said takes a human." },
|
|
240
|
-
{ id: "derived-state-writer-owns-currentness", statement: "No source writes the drawer. The writer of a derived statement owns keeping its dependencies true: re-validate them on a schedule or on a source event, and write the statement back stale with the moved pointer as cause when one no longer holds.
|
|
308
|
+
{ id: "derived-state-writer-owns-currentness", statement: "No source writes the drawer. The writer of a current derived statement owns keeping its dependencies true: re-validate them on a schedule or on a source event, and write the statement back stale with the moved pointer as cause when one no longer holds. Without this duty an agent may capture source-backed observations only as unknown; observations never assert currentness." },
|
|
241
309
|
];
|
|
242
310
|
const grantKey = (scope) => scopePath(scope);
|
|
243
311
|
/** The memory supply chain's top tier: a record whose provenance a human signed. Same tier rule
|
|
@@ -252,7 +320,7 @@ export function assertReadWithinGrants(principal, response) {
|
|
|
252
320
|
const granted = new Set(principal.grants.map(grantKey));
|
|
253
321
|
if (!granted.has(grantKey(response.scope)))
|
|
254
322
|
throw new Error(`read response scope ${grantKey(response.scope)} is outside the principal's grants`);
|
|
255
|
-
const refs = response.state_of_record ? [...response.state_of_record.current, ...response.state_of_record.in_force, ...response.state_of_record.done] : [];
|
|
323
|
+
const refs = response.state_of_record ? [...response.state_of_record.current, ...response.state_of_record.in_force, ...response.state_of_record.done, ...(response.state_of_record.observed ?? [])] : [];
|
|
256
324
|
for (const ref of refs) {
|
|
257
325
|
if (!granted.has(grantKey(ref.scope)))
|
|
258
326
|
throw new Error(`state ref ${ref.id} in scope ${grantKey(ref.scope)} leaked outside the principal's grants`);
|
|
@@ -289,6 +357,21 @@ export function assertDerivedState(d) {
|
|
|
289
357
|
throw new Error("derived state without dependencies is not state");
|
|
290
358
|
if (stateHash(d.content) !== d.content_hash)
|
|
291
359
|
throw new Error("derived state content hash does not match its content");
|
|
360
|
+
if (d.transform_version.startsWith(CAPTURE_TRANSFORM)) {
|
|
361
|
+
const content = z.object({
|
|
362
|
+
schema: z.literal("nuryel.observation-content/1"),
|
|
363
|
+
statement: CaptureRequestSchema.shape.statement, relevance: CaptureRequestSchema.shape.relevance,
|
|
364
|
+
evidence: z.array(z.object({ source: z.string(), excerpt: z.string().min(1).max(1200) }).strict()).min(1).max(8),
|
|
365
|
+
captured_by: PrincipalSchema.shape.id,
|
|
366
|
+
}).strict().parse(JSON.parse(d.content));
|
|
367
|
+
if (d.state === "current")
|
|
368
|
+
throw new Error("capture observations cannot assert currentness; publish a separately revalidated summary");
|
|
369
|
+
if (captureTransform(d.scope, d.subject, content.statement, content.evidence) !== d.transform_version)
|
|
370
|
+
throw new Error("capture identity does not match its assertion and evidence");
|
|
371
|
+
const pointers = new Set(d.dependencies.filter(dep => dep.kind === "external").map(dep => externalKey(dep.ref)));
|
|
372
|
+
if (content.evidence.some(e => !pointers.has(e.source)))
|
|
373
|
+
throw new Error("capture evidence lacks its external dependency");
|
|
374
|
+
}
|
|
292
375
|
}
|
|
293
376
|
/** Subscribe streams are strictly ordered per scope; a gap or regression means the caller must
|
|
294
377
|
* resynchronize instead of trusting what it holds. */
|
|
@@ -127,8 +127,16 @@ export const DerivedStateSchema = z.object({
|
|
|
127
127
|
computed_at: z.string().regex(ISO),
|
|
128
128
|
valid_to: z.string().regex(ISO).nullable().default(null),
|
|
129
129
|
state: z.enum(["current", "stale", "unknown"]),
|
|
130
|
+
/** Per-observation withdrawal. Original assertion, author and dependencies stay intact. */
|
|
131
|
+
review: z.object({
|
|
132
|
+
by: z.string().regex(TOKEN), at: z.string().regex(ISO), previous_hash: z.string().regex(SHA256), reason: z.string().trim().min(1).max(600),
|
|
133
|
+
evidence: z.array(z.object({ ref: ExternalRefSchema, excerpt: z.string().trim().min(1).max(1200) }).strict()).min(1).max(8),
|
|
134
|
+
}).strict().optional(),
|
|
130
135
|
provenance: ProvenanceSchema,
|
|
131
|
-
}).strict()
|
|
136
|
+
}).strict().superRefine((record, ctx) => {
|
|
137
|
+
if (record.review && record.state !== 'stale')
|
|
138
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['review'], message: 'withdrawal review belongs only to a stale observation' });
|
|
139
|
+
});
|
|
132
140
|
const AttributeValue = z.union([z.string().max(2048), z.number().finite(), z.boolean(), z.null()]);
|
|
133
141
|
/** entity — a customer, an incident, a thread: a non-code node, Landscape-shaped (kind-qualified
|
|
134
142
|
* id, lifecycle, provenance), with provenance pointers instead of mirrored content. Stored in
|
|
@@ -175,10 +183,17 @@ export const StateRelationshipSchema = z.object({
|
|
|
175
183
|
type: z.string().regex(/^[a-z][a-z0-9_]{0,63}$/),
|
|
176
184
|
scope: ScopeSchema,
|
|
177
185
|
reason: z.string().max(1024).default(""),
|
|
186
|
+
/** observation_about is a one-hop projection, never an entity merge or a copied fact. */
|
|
187
|
+
observation_hash: z.string().regex(SHA256).optional(),
|
|
188
|
+
evidence: ExternalRefSchema.optional(),
|
|
189
|
+
lifecycle: z.enum(["active", "retired"]).optional(),
|
|
178
190
|
provenance: ProvenanceSchema,
|
|
179
191
|
}).strict().superRefine((rel, ctx) => {
|
|
180
192
|
if (rel.id !== edgeId(rel.from, rel.to, rel.type))
|
|
181
193
|
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["id"], message: "relationship id must derive from its endpoints and type" });
|
|
194
|
+
if (rel.type === "observation_about" && (!/^nds_[a-f0-9]{24}$/.test(rel.from) || !rel.observation_hash || !rel.evidence?.content_hash || !rel.reason.trim() || rel.to.length > 512 || rel.to === rel.from)) {
|
|
195
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "observation_about requires an observation id/hash, a subject, reason and hashed external evidence of the explicit association" });
|
|
196
|
+
}
|
|
182
197
|
});
|
|
183
198
|
/** DNA facet: existing Project DNA profiles, keyed by scope. No new schema — the reference only. */
|
|
184
199
|
export const DnaFacetRefSchema = z.object({
|
package/dist/mcp/server.js
CHANGED
|
@@ -14,6 +14,8 @@ import { hunchPaths, findRoot, toPosixTarget } from "../core/paths.js";
|
|
|
14
14
|
import { canonicalRootPath, resolveActiveRoot } from "./roots.js";
|
|
15
15
|
import { HunchStore } from "../store/hunchStore.js";
|
|
16
16
|
import { StateRefusal, SubscribeResponseSchema, capabilities, partitionOf, readState, recordsState, subscribeState, writeState } from "../store/stateBinding.js";
|
|
17
|
+
import { captureState, captureBatchState } from "../store/stateCapture.js";
|
|
18
|
+
import { CaptureRequestSchema, CaptureBatchRequestSchema, CaptureBatchResultSchema, STATE_CAPTURE_VERSION, STATE_CAPTURE_BATCH_VERSION } from "../core/stateContract.js";
|
|
17
19
|
import { ReadRequestSchema, ReadResponseSchema, WriteRequestSchema, WriteResultSchema, SubscribeRequestSchema, RecordsRequestSchema, RecordsResponseSchema, STATE_READ_VERSION, STATE_WRITE_VERSION, STATE_SUBSCRIBE_VERSION, STATE_RECORDS_VERSION, stateHash } from "../core/stateContract.js";
|
|
18
20
|
import { selectEmbedder } from "../store/embedder.js";
|
|
19
21
|
import { decisionId, findingId } from "../core/ids.js";
|
|
@@ -1727,7 +1729,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1727
1729
|
});
|
|
1728
1730
|
server.registerTool("nuryel_read", {
|
|
1729
1731
|
title: "nuryel.state/1 read — the system-of-record answer for a subject",
|
|
1730
|
-
description: "Read organizational state under a delivery receipt. Pass the principal (id, kind, grants) and the scope; optionally a subject (an entity id, a decision topic, an external `object_type:object_key`) to get state_of_record — what is current, in force, done, what it depends on and what invalidates it — plus a task phrase for the ranked delivery envelope. Scopes the principal is not granted are named in denied_scopes, never silently dropped.",
|
|
1732
|
+
description: "Read organizational state under a delivery receipt. Pass the principal (id, kind, grants) and the scope; optionally a subject (an entity id, a decision topic, an external `object_type:object_key`) to get state_of_record — what is current, in force, done, what it depends on and what invalidates it — plus a task phrase for the ranked delivery envelope. Scopes the principal is not granted are named in denied_scopes, never silently dropped. To read observations beyond the default 64, use observed_page:{} with one scope and a subject, then pass state_of_record.observed_page.next_cursor as observed_page.cursor until null. A conflict means the observations changed: restart from the first page. Never claim complete coverage while a next cursor remains.",
|
|
1731
1733
|
inputSchema: ReadRequestSchema.omit({ schema: true }).shape,
|
|
1732
1734
|
outputSchema: ReadResponseSchema.shape,
|
|
1733
1735
|
}, async (input) => {
|
|
@@ -1735,7 +1737,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1735
1737
|
const { response, envelope } = readState(store, { schema: STATE_READ_VERSION, ...input });
|
|
1736
1738
|
const sor = response.state_of_record;
|
|
1737
1739
|
const summary = sor
|
|
1738
|
-
? `subject ${sor.subject}: current ${sor.current.length} · in force ${sor.in_force.length} · done ${sor.done.length} · depends on ${sor.depends_on.length} · invalidated by ${sor.invalidated_by.length}`
|
|
1740
|
+
? `subject ${sor.subject}: current ${sor.current.length} · in force ${sor.in_force.length} · done ${sor.done.length} · observed ${sor.observed?.length ?? 0} · depends on ${sor.depends_on.length} · invalidated by ${sor.invalidated_by.length}`
|
|
1739
1741
|
: "no subject — delivery envelope only";
|
|
1740
1742
|
const deniedNote = response.denied_scopes.length ? `\ndenied scopes: ${response.denied_scopes.map((s) => `${s.kind}/${s.id}`).join(", ")}` : "";
|
|
1741
1743
|
// Render the state of record itself, not only its refs: a consumer answers from this text.
|
|
@@ -1774,6 +1776,9 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1774
1776
|
};
|
|
1775
1777
|
const stateText = sor
|
|
1776
1778
|
? [...sor.current.map((r) => line("current", r)), ...sor.in_force.map((r) => line("in force", r)), ...sor.done.map((r) => line("done", r)),
|
|
1779
|
+
...(sor.observed ?? []).map(r => line("observed; verify currentness before relying on it", r)),
|
|
1780
|
+
...(sor.observed_page ? [`- Observation page: ${sor.observed_page.total} total; next_cursor: ${JSON.stringify(sor.observed_page.next_cursor)}`]
|
|
1781
|
+
: sor.observed_truncated ? ['- More observations exist; read this subject with observed_page:{} in one partition, then follow next_cursor.'] : []),
|
|
1777
1782
|
...(sor.invalidated_by.length ? [`- invalidated by: ${sor.invalidated_by.join(", ")}`] : [])].join("\n") || "(nothing on record for this subject)"
|
|
1778
1783
|
: "";
|
|
1779
1784
|
return stateResult(`${response.receipt_id} · ${summary}${deniedNote}${stateText ? `\n\nState of record:\n${stateText}` : ""}\n\n${envelope.text}`, response);
|
|
@@ -1784,7 +1789,7 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1784
1789
|
});
|
|
1785
1790
|
server.registerTool("nuryel_write", {
|
|
1786
1791
|
title: "nuryel.state/1 write — provenance + idempotency in, durability out",
|
|
1787
|
-
description: "Write one record into a facet (receipts, commitments, derived, entities, relationships, or the legacy decisions/constraints/bugs/findings). The record must carry provenance; the request must carry an idempotency_key — a replay returns the original, a reused key with a different payload is refused. Ids are derived from the record's facts, never chosen. A second live decision on a topic is refused with the incumbent named; pass supersedes to replace it explicitly. organization/team/user partitions never ride a repository: they require an overlay.",
|
|
1792
|
+
description: "Write one record into a facet (receipts, commitments, derived, entities, relationships, or the legacy decisions/constraints/bugs/findings). The record must carry provenance; the request must carry an idempotency_key — a replay returns the original, a reused key with a different payload is refused. Ids are derived from the record's facts, never chosen. A second live decision on a topic is refused with the incumbent named; pass supersedes to replace it explicitly. organization/team/user partitions never ride a repository: they require an overlay. To show an existing captured observation under another subject without copying it, write a relationship type observation_about with from=observation id, to=subject, observation_hash, lifecycle=active, reason and hashed external evidence of the explicit association. Retire the relationship to unlink; reactivation requires expected_version.",
|
|
1788
1793
|
inputSchema: { ...WriteRequestSchema.omit({ schema: true }).shape, cwd: cwdHintField },
|
|
1789
1794
|
outputSchema: WriteResultSchema.shape,
|
|
1790
1795
|
}, async ({ cwd: _cwd, ...input }) => {
|
|
@@ -1800,6 +1805,38 @@ export function buildServerWithRootControl(initialRoot, options = {}) {
|
|
|
1800
1805
|
return stateRefusal(e);
|
|
1801
1806
|
}
|
|
1802
1807
|
});
|
|
1808
|
+
server.registerTool("nuryel_capture", {
|
|
1809
|
+
title: "nuryel.state/1 capture — one relevant assertion with exact source excerpts",
|
|
1810
|
+
description: "Save ONE relevant atomic assertion learned during the task. First split mixed source material into independent assertions; check each one, retaining new relevant details inside otherwise known passages. Exclude chatter, speculation, unsupported conclusions and transient tool output. Supply a concrete future-use reason and exact excerpts from the source text. Whole source text is transient and is never stored. Deduplication is per assertion + subject + source excerpt, independent of agent and read time; never skip a whole document because some of it is known. Records are observations, not verified current summaries or execution receipts. Call after substantive learning without waiting for the user to say remember. Read back the returned record before claiming it was saved.",
|
|
1811
|
+
inputSchema: { ...CaptureRequestSchema.omit({ schema: true }).shape, cwd: cwdHintField },
|
|
1812
|
+
outputSchema: WriteResultSchema.shape,
|
|
1813
|
+
}, async ({ cwd: _cwd, ...input }) => {
|
|
1814
|
+
try {
|
|
1815
|
+
const result = await withWriteLock(hunchPaths(root).hunch, () => captureState(store, { schema: STATE_CAPTURE_VERSION, ...input }, {
|
|
1816
|
+
flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
|
|
1817
|
+
}));
|
|
1818
|
+
return stateResult(`${result.outcome} observation ${result.record_id} (${result.durability}); this does not assert currentness. ${result.record_hash}`, result);
|
|
1819
|
+
}
|
|
1820
|
+
catch (e) {
|
|
1821
|
+
return stateRefusal(e);
|
|
1822
|
+
}
|
|
1823
|
+
});
|
|
1824
|
+
server.registerTool("nuryel_capture_batch", {
|
|
1825
|
+
title: "nuryel.state/1 capture batch — save relevant atomic observations",
|
|
1826
|
+
description: "Preferred capture for multiple facts learned during the task. Split source material into independent relevant assertions, select exact supporting excerpts and give each a concrete future-use reason. Exclude chatter, unsupported inference and transient output. Check every assertion even in a known paragraph: deduplication never discards a whole passage. Send each source once and reference its zero-based index. At most 32 observations and 8 sources; split larger work into batches. One partition lock and index update, no extra model call. Results preserve input indexes; inspect every refusal and stored record. Saved observations have unknown currentness, not verified receipts or current summaries. Use your own initiating agent identity automatically after substantive learning. Optional reviews withdraw specific prior observations: supply record_id, expected_hash, a reason, and exact excerpts from a changed source that observation depends on. Mere hash changes, missing text or uncertain interpretation never suffice; the initiating agent must identify explicit contradiction or withdrawal. Original facts remain in history with the review author and evidence. Review results are separately indexed; inspect every refusal.",
|
|
1827
|
+
inputSchema: { ...CaptureBatchRequestSchema.omit({ schema: true }).shape, cwd: cwdHintField },
|
|
1828
|
+
outputSchema: CaptureBatchResultSchema.shape,
|
|
1829
|
+
}, async ({ cwd: _cwd, ...input }) => {
|
|
1830
|
+
try {
|
|
1831
|
+
const result = await withWriteLock(hunchPaths(root).hunch, () => captureBatchState(store, { schema: STATE_CAPTURE_BATCH_VERSION, ...input }, {
|
|
1832
|
+
flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
|
|
1833
|
+
}));
|
|
1834
|
+
return stateResult(`Capture batch: ${result.results.filter(r => r.status === "saved").length} saved/replayed, ${result.results.filter(r => r.status === "refused").length} refused.${result.reviews ? ` Reviews: ${result.reviews.filter(r => r.status === "saved").length} withdrawn/replayed, ${result.reviews.filter(r => r.status === "refused").length} refused.` : ''} Inspect each indexed result.`, result);
|
|
1835
|
+
}
|
|
1836
|
+
catch (e) {
|
|
1837
|
+
return stateRefusal(e);
|
|
1838
|
+
}
|
|
1839
|
+
});
|
|
1803
1840
|
server.registerTool("nuryel_subscribe", {
|
|
1804
1841
|
title: "nuryel.state/1 subscribe — the scope's ordered change stream after a cursor",
|
|
1805
1842
|
description: "Return the change events for a scope with seq > after_seq, strictly ordered. Unfiltered, the events are contiguous (a gap means resynchronize); with facets/subjects filters the response is a subsequence and head_seq is still your next cursor. Each event names the record, its hash, what changed, what it invalidates, and the cause.",
|
package/dist/serve/app.js
CHANGED
|
@@ -24,6 +24,8 @@ import { STATE_READ_VERSION, STATE_RECORDS_VERSION, STATE_SUBSCRIBE_VERSION, STA
|
|
|
24
24
|
import { partitionFor, resolvePrincipal } from "./config.js";
|
|
25
25
|
import { WriteLockTimeout, withWriteLock } from "./writelock.js";
|
|
26
26
|
import { HUNCH_VERSION } from "../core/version.js";
|
|
27
|
+
import { captureState, captureBatchState } from "../store/stateCapture.js";
|
|
28
|
+
import { STATE_CAPTURE_VERSION, STATE_CAPTURE_BATCH_VERSION } from "../core/stateContract.js";
|
|
27
29
|
export const BODY_LIMIT_BYTES = 1024 * 1024;
|
|
28
30
|
export const PROBLEM_TYPE = "https://www.hunchmemory.com/problems/nuryel.state/1/";
|
|
29
31
|
export class HttpProblem extends Error {
|
|
@@ -148,6 +150,8 @@ export function createServeApp(config, opts = {}) {
|
|
|
148
150
|
delete body.schema;
|
|
149
151
|
if (url.pathname === "/nuryel/v1/read") {
|
|
150
152
|
const scope = requireScope(principal, body);
|
|
153
|
+
if (body.observed_page !== undefined && body.scopes !== undefined)
|
|
154
|
+
throw problem(400, 'malformed', 'observation pages require a single partition without scopes');
|
|
151
155
|
const { store } = storeFor(scope);
|
|
152
156
|
if (body.scopes === undefined) {
|
|
153
157
|
const { response, envelope } = readState(store, { schema: STATE_READ_VERSION, principal, ...body });
|
|
@@ -178,6 +182,22 @@ export function createServeApp(config, opts = {}) {
|
|
|
178
182
|
}));
|
|
179
183
|
return send(res, result.outcome === "created" ? 201 : 200, result);
|
|
180
184
|
}
|
|
185
|
+
if (url.pathname === "/nuryel/v1/capture") {
|
|
186
|
+
const scope = requireScope(principal, body);
|
|
187
|
+
const { store } = storeFor(scope);
|
|
188
|
+
const result = await withWriteLock(hunchPaths(store.publicRoot).hunch, () => captureState(store, { schema: STATE_CAPTURE_VERSION, principal, ...body }, {
|
|
189
|
+
flush: (isPrivate, message) => flushCapture(store, hunchPaths(store.publicRoot).hunch, isPrivate, message),
|
|
190
|
+
}));
|
|
191
|
+
return send(res, result.outcome === "created" ? 201 : 200, result);
|
|
192
|
+
}
|
|
193
|
+
if (url.pathname === "/nuryel/v1/capture-batch") {
|
|
194
|
+
const scope = requireScope(principal, body);
|
|
195
|
+
const { store, root } = storeFor(scope);
|
|
196
|
+
const result = await withWriteLock(hunchPaths(root).hunch, () => captureBatchState(store, { schema: STATE_CAPTURE_BATCH_VERSION, principal, ...body }, {
|
|
197
|
+
flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message),
|
|
198
|
+
}));
|
|
199
|
+
return send(res, 200, result);
|
|
200
|
+
}
|
|
181
201
|
if (url.pathname === "/nuryel/v1/subscribe") {
|
|
182
202
|
const scope = requireScope(principal, body);
|
|
183
203
|
const { store } = storeFor(scope);
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* here (see docs/nuryel-state-contract.md, "Not decided here").
|
|
11
11
|
*/
|
|
12
12
|
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
13
|
-
import { join } from "node:path";
|
|
13
|
+
import { join, resolve } from "node:path";
|
|
14
14
|
import { createHash } from "node:crypto";
|
|
15
15
|
import { z } from "zod";
|
|
16
16
|
import { writeFileAtomic } from "../core/io.js";
|
|
@@ -39,6 +39,12 @@ export const LedgerSchema = z.object({
|
|
|
39
39
|
events: z.array(ChangeEventSchema),
|
|
40
40
|
idempotency: z.record(z.string(), IdempotencyEntrySchema).default({}),
|
|
41
41
|
}).strict();
|
|
42
|
+
// Process-local acceleration only: compare the actual bytes on EVERY read, never
|
|
43
|
+
// timestamps or a TTL. Separate processes and same-size replacements stay visible.
|
|
44
|
+
// Keep only four small snapshots; oversized ledgers follow the uncached path.
|
|
45
|
+
const validatedSnapshots = new Map();
|
|
46
|
+
const MAX_CACHED_LEDGERS = 4;
|
|
47
|
+
const MAX_CACHED_CHARACTERS = 1024 * 1024;
|
|
42
48
|
/** Scope ids may carry `:` `@` `+` (safe in the contract, not in every file system), so
|
|
43
49
|
* the file name is the sanitized id plus a short hash of the exact id — readable AND
|
|
44
50
|
* collision-free. The scope inside the file is authoritative, the name is a locator. */
|
|
@@ -53,10 +59,21 @@ export function emptyLedger(scope) {
|
|
|
53
59
|
/** Read the ledger for a scope; a missing file is an empty ledger, a corrupt one is an
|
|
54
60
|
* error (never silently treated as empty — that would restart the sequence). */
|
|
55
61
|
export function readLedger(hunchDir, scope) {
|
|
56
|
-
const file = ledgerFile(hunchDir, scope);
|
|
57
|
-
if (!existsSync(file))
|
|
62
|
+
const file = resolve(ledgerFile(hunchDir, scope));
|
|
63
|
+
if (!existsSync(file)) {
|
|
64
|
+
validatedSnapshots.delete(file);
|
|
58
65
|
return emptyLedger(scope);
|
|
59
|
-
|
|
66
|
+
}
|
|
67
|
+
const text = readFileSync(file, "utf8");
|
|
68
|
+
const cached = validatedSnapshots.get(file);
|
|
69
|
+
if (cached?.text === text && cached.scope === scopePath(scope)) {
|
|
70
|
+
validatedSnapshots.delete(file);
|
|
71
|
+
validatedSnapshots.set(file, cached);
|
|
72
|
+
// append/compaction callers mutate their copy. Never expose the cached object.
|
|
73
|
+
return JSON.parse(cached.normalized);
|
|
74
|
+
}
|
|
75
|
+
validatedSnapshots.delete(file);
|
|
76
|
+
const raw = JSON.parse(text);
|
|
60
77
|
const ledger = LedgerSchema.parse(raw);
|
|
61
78
|
if (scopePath(ledger.scope) !== scopePath(scope))
|
|
62
79
|
throw new Error(`ledger ${file} belongs to scope ${scopePath(ledger.scope)}, not ${scopePath(scope)}`);
|
|
@@ -68,18 +85,30 @@ export function readLedger(hunchDir, scope) {
|
|
|
68
85
|
}
|
|
69
86
|
if (ledger.head_seq !== ledger.floor_seq + ledger.events.length)
|
|
70
87
|
throw new Error(`ledger ${file} head_seq ${ledger.head_seq} disagrees with floor ${ledger.floor_seq} + ${ledger.events.length} events`);
|
|
88
|
+
if (text.length <= MAX_CACHED_CHARACTERS) {
|
|
89
|
+
while (validatedSnapshots.size >= MAX_CACHED_LEDGERS)
|
|
90
|
+
validatedSnapshots.delete(validatedSnapshots.keys().next().value);
|
|
91
|
+
validatedSnapshots.set(file, { text, normalized: JSON.stringify(ledger), scope: scopePath(scope) });
|
|
92
|
+
}
|
|
71
93
|
return ledger;
|
|
72
94
|
}
|
|
73
95
|
export function writeLedger(hunchDir, ledger) {
|
|
96
|
+
writeValidatedLedger(hunchDir, LedgerSchema.parse(ledger));
|
|
97
|
+
}
|
|
98
|
+
function writeValidatedLedger(hunchDir, ledger) {
|
|
74
99
|
const file = ledgerFile(hunchDir, ledger.scope);
|
|
75
100
|
mkdirSync(join(hunchDir, CHANGES_DIR), { recursive: true });
|
|
76
|
-
writeFileAtomic(file, JSON.stringify(
|
|
101
|
+
writeFileAtomic(file, JSON.stringify(ledger, null, 2) + "\n");
|
|
77
102
|
}
|
|
78
103
|
/** Append events (in order) and remember an idempotency key in ONE atomic write, so a
|
|
79
104
|
* crash between "record written" and "event appended" can be detected by the next
|
|
80
105
|
* writer (record present, ledger silent) rather than producing a half-applied write. */
|
|
81
|
-
export function appendChanges(hunchDir, scope, changes, idempotency, at = new Date().toISOString()
|
|
82
|
-
|
|
106
|
+
export function appendChanges(hunchDir, scope, changes, idempotency, at = new Date().toISOString(),
|
|
107
|
+
/** Already validated under the same uninterrupted partition lock; batch-local only. */
|
|
108
|
+
current) {
|
|
109
|
+
const ledger = current ?? readLedger(hunchDir, scope);
|
|
110
|
+
if (scopePath(ledger.scope) !== scopePath(scope))
|
|
111
|
+
throw new Error("cached ledger belongs to another scope");
|
|
83
112
|
const appended = [];
|
|
84
113
|
for (const change of changes) {
|
|
85
114
|
const event = ChangeEventSchema.parse({ schema: "nuryel.state.subscribe/1", seq: ledger.head_seq + 1, at, scope, ...change });
|
|
@@ -88,9 +117,11 @@ export function appendChanges(hunchDir, scope, changes, idempotency, at = new Da
|
|
|
88
117
|
appended.push(event);
|
|
89
118
|
}
|
|
90
119
|
if (idempotency) {
|
|
91
|
-
ledger.idempotency[idempotency.key] = { ...idempotency.entry, seq: ledger.head_seq, at };
|
|
120
|
+
ledger.idempotency[idempotency.key] = IdempotencyEntrySchema.parse({ ...idempotency.entry, seq: ledger.head_seq, at });
|
|
92
121
|
}
|
|
93
|
-
|
|
122
|
+
// The initial ledger and each new event/entry are validated; don't re-validate the
|
|
123
|
+
// full history per assertion. Each append still reaches disk atomically before return.
|
|
124
|
+
writeValidatedLedger(hunchDir, ledger);
|
|
94
125
|
return appended;
|
|
95
126
|
}
|
|
96
127
|
/** The latest seq that touched a record in this scope, or 0 when the ledger never saw it. */
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -192,8 +192,10 @@ export class HunchStore {
|
|
|
192
192
|
putCapture(kind, record, isPrivate = false) {
|
|
193
193
|
const home = this.captureHome(isPrivate);
|
|
194
194
|
const id = record.id;
|
|
195
|
-
const
|
|
196
|
-
|
|
195
|
+
const lookup = (json) => kind === "derived" || kind === "receipts" || kind === "commitments"
|
|
196
|
+
? json?.getDirect(kind, id) : json?.get(kind, id);
|
|
197
|
+
const targetHasRecord = !!lookup(home === "private" ? this.privateJson : this.json);
|
|
198
|
+
const otherHasRecord = !!lookup(home === "private" ? this.json : this.privateJson);
|
|
197
199
|
// Legacy repositories can already contain twins, so an idempotent update in
|
|
198
200
|
// the selected home remains possible. A new capture must never CREATE that
|
|
199
201
|
// ambiguous state: merged/private-first reads would make later writers and
|
|
@@ -244,6 +246,9 @@ export class HunchStore {
|
|
|
244
246
|
getPrivateRec(kind, id) {
|
|
245
247
|
return this.privateJson?.get(kind, id);
|
|
246
248
|
}
|
|
249
|
+
getStateDirect(kind, id, home) {
|
|
250
|
+
return (home === "private" ? this.privateJson : this.json)?.getDirect(kind, id);
|
|
251
|
+
}
|
|
247
252
|
/** Update an EXISTING record in the store that holds it — an overlay record must never
|
|
248
253
|
* fork a public copy on update (and vice versa). Falls back to captureHome routing for
|
|
249
254
|
* a record that exists nowhere yet. */
|
package/dist/store/jsonStore.js
CHANGED
|
@@ -559,6 +559,21 @@ export class JsonStore {
|
|
|
559
559
|
get(kind, id) {
|
|
560
560
|
return this.loadAll(kind).find((r) => r.id === id);
|
|
561
561
|
}
|
|
562
|
+
/** Direct authoritative state lookup. No directory enumeration or stale cache;
|
|
563
|
+
* migration precedes validation and corrupt files fail closed. */
|
|
564
|
+
getDirect(kind, id) {
|
|
565
|
+
const file = this.fileFor(kind, id);
|
|
566
|
+
const directory = this.safeKindDirectory(kind, false);
|
|
567
|
+
if (!directory)
|
|
568
|
+
return undefined;
|
|
569
|
+
const text = this.readContainedFile(directory, file, this.maxBytes(kind));
|
|
570
|
+
if (text === null || text.trim() === "")
|
|
571
|
+
return undefined;
|
|
572
|
+
const record = SCHEMAS[kind].parse(this.migrate(kind, JSON.parse(text), this.schemaVersion()));
|
|
573
|
+
if (record.id !== id)
|
|
574
|
+
throw new Error(`record identity does not match ${kind} file name`);
|
|
575
|
+
return record;
|
|
576
|
+
}
|
|
562
577
|
/** On-disk record count, independent of validation: per-record kinds count
|
|
563
578
|
* every non-tombstone .json file (a 0-byte merge tombstone is an intentional
|
|
564
579
|
* absence), single-file kinds count raw array entries (a corrupt index file
|
|
@@ -26,7 +26,7 @@ import { decisionId } from "../core/ids.js";
|
|
|
26
26
|
import { ENTITY_KINDS, SCHEMAS } from "../core/types.js";
|
|
27
27
|
import { captureConflicts, isLive } from "../core/topics.js";
|
|
28
28
|
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, externalKey, subjectOfRef, assertReadWithinGrants, assertWriteWellFormed, assertDerivedState, isHumanConfirmed, } from "../core/stateContract.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";
|
|
30
30
|
/** A typed refusal. `code` is stable for bindings; `conflict` names the incumbent when one exists. */
|
|
31
31
|
export class StateRefusal extends Error {
|
|
32
32
|
code;
|
|
@@ -169,6 +169,9 @@ export function readState(store, input) {
|
|
|
169
169
|
const request = ReadRequestSchema.parse(input);
|
|
170
170
|
if (!granted(request.principal, request.scope))
|
|
171
171
|
throw new StateRefusal("outside-grants", `scope ${scopePath(request.scope)} is outside the principal's grants`);
|
|
172
|
+
if (request.observed_page && (request.subject === undefined || request.scopes !== undefined || (request.facets && !request.facets.includes('derived')))) {
|
|
173
|
+
throw new StateRefusal('malformed', 'observation pages require a subject, the derived facet and a single partition without scopes');
|
|
174
|
+
}
|
|
172
175
|
const repo = partitionOf(store);
|
|
173
176
|
const facets = new Set(request.facets ?? STATE_FACETS);
|
|
174
177
|
const target = request.task ?? request.subject ?? scopePath(request.scope);
|
|
@@ -196,9 +199,23 @@ export function readState(store, input) {
|
|
|
196
199
|
// read for the entity id finds what was filed under its keys — one explicit hop, grants first.
|
|
197
200
|
const aliases = subjectAliases(store, request.principal, repo, subject);
|
|
198
201
|
const isSubject = (s) => s !== undefined && aliases.has(s);
|
|
202
|
+
// One hop only. Do not broaden aliases: a linked observation does not merge subjects,
|
|
203
|
+
// bring unrelated facts, receipts or commitments, or traverse another relationship.
|
|
204
|
+
const linkedObservations = new Map();
|
|
205
|
+
for (const r of store.recs("relationships")) {
|
|
206
|
+
if (!granted(request.principal, r.scope) || scopePath(r.scope) !== scopePath(request.scope))
|
|
207
|
+
continue;
|
|
208
|
+
if (r.type !== "observation_about" || r.lifecycle === "retired" || !isSubject(r.to) || !r.observation_hash)
|
|
209
|
+
continue;
|
|
210
|
+
const hashes = linkedObservations.get(r.from) ?? new Set();
|
|
211
|
+
hashes.add(r.observation_hash);
|
|
212
|
+
linkedObservations.set(r.from, hashes);
|
|
213
|
+
}
|
|
199
214
|
const current = [];
|
|
200
215
|
const inForce = [];
|
|
201
216
|
const done = [];
|
|
217
|
+
const observations = [];
|
|
218
|
+
let relationshipsTruncated = false;
|
|
202
219
|
const dependsOn = [];
|
|
203
220
|
const invalidatedBy = new Set();
|
|
204
221
|
/** authorization-before-retrieval: the grant check runs before the record is examined. */
|
|
@@ -265,15 +282,26 @@ export function readState(store, input) {
|
|
|
265
282
|
}
|
|
266
283
|
if (facets.has("derived"))
|
|
267
284
|
for (const d of store.recs("derived")) {
|
|
268
|
-
if (!isSubject(d.subject) && d.id !== subject)
|
|
269
|
-
continue;
|
|
270
285
|
const scope = admit("derived", d);
|
|
271
286
|
if (!scope)
|
|
272
287
|
continue;
|
|
288
|
+
if (request.observed_page && scopePath(scope) !== scopePath(request.scope))
|
|
289
|
+
continue;
|
|
290
|
+
const direct = isSubject(d.subject) || d.id === subject;
|
|
291
|
+
const linked = scopePath(scope) === scopePath(request.scope) && linkedObservations.get(d.id)?.has(stateHash(d));
|
|
292
|
+
if (!direct && !linked)
|
|
293
|
+
continue;
|
|
294
|
+
if (!direct) {
|
|
295
|
+
if (d.state === "unknown" && d.valid_to == null)
|
|
296
|
+
observations.push(d);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
273
299
|
if (d.state === "current" && d.valid_to == null) {
|
|
274
300
|
current.push(keep("derived", d, scope));
|
|
275
301
|
dependsOn.push(...d.dependencies);
|
|
276
302
|
}
|
|
303
|
+
else if (d.state === "unknown" && d.valid_to == null)
|
|
304
|
+
observations.push(d);
|
|
277
305
|
}
|
|
278
306
|
if (facets.has("entities"))
|
|
279
307
|
for (const e of store.recs("entities")) {
|
|
@@ -287,14 +315,39 @@ export function readState(store, input) {
|
|
|
287
315
|
}
|
|
288
316
|
if (facets.has("relationships"))
|
|
289
317
|
for (const r of store.recs("relationships")) {
|
|
318
|
+
if (r.lifecycle === "retired")
|
|
319
|
+
continue;
|
|
290
320
|
if (!isSubject(r.from) && !isSubject(r.to))
|
|
291
321
|
continue;
|
|
292
322
|
const scope = admit("relationships", r);
|
|
293
323
|
if (!scope)
|
|
294
324
|
continue;
|
|
325
|
+
if (current.length >= 256) {
|
|
326
|
+
relationshipsTruncated = true;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
295
329
|
current.push(keep("relationships", r, scope));
|
|
296
330
|
}
|
|
297
|
-
|
|
331
|
+
observations.sort((a, b) => Date.parse(b.computed_at) - Date.parse(a.computed_at) || a.id.localeCompare(b.id));
|
|
332
|
+
let offset = 0;
|
|
333
|
+
let page;
|
|
334
|
+
if (request.observed_page) {
|
|
335
|
+
// Fingerprint the authorized membership AND record contents. A change between
|
|
336
|
+
// pages is a conflict, never a silently skipped or duplicated observation.
|
|
337
|
+
const snapshot_hash = stateHash({ scope: request.scope, subject, observations });
|
|
338
|
+
const cursor = request.observed_page.cursor;
|
|
339
|
+
if (cursor && cursor.snapshot_hash !== snapshot_hash)
|
|
340
|
+
throw new StateRefusal('conflict', 'observations changed between pages; restart from the first page');
|
|
341
|
+
offset = cursor?.offset ?? 0;
|
|
342
|
+
if (offset > observations.length)
|
|
343
|
+
throw new StateRefusal('malformed', 'observation cursor is outside this snapshot');
|
|
344
|
+
page = { snapshot_hash, total: observations.length, next_cursor: offset + 64 < observations.length ? { snapshot_hash, offset: offset + 64 } : null };
|
|
345
|
+
}
|
|
346
|
+
const observed = observations.slice(offset, offset + 64).map(d => keep("derived", d, recordScope(d, repo)));
|
|
347
|
+
stateOfRecord = { subject, current, in_force: inForce, done, depends_on: dependsOn, invalidated_by: [...invalidatedBy].sort(),
|
|
348
|
+
...(relationshipsTruncated ? { relationships_truncated: true } : {}),
|
|
349
|
+
...(observed.length || page ? { observed, observed_truncated: observations.length > offset + observed.length } : {}),
|
|
350
|
+
...(page ? { observed_page: page } : {}) };
|
|
298
351
|
}
|
|
299
352
|
const response = ReadResponseSchema.parse({
|
|
300
353
|
schema: STATE_READ_VERSION,
|
|
@@ -364,6 +417,9 @@ export function mergeReadResponses(primary, others, extraDenied = []) {
|
|
|
364
417
|
current: dedupeRefs((s) => s.current),
|
|
365
418
|
in_force: dedupeRefs((s) => s.in_force),
|
|
366
419
|
done: dedupeRefs((s) => s.done),
|
|
420
|
+
...(sors.some(s => s.relationships_truncated) ? { relationships_truncated: true } : {}),
|
|
421
|
+
...(sors.some(s => s.observed?.length) ? { observed: dedupeRefs(s => s.observed ?? []).slice(0, 64),
|
|
422
|
+
observed_truncated: sors.some(s => s.observed_truncated) || dedupeRefs(s => s.observed ?? []).length > 64 } : {}),
|
|
367
423
|
depends_on: dependsOn,
|
|
368
424
|
invalidated_by: [...new Set(sors.flatMap((s) => s.invalidated_by))].sort(),
|
|
369
425
|
};
|
|
@@ -390,7 +446,7 @@ function subjectOf(facet, record) {
|
|
|
390
446
|
case "commitments":
|
|
391
447
|
case "derived": return typeof r.subject === "string" ? r.subject : undefined;
|
|
392
448
|
case "entities": return typeof r.id === "string" ? r.id : undefined;
|
|
393
|
-
case "relationships": return typeof r.from === "string" ? r.from : undefined;
|
|
449
|
+
case "relationships": return r.type === "observation_about" && typeof r.to === "string" ? r.to : typeof r.from === "string" ? r.from : undefined;
|
|
394
450
|
case "receipts": {
|
|
395
451
|
const t = r.target;
|
|
396
452
|
return t?.object_type && t.object_key ? `${t.object_type}:${t.object_key}` : undefined;
|
|
@@ -565,6 +621,8 @@ function normalizeRecord(facet, scope, raw, principal) {
|
|
|
565
621
|
expectedId = commitmentId(record);
|
|
566
622
|
else if (facet === "derived")
|
|
567
623
|
expectedId = derivedId(record);
|
|
624
|
+
else if (facet === "relationships")
|
|
625
|
+
expectedId = relationshipId(String(record.from), String(record.to), String(record.type));
|
|
568
626
|
else if (facet === "decisions" && typeof record.id !== "string")
|
|
569
627
|
expectedId = decisionId(String(record.topic ?? record.title ?? ""));
|
|
570
628
|
}
|
|
@@ -602,20 +660,46 @@ export function writeState(store, input, opts = {}) {
|
|
|
602
660
|
const { home, hunchDir, isPrivate } = stateHomeFor(store, request.scope);
|
|
603
661
|
const now = (opts.now ?? (() => new Date()))().toISOString();
|
|
604
662
|
const facet = request.facet;
|
|
663
|
+
const getHere = (id) => facet === "derived" || facet === "receipts" || facet === "commitments"
|
|
664
|
+
? store.getStateDirect(facet, id, home)
|
|
665
|
+
: home === "private" ? store.getPrivateRec(facet, id) : store.json.get(facet, id);
|
|
605
666
|
if (!ENTITY_KINDS.includes(facet))
|
|
606
667
|
throw new StateRefusal("unsupported", `facet ${facet} is not a store kind`);
|
|
607
668
|
const record = normalizeRecord(facet, request.scope, request.record, request.principal);
|
|
669
|
+
let replayLink;
|
|
670
|
+
if (facet === "relationships" && record.type === "observation_about") {
|
|
671
|
+
const link = record;
|
|
672
|
+
const observation = store.getStateDirect("derived", link.from, home);
|
|
673
|
+
if (!observation || scopePath(observation.scope) !== scopePath(request.scope) || !granted(request.principal, observation.scope))
|
|
674
|
+
throw new StateRefusal("conflict", "observation is absent from the granted partition");
|
|
675
|
+
if (!observation.transform_version.startsWith("agent-capture/1:"))
|
|
676
|
+
throw new StateRefusal("malformed", "only captured observations can be linked");
|
|
677
|
+
if (link.lifecycle !== "retired" && (observation.state !== "unknown" || observation.valid_to != null || stateHash(observation) !== link.observation_hash))
|
|
678
|
+
throw new StateRefusal("conflict", "observation changed or is no longer eligible; re-read before linking");
|
|
679
|
+
const prior = getHere(link.id);
|
|
680
|
+
if (prior?.lifecycle === "retired" && link.lifecycle !== "retired" && request.expected_version === null)
|
|
681
|
+
throw new StateRefusal("conflict", "retired observation link requires an explicit expected_version to reactivate");
|
|
682
|
+
// Repeated reads and different agents do not rewrite an identical association.
|
|
683
|
+
// Preserve its first author/evidence time and avoid index/Git work on replay.
|
|
684
|
+
if (prior && prior.from === link.from && prior.to === link.to && prior.type === link.type && prior.reason === link.reason
|
|
685
|
+
&& prior.observation_hash === link.observation_hash && prior.lifecycle === link.lifecycle
|
|
686
|
+
&& prior.evidence && link.evidence && externalKey(prior.evidence) === externalKey(link.evidence) && prior.evidence.content_hash === link.evidence.content_hash) {
|
|
687
|
+
replayLink = prior;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
608
690
|
const id = record.id;
|
|
609
691
|
assertExternalIdentity(store, request.principal, request.scope, facet, record);
|
|
610
692
|
/** The normalized PAYLOAD hash: what idempotency recognizes on a re-send. */
|
|
611
693
|
const hash = stateHash(record);
|
|
612
|
-
const ledger = readLedger(hunchDir, request.scope);
|
|
694
|
+
const ledger = opts.ledgerCache?.ledger ?? readLedger(hunchDir, request.scope);
|
|
695
|
+
if (opts.ledgerCache)
|
|
696
|
+
opts.ledgerCache.ledger = ledger;
|
|
613
697
|
const durability = () => opts.flush?.(isPrivate, `nuryel: write ${id}`) ?? "local";
|
|
614
698
|
/** The result reports the record ON FILE and its hash — the store may enrich a record on put
|
|
615
699
|
* (a private-mode decision gains `valid_from`), and a writer that goes on to rest a receipt
|
|
616
700
|
* on this record must hold the hash a reader will verify, never a pre-store one. */
|
|
617
701
|
const result = (outcome, conflict = null, rid = id) => {
|
|
618
|
-
const onFile =
|
|
702
|
+
const onFile = getHere(rid) ?? record;
|
|
619
703
|
return WriteResultSchema.parse({ schema: STATE_WRITE_VERSION, record_id: rid, record_hash: stateHash(onFile), durability: durability(), outcome, conflict, record: onFile });
|
|
620
704
|
};
|
|
621
705
|
// Idempotency: the same key replays the original; the same key with a different payload
|
|
@@ -631,9 +715,14 @@ export function writeState(store, input, opts = {}) {
|
|
|
631
715
|
const where = differing.length ? ` — this payload differs in: ${differing.join(", ")}` : (seen.record_id !== id ? ` — this payload derives a different identity (${id})` : "");
|
|
632
716
|
throw new StateRefusal("idempotency", `idempotency key "${request.idempotency_key}" was already used for ${seen.record_id}${where}. A key names ONE request payload: re-send the original payload to replay it, or use a new key to write this payload (the record keeps its derived id and is updated in place).`, { incumbent_id: seen.record_id, reason: "idempotency key reused with a different payload" });
|
|
633
717
|
}
|
|
634
|
-
const existing =
|
|
718
|
+
const existing = getHere(id);
|
|
719
|
+
if (facet === 'derived') {
|
|
720
|
+
const review = record.review;
|
|
721
|
+
if (review && stateHash(review) !== stateHash(existing?.review ?? null) && review.by !== request.principal.id)
|
|
722
|
+
throw new StateRefusal('malformed', 'reviewer must be the initiating principal');
|
|
723
|
+
}
|
|
635
724
|
if (existing && stateHash(existing) === hash) {
|
|
636
|
-
appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: id, record_hash: hash, payload_hash: hash, facet } }, now);
|
|
725
|
+
appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: id, record_hash: hash, payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
|
|
637
726
|
return result("replayed");
|
|
638
727
|
}
|
|
639
728
|
if (existing && request.expected_version !== null) {
|
|
@@ -643,6 +732,11 @@ export function writeState(store, input, opts = {}) {
|
|
|
643
732
|
if (!ok)
|
|
644
733
|
throw new StateRefusal("conflict", `expected_version does not match the incumbent ${id}`, { incumbent_id: id, reason: "expected_version mismatch" });
|
|
645
734
|
}
|
|
735
|
+
if (replayLink) {
|
|
736
|
+
const recordHash = stateHash(replayLink);
|
|
737
|
+
appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: replayLink.id, record_hash: recordHash, payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
|
|
738
|
+
return WriteResultSchema.parse({ schema: STATE_WRITE_VERSION, record_id: replayLink.id, record_hash: recordHash, record: replayLink, outcome: "replayed", conflict: null, durability: "local" });
|
|
739
|
+
}
|
|
646
740
|
// human-correction-outranks-agent-writes: what a human confirmed, an agent does not rewrite.
|
|
647
741
|
// Allowed for an agent: a replay (the same facts, the tier downgrade aside), a derived statement
|
|
648
742
|
// written back stale with the external cause that moved (the writer's currentness duty), a
|
|
@@ -670,7 +764,7 @@ export function writeState(store, input, opts = {}) {
|
|
|
670
764
|
};
|
|
671
765
|
const verdict = guard(existing, "overwrite");
|
|
672
766
|
if (verdict === "replay") {
|
|
673
|
-
appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: id, record_hash: stateHash(existing), payload_hash: hash, facet } }, now);
|
|
767
|
+
appendChanges(hunchDir, request.scope, [], { key: request.idempotency_key, entry: { record_id: id, record_hash: stateHash(existing), payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
|
|
674
768
|
return result("replayed");
|
|
675
769
|
}
|
|
676
770
|
if (verdict === "keep-provenance")
|
|
@@ -719,15 +813,15 @@ export function writeState(store, input, opts = {}) {
|
|
|
719
813
|
const closedBy = facet === "commitments" ? assertClosedBy(store, request.principal, record) : null;
|
|
720
814
|
store.putCapture(facet, record, isPrivate);
|
|
721
815
|
/** What is on file now — the hash every event, ref and result carries. */
|
|
722
|
-
const onFileHash = stateHash(
|
|
816
|
+
const onFileHash = stateHash(getHere(id) ?? record);
|
|
723
817
|
const changes = [];
|
|
724
818
|
const cause = closedBy ? { kind: "receipt", receipt_id: closedBy } : request.cause ?? { kind: "write", principal: request.principal.id };
|
|
725
819
|
// A current derived statement written back as stale is an INVALIDATION, not an update: the
|
|
726
820
|
// ledger says so, and names the external pointer that moved when the writer gives one.
|
|
727
|
-
const invalidated = facet === "derived" && !!existing && existing.state === "current" && record.state === "stale";
|
|
821
|
+
const invalidated = facet === "derived" && !!existing && (existing.state === "current" || existing.state === "unknown") && record.state === "stale";
|
|
728
822
|
const invalidates = facet === "receipts" ? record.invalidates : [];
|
|
729
823
|
// An entity leaving service is a `retired` change (a merge names the survivor in the record).
|
|
730
|
-
const retired = facet === "entities" && record.lifecycle === "retired" && (!existing || existing.lifecycle !== "retired");
|
|
824
|
+
const retired = (facet === "entities" || facet === "relationships") && record.lifecycle === "retired" && (!existing || existing.lifecycle !== "retired");
|
|
731
825
|
const subject = subjectOf(facet, record);
|
|
732
826
|
if (supersedes) {
|
|
733
827
|
const closed = closeWindow(store, facet, supersedes, id, now, isPrivate);
|
|
@@ -737,8 +831,9 @@ export function writeState(store, input, opts = {}) {
|
|
|
737
831
|
}
|
|
738
832
|
}
|
|
739
833
|
changes.push({ facet, record_id: id, record_hash: onFileHash, change: invalidated ? "invalidated" : retired ? "retired" : existing ? "updated" : "created", subject, invalidates: invalidated && subject ? [subject] : invalidates, cause });
|
|
740
|
-
appendChanges(hunchDir, request.scope, changes, { key: request.idempotency_key, entry: { record_id: id, record_hash: onFileHash, payload_hash: hash, facet } }, now);
|
|
741
|
-
|
|
834
|
+
appendChanges(hunchDir, request.scope, changes, { key: request.idempotency_key, entry: { record_id: id, record_hash: onFileHash, payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
|
|
835
|
+
if (!opts.deferReindex)
|
|
836
|
+
store.reindex();
|
|
742
837
|
return result(supersedes ? "superseded" : existing ? "updated" : "created");
|
|
743
838
|
}
|
|
744
839
|
// ---- subscribe -----------------------------------------------------------------------------
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
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
|
+
import { isCredentialFreeValue } from "../core/provenance.js";
|
|
3
|
+
import { StateRefusal, stateHomeFor, writeState } from "./stateBinding.js";
|
|
4
|
+
/** The caller selects relevant atomic claims; this deterministic boundary checks evidence
|
|
5
|
+
* fidelity and deduplication. It does not pretend to prove semantic entailment or relevance.
|
|
6
|
+
* Both bindings hold the partition write lock over lookup AND write. */
|
|
7
|
+
export function captureState(store, input, opts = {}) {
|
|
8
|
+
const request = CaptureRequestSchema.parse(input);
|
|
9
|
+
if (!request.principal.grants.some(s => scopePath(s) === scopePath(request.scope)))
|
|
10
|
+
throw new StateRefusal("outside-grants", "capture scope is outside the principal's grants");
|
|
11
|
+
const { home } = stateHomeFor(store, request.scope);
|
|
12
|
+
const statement = normalizeAssertion(request.statement);
|
|
13
|
+
if (![statement, request.relevance.reason, ...request.evidence.map(e => e.excerpt)].every(isCredentialFreeValue))
|
|
14
|
+
throw new StateRefusal("malformed", "captured content must not contain credential material");
|
|
15
|
+
const evidence = request.evidence.map(e => {
|
|
16
|
+
if (!e.source_text.includes(e.excerpt))
|
|
17
|
+
throw new StateRefusal("malformed", "every excerpt must occur exactly in its supplied source text");
|
|
18
|
+
const hash = opts.sourceHashes?.get(e.source_text) ?? stateHash(e.source_text);
|
|
19
|
+
opts.sourceHashes?.set(e.source_text, hash);
|
|
20
|
+
if (e.ref.content_hash && e.ref.content_hash !== hash)
|
|
21
|
+
throw new StateRefusal("malformed", "source text does not match its declared content hash");
|
|
22
|
+
return { ref: { ...e.ref, object_key: canonicalObjectKey(e.ref.object_key), content_hash: hash }, excerpt: e.excerpt };
|
|
23
|
+
});
|
|
24
|
+
// Ignore read time, writer and unrelated source text when deduplicating an assertion.
|
|
25
|
+
// A different excerpt, statement, source identity or subject is a distinct observation.
|
|
26
|
+
const transform = captureTransform(request.scope, request.subject, statement, evidence.map(e => ({ source: externalKey(e.ref), excerpt: e.excerpt })));
|
|
27
|
+
const id = derivedId({ scope: request.scope, subject: request.subject, transform_version: transform, dependencies: [] });
|
|
28
|
+
const incumbent = store.getStateDirect("derived", id, home);
|
|
29
|
+
if (incumbent) {
|
|
30
|
+
assertDerivedState(incumbent);
|
|
31
|
+
if (incumbent.transform_version !== transform || scopePath(incumbent.scope) !== scopePath(request.scope) || incumbent.subject !== request.subject)
|
|
32
|
+
throw new StateRefusal("conflict", "capture identity collision; incumbent preserved");
|
|
33
|
+
// Never revive stale or human-corrected evidence, replace its author, or claim a new
|
|
34
|
+
// observation because another agent saw the same excerpt. Return what actually exists.
|
|
35
|
+
return { schema: STATE_WRITE_VERSION, record_id: incumbent.id, record_hash: stateHash(incumbent), durability: "local", outcome: "replayed", conflict: null, record: incumbent };
|
|
36
|
+
}
|
|
37
|
+
const refs = [...new Map(evidence.map(e => [stateHash(e.ref), e.ref])).entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, ref]) => ref);
|
|
38
|
+
const content = JSON.stringify({ schema: "nuryel.observation-content/1", statement, relevance: request.relevance,
|
|
39
|
+
evidence: evidence.map(e => ({ source: externalKey(e.ref), excerpt: e.excerpt })), captured_by: request.principal.id });
|
|
40
|
+
const record = {
|
|
41
|
+
schema: "nuryel.derived/1", scope: request.scope, subject: request.subject, content, content_hash: stateHash(content),
|
|
42
|
+
dependencies: refs.map(ref => ({ kind: "external", ref })), transform_version: transform,
|
|
43
|
+
computed_at: (opts.now ?? (() => new Date()))().toISOString(), valid_to: null, state: "unknown",
|
|
44
|
+
provenance: { source: "agent_recorded", confidence: 0.8, evidence: [`captured by ${request.principal.id}`, ...refs.map(externalKey)] },
|
|
45
|
+
};
|
|
46
|
+
return writeState(store, { schema: STATE_WRITE_VERSION, principal: request.principal, scope: request.scope, facet: "derived", record, idempotency_key: transform }, opts);
|
|
47
|
+
}
|
|
48
|
+
/** Bounded partial-success batch. The caller holds the same partition lock as writeState.
|
|
49
|
+
* Every result has its input index; a refused claim never hides a later valid new detail.
|
|
50
|
+
* A duplicate-only batch performs no record, ledger, index or Git writes. */
|
|
51
|
+
export function captureBatchState(store, input, opts = {}) {
|
|
52
|
+
const request = CaptureBatchRequestSchema.parse(input);
|
|
53
|
+
if (!request.principal.grants.some(s => scopePath(s) === scopePath(request.scope)))
|
|
54
|
+
throw new StateRefusal("outside-grants", "capture scope is outside the principal's grants");
|
|
55
|
+
if (!request.observations.length && !request.reviews?.length)
|
|
56
|
+
throw new StateRefusal("malformed", "capture batch must contain observations or reviews");
|
|
57
|
+
const { home, isPrivate } = stateHomeFor(store, request.scope);
|
|
58
|
+
const sourceHashes = new Map();
|
|
59
|
+
const ledgerCache = {};
|
|
60
|
+
const results = [];
|
|
61
|
+
const reviews = [];
|
|
62
|
+
let changed = false;
|
|
63
|
+
try {
|
|
64
|
+
// Review before capture: a replay in the same batch must see the withdrawn state.
|
|
65
|
+
for (const [index, review] of (request.reviews ?? []).entries()) {
|
|
66
|
+
try {
|
|
67
|
+
const record = store.getStateDirect("derived", review.record_id, home);
|
|
68
|
+
if (!record || scopePath(record.scope) !== scopePath(request.scope) || !record.transform_version.startsWith('agent-capture/1:'))
|
|
69
|
+
throw new StateRefusal('conflict', 'captured observation is absent from this partition');
|
|
70
|
+
if (!isCredentialFreeValue(review.reason))
|
|
71
|
+
throw new StateRefusal('malformed', 'review reason contains credential material');
|
|
72
|
+
const evidence = review.evidence.map(e => {
|
|
73
|
+
const source = request.sources[e.source];
|
|
74
|
+
if (!source || !source.source_text.includes(e.excerpt) || !isCredentialFreeValue(e.excerpt))
|
|
75
|
+
throw new StateRefusal('malformed', 'review excerpt must occur exactly in the supplied source');
|
|
76
|
+
const hash = sourceHashes.get(source.source_text) ?? stateHash(source.source_text);
|
|
77
|
+
sourceHashes.set(source.source_text, hash);
|
|
78
|
+
if (source.ref.content_hash && source.ref.content_hash !== hash)
|
|
79
|
+
throw new StateRefusal('malformed', 'review source hash mismatch');
|
|
80
|
+
const ref = { ...source.ref, object_key: canonicalObjectKey(source.ref.object_key), content_hash: hash };
|
|
81
|
+
const original = record.dependencies.find(d => d.kind === 'external' && externalKey(d.ref) === externalKey(ref));
|
|
82
|
+
if (!original || original.kind !== 'external' || original.ref.content_hash === hash)
|
|
83
|
+
throw new StateRefusal('conflict', 'review must cite a changed source the observation actually depends on');
|
|
84
|
+
return { ref, excerpt: e.excerpt };
|
|
85
|
+
});
|
|
86
|
+
const identity = stateHash({ record_id: record.id, expected_hash: review.expected_hash, reason: review.reason, evidence: evidence.map(e => ({ source: externalKey(e.ref), hash: e.ref.content_hash, excerpt: e.excerpt })) });
|
|
87
|
+
// A safe retry returns the same withdrawal without changing its reviewer/time.
|
|
88
|
+
const old = record.review;
|
|
89
|
+
if (record.state === 'stale' && old && old.previous_hash === review.expected_hash && old.reason === review.reason && stateHash(old.evidence.map(e => ({ source: externalKey(e.ref), hash: e.ref.content_hash, excerpt: e.excerpt }))) === stateHash(evidence.map(e => ({ source: externalKey(e.ref), hash: e.ref.content_hash, excerpt: e.excerpt })))) {
|
|
90
|
+
reviews.push({ index, status: 'saved', result: { schema: STATE_WRITE_VERSION, record_id: record.id, record_hash: stateHash(record), record: record, durability: 'local', outcome: 'replayed', conflict: null } });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (record.state !== 'unknown' || record.valid_to != null || stateHash(record) !== review.expected_hash)
|
|
94
|
+
throw new StateRefusal('conflict', 'observation changed since review; read it again');
|
|
95
|
+
const at = (opts.now ?? (() => new Date()))().toISOString();
|
|
96
|
+
const result = writeState(store, { schema: STATE_WRITE_VERSION, principal: request.principal, scope: request.scope, facet: 'derived',
|
|
97
|
+
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 });
|
|
99
|
+
changed ||= result.outcome !== 'replayed';
|
|
100
|
+
reviews.push({ index, status: 'saved', result });
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (!(error instanceof StateRefusal))
|
|
104
|
+
throw error;
|
|
105
|
+
reviews.push({ index, status: 'refused', code: error.code, message: error.message });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
for (const [index, observation] of request.observations.entries()) {
|
|
109
|
+
try {
|
|
110
|
+
const evidence = observation.evidence.map(e => {
|
|
111
|
+
const source = request.sources[e.source];
|
|
112
|
+
if (!source)
|
|
113
|
+
throw new StateRefusal("malformed", `evidence source index ${e.source} is absent`);
|
|
114
|
+
return { ...source, excerpt: e.excerpt };
|
|
115
|
+
});
|
|
116
|
+
const result = captureState(store, { schema: STATE_CAPTURE_VERSION, principal: request.principal, scope: request.scope, ...observation, evidence }, { now: opts.now, sourceHashes, ledgerCache, deferReindex: true });
|
|
117
|
+
changed ||= result.outcome !== "replayed";
|
|
118
|
+
results.push({ index, status: "saved", result });
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (!(error instanceof StateRefusal))
|
|
122
|
+
throw error;
|
|
123
|
+
results.push({ index, status: "refused", code: error.code, message: error.message });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
// A filesystem failure may occur after an atomic record write but before its
|
|
129
|
+
// result; refresh the derived index before propagating the failure to the caller.
|
|
130
|
+
changed = true;
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
if (changed)
|
|
135
|
+
store.reindex();
|
|
136
|
+
}
|
|
137
|
+
if (changed) {
|
|
138
|
+
const durability = opts.flush?.(isPrivate, `nuryel: capture ${results.filter(r => r.status === "saved" && r.result.outcome === "created").length} observations`) ?? "local";
|
|
139
|
+
for (const item of [...results, ...reviews])
|
|
140
|
+
if (item.status === "saved")
|
|
141
|
+
item.result.durability = durability;
|
|
142
|
+
}
|
|
143
|
+
return { schema: STATE_CAPTURE_BATCH_VERSION, results, ...(request.reviews ? { reviews } : {}) };
|
|
144
|
+
}
|
|
145
|
+
//# sourceMappingURL=stateCapture.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.31.
|
|
10
|
+
"version": "1.31.1",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
15
15
|
"identifier": "@davesheffer/hunch",
|
|
16
|
-
"version": "1.31.
|
|
16
|
+
"version": "1.31.1",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|