Skip to content

fix(runtime): compare mixed-runtime target manifests in canonical contract form #1482

Open

Description

@doublewhy

Gap Claim

MixedRuntimeBinding refuses a component whose runtime target carries the admitted backend manifest when the backend built that manifest in another internal representation.

_require_bound_component (implementations/python/packages/raes_runtime/mixed_runtime.py:194-214) runs for every admitted component when a binding is constructed (:88, :184-191). It first compares the configured backend-manifest-v2 model with the admitted profile: identity, version, canonical digest and envelope identity (:197-211). It then rebuilds an internal BackendManifest from that model with backend_manifest_from_v2_model_with_envelope and requires the target's manifest to equal the rebuild under dataclass == (:212-214). The rebuild does not reproduce how a backend holds its own manifest:

  • provisioner_from_model (implementations/python/packages/raes_backend_protocols/provisioner_manifest.py:66-73) restores operating_systems as a tuple in the payload's row order. backend_manifest_v2_model writes those rows sorted by family and distribution (:20-30).
  • _realization_support_from_model (implementations/python/packages/raes_backend_protocols/manifest.py:273-290) restores artifact_mechanisms as a tuple (:288).
  • Neither ProvisionerCapabilities (implementations/python/packages/raes_backend_protocols/provisioner_capabilities.py:152, __post_init__ at :168-222) nor RealizationSupportDeclaration (implementations/python/packages/raes_contracts/apparatus.py:151, __post_init__ at :154-173) normalizes these sequences. A backend's manifest keeps the order and the container type it was built with. A list never equals a tuple, and two tuples with the same rows in another order are not equal.

LilRAE's APTL backend builds its manifest that way. At LilRAE dev (a5833df9), create_aptl_runtime_target puts create_aptl_manifest() on its RuntimeTarget (src/aptl/backends/raes.py:147-152). The provisioner keeps its operating-system rows in authored order: debian, ubuntu, rocky-linux, then the x-aptl: distributions (src/aptl/backends/raes_operating_systems.py:15-38, used at src/aptl/backends/raes_manifest.py:231). The realization support passes artifact_mechanisms=list(aptl_artifact_mechanisms()) (src/aptl/backends/raes_manifest.py:387).

Reproduction on rae dev (35122105) with the LilRAE dev source (a5833df9) on the path, using the rae project environment's Python 3.14.4 on 2026-10-10 at 19:52Z. The script below builds the manifest and the realization envelope with LilRAE's own functions. It admits the manifest's published payload, backend_manifest_payload(create_aptl_manifest()), which LilRAE records for provenance (src/aptl/core/provenance/providers/apparatus.py:39). Then it runs the check above on a target that carries LilRAE's manifest:

backend identity: aptl 0.1.0
admitted manifest digest: sha256:330baced51d67269813678b798b320f9673a85f26e3a59ddb6a918a0471c9c15
target manifest in canonical contract form, digest: sha256:330baced51d67269813678b798b320f9673a85f26e3a59ddb6a918a0471c9c15 (equal: True)
target.manifest == round-tripped admitted manifest: False
  differs: manifest.realization_support[0].artifact_mechanisms: list != tuple
  differs: manifest.capabilities.provisioner.operating_systems: same items in another order
  target: operating_systems (debian, ubuntu, rocky-linux, x-aptl:almalinux, x-aptl:alpine, x-aptl:amazon-linux, x-aptl:centos, x-aptl:kali); artifact_mechanisms is a list
  rebuilt: operating_systems (debian, rocky-linux, ubuntu, x-aptl:almalinux, x-aptl:alpine, x-aptl:amazon-linux, x-aptl:centos, x-aptl:kali); artifact_mechanisms is a tuple
check result: ValueError: mixed runtime target manifest differs from the admitted component

The target's manifest is the admitted contract: rendered by backend_manifest_v2_model, it has the admitted digest. The check refuses it because the two internal representations differ.

No test reaches this refusal. The only MixedRuntimeBinding fixture builds each target's manifest with the same backend_manifest_from_v2_model_with_envelope call the check makes (implementations/python/tests/test_issue_1016_mixed_runtime_coordination.py:278, target at :281-290). The check therefore compares the rebuild with itself, and no test expects mixed runtime target manifest differs from the admitted component. The #1344 fixture-provenance audit found the gap (finding MF-2 in the record #1461 proposes).

MixedRuntimeBinding and MixedRuntimeComponent are public raes_runtime exports (implementations/python/packages/raes_runtime/__init__.py:15). In this repository only the #1016 tests construct them, and LilRAE does not yet bind its target into a mixed composition. Any backend whose own manifest keeps its operating-system rows in an unsorted order, or holds one of these sequences as a list, is refused.

