# Protocol and durability

## Data model

`FileVersion` is a size, executable flag, and ordered list of `(BLAKE3 hash, chunk length)`. An `Entry` adds a relative path and its last accepted sequence. A null file is a tombstone. Tombstones retain their sequence, so deleting then recreating a file cannot fool an older client's precondition check.

A `Package` contains a UUID and changes `(path, expected sequence, new file or tombstone)`. Current checks every precondition and the final namespace under a serialized SQLite transaction. One failed check rejects the whole request, returning current entries for every submitted path. An accepted package assigns a single new sequence to all its entries and stores one event. These sequences and UUIDs are protocol machinery, not user-created commits.

SQLite runs in WAL mode with `synchronous=FULL`. A receipt binds each UUID to a hash of the request and its original response, including rejections. Publication updates entries, event, sequence, and receipt in one transaction. The long-poll notification is sent after commit. Separate clients changing disjoint paths do not conflict solely because the global sequence advanced.

## Publication validation

An optional `checks` policy in `corotation.json` declares built-in JSON/image/glTF checks or argv command checks, their complete input globs, preparation inputs/commands, and a runner. Clients explicitly pin approval in `.laplace/checks-trust.json`; current pins its policy in its private data directory. Changing the shared declaration cannot change either approval implicitly. `/v1/info` advertises the policy digest. A client with a mismatched or unapproved policy blocks outgoing publication while retaining incoming synchronization.

After capture, the client retains an immutable `draft` package through validation and holds. Preflight materializes the known tree plus the candidate changes into a private workspace from verified chunks. It excludes unrelated conflicted local edits. Only a passing candidate is promoted to durable `pending` and uploaded. A pending request retains its original identity across uncertain network retries. Validation failures are distinct from conflicts and do not advance edit bases. If an incoming event overlaps a failed draft, the conflict record includes every path of that draft so subsequent scans cannot publish its unrelated remainder. Draft identities change after relevant local input, received cursor, or explicit retry-epoch changes, allowing a fresh attempt after a rejected receipt.

Current serializes all publication attempts using a separate publication mutex. It reads the latest accepted namespace, overlays the proposed package, checks that combined tree outside the SQLite transaction, then atomically persists acceptance or rejection and its receipt. Reads, chunk transfer, and baseline creation do not take the publication mutex. Disjoint path changes still pass their path preconditions, but may fail the combined-state checks. A failing validation returns a `checks` report without conflict reasons; unpublished uploaded chunks are not referenced by current's namespace or events.

Successful and failed deterministic results are cached by input manifests, check definition, policy, preparation inputs and toolchain identity. A persistent workspace preserves unchanged source mtimes and build caches. Only modified/missing files are rematerialized. JSON/image checks memoize individual files; the Khronos adapter uses model and resource manifests so unrelated saves do not reread large assets. Lockfile/config changes invalidate preparation. Native executables are content fingerprinted; an explicit native toolchain key covers transitive tools/libraries. Docker images are resolved to immutable IDs before each candidate. Custom input globs are a completeness contract: omitted dependencies cannot invalidate a result. Generic full-build commands remain full builds unless the underlying tool supports incremental execution.

Cache state is marked dirty durably before commands run. Interrupted/mutating runs rebuild scratch state. Checks cannot approve changed source bytes; metadata-only changes from bind mounts trigger content verification for those paths. Cache records are outside the command mounts. Docker presets use a read-only root, the invoking Unix UID, no capabilities, no network during checks, bounded CPU/memory/process count, and only scratch work/cache mounts. Preparation may fetch dependencies; npm presets disable install scripts. Native execution is explicitly trusted, not sandboxed. Logs and command lifetimes are bounded, and child process groups are stopped. Check methods are validations of configured behavior, not a guarantee of semantic completion or atomic visibility inside a running client application.

`checks.laplace.json` projects private `.laplace/checks-report.json` state for people/agents. `cor checks --watch --json` observes transitions. `hold`/`resume` persist in `.laplace/control.json`; watcher events for approval, hold, and retry controls schedule a batch even though other internal churn is ignored. Already submitted pending requests may complete during a hold. Fresh retry changes cache epochs locally and on the configured server; old scratch directories are retired under the engine lock so each policy or toolchain change does not accumulate another full project copy. Source chunk retention is unchanged.

## Sessions and retained attribution

