@githolon/dsl 0.7.1 → 0.9.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.
@@ -0,0 +1,88 @@
1
+ // NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
2
+ // remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
3
+ // wasm32-wasip1 artifact {kernel · projection · embedded QuickJS engine}.
4
+ // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
5
+
6
+ /**
7
+ * framework/genesis.ts — THE GENESIS WARRANT (the bootstrap base case;
8
+ * `architecture/identity_keys.md` step 0, `home_holons_and_cross_custody.md` §2).
9
+ *
10
+ * THE BOOTSTRAP PARADOX: the relation gate refuses a founding control-plane grant
11
+ * on a VIRGIN chain (an empty `#admin`/`#creator`/… roster) — there is no admin yet
12
+ * to gate the first admin. Resolved LAWFULLY (not by a host bypass): at birth the
13
+ * host verifies the `x-nomos-auth` token ONCE and records the verified principal as
14
+ * an in-history fact — this `GenesisWarrant`. The gate's GENESIS CLAUSE
15
+ * (`executor::genesis_admits`) then admits a founding grant on a virgin object IFF
16
+ * the warrant binds the chain to the captured principal. Strictly additive (only an
17
+ * otherwise-`Unauthorized` virgin grant), so every sealed chain replays unchanged.
18
+ *
19
+ * CONTROL-PLANE: `seedGenesis` is authored by the BIRTH DOOR (the host, after the
20
+ * one-time token verification), NOT by the open session lane — the session lane
21
+ * structurally refuses control-plane intents, so a tenant cannot forge a warrant for
22
+ * a principal it does not own. The warrant is the host's recorded verification, not
23
+ * a host authority decision: every replaying peer re-reads the chain fact.
24
+ *
25
+ * SINGLETON per chain: one founding principal per home/workspace, so the warrant is
26
+ * a singleton keyed `genesis-warrant:self`. `seedGenesis` is create-once — a second
27
+ * seed is a dup-create refusal, so the birth principal is fixed forever-on-chain.
28
+ */
29
+ import { z } from "zod";
30
+ import { aggregate, instance } from "../aggregate.js";
31
+ import { t } from "../fields.js";
32
+ import { Lww } from "../drivers.js";
33
+ import { directive } from "../directive.js";
34
+ import { set, type PlannedOp } from "../ops.js";
35
+
36
+ /**
37
+ * Deterministic singleton id of the founding warrant — MUST equal
38
+ * `executor::GENESIS_WARRANT_ID` (the gate reads this exact key). One per chain.
39
+ */
40
+ export const GENESIS_WARRANT_ID = "genesis-warrant:self";
41
+
42
+ /**
43
+ * The founding-warrant record. It does THREE jobs at once:
44
+ * - `principal` — the x-nomos-auth-verified OWNER (the genesis clause binds the founding grant to it);
45
+ * - `workspaceId` — this workspace's UNIQUE SELF-ID (the source binding the author signature covers,
46
+ * re-derived locally by the gate — NOT the shared/deterministic genesis commit, which is identical
47
+ * across all workspaces and so bound nothing);
48
+ * - its mere PRESENCE — the SIGNED-AUTHOR REGIME signal (the "era"): a workspace with a warrant
49
+ * REQUIRES a verified author block (forge closed); one without falls back to the legacy
50
+ * host-stamped principal (pre-feature chains replay green, per-position).
51
+ */
52
+ export const GenesisWarrant = aggregate("GenesisWarrant", {
53
+ principal: t.string().merge(Lww), // the x-nomos-auth-verified owner, recorded at birth
54
+ workspaceId: t.string().merge(Lww), // this workspace's unique self-id (the author-signature source binding)
55
+ bornAt: t.string().merge(Lww), // ISO-8601, caller-stamped (determinism)
56
+ });
57
+ export type GenesisWarrantHandle = typeof GenesisWarrant;
58
+
59
+ /** The framework PRIMITIVE plan: record the founding warrant (singleton). */
60
+ export function writeGenesisWarrant(p: { principal: string; workspaceId: string; bornAt: string }): PlannedOp[] {
61
+ const w = instance(GenesisWarrant, GENESIS_WARRANT_ID);
62
+ return [
63
+ set(w, "principal", p.principal),
64
+ set(w, "workspaceId", p.workspaceId),
65
+ set(w, "bornAt", p.bornAt),
66
+ ];
67
+ }
68
+
69
+ /**
70
+ * `seedGenesis` — the birth-door directive that records the verified founding principal + this
71
+ * workspace's unique self-id at the FIXED singleton id `genesis-warrant:self`. `.ensures` (NOT
72
+ * `.creates`): the warrant is a deterministic-KEYED singleton, not a minted record — `.creates` would
73
+ * demand a runtime-minted `<Type>_<uuid>` id and the id-mint gate refuses a hand-written one. The
74
+ * gate reads the warrant at THIS exact key, so the id is law, not minted. A second seed UPSERTs the
75
+ * same leaf rather than minting a duplicate; re-seeding is itself prevented out-of-band — seedGenesis
76
+ * is a CONTROL-PLANE birth-door write (the open session lane refuses it) and post-genesis bootstrap
77
+ * intents are refused once the lifecycle controller is active.
78
+ */
79
+ export const seedGenesis = directive("seedGenesis")
80
+ .ensures(GenesisWarrant)
81
+ .payload(
82
+ z.object({
83
+ principal: z.string().min(1),
84
+ workspaceId: z.string().min(1),
85
+ bornAt: z.string().min(1),
86
+ }),
87
+ )
88
+ .plan((p) => writeGenesisWarrant(p));
@@ -0,0 +1,149 @@
1
+ // NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
2
+ // remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
3
+ // wasm32-wasip1 artifact {kernel · projection · embedded QuickJS engine}.
4
+ // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
5
+
6
+ /**
7
+ * framework/home.ts — THE HOME HOLON (the identity role; `architecture/home_holons_and_cross_custody.md`
8
+ * §4). One holon, three roles — "workspace," "platform," "home" are the SAME githolon wearing the role
9
+ * its installed LAW gives it. A HOME holon runs THIS law: an identity's subject-side root.
10
+ *
11
+ * It holds only PUBLIC material + signed facts (private keys never leave the client, so the cloud hosting
12
+ * it stays operator-blind):
13
+ * - KEY DIRECTORY — the identity's public device keys: the `signer` relation
14
+ * (`identity:<principal>#signer@key:<hash>`, `framework/signing_identity.ts`). Re-exported here so a
15
+ * home holon's law carries it: "wrap the scope key to U's device" becomes "read U's home key roster."
16
+ * - SHARED-WITH-ME index (§5) — one record per verified cross-custody attestation delivered here, so
17
+ * "show me everything shared with me" is a LOCAL query, no fan-out.
18
+ * - INBOX — grants/invites/notifications/dead-letters addressed to the identity.
19
+ *
20
+ * THE FORWARD-CHECK DOCTRINE (§5): the index is a DISCOVERY HINT, not the authority. When the identity
21
+ * actually opens the asserting workspace, THAT workspace's gate does the live `Check` — so a stale share
22
+ * (the asserter revoked it) is fail-safe: the identity sees a workspace it can no longer open, the forward
23
+ * check denies, the hint is pruned. (The cross-custody ATTESTATION that proves a delivered share is signed
24
+ * + verifiable is §3; the host DELIVERY lane that fills the index is §5. This file is the law that holds
25
+ * the result, and the owner-authored lane to maintain it.)
26
+ */
27
+ import { z } from "zod";
28
+ import { aggregate, instance } from "../aggregate.js";
29
+ import { t } from "../fields.js";
30
+ import { Lww } from "../drivers.js";
31
+ import { directive } from "../directive.js";
32
+ import { create } from "../authoring.js";
33
+ import { set, type PlannedOp } from "../ops.js";
34
+ import { query } from "../query.js";
35
+ import { identityRelations } from "./signing_identity.js";
36
+
37
+ /** The KEY DIRECTORY is the `signer` relation — re-exported so a home holon's law composes it (the
38
+ * identity's public device keys, enrolled via `enrollSigner`, checked by the gate). */
39
+ export { identityRelations };
40
+
41
+ /**
42
+ * THE HOME OWNER (home_holons §5 — the owner stamp). A home holon is born UNWARRANTED (the subject may
43
+ * never have logged in — a share can be delivered to a stranger's lazily-born home, before they hold any
44
+ * device key, so there is no signed-author regime yet). This singleton records WHO the home belongs to: the
45
+ * §3 cross-custody gate binds a delivered quote's subject to THIS principal (`executor::HOME_OWNER_ID`), so
46
+ * a quote signed for anyone else fails closed. Seeded ONCE at birth by the host (the control-plane
47
+ * `seedHomeOwner`, kept off the open session lane), so a tenant cannot forge ownership.
48
+ */
49
+ export const HOME_OWNER_ID = "home-owner:self";
50
+ export const HomeOwner = aggregate("HomeOwner", {
51
+ /** The identity this home belongs to — the canonical `user:<uid>` subject the §3 gate binds quotes to. */
52
+ principal: t.string().merge(Lww),
53
+ /** ISO-8601, caller-stamped (determinism — no clock in the plan). */
54
+ bornAt: t.string().merge(Lww),
55
+ });
56
+
57
+ /** The framework PRIMITIVE plan: stamp the home's owner (singleton at the fixed id the gate reads). */
58
+ export function writeHomeOwner(p: { principal: string; bornAt: string }): PlannedOp[] {
59
+ const h = instance(HomeOwner, HOME_OWNER_ID);
60
+ return [set(h, "principal", p.principal), set(h, "bornAt", p.bornAt)];
61
+ }
62
+
63
+ /**
64
+ * `seedHomeOwner` — the birth-door directive that stamps the home's owner at the fixed singleton id. A
65
+ * CONTROL-PLANE write: the host authors it once at lazy birth (after resolving who the home is for); the
66
+ * open session lane structurally REFUSES it (by directive id, like the §6 handoff seals), so a tenant
67
+ * cannot overwrite ownership. `.ensures` (a deterministic-keyed singleton, not a minted record).
68
+ */
69
+ export const seedHomeOwner = directive("seedHomeOwner")
70
+ .ensures(HomeOwner)
71
+ .payload(z.object({ principal: z.string().min(1), bornAt: z.string().min(1) }))
72
+ .plan((p) => writeHomeOwner(p));
73
+
74
+ /**
75
+ * THE SHARED-WITH-ME INDEX (§5) — one record per cross-custody attestation delivered to this home holon:
76
+ * a signed edge `<asserterWorkspace> : <object> # <relation> @ this-identity`, indexed LOCALLY so the
77
+ * access list is a local query (no fan-out across every resource holon). A DISCOVERY HINT — the live authz
78
+ * stays the forward `Check` on the asserting holon when the identity opens it.
79
+ */
80
+ export const SharedWithMe = aggregate("SharedWithMe", {
81
+ /** The holon that signed + delivered this edge — WHERE the live forward `Check` runs when opened. */
82
+ asserterWorkspace: t.string().merge(Lww),
83
+ /** The shared OBJECT — the whole workspace (`workspace:self`) or a specific record (`Entry:<id>`). */
84
+ object: t.string().merge(Lww),
85
+ /** The relation granted (`reader` / `editor` / a read cap). */
86
+ relation: t.string().merge(Lww),
87
+ /** The asserter's OWNER principal — whose key signed the attestation (verified at §3 admission). */
88
+ asserterPrincipal: t.string().merge(Lww),
89
+ /** ISO-8601, caller-stamped (determinism — no clock in the plan). */
90
+ attestedAt: t.string().merge(Lww),
91
+ /** PRUNED (forward-check fail-safe): set when the live `Check` on the asserter denies — the hint is
92
+ * dropped from the list but the audit row stays. `""` = live. */
93
+ prunedAt: t.string().merge(Lww),
94
+ });
95
+
96
+ /**
97
+ * `recordShare` — index a cross-custody share into the home holon's access list. AUTHORED on the home
98
+ * holon (the identity's own subject-side root). The payload carries the §3 CROSS-CUSTODY ATTESTATION — a
99
+ * SIGNED QUOTE: the asserter's device `publicKey` + a `signature` over the edge
100
+ * `<asserterWorkspace>:<object>#<relation>@<this-home-owner>`. The home holon's OWN gate verifies it
101
+ * (`executor::record_share_attestation_violation`, bound to the warrant owner), so the BAILIFF can deliver
102
+ * it HOST-FREE (the host carries bytes, vouches nothing — the gate vouches). The owner lane authors the
103
+ * SAME shape (the owner holds the quote the asserter gave it). The signature is a verification input, not
104
+ * stored — only the edge facts fold into the index. The live authority stays the forward `Check` on the
105
+ * asserter when the identity opens it (§5 fail-safe); a stale/forged quote can't enter the list at all.
106
+ */
107
+ export const recordShare = directive("recordShare")
108
+ .creates(SharedWithMe)
109
+ .payload(
110
+ z.object({
111
+ asserterWorkspace: z.string().min(1),
112
+ object: z.string().min(1),
113
+ relation: z.string().min(1),
114
+ asserterPrincipal: z.string().min(1),
115
+ attestedAt: z.string().min(1),
116
+ /** The asserter's device PUBLIC KEY (base64) — what the §3 gate verifies the quote against. */
117
+ publicKey: z.string().min(1),
118
+ /** The asserter's SIGNATURE (base64) over the edge bytes, subject = this home's owner. */
119
+ signature: z.string().min(1),
120
+ }),
121
+ )
122
+ .plan((p) => {
123
+ create(SharedWithMe)
124
+ .set("asserterWorkspace", p.asserterWorkspace)
125
+ .set("object", p.object)
126
+ .set("relation", p.relation)
127
+ .set("asserterPrincipal", p.asserterPrincipal)
128
+ .set("attestedAt", p.attestedAt)
129
+ .set("prunedAt", "");
130
+ return [];
131
+ })
132
+ // CROSS-CUSTODY ATTESTATION-AUTHORIZED (§3/§5): the payload's signed quote IS the authorization (lowered
133
+ // into the `nomosAttestationGate` era key) — the one gate verifies it against the home's owner (the
134
+ // LAW-DECLARED owner-source below, NOT a kernel-hardcoded id) + pins the principal to the attestation key,
135
+ // so the bailiff delivers it host-free to an OFFLINE subject (no device key, no author block).
136
+ .attestationAuthorized({ ownerObject: HOME_OWNER_ID, ownerField: "principal" });
137
+
138
+ /**
139
+ * `pruneShare` — fail-safe drop of a hint whose forward `Check` denied (the asserter revoked it). The row
140
+ * stays for audit; `prunedAt` removes it from the live list. (The forward check itself is the resource
141
+ * holon's job — this records the verdict locally.)
142
+ */
143
+ export const pruneShare = directive("pruneShare")
144
+ .mutates(SharedWithMe)
145
+ .payload(z.object({ shareId: z.string().min(1), prunedAt: z.string().min(1) }))
146
+ .plan((p) => [set(instance(SharedWithMe, p.shareId), "prunedAt", p.prunedAt)]);
147
+
148
+ /** "Show me everything <asserter> shared with me" — an indexed probe over the local access list. */
149
+ export const sharedByWorkspace = query("sharedByWorkspace").key("asserterWorkspace").returns(SharedWithMe);
@@ -41,7 +41,7 @@
41
41
  * response-mutation). This is exactly the proven `ExportJobAggregate` shape
