@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/index.ts CHANGED
@@ -32,7 +32,21 @@ export {
32
32
  type FieldKind,
33
33
  type EvidenceRefValue,
34
34
  evidenceRefSchema,
35
+ // FIELD FACETS (the endorse model — security_model.md): authority/standing/attribution gate
36
+ // set() acceptance of PrincipalEvidence; secret/public are the confidentiality lattice.
37
+ authorityField,
38
+ standingField,
39
+ attributionField,
40
+ secretField,
41
+ publicField,
35
42
  } from "./fields.js";
43
+ export {
44
+ type PrincipalEvidence,
45
+ type AttributionPrincipal,
46
+ type EndorsedAuthority,
47
+ type EndorsedStanding,
48
+ type FieldFacet,
49
+ } from "./principal_evidence.js";
36
50
  export {
37
51
  Lww,
38
52
  LwwCausal,
@@ -410,6 +424,16 @@ export {
410
424
  type InvariantVerdict,
411
425
  } from "./relation.js";
412
426
  export { generateDartDomain, type DomainModule } from "./codegen_dart.js";
427
+ // SHAPE COMPATIBILITY (#72) — the structural contract a client negotiates with a workspace
428
+ // by, instead of the law hash. Pure + deterministic; the editor/CI mirror imports these.
429
+ export {
430
+ domainInterface,
431
+ compatible,
432
+ type DomainInterface,
433
+ type CompatVerdict,
434
+ type DirectivePayloadShape,
435
+ type AggregateReadShape,
436
+ } from "./shape_compat.js";
413
437
  // The OPTIONAL Graphviz `.dot` ER-diagram emitter (off-by-default presentation projection,
414
438
  // turned on by the `--emit-dot` compiler flag). Pure `DomainModule -> string`, deterministic;
415
439
  // NOT part of the domain identity. Barrel-safe (no node:* imports), so the engine bundle can
package/src/manifest.ts CHANGED
@@ -39,6 +39,7 @@ import { finishExtremum, type AnyExtremum } from "./extremum.js";
39
39
  import { existsAsCount, type AnyExists } from "./exists.js";
40
40
  import type { OrderedReadDecl } from "./ordered_read.js";
41
41
  import type { CanonicalPred } from "./predicate.js";
42
+ import { assertFlowGate } from "./flow_gate.js";
42
43
  import type { DerivedDecl } from "./derived.js";
43
44
  import type { CombinedDecl } from "./combined.js";
44
45
  import type { CertifiedReadDecl } from "./certified_read.js";
@@ -1055,22 +1056,10 @@ export function domainManifest(mod: DomainModule): CanonicalManifest {
1055
1056
  }))
1056
1057
  .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
1057
1058
 
1058
- // E2E ENCRYPTION — critical #5 (e2e_encryption.md §5), the captured-read seal, COMPILE
1059
- // half. A captured read (`read()` on the write path) of a PRIVATE query would record its
1060
- // plaintext result onto the intent as PUBLIC, signed, replay-verified chain data —
1061
- // bypassing the read gate AND the encryption envelope. Forbid it: a `requires(cap)`
1062
- // query can NEVER be a captured read. (The one gate re-asserts this at admission/replay
1063
- // via `cap_for_query`, so a hand-crafted raw intent is refused on every peer too.)
1064
- const privateTypes = new Set(aggregates.filter((a) => a.requiresRead !== undefined).map((a) => a.id));
1065
- for (const d of directives) {
1066
- for (const cr of d.capturedReads ?? []) {
1067
- if (privateTypes.has(cr.returns)) {
1068
- throw new Error(
1069
- `directive '${d.id}': captured read '${cr.queryId}' returns the PRIVATE type '${cr.returns}' — a .reads() of a requires(cap) query would write its plaintext onto the chain as public data. Private data may only leave via the gated read path (query/qbyid), never a captured read.`,
1070
- );
1071
- }
1072
- }
1073
- }
1059
+ // THE INFORMATION-FLOW GATE (architecture/security_model.md): rule 2 (no private
1060
+ // captured read on the chain) + rule 1 (no encrypt-then-index) are enforced as ONE
1061
+ // pass over the assembled canonical manifest, just before return (see assertFlowGate).
1062
+ // E2E ENCRYPTION — critical #5 (e2e_encryption.md §5): the captured-read seal is rule 2.
1074
1063
 