Sessions are optional metadata over the same continuous publication flow. A package may carry `session: {id, client_id}`. Absent session metadata is omitted from serialization, preserving existing untagged request hashes and receipts. `/v1/info` advertises `sessions: true`; clients refuse to start a session on a server without that capability. Session-aware peers should be upgraded together. Registration accepts only id, workspace UUID, and title. The SSH transport assigns the author from the verified personal-key fingerprint, resolving enrolled devices to their parent identity. A separate nullable SQLite owner column enforces ownership before publication receipts and closure; old records have no authenticated owner and remain readable only. Client-supplied author fields are rejected on the wire.

Current adds `sessions` and `session_packages` tables and an index allowing one open session per workspace UUID. Start and end serialize on the publication mutex. Start UUIDs bind immutable metadata; repeated starts return their original record. End is idempotent, and a closed session rejects new package IDs. Existing package receipts are checked first, so an uncertain accepted request can still be retried after closure.

An accepted tagged package atomically stores its before/after entries and server acceptance time alongside its namespace update, sync event, receipt, and session package count. Rejected packages are not recorded as shared changes. Review history is separate from the compacted event feed: baseline creation never deletes it. Payload bytes are still stored once by content hash; sessions add changed-file manifests, not whole-tree snapshots. A future collector must retain chunks referenced by both sides of every retained session publication, even when no current file or baseline references them.

Session review preserves per-package causality. It does not derive a person's changes by diffing a global start and end baseline, which would misattribute interleaved work. Session lists are indexed and limited to 100 records; package pages are limited to 32 records and an approximate metadata budget (one large record can exceed it). JSONL exports stream pages, fix the initial package count, and materialize only referenced before/after files in a new directory. Chunk downloads are deduplicated across an export. Open-session review is a snapshot of the packages known when export begins.

### Local lifecycle journal

`.laplace/session-request.json` is a durable command intent. A separate short command lock prevents concurrent CLI transitions from overwriting requests. The existing client lock remains the only workspace writer. `session start/end/retry` queues an intent and waits for `.laplace/session.json`; a live worker observes the control file, or the CLI obtains the client lock and performs the exchanges itself. Interrupted CLI commands leave their intent recoverable. Read-only status, remote review, and local hook configuration do not need the client lock.

The worker first recovers apply and retries durable pending packages under their original attribution. Starting registers a session, journals each start hook, and allows new publication only after all hooks pass. Start-hook edits are captured by a full scan. Existing unpublished edits join the session. Active sessions add only a tag and audit metadata to normal packages. Stopping `work` does not close them.

Ending progresses through `end_flush`, `end_hooks`, `final_flush`, and `closing`. It refuses to advance past unresolved checks, conflicts, or holds. End hooks receive the accepted history; their edits pass through normal validation and publication, still tagged with the session. A final metadata scan catches work that arrived during the previous exchange. Closure is persisted as an intent before the remote call, so a lost response is safely retried. Edits after the final capture belong to later work; editors are not globally frozen. Failed start/end hooks block new packages while their phase remains recoverable. A failed flush can continue receiving and publishing repairs but needs an explicit session retry to finish closure.

### Hook execution

Hooks are explicit native argv programs in private `.laplace/session-hooks.json`, pinned when the session starts. No shared project policy can install them on a teammate's machine. They run sequentially in the project root, with inherited tools/environment/network and `LAPLACE_TOKEN` removed. They have a bounded timeout/output and use the same Unix process-group termination as native checks. Hooks are trusted working-tree mutations, not sandboxed validators. The worker waits during lifecycle hooks; hooks must not invoke another workspace writer or session transition.

Each hook journals `pending`, `running`, `passed`, or `failed`, plus a stable invocation ID. Context files under `.laplace/sessions/<id>/` supply session metadata, root, event, repository, and end-review JSONL. Success is saved before advancing. A crash leaving `running` becomes an interrupted failure; it is never blindly rerun. Explicit retry skips successful hooks and preserves invocation IDs for idempotent external effects. Hooks must implement their own external idempotency: no process can atomically commit both a local JSON journal and an arbitrary agent or flag-service action.

Sessions do not gate runtime activation or deployment. A hook can prepare disabled flags and agent instructions, but the application must implement the flag. End hooks do not inherently enable it or promote production.

## Byte storage

`chunks/` supports independently addressed loose objects and immutable packs in `chunks/packs/`. Baseline JSON is stored separately as a loose content-addressed object. Store handles are cloned within one owner process; the client/authority directory lock enforces that ownership at the application boundary.

Pack layout, with all integers little endian:

