@githolon/dsl 0.70.0 → 0.70.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.70.0",
3
+ "version": "0.70.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",
@@ -4275,61 +4275,10 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
4275
4275
  ]
4276
4276
  : [];
4277
4277
 
4278
- // ROLE SUGAR — per-(role × aggregate) `canEdit<Agg>`/`canView<Agg>` UI-HINT helpers, pure functions
4279
- // over the folded RelationTuple projection (the client-side MIRROR of engine.check). INFORMATIONAL
4280
- // only — THE KERNEL GATE REMAINS THE AUTHORITY. Emitted only when the domain declares roles (era-safe).
4281
- const roleBlock = (() => {
4282
- const editRels = new Map<string, Set<string>>();
4283
- const viewRels = new Map<string, Set<string>>();
4284
- for (const r of mod.roles ?? []) {
4285
- if (r.editRelation) for (const agg of r.editAggregates) (editRels.get(agg) ?? editRels.set(agg, new Set()).get(agg)!).add(r.editRelation);
4286
- if (r.viewRelation) for (const agg of r.viewAggregates) {
4287
- const s = viewRels.get(agg) ?? viewRels.set(agg, new Set()).get(agg)!;
4288
- s.add(r.viewRelation);
4289
- if (r.editRelation && r.editAggregates.includes(agg)) s.add(r.editRelation);
4290
- }
4291
- }
4292
- if (editRels.size === 0 && viewRels.size === 0) return "";
4293
- const lines: string[] = [
4294
- `/// True iff [principal] holds ANY of [relations] as an ACTIVE tuple in [rows] (or a \`user:*\` wildcard).`,
4295
- `bool _holdsAnyRelation(List<Map<String, dynamic>>? rows, Set<String> relations, String principal) {`,
4296
- ` for (final r in rows ?? const []) {`,
4297
- ` final data = r['data'];`,
4298
- ` if (data is! Map) continue;`,
4299
- ` final rel = data['relation'];`,
4300
- ` if (rel is! String || !relations.contains(rel)) continue;`,
4301
- ` final subjects = data['subjects'];`,
4302
- ` if (subjects is! Map) continue;`,
4303
- ` for (final entry in subjects.entries) {`,
4304
- ` final subj = entry.key.toString();`,
4305
- ` dynamic leaf = entry.value;`,
4306
- ` if (leaf is String) { try { leaf = jsonDecode(leaf); } catch (_) { leaf = const {}; } }`,
4307
- ` final status = leaf is Map ? leaf['status'] : null;`,
4308
- ` if (status != null && status != 'active') continue;`,
4309
- ` if (subj == 'user:\$principal' || subj == 'user:*') return true;`,
4310
- ` }`,
4311
- ` }`,
4312
- ` return false;`,
4313
- `}`,
4314
- ];
4315
- const dartSet = (s: Set<string>): string => `{${[...s].sort().map((x) => `'${x}'`).join(", ")}}`;
4316
- for (const [agg, rels] of [...editRels].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
4317
- lines.push(
4318
- `/// UI HINT — may [principal] EDIT a ${agg}? Mirrors the kernel's ReBAC check; INFORMATIONAL only`,
4319
- `/// (the one wasm gate is the authority and refuses regardless).`,
4320
- `bool canEdit${cap(agg)}(List<Map<String, dynamic>>? relations, String principal) =>`,
4321
- ` _holdsAnyRelation(relations, ${dartSet(rels)}, principal);`,
4322
- );
4323
- }
4324
- for (const [agg, rels] of [...viewRels].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
4325
- lines.push(
4326
- `/// UI HINT — may [principal] VIEW a ${agg}? Mirrors the kernel's ReBAC read gate; INFORMATIONAL only.`,
4327
- `bool canView${cap(agg)}(List<Map<String, dynamic>>? relations, String principal) =>`,
4328
- ` _holdsAnyRelation(relations, ${dartSet(rels)}, principal);`,
4329
- );
4330
- }
4331
- return lines.join("\n");
4332
- })();
4278
+ // ROLE SUGAR — the per-(role × aggregate) `canEdit<Agg>`/`canView<Agg>` UI-HINT helpers are NOT emitted
4279
+ // into this TYPES file: they walk the folded RelationTuple projection and need `dart:convert` (jsonDecode),
4280
+ // which the layered types-package PURITY GATE forbids. They live in the CLIENT package (`<name>.client.dart`,
4281
+ // which may import runtime) via {@link dartRoleHelpers}. Flat mode emits them there too.
4333
4282
 
