@serfab/cadre-core 0.12.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +23 -8
  2. package/dist/bootstrap-peer-store-file.js +2 -2
  3. package/dist/bootstrap-peer-store.d.ts +32 -12
  4. package/dist/bootstrap-peer-store.js +41 -15
  5. package/dist/bootstrap-peer-store.js.map +1 -1
  6. package/dist/cadre-node.d.ts +883 -102
  7. package/dist/cadre-node.js +1800 -209
  8. package/dist/cadre-node.js.map +1 -1
  9. package/dist/control-authorization.d.ts +2 -2
  10. package/dist/control-authorization.js +1 -0
  11. package/dist/control-authorization.js.map +1 -1
  12. package/dist/control-cohort.d.ts +13 -26
  13. package/dist/control-cohort.js +0 -26
  14. package/dist/control-cohort.js.map +1 -1
  15. package/dist/control-database.d.ts +318 -44
  16. package/dist/control-database.js +516 -74
  17. package/dist/control-database.js.map +1 -1
  18. package/dist/control-read-retry.d.ts +40 -11
  19. package/dist/control-read-retry.js +48 -15
  20. package/dist/control-read-retry.js.map +1 -1
  21. package/dist/control-retry.d.ts +58 -3
  22. package/dist/control-retry.js +58 -3
  23. package/dist/control-retry.js.map +1 -1
  24. package/dist/control-schema.d.ts +1 -1
  25. package/dist/control-schema.js +133 -21
  26. package/dist/control-schema.js.map +1 -1
  27. package/dist/control-write-retry.d.ts +80 -11
  28. package/dist/control-write-retry.js +168 -29
  29. package/dist/control-write-retry.js.map +1 -1
  30. package/dist/enrolled-machine-store-file.d.ts +13 -0
  31. package/dist/enrolled-machine-store-file.js +41 -0
  32. package/dist/enrolled-machine-store-file.js.map +1 -0
  33. package/dist/enrolled-machine-store.d.ts +81 -0
  34. package/dist/enrolled-machine-store.js +225 -0
  35. package/dist/enrolled-machine-store.js.map +1 -0
  36. package/dist/file-durable-slot.js +5 -3
  37. package/dist/file-durable-slot.js.map +1 -1
  38. package/dist/index.d.ts +17 -8
  39. package/dist/index.js +54 -9
  40. package/dist/index.js.map +1 -1
  41. package/dist/membership-connection-gater.d.ts +15 -1
  42. package/dist/membership-connection-gater.js +9 -3
  43. package/dist/membership-connection-gater.js.map +1 -1
  44. package/dist/node-local-snapshot.d.ts +21 -7
  45. package/dist/node-local-snapshot.js +30 -9
  46. package/dist/node-local-snapshot.js.map +1 -1
  47. package/dist/peer-dial.d.ts +143 -0
  48. package/dist/peer-dial.js +176 -0
  49. package/dist/peer-dial.js.map +1 -0
  50. package/dist/{strand-backfill.d.ts → peer-join-backfill.d.ts} +49 -20
  51. package/dist/{strand-backfill.js → peer-join-backfill.js} +133 -52
  52. package/dist/peer-join-backfill.js.map +1 -0
  53. package/dist/relay-addrs.d.ts +160 -49
  54. package/dist/relay-addrs.js +287 -60
  55. package/dist/relay-addrs.js.map +1 -1
  56. package/dist/relay-reservation.d.ts +71 -3
  57. package/dist/relay-reservation.js +116 -8
  58. package/dist/relay-reservation.js.map +1 -1
  59. package/dist/seed-bootstrap.d.ts +30 -1
  60. package/dist/seed-bootstrap.js +62 -17
  61. package/dist/seed-bootstrap.js.map +1 -1
  62. package/dist/storage-scope.d.ts +62 -0
  63. package/dist/storage-scope.js +75 -0
  64. package/dist/storage-scope.js.map +1 -0
  65. package/dist/strand-database.d.ts +41 -11
  66. package/dist/strand-database.js +48 -18
  67. package/dist/strand-database.js.map +1 -1
  68. package/dist/strand-first-sync-gate.d.ts +152 -0
  69. package/dist/strand-first-sync-gate.js +208 -0
  70. package/dist/strand-first-sync-gate.js.map +1 -0
  71. package/dist/strand-formation-manager.d.ts +99 -5
  72. package/dist/strand-formation-manager.js +119 -8
  73. package/dist/strand-formation-manager.js.map +1 -1
  74. package/dist/strand-formation-protocol.d.ts +97 -3
  75. package/dist/strand-formation-protocol.js +108 -2
  76. package/dist/strand-formation-protocol.js.map +1 -1
  77. package/dist/strand-instance-manager.d.ts +389 -19
  78. package/dist/strand-instance-manager.js +678 -65
  79. package/dist/strand-instance-manager.js.map +1 -1
  80. package/dist/strand-member-key.d.ts +20 -12
  81. package/dist/strand-member-key.js +20 -12
  82. package/dist/strand-member-key.js.map +1 -1
  83. package/dist/strand-member-registry.js +3 -0
  84. package/dist/strand-member-registry.js.map +1 -1
  85. package/dist/strand-membership-reconciler.d.ts +361 -0
  86. package/dist/strand-membership-reconciler.js +548 -0
  87. package/dist/strand-membership-reconciler.js.map +1 -0
  88. package/dist/strand-membership-writer.d.ts +143 -11
  89. package/dist/strand-membership-writer.js +419 -135
  90. package/dist/strand-membership-writer.js.map +1 -1
  91. package/dist/strand-network-config.d.ts +116 -0
  92. package/dist/strand-network-config.js +192 -0
  93. package/dist/strand-network-config.js.map +1 -0
  94. package/dist/strand-revocation-enforcer.d.ts +392 -0
  95. package/dist/strand-revocation-enforcer.js +533 -0
  96. package/dist/strand-revocation-enforcer.js.map +1 -0
  97. package/dist/strand-solicitation.d.ts +17 -1
  98. package/dist/strand-solicitation.js +13 -2
  99. package/dist/strand-solicitation.js.map +1 -1
  100. package/dist/strand-wake-protocol.d.ts +11 -7
  101. package/dist/strand-wake-protocol.js +20 -52
  102. package/dist/strand-wake-protocol.js.map +1 -1
  103. package/dist/strand-watcher.js +12 -1
  104. package/dist/strand-watcher.js.map +1 -1
  105. package/dist/timed-step.d.ts +12 -0
  106. package/dist/timed-step.js +28 -0
  107. package/dist/timed-step.js.map +1 -0
  108. package/dist/timeout-scheduler.d.ts +19 -0
  109. package/dist/timeout-scheduler.js +22 -0
  110. package/dist/timeout-scheduler.js.map +1 -0
  111. package/dist/types.d.ts +472 -46
  112. package/dist/types.js +7 -1
  113. package/dist/types.js.map +1 -1
  114. package/package.json +16 -8
  115. package/src/bootstrap-peer-store-file.ts +2 -2
  116. package/src/bootstrap-peer-store.ts +69 -27
  117. package/src/cadre-node.ts +1926 -228
  118. package/src/control-authorization.ts +2 -1
  119. package/src/control-cohort.ts +122 -130
  120. package/src/control-database.ts +586 -74
  121. package/src/control-read-retry.ts +202 -167
  122. package/src/control-retry.ts +297 -190
  123. package/src/control-schema.ts +133 -21
  124. package/src/control-write-retry.ts +525 -383
  125. package/src/enrolled-machine-store-file.ts +43 -0
  126. package/src/enrolled-machine-store.ts +280 -0
  127. package/src/file-durable-slot.ts +5 -3
  128. package/src/index.ts +134 -15
  129. package/src/membership-connection-gater.ts +9 -3
  130. package/src/node-local-snapshot.ts +38 -9
  131. package/src/peer-addr-book.ts +251 -250
  132. package/src/peer-dial.ts +215 -0
  133. package/src/{strand-backfill.ts → peer-join-backfill.ts} +478 -389
  134. package/src/relay-addrs.ts +458 -218
  135. package/src/relay-reservation.ts +951 -828
  136. package/src/seed-bootstrap.ts +76 -18
  137. package/src/storage-scope.ts +78 -0
  138. package/src/strand-database.ts +61 -21
  139. package/src/strand-first-sync-gate.ts +258 -0
  140. package/src/strand-formation-manager.ts +162 -8
  141. package/src/strand-formation-protocol.ts +850 -692
  142. package/src/strand-instance-manager.ts +877 -74
  143. package/src/strand-member-key.ts +20 -12
  144. package/src/strand-member-registry.ts +3 -0
  145. package/src/strand-membership-reconciler.ts +680 -0
  146. package/src/strand-membership-writer.ts +550 -190
  147. package/src/strand-network-config.ts +250 -0
  148. package/src/strand-revocation-enforcer.ts +653 -0
  149. package/src/strand-solicitation.ts +499 -471
  150. package/src/strand-wake-protocol.ts +21 -66
  151. package/src/strand-watcher.ts +325 -314
  152. package/src/timed-step.ts +28 -0
  153. package/src/timeout-scheduler.ts +28 -0
  154. package/src/types.ts +487 -52
  155. package/dist/strand-backfill.js.map +0 -1