| Field | Size |
| --- | --- |
| Magic `LAPPACK1` | 8 bytes |
| Number of chunk descriptors | u32 |
| Each descriptor: raw BLAKE3 hash, payload size | 32 bytes + u32 |
| BLAKE3 of the preceding header bytes | 32 bytes |
| Concatenated chunk payloads in descriptor order | Sum of payload sizes |

Headers are bounded and checksummed. Payload lengths are bounded by the protocol chunk maximum. Opening a store rebuilds its hash-to-pack-offset index from headers, checks published pack lengths, and ignores unpublished temporary files. It does not read every payload at startup. Each retrieved payload is BLAKE3-verified.

A pack writer filters duplicates under a shared writer mutex, writes a temporary pack, syncs the file, atomically publishes it without replacement, syncs the containing directory, and only then adds index entries. A crash before publication leaves an ignored temporary file. A crash after publication is recovered by rebuilding the index. Existing packs are never appended to or rewritten. Loose object publication uses the same durable temporary-file pattern.

Files are streamed into FastCDC batches, hashed in a Rayon pool, and added to packs. A before/after fingerprint (size, timestamps, and Unix inode/change time) detects changes while capturing. Incoming chunks are checked before storage; current only accepts metadata whose chunk references exist with matching lengths. Hardware corruption can still happen after publication and is detected when those bytes are read.

## Exchange

1. Resume an interrupted apply, then retry any durable pending outgoing request.
2. Scan local changes. A brand-new client first requests a coherent snapshot of the latest namespace: matching local files are adopted, different local files are preserved as conflicts, and obsolete historical versions are skipped. Established clients keep their original edit bases and submit before catch-up. Persist the **entire** candidate package against its original path versions before uploading/submitting it.
3. Negotiate missing hashes, exchange only those chunks in bounded binary batches, and submit the metadata package. Do this before pulling, so a pull cannot split off a conflicting part of the local batch and accidentally publish the remainder.
4. Apply the accepted receipt or record all paths in a rejected package as blocked conflicts. Later scans do not automatically resubmit blocked paths.
5. Consume events in sequence or bootstrap from an immutable baseline after compaction. Advance the durable cursor only after incoming work is applied or recorded as a conflict. An accepted outgoing receipt does not skip intervening incoming events.
6. Watch local filesystem events and long-poll current. Periodic metadata reconciliation and full rehashes cover missed notifications. Watcher overflow triggers a full verification pass.

Internal JSON state is persisted atomically. The root conflict report is a human-readable projection; reopening repairs the projection from private state. A repository UUID prevents accidentally attaching existing client state to another authority. A client cursor ahead of current causes an error instead of silently treating a rollback as valid history.

## Automatic batching

Native notifications carry monotonic arrival timestamps. The worker deduplicates changed paths and schedules an exchange at the earlier of the last relevant notification plus one second or the first notification plus five seconds. Another save to the same path resets the quiet period, while the first timestamp remains fixed. Events arriving during an exchange keep their arrival times for the next batch. Draining the bounded notification channel is also bounded so sustained activity cannot starve a due deadline. Overflow requests a full rehash.

Ignored paths are filtered before they can create or postpone a batch. Ignore-file changes invalidate the cached rules and schedule a full scan. Reconciliation and remote notifications request an exchange but respect a pending local deadline. Thus a remote wake-up cannot prematurely publish a subset of a still-forming local package. When there is no local batch, remote changes and reconciliation proceed immediately. `sync` remains an immediate, one-shot exchange.

One long-poll future is retained across local notifications, reconciliation, and transfers. Completed waits are rearmed at the latest cursor; a pending request is not cancelled just because an editor saved. No new filesystem scan or chunk capture is started solely because another notification arrived during the quiet period. The shutdown subscription stays live throughout batching and retry backoff. A maximum-delay deadline schedules an attempt; transfer time, file changes during capture, and offline retry backoff can extend convergence time.

## Configuration and ignore rules

`corotation.json` carries public remote endpoints and repository pins. `.laplace/local.json` contains overrides and exact-URL SSH key paths and host fingerprints, written atomically with mode 0600 on Unix. Root discovery walks upward through project configuration, existing client state, or a Git root. Current defaults to `.laplace/current` with a generated persistent Ed25519 SSH host key and local public-key allowlist. Read-only status loads private client state without taking the worker lock or contacting current.

Root and nested `.gitignore` files use directory-scoped matching; root `.laplaceignore` takes priority. Reserved internal paths remain excluded. Rule files are cached for one scan/receive operation, and malformed or symlinked rules fail the operation before publication. Changing an ignore file triggers a full watcher scan.

