create-githolon 0.98.3 → 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.
- package/index.mjs +7 -11
- package/package.json +1 -1
- package/template/CLAUDE.md +9 -32
- package/template/README.md +51 -132
- package/template/domains/todo.ts +18 -70
- package/template/nomos.package.mjs +1 -7
- package/template/package.json +1 -2
- package/template/docs/01-mental-model.md +0 -57
- package/template/docs/02-authoring.md +0 -437
- package/template/docs/03-client.md +0 -166
- package/template/docs/04-cloud.md +0 -78
- package/template/docs/05-superpowers.md +0 -69
- package/template/docs/06-law-evolution.md +0 -166
- package/template/docs/07-security.md +0 -195
- package/template/docs/08-births.md +0 -213
- package/template/docs/09-attested-reads.md +0 -87
- package/template/docs/10-flutter-layering.md +0 -79
- package/template/docs/11-governance-postures.md +0 -89
- package/template/test/e2e.mts +0 -114
|
@@ -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.
|