@serfab/cadre-core 1.17.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +3 -2
  2. package/dist/cadre-invite-protocol.d.ts +35 -13
  3. package/dist/cadre-invite-protocol.js +90 -25
  4. package/dist/cadre-invite-protocol.js.map +1 -1
  5. package/dist/cadre-node.d.ts +586 -255
  6. package/dist/cadre-node.js +1176 -564
  7. package/dist/cadre-node.js.map +1 -1
  8. package/dist/control-cohort.d.ts +66 -9
  9. package/dist/control-cohort.js +67 -9
  10. package/dist/control-cohort.js.map +1 -1
  11. package/dist/control-database.d.ts +451 -193
  12. package/dist/control-database.js +701 -359
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-formation-recorder.d.ts +46 -43
  15. package/dist/control-formation-recorder.js +77 -78
  16. package/dist/control-formation-recorder.js.map +1 -1
  17. package/dist/control-protocol-guard.d.ts +6 -0
  18. package/dist/control-protocol-guard.js +14 -2
  19. package/dist/control-protocol-guard.js.map +1 -1
  20. package/dist/control-schema-version.d.ts +48 -31
  21. package/dist/control-schema-version.js +67 -45
  22. package/dist/control-schema-version.js.map +1 -1
  23. package/dist/control-schema.d.ts +8 -8
  24. package/dist/control-schema.js +16 -16
  25. package/dist/control-schema.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +42 -7
  27. package/dist/control-write-retry.js +87 -19
  28. package/dist/control-write-retry.js.map +1 -1
  29. package/dist/enrolled-machine-store.d.ts +3 -3
  30. package/dist/enrolled-machine-store.js +3 -3
  31. package/dist/formation-token.d.ts +48 -0
  32. package/dist/formation-token.js +26 -0
  33. package/dist/formation-token.js.map +1 -0
  34. package/dist/index.d.ts +14 -13
  35. package/dist/index.js +16 -13
  36. package/dist/index.js.map +1 -1
  37. package/dist/joined-strand-store.d.ts +26 -9
  38. package/dist/joined-strand-store.js +63 -24
  39. package/dist/joined-strand-store.js.map +1 -1
  40. package/dist/link-budget.d.ts +9 -7
  41. package/dist/link-budget.js +9 -7
  42. package/dist/link-budget.js.map +1 -1
  43. package/dist/member-redemption.d.ts +15 -9
  44. package/dist/member-redemption.js +13 -8
  45. package/dist/member-redemption.js.map +1 -1
  46. package/dist/membership-view.d.ts +91 -0
  47. package/dist/membership-view.js +110 -0
  48. package/dist/membership-view.js.map +1 -0
  49. package/dist/owner-grant-protocol.d.ts +11 -7
  50. package/dist/owner-grant-protocol.js +31 -17
  51. package/dist/owner-grant-protocol.js.map +1 -1
  52. package/dist/peer-addr-book.d.ts +10 -1
  53. package/dist/peer-addr-book.js +4 -3
  54. package/dist/peer-addr-book.js.map +1 -1
  55. package/dist/peer-join-backfill.d.ts +89 -20
  56. package/dist/peer-join-backfill.js +265 -108
  57. package/dist/peer-join-backfill.js.map +1 -1
  58. package/dist/pending-join-runner.d.ts +48 -18
  59. package/dist/pending-join-runner.js +74 -56
  60. package/dist/pending-join-runner.js.map +1 -1
  61. package/dist/seed-bootstrap.d.ts +47 -40
  62. package/dist/seed-bootstrap.js +66 -61
  63. package/dist/seed-bootstrap.js.map +1 -1
  64. package/dist/strand-database.d.ts +23 -2
  65. package/dist/strand-database.js +26 -6
  66. package/dist/strand-database.js.map +1 -1
  67. package/dist/strand-first-sync-gate.d.ts +176 -89
  68. package/dist/strand-first-sync-gate.js +288 -122
  69. package/dist/strand-first-sync-gate.js.map +1 -1
  70. package/dist/strand-formation-deadlines.d.ts +9 -9
  71. package/dist/strand-formation-deadlines.js +9 -9
  72. package/dist/strand-formation-manager.d.ts +23 -17
  73. package/dist/strand-formation-manager.js +46 -44
  74. package/dist/strand-formation-manager.js.map +1 -1
  75. package/dist/strand-formation-protocol.d.ts +5 -8
  76. package/dist/strand-formation-protocol.js +101 -16
  77. package/dist/strand-formation-protocol.js.map +1 -1
  78. package/dist/strand-instance-manager.d.ts +26 -17
  79. package/dist/strand-instance-manager.js +119 -50
  80. package/dist/strand-instance-manager.js.map +1 -1
  81. package/dist/strand-membership-reconciler.d.ts +16 -13
  82. package/dist/strand-membership-reconciler.js +30 -22
  83. package/dist/strand-membership-reconciler.js.map +1 -1
  84. package/dist/strand-membership-writer.d.ts +40 -28
  85. package/dist/strand-membership-writer.js +212 -153
  86. package/dist/strand-membership-writer.js.map +1 -1
  87. package/dist/strand-move.js +5 -2
  88. package/dist/strand-move.js.map +1 -1
  89. package/dist/strand-revocation-enforcer.d.ts +134 -60
  90. package/dist/strand-revocation-enforcer.js +144 -71
  91. package/dist/strand-revocation-enforcer.js.map +1 -1
  92. package/dist/strand-solicitation.d.ts +50 -39
  93. package/dist/strand-solicitation.js +10 -3
  94. package/dist/strand-solicitation.js.map +1 -1
  95. package/dist/strand-watcher.d.ts +80 -13
  96. package/dist/strand-watcher.js +152 -48
  97. package/dist/strand-watcher.js.map +1 -1
  98. package/dist/table-change-watch.d.ts +63 -0
  99. package/dist/table-change-watch.js +209 -0
  100. package/dist/table-change-watch.js.map +1 -0
  101. package/dist/timeout-scheduler.d.ts +4 -3
  102. package/dist/timeout-scheduler.js +4 -3
  103. package/dist/timeout-scheduler.js.map +1 -1
  104. package/dist/types.d.ts +90 -49
  105. package/dist/types.js +4 -4
  106. package/dist/types.js.map +1 -1
  107. package/package.json +8 -8
  108. package/src/cadre-invite-protocol.ts +119 -31
  109. package/src/cadre-node.ts +1282 -584
  110. package/src/control-cohort.ts +86 -9
  111. package/src/control-database.ts +848 -403
  112. package/src/control-formation-recorder.ts +359 -353
  113. package/src/control-protocol-guard.ts +16 -2
  114. package/src/control-schema-version.ts +101 -51
  115. package/src/control-schema.ts +16 -16
  116. package/src/control-write-retry.ts +91 -19
  117. package/src/delegate-admission.ts +351 -351
  118. package/src/enrolled-machine-store.ts +3 -3
  119. package/src/formation-token.ts +63 -0
  120. package/src/index.ts +30 -17
  121. package/src/joined-strand-store.ts +69 -24
  122. package/src/link-budget.ts +9 -7
  123. package/src/member-redemption.ts +20 -13
  124. package/src/membership-view.ts +144 -0
  125. package/src/owner-grant-protocol.ts +33 -18
  126. package/src/peer-addr-book.ts +4 -3
  127. package/src/peer-join-backfill.ts +971 -761
  128. package/src/pending-join-runner.ts +76 -53
  129. package/src/seed-bootstrap.ts +72 -67
  130. package/src/strand-database.ts +352 -322
  131. package/src/strand-first-sync-gate.ts +334 -134
  132. package/src/strand-formation-deadlines.ts +227 -227
  133. package/src/strand-formation-manager.ts +731 -725
  134. package/src/strand-formation-protocol.ts +122 -24
  135. package/src/strand-instance-manager.ts +2092 -2026
  136. package/src/strand-membership-reconciler.ts +33 -25
  137. package/src/strand-membership-writer.ts +2093 -2019
  138. package/src/strand-move.ts +5 -2
  139. package/src/strand-revocation-enforcer.ts +740 -654
  140. package/src/strand-solicitation.ts +61 -34
  141. package/src/strand-watcher.ts +175 -56
  142. package/src/table-change-watch.ts +285 -0
  143. package/src/timeout-scheduler.ts +29 -28
  144. package/src/types.ts +89 -51
