hellhound/store
A storage port for Hellhound’s opaque login and custody records.
Stores provide three operations: get, conditional put, and
delete_expired. Hellhound owns protocol transitions and seals every record;
adapters own atomic write admission and report effect knowledge honestly.
Captured database pools remain application-owned. Hellhound bounds adapter
calls in isolated workers; timing out a write cannot prove it did not act.
Absent inserts only a missing key. AtVersion replaces only the expected
version. AtVersionBefore also requires the adapter’s wall clock to be
strictly before the supplied deadline. Check version first, then sample
time under exclusive write authority, after any lock or queue wait.
Missing rows and stale versions return VersionConflict even if expired.
Refusals change no record. Only AtVersionBefore returns DeadlineElapsed.
The deadline governs the protected decision, not commit acknowledgement
or request completion. Written requires confirmation that the write
succeeded. Lost acknowledgement remains StoreOutcomeUnknown.
Record.expires_at governs retention and must not stand in for validity.
The built-in store samples its configured Unix-second wall clock; durable
adapters document their clock and precision. Hosts must synchronize clocks.
In PostgreSQL a guarded replacement can lock the row in a transaction,
check its version, sample clock_timestamp(), and conditionally update.
Transaction-start time and an UPDATE time predicate evaluated before a
lock wait do not establish the required boundary. Test actual lock waits.
let records = store.new(
get: fn(key) { db.get(pool, key) },
put: fn(record, condition) { db.put(pool, record, condition) },
delete_expired: fn(now) { db.delete_expired(pool, now) },
)
config.new(issuer:, client_id:, redirect_uri:, authentication:)
|> config.with_custody_store(records)
|> config.with_sealing_key(sealing_key)
hellhound/testing.check_store checks observable contract outcomes. Adapters
also need queue/lock-wait and uncertain-commit tests for their own runtime.
There is no legacy adapter fallback with a weaker expiry guarantee.
Types
One stored record. sealed is opaque to the store.
pub type Record {
Record(
key: String,
version: Int,
expires_at: timestamp.Timestamp,
sealed: BitArray,
)
}
Constructors
-
Record( key: String, version: Int, expires_at: timestamp.Timestamp, sealed: BitArray, )
pub type StoreError {
StoreUnavailable
StoreOutcomeUnknown
StoreFull
}
Constructors
-
StoreUnavailableThe store did not act: nothing was read or written.
-
StoreOutcomeUnknownThe store may or may not have written (a timeout or a lost reply).
-
StoreFullThe store refuses new records because it is at capacity. Nothing was written.
The complete set of supported atomic write conditions.
pub type WriteCondition {
Absent
AtVersion(version: Int)
AtVersionBefore(version: Int, deadline: timestamp.Timestamp)
}
Constructors
-
Absent -
AtVersion(version: Int) -
AtVersionBefore(version: Int, deadline: timestamp.Timestamp)
A confirmed write or a confirmed refusal. Neither refusal changes the row.
pub type WriteOutcome {
Written
VersionConflict
DeadlineElapsed
}
Constructors
-
Written -
VersionConflictThe key already exists for Absent, or the expected version is absent.
-
DeadlineElapsedThe version matched, but the protected clock sample reached the deadline.
Values
pub fn delete_expired(
store: Store,
now: timestamp.Timestamp,
) -> Result(Int, StoreError)
Delete the records that expired at or before now.
pub fn describe_error(error: StoreError) -> String
Describe a store error for logs.
pub fn get(
store: Store,
key: String,
) -> Result(option.Option(Record), StoreError)
Read the record with this key.
pub fn new(
get get: fn(String) -> Result(option.Option(Record), StoreError),
put put: fn(Record, WriteCondition) -> Result(
WriteOutcome,
StoreError,
),
delete_expired delete_expired: fn(timestamp.Timestamp) -> Result(
Int,
StoreError,
),
) -> Store
A store from its three operations (see the module documentation).
pub fn put(
store: Store,
record: Record,
condition: WriteCondition,
) -> Result(WriteOutcome, StoreError)
Atomically write if the condition holds. See WriteCondition and the module contract for clock authority, refusal precedence and acknowledgement.