diff --git a/README.md b/README.md index 984708c..c284dd3 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,39 @@ heavy, mutually-incompatible dependencies live behind an extra. A single `uv.lock` covers the tree; a future simulator with a conflicting stack is isolated with uv's `conflicts` extras declaration, not a separate lockfile. +## Shared adapter plumbing + +`raes_adapters.base` is available from the base installation and composes the +published RAES APIs directly: + +- `build_runtime_target` constructs `raes_runtime.RuntimeTarget` from a + published manifest and component set, leaving all shape checks to RAES. +- `apply_logical_clock_transition` dispatches a caller-selected transition + through `ReferenceTimeRuntime`; adapters must explicitly map native events and + supply exact coordinates. +- `apply_seed_controls` applies an ordered list of published stochastic-control + bindings through driver-local callables and returns RAES diagnostics for + applied, unbound, unsupported, and failed controls. Application alone is not + a replay claim. +- `execute_cleanup` admits a published cleanup plan, runs driver-local + operations in dependency order, and returns a validated + `TrialCleanupReceiptModel`. Operations are synchronous and remain responsible + for native timeout and verification mechanics. +- `project_action`, `project_observation`, and `project_evaluation` provide + typed direction-specific callable seams. Observation and evaluation outputs + must pass a caller-supplied RAES validator; native failures never become + portable fallback values. +- `redact_native_value` is default-deny and never renders arbitrary objects. + `bounded_context_label` admits only short, grammar-checked, intentionally safe + labels. +- `run_conformance_probe` returns the exact `BackendConformanceReport` from the + published RAES target runner without adding profiles, fixtures, cases, or + claims. + +These helpers do not define simulator concepts, portable DTOs, schemas, +backend protocols, diagnostic envelopes, stores, policy gates, or conformance +authority. + ## Layout ```text diff --git a/docs/decisions/base-plumbing-guardrails.md b/docs/decisions/base-plumbing-guardrails.md new file mode 100644 index 0000000..c9e4767 --- /dev/null +++ b/docs/decisions/base-plumbing-guardrails.md @@ -0,0 +1,212 @@ +# Shared simulator-adapter plumbing guardrails + +GitHub issue #14 is the authority for the shared-base deliverable. This note +fixes the architecture boundaries that implementation must respect; it does not +define a new contract or prescribe an implementation plan. + +## Keep mechanics separate from semantics + +`raes_adapters.base` is an always-installed convenience layer over +`raes==2.0.0`. It may sequence functions, bind driver-local callables, enforce +bounded failure hygiene, and reduce repetitive target construction. It does not +own the meaning or portable shape of targets, clocks, stochastic controls, +cleanup, projections, diagnostics, or conformance. + +The public base surface must therefore use the published RAES types directly. +A local type may describe ephemeral in-process mechanics only when RAES has no +portable counterpart. Such a type must not be a Pydantic model, JSON schema, +manifest or wire format, must not be serialized as authority, and must not +repeat fields from a RAES contract. In particular, do not add a base semantic +model, backend protocol, schema/profile/fixture registry, diagnostic envelope, +exception hierarchy, concept catalog, store, or policy gate. + +The pinned RAES distribution does not currently ship a PEP 561 `py.typed` +marker. Keep concrete RAES annotations on the public API and confine any +`import-untyped` suppression to imports. Do not erase public types to `Any`, +copy RAES annotations into local stubs, or invent local DTOs to make mypy green. +`raes-adapters` already ships its own `py.typed`; strict mypy, documented +signatures, and behavior tests remain the local typing boundary. + +## Canonical incumbents + +| Concern | Published owner to compose | Base boundary | +| --- | --- | --- | +| Runtime target | `raes_runtime.registry.RuntimeTarget`, `RuntimeTargetComponents`, `BackendRegistry`; `raes_backend_protocols.backend_manifest.BackendManifest` | Construct the RAES target and let its manifest/presence/signature checks run. Do not mirror target fields, validate method shapes locally, or create another registry. | +| Backend behavior | Protocols in `raes_backend_protocols.protocols` and capability types/validators in `raes_backend_protocols.capabilities` | Driver implementations satisfy the published structural protocols. Base callables are mechanical extension seams, not another backend protocol family. | +| Logical time | `raes_runtime.time_coordinator.ReferenceTimeRuntime` and `TimeCoordinator`; closed time models and `validate_time_runtime_state` in `raes_contracts.contracts.time_model`; `time_model_conformance_diagnostics` | Reuse exact superdense coordinates, append-only history, declaration digest, lifecycle rules, and typed readback. Do not define a second clock state or infer that one simulator call equals one logical tick. | +| Stochastic control | `ExperimentStochasticControlModel`, `PublicSeedModel`, `RandomStreamControlBindingModel`, `RandomStreamDrawRecordModel`; `load_random_stream_profile` and the stateless `raes_contracts.random_stream_engine` | Apply explicitly named driver-local seed bindings and report unsupported/unbound controls honestly. Do not treat an integer seed or successful setter call as a replay claim. | +| Cleanup | `TrialCleanupPlanModel`, `TrialCleanupReceiptModel`, `validate_trial_cleanup_receipt`; `CleanupCapabilities` and `require_cleanup_plan_capability` | Sequence driver-local operations only after capability admission and return the published receipt at a portable boundary. RAES publishes no generic native cleanup executor, so do not invent one as a backend protocol. | +| Projection | The applicable closed RAES action, participant-observation, evaluation, evidence, and runtime-result models and their validators | Compose typed callables; validate the terminal portable value with its RAES owner. Do not introduce generic action/observation/reward DTOs or make native simulator tuples a shared protocol. | +| Diagnostics/results | `raes_contracts.diagnostics.Diagnostic`, `DiagnosticModel`, `diagnostic_model`; `raes_contracts.runtime_state.ApplyResult` | Portable failures use these exact types and bounded, input-free messages. Do not surface native exceptions or create a local result envelope. | +| Conformance | `raes_conformance.conformance.run_target_conformance`, `run_fixture_suite`, `profile_for_manifest`, `BackendConformanceReport`, and the published corpus roots | Delegate selection, fixture validation, target probes, bounded claims, and report construction. Do not append hand-made report cases, copy fixtures, or maintain a profile-to-contract table. | +| Runtime validation | `raes_runtime.RuntimeManager` / `RuntimeControlPlane` and their backend-call, snapshot-transition, time-readback, and result-contract gates | Exercise helpers through the public RAES manager/control plane where portable state crosses the boundary. Do not import RAES private `_call_backend_*` helpers or fork their validation. | +| Persistence | `raes_runtime.control_plane_store.ControlPlaneStore` and its existing implementations | Base helpers are in-process and stateless except for explicitly owned lifecycle state. Add no cache, repository, audit log, or evidence store. | + +## Mechanics and extension seams + +Runtime-target construction is composition, not translation. The driver +supplies a published `BackendManifest` and components satisfying the published +protocols; constructing `RuntimeTarget` (or using `BackendRegistry.create`) +remains the shape gate. Manifest capability declarations and actual optional +component presence must agree. A convenience factory must not catch and +reinterpret those `ValueError`s using rejected configuration values. + +Logical-clock convenience must use `ReferenceTimeRuntime`/`TimeCoordinator` and +RAES time contracts. Simulator steps, participant actions, complete environment +turns, wrapper cutoffs, evaluator cutoffs, termination, truncation, reset, +replay, and cleanup are distinct events. The driver owns the explicit mapping +from a native event to an admitted clock transition; the base must not guess it. +Determinism covers the declared logical transition sequence only, not wall +clock scheduling, native simulator determinism, or outcome equivalence. + +Seed application uses an explicit ordered set of driver-local bindings. Keep +each random source distinct: simulator, scenario generator, scheduler, +participant policy, action space/wrapper, Python, NumPy, evaluator, and any +backend-specific stream are separate controls. The report must distinguish +applied, unsupported, unbound, and failed controls and retain limitation +diagnostics; it must never silently skip a source, fall back to process-global +randomness, derive order from a set or mapping, or call `random.seed`/NumPy +globally unless that exact binding was explicitly supplied. Governed entropy +references are resolved only by an authorized driver-local resolver and raw +entropy never returns through the base API. Any local status collection remains +ephemeral mechanics; portable reporting uses RAES diagnostics and the existing +experiment/run contracts, not a base seed-report DTO. + +Cleanup must attempt every eligible triggered obligation consistent with its +dependency order while preserving the distinction between trial outcome and +cleanup outcome. Dependency order, retry safety, required verification, +residual-state disclosure, and receipt consistency come from the RAES +plan/receipt validators. A driver-local dispatch mapping is the extension seam +for native `destroy`, `reset`, `restore`, `compensate`, `verify`, or custom +operations. Cleanup failure must neither hide the primary failure nor be +swallowed; a successful trial with failed, unverified, or residual cleanup +remains a cleanup failure. Residual state is reported by bounded reference, +never by raw native inventory. + +Projection composition is direction-aware: + +- action projection translates an admitted portable action into a private + driver-native invocation; +- observation projection translates private native results into the applicable + participant-visible RAES contract; and +- evaluation projection translates private native measures into published + evaluation/evidence contracts. + +Use generic typed callable composition rather than an inheritance hierarchy or +simulator-aware base class. The required extensibility seam is the caller- +supplied terminal validator/factory for the applicable RAES type. Intermediate +native values remain in process and are never logged, placed in diagnostics, or +returned as a portable fallback. Composition must preserve order and must not +merge action admission, execution, observation, reward, evaluation, or +termination into one ambiguous "step". + +Conformance convenience returns the existing `BackendConformanceReport` from +`run_target_conformance`. Profile inference and the default fixture/profile +roots remain RAES-owned. Corpus-root overrides are test injection only, must +remain path-confined by the RAES loader, and are not production configuration. +The supported extension parameters are the runner's existing seams, including +the exact target, reference scenario, realization harness, execution basis, +selected envelope, and observer version. A driver-specific live probe belongs +behind the published harness/protocol seam; it does not mint a second report or +claim family. + +## Redaction and failure hygiene + +RAES provides closed diagnostic/result shapes and a redacted HTTP exception +handler, but no general native-value redactor. A small base redaction helper is +therefore legitimate mechanics. It must be default-deny and bounded: + +- arbitrary objects are never stringified or recursively inspected; `repr` and + `str` can execute backend code and expose state; +- native exceptions contribute at most an allowlisted exception type name, + never `str(exc)`, `repr(exc)`, `args`, causes, contexts, locals, or traceback; +- rejected actions, observations, configuration values, paths, environment + values, tokens, raw logs, stdout/stderr, argv, and native identifiers are not + echoed; +- caller-supplied safe stage/code/address text is grammar-checked, control + characters are rejected or normalized, and output has an explicit length + limit no greater than `DiagnosticModel.message`'s 512-character ceiling; and +- token/path/pattern filtering is defense in depth, not permission to render an + otherwise private value. An unsafe or unclassifiable input becomes a fixed + sentinel. + +The configurable seam is a smaller output-length bound and safe contextual +labels, not a caller-provided permissive redaction policy. Tests must use +hostile exception/string/repr objects and prove that native exception text, +object representations, rejected payloads, tracebacks, absolute and home +paths, environment values, bearer/API-like tokens, and overlong/control- +character input cannot reach the result. + +## Cross-cutting layers + +The shared base is an in-process library. Issue #14 adds no HTTP endpoint, +authentication mechanism, environment binding, CLI, subprocess, persistence, +or network service. Its cross-cutting path is: + +1. closed RAES contract construction (`ContractModel`, generally + `extra="forbid"`) and cross-object validators; +2. backend manifest capability/contract validation; +3. `RuntimeTarget` presence and callable-signature validation; +4. `RuntimeManager`/`RuntimeControlPlane` backend-result, snapshot-transition, + time-readback, and result-contract validation where applicable; +5. `DiagnosticModel`/published report projection at a portable boundary; and +6. the repository's strict type, test, distribution, policy, and docs graph. + +No base function reads configuration from environment variables, discovers +plugins from the filesystem, accepts arbitrary import paths, scans a user home, +or forwards process environment. If a later driver starts a subprocess, it +must use argument vectors, explicit confined working/cache directories, a +small environment allowlist, cleared `PYTHONPATH`, safe-path behavior, +timeouts, and bounded output as established by +`tools/verify_cyborg_qualification.py`; credentials and native payloads never +belong in argv or the forwarded environment. + +If a later change exposes these helpers over HTTP, it must use +`raes_runtime.control_plane_api.create_control_plane_app` with +`ControlPlaneSecurityConfig.strict_defaults`, verified identities, +role/target authorization, request-size guarding, denial audit, and the +redacted exception handler. It must not add a weaker adapter endpoint. + +Standard module logging, if needed, is limited to bounded operation names, +published addresses, counts, and dispositions. Portable observability uses +RAES diagnostics/reports. Never log native values, rejected inputs, secret +references or resolutions, environment/argument mappings, paths, raw +subprocess output, or tracebacks. + +## Repository and verification boundaries + +- Keep all base imports free of simulator extras and native simulator modules. + `pyproject.toml` and the single `uv.lock` remain dependency authorities. +- Reuse `_verification_envs()` in `noxfile.py`: the base install and each extra + are verified separately, never through `--all-extras`. +- Keep tests in the existing `tests` session and exercise the same plumbing + with a non-cyber toy driver. The toy must use neutral names and behavior; it + must not be a renamed CybORG fixture. +- Test deterministic ordering, repeated calls, partial failure, cleanup after + failure, invalid capability/target shapes, limitation preservation, and the + complete redaction threat set. Also prove imports and public annotations from + a base-only clean install. +- Reuse the existing `distributions` clean-install proof and `verify` graph. + Do not add a second workflow, build probe, policy validator, or simulator + registry for this work. + +## Non-goals and anti-patterns + +- No CybORG/CAGE-2 or CyberBattleSim adapter, mapping, terminology, scenario, + profile override, native dependency, or conformance claim is implemented. +- No authored SDL, manifest authority, schema, validation profile, fixture + corpus, backend protocol, portable diagnostic/result envelope, store, audit + service, or policy gate is defined by base. +- No generic simulator object model, `Environment`/`Step` DTO, Gym/PettingZoo + tuple normalization, reward-vector abstraction, action-id registry, or + simulator plugin discovery is introduced. +- Do not re-export broad RAES namespaces, copy private RAES helpers, catch + `Exception` and return success, inspect native values for convenience, or + make redaction a regex-only scrubber. +- Do not conflate seed application with deterministic replay, logical time with + wall time or simulator turns, cleanup completion with verified clean state, + fixture success with live target conformance, or finite conformance with + equivalence. +- No database, cache, durable lifecycle manager, background worker, HTTP + controller, CLI, environment-variable configuration, subprocess launcher, + or release/workflow change belongs to issue #14. diff --git a/mkdocs.yml b/mkdocs.yml index f8f8334..c280154 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -36,6 +36,7 @@ nav: - Decisions: - Overview: decisions/adrs/README.md - Template: decisions/adrs/TEMPLATE.md + - Shared simulator-adapter plumbing guardrails: decisions/base-plumbing-guardrails.md - CybORG/CAGE-2 runtime qualification guardrails: decisions/cyborg-cage2-runtime-qualification-guardrails.md - CybORG/CAGE-2 source-ledger guardrails: decisions/cyborg-cage2-source-ledger-guardrails.md - CyberBattleSim qualification guardrails: decisions/cyberbattlesim-qualification-guardrails.md diff --git a/src/raes_adapters/base/__init__.py b/src/raes_adapters/base/__init__.py index 05eb358..cc2c76c 100644 --- a/src/raes_adapters/base/__init__.py +++ b/src/raes_adapters/base/__init__.py @@ -1,13 +1,32 @@ -"""Shared plumbing for RAES simulator adapters (always installed). - -Convenience library for simulator drivers: target factories, clock/seed -controls, action/observation/reward projector bases, redaction helpers, and -conformance-probe wrappers land under REP-004. Per RAES ADR-069 ยง4 it consumes -RAES published contracts and MUST NOT define a new semantic model, schema -registry, backend protocol, diagnostic envelope, exception hierarchy, -conformance-profile table, fixture corpus, concept catalog, or policy gate. +"""Typed, simulator-neutral plumbing over published RAES contracts. + +The module is always installed and has no simulator dependency. It provides +small mechanics for target construction, explicit clock transitions, ordered +seed application, cleanup execution, direction-specific projection, bounded +redaction, and conformance-runner composition. RAES continues to own every +portable model, protocol, validation rule, diagnostic/result shape, profile, +fixture, and conformance claim. """ from __future__ import annotations -__all__: list[str] = [] +from .clock import apply_logical_clock_transition +from .conformance import run_conformance_probe +from .lifecycle import execute_cleanup +from .projection import project_action, project_evaluation, project_observation +from .redaction import bounded_context_label, redact_native_value +from .seeding import apply_seed_controls +from .targets import build_runtime_target + +__all__ = [ + "apply_logical_clock_transition", + "apply_seed_controls", + "bounded_context_label", + "build_runtime_target", + "execute_cleanup", + "project_action", + "project_evaluation", + "project_observation", + "redact_native_value", + "run_conformance_probe", +] diff --git a/src/raes_adapters/base/clock.py b/src/raes_adapters/base/clock.py new file mode 100644 index 0000000..98f2d0a --- /dev/null +++ b/src/raes_adapters/base/clock.py @@ -0,0 +1,116 @@ +"""Explicit logical-clock transition dispatch over the RAES reference runtime.""" + +from __future__ import annotations + +from raes_contracts.runtime_state import ( # type: ignore[import-untyped] + ApplyResult, + RuntimeSnapshot, +) +from raes_runtime.time_coordinator import ( # type: ignore[import-untyped] + ClockTransitionKind, + ReferenceTimeRuntime, +) + + +def _reject_unused_arguments( + *, + ticks: int | None, + tick: int | None, + microstep: int, +) -> None: + """Reject coordinates for transitions whose RAES operation has none.""" + + if ticks is not None or tick is not None or microstep != 0: + raise ValueError("This logical-clock transition does not accept coordinate arguments.") + + +def _apply_advance( + runtime: ReferenceTimeRuntime, + snapshot: RuntimeSnapshot, + clock_address: str, + ticks: int | None, + tick: int | None, + microstep: int, +) -> ApplyResult: + """Validate and apply a relative logical-clock advance.""" + + if ticks is None: + raise ValueError("Advance requires explicit ticks.") + if tick is not None: + raise ValueError("Advance accepts ticks, not an absolute tick.") + return runtime.advance(clock_address, ticks, microstep, snapshot) + + +def _apply_jump( + runtime: ReferenceTimeRuntime, + snapshot: RuntimeSnapshot, + clock_address: str, + ticks: int | None, + tick: int | None, + microstep: int, +) -> ApplyResult: + """Validate and apply an absolute logical-clock jump.""" + + if tick is None: + raise ValueError("Jump requires an explicit absolute tick.") + if ticks is not None: + raise ValueError("Jump accepts an absolute tick, not ticks.") + return runtime.jump(clock_address, tick, microstep, snapshot) + + +def _apply_reset( + runtime: ReferenceTimeRuntime, + snapshot: RuntimeSnapshot, + clock_address: str, + transition: ClockTransitionKind, +) -> ApplyResult: + """Apply reset or replay after rejecting irrelevant coordinates.""" + + return runtime.reset( + clock_address, + transition == ClockTransitionKind.REPLAY, + snapshot, + ) + + +def apply_logical_clock_transition( + runtime: ReferenceTimeRuntime, + snapshot: RuntimeSnapshot, + *, + transition: ClockTransitionKind, + clock_address: str, + ticks: int | None = None, + tick: int | None = None, + microstep: int = 0, +) -> ApplyResult: + """Apply one caller-selected transition and return the RAES ``ApplyResult``. + + There is intentionally no native-event inference here. An adapter must map + its own event to an exact RAES transition and supply explicit coordinates; + a simulator call is never assumed to equal one logical tick. + """ + + if transition == ClockTransitionKind.INITIALIZE: + raise ValueError( + "Initialize logical clocks through ReferenceTimeRuntime.initialize " + "with a published declaration." + ) + if transition == ClockTransitionKind.ADVANCE: + result = _apply_advance(runtime, snapshot, clock_address, ticks, tick, microstep) + elif transition == ClockTransitionKind.PAUSE: + _reject_unused_arguments(ticks=ticks, tick=tick, microstep=microstep) + result = runtime.pause(clock_address, snapshot) + elif transition == ClockTransitionKind.RESUME: + _reject_unused_arguments(ticks=ticks, tick=tick, microstep=microstep) + result = runtime.resume(clock_address, snapshot) + elif transition == ClockTransitionKind.JUMP: + result = _apply_jump(runtime, snapshot, clock_address, ticks, tick, microstep) + elif transition in {ClockTransitionKind.RESET, ClockTransitionKind.REPLAY}: + _reject_unused_arguments(ticks=ticks, tick=tick, microstep=microstep) + result = _apply_reset(runtime, snapshot, clock_address, transition) + else: + raise ValueError("Unsupported logical-clock transition.") + return result + + +__all__ = ["apply_logical_clock_transition"] diff --git a/src/raes_adapters/base/conformance.py b/src/raes_adapters/base/conformance.py new file mode 100644 index 0000000..a38fdbd --- /dev/null +++ b/src/raes_adapters/base/conformance.py @@ -0,0 +1,69 @@ +"""Thin target-conformance composition over the published RAES runner.""" + +from __future__ import annotations + +from pathlib import Path +from typing import NotRequired, TypedDict, Unpack + +from raes_conformance.conformance import ( # type: ignore[import-untyped] + BackendConformanceReport, + run_target_conformance, +) +from raes_conformance.conformance.profiles import ( # type: ignore[import-untyped] + BackendProfileSelector, +) +from raes_conformance.realization import ( # type: ignore[import-untyped] + ExecutionBasis, + RealizationConformanceHarness, +) +from raes_contracts.realization_envelope import ( # type: ignore[import-untyped] + BackendRealizationEnvelopeModel, +) +from raes_processor.reference import ScenarioInput # type: ignore[import-untyped] +from raes_runtime.registry import RuntimeTarget # type: ignore[import-untyped] + + +class _ConformanceProbeOptions(TypedDict): + """Type the optional keywords forwarded to the published RAES runner.""" + + profile: NotRequired[BackendProfileSelector | None] + fixture_root_for_tests: NotRequired[Path | None] + profiles_root_for_tests: NotRequired[Path | None] + reference_scenario: NotRequired[ScenarioInput | None] + realization_harness: NotRequired[RealizationConformanceHarness | None] + execution_basis: NotRequired[ExecutionBasis] + realization_envelope: NotRequired[BackendRealizationEnvelopeModel | None] + observer_version: NotRequired[str] + native_conformance: NotRequired[bool] + + +def run_conformance_probe( + target: RuntimeTarget, + **options: Unpack[_ConformanceProbeOptions], +) -> BackendConformanceReport: + """Return the exact report produced by ``run_target_conformance``. + + Fixture/profile-root overrides are named as test injection deliberately; + the published RAES corpus roots remain the production defaults. This helper + does not infer a local profile, add cases, or strengthen the runner's + bounded claim. + """ + + return run_target_conformance( + target, + profile=options.get("profile"), + root=options.get("fixture_root_for_tests"), + profiles_root=options.get("profiles_root_for_tests"), + reference_scenario=options.get("reference_scenario"), + realization_harness=options.get("realization_harness"), + execution_basis=options.get("execution_basis", ExecutionBasis.HERMETIC_LIVE), + realization_envelope=options.get("realization_envelope"), + observer_version=options.get( + "observer_version", + "raes-realization-observer/v1", + ), + native_conformance=options.get("native_conformance", False), + ) + + +__all__ = ["run_conformance_probe"] diff --git a/src/raes_adapters/base/lifecycle.py b/src/raes_adapters/base/lifecycle.py new file mode 100644 index 0000000..0cd0c23 --- /dev/null +++ b/src/raes_adapters/base/lifecycle.py @@ -0,0 +1,223 @@ +"""Driver-local cleanup sequencing over published RAES cleanup contracts.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from typing import NotRequired, TypedDict, Unpack + +from raes_backend_protocols.backend_manifest import ( # type: ignore[import-untyped] + BackendManifest, +) +from raes_backend_protocols.capability_admission import ( # type: ignore[import-untyped] + require_cleanup_plan_capability, +) +from raes_contracts.contracts.trial_cleanup import ( # type: ignore[import-untyped] + CleanStateClaimModel, + CleanupObligationModel, + CleanupObligationResultModel, + CleanupOutcome, + CleanupTrigger, + TrialCleanupPlanModel, + TrialCleanupReceiptModel, + TrialOutcome, + validate_trial_cleanup_receipt, +) + +CleanupOperation = Callable[[CleanupObligationModel], CleanupObligationResultModel] +CleanupFailureResultFactory = Callable[[CleanupObligationModel], CleanupObligationResultModel] + + +class _CleanupExecutionOptions(TypedDict): + """Type the execution metadata used to construct a RAES cleanup receipt.""" + + failure_result: CleanupFailureResultFactory + receipt_id: str + execution_attempt_id: str + trial_outcome: TrialOutcome + clean_state_claim: NotRequired[CleanStateClaimModel | None] + + +_TRIGGER_BY_OUTCOME: dict[TrialOutcome, CleanupTrigger] = { + "succeeded": "success", + "failed": "failure", + "cancelled": "cancellation", + "timed-out": "timeout", + "aborted": "abort", +} + + +def _dependency_order(plan: TrialCleanupPlanModel) -> tuple[CleanupObligationModel, ...]: + """Return obligations in stable dependency-first order.""" + + ordered: list[CleanupObligationModel] = [] + visited: set[str] = set() + + def visit(obligation_id: str) -> None: + """Visit one obligation after recursively visiting its dependencies.""" + + if obligation_id in visited: + return + obligation = plan.cleanup_obligations[obligation_id] + for dependency in obligation.depends_on: + visit(dependency) + visited.add(obligation_id) + ordered.append(obligation) + + for obligation_id in plan.cleanup_obligations: + visit(obligation_id) + return tuple(ordered) + + +def _checked_result( + result: object, + obligation: CleanupObligationModel, +) -> CleanupObligationResultModel: + """Validate a callback result against its published obligation.""" + + if not isinstance(result, CleanupObligationResultModel): + raise ValueError("Cleanup callback did not return a published RAES obligation result.") + if result.obligation_id != obligation.obligation_id: + raise ValueError("Cleanup callback result does not match its published obligation.") + return result + + +def _failure_result_without_native_context( + factory: CleanupFailureResultFactory, + obligation: CleanupObligationModel, +) -> CleanupObligationResultModel: + """Create a portable failure result without retaining native exceptions.""" + + try: + return factory(obligation) + except Exception: + pass + raise ValueError("Cleanup failure reporting failed.") + + +def _validated_failure_result( + factory: CleanupFailureResultFactory, + obligation: CleanupObligationModel, +) -> CleanupObligationResultModel: + """Create and validate a portable failure result.""" + + result = _failure_result_without_native_context(factory, obligation) + try: + return _checked_result(result, obligation) + except Exception: + pass + raise ValueError("Cleanup failure reporting returned an invalid RAES result.") + + +def _checked_result_or_failure( + result: object, + obligation: CleanupObligationModel, + failure_result: CleanupFailureResultFactory, +) -> CleanupObligationResultModel: + """Return a valid callback result or route invalid output as failure.""" + + try: + return _checked_result(result, obligation) + except Exception: + pass + return _validated_failure_result(failure_result, obligation) + + +def _cleanup_status( + results: Mapping[str, CleanupObligationResultModel], +) -> CleanupOutcome: + """Summarize obligation statuses using the published cleanup vocabulary.""" + + if not results: + outcome: CleanupOutcome = "not-required" + else: + statuses = [result.status for result in results.values()] + if all(status == "succeeded" for status in statuses): + outcome = "succeeded" + elif all(status == "unsupported" for status in statuses): + outcome = "unsupported" + elif all(status == "unverified" for status in statuses): + outcome = "unverified" + elif any(status == "succeeded" for status in statuses): + outcome = "partial" + elif any(status in {"failed", "skipped"} for status in statuses): + outcome = "failed" + else: + outcome = "partial" + return outcome + + +def execute_cleanup( + plan: TrialCleanupPlanModel, + manifest: BackendManifest, + operations: Mapping[str, CleanupOperation], + **options: Unpack[_CleanupExecutionOptions], +) -> TrialCleanupReceiptModel: + """Execute triggered obligations and return a validated RAES receipt. + + RAES admits the plan before any native callback runs. Callbacks execute + synchronously in dependency order and remain responsible for their declared + timeout and native verification mechanics; this helper does not start + threads or claim preemptive cancellation. If a callback raises, its native + exception is never inspected or rendered. ``failure_result`` receives only + the portable obligation and supplies the published, evidence-bounded failure + result. + """ + + failure_result = options["failure_result"] + receipt_id = options["receipt_id"] + execution_attempt_id = options["execution_attempt_id"] + trial_outcome = options["trial_outcome"] + clean_state_claim = options.get("clean_state_claim") + require_cleanup_plan_capability(manifest, plan) + trigger = _TRIGGER_BY_OUTCOME[trial_outcome] + results: dict[str, CleanupObligationResultModel] = {} + + for obligation in _dependency_order(plan): + if trigger not in obligation.triggers: + continue + blocking_dependency = any( + dependency in results and results[dependency].status != "succeeded" + for dependency in obligation.depends_on + ) + if blocking_dependency: + results[obligation.obligation_id] = CleanupObligationResultModel( + obligation_id=obligation.obligation_id, + status="skipped", + ) + continue + + operation = operations.get(obligation.obligation_id) + if operation is None: + results[obligation.obligation_id] = CleanupObligationResultModel( + obligation_id=obligation.obligation_id, + status="unsupported", + ) + continue + operation_failed = False + result: object = None + try: + result = operation(obligation) + except Exception: + operation_failed = True + if operation_failed: + checked_result = _validated_failure_result(failure_result, obligation) + else: + checked_result = _checked_result_or_failure(result, obligation, failure_result) + results[obligation.obligation_id] = checked_result + + receipt = TrialCleanupReceiptModel( + receipt_id=receipt_id, + cleanup_plan_ref=plan.plan_id, + plan_entry_id=plan.plan_entry_id, + run_id=plan.run_id, + execution_attempt_id=execution_attempt_id, + trial_outcome=trial_outcome, + cleanup_status=_cleanup_status(results), + obligation_results=results, + clean_state_claim=clean_state_claim, + ) + validate_trial_cleanup_receipt(plan, receipt) + return receipt + + +__all__ = ["execute_cleanup"] diff --git a/src/raes_adapters/base/projection.py b/src/raes_adapters/base/projection.py new file mode 100644 index 0000000..a31e455 --- /dev/null +++ b/src/raes_adapters/base/projection.py @@ -0,0 +1,59 @@ +"""Typed, direction-specific projection callables without a simulator model.""" + +from __future__ import annotations + +from collections.abc import Callable + + +def project_action[PortableActionT, NativeActionT]( + action: PortableActionT, + projector: Callable[[PortableActionT], NativeActionT], +) -> NativeActionT: + """Project one admitted portable action into a private native invocation.""" + + try: + return projector(action) + except Exception: + pass + raise ValueError("Action projection failed.") + + +def project_observation[ + NativeObservationT, + ObservationCandidateT, + PortableObservationT, +]( + native_observation: NativeObservationT, + projector: Callable[[NativeObservationT], ObservationCandidateT], + validator: Callable[[ObservationCandidateT], PortableObservationT], +) -> PortableObservationT: + """Project a native result and validate the terminal published RAES value.""" + + try: + candidate = projector(native_observation) + return validator(candidate) + except Exception: + pass + raise ValueError("Observation projection failed.") + + +def project_evaluation[ + NativeEvaluationT, + EvaluationCandidateT, + PortableEvaluationT, +]( + native_evaluation: NativeEvaluationT, + projector: Callable[[NativeEvaluationT], EvaluationCandidateT], + validator: Callable[[EvaluationCandidateT], PortableEvaluationT], +) -> PortableEvaluationT: + """Project native measures and validate the terminal published RAES value.""" + + try: + candidate = projector(native_evaluation) + return validator(candidate) + except Exception: + pass + raise ValueError("Evaluation projection failed.") + + +__all__ = ["project_action", "project_evaluation", "project_observation"] diff --git a/src/raes_adapters/base/redaction.py b/src/raes_adapters/base/redaction.py new file mode 100644 index 0000000..4c2662d --- /dev/null +++ b/src/raes_adapters/base/redaction.py @@ -0,0 +1,68 @@ +"""Default-deny handling for native values and bounded safe context labels.""" + +from __future__ import annotations + +import re + +REDACTED = "[redacted]" +_MAX_LABEL_LENGTH = 128 +_SAFE_LABEL = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +_WINDOWS_PATH = re.compile(r"^[A-Za-z]:[\\/]") +_HIGH_ENTROPY_HEX = re.compile(r"[0-9A-Fa-f]{32,}") +_SENSITIVE_MARKERS = ( + "api-key", + "api_key", + "authorization", + "bearer", + "credential", + "password", + "private-key", + "secret", + "token", + "traceback", +) +_SAFE_EXCEPTION_TYPES = frozenset({TimeoutError}) + + +def redact_native_value(value: object) -> str: + """Return a fixed sentinel without inspecting or rendering ``value``. + + Only exact, hard-coded safe exception classes may contribute their type + name. Subclasses are denied so backend code cannot smuggle a crafted class + name through the allowlist. + """ + + value_type = type(value) + if value_type in _SAFE_EXCEPTION_TYPES: + return value_type.__name__ + return REDACTED + + +def bounded_context_label(text: str, *, max_length: int = _MAX_LABEL_LENGTH) -> str: + """Admit one intentionally supplied, non-sensitive diagnostic label. + + This is not a general string scrubber. Exact strings must satisfy a small + ASCII grammar and defense-in-depth path, secret-marker, traceback, control, + and high-entropy checks. Everything else becomes the fixed sentinel. + """ + + if not 1 <= max_length <= _MAX_LABEL_LENGTH: + raise ValueError("max_length must be between 1 and 128.") + if type(text) is not str: + label = REDACTED + else: + lowered = text.lower() + safe = ( + bool(text) + and len(text) <= max_length + and not text.startswith(("/", "~")) + and _WINDOWS_PATH.match(text) is None + and not any(marker in lowered for marker in _SENSITIVE_MARKERS) + and _HIGH_ENTROPY_HEX.search(text) is None + and _SAFE_LABEL.fullmatch(text) is not None + ) + label = text if safe else REDACTED + return label + + +__all__ = ["bounded_context_label", "redact_native_value"] diff --git a/src/raes_adapters/base/seeding.py b/src/raes_adapters/base/seeding.py new file mode 100644 index 0000000..dbded80 --- /dev/null +++ b/src/raes_adapters/base/seeding.py @@ -0,0 +1,104 @@ +"""Ordered driver-local seed application with RAES diagnostic reporting.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence + +from raes_contracts.contracts import ( # type: ignore[import-untyped] + ExperimentStochasticControlModel, + RandomStreamControlBindingModel, +) +from raes_contracts.diagnostics import ( # type: ignore[import-untyped] + Diagnostic, + Severity, + diagnostic_model, +) + +SeedApplier = Callable[[RandomStreamControlBindingModel], None] + + +def _seed_diagnostic( + *, + index: int, + disposition: str, + message: str, + severity: Severity, +) -> Diagnostic: + """Build and validate one published RAES seed diagnostic.""" + + diagnostic = Diagnostic( + code=f"adapter.seed.{disposition}", + domain="adapter", + address=f"/stochastic_controls/{index}", + message=message, + severity=severity, + ) + diagnostic_model(diagnostic) + return diagnostic + + +def apply_seed_controls( + controls: Sequence[ExperimentStochasticControlModel], + appliers: Mapping[str, SeedApplier], +) -> tuple[Diagnostic, ...]: + """Apply controls in declared order and report every disposition. + + Each applier receives the complete published executable binding, preserving + its profile, namespace, and public/governed entropy distinction. Governed + entropy resolution remains inside the authorized driver callback. A + successful callback means only that the binding was applied; it does not + claim deterministic replay or outcome equivalence. + """ + + control_ids = [control.control_id for control in controls] + if len(control_ids) != len(set(control_ids)): + raise ValueError("Stochastic control ids must be unique.") + + diagnostics: list[Diagnostic] = [] + for index, control in enumerate(controls): + binding = control.executable_binding + if binding is None: + diagnostics.append( + _seed_diagnostic( + index=index, + disposition="unbound", + message="The stochastic control has no executable binding.", + severity=Severity.WARNING, + ) + ) + continue + applier = appliers.get(control.control_id) + if applier is None: + diagnostics.append( + _seed_diagnostic( + index=index, + disposition="unsupported", + message="The adapter has no applier for this executable binding.", + severity=Severity.WARNING, + ) + ) + continue + try: + applier(binding) + except Exception: + diagnostics.append( + _seed_diagnostic( + index=index, + disposition="failed", + message="The adapter could not apply this executable binding.", + severity=Severity.ERROR, + ) + ) + continue + diagnostics.append( + _seed_diagnostic( + index=index, + disposition="applied", + message="The adapter applied this executable binding.", + severity=Severity.INFO, + ) + ) + return tuple(diagnostics) + + +__all__ = ["apply_seed_controls"] diff --git a/src/raes_adapters/base/targets.py b/src/raes_adapters/base/targets.py new file mode 100644 index 0000000..9a08673 --- /dev/null +++ b/src/raes_adapters/base/targets.py @@ -0,0 +1,37 @@ +"""Runtime-target construction over the published RAES registry contract.""" + +from __future__ import annotations + +from raes_backend_protocols.backend_manifest import ( # type: ignore[import-untyped] + BackendManifest, +) +from raes_runtime.registry import ( # type: ignore[import-untyped] + RuntimeTarget, + RuntimeTargetComponents, +) + + +def build_runtime_target( + name: str, + manifest: BackendManifest, + components: RuntimeTargetComponents, +) -> RuntimeTarget: + """Construct a RAES runtime target and delegate every shape check to RAES. + + The helper deliberately does not catch or reinterpret validation failures: + :class:`raes_runtime.registry.RuntimeTarget` remains the authority for + manifest/component presence and callable signatures. + """ + + return RuntimeTarget( + name=name, + manifest=manifest, + provisioner=components.provisioner, + orchestrator=components.orchestrator, + evaluator=components.evaluator, + participant_runtime=components.participant_runtime, + time_runtime=components.time_runtime, + ) + + +__all__ = ["build_runtime_target"] diff --git a/tests/test_base_plumbing.py b/tests/test_base_plumbing.py new file mode 100644 index 0000000..dc21a0c --- /dev/null +++ b/tests/test_base_plumbing.py @@ -0,0 +1,734 @@ +"""Behavioral tests for simulator-neutral shared adapter plumbing.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import cast + +import pytest +from raes_backend_protocols.backend_manifest import BackendManifest +from raes_backend_protocols.capabilities import ( + CLEANUP_CAPABILITY_REQUIRED_CONTRACTS, + CleanupCapabilities, +) +from raes_conformance.conformance.report import BackendConformanceReport +from raes_conformance.realization import ExecutionBasis +from raes_contracts.contracts import ( + CleanStateRequirementModel, + CleanupObligationModel, + CleanupObligationResultModel, + CleanupResourceBoundaryModel, + ExecutionRetryPolicyModel, + ExperimentStochasticControlModel, + PublicSeedModel, + RandomStreamControlBindingModel, + RandomStreamProfileReferenceModel, + TrialCleanupPlanModel, +) +from raes_contracts.contracts.time_model import ( + ClockDeclarationModel, + ExactRatioModel, + TimeDomainDeclarationModel, + TimeModelDeclarationModel, + TimeProgressionPolicyDeclarationModel, +) +from raes_contracts.diagnostics import DiagnosticModel, Severity +from raes_contracts.runtime_state import ApplyResult, RuntimeSnapshot +from raes_runtime.registry import RuntimeTarget, RuntimeTargetComponents +from raes_runtime.time_coordinator import ClockTransitionKind, ReferenceTimeRuntime + +from raes_adapters.base import ( + apply_logical_clock_transition, + apply_seed_controls, + bounded_context_label, + build_runtime_target, + execute_cleanup, + project_action, + project_evaluation, + project_observation, + redact_native_value, + run_conformance_probe, +) + + +@dataclass +class ToyQueueProvisioner: + """Neutral, non-cyber provisioner used only as driver evidence.""" + + applied: list[object] = field(default_factory=list) + + def validate(self, plan: object) -> list[object]: + return [] + + def apply(self, plan: object, snapshot: RuntimeSnapshot) -> ApplyResult: + self.applied.append(plan) + return ApplyResult(success=True, snapshot=snapshot) + + +@dataclass(frozen=True) +class ToyManifest: + """Minimum structural manifest surface consumed by ``RuntimeTarget``.""" + + cleanup: CleanupCapabilities | None = None + time: object | None = None + participant_runtime: object | None = None + has_orchestrator: bool = False + has_evaluator: bool = False + has_participant_runtime: bool = False + has_time: bool = False + + +def _manifest(*, cleanup: CleanupCapabilities | None = None) -> BackendManifest: + return cast(BackendManifest, ToyManifest(cleanup=cleanup)) + + +def _time_declaration() -> TimeModelDeclarationModel: + domain_address = "time.domain.queue" + clock_address = "time.clock.queue" + policy_address = "time.progression.queue" + return TimeModelDeclarationModel( + domains={ + domain_address: TimeDomainDeclarationModel( + address=domain_address, + kind="logical", + tick_period_seconds=ExactRatioModel(numerator=1, denominator=1), + epoch="run_start", + visibility="runtime_only", + description="Queue event ordering.", + ) + }, + clocks={ + clock_address: ClockDeclarationModel( + address=clock_address, + time_domain_address=domain_address, + authority_kind="runtime", + authority_ref="toy-queue-driver", + monotonicity="may_jump", + supports_pause=True, + supports_reset=True, + supports_jump=True, + description="Explicit queue event clock.", + ) + }, + progression_policies={ + policy_address: TimeProgressionPolicyDeclarationModel( + address=policy_address, + clock_address=clock_address, + advancement_mode="event_driven", + synchronization_mode="none", + reset_behavior="new_segment_zero", + replay_behavior="restart_from_anchor", + description="Only caller-mapped queue events advance the clock.", + ) + }, + ) + + +def _seed_control(control_id: str, *, bound: bool = True) -> ExperimentStochasticControlModel: + executable_binding = None + if bound: + executable_binding = RandomStreamControlBindingModel( + profile_ref=RandomStreamProfileReferenceModel( + ref_kind="profile", + ref_id="blake3-xof-v1", + ), + namespace=f"queue-{control_id}", + root_entropy=PublicSeedModel( + kind="public-seed", + encoding="hex-fixed-width", + value="01" * 32, + ), + ) + return ExperimentStochasticControlModel( + control_id=control_id, + role="seed", + description="Toy queue random source.", + executable_binding=executable_binding, + ) + + +def _cleanup_plan() -> TrialCleanupPlanModel: + boundary = CleanupResourceBoundaryModel( + boundary_id="queue-range", + resource_kind="queue", + owner_ref="toy-driver", + resource_refs=["queue:primary"], + ) + prepare = CleanupObligationModel( + obligation_id="prepare", + boundary_refs=["queue-range"], + action_kind="reset", + triggers=["success", "failure"], + requirement="required", + idempotency="idempotent", + verification_probe_refs=["probe:queue-empty"], + timeout_seconds=5, + ) + finish = CleanupObligationModel( + obligation_id="finish", + boundary_refs=["queue-range"], + action_kind="verify", + triggers=["success", "failure"], + requirement="best-effort", + depends_on=["prepare"], + idempotency="idempotent", + verification_probe_refs=["probe:queue-depth"], + timeout_seconds=5, + ) + record = CleanupObligationModel( + obligation_id="record", + boundary_refs=["queue-range"], + action_kind="custom", + action_profile_ref="toy-cleanup-record/v1", + triggers=["success", "failure"], + requirement="best-effort", + idempotency="idempotent", + timeout_seconds=5, + ) + return TrialCleanupPlanModel( + plan_id="queue-cleanup", + plan_entry_id="queue-entry", + run_id="queue-run", + clean_state=CleanStateRequirementModel( + mode="verified-reset", + boundary_refs=["queue-range"], + verification_probe_refs=["probe:queue-empty"], + ), + resource_boundaries={"queue-range": boundary}, + cleanup_obligations={ + "prepare": prepare, + "finish": finish, + "record": record, + }, + retry_policy=ExecutionRetryPolicyModel( + max_attempts=1, + after_effect_policy="disallow", + ), + ) + + +def _cleanup_capabilities() -> CleanupCapabilities: + return CleanupCapabilities( + name="toy-cleanup", + supported_contract_versions=CLEANUP_CAPABILITY_REQUIRED_CONTRACTS, + supported_action_kinds=frozenset({"reset", "verify", "custom"}), + supported_verification_methods=frozenset({"probe"}), + supports_residual_state_disclosure=True, + ) + + +def test_runtime_target_factory_delegates_shape_validation_to_raes() -> None: + provisioner = ToyQueueProvisioner() + components = RuntimeTargetComponents(provisioner=provisioner) + + target = build_runtime_target("toy-queue", _manifest(), components) + + assert isinstance(target, RuntimeTarget) + assert target.name == "toy-queue" + assert target.provisioner is provisioner + + invalid_manifest = cast(BackendManifest, ToyManifest(has_orchestrator=True)) + with pytest.raises(ValueError, match="orchestrator presence"): + build_runtime_target("toy-queue", invalid_manifest, components) + + +def test_logical_clock_transitions_are_explicit_and_deterministic() -> None: + declaration = _time_declaration() + final_states = [] + for _ in range(2): + runtime = ReferenceTimeRuntime() + initialized = runtime.initialize(declaration, RuntimeSnapshot()) + advanced = apply_logical_clock_transition( + runtime, + initialized.snapshot, + transition=ClockTransitionKind.ADVANCE, + clock_address="time.clock.queue", + ticks=3, + microstep=2, + ) + paused = apply_logical_clock_transition( + runtime, + advanced.snapshot, + transition=ClockTransitionKind.PAUSE, + clock_address="time.clock.queue", + ) + final_states.append(runtime.state(paused.snapshot)) + + assert final_states[0] == final_states[1] + reading = final_states[0].clocks["time.clock.queue"] + assert (reading.coordinate.tick, reading.coordinate.microstep) == (3, 2) + assert [event.kind for event in reading.history] == ["initialize", "advance", "pause"] + + +def test_logical_clock_helper_refuses_implicit_transition_arguments() -> None: + runtime = ReferenceTimeRuntime() + initialized = runtime.initialize(_time_declaration(), RuntimeSnapshot()) + + with pytest.raises(ValueError, match="requires explicit ticks"): + apply_logical_clock_transition( + runtime, + initialized.snapshot, + transition=ClockTransitionKind.ADVANCE, + clock_address="time.clock.queue", + ) + + with pytest.raises(ValueError, match="Initialize logical clocks through ReferenceTimeRuntime"): + apply_logical_clock_transition( + runtime, + initialized.snapshot, + transition=ClockTransitionKind.INITIALIZE, + clock_address="time.clock.queue", + ) + + +def test_logical_clock_helper_delegates_resume_jump_reset_and_replay() -> None: + runtime = ReferenceTimeRuntime() + result = runtime.initialize(_time_declaration(), RuntimeSnapshot()) + for transition, arguments in ( + (ClockTransitionKind.PAUSE, {}), + (ClockTransitionKind.RESUME, {}), + (ClockTransitionKind.JUMP, {"tick": 7, "microstep": 1}), + (ClockTransitionKind.RESET, {}), + (ClockTransitionKind.REPLAY, {}), + ): + result = apply_logical_clock_transition( + runtime, + result.snapshot, + transition=transition, + clock_address="time.clock.queue", + **arguments, + ) + + reading = runtime.state(result.snapshot).clocks["time.clock.queue"] + assert (reading.coordinate.segment, reading.coordinate.tick) == (3, 0) + assert [event.kind for event in reading.history] == [ + "initialize", + "pause", + "resume", + "jump", + "reset", + "replay", + ] + + +def test_seed_controls_preserve_order_and_report_every_disposition() -> None: + controls = [ + _seed_control("simulator"), + _seed_control("policy"), + _seed_control("unbound", bound=False), + _seed_control("unsupported"), + ] + applied: list[str] = [] + + def apply_simulator(binding: RandomStreamControlBindingModel) -> None: + assert binding.namespace == "queue-simulator" + applied.append("simulator") + + def fail_policy(binding: RandomStreamControlBindingModel) -> None: + applied.append("policy") + raise RuntimeError("token=must-not-escape") + + diagnostics = apply_seed_controls( + controls, + { + "simulator": apply_simulator, + "policy": fail_policy, + }, + ) + + assert applied == ["simulator", "policy"] + assert [diagnostic.code for diagnostic in diagnostics] == [ + "adapter.seed.applied", + "adapter.seed.failed", + "adapter.seed.unbound", + "adapter.seed.unsupported", + ] + assert [diagnostic.severity for diagnostic in diagnostics] == [ + Severity.INFO, + Severity.ERROR, + Severity.WARNING, + Severity.WARNING, + ] + assert all("must-not-escape" not in diagnostic.message for diagnostic in diagnostics) + + +def test_seed_controls_refuse_duplicate_portable_control_ids() -> None: + control = _seed_control("simulator") + with pytest.raises(ValueError, match="control ids must be unique"): + apply_seed_controls([control, control], {}) + + +def test_cleanup_uses_capability_admission_dependency_order_and_raes_receipt() -> None: + plan = _cleanup_plan() + calls: list[str] = [] + + def succeed(obligation: CleanupObligationModel) -> CleanupObligationResultModel: + calls.append(obligation.obligation_id) + return CleanupObligationResultModel( + obligation_id=obligation.obligation_id, + status="succeeded", + evidence_refs=[f"evidence:{obligation.obligation_id}"], + ) + + class HostileCleanupError(RuntimeError): + def __str__(self) -> str: + raise AssertionError("native exception text was rendered") + + def __repr__(self) -> str: + raise AssertionError("native exception repr was rendered") + + def fail(obligation: CleanupObligationModel) -> CleanupObligationResultModel: + calls.append(obligation.obligation_id) + raise HostileCleanupError() + + def failure_result(obligation: CleanupObligationModel) -> CleanupObligationResultModel: + return CleanupObligationResultModel( + obligation_id=obligation.obligation_id, + status="failed", + evidence_refs=["evidence:cleanup-failure"], + ) + + receipt = execute_cleanup( + plan, + _manifest(cleanup=_cleanup_capabilities()), + { + "prepare": succeed, + "finish": fail, + "record": succeed, + }, + failure_result=failure_result, + receipt_id="queue-cleanup-receipt", + execution_attempt_id="queue-attempt-1", + trial_outcome="succeeded", + ) + + assert calls == ["prepare", "finish", "record"] + assert receipt.cleanup_status == "partial" + assert receipt.obligation_results["prepare"].status == "succeeded" + assert receipt.obligation_results["finish"].status == "failed" + assert receipt.obligation_results["record"].status == "succeeded" + + +def test_cleanup_reports_unsupported_bindings_and_capability_gaps() -> None: + plan = _cleanup_plan() + manifest = _manifest() + + def unsupported_result( + obligation: CleanupObligationModel, + ) -> CleanupObligationResultModel: + return CleanupObligationResultModel( + obligation_id=obligation.obligation_id, + status="unsupported", + ) + + with pytest.raises(ValueError, match="backend does not declare cleanup capabilities"): + execute_cleanup( + plan, + manifest, + {}, + failure_result=unsupported_result, + receipt_id="queue-cleanup-receipt", + execution_attempt_id="queue-attempt-1", + trial_outcome="succeeded", + ) + + receipt = execute_cleanup( + plan, + _manifest(cleanup=_cleanup_capabilities()), + {}, + failure_result=lambda obligation: CleanupObligationResultModel( + obligation_id=obligation.obligation_id, + status="unsupported", + ), + receipt_id="queue-cleanup-receipt", + execution_attempt_id="queue-attempt-1", + trial_outcome="cancelled", + ) + assert receipt.cleanup_status == "not-required" + assert receipt.obligation_results == {} + + +def test_cleanup_failure_reporting_does_not_retain_native_exception_context() -> None: + class HostileCleanupError(RuntimeError): + def __str__(self) -> str: + raise AssertionError("native cleanup exception was rendered") + + def __repr__(self) -> str: + raise AssertionError("native cleanup exception was represented") + + def fail_operation(obligation: CleanupObligationModel) -> CleanupObligationResultModel: + raise HostileCleanupError() + + def fail_reporting(obligation: CleanupObligationModel) -> CleanupObligationResultModel: + raise HostileCleanupError() + + plan = _cleanup_plan() + manifest = _manifest(cleanup=_cleanup_capabilities()) + operations = {"prepare": fail_operation} + with pytest.raises(ValueError, match=r"^Cleanup failure reporting failed\.$") as captured: + execute_cleanup( + plan, + manifest, + operations, + failure_result=fail_reporting, + receipt_id="queue-cleanup-receipt", + execution_attempt_id="queue-attempt-1", + trial_outcome="succeeded", + ) + assert captured.value.__cause__ is None + assert captured.value.__context__ is None + + +def test_cleanup_malformed_result_does_not_abandon_independent_obligations() -> None: + calls: list[str] = [] + + def succeed(obligation: CleanupObligationModel) -> CleanupObligationResultModel: + calls.append(obligation.obligation_id) + return CleanupObligationResultModel( + obligation_id=obligation.obligation_id, + status="succeeded", + evidence_refs=[f"evidence:{obligation.obligation_id}"], + ) + + def malformed(obligation: CleanupObligationModel) -> CleanupObligationResultModel: + calls.append(obligation.obligation_id) + return cast(CleanupObligationResultModel, object()) + + receipt = execute_cleanup( + _cleanup_plan(), + _manifest(cleanup=_cleanup_capabilities()), + { + "prepare": succeed, + "finish": malformed, + "record": succeed, + }, + failure_result=lambda obligation: CleanupObligationResultModel( + obligation_id=obligation.obligation_id, + status="failed", + evidence_refs=["evidence:malformed-cleanup-result"], + ), + receipt_id="queue-cleanup-receipt", + execution_attempt_id="queue-attempt-1", + trial_outcome="succeeded", + ) + + assert calls == ["prepare", "finish", "record"] + assert receipt.obligation_results["finish"].status == "failed" + assert receipt.obligation_results["record"].status == "succeeded" + + +def test_cleanup_failed_dependency_skips_dependent_but_runs_independent_obligation() -> None: + original = _cleanup_plan() + best_effort_prepare = original.cleanup_obligations["prepare"].model_copy( + update={"requirement": "best-effort"} + ) + plan = TrialCleanupPlanModel( + plan_id=original.plan_id, + plan_entry_id=original.plan_entry_id, + run_id=original.run_id, + clean_state=original.clean_state, + resource_boundaries=original.resource_boundaries, + cleanup_obligations={ + "prepare": best_effort_prepare, + "finish": original.cleanup_obligations["finish"], + "record": original.cleanup_obligations["record"], + }, + retry_policy=original.retry_policy, + ) + calls: list[str] = [] + + def fail_prepare(obligation: CleanupObligationModel) -> CleanupObligationResultModel: + calls.append(obligation.obligation_id) + return CleanupObligationResultModel( + obligation_id=obligation.obligation_id, + status="failed", + evidence_refs=["evidence:prepare-failure"], + ) + + def succeed(obligation: CleanupObligationModel) -> CleanupObligationResultModel: + calls.append(obligation.obligation_id) + return CleanupObligationResultModel( + obligation_id=obligation.obligation_id, + status="succeeded", + evidence_refs=[f"evidence:{obligation.obligation_id}"], + ) + + receipt = execute_cleanup( + plan, + _manifest(cleanup=_cleanup_capabilities()), + { + "prepare": fail_prepare, + "finish": succeed, + "record": succeed, + }, + failure_result=lambda obligation: CleanupObligationResultModel( + obligation_id=obligation.obligation_id, + status="failed", + evidence_refs=["evidence:cleanup-failure"], + ), + receipt_id="queue-cleanup-receipt", + execution_attempt_id="queue-attempt-1", + trial_outcome="succeeded", + ) + + assert calls == ["prepare", "record"] + assert receipt.obligation_results["prepare"].status == "failed" + assert receipt.obligation_results["finish"].status == "skipped" + assert receipt.obligation_results["record"].status == "succeeded" + + +def test_non_cyber_toy_driver_uses_direction_specific_projection_seams() -> None: + native_queue: list[str] = [] + portable_action = DiagnosticModel( + code="queue.enqueue", + domain="queue", + address="/items/0", + message="Enqueue one item.", + severity=Severity.INFO, + ) + + def action_projector(action: DiagnosticModel) -> str: + return action.code + + native_action = project_action(portable_action, action_projector) + native_queue.append(native_action) + + observation = project_observation( + native_queue, + lambda queue: { + "code": "queue.depth", + "domain": "queue", + "address": "", + "message": f"Queue depth is {len(queue)}.", + "severity": "info", + }, + DiagnosticModel.model_validate, + ) + evaluation = project_evaluation( + len(native_queue), + lambda depth: { + "code": "queue.nonempty", + "domain": "queue", + "address": "", + "message": "Queue contains work." if depth else "Queue is empty.", + "severity": "info", + }, + DiagnosticModel.model_validate, + ) + + assert native_action == "queue.enqueue" + assert isinstance(observation, DiagnosticModel) + assert observation.code == "queue.depth" + assert evaluation.code == "queue.nonempty" + + +def test_projection_failure_does_not_return_or_render_native_values() -> None: + class HostileNative: + def __str__(self) -> str: + raise AssertionError("native value was stringified") + + def __repr__(self) -> str: + raise AssertionError("native value was represented") + + native_value = HostileNative() + with pytest.raises(ValueError, match=r"^Observation projection failed\.$") as captured: + project_observation( + native_value, + lambda value: value, + DiagnosticModel.model_validate, + ) + assert captured.value.__cause__ is None + assert captured.value.__context__ is None + + +@pytest.mark.parametrize( + "native_value", + [ + "rejected-action-id", + {"payload": "secret"}, + Path("/private/run/state"), + RuntimeError("native failure"), + "Bearer abcdefghijklmnopqrstuvwxyz", + "environment-secret-value", + ], +) +def test_native_redaction_is_default_deny(native_value: object) -> None: + assert redact_native_value(native_value) == "[redacted]" + + +def test_native_redaction_never_calls_object_renderers_or_uses_tracebacks() -> None: + class HostileNative: + def __str__(self) -> str: + raise AssertionError("str called") + + def __repr__(self) -> str: + raise AssertionError("repr called") + + assert redact_native_value(HostileNative()) == "[redacted]" + assert redact_native_value(TimeoutError("private path /tmp/secret")) == "TimeoutError" + + +@pytest.mark.parametrize( + "unsafe", + [ + "/home/user/secret", + "~/secret", + "C:\\private\\state", + "bearer-token", + "api_key", + "Traceback-most-recent-call-last", + "line\\nwith-control", + "a" * 129, + "0123456789abcdef" * 4, + ], +) +def test_bounded_context_labels_reject_paths_tokens_tracebacks_and_entropy(unsafe: str) -> None: + assert bounded_context_label(unsafe) == "[redacted]" + + +def test_bounded_context_labels_preserve_only_short_grammar_checked_text() -> None: + assert bounded_context_label("queue-cleanup.stage_1") == "queue-cleanup.stage_1" + assert bounded_context_label("queue-cleanup.stage_1", max_length=8) == "[redacted]" + with pytest.raises(ValueError, match="between 1 and 128"): + bounded_context_label("queue", max_length=129) + + +def test_conformance_helper_delegates_to_published_runner( + monkeypatch: pytest.MonkeyPatch, +) -> None: + target = cast(RuntimeTarget, object()) + report = cast(BackendConformanceReport, object()) + captured: dict[str, object] = {} + + def fake_runner(received: RuntimeTarget, **options: object) -> BackendConformanceReport: + captured["target"] = received + captured.update(options) + return report + + monkeypatch.setattr( + "raes_adapters.base.conformance.run_target_conformance", + fake_runner, + ) + + result = run_conformance_probe( + target, + profile="provisioning-only", + fixture_root_for_tests=Path("/tmp/fixtures"), + profiles_root_for_tests=Path("/tmp/profiles"), + observer_version="toy-observer/v1", + ) + + assert result is report + assert captured == { + "target": target, + "profile": "provisioning-only", + "root": Path("/tmp/fixtures"), + "profiles_root": Path("/tmp/profiles"), + "reference_scenario": None, + "realization_harness": None, + "execution_basis": ExecutionBasis.HERMETIC_LIVE, + "realization_envelope": None, + "observer_version": "toy-observer/v1", + "native_conformance": False, + } diff --git a/tests/test_base_smoke.py b/tests/test_base_smoke.py index 789bfe0..378484e 100644 --- a/tests/test_base_smoke.py +++ b/tests/test_base_smoke.py @@ -1,4 +1,4 @@ -"""Smoke tests for the always-available base plumbing module (REP-002 standup).""" +"""Smoke tests for the always-available base plumbing module.""" from __future__ import annotations @@ -6,4 +6,15 @@ def test_base_module_imports() -> None: - assert raes_adapters.base.__all__ == [] + assert raes_adapters.base.__all__ == [ + "apply_logical_clock_transition", + "apply_seed_controls", + "bounded_context_label", + "build_runtime_target", + "execute_cleanup", + "project_action", + "project_evaluation", + "project_observation", + "redact_native_value", + "run_conformance_probe", + ]