Ignored incoming entries go into a durable `deferred` map without advancing `known`, the client's edit base. Only the latest ignored version per path is needed. Re-inclusion first submits any local changes against their original versions, preserving whole-package rejection. Then unblocked deferred entries are applied, including tombstones. This prevents old local bytes from being mistaken for a new edit against an ignored remote update. The cursor can advance once deferred entries are persisted; recovery does not depend on retaining old server events.

## Incoming installation and recovery

Download/verify needed chunks, then rescan affected paths to detect edits made during the download. If a package overlaps local work or an unresolved conflict, preserve local and remote versions and block the whole visible package.

Otherwise persist an apply plan containing every incoming entry and its expected local content. Process deletions before additions. Before replacing a file, verify its current content, move it into the recovery directory, sync the directories, and verify the displaced bytes. Stage incoming content in that same filesystem and install with an atomic **no-clobber hard link**. Remove directories only if recursively empty. Re-observe installed content to avoid associating a new editor write's fingerprint with an old manifest.

On restart, an already installed version is recognized; a missing target with a displaced original can finish installation. An unexpected local file stops installation and creates a conflict with recovery locations. Application can be partially visible at the filesystem level, but displaced originals are retained. The simulated crash tests exercise a lost acceptance receipt and interruption after displacement, including edits made before restarting. They are not an exhaustive power-cut simulation.

## Baselines

Under the authority's transaction lock, serialize the current namespace including tombstones, durably store that immutable manifest by hash, insert its baseline reference, and truncate covered events in the same transaction. Existing baselines and chunk objects stay available. A lagging client receives the latest baseline reference, verifies its hash/identity, then requests events after its sequence.

Baseline creation pauses package publication while serializing/storing metadata. It does not copy asset payloads. Retaining a baseline implicitly retains all chunks it references; a future collector must account for every retained baseline, current manifest, pending transfer/recovery needs, and concurrent uploads before removing anything.

## SSH transport and internal endpoints

### Team directory and accounting

`people.json` is server-local display metadata: stable person IDs, mutable names, and retained fingerprint bindings. Key enrollment can bind a person; additional keys may share that person ID. Assignment cannot move an already-bound key to a different person. The personal-key allowlist and its active device grants authorize access independently of display metadata; revocation still works if the directory is corrupt. CLI mutations use the same key lock and atomic private writes. Read-only directory snapshots do not contend with key administration. Session owners and audit authors remain exact personal-key fingerprints, never client-provided names.

`GET /v1/whoami`, `/v1/people`, and `/v1/team?before=SEQUENCE` require authenticated SSH channels and expose no private keys. Every accepted SSH package, tagged or untagged, inserts a bounded activity record and updates per-key counters in the same SQLite transaction as publication and its durable receipt. No extra file reads, hashing, transfers, or database transaction are needed. Each activity record retains sequence, package ID, key fingerprint, optional session ID, server time, path count, and up to eight path/kind previews. Replays and rejected packages cannot increment counters. Baseline compaction leaves these tables intact. Existing untagged history is not backfilled with guessed authors; `accounting_since` records the coverage start. Session totals use the indexed authenticated owner column, excluding legacy self-declared authors.

Team queries return all key totals, up to 50 recent activity records capped at 1 MiB with a sequence cursor, and the latest 100 sessions with their existing number cursor. The dashboard groups those counters by the current directory bindings. `team --web` fetches once over SSH and opens a self-contained, mode-0600 HTML snapshot in `.laplace`; it has no listener or network requests. Explicit output must be a new HTML file outside the synchronized project or inside `.laplace`. Embedded JSON escapes HTML delimiters, rendering uses DOM text nodes, and CSP forbids network access. The dashboard labels snapshot time and coverage instead of implying live presence. Re-run to refresh.

### Transport

The network listener speaks SSH through Russh, with only public-key authentication enabled. A verified key becomes a connection-scoped principal; usernames and request headers cannot change it. Each `laplace` subsystem channel carries HTTP/1 framing directly over the encrypted channel. Hyper pools these channels over one authenticated SSH connection; long polls do not block transfers. There is no TCP HTTP listener or local forwarding proxy. Plain HTTP constructors are explicitly named, loopback-only protocol test harnesses.

