@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/dart/lib/src/webview_bridge.dart +52 -0
- package/package.json +1 -1
- package/src/aggregate.ts +9 -1
- package/src/authoring.ts +10 -1
- package/src/codegen_dart.ts +22 -12
- package/src/codegen_dart_client.ts +54 -5
- package/src/codegen_ts.ts +32 -7
- package/src/compile_package_main.ts +90 -20
- package/src/ctx.ts +13 -0
- package/src/engine_entry.ts +22 -5
- package/src/fields.ts +44 -0
- package/src/flow_gate.ts +208 -0
- package/src/framework/capability.ts +19 -1
- package/src/framework/encryption_scope.ts +9 -1
- package/src/framework/genesis.ts +5 -1
- package/src/framework/relations.ts +23 -4
- package/src/framework/signing_identity.ts +1 -1
- package/src/framework/workspace_invariant.ts +29 -8
- package/src/framework/workspaces.ts +7 -0
- package/src/index.ts +24 -0
- package/src/manifest.ts +11 -17
- package/src/ops.ts +42 -1
- package/src/principal_evidence.ts +112 -0
- package/src/relation_sugar.ts +10 -4
- package/src/shape_compat.ts +208 -0
- package/src/usd.ts +131 -0
- package/src/usd_layers.ts +43 -0
- package/src/wire.ts +8 -0
- package/src/wire_encode.ts +75 -2
- package/src/workspace_sharding.ts +195 -17
- package/src/workspace_type.ts +1 -0
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
|
-
//
|
|
1059
|
-
//
|
|
1060
|
-
//
|
|
1061
|
-
//
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|
package/src/relation_sugar.ts
CHANGED
|
@@ -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
|
-
//
|
|
214
|
-
//
|
|
215
|
-
// the
|
|
216
|
-
|
|
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
|
+
}
|