@githolon/dsl 0.1.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.
Files changed (53) hide show
  1. package/LICENSE.md +36 -0
  2. package/compile_package.mjs +50 -0
  3. package/package.json +59 -0
  4. package/src/aggregate.ts +167 -0
  5. package/src/authoring.ts +119 -0
  6. package/src/build_package.ts +636 -0
  7. package/src/certified_read.ts +313 -0
  8. package/src/codegen_dart.ts +2732 -0
  9. package/src/codegen_dot.ts +466 -0
  10. package/src/codegen_provider_dart.ts +358 -0
  11. package/src/codegen_ts.ts +365 -0
  12. package/src/codegen_usda.ts +388 -0
  13. package/src/combined.ts +195 -0
  14. package/src/compile_engine.ts +567 -0
  15. package/src/compile_package_main.ts +496 -0
  16. package/src/compose.ts +317 -0
  17. package/src/count.ts +218 -0
  18. package/src/ctx.ts +57 -0
  19. package/src/derived.ts +138 -0
  20. package/src/directive.ts +306 -0
  21. package/src/drivers.ts +95 -0
  22. package/src/emits_guard.ts +123 -0
  23. package/src/engine_entry.ts +449 -0
  24. package/src/exists.ts +170 -0
  25. package/src/extremum.ts +227 -0
  26. package/src/fields.ts +291 -0
  27. package/src/framework/bootstrap.ts +22 -0
  28. package/src/framework/disclosure.ts +108 -0
  29. package/src/framework/domain_lifecycle.ts +108 -0
  30. package/src/framework/identity.ts +537 -0
  31. package/src/framework/impure_capability.ts +643 -0
  32. package/src/framework/rbac.ts +418 -0
  33. package/src/framework/repair.ts +150 -0
  34. package/src/framework/sync_lifecycle.ts +125 -0
  35. package/src/framework/workspace_invariant.ts +128 -0
  36. package/src/framework/workspaces.ts +817 -0
  37. package/src/index.ts +317 -0
  38. package/src/manifest.ts +947 -0
  39. package/src/ops.ts +145 -0
  40. package/src/ordered_read.ts +228 -0
  41. package/src/predicate.ts +203 -0
  42. package/src/query/compile.ts +0 -0
  43. package/src/query/relations.ts +144 -0
  44. package/src/query.ts +151 -0
  45. package/src/read.ts +54 -0
  46. package/src/relation.ts +189 -0
  47. package/src/report/csv.ts +54 -0
  48. package/src/report.ts +401 -0
  49. package/src/spatial.ts +115 -0
  50. package/src/sum.ts +194 -0
  51. package/src/usd.ts +563 -0
  52. package/src/wire.ts +149 -0
  53. package/src/wire_encode.ts +250 -0
