ADR-005 — Asset Storage (ladon.storage)
Context and Problem Statement
Ladon plugins that download binary assets (images, PDFs, attachments) have no
framework-provided abstraction for writing them. The previous RunConfig carried
output_dir: str | None and skip_assets: bool, but these fields were never read
by the runner and never passed to any plugin — dead API surface. As a result, plugins
embedded file I/O directly against hard-coded local paths, making it impossible to
swap backends (e.g. from local disk to object storage) without modifying plugin code.
The question is: what is the minimum storage surface the framework can provide without prescribing specific backends or adding runtime dependencies to core?
Decision Drivers
- The framework defines protocols, not implementations — storage backends are adapter concerns.
- Core must remain dependency-free: no
boto3, no cloud SDK inladon. - Storage backends must be swappable without changing plugin code.
- Plugins in multi-worker deployments (separate processes or containers) need a shared external backend; local storage must not be assumed as the only option.
- Third parties must be able to implement custom backends (GCS, Azure Blob, Redis) without forking or patching the framework.
Sink.consume(ref, client)signature must not change — storage injection must happen at plugin construction, not at call time.
Considered Options
-
Option A: Embed file I/O in each plugin — each plugin writes directly to a local path. Simple, but couples path management to plugin code and makes backend swapping impossible.
-
Option B: Pass
storagethroughrun_crawl()and down toSink.consume()— the runner threads a storage instance through the call stack. Avoids plugin construction changes but requires modifying theSink.consumeprotocol signature, a breaking change for all existing plugins. -
Option C:
Storageprotocol injected at plugin construction (chosen) — aStoragestructural protocol lives inladon.storage. Plugins acceptstorage: Storage | None = Noneat construction.Nonemeans no asset download. The runner is unaware of storage; the plugin decides when and whether to use it.
Decision Outcome
Chosen option: Option C, because it keeps the Sink.consume signature stable,
makes the storage backend fully swappable, and follows the same structural-protocol
pattern used throughout Ladon (ADR-003, ADR-006).
Consequences
- Good, because storage backends are testable independently of plugin logic — mock
the protocol in tests, use
LocalFileStoragein development,S3Storagein production, all without changing plugin code. - Good, because third parties can implement custom backends (GCS, Azure Blob, Redis) by satisfying the four-method structural protocol — no inheritance or registration required.
- Good, because
storage=Noneis an explicit, readable convention that replaces the formerskip_assets: boolflag. - Good, because
ladoncore shipsLocalFileStoragewith no new runtime dependencies — S3 and other cloud backends are separate opt-in packages. - Bad, because each plugin that handles assets must accept
storageat construction, a minor but required API change. - Bad, because key collision within a storage backend is the plugin's responsibility — the framework provides no deduplication.
Confirmation
ladon.storageexports aStorageprotocol (structural,@runtime_checkable) andLocalFileStorage(root: Path).isinstance(LocalFileStorage(...), Storage)returnsTrue.- A cloud-backend package (
ladon-storage-s3) ships separately and depends onboto3; core remains dependency-free. - Plugins receive
storage: Storage | None = Noneat construction; the runner passes no storage argument.
Pros and Cons of the Options
Option A: Embed file I/O in each plugin
- Good, because zero framework changes required.
- Bad, because path management is duplicated across all plugins.
- Bad, because swapping from local disk to object storage requires modifying every plugin that writes assets.
- Bad, because multi-worker deployments sharing a storage backend cannot use a local path.
Option B: Pass storage through run_crawl() and Sink.consume()
- Good, because plugins do not need a constructor change.
- Bad, because
Sink.consume(ref, client, storage)is a breaking change for all existing plugins. - Bad, because the runner acquires a dependency on a concept it does not own.
Option C: Storage protocol injected at plugin construction (chosen)
- Good, because
Sink.consumesignature is unchanged — no breaking change. - Good, because the
Storageprotocol is the extension point: structural subtyping means any compliant class satisfies it without inheriting from Ladon. - Good, because
Noneinjection disables asset download cleanly. - Neutral, because plugin constructors must be updated to accept
storage.
More Information
The Storage protocol exposes four methods:
from typing import Protocol, runtime_checkable
@runtime_checkable
class Storage(Protocol):
def write(self, key: str, data: bytes) -> None: ...
def read(self, key: str) -> bytes: ...
def exists(self, key: str) -> bool: ...
def delete(self, key: str) -> None: ...
Key scheme ({domain}/{run_id}/{leaf_id}/{filename}) is the plugin's responsibility.
The framework enforces no convention; structural isolation between plugin domains is
achieved through key prefixes.
Idempotency pattern — plugins should check before downloading:
if self._storage and not self._storage.exists(key):
data = client.get(url).value.content
self._storage.write(key, data)
Package boundary:
| Package | Ships |
|---|---|
ladon (core) |
Storage protocol, LocalFileStorage, error taxonomy |
ladon-storage-s3 |
S3Storage(bucket, prefix, client) — depends on boto3 |
| Adapter repos | Consume Storage via injection — no storage implementation |
Related decisions: ADR-003 (plugin interface), ADR-004 (SES protocol), ADR-006 (persistence layer).