@githolon/dsl 0.77.1 → 0.78.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,25 @@
1
+ ## 0.54.0
2
+
3
+ - **Parent-signer recovery** (the reborn-parent scar): `ensureParentSignerEnrollment(...)` —
4
+ idempotent verify-and-repair of `identity:<uid>#signer` on a configured parent (one relation read
5
+ when healthy; lawful repair via the discovered signing directive — enrollSigner pre-warrant, the
6
+ keyless enrollFirstDevice lane on a warranted parent); `parentSignerEnrollment(...)` posture read;
7
+ `devicePublicOf(secret)`.
8
+ - **`canAuthor(directiveId, ...)` preflight** — evaluates the gate's premises read-only and returns
9
+ the typed verdict ({actor, keyHash, cause}) BEFORE the user fills a form;
10
+ `NomosSession.canAuthorTyped` throws the typed error.
11
+ - Gate refusals classify into `nomos.authz.*` typed errors (see nomos_types 0.4.0). Requires the
12
+ paired runner bundle >= 0.78.0.
13
+
14
+ ## 0.53.0
15
+
16
+ - **Stale-baked-bundle detection.** The runner bundle stamps its release version into the `ready`
17
+ frame (build-time injected — it cannot lie); the bridge refuses an older or pre-stamp bundle at
18
+ boot with the typed `StaleRunnerBundle` naming found/expected and the `flutter clean` remedy.
19
+ Flutter silently keeping an old baked bundle across a pub upgrade is now a one-line error at app
20
+ start instead of behavioral archaeology. `allowStaleRunnerBundle: true` is the deliberate
21
+ mixed-version escape hatch. Requires the paired runner bundle >= 0.77.1 (nomos_flutter 0.53.7).
22
+
1
23
  ## 0.52.0
2
24
 
3
25
  - **Offer ack decoupled from watch fan-out** (co2's 2.08s 3×3-grid report): the durable acknowledgement
@@ -48,7 +48,7 @@ const int kWebviewBridgeVersion = 1;
48
48
  /// pre-stamp bundle, which reports nothing) is a stale baked asset and fails loudly at boot.
49
49
  /// MUST move with the paired @githolon/client release — a release-hygiene test pairs it with the
50
50
  /// runner stamp (the pubspec-pins pattern).
51
- const String kExpectedRunnerBundleVersion = '0.77.1';
51
+ const String kExpectedRunnerBundleVersion = '0.78.0';
52
52
 
53
53
  /// A STALE BAKED RUNNER BUNDLE: the app's built assets carry an older nomos-runner.js than this
54
54
  /// nomos_client release ships — Flutter's build cache kept it across a pub upgrade. Thrown from
@@ -1229,6 +1229,7 @@ class NomosBridge {
1229
1229
  List<Map<String, Object?>>? attestations,
1230
1230
  bool? force,
1231
1231
  String? session,
1232
+ String? authorSecret,
1232
1233
  }) =>
1233
1234
  offerOutcome(
1234
1235
  domain: domain,
@@ -1239,6 +1240,7 @@ class NomosBridge {
1239
1240
  attestations: attestations,
1240
1241
  force: force,
1241
1242
  session: session,
1243
+ authorSecret: authorSecret,
1242
1244
  );
1243
1245
 
1244
1246
  /// THE ONE OUTCOME-RETURNING OFFER LANE — [offerDirective] answering the full typed [NomosOfferOutcome]
@@ -1255,6 +1257,9 @@ class NomosBridge {
1255
1257
  List<Map<String, Object?>>? attestations,
1256
1258
  bool? force,
1257
1259
  String? session,
1260
+ // Per-offer signing key (base64 device secret): sign THIS offer with a specific device key instead
1261
+ // of the connect-time one — the parent-directed lanes (sibling births after a signer repair) use it.
1262
+ String? authorSecret,
1258
1263
  }) async {
1259
1264
  final Map<String, Object?> r;
1260
1265
  // OFFER SPAN TAXONOMY (bridge legs): stamp the Dart-side send/receive around the op; the runner
@@ -1274,6 +1279,7 @@ class NomosBridge {
1274
1279
  if (attestations != null) 'attestations': attestations,
1275
1280
  if (force != null) 'force': force,
1276
1281
  if (session != null) 'session': session,
1282
+ if (authorSecret != null) 'authorSecret': authorSecret,
1277
1283
  }));
