@githolon/dsl 0.19.0 → 0.20.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/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## 0.20.0
2
+ - Authority-aware: `NomosBridge.offerDirective(domain, directiveId, payload, {actor, domainHash})` — generated
3
+ clients route every offer through THE ONE encoder (`encodeIntentOfferBytes` in @githolon/client); no
4
+ per-client envelope encoding. New typed `MissingOfferAuthority` — a `.requires(...)` directive authored
5
+ with no actor throws BEFORE submit (an actorless gated offer cannot be generated). Lockstep jump to 0.20.0.
6
+
1
7
  ## 0.17.0
2
8
  - Lockstep bump with the birth-primitive release (see `@githolon/dsl` 0.17.0): birth is one law primitive
3
9
  (`birthChild`), the platform-creator tier collapsed onto `#creator`, `grantCreation` takes a user or key
@@ -77,3 +77,22 @@ class LawHashUnavailable extends NomosBridgeError {
77
77
  LawHashUnavailable({required this.workspace, String? message})
78
78
  : super(message ?? 'could not resolve the current law hash for "$workspace" — the cloud reported no installed law for the home');
79
79
  }
80
+
81
+ /// AUTHORITY IS UNFORGEABLE-BY-CONSTRUCTION: a GATED directive (one carrying a `.requires(relation)` write
82
+ /// gate in its law) authored with NO `actor` cannot produce an offer — the generated client throws THIS
83
+ /// BEFORE any submit, so an actorless gated offer is impossible to GENERATE (never merely refused at the
84
+ /// kernel gate). REMEDY: construct the generated client with an `actor` (the verified principal — under a
85
+ /// home scope this is the subject; the bridge owns signing). Secrets NEVER appear here — only the identity.
86
+ class MissingOfferAuthority extends NomosBridgeError {
87
+ /// The domain of the gated directive.
88
+ final String domain;
89
+
90
+ /// The gated directive id that could not be offered without an authority.
91
+ final String directiveId;
92
+
93
+ /// The relation the law requires the author to hold (e.g. `admin`).
94
+ final String requires;
95
+ MissingOfferAuthority({required this.domain, required this.directiveId, required this.requires, String? message})
96
+ : super(message ??
97
+ '$domain/$directiveId requires the "$requires" relation — construct the client with an actor (the verified principal) to author it; a gated offer cannot be made without an authority');
98
+ }
@@ -176,6 +176,28 @@ class NomosBridge {
176
176
  return r['head'].toString();
177
177
  }
178
178
 
179
+ /// THE ONE ENCODER offer: hand the holon a PLANNED directive (domain/directiveId/payload) + the verified
180
+ /// [actor]; the runner's `offerDirective` op encodes the v2 envelope via the SINGLE codec (index.mjs
181
+ /// `encodeIntentOfferBytes`, with `security.actor` included iff [actor] is set). The generated Dart client
182
+ /// calls THIS — it never hand-builds offer bytes, so there is exactly ONE copy of the wire format. Returns
183
+ /// the new local head; durable on [sync]. ([offer] with opaque bytes stays for back-compat carriers.)
184
+ Future<String> offerDirective({
185
+ required String domain,
186
+ required String directiveId,
187
+ required Object? payload,
188
+ String? actor,
189
+ String? domainHash,
190
+ }) async {
191
+ final r = _asMap(await _call('offerDirective', {
192
+ 'domain': domain,
193
+ 'directiveId': directiveId,
194
+ 'payload': payload,
195
+ if (actor != null) 'actor': actor,
196
+ if (domainHash != null) 'domainHash': domainHash,
197
+ }));
198
+ return r['head'].toString();
199
+ }
200
+
179
201
  /// Mint an ed25519 DEVICE KEY in-kernel → `{secret, public}` (base64). The SECRET stays on this device
180
202
  /// (persist it in secure storage); pass it to [connect] as `authorSecret` (or per-offer `signWith`) so the
181
203
  /// holon SIGNS each intent. The PUBLIC half is what gets enrolled so the gate re-verifies the signature on