42
42
  * (the tenant domains package `thing_structures`) generalised into a reusable framework pattern so a
43
43
  * domain dev does NOT hand-roll the {aggregate + order + complete + fail} quartet each
44
- * time — they DECLARE the task's params and get the quartet for free, like `rbac.ts`
44
+ * time — they DECLARE the task's params and get the quartet for free, like `relations.ts`
45
45
  * gives grant/revoke for free.
46
46
  *
47
47
  * ── What is framework vs tenant ──────────────────────────────────────────────
@@ -168,7 +168,7 @@ export function impureCapabilityAggregate<
168
168
  * captured + committed — so the `order` create is idempotent on replay by capture, and the
169
169
  * `complete`/`fail` receipt addresses the SAME instance by reading the committed minted id.
170
170
  * Trivial today (identity), but centralised here so the keying rule has ONE home (the
171
- * symmetry with `rbac.ts`'s `userPermissionsInstanceId`).
171
+ * symmetry with `relations.ts`'s `relationTupleInstanceId`).
172
172
  */
173
173
  export function impureCapabilityInstanceId(requestId: string): string {
174
174
  return requestId;
@@ -215,7 +215,7 @@ const deadLetterSchema = z.object({
215
215
  * The three plan builders. Each takes the task aggregate handle + the (already
216
216
  * Zod-validated) payload and returns the `PlannedOp[]` writing the envelope. A tenant
217
217
  * routes its directive `.plan` through these so the envelope shape can never drift —
218
- * exactly how `rbac.ts`'s grant/revoke route through `buildBinding`/`writeBinding`.
218
+ * exactly how `relations.ts`'s relate/unrelate route through `writeTuple`/`removeTuple`.
219
219
  */
220
220
 
221
221
  /**
@@ -61,11 +61,30 @@ const OBJECT_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]*:[A-Za-z0-9][A-Za-z0-9_.:/-]*$/;
61
61
  /** A relation name (the edge label) — a bare identifier, like a directive/cap name. */
62
62
  const RELATION_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
63
63
  /**
64
- * A SUBJECT: `user:<uid>`, the public wildcard `user:*`, or a userset `<object>#<relation>`.
65
- * v1 admits user-subjects + the wildcard; usersets are reserved for slice 3 (the rewrite
66
- * layer) and refused here so a raw uid can never smuggle one in.
64
+ * A SUBJECT: `user:<uid>`, the public wildcard `user:*`, a KEY-ADDRESSED `key:<64hex>`, or a
65
+ * USERSET `<type>:<id>#<relation>` (group membership / indirection — P2). A raw uid carrying the
66
+ * `:` delimiter or being the bare `*` is refused so it can never forge a wildcard/userset.
67
67
  */
68
68
  const USER_SUBJECT_RE = /^user:([A-Za-z0-9][A-Za-z0-9_.-]*|\*)$/;
69
+ /**
70
+ * A KEY-ADDRESSED subject: `key:<64 lowercase hex>` — a sha256 keyHash, the CONTROL-PLANE authority
71
+ * subject (an AdminGrant/CreationGrant/CapabilityBinding key becomes `…@key:<keyHash>`). Pinned to
72
+ * EXACTLY 64 hex with NO wildcard: the Check wildcard arm is `user:*`-only, so a `key:*` would be a
73
+ * forge surface (any key claiming admin). Distinct from `user:` so a keyHash is never read as a uid.
74
+ */
75
+ const KEY_SUBJECT_RE = /^key:[0-9a-f]{64}$/;
76
+ /**
77
+ * A USERSET subject `<type>:<id>#<relation>` — group membership / indirection (P2, the native-Zanzibar
78
+ * recursion EDGE the `RewriteEngine` resolves: `Doc#viewer@group:eng#member`). The body is OBJECT_RE
79
+ * (`<type>:<id>`) + `#` + RELATION_RE (`#` is never in either part, so the split is unambiguous). The
80
+ * CONTROL-PLANE CARVE-OUT (an admin/creator relation must NOT accept a userset — else admin-via-group)
81
+ * is enforced at the WRITE GATE by the relation's declared `AllowedType` (P4), not here: this generic
82
+ * boundary only checks the subject is well-FORMED. A relation that declares no userset allowed-type
83
+ * refuses one fail-closed at the gate. (Governance does not use the relation schema until P5, and P4
84
+ * lands the AllowedType gate before it — so no control-plane userset is admissible in the interim.)
85
+ */
86
+ const USERSET_SUBJECT_RE =
87
+ /^[A-Za-z0-9][A-Za-z0-9_.-]*:[A-Za-z0-9][A-Za-z0-9_.:/-]*#[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
69
88
 
70
89
  /** Deterministic per-(object, relation) aggregate instance id — replay-stable. */
71
90
  export function relationTupleInstanceId(object: string, relation: string): string {
@@ -166,7 +185,12 @@ export function removeTuple(
166
185
  const relatePayload = z.object({
167
186
  object: z.string().regex(OBJECT_RE, "object must be <type>:<id>"),
168
187
  relation: z.string().regex(RELATION_RE),
169
- subject: z.string().regex(USER_SUBJECT_RE, "subject must be user:<uid> or user:*"),
188
+ subject: z
189
+ .string()
190
+ .refine(
191
+ (s) => USER_SUBJECT_RE.test(s) || KEY_SUBJECT_RE.test(s) || USERSET_SUBJECT_RE.test(s),
192
+ "subject must be user:<uid>, user:*, key:<64hex>, or a userset <type>:<id>#<relation>",
193
+ ),
170
194
  grantedBy: z.string().min(1),
171
195
  grantedAt: z.string().min(1), // ISO-8601, caller-stamped (determinism)
172
196
  });
@@ -0,0 +1,97 @@
1
+ // NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
2
+ // remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
3
+ // wasm32-wasip1 artifact {kernel · projection · embedded QuickJS engine}.
4
+ // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
5
+
6
+ /**
7
+ * framework/signing_identity.ts — THE `signer` RELATION (the key↔identity binding,
8
+ * `architecture/identity_keys.md`). NATIVE ZANZIBAR, not a bespoke roster.
9
+ *
10
+ * THE CLEAN SPLIT. Proving WHO authored an intent has exactly two parts:
11
+ * 1. The SIGNATURE — "this ed25519 device key signed these bytes." That is the ONE
12
+ * irreducibly-crypto primitive (the attestation layer beneath Zanzibar; relations
13
+ * can't prove a signature). It is the kernel/provenance `verify` over the
14
+ * source-bound author bytes — NOT modelled here.
15
+ * 2. WHICH KEY BELONGS TO WHICH IDENTITY — a RELATION:
16
+ * `identity:<principal>#signer@key:<sha256hex(pubkey)>`
17
+ * "this device key may AUTHOR as this principal." ENROLL with `relateSubject`,
18
+ * REVOKE with `unrelateSubject` (remove-wins — already native), CHECK with the
19
+ * relation engine (`RewriteEngine.check`). No aggregate, no roster fold; the pubkey
20
+ * rides the signature and its hash is just the subject. The gate reads THIS
21
+ * workspace's own signer tuples (local — each workspace holds its own guest list).
22
+ *
23
+ * So: the gate derives a PROVEN principal by `verify(sig)` (1) then
24
+ * `Check(identity:P # signer @ key:<hash>)` (2) — and only THEN runs the directive's
25
+ * own `.requires(...)` relation gate. Two of the three steps are relations; only the
26
+ * signature is crypto.
27
+ *
28
+ * BOOTSTRAP: the FOUNDING signer tuple of a principal (on a virgin `identity:P#signer`)
29
+ * is admitted by the genesis clause (`framework/genesis.ts`, token-bound), exactly like
30
+ * the founding admin grant; every later device is enrolled by proof-of-possession (an
31
+ * intent signed by an already-enrolled key).
32
+ */
33
+ import { z } from "zod";
34
+ import { directive } from "../directive.js";
35
+ import { relationType, self, key } from "../relation_schema.js";
36
+ import { writeTuple, RelationTuple } from "./relations.js";
37
+
38
+ /**
39
+ * The framework `identity` relation schema — the `signer` relation, LEAF-ONLY
40
+ * (`allow: [key()]`, never a userset/wildcard: a `key:*` would let any key claim to author
41
+ * as anyone). A workspace that admits signed authorship composes this into its law.
42
+ */
43
+ export const identityRelations = relationType("identity").relation("signer", {
44
+ rewrite: self(),
45
+ allow: [key()],
46
+ });
47
+
48
+ /** The Zanzibar OBJECT for a principal's signer relation — `identity:<principal>` (the gate mirrors
49
+ * this as `executor::signer_object`; the subject is `key:<sha256hex(pubkey)>`). */
50
+ export function signerObject(principal: string): string {
51
+ return `identity:${principal}`;
52
+ }
53
+
54
+ /** The `signer` relation name — the relation a device key holds on its `identity:<principal>` object. */
55
+ export const SIGNER_RELATION = "signer";
56
+
57
+ const sha256Hex = z.string().regex(/^[0-9a-f]{64}$/, "expected lowercase sha256 hex (the device pubkey hash)");
58
+
59
+ /**
60
+ * `enrollSigner` — author the founding `identity:<principal>#signer@key:<keyHash>` tuple (the bootstrap
61
+ * E of the warrant-flip, `architecture/identity_keys.md`). A `relateSubject` SPECIALISATION:
62
+ * - `.ensures(RelationTuple)` so the deterministic-keyed `(identity:<principal>, signer)` head is
63
+ * admitted by the id-mint gate (upsert-by-key) — exactly like `grantAdmin`;
64
+ * - **NO `.requires`** — DELIBERATE. At genesis the founding signer enrolls on a still-WARRANT-FREE
65
+ * prefix (it precedes `seedGenesis`), so `verify_captured_principal` is in its legacy fast path and
66
+ * admits it UNSIGNED; there is no admin/signer yet to gate it, so a `.requires` would re-create the
67
+ * bootstrap chicken-and-egg. The forge surface a bare `relateSubject` would open is closed by
68
+ * DOMAIN PLACEMENT: this directive is composed into the CONTROL-PLANE governance law (domain
69
+ * `workspaces`/`nomos`), which the open session lane structurally refuses
70
+ * (`cloud/holon-host/src/engine.mjs` — only the birth door + the secret-gated `/author` + admin
71
+ * lanes author it), so no tenant can forge a signer for another principal.
72
+ *
73
+ * THE ORDER IS THE CONTRACT (verified, zero gate change): `enrollSigner` (E, unsigned/pre-warrant) →
74
+ * `seedGenesis` (W, unsigned/pre-warrant, the warrant lands in its output) → `grantAdmin` (A, the FIRST
75
+ * SIGNED intent — its prefix now carries the warrant, the E-tuple proves the key, the virgin `#admin` is
76
+ * admitted by the genesis clause). NB: a GENERAL tenant-callable enrolment (additional devices) needs a
77
+ * self-binding (captured principal == payload principal) or admin-gating — NOT this founding lane.
78
+ */
79
+ export const enrollSigner = directive("enrollSigner")
80
+ .ensures(RelationTuple)
81
+ .payload(
82
+ z.object({
83
+ principal: z.string().min(1), // the auth-provider uid the key may author as
84
+ keyHash: sha256Hex, // sha256hex of the ed25519 device PUBLIC key
85
+ grantedBy: z.string().min(1),
86
+ grantedAt: z.number().int().nonnegative(), // epoch ms, caller-stamped (determinism)
87
+ }),
88
+ )
89
+ .plan((p) =>
90
+ writeTuple({
91
+ object: signerObject(p.principal),
92
+ relation: SIGNER_RELATION,
93
+ subject: "key:" + p.keyHash,
94
+ grantedBy: p.grantedBy,
95
+ grantedAt: String(p.grantedAt),
96
+ }),
97
+ );
@@ -69,8 +69,10 @@ export function refAs(name: string, aggregateType: string, id: string): Invarian
69
69
  return { name, aggregate: aggregateType, id };
70
70
  }
71
71
 
72
- /** Derive the BOUNDED declared read-set from the intent payload. Pure — no IO, no live read. */
73
- export type WorkspaceInvariantReads = (ctx: { intent: Record<string, unknown> }) => InvariantRef[];
72
+ /** Derive the BOUNDED declared read-set from the intent payload. Pure — no IO, no live read.
73
+ * `ctx.principal` is the captured, authenticated AUTHOR identity (e.g. `key:<keyHash>` / a verified uid)
74
+ * — the same value on every replaying peer — so an invariant can gate by WHO authored (law-gated authz). */
75
+ export type WorkspaceInvariantReads = (ctx: { intent: Record<string, unknown>; principal?: string }) => InvariantRef[];
74
76
 
75
77
  /**
76
78
  * The post-apply predicate over the resolved snapshots, keyed by each ref's binding `name`.
@@ -84,7 +86,7 @@ export type WorkspaceInvariantReads = (ctx: { intent: Record<string, unknown> })
84
86
  */
85
87
  export type WorkspaceInvariantAssert = (
86
88
  snapshots: Record<string, Record<string, unknown>>,
87
- ctx?: { intent: Record<string, unknown> },
89
+ ctx?: { intent: Record<string, unknown>; principal?: string },
88
90
  ) => WorkspaceInvariantVerdict;
89
91
 
90
92
  /** A complete, registered workspace-invariant declaration (the `.assert` terminal yields this). */