create-githolon 0.93.0 → 0.94.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/docs/02-authoring.md +49 -48
- package/template/docs/03-client.md +6 -6
- package/template/docs/06-law-evolution.md +2 -2
- package/template/docs/07-security.md +16 -0
- package/template/docs/08-births.md +33 -33
- package/template/docs/09-attested-reads.md +5 -5
- package/template/docs/10-flutter-layering.md +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-githolon",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.94.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Scaffold a Nomos domain package: the starter domain + compile config + live e2e. `npm create githolon my-app`.",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
@@ -16,7 +16,8 @@ export const Book = aggregate("Book", {
|
|
|
16
16
|
});
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
Field kinds: `t.string()` · `t.int()` · `t.
|
|
19
|
+
Field kinds: `t.string()` · `t.int()` · `t.bool()` (a true/false flag —
|
|
20
|
+
count/filter key on it; NOT a 2-value `t.enum`) · `t.enum([...] as const)` (typos squeal
|
|
20
21
|
at compile time) · `t.set(t.string())` · `t.map(inner)` · `t.json()` /
|
|
21
22
|
`t.jsonObject()` (JSON-string leaf; `jsonObject` asserts an object and reads
|
|
22
23
|
back structured) · `t.ref(Agg)` (an id-valued reference) ·
|
|
@@ -75,20 +76,20 @@ const PostalAddress = valueObject("PostalAddress", {
|
|
|
75
76
|
streetAddress: z.string(), city: z.string(), countryCode: z.string(),
|
|
76
77
|
});
|
|
77
78
|
const GeoPoint = geo.point("GeoPoint"); // RFC 7946 Point
|
|
78
|
-
const
|
|
79
|
+
const TaskLocation = valueObject("TaskLocation", {
|
|
79
80
|
address: PostalAddress.optional(), // VOs nest in VOs
|
|
80
81
|
point: GeoPoint.optional(),
|
|
81
82
|
});
|
|
82
83
|
|
|
83
|
-
export const
|
|
84
|
+
export const ProjectTask = aggregate("ProjectTask", {
|
|
84
85
|
name: t.string().merge(Lww),
|
|
85
|
-
location:
|
|
86
|
+
location: TaskLocation.field().optional(), // ONE json-object leaf
|
|
86
87
|
point: GeoPoint.field().optional(), // give a geometry its OWN field…
|
|
87
88
|
});
|
|
88
|
-
export const
|
|
89
|
-
export const
|
|
90
|
-
.payload(z.object({ name: z.string(), location:
|
|
91
|
-
.plan((p) => { create(
|
|
89
|
+
export const tasksByBounds = spatial("tasksByBounds").of(ProjectTask).on("point"); // …to index it
|
|
90
|
+
export const recordTask = directive("recordTask").creates(ProjectTask)
|
|
91
|
+
.payload(z.object({ name: z.string(), location: TaskLocation.zod })) // the SAME zod validates
|
|
92
|
+
.plan((p) => { create(ProjectTask).set("name", p.name).set("location", p.location); return []; });
|
|
92
93
|
```
|
|
93
94
|
|
|
94
95
|
ONE definition yields the zod (payload validation — a malformed VO is the
|
|
@@ -128,29 +129,29 @@ no behavior:
|
|
|
128
129
|
|
|
129
130
|
```ts
|
|
130
131
|
// contracts.ts — the shared public shapes
|
|
131
|
-
export const
|
|
132
|
-
|
|
132
|
+
export const SpaceLabelContract = contract("SpaceLabel", {
|
|
133
|
+
labelId: t.string(),
|
|
133
134
|
name: t.string(),
|
|
134
135
|
});
|
|
135
136
|
|
|
136
|
-
//
|
|
137
|
+
// space.ts — the concrete owner declares it satisfies the shape (compile-checked;
|
|
137
138
|
// a missing/mismatched field fails the build naming it). Private fields stay private.
|
|
138
|
-
export const
|
|
139
|
-
|
|
139
|
+
export const SpaceLabel = aggregate("SpaceLabel", {
|
|
140
|
+
labelId: t.string().merge(Lww),
|
|
140
141
|
name: t.string().merge(Lww),
|
|
141
142
|
internalNotes: t.string().merge(Lww), // not in the contract — behind the boundary
|
|
142
|
-
}).implements(
|
|
143
|
+
}).implements(SpaceLabelContract);
|
|
143
144
|
|
|
144
|
-
//
|
|
145
|
-
export const
|
|
146
|
-
|
|
147
|
-
|
|
145
|
+
// project.ts — reference the CONTRACT, never the concrete aggregate
|
|
146
|
+
export const ProjectTask = aggregate("ProjectTask", {
|
|
147
|
+
spaceWorkspaceName: t.string().merge(Lww),
|
|
148
|
+
label: t.foreignRef({ workspace: "spaceWorkspaceName", target: SpaceLabelContract, domain: "space" }),
|
|
148
149
|
});
|
|
149
150
|
```
|
|
150
151
|
|
|
151
|
-
The generated client types the reference as `ForeignRef<
|
|
152
|
-
the Dart DTO class lives in a shared `contracts.dart` every domain imports, so `
|
|
153
|
-
never imports `
|
|
152
|
+
The generated client types the reference as `ForeignRef<SpaceLabelContract>`;
|
|
153
|
+
the Dart DTO class lives in a shared `contracts.dart` every domain imports, so `project`
|
|
154
|
+
never imports `space`'s implementation. If two domains ever import each other's
|
|
154
155
|
concrete types, the compiler fails with a cycle diagnostic pointing you here. Contracts
|
|
155
156
|
are pure typing metadata — they never change an aggregate's law hash.
|
|
156
157
|
|
|
@@ -161,9 +162,9 @@ each deliberately (the names below are the DEFAULTS, derived from your ids):
|
|
|
161
162
|
|
|
162
163
|
| tier | what it is | generated name |
|
|
163
164
|
|---|---|---|
|
|
164
|
-
| **Command payloads** | the typed input to a directive (`toPayloadJson()`) | `<Cap(directiveId)>Payload` — e.g. `
|
|
165
|
-
| **Aggregate state** | the concrete decoded aggregate class | `<Cap(aggId)>` — e.g. `
|
|
166
|
-
| **Read models / projections** | the typed projection row (incl. derived/combined fields) | `<stem(aggId)>ReadModel` — e.g. `
|
|
165
|
+
| **Command payloads** | the typed input to a directive (`toPayloadJson()`) | `<Cap(directiveId)>Payload` — e.g. `RecordTaskPayload` |
|
|
166
|
+
| **Aggregate state** | the concrete decoded aggregate class | `<Cap(aggId)>` — e.g. `ProjectTask` |
|
|
167
|
+
| **Read models / projections** | the typed projection row (incl. derived/combined fields) | `<stem(aggId)>ReadModel` — e.g. `ProjectTaskReadModel` |
|
|
167
168
|
| **Value objects** | a named structured value (`valueObject`/`geo.*`) | the VO's own declared name, unprefixed |
|
|
168
169
|
|
|
169
170
|
The wire identity (aggregate id, stable ids, projection type tag) is separate from ALL of
|
|
@@ -171,34 +172,34 @@ these — names are labels, identity is minted. Renaming below never touches the
|
|
|
171
172
|
|
|
172
173
|
### `.dartName()` — declare the product name (Dart codegen only)
|
|
173
174
|
|
|
174
|
-
An aggregate id is often long for uniqueness (`
|
|
175
|
-
is short (`
|
|
175
|
+
An aggregate id is often long for uniqueness (`ProjectTask`); the Dart product name you want
|
|
176
|
+
is short (`Task`). `.dartName("Task")` makes `Task` the PRIMARY generated Dart identity —
|
|
176
177
|
not a post-hoc alias:
|
|
177
178
|
|
|
178
179
|
```ts
|
|
179
|
-
export const
|
|
180
|
+
export const ProjectTask = aggregate("ProjectTask", {
|
|
180
181
|
owner: t.string().merge(Lww),
|
|
181
182
|
name: t.string().merge(Lww),
|
|
182
|
-
}).public().dartName("
|
|
183
|
+
}).public().dartName("Task");
|
|
183
184
|
```
|
|
184
185
|
|
|
185
|
-
Now EVERY Dart site renames consistently: the state class `
|
|
186
|
-
`
|
|
187
|
-
`
|
|
188
|
-
unchanged stable ids — the minted-id tag is still `
|
|
186
|
+
Now EVERY Dart site renames consistently: the state class `Task`, the read model
|
|
187
|
+
`TaskReadModel`, the typed id `TaskId`, the by-id accessors `readTaskById`/`watchTaskById`,
|
|
188
|
+
`listTasks`, and any `ForeignRef<Task>`. The WIRE id stays `ProjectTask` (unchanged law,
|
|
189
|
+
unchanged stable ids — the minted-id tag is still `ProjectTask_<uuid>`). It is validated
|
|
189
190
|
at author time (must be a Dart type identifier `^[A-Z][A-Za-z0-9_]*$`; a collision with
|
|
190
191
|
another aggregate's name fails the compile). **TS codegen is deliberately unaffected** —
|
|
191
|
-
the generated `.client.ts` stays id-derived (`
|
|
192
|
+
the generated `.client.ts` stays id-derived (`ProjectTaskData`), so there is no
|
|
192
193
|
cross-language identity confusion; `.dartName()` is a Dart-only presentation choice.
|
|
193
194
|
|
|
194
195
|
### Namespaces, not prefixes (multi-domain packages)
|
|
195
196
|
|
|
196
|
-
Each domain compiles to its OWN generated `.dart` library (`
|
|
197
|
-
That IS Dart's namespace mechanism: two domains can BOTH declare a `
|
|
198
|
-
unambiguous — `import 'package:x/src/generated/
|
|
197
|
+
Each domain compiles to its OWN generated `.dart` library (`project.dart`, `space.dart`).
|
|
198
|
+
That IS Dart's namespace mechanism: two domains can BOTH declare a `Comment` and stay
|
|
199
|
+
unambiguous — `import 'package:x/src/generated/project.dart' as project;` then `project.Comment`.
|
|
199
200
|
The compiler never smashes a domain word into your class name. The top-level package barrel
|
|
200
201
|
flat-re-exports every domain's classes by default (`dart.packages.barrelExports: 'all'`);
|
|
201
|
-
for a multi-domain package where you'd rather force prefixed imports (so two `
|
|
202
|
+
for a multi-domain package where you'd rather force prefixed imports (so two `Comment`s never
|
|
202
203
|
silently share one flat scope), set `barrelExports: 'explicit'` — the barrel then exports
|
|
203
204
|
only the shared core and you import each domain with an `as` prefix. A single-domain package
|
|
204
205
|
is unaffected either way.
|
|
@@ -215,24 +216,24 @@ generated name via `dart.names`, and route it to the compat package:
|
|
|
215
216
|
dart: {
|
|
216
217
|
support: "package",
|
|
217
218
|
packages: {
|
|
218
|
-
types: "
|
|
219
|
-
client: "
|
|
220
|
-
test: "
|
|
221
|
-
compat: "
|
|
219
|
+
types: "task_tracker_types",
|
|
220
|
+
client: "task_tracker_client",
|
|
221
|
+
test: "task_tracker_test",
|
|
222
|
+
compat: "task_tracker_compat_names", // ← the migration-only shim
|
|
222
223
|
},
|
|
223
|
-
//
|
|
224
|
-
// imported the old `
|
|
225
|
-
names: {
|
|
224
|
+
// ProjectTask.dartName("Task") → the read model is now `TaskReadModel`. Callers that
|
|
225
|
+
// imported the old `TaskRootAggregate` name keep compiling via the compat typedef.
|
|
226
|
+
names: { TaskReadModel: "TaskRootAggregate" },
|
|
226
227
|
}
|
|
227
228
|
```
|
|
228
229
|
|
|
229
|
-
The `
|
|
230
|
+
The `task_tracker_compat_names` package then contains exactly:
|
|
230
231
|
|
|
231
232
|
```dart
|
|
232
233
|
// MIGRATION-ONLY — delete this package once callers have moved to the real names in
|
|
233
|
-
// `
|
|
234
|
-
import 'package:
|
|
235
|
-
typedef
|
|
234
|
+
// `task_tracker_types`. …
|
|
235
|
+
import 'package:task_tracker_types/task_tracker_types.dart';
|
|
236
|
+
typedef TaskRootAggregate = TaskReadModel;
|
|
236
237
|
```
|
|
237
238
|
|
|
238
239
|
When `compat` is UNSET, the typedefs land in the types barrel exactly as before.
|
|
@@ -36,7 +36,7 @@ and the semantics live in the doc comments.
|
|
|
36
36
|
## Many workspaces at once (the normal shape)
|
|
37
37
|
|
|
38
38
|
Everything about Nomos is multi-workspace: a real app holds a dozen+ live
|
|
39
|
-
local workspaces (the user's home, each
|
|
39
|
+
local workspaces (the user's home, each space, each project). Don't build
|
|
40
40
|
on a single-connection assumption — hold one PLANE and many handles on it.
|
|
41
41
|
The four-layer split (the blessed shape):
|
|
42
42
|
|
|
@@ -50,7 +50,7 @@ The four-layer split (the blessed shape):
|
|
|
50
50
|
|
|
51
51
|
- **TS/JS**: `openRealm` mounts N workspaces in ONE engine; give each
|
|
52
52
|
generated client its own handle:
|
|
53
|
-
`const realm = await openRealm({ cloud }); const
|
|
53
|
+
`const realm = await openRealm({ cloud }); const project = projectClient(await realm.workspace("project-…"));`
|
|
54
54
|
(`connect()` remains the one-workspace sugar over the same machinery.)
|
|
55
55
|
- **Flutter/Dart** — session-bound clients, preflighted at construction:
|
|
56
56
|
|
|
@@ -61,13 +61,13 @@ The four-layer split (the blessed shape):
|
|
|
61
61
|
final homeClient = await HomeClient.bind(home); // ASYNC preflight (see below)
|
|
62
62
|
|
|
63
63
|
final platform = await engine.open(cloud: cloud, workspace: platformWs);
|
|
64
|
-
final platformClient = await
|
|
64
|
+
final platformClient = await PlatformClient.bind(platform);
|
|
65
65
|
|
|
66
66
|
// A `.births()` directive takes NO parent — the parent IS the session workspace:
|
|
67
|
-
final birth = await platformClient.
|
|
67
|
+
final birth = await platformClient.birthProjectWorkspace(payload: order);
|
|
68
68
|
// NomosRef is THE cross-workspace primitive — follow it, bind the next client:
|
|
69
|
-
final
|
|
70
|
-
final
|
|
69
|
+
final project = await engine.follow(birth.bornRefs.single);
|
|
70
|
+
final projectClient = await ProjectClient.bind(project);
|
|
71
71
|
```
|
|
72
72
|
|
|
73
73
|
`XClient.bind(session)` verifies the session's workspace is reachable, holds
|
|
@@ -153,9 +153,9 @@ intent parks in the dead-letter queue on both sides, then replays with
|
|
|
153
153
|
`retryDeadLetter` once the law is deployed (see [04-cloud.md](./04-cloud.md)).
|
|
154
154
|
Deploy first, then ship the client.
|
|
155
155
|
|
|
156
|
-
### Sharded
|
|
156
|
+
### Sharded workspaces
|
|
157
157
|
|
|
158
|
-
On a sharded (coordinator + shards)
|
|
158
|
+
On a sharded (coordinator + shards) workspace you deploy ONCE, to the coordinator:
|
|
159
159
|
the cloud fans the same deploy into every open shard
|
|
160
160
|
(`/deploy-from-coordinator`, idempotent per package hash). Proven live in
|
|
161
161
|
`cloud/web-client/test/heavy_coordinator.e2e.mjs`.
|
|
@@ -67,6 +67,22 @@ How it behaves:
|
|
|
67
67
|
- **Who you are.** A read carries the principal (an `x-nomos-auth` token, verified;
|
|
68
68
|
or the transitional `x-nomos-principal`). No principal ⇒ a private read is
|
|
69
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."
|
|
70
86
|
- **Revocation is honest.** Remove-wins: a revoked principal can never be
|
|
71
87
|
re-granted by a replayed grant. The post-revoke read re-derives authority from
|
|
72
88
|
the maintained projection — cost scales with the number of *identities*, not
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Birthing child workspaces (law-at-birth)
|
|
2
2
|
|
|
3
3
|
A directive in your law can **birth a child workspace** that is born already running its own domain law.
|
|
4
|
-
A home births
|
|
4
|
+
A home births project children; a platform births workspaces; root births platforms — all the **same one
|
|
5
5
|
way**. You do *not* ask Nomos for a new primitive and you do *not* hand a finished workspace to the cloud.
|
|
6
6
|
|
|
7
7
|
## How it works (birth is a kernel concern, defined by the ledger)
|
|
@@ -18,7 +18,7 @@ The host installs nothing and decides nothing. The recipe is a **ledger fact**,
|
|
|
18
18
|
|
|
19
19
|
## The leaf recipe = two installs + your seed
|
|
20
20
|
|
|
21
|
-
A leaf child (a home,
|
|
21
|
+
A leaf child (a home, a project) is born with exactly:
|
|
22
22
|
|
|
23
23
|
```
|
|
24
24
|
[ bootstrap/installDomain(<frameworkHash>), // the bootstrap controller (bytes-by-hash, kernel-resolved)
|
|
@@ -31,9 +31,9 @@ resolves the bytes from custody by hash — they are never shipped on the wire).
|
|
|
31
31
|
|
|
32
32
|
## Every birth is **keyed** — so you can find the workspace again
|
|
33
33
|
|
|
34
|
-
A workspace exists to be **used**, and using it — sync it, open it,
|
|
34
|
+
A workspace exists to be **used**, and using it — sync it, open it, add a task to it — means **finding it
|
|
35
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 `
|
|
36
|
+
minted-and-forgotten: it is always **derived from a key you already own** (a business id like `projectId`, or
|
|
37
37
|
an owner uid). One verb: **`birth.keyed`**.
|
|
38
38
|
|
|
39
39
|
> **The one rule.** The name comes from a key you own → the client re-derives the same name from that same
|
|
@@ -44,10 +44,10 @@ an owner uid). One verb: **`birth.keyed`**.
|
|
|
44
44
|
```ts
|
|
45
45
|
import { directive, birth, create, z } from "@githolon/dsl";
|
|
46
46
|
|
|
47
|
-
export const
|
|
48
|
-
.creates(
|
|
47
|
+
export const birthProject = directive("birthProject")
|
|
48
|
+
.creates(ProjectBirth) // your lineage/audit row
|
|
49
49
|
.payload(z.object({
|
|
50
|
-
|
|
50
|
+
projectId: z.string().min(1), // THE KEY — an id you mint app-side; the workspace is FOR it
|
|
51
51
|
owner: z.string().min(1),
|
|
52
52
|
frameworkHash: z.string().min(1),
|
|
53
53
|
lawHash: z.string().min(1),
|
|
@@ -56,36 +56,36 @@ export const birthEstate = directive("birthEstate")
|
|
|
56
56
|
.plan((p) => {
|
|
57
57
|
const actor = "user:" + p.owner;
|
|
58
58
|
birth.keyed({
|
|
59
|
-
key: p.
|
|
60
|
-
namespace: "
|
|
59
|
+
key: p.projectId, // ← the workspace name is derived from this
|
|
60
|
+
namespace: "project", // → project-<sha256(project ⏎ projectId)[:40]>
|
|
61
61
|
genesisChain: (ws) => [ // the builder receives the resolved name
|
|
62
62
|
{ domain: "bootstrap", directiveId: "installDomain", actor, payload: { domainHash: p.frameworkHash }, domainHash: p.frameworkHash, domainPackageB64: "" },
|
|
63
63
|
{ domain: "nomos", directiveId: "installDomain", actor, payload: { domainHash: p.lawHash }, domainHash: p.lawHash, domainPackageB64: "" },
|
|
64
|
-
{ domain: "
|
|
64
|
+
{ domain: "project", directiveId: "seedProject", actor, payload: { projectId: p.projectId, workspaceName: ws, bornAt: p.bornAt } },
|
|
65
65
|
],
|
|
66
66
|
});
|
|
67
67
|
return [];
|
|
68
68
|
})
|
|
69
69
|
.births()
|
|
70
|
-
.requires("
|
|
70
|
+
.requires("projectCreator");
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
-
Then in the app you address that
|
|
73
|
+
Then in the app you address that project from just its id — **no lookup table, no persisted name**:
|
|
74
74
|
|
|
75
75
|
```dart
|
|
76
76
|
// Dart (nomos_client)
|
|
77
|
-
final ws = deriveWorkspaceName('
|
|
78
|
-
final session = await engine.open(ws); // open / sync /
|
|
77
|
+
final ws = deriveWorkspaceName('project', projectId);
|
|
78
|
+
final session = await engine.open(ws); // open / sync / add tasks — any device, any time
|
|
79
79
|
```
|
|
80
80
|
```ts
|
|
81
81
|
// TS (@githolon/client)
|
|
82
|
-
const ws = await deriveWorkspaceName('
|
|
82
|
+
const ws = await deriveWorkspaceName('project', projectId);
|
|
83
83
|
const holon = await connect({ cloud, workspace: ws, clientId });
|
|
84
84
|
```
|
|
85
85
|
|
|
86
86
|
`deriveWorkspaceName` computes the byte-identical name the law's `birth.keyed` produced (contract-tested
|
|
87
87
|
across the engine, Dart, and TS). This is the helper that replaces every hand-rolled
|
|
88
|
-
`
|
|
88
|
+
`projectWorkspaceForProjectId` — delete yours and use this.
|
|
89
89
|
|
|
90
90
|
### One-per-user things (a home): `birth.singleton`
|
|
91
91
|
|
|
@@ -111,9 +111,9 @@ workspace IS the parent — no loose parent string, and the born child comes bac
|
|
|
111
111
|
|
|
112
112
|
```dart
|
|
113
113
|
final parentClient = await MyPlatformClient.bind(parentSession);
|
|
114
|
-
final birth = await parentClient.
|
|
114
|
+
final birth = await parentClient.birthProjectChild(payload: order); // parent = the session workspace
|
|
115
115
|
final child = await engine.follow(birth.bornRefs.single); // mount the born child
|
|
116
|
-
final childClient = await
|
|
116
|
+
final childClient = await ProjectClient.bind(child); // typed client on the child
|
|
117
117
|
```
|
|
118
118
|
|
|
119
119
|
(The old `parent:` parameter survives one minor version as a deprecated legacy-gateway shim; its verdict
|
|
@@ -122,13 +122,13 @@ rides `outcome.gatewayVerdict`.)
|
|
|
122
122
|
## Authority — who may birth
|
|
123
123
|
|
|
124
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
|
|
125
|
+
home (self-bound at birth, the same way the owner is seeded) so a verified owner can birth their project
|
|
126
126
|
children — and only their own. (Relations: `grant` / `writeTuple`, see `07-security.md`. Use whatever
|
|
127
127
|
relation models your policy; `creator` is the convention `birthChild` uses.)
|
|
128
128
|
|
|
129
129
|
## What NOT to do
|
|
130
130
|
|
|
131
|
-
- **Don't hand-roll the child's name** (`
|
|
131
|
+
- **Don't hand-roll the child's name** (`project-<sha256(owner)>`, or your own `projectWorkspaceForProjectId`).
|
|
132
132
|
`birth.keyed` owns the naming and `deriveWorkspaceName` gives the client the identical name — hand-rolling
|
|
133
133
|
either half is how the two drift and a workspace becomes unfindable.
|
|
134
134
|
- **Don't use `birth.instance`.** It's deprecated — it mints a name you can't re-derive, so the workspace is
|
|
@@ -154,7 +154,7 @@ npx githolon proof # OFFLINE: drives your .births() directive on the real
|
|
|
154
154
|
A red proof names the exact genesis step the child's gate refused. The most common first-timer halt: a
|
|
155
155
|
plan that returns a fluent builder — builders record themselves, so end the plan with `return [];`.
|
|
156
156
|
A complete working two-domain births package to copy: `bench/scale/fixtures/home-delegated-estate` (nomos2
|
|
157
|
-
repo) — it births
|
|
157
|
+
repo) — it births child workspaces with `birth.keyed` (addressable by key) and is exercised by
|
|
158
158
|
`bench/scale/home_delegated_estate.smoke.mjs`.
|
|
159
159
|
|
|
160
160
|
## Posture at birth
|
|
@@ -166,11 +166,11 @@ the child's own consent fact first (a heal = consent first). See
|
|
|
166
166
|
[11-governance-postures.md](./11-governance-postures.md) for the two postures, the consent ceremony, and
|
|
167
167
|
the exit rights.
|
|
168
168
|
|
|
169
|
-
## Offline
|
|
169
|
+
## Offline workspace creation via delegated authority (`delegatedRole` / `.delegatedFrom(parent)`)
|
|
170
170
|
|
|
171
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
|
|
173
|
-
holds a **home** holon **H** on-device; U births
|
|
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
174
|
law. The parent's grant cannot be an author-time attested read (that needs connectivity). Instead the
|
|
175
175
|
grant is **delivered into H as a signed cert and folded as a local authority fact** — then read offline.
|
|
176
176
|
|
|
@@ -178,21 +178,21 @@ Author the delivery directive with `.delegatedRole(relation).delegatedFrom(paren
|
|
|
178
178
|
|
|
179
179
|
```ts
|
|
180
180
|
export const acceptDelegatedRole = directive("acceptDelegatedRole")
|
|
181
|
-
.creates(
|
|
181
|
+
.creates(ProjectCreatorGrant)
|
|
182
182
|
.payload(z.object({ subject: roleSubject, delegationCert: z.string().min(1), grantedBy: roleSubject, grantedAt: z.string() }))
|
|
183
183
|
.plan((p) => {
|
|
184
|
-
create(
|
|
185
|
-
return writeTuple({ object: "workspace:self", relation: "
|
|
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
186
|
})
|
|
187
|
-
.grants("
|
|
188
|
-
.delegatedRole("
|
|
187
|
+
.grants("projectCreator")
|
|
188
|
+
.delegatedRole("projectCreator", { subjectField: "subject", certField: "delegationCert" })
|
|
189
189
|
.delegatedFrom({ parentKeyObject: "birth-attestation:self", parentKeyField: "parentKey" }); // H's parent P's key
|
|
190
190
|
|
|
191
191
|
// the birth itself is gated on the DELIVERED local tuple — read OFFLINE, no parent contact:
|
|
192
|
-
export const
|
|
193
|
-
.creates(
|
|
192
|
+
export const birthProjectWorkspace = directive("birthProjectWorkspace")
|
|
193
|
+
.creates(ProjectBirthOrder).payload(/* … */).plan(/* birth(...) */)
|
|
194
194
|
.births()
|
|
195
|
-
.requires("
|
|
195
|
+
.requires("projectCreator"); // ← reads the delegated tuple folded by acceptDelegatedRole
|
|
196
196
|
```
|
|
197
197
|
|
|
198
198
|
How it works:
|
|
@@ -204,7 +204,7 @@ How it works:
|
|
|
204
204
|
- **The birth is authored OFFLINE**, gated only by H's LOCAL folded tuple; `verify_chain` re-verifies the
|
|
205
205
|
delegation to `K_root` with no cross-workspace read.
|
|
206
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
|
|
207
|
+
the folded tuple flips revoked (`RemoveWins`), so U's NEXT birth refuses — but a project born WHILE the
|
|
208
208
|
grant was live re-verifies green forever (verify_chain re-folds the chain prefix at each intent's
|
|
209
209
|
position). Both the grant and the revoke are permanent ledger facts.
|
|
210
210
|
|
|
@@ -13,7 +13,7 @@ import { z, directive, read, trustSource, foreignQuery } from "@githolon/dsl";
|
|
|
13
13
|
const platformPolicy = foreignQuery("platformPolicy"); // the SOURCE's declared query, by id
|
|
14
14
|
|
|
15
15
|
// module level — the trust boundary IS law (rides the domainHash):
|
|
16
|
-
export const platformTrust = trustSource("
|
|
16
|
+
export const platformTrust = trustSource("acme-platform", {
|
|
17
17
|
genesis: "9d1c…", // the source's genesis oid (its true identity — names are routing)
|
|
18
18
|
keyHashes: ["41aa…"], // sha256 of the source's read-attestor public key(s)
|
|
19
19
|
maxAgeMs: 86_400_000, // how stale an envelope may be vs the intent's own clock
|
|
@@ -24,9 +24,9 @@ export const platformTrust = trustSource("co2-platform", {
|
|
|
24
24
|
export const enrollDevice = directive("enrollDevice")
|
|
25
25
|
.creates(HomeDevice)
|
|
26
26
|
.payload(z.object({ device: z.string() }))
|
|
27
|
-
.readsFrom("
|
|
27
|
+
.readsFrom("acme-platform", platformPolicy) // the declared foreign read
|
|
28
28
|
.plan((p) => {
|
|
29
|
-
const rows = read(platformPolicy, { scope: "homes" }, { from: "
|
|
29
|
+
const rows = read(platformPolicy, { scope: "homes" }, { from: "acme-platform" });
|
|
30
30
|
if (rows[0]?.data?.enrollmentOpen !== "true") throw new Error("enrollment closed");
|
|
31
31
|
create(HomeDevice).set("device", p.device);
|
|
32
32
|
return [];
|
|
@@ -51,10 +51,10 @@ lanes, in precedence order:
|
|
|
51
51
|
|
|
52
52
|
```ts
|
|
53
53
|
const holon = await connect({ cloud, workspace, clientId,
|
|
54
|
-
attestedSources: { "
|
|
54
|
+
attestedSources: { "acme-platform": "https://nomos.captainapp.co.uk" } }); // optional override
|
|
55
55
|
await holon.dispatch("home", "enrollDevice", { device: "d-1" });
|
|
56
56
|
// → the kernel refuses TYPED naming the missing read; the client fetches
|
|
57
|
-
// GET <sourceCloud>/v2/workspaces/
|
|
57
|
+
// GET <sourceCloud>/v2/workspaces/acme-platform/platformPolicy?scope=homes&attest=1
|
|
58
58
|
// injects the signed envelope, retries — automatically.
|
|
59
59
|
```
|
|
60
60
|
|
|
@@ -17,8 +17,8 @@ export default {
|
|
|
17
17
|
},
|
|
18
18
|
names: {
|
|
19
19
|
// public aliases over the stable generated identities (typedefs in the types barrel)
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
AcmeProjectCustomFieldDefinitionFieldType: "FieldType",
|
|
21
|
+
AcmeProjectDrawingTransform2d: "DrawingTransform2d",
|
|
22
22
|
},
|
|
23
23
|
},
|
|
24
24
|
};
|