@githolon/dsl 0.100.6 → 0.101.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/CHANGELOG.md +6 -0
- package/dart/lib/src/dev_cloud.dart +46 -15
- package/dart/lib/src/test_harness.dart +78 -16
- package/dart/lib/src/webview_bridge.dart +640 -126
- package/dart/pubspec.lock +1 -1
- package/dart/pubspec.yaml +2 -2
- package/dart/test/bundle_version_pairing_test.dart +11 -22
- package/dart/test/co2_product_gauntlet_test.dart +228 -0
- package/dart/test/errors_test.dart +63 -12
- package/dart/test/first_app_gauntlet_test.dart +29 -3
- package/dart/test/webview_bridge_test.dart +274 -0
- package/dart_types/CHANGELOG.md +6 -0
- package/dart_types/lib/src/errors.dart +136 -30
- package/dart_types/lib/src/surfaced.dart +48 -2
- package/dart_types/lib/src/wire.dart +3 -1
- package/dart_types/pubspec.yaml +1 -1
- package/dart_types/test/surfaced_test.dart +19 -0
- package/package.json +1 -1
- package/src/activity.ts +43 -4
- package/src/aggregate.ts +132 -22
- package/src/attested_core.ts +1 -1
- package/src/authoring.ts +119 -6
- package/src/birth_meter.ts +4 -4
- package/src/birth_meter_ensure.ts +9 -1
- package/src/births.ts +53 -22
- package/src/build_package.ts +55 -14
- package/src/business_birth.ts +48 -7
- package/src/codegen_dart.ts +267 -37
- package/src/codegen_dart_client.ts +921 -82
- package/src/codegen_openapi.ts +28 -5
- package/src/codegen_proof.ts +107 -11
- package/src/codegen_ts.ts +961 -135
- package/src/compile_package_main.ts +155 -45
- package/src/conflicts.ts +252 -0
- package/src/describe_model.ts +448 -0
- package/src/directive.ts +5 -38
- package/src/drivers.ts +12 -4
- package/src/engine_entry.ts +4 -0
- package/src/evolve_preview.ts +72 -11
- package/src/fields.ts +201 -55
- package/src/framework/application_home.ts +103 -0
- package/src/framework/application_home_domain.ts +2 -0
- package/src/framework/attested_receipts.ts +2 -2
- package/src/framework/auth_provider_law.ts +51 -24
- package/src/framework/business_corrections.ts +170 -0
- package/src/framework/disclosure.ts +1 -1
- package/src/framework/domain_lifecycle.ts +250 -16
- package/src/framework/encryption_identity.ts +1 -1
- package/src/framework/encryption_scope.ts +21 -13
- package/src/framework/era_certification.ts +1 -1
- package/src/framework/framework_aggregates.ts +2 -0
- package/src/framework/genesis.ts +1 -1
- package/src/framework/home.ts +22 -9
- package/src/framework/identity.ts +5 -5
- package/src/framework/impure_capability.ts +2 -2
- package/src/framework/owned_custody.ts +561 -0
- package/src/framework/platform_admin.ts +1 -1
- package/src/framework/relations.ts +9 -1
- package/src/framework/repair.ts +1 -1
- package/src/framework/signing_identity.ts +2 -2
- package/src/framework/source_roster.ts +1 -1
- package/src/framework/workspaces.ts +17 -10
- package/src/generated_client_names.ts +39 -0
- package/src/index.ts +20 -15
- package/src/kernel_capabilities.ts +25 -1
- package/src/law_check.ts +4 -1
- package/src/manifest.ts +356 -63
- package/src/read_projection_manifest.ts +8 -0
- package/src/read_projection_namespace.ts +4 -0
- package/src/relation.ts +1 -1
- package/src/shape_compat.ts +47 -2
- package/src/stable_ids.ts +38 -6
- package/src/stable_ids_types.ts +30 -0
- package/src/surfaced_lint.ts +0 -0
- package/src/usd.ts +62 -11
- package/src/usd_layers.ts +54 -3
- package/src/value_object.ts +23 -19
- package/src/wire.ts +1 -0
- package/src/wire_encode.ts +48 -4
- package/src/workspace_routing.ts +57 -0
- package/src/workspace_sharding.ts +9 -9
- package/src/certified_read.ts +0 -313
package/src/conflicts.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// NOMOS — business-first conflict policy.
|
|
2
|
+
//
|
|
3
|
+
// The aggregate says what the business IS. This separate, composable object says
|
|
4
|
+
// what to do only when offline copies disagree. Ordinary scalar fields already use
|
|
5
|
+
// FirstWriterWins; a policy names exceptions without scattering merge machinery
|
|
6
|
+
// through the happy-path model.
|
|
7
|
+
|
|
8
|
+
import type { AggregateHandle, StoredFields } from "./aggregate.js";
|
|
9
|
+
import type { Directive } from "./directive.js";
|
|
10
|
+
import type { Field } from "./fields.js";
|
|
11
|
+
import {
|
|
12
|
+
FirstWriterWins,
|
|
13
|
+
Lww,
|
|
14
|
+
Surfaced,
|
|
15
|
+
type Driver,
|
|
16
|
+
} from "./drivers.js";
|
|
17
|
+
|
|
18
|
+
const POLICY_BRAND = "__nomos_conflict_policy_v1" as const;
|
|
19
|
+
const RULE_BRAND = "__nomos_conflict_rule_v1" as const;
|
|
20
|
+
|
|
21
|
+
export interface ConflictRule {
|
|
22
|
+
readonly [RULE_BRAND]: true;
|
|
23
|
+
readonly driver: Driver;
|
|
24
|
+
readonly resolver?: Directive<any>;
|
|
25
|
+
readonly description: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Restate the framework default explicitly inside a policy when that improves its prose. */
|
|
29
|
+
export function firstWriterWins(): ConflictRule {
|
|
30
|
+
return {
|
|
31
|
+
[RULE_BRAND]: true,
|
|
32
|
+
driver: FirstWriterWins,
|
|
33
|
+
description: "the first concurrent writer wins",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Prefer the latest concurrent writer for a field whose business meaning demands it. */
|
|
38
|
+
export function latestWriterWins(): ConflictRule {
|
|
39
|
+
return {
|
|
40
|
+
[RULE_BRAND]: true,
|
|
41
|
+
driver: Lww,
|
|
42
|
+
description: "the latest concurrent writer wins",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Retain concurrent values and route the typed decision through this business verb. */
|
|
47
|
+
export function manual<P>(resolver: Directive<P>): ConflictRule {
|
|
48
|
+
if (
|
|
49
|
+
resolver === null ||
|
|
50
|
+
typeof resolver !== "object" ||
|
|
51
|
+
typeof resolver.id !== "string" ||
|
|
52
|
+
typeof resolver.plan !== "function"
|
|
53
|
+
) {
|
|
54
|
+
throw new Error("manual(resolver) needs a finished Nomos directive — the business verb that decides the conflict");
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
[RULE_BRAND]: true,
|
|
58
|
+
driver: Surfaced(FirstWriterWins),
|
|
59
|
+
resolver,
|
|
60
|
+
description: `hold both values for ${resolver.id}`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type ConflictPolicyShape<F extends Record<string, Field>> = Partial<{
|
|
65
|
+
[K in keyof StoredFields<F>]: ConflictRule;
|
|
66
|
+
}>;
|
|
67
|
+
|
|
68
|
+
export interface ConflictPolicy<
|
|
69
|
+
A extends AggregateHandle = AggregateHandle,
|
|
70
|
+
> {
|
|
71
|
+
readonly [POLICY_BRAND]: true;
|
|
72
|
+
readonly aggregate: A;
|
|
73
|
+
readonly fields: Readonly<Record<string, ConflictRule>>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Attach exceptional offline-conflict policy to one aggregate without interrupting
|
|
78
|
+
* its declaration. Export the returned object beside the aggregate; the compiler
|
|
79
|
+
* discovers it and lowers the chosen rules into the aggregate's law.
|
|
80
|
+
*/
|
|
81
|
+
export function conflicts<
|
|
82
|
+
const Id extends string,
|
|
83
|
+
F extends Record<string, Field>,
|
|
84
|
+
>(
|
|
85
|
+
aggregate: AggregateHandle<Id, F>,
|
|
86
|
+
fields: ConflictPolicyShape<F>,
|
|
87
|
+
): ConflictPolicy<AggregateHandle<Id, F>> {
|
|
88
|
+
const normalized: Record<string, ConflictRule> = {};
|
|
89
|
+
for (const [fieldName, rule] of Object.entries(fields)) {
|
|
90
|
+
const field = (aggregate.fields as Record<string, Field>)[fieldName];
|
|
91
|
+
if (field === undefined) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`conflicts(${aggregate.id}): '${fieldName}' is not a stored field of ${aggregate.id}`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (
|
|
97
|
+
field.kind === "set" ||
|
|
98
|
+
field.kind === "map" ||
|
|
99
|
+
field.kind === "counter" ||
|
|
100
|
+
field.kind === "position" ||
|
|
101
|
+
field.kind === "hasMany"
|
|
102
|
+
) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`conflicts(${aggregate.id}): '${fieldName}' is a ${field.kind} with its own compositional merge law; ` +
|
|
105
|
+
`field policies currently apply to wholesale business values only`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const candidate = rule as Partial<ConflictRule> | undefined;
|
|
109
|
+
if (candidate === undefined || candidate[RULE_BRAND] !== true) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`conflicts(${aggregate.id}): '${fieldName}' needs a conflict rule such as firstWriterWins(), ` +
|
|
112
|
+
`latestWriterWins(), or manual(resolveVerb)`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
if (candidate.resolver !== undefined && candidate.resolver.aggregateId !== aggregate.id) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`conflicts(${aggregate.id}): manual resolver '${candidate.resolver.id}' targets ` +
|
|
118
|
+
`'${candidate.resolver.aggregateId}', not '${aggregate.id}'`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
normalized[fieldName] = candidate as ConflictRule;
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
[POLICY_BRAND]: true,
|
|
125
|
+
aggregate,
|
|
126
|
+
fields: Object.freeze(normalized),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function isConflictPolicy(value: unknown): value is ConflictPolicy {
|
|
131
|
+
return (
|
|
132
|
+
value !== null &&
|
|
133
|
+
typeof value === "object" &&
|
|
134
|
+
(value as Record<string, unknown>)[POLICY_BRAND] === true
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Collect exported policies without allowing module spread order to hide a collision. */
|
|
139
|
+
export function conflictPoliciesOf(
|
|
140
|
+
modules: readonly Record<string, unknown>[],
|
|
141
|
+
domain: string,
|
|
142
|
+
): ConflictPolicy[] {
|
|
143
|
+
const policies: ConflictPolicy[] = [];
|
|
144
|
+
const byAggregate = new Map<string, ConflictPolicy>();
|
|
145
|
+
for (const mod of modules) {
|
|
146
|
+
for (const value of Object.values(mod)) {
|
|
147
|
+
if (!isConflictPolicy(value)) continue;
|
|
148
|
+
const prior = byAggregate.get(value.aggregate.id);
|
|
149
|
+
if (prior !== undefined && prior !== value) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`domain '${domain}' exports more than one conflict policy for '${value.aggregate.id}' — ` +
|
|
152
|
+
`an aggregate has exactly one policy object; compose its field rules there`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
if (prior === undefined) {
|
|
156
|
+
byAggregate.set(value.aggregate.id, value);
|
|
157
|
+
policies.push(value);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return policies;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function cloneFieldWithDriver(field: Field, driver: Driver): Field {
|
|
165
|
+
const clone = Object.defineProperties({}, Object.getOwnPropertyDescriptors(field)) as Field;
|
|
166
|
+
Object.defineProperty(clone, "driver", {
|
|
167
|
+
value: driver,
|
|
168
|
+
enumerable: true,
|
|
169
|
+
configurable: true,
|
|
170
|
+
writable: false,
|
|
171
|
+
});
|
|
172
|
+
return clone;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Apply policy to the aggregate handles the composed manifest/codegen consumes. */
|
|
176
|
+
export function applyConflictPoliciesToAggregates(
|
|
177
|
+
aggregates: readonly AggregateHandle[],
|
|
178
|
+
policies: readonly ConflictPolicy[],
|
|
179
|
+
domain: string,
|
|
180
|
+
): AggregateHandle[] {
|
|
181
|
+
const aggregateIds = new Set(aggregates.map((aggregate) => aggregate.id));
|
|
182
|
+
for (const policy of policies) {
|
|
183
|
+
if (!aggregateIds.has(policy.aggregate.id)) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`domain '${domain}' exports a conflict policy for '${policy.aggregate.id}' but not the aggregate itself`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const byAggregate = new Map(policies.map((policy) => [policy.aggregate.id, policy]));
|
|
190
|
+
return aggregates.map((aggregate) => {
|
|
191
|
+
const policy = byAggregate.get(aggregate.id);
|
|
192
|
+
if (policy === undefined) return aggregate;
|
|
193
|
+
const fields = Object.defineProperties(
|
|
194
|
+
{},
|
|
195
|
+
Object.getOwnPropertyDescriptors(aggregate.fields),
|
|
196
|
+
) as Record<string, Field>;
|
|
197
|
+
for (const [name, rule] of Object.entries(policy.fields)) {
|
|
198
|
+
fields[name] = cloneFieldWithDriver(fields[name]!, rule.driver);
|
|
199
|
+
}
|
|
200
|
+
const clone = Object.defineProperties(
|
|
201
|
+
{},
|
|
202
|
+
Object.getOwnPropertyDescriptors(aggregate),
|
|
203
|
+
) as AggregateHandle;
|
|
204
|
+
Object.defineProperty(clone, "fields", {
|
|
205
|
+
value: fields,
|
|
206
|
+
enumerable: true,
|
|
207
|
+
configurable: true,
|
|
208
|
+
writable: false,
|
|
209
|
+
});
|
|
210
|
+
return clone;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Apply `manual(resolveVerb)` designations without making the verb declaration lead with edge cases. */
|
|
215
|
+
export function applyConflictPolicyResolvers(
|
|
216
|
+
directives: readonly Directive<any>[],
|
|
217
|
+
policies: readonly ConflictPolicy[],
|
|
218
|
+
): Directive<any>[] {
|
|
219
|
+
const designation = new Map<string, string>();
|
|
220
|
+
for (const policy of policies) {
|
|
221
|
+
for (const [field, rule] of Object.entries(policy.fields)) {
|
|
222
|
+
const resolver = rule.resolver;
|
|
223
|
+
if (resolver === undefined) continue;
|
|
224
|
+
const prior = designation.get(resolver.id);
|
|
225
|
+
if (prior !== undefined && prior !== field) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
`manual resolver '${resolver.id}' is assigned to both '${prior}' and '${field}' — ` +
|
|
228
|
+
`one business verb resolves one field`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
designation.set(resolver.id, field);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const exported = new Set(directives.map((directive) => directive.id));
|
|
235
|
+
for (const resolverId of designation.keys()) {
|
|
236
|
+
if (!exported.has(resolverId)) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
`manual resolver '${resolverId}' is used by conflict policy but is not exported with the domain`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return directives.map((directive) => {
|
|
243
|
+
const field = designation.get(directive.id);
|
|
244
|
+
if (field === undefined) return directive;
|
|
245
|
+
if (directive.resolvesField !== undefined && directive.resolvesField !== field) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`directive '${directive.id}' already resolves '${directive.resolvesField}' but conflict policy assigns '${field}'`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
return { ...directive, resolvesField: field };
|
|
251
|
+
});
|
|
252
|
+
}
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
// NOMOS — compiler-owned product description. This is deliberately a small,
|
|
2
|
+
// JSON-serialisable model: generated TypeScript and Dart clients consume the SAME
|
|
3
|
+
// facts, then join them to the verified runtime state behind `describe()`.
|
|
4
|
+
|
|
5
|
+
import type { DomainModule } from "./codegen_dart.js";
|
|
6
|
+
import type { Directive } from "./directive.js";
|
|
7
|
+
import { OWNER_FIELD, type AggregateHandle } from "./aggregate.js";
|
|
8
|
+
import type { Field } from "./fields.js";
|
|
9
|
+
import { finishCount } from "./count.js";
|
|
10
|
+
import { finishSum } from "./sum.js";
|
|
11
|
+
import { finishExists } from "./exists.js";
|
|
12
|
+
import { finishExtremum } from "./extremum.js";
|
|
13
|
+
import { isNamespacedAggregate, qualifyReadProjectionType } from "./read_projection_namespace.js";
|
|
14
|
+
|
|
15
|
+
export const NOMOS_DESCRIBE_SECTIONS = [
|
|
16
|
+
"model",
|
|
17
|
+
"current",
|
|
18
|
+
"rules",
|
|
19
|
+
"history",
|
|
20
|
+
"decisions",
|
|
21
|
+
"evidence",
|
|
22
|
+
] as const;
|
|
23
|
+
|
|
24
|
+
export type NomosDescribeDetailSection = (typeof NOMOS_DESCRIBE_SECTIONS)[number];
|
|
25
|
+
|
|
26
|
+
export interface NomosDescribeLinkModel {
|
|
27
|
+
readonly section: NomosDescribeDetailSection;
|
|
28
|
+
readonly label: string;
|
|
29
|
+
readonly question: string;
|
|
30
|
+
readonly summary: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface NomosDescribeItemModel {
|
|
34
|
+
readonly kind: string;
|
|
35
|
+
readonly id: string;
|
|
36
|
+
readonly label: string;
|
|
37
|
+
readonly summary: string;
|
|
38
|
+
readonly details?: Readonly<Record<string, unknown>>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface NomosDescribePageModel {
|
|
42
|
+
readonly title: string;
|
|
43
|
+
readonly summary: string;
|
|
44
|
+
readonly items: readonly NomosDescribeItemModel[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface NomosDescribeAggregateModel {
|
|
48
|
+
readonly domain: string;
|
|
49
|
+
readonly type: string;
|
|
50
|
+
readonly label: string;
|
|
51
|
+
readonly audience: string;
|
|
52
|
+
/** Actual projection type used by the runtime's built-in type index. */
|
|
53
|
+
readonly queryType: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The compiler-known half of the one-page-plus-six product description. */
|
|
57
|
+
export interface NomosDescribeModel {
|
|
58
|
+
readonly packageName: string;
|
|
59
|
+
readonly title: string;
|
|
60
|
+
readonly summary: string;
|
|
61
|
+
readonly links: readonly NomosDescribeLinkModel[];
|
|
62
|
+
readonly pages: {
|
|
63
|
+
readonly model: NomosDescribePageModel;
|
|
64
|
+
readonly rules: NomosDescribePageModel;
|
|
65
|
+
};
|
|
66
|
+
/** Public business types the generated client may ask the projection for. */
|
|
67
|
+
readonly aggregateTypes: readonly NomosDescribeAggregateModel[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const label = (id: string): string => {
|
|
71
|
+
const spaced = id
|
|
72
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
73
|
+
.replace(/[_-]+/g, " ")
|
|
74
|
+
.trim();
|
|
75
|
+
return spaced.length === 0 ? id : spaced[0]!.toUpperCase() + spaced.slice(1);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Explain concurrency as policy, never as a CRDT/driver name. */
|
|
79
|
+
const mergePolicy = (driver: unknown): string => {
|
|
80
|
+
if (driver === null || typeof driver !== "object") return "the model's declared policy";
|
|
81
|
+
const d = driver as { kind?: unknown; inner?: unknown; principal?: unknown };
|
|
82
|
+
switch (d.kind) {
|
|
83
|
+
case "FirstWriterWins": return "the first concurrent change wins; a later informed edit may replace it";
|
|
84
|
+
case "Lww": return "the latest recorded change wins";
|
|
85
|
+
case "LwwCausal": return "a change based on another wins; truly concurrent changes use a stable tie-break";
|
|
86
|
+
case "LwwBy": return `${String(d.principal ?? "the declared authority")} is the sole writer`;
|
|
87
|
+
case "AddWins": return "concurrent additions are kept";
|
|
88
|
+
case "RemoveWins": return "a concurrent removal wins over an addition";
|
|
89
|
+
case "Counter": return "concurrent adjustments are added together";
|
|
90
|
+
case "LastPosition": return "the latest recorded position wins";
|
|
91
|
+
case "Conflict": return "competing versions wait for a business merge";
|
|
92
|
+
case "Surfaced": return "competing versions are retained for a business merge";
|
|
93
|
+
case "MapOf": return `each named entry follows this rule: ${mergePolicy(d.inner)}`;
|
|
94
|
+
case "Attributed": return `${mergePolicy(d.inner)}, with the winning author's identity retained`;
|
|
95
|
+
default: return "the model's declared policy";
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const audienceName = (aggregate: AggregateHandle): string => {
|
|
100
|
+
const audience = aggregate.audience;
|
|
101
|
+
if (audience?.kind === "public") return "public";
|
|
102
|
+
if (audience?.kind === "owned") return "owned by its creator";
|
|
103
|
+
if (audience?.kind === "readableBy") return `readable by ${audience.cap}`;
|
|
104
|
+
if (aggregate.visibility !== undefined) return `readable by ${aggregate.visibility.requires}`;
|
|
105
|
+
// A package with this value cannot pass the audience compile gate. Keeping the
|
|
106
|
+
// fact explicit makes direct unit-codegen honest too.
|
|
107
|
+
return "undeclared (the compiler will refuse this package)";
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const permissionName = (gate: NonNullable<Directive<unknown>["requiresRelationGate"]>): string => {
|
|
111
|
+
const relation = label(gate.relation).toLowerCase();
|
|
112
|
+
if (gate.objectFrom !== undefined) return `${relation} for this ${label(gate.objectFrom.recordType).toLowerCase()}`;
|
|
113
|
+
if (gate.object === "workspace:self") return `${relation} for this workspace`;
|
|
114
|
+
return `${relation} for ${label(gate.object).toLowerCase()}`;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Best-effort field names for human explanation only; law validation remains Zod. */
|
|
118
|
+
const payloadFields = (directive: Directive<unknown>): string[] => {
|
|
119
|
+
const schema = directive.payloadSchema as unknown as {
|
|
120
|
+
shape?: unknown;
|
|
121
|
+
_def?: { shape?: unknown };
|
|
122
|
+
def?: { shape?: unknown };
|
|
123
|
+
};
|
|
124
|
+
const candidate = schema.shape ?? schema._def?.shape ?? schema.def?.shape;
|
|
125
|
+
try {
|
|
126
|
+
const shape = typeof candidate === "function" ? candidate() : candidate;
|
|
127
|
+
return shape !== null && typeof shape === "object" ? Object.keys(shape).sort() : [];
|
|
128
|
+
} catch {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
type BusinessBirthPresentation = {
|
|
134
|
+
readonly aggregateId: string;
|
|
135
|
+
readonly publicPayloadSchema: unknown;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const businessBirthOf = (directive: Directive<unknown>): BusinessBirthPresentation | undefined =>
|
|
139
|
+
(directive as Directive<unknown> & { businessBirth?: BusinessBirthPresentation }).businessBirth;
|
|
140
|
+
|
|
141
|
+
const publicAggregates = (mod: DomainModule): AggregateHandle[] =>
|
|
142
|
+
mod.aggregates.filter((aggregate) =>
|
|
143
|
+
aggregate.framework !== true
|
|
144
|
+
&& (aggregate as { businessBirthInternal?: unknown }).businessBirthInternal !== true
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const publicDirectives = (mod: DomainModule): Directive<unknown>[] =>
|
|
148
|
+
(mod.directives as Directive<unknown>[]).filter((directive) =>
|
|
149
|
+
directive.framework !== true
|
|
150
|
+
&& (directive as { businessBirthInternal?: unknown }).businessBirthInternal !== true
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const businessFields = (aggregate: AggregateHandle): [string, Field][] =>
|
|
154
|
+
Object.entries(aggregate.fields as Record<string, Field>)
|
|
155
|
+
.filter(([name]) => name !== OWNER_FIELD);
|
|
156
|
+
|
|
157
|
+
const aggregateItem = (domain: string, aggregate: AggregateHandle): NomosDescribeItemModel => {
|
|
158
|
+
const fields = businessFields(aggregate)
|
|
159
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
160
|
+
.map(([name, field]) => ({
|
|
161
|
+
name,
|
|
162
|
+
type: field.kind,
|
|
163
|
+
merge: mergePolicy(field.driver),
|
|
164
|
+
optional: field.isOptional,
|
|
165
|
+
...(field.isFromBirth === true ? { fromBirth: true } : {}),
|
|
166
|
+
...((field.evolutions?.length ?? 0) > 0 ? { understoodEarlierShapes: field.evolutions!.length } : {}),
|
|
167
|
+
}));
|
|
168
|
+
const audience = audienceName(aggregate);
|
|
169
|
+
return {
|
|
170
|
+
kind: "noun",
|
|
171
|
+
id: `${domain}.${aggregate.id}`,
|
|
172
|
+
label: label(aggregate.id),
|
|
173
|
+
summary: `${label(aggregate.id)} is ${audience}; it has ${fields.length} business field${fields.length === 1 ? "" : "s"}.`,
|
|
174
|
+
details: {
|
|
175
|
+
domain,
|
|
176
|
+
type: aggregate.id,
|
|
177
|
+
audience,
|
|
178
|
+
fields,
|
|
179
|
+
reads: [`${aggregate.id} by id`, `all ${aggregate.id}s`],
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const actionItem = (domain: string, directive: Directive<unknown>): NomosDescribeItemModel => {
|
|
185
|
+
const businessBirth = businessBirthOf(directive);
|
|
186
|
+
const actionId = businessBirth === undefined ? directive.id : "birth";
|
|
187
|
+
const actionLabel = businessBirth === undefined
|
|
188
|
+
? label(directive.id)
|
|
189
|
+
: `Birth ${label(businessBirth.aggregateId)}`;
|
|
190
|
+
const outputs = [
|
|
191
|
+
{ effect: directive.marker, type: directive.aggregateId },
|
|
192
|
+
...(directive.secondaryOutputs ?? []).map((output) => ({ effect: output.marker, type: output.aggregateId })),
|
|
193
|
+
];
|
|
194
|
+
const effectVerb = (marker: string): string => ({
|
|
195
|
+
creates: "create",
|
|
196
|
+
mutates: "change",
|
|
197
|
+
archives: "archive",
|
|
198
|
+
ensures: "ensure",
|
|
199
|
+
}[marker] ?? marker);
|
|
200
|
+
const effect = outputs.map((output) => `${effectVerb(output.effect)} ${label(output.type)}`).join(", ");
|
|
201
|
+
const inputs = businessBirth === undefined
|
|
202
|
+
? payloadFields(directive)
|
|
203
|
+
: payloadFields({ ...directive, payloadSchema: businessBirth.publicPayloadSchema } as Directive<unknown>);
|
|
204
|
+
const gate = directive.requiresRelationGate;
|
|
205
|
+
return {
|
|
206
|
+
kind: "verb",
|
|
207
|
+
id: `${domain}.${actionId}`,
|
|
208
|
+
label: actionLabel,
|
|
209
|
+
summary: businessBirth === undefined
|
|
210
|
+
? directive.description ?? `${actionLabel} will ${effect}.`
|
|
211
|
+
: `${actionLabel} will ${effect}.`,
|
|
212
|
+
details: {
|
|
213
|
+
domain,
|
|
214
|
+
action: actionId,
|
|
215
|
+
inputs,
|
|
216
|
+
effects: outputs,
|
|
217
|
+
...(gate !== undefined
|
|
218
|
+
? { permittedWhen: permissionName(gate) }
|
|
219
|
+
: {}),
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const questionItems = (mod: DomainModule): NomosDescribeItemModel[] => {
|
|
225
|
+
const domain = mod.domain ?? mod.name;
|
|
226
|
+
const items: NomosDescribeItemModel[] = [];
|
|
227
|
+
const publicAggregateIds = new Set(publicAggregates(mod).map((aggregate) => aggregate.id));
|
|
228
|
+
const privateAggregateIds = new Set(
|
|
229
|
+
mod.aggregates
|
|
230
|
+
.filter((aggregate) => !publicAggregateIds.has(aggregate.id))
|
|
231
|
+
.map((aggregate) => aggregate.id),
|
|
232
|
+
);
|
|
233
|
+
for (const query of mod.queries ?? []) {
|
|
234
|
+
if (
|
|
235
|
+
(query as { framework?: unknown }).framework === true
|
|
236
|
+
|| (query as { businessBirthInternal?: unknown }).businessBirthInternal === true
|
|
237
|
+
|| privateAggregateIds.has(query.returns)
|
|
238
|
+
) {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
items.push({
|
|
242
|
+
kind: "question",
|
|
243
|
+
id: `${domain}.${query.id}`,
|
|
244
|
+
label: label(query.id),
|
|
245
|
+
summary: `Find ${label(query.returns)} by ${query.key.map(label).join(" and ")}.`,
|
|
246
|
+
details: { domain, question: query.id, returns: query.returns, by: query.key },
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
for (const raw of mod.counts ?? []) {
|
|
250
|
+
const count = finishCount(raw);
|
|
251
|
+
items.push({
|
|
252
|
+
kind: "question",
|
|
253
|
+
id: `${domain}.${count.id}`,
|
|
254
|
+
label: label(count.id),
|
|
255
|
+
summary: `Count ${label(count.of)}${count.by === undefined ? "" : ` by ${label(count.by)}`}.`,
|
|
256
|
+
details: { domain, question: count.id, returns: "number", counts: count.of, ...(count.by ? { by: count.by } : {}) },
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
for (const raw of mod.sums ?? []) {
|
|
260
|
+
const sum = finishSum(raw);
|
|
261
|
+
items.push({
|
|
262
|
+
kind: "question",
|
|
263
|
+
id: `${domain}.${sum.id}`,
|
|
264
|
+
label: label(sum.id),
|
|
265
|
+
summary: `Total ${label(sum.sumField)} across ${label(sum.of)}${sum.by === undefined ? "" : ` by ${label(sum.by)}`}.`,
|
|
266
|
+
details: { domain, question: sum.id, returns: "number", sums: `${sum.of}.${sum.sumField}`, ...(sum.by ? { by: sum.by } : {}) },
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
for (const raw of mod.exists_ ?? []) {
|
|
270
|
+
const exists = finishExists(raw);
|
|
271
|
+
items.push({
|
|
272
|
+
kind: "question",
|
|
273
|
+
id: `${domain}.${exists.id}`,
|
|
274
|
+
label: label(exists.id),
|
|
275
|
+
summary: `Ask whether any ${label(exists.of)} exists${exists.by === undefined ? "" : ` for ${label(exists.by)}`}.`,
|
|
276
|
+
details: { domain, question: exists.id, returns: "yes or no", checks: exists.of, ...(exists.by ? { by: exists.by } : {}) },
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
for (const raw of [...(mod.mins ?? []), ...(mod.maxes ?? [])]) {
|
|
280
|
+
const extremum = finishExtremum(raw);
|
|
281
|
+
items.push({
|
|
282
|
+
kind: "question",
|
|
283
|
+
id: `${domain}.${extremum.id}`,
|
|
284
|
+
label: label(extremum.id),
|
|
285
|
+
summary: `Find the ${extremum.kind === "min" ? "smallest" : "largest"} ${label(extremum.valueField)} among ${label(extremum.of)}${extremum.by === undefined ? "" : ` for ${label(extremum.by)}`}.`,
|
|
286
|
+
details: { domain, question: extremum.id, returns: "number", from: extremum.of, field: extremum.valueField, ...(extremum.by ? { by: extremum.by } : {}) },
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
for (const spatial of mod.spatials ?? []) {
|
|
290
|
+
items.push({
|
|
291
|
+
kind: "question",
|
|
292
|
+
id: `${domain}.${spatial.id}`,
|
|
293
|
+
label: label(spatial.id),
|
|
294
|
+
summary: `Find ${label(spatial.of)} within an area using ${label(spatial.on)}.`,
|
|
295
|
+
details: { domain, question: spatial.id, returns: spatial.of, areaFrom: spatial.on },
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
for (const ordered of mod.orderedReads ?? []) {
|
|
299
|
+
items.push({
|
|
300
|
+
kind: "question",
|
|
301
|
+
id: `${domain}.${ordered.id}`,
|
|
302
|
+
label: label(ordered.id),
|
|
303
|
+
summary: `Find ${ordered.limit === 1 ? "the first" : `the first ${ordered.limit}`} ${label(ordered.of)} ordered by ${label(ordered.orderKey)}${ordered.orderDesc ? " descending" : ""}.`,
|
|
304
|
+
details: { domain, question: ordered.id, returns: ordered.of, orderedBy: ordered.orderKey, limit: ordered.limit },
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
return items;
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const ruleItems = (mod: DomainModule): NomosDescribeItemModel[] => {
|
|
311
|
+
const domain = mod.domain ?? mod.name;
|
|
312
|
+
const items: NomosDescribeItemModel[] = [];
|
|
313
|
+
for (const aggregate of publicAggregates(mod)) {
|
|
314
|
+
items.push({
|
|
315
|
+
kind: "access-rule",
|
|
316
|
+
id: `${domain}.${aggregate.id}.audience`,
|
|
317
|
+
label: `${label(aggregate.id)} audience`,
|
|
318
|
+
summary: `${label(aggregate.id)} is ${audienceName(aggregate)}.`,
|
|
319
|
+
details: { audience: audienceName(aggregate) },
|
|
320
|
+
});
|
|
321
|
+
if (aggregate.hasInvariant === true) {
|
|
322
|
+
items.push({
|
|
323
|
+
kind: "invariant",
|
|
324
|
+
id: `${domain}.${aggregate.id}.invariant`,
|
|
325
|
+
label: `${label(aggregate.id)} must remain valid`,
|
|
326
|
+
summary: `Every proposed change to ${label(aggregate.id)} is checked before it can be committed.`,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
for (const [fieldName, field] of businessFields(aggregate)) {
|
|
330
|
+
const merge = mergePolicy(field.driver);
|
|
331
|
+
const surfaced = ["Surfaced", "Conflict"].includes(String((field.driver as { kind?: unknown }).kind ?? ""));
|
|
332
|
+
items.push({
|
|
333
|
+
kind: surfaced ? "conflict-rule" : "merge-rule",
|
|
334
|
+
id: `${domain}.${aggregate.id}.${fieldName}.merge`,
|
|
335
|
+
label: `${label(aggregate.id)} ${label(fieldName)}`,
|
|
336
|
+
summary: surfaced
|
|
337
|
+
? `Concurrent ${label(fieldName)} changes are retained for a business merge.`
|
|
338
|
+
: `For concurrent ${label(fieldName)} changes, ${merge}.`,
|
|
339
|
+
details: {
|
|
340
|
+
merge,
|
|
341
|
+
...((field.evolutions?.length ?? 0) > 0
|
|
342
|
+
? { understoodEarlierShapes: field.evolutions!.length }
|
|
343
|
+
: {}),
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
for (const directive of publicDirectives(mod)) {
|
|
349
|
+
const gate = directive.requiresRelationGate;
|
|
350
|
+
if (gate !== undefined) {
|
|
351
|
+
items.push({
|
|
352
|
+
kind: "permission-rule",
|
|
353
|
+
id: `${domain}.${directive.id}.permission`,
|
|
354
|
+
label: `Who may ${label(directive.id).toLowerCase()}`,
|
|
355
|
+
summary: `${label(directive.id)} requires ${permissionName(gate)}.`,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
if ((directive.preconditions?.length ?? 0) > 0) {
|
|
359
|
+
items.push({
|
|
360
|
+
kind: "precondition",
|
|
361
|
+
id: `${domain}.${directive.id}.preconditions`,
|
|
362
|
+
label: `${label(directive.id)} preconditions`,
|
|
363
|
+
summary: `${label(directive.id)} has ${directive.preconditions!.length} declared condition${directive.preconditions!.length === 1 ? "" : "s"} checked before commit.`,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
// Framework invariants remain certified law, but `describe()` is a product explanation. Only an
|
|
368
|
+
// invariant attached to an ordinary tenant-visible action belongs here. Birth's quota/attestation/
|
|
369
|
+
// signer invariants are privately owned by the one generated `birth(...)` action.
|
|
370
|
+
const ordinaryPublicActionIds = new Set(
|
|
371
|
+
publicDirectives(mod)
|
|
372
|
+
.filter((directive) => businessBirthOf(directive) === undefined)
|
|
373
|
+
.map((directive) => directive.id),
|
|
374
|
+
);
|
|
375
|
+
for (const invariant of mod.workspaceInvariants ?? []) {
|
|
376
|
+
if (
|
|
377
|
+
(invariant as { businessBirthInternal?: unknown }).businessBirthInternal === true
|
|
378
|
+
|| !ordinaryPublicActionIds.has(invariant.on)
|
|
379
|
+
) {
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
items.push({
|
|
383
|
+
kind: "workspace-invariant",
|
|
384
|
+
id: `${domain}.${invariant.id}`,
|
|
385
|
+
label: label(invariant.id),
|
|
386
|
+
summary: `The whole workspace must satisfy ${label(invariant.id)} when ${label(invariant.on)} is proposed.`,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
return items;
|
|
390
|
+
};
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Build the product description from tenant law only. No executable functions or
|
|
394
|
+
* framework-private names enter the result, so it is safe to bake into clients.
|
|
395
|
+
*/
|
|
396
|
+
export function buildNomosDescribeModel(
|
|
397
|
+
modules: readonly DomainModule[],
|
|
398
|
+
packageName: string,
|
|
399
|
+
): NomosDescribeModel {
|
|
400
|
+
const aggregates = modules.flatMap((mod) => {
|
|
401
|
+
const domain = mod.domain ?? mod.name;
|
|
402
|
+
return publicAggregates(mod).map((aggregate) => ({ domain, aggregate }));
|
|
403
|
+
});
|
|
404
|
+
const actions = modules.flatMap((mod) => {
|
|
405
|
+
const domain = mod.domain ?? mod.name;
|
|
406
|
+
return publicDirectives(mod).map((directive) => ({ domain, directive }));
|
|
407
|
+
});
|
|
408
|
+
const questions = modules.flatMap(questionItems);
|
|
409
|
+
const nouns = aggregates.map(({ domain, aggregate }) => aggregateItem(domain, aggregate));
|
|
410
|
+
const verbs = actions.map(({ domain, directive }) => actionItem(domain, directive));
|
|
411
|
+
const rules = modules.flatMap(ruleItems);
|
|
412
|
+
const domainCount = new Set(modules.map((mod) => mod.domain ?? mod.name)).size;
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
packageName,
|
|
416
|
+
title: label(packageName),
|
|
417
|
+
summary: `${label(packageName)} models ${nouns.length} business noun${nouns.length === 1 ? "" : "s"}, ${verbs.length} action${verbs.length === 1 ? "" : "s"}, and ${questions.length} named question${questions.length === 1 ? "" : "s"} across ${domainCount} domain${domainCount === 1 ? "" : "s"}.`,
|
|
418
|
+
links: [
|
|
419
|
+
{ section: "model", label: "The model", question: "What business does this workspace model?", summary: "Its nouns, actions and named questions." },
|
|
420
|
+
{ section: "current", label: "Current state", question: "What exists now?", summary: "The records this reader is allowed to see now." },
|
|
421
|
+
{ section: "rules", label: "The rules", question: "What must always remain true?", summary: "Access, validity, merge and evolution policy." },
|
|
422
|
+
{ section: "history", label: "History", question: "What changed, and who changed it?", summary: "The verified business story in time order." },
|
|
423
|
+
{ section: "decisions", label: "Decisions", question: "What was decided, and what still needs attention?", summary: "Recorded reasons, pending business merges, retained rejected work and conflict policy." },
|
|
424
|
+
{ section: "evidence", label: "Evidence", question: "What proves this answer?", summary: "Workspace, law and verified history identities." },
|
|
425
|
+
],
|
|
426
|
+
pages: {
|
|
427
|
+
model: {
|
|
428
|
+
title: "The business model",
|
|
429
|
+
summary: "The nouns, actions and named questions generated from the installed business law.",
|
|
430
|
+
items: [...nouns, ...verbs, ...questions],
|
|
431
|
+
},
|
|
432
|
+
rules: {
|
|
433
|
+
title: "The rules",
|
|
434
|
+
summary: "The access, validity, merge and evolution policy Nomos enforces before accepting change.",
|
|
435
|
+
items: rules,
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
aggregateTypes: aggregates.map(({ domain, aggregate }) => ({
|
|
439
|
+
domain,
|
|
440
|
+
type: aggregate.id,
|
|
441
|
+
label: label(aggregate.id),
|
|
442
|
+
audience: audienceName(aggregate),
|
|
443
|
+
queryType: isNamespacedAggregate(aggregate)
|
|
444
|
+
? qualifyReadProjectionType(domain, aggregate.id)
|
|
445
|
+
: aggregate.id,
|
|
446
|
+
})),
|
|
447
|
+
};
|
|
448
|
+
}
|