@githolon/dsl 0.103.12 → 0.103.14

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.
Files changed (45) hide show
  1. package/dart/CHANGELOG.md +5 -0
  2. package/dart/lib/src/runner/node-entry.mjs +244 -173
  3. package/dart/lib/src/runner/offline-install.mjs +87 -112
  4. package/dart/lib/src/session_bound.dart +7 -4
  5. package/dart/lib/src/webview_bridge.dart +13 -11
  6. package/dart/pubspec.yaml +1 -1
  7. package/dart/test/webview_bridge_test.dart +10 -7
  8. package/dart_types/CHANGELOG.md +2 -2
  9. package/dart_types/lib/nomos_types.dart +4 -4
  10. package/dart_types/lib/src/versioned_nomos_ref.dart +13 -11
  11. package/dart_types/pubspec.yaml +1 -1
  12. package/package.json +1 -1
  13. package/runtime/generated-application-host.js +433 -239
  14. package/src/aggregate.ts +3 -1
  15. package/src/build_package.ts +77 -6
  16. package/src/check_package_main.ts +39 -16
  17. package/src/codegen_dart.ts +189 -52
  18. package/src/codegen_dart_application.ts +82 -16
  19. package/src/codegen_dart_client.ts +42 -14
  20. package/src/codegen_dot.ts +3 -3
  21. package/src/codegen_proof.ts +24 -2
  22. package/src/codegen_ts.ts +32 -19
  23. package/src/compile_package_main.ts +68 -11
  24. package/src/derived.ts +17 -0
  25. package/src/directive.ts +65 -0
  26. package/src/engine_entry.ts +22 -2
  27. package/src/enum_type.ts +71 -0
  28. package/src/fields.ts +135 -5
  29. package/src/flow_gate.ts +2 -1
  30. package/src/framework/workspaces.ts +73 -0
  31. package/src/idof_lint.ts +5 -1
  32. package/src/index.ts +20 -0
  33. package/src/kernel_capabilities.ts +12 -1
  34. package/src/law_check.ts +107 -28
  35. package/src/law_graph.ts +151 -20
  36. package/src/law_graph_usda.ts +23 -0
  37. package/src/law_value_type.ts +10 -3
  38. package/src/lsp_server.ts +26 -16
  39. package/src/package_config.ts +112 -9
  40. package/src/package_feature_topology.ts +174 -0
  41. package/src/plan_source_slice.ts +6 -0
  42. package/src/report.ts +6 -0
  43. package/src/stable_ids_for_module.ts +9 -4
  44. package/src/value_object.ts +9 -2
  45. package/src/workspace_sharding.ts +4 -4
package/src/fields.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  * the value type (via Zod) is preserved.
16
16
  */
17
17
  import { z } from "zod";
18
+ import { isEnumType, type EnumType } from "./enum_type.js";
18
19
  import { Conflict, Counter, FirstWriterWins, LastPosition, type Driver } from "./drivers.js";
19
20
  import type { AggregateHandle } from "./aggregate.js";
20
21
  import type { ContractHandle } from "./contract.js";
@@ -292,7 +293,13 @@ export type ValueObjectFieldDescriptor =
292
293
  * was declared through `unit(...)` — the generated Dart extension-type wrapper's name. */
293
294
  | { readonly type: "number"; readonly int?: true; readonly unit?: string; readonly optional?: true }
294
295
  | { readonly type: "boolean"; readonly optional?: true }
295
- | { readonly type: "enum"; readonly values: readonly string[]; readonly optional?: true }
296
+ | {
297
+ readonly type: "enum";
298
+ readonly values: readonly string[];
299
+ /** Stable authored scalar name from enumType(...), when this is shared vocabulary. */
300
+ readonly name?: string;
301
+ readonly optional?: true;
302
+ }
296
303
  /** A `z.literal(<string>)` — a CONSTANT discriminator-style member (generated as a getter). */
297
304
  | { readonly type: "literal"; readonly value: string; readonly optional?: true }
298
305
  | { readonly type: "array"; readonly items: ValueObjectFieldDescriptor; readonly optional?: true }
