@serfab/quereus-plugin-sereus 0.10.0 → 0.12.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.
@@ -27,508 +27,549 @@
27
27
  * the founding `Manager`/`Member`, and the invite/peer flows is owned by
28
28
  * `strand-membership-lifecycle-population`.
29
29
  */
30
- export const STRAND_SCHEMA = ` table Header (
31
- Id text, -- UUID of this network - generated at inception - immutable
32
-
33
- -- Types: 'o' = Open, 'c' = Closed
34
- -- Any peers can join an open network, but only members can join a closed network
35
- -- Open can still control writes in the app, but only Closed prevents reads
36
- Type text check (Type in ('o', 'c')),
37
-
38
- -- The app that this strand faciliates - public key from author to prevent tampering
39
- sAppId text,
40
-
41
- -- The version of the sApp that this strand faciliates
42
- sAppVersion text,
43
-
44
- -- The schema of the sApp that this strand faciliates
45
- sAppSchema text,
46
-
47
- -- The author's signature on the schema to guarantee authorship
48
- sAppSignature text,
49
-
50
- -- The engine (rules logic system) that is assumed to manage this strand
51
- Engine text,
52
-
53
- -- The version of the engine that is assumed to manage this strand
54
- EngineVersion text,
55
-
56
- primary key (/* empty - singleton */),
57
- constraint InsertOnly check on update, delete (false), -- One-time insert only for now - TODO: revisit for versioning
58
- );
59
-
60
- -- An invitation to join a strand as a member
61
- table Invite (
62
- Key text primary key,
63
- Expiration datetime null,
64
- -- Deactivation is a CancelledInvite tombstone (below), not a delete of this row
65
- -- or a state column on it — rationale on that table.
66
- constraint InsertOnly check on update, delete (false),
67
- constraint OnlyClosed check (
68
- exists (select 1 from Header H where H.Type = 'c')
69
- ),
70
- constraint InviteValid check on insert (
71
- -- Can only be inserted by a manager,
72
- exists (select 1 from Manager A
73
- where A.MemberKey = context.ManagerKey
74
- and verify(digest(new.Key || '|' || coalesce(new.Expiration, '')), context.ManagerSignature, A.MemberKey, 'ed25519')
75
- )
76
- -- and must also prove invite private key held by issuing manager
77
- and verify(digest(new.Key || '|' || coalesce(new.Expiration, '')), context.InviteSignature, new.Key, 'ed25519')
78
- )
79
- ) with context (ManagerKey text null, ManagerSignature text null, InviteSignature text null);
80
-
81
- -- Invite [InviteKey] has been used to add [MemberKey] as a member
82
- -- No StampId here or on Invite: both tables are insert-only and their primary keys are
83
- -- minted fresh per issuance/consumption, so a replayed insert collides on the PK.
84
- table ConsumedInvite (
85
- InviteKey text primary key,
86
- MemberKey text,
87
- constraint InsertOnly check on update, delete (false),
88
- constraint InviteExists check (exists (select 1 from Invite I where I.Key = new.InviteKey)),
89
- constraint MemberExists check (exists (select 1 from Member M where M.Key = new.MemberKey)),
90
- constraint ValidUsage check on insert (
91
- exists (select 1 from Invite I where I.Key = new.InviteKey and verify(digest(new.InviteKey || '|' || new.MemberKey), context.InviteSignature, new.InviteKey, 'ed25519'))
92
- ),
93
- -- An invite with a non-null Expiration may only be consumed while it is still
94
- -- in the future. context.Now is the canonical-datetime "now" supplied by the
95
- -- consumeInvite writer (same canonicalDatetime() transform used to store
96
- -- Invite.Expiration), so both sides of the comparison are byte-identical
97
- -- canonical strings and the lexical ">" comparison orders chronologically. A
98
- -- null Expiration never expires. Mirrors CadreControl.FormationUsage's
99
- -- "FI.ExpiresAt is null or FI.ExpiresAt > context.Now" gate.
100
- constraint NotExpired check on insert (
101
- exists (select 1 from Invite I where I.Key = new.InviteKey and (I.Expiration is null or I.Expiration > context.Now))
102
- ),
103
- -- A cancelled invitation may never be consumed. Blocking the ConsumedInvite
104
- -- insert is sufficient to block the JOIN: Member.Authorized's invite branch
105
- -- requires a same-transaction FRESH ConsumedInvite row, and consumeInvite writes
106
- -- both rows in one transaction, so the whole join rolls back. The rule lives here
107
- -- alone rather than being duplicated into Member.Authorized. Reads only stored
108
- -- rows — no caller-supplied context decides admission (unlike NotExpired's Now).
109
- constraint NotCancelled check on insert (
110
- not exists (select 1 from CancelledInvite C where C.InviteKey = new.InviteKey)
111
- )
112
- ) with context (InviteSignature text null, Now datetime null);
113
-
114
- -- A manager has cancelled invitation [InviteKey]: it may never be consumed.
115
- --
116
- -- A monotone TOMBSTONE, not a delete of the Invite row and not a state column on it:
117
- -- 1. Deleting the Invite row would re-open a replay surface. InviteValid signs only
118
- -- (Key, Expiration) — there is no per-row StampId on Invite — so a deleted Invite
119
- -- row can be re-created by replaying the captured issue signatures. Closing that
120
- -- would mean adding Invite.StampId and widening Revocation.RowIsGone to a fourth
121
- -- table. (CadreControl.FormationInvite IS deletable — it carries a StampId for
122
- -- exactly this reason.)
123
- -- 2. A state column needs an UPDATE, and Invite is insert-only on purpose.
124
- -- 3. Insert-only converges: a growing set of dead markers merges cleanly across
125
- -- nodes, a delete/re-insert pair does not. Same rationale as Strand.Revocation.
126
- -- No StampId here: the table is insert-only and its primary key IS the invite key, so
127
- -- a replayed insert collides on the PK — the argument the comment above ConsumedInvite
128
- -- makes for Invite/ConsumedInvite.
129
- -- Deliberately NO InviteExists gate: a node that has not yet converged on the Invite
130
- -- row must still be able to record the cancellation. Refusing it would fail OPEN on a
131
- -- security control, which is strictly worse than accepting a junk row.
132
- -- NOTE: this table only grows — one row per cancellation, plus any junk a manager
133
- -- chooses to file, and Immutable means rows never go away (the tradeoff
134
- -- Revocation.RowIsGone already documents for junk stamps). Fine at strand scale; if
135
- -- invite churn ever gets large it needs pruning bounded by something other than
136
- -- "forever".
137
- -- NOTE: a cancellation must PROPAGATE before the holder redeems — a holder consuming
138
- -- on a node that has not yet seen this row still gets in. Same convergence class as
139
- -- the NotRevoked / MinOneMember / MinOneManager local-visibility notes; not solved here.
140
- table CancelledInvite (
141
- InviteKey text primary key,
142
- constraint OnlyClosed check (
143
- exists (select 1 from Header H where H.Type = 'c')
144
- ),
145
- -- Cancellation is permanent: un-cancelling would restore the re-entry it closed.
146
- -- To re-invite a party, issue a fresh invitation.
147
- constraint Immutable check on update, delete (false),
148
- -- committed.Manager, not Manager: this check has a subquery so it defers to
149
- -- commit, by which point a manager seated in the SAME transaction would otherwise
150
- -- be able to authorize its own cancellation. Member, MemberPeer and Revocation
151
- -- read committed.* for the same reason. (Manager.Authorized reads the LIVE table
152
- -- instead — there it is the strict Generation ordering, not a snapshot, that
153
- -- excludes an authorizer seated in the same transaction.)
154
- constraint Authorized check on insert (
155
- exists (select 1 from committed.Manager A
156
- where A.MemberKey = context.ManagerKey
157
- and verify(digest('Strand.CancelledInvite', 'cancel', new.InviteKey),
158
- context.ManagerSignature, A.MemberKey, 'ed25519'))
159
- )
160
- ) with context (ManagerKey text, ManagerSignature text);
161
-
162
- -- A party in the closed strand network
163
- table Member (
164
- Key text primary key,
165
- StampId text not null unique, -- per-row authorization nonce, bound into the signed digests below.
166
- -- unique holds over LIVE rows only; a removed row's stamp is retired
167
- -- permanently into Revocation (NotRevoked below), so the never-expiring
168
- -- admission signature cannot resurrect a revoked member.
169
- -- A removed row's StampId is retired into Revocation, and this refuses any insert
170
- -- naming a retired stamp: the approval that seated this row can never re-seat it
171
- -- after removal. unique alone did not do this — it only holds over LIVE rows, so
172
- -- a delete freed the stamp and the original never-expiring signature verified
173
- -- again. A legitimate re-add mints a FRESH StampId and a fresh signature, so it is
174
- -- unaffected. Mirrors CadreControl.OwnerKey.NotRevoked (rationale in full there).
175
- constraint NotRevoked check on insert (
176
- not exists (select 1 from Revocation R where R.TableName = 'Member' and R.StampId = new.StampId)
177
- ),
178
- -- Retirement is MANDATORY: a delete must carry the matching Revocation row in the
179
- -- same transaction, or the stamp would free up again. Deferred (subquery), so the
180
- -- sibling insert is visible at commit regardless of statement order.
181
- constraint RevocationRecorded check on delete (
182
- exists (select 1 from Revocation R where R.TableName = 'Member' and R.StampId = old.StampId)
183
- ),
184
- -- Membership is insert + delete only; a re-keyed member is a remove + fresh add.
185
- constraint NoUpdate check on update (false),
186
- -- Mask is explicit on all three operations: a bare check defaults to
187
- -- insert|update and silently never runs on DELETE.
188
- constraint OnlyClosed check on insert, update, delete (
189
- exists (select 1 from Header H where H.Type = 'c')
190
- ),
191
- -- A closed strand must never lose its last member: Member is the strand's read
192
- -- gate, and the founding Manager is only seatable beside an existing Member, so
193
- -- an empty member set is a permanent denial of the whole strand. Deferred
194
- -- (subquery), so the count it sees is the POST-delete count.
195
- -- NOTE: this is a per-transaction check against locally visible rows. Two nodes
196
- -- that concurrently remove different members can each see a surviving one and
197
- -- still converge to zero; if partitioned removal ever becomes a real workflow,
198
- -- the floor needs a cross-node guard, not a local count.
199
- constraint MinOneMember check on delete (
200
- (select count(1) from Member) >= 1
201
- ),
202
- -- A member still holding a Manager row cannot be un-membered — it must resign
203
- -- its Manager row first. Deferred (subquery), so it reads the POST-image: a
204
- -- single transaction deleting BOTH the Manager and the Member row passes.
205
- constraint NotAManager check on delete (
206
- not exists (select 1 from Manager A where A.MemberKey = old.Key)
207
- ),
208
- constraint Authorized check on insert, delete (
209
- -- Every authorizer is read from the PRE-transaction snapshot (committed.*):
210
- -- this CHECK auto-defers to commit (it has a subquery), by which point rows
211
- -- inserted alongside are live, so a plain from-Manager would let a manager
212
- -- seated in this same transaction authorize a removal. committed.* states
213
- -- the rule directly: the authorizer existed BEFORE this transaction.
214
-
215
- -- Every signed digest below leads with two fixed literals — the
216
- -- 'Strand.Member' domain tag and an action tag — so an approval verifies
217
- -- ONLY against the one rule it was minted for (the idiom of CadreControl's
218
- -- control-authorization.ts), and binds the row's StampId, so it authorizes
219
- -- exactly one INCARNATION of the row: once the row is deleted (retiring the
220
- -- stamp) or re-created under a fresh stamp, the captured approval matches
221
- -- nothing.
222
-
223
- -- Bootstrap: the FOUNDING transaction — one whose pre-transaction member
224
- -- set is empty — seats the first member, AND ONLY THAT ONE, with no
225
- -- authorization. Both counts are load-bearing. The committed (PRE-image)
226
- -- count keeps the branch off for a DELETE and for the insert half of a
227
- -- same-transaction wipe-then-seat (the wiped rows were committed). The
228
- -- live (POST-image) count keeps a single founding transaction from
229
- -- seating an unbounded unauthenticated member set — which would also
230
- -- brick the strand, since Manager's own bootstrap branch requires at
231
- -- most one Member.
232
- (old.Key is null
233
- and (select count(1) from committed.Member) = 0
234
- and (select count(1) from Member) <= 1)
235
-
236
- -- or added directly by a pre-existing manager signing the add-tagged
237
- -- digest over (Key, StampId) — bound to this row's fresh stamp, so the
238
- -- approval seats exactly this incarnation and dies with it.
239
- or (old.Key is null and exists (
240
- select 1 from committed.Manager A
241
- where A.MemberKey = context.ManagerKey
242
- and verify(digest('Strand.Member', 'add', new.Key, new.StampId), context.ManagerSignature, A.MemberKey, 'ed25519')
243
- ))
244
-
245
- -- or added by consuming an invite. The ConsumedInvite admitting this
246
- -- member must be same-transaction FRESH — its InviteKey absent from the
247
- -- committed snapshot. consumeInvite inserts Member + ConsumedInvite in
248
- -- one transaction, so a genuine join passes; a revoked member's STALE
249
- -- committed row no longer re-admits it, while a fresh manager-issued
250
- -- invite still does (new InviteKey → uncommitted row). InviteExists +
251
- -- ValidUsage on ConsumedInvite keep this branch mintable only via a
252
- -- real invite and possession of its private key.
253
- or (old.Key is null and exists (
254
- select 1 from ConsumedInvite CI
255
- where CI.MemberKey = new.Key
256
- and not exists (select 1 from committed.ConsumedInvite CC where CC.InviteKey = CI.InviteKey)
257
- ))
258
-
259
- -- or a removal authorized by a pre-existing manager over the DISTINCT
260
- -- remove-tagged digest bound to the departing row's (Key, StampId).
261
- or (new.Key is null and exists (
262
- select 1 from committed.Manager A
263
- where A.MemberKey = context.ManagerKey
264
- and verify(digest('Strand.Member', 'remove', old.Key, old.StampId), context.ManagerSignature, A.MemberKey, 'ed25519')
265
- ))
266
-
267
- -- or the member removes ITSELF (leaving): the leave-tagged signature
268
- -- verifies against old.Key itself, so only the departing key's holder
269
- -- can produce it — no manager involved. The 'leave' tag (vs the manager
270
- -- branch's 'remove') is belt-and-braces ONLY: the two branches verify
271
- -- against DIFFERENT keys, so the former shared tag was never
272
- -- exploitable — the anti-replay fix here is the StampId binding, not
273
- -- this tag.
274
- or (new.Key is null
275
- and verify(digest('Strand.Member', 'leave', old.Key, old.StampId), context.MemberSignature, old.Key, 'ed25519'))
276
- ),
277
- ) with context (ManagerKey text null, ManagerSignature text null, MemberSignature text null);
278
-
279
- -- A member-associated peer (node)
280
- --
281
- -- NOTE: a MemberPeer row can OUTLIVE the Member row it names. MemberExists runs on
282
- -- insert only, and nothing cascades a Member delete, so revoking a member leaves its
283
- -- peer bindings behind as orphans until a manager clears them (removeMemberPeer).
284
- -- Any consumer that treats a MemberPeer row as evidence of membership MUST join to
285
- -- Member rather than trusting the row alone.
286
- table MemberPeer (
287
- MemberKey text,
288
- PeerId text,
289
- StampId text not null unique, -- per-row authorization nonce, bound into the signed
290
- -- digests below; retired into Revocation on delete
291
- -- (same idiom as Member.StampId, rationale there).
292
- primary key (MemberKey, PeerId),
293
- -- Refuses any insert naming a retired stamp — see Member.NotRevoked.
294
- constraint NotRevoked check on insert (
295
- not exists (select 1 from Revocation R where R.TableName = 'MemberPeer' and R.StampId = new.StampId)
296
- ),
297
- -- A delete must retire its stamp in the same transaction — see Member.RevocationRecorded.
298
- constraint RevocationRecorded check on delete (
299
- exists (select 1 from Revocation R where R.TableName = 'MemberPeer' and R.StampId = old.StampId)
300
- ),
301
- -- Binding is insert + delete only, as on Member and Manager; a re-binding is a
302
- -- remove plus a fresh register. The whole visible identity IS the primary key, so
303
- -- an update is never an edit — and Authorized reads the NEW image, so an update
304
- -- would let ANY member re-point another member's row at its OWN key, signing only
305
- -- over its own new values, and thereby clear a binding it has no signature to
306
- -- delete.
307
- constraint NoUpdate check on update (false),
308
- -- Mask is explicit: a bare check defaults to insert|update and silently never runs on
309
- -- DELETE. Insert is the only reachable case — update is forbidden above, and a delete
310
- -- leaves no new row image to validate.
311
- constraint MemberExists check on insert (exists (select 1 from Member M where M.Key = new.MemberKey)),
312
- constraint Authorized check on insert, delete (
313
- -- The member itself signs its own binding, verified against the MemberKey the
314
- -- row names — so only that member registers (or removes) its own peers. The
315
- -- registration and removal approvals are DISTINCT action-tagged digests, each
316
- -- bound to the row's StampId, so a captured registration approval neither
317
- -- re-registers a cleared binding (its stamp is retired) nor authorizes a
318
- -- removal (wrong tag).
319
- (old.MemberKey is null
320
- and verify(digest('Strand.MemberPeer', 'add', new.MemberKey, new.PeerId, new.StampId), context.Signature, new.MemberKey, 'ed25519'))
321
-
322
- -- or the member clears its OWN binding.
323
- or (new.MemberKey is null
324
- and verify(digest('Strand.MemberPeer', 'remove', old.MemberKey, old.PeerId, old.StampId), context.Signature, old.MemberKey, 'ed25519'))
325
-
326
- -- or a manager clears ANOTHER member's binding — the cleanup path after a
327
- -- revocation, which the departing member has no reason to cooperate with.
328
- -- Gated to DELETE and bound to a manager-remove-tagged digest over the exact
329
- -- row + stamp. The 'manager-remove' tag (vs the self branch's 'remove') is
330
- -- belt-and-braces ONLY — the two branches verify against different keys.
331
- -- The authorizer is read from the PRE-transaction snapshot
332
- -- (committed.Manager) for the same reason Member.Authorized does: this check
333
- -- defers to commit, by which point a manager seated in the SAME transaction
334
- -- would otherwise be able to authorize its own cleanup.
335
- or (new.MemberKey is null and exists (
336
- select 1 from committed.Manager A
337
- where A.MemberKey = context.ManagerKey
338
- and verify(digest('Strand.MemberPeer', 'manager-remove', old.MemberKey, old.PeerId, old.StampId), context.ManagerSignature, A.MemberKey, 'ed25519')
339
- ))
340
- ),
341
- ) with context (Signature text null, ManagerKey text null, ManagerSignature text null);
342
-
343
- -- A manager is a member that can issue invites, authorize members, and rotate managers
344
- table Manager (
345
- MemberKey text primary key,
346
- -- Lineage ordering. The founding manager is generation 0; every later manager is
347
- -- seated strictly after (greater than) the manager that appointed it. This is what
348
- -- makes same-transaction mutual promotion impossible: a deferred CHECK sees only
349
- -- the post-image, so "did my authorizer exist before this transaction?" is not
350
- -- directly askable — but the minimum-generation row of any inserted set must find
351
- -- its authorizer among rows of strictly smaller generation, which can only be a
352
- -- pre-existing one. Generation is NOT a privilege level: a generation-5 manager
353
- -- has exactly the same powers as a generation-1 manager.
354
- Generation integer not null,
355
- StampId text not null unique, -- per-row authorization nonce, bound into the signed
356
- -- digests below; retired into Revocation on delete
357
- -- (same idiom as Member.StampId, rationale there).
358
- -- Refuses any insert naming a retired stamp — see Member.NotRevoked. This is what
359
- -- makes a captured promotion approval single-use: once the manager is removed (its
360
- -- stamp retired), the approval that seated it can never re-seat it.
361
- constraint NotRevoked check on insert (
362
- not exists (select 1 from Revocation R where R.TableName = 'Manager' and R.StampId = new.StampId)
363
- ),
364
- -- A delete must retire its stamp in the same transaction — see Member.RevocationRecorded.
365
- constraint RevocationRecorded check on delete (
366
- exists (select 1 from Revocation R where R.TableName = 'Manager' and R.StampId = old.StampId)
367
- ),
368
- constraint OnlyClosed check (
369
- exists (select 1 from Header H where H.Type = 'c')
370
- ),
371
- -- A manager IS a member: the table comment has always said so, and since the
372
- -- single-use-approval work it is load-bearing — Revocation.Authorized verifies a
373
- -- delete's tombstone against a committed Member row, so a manager with no Member
374
- -- row cannot revoke, clear a peer binding, or even resign. Reads the LIVE Member
375
- -- table, not committed.Member, so an admit-then-promote in ONE transaction passes
376
- -- (the same shape ConsumedInvite.MemberExists allows for the invite join); the
377
- -- sibling Member insert is authorized at that same commit, so nothing is waived.
378
- -- Mask is explicit: a bare check defaults to insert|update, and update is
379
- -- forbidden outright by NoUpdate below. Nothing is needed on DELETE — the other
380
- -- half of the invariant is Member.NotAManager, which refuses to un-member a key
381
- -- that still holds a Manager row.
382
- -- NOTE: a per-transaction check against locally visible rows. Two partitioned nodes
383
- -- — one promoting X, one removing X's Member and Manager rows — can each pass
384
- -- locally and converge to a Manager row with no Member row. Same convergence class
385
- -- as the MinOneMember / MinOneManager / NotRevoked notes; not solved here.
386
- constraint MemberExists check on insert (
387
- exists (select 1 from Member M where M.Key = new.MemberKey)
388
- ),
389
- -- Rotation is insert + delete only. An update would let a resignation signature
390
- -- (which proves only that old.MemberKey consented) double as a hand-off: the
391
- -- former-manager branch would accept re-pointing the row at any new key.
392
- constraint NoUpdate check on update (false),
393
- -- A closed strand must never lose its last manager. Every admit path (Invite,
394
- -- addMemberByManager, addManager) requires an existing Manager row, so an
395
- -- admin-less strand can never admit anyone again. Deferred (subquery), so the
396
- -- count it sees is the POST-delete count.
397
- -- NOTE: this is a per-transaction check against locally visible rows. Two nodes
398
- -- that concurrently remove different managers can each see a surviving one and
399
- -- still converge to zero; if partitioned rotation ever becomes a real workflow,
400
- -- the floor needs a cross-node guard, not a local count.
401
- constraint MinOneManager check on delete (
402
- (select count(1) from Manager) >= 1
403
- ),
404
- constraint Authorized check on insert, delete (
405
- -- Every signed branch below is a domain/action-tagged digest bound to the
406
- -- row's StampId — the same single-use idiom as Member.Authorized: an
407
- -- approval seats (or removes) exactly one incarnation of one row.
408
-
409
- -- Bootstrap: the founding manager is seated with no prior signer, at
410
- -- generation 0. Gated to INSERT (old.MemberKey is null) AND to the founding
411
- -- state — at most one Member, and this manager IS that member. Deferred
412
- -- checks see post-image state, so an ungated count test is also true for a
413
- -- DELETE that drops the count to <= 1, and for the INSERT half of a
414
- -- same-transaction swap of the sole manager. The trailing Member-exists test
415
- -- is belt-and-braces since MemberExists above enforces it on EVERY insert —
416
- -- kept so this branch's own claim ("this manager IS that member") reads in
417
- -- place rather than by cross-reference.
418
- (old.MemberKey is null
419
- and new.Generation = 0
420
- and (select count(1) from Manager) <= 1
421
- and (select count(1) from Member) <= 1
422
- and exists (select 1 from Member M where M.Key = new.MemberKey))
423
-
424
- -- or authorized by this former manager (self-resignation), over the
425
- -- resign-tagged digest bound to its own row's stamp. The 'resign' tag
426
- -- (vs the admin branch's 'remove') is belt-and-braces — the two
427
- -- branches verify against different keys; the anti-replay fix is the
428
- -- stamp: once this manager is re-promoted under a fresh stamp, a
429
- -- captured resignation no longer removes it.
430
- or (
431
- old.MemberKey is not null
432
- and old.MemberKey = context.ManagerKey
433
- and verify(digest('Strand.Manager', 'resign', old.MemberKey, old.StampId), context.Signature, old.MemberKey, 'ed25519')
434
- )
435
-
436
- -- or a promotion signed by an EARLIER-generation manager, over the
437
- -- add-tagged digest binding (MemberKey, Generation, StampId) — so a
438
- -- captured promotion replays at neither a different generation nor a
439
- -- second incarnation of the row. The strict
440
- -- A.Generation < new.Generation is what closes same-transaction mutual
441
- -- promotion: this subquery runs at commit against the post-insert row
442
- -- set, so sibling rows inserted in the same transaction are visible —
443
- -- but the minimum-generation row of any inserted set cannot find its
444
- -- authorizer among its siblings (that would contradict minimality), so
445
- -- that authorizer must be a pre-existing manager who really signed.
446
- -- A mutual pair or ring needs each generation strictly below another's
447
- -- in a cycle; impossible. Nothing sits below the founder's 0, so a
448
- -- chain cannot duck underneath either. The <> is subsumed by the
449
- -- ordering (a row's generation is never below its own) but kept to
450
- -- state the no-self-promotion intent locally.
451
- or (old.MemberKey is null and exists (
452
- select 1 from Manager A
453
- where A.MemberKey = context.ManagerKey
454
- and A.MemberKey <> new.MemberKey
455
- and A.Generation < new.Generation
456
- and verify(digest('Strand.Manager', 'add', new.MemberKey, new.Generation, new.StampId), context.Signature, A.MemberKey, 'ed25519')))
457
-
458
- -- or a removal authorized by ANOTHER existing manager, over the
459
- -- remove-tagged digest bound to (MemberKey, StampId). Deliberately no
460
- -- generation condition here: deletes are safe once inserts are (every
461
- -- accepting branch requires a Manager row in the post-image, and no
462
- -- attacker row can get there), and a generation gate would break a
463
- -- later-generation manager removing an earlier-generation one.
464
- or (new.MemberKey is null and exists (
465
- select 1 from Manager A
466
- where A.MemberKey = context.ManagerKey
467
- and A.MemberKey <> old.MemberKey
468
- and verify(digest('Strand.Manager', 'remove', old.MemberKey, old.StampId), context.Signature, A.MemberKey, 'ed25519')))
469
- )
470
- ) with context (ManagerKey text null, Signature text null);
471
-
472
- -- Permanent tombstones for retired per-row authorization nonces (StampIds) — the
473
- -- delete half of the retired-stamp anti-replay idiom on Member / Manager /
474
- -- MemberPeer. Adapted from CadreControl.Revocation (the idiom is stated in full on
475
- -- CadreControl.OwnerKey); two deliberate differences:
476
- -- 1. TableName is confined to the three guarded STRAND tables (RowIsGone below).
477
- -- 2. Authorized verifies against a committed MEMBER row, not an owner key: every
478
- -- delete path in this schema is driven by a manager or by the departing party,
479
- -- and both are members. The action tag is 'retire' where the control layer
480
- -- says 'remove'; the domain tag already keeps the two schemas' approvals
481
- -- disjoint, so the divergence is cosmetic — kept because 'retire' names the
482
- -- act (retiring a stamp), not a row operation.
483
- -- NOTE: the guarded tables' NotRevoked CHECK runs against locally visible rows, so
484
- -- a node that has not yet converged on a Revocation row can still accept a replayed
485
- -- add and the resurrected row coexists with the tombstone after merge — the same
486
- -- convergence class as the MinOneMember / MinOneManager local-count notes.
487
- -- NOTE: this table only grows — one row per membership deletion, plus any junk any
488
- -- member chooses to file, and Immutable means rows never go away. Fine at strand
489
- -- scale; if membership churn ever gets large it needs pruning bounded by something
490
- -- other than "forever".
491
- -- NOTE: nothing on the strand read side drops a row because its stamp is retired
492
- -- (NotRevoked is insert-only), which is why a tombstone filed against a live but
493
- -- not-yet-converged row is inert here. If a read path ever starts filtering on
494
- -- Revocation (as the control layer's listAuthorizedMembers does), filing a tombstone
495
- -- becomes a remote eviction primitive and Authorized below must narrow from "any
496
- -- member" to "a manager, or the row's own owner".
497
- table Revocation (
498
- TableName text, -- 'Member' | 'Manager' | 'MemberPeer' (confined by RowIsGone below)
499
- StampId text, -- the retired nonce
500
- primary key (TableName, StampId),
501
- constraint OnlyClosed check (
502
- exists (select 1 from Header H where H.Type = 'c')
503
- ),
504
- -- Retirement is permanent: clearing or re-pointing a tombstone would restore the replay.
505
- constraint Immutable check on update, delete (false),
506
- -- A stamp may only be retired once its row is actually gone. Deferred (subquery), so a
507
- -- delete in the same transaction has already landed. Retiring a stamp that never
508
- -- existed is permitted and harmless: a stamp carries 256 bits of CSPRNG output
509
- -- (strand-membership-writer.ts generateStrandStampId), so a future legitimate one
510
- -- cannot be guessed and pre-planted — junk stamps only grow the table.
511
- constraint RowIsGone check on insert (
512
- (new.TableName = 'Member' and not exists (select 1 from Member M where M.StampId = new.StampId))
513
- or (new.TableName = 'Manager' and not exists (select 1 from Manager A where A.StampId = new.StampId))
514
- or (new.TableName = 'MemberPeer' and not exists (select 1 from MemberPeer P where P.StampId = new.StampId))
515
- ),
516
- -- Filing a tombstone is a MEMBER action: every legitimate delete in this schema is
517
- -- driven by a manager or by the departing party — both members — and the tombstone
518
- -- rides the delete's own transaction. The authorizer is read from the
519
- -- PRE-transaction snapshot (committed.Member) for the same reason the sibling
520
- -- tables read committed.*: this check defers to commit, by which point a member
521
- -- seated in the same transaction is live. The digest binds the WHOLE row
522
- -- (TableName, StampId) under its own domain tag, so it is disjoint from every
523
- -- other rule — including the remove-tagged approval the same party signs in the
524
- -- SAME transaction for the delete this tombstone accompanies. RowIsGone and
525
- -- Immutable guard what authorization does not: retiring a stamp early, or
526
- -- withdrawing one later.
527
- constraint Authorized check on insert (
528
- exists (select 1 from committed.Member M
529
- where M.Key = context.MemberKey
530
- and verify(digest('Strand.Revocation', 'retire', new.TableName, new.StampId), context.Signature, M.Key, 'ed25519'))
531
- )
532
- ) with context (MemberKey text, Signature text);
30
+ export const STRAND_SCHEMA = ` table Header (
31
+ Id text, -- UUID of this network - generated at inception - immutable
32
+
33
+ -- Types: 'o' = Open, 'c' = Closed
34
+ -- Any peers can join an open network, but only members can join a closed network
35
+ -- Open can still control writes in the app, but only Closed prevents reads
36
+ Type text check (Type in ('o', 'c')),
37
+
38
+ -- The app that this strand faciliates - public key from author to prevent tampering
39
+ sAppId text,
40
+
41
+ -- The version of the sApp that this strand faciliates
42
+ sAppVersion text,
43
+
44
+ -- The schema of the sApp that this strand faciliates
45
+ sAppSchema text,
46
+
47
+ -- The author's signature on the schema to guarantee authorship
48
+ sAppSignature text,
49
+
50
+ -- The engine (rules logic system) that is assumed to manage this strand
51
+ Engine text,
52
+
53
+ -- The version of the engine that is assumed to manage this strand
54
+ EngineVersion text,
55
+
56
+ primary key (/* empty - singleton */),
57
+ constraint InsertOnly check on update, delete (false), -- One-time insert only for now - TODO: revisit for versioning
58
+ );
59
+
60
+ -- An invitation to join a strand as a member
61
+ table Invite (
62
+ Key text primary key,
63
+ Expiration datetime null,
64
+ -- Deactivation is a CancelledInvite tombstone (below), not a delete of this row
65
+ -- or a state column on it — rationale on that table.
66
+ constraint InsertOnly check on update, delete (false),
67
+ constraint OnlyClosed check (
68
+ exists (select 1 from Header H where H.Type = 'c')
69
+ ),
70
+ constraint InviteValid check on insert (
71
+ -- Can only be inserted by a manager,
72
+ exists (select 1 from Manager A
73
+ where A.MemberKey = context.ManagerKey
74
+ and verify(digest(new.Key || '|' || coalesce(new.Expiration, '')), context.ManagerSignature, A.MemberKey, 'ed25519')
75
+ )
76
+ -- and must also prove invite private key held by issuing manager
77
+ and verify(digest(new.Key || '|' || coalesce(new.Expiration, '')), context.InviteSignature, new.Key, 'ed25519')
78
+ )
79
+ ) with context (ManagerKey text null, ManagerSignature text null, InviteSignature text null);
80
+
81
+ -- Invite [InviteKey] has been used to add [MemberKey] as a member
82
+ -- No StampId here or on Invite: both tables are insert-only and their primary keys are
83
+ -- minted fresh per issuance/consumption, so a replayed insert collides on the PK.
84
+ table ConsumedInvite (
85
+ InviteKey text primary key,
86
+ MemberKey text,
87
+ constraint InsertOnly check on update, delete (false),
88
+ constraint InviteExists check (exists (select 1 from Invite I where I.Key = new.InviteKey)),
89
+ constraint MemberExists check (exists (select 1 from Member M where M.Key = new.MemberKey)),
90
+ constraint ValidUsage check on insert (
91
+ exists (select 1 from Invite I where I.Key = new.InviteKey and verify(digest(new.InviteKey || '|' || new.MemberKey), context.InviteSignature, new.InviteKey, 'ed25519'))
92
+ ),
93
+ -- An invite with a non-null Expiration may only be consumed while it is still
94
+ -- in the future. context.Now is the canonical-datetime "now" supplied by the
95
+ -- consumeInvite writer (same canonicalDatetime() transform used to store
96
+ -- Invite.Expiration), so both sides of the comparison are byte-identical
97
+ -- canonical strings and the lexical ">" comparison orders chronologically. A
98
+ -- null Expiration never expires. Mirrors CadreControl.FormationUsage's
99
+ -- "FI.ExpiresAt is null or FI.ExpiresAt > context.Now" gate.
100
+ constraint NotExpired check on insert (
101
+ exists (select 1 from Invite I where I.Key = new.InviteKey and (I.Expiration is null or I.Expiration > context.Now))
102
+ ),
103
+ -- A cancelled invitation may never be consumed. Blocking the ConsumedInvite
104
+ -- insert is sufficient to block the JOIN: Member.Authorized's invite branch
105
+ -- requires a same-transaction FRESH ConsumedInvite row, and consumeInvite writes
106
+ -- both rows in one transaction, so the whole join rolls back. The rule lives here
107
+ -- alone rather than being duplicated into Member.Authorized. Reads only stored
108
+ -- rows — no caller-supplied context decides admission (unlike NotExpired's Now).
109
+ constraint NotCancelled check on insert (
110
+ not exists (select 1 from CancelledInvite C where C.InviteKey = new.InviteKey)
111
+ ),
112
+ -- A SEALED strand (a closed strand whose Manager table its sole manager has
113
+ -- deliberately emptied — see the Manager table comment) admits nobody, and that
114
+ -- includes the holder of an unspent invitation issued BEFORE the seal.
115
+ -- cancelInvite itself needs a manager, so after sealing nobody could kill such
116
+ -- an invitation — this gate is what kills it. The subquery reads the LIVE
117
+ -- Manager table and defers to commit, so it sees the post-image: at least one
118
+ -- manager must survive the transaction, and sealing plus consuming in the SAME
119
+ -- transaction is rejected too. Blocking this insert blocks the whole join, for
120
+ -- exactly the reason NotCancelled gives above.
121
+ constraint NotSealed check on insert (
122
+ exists (select 1 from Manager)
123
+ )
124
+ ) with context (InviteSignature text null, Now datetime null);
125
+
126
+ -- A manager has cancelled invitation [InviteKey]: it may never be consumed.
127
+ --
128
+ -- A monotone TOMBSTONE, not a delete of the Invite row and not a state column on it:
129
+ -- 1. Deleting the Invite row would re-open a replay surface. InviteValid signs only
130
+ -- (Key, Expiration) — there is no per-row StampId on Invite — so a deleted Invite
131
+ -- row can be re-created by replaying the captured issue signatures. Closing that
132
+ -- would mean adding Invite.StampId and widening Revocation.RowIsGone to a fourth
133
+ -- table. (CadreControl.FormationInvite IS deletable — it carries a StampId for
134
+ -- exactly this reason.)
135
+ -- 2. A state column needs an UPDATE, and Invite is insert-only on purpose.
136
+ -- 3. Insert-only converges: a growing set of dead markers merges cleanly across
137
+ -- nodes, a delete/re-insert pair does not. Same rationale as Strand.Revocation.
138
+ -- No StampId here: the table is insert-only and its primary key IS the invite key, so
139
+ -- a replayed insert collides on the PK — the argument the comment above ConsumedInvite
140
+ -- makes for Invite/ConsumedInvite.
141
+ -- Deliberately NO InviteExists gate: a node that has not yet converged on the Invite
142
+ -- row must still be able to record the cancellation. Refusing it would fail OPEN on a
143
+ -- security control, which is strictly worse than accepting a junk row.
144
+ -- NOTE: this table only grows — one row per cancellation, plus any junk a manager
145
+ -- chooses to file, and Immutable means rows never go away (the tradeoff
146
+ -- Revocation.RowIsGone already documents for junk stamps). Fine at strand scale; if
147
+ -- invite churn ever gets large it needs pruning bounded by something other than
148
+ -- "forever".
149
+ -- NOTE: a cancellation must PROPAGATE before the holder redeems — a holder consuming
150
+ -- on a node that has not yet seen this row still gets in. Same convergence class as
151
+ -- the NotRevoked / MinOneMember local-visibility notes and the Manager table's
152
+ -- seal-propagation note; not solved here.
153
+ table CancelledInvite (
154
+ InviteKey text primary key,
155
+ constraint OnlyClosed check (
156
+ exists (select 1 from Header H where H.Type = 'c')
157
+ ),
158
+ -- Cancellation is permanent: un-cancelling would restore the re-entry it closed.
159
+ -- To re-invite a party, issue a fresh invitation.
160
+ constraint Immutable check on update, delete (false),
161
+ -- committed.Manager, not Manager: this check has a subquery so it defers to
162
+ -- commit, by which point a manager seated in the SAME transaction would otherwise
163
+ -- be able to authorize its own cancellation. Member, MemberPeer and Revocation
164
+ -- read committed.* for the same reason. (Manager.Authorized reads the LIVE table
165
+ -- instead — there it is the strict Generation ordering, not a snapshot, that
166
+ -- excludes an authorizer seated in the same transaction.)
167
+ constraint Authorized check on insert (
168
+ exists (select 1 from committed.Manager A
169
+ where A.MemberKey = context.ManagerKey
170
+ and verify(digest('Strand.CancelledInvite', 'cancel', new.InviteKey),
171
+ context.ManagerSignature, A.MemberKey, 'ed25519'))
172
+ )
173
+ ) with context (ManagerKey text, ManagerSignature text);
174
+
175
+ -- A party in the closed strand network
176
+ table Member (
177
+ Key text primary key,
178
+ StampId text not null unique, -- per-row authorization nonce, bound into the signed digests below.
179
+ -- unique holds over LIVE rows only; a removed row's stamp is retired
180
+ -- permanently into Revocation (NotRevoked below), so the never-expiring
181
+ -- admission signature cannot resurrect a revoked member.
182
+ -- A removed row's StampId is retired into Revocation, and this refuses any insert
183
+ -- naming a retired stamp: the approval that seated this row can never re-seat it
184
+ -- after removal. unique alone did not do this — it only holds over LIVE rows, so
185
+ -- a delete freed the stamp and the original never-expiring signature verified
186
+ -- again. A legitimate re-add mints a FRESH StampId and a fresh signature, so it is
187
+ -- unaffected. Mirrors CadreControl.OwnerKey.NotRevoked (rationale in full there).
188
+ constraint NotRevoked check on insert (
189
+ not exists (select 1 from Revocation R where R.TableName = 'Member' and R.StampId = new.StampId)
190
+ ),
191
+ -- Retirement is MANDATORY: a delete must carry the matching Revocation row in the
192
+ -- same transaction, or the stamp would free up again. Deferred (subquery), so the
193
+ -- sibling insert is visible at commit regardless of statement order.
194
+ constraint RevocationRecorded check on delete (
195
+ exists (select 1 from Revocation R where R.TableName = 'Member' and R.StampId = old.StampId)
196
+ ),
197
+ -- Membership is insert + delete only; a re-keyed member is a remove + fresh add.
198
+ constraint NoUpdate check on update (false),
199
+ -- Mask is explicit on all three operations: a bare check defaults to
200
+ -- insert|update and silently never runs on DELETE.
201
+ constraint OnlyClosed check on insert, update, delete (
202
+ exists (select 1 from Header H where H.Type = 'c')
203
+ ),
204
+ -- A closed strand must never lose its last member: Member is the strand's read
205
+ -- gate, and the founding Manager is only seatable beside an existing Member, so
206
+ -- an empty member set is a permanent denial of the whole strand. Deferred
207
+ -- (subquery), so the count it sees is the POST-delete count.
208
+ -- NOTE: this is a per-transaction check against locally visible rows. Two nodes
209
+ -- that concurrently remove different members can each see a surviving one and
210
+ -- still converge to zero; if partitioned removal ever becomes a real workflow,
211
+ -- the floor needs a cross-node guard, not a local count.
212
+ constraint MinOneMember check on delete (
213
+ (select count(1) from Member) >= 1
214
+ ),
215
+ -- A member still holding a Manager row cannot be un-membered — it must resign
216
+ -- its Manager row first. Deferred (subquery), so it reads the POST-image: a
217
+ -- single transaction deleting BOTH the Manager and the Member row passes.
218
+ constraint NotAManager check on delete (
219
+ not exists (select 1 from Manager A where A.MemberKey = old.Key)
220
+ ),
221
+ constraint Authorized check on insert, delete (
222
+ -- Every authorizer is read from the PRE-transaction snapshot (committed.*):
223
+ -- this CHECK auto-defers to commit (it has a subquery), by which point rows
224
+ -- inserted alongside are live, so a plain from-Manager would let a manager
225
+ -- seated in this same transaction authorize a removal. committed.* states
226
+ -- the rule directly: the authorizer existed BEFORE this transaction.
227
+
228
+ -- Every signed digest below leads with two fixed literals — the
229
+ -- 'Strand.Member' domain tag and an action tag — so an approval verifies
230
+ -- ONLY against the one rule it was minted for (the idiom of CadreControl's
231
+ -- control-authorization.ts), and binds the row's StampId, so it authorizes
232
+ -- exactly one INCARNATION of the row: once the row is deleted (retiring the
233
+ -- stamp) or re-created under a fresh stamp, the captured approval matches
234
+ -- nothing.
235
+
236
+ -- Bootstrap: the FOUNDING transaction — one whose pre-transaction member
237
+ -- set is empty — seats the first member, AND ONLY THAT ONE, with no
238
+ -- authorization. Both counts are load-bearing. The committed (PRE-image)
239
+ -- count keeps the branch off for a DELETE and for the insert half of a
240
+ -- same-transaction wipe-then-seat (the wiped rows were committed). The
241
+ -- live (POST-image) count keeps a single founding transaction from
242
+ -- seating an unbounded unauthenticated member set — which would also
243
+ -- brick the strand, since Manager's own bootstrap branch requires at
244
+ -- most one Member.
245
+ (old.Key is null
246
+ and (select count(1) from committed.Member) = 0
247
+ and (select count(1) from Member) <= 1)
248
+
249
+ -- or added directly by a pre-existing manager signing the add-tagged
250
+ -- digest over (Key, StampId) — bound to this row's fresh stamp, so the
251
+ -- approval seats exactly this incarnation and dies with it.
252
+ or (old.Key is null and exists (
253
+ select 1 from committed.Manager A
254
+ where A.MemberKey = context.ManagerKey
255
+ and verify(digest('Strand.Member', 'add', new.Key, new.StampId), context.ManagerSignature, A.MemberKey, 'ed25519')
256
+ ))
257
+
258
+ -- or added by consuming an invite. The ConsumedInvite admitting this
259
+ -- member must be same-transaction FRESH — its InviteKey absent from the
260
+ -- committed snapshot. consumeInvite inserts Member + ConsumedInvite in
261
+ -- one transaction, so a genuine join passes; a revoked member's STALE
262
+ -- committed row no longer re-admits it, while a fresh manager-issued
263
+ -- invite still does (new InviteKey → uncommitted row). InviteExists +
264
+ -- ValidUsage on ConsumedInvite keep this branch mintable only via a
265
+ -- real invite and possession of its private key.
266
+ or (old.Key is null and exists (
267
+ select 1 from ConsumedInvite CI
268
+ where CI.MemberKey = new.Key
269
+ and not exists (select 1 from committed.ConsumedInvite CC where CC.InviteKey = CI.InviteKey)
270
+ ))
271
+
272
+ -- or a removal authorized by a pre-existing manager over the DISTINCT
273
+ -- remove-tagged digest bound to the departing row's (Key, StampId).
274
+ or (new.Key is null and exists (
275
+ select 1 from committed.Manager A
276
+ where A.MemberKey = context.ManagerKey
277
+ and verify(digest('Strand.Member', 'remove', old.Key, old.StampId), context.ManagerSignature, A.MemberKey, 'ed25519')
278
+ ))
279
+
280
+ -- or the member removes ITSELF (leaving): the leave-tagged signature
281
+ -- verifies against old.Key itself, so only the departing key's holder
282
+ -- can produce it — no manager involved. The 'leave' tag (vs the manager
283
+ -- branch's 'remove') is belt-and-braces ONLY: the two branches verify
284
+ -- against DIFFERENT keys, so the former shared tag was never
285
+ -- exploitable — the anti-replay fix here is the StampId binding, not
286
+ -- this tag.
287
+ or (new.Key is null
288
+ and verify(digest('Strand.Member', 'leave', old.Key, old.StampId), context.MemberSignature, old.Key, 'ed25519'))
289
+ ),
290
+ ) with context (ManagerKey text null, ManagerSignature text null, MemberSignature text null);
291
+
292
+ -- A member-associated peer (node)
293
+ --
294
+ -- NOTE: a MemberPeer row can OUTLIVE the Member row it names. MemberExists runs on
295
+ -- insert only, and nothing cascades a Member delete, so revoking a member leaves its
296
+ -- peer bindings behind as orphans until a manager clears them (removeMemberPeer).
297
+ -- Any consumer that treats a MemberPeer row as evidence of membership MUST join to
298
+ -- Member rather than trusting the row alone.
299
+ table MemberPeer (
300
+ MemberKey text,
301
+ PeerId text,
302
+ StampId text not null unique, -- per-row authorization nonce, bound into the signed
303
+ -- digests below; retired into Revocation on delete
304
+ -- (same idiom as Member.StampId, rationale there).
305
+ primary key (MemberKey, PeerId),
306
+ -- Refuses any insert naming a retired stamp — see Member.NotRevoked.
307
+ constraint NotRevoked check on insert (
308
+ not exists (select 1 from Revocation R where R.TableName = 'MemberPeer' and R.StampId = new.StampId)
309
+ ),
310
+ -- A delete must retire its stamp in the same transaction — see Member.RevocationRecorded.
311
+ constraint RevocationRecorded check on delete (
312
+ exists (select 1 from Revocation R where R.TableName = 'MemberPeer' and R.StampId = old.StampId)
313
+ ),
314
+ -- Binding is insert + delete only, as on Member and Manager; a re-binding is a
315
+ -- remove plus a fresh register. The whole visible identity IS the primary key, so
316
+ -- an update is never an edit — and Authorized reads the NEW image, so an update
317
+ -- would let ANY member re-point another member's row at its OWN key, signing only
318
+ -- over its own new values, and thereby clear a binding it has no signature to
319
+ -- delete.
320
+ constraint NoUpdate check on update (false),
321
+ -- Mask is explicit: a bare check defaults to insert|update and silently never runs on
322
+ -- DELETE. Insert is the only reachable case — update is forbidden above, and a delete
323
+ -- leaves no new row image to validate.
324
+ constraint MemberExists check on insert (exists (select 1 from Member M where M.Key = new.MemberKey)),
325
+ constraint Authorized check on insert, delete (
326
+ -- The member itself signs its own binding, verified against the MemberKey the
327
+ -- row names — so only that member registers (or removes) its own peers. The
328
+ -- registration and removal approvals are DISTINCT action-tagged digests, each
329
+ -- bound to the row's StampId, so a captured registration approval neither
330
+ -- re-registers a cleared binding (its stamp is retired) nor authorizes a
331
+ -- removal (wrong tag).
332
+ (old.MemberKey is null
333
+ and verify(digest('Strand.MemberPeer', 'add', new.MemberKey, new.PeerId, new.StampId), context.Signature, new.MemberKey, 'ed25519'))
334
+
335
+ -- or the member clears its OWN binding.
336
+ or (new.MemberKey is null
337
+ and verify(digest('Strand.MemberPeer', 'remove', old.MemberKey, old.PeerId, old.StampId), context.Signature, old.MemberKey, 'ed25519'))
338
+
339
+ -- or a manager clears ANOTHER member's binding — the cleanup path after a
340
+ -- revocation, which the departing member has no reason to cooperate with.
341
+ -- Gated to DELETE and bound to a manager-remove-tagged digest over the exact
342
+ -- row + stamp. The 'manager-remove' tag (vs the self branch's 'remove') is
343
+ -- belt-and-braces ONLY — the two branches verify against different keys.
344
+ -- The authorizer is read from the PRE-transaction snapshot
345
+ -- (committed.Manager) for the same reason Member.Authorized does: this check
346
+ -- defers to commit, by which point a manager seated in the SAME transaction
347
+ -- would otherwise be able to authorize its own cleanup.
348
+ or (new.MemberKey is null and exists (
349
+ select 1 from committed.Manager A
350
+ where A.MemberKey = context.ManagerKey
351
+ and verify(digest('Strand.MemberPeer', 'manager-remove', old.MemberKey, old.PeerId, old.StampId), context.ManagerSignature, A.MemberKey, 'ed25519')
352
+ ))
353
+ ),
354
+ ) with context (Signature text null, ManagerKey text null, ManagerSignature text null);
355
+
356
+ -- A manager is a member that can issue invites, authorize members, and rotate managers
357
+ table Manager (
358
+ MemberKey text primary key,
359
+ -- Lineage ordering. The founding manager is generation 0; every later manager is
360
+ -- seated strictly after (greater than) the manager that appointed it. This is what
361
+ -- makes same-transaction mutual promotion impossible: a deferred CHECK sees only
362
+ -- the post-image, so "did my authorizer exist before this transaction?" is not
363
+ -- directly askable — but the minimum-generation row of any inserted set must find
364
+ -- its authorizer among rows of strictly smaller generation, which can only be a
365
+ -- pre-existing one. Generation is NOT a privilege level: a generation-5 manager
366
+ -- has exactly the same powers as a generation-1 manager.
367
+ Generation integer not null,
368
+ StampId text not null unique, -- per-row authorization nonce, bound into the signed
369
+ -- digests below; retired into Revocation on delete
370
+ -- (same idiom as Member.StampId, rationale there).
371
+ -- Refuses any insert naming a retired stamp — see Member.NotRevoked. This is what
372
+ -- makes a captured promotion approval single-use: once the manager is removed (its
373
+ -- stamp retired), the approval that seated it can never re-seat it.
374
+ constraint NotRevoked check on insert (
375
+ not exists (select 1 from Revocation R where R.TableName = 'Manager' and R.StampId = new.StampId)
376
+ ),
377
+ -- A delete must retire its stamp in the same transaction — see Member.RevocationRecorded.
378
+ constraint RevocationRecorded check on delete (
379
+ exists (select 1 from Revocation R where R.TableName = 'Manager' and R.StampId = old.StampId)
380
+ ),
381
+ constraint OnlyClosed check (
382
+ exists (select 1 from Header H where H.Type = 'c')
383
+ ),
384
+ -- A manager IS a member: the table comment has always said so, and since the
385
+ -- single-use-approval work it is load-bearing — Revocation.Authorized verifies a
386
+ -- delete's tombstone against a committed Member row, so a manager with no Member
387
+ -- row cannot revoke, clear a peer binding, or even resign. Reads the LIVE Member
388
+ -- table, not committed.Member, so an admit-then-promote in ONE transaction passes
389
+ -- (the same shape ConsumedInvite.MemberExists allows for the invite join); the
390
+ -- sibling Member insert is authorized at that same commit, so nothing is waived.
391
+ -- Mask is explicit: a bare check defaults to insert|update, and update is
392
+ -- forbidden outright by NoUpdate below. Nothing is needed on DELETE — the other
393
+ -- half of the invariant is Member.NotAManager, which refuses to un-member a key
394
+ -- that still holds a Manager row.
395
+ -- NOTE: a per-transaction check against locally visible rows. Two partitioned nodes
396
+ -- — one promoting X, one removing X's Member and Manager rows — can each pass
397
+ -- locally and converge to a Manager row with no Member row. Same convergence class
398
+ -- as the MinOneMember / NotRevoked / seal-propagation notes; not solved here.
399
+ constraint MemberExists check on insert (
400
+ exists (select 1 from Member M where M.Key = new.MemberKey)
401
+ ),
402
+ -- Rotation is insert + delete only. An update would let a resignation signature
403
+ -- (which proves only that old.MemberKey consented) double as a hand-off: the
404
+ -- former-manager branch would accept re-pointing the row at any new key.
405
+ constraint NoUpdate check on update (false),
406
+ -- A closed strand loses its LAST manager only by SEALING — a distinct,
407
+ -- self-signed act (the 'seal' branch of Authorized below), never an ordinary
408
+ -- resignation and never an admin removal. There is deliberately NO
409
+ -- min-one-manager floor: a strand with no managers is frozen, not bricked.
410
+ -- Every admission path (Invite, addMemberByManager, addManager) requires a
411
+ -- Manager row, so a sealed strand's membership can never grow again — and that
412
+ -- freeze is the privacy guarantee its remaining members are buying: nobody
413
+ -- holds the power to admit a party who would then read the strand's whole
414
+ -- history. Sealing is irreversible: the founding branch of Authorized closes
415
+ -- for good once any manager stamp has been retired into Revocation.
416
+ -- NOTE: a seal must PROPAGATE before it binds a given node — a node that has
417
+ -- not yet converged on the delete still shows the manager row, so the
418
+ -- ex-manager's OWN key could still admit there until the delete arrives. Only
419
+ -- that one key; no stranger gains anything. Same convergence class as the
420
+ -- MinOneMember / NotRevoked local-visibility notes; not solved here.
421
+ constraint Authorized check on insert, delete (
422
+ -- Every signed branch below is a domain/action-tagged digest bound to the
423
+ -- row's StampId — the same single-use idiom as Member.Authorized: an
424
+ -- approval seats (or removes) exactly one incarnation of one row.
425
+
426
+ -- Bootstrap: the founding manager is seated with no prior signer, at
427
+ -- generation 0. Gated to INSERT (old.MemberKey is null) AND to the founding
428
+ -- state — at most one Member, and this manager IS that member. Deferred
429
+ -- checks see post-image state, so an ungated count test is also true for a
430
+ -- DELETE that drops the count to <= 1, and for the INSERT half of a
431
+ -- same-transaction swap of the sole manager. The trailing Member-exists test
432
+ -- is belt-and-braces since MemberExists above enforces it on EVERY insert —
433
+ -- kept so this branch's own claim ("this manager IS that member") reads in
434
+ -- place rather than by cross-reference.
435
+ -- The Revocation gate closes this branch FOREVER once any manager seat has
436
+ -- been retired: founding happens exactly once per strand (Header, then
437
+ -- Member, then Manager, with no Revocation rows in existence), so on a
438
+ -- SEALED strand a lone surviving member cannot re-seat itself as a
439
+ -- generation-0 founder and start admitting. Accepted, deliberate: between
440
+ -- its own founding Member and Manager inserts a founder could, with its own
441
+ -- key, file a junk Revocation('Manager', ...) tombstone (RowIsGone permits
442
+ -- stamps naming no real row) and permanently block its own founding — pure
443
+ -- self-harm needing the founder's private key; no other party can reach
444
+ -- that window (there are no other committed members yet).
445
+ (old.MemberKey is null
446
+ and new.Generation = 0
447
+ and (select count(1) from Manager) <= 1
448
+ and (select count(1) from Member) <= 1
449
+ and exists (select 1 from Member M where M.Key = new.MemberKey)
450
+ and not exists (select 1 from Revocation R where R.TableName = 'Manager'))
451
+
452
+ -- or removed by the departing manager ITSELF, over one of two
453
+ -- self-signed, action-tagged digests bound to its own row's stamp. The
454
+ -- POST-image manager count is what distinguishes the two: 'resign' is
455
+ -- an ordinary departure that leaves at least one manager standing,
456
+ -- while 'seal' is the delete that empties the table and permanently
457
+ -- freezes admission (see the table comment above). The tag is what
458
+ -- makes each a signed statement of THAT intent — a captured
459
+ -- resignation approval can never seal, nor a seal approval merely
460
+ -- resign — and the stamp is still what makes each single-use: once
461
+ -- this manager is re-promoted under a fresh stamp, neither captured
462
+ -- approval removes the new row. Both counts are subqueries, so the
463
+ -- whole check defers to commit and the counts are the post-image.
464
+ or (
465
+ old.MemberKey is not null
466
+ and old.MemberKey = context.ManagerKey
467
+ and (select count(1) from Manager) >= 1
468
+ and verify(digest('Strand.Manager', 'resign', old.MemberKey, old.StampId), context.Signature, old.MemberKey, 'ed25519')
469
+ )
470
+ or (
471
+ old.MemberKey is not null
472
+ and old.MemberKey = context.ManagerKey
473
+ and (select count(1) from Manager) = 0
474
+ and verify(digest('Strand.Manager', 'seal', old.MemberKey, old.StampId), context.Signature, old.MemberKey, 'ed25519')
475
+ )
476
+
477
+ -- or a promotion signed by an EARLIER-generation manager, over the
478
+ -- add-tagged digest binding (MemberKey, Generation, StampId) — so a
479
+ -- captured promotion replays at neither a different generation nor a
480
+ -- second incarnation of the row. The strict
481
+ -- A.Generation < new.Generation is what closes same-transaction mutual
482
+ -- promotion: this subquery runs at commit against the post-insert row
483
+ -- set, so sibling rows inserted in the same transaction are visible —
484
+ -- but the minimum-generation row of any inserted set cannot find its
485
+ -- authorizer among its siblings (that would contradict minimality), so
486
+ -- that authorizer must be a pre-existing manager who really signed.
487
+ -- A mutual pair or ring needs each generation strictly below another's
488
+ -- in a cycle; impossible. Nothing sits below the founder's 0, so a
489
+ -- chain cannot duck underneath either. The <> is subsumed by the
490
+ -- ordering (a row's generation is never below its own) but kept to
491
+ -- state the no-self-promotion intent locally.
492
+ or (old.MemberKey is null and exists (
493
+ select 1 from Manager A
494
+ where A.MemberKey = context.ManagerKey
495
+ and A.MemberKey <> new.MemberKey
496
+ and A.Generation < new.Generation
497
+ and verify(digest('Strand.Manager', 'add', new.MemberKey, new.Generation, new.StampId), context.Signature, A.MemberKey, 'ed25519')))
498
+
499
+ -- or a removal authorized by ANOTHER existing manager, over the
500
+ -- remove-tagged digest bound to (MemberKey, StampId). Deliberately no
501
+ -- generation condition here: deletes are safe once inserts are (every
502
+ -- accepting branch requires a Manager row in the post-image, and no
503
+ -- attacker row can get there), and a generation gate would break a
504
+ -- later-generation manager removing an earlier-generation one.
505
+ or (new.MemberKey is null and exists (
506
+ select 1 from Manager A
507
+ where A.MemberKey = context.ManagerKey
508
+ and A.MemberKey <> old.MemberKey
509
+ and verify(digest('Strand.Manager', 'remove', old.MemberKey, old.StampId), context.Signature, A.MemberKey, 'ed25519')))
510
+ )
511
+ ) with context (ManagerKey text null, Signature text null);
512
+
513
+ -- Permanent tombstones for retired per-row authorization nonces (StampIds) — the
514
+ -- delete half of the retired-stamp anti-replay idiom on Member / Manager /
515
+ -- MemberPeer. Adapted from CadreControl.Revocation (the idiom is stated in full on
516
+ -- CadreControl.OwnerKey); two deliberate differences:
517
+ -- 1. TableName is confined to the three guarded STRAND tables (RowIsGone below).
518
+ -- 2. Authorized verifies against a committed MEMBER row, not an owner key: every
519
+ -- delete path in this schema is driven by a manager or by the departing party,
520
+ -- and both are members. The action tag is 'retire' where the control layer
521
+ -- says 'remove'; the domain tag already keeps the two schemas' approvals
522
+ -- disjoint, so the divergence is cosmetic — kept because 'retire' names the
523
+ -- act (retiring a stamp), not a row operation.
524
+ -- NOTE: the guarded tables' NotRevoked CHECK runs against locally visible rows, so
525
+ -- a node that has not yet converged on a Revocation row can still accept a replayed
526
+ -- add and the resurrected row coexists with the tombstone after merge — the same
527
+ -- convergence class as the MinOneMember / Manager seal-propagation notes.
528
+ -- NOTE: this table only grows — one row per membership deletion, plus any junk any
529
+ -- member chooses to file, and Immutable means rows never go away. Fine at strand
530
+ -- scale; if membership churn ever gets large it needs pruning bounded by something
531
+ -- other than "forever".
532
+ -- NOTE: nothing on the strand read side drops a row because its stamp is retired
533
+ -- (NotRevoked is insert-only), which is why a tombstone filed against a live but
534
+ -- not-yet-converged row is inert here. If a read path ever starts filtering on
535
+ -- Revocation (as the control layer's listAuthorizedMembers does), filing a tombstone
536
+ -- becomes a remote eviction primitive and Authorized below must narrow from "any
537
+ -- member" to "a manager, or the row's own owner".
538
+ table Revocation (
539
+ TableName text, -- 'Member' | 'Manager' | 'MemberPeer' (confined by RowIsGone below)
540
+ StampId text, -- the retired nonce
541
+ primary key (TableName, StampId),
542
+ constraint OnlyClosed check (
543
+ exists (select 1 from Header H where H.Type = 'c')
544
+ ),
545
+ -- Retirement is permanent: clearing or re-pointing a tombstone would restore the replay.
546
+ constraint Immutable check on update, delete (false),
547
+ -- A stamp may only be retired once its row is actually gone. Deferred (subquery), so a
548
+ -- delete in the same transaction has already landed. Retiring a stamp that never
549
+ -- existed is permitted and harmless: a stamp carries 256 bits of CSPRNG output
550
+ -- (strand-membership-writer.ts generateStrandStampId), so a future legitimate one
551
+ -- cannot be guessed and pre-planted — junk stamps only grow the table.
552
+ constraint RowIsGone check on insert (
553
+ (new.TableName = 'Member' and not exists (select 1 from Member M where M.StampId = new.StampId))
554
+ or (new.TableName = 'Manager' and not exists (select 1 from Manager A where A.StampId = new.StampId))
555
+ or (new.TableName = 'MemberPeer' and not exists (select 1 from MemberPeer P where P.StampId = new.StampId))
556
+ ),
557
+ -- Filing a tombstone is a MEMBER action: every legitimate delete in this schema is
558
+ -- driven by a manager or by the departing party — both members — and the tombstone
559
+ -- rides the delete's own transaction. The authorizer is read from the
560
+ -- PRE-transaction snapshot (committed.Member) for the same reason the sibling
561
+ -- tables read committed.*: this check defers to commit, by which point a member
562
+ -- seated in the same transaction is live. The digest binds the WHOLE row
563
+ -- (TableName, StampId) under its own domain tag, so it is disjoint from every
564
+ -- other rule — including the remove-tagged approval the same party signs in the
565
+ -- SAME transaction for the delete this tombstone accompanies. RowIsGone and
566
+ -- Immutable guard what authorization does not: retiring a stamp early, or
567
+ -- withdrawing one later.
568
+ constraint Authorized check on insert (
569
+ exists (select 1 from committed.Member M
570
+ where M.Key = context.MemberKey
571
+ and verify(digest('Strand.Revocation', 'retire', new.TableName, new.StampId), context.Signature, M.Key, 'ed25519'))
572
+ )
573
+ ) with context (MemberKey text, Signature text);
533
574
  `;
534
575
  //# sourceMappingURL=strand-schema.js.map