@githolon/dsl 0.55.0 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dart/pubspec.yaml CHANGED
@@ -11,7 +11,7 @@ description: >-
11
11
  Nomos 2 Dart frontend types — the hand-written kernel wire types plus the
12
12
  code-generated per-domain aggregate + directive-payload types. (De)serializes
13
13
  the EXACT serde JSON the Rust kernel + projection produce/consume.
14
- version: 0.34.0
14
+ version: 0.35.0
15
15
 
16
16
  environment:
17
17
  sdk: ^3.5.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.55.0",
3
+ "version": "0.56.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
@@ -44,6 +44,7 @@ import type { FieldOp } from "./ops.js";
44
44
  import type { RelationTypeDecl, Rewrite } from "./relation_schema.js";
45
45
  import type { WorkspaceInvariantDecl } from "./framework/workspace_invariant.js";
46
46
  import { finishCount } from "./count.js";
47
+ import { clearDirectiveId } from "./clear_directive.js";
47
48
  import { voMetaOf, voSample } from "./value_object.js";
48
49
  import { mintedCreateField } from "./workspace_routing.js";
49
50
  import { deterministicPorts } from "./ctx.js";
@@ -1883,7 +1884,20 @@ export interface ProofLegs {
1883
1884
  function isStandaloneCreate(d: Directive<unknown> | undefined): boolean {
1884
1885
  return d?.marker === "creates" && typeof d.payloadSchema === "object"
1885
1886
  && d.birthsChild !== true && d.attestationAuthorizedGate === undefined
1886
- && 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
+ );
1887
1901
  }
1888
1902
 
1889
1903
  /** Does `v` carry an RFC 7946 geometry object at `segs` (walked into the composite)? */
@@ -2660,6 +2674,39 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
2660
2674
  );
2661
2675
  }
2662
2676
 