package/dart/pubspec.yaml CHANGED
@@ -11,7 +11,7 @@ description: >-
11
11
  Nomos 2 Dart frontend types — the hand-written kernel wire types plus the
12
12
  code-generated per-domain aggregate + directive-payload types. (De)serializes
13
13
  the EXACT serde JSON the Rust kernel + projection produce/consume.
14
- version: 0.18.0
14
+ version: 0.20.0
15
15
 
16
16
  environment:
17
17
  sdk: ^3.5.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "type": "module",
5
5
  "description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -123,15 +123,39 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
123
123
  const payloadClass = `${cap(d.id)}Payload`;
124
124
  const stamps = autoStampFields((d as unknown as { payloadSchema: z.ZodTypeAny }).payloadSchema);
125
125
  const method = safeMethod(d.id);
126
+ // THE AUTHORITY GATE (codegen-time): a directive carrying `.requires(relation)` is GATED — its method
127
+ // throws MissingOfferAuthority when `actor` is null/empty, BEFORE any submit, so an actorless gated
128
+ // offer is impossible to GENERATE (the war: the gate is structural, not just kernel-side). Secrets never
129
+ // appear — only the actor identity (the bridge owns signing).
130
+ const gate = (d as { requiresRelationGate?: { relation: string } }).requiresRelationGate;
131
+ const gateGuard = gate !== undefined
132
+ ? ` if (actor == null || actor!.trim().isEmpty) throw MissingOfferAuthority(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, requires: ${dartStr(gate.relation)});`
133
+ : "";
134
+ const gateDoc = gate !== undefined
135
+ ? ` /// GATED: needs an \`actor\` (the \`${gate.relation}\` relation) — throws MissingOfferAuthority otherwise.`
136
+ : "";
126
137
  if (stamps.length > 0) {
127
138
  methods.push(
128
139
  ` /// Author \`${domain}/${d.id}\` locally under the installed law; returns the new local head.`,
129
140
  ` /// Omitted ${stamps.map((s) => `\`${s}\``).join(" / ")} auto-stamp(s) ISO now() at offer.`,
141
+ ...(gateDoc ? [gateDoc] : []),
130
142
  ` Future<String> ${method}(${payloadClass} payload) {`,
143
+ ...(gateGuard ? [gateGuard] : []),
131
144
  ` final data = payload.toPayloadJson();`,
132
145
  ` final now = DateTime.now().toUtc().toIso8601String();`,
133
146
  ...stamps.map((s) => ` data.putIfAbsent(${dartStr(s)}, () => now);`),
134
- ` return bridge.offer(intentBytes: _encodeIntentOffer(${dartStr(domain)}, ${dartStr(d.id)}, data, domainHash));`,
147
+ ` return bridge.offerDirective(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, payload: data, actor: actor, domainHash: domainHash);`,
148
+ ` }`,
149
+ ``,
150
+ );
151
+ } else if (gateGuard) {
152
+ // A gated directive with no `…At` sugar still needs a block body for the throw guard.
153
+ methods.push(
154
+ ` /// Author \`${domain}/${d.id}\` locally under the installed law; returns the new local head.`,
155
+ gateDoc,
156
+ ` Future<String> ${method}(${payloadClass} payload) {`,
157
+ gateGuard,
158
+ ` return bridge.offerDirective(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, payload: payload.toPayloadJson(), actor: actor, domainHash: domainHash);`,
135
159
  ` }`,
136
160
  ``,
137
161
  );
@@ -139,7 +163,7 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
139
163
  methods.push(
140
164
  ` /// Author \`${domain}/${d.id}\` locally under the installed law; returns the new local head.`,
141
165
  ` Future<String> ${method}(${payloadClass} payload) =>`,
142
- ` bridge.offer(intentBytes: _encodeIntentOffer(${dartStr(domain)}, ${dartStr(d.id)}, payload.toPayloadJson(), domainHash));`,
166
+ ` bridge.offerDirective(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, payload: payload.toPayloadJson(), actor: actor, domainHash: domainHash);`,
143
167
  ``,
144
168
  );