Reproduction script (run with the rae project environment's Python and PYTHONPATH=<lilrae>/src)
import dataclasses
import sys

from aptl.backends.raes_manifest import create_aptl_manifest, create_aptl_realization_envelope
from raes_backend_protocols.manifest import (
    backend_manifest_from_v2_model_with_envelope,
    backend_manifest_payload,
    backend_manifest_v2_model,
)
from raes_backend_stubs.stubs import create_stub_components
from raes_contracts.canonical import canonical_json_digest
from raes_contracts.contracts import BackendManifestV2Model
from raes_contracts.contracts.mixed_composition import MixedCompositionComponentModel
from raes_runtime.mixed_runtime import MixedRuntimeComponent, _require_bound_component
from raes_runtime.registry import RuntimeTarget


def differences(left: object, right: object, path: str = "manifest") -> list[str]:
    if dataclasses.is_dataclass(left) and type(left) is type(right):
        found: list[str] = []
        for item in dataclasses.fields(left):
            found += differences(getattr(left, item.name), getattr(right, item.name), f"{path}.{item.name}")
        return found
    if isinstance(left, tuple | list) and isinstance(right, tuple | list) and len(left) == len(right):
        notes = [f"{type(left).__name__} != {type(right).__name__}"] if type(left) is not type(right) else []
        if list(left) != list(right) and all(item in right for item in left):
            notes.append("same items in another order")
        elif list(left) != list(right):
            found = []
            for index, (a, b) in enumerate(zip(left, right, strict=True)):
                found += differences(a, b, f"{path}[{index}]")
            return found
        return [f"{path}: {', '.join(notes)}"] if notes else []
    if left != right:
        return [f"{path}: {left!r} != {right!r}"]
    return []


def main() -> int:
    manifest = create_aptl_manifest()  # what create_aptl_runtime_target() puts on its RuntimeTarget
    envelope = create_aptl_realization_envelope()
    admitted = BackendManifestV2Model.model_validate(backend_manifest_payload(manifest))  # LilRAE's published payload
    digest = canonical_json_digest(admitted.model_dump(mode="json"))
    declaration = MixedCompositionComponentModel(
        component_id="aptl",
        apparatus_identity_ref="apparatus:aptl",
        realization_form="emulation-or-operation",
        manifest_ref={
            "ref_kind": "manifest",
            "ref_id": admitted.identity.name,
            "ref_version": admitted.schema_version,
            "ref_digest": digest,
            "subject_ref": {
                "ref_kind": "backend",
                "ref_id": admitted.identity.name,
                "ref_version": admitted.identity.version,
            },
        },
        realization_envelope=envelope.identity,
    )
    parts = create_stub_components(manifest=manifest)
    target = RuntimeTarget(
        name="aptl",
        manifest=manifest,
        provisioner=parts.provisioner,
        orchestrator=parts.orchestrator,
        evaluator=parts.evaluator,
        participant_runtime=parts.participant_runtime,
        time_runtime=parts.time_runtime,
        observation_runtime=parts.observation_runtime,
    )
    print(f"backend identity: {manifest.identity.name} {manifest.identity.version}")
    print(f"admitted manifest digest: {digest}")
    rendered = canonical_json_digest(backend_manifest_v2_model(manifest).model_dump(mode="json"))
    print(f"target manifest in canonical contract form, digest: {rendered} (equal: {rendered == digest})")
    expected = backend_manifest_from_v2_model_with_envelope(admitted, envelope)
    print(f"target.manifest == round-tripped admitted manifest: {manifest == expected}")
    for line in differences(manifest, expected):
        print(f"  differs: {line}")
    for label, value in (("target", manifest), ("rebuilt", expected)):
        rows = ", ".join(row.distribution for row in value.provisioner.operating_systems)
        mechanisms = type(value.realization_support[0].artifact_mechanisms).__name__
        print(f"  {label}: operating_systems ({rows}); artifact_mechanisms is a {mechanisms}")
    try:
        _require_bound_component(MixedRuntimeComponent(target=target, manifest=admitted, envelope=envelope), declaration)
    except ValueError as exc:
        print(f"check result: ValueError: {exc}")
        return 1
    print("check result: accepted")
    return 0


if __name__ == "__main__":
    sys.exit(main())

The stub components only satisfy RuntimeTarget's shape checks; the check reads the target's manifest alone.

Existing Surface Audit

  • Admission. Trial admission pins each composition component to the digest of its concrete payload, canonical_json_digest(manifest.model_dump(mode="json")) (implementations/python/packages/raes_processor/trial_compiler/apparatus.py:84-85, through _resolve_composition_components at :348-386), and to its envelope identity. The binding's first comparison re-checks the configured model against that digest.
  • Canonical form. backend_manifest_v2_model (implementations/python/packages/raes_backend_protocols/manifest.py:84-257) renders an internal manifest as the authoritative v2 contract model. It sorts the set-valued fields and the operating-system rows, and lists contract ids in authority order. Other checks identify a target's manifest through it: _require_preparation_contract (implementations/python/packages/raes_runtime/backend_preparation.py:177-179), preparation_authority (implementations/python/packages/raes_processor/planner/realization_preparation.py:47) and the materialization binding (implementations/python/packages/raes_processor/planner/materialization_admission.py:98) each compare canonical_json_digest(backend_manifest_v2_model(manifest).model_dump(mode="json")).
  • Envelope. The v2 form carries only the envelope's identity (manifest.py:121-125). BackendRealizationEnvelopeModel checks its digest against its content when it is validated (implementations/python/packages/raes_contracts/realization_envelope_carrier.py:286-313), and contract models are not frozen (implementations/python/packages/raes_contracts/_base.py:8-11). Today's dataclass equality also compares the full envelope the target holds.
  • Decision record. The Coordinate mixed participant runtimes fail-closed #1016 preflight asks the binding to "validate every component target's manifest identity and digest, realization-envelope membership, required runtime component, and current profile-relative support against the sealed profile" (docs/decisions/issue-1016-mixed-runtime-coordination-preflight.md:49-52). The reference page says the control plane accepts a binding only when its component manifests, realization envelopes and runtime targets "match exactly" (docs/explain/reference/mixed-participant-composition.md:80-83).
  • Requirement. SEM-234 traces raes_runtime/mixed_runtime.py (docs/requirements/SEM-234/requirement.md:43).
  • Tests. Besides the Coordinate mixed participant runtimes fail-closed #1016 module, implementations/python/tests/test_issue_1355_mixed_mapping_time.py builds its bindings through the same _admitted_binding helper.
  • Size. mixed_runtime.py has 407 lines, against the 500-line cap.

Alternatives

  1. Normalize the sequences in the protocol types, for example sort operating_systems into a tuple in ProvisionerCapabilities and make artifact_mechanisms a tuple in RealizationSupportDeclaration: rejected. It changes types that every backend constructs, and the check would still depend on the internal representation of every field added later.
  2. Make the rebuild reproduce the producer's representation: rejected. The published payload sorts the rows, so the authored order is gone, and the container type is the producer's choice.
  3. Compare the target's rendered manifest, or its digest, with the configured payload as given: rejected. backend-manifest-v2 does not require sorted lists, so a payload that lists its rows in another order would still be refused, although it is the same contract.
  4. Render both the target's manifest and the rebuilt admitted manifest with backend_manifest_v2_model and compare the two, keep the rebuild, which still validates the admitted payload and its envelope join, and compare the target's envelope with the admitted envelope directly: selected.

Chosen Architecture

  • _require_bound_component keeps both refusals and their messages. Its second comparison requires the target's manifest and the rebuilt admitted manifest to be equal once each is rendered by backend_manifest_v2_model, and the target's realization envelope to equal the admitted envelope.
  • Every field the contract carries is still compared. Only the internal order and container type stop mattering, where the contract form sorts or lists them. A target whose contract differs from the admitted one, such as one with an operating-system row fewer, is still refused.
  • No contract, schema, trial admission or backend changes. LilRAE needs no change.
  • The change edits package code bound by the research evidence, so both captures are republished at the next free releases.

Verification Plan

  • Commit a capture of LilRAE's manifest as test data, implementations/python/tests/data/boundary_captures/lilrae-aptl-backend-manifest.json, produced by LilRAE's own functions at a5833df9 and holding its provenance. It records the published payload, the realization envelope, and how the producer's own manifest orders its operating-system rows and holds its artifact mechanisms.
  • A new test module, implementations/python/tests/test_issue_<this issue>_mixed_runtime_canonical_manifest.py, binds:
    • a target manifest rebuilt from the capture in the producer's representation, which must be accepted (this case fails on dev);
    • the round-trip target that the Coordinate mixed participant runtimes fail-closed #1016 tests build, which must stay accepted;
    • a target whose contract differs from the admitted one, which must still be refused with today's message.
  • Run test_issue_1016_mixed_runtime_coordination.py, test_issue_1355_mixed_mapping_time.py and the new module with default markers and with -m integration.
  • Rerun the reproduction above against the fix.
  • Run nox -s verify-fast-feedback -- --base-rev origin/dev, nox -s lint, make policy, both research evidence checkers and the three evidence test modules.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions