Resolution Plan Specification¶
Overview¶
This spec defines resolve() — a universal introspection method that makes
key-to-location resolution explicit and inspectable across all backends.
Store.resolve(key) returns a frozen ResolutionPlan dataclass describing
how a key maps to its storage location, which backend handles it, and
backend-specific context for debugging, caching, and composition.
Relationship to existing specs:
- Extends
003-backend-adapter-contract.md(new default method on Backend ABC) - Extends
001-store-api.md(new method on Store) - Builds on
010-native-path-resolution.md(native_path()as foundation) - Relates to
023-ext-cache.md(future cache key derivation, Phase 2+) - See also: Research
The Problem¶
RES-001: Resolution Opacity¶
Backends resolve keys to bytes through different strategies (filesystem paths,
S3 objects, URLs, SQL queries, tiered fallthrough). Today this resolution is
implicit — callers get bytes but cannot inspect how or where those bytes
came from. native_path() exposes the resolved location as a string but
carries no metadata about the resolution strategy, backend identity, or
backend-specific context.
This creates three practical problems:
- Debugging opacity — when a read fails or returns unexpected data, there is no way to ask "which backend handled this key and how?"
- Cache key fragility — cache key construction requires ad-hoc assembly of identity fields rather than deriving from a canonical resolution result.
- Composition blindness — a future
CompositeStore(ID-121) that delegates across tiers has no standard way to report which tier resolved a key, which tiers were tried, or why resolution succeeded/failed.
Specification¶
RES-010: ResolutionPlan Dataclass¶
ResolutionPlan is a frozen dataclass in remote_store._resolution.
@dataclass(frozen=True)
class ResolutionPlan:
kind: str
backend: str
key: str
native_path: str
details: dict[str, Any] # wrapped in MappingProxyType via __post_init__
Fields:
kind— Resolution strategy identifier. Standard values:"local","s3","s3-pyarrow","azure","sftp","http","memory","sql-blob","sql-query","composite". Custom backends use their own strings. For simple backends,kindequalsBackend.name.backend— Human-readable backend identifier (typicallyBackend.name).key— The resolved key (store-relative afterStore.resolve(), backend-relative afterBackend.resolve()).native_path— The backend-native location string (same asBackend.native_path()output). Included for convenience.details— Backend-specific resolution context. Immutable at runtime: wrapped intypes.MappingProxyTypevia__post_init__to prevent accidental mutation. Values should be JSON-serializable primitives for logging/OTel compatibility.
Invariants:
ResolutionPlanis frozen (FrozenInstanceErroron attribute assignment).ResolutionPlanis NOT hashable (TypeErroronhash()) becauseMappingProxyType(dict)is not hashable. Cache keys must be derived from specific fields (see RES-100, Phase 2).detailsis immutable at runtime (TypeErroron item assignment).resolve()is an introspection API. It is never called implicitly by other Store or Backend methods (read(),write(), etc.).
RES-020: Backend.resolve() Default Implementation¶
Backend gains a non-abstract resolve() method with a sensible default.
Placed alongside native_path(), to_key(), and unwrap() in the interop
section.
def resolve(self, path: str) -> ResolutionPlan:
return ResolutionPlan(
kind=self.name,
backend=self.name,
key=path,
native_path=self.native_path(path),
details={},
)
- Non-abstract: existing and third-party backends get a working default.
- No I/O: pure name-to-plan mapping.
- Backends override to add meaningful
details.
RES-025: Universal Contract¶
For any backend b and valid path p:
plan = b.resolve(p)
assert plan.native_path == b.native_path(p)
assert isinstance(plan.details, MappingProxyType)
assert plan.kind == b.name # true for default impl; overrides may differ
The native_path invariant ensures resolve() and native_path() agree.
RES-030: Store.resolve() Key Rebasing¶
def resolve(self, key: str) -> ResolutionPlan:
full_path = self._full_path(key)
plan = self._backend.resolve(full_path)
return dataclasses.replace(plan, key=key)
- Rebases the key:
plan.keyis the store-relative key, not the backend-relative path. - All other fields (
kind,backend,native_path,details) are forwarded unchanged from the backend's plan. resolve("")is valid and resolves the store root.
RES-035: Store.resolve() Invariant¶
For any store s and valid key k:
This is the store-level coherence contract: the plan's native_path always
agrees with what the store would return for that key.
RES-040: ProxyStore.resolve() Delegation¶
ProxyStore delegates to the inner store. The returned plan is unchanged.
Subclasses (ObservedStore, CachedStore) may override to add behavior
(e.g., observation events).
RES-050: LocalBackend.resolve()¶
RES-051: S3Backend.resolve()¶
endpoint_url is stripped of userinfo (RFC 3986) before inclusion.
RES-052: S3PyArrowBackend.resolve()¶
RES-053: AzureBackend.resolve()¶
RES-054: SFTPBackend.resolve()¶
RES-055: ReadOnlyHttpBackend.resolve()¶
url is the fully-resolved URL for the key. method is "GET".
RES-056: MemoryBackend.resolve()¶
Uses the default Backend.resolve() implementation. No override needed.
RES-057: SQLBlobBackend.resolve()¶
RES-058: SQLQueryBackend.resolve()¶
source is "explicit" for mapped queries. format is the output format
(e.g., "parquet", "csv"). The raw SQL query is not included in
details (see Security section).
Security¶
ResolutionPlan may be logged, serialized, or sent to observability systems.
Its contents must be treated as potentially public.
detailsmust never include credentials, passwords, tokens, or secret keys.endpoint_urlvalues must be stripped of userinfo (user:pass@host) before inclusion indetails.- SQL queries are not included in
detailsto avoid leaking business logic or parameterized values. - Connection strings and database URLs are not included in
details.
Phase 1 Scope (ID-120)¶
Phase 1 covers RES-010 through RES-058:
ResolutionPlandataclass inremote_store._resolution- Default
Backend.resolve()on the ABC Store.resolve()with key rebasingProxyStore.resolve()delegation- Per-backend overrides for all 9 backends
- Export
ResolutionPlanfromremote_store.__init__
Out of scope (future phases)¶
- Phase 2 (RES-100): Cache key derivation from
ResolutionPlanfields inext.cache. - Phase 3 (RES-110, ID-121):
CompositeStore.resolve()with tier reporting,on_resolveobservation hook.
Test Requirements¶
Per sdd/TESTING.md rules:
- Every spec ID gets
@pytest.mark.spec("RES-xxx")tracing. MemoryBackendpreferred over mocks for Store/ProxyStore tests.- Per-backend tests assert expected detail key names exist (not values),
except where semantically important (e.g.,
details["bucket"]matches configured bucket). - Edge cases:
resolve(""),child().resolve(), special characters in keys. - Failure-path:
hash(plan)raisesTypeError.