145
169
  }
@@ -216,14 +240,15 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
216
240
  `// final app = ${className}(bridge);`,
217
241
  `// await app.${dirs[0] ? safeMethod(dirs[0].id) : "someDirective"}(/* typed payload */);`,
218
242
  ``,
219
- `import 'dart:convert';`,
220
- ``,
243
+ // `dart:convert` is needed ONLY for the interface block's `jsonDecode` — the offer lane no longer encodes
244
+ // any envelope (it routes through `bridge.offerDirective` → THE ONE ENCODER). Omit it when no interface.
245
+ ...(opts.interface !== undefined ? [`import 'dart:convert';`, ``] : []),
221
246
  `import '${supportImport}';`,
222
247
  `import '${mod.name}.dart';`,
223
248
  `// Re-export the sibling types (read models + payloads + the bridge runtime) so ONE import of this`,
224
249
  `// client file gives an app the whole typed surface.`,
225
250
  `export '${mod.name}.dart';`,
226
- `export '${supportImport}' show NomosBridge, BridgeTransport, NomosBridgeError, kWebviewBridgeVersion;`,
251
+ `export '${supportImport}' show NomosBridge, BridgeTransport, NomosBridgeError, MissingOfferAuthority, kWebviewBridgeVersion;`,
227
252
  ``,
228
253
  `/// sha256 of the deployed \`.package.usda\` — the installed law this client offers under.`,
229
254
  `const String ${hashConst} = ${dartStr(opts.domainHash)};`,
@@ -238,22 +263,22 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
238
263
  ``,
239
264
  ]
240
265
  : []),
241
- `String _encodeIntentOffer(String domain, String directiveId, Object? payload, String domainHash) =>`,
242
- ` base64Encode(utf8.encode(jsonEncode({`,
243
- ` 'v': 2,`,
244
- ` 'intent': {`,
245
- ` 'domain': domain,`,
246
- ` 'directiveId': directiveId,`,
247
- ` 'payload': payload,`,
248
- ` 'domainHash': domainHash,`,
249
- ` },`,
250
- ` })));`,
251
- ``,
252
266
  `/// Typed async client for the \`${domain}\` domain, bound to a connected [NomosBridge].`,
267
+ `///`,
268
+ `/// Pass \`actor\` (the verified principal uid) to author AS that user — the bridge's \`offerDirective\` op`,
269
+ `/// carries it through THE ONE ENCODER (\`encodeIntentOfferBytes\` in @githolon/client) as \`security.actor\`,`,
270
+ `/// so the kernel seals it as the captured principal and the ReBAC \`.requires(...)\` gate judges the real`,
271
+ `/// author. A GATED directive (one whose law declares \`.requires(relation)\`) authored with no actor throws`,
272
+ `/// [MissingOfferAuthority] BEFORE any submit — an actorless gated offer can't be constructed. The bridge`,
273
+ `/// owns SIGNING (a device secret on \`connect\`); only the actor IDENTITY is passed here, never a secret.`,
253
274
  `class ${className} {`,
254
275
  ` final NomosBridge bridge;`,
255
276
  ` final String domainHash;`,
256
- ` ${className}(this.bridge, {this.domainHash = ${hashConst}});`,
277
+ ``,
278
+ ` /// The verified principal uid every offer is authored as (sealed as \`security.actor\` by the one encoder).`,
279
+ ` /// Null ⇒ open (un-gated) authoring; a gated directive then throws [MissingOfferAuthority].`,
280
+ ` final String? actor;`,
281
+ ` ${className}(this.bridge, {this.domainHash = ${hashConst}, this.actor});`,
257
282
  ``,