1075
1064
  // exists_ entries are COUNT declarations in the IR — merge them into `counts` so the
1076
1065
  // Rust engine sees them as ordinary counts (the marker lives only in the DSL/codegen
@@ -1081,7 +1070,7 @@ export function domainManifest(mod: DomainModule): CanonicalManifest {
1081
1070
  // Omit-when-empty: a domain declaring NO query / NO count / NO sum contributes neither
1082
1071
  // key, so its canonical manifest is byte-identical to before the read-closure layer
1083
1072
  // existed. `sums` follows the same omit-when-empty discipline as `counts`.
1084
- return {
1073
+ const manifest: CanonicalManifest = {
1085
1074
  domain: mod.name,
1086
1075
  aggregates,
1087
1076
  directives,
@@ -1109,6 +1098,11 @@ export function domainManifest(mod: DomainModule): CanonicalManifest {
1109
1098
  ...(mod.workspaceTypes !== undefined ? { workspaceTypes: mod.workspaceTypes } : {}),
1110
1099
  }),
1111
1100
  };
1101
+ // THE STATIC INFORMATION-FLOW GATE — leaky law is unrepresentable (a typed compile
1102
+ // error naming the field, the sink, and the rule). Rules 1/2/5 read the manifest IR;
1103
+ // rule 4 also needs the raw module (the executable invariant reads + declassify waivers).
1104
+ assertFlowGate(manifest, mod as never);
1105
+ return manifest;
1112
1106
  }
1113
1107
 
1114
1108
  /** Recursively sort object keys; arrays are already in sorted/stable order. */
package/src/ops.ts CHANGED
@@ -17,6 +17,13 @@
17
17
  */
18
18
  import type { AggregateHandle, BoundAggregate } from "./aggregate.js";
19
19
  import type { Field, FieldValue } from "./fields.js";
20
+ import {
21
+ endorsementOf,
22
+ type AttributionPrincipal,
23
+ type EndorsedAuthority,
24
+ type EndorsedStanding,
25
+ type PrincipalFacet,
26
+ } from "./principal_evidence.js";
20
27
 
21
28
  /**
22
29
  * A FIELD op, retaining the source aggregate id + field for deterministic wire encoding.
@@ -41,6 +48,13 @@ export interface FieldOp {
41
48
  readonly value?: unknown;
42
49
  readonly items?: readonly string[];
43
50
  readonly entryKey?: string;
51
+ /**
52
+ * THE PRINCIPAL-FLOW ENDORSEMENT (security_model.md — the endorse model): present iff this op's
53
+ * value came from a PrincipalEvidence conversion. The raw principal is materialised into `value`;
54
+ * this records the facet + endorsement reason so the discovery harvest can lift it into the
55
+ * manifest `principalFlows`, and the runtime witness can emit the observed flow. Internal.
56
+ */
57
+ readonly endorsement?: { readonly facet: PrincipalFacet; readonly reason: string | null };
44
58
  }
45
59
 
46
60
  export type PlannedEventMarker = "creates" | "mutates" | "ensures" | "archives";
@@ -84,6 +98,20 @@ type Target<Id extends string = string, F extends Record<string, Field> = Record
84
98
 
85
99
  type Fields<A> = A extends Target<string, infer F> ? F : never;
86
100
 
101
+ /**
102
+ * What `set()` ACCEPTS for a field, given its facet (security_model.md — the endorse model).
103
+ * A plain (un-faceted) field accepts its ordinary value. A faceted field ALSO accepts the matching
104
+ * PrincipalEvidence conversion — and ONLY that one, so writing un-endorsed identity (or the wrong
105
+ * endorsement) into an authority/standing sink does not typecheck. The read/wire type is unchanged.
106
+ */
107
+ type AcceptedValue<F> = F extends { readonly facet: "authority" }
108
+ ? FieldValue<F> | EndorsedAuthority<FieldValue<F>>
109
+ : F extends { readonly facet: "standing" }
110
+ ? FieldValue<F> | EndorsedStanding<FieldValue<F>>
111
+ : F extends { readonly facet: "attribution" }
112
+ ? FieldValue<F> | AttributionPrincipal
113
+ : FieldValue<F>;
114
+
87
115
  /** Resolve a target's `{aggregateId, aggregateType}` — bound → instance id + type. */
