create-githolon 0.4.0 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-githolon",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Scaffold a Nomos domain package: the starter domain + compile config + live e2e. `npm create githolon my-app`.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -4,7 +4,7 @@ You write exactly TWO things: aggregates and directives. Never apply/fold/
4
4
  merge code — the kernel owns folding; your directive PLANS ops and the sealed
5
5
  engine replays them deterministically on every peer. Declared reads (query,
6
6
  count, derived, sum) are auto-discovered from your module's exports by shape.
7
- `domains/guestbook.ts` demonstrates every pattern below; reshape it.
7
+ `domains/guestbook.ts` demonstrates the core patterns below; reshape it.
8
8
 
9
9
  ## Aggregates: typed fields, each tagged a merge driver
10
10
 
@@ -48,7 +48,90 @@ Plan ops: `set` (scalars/refs) · `addToSet` (the ONLY additive write to an
48
48
  AddWins set — `set()` on a set field would overwrite the union and is REFUSED
49
49
  at the type level and at runtime) · `setEntry` (one map key) · `strike`
50
50
  (retract). `.creates(Agg)` mints the id — payloads never carry one;
51
- `.mutates(Agg)` takes the instance id in the payload.
51
+ `.mutates(Agg)` takes the instance id in the payload; `.ensures(Agg)` upserts
52
+ at a DETERMINISTIC id (next section).
53
+
54
+ ## Upserts: `.ensures` — create-or-amend at a deterministic id
55
+
56
+ When the CALLER owns the identity (a reading keyed `probe:day`, a config row
57
+ keyed by name), mark the directive `.ensures(Agg)` and address the instance
58
+ with an id DERIVED FROM THE PAYLOAD — never minted, never guessed:
59
+
60
+ ```ts
61
+ export const recordReading = directive("recordReading").ensures(Reading)
62
+ .payload(z.object({ probe: z.string(), day: z.string(), value: z.number().int() }))
63
+ .plan((p) => {
64
+ const r = instance(Reading, `reading:${p.probe}:${p.day}`);
65
+ return [set(r, "probe", p.probe), set(r, "day", p.day), set(r, "value", p.value)];
66
+ });
67
+ ```
68
+
69
+ The first dispatch creates the row; a re-dispatch folds onto it IN PLACE under
70
+ the field merge drivers (Lww: a re-submission of the same value is a no-op, a
71
+ corrected value replaces it). That makes the write IDEMPOTENT — any host can
72
+ re-submit without double-counting. Collectors, crons, and retry loops live on
73
+ this.
74
+
75
+ ## "Singletons" — minted record + natural-key query, NEVER a fixed row id
76
+
77
+ A common instinct is to model a one-of-a-kind record (a platform, a config, a
78
+ tenant) with a FIXED aggregate id — `create(Platform)` then address it forever
79
+ at `"the-platform"`. **The gate refuses this.** Every `create()` mints a
80
+ kernel id (`<TypeTag>_<uuidv7>`) and `check_create_ids` rejects a hand-written
81
+ id typed (`NotMinted`). Identity is minted; names are data.
82
+
83
+ So a singleton is **a minted record carrying its natural key as a FIELD, plus an
84
+ indexed query to find it by that key:**
85
+
86
+ ```ts
87
+ export const Platform = aggregate("Platform", {
88
+ platformId: t.string().merge(Lww), // the natural key — a FIELD, not the row id
89
+ namespace: t.string().merge(Lww),
90
+ // … the rest of the platform's state
91
+ });
92
+
93
+ export const registerPlatform = directive("registerPlatform").creates(Platform)
94
+ .payload(z.object({ platformId: z.string().min(1), namespace: z.string().min(1) }))
95
+ .plan((p) => { create(Platform).set("platformId", p.platformId).set("namespace", p.namespace); return []; });
96
+ // ^ NO id in the payload — Nomos MINTS it. The typed client mints-when-omitted.
97
+
98
+ export const platformByPlatformId = query("platformByPlatformId").key("platformId").returns(Platform);
99
+ // ^ "the singleton" is read by its natural key, O(1) — never by a guessed row id.
100
+ ```
101
+
102
+ The same pattern is how you model `namespaceId`, `providerId`, a config record
103
+ keyed by `name` — each a minted row + a `…By<NaturalKey>` query. If you truly
104
+ want create-or-amend semantics at a caller-owned key (and the natural key IS the
105
+ identity), use `.ensures` above instead — that lane DELIBERATELY addresses a
106
+ derived id and is the ONLY way a non-minted id is lawful.
107
+
108
+ ### Multi-aggregate ensures: `withMarker`
109
+
110
+ A directive's marker covers its OWN target aggregate only. When the same plan
111
+ also upserts a SECOND aggregate (a sample updating its monthly meter in the
112
+ same intent), tag that aggregate's write explicitly with
113
+ `withMarker(op, "ensures")` — untagged fan-out rides as a mutate of a row
114
+ that may not exist yet:
115
+
116
+ ```ts
117
+ export const recordSample = directive("recordSample").ensures(Sample)
118
+ .payload(/* … */)
119
+ .plan((p) => {
120
+ const s = instance(Sample, sampleId(p)); // the directive's .ensures target
121
+ const m = instance(Meter, meterId(p)); // the sibling upsert
122
+ return [
123
+ set(s, "value", p.value),
124
+ withMarker(set(m, "month", monthOf(p)), "ensures"), // ONE tagged op marks the whole aggregate's event
125
+ set(m, "monthToDate", p.monthToDate),
126
+ ];
127
+ });
128
+ ```
129
+
130
+ One tagged op per sibling aggregate is enough (conflicting markers for the
131
+ same aggregate refuse at encode, fail-closed). This is exactly how Nomos
132
+ Cloud's own usage tenant folds a reading AND its budget meter in ONE intent —
133
+ so the budget invariant judges both together: a reading that would leave the
134
+ meter over an unacked threshold refuses, and the sample refuses WITH it.
52
135
 
