@githolon/dsl 0.100.2 → 0.100.4

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.62.1
2
+
3
+ - Align the public Dart documentation with the one Flutter application entry point: identity comes from the
4
+ token passed to `NomosScope.home(application:, authToken:)`; application code no longer supplies a subject
5
+ or device-token callback.
6
+
1
7
  ## 0.62.0
2
8
 
3
9
  - Generated business clients expose one `birth(payload)` method. The bound session supplies its verified
@@ -10,7 +10,7 @@ import 'package:crypto/crypto.dart';
10
10
  /// every peer and on the cloud. Use it directly as the `workspace`:
11
11
  ///
12
12
  /// ```dart
13
- /// NomosScope(workspace: homeNameOf('user:$firebaseUid'), ...)
13
+ /// NomosScope.home(application: 'my-app', authToken: idToken, ...)
14
14
  /// ```
15
15
  ///
16
16
  /// Formula: `home-` + the first 40 hex chars of `sha256(utf8(subject))`.
@@ -1895,8 +1895,7 @@ class NomosBridge {
1895
1895
 
1896
1896
  /// Derive the device key's base64 PUBLIC half from its base64 SECRET seed, inside the LOCAL kernel
1897
1897
  /// (the secret never leaves the device). The recovery lanes use this when only the secret was
1898
- /// persisted (the device-key store keeps just the seed) — e.g. to mint a device-BOUND IdP token
1899
- /// (`authTokenForDevice(publicKey)`) for the keyless re-enroll lane.
1898
+ /// persisted (the device-key store keeps just the seed), including low-level device-bound enrollment.
1900
1899
  Future<String> devicePublicOf(String secret) async =>
1901
1900
  (_asMap(await _call('devicePublicOf', {'secret': secret}))['public'] ?? '')
1902
1901
  .toString();
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.62.0
14
+ version: 0.62.1
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.100.2",
3
+ "version": "0.100.4",
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",
@@ -57,12 +57,18 @@ import type { BirthRecipe } from "./births.js";
57
57
  * a re-birth of the same name updates the SAME record, Lww). The kernel's `nomosBirthRecordGate`
58
58
  * era key carries this prefix so the wasm arm reads it from LAW, never hardcodes it. */
59
59
  export const NOMOS_BIRTH_RECORD_PREFIX = "nomos-birth-record:";
60
+ export const NOMOS_CHILD_LIFECYCLE_PREFIX = "nomos-child-lifecycle:";
60
61
 
61
62
  /** The birth record's deterministic aggregate instance id for a child workspace name. */
62
63
  export function birthRecordId(child: string): string {
63
64
  return NOMOS_BIRTH_RECORD_PREFIX + child;
64
65
  }
65
66
 
67
+ /** The additive lifecycle record beside the immutable historical birth witness. */
68
+ export function childLifecycleId(child: string): string {
69
+ return NOMOS_CHILD_LIFECYCLE_PREFIX + child;
70
+ }
71
+
66
72
  /**
67
73
  * NomosBirthRecord — THE STRUCTURAL BIRTH RECORD (A1). One row per child this workspace has
68
74
  * birthed, written AUTOMATICALLY in the same intent as the birth effect (the `.births()` lane).
@@ -79,6 +85,21 @@ export const NomosBirthRecord = aggregate("NomosBirthRecord", {
79
85
  bornAt: t.string().merge(Lww),
80
86
  });
81
87
 
88
+ /**
89
+ * NomosChildLifecycle — additive lifecycle for a child witnessed by NomosBirthRecord. It is deliberately
90
+ * a separate aggregate: a framework upgrade must never reinterpret the projection schema of historical
91
+ * birth rows. No lifecycle row means active; reclaimed is terminal.
92
+ */
93
+ export const NomosChildLifecycle = aggregate("NomosChildLifecycle", {
94
+ child: t.string().merge(Lww),
95
+ principal: t.string().merge(Lww),
96
+ status: t.string().merge(Lww),
97
+ retiredBy: t.string().merge(Lww),
98
+ retiredAt: t.int().merge(Lww),
99
+ reclaimedBy: t.string().merge(Lww),
100
+ reclaimedAt: t.int().merge(Lww),
101
+ });
102
+
82
103
  /** BirthPolicy — the workspace's birth-allowance singleton (id `birth-policy:workspace`), the
83
104
  * `CloudPolicy` shape one level down: the default max live births per subject in THIS workspace. */
84
105
  export const BIRTH_POLICY_ID = "birth-policy:workspace";
@@ -111,6 +132,16 @@ export const birthRecordsByPrincipal = query("birthRecordsByPrincipal")
111
132
  .key("principal")
112
133
  .returns(NomosBirthRecord);
113
134
 
135
+ /** Lifecycle rows for quota accounting; no row means the historical child remains active. */
136
+ export const childLifecyclesByPrincipal = query("childLifecyclesByPrincipal")
137
+ .key("principal")
138
+ .returns(NomosChildLifecycle);
139
+
140
+ /** Terminal lifecycle facts for the custody bailiff. */
141
+ export const childLifecyclesByStatus = query("childLifecyclesByStatus")
142
+ .key("status")
143
+ .returns(NomosChildLifecycle);
144
+
114
145
  /** The workspace's birth-policy singleton (`scope: "workspace"`). */
