@githolon/dsl 0.15.0 → 0.16.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/src/usd.ts CHANGED
@@ -41,7 +41,9 @@ import {
41
41
  } from "./compose.js";
42
42
  import type { WireSchema, WireRefMode } from "./wire.js";
43
43
  import type { DomainModule } from "./codegen_dart.js";
44
+ import { domainInterface } from "./shape_compat.js";
44
45
  import { relationType, user, key, anyUser } from "./relation_schema.js";
46
+ import { harvestPrincipalFlows } from "./wire_encode.js";
45
47
 
46
48
  /** The referential marker of a directive prim (kernel `RefMode` wire string). */
47
49
  export type RefMode = WireRefMode;
@@ -292,6 +294,15 @@ export interface UsdLayer {
292
294
  * whose old AND new packages both carry this key is DIFFED BY SID at the one gate.
293
295
  */
294
296
  readonly nomosStableIds?: import("./stable_ids_types.js").StableIds;
297
+ /**
298
+ * THE STRUCTURAL INTERFACE (#72) — this domain's directives (payload shapes) + aggregates
299
+ * (read shapes, sid-keyed). ON-LEDGER (it rides the package, which installs as the
300
+ * `policy:{hash}.bundle`), so a client reads the workspace's OFFER straight from the folded
301
+ * law — reactively, host-blind (architecture/shape_compatibility.md). A client negotiates
302
+ * shape-compatibility against THIS, not the package hash. Pre-#72 bundles omit it; a client
303
+ * then reports `offerKnown:false` and never refuses on its absence (era-safe, graceful).
304
+ */
305
+ readonly nomosInterface?: import("./shape_compat.js").DomainInterface;
295
306
  /**
296
307
  * THE CAPTURED-READ GATE DECLARATION (#58). Present IFF ≥1 directive of this layer
297
308
  * declares a captured-read query (`.reads(someQuery)`): the law's EXPLICIT opt-in
@@ -341,6 +352,47 @@ export interface UsdLayer {
341
352
  readonly v: 1;
342
353
  readonly aggregates: Record<string, { scope: string; fields: string[]; delegatable: false }>;
343
354
  };
355
+ /**
356
+ * THE CAPABILITY DECLARATION IN LAW (Full-A — capability_marketplace.md §4): the impure-
357
+ * capability quartets (obligation/complete/fail/block/deadLetter) lowered into the SEALED
358
+ * law, so the holon SURFACES them on the `declared_capabilities` read and the host relays a
359
+ * deploy-time "declared — bind it or obligations wait" notice without reaching into root
360
+ * law-state (boundness stays P6 — it is cross-holon root state). OMITTED for capability-free
361
+ * law (byte-identical hash — the era rule); declaring a capability MOVES the domainHash
362
+ * (named, expected). SORTED by aggregateId.
363
+ */
364
+ readonly nomosCapabilities?: {
365
+ readonly v: 1;
366
+ readonly capabilities: readonly {
367
+ readonly capability: string;
368
+ readonly aggregateId: string;
369
+ readonly obligation: string;
370
+ readonly complete: string;
371
+ readonly fail: string;
372
+ readonly block: string;
373
+ readonly deadLetter: string;
374
+ readonly tasksQueryId: string | null;
375
+ /** The HTTPS endpoint the obligation is offered to (outbox); absent ⇒ no destination. */
376
+ readonly endpoint?: string;
377
+ }[];
378
+ };
379
+ /**
380
+ * THE FLOW GATE WAIVERS (architecture/security_model.md) — both halves of the audit surface,
381
+ * hashed into the law + replay-verified. `declassify` (confidentiality, rule 4): every acknowledged
382
+ * secret-correlated refusal. `endorse` (the integrity dual): every harvested PrincipalEvidence flow
383
+ * into an authority/standing/attribution sink — discovery IS law, witnessed fail-closed at runtime.
384
+ * Each half is OMITTED when empty (the era rule — a flow-free domain is byte-identical).
385
+ */
386
+ readonly nomosFlowGate?: {
387
+ readonly v: 1;
388
+ readonly declassify?: readonly { readonly site: string; readonly reason: string }[];
389
+ readonly endorse?: readonly {
390
+ readonly directiveId: string;
391
+ readonly sink: string;
392
+ readonly facet: string;
393
+ readonly reason: string | null;
394
+ }[];
395
+ };
344
396
  }