@@ -540,6 +547,11 @@ export interface Field<T = unknown, K extends FieldKind = FieldKind> {
540
547
  /** Business-declared source contracts this field can understand during a gradual rollout.
541
548
  * Compiler-owned metadata; generated clients and tenant runtime code never see contract ids. */
542
549
  readonly evolutions?: readonly FieldEvolutionDeclaration[];
550
+ /** TRUE for `t.timestamp()`: the physical wire remains an int, while the business value is an
551
+ * epoch-millisecond timestamp and may own compiler-generated temporal enum reads. */
552
+ readonly isTimestamp?: true;
553
+ /** Temporal enum projections declared directly on this timestamp. Present only on `t.timestamp()`. */
554
+ readonly temporalEnums?: readonly TemporalEnumFieldDeclaration[];
543
555
  /** Returns a copy with the chosen driver while preserving fluent law markers already applied. */
544
556
  merge(driver: Driver): this;
545
557
  /** Tag this field's facet, threaded through merge/optional (the canonical form is the
@@ -629,6 +641,8 @@ function makeField<T, K extends FieldKind = FieldKind>(
629
641
  clearRequires?: string;
630
642
  clearRequiresObject?: string;
631
643
  evolutions?: readonly FieldEvolutionDeclaration[];
644
+ timestamp?: true;
645
+ temporalEnums?: readonly TemporalEnumFieldDeclaration[];
632
646
  } = {},
633
647
  ): Field<T, K> {
634
648
  const isOptional = extra.isOptional ?? false;
@@ -661,6 +675,10 @@ function makeField<T, K extends FieldKind = FieldKind>(
661
675
  ...(extra.evolutions !== undefined && extra.evolutions.length > 0
662
676
  ? { evolutions: extra.evolutions }
663
677
  : {}),
678
+ ...(extra.timestamp ? { isTimestamp: true as const } : {}),
679
+ ...(extra.temporalEnums !== undefined && extra.temporalEnums.length > 0
680
+ ? { temporalEnums: extra.temporalEnums }
681
+ : {}),
664
682
  merge(d: Driver): typeof f {
665
683
  return makeField<T, K>(kind, zod, d, extra) as typeof f;
666
684
  },
@@ -775,6 +793,62 @@ function makeField<T, K extends FieldKind = FieldKind>(
775
793
  return makeField<T, K>(kind, zod, driver, { ...extra, facet }) as Field<T, K> & { readonly facet: Fa };
776
794
  },
777
795
  };
796
+ if (extra.timestamp) {
797
+ (f as Field<T, K> & {
798
+ temporalEnum(id: string, states: TemporalEnumStates<string>): Field<T, K>;
799
+ }).temporalEnum = (id, states) => {
800
+ if (!/^[A-Za-z][A-Za-z0-9_]{0,127}$/.test(id)) {
801
+ throw new Error(
802
+ `t.timestamp().temporalEnum(): read id must be an identifier (letter, then letters/digits/_); got ${JSON.stringify(id)}`,
803
+ );
804
+ }
805
+ if (typeof states.missing !== "string" || states.missing.length === 0 ||
806
+ typeof states.initial !== "string" || states.initial.length === 0) {
807
+ throw new Error(
808
+ `t.timestamp().temporalEnum('${id}'): missing and initial must be non-empty enum values`,
809
+ );
810
+ }
811
+ const transitions: TemporalEnumTransition[] = [];
812
+ let previous = 0;
813
+ let previousValue = states.initial;
814
+ const values = new Set<string>([states.missing, states.initial]);
815
+ for (const [afterMillis, value] of states.after) {
816
+ if (!Number.isSafeInteger(afterMillis) || afterMillis <= previous) {
817
+ throw new Error(
818
+ `t.timestamp().temporalEnum('${id}'): thresholds must be strictly increasing positive safe integer milliseconds; ` +
819
+ `got ${JSON.stringify(afterMillis)} after ${previous}`,
820
+ );
821
+ }
822
+ if (typeof value !== "string" || value.length === 0) {
823
+ throw new Error(`t.timestamp().temporalEnum('${id}'): every transition needs a non-empty enum value`);
824
+ }
825
+ if (value === previousValue) {
826
+ throw new Error(
827
+ `t.timestamp().temporalEnum('${id}'): transition at ${afterMillis}ms repeats ${JSON.stringify(value)}; remove the no-op threshold`,
828
+ );
829
+ }
830
+ if (values.has(value)) {
831
+ throw new Error(
832
+ `t.timestamp().temporalEnum('${id}'): enum value ${JSON.stringify(value)} is reused; age states must progress monotonically`,
833
+ );
834
+ }
835
+ values.add(value);
836
+ transitions.push({ afterMillis, value });
837
+ previous = afterMillis;
838
+ previousValue = value;
839
+ }
840
+ if ((extra.temporalEnums ?? []).some((candidate) => candidate.id === id)) {
841
+ throw new Error(`t.timestamp().temporalEnum('${id}'): this timestamp already declares that read id`);
842
+ }
843
+ return makeField<T, K>(kind, zod, driver, {
844
+ ...extra,
845
+ temporalEnums: [
846
+ ...(extra.temporalEnums ?? []),
847
+ { id, missing: states.missing, initial: states.initial, transitions },
848
+ ],
849
+ });
850
+ };
851
+ }
778
852
  // LAZY REF TARGET: when the target was given as a thunk (`t.ref(() => Agg)` / `t.hasMany(() => Agg)` — to
779
853
  // break a bidirectional declaration cycle), expose `refAggregateId` as a MEMOIZED getter that resolves the
780
854
  // thunk on first read (build time, after all module consts init). Consumers read `refAggregateId` as usual.
@@ -902,6 +976,46 @@ export interface IntRules {
902
976
  readonly maximum?: number;
903
977
  }
904
978
 
979
+ /** One monotone age threshold in a temporal enum projection. `afterMillis` is measured from the
980
+ * stored timestamp; crossing it changes the read value without writing business state. */
981
+ export interface TemporalEnumTransition {
982
+ readonly afterMillis: number;
983
+ readonly value: string;
984
+ }
985
+
986
+ /** Compiler-owned declaration attached to a timestamp field by `.temporalEnum(...)`. The aggregate
987
+ * composition seam turns this into a first-class read declaration automatically, so the author never
988
+ * has to export/register a second object. */
989
+ export interface TemporalEnumFieldDeclaration {
990
+ readonly id: string;
991
+ readonly missing: string;
992
+ readonly initial: string;
993
+ readonly transitions: readonly TemporalEnumTransition[];
994
+ }
995
+
996
+ export interface TemporalEnumStates<E extends string> {
997
+ /** Value when the source timestamp has never been observed. Exhaustive by construction. */
998
+ readonly missing: E;
999
+ /** Value from the source timestamp until the first threshold. */
1000
+ readonly initial: E;
1001
+ /** Strictly increasing `[ageInMilliseconds, value]` transitions. */
1002
+ readonly after: readonly (readonly [number, E])[];
1003
+ }
1004
+
1005
+ /** An epoch-millisecond business timestamp. It keeps the kernel's efficient integer wire but exposes
1006
+ * the safe temporal projection vocabulary directly through autocomplete. */
1007
+ export interface TimestampField<T = number> extends Field<T, "int"> {
1008
+ readonly isTimestamp: true;
1009
+ readonly temporalEnums?: readonly TemporalEnumFieldDeclaration[];
1010
+ merge(driver: Driver): this;
1011
+ optional(): TimestampField<T | undefined>;
1012
+ fromBirth(): this & { readonly isFromBirth: true };
1013
+ temporalEnum<const E extends string>(
1014
+ id: string,
1015
+ states: TemporalEnumStates<E>,
1016
+ ): TimestampField<T>;
1017
+ }
1018
+
905
1019
  function naturalRule(value: number | undefined, name: string): void {
906
1020
  if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) {
907
1021
  throw new Error(`${name} must be a non-negative safe integer; got ${JSON.stringify(value)}`);
@@ -954,6 +1068,17 @@ export const t = {
954
1068
  if (maximum !== undefined) schema = schema.max(maximum);
955
1069
  return makeField("int", schema, FirstWriterWins);
956
1070
  },
1071
+ /** A UTC timestamp stored canonically as non-negative epoch milliseconds. Unlike a bare `t.int()`,
1072
+ * this progressively exposes `.temporalEnum(...)`, making time-dependent categories a typed,
1073
+ * kernel-owned read instead of an arbitrary callback or UI calculation. */
1074
+ timestamp(): TimestampField<number> {
1075
+ return makeField<number, "int">(
1076
+ "int",
1077
+ z.number().int().nonnegative(),
1078
+ FirstWriterWins,
1079
+ { timestamp: true },
1080
+ ) as TimestampField<number>;
1081
+ },
957
1082
  /** A boolean flag (Lww). Stored as the kernel scalar `"true"`/`"false"` and decoded back to a real
958
1083
  * JSON boolean on read — so a flag is `t.bool()`, not a 2-value `t.enum`. Counts/filters key on the
959
1084
  * same string form. (Not encryptable — only Lww-class scalars are, and bool already is.) */
@@ -988,10 +1113,15 @@ export const t = {
988
1113
  return z.array(el);
989
1114
  },
990
1115
  /** Enum via `as const` literal union — value typos squeal at compile time. */
991
- enum<const E extends readonly [string, ...string[]]>(values: E): Field<E[number], "enum"> {
1116
+ enum<const E extends readonly [string, ...string[]]>(
1117
+ values: E | EnumType<E>,
1118
+ ): Field<E[number], "enum"> {
1119
+ const schema = isEnumType(values)
1120
+ ? values.zod
1121
+ : z.enum(values) as unknown as z.ZodType<E[number]>;
992
1122
  return makeField<E[number], "enum">(
993
1123
  "enum",
994
- z.enum(values) as unknown as z.ZodType<E[number]>,
1124
+ schema,
995
1125
  FirstWriterWins,
996
1126
  );
997
1127
  },
@@ -1161,7 +1291,7 @@ export const t = {
1161
1291
  },
1162
1292
  });
1163
1293
  },
