@githolon/dsl 0.68.0 → 0.69.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.
package/dart/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## 0.46.0
2
+
3
+ - Custody lifecycle on `NomosBridge`/`NomosSession`: `connect(..., onCustodyBreak: CustodyBreakPolicy)`,
4
+ `custodyStatus()`, the `custodyEvents` stream, `resolveCustodyBreak()` (the ask lane — put the decision in
5
+ front of the user), `salvagedIntents`/`reofferSalvaged`/`discardSalvaged`. A rebirth/supersession is a
6
+ first-class typed event: named truthfully, decided by the app, local work never silently adopted nor lost.
7
+ Requires `nomos_types >= 0.1.4`.
8
+
1
9
  ## 0.45.0
2
10
 
3
11
  - `offerOutcome(...)` — the one outcome-returning offer lane generated clients now call for EVERY directive:
@@ -26,7 +26,10 @@ import 'package:nomos_types/nomos_types.dart'
26
26
  NomosRef,
27
27
  NomosForeignReader,
28
28
  NomosForeignWorkspaceReader,
29
- ForeignAttestation;
29
+ ForeignAttestation,
30
+ CustodyBreakPolicy,
31
+ NomosCustodyEvent,
32
+ NomosCustodyStatus;
30
33
 
31
34
  /// The protocol version the runner and the bridge agree on (`webview-runner.mjs` `WEBVIEW_BRIDGE_VERSION`).
32
35
  const int kWebviewBridgeVersion = 1;
@@ -656,6 +659,11 @@ class NomosBridge {
656
659
  StreamController<Map<String, Object?>>.broadcast();
657
660
  final StreamController<Map<String, Object?>> _signalEvents =
658
661
  StreamController<Map<String, Object?>>.broadcast();
662
+ // CUSTODY LIFECYCLE (rebirth_and_custody_lifecycle.md): each `custody` event carries the raw map (session +
663
+ // custody:{kind,break,…}); a Flutter app binds a break dialog / posture banner to this. Kind "ask" awaits
664
+ // [resolveCustodyBreak].
665
+ final StreamController<NomosCustodyEvent> _custodyEvents =
666
+ StreamController<NomosCustodyEvent>.broadcast();
659
667
  final StreamController<Map<String, Object?>> _flameEvents =
660
668
  StreamController<Map<String, Object?>>.broadcast();
661
669
  final StreamController<Map<String, Object?>> _spanEvents =
@@ -771,6 +779,13 @@ class NomosBridge {
771
779
  }
772
780
  return;
773
781
  }
782
+ if (event == 'custody') {
783
+ // Custody lifecycle: { event:'custody', session, custody:{ kind, break, … } }. Decode + fan out so the
784
+ // app can react (kind "ask" → show a dialog then call resolveCustodyBreak; others are informational).
785
+ final ev = NomosCustodyEvent.fromMessage(m);
786
+ if (ev != null && !_custodyEvents.isClosed) _custodyEvents.add(ev);
787
+ return;
788
+ }
774
789
  if (event == 'transfer') {
775
790
  // LIVE pack byte-progress { received, total, kind } — the real loading bar (host Content-Length).
776
791
  final tp = TransferProgress.fromMessage(m);
@@ -892,6 +907,7 @@ class NomosBridge {
892
907
  String? restoreFrom,
893
908
  bool allowUnborn = false,
894
909
  bool recover = false,
910
+ CustodyBreakPolicy? onCustodyBreak,
895
911
  String? session,
896
912
  int? maxMounted}) async {
897
913
  final ws = session ?? workspace;
@@ -917,10 +933,51 @@ class NomosBridge {
917
933
  // RECOVER-IN-PLACE over a corrupt restoreFrom snapshot: salvage local-only writes + DLQ, refold fresh
918
934
  // from cloud, re-offer the salvaged work. The report lands on [lastRecovery]. Nothing local is lost.
919
935
  if (recover) 'recover': true,
936
+ // CUSTODY-BREAK POLICY (rebirth_and_custody_lifecycle.md): "adopt"/"reject" decide automatically;
937
+ // "ask" surfaces the break on [custodyEvents] (kind "ask") and awaits [resolveCustodyBreak]. Omitted ⇒
938
+ // fail closed (the break surfaces as an error on the sync/pull that hit it; the holon stays local).
939
+ if (onCustodyBreak != null) 'onCustodyBreak': onCustodyBreak.wire,
920
940
  }));
921
941
  _lastRecovery = (r['recovery'] as Map?)?.cast<String, Object?>();
922
942
  }
923
943
 
944
+ /// The custody lifecycle stream (rebirth_and_custody_lifecycle.md) — a rebirth/divergence surfaces here as a
945
+ /// typed [NomosCustodyEvent]. Bind a Flutter break dialog / posture banner to it; for a `CustodyBreakPolicy.
946
+ /// ask` session, an event of `kind == 'ask'` awaits [resolveCustodyBreak].
947
+ Stream<NomosCustodyEvent> get custodyEvents => _custodyEvents.stream;
948
+
949
+ /// Where a session sits on the custody spectrum (incarnation, posture, pending/salvaged local work,
950
+ /// supersession history) — for a status banner or a "you're offline on old truth" indicator.
951
+ Future<NomosCustodyStatus> custodyStatus({String? session}) async =>
952
+ NomosCustodyStatus.fromJson(
953
+ _asMap(await _call('custodyStatus', {if (session != null) 'session': session})));
954
+
955
+ /// Continue an `ask`-policy custody break with the user's decision (adopt|reject) — the async UX lane.
956
+ Future<Map<String, Object?>> resolveCustodyBreak(String decision,
957
+ {String? session}) async =>
958
+ _asMap(await _call('resolveCustodyBreak', {
959
+ 'decision': decision,
960
+ if (session != null) 'session': session,
961
+ }));
962
+
963
+ /// The parked salvaged intents (re-offerable local work an `adopt` rescued) — metadata only.
964
+ Future<List<Map<String, Object?>>> salvagedIntents({String? session}) async {
965
+ final r = _asMap(await _call('salvagedIntents', {if (session != null) 'session': session}));
966
+ return ((r['salvaged'] as List?) ?? const [])
967
+ .map((e) => (e as Map).cast<String, Object?>())
968
+ .toList();
969
+ }
970
+
971
+ /// Re-offer parked salvaged work under the current (adopted) law — the explicit app move (never automatic
972
+ /// across incarnations). [id] re-offers one; omitted re-offers all.
973
+ Future<Map<String, Object?>> reofferSalvaged({String? id, String? session}) async =>
974
+ _asMap(await _call('reofferSalvaged',
975
+ {if (id != null) 'id': id, if (session != null) 'session': session}));
976
+
977
+ /// Discard one parked salvaged intent — the app's explicit choice.
978
+ Future<Map<String, Object?>> discardSalvaged(String id, {String? session}) async =>
979
+ _asMap(await _call('discardSalvaged', {'id': id, if (session != null) 'session': session}));
980
+
924
981
  /// The salvage report from the most recent `connect(recover: true)` — `{ salvagedWrites, requeuedDeadLetters,