88
116
  function addressOf(agg: Target): { aggregateId: string; aggregateType?: string } {
89
117
  if ((agg as BoundAggregate).__isBoundAggregate === true) {
@@ -110,7 +138,7 @@ export function set<
110
138
  (Fields<A>[K] extends { kind: "set" }
111
139
  ? `TYPE ERROR: '${K}' is a set field — set() would OVERWRITE the merge; use addToSet(handle, "${K}", values)`
112
140
  : K),
113
- value: FieldValue<Fields<A>[K]>,
141
+ value: AcceptedValue<Fields<A>[K]>,
114
142
  ): FieldOp {
115
143
  const f = (agg as { fields?: Record<string, { kind?: string }> }).fields?.[field];
116
144
  if (f?.kind === "set") {
@@ -119,6 +147,19 @@ export function set<
119
147
  `(concurrent adds would be lost). Use addToSet(handle, '${field}', values) — the additive write.`,
120
148
  );
121
149
  }
150
+ // MATERIALISE PrincipalEvidence: the typed value may be an evidence conversion (an opaque token).
151
+ // Store the RAW principal in the op, and record the endorsement so the harvest + runtime witness
152
+ // see the flow (security_model.md). A plain value passes through untouched.
153
+ const endorsement = endorsementOf(value);
154
+ if (endorsement) {
155
+ return {
156
+ ...addressOf(agg),
157
+ field,
158
+ kind: "set",
159
+ value: endorsement.principal,
160
+ endorsement: { facet: endorsement.facet, reason: endorsement.reason },
161
+ };
162
+ }
122
163
  return { ...addressOf(agg), field, kind: "set", value };
123
164
  }
124
165
 