2677
+ // CLEAR-LANE PROOF (null_semantics.md): a `t.clearable(...)` field's compose-injected clear directive
2678
+ // (`clearDirectiveId(agg, field)`) LOWERS to a Set of json null — dispatch it on the created instance
2679
+ // and read the field back as null (ExplicitNull). Only UN-GATED clear directives (the proof binds no
2680
+ // actor); a gated clear needs authority the negative/positive gate legs already cover. Empty for a
2681
+ // clearable-free aggregate (byte-identical proof).
2682
+ const clearLegs = Object.entries(agg.fields as Record<string, Field>)
2683
+ .filter(([, f]) => f.isClearable === true)
2684
+ .map(([field]) => ({ field, dirId: clearDirectiveId(agg.id, field) }))
2685
+ .filter(({ dirId }) => {
2686
+ const cd = (mod.directives as Directive<unknown>[]).find((d) => d.id === dirId);
2687
+ return cd !== undefined && (cd as { requiresRelationGate?: unknown }).requiresRelationGate === undefined;
2688
+ });
2689
+ for (const { field, dirId } of clearLegs) {
2690
+ out.push(
2691
+ `// ${n()}. CLEAR-LANE PROOF — \`${field}\` is clearable: \`${dirId}\` lowers to Set(json null); after it,`,
2692
+ `// the field reads back null (ExplicitNull), never omitted.`,
2693
+ `try {`,
2694
+ ` await app.${dirId}({ id: createdId });`,
2695
+ `} catch (e) {`,
2696
+ ` fail(\`clear-lane ${dirId} was REFUSED — an un-gated clear of a clearable field must be admitted: \${(e as Error).message}\`);`,
2697
+ `}`,
2698
+ `{`,
2699
+ ` const clearedRows = await holon.queryById(createdId) as { data?: Record<string, unknown> }[];`,
2700
+ ` const clearedRow = clearedRows[0];`,
2701
+ ` if (clearedRow !== undefined && clearedRow.data !== undefined && clearedRow.data[${JSON.stringify(field)}] != null) {`,
2702
+ ` fail(\`clear-lane ${field}: expected null after clear, got \${JSON.stringify(clearedRow.data[${JSON.stringify(field)}])}\`);`,
2703
+ ` }`,
2704
+ ` ok(${JSON.stringify(`clear-lane — ${domain}/${dirId} admitted; \`${field}\` reads back null (ExplicitNull)`)});`,
2705
+ `}`,
2706
+ ``,
2707
+ );
2708
+ }
2709
+
2663
2710
  out.push(
2664
2711
  `globalThis.fetch = realFetch;`,
2665
2712
  `ok("offline proof ENFORCED — fetch was trapped: local write/read touched no network");`,
@@ -2738,6 +2785,23 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
2738
2785
  );
2739
2786
  }
2740
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
+
2741
2805
  // THE AUTHORITY-CONFORMANCE LEG (the one-encoder + gate proof): runs on the LOCAL holon (fetch re-trapped),
2742
2806
  // proving the generated client threads security.actor through THE ONE ENCODER and that an actorless gated
2743
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
  };
@@ -54,10 +54,13 @@ export interface DomainInterface {
54
54
  readonly directives: Record<string, DirectivePayloadShape>;
55
55
  /** keyed by AGGREGATE SID. */
56
56
  readonly aggregates: Record<string, AggregateReadShape>;
57
- /** ATTESTED READS (slice B): directive id → its declared `(queryId, from)` trust edges —
58
- * the cross-workspace dependencies this law consumes on its write path. OMITTED when the
59
- * law declares none (the era rule). */
60
- readonly foreignReads?: Record<string, { queryId: string; from: string }[]>;
57
+ /** ATTESTED READS (slice B + dynamic policies): directive id → its declared foreign-read edges —
58
+ * a STATIC `{queryId, from}` edge or a dynamic `{queryId, policy, fromPayload}` edge — the
59
+ * cross-workspace dependencies this law consumes on its write path. OMITTED when none (era rule). */
60
+ readonly foreignReads?: Record<
61
+ string,
62
+ { queryId: string; from?: string; policy?: string; fromPayload?: string }[]
63
+ >;
61
64
  }
62
65
 
63
66
  /** The verdict: compatible iff nothing is missing and nothing is type-incompatible.
@@ -161,8 +164,9 @@ export function domainInterface(mod: DomainModule, stableIds?: StableIds): Domai
161
164
  Object.assign(aggregates, sortedRec(aggEntries));
162
165
  // ATTESTED READS (slice B): the declared trust edges — so shape-compat + client codegen can
163
166
  // SEE the cross-workspace dependency (typed clients don't call it; it's write-path-internal).
164
- const foreignReadEntries: Array<[string, { queryId: string; from: string }[]]> = [];
165
- for (const d of (mod.directives ?? []) as Array<{ id?: unknown; declaredForeignReads?: { queryId: string; from: string }[] }>) {
167
+ type ForeignEdge = { queryId: string; from?: string; policy?: string; fromPayload?: string };
168
+ const foreignReadEntries: Array<[string, ForeignEdge[]]> = [];
169
+ for (const d of (mod.directives ?? []) as Array<{ id?: unknown; declaredForeignReads?: ForeignEdge[] }>) {
166
170
  if (!d || typeof d.id !== "string") continue;
167
171
  if (d.declaredForeignReads !== undefined && d.declaredForeignReads.length > 0) {
168
172
  foreignReadEntries.push([d.id, d.declaredForeignReads.map((e) => ({ ...e }))]);
package/src/usd.ts CHANGED
@@ -44,6 +44,7 @@ import type { DomainModule } from "./codegen_dart.js";
44
44
  import { domainInterface } from "./shape_compat.js";
45
45
  import { relationType, user, key, anyUser, qualifiedTypeId, qualifyRelationType } from "./relation_schema.js";
46
46
  import { harvestPrincipalFlows } from "./wire_encode.js";
47
+ import { zodKind, zodObjectShape } from "./codegen_ts.js";
47
48
  import { composeSharedRelationTypes } from "./relation_composition.js";
48
49
  import { collectValueObjectUsages } from "./value_object.js";
49
50
  import {
@@ -459,7 +460,29 @@ export interface UsdLayer {
459
460
  onDishonor: "refuse" | "record";
460
461
  }
461
462
  >;
462
- readonly directives: Record<string, { queryId: string; from: string }[]>;
463
+ /**
464
+ * DYNAMIC-SOURCE trust POLICIES (dynamic_source_attested_reads.md): the acceptable-source SET is
465
+ * law-state — a reader-side allow-list aggregate. A directive edge may route through a policy
466
+ * (`{queryId, policy}`) instead of a fixed workspace; the gate resolves the envelope's genesis
467
+ * against the folded `allowList` rows (genesis field) → the row's `keysField` accepted keys.
468
+ * OMITTED when no policy is declared (byte-identical to a policy-free bundle — the era rule).
469
+ */
470
+ readonly policies?: Record<
471
+ string,
472
+ {
473
+ allowList: string;
474
+ genesisField: string;
475
+ keysField: string;
476
+ maxAgeMs?: number;
477
+ minSourceEpoch?: "birth" | number;
478
+ monotone?: boolean;
479
+ onDishonor?: "refuse" | "record";
480
+ }
481
+ >;
482
+ readonly directives: Record<
483
+ string,
484
+ ({ queryId: string; from: string } | { queryId: string; policy: string })[]
485
+ >;
463
486
  };
464
487
  /**
465
488
  * THE READ-VISIBILITY GATE DECLARATION (the native-relations read gate).
@@ -566,6 +589,27 @@ export interface UsdDocument {
566
589
  readonly layers: UsdLayer[];
567
590
  }
568
591
 
592
+ /** The payload field kinds of a directive — `"string-required"` for a required `z.string()`, else the
593
+ * zod kind (`"optional"`, `"number"`, …). Used to validate a `fromPayload` routing field. Empty when
594
+ * the payload isn't a plain object (a shape the check can't reason about — the runtime gate arbitrates). */
595
+ function payloadFieldKinds(dir: { payloadSchema?: unknown } | undefined): Map<string, string> {
596
+ const m = new Map<string, string>();
597
+ const schema = dir?.payloadSchema as Parameters<typeof zodKind>[0] | undefined;
598
+ if (schema === undefined) return m;
599
+ let shape: Record<string, Parameters<typeof zodKind>[0]>;
600
+ try {
601
+ if (zodKind(schema) !== "object") return m;
602
+ shape = zodObjectShape(schema);
603
+ } catch {
604
+ return m;
605
+ }
606
+ for (const [name, f] of Object.entries(shape)) {
607
+ const k = zodKind(f);
608
+ m.set(name, k === "string" ? "string-required" : k);
609
+ }
610
+ return m;
611
+ }
612
+
569
613
  /** Sort prims by their `path` (canonical structure). */
570
614
  function sortPrims(prims: UsdPrim[]): UsdPrim[] {
571
615
  return [...prims].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
@@ -984,47 +1028,122 @@ export function emitUsd(
984
1028
  // trustSource declarations. Emitted iff ≥1 directive declares a foreign read (the era
985
1029
  // rule). A declared `from` with NO matching trustSource is a NAMED COMPILE ERROR — the
986
1030
  // kernel would fail it closed anyway; refuse loudly at build instead.
987
- const foreignDirectives: Record<string, { queryId: string; from: string }[]> = {};
1031
+ type ForeignEdgeInternal =
1032
+ | { queryId: string; from: string }
1033
+ | { queryId: string; policy: string; fromPayload: string };
1034
+ type ForeignEdgeEmit = { queryId: string; from: string } | { queryId: string; policy: string };
1035
+ const foreignDirectives: Record<string, ForeignEdgeInternal[]> = {};
988
1036
  for (const d of [...l.module.directives].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))) {
989
- const fr = (d as { declaredForeignReads?: { queryId: string; from: string }[] }).declaredForeignReads;
1037
+ const fr = (d as { declaredForeignReads?: ForeignEdgeInternal[] }).declaredForeignReads;
990
1038
  if (fr !== undefined && fr.length > 0) foreignDirectives[d.id] = fr.map((e) => ({ ...e }));
991
1039
  }
992
1040
  let nomosAttestedReadGate: UsdLayer["nomosAttestedReadGate"];
993
1041
  if (Object.keys(foreignDirectives).length > 0) {
994
1042
  const trust = (l.module as { trustSources?: readonly import("./attested.js").TrustSourceDecl[] }).trustSources ?? [];
995
1043
  const trustByWs = new Map(trust.map((tsrc) => [tsrc.workspace, tsrc]));
1044
+ const policies = (l.module as { trustPolicies?: readonly import("./attested.js").TrustPolicyDecl[] }).trustPolicies ?? [];
1045
+ const policyById = new Map(policies.map((p) => [p.policyId, p]));
1046
+ const aggIds = new Set(l.module.aggregates.map((a) => a.id));
1047
+ // A policy-referenced allow-list must be a real aggregate of THIS module (the fold happens in
1048
+ // the reader's own state) — else the gate could never resolve it (fail-closed at build).
1049
+ for (const p of policies) {
1050
+ if (!aggIds.has(p.allowListType)) {
1051
+ throw new Error(
1052
+ `trustPolicy('${p.policyId}'): its allowList aggregate '${p.allowListType}' is not an ` +
1053
+ `aggregate of this domain — the acceptable-source set is the reader's OWN folded state, ` +
1054
+ `so the allow-list must be declared (and written by a gated enrolment directive) here.`,
1055
+ );
1056
+ }
1057
+ }
996
1058
  const sourcesRec: NonNullable<UsdLayer["nomosAttestedReadGate"]>["sources"] = {};
