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,213 +0,0 @@
|
|
|
1
|
-
# Birthing child workspaces (law-at-birth)
|
|
2
|
-
|
|
3
|
-
A directive in your law can **birth a child workspace** that is born already running its own domain law.
|
|
4
|
-
A home births project children; a platform births workspaces; root births platforms — all the **same one
|
|
5
|
-
way**. You do *not* ask Nomos for a new primitive and you do *not* hand a finished workspace to the cloud.
|
|
6
|
-
|
|
7
|
-
## How it works (birth is a kernel concern, defined by the ledger)
|
|
8
|
-
|
|
9
|
-
Your directive declares `.births()` and, in its `plan`, authors the child's **genesis recipe** — the
|
|
10
|
-
ordered intents the child will run — then calls `birth({ workspace, genesisChain })`. That's it. When the
|
|
11
|
-
parent's offer is admitted, the kernel's birth offer-effect:
|
|
12
|
-
|
|
13
|
-
1. spawns the child's (empty) custody,
|
|
14
|
-
2. **installs the child's law in-chain** and folds the genesis recipe **through the child's own gate**,
|
|
15
|
-
3. so the child **self-validates from intent 0** — it is not trusted, it proves itself.
|
|
16
|
-
|
|
17
|
-
The host installs nothing and decides nothing. The recipe is a **ledger fact**, not a runtime trick.
|
|
18
|
-
|
|
19
|
-
## The leaf recipe = two installs + your seed
|
|
20
|
-
|
|
21
|
-
A leaf child (a home, a project) is born with exactly:
|
|
22
|
-
|
|
23
|
-
```
|
|
24
|
-
[ bootstrap/installDomain(<frameworkHash>), // the bootstrap controller (bytes-by-hash, kernel-resolved)
|
|
25
|
-
nomos/installDomain(<lawHash>), // YOUR child's domain law package
|
|
26
|
-
<your own seed step(s)> ] // e.g. seed the owner / initial state
|
|
27
|
-
```
|
|
28
|
-
|
|
29
|
-
`frameworkHash` + `lawHash` are the content hashes of the two packages, pinned at compile time (the kernel
|
|
30
|
-
resolves the bytes from custody by hash — they are never shipped on the wire).
|
|
31
|
-
|
|
32
|
-
## Every birth is **keyed** — so you can find the workspace again
|
|
33
|
-
|
|
34
|
-
A workspace exists to be **used**, and using it — sync it, open it, add a task to it — means **finding it
|
|
35
|
-
again**, often from another device or after a restart. So a born workspace's name is **never** opaque or
|
|
36
|
-
minted-and-forgotten: it is always **derived from a key you already own** (a business id like `projectId`, or
|
|
37
|
-
an owner uid). One verb: **`birth.keyed`**.
|
|
38
|
-
|
|
39
|
-
> **The one rule.** The name comes from a key you own → the client re-derives the same name from that same
|
|
40
|
-
> key with `deriveWorkspaceName(namespace, key)` → the workspace is addressable **forever, with zero
|
|
41
|
-
> persistence**. Same key ⇒ same workspace (re-birth is idempotent). You never hand-roll a naming scheme, and
|
|
42
|
-
> you never store a minted name to find it later.
|
|
43
|
-
|
|
44
|
-
```ts
|
|
45
|
-
import { directive, birth, create, z } from "@githolon/dsl";
|
|
46
|
-
|
|
47
|
-
export const birthProject = directive("birthProject")
|
|
48
|
-
.creates(ProjectBirth) // your lineage/audit row
|
|
49
|
-
.payload(z.object({
|
|
50
|
-
projectId: z.string().min(1), // THE KEY — an id you mint app-side; the workspace is FOR it
|
|
51
|
-
owner: z.string().min(1),
|
|
52
|
-
frameworkHash: z.string().min(1),
|
|
53
|
-
lawHash: z.string().min(1),
|
|
54
|
-
bornAt: z.string().min(1), // ISO-8601, caller-stamped (determinism)
|
|
55
|
-
}))
|
|
56
|
-
.plan((p) => {
|
|
57
|
-
const actor = "user:" + p.owner;
|
|
58
|
-
birth.keyed({
|
|
59
|
-
key: p.projectId, // ← the workspace name is derived from this
|
|
60
|
-
namespace: "project", // → project-<sha256(project ⏎ projectId)[:40]>
|
|
61
|
-
genesisChain: (ws) => [ // the builder receives the resolved name
|
|
62
|
-
{ domain: "bootstrap", directiveId: "installDomain", actor, payload: { domainHash: p.frameworkHash }, domainHash: p.frameworkHash, domainPackageB64: "" },
|
|
63
|
-
{ domain: "nomos", directiveId: "installDomain", actor, payload: { domainHash: p.lawHash }, domainHash: p.lawHash, domainPackageB64: "" },
|
|
64
|
-
{ domain: "project", directiveId: "seedProject", actor, payload: { projectId: p.projectId, workspaceName: ws, bornAt: p.bornAt } },
|
|
65
|
-
],
|
|
66
|
-
});
|
|
67
|
-
return [];
|
|
68
|
-
})
|
|
69
|
-
.births()
|
|
70
|
-
.requires("projectCreator");
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
Then in the app you address that project from just its id — **no lookup table, no persisted name**:
|
|
74
|
-
|
|
75
|
-
```dart
|
|
76
|
-
// Dart (nomos_client)
|
|
77
|
-
final ws = deriveWorkspaceName('project', projectId);
|
|
78
|
-
final session = await engine.open(ws); // open / sync / add tasks — any device, any time
|
|
79
|
-
```
|
|
80
|
-
```ts
|
|
81
|
-
// TS (@githolon/client)
|
|
82
|
-
const ws = await deriveWorkspaceName('project', projectId);
|
|
83
|
-
const holon = await connect({ cloud, workspace: ws, clientId });
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
`deriveWorkspaceName` computes the byte-identical name the law's `birth.keyed` produced (contract-tested
|
|
87
|
-
across the engine, Dart, and TS). This is the helper that replaces every hand-rolled
|
|
88
|
-
`projectWorkspaceForProjectId` — delete yours and use this.
|
|
89
|
-
|
|
90
|
-
### One-per-user things (a home): `birth.singleton`
|
|
91
|
-
|
|
92
|
-
A "singleton" is just a keyed birth whose key is a natural single key — one home per user, so `key = owner`.
|
|
93
|
-
Sugar over `birth.keyed`:
|
|
94
|
-
|
|
95
|
-
```ts
|
|
96
|
-
birth.singleton({ keyedBy: p.owner, namespace: "home", genesisChain: (home) => [ /* installs + seed */ ] });
|
|
97
|
-
// app: deriveWorkspaceName('home', owner)
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
> ⚠️ **`birth.instance` is deprecated — don't use it.** It mints a name you throw away, so the born
|
|
101
|
-
> workspace can only be reached via the birth result. There is no workspace you make and never want to find
|
|
102
|
-
> again. Always `birth.keyed` (or `birth.singleton`).
|
|
103
|
-
|
|
104
|
-
(Raw `birth({ workspace, genesisChain })` stays the low-level escape hatch for a name that is genuinely
|
|
105
|
-
externally-determined — but if you're deriving it from your own data, use `birth.keyed`.)
|
|
106
|
-
|
|
107
|
-
## From the app (Dart) — parentless, session-bound
|
|
108
|
-
|
|
109
|
-
The generated Dart client emits a typed method per `.births()` directive, bound to the SESSION whose
|
|
110
|
-
workspace IS the parent — no loose parent string, and the born child comes back as a followable ref:
|
|
111
|
-
|
|
112
|
-
```dart
|
|
113
|
-
final parentClient = await MyPlatformClient.bind(parentSession);
|
|
114
|
-
final birth = await parentClient.birthProjectChild(payload: order); // parent = the session workspace
|
|
115
|
-
final child = await engine.follow(birth.bornRefs.single); // mount the born child
|
|
116
|
-
final childClient = await ProjectClient.bind(child); // typed client on the child
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
(The old `parent:` parameter survives one minor version as a deprecated legacy-gateway shim; its verdict
|
|
120
|
-
rides `outcome.gatewayVerdict`.)
|
|
121
|
-
|
|
122
|
-
## Authority — who may birth
|
|
123
|
-
|
|
124
|
-
`.requires("creator")` gates the birth on a relation tuple. Grant the home owner `#creator` on their own
|
|
125
|
-
home (self-bound at birth, the same way the owner is seeded) so a verified owner can birth their project
|
|
126
|
-
children — and only their own. (Relations: `grant` / `writeTuple`, see `07-security.md`. Use whatever
|
|
127
|
-
relation models your policy; `creator` is the convention `birthChild` uses.)
|
|
128
|
-
|
|
129
|
-
## What NOT to do
|
|
130
|
-
|
|
131
|
-
- **Don't hand-roll the child's name** (`project-<sha256(owner)>`, or your own `projectWorkspaceForProjectId`).
|
|
132
|
-
`birth.keyed` owns the naming and `deriveWorkspaceName` gives the client the identical name — hand-rolling
|
|
133
|
-
either half is how the two drift and a workspace becomes unfindable.
|
|
134
|
-
- **Don't use `birth.instance`.** It's deprecated — it mints a name you can't re-derive, so the workspace is
|
|
135
|
-
addressable only via the birth result. Every workspace needs to be found again; birth it keyed.
|
|
136
|
-
- **Don't ask for a "child-birth primitive."** It already exists — `.births()` + `birth.keyed`. A home is a
|
|
137
|
-
first-class workspace; it births children exactly as root births platforms.
|
|
138
|
-
- **Don't hand-roll a second recipe *builder*** across your domains — keep the recipe inline (it's small)
|
|
139
|
-
or share one tiny helper *in your own code*; the framework deliberately keeps **one** birth primitive.
|
|
140
|
-
- **Don't put a clock/random in the plan** — `bornAt` rides the payload (the timestamp doctrine).
|
|
141
|
-
|
|
142
|
-
## Prove it — the 30-second loop
|
|
143
|
-
|
|
144
|
-
Every compile validates your genesis recipe statically (child domain composed in the package? seed
|
|
145
|
-
directive exists? recipe payload satisfies its schema? every plan returns an **array** of ops) and prints
|
|
146
|
-
any problem as a `⚠ births` warning **containing the fix**. Then:
|
|
147
|
-
|
|
148
|
-
```bash
|
|
149
|
-
npx githolon compile # ⚠ births warnings = fix these first (the message says how)
|
|
150
|
-
npx githolon proof # OFFLINE: drives your .births() directive on the real kernel, folds the
|
|
151
|
-
# child through its OWN gate, reads the seed back, verify_chain green
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
A red proof names the exact genesis step the child's gate refused. The most common first-timer halt: a
|
|
155
|
-
plan that returns a fluent builder — builders record themselves, so end the plan with `return [];`.
|
|
156
|
-
A complete working two-domain births package to copy: `bench/scale/fixtures/home-delegated-estate` (nomos2
|
|
157
|
-
repo) — it births child workspaces with `birth.keyed` (addressable by key) and is exercised by
|
|
158
|
-
`bench/scale/home_delegated_estate.smoke.mjs`.
|
|
159
|
-
|
|
160
|
-
## Posture at birth
|
|
161
|
-
|
|
162
|
-
`birthChild` / `createWorkspace` take an optional `posture: "managed" | "sovereign"` (default **managed**)
|
|
163
|
-
— the child's declared governance posture, mirrored onto its lineage record. A **Managed** child can be
|
|
164
|
-
recovered by the operator on request (a rebirth heal = "ask us"); a **Sovereign** child's rebirth requires
|
|
165
|
-
the child's own consent fact first (a heal = consent first). See
|
|
166
|
-
[11-governance-postures.md](./11-governance-postures.md) for the two postures, the consent ceremony, and
|
|
167
|
-
the exit rights.
|
|
168
|
-
|
|
169
|
-
## Offline workspace creation via delegated authority (`delegatedRole` / `.delegatedFrom(parent)`)
|
|
170
|
-
|
|
171
|
-
Sometimes a CHILD must birth its own children **offline**, under authority a PARENT granted — with no
|
|
172
|
-
contact with the parent at authoring time. Example: a platform **P** lets a user **U** create projects; U
|
|
173
|
-
holds a **home** holon **H** on-device; U births a project **E** as a child of H, offline, gated by H's
|
|
174
|
-
law. The parent's grant cannot be an author-time attested read (that needs connectivity). Instead the
|
|
175
|
-
grant is **delivered into H as a signed cert and folded as a local authority fact** — then read offline.
|
|
176
|
-
|
|
177
|
-
Author the delivery directive with `.delegatedRole(relation).delegatedFrom(parent)`:
|
|
178
|
-
|
|
179
|
-
```ts
|
|
180
|
-
export const acceptDelegatedRole = directive("acceptDelegatedRole")
|
|
181
|
-
.creates(ProjectCreatorGrant)
|
|
182
|
-
.payload(z.object({ subject: roleSubject, delegationCert: z.string().min(1), grantedBy: roleSubject, grantedAt: z.string() }))
|
|
183
|
-
.plan((p) => {
|
|
184
|
-
create(ProjectCreatorGrant).set("subject", p.subject).set("grantedAt", p.grantedAt);
|
|
185
|
-
return writeTuple({ object: "workspace:self", relation: "projectCreator", subject: p.subject, grantedBy: p.grantedBy, grantedAt: p.grantedAt });
|
|
186
|
-
})
|
|
187
|
-
.grants("projectCreator")
|
|
188
|
-
.delegatedRole("projectCreator", { subjectField: "subject", certField: "delegationCert" })
|
|
189
|
-
.delegatedFrom({ parentKeyObject: "birth-attestation:self", parentKeyField: "parentKey" }); // H's parent P's key
|
|
190
|
-
|
|
191
|
-
// the birth itself is gated on the DELIVERED local tuple — read OFFLINE, no parent contact:
|
|
192
|
-
export const birthProjectWorkspace = directive("birthProjectWorkspace")
|
|
193
|
-
.creates(ProjectBirthOrder).payload(/* … */).plan(/* birth(...) */)
|
|
194
|
-
.births()
|
|
195
|
-
.requires("projectCreator"); // ← reads the delegated tuple folded by acceptDelegatedRole
|
|
196
|
-
```
|
|
197
|
-
|
|
198
|
-
How it works:
|
|
199
|
-
- **The one gate re-verifies P's grant on every lane** (author / edge admission / verify_chain): the
|
|
200
|
-
carried `DelegationCert` must be signed by a grantor whose key chains to the pinned `K_root`, bind the
|
|
201
|
-
same `(relation, subject)` the tuple writes, and — `.delegatedFrom(parent)` — whose grantor key IS this
|
|
202
|
-
workspace's parent key. A forged / mis-bound / wrong-parent grant fails closed. This is the
|
|
203
|
-
`nomosDelegationGate` era key — a law that declares no `.delegatedRole()` is byte-identical.
|
|
204
|
-
- **The birth is authored OFFLINE**, gated only by H's LOCAL folded tuple; `verify_chain` re-verifies the
|
|
205
|
-
delegation to `K_root` with no cross-workspace read.
|
|
206
|
-
- **Revocation is forward-only + evidence-preserving.** P delivers a revoke (`removeTuple`, same gate);
|
|
207
|
-
the folded tuple flips revoked (`RemoveWins`), so U's NEXT birth refuses — but a project born WHILE the
|
|
208
|
-
grant was live re-verifies green forever (verify_chain re-folds the chain prefix at each intent's
|
|
209
|
-
position). Both the grant and the revoke are permanent ledger facts.
|
|
210
|
-
|
|
211
|
-
The platform produces the cert with the `delegation_cert_sign` holon op (grantor secret injected per call,
|
|
212
|
-
never persisted), carrying its lineage to `K_root`; the bailiff delivers it host-free. Worked end-to-end:
|
|
213
|
-
`bench/scale/home_delegated_estate.smoke.mjs`; doctrine: `architecture/delegated_authority_births.md`.
|
|
@@ -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.
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
# 10 — Flutter clients: layered packages + humane names
|
|
2
|
-
|
|
3
|
-
`nomos-compile` can emit your generated Dart as **three sibling pub packages** instead of one
|
|
4
|
-
flat tree, so a Flutter app can enforce a layered architecture with the package graph itself:
|
|
5
|
-
|
|
6
|
-
```js
|
|
7
|
-
// nomos.package.mjs
|
|
8
|
-
export default {
|
|
9
|
-
name: "acme",
|
|
10
|
-
domains: [ /* … */ ],
|
|
11
|
-
dart: {
|
|
12
|
-
out: "build/dart",
|
|
13
|
-
packages: {
|
|
14
|
-
types: "acme_nomos_types", // pure data — frontend-safe
|
|
15
|
-
client: "acme_nomos_client", // effectful runtime clients
|
|
16
|
-
test: "acme_nomos_test", // headless harness — dev_dependencies only
|
|
17
|
-
},
|
|
18
|
-
names: {
|
|
19
|
-
// public aliases over the stable generated identities (typedefs in the types barrel)
|
|
20
|
-
AcmeProjectCustomFieldDefinitionFieldType: "FieldType",
|
|
21
|
-
AcmeProjectDrawingTransform2d: "DrawingTransform2d",
|
|
22
|
-
},
|
|
23
|
-
},
|
|
24
|
-
};
|
|
25
|
-
```
|
|
26
|
-
|
|
27
|
-
## The layering line
|
|
28
|
-
|
|
29
|
-
**Don't ban generated types from UI; ban generated clients/runtime from UI.**
|
|
30
|
-
|
|
31
|
-
```text
|
|
32
|
-
your_frontend -> acme_nomos_types (payloads, read models, VOs/enums,
|
|
33
|
-
typed watch/read accessors, aggregate meta)
|
|
34
|
-
your_composition -> acme_nomos_client + types (offer methods over NomosBridge,
|
|
35
|
-
sessions, sync, workspace lifecycle)
|
|
36
|
-
apps/your_app -> your_frontend + your_composition
|
|
37
|
-
widget/e2e tests -> acme_nomos_test (dev_dependencies only)
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
- `types` depends ONLY on the pure `nomos_types` core — no bridge, no sessions, no lifecycle.
|
|
41
|
-
A type-only law change reaches the UI; a client/runtime change does not.
|
|
42
|
-
- `client` depends on the types package + `nomos_client`. It re-exports the types barrel, so
|
|
43
|
-
composition code needs one import.
|
|
44
|
-
- `test` re-exports the headless harness (`package:nomos_client/testing.dart`) plus the full
|
|
45
|
-
client layer.
|
|
46
|
-
|
|
47
|
-
The generated `watch*/read*` accessors live in **types** on purpose: they are pure typed
|
|
48
|
-
wrappers over an injected `NomosReads` seam (plain function fields) — the UI can render live
|
|
49
|
-
data without ever seeing the runtime. Your composition layer constructs the one `NomosReads`
|
|
50
|
-
from its bridge and hands it down.
|
|
51
|
-
|
|
52
|
-
## What is generated-owned vs yours
|
|
53
|
-
|
|
54
|
-
- `lib/src/generated/**` and each package barrel (`lib/<pkg>.dart`) are **generated-owned** —
|
|
55
|
-
wiped and rewritten on every compile. Never hand-edit them.
|
|
56
|
-
- Each `pubspec.yaml` is scaffolded **only if missing** — you own versions, publishing
|
|
57
|
-
(`publish_to`), and any extra dependencies. Version the three packages independently: that
|
|
58
|
-
independence IS the blast-radius win; releasing them in lockstep rebuilds the god artifact
|
|
59
|
-
with more pubspecs.
|
|
60
|
-
|
|
61
|
-
## Aliases (`dart.names`)
|
|
62
|
-
|
|
63
|
-
Generated names are fully qualified (`<Domain><Aggregate><Field>…`) because identity must be
|
|
64
|
-
collision-free across your whole law. The `names` map layers humane public names on top as
|
|
65
|
-
`typedef` aliases in the types barrel. Validation is fail-closed at compile: the generated
|
|
66
|
-
name must exist, and an alias may not collide with any generated public name or another alias.
|
|
67
|
-
The compile summary prints long generated names that still lack an alias.
|
|
68
|
-
|
|
69
|
-
Renaming an alias is free (it's a label over a stable identity — the law and the wire never
|
|
70
|
-
see it). `names` works in the single-package emit too.
|
|
71
|
-
|
|
72
|
-
## Notes
|
|
73
|
-
|
|
74
|
-
- `dart.packages` implies `support: "package"` (pub dependencies on `nomos_types` /
|
|
75
|
-
`nomos_client`); combining it with `support: "vendored"` is a named compile refusal.
|
|
76
|
-
- The scaffolded client/test pubspecs wire the sibling dependency as a `path:` (they are
|
|
77
|
-
co-emitted); replace with hosted constraints when you publish.
|
|
78
|
-
- Types-file purity is enforced by the compiler, not convention: a generated types file that
|
|
79
|
-
imported anything beyond the `nomos_types` core would refuse the compile.
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
# Governance postures — Managed and Sovereign
|
|
2
|
-
|
|
3
|
-
Every platform you run on Nomos Cloud **declares a governance posture**. It decides one thing: **may the
|
|
4
|
-
operator (the root custodian) run recovery verbs — rebirth, certifyEra, repairs — FOR your workspace
|
|
5
|
-
without asking you first?** Both postures are first-class; you choose, and you can change your mind.
|
|
6
|
-
|
|
7
|
-
The honest foundation, stated plainly and never hidden:
|
|
8
|
-
|
|
9
|
-
- **Validity sovereignty is absolute for everyone.** Holons self-verify. The operator can *act* but can
|
|
10
|
-
never *lie* — every operator act is a signed, ordered, permanent fact in root's replayable chain.
|
|
11
|
-
- **Custody power is irreducibly the host's while you are hosted.** No cryptography stops a landlord
|
|
12
|
-
changing the locks. What Nomos gives you is **detectability** (a custody break fails closed) and
|
|
13
|
-
**exit** (clone the ledger and leave — the byte-identical wasm makes self-hosting the ultimate exit).
|
|
14
|
-
- Therefore **hosted sovereignty = law-encoded restraint + total detectability + credible exit.** Never
|
|
15
|
-
prevention.
|
|
16
|
-
|
|
17
|
-
## Posture A — Managed (the default)
|
|
18
|
-
|
|
19
|
-
Your platform's law folds an **OperatorGrant**: the root custodian may run recovery verbs for you — each
|
|
20
|
-
a signed, attributed law fact + certificate you audit forever. This is *the assurance of being able to
|
|
21
|
-
ask us for help*: if your chain scars unrecoverably, we can rebirth it for you without a ceremony.
|
|
22
|
-
|
|
23
|
-
A workspace with **no posture fact at all is Managed** — that is the default, and nothing changes for a
|
|
24
|
-
platform that never touches postures.
|
|
25
|
-
|
|
26
|
-
## Posture B — Sovereign
|
|
27
|
-
|
|
28
|
-
No OperatorGrant. Your founding key is terminal for your subtree's law. **The teeth:** root's
|
|
29
|
-
`rebirthWorkspace` REFUSES for a Sovereign workspace unless the offer carries **your consent** — a
|
|
30
|
-
`RebirthConsent` fact folded in **your own chain**, signed by your own admin, in your own history. The
|
|
31
|
-
two-key ceremony: the custody act cannot move without BOTH root's law fact AND your consent fact.
|
|
32
|
-
Operator restraint becomes law that replays, not policy you have to trust.
|
|
33
|
-
|
|
34
|
-
### The honest ceiling (read this)
|
|
35
|
-
|
|
36
|
-
> The operator controls root's law and could deploy law removing the consent check — but that act is
|
|
37
|
-
> itself a signed, ordered, permanent fact in root's chain, visible to every tenant running `replay root`,
|
|
38
|
-
> and grounds for exit-with-everything. Not "we can't" — "we can't secretly, and you can always leave."
|
|
39
|
-
|
|
40
|
-
## Declaring and changing your posture
|
|
41
|
-
|
|
42
|
-
Posture is **law-state in your own chain** (you own the fact), mirrored onto root's lineage record so the
|
|
43
|
-
bailiff can consult it on the hot path without reading every tenant chain. **The tenant chain is
|
|
44
|
-
authoritative — a mirror mismatch fails closed to Sovereign.**
|
|
45
|
-
|
|
46
|
-
```bash
|
|
47
|
-
# see the current posture, the consent state, and exactly what it means
|
|
48
|
-
githolon posture <platform>
|
|
49
|
-
|
|
50
|
-
# withdraw the operator grant (Managed → Sovereign) — forward-only; help already given stays in history
|
|
51
|
-
githolon posture set <platform> sovereign --reason "we run our own recovery from here"
|
|
52
|
-
|
|
53
|
-
# re-invite help (Sovereign → Managed)
|
|
54
|
-
githolon posture set <platform> managed --reason "hand recovery back to the operator"
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
You can also declare a posture **at birth**: `birthChild` / `createWorkspace` take an optional
|
|
58
|
-
`posture: "managed" | "sovereign"` (default managed), mirrored onto the child's lineage record.
|
|
59
|
-
|
|
60
|
-
## The consent ceremony (Sovereign rebirth)
|
|
61
|
-
|
|
62
|
-
When a Sovereign workspace needs a rebirth, the operator plans it but **cannot proceed until you consent
|
|
63
|
-
in your own chain**:
|
|
64
|
-
|
|
65
|
-
```bash
|
|
66
|
-
# operator: sees the block up front
|
|
67
|
-
githolon rebirth plan <ws> # GOVERNANCE POSTURE → SOVEREIGN, consent ABSENT → refused pre-seed
|
|
68
|
-
|
|
69
|
-
# tenant: fold the consent fact in YOUR OWN chain (signed by your admin)
|
|
70
|
-
githolon consent rebirth <ws> --reason "authorising the reborn incarnation" [--expires-at <epoch-ms>]
|
|
71
|
-
|
|
72
|
-
# operator: now the two-key ceremony is satisfied — the rebirth admits
|
|
73
|
-
githolon rebirth apply --plan rebirths/<date>-<ws>/plan.json --gcp-secret nomos-root-custodian-key
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
Without a live consent fact, both the CLI (`rebirth apply`) and the custody act itself (a `428` refusal)
|
|
77
|
-
decline and name the exact `githolon consent rebirth` command you need. Consent is forward-only and may
|
|
78
|
-
carry an `expiresAt` window.
|
|
79
|
-
|
|
80
|
-
## Recursion and exit
|
|
81
|
-
|
|
82
|
-
A platform is root-for-its-subtree: it offers **its** children the same postures with its founding key in
|
|
83
|
-
the operator role. **Custody does not nest** — a Sovereign sub-platform still rides the grandparent's
|
|
84
|
-
bailiff; true custody independence is self-hosting (clone the ledger, run the byte-identical wasm
|
|
85
|
-
yourself). No level ever rebirths itself; root operations stay the one extra-legal act, always named in
|
|
86
|
-
CLI output.
|
|
87
|
-
|
|
88
|
-
See also: [08-births.md](./08-births.md) (a Managed heal = "ask us"; a Sovereign heal = consent first) and
|
|
89
|
-
the ratified spec `architecture/governance_postures.md`.
|
package/template/test/e2e.mts
DELETED
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
// E2E: the FULL loop against a LIVE Nomos Cloud, driven through the GENERATED TYPED
|
|
2
|
-
// CLIENT (`build/__APP_NAME__.client.ts` — emitted by githolon compile):
|
|
3
|
-
//
|
|
4
|
-
// compile → create workspace → deploy (package + manifest overlay, ONE POST) →
|
|
5
|
-
// web client connects → TYPED createList + addTodo LOCALLY (offline writes) →
|
|
6
|
-
// sync + edge admission → the CLOUD's declared query + count return the work.
|
|
7
|
-
//
|
|
8
|
-
// The docs travel with you, offline: docs/01-mental-model.md first, then
|
|
9
|
-
// docs/03-client.md explains every call here. This file is the NARRATED
|
|
10
|
-
// walkthrough you reshape by hand; its GENERATED sibling — build/__APP_NAME__.proof.mts,
|
|
11
|
-
// emitted by every compile from your own law — needs no rewriting: `githolon proof`.
|
|
12
|
-
//
|
|
13
|
-
// The OFFLINE convergence proof (two peers, blind edits, byte-identical sync, no cloud)
|
|
14
|
-
// lives in test/convergence.local.mjs: node test/convergence.local.mjs
|
|
15
|
-
//
|
|
16
|
-
// Run from this directory AFTER `npx githolon compile`: npx tsx test/e2e.mts
|
|
17
|
-
import { readFileSync } from "node:fs";
|
|
18
|
-
import { connect } from "@githolon/client";
|
|
19
|
-
import { todoClient, __APP_HASH__ } from "../build/__APP_NAME__.client.ts";
|
|
20
|
-
|
|
21
|
-
const CLOUD = (process.env.NOMOS_CLOUD || "https://nomos.captainapp.co.uk").replace(/\/+$/, "");
|
|
22
|
-
const WS = process.env.NOMOS_WS || "todo-e2e-" + Math.random().toString(36).slice(2, 8);
|
|
23
|
-
|
|
24
|
-
// explicit annotation on the const so TS's never-call narrowing applies after fail(...)
|
|
25
|
-
// IDENTITY NOTE: this proof uses the bare x-nomos-principal lane so the test is
|
|
26
|
-
// SELF-CONTAINED (a throwaway workspace, no stored credentials). Real apps use
|
|
27
|
-
// `githolon login --agent` (a VERIFIED identity) + `githolon ws create/deploy`.
|
|
28
|
-
const fail: (m: string) => never = (m) => { console.error("✗ " + m); process.exit(1); };
|
|
29
|
-
const ok = (m: string) => console.log("✓ " + m);
|
|
30
|
-
|
|
31
|
-
const deploy = JSON.parse(readFileSync(new URL("../build/__APP_NAME__.deploy.json", import.meta.url), "utf8"));
|
|
32
|
-
|
|
33
|
-
// 1. create the workspace (capture the ONE-TIME secret)
|
|
34
|
-
let r = await fetch(`${CLOUD}/v1/workspaces/${WS}`, { method: "POST", headers: { "x-nomos-principal": "nomos-e2e-suite" } });
|
|
35
|
-
let d = await r.json();
|
|
36
|
-
if (!d.ok) fail(`create workspace: ${JSON.stringify(d)}`);
|
|
37
|
-
const SECRET: string = d.workspaceSecret;
|
|
38
|
-
if (!SECRET?.startsWith("nws_v1_")) fail(`no workspaceSecret returned: ${JSON.stringify(d)}`);
|
|
39
|
-
ok(`workspace ${WS} created — controller ${d.controller.domainHash.slice(0, 12)}…`);
|
|
40
|
-
|
|
41
|
-
// ownership: a deploy WITHOUT the secret must be refused
|
|
42
|
-
r = await fetch(`${CLOUD}/v1/workspaces/${WS}/domains`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(deploy) });
|
|
43
|
-
if (r.status !== 401) fail(`unauthenticated deploy was not refused (got ${r.status})`);
|
|
44
|
-
ok("ownership — deploy without the workspace secret refused (401)");
|
|
45
|
-
|
|
46
|
-
// deploy WITH the secret. The generated client's baked hash IS the deployed law's hash.
|
|
47
|
-
r = await fetch(`${CLOUD}/v1/workspaces/${WS}/domains`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${SECRET}` }, body: JSON.stringify(deploy) });
|
|
48
|
-
d = await r.json();
|
|
49
|
-
if (!d.ok || d.installation?.[0]?.data?.["status.phase"] !== "Active") fail(`deploy: ${JSON.stringify(d)}`);
|
|
50
|
-
if (d.domainHash !== __APP_HASH__) fail(`deploy hash ${d.domainHash} != generated client's ${__APP_HASH__}`);
|
|
51
|
-
ok(`law deployed Active — ${__APP_HASH__.slice(0, 12)}… (baked into the typed client)`);
|
|
52
|
-
|
|
53
|
-
// 2. connect a web client (pulls wasm + manifests + ledger) and bind the typed surface
|
|
54
|
-
const holon = await connect({ cloud: CLOUD, workspace: WS, clientId: "owner-1" });
|
|
55
|
-
const app = todoClient(holon);
|
|
56
|
-
ok(`web client connected — typed client bound (replica ${holon.replica.slice(0, 6)}…)`);
|
|
57
|
-
|
|
58
|
-
// 3. LOCAL-FIRST writes — ENFORCED offline (fetch is trapped; any network touch fails).
|
|
59
|
-
const realFetch = globalThis.fetch;
|
|
60
|
-
globalThis.fetch = (() => { throw new Error("OFFLINE PROOF VIOLATED: local authoring touched the network"); }) as typeof fetch;
|
|
61
|
-
|
|
62
|
-
// A directive returns the new local HEAD (a string). Aggregate ids are MINTED by the
|
|
63
|
-
// engine — read them back from the declared query. `addTodo.listId` is a plain string
|
|
64
|
-
// the app chooses (the grouping key), so we don't need createList's minted id to wire
|
|
65
|
-
// todos to a list — we just key both on the same `listId`.
|
|
66
|
-
const listId = "list-launch-plan";
|
|
67
|
-
await app.createList({ title: "Launch plan" });
|
|
68
|
-
await app.addTodo({ listId, text: "Draft the deck" });
|
|
69
|
-
await app.addTodo({ listId, text: "Book the venue" });
|
|
70
|
-
ok("typed offline writes — createList + 2 addTodo, under the pulled law");
|
|
71
|
-
|
|
72
|
-
// the TYPED declared query routes LOCALLY; minted Todo ids come back on the rows
|
|
73
|
-
const seeded = await app.todosByList({ listId });
|
|
74
|
-
if (seeded.length !== 2) fail(`local query: expected 2 todos, got ${seeded.length}`);
|
|
75
|
-
const t1 = seeded.find((row) => row.data.text === "Draft the deck")!.id;
|
|
76
|
-
await app.toggleTodo({ todoId: t1, done: true }); // Lww boolean (t.bool)
|
|
77
|
-
await app.tagTodo({ todoId: t1, tags: ["urgent", "q3"] }); // AddWins set
|
|
78
|
-
ok(`read back a minted Todo id (${t1.slice(0, 16)}…) → toggled done + tagged`);
|
|
79
|
-
|
|
80
|
-
const localOpen = await app.openTodosByList(listId);
|
|
81
|
-
if (localOpen !== 1) fail(`local count: expected 1 open, got ${localOpen}`);
|
|
82
|
-
ok("local reads route — todosByList = 2, openTodosByList = 1 (2 todos − 1 done), no network");
|
|
83
|
-
|
|
84
|
-
globalThis.fetch = realFetch;
|
|
85
|
-
ok("offline proof ENFORCED — fetch was trapped: local write/query/count touched no network");
|
|
86
|
-
|
|
87
|
-
// 4. sync: push the session branch → edge admission merges to canonical main
|
|
88
|
-
const s = await holon.sync();
|
|
89
|
-
if (!s.admission?.ok) fail(`sync/admit: ${JSON.stringify(s)}`);
|
|
90
|
-
ok("synced — session branch admitted onto canonical main");
|
|
91
|
-
|
|
92
|
-
// 5. the CLOUD answers the declared query + the maintained count from canonical main
|
|
93
|
-
d = await (await fetch(`${CLOUD}/v1/workspaces/${WS}/query?queryId=todosByList¶ms=${encodeURIComponent(JSON.stringify({ listId }))}`)).json();
|
|
94
|
-
if (!(d.ok && d.rows.length === 2)) fail(`cloud query: ${JSON.stringify(d)}`);
|
|
95
|
-
ok("cloud query — todosByList returned both todos from canonical main");
|
|
96
|
-
d = await (await fetch(`${CLOUD}/v1/workspaces/${WS}/counts/openTodosByList?group=${encodeURIComponent(listId)}`)).json();
|
|
97
|
-
if (!(d.ok && d.count === 1)) fail(`cloud count: ${JSON.stringify(d)}`);
|
|
98
|
-
ok("cloud count — openTodosByList = 1 (the where-filtered O(1) tally, maintained)");
|
|
99
|
-
|
|
100
|
-
// 6. SHARING — one native Zanzibar tuple. Author it through the owner's secret-gated
|
|
101
|
-
// /author lane, then read it back from the cloud's audit query.
|
|
102
|
-
r = await fetch(`${CLOUD}/v1/workspaces/${WS}/author`, {
|
|
103
|
-
method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${SECRET}` },
|
|
104
|
-
body: JSON.stringify({ domain: "todo", directiveId: "shareList", payload: {
|
|
105
|
-
listId, userId: "bob", role: "editor", sharedBy: "owner-1", sharedAt: new Date().toISOString() } }),
|
|
106
|
-
});
|
|
107
|
-
d = await r.json();
|
|
108
|
-
if (!d.ok) fail(`shareList author: ${JSON.stringify(d)}`);
|
|
109
|
-
d = await (await fetch(`${CLOUD}/v1/workspaces/${WS}/query?queryId=relationTuplesByObject¶ms=${encodeURIComponent(JSON.stringify({ object: `TodoList:${listId}` }))}`)).json();
|
|
110
|
-
const editor = (d.rows ?? []).find((u: { data: { relation: string } }) => u.data.relation === "editor");
|
|
111
|
-
if (editor?.data?.subjects?.["user:bob"]?.status !== "active") fail(`sharing tuple not recorded: ${JSON.stringify(d)}`);
|
|
112
|
-
ok("sharing — TodoList#editor@user:bob recorded as a native relation tuple (active)");
|
|
113
|
-
|
|
114
|
-
console.log(`\nALL GREEN — typed client: compile → deploy → connect → typed offline writes → query/count → admit → cloud reads → native sharing (${WS})`);
|