@githolon/dsl 0.15.0 → 0.16.1

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.
Files changed (47) hide show
  1. package/dart/CHANGELOG.md +2 -0
  2. package/dart/FINDINGS.md +2 -2
  3. package/dart/LICENSE +45 -0
  4. package/dart/README.md +9 -0
  5. package/dart/example/nomos_webview_transport.dart +4 -4
  6. package/dart/lib/src/offer.dart +2 -2
  7. package/dart/lib/src/subscriptions.dart +1 -1
  8. package/dart/lib/src/webview_bridge.dart +52 -0
  9. package/dart/pubspec.yaml +3 -3
  10. package/dart/test/schema_validation_test.dart +1 -1
  11. package/dart/test/webview_bridge_test.dart +1 -1
  12. package/dart/test/wire_test.dart +1 -1
  13. package/package.json +1 -1
  14. package/src/aggregate.ts +9 -1
  15. package/src/authoring.ts +10 -1
  16. package/src/codegen_dart.ts +25 -15
  17. package/src/codegen_dart_client.ts +54 -5
  18. package/src/codegen_openapi.ts +239 -0
  19. package/src/codegen_proof.ts +16 -9
  20. package/src/codegen_ts.ts +83 -22
  21. package/src/compile_package_main.ts +117 -29
  22. package/src/ctx.ts +13 -0
  23. package/src/directive.ts +1 -1
  24. package/src/engine_entry.ts +22 -5
  25. package/src/fields.ts +44 -0
  26. package/src/flow_gate.ts +208 -0
  27. package/src/framework/capability.ts +19 -1
  28. package/src/framework/encryption_scope.ts +9 -1
  29. package/src/framework/genesis.ts +5 -1
  30. package/src/framework/relations.ts +23 -4
  31. package/src/framework/repair.ts +2 -2
  32. package/src/framework/signing_identity.ts +1 -1
  33. package/src/framework/workspace_invariant.ts +29 -8
  34. package/src/framework/workspaces.ts +147 -15
  35. package/src/index.ts +24 -0
  36. package/src/manifest.ts +12 -18
  37. package/src/ops.ts +42 -1
  38. package/src/principal_evidence.ts +112 -0
  39. package/src/relation_sugar.ts +10 -4
  40. package/src/shape_compat.ts +208 -0
  41. package/src/usd.ts +131 -0
  42. package/src/usd_layers.ts +43 -0
  43. package/src/wire.ts +8 -0
  44. package/src/wire_encode.ts +75 -2
  45. package/src/workspace_sharding.ts +196 -18
  46. package/src/workspace_type.ts +3 -2
  47. /package/dart/lib/{nomos_dsl.dart → nomos_client.dart} +0 -0