1059
+ const policiesRec: NonNullable<NonNullable<UsdLayer["nomosAttestedReadGate"]>["policies"]> = {};
1060
+ const emitDirectives: Record<string, ForeignEdgeEmit[]> = {};
997
1061
  for (const [dirId, edges] of Object.entries(foreignDirectives)) {
1062
+ const dir = l.module.directives.find((d) => d.id === dirId);
1063
+ const payloadShape = payloadFieldKinds(dir);
1064
+ const emitted: ForeignEdgeEmit[] = [];
998
1065
  for (const e of edges) {
999
- const tsrc = trustByWs.get(e.from);
1000
- if (tsrc === undefined) {
1001
- throw new Error(
1002
- `directive '${dirId}' declares .readsFrom('${e.from}', '${e.queryId}') but the ` +
1003
- `module exports no trustSource('${e.from}', …) — the trust edge must be declared ` +
1004
- `in law (attested_reads_design.md §3.1)`,
1005
- );
1006
- }
1007
- if (sourcesRec[e.from] === undefined) {
1008
- sourcesRec[e.from] = {
1009
- genesis: tsrc.genesis,
1010
- ...(tsrc.keyHashes !== undefined && tsrc.keyHashes.length > 0
1011
- ? { keyHashes: [...tsrc.keyHashes].sort() }
1012
- : {}),
1013
- ...(tsrc.maxAgeMs !== undefined ? { maxAgeMs: tsrc.maxAgeMs } : {}),
1014
- ...(tsrc.minSourceEpoch !== undefined ? { minSourceEpoch: tsrc.minSourceEpoch } : {}),
1015
- ...(tsrc.monotone === true ? { monotone: true } : {}),
1016
- roster: tsrc.roster ?? "pinned",
1017
- onDishonor: tsrc.onDishonor ?? "refuse",
1018
- };
1066
+ if ("from" in e) {
1067
+ const tsrc = trustByWs.get(e.from);
1068
+ if (tsrc === undefined) {
1069
+ throw new Error(
1070
+ `directive '${dirId}' declares .readsFrom('${e.from}', '${e.queryId}') but the ` +
1071
+ `module exports no trustSource('${e.from}', …) — the trust edge must be declared ` +
1072
+ `in law (attested_reads_design.md §3.1)`,
1073
+ );
1074
+ }
1075
+ if (sourcesRec[e.from] === undefined) {
1076
+ sourcesRec[e.from] = {
1077
+ genesis: tsrc.genesis,
1078
+ ...(tsrc.keyHashes !== undefined && tsrc.keyHashes.length > 0
1079
+ ? { keyHashes: [...tsrc.keyHashes].sort() }
1080
+ : {}),
1081
+ ...(tsrc.maxAgeMs !== undefined ? { maxAgeMs: tsrc.maxAgeMs } : {}),
1082
+ ...(tsrc.minSourceEpoch !== undefined ? { minSourceEpoch: tsrc.minSourceEpoch } : {}),
1083
+ ...(tsrc.monotone === true ? { monotone: true } : {}),
1084
+ roster: tsrc.roster ?? "pinned",
1085
+ onDishonor: tsrc.onDishonor ?? "refuse",
1086
+ };
1087
+ }
1088
+ emitted.push({ queryId: e.queryId, from: e.from });
1089
+ } else {
1090
+ const pol = policyById.get(e.policy);
1091
+ if (pol === undefined) {
1092
+ throw new Error(
1093
+ `directive '${dirId}' declares .readsFrom(fromPayload('${e.fromPayload}'), '${e.queryId}', ` +
1094
+ `{ policy: '${e.policy}' }) but the module exports no trustPolicy('${e.policy}', …) — the ` +
1095
+ `policy must be declared in law (dynamic_source_attested_reads.md).`,
1096
+ );
1097
+ }
1098
+ // The routing field must be a REQUIRED string in the directive's payload (the workspace
1099
+ // name is chosen at runtime; a missing/optional/non-string field could never route).
1100
+ const kind = payloadShape.get(e.fromPayload);
1101
+ if (kind === undefined) {
1102
+ throw new Error(
1103
+ `directive '${dirId}': .readsFrom(fromPayload('${e.fromPayload}'), …) names a payload ` +
1104
+ `field that is not declared in the directive's payload — add a required z.string() ` +
1105
+ `'${e.fromPayload}' to the payload (it carries the routed source workspace name).`,
1106
+ );
1107
+ }
1108
+ if (kind !== "string-required") {
1109
+ throw new Error(
1110
+ `directive '${dirId}': the fromPayload routing field '${e.fromPayload}' must be a ` +
1111
+ `REQUIRED z.string() in the payload (it names the routed source workspace) — got '${kind}'.`,
1112
+ );
1113
+ }
1114
+ if (policiesRec[e.policy] === undefined) {
1115
+ policiesRec[e.policy] = {
1116
+ // Qualify the allow-list type by the owning domain iff it is namespaced — the
1117
+ // gate scans rows by their stored `__type`, which the engine stamps qualified
1118
+ // (`qaType`, the SAME read-projection namespacing rows/schema keys use). A bare
1119
+ // (unqualified) allowList would match no folded row → every dynamic read refuses.
1120
+ allowList: qaType(pol.allowListType),
1121
+ genesisField: pol.genesisField,
1122
+ keysField: pol.keysField,
1123
+ ...(pol.maxAgeMs !== undefined ? { maxAgeMs: pol.maxAgeMs } : {}),
1124
+ ...(pol.minSourceEpoch !== undefined ? { minSourceEpoch: pol.minSourceEpoch } : {}),
1125
+ ...(pol.monotone === true ? { monotone: true } : {}),
1126
+ ...(pol.onDishonor !== undefined ? { onDishonor: pol.onDishonor } : {}),
1127
+ };
1128
+ }
1129
+ emitted.push({ queryId: e.queryId, policy: e.policy });
1019
1130
  }
1020
1131
  }
1132
+ emitDirectives[dirId] = emitted;
1021
1133
  }
1022
1134
  // SORTED-KEY records (byte-determinism — matching the layered flatten).
1023
1135
  const sortedSources: typeof sourcesRec = {};
1024
1136
  for (const k of Object.keys(sourcesRec).sort()) sortedSources[k] = sourcesRec[k]!;
1025
- const sortedDirectives: Record<string, { queryId: string; from: string }[]> = {};
1026
- for (const k of Object.keys(foreignDirectives).sort()) sortedDirectives[k] = foreignDirectives[k]!;
1027
- nomosAttestedReadGate = { v: 1 as const, sources: sortedSources, directives: sortedDirectives };
1137
+ const sortedPolicies: typeof policiesRec = {};
1138
+ for (const k of Object.keys(policiesRec).sort()) sortedPolicies[k] = policiesRec[k]!;
1139
+ const sortedDirectives: Record<string, ForeignEdgeEmit[]> = {};
1140
+ for (const k of Object.keys(emitDirectives).sort()) sortedDirectives[k] = emitDirectives[k]!;
1141
+ nomosAttestedReadGate = {
1142
+ v: 1 as const,
1143
+ sources: sortedSources,
1144
+ ...(Object.keys(sortedPolicies).length > 0 ? { policies: sortedPolicies } : {}),
1145
+ directives: sortedDirectives,
1146
+ };
1028
1147
  }