925
982
  /// unsalvageable }`, or null if the connection didn't recover. Show "recovered N writes" / surface
926
983
  /// `unsalvageable` (writes the current law refused — kept in the DLQ, never dropped).
@@ -1713,6 +1770,7 @@ class NomosBridge {
1713
1770
  _stopStatusPoll();
1714
1771
  if (!_syncEvents.isClosed) await _syncEvents.close();
1715
1772
  if (!_signalEvents.isClosed) await _signalEvents.close();
1773
+ if (!_custodyEvents.isClosed) await _custodyEvents.close();
1716
1774
  if (!_flameEvents.isClosed) await _flameEvents.close();
1717
1775
  if (!_spanEvents.isClosed) await _spanEvents.close();
1718
1776
  if (!_transferEvents.isClosed) await _transferEvents.close();
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.45.0
14
+ version: 0.46.0
15
15
 
16
16
  environment:
17
17
  sdk: ^3.5.0
@@ -20,7 +20,7 @@ dependencies:
20
20
  # The pure-data core (wire types, offer codec, NomosReads, schema validation, error
21
21
  # info). nomos_client depends on and re-exports it, so existing imports are unchanged.
22
22
  # For local monorepo dev, pubspec_overrides.yaml points this at ../dart_types.
23
- nomos_types: ^0.1.3
23
+ nomos_types: ^0.1.4
24
24
  json_schema: ^5.2.2
25
25
  crypto: ^3.0.0
26
26
 
@@ -1,3 +1,9 @@
1
+ ## 0.1.4
2
+
3
+ - Custody-lifecycle types: `NomosCustodyBreak`, `NomosCustodyStatus`, `NomosCustodyEvent`,
4
+ `CustodyBreakPolicy` (adopt/reject/ask) — a workspace rebirth surfaces as a typed, app-decidable event.
5
+ Purely additive.
6
+
1
7
  ## 0.1.3
2
8
 
3
9
  - `NomosRef` — THE one typed reference `{workspace, type, id}`: value semantics, the canonical
@@ -33,3 +33,6 @@ export 'src/errors.dart';
33
33
  export 'src/foreign_ref.dart';
34
34
  export 'src/activity.dart';
35
35
  export 'src/nomos_ref.dart';
36
+ // The CUSTODY-LIFECYCLE types (rebirth_and_custody_lifecycle.md): NomosCustodyBreak / NomosCustodyStatus /
37
+ // CustodyBreakPolicy — custody incompatibility as a first-class, typed, app-decidable event. Pure data.
38
+ export 'src/custody.dart';
@@ -0,0 +1,203 @@
1
+ // NOMOS — the CUSTODY-LIFECYCLE types (rebirth_and_custody_lifecycle.md, the client half). Custody
2
+ // incompatibility is a FIRST-CLASS, app-decidable event: a rebirth (rebirthWorkspace) re-genesises a
3
+ // workspace's chain, invalidating every client's local history. The client detects the break, names it
4
+ // truthfully, and hands the app the decision — it NEVER silently adopts foreign truth nor loses local work.
5
+ //
6
+ // Pure data (no bridge/lifecycle/Flutter) so BOTH the layered TYPES package and the runtime `nomos_client`
7
+ // resolve them. The bridge decodes these from the runner's `custody` events + `custodyStatus` reply.
8
+ library;
9
+
10
+ /// WHY a chain was superseded — the lineage annotation lifted from the workspace status onto a break.
11
+ class NomosCustodySupersession {
12
+ final String? reason;
13
+ final String? supersededAt;
14
+
15
+ const NomosCustodySupersession({this.reason, this.supersededAt});
16
+
17
+ factory NomosCustodySupersession.fromJson(Map<String, dynamic> j) =>
18
+ NomosCustodySupersession(
19
+ reason: j['reason'] as String?,
20
+ supersededAt: j['supersededAt'] as String?,
21
+ );
22
+ Map<String, dynamic> toJson() =>
23
+ {'reason': reason, 'supersededAt': supersededAt};
24
+ }
25
+
26
+ /// The kind of custody break.
27
+ /// - [superseded]: a LAWFUL rebirth — the remote incarnation differs and the lineage records the
28
+ /// supersession (a reason/attribution rides the break).
29
+ /// - [diverged]: the histories don't reconcile with NO supersession explanation (an unrelated/foreign chain).
30
+ enum NomosCustodyBreakKind {
31
+ superseded,
32
+ diverged;
33
+
34
+ static NomosCustodyBreakKind fromWire(String? s) =>
35
+ s == 'diverged' ? NomosCustodyBreakKind.diverged : NomosCustodyBreakKind.superseded;
36
+ String get wire => this == NomosCustodyBreakKind.diverged ? 'diverged' : 'superseded';
37
+ }
38
+
39
+ /// A CUSTODY BREAK — the remote incarnation is incompatible with this client's local chain. Carries
40
+ /// everything the app needs to choose adopt vs reject (and to explain WHY to a user).
41
+ class NomosCustodyBreak {
42
+ final NomosCustodyBreakKind kind;
43
+
44
+ /// This client's local chain genesis oid.
45
+ final String localGenesis;
46
+
47
+ /// The remote serving chain's genesis oid (the workspace's current incarnation).
48
+ final String remoteGenesis;
49
+ final String remoteIncarnation;
50
+
51
+ /// Present for a [NomosCustodyBreakKind.superseded] break — why the old chain was retired.
52
+ final NomosCustodySupersession? supersession;
53
+
54
+ /// How many local-only intents (un-synced authored work) are at stake — never lost by any policy.
55
+ final int localOnlyIntents;
56
+
57
+ /// Epoch-ms of the last successful converge before the break (or null if never synced).
58
+ final int? lastSyncedAt;
59
+
60
+ const NomosCustodyBreak({
61
+ required this.kind,
62
+ required this.localGenesis,
63
+ required this.remoteGenesis,
64
+ required this.remoteIncarnation,
65
+ this.supersession,
66
+ this.localOnlyIntents = 0,
67
+ this.lastSyncedAt,
68
+ });
69
+
70
+ factory NomosCustodyBreak.fromJson(Map<String, dynamic> j) => NomosCustodyBreak(
71
+ kind: NomosCustodyBreakKind.fromWire(j['kind'] as String?),
72
+ localGenesis: (j['localGenesis'] ?? '') as String,
73
+ remoteGenesis: (j['remoteGenesis'] ?? j['remoteIncarnation'] ?? '') as String,
74
+ remoteIncarnation: (j['remoteIncarnation'] ?? j['remoteGenesis'] ?? '') as String,
75
+ supersession: (j['supersession'] is Map)
76
+ ? NomosCustodySupersession.fromJson(
77
+ (j['supersession'] as Map).cast<String, dynamic>())
78
+ : null,
79
+ localOnlyIntents: (j['localOnlyIntents'] as num?)?.toInt() ?? 0,
80
+ lastSyncedAt: (j['lastSyncedAt'] as num?)?.toInt(),
81
+ );
82
+
83
+ Map<String, dynamic> toJson() => {
84
+ 'kind': kind.wire,
85
+ 'localGenesis': localGenesis,
86
+ 'remoteGenesis': remoteGenesis,
87
+ 'remoteIncarnation': remoteIncarnation,
88
+ if (supersession != null) 'supersession': supersession!.toJson(),
89
+ 'localOnlyIntents': localOnlyIntents,
90
+ 'lastSyncedAt': lastSyncedAt,
91
+ };
92
+ }
93
+
94
+ /// The app's decision for a custody break (the declared or interactive policy outcome).
95
+ enum CustodyBreakPolicy {
96
+ /// Salvage local-only work, re-clone the new incarnation, park the salvage re-offerable, resume.
97
+ adopt,
98
+
99
+ /// Stay on local truth, go offline-only (the local holon keeps serving; sync suspends).
100
+ reject,
101
+
102
+ /// Surface the break on [custodyEvents] and await `resolveCustodyBreak(...)` (async UX — a dialog).
103
+ ask;
104
+
105
+ String get wire => name;
106
+ }
107
+
108
+ /// A supersession entry from the workspace lineage (custody introspection history).
109
+ class NomosSupersededChain {
110
+ final String? ref;
111
+ final String? supersededAt;
112
+ final String? reason;
113
+
114
+ const NomosSupersededChain({this.ref, this.supersededAt, this.reason});
115
+
116
+ factory NomosSupersededChain.fromJson(Map<String, dynamic> j) => NomosSupersededChain(
117
+ ref: j['ref'] as String?,
118
+ supersededAt: j['supersededAt'] as String?,
119
+ reason: j['reason'] as String?,
120
+ );
121
+ Map<String, dynamic> toJson() =>
122
+ {'ref': ref, 'supersededAt': supersededAt, 'reason': reason};
123
+ }
124
+
125
+ /// A custody lifecycle event pushed over the bridge (mirrors syncEvents). `kind` is one of
126
+ /// "detected" | "rejected" | "adopting" | "adopted" | "ask"; "ask" awaits `resolveCustodyBreak`.
127
+ class NomosCustodyEvent {
128
+ final String kind;
129
+ final String? session;
130
+ final NomosCustodyBreak? break_;
131
+ final bool? recloned;
132
+ final int? parkedIntents;
133
+
134
+ const NomosCustodyEvent({
135
+ required this.kind,
136
+ this.session,
137
+ this.break_,
138
+ this.recloned,
139
+ this.parkedIntents,
140
+ });
141
+
142
+ /// True when this event is an interactive prompt awaiting a decision.
143
+ bool get isAsk => kind == 'ask';
144
+
145
+ static NomosCustodyEvent? fromMessage(Map<String, dynamic> m) {
146
+ final c = (m['custody'] as Map?)?.cast<String, dynamic>();
147
+ if (c == null) return null;
148
+ final b = (c['break'] as Map?)?.cast<String, dynamic>();
149
+ return NomosCustodyEvent(
150
+ kind: (c['kind'] ?? '') as String,
151
+ session: m['session'] as String?,
152
+ break_: b != null ? NomosCustodyBreak.fromJson(b) : null,
153
+ recloned: c['recloned'] as bool?,
154
+ parkedIntents: (c['parkedIntents'] as num?)?.toInt(),
155
+ );
156
+ }
157
+ }
158
+
159
+ /// Where a holon sits on the custody spectrum ([custodyStatus]).
160
+ class NomosCustodyStatus {
161
+ /// The chain THIS holon serves (its own genesis oid).
162
+ final String? incarnation;
163
+ final String? localGenesis;
164
+
165
+ /// The remote serving chain's genesis (or null when unknown / pre-rebirth cloud).
166
+ final String? remoteIncarnation;
167
+
168
+ /// One of: synced | custody-break | custody-break-rejected | adopting.
169
+ final String syncPosture;
170
+ final int pendingLocalIntents;
171
+ final int salvagedIntents;
172
+ final List<NomosSupersededChain> supersessionHistory;
173
+
174
+ /// The unresolved break, when the holon is sitting in a break posture.
175
+ final NomosCustodyBreak? pendingBreak;
176
+
177
+ const NomosCustodyStatus({
178
+ this.incarnation,
179
+ this.localGenesis,
180
+ this.remoteIncarnation,
181
+ this.syncPosture = 'synced',
182
+ this.pendingLocalIntents = 0,
183
+ this.salvagedIntents = 0,
184
+ this.supersessionHistory = const [],
185
+ this.pendingBreak,
186
+ });
187
+
188
+ factory NomosCustodyStatus.fromJson(Map<String, dynamic> j) => NomosCustodyStatus(
189
+ incarnation: j['incarnation'] as String?,
190
+ localGenesis: j['localGenesis'] as String?,
191
+ remoteIncarnation: j['remoteIncarnation'] as String?,
192
+ syncPosture: (j['syncPosture'] ?? 'synced') as String,
193
+ pendingLocalIntents: (j['pendingLocalIntents'] as num?)?.toInt() ?? 0,
194
+ salvagedIntents: (j['salvagedIntents'] as num?)?.toInt() ?? 0,
195
+ supersessionHistory: ((j['supersessionHistory'] as List?) ?? const [])
196
+ .whereType<Map>()
197
+ .map((m) => NomosSupersededChain.fromJson(m.cast<String, dynamic>()))
198
+ .toList(),
199
+ pendingBreak: (j['pendingBreak'] is Map)
200
+ ? NomosCustodyBreak.fromJson((j['pendingBreak'] as Map).cast<String, dynamic>())
201
+ : null,
202
+ );
203
+ }
@@ -11,7 +11,7 @@ description: >-
11
11
  Pure kernel wire/data types for Nomos Dart frontends — no transport, no bridge,
12
12
  no runtime. The hand-written serde-JSON-compatible types plus the offer codec,
13
13
  reactive read engine, schema validation, and data-only error/trace types.
14
- version: 0.1.3
14
+ version: 0.1.4
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.68.0",
3
+ "version": "0.69.1",
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",
@@ -432,6 +432,9 @@ const DART_SUPPORT_CORE_SRC = [
432
432
  "foreign_ref.dart",
433
433
  "activity.dart",
434
434
  "nomos_ref.dart",
435
+ // The custody-lifecycle types (NomosCustodyBreak/NomosCustodyStatus/CustodyBreakPolicy) — the generated
436
+ // client + the vendored bridge reference them through the barrel, so they must vendor. Pure data.
437
+ "custody.dart",
435
438
  ] as const;
436
439
  // The RUNTIME support files, vendored from `nomos_client` (dsl/dart/lib/src): the async WebView
437
440
  // bridge the generated `<name>.client.dart` drives, and the provider surface. Their
@@ -283,6 +283,27 @@ export const CloudWorkspace = aggregate("CloudWorkspace", {
283
283
  * (JSON), never `t.json`: it rides verbatim as a scalar (the kernel `Value` has no JSON variant — the same
284
284
  * reasoning as the auth keys). Absent on root (the base of the world tree, born under nothing). */
285
285
  bornUnder: t.string().merge(Lww).optional(),
286
+ /**
287
+ * SUPERSESSION (recovery verb 3 — {@link rebirthWorkspace}, architecture/rebirth_and_custody_lifecycle.md).
288
+ * "Supersession, never forgiveness": a chain scarred by a historical LAW violation cannot be retro-blessed;
289
+ * the lawful remedy is a REBIRTH — a new chain re-genesised from the current law + state that self-verifies
290
+ * from intent 0. These three ADDITIVE, append-only lineage facts annotate THAT act on the workspace's record
291
+ * (the workspace NAME survives; the chain identity changes). Absent = the workspace has never been reborn.
292
+ * The presence of {@link CloudWorkspace.supersededAt} is the committed LAW FACT the bailiff's `/rebirth-custody`
293
+ * verb gates on — the law authorizes, the bailiff executes the compaction+swap. Era-safe (optional Lww, additive sids).
294
+ *
295
+ * `supersededBy` — the new incarnation's genesis oid once custody has built + self-verified it (the receipt,
296
+ * authored by the same custodian via a follow-up rebirthWorkspace carrying `incarnation`); `""` until then.
297
+ */
298
+ supersededBy: t.string().merge(Lww).optional(),
299
+ /** WHY the chain was superseded — the custodian's mandatory justification (audit, on the ledger forever). */
300
+ supersessionReason: t.string().merge(Lww).optional(),
301
+ /** Epoch-ms the supersession was authored (caller-stamped) — its PRESENCE is the law fact custody gates on. */
302
+ supersededAt: t.int().merge(Lww).optional(),
303
+ /** LINEAGE CONTINUITY (ratified 2026-07-09): on a REBORN holon's OWN birth record — the superseded
304
+ * incarnation's genesis oid. The reborn holon itself testifies to its ancestry (alongside the UNCHANGED
305
+ * `parent` — a rebirth may never claim new parents; see {@link rebirthRetainsLineage}). Additive/era-safe. */
306
+ rebornFrom: t.string().merge(Lww).optional(),
286
307
  });
287
308
 
288
309
  // AdminGrant / CreationGrant aggregates: DELETED (full-native Zanzibar). Admin/creation authority is
@@ -489,6 +510,10 @@ export function buildGenesisChain(p: {
489
510
  * package compiled under the metered-birth DSL) — see the poolSteps note below.
490
511
  */
491
512
  descendantPool?: number;
513
+ /** LINEAGE CONTINUITY (rebirth genesis only): the superseded incarnation's genesis oid. Threads onto the
514
+ * child's birthCloud record (`rebornFrom`) + the bornUnder commitment — the reborn holon testifies to its
515
+ * ancestry while `parent` stays the SUPERSEDED record's parent (a rebirth may never reparent). */
516
+ rebornFrom?: string;
492
517
  }): string {
493
518
  const founder = "user:" + p.principal;
494
519
  // THE ORDER-DECLARED LAW INSTALL (G3): the child installs the EXACT law the order names — bytes are NOT in
@@ -514,6 +539,7 @@ export function buildGenesisChain(p: {
514
539
  lawHash: "",
515
540
  authorizationHash: "",
516
541
  parentHead: "",
542
+ ...(p.rebornFrom ? { rebornFrom: p.rebornFrom } : {}),
517
543
  });
518
544
  // METERED BIRTH (slice A3 — THE POOL CHAINS TO ROOT): when the order carries a `descendantPool`, the
519
545
  // child is BORN BOUNDED — a `seedBirthPolicy` genesis step writes the child's own BirthPolicy singleton
@@ -546,7 +572,7 @@ export function buildGenesisChain(p: {
546
572
  { domain: "workspaces", directiveId: "seedGenesis", actor: founder, payload: { principal: founder, workspaceId: p.requiresWarrant !== false ? p.name : "", bornAt: p.bornAt } },
547
573
  { domain: "workspaces", directiveId: "grantAdmin", actor: founder, payload: { keyHash: p.foundingAdminHash, label: `founding admin (born for ${p.principal})`, grantedBy: p.principal, grantedAt: p.requestedAt } },
548
574
  // No `role` — it was a vestigial node-class label (collapsed with `kind`); birthCloud ignores it now.
549
- { domain: "workspaces", directiveId: "birthCloud", actor: "", payload: { principal: p.principal, name: p.name, tier: p.tier, bornAt: p.requestedAt, bornUnder } },
575
+ { domain: "workspaces", directiveId: "birthCloud", actor: "", payload: { principal: p.principal, name: p.name, tier: p.tier, bornAt: p.requestedAt, bornUnder, ...(p.rebornFrom ? { rebornFrom: p.rebornFrom } : {}) } },
550
576
  ]);
551
577
  }
