@githolon/dsl 0.119.29 → 0.119.32

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 CHANGED
@@ -1,3 +1,17 @@
1
+ ## 0.70.30
2
+
3
+ - Restage the bundled runtime with the coordinated compiler release that selects founded business births from the durable founding record, so console device re-enrolment keeps a founded workspace able to birth children (CAP-1011).
4
+
5
+ - Release the generated runner restaged with the CAP-892 compiler (wire-type evolution gate) package versions.
6
+
7
+ - The bundled Node runner carries the @githolon/runtime release that refuses an oversized live offer frame before encoding it and never records a refused frame as sent (CAP-1008).
8
+
9
+ - The bundled Node runner carries the @githolon/runtime release whose post-rebase offer check asks the kernel for one change above the custodian's head instead of every unsent change (CAP-1007).
10
+
11
+ ## 0.70.29
12
+
13
+ - The bundled Node runner names the @githolon/runtime release it was built from; it now carries the runtime that accompanies native hosts 0.69.5.
14
+
1
15
  ## 0.70.28
2
16
 
3
17
  - The bundled Node runner names the @githolon/runtime release it was built from; it now carries the runtime that accompanies native hosts 0.69.4.
@@ -26258,7 +26258,7 @@ function delayUntilDue(follow, nowUnixMillis) {
26258
26258
 
26259
26259
  // ../src/runtime/runtime_identity.ts
26260
26260
  init_define_NOMOS_KERNEL_RELEASE();
26261
- var PEER_RUNTIME_IDENTITY = "0.119.29" ? `@githolon/runtime@${"0.119.29"}` : "@githolon/runtime@unbundled";
26261
+ var PEER_RUNTIME_IDENTITY = "0.119.32" ? `@githolon/runtime@${"0.119.32"}` : "@githolon/runtime@unbundled";
26262
26262
 
26263
26263
  // ../src/runtime/replica_follow_desk.ts
26264
26264
  init_define_NOMOS_KERNEL_RELEASE();
@@ -27740,6 +27740,10 @@ function boundedReason(reason) {
27740
27740
  return new TextDecoder().decode(bytes.subarray(0, CLOSE_REASON_BYTES)).replace(/\uFFFD+$/, "");
27741
27741
  }
27742
27742
  var LIVE_CARRIED_COMMITS_BYTES = 4 * 1024 * 1024;
27743
+ function offerFrameBytes(batch) {
27744
+ const offers = batch.source.case === "inline" ? batch.source.value.offers : [];
27745
+ return batch.evidencePack.byteLength + offers.reduce((total, offer) => total + offer.changeUsda.byteLength + offer.commitOid.length + offer.session.length + 16, 0) + 1024;
27746
+ }
27743
27747
  var LIVE_ANSWER_WINDOW = 15000000000n;
27744
27748
  var LIVE_STREAM_WINDOW = 16;
27745
27749
  var LIVE_BATCH_COMMITS = 64;
@@ -27854,8 +27858,7 @@ var LiveCorrespondenceDesk = class {
27854
27858
  send: (workspace, message) => {
27855
27859
  const link = this.#links.get(workspace);
27856
27860
  if (!link || link.state !== "open" || link.publisher) return false;
27857
- this.#send(link, message);
27858
- return true;
27861
+ return this.#send(link, message);
27859
27862
  },
27860
27863
  // This copy's main as its kernel holds it (residency is not asked: a stream ask only ever
27861
27864
  // comes from a procedure that has the copy open).
@@ -28145,10 +28148,17 @@ var LiveCorrespondenceDesk = class {
28145
28148
  this.#effect(link, { case: "openUrl", value: `${endpoint}/v2/workspaces/${encodeURIComponent(link.binding.workspace)}/subscribe?${subscribeQuery(link)}` });
28146
28149
  return true;
28147
28150
  }
28151
+ /** Sends one frame, or refuses one too large for the live socket (large recovery uses Git) and
28152
+ * answers false: the caller must not account a refused frame as sent. An offers frame is refused
28153
+ * on its byte-length estimate, before it is encoded; the encoded size is the backstop. */
28148
28154
  #send(link, message) {
28155
+ if (message.case === "offers" && offerFrameBytes(create(CustodyOfferBatchRequestSchema, message.value)) > MAX_LIVE_FRAME_BYTES) {
28156
+ return false;
28157
+ }
28149
28158
  const bytes = toBinary(LiveOfferFrameSchema, create(LiveOfferFrameSchema, { message }));
28150
- if (bytes.byteLength > MAX_LIVE_FRAME_BYTES) return;
28159
+ if (bytes.byteLength > MAX_LIVE_FRAME_BYTES) return false;
28151
28160
  this.#effect(link, { case: "send", value: bytes });
28161
+ return true;
28152
28162
  }
28153
28163
  export(binding, base, requestId, options = {}) {
28154
28164
  if (this.kernel.isResident(binding.workspace) === false) {
@@ -28172,11 +28182,7 @@ var LiveCorrespondenceDesk = class {
28172
28182
  }
28173
28183
  }
28174
28184
  const batchRequestId = `${binding.replicaId}:${batchHead}`;
28175
- if (intents.length) {
28176
- this.#own.commits(binding.workspace, intents.map((intent) => intent.oid));
28177
- this.#own.request(binding.workspace, batchRequestId, batchHead);
28178
- }
28179
- return create(CustodyOfferBatchRequestSchema, {
28185
+ const batch = create(CustodyOfferBatchRequestSchema, {
28180
28186
  protocolVersion: 2,
28181
28187
  requestId: batchRequestId,
28182
28188
  source: { case: "inline", value: { offers: intents.map((intent) => ({
@@ -28191,6 +28197,15 @@ var LiveCorrespondenceDesk = class {
28191
28197
  // travel the same way.
28192
28198
  evidencePack: options.carryCommits || intents.some((intent) => intent.transportRequirement === IntentTransportRequirement.COMMIT_TREE) ? this.kernel.packDelta(mount, requestId, batchHead, base) : new Uint8Array()
28193
28199
  });
28200
+ if (!options.registerWhenSent) this.#ownBatch(binding.workspace, batch);
28201
+ return batch;
28202
+ }
28203
+ /** Records a batch this replica offered, so the custodian's answer to it is recognised as its own. */
28204
+ #ownBatch(workspace, batch) {
28205
+ const offers = batch.source.case === "inline" ? batch.source.value.offers : [];
28206
+ if (!offers.length) return;
28207
+ this.#own.commits(workspace, offers.map((offer) => offer.commitOid));
28208
+ this.#own.request(workspace, batch.requestId, offers.at(-1).commitOid);
28194
28209
  }
28195
28210
  receive(receipt) {
28196
28211
  const link = [...this.#links.values()].find((item) => item.id === receipt.connectionId);
@@ -28378,6 +28393,10 @@ var LiveCorrespondenceDesk = class {
28378
28393
  link.convergeDue = void 0;
28379
28394
  link.heldRing = void 0;
28380
28395
  }
28396
+ this.#followNow(workspace);
28397
+ }
28398
+ /** Asks for the ordinary (HTTP) round for this workspace now. */
28399
+ #followNow(workspace) {
28381
28400
  if (this.declined) this.declined(workspace);
28382
28401
  else this.remoteAdvanced?.(workspace);
28383
28402
  }
@@ -28539,19 +28558,31 @@ var LiveCorrespondenceDesk = class {
28539
28558
  link.binding,
28540
28559
  continuesAfter ?? answered2,
28541
28560
  ++this.#requestId,
28542
- { baseInHistory: continuesAfter !== void 0, carryCommits: false }
28561
+ { baseInHistory: continuesAfter !== void 0, carryCommits: false, registerWhenSent: true }
28543
28562
  ) : this.#fittingBatch(link, continuesAfter ?? answered2, continuesAfter !== void 0);
28544
28563
  if (batch.evidencePack.byteLength > LIVE_CARRIED_COMMITS_BYTES && !link.publisher) {
28545
28564
  batch = create(CustodyOfferBatchRequestSchema, { ...batch, evidencePack: new Uint8Array() });
28546
28565
  }
28566
+ let refused = false;
28547
28567
  if (batch.source.case === "inline" && batch.source.value.offers.length) {
28548
- this.#send(link, { case: "offers", value: link.publisher ? batch : create(CustodyOfferBatchRequestSchema, {
28568
+ refused = !this.#send(link, { case: "offers", value: link.publisher ? batch : create(CustodyOfferBatchRequestSchema, {
28549
28569
  ...batch,
28550
28570
  baseHead: fence,
28551
28571
  fastForwardOnly: true,
28552
28572
  ...continuesAfter ? { continuesAfter } : {}
28553
28573
  }) });
28554
- if (!link.publisher && head) {
28574
+ if (!refused) this.#ownBatch(link.binding.workspace, batch);
28575
+ else if (!link.publisher) {
28576
+ this.#followNow(link.binding.workspace);
28577
+ } else {
28578
+ this.#send(link, { case: "offers", value: create(CustodyOfferBatchRequestSchema, {
28579
+ protocolVersion: 2,
28580
+ requestId: batch.requestId,
28581
+ source: { case: "inline", value: { offers: [] } },
28582
+ ...head ? { observedHead: head } : {}
28583
+ }) });
28584
+ }
28585
+ if (!refused && !link.publisher && head) {
28555
28586
  const batchHead = batch.source.value.offers.at(-1).commitOid;
28556
28587
  link.stream ??= { sent: batchHead, unanswered: [] };
28557
28588
  link.stream.sent = batchHead;
@@ -28603,8 +28634,9 @@ var LiveCorrespondenceDesk = class {
28603
28634
  /**
28604
28635
  * A follower's next batch above `base`: up to the link's commit bound, cut down until its carried
28605
28636
  * commits fit LIVE_CARRIED_COMMITS_BYTES and its frame fits MAX_LIVE_FRAME_BYTES. A single commit
28606
- * too large to carry still goes out (the custodian admits it on its own commits). Each attempt is
28607
- * the same bounded export, so every batch still continues its base with no hole below it.
28637
+ * too large to carry still goes out (the custodian admits it on its own commits); a single commit
28638
+ * whose frame cannot fit is refused by #send before it is encoded. Each attempt is the same bounded
28639
+ * export, so every batch still continues its base with no hole below it.
28608
28640
  */
28609
28641
  #fittingBatch(link, base, baseInHistory) {
28610
28642
  let bound = link.batchCommits ?? LIVE_BATCH_COMMITS;
@@ -28613,12 +28645,13 @@ var LiveCorrespondenceDesk = class {
28613
28645
  link.binding,
28614
28646
  base,
28615
28647
  ++this.#requestId,
28616
- { baseInHistory, carryCommits: true, maxCommits: bound }
28648
+ { baseInHistory, carryCommits: true, maxCommits: bound, registerWhenSent: true }
28617
28649
  );
28618
28650
  const offers = batch.source.case === "inline" ? batch.source.value.offers : [];
28619
- const frameBytes = batch.evidencePack.byteLength + offers.reduce((total, offer) => total + offer.changeUsda.byteLength + offer.commitOid.length + offer.session.length + 16, 0) + 1024;
28651
+ const frameBytes = offerFrameBytes(batch);
28620
28652
  if (offers.length > 1 && (batch.evidencePack.byteLength > LIVE_CARRIED_COMMITS_BYTES || frameBytes > MAX_LIVE_FRAME_BYTES)) {
28621
- bound = Math.max(1, Math.floor(offers.length / 2));
28653
+ const first = offers[0];
28654
+ bound = first.changeUsda.byteLength + first.commitOid.length + first.session.length + 16 + 1024 > MAX_LIVE_FRAME_BYTES ? 1 : Math.max(1, Math.floor(offers.length / 2));
28622
28655
  link.batchCommits = bound;
28623
28656
  continue;
28624
28657
  }
@@ -29708,6 +29741,7 @@ var notContainedThisLifetime = /* @__PURE__ */ new Map();
29708
29741
  var MAX_NOT_CONTAINED = 1024;
29709
29742
  var NOT_CONTAINED_REFUSALS = /* @__PURE__ */ new Set(["nomos.stream.off_main", "nomos.stream.history_required"]);
29710
29743
  var MAX_REBASES_PER_ROUND = 2;
29744
+ var OFF_CHAIN_BASE = /intents_above: base \S* is not on the first-parent chain/;
29711
29745
  var elapsedMillis = (clock2, started) => {
29712
29746
  const finished = clock2.monotonicNanoseconds();
29713
29747
  return finished >= started ? Number(finished - started) / 1e6 : 0;
@@ -30999,7 +31033,7 @@ var WorkspaceSyncWork = class {
30999
31033
  const head = this.#kernel.head(this.#mount, this.#requestId);
31000
31034
  if (tracked !== candidateHead || !head || head === tracked) return void 0;
31001
31035
  if (candidateHead === this.#rebaseFrom) return void 0;
31002
- if (this.#kernel.transportIntentsAbove(this.#mount, this.#requestId, 0n, tracked, this.#offerHead).intents.length === 0) return void 0;
31036
+ if (!this.#holdsWorkAbove(tracked)) return void 0;
31003
31037
  this.#rebases += 1;
31004
31038
  this.#offerAfterConverge = false;
31005
31039
  this.#previousHead = tracked;
@@ -31010,6 +31044,23 @@ var WorkspaceSyncWork = class {
31010
31044
  this.#candidateDurable = true;
31011
31045
  return this.#offerOrReconcile();
31012
31046
  }
31047
+ /**
31048
+ * Whether the head this round offers holds at least one commit above `base`. Asks for the ONE
31049
+ * commit immediately above base (the bounded `fromBase` walk the live export uses): a walk of
31050
+ * commit objects to base plus one decoded change, never the whole suffix. Asking for the whole
31051
+ * suffix only to test it for emptiness decoded every unsent change (5,001 commits: 3.6 s and
31052
+ * 70.6 MB). A base off that head's first-parent chain (a dependency's captured prefix the
31053
+ * rebase rewrote, or history that ends before base) is no fast-forward from base: there is
31054
+ * nothing to offer onto it in this round, and the next round plans from the rebased head.
31055
+ */
31056
+ #holdsWorkAbove(base) {
31057
+ try {
31058
+ return this.#kernel.transportIntentsAbove(this.#mount, this.#requestId, 1n, base, this.#offerHead, true).intents.length > 0;
31059
+ } catch (error) {
31060
+ if (error instanceof Error && OFF_CHAIN_BASE.test(error.message)) return false;
31061
+ throw error;
31062
+ }
31063
+ }
31013
31064
  /**
31014
31065
  * The admitted local changes a copy with a missing history commit holds above the provider's last
31015
31066
  * admitted head (its bookmark), oldest first. Without a reachable bookmark the walk ends at the
package/dart/pubspec.yaml CHANGED
@@ -7,7 +7,7 @@ repository: https://github.com/Captain-App/nomos2
7
7
  description: >-
8
8
  Nomos 2 Dart client — generated domain values, intent payloads and application
9
9
  lifecycle types over the typed Nomos kernel protocol and USDA custody model.
10
- version: 0.70.28
10
+ version: 0.70.30
11
11
 
12
12
  environment:
13
13
  sdk: ^3.5.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.119.29",
3
+ "version": "0.119.32",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
@@ -82,7 +82,7 @@
82
82
  "test": "vitest run"
83
83
  },
84
84
  "dependencies": {
85
- "@githolon/runtime": "0.119.29",
85
+ "@githolon/runtime": "0.119.32",
86
86
  "@bufbuild/protobuf": "2.13.0",
87
87
  "@bufbuild/protoc-gen-es": "2.13.0",
88
88
  "esbuild": "0.24.2",
@@ -79,6 +79,7 @@ import {
79
79
  } from "./value_object.js";
80
80
  import { expandCapabilityExports, isCapabilityDecl } from "./capability_exports.js";
81
81
  import { RelationTuple, relationTuplesByObject } from "./framework/relations.js";
82
+ import { type FoundingPrincipalSourceQuery } from "./framework/founding_genesis.js";
82
83
  import {
83
84
  SignerDelegation,
84
85
  signerDelegationByUser,
@@ -578,6 +579,15 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
578
579
  if (!byId.has(AuthProvider.id)) byId.set(AuthProvider.id, AuthProvider);
579
580
  if (!directives.some((d) => d.id === seedAuthProvider.id)) directives.push(seedAuthProvider);
580
581
  if (!directives.some((d) => d.id === setAuthProvider.id)) directives.push(setAuthProvider);
582
+ // Read-only founding schemas preserve each source's physical namespace. No writer or ensure effect.
583
+ for (const reads of [spec.queries ?? [], ...directives.map((directive) => directive.declaredQueryReads ?? [])]) {
584
+ for (const query of reads) {
585
+ if (!("foundingPrincipalSource" in query)) continue;
586
+ const source = (query as FoundingPrincipalSourceQuery).foundingPrincipalSource;
587
+ if (!byId.has(source.id)) byId.set(source.id,
588
+ { ...source, businessBirthInternal: true } as AggregateHandle);
589
+ }
590
+ }
581
591
  }
582
592
  // SECONDARY-CREATE AUTO-DECLARE (secondary_ensure.ts — the id-mint-gate DX): a plan may `ensure(B, "det:id")`
583
593
  // a SECOND aggregate at a deterministic id of a type its directive does NOT declare `.ensures()` for. The
@@ -79,6 +79,8 @@ import {
79
79
  import {
80
80
  BirthOrigin,
81
81
  FoundingPrincipal,
82
+ FOUNDING_PRINCIPAL_ID,
83
+ foundingPrincipalSourceQueries,
82
84
  birthOriginVirginOnly,
83
85
  foundingPrincipalVirginOnly,
84
86
  seedBirthOrigin,
@@ -98,58 +100,49 @@ import {
98
100
  grantDelegatedRole,
99
101
  revokeDelegatedRole,
100
102
  } from "./framework/delegated_roles.js";
101
- import { consoleGovernanceLaw, inheritedGovernanceStep, INHERITED_SIGNER_GRANTOR } from "./framework/console_governance.js";
102
- import { readFrameworkDeclaredRows } from "./read.js";
103
+ import { consoleGovernanceLaw, inheritedGovernanceStep } from "./framework/console_governance.js";
104
+ import { readFrameworkDeclared, readFrameworkDeclaredRows } from "./read.js";
103
105
  import { endorsementOf } from "./principal_evidence.js";
104
106
 
105
- /** The `grantedBy` of every tuple `seedFoundingPrincipal` seats (`writeFoundingPrincipal` in
106
- * framework/founding_genesis.ts); nothing else grants as it. */
107
- const FOUNDING_GRANTOR = "genesis";
108
-
109
107
  /**
110
- * THE MODE IS SELECTED BY LAW, FROM THE PARENT'S STATE (identity target model D22). A parent founded
111
- * by token-free genesis by P holds `workspace:self#admin@P` granted by {@link FOUNDING_GRANTOR}
112
- * (`seedFoundingPrincipal`; nothing else grants admin as it), and P's founding key as a signer seated
113
- * on its warrant-free prefix. When the birth's principal IS that founder (a founded Home birthing its
114
- * owner's estate), the child is founded the same way: the author is its founding principal, with that
115
- * key, and the parent's operators and enrollers. Every other parent (every workspace born on the
116
- * certificate lane) keeps the historical recipe byte-for-byte, so live chains keep their path until
117
- * regenerated. Both reads are the declared `relationTuplesByObject` every birth already captures.
118
- *
119
- * The founding key is P's one `genesis`-granted signer. A workspace founded on the e712cae05 law, whose
120
- * inherited-governance step re-seated that same leaf as {@link INHERITED_SIGNER_GRANTOR} when P was also
121
- * an inherited operator or enroller, has no `genesis` key left; there the key is P's one
122
- * inheritance-granted signer instead. Current law never rewrites the founding leaf
123
- * (`inheritedGovernanceStepFrom` skips it), so its founders keep exactly one `genesis` key.
108
+ * Select token-free ancestry from the durable, kernel-validated founding singleton. Enrollment may
109
+ * re-grant an existing signer or admin leaf, so `grantedBy` is current grant provenance, not a birth
110
+ * fact. The original key still must be an active signer and its principal must remain an active
111
+ * administrator; neither another enrolled device nor an admin grant can replace the founding record.
124
112
  *
125
- * A signer leaf is an Lww entry, so its grantor is only the LAST writer: when an enroller enrols the
126
- * founding key again (`enrollDevice`) the leaf stops saying `genesis`, and when the founder revokes the
127
- * founding device the leaf stops being active. Either way neither rule above finds a key, and every
128
- * birth of a parent P founded used to fall onto the certificate lane, which a founded parent cannot
129
- * satisfy (CAP-957). P is still the founder — the admin grant above is the founding step's and nothing
130
- * else writes it — so the child is founded with a key P holds now: the lowest of P's active signer keys,
131
- * so the recipe stays a pure function of the tuple set. Anything else — no founding admin grant, several
132
- * `genesis` or several inherited keys, or no active key at all — is not a founded parent of P.
113
+ * A parent with no founding record for this principal keeps its historical certificate recipe.
114
+ * A founded parent whose original authority was revoked fails closed: key rotation is not permission
115
+ * to seat that revoked key in a new child, or to invent a new immutable founder key here.
133
116
  */
134
- function foundedBirthKeyHash(principal: string): string | undefined {
117
+ function foundedBirthKeyHash(principal: string, domains: readonly string[]): string | undefined {
118
+ const rows = foundingPrincipalSourceQueries(domains).flatMap((query) => {
119
+ const rows = readFrameworkDeclared<unknown>(query.id, { principal });
120
+ if (!Array.isArray(rows)) throw new Error("founded-business-birth-invalid-founding-record");
121
+ return rows;
122
+ });
123
+ if (rows.length === 0) return undefined;
124
+ if (rows.length !== 1) throw new Error("founded-business-birth-ambiguous-founding-record");
125
+ const parsed = z.object({
126
+ id: z.literal(FOUNDING_PRINCIPAL_ID),
127
+ data: z.object({
128
+ principal: FoundingPrincipal.fields.principal.zod,
129
+ keyHash: FoundingPrincipal.fields.keyHash.zod,
130
+ }),
131
+ }).safeParse(rows[0]);
132
+ if (!parsed.success || parsed.data.data.principal !== principal
133
+ || !/^[0-9a-f]{64}$/.test(parsed.data.data.keyHash)) {
134
+ throw new Error("founded-business-birth-invalid-founding-record");
135
+ }
136
+ const keyHash = parsed.data.data.keyHash;
135
137
  const adminRow = readFrameworkDeclaredRows(relationTuplesByObject, RelationTuple, { object: "workspace:self" })
136
138
  .find((candidate) => candidate.relation === "admin");
137
- const admin = (adminRow?.subjects as Record<string, { status?: string; grantedBy?: string }> | undefined)?.[principal];
138
- if (admin?.status !== "active" || admin.grantedBy !== FOUNDING_GRANTOR) return undefined;
139
- const rows = readFrameworkDeclaredRows(relationTuplesByObject, RelationTuple, { object: signerObject(principal) });
140
- const row = rows.find((candidate) => candidate.relation === SIGNER_RELATION);
141
- const subjects = row?.subjects as Record<string, { status?: string; grantedBy?: string }> | undefined;
142
- const activeKeys = (grantor?: string) => Object.entries(subjects ?? {})
143
- .filter(([subject, leaf]) =>
144
- leaf?.status === "active" && (grantor === undefined || leaf.grantedBy === grantor) &&
145
- /^key:[0-9a-f]{64}$/.test(subject))
146
- .map(([subject]) => subject.slice("key:".length))
147
- .sort();
148
- const genesis = activeKeys(FOUNDING_GRANTOR);
149
- if (genesis.length > 0) return genesis.length === 1 ? genesis[0] : undefined;
150
- const inherited = activeKeys(INHERITED_SIGNER_GRANTOR);
151
- if (inherited.length > 0) return inherited.length === 1 ? inherited[0] : undefined;
152
- return activeKeys()[0];
139
+ const admin = (adminRow?.subjects as Record<string, { status?: string }> | undefined)?.[principal];
140
+ if (admin?.status !== "active") throw new Error("founded-business-birth-founder-not-current-admin");
141
+ const signerRow = readFrameworkDeclaredRows(relationTuplesByObject, RelationTuple, { object: signerObject(principal) })
142
+ .find((candidate) => candidate.relation === SIGNER_RELATION);
143
+ const signer = (signerRow?.subjects as Record<string, { status?: string }> | undefined)?.[`key:${keyHash}`];
144
+ if (signer?.status !== "active") throw new Error("founded-business-birth-founding-key-not-current-signer");
145
+ return keyHash;
153
146
  }
154
147
 
155
148
  /** @internal Compiler-only domain enumeration. It carries no allowance or authority. */
@@ -379,7 +372,7 @@ function lowerOne<P extends Record<string, unknown>, F extends Record<string, Fi
379
372
  );
380
373
  }
381
374
  const child = workspaceNameForKey(namespace, birthKey);
382
- const foundingKeyHash = foundedBirthKeyHash(founder);
375
+ const foundingKeyHash = foundedBirthKeyHash(founder, decl.__nomosBirthPolicyDomains ?? initialBirthPolicyDomains);
383
376
  const founded = foundingKeyHash !== undefined;
384
377
  if (founded) {
385
378
  // No certificate or token proves who the founder is, so the parent's own admission does: the
@@ -516,6 +509,7 @@ function lowerOne<P extends Record<string, unknown>, F extends Record<string, Fi
516
509
  .reads(authProviderKeys)
517
510
  .reads(authProviderCertificates)
518
511
  .reads(relationTuplesByObject)
512
+ .reads(...foundingPrincipalSourceQueries(initialBirthPolicyDomains))
519
513
  // Token-free founding: the enroller tuples a founded child inherits.
520
514
  .reads(relationTuplesByRelation)
521
515
  .alsoEnsures(RelationTuple)
@@ -552,6 +546,10 @@ function lowerOne<P extends Record<string, unknown>, F extends Record<string, Fi
552
546
  businessBirth: metadata,
553
547
  [BUSINESS_BIRTH_POLICY_BINDING]: (domains: readonly string[]) => {
554
548
  decl.__nomosBirthPolicyDomains = domains;
549
+ const reads = parent.declaredQueryReads!;
550
+ reads.splice(0, reads.length,
551
+ ...reads.filter((query) => !("foundingPrincipalSource" in query)),
552
+ ...foundingPrincipalSourceQueries(domains));
555
553
  },
556
554
  ...(applicationIntentOnlyBirths.has(decl) ? { businessBirthInternal: true } : {}),
557
555
  } as BusinessBirthDirective;
@@ -2593,8 +2593,8 @@ const NOMOS_TYPES_DEP = "^0.16.0";
2593
2593
  // 0.48.x: session-bound clients (XClient.bind/NomosClientInterface/DomainClientSessionMismatch/
2594
2594
  // DomainNotInstalledForSession, engine.follow, pendingConflicts lanes) — the generated client
2595
2595
  // REQUIRES this surface. 0.48.1 fixes NomosSession.watchPendingConflicts delegation.
2596
- const NOMOS_CLIENT_DEP = "^0.70.28";
2597
- const NOMOS_FLUTTER_DEP = "^0.69.19";
2596
+ const NOMOS_CLIENT_DEP = "^0.70.30";
2597
+ const NOMOS_FLUTTER_DEP = "^0.69.22";
2598
2598
  // DRIFT TRIPWIRE: pubspec_pins.test.ts asserts these equal the CURRENT versions in the Dart and Flutter
2599
2599
  // package pubspecs — a release without this bump fails the suite, so tenants never
2600
2600
  // land back in dependency_overrides (the exact co2 report this closes, third occurrence).
@@ -5370,7 +5370,8 @@ async function main(): Promise<void> {
5370
5370
  composeDomainModule({
5371
5371
  name: d.name ?? d.key,
5372
5372
  domain: d.key,
5373
- modules: [...mods, ...evolutionMods],
5373
+ modules: [...mods, ...evolutionMods,
5374
+ { [BUSINESS_BIRTH_POLICY_DOMAINS_EXPORT]: applicationBirthPolicyDomains }],
5374
5375
  ...(extraAggregates.length > 0 ? { extraAggregates } : {}),
5375
5376
  ...(queries.length > 0 ? { queries } : {}),
5376
5377
  ...(counts.length > 0 ? { counts } : {}),
@@ -21,6 +21,10 @@
21
21
  * REMOVAL (refuse) — a sid disappeared; declare a typed retirement in the evolution layer.
22
22
  * CONTRACT (refuse) — same sid, changed value language without a deterministic projection;
23
23
  * needs `.evolvesFrom(...)`, never a disposition.
24
+ * WIRE (refuse) — same sid, different Protobuf wire form (CAP-892): the field number is a
25
+ * function of the sid, so an old-law replica's rows would not decode. No
26
+ * evolution covers it — `.evolvesFrom(...)` proves VALUES, not the wire; the
27
+ * lawful path is a new field. See `wire_evolution_gate.ts`.
24
28
  * DRIVER (refuse) — same sid, merge driver moved to a different stored value space (e.g.
25
29
  * Lww → Counter); needs a typed evolution or a new field. Switching among single-value
26
30
  * drivers (Lww/LwwCausal/LwwBy/FirstWriterWins/FirstWriterCandidates/Surfaced/Attributed)
@@ -42,6 +46,9 @@ export type { CompilerIdentityModel } from "./identity_model.js";
42
46
  import type { DomainModule } from "./codegen_dart.js";
43
47
  import type { WireSchema } from "./wire.js";
44
48
  import { driversShareValueSpace } from "./drivers.js";
49
+ import type { StableFieldId } from "./stable_ids_types.js";
50
+ import { describeWireForm, storedFieldWireForm, wireDivergence, type WireForm } from "./wire_evolution_gate.js";
51
+ import { protobufFieldNumber } from "./protobuf_field_number.js";
45
52
 
46
53
  // ── the normalized law shape both sides of the diff speak ──────────────────────────
47
54
 
@@ -60,6 +67,9 @@ export interface EvolveAggregate {
60
67
  contract?: string;
61
68
  schema?: string;
62
69
  evolutions?: readonly { from: string; to: string }[];
70
+ /** The stored Protobuf wire form, derived from the recorded value contract + driver.
71
+ * Absent for a pre-contract lock entry (nothing to judge). */
72
+ wire?: WireForm;
63
73
  }>;
64
74
  }
65
75
 
@@ -83,6 +93,16 @@ export type EvolveLaw = Record<string, EvolveDomain>;
83
93
 
84
94
  // ── normalizers: build an EvolveLaw from the artifacts a dev already has ────────────
85
95
 
96
+ /** The wire form of a recorded stable field, or nothing when it carries no value contract. */
97
+ function wireOf(field: Pick<StableFieldId, "kind" | "shape">, driver: unknown): { wire: WireForm } | Record<string, never> {
98
+ const wire = storedFieldWireForm({
99
+ ...(field.kind === undefined ? {} : { kind: field.kind }),
100
+ shape: field.shape,
101
+ driver,
102
+ });
103
+ return wire === undefined ? {} : { wire };
104
+ }
105
+
86
106
  /** Build ONE domain's aggregate surface from a #58 `StableIds` block (+ optional driver
87
107
  * schemas keyed by CURRENT aggregate label). The sids/kinds come from the stable-id block;
88
108
  * the drivers (when supplied) sharpen the retype arm. */
@@ -103,6 +123,7 @@ function aggregatesFromStableIds(
103
123
  ...(driver !== undefined ? { driver: JSON.stringify(driver) } : {}),
104
124
  ...(f.contract !== undefined ? { contract: f.contract } : {}),
105
125
  ...(f.schema !== undefined ? { schema: f.schema } : {}),
126
+ ...wireOf(f, driver),
106
127
  ...(f.evolutions !== undefined ? {
107
128
  evolutions: f.evolutions.map((rule) => ({ from: rule.from, to: rule.to })),
108
129
  } : {}),
@@ -157,11 +178,12 @@ export function evolveLawFromInterface(
157
178
  const schema = locked?.schemas?.[agg.label]
158
179
  ?? Object.entries(locked?.schemas ?? {}).find(([type]) => type.endsWith(`.${agg.label}`))?.[1];
159
180
  for (const f of Object.values(agg.fields)) {
160
- const driver = schema?.[f.label];
161
- if (driver !== undefined) (f as { driver?: string }).driver = JSON.stringify(driver);
162
181
  const stable = stableFields.get(f.sid);
182
+ const driver = stable?.driver ?? schema?.[f.label];
183
+ if (driver !== undefined) (f as { driver?: string }).driver = JSON.stringify(driver);
163
184
  if (stable?.contract !== undefined) f.contract = stable.contract;
164
185
  if (stable?.schema !== undefined) f.schema = stable.schema;
186
+ if (stable !== undefined) Object.assign(f, wireOf(stable, driver));
165
187
  if (stable?.evolutions !== undefined) {
166
188
  f.evolutions = stable.evolutions.map((rule) => ({ from: rule.from, to: rule.to }));
167
189
  }
@@ -231,6 +253,7 @@ export function currentEvolveLaw(
231
253
  if (driver !== undefined) (f as { driver?: string }).driver = JSON.stringify(driver);
232
254
  if (stableField?.contract !== undefined) f.contract = stableField.contract;
233
255
  if (stableField?.schema !== undefined) f.schema = stableField.schema;
256
+ if (stableField !== undefined) Object.assign(f, wireOf(stableField, driver));
234
257
  if (stableField?.evolutions !== undefined) {
235
258
  f.evolutions = stableField.evolutions.map((rule) => ({ from: rule.from, to: rule.to }));
236
259
  }
@@ -368,6 +391,23 @@ function diffDomain(
368
391
  });
369
392
  continue;
370
393
  }
394
+ // WIRE (CAP-892): no evolution covers it — an evolution proves the VALUE projection, but an
395
+ // old-law replica still encodes the old wire form under this Protobuf number.
396
+ const wire = oldF.wire !== undefined && newF.wire !== undefined
397
+ ? wireDivergence(oldF.wire, newF.wire, newF.label)
398
+ : undefined;
399
+ if (wire !== undefined) {
400
+ const at = wire.path === newF.label ? "" : ` (at ${wire.path})`;
401
+ const before = describeWireForm(wire.before);
402
+ const after = describeWireForm(wire.after);
403
+ findings.push({
404
+ domain,
405
+ level: "refuse",
406
+ what: `field '${oldAgg.label}.${oldF.label}' changes the wire type of ${newAgg.label}.${newF.label}${at} under the unchanged stable id ${fSid} (Protobuf field ${protobufFieldNumber(fSid)}): ${before} -> ${after}. A replica still on the predecessor law encodes it as ${before}, and an application built for this law cannot decode it`,
407
+ fix: `never change a field's wire type under the same id: add a new field (it gets a new stable id) carrying the new type, and deprecate the old one (retire it once no era writes it)`,
408
+ });
409
+ continue;
410
+ }
371
411
  if ((kindChanged || driverChanged) && !evolutionCovered) {
372
412
  const how = kindChanged && !contractCovered
373
413
  ? `read kind ${oldF.kind} → ${newF.kind}`
@@ -32,13 +32,15 @@
32
32
  * enroller (`principalScope:user#enroller`) exactly like any enrolled principal.
33
33
  */
34
34
  import { z } from "zod";
35
- import { aggregate, instance } from "../aggregate.js";
35
+ import { aggregate, instance, type AggregateHandle } from "../aggregate.js";
36
36
  import { t } from "../fields.js";
37
37
  import { Lww } from "../drivers.js";
38
38
  import { directive } from "../directive.js";
39
39
  import { set, type PlannedOp } from "../ops.js";
40
40
  import { principalScopeOf } from "../member_authentication.js";
41
41
  import { RelationTuple, relationTuplesByObject, writeTuple } from "./relations.js";
42
+ import { query, type QueryDecl } from "../query.js";
43
+ import { qualifyReadProjectionType } from "../read_projection_namespace.js";
42
44
  import { readFrameworkDeclaredRows } from "../read.js";
43
45
  import { readRef, readSet, workspaceInvariant } from "./workspace_invariant.js";
44
46
  import { GENESIS_WARRANT_ID } from "./genesis.js";
@@ -75,6 +77,24 @@ export const FoundingPrincipal = aggregate("FoundingPrincipal", {
75
77
  keyHash: t.string().merge(Lww), // sha256hex of the founding signer's public key
76
78
  }).public();
77
79
 
80
+ /** A read-only schema for an existing physical founding type. This never changes its writer's type. */
81
+ export interface FoundingPrincipalSourceQuery extends QueryDecl {
82
+ readonly foundingPrincipalSource: AggregateHandle;
83
+ }
84
+
85
+ export function foundingPrincipalSourceQueries(domains: readonly string[]): FoundingPrincipalSourceQuery[] {
86
+ // Governance is a separately installed framework law, so its canonical source is not in the
87
+ // application's business-birth inventory. Other sources come from that compiler-derived inventory.
88
+ return [...new Set(["workspaces", ...domains])].sort().map((domain) => {
89
+ const source = aggregate(qualifyReadProjectionType(domain, FoundingPrincipal.id), FoundingPrincipal.fields,
90
+ { global: true }).public();
91
+ return {
92
+ ...query(`foundingPrincipalByPrincipal_${domain}`).key("principal").returns(source),
93
+ foundingPrincipalSource: source,
94
+ };
95
+ });
96
+ }
97
+
78
98
  const sha256Hex = z.string().regex(/^[0-9a-f]{64}$/, "expected lowercase sha256 hex (a public key hash)");
79
99
  /** Every field that is law on the founding step. Strict: no token-, issuer- or certificate-shaped field.
80
100
  * Subject-class checks are object-level refinements (the compiler's security discovery must be able to
@@ -42,6 +42,7 @@ import * as disclosure from "./disclosure.js";
42
42
  import * as domainLifecycle from "./domain_lifecycle.js";
43
43
  import * as encryptionIdentity from "./encryption_identity.js";
44
44
  import * as encryptionScope from "./encryption_scope.js";
45
+ import * as foundingGenesis from "./founding_genesis.js";
45
46
  import * as genesis from "./genesis.js";
46
47
  import * as home from "./receiving_group.js";
47
48
  import * as ownedCustody from "./owned_custody.js";
@@ -66,6 +67,7 @@ const FRAMEWORK_NAMESPACES: ReadonlyArray<Record<string, unknown>> = [
66
67
  domainLifecycle,
67
68
  encryptionIdentity,
68
69
  encryptionScope,
70
+ foundingGenesis,
69
71
  genesis,
70
72
  home,
71
73
  ownedCustody,
package/src/intent.ts CHANGED
@@ -1063,9 +1063,16 @@ class IntentBuilder<I> {
1063
1063
  const businessBirth = (
1064
1064
  birthSteps[0]?.directive as BusinessBirthDirective | undefined
1065
1065
  )?.businessBirth;
1066
- const bindBusinessBirthPolicy = (
1066
+ const bindBirthPolicy = (
1067
1067
  birthSteps[0]?.directive as BusinessBirthDirective | undefined
1068
1068
  )?.[BUSINESS_BIRTH_POLICY_BINDING];
1069
+ const bindBusinessBirthPolicy = bindBirthPolicy === undefined ? undefined : (domains: readonly string[]) => {
1070
+ bindBirthPolicy(domains);
1071
+ queryReads.splice(0, queryReads.length, ...dedupeBy([
1072
+ ...intentQueryReads,
1073
+ ...this.steps.flatMap((step) => step.directive.declaredQueryReads ?? []),
1074
+ ], (query) => query.id));
1075
+ };
1069
1076
  const carriedCapabilities = [
1070
1077
  ...new Set(
1071
1078
  this.steps.flatMap((step) => capabilityDeclarationsOf(step.directive))
@@ -0,0 +1,192 @@
1
+ // NOMOS — THE WIRE-TYPE EVOLUTION GATE (CAP-892).
2
+ //
3
+ // A stored field's Protobuf field number is a pure function of its stable id
4
+ // (`protobuf_field_number.ts`), never of its type. That is what lets a field be renamed or an enum
5
+ // widened without renumbering — and it is also why a field's WIRE FORM must never change under an
6
+ // unchanged stable id. A replica still on the predecessor law encodes rows with the predecessor's
7
+ // descriptor; an application built for the successor decodes them with the successor's bindings.
8
+ // If the same number now means a different wire form, the decoder reads garbage (CO2-1714 turned
9
+ // catalogue listing `shape` from a plain string into a `NullableField` envelope under sid
10
+ // b8263dbf0459cb01 / field 324349277, and an old-law replica's `"circle"` then failed to parse as
11
+ // a message: CO2-APP-209).
12
+ //
13
+ // Protobuf's own evolution rule is the law here: never change a field's wire type under the same
14
+ // number. This module derives each stored field's wire form from the value contract the committed
15
+ // identity lock already records (`StableFieldId.shape` + `driver`), so it judges every existing
16
+ // lock without a format change, and compares predecessor against successor per stable id.
17
+ //
18
+ // Wire-compatible (allowed): new fields (new sids), removed fields, label renames on the same sid,
19
+ // enum values added or removed (enums travel as strings), optional/required changes (every scalar
20
+ // is emitted `optional`), driver changes within the same envelope, record members added/removed.
21
+ // Wire-incompatible (refused): scalar ↔ message, a different scalar (string ↔ sint64 ↔ double ↔
22
+ // bool), gaining or losing the explicit-null `NullableField` envelope (clearable / nullable), the
23
+ // `SurfacedField` envelope (a `Surfaced` driver), or a change of collection shape (single value ↔
24
+ // list ↔ map ↔ record ↔ sum) — at the top level or inside a record member, list item or map value.
25
+ // The operation schema has no `bytes` type (encrypted fields cross the wire as their plaintext
26
+ // type), so string ↔ bytes cannot arise here; encryption toggles stay refused by the value-contract
27
+ // evolution proof in `stable_ids_for_module.ts`.
28
+ //
29
+ // There is no declared-breaking-change escape: the lawful path is a new field (new stable id) and
30
+ // deprecating the old one, which keeps every era's rows decodable by every application.
31
+ //
32
+ // ONE GATE: this module only DERIVES and COMPARES wire forms. The refusal is the WIRE arm of the
33
+ // evolve gate (`evolve_preview.ts` evolveDiff), so `nomos-compile`, `githolon check --against`
34
+ // and the LSP all judge it in the same pass as removals, contract and driver changes.
35
+
36
+ import type { StableFieldId, StableIds } from "./stable_ids_types.js";
37
+
38
+ /** The structural wire form of one stored value, as the operation Protobuf schema encodes it. */
39
+ export type WireForm =
40
+ | { readonly t: "scalar"; readonly scalar: "string" | "sint64" | "double" | "bool" }
41
+ | { readonly t: "nullable-field"; readonly inner: WireForm }
42
+ | { readonly t: "surfaced-field"; readonly inner: WireForm }
43
+ | { readonly t: "nullable"; readonly inner: WireForm }
44
+ | { readonly t: "record"; readonly members: Readonly<Record<string, WireForm>> }
45
+ | { readonly t: "map"; readonly value: WireForm }
46
+ | { readonly t: "list"; readonly item: WireForm }
47
+ | { readonly t: "message"; readonly what: "sum" | "null" | "unknown" | "literal" };
48
+
49
+ type Json = Record<string, unknown>;
50
+
51
+ const isObject = (value: unknown): value is Json =>
52
+ value !== null && typeof value === "object" && !Array.isArray(value);
53
+
54
+ const scalarOfLiteral = (value: unknown): WireForm => {
55
+ if (value === null) return { t: "message", what: "null" };
56
+ if (typeof value === "string") return { t: "scalar", scalar: "string" };
57
+ if (typeof value === "boolean") return { t: "scalar", scalar: "bool" };
58
+ if (typeof value === "number") return { t: "scalar", scalar: Number.isInteger(value) ? "sint64" : "double" };
59
+ return { t: "message", what: "literal" };
60
+ };
61
+
62
+ const isNullSchema = (schema: unknown): boolean =>
63
+ isObject(schema) && (schema.type === "null" || (Object.prototype.hasOwnProperty.call(schema, "const") && schema.const === null));
64
+
65
+ /** The wire form of a canonical JSON-schema value contract (the same Zod value the law graph lowers). */
66
+ export function jsonSchemaWireForm(schema: unknown): WireForm {
67
+ if (!isObject(schema)) return { t: "message", what: "unknown" };
68
+ const union = Array.isArray(schema.anyOf) ? schema.anyOf : Array.isArray(schema.oneOf) ? schema.oneOf : undefined;
69
+ if (union !== undefined) {
70
+ const present = union.filter((arm) => !isNullSchema(arm));
71
+ if (present.length === 1 && present.length < union.length) {
72
+ return { t: "nullable", inner: jsonSchemaWireForm(present[0]) };
73
+ }
74
+ if (present.length === 1) return jsonSchemaWireForm(present[0]);
75
+ return { t: "message", what: "sum" };
76
+ }
77
+ if (Array.isArray(schema.enum)) {
78
+ // Enums travel as their string member; adding or removing members never changes the wire.
79
+ const forms = new Set(schema.enum.map((member) => JSON.stringify(scalarOfLiteral(member))));
80
+ return forms.size === 1 ? scalarOfLiteral(schema.enum[0]) : { t: "message", what: "sum" };
81
+ }
82
+ if (Object.prototype.hasOwnProperty.call(schema, "const")) return scalarOfLiteral(schema.const);
83
+ const types = Array.isArray(schema.type) ? (schema.type as unknown[]) : schema.type === undefined ? [] : [schema.type];
84
+ const present = types.filter((type) => type !== "null");
85
+ if (present.length === 1 && present.length < types.length) {
86
+ return { t: "nullable", inner: jsonSchemaWireForm({ ...schema, type: present[0] }) };
87
+ }
88
+ if (present.length > 1) return { t: "message", what: "sum" };
89
+ switch (present[0]) {
90
+ case "string": return { t: "scalar", scalar: "string" };
91
+ case "integer": return { t: "scalar", scalar: "sint64" };
92
+ case "number": return { t: "scalar", scalar: "double" };
93
+ case "boolean": return { t: "scalar", scalar: "bool" };
94
+ case "null": return { t: "message", what: "null" };
95
+ case "array": return { t: "list", item: jsonSchemaWireForm(schema.items) };
96
+ case "object": {
97
+ const properties = isObject(schema.properties) ? schema.properties : {};
98
+ if (Object.keys(properties).length === 0 && isObject(schema.additionalProperties)) {
99
+ return { t: "map", value: jsonSchemaWireForm(schema.additionalProperties) };
100
+ }
101
+ return {
102
+ t: "record",
103
+ members: Object.fromEntries(
104
+ Object.entries(properties).map(([name, member]) => [name, jsonSchemaWireForm(member)]),
105
+ ),
106
+ };
107
+ }
108
+ default: return { t: "message", what: "unknown" };
109
+ }
110
+ }
111
+
112
+ const NULL_ADMITTING = new Set(["explicit-empty", "nullable"]);
113
+
114
+ /** The outermost merge driver kind of an encoded (`encodeDriver`) driver. */
115
+ const outermostDriver = (driver: unknown): string | undefined =>
116
+ typeof driver === "string" ? driver : isObject(driver) ? Object.keys(driver)[0] : undefined;
117
+
118
+ /**
119
+ * The wire form of one stored aggregate field from its recorded value contract and driver —
120
+ * mirroring the row emission in `codegen_call_proto.ts`: the authored value, wrapped in
121
+ * `SurfacedField` when the outermost driver is `Surfaced`, wrapped in `NullableField` when the
122
+ * field admits an explicit null (clearable or a null-admitting null semantic).
123
+ * `undefined` when the record carries no contract (a pre-contract lock entry: nothing to judge).
124
+ */
125
+ export function storedFieldWireForm(field: Pick<StableFieldId, "kind" | "shape" | "driver">): WireForm | undefined {
126
+ if (!isObject(field.shape)) return undefined;
127
+ const shape = field.shape;
128
+ const kind = (shape.kind ?? field.kind) as string | undefined;
129
+ let form: WireForm;
130
+ if (kind === "counter") form = { t: "scalar", scalar: "sint64" };
131
+ else if (kind === "position") form = { t: "scalar", scalar: "string" };
132
+ else if (kind === "ref" && shape.nomosRef === undefined && shape.versionedForeignRef === undefined) {
133
+ // A plain reference is stored and transported as its target id string.
134
+ form = { t: "scalar", scalar: "string" };
135
+ } else form = jsonSchemaWireForm(shape.jsonSchema);
136
+ if (outermostDriver(field.driver) === "Surfaced") form = { t: "surfaced-field", inner: form };
137
+ const admitsNull = shape.clearable === true ||
138
+ (typeof shape.nullSemantic === "string" && NULL_ADMITTING.has(shape.nullSemantic));
139
+ if (admitsNull) form = { t: "nullable-field", inner: form };
140
+ return form;
141
+ }
142
+
143
+ /** A readable rendering of a wire form for refusals. */
144
+ export function describeWireForm(form: WireForm): string {
145
+ switch (form.t) {
146
+ case "scalar": return form.scalar;
147
+ case "nullable-field": return `NullableField message { is_null | ${describeWireForm(form.inner)} }`;
148
+ case "surfaced-field": return `SurfacedField message { resolved ${describeWireForm(form.inner)} | pending }`;
149
+ case "nullable": return `Nullable message { is_null | ${describeWireForm(form.inner)} }`;
150
+ case "record": {
151
+ const names = Object.keys(form.members).sort();
152
+ return `record message { ${names.join(", ")} }`;
153
+ }
154
+ case "map": return `map message <${describeWireForm(form.value)}>`;
155
+ case "list": return `list message <${describeWireForm(form.item)}>`;
156
+ case "message": return `${form.what} message`;
157
+ }
158
+ }
159
+
160
+ /** The first wire divergence between two forms (`undefined` when wire-compatible). */
161
+ export function wireDivergence(
162
+ before: WireForm,
163
+ after: WireForm,
164
+ path: string,
165
+ ): { readonly path: string; readonly before: WireForm; readonly after: WireForm } | undefined {
166
+ if (before.t !== after.t) return { path, before, after };
167
+ switch (before.t) {
168
+ case "scalar":
169
+ return before.scalar === (after as typeof before).scalar ? undefined : { path, before, after };
170
+ case "message":
171
+ return before.what === (after as typeof before).what ? undefined : { path, before, after };
172
+ case "nullable-field":
173
+ case "surfaced-field":
174
+ case "nullable":
175
+ return wireDivergence(before.inner, (after as typeof before).inner, path);
176
+ case "list":
177
+ return wireDivergence(before.item, (after as typeof before).item, `${path}[]`);
178
+ case "map":
179
+ return wireDivergence(before.value, (after as typeof before).value, `${path}{}`);
180
+ case "record": {
181
+ const next = (after as typeof before).members;
182
+ // Members present in only one era are added/removed fields: wire-compatible.
183
+ for (const name of Object.keys(before.members).sort()) {
184
+ const nextMember = next[name];
185
+ if (nextMember === undefined) continue;
186
+ const divergence = wireDivergence(before.members[name]!, nextMember, `${path}.${name}`);
187
+ if (divergence !== undefined) return divergence;
188
+ }
189
+ return undefined;
190
+ }
191
+ }
192
+ }