345
397
 
346
398
  /** The reserved variant name declaring a set's DEFAULT (USD has a default variant). */
@@ -700,6 +752,70 @@ export function emitUsd(
700
752
  encAggregates[a.id] = { scope: a.requiresRead, fields: [...a.encryptedFields], delegatable: false };
701
753
  }
702
754
  }
755
+ // THE CAPABILITY DECLARATION IN LAW (Full-A — capability_marketplace.md §4): the
756
+ // impure-capability quartets lowered into the SEALED law, so the holon can SURFACE
757
+ // them on a read (`declared_capabilities`) and the host RELAYS a deploy-time notice
758
+ // ("declares 'x' — bind it or obligations wait") WITHOUT reaching into root law-state.
759
+ // Mirrors the deploy-sidecar `capabilityLowerings` extraction; SORTED by aggregate id;
760
+ // omit-when-empty so capability-free law is byte-identical (the era rule). Declaring a
761
+ // capability now MOVES the domainHash (named, expected — like the stable-ids key).
762
+ const nomosCapabilities = [...((l.module as { impureCapabilities?: readonly unknown[] }).impureCapabilities ?? [])]
763
+ .map((c) => {
764
+ const k = c as {
765
+ aggregate: { id: string };
766
+ obligation: { id: string };
767
+ complete: { id: string };
768
+ fail: { id: string };
769
+ block: { id: string };
770
+ deadLetter: { id: string };
771
+ name?: unknown;
772
+ capability?: unknown;
773
+ endpoint?: unknown;
774
+ tasks?: { id?: unknown };
775
+ };
776
+ const cap = typeof k.name === "string" ? k.name : typeof k.capability === "string" ? k.capability : null;
777
+ if (cap === null) return null; // a binding-free quartet declares nothing (omit-when-absent)
778
+ return {
779
+ capability: cap,
780
+ aggregateId: k.aggregate.id,
781
+ obligation: k.obligation.id,
782
+ complete: k.complete.id,
783
+ fail: k.fail.id,
784
+ block: k.block.id,
785
+ deadLetter: k.deadLetter.id,
786
+ tasksQueryId: typeof k.tasks?.id === "string" ? k.tasks.id : null,
787
+ ...(typeof k.endpoint === "string" ? { endpoint: k.endpoint } : {}),
788
+ };
789
+ })
790
+ .filter((c): c is NonNullable<typeof c> => c !== null)
791
+ .sort((a, b) => (a.aggregateId < b.aggregateId ? -1 : a.aggregateId > b.aggregateId ? 1 : 0));
792
+ // THE DECLASSIFY SITES (nomosFlowGate rule 4 — security_model.md): every acknowledged
793
+ // intended secret-correlated refusal, with its human reason. Hashed into the law (it IS
794
+ // a law-declared waiver) + replay-verified — "the set of declassify sites is the entire
795
+ // audit surface". Omit-when-empty: a domain with no declassify is byte-identical.
796
+ const flowDeclassify = [
797
+ ...[...(l.module.workspaceInvariants ?? [])]
798
+ .filter((wi) => typeof (wi as { declassify?: unknown }).declassify === "string")
799
+ .map((wi) => ({ site: `workspaceInvariant:${wi.id}`, reason: (wi as { declassify: string }).declassify })),
800
+ ...[...(l.module.aggregates as { id: string; invariantDeclassify?: unknown }[])]
801
+ .filter((a) => typeof a.invariantDeclassify === "string")
802
+ .map((a) => ({ site: `aggregateInvariant:${a.id}`, reason: a.invariantDeclassify as string })),
803
+ ...[...((l.module as { impureCapabilities?: readonly { name?: string; capability?: string; aggregate: { id: string }; declassify?: unknown }[] }).impureCapabilities ?? [])]
804
+ .filter((c) => typeof c.declassify === "string")
805
+ .map((c) => ({ site: `capability:${c.name ?? c.capability ?? c.aggregate.id}`, reason: c.declassify as string })),
806
+ ].sort((a, b) => (a.site < b.site ? -1 : a.site > b.site ? 1 : 0));
807
+ // THE ENDORSE FLOWS (the integrity dual — security_model.md): discovery IS law. Run each
808
+ // directive's pure plan with a probe principal + payload and harvest every PrincipalEvidence
809
+ // conversion's declared flow {directiveId, sink, facet, reason}. Hashed into nomosFlowGate +
810
+ // witnessed fail-closed at runtime (Slice B). Omit-when-empty (a principal-free domain is
811
+ // byte-identical). SORTED for determinism.
812
+ const flowEndorse = [...l.module.directives]
813
+ .flatMap((d) => harvestPrincipalFlows(d as never).map((f) => ({ directiveId: d.id, sink: f.sink, facet: f.facet, reason: f.reason })))
814
+ .sort((a, b) =>
815
+ a.directiveId !== b.directiveId ? (a.directiveId < b.directiveId ? -1 : 1)
816
+ : a.sink !== b.sink ? (a.sink < b.sink ? -1 : 1)
817
+ : a.facet < b.facet ? -1 : a.facet > b.facet ? 1 : 0,
818
+ );
703
819
  return {
704
820
  path: l.path,
705
821
  prims: encodeModuleToPrims(l.path, l.module),
@@ -710,6 +826,9 @@ export function emitUsd(
710
826
  ...(manifest.combineds !== undefined ? { combineds: manifest.combineds } : {}),
711
827
  // STABLE IDS (#58): every post-#58 compile carries them (the era key).
712
828
  ...(manifest.stableIds !== undefined ? { nomosStableIds: manifest.stableIds } : {}),
829
+ // THE STRUCTURAL INTERFACE (#72): the on-ledger OFFER — a client negotiates shape-
830
+ // compatibility against this, reactively, straight from the folded `policy:{hash}.bundle`.
831
+ nomosInterface: domainInterface(l.module, manifest.stableIds),
713
832
  ...(Object.keys(readGateDirectives).length > 0
714
833
  ? {
715
834
  nomosReadGate: {
@@ -737,6 +856,18 @@ export function emitUsd(
737
856
  ...(Object.keys(encAggregates).length > 0
738
857
  ? { nomosEncryption: { v: 1 as const, aggregates: encAggregates } }
739
858
  : {}),
859
+ ...(nomosCapabilities.length > 0
860
+ ? { nomosCapabilities: { v: 1 as const, capabilities: nomosCapabilities } }
861
+ : {}),
862
+ ...(flowDeclassify.length > 0 || flowEndorse.length > 0
863
+ ? {
864
+ nomosFlowGate: {
865
+ v: 1 as const,
866
+ ...(flowDeclassify.length > 0 ? { declassify: flowDeclassify } : {}),
867
+ ...(flowEndorse.length > 0 ? { endorse: flowEndorse } : {}),
868
+ },
869
+ }
870
+ : {}),
740
871
  };
741
872
  })
742
873
  .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
package/src/usd_layers.ts CHANGED
@@ -156,6 +156,9 @@ function domainLayerAttrLines(domain: string, layer: UsdLayer, indent: string):
156
156
  ...(layer.nomosStableIds !== undefined
157
157
  ? [`${indent}custom string nomos:stableIdsHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosStableIds)))}`]
158
158
  : []),
159
+ ...(layer.nomosInterface !== undefined
160
+ ? [`${indent}custom string nomos:interfaceHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosInterface)))}`]
161
+ : []),
159
162
  ...(layer.nomosReadGate !== undefined
160
163
  ? [`${indent}custom string nomos:readGateHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosReadGate)))}`]
161
164
  : []),
@@ -635,6 +638,37 @@ export function flattenLayeredUsd(layers: readonly UsdLayer[]): UsdDocument {
635
638
  stableIds = { v: 1, domain: domainSid, aggregates };
636
639
  }
637
640
  }
641
+ // THE STRUCTURAL INTERFACE (#72): fold per-module interfaces — directives + aggregates merged
642
+ // later-wins, SORTED keys (matching the composed emission's sorted-key `domainInterface`).
643
+ let iface: UsdLayer["nomosInterface"];
644
+ {
645
+ type Dir = NonNullable<UsdLayer["nomosInterface"]>["directives"][string];
646
+ type Agg = NonNullable<UsdLayer["nomosInterface"]>["aggregates"][string];
647
+ const dirs = new Map<string, Dir>();
648
+ const aggsI = new Map<string, Agg>();
649
+ let domainI: string | undefined;
650
+ let domainSidI: string | undefined;
651
+ for (const l of stack) {
652
+ if (l.nomosInterface === undefined) continue;
653
+ domainI = l.nomosInterface.domain;
654
+ domainSidI = l.nomosInterface.domainSid;
655
+ for (const [id, spec] of Object.entries(l.nomosInterface.directives)) dirs.set(id, spec);
656
+ for (const [sid, spec] of Object.entries(l.nomosInterface.aggregates)) aggsI.set(sid, spec);
657
+ }
658
+ if (domainI !== undefined) {
659
+ const directivesRec: Record<string, Dir> = {};
660
+ for (const k of [...dirs.keys()].sort()) directivesRec[k] = dirs.get(k)!;
661
+ const aggregatesRec: Record<string, Agg> = {};
662
+ for (const k of [...aggsI.keys()].sort()) aggregatesRec[k] = aggsI.get(k)!;
663
+ iface = {
664
+ v: 1,
665
+ domain: domainI,
666
+ ...(domainSidI !== undefined ? { domainSid: domainSidI } : {}),
667
+ directives: directivesRec,
668
+ aggregates: aggregatesRec,
669
+ };
670
+ }
671
+ }
638
672
  const identityManifest = foldIdentityManifest(stack, stableIds);
639
673
  const readProjection = foldReadProjection(stack);
640
674
  // The captured-read gate (#58): queries + per-directive read sets merged
@@ -740,6 +774,7 @@ export function flattenLayeredUsd(layers: readonly UsdLayer[]): UsdDocument {
740
774
  ...(deriveds !== undefined ? { deriveds } : {}),
741
775
  ...(combineds !== undefined ? { combineds } : {}),
742
776
  ...(stableIds !== undefined ? { nomosStableIds: stableIds } : {}),
777
+ ...(iface !== undefined ? { nomosInterface: iface } : {}),
743
778
  ...(readGate !== undefined ? { nomosReadGate: readGate } : {}),
744
779
  ...(readVisibility !== undefined ? { nomosReadVisibility: readVisibility } : {}),
745
780
  ...(encryption !== undefined ? { nomosEncryption: encryption } : {}),
@@ -866,6 +901,7 @@ export function parseUsdaDocument(text: string): UsdDocument {
866
901
  const identityManifestByDomain = new Map<string, UsdLayer["nomosIdentityManifest"]>();
867
902
  const readProjectionByDomain = new Map<string, UsdLayer["nomosReadProjection"]>();
868
903
  const stableIdsByDomain = new Map<string, UsdLayer["nomosStableIds"]>();
904
+ const interfaceByDomain = new Map<string, UsdLayer["nomosInterface"]>();
869
905
  const readGateByDomain = new Map<string, UsdLayer["nomosReadGate"]>();
870
906
  const readVisibilityByDomain = new Map<string, UsdLayer["nomosReadVisibility"]>();
871
907
  const encryptionByDomain = new Map<string, UsdLayer["nomosEncryption"]>();
@@ -889,6 +925,11 @@ export function parseUsdaDocument(text: string): UsdDocument {
889
925
  if (typeof sidHex === "string") {
890
926
  stableIdsByDomain.set(domain, JSON.parse(unhexUtf8(sidHex)) as UsdLayer["nomosStableIds"]);
891
927
  }
928
+ // THE STRUCTURAL INTERFACE (#72): the on-ledger OFFER, hex-carried per domain layer.
929
+ const ifaceHex = p.attrs.get("nomos:interfaceHex");
930
+ if (typeof ifaceHex === "string") {
931
+ interfaceByDomain.set(domain, JSON.parse(unhexUtf8(ifaceHex)) as UsdLayer["nomosInterface"]);
932
+ }
892
933
  const rgHex = p.attrs.get("nomos:readGateHex");
893
934
  if (typeof rgHex === "string") {
894
935
  readGateByDomain.set(domain, JSON.parse(unhexUtf8(rgHex)) as UsdLayer["nomosReadGate"]);
@@ -1008,6 +1049,7 @@ export function parseUsdaDocument(text: string): UsdDocument {
1008
1049
  const identity = identityManifestByDomain.get(domain);
1009
1050
  const readProjection = readProjectionByDomain.get(domain);
1010
1051
  const sids = stableIdsByDomain.get(domain);
1052
+ const iface = interfaceByDomain.get(domain);
1011
1053
  const rg = readGateByDomain.get(domain);
1012
1054
  const rv = readVisibilityByDomain.get(domain);
1013
1055
  const enc = encryptionByDomain.get(domain);
@@ -1023,6 +1065,7 @@ export function parseUsdaDocument(text: string): UsdDocument {
1023
1065
  ...(b.deriveds.length > 0 ? { deriveds: [...b.deriveds].sort(byId) } : {}),
1024
1066
  ...(b.combineds.length > 0 ? { combineds: [...b.combineds].sort(byId) } : {}),
1025
1067
  ...(sids !== undefined ? { nomosStableIds: sids } : {}),
1068
+ ...(iface !== undefined ? { nomosInterface: iface } : {}),
1026
1069
  ...(rg !== undefined ? { nomosReadGate: rg } : {}),
1027
1070
  ...(rv !== undefined ? { nomosReadVisibility: rv } : {}),
1028
1071
  ...(enc !== undefined ? { nomosEncryption: enc } : {}),
package/src/wire.ts CHANGED
@@ -127,6 +127,14 @@ export interface WireIntent {
127
127
  strikes: string[];
128
128
  events: WireEvent[];
129
129
  reads?: WireRead[];
130
+ /**
131
+ * THE OBSERVED ENDORSE FOOTPRINT (security_model.md — the integrity dual, Slice B): every
132
+ * PrincipalEvidence flow this plan run actually produced `{sink, facet, reason}`. A TRANSIENT
133
+ * verify-input — it rides the plan output the gate checks against the law's `nomosFlowGate.endorse`
134
+ * (author + replay), NEVER the committed intent's events, so it is not OID-hashed. OMITTED when
135
+ * empty so a principal-free intent is byte-identical.
136
+ */
137
+ endorse?: { sink: string; facet: string; reason: string | null }[];
130
138
  }
131
139
 
132
140
  /**
@@ -26,7 +26,8 @@ import type { AggregateHandle } from "./aggregate.js";
26
26
  import type { Field, EvidenceRefValue } from "./fields.js";
27
27
  import type { Directive, ReferentialMarker } from "./directive.js";
28
28
  import type { PlannedOp, FieldOp } from "./ops.js";
29
- import type { Ports } from "./ctx.js";
29
+ import { deterministicPorts, type Ports } from "./ctx.js";
30
+ import type { PrincipalFacet } from "./principal_evidence.js";
30
31
  import type {
31
32
  WireEvent,
32
33
  WireHlc,
@@ -290,5 +291,77 @@ export function executeDirectiveToIntent<P>(
290
291
  return { aggregate, marker: eventMarker, ops };
291
292
  });
292
293
  // Attach the captured read footprint — OMITTED when empty so a read-free intent is byte-identical.
293
- return { id: intentIdFromHlc(hlc), hlc, strikes, events, ...(reads.length ? { reads } : {}) };
294
+ // Attach the OBSERVED ENDORSE footprint (Slice B) — the principal flows this run produced; the gate
295
+ // checks them against the law's nomosFlowGate.endorse. Transient verify-input (not OID-hashed),
296
+ // OMITTED when empty so a principal-free intent is byte-identical.
297
+ const endorse = flowsFromOps(ops);
298
+ return {
299
+ id: intentIdFromHlc(hlc),
300
+ hlc,
301
+ strikes,
302
+ events,
303
+ ...(reads.length ? { reads } : {}),
304
+ ...(endorse.length ? { endorse } : {}),
305
+ };
306
+ }
307
+
308
+ /** One declared principal flow harvested from a plan — `sink` is `<AggregateType>.<field>`. */
309
+ export interface PrincipalFlow {
310
+ readonly sink: string;
311
+ readonly facet: PrincipalFacet;
312
+ readonly reason: string | null;
313
+ }
314
+
315
+ /** Extract the principal flows carried by a plan's field ops (those with an `endorsement`). The ONE
316
+ * op→flow mapping shared by the discovery harvest (compile) and the runtime footprint (Slice B). */
317
+ function flowsFromOps(ops: readonly FieldOp[]): PrincipalFlow[] {
318
+ const flows: PrincipalFlow[] = [];
319
+ for (const op of ops) {
320
+ if (op.endorsement) {
321
+ flows.push({ sink: `${op.aggregateType ?? op.aggregateId}.${op.field}`, facet: op.endorsement.facet, reason: op.endorsement.reason });
322
+ }
323
+ }
324
+ return flows.sort((a, b) =>
325
+ a.sink !== b.sink ? (a.sink < b.sink ? -1 : 1) : a.facet < b.facet ? -1 : a.facet > b.facet ? 1 : 0,
326
+ );
327
+ }
328
+
329
+ /**
330
+ * THE DISCOVERY HARVEST (security_model.md — discovery IS law): run a directive's PURE plan with a
331
+ * probe payload + a probe principal, and harvest every PrincipalEvidence conversion's declared flow
332
+ * (`{sink, facet, reason}`). Executes the plan (not its AST) — the same technique as the rule-4 reads
333
+ * probe. Best-effort + fail-open on HARVEST (a probe that can't drive a branch just omits that flow);
334
+ * the omission is SAFE because the runtime/replay witness (Slice B) refuses any observed flow absent
335
+ * from the manifest. The reasons are static law, so they are intent-independent. SORTED for determinism.
336
+ */
337
+ export function harvestPrincipalFlows(directive: Directive<unknown>): PrincipalFlow[] {
338
+ const ctx: Ports = deterministicPorts({ principal: "principal:probe" });
339
+ // A probe payload: any field access yields a benign string. Plans that need a typed shape (an
340
+ // array .map, etc.) may throw — caught below; that directive's flows are then witnessed at runtime.
341
+ const probe = new Proxy({}, { get: () => "probe" });
342
+ // A plan that MINTS (`create(Agg)` — the common `.creates` shape) calls `globalThis.nomos.mint`; the
343
+ // harvest runs at compile, where the engine's real shim is absent. Install a deterministic stub so the
344
+ // probe plan runs to completion and its flows ARE harvested (an un-harvested authority flow would be
345
+ // refused at runtime by the Slice B witness — fail-closed, but a false jam). The minted VALUE is
346
+ // irrelevant — a flow's sink is the field path, not the id. Restore any pre-existing shim after.
347
+ const g = globalThis as unknown as { nomos?: { mint?: (t: string) => string; read?: unknown } };
348
+ const hadNomos = "nomos" in g;
349
+ const prior: { mint?: (t: string) => string; read?: unknown } | undefined = g.nomos;
350
+ if (!hadNomos || typeof prior?.mint !== "function") {
351
+ g.nomos = { ...(prior ?? {}), mint: (typeTag: string) => `${typeTag}_0192f00d-0000-7000-8000-000000000000` };
352
+ }
353
+ let ops: FieldOp[];
354
+ __resetAuthoring();
355
+ __resetReads();
356
+ try {
357
+ const returned = directive.plan(probe as never, ctx);
358
+ ops = [...__drainAuthoring(), ...(Array.isArray(returned) ? returned : [])].filter((op): op is FieldOp => op.kind !== "strike");
359
+ } catch {
360
+ try { __drainAuthoring(); __drainReads(); } catch { /* sink already drained */ }
361
+ return [];
362
+ } finally {
363
+ if (hadNomos && prior !== undefined) g.nomos = prior; else delete g.nomos;
364
+ }
365
+ __drainReads();
366
+ return flowsFromOps(ops);
294
367
  }