552
578
 
@@ -708,6 +734,9 @@ export const birthCloud = directive("birthCloud")
708
734
  * birth, recorded onto the child's self-record so its OWN chain self-attests its parentage. Absent on
709
735
  * root (born under nothing). Authored by {@link buildGenesisChain} for a child birth. */
710
736
  bornUnder: z.string().optional(),
737
+ /** LINEAGE CONTINUITY: present ONLY on a REBIRTH genesis — the superseded incarnation's genesis oid.
738
+ * The reborn holon's own chain testifies to its ancestry; `parent` (in bornUnder) stays UNCHANGED. */
739
+ rebornFrom: z.string().optional(),
711
740
  }),
712
741
  )
713
742
  .plan((p) => {
@@ -723,6 +752,9 @@ export const birthCloud = directive("birthCloud")
723
752
  .set("provisionedAt", p.bornAt);
724
753
  // The child's chain self-attests its lineage authorization (the proof-friendly binding); root omits it.
725
754
  if (p.bornUnder) rec.set("bornUnder", p.bornUnder);
755
+ // LINEAGE CONTINUITY: a rebirth genesis records the superseded incarnation it re-genesises (ancestry
756
+ // testimony in the reborn holon's OWN chain, beside the unchanged parent).
757
+ if (p.rebornFrom) rec.set("rebornFrom", p.rebornFrom);
726
758
  // The self-identity singleton at the CONSTANT id (ungated — birthCloud is not admin-mutating), so the
727
759
  // gate's self-record exists before any admin write. ensure() = deterministic-id sibling birth. `name` is
728
760
  // the born marker; `role` is no longer written (vestigial node-class — see WorkspaceSelf).
@@ -896,6 +928,106 @@ export const reclaimWorkspace = directive("reclaimWorkspace")
896
928
  })
