@davesheffer/hunch 1.23.3 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,250 @@
1
+ /**
2
+ * nuryel.state/1 — the ONE contract every orchestrator and agent speaks to the state layer.
3
+ *
4
+ * Direction (private records vision.deterministic-state-layer, architecture.single-integration-
5
+ * surface): agents are probabilistic, organizations need deterministic state, Nuryel holds it.
6
+ * Protocols are bindings of this contract, never separate integrations. This module is the
7
+ * contract FROZEN AS CODE — the verbs, canonical hashing, id derivation and invariants. The
8
+ * record schemas (facets) live in ./stateRecords.js so the store's kind registry can import
9
+ * them without a cycle; they are re-exported here so callers see one contract module.
10
+ *
11
+ * Facets of organizational state (each maps to a record kind):
12
+ * decided — Decision (exists) in force — Constraint / valid_to (exists)
13
+ * done — ActionReceipt committed — Commitment
14
+ * changed — ExternalRef version pointer current — DerivedState with dependencies
15
+ * entity / relationship — external entities and their links (Landscape-shaped)
16
+ * DNA — hunch.project-dna/1 profiles keyed by scope (exists; scope keying is new)
17
+ *
18
+ * Three verbs: read (with a delivery receipt), write (provenance + idempotency, returns
19
+ * durability), subscribe (changes to what the caller holds). Invariants are exported as
20
+ * assertions so bindings and tests enforce them, not prose.
21
+ *
22
+ * Compatibility: additive. No existing record changes shape; `scope` on legacy records defaults
23
+ * to the repository scope; the new facets are new record kinds an older reader ignores. The
24
+ * schema version of the JSON store is untouched. Verbs are not wired into the store, CLI or MCP
25
+ * here; bindings are generated from these schemas in a later step.
26
+ */
27
+ import { createHash } from "node:crypto";
28
+ import { z } from "zod";
29
+ import { compareCodeUnits } from "./canonicalOrder.js";
30
+ import { DELIVERY_PROFILES } from "./delivery.js";
31
+ import { ScopeSchema, scopePath, DependencyRefSchema, ExternalRefSchema, RECEIPT_SCHEMA_VERSION, COMMITMENT_SCHEMA_VERSION, DERIVED_SCHEMA_VERSION, ENTITY_SCHEMA_VERSION, RELATIONSHIP_SCHEMA_VERSION, } from "./stateRecords.js";
32
+ export * from "./stateRecords.js";
33
+ export const STATE_CONTRACT_VERSION = "nuryel.state/1";
34
+ export const STATE_READ_VERSION = "nuryel.state.read/1";
35
+ export const STATE_WRITE_VERSION = "nuryel.state.write/1";
36
+ export const STATE_SUBSCRIBE_VERSION = "nuryel.state.subscribe/1";
37
+ /** Capabilities a server advertises; a client that needs one the server lacks gets a typed
38
+ * `unsupported`, never a compatible-looking degraded answer. */
39
+ export const STATE_CAPABILITIES = [
40
+ STATE_READ_VERSION, STATE_WRITE_VERSION, STATE_SUBSCRIBE_VERSION,
41
+ RECEIPT_SCHEMA_VERSION, COMMITMENT_SCHEMA_VERSION, DERIVED_SCHEMA_VERSION, ENTITY_SCHEMA_VERSION, RELATIONSHIP_SCHEMA_VERSION,
42
+ ];
43
+ const SHA256 = /^sha256:[a-f0-9]{64}$/;
44
+ // Explicit classes, no `i` flag: the pattern must survive zod → JSON schema for MCP output validation.
45
+ const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,199}$/;
46
+ const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/;
47
+ // ---- principal --------------------------------------------------------------------------
48
+ /** Who is reading or writing. Grants are the scopes the principal may see; authorization is
49
+ * decided BEFORE retrieval against these, never after ranking. */
50
+ export const PrincipalSchema = z.object({
51
+ id: z.string().regex(TOKEN),
52
+ kind: z.enum(["human", "agent", "service"]),
53
+ display: z.string().max(256).optional(),
54
+ grants: z.array(ScopeSchema).min(1).max(64),
55
+ }).strict();
56
+ export const STATE_FACETS = ["decisions", "constraints", "bugs", "findings", "receipts", "commitments", "derived", "entities", "relationships"];
57
+ // ---- verbs ------------------------------------------------------------------------------
58
+ export const ReadRequestSchema = z.object({
59
+ schema: z.literal(STATE_READ_VERSION),
60
+ principal: PrincipalSchema,
61
+ scope: ScopeSchema,
62
+ subject: z.string().max(512).optional(),
63
+ task: z.string().max(4096).optional(),
64
+ profile: z.enum(DELIVERY_PROFILES).optional(),
65
+ budget_tokens: z.number().int().min(200).max(200_000).optional(),
66
+ facets: z.array(z.enum(STATE_FACETS)).max(STATE_FACETS.length).optional(),
67
+ }).strict();
68
+ const StateRefSchema = z.object({
69
+ facet: z.enum(STATE_FACETS),
70
+ id: z.string().min(1).max(2048),
71
+ record_hash: z.string().regex(SHA256),
72
+ scope: ScopeSchema,
73
+ }).strict();
74
+ /** The system-of-record answer for a subject: what is true now, on what it rests, what would
75
+ * invalidate it. Carried beside the existing delivery envelope, under the same receipt. */
76
+ export const StateOfRecordSchema = z.object({
77
+ subject: z.string().max(512),
78
+ current: z.array(StateRefSchema).max(256),
79
+ in_force: z.array(StateRefSchema).max(256),
80
+ done: z.array(StateRefSchema).max(256),
81
+ depends_on: z.array(DependencyRefSchema).max(1024),
82
+ invalidated_by: z.array(z.string().max(512)).max(256),
83
+ }).strict();
84
+ export const ReadResponseSchema = z.object({
85
+ schema: z.literal(STATE_READ_VERSION),
86
+ receipt_id: z.string().regex(/^hdr_[a-f0-9]{24}$/).describe("the delivery envelope's receipt"),
87
+ scope: ScopeSchema,
88
+ state_of_record: StateOfRecordSchema.nullable(),
89
+ /** Scopes the principal asked about but is not granted — named, never silently dropped. */
90
+ denied_scopes: z.array(ScopeSchema).max(64).default([]),
91
+ }).strict();
92
+ export const WriteRequestSchema = z.object({
93
+ schema: z.literal(STATE_WRITE_VERSION),
94
+ principal: PrincipalSchema,
95
+ scope: ScopeSchema,
96
+ facet: z.enum(STATE_FACETS),
97
+ record: z.record(z.string(), z.unknown()),
98
+ idempotency_key: z.string().min(8).max(256),
99
+ expected_version: z.union([z.string().max(256), z.number().int().nonnegative()]).nullable().default(null),
100
+ supersedes: z.string().max(2048).optional(),
101
+ }).strict();
102
+ export const DURABILITY = ["pushed", "committed", "local"];
103
+ export const WriteResultSchema = z.object({
104
+ schema: z.literal(STATE_WRITE_VERSION),
105
+ record_id: z.string().min(1).max(2048),
106
+ record_hash: z.string().regex(SHA256),
107
+ durability: z.enum(DURABILITY),
108
+ outcome: z.enum(["created", "updated", "replayed", "superseded"]),
109
+ conflict: z.object({ incumbent_id: z.string().max(2048), reason: z.string().max(512) }).strict().nullable().default(null),
110
+ }).strict();
111
+ export const SubscribeRequestSchema = z.object({
112
+ schema: z.literal(STATE_SUBSCRIBE_VERSION),
113
+ principal: PrincipalSchema,
114
+ scope: ScopeSchema,
115
+ after_seq: z.number().int().nonnegative(),
116
+ subjects: z.array(z.string().max(512)).max(256).optional(),
117
+ facets: z.array(z.enum(STATE_FACETS)).max(STATE_FACETS.length).optional(),
118
+ }).strict();
119
+ export const ChangeEventSchema = z.object({
120
+ schema: z.literal(STATE_SUBSCRIBE_VERSION),
121
+ seq: z.number().int().positive(),
122
+ at: z.string().regex(ISO),
123
+ scope: ScopeSchema,
124
+ facet: z.enum(STATE_FACETS),
125
+ record_id: z.string().min(1).max(2048),
126
+ record_hash: z.string().regex(SHA256),
127
+ change: z.enum(["created", "updated", "superseded", "retired", "invalidated"]),
128
+ /** The record's subject (entity id / topic / external object key), so a subscriber can
129
+ * filter by what it holds without reading every record. Optional: legacy facets may lack one. */
130
+ subject: z.string().max(512).optional(),
131
+ invalidates: z.array(z.string().max(512)).max(256).default([]),
132
+ cause: z.union([
133
+ z.object({ kind: z.literal("receipt"), receipt_id: z.string().regex(/^nrc_[a-f0-9]{24}$/) }).strict(),
134
+ z.object({ kind: z.literal("external"), ref: ExternalRefSchema }).strict(),
135
+ z.object({ kind: z.literal("write"), principal: z.string().regex(TOKEN) }).strict(),
136
+ ]).optional(),
137
+ }).strict();
138
+ export const CapabilityNegotiationSchema = z.object({
139
+ protocol: z.literal(STATE_CONTRACT_VERSION),
140
+ capabilities: z.array(z.string().max(128)).max(64),
141
+ }).strict();
142
+ export function negotiate(offered, required = STATE_CAPABILITIES) {
143
+ const have = new Set(offered);
144
+ const supported = [];
145
+ const unsupported = [];
146
+ for (const cap of required)
147
+ (have.has(cap) ? supported : unsupported).push(cap);
148
+ return { supported, unsupported };
149
+ }
150
+ // ---- canonical form, hashes, ids -----------------------------------------------------------
151
+ /** Canonical JSON: keys sorted by code unit at every level, `undefined` dropped, non-finite
152
+ * numbers rejected. Two records with the same facts hash the same regardless of who wrote them. */
153
+ export function canonicalize(value) {
154
+ if (value === null || typeof value === "string" || typeof value === "boolean")
155
+ return value;
156
+ if (typeof value === "number") {
157
+ if (!Number.isFinite(value))
158
+ throw new Error("canonical form rejects non-finite numbers");
159
+ return value;
160
+ }
161
+ if (Array.isArray(value))
162
+ return value.map(canonicalize);
163
+ if (typeof value === "object") {
164
+ const out = {};
165
+ for (const key of Object.keys(value).sort(compareCodeUnits)) {
166
+ const v = value[key];
167
+ if (v !== undefined)
168
+ out[key] = canonicalize(v);
169
+ }
170
+ return out;
171
+ }
172
+ throw new Error(`canonical form rejects ${typeof value}`);
173
+ }
174
+ export function stateHash(value) {
175
+ return `sha256:${createHash("sha256").update(JSON.stringify(canonicalize(value))).digest("hex")}`;
176
+ }
177
+ const idFrom = (prefix, seed) => `${prefix}_${createHash("sha256").update(JSON.stringify(canonicalize(seed))).digest("hex").slice(0, 24)}`;
178
+ /** Identity = what makes two receipts the same action: who did what to which object, with which
179
+ * request. Re-sending the same action replays the same receipt instead of minting a second one. */
180
+ export function actionReceiptId(r) {
181
+ return idFrom("nrc", { scope: r.scope, actor: r.actor, action_kind: r.action_kind, target: { system: r.target.system, object_type: r.target.object_type, object_key: r.target.object_key }, request_fingerprint: r.request_fingerprint, idempotency_key: r.idempotency_key ?? null });
182
+ }
183
+ export function commitmentId(c) {
184
+ return idFrom("ncm", { scope: c.scope, subject: c.subject, title: c.title.trim(), owner: c.owner, due: c.due });
185
+ }
186
+ export function derivedId(d) {
187
+ return idFrom("nds", { scope: d.scope, subject: d.subject, transform_version: d.transform_version, dependencies: d.dependencies.map((dep) => stateHash(dep)).sort(compareCodeUnits) });
188
+ }
189
+ // ---- invariants --------------------------------------------------------------------------
190
+ export const STATE_INVARIANTS = [
191
+ { id: "authorization-before-retrieval", statement: "A record outside the principal's grants never enters a candidate set; filtering after ranking is a violation." },
192
+ { id: "similarity-never-authorizes", statement: "Semantic similarity may find candidates; only deterministic dependency, freshness and grant checks make a record current or visible." },
193
+ { id: "never-in-request-path", statement: "Nuryel is read and written by orchestrators; it never proxies, fetches or stores on an agent's behalf. A gate that checks state and refuses is allowed; an intermediary is not." },
194
+ { id: "provenance-on-every-write", statement: "Every write carries provenance and an idempotency key; a replay returns the original record, never a duplicate." },
195
+ { id: "one-live-decision-per-topic", statement: "A second live decision on a topic is refused with the incumbent named; supersession is explicit." },
196
+ { id: "external-truth-stays-external", statement: "External systems remain authoritative for their own content; Nuryel holds credential-free pointers, versions and hashes, never mirrored bodies." },
197
+ { id: "derived-state-carries-dependencies", statement: "A derived statement without dependencies cannot be invalidated and is therefore not state." },
198
+ ];
199
+ const grantKey = (scope) => scopePath(scope);
200
+ /** authorization-before-retrieval, checked on the way OUT as well: nothing in a read response
201
+ * may sit outside the principal's grants. Bindings must also filter on the way in. */
202
+ export function assertReadWithinGrants(principal, response) {
203
+ const granted = new Set(principal.grants.map(grantKey));
204
+ if (!granted.has(grantKey(response.scope)))
205
+ throw new Error(`read response scope ${grantKey(response.scope)} is outside the principal's grants`);
206
+ const refs = response.state_of_record ? [...response.state_of_record.current, ...response.state_of_record.in_force, ...response.state_of_record.done] : [];
207
+ for (const ref of refs) {
208
+ if (!granted.has(grantKey(ref.scope)))
209
+ throw new Error(`state ref ${ref.id} in scope ${grantKey(ref.scope)} leaked outside the principal's grants`);
210
+ }
211
+ for (const denied of response.denied_scopes) {
212
+ if (granted.has(grantKey(denied)))
213
+ throw new Error(`denied scope ${grantKey(denied)} is actually granted — the response is inconsistent`);
214
+ }
215
+ }
216
+ /** provenance-on-every-write + scope agreement between the envelope and the record. */
217
+ export function assertWriteWellFormed(request) {
218
+ // Authorization first — before the record is even looked at.
219
+ if (!request.principal.grants.some((g) => grantKey(g) === grantKey(request.scope)))
220
+ throw new Error("write scope is outside the principal's grants");
221
+ const record = request.record;
222
+ if (!record.provenance || typeof record.provenance !== "object")
223
+ throw new Error("write record lacks provenance");
224
+ // Only a PARTITION scope on the record is compared: legacy constraints carry path globs
225
+ // under the same key, and those are not a partition claim.
226
+ if (ScopeSchema.safeParse(record.scope).success && stateHash(record.scope) !== stateHash(request.scope))
227
+ throw new Error("write record scope disagrees with the request scope");
228
+ }
229
+ /** derived-state-carries-dependencies + content integrity. */
230
+ export function assertDerivedState(d) {
231
+ if (d.dependencies.length === 0)
232
+ throw new Error("derived state without dependencies is not state");
233
+ if (stateHash(d.content) !== d.content_hash)
234
+ throw new Error("derived state content hash does not match its content");
235
+ }
236
+ /** Subscribe streams are strictly ordered per scope; a gap or regression means the caller must
237
+ * resynchronize instead of trusting what it holds. */
238
+ export function assertChangeSequence(events, afterSeq) {
239
+ let expected = afterSeq + 1;
240
+ for (const event of events) {
241
+ if (event.seq !== expected)
242
+ throw new Error(`change stream gap: expected seq ${expected}, got ${event.seq}`);
243
+ expected += 1;
244
+ }
245
+ }
246
+ /** A delivery envelope is the read receipt this contract reuses unchanged. */
247
+ export function receiptOf(envelope) {
248
+ return envelope.receipt_id;
249
+ }
250
+ //# sourceMappingURL=stateContract.js.map
@@ -0,0 +1,150 @@
1
+ /**
2
+ * nuryel.state/1 — the RECORD schemas (the facets that are new record kinds in the store).
3
+ *
4
+ * Kept separate from stateContract.ts (verbs, invariants, hashing) because the store's kind
5
+ * registry in types.ts must reference these schemas, and stateContract imports types.ts —
6
+ * this module imports only zod, the id helpers and the provenance leaf, so there is no cycle.
7
+ *
8
+ * Facets here: done (ActionReceipt), committed (Commitment), current (DerivedState with
9
+ * mandatory dependencies), entity / relationship (external, Landscape-shaped), plus the
10
+ * credential-free ExternalRef ("changed") and DependencyRef they share. Scope is the one
11
+ * graph's partition: organization › team › user › repository.
12
+ */
13
+ import { z } from "zod";
14
+ import { edgeId, resourceId } from "./ids.js";
15
+ import { ProvenanceSchema, isCredentialFreeValue } from "./provenance.js";
16
+ export const RECEIPT_SCHEMA_VERSION = "nuryel.receipt/1";
17
+ export const COMMITMENT_SCHEMA_VERSION = "nuryel.commitment/1";
18
+ export const DERIVED_SCHEMA_VERSION = "nuryel.derived/1";
19
+ export const ENTITY_SCHEMA_VERSION = "nuryel.entity/1";
20
+ export const RELATIONSHIP_SCHEMA_VERSION = "nuryel.relationship/1";
21
+ const SHA256 = /^sha256:[a-f0-9]{64}$/;
22
+ // Explicit classes, no `i` flag: the pattern must survive zod → JSON schema for MCP output validation.
23
+ const TOKEN = /^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,199}$/;
24
+ const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/;
25
+ const DAY = /^\d{4}-\d{2}-\d{2}$/;
26
+ const credentialFree = (label) => z.string().min(1).max(2048).refine(isCredentialFreeValue, { message: `${label} must not carry credential material` });
27
+ // ---- scope ------------------------------------------------------------------------------
28
+ /** organization › team › user › repository — partitions of ONE graph, not separate stores. */
29
+ export const SCOPE_KINDS = ["organization", "team", "user", "repository"];
30
+ export const ScopeSchema = z.object({
31
+ kind: z.enum(SCOPE_KINDS),
32
+ id: z.string().regex(TOKEN),
33
+ }).strict();
34
+ export const scopePath = (scope) => `${scope.kind}/${scope.id}`;
35
+ // ---- provenance pointer into an external system --------------------------------------------
36
+ /** "What changed" and "where this came from": a credential-free pointer to an object that
37
+ * stays authoritative in its own system. Nuryel never mirrors its content. */
38
+ export const ExternalRefSchema = z.object({
39
+ system: z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/),
40
+ object_type: z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/),
41
+ object_key: credentialFree("external object key").max(512),
42
+ version: credentialFree("external version").max(256).optional(),
43
+ content_hash: z.string().regex(SHA256).optional(),
44
+ observed_at: z.string().regex(ISO),
45
+ locator: credentialFree("external locator").optional(),
46
+ }).strict();
47
+ /** What a derived statement rests on. Exactly what a currentness check re-validates. */
48
+ export const DependencyRefSchema = z.discriminatedUnion("kind", [
49
+ z.object({ kind: z.literal("record"), id: z.string().regex(TOKEN), record_hash: z.string().regex(SHA256) }).strict(),
50
+ z.object({ kind: z.literal("external"), ref: ExternalRefSchema }).strict(),
51
+ z.object({ kind: z.literal("schema"), name: z.string().max(256), fingerprint: z.string().regex(SHA256) }).strict(),
52
+ ]);
53
+ // ---- facets ------------------------------------------------------------------------------
54
+ /** done — a side effect that happened. Never replayable as a read; idempotency is explicit. */
55
+ export const ActionReceiptSchema = z.object({
56
+ schema: z.literal(RECEIPT_SCHEMA_VERSION),
57
+ id: z.string().regex(/^nrc_[a-f0-9]{24}$/),
58
+ scope: ScopeSchema,
59
+ actor: z.string().regex(TOKEN).describe("principal id"),
60
+ action_kind: z.string().regex(/^[a-z][a-z0-9_]{0,63}$/),
61
+ target: ExternalRefSchema,
62
+ request_fingerprint: z.string().regex(SHA256),
63
+ idempotency_key: z.string().max(256).optional(),
64
+ state: z.enum(["requested", "succeeded", "failed", "unknown", "verified"]),
65
+ occurred_at: z.string().regex(ISO),
66
+ verified_at: z.string().regex(ISO).optional(),
67
+ result_fingerprint: z.string().regex(SHA256).optional(),
68
+ invalidates: z.array(z.string().max(512)).max(64).default([]),
69
+ provenance: ProvenanceSchema,
70
+ }).strict();
71
+ /** committed — an obligation with a due date and an in-force window. */
72
+ export const CommitmentSchema = z.object({
73
+ schema: z.literal(COMMITMENT_SCHEMA_VERSION),
74
+ id: z.string().regex(/^ncm_[a-f0-9]{24}$/),
75
+ scope: ScopeSchema,
76
+ subject: z.string().max(512).describe("entity id or stable subject key"),
77
+ title: z.string().min(1).max(512),
78
+ owner: z.string().regex(TOKEN).describe("principal id"),
79
+ due: z.string().regex(DAY),
80
+ status: z.enum(["open", "waiting", "done", "cancelled"]),
81
+ source: ExternalRefSchema.optional(),
82
+ evidence_excerpt: z.string().max(900).optional(),
83
+ valid_from: z.string().regex(ISO),
84
+ valid_to: z.string().regex(ISO).nullable().default(null),
85
+ provenance: ProvenanceSchema,
86
+ }).strict();
87
+ /** current — a statement that is true now, and on what it rests. Dependencies are mandatory:
88
+ * a derived statement without them cannot be invalidated and therefore cannot be trusted. */
89
+ export const DerivedStateSchema = z.object({
90
+ schema: z.literal(DERIVED_SCHEMA_VERSION),
91
+ id: z.string().regex(/^nds_[a-f0-9]{24}$/),
92
+ scope: ScopeSchema,
93
+ subject: z.string().max(512),
94
+ content: z.string().min(1).max(20_000),
95
+ content_hash: z.string().regex(SHA256),
96
+ dependencies: z.array(DependencyRefSchema).min(1).max(256),
97
+ transform_version: z.string().max(128),
98
+ computed_at: z.string().regex(ISO),
99
+ valid_to: z.string().regex(ISO).nullable().default(null),
100
+ state: z.enum(["current", "stale", "unknown"]),
101
+ provenance: ProvenanceSchema,
102
+ }).strict();
103
+ const AttributeValue = z.union([z.string().max(2048), z.number().finite(), z.boolean(), z.null()]);
104
+ /** entity — a customer, an incident, a thread: a non-code node, Landscape-shaped (kind-qualified
105
+ * id, lifecycle, provenance), with provenance pointers instead of mirrored content. Stored in
106
+ * an index file, like resources, because kind-qualified ids are not safe file names. */
107
+ export const ExternalEntitySchema = z.object({
108
+ schema: z.literal(ENTITY_SCHEMA_VERSION),
109
+ id: z.string().min(3).max(2048),
110
+ kind: z.string().regex(/^[a-z][a-z0-9_]{0,63}$/),
111
+ name: credentialFree("entity name").max(256),
112
+ scope: ScopeSchema,
113
+ refs: z.array(ExternalRefSchema).min(1).max(64),
114
+ attributes: z.record(z.string().max(128), AttributeValue).default({}),
115
+ lifecycle: z.enum(["active", "deprecated", "retired"]).default("active"),
116
+ provenance: ProvenanceSchema,
117
+ created_at: z.string().regex(ISO),
118
+ updated_at: z.string().regex(ISO),
119
+ }).strict().superRefine((entity, ctx) => {
120
+ const prefix = `${entity.kind}:`;
121
+ const key = entity.id.startsWith(prefix) ? entity.id.slice(prefix.length) : "";
122
+ if (!key.trim() || entity.id !== resourceId(entity.kind, key)) {
123
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["id"], message: "entity id must be a canonical kind-qualified identity" });
124
+ }
125
+ if (Object.keys(entity.attributes).length > 64)
126
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["attributes"], message: "attributes are a minimal projection, not a mirror" });
127
+ });
128
+ /** relationship — rides the same identity rule as the graph's edges. Index-file stored. */
129
+ export const StateRelationshipSchema = z.object({
130
+ schema: z.literal(RELATIONSHIP_SCHEMA_VERSION),
131
+ id: z.string().regex(/^edge_[a-f0-9]+$/),
132
+ from: z.string().min(1).max(2048),
133
+ to: z.string().min(1).max(2048),
134
+ type: z.string().regex(/^[a-z][a-z0-9_]{0,63}$/),
135
+ scope: ScopeSchema,
136
+ reason: z.string().max(1024).default(""),
137
+ provenance: ProvenanceSchema,
138
+ }).strict().superRefine((rel, ctx) => {
139
+ if (rel.id !== edgeId(rel.from, rel.to, rel.type))
140
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["id"], message: "relationship id must derive from its endpoints and type" });
141
+ });
142
+ /** DNA facet: existing Project DNA profiles, keyed by scope. No new schema — the reference only. */
143
+ export const DnaFacetRefSchema = z.object({
144
+ schema: z.literal("hunch.project-dna/1"),
145
+ scope: ScopeSchema,
146
+ profile_id: z.string().regex(/^pdna_[a-f0-9]{24}$/),
147
+ }).strict();
148
+ export const entityId = resourceId;
149
+ export const relationshipId = edgeId;
150
+ //# sourceMappingURL=stateRecords.js.map
@@ -8,14 +8,11 @@
8
8
  import { z } from "zod";
