Skip to content

feat: allow atomic, opt-in game provisioning holds - #43

Draft
Seagulls wants to merge 7 commits into
Liquid-co:mainfrom
Seagulls:feat/atomic-safe-game-provisioning
Draft

Seagulls wants to merge 7 commits into
Liquid-co:mainfrom
Seagulls:feat/atomic-safe-game-provisioning

Conversation

@Seagulls

@Seagulls Seagulls commented Oct 9, 2026 •

Copy link
Copy Markdown

Refs #40.

A headless peer, a home server, or another controller sometimes needs to record a game and its save locations before any bytes move. Today, tracking forces AutoSync on and can sync before a later update turns it off. Turning ordinary AutoSync off also does not stop a peer that already knows the id from requesting the manifest.

This is opt-in. Ordinary tracking and offer placement are unchanged when the new fields are omitted.

The release action is deliberate, and it is not the same as the settings update suggested in #40. A normal PATCH of autoSync cannot enforce an inbound hold, so it does not clear one. Maintainer feedback on that split is welcome.

Contract

Call Effect
POST /api/games or POST /api/offered-games/{id}/place with no new fields unchanged
same calls with "provisioningHold": true or "autoSync": false game and hold committed together; no inbound or outbound transfer
explicit id on held create local registration of a chosen id; not remote provisioning
POST /api/games/{id}/release-provisioning with an empty body clear the hold and turn AutoSync on. Release does not call SyncGame. A later reconcile or reconnect can
same call with {"autoSync": false} clear the hold and leave local AutoSync off. An explicit sync can still run. An unheld peer can still request the save
PATCH of ordinary game settings cannot clear the hold
already tracked game cannot be converted into a hold

A failed hold read is not treated as "not held". The payload sets provisioningHoldUnknown rather than reporting a false ready state.

Held creation and a held path update reject a symlink as the save root. Extra named locations can be configured while the hold is in place.

Examples

Place an offered game without syncing:

POST /api/offered-games/hades/place
Content-Type: application/json

{"path": "/srv/saves/hades", "autoSync": false}

Create the same id on another machine, still held:

POST /api/games
Content-Type: application/json

{"id": "hades", "name": "Hades", "savePath": "/srv/saves/hades", "provisioningHold": true}

Clear the hold without turning local automatic sync on:

POST /api/games/hades/release-provisioning
Content-Type: application/json

{"autoSync": false}

CLI: opensave add --hold [--id slug], opensave offers place --hold, opensave game <id> release [--no-autosync]. An idempotent offline release reports the stored AutoSync value.

Why the hold is checked in several places

AutoSync only stops this process from starting a sync. Pause does not survive daemon.New. The hold is therefore checked at the store, at watch/cloud publish, at reconcile and direct sync, and on LAN and WAN manifest, block, delete, and snapshot serve. That is existing entry points, not a second sync engine.

Review map

  • store: 0038_game_provisioning_holds.sql, hold read/release in one transaction
  • API/CLI: create, place, release, PATCH cannot clear the hold, symlink refusal
  • daemon and P2P: watch, reconcile, retry, inbound serve, fail-closed read
  • tests: restart, one-sided release, staged versus default release, peer-initiated request after staged release, offers, named roots, 100 held games, generic three-peer star (leafA, hub, leafB)

Warning

A stock or older daemon on this database does not understand the hold table. In a mixed-version probe it served a held manifest with HTTP 200. Do not downgrade a data directory that has provisioning holds. A stock client talking to a patched held server was refused for the tested manifest and delete requests. That is not general mixed-version safety.

Staged release is not a peer lock. After the hold is cleared, an unheld peer can still request the save even if this device's AutoSync is off. Other peers must stay held if they must not exchange bytes.

Tests

The 100-game case originally tracked the same live bytes on both sides with no agreed hash. A later edit correctly returned conflict and did not transfer. That was the fixture, not a capacity failure. The fixture now syncs once, requires both persisted agreed hashes, and only then creates the 100 held games. TestProvisioningHold_HundredGamesStayOneDaemon passed three times on Go 1.26.4 (0.98s, 0.99s, 0.85s).

Check Toolchain Result
go test -count=1 -timeout 25m ./e2e/... on the fixture working tree that became 2fee76f Go 1.26.4 pass, 757s
go test -count=1 -timeout 20m p2p, store, api, delta Go 1.26.4 pass
go test -race -run TestProvisioning on e2e, store, p2p Go 1.26.4 pass
go vet, gofmt, git diff --check Go 1.26.4 pass
./internal/daemon excluding only TestSessionNamesTheSnapshotAlreadyTaken Go 1.26.4 pass

TestSessionNamesTheSnapshotAlreadyTaken is not part of this change. It failed on an untouched archive of 346d9bda (eight runs, extra Auto backup, want the watcher) and still fails intermittently on Go 1.26.4. It was excluded only from that supplemental daemon gate. This is not a fully green go test ./....

cmd/opensave-app embeds frontend/dist, which is not in the clone. That target was not stubbed and was not tested.

A separate self-hosted CI run exercised the combined production code of the previous provisioning tip plus the timestamp branch, on Go 1.26.4. That is supporting evidence for the combination, not upstream OpenSave CI, and it is not a substitute for the tests on 2fee76f.

Non-goals

No new sync engine, no peer-targeted sync, no version vectors, no conversion of an already live game into a hold, and no claim that default release means nothing moves until an explicit sync. Empty-body release turns AutoSync on, so reconcile can transfer.

Draft #30 uses migration numbers 0038 and 0039 for a different design. This branch adds 0038_game_provisioning_holds.sql against current main, where 0037 is the latest. If both are merged, one migration will need renumbering. They are not the same feature.

Seagulls added 7 commits October 8, 2026 23:13
AutoSync off only stops this device from starting a sync. A paired
device can still ask for the manifest and move files, and an in-memory
pause dies on restart. Record the hold in the same transaction as the
game, in a table older builds ignore, so a crash cannot leave a game
the next launch will sync. Existing games have no row and stay as they are.
Creating or placing with provisioningHold, or with autoSync false, records
the game and refuses manifest, block, delete, snapshot, watch, retry,
reconcile and direct sync until that one game is released. Releasing does
not sync the rest of the library. An explicit id is accepted only while
held, so the other device can be registered without an offer and without
sending save contents.
Cover paired creation without an offer, unmanaged daemon.New restart,
inbound refusal, offer placement, symlink and duplicate paths, named
roots, and a 100-game library that still syncs an unrelated title.
A failed SQLite read used to look like "not held", so a protected game
could be watched, served, or synced. Hold lookups now return the error,
and every transfer path refuses rather than continuing. Release no longer
calls SyncGame, which would contact every online peer before the caller
has said which pair should converge.
Default release still enables AutoSync, and reconcile then syncs that
game with every online peer that is not held. That is now tested.
{"autoSync": false} clears the hold and leaves AutoSync off, so
reconcile, reconnect and the watcher do not sync. A PATCH cannot clear
the hold. A failed hold read is not reported as an ordinary false.
The unrelated live game was tracked on both sides with identical bytes
and no recorded convergence. A later edit then correctly conflicted
instead of syncing. Sync once first, require both agreed hashes, and
only then create the held games.
@vercel

vercel Bot commented Oct 9, 2026

Copy link
Copy Markdown

Someone is attempting to deploy a commit to the sivadaboi's projects Team on Vercel.

A member of the Team first needs to authorize it.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant