ADR-0026: Strict-Gate Pattern for Optional Capability Kwargs¶
Status¶
Accepted
Context¶
USER_METADATA (RFC-0011 / ID-146) adds an optional metadata= kwarg to
Store.write*(). The backend either supports user metadata natively
(Capability.USER_METADATA) or it does not. When it does not, the question
is: raise or silently drop?
The same question was settled for atomic writes. Two atomic-write invariants together establish the precedent:
- AW-007 (spec 007) — "Atomicity is Never Assumed": the core never
silently falls back to non-atomic writes when
ATOMIC_WRITEis missing. This is the never-silently-degrade principle the present ADR inherits forUSER_METADATA. - AW-002 (spec 007) — "Capability Gate":
write_atomicraisesCapabilityNotSupportedbefore any I/O if the backend lacksATOMIC_WRITE. This is the raise-before-I/O mechanism the present ADR inherits forUSER_METADATA.
Together, these two invariants name a pattern: every optional behaviour that the caller explicitly requests — rather than a capability that merely upgrades a default path — raises before I/O if the backend cannot honour it, and never silently drops.
USER_METADATA is a second instance of this pattern. The decision deserves
its own ADR to name the pattern so future contributors can follow it
deliberately rather than rediscovering it case by case.
The case against silent drop¶
Saga consumers treat "write returned" as "metadata is durable." A silent drop means:
- The write succeeds.
- The caller believes metadata was stored.
- A downstream
get_file_info()returnsFileInfo.metadata == None. - Data integrity invariants break silently, without a traceable error.
Silent degradation is worse than a loud failure: the failure is deferred, possibly to a different service or audit step, and by then the context that would explain it is gone.
The case against silent drop even for idempotent metadata¶
One might argue that metadata is "advisory" and a drop is tolerable. This argument fails for the target consumer: saga orchestrators use metadata to carry correlation IDs and idempotency tokens. A drop is not a degraded experience; it is a correctness failure.
Why not a capability guard on the method?¶
WRITE already gates write(). Adding a second gate (USER_METADATA) to
gate the entire method would prevent callers from writing on non-declaring
backends when metadata= is absent — which is wrong. The capability gates
only the use of the kwarg, not the method.
This is a new pattern: a capability that gates a specific kwarg on an existing method rather than the method as a whole.
Decision¶
When a caller passes an optional kwarg that requires a specific capability,
and the backend does not declare that capability, raise
CapabilityNotSupported before any I/O. Never silently drop the kwarg.
Naming the pattern: strict gate on kwarg¶
A strict gate on kwarg is a capability that:
- Does not gate the method — the method works without it.
- Does gate a specific optional argument — passing that argument requires the capability.
- Raises
CapabilityNotSupportedbefore any I/O if the backend lacks the capability and the argument is supplied.
The validation happens in the Store layer (one place), not in each backend.
Precedent (method-level raise-before-I/O gate)¶
| Capability | Gate target | Method(s) | Spec ref |
|---|---|---|---|
ATOMIC_WRITE |
whole method | write_atomic() |
AW-002 |
ATOMIC_WRITE is not a strict-gate-on-kwarg instance — it gates the
entire method, not an optional kwarg. It appears here because it
established the raise-before-I/O principle that the strict-gate-on-kwarg
pattern inherits. Future contributors should not use this row as a
pattern template.
Strict-gate-on-kwarg instances¶
| Capability | Gate target | Method(s) | Spec ref |
|---|---|---|---|
USER_METADATA |
metadata= kwarg |
write*() variants |
WR-010 |
USER_METADATA is the first true strict-gate-on-kwarg instance. New
instances of this pattern go in this table.
How to apply the pattern for future capabilities¶
When designing a new optional kwarg on an existing Store method:
- Define a new
Capabilityenum member for the feature. - Add Store-layer validation: if the kwarg is non-
None/ non-default and the backend lacks the capability, raiseCapabilityNotSupported. - Add the capability to
CAP-007(spec 003) under the strict-gate section. - Document per-backend declarations in the feature spec (e.g., WR-010).
- Add negative tests: every non-declaring backend raises on the guarded kwarg.
Adapter masking as a defensive application of the strict-gate pattern¶
AsyncBackendSyncAdapter applies the pattern defensively via capability masking.
It strips USER_METADATA (and WRITE_RESULT_NATIVE) from the inner async
backend's capability set, even when the wrapped backend declares them. Without
masking, the Store-layer WR-010 gate would pass a non-empty metadata= argument
through to the adapter, but the adapter has no forwarding target — the async ABC
does not yet accept metadata=. A silent drop would violate WR-012 (the
WriteResult.metadata echo guarantee) without triggering any error.
Masking is the mechanism that keeps the strict-gate invariant intact across
adapter wrapping: the gate fires at the Store layer (CapabilityNotSupported)
before the adapter is reached, so no I/O runs and no metadata is silently lost.
This is not an exception to the pattern; it is the same pattern applied one layer
earlier. When the async ABC grows metadata= support (Step 3c), the masking is
removed and the adapter naturally inherits the inner backend's declarations.
Consequences¶
- Callers get a clear, early error rather than a silent correctness failure.
- The pattern is named and documented; future contributors have a precedent.
- Test coverage requirement: every non-declaring backend must have a negative
test asserting
CapabilityNotSupportedwhen the guarded kwarg is passed. - The Store layer is the single enforcement point — backends do not need to validate the kwarg themselves.