@githolon/dsl 0.102.1 → 0.102.3

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.
@@ -262,8 +262,9 @@ interface PackageConfig {
262
262
  * • `test` — optional; generated business scenarios for widget/integration tests.
263
263
  * The layering line: UI code depends on `types`; composition/runtime code on `client`;
264
264
  * generated TYPES may flow to the UI, generated CLIENTS may not.
265
- * Each package's `pubspec.yaml` is scaffolded ONLY IF MISSING (tenant-owned versioning);
266
- * barrels + `lib/src/generated/` are generated-owned and rewritten every compile.
265
+ * Each package's `pubspec.yaml` keeps tenant-owned versioning/publishing metadata and custom
266
+ * dependencies; Nomos-owned framework dependency floors are reconciled on every compile.
267
+ * Barrels + `lib/src/generated/` are generated-owned and rewritten every compile.
267
268
  * Requires `support: "package"` (vendored mode contradicts pub-package layering).
268
269
  */
269
270
  readonly packages?: {
@@ -1298,12 +1299,27 @@ function emitDart(
1298
1299
  // ── the LAYERED-PACKAGES Dart emit (dart.packages: blast-radius isolation) ────────
1299
1300
 
1300
1301
  /** The pure-core support import generated TYPES files carry in layered-packages mode. */
1301
- const DART_TYPES_IMPORT = [
1302
- `import 'package:nomos_types/nomos_types.dart';`,
1303
- `import 'package:nomos_types/src/nomos_ref.dart' show NmsInternalNomosRefs;`,
1304
- `import 'package:nomos_types/src/foreign_ref.dart' show NmsInternalForeignRefs;`,
1305
- `import 'package:nomos_types/src/invitation.dart' show NmsInternalInvitations, NmsInternalRecipients;`,
1306
- ].join("\n");
1302
+ const dartTypesImportFor = (generatedSource: string): string => {
1303
+ const lines = [
1304
+ `import 'package:nomos_types/nomos_types.dart';`,
1305
+ ...(generatedSource.includes("NmsInternalNomosRefs.")
1306
+ ? [`import 'package:nomos_types/src/nomos_ref.dart' show NmsInternalNomosRefs;`]
1307
+ : []),
1308
+ ...(generatedSource.includes("NmsInternalForeignRefs.")
1309
+ ? [`import 'package:nomos_types/src/foreign_ref.dart' show NmsInternalForeignRefs;`]
1310
+ : []),
1311
+ ];
1312
+ const invitationHelpers = [
1313
+ ...(generatedSource.includes("NmsInternalInvitations.") ? ["NmsInternalInvitations"] : []),
1314
+ ...(generatedSource.includes("NmsInternalRecipients.") ? ["NmsInternalRecipients"] : []),
1315
+ ];
1316
+ if (invitationHelpers.length > 0) {
1317
+ lines.push(
1318
+ `import 'package:nomos_types/src/invitation.dart' show ${invitationHelpers.join(", ")};`,
1319
+ );
1320
+ }
1321
+ return lines.join("\n");
1322
+ };
1307
1323
  /** Version constraints the scaffolded pubspecs pin — move together with the Dart packages' releases.
1308
1324
  * Caret on 0.x is EXACT-MINOR (`^0.45.0` = `<0.46.0`), so these must track the CURRENT release, not
1309
1325
  * just the floor generated code needs — a lagging pin forces tenants into dependency_overrides. */
@@ -1313,8 +1329,8 @@ const NOMOS_TYPES_DEP = "^0.8.0";
1313
1329
  // 0.48.x: session-bound clients (XClient.bind/NomosClientInterface/DomainClientSessionMismatch/
1314
1330
  // DomainNotInstalledForSession, engine.follow, pendingConflicts lanes) — the generated client
1315
1331
  // REQUIRES this surface. 0.48.1 fixes NomosSession.watchPendingConflicts delegation.
1316
- const NOMOS_CLIENT_DEP = "^0.64.0";
1317
- const NOMOS_FLUTTER_DEP = "^0.63.0";
1332
+ const NOMOS_CLIENT_DEP = "^0.64.1";
1333
+ const NOMOS_FLUTTER_DEP = "^0.63.1";
1318
1334
  // DRIFT TRIPWIRE: pubspec_pins.test.ts asserts these equal the CURRENT versions in dsl/dart/pubspec.yaml
1319
1335
  // and dsl/dart_types/pubspec.yaml — a Dart release without this bump fails the suite, so tenants never
1320
1336
  // land back in dependency_overrides (the exact co2 report this closes, third occurrence).
@@ -1690,13 +1706,59 @@ function cursedNameHints(declared: ReadonlySet<string>, names: Readonly<Record<s
1690
1706
  .sort((a, b) => b.length - a.length);
1691
1707
  }
1692
1708
 
1693
- /** Scaffold a pubspec ONLY IF MISSING — the tenant owns versioning/publishing; regeneration must
1694
- * never clobber their bumps. Returns true when written. */
1695
- function scaffoldPubspec(pkgDir: string, yaml: string): boolean {
1709
+ /** Scaffold a pubspec when missing, otherwise reconcile only the dependencies owned by Nomos.
1710
+ * Tenant versioning, publishing metadata and application dependencies remain untouched, while a
1711
+ * regenerated client can never silently retain an obsolete framework floor. */
1712
+ function reconcilePubspec(
1713
+ pkgDir: string,
1714
+ yaml: string,
1715
+ managedDependencies: Readonly<Record<string, string>> = {},
1716
+ ): boolean {
1696
1717
  const p = path.join(pkgDir, "pubspec.yaml");
1697
- if (existsSync(p)) return false;
1698
- mkdirSync(pkgDir, { recursive: true });
1699
- writeFileSync(p, yaml, "utf8");
1718
+ if (!existsSync(p)) {
1719
+ mkdirSync(pkgDir, { recursive: true });
1720
+ writeFileSync(p, yaml, "utf8");
1721
+ return true;
1722
+ }
1723
+ if (Object.keys(managedDependencies).length === 0) return false;
1724
+
1725
+ const source = readFileSync(p, "utf8");
1726
+ const trailingNewline = source.endsWith("\n");
1727
+ const lines = source.split(/\r?\n/);
1728
+ if (trailingNewline) lines.pop();
1729
+ let dependenciesStart = lines.findIndex((line) => /^dependencies:\s*(?:#.*)?$/.test(line));
1730
+ if (dependenciesStart < 0) {
1731
+ const before = lines.findIndex((line) => /^dev_dependencies:\s*(?:#.*)?$/.test(line));
1732
+ dependenciesStart = before < 0 ? lines.length : before;
1733
+ lines.splice(dependenciesStart, 0, "dependencies:");
1734
+ }
1735
+ let dependenciesEnd = dependenciesStart + 1;
1736
+ while (dependenciesEnd < lines.length) {
1737
+ const line = lines[dependenciesEnd]!;
1738
+ if (line.trim() !== "" && !/^\s/.test(line) && !/^\s*#/.test(line)) break;
1739
+ dependenciesEnd += 1;
1740
+ }
1741
+
1742
+ for (const [name, constraint] of Object.entries(managedDependencies)) {
1743
+ const prefix = ` ${name}:`;
1744
+ const existing = lines.findIndex(
1745
+ (line, index) => index > dependenciesStart && index < dependenciesEnd && line.startsWith(prefix),
1746
+ );
1747
+ const replacement = ` ${name}: ${constraint}`;
1748
+ if (existing < 0) {
1749
+ lines.splice(dependenciesEnd, 0, replacement);
1750
+ dependenciesEnd += 1;
1751
+ continue;
1752
+ }
1753
+ let nestedEnd = existing + 1;
1754
+ while (nestedEnd < dependenciesEnd && /^(?: {4,}|\t)/.test(lines[nestedEnd]!)) nestedEnd += 1;
1755
+ lines.splice(existing, nestedEnd - existing, replacement);
1756
+ dependenciesEnd -= nestedEnd - existing - 1;
1757
+ }
1758
+
1759
+ const next = `${lines.join("\n")}${trailingNewline ? "\n" : ""}`;
1760
+ if (next === source) return false;
1761
+ writeFileSync(p, next, "utf8");
1700
1762
  return true;
1701
1763
  }
1702
1764
 
@@ -1705,7 +1767,8 @@ function scaffoldPubspec(pkgDir: string, yaml: string): boolean {
1705
1767
  * `<types>/` (pure data, depends only on `nomos_types`), `<client>/` (effectful runtime clients,
1706
1768
  * depends on the types package + `nomos_client`), and optionally `<test>/` (the headless-harness
1707
1769
  * surface). Generated code lives under each package's `lib/src/generated/` (wiped per compile)
1708
- * behind a generated-owned barrel `lib/<pkg>.dart`; pubspecs are scaffolded only if missing.
1770
+ * behind a generated-owned barrel `lib/<pkg>.dart`; pubspec framework floors are compiler-owned while
1771
+ * tenant versioning, publishing metadata and application dependencies survive regeneration.
1709
1772
  * TYPES-FILE PURITY is enforced fail-closed: a generated types file may import ONLY the
1710
1773
  * `nomos_types` barrel and the relative `nomos_framework.dart` — any runtime import is a compile
1711
1774
  * failure here, not a review comment downstream.
@@ -1760,7 +1823,7 @@ function emitDartPackages(
1760
1823
  if (!src.includes(DART_PACKAGE_IMPORT)) {
1761
1824
  fail(`generated Dart for '${fileBase}' does not carry the expected support import — codegen_dart header drifted`);
1762
1825
  }
1763
- const pure = src.replace(DART_PACKAGE_IMPORT, DART_TYPES_IMPORT);
1826
+ const pure = src.replace(DART_PACKAGE_IMPORT, dartTypesImportFor(src));
1764
1827
  // PURITY, fail-closed: only the nomos_types barrel, the framework file, and SIBLING generated type files
1765
1828
  // (cross-domain foreignRef targets) may be imported. Runtime/bridge surfaces (nomos_client) stay out.
1766
1829
  for (const line of pure.split("\n")) {
@@ -1954,7 +2017,7 @@ function emitDartPackages(
1954
2017
  const typesBarrelPath = path.join(pkgDir(packages.types), "lib", `${packages.types}.dart`);
1955
2018
  writeFileSync(typesBarrelPath, typesBarrel.join("\n"), "utf8");
1956
2019
  written.push(typesBarrelPath);
1957
- scaffoldPubspec(
2020
+ reconcilePubspec(
1958
2021
  pkgDir(packages.types),
1959
2022
  [
1960
2023
  `name: ${packages.types}`,
@@ -1967,6 +2030,7 @@ function emitDartPackages(
1967
2030
  ` nomos_types: ${NOMOS_TYPES_DEP}`,
1968
2031
  ``,
1969
2032
  ].join("\n"),
2033
+ { nomos_types: NOMOS_TYPES_DEP },
1970
2034
  );
1971
2035
 
1972
2036
  // ── the CLIENT package — effectful runtime, depends on types + nomos_client ──
@@ -2110,7 +2174,7 @@ function emitDartPackages(
2110
2174
  const clientBarrelPath = path.join(pkgDir(packages.client), "lib", `${packages.client}.dart`);
2111
2175
  writeFileSync(clientBarrelPath, clientBarrel.join("\n"), "utf8");
2112
2176
  written.push(clientBarrelPath);
2113
- scaffoldPubspec(
2177
+ reconcilePubspec(
2114
2178
  pkgDir(packages.client),
2115
2179
  [
2116
2180
  `name: ${packages.client}`,
@@ -2129,6 +2193,7 @@ function emitDartPackages(
2129
2193
  ` path: ../${packages.types} # co-emitted sibling; replace with a hosted constraint when publishing`,
2130
2194
  ``,
2131
2195
  ].join("\n"),
2196
+ { nomos_client: NOMOS_CLIENT_DEP, nomos_flutter: NOMOS_FLUTTER_DEP },
2132
2197
  );
2133
2198
 
2134
2199
  // ── the TEST package (optional) — generated business scenarios, test-only dependency ──
@@ -2195,7 +2260,7 @@ function emitDartPackages(
2195
2260
  const testBarrelPath = path.join(pkgDir(packages.test), "lib", `${packages.test}.dart`);
2196
2261
  writeFileSync(testBarrelPath, testBarrel.join("\n"), "utf8");
2197
2262
  written.push(testBarrelPath);
2198
- scaffoldPubspec(
2263
+ reconcilePubspec(
2199
2264
  pkgDir(packages.test),
2200
2265
  [
2201
2266
  `name: ${packages.test}`,
@@ -2217,6 +2282,7 @@ function emitDartPackages(
2217
2282
  ` - lib/${genRel}/${embeddedDeployName}`,
2218
2283
  ``,
2219
2284
  ].join("\n"),
2285
+ { nomos_client: NOMOS_CLIENT_DEP },
2220
2286
  );
2221
2287
  }
2222
2288
 
@@ -2270,7 +2336,7 @@ function emitDartPackages(
2270
2336
  const compatBarrelPath = path.join(pkgDir(compatName), "lib", `${compatName}.dart`);
2271
2337
  writeFileSync(compatBarrelPath, compatBarrel.join("\n"), "utf8");
2272
2338
  written.push(compatBarrelPath);
2273
- scaffoldPubspec(
2339
+ reconcilePubspec(
2274
2340
  pkgDir(compatName),
2275
2341
  [
2276
2342
  `name: ${compatName}`,
@@ -2341,6 +2407,8 @@ async function main(): Promise<void> {
2341
2407
  `${configValue.application} — compiled Nomos application composition`,
2342
2408
  `stage: ${emitted.outputPath}`,
2343
2409
  `assets: ${emitted.referencedAssets.length} local content-addressed reference(s)`,
2410
+ `deployment: ${emitted.deploymentPackages.filter((p) => p.placement === "reference").length} serving reference package(s), ` +
2411
+ `${emitted.deploymentPackages.filter((p) => p.placement === "payload").length} deferred payload package(s) — derived from the stage`,
2344
2412
  `runtime configuration: USD (the authoring .mjs does not travel)`,
2345
2413
  ].join("\n"));
2346
2414
  return;
@@ -2897,6 +2965,44 @@ async function main(): Promise<void> {
2897
2965
  ] as const) {
2898
2966
  const canonical = (composed as unknown as Record<string, unknown>)[field];
2899
2967
  if (!Array.isArray(canonical)) continue;
2968
+ // A package-level language may be declared in a different source module from the
2969
+ // aggregate its generated read indexes (workspaceSharing is the canonical case).
2970
+ // Single-module composition then discovers the read in the language module even though
2971
+ // that layer cannot type it. Move every targeted declaration beside its aggregate schema
2972
+ // before filling genuinely missing package opinions. This changes no composed law: one
2973
+ // declaration id still contributes once; it merely gives every authored USD layer a
2974
+ // self-contained, valid read projection.
2975
+ for (const value of canonical) {
2976
+ if (value === null || typeof value !== "object") continue;
2977
+ const id = (value as { id?: unknown }).id;
2978
+ if (typeof id !== "string") continue;
2979
+ const declarationTarget = field === "queries"
2980
+ ? (value as { returns?: unknown }).returns
2981
+ : (["counts", "spatials", "sums", "mins", "maxes", "deriveds", "combineds"] as const)
2982
+ .includes(field as never)
2983
+ ? (value as { of?: unknown }).of
2984
+ : undefined;
2985
+ if (typeof declarationTarget !== "string") continue;
2986
+ const ownerIndex = inputs.findIndex((input) =>
2987
+ input.module.aggregates.some((aggregate) => aggregate.id === declarationTarget));
2988
+ if (ownerIndex < 0) continue;
2989
+ for (const [inputIndex, input] of inputs.entries()) {
2990
+ if (inputIndex === ownerIndex) continue;
2991
+ const module = input.module as DomainModule & Record<string, unknown>;
2992
+ const existing = module[field];
2993
+ if (!Array.isArray(existing)) continue;
2994
+ (module as Record<string, unknown>)[field] = existing.filter((candidate) =>
2995
+ candidate === null || typeof candidate !== "object" ||
2996
+ (candidate as { id?: unknown }).id !== id);
2997
+ }
2998
+ const owner = inputs[ownerIndex]!.module as DomainModule & Record<string, unknown>;
2999
+ const ownerValues = Array.isArray(owner[field]) ? owner[field] as unknown[] : [];
3000
+ if (!ownerValues.some((candidate) =>
3001
+ candidate !== null && typeof candidate === "object" &&
3002
+ (candidate as { id?: unknown }).id === id)) {
3003
+ (owner as Record<string, unknown>)[field] = [...ownerValues, value];
3004
+ }
3005
+ }
2900
3006
  const present = new Set(inputs.flatMap((input) => {
2901
3007
  const values = (input.module as unknown as Record<string, unknown>)[field];
2902
3008
  return Array.isArray(values)
@@ -2956,7 +3062,11 @@ async function main(): Promise<void> {
2956
3062
  ? (JSON.parse(readFileSync(lockPath, "utf8")) as StableIdLock)
2957
3063
  : undefined;
2958
3064
  const inferredRenames: { domain: string; renames: InferredRename[] }[] = [];
2959
- const nextLock: StableIdLock = { v: 1, domains: {} };
3065
+ // The lock is project-wide, not package-local. Several package configs commonly live beside one
3066
+ // application stage; compiling one must not erase the stable identity of domains owned by its siblings.
3067
+ // Current package domains are replaced below, while absent domains remain only as continuity metadata
3068
+ // and never leak into this package's interface, law, clients or deployment.
3069
+ const nextLock: StableIdLock = { v: 1, domains: { ...(priorLock?.domains ?? {}) } };
2960
3070
  for (const mod of domainModules) {
2961
3071
  const identityName = mod.name;
2962
3072
  // The CURRENT canonical aggregate schemas (a pre-continuity manifest pass —
@@ -3052,7 +3162,9 @@ async function main(): Promise<void> {
3052
3162
  };
3053
3163
  }
3054
3164
  // Persist the lockfile (commit it with the law: it is how a rename keeps its
3055
- // identity across clean clones). Deterministic — re-compiles rewrite the same bytes.
3165
+ // identity across clean clones). Existing project-domain order is retained, so once sibling
3166
+ // packages are registered, recompiling them in any order rewrites the same bytes without a
3167
+ // giant formatting-only diff in an established lock.
3056
3168
  writeFileSync(lockPath, JSON.stringify(nextLock, null, 2) + "\n", "utf8");
3057
3169
 
3058
3170
  // ── THE STRUCTURAL INTERFACE (#72) — directives (payload shapes) + aggregates (read
@@ -3434,24 +3546,21 @@ async function main(): Promise<void> {
3434
3546
  for (const ld of layeredDomains) {
3435
3547
  const authored = emitLayeredDomain(ld.key, ld.inputs, { applyBusinessCorrectionLayer: false });
3436
3548
  const canonicalLayer = canonicalLayers.get(`/Nomos/${ld.key}`);
3437
- // Workspace invariants include compiler-owned consequences of the complete
3438
- // application language (birth quota, receiving-group policy, recovery). They
3439
- // are not owned by one source file, so seat the canonical declaration once in
3440
- // the weakest package prelude instead of losing it or duplicating it. Contract
3441
- // implementation links are likewise a cross-module fact: the contract may be
3442
- // declared in one layer and implemented by an aggregate in another.
3443
- const emittedLayers = authored.map((emitted, index) => {
3444
- if (index !== 0 || canonicalLayer === undefined) return emitted;
3445
- const needsPackagePrelude = canonicalLayer.nomosInvariantGate !== undefined || canonicalLayer.nomosContracts !== undefined;
3446
- if (!needsPackagePrelude) return emitted;
3549
+ // Authored layers carry structural declarations (aggregate/directive/query prims).
3550
+ // Domain-level `nomos:*` values are the compiler's closure over the complete package
3551
+ // (relation namespaces, read projections, invariant gates, contracts and language
3552
+ // consequences), so placing partial versions on source layers lets weaker generated
3553
+ // facts show through. Strip those derived attributes from source layers and append one
3554
+ // explicit strongest closure layer. This is ordinary OpenUSD composition: source
3555
+ // opinions remain inspectable and the compiler-owned overlay is the one canonical law.
3556
+ const structuralLayers = authored.map((emitted, index) => {
3557
+ const { path: layerPath, prims, queries, deriveds, combineds } = emitted.layer;
3447
3558
  const layer: UsdLayer = {
3448
- ...emitted.layer,
3449
- ...(canonicalLayer.nomosInvariantGate !== undefined
3450
- ? { nomosInvariantGate: canonicalLayer.nomosInvariantGate }
3451
- : {}),
3452
- ...(canonicalLayer.nomosContracts !== undefined
3453
- ? { nomosContracts: canonicalLayer.nomosContracts }
3454
- : {}),
3559
+ path: layerPath,
3560
+ prims,
3561
+ ...(queries !== undefined ? { queries } : {}),
3562
+ ...(deriveds !== undefined ? { deriveds } : {}),
3563
+ ...(combineds !== undefined ? { combineds } : {}),
3455
3564
  };
3456
3565
  return {
3457
3566
  ...emitted,
@@ -3459,6 +3568,20 @@ async function main(): Promise<void> {
3459
3568
  usda: usdaLayerText(ld.key, emitted.name, layer, ld.inputs[index]!.module, new Set()),
3460
3569
  };
3461
3570
  });
3571
+ const closureLayers = canonicalLayer === undefined
3572
+ ? []
3573
+ : (() => {
3574
+ const layer: UsdLayer = canonicalLayer;
3575
+ const name = "__nomos_package_closure";
3576
+ const canonicalModule = domainModules.find((module) =>
3577
+ (module.domain ?? module.name) === ld.key);
3578
+ return [{
3579
+ name,
3580
+ layer,
3581
+ usda: usdaLayerText(ld.key, name, layer, canonicalModule, new Set()),
3582
+ }];
3583
+ })();
3584
+ const emittedLayers = [...structuralLayers, ...closureLayers];
3462
3585
  for (const emitted of emittedLayers) {
3463
3586
  const base = `${safe(ld.key)}--${safe(emitted.name)}`;
3464
3587
  pendingWrites.push({ path: path.join(layersDir, `${base}.usda`), text: emitted.usda });
@@ -3485,7 +3608,7 @@ async function main(): Promise<void> {
3485
3608
  }
3486
3609
  for (const w of pendingWrites) writeFileSync(w.path, w.text, "utf8");
3487
3610
  layeredRootPath = path.join(outDir, `${cfg.name}.layered.usda`);
3488
- writeFileSync(layeredRootPath, layeredRootUsda(relPaths, flat), "utf8");
3611
+ writeFileSync(layeredRootPath, layeredRootUsda(relPaths), "utf8");
3489
3612
  layeredFileCount = relPaths.length;
3490
3613
 
3491
3614
  const usdcat = resolveUsdcat();
@@ -3634,6 +3757,9 @@ async function main(): Promise<void> {
3634
3757
  // agent who simply cats the build output.
3635
3758
  const summary = {
3636
3759
  package: cfg.name,
3760
+ // Binds the deploy body to the published package asset consumed by an application stage. This is the
3761
+ // content identity of the composed source layers; domainHash below is the runtime package-law hash.
3762
+ sourceIdentity: composedUsdIdentity!,
3637
3763
  domainHash,
3638
3764
  domains: presentationDomainModules.map((m) => ({
3639
3765
  domain: m.domain ?? m.name,
@@ -81,7 +81,11 @@ export const ReceivingGroup = aggregate("NomosInternalReceivingGroup7c1f5e", {
81
81
  workspaceName: t.string().merge(Lww),
82
82
  /** ISO-8601, caller-stamped (determinism — no clock in the plan). */
83
83
  bornAt: t.string().merge(Lww),
84
- }, { cap: 1 }).public();
84
+ // The group's genesis seats its controller in OWNED_READ_RELATION below. Declaring that same capability
85
+ // here makes the law's coarse pack gate recognise the seat, so a newly enrolled device can clone its own
86
+ // receiving group before any tenant records or tenant-authored grants exist. This remains ordinary,
87
+ // replayable Zanzibar state: revocation removes the seat; there is no kernel-level founder bypass.
88
+ }, { cap: 1 }).readableBy(OWNED_READ_RELATION);
85
89
 
86
90
  /**
87
91
  * The framework-owned device lifecycle policy for one receiving group. This is deliberately data, not a
@@ -177,6 +181,7 @@ export const seedReceivingGroup = directive("nomosInternalSeedReceivingGroup7c1f
177
181
  export const receivingGroupNameBindsPrincipal = workspaceInvariant("ReceivingGroupNameBindsPrincipal")
178
182
  .on("nomosInternalSeedReceivingGroup7c1f5e")
179
183
  .reads(() => [refAs("group", "NomosInternalReceivingGroup7c1f5e", RECEIVING_GROUP_ID)])
184
+ .declassify("the receiving-group address is a public hash of its stable principal; refusing a mismatch reveals no private field")
180
185
  .assert(({ group }) => {
181
186
  const g = (group ?? {}) as { principal?: string; workspaceName?: string };
182
187
  const expected = RECEIVING_GROUP_NAME_PREFIX
@@ -196,6 +201,7 @@ const receivingGroupPolicyRefs = () => [
196
201
  export const receivingGroupRejectsKeylessAdditionalDevice = workspaceInvariant("ReceivingGroupRejectsKeylessAdditionalDevice")
197
202
  .on("enrollFirstDevice")
198
203
  .reads(receivingGroupPolicyRefs)
204
+ .declassify("device enrollment deliberately reveals that this receiving group has already completed first-device bootstrap")
199
205
  .assert(({ group }) => typeof group?.principal !== "string" || group.principal.length === 0
200
206
  ? { accept: true }
201
207
  : { reject: "receiving-group-additional-device-requires-current-device-or-guardian" });
package/src/law_check.ts CHANGED
@@ -409,6 +409,14 @@ function unreadPayloadFindings(domain: string, dir: Directive<unknown>, probe: P
409
409
  // the gate object from a named payload id field (per-record authz). Excluding it keeps a real
410
410
  // gate-consumed field from being flagged dead (the todo-pro `shareTodo.listId` case).
411
411
  const gateConsumed = new Set<string>();
412
+ // Compiler-owned law may deliberately consume proof material only in an invariant. The
413
+ // plan must not persist a signature merely to satisfy this lint, and the tenant must not
414
+ // receive a warning about private security machinery. This is internal lowering metadata,
415
+ // not an authored escape hatch.
416
+ for (const field of (dir as { nomosInvariantConsumedPayloadFields?: readonly string[] })
417
+ .nomosInvariantConsumedPayloadFields ?? []) {
418
+ gateConsumed.add(field);
419
+ }
412
420
  const objectFromField = (dir as { requiresRelationGate?: { objectFrom?: { idField?: string } } })
413
421
  .requiresRelationGate?.objectFrom?.idField;
414
422
  if (typeof objectFromField === "string") gateConsumed.add(objectFromField);
@@ -721,7 +729,7 @@ function dottedQueryKeyFindings(mod: DomainModule, aggById: ReadonlyMap<string,
721
729
  // admits before a null is lawful (t.optional / t.clearable / t.nullable / t.json({null})). These
722
730
  // twins pre-fire the three refusals the gate would raise: (a) a payload field that ADMITS null with
723
731
  // no null-admitting receiver; (b) a null-admitting field used where a null is structurally illegal
724
- // (authority gate, identity ref, spatial index, read key); (c) a plan op writing literal null into a
732
+ // (authority gate, spatial index, read key); (c) a plan op writing literal null into a
725
733
  // field that never declared null a meaning. All PAYLOAD-INDEPENDENT (pure declarations) ⇒ REFUSE.
726
734
 
727
735
  /** The verbatim null-semantics guidance (`null_semantics.md`) — the `fix` on twin N-a/N-c. */
@@ -835,7 +843,10 @@ function nullBanFindings(mod: DomainModule, aggById: ReadonlyMap<string, Aggrega
835
843
  });
836
844
  };
837
845
 
838
- // Facet gates (authority/standing) + identity refs — a null in these breaks the very thing they decide.
846
+ // Facet gates (authority/standing) — a null in these breaks the very thing they decide. A plain
847
+ // typed ref is an association, not structural identity: clearing `asset.spaceId`, for example,
848
+ // means the asset is no longer in a space. If a ref participates in a query/group/spatial key the
849
+ // corresponding structural check below still refuses it.
839
850
  for (const agg of mod.aggregates) {
840
851
  for (const [name, f0] of Object.entries(agg.fields)) {
841
852
  const f = f0 as Field;
@@ -843,9 +854,6 @@ function nullBanFindings(mod: DomainModule, aggById: ReadonlyMap<string, Aggrega
843
854
  if (f.facet === "authority" || f.facet === "standing") {
844
855
  ban(agg.id, name, "an authority/standing gate", "drop the null semantic — an authority sink must always resolve to a subject.");
845
856
  }
846
- if (f.kind === "ref") {
847
- ban(agg.id, name, "an identity reference", "an identity ref must always point somewhere — model absence with a separate presence flag, not a null ref.");
848
- }
849
857
  }
850
858
  }
851
859
  // Read keys / group-bys / spatial indexes — a null key row is a silent dead index.