Skip to content
C.W.K.
Lesson 02 of 05 · published

Generation Only Grows

~14 min · monotonic-counter, fencing, rollback-protection, configuration

Level 0Loose Note
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

Git Can Go Backwards

Putting the pointer in git buys history and availability. It also brings a danger a service does not have: a checkout can move backwards. Someone checks out an old branch to look at something. A backup of the repository is restored. A Mac that has been asleep for a week still holds last week's tree. In each case the record on disk is valid JSON with a valid schema, and it names a store that is no longer the vault.

The defence is a single integer. Every pointer record carries a generation, and the rule is that it only grows. Each move is a new, higher number: generation 3 added the store's tree name, generation 4 switched the backend to Delta, generation 5 removed the retired implementation's metadata.

Each Mac Remembers the Highest It Saw

A rule that only grows needs a memory. Every Mac keeps one small file, pointer-seen.json under Soul Vault's state root, holding the highest generation it has ever loaded and that record's hash. Every time the pointer is loaded, the reader compares:

  • A higher generation than remembered: accepted, and remembered from now on (written atomically, under a file lock, so two sessions starting at once cannot interleave). Remembering is best effort: a Mac whose state root cannot be written still reads its pointer but keeps no record, and a write that fails is skipped. The rule protects only what was remembered.
  • The same generation: accepted.
  • A lower generation: pointer_regressed, and nothing loads. The cause says what happened and what to do: "the checkout moved backwards (pull cwkFirelink, or Dad moves the pointer forward by hand)".

One more case is easy to miss. If the remembered file exists but cannot be read, the reader does not shrug and treat it as zero. It raises pointer_regressed too: "a regression cannot be ruled out, so nothing is loaded until the record is repaired". Treating an unreadable memory as "never seen anything" would quietly reopen exactly the hole the file exists to close.

A Write Checks the Pointer Again

Reads check the generation when they load the pointer. Writes are stricter, because a write can be prepared under one record and arrive under another. A long-running service might open the store at generation 4 and try to write an hour later, after Dad has moved the pointer to 5.

So every write re-reads the live record at the moment it is admitted, through refuse_a_moved_pointer. If the generation or backend differs from the one the store was opened under, the write is refused with stale_pointer_generation: "Nothing was written: open the store again." The canonical store's share does the mirror-image check: a request from a Mac whose checkout still names a store that has since been replaced is told so by name, and is never handed the replaced store's notes as the current vault.

Why Not a Timestamp?

A timestamp seems like it would do the same job. It does not, for two reasons. Clocks on different Macs disagree, and a restored record carries its original timestamp, which can be later than a record written on a Mac whose clock runs slow. And a timestamp invites arithmetic ("close enough"). A generation is a plain count that one hand advances deliberately; the only question it answers is "is this older than something I have already seen?", and that question has an exact answer.

Code

Comparing against the highest generation seen (trimmed from soulvault/pointer.py)·python
def _compare_and_remember(pointer, seen_path, writable):
    seen = 0
    if seen_path.exists():
        try:
            seen = int(json.loads(seen_path.read_text(encoding="utf-8"))["generation"])
        except (OSError, ValueError, KeyError, TypeError) as exc:
            raise VaultError("pointer_regressed",
                f"this Mac's record of the highest pointer generation ({seen_path}) is unreadable "
                f"({type(exc).__name__}: {exc}); a regression cannot be ruled out, so nothing is "
                "loaded until the record is repaired")
    if pointer.generation < seen:
        raise VaultError("pointer_regressed",
            f"{pointer.path} holds generation {pointer.generation}, but this Mac has already "
            f"seen generation {seen}; the checkout moved backwards (pull cwkFirelink, or Dad "
            "moves the pointer forward by hand)")
    if pointer.generation > seen and writable:
        try:
            tmp = seen_path.with_suffix(f".{os.getpid()}.tmp")
            tmp.write_text(json.dumps({"generation": pointer.generation, "sha256": pointer.sha256}) + "\n")
            os.replace(tmp, seen_path)      # atomic: never half a number
        except OSError:
            pass  # remembering is best effort; the comparison above is the rule
The write-time recheck (trimmed from soulvault/pointer.py)·python
def refuse_a_moved_pointer(pointer_path, generation, backend):
    """A write acts on the pointer as it is now, never as it was when its store was opened."""
    if pointer_path is None:
        return                     # a test's store, built without a record
    live = load_pointer(Path(pointer_path), remember=False)
    if live.generation != generation or live.backend != backend:
        raise VaultError("stale_pointer_generation",
            f"the pointer is at generation {live.generation} (backend {live.backend}); this store "
            f"was opened at generation {generation} as {backend}. Nothing was written: open the "
            "store again")

External links

Exercise

Add a generation to a configuration record you load on several machines. On each machine, remember the highest generation seen in a small file written atomically under a lock, refuse any lower one with a message that says how to recover, and treat an unreadable memory file as a refusal, not a zero. Then simulate a rollback by checking out an older commit of the record and confirm that the reader refuses.
Hint
Test the unreadable case explicitly by writing garbage into the memory file. It is the branch most implementations get wrong, because the code that handles it is the code that never runs in a demo.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.