create-holon 0.1.5 → 0.2.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-holon",
3
- "version": "0.1.5",
3
+ "version": "0.2.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",
@@ -10,6 +10,12 @@ npx githolon compile # → build/: deployable package + manifests + typ
10
10
  npx githolon login --agent # self-onboard a verified identity (no browser; linkable to a full account later)
11
11
  ```
12
12
 
13
+ **The shortest complete proof** — `npm install && npx githolon compile && npm run e2e`
14
+ — IS the whole demonstration: your law deploys to a throwaway workspace, an
15
+ ENFORCED-offline write (fetch is trapped) syncs through edge admission, and the
16
+ cloud answers your declared query and count. Paths A/B below are for keeping a
17
+ real workspace afterwards.
18
+
13
19
  ## Path A — deploy your law
14
20
 
15
21
  ```bash
@@ -55,9 +61,11 @@ The starter is the tutorial; this is the exact path from guestbook to your own l
55
61
  ```js
56
62
  export default { name: "<pkg>", domains: [{ key: "<pkg>", modules: ["./domains/<yours>.ts"] }] };
57
63
  ```
58
- **Naming convention:** make `<pkg>` ONE lowercase word (`potluck`, `studygroup`).
59
- It becomes the generated symbols verbatim: `build/<pkg>.client.ts` exporting
60
- `<pkg>Client(holon)` and `<PKG>_DOMAIN_HASH`.
64
+ **Naming convention: there isn't one.** `<pkg>` can be whatever you'd actually
65
+ call it `potluck`, `tool-library`, `study_group`, even `Tool Library`.
66
+ Artifact filenames keep your name verbatim (`build/<pkg>.client.ts`); the
67
+ generated symbols normalize it for you (`toolLibraryClient(holon)`,
68
+ `TOOL_LIBRARY_DOMAIN_HASH`).
61
69
  3. **Compile and read what you built:**
62
70
  ```bash
63
71
  npx githolon compile && cat build/<pkg>.summary.txt
@@ -71,8 +79,27 @@ The starter is the tutorial; this is the exact path from guestbook to your own l
71
79
 
72
80
  Then deploy it for real: `npx githolon login --agent && npx githolon ws create <ws> && npx githolon deploy <ws>`.
73
81
 
82
+ ## Docs — in the box
83
+
84
+ `docs/` travels with you — offline, like everything else here. Five short
85
+ pages, each under a screen and a half:
86
+
87
+ 1. [The mental model](./docs/01-mental-model.md) — holons, content-addressed
88
+ law, custody-not-truth, admission, no server authority.
89
+ 2. [Authoring law](./docs/02-authoring.md) — aggregates, field kinds, the
90
+ merge-driver table, plan ops (`addToSet` vs `set`), determinism rules,
91
+ declared reads (query/count/derived/sum).
92
+ 3. [The client](./docs/03-client.md) — `connect`, the generated client
93
+ contract, `sync` semantics (`admission: null` is normal), watches, dead
94
+ letters, the two-clientId concurrency proof.
95
+ 4. [Nomos Cloud](./docs/04-cloud.md) — both deploy lanes, `login --agent`
96
+ identity, retirement + data retention per the license, DLQ retry, quotas.
97
+ 5. [Superpowers](./docs/05-superpowers.md) — mint a holon locally, the upload
98
+ birth, byte-identical wasm everywhere, the enforced-offline proof.
99
+
74
100
  ## What's here
75
101
 
102
+ - `docs/` — the five pages above; the offline reference.
76
103
  - `domains/guestbook.ts` — the starter domain (the law): one aggregate, two
77
104
  directives, a declared query, a derived field, a count. Rename it, reshape
78
105
  it, or add domains with `npx githolon generate domain <name>`.
@@ -88,7 +115,9 @@ Then deploy it for real: `npx githolon login --agent && npx githolon ws create <
88
115
  ## Authoring rules (the engine enforces these)
89
116
 
90
117
  - A directive's plan is a PURE function of `(payload, ports)` — timestamps ride
91
- in the payload as ISO strings; no `Date.now()` / `Math.random()`.
118
+ in the payload as ISO strings; no `Date.now()` / `Math.random()`. You rarely
119
+ stamp one by hand: payload fields named `…At` are auto-filled with ISO now()
120
+ by the generated client when you omit them (an explicit value always wins).
92
121
  - `create(Agg)` mints ids — `.creates` payloads never carry one.
93
122
  - Merge drivers are the law: `Lww`, `AddWins`, `MapOf(Lww)`.
94
123
 
@@ -0,0 +1,57 @@
1
+ # The mental model
2
+
3
+ Five ideas. Everything else in this scaffold is a consequence of them.
4
+
5
+ ## Holons
6
+
7
+ A holon is the runtime: kernel + SQLite projection + an embedded deterministic
8
+ JS engine, compiled into ONE `wasm32-wasip1` artifact. Every peer — the cloud
9
+ edge, your laptop, a browser tab, the e2e test — runs the SAME bytes. That is
10
+ why results agree: not because peers trust each other, but because they cannot
11
+ diverge. There is no "server build" and "client build". There is one build.
12
+
13
+ ## Law
14
+
15
+ Your domain — aggregates + directives in `domains/*.ts` — compiles to a
16
+ package whose sha256 IS its identity (`policy:{hash}`). Law is
17
+ content-addressed: a workspace doesn't run "version 3 of guestbook", it runs
18
+ `policy:ab12…`, and the generated client carries that exact hash baked in.
19
+ A write is judged against the hash it was authored under. No ambient config,
20
+ no environment drift — if the bytes differ, it is different law.
21
+
22
+ ## Custody, not truth
23
+
24
+ The ledger is a git repo. Git stores and transports the chain; it does not
25
+ vouch for it. Every holon re-verifies everything it replays: contiguity,
26
+ admission of every intent, plans re-run and byte-compared. So possession of
27
+ the repo confers nothing — a chain is valid because it verifies, wherever it
28
+ sits. This is why `git clone` of your workspace works with stock git, and why
29
+ a chain minted on your laptop is exactly as good as one minted in the cloud.
30
+
31
+ ## Admission
32
+
33
+ Clients author OFFLINE under the pulled law, then push to an untrusted
34
+ `session/<clientId>` branch. The edge holon re-admits each intent — validates
35
+ the payload, re-runs the plan, byte-compares the result — before merging to
36
+ `main`. Verification, never trust: `refs/heads/main` is written only through
37
+ that gate. A rejected intent is dead-lettered on both sides, never lost
38
+ (see [03-client.md](./03-client.md)).
39
+
40
+ ## No server authority
41
+
42
+ No holon outranks another. The cloud edge is custody plus a convenient
43
+ always-on peer — its verdicts are re-derivable by anyone with the chain and
44
+ the wasm. That's the point of Path B in the README: mint a ledger locally,
45
+ verify it locally, push it, and watch the cloud re-derive your exact verdict
46
+ before serving anything. Authority lives in the git, not in a hostname.
47
+
48
+ ## What this buys you
49
+
50
+ - Offline is the normal case, not a degraded mode. Writes, queries, counts,
51
+ derived fields all run locally; sync is reconciliation, not submission.
52
+ - Merges are law, not luck: every field declares its merge driver, so
53
+ concurrent edits resolve the same way on every peer
54
+ (see [02-authoring.md](./02-authoring.md)).
55
+ - The audit trail is `git log`. Every commit is an intent envelope.
56
+
57
+ Next: [02-authoring.md](./02-authoring.md) — what you actually write.
@@ -0,0 +1,76 @@
1
+ # Authoring law
2
+
3
+ You write exactly TWO things: aggregates and directives. Never apply/fold/
4
+ merge code — the kernel owns folding; your directive PLANS ops and the sealed
5
+ engine replays them deterministically on every peer. Declared reads (query,
6
+ count, derived, sum) are auto-discovered from your module's exports by shape.
7
+ `domains/guestbook.ts` demonstrates every pattern below; reshape it.
8
+
9
+ ## Aggregates: typed fields, each tagged a merge driver
10
+
11
+ ```ts
12
+ export const Book = aggregate("Book", {
13
+ title: t.string().merge(Lww),
14
+ tags: t.set(t.string()).merge(AddWins),
15
+ notes: t.map(t.string()).merge(MapOf(Lww)),
16
+ });
17
+ ```
18
+
19
+ Field kinds: `t.string()` · `t.int()` · `t.enum([...] as const)` (typos squeal
20
+ at compile time) · `t.set(t.string())` · `t.map(inner)` · `t.json()` /
21
+ `t.jsonObject()` (JSON-string leaf; `jsonObject` asserts an object and reads
22
+ back structured) · `t.ref(Agg)` (an id-valued reference) ·
23
+ `t.hasMany(Child).via("parent")` (the virtual read side of a child's ref —
24
+ nothing stored on the parent). Add `.optional()` where absence is legal.
25
+
26
+ ## Merge drivers ARE the conflict policy
27
+
28
+ | driver | concurrent writes to the same field… |
29
+ |---|---|
30
+ | `Lww` | last write wins (by the intent's HLC timestamp) |
31
+ | `AddWins` | sets union — concurrent adds ALL survive |
32
+ | `MapOf(Lww)` | per-key LWW — concurrent writers to different keys commute |
33
+ | `Conflict` | refuse to merge (fail-closed; the default if you tag nothing) |
34
+
35
+ You choose the policy per field, at authoring time. Nobody writes merge code
36
+ at 2am during an incident — the kernel applies the declared driver, the same
37
+ way on every peer.
38
+
39
+ ## Directives: a zod payload → a pure plan
40
+
41
+ ```ts
42
+ export const addBook = directive("addBook").creates(Book)
43
+ .payload(z.object({ title: z.string(), addedAt: z.string() }))
44
+ .plan((p) => { create(Book).set("title", p.title); return []; });
45
+ ```
46
+
47
+ Plan ops: `set` (scalars/refs) · `addToSet` (the ONLY additive write to an
48
+ AddWins set — `set()` on a set field would overwrite the union and is REFUSED
49
+ at the type level and at runtime) · `setEntry` (one map key) · `strike`
50
+ (retract). `.creates(Agg)` mints the id — payloads never carry one;
51
+ `.mutates(Agg)` takes the instance id in the payload.
52
+
53
+ ## Determinism or death
54
+
55
+ A plan is a PURE function of its payload. No `Date.now()`, no
56
+ `Math.random()`, no I/O — the sandbox traps them. Timestamps ride IN the
57
+ payload as ISO strings, stamped by the caller. Why so strict: every peer
58
+ re-runs your plan and byte-compares the result; one nondeterministic call and
59
+ admission fails everywhere, forever.
60
+
61
+ ## Declared reads — name them, never scan
62
+
63
+ - `query("booksByShelf").key("shelf").returns(Book)` — an indexed probe.
64
+ - `count("booksPerShelf").of(Book).by("shelf")` — a maintained O(1) tally.
65
+ - `sum("stockValue", "price").of(Book).by("shelf")` — count's numeric sibling:
66
+ a maintained running total of an int field; `.where(p => …)` filters, `.by`
67
+ groups. Never a `SUM(*)` scan.
68
+ - `derived("isLong").of(Book).returns(z.boolean()).as((b) => …)` — a PURE
69
+ engine-projected read field. Lives only in the read model, never in the
70
+ ledger, so it is always re-derivable.
71
+
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.
75
+
76
+ Next: [03-client.md](./03-client.md) — driving it from an app.
@@ -0,0 +1,78 @@
1
+ # The client
2
+
3
+ `test/e2e.mts` IS the tutorial — every claim below runs live in it. This page
4
+ is the map.
5
+
6
+ ## connect()
7
+
8
+ ```ts
9
+ import { connect } from "@githolon/client";
10
+ import { guestbookClient } from "../build/guestbook.client.ts";
11
+
12
+ const holon = await connect({ cloud, workspace, clientId });
13
+ const app = guestbookClient(holon);
14
+ ```
15
+
16
+ `connect` pulls the wasm, the workspace manifests, and the ledger, then runs
17
+ the byte-identical holon LOCALLY. `clientId` names your session branch
18
+ (`session/<clientId>`) — one per writer. Everything after connect works
19
+ offline; the e2e proves it by trapping `fetch` while it authors and queries.
20
+
21
+ ## The generated client contract
22
+
23
+ `githolon compile` emits `build/<pkg>.client.ts`: a typed method per directive
24
+ (payload types from the SAME zod the engine validates), a read-model interface
25
+ per aggregate (every field optional — partial folds are normal), typed
26
+ accessors per declared query/count/sum, by-id read and watch helpers, and the
27
+ deployed law's content hash baked in. Zero imports — it binds structurally to
28
+ `connect()`'s holon. If the cloud's law hash differs from the client's, you
29
+ compiled different bytes; recompile rather than wonder.
30
+
31
+ The raw surface stays underneath: `holon.dispatch(...)`, `holon.query(id,
32
+ params)`, `holon.queryById(id)`, `holon.count(id, group)`, `holon.sum(id,
33
+ group)`. Hover anything in your editor — `@githolon/client` ships full types
34
+ and the semantics live in the doc comments.
35
+
36
+ ## sync()
37
+
38
+ ```ts
39
+ const s = await holon.sync({ admit: true });
40
+ ```
41
+
42
+ One call: push unpushed intents to your session branch, ask the edge to judge
43
+ now, pull canonical main, rebase-replay anything still unacked (intent-id
44
+ deduped — nothing ever double-folds). `s.admission` is `null` when there was
45
+ nothing to push — normal, not an error. Without `{admit: true}` a push still
46
+ lands within ~2s: the edge self-schedules admission. `holon.pull()` converges
47
+ in place on demand; no reconnects, ever.
48
+
49
+ ## watch
50
+
51
+ `app.watch<Agg>ById(id, (rows) => …)` is a LOCAL reactive read — it fires on
52
+ local folds and on pulls. Render from watches; treat sync as background
53
+ reconciliation, not as the read path.
54
+
55
+ ## Dead letters — refused work is never lost
56
+
57
+ A DOMAIN rejection (the law couldn't admit it — e.g. its domain isn't deployed
58
+ yet) parks the FULL intent on both sides: `holon.deadLetters()` locally
59
+ (durable — it rides `holon.export()`/restore) and the workspace DLQ in the
60
+ cloud. Ship a law fix through the deploy lane, then `holon.retryDeadLetter(id)`
61
+ — the user's work lands on main. `discardDeadLetter(id)` is the app's explicit
62
+ choice; nothing is silently dropped. Obvious attacks (session-lane law
63
+ intents) are dropped, never queued.
64
+
65
+ ## Two writers, no coordination
66
+
67
+ The canonical concurrency demo (e2e step 9): two `connect()`s with DIFFERENT
68
+ clientIds tag the same entry offline, blind to each other. Both sync; the
69
+ AddWins union keeps every add. No locks, no "last writer wins the whole
70
+ record", no merge code — the field's declared driver did it
71
+ (see [02-authoring.md](./02-authoring.md)).
72
+
73
+ ## Persistence
74
+
75
+ `holon.export()` → bytes; `connect({ restoreFrom: bytes })` restores. Pending
76
+ un-synced writes survive an app reload and still sync.
77
+
78
+ Next: [04-cloud.md](./04-cloud.md) — workspaces, identity, quotas.
@@ -0,0 +1,78 @@
1
+ # Nomos Cloud
2
+
3
+ The cloud (`https://nomos.captainapp.co.uk`) hosts edge holons, one per
4
+ workspace, ledgered in git. It is custody plus an always-on peer — not an
5
+ authority (see [01-mental-model.md](./01-mental-model.md)). Everything the
6
+ CLI does, curl and stock git can do; `npx githolon --help` lists every lane.
7
+
8
+ ## Identity first
9
+
10
+ ```bash
11
+ npx githolon login --agent
12
+ ```
13
+
14
+ One command, no browser, no signup form: a VERIFIED self-onboarded identity,
15
+ linkable to a full account later. Every birth needs a principal — no
16
+ anonymous nodes — so log in before creating anything. `githolon whoami` shows
17
+ the principal births will ride; sessions auto-refresh from
18
+ `~/.holon/credentials.json`.
19
+
20
+ ## Two deploy lanes, one destination
21
+
22
+ **Path A — deploy your law** (the cloud births the workspace):
23
+
24
+ ```bash
25
+ npx githolon ws create <ws> # birth; the ONE-TIME workspaceSecret is saved to ~/.holon
26
+ npx githolon deploy <ws> # POST build/<pkg>.deploy.json with the stored secret
27
+ ```
28
+
29
+ **Path B — the upload birth** (you birth it, the cloud verifies):
30
+
31
+ ```bash
32
+ npx githolon ledger init holon
33
+ cd holon && npx githolon git remote <ws> && git push nomos main
34
+ ```
35
+
36
+ Both end in the same place — all holons are equal in authority; the chain
37
+ proves itself either way ([05-superpowers.md](./05-superpowers.md) walks B).
38
+
39
+ ## Ownership
40
+
41
+ The workspace secret gates LAW (deploys, retirement, secret rotation). End-user
42
+ apps carry NO secret: reads and session pushes are open, judged by edge
43
+ admission; the session lane structurally refuses control-plane intents. So law
44
+ only ever enters through the secret-gated deploy. Lose the secret? It was
45
+ shown once — `githolon secret rotate` with the current one, or ask the
46
+ operator. Never embed it in an app.
47
+
48
+ ## Dead-letter retry — the operator's unjam
49
+
50
+ Domain-rejected intents land in the workspace DLQ with full provenance
51
+ (`GET /v1/workspaces/<ws>/dead-letters`, owner-keyed). Ship the law fix via
52
+ the deploy lane, then `POST /v1/workspaces/<ws>/dead-letters/retry?id=` (or
53
+ client-side `retryDeadLetter(id)`). The refused work — somebody's actual
54
+ writes — lands on main. DELETE discards, explicitly.
55
+
56
+ ## Retirement and data retention
57
+
58
+ ```bash
59
+ npx githolon ws retire <ws>
60
+ ```
61
+
62
+ Owner-gated. The workspace stops counting toward your quota; deploys and
63
+ session pushes refuse; reads keep serving. The data is NOT deleted: this is a
64
+ pre-release and the license says so plainly — workspace data (ledgers, law,
65
+ intents) is retained and may be examined to evaluate the product. Don't put
66
+ anything in a pre-release workspace you wouldn't want the builders to read.
67
+ Need something truly expunged? jack@captainapp.co.uk.
68
+
69
+ ## Quotas — law-state, not host config
70
+
71
+ The limits are themselves law (a `CloudPolicy` record in the root workspace):
72
+ **50 live workspaces** per principal, **1 GiB** cumulative pushed bytes per
73
+ workspace ledger, push 30/min, admit 10/min per workspace. Refusals on the
74
+ git lane answer with a pkt-line `ERR`, so stock git prints the reason. Hitting
75
+ a limit is a quota REQUEST, not a wall: an admin grants an allowance and a
76
+ fresh grant unjams immediately. Ask: jack@captainapp.co.uk.
77
+
78
+ Next: [05-superpowers.md](./05-superpowers.md) — what only this stack can do.
@@ -0,0 +1,66 @@
1
+ # Superpowers
2
+
3
+ Things this stack does that a client-server stack structurally cannot. Each
4
+ one is e2e-proven, not aspirational.
5
+
6
+ ## Mint a real holon locally
7
+
8
+ ```bash
9
+ npx githolon ledger init holon # genesis + your compiled law, under YOUR identity
10
+ npx githolon ledger verify holon # re-assert any time
11
+ ```
12
+
13
+ `ledger init` builds a brand-new GitHolon ledger on your machine: genesis (the
14
+ nomos controller), then your law, every intent stamped `installedBy: <your
15
+ identity>` — and verifies the chain with the SAME byte-identical wasm gate the
16
+ cloud runs. This is not a mock or a dev server. It is a holon, born valid,
17
+ and you can assert that yourself, offline, before any network exists.
18
+
19
+ ## The upload birth — the cloud re-derives YOUR verdict
20
+
21
+ ```bash
22
+ cd holon
23
+ npx githolon git remote <ws> # wires the remote + credential helper, mints the owner secret
24
+ git push nomos main # stock git; the credential helper answers the 401
25
+ ```
26
+
27
+ Pushing `refs/heads/main` to an unborn workspace births it FROM YOUR REPO. The
28
+ cloud replay-verifies the whole chain from genesis — contiguity, every intent
29
+ re-admitted against the state folded from the chain prefix, every plan re-run
30
+ and byte-compared — and re-derives your exact local verdict: same head, same
31
+ plans. Valid ⇒ custody adopts your head BYTE-IDENTICAL (no re-seal), and
32
+ sha256 of your push password becomes the owner credential. Invalid ⇒ archived
33
+ under `refs/heads/refused/`, the workspace stays unborn, the verdict rides
34
+ `githolon ws status <ws>`. The pushed bytes buy nothing; the chain proves
35
+ itself. The git holon executes — and thus validates — itself.
36
+
37
+ Why this matters: deployment is `git push`, the audit trail is `git log`, and
38
+ "does the cloud agree with my machine?" is a checkable equation, not a hope.
39
+
40
+ ## Byte-identical wasm everywhere
41
+
42
+ One `wasm32-wasip1` artifact runs in the cloud edge, the heavy container tier,
43
+ your browser, your terminal, and the e2e. The same law, the same fold, the
44
+ same admission gate — so a verdict computed anywhere is the verdict
45
+ everywhere. The e2e's final convergence check (local head == canonical main)
46
+ is only meaningful because both sides ran the same bytes.
47
+
48
+ ## Offline is enforced, not narrated
49
+
50
+ `npm run e2e` doesn't claim offline-first — it traps `fetch` to THROW while it
51
+ authors a write, routes a declared query, folds a mutate, projects a derived
52
+ field, and reads a count, all locally. If any "local" path touched the
53
+ network, the test fails. Then it restores fetch, syncs, and the cloud answers
54
+ the same query. That trap is the honest definition of local-first: the network
55
+ is for reconciliation only.
56
+
57
+ ## What to do with all this
58
+
59
+ - Demo without wifi. Author, query, watch — then sync when it's back.
60
+ - Treat the cloud as replaceable custody. Your ledger clones with stock git;
61
+ a holon minted from it locally verifies without asking anyone.
62
+ - Audit by reading. Every commit is an intent envelope; `git log` in `holon/`
63
+ is the complete, verifiable history of the workspace.
64
+
65
+ Back to the [README](../README.md) — or start reshaping
66
+ `domains/guestbook.ts` ([02-authoring.md](./02-authoring.md)).
@@ -12,8 +12,8 @@
12
12
  * * a DERIVED read field (engine-projected, PURE fn of the folded fields);
13
13
  * * a COUNT (a maintained tally, grouped by a field).
14
14
  */
15
- import { z } from "zod";
16
15
  import {
16
+ z,
17
17
  aggregate,
18
18
  t,
19
19
  Lww,
@@ -10,12 +10,12 @@
10
10
  "typecheck": "tsc --noEmit"
11
11
  },
12
12
  "dependencies": {
13
- "@githolon/dsl": "^0.1.0",
14
- "@githolon/client": "^0.1.0",
13
+ "@githolon/dsl": "^0.2.0",
14
+ "@githolon/client": "^0.2.0",
15
15
  "zod": "^4.4.3"
16
16
  },
17
17
  "devDependencies": {
18
- "githolon": "^0.1.2",
18
+ "githolon": "^0.2.0",
19
19
  "@types/node": "^25.9.2",
20
20
  "tsx": "^4.19.2",
21
21
  "typescript": "^5.6.3"
@@ -6,6 +6,8 @@
6
6
  // TYPED declared query routes LOCALLY → sync + edge admission → the CLOUD's
7
7
  // declared query returns the entry.
8
8
  //
9
+ // docs/03-client.md explains every call here.
10
+ //
9
11
  // Run from this directory AFTER `npx nomos-compile`: npx tsx test/e2e.mts
10
12
  import { readFileSync } from "node:fs";
11
13
  import { connect } from "@githolon/client";
@@ -15,6 +17,9 @@ const CLOUD = (process.env.NOMOS_CLOUD || "https://nomos.captainapp.co.uk").repl
15
17
  const WS = process.env.NOMOS_WS || "gb-e2e-" + Math.random().toString(36).slice(2, 8);
16
18
 
17
19
  // explicit annotation on the const so TS's never-call narrowing applies after fail(...)
20
+ // IDENTITY NOTE: this proof uses the bare x-nomos-principal lane so the test is
21
+ // SELF-CONTAINED (a throwaway workspace, no stored credentials). Real apps use
22
+ // `githolon login --agent` (a VERIFIED identity) + `githolon ws create/deploy`.
18
23
  const fail: (m: string) => never = (m) => { console.error("✗ " + m); process.exit(1); };
19
24
  const ok = (m: string) => console.log("✓ " + m);
20
25
 
@@ -67,11 +72,14 @@ if (!(entries.length === 1 && entries[0]!.data.message === "hello from the edge
67
72
  const entryId = entries[0]!.id;
68
73
  ok(`typed declared query routes — entriesByAuthor → ${entryId} (mood: ${entries[0]!.data.mood})`);
69
74
 
70
- // 5. typed mutate + typed by-id read (SetEntry map-put folds locally)
71
- await app.reactToEntry({ entryId, reactor: "bob", reaction: "love", reactedAt: new Date().toISOString() });
75
+ // 5. typed mutate + typed by-id read (SetEntry map-put folds locally).
76
+ // `reactedAt` is OMITTED the generated client auto-stamps `…At` payload fields
77
+ // with ISO now() at dispatch (caller-side, so the plan stays pure).
78
+ await app.reactToEntry({ entryId, reactor: "bob", reaction: "love" });
72
79
  const after = await app.guestbookEntryById(entryId);
73
80
  if (after[0]!.data.reactions?.bob !== "love") fail(`typed mutate/read: ${JSON.stringify(after)}`);
74
- ok("typed mutate folds reactions map shows bob love locally");
81
+ if (!/^\d{4}-\d{2}-\d{2}T/.test(after[0]!.data.updatedAt ?? "")) fail(`auto-stamped reactedAt did not fold into updatedAt: ${JSON.stringify(after[0]!.data)}`);
82
+ ok("typed mutate folds — reactions map shows bob → love locally (reactedAt auto-stamped by the client)");
75
83
 
76
84
  // 5b. DERIVED read field: the engine projects moodEmoji from the law's pure fn
77
85
  if (after[0]!.data.moodEmoji !== "🤩") fail(`derived moodEmoji not projected locally: ${JSON.stringify(after[0]!.data)}`);
@@ -85,8 +93,9 @@ ok("declared count maintained locally — entriesPerMood('delighted') = 1");
85
93
  globalThis.fetch = realFetch;
86
94
  ok("offline proof ENFORCED — fetch was trapped: local write/query/mutate/count touched no network");
87
95
 
88
- // 6. sync: push the session branch + edge admission merges to main
89
- const s = await holon.sync({ admit: true });
96
+ // 6. sync: push the session branch + edge admission merges to main.
97
+ // Bare sync() IS the whole loop — admit defaults to true.
98
+ const s = await holon.sync();
90
99
  if (!s.admission?.ok) fail(`sync/admit: ${JSON.stringify(s)}`);
91
100
  const admitted = (s.admission.sessions || []).flatMap((x: { admitted?: unknown[] }) => x.admitted || []);
92
101
  if (admitted.length < 2) fail(`edge admitted ${admitted.length} intent(s), expected 2: ${JSON.stringify(s.admission)}`);
@@ -114,8 +123,8 @@ const taggerA = await connect({ cloud: CLOUD, workspace: WS, clientId: "tagger-a
114
123
  const taggerB = await connect({ cloud: CLOUD, workspace: WS, clientId: "tagger-b" });
115
124
  await guestbookClient(taggerA).tagEntry({ entryId, tags: ["vegan"] });
116
125
  await guestbookClient(taggerB).tagEntry({ entryId, tags: ["gluten-free"] });
117
- await taggerA.sync({ admit: true });
118
- await taggerB.sync({ admit: true });
126
+ await taggerA.sync();
127
+ await taggerB.sync();
119
128
  d = await (await fetch(`${CLOUD}/v1/workspaces/${WS}/aggregates/${encodeURIComponent(entryId)}`)).json();
120
129
  const mergedTags: string[] = d.rows?.[0]?.data?.tags ?? [];
121
130
  if (!(mergedTags.includes("vegan") && mergedTags.includes("gluten-free"))) fail(`AddWins union lost an add: ${JSON.stringify(mergedTags)}`);
@@ -130,4 +139,53 @@ const remoteMain = (refsAdv.match(/([0-9a-f]{40}) refs\/heads\/main/) || [])[1];
130
139
  if (head !== remoteMain) fail(`not converged in place: local ${head?.slice(0, 10)} vs main ${remoteMain?.slice(0, 10)}`);
131
140
  ok("converged in place — local head == canonical main, no reconnect");
132
141
 
133
- console.log(`\nALL GREENtyped client: compile deploy connect typed write/query/mutate/derive/count → admit → converge → cloud reads (${WS})`);
142
+ // 11. WATCHthe local reactive read: watchById polls the LOCAL projection and fires
143
+ // the callback whenever the row changes (purely local — no network, no server push
144
+ // to wait on). A typed mutate lands; the watcher sees it.
145
+ const watched: Record<string, unknown>[] = [];
146
+ const stopWatch = holon.watchById(entryId, (rows) => { watched.push(rows[0]?.data ?? {}); }, { intervalMs: 50 });
147
+ await app.reactToEntry({ entryId, reactor: "carol", reaction: "wow" });
148
+ const sawReaction = async () => {
149
+ for (let i = 0; i < 100; i++) {
150
+ if (watched.some((dd) => (dd.reactions as Record<string, unknown> | undefined)?.carol === "wow")) return true;
151
+ await new Promise((res) => setTimeout(res, 50));
152
+ }
153
+ return false;
154
+ };
155
+ if (!(await sawReaction())) fail(`watchById never observed carol's reaction: ${JSON.stringify(watched.at(-1))}`);
156
+ stopWatch(); // the returned stop() ends the polling — no leaked timers
157
+ ok("watchById fires on change — typed mutate observed reactively (carol → wow), watcher stopped");
158
+ await holon.sync(); // ride carol's reaction to main so the ledger ends converged
159
+
160
+ // 12. DEAD-LETTER QUEUE — refused work is NEVER lost. The honest jam: a user's client
161
+ // on a FRESH workspace installs a domain LOCALLY (offline-first — the edge has no
162
+ // such law) and does real work under it. On sync the edge cannot certify that law:
163
+ // a DOMAIN rejection PARKS the intent — full provenance, durable, rides
164
+ // export()/restore — it does not vanish. (A directive whose domain isn't in YOUR
165
+ // law refuses at dispatch time, loudly — only committed-then-refused work parks.)
166
+ const WS2 = WS + "-dlq";
167
+ d = await (await fetch(`${CLOUD}/v1/workspaces/${WS2}`, { method: "POST", headers: { "x-nomos-principal": "nomos-e2e-suite" } })).json();
168
+ if (!d.ok) fail(`create DLQ workspace: ${JSON.stringify(d)}`);
169
+ const jammed = await connect({ cloud: CLOUD, workspace: WS2, clientId: "jammed-user" });
170
+ await jammed.dispatch("nomos", "installDomain", {
171
+ domainHash: GUESTBOOK_DOMAIN_HASH, packageUsda: deploy.packageUsda, installedBy: "local-user",
172
+ authorityScope: "workspace/root", dependencies: [], finalizers: [],
173
+ }, { domainHash: d.controller.domainHash });
174
+ await guestbookClient(jammed).signGuestbook({ author: "marta", message: "work I refuse to lose", mood: "happy", signedAt: new Date().toISOString() });
175
+ const js = await jammed.sync();
176
+ if (!(js.converged?.deadLettered ?? []).length) fail(`tenant write did not dead-letter: ${JSON.stringify(js.converged)}`);
177
+ const dlq = await jammed.deadLetters();
178
+ if (!(dlq.length === 1 && dlq[0]!.domain === "guestbook" && dlq[0]!.directiveId === "signGuestbook" && dlq[0]!.source === "edge")) {
179
+ fail(`DLQ provenance wrong: ${JSON.stringify(dlq)}`);
180
+ }
181
+ ok(`dead letter parked with provenance — ${dlq[0]!.domain}/${dlq[0]!.directiveId} refused by ${dlq[0]!.source}, work NOT lost`);
182
+ // THE RETRY LANE (not run here — see test/dlq.e2e.mjs in the nomos2 repo for the full
183
+ // unjam): the domain dev deploys the law fix (`POST /v1/workspaces/:ws/domains`, secret-
184
+ // gated), then `retryDeadLetter(id)` — or the owner's `POST /dead-letters/retry` — lands
185
+ // the parked write on main. Here the app makes the OTHER explicit choice:
186
+ const dropped = await jammed.discardDeadLetter(dlq[0]!.id ?? dlq[0]!.oid);
187
+ if (!(dropped.ok && dropped.removed === 1)) fail(`discard: ${JSON.stringify(dropped)}`);
188
+ if ((await jammed.deadLetters()).length !== 0) fail("DLQ not drained after discard");
189
+ ok("discardDeadLetter drains the queue — the APP's explicit choice, never the runtime's");
190
+
191
+ console.log(`\nALL GREEN — typed client: compile → deploy → connect → typed write/query/mutate/derive/count → admit → converge → watch → dead-letter custody → cloud reads (${WS})`);