53
136
  ## Determinism or death
54
137
 
@@ -69,8 +152,10 @@ admission fails everywhere, forever.
69
152
  engine-projected read field. Lives only in the read model, never in the
70
153
  ledger, so it is always re-derivable.
71
154
 
72
- Export each at top level; `githolon compile` routes them into the read
73
- manifest so they work at the edge AND in clients. After a compile, read
74
- `build/<pkg>.summary.txt` — it describes exactly what you built.
155
+ Export each at top level; `githolon compile` auto-discovers them by shape and
156
+ routes them into the read manifest so they work at the edge AND in clients.
157
+ After a compile, read `build/<pkg>.summary.txt` — it lists every aggregate,
158
+ directive, query, count, and sum you actually built; if a declared read is
159
+ missing there, it is missing everywhere.
75
160
 
76
161
  Next: [03-client.md](./03-client.md) — driving it from an app.
@@ -27,10 +27,12 @@ the three-era chain cold-verifies green end to end; also
27
27
  | add an aggregate / a domain | **safe** | the same deploy lane; nothing existing moves |
28
28
  | widen a payload (new optional zod key) | **safe** | old clients' payloads still validate |
29
29
  | deprecate a field (keep it, stop writing) | **safe** | old rows keep reading it |
30
- | rename a field | **honest, manual** | NO migration old rows keep the old name, new rows the new; backfill is client-driven |
31
- | retype a field in place | **don't** | rows keep their authored type mixed-type reads; typed clients break on old rows |
30
+ | rename a field / an aggregate | **safe — framework-handled** | names are labels; identity is the compiler-minted STABLE ID. Rename the declaration, recompile (commit `nomos.stable-ids.json` — the lockfile carries identity), deploy: the evolve gate admits silently and old rows READ THE NEW NAME automatically. No migration, no backfill |
31
+ | remove a field / an aggregate | **refused typed, then disposed** | the upgrade refuses naming the stable id; redeploy with `dispositions: { retired: ["<sid>"] }` in the deploy JSON to acknowledge the true removal |
32
+ | retype a field in place | **refused typed — prefer a new field** | the upgrade refuses (same stable id, new kind); `dispositions: { retyped: { "<sid>": "why" } }` admits it, but old rows keep their authored type → mixed-type reads. A NEW field is almost always better |
32
33
  | narrow/change a payload or plan old clients still dispatch | **refused typed** | the old trap is closed: a divergent old-hash write gets a typed era refusal at the gate (never a silently poisoned chain) — see below |
33
- | automatic migration of old events (true rename/rescale on read) | **not yet** | the upcaster hatch exists in the gate but ships inert; specced in `architecture/law_evolution.md` |
34
+ | read on the write path (`read()` in a plan) | **safe — when declared** | declare each query on the directive (`.reads(theQuery)`); the read is served live at author, captured onto the intent, replayed at verify, and CAS-checked at every gate (a stale premise is a typed read-conflict refusal) |
35
+ | automatic migration of old VALUES (rescale on read) | **not yet** | the upcaster hatch exists in the gate but ships inert; renames no longer need it (stable ids) |
34
36
  | rewrite what old intents meant | **never** | history is sealed and self-verifying — that is the point |
35
37
 
36
38
  ## Recipes
@@ -49,32 +51,72 @@ a field's declaration orphans it from reads (see rename, next) even though the
49
51
  data stays in the chain. Removing a directive breaks any client still
50
52
  dispatching it. Deletion buys you nothing — law size is not data size.
51
53
 
