create-holon 0.1.6 → 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 +1 -1
- package/template/README.md +27 -4
- package/template/docs/01-mental-model.md +57 -0
- package/template/docs/02-authoring.md +76 -0
- package/template/docs/03-client.md +78 -0
- package/template/docs/04-cloud.md +78 -0
- package/template/docs/05-superpowers.md +66 -0
- package/template/domains/guestbook.ts +1 -1
- package/template/package.json +3 -3
- package/template/test/e2e.mts +63 -8
package/package.json
CHANGED
package/template/README.md
CHANGED
|
@@ -61,9 +61,11 @@ The starter is the tutorial; this is the exact path from guestbook to your own l
|
|
|
61
61
|
```js
|
|
62
62
|
export default { name: "<pkg>", domains: [{ key: "<pkg>", modules: ["./domains/<yours>.ts"] }] };
|
|
63
63
|
```
|
|
64
|
-
**Naming convention
|
|
65
|
-
|
|
66
|
-
|
|
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`).
|
|
67
69
|
3. **Compile and read what you built:**
|
|
68
70
|
```bash
|
|
69
71
|
npx githolon compile && cat build/<pkg>.summary.txt
|
|
@@ -77,8 +79,27 @@ The starter is the tutorial; this is the exact path from guestbook to your own l
|
|
|
77
79
|
|
|
78
80
|
Then deploy it for real: `npx githolon login --agent && npx githolon ws create <ws> && npx githolon deploy <ws>`.
|
|
79
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
|
+
|
|
80
100
|
## What's here
|
|
81
101
|
|
|
102
|
+
- `docs/` — the five pages above; the offline reference.
|
|
82
103
|
- `domains/guestbook.ts` — the starter domain (the law): one aggregate, two
|
|
83
104
|
directives, a declared query, a derived field, a count. Rename it, reshape
|
|
84
105
|
it, or add domains with `npx githolon generate domain <name>`.
|
|
@@ -94,7 +115,9 @@ Then deploy it for real: `npx githolon login --agent && npx githolon ws create <
|
|
|
94
115
|
## Authoring rules (the engine enforces these)
|
|
95
116
|
|
|
96
117
|
- A directive's plan is a PURE function of `(payload, ports)` — timestamps ride
|
|
97
|
-
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).
|
|
98
121
|
- `create(Agg)` mints ids — `.creates` payloads never carry one.
|
|
99
122
|
- Merge drivers are the law: `Lww`, `AddWins`, `MapOf(Lww)`.
|
|
100
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)).
|
package/template/package.json
CHANGED
|
@@ -10,12 +10,12 @@
|
|
|
10
10
|
"typecheck": "tsc --noEmit"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@githolon/dsl": "^0.
|
|
14
|
-
"@githolon/client": "^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.
|
|
18
|
+
"githolon": "^0.2.0",
|
|
19
19
|
"@types/node": "^25.9.2",
|
|
20
20
|
"tsx": "^4.19.2",
|
|
21
21
|
"typescript": "^5.6.3"
|
package/template/test/e2e.mts
CHANGED
|
@@ -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";
|
|
@@ -70,11 +72,14 @@ if (!(entries.length === 1 && entries[0]!.data.message === "hello from the edge
|
|
|
70
72
|
const entryId = entries[0]!.id;
|
|
71
73
|
ok(`typed declared query routes — entriesByAuthor → ${entryId} (mood: ${entries[0]!.data.mood})`);
|
|
72
74
|
|
|
73
|
-
// 5. typed mutate + typed by-id read (SetEntry map-put folds locally)
|
|
74
|
-
|
|
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" });
|
|
75
79
|
const after = await app.guestbookEntryById(entryId);
|
|
76
80
|
if (after[0]!.data.reactions?.bob !== "love") fail(`typed mutate/read: ${JSON.stringify(after)}`);
|
|
77
|
-
|
|
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)");
|
|
78
83
|
|
|
79
84
|
// 5b. DERIVED read field: the engine projects moodEmoji from the law's pure fn
|
|
80
85
|
if (after[0]!.data.moodEmoji !== "🤩") fail(`derived moodEmoji not projected locally: ${JSON.stringify(after[0]!.data)}`);
|
|
@@ -88,8 +93,9 @@ ok("declared count maintained locally — entriesPerMood('delighted') = 1");
|
|
|
88
93
|
globalThis.fetch = realFetch;
|
|
89
94
|
ok("offline proof ENFORCED — fetch was trapped: local write/query/mutate/count touched no network");
|
|
90
95
|
|
|
91
|
-
// 6. sync: push the session branch + edge admission merges to main
|
|
92
|
-
|
|
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();
|
|
93
99
|
if (!s.admission?.ok) fail(`sync/admit: ${JSON.stringify(s)}`);
|
|
94
100
|
const admitted = (s.admission.sessions || []).flatMap((x: { admitted?: unknown[] }) => x.admitted || []);
|
|
95
101
|
if (admitted.length < 2) fail(`edge admitted ${admitted.length} intent(s), expected 2: ${JSON.stringify(s.admission)}`);
|
|
@@ -117,8 +123,8 @@ const taggerA = await connect({ cloud: CLOUD, workspace: WS, clientId: "tagger-a
|
|
|
117
123
|
const taggerB = await connect({ cloud: CLOUD, workspace: WS, clientId: "tagger-b" });
|
|
118
124
|
await guestbookClient(taggerA).tagEntry({ entryId, tags: ["vegan"] });
|
|
119
125
|
await guestbookClient(taggerB).tagEntry({ entryId, tags: ["gluten-free"] });
|
|
120
|
-
await taggerA.sync(
|
|
121
|
-
await taggerB.sync(
|
|
126
|
+
await taggerA.sync();
|
|
127
|
+
await taggerB.sync();
|
|
122
128
|
d = await (await fetch(`${CLOUD}/v1/workspaces/${WS}/aggregates/${encodeURIComponent(entryId)}`)).json();
|
|
123
129
|
const mergedTags: string[] = d.rows?.[0]?.data?.tags ?? [];
|
|
124
130
|
if (!(mergedTags.includes("vegan") && mergedTags.includes("gluten-free"))) fail(`AddWins union lost an add: ${JSON.stringify(mergedTags)}`);
|
|
@@ -133,4 +139,53 @@ const remoteMain = (refsAdv.match(/([0-9a-f]{40}) refs\/heads\/main/) || [])[1];
|
|
|
133
139
|
if (head !== remoteMain) fail(`not converged in place: local ${head?.slice(0, 10)} vs main ${remoteMain?.slice(0, 10)}`);
|
|
134
140
|
ok("converged in place — local head == canonical main, no reconnect");
|
|
135
141
|
|
|
136
|
-
|
|
142
|
+
// 11. WATCH — the 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})`);
|