cadrumo.adapters.persistence.storage.master_key.bucket_session module¶
Per-bucket instance-scoped unlock state.
BucketSession replaces the module-global ClassVar caches that once survived a bucket switch on the shared-master providers, both since deleted. Each instance binds to exactly one bucket_id; the unlocked DEK is held in a bytearray buffer so close() can overwrite the bytes in place before the reference is dropped. The session is the only object that holds cleartext key material on this surface; the substrate invariant forbids any module-global mutable state that could survive a bucket switch.
The bytearray zeroisation is best-effort. Python may have produced short-lived bytes copies of the buffer when callers materialised the dek property; the garbage collector owns the lifetime of those copies. The contract is documented honestly so callers do not assume Python guarantees a deeper wipe than the language can deliver.
- class BucketSession(*, bucket_id, dek_buffer, idle_window, idle_deadline, opened_at, absolute_deadline, storage_root)[source]¶
Bases:
objectOne per-bucket unlock session.
The class deliberately holds NO ClassVar mutable state. Two sessions for two distinct bucket ids own two independent bytearray buffers and never share key material.
- Parameters:
- classmethod open_resumed(*, bucket_id, dek, idle_minutes, opened_at, idle_deadline, absolute_deadline, storage_root=None)[source]¶
Re-open a session from a resumed persisted profile session.
The sole constructor. The persisted profile-session record session-wraps the DEK only, and the key-encryption key that unwrapped it stays profile-login-scoped and is never placed in the OS keychain, so a session never holds one. Column crypto reads the DEK, which is all this surface needs.
Both deadlines come from the resumed record instead of being re-derived, so the cross-process session inherits the ORIGINAL login’s absolute cap and cannot be extended by re-opening.
- Parameters:
bucket_id (
str) – Non-empty identifier of the resumed bucket.dek (
bytes) – 32-byte data-encryption key recovered by unwrapping the persisted session record under its keychain session key.idle_minutes (
int) – Sliding idle window applied bytouch().opened_at (
datetime) – The ORIGINAL login instant from the record.idle_deadline (
datetime) – The record’s current sliding idle deadline.absolute_deadline (
datetime) – The record’s immutable absolute cap.storage_root (
Path|None) – Canonical local-storage root that owns the bucket.
- Return type:
- Returns:
A
BucketSessioncarrying the resumed DEK and deadlines.- Raises:
StorageValidationError – When
bucket_idis empty,idle_minutesis not positive,dekis not 32 bytes, or the idle deadline outlives the absolute deadline.
- property storage_root: Path | None¶
Return the local-storage root that owns this session’s key material.
- property absolute_deadline: datetime¶
Return the immutable absolute session-lifetime cap.
Fixed when the session opens as
opened_at + absolute_minutesand never refreshed;touch()clamps the sliding idle deadline to it.
- property dek: bytes¶
Return an immutable view of the live DEK bytes.
Raises BucketLockedError after close() has sealed the session.
- hmac_subkey(context)[source]¶
Return the HKDF-SHA256 sub-key of this session’s DEK for
context, memoised.The keyed-lookup digest recipe (HKDF sub-key, then HMAC-SHA256 of the material) re-derived the sub-key on every call, yet the derivation depends only on
(DEK, context)— both constant for the session’s life. On the secure-object write path that derivation ran several times per row and dominated the crypto budget. Memoising it here follows therouted_settings()precedent: the value is bucket-scoped, so its cache must live and die with the session that owns the bucket, never at module scope. Distinctcontextvalues yield cryptographically independent sub-keys, so entries cannot collide across consumers.- Parameters:
context (
bytes) – Stable per-consumer HKDF info bytes distinguishing this digest space from every other caller’s.- Return type:
- Returns:
The 32-byte derived sub-key.
- Raises:
BucketLockedError – When the session has already been sealed.
- touch(now)[source]¶
Roll the idle deadline forward to now + idle_window, clamped to the cap.
The sliding idle deadline is never advanced past the immutable absolute deadline, so continuous activity within the idle window cannot extend the session beyond its absolute lifetime cap.
- routed_settings(source)[source]¶
Return
sourcere-routed to this bucket’s database, memoised.Deriving the bucket route is not cheap: it re-validates the whole settings model, and the path validators resolve every configured directory against the filesystem – on Windows, over a hundred path-canonicalisation syscalls per derivation. A single profile write resolves the route three times (the workflow store, the lifecycle store, the profile store each ask for a repository), so the same derivation was recomputed from scratch three times over.
The memo is scoped to the SESSION, not the process, because its value is bucket-scoped: the substrate invariant is that no module-global mutable state may outlive a bucket switch, and a process-lifetime cache keyed on a bucket-scoped value is exactly what that forbids. Held here, the derivation dies with the session that owns the bucket – the same lifetime, and the same reasoning, as the engine handle
acquire_engine()registers.Keyed on the serialised source rather than its identity: callers reach this through
load_settings(), which returns a fresh instance whenever no override is in scope, so identity would miss every time. Serialising costs about a thousandth of the derivation it avoids, and equal settings provably derive an equal route.A few entries are kept rather than one. The memo originally held a single slot on the premise that the access pattern is the same source asked repeatedly; measurement disproved it. A profile field edit asks for this route twice, once under a settings override and once outside it, and those two sources alternate strictly – so a one-slot memo had each arm evict the other and served zero hits across an entire editing session, recomputing every derivation it existed to avoid. The bound stays small because the working set is that alternating pair, not an unbounded space of sources.
- Parameters:
source (
Settings) – The liveSettingswhose non-route fields the derived settings preserve.- Return type:
- Returns:
A
Settingsrouted to this session’s bucket.- Raises:
BucketLockedError – When the session has already been sealed.
- acquire_engine(settings_factory)[source]¶
Lazily acquire and register this bucket’s engine on first storage access.
The session is the single owner of the SQLAlchemy engine lifecycle for its bucket: the first storage access within the session resolves (or creates) the bucket engine and registers the handle here, so
close()disposes exactly that engine on session close or profile switch. Subsequent accesses return the already-registered handle.The route arrives as a factory rather than a value because the handle is cached: every access after the first discards the settings it is handed. Building a
Settingseagerly to satisfy a call that will not look at it costs a full model validation, and the caller has no way to know from the outside whether this session has an engine yet. Deferring the construction into this method puts that decision where the answer is known.- Parameters:
settings_factory (
Callable[[],Settings]) – Returns theSettingsrouting to this bucket’s database. Invoked at most once, and only when an engine must actually be resolved.- Return type:
- Returns:
The
Enginebound to this session.- Raises:
BucketLockedError – When the session has already been sealed.
- close()[source]¶
Zeroise key buffers, dispose the bucket’s engine, and seal the session.
Idempotent: a second call after the first is a no-op.
Engine disposal:
The session owns the engine lifecycle for its bucket. Closing the session (on idle expiry, profile switch, or explicit close) disposes the engine handle registered at first storage access and evicts every cached engine bound to this bucket id, so the next consumer that opens a different bucket — or re-opens this one — never reuses a stale engine handle. Disposal keys on bucket identity, so it never depends on re-deriving a database route from live settings.
- Return type: