Skip to main content

max / goingson

Declare the storage version, so the gate is on before it is needed goingson's SyncSchema declared no storage version, which left both of the SDK's gates off: enforce_local (this build against its own store) and check_peer (this build against a peer, off the HLC envelope). Declaring one is the opt-in, and synckit 82bc96ba built the mechanism. STORAGE_VERSION = 1 records the shape as shipped, vacation_days included, which is not an oversight. check_peer passes when either side's stamp is absent, because a build predating the gate seals no version and refusing there would reject every row already on the changelog. The gate therefore protects from the version it lands in forward and no earlier, and a release that both turned it on and dropped a synced column would leave exactly the client the gate exists to stop unrefused: a pre-gate build still writing vacation_days into a store that no longer has the column. The declaration has to ship one release ahead of the change it protects. Dropping vacation_days is goingson 0f240511 and waits on a release carrying this. The LEDGER is what makes forgetting a bump impossible: a hand-written integer alone would ship two incompatible shapes claiming one number, and every peer would compare equal and sync anyway. manifest_matches_its_ledger was verified to fail first, with Moved against a placeholder fingerprint, so the mechanism is observed rather than assumed. No further wiring. The facade already reads declared_storage_version(), and enforce_local adopts the stamp on a store that has never carried one, so no existing install is refused on the upgrade that turns the gate on.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-24 16:33 UTC
Signed with PGP, not checked
Commit: 38450562a59fa6e21cc7491417c4bd28d9f31e7a
Parent: 0a131ff
1 file changed, +53 insertions, -0 deletions
@@ -25,6 +25,7 @@
25 25 //! config/credential table can never leak into a group scope. `group_id` is
26 26 //! provenance only, never a synced column.
27 27
28 + use synckit_client::store::LedgerEntry;
28 29 use synckit_client::store::config_sync_table;
29 30 use synckit_client::{ConflictStrategy, SyncSchema, SyncTable};
30 31
@@ -429,8 +430,43 @@
429 430 ])
430 431 // GO's current model; also the engine default.
431 432 .conflict_strategy(ConflictStrategy::HybridLogicalClock)
433 + .storage_version(STORAGE_VERSION)
432 434 }
433 435
436 + /// The storage version this manifest is at, and the number a peer compares
437 + /// against to decide whether it may sync at all.
438 + ///
439 + /// Version 1 is the shape as shipped, `weekly_reviews.vacation_days` included.
440 + /// Declaring it here does not change the shape; it turns the gate on, so that
441 + /// from this release forward every change a device pushes seals its version into
442 + /// the HLC envelope and a peer on a different number is refused before anything
443 + /// is applied.
444 + ///
445 + /// **The declaration has to ship one release ahead of the change it protects**,
446 + /// which is why 1 records a shape nobody wanted to keep. `check_peer` passes an
447 + /// absent stamp: a build predating the gate seals no version, there is nothing to
448 + /// compare, and refusing there would reject every row already on the changelog.
449 + /// So the gate protects from the version it lands in forward and no earlier. A
450 + /// release that both turned the gate on and dropped a synced column would leave
451 + /// exactly the client the gate exists to stop, the pre-gate one, unrefused and
452 + /// still writing the dropped column.
453 + ///
454 + /// What moves this number is the manifest, not migrations: a new synced table or
455 + /// column, a changed `pk`, row-id scheme, sync mode or delete mode. Local-only
456 + /// DDL does not cross the wire and must not fire the gate.
457 + pub(crate) const STORAGE_VERSION: u32 = 1;
458 +
459 + /// Every storage version this app has ever shipped, with the manifest
460 + /// fingerprint as of that version.
461 + ///
462 + /// Committed history: it only ever grows a row. `manifest_matches_its_ledger`
463 + /// fails if the manifest moves without a bump, which is the whole mechanism, a
464 + /// declared integer alone would rely on someone remembering.
465 + const LEDGER: &[LedgerEntry] = &[LedgerEntry {
466 + version: 1,
467 + fingerprint: "41d646183a266e6ee4e7a5b58dc054afa96b369923f231bc688f130228764e4e",
468 + }];
469 +
434 470 /// How to describe one of the manifest's tables to a person: a display name, and
435 471 /// the payload fields worth trying as a title, best first.
436 472 ///
@@ -707,6 +743,23 @@
707 743 }
708 744 }
709 745
746 + /// The manifest cannot move without its storage version moving with it.
747 + ///
748 + /// This is the gate's only enforcement. `storage_version` is a hand-written
749 + /// integer, so without a fingerprint beside it a manifest edit that forgot
750 + /// the bump would ship two incompatible shapes claiming the same number, and
751 + /// every peer would compare equal and sync anyway.
752 + ///
753 + /// When this fails, read the wire manifest in the failure and diff it against
754 + /// the previous commit. If the change is real, add a row to `LEDGER` and bump
755 + /// `STORAGE_VERSION`; if it is not (a comment, a local-only policy), the wire
756 + /// manifest will not have moved and the failure is telling you something else
757 + /// did.
758 + #[test]
759 + fn manifest_matches_its_ledger() {
760 + goingson_schema().check_ledger(super::LEDGER).unwrap();
761 + }
762 +
710 763 /// FK dependencies: a child must not be declared before its parent, or the
711 764 /// engine's parents-first upsert would violate referential integrity.
712 765 #[test]