Skip to content

DCTL FFI-stable error codes (FROZEN)

The DCTL library crates expose a stable numeric error-code contract so the GUI/Tauri and iOS FFI layers can branch on a code, not on fragile message strings. Every library error answers two stable questions:

  • code() -> u32 — a precise, frozen number identifying the exact variant.
  • kind() -> ErrorKind — a coarse retry/UX classification (on CoreError).

CoreError::code() delegates through its wrapper variants (Crypto/Store/Index) into the underlying sub-error's code(), so a single call on the top-level library error yields the precise code from anywhere in the tree. This is the code an FFI boundary should surface. (docs/FORMAT.md §9 rule 11 points here.)

STABILITY CONTRACT#

Modeled on the format's frozen one-way door (docs/FORMAT.md §8):

  • Codes are frozen. Once a number ships, its meaning never changes.
  • Never renumbered, never reused. A retired variant's number is burned, not recycled for a new meaning.
  • Additive only. New error variants get new, unused numbers. New numbers may only extend the table; they never re-scope an existing entry.
  • 0 is reserved for success / "no error" and is never returned by code().
  • Domain ranges are fixed: 1xxx crypto · 2xxx store · 3xxx index · 4xxx core/vault. New variants take the next free number in their domain.
  • ErrorKind is stable and additive-only. Its variants (Transient, Permanent, Usage, Integrity, Auth, NotFound) and their meanings never change; a variant may be added but never removed or re-scoped.

FFI consumers may hard-code these numbers. Unit tests in each crate assert the representative numbers below and that codes are unique within a crate; a dctl-core test asserts the wrapper delegation and the kind() mapping.

ErrorKind (retry/UX classification)#

KindMeaning / host action
TransientWorth retrying — transient I/O, backend/network, or DB busy/locked contention.
PermanentA retry cannot succeed — malformed input or an internal derivation/parse failure.
UsageThe caller passed something invalid — bad object key, KDF params, or read range.
IntegrityStored bytes failed authentication/checksum — tampering or corruption.
AuthWrong password/factor — re-prompt for credentials.
NotFoundThe requested object/path does not exist.

1xxx — crypto (dctl_crypto::CryptoError)#

CodeSymbolic nameMeaningKind*
1001CRYPTO_KDFArgon2id key derivation failed (bad params, etc.).Permanent
1002CRYPTO_INVALID_KDF_PARAMSKDF cost parameters outside the mandatory ceilings (rejected before the KDF runs).Usage
1003CRYPTO_AEADAEAD authentication failed — wrong key, tampered ciphertext, or wrong context (deliberately non-distinguishing).Integrity
1004CRYPTO_FORMATA container/header did not parse or failed a structural invariant.Permanent
1005CRYPTO_HKDFHKDF-SHA512 expansion failed (output length out of range).Permanent

2xxx — store (dctl_store::StoreError)#

CodeSymbolic nameMeaningKind*
2001STORE_NOT_FOUNDThe requested object does not exist.NotFound
2002STORE_CHECKSUM_MISMATCHStored bytes did not match the expected content hash (verified read/write refused corruption).Integrity
2003STORE_INVALID_KEYThe object key is malformed or unsafe (e.g. path traversal).Usage
2004STORE_RANGE_OUT_OF_BOUNDSA requested read range starts beyond the object's size.Usage
2005STORE_IOUnderlying I/O failure.Transient
2006STORE_BACKENDBackend-specific failure (network, auth, quota, provider error).Transient
2007STORE_SHORT_WRITEFewer bytes reached the destination than were written to it. Deliberately not 2002: a file shorter than what was sent is a write that stopped (full filesystem, exhausted quota), not content that changed, and the two send an operator to opposite places.Transient
2008STORE_ROOT_CHANGEDThe store root is not the directory this backend was opened on — removed, or replaced by a different one, while the run was using it. Its own code because the errno is not what the operator needs and there may not be one: the characteristic case is a root a write re-created, which reports nothing at all.Transient
2009STORE_PROVIDERThe provider answered the request and refused it, carrying the status, the provider's own error code and any Retry-After. Distinct from 2006 because the retry layer decides from those fields rather than from the message text — a rule that read "503" out of a string would stop firing the first time somebody reworded it.Transient
2010STORE_TRANSPORTNothing answered: a connect that did not complete, a read that timed out, a connection reset mid-body. Distinct from 2009 because only one of the two is a request the provider has certainly seen, and the two send an operator to different places — the network path, or the account.Transient
2011STORE_RUN_DEADLINEThe run reached the deadline --max-duration set for it while this operation was in flight. Permanent, and deliberately not Transient: a consumer that re-drove the operation would be working inside a window that has already closed. What is transient about it is the next invocation, which is the caller's decision.Permanent
2012STORE_REFUSEDThe server received the request and refused it without naming a cause. Permanent by this table's own rule for anything nobody has classified: a read-only mount or an exhausted quota refuses identically every time.Permanent
2013STORE_STALLEDThe run stopped asking a link that answered nothing — no status, no protocol reply, no errno — for a whole schedule of consecutive attempts (--timeout × the attempt count). Distinct from 2010, which is one attempt that got no answer: this is the run concluding, and it carries the attempts and the --timeout they were each bounded by so the arithmetic can be checked against the flag. Permanent for the same reason as 2011: another attempt would spend a second schedule on the silence this one was raised to end. It maps to exit 5, like 2010, because the cause is unchanged — a scheduler should still come back later.Permanent

3xxx — index (dctl_index::IndexError)#

CodeSymbolic nameMeaningKind*
3001INDEX_DBUnderlying embedded-database failure (also the wrong whole-DB key case: SQLite reports SQLITE_NOTADB).Transient
3002INDEX_SERIALIZERecord could not be (de)serialized.Permanent
3003INDEX_CRYPTORecord decryption/authentication failed (wrong key or tampered entry).Integrity

4xxx — core / vault (dctl_core::CoreError own variants)#

CoreError's wrapper variants (Crypto/Store/Index) do not get their own 4xxx numbers — they delegate to the wrapped sub-error's code (1xxx/2xxx/ 3xxx). Only CoreError's own variants live here. Sub-ranges are reserved so related conditions cluster:

  • 41xx = unlock / auth / whether a vault is there at all
  • 42xx = not-found
  • 43xx = integrity
  • 44xx = config (reserved — no variant yet)
CodeSymbolic nameMeaningKind
4101CORE_UNLOCKThe envelope is present and either unreadable or opened by no slot this secret holds. One answer for both, so an attacker holding the envelope cannot learn whether a password was close.Auth
4102CORE_NO_VAULTThere is no envelope object at this location — a plain object store, not a vault. Split out of 4101 because no password is involved and none can help; a plain remote has no envelope by definition, and reporting the unlock wording sent operators to check a secret and restore a file that cannot exist there. Leaks nothing 4101 protects: there is no password to be close to.NotFound
4201CORE_NOT_FOUNDNo index record for the given logical path.NotFound
4301CORE_INTEGRITYA stored object failed its integrity check on read.Integrity

* The Kind column for 1xxx/2xxx/3xxx is how CoreError::kind() classifies that sub-error when it surfaces through a CoreError wrapper variant.

See also#