4334
4283
  return [
4335
4284
  `// GENERATED by nomos2/dsl/src/codegen_dart.ts — DO NOT EDIT BY HAND.`,
@@ -4342,7 +4291,7 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
4342
4291
  `// ignore_for_file: unused_import`,
4343
4292
  ``,
4344
4293
  // A nullable plain-`t.json()` read-model slot decode re-stringifies via `jsonEncode`.
4345
- ...(needsJsonConvert || roleBlock !== "" ? [`import 'dart:convert';`] : []),
4294
+ ...(needsJsonConvert ? [`import 'dart:convert';`] : []),
4346
4295
  // The generated tenant domains live in the `co2_nomos_domains` package and depend
4347
4296
  // on the framework `nomos_client` package by its PUBLIC package: URI — NOT a relative
4348
4297
  // `../wire.dart` path into the framework (which broke when the generated dir moved
@@ -4416,12 +4365,65 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
4416
4365
  ]
4417
4366
  : []),
4418
4367
  ...helperBlock,
4419
- ...(roleBlock !== ""
4420
- ? [
4421
- ``,
4422
- `// ---- role policy (UI hints — the KERNEL GATE is the authority) ----`,
4423
- roleBlock,
4424
- ]
4425
- : []),
4368
+ // NOTE: role UI-hint helpers (canEdit/canView) are emitted into the CLIENT package by dartRoleHelpers,
4369
+ // NOT here — they need dart:convert, which the types-package purity gate forbids.
4426
4370
  ].join("\n");
4427
4371
  }
4372
+
4373
+ /** The role UI-hint helpers (`canEdit<Agg>`/`canView<Agg>` + `_holdsAnyRelation`) for the CLIENT package — a
4374
+ * pure client-side MIRROR of the kernel ReBAC check over the folded RelationTuple projection. INFORMATIONAL
4375
+ * only (the one wasm gate is the authority and refuses regardless). Uses `dart:convert` (jsonDecode of the
4376
+ * per-subject leaf), so it may NOT live in the pure types file — the caller emits it into `<name>.client.dart`
4377
+ * and ensures a `dart:convert` import. Returns "" when the domain declares no roles (era-safe, no import). */
4378
+ export function dartRoleHelpers(mod: DomainModule): string {
4379
+ const editRels = new Map<string, Set<string>>();
4380
+ const viewRels = new Map<string, Set<string>>();
4381
+ for (const r of mod.roles ?? []) {
4382
+ if (r.editRelation) for (const agg of r.editAggregates) (editRels.get(agg) ?? editRels.set(agg, new Set()).get(agg)!).add(r.editRelation);
4383
+ if (r.viewRelation) for (const agg of r.viewAggregates) {
4384
+ const s = viewRels.get(agg) ?? viewRels.set(agg, new Set()).get(agg)!;
4385
+ s.add(r.viewRelation);
4386
+ if (r.editRelation && r.editAggregates.includes(agg)) s.add(r.editRelation);
4387
+ }
4388
+ }
4389
+ if (editRels.size === 0 && viewRels.size === 0) return "";
4390
+ const lines: string[] = [
4391
+ `/// True iff [principal] holds ANY of [relations] as an ACTIVE tuple in [rows] (or a \`user:*\` wildcard).`,
4392
+ `bool _holdsAnyRelation(List<Map<String, dynamic>>? rows, Set<String> relations, String principal) {`,
4393
+ ` for (final r in rows ?? const []) {`,
4394
+ ` final data = r['data'];`,
4395
+ ` if (data is! Map) continue;`,
4396
+ ` final rel = data['relation'];`,
4397
+ ` if (rel is! String || !relations.contains(rel)) continue;`,
4398
+ ` final subjects = data['subjects'];`,
4399
+ ` if (subjects is! Map) continue;`,
4400
+ ` for (final entry in subjects.entries) {`,
4401
+ ` final subj = entry.key.toString();`,
4402
+ ` dynamic leaf = entry.value;`,
4403
+ ` if (leaf is String) { try { leaf = jsonDecode(leaf); } catch (_) { leaf = const {}; } }`,
4404
+ ` final status = leaf is Map ? leaf['status'] : null;`,
4405
+ ` if (status != null && status != 'active') continue;`,
4406
+ ` if (subj == 'user:\$principal' || subj == 'user:*') return true;`,
4407
+ ` }`,
4408
+ ` }`,
4409
+ ` return false;`,
4410
+ `}`,
4411
+ ];
4412
+ const dartSet = (s: Set<string>): string => `{${[...s].sort().map((x) => `'${x}'`).join(", ")}}`;
4413
+ for (const [agg, rels] of [...editRels].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
4414
+ lines.push(
4415
+ `/// UI HINT — may [principal] EDIT a ${agg}? Mirrors the kernel's ReBAC check; INFORMATIONAL only`,
4416
+ `/// (the one wasm gate is the authority and refuses regardless).`,
4417
+ `bool canEdit${cap(agg)}(List<Map<String, dynamic>>? relations, String principal) =>`,
4418
+ ` _holdsAnyRelation(relations, ${dartSet(rels)}, principal);`,
4419
+ );
4420
+ }
4421
+ for (const [agg, rels] of [...viewRels].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
4422
+ lines.push(
4423
+ `/// UI HINT — may [principal] VIEW a ${agg}? Mirrors the kernel's ReBAC read gate; INFORMATIONAL only.`,
4424
+ `bool canView${cap(agg)}(List<Map<String, dynamic>>? relations, String principal) =>`,
4425
+ ` _holdsAnyRelation(relations, ${dartSet(rels)}, principal);`,
4426
+ );
4427
+ }
4428
+ return lines.join("\n");
4429
+ }
@@ -33,7 +33,7 @@ import type { Field } from "./fields.js";
33
33
  import type { Directive } from "./directive.js";