115
146
  export const birthPolicy = query("birthPolicy").key("scope").returns(BirthPolicy);
116
147
 
@@ -119,13 +150,20 @@ export const birthGrantByPrincipal = query("birthGrantByPrincipal")
119
150
  .key("principal")
120
151
  .returns(BirthGrant);
121
152
 
122
- /** The three meter reads every `.births()` directive auto-declares (`nomosReadGate`). */
153
+ /** The framework reads every `.births()` directive auto-declares (`nomosReadGate`). */
123
154
  export const BIRTH_METER_QUERIES: readonly QueryDecl[] = [
124
155
  birthRecordsByPrincipal,
156
+ childLifecyclesByPrincipal,
125
157
  birthPolicy,
126
158
  birthGrantByPrincipal,
127
159
  ];
128
160
 
161
+ /** Every query a birth-capable law carries, including the custody bailiff's terminal-child index. */
162
+ export const BIRTH_FRAMEWORK_QUERIES: readonly QueryDecl[] = [
163
+ ...BIRTH_METER_QUERIES,
164
+ childLifecyclesByStatus,
165
+ ];
166
+
129
167
  /**
130
168
  * THE DEFAULT BIRTH ALLOWANCE when a workspace carries neither a BirthPolicy singleton nor a grant
131
169
  * for the subject: a CONSERVATIVE floor for non-root workspaces (a home births a handful of estate
@@ -173,13 +211,14 @@ function sha256(text: string): string {
173
211
  }
174
212
 
175
213
  /**
176
- * CAPTURE the three meter reads on the write path (A2 — the plan side of the birth-quota gate; the
214
+ * CAPTURE the meter reads on the write path (A2 — the plan side of the birth-quota gate; the
177
215
  * exact `countQuotaReads` shape). Called by `executeDirectiveToIntent` for every `.births()` dispatch
178
216
  * that planned ≥1 birth — the values ride `captured_ports.reads`, CAS-re-derived + byte-compared at
179
217
  * the pre-apply position on every lane; the verdict is the synthesized invariant's.
180
218
  */
181
219
  export function captureBirthMeterReads(subject: string): void {
182
220
  __captureBirthMeterRead(birthRecordsByPrincipal, { principal: subject });
221
+ __captureBirthMeterRead(childLifecyclesByPrincipal, { principal: subject });
183
222
  __captureBirthMeterRead(birthGrantByPrincipal, { principal: subject });
184
223
  __captureBirthMeterRead(birthPolicy, { scope: "workspace" });
185
224
  }
@@ -231,7 +270,13 @@ export function birthQuotaVerdict(
231
270
  const hit = reads.find((r) => r.queryId === queryId && r.args[argKey] === argValue);
232
271
  return Array.isArray(hit?.result) ? (hit!.result as MeterRow[]) : [];
233
272
  };
234
- const count = rowsOf(birthRecordsByPrincipal.id, "principal", subject).length;
273
+ // Terminal children no longer consume the parent's live-child allowance. Lifecycle is kept in an additive
274
+ // aggregate beside the historical birth witness, so an old row with no lifecycle remains active.
275
+ const born = rowsOf(birthRecordsByPrincipal.id, "principal", subject).length;
276
+ const terminal = rowsOf(childLifecyclesByPrincipal.id, "principal", subject)
277
+ .filter((r) => r.data?.status === "retired" || r.data?.status === "reclaimed")
278
+ .length;
279
+ const count = Math.max(0, born - terminal);
235
280
  // ALLOWANCE: the LATEST BirthGrant (max grantedAt) wins; else the BirthPolicy singleton; else the floor.
236
281
  let allowance: number | undefined;
237
282
  let latestGrantedAt = -Infinity;