1029
1148
  // THE BIRTH GATE (G3 — birth is a declared kernel primitive): the SORTED set of directive ids
1030
1149
  // declaring `.births()`. The kernel reads this off the manifest (never a domain aggregate) to know an
package/src/usd_layers.ts CHANGED
@@ -835,20 +835,32 @@ export function flattenLayeredUsd(layers: readonly UsdLayer[]): UsdDocument {
835
835
  // layers, SORTED keys (matching the composed emission). Omit-when-empty (era rule).
836
836
  let attestedReadGate: UsdLayer["nomosAttestedReadGate"];
837
837
  {
838
- type SrcSpec = NonNullable<UsdLayer["nomosAttestedReadGate"]>["sources"][string];
838
+ type Gate = NonNullable<UsdLayer["nomosAttestedReadGate"]>;
839
+ type SrcSpec = Gate["sources"][string];
840
+ type PolSpec = NonNullable<Gate["policies"]>[string];
841
+ type EdgeList = Gate["directives"][string];
839
842
  const srcs = new Map<string, SrcSpec>();
840
- const dirs = new Map<string, { queryId: string; from: string }[]>();
843
+ const pols = new Map<string, PolSpec>();
844
+ const dirs = new Map<string, EdgeList>();
841
845
  for (const l of stack) {
842
846
  if (l.nomosAttestedReadGate === undefined) continue;
843
847
  for (const [ws, spec] of Object.entries(l.nomosAttestedReadGate.sources)) srcs.set(ws, spec);
848
+ for (const [id, spec] of Object.entries(l.nomosAttestedReadGate.policies ?? {})) pols.set(id, spec);
844
849
  for (const [id, edges] of Object.entries(l.nomosAttestedReadGate.directives)) dirs.set(id, edges);
845
850
  }
846
851
  if (dirs.size > 0) {
847
852
  const sourcesRec: Record<string, SrcSpec> = {};
848
853
  for (const k of [...srcs.keys()].sort()) sourcesRec[k] = srcs.get(k)!;
849
- const directivesRec: Record<string, { queryId: string; from: string }[]> = {};
854
+ const policiesRec: Record<string, PolSpec> = {};
855
+ for (const k of [...pols.keys()].sort()) policiesRec[k] = pols.get(k)!;
856
+ const directivesRec: Record<string, EdgeList> = {};
850
857
  for (const k of [...dirs.keys()].sort()) directivesRec[k] = dirs.get(k)!;
851
- attestedReadGate = { v: 1, sources: sourcesRec, directives: directivesRec };
858
+ attestedReadGate = {
859
+ v: 1,
860
+ sources: sourcesRec,
861
+ ...(Object.keys(policiesRec).length > 0 ? { policies: policiesRec } : {}),
862
+ directives: directivesRec,
863
+ };
852
864
  }
853
865
  }
854
866
  // THE BIRTH GATE (G3): the SORTED union of every layer's `.births()` directive ids. Omit-when-empty