34
34
  import type { QueryDecl } from "./query.js";
35
35
  import type { DomainModule } from "./codegen_dart.js";
36
- import { cap, readModelClassName } from "./codegen_dart.js";
36
+ import { cap, readModelClassName, dartRoleHelpers } from "./codegen_dart.js";
37
37
  import { finishCount } from "./count.js";
38
38
  import { finishSum } from "./sum.js";
39
39
  import { autoStampFields, domainEncryptionMap, type EncDomainMap, type InterfaceAggShape } from "./codegen_ts.js";
@@ -149,6 +149,11 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
149
149
  const aggById = new Map<string, AggregateHandle>();
150
150
  for (const a of mod.aggregates) aggById.set(a.id, a);
151
151
 
152
+ // ROLE SUGAR — the `canEdit<Agg>`/`canView<Agg>` UI-hint helpers live HERE (the client package, which may
153
+ // import runtime), NOT in the pure types file: they `jsonDecode` the folded RelationTuple leaf, and the
154
+ // layered types-package purity gate forbids `dart:convert`. Empty ⇒ no roles (era-safe; no extra import).
155
+ const roleHelpers = dartRoleHelpers(mod);
156
+
152
157
  // THE E2E-SEALING MAP (the sealing client) — non-null iff a field is `.encrypted()`. When null NOTHING
153
158
  // below emits (the generated Dart client is byte-identical to a pre-sealing compile). Same map + sids as
154
159
  // the TS client (one source of truth: `domainEncryptionMap`); requires the baked interface for the sids.
@@ -407,7 +412,7 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
407
412
  ``,
408
413
  // `dart:convert` is needed ONLY for the interface block's `jsonDecode` — the offer lane no longer encodes
409
414
  // any envelope (it routes through `bridge.offerDirective` → THE ONE ENCODER). Omit it when no interface.
410
- ...(opts.interface !== undefined ? [`import 'dart:convert';`, ``] : []),
415
+ ...(opts.interface !== undefined || roleHelpers !== "" ? [`import 'dart:convert';`, ``] : []),
411
416
  `import '${supportImport}';`,
412
417
  // Layered-packages mode: the sibling types live in their OWN package — one barrel import
413
418
  // replaces both the relative types import and the relative framework import.
@@ -531,6 +536,13 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
531
536
  ...methods,
532
537
  `}`,
533
538
  ``,
539
+ ...(roleHelpers !== ""
540
+ ? [
541
+ `// ---- role policy (UI hints — the KERNEL GATE is the authority) ----`,
542
+ roleHelpers,
543
+ ``,
544
+ ]
545
+ : []),
534
546
  ].join("\n");