897
929
  .requires("admin");
898
930
 
931
+ /**
932
+ * rebirthWorkspace — LAWFUL SUPERSESSION (recovery verb 3, architecture/rebirth_and_custody_lifecycle.md +
933
+ * lawful_recovery.md). "Supersession, never forgiveness": where {@link certifyEra} vouches IDENTITY (heals a
934
+ * ForgedPrincipal/BadSignature scar) and cannot paper over a LAW violation, rebirth is the remedy for a chain
935
+ * scarred UNRECOVERABLY (e.g. an evolve-gate retype with no dispositions) — a NEW chain re-genesised from the
936
+ * current law + state that self-verifies from intent 0, with the OLD chain SUPERSEDED in lineage (annotated,
937
+ * kept in custody, never deleted). Mirrors reclaim's law-act/custody-act split:
938
+ * • LAW ACT (this directive): the custodian records the supersession on the workspace's lineage record —
939
+ * {@link CloudWorkspace.supersededAt} (its PRESENCE is the committed fact the bailiff gates on),
940
+ * `supersessionReason` (mandatory), and `supersededBy` (the new incarnation oid — `""` on the first
941
+ * offer, filled by a follow-up offer once custody has built + verified the incarnation).
942
+ * • CUSTODY ACT: the internal `/rebirth-custody` worker verb builds the compacted incarnation, self-verifies
943
+ * it through the upload-birth `verify_chain` lane (GREEN from intent 0 REQUIRED before any repoint),
944
+ * births it into FRESH custody (ONE LEDGER PER REPO — a repo IS a holon; lineage forms the holarchy),
945
+ * REPOINTS the workspace NAME to it, and FREEZES the old repo untouched at its own custody address —
946
+ * retained whole, never rewritten (GC-later = deleting that repo wholesale).
947
+ *
948
+ * THE FIXED POINT (inherited from reclaim): the law REFUSES to rebirth ROOT — root is the seat of the very law
949
+ * that authorizes rebirth, so the law can re-genesise everything it governs EXCEPT itself. Enforced BY LAW here
950
+ * (the {@link rebirthNotRoot} invariant reads the target record's identity at the one gate on every lane), not
951
+ * merely by the bailiff. Gated `.requires("admin")` — the strongest control-plane authority (a custodian act).
952
+ */
953
+ export const rebirthWorkspace = directive("rebirthWorkspace")
954
+ .mutates(CloudWorkspace)
955
+ .payload(
956
+ z.object({
957
+ /** The target CloudWorkspace AGGREGATE id (the minted lineage-record id; root-custody registry). */
958
+ workspaceId: z.string().min(1),
959
+ /** MANDATORY — the custodian's justification for superseding this chain (audit, on the ledger forever). */
960
+ reason: z.string().min(1),
961
+ /** Epoch-ms the supersession was authored (caller-stamped — the timestamp doctrine). Its presence on the
962
+ * record is the LAW FACT the `/rebirth-custody` verb gates on. */
963
+ rebornAt: epochMs,
964
+ /** The new incarnation's genesis oid — OPTIONAL: absent/`""` on the first (decision) offer; the custodian
965
+ * re-offers carrying it once custody has built + self-verified the incarnation (the receipt). */
966
+ incarnation: z.string().optional(),
967
+ /** LINEAGE CONTINUITY: the parent the new incarnation CLAIMS (carried on the receipt leg for the
968
+ * audit record). When present it MUST equal the superseded record's parent — a reparenting rebirth
969
+ * is a typed refusal ({@link rebirthRetainsLineage}). Absent ⇒ the parent is unchanged by definition. */
970
+ parent: z.string().optional(),
971
+ }),
972
+ )
973
+ .plan((p) => {
974
+ const ws = instance(CloudWorkspace, p.workspaceId);
975
+ return [
976
+ set(ws, "supersessionReason", p.reason),
977
+ set(ws, "supersededAt", p.rebornAt),
978
+ set(ws, "supersededBy", p.incarnation ?? ""),
979
+ ];
980
+ })
981
+ .requires("admin");
982
+
983
+ /**
984
+ * rebirthNotRoot — THE FIXED POINT AS LAW. The one gate reads the target record's identity (its `name`) at the
985
+ * pre-apply position and REFUSES a rebirth of the genesis workspace `root` — the seat of the very law that
986
+ * authorizes rebirth (mirroring the bailiff's `ws === ROOT_WS` fixed point in `/rebirth-custody`, and reclaim's
987
+ * root carve-out). NOTE `parent` is NOT the test: every workspace's OWN birthCloud self-record carries
988
+ * `parent: ""` (lineage lives in the PARENT's registry), so `parent === ""` would reject every workspace. A
989
+ * record read of the target (no NEW declared query) ⇒ the read manifest is unchanged (era-/evolve-safe).
990
+ * Enforced on every lane (author / edge admission / verify_chain).
991
+ */
992
+ export const rebirthNotRoot = workspaceInvariant("RebirthNotRoot")
993
+ .on("rebirthWorkspace")
994
+ .reads((ctx) => [
995
+ refAs("target", "CloudWorkspace", "pre:" + String((ctx?.intent ?? {}).workspaceId ?? "")),
996
+ ])
997
+ .assert((refs) => {
998
+ const target = (refs as { target?: { name?: string } }).target;
999
+ return target?.name === "root"
1000
+ ? { reject: "rebirth-refuses-root: root is the seat of the authorizing law and cannot supersede itself" }
1001
+ : { accept: true };
1002
+ });
1003
+
1004
+ /**
1005
+ * rebirthRetainsLineage — LINEAGE CONTINUITY AS LAW (ratified 2026-07-09). A rebirth/incarnation of an
1006
+ * existing workspace MUST retain its lineage: the parent the offer claims for the new incarnation must equal
1007
+ * the SUPERSEDED record's `parent` — a reparenting rebirth is a TYPED refusal at the one gate on every lane.
1008
+ * WHY: the holarchy must stay TOTALLY REPLAYABLE from root genesis — every birth/supersession/rebirth an
1009
+ * ordered law fact; a reborn holon that claimed new parents would fork the lineage graph out-of-history.
1010
+ * (The reborn holon's OWN chain additionally testifies via `rebornFrom` + the unchanged parent in bornUnder.)
1011
+ */
1012
+ export const rebirthRetainsLineage = workspaceInvariant("RebirthRetainsLineage")
1013
+ .on("rebirthWorkspace")
1014
+ .reads((ctx) => [
1015
+ refAs("target", "CloudWorkspace", "pre:" + String((ctx?.intent ?? {}).workspaceId ?? "")),
1016
+ ])
1017
+ .assert((refs, ctx) => {
1018
+ const target = (refs as { target?: { parent?: string } }).target;
1019
+ const claimed = (ctx?.intent as { parent?: string } | undefined)?.parent;
1020
+ // No claim ⇒ the parent is untouched (the plan never writes parent) — lineage retained by construction.
1021
+ if (claimed === undefined || claimed === "") return { accept: true };
1022
+ return claimed === (target?.parent ?? "")
1023
+ ? { accept: true }
1024
+ : { reject: "rebirth-must-retain-lineage: a rebirth cannot claim new parents — the incarnation keeps the superseded record's parent" };
1025
+ });
1026
+
1027
+ /** supersededWorkspaces — the bailiff/CLI reads workspaces carrying a supersession fact (audit + the
1028
+ * `/rebirth-custody` drain's queue). Keyed on `supersededBy` is unhelpful; the flat record read via
1029
+ * {@link workspaceByName} suffices for the custody gate, so this stays a by-name lookup upstream. */
1030
+
899
1031
  // ─────────────────────────── BIRTH AS A NOMOS PRIMITIVE ──────────────────────────────────────────────