@@ -135,6 +135,21 @@ export function controlNetworkName(partyId: string): string {
135
135
  return `control-${partyId}`;
136
136
  }
137
137
 
138
+ /** The control database's two storage protocols, `repo` and `cluster`. */
139
+ function controlDatabaseProtocols(partyId: string): readonly string[] {
140
+ const prefix = `/optimystic/${controlNetworkName(partyId)}`;
141
+ return [`${prefix}/repo/1.0.0`, `${prefix}/cluster/1.0.0`];
142
+ }
143
+
144
+ /**
145
+ * Whether a peer whose protocol list is `protocols` serves the control database. Optimystic
146
+ * counts a peer into a control block's cohort by the same test (`servesThisNetwork` in db-p2p's
147
+ * `libp2p-key-network.ts`), before its membership check.
148
+ */
149
+ export function servesControlDatabase(partyId: string, protocols: readonly string[]): boolean {
150
+ return controlDatabaseProtocols(partyId).some((protocol) => protocols.includes(protocol));
151
+ }
152
+
138
153
  /**
139
154
  * The class of every protocol a control node serves, by exact protocol id: the single
140
155
  * declaration the guard enforces. See the module doc for what each class admits and why each
@@ -152,8 +167,7 @@ export function controlProtocolClasses(partyId: string): ReadonlyMap<string, Con
152
167
  [WAKE_PROTOCOL, 'members'],
153
168
  [STRAND_ADDR_PROTOCOL, 'members'],
154
169
 
155
- [`${prefix}/repo/1.0.0`, 'members-snapshot'],
156
- [`${prefix}/cluster/1.0.0`, 'members-snapshot'],
170
+ ...controlDatabaseProtocols(partyId).map((protocol): [string, ControlProtocolClass] => [protocol, 'members-snapshot']),
157
171
  [`${prefix}/db-p2p/sync/1.0.0`, 'members-snapshot'],
158
172
  [`${prefix}/db-p2p/block-transfer/1.0.0`, 'members-snapshot'],
159
173
  [`${fret}/neighbors`, 'members-snapshot'],
@@ -3,40 +3,53 @@
3
3
  * store after declaring the control schema and before applying it.
4
4
  *
5
5
  * `apply schema CadreControl` runs whatever DDL makes the hydrated catalog equal the declared
6
- * schema. Against a store written by another release that DDL is either refused by the
7
- * optimystic module part-way through (`alter table … drop constraint`, `add column`,
8
- * `drop column`), which fails the start with a message naming whichever step came first, or
9
- * runs and destroys rows (`drop table`). This module decides from the pending DDL and the
10
- * stored schema version, before any of it runs, and throws one typed error instead.
6
+ * schema, and that apply is how a store written under an older control schema version is
7
+ * migrated in place: between consecutive versions the difference is limited to statements the
8
+ * optimystic module applies to a table's catalog record without rewriting a row, so running the
9
+ * diff from a store at version K to this build's declaration runs each version's changes from
10
+ * K+1 up to the build's, in order. This module decides, from the stored version and the pending
11
+ * DDL and before any of it runs, whether the apply may go ahead, and throws one typed error
12
+ * naming the reason when it may not.
11
13
  *
12
14
  * The same change classifier ({@link classifyControlSchemaChange}) is what
13
15
  * `control-schema-versions.spec.ts` runs over each pair of consecutive control schema versions,
14
16
  * so a schema edit that no store could be migrated across fails when it is made. Where the
15
17
  * version lives, when it changes and what a new version may change: docs/architecture.md →
16
- * "Control schema changes and incompatible stores".
18
+ * "Control schema versions and migrations".
17
19
  *
18
- * NOTE: the stored tag is the version the store's `OwnerKey` table was CREATED under. The
19
- * optimystic plugin does not persist `alter table … set tags` (Quereus applies a tag change to
20
- * its catalog without calling the module, and the plugin follows it in memory only), so the
21
- * `set tags` an additive upgrade runs raises the version for that process alone. The check stays
22
- * safe regardless, because it lets through only DDL that adds, so an older build's diff against
23
- * an upgraded store lists the drop and is refused on that ground. Migrating a store in place
24
- * (control-store-migrates-on-start) needs the version a store was upgraded to, so it needs that
25
- * persisted upstream (optimystic-catalog-only-alter-table).
20
+ * The stored tag is the version of the last start that applied the schema to the store: the
21
+ * apply's `alter table OwnerKey set tags (…)` persists since Optimystic 1.16.0, so after a
22
+ * migrating start the store records this build's version. A store additively upgraded by a build
23
+ * from before that still reads the version it was created under, which is harmless: the diff is
24
+ * computed from the catalog, not from the number, and the number only has to tell a store that
25
+ * a newer release migrated from one this build can migrate.
26
26
  */
