Skip to content

App Backends Adapter SPI

An adapter owns where a backend’s database lives and how it is created, credentialed, backed up, restored, exported and measured. It implements the AppBackendAdapter interface exported from @hellmai/lumenflow-packs-app-backends/spi. The data plane that serves a backend to an application is not part of this interface and is not part of this pack.

Every operation resolves to an OpResult<T>: { ok: true, value } or { ok: false, error } where error is an AdapterError. An adapter never throws for an expected failure. ok() and fail() build results.

OPERATION_NAMES lists the 25 operations and OPERATION_CATALOG describes each one. The catalogue is the single place that says which operations mutate (and so need an idempotency record), which take a handle (and so check ownership), which are operator-only and which depend on an optional capability. The pipeline, the conformance kit and the reference adapters all read this table.

OperationPurposeMutatingTakes handleOperator onlyCapability gate
provisionCreate a backend and return its handle.yesnonoplacement, regions
adoptRegister an existing database by sealed connection reference. Optional.yesnonoconnectionSource (adapter config)
deprovisionRemove a backend.yesyesno
statusState, endpoints, capabilities, recovery window, usage.noyesno
rotateCredentialsRotate one credential; new material goes to custody, never to the caller.yesyesno
connectionForA sealed connection reference for a role.noyesno
planMigrationLint and plan a migration bundle. Does not change the database.noyesno
applyMigrationApply a bundle all-or-nothing; one ledger entry per migration.yesyesno
ledgerThe complete migration ledger, oldest first.noyesno
createRestorePointCreate a restore point.yesyesnorestorePoints
recoveryWindowThe earliest and latest instants a point-in-time restore can reach.noyesnopitr.supported
restoreRestore to a point in time or a restore point; always returns a new handle.yesyesnopitr.supported or restorePoints
verifyCounts and per-table checksums of two backends of the same isolation key.noyesno
exportSnapshotExport to a sealed sink in a declared format.yesyesnoexportFormats
importSnapshotImport from a sealed source into a backend.yesyesno
branchCreate a branch backend for a preview environment. Optional method.yesyesnobranching
usageUsage over a time window.noyesno
setAccessModeStorage switch, freeze and rollback (read_write or read_only).yesyesno
applyLimitsStatement and idle timeouts, lock timeout and connection caps.yesyesno
swapPromote a restored candidate to live; the adapter owns the rename.yesyesno
discardDrop a candidate or a previous live database after the rollback window.yesyesno
listHandles owned by the caller’s isolation key.nonofor scope: 'adapter'
reconcileDrive a backend left half-done by a crash to a consistent state.yesyesno
cellCapacityPlacement capacity report, used for placement decisions.nonoyes
cancelCancel an in-flight operation owned by the caller’s isolation key.yesnono

branch is optional on the interface: it is present only when capabilities().branching is true. adopt is optional too: an adapter implements it only when it can resolve a sealed connection reference to a login on an existing database. MUTATING_OPERATIONS lists the mutating names.

adopt(req, ctx) takes { connectionRef, mode, environment } and returns a BackendHandle whose adoption field is { mode }. connectionRef is a sealed reference with purpose adoption_source; the request has no field for a host, user, password or URL, and a request that carries one does not parse. The adapter resolves the reference through its own connection source, checks the login works, and records the registration in its own registry. It changes nothing in the adopted database.

ModeWhat it permits
read_onlyexportSnapshot (neutral-jsonl-v1), verify, status, list, reconcile, cancel. Every other operation is refused with policy_violation.
managedReserved for a later, separately gated step (restore points, restore, migrations). adopt in this mode answers unsupported_capability today.

The catalogue column allowedOnReadOnlyAdoption is the only allow-list. Adoption guarantees:

  • Ownership. The registry records the backend as adopted, not provisioned. deprovision never drops an adopted database: a read_only adoption refuses it, and a release of any adoption only forgets the registration. The drop path accepts only database names the pack created, so no code path can hand it an adopted database. The handle’s adoption field is informational; the adapter reads ownership from its registry, never from the handle.
  • Idempotent per database. Adopting the same server, port and database again returns the same handle. Adopting a database that is registered under another backend, or one the adapter manages itself (its registry or a provisioned backend), is a conflict that names no other caller’s backend.
  • Secrets. The password is sealed through the sealing port before it is stored. No result, error or manifest contains it.