@@ -142,6 +142,17 @@ declare schema CadreControl {
142
142
  Id text primary key, -- UUID
143
143
  MemberPrivateKey text null unique, -- Our private key as a member of this strand
144
144
  Type text, -- Types: 'o' = Open, 'c' = Closed -- Open can still control writes in the sApp, but only Closed controls reads
145
+ FounderOwnerKey text null, -- ed25519 (base64url) owner key of the MACHINE that published this row
146
+ -- (== insert context.OwnerKey; in the reference model each machine's
147
+ -- owner key is the key behind its PeerId, so this names the founding
148
+ -- machine — the one machine that runs the strand's one-time founder
149
+ -- bootstrap). cadre-core derives "am I the founder?" at launch by
150
+ -- comparing it to the node's own owner key (cadre-node.ts
151
+ -- launchStrand). Null on a consent-seated strand — the consent branch
152
+ -- of AuthorizedInsert carries no signature, so there is no
153
+ -- trustworthy signer to record (see the NOTE on that branch below).
154
+ -- Provenance, not content: cadre-node.ts strandRowMismatches
155
+ -- deliberately excludes it from the identical-content comparison.
145
156
  StampId text not null unique, -- single-use authorization nonce (anti-replay).
146
157
  -- \`unique\` holds over LIVE rows only; a removed row's stamp is retired
147
158
  -- permanently into Revocation (NotRevoked below).
@@ -172,8 +183,16 @@ declare schema CadreControl {
172
183
  -- Mirrors OwnerKey.NoUpdate / FormationInvite.Immutable.
173
184
  constraint NoUpdate check on update (false),
174
185
  constraint AuthorizedInsert check on insert (
175
- -- Authorized by an owner signing over THIS row (Id, Type, MemberPrivateKey, StampId); single-use via unique StampId
176
- exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.Strand', 'add', new.Id, new.Type, coalesce(new.MemberPrivateKey, ''), new.StampId), context.Signature, A.Key, 'ed25519'))
186
+ -- Authorized by an owner signing over THIS row (Id, Type, MemberPrivateKey, StampId); single-use via unique StampId.
187
+ -- FounderOwnerKey persists WHO published the row, so the founding machine can later
188
+ -- recognise its own strand (founder derivation, cadre-node.ts launchStrand). The
189
+ -- equality pins the stored column to the VERIFIED signer — same shape as
190
+ -- CadrePeer.VouchOwner — so a writer cannot record a founder it is not. Deliberately
191
+ -- NOT added to the signed digest: the equality already binds it to the signature's
192
+ -- context.OwnerKey, and widening the digest would churn every existing
193
+ -- insertStrand field-order contract for nothing.
194
+ (exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.Strand', 'add', new.Id, new.Type, coalesce(new.MemberPrivateKey, ''), new.StampId), context.Signature, A.Key, 'ed25519'))
195
+ and new.FounderOwnerKey = context.OwnerKey)
177
196
 
178
197
  -- or authorized WITHOUT a signature by a redemption record naming THIS EXACT ROW: a
179
198
  -- FormationUsage row carrying both this strand's Id and its one-off StampId
@@ -240,6 +259,15 @@ declare schema CadreControl {
240
259
  or (
241
260
  new.Type = 'o'
242
261
  and new.MemberPrivateKey is null
262
+ -- NOTE: a consent-seated strand records NO founder machine — this branch
263
+ -- carries no signature, so there is no trustworthy signer to persist.
264
+ -- Consequence: the restart-before-founding orphan shape (a relaunch cannot
265
+ -- derive founder-ness from the row) persists for consent-formed strands
266
+ -- only; the responder that provisions one must keep passing an explicit
267
+ -- founder flag at launch. Revisit if consent-formed strands ever hit the
268
+ -- empty-Header symptom in practice: FormationUsage names the redeeming
269
+ -- peer (PeerKey), which is the candidate derivation source.
270
+ and new.FounderOwnerKey is null
243
271
  and exists (
244
272
  select 1 from FormationUsage FU
245
273
  where FU.StrandId = new.Id
@@ -277,6 +305,66 @@ declare schema CadreControl {
277
305
  )
278
306
  ) with context (OwnerKey text, Signature text);
279
307
 
308
+ -- ONE party's own membership identity for one strand: the private key whose public
309
+ -- half seats this party's \`Strand.Member\` / \`Strand.Manager\` rows. Deliberately a
310
+ -- SEPARATE key from Strand.MemberPrivateKey — that column is the strand-wide read
311
+ -- secret formation hands to EVERY joining party, so an identity derived from it is
312
+ -- one every joiner can forge (gotchoices/sereus#4). A side table rather than a
313
+ -- Strand column because a JOINER holds no control Strand row at all (reference apps
314
+ -- attach formed strands from an in-memory row) yet must persist its own party key
315
+ -- too; keyed by strand id it serves founder and joiner uniformly, and it leaves the
316
+ -- heavily-audited Strand insert digest untouched. Deliberately NO closed-strands-only
317
+ -- cross-table check: the joiner writes this row with no local Strand row to check
318
+ -- against. Party-private in the same sense as MemberPrivateKey — replicated in
319
+ -- plaintext to every machine the party owns (docs/strands.md → "Closed-Strand Member
320
+ -- Key Handling"; that replication is what lets any of the party's machines sign
321
+ -- membership writes) — but unlike MemberPrivateKey it is NEVER put on the formation
322
+ -- wire.
323
+ table StrandPartyKey (
324
+ Id text primary key, -- the strand id this key is for
325
+ PrivateKey text not null, -- THIS party's ed25519 strand member private key, base64
326
+ -- protobuf — the same encoding as Strand.MemberPrivateKey;
327
+ -- decode with cadre-core's strandMemberKeyPair.
328
+ StampId text not null unique, -- single-use authorization nonce (anti-replay).
329
+ -- \`unique\` holds over LIVE rows only; a removed row's stamp is
330
+ -- retired permanently into Revocation (NotRevoked below).
331
+ -- A removed row's StampId is retired into Revocation, and this refuses any insert
332
+ -- naming a retired stamp: the approval that seated this row can never re-seat it
333
+ -- after removal. Same rationale as OwnerKey.NotRevoked, stated in full there.
334
+ constraint NotRevoked check on insert (
335
+ not exists (select 1 from Revocation R where R.TableName = 'StrandPartyKey' and R.StampId = new.StampId)
336
+ ),
337
+ -- Retirement is MANDATORY: a delete must carry the matching Revocation row in the same
338
+ -- transaction, or the stamp would free up again. Deferred (subquery), so the sibling
339
+ -- insert is visible at commit regardless of statement order. The tombstone must also
340
+ -- name the removed ROW, not just its stamp — see Revocation.RowKey.
341
+ constraint RevocationRecorded check on delete (
342
+ exists (select 1 from Revocation R where R.TableName = 'StrandPartyKey' and R.StampId = old.StampId and R.RowKey = old.Id)
343
+ ),
344
+ -- Insert + delete only; a rotation is remove-then-insert (fresh stamp, fresh
345
+ -- signature). Required because AuthorizedInsert / AuthorizedDelete below cover
346
+ -- neither update — an in-place rewrite would swap the party's identity key with
347
+ -- no signature at all. Mirrors ValidationKey.NoUpdate.
348
+ constraint NoUpdate check on update (false),
349
+ constraint AuthorizedInsert check on insert (
350
+ -- Owners authorize by signing over THIS row (Id, PrivateKey, StampId);
351
+ -- single-use via unique StampId. Binding PrivateKey means a captured approval
352
+ -- can only ever reproduce the exact key it approved, never seat an
353
+ -- attacker-chosen identity under the party's name.
354
+ exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.StrandPartyKey', 'add', new.Id, new.PrivateKey, new.StampId), context.Signature, A.Key, 'ed25519'))
355
+ ),
356
+ constraint AuthorizedDelete check on delete (
357
+ -- Delete is authorized by a signature over the DISTINCT 'remove'-tagged digest
358
+ -- bound to the STORED row (Id, StampId), so the enrollment approval — which
359
+ -- never expires — can never be replayed as a removal.
360
+ -- DELIBERATELY no REAP branch (cf. ValidationKey.AuthorizedDelete): like
361
+ -- Strand.MemberPrivateKey, PrivateKey is a secret stored nowhere else, so this
362
+ -- delete's effect is unrecoverable and stays strictly owner-signed. Same
363
+ -- reasoning as Strand.AuthorizedDelete.
364
+ exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.StrandPartyKey', 'remove', old.Id, old.StampId), context.Signature, A.Key, 'ed25519'))
365
+ )
366
+ ) with context (OwnerKey text, Signature text);
367
+
280
368
  -- A peer (node) that is part of the cadre, carrying a self-published,
281
369
  -- freshness-stamped, self-signed address record (see PeerAddressRecord in cadre-core).
282
370
  -- The row IS the peer-address record: a resolver reads it, re-verifies Sig against
@@ -676,25 +764,26 @@ declare schema CadreControl {
676
764
 
677
765
  -- Token is no longer a primary-key prefix (the key is the redemption nonce), so the
678
766
  -- per-token filters — the cap count in Authorized above, and cadre-core's
679
- -- countFormationUsage / isTokenUsed / hasOutstandingFormationInvite — are served by a
680
- -- table scan: a bare equality on a non-key column is a shape the optimystic vtab declines
681
- -- to claim, so the engine applies the filter itself.
767
+ -- countFormationUsage / isTokenUsed / hasOutstandingFormationInvite — are served by this
768
+ -- index rather than by a full scan of a table that is append-only and grows for the life
769
+ -- of the party.
682
770
  --
683
- -- There WAS an \`index FormationUsageByToken on FormationUsage (Token)\` here, declared by
684
- -- \`formation-unique-token-redesign\` purely to spare that scan. It was removed 2026-08-25
685
- -- because the tripwire that change recorded ("an under-reporting descent would admit a
686
- -- seat the invitation did not pay for — a failure mode a scan could not produce") fired:
687
- -- a secondary-index descent on a second machine returns only the rows THAT machine wrote,
688
- -- so a cap counted through it under-reports without bound across machines. Measured with
689
- -- the engine's own \`index:seek\` trace — each machine's copy of the index sub-collection
690
- -- sits at a different revision while their copies of the table itself agree, and the
691
- -- refresh that runs immediately before the descent does not close the gap. Tracked in
692
- -- \`tickets/blocked/secondary-index-seek-blind-to-sibling-rows.md\`, and upstream as
693
- -- \`bug-index-subcollection-sits-one-revision-behind-on-the-sibling\`.
771
+ -- The cap count is only ever as correct as this index's convergence across machines. That
772
+ -- convergence failed once: from 2026-08-04 to 2026-08-25 a descent on a second machine
773
+ -- returned only the rows THAT machine wrote, so the index was removed and the reads went
774
+ -- back to the scan. The engine defect was fixed upstream and re-measured here on
775
+ -- 2026-09-17, so the index is declared again. Its guard is the integration-tests scenario
776
+ -- \`strand-formation-concurrent-redemption\`, which asserts BOTH machines' views of a raced
777
+ -- redemption and therefore goes red if that convergence regresses.
694
778
  --
695
- -- DO NOT re-declare it to speed the count up until that is fixed upstream. Re-adding this
696
- -- one line is also how the reproducer comes back: it is what makes
697
- -- \`strand-formation-concurrent-redemption\` fail.
779
+ -- NOTE: the write side has its own exposure. Every FormationUsage insert now maintains this
780
+ -- sub-collection as well as the table, and an intermittent engine failure once hit exactly that
781
+ -- step on other tables (a table's rows committed while its index did not; closed 2026-09-17 with
782
+ -- the upstream sync fixes, \`tickets/complete/strand-unique-index-sync-stale-revision\`). Nothing
783
+ -- has been observed here, and redemptions are rare writes; but if joins start failing
784
+ -- intermittently with an index-sync error naming FormationUsage, this declaration is the first
785
+ -- thing to suspect.
786
+ index FormationUsageByToken on FormationUsage (Token);
698
787
 
699
788
  -- Append-only retirement record for the one-off StampId nonces of removed OwnerKey /
700
789
  -- CadrePeer / ValidationKey / Strand rows. Without it a removal was undoable: the add
@@ -716,10 +805,20 @@ declare schema CadreControl {
716
805
  -- A tombstone committed while the node was alone is local-only (never broadcast); the
717
806
  -- ReissuedAt counter below exists so an owner can re-write — and therefore re-broadcast —
718
807
  -- such a tombstone once peers are reachable, without changing what it means.
808
+ -- One row retires nothing: the singleton ledger marker ('Revocation', 'ledger', 'opened'),
809
+ -- filed once by an owner's connected reconcile pass (ControlDatabase.openRevocationLedger).
810
+ -- It exists only so this table is never a never-written block. The storage layer consults
811
+ -- a block's cohort on EVERY read of a block it does not hold, and every membership lookup
812
+ -- and every guarded insert (NotRevoked) reads this table; once any row exists the block is
813
+ -- held and re-checked on the storage layer's normal read-repair schedule, like every other
814
+ -- populated table. It cannot read as a retirement: every reader of this table filters on
815
+ -- its own guarded TableName, 'Revocation' is not one, and ControlDatabase.queryRevocations
816
+ -- skips the marker, so nothing reaps or re-issues it.
719
817
  table Revocation (
720
- TableName text, -- 'OwnerKey' | 'CadrePeer' | 'ValidationKey' | 'Strand' | 'DeviceToken' (confined by RowIsGone below)
818
+ TableName text, -- 'OwnerKey' | 'CadrePeer' | 'ValidationKey' | 'Strand' | 'StrandPartyKey' | 'DeviceToken',
819
+ -- or 'Revocation' for the ledger marker only (all confined by RowIsGone below)
721
820
  RowKey text not null, -- primary key of the removed row: OwnerKey.Key / ValidationKey.Key /
722
- -- CadrePeer.PeerId / DeviceToken.PeerId / Strand.Id
821
+ -- CadrePeer.PeerId / DeviceToken.PeerId / Strand.Id / StrandPartyKey.Id
723
822
  -- Every guarded table's RevocationRecorded CHECK requires the
724
823
  -- accompanying tombstone to carry the removed row's key here, so
725
824
  -- a REMOVAL cannot file a misnamed tombstone. A tombstone with no
@@ -759,12 +858,19 @@ declare schema CadreControl {
759
858
  -- it, stamp1) then re-admitted (stamp2) would leave a node that converges on the
760
859
  -- re-add FIRST rejecting the stamp1 tombstone forever, never learning that stamp is
761
860
  -- retired. The stamp-only form passes there (stamp1 is not live).
861
+ -- The last branch admits the ledger marker (see the table comment) and nothing else under
862
+ -- TableName 'Revocation': the whole triple is pinned, so with the primary key
863
+ -- (TableName, StampId) it is a singleton and the append-only growth surface is unchanged.
864
+ -- 'opened' cannot collide with a real stamp (43 base64url characters, generateStampId),
865
+ -- and the primary key includes TableName anyway.
762
866
  constraint RowIsGone check on insert (
763
867
  (new.TableName = 'OwnerKey' and not exists (select 1 from OwnerKey K where K.StampId = new.StampId))
764
868
  or (new.TableName = 'CadrePeer' and not exists (select 1 from CadrePeer P where P.StampId = new.StampId))
765
869
  or (new.TableName = 'ValidationKey' and not exists (select 1 from ValidationKey V where V.StampId = new.StampId))
766
870
  or (new.TableName = 'Strand' and not exists (select 1 from Strand S where S.StampId = new.StampId))
871
+ or (new.TableName = 'StrandPartyKey' and not exists (select 1 from StrandPartyKey K where K.StampId = new.StampId))
767
872
  or (new.TableName = 'DeviceToken' and not exists (select 1 from DeviceToken D where D.StampId = new.StampId))
873
+ or (new.TableName = 'Revocation' and new.RowKey = 'ledger' and new.StampId = 'opened')
768
874
  ),
769
875
  -- Appending a tombstone is an OWNER action, like every other write in this schema.
770
876
  -- Ungated, any writer that could reach the control database could retire a stamp:
@@ -798,6 +904,12 @@ declare schema CadreControl {
798
904
  -- than that one.
799
905
  -- RowIsGone and NoDelete are unchanged: they guard an owner retiring a stamp early
800
906
  -- or withdrawing one later, which authorization does not cover.
907
+ -- The ledger marker is signed under this same rule, over
908
+ -- digest('CadreControl.Revocation', 'remove', 'Revocation', 'ledger', 'opened'). The
909
+ -- 'remove' tag on a row that retires nothing is deliberate: reusing the one append rule
910
+ -- keeps every append owner-signed without adding an unsigned or differently tagged
911
+ -- branch, and a captured marker signature can only re-file the identical row, whose
912
+ -- primary key is already taken.
801
913
  constraint Authorized check on insert (
802
914
  exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest('CadreControl.Revocation', 'remove', new.TableName, new.RowKey, new.StampId), context.Signature, A.Key, 'ed25519'))
803
915
  ),