create-githolon 0.99.0 → 0.100.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.
@@ -1,78 +0,0 @@
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
- **Deploy your law** (the cloud births the workspace by its parent's offer-effect):
23
-
24
- ```bash
25
- npx githolon ws create <ws> # birth; NO secret is minted — ownership is a law fact in the chain
26
- npx githolon deploy <ws> # POST build/<pkg>.deploy.json, authenticated as your principal
27
- ```
28
-
29
- A workspace is born ONLY by its PARENT authoring its birth (an offer-effect that records the
30
- child in the parent's own chain). There is no "push a repo to create a workspace" — pushing
31
- `refs/heads/main` to an unborn name is refused. You sync your work by pushing session branches
32
- (`refs/heads/session/<client-id>`) to an EXISTING workspace; child workspaces you author offline
33
- (e.g. an estate under a home in local custody) materialize on the cloud when you sync the parent.
34
-
35
- ## Ownership
36
-
37
- There is no host secret. Ownership is a LAW FACT bound IN THE CHAIN — the principal the parent's
38
- birth records as the owner (e.g. `HomeOwner.principal`). Born-ness derives from custody (the ledger
39
- has a `main`); `/boot` returns no secret. DEPLOY write-authority is judged by the
40
- KERNEL at the offer gate — you authenticate as a principal the law grants deploy
41
- authority (`x-nomos-auth`, or transitionally `x-nomos-principal`), not a Bearer
42
- secret. End-user apps carry no credential: reads and session pushes are open,
43
- judged by edge admission; the session lane structurally refuses control-plane
44
- intents. So law only ever enters through a deploy the kernel authorizes. There is
45
- nothing host-side to rotate (`/rotate-secret` is retired, 410); to re-own, re-birth
46
- via the upload lane with a new push password — or ask the operator.
47
-
48
- ## Dead-letter retry — the operator's unjam
49
-
50
- Domain-rejected intents land in the workspace DLQ with full provenance
51
- (`GET /v2/workspaces/<ws>/dead-letters`, owner-keyed). Ship the law fix via
52
- the deploy lane, then `POST /v2/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.
@@ -1,69 +0,0 @@
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
- ## Offline birth — the parent births the child, the cloud re-derives it on sync
20
-
21
- A workspace is born ONLY by its PARENT authoring its birth. You don't push a repo to create a
22
- workspace; you author the birth on a parent you hold, and the child materializes when the parent
23
- syncs (or when you offer the birth to the parent's edge online). A home you hold in local custody
24
- can birth an estate OFFLINE — no network — and the cloud materializes that estate, byte-identical,
25
- the moment the home syncs:
26
-
27
- ```bash
28
- cd holon
29
- npx githolon git remote <ws> # wire the remote + credential helper for THIS workspace
30
- git push nomos refs/heads/session/<id> # sync your writes (incl. any births you authored) to the edge
31
- ```
32
-
33
- The cloud replay-verifies every synced intent — contiguity, each intent re-admitted against the
34
- state folded from the chain prefix, every plan re-run and byte-compared — and re-derives your exact
35
- local verdict: same head, same plans. A birth intent's consequence is the child, materialized in
36
- cloud custody with an immutable `nomos-birth-record` on the parent (so the whole tree is walkable
37
- from root — no orphans). Pushing `refs/heads/main` to an unborn name is REFUSED: the pushed bytes
38
- buy nothing; a workspace comes to exist through its parent's law, not a push.
39
-
40
- Why this matters: authorship is `git log`, "does the cloud agree with my machine?" is a checkable
41
- equation, and every workspace's parentage is a verifiable ledger fact — no host says what exists.
42
-
43
- ## Byte-identical wasm everywhere
44
-
45
- One `wasm32-wasip1` artifact runs in the cloud edge, the heavy container tier,
46
- your browser, your terminal, and the e2e. The same law, the same fold, the
47
- same admission gate — so a verdict computed anywhere is the verdict
48
- everywhere. The e2e's final convergence check (local head == canonical main)
49
- is only meaningful because both sides ran the same bytes.
50
-
51
- ## Offline is enforced, not narrated
52
-
53
- `npm run e2e` doesn't claim offline-first — it traps `fetch` to THROW while it
54
- authors a write, routes a declared query, folds a mutate, projects a derived
55
- field, and reads a count, all locally. If any "local" path touched the
56
- network, the test fails. Then it restores fetch, syncs, and the cloud answers
57
- the same query. That trap is the honest definition of local-first: the network
58
- is for reconciliation only.
59
-
60
- ## What to do with all this
61
-
62
- - Demo without wifi. Author, query, watch — then sync when it's back.
63
- - Treat the cloud as replaceable custody. Your ledger clones with stock git;
64
- a holon minted from it locally verifies without asking anyone.
65
- - Audit by reading. Every commit is an intent envelope; `git log` in `holon/`
66
- is the complete, verifiable history of the workspace.
67
-
68
- Back to the [README](../README.md) — or start reshaping
69
- `domains/todo.ts` ([02-authoring.md](./02-authoring.md)).
@@ -1,166 +0,0 @@
1
- # Evolving law
2
-
3
- Your domain will change after real data exists. This page is the honest map of
4
- what each change actually does — every claim below is asserted by a passing
5
- test against the real wasm (`bench/scale/law_evolution.test.mjs` in the nomos2
6
- repo, "the proof" hereafter), not aspiration.
7
-
8
- First, the mental model: **you never modify a domain — you install a new law
9
- beside the old one.** Law is content-addressed (`sha256(package bytes)` IS the
10
- identity), so editing `domains/*.ts` and recompiling yields a NEW hash. Deploying
11
- it is the same `githolon deploy <ws>` / `POST /domains` you already used; the old
12
- law stays installed and Active on the chain. Recompile, and the regenerated
13
- typed client bakes the new hash — shipping it moves your writers to the new law.
14
-
15
- **The era rule** (your history is safe): every sealed intent is judged by the
16
- law that governed it at its position in the chain — old intents replay and
17
- verify under THEIR law, forever. Upgrading never invalidates history. *(Proof:
18
- the three-era chain cold-verifies green end to end; also
19
- `bench/scale/invariant_era.test.mjs`.)*
20
-
21
- ## The capability map
22
-
23
- | change | verdict | what really happens |
24
- |---|---|---|
25
- | add a field | **safe** | old rows simply lack it (every read-model field is optional) |
26
- | add a directive | **safe** | works on old rows the moment the new law installs |
27
- | add an aggregate / a domain | **safe** | the same deploy lane; nothing existing moves |
28
- | widen a payload (new optional zod key) | **safe** | old clients' payloads still validate |
29
- | deprecate a field (keep it, stop writing) | **safe** | old rows keep reading it |
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 |
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 |
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) |
36
- | rewrite what old intents meant | **never** | history is sealed and self-verifying — that is the point |
37
-
38
- ## Recipes
39
-
40
- ### Add a field, add a directive (the free moves)
41
-
42
- Add the field to the aggregate (`.optional()` if your `.creates` doesn't always
43
- set it), add the directive, recompile, deploy, ship the regenerated client.
44
- Old rows read fine without the field, and the new directive mutates old rows
45
- immediately. *(Proof: era-1 `alpha` gains `mood` via the era-2 `setMood`.)*
46
-
47
- ### Deprecate — don't delete
48
-
49
- To retire a field or directive, KEEP its declaration and stop using it. Removing
50
- a field's declaration orphans it from reads (see rename, next) even though the
51
- data stays in the chain. Removing a directive breaks any client still
52
- dispatching it. Deletion buys you nothing — law size is not data size.
53
-
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`.)*
120
-
121
- ### Old clients after a breaking change (the trap, now closed)
122
-
123
- After you deploy v2, a client still authoring under v1's hash is judged by the
124
- **era gate**: the gate resolves the LATEST install for the domain and re-runs
125
- the write's plan under it (the same judgment every cold replay — upload birth,
126
- deep-verify, any fresh peer — passes at that position):
127
-
128
- - if the new law re-derives the SAME bytes for that write (you didn't touch
129
- that directive), it admits and verifies green everywhere — *(proof: the
130
- unchanged `tagSite` under the old hash)*;
131
- - if the new law re-derives DIFFERENTLY (changed payload schema or plan), the
132
- write is a **typed era refusal** naming both law versions — it can no longer
133
- admit silently and poison the chain. At the author door the write stays in
134
- the client's hands; on the cloud sync lane the full intent parks in the
135
- dead-letter queue on both sides (work is never lost) — deploy a compatible
136
- law (or ship the regenerated client) and retry. *(Proof: the divergent
137
- old-hash write — typed refusal at the author door AND at edge admission; the
138
- chain stays cold-green.)*
139
-
140
- The discipline is still: **don't change the meaning of a directive old clients
141
- still dispatch** — the era gate turns the mistake from a silent ledger poison
142
- into a visible, recoverable refusal, but your users' offline writes will jam
143
- in the DLQ until you ship the fix. Either evolve additively (new directive
144
- names, widened payloads), or retire/redeploy all writers before deploying the
145
- breaking law. The generated client makes the move atomic per app release — the
146
- law hash is baked in at compile time.
147
-
148
- ### Dispatching ahead of the deploy
149
-
150
- A write under a law hash the workspace hasn't installed is a typed refusal
151
- (`no-active-installation`) at the gate — and on the cloud sync lane the full
152
- intent parks in the dead-letter queue on both sides, then replays with
153
- `retryDeadLetter` once the law is deployed (see [04-cloud.md](./04-cloud.md)).
154
- Deploy first, then ship the client.
155
-
156
- ### Sharded workspaces
157
-
158
- On a sharded (coordinator + shards) workspace you deploy ONCE, to the coordinator:
159
- the cloud fans the same deploy into every open shard
160
- (`/deploy-from-coordinator`, idempotent per package hash). Proven live in
161
- `cloud/web-client/test/heavy_coordinator.e2e.mjs`.
162
-
163
- Back to the [README](../README.md) — or the deep dive:
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).
@@ -1,195 +0,0 @@
1
- # Security: private reads & operator-blind encryption
2
-
3
- Two opt-in layers, both e2e-proven, both **era-ruled** — a domain that uses
4
- neither pays nothing, and a chain sealed before these features replays
5
- byte-identically forever. You add them field by field; nothing else changes.
6
-
7
- The throughline: **the githolon enforces its own access**. Not the host, not a
8
- server's `if (user.canRead)` — the same byte-identical wasm gate every peer runs.
9
- A read you aren't entitled to is refused by the holon itself, on every replica.
10
-
11
- ## Layer 1 — private aggregates: the holon gates its own reads
12
-
13
- Make a type private and a read touching it is served only to a principal the
14
- workspace **granted** the capability. `role ≡ cap` in v1 — the role you grant is
15
- the cap a read requires.
16
-
17
- ```ts
18
- import {
19
- aggregate, t, Lww, requires, userPermissionAggregate, grant, revoke, directive, query, z,
20
- } from "@githolon/dsl";
21
-
22
- // PUBLIC — anyone reads (the control: privacy must not change how public serves).
23
- export const Announcement = aggregate("Announcement", { text: t.string().merge(Lww) });
24
-
25
- // PRIVATE — a read of Note needs the `readNote` cap.
26
- export const Note = aggregate("Note",
27
- { owner: t.string().merge(Lww), body: t.string().merge(Lww) },
28
- { visibility: requires("readNote") });
29
-
30
- // The framework permission aggregate — EXPORT it, so grants fold into THIS chain
31
- // (the bindings the read gate reads). Forgetting this is the #1 mistake.
32
- export const UserPermission = userPermissionAggregate;
33
-
34
- // The owner bestows the cap on a reader. Authored through the gateway offer lane
35
- // (`POST /v2/workspaces/<ws>/grantReader`, authenticated as the owning principal) —
36
- // write-authority is kernel-judged at the offer gate; there is no host secret.
37
- export const grantReader = directive("grantReader").ensures(UserPermission)
38
- .payload(z.object({ principal: z.string(), grantedBy: z.string(), grantedAt: z.string() }))
39
- .plan((p) => grant({
40
- userId: p.principal, scope: ["workspace", "notes"], role: "readNote",
41
- grantedBy: p.grantedBy, grantedAt: p.grantedAt,
42
- }));
43
-
44
- // Revoke flips that same binding to status:revoked (forward-only, remove-wins).
45
- export const revokeReader = directive("revokeReader").ensures(UserPermission)
46
- .payload(z.object({
47
- principal: z.string(), grantedBy: z.string(), grantedAt: z.string(),
48
- revokedBy: z.string(), revokedAt: z.string(),
49
- }))
50
- .plan((p) => revoke({
51
- userId: p.principal, scope: ["workspace", "notes"], role: "readNote",
52
- grantedBy: p.grantedBy, grantedAt: p.grantedAt, revokedBy: p.revokedBy, revokedAt: p.revokedAt,
53
- }));
54
-
55
- export const notesByOwner = query("notesByOwner").key("owner").returns(Note); // inherits the gate
56
- ```
57
-
58
- How it behaves:
59
-
60
- - **Who decides.** The read gate runs in the wasm against the workspace's own
61
- folded role-bindings — never the host. Every peer (the cloud edge included)
62
- re-derives the same verdict.
63
- - **Two grains.** *Granular* reads (`query`, `queryById`) need the **specific**
64
- cap. *Bulk* reads — cloning `main`, `snapshot`, `count`, `sum` — need **any**
65
- declared cap and reveal everything (pulling `main` hands over the whole ledger;
66
- that's the deal). A fully public workspace clones openly, as before.
67
- - **Who you are.** A read carries the principal (an `x-nomos-auth` token, verified;
68
- or the transitional `x-nomos-principal`). No principal ⇒ a private read is
69
- refused, fail-closed.
70
- - **⚠️ A private workspace needs its OWN verifier (`setAuthProvider`).** The token is
71
- verified **against the workspace's own law-declared AuthProvider** — every workspace
72
- verifies for itself; there is no global verifier, and **a child does NOT inherit its
73
- parent's AuthProvider**. If a private workspace has **no** AuthProvider, a presented
74
- `x-nomos-auth` token cannot be verified and the read falls back to the **unverified**
75
- bare `x-nomos-principal` claim — i.e. anyone who *claims* to be the owner is served.
76
- So a workspace that holds private (`requires(cap)`) or `.encrypted()` data **must**
77
- `setAuthProvider` for itself (issuer + JWKS/keys + audience), authored by a principal
78
- the workspace's law authorizes. This is true for **every** private workspace — a
79
- platform, and each home/space/project born under it. Births do **not** seed it for you:
80
- the birth recipe installs law + seats the owner, but the AuthProvider is a separate
81
- `setAuthProvider` the new workspace's owner authors (you can copy the parent's config).
82
- Refusals are typed — a 403 on a bulk read (`/pack`, `/snapshot`, clone) names the
83
- premise (`no-authprovider-verifier` / `token-verification-failed` / `no-principal-presented`
84
- / `principal-lacks-workspace-read-cap`), the resolved principal, the required cap, and
85
- the workspace's issuer — so the app can tell "token expired" from "not granted."
86
- - **Revocation is honest.** Remove-wins: a revoked principal can never be
87
- re-granted by a replayed grant. The post-revoke read re-derives authority from
88
- the maintained projection — cost scales with the number of *identities*, not
89
- your data volume (revoking one reader over 10k private records stays fast).
90
-
91
- The owner grants through the gateway offer lane, authenticated as the owning
92
- principal (write-authority is kernel-judged at the offer gate — no host secret);
93
- your end-user app carries **no** credential — its writes are judged by edge
94
- admission, and it simply can't author a grant. Authority enters only through a
95
- principal the law authorizes.
96
-
97
- ## Layer 2 — encrypted fields: operator-blind, end-to-end
98
-
99
- Mark a field `.encrypted()` and its value is sealed **on the client** before it
100
- ever reaches the cloud. The operator stores and serves ciphertext; only a
101
- key-holder can read it. This is RFC 9420-style group keys mapped onto the ledger:
102
- per-scope keys, distributed as on-chain wrapped-key events, revocation as
103
- forward-only key rotation.
104
-
105
- ```ts
106
- import {
107
- aggregate, t, Lww, requires, directive, create, query, z,
108
- userPermissionAggregate, grant,
109
- KeyScope, WrappedKey, advanceKeyEpoch, wrapKey, wrappedKeysByRecipient,
110
- } from "@githolon/dsl";
111
-
112
- export const Note = aggregate("Note",
113
- { owner: t.string().merge(Lww), body: t.string().merge(Lww).encrypted() }, // body is E2E-encrypted
114
- { visibility: requires("readNote") }); // .encrypted() REQUIRES a private aggregate (the cap is the scope)
115
-
116
- export const UserPermission = userPermissionAggregate;
117
- export const KS = KeyScope; // the per-scope key head — export so it folds
118
- export const WK = WrappedKey; // the on-chain wrapped keys — export so it folds
119
- export const advanceEpoch = advanceKeyEpoch; // the key rotation — export so it routes
120
- export const wrap1 = wrapKey;
121
- export const wraps = wrappedKeysByRecipient;
122
-
123
- export const writeNote = directive("writeNote").creates(Note)
124
- .payload(z.object({ owner: z.string(), body: z.string() })) // `body` is the PRE-SEALED envelope
125
- .plan((p) => { create(Note).set("owner", p.owner).set("body", p.body); return []; });
126
-
127
- export const grantReader = directive("grantReader").ensures(UserPermission)
128
- .payload(z.object({ principal: z.string(), grantedBy: z.string(), grantedAt: z.string() }))
129
- .plan((p) => grant({ userId: p.principal, scope: ["workspace", "w"], role: "readNote",
130
- grantedBy: p.grantedBy, grantedAt: p.grantedAt }));
131
-
132
- export const notesByOwner = query("notesByOwner").key("owner").returns(Note);
133
- ```
134
-
135
- `.encrypted()` is only legal on a **private** (`requires(cap)`) aggregate — the
136
- cap *is* the encryption scope — and only on an **Lww-class** field (the value is
137
- a ciphertext envelope replaced wholesale). `githolon compile` refuses otherwise,
138
- naming the fix.
139
-
140
- ### The handshake (operator-blind)
141
-
142
- The cloud never sees a key or a plaintext. The dance:
143
-
144
- 1. **The reader makes a device key.** An X25519 keypair (`cryptoKeygen`); the
145
- secret stays on the device, the public key is published.
146
- 2. **The owner grants the cap** (`grantReader`) — opens the read gate *and* adds
147
- the reader to the scope's key roster.
148
- 3. **The owner advances the epoch** (`advanceKeyEpoch`), wrapping the scope key
149
- to every member's public key (computed client-side, `cryptoWrapKey`). The
150
- fan-out gate verifies the wraps cover exactly the surviving roster — no missing
151
- member (lost access), no extra (a leak).
152
- 4. **The author seals the field** — resolve the `(type, field)` stable ids from
153
- the law, `crypto_seal_field` with the scope key → an envelope, write the
154
- envelope as the field value.
155
- 5. **The reader unwraps and reads** — `cryptoUnwrapKey` with the device secret
156
- recovers the scope key; inject it on the read and the field comes back
157
- decrypted. Without the key it stays an opaque envelope.
158
-
159
- A standalone proof of the whole loop ships as a dev tool:
160
-
161
- ```bash
162
- githolon decrypt <ws> --as <principal> --secret <b64> --query notesByOwner
163
- ```
164
-
165
- It clones the ledger, unwraps that principal's scope keys from the on-chain
166
- wrapped keys, and runs the query **decrypted** — the operator-blind ledger is
167
- plaintext only to a key-holder, and this proves it as that holder.
168
-
169
- ### What to keep in mind
170
-
171
- - **Revocation is forward-only.** Removing a reader advances the epoch and
172
- re-wraps to the survivors; old ciphertext is *not* re-encrypted. A removed
173
- member can still read what they were already shown — "no new reads" is the only
174
- honest semantic over an append-only log.
175
- - **Metadata is public.** Who is a member, how many, ciphertext sizes, write
176
- timing, and any plaintext field you `count` / `sum` / index *by* — all visible.
177
- Encrypt the secret value, not the thing you group by.
178
- - **Renames are safe.** The envelope carries the field's stable id, so renaming
179
- an encrypted field (or its aggregate) keeps old ciphertext decryptable under the
180
- new label. A true removal or retype still needs the evolve-gate dispositions
181
- (see `06-law-evolution.md`).
182
- - **Never write plaintext into an encrypted field.** The gate refuses anything
183
- that isn't a valid envelope sealed to the declared slot.
184
- - **Ergonomics today.** The generated client types the payloads but does not yet
185
- seal/unwrap for you — drive the seal and the key injection through the holon's
186
- crypto ops (the reference flow is small; ask if you want a helper wired into the
187
- generated client).
188
-
189
- ## Defense in depth, not a panic button
190
-
191
- Per-scope keys mean a client only ever *holds* keys for scopes it may read — so
192
- leaking your own authorized view is harmless, and holding a key for data you were
193
- never granted simply can't happen. A device keystore (non-extractable WebCrypto on
194
- web, the OS keystore on native) is real hardening on top, but the load-bearing
195
- guarantee is the key scoping, not the enclave.
@@ -1,66 +0,0 @@
1
- # Birth: give a business thing its own life
2
-
3
- Use `birth(Aggregate, spec)` when a business thing should be independently durable, portable, and
4
- recoverable. Describe the business facts once; Nomos owns the workspace, identity proof, provenance,
5
- persistence, and recovery beneath it.
6
-
7
- ```ts
8
- import { Lww, aggregate, birth, t, z } from "@githolon/dsl";
9
-
10
- export const Project = aggregate("Project", {
11
- reference: t.string().merge(Lww),
12
- name: t.string().merge(Lww),
13
- country: t.string().merge(Lww),
14
- });
15
-
16
- export const project = birth(Project, {
17
- payload: z.object({
18
- reference: z.string().min(1),
19
- name: z.string().min(1),
20
- country: z.string().length(2),
21
- }),
22
- key: (facts) => facts.reference,
23
- plan: (project, facts) => project
24
- .set("reference", facts.reference)
25
- .set("name", facts.name)
26
- .set("country", facts.country),
27
- });
28
- ```
29
-
30
- The generated client has one birth method:
31
-
32
- ```ts
33
- const born = await app.birth({
34
- reference: "PROJECT-001",
35
- name: "Foundry retrofit",
36
- country: "GB",
37
- });
38
- ```
39
-
40
- `born` is the typed reference to the business root. The call returns only after the birth is committed
41
- locally and persistence has been requested from the bound Nomos session.
42
-
43
- That is the tenant contract. Do not create a birth directive, construct a child address, assemble setup
44
- steps, or expose framework hashes in application code. Those are framework implementation details.
45
-
46
- ## One root per domain
47
-
48
- A domain declares at most one business birth. This keeps the generated method unambiguous and makes the
49
- born aggregate the stable root of its workspace. Model other records beneath that root using ordinary
50
- aggregates and directives.
51
-
52
- ## Prove it locally
53
-
54
- ```bash
55
- npx githolon compile
56
- npx githolon proof
57
- ```
58
-
59
- The offline proof drives the real kernel. It checks that the child installs its law in-chain, authors the
60
- business root, verifies its full history, packs its custody, discards the resident copy, reopens from that
61
- custody, verifies again, and can still read the root.
62
-
63
- ## Framework compatibility
64
-
65
- The lower-level recipe APIs still exist for Nomos' built-in workspace laws and for older packages. They
66
- are not the tenant authoring surface. New business domains use `birth(Aggregate, spec)`.
@@ -1,87 +0,0 @@
1
- # 09 — Attested cross-workspace reads (slice B)
2
-
3
- Your law can gate a write on a FACT IN ANOTHER WORKSPACE — without ever trusting the host,
4
- and without any peer opening the foreign chain at replay. The source's kernel SIGNS the
5
- answer; your law declares whose signature it trusts; every door (author, edge admission,
6
- `verify_chain` forever) re-verifies the signed envelope.
7
-
8
- ## Declare the trust edge in law
9
-
10
- ```ts
11
- import { z, directive, read, trustSource, foreignQuery } from "@githolon/dsl";
12
-
13
- const platformPolicy = foreignQuery("platformPolicy"); // the SOURCE's declared query, by id
14
-
15
- // module level — the trust boundary IS law (rides the domainHash):
16
- export const platformTrust = trustSource("acme-platform", {
17
- genesis: "9d1c…", // the source's genesis oid (its true identity — names are routing)
18
- keyHashes: ["41aa…"], // sha256 of the source's read-attestor public key(s)
19
- maxAgeMs: 86_400_000, // how stale an envelope may be vs the intent's own clock
20
- minSourceEpoch: "birth",
21
- monotone: true, // refuse an answer older than one this chain already saw
22
- });
23
-
24
- export const enrollDevice = directive("enrollDevice")
25
- .creates(HomeDevice)
26
- .payload(z.object({ device: z.string() }))
27
- .readsFrom("acme-platform", platformPolicy) // the declared foreign read
28
- .plan((p) => {
29
- const rows = read(platformPolicy, { scope: "homes" }, { from: "acme-platform" });
30
- if (rows[0]?.data?.enrollmentOpen !== "true") throw new Error("enrollment closed");
31
- create(HomeDevice).set("device", p.device);
32
- return [];
33
- });
34
- ```
35
-
36
- ## Who fetches the evidence? THE AUTHOR — never the host
37
-
38
- The host never initiates a fetch (that would be orchestration — host-purge doctrine). Three
39
- lanes, in precedence order:
40
-
41
- 1. **Carried** — the offer already contains `attestations: [{queryId, result, attestation}]`.
42
- The gate judges the carried envelope; nothing ever replaces it.
43
- 2. **Colocated (kernel-internal)** — when the source workspace is mounted in the SAME engine
44
- instance (the container pool colocates workspaces), the engine plane serves + signs the
45
- read in-kernel and injects it. Zero HTTP. The envelope is byte-identical to the HTTP lane —
46
- colocation is invisible to the gate.
47
- 3. **Client-carried (`?attest=1`)** — otherwise the CLIENT pre-fetches from the source's open
48
- read lane and carries the envelope on the offer.
49
-
50
- ## `@githolon/client` does the dance for you
51
-
52
- ```ts
53
- const holon = await connect({ cloud, workspace, clientId,
54
- attestedSources: { "acme-platform": "https://nomos.captainapp.co.uk" } }); // optional override
55
- await holon.dispatch("home", "enrollDevice", { device: "d-1" });
56
- // → the kernel refuses TYPED naming the missing read; the client fetches
57
- // GET <sourceCloud>/v2/workspaces/acme-platform/platformPolicy?scope=homes&attest=1
58
- // injects the signed envelope, retries — automatically.
59
- ```
60
-
61
- **Offline:** if the source is unreachable, the offer FAILS with the named
62
- `attested-read-unreachable` error. There is NO stale fallback — a plan without its live
63
- premise cannot author. Retry when the source is reachable. You can also pre-fetch ahead of
64
- time (`holon.fetchAttestedRead(from, queryId, args)`) and pass
65
- `dispatch(..., { attestations: [entry] })`.
66
-
67
- ## The two-step dance for THIN clients (raw HTTP, no SDK)
68
-
69
- 1. `POST /v2/workspaces/<ws>/<directiveId>` with your payload. If the law declares foreign
70
- reads and your body carries no matching attestation, you get **422** with:
71
- `attested-read-unreachable: read('<queryId>', {from: '<source>'}) args={…} has no injected
72
- attestation — pre-fetch it from the source's open read lane (?attest=1 …)`.
73
- 2. `GET /v2/workspaces/<source>/<queryId>?<args>&attest=1` → `{ok, rows, attestation}` —
74
- the source KERNEL signed it; the host relayed opaque bytes. Retry the POST with
75
- `"attestations": [{"queryId": "<queryId>", "result": <rows>, "attestation": <attestation>}]`
76
- in the body (it rides beside your payload fields, not inside them).
77
-
78
- No server fills this in for you — the refusal is the kernel INSTRUCTING you what evidence to
79
- carry. That is the design, not a limitation: the author carries the premise; every replayer
80
- verifies the signature forever, offline.
81
-
82
- ## What the gate checks (every door, every peer, forever)
83
-
84
- declared edge ✓ → key hash ∈ your law's pins ∧ genesis matches ∧ signature verifies over the
85
- result ✓ → size bounds ✓ → freshness (`maxAgeMs` vs the intent's frozen clock, `minSourceEpoch`,
86
- the `monotone` frontier) ✓ → the plan re-runs with the replayed capture and byte-compares.
87
- A forged/tampered/stale/wrong-key envelope is a TYPED refusal. No peer ever contacts the source.