Return types: provision, restore and branch return a BackendHandle; status a BackendStatus; connectionFor a SealedConnectionRef; planMigration a MigrationPlan; applyMigration and ledger a MigrationLedgerEntry[]; createRestorePoint a RestorePoint; recoveryWindow a RecoveryWindow; verify a VerificationReport; exportSnapshot an ExportManifest; importSnapshot an ImportReport; usage a UsageReport; swap a SwapResult; list a ListPage; reconcile a ReconcileReport; cellCapacity a CapacityReport; cancel a CancelResult. deprovision, rotateCredentials, setAccessMode, applyLimits and discard return void.

Every call receives an OpContext:

FieldMeaning
isolationThe caller’s isolation key.
backendIdOptional. Absent only for operations with no backend yet (provision, and import into a new backend).
operationIdRequired. Caller-assigned id of this operation; cancel addresses it.
idempotencyKeyRequired on every mutating operation. 8 to 255 characters from A-Z a-z 0-9 . _ : ~ -.
requestHashSHA-256 of the canonical request. See below.
deadlineISO-8601 instant. Adapters stop and report timeout after it.
traceIdRequired. Correlates logs.
privilegeOptional: tenant (the default) or operator. operator unlocks adapter-wide operations.

buildOpContext(args, isolation, options?, clock?) builds a correct context for a call: it computes the hash, takes backendId from the handle and defaults every id to a fresh random value. A caller should use it so it can never hand an adapter a context whose hash disagrees with the call. Its options are idempotencyKey, deadlineMs (default 30 000), privilege, operationId and traceId. args is an OperationArgs value, a discriminated union on operation.

requestHash is the lowercase hex SHA-256 over the RFC 8785 (JSON Canonicalization Scheme) canonical form of the triple { operation, isolation, body }. computeRequestHash(operation, isolation, body) computes it and requestBodyFor(args) builds the body for each operation. A handle in a body is represented by its backendId alone, so re-serialising a handle cannot change the hash.

  • Object keys sort by UTF-16 code unit order; strings and finite numbers serialise as ECMAScript JSON.stringify does, which is what RFC 8785 specifies; properties holding undefined are absent.
  • Numbers outside the safe integer range are not canonicalised silently. They must travel as strings. canonicalize throws CanonicalJsonError on an integer outside the range of plus or minus 2^53 - 1, on a bigint, on NaN or an infinity, and on anything that is not plain JSON data. A value such as a 64-bit database id is therefore sent as a string.
  • Including the operation name and the isolation key in the hash means one tenant’s hash can never equal another’s and a key reused for a different operation is a mismatch.

An adapter recomputes the hash and refuses a context whose hash does not match the call it carries (invalid_request).

Every mutating operation persists an operation record. The record key is the tenant-scoped tuple

(adapter_id, workspace_id, tenant_subject, app_id, idempotency_key)

encoded as canonical JSON by operationRecordKey(adapterId, isolation, idempotencyKey), so no choice of values can make two different tuples collide.

  • The same key and hash returns the first result, including a failure.
  • The same key with a different hash returns idempotency_mismatch and changes nothing.
  • Another tenant using the same key is independent: no mismatch and no shared handle.
  • Concurrent replays of one key run one operation; the others wait for its result.
  • Records are kept for at least seven days (IDEMPOTENCY_RETENTION_MS, 604 800 000 ms). createOperationPipeline refuses a smaller retentionMs with a RangeError.
  • A record whose attempt crashed stays in_flight. Once the call’s deadline plus a 30 second grace (IN_FLIGHT_LEASE_GRACE_MS) has passed, a replay returns timeout with outcome unknown, and the caller reconciles. An adapter’s reconcile can settle such a record through pipeline.settleOrphan, so a later replay of the same key returns the final result.

You do not need to write this logic yourself. createOperationPipeline({ adapterId, capabilities, store, clock?, retentionMs?, pollMs?, sleep? }) returns the shared front half of every operation. pipeline.execute(args, ctx, { authorize?, work }) runs, in order:

  1. Validate the context (invalid_request).
  2. Recompute requestHash and compare (invalid_request).
  3. Refuse an operator-only call from a tenant context, including list with scope: 'adapter' (policy_violation).
  4. Apply capability honesty (unsupported_capability).
  5. Run your ownership check, authorize, before any record is written (normally not_found), so probing someone else’s handle leaves no record behind.
  6. For a mutating operation, apply tenant-scoped idempotency.
  7. Run work under the call’s deadline.