@@ -0,0 +1,239 @@
1
+ // NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted remotes are
2
+ // replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon wasm32-wasip1 artifact.
3
+ // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
4
+
5
+ /**
6
+ * codegen_openapi.ts — THE DOMAIN'S SELF-DESCRIPTION (architecture/githolon_host_contract.md,
7
+ * "The Self-Describing API"). Each domain emits an OpenAPI fragment FROM ITS OWN ZOD at compile
8
+ * time — the same schemas the engine validates, lowered via `z.toJSONSchema`. This is the law's
9
+ * own product, never host code: the host INGESTS the workspace's merged spec at runtime and builds
10
+ * its router from it (path → `offer`/`query` rewrite), so no domain or directive name ever lives in
11
+ * host source.
12
+ *
13
+ * Each operation is an OFFER you can make to the workspace (the one effectful move) or a read:
14
+ * - an offerable intent → `POST /{directiveId}` whose requestBody is the intent's payload JSON
15
+ * Schema. On the wire an intent names exactly one directive, so `directiveId` is the offer
16
+ * SELECTOR (it rides in `x-nomos`, not as "the API"); the directive's PLAN — its fan-out into
17
+ * aggregates/events — is private and never appears here;
18
+ * - every READ → a `GET`: declared queries (+ the hasMany inverse indexes) by their key, and the
19
+ * maintained primitives — counts / sums / extremums (min,max) / spatial — by `group`/`bbox`;
20
+ * - each operation carries an `x-nomos` extension — `{ kind: "offer"|"query"|"count"|"sum"|
21
+ * "extremum"|"spatial", domain, … }` — the ONLY thing the host reads to fold a matched request
22
+ * into the opaque intent/query/read envelope.
23
+ *
24
+ * THE WORKSPACE SPEC is the runtime MERGE of its installed domains' fragments (which are active is
25
+ * `currentLaw`). `mergeOpenApiFragments` is that merge: a plain union today (no domain cascades on
26
+ * another's API SURFACE — scoped reads / `.encrypted` / `requires(cap)` change runtime BEHAVIOUR,
27
+ * not operation SHAPE), with two law-blind structural touch-ups — NAMESPACE a cross-domain path
28
+ * collision under `/{domain}/…` so neither domain's operation clobbers the other (operations carry
29
+ * their domain in `operationId`, so two domains' same-named directives ARE distinct ops and collide),
30
+ * and DEDUPE a byte-identical operation (defensive — e.g. a fragment seen twice). The merge reads
31
+ * only structure; if a real cross-domain cascade ever lands, THIS is the seam for it.
32
+ */
33
+ import { z } from "zod";
34
+ import type { DomainModule } from "./codegen_dart.js";
35
+ import type { Directive } from "./directive.js";
36
+ import type { QueryDecl } from "./query.js";
37
+ import { hasManyIndexes } from "./query.js";
38
+ import { finishCount } from "./count.js";
39
+ import { finishSum } from "./sum.js";
40
+ import { finishExtremum } from "./extremum.js";
41
+
42
+ type AnyDirective = Directive<unknown>;
43
+
44
+ const GROUP_PARAM = { name: "group", in: "query", required: false, schema: { type: "string" } } as const;
45
+
46
+ /** Minimal OpenAPI 3.1 document shape we emit (open object — extensions ride as `x-…` keys). */
47
+ export interface OpenApiDoc {
48
+ openapi: "3.1.0";
49
+ info: Record<string, unknown>;
50
+ paths: Record<string, Record<string, unknown>>;
51
+ }
52
+
53
+ export interface OpenApiFragmentOptions {
54
+ /** The content hash of the installed law (the spec's version + ingest cache key). */
55
+ lawHash?: string;
56
+ }
57
+
58
+ /** Lower a directive's payload zod to JSON Schema; a non-representable schema falls back to a
59
+ * permissive object rather than failing the whole compile (the engine still validates the real zod). */
60
+ function payloadJsonSchema(d: AnyDirective): unknown {
61
+ try {
62
+ return z.toJSONSchema((d as { payloadSchema: z.ZodTypeAny }).payloadSchema, { io: "input" });
63
+ } catch {
64
+ return { type: "object", additionalProperties: true };
65
+ }
66
+ }
67
+
68
+ /** The domain key the engine resolves intents against (the `author` registry key). */
69
+ function domainKey(module: DomainModule): string {
70
+ return module.domain ?? module.name;
71
+ }
72
+
73
+ /**
74
+ * generateOpenApiFragment — one domain → its OpenAPI fragment. Pure: same module ⇒ same fragment.
75
+ * Paths are bare (`/{id}`); the workspace gateway mounts the merged spec under `/ws/{holonId}`, and
76
+ * the merge namespaces any cross-domain collision. The offer/query distinction rides on the HTTP
77
+ * method (POST/GET) AND the authoritative `x-nomos.kind` — the host reads the latter, never the name.
78
+ */
79
+ export function generateOpenApiFragment(
80
+ module: DomainModule,
81
+ opts: OpenApiFragmentOptions = {},
82
+ ): OpenApiDoc {
83
+ const domain = domainKey(module);
84
+ const paths: Record<string, Record<string, unknown>> = {};
85
+
86
+ // THE GATE AUDIT INDEX: every offer must show its protection, so enumerating the spec IS the security
87
+ // audit. `requires` = the .requires relation gate; `invariants` = the workspaceInvariants that judge it
88
+ // (e.g. provisionWorkspace's ownerGrantHash==foundingAdminHash). An offer with NEITHER is a VISIBLE
89
+ // red flag (a raw ungated primitive like relateSubject). The spec hides nothing — that's the point.
90
+ const invariantsOf = (directiveId: string) =>
91
+ (module.workspaceInvariants ?? []).filter((i) => i.on === directiveId).map((i) => i.id);
92
+
93
+ // OFFERABLE INTENTS → POST offers. The payload JSON Schema is the intent's own zod, lowered.
94
+ for (const dir of module.directives as AnyDirective[]) {
95
+ const invs = invariantsOf(dir.id);
96
+ const op: Record<string, unknown> = {
97
+ operationId: `${domain}__${dir.id}`,
98
+ tags: [domain],
99
+ summary: `offer: ${dir.id}`,
100
+ description: dir.marker && dir.aggregateId
101
+ ? `Offer the \`${dir.id}\` intent (${dir.marker} ${dir.aggregateId}). Admitted or refused by the law; the plan is private.`
102
+ : `Offer the \`${dir.id}\` intent. Admitted or refused by the law.`,
103
+ "x-nomos": {
104
+ kind: "offer",
105
+ domain,
106
+ directiveId: dir.id,
107
+ ...(dir.marker ? { marker: dir.marker } : {}),
108
+ ...(dir.aggregateId ? { aggregate: dir.aggregateId } : {}),
109
+ ...(dir.requiresRelationGate ? { requires: dir.requiresRelationGate.relation } : {}),
110
+ ...(invs.length ? { invariants: invs } : {}),
111
+ },
112
+ requestBody: {
113
+ required: true,
114
+ content: { "application/json": { schema: payloadJsonSchema(dir) } },
115
+ },
116
+ responses: {
117
+ "200": { description: "admitted — the sealed intent head" },
118
+ "401": { description: "no identity / identity refused" },
119
+ "422": { description: "the law refused the intent (typed refusal)" },
120
+ },
121
+ };
122
+ (paths[`/${dir.id}`] ??= {}).post = op;
123
+ }
124
+
125
+ // QUERIES → GET reads. Declared queries + the implicit hasMany inverse indexes (the relation IS
126
+ // the index) — both are reads the workspace serves; both belong in the spec.
127
+ const queries: QueryDecl[] = [
128
+ ...(module.queries ?? []),
129
+ ...module.aggregates.flatMap((agg) => hasManyIndexes(agg)),
130
+ ];
131
+ for (const q of queries) {
132
+ const op: Record<string, unknown> = {
133
+ operationId: `${domain}__${q.id}`,
134
+ tags: [domain],
135
+ summary: `read ${q.returns} by ${q.key.join(", ") || "—"}`,
136
+ "x-nomos": { kind: "query", domain, queryId: q.id, key: q.key, returns: q.returns },
137
+ parameters: q.key.map((k) => ({ name: k, in: "query", required: true, schema: { type: "string" } })),
138
+ responses: { "200": { description: `rows of ${q.returns}` } },
139
+ };
140
+ (paths[`/${q.id}`] ??= {}).get = op;
141
+ }
142
+
143
+ // THE MAINTAINED READ PRIMITIVES — counts / sums / extremums (min,max) / spatial. Each is an O(1)
144
+ // read the workspace serves; each gets its GET. (Declared queries above are the indexed-row reads;
145
+ // these are the tallies + the geo index. Derived/combined fields are read-MODEL fields, surfaced on
146
+ // the aggregate's query rows — not their own endpoints.)
147
+ const getRead = (id: string, summary: string, xnomos: Record<string, unknown>, params: unknown[]) => {
148
+ (paths[`/${id}`] ??= {}).get = {
149
+ operationId: `${domain}__${id}`,
150
+ tags: [domain],
151
+ summary,
152
+ "x-nomos": { domain, ...xnomos },
153
+ parameters: params,
154
+ responses: { "200": { description: summary } },
155
+ };
156
+ };
157
+ for (const raw of module.counts ?? []) {
158
+ const c = finishCount(raw);
159
+ getRead(c.id, `count: ${c.of}${c.by ? ` by ${c.by}` : ""}`, { kind: "count", countId: c.id, ...(c.by ? { group: c.by } : {}) }, c.by ? [GROUP_PARAM] : []);
160
+ }
161
+ for (const raw of module.sums ?? []) {
162
+ const s = finishSum(raw);
163
+ getRead(s.id, `sum: ${s.sumField} of ${s.of}${s.by ? ` by ${s.by}` : ""}`, { kind: "sum", sumId: s.id, sumField: s.sumField, ...(s.by ? { group: s.by } : {}) }, s.by ? [GROUP_PARAM] : []);
164
+ }
165
+ for (const [key, kind] of [["mins", "min"], ["maxes", "max"]] as const) {
166
+ for (const raw of (module as unknown as Record<string, unknown[]>)[key] ?? []) {
167
+ const e = finishExtremum(raw as never);
168
+ getRead(e.id, `${kind}: ${e.valueField} of ${e.of}${e.by ? ` by ${e.by}` : ""}`, { kind: "extremum", extremumId: e.id, extremumKind: e.kind, ...(e.by ? { group: e.by } : {}) }, e.by ? [GROUP_PARAM] : []);
169
+ }
170
+ }
171
+ for (const sp of module.spatials ?? []) {
172
+ getRead(sp.id, `spatial: ${sp.of} by ${sp.on} bbox`, { kind: "spatial", spatialId: sp.id, on: sp.on }, [
173
+ { name: "bbox", in: "query", required: true, schema: { type: "string" }, description: "minLng,minLat,maxLng,maxLat" },
174
+ ]);
175
+ }
176
+
177
+ return {
178
+ openapi: "3.1.0",
179
+ info: {
180
+ title: `${domain} (nomos domain)`,
181
+ version: opts.lawHash ? opts.lawHash.slice(0, 12) : "0",
182
+ "x-nomos-domain": domain,
183
+ ...(opts.lawHash ? { "x-nomos-law-hash": opts.lawHash } : {}),
184
+ },
185
+ paths,
186
+ };
187
+ }
188
+
189
+ /** Stable JSON of an operation, to detect a genuine duplicate vs a real collision in the merge. */
190
+ function operationKey(op: unknown): string {
191
+ return JSON.stringify(op);
192
+ }
193
+
194
+ /**
195
+ * mergeOpenApiFragments — the WORKSPACE spec = the union of its installed domains' fragments. The
196
+ * runtime step (driven by `currentLaw`), kept here as a pure, law-blind function:
197
+ * - UNION the paths;
198
+ * - DEDUPE an operation declared identically by several domains (framework aggregates) — one entry;
199
+ * - NAMESPACE a genuine collision (same path+method, DIFFERENT operation, different domains) under
200
+ * `/{domain}/{path}` so neither is clobbered.
201
+ * It reads only structure (paths, methods, the `x-nomos.domain` tag) — never a directive's meaning.
202
+ */
203
+ export function mergeOpenApiFragments(
204
+ fragments: readonly OpenApiDoc[],
205
+ opts: { title?: string; lawHash?: string } = {},
206
+ ): OpenApiDoc {
207
+ const paths: Record<string, Record<string, unknown>> = {};
208
+ for (const frag of fragments) {
209
+ for (const [path, methods] of Object.entries(frag.paths)) {
210
+ for (const [method, op] of Object.entries(methods)) {
211
+ const existing = paths[path]?.[method];
212
+ if (existing === undefined) {
213
+ (paths[path] ??= {})[method] = op;
214
+ } else if (operationKey(existing) === operationKey(op)) {
215
+ // identical (a framework operation shared by many domains) — keep the one entry.
216
+ } else {
217
+ // genuine collision: two domains, same path+method, different operations. Namespace BOTH
218
+ // under their domain so neither wins silently.
219
+ const domOf = (o: unknown): string =>
220
+ String((o as { "x-nomos"?: { domain?: string } })?.["x-nomos"]?.domain ?? "domain");
221
+ const bucket = paths[path]!;
222
+ (paths[`/${domOf(existing)}${path}`] ??= {})[method] = existing;
223
+ (paths[`/${domOf(op)}${path}`] ??= {})[method] = op;
224
+ delete bucket[method];
225
+ if (Object.keys(bucket).length === 0) delete paths[path];
226
+ }
227
+ }
228
+ }
229
+ }
230
+ return {
231
+ openapi: "3.1.0",
232
+ info: {
233
+ title: opts.title ?? "nomos workspace",
234
+ version: opts.lawHash ? opts.lawHash.slice(0, 12) : "0",
235
+ ...(opts.lawHash ? { "x-nomos-law-hash": opts.lawHash } : {}),
236
+ },
237
+ paths,
238
+ };
239
+ }
@@ -332,6 +332,12 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
332
332
  : JSON.stringify(baseValue);
