Portability
Move records, threads, and workspace pairings between Syncropel workspaces. Export from one workspace, import into another, with zero data loss.
What this is
spl export produces a signed, content-addressed bundle of one Syncropel instance's state. spl import ingests that bundle into another workspace. After a round-trip, every record, thread, workspace pairing, and consent grant is present on the new side: bit for bit identical.
This is why hosted and self-hosted are equivalent: any Syncropel workspace can be moved between hosts, between operators, between hosted and self-hosted, without losing data or relationships.
What's portable
Everything that's part of your workspace:
- Records: every record on every thread, content-addressed (so re-importing the same bundle is a no-op, automatically)
- Threads: derived state is rebuilt from the records on import
- Workspace pairings: the persistent relationships established via
spl federation pair. URLs auto-refresh on first contact post-migration; you don't need to re-pair. - Consent grants: cross-namespace permissions via the
th_consentthread - Identity: the workspace's identity can travel in the bundle (optional, see below), but an import does not yet bring the workspace's identity with it; the imported workspace keeps its own until you bring the identity back with
spl backup --auto restore-keysor an identity sidecar - Service accounts + adapter registry: the structural metadata
- Engine config: routing rules, summary rules, decision policies, permission rules
What's not in the bundle:
- Plaintext secret values. Secrets live in your secret backend (env vars, vault, OS keyring). The bundle has handle-references only. To complete the migration, bring up the same secret backend on the new workspace separately.
- Sign-in credentials: by default the bundle leaves out the records that make a bearer token work, and a workspace that imports a bundle drops any such records it finds unless you tell it to keep them. Pass
--carry-tokenson export, and accept them on import, only if you intend to preserve them across the migration.
A copy exported before version 0.254 contains your working credentials. Older exports carried those records even when tokens were not meant to travel, so an older copy on disk is a secret: store it as you would a password, and if one has left your machine, rotate the tokens of the workspace it came from with
spl token rotate.spl doctornames any such copy it finds in the workspace's own folders.
- Trust scores: these rebuild automatically from the imported records (
KNOW/DOoutcomes replay through the trust math).
Export
The minimal command:
spl export --out /tmp/my-instance.tar.zstThis produces a tar.zst bundle signed by your workspace's identity. The signature ties the bundle to the issuing instance: a tampered bundle won't verify on import.
Useful flags:
| Flag | What it does |
|---|---|
--out PATH | Bundle output path (required) |
--uncompressed | Plain .tar instead of .tar.zst (inspectable; larger) |
--no-identity | Don't include private key + DID document (then the bundle isn't importable) |
--carry-tokens | Keep service-account tokens valid across migration |
--offline | Read the store directly, with no daemon (below) |
When the workspace will not start
spl export normally asks the running daemon for the bundle. A workspace
that cannot boot (too little memory for its store, a corrupt runtime, a
machine that will not come up) cannot answer, which is the state where the
records are hardest to get out. --offline opens the store
file directly, signs the bundle with the workspace's own keys from its home,
and needs no daemon:
spl export --offline --out /tmp/my-instance.tar.zstIt refuses, and writes nothing, while a daemon owns the home: a PID file, the
configured port answering, or a spl-serve.service unit that is active,
because a second reader on a store a daemon is writing is the one thing this
must never be, and "running" is not a boolean during boot (a large store takes
up to a minute and a half between start and bind). Stop the daemon first, or
export through it without --offline. A PID file left by a body the kernel
killed at boot is the honest hard case: confirm nothing is running, remove
run/spl.pid under the home, and export. The bundle is byte-for-byte the
bundle the daemon door produces and imports the same way; it needs free space
for one copy of the store.
Inspect a bundle without unpacking:
tar -tzf /tmp/my-instance.tar.zst | head
# manifest.json
# manifest.sig
# identity/did_document.json
# identity/private_key.bin
# records/<thread_id>/<record_id>.json
# ...The manifest.json carries:
bundle_version: schema versionsource_did: the DID of the workspace that made the bundlerecord_count: total recordsbundle_sha256: hash over all staged files in path-sorted order
Import
Importing into a fresh instance:
spl import /tmp/my-instance.tar.zstImporting into an existing instance (force overwrite: see safety below):
spl import /tmp/my-instance.tar.zst --force-overwriteUseful flags:
| Flag | What it does |
|---|---|
--dry-run | Read-only verification. Reports counts without ingesting. |
--force-overwrite | Allow import into a non-empty store. |
-o json | Machine-readable output. |
Safety: refusing to clobber
By default spl import refuses to import into a non-empty store. This prevents accidentally overwriting work on a populated workspace with a snapshot from somewhere else.
To override:
spl import bundle.tar.zst --force-overwriteUse this flag deliberately. Re-imports are idempotent (same bundle → same record IDs → no double-ingestion), so the typical migration flow is:
- Import once on a fresh instance (no
--force-overwriteneeded) - Re-import later if you need to verify it (idempotent:
records_inserted: 0,records_deduplicated: 100)
Migrating between workspaces
The canonical pattern: hosted → local.
Imagine you've been running on a hosted workspace. You want to move to your laptop:
# On the hosted side
spl export --out ~/migration.tar.zst
# Locally
spl serve
spl identity generate
spl import ~/migration.tar.zstAfter import, the pairing records still reference the hosted URL. The first time you spl sync from a paired peer, the local instance discovers the cached URL is stale. It re-resolves the peer via DID lookup, updates the pair's peer_base_url, and proceeds. You don't need to re-pair.
The reverse migration (local → hosted) works the same way.
What round-trip means
The bundle format and import logic together guarantee a property the v1.0 vision depends on: any Syncropel instance can be exported and re-imported into a fresh instance with zero divergence on the core surfaces:
- Same record IDs (records are content-addressed, so this is structural, not luck)
- Same threads (threads are rebuilt from the records)
- Same workspace pairings (state derives from
th_federation_pairsrecords) - Same consent grants (state derives from
th_consentrecords)
This isn't aspirational: it's tested on every release as a CI gate. If round-trip ever produces a non-zero diff, the release is held until the divergence is fixed. "You can leave any time and take your records" has a mechanism, not just a promise.
Troubleshooting
bundle malformed: identity/did_document.json has no Ed25519VerificationKey2020: The export side wrote a DID document the import side can't parse. Either the bundle was produced by an incompatible instance, or the bundle is corrupted. Re-export.
import refused: local store has N records; pass --force-overwrite to import anyway: Safety gate. Either start with a fresh instance, or pass --force-overwrite if you mean it.
signature verification failed: Bundle signature doesn't match the included identity. Either tampered or corrupted. Re-export from a trusted source.
identity-family record failed verification on import: DID missing expected method prefix: <missing master_did> (before v0.235): The bundle holds a core.identity.revoke.v1 naming revoked_grant, which is what retiring an assistant writes, and the importer held it to the identity family's countersignature that the grant it revokes never had; the whole bundle was refused, so a workspace that had ever retired an assistant could not be moved. Since v0.235 such a revoke is admitted as the grant it names is. Re-export and import on 0.235 or later.
Reading the import summary. records_inspected equals records_inserted plus records_deduplicated (the same id was already there) plus records_skipped (a record whose thread, actor and clock were already held under a different id, which a fresh home's own configuration seeds collide on) plus records_failed; refusals_by_reason names each. identity_restored says whether the bundle's identity was installed into the target home; when the bundle carried one and it was not, identity_note says so and names the paths that do bring an identity back (spl backup --auto restore-keys, spl repo restore --identity-sidecar). A restore of records without the identity boots as a different workspace.
A pairing shows a stale URL after import: Expected. The URL refreshes on first contact via DID resolution. If your pair's peer is unreachable at the cached URL AND the peer DID is unresolvable, sync will surface a clear error pointing you at spl federation refresh <peer-did>.
See also
- Pairing workspaces: the relationship that survives migration
- Backup & restore: local snapshot/restore (different mechanism, similar shape)
- Consent: the cross-namespace policy layer that round-trips with the bundle