v0.9 Sub-issue #3 — Stage 2 Resolve
Part of v0.9 epic.
Implements the v0.8 Part B Stage 2 (Resolve): map each capability declared
in binding/runtime_binding.json::capabilities to a concrete
LifeCapabilityProvider via the Provider Registry, walking
engine_compatibility[] and applying tier-aware preference.
Spec ref
docs/LIFE_RUNTIME_STANDARD.md Part B §B.2 (Provider Registry)
docs/LIFE_RUNTIME_STANDARD.md Part B §B.2.1 (LifeCapabilityProvider)
docs/LIFE_RUNTIME_STANDARD.md Part B §B.3 (tier-aware resolution)
docs/LIFE_RUNTIME_STANDARD.md Part B §B.4 (sandbox classes)
docs/LIFE_BINDING_SPEC.md §5 (engine_compatibility / strict / version_range)
Scope
LifeCapabilityProvider abstract
runtime/resolve/provider_interface.py:
class LifeCapabilityProvider(abc.ABC):
@abc.abstractmethod
def capability_name(self) -> str: ...
@abc.abstractmethod
def provider_name(self) -> str: ...
@abc.abstractmethod
def provider_version(self) -> str: ...
@abc.abstractmethod
def sandbox_class(self) -> Literal["built_in", "user_installed", "bundled_in_life"]: ...
@abc.abstractmethod
def initialize(self, asset_paths: list[Path], params: dict, hard_constraints: dict) -> None: ...
@abc.abstractmethod
def teardown(self) -> None: ...
@abc.abstractmethod
def invoke(self, input: dict) -> dict: ...
(Implementations for built_in echo Provider land in sub-issue 7.
Implementations for user_installed IPC subprocess shim land in
sub-issue 4.)
Provider Registry
runtime/resolve/registry.py:
ProviderRegistry: in-memory, populated from a config file
(~/.config/dlrs/providers.json or ${DLRS_PROVIDERS} env var).
Built-in Providers are auto-registered at import time.
list_providers(capability) → ordered list (built-in first, then user-installed).
resolve(capability, engine_compatibility[]) → ProviderRef — implements
the spec walk:
- For each entry in
engine_compatibility[] in order:
- find Providers whose
capability_name() == capability and
provider_name() matches the entry's engine.name
- filter by
version_range (semver-compatible match using
packaging.specifiers.SpecifierSet)
- if
engine.strict == true, require exact (name, version) match
- if any survive, return the highest version
- After exhausting
engine_compatibility[]: fail-close with
engine_resolution_exhausted.
metadata(ProviderRef) → ProviderMetadata returning at minimum
(name, version, sandbox_class).
bundled_in_life defence in depth
Per §B.4 + §B.4.1, even though the binding schema (#111) statically rejects
engine_kind: bundled_in_life, the Provider Registry MUST also refuse at
resolve-time. Add an explicit check: any Provider whose
sandbox_class() == "bundled_in_life" is filtered out before resolution
runs. If after filtering no Provider remains, emit
assembly_aborted{stage: "resolve", reason: "bundled_in_life_refused"}.
Tier-aware preference (B.3)
The resolve walk above respects issuer-declared engine_compatibility[]
order. When two Providers tie (same engine.name, same satisfied
version_range), break the tie by tier band:
- I–IV: prefer Providers with
metadata.fidelity_class == "low" (a hint
field in registry entries; absent → neutral).
- V–VIII: no preference.
- IX–XII: prefer
metadata.fidelity_class == "high"; for capabilities
permitted by hosted_api_preference.allowed == true, prefer
metadata.deployment == "hosted" (hosted-API actual use still gated
at Stage 4 via the AND-gate — Stage 2 only declares preference).
The package's tier.level is read from the tier block (life-format
v0.1.1, descriptor field). Absent → assume tier band V–VIII (neutral).
tier_floor honouring
Per binding spec §5.1: if a capability_binding.tier_floor is present
and the package's tier.level is below it, emit a warning audit event
tier_floor_below_warning{capability, tier_floor, tier_actual} and
continue (SHOULD-level, not MUST-level). Reasons listed in spec.
Audit events emitted
assembly_aborted{stage: "resolve", reason} — on any resolve failure
(engine exhaustion, bundled refusal, etc.).
tier_floor_below_warning{capability, tier_floor, tier_actual} —
per offending capability.
(No provider_resolved event — that's emitted by Stage 3 as
capability_bound after Assemble finishes.)
CLI surface
lifectl run <pkg.life> after this PR: runs Stage 1 + Stage 2; on PASS
prints a resolution summary:
Stage 1 Verify ✓ package_id=... lifecycle=active
Stage 2 Resolve ✓ 3 capabilities resolved:
• text_chat → echo-builtin@1.0.0 (built_in)
• voice_synthesis → xtts-v2@2.1.0 (user_installed)
• memory_recall → graphrag-local@0.4.0 (built_in)
Stage 3+ pending sub-issues 4-7
Tests
tools/test_runtime_resolve.py:
- Happy path: minimal-life-package + a registry containing a built-in
echo Provider for text_chat → resolves cleanly.
- Engine_compat walk: binding declares
[xtts-v2, builtin-tts];
only builtin-tts registered → walks past xtts, returns builtin-tts.
- Strict version mismatch: binding declares
xtts-v2 >=2.0.0 strict=true; registry has xtts-v2@1.5.0 → fail-close.
- All entries exhausted: empty registry →
engine_resolution_exhausted.
- Bundled refusal: registry contains a fake Provider with
sandbox_class == "bundled_in_life" → filtered + fail.
- Tier-floor warning: binding has
tier_floor: VIII, package
tier.level == VI → warning event emitted, resolve still succeeds.
- Tier preference tie-break IX: two equally valid providers, package
tier IX, one with fidelity_class: high, one without → high wins.
Acceptance
v0.9 Sub-issue #3 — Stage 2 Resolve
Part of v0.9 epic.
Implements the v0.8 Part B Stage 2 (Resolve): map each capability declared
in
binding/runtime_binding.json::capabilitiesto a concreteLifeCapabilityProvidervia the Provider Registry, walkingengine_compatibility[]and applying tier-aware preference.Spec ref
docs/LIFE_RUNTIME_STANDARD.mdPart B §B.2 (Provider Registry)docs/LIFE_RUNTIME_STANDARD.mdPart B §B.2.1 (LifeCapabilityProvider)docs/LIFE_RUNTIME_STANDARD.mdPart B §B.3 (tier-aware resolution)docs/LIFE_RUNTIME_STANDARD.mdPart B §B.4 (sandbox classes)docs/LIFE_BINDING_SPEC.md§5 (engine_compatibility / strict / version_range)Scope
LifeCapabilityProviderabstractruntime/resolve/provider_interface.py:(Implementations for
built_inecho Provider land in sub-issue 7.Implementations for
user_installedIPC subprocess shim land insub-issue 4.)
Provider Registry
runtime/resolve/registry.py:ProviderRegistry: in-memory, populated from a config file(
~/.config/dlrs/providers.jsonor${DLRS_PROVIDERS}env var).Built-in Providers are auto-registered at import time.
list_providers(capability)→ ordered list (built-in first, then user-installed).resolve(capability, engine_compatibility[]) → ProviderRef— implementsthe spec walk:
engine_compatibility[]in order:capability_name() == capabilityandprovider_name()matches the entry'sengine.nameversion_range(semver-compatible match usingpackaging.specifiers.SpecifierSet)engine.strict == true, require exact(name, version)matchengine_compatibility[]: fail-close withengine_resolution_exhausted.metadata(ProviderRef) → ProviderMetadatareturning at minimum(name, version, sandbox_class).bundled_in_lifedefence in depthPer §B.4 + §B.4.1, even though the binding schema (#111) statically rejects
engine_kind: bundled_in_life, the Provider Registry MUST also refuse atresolve-time. Add an explicit check: any Provider whose
sandbox_class() == "bundled_in_life"is filtered out before resolutionruns. If after filtering no Provider remains, emit
assembly_aborted{stage: "resolve", reason: "bundled_in_life_refused"}.Tier-aware preference (B.3)
The resolve walk above respects issuer-declared
engine_compatibility[]order. When two Providers tie (same
engine.name, same satisfiedversion_range), break the tie by tier band:metadata.fidelity_class == "low"(a hintfield in registry entries; absent → neutral).
metadata.fidelity_class == "high"; for capabilitiespermitted by
hosted_api_preference.allowed == true, prefermetadata.deployment == "hosted"(hosted-API actual use still gatedat Stage 4 via the AND-gate — Stage 2 only declares preference).
The package's
tier.levelis read from thetierblock (life-formatv0.1.1, descriptor field). Absent → assume tier band V–VIII (neutral).
tier_floorhonouringPer binding spec §5.1: if a
capability_binding.tier_flooris presentand the package's
tier.levelis below it, emit a warning audit eventtier_floor_below_warning{capability, tier_floor, tier_actual}andcontinue (SHOULD-level, not MUST-level). Reasons listed in spec.
Audit events emitted
assembly_aborted{stage: "resolve", reason}— on any resolve failure(engine exhaustion, bundled refusal, etc.).
tier_floor_below_warning{capability, tier_floor, tier_actual}—per offending capability.
(No
provider_resolvedevent — that's emitted by Stage 3 ascapability_boundafter Assemble finishes.)CLI surface
lifectl run <pkg.life>after this PR: runs Stage 1 + Stage 2; on PASSprints a resolution summary:
Tests
tools/test_runtime_resolve.py:echo Provider for
text_chat→ resolves cleanly.[xtts-v2, builtin-tts];only
builtin-ttsregistered → walks past xtts, returns builtin-tts.xtts-v2 >=2.0.0 strict=true; registry hasxtts-v2@1.5.0→ fail-close.engine_resolution_exhausted.sandbox_class == "bundled_in_life"→ filtered + fail.tier_floor: VIII, packagetier.level == VI→ warning event emitted, resolve still succeeds.tier IX, one with
fidelity_class: high, one without → high wins.Acceptance
LifeCapabilityProviderabstract publishedProviderRegistryimplemented with all spec-listed operationsruntime-resolvejob green