333
333
  const queryParamsObj = query ? Object.fromEntries(query.key.map((k) => [k, baseValue[k]])) : undefined;
334
334
  const queryParams = queryParamsObj ? JSON.stringify(queryParamsObj) : "";
335
+ // The gateway routes a declared query by its id: GET /v2/workspaces/<ws>/<queryId>?<key>=<val>
336
+ // (the host reads each declared key from the querystring — never a generic /query?params= lane).
337
+ const queryQs =
338
+ query && queryParamsObj
339
+ ? query.key.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(String(queryParamsObj[k]))}`).join("&")
340
+ : "";
335
341
  const listAccessor = `list${agg.id}s`;
336
342
 
337
343
  // THE MACHINE-READABLE LEGS (parsed by `githolon`'s offline proof runner —
@@ -376,7 +382,7 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
376
382
  `try {`,
377
383
  ``,
378
384
  `// ${n()}. a throwaway workspace (the ONE-TIME secret comes back on create)`,
379
- `let r = await fetch(\`\${CLOUD}/v1/workspaces/\${WS}\`, { method: "POST", headers: { "x-nomos-principal": "githolon-proof" } });`,
385
+ `let r = await fetch(\`\${CLOUD}/v2/workspaces/\${WS}\`, { method: "POST", headers: { "x-nomos-principal": "githolon-proof" } });`,
380
386
  `let d = await r.json();`,
381
387
  `if (!d.ok) fail(\`create workspace: \${JSON.stringify(d)}\`);`,
382
388
  `SECRET = d.workspaceSecret;`,
@@ -384,7 +390,7 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
384
390
  `ok(\`workspace \${WS} created (one-time secret held for cleanup — PROOF_KEEP=1 keeps the workspace and prints it)\`);`,
385
391
  ``,
386
392
  `// ${n()}. deploy YOUR compiled law (build/${opts.packageName}.deploy.json) WITH the secret`,
387
- `r = await fetch(\`\${CLOUD}/v1/workspaces/\${WS}/domains\`, { method: "POST", headers: { "content-type": "application/json", authorization: \`Bearer \${SECRET}\` }, body: JSON.stringify(deploy) });`,
393
+ `r = await fetch(\`\${CLOUD}/v2/workspaces/\${WS}/domains\`, { method: "POST", headers: { "content-type": "application/json", authorization: \`Bearer \${SECRET}\` }, body: JSON.stringify(deploy) });`,
388
394
  `d = await r.json();`,
389
395
  `if (!d.ok || d.installation?.[0]?.data?.["status.phase"] !== "Active") fail(\`deploy: \${JSON.stringify(d)}\`);`,
390
396
  `if (d.domainHash !== ${hashConst}) fail(\`deploy hash \${d.domainHash} != the generated client's \${${hashConst}}\`);`,
@@ -463,8 +469,9 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
463
469
 
464
470
  if (query) {
465
471
  out.push(
466
- `// ${n()}. the CLOUD answers the SAME declared query (read-manifest overlay, edge projection)`,
467
- `d = await (await fetch(\`\${CLOUD}/v1/workspaces/\${WS}/query?queryId=${query.id}&params=\${encodeURIComponent(${JSON.stringify(queryParams)})}\`)).json();`,
472
+ `// ${n()}. the CLOUD answers the SAME declared query — routed by the workspace's OWN OpenAPI`,
473
+ `// (GET /v2/workspaces/<ws>/${query.id}?${queryQs}); the host learns the route from the law's spec.`,
474
+ `d = await (await fetch(\`\${CLOUD}/v2/workspaces/\${WS}/${query.id}${queryQs ? `?${queryQs}` : ``}\`)).json();`,
468
475
  `if (!(d.ok && d.rows.length === 1 && d.rows[0].id === createdId)) fail(\`cloud declared query: \${JSON.stringify(d)}\`);`,
469
476
  `ok("cloud declared query ${query.id} returns the admitted row — full loop closed");`,
470
477
  ``,
@@ -472,7 +479,7 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
472
479
  } else {
473
480
  out.push(
474
481
  `// ${n()}. the CLOUD answers the by-id read (edge projection of the admitted write)`,
475
- `d = await (await fetch(\`\${CLOUD}/v1/workspaces/\${WS}/aggregates/\${encodeURIComponent(createdId)}\`)).json();`,
482
+ `d = await (await fetch(\`\${CLOUD}/v2/workspaces/\${WS}/aggregates/\${encodeURIComponent(createdId)}\`)).json();`,
476
483
  `if (!(d.rows?.length === 1 && d.rows[0].id === createdId)) fail(\`cloud by-id read: \${JSON.stringify(d)}\`);`,
477
484
  `ok("cloud read returns the admitted row — full loop closed");`,
478
485
  ``,
@@ -482,7 +489,7 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
482
489
  if (count) {
483
490
  out.push(
484
491
  `// ${n()}. the EDGE maintains the same count (same wasm, same law)`,
485
- `d = await (await fetch(\`\${CLOUD}/v1/workspaces/\${WS}/counts/${count.id}${count.by != null ? `?group=\${encodeURIComponent(${JSON.stringify(String(baseValue[count.by]))})}` : ``}\`)).json();`,
492
+ `d = await (await fetch(\`\${CLOUD}/v2/workspaces/\${WS}/${count.id}${count.by != null ? `?group=\${encodeURIComponent(${JSON.stringify(String(baseValue[count.by]))})}` : ``}\`)).json();`,
486
493
  `if (!(d.ok && d.count === 1)) fail(\`cloud count ${count.id}: \${JSON.stringify(d)}\`);`,
487
494
  `ok("cloud count ${count.id} = 1 — the O(1) maintained read, at the edge too");`,
488
495
  ``,
@@ -510,7 +517,7 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
510
517
  `await ${factory}(writerB).${cd.id}(${concPayload("b")});`,
511
518
  `await writerA.sync();`,
512
519
  `await writerB.sync();`,
513
- `d = await (await fetch(\`\${CLOUD}/v1/workspaces/\${WS}/aggregates/\${encodeURIComponent(createdId)}\`)).json();`,
520
+ `d = await (await fetch(\`\${CLOUD}/v2/workspaces/\${WS}/aggregates/\${encodeURIComponent(createdId)}\`)).json();`,
514
521
  `const merged: string[] = d.rows?.[0]?.data?.[${JSON.stringify(setField)}] ?? [];`,
515
522
  `if (!(merged.includes("proof-a") && merged.includes("proof-b"))) fail(\`AddWins union lost an add: \${JSON.stringify(merged)}\`);`,
516
523
  `ok(\`two blind writers' concurrent adds BOTH survive the merge — ${setField} [\${[...merged].sort().join(", ")}]\`);`,
@@ -529,7 +536,7 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
529
536
  `// ${n()}. CONVERGENCE: the original instance adopts canonical main in place — no reconnect`,
530
537
  `await holon.pull();`,
531
538
  `const head = await holon.head();`,
532
- `const refsAdv = await (await fetch(\`\${CLOUD}/v1/workspaces/\${WS}/git/info/refs?service=git-upload-pack\`)).text();`,
539
+ `const refsAdv = await (await fetch(\`\${CLOUD}/v2/workspaces/\${WS}/git/info/refs?service=git-upload-pack\`)).text();`,
533
540
  `const remoteMain = (refsAdv.match(/([0-9a-f]{40}) refs\\/heads\\/main/) || [])[1];`,
534
541
  `if (head !== remoteMain) fail(\`not converged in place: local \${head?.slice(0, 10)} vs main \${remoteMain?.slice(0, 10)}\`);`,
535
542
  `ok("converged in place — local head == canonical main");`,
@@ -546,7 +553,7 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
546
553
  ` console.log(\`workspace \${WS} KEPT — secret \${SECRET}\`);`,
547
554
  ` console.log(\` persist it: githolon secret set \${WS} <the secret above> (then: githolon ws retire \${WS} when done)\`);`,
548
555
  ` } else if (SECRET) {`,
549
- ` const rr = await fetch(\`\${CLOUD}/v1/workspaces/\${WS}/retire\`, { method: "POST", headers: { authorization: \`Bearer \${SECRET}\` } }).then((x) => x.json()).catch(() => ({}));`,
556
+ ` const rr = await fetch(\`\${CLOUD}/v2/workspaces/\${WS}/retire\`, { method: "POST", headers: { authorization: \`Bearer \${SECRET}\` } }).then((x) => x.json()).catch(() => ({}));`,
550
557
  ` console.log(\` retired \${WS}: \${(rr as { ok?: boolean }).ok === true}\`);`,
551
558
  ` }`,
552
559
  ` process.exit(runError ? 1 : 0);`,
package/src/codegen_ts.ts CHANGED
@@ -64,6 +64,9 @@ export const tsClientFactoryName = (domain: string): string => `${lcFirst(camel(
64
64
  /** `tool-library` / `tool_library` / `Tool Library` → `TOOL_LIBRARY_DOMAIN_HASH` — the hash const. */
65
65
  export const tsHashConstName = (packageName: string): string => `${upperSnake(packageName)}_DOMAIN_HASH`;
66
66
 
67
+ /** `tool-library` → `TOOL_LIBRARY_INTERFACE` — the baked structural-interface const (#72). */
68
+ export const interfaceConstName = (packageName: string): string => `${upperSnake(packageName)}_INTERFACE`;
69
+
67
70
  // ── zod → TS (the same `_def ?? def` convention as codegen_dart's walker) ─────────
68
71
  // The walkers are exported: codegen_proof.ts synthesizes SAMPLE payloads from the
69
72
  // same zod shapes this file derives TYPES from — one introspection, two emitters.
@@ -289,6 +292,10 @@ export interface TsClientOptions {
289
292
  readonly packageName: string;
290
293
  /** sha256 of the built `.package.usda` — the law this client offers under. */
291
294
  readonly domainHash: string;
295
+ /** The package's STRUCTURAL INTERFACE (#72) — directives (payload shapes) + aggregates
296
+ * (read shapes, sid-keyed), keyed by domain. Baked in so `connect()` can negotiate
297
+ * shape-compatibility against the workspace's installed offer — NOT by hash. */
298
+ readonly interface?: Record<string, unknown>;
292
299
  }
293
300
 
294
301
  /** TRUE when any module declares a PACKED workspace taxonomy (the routed-holon shape).
@@ -322,6 +329,16 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
322
329
  `/** sha256 of the deployed \`.package.usda\` — the installed law this client offers under. */`,
323
330
  `export const ${hashConst} = ${JSON.stringify(opts.domainHash)};`,
324
331
  ``,
332
+ ...(opts.interface !== undefined
333
+ ? [
334
+ `/** This client's STRUCTURAL INTERFACE REQUIREMENT (#72): the directives (payload shapes)`,
335
+ ` * and aggregates (read shapes, sid-keyed) it depends on, keyed by domain. \`connect()\``,
336
+ ` * negotiates this against the workspace's installed offer by SHAPE (stable-id subtyping),`,
337
+ ` * not by law hash — so the domain can evolve without republishing this client. */`,
338
+ `export const ${interfaceConstName(opts.packageName)} = ${JSON.stringify(opts.interface)} as const;`,
339
+ ``,
340
+ ]
341
+ : []),
325
342
  `/** One projection row: the aggregate's wire type, its id, and the folded fields. */`,
326
343
  `export interface Row<T> { type: string; id: string; data: T }`,
327
344
  ``,
@@ -347,8 +364,8 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
347
364
  ` discardDeadLetter(id: string): Promise<{ ok: boolean; removed: number }>;`,
348
365
  `}`,
349
366
  ``,
350
- `const encodeIntentOffer = (domain: string, directiveId: string, payload: unknown, domainHash: string): Uint8Array =>`,
351
- ` new TextEncoder().encode(JSON.stringify({ v: 2, intent: { domain, directiveId, payload, domainHash } }));`,
367
+ `const encodeIntentOffer = (domain: string, directiveId: string, payload: unknown, domainHash: string, actor?: string): Uint8Array =>`,
368
+ ` new TextEncoder().encode(JSON.stringify({ v: 2, intent: { domain, directiveId, payload, domainHash }, ...(actor ? { security: { actor } } : {}) }));`,
352
369
  ``,
353
370
  );
354
371
 
@@ -402,8 +419,9 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
402
419
 
403
420
  // ── DERIVED BIRTH LANES (sharding §10 RATIFIED, slice 1) ──
404
421
  // A `workspaceType(...).hasMany(...)` taxonomy DERIVES typed birth helpers over
405
- // the EXISTING platform machinery (`POST /v1/platforms/...` — derived, never
406
- // forked). Fully typesafe per Jack's ruling: the caller passes LOGICAL names
422
+ // the EXISTING birth machinery (the boot lane + the parent's birthPlatform/createWorkspace
423
+ // governance offers, `POST /v2/workspaces/...` — derived, never forked). Fully typesafe
424
+ // per Jack's ruling: the caller passes LOGICAL names
407
425
  // (parent param named after the parent TYPE); the `p--ws` composition is internal
408
426
  // plumbing — the composed workspace name comes back on the result for `connect()`.
409
427
  // PACKED child types derive NO holon-birth helper: a packed birth is a shard-map
@@ -478,16 +496,25 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
478
496
  ` [k: string]: unknown;`,
479
497
  `}`,
480
498
  ``,
481
- `const nomosBirth = async (cloud: string, path: string, auth: NomosBirthAuth): Promise<NomosBirthResult> => {`,
499
+ `const nomosBirth = async (cloud: string, path: string, auth: NomosBirthAuth, body?: unknown): Promise<NomosBirthResult> => {`,
482
500
  ` const headers: Record<string, string> = {};`,
483
501
  ` if (auth.authToken !== undefined) headers["x-nomos-auth"] = auth.authToken;`,
484
502
  ` if (auth.principal !== undefined) headers["x-nomos-principal"] = auth.principal;`,
485
503
  ` if (auth.bearer !== undefined) headers["authorization"] = \`Bearer \${auth.bearer}\`;`,
486
504
  ` if (auth.platformAdminKey !== undefined) headers["x-nomos-platform-admin-key"] = auth.platformAdminKey;`,
487
- ` const res = await fetch(\`\${cloud.replace(/\\/$/, "")}\${path}\`, { method: "POST", headers });`,
505
+ ` if (body !== undefined) headers["content-type"] = "application/json";`,
506
+ ` const res = await fetch(\`\${cloud.replace(/\\/$/, "")}\${path}\`, {`,
507
+ ` method: "POST", headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}),`,
508
+ ` });`,
488
509
  ` return (await res.json()) as NomosBirthResult;`,
489
510
  `};`,
490
511
  ``,
512
+ `/** sha256-hex of the chosen platform admin key — the on-chain \`foundingAdminHash\` (the host never mints it). */`,
513
+ `const nomosKeyHash = async (key: string): Promise<string> => {`,
514
+ ` const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(key));`,
515
+ ` return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");`,
516
+ `};`,
517
+ ``,
491
518
  );
492
519
  const emittedBirths = new Set<string>();
493
520
  for (const mod of birthModules) {
@@ -501,13 +528,40 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
501
528
  }
502
529
  const childIds = new Set([...types.values()].flatMap((t) => [...t.childIds]));
503
530
  const fnName = (typeId: string) => `birth${cap(camel(typeId))}`;
504
- const emit = (name: string, doc: string, params: string[], pathExpr: string) => {
531
+ // THE GATEWAY BIRTH LANES (post-/v2): a top-level WORKSPACE births on the boot lane
532
+ // (POST /v2/workspaces/<name> — returns the one-time secret); every other birth is a
533
+ // GOVERNANCE OFFER routed by the parent's OpenAPI (POST /v2/workspaces/<parentWs>/<directiveId>),
534
+ // the obligation the birth provider materializes. parentWs = "root" for a top-level platform,
535
+ // else the parent holon's own name. timestamps ride IN the payload (the determinism doctrine);
536
+ // a platform's foundingAdminHash = sha256(your chosen platformAdminKey) — the host never mints it.
537
+ const emit = (name: string, doc: string, params: string[], lane: { kind: "boot" } | { kind: "offer"; parentWs: string; directiveId: "createWorkspace" | "birthPlatform" }) => {
505
538
  if (emittedBirths.has(name)) return;
506
539
  emittedBirths.add(name);
540
+ const sig = `opts: NomosBirthAuth & { cloud: string; ${params.join("; ")} }`;
541
+ if (lane.kind === "boot") {
542
+ out.push(
543
+ `/** ${doc} */`,
544
+ `export const ${name} = (${sig}): Promise<NomosBirthResult> =>`,
545
+ ` nomosBirth(opts.cloud, \`/v2/workspaces/\${encodeURIComponent(opts.name)}\`, opts);`,
546
+ ``,
547
+ );
548
+ return;
549
+ }
550
+ const isPlatformOffer = lane.directiveId === "birthPlatform";
551
+ const bodyFields = [
552
+ `name: opts.name`,
553
+ `parent: ${lane.parentWs}`,
554
+ `principal: opts.principal ?? ""`,
555
+ `requestedBy: opts.principal ?? ""`,
556
+ `requestedAt: Date.now()`,
557
+ ...(isPlatformOffer ? [`foundingAdminHash: await nomosKeyHash(opts.platformAdminKey)`] : []),
558
+ ];
507
559
  out.push(
508
560
  `/** ${doc} */`,
509
- `export const ${name} = (opts: NomosBirthAuth & { cloud: string; ${params.join("; ")} }): Promise<NomosBirthResult> =>`,
510
- ` nomosBirth(opts.cloud, ${pathExpr}, opts);`,
561
+ `export const ${name} = async (${sig}): Promise<NomosBirthResult> =>`,
562
+ ` nomosBirth(opts.cloud, \`/v2/workspaces/\${encodeURIComponent(${lane.parentWs})}/${lane.directiveId}\`, opts, {`,
563
+ ` ${bodyFields.join(", ")},`,
564
+ ` });`,
511
565
  ``,
512
566
  );
513
567
  };
@@ -515,14 +569,15 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
515
569
  if (t.mode === "packed") continue; // placement, not a holon birth (slice 2)
516
570
  const isPlatform = t.childIds.length > 0;
517
571
  if (!childIds.has(t.id)) {
518
- // A TOP-LEVEL type: births directly under the cloud.
572
+ // A TOP-LEVEL type: a workspace births on the boot lane; a platform-kind
573
+ // coordinator births via the root holon's birthPlatform offer.
519
574
  emit(
520
575
  fnName(t.id),
521
- `Birth a '${t.id}' workspace${isPlatform ? " (a platform-kind coordinator: hosts its own subtree; PRESENT your chosen platformAdminKey)" : ""} — derived from the taxonomy over the existing ${isPlatform ? "platform" : "workspace"} lane. Placement is automatic (measured shape) — no tier to pick.`,
576
+ `Birth a '${t.id}' workspace${isPlatform ? " (a platform-kind coordinator: hosts its own subtree; PRESENT your chosen platformAdminKey) via root's birthPlatform offer" : " on the boot lane (the one-time secret comes back)"} — derived from the taxonomy. Placement is automatic (measured shape) — no tier to pick.`,
522
577
  isPlatform ? [`name: string`, `platformAdminKey: string`] : [`name: string`],
523
578
  isPlatform
524
- ? `\`/v1/platforms/\${encodeURIComponent(opts.name)}\``
525
- : `\`/v1/workspaces/\${encodeURIComponent(opts.name)}\``,
579
+ ? { kind: "offer", parentWs: `"root"`, directiveId: "birthPlatform" }
580
+ : { kind: "boot" },
526
581
  );
527
582
  }
528
583
  for (const childId of t.childIds) {
@@ -532,13 +587,11 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
532
587
  const childIsPlatform = child.childIds.length > 0;
533
588
  emit(
534
589
  fnName(child.id),
535
- `Birth a '${child.id}' under a '${t.id}' — the taxonomy's hasMany edge over the existing platform lane (lineage + pool enforced by the parent's law).${childIsPlatform ? " PRESENT your chosen platformAdminKey." : ""} Placement is automatic (measured shape).`,
590
+ `Birth a '${child.id}' under a '${t.id}' — the taxonomy's hasMany edge, a governance offer on the parent holon (POST /v2/workspaces/<${parentParam}>/${childIsPlatform ? "birthPlatform" : "createWorkspace"}; lineage + pool enforced by the parent's law).${childIsPlatform ? " PRESENT your chosen platformAdminKey." : ""} Placement is automatic (measured shape).`,
536
591
  childIsPlatform
537
592
  ? [`${parentParam}: string`, `name: string`, `platformAdminKey: string`]
538
593
  : [`${parentParam}: string`, `name: string`],
539
- childIsPlatform
540
- ? `\`/v1/platforms/\${encodeURIComponent(opts.${parentParam})}/platforms/\${encodeURIComponent(opts.name)}\``
541
- : `\`/v1/platforms/\${encodeURIComponent(opts.${parentParam})}/workspaces/\${encodeURIComponent(opts.name)}\``,
594
+ { kind: "offer", parentWs: `opts.${parentParam}`, directiveId: childIsPlatform ? "birthPlatform" : "createWorkspace" },
542
595
  );
543
596
  }
544
597
  }
@@ -595,7 +648,7 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
595
648
  ` * — ids stay internal plumbing — and returns it with the new local head. */`,
596
649
  ` ${d.id}: async (payload: ${payloadName}): Promise<{ head: string; ${mint.field}: string }> => {`,
597
650
  ` const ${mint.field} = payload.${mint.field} ?? (await (holon as NomosRoutedHolon).mint(${JSON.stringify(mint.mintType)}${mintOpts}));`,
598
- ` const head = await holon.offer(encodeIntentOffer(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${body}, domainHash));`,
651
+ ` const head = await holon.offer(encodeIntentOffer(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${body}, domainHash, actor));`,
599
652
  ` return { head, ${mint.field} };`,
600
653
  ` },`,
601
654
  );
@@ -604,8 +657,8 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
604
657
  methods.push(
605
658
  ` /** Author \`${domain}/${d.id}\` locally under the installed law; returns the new local head. */`,
606
659
  stamps.length
607
- ? ` ${d.id}: (payload: ${payloadName}) => holon.offer(encodeIntentOffer(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, stampNow(payload, ${JSON.stringify(stamps)}), domainHash)),`
608
- : ` ${d.id}: (payload: ${payloadName}) => holon.offer(encodeIntentOffer(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, payload, domainHash)),`,
660
+ ? ` ${d.id}: (payload: ${payloadName}) => holon.offer(encodeIntentOffer(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, stampNow(payload, ${JSON.stringify(stamps)}), domainHash, actor)),`
661
+ : ` ${d.id}: (payload: ${payloadName}) => holon.offer(encodeIntentOffer(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, payload, domainHash, actor)),`,
609
662
  );
610
663
  }
611
664
 
@@ -668,11 +721,19 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
668
721
  }
669
722
 
670
723
  out.push(
671
- `/** Typed client for the \`${domain}\` domain, bound to a connected holon. */`,
672
- `export function ${tsClientFactoryName(domain)}(holon: NomosHolon, domainHash: string = ${hashConst}) {`,
724
+ `/** Typed client for the \`${domain}\` domain, bound to a connected holon.`,
725
+ ` * Pass \`{ actor }\` (the verified principal uid) to author AS that user — every write seals it as`,
726
+ ` * the captured principal, so the ReBAC \`.requires(...)\` gate + the integrity-dual endorse model`,
727
+ ` * judge the real author. Omit it for open (un-gated) domains. */`,
728
+ `export function ${tsClientFactoryName(domain)}(holon: NomosHolon, opts: { domainHash?: string; actor?: string } = {}) {`,
729
+ ` const domainHash = opts.domainHash ?? ${hashConst};`,
730
+ ` const actor = opts.actor;`,
673
731
  ` return {`,
674
732
  ` domain: ${JSON.stringify(domain)} as const,`,
675
733
  ` domainHash,`,
734
+ ...(opts.interface !== undefined
735
+ ? [` interface: ${interfaceConstName(opts.packageName)}[${JSON.stringify(domain)}],`]
736
+ : []),
676
737
  ...methods,
677
738
  ` };`,
678
739
  `}`,