Catalog compatibility, upgrade and rollback

Status: accepted. This is the decision record and operating contract for how a published catalog, its embeddings sidecar, host-local caches and scat binaries of different versions relate. Tests in tests/compat.rs keep it true; RELEASING.md is the checklist that exercises it.

Context

One publisher builds scripts.sqlite nightly and replaces it atomically. Clients are independently deployed (RPM) and open the catalog read-only (ARCHITECTURE.md §3). Four artifacts carry a version:

ArtifactVersionWhere it is storedChecked by
Catalogschema versionindex_metadata.schema_versioncore::db::open_db
Embeddings sidecarformat versionembedding_metadata.format_version (absent = format 1)EmbeddingsSidecar::open_checked
Host-local cache entrynone of its owna byte-for-byte copy of the catalog, keyed by published path + file identitythe checks above, on open
scat binarythe two constants aboveSCHEMA_VERSION, SIDECAR_FORMAT_VERSION—

Until now a client opened a catalog only if the schema matched exactly, and told the user to “re-index” — advice that is wrong for a read-only client.

Decision

Coordinated upgrades with an exact schema match. A client reads exactly the catalog schema it was built for; anything else is refused, unmodified, with a message that says which side to upgrade. The policy lives in one function, core::db::schema_compatibility.

OptionCostVerdict
Coordinated upgrade, exact matchA short window per client between its upgrade and the catalog flip, where it is refused. No reader code beyond the existing check.Chosen. One publisher, RPM-managed clients and nightly publication make a maintenance window cheap; every schema bump so far has changed columns, which a reader window would have to emulate in every query.
Parallel versioned catalogs (scripts.sqlite per schema, publisher runs both builds)Double build time and storage, a path convention, and the publisher needs both scat versions. Zero client downtime.Not now. It needs no client machinery, so it is the next step if a window becomes unacceptable.
Reader window (N reads N-1 …)Every query tolerates old layouts; a min_reader field in the catalog; permanent test matrix.Not now. Disproportionate for a fleet that can upgrade together.

Revisit (to parallel catalogs first) when clients can no longer be upgraded within one maintenance window, or when the window’s outage is no longer acceptable. Changing the policy means changing schema_compatibility, this table, and the combination matrix below together.

Retained regardless: clients never open the catalog read-write, publication is an atomic rename(2), and no migration framework exists. Catalogs are rebuilt, never migrated; a build whose predecessor has a different schema is a full rebuild automatically.

Supported combinations

Catalog schema (SCHEMA_VERSION in src/core/db.rs):

SchemaStatusRead by
14currentscat built from this tree

Embeddings sidecar format (SIDECAR_FORMAT_VERSION in src/core/embeddings.rs):

FormatStatusNotes
1currentIncludes sidecars written before embedding_metadata existed (no provenance)

A test fails if either table stops listing the compiled-in version as current, so a bump cannot merge without updating this document.

ClientCatalogResult
schema Nschema NWorks.
schema Nschema < NRefused: the catalog is older than this scat; the catalog publisher must upgrade scat and run scat catalog build.
schema Nschema > NRefused: this scat is older than the catalog; upgrade scat on this host (do not rebuild the shared catalog).
anyno index_metadataRefused: not a scat catalog, or published by a much older scat.

“Refused” holds for every command that reads the catalog (CLI and TUI, which checks once before taking over the terminal), with the cache on or off. scat catalog info and scat doctor are the exception that explain the refusal: they print both versions and a compatibility verdict (compatible, catalog_older, catalog_newer), and catalog info then exits non-zero. Nothing in a refusal writes to the share.

scat catalog diff against a rotated .1/.2 copy needs both files to be the current schema, so across a schema change it fails (with the message above) until two builds share the new schema.

Host-local cache

A cache entry is a faithful copy of the published file, keyed by the file’s path and identified by its stat identity (length, mtime, inode, device). It is refreshed whenever that identity changes — which every atomic publication and every rollback by rename does.

Embeddings sidecar

The sidecar pairs with the catalog by logical_path, so it does not depend on the catalog schema: a schema bump needs no sidecar rebuild, and sidecar and catalog may be republished in either order. It is built out of band (scat-embed) and is normally older than the nightly catalog.

Rolling out a schema change

Run during a maintenance window; announce it, because clients are refused between their upgrade and the flip.

  1. Publisher. Install the new RPM. Pause the nightly cron.
  2. Stage. Build to a path beside the catalog so nothing changes for clients yet, and check it:
    scat --config /path/to/site.yaml --db /share/scripts.sqlite.next catalog build
    scat --db /share/scripts.sqlite.next catalog info   # compatibility: compatible
    scat --db /share/scripts.sqlite.next search <known term>
  3. Keep the outgoing catalog for rollback, then flip (rename(2) is atomic and readers holding the old file keep reading it):
    ln -f /share/scripts.sqlite /share/scripts.sqlite.pre-upgrade
    mv /share/scripts.sqlite.next /share/scripts.sqlite
  4. Clients. Upgrade scat on every client host immediately before or after the flip (a refused client prints which side is behind). Clients not yet upgraded report this scat is older than the catalog until they are.
  5. Resume the cron (now running the new binary). Republish the sidecar if its format changed, after clients are upgraded.

For a small catalog the staging steps can be skipped: install the new RPM on the publisher and run scat catalog build; the old-schema predecessor forces a full rebuild and is rotated to .1.

Rolling back

Pick the matching case; rollback never needs a migration.

Changing a schema or format

Before merging a change to SCHEMA_VERSION or SIDECAR_FORMAT_VERSION:

  1. Update the version tables above and the “current schema” note in QUICKSTART.md; tests/compat.rs fails otherwise.
  2. Add a CHANGELOG.md entry that says the release is a coordinated upgrade (or, for the sidecar, which clients can no longer read it).
  3. tests/compat.rs derives its older/newer fixtures from the constants, so it needs no edits; run it and read the failures as a to-do list. The release smoke job derives the fixture version from src/core/db.rs too.
  4. Walk the rollout and rollback steps against a staging catalog and tick the compatibility items in RELEASING.md.