@davesheffer/hunch 1.32.8 → 1.33.0

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