1278
1284
  } catch (e) {
1279
1285
  throw await _wrapDomainNotInstalled(e,
@@ -1692,6 +1698,87 @@ class NomosBridge {
1692
1698
  if (authToken != null) 'authToken': authToken,
1693
1699
  }));
1694
1700
 
1701
+ /// Derive the device key's base64 PUBLIC half from its base64 SECRET seed, inside the LOCAL kernel
1702
+ /// (the secret never leaves the device). The recovery lanes use this when only the secret was
1703
+ /// persisted (the device-key store keeps just the seed) — e.g. to mint a device-BOUND IdP token
1704
+ /// (`authTokenForDevice(publicKey)`) for the keyless re-enroll lane.
1705
+ Future<String> devicePublicOf(String secret) async =>
1706
+ (_asMap(await _call('devicePublicOf', {'secret': secret}))['public'] ?? '')
1707
+ .toString();
1708
+
1709
+ /// PARENT-AUTHORITY POSTURE (read-only diagnostic): is this device key a LIVE `signer` for
1710
+ /// `principal` on `parent`'s chain (`identity:<principal>#signer@key:<sha256(publicKey)>`)? One O(1)
1711
+ /// relation read over the open gateway; nothing is authored. Returns
1712
+ /// `{parent, principal, keyHash, object, relation, enrolled, revoked, checkedAt}`.
1713
+ Future<Map<String, Object?>> parentSignerEnrollment(
1714
+ {required String parent,
1715
+ required String principal,
1716
+ String? keyHash,
1717
+ String? publicKey,
1718
+ String? authToken}) async =>
1719
+ _asMap(await _call('parentSignerEnrollment', {
1720
+ 'parent': parent,
1721
+ 'principal': principal,
1722
+ if (keyHash != null) 'keyHash': keyHash,
1723
+ if (publicKey != null) 'publicKey': publicKey,
1724
+ if (authToken != null) 'authToken': authToken,
1725
+ }));
1726
+
1727
+ /// IDEMPOTENT PARENT-SIGNER RECOVERY — ensure this device key is a live `signer` on `parent`,
1728
+ /// lawfully restoring the tuple a parent custody REBIRTH lost (the device key + the born home stay
1729
+ /// valid; no home rebirth, no hand-posted secrets). Already enrolled ⇒ pure no-op (`repaired:false`).
1730
+ /// Absent ⇒ re-authors the enrollment via the parent's OWN installed signing directive, DISCOVERED
1731
+ /// from its law (never a hardcoded framework domain — a tenant-composed `co2_platform` works);
1732
+ /// `domain`/`directiveId` override the discovery. A REVOKED key is never silently re-enrolled.
1733
+ /// TWO LAWFUL LANES, kernel-decided: a WARRANTED parent (the reborn-platform case) admits a fresh
1734
+ /// device-signer only via the keyless `enrollFirstDevice` — pass the device-bound [idToken]
1735
+ /// (+ [verifiedVia], the parent's AuthProvider issuer); a PRE-WARRANT parent admits the founding
1736
+ /// `enrollSigner` (no token needed).
1737
+ /// Returns `{parent, principal, keyHash, enrolled, repaired, domain?, directiveId?, checkedAt}`.
1738
+ Future<Map<String, Object?>> ensureParentSignerEnrollment(
1739
+ {required String parent,
1740
+ required String principal,
1741
+ String? publicKey,
1742
+ String? authorSecret,
1743
+ String? domain,
1744
+ String? directiveId,
1745
+ String? authToken,
1746
+ String? idToken,
1747
+ String? verifiedVia}) async =>
1748
+ _asMap(await _call('ensureParentSignerEnrollment', {
1749
+ 'parent': parent,
1750
+ 'principal': principal,
1751
+ if (publicKey != null) 'publicKey': publicKey,
1752
+ if (authorSecret != null) 'authorSecret': authorSecret,
1753
+ if (domain != null) 'domain': domain,
1754
+ if (directiveId != null) 'directiveId': directiveId,
1755
+ if (authToken != null) 'authToken': authToken,
1756
+ if (idToken != null) 'idToken': idToken,
1757
+ if (verifiedVia != null) 'verifiedVia': verifiedVia,
1758
+ }));
1759
+
1760
+ /// AUTHORING PREFLIGHT — evaluate the SAME premises the kernel's authorization gate judges,
1761
+ /// READ-ONLY, and return a typed verdict WITHOUT authoring — so the app catches a doomed write
1762
+ /// (e.g. a reborn parent missing this device's signer tuple) BEFORE the user fills a form.
1763
+ /// `ws` targets another workspace (pass the PARENT to preflight a sibling birth). Returns
1764
+ /// `{ok, ws, directiveId, actor, keyHash, cause, causes, domain?, relation?, object?, error?, checkedAt}`
1765
+ /// where `cause` ∈ {directive-not-found, no-signing-key, missing-signer-enrollment, revoked-signer,
1766
+ /// missing-role, null}. Use [canAuthorTyped] on a [NomosSession] for the typed-error variant.
1767
+ Future<Map<String, Object?>> canAuthor(String directiveId,
1768
+ {String? ws,
1769
+ String? principal,
1770
+ String? publicKey,
1771
+ String? keyHash,
1772
+ String? authToken}) async =>
1773
+ _asMap(await _call('canAuthor', {
1774
+ 'directiveId': directiveId,
1775
+ if (ws != null) 'ws': ws,
1776
+ if (principal != null) 'principal': principal,
1777
+ if (publicKey != null) 'publicKey': publicKey,
1778
+ if (keyHash != null) 'keyHash': keyHash,
1779
+ if (authToken != null) 'authToken': authToken,
1780
+ }));
1781
+
1695
1782
  /// VERIFY_CHAIN — the kernel's from-genesis self-verification of THIS holon's folded ledger (read-only:
