Skip to content
Back to skills

Offline And Portable

ASecurity

Offline-first behavior and portable-install constraints - the outbox, relative paths, clock skew, filesystem limits, token storage. Use when touching local state, the sync flush, file paths, or anything that must survive being unplugged or moved.

  • 2 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added October 15, 2026
businessrustgosqlapidatabase

Works with

  • terminal
  • cli
  • api

Security analysis

A100/100

Pro scans all 4 files and shows the line behind each finding

Scanned October 15, 2026

npx -y skills add Spinnich/rommbat --skill offline-and-portable --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Offline And Portable?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Offline And Portable
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/spinnich-offline-and-portable/badge)](https://www.skillsdirectory.com/skills/spinnich-offline-and-portable)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: offline-and-portable
description: Offline-first behavior and portable-install constraints - the outbox, relative paths, clock skew, filesystem limits, token storage. Use when touching local state, the sync flush, file paths, or anything that must survive being unplugged or moved.
---

# Offline and portable

Two constraints that decide the data model. Build to them, do not retrofit.

## Map

This file holds the core offline rules, the background pass and portability. The rest is in topic
files beside it, by section:

| Section                                                                                                                                                           | File                           |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ |
| [Offline-first](transfers.md#offline-first): canceled transfers, the `.part` rule and the `partial/` sweep                                                        | [transfers.md](transfers.md)   |
| [Offline-first](store.md#offline-first): the shared SQLite connection and `TreeLock`                                                                              | [store.md](store.md)           |
| [Offline-first](offline-ui.md#offline-first): which set and mapping operations work with the server off                                                           | [offline-ui.md](offline-ui.md) |
| [Browsing and removing, offline](offline-ui.md#browsing-and-removing-offline)                                                                                     | [offline-ui.md](offline-ui.md) |
| [Two kinds of list, and drawing one as the other](offline-ui.md#two-kinds-of-list-and-drawing-one-as-the-other)                                                   | [offline-ui.md](offline-ui.md) |
| [A footer offers a verb exactly when that verb works](offline-ui.md#a-footer-offers-a-verb-exactly-when-that-verb-works)                                          | [offline-ui.md](offline-ui.md) |
| [The claim rule: a game another enabled set still wants is held back](offline-ui.md#the-claim-rule-a-game-another-enabled-set-still-wants-is-held-back)           | [offline-ui.md](offline-ui.md) |
| [Removing content](offline-ui.md#removing-content)                                                                                                                | [offline-ui.md](offline-ui.md) |
| [`local_file` rows outlive their bytes, and the budget counts them forever](offline-ui.md#local_file-rows-outlive-their-bytes-and-the-budget-counts-them-forever) | [offline-ui.md](offline-ui.md) |

## Offline-first

The target is a handheld Windows gaming PC away from the server for days. Local SQLite is
the source of truth; the network is optional, probed with a short-timeout
`GET /api/heartbeat` (budget from RB-353).

- **`game-start` and `game-end` are journal-only.** Those two run inside the game-launch
  path: append and exit in milliseconds, never open a socket, never start a process.
  **`start` and `quit` are outside it** and each spawns a detached `background <event>`
  agent pass. The set is `SpoolRecord.BackgroundEvents`, which the hook compiles rather than
  references, and a test asserts it.
- **Everything produced offline goes to an outbox** with its real local mtime and content
  hash, never its sync time. A week offline is just a bigger negotiate payload; the protocol
  is full-state reconciliation and handles it natively when timestamps are honest.
  **An entry the server answers for and does not accept ends `failed`**, keeping its error: a
  replay is refused the same way, and a pending one would hold `SaveGuard` and `uninstall`
  forever. That a replay is refused the same way is inferred from the status, not measured
  against a live RomM. Offline, a 5xx, a 401 and a refused whole batch say nothing about one entry and stay
  pending. Dropping, from `outbox drop` or the UI's Waiting to upload screen, is the only way a queued record is deleted unsent.
- **Retries are safe by design, and this is measured.** A byte-identical save re-uploaded into
  the same slot reuses the row; a repeated play session comes back `"status": "duplicate"` in
  a per-index result array carrying `created_count`/`skipped_count`, so a partial flush is
  reconciled exactly rather than inferred. Lean on that instead of inventing an ack protocol.
  **The precondition is that "identical" really is identical**, so a bundled directory save
  has to archive deterministically or every flush mints a new server row.
- **Uploads are safe to replay; downloads are not safe to abandon.** `GET /api/saves/{id}/content`
  records the device as current **on the request** unless `optimistic=false` is passed, so a
  transfer killed mid-body by a dropped link leaves the server sure the device holds a save it
  does not, and the next negotiate answers `no_op`. Pass `optimistic=false` and ack with
  `POST /api/saves/{id}/downloaded` after the bytes are written and verified. Same shape as the
  [`.part` rule](transfers.md#offline-first): verify, then commit. See `save-sync` and RM-17.
- **Clock skew is a real failure mode.** A flat RTC produces timestamps that lose every
  conflict. Keep a monotonic sequence alongside wall clock, compare against the server's
  `Date` header on first contact, and offer to re-stamp the outbox past a threshold.
- **Conflicts are normal, not exceptional.** Default `keep_both`, never silently overwrite,
  always copy aside first. Keeping both means keeping the local side **local**: uploading it
  would make it the newest row in the slot and tell every other device to take it, which is an
  unresolved conflict resolving itself in favor of whoever synced last. The conflict is
  persisted and waits for a person to pick a side, with `saves resolve` or the UI's conflict
  screens.
- **Exit `Offline` (5) means unreachable and nothing else.** It is the one code that tells a
  script waiting will fix it. A 401 or 403 exits `NotPaired` (4). Any other server answer, or a
  result that could not be verified or written here, exits `ServerError` (8), so a locked
  destination or a path too long for this machine is 8 too. A run with several failures is
  `Offline` only when every one of them was unreachable, which is `FailureCause` ranked worst
  first and mapped in `ExitCode.For`. Classify there rather than returning `Offline` from a
  failed `RomMResponse` (#143).
- **No daemon exists.** A portable install cannot register a service or scheduled task, so
  the flush is a short-lived process, guarded by a lock file in the tree. One pass, then exit.

  **What invokes it: the `start` and `quit` hooks, `sync`, and a person typing `flush`.**
  The hook spawn is what makes an install nobody administers from a terminal work: without
  it, an install that is never synced spools events forever. The spawn costs the launch
  nothing (RB-195 and RB-197): ES does not wait for a hook, and the 75.9 MB agent reaches
  `Main` in 34 ms, against 49 ms for a whole invocation of the trimmed, ReadyToRun hook.
  **What limits the spawn is rule 4**, which allows it only from the two events outside the
  launch path.

  **`background quit` waits for the ES process to be gone before it writes any config**, and
  gives up rather than hanging. Measured: ES exits 48 to 68 ms after the quit hook stamps
  itself, and 10 ms after the pass starts looking on a real session. If it never exits, the
  config stays queued and the flush runs anyway, because the flush touches no file ES owns.

  **The same holds when a change throws rather than refusing**, which a full or read-only
  volume makes it do: the row is finished as `Failed` with the exception message, and the pass
  carries on. A throw left unrecorded is worse than it looks, because the row stays outstanding
  and every later quit re-enters it before reaching the flush, so one row that cannot be
  written stops that machine flushing at all. **Nothing on the config side may be able to end
  the pass before the flush.**

  **The pass logs to `emulators/rommbat/logs/background.log`.** It is started with
  `CreateNoWindow`, so nothing it prints reaches a person any other way, and "why did my save
  not go up" is the first question anyone asks about it.

  **A shared log needs an append-only handle, and `FileMode.Append` is not one.** It seeks to
  the end once, at open, and `FileStream` then writes at its own tracked offset, so two passes
  in flight overwrite each other: measured as four writes from two handles leaving one line,
  and seen on a real install as a line missing its first 18 characters (#153). `BackgroundLog`
  opens with `FileSystemRights.AppendData` only, writes each line as one buffer, and shares
  `Delete` so another pass can roll the file while this one holds it.

## Portable

RetroBat runs from a USB drive and moves between machines.

- **Nothing outside the tree.** No `%APPDATA%`, no registry, no service, no scheduled task,
  no admin rights. Database, logs, outbox and device identity all live under the install.
- **Never persist an absolute path.** Store relative to the RetroBat root, resolve at point
  of use. A drive letter changing from `E:` to `F:` must be a non-event. Three layers
  enforce it and none of them is optional: the `RelativePath` type is the only path shape a
  store API accepts, every path column carries a `CHECK` constraint, and a test drives the
  same table of bad values through both. `RetroBatInstall.Resolve` and `.Relativize` are the
  only places the two representations convert.
- **Upstream does not follow that rule, and one of its files is inside the sync set.** A
  multi-disc launch leaves `saves/<system>/<playlist stem>.ldci`, RetroArch's record of which
  disc was in the drive, whose `image_path` is absolute down to the drive letter. Anything
  RomMBat copies out of the save tree can carry a foreign machine's paths, so **treat the save
  tree as untrusted for portability**: exclude the file, or rewrite the path on restore. The
  three layers above protect what RomMBat writes, not what it relays.
- **Find the root relative to the executable.** Walk up from `AppContext.BaseDirectory` to a
  marker (`retrobat.ini`, `emulationstation/`, `roms/`). There is no `build.ini`; the version
  lives in `system/version.info`. From a hook, `%~dp0..\..\..\` reaches `emulationstation/`
  and the **root needs a fourth level**, `%~dp0..\..\..\..\`.
- **The app installs at `emulators/rommbat/`, not `plugins/`.** RB-384: a
  `system/es_menu/*.menu` entry resolves its executable under `emulators\` and
  `emulatorLauncher` refuses `..\` escapes outright. Anywhere else cannot be menu-launched.
- **Identity follows the drive.** A GUID in the tree sent as `client_device_identifier`.
  Never MAC or hostname. See `romm-api`.
- **The filesystem may be exFAT or FAT32.** All of this is measured, not assumed; see
  RB-48 and RB-393.
  - FAT32 cannot hold a file over 4 GB, which excludes many PS2/GameCube/Wii images. Detect
    and refuse cleanly rather than failing mid-write. The write fails with Win32 112
    `ERROR_DISK_FULL`, **"There is not enough space on the disk"**, on a volume with plenty
    free, so **never surface that message**: compare `fs_size_bytes` up front instead.
  - **exFAT is no finer than FAT32: 2-second mtime granularity on both.** Its format allows
    10 ms; Windows does not use it. `content_hash` is the primary comparison and mtime only a
    tiebreak.
  - **FAT rounds mtime up**, so a file is stamped up to 2 s **later** than it was written,
    and files written inside one 2-second window share an identical mtime. Give any
    "timestamp is in the future" skew check a 2-second tolerance, and never order class-B
    files by mtime.
  - No ACLs, no symlinks. Neither can be part of any design.
- **No DPAPI.** `CurrentUser` binds ciphertext to a profile on one machine, `LocalMachine`
  to that machine; either makes the drive undecryptable on the next PC. On a portable
  install the token is only as protected as the drive, so default to a scoped, expiring
  token, offer an optional passphrase (`TokenProtector`, AES-GCM over PBKDF2), and make
  re-pairing cheap. A passphrase-protected install cannot flush unattended; say so rather
  than pretending the option is free.
- **Identity is a file, not a row.** The `client_device_identifier` GUID lives in
  `emulators/rommbat/device.id` so it outlives the database. A rebuilt store must not become
  a second device in the RomM UI.
- **The local store is SQLite with `PRAGMA user_version` migrations.** Add a new
  `Store/Migrations/NNN-*.sql`; never edit `001`. A database from a newer build is refused,
  because a portable stick may have met one.
- **Long paths.** Deep portable paths plus long ROM names plus `images/` siblings can cross
  `MAX_PATH`. Use long-path-aware APIs and `\\?\` prefixes where needed.

Files in this skill

  • SKILL.md12.7 KB
  • offline-ui.md11.3 KB
  • store.md8.1 KB
  • transfers.md5.3 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.