535
547
  }
536
548
 
@@ -508,19 +508,25 @@ function relationSeedLegLines(
508
508
  ];
509
509
  }
510
510
  const ACTOR = `"user:proof-conformance"`;
511
+ const BARE_ACTOR = `"proof-conformance"`; // the uid without the `user:` prefix — for WRAPPED subject fields
511
512
  const speculative = leg.seedSpeculative === true;
512
513
  // A payload literal with subject/founder-conventional fields (+ the routed set) overwritten to the actor —
513
- // so a seat lands ON the actor AND every write-once/author-binding/founder-binding invariant holds.
514
- const actorSeatLit = (payload: Record<string, unknown>, routed: readonly string[]): string => {
514
+ // so a seat lands ON the actor AND every write-once/author-binding/founder-binding invariant holds. A WRAPPED
515
+ // subject field (the plan lifts a bare uid into `user:${field}`) gets the BARE uid — else the re-wrap yields
516
+ // a double-prefixed `user:user:<uid>` the gate never stamped.
517
+ const actorSeatLit = (payload: Record<string, unknown>, routed: readonly string[], wrapped: readonly string[] = []): string => {
515
518
  const overwrite = new Set<string>(routed);
519
+ const wrappedSet = new Set<string>(wrapped);
516
520
  const entries = Object.entries(payload).map(([k, v]) =>
517
- overwrite.has(k) || /^(subject|principal|grantedBy|grantee|member|user|owner|founder)$/i.test(k)
518
- ? `${JSON.stringify(k)}: ${ACTOR}`
519
- : `${JSON.stringify(k)}: ${JSON.stringify(v)}`,
521
+ wrappedSet.has(k)
522
+ ? `${JSON.stringify(k)}: ${BARE_ACTOR}`
523
+ : overwrite.has(k) || /^(subject|principal|grantedBy|grantee|member|user|owner|founder)$/i.test(k)
524
+ ? `${JSON.stringify(k)}: ${ACTOR}`
525
+ : `${JSON.stringify(k)}: ${JSON.stringify(v)}`,
520
526
  );
521
527
  return `{ ${entries.join(", ")} }`;
522
528
  };
523
- const grantPayloadLit = actorSeatLit(leg.grantPayload, [...leg.grantSubjectFields, ...leg.grantGrantedByFields]);
529
+ const grantPayloadLit = actorSeatLit(leg.grantPayload, [...leg.grantSubjectFields, ...leg.grantGrantedByFields], leg.grantSubjectWrappedFields ?? []);
524
530
  // The PREREQUISITE-SEED CHAIN dispatches (each as the actor = founder) BEFORE the grant.
525
531
  const prereqLines = (leg.prerequisiteSeeds ?? []).flatMap((seed) => [
526
532
  ` // prerequisite: ${leg.domain}/${seed.directiveId} establishes the state the grant's invariant reads (as founder = ACTOR).`,
@@ -665,6 +671,10 @@ export interface RelationBootstrapLeg {
665
671
  readonly grantSubjectFields: readonly string[];
666
672
  /** Grant-payload field(s) routed into the tuple `grantedBy` — set to the proof actor (free attribution). */
667
673
  readonly grantGrantedByFields: readonly string[];
674
+ /** Grant-payload field(s) the plan WRAPPED into a `<prefix>:<uid>` subject (`user:${principal}`) — the
675
+ * runner seats the BARE actor uid here (else the re-wrap double-prefixes to `user:user:<uid>` and the seat
676
+ * lands on nobody the gate stamped). The role-sugar + framework grant/seed shape. */
677
+ readonly grantSubjectWrappedFields?: readonly string[];
668
678
  /** How the granter was resolved — `"marker"` (`.grants(R)` on R directly), `"inferred"` (a plan-probed
669
679
  * `writeTuple(R)`), `"computed-base"` (R is computed; the grant seats a rewrite BASE relation the granter
670
680
  * was found for), or `"seed-path"` (LAYER 3: R is reachable only through ttu/intersection/exclusion — no
@@ -887,7 +897,7 @@ function probeRelationsWritten(dir: Directive<unknown>, where: string): string[]
887
897
  * values. Best-effort: an unmatched routing just leaves the field list empty (runtime falls back). */
888
898
  function resolveGrantSubjectFields(
889
899
  probed: { ops: FieldOp[]; synth: SynthesizedPayload },
890
- ): { subjectFields: string[]; grantedByFields: string[] } {
900
+ ): { subjectFields: string[]; grantedByFields: string[]; wrappedSubjectFields: string[] } {
891
901
  const { ops, synth } = probed;
892
902
  const fieldForValue = (v: unknown): string | undefined =>
893
903
  typeof v === "string" && v.length > 0
@@ -895,11 +905,23 @@ function resolveGrantSubjectFields(
895
905
  : undefined;
896
906
  const subjectFields = new Set<string>();
897
907
  const grantedByFields = new Set<string>();
908
+ // WRAPPED subject fields: the plan lifted a BARE payload uid into a `<prefix>:<uid>` subject wire form
909
+ // (`writeTuple({ subject: `user:${p.principal}` })` — the role-sugar + framework grant shape). The runner
910
+ // must seat the BARE actor uid into these (not the full `user:` subject), else the plan re-wraps it into a
911
+ // double-prefixed `user:user:<uid>` that never matches the actor the gate stamped. Distinct from a DIRECT
912
+ // subject field (entryKey === the payload value verbatim — a full `user:<uid>` payload), which stays full.
913
+ const wrappedSubjectFields = new Set<string>();
898
914
  for (const o of ops) {
899
915
  // The subject rides the tuple map entry KEY (`setEntry(rt, "subjects", <subjectRaw>, leaf)`).
900
916
  if (o.kind === "setEntry" && o.field === "subjects" && typeof o.entryKey === "string") {
901
917
  const f = fieldForValue(o.entryKey);
902
918
  if (f !== undefined) subjectFields.add(f);
919
+ else {
920
+ // entryKey `<prefix>:<uid>` (e.g. `user:sample-principal`) where <uid> is a payload value ⇒ wrapped.
921
+ const m = /^[a-z]+:(.+)$/.exec(o.entryKey);
922
+ const inner = m !== null ? fieldForValue(m[1]) : undefined;
923
+ if (inner !== undefined) wrappedSubjectFields.add(inner);
924
+ }
903
925
  // grantedBy rides the JSON leaf value — scan the serialized leaf for any synthesized payload value.
904
926
  if (typeof o.value === "string") {
905
927
  for (const [name, sv] of Object.entries(synth.value)) {
@@ -910,7 +932,7 @@ function resolveGrantSubjectFields(
910
932
  }
911
933
  }
912
934
  }
913
- return { subjectFields: [...subjectFields], grantedByFields: [...grantedByFields] };
935
+ return { subjectFields: [...subjectFields], grantedByFields: [...grantedByFields], wrappedSubjectFields: [...wrappedSubjectFields] };
914
936
  }
915
937
 
916
938
  // ── rewrite-aware base-relation resolution (the COMPUTED-authority lane) ──────────────────────────────
@@ -1382,7 +1404,7 @@ export function discoverRelationBootstrapLeg(mod: DomainModule, gatedOverride?:
1382
1404
  }
1383
1405
  const grantPayload: Record<string, unknown> = { ...grantSynth.value };
1384
1406
  const probed = probePlanOps(granter, `${domain}/${granter.id}`);
1385
- const routed = probed !== undefined ? resolveGrantSubjectFields(probed) : { subjectFields: [], grantedByFields: [] };
1407
+ const routed = probed !== undefined ? resolveGrantSubjectFields(probed) : { subjectFields: [], grantedByFields: [], wrappedSubjectFields: [] };
1386
1408
 
1387
1409
  return {
1388
1410
  domain,
@@ -1397,6 +1419,7 @@ export function discoverRelationBootstrapLeg(mod: DomainModule, gatedOverride?:
1397
1419
  grantAutoStamped: grantSynth.autoStamped,
1398
1420
  grantSubjectFields: routed.subjectFields,
1399
1421
  grantGrantedByFields: routed.grantedByFields,
1422
+ ...(routed.wrappedSubjectFields.length > 0 ? { grantSubjectWrappedFields: routed.wrappedSubjectFields } : {}),
1400
1423
  via,
1401
1424
  ...(seedSpeculative ? { seedSpeculative: true } : {}),
1402
1425
  ...(seedWritesRelations !== undefined ? { seedWritesRelations } : {}),