@@ -295,6 +340,7 @@ export function synthesizeBirthQuotaInvariants(directive: {
295
340
  /** The meter aggregates every `.births()`-declaring domain auto-ensures (the RelationTuple courtesy). */
296
341
  export const BIRTH_METER_AGGREGATES: readonly AggregateHandle[] = [
297
342
  NomosBirthRecord,
343
+ NomosChildLifecycle,
298
344
  BirthPolicy,
299
345
  BirthGrant,
300
346
  ] as unknown as readonly AggregateHandle[];
@@ -26,9 +26,17 @@ import { directive } from "./directive.js";
26
26
  import type { Directive } from "./directive.js";
27
27
  import {
28
28
  NomosBirthRecord,
29
+ NomosChildLifecycle,
29
30
  BirthPolicy,
30
31
  BirthGrant,
31
32
  } from "./birth_meter.js";
33
+ import {
34
+ reclaimChild,
35
+ reclaimChildRequiresRetired,
36
+ retireChild,
37
+ retireChildRequiresBirth,
38
+ retireChildNotReclaimed,
39
+ } from "./framework/birth_meter.js";
32
40
 
33
41
  /**
34
42
  * The sanctioning declaration — an `ensures(NomosBirthRecord)` directive whose plan emits ZERO ops.
@@ -59,9 +67,23 @@ export function withBirthMeterExports<M extends Record<string, unknown>>(mod: M)
59
67
  );
60
68
  if (!declaresBirth) return mod;
61
69
  const out: Record<string, unknown> = { ...mod };
62
- if (!("nomosBirthRecordEnsure" in out)) out["nomosBirthRecordEnsure"] = nomosBirthRecordEnsure;
63
- if (!("NomosBirthRecord" in out)) out["NomosBirthRecord"] = NomosBirthRecord;
64
- if (!("BirthPolicy" in out)) out["BirthPolicy"] = BirthPolicy;
65
- if (!("BirthGrant" in out)) out["BirthGrant"] = BirthGrant;
70
+ // Business birth hides framework maintenance declarations from the generated application facade while
71
+ // retaining them in certified law for the operator's structural offer discovery. A non-business
72
+ // `.births()` law keeps its existing explicit generated surface.
73
+ const businessInternal = Object.values(mod).some(
74
+ (v) => v !== null && typeof v === "object" && (v as { businessBirth?: unknown }).businessBirth !== undefined,
75
+ );
76
+ const presented = <T extends object>(v: T): T =>
77
+ businessInternal ? ({ ...v, businessBirthInternal: true } as T) : v;
78
+ if (!("nomosBirthRecordEnsure" in out)) out["nomosBirthRecordEnsure"] = presented(nomosBirthRecordEnsure);
79
+ if (!("NomosBirthRecord" in out)) out["NomosBirthRecord"] = presented(NomosBirthRecord);
80
+ if (!("NomosChildLifecycle" in out)) out["NomosChildLifecycle"] = presented(NomosChildLifecycle);
81
+ if (!("BirthPolicy" in out)) out["BirthPolicy"] = presented(BirthPolicy);
82
+ if (!("BirthGrant" in out)) out["BirthGrant"] = presented(BirthGrant);
83
+ if (!("retireChild" in out)) out["retireChild"] = presented(retireChild);
84
+ if (!("reclaimChild" in out)) out["reclaimChild"] = presented(reclaimChild);
85
+ if (!("reclaimChildRequiresRetired" in out)) out["reclaimChildRequiresRetired"] = reclaimChildRequiresRetired;
86
+ if (!("retireChildRequiresBirth" in out)) out["retireChildRequiresBirth"] = retireChildRequiresBirth;
87
+ if (!("retireChildNotReclaimed" in out)) out["retireChildNotReclaimed"] = retireChildNotReclaimed;
66
88
  return out as M;
67
89
  }
@@ -56,7 +56,7 @@ import type { WorkspaceInvariantDecl } from "./framework/workspace_invariant.js"
56
56
  import { synthesizePreconditionInvariants } from "./precondition.js";
57
57
  import {
58
58
  BIRTH_METER_AGGREGATES,
59
- BIRTH_METER_QUERIES,
59
+ BIRTH_FRAMEWORK_QUERIES,
60
60
  synthesizeBirthQuotaInvariants,
61
61
  } from "./birth_meter.js";
62
62
  import { withBirthMeterExports } from "./birth_meter_ensure.js";
@@ -81,6 +81,11 @@ import { discoverUnsanctionedEnsures, sanctioningEnsureDirective } from "./secon
81
81
  import { assertIdOfLint } from "./idof_lint.js";
82
82
  import { assertSurfacedResolvers } from "./surfaced_lint.js";
83
83
  import { expandBusinessBirthExports } from "./business_birth.js";
84
+ import {
85
+ reclaimChildRequiresRetired,
86
+ retireChildNotReclaimed,
87
+ retireChildRequiresBirth,
88
+ } from "./framework/birth_meter.js";
84
89
 
85
90
  // ─────────────────────────────────────────────────────────────────────────────────
86
91
  // composeDomainModule — the named-keys `module(...)`
@@ -309,6 +314,12 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
309
314
  const maxes = [...(spec.maxes ?? [])];
310
315
  const workspaceTypes = [...(spec.workspaceTypes ?? [])];
311
316
  const workspaceInvariants = [...(spec.workspaceInvariants ?? [])];
317
+ if (declaresBirth) {
318
+ const haveInvariant = new Set(workspaceInvariants.map((w) => w.id));
319
+ for (const lifecycle of [reclaimChildRequiresRetired, retireChildNotReclaimed, retireChildRequiresBirth]) {
320
+ if (!haveInvariant.has(lifecycle.id)) workspaceInvariants.push(lifecycle);
321
+ }
322
+ }
312
323
  // DESUGAR the write-path precondition primitives (`.requiresExisting`/`.creates(Agg,idFn)`/`.where`):
313
324
  // each directive's descriptors synthesize workspace invariants (LANE B) appended to the SAME list a
314
325
  // hand-authored `workspaceInvariant(...)` occupies — so downstream treats them identically. Era-safe:
@@ -321,7 +332,7 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
321
332
  // The meter's declared queries route like any exported query (read manifest + captured-read re-derive).
322
333
  if (declaresBirth) {
323
334
  const haveQuery = new Set(queries.map((q) => q.id));
324
- for (const q of BIRTH_METER_QUERIES) {
335
+ for (const q of BIRTH_FRAMEWORK_QUERIES) {
325
336
  if (!haveQuery.has(q.id)) queries.push(q);
326
337
  }
327
338
  // The inheritance read (`.reads("authProvider")` on birthHome) routes like any declared query.
@@ -15,10 +15,13 @@ import { AuthProvider, authProvider, setAuthProvider } from "./framework/auth_pr
15
15
  import {
16
16
  BirthGrant,
17
17
  BirthPolicy,
18
+ NomosChildLifecycle,
18
19
  NomosBirthRecord,
19
20
  birthGrantByPrincipal,
20
21
  birthPolicy,
21
22
  birthRecordsByPrincipal,
23
+ childLifecyclesByPrincipal,
24
+ childLifecyclesByStatus,
22
25
  } from "./birth_meter.js";
23
26
  import { nomosBirthRecordEnsure } from "./birth_meter_ensure.js";
24
27
  import { GENESIS_WARRANT_ID, GenesisWarrant, seedGenesis } from "./framework/genesis.js";
@@ -238,10 +241,13 @@ function lowerOne(decl: BusinessBirthDecl, domain: string): Record<string, unkno
238
241
  // Exact export keys prevent the generic `.births()` auto-compose seam from inserting a second,
239
242
  // unmarked copy. The values are still the canonical framework declarations.
240
243
  NomosBirthRecord: internalSurface(NomosBirthRecord),
244
+ NomosChildLifecycle: internalSurface(NomosChildLifecycle),
241
245
  BirthPolicy: internalSurface(BirthPolicy),
242
246
  BirthGrant: internalSurface(BirthGrant),
243
247
  nomosBirthRecordEnsure: internalDirective(nomosBirthRecordEnsure),
244
248
  __nomos_birthRecordsByPrincipal: internalSurface(birthRecordsByPrincipal),
249
+ __nomos_childLifecyclesByPrincipal: internalSurface(childLifecyclesByPrincipal),
250
+ __nomos_childLifecyclesByStatus: internalSurface(childLifecyclesByStatus),
245
251
  __nomos_birthPolicy: internalSurface(birthPolicy),
246
252
  __nomos_birthGrantByPrincipal: internalSurface(birthGrantByPrincipal),
247
253
  __nomos_parentAttestedBirth: businessParentAttestedBirth,
@@ -50,11 +50,18 @@ export interface OpenApiDoc {
50
50
  openapi: "3.1.0";
51
51
  info: Record<string, unknown>;
52
52
  paths: Record<string, Record<string, unknown>>;
53
+ /**
54
+ * Law-derived routes needed by Nomos itself (device seating, provenance, recovery, ...).
55
+ * The host consumes this only on its internal describe path and strips it from public OpenAPI.
56
+ */
57
+ "x-nomos-framework-routes"?: { paths: Record<string, Record<string, unknown>> };
53
58
  }
54
59
 
55
60
  export interface OpenApiFragmentOptions {
56
61
  /** The content hash of the installed law (the spec's version + ingest cache key). */
57
62
  lawHash?: string;
63
+ /** Include the private framework router manifest used by the Nomos host. Public codegen omits it. */
64
+ includeFrameworkRoutes?: boolean;
58
65
  }
59
66
 
60
67
  /** Lower a directive's payload zod to JSON Schema; a non-representable schema falls back to a
@@ -95,22 +102,10 @@ function domainKey(module: DomainModule): string {
95
102
  * the merge namespaces any cross-domain collision. The offer/query distinction rides on the HTTP
96
103
  * method (POST/GET) AND the authoritative `x-nomos.kind` — the host reads the latter, never the name.
97
104
  */
98
- export function generateOpenApiFragment(
105
+ function generateOpenApiSurface(
99
106
  module: DomainModule,
100
107
  opts: OpenApiFragmentOptions = {},
101
108
  ): OpenApiDoc {
102
- module = {
103
- ...module,
104
- aggregates: module.aggregates.filter(
105
- (a) => (a as { businessBirthInternal?: unknown }).businessBirthInternal !== true,
106
- ),
107
- directives: module.directives.filter(
108
- (d) => (d as { businessBirthInternal?: unknown }).businessBirthInternal !== true,
109
- ),
110
- ...(module.queries === undefined
111
- ? {}
112
- : { queries: module.queries.filter((q) => (q as { businessBirthInternal?: unknown }).businessBirthInternal !== true) }),
113
- };
114
109
  const domain = domainKey(module);
115
110
  const paths: Record<string, Record<string, unknown>> = {};
116
111
 
@@ -123,7 +118,6 @@ export function generateOpenApiFragment(
123
118
 
124
119
  // OFFERABLE INTENTS → POST offers. The payload JSON Schema is the intent's own zod, lowered.
125
120
  for (const dir of module.directives as AnyDirective[]) {
126
- if ((dir as { businessBirthInternal?: unknown }).businessBirthInternal === true) continue;
127
121
  const businessBirth = (dir as {
128
122
  businessBirth?: { aggregateId: string; publicPayloadSchema: unknown };
129
123
  }).businessBirth;
@@ -247,11 +241,79 @@ export function generateOpenApiFragment(
247
241
  };
248
242
  }
249
243
 
244
+ /**
245
+ * Generate the developer-facing OpenAPI plus, only when requested by the runtime, a separate
246
+ * framework routing manifest. The split is deliberate: filtering framework declarations out of
247
+ * `paths` is presentation; it must never make Nomos's own law declarations unroutable.
248
+ */
249
+ export function generateOpenApiFragment(
250
+ module: DomainModule,
251
+ opts: OpenApiFragmentOptions = {},
252
+ ): OpenApiDoc {
253
+ const isInternal = (value: unknown) =>
254
+ !!value && typeof value === "object"
255
+ && (value as { businessBirthInternal?: unknown }).businessBirthInternal === true;
256
+ const publicModule: DomainModule = {
257
+ ...module,
258
+ aggregates: module.aggregates.filter((a) => !isInternal(a)),
259
+ directives: module.directives.filter((d) => !isInternal(d)),
260
+ ...(module.queries === undefined ? {} : { queries: module.queries.filter((q) => !isInternal(q)) }),
261
+ };
262
+ const publicDoc = generateOpenApiSurface(publicModule, opts);
263
+ if (opts.includeFrameworkRoutes !== true) return publicDoc;
264
+
265
+ const frameworkModule: DomainModule = {
266
+ ...module,
267
+ aggregates: module.aggregates.filter(isInternal),
268
+ directives: module.directives.filter(isInternal),
269
+ ...(module.queries === undefined ? {} : { queries: module.queries.filter(isInternal) }),
270
+ // Framework routing is for declared internal offers/queries. Business maintained reads remain
271
+ // on the public module; do not duplicate them into the private table.
272
+ counts: [],
273
+ sums: [],
274
+ mins: [],
275
+ maxes: [],
276
+ spatials: [],
277
+ };
278
+ const frameworkDoc = generateOpenApiSurface(frameworkModule, opts);
279
+ if (Object.keys(frameworkDoc.paths).length === 0) return publicDoc;
280
+ return {
281
+ ...publicDoc,
282
+ "x-nomos-framework-routes": { paths: frameworkDoc.paths },
283
+ };
284
+ }
285
+
250
286
  /** Stable JSON of an operation, to detect a genuine duplicate vs a real collision in the merge. */
251
287
  function operationKey(op: unknown): string {
252
288
  return JSON.stringify(op);
253
289
  }
254
290
 
291
+ function mergePathsInto(
292
+ paths: Record<string, Record<string, unknown>>,
293
+ incoming: Record<string, Record<string, unknown>>,
294
+ ): void {
295
+ for (const [path, methods] of Object.entries(incoming)) {
296
+ for (const [method, op] of Object.entries(methods)) {
297
+ const existing = paths[path]?.[method];
298
+ if (existing === undefined) {
299
+ (paths[path] ??= {})[method] = op;
300
+ } else if (operationKey(existing) === operationKey(op)) {
301
+ // identical (a framework operation shared by many domains) — keep the one entry.
302
+ } else {
303
+ // genuine collision: two domains, same path+method, different operations. Namespace BOTH
304
+ // under their domain so neither wins silently.
305
+ const domOf = (o: unknown): string =>
306
+ String((o as { "x-nomos"?: { domain?: string } })?.["x-nomos"]?.domain ?? "domain");
307
+ const bucket = paths[path]!;
308
+ (paths[`/${domOf(existing)}${path}`] ??= {})[method] = existing;
309
+ (paths[`/${domOf(op)}${path}`] ??= {})[method] = op;
310
+ delete bucket[method];
311
+ if (Object.keys(bucket).length === 0) delete paths[path];
312
+ }
313
+ }
314
+ }
315
+ }
316
+
255
317
  /**
256
318
  * mergeOpenApiFragments — the WORKSPACE spec = the union of its installed domains' fragments. The
257
319
  * runtime step (driven by `currentLaw`), kept here as a pure, law-blind function:
@@ -266,27 +328,10 @@ export function mergeOpenApiFragments(
266
328
  opts: { title?: string; lawHash?: string } = {},
267
329
  ): OpenApiDoc {
268
330
  const paths: Record<string, Record<string, unknown>> = {};
331
+ const frameworkPaths: Record<string, Record<string, unknown>> = {};
269
332
  for (const frag of fragments) {
270
- for (const [path, methods] of Object.entries(frag.paths)) {
271
- for (const [method, op] of Object.entries(methods)) {
272
- const existing = paths[path]?.[method];
273
- if (existing === undefined) {
274
- (paths[path] ??= {})[method] = op;
275
- } else if (operationKey(existing) === operationKey(op)) {
276
- // identical (a framework operation shared by many domains) — keep the one entry.
277
- } else {
278
- // genuine collision: two domains, same path+method, different operations. Namespace BOTH
279
- // under their domain so neither wins silently.
280
- const domOf = (o: unknown): string =>
281
- String((o as { "x-nomos"?: { domain?: string } })?.["x-nomos"]?.domain ?? "domain");
282
- const bucket = paths[path]!;
283
- (paths[`/${domOf(existing)}${path}`] ??= {})[method] = existing;
284
- (paths[`/${domOf(op)}${path}`] ??= {})[method] = op;
285
- delete bucket[method];
286
- if (Object.keys(bucket).length === 0) delete paths[path];
287
- }
288
- }
289
- }
333
+ mergePathsInto(paths, frag.paths);
334
+ mergePathsInto(frameworkPaths, frag["x-nomos-framework-routes"]?.paths ?? {});
290
335
  }
291
336
  return {
292
337
  openapi: "3.1.0",
@@ -296,5 +341,8 @@ export function mergeOpenApiFragments(
296
341
  ...(opts.lawHash ? { "x-nomos-law-hash": opts.lawHash } : {}),
297
342
  },
298
343
  paths,
344
+ ...(Object.keys(frameworkPaths).length > 0
345
+ ? { "x-nomos-framework-routes": { paths: frameworkPaths } }
346
+ : {}),
299
347
  };
300
348
  }
@@ -91,7 +91,7 @@ import {
91
91
  type Mod,
92
92
  } from "./build_package.js";
93
93
  import { synthesizePreconditionInvariants } from "./precondition.js";
94
- import { BIRTH_METER_QUERIES, synthesizeBirthQuotaInvariants } from "./birth_meter.js";
94
+ import { BIRTH_FRAMEWORK_QUERIES, synthesizeBirthQuotaInvariants } from "./birth_meter.js";
95
95
  import { withBirthMeterExports } from "./birth_meter_ensure.js";
96
96
  import { expandBusinessBirthExports } from "./business_birth.js";
97
97
  import { expandRoleExports, rolesOf } from "./role.js";
@@ -1772,7 +1772,7 @@ async function main(): Promise<void> {
1772
1772
  // the same auto-ensure `composeDomainModule` applies to the manifest half.
1773
1773
  if (directivesOf(merged).some((dir) => (dir as { birthsChild?: boolean }).birthsChild === true)) {
1774
1774
  const haveQuery = new Set(queries.map((q) => q.id));
1775
- for (const q of BIRTH_METER_QUERIES) {
1775
+ for (const q of BIRTH_FRAMEWORK_QUERIES) {
1776
1776
  if (!haveQuery.has(q.id)) queries.push(q);
1777
1777
  }
1778
1778
  }
package/src/directive.ts CHANGED
@@ -958,8 +958,8 @@ function makeRequirable<P>(base: Directive<P>): RequirableDirective<P> {
958
958
  // BIRTH AS A DECLARED KERNEL PRIMITIVE (G3): mark this directive a birth — its plan's `birth(recipe)`
959
959
  // rides the intent, and the kernel (reading the `nomosBirthGate` era key, not domain state) executes
960
960
  // the birth after admit. Era-ruled: a non-birthing directive omits the flag and is byte-identical.
961
- // METERED BIRTH (slice A): `.births()` ALSO auto-declares the three birth-meter captured reads
962
- // (`birthRecordsByPrincipal`/`birthGrantByPrincipal`/`birthPolicy` — the #58 lane), so the
961
+ // METERED BIRTH (slice A): `.births()` ALSO auto-declares the birth-meter reads
962
+ // (`birthRecordsByPrincipal`/`childLifecyclesByPrincipal`/`birthGrantByPrincipal`/`birthPolicy`), so the
963
963
  // `nomosReadGate` era key carries them by construction and the meter reads the `.births()` dispatch
964
964
  // auto-captures (see `executeDirectiveToIntent`) CAS-verify on every lane. The era is the bundle:
965
965
  // recompiling under this DSL is what arms the meter (every pre-era bundle replays byte-green).
@@ -931,11 +931,9 @@ export function registerEngine(config: EngineEntryConfig): RegisteredEngine {
931
931
  * THE SELF-DESCRIBE VERB (G1) — the lump's OWN OpenAPI, derived from the SAME modules it
932
932
  * dispatches. The kernel runs this over the FOLDED law (the lump IS `nomos:executable:
933
933
  * javascriptHex` in the package USDA in custody), so the workspace's API surface is a pure,
934
- * deterministic function of installed law — NOT a host-staged blob. It reproduces, byte for
935
- * byte, the `<name>.openapi.json` the compiler wrote: per domain (declaration order) build the
936
- * openapi-relevant DomainModule (directives + aggregates from the live exports; queries/counts/
937
- * sums/mins/maxes/spatials/workspaceInvariants from `config.openapiDecls`), `generateOpenApi
938
- * Fragment`, then `mergeOpenApiFragments` — the EXACT calls compile_package_main makes.
934
+ * deterministic function of installed law — NOT a host-staged blob. Its public `paths` reproduce
935
+ * the `<name>.openapi.json` the compiler wrote; describe also carries a private, law-derived
936
+ * framework routing manifest which the host consumes internally and never publishes.
939
937
  * `opts.lawHash`/`opts.title` are injected by the kernel at describe-time (the version stamp +
940
938
  * package title), matching the compiler's `domainHash`/`"<name> (nomos package)"`.
941
939
  */
@@ -969,7 +967,10 @@ export function registerEngine(config: EngineEntryConfig): RegisteredEngine {
969
967
  ? { workspaceInvariants: [...decls.workspaceInvariants] }
970
968
  : {}),
971
969
  } as unknown as DomainModule;
972
- fragments.push(generateOpenApiFragment(module, lawHash !== undefined ? { lawHash } : {}));
970
+ fragments.push(generateOpenApiFragment(module, {
971
+ ...(lawHash !== undefined ? { lawHash } : {}),
972
+ includeFrameworkRoutes: true,
973
+ }));
973
974
  }
974
975
  const title = opts?.title ?? config.packageTitle;
975
976
  return mergeOpenApiFragments(fragments, {
@@ -27,23 +27,33 @@ import { z } from "zod";
27
27
  import { instance } from "../aggregate.js";
28
28
  import { directive } from "../directive.js";
29
29
  import { set, type PlannedOp } from "../ops.js";
30
+ import { refAs, workspaceInvariant } from "./workspace_invariant.js";
30
31
  import {
31
32
  BirthPolicy,
32
33
  BirthGrant,
34
+ NomosChildLifecycle,
35
+ NomosBirthRecord,
33
36
  BIRTH_POLICY_ID,
37
+ birthRecordId,
38
+ childLifecycleId,
34
39
  birthGrantId,
35
40
  } from "../birth_meter.js";
36
41
 
37
42
  // Re-export the whole meter core so `@githolon/dsl` framework consumers get one import site.
38
43
  export {
39
44
  NomosBirthRecord,
45
+ NomosChildLifecycle,
40
46
  BirthPolicy,
41
47
  BirthGrant,
42
48
  BIRTH_POLICY_ID,
43
49
  NOMOS_BIRTH_RECORD_PREFIX,
50
+ NOMOS_CHILD_LIFECYCLE_PREFIX,
44
51
  birthRecordId,
52
+ childLifecycleId,
45
53
  birthGrantId,
46
54
  birthRecordsByPrincipal,
55
+ childLifecyclesByPrincipal,
56
+ childLifecyclesByStatus,
47
57
  birthPolicy,
48
58
  birthGrantByPrincipal,
49
59
  DEFAULT_MAX_BIRTHS_PER_PRINCIPAL,
@@ -51,6 +61,93 @@ export {
51
61
  birthQuotaInvariantId,
52
62
  } from "../birth_meter.js";
53
63
 
64
+ /**
65
+ * retireChild / reclaimChild are the lifecycle of the SAME child born by `.births()`. They are operator
66
+ * maintenance beneath the application API, not alternative creation lanes. The durable parent-side
67
+ * NomosBirthRecord remains the immutable birth witness; an additive NomosChildLifecycle is the authority
68
+ * for withdrawal/disposal, so a framework upgrade never changes the schema of historical birth rows.
69
+ */
70
+ export const retireChild = directive("retireChild")
71
+ .ensures(NomosChildLifecycle)
72
+ .payload(z.object({
73
+ child: z.string().min(1),
74
+ principal: z.string().min(1),
75
+ retiredBy: z.string().min(1),
76
+ retiredAt: z.number().int().nonnegative(),
77
+ }))
78
+ .plan((p) => {
79
+ const rec = instance(NomosChildLifecycle, childLifecycleId(p.child));
80
+ return [
81
+ set(rec, "child", p.child),
82
+ set(rec, "principal", p.principal),
83
+ set(rec, "status", "retired"),
84
+ set(rec, "retiredBy", p.retiredBy),
85
+ set(rec, "retiredAt", p.retiredAt),
86
+ ];
87
+ });
88
+
89
+ export const reclaimChild = directive("reclaimChild")
90
+ .ensures(NomosChildLifecycle)
91
+ .payload(z.object({
92
+ child: z.string().min(1),
93
+ reclaimedBy: z.string().min(1),
94
+ reclaimedAt: z.number().int().nonnegative(),
95
+ }))
96
+ .plan((p) => {
97
+ const rec = instance(NomosChildLifecycle, childLifecycleId(p.child));
98
+ return [
99
+ set(rec, "status", "reclaimed"),
100
+ set(rec, "reclaimedBy", p.reclaimedBy),
101
+ set(rec, "reclaimedAt", p.reclaimedAt),
102
+ ];
103
+ });
104
+
105
+ /** Reclaim is two-phase; a child must first be explicitly withdrawn. */
106
+ export const reclaimChildRequiresRetired = workspaceInvariant("ReclaimChildRequiresRetired")
107
+ .on("reclaimChild")
108
+ .reads((ctx) => [
109
+ refAs("record", "NomosChildLifecycle", "pre:" + childLifecycleId(String(ctx.intent.child ?? ""))),
110
+ ])
111
+ .assert(({ record }, ctx) =>
112
+ (record as { status?: string; principal?: string } | undefined)?.status === "retired"
113
+ && sameLifecyclePrincipal((record as { principal?: string }).principal, ctx?.principal)
114
+ ? { accept: true }
115
+ : { reject: "reclaim-child-requires-retired" },
116
+ );
117
+
118
+ /** Reclaimed is permanent: retirement cannot move a child back out of the terminal state. */
119
+ export const retireChildNotReclaimed = workspaceInvariant("RetireChildNotReclaimed")
120
+ .on("retireChild")
121
+ .reads((ctx) => [
122
+ refAs("record", "NomosChildLifecycle", "pre:" + childLifecycleId(String(ctx.intent.child ?? ""))),
123
+ ])
124
+ .assert(({ record }) =>
125
+ (record as { status?: string } | undefined)?.status === "reclaimed"
126
+ ? { reject: "reclaimed-child-is-terminal" }
127
+ : { accept: true },
128
+ );
129
+
130
+ /** A lifecycle cannot mint authority for arbitrary custody: retirement must name its exact birth witness. */
131
+ export const retireChildRequiresBirth = workspaceInvariant("RetireChildRequiresBirth")
132
+ .on("retireChild")
133
+ .reads((ctx) => [
134
+ refAs("birth", "NomosBirthRecord", "pre:" + birthRecordId(String(ctx.intent.child ?? ""))),
135
+ ])
136
+ .assert(({ birth }, ctx) => {
137
+ const record = birth as { child?: string; principal?: string } | undefined;
138
+ const intent = ctx?.intent as { child?: string; principal?: string } | undefined;
139
+ return record?.child === intent?.child
140
+ && record?.principal === intent?.principal
141
+ && sameLifecyclePrincipal(record?.principal, ctx?.principal)
142
+ ? { accept: true }
143
+ : { reject: "retire-child-requires-exact-birth-record" };
144
+ });
145
+
146
+ function sameLifecyclePrincipal(recorded: string | undefined, author: string | undefined): boolean {
147
+ const normalize = (s: string | undefined): string => s?.startsWith("user:") ? s.slice(5) : (s ?? "");
148
+ return normalize(recorded) !== "" && normalize(recorded) === normalize(author);
149
+ }
150
+
54
151
  /** The BirthPolicy singleton upsert ops (shared by the governed set and the genesis seed). */
55
152
  function birthPolicyOps(p: { maxBirthsPerPrincipal: number; setBy: string; setAt: string }): PlannedOp[] {
56
153
  const pol = instance(BirthPolicy, BIRTH_POLICY_ID);
@@ -35,6 +35,7 @@ import type { QueryDecl } from "../query.js";
35
35
  import { nomosSourceFrontierEnsure } from "../attested.js";
36
36
  import * as birthMeter from "../birth_meter.js";
37
37
  import * as birthMeterEnsure from "../birth_meter_ensure.js";
38
+ import * as birthMeterFramework from "./birth_meter.js";
38
39
  import * as disclosure from "./disclosure.js";
39
40
  import * as domainLifecycle from "./domain_lifecycle.js";
40
41
  import * as encryptionIdentity from "./encryption_identity.js";
@@ -56,6 +57,7 @@ const FRAMEWORK_NAMESPACES: ReadonlyArray<Record<string, unknown>> = [
56
57
  // read models/accessors) once PER domain — the exact ambiguous_export co2 hit on its barrel.
57
58
  birthMeter,
58
59
  birthMeterEnsure,
60
+ birthMeterFramework,
59
61
  disclosure,
60
62
  domainLifecycle,
61
63
  encryptionIdentity,
package/src/index.ts CHANGED
@@ -456,13 +456,18 @@ export {
456
456
  // tenant composes in to set/override allowances, plus the constants/predicates tests and tools read.
457
457
  export {
458
458
  NomosBirthRecord,
459
+ NomosChildLifecycle,
459
460
  BirthPolicy,
460
461
  BirthGrant,
461
462
  BIRTH_POLICY_ID,
462
463
  NOMOS_BIRTH_RECORD_PREFIX,
464
+ NOMOS_CHILD_LIFECYCLE_PREFIX,
463
465
  birthRecordId,
466
+ childLifecycleId,
464
467
  birthGrantId,
465
468
  birthRecordsByPrincipal,
469
+ childLifecyclesByPrincipal,
470
+ childLifecyclesByStatus,
466
471
  birthPolicy,
467
472
  birthGrantByPrincipal,
468
473
  DEFAULT_MAX_BIRTHS_PER_PRINCIPAL,
@@ -471,6 +476,11 @@ export {
471
476
  setBirthPolicy,
472
477
  seedBirthPolicy,
473
478
  grantBirthAllowance,
479
+ retireChild,
480
+ reclaimChild,
481
+ reclaimChildRequiresRetired,
482
+ retireChildRequiresBirth,
483
+ retireChildNotReclaimed,
474
484
  } from "./framework/birth_meter.js";
475
485
  // THE PLATFORM FOUNDING-ADMIN BOOTSTRAP (the platform analog of seedHomeCreator) — a tenant-importable
476
486
  // factory that seats a tenant-named governance relation for a write-once, author-bound founder at genesis.
@@ -81,6 +81,7 @@ export const GLOBAL_READ_PROJECTION_AGGREGATE_IDS: ReadonlySet<string> = new Set
81
81
  // metered birth (slice A) — NomosBirthRecord is kernel-read by the birth-record
82
82
  // gate (the `nomos-birth-record:` prefix); the policy/grant singletons ride with it
83
83
  "NomosBirthRecord",
84
+ "NomosChildLifecycle",
84
85
  "BirthPolicy",
85
86
  "BirthGrant",
86
87
  // home holons
@@ -306,7 +306,7 @@ export function executeDirectiveToIntent<P>(
306
306
  // A1 THE AUTO-RECORD: append the `NomosBirthRecord` field ops (deterministic id per child; Lww upsert)
307
307
  // to the SAME intent that carries the birth effect. The kernel's `nomosBirthRecordGate` era arm
308
308
  // refuses a new-era birth whose intent carries no record — this is what satisfies it.
309
- // A2 THE AUTO-CAPTURE: capture the three meter reads (records-by-subject + grant + policy — the #58
309
+ // A2 THE AUTO-CAPTURE: capture the meter reads (births + terminal lifecycle + grant + policy — the #58
310
310
  // lane `.births()` auto-declares) so the synthesized birth-quota invariant judges over COMMITTED,
311
311
  // CAS-re-derived pre-apply state on every lane.
312
312
  // A `.births()` dispatch that planned NO birth (e.g. birthChild without a genesis) appends/captures