900
1032
  // (birthPlatform is GONE — the tier collapsed onto #creator; birthChild is the one birth directive.)
901
1033
  // THE RECORD IS THE OBLIGATION (architecture/githolon_host_contract.md "Birth is a capability" +
package/src/index.ts CHANGED
@@ -137,6 +137,9 @@ export {
137
137
  export { create, existing, ensure, type AggregateRef } from "./authoring.js";
138
138
  export { contract, isContractHandle, satisfiesContract, type ContractHandle, type ContractField, type ContractSatisfaction } from "./contract.js";
139
139
  export { normalizeActivity, renderActivityTemplate, activityKey, type ActivityInput, type ActivityDescriptor, type ActivityRefInput, type ActivitySeverity } from "./activity.js";
140
+ // The REBIRTH-boundary repair DSL (architecture/repair_dsl.md) — distinct from framework/repair.js (the
141
+ // IN-CHAIN compensating-intent lane): repair() binds a typed replay refusal to a declared re-fold remedy.
142
+ export { repair, repairsOf, isRepairDecl, type RepairDecl, type RepairMatcher, type RepairVerb, type RepairRefusalKind, type RetypeMapping } from "./repair.js";
140
143
  // THE CAPTURED READ — a plan reads O(1) DSL queries via `read(query, args)`; the result is committed as
141
144
  // the intent's read footprint (deterministic replay + stale-premise detection). The dev calls typed DSL
142
145
  // queries; the library routes them here. The host capability is `nomos.read` (twin of `nomos.rng`).
package/src/repair.ts ADDED
@@ -0,0 +1,151 @@
1
+ // NOMOS — THE REPAIR DSL (architecture/repair_dsl.md). A REPAIR binds a KNOWN replay failure (a TYPED
2
+ // refusal matcher — kind + stable ids, never error prose) to a DECLARED remedy (a verb from the small
3
+ // deterministic palette), authored as CODE in the tenant repo's `repairs/` folder. Repairs IMPORT the
4
+ // domain; the domain never imports repairs. The repair manifest is NOT law and never hashed — the LAW facts
5
+ // are the supersession + certificate the execution emits (a custodian-gated rebirth-with-transform at the
6
+ // producer's reserved `transformState` seam).
7
+ //
8
+ // import { repair } from "@githolon/dsl";
9
+ // import { EstateAsset } from "../src/domains/estate";
10
+ //
11
+ // export const healAssetTypeRetype = repair("heal-assettype-retype")
12
+ // .matches({ refusal: "evolve-gate", stableId: "2c6d2218ad784422" })
13
+ // .reason("assetType retyped string→json in 0.54; history re-expressed through the mapping")
14
+ // .retypes(EstateAsset, "assetType", { through: "wrapAsJson" });
15
+ //
16
+ // DX (the product): `githolon replay` names the matching repair on refusal; `--repair` executes it
17
+ // (plan → confirm → rebirth → certificate); replay again is green. A repair costs one file + two commands.
18
+ import type { AggregateHandle } from "./aggregate.js";
19
+
20
+ /** The refusal kinds a matcher may bind (v1 — extend deliberately; each addition is a design conversation). */
21
+ export type RepairRefusalKind = "evolve-gate";
22
+
23
+ /** A typed refusal matcher — binds to the refusal's KIND + the stable id it names, never to prose. */
24
+ export interface RepairMatcher {
25
+ readonly refusal: RepairRefusalKind;
26
+ /** The stable id the refusal names (a field/aggregate sid from the lockfile lineage). */
27
+ readonly stableId: string;
28
+ /** Optionally pin the domain (when the same sid could appear in more than one — defensive). */
29
+ readonly domain?: string;
30
+ }
31
+
32
+ /** The NAMED deterministic mapping palette for `.retypes` — like merge drivers, a closed set the engine
33
+ * implements identically on every peer (determinism or death applies to repairs too). v1:
34
+ * - `wrapAsJson`: a scalar value v re-expresses as the json value v (string "x" → json "x") — the
35
+ * string→json retype lane (co2's assetType wound). */
36
+ export type RetypeMapping = "wrapAsJson";
37
+
38
+ /** A declared repair verb (v1 palette — designed against the three real cases). */
39
+ export type RepairVerb =
40
+ | { readonly kind: "retypes"; readonly aggregateId: string; readonly field: string; readonly through: RetypeMapping }
41
+ | { readonly kind: "strikes"; readonly aggregateId: string; readonly whereField: string; readonly equals: string }
42
+ | { readonly kind: "shreds"; readonly aggregateId: string; readonly field: string };
43
+
44
+ /** The finished, serializable repair declaration (what the compiler emits into the repairs manifest). */
45
+ export interface RepairDecl {
46
+ readonly id: string;
47
+ readonly matcher: RepairMatcher;
48
+ readonly reason: string;
49
+ readonly verb: RepairVerb;
50
+ readonly __isRepair: true;
51
+ }
52
+
53
+ /** True iff `x` is a finished repair declaration (the discovery duck-type). */
54
+ export function isRepairDecl(x: unknown): x is RepairDecl {
55
+ return typeof x === "object" && x !== null && (x as { __isRepair?: unknown }).__isRepair === true;
56
+ }
57
+
58
+ const RETYPE_MAPPINGS: ReadonlySet<string> = new Set(["wrapAsJson"]);
59
+ const REFUSAL_KINDS: ReadonlySet<string> = new Set(["evolve-gate"]);
60
+ const SID_RE = /^[0-9a-f]{16}$/i;
61
+
62
+ interface RepairNeedsMatcher {
63
+ /** Bind the KNOWN failure: the typed refusal this repair remedies. Validated: known kind, sid-shaped id. */
64
+ matches(m: RepairMatcher): RepairNeedsReason;
65
+ }
66
+ interface RepairNeedsReason {
67
+ /** MANDATORY human reason — rides the plan, the certificate, and the lineage supersession fact. */
68
+ reason(text: string): RepairNeedsVerb;
69
+ }
70
+ interface RepairNeedsVerb {
71
+ /** Historical RETYPE through a NAMED deterministic mapping (the co2-assetType lane). */
72
+ retypes(agg: AggregateHandle, field: string, opts: { through: RetypeMapping }): RepairDecl;
73
+ /** Drop matching rows' history at the re-fold (the data-purge lane). v1 predicate: field equality. */
74
+ strikes(agg: AggregateHandle, where: { field: string; equals: string }): RepairDecl;
75
+ /** Carry the field forward ABSENT — crypto-shred / PII erasure (the GDPR verb). */
76
+ shreds(agg: AggregateHandle, field: string): RepairDecl;
77
+ }
78
+
79
+ /**
80
+ * Declare a REPAIR — a known replay failure bound to a declared remedy. Staged builder so a half-finished
81
+ * repair is unrepresentable: `repair(id).matches(...).reason(...).<verb>(...)` yields the {@link RepairDecl}.
82
+ */
83
+ export function repair(id: string): RepairNeedsMatcher {
84
+ if (typeof id !== "string" || !/^[a-z0-9][a-z0-9-]*$/.test(id)) {
85
+ throw new Error(`repair(id): the id must be a kebab-case slug (got '${id}') — it names the remedy in plans, certificates, and lineage.`);
86
+ }
87
+ const requireAgg = (agg: AggregateHandle, verb: string): string => {
88
+ if (typeof agg !== "object" || agg === null || (agg as { __isAggregateHandle?: unknown }).__isAggregateHandle !== true) {
89
+ throw new Error(`repair('${id}').${verb}: the first argument must be an aggregate handle (import it from your domain).`);
90
+ }
91
+ return agg.id;
92
+ };
93
+ const requireField = (agg: AggregateHandle, field: string, verb: string): void => {
94
+ if (!(field in agg.fields)) {
95
+ throw new Error(
96
+ `repair('${id}').${verb}: aggregate '${agg.id}' has no field '${field}' (known: ${Object.keys(agg.fields).sort().join(", ")}).`,
97
+ );
98
+ }
99
+ };
100
+ return {
101
+ matches(m: RepairMatcher): RepairNeedsReason {
102
+ if (!REFUSAL_KINDS.has(m?.refusal)) {
103
+ throw new Error(`repair('${id}').matches: unknown refusal kind '${m?.refusal}' (known: ${[...REFUSAL_KINDS].join(", ")}).`);
104
+ }
105
+ if (typeof m.stableId !== "string" || !SID_RE.test(m.stableId)) {
106
+ throw new Error(`repair('${id}').matches: 'stableId' must be the 16-hex stable id the refusal names (got '${m.stableId}').`);
107
+ }
108
+ return {
109
+ reason(text: string): RepairNeedsVerb {
110
+ if (typeof text !== "string" || text.trim().length === 0) {
111
+ throw new Error(`repair('${id}').reason: a non-empty reason is mandatory — it rides the plan, certificate, and lineage.`);
112
+ }
113
+ const finish = (verb: RepairVerb): RepairDecl => ({ id, matcher: { refusal: m.refusal, stableId: m.stableId, ...(m.domain !== undefined ? { domain: m.domain } : {}) }, reason: text, verb, __isRepair: true });
114
+ return {
115
+ retypes(agg, field, opts) {
116
+ const aggregateId = requireAgg(agg, "retypes");
117
+ requireField(agg, field, "retypes");
118
+ if (!RETYPE_MAPPINGS.has(opts?.through)) {
119
+ throw new Error(`repair('${id}').retypes: unknown mapping '${opts?.through}' (known: ${[...RETYPE_MAPPINGS].join(", ")}) — mappings are a NAMED deterministic palette, never closures.`);
120
+ }
121
+ return finish({ kind: "retypes", aggregateId, field, through: opts.through });
122
+ },
123
+ strikes(agg, where) {
124
+ const aggregateId = requireAgg(agg, "strikes");
125
+ requireField(agg, where?.field ?? "", "strikes");
126
+ if (typeof where?.equals !== "string") throw new Error(`repair('${id}').strikes: 'where.equals' must be a string.`);
127
+ return finish({ kind: "strikes", aggregateId, whereField: where.field, equals: where.equals });
128
+ },
129
+ shreds(agg, field) {
130
+ const aggregateId = requireAgg(agg, "shreds");
131
+ requireField(agg, field, "shreds");
132
+ return finish({ kind: "shreds", aggregateId, field });
133
+ },
134
+ };
135
+ },
136
+ };
137
+ },
138
+ };
139
+ }
140
+
141
+ /** Collect every exported repair from a module's exports bag (the discovery duck-type, like aggregatesOf). */
142
+ export function repairsOf(mod: Record<string, unknown>): RepairDecl[] {
143
+ const out: RepairDecl[] = [];
144
+ for (const v of Object.values(mod)) if (isRepairDecl(v)) out.push(v);
145
+ // deterministic order + duplicate-id refusal (two repairs with one id would make --repair ambiguous)
146
+ out.sort((a, b) => a.id.localeCompare(b.id));
147
+ for (let i = 1; i < out.length; i++) {
148
+ if (out[i]!.id === out[i - 1]!.id) throw new Error(`repairs: duplicate repair id '${out[i]!.id}' — repair ids must be unique.`);
149
+ }
150
+ return out;
151
+ }