52
- ### Rename a field (what really happens)
53
-
54
- A rename is a remove + an add. The proof shows exactly this:
55
-
56
- - era-1 rows KEEP their value under the OLD name in reads (the old install is
57
- still that field's latest declaring install) orphaned name, not lost data;
58
- - the NEW name is ABSENT on era-1 rows: **nothing migrates**;
59
- - new-law rows carry only the new name — reads are SPLIT across two names.
60
-
61
- The safe sequence, if you must rename:
62
-
63
- 1. Add the new field (keep the old one declared).
64
- 2. New law's directives write the new field; deploy; ship the new client.
65
- 3. **Backfill client-side**: read each old row, dispatch the new-law mutate
66
- with the old value. *(Proof: the backfill lands `pos`; `geoPos` remains as
67
- residue history is append-only.)*
68
- 4. Treat the old field as deprecated (recipe above). Reads that matter use the
69
- new name only.
70
-
71
- ### Never retype in place
72
-
73
- Same field name, new kind (`t.string()` `t.int()`) compiles and verifies
74
- and gives you rows of BOTH types under one field name, era by era. A typed
75
- client's read model is then wrong for old rows. Use a NEW field name and the
76
- rename recipe. *(Proof: the era-2 string `pos` row reads back as a string under
77
- the era-3 int schema, beside an int row.)*
54
+ ### Rename a field or aggregate (framework-handled)
55
+
56
+ Names are LABELS; identity is the STABLE ID the compiler mints at first
57
+ appearance and carries across compiles via `nomos.stable-ids.json` — **commit
58
+ that lockfile** (it is how a rename keeps its identity across machines and
59
+ clean clones). The flow:
60
+
61
+ 1. Rename the declaration in `domains/*.ts`.
62
+ 2. Recompile — the summary prints `INFERRED RENAME geoPos -> pos (stable id
63
+ carried)`. If you meant a removal + an unrelated addition instead, split
64
+ the edit into two compiles (or answer the evolve gate, below).
65
+ 3. Deploy. The evolve gate diffs old vs new law BY STABLE ID: a rename is the
66
+ same id with a new label **it admits silently**, and the projection folds
67
+ via the stable id, so old rows READ THE NEW NAME immediately. Nothing on
68
+ the chain changes (history stays append-only); no client backfill exists
69
+ anymore. *(Proof: the era-1 row reads `pos` the moment v2 installs.)*
70
+
71
+ ### Remove a field (the evolve gate asks)
72
+
73
+ Deleting a declaration makes its stable id DISAPPEAR — the deploy is refused
74
+ typed, naming the id and the remedies. If it is a true removal, redeploy with
75
+ the disposition (an in-history fact on the upgrade intent):
76
+
77
+ ```jsonc
78
+ // add to build/<name>.deploy.json before POSTing (or via curl --data)
79
+ "dispositions": { "retired": ["<the sid from the refusal>"] }
80
+ ```
81
+
82
+ If it was actually a rename the compiler could not infer (no lockfile, or an
83
+ ambiguous edit), recompile with the lockfile present — or declare
84
+ `"dispositions": { "rebinds": { "<newSid>": "<oldSid>" } }`.
85
+
86
+ ### Retype in place (refused; prefer a new field)
87
+
88
+ Same field name, new kind (`t.string()` → `t.int()`) is the SAME stable id
89
+ with a different kind — the deploy refuses typed. You may acknowledge it with
90
+ `"dispositions": { "retyped": { "<sid>": "grid index supersedes lat-long" } }`,
91
+ but rows keep their AUTHORED type, so reads go mixed-type across eras and a
92
+ typed client's read model lies for old rows. A NEW field (new identity) is
93
+ almost always the better move. *(Proof: the era-2 string `pos` row reads back
94
+ as a string under the disposed era-3 int schema, beside an int row.)*
95
+
96
+ ### Read on the write path (captured reads)
97
+
98
+ A plan that must read to decide declares the query on its directive and calls
99
+ `read()`:
100
+
101
+ ```ts
102
+ export const auditShelf = directive("auditShelf")
103
+ .creates(Audit)
104
+ .payload(z.object({ shelf: z.string().min(1), auditedAt: z.string().min(1) }))
105
+ .plan((p) => {
106
+ const rows = read(entriesByShelf, { shelf: p.shelf }) as unknown[];
107
+ create(Audit).set("shelf", p.shelf).set("seen", rows.length).set("auditedAt", p.auditedAt);
108
+ return [];
109
+ })
110
+ .reads(entriesByShelf); // ← the declaration that makes nomos.read lawful
111
+ ```
112
+
113
+ The read is served LIVE from the committed pre-write state at author, captured
114
+ onto the intent (its premise becomes part of its truth), replayed at verify,
115
+ and RE-DERIVED at every gate: if the rows moved between your author and the
116
+ edge admission (a concurrent writer), the write is a **typed read-conflict
117
+ refusal** — the chain is the compare-and-swap register; re-read and
118
+ re-dispatch. Undeclared `read()` refuses at compile. Reads are same-workspace
119
+ and size-bounded by law. *(Proof: `bench/scale/captured_reads.test.mjs`.)*
78
120
 
79
121
  ### Old clients after a breaking change (the trap, now closed)
80
122
 
@@ -119,5 +161,6 @@ the cloud fans the same deploy into every open shard
119
161
  `cloud/web-client/test/heavy_coordinator.e2e.mjs`.
120
162
 
121
163
  Back to the [README](../README.md) — or the deep dive:
122
- `architecture/law_evolution.md` in the nomos2 repo (the substrate mechanics +
123
- the specced upcaster surface for true migrations).
164
+ `architecture/law_evolution.md` in the nomos2 repo (the substrate mechanics:
165
+ stable identifiers, the evolve gate, the sid-lineage fold, and the still-inert
166
+ upcaster surface for true value migrations).