ADR 0019: The storage contract, and keys as the security boundary¶
Status: accepted
Context¶
An application that handles uploads or generated artefacts needs somewhere to put bytes, and every option in that space — a directory, an object store, a database column — has the same two failure modes: a key that escapes the namespace it was given, and an unbounded object. Both are ordinary mistakes in an adapter and both are unrecoverable in production, which is what makes them worth a contract rather than a convention.
Two things about this framework decide the shape:
- It ships one implementation rather than a catalogue. ADR 0016 made the database seam reusable and proved it with a conformance suite that fails for two deliberately broken providers. A storage backend is the same kind of seam — a local directory today, an S3-compatible store later — and deserves the same treatment rather than a second, differently-shaped one.
- A dependency-free capability is a module here, not an extra.
ADR 0018 places it. The filesystem implementation uses only the
standard library, so
xyberos.storageis importable without an extra, exactly asxyberos.aiis.
Decision¶
Ship a five-member contract, one working implementation, a double, and a suite — and make the key rules part of the contract, not part of each provider.
- The contract is
max_object_bytes,put,get,delete,list_keys, andhealth. It deliberately says nothing about content types, versions, multipart uploads, signed URLs, or metadata, so a provider cannot smuggle a backend-specific API into application code.putreplaces what was there and reports what it stored;getraisesStorageNotFoundErrorrather than returningNone;deletereports whether an object was there;list_keysyields keys in ascending order;healthproves the space can be read and written, because a filesystem that has gone read-only still reports a directory that exists. - A key is a relative path, and
validate_keyis the only rule. No leading or trailing separator, no empty,.or..segment, no backslash, no control character, and no segment beginning with the reserved prefix.xyberos-that providers use for their own scratch space. The rule lives in the contract module rather than in each provider because it is the one mistake every backend can make, and it is called by every provider that ships here and by the double. - The local provider checks containment on the resolved path. Validating the key is not enough:
(root / key).resolve()follows symlinks, so a link inside the root pointing outside it is refused by a containment test where a prefix test on the joined string would have written through it. It also refuses Windows device names (con,nul,com1, …) unconditionally, because a key that resolves to a device on one platform and a file on another means two things depending on where it runs. - The ceiling is enforced before the disk is touched, and publish is atomic.
putrefuses an object overmax_object_byteswithStorageSizeLimitErrorand stores nothing. A write lands in a temporary file thatos.replacepublishes within the filesystem, so a reader sees either the previous object or the new one and never a half-written one. assert_storage_contractships with the contract. A provider author outside this project runs it against their own implementation — the same claims checked here as against the shipped provider, so "we support an S3-compatible backend" means evidence. It is exercised againstInMemoryStorage, the filesystem provider, and two deliberately broken providers intests/storage/.
What is deliberately not promised. Durability against power loss: the bytes reach the disk on the operating system's schedule, and a deployment that needs the stronger guarantee uses a database. Nor performance, isolation, or operational maturity — a provider that passes the suite can still be wrong for a workload, and the suite says so in its own docstring.
Consequences¶
- Swapping the backend is a supported path, not a promised feature. A team that needs S3 writes a
provider package, runs
assert_storage_contract, and keeps the service code that depends onStorage. This repository never imports such a package, and the compatibility job proves the published pair. xyberos.storageis public surface in the preview tier.Storage,StoredObject,validate_key, the limits, the settings object, the provider, and the factory are declared in__all__and pinned bytests/test_public_surface.py; the errors (StorageError,StorageKeyError,StorageNotFoundError,StorageSizeLimitError,StorageConfigurationError) join the taxonomy inxyberos.errorsbeside the AI and durable families.- Configuration is the application's.
FilesystemStorageSettingsplusfilesystem_storage(settings)is the whole integration: the application composes the settings, the module handsapp.providea zero-argument async factory, and nothing reads the environment or a configuration file. It is the explicit-argument route of ADR 0018, proven intests/storage/through the registry rather than in prose. listwas renamedlist_keys. The first implementation named the memberlist, which shadows the builtin inside the class body: twomypyerrors in_keyswere the visible symptom, and the invisible one is that any later method in those classes annotatinglist[str]would have silently resolved to the method. The contract pays one word to remove a whole class of mistake.- A key is data, not text. Refusing control characters and reserving one segment prefix is what makes a key safe to put in a log line, a URL path, and a filesystem path at the same time — which is the only reason the validation belongs in the contract rather than in the provider that happens to be first.