@githolon/dsl 0.55.1 → 0.57.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.
@@ -239,6 +239,18 @@ Future<Object?> _invoke(
239
239
  return b.sync(admit: a['admit'] as bool);
240
240
  case 'lawStatus':
241
241
  return b.lawStatus(a['hash'] as String);
242
+ case 'offerDirective':
243
+ return b.offerDirective(
244
+ domain: a['domain'] as String,
245
+ directiveId: a['directiveId'] as String,
246
+ payload: a['payload'],
247
+ actor: a['actor'] as String?,
248
+ domainHash: a['domainHash'] as String?,
249
+ attestations: (a['attestations'] as List?)
250
+ ?.map((e) => (e as Map).cast<String, Object?>())
251
+ .toList(),
252
+ force: a['force'] as bool?,
253
+ );
242
254
  default:
243
255
  fail('corpus op not wired into the Dart bridge: $op');
244
256
  }
@@ -248,6 +260,7 @@ Future<Object?> _invoke(
248
260
  Object? _expectedTyped(String op, Map<String, Object?> result) {
249
261
  switch (op) {
250
262
  case 'offer':
263
+ case 'offerDirective':
251
264
  return result['head'];
252
265
  case 'query':
253
266
  case 'queryById':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.55.1",
3
+ "version": "0.57.0",
4
4
  "type": "module",
5
5
  "description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
package/src/attested.ts CHANGED
@@ -32,12 +32,21 @@ export {
32
32
  PARENT_TRUST_SENTINEL,
33
33
  foreignQuery,
34
34
  isTrustSourceDecl,
35
+ trustPolicy,
36
+ isTrustPolicyDecl,
37
+ fromPayload,
38
+ isFromPayloadRef,
35
39
  NomosSourceFrontier,
36
40
  sourceFrontierId,
37
41
  SOURCE_FRONTIER_PREFIX,
38
42
  __trustFor,
43
+ __policyFor,
39
44
  type TrustSourceDecl,
40
45
  type TrustSourceOptions,
46
+ type TrustPolicyDecl,
47
+ type TrustPolicyOptions,
48
+ type FromPayloadRef,
49
+ type ForeignReadEdge,
41
50
  type ForeignQueryRef,
42
51
  } from "./attested_core.js";
43
52
 
@@ -10,8 +10,8 @@
10
10
  * module-init cycle. The sanctioning ensure directive + the merge-seam injector live in
11
11
  * `attested.ts`. Full doctrine: `architecture/attested_reads_design.md`.
12
12
  */
13
- import { aggregate } from "./aggregate.js";
14
- import { t } from "./fields.js";
13
+ import { aggregate, type AggregateHandle } from "./aggregate.js";
14
+ import { t, type Field } from "./fields.js";
15
15
  import { Lww } from "./drivers.js";
16
16
 
17
17
  /** A lightweight reference to a query declared in the SOURCE workspace's law — the reader
@@ -125,6 +125,203 @@ export function isTrustSourceDecl(v: unknown): v is TrustSourceDecl {
125
125
  );
126
126
  }
127
127
 
128
+ // ── DYNAMIC-SOURCE trust: a policy over a LAW-STATE allow-list (dynamic_source_attested_reads.md) ──
129
+ // The static `trustSource("ws", …)` edge names a FIXED workspace; a `trustPolicy` instead names a
130
+ // reader-side aggregate whose folded rows ARE the acceptable-source set — so the routed workspace is
131
+ // chosen at RUNTIME (the payload names it) while the trust judgment (genesis + accepted keys) stays a
132
+ // deterministic fold of the reader's own gated-enrolment writes. The gate resolves `att.source_genesis`
133
+ // against the allow-list rows; the NAME is routing, the genesis + keys are identity.
134
+
135
+ /** A marker naming a PAYLOAD FIELD that carries the routed source-workspace name at runtime — the
136
+ * first argument of the dynamic `.readsFrom(fromPayload("field"), query, { policy })` overload. */
137
+ export interface FromPayloadRef {
138
+ readonly payloadField: string;
139
+ /** brand — distinguishes the dynamic (payload-routed) lane from a static workspace-name string. */
140
+ readonly __nomosFromPayload: true;
141
+ }
142
+
143
+ /** Name the PAYLOAD FIELD whose value routes a dynamic attested read (see {@link FromPayloadRef}). */
144
+ export function fromPayload(field: string): FromPayloadRef {
145
+ if (typeof field !== "string" || field.length === 0) {
146
+ throw new Error(`fromPayload(): the payload field name must be a non-empty string`);
147
+ }
148
+ return { payloadField: field, __nomosFromPayload: true };
149
+ }
150
+
151
+ /** Shape predicate — a {@link FromPayloadRef} vs a static workspace-name string. Library-internal. */
152
+ export function isFromPayloadRef(v: unknown): v is FromPayloadRef {
153
+ return (
154
+ v !== null &&
155
+ typeof v === "object" &&
156
+ (v as { __nomosFromPayload?: unknown }).__nomosFromPayload === true &&
157
+ typeof (v as { payloadField?: unknown }).payloadField === "string"
158
+ );
159
+ }
160
+
161
+ /** The §4.4 freshness + allow-list vocabulary of one declared trust POLICY. */
162
+ export interface TrustPolicyOptions {
163
+ /** The reader-side aggregate whose folded rows are the acceptable-source set (genesis + keyHashes
164
+ * per row). Enrolling a row is a GATED WRITE (owner authority); revoking = archiving the row. */
165
+ readonly allowList: AggregateHandle;
166
+ /** The allow-list field (a `t.string()`) holding a row's source GENESIS oid. Inferred when the
167
+ * aggregate has exactly one string field whose name contains "genesis"; else required. */
168
+ readonly genesisField?: string;
169
+ /** The allow-list field (a `t.set(t.string())`) holding a row's accepted attestor keyHashes.
170
+ * Inferred when the aggregate has exactly one set-of-string field; else required. */
171
+ readonly keysField?: string;
172
+ /** §4.4a — wall-clock freshness bound in ms (judged against the intent's frozen HLC). */
173
+ readonly maxAgeMs?: number;
174
+ /** §4.4b — the epoch anchor: `"birth"` (the reader's own birth-cert epoch) or an integer floor. */
175
+ readonly minSourceEpoch?: "birth" | number;
176
+ /** §4.4c — maintain + enforce the monotone `SourceFrontier` (no regression per source genesis). */
177
+ readonly monotone?: boolean;
178
+ /** §5.3 — what a folded `SourceDishonor` fact does at verify: `"refuse"` or `"record"`. */
179
+ readonly onDishonor?: "refuse" | "record";
180
+ }
181
+
182
+ /** One declared trust POLICY — an exported {@link trustPolicy} value, auto-discovered by shape.
183
+ * Lowered into the `nomosAttestedReadGate` era key's `policies` map. */
184
+ export interface TrustPolicyDecl {
185
+ readonly policyId: string;
186
+ /** The resolved allow-list aggregate TYPE id. */
187
+ readonly allowListType: string;
188
+ /** The resolved genesis field LABEL. */
189
+ readonly genesisField: string;
190
+ /** The resolved keyHashes field LABEL. */
191
+ readonly keysField: string;
192
+ readonly maxAgeMs?: number;
193
+ readonly minSourceEpoch?: "birth" | number;
194
+ readonly monotone?: boolean;
195
+ readonly onDishonor?: "refuse" | "record";
196
+ readonly __nomosTrustPolicy: true;
197
+ }
198
+
199
+ // The runtime policy registry — the dynamic read lane resolves a policy's monotone posture through
200
+ // the directive's readsFrom (which registers the queryId); this map lets the compile discovery + the
201
+ // validators resolve a decl by id. Keyed by policyId; later declaration wins.
202
+ const policyRegistry = new Map<string, TrustPolicyDecl>();
203
+
204
+ /** The registered trust policy for an id (`undefined` = undeclared). Library-internal. */
205
+ export function __policyFor(policyId: string): TrustPolicyDecl | undefined {
206
+ return policyRegistry.get(policyId);
207
+ }
208
+
209
+ /**
210
+ * DECLARE a dynamic trust POLICY (dynamic_source_attested_reads.md): the acceptable-source set is
211
+ * LAW-STATE — the folded rows of `allowList`, each a `{ <genesisField>: string, <keysField>:
212
+ * set-of-string }`. A directive routes a runtime-named source through this policy with
213
+ * `.readsFrom(fromPayload("wsField"), theQuery, { policy: thisDecl })`. Auto-discovered by shape and
214
+ * lowered into the `nomosAttestedReadGate` era key's `policies` map. The field names are inferred by
215
+ * shape (see {@link TrustPolicyOptions}) or given explicitly; a bad allow-list is a NAMED compile error.
216
+ */
217
+ export function trustPolicy(policyId: string, opts: TrustPolicyOptions): TrustPolicyDecl {
218
+ const al = opts.allowList as AggregateHandle | undefined;
219
+ if (
220
+ al === null ||
221
+ typeof al !== "object" ||
222
+ (al as { __isAggregateHandle?: unknown }).__isAggregateHandle !== true
223
+ ) {
224
+ throw new Error(
225
+ `trustPolicy('${policyId}'): 'allowList' must be an aggregate handle — pass the aggregate(...) ` +
226
+ `whose rows hold the trusted sources ({ <genesis> string, <keyHashes> set-of-string })`,
227
+ );
228
+ }
229
+ const fields = al.fields as Record<string, Field>;
230
+ const stringFields = Object.keys(fields).filter((n) => (fields[n] as Field).kind === "string");
231
+ const setFields = Object.keys(fields).filter((n) => (fields[n] as Field).kind === "set");
232
+
233
+ let genesisField: string;
234
+ if (opts.genesisField !== undefined) {
235
+ if (!(opts.genesisField in fields)) {
236
+ throw new Error(
237
+ `trustPolicy('${policyId}'): genesisField '${opts.genesisField}' is not a field of aggregate '${al.id}'`,
238
+ );
239
+ }
240
+ if ((fields[opts.genesisField] as Field).kind !== "string") {
241
+ throw new Error(
242
+ `trustPolicy('${policyId}'): genesisField '${opts.genesisField}' must be a t.string() field (the source genesis oid)`,
243
+ );
244
+ }
245
+ genesisField = opts.genesisField;
246
+ } else {
247
+ const cands = stringFields.filter((n) => n.toLowerCase().includes("genesis"));
248
+ if (cands.length !== 1) {
249
+ throw new Error(
250
+ `trustPolicy('${policyId}'): cannot infer genesisField on aggregate '${al.id}' — declare exactly ` +
251
+ `ONE t.string() field whose name contains "genesis", or pass genesisField: "<name>" explicitly`,
252
+ );
253
+ }
254
+ genesisField = cands[0]!;
255
+ }
256
+
257
+ let keysField: string;
258
+ if (opts.keysField !== undefined) {
259
+ if (!(opts.keysField in fields)) {
260
+ throw new Error(
261
+ `trustPolicy('${policyId}'): keysField '${opts.keysField}' is not a field of aggregate '${al.id}'`,
262
+ );
263
+ }
264
+ if ((fields[opts.keysField] as Field).kind !== "set") {
265
+ throw new Error(
266
+ `trustPolicy('${policyId}'): keysField '${opts.keysField}' must be a t.set(t.string()) field (the accepted attestor keyHashes)`,
267
+ );
268
+ }
269
+ keysField = opts.keysField;
270
+ } else {
271
+ if (setFields.length !== 1) {
272
+ throw new Error(
273
+ `trustPolicy('${policyId}'): cannot infer keysField on aggregate '${al.id}' — declare exactly ` +
274
+ `ONE t.set(t.string()) field (the accepted attestor keyHashes), or pass keysField: "<name>" explicitly`,
275
+ );
276
+ }
277
+ keysField = setFields[0]!;
278
+ }
279
+
280
+ const decl: TrustPolicyDecl = {
281
+ policyId,
282
+ allowListType: al.id,
283
+ genesisField,
284
+ keysField,
285
+ ...(opts.maxAgeMs !== undefined ? { maxAgeMs: opts.maxAgeMs } : {}),
286
+ ...(opts.minSourceEpoch !== undefined ? { minSourceEpoch: opts.minSourceEpoch } : {}),
287
+ ...(opts.monotone !== undefined ? { monotone: opts.monotone } : {}),
288
+ ...(opts.onDishonor !== undefined ? { onDishonor: opts.onDishonor } : {}),
289
+ __nomosTrustPolicy: true,
290
+ };
291
+ policyRegistry.set(policyId, decl);
292
+ return decl;
293
+ }
294
+
295
+ /** Shape predicate for compile discovery (the `isTrustSourceDecl` pattern). */
296
+ export function isTrustPolicyDecl(v: unknown): v is TrustPolicyDecl {
297
+ return (
298
+ v !== null &&
299
+ typeof v === "object" &&
300
+ (v as { __nomosTrustPolicy?: unknown }).__nomosTrustPolicy === true &&
301
+ typeof (v as { policyId?: unknown }).policyId === "string"
302
+ );
303
+ }
304
+
305
+ /** One declared foreign-read edge on a directive — a STATIC workspace edge or a dynamic policy edge.
306
+ * The static edge emits `{queryId, from}`; the policy edge emits `{queryId, policy}` (the routing
307
+ * `fromPayload` field is DSL-internal — the gate judges by genesis, the name is transport). */
308
+ export type ForeignReadEdge =
309
+ | { readonly queryId: string; readonly from: string }
310
+ | { readonly queryId: string; readonly policy: string; readonly fromPayload: string };
311
+
312
+ // A dynamic (policy-routed) read carries no declared `from` string, so the read lane cannot resolve
313
+ // its monotone posture through `__trustFor(name)`. The directive's readsFrom registers the queryId of
314
+ // every MONOTONE policy edge here; the foreign read lane consults it to record the SourceFrontier.
315
+ const monotoneForeignQueries = new Set<string>();
316
+ /** Register a foreign query id whose governing policy is monotone. Library-internal. */
317
+ export function __registerMonotoneForeignQuery(queryId: string): void {
318
+ monotoneForeignQueries.add(queryId);
319
+ }
320
+ /** Is a foreign query governed by a monotone policy edge? Library-internal. */
321
+ export function __isMonotoneForeignQuery(queryId: string): boolean {
322
+ return monotoneForeignQueries.has(queryId);
323
+ }
324
+
128
325
  // ── the SourceFrontier (§4.4c) — the reader-side monotone record of the source's position ──────
129
326
 
130
327
  /** The deterministic instance-id prefix — `source-frontier:<source_genesis>` (keyed by the source's
@@ -61,7 +61,13 @@ import {
61
61
  } from "./birth_meter.js";
62
62
  import { withBirthMeterExports } from "./birth_meter_ensure.js";
63
63
  import { withClearDirectives } from "./clear_directive.js";
64
- import { withAttestedReadExports, isTrustSourceDecl, type TrustSourceDecl } from "./attested.js";
64
+ import {
65
+ withAttestedReadExports,
66
+ isTrustSourceDecl,
67
+ isTrustPolicyDecl,
68
+ type TrustSourceDecl,
69
+ type TrustPolicyDecl,
70
+ } from "./attested.js";
65
71
  import type { RelationTypeDecl } from "./relation_schema.js";
66
72
  import { domainHash, emitManifestBytes } from "./manifest.js";
67
73
  import { collectValueObjectDescriptors } from "./value_object.js";
@@ -336,6 +342,15 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
336
342
  const trustSources = [...trustByWs.values()].sort((a, b) =>
337
343
  a.workspace < b.workspace ? -1 : a.workspace > b.workspace ? 1 : 0,
338
344
  );
345
+ // Dynamic-source trust POLICIES (dynamic_source_attested_reads.md) — discovered by shape like
346
+ // trustSource. Later-wins by policy id; sorted for byte-determinism.
347
+ const policyById = new Map<string, TrustPolicyDecl>();
348
+ for (const v of Object.values(merged)) {
349
+ if (isTrustPolicyDecl(v)) policyById.set(v.policyId, v);
350
+ }
351
+ const trustPolicies = [...policyById.values()].sort((a, b) =>
352
+ a.policyId < b.policyId ? -1 : a.policyId > b.policyId ? 1 : 0,
353
+ );
339
354
  const pabMarker =
340
355
  parentAttestedBirthMarker(merged) ??
341
356
  spec.modules.map((m) => parentAttestedBirthMarker(m)).find((x) => x !== null) ??
@@ -385,6 +400,7 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
385
400
  // Omit-when-empty — the nomosAttestedReadGate era rule: a domain with no declared trust
386
401
  // edge is byte-identical in the canonical manifest/USD.
387
402
  ...(trustSources.length > 0 ? { trustSources } : {}),
403
+ ...(trustPolicies.length > 0 ? { trustPolicies } : {}),
388
404
  ...(dartImports.length > 0 ? { dartImports } : {}),
389
405
  ...(spec.dartRefImports !== undefined && spec.dartRefImports.length > 0
390
406
  ? { dartRefImports: [...spec.dartRefImports] }
@@ -3488,6 +3488,13 @@ export interface DomainModule {
3488
3488
  * ADDITIVE + OPTIONAL: a domain with no trust edge omits this and is byte-identical (era rule).
3489
3489
  */
3490
3490
  trustSources?: import("./attested.js").TrustSourceDecl[];
3491
+ /**
3492
+ * The domain's DYNAMIC-SOURCE TRUST POLICIES (dynamic_source_attested_reads.md): the exported
3493
+ * `trustPolicy(id, { allowList, … })` declarations, discovered by shape. Lowered — together with
3494
+ * directives' `.readsFrom(fromPayload(…), query, { policy })` edges — into the `nomosAttestedReadGate`
3495
+ * era key's `policies` map. ADDITIVE + OPTIONAL: a policy-free domain omits this (era rule).
3496
+ */
3497
+ trustPolicies?: import("./attested.js").TrustPolicyDecl[];
3491
3498
  /**
3492
3499
  * The domain's NATIVE ZANZIBAR RELATION SCHEMA (`relation_schema.ts` — the userset-rewrite law,
3493
3500
  * P1). Pure-data per-type relation rewrites lowered to the `nomosRelations` USD era key (NO JS on
@@ -1884,7 +1884,20 @@ export interface ProofLegs {
1884
1884
  function isStandaloneCreate(d: Directive<unknown> | undefined): boolean {
1885
1885
  return d?.marker === "creates" && typeof d.payloadSchema === "object"
1886
1886
  && d.birthsChild !== true && d.attestationAuthorizedGate === undefined
1887
- && d.requiresRelationGate === undefined;
1887
+ && d.requiresRelationGate === undefined
1888
+ // NOT a foreign-read directive (`.readsFrom(...)` — a static attested edge or a dynamic
1889
+ // policy edge): its plan reads a SIGNED cross-workspace attestation the single-workspace
1890
+ // offline proof plane cannot mint (that needs a second SOURCE workspace + its attestor key).
1891
+ // Dispatched offline it would throw at the un-served read; proven instead by the two-workspace
1892
+ // smoke (bench/scale/{attested_reads.test,dynamic_attested_reads.smoke}.mjs). Named below.
1893
+ && (((d as { declaredForeignReads?: readonly unknown[] }).declaredForeignReads?.length ?? 0) === 0);
1894
+ }
1895
+
1896
+ /** Does this module carry ≥1 foreign-read directive (a static or dynamic-policy `.readsFrom`)? */
1897
+ function hasForeignReadDirective(mod: DomainModule): boolean {
1898
+ return (mod.directives as Directive<unknown>[]).some(
1899
+ (d) => ((d as { declaredForeignReads?: readonly unknown[] }).declaredForeignReads?.length ?? 0) > 0,
1900
+ );
1888
1901
  }
1889
1902
 
1890
1903
  /** Does `v` carry an RFC 7946 geometry object at `segs` (walked into the composite)? */
@@ -2772,6 +2785,23 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
2772
2785
  );
2773
2786
  }
2774
2787
 
2788
+ // THE ATTESTED / DYNAMIC-POLICY FOREIGN-READ LEG — named, honestly SKIPPED here. A `.readsFrom(...)`
2789
+ // directive's plan reads a SIGNED cross-workspace attestation; proving it needs a SECOND source
2790
+ // workspace, its read-attestor key, and a served+signed envelope — none of which the single-workspace,
2791
+ // client-driven offline proof plane can mint (the client has no `attestedRead` serving op). The full
2792
+ // offline two-workspace proof (birth a scratch source → seed a row → enrol its genesis → attested read
2793
+ // through the policy edge → admitted, plus the wrong-genesis/unenrolled negative + revocation +
2794
+ // verify_chain) lives in the bench harness against the real wasm.
2795
+ if (hasForeignReadDirective(mod)) {
2796
+ out.push(
2797
+ `// ${n()}. attested foreign-read leg: a .readsFrom(...) directive reads a SIGNED cross-workspace`,
2798
+ `// attestation — proving it needs a second SOURCE workspace + its attestor key, which this`,
2799
+ `// single-workspace client proof cannot mint. Proven in the two-workspace bench smoke.`,
2800
+ `console.log("– attested foreign-read leg SKIPPED — a .readsFrom(...) attested/policy read is proven in the two-workspace bench harness (bench/scale/{attested_reads.test,dynamic_attested_reads.smoke}.mjs), not this single-workspace offline proof");`,
2801
+ ``,
2802
+ );
2803
+ }
2804
+
2775
2805
  // THE AUTHORITY-CONFORMANCE LEG (the one-encoder + gate proof): runs on the LOCAL holon (fetch re-trapped),
2776
2806
  // proving the generated client threads security.actor through THE ONE ENCODER and that an actorless gated
2777
2807
  // offer is impossible to GENERATE (throws MissingOfferAuthority before any submit). No-op when un-gated.
package/src/directive.ts CHANGED
@@ -24,6 +24,14 @@ import type { CertifiedReadDecl } from "./certified_read.js";
24
24
  import type { RelationDecl } from "./relation.js";
25
25
  import type { QueryDecl } from "./query.js";
26
26
  import { BIRTH_METER_QUERIES } from "./birth_meter.js";
27
+ import {
28
+ isFromPayloadRef,
29
+ isTrustPolicyDecl,
30
+ __registerMonotoneForeignQuery,
31
+ type FromPayloadRef,
32
+ type TrustPolicyDecl,
33
+ type ForeignReadEdge,
34
+ } from "./attested_core.js";
27
35
 
28
36
  export type ReferentialMarker = "creates" | "mutates" | "ensures" | "archives";
29
37
 
@@ -183,7 +191,7 @@ export interface Directive<P = unknown> {
183
191
  * era key. OPTIONAL + ADDITIVE; OMITTED when empty (a foreign-read-free directive is
184
192
  * byte-identical — the era rule).
185
193
  */
186
- readonly declaredForeignReads?: { queryId: string; from: string }[];
194
+ readonly declaredForeignReads?: ForeignReadEdge[];
187
195
  /**
188
196
  * The directive's DECLARED emit boundary: the event types its `plan` may EMIT,
189
197
  * each with an optional `max` bound. Part of the domain identity. Defaults to `{}`
@@ -331,6 +339,18 @@ export interface RequirableDirective<P> extends Directive<P> {
331
339
  * times; deduped by `(queryId, from)`. Purely additive (the era rule).
332
340
  */
333
341
  readsFrom(sourceWorkspace: string, query: { id: string }): RequirableDirective<P>;
342
+ /**
343
+ * Declare ONE DYNAMIC (payload-routed) foreign read (dynamic_source_attested_reads.md): the plan may
344
+ * `read(theQuery, args, { from: payload.<field> })` — the routed workspace is chosen at runtime by the
345
+ * named payload field, while the trust judgment (genesis + accepted attestor keys) is a fold of the
346
+ * reader's own {@link TrustPolicyDecl} allow-list. Lowered into the era key as a `{queryId, policy}`
347
+ * edge (the routing field is DSL-internal — the gate judges by genesis, the name is transport).
348
+ */
349
+ readsFrom(
350
+ source: FromPayloadRef,
351
+ query: { id: string },
352
+ opts: { policy: TrustPolicyDecl },
353
+ ): RequirableDirective<P>;
334
354
  /**
335
355
  * Declare ONE entry of the directive's EMIT boundary: an `eventType` its `plan` may
336
356
  * emit, with an optional `max` count bound. Callable MULTIPLE times to accumulate
@@ -671,17 +691,41 @@ function makeRequirable<P>(base: Directive<P>): RequirableDirective<P> {
671
691
  const mergedQueries = [...byId.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
672
692
  return makeRequirable({ ...base, declaredReads: merged, declaredQueryReads: mergedQueries });
673
693
  },
674
- readsFrom(sourceWorkspace: string, query: { id: string }): RequirableDirective<P> {
675
- // ATTESTED CROSS-WORKSPACE READ (slice B): accumulate the (queryId, from) trust edge,
676
- // dedup by pair, sort (only the SET is the contract — byte-determinism for the era key).
694
+ readsFrom(
695
+ source: string | FromPayloadRef,
696
+ query: { id: string },
697
+ opts?: { policy: TrustPolicyDecl },
698
+ ): RequirableDirective<P> {
699
+ // ATTESTED CROSS-WORKSPACE READ (slice B + dynamic policies): accumulate a STATIC (queryId, from)
700
+ // edge OR a DYNAMIC (queryId, policy, fromPayload) edge, dedup by canonical key, sort (only the
701
+ // SET is the contract — byte-determinism for the era key).
702
+ let entry: ForeignReadEdge;
703
+ if (isFromPayloadRef(source)) {
704
+ const policy = opts?.policy;
705
+ if (policy === undefined) {
706
+ throw new Error(
707
+ `readsFrom(fromPayload('${source.payloadField}'), '${query.id}'): a payload-routed foreign ` +
708
+ `read REQUIRES a trust policy — pass { policy: <trustPolicy(...) decl> } (the acceptable ` +
709
+ `sources are LAW-STATE, so the runtime-named workspace is judged against a folded allow-list).`,
710
+ );
711
+ }
712
+ if (!isTrustPolicyDecl(policy)) {
713
+ throw new Error(
714
+ `readsFrom(fromPayload('${source.payloadField}'), '${query.id}'): { policy } must be a ` +
715
+ `trustPolicy(...) declaration.`,
716
+ );
717
+ }
718
+ entry = { queryId: query.id, policy: policy.policyId, fromPayload: source.payloadField };
719
+ if (policy.monotone === true) __registerMonotoneForeignQuery(query.id);
720
+ } else {
721
+ entry = { queryId: query.id, from: source };
722
+ }
723
+ const canon = (e: ForeignReadEdge): string =>
724
+ "from" in e ? `0${e.from}${e.queryId}` : `1${e.policy}${e.fromPayload}${e.queryId}`;
677
725
  const prior = base.declaredForeignReads ?? [];
678
- const key = (e: { queryId: string; from: string }) => `${e.from}\u0000${e.queryId}`;
679
- const byKey = new Map<string, { queryId: string; from: string }>(prior.map((e) => [key(e), e]));
680
- const entry = { queryId: query.id, from: sourceWorkspace };
681
- byKey.set(key(entry), entry);
682
- const merged = [...byKey.values()].sort((a, b) =>
683
- a.from !== b.from ? (a.from < b.from ? -1 : 1) : a.queryId < b.queryId ? -1 : a.queryId > b.queryId ? 1 : 0,
684
- );
726
+ const byKey = new Map<string, ForeignReadEdge>(prior.map((e) => [canon(e), e]));
727
+ byKey.set(canon(entry), entry);
728
+ const merged = [...byKey.values()].sort((a, b) => (canon(a) < canon(b) ? -1 : canon(a) > canon(b) ? 1 : 0));
685
729
  return makeRequirable({ ...base, declaredForeignReads: merged });
686
730
  },
687
731
  emits(eventType: string, opts?: { max?: number }): RequirableDirective<P> {
package/src/index.ts CHANGED
@@ -148,12 +148,18 @@ export {
148
148
  trustParent,
149
149
  PARENT_TRUST_SENTINEL,
150
150
  foreignQuery,
151
+ trustPolicy,
152
+ fromPayload,
151
153
  NomosSourceFrontier,
152
154
  sourceFrontierId,
153
155
  SOURCE_FRONTIER_PREFIX,
154
156
  nomosSourceFrontierEnsure,
155
157
  type TrustSourceDecl,
156
158
  type TrustSourceOptions,
159
+ type TrustPolicyDecl,
160
+ type TrustPolicyOptions,
161
+ type FromPayloadRef,
162
+ type ForeignReadEdge,
157
163
  type ForeignQueryRef,
158
164
  } from "./attested.js";
159
165
  // ATTESTATION RECEIPTS + EQUIVOCATION ACCOUNTABILITY (slice B stage 3 — §5): the source-side
package/src/law_check.ts CHANGED
@@ -44,6 +44,7 @@ import { finishSum } from "./sum.js";
44
44
  import { finishExtremum } from "./extremum.js";
45
45
  import { finishExists } from "./exists.js";
46
46
  import { clearDirectiveId } from "./clear_directive.js";
47
+ import type { ForeignReadEdge } from "./attested_core.js";
47
48
 
48
49
  /** One author-time finding — the gate refusal pre-fired at compile, or a courtesy note. */
49
50
  export interface Finding {
@@ -994,6 +995,111 @@ function topLevelPortReadFindings(mod: DomainModule, portReadingDomains: Readonl
994
995
  ];
995
996
  }
996
997
 
998
+ // ── ATTESTED-READ twins (dynamic_source_attested_reads.md §"proof + check") ──────────────────────
999
+ // (a) a foreign read whose (directive, query) has no declared trust edge → REFUSE naming the fix;
1000
+ // (b) a trustPolicy whose allow-list aggregate has no directive writing it → WARN (unpopulatable);
1001
+ // (c) a query declared BOTH as a local .reads() premise AND a foreign policy read → REFUSE (laundering).
1002
+
1003
+ /** The FOREIGN query ids a directive's plan actually reads (`read(q, …, { from })`) on the one probe
1004
+ * sample — `undefined` when the plan can't be probed (unsynthesizable payload / it threw). A clean re-run
1005
+ * under a read port that answers both lanes; every side-effect sink is drained (never leaks). */
1006
+ function foreignReadsPerformed(dir: Directive<unknown>, where: string): Set<string> | undefined {
1007
+ let synth: SynthesizedPayload;
1008
+ try {
1009
+ synth = synthesizePayload(dir.payloadSchema as z.ZodTypeAny, where);
1010
+ } catch {
1011
+ return undefined;
1012
+ }
1013
+ const payload: Record<string, unknown> = { ...synth.value };
1014
+ for (const f of synth.autoStamped) payload[f] = "2026-01-01T00:00:00Z";
1015
+ const performed = new Set<string>();
1016
+ let ok = true;
1017
+ withBirthProbeGlobals(() => {
1018
+ const g = globalThis as unknown as { nomos: Record<string, unknown> };
1019
+ const prevRead = g.nomos.read;
1020
+ const stubRows = [{ id: "probe", data: {} }];
1021
+ g.nomos.read = (_q: string, _a: unknown, o?: { from?: string }) =>
1022
+ o?.from
1023
+ ? {
1024
+ rows: stubRows,
1025
+ attestation: { v: 1, source_genesis: "probe-genesis", source_head: "h", source_epoch: 0, attested_at_ms: 0 },
1026
+ }
1027
+ : stubRows;
1028
+ __resetAuthoring();
1029
+ __resetReads();
1030
+ try {
1031
+ dir.plan(payload as never, deterministicPorts({ physical: 1, replica: 1, principal: "principal:probe" }));
1032
+ for (const r of __drainReads()) {
1033
+ if ((r as { attestation?: unknown }).attestation !== undefined) performed.add(r.queryId);
1034
+ }
1035
+ } catch {
1036
+ ok = false;
1037
+ } finally {
1038
+ try { __drainAuthoring(); } catch { /* drained */ }
1039
+ try { __drainReads(); } catch { /* drained */ }
1040
+ g.nomos.read = prevRead;
1041
+ }
1042
+ });
1043
+ return ok ? performed : undefined;
1044
+ }
1045
+
1046
+ function attestedReadFindings(mod: DomainModule): Finding[] {
1047
+ const domain = mod.domain ?? mod.name;
1048
+ const findings: Finding[] = [];
1049
+ const directives = mod.directives as Directive<unknown>[];
1050
+
1051
+ // (b) unpopulatable trust set — a policy allow-list no directive writes.
1052
+ const writtenTypes = new Set(directives.map((d) => (d as { aggregateId?: string }).aggregateId).filter(Boolean) as string[]);
1053
+ for (const pol of mod.trustPolicies ?? []) {
1054
+ if (!writtenTypes.has(pol.allowListType)) {
1055
+ findings.push({
1056
+ domain,
1057
+ level: "warn",
1058
+ what: `trustPolicy '${pol.policyId}' trusts sources enrolled in aggregate '${pol.allowListType}', but NO directive in this domain writes that aggregate — the trust set is unpopulatable (no gated enrolment lane), so every dynamic read under this policy will refuse (untrusted source)`,
1059
+ fix: `add a gated enrolment directive, e.g. directive("enroll…").creates(${pol.allowListType}).requires("<ownerCap>") whose payload carries { <genesis> string, <keyHashes> string[] }`,
1060
+ });
1061
+ }
1062
+ }
1063
+
1064
+ for (const dir of directives) {
1065
+ const edges = (dir as { declaredForeignReads?: ForeignReadEdge[] }).declaredForeignReads ?? [];
1066
+ const declaredQueryIds = new Set(edges.map((e) => e.queryId));
1067
+ const localQueryIds = new Set(
1068
+ ((dir as { declaredQueryReads?: { id: string }[] }).declaredQueryReads ?? []).map((q) => q.id),
1069
+ );
1070
+
1071
+ // (c) laundering — a query cannot be both a local .reads() premise AND a foreign read.
1072
+ for (const qid of declaredQueryIds) {
1073
+ if (localQueryIds.has(qid)) {
1074
+ findings.push({
1075
+ domain,
1076
+ directiveId: dir.id,
1077
+ level: "refuse",
1078
+ what: `query '${qid}' is declared BOTH as a local .reads() premise AND a foreign read on directive '${dir.id}' — a query cannot be both local (same-workspace CAS) and foreign (a signed cross-workspace attestation)`,
1079
+ fix: `drop one: keep .reads(${qid}) for a same-workspace captured read, OR keep the foreign .readsFrom(...) edge and remove the local .reads(${qid})`,
1080
+ });
1081
+ }
1082
+ }
1083
+
1084
+ // (a) an undeclared foreign read — the plan routes read(q, …, { from }) with no trust edge for q.
1085
+ const performed = foreignReadsPerformed(dir, `${domain}/${dir.id}`);
1086
+ if (performed !== undefined) {
1087
+ for (const qid of performed) {
1088
+ if (!declaredQueryIds.has(qid)) {
1089
+ findings.push({
1090
+ domain,
1091
+ directiveId: dir.id,
1092
+ level: "refuse",
1093
+ what: `directive '${dir.id}' reads a foreign workspace — read('${qid}', …, { from }) — but declares no trust edge for '${qid}', so the gate refuses it on every lane`,
1094
+ fix: `declare the edge: .readsFrom(fromPayload("<wsField>"), <${qid} query>, { policy: <trustPolicy(...) decl> }) for a payload-routed source, or .readsFrom("<ws>", <query>) + trustSource("<ws>", …) for a fixed one`,
1095
+ });
1096
+ }
1097
+ }
1098
+ }
1099
+ }
1100
+ return findings;
1101
+ }
1102
+
997
1103
  /**
998
1104
  * Run every author-time twin over the composed domain modules and return the findings, ordered
999
1105
  * refuse → warn → note (stable within a level, by domain/directive appearance). This is the ONE
@@ -1044,6 +1150,7 @@ export function checkDomainModules(
1044
1150
  findings.push(...dottedQueryKeyFindings(mod, aggById));
1045
1151
  findings.push(...nullBanFindings(mod, aggById));
1046
1152
  findings.push(...nullClearAuthorityFindings(mod, aggById));
1153
+ findings.push(...attestedReadFindings(mod));
1047
1154
  }
1048
1155
 
1049
1156
  // The BIRTH-RECIPE twin (unify): fold the scattered `.births()` recipe diagnostics into the
package/src/read.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  */
20
20
  import type { QueryDecl } from "./query.js";
21
21
  import type { WireRead, WireReadAttestation } from "./wire.js";
22
- import { NomosSourceFrontier, sourceFrontierId, __trustFor, __ensure } from "./attested_core.js";
22
+ import { NomosSourceFrontier, sourceFrontierId, __trustFor, __isMonotoneForeignQuery, __ensure } from "./attested_core.js";
23
23
 
24
24
  // ── the per-dispatch read footprint — library state drained by `executeDirectiveToIntent` (mirrors the authoring sink) ──
25
25
  let footprint: WireRead[] = [];
@@ -101,7 +101,10 @@ export function read<T = unknown>(
101
101
  );
102
102
  }
103
103
  footprint.push({ queryId: query.id, args, result: answer.rows, attestation: att });
104
- if (__trustFor(from)?.monotone === true) {
104
+ // `from` is a STATIC declared workspace for a trustSource edge, or a RUNTIME payload value for a
105
+ // dynamic policy edge — so the monotone posture comes either from the static edge (`__trustFor`) or
106
+ // from the policy the directive registered for this query id. Either way the frontier is genesis-keyed.
107
+ if (__trustFor(from)?.monotone === true || __isMonotoneForeignQuery(query.id)) {
105
108
  const frontier = __ensure(NomosSourceFrontier, sourceFrontierId(att.source_genesis)) as {
106
109
  set(f: string, v: unknown): unknown;
107
110
  };