1696
1783
  /// contiguity → every intent re-admitted through the ONE gate against the state folded from the prefix). For
1697
1784
  /// a WARRANTED home this RE-FIRES the parent-attested-birth cert door on the cold-replay lane — so a
@@ -2258,6 +2345,42 @@ class NomosSession extends NomosBridge {
2258
2345
  @override
2259
2346
  Future<void> dispose() async {}
2260
2347
 
2348
+ /// TYPED AUTHORING PREFLIGHT — [NomosBridge.canAuthor], returning the raw verdict when the write
2349
+ /// would admit and THROWING the matching typed error when it would not: a missing parent signer
2350
+ /// tuple throws [ParentSignerEnrollmentMissing] (its `recommendedAction` names the ONE remedy —
2351
+ /// [ensureParentSignerEnrollment]); any other failing premise throws [AuthzRefusedError] carrying
2352
+ /// the evaluated {workspace, actor, keyHash, object, relation, cause}. Nothing is authored.
2353
+ Future<Map<String, Object?>> canAuthorTyped(String directiveId,
2354
+ {String? ws,
2355
+ String? principal,
2356
+ String? publicKey,
2357
+ String? keyHash,
2358
+ String? authToken}) async {
2359
+ final v = await canAuthor(directiveId,
2360
+ ws: ws, principal: principal, publicKey: publicKey, keyHash: keyHash, authToken: authToken);
2361
+ if (v['ok'] == true) return v;
2362
+ final cause = v['cause']?.toString();
2363
+ final target = v['ws']?.toString() ?? ws ?? workspace;
2364
+ final info = NomosErrorInfo(
2365
+ code: cause == 'missing-signer-enrollment'
2366
+ ? 'nomos.authz.signer_enrollment_missing'
2367
+ : 'nomos.authz.refused',
2368
+ message:
2369
+ 'canAuthor($directiveId) on $target: premise "$cause" failed for ${v['actor'] ?? principal ?? '(unknown actor)'}',
2370
+ workspace: target,
2371
+ operation: 'canAuthor',
2372
+ attributes: {
2373
+ if (v['actor'] != null) 'nomos.authz.actor': v['actor'],
2374
+ if (v['keyHash'] != null) 'nomos.authz.keyHash': v['keyHash'],
2375
+ if (v['object'] != null) 'nomos.authz.object': v['object'],
2376
+ if (v['relation'] != null) 'nomos.authz.relation': v['relation'],
2377
+ 'nomos.authz.directiveId': directiveId,
2378
+ if (cause != null) 'nomos.authz.cause': cause,
2379
+ },
2380
+ );
2381
+ throw nomosErrorFromInfo(info);
2382
+ }
2383
+
2261
2384
  // NO per-method Stream/watch/snapshot overrides live here anymore — the base class routes ALL event
2262
2385
  // dispatch + plane state through [_eventPlane] (and law-scopes compat/onLawChanged via [_sessionName]),
2263
2386
  // and the watch ops still carry this session's workspace because their registration goes through the
package/dart/pubspec.lock CHANGED
@@ -191,7 +191,7 @@ packages:
191
191
  path: "../dart_types"
192
192
  relative: true
193
193
  source: path
194
- version: "0.3.0"
194
+ version: "0.4.0"
195
195
  package_config:
196
196
  dependency: transitive
197
197
  description:
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.52.0
14
+ version: 0.54.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.3.0
23
+ nomos_types: ^0.4.0
24
24
  json_schema: ^5.2.2
25
25
  crypto: ^3.0.0
26
26
 
@@ -68,6 +68,58 @@ void main() {
68
68
  expect(l.recommendedAction, NomosRecommendedAction.retry);
69
69
  });