@@ -0,0 +1,112 @@
1
+ // NOMOS — the integrity dual of the information-flow gate (architecture/security_model.md).
2
+ // "Identity may attribute freely. Identity may authorise only when endorsed by declared law."
3
+ //
4
+ // `ctx.principal()` returns opaque PrincipalEvidence — NOT a string. It cannot be compared,
5
+ // concatenated, branched on, or smuggled into a payload (the raw value is held in a module-
6
+ // private registry, unreadable by authors). Its ONLY exits are three typed conversions:
7
+ //
8
+ // .asAttribution() → AttributionPrincipal (free — createdBy/grantedBy/...)
9
+ // .asAuthoritySubject(reason) → EndorsedAuthority<T> (endorsed — a write-up into authority)
10
+ // .asStandingParty(reason) → EndorsedStanding<T> (endorsed — accountability: owedBy/...)
11
+ //
12
+ // Field facets gate `set()` acceptance (authorityField accepts only EndorsedAuthority, etc.), so an
13
+ // un-endorsed flow into an authority sink does not COMPILE — the confused deputy is closed by
14
+ // construction, not by plan-body taint analysis. `requires`/`assert` remain the only place an
15
+ // authority decision may be MADE (evidence can't branch, so `if (principal() === …)` won't typecheck).
16
+ //
17
+ // The `reason` is part of the LAW, not the data: a non-empty human string, harvested into the
18
+ // manifest `principalFlows` + the endorse era key (the audit surface), and witnessed fail-closed at
19
+ // runtime/replay (Slice B).
20
+
21
+ /** The endorsement facet — which kind of sink the evidence has been converted for. */
22
+ export type PrincipalFacet = "attribution" | "authority" | "standing";
23
+
24
+ /**
25
+ * A field's FACET — the integrity/confidentiality role of the sink. The integrity facets
26
+ * (authority/attribution/standing) gate `set()` acceptance of PrincipalEvidence conversions;
27
+ * the confidentiality facets (secret/public) mark the read-down lattice. Default (no facet) =
28
+ * an ordinary field that accepts a plain value.
29
+ */
30
+ export type FieldFacet = "authority" | "attribution" | "standing" | "secret" | "public";
31
+
32
+ /** The record behind an evidence-derived value, readable ONLY by the framework (never the author). */
33
+ export interface EndorsementRecord {
34
+ readonly facet: PrincipalFacet;
35
+ /** The verified authoring principal (the captured `ctx.principal()` value). */
36
+ readonly principal: string;
37
+ /** The static endorsement reason (LAW) — null for free attribution. */
38
+ readonly reason: string | null;
39
+ }
40
+
41
+ // The raw principal + endorsement live HERE, module-private — an author holding a converted value
42
+ // has no accessor to them (the brand below is phantom). The framework reads via `endorsementOf`.
43
+ const REGISTRY = new WeakMap<object, EndorsementRecord>();
44
+
45
+ declare const FACET_BRAND: unique symbol;
46
+ /** A principal converted for ATTRIBUTION — accepted by `attributionField` sinks. Opaque. */
47
+ export interface AttributionPrincipal {
48
+ readonly [FACET_BRAND]: "attribution";
49
+ }
50
+ /** A principal ENDORSED for an AUTHORITY sink (relation subject/relation/object, token, policy…). Opaque. */
51
+ export interface EndorsedAuthority<T = string> {
52
+ readonly [FACET_BRAND]: "authority";
53
+ /** Phantom — carries the sink's value type for `set()` inference; never materialised. */
54
+ readonly __t?: T;
55
+ }
56
+ /** A principal ENDORSED for a STANDING sink (owedBy/responsible/issuer…). Opaque. */
57
+ export interface EndorsedStanding<T = string> {
58
+ readonly [FACET_BRAND]: "standing";
59
+ readonly __t?: T;
60
+ }
61
+
62
+ /** The opaque identity capability `ctx.principal()` returns. No raw exit — only typed conversions. */
63
+ export interface PrincipalEvidence {
64
+ /** Free: record WHO acted, into an `attributionField` (createdBy/grantedBy/submittedBy). */
65
+ asAttribution(): AttributionPrincipal;
66
+ /** Endorsed write-up into an `authorityField` — `reason` is LAW (a static, non-empty string). */
67
+ asAuthoritySubject<T = string>(reason: string): EndorsedAuthority<T>;
68
+ /** Endorsed write-up into a `standingField` (accountability) — `reason` is LAW. */
69
+ asStandingParty<T = string>(reason: string): EndorsedStanding<T>;
70
+ }
71
+
72
+ function requireReason(kind: string, reason: unknown): string {
73
+ if (typeof reason !== "string" || reason.trim().length === 0) {
74
+ throw new Error(
75
+ `${kind}(reason) requires a non-empty reason — endorsing identity into an authority sink is a ` +
76
+ `declared act of law, not data. Pass a static string explaining why this flow is intended.`,
77
+ );
78
+ }
79
+ return reason;
80
+ }
81
+
82
+ function mint(facet: PrincipalFacet, principal: string, reason: string | null): object {
83
+ const token = {};
84
+ REGISTRY.set(token, { facet, principal, reason });
85
+ return token;
86
+ }
87
+
88
+ /** Build the opaque evidence capability over a verified principal (the engine wires this into ctx). */
89
+ export function principalEvidence(principal: string): PrincipalEvidence {
90
+ return {
91
+ asAttribution: () => mint("attribution", principal, null) as AttributionPrincipal,
92
+ asAuthoritySubject: <T = string>(reason: string) =>
93
+ mint("authority", principal, requireReason("asAuthoritySubject", reason)) as EndorsedAuthority<T>,
94
+ asStandingParty: <T = string>(reason: string) =>
95
+ mint("standing", principal, requireReason("asStandingParty", reason)) as EndorsedStanding<T>,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * FRAMEWORK-INTERNAL: read the endorsement behind an evidence-derived value — used by the write
101
+ * path (to materialise the raw principal into the op), the discovery harvest (to record the flow),
102
+ * and the runtime/replay witness (to emit the observed flow). Returns null for a plain value (a
103
+ * non-evidence write). Authors cannot call this usefully — the REGISTRY is module-private.
104
+ */
105
+ export function endorsementOf(value: unknown): EndorsementRecord | null {
106
+ return typeof value === "object" && value !== null && REGISTRY.has(value) ? (REGISTRY.get(value) ?? null) : null;
107
+ }
108
+
109
+ /** Is this value an evidence conversion (any facet)? */
110
+ export function isPrincipalEvidenceValue(value: unknown): boolean {
111
+ return endorsementOf(value) !== null;
112
+ }
@@ -210,10 +210,16 @@ export interface SugaredRelationTypeBuilder extends RelationTypeBuilder {
210
210
  * builder; we re-wrap on every chain step so the audience methods survive interleaving with `.relation`.
211
211
  */
212
212
  export function withAudiences(builder: RelationTypeBuilder): SugaredRelationTypeBuilder {
213
- // The builder object is mutated in place by `.relation(...)` (it returns itself), so we can safely
214
- // attach methods that delegate to it and re-wrap. We attach to a fresh proxy-like object that forwards
215
- // the decl fields by reference (the Map is shared, so reads stay live).
216
- const sugared = Object.create(builder) as SugaredRelationTypeBuilder;
213
+ // Carry the decl's data as OWN enumerable properties (NOT `Object.create(builder)` — a prototype-only
214
+ // object serializes to `{}`, which collapses the compiler's `discover()` JSON dedup so every sugared
215
+ // type but the first is dropped). `relations` is the SAME Map reference the underlying builder mutates,
216
+ // so reads stay live as `.relation(...)`/the audience methods add to it.
217
+ const sugared = {
218
+ type: builder.type,
219
+ relations: builder.relations,
220
+ ...(builder.caps !== undefined ? { caps: builder.caps } : {}),
221
+ __isRelationType: true as const,
222
+ } as SugaredRelationTypeBuilder;
217
223
 
218
224
  sugared.relation = function (name, opts) {
219
225
  builder.relation(name, opts);
@@ -0,0 +1,208 @@
1
+ // NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
2
+ // remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
3
+ // wasm32-wasip1 artifact. If a file isn't this / hosting this / authoring for this
4
+ // / proving this — it's gone.
5
+ //
6
+ // SHAPE COMPATIBILITY (#72) — the structural contract a client and a workspace
7
+ // negotiate by, INSTEAD of the package hash. See architecture/shape_compatibility.md.
8
+ //
9
+ // A client fits a workspace iff the workspace's installed interface SUBTYPES what the
10
+ // client depends on:
11
+ // • WRITES are CONTRAVARIANT — for every directive the client calls, the workspace
12
+ // has that directive id and its CURRENT payload schema ACCEPTS the client's
13
+ // payload: every field the workspace REQUIRES the client always sends, and every
14
+ // shared field's kind matches. (Extra client fields are ignored — non-strict zod.)
15
+ // • READS are COVARIANT by STABLE ID (#58) — for every aggregate the client reads,
16
+ // the workspace has that aggregate SID and every field the client CONSUMES (by
17
+ // field sid) is present with a compatible kind. The workspace may serve MORE.
18
+ //
19
+ // Identity is by stable id, not name or hash — that is what makes the relation
20
+ // decidable across renames (a renamed aggregate field keeps its sid, so an old client
21
+ // stays read-compatible). Directive/payload identity is the dispatched id + field
22
+ // names (directives are not sid-tracked yet — a payload rename IS a write break, and
23
+ // is reported precisely).
24
+
25
+ import { z } from "zod";
26
+ import { introspectPayload, type DomainModule, type PayloadFieldSpec } from "./codegen_dart.js";
27
+ import type { StableIds } from "./stable_ids_types.js";
28
+ import type { Field } from "./fields.js";
29
+
30
+ /** One payload field's normalized shape: its kind + whether the schema makes it optional. */
31
+ export interface PayloadFieldShape {
32
+ readonly kind: string;
33
+ readonly optional: boolean;
34
+ }
35
+ /** A directive's payload contract — the field set + types the gate validates against. */
36
+ export interface DirectivePayloadShape {
37
+ readonly fields: Record<string, PayloadFieldShape>;
38
+ }
39
+ /** An aggregate's read contract — fields keyed by STABLE ID (rename-safe), each carrying
40
+ * its current label (for human-readable verdicts) + its read kind. */
41
+ export interface AggregateReadShape {
42
+ readonly sid: string;
43
+ readonly label: string;
44
+ /** keyed by FIELD SID. */
45
+ readonly fields: Record<string, { kind: string; label: string }>;
46
+ }
47
+ /** A domain's full structural interface — the offer (a workspace's installed law) AND
48
+ * the requirement (the law a client was generated from) are the SAME vocabulary. */
49
+ export interface DomainInterface {
50
+ readonly v: 1;
51
+ readonly domain: string;
52
+ readonly domainSid?: string;
53
+ /** keyed by DIRECTIVE ID (the dispatched name). */
54
+ readonly directives: Record<string, DirectivePayloadShape>;
55
+ /** keyed by AGGREGATE SID. */
56
+ readonly aggregates: Record<string, AggregateReadShape>;
57
+ }
58
+
59
+ /** The verdict: compatible iff nothing is missing and nothing is type-incompatible.
60
+ * `missing` = a depended-on directive/aggregate/field the workspace doesn't expose;
61
+ * `incompatible` = present but type-divergent (a payload field kind, a retyped read field). */
62
+ export interface CompatVerdict {
63
+ readonly ok: boolean;
64
+ readonly missing: string[];
65
+ readonly incompatible: string[];
66
+ }
67
+
68
+ /** Normalize a directive payload field to a language-neutral kind from its introspected
69
+ * spec (the SAME `introspectPayload` the Dart/TS clients are generated from, so both
70
+ * sides of a comparison speak one vocabulary). */
71
+ function payloadFieldKind(spec: PayloadFieldSpec): string {
72
+ switch (spec.toJson) {
73
+ case "enum":
74
+ return "enum";
75
+ case "strList":
76
+ return "set";
77
+ case "json":
78
+ return "json";
79
+ case "scalar":
80
+ default:
81
+ switch (spec.dartType) {
82
+ case "int":
83
+ return "int";
84
+ case "double":
85
+ return "double";
86
+ case "bool":
87
+ return "bool";
88
+ default:
89
+ return "string";
90
+ }
91
+ }
92
+ }
93
+
94
+ /** The aggregate read kind when no stable-id kind is recorded (pre-#58 fallback): the
95
+ * field's own declared kind, with the read-collapsed CRDTs normalized to their scalar. */
96
+ function aggFieldReadKind(f: Field): string {
97
+ switch (f.kind) {
98
+ case "counter":
99
+ return "int";
100
+ case "position":
101
+ return "string";
102
+ default:
103
+ return f.kind;
104
+ }
105
+ }
106
+
107
+ /** Extract a domain's structural interface from its loaded module + (optionally) its
108
+ * stable-id surface. Without stable ids (pre-#58) aggregate fields key by NAME and the
109
+ * kind comes from the field declaration — a graceful, rename-unaware fallback. */
110
+ export function domainInterface(mod: DomainModule, stableIds?: StableIds): DomainInterface {
111
+ const enumTypes = new Map<string, string>();
112
+ // DETERMINISTIC KEY ORDER — directive ids, aggregate sids, and field keys are SORTED, so the
113
+ // emitted interface is byte-stable and the layered-USD fold (which merges + sorts) reproduces it
114
+ // byte-identically (the `nomosStableIds`/`nomosReadGate` convention). architecture/shape_compatibility.md.
115
+ const sortedRec = <T>(entries: Array<[string, T]>): Record<string, T> => {
116
+ const out: Record<string, T> = {};
117
+ for (const [k, v] of entries.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))) out[k] = v;
118
+ return out;
119
+ };
120
+ const directives: Record<string, DirectivePayloadShape> = {};
121
+ const dirEntries: Array<[string, DirectivePayloadShape]> = [];
122
+ for (const d of (mod.directives ?? []) as Array<{ id?: unknown; payloadSchema?: unknown }>) {
123
+ if (!d || typeof d.id !== "string") continue;
124
+ const fieldEntries: Array<[string, PayloadFieldShape]> = [];
125
+ const schema = d.payloadSchema;
126
+ if (schema && typeof schema === "object") {
127
+ for (const spec of introspectPayload(schema as z.ZodTypeAny, enumTypes)) {
128
+ fieldEntries.push([spec.name, { kind: payloadFieldKind(spec), optional: !!spec.optional }]);
129
+ }
130
+ }
131
+ dirEntries.push([d.id, { fields: sortedRec(fieldEntries) }]);
132
+ }
133
+ Object.assign(directives, sortedRec(dirEntries));
134
+ const aggregates: Record<string, AggregateReadShape> = {};
135
+ const aggEntries: Array<[string, AggregateReadShape]> = [];
136
+ for (const agg of (mod.aggregates ?? []) as Array<{ id: string; fields: Record<string, Field> }>) {
137
+ const label = agg.id;
138
+ const sa = stableIds?.aggregates?.[label];
139
+ const aggSid = sa?.sid ?? label;
140
+ const fieldEntries: Array<[string, { kind: string; label: string }]> = [];
141
+ for (const [fname, f] of Object.entries(agg.fields ?? {})) {
142
+ if (!f || f.kind === "hasMany") continue;
143
+ const fsid = sa?.fields?.[fname]?.sid ?? fname;
144
+ const kind = sa?.fields?.[fname]?.kind ?? aggFieldReadKind(f);
145
+ fieldEntries.push([fsid, { kind, label: fname }]);
146
+ }
147
+ aggEntries.push([aggSid, { sid: aggSid, label, fields: sortedRec(fieldEntries) }]);
148
+ }
149
+ Object.assign(aggregates, sortedRec(aggEntries));
150
+ return {
151
+ v: 1,
152
+ domain: (mod as { domain?: string; name?: string }).domain ?? (mod as { name?: string }).name ?? "",
153
+ ...(stableIds?.domain !== undefined ? { domainSid: stableIds.domain } : {}),
154
+ directives,
155
+ aggregates,
156
+ };
157
+ }
158
+
159
+ /** Decide whether a CLIENT interface is satisfied by a WORKSPACE interface (the offer).
160
+ * Pure + deterministic — the SAME relation runs at connect() and (mirrored) at the
161
+ * editor/CI. Names the exact directive / aggregate / field at fault, never a hash. */
162
+ export function compatible(client: DomainInterface, workspace: DomainInterface): CompatVerdict {
163
+ const missing: string[] = [];
164
+ const incompatible: string[] = [];
165
+
166
+ // WRITES — contravariant. The workspace must accept every payload the client sends.
167
+ for (const [did, cShape] of Object.entries(client.directives)) {
168
+ const wShape = workspace.directives[did];
169
+ if (!wShape) {
170
+ missing.push(`directive '${did}'`);
171
+ continue;
172
+ }
173
+ // Every field the WORKSPACE requires, the client must ALWAYS send (present + required).
174
+ for (const [f, wf] of Object.entries(wShape.fields)) {
175
+ if (wf.optional) continue;
176
+ const cf = cShape.fields[f];
177
+ if (!cf || cf.optional) {
178
+ incompatible.push(`directive '${did}': workspace requires field '${f}' the client does not always send`);
179
+ }
180
+ }
181
+ // Every field both sides know must agree on kind (a payload retype is a write break).
182
+ for (const [f, cf] of Object.entries(cShape.fields)) {
183
+ const wf = wShape.fields[f];
184
+ if (wf && wf.kind !== cf.kind) {
185
+ incompatible.push(`directive '${did}': field '${f}' kind ${cf.kind} ≠ workspace ${wf.kind}`);
186
+ }
187
+ }
188
+ }
189
+
190
+ // READS — covariant by stable id. Every field the client consumes must be served compatibly.
191
+ for (const [sid, cAgg] of Object.entries(client.aggregates)) {
192
+ const wAgg = workspace.aggregates[sid];
193
+ if (!wAgg) {
194
+ missing.push(`aggregate '${cAgg.label}'`);
195
+ continue;
196
+ }
197
+ for (const [fsid, cf] of Object.entries(cAgg.fields)) {
198
+ const wf = wAgg.fields[fsid];
199
+ if (!wf) {
200
+ missing.push(`field '${cAgg.label}.${cf.label}'`);
201
+ } else if (wf.kind !== cf.kind) {
202
+ incompatible.push(`field '${cAgg.label}.${cf.label}': read kind ${cf.kind} ≠ workspace ${wf.kind}`);
203
+ }
204
+ }
205
+ }
206
+
207
+ return { ok: missing.length === 0 && incompatible.length === 0, missing, incompatible };
208
+ }