package/LICENSE.md ADDED
@@ -0,0 +1,36 @@
1
+ # Nomos Pre-Release License (v1)
2
+
3
+ Copyright © 2026 Captain App Ltd. All rights reserved.
4
+
5
+ This is a pre-release of Nomos. This license gives you what you need to BUILD
6
+ with it; we keep the rest for now.
7
+
8
+ ## You may
9
+
10
+ - install and use these packages to author Nomos domains, compile them, and
11
+ deploy them to Nomos Cloud or any Nomos instance Captain App operates or
12
+ authorizes;
13
+ - build, run, and ship applications on top of them — including commercial ones;
14
+ - keep everything that's yours: code you write, and everything these tools
15
+ generate FOR you (scaffolds from `create-holon` / `holon generate`, generated
16
+ clients, compiled domain packages) carries NO restriction from us — it is
17
+ yours outright.
18
+
19
+ ## You may not
20
+
21
+ - redistribute these packages or their source, in whole or in part, outside
22
+ your team;
23
+ - modify them or build derivative tools, SDKs, or runtimes from their source;
24
+ - offer the Nomos runtime, or anything materially similar, as a hosted service;
25
+ - reverse-engineer the holon wasm runtime.
26
+
27
+ ## The rest
28
+
29
+ Provided **as is**, with no warranty of any kind; to the maximum extent
30
+ permitted by law, Captain App Ltd accepts no liability arising from your use.
31
+ This license terminates automatically if you breach it.
32
+
33
+ Want the kernel source, broader rights, or to do something this doesn't cover?
34
+ **Ask: jack@captainapp.co.uk.** The plan is to open Nomos up gradually, with
35
+ the people actually using it — telling us what you're building is how that
36
+ happens faster.
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ // NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
3
+ // remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
4
+ // wasm32-wasip1 artifact {kernel · projection · embedded
5
+ // QuickJS engine} on V8 + WASI-shim, byte-identical everywhere.
6
+ // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
7
+
8
+ // `nomos-compile` / `holon-compile` — the thin launcher: resolve `tsx` (the
9
+ // caller's, else the DSL's own dependency) and run `src/compile_package_main.ts`
10
+ // under it (the main dynamically imports the caller's TS domain modules, which
11
+ // needs the tsx ESM loader). tsx is a regular dependency of @githolon/dsl, so a bare
12
+ // `npx holon compile` / `npx nomos-compile` works with zero devDependency setup.
13
+ // Resolution goes through Node's resolver (createRequire), NOT node_modules/.bin
14
+ // paths — hoisted/workspace installs put the bin shims elsewhere.
15
+ import { execFileSync } from "node:child_process";
16
+ import { readFileSync } from "node:fs";
17
+ import { createRequire } from "node:module";
18
+ import path from "node:path";
19
+ import { fileURLToPath, pathToFileURL } from "node:url";
20
+
21
+ const DSL_DIR = path.dirname(fileURLToPath(import.meta.url));
22
+ const MAIN = path.join(DSL_DIR, "src", "compile_package_main.ts");
23
+
24
+ /** Resolve a dependency's package dir from `fromDir`, walking Node's resolver. */
25
+ function resolvePkgDir(name, fromDir) {
26
+ try {
27
+ const req = createRequire(pathToFileURL(path.join(fromDir, "noop.js")));
28
+ return path.dirname(req.resolve(`${name}/package.json`));
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ }
33
+
34
+ const tsxDir = resolvePkgDir("tsx", process.cwd()) ?? resolvePkgDir("tsx", DSL_DIR);
35
+ if (!tsxDir) {
36
+ console.error("nomos-compile: tsx not found — reinstall @githolon/dsl (tsx is one of its dependencies)");
37
+ process.exit(1);
38
+ }
39
+ const tsxPkg = JSON.parse(readFileSync(path.join(tsxDir, "package.json"), "utf8"));
40
+ const tsxBinRel = typeof tsxPkg.bin === "string" ? tsxPkg.bin : tsxPkg.bin?.tsx;
41
+ const tsxCli = path.join(tsxDir, tsxBinRel ?? "dist/cli.mjs");
42
+
43
+ try {
44
+ execFileSync(process.execPath, [tsxCli, MAIN, ...process.argv.slice(2)], {
45
+ stdio: "inherit",
46
+ cwd: process.cwd(),
47
+ });
48
+ } catch (e) {
49
+ process.exit(e.status ?? 1);
50
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@githolon/dsl",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
6
+ "license": "SEE LICENSE IN LICENSE.md",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Captain-App/nomos2.git",
10
+ "directory": "dsl"
11
+ },
12
+ "main": "src/index.ts",
13
+ "bin": {
14
+ "nomos-compile": "./compile_package.mjs",
15
+ "holon-compile": "./compile_package.mjs"
16
+ },
17
+ "exports": {
18
+ ".": "./src/index.ts",
19
+ "./manifest": "./src/manifest.ts",
20
+ "./compose": "./src/compose.ts",
21
+ "./usd": "./src/usd.ts",
22
+ "./engine-entry": "./src/engine_entry.ts",
23
+ "./build-package": "./src/build_package.ts",
24
+ "./compile-engine": "./src/compile_engine.ts",
25
+ "./codegen-dart": "./src/codegen_dart.ts",
26
+ "./codegen-dot": "./src/codegen_dot.ts",
27
+ "./codegen-provider": "./src/codegen_provider_dart.ts",
28
+ "./framework/identity": "./src/framework/identity.ts",
29
+ "./framework/disclosure": "./src/framework/disclosure.ts",
30
+ "./framework/bootstrap": "./src/framework/bootstrap.ts",
31
+ "./framework/domain_lifecycle": "./src/framework/domain_lifecycle.ts",
32
+ "./framework/sync_lifecycle": "./src/framework/sync_lifecycle.ts",
33
+ "./framework/repair": "./src/framework/repair.ts",
34
+ "./framework/workspaces": "./src/framework/workspaces.ts",
35
+ "./package.json": "./package.json"
36
+ },
37
+ "files": [
38
+ "src",
39
+ "!src/**/*.test.ts",
40
+ "compile_package.mjs"
41
+ ],
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "scripts": {
46
+ "typecheck": "tsc --noEmit",
47
+ "test": "vitest run"
48
+ },
49
+ "dependencies": {
50
+ "esbuild": "0.24.2",
51
+ "tsx": "^4.19.2",
52
+ "zod": "^4.4.3"
53
+ },
54
+ "devDependencies": {
55
+ "@types/node": "^25.9.2",
56
+ "typescript": "^5.6.3",
57
+ "vitest": "^2.1.8"
58
+ }
59
+ }
@@ -0,0 +1,167 @@
1
+ // NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
2
+ // remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
3
+ // wasm32-wasip1 artifact {kernel · projection · embedded
4
+ // QuickJS engine} on V8 + WASI-shim, byte-identical everywhere. V8 = portability; the one
5
+ // wasm = determinism. No native, no wasmtime, no 2nd artifact, no domain-JS on bare V8.
6
+ // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
7
+
8
+ /**
9
+ * `aggregate(id, fields)` -> a typed handle.
10
+ *
11
+ * The stable string id is declared ONCE, here, at the declaration site. That
12
+ * literal IS the wire id (it's string DATA, so it's minify-safe — never a
13
+ * renamed symbol). Everywhere else you reference the returned handle, never the
14
+ * string: a typo'd handle is a compile error.
15
+ */
16
+ import type { Field } from "./fields.js";
17
+
18
+ /**
19
+ * A pure, replay-stable predicate over one aggregate's PROJECTED snapshot —
20
+ * evaluated BY THE ENGINE after each event-apply. Returns a TYPED verdict.
21
+ * PURE over the snapshot (no clock, no IO, no live reads); runs in the sealed engine.
22
+ * PRESENCE ONLY is hashed in the manifest (like a directive `plan`); the body ships
23
+ * in the engine bundle, never in the ledger.
24
+ */
25
+ export type AggregateInvariantVerdict = { accept: true } | { reject: string };
26
+ export type AggregateInvariantFn = (snapshot: Record<string, unknown>) => AggregateInvariantVerdict;
27
+
28
+ /**
29
+ * An aggregate has two genuinely different kinds of member, and the DSL keeps them
30
+ * APART so no downstream layer ever has to re-derive the distinction:
31
+ *
32
+ * • STORED FIELDS (`fields`) — real data with a leaf kind (string/int/ref/enum/…).
33
+ * The engine projects, decodes, hashes and wires these. Every manifest / wire /
34
+ * codegen / projection consumer iterates `fields` and is GUARANTEED never to meet
35
+ * a virtual kind — so none of them special-case anything.
36
+ * • VIRTUAL INVERSES (`hasMany`) — `t.hasMany(Child).via("backref")` relations. NOT
37
+ * stored: they derive the inverse read index (`Child by backref`) and the `.add`
38
+ * back-ref write. Consumed ONLY by the two layers that own the 1:N read side
39
+ * (`hasManyIndexes`) and the `.add` authoring dispatch.
40
+ *
41
+ * The dev still declares both inline in one object (easy to model); `aggregate()`
42
+ * partitions them here, ONCE — the only place that knows `hasMany` is virtual.
43
+ */
44
+ type IsHasMany<Fld> = Fld extends Field<unknown, "hasMany"> ? true : false;
45
+ /** The STORED subset of a declared field record — everything except virtual `t.hasMany`. */
46
+ export type StoredFields<F extends Record<string, Field>> = {
47
+ [K in keyof F as IsHasMany<F[K]> extends true ? never : K]: F[K];
48
+ };
49
+ /** The VIRTUAL `t.hasMany` inverse subset of a declared field record. */
50
+ export type HasManyFields<F extends Record<string, Field>> = {
51
+ [K in keyof F as IsHasMany<F[K]> extends true ? K : never]: F[K];
52
+ };
53
+
54
+ /** Runtime partition of a declared field record into stored fields + virtual `hasMany` inverses. */
55
+ function partitionFields(fields: Record<string, Field>): {
56
+ stored: Record<string, Field>;
57
+ hasMany: Record<string, Field>;
58
+ } {
59
+ const stored: Record<string, Field> = {};
60
+ const hasMany: Record<string, Field> = {};
61
+ for (const [name, field] of Object.entries(fields)) {
62
+ if (field.kind === "hasMany") hasMany[name] = field;
63
+ else stored[name] = field;
64
+ }
65
+ return { stored, hasMany };
66
+ }
67
+
68
+ export interface AggregateHandle<
69
+ Id extends string = string,
70
+ F extends Record<string, Field> = Record<string, Field>,
71
+ > {
72
+ /** The wire id — the literal you passed in. */
73
+ readonly id: Id;
74
+ /** STORED fields only — real data; `keyof` is what op helpers check field names against. */
75
+ readonly fields: StoredFields<F>;
76
+ /** VIRTUAL `t.hasMany` inverses — empty object when the aggregate declares none. */
77
+ readonly hasMany: HasManyFields<F>;
78
+ /** Brand so a plain string is never accepted where a handle is required. */
79
+ readonly __isAggregateHandle: true;
80
+ /** Presence-only flag (hashed); OMITTED ENTIRELY when no invariant is declared. */
81
+ readonly hasInvariant?: true;
82
+ /** The executable body — ships in the engine bundle, NOT the manifest. */
83
+ readonly invariant?: AggregateInvariantFn;
84
+ /**
85
+ * The LAW-DECLARED instance CAP (Jack's ruling: instance limits are DOMAIN POLICY,
86
+ * never a fabric id-shape special case). `cap: 1` = at most one instance per
87
+ * workspace — the admission gate refuses an over-cap Create with a clear message.
88
+ * Carried in the canonical manifest (hash-bearing); OMITTED when uncapped so
89
+ * cap-free domains hash byte-identically to before this key existed.
90
+ */
91
+ readonly cap?: number;
92
+ }
93
+
94
+ export function aggregate<const Id extends string, F extends Record<string, Field>>(
95
+ id: Id,
96
+ fields: F,
97
+ opts?: { invariant?: AggregateInvariantFn; cap?: number },
98
+ ): AggregateHandle<Id, F> {
99
+ const { stored, hasMany } = partitionFields(fields);
100
+ if (opts?.cap !== undefined && (!Number.isInteger(opts.cap) || opts.cap < 1)) {
101
+ throw new Error(`aggregate '${id}': cap must be a positive integer (got ${opts.cap})`);
102
+ }
103
+ return {
104
+ id,
105
+ fields: stored as StoredFields<F>,
106
+ hasMany: hasMany as HasManyFields<F>,
107
+ __isAggregateHandle: true,
108
+ ...(opts?.invariant !== undefined
109
+ ? { hasInvariant: true as const, invariant: opts.invariant }
110
+ : {}),
111
+ ...(opts?.cap !== undefined ? { cap: opts.cap } : {}),
112
+ };
113
+ }
114
+
115
+ /**
116
+ * A bound aggregate reference — a handle pinned to a concrete INSTANCE id.
117
+ *
118
+ * #105 (instance binding): an `AggregateHandle.id` is the aggregate TYPE literal
119
+ * (e.g. `Thing`), correct only for one-per-workspace aggregates. Every
120
+ * multi-instance aggregate (Thing, Node, Container, …) needs a per-instance
121
+ * address. `instance(Thing, "thing-a")` returns a `BoundAggregate` carrying BOTH
122
+ * the runtime instance `id` AND the static `type` (+ the handle's `fields`, so op
123
+ * helpers keep their `keyof`-checked field/value typing). Op helpers accept either
124
+ * a bare handle (unbound → `aggregateId = type`, the legacy one-per-workspace
125
+ * shape) or a bound ref (→ `aggregateId = instanceId`, `aggregateType = type`).
126
+ */
127
+ export interface BoundAggregate<
128
+ Id extends string = string,
129
+ F extends Record<string, Field> = Record<string, Field>,
130
+ > {
131
+ /** The concrete instance id — what reaches `WireEvent.aggregate`. */
132
+ readonly id: string;
133
+ /** The aggregate TYPE (the handle's static id) — surfaced to `view()` via `__type`. */
134
+ readonly type: Id;
135
+ /** The STORED field map, carried through so op helpers keep field-name/value typing.
136
+ * (Op-helper typing infers off the `F` param, so virtual `hasMany` is never settable.) */
137
+ readonly fields: StoredFields<F>;
138
+ /** Brand: a bound ref is distinct from a bare handle. */
139
+ readonly __isBoundAggregate: true;
140
+ }
141
+
142
+ /**
143
+ * Bind an aggregate handle to a concrete instance id. ADDITIVE: the handle itself
144
+ * is unchanged; `instance(h, id)` is the opt-in per-instance address. Op helpers
145
+ * (`set`/`addToSet`/`setEntry`) accept the returned ref and lower it to a
146
+ * `PlannedOp` carrying the instance id AND the aggregate type.
147
+ */
148
+ export function instance<Id extends string, F extends Record<string, Field>>(
149
+ handle: AggregateHandle<Id, F>,
150
+ id: string,
151
+ ): BoundAggregate<Id, F> {
152
+ return { id, type: handle.id, fields: handle.fields, __isBoundAggregate: true };
153
+ }
154
+
155
+ /**
156
+ * A name-only handle for a FORWARD / CIRCULAR `t.ref` target — an aggregate referenced
157
+ * before its `const` is declared (e.g. `Estate.headquartersSiteId -> Site` while
158
+ * `Site.estateId -> Estate`). `t.ref` reads only the target's wire `.id`, so a
159
+ * fields-less, relation-less stand-in is a faithful, minify-safe forward declaration.
160
+ *
161
+ * Use this instead of hand-building `{ id, fields: {}, hasMany: {}, __isAggregateHandle }`
162
+ * so the handle shape stays in exactly ONE place — add a member to `AggregateHandle`
163
+ * and every forward ref keeps compiling for free.
164
+ */
165
+ export function forwardRef<const Id extends string>(id: Id): AggregateHandle<Id, Record<string, never>> {
166
+ return { id, fields: {}, hasMany: {}, __isAggregateHandle: true };
167
+ }
@@ -0,0 +1,119 @@
1
+ // NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
2
+ // remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
3
+ // wasm32-wasip1 artifact {kernel · projection · embedded
4
+ // QuickJS engine}. If a file isn't this / hosting this / authoring for this / proving this — it's gone.
5
+
6
+ /**
7
+ * THE AGGREGATE AUTHORING SURFACE — Nomos owns every birth; the dev talks DDD (Jack 2026-06-08).
8
+ *
9
+ * This is LIBRARY code (`@githolon/dsl`). It uses exactly ONE host capability — `nomos.mint(typeTag)` (a
10
+ * captured-entropy id) — and does everything else itself: it turns `create()` / `.set` / `.add` /
11
+ * `.relate` into ordinary `PlannedOp`s (the same shape the typed `set`/`addToSet`/`setEntry` produce),
12
+ * which `executeDirectiveToIntent` groups into kernel events. The engine never records wire events directly.
13
+ *
14
+ * const child = create(Child).set("name", p.name);
15
+ * parent.add("children", child); // ← writes child.parent = parent.id (ONE event, on the child)
16
+ *
17
+ * `.add` DISPATCHES on the declared field kind: a `t.hasMany(Child).via("parent")` field writes the
18
+ * child's back-reference (the cheap N:1 — parent untouched, no growing set); a bounded `t.set(...)` field
19
+ * does an `AddToSet` on the aggregate itself. A dev therefore cannot build a write-amplifying parent
20
+ * collection — see `docs/aggregate_lifecycle_and_relations.md`.
21
+ *
22
+ * Ops are collected in a per-dispatch SINK (library-side state, invisible to the engine); `executeDirectiveToIntent`
23
+ * resets it before the plan and drains it after, merging the authored ops with whatever the plan returns.
24
+ * So a `.plan` that fluently authors and `return []`s still emits its events — the recorded ops are real.
25
+ */
26
+ import type { AggregateHandle } from "./aggregate.js";
27
+ import type { Field } from "./fields.js";
28
+ import type { FieldOp, PlannedOp } from "./ops.js";
29
+
30
+ // ── the per-dispatch authoring sink — library state the engine never sees (NOT a side channel out of
31
+ // the sandbox; it is internal to the directive runtime, drained synchronously by `executeDirectiveToIntent`). ──
32
+ let sink: PlannedOp[] = [];
33
+ /** Clear the sink before a plan runs (called by `executeDirectiveToIntent`). */
34
+ export function __resetAuthoring(): void {
35
+ sink = [];
36
+ }
37
+ /** Take + clear the ops authored via the fluent surface during a plan (called by `executeDirectiveToIntent`). */
38
+ export function __drainAuthoring(): PlannedOp[] {
39
+ const s = sink;
40
+ sink = [];
41
+ return s;
42
+ }
43
+
44
+ interface NomosMint {
45
+ mint(typeTag: string): string;
46
+ }
47
+ /** The ONE host capability this surface uses — a captured, typed, guaranteed-unique id. */
48
+ function mint(typeTag: string): string {
49
+ return (globalThis as unknown as { nomos: NomosMint }).nomos.mint(typeTag);
50
+ }
51
+
52
+ /** A reference to an aggregate the dev HOLDS — minted-new (`create`) or bound-to-existing (`existing`). */
53
+ export interface AggregateRef {
54
+ readonly __type: string;
55
+ readonly __id: string;
56
+ /** Set a scalar field (Lww). */
57
+ set(field: string, value: unknown): this;
58
+ /** Set one entry of a map field (per-key Lww). */
59
+ setEntry(field: string, key: string, value: unknown): this;
60
+ /** Add to a collection: a `t.hasMany` relation → writes the child's back-ref; a `t.set` → AddToSet on self. */
61
+ add(field: string, value: unknown): this;
62
+ /** Relate to another aggregate by reference (writes the ref field on THIS aggregate). */
63
+ relate(rel: string, target: AggregateRef): this;
64
+ }
65
+
66
+ class Ref implements AggregateRef {
67
+ constructor(
68
+ private readonly handle: AggregateHandle,
69
+ readonly __id: string,
70
+ private readonly birth: boolean,
71
+ ) {}
72
+ get __type(): string {
73
+ return this.handle.id;
74
+ }
75
+ /** Emit a field op TARGETING THIS aggregate, marked `creates` iff this ref is a birth. */
76
+ private emit(field: string, kind: FieldOp["kind"], extra: Partial<FieldOp>): void {
77
+ const op: FieldOp = { aggregateId: this.__id, aggregateType: this.__type, field, kind, ...extra };
78
+ sink.push(this.birth ? { ...op, marker: "creates" } : op);
79
+ }
80
+ set(field: string, value: unknown): this {
81
+ this.emit(field, "set", { value });
82
+ return this;
83
+ }
84
+ setEntry(field: string, key: string, value: unknown): this {
85
+ this.emit(field, "setEntry", { entryKey: key, value });
86
+ return this;
87
+ }
88
+ relate(rel: string, target: AggregateRef): this {
89
+ this.emit(rel, "set", { value: target.__id });
90
+ return this;
91
+ }
92
+ add(field: string, value: unknown): this {
93
+ // `.add` resolves the named member across the aggregate's two member kinds: a virtual
94
+ // `t.hasMany` relation (write the child's back-ref) vs a stored `t.set` field (AddToSet).
95
+ // The two collections are kept apart by `aggregate()`, so this is the ONE place that bridges them.
96
+ const rel = (this.handle.hasMany as Record<string, Field>)[field];
97
+ if (rel !== undefined) {
98
+ // RELATION: write the back-reference ON THE CHILD (the cheap N:1). The op lands in the CHILD's
99
+ // bucket with the CHILD's birth-marker — this aggregate (the parent) is never touched.
100
+ const child = value as Ref;
101
+ child.emit(rel.viaField!, "set", { value: this.__id });
102
+ } else {
103
+ // BOUNDED INTRINSIC SET: AddToSet on this aggregate itself.
104
+ const v = value instanceof Ref ? value.__id : String(value);
105
+ this.emit(field, "addToSet", { items: [v] });
106
+ }
107
+ return this;
108
+ }
109
+ }
110
+
111
+ /** Ask Nomos to CREATE a new aggregate of the handle's type → a fluent reference (id minted, no choice). */
112
+ export function create(agg: AggregateHandle): AggregateRef {
113
+ return new Ref(agg, mint(agg.id), true);
114
+ }
115
+
116
+ /** A reference to an EXISTING aggregate the dev already holds an id for (e.g. from the payload / a read). */
117
+ export function existing(agg: AggregateHandle, id: string): AggregateRef {
118
+ return new Ref(agg, id, false);
119
+ }