70
70
 
71
+ test('authz signer_enrollment_missing mints ParentSignerEnrollmentMissing with the ONE remedy', () {
72
+ final e = nomosErrorFromInfo(info(
73
+ 'nomos.authz.signer_enrollment_missing',
74
+ workspace: 'co2-platform',
75
+ attrs: {
76
+ 'nomos.authz.actor': 'user:u-1',
77
+ 'nomos.authz.keyHash': 'abab',
78
+ 'nomos.authz.object': 'identity:u-1',
79
+ 'nomos.authz.directiveId': 'birthEstateWorkspace',
80
+ },
81
+ ));
82
+ expect(e, isA<NomosBridgeError>());
83
+ expect(e, isA<AuthzRefusedError>());
84
+ expect(e, isA<ParentSignerEnrollmentMissing>());
85
+ final p = e as ParentSignerEnrollmentMissing;
86
+ expect(p.cause, 'missing-signer-enrollment');
87
+ expect(p.workspace, 'co2-platform');
88
+ expect(p.actor, 'user:u-1');
89
+ expect(p.keyHash, 'abab');
90
+ expect(p.object, 'identity:u-1');
91
+ expect(p.relation, 'signer');
92
+ expect(p.directiveId, 'birthEstateWorkspace');
93
+ expect(p.recommendedAction, NomosRecommendedAction.ensureParentSignerEnrollment);
94
+ });
95
+
96
+ test('authz cause=missing-signer-enrollment routes to ParentSignerEnrollmentMissing even under the generic code', () {
97
+ final e = nomosErrorFromInfo(info(
98
+ 'nomos.authz.refused',
99
+ attrs: {'nomos.authz.cause': 'missing-signer-enrollment', 'nomos.authz.actor': 'user:u-9'},
100
+ ));
101
+ expect(e, isA<ParentSignerEnrollmentMissing>());
102
+ });
103
+
104
+ test('authz missing_role stays AuthzRefusedError — role-absent is DISTINGUISHED from signer-absent', () {
105
+ final e = nomosErrorFromInfo(info(
106
+ 'nomos.authz.missing_role',
107
+ workspace: 'ws-1',
108
+ attrs: {
109
+ 'nomos.authz.cause': 'missing-role',
110
+ 'nomos.authz.relation': 'creator',
111
+ 'nomos.authz.object': 'workspace:self',
112
+ 'nomos.authz.actor': 'user:u-2',
113
+ },
114
+ ));
115
+ expect(e, isA<AuthzRefusedError>());
116
+ expect(e, isNot(isA<ParentSignerEnrollmentMissing>()));
117
+ final a = e as AuthzRefusedError;
118
+ expect(a.cause, 'missing-role');
119
+ expect(a.relation, 'creator');
120
+ expect(a.object, 'workspace:self');
121
+ });
122
+
71
123
  test('custody.* still mints CustodyError (unchanged), unknown codes stay generic', () {
72
124
  expect(nomosErrorFromInfo(info('nomos.custody.missing_object')), isA<CustodyError>());
73
125
  final g = nomosErrorFromInfo(info('nomos.bridge.error'));
@@ -0,0 +1,134 @@
1
+ // PARENT-SIGNER RECOVERY — the Dart surface leg (offline, the real runner + wasm).
2
+ //
3
+ // The co2 gap: after a parent custody rebirth the device key stays valid but the parent's chain no
4
+ // longer carries `identity:<uid>#signer` — and there was NO published Dart recovery path. This proves
5
+ // the new surfaces end-to-end over the node runner:
6
+ // · `canAuthor` / `canAuthorTyped` — the read-only preflight returns the TYPED verdict
7
+ // (cause `missing-signer-enrollment` → ParentSignerEnrollmentMissing) BEFORE any authoring;
8
+ // · `ensureParentSignerEnrollment` — idempotent verify-and-repair on the parent (repairs when the
9
+ // tuple is absent, pure no-op when live) with the signing directive DISCOVERED from the law;
10
+ // · `parentSignerEnrollment` — the diagnostic posture read;
11
+ // · after the repair, canAuthor flips ok and a SIGNED birthEstateWorkspace admits.
12
+ // (The warranted-reborn-parent kernel legs — the exact refusal + the keyless enrollFirstDevice
13
+ // recovery — are proven on the wasm by bench/scale/parent_signer_recovery.smoke.mjs.)
14
+ @TestOn('vm')
15
+ library;
16
+
17
+ import 'dart:io';
18
+
19
+ import 'package:nomos_client/nomos_headless.dart';
20
+ import 'package:nomos_types/nomos_types.dart'
21
+ show ParentSignerEnrollmentMissing, NomosRecommendedAction;
22
+ import 'package:test/test.dart';
23
+
24
+ String get _root => '${Directory.current.path}/../..';
25
+ String get _repoEntry => '$_root/cloud/web-client/runner/node-entry.mjs';
26
+ String get _deploy =>
27
+ '$_root/bench/scale/fixtures/estate-birth-signed/build/estate-birth-signed.deploy.json';
28
+ String get _runtimeDir => '${Platform.environment['HOME'] ?? ''}/.holon/runtime';
29
+
30
+ const _ws = 'estate-birth-signed';
31
+ const _uid = 'sample-owner';
32
+
33
+ String? _missingPrereq() {
34
+ if (!File(_repoEntry).existsSync()) return 'in-repo runner not found at $_repoEntry';
35
+ if (!File(_deploy).existsSync()) {
36
+ return 'compile the fixture: cd bench/scale/fixtures/estate-birth-signed && node ../../../../dsl/compile_package.mjs';
37
+ }
38
+ if (!File('$_runtimeDir/holon.wasm').existsSync()) {
39
+ return 'warm ~/.holon/runtime first (run any `githolon` command once)';
40
+ }
41
+ return null;
42
+ }
43
+
44
+ void main() {
45
+ test(
46
+ 'canAuthor preflights the missing parent signer TYPED, ensureParentSignerEnrollment repairs it, '
47
+ 'and the signed birth then admits', () async {
48
+ final skip = _missingPrereq();
49
+ if (skip != null) {
50
+ markTestSkipped(skip);
51
+ return;
52
+ }
53
+ final harness = await NomosTestHarness.ephemeral(
54
+ deployJsonPaths: [_deploy],
55
+ nodeEntry: _repoEntry,
56
+ runtimeDir: _runtimeDir,
57
+ workspace: _ws,
58
+ );
59
+ try {
60
+ final session = harness.bridge.session(_ws);
61
+ final device = await session.mintDeviceKey();
62
+ final pub = device['public']!.toString();
63
+ final secret = device['secret']!.toString();
64
+
65
+ // devicePublicOf re-derives the public half from the persisted seed (the Dart key store keeps
66
+ // only the secret) — the recovery lanes depend on this.
67
+ expect(await session.devicePublicOf(secret), pub);
68
+
69
+ // 1. THE PREFLIGHT, broken state: the verdict names the failing premise BEFORE any authoring.
70
+ final v0 = await session.canAuthor('birthEstateWorkspace',
71
+ ws: _ws, principal: _uid, publicKey: pub);
72
+ expect(v0['ok'], isNot(true));
73
+ expect(v0['cause'], 'missing-signer-enrollment');
74
+ expect(v0['actor'], 'user:$_uid');
75
+ expect((v0['keyHash'] ?? '').toString(), hasLength(64));
76
+
77
+ // ...and the TYPED variant throws the ONE error with the ONE remedy.
78
+ await expectLater(
79
+ session.canAuthorTyped('birthEstateWorkspace', ws: _ws, principal: _uid, publicKey: pub),
80
+ throwsA(isA<ParentSignerEnrollmentMissing>()
81
+ .having((e) => e.recommendedAction, 'recommendedAction',
82
+ NomosRecommendedAction.ensureParentSignerEnrollment)
83
+ .having((e) => e.workspace, 'workspace', _ws)),
84
+ );
85
+
86
+ // 2. THE POSTURE READ agrees (diagnostic; nothing authored).
87
+ final posture0 = await session.parentSignerEnrollment(
88
+ parent: _ws, principal: _uid, publicKey: pub);
89
+ expect(posture0['enrolled'], false);
90
+ expect(posture0['object'], 'identity:$_uid');
91
+ expect(posture0['relation'], 'signer');
92
+
93
+ // 3. THE REPAIR — idempotent; the signing directive is DISCOVERED from the installed law
94
+ // (this fixture composes it under domain "home", NOT the framework "workspaces").
95
+ final r1 = await session.ensureParentSignerEnrollment(
96
+ parent: _ws, principal: _uid, publicKey: pub, authorSecret: secret);
97
+ expect(r1['enrolled'], true);
98
+ expect(r1['repaired'], true);
99
+ expect(r1['domain'], 'home',
100
+ reason: 'discovery must find the tenant-composed domain, never assume "workspaces"');
101
+
102
+ // 4. Re-run = pure no-op (the idempotence contract).
103
+ final r2 = await session.ensureParentSignerEnrollment(
104
+ parent: _ws, principal: _uid, publicKey: pub, authorSecret: secret);
105
+ expect(r2['enrolled'], true);
106
+ expect(r2['repaired'], false);
107
+
108
+ // 5. The preflight flips OK…
109
+ final v1 = await session.canAuthor('birthEstateWorkspace',
110
+ ws: _ws, principal: _uid, publicKey: pub);
111
+ expect(v1['ok'], true, reason: 'causes: ${v1['causes']}');
112
+
113
+ // 6. …and the SIGNED sibling birth ADMITS (per-offer signing key — the repaired device).
114
+ final lawHash = harness.lawHashes(_ws).first;
115
+ final outcome = await session.offerCreates(
116
+ domain: 'home',
117
+ directiveId: 'birthEstateWorkspace',
118
+ payload: {
119
+ 'owner': 'user:$_uid',
120
+ 'frameworkHash': harness.frameworkHash,
121
+ 'lawHash': lawHash,
122
+ 'bornAt': '2026-07-12T00:00:00Z',
123
+ },
124
+ actor: 'user:$_uid',
125
+ authorSecret: secret,
126
+ );
127
+ expect(outcome.head, isNotEmpty);
128
+ expect(outcome.born, isNotEmpty,
129
+ reason: 'the estate child births after the signer repair');
130
+ } finally {
131
+ await harness.dispose();
132
+ }
133
+ }, timeout: const Timeout(Duration(minutes: 3)));
134
+ }
@@ -1,3 +1,10 @@
1
+ ## 0.4.0
2
+
3
+ - Typed authorization diagnosis errors: `AuthzRefusedError` ({workspace, actor, keyHash, object,
4
+ relation, directiveId, cause}) and `ParentSignerEnrollmentMissing` (the one remedy named:
5
+ `ensureParentSignerEnrollment`) — "valid user, but this device cannot prove it" and "user lacks
6
+ the role" are now distinct typed diagnoses. `NomosRecommendedAction.ensureParentSignerEnrollment`.
7
+
1
8
  ## 0.3.0
2
9
 
3
10
  - `SurfacedValue<T>` — the typed read surface for `Surfaced(inner)` merge-driver fields: sealed
@@ -23,6 +23,7 @@ import 'error_info.dart' show NomosBridgeError, NomosErrorInfo;
23
23
  NomosBridgeError nomosErrorFromInfo(NomosErrorInfo info) {
24
24
  if (info.code.startsWith('nomos.custody.')) return CustodyError.fromInfo(info);
25
25
  if (info.code.startsWith('nomos.law.')) return LawMismatchError.fromInfo(info);
26
+ if (info.code.startsWith('nomos.authz.')) return AuthzRefusedError.fromInfo(info);
26
27
  if (info.code.startsWith('nomos.auth.')) return AuthError.fromInfo(info);
27
28
  if (info.code.startsWith('nomos.load.')) return LoadError.fromInfo(info);
28
29
  return NomosBridgeError(info.message, info: info);
@@ -56,6 +57,11 @@ class NomosRecommendedAction {
56
57
  /// Re-open the holon / reconnect fresh (the loaded runtime is unusable).
57
58
  static const reopen = 'reopen';
58
59
 
60
+ /// Restore the device signer on the parent workspace (`ensureParentSignerEnrollment` /
61
+ /// [NomosScope.home] repairs it automatically on reopen) — the ONE remedy for
62
+ /// [ParentSignerEnrollmentMissing] (a reborn parent that lost the signer tuple).
63
+ static const ensureParentSignerEnrollment = 'ensureParentSignerEnrollment';
64
+
59
65
  /// No automatic remedy — surface to a human.
60
66
  static const none = 'none';
61
67
 
@@ -76,6 +82,8 @@ class NomosRecommendedAction {
76
82
  return 'retry the operation';
77
83
  case reopen:
78
84
  return 'reopen the holon (reconnect fresh)';
85
+ case ensureParentSignerEnrollment:
86
+ return 'restore the device signer on the parent (ensureParentSignerEnrollment / NomosScope.home repairs it on reopen)';
79
87
  case none:
80
88
  return 'no automatic remedy — inspect the error';
81
89
  default:
@@ -175,6 +183,136 @@ class LawMismatchError extends NomosBridgeError {
175
183
  String toString() => 'LawMismatchError($code): $mismatchMessage';
176
184
  }
177
185
 
186
+ /// AUTHZ REFUSAL — the kernel's relation/signer gate refused an authored intent (`nomos.authz.*`).
187
+ /// ONE structured diagnosis instead of hand-comparing relation tuples across two workspaces: it names
188
+ /// the evaluated [workspace], [actor], [keyHash], [object], [relation] and the [cause] the gate
189
+ /// distinguished:
190
+ /// - `missing-signer-enrollment` — the signature is valid but `identity:<actor>#signer@key:<keyHash>`
191
+ /// is absent on the judging workspace (the reborn-parent case) → [ParentSignerEnrollmentMissing].
192
+ /// - `missing-role` — the signer binding held (or was not required) but the directive's
193
+ /// `.requires(<relation>)` tuple is absent for the actor on [object].
194
+ /// These fields ride the UNSEALED presentation lanes only (verdict / 422 body / bridge errorInfo) —
195
+ /// never the sealed chain bytes.
196
+ class AuthzRefusedError extends NomosBridgeError {
197
+ /// The stable machine code, e.g. `nomos.authz.signer_enrollment_missing`.
198
+ final String code;
199
+
200
+ /// The workspace whose gate judged the offer (e.g. the PARENT for a sibling birth).
201
+ final String? workspace;
202
+
203
+ /// The evaluated actor (`user:<uid>`).
204
+ final String? actor;
205
+
206
+ /// sha256(device public key) — the on-chain `key:<hash>` subject the gate checked.
207
+ final String? keyHash;
208
+
209
+ /// The Zanzibar object checked (e.g. `identity:<uid>` / `workspace:self`).
210
+ final String? object;
211
+
212
+ /// The required relation (`signer` for the enrollment premise; the `.requires(...)` relation otherwise).
213
+ final String? relation;
214
+
215
+ /// The refused directive, when known.
216
+ final String? directiveId;
217
+
218
+ /// WHICH premise failed: `missing-signer-enrollment` | `missing-role` | `revoked-signer` | …
219
+ final String? cause;
220
+
221
+ /// The named remedy.
222
+ final String recommendedAction;
223
+
224
+ AuthzRefusedError({
225
+ required this.code,
226
+ required String message,
227
+ this.workspace,
228
+ this.actor,
229
+ this.keyHash,
230
+ this.object,
231
+ this.relation,
232
+ this.directiveId,
233
+ this.cause,
234
+ this.recommendedAction = NomosRecommendedAction.none,
235
+ NomosErrorInfo? info,
236
+ }) : super(message, info: info);
237
+
238
+ factory AuthzRefusedError.fromInfo(NomosErrorInfo info) {
239
+ String? attr(String name) {
240
+ final v = info.attributes['nomos.authz.$name'] ?? info.attributes[name];
241
+ return v?.toString();
242
+ }
243
+
244
+ final cause = attr('cause');
245
+ final fields = (
246
+ workspace: info.workspace,
247
+ actor: attr('actor'),
248
+ keyHash: attr('keyHash') ?? attr('key_hash'),
249
+ object: attr('object'),
250
+ relation: attr('relation'),
251
+ directiveId: attr('directiveId') ?? attr('directive_id') ?? attr('directive'),
252
+ );
253
+ if (info.code == 'nomos.authz.signer_enrollment_missing' || cause == 'missing-signer-enrollment') {
254
+ return ParentSignerEnrollmentMissing(
255
+ message: info.message,
256
+ workspace: fields.workspace,
257
+ actor: fields.actor,
258
+ keyHash: fields.keyHash,
259
+ object: fields.object,
260
+ directiveId: fields.directiveId,
261
+ info: info,
262
+ );
263
+ }
264
+ return AuthzRefusedError(
265
+ code: info.code,
266
+ message: info.message,
267
+ workspace: fields.workspace,
268
+ actor: fields.actor,
269
+ keyHash: fields.keyHash,
270
+ object: fields.object,
271
+ relation: fields.relation,
272
+ directiveId: fields.directiveId,
273
+ cause: cause,
274
+ recommendedAction: attr('recommendedAction') ?? attr('recommended_action') ?? NomosRecommendedAction.none,
275
+ info: info,
276
+ );
277
+ }
278
+
279
+ @override
280
+ String toString() =>
281
+ 'AuthzRefusedError($code${workspace != null ? ', workspace: $workspace' : ''}'
282
+ '${actor != null ? ', actor: $actor' : ''}${keyHash != null ? ', keyHash: $keyHash' : ''}'
283
+ '${object != null ? ', object: $object' : ''}${relation != null ? ', relation: $relation' : ''}'
284
+ '${cause != null ? ', cause: $cause' : ''}, recommendedAction: $recommendedAction): $message';
285
+ }
286
+
287
+ /// PARENT SIGNER ENROLLMENT MISSING (`nomos.authz.signer_enrollment_missing`) — the device key is valid
288
+ /// and enrolled in the home, but the judging PARENT's chain (e.g. after a custody REBIRTH) no longer
289
+ /// carries the `identity:<actor>#signer@key:<keyHash>` tuple, so a correctly-signed offer (a sibling
290
+ /// birth like `birthEstateWorkspace`) is refused. THE ONE SUPPORTED REMEDY:
291
+ /// `session.ensureParentSignerEnrollment(...)` — [NomosScope.home] runs it automatically on reopen.
292
+ class ParentSignerEnrollmentMissing extends AuthzRefusedError {
293
+ ParentSignerEnrollmentMissing({
294
+ required String message,
295
+ String? workspace,
296
+ String? actor,
297
+ String? keyHash,
298
+ String? object,
299
+ String? directiveId,
300
+ NomosErrorInfo? info,
301
+ }) : super(
302
+ code: 'nomos.authz.signer_enrollment_missing',
303
+ message: message,
304
+ workspace: workspace,
305
+ actor: actor,
306
+ keyHash: keyHash,
307
+ object: object,
308
+ relation: 'signer',
309
+ directiveId: directiveId,
310
+ cause: 'missing-signer-enrollment',
311
+ recommendedAction: NomosRecommendedAction.ensureParentSignerEnrollment,
312
+ info: info,
313
+ );
314
+ }
315
+
178
316
  /// AUTH — the auth-provider token was missing/malformed/expired/rejected, or its issuer does not match the
179
317
  /// workspace's law-declared key (`nomos.auth.*`, e.g. `nomos.auth.issuer_mismatch`, `nomos.auth.token_invalid`,
180
318
  /// `nomos.auth.token_expired`). REMEDY is always `reauth`: acquire a fresh token and reconnect.
@@ -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.3.0
14
+ version: 0.4.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.77.1",
3
+ "version": "0.78.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",
@@ -924,11 +924,11 @@ const DART_TYPES_IMPORT = `import 'package:nomos_types/nomos_types.dart';`;
924
924
  * just the floor generated code needs — a lagging pin forces tenants into dependency_overrides. */
925
925
  // 0.3.0: SurfacedValue<T>/SurfacedObservation/PendingConflictRow — generated Surfaced-field typing +
926
926
  // the pendingConflicts read rows.
927
- const NOMOS_TYPES_DEP = "^0.3.0";
927
+ const NOMOS_TYPES_DEP = "^0.4.0";
928
928
  // 0.48.x: session-bound clients (XClient.bind/NomosClientInterface/DomainClientSessionMismatch/
929
929
  // DomainNotInstalledForSession, engine.follow, pendingConflicts lanes) — the generated client
930
930
  // REQUIRES this surface. 0.48.1 fixes NomosSession.watchPendingConflicts delegation.
931
- const NOMOS_CLIENT_DEP = "^0.52.0";
931
+ const NOMOS_CLIENT_DEP = "^0.54.0";
932
932
  // DRIFT TRIPWIRE: pubspec_pins.test.ts asserts these equal the CURRENT versions in dsl/dart/pubspec.yaml
933
933
  // and dsl/dart_types/pubspec.yaml — a Dart release without this bump fails the suite, so tenants never
934
934
  // land back in dependency_overrides (the exact co2 report this closes, third occurrence).