@githolon/dsl 0.5.1 → 0.7.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/src/aggregate.ts +168 -4
- package/src/authoring.ts +18 -6
- package/src/build_package.ts +10 -1
- package/src/codegen_dart.ts +10 -0
- package/src/codegen_ts.ts +2 -0
- package/src/drivers.ts +48 -2
- package/src/engine_entry.ts +7 -0
- package/src/fields.ts +46 -0
- package/src/framework/encryption_identity.ts +92 -0
- package/src/framework/encryption_scope.ts +158 -0
- package/src/framework/relations.ts +205 -0
- package/src/framework/workspaces.ts +41 -2
- package/src/index.ts +47 -1
- package/src/manifest.ts +54 -2
- package/src/usd.ts +50 -0
- package/src/usd_layers.ts +68 -3
- package/src/wire.ts +7 -3
|
@@ -0,0 +1,158 @@
|
|
|
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/encryption_scope.ts — THE GROUP-KEY EVENTS (e2e_encryption.md slice 4).
|
|
8
|
+
* RFC 9420 (MLS) mapped onto the event-sourced ledger, SENDER-KEYS variant (flat
|
|
9
|
+
* per-epoch direct-wrap; TreeKEM dropped — small scopes, §4 of the sheet).
|
|
10
|
+
*
|
|
11
|
+
* A SCOPE is the read cap (`requires("readNote")` ≡ the MLS group). Each scope has
|
|
12
|
+
* EPOCHS; a write encrypts a field with the scope's CURRENT epoch key. The epoch key
|
|
13
|
+
* itself NEVER lands on-chain — only its WRAPS do: per member, the epoch key
|
|
14
|
+
* HPKE-sealed to that member's published X25519 key (`encryption_identity.ts`).
|
|
15
|
+
* Wrapped keys are CIPHERTEXT, safe to publish. A reader replays the WrappedKey
|
|
16
|
+
* events addressed to its principal, unwraps with its device key (host-side, never
|
|
17
|
+
* in the wasm), and decrypts the fields it is authorized for.
|
|
18
|
+
*
|
|
19
|
+
* THE EVENTS (this module's aggregates):
|
|
20
|
+
* - `KeyScope` — the per-scope head: `currentEpoch`. A write's envelope epoch must
|
|
21
|
+
* equal this at the write's position (the gate enforces it — slice 5).
|
|
22
|
+
* - `WrappedKey` — one epoch-key wrap to one member (keyed scope|epoch|principal).
|
|
23
|
+
*
|
|
24
|
+
* THE DIRECTIVE: `advanceKeyEpoch` opens a new epoch and FANS OUT the wraps to every
|
|
25
|
+
* member IN ONE INTENT (the membership change is a single visible, owner-authored
|
|
26
|
+
* event). The wraps are computed CLIENT-SIDE by the committer (who holds the epoch
|
|
27
|
+
* secret + looks up each recipient's pubkey) and ride in the payload as opaque
|
|
28
|
+
* ciphertext; the plan only stores the facts. Forward-only revocation = an advance
|
|
29
|
+
* whose fan-out OMITS the removed member (slice 5 gates the roster + the rotation).
|
|
30
|
+
*
|
|
31
|
+
* NON-DELEGATABLE (critical #2): an encrypted scope's `nomosEncryption` carries
|
|
32
|
+
* `delegatable:false`; the slice-5 fan-out gate derives the roster from DIRECT grants
|
|
33
|
+
* only and refuses a wrap to a delegation-sourced member.
|
|
34
|
+
*/
|
|
35
|
+
import { z } from "zod";
|
|
36
|
+
import { aggregate, instance } from "../aggregate.js";
|
|
37
|
+
import { t } from "../fields.js";
|
|
38
|
+
import { Lww } from "../drivers.js";
|
|
39
|
+
import { directive } from "../directive.js";
|
|
40
|
+
import { set, withMarker, type PlannedOp } from "../ops.js";
|
|
41
|
+
import { query } from "../query.js";
|
|
42
|
+
|
|
43
|
+
const CAP_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
|
|
44
|
+
const B64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
45
|
+
|
|
46
|
+
/** Deterministic ids — the scope head + a per-member wrap, both replay-stable. */
|
|
47
|
+
export function keyScopeInstanceId(scope: string): string {
|
|
48
|
+
return `keyscope-${scope}`;
|
|
49
|
+
}
|
|
50
|
+
export function wrappedKeyInstanceId(scope: string, epoch: number, principal: string): string {
|
|
51
|
+
return `wrapkey-${scope}-${epoch}-${principal}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The per-scope key-schedule HEAD. `currentEpoch` is advisory-read here; the gate
|
|
56
|
+
* (slice 5) enforces that an encrypted-field write's envelope epoch == this value at
|
|
57
|
+
* the write's position, and that an advance only ever increments it.
|
|
58
|
+
*/
|
|
59
|
+
export const KeyScope = aggregate("KeyScope", {
|
|
60
|
+
scope: t.string().merge(Lww),
|
|
61
|
+
currentEpoch: t.int().merge(Lww),
|
|
62
|
+
openedAt: t.string().merge(Lww),
|
|
63
|
+
});
|
|
64
|
+
export type KeyScopeHandle = typeof KeyScope;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* ONE wrapped epoch key, addressed to ONE member. `hpkeEpk` is the ephemeral HPKE
|
|
68
|
+
* public key, `ct` the sealed epoch secret — both opaque base64 ciphertext, safe
|
|
69
|
+
* on-chain. `recipientPubKey` pins which device key it was sealed to (audit). Keyed
|
|
70
|
+
* `wrapkey-<scope>-<epoch>-<principal>` so a re-offer is idempotent.
|
|
71
|
+
*/
|
|
72
|
+
export const WrappedKey = aggregate("WrappedKey", {
|
|
73
|
+
scope: t.string().merge(Lww),
|
|
74
|
+
epoch: t.int().merge(Lww),
|
|
75
|
+
recipientPrincipal: t.string().merge(Lww),
|
|
76
|
+
recipientPubKey: t.string().merge(Lww), // base64 X25519 the wrap was sealed to
|
|
77
|
+
hpkeEpk: t.string().merge(Lww), // base64 HPKE ephemeral public key
|
|
78
|
+
ct: t.string().merge(Lww), // base64 sealed epoch secret
|
|
79
|
+
});
|
|
80
|
+
export type WrappedKeyHandle = typeof WrappedKey;
|
|
81
|
+
|
|
82
|
+
/** One wrap entry in an `advanceKeyEpoch` payload (computed client-side). */
|
|
83
|
+
const wrapEntrySchema = z.object({
|
|
84
|
+
recipientPrincipal: z.string().min(1),
|
|
85
|
+
recipientPubKey: z.string().regex(B64_RE),
|
|
86
|
+
hpkeEpk: z.string().regex(B64_RE),
|
|
87
|
+
ct: z.string().regex(B64_RE),
|
|
88
|
+
});
|
|
89
|
+
export type WrapEntry = z.infer<typeof wrapEntrySchema>;
|
|
90
|
+
|
|
91
|
+
/** Build the plan ops for one wrap — a deterministic-keyed WrappedKey record. */
|
|
92
|
+
function writeWrap(scope: string, epoch: number, w: WrapEntry): PlannedOp[] {
|
|
93
|
+
const wk = instance(WrappedKey, wrappedKeyInstanceId(scope, epoch, w.recipientPrincipal));
|
|
94
|
+
// The first op MATERIALIZES this new sibling bucket (`ensures` — a re-offer is idempotent);
|
|
95
|
+
// the rest mutate it. Without the marker the referential gate rejects a set on an absent id.
|
|
96
|
+
return [
|
|
97
|
+
withMarker(set(wk, "scope", scope), "ensures"),
|
|
98
|
+
set(wk, "epoch", epoch),
|
|
99
|
+
set(wk, "recipientPrincipal", w.recipientPrincipal),
|
|
100
|
+
set(wk, "recipientPubKey", w.recipientPubKey),
|
|
101
|
+
set(wk, "hpkeEpk", w.hpkeEpk),
|
|
102
|
+
set(wk, "ct", w.ct),
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* `advanceKeyEpoch` — open a new epoch for `scope` and FAN OUT the wraps to every
|
|
108
|
+
* member, in ONE intent. `epoch` is the new epoch number; `wraps` are the
|
|
109
|
+
* client-computed per-member seals of the new epoch secret. The plan bumps the
|
|
110
|
+
* `KeyScope` head and writes each `WrappedKey`. (Slice 5's fan-out gate verifies the
|
|
111
|
+
* roster == the direct-grant members at this position, that the epoch increments, and
|
|
112
|
+
* — for a revoke — that the removed member is omitted.)
|
|
113
|
+
*/
|
|
114
|
+
export const advanceKeyEpoch = directive("advanceKeyEpoch")
|
|
115
|
+
.ensures(KeyScope)
|
|
116
|
+
.payload(
|
|
117
|
+
z.object({
|
|
118
|
+
scope: z.string().regex(CAP_RE),
|
|
119
|
+
epoch: z.number().int().nonnegative(),
|
|
120
|
+
openedAt: z.string().min(1), // ISO-8601, caller-stamped (determinism)
|
|
121
|
+
wraps: z.array(wrapEntrySchema),
|
|
122
|
+
}),
|
|
123
|
+
)
|
|
124
|
+
.plan((p) => {
|
|
125
|
+
const ks = instance(KeyScope, keyScopeInstanceId(p.scope));
|
|
126
|
+
const ops: PlannedOp[] = [
|
|
127
|
+
withMarker(set(ks, "scope", p.scope), "ensures"), // upsert the scope head (first advance creates it)
|
|
128
|
+
set(ks, "currentEpoch", p.epoch),
|
|
129
|
+
set(ks, "openedAt", p.openedAt),
|
|
130
|
+
];
|
|
131
|
+
for (const w of p.wraps) ops.push(...writeWrap(p.scope, p.epoch, w));
|
|
132
|
+
return ops;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* `wrapKey` — write ONE wrap (the single-member handoff). Beyond its use as a primitive,
|
|
137
|
+
* its `.ensures(WrappedKey)` DECLARATION makes `WrappedKey` an ENSURED type, so the
|
|
138
|
+
* deterministic-keyed `WrappedKey` sibling rows that `advanceKeyEpoch` fans out are
|
|
139
|
+
* admitted by the id-mint gate (the sanctioned upsert-by-deterministic-key category).
|
|
140
|
+
*/
|
|
141
|
+
export const wrapKey = directive("wrapKey")
|
|
142
|
+
.ensures(WrappedKey)
|
|
143
|
+
.payload(
|
|
144
|
+
z.object({
|
|
145
|
+
scope: z.string().regex(CAP_RE),
|
|
146
|
+
epoch: z.number().int().nonnegative(),
|
|
147
|
+
recipientPrincipal: z.string().min(1),
|
|
148
|
+
recipientPubKey: z.string().regex(B64_RE),
|
|
149
|
+
hpkeEpk: z.string().regex(B64_RE),
|
|
150
|
+
ct: z.string().regex(B64_RE),
|
|
151
|
+
}),
|
|
152
|
+
)
|
|
153
|
+
.plan((p) => writeWrap(p.scope, p.epoch, p));
|
|
154
|
+
|
|
155
|
+
/** A reader fetches the wraps addressed to it for a scope+epoch (then unwraps host-side). */
|
|
156
|
+
export const wrappedKeysByRecipient = query("wrappedKeysByRecipient").key("recipientPrincipal").returns(WrappedKey);
|
|
157
|
+
/** The current epoch of a scope (the writer reads it to tag its envelope). */
|
|
158
|
+
export const keyScopeByScope = query("keyScopeByScope").key("scope").returns(KeyScope);
|
|
@@ -0,0 +1,205 @@
|
|
|
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/relations.ts — NATIVE ZANZIBAR / ReBAC TUPLE EVENTS (the ONE authz spine).
|
|
8
|
+
*
|
|
9
|
+
* A relation tuple `object#relation@subject` is a Zanzibar authorization FACT, and on
|
|
10
|
+
* Nomos it is a LEDGER EVENT folded into the projected `relations` table. This module is
|
|
11
|
+
* the DSL surface that AUTHORS those events — the object-keyed, clean-slate-native shape
|
|
12
|
+
* the F4 reversal ratified (the per-USER `UserPermission` map was the pre-Zanzibar relic;
|
|
13
|
+
* object-keying is what gives per-RECORD sharing + `Expand` for free).
|
|
14
|
+
*
|
|
15
|
+
* ── The aggregate (object-keyed) ─────────────────────────────────────────────
|
|
16
|
+
* `RelationTuple` is keyed `reltuple-<object>-<relation>` — ONE aggregate per
|
|
17
|
+
* (object, relation) pair — carrying a `subjects: MapOf(Lww)` map of `subject → leaf`,
|
|
18
|
+
* where each leaf is a JSON `{ status }` ("active" | "revoked"). The projection folds an
|
|
19
|
+
* ACTIVE subject into a `relations(object, relation, subject)` row; a revoked subject
|
|
20
|
+
* yields no row (Check fails closed).
|
|
21
|
+
*
|
|
22
|
+
* ── Why a status leaf, not a remove-wins set ─────────────────────────────────
|
|
23
|
+
* Kernel `RemoveWins` is palette-only (no kernel driver → excluded from the identity
|
|
24
|
+
* manifest), so a native tuple cannot use a remove-wins member set and stay deployable.
|
|
25
|
+
* We replicate `UserPermission`'s sound trick: revoke flips the SAME map entry to
|
|
26
|
+
* `status:"revoked"` (an Lww write over the per-subject key). The gate supplies
|
|
27
|
+
* REMOVE-WINS by refusing to re-grant a subject that was ever revoked in the prefix
|
|
28
|
+
* (slice 4); the projection already drops a revoked leaf. Only Lww/MapOf drivers → the
|
|
29
|
+
* aggregate is identity-manifest-eligible.
|
|
30
|
+
*
|
|
31
|
+
* ── Determinism ──────────────────────────────────────────────────────────────
|
|
32
|
+
* The aggregate id is a pure function of (object, relation); the subject is the map KEY.
|
|
33
|
+
* A re-offered grant is therefore idempotent (upsert-by-deterministic-key — the
|
|
34
|
+
* sanctioned `.ensures` category). Plans carry no clock/random — provenance timestamps
|
|
35
|
+
* ride in the payload as ISO strings.
|
|
36
|
+
*
|
|
37
|
+
* ── Reserved tokens ──────────────────────────────────────────────────────────
|
|
38
|
+
* `object` is `"<type>:<id>"`; `subject` is `user:<uid>`, the public wildcard `user:*`,
|
|
39
|
+
* or a userset `<object>#<relation>`. A raw uid carrying the `:` delimiter or being the
|
|
40
|
+
* bare `*` is refused at the payload boundary so it can never forge a wildcard/userset.
|
|
41
|
+
*/
|
|
42
|
+
import { z } from "zod";
|
|
43
|
+
import { aggregate, instance } from "../aggregate.js";
|
|
44
|
+
import { t } from "../fields.js";
|
|
45
|
+
import { Lww, MapOf } from "../drivers.js";
|
|
46
|
+
import { directive } from "../directive.js";
|
|
47
|
+
import { set, setEntry, withMarker, type PlannedOp } from "../ops.js";
|
|
48
|
+
import { query } from "../query.js";
|
|
49
|
+
|
|
50
|
+
/** Tuple subject lifecycle — the same vocabulary the kernel read drops a non-active leaf by. */
|
|
51
|
+
export const RELATION_STATUSES = ["active", "revoked"] as const;
|
|
52
|
+
export type RelationStatus = (typeof RELATION_STATUSES)[number];
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* An OBJECT address: `"<type>:<id>"` (per-record grain) or a workspace-scope object such
|
|
56
|
+
* as `"workspace:<ws>"`. The `:` delimiter is structural — a single colon separating the
|
|
57
|
+
* type from the id — so the projection can split it back into the `relations` row's
|
|
58
|
+
* (object) column verbatim. We allow at most the one delimiter the address shape needs.
|
|
59
|
+
*/
|
|
60
|
+
const OBJECT_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]*:[A-Za-z0-9][A-Za-z0-9_.:/-]*$/;
|
|
61
|
+
/** A relation name (the edge label) — a bare identifier, like a directive/cap name. */
|
|
62
|
+
const RELATION_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
|
|
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.
|
|
67
|
+
*/
|
|
68
|
+
const USER_SUBJECT_RE = /^user:([A-Za-z0-9][A-Za-z0-9_.-]*|\*)$/;
|
|
69
|
+
|
|
70
|
+
/** Deterministic per-(object, relation) aggregate instance id — replay-stable. */
|
|
71
|
+
export function relationTupleInstanceId(object: string, relation: string): string {
|
|
72
|
+
return `reltuple-${object}-${relation}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The native relation-tuple aggregate, OBJECT-KEYED. One instance per (object, relation);
|
|
77
|
+
* `subjects` is the per-subject status map. A read of the projected `relations` table is
|
|
78
|
+
* the Check leaf — this aggregate is the WRITE shape that fold produces those rows.
|
|
79
|
+
*
|
|
80
|
+
* - object Lww (the address, materialised on first relate so the aggregate exists)
|
|
81
|
+
* - relation Lww (the edge label)
|
|
82
|
+
* - subjects MapOf(Lww) — subject → JSON `{status}` leaf (active drops to a tuple row)
|
|
83
|
+
*/
|
|
84
|
+
export const RelationTuple = aggregate("RelationTuple", {
|
|
85
|
+
object: t.string().merge(Lww),
|
|
86
|
+
relation: t.string().merge(Lww),
|
|
87
|
+
subjects: t.map(t.json()).merge(MapOf(Lww)),
|
|
88
|
+
});
|
|
89
|
+
export type RelationTupleHandle = typeof RelationTuple;
|
|
90
|
+
|
|
91
|
+
/** The per-subject leaf the `subjects` map stores (JSON-encoded). Frozen wire shape. */
|
|
92
|
+
export interface RelationSubjectLeaf {
|
|
93
|
+
status: RelationStatus;
|
|
94
|
+
grantedBy: string;
|
|
95
|
+
grantedAt: string;
|
|
96
|
+
revokedBy?: string;
|
|
97
|
+
revokedAt?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Strip undefined keys so the JSON leaf is byte-stable (no dropped-slot `"k":undefined`). */
|
|
101
|
+
function compact(obj: Record<string, unknown>): RelationSubjectLeaf {
|
|
102
|
+
const out: Record<string, unknown> = {};
|
|
103
|
+
for (const [k, v] of Object.entries(obj)) if (v !== undefined) out[k] = v;
|
|
104
|
+
return out as unknown as RelationSubjectLeaf;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface WriteTupleInput {
|
|
108
|
+
object: string;
|
|
109
|
+
relation: string;
|
|
110
|
+
subject: string;
|
|
111
|
+
grantedBy: string;
|
|
112
|
+
grantedAt: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Lower a tuple write to plan ops. The first op MATERIALIZES the (object, relation)
|
|
116
|
+
* aggregate (`ensures` — a re-offer is idempotent); the subject entry rides the map. */
|
|
117
|
+
function lowerTuple(
|
|
118
|
+
p: WriteTupleInput & {
|
|
119
|
+
status: RelationStatus;
|
|
120
|
+
revokedBy?: string | undefined;
|
|
121
|
+
revokedAt?: string | undefined;
|
|
122
|
+
},
|
|
123
|
+
): PlannedOp[] {
|
|
124
|
+
const rt = instance(RelationTuple, relationTupleInstanceId(p.object, p.relation));
|
|
125
|
+
const leaf = compact({
|
|
126
|
+
status: p.status,
|
|
127
|
+
grantedBy: p.grantedBy,
|
|
128
|
+
grantedAt: p.grantedAt,
|
|
129
|
+
revokedBy: p.revokedBy,
|
|
130
|
+
revokedAt: p.revokedAt,
|
|
131
|
+
});
|
|
132
|
+
return [
|
|
133
|
+
// upsert the aggregate head (first relate creates it); without this the referential
|
|
134
|
+
// gate would reject a setEntry on an absent id.
|
|
135
|
+
withMarker(set(rt, "object", p.object), "ensures"),
|
|
136
|
+
set(rt, "relation", p.relation),
|
|
137
|
+
setEntry(rt, "subjects", p.subject, JSON.stringify(leaf)),
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* writeTuple — the framework PRIMITIVE: write one ACTIVE tuple `object#relation@subject`.
|
|
143
|
+
* The collapse target of a relate-family directive. Ensures the (object, relation) head.
|
|
144
|
+
*/
|
|
145
|
+
export function writeTuple(p: WriteTupleInput): PlannedOp[] {
|
|
146
|
+
return lowerTuple({ ...p, status: "active" });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* removeTuple — the framework PRIMITIVE: flip the subject's leaf to `status:revoked`
|
|
151
|
+
* (an Lww write over the SAME map entry — the projection then drops the tuple row). The
|
|
152
|
+
* gate's remove-wins check (slice 4) refuses a later re-grant of an ever-revoked subject.
|
|
153
|
+
*/
|
|
154
|
+
export function removeTuple(
|
|
155
|
+
p: WriteTupleInput & { revokedBy: string; revokedAt: string },
|
|
156
|
+
): PlannedOp[] {
|
|
157
|
+
return lowerTuple({
|
|
158
|
+
...p,
|
|
159
|
+
status: "revoked",
|
|
160
|
+
revokedBy: p.revokedBy,
|
|
161
|
+
revokedAt: p.revokedAt,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The shared payload boundary — validates the address + subject reserved-token rules. */
|
|
166
|
+
const relatePayload = z.object({
|
|
167
|
+
object: z.string().regex(OBJECT_RE, "object must be <type>:<id>"),
|
|
168
|
+
relation: z.string().regex(RELATION_RE),
|
|
169
|
+
subject: z.string().regex(USER_SUBJECT_RE, "subject must be user:<uid> or user:*"),
|
|
170
|
+
grantedBy: z.string().min(1),
|
|
171
|
+
grantedAt: z.string().min(1), // ISO-8601, caller-stamped (determinism)
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* `relateSubject` — author one ACTIVE tuple. The `.ensures(RelationTuple)` DECLARATION
|
|
176
|
+
* makes `RelationTuple` an ensured type so the deterministic-keyed (object, relation)
|
|
177
|
+
* aggregate is admitted by the id-mint gate (upsert-by-deterministic-key). The relation
|
|
178
|
+
* itself is the cap the gate will require to author it (slice 4 wires `.requires`).
|
|
179
|
+
*/
|
|
180
|
+
export const relateSubject = directive("relateSubject")
|
|
181
|
+
.ensures(RelationTuple)
|
|
182
|
+
.payload(relatePayload)
|
|
183
|
+
.plan((p) => writeTuple(p));
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* `unrelateSubject` — revoke a tuple (flip the subject leaf to revoked). Same ensured
|
|
187
|
+
* type; the aggregate already exists, but `ensures` keeps a revoke-before-grant ordering
|
|
188
|
+
* race idempotent (it materialises the head if somehow absent).
|
|
189
|
+
*/
|
|
190
|
+
export const unrelateSubject = directive("unrelateSubject")
|
|
191
|
+
.ensures(RelationTuple)
|
|
192
|
+
.payload(
|
|
193
|
+
relatePayload.extend({
|
|
194
|
+
revokedBy: z.string().min(1),
|
|
195
|
+
revokedAt: z.string().min(1),
|
|
196
|
+
}),
|
|
197
|
+
)
|
|
198
|
+
.plan((p) => removeTuple(p));
|
|
199
|
+
|
|
200
|
+
/** Enumerate the tuples for one object (the subjects map per relation). The Check/Expand
|
|
201
|
+
* read serves off the projected `relations` table; this declared query exposes the raw
|
|
202
|
+
* tuple aggregate for tooling/audit. */
|
|
203
|
+
export const relationTuplesByObject = query("relationTuplesByObject")
|
|
204
|
+
.key("object")
|
|
205
|
+
.returns(RelationTuple);
|
|
@@ -81,9 +81,18 @@ export const WORKSPACE_TIERS = ["edge", "heavy"] as const;
|
|
|
81
81
|
/**
|
|
82
82
|
* The workspace lifecycle: `requested` (law fact only — nothing physical exists)
|
|
83
83
|
* → `provisioned` (the worker built the machinery and recorded the owner hash)
|
|
84
|
-
* → `retired` (the law has withdrawn it; the worker refuses its bearers)
|
|
84
|
+
* → `retired` (the law has withdrawn it; the worker refuses its bearers)
|
|
85
|
+
* → `reclaimed` (the LAW has authorized the host to DISCARD the custody — the git
|
|
86
|
+
* ledger and the DO state are dropped; the custody-reclaimed FACT remains here
|
|
87
|
+
* forever. The ONE status from which there is no return: retirement is a status,
|
|
88
|
+
* reclamation is the lawful discard of the bytes. See {@link reclaimWorkspace}).
|
|
85
89
|
*/
|
|
86
|
-
export const WORKSPACE_STATUSES = [
|
|
90
|
+
export const WORKSPACE_STATUSES = [
|
|
91
|
+
"requested",
|
|
92
|
+
"provisioned",
|
|
93
|
+
"retired",
|
|
94
|
+
"reclaimed",
|
|
95
|
+
] as const;
|
|
87
96
|
|
|
88
97
|
/** A grant is `active` until the law revokes it — revocation is permanent. */
|
|
89
98
|
export const GRANT_STATUSES = ["active", "revoked"] as const;
|
|
@@ -454,6 +463,36 @@ export const retireWorkspace = directive("retireWorkspace")
|
|
|
454
463
|
return [set(ws, "status", "retired" as const)];
|
|
455
464
|
});
|
|
456
465
|
|
|
466
|
+
/**
|
|
467
|
+
* reclaimWorkspace — THE LAWFUL CUSTODY DISCARD. Where {@link retireWorkspace}
|
|
468
|
+
* withdraws the law (a status, the bytes untouched), reclaim is the law AUTHORIZING
|
|
469
|
+
* the host to drop the custody: the worker bailiff, on seeing THIS fact folded,
|
|
470
|
+
* `ARTIFACTS.delete`s the workspace's git ledger and `deleteAll`s its DO storage.
|
|
471
|
+
*
|
|
472
|
+
* THE TWO-PHASE RULE (enforced worker-side, since a plan cannot read prior state):
|
|
473
|
+
* only a `retired` workspace may be reclaimed — retire withdraws, reclaim discards.
|
|
474
|
+
*
|
|
475
|
+
* THE FIXED POINT: this is authored for CHILDREN only. The worker refuses to reclaim
|
|
476
|
+
* ROOT — the genesis workspace is the seat of the law that authorizes reclaim, so the
|
|
477
|
+
* law can reap everything it governs EXCEPT itself. Dropping root is the one act Nomos
|
|
478
|
+
* cannot perform in-law; the custodian does it through Cloudflare directly. The
|
|
479
|
+
* custody-reclaimed fact (this status flip) is append-only and outlives the bytes:
|
|
480
|
+
* Nomos never erases the FACT that a workspace existed and was reclaimed.
|
|
481
|
+
*/
|
|
482
|
+
export const reclaimWorkspace = directive("reclaimWorkspace")
|
|
483
|
+
.mutates(CloudWorkspace)
|
|
484
|
+
.payload(
|
|
485
|
+
z.object({
|
|
486
|
+
workspaceId: z.string().min(1),
|
|
487
|
+
reclaimedBy: z.string().min(1),
|
|
488
|
+
reclaimedAt: epochMs,
|
|
489
|
+
}),
|
|
490
|
+
)
|
|
491
|
+
.plan((p) => {
|
|
492
|
+
const ws = instance(CloudWorkspace, p.workspaceId);
|
|
493
|
+
return [set(ws, "status", "reclaimed" as const)];
|
|
494
|
+
});
|
|
495
|
+
|
|
457
496
|
/**
|
|
458
497
|
* birthPlatform — A PLATFORM IS BORN: the cloud, one level down. Records the
|
|
459
498
|
* platform-kind workspace (parented to root, owned by the invited principal,
|
package/src/index.ts
CHANGED
|
@@ -13,12 +13,17 @@ export {
|
|
|
13
13
|
aggregate,
|
|
14
14
|
instance,
|
|
15
15
|
forwardRef,
|
|
16
|
+
requires,
|
|
17
|
+
OWNER_FIELD,
|
|
16
18
|
type AggregateHandle,
|
|
19
|
+
type AudienceMethods,
|
|
20
|
+
type Audience,
|
|
17
21
|
type BoundAggregate,
|
|
18
22
|
type StoredFields,
|
|
19
23
|
type HasManyFields,
|
|
20
24
|
type AggregateInvariantFn,
|
|
21
25
|
type AggregateInvariantVerdict,
|
|
26
|
+
type ReadVisibility,
|
|
22
27
|
} from "./aggregate.js";
|
|
23
28
|
export {
|
|
24
29
|
t,
|
|
@@ -30,6 +35,8 @@ export {
|
|
|
30
35
|
} from "./fields.js";
|
|
31
36
|
export {
|
|
32
37
|
Lww,
|
|
38
|
+
LwwCausal,
|
|
39
|
+
LwwBy,
|
|
33
40
|
AddWins,
|
|
34
41
|
Conflict,
|
|
35
42
|
MapOf,
|
|
@@ -62,7 +69,7 @@ export { directive, type Directive, type ReferentialMarker } from "./directive.j
|
|
|
62
69
|
// a DDD-fluent `AggregateRef`; the dev `.set`/`.add`(collection)/`.setEntry`(map)/`.relate`(reference)
|
|
63
70
|
// — naming no id. A directive's `.plan` calls these and returns `[]` (the recorded graph is the write
|
|
64
71
|
// surface). The dev never sees a "prim" — that is the GitHolon's internal OpenUSD IR.
|
|
65
|
-
export { create, existing, type AggregateRef } from "./authoring.js";
|
|
72
|
+
export { create, existing, ensure, type AggregateRef } from "./authoring.js";
|
|
66
73
|
// THE CAPTURED READ — a plan reads O(1) DSL queries via `read(query, args)`; the result is committed as
|
|
67
74
|
// the intent's read footprint (deterministic replay + stale-premise detection). The dev calls typed DSL
|
|
68
75
|
// queries; the library routes them here. The host capability is `nomos.read` (twin of `nomos.rng`).
|
|
@@ -202,6 +209,45 @@ export {
|
|
|
202
209
|
type Scope,
|
|
203
210
|
type RoleCatalogue,
|
|
204
211
|
} from "./framework/rbac.js";
|
|
212
|
+
// NATIVE ZANZIBAR / ReBAC (architecture/zanzibar_native_plan.md, slice 1) — object-keyed
|
|
213
|
+
// relation-tuple events `object#relation@subject`, the clean-slate-native authz spine.
|
|
214
|
+
export {
|
|
215
|
+
RELATION_STATUSES,
|
|
216
|
+
RelationTuple,
|
|
217
|
+
relationTupleInstanceId,
|
|
218
|
+
writeTuple,
|
|
219
|
+
removeTuple,
|
|
220
|
+
relateSubject,
|
|
221
|
+
unrelateSubject,
|
|
222
|
+
relationTuplesByObject,
|
|
223
|
+
type RelationStatus,
|
|
224
|
+
type RelationSubjectLeaf,
|
|
225
|
+
type RelationTupleHandle,
|
|
226
|
+
} from "./framework/relations.js";
|
|
227
|
+
// E2E ENCRYPTION (e2e_encryption.md slice 1) — the published per-principal X25519
|
|
228
|
+
// encryption-key registry; the wrap-target lookup the key-handoff fan-out resolves.
|
|
229
|
+
export {
|
|
230
|
+
EncKey,
|
|
231
|
+
encKeyInstanceId,
|
|
232
|
+
writeEncKey,
|
|
233
|
+
publishEncKey,
|
|
234
|
+
encKeyByPrincipal,
|
|
235
|
+
type EncKeyHandle,
|
|
236
|
+
} from "./framework/encryption_identity.js";
|
|
237
|
+
// E2E ENCRYPTION (slice 4) — the group-key events (RFC 9420 sender-keys on the ledger).
|
|
238
|
+
export {
|
|
239
|
+
KeyScope,
|
|
240
|
+
WrappedKey,
|
|
241
|
+
advanceKeyEpoch,
|
|
242
|
+
wrapKey,
|
|
243
|
+
wrappedKeysByRecipient,
|
|
244
|
+
keyScopeByScope,
|
|
245
|
+
keyScopeInstanceId,
|
|
246
|
+
wrappedKeyInstanceId,
|
|
247
|
+
type KeyScopeHandle,
|
|
248
|
+
type WrappedKeyHandle,
|
|
249
|
+
type WrapEntry,
|
|
250
|
+
} from "./framework/encryption_scope.js";
|
|
205
251
|
export {
|
|
206
252
|
IMPURE_CAPABILITY_STATUSES,
|
|
207
253
|
IMPURE_CAPABILITY_ENVELOPE,
|
package/src/manifest.ts
CHANGED
|
@@ -89,6 +89,22 @@ export interface CanonicalAggregate {
|
|
|
89
89
|
readonly hasInvariant?: true;
|
|
90
90
|
/** The LAW-DECLARED instance cap; omitted when uncapped (hash-stable). */
|
|
91
91
|
readonly cap?: number;
|
|
92
|
+
/**
|
|
93
|
+
* READ VISIBILITY (slice 5.1) — the capability a read TOUCHING this type requires.
|
|
94
|
+
* PRESENT only when the type is declared `visibility: requires(cap)` (PRIVATE);
|
|
95
|
+
* OMITTED ENTIRELY for the default `public`, so a public-only domain is
|
|
96
|
+
* byte-identical in the manifest to before this key existed (golden hashes
|
|
97
|
+
* UNCHANGED — mirrors `cap`/`hasInvariant`). It IS law (the githolon refuses an
|
|
98
|
+
* unauthorized read on it), so adding/changing it MOVES the hash.
|
|
99
|
+
*/
|
|
100
|
+
readonly requiresRead?: string;
|
|
101
|
+
/**
|
|
102
|
+
* E2E ENCRYPTION (slice 2) — the STORED field names whose values are `.encrypted()`,
|
|
103
|
+
* SORTED. PRESENT only when ≥1 field is encrypted; OMITTED otherwise (a plaintext
|
|
104
|
+
* aggregate is byte-identical). It IS law (the gate enforces the envelope + the scope),
|
|
105
|
+
* so adding/changing it MOVES the hash. The scope is the aggregate's `requiresRead`.
|
|
106
|
+
*/
|
|
107
|
+
readonly encryptedFields?: string[];
|
|
92
108
|
}
|
|
93
109
|
|
|
94
110
|
/** One captured directive contract — NO `plan`/`scopeFrom` behaviour. */
|
|
@@ -994,7 +1010,19 @@ function stableIdsForModule(
|
|
|
994
1010
|
*/
|
|
995
1011
|
export function domainManifest(mod: DomainModule): CanonicalManifest {
|
|
996
1012
|
const aggregates: CanonicalAggregate[] = mod.aggregates
|
|
997
|
-
.map((agg) =>
|
|
1013
|
+
.map((agg) => {
|
|
1014
|
+
// SECURE-BY-DEFAULT — `.owned()` is declared in the audience API, but its owner-SCOPED reads
|
|
1015
|
+
// (the projection row-filter) + per-record E2E are not yet enforced. Refuse to compile owned law
|
|
1016
|
+
// FAIL-CLOSED rather than ship a type whose reads aren't gated (architecture/secure_by_default_dsl.md
|
|
1017
|
+
// build order). Use `.public()` or `.readableBy(cap)` until the `.owned()` slice lands.
|
|
1018
|
+
if (agg.audience?.kind === "owned") {
|
|
1019
|
+
throw new Error(
|
|
1020
|
+
`aggregate '${agg.id}': .owned() is declared but its read-scoping + E2E are not yet enforced — ` +
|
|
1021
|
+
`refusing to compile owned law (fail-closed). Use .public() or .readableBy("<cap>") for now ` +
|
|
1022
|
+
`(the .owned() slice is next — see architecture/secure_by_default_dsl.md).`,
|
|
1023
|
+
);
|
|
1024
|
+
}
|
|
1025
|
+
return {
|
|
998
1026
|
id: agg.id,
|
|
999
1027
|
schema: sortedSchema(encodeKernelSchema(agg)),
|
|
1000
1028
|
// OMIT-WHEN-ABSENT: only emit `hasInvariant` when the aggregate declares one,
|
|
@@ -1003,7 +1031,14 @@ export function domainManifest(mod: DomainModule): CanonicalManifest {
|
|
|
1003
1031
|
...(agg.hasInvariant === true ? { hasInvariant: true as const } : {}),
|
|
1004
1032
|
// The LAW-DECLARED instance cap — hash-bearing (it IS law), omit-when-absent.
|
|
1005
1033
|
...(agg.cap !== undefined ? { cap: agg.cap } : {}),
|
|
1006
|
-
|
|
1034
|
+
// READ VISIBILITY (slice 5.1) — the capability a read of this type requires.
|
|
1035
|
+
// Hash-bearing (it IS law); omit-when-public so all-public domains are byte-stable.
|
|
1036
|
+
...(agg.visibility !== undefined ? { requiresRead: agg.visibility.requires } : {}),
|
|
1037
|
+
// E2E ENCRYPTION (slice 2) — the encrypted field names. Hash-bearing law;
|
|
1038
|
+
// omit-when-none so plaintext aggregates are byte-stable.
|
|
1039
|
+
...(agg.encryptedFields !== undefined ? { encryptedFields: [...agg.encryptedFields] } : {}),
|
|
1040
|
+
};
|
|
1041
|
+
})
|
|
1007
1042
|
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
1008
1043
|
|
|
1009
1044
|
const directives: CanonicalDirective[] = mod.directives
|
|
@@ -1026,6 +1061,23 @@ export function domainManifest(mod: DomainModule): CanonicalManifest {
|
|
|
1026
1061
|
}))
|
|
1027
1062
|
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
1028
1063
|
|
|
1064
|
+
// E2E ENCRYPTION — critical #5 (e2e_encryption.md §5), the captured-read seal, COMPILE
|
|
1065
|
+
// half. A captured read (`read()` on the write path) of a PRIVATE query would record its
|
|
1066
|
+
// plaintext result onto the intent as PUBLIC, signed, replay-verified chain data —
|
|
1067
|
+
// bypassing the read gate AND the encryption envelope. Forbid it: a `requires(cap)`
|
|
1068
|
+
// query can NEVER be a captured read. (The one gate re-asserts this at admission/replay
|
|
1069
|
+
// via `cap_for_query`, so a hand-crafted raw intent is refused on every peer too.)
|
|
1070
|
+
const privateTypes = new Set(aggregates.filter((a) => a.requiresRead !== undefined).map((a) => a.id));
|
|
1071
|
+
for (const d of directives) {
|
|
1072
|
+
for (const cr of d.capturedReads ?? []) {
|
|
1073
|
+
if (privateTypes.has(cr.returns)) {
|
|
1074
|
+
throw new Error(
|
|
1075
|
+
`directive '${d.id}': captured read '${cr.queryId}' returns the PRIVATE type '${cr.returns}' — a .reads() of a requires(cap) query would write its plaintext onto the chain as public data. Private data may only leave via the gated read path (query/qbyid), never a captured read.`,
|
|
1076
|
+
);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1029
1081
|
// exists_ entries are COUNT declarations in the IR — merge them into `counts` so the
|
|
1030
1082
|
// Rust engine sees them as ordinary counts (the marker lives only in the DSL/codegen
|
|
1031
1083
|
// layer). Combine mod.counts + normalized exists_ before canonicalizing counts.
|
package/src/usd.ts
CHANGED
|
@@ -213,6 +213,37 @@ export interface UsdLayer {
|
|
|
213
213
|
/** directive id → the SORTED query ids its plan may read. */
|
|
214
214
|
readonly directives: Record<string, string[]>;
|
|
215
215
|
};
|
|
216
|
+
/**
|
|
217
|
+
* THE READ-VISIBILITY GATE DECLARATION (slice 5.1, `host_routes_only.md` §5.1).
|
|
218
|
+
* Present IFF ≥1 aggregate of this layer is declared PRIVATE (`visibility:
|
|
219
|
+
* requires(cap)`): the law's EXPLICIT opt-in to the githolon gating its own
|
|
220
|
+
* reads. The one wasm read boundary serves a read touching a private type ONLY to
|
|
221
|
+
* a principal that effectively holds the required `cap` in this workspace's own
|
|
222
|
+
* folded role-bindings (the kernel `identity::authz` path). OMITTED for all-public
|
|
223
|
+
* law (byte-identical hash — the EXACT `nomosReadGate`/`nomosInvariantGate` era
|
|
224
|
+
* discipline; every pre-slice-5.1 chain serves reads exactly as before, and a
|
|
225
|
+
* public type is never gated). Keyed by aggregate TYPE name → required capability.
|
|
226
|
+
*/
|
|
227
|
+
readonly nomosReadVisibility?: {
|
|
228
|
+
readonly v: 1;
|
|
229
|
+
/** Aggregate TYPE name → the capability a read touching it requires. SORTED by type name. */
|
|
230
|
+
readonly aggregates: Record<string, { requires: string }>;
|
|
231
|
+
};
|
|
232
|
+
/**
|
|
233
|
+
* THE ENCRYPTION GATE DECLARATION (e2e_encryption.md slice 2). Present IFF ≥1 field of
|
|
234
|
+
* this layer is `.encrypted()`: the law's EXPLICIT opt-in to at-rest field ciphertext.
|
|
235
|
+
* The gate enforces the envelope structure + `scope == the aggregate's read cap` +
|
|
236
|
+
* `epoch ≤ active` on every encrypted-field write (key-free, every peer); a key-holder
|
|
237
|
+
* decrypts at read time. OMITTED for plaintext law (byte-identical hash — the EXACT
|
|
238
|
+
* `nomosReadVisibility` era discipline; every pre-encryption chain replays as before).
|
|
239
|
+
* `scope` is the aggregate's `requires(cap)`; `delegatable:false` — an encrypted scope
|
|
240
|
+
* is NON-DELEGATABLE (the adversarial-review fix: a delegated read cap must NOT mint a
|
|
241
|
+
* key), so the key roster is direct grants only. Keyed by aggregate TYPE, SORTED.
|
|
242
|
+
*/
|
|
243
|
+
readonly nomosEncryption?: {
|
|
244
|
+
readonly v: 1;
|
|
245
|
+
readonly aggregates: Record<string, { scope: string; fields: string[]; delegatable: false }>;
|
|
246
|
+
};
|
|
216
247
|
}
|
|
217
248
|
|
|
218
249
|
/** The reserved variant name declaring a set's DEFAULT (USD has a default variant). */
|
|
@@ -310,6 +341,19 @@ export function emitUsd(
|
|
|
310
341
|
for (const k of [...rgQueries.keys()].sort()) readGateQueries[k] = rgQueries.get(k)!;
|
|
311
342
|
const readGateDirectives: Record<string, string[]> = {};
|
|
312
343
|
for (const k of [...rgDirectives.keys()].sort()) readGateDirectives[k] = rgDirectives.get(k)!;
|
|
344
|
+
// THE READ-VISIBILITY GATE (slice 5.1): every aggregate the manifest carries a
|
|
345
|
+
// `requiresRead` for, keyed by TYPE name → required cap. SORTED keys
|
|
346
|
+
// (byte-determinism); omit-when-empty so an all-public layer is byte-identical.
|
|
347
|
+
const visAggregates: Record<string, { requires: string }> = {};
|
|
348
|
+
const encAggregates: Record<string, { scope: string; fields: string[]; delegatable: false }> = {};
|
|
349
|
+
for (const a of [...manifest.aggregates].sort((x, y) => (x.id < y.id ? -1 : x.id > y.id ? 1 : 0))) {
|
|
350
|
+
if (a.requiresRead !== undefined) visAggregates[a.id] = { requires: a.requiresRead };
|
|
351
|
+
// THE ENCRYPTION GATE (slice 2): an aggregate with encrypted field(s) — its scope
|
|
352
|
+
// is its read cap (aggregate()/the gate guarantee they coincide). delegatable:false.
|
|
353
|
+
if (a.encryptedFields !== undefined && a.encryptedFields.length > 0 && a.requiresRead !== undefined) {
|
|
354
|
+
encAggregates[a.id] = { scope: a.requiresRead, fields: [...a.encryptedFields], delegatable: false };
|
|
355
|
+
}
|
|
356
|
+
}
|
|
313
357
|
return {
|
|
314
358
|
path: l.path,
|
|
315
359
|
prims: encodeModuleToPrims(l.path, l.module),
|
|
@@ -336,6 +380,12 @@ export function emitUsd(
|
|
|
336
380
|
},
|
|
337
381
|
}
|
|
338
382
|
: {}),
|
|
383
|
+
...(Object.keys(visAggregates).length > 0
|
|
384
|
+
? { nomosReadVisibility: { v: 1 as const, aggregates: visAggregates } }
|
|
385
|
+
: {}),
|
|
386
|
+
...(Object.keys(encAggregates).length > 0
|
|
387
|
+
? { nomosEncryption: { v: 1 as const, aggregates: encAggregates } }
|
|
388
|
+
: {}),
|
|
339
389
|
};
|
|
340
390
|
})
|
|
341
391
|
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|