@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 +14 -0
- package/dart/lib/src/runner/node-entry.mjs +69 -18
- package/dart/pubspec.yaml +1 -1
- package/package.json +2 -2
- package/src/build_package.ts +10 -0
- package/src/business_birth.ts +44 -46
- package/src/compile_package_main.ts +4 -3
- package/src/evolve_preview.ts +42 -2
- package/src/framework/founding_genesis.ts +21 -1
- package/src/framework/framework_aggregates.ts +2 -0
- package/src/intent.ts +8 -1
- package/src/wire_evolution_gate.ts +192 -0
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.
|
|
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
|
-
|
|
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.
|
|
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)
|
|
28607
|
-
*
|
|
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
|
|
28651
|
+
const frameBytes = offerFrameBytes(batch);
|
|
28620
28652
|
if (offers.length > 1 && (batch.evidencePack.byteLength > LIVE_CARRIED_COMMITS_BYTES || frameBytes > MAX_LIVE_FRAME_BYTES)) {
|
|
28621
|
-
|
|
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.#
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/src/build_package.ts
CHANGED
|
@@ -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
|
package/src/business_birth.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
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
|
|
126
|
-
*
|
|
127
|
-
*
|
|
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
|
|
138
|
-
if (admin?.status !== "active"
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
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.
|
|
2597
|
-
const NOMOS_FLUTTER_DEP = "^0.69.
|
|
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 } : {}),
|
package/src/evolve_preview.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|