CLI clients prefer a saved device identity; otherwise they try ssh-agent keys, then the first conventional key file. An explicit identity overrides saved-key selection and authenticates directly when unencrypted, or restricts agent selection when public-only/encrypted. Encrypted private keys are unlocked by the agent. Host fingerprints are verified before client authentication and pinned privately to the exact remote URL; unknown hosts require confirmation or an explicit fingerprint, and changed pins fail closed. Reconnect preserves the selected identity. Certificates, SSH config aliases, shell execution and port forwarding are unsupported.

`authorized-keys.json` maps SHA256 fingerprints to OpenSSH public keys. `keys add/remove` atomically update it under a local lock. Every request rechecks the allowlist on a blocking worker, so revocation applies to existing pooled channels; admitted requests can finish. Session ownership is stored separately from legacy author labels. New registration binds the owner in its SQLite transaction. Teammates can read review history but cannot append or close another key's session. All human member keys otherwise share project access. Separately enrolled runner keys have the restricted permissions described below. Key fingerprints identify credentials, not verified real-world people.

The server caps concurrent connections at 64 and channels at 32 per connection. Global endpoint/publication semaphores are shared across all SSH connections. The listener bounds concurrent requests; exhausted capacity returns 503 for retry. Chunk transfer batches target 8 MiB, with a 16 MiB HTTP body cap. Single chunk PUTs cap the body at 1 MiB.

| Method and path | Purpose |
| --- | --- |
| `GET /v1/info` | Repository identity, sequence, protocol/chunk parameters |
| `GET /v1/checks` | Pinned required checks policy, or null |
| `POST /v1/checks/retry` | Authenticated request to invalidate validation caches |
| `GET /v1/sessions?before=N` | Indexed team session history, up to 100 records |
| `POST /v1/sessions` | Idempotently register a session and its author/workspace metadata |
| `GET /v1/sessions/:id?after=N` | Session metadata and a bounded page of exact package revisions |
| `POST /v1/sessions/:id/end` | Idempotently close a session; serialize against publication |
| `GET /v1/snapshot` | Coherent latest namespace and sequence for first join |
| `POST /v1/chunks/missing` | Missing subset of up to 4096 supplied hashes |
| `POST /v1/chunks/pack` | Upload a binary chunk batch |
| `POST /v1/chunks/fetch` | Fetch up to 128 hashes / 8 MiB of chunk data as a batch |
| `PUT /v1/chunks/:hash` | Upload one independently verified chunk |
| `GET /v1/chunks/:hash` | Download one independently verified chunk |
| `POST /v1/packages` | Idempotent whole-package acceptance/rejection |
| `GET /v1/changes?after=N&wait=S` | Ordered event page or baseline reference |
| `GET /v1/wait?after=N` | Wait up to 25 seconds for a newer sequence |
| `GET /v1/baselines?before=N` | Up to 100 baseline references, newest first |
| `GET /v1/baselines/:hash` | Immutable baseline manifest |

Protocol version is currently 1. This initial implementation has no compatibility/migration promise for future on-disk or wire formats; preserve backups before upgrading formats.

## Multiple repositories per SSH listener

The server root remains the legacy `default` current. Named currents are published by atomic directory rename under `repositories/NAME`, after their database, key allowlist, name marker, and optional checks policy are durable. Creation is server-local and serialized by `repositories.lock`; at most 128 repositories are supported. Each owns its own SQLite database, chunks, receipts, checks state, sessions, baselines and team directory. Deduplication does not cross this boundary. Membership is either explicitly provided or copied once from default.

The URL's single validated path selects a repository. The SSH login carries `laplace:NAME` as a selector, never as a claimed author, so an agent with several keys tries keys eligible for that repository. Catalog connections use a separate selector accepting membership in any repository. Each HTTP-framed request is independently routed and authorized; changing its URL cannot borrow another repository's permission. Routing caches only one selected router per channel, while its middleware continues checking the actual repository allowlist on every request. Global admission and connection/channel limits remain shared. A small authenticated catalog lists only accessible names.

All request paths, including chunks, sessions, team data, snapshots, changes, wait, validation and baseline exports, pass through this selection. Raw and percent-encoded traversal, backslashes, and nested repository paths are rejected. Existing URL-less/default clients and data keep their UUID. Each current has its own publication lock and wake channel; baseline scheduling visits all repositories. Named currents open lazily and remain cached. Native checks require the host's explicit startup permission.

A per-user client setting can select an SSH agent socket, configured through `install --ssh-agent PATH`. `LAPLACE_SSH_AGENT` overrides the saved setting, with `SSH_AUTH_SOCK` as the fallback. The agent signs requests; its private keys are never copied to the client or server.