9
9
  import { createHash } from "node:crypto";
10
10
  import { findingId, resourceId, resourceRelationshipId } from "./ids.js";
11
- /** Where a fact came from and how much to trust it. Confidence tiers (DESIGN §4):
12
- * inferred < extracted < llm_draft < llm_draft+human_confirmed/derived. */
13
- export const ProvenanceSchema = z.object({
14
- source: z.string().describe("e.g. extracted | inferred | llm_draft | human_confirmed | test_failure+llm | derived"),
15
- confidence: z.number().min(0).max(1),
16
- evidence: z.array(z.string()).default([]).describe("file paths, commit ids, test ids backing the claim"),
17
- last_verified: z.string().optional().describe("ISO timestamp of last re-validation"),
18
- });
11
+ import { ProvenanceSchema, SENSITIVE_METADATA_KEY, isCredentialFreeText } from "./provenance.js";
12
+ import { ActionReceiptSchema, CommitmentSchema, DerivedStateSchema, ExternalEntitySchema, StateRelationshipSchema, } from "./stateRecords.js";
13
+ // Provenance and the credential-free text check live in the leaf module ./provenance.js so
14
+ // record schemas registered below can import them without a cycle; re-exported unchanged.
15
+ export { ProvenanceSchema, isCredentialFreeText };
19
16
  export const ComponentKind = z.enum(["service", "module", "layer", "external"]);