258
283
  ...(opts.interface !== undefined
259
284
  ? [
@@ -183,6 +183,136 @@ function findConcurrencyLeg(
183
183
  return undefined;
184
184
  }
185
185
 
186
+ // ── authority-conformance discovery (the ONE-ENCODER + gate proof — actorless gated offer is impossible) ──
187
+
188
+ /** A discovered AUTHORITY-CONFORMANCE leg: a GATED directive (one carrying `.requires(relation)`) the proof
189
+ * can drive locally to prove (a) authored WITH an actor, the produced offer envelope carries `security.actor`
190
+ * (the one encoder threaded the actor through), and (b) authored WITHOUT an actor, the generated client
191
+ * THROWS MissingOfferAuthority before any submit (an actorless gated offer is impossible to GENERATE). */
192
+ export interface AuthorityConformanceLeg {
193
+ /** The gated directive's domain (the generated factory + its `domain` field). */
194
+ readonly domain: string;
195
+ /** The gated directive id (the generated client method). */
196
+ readonly directiveId: string;
197
+ /** The relation the law requires the author to hold (e.g. `admin`) — the MissingOfferAuthority `requires`. */
198
+ readonly relation: string;
199
+ /** The synthesized payload the proof drives the gated directive with (auto-stamped `…At` omitted). */
200
+ readonly payload: Record<string, unknown>;
201
+ /** Required `…At` fields the runtime stamps at dispatch (mirrors the create leg). */
202
+ readonly autoStamped: readonly string[];
203
+ /** When the gated directive is a `.creates` whose own id is kernel-minted, the field to mint at runtime. */
204
+ readonly mintField?: string;
205
+ }
206
+
207
+ /** Discover the AUTHORITY-CONFORMANCE leg: the first GATED directive (`.requires(relation)` ⇒
208
+ * `requiresRelationGate`) whose payload the synthesizer can sample. A birth / attestation-authorized
209
+ * directive is skipped (their authority is a carried cert / signed quote, not an `actor` block — a different
210
+ * lane). `undefined` ⇒ this package has no gated directive (the leg becomes a documented no-op). */
211
+ export function discoverAuthorityConformanceLeg(mod: DomainModule): AuthorityConformanceLeg | undefined {
212
+ const domain = mod.domain ?? mod.name;
213
+ const gated = (mod.directives as Directive<unknown>[]).find(
214
+ (d) =>
215
+ d?.requiresRelationGate !== undefined &&
216
+ typeof d.payloadSchema === "object" &&
217
+ d.birthsChild !== true &&
218
+ d.attestationAuthorizedGate === undefined,
219
+ );
220
+ if (gated === undefined) return undefined;
221
+ let synth: SynthesizedPayload;
222
+ try {
223
+ synth = synthesizePayload(gated.payloadSchema as z.ZodTypeAny, `${domain}/${gated.id}`);
224
+ } catch {
225
+ return undefined; // an exotic payload the synthesizer can't sample → no conformance leg (still gated at the kernel)
226
+ }
227
+ const agg = (mod.aggregates as AggregateHandle[]).find((a) => a.id === gated.aggregateId);
228
+ const mintField = agg !== undefined ? mintedCreateField(gated, agg) : undefined;
229
+ const payload: Record<string, unknown> = { ...synth.value };
230
+ if (mintField !== undefined) delete payload[mintField];
231
+ return {
232
+ domain,
233
+ directiveId: gated.id,
234
+ relation: gated.requiresRelationGate!.relation,
235
+ payload,
236
+ autoStamped: synth.autoStamped,
237
+ ...(mintField !== undefined ? { mintField } : {}),
238
+ };
239
+ }
240
+
241
+ /**
242
+ * THE AUTHORITY-CONFORMANCE LEG (the one-encoder + gate proof). On the real LOCAL holon, with fetch trapped:
243
+ * (a) bind the generated client WITH an actor + drive the gated directive. SPY on `holon.offerDirective` to
244
+ * capture the (domain, directiveId, payload, opts) the client passes, RE-ENCODE that through the v2
245
+ * envelope shape (the SAME `{ v:2, intent, security:{actor} }` the one encoder emits) and DECODE it back
246
+ * to assert `security.actor` IS the bound actor — the produced offer envelope carries the principal.
247
+ * (b) bind a SECOND client WITHOUT an actor + call the gated directive — assert it THROWS
248
+ * MissingOfferAuthority LOCALLY (before any offerDirective/submit fires; the spy stays untouched).
249
+ * Emits nothing when the package has no gated directive (a documented no-op note).
250
+ */
251
+ function authorityConformanceLegLines(
252
+ leg: AuthorityConformanceLeg | undefined,
253
+ factory: string,
254
+ n: () => number,
255
+ ): string[] {
256
+ if (leg === undefined) {
257
+ return [
258
+ `// ${n()}. authority-conformance leg: this law has no gated (\`.requires(...)\`) directive, so there is`,
259
+ `// nothing to prove about actorless-gated-offer refusal (an un-gated domain authors openly).`,
260
+ `console.log("– authority-conformance leg SKIPPED — no .requires(...) directive (add one to prove the one-encoder threads security.actor + an actorless gated offer can't be generated)");`,
261
+ ``,
262
+ ];
263
+ }
264
+ const payloadEntries = leg.mintField !== undefined
265
+ ? `{ ...${JSON.stringify(leg.payload)}, ${JSON.stringify(leg.mintField)}: await holon.mint("conformance") }`
266
+ : JSON.stringify(leg.payload);
267
+ return [
268
+ `// ${n()}. AUTHORITY CONFORMANCE (THE ONE ENCODER + the gate): \`${leg.domain}/${leg.directiveId}\` is GATED`,
269
+ `// (\`.requires("${leg.relation}")\`). (a) WITH an actor the produced offer envelope must carry`,
270
+ `// security.actor; (b) WITHOUT one the generated client THROWS MissingOfferAuthority before any submit.`,
271
+ `{`,
272
+ ` const realFetch2 = globalThis.fetch;`,
273
+ ` globalThis.fetch = (() => { throw new Error("OFFLINE PROOF VIOLATED: authority-conformance touched the network"); }) as typeof fetch;`,
274
+ ` try {`,
275
+ ` // (a) SPY on offerDirective: capture what the generated client passes, re-encode the v2 envelope it`,
276
+ ` // would produce (the one-encoder shape), decode it back, and assert security.actor IS the actor.`,
277
+ ` const ACTOR = "user:proof-conformance";`,
278
+ ` let captured: { domain: string; directiveId: string; payload: unknown; opts?: { domainHash?: string; actor?: string } } | undefined;`,
279
+ ` const realOffer: typeof holon.offerDirective = holon.offerDirective.bind(holon);`,
280
+ ` holon.offerDirective = (domain, directiveId, payload, opts) => {`,
281
+ ` captured = { domain, directiveId, payload, opts };`,
282
+ ` return realOffer(domain, directiveId, payload, opts);`,
283
+ ` };`,
284
+ ` try {`,
285
+ ` await ${factory}(holon, { actor: ACTOR }).${leg.directiveId}(${payloadEntries});`,
286
+ ` } finally {`,
287
+ ` holon.offerDirective = realOffer;`,
288
+ ` }`,
289
+ ` if (!captured) fail("authority-conformance: the gated directive did not reach holon.offerDirective");`,
290
+ ` const actorOnOffer = captured!.opts?.actor;`,
291
+ ` if (actorOnOffer !== ACTOR) fail(\`authority-conformance: the offer did not carry the actor (got \${JSON.stringify(actorOnOffer)})\`);`,
292
+ ` // Decode the bytes THE ONE ENCODER actually emits from these args (NOT a hand-rolled copy) — so this`,
293
+ ` // leg catches encoder DRIFT (a dropped/renamed/whitespace-normalized security.actor), not merely that`,
294
+ ` // the actor reached offerDirective. The v2 wire format lives in exactly one place: encodeIntentOfferBytes.`,
295
+ ` const decoded = JSON.parse(new TextDecoder().decode(encodeIntentOfferBytes({ domain: captured!.domain, directiveId: captured!.directiveId, payload: captured!.payload, domainHash: captured!.opts?.domainHash, actor: actorOnOffer })));`,
296
+ ` if (decoded?.security?.actor !== ACTOR) fail(\`authority-conformance: THE ONE ENCODER dropped security.actor (\${JSON.stringify(decoded?.security)})\`);`,
297
+ ` ok(\`authority conformance (a) — \${${JSON.stringify(`${leg.domain}/${leg.directiveId}`)}} authored WITH an actor carries security.actor through THE ONE ENCODER\`);`,
298
+ ``,
299
+ ` // (b) the SAME directive with NO actor: the generated client refuses to BUILD the offer — it throws`,
300
+ ` // MissingOfferAuthority locally, BEFORE any submit (an actorless gated offer is impossible to make).`,
301
+ ` let threw: Error | undefined;`,
302
+ ` try {`,
303
+ ` await ${factory}(holon).${leg.directiveId}(${payloadEntries});`,
304
+ ` } catch (e) { threw = e as Error; }`,
305
+ ` if (threw === undefined) fail("authority-conformance: the gated directive with NO actor did NOT throw — an actorless gated offer must be impossible to generate");`,
306
+ ` if (threw.name !== "MissingOfferAuthority") fail(\`authority-conformance: expected MissingOfferAuthority, got \${threw.name}: \${threw.message}\`);`,
307
+ ` ok(\`authority conformance (b) — \${${JSON.stringify(`${leg.domain}/${leg.directiveId}`)}} with NO actor throws MissingOfferAuthority before any submit (gated offer impossible to generate)\`);`,
308
+ ` } finally {`,
309
+ ` globalThis.fetch = realFetch2;`,
310
+ ` }`,
311
+ `}`,
312
+ ``,
313
+ ];
314
+ }
315
+
186
316
  // ── child-birth discovery (the G3 `.births()` lane — prove a parent birth end-to-end into the child) ──────
187
317
 
188
318
  /** ONE step of a discovered birth recipe's genesis chain (the subset codegen reads). */
@@ -733,7 +863,7 @@ function generateBirthOnlyProof(
733
863
  `// Regenerated on every compile; do not edit.`,
734
864
  `// @nomos-proof-legs ${legsMarker}`,
735
865
  `import { readFileSync } from "node:fs";`,
736
- `import { connect } from "@githolon/client";`,
866
+ `import { connect, encodeIntentOfferBytes } from "@githolon/client";`,
737
867
  `import { ${factory}, ${hashConst} } from "./${opts.packageName}.client.ts";`,
738
868
  `void connect; void ${factory};`,
739
869
  ``,
@@ -823,6 +953,11 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
823
953
 
824
954
  const concurrency = findConcurrencyLeg(mod.directives as Directive<unknown>[], agg);
825
955
 
956
+ // THE AUTHORITY-CONFORMANCE LEG (the one-encoder + gate proof): the first gated (`.requires(...)`) directive
957
+ // in the primary domain, proven locally — actor threads through the one encoder; an actorless gated offer
958
+ // throws MissingOfferAuthority. `undefined` ⇒ a documented no-op (the domain authors openly).
959
+ const conformance = discoverAuthorityConformanceLeg(mod);
960
+
826
961
  // Workspace-name slug: any package name → a safe, recognizable ws prefix.
827
962
  const slug =
828
963
  opts.packageName
@@ -865,7 +1000,7 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
865
1000
  `// every call — start at docs/01-mental-model.md). Regenerated on every compile; do not edit.`,
866
1001
  `// @nomos-proof-legs ${legsMarker}`,
867
1002
  `import { readFileSync } from "node:fs";`,
868
- `import { connect } from "@githolon/client";`,
1003
+ `import { connect, encodeIntentOfferBytes } from "@githolon/client";`,
869
1004
  `import { ${factory}, ${hashConst} } from "./${opts.packageName}.client.ts";`,
870
1005
  ``,
871
1006
  `const CLOUD = (process.env.NOMOS_CLOUD || "https://nomos.captainapp.co.uk").replace(/\\/+$/, "");`,
@@ -1009,6 +1144,11 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
1009
1144
  );
1010
1145
  }
1011
1146
 
1147
+ // THE AUTHORITY-CONFORMANCE LEG (the one-encoder + gate proof): runs on the LOCAL holon (fetch re-trapped),
1148
+ // proving the generated client threads security.actor through THE ONE ENCODER and that an actorless gated
1149
+ // offer is impossible to GENERATE (throws MissingOfferAuthority before any submit). No-op when un-gated.
1150
+ out.push(...authorityConformanceLegLines(conformance, factory, n));
1151
+
1012
1152
  // THE CHILD-BIRTH LEG (G3, gap 2): when the primary domain has a `.births()` directive, prove the birth
1013
1153
  // END-TO-END against the cloud — drive the parent birth offer, then read the BORN child workspace it spawns.
1014
1154
  if (childBirth !== undefined) {
package/src/codegen_ts.ts CHANGED
@@ -344,6 +344,11 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
344
344
  ``,
345
345
  `/** The holon surface this client binds to (structurally satisfied by @githolon/client). */`,
346
346
  `export interface NomosHolon {`,
347
+ ` /** THE ONE ENCODER offer (the single v2-envelope codec lives in @githolon/client \`encodeIntentOfferBytes\`):`,
348
+ ` * hand the holon a PLANNED directive + the verified \`actor\`; the holon encodes + offers. The generated`,
349
+ ` * client calls THIS, never building offer bytes itself — so there is exactly ONE copy of the wire format. */`,
350
+ ` offerDirective(domain: string, directiveId: string, payload: unknown, opts?: { domainHash?: string; actor?: string }): Promise<string>;`,
351
+ ` /** Opaque-bytes offer — retained for carriers that already hold an encoded envelope (back-compat). */`,
347
352
  ` offer(intentBytes: string | Uint8Array): Promise<string>;`,
348
353
  ` query(queryId: string, params?: Record<string, unknown>): Promise<unknown>;`,
349
354
  ` queryById(aggregateId: string): Promise<unknown>;`,
@@ -364,8 +369,16 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
364
369
  ` discardDeadLetter(id: string): Promise<{ ok: boolean; removed: number }>;`,
365
370
  `}`,
366
371
  ``,
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 } } : {}) }));`,
372
+ `/** AUTHORITY IS UNFORGEABLE-BY-CONSTRUCTION: a GATED directive (one carrying a \`.requires(relation)\` write`,
373
+ ` * gate) authored with NO \`actor\` cannot produce an offer — it throws THIS before any submit, so an`,
374
+ ` * actorless gated offer is impossible to GENERATE, never merely refused at the kernel gate. Bind the`,
375
+ ` * client with \`{ actor }\` (the verified principal) to author a gated directive. */`,
376
+ `export class MissingOfferAuthority extends Error {`,
377
+ ` constructor(readonly domain: string, readonly directiveId: string, readonly requires: string) {`,
378
+ ` super(\`\${domain}/\${directiveId} requires the "\${requires}" relation — bind the client with { actor } (the verified principal) to author it; a gated offer cannot be made without an authority\`);`,
379
+ ` this.name = "MissingOfferAuthority";`,
380
+ ` }`,
381
+ `}`,
369
382
  ``,
370
383
  );
371
384
 
@@ -632,6 +645,13 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
632
645
  const stamps = autoStampFields(schema);
633
646
  const mint = mintPlan?.get(d.id);
634
647
  const placed = placementDirs.has(d.id) ? ["shard"] : [];
648
+ // THE AUTHORITY GATE (codegen-time): a directive carrying `.requires(relation)` is GATED — its
649
+ // generated method refuses to encode an offer without an `actor` (throws MissingOfferAuthority
650
+ // BEFORE any submit), so an actorless gated offer is impossible to GENERATE, not merely refused.
651
+ const gate = (d as { requiresRelationGate?: { relation: string } }).requiresRelationGate;
652
+ const gateGuard = gate !== undefined
653
+ ? ` if (!actor || !actor.trim()) throw new MissingOfferAuthority(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${JSON.stringify(gate.relation)});\n`
654
+ : "";
635
655
  out.push(
636
656
  `/** \`${domain}/${d.id}\` payload (mirrors the engine's zod schema).${stamps.length ? ` Omitted ${stamps.map((s) => `\`${s}\``).join("/")} auto-stamp(s) ISO now() at offer.` : ""}${mint ? ` Omitted \`${mint.field}\` is KERNEL-MINTED at offer (returned on the result).` : ""}${placed.length ? " Omitted `shard` is picked LEAST-LOADED at offer (the routed holon)." : ""} */`,
637
657
  `export type ${payloadName} = ${tsTypeOfPayload(schema, stamps, mint ? [mint.field] : [], placed)};`,
@@ -649,21 +669,35 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
649
669
  : `{ ...payload, ${mint.field} }`;
650
670
  methods.push(
651
671
  ` /** Author \`${domain}/${d.id}\`: the front-door MINTS \`${mint.field}\` (route-tagged) when omitted`,
652
- ` * — ids stay internal plumbing — and returns it with the new local head. */`,
672
+ ` * — ids stay internal plumbing — and returns it with the new local head.${gate ? ` GATED: needs \`{ actor }\` (\`${gate.relation}\`).` : ""} */`,
653
673
  ` ${d.id}: async (payload: ${payloadName}): Promise<{ head: string; ${mint.field}: string }> => {`,
674
+ ...(gateGuard ? [gateGuard.replace(/\n$/, "")] : []),
654
675
  ` const ${mint.field} = payload.${mint.field} ?? (await (holon as NomosRoutedHolon).mint(${JSON.stringify(mint.mintType)}${mintOpts}));`,
655
- ` const head = await holon.offer(encodeIntentOffer(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${body}, domainHash, actor));`,
676
+ ` const head = await holon.offerDirective(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${body}, { domainHash, actor });`,
656
677
  ` return { head, ${mint.field} };`,
657
678
  ` },`,
658
679
  );
659
680
  continue;
660
681
  }
661
- methods.push(
662
- ` /** Author \`${domain}/${d.id}\` locally under the installed law; returns the new local head. */`,
663
- stamps.length
664
- ? ` ${d.id}: (payload: ${payloadName}) => holon.offer(encodeIntentOffer(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, stampNow(payload, ${JSON.stringify(stamps)}), domainHash, actor)),`
665
- : ` ${d.id}: (payload: ${payloadName}) => holon.offer(encodeIntentOffer(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, payload, domainHash, actor)),`,
666
- );
682
+ const offerPayload = stamps.length ? `stampNow(payload, ${JSON.stringify(stamps)})` : `payload`;
683
+ const offerCall = `holon.offerDirective(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${offerPayload}, { domainHash, actor })`;
684
+ if (gateGuard) {
685
+ // GATED directive: a block body that throws MissingOfferAuthority when no actor is bound — an
686
+ // actorless gated offer can't be constructed (the war: the gate is structural, not just kernel-side).
687
+ methods.push(
688
+ ` /** Author \`${domain}/${d.id}\` locally under the installed law; returns the new local head.`,
689
+ ` * GATED: needs \`{ actor }\` (the \`${gate!.relation}\` relation) — throws MissingOfferAuthority otherwise. */`,
690
+ ` ${d.id}: (payload: ${payloadName}): Promise<string> => {`,
691
+ gateGuard.replace(/\n$/, ""),
692
+ ` return ${offerCall};`,
693
+ ` },`,
694
+ );
695
+ } else {
696
+ methods.push(
697
+ ` /** Author \`${domain}/${d.id}\` locally under the installed law; returns the new local head. */`,
698
+ ` ${d.id}: (payload: ${payloadName}) => ${offerCall},`,
699
+ );
700
+ }
667
701
  }
668
702
 
669
703
  for (const q of (mod.queries ?? []) as QueryDecl[]) {