27
27
 
28
28
  import { Parser, type Database, type SchemaManager, type SqlValue, type TableSchema } from '@quereus/quereus';
29
29
  import type { AlterTableStmt, ColumnDef, Statement } from '@quereus/quereus/parser';
30
- import { CONTROL_SCHEMA_NAME, CONTROL_SCHEMA_VERSION_TAG } from './control-schema.js';
30
+ import { CONTROL_SCHEMA_BASELINE_VERSION, CONTROL_SCHEMA_NAME, CONTROL_SCHEMA_VERSION_TAG } from './control-schema.js';
31
31
  import { controlStorageScope } from './storage-scope.js';
32
32
 
33
33
  /** The control table whose tags carry {@link CONTROL_SCHEMA_VERSION_TAG}. */
34
34
  const VERSION_TABLE = 'OwnerKey';
35
35
 
36
- /** A warm control store this build cannot apply its control schema to without destroying data. */
36
+ /**
37
+ * Why a warm control store is refused, in the order the check decides them:
38
+ * - `newer-release`: the store records a version above this build's, so a newer release has
39
+ * migrated it (this machine before a downgrade, or another machine sharing its catalog
40
+ * lineage); this machine is the one to update.
41
+ * - `before-migrations`: the store records a version below {@link CONTROL_SCHEMA_BASELINE_VERSION}
42
+ * (written by 1.16.0 or earlier); it cannot be migrated and must be recreated.
43
+ * - `not-migratable`: a pending statement would rewrite or discard rows. Reachable only from a
44
+ * development or hand-made store, since the edit-time check refuses such a version.
45
+ */
46
+ export type ControlStoreIncompatibility = 'newer-release' | 'before-migrations' | 'not-migratable';
47
+
48
+ /** A warm control store this build cannot apply its control schema to. */
37
49
  export class ControlStoreIncompatibleError extends Error {
50
+ readonly reason: ControlStoreIncompatibility;
38
51
  readonly partyId: string;
39
- /** The version the store records: the one it was created under; 0 = before versions were recorded. */
52
+ /** The version the store records: the one its last migrating start declared; 0 = before versions were recorded. */
40
53
  readonly storeVersion: number;
41
54
  /** The version this build declares. */
42
55
  readonly buildVersion: number;
@@ -45,16 +58,17 @@ export class ControlStoreIncompatibleError extends Error {
45
58
  /** `controlStorageScope(partyId)`: the storage the embedder deletes to recreate (docs/architecture.md). */
46
59
  readonly storageScope: string;
47
60
 
48
- constructor(partyId: string, storeVersion: number, buildVersion: number, pendingDdl: readonly string[]) {
61
+ constructor(
62
+ reason: ControlStoreIncompatibility,
63
+ partyId: string,
64
+ storeVersion: number,
65
+ buildVersion: number,
66
+ pendingDdl: readonly string[]
67
+ ) {
49
68
  const storageScope = controlStorageScope(partyId);
50
- super(
51
- `The control store for party ${JSON.stringify(partyId)} ${provenance(storeVersion, buildVersion)} ` +
52
- `(it records control schema version ${storeVersion}; this build declares ${buildVersion}), and this build ` +
53
- 'cannot apply its control schema to it without losing data. Run a release whose control schema ' +
54
- `matches the store, or recreate the control store (storage scope ${storageScope}) as ` +
55
- 'docs/architecture.md describes.'
56
- );
69
+ super(refusalMessage(reason, partyId, storeVersion, buildVersion, storageScope));
57
70
  this.name = 'ControlStoreIncompatibleError';
71
+ this.reason = reason;
58
72
  this.partyId = partyId;
59
73
  this.storeVersion = storeVersion;
60
74
  this.buildVersion = buildVersion;
@@ -64,33 +78,55 @@ export class ControlStoreIncompatibleError extends Error {
64
78
  }
65
79
 
66
80
  /**
67
- * Which release created the store, relative to this build, for the error message. Created, not
68
- * last written: the stored version does not move on an additive upgrade (module NOTE).
81
+ * The message for each {@link ControlStoreIncompatibility}. Only the two recreate cases advise
82
+ * recreating: a store a newer release migrated holds rows written since, and recreating it
83
+ * would throw them away.
69
84
  */
70
- function provenance(storeVersion: number, buildVersion: number): string {
71
- if (storeVersion > buildVersion) {
72
- return 'was created by a newer release';
85
+ function refusalMessage(
86
+ reason: ControlStoreIncompatibility,
87
+ partyId: string,
88
+ storeVersion: number,
89
+ buildVersion: number,
90
+ storageScope: string
91
+ ): string {
92
+ const store = `The control store for party ${JSON.stringify(partyId)}`;
93
+ const versions = `it records control schema version ${storeVersion}; this build declares ${buildVersion}`;
94
+ const recreate = 'Run a release whose control schema matches the store, or recreate the control store ' +
95
+ `(storage scope ${storageScope}) as docs/architecture.md describes.`;
96
+ switch (reason) {
97
+ case 'newer-release':
98
+ return `${store} was migrated by a newer release (${versions}). Update this app or node to that ` +
99
+ 'release or a later one; the store and its rows are intact.';
100
+ case 'before-migrations':
101
+ return `${store} was written before control schema migrations (${versions}; migrations start at ` +
102
+ `version ${CONTROL_SCHEMA_BASELINE_VERSION}, and docs/architecture.md says why earlier stores are not ` +
103
+ `migrated), so this build cannot upgrade it in place. ${recreate}`;
104
+ case 'not-migratable':
105
+ return `${store} cannot be migrated to this build's control schema (${versions}): applying it would ` +
106
+ `rewrite or discard stored rows. ${recreate}`;
73
107
  }
74
- return storeVersion < buildVersion
75
- ? 'was created by an older release'
76
- : 'was created under the same control schema version';
77
108
  }
78
109
 
79
110
  /**
80
- * Throw {@link ControlStoreIncompatibleError} when applying the declared control schema to the
81
- * hydrated store would destroy data; return having run nothing otherwise. Needs
82
- * `declare schema CadreControl` to have run, and runs only `diff schema`, which executes no DDL.
111
+ * Throw {@link ControlStoreIncompatibleError} when the declared control schema must not be applied
112
+ * to the hydrated store; return having run nothing otherwise, and the apply then migrates the
113
+ * store. Needs `declare schema CadreControl` to have run, and runs only `diff schema`, which
114
+ * executes no DDL.
83
115
  *
84
116
  * A cold store (the hydrated catalog lists no control table) passes without a diff: the apply
85
- * creates everything. A warm store is refused when either
86
- * - any pending statement is not `additive` ({@link classifyControlSchemaChange}): every other
87
- * change to an existing control table is refused by the optimystic module or discards rows; or
88
- * - its version is above the build's: a newer release created it, so even a diff that only adds
89
- * (the newer release no longer declares something this build does) would put back what that
90
- * release removed, and the newer release would then refuse its own store.
117
+ * creates everything. A warm store is refused for the first of these that holds
118
+ * ({@link ControlStoreIncompatibility}):
119
+ * - its version is above the build's: even a diff that only adds (the newer release no longer
120
+ * declares something this build does) would put back what that release removed, and the newer
121
+ * release would then refuse its own store;
122
+ * - its version is below the baseline, whatever the diff: a store written by 1.16.0 or earlier,
123
+ * which the baseline paragraph of docs/architecture.md explains cannot be migrated;
124
+ * - any pending statement is `unmigratable` ({@link classifyControlSchemaChange}).
91
125
  *
92
- * So a store from an older release still warm-starts when the only change since is a new table
93
- * or index.
126
+ * Every `additive` and `catalog-only` change passes, so a store from an older migratable
127
+ * release warm-starts and the apply brings it to this build's version. A store that is both
128
+ * older than the baseline and not migratable reports `before-migrations`, whose message gives the
129
+ * recreate advice and points at the explanation.
94
130
  */
95
131
  export async function refuseIncompatibleControlStore(db: Database, partyId: string): Promise<void> {
96
132
  if (!isWarmControlStore(db)) {
@@ -99,11 +135,25 @@ export async function refuseIncompatibleControlStore(db: Database, partyId: stri
99
135
  const storeVersion = schemaVersionOf(db.schemaManager.getTable(CONTROL_SCHEMA_NAME, VERSION_TABLE)?.tags);
100
136
  const buildVersion = declaredSchemaVersion(db);
101
137
  const pending = await pendingControlSchemaChanges(db);
102
- // `catalog-only` changes stay refused until the optimystic module applies them
103
- // (optimystic-catalog-only-alter-table); control-store-migrates-on-start widens this then.
104
- if (storeVersion > buildVersion || pending.some((change) => change.kind !== 'additive')) {
105
- throw new ControlStoreIncompatibleError(partyId, storeVersion, buildVersion, pending.map((change) => change.ddl));
138
+ const reason = incompatibilityOf(storeVersion, buildVersion, pending);
139
+ if (reason !== undefined) {
140
+ throw new ControlStoreIncompatibleError(reason, partyId, storeVersion, buildVersion, pending.map((change) => change.ddl));
141
+ }
142
+ }
143
+
144
+ /** The first {@link ControlStoreIncompatibility} that holds for a warm store, or undefined when the apply may migrate it. */
145
+ function incompatibilityOf(
146
+ storeVersion: number,
147
+ buildVersion: number,
148
+ pending: readonly PendingControlSchemaChange[]
149
+ ): ControlStoreIncompatibility | undefined {
150
+ if (storeVersion > buildVersion) {
151
+ return 'newer-release';
152
+ }
153
+ if (storeVersion < CONTROL_SCHEMA_BASELINE_VERSION) {
154
+ return 'before-migrations';
106
155
  }
156
+ return pending.some((change) => change.kind === 'unmigratable') ? 'not-migratable' : undefined;
107
157
  }
108
158
 
109
159
  /** Whether the hydrated catalog lists at least one control table, i.e. the store was written before. */
@@ -139,11 +189,11 @@ function schemaVersionOf(tags: Readonly<Record<string, SqlValue>> | undefined):
139
189
 
140
190
  /**
141
191
  * How applying one `diff schema` statement to a warm store treats its rows, from most to least
142
- * migratable (docs/architecture.md → "Control schema changes and incompatible stores"):
192
+ * migratable (docs/architecture.md → "Control schema versions and migrations"):
143
193
  * - `additive`: a new table, a new index, or a table tag change, which the optimystic module
144
- * applies today without touching a row;
194
+ * applies without touching a row;
145
195
  * - `catalog-only`: a change the optimystic module applies to the table's catalog record without
146
- * rewriting a row, once optimystic-catalog-only-alter-table ships;
196
+ * rewriting a row (since Optimystic 1.16.0);
147
197
  * - `unmigratable`: everything else, which rewrites or discards rows.
148
198
  */
149
199
  export type ControlSchemaChangeKind = 'additive' | 'catalog-only' | 'unmigratable';
@@ -3,20 +3,20 @@ import { Parser } from '@quereus/quereus';
3
3
  /**
4
4
  * The version of the declared control schema, carried in the schema text as the
5
5
  * {@link CONTROL_SCHEMA_VERSION_TAG} tag on the `OwnerKey` table. A warm start compares
6
- * it with the version the store records before applying anything
7
- * (`control-schema-version.ts`, which also says why that is the version the store was
8
- * created under).
6
+ * it with the version the store records before applying anything, and the apply then
7
+ * migrates the store to this version and records it (`control-schema-version.ts`).
9
8
  *
10
9
  * When it changes, what a new version may change, and the fixture each version keeps:
11
- * docs/architecture.md → "Control schema changes and incompatible stores", enforced by
10
+ * docs/architecture.md → "Control schema versions and migrations", enforced by
12
11
  * `control-schema-versions.spec.ts`.
13
12
  */
14
13
  export const CONTROL_SCHEMA_VERSION = 3;
15
14
 
16
15
  /**
17
16
  * The first control schema version whose fixture is kept, and the oldest a store can be
18
- * migrated from. Stores that record no version (written by 1.16.0 and earlier) are not
19
- * migrated; docs/architecture.md says why.
17
+ * migrated from. A store that records a version below it (written by 1.16.0 and earlier,
18
+ * which recorded none) is refused with reason `before-migrations`; docs/architecture.md
19
+ * says why it is not migrated.
20
20
  */
21
21
  export const CONTROL_SCHEMA_BASELINE_VERSION = 3;
22
22
 
@@ -667,7 +667,7 @@ declare schema CadreControl {
667
667
  ) with context (Now datetime, ValidationKey text null, ValidationSignature text null);
668
668
 
669
669
  -- Serves the per-token reads (the cap count above; cadre-core's countFormationUsage,
670
- -- isTokenUsed and hasOutstandingFormationInvite) on an append-only table. Its cross-machine
670
+ -- readToken and hasOutstandingFormationInvite) on an append-only table. Its cross-machine
671
671
  -- convergence is guarded by the integration scenario strand-formation-concurrent-redemption.
672
672
  index FormationUsageByToken on FormationUsage (Token);
673
673
 
@@ -903,16 +903,15 @@ declare schema CadreControl {
903
903
 
904
904
  -- Append-only retirement record for the stamps of removed rows of every guarded table, with
905
905
  -- the removed row's key. Every append is owner-signed, so the growth surface is the owner
906
- -- keys. One singleton marker row, ('Revocation', 'ledger', 'opened'), retires nothing: an
907
- -- owner's connected reconcile pass files it (ControlDatabase.openRevocationLedger) so this
908
- -- table is never a never-written block, which the storage layer would consult the cohort
909
- -- for on every guarded insert and membership lookup. Readers filter on their own guarded
906
+ -- keys. A party founded before genesis created every control collection may hold one marker
907
+ -- row, ('Revocation', 'ledger', 'opened'), which retires nothing: an owner filed it so this
908
+ -- table had a collection. Nothing files it now. Readers filter on their own guarded
910
909
  -- TableName, and queryRevocations skips it. A 'CadreInvite' or 'OwnerGrant' row withdraws
911
910
  -- an invitation or a grant whose row stays (the table comments).
912
911
  table Revocation (
913
912
  TableName text, -- 'OwnerKey' | 'ValidationKey' | 'Strand' | 'StrandPartyKey' | 'JoinedStrand' |
914
913
  -- 'JoinRequest' | 'CadrePeer' | 'DeviceToken' | 'CadreInvite' | 'OwnerGrant', or
915
- -- 'Revocation' for the marker
914
+ -- 'Revocation' for an older party's marker
916
915
  RowKey text, -- primary key of the removed row
917
916
  StampId text, -- the retired stamp
918
917
  ReissuedAt integer default 0, -- bumped by an owner-signed re-issue so a tombstone committed while the
@@ -942,7 +941,9 @@ declare schema CadreControl {
942
941
  -- never existed is harmless: stamps carry 128 random bits (generateStampId). For
943
942
  -- 'CadreInvite' and 'OwnerGrant' there is no row-gone test: a tombstone there is a
944
943
  -- withdrawal, and the row stays (the table comments). The last branch admits the marker
945
- -- row and nothing else under 'Revocation'.
944
+ -- row an older party may hold, and nothing else under 'Revocation'; nothing files it now.
945
+ -- NOTE: drop this branch, REVOCATION_LEDGER_MARKER and the readers' skip together with the
946
+ -- next CONTROL_SCHEMA_VERSION change that already needs a migration.
946
947
  constraint RowIsGone check on insert (
947
948
  (new.TableName = 'OwnerKey' and not exists (select 1 from OwnerKey K where K.StampId = new.StampId))
948
949
  or (new.TableName = 'CadrePeer' and not exists (select 1 from CadrePeer P where P.StampId = new.StampId))
@@ -961,9 +962,8 @@ declare schema CadreControl {
961
962
  -- writer could evict a peer party-wide and block its re-admission. RowKey is not checked
962
963
  -- against the retired row here (that would reject a tombstone re-issued later, or filed on
963
964
  -- a node that never held the row); each guarded table's RevocationRecorded binds it
964
- -- instead. Reads live OwnerKey like the sibling tables. The marker is signed under this
965
- -- rule too, over ('Revocation', 'ledger', 'opened'). The stored signer pair must be the
966
- -- pair verified here.
965
+ -- instead. Reads live OwnerKey like the sibling tables. An older party's marker was signed
966
+ -- under this rule too. The stored signer pair must be the pair verified here.
967
967
  constraint Authorized check on insert (
968
968
  exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and not exists (select 1 from Revocation R where R.TableName = 'OwnerKey' and R.StampId = A.StampId) and verify(digest('CadreControl.Revocation', 'remove', party_id(), new.TableName, new.RowKey, new.StampId), context.Signature, A.Key, 'ed25519'))
969
969
  and coalesce(new.SignerKey, '') = context.OwnerKey
@@ -1,5 +1,5 @@
1
1
  import { CoordinatorPartialCommitError, SyncRetryExhaustedError, TornActionError } from '@optimystic/db-core';
2
- import { PartialCommitError } from '@optimystic/quereus-plugin-optimystic';
2
+ import { ConcurrentModificationError, PartialCommitError } from '@optimystic/quereus-plugin-optimystic';
3
3
  import { causeChain, chainMessages, retryControlOperation } from './control-retry.js';
4
4
  import type { ControlRetryOptions } from './control-retry.js';
5
5
 
@@ -32,12 +32,13 @@ import type { ControlRetryOptions } from './control-retry.js';
32
32
  * before any matcher runs. The one half-landed write that IS re-presented is a torn write the
33
33
  * library marks final — not saved and unable to land ({@link isFinalTornWrite}).
34
34
  *
35
- * TWO policies ship from here, and the split is deliberate: the default
35
+ * THREE policies ship from here, and the split is deliberate: the default
36
36
  * ({@link isRetriableControlWriteFailure}, {@link CONTROL_WRITE_ATTEMPTS}) sits under every
37
- * control write, and {@link SCHEMA_INIT_RETRY_POLICY} is opted into by
38
- * `ControlDatabase.applyControlSchema` alone. The widened one absorbs one extra failure class whose
39
- * re-run safety holds for schema DDL and NOT for writes in general; anything added to the default
40
- * list must be safe for all ~19 callers.
37
+ * control write; {@link SCHEMA_INIT_RETRY_POLICY} is opted into by
38
+ * `ControlDatabase.applyControlSchema` alone; {@link LOST_UPDATE_RETRY_POLICY} is opted into by
39
+ * the `CadrePeer` writers whose bodies re-read the row and decide again. Each widened one absorbs
40
+ * one extra failure class whose re-run safety holds for its opted-in bodies and NOT for writes in
41
+ * general; anything added to the default list must be safe for all ~19 callers.
41
42
  *
42
43
  * Lives outside `control-database.ts` so a spec can drive the classifier against real engine
43
44
  * errors without importing the whole database class. The loop itself lives in
@@ -343,6 +344,34 @@ function isUnansweredSuperMajorityShortfall(message: string): boolean {
343
344
  return SUPER_MAJORITY_SHORTFALL_UNANSWERED.test(message);
344
345
  }
345
346
 
347
+ /**
348
+ * A LOST UPDATE: the optimystic bridge refusing a commit because another writer changed or
349
+ * removed a row this statement read (`TransactionBridge.mapCommitRefusal` over a
350
+ * `TreeEntryChangedError`, `quereus-plugin-optimystic/src/optimystic-adapter/txn-bridge.ts`).
351
+ * Claimed by TYPE for {@link LOST_UPDATE_RETRY_POLICY} only.
352
+ *
353
+ * Why it is a proven non-commit: the refusal is raised inside the action's atomic handler, so
354
+ * none of that action's entries land. A refusal after a sibling tree or collection of the same
355
+ * transaction already synced is not delivered bare — the per-tree sweep wraps it in
356
+ * `PartialCommitError`, the coordinator path in `CoordinatorPartialCommitError` — and
357
+ * {@link reportsPossiblyStoredWrite} vetoes both by type before this predicate runs. A bare chain
358
+ * carrying this class therefore means nothing of the write is durable.
359
+ *
360
+ * Why it is not in the default list: the rejection is of the write's READ SET, not of its
361
+ * signature, so re-presenting the same statement is only right when the body re-reads the row and
362
+ * decides again over what it finds. That is a property of each opted-in body (see the policy's
363
+ * `NOTE:`), not of control writes in general.
364
+ *
365
+ * Not matched by text: `ConcurrentModificationError` is exported from both the plugin's root and
366
+ * `/plugin` entries over one shared chunk, as `PartialCommitError` is, so the one `instanceof`
367
+ * covers both (the `NOTE:` on {@link reportsPossiblyStoredWrite} applies to it too). A second
368
+ * loaded copy of the plugin would fail the `instanceof` and the lost update would then simply not
369
+ * be retried — the safe side.
370
+ */
371
+ function isLostUpdate(link: Error): boolean {
372
+ return link instanceof ConcurrentModificationError;
373
+ }
374
+
346
375
  /**
347
376
  * Optimystic refusing to let a node elect ITSELF coordinator because its last connection dropped
348
377
  * less than the guard's grace period ago (`Libp2pKeyPeerNetwork.shouldAllowSelfCoordination` →
@@ -411,9 +440,9 @@ const RETRIABLE_CONTROL_WRITE_MATCHERS: readonly ((message: string) => boolean)[
411
440
  * does not complete — an undo statement threw, the post-unwind catalog did not match the pre-apply
412
441
  * fingerprint, that catalog could not be re-collected to check, or a step the differ marks
413
442
  * irreversible because it discards data (dropping a table or a column, narrowing a column's type —
414
- * none of which reaches a `CadreControl` apply: a cold store's diff is all creates, and
415
- * `refuseIncompatibleControlStore` in `control-schema-version.ts` refuses a warm store whose diff
416
- * holds anything but creates and tag changes before the apply runs) poisoned the journal before it
443
+ * of which only dropping a column reaches a `CadreControl` apply, as the catalog-only migration
444
+ * step `refuseIncompatibleControlStore` in `control-schema-version.ts` lets through; it discards
445
+ * nothing there, since rows keep the field) poisoned the journal before it
417
446
  * ran, leaving nothing unwound — Quereus keeps the failing step's own message, APPENDS the reason the
418
447
  * schema could not be restored, and carries the original as `cause`. So a transient cause
419
448
  * underneath still matches here, and neither veto ({@link reportsPossiblyStoredWrite},
@@ -469,16 +498,30 @@ export function isRetriableSchemaInitFailure(error: unknown): boolean {
469
498
  }
470
499
 
471
500
  /**
472
- * Shared body of both classifiers: walk the `cause` chain, let {@link reportsPossiblyStoredWrite}
473
- * and then {@link reportsIndeterminateCommit} veto it, then claim a final torn write
474
- * ({@link isFinalTornWrite}), then ask `matchers`. The vetoes run BEFORE any matcher for every
475
- * policy — a chain reporting a possibly-stored write or a commit-phase batch is never retried,
476
- * however transient some other level looks, and a final torn write carried by a partial commit is
477
- * still declined because a sibling collection landed.
501
+ * {@link isRetriableControlWriteFailure} widened by exactly one class — a lost update
502
+ * ({@link isLostUpdate}), claimed by type — for the `CadrePeer` writers that re-read the row inside
503
+ * their locked body. Opt-in per call site, via {@link LOST_UPDATE_RETRY_POLICY}; both vetoes still
504
+ * run first, so a lost update carried inside a partial commit, a spent sync budget or a non-final
505
+ * torn write stays declined.
506
+ */
507
+ export function isRetriableLostUpdateFailure(error: unknown): boolean {
508
+ return matchesRetriableFailure(error, RETRIABLE_CONTROL_WRITE_MATCHERS, isLostUpdate);
509
+ }
510
+
511
+ /**
512
+ * Shared body of the three classifiers: walk the `cause` chain, let
513
+ * {@link reportsPossiblyStoredWrite} and then {@link reportsIndeterminateCommit} veto it, then claim
514
+ * a final torn write ({@link isFinalTornWrite}) or any link `claimsLink` names, then ask `matchers`.
515
+ * The vetoes run BEFORE any claim for every policy — a chain reporting a possibly-stored write or a
516
+ * commit-phase batch is never retried, however transient some other level looks, and a final torn
517
+ * write or a lost update carried by a partial commit is still declined because a sibling collection
518
+ * landed. `claimsLink` is the one typed claim a policy may add; it sits here beside the torn-write
519
+ * claim so the vetoes stay in one place above both.
478
520
  */
479
521
  function matchesRetriableFailure(
480
522
  error: unknown,
481
- matchers: readonly ((message: string) => boolean)[]
523
+ matchers: readonly ((message: string) => boolean)[],
524
+ claimsLink?: (link: Error) => boolean
482
525
  ): boolean {
483
526
  if (!(error instanceof Error)) {
484
527
  return false;
@@ -491,7 +534,7 @@ function matchesRetriableFailure(
491
534
  if (reportsIndeterminateCommit(messages)) {
492
535
  return false;
493
536
  }
494
- if (links.some(isFinalTornWrite)) {
537
+ if (links.some(link => isFinalTornWrite(link) || claimsLink?.(link) === true)) {
495
538
  return true;
496
539
  }
497
540
  return messages.some(message => matchers.some(matches => matches(message)));
@@ -510,7 +553,7 @@ function matchesRetriableFailure(
510
553
  export type ControlWriteRetryOptions = ControlRetryOptions;
511
554
 
512
555
  /**
513
- * The one non-default retry policy: `ControlDatabase.applyControlSchema`'s distributed DDL, which
556
+ * The schema-init policy: `ControlDatabase.applyControlSchema`'s distributed DDL, which
514
557
  * additionally absorbs optimystic's self-coordination grace refusal and gets more attempts over a
515
558
  * longer backoff to do it (see {@link SCHEMA_INIT_ATTEMPTS} and
516
559
  * {@link SCHEMA_INIT_RETRY_DELAYS_MS}). Still bounded by the shared
@@ -521,7 +564,8 @@ export type ControlWriteRetryOptions = ControlRetryOptions;
521
564
  * and verified against the pre-apply catalog (full argument at the `applyControlSchema` call site
522
565
  * in `control-database.ts`). A second opt-in must re-derive that argument for its own write body
523
566
  * first; if this policy ever grows a third consumer, rename it for what the callers share rather
524
- * than widening it by default.
567
+ * than widening it by default. {@link LOST_UPDATE_RETRY_POLICY} is a separate policy for a
568
+ * separate class, not a second consumer of this one.
525
569
  */
526
570
  export const SCHEMA_INIT_RETRY_POLICY: Readonly<ControlWriteRetryOptions> = {
527
571
  attempts: SCHEMA_INIT_ATTEMPTS,
@@ -529,6 +573,34 @@ export const SCHEMA_INIT_RETRY_POLICY: Readonly<ControlWriteRetryOptions> = {
529
573
  isRetriable: isRetriableSchemaInitFailure,
530
574
  };
531
575
 
576
+ /**
577
+ * The lost-update policy: the default classifier plus a lost update claimed by type
578
+ * ({@link isRetriableLostUpdateFailure}), over the default attempts, backoff and budget. A re-run
579
+ * here is a RE-DECISION, not a re-presentation: the body reads the row again under the lock and
580
+ * signs over what it finds, so the write that lost to a rival is replaced by one made over the
581
+ * rival's result. The budget bounds it as it bounds every write — a losing attempt whose read
582
+ * phase waited out one per-peer read deadline (~7.5 s measured in the degraded-cohort scenario's
583
+ * trio boot) gets exactly one retry, which is the ceiling to expect; one that waited out two
584
+ * (14.7 s, measured once in 18 of that scenario's boots, on the member that cannot reach one
585
+ * sibling) gets none and is abandoned on the budget, and the lost publish is then the reconcile
586
+ * pass's or the heartbeat's to redo, as every abandoned self-publish is.
587
+ *
588
+ * NOTE: the re-run safety this policy assumes is each opted-in body's, not a general property. A
589
+ * body may opt in only when its read of the row, its decision and its signature ALL run inside
590
+ * the locked body, so that the re-run presents a write made over the row as it now stands rather
591
+ * than the first attempt's signed record (which the rival may have stamped past, or which may
592
+ * overwrite what the rival wrote). Three `CadrePeer` writers opt in, through
593
+ * `ControlDatabase.mutateCadrePeer`'s policy parameter or `lockedWithRetry` directly:
594
+ * `updateSelfPeerRecord` (re-reads and re-signs the node's own record), `reauthorizeCadrePeer`
595
+ * (re-reads the stamp and decides again whether the row is still the owner's to re-touch) and
596
+ * the two removals `deleteCadrePeer` / `removeMachine` (stamp read and signature already inside
597
+ * the body, and a self-publish never changes the stamp the `'remove'` digest binds). A new opt-in
598
+ * must re-derive that argument for its own body first.
599
+ */
600
+ export const LOST_UPDATE_RETRY_POLICY: Readonly<ControlWriteRetryOptions> = {
601
+ isRetriable: isRetriableLostUpdateFailure,
602
+ };
603
+
532
604
  /**
533
605
  * Run `attempt` up to {@link CONTROL_WRITE_ATTEMPTS} times (or `options.attempts`), retrying only
534
606
  * failures the policy's classifier — {@link isRetriableControlWriteFailure} by default —