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.
The operation catalogue
Section titled “The operation catalogue”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.
| Operation | Purpose | Mutating | Takes handle | Operator only | Capability gate |
|---|---|---|---|---|---|
provision | Create a backend and return its handle. | yes | no | no | placement, regions |
adopt | Register an existing database by sealed connection reference. Optional. | yes | no | no | connectionSource (adapter config) |
deprovision | Remove a backend. | yes | yes | no | |
status | State, endpoints, capabilities, recovery window, usage. | no | yes | no | |
rotateCredentials | Rotate one credential; new material goes to custody, never to the caller. | yes | yes | no | |
connectionFor | A sealed connection reference for a role. | no | yes | no | |
planMigration | Lint and plan a migration bundle. Does not change the database. | no | yes | no | |
applyMigration | Apply a bundle all-or-nothing; one ledger entry per migration. | yes | yes | no | |
ledger | The complete migration ledger, oldest first. | no | yes | no | |
createRestorePoint | Create a restore point. | yes | yes | no | restorePoints |
recoveryWindow | The earliest and latest instants a point-in-time restore can reach. | no | yes | no | pitr.supported |
restore | Restore to a point in time or a restore point; always returns a new handle. | yes | yes | no | pitr.supported or restorePoints |
verify | Counts and per-table checksums of two backends of the same isolation key. | no | yes | no | |
exportSnapshot | Export to a sealed sink in a declared format. | yes | yes | no | exportFormats |
importSnapshot | Import from a sealed source into a backend. | yes | yes | no | |
branch | Create a branch backend for a preview environment. Optional method. | yes | yes | no | branching |
usage | Usage over a time window. | no | yes | no | |
setAccessMode | Storage switch, freeze and rollback (read_write or read_only). | yes | yes | no | |
applyLimits | Statement and idle timeouts, lock timeout and connection caps. | yes | yes | no | |
swap | Promote a restored candidate to live; the adapter owns the rename. | yes | yes | no | |
discard | Drop a candidate or a previous live database after the rollback window. | yes | yes | no | |
list | Handles owned by the caller’s isolation key. | no | no | for scope: 'adapter' | |
reconcile | Drive a backend left half-done by a crash to a consistent state. | yes | yes | no | |
cellCapacity | Placement capacity report, used for placement decisions. | no | no | yes | |
cancel | Cancel an in-flight operation owned by the caller’s isolation key. | yes | no | no |
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.
Adopting an existing database
Section titled “Adopting an existing database”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.
| Mode | What it permits |
|---|---|
read_only | exportSnapshot (neutral-jsonl-v1), verify, status, list, reconcile, cancel. Every other operation is refused with policy_violation. |
managed | Reserved 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, notprovisioned.deprovisionnever drops an adopted database: aread_onlyadoption 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’sadoptionfield 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
conflictthat 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.
OpContext
Section titled “OpContext”Every call receives an OpContext:
| Field | Meaning |
|---|---|
isolation | The caller’s isolation key. |
backendId | Optional. Absent only for operations with no backend yet (provision, and import into a new backend). |
operationId | Required. Caller-assigned id of this operation; cancel addresses it. |
idempotencyKey | Required on every mutating operation. 8 to 255 characters from A-Z a-z 0-9 . _ : ~ -. |
requestHash | SHA-256 of the canonical request. See below. |
deadline | ISO-8601 instant. Adapters stop and report timeout after it. |
traceId | Required. Correlates logs. |
privilege | Optional: 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
Section titled “requestHash”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.stringifydoes, which is what RFC 8785 specifies; properties holdingundefinedare absent. - Numbers outside the safe integer range are not canonicalised silently. They must travel as strings.
canonicalizethrowsCanonicalJsonErroron an integer outside the range of plus or minus 2^53 - 1, on a bigint, onNaNor 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).
Tenant-scoped idempotency
Section titled “Tenant-scoped idempotency”Every mutating operation persists an operation record. The record key is the tenant-scoped tuple
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_mismatchand 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).createOperationPipelinerefuses a smallerretentionMswith aRangeError. - 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 returnstimeoutwith outcomeunknown, and the caller reconciles. An adapter’sreconcilecan settle such a record throughpipeline.settleOrphan, so a later replay of the same key returns the final result.
The operation pipeline
Section titled “The operation pipeline”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:
- Validate the context (
invalid_request). - Recompute
requestHashand compare (invalid_request). - Refuse an operator-only call from a tenant context, including
listwithscope: 'adapter'(policy_violation). - Apply capability honesty (
unsupported_capability). - Run your ownership check,
authorize, before any record is written (normallynot_found), so probing someone else’s handle leaves no record behind. - For a mutating operation, apply tenant-scoped idempotency.
- Run
workunder 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.
Error taxonomy
Section titled “Error taxonomy”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 withstatus()(orreconcile()) before retrying.partial: part of the operation took effect.partialStateis required and says what did.
ERROR_POLICY binds each code to the combinations it may carry, and AdapterErrorSchema refuses any
other combination:
| Code | Retryable | Allowed outcomes |
|---|---|---|
invalid_request | never | none |
not_found | never | none |
conflict | never | none |
idempotency_mismatch | never | none |
quota_exceeded | never | none |
capacity_unavailable | always | none |
unsupported_capability | never | none |
policy_violation | never | none |
integrity_failed | never | none, unknown, partial |
provider_unavailable | always | none, unknown, partial |
provider_rate_limited | always | none |
timeout | always | none, unknown, partial |
internal | either | none, 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.
Rules every adapter keeps
Section titled “Rules every adapter keeps”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, ornull. It covers a placement or region the adapter did not declare,restorePoints,pitr.supported,branchingand 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.isolationowns it and returnsnot_foundotherwise. - Restore is never in place.
restorereturns a new handle. Promotion is a separate step,swap, and the previous database is kept for the rollback window untildiscard. - Deadlines. An adapter stops at
ctx.deadlineand reportstimeout.runWithDeadline(ctx, clock, work)implements it: a deadline that has already passed does no work and yieldstimeoutwith outcomenone; a deadline that passes while the provider call is in flight yieldstimeoutwith outcomeunknown, aborts the work through anAbortSignaland discards the late result. - Opaque names. Database and role names derive from random references, never from a workspace, tenant or app identifier.
Differences from the original design
Section titled “Differences from the original design”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. applyMigrationreturns oneMigrationLedgerEntryper 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 withintegrity_failed.OpContextgains a requiredoperationIdand an optionalprivilege(tenantby default, oroperator).listis scoped to the caller’s isolation key.scope: 'adapter'andcellCapacityneed operator privilege and returnpolicy_violationotherwise.AdapterErrorgains an optionalpartialState, required whenoutcomeispartial.- Error codes are bound to
retryableandoutcomecombinations byERROR_POLICY. swapreturns{ live, previous }.cancelreturns{ operationId, outcome }whereoutcomeiscancelledortoo_late.reconcilereturns{ state, actions }.cellCapacityreturns aCapacityReport.- The management read models
AppBackendandOperationusesnake_casewire names, while every SPI type iscamelCase. - 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.