@serfab/cadre-core 0.10.0 → 0.11.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 (105) hide show
  1. package/README.md +255 -255
  2. package/dist/cadre-node.d.ts +203 -28
  3. package/dist/cadre-node.js +445 -84
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/control-cohort.d.ts +26 -0
  6. package/dist/control-cohort.js +26 -0
  7. package/dist/control-cohort.js.map +1 -1
  8. package/dist/control-database.d.ts +82 -117
  9. package/dist/control-database.js +217 -295
  10. package/dist/control-database.js.map +1 -1
  11. package/dist/control-formation-recorder.d.ts +8 -7
  12. package/dist/control-formation-recorder.js +8 -7
  13. package/dist/control-formation-recorder.js.map +1 -1
  14. package/dist/control-schema.d.ts +1 -1
  15. package/dist/control-schema.js +789 -774
  16. package/dist/control-schema.js.map +1 -1
  17. package/dist/control-write-retry.d.ts +15 -5
  18. package/dist/control-write-retry.js +19 -18
  19. package/dist/control-write-retry.js.map +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/index.js +1 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/peer-addr-book.d.ts +92 -0
  24. package/dist/peer-addr-book.js +220 -0
  25. package/dist/peer-addr-book.js.map +1 -0
  26. package/dist/seed-bootstrap.d.ts +7 -0
  27. package/dist/seed-bootstrap.js +15 -13
  28. package/dist/seed-bootstrap.js.map +1 -1
  29. package/dist/strand-cohort.d.ts +4 -37
  30. package/dist/strand-cohort.js +4 -15
  31. package/dist/strand-cohort.js.map +1 -1
  32. package/dist/strand-database.d.ts +11 -16
  33. package/dist/strand-database.js +19 -7
  34. package/dist/strand-database.js.map +1 -1
  35. package/dist/strand-formation-manager.d.ts +8 -8
  36. package/dist/strand-formation-manager.js +15 -18
  37. package/dist/strand-formation-manager.js.map +1 -1
  38. package/dist/strand-instance-manager.d.ts +52 -21
  39. package/dist/strand-instance-manager.js +100 -34
  40. package/dist/strand-instance-manager.js.map +1 -1
  41. package/dist/strand-membership-writer.d.ts +6 -4
  42. package/dist/strand-membership-writer.js +38 -36
  43. package/dist/strand-membership-writer.js.map +1 -1
  44. package/dist/types.d.ts +39 -31
  45. package/dist/types.js.map +1 -1
  46. package/package.json +8 -7
  47. package/src/arachnode-stub.ts +159 -159
  48. package/src/bootstrap-peer-store-file.ts +44 -44
  49. package/src/bootstrap-peer-store.ts +172 -172
  50. package/src/cadre-node.ts +5158 -4771
  51. package/src/canonical-json.ts +30 -30
  52. package/src/control-authorization.ts +114 -114
  53. package/src/control-cohort.ts +130 -103
  54. package/src/control-database.ts +2406 -2506
  55. package/src/control-formation-recorder.ts +305 -304
  56. package/src/control-schema.ts +801 -786
  57. package/src/control-stream.ts +205 -205
  58. package/src/control-write-retry.ts +483 -473
  59. package/src/delegate-admission.ts +302 -302
  60. package/src/device-token.ts +94 -94
  61. package/src/diagnostics/connection-path.ts +277 -277
  62. package/src/diagnostics/webrtc-turn-tracker.ts +222 -222
  63. package/src/ed25519-key.ts +134 -134
  64. package/src/enrollment.ts +199 -199
  65. package/src/file-durable-slot.ts +61 -61
  66. package/src/formation-approval.ts +604 -604
  67. package/src/fs-atomic.ts +107 -107
  68. package/src/hibernation-manager.ts +437 -437
  69. package/src/identity-key.ts +109 -109
  70. package/src/index.ts +1 -0
  71. package/src/key-store-file.ts +132 -132
  72. package/src/key-store.ts +99 -99
  73. package/src/membership-connection-gater.ts +204 -204
  74. package/src/node-local-snapshot.ts +260 -260
  75. package/src/peer-addr-book.ts +250 -0
  76. package/src/peer-authorization.ts +259 -259
  77. package/src/peer-record.ts +164 -164
  78. package/src/push-credentials.ts +84 -84
  79. package/src/push-node.ts +64 -64
  80. package/src/push-notifier-apns.ts +247 -247
  81. package/src/push-notifier-fcm.ts +220 -220
  82. package/src/push-notifier-shared.ts +28 -28
  83. package/src/push-notifier.ts +52 -52
  84. package/src/relay-addrs.ts +111 -111
  85. package/src/relay-reservation.ts +823 -823
  86. package/src/seed-bootstrap.ts +15 -15
  87. package/src/seed-trust-policy.ts +140 -140
  88. package/src/strand-addr-protocol.ts +441 -441
  89. package/src/strand-backfill.ts +366 -366
  90. package/src/strand-cohort.ts +38 -77
  91. package/src/strand-database.ts +202 -204
  92. package/src/strand-formation-manager.ts +491 -494
  93. package/src/strand-formation-protocol.ts +692 -692
  94. package/src/strand-instance-manager.ts +108 -50
  95. package/src/strand-member-key.ts +39 -39
  96. package/src/strand-member-registry.ts +182 -182
  97. package/src/strand-membership-writer.ts +1432 -1430
  98. package/src/strand-solicitation.ts +472 -472
  99. package/src/strand-transport-key.ts +58 -58
  100. package/src/strand-wake-payload.ts +32 -32
  101. package/src/strand-wake-protocol.ts +314 -314
  102. package/src/strand-watcher.ts +314 -314
  103. package/src/trusted-owner-store-file.ts +41 -41
  104. package/src/trusted-owner-store.ts +171 -171
  105. package/src/types.ts +1309 -1303
@@ -9,779 +9,794 @@
9
9
  * build if they drift. Any edit here must be mirrored in `schemas/control.qsql`
10
10
  * and vice versa.
11
11
  */