20
17
  /** Architecture node — a service / module / layer / external dependency. */
21
18
  export const ComponentSchema = z.object({
@@ -61,26 +58,6 @@ const MetadataValueSchema = z.union([
61
58
  z.null(),
62
59
  z.array(z.union([z.string().max(1024), z.number().finite(), z.boolean(), z.null()])).max(32),
63
60
  ]);
64
- const SENSITIVE_METADATA_KEY = /(^|[_-])(authorization|bearer|credential|password|passwd|private[_-]?key|secret|token|api[_-]?key)($|[_-])/i;
65
- const SENSITIVE_ASSIGNMENT = /\b(authorization|password|passwd|private[_-]?key|secret|access[_-]?token|refresh[_-]?token|api[_-]?key)\s*[:=]\s*[^\s,;]{4,}/i;
66
- const PRIVATE_KEY_BLOCK = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/i;
67
- const BEARER_VALUE = /\bBearer\s+[A-Za-z0-9._~+\/-]{12,}/i;
68
- /** Reject credential material while allowing ordinary architecture prose such as
69
- * "authentication service" or "secrets are managed externally". */
70
- export function isCredentialFreeText(value) {
71
- if (PRIVATE_KEY_BLOCK.test(value) || BEARER_VALUE.test(value) || SENSITIVE_ASSIGNMENT.test(value))
72
- return false;
73
- try {
74
- const url = new URL(value);
75
- if (url.username || url.password)
76
- return false;
77
- for (const [key] of url.searchParams)
78
- if (SENSITIVE_METADATA_KEY.test(key))
79
- return false;
80
- }
81
- catch { /* credential-free canonical locators need not be absolute URLs */ }
82
- return true;
83
- }
84
61
  function isCanonicalResourceIdentity(value) {
85
62
  const separator = value.indexOf(":");
86
63
  if (separator <= 0 || separator === value.length - 1)
@@ -613,7 +590,12 @@ export function landscapeDriftCandidateFinding(value) {
613
590
  });
614
591
  }
615
592
  /** The entity collections, keyed by their on-disk directory name. */
616
- export const ENTITY_KINDS = ["components", "resources", "edges", "symbols", "decisions", "bugs", "constraints", "runbooks", "findings"];
593
+ // nuryel.state/1 facets are ADDITIVE record kinds: a store without their directories
594
+ // loads exactly as before, and an older build ignores directories it does not know.
595
+ export const ENTITY_KINDS = [
596
+ "components", "resources", "edges", "symbols", "decisions", "bugs", "constraints", "runbooks", "findings",
597
+ "receipts", "commitments", "derived", "entities", "relationships",
598
+ ];
617
599
  export const SCHEMAS = {
618
600
  components: ComponentSchema,
619
601
  resources: ResourceSchema,
@@ -624,6 +606,11 @@ export const SCHEMAS = {
624
606
  constraints: ConstraintSchema,
625
607
  runbooks: RunbookSchema,
626
608
  findings: FindingSchema,
609
+ receipts: ActionReceiptSchema,
610
+ commitments: CommitmentSchema,
611
+ derived: DerivedStateSchema,
612
+ entities: ExternalEntitySchema,
613
+ relationships: StateRelationshipSchema,
627
614
  };
628
615
  /** Default provenance helper for deterministic (extracted) records. */
629
616
  export function extracted(confidence, evidence = []) {
@@ -46,6 +46,7 @@ export function renderHunchSection(store, root) {
46
46
  lines.push("**Consult Hunch via the `hunch_*` MCP tools — pick by MOMENT, not from memory:**");
47
47
  lines.push("");
48
48
  lines.push("**Orient (session/task start):**");
49
+ lines.push("- When the user asks to **update Hunch**, run `hunch update` from this repository root. It updates to the latest release and repairs all configured harness pins. Use `hunch update --global` to also update a global CLI alongside a repository dependency; reconnect active MCP sessions afterward.");
49
50
  lines.push("- `hunch_context(target)` — the minimal relevant slice for what you're about to do; a task phrase falls back to the closest graph matches. **Call FIRST.**");
50
51
  lines.push("- `hunch_structure(target?)` — the indexed shape of the repo/dir/file/symbol — orient from the graph, not grep rounds.");
51
52
  lines.push("- `hunch_runbook(task)` — the proven steps for a recurring task, before re-deriving them.");
@@ -54,6 +54,14 @@ const MEM_ENTRIES = [
54
54
  ".hunch/edges/",
55
55
  ".hunch/runbooks/",
56
56
  ".hunch/findings/",
57
+ // nuryel.state/1 record kinds (state facets)
58
+ ".hunch/receipts/",
59
+ ".hunch/commitments/",
60
+ ".hunch/derived/",
61
+ ".hunch/entities/",
62
+ ".hunch/relationships/",
63
+ // nuryel.state/1 per-scope change ledgers (subscribe stream + idempotency table)
64
+ ".hunch/changes/",
57
65
  ];
58
66
  function pathIsWithin(path, parent) {
59
67
  const rel = relative(parent, path);
@@ -13,6 +13,8 @@ import { z } from "zod";
13
13
  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
+ import { StateRefusal, SubscribeResponseSchema, capabilities, readState, subscribeState, writeState } from "../store/stateBinding.js";
17
+ import { ReadRequestSchema, ReadResponseSchema, WriteRequestSchema, WriteResultSchema, SubscribeRequestSchema, STATE_READ_VERSION, STATE_WRITE_VERSION, STATE_SUBSCRIBE_VERSION } from "../core/stateContract.js";
16
18
  import { selectEmbedder } from "../store/embedder.js";
17
19
  import { decisionId, findingId } from "../core/ids.js";
18
20
  import { buildCorrectionConstraint } from "../core/correction.js";
@@ -1672,6 +1674,79 @@ export function buildServerWithRootControl(initialRoot) {
1672
1674
  return err(`Failed to record finding: ${e.message}`);
1673
1675
  }
1674
1676
  });
1677
+ // -- nuryel.state/1 — the ONE contract, bound over MCP -------------------------
1678
+ // These four tools are a BINDING of src/store/stateBinding.ts, never a second
1679
+ // implementation: every rule (grants first, provenance + idempotency, one live
1680
+ // decision per topic, derived state carries dependencies, partition homing) lives
1681
+ // there and is shared with every other transport. Client-agnostic (con_e04226bd05).
1682
+ const stateRefusal = (e) => {
1683
+ if (e instanceof StateRefusal) {
1684
+ const conflict = e.conflict ? ` incumbent=${e.conflict.incumbent_id} (${e.conflict.reason})` : "";
1685
+ return err(`nuryel.state/1 refused [${e.code}]: ${e.message}.${conflict}`);
1686
+ }
1687
+ if (e instanceof z.ZodError)
1688
+ return err(`nuryel.state/1 malformed request: ${e.issues.map((i) => `${i.path.join(".") || "request"}: ${i.message}`).join("; ")}`);
1689
+ return err(`nuryel.state/1 failed: ${e.message}`);
1690
+ };
1691
+ const stateResult = (text, structured) => ({ content: [{ type: "text", text }], structuredContent: structured });
1692
+ server.registerTool("nuryel_capabilities", {
1693
+ title: "nuryel.state/1 — what this state layer supports",
1694
+ description: "Negotiate before depending on anything: returns the contract version, the capability list (verbs + record schemas), the repository partition this store serves, and which partition kinds it can hold. A capability you need that is missing here is a typed refusal on use, never a degraded answer.",
1695
+ inputSchema: {},
1696
+ }, async () => {
1697
+ const caps = capabilities(store);
1698
+ return stateResult(`${caps.protocol} · repository ${caps.repository.id} · partitions ${caps.partitions.join(", ")} · ${caps.capabilities.length} capabilities`, caps);
1699
+ });
1700
+ server.registerTool("nuryel_read", {
1701
+ title: "nuryel.state/1 read — the system-of-record answer for a subject",
1702
+ 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.",
1703
+ inputSchema: ReadRequestSchema.omit({ schema: true }).shape,
1704
+ outputSchema: ReadResponseSchema.shape,
1705
+ }, async (input) => {
1706
+ try {
1707
+ const { response, envelope } = readState(store, { schema: STATE_READ_VERSION, ...input });
1708
+ const sor = response.state_of_record;
1709
+ const summary = sor
1710
+ ? `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}`
1711
+ : "no subject — delivery envelope only";
1712
+ const deniedNote = response.denied_scopes.length ? `\ndenied scopes: ${response.denied_scopes.map((s) => `${s.kind}/${s.id}`).join(", ")}` : "";
1713
+ return stateResult(`${response.receipt_id} · ${summary}${deniedNote}\n\n${envelope.text}`, response);
1714
+ }
1715
+ catch (e) {
1716
+ return stateRefusal(e);
1717
+ }
1718
+ });
1719
+ server.registerTool("nuryel_write", {
1720
+ title: "nuryel.state/1 write — provenance + idempotency in, durability out",
1721
+ 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.",
1722
+ inputSchema: { ...WriteRequestSchema.omit({ schema: true }).shape, cwd: cwdHintField },
1723
+ outputSchema: WriteResultSchema.shape,
1724
+ }, async ({ cwd: _cwd, ...input }) => {
1725
+ try {
1726
+ const result = writeState(store, { schema: STATE_WRITE_VERSION, ...input }, {
1727
+ flush: (isPrivate, message) => flushCapture(store, hunchPaths(root).hunch, isPrivate, message, startupTeamRoute ?? undefined),
1728
+ });
1729
+ return stateResult(`${result.outcome} ${result.record_id} (${result.durability}) ${result.record_hash}`, result);
1730
+ }
1731
+ catch (e) {
1732
+ return stateRefusal(e);
1733
+ }
1734
+ });
1735
+ server.registerTool("nuryel_subscribe", {
1736
+ title: "nuryel.state/1 subscribe — the scope's ordered change stream after a cursor",
1737
+ 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.",
1738
+ inputSchema: SubscribeRequestSchema.omit({ schema: true }).shape,
1739
+ outputSchema: SubscribeResponseSchema.shape,
1740
+ }, async (input) => {
1741
+ try {
1742
+ const response = subscribeState(store, { schema: STATE_SUBSCRIBE_VERSION, ...input });
1743
+ const lines = response.events.map((e) => `${e.seq} ${e.at} ${e.change} ${e.facet}/${e.record_id}${e.invalidates.length ? ` invalidates ${e.invalidates.join(", ")}` : ""}`);
1744
+ return stateResult(`${response.scope.kind}/${response.scope.id} head_seq ${response.head_seq} · ${response.events.length} event(s)${response.filtered ? " (filtered)" : ""}\n${lines.join("\n")}`, response);
1745
+ }
1746
+ catch (e) {
1747
+ return stateRefusal(e);
1748
+ }
1749
+ });
1675
1750
  // -- hunch_findings (read: the open-observations ledger) --------------------
1676
1751
  server.registerTool("hunch_findings", {
1677
1752
  title: "Open findings for a scope",