@githolon/dsl 0.15.0 → 0.16.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/dart/lib/src/webview_bridge.dart +52 -0
- package/package.json +1 -1
- package/src/aggregate.ts +9 -1
- package/src/authoring.ts +10 -1
- package/src/codegen_dart.ts +22 -12
- package/src/codegen_dart_client.ts +54 -5
- package/src/codegen_ts.ts +32 -7
- package/src/compile_package_main.ts +90 -20
- package/src/ctx.ts +13 -0
- package/src/engine_entry.ts +22 -5
- package/src/fields.ts +44 -0
- package/src/flow_gate.ts +208 -0
- package/src/framework/capability.ts +19 -1
- package/src/framework/encryption_scope.ts +9 -1
- package/src/framework/genesis.ts +5 -1
- package/src/framework/relations.ts +23 -4
- package/src/framework/signing_identity.ts +1 -1
- package/src/framework/workspace_invariant.ts +29 -8
- package/src/framework/workspaces.ts +7 -0
- package/src/index.ts +24 -0
- package/src/manifest.ts +11 -17
- package/src/ops.ts +42 -1
- package/src/principal_evidence.ts +112 -0
- package/src/relation_sugar.ts +10 -4
- package/src/shape_compat.ts +208 -0
- package/src/usd.ts +131 -0
- package/src/usd_layers.ts +43 -0
- package/src/wire.ts +8 -0
- package/src/wire_encode.ts +75 -2
- package/src/workspace_sharding.ts +195 -17
- package/src/workspace_type.ts +1 -0
package/src/ctx.ts
CHANGED
|
@@ -14,11 +14,19 @@
|
|
|
14
14
|
* - ctx.rng -> Random port
|
|
15
15
|
*/
|
|
16
16
|
import type { WireHlc } from "./wire.js";
|
|
17
|
+
import { principalEvidence, type PrincipalEvidence } from "./principal_evidence.js";
|
|
17
18
|
|
|
18
19
|
export interface Ports {
|
|
19
20
|
clock(): WireHlc;
|
|
20
21
|
id(): string;
|
|
21
22
|
rng(): number;
|
|
23
|
+
/**
|
|
24
|
+
* THE IDENTITY PORT (security_model.md — the endorse model): the captured, gate-verified
|
|
25
|
+
* authoring principal as opaque {@link PrincipalEvidence}. NOT a string — it can only become an
|
|
26
|
+
* attribution or an ENDORSED authority/standing value via its conversions, so identity cannot
|
|
27
|
+
* decide authority in plan code (the confused-deputy defence). Replay-stable (a captured port).
|
|
28
|
+
*/
|
|
29
|
+
principal(): PrincipalEvidence;
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
/** A deterministic stub set of ports — seeded, replayable. */
|
|
@@ -27,6 +35,7 @@ export function deterministicPorts(seed: {
|
|
|
27
35
|
logical?: number;
|
|
28
36
|
replica?: number;
|
|
29
37
|
idPrefix?: string;
|
|
38
|
+
principal?: string;
|
|
30
39
|
} = {}): Ports {
|
|
31
40
|
let physical = seed.physical ?? 1;
|
|
32
41
|
let logical = seed.logical ?? 0;
|
|
@@ -34,7 +43,11 @@ export function deterministicPorts(seed: {
|
|
|
34
43
|
const idPrefix = seed.idPrefix ?? "id";
|
|
35
44
|
let idCounter = 0;
|
|
36
45
|
let rngState = (seed.physical ?? 1) >>> 0 || 1;
|
|
46
|
+
const evidence = principalEvidence(seed.principal ?? "");
|
|
37
47
|
return {
|
|
48
|
+
principal(): PrincipalEvidence {
|
|
49
|
+
return evidence;
|
|
50
|
+
},
|
|
38
51
|
clock(): WireHlc {
|
|
39
52
|
const h: WireHlc = { physical, logical, replica };
|
|
40
53
|
// Advance: same physical -> bump logical (mirrors merge.md local-event rule).
|
package/src/engine_entry.ts
CHANGED
|
@@ -57,6 +57,7 @@ import { RelationTuple } from "./framework/relations.js";
|
|
|
57
57
|
import type { Directive } from "./directive.js";
|
|
58
58
|
import type { AggregateHandle, AggregateInvariantFn, AggregateInvariantVerdict } from "./aggregate.js";
|
|
59
59
|
import type { Ports } from "./ctx.js";
|
|
60
|
+
import { principalEvidence, type PrincipalEvidence } from "./principal_evidence.js";
|
|
60
61
|
import type { WireEvent, WireHlc } from "./wire.js";
|
|
61
62
|
import type { DerivedDecl } from "./derived.js";
|
|
62
63
|
import type { CombinedDecl } from "./combined.js";
|
|
@@ -365,12 +366,18 @@ export function collectEngineRouting(
|
|
|
365
366
|
* intent's HLC — the committed HLC/id is host-injected in Rust); `id()` derives a
|
|
366
367
|
* deterministic per-dispatch value; `rng()` replays the captured draw.
|
|
367
368
|
*/
|
|
368
|
-
function portsFromHost(): Ports {
|
|
369
|
+
function portsFromHost(principalRaw = ""): Ports {
|
|
369
370
|
const hostPorts = (globalThis as { __ports?: { clock(): number; rng(): number } }).__ports;
|
|
370
371
|
const clockScalar = hostPorts ? hostPorts.clock() : 0;
|
|
371
372
|
const rngValue = hostPorts ? hostPorts.rng() : 0;
|
|
372
373
|
let idCounter = 0;
|
|
374
|
+
// The captured, gate-verified principal (the engine serialises it into the dispatch job —
|
|
375
|
+
// host.rs EnginePortsSnapshot.principal) becomes the opaque PrincipalEvidence the plan reads.
|
|
376
|
+
const evidence = principalEvidence(principalRaw);
|
|
373
377
|
return {
|
|
378
|
+
principal(): PrincipalEvidence {
|
|
379
|
+
return evidence;
|
|
380
|
+
},
|
|
374
381
|
clock(): WireHlc {
|
|
375
382
|
return { physical: clockScalar, logical: 0, replica: 0 };
|
|
376
383
|
},
|
|
@@ -386,7 +393,7 @@ function portsFromHost(): Ports {
|
|
|
386
393
|
|
|
387
394
|
/** The compiled engine surface `registerEngine` returns (and assigns onto `globalThis`). */
|
|
388
395
|
export interface RegisteredEngine {
|
|
389
|
-
plan(job: unknown): WireEvent[] | { events: WireEvent[]; strikes: string[] };
|
|
396
|
+
plan(job: unknown): WireEvent[] | { events: WireEvent[]; strikes?: string[]; endorse?: { sink: string; facet: string; reason: string | null }[] };
|
|
390
397
|
planReport(job: unknown): string;
|
|
391
398
|
}
|
|
392
399
|
|
|
@@ -709,7 +716,8 @@ export function registerEngine(config: EngineEntryConfig): RegisteredEngine {
|
|
|
709
716
|
|
|
710
717
|
function plan(job: {
|
|
711
718
|
intent?: { domain?: string; directiveId?: string; payload?: unknown };
|
|
712
|
-
|
|
719
|
+
capturedPorts?: { principal?: string };
|
|
720
|
+
}): WireEvent[] | { events: WireEvent[]; strikes?: string[]; endorse?: { sink: string; facet: string; reason: string | null }[] } {
|
|
713
721
|
const intent = job.intent ?? {};
|
|
714
722
|
const domain = intent.domain;
|
|
715
723
|
const directiveId = intent.directiveId;
|
|
@@ -723,11 +731,20 @@ export function registerEngine(config: EngineEntryConfig): RegisteredEngine {
|
|
|
723
731
|
if (entry === undefined) {
|
|
724
732
|
throw new Error(`engine plan: no directive registered for (${domain}, ${directiveId})`);
|
|
725
733
|
}
|
|
726
|
-
const ctx = portsFromHost();
|
|
734
|
+
const ctx = portsFromHost(job.capturedPorts?.principal);
|
|
727
735
|
const wire = executeDirectiveToIntent(entry.directive, entry.agg, intent.payload as never, ctx);
|
|
728
736
|
// Emit {events, strikes} ONLY when a strike is present, else the bare event array —
|
|
729
737
|
// so every strikes-free directive canonicalizes byte-identically to the pre-strikeout contract.
|
|
730
|
-
|
|
738
|
+
// The OBSERVED ENDORSE footprint (Slice B) rides the object form too — TRANSIENT (the gate reads
|
|
739
|
+
// it to check the law's nomosFlowGate.endorse; decode_plan_output ignores it for the byte-compare),
|
|
740
|
+
// so a principal-free intent still emits the bare array, byte-identical.
|
|
741
|
+
const endorse = wire.endorse;
|
|
742
|
+
if (!wire.strikes.length && !(endorse && endorse.length)) return wire.events;
|
|
743
|
+
return {
|
|
744
|
+
events: wire.events,
|
|
745
|
+
...(wire.strikes.length ? { strikes: wire.strikes } : {}),
|
|
746
|
+
...(endorse && endorse.length ? { endorse } : {}),
|
|
747
|
+
};
|
|
731
748
|
}
|
|
732
749
|
|
|
733
750
|
// The engine evals the bundle as a classic script and FREEZES globalThis before
|
package/src/fields.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { z } from "zod";
|
|
18
18
|
import { Conflict, Counter, LastPosition, Lww, type Driver } from "./drivers.js";
|
|
19
19
|
import type { AggregateHandle } from "./aggregate.js";
|
|
20
|
+
import type { FieldFacet } from "./principal_evidence.js";
|
|
20
21
|
|
|
21
22
|
export type FieldKind =
|
|
22
23
|
| "string"
|
|
@@ -143,8 +144,20 @@ export interface Field<T = unknown, K extends FieldKind = FieldKind> {
|
|
|
143
144
|
* Flag named `isMeta` — the `meta()` METHOD sets it, mirroring `encrypted()`/`optional()`.
|
|
144
145
|
*/
|
|
145
146
|
readonly isMeta?: true;
|
|
147
|
+
/**
|
|
148
|
+
* THE INTEGRITY/CONFIDENTIALITY FACET (security_model.md — the endorse model). Set by the
|
|
149
|
+
* `authorityField`/`attributionField`/`standingField`/`secretField`/`publicField` wrappers (or the
|
|
150
|
+
* `.facet()` sugar). Drives `set()` acceptance of PrincipalEvidence: an `authority`/`standing` sink
|
|
151
|
+
* accepts only the matching ENDORSED conversion (never un-endorsed identity), so the confused deputy
|
|
152
|
+
* is unrepresentable. OMITTED on a plain field (accepts a plain value). The field's READ/wire type is
|
|
153
|
+
* unchanged — the facet governs WRITES, not what's stored.
|
|
154
|
+
*/
|
|
155
|
+
readonly facet?: FieldFacet;
|
|
146
156
|
/** Returns a copy with the chosen driver (kind literal preserved). */
|
|
147
157
|
merge(driver: Driver): Field<T, K>;
|
|
158
|
+
/** Tag this field's facet, threaded through merge/optional (the canonical form is the
|
|
159
|
+
* `authorityField(...)`/`attributionField(...)`/… wrappers, which call this). */
|
|
160
|
+
withFacet<Fa extends FieldFacet>(facet: Fa): Field<T, K> & { readonly facet: Fa };
|
|
148
161
|
/** Mark this field's value ENCRYPTED (sets {@link Field.isEncrypted}). Chainable + non-destructive. */
|
|
149
162
|
encrypted(): Field<T, K>;
|
|
150
163
|
/** Mark this field plaintext METADATA (sets {@link Field.isMeta}) — the indexable opt-out on an owned aggregate. */
|
|
@@ -173,6 +186,7 @@ function makeField<T, K extends FieldKind = FieldKind>(
|
|
|
173
186
|
jsonShape?: "object";
|
|
174
187
|
encrypted?: true;
|
|
175
188
|
meta?: true;
|
|
189
|
+
facet?: FieldFacet;
|
|
176
190
|
} = {},
|
|
177
191
|
): Field<T, K> {
|
|
178
192
|
const isOptional = extra.isOptional ?? false;
|
|
@@ -181,6 +195,7 @@ function makeField<T, K extends FieldKind = FieldKind>(
|
|
|
181
195
|
zod,
|
|
182
196
|
driver,
|
|
183
197
|
isOptional,
|
|
198
|
+
...(extra.facet !== undefined ? { facet: extra.facet } : {}),
|
|
184
199
|
...(extra.refWorkspace !== undefined ? { refWorkspace: extra.refWorkspace } : {}),
|
|
185
200
|
...(extra.refAggregateId !== undefined ? { refAggregateId: extra.refAggregateId } : {}),
|
|
186
201
|
...(extra.viaField !== undefined ? { viaField: extra.viaField } : {}),
|
|
@@ -211,10 +226,39 @@ function makeField<T, K extends FieldKind = FieldKind>(
|
|
|
211
226
|
// Driver/schema unchanged; the fact rides the aggregate's audience surface, not the wire kind.
|
|
212
227
|
return makeField<T, K>(kind, zod, driver, { ...extra, meta: true });
|
|
213
228
|
},
|
|
229
|
+
withFacet<Fa extends FieldFacet>(facet: Fa): Field<T, K> & { readonly facet: Fa } {
|
|
230
|
+
// The facet governs WRITES (set() acceptance), never the wire/read type — so the schema +
|
|
231
|
+
// driver are unchanged. Threaded through merge/optional (it rides `extra`).
|
|
232
|
+
return makeField<T, K>(kind, zod, driver, { ...extra, facet }) as Field<T, K> & { readonly facet: Fa };
|
|
233
|
+
},
|
|
214
234
|
};
|
|
215
235
|
return f;
|
|
216
236
|
}
|
|
217
237
|
|
|
238
|
+
// ── FIELD FACETS (security_model.md — the endorse model) ─────────────────────────────────────
|
|
239
|
+
// Canonical wrappers that tag a field's facet; `set()` then gates which PrincipalEvidence
|
|
240
|
+
// conversions it accepts. The field's read/wire type is unchanged — only WRITES are governed.
|
|
241
|
+
|
|
242
|
+
/** An AUTHORITY sink (relation subject/relation/object, token audience/scope, policy/role/identity
|
|
243
|
+
* write). Accepts a plain value OR `principal().asAuthoritySubject(reason)` — never un-endorsed identity. */
|
|
244
|
+
export const authorityField = <T, K extends FieldKind>(field: Field<T, K>): Field<T, K> & { readonly facet: "authority" } =>
|
|
245
|
+
field.withFacet("authority");
|
|
246
|
+
/** A STANDING sink (accountability: owedBy/owedTo/responsible/issuer/escalation). Accepts a plain
|
|
247
|
+
* value OR `principal().asStandingParty(reason)`. */
|
|
248
|
+
export const standingField = <T, K extends FieldKind>(field: Field<T, K>): Field<T, K> & { readonly facet: "standing" } =>
|
|
249
|
+
field.withFacet("standing");
|
|
250
|
+
/** An ATTRIBUTION sink (audit: createdBy/grantedBy/submittedBy). Accepts a plain value OR
|
|
251
|
+
* `principal().asAttribution()` — free, no endorsement. Promote to `authorityField` if law later
|
|
252
|
+
* reads it to decide authority. */
|
|
253
|
+
export const attributionField = <T, K extends FieldKind>(field: Field<T, K>): Field<T, K> & { readonly facet: "attribution" } =>
|
|
254
|
+
field.withFacet("attribution");
|
|
255
|
+
/** A confidentiality SOURCE (the read-down lattice). */
|
|
256
|
+
export const secretField = <T, K extends FieldKind>(field: Field<T, K>): Field<T, K> & { readonly facet: "secret" } =>
|
|
257
|
+
field.withFacet("secret");
|
|
258
|
+
/** Explicitly PUBLIC (⊤) — the read-down lattice top. */
|
|
259
|
+
export const publicField = <T, K extends FieldKind>(field: Field<T, K>): Field<T, K> & { readonly facet: "public" } =>
|
|
260
|
+
field.withFacet("public");
|
|
261
|
+
|
|
218
262
|
/** Infer the authored value type of a Field. */
|
|
219
263
|
export type FieldValue<F> = F extends Field<infer T> ? T : never;
|
|
220
264
|
|
package/src/flow_gate.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// NOMOS — the domain security model as a STATIC information-flow gate
|
|
2
|
+
// (architecture/security_model.md). A domain's security is a property of its
|
|
3
|
+
// compiled law; this pass makes leaky law UNREPRESENTABLE — the compiler refuses a
|
|
4
|
+
// domain that could ever emit a secret through one of the offer kernel's finite
|
|
5
|
+
// public sinks (refusal/ref transition, captured read, read group/key/where,
|
|
6
|
+
// public field, outbound offer). The irreducible remainder — flows the author
|
|
7
|
+
// INTENDS — is forced into an explicit, audited `declassify(reason)` (a later slice).
|
|
8
|
+
//
|
|
9
|
+
// v1 (this file): the two CONFIDENTIALITY rules that never have a legitimate
|
|
10
|
+
// exception, so they hard-refuse with no `declassify` escape, and both read only
|
|
11
|
+
// canonical manifest IR (no plan-AST interpreter needed — the captured-read-private
|
|
12
|
+
// constraint already severed the plan from foreign secrets):
|
|
13
|
+
// Rule 1 — no encrypt-then-index (an .encrypted() field used as a group/key/where/value)
|
|
14
|
+
// Rule 2 — no private read on chain (a captured read returning a requires(cap) type)
|
|
15
|
+
// Integrity (who may WRITE) is gated natively at admission by `.requires(relation)`;
|
|
16
|
+
// the integrity-flow dual + rule 4 (secret-correlated refusal) + `declassify` + the
|
|
17
|
+
// `nomosFlowGate` era key are the next slices.
|
|
18
|
+
import type { CanonicalManifest } from "./manifest.js";
|
|
19
|
+
import type { CanonicalPred } from "./predicate.js";
|
|
20
|
+
|
|
21
|
+
/** A read projection, read structurally — every read kind (query / count / sum / extremum /
|
|
22
|
+
* ordered) carries its target type (`of`, or a string `returns`) and the fields it observes
|
|
23
|
+
* (`key` / `by` / `valueField` / `where`). Loose by design — one walk over all read shapes. */
|
|
24
|
+
type Projection = {
|
|
25
|
+
readonly id?: string;
|
|
26
|
+
readonly queryId?: string;
|
|
27
|
+
readonly of?: string;
|
|
28
|
+
readonly returns?: unknown;
|
|
29
|
+
readonly key?: readonly string[];
|
|
30
|
+
readonly by?: string;
|
|
31
|
+
readonly valueField?: string;
|
|
32
|
+
readonly where?: CanonicalPred;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** The raw module bits rule 4 needs that the canonical manifest strips (the executable
|
|
36
|
+
* invariant `.reads` + the declassify waivers). Read structurally — no coupling to DomainModule. */
|
|
37
|
+
type FlowModule = {
|
|
38
|
+
readonly aggregates?: readonly {
|
|
39
|
+
readonly id: string;
|
|
40
|
+
readonly hasInvariant?: boolean;
|
|
41
|
+
readonly invariantDeclassify?: string;
|
|
42
|
+
readonly encryptedFields?: readonly string[];
|
|
43
|
+
readonly fields?: Record<string, unknown>;
|
|
44
|
+
}[];
|
|
45
|
+
readonly workspaceInvariants?: readonly {
|
|
46
|
+
readonly id: string;
|
|
47
|
+
readonly on: string;
|
|
48
|
+
readonly declassify?: string;
|
|
49
|
+
readonly reads: (ctx: { intent: Record<string, unknown>; principal?: string }) => readonly { readonly aggregate: string }[];
|
|
50
|
+
}[];
|
|
51
|
+
readonly impureCapabilities?: readonly {
|
|
52
|
+
readonly name?: string;
|
|
53
|
+
readonly capability?: string;
|
|
54
|
+
readonly endpoint?: string;
|
|
55
|
+
readonly declassify?: string;
|
|
56
|
+
readonly aggregate: { readonly id: string };
|
|
57
|
+
}[];
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** A typed flow-gate violation — the message names the field, the sink, and the rule. */
|
|
61
|
+
export class FlowGateError extends Error {
|
|
62
|
+
constructor(message: string) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.name = "FlowGateError";
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The set of field names a `where` predicate OBSERVES (a filter on a value is observing it). */
|
|
69
|
+
function predFields(p: CanonicalPred | undefined): string[] {
|
|
70
|
+
if (!p) return [];
|
|
71
|
+
if (p.kind === "eq" || p.kind === "ne") return [p.field];
|
|
72
|
+
return p.clauses.flatMap(predFields);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Run the static information-flow gate over a domain's canonical manifest. Throws a
|
|
77
|
+
* {@link FlowGateError} on the first violation (a typed compile error). Pure over
|
|
78
|
+
* manifest IR — no side effects, no AST.
|
|
79
|
+
*/
|
|
80
|
+
export function assertFlowGate(m: CanonicalManifest, mod?: FlowModule): void {
|
|
81
|
+
// ── RULE 1 — NO ENCRYPT-THEN-INDEX ──────────────────────────────────────────
|
|
82
|
+
// A field that is .encrypted() (operator-blind ciphertext) MUST NOT appear in any
|
|
83
|
+
// read's group-by / key / value / where over its own type: grouping/indexing/
|
|
84
|
+
// filtering/summing BY a value observes it, so the encryption is defeated and the
|
|
85
|
+
// ciphertext-size + membership metadata leaks. Pure manifest-shape check.
|
|
86
|
+
const encByType = new Map<string, Set<string>>();
|
|
87
|
+
for (const a of m.aggregates) {
|
|
88
|
+
if (a.encryptedFields && a.encryptedFields.length > 0) encByType.set(a.id, new Set(a.encryptedFields));
|
|
89
|
+
}
|
|
90
|
+
if (encByType.size > 0) {
|
|
91
|
+
const typeOf = (p: Projection): string | undefined =>
|
|
92
|
+
p.of ?? (typeof p.returns === "string" ? p.returns : undefined);
|
|
93
|
+
const fieldsOf = (p: Projection): (string | undefined)[] =>
|
|
94
|
+
[...(p.key ?? []), p.by, p.valueField, ...predFields(p.where)];
|
|
95
|
+
const reads: { list: readonly Projection[]; sink: string }[] = [
|
|
96
|
+
{ list: (m.queries ?? []) as unknown as Projection[], sink: "a query key" },
|
|
97
|
+
{ list: (m.counts ?? []) as unknown as Projection[], sink: "a count group / where" },
|
|
98
|
+
{ list: (m.sums ?? []) as unknown as Projection[], sink: "a sum group / value / where" },
|
|
99
|
+
{ list: [...(m.mins ?? []), ...(m.maxes ?? [])] as unknown as Projection[], sink: "an extremum group / value / where" },
|
|
100
|
+
{ list: (m.orderedReads ?? []) as unknown as Projection[], sink: "an ordered-read key / where" },
|
|
101
|
+
];
|
|
102
|
+
for (const { list, sink } of reads) {
|
|
103
|
+
for (const p of list) {
|
|
104
|
+
const type = typeOf(p);
|
|
105
|
+
if (type === undefined) continue;
|
|
106
|
+
const enc = encByType.get(type);
|
|
107
|
+
if (!enc) continue;
|
|
108
|
+
for (const f of fieldsOf(p)) {
|
|
109
|
+
if (f !== undefined && enc.has(f)) {
|
|
110
|
+
throw new FlowGateError(
|
|
111
|
+
`[nomosFlowGate rule 1 — no encrypt-then-index] '${type}.${f}' is .encrypted() but is used as ${sink} ` +
|
|
112
|
+
`('${p.id ?? p.queryId ?? "?"}'). Grouping / indexing / filtering / summing BY a value observes it — the ` +
|
|
113
|
+
`encryption is defeated and the ciphertext-size + membership metadata leaks. Encrypt the field OR index a ` +
|
|
114
|
+
`plaintext one, never both.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── RULE 2 — NO PRIVATE READ RESULT ON THE CHAIN ────────────────────────────
|
|
123
|
+
// A captured read (`.reads(Q)` on the write path) whose Q returns a private type
|
|
124
|
+
// would write its PLAINTEXT result onto the intent on main — public, signed,
|
|
125
|
+
// replay-verified — bypassing the read gate AND the encryption envelope. Private
|
|
126
|
+
// data may only leave via the gated read path (query/qbyid), never a captured read.
|
|
127
|
+
const privateTypes = new Set(m.aggregates.filter((a) => a.requiresRead !== undefined).map((a) => a.id));
|
|
128
|
+
if (privateTypes.size > 0) {
|
|
129
|
+
for (const d of m.directives) {
|
|
130
|
+
for (const cr of (d.capturedReads ?? []) as { queryId: string; returns: string }[]) {
|
|
131
|
+
if (privateTypes.has(cr.returns)) {
|
|
132
|
+
throw new FlowGateError(
|
|
133
|
+
`[nomosFlowGate rule 2 — no private read on the chain] directive '${d.id}': captured read '${cr.queryId}' ` +
|
|
134
|
+
`returns the PRIVATE type '${cr.returns}'. A .reads() of a requires(cap) query writes its plaintext result ` +
|
|
135
|
+
`onto the intent on main as public, signed, replay-verified data — bypassing the read gate. Private data may ` +
|
|
136
|
+
`only leave via the gated read path (query / qbyid), never a captured read.`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── RULE 4 — NO SECRET-CORRELATED REFUSAL (abort-cohort, Send Prop 4) ────────
|
|
144
|
+
// A refusal is a PUBLIC ref transition (refs/heads/{refused,parked}/<offer>). If the
|
|
145
|
+
// DECISION to refuse depends on a secret, the refusal leaks it. Conservative + declared-
|
|
146
|
+
// set based (no predicate-body analysis): an invariant that READS a private aggregate may
|
|
147
|
+
// correlate its refusal with that secret. This FLAGS (require declassify) rather than
|
|
148
|
+
// hard-refusing, because a secret-gated refusal is often INTENDED (the overdraft case).
|
|
149
|
+
if (mod && privateTypes.size > 0) {
|
|
150
|
+
// 4a — an aggregate `invariant` over a private aggregate with a plaintext field.
|
|
151
|
+
for (const a of mod.aggregates ?? []) {
|
|
152
|
+
if (!a.hasInvariant || !privateTypes.has(a.id) || a.invariantDeclassify) continue;
|
|
153
|
+
const enc = new Set(a.encryptedFields ?? []);
|
|
154
|
+
const hasPlaintext = Object.keys(a.fields ?? {}).some((f) => !enc.has(f));
|
|
155
|
+
if (!hasPlaintext) continue; // fully-encrypted: the invariant reads only ciphertext — can't leak this way
|
|
156
|
+
throw new FlowGateError(
|
|
157
|
+
`[nomosFlowGate rule 4 — no secret-correlated refusal] aggregate '${a.id}' is private (requires a read cap) and ` +
|
|
158
|
+
`declares an invariant over plaintext field(s) — its refusal is a public ref transition that can correlate with ` +
|
|
159
|
+
`the secret. If that leak is intended, acknowledge it once: ` +
|
|
160
|
+
`aggregate('${a.id}', fields, { invariant, invariantDeclassify: "<why a refusal reveals this>" }).`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
// 4b — a `workspaceInvariant` whose DECLARED reads touch a private aggregate (in this domain).
|
|
164
|
+
for (const wi of mod.workspaceInvariants ?? []) {
|
|
165
|
+
if (wi.declassify) continue;
|
|
166
|
+
let refs: readonly { readonly aggregate: string }[];
|
|
167
|
+
try {
|
|
168
|
+
refs = wi.reads({ intent: new Proxy({}, { get: () => "" }) as Record<string, unknown>, principal: "" }) ?? [];
|
|
169
|
+
} catch {
|
|
170
|
+
continue; // the read-set can't be resolved on a probe intent — skip (no false refusal; conservative on availability)
|
|
171
|
+
}
|
|
172
|
+
for (const r of refs) {
|
|
173
|
+
const short = r.aggregate.includes(".") ? r.aggregate.slice(r.aggregate.lastIndexOf(".") + 1) : r.aggregate;
|
|
174
|
+
if (privateTypes.has(short)) {
|
|
175
|
+
throw new FlowGateError(
|
|
176
|
+
`[nomosFlowGate rule 4 — no secret-correlated refusal] workspaceInvariant '${wi.id}' (on '${wi.on}') reads the ` +
|
|
177
|
+
`PRIVATE aggregate '${r.aggregate}' — its refusal is a public ref transition that can correlate with that secret. ` +
|
|
178
|
+
`If intended (e.g. an overdraft refusal reveals balance < amount), acknowledge it once: ` +
|
|
179
|
+
`workspaceInvariant('${wi.id}').on(...).reads(...).declassify("<why a refusal reveals this>").assert(...).`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── RULE 5 — NO PRIVATE BYTES IN AN OUTBOUND OFFER (Send's external send) ────
|
|
187
|
+
// The outbox (refs/heads/outbox/<offer>) POSTs a capability's obligation to its endpoint —
|
|
188
|
+
// a DIFFERENT observer set (the external adapter). If the capability's task aggregate is
|
|
189
|
+
// private (requires a read cap) or encrypted, those bytes cross the confidentiality boundary.
|
|
190
|
+
// Refuse unless declassified. CURRENTLY VACUOUS BY CONSTRUCTION (capability task aggregates are
|
|
191
|
+
// public, and rule 2 severs the plan from foreign secrets — so nothing private reaches the
|
|
192
|
+
// outbox), but this ARMS the fifth and last sink so the static analysis is provably complete:
|
|
193
|
+
// it fires the moment a private/encrypted capability becomes expressible.
|
|
194
|
+
if (mod && privateTypes.size > 0) {
|
|
195
|
+
for (const cap of mod.impureCapabilities ?? []) {
|
|
196
|
+
if (!cap.endpoint || cap.declassify) continue;
|
|
197
|
+
if (privateTypes.has(cap.aggregate.id)) {
|
|
198
|
+
const name = cap.name ?? cap.capability ?? cap.aggregate.id;
|
|
199
|
+
throw new FlowGateError(
|
|
200
|
+
`[nomosFlowGate rule 5 — no private bytes in an outbound offer] capability '${name}' POSTs its obligation to ` +
|
|
201
|
+
`'${cap.endpoint}', but its task aggregate '${cap.aggregate.id}' is PRIVATE — the outbound bytes cross to a ` +
|
|
202
|
+
`different observer set (the external adapter). If that send is intended, acknowledge it once: ` +
|
|
203
|
+
`capability('${name}', { request, endpoint, declassify: "<why these bytes may leave the workspace>" }).`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -84,6 +84,22 @@ export interface CapabilityDecl<
|
|
|
84
84
|
> extends ImpureCapability<string, RequestShape<Req>, Req & Rec> {
|
|
85
85
|
/** The capability name — the `CapabilityBinding` slot the executor must hold. */
|
|
86
86
|
readonly name: string;
|
|
87
|
+
/**
|
|
88
|
+
* THE CAPABILITY ENDPOINT (outbox / `capability_offers_https.md`): the HTTPS URL
|
|
89
|
+
* the obligation is offered to. Sealed into the law (the `nomosCapabilities` key);
|
|
90
|
+
* the bailiff reads it from `declared_capabilities` and POSTs each requested
|
|
91
|
+
* obligation there ("a capability is a holon at a URL"). Omitted ⇒ obligations
|
|
92
|
+
* rest at `requested` with nowhere to go (the deploy notice says so).
|
|
93
|
+
*/
|
|
94
|
+
readonly endpoint?: string;
|
|
95
|
+
/**
|
|
96
|
+
* THE DECLASSIFY WAIVER (nomosFlowGate rule 5 — no private bytes in an outbound offer): present
|
|
97
|
+
* iff the author acknowledged that this capability POSTs data derived from a private/encrypted
|
|
98
|
+
* source to its endpoint — a DIFFERENT observer set (the external adapter, Send's external send).
|
|
99
|
+
* Rides the `nomosFlowGate` era key (replay-verified audit surface). Absent on an emitting
|
|
100
|
+
* capability over a private task aggregate ⇒ the flow gate refuses to compile.
|
|
101
|
+
*/
|
|
102
|
+
readonly declassify?: string;
|
|
87
103
|
/** The request fields' zod object (what `oblige<Pascal>`'s params validate as). */
|
|
88
104
|
readonly requestSchema: z.ZodObject<RequestShape<Req>>;
|
|
89
105
|
/**
|
|
@@ -112,7 +128,7 @@ export function capability<
|
|
|
112
128
|
const Name extends string,
|
|
113
129
|
Req extends Record<string, Field>,
|
|
114
130
|
Rec extends Record<string, Field> = Record<never, Field>,
|
|
115
|
-
>(name: Name, spec: { request: Req; receipt?: Rec }): CapabilityDecl<Req, Rec> {
|
|
131
|
+
>(name: Name, spec: { request: Req; receipt?: Rec; endpoint?: string; declassify?: string }): CapabilityDecl<Req, Rec> {
|
|
116
132
|
if (!CAPABILITY_NAME_RE.test(name)) {
|
|
117
133
|
throw new Error(
|
|
118
134
|
`capability('${name}'): the name must match ${CAPABILITY_NAME_RE} (lowercase, dot-namespaced — e.g. 'llm.complete'); it doubles as the CapabilityBinding slot the executor must hold`,
|
|
@@ -204,6 +220,8 @@ export function capability<
|
|
|
204
220
|
aggregate: taskAggregate,
|
|
205
221
|
instanceId: impureCapabilityInstanceId,
|
|
206
222
|
name,
|
|
223
|
+
...(spec.endpoint !== undefined ? { endpoint: spec.endpoint } : {}),
|
|
224
|
+
...(spec.declassify !== undefined ? { declassify: spec.declassify } : {}),
|
|
207
225
|
requestSchema: z.object(requestShape),
|
|
208
226
|
receiptSchema: z.object(receiptShape),
|
|
209
227
|
encodeResult: (values) => JSON.stringify(z.object(receiptShape).parse(values)),
|
|
@@ -40,7 +40,15 @@ import { directive } from "../directive.js";
|
|
|
40
40
|
import { set, withMarker, type PlannedOp } from "../ops.js";
|
|
41
41
|
import { query } from "../query.js";
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
// A SCOPE is the read cap (relation), OPTIONALLY object-qualified for a PER-RECORD scope:
|
|
44
|
+
// `<relation>` — workspace-wide (≡ `workspace:self#<relation>`; the era rule "no `#` ⇒
|
|
45
|
+
// workspace:self" keeps every pre-feature chain byte-identical), or
|
|
46
|
+
// `<type>:<id>#<relation>` — per-record (the roster = Expand(<type>:<id>#<relation>), the §6 grain).
|
|
47
|
+
// The object half mirrors the relation-tuple OBJECT_RE; the relation half is a cap name. `#` is in neither
|
|
48
|
+
// char class, so the split is unambiguous. The kernel parses the same way (executor `parse_scope_object`).
|
|
49
|
+
const REL_PART = "[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}";
|
|
50
|
+
const OBJ_PART = "[A-Za-z0-9][A-Za-z0-9_.-]*:[A-Za-z0-9][A-Za-z0-9_.:/-]*";
|
|
51
|
+
const CAP_RE = new RegExp(`^(?:${OBJ_PART}#)?${REL_PART}$`);
|
|
44
52
|
const B64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
45
53
|
|
|
46
54
|
/** Deterministic ids — the scope head + a per-member wrap, both replay-stable. */
|
package/src/framework/genesis.ts
CHANGED
|
@@ -81,7 +81,11 @@ export const seedGenesis = directive("seedGenesis")
|
|
|
81
81
|
.payload(
|
|
82
82
|
z.object({
|
|
83
83
|
principal: z.string().min(1),
|
|
84
|
-
|
|
84
|
+
// EMPTY ⇒ a CONTROL-PLANE genesis: records the founding principal (arming the kernel's genesis
|
|
85
|
+
// bootstrap clause for the founding admin grant) WITHOUT a self-id, so the workspace stays
|
|
86
|
+
// UNWARRANTED (host-stamped authorship, like root) — deploys/governance/child births keep working
|
|
87
|
+
// unsigned. NON-EMPTY ⇒ a TENANT (signed-author) self-id (the source binding author signatures cover).
|
|
88
|
+
workspaceId: z.string(),
|
|
85
89
|
bornAt: z.string().min(1),
|
|
86
90
|
}),
|
|
87
91
|
)
|
|
@@ -46,6 +46,7 @@ import { Lww, MapOf } from "../drivers.js";
|
|
|
46
46
|
import { directive } from "../directive.js";
|
|
47
47
|
import { set, setEntry, withMarker, type PlannedOp } from "../ops.js";
|
|
48
48
|
import { query } from "../query.js";
|
|
49
|
+
import { endorsementOf, type AttributionPrincipal, type EndorsedAuthority } from "../principal_evidence.js";
|
|
49
50
|
|
|
50
51
|
/** Tuple subject lifecycle — the same vocabulary the kernel read drops a non-active leaf by. */
|
|
51
52
|
export const RELATION_STATUSES = ["active", "revoked"] as const;
|
|
@@ -126,8 +127,15 @@ function compact(obj: Record<string, unknown>): RelationSubjectLeaf {
|
|
|
126
127
|
interface WriteTupleInput {
|
|
127
128
|
object: string;
|
|
128
129
|
relation: string;
|
|
129
|
-
|
|
130
|
-
|
|
130
|
+
/**
|
|
131
|
+
* THE AUTHORITY SINK (the endorse model — security_model.md): who is granted the relation. A plain
|
|
132
|
+
* subject string (`user:<id>` from the payload) OR `ctx.principal().asAuthoritySubject(reason)` — an
|
|
133
|
+
* un-endorsed identity (`asAttribution()`) cannot be a subject (it won't typecheck), so identity
|
|
134
|
+
* never SILENTLY decides a grant.
|
|
135
|
+
*/
|
|
136
|
+
subject: string | EndorsedAuthority<string>;
|
|
137
|
+
/** ATTRIBUTION (free): who recorded the grant. A plain string OR `ctx.principal().asAttribution()`. */
|
|
138
|
+
grantedBy: string | AttributionPrincipal;
|
|
131
139
|
grantedAt: string;
|
|
132
140
|
}
|
|
133
141
|
|
|
@@ -141,19 +149,30 @@ function lowerTuple(
|
|
|
141
149
|
},
|
|
142
150
|
): PlannedOp[] {
|
|
143
151
|
const rt = instance(RelationTuple, relationTupleInstanceId(p.object, p.relation));
|
|
152
|
+
// MATERIALISE PrincipalEvidence subjects/grantedBy → the raw principal (the endorse model). The
|
|
153
|
+
// subject's endorsement is recorded on the setEntry op so the discovery harvest lifts it into
|
|
154
|
+
// nomosFlowGate.endorse; grantedBy is free attribution (materialised, not gated).
|
|
155
|
+
//
|
|
156
|
+
// The captured authoring principal IS already a wire-form subject (`user:<uid>` / `key:<hash>`, the
|
|
157
|
+
// host-stamped actor — the SAME form the write gate parses via `Subject::parse`), so an endorsed
|
|
158
|
+
// authority subject lowers to that leaf verbatim and the gate's `engine.check` matches it.
|
|
159
|
+
const subjectE = endorsementOf(p.subject);
|
|
160
|
+
const subjectRaw = subjectE ? subjectE.principal : (p.subject as string);
|
|
161
|
+
const grantedByRaw = endorsementOf(p.grantedBy)?.principal ?? (p.grantedBy as string);
|
|
144
162
|
const leaf = compact({
|
|
145
163
|
status: p.status,
|
|
146
|
-
grantedBy:
|
|
164
|
+
grantedBy: grantedByRaw,
|
|
147
165
|
grantedAt: p.grantedAt,
|
|
148
166
|
revokedBy: p.revokedBy,
|
|
149
167
|
revokedAt: p.revokedAt,
|
|
150
168
|
});
|
|
169
|
+
const subjectEntry = setEntry(rt, "subjects", subjectRaw, JSON.stringify(leaf));
|
|
151
170
|
return [
|
|
152
171
|
// upsert the aggregate head (first relate creates it); without this the referential
|
|
153
172
|
// gate would reject a setEntry on an absent id.
|
|
154
173
|
withMarker(set(rt, "object", p.object), "ensures"),
|
|
155
174
|
set(rt, "relation", p.relation),
|
|
156
|
-
|
|
175
|
+
subjectE ? { ...subjectEntry, endorsement: { facet: subjectE.facet, reason: subjectE.reason } } : subjectEntry,
|
|
157
176
|
];
|
|
158
177
|
}
|
|
159
178
|
|
|
@@ -67,7 +67,7 @@ const sha256Hex = z.string().regex(/^[0-9a-f]{64}$/, "expected lowercase sha256
|
|
|
67
67
|
* bootstrap chicken-and-egg. The forge surface a bare `relateSubject` would open is closed by
|
|
68
68
|
* DOMAIN PLACEMENT: this directive is composed into the CONTROL-PLANE governance law (domain
|
|
69
69
|
* `workspaces`/`nomos`), which the open session lane structurally refuses
|
|
70
|
-
* (`cloud/
|
|
70
|
+
* (`cloud/cloudflare-shell/src/engine.mjs` — only the birth door + the secret-gated `/author` + admin
|
|
71
71
|
* lanes author it), so no tenant can forge a signer for another principal.
|
|
72
72
|
*
|
|
73
73
|
* THE ORDER IS THE CONTRACT (verified, zero gate change): `enrollSigner` (E, unsigned/pre-warrant) →
|
|
@@ -99,6 +99,14 @@ export interface WorkspaceInvariantDecl {
|
|
|
99
99
|
readonly reads: WorkspaceInvariantReads;
|
|
100
100
|
/** The executable predicate — ships in the engine bundle, NOT the manifest. */
|
|
101
101
|
readonly assert: WorkspaceInvariantAssert;
|
|
102
|
+
/**
|
|
103
|
+
* THE DECLASSIFY WAIVER (nomosFlowGate rule 4 — architecture/security_model.md): present iff
|
|
104
|
+
* the author acknowledged that this invariant's refusal MAY correlate with a secret it reads
|
|
105
|
+
* (a `requires`/`owned` aggregate). The reason is hashed into the `nomosFlowGate` era key (so
|
|
106
|
+
* it is replay-verified + the entire audit surface). Absent ⇒ the flow gate refuses to compile
|
|
107
|
+
* a secret-correlated refusal.
|
|
108
|
+
*/
|
|
109
|
+
readonly declassify?: string;
|
|
102
110
|
/** Brand so a plain object is never mistaken for a declaration. */
|
|
103
111
|
readonly __isWorkspaceInvariant: true;
|
|
104
112
|
}
|
|
@@ -112,22 +120,35 @@ export function workspaceInvariant(id: string): {
|
|
|
112
120
|
on(directiveId: string): {
|
|
113
121
|
reads(reads: WorkspaceInvariantReads): {
|
|
114
122
|
assert(assert: WorkspaceInvariantAssert): WorkspaceInvariantDecl;
|
|
123
|
+
/** Acknowledge an intended secret-correlated refusal (nomosFlowGate rule 4). The reason
|
|
124
|
+
* rides the `nomosFlowGate` era key; then chain `.assert(...)` as usual. */
|
|
125
|
+
declassify(reason: string): { assert(assert: WorkspaceInvariantAssert): WorkspaceInvariantDecl };
|
|
115
126
|
};
|
|
116
127
|
};
|
|
117
128
|
} {
|
|
129
|
+
const finish = (directiveId: string, reads: WorkspaceInvariantReads, declassify: string | undefined) => ({
|
|
130
|
+
assert(assert: WorkspaceInvariantAssert): WorkspaceInvariantDecl {
|
|
131
|
+
return {
|
|
132
|
+
id,
|
|
133
|
+
on: directiveId,
|
|
134
|
+
reads,
|
|
135
|
+
assert,
|
|
136
|
+
...(declassify !== undefined ? { declassify } : {}),
|
|
137
|
+
__isWorkspaceInvariant: true as const,
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
});
|
|
118
141
|
return {
|
|
119
142
|
on(directiveId: string) {
|
|
120
143
|
return {
|
|
121
144
|
reads(reads: WorkspaceInvariantReads) {
|
|
122
145
|
return {
|
|
123
|
-
assert
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
__isWorkspaceInvariant: true,
|
|
130
|
-
};
|
|
146
|
+
assert: finish(directiveId, reads, undefined).assert,
|
|
147
|
+
declassify(reason: string) {
|
|
148
|
+
if (typeof reason !== "string" || reason.trim().length === 0) {
|
|
149
|
+
throw new Error(`workspaceInvariant('${id}').declassify(reason) needs a non-empty human reason — it is the audit record for an intended secret-correlated refusal.`);
|
|
150
|
+
}
|
|
151
|
+
return finish(directiveId, reads, reason);
|
|
131
152
|
},
|
|
132
153
|
};
|
|
133
154
|
},
|
|
@@ -75,6 +75,13 @@ import { query } from "../query.js";
|
|
|
75
75
|
import { workspaceInvariant, refAs } from "./workspace_invariant.js";
|
|
76
76
|
import { writeTuple, removeTuple, RelationTuple } from "./relations.js";
|
|
77
77
|
import { relationType, self, user, key } from "../relation_schema.js";
|
|
78
|
+
// THE GENESIS BOOTSTRAP (platform-birth genesis bootstrap, Approach A): governance carries the
|
|
79
|
+
// warrant-flip primitives so the birth door can lawfully bootstrap a virgin platform's founding admin —
|
|
80
|
+
// E `enrollSigner` (the founding signer tuple) → W `seedGenesis` (the GenesisWarrant) → A signed
|
|
81
|
+
// `grantAdmin` (admitted by the kernel genesis clause). Re-exported so the package's shape-discovery picks
|
|
82
|
+
// them up as part of the `workspaces` domain. (architecture/platform_birth_genesis_bootstrap.md)
|
|
83
|
+
export { GenesisWarrant, seedGenesis } from "./genesis.js";
|
|
84
|
+
export { identityRelations, enrollSigner } from "./signing_identity.js";
|
|
78
85
|
|
|
79
86
|
// ── WORKSPACE SELF-IDENTITY (the law reads its OWN install-context) ──────────────────────────────────
|
|
80
87
|
// The governance bundle runs on root AND every born platform. A workspace knows its ROLE via this
|