IdempotencyStore is the interface for the record store: begin (atomic; exactly one of several racing callers gets started), complete and get. createInMemoryIdempotencyStore() serves tests and single-process use. A durable adapter backs begin with a unique constraint on the record key.

Every error says three things a caller can act on without parsing text: code (what kind of failure), retryable (whether repeating the same call can succeed) and outcome (what happened to the world).

outcome is one of:

  • none: nothing changed.
  • unknown: a provider call was sent and its result was not seen. The caller must reconcile with status() (or reconcile()) before retrying.
  • partial: part of the operation took effect. partialState is required and says what did.

ERROR_POLICY binds each code to the combinations it may carry, and AdapterErrorSchema refuses any other combination:

CodeRetryableAllowed outcomes
invalid_requestnevernone
not_foundnevernone
conflictnevernone
idempotency_mismatchnevernone
quota_exceedednevernone
capacity_unavailablealwaysnone
unsupported_capabilitynevernone
policy_violationnevernone
integrity_failednevernone, unknown, partial
provider_unavailablealwaysnone, unknown, partial
provider_rate_limitedalwaysnone
timeoutalwaysnone, unknown, partial
internaleithernone, unknown, partial

Further rules the schema enforces: retryAfterMs applies only to a retryable error; outcome: 'partial' requires partialState; and detail (1 to 1024 characters) must not contain a credential, connection string or other secret-shaped value.

adapterError(code, detail, options?) builds a valid error: defaults follow the policy (an either code defaults to not retryable, outcome defaults to none) and detail is redacted. requiresReconciliation(error) is true when the outcome is unknown or partial.

Outcome honesty and reconcile-before-retry

Section titled “Outcome honesty and reconcile-before-retry”

A failure after a provider call was sent and before its result was known carries outcome: 'unknown', never none. Reporting none there would tell the caller that retrying is safe when it may repeat a side effect. The rule for callers is: when requiresReconciliation(error) is true, call status() or reconcile() and read the state before you retry. partial is returned only with a description of the partial state.

The conformance kit checks each of these.

  • Idempotency. As above.
  • Outcome honesty. As above.
  • Capability honesty. An operation whose capability is false returns unsupported_capability, never a silent fallback or a no-op success. unsupportedCapabilityReason(args, capabilities) returns the reason an operation is unsupported, or null. It covers a placement or region the adapter did not declare, restorePoints, pitr.supported, branching and an export format that is not declared.
  • Secrets. No operation returns a credential in clear. Sealed references are custody references resolvable only inside the data plane. No error, log line or manifest contains a credential or a connection string.
  • Isolation. Every operation that takes a handle checks that ctx.isolation owns it and returns not_found otherwise.
  • Restore is never in place. restore returns a new handle. Promotion is a separate step, swap, and the previous database is kept for the rollback window until discard.
  • Deadlines. An adapter stops at ctx.deadline and reports timeout. runWithDeadline(ctx, clock, work) implements it: a deadline that has already passed does no work and yields timeout with outcome none; a deadline that passes while the provider call is in flight yields timeout with outcome unknown, aborts the work through an AbortSignal and discards the late result.
  • Opaque names. Database and role names derive from random references, never from a workspace, tenant or app identifier.

The original adapter design left some points open. This pack settles them as follows. They are intentional.

  • Added operation ledger(h). The migration ledger needed a read operation.
  • applyMigration returns one MigrationLedgerEntry per migration of the bundle, in order. A version already applied with the same digest is a no-op; the same version with another digest is refused with integrity_failed.
  • OpContext gains a required operationId and an optional privilege (tenant by default, or operator).
  • list is scoped to the caller’s isolation key. scope: 'adapter' and cellCapacity need operator privilege and return policy_violation otherwise.
  • AdapterError gains an optional partialState, required when outcome is partial.
  • Error codes are bound to retryable and outcome combinations by ERROR_POLICY.
  • swap returns { live, previous }.
  • cancel returns { operationId, outcome } where outcome is cancelled or too_late.
  • reconcile returns { state, actions }.
  • cellCapacity returns a CapacityReport.
  • The management read models AppBackend and Operation use snake_case wire names, while every SPI type is camelCase.
  • An adapter may declare rowLevelPolicies: false, but it cannot pass the policy conformance cases (they are reported as not applicable) and cannot be selected for hosted use.