@serfab/quereus-plugin-sereus 0.9.0 → 0.10.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,145 +27,508 @@
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
- -- TODO: ability to deactivate?
65
- constraint InsertOnly check on update, delete (false),
66
- constraint OnlyClosed check (
67
- exists (select 1 from Header H where H.Type = 'c')
68
- ),
69
- constraint InviteValid check on insert (
70
- -- Can only be inserted by a manager,
71
- exists (select 1 from Manager A
72
- where A.MemberKey = context.ManagerKey
73
- and verify(digest(new.Key || '|' || coalesce(new.Expiration, '')), context.ManagerSignature, A.MemberKey, 'ed25519')
74
- )
75
- -- and must also prove invite private key held by issuing manager
76
- and verify(digest(new.Key || '|' || coalesce(new.Expiration, '')), context.InviteSignature, new.Key, 'ed25519')
77
- )
78
- ) with context (ManagerKey text null, ManagerSignature text null, InviteSignature text null);
79
-
80
- -- Invite [InviteKey] has been used to add [MemberKey] as a member
81
- table ConsumedInvite (
82
- InviteKey text primary key,
83
- MemberKey text,
84
- constraint InsertOnly check on update, delete (false),
85
- constraint InviteExists check (exists (select 1 from Invite I where I.Key = new.InviteKey)),
86
- constraint MemberExists check (exists (select 1 from Member M where M.Key = new.MemberKey)),
87
- constraint ValidUsage check on insert (
88
- exists (select 1 from Invite I where I.Key = new.InviteKey and verify(digest(new.InviteKey || '|' || new.MemberKey), context.InviteSignature, new.InviteKey, 'ed25519'))
89
- ),
90
- -- An invite with a non-null Expiration may only be consumed while it is still
91
- -- in the future. context.Now is the canonical-datetime "now" supplied by the
92
- -- consumeInvite writer (same canonicalDatetime() transform used to store
93
- -- Invite.Expiration), so both sides of the comparison are byte-identical
94
- -- canonical strings and the lexical ">" comparison orders chronologically. A
95
- -- null Expiration never expires. Mirrors CadreControl.FormationUsage's
96
- -- "FI.ExpiresAt is null or FI.ExpiresAt > context.Now" gate.
97
- constraint NotExpired check on insert (
98
- exists (select 1 from Invite I where I.Key = new.InviteKey and (I.Expiration is null or I.Expiration > context.Now))
99
- ),
100
- constraint MemberValid check (exists (select 1 from Member M where M.Key = new.MemberKey))
101
- ) with context (InviteSignature text null, Now datetime null);
102
-
103
- -- A party in the closed strand network
104
- table Member (
105
- Key text primary key,
106
- constraint NoUpdate check on update (false),
107
- constraint OnlyClosed check (
108
- exists (select 1 from Header H where H.Type = 'c')
109
- ),
110
- constraint Authorized check on insert (
111
- -- There are no other records - first member needs no authorization
112
- (select count(1) from Member) <= 1
113
-
114
- -- or added directly by manager
115
- or exists (
116
- select 1 from Manager A
117
- where A.MemberKey = context.ManagerKey
118
- and verify(digest(new.Key), context.ManagerSignature, A.MemberKey, 'ed25519')
119
- )
120
-
121
- -- or added by invite
122
- or exists (
123
- select 1 from ConsumedInvite CI where CI.MemberKey = new.Key
124
- )
125
- ),
126
- -- TODO: handle member revocation constraint
127
- ) with context (ManagerKey text null, ManagerSignature text null);
128
-
129
- -- A member-associated peer (node)
130
- table MemberPeer (
131
- MemberKey text,
132
- PeerId text,
133
- primary key (MemberKey, PeerId),
134
- constraint MemberExists check (exists (select 1 from Member M where M.Key = new.MemberKey)),
135
- constraint Authorized check on insert, update, delete (
136
- verify(
137
- digest(coalesce(new.MemberKey, old.MemberKey) || '|' || coalesce(new.PeerId, old.PeerId)),
138
- context.Signature,
139
- coalesce(new.MemberKey, old.MemberKey),
140
- 'ed25519'
141
- )
142
- ),
143
- ) with context (Signature text null);
144
-
145
- -- A manager is a member that can issue invites, authorize members, and rotate managers
146
- table Manager (
147
- MemberKey text primary key,
148
- constraint OnlyClosed check (
149
- exists (select 1 from Header H where H.Type = 'c')
150
- ),
151
- constraint Authorized check on insert, update, delete (
152
- -- There are no existing records - first manager needs no authorization
153
- (select count(1) from Manager) <= 1
154
-
155
- -- or authorized by this former manager
156
- or (
157
- old.MemberKey is not null
158
- and old.MemberKey = context.ManagerKey
159
- and verify(digest(old.MemberKey), context.Signature, old.MemberKey, 'ed25519')
160
- )
161
-
162
- -- or authorized by another existing manager
163
- or exists (
164
- select 1 from Manager A
165
- where A.MemberKey = context.ManagerKey
166
- and verify(digest(coalesce(new.MemberKey, old.MemberKey)), context.Signature, A.MemberKey, 'ed25519')
167
- )
168
- )
169
- ) with context (ManagerKey text null, Signature text null);
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);
170
533
  `;
171
534
  //# sourceMappingURL=strand-schema.js.map