12
- export const CONTROL_SCHEMA = `-- This manages a Sereus party's cadre, or set of nodes, and their participation in strands (networks)
13
- declare schema CadreControl {
14
- -- A key that can authorize various control changes
15
- table OwnerKey (
16
- Key text primary key,
17
- StampId text not null unique, -- per-row authorization nonce, bound into the signed digests below.
18
- -- \`unique\` holds over LIVE rows only; a removed row's stamp is retired
19
- -- permanently into Revocation (NotRevoked below), so the never-expiring
20
- -- enrollment signature cannot resurrect a removed owner.
21
- -- A removed row's StampId is retired into Revocation, and this refuses any insert
22
- -- naming a retired stamp: the approval that seated this row can never re-seat it after
23
- -- removal. \`unique\` alone did not do this — it only holds over LIVE rows, so a delete
24
- -- freed the stamp and the original never-expiring signature verified again. A
25
- -- legitimate re-add mints a FRESH StampId and a fresh signature, so it is unaffected.
26
- constraint NotRevoked check on insert (
27
- not exists (select 1 from Revocation R where R.TableName = 'OwnerKey' and R.StampId = new.StampId)
28
- ),
29
- -- Retirement is MANDATORY: a delete must carry the matching Revocation row in the same
30
- -- transaction, or the stamp would free up again. Deferred (subquery), so the sibling
31
- -- insert is visible at commit regardless of statement order. The tombstone must also
32
- -- name the removed ROW, not just its stamp — see Revocation.RowKey.
33
- constraint RevocationRecorded check on delete (
34
- exists (select 1 from Revocation R where R.TableName = 'OwnerKey' and R.StampId = old.StampId and R.RowKey = old.Key)
35
- ),
36
- -- A party must never lose its last owner key: every other CadreControl table's
37
- -- CHECK requires an OwnerKey row, so an owner-less control database can never
38
- -- authorize anything again — emptying this table is a permanent denial of the
39
- -- party's control plane. Deferred (subquery), so the count is the POST-delete count.
40
- -- NOTE: this is a per-transaction check against locally visible rows. Two
41
- -- partitioned nodes that concurrently remove different owners can each see a
42
- -- survivor and still converge to zero; if partitioned rotation ever becomes a real
43
- -- workflow, the floor needs a cross-node guard, not a local count.
44
- constraint MinOneOwner check on delete (
45
- (select count(1) from OwnerKey) >= 1
46
- ),
47
- -- Enrollment is insert + delete only; rotation is add-then-remove. No writer
48
- -- updates an OwnerKey row, and a self-rotation branch would double as a sole-owner
49
- -- takeover: a deferred post-image count is also true of an update that re-points
50
- -- the only row at an attacker key. Mirrors Strand.Manager.NoUpdate.
51
- constraint NoUpdate check on update (false),
52
- -- NOTE: the domain tag scopes an approval to a table and an action, not to a PARTY. Two
53
- -- parties that share an owner key would accept each other's approvals. Fine today (each
54
- -- party has its own owner key); if shared-owner multi-party ever ships, bind a party
55
- -- identity row into these digests.
56
- constraint Authorized check on insert, delete (
57
- -- Every authorizer is read from the PRE-transaction snapshot (committed.*):
58
- -- this CHECK auto-defers to commit (it has a subquery), by which point the row
59
- -- being inserted — and any sibling inserted alongside it — is live, so a plain
60
- -- from-OwnerKey would let a row authorize itself, or two strangers seat each
61
- -- other. committed.* excludes both, stating the rule directly: the authorizer
62
- -- must have existed BEFORE this transaction.
63
-
64
- -- Every signed digest below (and in every other CadreControl table) leads with two
65
- -- fixed literals — a 'CadreControl.<Table>' domain tag and an 'add'/'remove'/
66
- -- 'vouch'/'publish' action tag (cadre-core control-authorization.ts) — so an
67
- -- approval verifies ONLY against the one rule it was minted for. Without them,
68
- -- e.g. a ValidationKey enrollment approval covered the identical bytes as an
69
- -- OwnerKey enrollment: a narrow grant that doubled as full ownership.
70
-
71
- -- Bootstrap: the FOUNDING transaction — one whose pre-transaction owner set is
72
- -- empty — needs no authorization, and every row it inserts rides this branch
73
- -- (whoever writes it already owns the party outright, so a co-founder row grants
74
- -- nothing a single row would not). Gated on the PRE-transaction count, so a
75
- -- same-transaction swap of the sole owner (delete the founder, insert the
76
- -- attacker — post-image count still 1) cannot ride it.
77
- (old.Key is null and (select count(1) from committed.OwnerKey) = 0)
78
-
79
- -- or a pre-existing owner authorizes by signing over THIS row (Key, StampId)
80
- or (old.Key is null and exists (select 1 from committed.OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.OwnerKey', 'add', new.Key, new.StampId), context.Signature, A.Key, 'ed25519')))
81
-
82
- -- or a removal authorized by ANOTHER pre-existing owner over the DISTINCT
83
- -- 'remove'-tagged digest bound to the stored row's StampId, so an enrollment
84
- -- signature can never be replayed as a removal (cf. CadrePeer.AuthorizedDelete).
85
- -- A.Key <> old.Key: an owner cannot sign its own removal (no self-resignation).
86
- or (new.Key is null and exists (select 1 from committed.OwnerKey A where A.Key = context.OwnerKey and A.Key <> old.Key and verify(digest('CadreControl.OwnerKey', 'remove', old.Key, old.StampId), context.Signature, A.Key, 'ed25519')))
87
- )
88
- ) with context (OwnerKey text null, Signature text null);
89
-
90
- -- A key that can validate a strand formation disclosure
91
- table ValidationKey (
92
- Key text primary key,
93
- StampId text not null unique, -- single-use authorization nonce (anti-replay).
94
- -- \`unique\` holds over LIVE rows only; a removed row's stamp is retired
95
- -- permanently into Revocation (NotRevoked below), so the never-expiring
96
- -- enrollment signature cannot resurrect a removed key.
97
- -- A removed row's StampId is retired into Revocation, and this refuses any insert naming
98
- -- a retired stamp: the approval that seated this row can never re-seat it after removal.
99
- -- Same rationale as OwnerKey.NotRevoked, stated in full there.
100
- -- NOTE: this table gained a consumer — FormationUsage.Authorized reads it to gate redemption of
101
- -- a ValidationUrl invite — so the convergence gap noted on Strand.StampId now has teeth here: on
102
- -- a node that has not yet seen the tombstone, a replayed enrollment re-seats the key and it can
103
- -- approve redemptions there until the Revocation row arrives. Still no per-request READ surface to
104
- -- filter (the gate is a write-time CHECK), so there is nothing to add today; if one appears, filter
105
- -- retired stamps there.
106
- constraint NotRevoked check on insert (
107
- not exists (select 1 from Revocation R where R.TableName = 'ValidationKey' and R.StampId = new.StampId)
108
- ),
109
- -- Retirement is MANDATORY: a delete must carry the matching Revocation row in the same
110
- -- transaction, or the stamp would free up again. Deferred (subquery), so the sibling
111
- -- insert is visible at commit regardless of statement order. The tombstone must also
112
- -- name the removed ROW, not just its stamp — see Revocation.RowKey.
113
- constraint RevocationRecorded check on delete (
114
- exists (select 1 from Revocation R where R.TableName = 'ValidationKey' and R.StampId = old.StampId and R.RowKey = old.Key)
115
- ),
116
- -- Enrollment is insert + delete only; rotation is add-then-remove. No writer in the repo
117
- -- updates a ValidationKey row. Required because the AuthorizedInsert / AuthorizedDelete
118
- -- pair below covers neither update — and a bare \`check\`, which is what this rule used to
119
- -- be, covers insert+update but NOT delete. Mirrors OwnerKey.NoUpdate.
120
- constraint NoUpdate check on update (false),
121
- constraint AuthorizedInsert check on insert (
122
- -- Owners authorize by signing over THIS row (Key, StampId); single-use via unique StampId
123
- exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.ValidationKey', 'add', new.Key, new.StampId), context.Signature, A.Key, 'ed25519'))
124
- ),
125
- constraint AuthorizedDelete check on delete (
126
- -- Delete is authorized by a signature over the DISTINCT 'remove'-tagged digest bound to
127
- -- the STORED row (Key, StampId), so an enrollment approval — which never expires — can
128
- -- never be replayed as a removal. Same shape as CadrePeer.AuthorizedDelete.
129
- exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.ValidationKey', 'remove', old.Key, old.StampId), context.Signature, A.Key, 'ed25519'))
130
-
131
- -- or REAP: a COMMITTED tombstone already retires this exact row incarnation, so a node
132
- -- that was offline at removal time may delete the stale row locally with no owner key.
133
- -- Why committed.* and why the stamp must be bound: stated in full on
134
- -- CadrePeer.AuthorizedDelete.
135
- or exists (select 1 from committed.Revocation R where R.TableName = 'ValidationKey' and R.RowKey = old.Key and R.StampId = old.StampId)
136
- )
137
- ) with context (OwnerKey text, Signature text);
138
-
139
- -- A network of members sharing an sApp database, each contributing peer nodes (their cadre) to the overall cohort
140
- -- Cadre peers should participate in each of these strands
141
- table Strand (
142
- Id text primary key, -- UUID
143
- MemberPrivateKey text null unique, -- Our private key as a member of this strand
144
- Type text, -- Types: 'o' = Open, 'c' = Closed -- Open can still control writes in the sApp, but only Closed controls reads
145
- StampId text not null unique, -- single-use authorization nonce (anti-replay).
146
- -- \`unique\` holds over LIVE rows only; a removed row's stamp is retired
147
- -- permanently into Revocation (NotRevoked below).
148
- -- NOTE: NotRevoked is a per-transaction check against LOCALLY VISIBLE rows, so a node that
149
- -- has not yet converged on the Revocation row can still accept a replayed add and end up
150
- -- holding the resurrected strand alongside its tombstone. CadrePeer has a read-side
151
- -- mitigation for this class (CadreNode.listAuthorizedMembers drops rows whose stamp is
152
- -- retired); Strand and ValidationKey deliberately do NOT, because neither has a
153
- -- per-request authorization surface today. If one ever gains one, filter it there.
154
- constraint NotRevoked check on insert (
155
- not exists (select 1 from Revocation R where R.TableName = 'Strand' and R.StampId = new.StampId)
156
- ),
157
- -- Retirement is MANDATORY: a delete must carry the matching Revocation row in the same
158
- -- transaction, or the stamp would free up again. Deferred (subquery), so the sibling
159
- -- insert is visible at commit regardless of statement order. The tombstone must also
160
- -- name the removed ROW, not just its stamp — see Revocation.RowKey. That is what makes
161
- -- the consent branch of AuthorizedInsert below trustworthy: a removal cannot file a
162
- -- tombstone that omits or misnames the id it retires.
163
- constraint RevocationRecorded check on delete (
164
- exists (select 1 from Revocation R where R.TableName = 'Strand' and R.StampId = old.StampId and R.RowKey = old.Id)
165
- ),
166
- -- Strand rows are insert + delete only. Required: the consent branch of AuthorizedInsert
167
- -- below authorizes by the EXISTENCE of a FormationUsage row naming (new.Id, new.StampId)
168
- -- and carries no signature, so while this rule was a bare \`check\` (insert+update) it also
169
- -- said "anyone may REWRITE any consent-formed strand, unsigned" — flipping Type to 'o' and
170
- -- nulling MemberPrivateKey destroys the party's own membership key for that network in place.
171
- -- Mirrors OwnerKey.NoUpdate / FormationInvite.Immutable.
172
- constraint NoUpdate check on update (false),
173
- constraint AuthorizedInsert check on insert (
174
- -- Authorized by an owner signing over THIS row (Id, Type, MemberPrivateKey, StampId); single-use via unique StampId
175
- exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.Strand', 'add', new.Id, new.Type, coalesce(new.MemberPrivateKey, ''), new.StampId), context.Signature, A.Key, 'ed25519'))
176
-
177
- -- or authorized WITHOUT a signature by a redemption record naming THIS EXACT ROW: a
178
- -- FormationUsage row carrying both this strand's Id and its one-off StampId
179
- -- (control-database.ts:redeemInvitation writes the pair in one transaction; both
180
- -- CHECKs defer, so each sees the other's row at commit).
181
- --
182
- -- Matching the STAMP, not the id alone, is what makes a removal stick. FormationUsage
183
- -- is append-only, so a redemption record outlives the strand it formed forever; keyed
184
- -- on the id alone it re-authorized ANY later insert of that id, so after a legitimate
185
- -- owner-signed, tombstoned removal any writer could re-seat the strand unsigned with a
186
- -- fresh StampId (sidestepping NotRevoked, which retires only the removed row's stamp)
187
- -- and an ATTACKER-CHOSEN MemberPrivateKey — the party's own secret for that network.
188
- -- Bound to the stamp, the removal retires that stamp into Revocation and NotRevoked
189
- -- closes this consent record's branch permanently.
190
- --
191
- -- Beyond the stamp match, the branch is deliberately NARROW — each clause closes one door:
192
- -- * Type = 'o' / MemberPrivateKey null — a consent-seated strand looks exactly like
193
- -- what the unbound redemption path writes (open, keyless). A closed strand carrying
194
- -- a caller-chosen member key (the party's own read-gating secret for that network)
195
- -- has no legitimate producer. MemberKeyClosedOnly already implies the second clause
196
- -- from the first today; both are stated so this rule does not silently depend on
197
- -- another constraint staying as it is.
198
- -- * the inner FI.StrandId is null — only an UNBOUND invite may seat a strand at all.
199
- -- A bound invite's host strand is owner-provisioned up front; letting the invite
200
- -- consent-seat that id would allow an open, keyless downgrade of the real (possibly
201
- -- closed, key-bearing) host row on a node where it has not converged yet. The bound
202
- -- path records usage only (resolveStrand reports an absent host as missing and the
203
- -- formation manager rejects cleanly).
204
- -- * the not-exists over OTHER stamps — a strand id may be consent-seated once, EVER.
205
- -- FormationUsage is append-only, so the first redemption's record survives a later
206
- -- owner-signed removal and permanently forecloses a second consent-seating of the
207
- -- id (a spare use of the same token minted a new stamp and rode this branch to
208
- -- re-seat the id with an attacker-chosen row). Re-joining a removed id is
209
- -- owner-gated: the owner re-seats it signed (fresh stamp, fresh signature) and
210
- -- issues a BOUND invite the returning party records consent against — the id is
211
- -- not blacklisted, only its unsigned re-seat is. This cannot be weaponised to
212
- -- pre-block a legitimate id: StrandExists refuses a usage row naming a strand that
213
- -- does not exist, so no usage row can be pre-planted for an unseated id — and ids
214
- -- are 128 random bytes anyway.
215
- -- * the not-exists over Revocation — a strand id may be consent-seated once EVER
216
- -- and never after ANY removal of that id, however it was seated. The usage-row
217
- -- rule above binds only ids that ever carried a consent record; an id seated
218
- -- purely owner-signed (insertStrand, no usage row) leaves nothing behind on
219
- -- removal, so its removal needs its own trace. Every removal files a Revocation
220
- -- row naming the removed row's key (RowKey), and this clause refuses any id
221
- -- that has ever been tombstoned. RevocationRecorded above makes that trace
222
- -- mandatory AND correctly named, so no removal can slip past this clause. The
223
- -- usage-row clause is arguably subsumed by this one (a usage row implies the
224
- -- strand row existed, and its removal must file a tombstone), but it reaches
225
- -- the same conclusion without depending on Revocation at all; both are stated,
226
- -- per the style note above. The owner-signed branch is deliberately unaffected,
227
- -- so re-join stays owner-gated exactly as described.
228
- -- What is NOT schema-verified is a STANDALONE tombstone (one with no
229
- -- accompanying delete — see Revocation.Authorized): an owner can file one
230
- -- naming an id that never existed and thereby permanently foreclose
231
- -- consent-seating of that id — owner-only, the owner already controls invite
232
- -- issuance, and unbound ids are 128 random bytes minted at redemption, so no
233
- -- escalation (same reasoning as the pre-plant note above).
234
- -- NOTE: like NotRevoked above, this is a write-time check against LOCALLY
235
- -- VISIBLE rows — a node that has not yet converged on the Revocation row still
236
- -- accepts the re-seat. Fails in the safe direction: the tombstone wins after
237
- -- merge for the STAMP, and the resurrected row coexists with it (same class as
238
- -- the StampId note above).
239
- or (
240
- new.Type = 'o'
241
- and new.MemberPrivateKey is null
242
- and exists (
243
- select 1 from FormationUsage FU
244
- where FU.StrandId = new.Id
245
- and FU.StrandStampId = new.StampId
246
- and exists (select 1 from FormationInvite FI where FI.Token = FU.Token and FI.StrandId is null)
247
- )
248
- and not exists (
249
- select 1 from FormationUsage FU2
250
- where FU2.StrandId = new.Id and FU2.StrandStampId <> new.StampId
251
- )
252
- and not exists (
253
- select 1 from Revocation R
254
- where R.TableName = 'Strand' and R.RowKey = new.Id
255
- )
256
- )
257
- ),
258
- constraint AuthorizedDelete check on delete (
259
- -- Delete is authorized by a signature over the DISTINCT 'remove'-tagged digest bound to
260
- -- the STORED row (Id, StampId), so the add approval — which never expires — can never be
261
- -- replayed as a removal. Note the consent branch above is deliberately NOT mirrored
262
- -- here: an invitation authorizes forming a strand, never destroying one.
263
- --
264
- -- DELIBERATELY no REAP branch (cf. CadrePeer.AuthorizedDelete): this row carries
265
- -- MemberPrivateKey — the party's own membership secret for that network, stored nowhere
266
- -- else — so this is the one guarded delete whose effect is unrecoverable, and a stale
267
- -- row is already inert for the purpose a tombstone serves (the consent branch of
268
- -- AuthorizedInsert above refuses any id ever tombstoned). Do not "fix" the asymmetry;
269
- -- tickets/backlog/debt-strand-tombstone-reap.md owns any future change.
270
- exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.Strand', 'remove', old.Id, old.StampId), context.Signature, A.Key, 'ed25519'))
271
- ),
272
- constraint MemberKeyClosedOnly check (
273
- -- An open strand ('o') has no membership gate, so it must not carry a
274
- -- member key. A non-null MemberPrivateKey requires a closed strand ('c').
275
- new.MemberPrivateKey is null or new.Type = 'c'
276
- )
277
- ) with context (OwnerKey text, Signature text);
278
-
279
- -- A peer (node) that is part of the cadre, carrying a self-published,
280
- -- freshness-stamped, self-signed address record (see PeerAddressRecord in cadre-core).
281
- -- The row IS the peer-address record: a resolver reads it, re-verifies Sig against
282
- -- PublicKey, checks freshness (UpdatedAt) and trust, then dials the addrs.
283
- table CadrePeer (
284
- PeerId text primary key,
285
- PublicKey text null, -- ed25519 (base64url) whose libp2p identity == PeerId (null if non-Ed25519)
286
- Multiaddr text, -- comma-joined current addrs (signaling / p2p-circuit first)
287
- UpdatedAt int null, -- epoch ms; strictly increases per self-update (replay/rollback guard)
288
- Sig text null, -- ed25519 self-signature over the signed payload (base64url); null until self-published
289
- StampId text not null unique, -- per-row authorization nonce, bound into the voucher/remove digests; rotates on
290
- -- (re)insert. A removed row's stamp is retired permanently into Revocation
291
- -- (NotRevoked below), so a captured admission approval cannot re-seat a removed peer.
292
- VouchOwner text null, -- ed25519 (base64url) owner key that vouched this membership (== insert context.OwnerKey)
293
- VouchSig text null, -- that owner's signature over the 'vouch'-tagged digest below (== insert context.Signature). A reader checks VouchOwner against its NODE-LOCAL trusted-owner anchor (not this replicated table, which a self-owner can pollute) to decide authorized membership. The domain/action tags keep this STORED, REPLICATED signature useless against every other rule (pre-tag it satisfied OwnerKey/ValidationKey inserts for Key = PeerId).
294
- -- A removed row's StampId is retired into Revocation, and this refuses any insert
295
- -- naming a retired stamp: the approval that seated this row can never re-seat it after
296
- -- removal. \`unique\` alone did not do this — it only holds over LIVE rows, so a delete
297
- -- freed the stamp and the original never-expiring signature verified again. A
298
- -- legitimate re-add mints a FRESH StampId and a fresh signature, so it is unaffected.
299
- constraint NotRevoked check on insert (
300
- not exists (select 1 from Revocation R where R.TableName = 'CadrePeer' and R.StampId = new.StampId)
301
- ),
302
- -- Retirement is MANDATORY: a delete must carry the matching Revocation row in the same
303
- -- transaction, or the stamp would free up again. Deferred (subquery), so the sibling
304
- -- insert is visible at commit regardless of statement order. The tombstone must also
305
- -- name the removed ROW, not just its stamp — see Revocation.RowKey.
306
- constraint RevocationRecorded check on delete (
307
- exists (select 1 from Revocation R where R.TableName = 'CadrePeer' and R.StampId = old.StampId and R.RowKey = old.PeerId)
308
- ),
309
- constraint AuthorizedInsert check on insert (
310
- -- Authorized by an owner key, which vouches both membership and the
311
- -- PublicKey<->PeerId binding (cadre-core derives PublicKey from PeerId before insert).
312
- -- The digest binds PeerId + the single-use StampId nonce (two TEXT fields),
313
- -- matching cadre-core peer-authorization.ts:cadrePeerVoucherDigest. The same
314
- -- 'vouch'-tagged digest is DELIBERATELY shared with the owner branch of
315
- -- AuthorizedUpdate below: both mean "this owner vouches this membership row".
316
- exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.CadrePeer', 'vouch', new.PeerId, new.StampId), context.Signature, A.Key, 'ed25519'))
317
- -- Persist the vouching (owner, signature) onto the row so a reader can later
318
- -- re-check VouchOwner against its node-local anchor. The stored pair MUST equal
319
- -- the pair the verify above validated, so a writer cannot store a voucher it did
320
- -- not actually receive a signature for.
321
- and new.VouchOwner = context.OwnerKey
322
- and new.VouchSig = context.Signature
323
- ),
324
- constraint AuthorizedDelete check on delete (
325
- -- Delete is authorized by a signature over the DISTINCT 'remove'-tagged digest bound
326
- -- to the row's StampId (cadre-core peer-authorization.ts:cadrePeerRemoveDigest), so
327
- -- the stored voucher (a signature over the 'vouch'-tagged digest) can NEVER be
328
- -- replayed to authorize a delete. The remove signature rides in context and is never stored.
329
- exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.CadrePeer', 'remove', old.PeerId, old.StampId), context.Signature, A.Key, 'ed25519'))
330
-
331
- -- or REAP: this node already holds a COMMITTED tombstone retiring THIS EXACT row
332
- -- incarnation, so the party owner has already authorized this row's removal and this
333
- -- node is merely catching up. That is what lets a node which was offline at
334
- -- revocation time delete the stale row locally, with no owner private key
335
- -- (control-database.ts:reapRevokedRow). Same shape as the consent branch of
336
- -- Strand.AuthorizedInsert: authorization by the EXISTENCE of a row rather than by a
337
- -- signature — and that row is itself owner-signed (Revocation.Authorized), so this
338
- -- widens WHO may execute a removal, never WHO may decide one. RevocationRecorded is
339
- -- satisfied by the same committed tombstone, so a reap files no second one.
340
- --
341
- -- committed.Revocation, NOT Revocation — and the stamp clause — are both load-bearing:
342
- -- * committed.* : this CHECK defers to commit (it has a subquery), by which point a
343
- -- tombstone written in the SAME transaction is live. Reading plain Revocation
344
- -- would therefore let deleteGuardedRow's own sibling tombstone satisfy this
345
- -- branch, making the 'remove'-tagged delete signature above dead weight and
346
- -- collapsing two domain-separated approvals into one. committed.* states the rule
347
- -- exactly: the tombstone must have existed BEFORE this transaction.
348
- -- * R.StampId = old.StampId : binds the ROW INCARNATION, not the name. One name may
349
- -- legitimately carry several tombstones over its life (seat -> delete -> owner
350
- -- re-seat -> delete). Without this clause a tombstone from a PREVIOUS incarnation
351
- -- would authorize deleting the CURRENT row, which the owner never removed.
352
- or exists (select 1 from committed.Revocation R where R.TableName = 'CadrePeer' and R.RowKey = old.PeerId and R.StampId = old.StampId)
353
- ),
354
- constraint AuthorizedUpdate check on update (
355
- -- Peer self-updates its own addrs + freshness, signing with its OWN ed25519 key
356
- -- (the key behind PeerId). PeerId/PublicKey/StampId/voucher are immutable on
357
- -- self-update, UpdatedAt must strictly increase (replay guard), and Sig is verified
358
- -- against the stored PublicKey over the same payload the publish path signs
359
- -- (cadre-core peer-record.ts:peerRecordSignedPayload).
360
- (
361
- new.PeerId = old.PeerId
362
- and new.PublicKey = old.PublicKey
363
- and new.StampId = old.StampId
364
- and new.VouchOwner = old.VouchOwner
365
- and new.VouchSig = old.VouchSig
366
- and new.UpdatedAt > coalesce(old.UpdatedAt, 0)
367
- and verify(
368
- digest('CadreControl.CadrePeer', 'publish', new.PeerId, new.Multiaddr, cast(new.UpdatedAt as text)),
369
- new.Sig, new.PublicKey, 'ed25519')
370
- )
371
- -- or an owner re-authorizes (rotation / correction): re-vouches the row over
372
- -- its current StampId, so the stored voucher is re-bound to the re-authorizing owner.
373
- -- StampId is immutable here too: an update that rotated it would leave the OLD
374
- -- stamp live-free and untombstoned (RevocationRecorded only retires the stamp a
375
- -- delete carries), so the original admission approval — which is stored on the
376
- -- replicated row as VouchSig — would resurrect the peer after a later removal.
377
- -- Retiring a stamp is delete + Revocation row, never an update.
378
- or (
379
- new.StampId = old.StampId
380
- and exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.CadrePeer', 'vouch', new.PeerId, new.StampId), context.Signature, A.Key, 'ed25519'))
381
- and new.VouchOwner = context.OwnerKey
382
- and new.VouchSig = context.Signature
383
- )
384
- )
385
- ) with context (OwnerKey text null, Signature text);
386
-
387
- -- A mobile cadre peer's platform push token (FCM/APNs), self-published so a
388
- -- server peer can deliver a push-wake to a suspended app over the platform push
389
- -- channel (a control-network dial cannot reach an OS-suspended process). The row
390
- -- mirrors CadrePeer: a resolver re-verifies Sig against the CadrePeer.PublicKey for
391
- -- this PeerId (see device-token.ts:deviceTokenSignedPayload), checks freshness, then
392
- -- hands the token to the push sender.
393
- table DeviceToken (
394
- PeerId text primary key, -- the CadrePeer this token belongs to
395
- Platform text not null, -- 'fcm' | 'apns'
396
- Token text not null, -- opaque platform device/registration token
397
- UpdatedAt int null, -- epoch ms; strictly increases per self-update (replay guard)
398
- Sig text null, -- ed25519 self-sig over (PeerId|Platform|Token|UpdatedAt)
399
- StampId text not null unique, -- single-use authorization nonce, bound into the add/remove digests;
400
- -- rotates on (re)insert. A cleared token's stamp is retired permanently
401
- -- into Revocation (NotRevoked below), so the approval that published a
402
- -- token cannot re-seat it after the owner cleared it (logout).
403
- -- A removed row's StampId is retired into Revocation, and this refuses any insert
404
- -- naming a retired stamp: the approval that seated this row can never re-seat it after
405
- -- the clear. \`unique\` alone did not do this — it only holds over LIVE rows, so a delete
406
- -- freed the stamp and the original never-expiring signature verified again. A legitimate
407
- -- re-register mints a FRESH StampId and a fresh signature, so it is unaffected. This
408
- -- matters more here than the stored-approval case (CadrePeer.VouchSig): a resurrected
409
- -- push token has NO freshness ceiling to retire it — cadre-node.ts:resolveDeviceToken
410
- -- defaults maxAgeMs to infinity by design — so retirement is the only thing that sticks.
411
- constraint NotRevoked check on insert (
412
- not exists (select 1 from Revocation R where R.TableName = 'DeviceToken' and R.StampId = new.StampId)
413
- ),
414
- -- Retirement is MANDATORY: a delete must carry the matching Revocation row in the same
415
- -- transaction, or the stamp would free up again. Deferred (subquery), so the sibling
416
- -- insert is visible at commit regardless of statement order. The tombstone must also
417
- -- name the removed ROW, not just its stamp — see Revocation.RowKey.
418
- constraint RevocationRecorded check on delete (
419
- exists (select 1 from Revocation R where R.TableName = 'DeviceToken' and R.StampId = old.StampId and R.RowKey = old.PeerId)
420
- ),
421
- constraint AuthorizedInsert check on insert (
422
- -- Authorized by an owner key (membership vouch), exactly like CadrePeer.
423
- -- The digest binds the WHOLE row behind the domain/action tags — every column,
424
- -- with the nullable UpdatedAt/Sig signing as '' when absent (the shape
425
- -- FormationInvite and Strand already use) — so a replayed approval can only ever
426
- -- reproduce the row it approved, never one carrying attacker-chosen
427
- -- Platform/Token/UpdatedAt; NotRevoked above then refuses even that exact row
428
- -- once it has been cleared. The DISTINCT 'remove'-tagged digest below keeps a
429
- -- captured insert approval from doubling as a delete (and vice versa).
430
- -- Mirrors cadre-core peer-authorization.ts:deviceTokenAddDigest.
431
- exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(
432
- digest(
433
- 'CadreControl.DeviceToken', 'add',
434
- new.PeerId,
435
- new.Platform,
436
- new.Token,
437
- coalesce(cast(new.UpdatedAt as text), ''),
438
- coalesce(new.Sig, ''),
439
- new.StampId
440
- ),
441
- context.Signature, A.Key, 'ed25519'))
442
- ),
443
- constraint AuthorizedDelete check on delete (
444
- -- Bound to the STORED row's (PeerId, StampId) — cadre-core
445
- -- peer-authorization.ts:deviceTokenRemoveDigest — so the never-expiring insert
446
- -- approval can never be replayed as a clear, and a captured clear approval is
447
- -- dead once the stamp it names is retired.
448
- exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.DeviceToken', 'remove', old.PeerId, old.StampId), context.Signature, A.Key, 'ed25519'))
449
-
450
- -- or REAP: a COMMITTED tombstone already retires this exact row incarnation, so a node
451
- -- that was offline at removal time may delete the stale row locally with no owner key.
452
- -- Why committed.* and why the stamp must be bound: stated in full on
453
- -- CadrePeer.AuthorizedDelete.
454
- or exists (select 1 from committed.Revocation R where R.TableName = 'DeviceToken' and R.RowKey = old.PeerId and R.StampId = old.StampId)
455
- ),
456
- constraint AuthorizedUpdate check on update (
457
- -- Peer self-updates its own token, signing with its OWN ed25519 key (the key
458
- -- behind PeerId). PeerId and StampId are immutable, UpdatedAt must strictly
459
- -- increase (replay guard), and Sig is verified against the stored
460
- -- CadrePeer.PublicKey over the signed payload (cadre-core
461
- -- device-token.ts:deviceTokenSignedPayload). Platform and Token may change on
462
- -- self-update (platform switch / reinstall / rotation).
463
- -- StampId is immutable for the reason CadrePeer spells out: an update that
464
- -- rotated it would leave the OLD stamp live-free and untombstoned, so retiring a
465
- -- stamp stays delete + Revocation row, never an update.
466
- -- This is the ONLY branch: there is deliberately NO owner re-touch. The branch
467
- -- that used to sit beside it verified an owner signature over the peer id alone
468
- -- and sat OUTSIDE the monotonicity requirement, so one captured owner approval
469
- -- rewrote Platform/Token and rolled UpdatedAt BACKWARDS — and seating a
470
- -- far-future UpdatedAt wedged the peer's own self-updates permanently. No writer
471
- -- used it. An owner that must correct a row deletes it (retiring the stamp) and
472
- -- inserts a fresh one, the same path it already takes for CadrePeer.
473
- new.PeerId = old.PeerId
474
- and new.StampId = old.StampId
475
- and new.UpdatedAt > coalesce(old.UpdatedAt, 0)
476
- and exists (select 1 from CadrePeer P where P.PeerId = new.PeerId and verify(
477
- digest('CadreControl.DeviceToken', 'publish', new.PeerId, new.Platform, new.Token, cast(new.UpdatedAt as text)),
478
- new.Sig, P.PublicKey, 'ed25519'))
479
- )
480
- ) with context (OwnerKey text null, Signature text);
481
-
482
- -- An open invitation to form a strand with this party
483
- table FormationInvite (
484
- Token text primary key, -- Just a random string
485
- sAppId text, -- The app for the strand that will be formed
486
- ExpiresAt datetime null,
487
- TotalUses int null check (TotalUses >= 0),
488
- ValidationUrl text null, -- Web hook - send disclosure, IP address...
489
- StrandId text null, -- Host strand this invite binds to (provision-then-record). Non-null => the responder records consent against this pre-existing strand; null => legacy responder-provisions path.
490
- StampId text not null unique, -- single-use authorization nonce (anti-replay)
491
- constraint AuthorizedInsert check on insert (
492
- -- Authorized by an owner signing over THIS row
493
- -- (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId); single-use via unique StampId.
494
- -- Nullable bound fields (ExpiresAt/TotalUses/ValidationUrl/StrandId) sign as '' when absent.
495
- -- The 'remove'-tagged AuthorizedDelete digest below is DISTINCT, so a captured
496
- -- insert approval can never be replayed to revoke-then-reissue an invite (or vice versa).
497
- exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(
498
- digest(
499
- 'CadreControl.FormationInvite', 'add',
500
- new.Token,
501
- new.sAppId,
502
- coalesce(cast(new.ExpiresAt as text), ''),
503
- coalesce(cast(new.TotalUses as text), ''),
504
- coalesce(new.ValidationUrl, ''),
505
- coalesce(new.StrandId, ''),
506
- new.StampId
507
- ),
508
- context.Signature, A.Key, 'ed25519'))
509
- ),
510
- constraint AuthorizedDelete check on delete (
511
- exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(
512
- digest(
513
- 'CadreControl.FormationInvite', 'remove',
514
- old.Token,
515
- old.sAppId,
516
- coalesce(cast(old.ExpiresAt as text), ''),
517
- coalesce(cast(old.TotalUses as text), ''),
518
- coalesce(old.ValidationUrl, ''),
519
- coalesce(old.StrandId, ''),
520
- old.StampId
521
- ),
522
- context.Signature, A.Key, 'ed25519'))
523
- ),
524
- -- Invites are insert/delete only: forbid in-place mutation so the row-bound
525
- -- AuthorizedInsert signature cannot be sidestepped by updating consent
526
- -- parameters (TotalUses/ExpiresAt/...) after a legitimate insert. A bare check
527
- -- defaults to insert+update; the checks on insert / on delete above exclude update,
528
- -- so this guard is required. Mirrors FormationUsage.InsertOnly.
529
- constraint Immutable check on update (false)
530
- ) with context (OwnerKey text, Signature text);
531
-
532
- table FormationUsage (
533
- Token text,
534
- UseNumber int,
535
- UsageStampId text not null unique, -- single-use nonce for THIS redemption. The JOINING peer mints it and
536
- -- sends it in its contact message, having signed it into its own 'consent'
537
- -- digest (PeerConsented below); the responder inserts that same value and
538
- -- asks the approver about it, so it is bound into the 'vouch' digest below
539
- -- too and one sign-off is spendable exactly once. Neither side can swap it:
540
- -- a substituted nonce fails the joiner's consent signature.
541
- -- Unlike every other StampId in this schema, this table is append-only
542
- -- (InsertOnly below), so the nonce is never freed by a delete: \`unique\`
543
- -- alone makes it permanent and there is deliberately NO NotRevoked /
544
- -- RevocationRecorded pair here — do not go looking for the missing one.
545
- -- NOTE: \`unique\` is evaluated against LOCALLY VISIBLE rows, so two nodes
546
- -- that have not yet converged could each admit the same nonce and both
547
- -- rows survive the merge. Same class as the NotRevoked convergence notes
548
- -- on OwnerKey / Strand / ValidationKey; the digest binding still holds on
549
- -- both nodes, so the outcome is a duplicated audit row, never a join
550
- -- nobody approved.
551
- PeerKey text not null, -- the joining peer's own ed25519 public key (base64url, 32 bytes), bound
552
- -- into BOTH the approver's 'vouch' digest and the joiner's 'consent'
553
- -- digest below, so an approval cannot be re-filed under a different
554
- -- joiner's name and the named joiner provably agreed (PeerConsented).
555
- -- The key IS the joiner's identity — its libp2p peer id is derivable
556
- -- (identity multihash of these bytes), never stored here.
557
- PeerSig text not null, -- the joining peer's signature over the 'consent' digest — see
558
- -- PeerConsented below for what it proves and why it is stored on the row.
559
- Disclosure text,
560
- StrandId text,
561
- StrandStampId text not null, -- the Strand.StampId this consent record authorizes. Consent is
562
- -- bound to ONE strand ROW, not to a strand id forever — see
563
- -- Strand.AuthorizedInsert's consent branch for why. NOT unique:
564
- -- in the bound (provision-then-record) path several invitees
565
- -- each write a usage row against the SAME host strand, so many
566
- -- usage rows legitimately share one stamp.
567
- primary key (Token, UseNumber),
568
- constraint InsertOnly check on update, delete (false),
569
- constraint Monotonic check (
570
- -- Read the PRE-transaction snapshot (committed.*): this CHECK auto-defers
571
- -- to commit (it has a subquery), by which point the row being inserted is
572
- -- live, so a plain from-FormationUsage would count this row itself and make
573
- -- UseNumber = max+1 unsatisfiable. committed.* excludes the in-flight row,
574
- -- giving sequential 1,2,3... per token (cross-transaction; concurrent
575
- -- redemptions of the same token collide on the (Token, UseNumber) PK).
576
- -- ControlDatabase's use-number retry (withUseNumberRetry) is what resolves
577
- -- that collision, by re-presenting the same approval under a fresh use
578
- -- number. The deferred check above is the other surface the same race can
579
- -- take: a concurrent row the inserting transaction's key probe did not yet
580
- -- see trips this Monotonic CHECK at commit instead of the PK.
581
- new.UseNumber = coalesce((select max(UseNumber) from committed.FormationUsage U where U.Token = new.Token), 0) + 1
582
- ),
583
- constraint Authorized check on insert (
584
- -- Satisfies an invitation
585
- exists (
586
- select 1 from FormationInvite FI
587
- where FI.Token = new.Token
588
- and (FI.TotalUses is null or FI.TotalUses >= new.UseNumber)
589
- and (FI.ExpiresAt is null or FI.ExpiresAt > context.Now)
590
- -- An invite carrying a ValidationUrl demands a sign-off from a key the party
591
- -- ENROLLED in ValidationKey. The signature is verified against the STORED VK.Key,
592
- -- NOT against context.ValidationKey — that distinction is the whole point:
593
- -- context.ValidationKey is a plain insert parameter, so verifying against it asked
594
- -- only "did the redeemer sign its own disclosure with SOME key it holds?", which any
595
- -- redeemer answers by minting a throwaway keypair. context.ValidationKey survives
596
- -- purely to SELECT which enrolled row is being claimed; naming an enrolled key while
597
- -- signing with a different one fails the verify against VK.Key.
598
- --
599
- -- Deliberate: CHECKs run on write only, so removing a validation key later does NOT
600
- -- re-examine the FormationUsage rows it already approved. The sign-off was valid when
601
- -- given and the strand it authorized stays formed; removal narrows who may approve
602
- -- FUTURE redemptions, it is not a retroactive revocation of past ones.
603
- --
604
- -- What ONE sign-off buys: exactly ONE row. The digest covers
605
- -- (Token, UsageStampId, StrandId, PeerKey, Disclosure), so an approval is NOT
606
- -- transferable across invitations, redemptions, networks, joiners, or disclosure
607
- -- text. Replay is closed twice over, by two independent mechanisms: presenting an
608
- -- approval VERBATIM repeats its UsageStampId and is refused by that column's
609
- -- \`unique\`; presenting it under any OTHER redemption changes a signed field and
610
- -- fails this verify.
611
- -- Why a fresh nonce rather than binding UseNumber: UseNumber is derived from the
612
- -- table's current state (max+1, see Monotonic), so binding it would make the
613
- -- approver sign a value a concurrent redemption can invalidate — the loser of the
614
- -- race would need a SECOND approval for the same joiner (for a manual review
615
- -- queue, a second human review). A nonce chosen freely by the redeeming node is
616
- -- just as single-use and lets the loser retry the same approval under a new use
617
- -- number.
618
- -- Why StrandStampId is deliberately NOT bound: it names the strand ROW
619
- -- incarnation, which the unbound path (redeemInvitation) mints inside its own
620
- -- transaction, and it adds nothing on top of a strictly single-use approval —
621
- -- StrandId already pins WHICH network the approver approved joining.
622
- and (FI.ValidationUrl is null or exists (
623
- select 1 from ValidationKey VK
624
- where VK.Key = context.ValidationKey
625
- and verify(digest('CadreControl.FormationUsage', 'vouch', new.Token, new.UsageStampId, new.StrandId, new.PeerKey, new.Disclosure), context.ValidationSignature, VK.Key, 'ed25519')))
626
- -- A bound invite (non-null StrandId) may only ever name its own host strand:
627
- -- without this, any holder of a bound token could record consent against an
628
- -- arbitrary strand id — annotating an unrelated strand, or (before
629
- -- Strand.AuthorizedInsert's consent branch was narrowed to unbound invites)
630
- -- consent-seating that id outright.
631
- -- NOTE: an UNBOUND invite is deliberately left free to name any strand, so a
632
- -- holder can spend a use appending a consent row to an unrelated existing
633
- -- strand. Harmless today (the row grants nothing, it only burns a use and
634
- -- forecloses that id's consent-seating, which is the safe direction); if
635
- -- unlimited-TotalUses invites ever become common, this is an append-only
636
- -- growth surface worth bounding.
637
- and (FI.StrandId is null or FI.StrandId = new.StrandId)
638
- )
639
- ),
640
- -- Matched on the (id, stamp) PAIR, the same key Strand.AuthorizedInsert's consent branch
641
- -- reads back: a usage row may only ever name a strand ROW that exists, so a consent record
642
- -- can never be filed against a stamp of the writer's choosing and held in reserve to
643
- -- re-seat the id after a removal.
644
- constraint StrandExists check (exists (select 1 from Strand S where S.Id = new.StrandId and S.StampId = new.StrandStampId)),
645
- -- The JOINING peer proves it consented to this redemption: PeerKey is its own ed25519
646
- -- public key and PeerSig is its signature over the 'consent'-tagged digest below.
647
- -- Unlike the approver's sign-off (context.ValidationSignature, checked against a STORED
648
- -- ValidationKey row), the identity here IS the key, so there is no enrolled row to look
649
- -- up and nothing for a writer to substitute: a forged joiner would need that joiner's
650
- -- private key. Stored rather than passed in context so any later reader can re-check it
651
- -- (verifyFormationConsent); the 'consent' action tag keeps this replicated signature
652
- -- useless against every other rule in this schema (notably the approver's 'vouch'
653
- -- digest over the same table).
654
- -- NOTE: StrandId is deliberately NOT in this digest — the joiner cannot know it when it
655
- -- signs (a bound invite's host strand arrives only in the result frame; an unbound
656
- -- strand is responder-minted). The responder cannot substitute a strand anyway: bound
657
- -- invites are pinned by Authorized's FI.StrandId check, unbound invites mint fresh.
658
- constraint PeerConsented check on insert (
659
- verify(digest('CadreControl.FormationUsage', 'consent',
660
- new.Token, new.UsageStampId, new.PeerKey, new.Disclosure),
661
- new.PeerSig, new.PeerKey, 'ed25519')
662
- ),
663
- -- Context here is only what the approver-side rules read: Now for expiry, and the
664
- -- ValidationKey/ValidationSignature pair for the 'vouch' check in Authorized. The joiner's
665
- -- signature is NOT context — it lives on the row (PeerSig, verified by PeerConsented above).
666
- ) with context (Now datetime, ValidationKey text null, ValidationSignature text null);
667
-
668
- -- Append-only retirement record for the one-off StampId nonces of removed OwnerKey /
669
- -- CadrePeer / ValidationKey / Strand rows. Without it a removal was undoable: the add
670
- -- approval is a signature over
671
- -- (row key, stamp) that never expires, and deleting the row freed the stamp, so anyone who
672
- -- kept a copy of the approval could resurrect the row.
673
- -- Each tombstone also records WHICH row was retired (RowKey below): the consent branch of
674
- -- Strand.AuthorizedInsert reads it to refuse consent-seating any strand id that has ever
675
- -- been removed, whichever way it was seated.
676
- -- NOTE: the guarded tables' NotRevoked CHECK runs against locally visible rows, so a node
677
- -- that has not yet converged on a Revocation row can still accept the replayed add, and the
678
- -- resurrected row coexists with the tombstone after merge — same class as the MinOneOwner
679
- -- note on OwnerKey. The read-side mitigation is ControlDatabase.queryCadrePeers, which
680
- -- drops any CadrePeer row whose StampId appears here; every membership reader inherits it.
681
- -- NOTE: append-only, so this table only ever grows. Every append is owner-signed
682
- -- (Authorized below), so the growth surface is the owner keys, not every writer that can
683
- -- reach the control database. Cadres are a handful of peers and owner rotation is rare, so
684
- -- unbounded growth is fine today; revisit if either changes.
685
- -- A tombstone committed while the node was alone is local-only (never broadcast); the
686
- -- ReissuedAt counter below exists so an owner can re-write — and therefore re-broadcast —
687
- -- such a tombstone once peers are reachable, without changing what it means.
688
- table Revocation (
689
- TableName text, -- 'OwnerKey' | 'CadrePeer' | 'ValidationKey' | 'Strand' | 'DeviceToken' (confined by RowIsGone below)
690
- RowKey text not null, -- primary key of the removed row: OwnerKey.Key / ValidationKey.Key /
691
- -- CadrePeer.PeerId / DeviceToken.PeerId / Strand.Id
692
- -- Every guarded table's RevocationRecorded CHECK requires the
693
- -- accompanying tombstone to carry the removed row's key here, so
694
- -- a REMOVAL cannot file a misnamed tombstone. A tombstone with no
695
- -- accompanying delete is not covered by that (see Authorized).
696
- StampId text, -- the retired nonce
697
- -- Bumped by an owner-signed re-issue so a tombstone written while the node was alone
698
- -- (committed local-only, never broadcast) can be re-written and therefore re-broadcast
699
- -- on cohort growth. Carries NO semantics: nothing reads it, and retirement is decided
700
- -- by the row's existence, not by this value.
701
- ReissuedAt integer not null default 0,
702
- -- Keyed on the stamp, not the RowKey: stamps are unique per row incarnation, and one
703
- -- name may legitimately carry several tombstones over its life (seat -> delete ->
704
- -- owner re-seat -> delete).
705
- primary key (TableName, StampId),
706
- -- Retirement is permanent: a tombstone may never be withdrawn.
707
- constraint NoDelete check on delete (false),
708
- -- Pinned at 0 on insert so an owner cannot seat a tombstone at a saturated counter and
709
- -- thereby freeze its own later re-issues (ReissueOnly below). Deliberately NOT folded
710
- -- into the Authorized digest: the value is fixed by this rule, so signing over it would
711
- -- only widen the signed surface for no gain, and would invalidate every existing
712
- -- insert-side digest.
713
- constraint FreshTombstone check on insert (new.ReissuedAt = 0),
714
- -- A re-issue may move NOTHING but the counter, and only upward. Without the identity
715
- -- clause an "update" would be a way to re-point a tombstone at a different row,
716
- -- restoring exactly the replay this table exists to stop.
717
- constraint ReissueOnly check on update (
718
- new.TableName = old.TableName and new.RowKey = old.RowKey
719
- and new.StampId = old.StampId and new.ReissuedAt > old.ReissuedAt
720
- ),
721
- -- A stamp may only be retired once its row is actually gone. Deferred (subquery), so a
722
- -- delete in the same transaction has already landed. Retiring a stamp that never
723
- -- existed is permitted and harmless: a stamp carries 128 bits of CSPRNG output
724
- -- (control-database.ts:generateStampId — 16 bytes of sha256(PeerId) followed by 16
725
- -- random bytes), so a future legitimate one cannot be guessed and pre-planted.
726
- -- Deliberately keyed on the STAMP only, not RowKey: checking "no live row named
727
- -- new.RowKey" breaks under out-of-order convergence — a peer removed (tombstone names
728
- -- it, stamp1) then re-admitted (stamp2) would leave a node that converges on the
729
- -- re-add FIRST rejecting the stamp1 tombstone forever, never learning that stamp is
730
- -- retired. The stamp-only form passes there (stamp1 is not live).
731
- constraint RowIsGone check on insert (
732
- (new.TableName = 'OwnerKey' and not exists (select 1 from OwnerKey K where K.StampId = new.StampId))
733
- or (new.TableName = 'CadrePeer' and not exists (select 1 from CadrePeer P where P.StampId = new.StampId))
734
- or (new.TableName = 'ValidationKey' and not exists (select 1 from ValidationKey V where V.StampId = new.StampId))
735
- or (new.TableName = 'Strand' and not exists (select 1 from Strand S where S.StampId = new.StampId))
736
- or (new.TableName = 'DeviceToken' and not exists (select 1 from DeviceToken D where D.StampId = new.StampId))
737
- ),
738
- -- Appending a tombstone is an OWNER action, like every other write in this schema.
739
- -- Ungated, any writer that could reach the control database could retire a stamp:
740
- -- filing one against a CadrePeer row not visible locally (RowIsGone reads LOCALLY
741
- -- VISIBLE rows — see the convergence note above) evicted that peer party-wide AND
742
- -- permanently blocked the owner's own later re-admission of it (NotRevoked), and
743
- -- filing arbitrary stamps flooded a table whose rows never go away.
744
- -- The digest binds the WHOLE row (TableName, RowKey, StampId); the 'CadreControl.Revocation'
745
- -- domain tag keeps it disjoint from every other rule — including the
746
- -- 'CadreControl.CadrePeer' 'remove' digest the same owner signs in the SAME
747
- -- transaction for the delete this tombstone accompanies.
748
- -- RowKey is not cross-checked HERE against the retired row: positively binding it (e.g.
749
- -- requiring a committed.Strand row carrying the pair) would reject a tombstone re-issued
750
- -- in a LATER transaction — which the delete-while-alone durability path
751
- -- (docs/architecture.md) plans to do — and reject it outright on a node that never
752
- -- converged on the removed row. The binding lives on the other side instead: each
753
- -- guarded table's RevocationRecorded requires the tombstone accompanying a DELETE to
754
- -- name that row's key, which the re-issue path never trips (it files no delete).
755
- -- Residual: a STANDALONE tombstone — owner-signed, naming a stamp no live row holds —
756
- -- is still accepted, so an owner can name an id that never existed and thereby
757
- -- permanently foreclose CONSENT-seating of that id (Strand.AuthorizedInsert).
758
- -- Owner-only, the owner already controls invite issuance, and unbound ids are 128
759
- -- random bytes minted at redemption — no escalation (same reasoning as the pre-plant
760
- -- note on RowIsGone).
761
- -- Reads LIVE OwnerKey (like ValidationKey / Strand / CadrePeer / DeviceToken), not
762
- -- committed.OwnerKey. The one difference that makes: a founding transaction on an
763
- -- owner-less database could seat an owner key and file a tombstone in one go. An
764
- -- owner-less control database is already a total loss, and a second transaction
765
- -- achieves the same thing under committed.*, so consistency with the siblings is
766
- -- worth more than the distinction — do not "tighten" this without a reason other
767
- -- than that one.
768
- -- RowIsGone and NoDelete are unchanged: they guard an owner retiring a stamp early
769
- -- or withdrawing one later, which authorization does not cover.
770
- constraint Authorized check on insert (
771
- exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.Revocation', 'remove', new.TableName, new.RowKey, new.StampId), context.Signature, A.Key, 'ed25519'))
772
- ),
773
- -- A re-issue is an OWNER action, like the original append. Distinct 'reissue' action
774
- -- tag, so an append approval can never be replayed as a re-issue and vice versa. The
775
- -- digest adds ReissuedAt so a captured re-issue signature cannot bump the counter to
776
- -- any other value. Reads LIVE OwnerKey, matching Authorized above.
777
- constraint AuthorizedReissue check on update (
778
- exists (select 1 from OwnerKey A where A.Key = context.OwnerKey
779
- and verify(digest('CadreControl.Revocation', 'reissue',
780
- new.TableName, new.RowKey, new.StampId, cast(new.ReissuedAt as text)),
781
- context.Signature, A.Key, 'ed25519'))
782
- )
783
- ) with context (OwnerKey text, Signature text);
784
- }
785
-
12
+ export const CONTROL_SCHEMA = `-- This manages a Sereus party's cadre, or set of nodes, and their participation in strands (networks)
13
+ declare schema CadreControl {
14
+ -- A key that can authorize various control changes
15
+ table OwnerKey (
16
+ Key text primary key,
17
+ StampId text not null unique, -- per-row authorization nonce, bound into the signed digests below.
18
+ -- \`unique\` holds over LIVE rows only; a removed row's stamp is retired
19
+ -- permanently into Revocation (NotRevoked below), so the never-expiring
20
+ -- enrollment signature cannot resurrect a removed owner.
21
+ -- A removed row's StampId is retired into Revocation, and this refuses any insert
22
+ -- naming a retired stamp: the approval that seated this row can never re-seat it after
23
+ -- removal. \`unique\` alone did not do this — it only holds over LIVE rows, so a delete
24
+ -- freed the stamp and the original never-expiring signature verified again. A
25
+ -- legitimate re-add mints a FRESH StampId and a fresh signature, so it is unaffected.
26
+ constraint NotRevoked check on insert (
27
+ not exists (select 1 from Revocation R where R.TableName = 'OwnerKey' and R.StampId = new.StampId)
28
+ ),
29
+ -- Retirement is MANDATORY: a delete must carry the matching Revocation row in the same
30
+ -- transaction, or the stamp would free up again. Deferred (subquery), so the sibling
31
+ -- insert is visible at commit regardless of statement order. The tombstone must also
32
+ -- name the removed ROW, not just its stamp — see Revocation.RowKey.
33
+ constraint RevocationRecorded check on delete (
34
+ exists (select 1 from Revocation R where R.TableName = 'OwnerKey' and R.StampId = old.StampId and R.RowKey = old.Key)
35
+ ),
36
+ -- A party must never lose its last owner key: every other CadreControl table's
37
+ -- CHECK requires an OwnerKey row, so an owner-less control database can never
38
+ -- authorize anything again — emptying this table is a permanent denial of the
39
+ -- party's control plane. Deferred (subquery), so the count is the POST-delete count.
40
+ -- NOTE: this is a per-transaction check against locally visible rows. Two
41
+ -- partitioned nodes that concurrently remove different owners can each see a
42
+ -- survivor and still converge to zero; if partitioned rotation ever becomes a real
43
+ -- workflow, the floor needs a cross-node guard, not a local count.
44
+ constraint MinOneOwner check on delete (
45
+ (select count(1) from OwnerKey) >= 1
46
+ ),
47
+ -- Enrollment is insert + delete only; rotation is add-then-remove. No writer
48
+ -- updates an OwnerKey row, and a self-rotation branch would double as a sole-owner
49
+ -- takeover: a deferred post-image count is also true of an update that re-points
50
+ -- the only row at an attacker key. Mirrors Strand.Manager.NoUpdate.
51
+ constraint NoUpdate check on update (false),
52
+ -- NOTE: the domain tag scopes an approval to a table and an action, not to a PARTY. Two
53
+ -- parties that share an owner key would accept each other's approvals. Fine today (each
54
+ -- party has its own owner key); if shared-owner multi-party ever ships, bind a party
55
+ -- identity row into these digests.
56
+ constraint Authorized check on insert, delete (
57
+ -- Every authorizer is read from the PRE-transaction snapshot (committed.*):
58
+ -- this CHECK auto-defers to commit (it has a subquery), by which point the row
59
+ -- being inserted — and any sibling inserted alongside it — is live, so a plain
60
+ -- from-OwnerKey would let a row authorize itself, or two strangers seat each
61
+ -- other. committed.* excludes both, stating the rule directly: the authorizer
62
+ -- must have existed BEFORE this transaction.
63
+
64
+ -- Every signed digest below (and in every other CadreControl table) leads with two
65
+ -- fixed literals — a 'CadreControl.<Table>' domain tag and an 'add'/'remove'/
66
+ -- 'vouch'/'publish' action tag (cadre-core control-authorization.ts) — so an
67
+ -- approval verifies ONLY against the one rule it was minted for. Without them,
68
+ -- e.g. a ValidationKey enrollment approval covered the identical bytes as an
69
+ -- OwnerKey enrollment: a narrow grant that doubled as full ownership.
70
+
71
+ -- Bootstrap: the FOUNDING transaction — one whose pre-transaction owner set is
72
+ -- empty — needs no authorization, and every row it inserts rides this branch
73
+ -- (whoever writes it already owns the party outright, so a co-founder row grants
74
+ -- nothing a single row would not). Gated on the PRE-transaction count, so a
75
+ -- same-transaction swap of the sole owner (delete the founder, insert the
76
+ -- attacker — post-image count still 1) cannot ride it.
77
+ (old.Key is null and (select count(1) from committed.OwnerKey) = 0)
78
+
79
+ -- or a pre-existing owner authorizes by signing over THIS row (Key, StampId)
80
+ or (old.Key is null and exists (select 1 from committed.OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.OwnerKey', 'add', new.Key, new.StampId), context.Signature, A.Key, 'ed25519')))
81
+
82
+ -- or a removal authorized by ANOTHER pre-existing owner over the DISTINCT
83
+ -- 'remove'-tagged digest bound to the stored row's StampId, so an enrollment
84
+ -- signature can never be replayed as a removal (cf. CadrePeer.AuthorizedDelete).
85
+ -- A.Key <> old.Key: an owner cannot sign its own removal (no self-resignation).
86
+ or (new.Key is null and exists (select 1 from committed.OwnerKey A where A.Key = context.OwnerKey and A.Key <> old.Key and verify(digest('CadreControl.OwnerKey', 'remove', old.Key, old.StampId), context.Signature, A.Key, 'ed25519')))
87
+ )
88
+ ) with context (OwnerKey text null, Signature text null);
89
+
90
+ -- A key that can validate a strand formation disclosure
91
+ table ValidationKey (
92
+ Key text primary key,
93
+ StampId text not null unique, -- single-use authorization nonce (anti-replay).
94
+ -- \`unique\` holds over LIVE rows only; a removed row's stamp is retired
95
+ -- permanently into Revocation (NotRevoked below), so the never-expiring
96
+ -- enrollment signature cannot resurrect a removed key.
97
+ -- A removed row's StampId is retired into Revocation, and this refuses any insert naming
98
+ -- a retired stamp: the approval that seated this row can never re-seat it after removal.
99
+ -- Same rationale as OwnerKey.NotRevoked, stated in full there.
100
+ -- NOTE: this table gained a consumer — FormationUsage.Authorized reads it to gate redemption of
101
+ -- a ValidationUrl invite — so the convergence gap noted on Strand.StampId now has teeth here: on
102
+ -- a node that has not yet seen the tombstone, a replayed enrollment re-seats the key and it can
103
+ -- approve redemptions there until the Revocation row arrives. Still no per-request READ surface to
104
+ -- filter (the gate is a write-time CHECK), so there is nothing to add today; if one appears, filter
105
+ -- retired stamps there.
106
+ constraint NotRevoked check on insert (
107
+ not exists (select 1 from Revocation R where R.TableName = 'ValidationKey' and R.StampId = new.StampId)
108
+ ),
109
+ -- Retirement is MANDATORY: a delete must carry the matching Revocation row in the same
110
+ -- transaction, or the stamp would free up again. Deferred (subquery), so the sibling
111
+ -- insert is visible at commit regardless of statement order. The tombstone must also
112
+ -- name the removed ROW, not just its stamp — see Revocation.RowKey.
113
+ constraint RevocationRecorded check on delete (
114
+ exists (select 1 from Revocation R where R.TableName = 'ValidationKey' and R.StampId = old.StampId and R.RowKey = old.Key)
115
+ ),
116
+ -- Enrollment is insert + delete only; rotation is add-then-remove. No writer in the repo
117
+ -- updates a ValidationKey row. Required because the AuthorizedInsert / AuthorizedDelete
118
+ -- pair below covers neither update — and a bare \`check\`, which is what this rule used to
119
+ -- be, covers insert+update but NOT delete. Mirrors OwnerKey.NoUpdate.
120
+ constraint NoUpdate check on update (false),
121
+ constraint AuthorizedInsert check on insert (
122
+ -- Owners authorize by signing over THIS row (Key, StampId); single-use via unique StampId
123
+ exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.ValidationKey', 'add', new.Key, new.StampId), context.Signature, A.Key, 'ed25519'))
124
+ ),
125
+ constraint AuthorizedDelete check on delete (
126
+ -- Delete is authorized by a signature over the DISTINCT 'remove'-tagged digest bound to
127
+ -- the STORED row (Key, StampId), so an enrollment approval — which never expires — can
128
+ -- never be replayed as a removal. Same shape as CadrePeer.AuthorizedDelete.
129
+ exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.ValidationKey', 'remove', old.Key, old.StampId), context.Signature, A.Key, 'ed25519'))
130
+
131
+ -- or REAP: a COMMITTED tombstone already retires this exact row incarnation, so a node
132
+ -- that was offline at removal time may delete the stale row locally with no owner key.
133
+ -- Why committed.* and why the stamp must be bound: stated in full on
134
+ -- CadrePeer.AuthorizedDelete.
135
+ or exists (select 1 from committed.Revocation R where R.TableName = 'ValidationKey' and R.RowKey = old.Key and R.StampId = old.StampId)
136
+ )
137
+ ) with context (OwnerKey text, Signature text);
138
+
139
+ -- A network of members sharing an sApp database, each contributing peer nodes (their cadre) to the overall cohort
140
+ -- Cadre peers should participate in each of these strands
141
+ table Strand (
142
+ Id text primary key, -- UUID
143
+ MemberPrivateKey text null unique, -- Our private key as a member of this strand
144
+ Type text, -- Types: 'o' = Open, 'c' = Closed -- Open can still control writes in the sApp, but only Closed controls reads
145
+ StampId text not null unique, -- single-use authorization nonce (anti-replay).
146
+ -- \`unique\` holds over LIVE rows only; a removed row's stamp is retired
147
+ -- permanently into Revocation (NotRevoked below).
148
+ -- NOTE: NotRevoked is a per-transaction check against LOCALLY VISIBLE rows, so a node that
149
+ -- has not yet converged on the Revocation row can still accept a replayed add and end up
150
+ -- holding the resurrected strand alongside its tombstone. CadrePeer has a read-side
151
+ -- mitigation for this class (ControlDatabase.queryCadrePeers drops rows whose stamp is
152
+ -- retired, so every membership reader inherits it); Strand and ValidationKey deliberately
153
+ -- do NOT, because neither has a
154
+ -- per-request authorization surface today. If one ever gains one, filter it there.
155
+ constraint NotRevoked check on insert (
156
+ not exists (select 1 from Revocation R where R.TableName = 'Strand' and R.StampId = new.StampId)
157
+ ),
158
+ -- Retirement is MANDATORY: a delete must carry the matching Revocation row in the same
159
+ -- transaction, or the stamp would free up again. Deferred (subquery), so the sibling
160
+ -- insert is visible at commit regardless of statement order. The tombstone must also
161
+ -- name the removed ROW, not just its stamp — see Revocation.RowKey. That is what makes
162
+ -- the consent branch of AuthorizedInsert below trustworthy: a removal cannot file a
163
+ -- tombstone that omits or misnames the id it retires.
164
+ constraint RevocationRecorded check on delete (
165
+ exists (select 1 from Revocation R where R.TableName = 'Strand' and R.StampId = old.StampId and R.RowKey = old.Id)
166
+ ),
167
+ -- Strand rows are insert + delete only. Required: the consent branch of AuthorizedInsert
168
+ -- below authorizes by the EXISTENCE of a FormationUsage row naming (new.Id, new.StampId)
169
+ -- and carries no signature, so while this rule was a bare \`check\` (insert+update) it also
170
+ -- said "anyone may REWRITE any consent-formed strand, unsigned" — flipping Type to 'o' and
171
+ -- nulling MemberPrivateKey destroys the party's own membership key for that network in place.
172
+ -- Mirrors OwnerKey.NoUpdate / FormationInvite.Immutable.
173
+ constraint NoUpdate check on update (false),
174
+ constraint AuthorizedInsert check on insert (
175
+ -- Authorized by an owner signing over THIS row (Id, Type, MemberPrivateKey, StampId); single-use via unique StampId
176
+ exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.Strand', 'add', new.Id, new.Type, coalesce(new.MemberPrivateKey, ''), new.StampId), context.Signature, A.Key, 'ed25519'))
177
+
178
+ -- or authorized WITHOUT a signature by a redemption record naming THIS EXACT ROW: a
179
+ -- FormationUsage row carrying both this strand's Id and its one-off StampId
180
+ -- (control-database.ts:redeemInvitation writes the pair in one transaction; both
181
+ -- CHECKs defer, so each sees the other's row at commit).
182
+ --
183
+ -- Matching the STAMP, not the id alone, is what makes a removal stick. FormationUsage
184
+ -- is append-only, so a redemption record outlives the strand it formed forever; keyed
185
+ -- on the id alone it re-authorized ANY later insert of that id, so after a legitimate
186
+ -- owner-signed, tombstoned removal any writer could re-seat the strand unsigned with a
187
+ -- fresh StampId (sidestepping NotRevoked, which retires only the removed row's stamp)
188
+ -- and an ATTACKER-CHOSEN MemberPrivateKey — the party's own secret for that network.
189
+ -- Bound to the stamp, the removal retires that stamp into Revocation and NotRevoked
190
+ -- closes this consent record's branch permanently.
191
+ --
192
+ -- Beyond the stamp match, the branch is deliberately NARROW — each clause closes one door:
193
+ -- * Type = 'o' / MemberPrivateKey null — a consent-seated strand looks exactly like
194
+ -- what the unbound redemption path writes (open, keyless). A closed strand carrying
195
+ -- a caller-chosen member key (the party's own read-gating secret for that network)
196
+ -- has no legitimate producer. MemberKeyClosedOnly already implies the second clause
197
+ -- from the first today; both are stated so this rule does not silently depend on
198
+ -- another constraint staying as it is.
199
+ -- * the inner FI.StrandId is null — only an UNBOUND invite may seat a strand at all.
200
+ -- A bound invite's host strand is owner-provisioned up front; letting the invite
201
+ -- consent-seat that id would allow an open, keyless downgrade of the real (possibly
202
+ -- closed, key-bearing) host row on a node where it has not converged yet. The bound
203
+ -- path records usage only (resolveStrand reports an absent host as missing and the
204
+ -- formation manager rejects cleanly).
205
+ -- * the not-exists over OTHER stamps — a strand id may be consent-seated once, EVER.
206
+ -- FormationUsage is append-only, so the first redemption's record survives a later
207
+ -- owner-signed removal and permanently forecloses a second consent-seating of the
208
+ -- id (a spare use of the same token minted a new stamp and rode this branch to
209
+ -- re-seat the id with an attacker-chosen row). Re-joining a removed id is
210
+ -- owner-gated: the owner re-seats it signed (fresh stamp, fresh signature) and
211
+ -- issues a BOUND invite the returning party records consent against — the id is
212
+ -- not blacklisted, only its unsigned re-seat is. This cannot be weaponised to
213
+ -- pre-block a legitimate id: StrandExists refuses a usage row naming a strand that
214
+ -- does not exist, so no usage row can be pre-planted for an unseated id — and ids
215
+ -- are 128 random bytes anyway.
216
+ -- * the not-exists over Revocation — a strand id may be consent-seated once EVER
217
+ -- and never after ANY removal of that id, however it was seated. The usage-row
218
+ -- rule above binds only ids that ever carried a consent record; an id seated
219
+ -- purely owner-signed (insertStrand, no usage row) leaves nothing behind on
220
+ -- removal, so its removal needs its own trace. Every removal files a Revocation
221
+ -- row naming the removed row's key (RowKey), and this clause refuses any id
222
+ -- that has ever been tombstoned. RevocationRecorded above makes that trace
223
+ -- mandatory AND correctly named, so no removal can slip past this clause. The
224
+ -- usage-row clause is arguably subsumed by this one (a usage row implies the
225
+ -- strand row existed, and its removal must file a tombstone), but it reaches
226
+ -- the same conclusion without depending on Revocation at all; both are stated,
227
+ -- per the style note above. The owner-signed branch is deliberately unaffected,
228
+ -- so re-join stays owner-gated exactly as described.
229
+ -- What is NOT schema-verified is a STANDALONE tombstone (one with no
230
+ -- accompanying delete — see Revocation.Authorized): an owner can file one
231
+ -- naming an id that never existed and thereby permanently foreclose
232
+ -- consent-seating of that id — owner-only, the owner already controls invite
233
+ -- issuance, and unbound ids are 128 random bytes minted at redemption, so no
234
+ -- escalation (same reasoning as the pre-plant note above).
235
+ -- NOTE: like NotRevoked above, this is a write-time check against LOCALLY
236
+ -- VISIBLE rows — a node that has not yet converged on the Revocation row still
237
+ -- accepts the re-seat. Fails in the safe direction: the tombstone wins after
238
+ -- merge for the STAMP, and the resurrected row coexists with it (same class as
239
+ -- the StampId note above).
240
+ or (
241
+ new.Type = 'o'
242
+ and new.MemberPrivateKey is null
243
+ and exists (
244
+ select 1 from FormationUsage FU
245
+ where FU.StrandId = new.Id
246
+ and FU.StrandStampId = new.StampId
247
+ and exists (select 1 from FormationInvite FI where FI.Token = FU.Token and FI.StrandId is null)
248
+ )
249
+ and not exists (
250
+ select 1 from FormationUsage FU2
251
+ where FU2.StrandId = new.Id and FU2.StrandStampId <> new.StampId
252
+ )
253
+ and not exists (
254
+ select 1 from Revocation R
255
+ where R.TableName = 'Strand' and R.RowKey = new.Id
256
+ )
257
+ )
258
+ ),
259
+ constraint AuthorizedDelete check on delete (
260
+ -- Delete is authorized by a signature over the DISTINCT 'remove'-tagged digest bound to
261
+ -- the STORED row (Id, StampId), so the add approval — which never expires — can never be
262
+ -- replayed as a removal. Note the consent branch above is deliberately NOT mirrored
263
+ -- here: an invitation authorizes forming a strand, never destroying one.
264
+ --
265
+ -- DELIBERATELY no REAP branch (cf. CadrePeer.AuthorizedDelete): this row carries
266
+ -- MemberPrivateKey — the party's own membership secret for that network, stored nowhere
267
+ -- else — so this is the one guarded delete whose effect is unrecoverable, and a stale
268
+ -- row is already inert for the purpose a tombstone serves (the consent branch of
269
+ -- AuthorizedInsert above refuses any id ever tombstoned). Do not "fix" the asymmetry;
270
+ -- tickets/backlog/debt-strand-tombstone-reap.md owns any future change.
271
+ exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.Strand', 'remove', old.Id, old.StampId), context.Signature, A.Key, 'ed25519'))
272
+ ),
273
+ constraint MemberKeyClosedOnly check (
274
+ -- An open strand ('o') has no membership gate, so it must not carry a
275
+ -- member key. A non-null MemberPrivateKey requires a closed strand ('c').
276
+ new.MemberPrivateKey is null or new.Type = 'c'
277
+ )
278
+ ) with context (OwnerKey text, Signature text);
279
+
280
+ -- A peer (node) that is part of the cadre, carrying a self-published,
281
+ -- freshness-stamped, self-signed address record (see PeerAddressRecord in cadre-core).
282
+ -- The row IS the peer-address record: a resolver reads it, re-verifies Sig against
283
+ -- PublicKey, checks freshness (UpdatedAt) and trust, then dials the addrs.
284
+ table CadrePeer (
285
+ PeerId text primary key,
286
+ PublicKey text null, -- ed25519 (base64url) whose libp2p identity == PeerId (null if non-Ed25519)
287
+ Multiaddr text, -- comma-joined current addrs (signaling / p2p-circuit first)
288
+ UpdatedAt int null, -- epoch ms; strictly increases per self-update (replay/rollback guard)
289
+ Sig text null, -- ed25519 self-signature over the signed payload (base64url); null until self-published
290
+ StampId text not null unique, -- per-row authorization nonce, bound into the voucher/remove digests; rotates on
291
+ -- (re)insert. A removed row's stamp is retired permanently into Revocation
292
+ -- (NotRevoked below), so a captured admission approval cannot re-seat a removed peer.
293
+ VouchOwner text null, -- ed25519 (base64url) owner key that vouched this membership (== insert context.OwnerKey)
294
+ VouchSig text null, -- that owner's signature over the 'vouch'-tagged digest below (== insert context.Signature). A reader checks VouchOwner against its NODE-LOCAL trusted-owner anchor (not this replicated table, which a self-owner can pollute) to decide authorized membership. The domain/action tags keep this STORED, REPLICATED signature useless against every other rule (pre-tag it satisfied OwnerKey/ValidationKey inserts for Key = PeerId).
295
+ -- A removed row's StampId is retired into Revocation, and this refuses any insert
296
+ -- naming a retired stamp: the approval that seated this row can never re-seat it after
297
+ -- removal. \`unique\` alone did not do this — it only holds over LIVE rows, so a delete
298
+ -- freed the stamp and the original never-expiring signature verified again. A
299
+ -- legitimate re-add mints a FRESH StampId and a fresh signature, so it is unaffected.
300
+ constraint NotRevoked check on insert (
301
+ not exists (select 1 from Revocation R where R.TableName = 'CadrePeer' and R.StampId = new.StampId)
302
+ ),
303
+ -- Retirement is MANDATORY: a delete must carry the matching Revocation row in the same
304
+ -- transaction, or the stamp would free up again. Deferred (subquery), so the sibling
305
+ -- insert is visible at commit regardless of statement order. The tombstone must also
306
+ -- name the removed ROW, not just its stamp — see Revocation.RowKey.
307
+ constraint RevocationRecorded check on delete (
308
+ exists (select 1 from Revocation R where R.TableName = 'CadrePeer' and R.StampId = old.StampId and R.RowKey = old.PeerId)
309
+ ),
310
+ constraint AuthorizedInsert check on insert (
311
+ -- Authorized by an owner key, which vouches both membership and the
312
+ -- PublicKey<->PeerId binding (cadre-core derives PublicKey from PeerId before insert).
313
+ -- The digest binds PeerId + the single-use StampId nonce (two TEXT fields),
314
+ -- matching cadre-core peer-authorization.ts:cadrePeerVoucherDigest. The same
315
+ -- 'vouch'-tagged digest is DELIBERATELY shared with the owner branch of
316
+ -- AuthorizedUpdate below: both mean "this owner vouches this membership row".
317
+ exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.CadrePeer', 'vouch', new.PeerId, new.StampId), context.Signature, A.Key, 'ed25519'))
318
+ -- Persist the vouching (owner, signature) onto the row so a reader can later
319
+ -- re-check VouchOwner against its node-local anchor. The stored pair MUST equal
320
+ -- the pair the verify above validated, so a writer cannot store a voucher it did
321
+ -- not actually receive a signature for.
322
+ and new.VouchOwner = context.OwnerKey
323
+ and new.VouchSig = context.Signature
324
+ ),
325
+ constraint AuthorizedDelete check on delete (
326
+ -- Delete is authorized by a signature over the DISTINCT 'remove'-tagged digest bound
327
+ -- to the row's StampId (cadre-core peer-authorization.ts:cadrePeerRemoveDigest), so
328
+ -- the stored voucher (a signature over the 'vouch'-tagged digest) can NEVER be
329
+ -- replayed to authorize a delete. The remove signature rides in context and is never stored.
330
+ exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.CadrePeer', 'remove', old.PeerId, old.StampId), context.Signature, A.Key, 'ed25519'))
331
+
332
+ -- or REAP: this node already holds a COMMITTED tombstone retiring THIS EXACT row
333
+ -- incarnation, so the party owner has already authorized this row's removal and this
334
+ -- node is merely catching up. That is what lets a node which was offline at
335
+ -- revocation time delete the stale row locally, with no owner private key
336
+ -- (control-database.ts:reapRevokedRow). Same shape as the consent branch of
337
+ -- Strand.AuthorizedInsert: authorization by the EXISTENCE of a row rather than by a
338
+ -- signature — and that row is itself owner-signed (Revocation.Authorized), so this
339
+ -- widens WHO may execute a removal, never WHO may decide one. RevocationRecorded is
340
+ -- satisfied by the same committed tombstone, so a reap files no second one.
341
+ --
342
+ -- committed.Revocation, NOT Revocation — and the stamp clause — are both load-bearing:
343
+ -- * committed.* : this CHECK defers to commit (it has a subquery), by which point a
344
+ -- tombstone written in the SAME transaction is live. Reading plain Revocation
345
+ -- would therefore let deleteGuardedRow's own sibling tombstone satisfy this
346
+ -- branch, making the 'remove'-tagged delete signature above dead weight and
347
+ -- collapsing two domain-separated approvals into one. committed.* states the rule
348
+ -- exactly: the tombstone must have existed BEFORE this transaction.
349
+ -- * R.StampId = old.StampId : binds the ROW INCARNATION, not the name. One name may
350
+ -- legitimately carry several tombstones over its life (seat -> delete -> owner
351
+ -- re-seat -> delete). Without this clause a tombstone from a PREVIOUS incarnation
352
+ -- would authorize deleting the CURRENT row, which the owner never removed.
353
+ or exists (select 1 from committed.Revocation R where R.TableName = 'CadrePeer' and R.RowKey = old.PeerId and R.StampId = old.StampId)
354
+ ),
355
+ constraint AuthorizedUpdate check on update (
356
+ -- Peer self-updates its own addrs + freshness, signing with its OWN ed25519 key
357
+ -- (the key behind PeerId). PeerId/PublicKey/StampId/voucher are immutable on
358
+ -- self-update, UpdatedAt must strictly increase (replay guard), and Sig is verified
359
+ -- against the stored PublicKey over the same payload the publish path signs
360
+ -- (cadre-core peer-record.ts:peerRecordSignedPayload).
361
+ (
362
+ new.PeerId = old.PeerId
363
+ and new.PublicKey = old.PublicKey
364
+ and new.StampId = old.StampId
365
+ and new.VouchOwner = old.VouchOwner
366
+ and new.VouchSig = old.VouchSig
367
+ and new.UpdatedAt > coalesce(old.UpdatedAt, 0)
368
+ and verify(
369
+ digest('CadreControl.CadrePeer', 'publish', new.PeerId, new.Multiaddr, cast(new.UpdatedAt as text)),
370
+ new.Sig, new.PublicKey, 'ed25519')
371
+ )
372
+ -- or an owner re-authorizes (rotation / correction): re-vouches the row over
373
+ -- its current StampId, so the stored voucher is re-bound to the re-authorizing owner.
374
+ -- StampId is immutable here too: an update that rotated it would leave the OLD
375
+ -- stamp live-free and untombstoned (RevocationRecorded only retires the stamp a
376
+ -- delete carries), so the original admission approval — which is stored on the
377
+ -- replicated row as VouchSig — would resurrect the peer after a later removal.
378
+ -- Retiring a stamp is delete + Revocation row, never an update.
379
+ or (
380
+ new.StampId = old.StampId
381
+ and exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.CadrePeer', 'vouch', new.PeerId, new.StampId), context.Signature, A.Key, 'ed25519'))
382
+ and new.VouchOwner = context.OwnerKey
383
+ and new.VouchSig = context.Signature
384
+ )
385
+ )
386
+ ) with context (OwnerKey text null, Signature text);
387
+
388
+ -- A mobile cadre peer's platform push token (FCM/APNs), self-published so a
389
+ -- server peer can deliver a push-wake to a suspended app over the platform push
390
+ -- channel (a control-network dial cannot reach an OS-suspended process). The row
391
+ -- mirrors CadrePeer: a resolver re-verifies Sig against the CadrePeer.PublicKey for
392
+ -- this PeerId (see device-token.ts:deviceTokenSignedPayload), checks freshness, then
393
+ -- hands the token to the push sender.
394
+ table DeviceToken (
395
+ PeerId text primary key, -- the CadrePeer this token belongs to
396
+ Platform text not null, -- 'fcm' | 'apns'
397
+ Token text not null, -- opaque platform device/registration token
398
+ UpdatedAt int null, -- epoch ms; strictly increases per self-update (replay guard)
399
+ Sig text null, -- ed25519 self-sig over (PeerId|Platform|Token|UpdatedAt)
400
+ StampId text not null unique, -- single-use authorization nonce, bound into the add/remove digests;
401
+ -- rotates on (re)insert. A cleared token's stamp is retired permanently
402
+ -- into Revocation (NotRevoked below), so the approval that published a
403
+ -- token cannot re-seat it after the owner cleared it (logout).
404
+ -- A removed row's StampId is retired into Revocation, and this refuses any insert
405
+ -- naming a retired stamp: the approval that seated this row can never re-seat it after
406
+ -- the clear. \`unique\` alone did not do this — it only holds over LIVE rows, so a delete
407
+ -- freed the stamp and the original never-expiring signature verified again. A legitimate
408
+ -- re-register mints a FRESH StampId and a fresh signature, so it is unaffected. This
409
+ -- matters more here than the stored-approval case (CadrePeer.VouchSig): a resurrected
410
+ -- push token has NO freshness ceiling to retire it — cadre-node.ts:resolveDeviceToken
411
+ -- defaults maxAgeMs to infinity by design — so retirement is the only thing that sticks.
412
+ constraint NotRevoked check on insert (
413
+ not exists (select 1 from Revocation R where R.TableName = 'DeviceToken' and R.StampId = new.StampId)
414
+ ),
415
+ -- Retirement is MANDATORY: a delete must carry the matching Revocation row in the same
416
+ -- transaction, or the stamp would free up again. Deferred (subquery), so the sibling
417
+ -- insert is visible at commit regardless of statement order. The tombstone must also
418
+ -- name the removed ROW, not just its stamp — see Revocation.RowKey.
419
+ constraint RevocationRecorded check on delete (
420
+ exists (select 1 from Revocation R where R.TableName = 'DeviceToken' and R.StampId = old.StampId and R.RowKey = old.PeerId)
421
+ ),
422
+ constraint AuthorizedInsert check on insert (
423
+ -- Authorized by an owner key (membership vouch), exactly like CadrePeer.
424
+ -- The digest binds the WHOLE row behind the domain/action tags — every column,
425
+ -- with the nullable UpdatedAt/Sig signing as '' when absent (the shape
426
+ -- FormationInvite and Strand already use) — so a replayed approval can only ever
427
+ -- reproduce the row it approved, never one carrying attacker-chosen
428
+ -- Platform/Token/UpdatedAt; NotRevoked above then refuses even that exact row
429
+ -- once it has been cleared. The DISTINCT 'remove'-tagged digest below keeps a
430
+ -- captured insert approval from doubling as a delete (and vice versa).
431
+ -- Mirrors cadre-core peer-authorization.ts:deviceTokenAddDigest.
432
+ exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(
433
+ digest(
434
+ 'CadreControl.DeviceToken', 'add',
435
+ new.PeerId,
436
+ new.Platform,
437
+ new.Token,
438
+ coalesce(cast(new.UpdatedAt as text), ''),
439
+ coalesce(new.Sig, ''),
440
+ new.StampId
441
+ ),
442
+ context.Signature, A.Key, 'ed25519'))
443
+ ),
444
+ constraint AuthorizedDelete check on delete (
445
+ -- Bound to the STORED row's (PeerId, StampId) — cadre-core
446
+ -- peer-authorization.ts:deviceTokenRemoveDigest — so the never-expiring insert
447
+ -- approval can never be replayed as a clear, and a captured clear approval is
448
+ -- dead once the stamp it names is retired.
449
+ exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.DeviceToken', 'remove', old.PeerId, old.StampId), context.Signature, A.Key, 'ed25519'))
450
+
451
+ -- or REAP: a COMMITTED tombstone already retires this exact row incarnation, so a node
452
+ -- that was offline at removal time may delete the stale row locally with no owner key.
453
+ -- Why committed.* and why the stamp must be bound: stated in full on
454
+ -- CadrePeer.AuthorizedDelete.
455
+ or exists (select 1 from committed.Revocation R where R.TableName = 'DeviceToken' and R.RowKey = old.PeerId and R.StampId = old.StampId)
456
+ ),
457
+ constraint AuthorizedUpdate check on update (
458
+ -- Peer self-updates its own token, signing with its OWN ed25519 key (the key
459
+ -- behind PeerId). PeerId and StampId are immutable, UpdatedAt must strictly
460
+ -- increase (replay guard), and Sig is verified against the stored
461
+ -- CadrePeer.PublicKey over the signed payload (cadre-core
462
+ -- device-token.ts:deviceTokenSignedPayload). Platform and Token may change on
463
+ -- self-update (platform switch / reinstall / rotation).
464
+ -- StampId is immutable for the reason CadrePeer spells out: an update that
465
+ -- rotated it would leave the OLD stamp live-free and untombstoned, so retiring a
466
+ -- stamp stays delete + Revocation row, never an update.
467
+ -- This is the ONLY branch: there is deliberately NO owner re-touch. The branch
468
+ -- that used to sit beside it verified an owner signature over the peer id alone
469
+ -- and sat OUTSIDE the monotonicity requirement, so one captured owner approval
470
+ -- rewrote Platform/Token and rolled UpdatedAt BACKWARDS — and seating a
471
+ -- far-future UpdatedAt wedged the peer's own self-updates permanently. No writer
472
+ -- used it. An owner that must correct a row deletes it (retiring the stamp) and
473
+ -- inserts a fresh one, the same path it already takes for CadrePeer.
474
+ new.PeerId = old.PeerId
475
+ and new.StampId = old.StampId
476
+ and new.UpdatedAt > coalesce(old.UpdatedAt, 0)
477
+ and exists (select 1 from CadrePeer P where P.PeerId = new.PeerId and verify(
478
+ digest('CadreControl.DeviceToken', 'publish', new.PeerId, new.Platform, new.Token, cast(new.UpdatedAt as text)),
479
+ new.Sig, P.PublicKey, 'ed25519'))
480
+ )
481
+ ) with context (OwnerKey text null, Signature text);
482
+
483
+ -- An open invitation to form a strand with this party
484
+ table FormationInvite (
485
+ Token text primary key, -- Just a random string
486
+ sAppId text, -- The app for the strand that will be formed
487
+ ExpiresAt datetime null,
488
+ TotalUses int null check (TotalUses >= 0),
489
+ ValidationUrl text null, -- Web hook - send disclosure, IP address...
490
+ StrandId text null, -- Host strand this invite binds to (provision-then-record). Non-null => the responder records consent against this pre-existing strand; null => legacy responder-provisions path.
491
+ StampId text not null unique, -- single-use authorization nonce (anti-replay)
492
+ constraint AuthorizedInsert check on insert (
493
+ -- Authorized by an owner signing over THIS row
494
+ -- (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId); single-use via unique StampId.
495
+ -- Nullable bound fields (ExpiresAt/TotalUses/ValidationUrl/StrandId) sign as '' when absent.
496
+ -- The 'remove'-tagged AuthorizedDelete digest below is DISTINCT, so a captured
497
+ -- insert approval can never be replayed to revoke-then-reissue an invite (or vice versa).
498
+ exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(
499
+ digest(
500
+ 'CadreControl.FormationInvite', 'add',
501
+ new.Token,
502
+ new.sAppId,
503
+ coalesce(cast(new.ExpiresAt as text), ''),
504
+ coalesce(cast(new.TotalUses as text), ''),
505
+ coalesce(new.ValidationUrl, ''),
506
+ coalesce(new.StrandId, ''),
507
+ new.StampId
508
+ ),
509
+ context.Signature, A.Key, 'ed25519'))
510
+ ),
511
+ constraint AuthorizedDelete check on delete (
512
+ exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(
513
+ digest(
514
+ 'CadreControl.FormationInvite', 'remove',
515
+ old.Token,
516
+ old.sAppId,
517
+ coalesce(cast(old.ExpiresAt as text), ''),
518
+ coalesce(cast(old.TotalUses as text), ''),
519
+ coalesce(old.ValidationUrl, ''),
520
+ coalesce(old.StrandId, ''),
521
+ old.StampId
522
+ ),
523
+ context.Signature, A.Key, 'ed25519'))
524
+ ),
525
+ -- Invites are insert/delete only: forbid in-place mutation so the row-bound
526
+ -- AuthorizedInsert signature cannot be sidestepped by updating consent
527
+ -- parameters (TotalUses/ExpiresAt/...) after a legitimate insert. A bare check
528
+ -- defaults to insert+update; the checks on insert / on delete above exclude update,
529
+ -- so this guard is required. Mirrors FormationUsage.InsertOnly.
530
+ constraint Immutable check on update (false)
531
+ ) with context (OwnerKey text, Signature text);
532
+
533
+ table FormationUsage (
534
+ Token text not null, -- the FormationInvite this acceptance redeems. Stated explicitly because
535
+ -- Token is no longer a primary-key prefix; Authorized below would refuse a
536
+ -- null anyway (null = null is never true, so its FormationInvite
537
+ -- exists-clause cannot match), but the column carries the invariant rather
538
+ -- than leaving it to a CHECK.
539
+ UsageStampId text not null, -- single-use nonce for THIS redemption, and this table's PRIMARY KEY.
540
+ -- The JOINING peer mints it and sends it in its contact message, having
541
+ -- signed it into its own 'consent' digest (PeerConsented below); the
542
+ -- responder inserts that same value and asks the approver about it, so it
543
+ -- is bound into the 'vouch' digest below too and one sign-off is spendable
544
+ -- exactly once. Neither side can swap it: a substituted nonce fails the
545
+ -- joiner's consent signature.
546
+ -- As the row key, a verbatim replay of an approval collides on the
547
+ -- primary key locally instead of on a column \`unique\`. Unlike every other
548
+ -- StampId in this schema, this table is append-only (InsertOnly below),
549
+ -- so the nonce is never freed by a delete: the key alone makes it
550
+ -- permanent and there is deliberately NO NotRevoked / RevocationRecorded
551
+ -- pair here — do not go looking for the missing one.
552
+ -- NOTE: the key is evaluated against LOCALLY VISIBLE rows, so two nodes
553
+ -- that have not yet converged could each admit the same nonce and both
554
+ -- rows survive the merge. Same class as the NotRevoked convergence notes
555
+ -- on OwnerKey / Strand / ValidationKey; the digest binding still holds on
556
+ -- both nodes, so the outcome is a duplicated audit row, never a join
557
+ -- nobody approved.
558
+ PeerKey text not null, -- the joining peer's own ed25519 public key (base64url, 32 bytes), bound
559
+ -- into BOTH the approver's 'vouch' digest and the joiner's 'consent'
560
+ -- digest below, so an approval cannot be re-filed under a different
561
+ -- joiner's name and the named joiner provably agreed (PeerConsented).
562
+ -- The key IS the joiner's identity — its libp2p peer id is derivable
563
+ -- (identity multihash of these bytes), never stored here.
564
+ PeerSig text not null, -- the joining peer's signature over the 'consent' digest — see
565
+ -- PeerConsented below for what it proves and why it is stored on the row.
566
+ Disclosure text,
567
+ StrandId text,
568
+ StrandStampId text not null, -- the Strand.StampId this consent record authorizes. Consent is
569
+ -- bound to ONE strand ROW, not to a strand id forever — see
570
+ -- Strand.AuthorizedInsert's consent branch for why. NOT unique:
571
+ -- in the bound (provision-then-record) path several invitees
572
+ -- each write a usage row against the SAME host strand, so many
573
+ -- usage rows legitimately share one stamp.
574
+ primary key (UsageStampId),
575
+ constraint InsertOnly check on update, delete (false),
576
+ constraint Authorized check on insert (
577
+ -- Satisfies an invitation
578
+ exists (
579
+ select 1 from FormationInvite FI
580
+ where FI.Token = new.Token
581
+ -- The use cap is COUNTED against the pre-transaction snapshot, never
582
+ -- sequenced. This CHECK auto-defers to commit (it has a subquery), by
583
+ -- which point the in-flight row is live; committed.* is what excludes
584
+ -- it, so a sequentially exhausted invite — every seat already committed
585
+ -- on this node — is still refused here. What is deliberately NOT
586
+ -- refused: concurrent redeemers on separate nodes (or nodes that have
587
+ -- not yet converged) each count the same committed snapshot and both
588
+ -- land, over-admitting by at most the number of concurrent redeemers.
589
+ -- Both rows survive in this append-only record, so the overage is
590
+ -- auditable, and removal is owner-gated (member removal + Revocation).
591
+ -- Same class as the convergence note on UsageStampId above.
592
+ -- One rule for FUTURE writers: two FormationUsage rows for one token
593
+ -- inserted in ONE transaction each read the same committed count, so
594
+ -- they can jointly exceed the cap. No writer in this repo does that; it
595
+ -- is a constraint on new writers, not a case to engineer for.
596
+ -- Do not "fix" this into a per-token sequence number to restore a
597
+ -- strict cap — that trade was weighed and rejected (2026-08-04): a
598
+ -- fought-over sequence key turns a concurrent redemption into a silent,
599
+ -- unrecoverable loss of a consented join, which is strictly worse than
600
+ -- a visible, reversible over-admission.
601
+ and (FI.TotalUses is null or FI.TotalUses > (select count(1) from committed.FormationUsage U where U.Token = new.Token))
602
+ and (FI.ExpiresAt is null or FI.ExpiresAt > context.Now)
603
+ -- An invite carrying a ValidationUrl demands a sign-off from a key the party
604
+ -- ENROLLED in ValidationKey. The signature is verified against the STORED VK.Key,
605
+ -- NOT against context.ValidationKey — that distinction is the whole point:
606
+ -- context.ValidationKey is a plain insert parameter, so verifying against it asked
607
+ -- only "did the redeemer sign its own disclosure with SOME key it holds?", which any
608
+ -- redeemer answers by minting a throwaway keypair. context.ValidationKey survives
609
+ -- purely to SELECT which enrolled row is being claimed; naming an enrolled key while
610
+ -- signing with a different one fails the verify against VK.Key.
611
+ --
612
+ -- Deliberate: CHECKs run on write only, so removing a validation key later does NOT
613
+ -- re-examine the FormationUsage rows it already approved. The sign-off was valid when
614
+ -- given and the strand it authorized stays formed; removal narrows who may approve
615
+ -- FUTURE redemptions, it is not a retroactive revocation of past ones.
616
+ --
617
+ -- What ONE sign-off buys: exactly ONE row. The digest covers
618
+ -- (Token, UsageStampId, StrandId, PeerKey, Disclosure), so an approval is NOT
619
+ -- transferable across invitations, redemptions, networks, joiners, or disclosure
620
+ -- text. Replay is closed twice over, by two independent mechanisms: presenting an
621
+ -- approval VERBATIM repeats its UsageStampId and is refused by this table's
622
+ -- primary key; presenting it under any OTHER redemption changes a signed field
623
+ -- and fails this verify.
624
+ -- Every signed field is supplied by the redeeming side — nothing derived from
625
+ -- the table's current state is bound — so no concurrent write can invalidate an
626
+ -- approval between the sign-off and the row landing.
627
+ -- Why StrandStampId is deliberately NOT bound: it names the strand ROW
628
+ -- incarnation, which the unbound path (redeemInvitation) mints inside its own
629
+ -- transaction, and it adds nothing on top of a strictly single-use approval —
630
+ -- StrandId already pins WHICH network the approver approved joining.
631
+ and (FI.ValidationUrl is null or exists (
632
+ select 1 from ValidationKey VK
633
+ where VK.Key = context.ValidationKey
634
+ and verify(digest('CadreControl.FormationUsage', 'vouch', new.Token, new.UsageStampId, new.StrandId, new.PeerKey, new.Disclosure), context.ValidationSignature, VK.Key, 'ed25519')))
635
+ -- A bound invite (non-null StrandId) may only ever name its own host strand:
636
+ -- without this, any holder of a bound token could record consent against an
637
+ -- arbitrary strand id — annotating an unrelated strand, or (before
638
+ -- Strand.AuthorizedInsert's consent branch was narrowed to unbound invites)
639
+ -- consent-seating that id outright.
640
+ -- NOTE: an UNBOUND invite is deliberately left free to name any strand, so a
641
+ -- holder can spend a use appending a consent row to an unrelated existing
642
+ -- strand. Harmless today (the row grants nothing, it only burns a use and
643
+ -- forecloses that id's consent-seating, which is the safe direction); if
644
+ -- unlimited-TotalUses invites ever become common, this is an append-only
645
+ -- growth surface worth bounding.
646
+ and (FI.StrandId is null or FI.StrandId = new.StrandId)
647
+ )
648
+ ),
649
+ -- Matched on the (id, stamp) PAIR, the same key Strand.AuthorizedInsert's consent branch
650
+ -- reads back: a usage row may only ever name a strand ROW that exists, so a consent record
651
+ -- can never be filed against a stamp of the writer's choosing and held in reserve to
652
+ -- re-seat the id after a removal.
653
+ constraint StrandExists check (exists (select 1 from Strand S where S.Id = new.StrandId and S.StampId = new.StrandStampId)),
654
+ -- The JOINING peer proves it consented to this redemption: PeerKey is its own ed25519
655
+ -- public key and PeerSig is its signature over the 'consent'-tagged digest below.
656
+ -- Unlike the approver's sign-off (context.ValidationSignature, checked against a STORED
657
+ -- ValidationKey row), the identity here IS the key, so there is no enrolled row to look
658
+ -- up and nothing for a writer to substitute: a forged joiner would need that joiner's
659
+ -- private key. Stored rather than passed in context so any later reader can re-check it
660
+ -- (verifyFormationConsent); the 'consent' action tag keeps this replicated signature
661
+ -- useless against every other rule in this schema (notably the approver's 'vouch'
662
+ -- digest over the same table).
663
+ -- NOTE: StrandId is deliberately NOT in this digest — the joiner cannot know it when it
664
+ -- signs (a bound invite's host strand arrives only in the result frame; an unbound
665
+ -- strand is responder-minted). The responder cannot substitute a strand anyway: bound
666
+ -- invites are pinned by Authorized's FI.StrandId check, unbound invites mint fresh.
667
+ constraint PeerConsented check on insert (
668
+ verify(digest('CadreControl.FormationUsage', 'consent',
669
+ new.Token, new.UsageStampId, new.PeerKey, new.Disclosure),
670
+ new.PeerSig, new.PeerKey, 'ed25519')
671
+ ),
672
+ -- Context here is only what the approver-side rules read: Now for expiry, and the
673
+ -- ValidationKey/ValidationSignature pair for the 'vouch' check in Authorized. The joiner's
674
+ -- signature is NOT context — it lives on the row (PeerSig, verified by PeerConsented above).
675
+ ) with context (Now datetime, ValidationKey text null, ValidationSignature text null);
676
+
677
+ -- Token is no longer a primary-key prefix (the key is the redemption nonce), so the
678
+ -- per-token filters — the cap count in Authorized above, and cadre-core's
679
+ -- countFormationUsage / isTokenUsed / hasOutstandingFormationInvite — get an index
680
+ -- rather than a table scan.
681
+ index FormationUsageByToken on FormationUsage (Token);
682
+
683
+ -- Append-only retirement record for the one-off StampId nonces of removed OwnerKey /
684
+ -- CadrePeer / ValidationKey / Strand rows. Without it a removal was undoable: the add
685
+ -- approval is a signature over
686
+ -- (row key, stamp) that never expires, and deleting the row freed the stamp, so anyone who
687
+ -- kept a copy of the approval could resurrect the row.
688
+ -- Each tombstone also records WHICH row was retired (RowKey below): the consent branch of
689
+ -- Strand.AuthorizedInsert reads it to refuse consent-seating any strand id that has ever
690
+ -- been removed, whichever way it was seated.
691
+ -- NOTE: the guarded tables' NotRevoked CHECK runs against locally visible rows, so a node
692
+ -- that has not yet converged on a Revocation row can still accept the replayed add, and the
693
+ -- resurrected row coexists with the tombstone after merge — same class as the MinOneOwner
694
+ -- note on OwnerKey. The read-side mitigation is ControlDatabase.queryCadrePeers, which
695
+ -- drops any CadrePeer row whose StampId appears here; every membership reader inherits it.
696
+ -- NOTE: append-only, so this table only ever grows. Every append is owner-signed
697
+ -- (Authorized below), so the growth surface is the owner keys, not every writer that can
698
+ -- reach the control database. Cadres are a handful of peers and owner rotation is rare, so
699
+ -- unbounded growth is fine today; revisit if either changes.
700
+ -- A tombstone committed while the node was alone is local-only (never broadcast); the
701
+ -- ReissuedAt counter below exists so an owner can re-write — and therefore re-broadcast —
702
+ -- such a tombstone once peers are reachable, without changing what it means.
703
+ table Revocation (
704
+ TableName text, -- 'OwnerKey' | 'CadrePeer' | 'ValidationKey' | 'Strand' | 'DeviceToken' (confined by RowIsGone below)
705
+ RowKey text not null, -- primary key of the removed row: OwnerKey.Key / ValidationKey.Key /
706
+ -- CadrePeer.PeerId / DeviceToken.PeerId / Strand.Id
707
+ -- Every guarded table's RevocationRecorded CHECK requires the
708
+ -- accompanying tombstone to carry the removed row's key here, so
709
+ -- a REMOVAL cannot file a misnamed tombstone. A tombstone with no
710
+ -- accompanying delete is not covered by that (see Authorized).
711
+ StampId text, -- the retired nonce
712
+ -- Bumped by an owner-signed re-issue so a tombstone written while the node was alone
713
+ -- (committed local-only, never broadcast) can be re-written and therefore re-broadcast
714
+ -- on cohort growth. Carries NO semantics: nothing reads it, and retirement is decided
715
+ -- by the row's existence, not by this value.
716
+ ReissuedAt integer not null default 0,
717
+ -- Keyed on the stamp, not the RowKey: stamps are unique per row incarnation, and one
718
+ -- name may legitimately carry several tombstones over its life (seat -> delete ->
719
+ -- owner re-seat -> delete).
720
+ primary key (TableName, StampId),
721
+ -- Retirement is permanent: a tombstone may never be withdrawn.
722
+ constraint NoDelete check on delete (false),
723
+ -- Pinned at 0 on insert so an owner cannot seat a tombstone at a saturated counter and
724
+ -- thereby freeze its own later re-issues (ReissueOnly below). Deliberately NOT folded
725
+ -- into the Authorized digest: the value is fixed by this rule, so signing over it would
726
+ -- only widen the signed surface for no gain, and would invalidate every existing
727
+ -- insert-side digest.
728
+ constraint FreshTombstone check on insert (new.ReissuedAt = 0),
729
+ -- A re-issue may move NOTHING but the counter, and only upward. Without the identity
730
+ -- clause an "update" would be a way to re-point a tombstone at a different row,
731
+ -- restoring exactly the replay this table exists to stop.
732
+ constraint ReissueOnly check on update (
733
+ new.TableName = old.TableName and new.RowKey = old.RowKey
734
+ and new.StampId = old.StampId and new.ReissuedAt > old.ReissuedAt
735
+ ),
736
+ -- A stamp may only be retired once its row is actually gone. Deferred (subquery), so a
737
+ -- delete in the same transaction has already landed. Retiring a stamp that never
738
+ -- existed is permitted and harmless: a stamp carries 128 bits of CSPRNG output
739
+ -- (control-database.ts:generateStampId — 16 bytes of sha256(PeerId) followed by 16
740
+ -- random bytes), so a future legitimate one cannot be guessed and pre-planted.
741
+ -- Deliberately keyed on the STAMP only, not RowKey: checking "no live row named
742
+ -- new.RowKey" breaks under out-of-order convergence — a peer removed (tombstone names
743
+ -- it, stamp1) then re-admitted (stamp2) would leave a node that converges on the
744
+ -- re-add FIRST rejecting the stamp1 tombstone forever, never learning that stamp is
745
+ -- retired. The stamp-only form passes there (stamp1 is not live).
746
+ constraint RowIsGone check on insert (
747
+ (new.TableName = 'OwnerKey' and not exists (select 1 from OwnerKey K where K.StampId = new.StampId))
748
+ or (new.TableName = 'CadrePeer' and not exists (select 1 from CadrePeer P where P.StampId = new.StampId))
749
+ or (new.TableName = 'ValidationKey' and not exists (select 1 from ValidationKey V where V.StampId = new.StampId))
750
+ or (new.TableName = 'Strand' and not exists (select 1 from Strand S where S.StampId = new.StampId))
751
+ or (new.TableName = 'DeviceToken' and not exists (select 1 from DeviceToken D where D.StampId = new.StampId))
752
+ ),
753
+ -- Appending a tombstone is an OWNER action, like every other write in this schema.
754
+ -- Ungated, any writer that could reach the control database could retire a stamp:
755
+ -- filing one against a CadrePeer row not visible locally (RowIsGone reads LOCALLY
756
+ -- VISIBLE rows — see the convergence note above) evicted that peer party-wide AND
757
+ -- permanently blocked the owner's own later re-admission of it (NotRevoked), and
758
+ -- filing arbitrary stamps flooded a table whose rows never go away.
759
+ -- The digest binds the WHOLE row (TableName, RowKey, StampId); the 'CadreControl.Revocation'
760
+ -- domain tag keeps it disjoint from every other rule — including the
761
+ -- 'CadreControl.CadrePeer' 'remove' digest the same owner signs in the SAME
762
+ -- transaction for the delete this tombstone accompanies.
763
+ -- RowKey is not cross-checked HERE against the retired row: positively binding it (e.g.
764
+ -- requiring a committed.Strand row carrying the pair) would reject a tombstone re-issued
765
+ -- in a LATER transaction — which the delete-while-alone durability path
766
+ -- (docs/architecture.md) plans to do — and reject it outright on a node that never
767
+ -- converged on the removed row. The binding lives on the other side instead: each
768
+ -- guarded table's RevocationRecorded requires the tombstone accompanying a DELETE to
769
+ -- name that row's key, which the re-issue path never trips (it files no delete).
770
+ -- Residual: a STANDALONE tombstone — owner-signed, naming a stamp no live row holds —
771
+ -- is still accepted, so an owner can name an id that never existed and thereby
772
+ -- permanently foreclose CONSENT-seating of that id (Strand.AuthorizedInsert).
773
+ -- Owner-only, the owner already controls invite issuance, and unbound ids are 128
774
+ -- random bytes minted at redemption — no escalation (same reasoning as the pre-plant
775
+ -- note on RowIsGone).
776
+ -- Reads LIVE OwnerKey (like ValidationKey / Strand / CadrePeer / DeviceToken), not
777
+ -- committed.OwnerKey. The one difference that makes: a founding transaction on an
778
+ -- owner-less database could seat an owner key and file a tombstone in one go. An
779
+ -- owner-less control database is already a total loss, and a second transaction
780
+ -- achieves the same thing under committed.*, so consistency with the siblings is
781
+ -- worth more than the distinction — do not "tighten" this without a reason other
782
+ -- than that one.
783
+ -- RowIsGone and NoDelete are unchanged: they guard an owner retiring a stamp early
784
+ -- or withdrawing one later, which authorization does not cover.
785
+ constraint Authorized check on insert (
786
+ exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.Revocation', 'remove', new.TableName, new.RowKey, new.StampId), context.Signature, A.Key, 'ed25519'))
787
+ ),
788
+ -- A re-issue is an OWNER action, like the original append. Distinct 'reissue' action
789
+ -- tag, so an append approval can never be replayed as a re-issue and vice versa. The
790
+ -- digest adds ReissuedAt so a captured re-issue signature cannot bump the counter to
791
+ -- any other value. Reads LIVE OwnerKey, matching Authorized above.
792
+ constraint AuthorizedReissue check on update (
793
+ exists (select 1 from OwnerKey A where A.Key = context.OwnerKey
794
+ and verify(digest('CadreControl.Revocation', 'reissue',
795
+ new.TableName, new.RowKey, new.StampId, cast(new.ReissuedAt as text)),
796
+ context.Signature, A.Key, 'ed25519'))
797
+ )
798
+ ) with context (OwnerKey text, Signature text);
799
+ }
800
+
786
801
  apply schema CadreControl;`;
787
802
  //# sourceMappingURL=control-schema.js.map