1164
- /** A version pin whose freshness is checked against one law-declared current pointer on a stable series.
1294
+ /** A version pin whose version status is checked against one law-declared current pointer on a stable series.
1165
1295
  * The declaration fails immediately if `series.current` is not an atomic ref to `target`. */
1166
1296
  versionedForeignRef(opts: {
1167
1297
  target: AggregateHandle<string, Record<string, Field>> | ContractHandle;
@@ -1178,7 +1308,7 @@ export const t = {
1178
1308
  if (current.nomosRef?.targetAggregateId !== opts.target.id) {
1179
1309
  throw new Error(
1180
1310
  `t.versionedForeignRef: '${opts.series.id}.${opts.current}' must be ` +
1181
- `t.nomosRef(${opts.target.id}); it is the O(1) current pointer used for freshness`,
1311
+ `t.nomosRef(${opts.target.id}); it is the O(1) current pointer used for version status`,
1182
1312
  );
1183
1313
  }
1184
1314
  const targetIsContract = (opts.target as { __isContract?: unknown }).__isContract === true;
package/src/flow_gate.ts CHANGED
@@ -235,7 +235,8 @@ export function assertFlowGate(m: FlowLaw, mod?: FlowModule): void {
235
235
  }
236
236
 
237
237
  // ── RULE 5 — NO PRIVATE BYTES IN AN OUTBOUND OFFER (Send's external send) ────
238
- // The outbox (refs/heads/outbox/<offer>) POSTs a capability's obligation to its endpoint —
238
+ // The kernel's due-offer read exposes typed bytes from the authenticated main frontier's outbox/
239
+ // tree for POSTing to a capability endpoint —
239
240
  // a DIFFERENT observer set (the external adapter). If the capability's task aggregate is
240
241
  // private (requires a read cap) or encrypted, those bytes cross the confidentiality boundary.
241
242
  // Refuse unless declassified. CURRENTLY VACUOUS BY CONSTRUCTION (capability task aggregates are
@@ -471,6 +471,27 @@ export const CapabilityBinding = aggregate("CapabilityBinding", {
471
471
  status: t.enum(GRANT_STATUSES).merge(Lww),
472
472
  }).public();
473
473
 
474
+ /**
475
+ * CapabilityAddress — the custodied destination for an addressed capability.
476
+ *
477
+ * An address is public routing configuration, not a credential. Keeping it in
478
+ * workspace state lets one compiled sovereign package serve production, dev and
479
+ * self-hosted workspaces without hard-coding one tenant's infrastructure into law.
480
+ * The kernel reads this fact only to address a sealed outbound offer; the host
481
+ * still receives opaque bytes and cannot choose or rewrite the destination.
482
+ */
483
+ export const CapabilityAddress = aggregate("CapabilityAddress", {
484
+ capability: t.string().merge(Lww),
485
+ endpoint: t.string().merge(Lww),
486
+ configuredBy: t.string().merge(Lww),
487
+ configuredAt: t.int().merge(Lww),
488
+ status: t.enum(GRANT_STATUSES).merge(Lww),
489
+ }).public();
490
+
491
+ export function capabilityAddressId(capability: string): string {
492
+ return `capability-address:${capability}`;
493
+ }
494
+
474
495
  // --- workspace directives ---------------------------------------------------------
475
496
 
476
497
  // ── THE ONE BIRTH RECORD (the `kind`-collapse — architecture/provisioning_as_obligations.md) ───────────
@@ -1524,6 +1545,53 @@ export const revokeCapability = directive("revokeCapability")
1524
1545
  })
1525
1546
  .requires("admin");
1526
1547
 
1548
+ const httpsEndpoint = z.string().url().refine(
1549
+ (value) => value.startsWith("https://"),
1550
+ "capability endpoint must use https",
1551
+ );
1552
+
1553
+ /** Configure where this workspace offers one named capability. */
1554
+ export const configureCapabilityAddress = directive("configureCapabilityAddress")
1555
+ .ensures(CapabilityAddress)
1556
+ .payload(
1557
+ z.object({
1558
+ capability: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,63}$/),
1559
+ endpoint: httpsEndpoint,
1560
+ configuredBy: z.string().min(1),
1561
+ configuredAt: epochMs,
1562
+ }),
1563
+ )
1564
+ .plan((p) => {
1565
+ ensure(CapabilityAddress, capabilityAddressId(p.capability))
1566
+ .set("capability", p.capability)
1567
+ .set("endpoint", p.endpoint)
1568
+ .set("configuredBy", p.configuredBy)
1569
+ .set("configuredAt", p.configuredAt)
1570
+ .set("status", "active");
1571
+ return [];
1572
+ })
1573
+ .requires("admin");
1574
+
1575
+ /** Stop new outbound delivery without deleting the auditable address fact. */
1576
+ export const revokeCapabilityAddress = directive("revokeCapabilityAddress")
1577
+ .mutates(CapabilityAddress)
1578
+ .payload(
1579
+ z.object({
1580
+ addressId: idOf(CapabilityAddress),
1581
+ revokedBy: z.string().min(1),
1582
+ revokedAt: epochMs,
1583
+ }),
1584
+ )
1585
+ .plan((p) => {
1586
+ const address = instance(CapabilityAddress, p.addressId);
1587
+ return [
1588
+ set(address, "configuredBy", p.revokedBy),
1589
+ set(address, "configuredAt", p.revokedAt),
1590
+ set(address, "status", "revoked" as const),
1591
+ ];
1592
+ })
1593
+ .requires("admin");
1594
+
1527
1595
  /**
1528
1596
  * setCloudPolicy — set (or amend) THE QUOTA LAW. An Ensure on the law-state
1529
1597
  * singleton: the first author births it, later authors amend it in place —
@@ -1749,6 +1817,11 @@ export const capabilityBindingByKeyHash = query("capabilityBindingByKeyHash")
1749
1817
  .key("keyHash")
1750
1818
  .returns(CapabilityBinding);
1751
1819
 
1820
+ /** The current workspace-owned delivery address for a named capability. */
1821
+ export const capabilityAddressByName = query("capabilityAddressByName")
1822
+ .key("capability")
1823
+ .returns(CapabilityAddress);
1824
+
1752
1825
  // ── THE ZANZIBAR SPINE comes to the CONTROL PLANE (governance → tuples) ──────────────────────────