## Jobs and distributed runners

`laplace.jobs.toml` is shareable but inert. A repository member explicitly enables a validated definition through the authenticated jobs API. Definitions and pending events live in the repository SQLite database. Publication appends one shared event with paths, verified SSH authors and session tags in its existing transaction; it does not copy a tree, start a process or perform network work. Glob sets compile once per job per package.

Runner polls and status queries advance scheduling. After quiet/max-wait batching, the scheduler captures the live namespace and sequence in one transaction, saves an immutable manifest and the job definition, and creates one task per target. A job has at most one active run; further publications accumulate in one pending batch. Manifests survive baseline compaction. An absent runner leaves durable queued work. All tasks of a run share one snapshot even when assigned hours apart.

Runners enroll separate Ed25519 keys in private `runners.json`, with enrolling member, target and job scope. HTTP-framed SSH requests recheck both the runner grant and its owner's membership. Runners can claim work, renew/complete their own leased tasks, read source chunks and exchange artifact chunks. They cannot publish packages, change definitions, or read team/session APIs. Revocation applies to existing channels. The runner key stays outside the synchronized project.

A task is queued, running, succeeded or failed. Claims use a SQLite transaction, a new random token and a 60-second lease. Heartbeats every ten seconds extend the lease and replace a bounded log tail. Expired attempts requeue up to three attempts. Every completion checks authenticated owner, attempt token and expiry. Duplicate completion with the same token is harmless; a stale attempt cannot replace a newer result. Retry resets only failed targets and retains the original manifest and command. Execution remains at least once, so external side effects need the stable task ID as an idempotency key.

Each runner has one execution slot and an exclusive local lock. Missing source chunks use the existing bounded pack transfer. Per-job/target source workspaces preserve unchanged file fingerprints and timestamps, replace modified source, and remove untracked products. Cache and state directories persist outside that source tree. Native commands require explicit local trust; they run as the OS user with a cleared environment and are not a sandbox. Docker pins a local image ID, disables network/capabilities, restricts resources, and mounts only the task's working data. Neither backend forwards SSH credentials in environment variables. Timeout, lease loss and shutdown terminate the process group; Docker cancellation removes the named container.

Logs retain a 128 KiB tail. Task artifacts are bounded to 1,000 files / 10 GiB, chunk-addressed and stored separately under `jobs/objects`; missing objects alone are uploaded. All targets must succeed before CLI artifact download. Status omits log bodies and artifact manifests, while inspecting one run fetches those details. Job history, artifacts and runner caches currently require manual retention management; there is no garbage collector.

`POST /v1/jobs` carries typed list/inspect/enable/disable/run/retry/enroll/revoke/claim/heartbeat/complete/missing requests. `GET/PUT /v1/jobs/objects/:hash` transfer verified artifact chunks. Both use existing repository routing and SSH transport. See `docs/jobs.md` for the user-facing script contract and supervised runner setup.


## Saved device identities

`src/devices.rs` handles repository-local device grants and machine-local credentials. `/v1/devices` supports enrollment, listing, and revocation over authenticated SSH. Only a directly authorized personal key may enroll a device; the grant binds its fingerprint to that personal key within one repository. Requests on existing channels recheck both grant and parent membership. Parent-key removal tombstones its device grants before removing membership, preventing resurrection after reauthorization. Runners enrolled by a device depend on that device's continued authorization.

The transport retains the actual SSH fingerprint for authorization and device/runner management. Sessions, publication accounting, whoami, and team views use the server-resolved personal identity, preserving session ownership across device switches. Host routing still checks each requested repository independently.

The CLI saves independent Ed25519 keys in `~/.config/laplace/identities/<URL hash>/`, with mode 0700 directories and 0600 files on Unix. Metadata pins the normalized URL, host key, repository UUID, and device fingerprint. Keys are persisted before idempotent enrollment; credentials are remembered before switching connections. Personal private keys are never exported from agents. Explicit unencrypted keys authenticate directly without contacting an agent. Saved-credential rejection fails with a renewal instruction rather than silently invoking personal authentication. Older servers remain usable with personal authentication but cannot enroll devices.

Workspace locks are acquired before connection setup. A repeated `work` reports the existing worker without contacting current. `sync` and `resolve` also reject busy workspaces before asking for authentication. This does not introduce a connection broker: separate CLI processes still authenticate over SSH, using the saved key without agent interaction.