1753
1826
  // The authority grants above ARE relation tuples in disguise (the same smell as the deleted
1754
1827
  // UserPermission): admin=`workspace:self#admin@key:<keyHash>`, creator=`…#creator@key:…`,
package/src/idof_lint.ts CHANGED
@@ -140,7 +140,11 @@ export function assertIdOfLintForDirective(dir: Directive<unknown>, where: strin
140
140
  // this guard a model can compile `entryId: z.string()`, after which every generated caller can mix
141
141
  // unrelated record ids and is encouraged to reconstruct identity as application state.
142
142
  const effectiveMarker = op.marker ?? (op.aggregateType === dir.aggregateId ? dir.marker : "mutates");
143
- if (op.aggregateType !== undefined && (effectiveMarker === "mutates" || effectiveMarker === "archives")) {
143
+ if (
144
+ op.aggregateType !== undefined &&
145
+ (effectiveMarker === "mutates" ||
146
+ effectiveMarker === "archives")
147
+ ) {
144
148
  const payloadField = byValue.get(op.aggregateId);
145
149
  if (payloadField !== undefined) {
146
150
  const fieldSchema = shape[payloadField];
package/src/index.ts CHANGED
@@ -10,11 +10,17 @@
10
10
  // authors never juggle a separate zod dependency (or risk a second zod instance).
11
11
  export { z } from "zod";
12
12
  export {
13
+ defineDomain,
14
+ defineFeature,
15
+ featureDependenciesOf,
13
16
  definePackage,
14
17
  validateNomosPackageConfig,
18
+ type NomosPackageFeatureConfig,
15
19
  type NomosPackageConfig,
16
20
  type NomosPackageDefinition,
17
21
  type NomosPackageDomainConfig,
22
+ type NormalizedNomosPackageConfig,
23
+ type NormalizedNomosPackageDomainConfig,
18
24
  } from "./package_config.js";
19
25
  export {
20
26
  applicationHomeGenesis,
@@ -50,6 +56,10 @@ export {
50
56
  type FieldEvolutionDeclaration,
51
57
  type StringRules,
52
58
  type IntRules,
59
+ type TimestampField,
60
+ type TemporalEnumStates,
61
+ type TemporalEnumFieldDeclaration,
62
+ type TemporalEnumTransition,
53
63
  NULL_ADMITTING,
54
64
  admitsNull,
55
65
  jsonNoNullSchema,
@@ -113,6 +123,12 @@ export {
113
123
  type GeoMultiPointValue,
114
124
  type ValueObjectUsage,
115
125
  } from "./value_object.js";
126
+ export {
127
+ enumType,
128
+ enumTypeNameOf,
129
+ isEnumType,
130
+ type EnumType,
131
+ } from "./enum_type.js";
116
132
  export {
117
133
  type PrincipalEvidence,
118
134
  type AttributionPrincipal,
@@ -387,6 +403,8 @@ export {
387
403
  export {
388
404
  derived,
389
405
  type DerivedDecl,
406
+ type TemporalEnumDecl,
407
+ type AnyDerivedDecl,
390
408
  type DerivedOf,
391
409
  type TypelessDerived,
392
410
  type DeriveFn,
@@ -407,6 +425,8 @@ export {
407
425
  EntityGraphBuilder,
408
426
  days,
409
427
  hours,
428
+ minutes,
429
+ seconds,
410
430
  type QueryAst,
411
431
  type QueryRow,
412
432
  type Report,
@@ -48,7 +48,9 @@ export type KernelFeature =
48
48
  /** Law-declared immutable pending-conflict snapshots (compare-and-set merge refs). */
49
49
  | "conflictSnapshots"
50
50
  /** `.owned()` automatic owner stamp, atomic per-record seat, and framework-custodied epoch-0 key. */
51
- | "ownedAudience";
51
+ | "ownedAudience"
52
+ /** Captured-clock temporal enum projections declared by `t.timestamp().temporalEnum(...)`. */
53
+ | "temporalEnum";
52
54
 
53
55
  /** The USD-IR era key each derived-from-law capability rides on, for the requirement
54
56
  * derivation below (a package that emits the era key needs the capability). */
@@ -64,6 +66,7 @@ export const ERA_KEY_TO_FEATURE: Readonly<Record<string, KernelFeature>> = {
64
66
  /** A minimal shape of a compiled domain module — enough to derive its kernel needs. */
65
67
  export interface FeatureProbeModule {
66
68
  readonly queries?: readonly QueryDecl[];
69
+ readonly deriveds?: readonly unknown[];
67
70
  readonly surfacedResolvers?: readonly unknown[];
68
71
  readonly aggregates?: readonly { audience?: { kind?: string } }[];
69
72
  /** The domain's USD-IR layer (the era keys ride here). Optional — shape-derived
@@ -83,6 +86,11 @@ export function requiredKernelFeatures(modules: readonly FeatureProbeModule[]):
83
86
  for (const q of m.queries ?? []) {
84
87
  if (Array.isArray(q.key) && q.key.length > 1) needs.add("compoundKeys");
85
88
  }
89
+ if ((m.deriveds ?? []).some((derived) =>
90
+ typeof derived === "object" && derived !== null && "kind" in derived &&
91
+ derived.kind === "temporalEnum")) {
92
+ needs.add("temporalEnum");
93
+ }
86
94
  if ((m.surfacedResolvers?.length ?? 0) > 0) needs.add("conflictSnapshots");
87
95
  if ((m.aggregates ?? []).some((aggregate) => aggregate.audience?.kind === "owned")) {
88
96
  needs.add("ownedAudience");
@@ -251,6 +259,9 @@ export function assessKernelCoherence(params: {
251
259
  (missing.includes("compoundKeys")
252
260
  ? `A compound-key query (\`.key(a, b, …)\`) will be refused deep in a law fold with ` +
253
261
  `"query declares N index keys; the read router supports exactly one". `
262
+ : missing.includes("temporalEnum")
263
+ ? `A temporal enum read will be refused while mounting law with ` +
264
+ `"unknown nomos:readKind temporalEnum". `
254
265
  : ``) +
255
266
  `Upgrade the cloud's kernel (or point at a cloud whose kernel is coherent with this release).`,
256
267
  };
package/src/law_check.ts CHANGED
@@ -63,6 +63,12 @@ export interface Finding {
63
63
  readonly what: string;
64
64
  /** How to fix it (or, for a note, how the repair keeps the law valid). */
65
65
  readonly fix: string;
66
+ /** Exact authored source anchor when the finding belongs to a file rather than a law declaration. */
67
+ readonly source?: {
68
+ readonly file: string;
69
+ readonly line: number;
70
+ readonly column: number;
71
+ };
66
72
  }
67
73
 
68
74
  /** Payload field names the FRAMEWORK consumes off the intent (never the plan) — excluded from the
@@ -150,6 +156,33 @@ function probeDirective(dir: Directive<unknown>, where: string): ProbeResult | u
150
156
  return { synth, probePayload, returned, threw, error, ops, reads, readFields };
151
157
  }
152
158
 
159
+ /** Empirical agent-DX guardrail from the CO2 migration: agents model `last…At` observations as a
160
+ * plain integer, erasing clock semantics and then rebuilding freshness in Dart. Keep this narrow:
161
+ * it only warns for the observed `lastXAt`/`lastXTimestamp` shape, and recommends the discoverable
162
+ * timestamp primitive without presuming that every timestamp needs a temporal enum. */
163
+ function timestampFieldFindings(mod: DomainModule): Finding[] {
164
+ const domain = mod.domain ?? mod.name;
165
+ const findings: Finding[] = [];
166
+ for (const aggregate of mod.aggregates) {
167
+ for (const [fieldName, field] of Object.entries(aggregate.fields as Record<string, Field>)) {
168
+ if (
169
+ field.kind === "int" &&
170
+ field.isTimestamp !== true &&
171
+ /^last[A-Z].*(?:At|Timestamp)$/.test(fieldName)
172
+ ) {
173
+ findings.push({
174
+ domain,
175
+ directiveId: aggregate.id,
176
+ level: "warn",
177
+ what: `aggregate '${aggregate.id}' field '${fieldName}' looks like a captured timestamp but is declared as a plain t.int(); agents then tend to rebuild age/freshness policy in the client`,
178
+ fix: `declare '${fieldName}: t.timestamp()…'; autocomplete will then expose .temporalEnum(...) when the business value changes with age`,
179
+ });
180
+ }
181
+ }
182
+ }
183
+ return findings;
184
+ }
185
+
153
186
  /** `zodObjectShape` with a swallow — a non-object payload yields `{}` (the return twin handles it). */
154
187
  function safeShape(schema: z.ZodTypeAny): Record<string, z.ZodTypeAny> {
155
188
  try {
@@ -609,52 +642,97 @@ function isScalarLeafDescriptor(d: ValueObjectFieldDescriptor): boolean {
609
642
  return d.type === "string" || d.type === "number" || d.type === "boolean" || d.type === "enum" || d.type === "literal";
610
643
  }
611
644
 
612
- /** Enumerate every dotted leaf path reachable through `object`/`geometry` refs under one VO
613
- * descriptor, `prefix`-rooted. Arrays/maps are not name-addressable, so they are not descended. */
645
+ /** Enumerate every dotted leaf path reachable through `object`/`geometry` refs or variant arms
646
+ * under one VO descriptor, `prefix`-rooted. Anonymous variant-arm fields are flattened beside
647
+ * `type`; named-VO arms descend through their wire-level `value` member. Arrays/maps are not
648
+ * name-addressable, so they are not descended. */
614
649
  function voLeafPaths(
615
650
  desc: ValueObjectDescriptor,
616
651
  byName: ReadonlyMap<string, ValueObjectDescriptor>,
617
652
  prefix: string,
618
653
  seen: ReadonlySet<string>,
619
654
  ): string[] {
620
- if (desc.fields === undefined) return []; // a geo/union VO exposes no named structural subfields here
621
655
  if (seen.has(desc.name)) return []; // cycle guard (a VO transitively nesting itself)
622
656
  const nextSeen = new Set(seen).add(desc.name);
623
- const out: string[] = [];
624
- for (const [name, f] of Object.entries(desc.fields)) {
625
- const path = `${prefix}.${name}`;
626
- if (isScalarLeafDescriptor(f)) {
627
- out.push(path);
628
- } else if ((f.type === "object" || f.type === "geometry") && byName.has(f.ref)) {
629
- out.push(...voLeafPaths(byName.get(f.ref)!, byName, path, nextSeen));
657
+ const out = new Set<string>();
658
+ const visitFields = (
659
+ fields: Readonly<Record<string, ValueObjectFieldDescriptor>>,
660
+ fieldPrefix: string,
661
+ ): void => {
662
+ for (const [name, f] of Object.entries(fields)) {
663
+ const path = `${fieldPrefix}.${name}`;
664
+ if (isScalarLeafDescriptor(f)) {
665
+ out.add(path);
666
+ } else if ((f.type === "object" || f.type === "geometry") && byName.has(f.ref)) {
667
+ for (const nested of voLeafPaths(byName.get(f.ref)!, byName, path, nextSeen)) out.add(nested);
668
+ }
669
+ }
670
+ };
671
+ if (desc.fields !== undefined) visitFields(desc.fields, prefix);
672
+ if (desc.union !== undefined) {
673
+ out.add(`${prefix}.type`);
674
+ for (const arm of Object.values(desc.union)) {
675
+ if ("fields" in arm) {
676
+ visitFields(arm.fields, prefix);
677
+ } else if (byName.has(arm.ref)) {
678
+ for (const nested of voLeafPaths(byName.get(arm.ref)!, byName, `${prefix}.value`, nextSeen)) out.add(nested);
679
+ }
630
680
  }
631
681
  }
632
- return out;
682
+ return [...out];
633
683
  }
634
684
 
635
- /** Resolve a dotted path (segments AFTER the lead VO field) through a VO descriptor tree. Returns
636
- * `"leaf"` if it lands on a scalar leaf, `"nonleaf"` if it resolves but to a non-scalar node (an
637
- * object/array/map — not indexable), or `undefined` if a segment is absent. */
685
+ type VoPathVerdict = "leaf" | "nonleaf" | undefined;
686
+
687
+ function resolveFieldPath(
688
+ field: ValueObjectFieldDescriptor,
689
+ byName: ReadonlyMap<string, ValueObjectDescriptor>,
690
+ remaining: readonly string[],
691
+ seen: ReadonlySet<string>,
692
+ ): VoPathVerdict {
693
+ if (remaining.length === 0) return isScalarLeafDescriptor(field) ? "leaf" : "nonleaf";
694
+ if ((field.type === "object" || field.type === "geometry") && byName.has(field.ref)) {
695
+ return resolveVoPath(byName.get(field.ref)!, byName, remaining, seen);
696
+ }
697
+ return undefined;
698
+ }
699
+
700
+ /** Resolve one path through a structural VO or a discriminated variant. A field which exists only
701
+ * on SOME anonymous variant arms is still a lawful sparse index: rows in the other arms simply
702
+ * contribute no key, matching the projection's JSON-path extraction semantics. */
638
703
  function resolveVoPath(
639
704
  rootDesc: ValueObjectDescriptor,
640
705
  byName: ReadonlyMap<string, ValueObjectDescriptor>,
641
706
  segments: readonly string[],
642
- ): "leaf" | "nonleaf" | undefined {
643
- let desc: ValueObjectDescriptor | undefined = rootDesc;
644
- for (let i = 0; i < segments.length; i++) {
645
- const seg = segments[i]!;
646
- const f = desc?.fields?.[seg];
647
- if (f === undefined) return undefined;
648
- const isLast = i === segments.length - 1;
649
- if (isLast) return isScalarLeafDescriptor(f) ? "leaf" : "nonleaf";
650
- // A non-terminal segment must descend into a nested VO by ref.
651
- if ((f.type === "object" || f.type === "geometry") && byName.has(f.ref)) {
652
- desc = byName.get(f.ref);
653
- } else {
654
- return undefined; // a scalar/array/map mid-path can't be descended by name
707
+ seen: ReadonlySet<string> = new Set(),
708
+ ): VoPathVerdict {
709
+ if (segments.length === 0 || seen.has(rootDesc.name)) return undefined;
710
+ const [seg, ...rest] = segments;
711
+ const nextSeen = new Set(seen).add(rootDesc.name);
712
+
713
+ const direct = rootDesc.fields?.[seg!];
714
+ if (direct !== undefined) return resolveFieldPath(direct, byName, rest, nextSeen);
715
+
716
+ if (rootDesc.union === undefined) return undefined;
717
+ if (seg === "type") return rest.length === 0 ? "leaf" : undefined;
718
+
719
+ const verdicts: Exclude<VoPathVerdict, undefined>[] = [];
720
+ for (const arm of Object.values(rootDesc.union)) {
721
+ let verdict: VoPathVerdict;
722
+ if ("fields" in arm) {
723
+ const field = arm.fields[seg!];
724
+ verdict = field === undefined
725
+ ? undefined
726
+ : resolveFieldPath(field, byName, rest, nextSeen);
727
+ } else if (seg === "value" && byName.has(arm.ref)) {
728
+ verdict = rest.length === 0
729
+ ? "nonleaf"
730
+ : resolveVoPath(byName.get(arm.ref)!, byName, rest, nextSeen);
655
731
  }
732
+ if (verdict !== undefined) verdicts.push(verdict);
656
733
  }
657
- return undefined;
734
+ if (verdicts.length === 0) return undefined;
735
+ return verdicts.every((verdict) => verdict === "leaf") ? "leaf" : "nonleaf";
658
736
  }
659
737
 
660
738
  function dottedQueryKeyFindings(mod: DomainModule, aggById: ReadonlyMap<string, AggregateHandle>): Finding[] {
@@ -1239,6 +1317,7 @@ export function checkDomainModules(
1239
1317
 
1240
1318
  // Slice-2 twin: the module top level read the captured ports (pristine-pool ineligibility).
1241
1319
  findings.push(...topLevelPortReadFindings(mod, portReadingDomains));
1320
+ findings.push(...timestampFieldFindings(mod));
1242
1321
 
1243
1322
  for (const dir of mod.directives as Directive<unknown>[]) {
1244
1323
  const where = `${domain}/${dir.id}`;