From 24d3e097bae4d4e90032ab985f12702c8ad798f9 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Mon, 6 Jul 2026 15:53:43 -0700 Subject: [PATCH 01/15] feat(sdl): add realization-envelope membership/subsumption/witness relation (#668) (#685) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add realization-envelope membership/subsumption/witness relation (#668) Implement the deterministic relation over the #667 realization-envelope semantics (ADR-070, specs/formal/realization/envelope-semantics.md): - aces_contracts.realization_envelope: closed, versioned envelope expression contract (exact / enum / boolean / numeric-interval / governed-reference / record domains, scoped bindings, posture, closure, witness policy). Intentionally unpublished; schema carriage is a downstream sibling. - aces_sdl.realization_envelope (+ _realization_envelope_engine): member / subsumes / witness / generate_negative_probes over one shared domain and closure engine, with stable secret-free diagnostics. Membership requires validated instantiated SDL; subsumption is set inclusion on the admitted fragment; witnesses are deterministic and SDL-validated; closed envelopes yield out-of-envelope negative probes; the R2 overridability rule is enforced. - Unit + hypothesis property tests: membership decidable/deterministic, subsumption is set inclusion (reflexive/transitive, authority-scoped governed refs), witness in-envelope and deterministic, negative probes out-of-envelope. Backend-manifest carriage and retiring the #663 reference-scenario conformance bridge remain the explicitly-downstream siblings (ADR-070 §5). * Reduce realization-envelope relation complexity for SonarCloud Refactor the relation for the SonarCloud quality gate (new_violations=0) without behavior change: - Split domain-kind dispatch into aces_sdl._realization_envelope_domains (finite enumeration, subset, witness selection, out-of-envelope variation) as type-keyed dispatch tables, flattening the per-kind return/complexity that tripped S1142 / cognitive-complexity. - Extract membership / subsumption / witness / negative-probe loops into focused helpers; dispatch _is_singleton_domain and scalar_in_domain in aces_contracts via lookup tables. - Type value hints as DomainScalar / object (drop Any, S6542), use min() over sorted()[0] (S8517), set .issubset() over negated <= (S1940), move trailing comments to their own lines (S139). All 42 unit + property tests still pass; ~94% line coverage on the new modules. * chore(sdl): drop orphaned changelog.d fragment for #668 The repo migrated from towncrier changelog.d fragments to release-please (#682/#684); plan-rules now state "There is no changelog.d/". The #668 changelog entry is derived from the conventional PR title/commits by release-please, so the hand-written fragment is obsolete. * refactor(sdl): flatten nested ternary in envelope enum sort key Replace the nested conditional in _enum_sort_key with an if/elif/else to clear the last SonarCloud new-code violation (S3358). Behavior unchanged; all envelope relation tests still pass. --- .../issue-668-envelope-relation-preflight.md | 202 ++++++ .../aces_contracts/realization_envelope.py | 402 ++++++++++++ .../aces_sdl/_realization_envelope_domains.py | 197 ++++++ .../aces_sdl/_realization_envelope_engine.py | 361 +++++++++++ .../packages/aces_sdl/realization_envelope.py | 405 ++++++++++++ .../invalid/empty-interval.json | 3 + .../invalid/exact-nonsingleton.json | 3 + .../invalid/open-with-domain.json | 3 + .../invalid/record-cycle.json | 4 + .../invalid/unknown-domain-ref.json | 2 + .../valid/closed-scenario.json | 13 + .../valid/record-node.json | 12 + .../valid/resource-bounds.json | 16 + .../valid/scenario-web-family.json | 15 + .../test_realization_envelope_relation.py | 594 ++++++++++++++++++ .../formal/realization/envelope-semantics.md | 21 +- 16 files changed, 2248 insertions(+), 5 deletions(-) create mode 100644 docs/decisions/issue-668-envelope-relation-preflight.md create mode 100644 implementations/python/packages/aces_contracts/realization_envelope.py create mode 100644 implementations/python/packages/aces_sdl/_realization_envelope_domains.py create mode 100644 implementations/python/packages/aces_sdl/_realization_envelope_engine.py create mode 100644 implementations/python/packages/aces_sdl/realization_envelope.py create mode 100644 implementations/python/tests/data/realization_envelope/invalid/empty-interval.json create mode 100644 implementations/python/tests/data/realization_envelope/invalid/exact-nonsingleton.json create mode 100644 implementations/python/tests/data/realization_envelope/invalid/open-with-domain.json create mode 100644 implementations/python/tests/data/realization_envelope/invalid/record-cycle.json create mode 100644 implementations/python/tests/data/realization_envelope/invalid/unknown-domain-ref.json create mode 100644 implementations/python/tests/data/realization_envelope/valid/closed-scenario.json create mode 100644 implementations/python/tests/data/realization_envelope/valid/record-node.json create mode 100644 implementations/python/tests/data/realization_envelope/valid/resource-bounds.json create mode 100644 implementations/python/tests/data/realization_envelope/valid/scenario-web-family.json create mode 100644 implementations/python/tests/test_realization_envelope_relation.py diff --git a/docs/decisions/issue-668-envelope-relation-preflight.md b/docs/decisions/issue-668-envelope-relation-preflight.md new file mode 100644 index 000000000..bf95298f1 --- /dev/null +++ b/docs/decisions/issue-668-envelope-relation-preflight.md @@ -0,0 +1,202 @@ +# Issue 668 Envelope Relation Preflight + +Date: 2026-07-05 + +Issue: #668. + +Requirement: none. The GitHub issue title, body, and acceptance criteria are +the contract. + +This note records architecture guardrails for implementing the realization +envelope membership, subsumption, and witness relation. It is guidance only: it +does not implement the relation, publish schemas, change manifests, alter +conformance behavior, or replace the #663 bridge. + +## Binding Sources + +- ADR-070 and `specs/formal/realization/envelope-semantics.md` own the + realization-envelope language, admitted fragment, set-relation semantics, + witness rules, and negative-conformance requirement. +- `docs/decisions/issue-667-realization-envelope-preflight.md` owns the + design boundary for envelope semantics. Issue #668 consumes that semantics; + it must not widen the language while implementing the relation. +- `specs/sdl/variables-and-instantiation.md` owns typed variables, + placeholder syntax, fail-closed instantiation, and post-instantiation + semantic revalidation. +- `specs/sdl/diagnostics.md` owns the fail-closed SDL diagnostic stages and + collect-all validation behavior. +- `specs/formal/realization/explicitness-and-realization.md` and + `aces_processor.semantics.realization` own SEM-218 exact/constrained/open + runtime realization gates. The envelope relation composes with those gates; + it does not replace or redefine them. +- `docs/explain/reference/backend-conformance.md` and + `docs/decisions/issue-663-target-conformance-provisioning-scope-preflight.md` + own target-conformance guardrails and the temporary `reference_scenario` + bridge the witness relation is meant to retire later. +- ADR-009, ADR-019, ADR-061, `specs/authority/authority-boundary.yaml`, and + `contracts/schema-publication-manifest.json` own contract authority and schema + publication discipline. + +## Architecture Decisions + +- Implement one deterministic semantic relation over the #667 envelope + expression: concrete membership, envelope subsumption, and witness generation + must share domain comparison and closure logic. Do not create separate + author-side, backend-side, and conformance-side interpretations. +- A concrete scenario is in an envelope only after ordinary SDL structural + validation, semantic validation, instantiation, and no-unresolved-variable + checks pass. Invalid SDL is never a member. +- Subsumption is set inclusion in the admitted fragment. It must reduce to + bounded domain subset checks, product/record checks, governed-reference + subset checks, and scoped closure compatibility. No silent approximation, + sampling, probabilistic answer, backend callback, or solver-only answer is + acceptable. +- Witness generation is a deterministic selector plus ordinary SDL validation. + A witness proves one executable in-envelope instance; it is not proof of + subsumption, backend honesty, or closed-world refusal. +- Negative probes for closed envelopes must use the same relation evidence and + must expect refusal through `OperationStatus` / `Diagnostic` without runtime + mutation. Backend-native exceptions or no-op successes are not portable + refusal evidence. +- Relation diagnostics must be stable and public-safe: name relation kind, + envelope id/ref, SDL path/address, domain kind, governed refs, and contract or + digest ids when needed. Do not echo raw concrete values that may be sensitive. +- Keep current coarse manifest fields in their lane. `realization_support`, + `ProvisionerCapabilities`, backend profiles, semantic profiles, validation + profiles, and experiment study membership are not substitutes for envelope + membership/subsumption. +- If implementation discovers the current unpublished envelope shape is + insufficient, update the formal spec or ADR path first. Do not smuggle new + semantics through prose `constraints` strings or conformance-only DTOs. + +## Required Incumbents + +Reuse these before adding anything new: + +- Envelope authority: + `docs/decisions/adrs/adr-070-realization-envelope-semantics.md`, + `specs/formal/realization/envelope-semantics.md`, and + `docs/research/realization-envelope/`. +- SDL parsing, variables, and validation: `parse_sdl()`, `parse_sdl_file()`, + `SDLModel(extra="forbid")`, `VARIABLE_TOKEN_RE`, `Variable`, + `VariableType`, `instantiate_scenario()`, `SDLParseError`, + `SDLValidationError`, `SDLInstantiationError`, and `SemanticValidator`. +- Existing realization semantics: + `aces_sdl.explicitness`, `CompiledRealizationRequirement`, + `realization_support_diagnostics()`, `realization_disclosure()`, + `RuntimeSnapshot.realization_provenance`, and the SEM-218 formal spec. +- Contract and authority surfaces: `ContractModel`, `schema_bundle()`, + published `contracts/schemas/`, `contracts/fixtures/`, + `contracts/profiles/`, `contracts/concept-authority/`, + `validate_controlled_vocabulary_scope_values()`, reference-model validators, + concept bindings, and schema-publication checks. +- Backend manifest path: `BackendManifest`, + `RealizationSupportDeclaration`, `ProvisionerCapabilities`, + `BackendManifestV2Model`, `backend_manifest_payload()`, + `validate_backend_supported_contract_versions()`, and capability-gap helpers. +- Processor/runtime/conformance path: `run_reference_processor()`, + `compile_scenario_runtime_model()`, `plan()`, `RuntimeTarget`, + `RuntimeControlPlane`, `_call_backend_apply()`, `_snapshot_contract_diagnostics()`, + `run_target_conformance()`, `ConformanceCaseResult`, `OperationReceipt`, + `OperationStatus`, `Diagnostic`, and `Severity`. +- Repository workflow: `.ground-control.yaml`, `.gc/plan-rules.md`, + `tools/check_repo_policy.py`, `tools/check_requirement_governance.py`, + `tools/check_generated_schemas.py`, `tools/check_schema_publication.py`, + `tools/check_json_artifacts.py`, and `tools/verify_all.py`. + +## Cross-Cutting Layers + +- SDL/config ingress: relation inputs that represent SDL must enter through + parsed `Scenario` / `InstantiatedScenario` models or a contract model, not + free-form dicts. Unknown fields, variable placeholders in keys, unresolved + variables, reference ambiguity, and semantic validation failures remain fatal. +- Domain validation: envelope domains must use the typed variable substrate and + governed reference surfaces. Numeric intervals need declared numeric type and + bounded endpoints; enum and governed-reference domains must be finite; record + domains must reject unknown extras when closed. +- Contract shape: any public envelope payload or manifest carrier must be a + closed `ContractModel` with `schema_bundle()` parity, valid/invalid fixtures, + `contracts/schema-publication-manifest.json` ledger entries, and + `x-aces-invariants` for semantic rules JSON Schema cannot express. +- Manifest authority: backend carriage must render through + `backend_manifest_payload()` and validate as a backend manifest. Support + declarations, concept bindings, supported contract versions, capability + vocabularies, and governed extension terms must keep using existing + validators. +- Planning admission: a failed relation check is admission evidence, not a + backend deployment attempt. It should surface as stable `Diagnostic` values + and must not bypass planner diagnostics, capability-gap checks, or SEM-218 + realization-support gates. +- Runtime/control-plane: generated witnesses and negative probes must execute + only through `RuntimeControlPlane` and target components that already pass + `RuntimeTarget` shape validation. `_call_backend_apply()` must remain the + fail-closed backend result boundary. +- Error envelope: public diagnostics and reports may contain ids, refs, paths, + domain kinds, relation kinds, and bounded summaries. They must not contain + credentials, bearer tokens, private keys, raw backend objects, host paths, + process argv, stdout/stderr, hidden truth, scoring state, full tracebacks, or + environment dumps. +- OS and secret exposure: witness generation must not require reading + `~/.secrets`, local daemon inventory, privileged host state, or secrets in + command-line arguments. Default verification must stay hermetic. +- Persistence/evidence: conformance remains report-oriented. If durable + artifacts are later needed, persist envelope refs, digests, relation result + summaries, witnesses, and refusal evidence through existing run-artifact or + experiment evidence surfaces, not a new relation database or cache. + +## Extensibility Seam + +The seam is a pure relation over a versioned envelope expression plus a +validated SDL instance. Keep it parameterized by: + +- relation kind: membership, subsumption, witness, or negative probe; +- scope/path: field, node, topology, app, or scenario; +- domain kind: exact, enum, boolean, numeric interval, governed reference, or + record; +- closure/posture: scoped open-world or closed-world overlays and exact, + constrained, or open posture; +- governed authority: vocabulary, reference-model, scenario registry, contract + id, digest, or concept-family ref; and +- witness policy/seed: deterministic selection without a global hard-coded + scenario. + +Future domain kinds or carriers should land by extending the formal envelope +spec, contract model/schema, fixtures, relation helper, and tests together. +They should not require per-backend relation code, duplicate manifest +renderers, or conformance-profile branches. + +## Gotchas And Anti-Patterns + +Avoid: + +- equating `instance in envelope` with experiment-study membership, + validation/admission profile membership, backend-profile selection, or + semantic-profile applicability; +- treating `subsumes(offered, requested)` as a planner capability shortcut that + can skip manifest, capability, SEM-218, or snapshot validation; +- treating `realization_support.support_mode` as value-level envelope posture; +- letting authored variables survive into concrete membership checks; +- deriving witness values from Python dict iteration, current time, host state, + random choices without an explicit seed policy, or backend discovery; +- implementing relation failures as new exception hierarchies, raw Pydantic + prose, booleans with lost diagnostics, or backend-native errors; +- adding arbitrary predicates, unbounded regex, recursion, non-linear + arithmetic, quantification over unbounded collections, external queries, or + backend callbacks to portable envelopes; +- duplicating schema registries, fixture loaders, vocabulary tables, manifest + renderers, conformance reports, persistence stores, or validation passes; +- using `constraints` prose as the machine-checkable carrier for membership, + subsumption, closure, or witness policy; and +- leaking sensitive concrete values through diagnostics, witnesses, negative + probes, fixtures, docs, audit details, or report payloads. + +## Non-Goals + +- Implementing issue #668 in this preflight. +- Publishing a new envelope schema or backend-manifest version. +- Replacing `run_target_conformance(reference_scenario=...)` in this note. +- Redesigning SDL variables, SEM-218 explicitness, backend profiles, + validation/admission profiles, experiment run-set semantics, runtime + snapshots, control-plane security, or conformance reporting. +- Adding a solver dependency, HTTP API, persistence service, backend adapter, + controlled vocabulary, or new SDL dialect. diff --git a/implementations/python/packages/aces_contracts/realization_envelope.py b/implementations/python/packages/aces_contracts/realization_envelope.py new file mode 100644 index 000000000..ab43f6fb7 --- /dev/null +++ b/implementations/python/packages/aces_contracts/realization_envelope.py @@ -0,0 +1,402 @@ +"""Realization-envelope expression contract (ADR-070, envelope-semantics.md). + +A realization envelope is a closed, versioned expression that denotes a *set* of +SDL scenario instances. The same expression is used in both directions: an author +describes an acceptable scenario family, and a backend describes the family it can +realize. The membership / subsumption / witness / negative-probe relation over +this contract lives in :mod:`aces_sdl.realization_envelope`. + +The model is deliberately *closed* (``extra="forbid"`` plus a finite discriminated +union of domain kinds). That closedness is the portability guarantee of +``envelope-semantics.md`` R3 / ADR-070 §3: arbitrary Python predicates, backend +callbacks, external queries, unbounded regex, recursion, and non-linear arithmetic +are simply not representable, which keeps membership and subsumption reducible to +local structural checks and witness generation deterministic. + +Schema publication (a bundled ``contracts/schemas/`` artifact with a publication +ledger) and backend-manifest carriage are downstream siblings, not this issue: +``envelope-semantics.md`` "Realization Status" lists the *schema carrier* and +*manifest evolution* separately from the *relation helper*, ADR-070 §5 defers +manifest carriage to a schema-evolution question, and the issue-668 preflight note +records "the current unpublished envelope shape". This module therefore ships the +unpublished, first-class contract shape the relation operates over. +""" + +from __future__ import annotations + +from collections.abc import Callable +from enum import Enum +from typing import Annotated, Literal + +from pydantic import Field, model_validator + +from aces_contracts.contracts import ContractModel, NonEmptyString + +# Version identity for the envelope expression. Kept local to this module rather +# than in ``versions.py`` (which is scoped to *published* external contracts): +# the envelope schema is intentionally unpublished at this stage — schema +# publication and manifest carriage are downstream siblings (module docstring). +REALIZATION_ENVELOPE_SCHEMA_VERSION = "realization-envelope/v1" + +__all__ = [ + "REALIZATION_ENVELOPE_SCHEMA_VERSION", + "BooleanDomain", + "Closure", + "ClosureOverlay", + "DomainDescriptor", + "EnumDomain", + "EnvelopeBinding", + "EnvelopeScope", + "ExactDomain", + "GovernedReferenceDomain", + "NumericIntervalDomain", + "NumericType", + "Posture", + "RealizationEnvelopeModel", + "RecordDomain", + "WitnessPolicy", + "scalar_in_domain", + "scalar_matches_numeric_type", +] + +# Portable envelope values are JSON scalars. ``bool`` is intentionally distinct +# from ``int`` here (see ``scalar_matches_numeric_type``); Pydantic preserves the +# authored Python type on a closed model, so ``True`` never collapses to ``1``. +DomainScalar = bool | int | float | str + + +class EnvelopeScope(str, Enum): + """Semantic extent where a posture or closure applies (most local first).""" + + FIELD = "field" + NODE = "node" + TOPOLOGY = "topology" + APP = "app" + SCENARIO = "scenario" + + +class Posture(str, Enum): + """Author/backend intent for a bound value or child scope.""" + + OPEN = "open" + CONSTRAINED = "constrained" + EXACT = "exact" + + +class Closure(str, Enum): + """Whether unspecified realizable dimensions under a scope are admitted.""" + + OPEN_WORLD = "open-world" + CLOSED_WORLD = "closed-world" + + +class NumericType(str, Enum): + """Declared numeric type for a numeric-interval domain.""" + + INTEGER = "integer" + NUMBER = "number" + + +class ExactDomain(ContractModel): + """A singleton value set: values equal to ``value``.""" + + kind: Literal["exact"] = "exact" + value: DomainScalar + + +class EnumDomain(ContractModel): + """A finite value set: values equal to one listed member.""" + + kind: Literal["enum"] = "enum" + values: list[DomainScalar] = Field(min_length=1) + + @model_validator(mode="after") + def _validate_unique(self) -> EnumDomain: + # ``list`` (not ``set``) preserves authored order and the bool/int + # distinction; dedupe on a type-tagged key so ``True`` and ``1`` stay + # separate members. + seen: set[tuple[str, object]] = set() + for member in self.values: + key = (type(member).__name__, member) + if key in seen: + raise ValueError("enum domain values must be unique") + seen.add(key) + return self + + +class BooleanDomain(ContractModel): + """Booleans: both ``true``/``false``, or an exact boolean when ``value`` set.""" + + kind: Literal["boolean"] = "boolean" + value: bool | None = None + + +class NumericIntervalDomain(ContractModel): + """Numbers of ``numeric_type`` inside a bounded interval. + + Both endpoints are required (the fragment admits only *bounded* intervals, + ``envelope-semantics.md`` R3). An integer interval requires integral + endpoints. Empty intervals are rejected at construction. + """ + + kind: Literal["numeric-interval"] = "numeric-interval" + numeric_type: NumericType + lower: float + upper: float + lower_closed: bool = True + upper_closed: bool = True + + @model_validator(mode="after") + def _validate_interval(self) -> NumericIntervalDomain: + if self.numeric_type is NumericType.INTEGER: + if self.lower != int(self.lower) or self.upper != int(self.upper): + raise ValueError("integer numeric-interval endpoints must be integral") + if self.lower > self.upper: + raise ValueError("numeric-interval lower endpoint must not exceed upper") + if self.lower == self.upper and not (self.lower_closed and self.upper_closed): + raise ValueError("degenerate numeric-interval must be closed on both endpoints") + return self + + +class GovernedReferenceDomain(ContractModel): + """References in a finite governed set under a named authority.""" + + kind: Literal["governed-reference"] = "governed-reference" + authority: NonEmptyString + allowed_refs: list[NonEmptyString] = Field(min_length=1) + + @model_validator(mode="after") + def _validate_unique(self) -> GovernedReferenceDomain: + if len(self.allowed_refs) != len(set(self.allowed_refs)): + raise ValueError("governed-reference allowed_refs must be unique") + return self + + +class RecordDomain(ContractModel): + """Product structure: each declared field references another named domain. + + ``extra`` controls undeclared fields: ``False`` (closed) rejects any field not + named in ``fields``; ``True`` (open) admits them. Field values reference domain + names resolved against the envelope's ``domains`` map, keeping the structure + acyclic and free of inline recursion. + """ + + kind: Literal["record"] = "record" + fields: dict[NonEmptyString, NonEmptyString] = Field(min_length=1) + extra: bool = False + + +DomainDescriptor = Annotated[ + ExactDomain | EnumDomain | BooleanDomain | NumericIntervalDomain | GovernedReferenceDomain | RecordDomain, + Field(discriminator="kind"), +] + + +class EnvelopeBinding(ContractModel): + """Binds an SDL path (or governed scope ref) to a domain at a scope. + + ``domain`` names a descriptor in the envelope's ``domains`` map and is required + for ``constrained`` / ``exact`` posture and forbidden for ``open`` posture (an + open value is left to a downstream realizer). ``overrideable`` allows a + more-specific binding to widen a value an enclosing closed scope fixed + (``envelope-semantics.md`` R2). + """ + + path: NonEmptyString + scope: EnvelopeScope + posture: Posture + domain: str | None = None + overrideable: bool = False + + @model_validator(mode="after") + def _validate_posture_domain(self) -> EnvelopeBinding: + if self.posture is Posture.OPEN and self.domain is not None: + raise ValueError("open posture binding must not name a domain") + if self.posture in (Posture.CONSTRAINED, Posture.EXACT) and not self.domain: + raise ValueError(f"{self.posture.value} posture binding must name a domain") + return self + + +class ClosureOverlay(ContractModel): + """Declares open-world or closed-world closure at a scope path.""" + + path: str = "" + scope: EnvelopeScope + closure: Closure + + +class WitnessPolicy(ContractModel): + """Deterministic default-selection policy for witness generation. + + ``selections`` overrides the default choice for named domains (each value must + be a member of the referenced domain); ``seed`` records the selection basis for + reproducibility. Neither introduces randomness: witness generation stays a pure + function of ``(envelope, policy)``. + """ + + seed: str | None = None + selections: dict[NonEmptyString, DomainScalar] = Field(default_factory=dict) + + +class RealizationEnvelopeModel(ContractModel): + """A versioned expression denoting a set of SDL scenario instances.""" + + schema_version: Literal["realization-envelope/v1"] = "realization-envelope/v1" + id: NonEmptyString + scope: EnvelopeScope + domains: dict[NonEmptyString, DomainDescriptor] = Field(default_factory=dict) + bindings: list[EnvelopeBinding] = Field(default_factory=list) + closure: list[ClosureOverlay] = Field(default_factory=list) + witness_policy: WitnessPolicy | None = None + source_ref: str | None = None + contract_id: str | None = None + digest: str | None = None + + @model_validator(mode="after") + def _validate_envelope(self) -> RealizationEnvelopeModel: + self._validate_domain_references() + self._validate_acyclic_records() + self._validate_bindings() + self._validate_witness_policy() + return self + + def _validate_domain_references(self) -> None: + for name, descriptor in self.domains.items(): + if isinstance(descriptor, RecordDomain): + for field_name, domain_name in descriptor.fields.items(): + if domain_name not in self.domains: + raise ValueError( + f"record domain '{name}' field '{field_name}' references unknown domain '{domain_name}'" + ) + + def _validate_acyclic_records(self) -> None: + # DFS over record field references; ADR-070 §3 admits only acyclic + # record/product structure. + visiting: set[str] = set() + done: set[str] = set() + + def visit(name: str) -> None: + if name in done: + return + if name in visiting: + raise ValueError(f"record domain reference cycle through '{name}'") + descriptor = self.domains.get(name) + visiting.add(name) + if isinstance(descriptor, RecordDomain): + for domain_name in descriptor.fields.values(): + visit(domain_name) + visiting.discard(name) + done.add(name) + + for name in self.domains: + visit(name) + + def _validate_bindings(self) -> None: + seen: dict[tuple[str, str], EnvelopeBinding] = {} + for binding in self.bindings: + if binding.domain is not None and binding.domain not in self.domains: + raise ValueError(f"binding path '{binding.path}' references unknown domain '{binding.domain}'") + if binding.posture is Posture.EXACT and binding.domain is not None: + if not _is_singleton_domain(self.domains[binding.domain]): + raise ValueError(f"exact posture binding path '{binding.path}' requires a singleton domain") + key = (binding.path, binding.scope.value) + existing = seen.get(key) + if existing is not None and (existing.domain, existing.posture) != (binding.domain, binding.posture): + # Equal-specificity, incompatible binding is invalid, not + # merge-order dependent (envelope-semantics.md R2). + raise ValueError( + f"conflicting equal-specificity bindings for path '{binding.path}' at scope '{binding.scope.value}'" + ) + seen[key] = binding + + def _validate_witness_policy(self) -> None: + if self.witness_policy is None: + return + for domain_name, value in self.witness_policy.selections.items(): + descriptor = self.domains.get(domain_name) + if descriptor is None: + raise ValueError(f"witness policy selects unknown domain '{domain_name}'") + if not scalar_in_domain(value, descriptor): + raise ValueError(f"witness policy selection for domain '{domain_name}' is not a domain member") + + +# Per-domain-kind predicates, dispatched by type to keep the public entry points +# flat (SonarCloud caps returns/complexity per function; type-dispatch chains +# otherwise trip S1142 / cognitive-complexity). +_SINGLETON_DOMAIN_CHECKS: dict[type, Callable[..., bool]] = { + ExactDomain: lambda descriptor: True, + EnumDomain: lambda descriptor: len(descriptor.values) == 1, + BooleanDomain: lambda descriptor: descriptor.value is not None, + NumericIntervalDomain: lambda descriptor: descriptor.lower == descriptor.upper, + GovernedReferenceDomain: lambda descriptor: len(descriptor.allowed_refs) == 1, +} + + +def _is_singleton_domain(descriptor: DomainDescriptor) -> bool: + check = _SINGLETON_DOMAIN_CHECKS.get(type(descriptor)) + return bool(check(descriptor)) if check is not None else False + + +def scalar_matches_numeric_type(value: object, numeric_type: NumericType) -> bool: + """Return whether ``value`` is a number of the declared numeric type. + + ``bool`` is never a number here (it is its own domain kind). + """ + if isinstance(value, bool): + return False + if numeric_type is NumericType.INTEGER: + return isinstance(value, int) + return isinstance(value, (int, float)) + + +def _scalar_eq(value: object, member: DomainScalar) -> bool: + """Type-strict scalar equality so ``True`` never equals ``1``.""" + return type(value) is type(member) and value == member + + +def _exact_member(value: object, descriptor: ExactDomain) -> bool: + return _scalar_eq(value, descriptor.value) + + +def _enum_member(value: object, descriptor: EnumDomain) -> bool: + return any(_scalar_eq(value, member) for member in descriptor.values) + + +def _boolean_member(value: object, descriptor: BooleanDomain) -> bool: + return isinstance(value, bool) and (descriptor.value is None or value == descriptor.value) + + +def _interval_member(value: object, descriptor: NumericIntervalDomain) -> bool: + if not scalar_matches_numeric_type(value, descriptor.numeric_type): + return False + # narrowed to a number by scalar_matches_numeric_type above + numeric = float(value) + lower_ok = numeric >= descriptor.lower if descriptor.lower_closed else numeric > descriptor.lower + upper_ok = numeric <= descriptor.upper if descriptor.upper_closed else numeric < descriptor.upper + return lower_ok and upper_ok + + +def _governed_member(value: object, descriptor: GovernedReferenceDomain) -> bool: + return isinstance(value, str) and value in descriptor.allowed_refs + + +_SCALAR_MEMBER_CHECKS: dict[type, Callable[..., bool]] = { + ExactDomain: _exact_member, + EnumDomain: _enum_member, + BooleanDomain: _boolean_member, + NumericIntervalDomain: _interval_member, + GovernedReferenceDomain: _governed_member, +} + + +def scalar_in_domain(value: object, descriptor: DomainDescriptor) -> bool: + """Structural membership for the scalar domain kinds. + + The single scalar-membership engine, hosted in the contract layer so both the + contract's own ``witness_policy`` validation and the relation in + ``aces_sdl.realization_envelope`` (which cannot import this module without a + dependency cycle) share one definition. Record domains are product structures, + not scalars, and are handled by the relation engine. + """ + check = _SCALAR_MEMBER_CHECKS.get(type(descriptor)) + return check(value, descriptor) if check is not None else False diff --git a/implementations/python/packages/aces_sdl/_realization_envelope_domains.py b/implementations/python/packages/aces_sdl/_realization_envelope_domains.py new file mode 100644 index 000000000..805de2425 --- /dev/null +++ b/implementations/python/packages/aces_sdl/_realization_envelope_domains.py @@ -0,0 +1,197 @@ +"""Domain-kind dispatch for the realization-envelope relation. + +Per-domain-kind logic — finite enumeration, subset comparison (R4), deterministic +witness selection (R5), and out-of-envelope variation (R6) — dispatched by type so +each public entry point stays flat. Kept separate from the flattening/path engine +so neither file exceeds the repo source-size cap and so the type-dispatch tables +live in one place. Pure functions over the contract types; no SDL, diagnostic, or +parser dependency. +""" + +from __future__ import annotations + +from collections.abc import Callable + +from aces_contracts.realization_envelope import ( + BooleanDomain, + DomainDescriptor, + DomainScalar, + EnumDomain, + ExactDomain, + GovernedReferenceDomain, + NumericIntervalDomain, + NumericType, + scalar_in_domain, +) + +# Sentinel meaning "no out-of-envelope value can be formed within the domain kind". +_MISSING = object() + +WitnessSelection = tuple[object, str | None] + + +# --------------------------------------------------------------------------- # +# Finite enumeration # +# --------------------------------------------------------------------------- # + +_FINITE_MEMBERS: dict[type, Callable[..., list[DomainScalar]]] = { + ExactDomain: lambda d: [d.value], + EnumDomain: lambda d: list(d.values), + BooleanDomain: lambda d: [d.value] if d.value is not None else [False, True], + GovernedReferenceDomain: lambda d: list(d.allowed_refs), +} + + +def finite_members(domain: DomainDescriptor) -> list[DomainScalar] | None: + """Finite value list for a scalar domain, or ``None`` when infinite.""" + + factory = _FINITE_MEMBERS.get(type(domain)) + return factory(domain) if factory is not None else None + + +# --------------------------------------------------------------------------- # +# Subset comparison (R4) # +# --------------------------------------------------------------------------- # + + +def _governed_subset(sub: DomainDescriptor, sup: DomainDescriptor) -> bool: + # Governed references carry an authority that scopes their refs; subsumption + # must not drop it. Only a same-authority governed-reference domain subsumes; + # raw refs from a different kind are never authority-scoped. + if not (isinstance(sub, GovernedReferenceDomain) and isinstance(sup, GovernedReferenceDomain)): + return False + if sub.authority != sup.authority: + return False + return set(sub.allowed_refs) <= set(sup.allowed_refs) + + +def _interval_subset(sub: NumericIntervalDomain, sup: NumericIntervalDomain) -> bool: + if sup.numeric_type is NumericType.INTEGER and sub.numeric_type is NumericType.NUMBER: + return False + lower_ok = sup.lower < sub.lower or (sup.lower == sub.lower and (sup.lower_closed or not sub.lower_closed)) + upper_ok = sup.upper > sub.upper or (sup.upper == sub.upper and (sup.upper_closed or not sub.upper_closed)) + return lower_ok and upper_ok + + +def _numeric_subset(sub: NumericIntervalDomain, sup: DomainDescriptor) -> bool: + if sub.lower == sub.upper: + value: DomainScalar = int(sub.lower) if sub.numeric_type is NumericType.INTEGER else sub.lower + return scalar_in_domain(value, sup) + if isinstance(sup, NumericIntervalDomain): + return _interval_subset(sub, sup) + return False + + +def domain_subset(sub: DomainDescriptor, sup: DomainDescriptor) -> bool: + """Return whether every value admitted by ``sub`` is admitted by ``sup``.""" + + if isinstance(sub, GovernedReferenceDomain) or isinstance(sup, GovernedReferenceDomain): + return _governed_subset(sub, sup) + if isinstance(sub, NumericIntervalDomain): + return _numeric_subset(sub, sup) + members = finite_members(sub) + return members is not None and all(scalar_in_domain(candidate, sup) for candidate in members) + + +# --------------------------------------------------------------------------- # +# Deterministic witness selection (R5) # +# --------------------------------------------------------------------------- # + + +def _enum_sort_key(value: DomainScalar) -> tuple[int, str]: + if isinstance(value, bool): + kind = 0 + elif isinstance(value, (int, float)): + kind = 1 + else: + kind = 2 + return (kind, str(value)) + + +def _integer_witness_value(domain: NumericIntervalDomain) -> WitnessSelection: + lower = int(domain.lower) + upper = int(domain.upper) + candidate = lower if domain.lower_closed else lower + 1 + admissible = candidate < upper or (candidate == upper and domain.upper_closed) + return (candidate, None) if admissible else (None, "integer interval admits no witness value") + + +def _real_witness_value(domain: NumericIntervalDomain) -> WitnessSelection: + # A closed lower bound is itself admissible; an open lower bound on a + # non-degenerate interval (open degenerate intervals are forbidden by the + # contract) admits the interior midpoint. + if domain.lower_closed: + return domain.lower, None + return (domain.lower + domain.upper) / 2, None + + +def _numeric_witness_value(domain: NumericIntervalDomain) -> WitnessSelection: + if domain.numeric_type is NumericType.INTEGER: + return _integer_witness_value(domain) + return _real_witness_value(domain) + + +_WITNESS_SELECTORS: dict[type, Callable[..., WitnessSelection]] = { + ExactDomain: lambda d: (d.value, None), + EnumDomain: lambda d: (min(d.values, key=_enum_sort_key), None), + BooleanDomain: lambda d: (d.value if d.value is not None else False, None), + GovernedReferenceDomain: lambda d: (min(d.allowed_refs), None), + NumericIntervalDomain: _numeric_witness_value, +} + + +def default_witness_value(domain: DomainDescriptor) -> WitnessSelection: + """Deterministic default selection for a scalar domain (R5).""" + + selector = _WITNESS_SELECTORS.get(type(domain)) + if selector is None: + return None, "record domains are not scalar witness leaves" + return selector(domain) + + +# --------------------------------------------------------------------------- # +# Out-of-envelope variation (R6) # +# --------------------------------------------------------------------------- # + + +def _perturb_scalar(value: DomainScalar) -> DomainScalar: + if isinstance(value, bool): + return not value + if isinstance(value, (int, float)): + return value + 1 + return f"{value}-out-of-envelope" + + +def _fresh_scalar_outside(members: list[DomainScalar]) -> object: + if all(isinstance(member, bool) for member in members): + return _MISSING + numeric = [m for m in members if isinstance(m, (int, float)) and not isinstance(m, bool)] + if numeric and len(numeric) == len(members): + return max(numeric) + 1 + candidate = "out-of-envelope" + existing = {str(member) for member in members} + while candidate in existing: + candidate += "-x" + return candidate + + +def _interval_out_of_domain(domain: NumericIntervalDomain) -> object: + step: int | float = 1 if domain.numeric_type is NumericType.INTEGER else 1.0 + upper: int | float = int(domain.upper) if domain.numeric_type is NumericType.INTEGER else domain.upper + return upper + step if domain.upper_closed else upper + + +_OUT_OF_DOMAIN: dict[type, Callable[..., object]] = { + ExactDomain: lambda d: _perturb_scalar(d.value), + EnumDomain: lambda d: _fresh_scalar_outside(list(d.values)), + BooleanDomain: lambda d: _MISSING if d.value is None else (not d.value), + GovernedReferenceDomain: lambda d: _fresh_scalar_outside(list(d.allowed_refs)), + NumericIntervalDomain: _interval_out_of_domain, +} + + +def out_of_domain_value(domain: DomainDescriptor) -> object: + """A scalar just outside ``domain``, or ``_MISSING`` when none can be formed.""" + + factory = _OUT_OF_DOMAIN.get(type(domain)) + return factory(domain) if factory is not None else _MISSING diff --git a/implementations/python/packages/aces_sdl/_realization_envelope_engine.py b/implementations/python/packages/aces_sdl/_realization_envelope_engine.py new file mode 100644 index 000000000..02100ba1c --- /dev/null +++ b/implementations/python/packages/aces_sdl/_realization_envelope_engine.py @@ -0,0 +1,361 @@ +"""Internal engine for the realization-envelope relation. + +SDL-path navigation, most-specific-wins constraint flattening (R2), and closure +bookkeeping shared by the four relation kinds in +:mod:`aces_sdl.realization_envelope`. Domain-kind dispatch (subset, witness, +variation) lives in :mod:`aces_sdl._realization_envelope_domains`. Kept separate +from the public relation module so neither file exceeds the repo source-size cap. +This module has no diagnostic, parser, or validator dependency; those live in the +public module. +""" + +from __future__ import annotations + +import re +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from enum import Enum + +from aces_contracts.realization_envelope import ( + Closure, + DomainDescriptor, + EnvelopeBinding, + Posture, + RealizationEnvelopeModel, + RecordDomain, + WitnessPolicy, +) +from pydantic import BaseModel + +from ._realization_envelope_domains import default_witness_value, domain_subset + +PathToken = str | int + + +@dataclass(frozen=True) +class LeafConstraint: + """A flattened scalar constraint at a concrete SDL path.""" + + domain: DomainDescriptor + domain_name: str + posture: Posture + overrideable: bool + + +# --------------------------------------------------------------------------- # +# Path handling # +# --------------------------------------------------------------------------- # + +_PATH_TOKEN_RE = re.compile(r"[^.\[\]]+|\[\d+\]") + + +def tokenize_path(path: str) -> list[PathToken]: + """Split an SDL path into attribute/key segments and ``[i]`` list indices.""" + + tokens: list[PathToken] = [] + for raw in _PATH_TOKEN_RE.findall(path): + tokens.append(int(raw[1:-1]) if raw.startswith("[") else raw) + return tokens + + +def _navigate_step(current: object, token: PathToken) -> tuple[bool, object]: + result: tuple[bool, object] = (False, None) + if isinstance(token, int): + if isinstance(current, Sequence) and not isinstance(current, (str, bytes)) and 0 <= token < len(current): + result = (True, current[token]) + elif isinstance(current, BaseModel) and token in type(current).model_fields: + result = (True, getattr(current, token)) + elif isinstance(current, Mapping) and token in current: + result = (True, current[token]) + return result + + +def navigate(root: object, tokens: Sequence[PathToken]) -> tuple[bool, object]: + """Resolve ``tokens`` against a model/mapping/sequence tree.""" + + current: object = root + for token in tokens: + found, current = _navigate_step(current, token) + if not found: + return False, None + return True, current + + +def normalize_scalar(value: object) -> object: + """Reduce an SDL model value to a portable scalar for domain comparison. + + SDL string enums (e.g. ``OSFamily``, ``NodeType``) navigate out as ``Enum`` + instances; envelope domains carry plain JSON scalars. Comparing them requires + the enum's underlying value. ``bool`` is preserved (it is its own domain kind). + """ + + return value.value if isinstance(value, Enum) else value + + +def _is_nonempty(value: object) -> bool: + if value is None: + return False + return not (isinstance(value, (dict, list, tuple, set)) and len(value) == 0) + + +def present_children(value: object) -> set[str]: + """Immediate realizable child keys carrying a value under ``value``. + + A child is a *present realizable dimension* when it is non-empty and either a + required field or set to something other than its declared default. This is + robust to the instantiation round-trip (``model_dump`` → ``model_validate``), + which marks every field as set and so makes ``model_fields_set`` unusable: + default identity scalars (e.g. ``version="*"``) are not realizable dimensions + and must not count as closed-world extras (envelope-semantics.md I3). + """ + + if isinstance(value, BaseModel): + return _model_present_children(value) + if isinstance(value, Mapping): + return {str(key) for key in value} + return set() + + +def _model_present_children(model: BaseModel) -> set[str]: + present: set[str] = set() + for name, info in type(model).model_fields.items(): + child = getattr(model, name) + if not _is_nonempty(child): + continue + if info.is_required() or child != info.get_default(call_default_factory=True): + present.add(name) + return present + + +# --------------------------------------------------------------------------- # +# Effective constraints (most-specific-wins flattening, R2) # +# --------------------------------------------------------------------------- # + + +def _expand_constraint( + path: str, + domain: DomainDescriptor, + domain_name: str, + posture: Posture, + overrideable: bool, + envelope: RealizationEnvelopeModel, + constraints: dict[str, LeafConstraint], +) -> None: + if isinstance(domain, RecordDomain): + for field_name, referenced in domain.fields.items(): + _expand_constraint( + f"{path}.{field_name}", + envelope.domains[referenced], + referenced, + posture, + overrideable, + envelope, + constraints, + ) + return + constraints[path] = LeafConstraint( + domain=domain, domain_name=domain_name, posture=posture, overrideable=overrideable + ) + + +def _collect_record_closures( + path: str, + domain: DomainDescriptor, + envelope: RealizationEnvelopeModel, + closed: dict[str, set[str]], +) -> None: + if not isinstance(domain, RecordDomain): + return + if not domain.extra: + closed[path] = set(domain.fields) + for field_name, referenced in domain.fields.items(): + _collect_record_closures(f"{path}.{field_name}", envelope.domains[referenced], envelope, closed) + + +def _record_closed_scopes(envelope: RealizationEnvelopeModel) -> dict[str, set[str]]: + """Closed record domains contribute an admitted-child-key set at their path.""" + + closed: dict[str, set[str]] = {} + for binding in envelope.bindings: + if binding.domain is not None: + _collect_record_closures(binding.path, envelope.domains[binding.domain], envelope, closed) + return closed + + +def _prefixed_paths(constraints: dict[str, LeafConstraint], prefix: str) -> list[str]: + return [path for path in constraints if path == prefix or path.startswith(prefix + ".")] + + +def _apply_binding( + binding: EnvelopeBinding, + envelope: RealizationEnvelopeModel, + constraints: dict[str, LeafConstraint], +) -> None: + if binding.posture is Posture.OPEN: + for path in _prefixed_paths(constraints, binding.path): + del constraints[path] + return + if binding.domain is None: + return + _expand_constraint( + binding.path, + envelope.domains[binding.domain], + binding.domain, + binding.posture, + binding.overrideable, + envelope, + constraints, + ) + + +def _add_admitted_children(envelope: RealizationEnvelopeModel, scope_path: str, admitted: set[str]) -> None: + prefix = scope_path + "." if scope_path else "" + for binding in envelope.bindings: + if not binding.path.startswith(prefix): + continue + remainder = binding.path[len(prefix) :] + tokens = tokenize_path(remainder) if remainder else [] + if tokens and isinstance(tokens[0], str): + admitted.add(tokens[0]) + + +def effective_constraints( + envelope: RealizationEnvelopeModel, +) -> tuple[dict[str, LeafConstraint], dict[str, set[str]]]: + """Flatten bindings into per-path scalar constraints and closed-scope key sets. + + Most-specific-wins (R2) is realized by processing bindings shortest-path-first + so a more-specific explicit binding overwrites a record expansion, and an + ``open`` binding removes any constraint at (and under) its path. + """ + + constraints: dict[str, LeafConstraint] = {} + for binding in sorted(envelope.bindings, key=lambda b: len(tokenize_path(b.path))): + _apply_binding(binding, envelope, constraints) + + closed = _record_closed_scopes(envelope) + for overlay in envelope.closure: + if overlay.closure is Closure.CLOSED_WORLD: + _add_admitted_children(envelope, overlay.path, closed.setdefault(overlay.path, set())) + return constraints, closed + + +# --------------------------------------------------------------------------- # +# Overridability (R2 well-formedness) # +# --------------------------------------------------------------------------- # + + +def _record_open_widenings(constraints: dict[str, LeafConstraint], prefix: str, violations: list[str]) -> None: + for path in _prefixed_paths(constraints, prefix): + if not constraints[path].overrideable: + # open posture widens a fixed inherited value + violations.append(path) + del constraints[path] + + +def _record_overwrite_widenings( + binding: EnvelopeBinding, + envelope: RealizationEnvelopeModel, + constraints: dict[str, LeafConstraint], + violations: list[str], +) -> None: + new_leaves: dict[str, LeafConstraint] = {} + _expand_constraint( + binding.path, + envelope.domains[binding.domain], + binding.domain, + binding.posture, + binding.overrideable, + envelope, + new_leaves, + ) + for path, new_constraint in new_leaves.items(): + existing = constraints.get(path) + # A narrowing (subset) overwrite is allowed; a non-subset overwrite of a + # non-overrideable inherited value is a forbidden widening. + if ( + existing is not None + and not existing.overrideable + and not domain_subset(new_constraint.domain, existing.domain) + ): + violations.append(path) + constraints[path] = new_constraint + + +def overridability_violations(envelope: RealizationEnvelopeModel) -> list[str]: + """Paths where a more-specific binding illegally widens a fixed inherited value. + + Envelope-semantics.md R2: a more-specific binding may not widen a value an + enclosing scope fixed (or excluded) unless the enclosing binding marked that + child ``overrideable``. Replays the same shortest-path-first order as + :func:`effective_constraints`. An empty list means the envelope is well-formed. + """ + + violations: list[str] = [] + constraints: dict[str, LeafConstraint] = {} + for binding in sorted(envelope.bindings, key=lambda b: len(tokenize_path(b.path))): + if binding.posture is Posture.OPEN: + _record_open_widenings(constraints, binding.path, violations) + elif binding.domain is not None: + _record_overwrite_widenings(binding, envelope, constraints, violations) + return violations + + +# --------------------------------------------------------------------------- # +# Witness assembly helpers # +# --------------------------------------------------------------------------- # + + +def witness_value(constraint: LeafConstraint, policy: WitnessPolicy | None) -> tuple[object, str | None]: + """Selected witness value for one leaf, honoring an explicit policy override (R5).""" + + if policy is not None and constraint.domain_name in policy.selections: + return policy.selections[constraint.domain_name], None + return default_witness_value(constraint.domain) + + +def assign_path(payload: dict[str, object], tokens: Sequence[PathToken], value: object) -> str | None: + """Set ``value`` at ``tokens`` in a nested dict payload. + + Returns an error string if the path uses list indices, which witness assembly + does not support (SDL sections are keyed mappings). + """ + + current = payload + for token in tokens[:-1]: + if isinstance(token, int): + return "list-indexed paths are not supported for witness generation" + nested = current.get(token) + if not isinstance(nested, dict): + nested = {} + current[token] = nested + current = nested + last = tokens[-1] + if isinstance(last, int): + return "list-indexed paths are not supported for witness generation" + current[last] = value + return None + + +def remove_path(payload: dict[str, object], tokens: Sequence[PathToken]) -> bool: + """Delete the leaf at ``tokens`` from a nested dict payload; return whether removed.""" + + current: object = payload + for token in tokens[:-1]: + if not isinstance(current, dict) or not isinstance(token, str) or token not in current: + return False + current = current[token] + last = tokens[-1] + if isinstance(current, dict) and isinstance(last, str) and last in current: + del current[last] + return True + return False + + +def fresh_extra_key(admitted: set[str]) -> str: + """A child key not in ``admitted`` (for closed-scope extra-dimension probes).""" + + candidate = "out_of_envelope" + while candidate in admitted: + candidate += "_x" + return candidate diff --git a/implementations/python/packages/aces_sdl/realization_envelope.py b/implementations/python/packages/aces_sdl/realization_envelope.py new file mode 100644 index 000000000..76de4d283 --- /dev/null +++ b/implementations/python/packages/aces_sdl/realization_envelope.py @@ -0,0 +1,405 @@ +"""Realization-envelope relation: membership, subsumption, witness, negative probes. + +One deterministic semantic relation over :class:`RealizationEnvelopeModel` and +validated SDL instances (ADR-070 §2, ``specs/formal/realization/envelope-semantics.md`` +R1-R8). Membership, subsumption, witness generation, and negative-probe generation +share a single flattening/closure engine (:mod:`aces_sdl._realization_envelope_engine`) +and domain-kind dispatch (:mod:`aces_sdl._realization_envelope_domains`) — there are +no separate author-side, backend-side, or conformance-side interpretations. + +Guarantees: + +- **Membership (R1)** — a concrete instance is in an envelope only when it is + structurally and semantically valid SDL with no unresolved variables *and* every + effective binding and closed-world scope is satisfied. Invalid SDL is never a + member. +- **Subsumption (R4)** — ``subsumes(offered, requested)`` is set inclusion reduced + to bounded per-path domain-subset, closed-scope key-set, and closure-compatibility + checks. No sampling, approximation, solver, or backend probing. +- **Witness (R5)** — ``witness`` deterministically selects one candidate from the + envelope's own bindings (no externally supplied scenario) and then runs the + ordinary ``parse``/``instantiate``/``validate`` pipeline. A witness is one + executable in-envelope instance; it is not proof of subsumption or backend honesty. +- **Negative probes (R6)** — ``generate_negative_probes`` derives out-of-envelope + variants of a valid witness for the closed dimensions that can be varied, so + conformance can require refusal. + +Diagnostics name the relation kind, envelope id, SDL path, and domain kind — never +raw sensitive values (R8 / ADR-070 §7). +""" + +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass + +from aces_contracts.diagnostics import Diagnostic, Severity +from aces_contracts.realization_envelope import Posture, RealizationEnvelopeModel, WitnessPolicy, scalar_in_domain +from pydantic import ValidationError + +from ._errors import SDLInstantiationError, SDLValidationError +from ._realization_envelope_domains import _MISSING, domain_subset, out_of_domain_value +from ._realization_envelope_engine import ( + LeafConstraint, + assign_path, + effective_constraints, + fresh_extra_key, + navigate, + normalize_scalar, + overridability_violations, + present_children, + remove_path, + tokenize_path, + witness_value, +) +from .instantiate import instantiate_scenario +from .scenario import InstantiatedScenario, Scenario +from .validator import SemanticValidator + +__all__ = [ + "NegativeProbe", + "RelationKind", + "RelationResult", + "WitnessResult", + "generate_negative_probes", + "member", + "subsumes", + "witness", +] + +_DOMAIN = "realization-envelope" + + +class RelationKind: + """Relation kind labels used in diagnostic codes.""" + + MEMBERSHIP = "membership" + SUBSUMPTION = "subsumption" + WITNESS = "witness" + NEGATIVE_PROBE = "negative-probe" + + +@dataclass(frozen=True) +class RelationResult: + """Result of a membership or subsumption evaluation.""" + + holds: bool + diagnostics: tuple[Diagnostic, ...] = () + + +@dataclass(frozen=True) +class WitnessResult: + """A generated witness scenario, or diagnostics proving none can be generated.""" + + scenario: InstantiatedScenario | None + diagnostics: tuple[Diagnostic, ...] = () + + +@dataclass(frozen=True) +class NegativeProbe: + """An out-of-envelope variant of a valid witness for a closed dimension.""" + + path: str + domain_kind: str + variation: str + payload: dict[str, object] + + +def _diag(code: str, address: str, message: str, severity: Severity = Severity.ERROR) -> Diagnostic: + return Diagnostic(code=f"{_DOMAIN}.{code}", domain=_DOMAIN, address=address, message=message, severity=severity) + + +def _envelope_r2_diagnostics(envelope: RealizationEnvelopeModel) -> tuple[Diagnostic, ...]: + """R2 well-formedness: reject a more-specific binding that widens a fixed value. + + An envelope that violates the overridability rule is ill-formed; the relation + answers deny (not a member / no witness) rather than silently resolving against + a broader-than-authored set. + """ + + return tuple( + _diag( + "invalid.non-overrideable-widen", path, "a more-specific binding widens a non-overrideable inherited value" + ) + for path in overridability_violations(envelope) + ) + + +# --------------------------------------------------------------------------- # +# Membership (R1, R3) # +# --------------------------------------------------------------------------- # + + +def _member_sdl_invalid(instance: InstantiatedScenario, envelope: RealizationEnvelopeModel) -> RelationResult | None: + """Return a deny result when the instance is not semantically valid SDL (R1).""" + + if getattr(instance, "semantic_validated", False): + return None + try: + SemanticValidator(instance).validate() + except SDLValidationError: + return RelationResult( + False, + (_diag(f"{RelationKind.MEMBERSHIP}.invalid-sdl", envelope.id, "instance failed SDL semantic validation"),), + ) + return None + + +def _member_constraint_diagnostics( + instance: InstantiatedScenario, constraints: dict[str, LeafConstraint] +) -> list[Diagnostic]: + diagnostics: list[Diagnostic] = [] + for path, constraint in constraints.items(): + found, value = navigate(instance, tokenize_path(path)) + if not found or value is None: + # A constrained/exact dimension left unspecified (missing field, or an + # optional field defaulting to ``None``) is not a member. Domains never + # admit ``None`` (they range over scalars), so this is always absence. + diagnostics.append( + _diag(f"{RelationKind.MEMBERSHIP}.path-absent", path, "constrained path is unspecified in the instance") + ) + elif not scalar_in_domain(normalize_scalar(value), constraint.domain): + diagnostics.append( + _diag( + f"{RelationKind.MEMBERSHIP}.domain-mismatch", + path, + f"value is not in the {constraint.domain.kind} domain", + ) + ) + return diagnostics + + +_UNRESOLVED = object() + + +def _resolve_scope_value(instance: InstantiatedScenario, scope_path: str) -> object: + """Value at ``scope_path`` (the whole instance for the root), or ``_UNRESOLVED``.""" + + if not scope_path: + return instance + found, value = navigate(instance, tokenize_path(scope_path)) + return value if found else _UNRESOLVED + + +def _closed_extra_diagnostics(scope_path: str, scope_value: object, admitted: set[str]) -> list[Diagnostic]: + """Diagnostics for realizable child dimensions not admitted under a closed scope.""" + + diagnostics: list[Diagnostic] = [] + for child in sorted(present_children(scope_value)): + if child not in admitted: + address = f"{scope_path}.{child}" if scope_path else child + diagnostics.append( + _diag( + f"{RelationKind.MEMBERSHIP}.closed-world-extra", + address, + "closed-world scope admits no unspecified realizable dimension", + ) + ) + return diagnostics + + +def _member_closed_diagnostics(instance: InstantiatedScenario, closed: dict[str, set[str]]) -> list[Diagnostic]: + diagnostics: list[Diagnostic] = [] + for scope_path in sorted(closed): + scope_value = _resolve_scope_value(instance, scope_path) + if scope_value is not _UNRESOLVED: + diagnostics.extend(_closed_extra_diagnostics(scope_path, scope_value, closed[scope_path])) + return diagnostics + + +def member(instance: InstantiatedScenario, envelope: RealizationEnvelopeModel) -> RelationResult: + """Decide whether ``instance`` is a member of ``envelope`` (R1).""" + + invalid = _envelope_r2_diagnostics(envelope) + if invalid: + return RelationResult(False, invalid) + + sdl_invalid = _member_sdl_invalid(instance, envelope) + if sdl_invalid is not None: + return sdl_invalid + + constraints, closed = effective_constraints(envelope) + diagnostics = _member_constraint_diagnostics(instance, constraints) + _member_closed_diagnostics(instance, closed) + return RelationResult(not diagnostics, tuple(diagnostics)) + + +# --------------------------------------------------------------------------- # +# Subsumption (R4) # +# --------------------------------------------------------------------------- # + + +def _subsumption_domain_diagnostics( + offered: dict[str, LeafConstraint], requested: dict[str, LeafConstraint] +) -> list[Diagnostic]: + diagnostics: list[Diagnostic] = [] + for path in sorted(set(offered) | set(requested)): + offered_constraint = offered.get(path) + if offered_constraint is None: + # offered is open/universal here: it admits any requested value + continue + requested_constraint = requested.get(path) + if requested_constraint is None: + diagnostics.append( + _diag( + f"{RelationKind.SUBSUMPTION}.requested-unconstrained", + path, + "requested leaves a path open that offered constrains", + ) + ) + elif not domain_subset(requested_constraint.domain, offered_constraint.domain): + diagnostics.append( + _diag( + f"{RelationKind.SUBSUMPTION}.domain-not-subset", + path, + "requested domain is not a subset of the offered domain", + ) + ) + return diagnostics + + +def _subsumption_closure_diagnostics(offered: dict[str, set[str]], requested: dict[str, set[str]]) -> list[Diagnostic]: + diagnostics: list[Diagnostic] = [] + for scope_path in sorted(offered): + label = scope_path or "" + if scope_path not in requested: + diagnostics.append( + _diag( + f"{RelationKind.SUBSUMPTION}.closure-mismatch", + label, + "offered is closed-world where requested is open-world", + ) + ) + elif not requested[scope_path].issubset(offered[scope_path]): + diagnostics.append( + _diag( + f"{RelationKind.SUBSUMPTION}.closed-extra", + label, + "requested admits a closed dimension offered does not", + ) + ) + return diagnostics + + +def subsumes(offered: RealizationEnvelopeModel, requested: RealizationEnvelopeModel) -> RelationResult: + """Decide whether every scenario in ``requested`` is in ``offered`` (R4).""" + + invalid = _envelope_r2_diagnostics(offered) + _envelope_r2_diagnostics(requested) + if invalid: + return RelationResult(False, invalid) + + offered_constraints, offered_closed = effective_constraints(offered) + requested_constraints, requested_closed = effective_constraints(requested) + diagnostics = _subsumption_domain_diagnostics( + offered_constraints, requested_constraints + ) + _subsumption_closure_diagnostics(offered_closed, requested_closed) + return RelationResult(not diagnostics, tuple(diagnostics)) + + +# --------------------------------------------------------------------------- # +# Witness generation (R5) # +# --------------------------------------------------------------------------- # + + +def _build_witness_payload( + envelope: RealizationEnvelopeModel, policy: WitnessPolicy | None +) -> tuple[dict[str, object], list[Diagnostic]]: + effective_policy = policy if policy is not None else envelope.witness_policy + constraints, _closed = effective_constraints(envelope) + payload: dict[str, object] = {} + diagnostics: list[Diagnostic] = [] + for path in sorted(constraints): + value, error = witness_value(constraints[path], effective_policy) + if error is None: + error = assign_path(payload, tokenize_path(path), value) + if error is not None: + diagnostics.append(_diag(f"{RelationKind.WITNESS}.no-witness", path, error)) + return payload, diagnostics + + +def _validate_witness_payload(payload: dict[str, object], envelope: RealizationEnvelopeModel) -> WitnessResult: + try: + raw = Scenario.model_validate(payload) + raw._set_semantic_validated(False) + instantiated = instantiate_scenario(raw, validate_semantics=True) + except (ValidationError, SDLValidationError, SDLInstantiationError): + return WitnessResult( + None, + ( + _diag( + f"{RelationKind.WITNESS}.invalid", + envelope.id, + "generated witness did not pass SDL structural/semantic validation " + "(the envelope does not fully determine a valid scenario instance)", + ), + ), + ) + return WitnessResult(instantiated, ()) + + +def witness(envelope: RealizationEnvelopeModel, policy: WitnessPolicy | None = None) -> WitnessResult: + """Deterministically derive one in-envelope scenario instance (R5).""" + + invalid = _envelope_r2_diagnostics(envelope) + if invalid: + return WitnessResult(None, invalid) + payload, diagnostics = _build_witness_payload(envelope, policy) + if diagnostics: + return WitnessResult(None, tuple(diagnostics)) + return _validate_witness_payload(payload, envelope) + + +# --------------------------------------------------------------------------- # +# Negative probes (R6) # +# --------------------------------------------------------------------------- # + + +def _value_probes_for(base_payload: dict[str, object], path: str, constraint: LeafConstraint) -> list[NegativeProbe]: + probes: list[NegativeProbe] = [] + variation_value = out_of_domain_value(constraint.domain) + if variation_value is not _MISSING: + payload = deepcopy(base_payload) + if assign_path(payload, tokenize_path(path), variation_value) is None: + probes.append(NegativeProbe(path, constraint.domain.kind, "value-outside-domain", payload)) + if constraint.posture is Posture.EXACT: + omitted = deepcopy(base_payload) + if remove_path(omitted, tokenize_path(path)): + probes.append(NegativeProbe(path, constraint.domain.kind, "omitted-required-exact", omitted)) + return probes + + +def _closed_scope_probes(base_payload: dict[str, object], closed: dict[str, set[str]]) -> list[NegativeProbe]: + probes: list[NegativeProbe] = [] + for scope_path in sorted(closed): + extra_key = fresh_extra_key(closed[scope_path]) + payload = deepcopy(base_payload) + tokens = tokenize_path(scope_path) + [extra_key] if scope_path else [extra_key] + if assign_path(payload, tokens, "out-of-envelope") is None: + address = f"{scope_path}.{extra_key}" if scope_path else extra_key + probes.append(NegativeProbe(address, "closed-scope", "extra-dimension", payload)) + return probes + + +def generate_negative_probes( + envelope: RealizationEnvelopeModel, +) -> tuple[tuple[NegativeProbe, ...], tuple[Diagnostic, ...]]: + """Derive out-of-envelope probes for the envelope's closed dimensions (R6).""" + + base = witness(envelope) + if base.scenario is None: + return (), ( + _diag( + f"{RelationKind.NEGATIVE_PROBE}.no-witness", + envelope.id, + "cannot derive a witness base for negative probes", + ), + ) + + # ``mode="json"`` yields plain JSON scalars (enums as their string value), so + # each probe payload is a portable, re-parseable scenario request. + base_payload = base.scenario.model_dump(mode="json", by_alias=True) + constraints, closed = effective_constraints(envelope) + probes: list[NegativeProbe] = [] + for path in sorted(constraints): + probes.extend(_value_probes_for(base_payload, path, constraints[path])) + probes.extend(_closed_scope_probes(base_payload, closed)) + return tuple(probes), () diff --git a/implementations/python/tests/data/realization_envelope/invalid/empty-interval.json b/implementations/python/tests/data/realization_envelope/invalid/empty-interval.json new file mode 100644 index 000000000..99a6bb252 --- /dev/null +++ b/implementations/python/tests/data/realization_envelope/invalid/empty-interval.json @@ -0,0 +1,3 @@ +{"id": "bad-interval", "scope": "node", + "domains": {"cpu": {"kind": "numeric-interval", "numeric_type": "integer", "lower": 8, "upper": 1}}, + "bindings": []} diff --git a/implementations/python/tests/data/realization_envelope/invalid/exact-nonsingleton.json b/implementations/python/tests/data/realization_envelope/invalid/exact-nonsingleton.json new file mode 100644 index 000000000..e487ee723 --- /dev/null +++ b/implementations/python/tests/data/realization_envelope/invalid/exact-nonsingleton.json @@ -0,0 +1,3 @@ +{"id": "bad-exact", "scope": "scenario", + "domains": {"os": {"kind": "enum", "values": ["linux", "windows"]}}, + "bindings": [{"path": "nodes.web.os", "scope": "field", "posture": "exact", "domain": "os"}]} diff --git a/implementations/python/tests/data/realization_envelope/invalid/open-with-domain.json b/implementations/python/tests/data/realization_envelope/invalid/open-with-domain.json new file mode 100644 index 000000000..e6c57fc99 --- /dev/null +++ b/implementations/python/tests/data/realization_envelope/invalid/open-with-domain.json @@ -0,0 +1,3 @@ +{"id": "bad-open", "scope": "scenario", + "domains": {"os": {"kind": "enum", "values": ["linux"]}}, + "bindings": [{"path": "nodes.web.os", "scope": "field", "posture": "open", "domain": "os"}]} diff --git a/implementations/python/tests/data/realization_envelope/invalid/record-cycle.json b/implementations/python/tests/data/realization_envelope/invalid/record-cycle.json new file mode 100644 index 000000000..9cdee410f --- /dev/null +++ b/implementations/python/tests/data/realization_envelope/invalid/record-cycle.json @@ -0,0 +1,4 @@ +{"id": "bad-cycle", "scope": "node", + "domains": {"a": {"kind": "record", "fields": {"x": "b"}}, + "b": {"kind": "record", "fields": {"y": "a"}}}, + "bindings": []} diff --git a/implementations/python/tests/data/realization_envelope/invalid/unknown-domain-ref.json b/implementations/python/tests/data/realization_envelope/invalid/unknown-domain-ref.json new file mode 100644 index 000000000..0ae1faa9b --- /dev/null +++ b/implementations/python/tests/data/realization_envelope/invalid/unknown-domain-ref.json @@ -0,0 +1,2 @@ +{"id": "bad-ref", "scope": "scenario", "domains": {}, + "bindings": [{"path": "name", "scope": "scenario", "posture": "constrained", "domain": "missing"}]} diff --git a/implementations/python/tests/data/realization_envelope/valid/closed-scenario.json b/implementations/python/tests/data/realization_envelope/valid/closed-scenario.json new file mode 100644 index 000000000..32d7efacb --- /dev/null +++ b/implementations/python/tests/data/realization_envelope/valid/closed-scenario.json @@ -0,0 +1,13 @@ +{ + "id": "closed-scenario-v1", + "scope": "scenario", + "domains": { + "scenario_name": {"kind": "exact", "value": "sealed"}, + "node_type": {"kind": "exact", "value": "vm"} + }, + "bindings": [ + {"path": "name", "scope": "scenario", "posture": "exact", "domain": "scenario_name"}, + {"path": "nodes.only.type", "scope": "node", "posture": "exact", "domain": "node_type"} + ], + "closure": [{"path": "", "scope": "scenario", "closure": "closed-world"}] +} diff --git a/implementations/python/tests/data/realization_envelope/valid/record-node.json b/implementations/python/tests/data/realization_envelope/valid/record-node.json new file mode 100644 index 000000000..bf0518098 --- /dev/null +++ b/implementations/python/tests/data/realization_envelope/valid/record-node.json @@ -0,0 +1,12 @@ +{ + "id": "record-node-v1", + "scope": "node", + "domains": { + "os": {"kind": "enum", "values": ["linux", "windows"]}, + "vm": {"kind": "exact", "value": "vm"}, + "node": {"kind": "record", "fields": {"type": "vm", "os": "os"}, "extra": false} + }, + "bindings": [ + {"path": "nodes.web", "scope": "node", "posture": "constrained", "domain": "node"} + ] +} diff --git a/implementations/python/tests/data/realization_envelope/valid/resource-bounds.json b/implementations/python/tests/data/realization_envelope/valid/resource-bounds.json new file mode 100644 index 000000000..6a76597ca --- /dev/null +++ b/implementations/python/tests/data/realization_envelope/valid/resource-bounds.json @@ -0,0 +1,16 @@ +{ + "id": "resource-bounds-v1", + "scope": "node", + "domains": { + "cpu": {"kind": "numeric-interval", "numeric_type": "integer", "lower": 1, "upper": 8}, + "ratio": {"kind": "numeric-interval", "numeric_type": "number", "lower": 0.0, "upper": 1.0, "upper_closed": false}, + "enabled": {"kind": "boolean", "value": true}, + "image": {"kind": "governed-reference", "authority": "image-registry", "allowed_refs": ["base/linux", "base/windows"]} + }, + "bindings": [ + {"path": "nodes.host.resources.cpu", "scope": "field", "posture": "constrained", "domain": "cpu"}, + {"path": "nodes.host.resources.ratio", "scope": "field", "posture": "constrained", "domain": "ratio"}, + {"path": "nodes.host.enabled", "scope": "field", "posture": "exact", "domain": "enabled"}, + {"path": "nodes.host.image", "scope": "field", "posture": "constrained", "domain": "image"} + ] +} diff --git a/implementations/python/tests/data/realization_envelope/valid/scenario-web-family.json b/implementations/python/tests/data/realization_envelope/valid/scenario-web-family.json new file mode 100644 index 000000000..96531e722 --- /dev/null +++ b/implementations/python/tests/data/realization_envelope/valid/scenario-web-family.json @@ -0,0 +1,15 @@ +{ + "id": "web-family-v1", + "scope": "scenario", + "domains": { + "scenario_name": {"kind": "exact", "value": "web-family"}, + "node_type": {"kind": "exact", "value": "vm"}, + "os": {"kind": "enum", "values": ["linux", "windows"]} + }, + "bindings": [ + {"path": "name", "scope": "scenario", "posture": "exact", "domain": "scenario_name"}, + {"path": "nodes.web.type", "scope": "node", "posture": "exact", "domain": "node_type"}, + {"path": "nodes.web.os", "scope": "field", "posture": "constrained", "domain": "os"} + ], + "witness_policy": {"seed": "linux-first", "selections": {"os": "linux"}} +} diff --git a/implementations/python/tests/test_realization_envelope_relation.py b/implementations/python/tests/test_realization_envelope_relation.py new file mode 100644 index 000000000..4ed5349c3 --- /dev/null +++ b/implementations/python/tests/test_realization_envelope_relation.py @@ -0,0 +1,594 @@ +"""Tests for the realization-envelope relation (issue #668). + +Covers the envelope contract's construction invariants and the four relation +kinds — membership, subsumption, witness generation, and negative probes — against +``specs/formal/realization/envelope-semantics.md`` R1-R8 and ADR-070 §2/§3. Property +tests prove decidability/determinism, that subsumption is set inclusion on the +admitted fragment, that witnesses are in-envelope, and that negative probes are +out-of-envelope. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from aces_contracts.realization_envelope import ( + BooleanDomain, + Closure, + ClosureOverlay, + EnumDomain, + EnvelopeBinding, + EnvelopeScope, + ExactDomain, + GovernedReferenceDomain, + NumericIntervalDomain, + NumericType, + Posture, + RealizationEnvelopeModel, + RecordDomain, + WitnessPolicy, +) +from aces_sdl import ( + SDLInstantiationError, + SDLValidationError, + instantiate_scenario, + parse_sdl, +) +from aces_sdl._realization_envelope_domains import _MISSING, default_witness_value, out_of_domain_value +from aces_sdl.realization_envelope import generate_negative_probes, member, subsumes, witness +from aces_sdl.scenario import InstantiatedScenario, Scenario +from hypothesis import given, settings +from hypothesis import strategies as st +from pydantic import ValidationError + +_DATA = Path(__file__).parent / "data" / "realization_envelope" + + +# --------------------------------------------------------------------------- # +# Builders # +# --------------------------------------------------------------------------- # + + +def _web_envelope( + *, + os_values: tuple[str, ...] = ("linux", "windows"), + closed: bool = False, + name: str = "web-family", + selection: str | None = None, +) -> RealizationEnvelopeModel: + return RealizationEnvelopeModel( + id="web-family", + scope=EnvelopeScope.SCENARIO, + domains={ + "scenario_name": ExactDomain(value=name), + "node_type": ExactDomain(value="vm"), + "os": EnumDomain(values=list(os_values)), + }, + bindings=[ + EnvelopeBinding(path="name", scope=EnvelopeScope.SCENARIO, posture=Posture.EXACT, domain="scenario_name"), + EnvelopeBinding(path="nodes.web.type", scope=EnvelopeScope.NODE, posture=Posture.EXACT, domain="node_type"), + EnvelopeBinding(path="nodes.web.os", scope=EnvelopeScope.FIELD, posture=Posture.CONSTRAINED, domain="os"), + ], + closure=( + [ClosureOverlay(path="", scope=EnvelopeScope.SCENARIO, closure=Closure.CLOSED_WORLD)] if closed else [] + ), + witness_policy=(WitnessPolicy(selections={"os": selection}) if selection is not None else None), + ) + + +def _instantiate(yaml_text: str) -> InstantiatedScenario: + return instantiate_scenario(parse_sdl(yaml_text)) + + +def _leaf_enum_env(values: set[str], envelope_id: str = "leaf") -> RealizationEnvelopeModel: + """Single-leaf envelope over an arbitrary enum universe (no SDL binding).""" + + return RealizationEnvelopeModel( + id=envelope_id, + scope=EnvelopeScope.FIELD, + domains={"leaf": EnumDomain(values=sorted(values))}, + bindings=[EnvelopeBinding(path="x", scope=EnvelopeScope.FIELD, posture=Posture.CONSTRAINED, domain="leaf")], + ) + + +def _leaf_int_interval_env(lower: int, upper: int, envelope_id: str = "leaf") -> RealizationEnvelopeModel: + return RealizationEnvelopeModel( + id=envelope_id, + scope=EnvelopeScope.FIELD, + domains={"leaf": NumericIntervalDomain(numeric_type=NumericType.INTEGER, lower=lower, upper=upper)}, + bindings=[EnvelopeBinding(path="x", scope=EnvelopeScope.FIELD, posture=Posture.CONSTRAINED, domain="leaf")], + ) + + +# --------------------------------------------------------------------------- # +# Contract fixtures # +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize("path", sorted((_DATA / "valid").glob("*.json")), ids=lambda p: p.stem) +def test_valid_fixtures_parse(path: Path) -> None: + model = RealizationEnvelopeModel.model_validate(json.loads(path.read_text(encoding="utf-8"))) + assert model.schema_version == "realization-envelope/v1" + # round-trips through JSON without loss + reparsed = RealizationEnvelopeModel.model_validate(json.loads(model.model_dump_json())) + assert reparsed == model + + +@pytest.mark.parametrize("path", sorted((_DATA / "invalid").glob("*.json")), ids=lambda p: p.stem) +def test_invalid_fixtures_rejected(path: Path) -> None: + with pytest.raises(ValidationError): + RealizationEnvelopeModel.model_validate(json.loads(path.read_text(encoding="utf-8"))) + + +def test_contract_rejects_unknown_field() -> None: + with pytest.raises(ValidationError): + RealizationEnvelopeModel.model_validate({"id": "x", "scope": "scenario", "unexpected": True}) + + +def test_contract_rejects_arbitrary_predicate_domain() -> None: + # The closed discriminated union is the portability guarantee: no predicate + # domain kind exists, so one cannot be expressed (envelope-semantics.md R3). + with pytest.raises(ValidationError): + RealizationEnvelopeModel.model_validate( + { + "id": "x", + "scope": "field", + "domains": {"p": {"kind": "predicate", "expr": "lambda v: True"}}, + "bindings": [], + } + ) + + +# --------------------------------------------------------------------------- # +# Membership (R1, R3) # +# --------------------------------------------------------------------------- # + + +def test_member_accepts_in_envelope_instance() -> None: + env = _web_envelope() + inst = _instantiate("name: web-family\nnodes:\n web:\n type: vm\n os: linux\n") + assert member(inst, env).holds + + +def test_member_rejects_out_of_domain_value() -> None: + env = _web_envelope(os_values=("linux",)) + inst = _instantiate("name: web-family\nnodes:\n web:\n type: vm\n os: windows\n") + result = member(inst, env) + assert not result.holds + assert any(d.code == "realization-envelope.membership.domain-mismatch" for d in result.diagnostics) + assert all(d.address == "nodes.web.os" for d in result.diagnostics) + + +def test_member_rejects_wrong_exact_name() -> None: + env = _web_envelope() + inst = _instantiate("name: other\nnodes:\n web:\n type: vm\n os: linux\n") + assert not member(inst, env).holds + + +def test_member_reports_absent_constrained_path() -> None: + env = _web_envelope() + inst = _instantiate("name: web-family\nnodes:\n web:\n type: vm\n") + result = member(inst, env) + assert not result.holds + assert any(d.code == "realization-envelope.membership.path-absent" for d in result.diagnostics) + + +def test_member_closed_world_rejects_unspecified_dimension() -> None: + env = _web_envelope(closed=True) + ok = _instantiate("name: web-family\nnodes:\n web:\n type: vm\n os: linux\n") + assert member(ok, env).holds + extra = _instantiate('name: web-family\nversion: "2.0"\nnodes:\n web:\n type: vm\n os: linux\n') + result = member(extra, env) + assert not result.holds + assert any(d.code == "realization-envelope.membership.closed-world-extra" for d in result.diagnostics) + + +def test_member_diagnostics_are_secret_free() -> None: + env = _web_envelope(os_values=("linux",)) + inst = _instantiate("name: web-family\nnodes:\n web:\n type: vm\n os: windows\n") + for diagnostic in member(inst, env).diagnostics: + # Diagnostics name paths, ids, and domain kinds — never raw values (R8). + assert "windows" not in diagnostic.message + assert diagnostic.domain == "realization-envelope" + + +def test_member_governed_reference_and_numeric_interval() -> None: + env = RealizationEnvelopeModel( + id="host", + scope=EnvelopeScope.NODE, + domains={ + "name": ExactDomain(value="s"), + "vm": ExactDomain(value="vm"), + "cpu": NumericIntervalDomain(numeric_type=NumericType.INTEGER, lower=1, upper=4), + }, + bindings=[ + EnvelopeBinding(path="name", scope=EnvelopeScope.SCENARIO, posture=Posture.EXACT, domain="name"), + EnvelopeBinding(path="nodes.h.type", scope=EnvelopeScope.NODE, posture=Posture.EXACT, domain="vm"), + EnvelopeBinding( + path="nodes.h.resources.cpu", scope=EnvelopeScope.FIELD, posture=Posture.CONSTRAINED, domain="cpu" + ), + ], + ) + within = _instantiate("name: s\nnodes:\n h:\n type: vm\n resources:\n ram: 2048\n cpu: 2\n") + outside = _instantiate("name: s\nnodes:\n h:\n type: vm\n resources:\n ram: 2048\n cpu: 9\n") + assert member(within, env).holds + assert not member(outside, env).holds + + +# --------------------------------------------------------------------------- # +# Subsumption (R4) # +# --------------------------------------------------------------------------- # + + +def test_subsumption_reflexive() -> None: + env = _web_envelope() + assert subsumes(env, env).holds + + +def test_subsumption_wider_offered_contains_narrower() -> None: + offered = _leaf_enum_env({"a", "b", "c"}) + requested = _leaf_enum_env({"a", "b"}) + assert subsumes(offered, requested).holds + assert not subsumes(requested, offered).holds + + +def test_subsumption_requested_open_where_offered_constrained() -> None: + offered = _leaf_enum_env({"a", "b"}) + requested = RealizationEnvelopeModel( + id="req", + scope=EnvelopeScope.FIELD, + domains={"leaf": EnumDomain(values=["a", "b"])}, + bindings=[EnvelopeBinding(path="x", scope=EnvelopeScope.FIELD, posture=Posture.OPEN)], + ) + result = subsumes(offered, requested) + assert not result.holds + assert any(d.code == "realization-envelope.subsumption.requested-unconstrained" for d in result.diagnostics) + + +def test_subsumption_closure_mismatch() -> None: + offered = _web_envelope(closed=True) + requested = _web_envelope(closed=False) + result = subsumes(offered, requested) + assert not result.holds + assert any(d.code == "realization-envelope.subsumption.closure-mismatch" for d in result.diagnostics) + + +def test_subsumption_numeric_interval_types() -> None: + number = RealizationEnvelopeModel( + id="num", + scope=EnvelopeScope.FIELD, + domains={"leaf": NumericIntervalDomain(numeric_type=NumericType.NUMBER, lower=0.0, upper=10.0)}, + bindings=[EnvelopeBinding(path="x", scope=EnvelopeScope.FIELD, posture=Posture.CONSTRAINED, domain="leaf")], + ) + integer = _leaf_int_interval_env(2, 5) + # An integer sub-interval fits inside a real super-interval... + assert subsumes(number, integer).holds + # ...but a real interval is not contained in an integer one (it has non-ints). + assert not subsumes(integer, number).holds + + +# --------------------------------------------------------------------------- # +# Witness (R5) # +# --------------------------------------------------------------------------- # + + +def test_witness_is_deterministic_and_in_envelope() -> None: + env = _web_envelope() + first = witness(env) + second = witness(env) + assert first.scenario is not None + assert second.scenario is not None + assert first.scenario.model_dump(mode="json") == second.scenario.model_dump(mode="json") + assert member(first.scenario, env).holds + + +def test_witness_default_selects_lexicographically_first_enum() -> None: + env = _web_envelope(os_values=("windows", "linux")) + result = witness(env) + assert result.scenario is not None + assert result.scenario.nodes["web"].os.value == "linux" + + +def test_witness_policy_overrides_default() -> None: + env = _web_envelope(selection="windows") + result = witness(env) + assert result.scenario is not None + assert result.scenario.nodes["web"].os.value == "windows" + + +def test_witness_under_specified_envelope_yields_diagnostic() -> None: + # Binds os but not the required scenario name/node type: no valid witness. + env = RealizationEnvelopeModel( + id="partial", + scope=EnvelopeScope.FIELD, + domains={"os": EnumDomain(values=["linux"])}, + bindings=[ + EnvelopeBinding(path="nodes.web.os", scope=EnvelopeScope.FIELD, posture=Posture.CONSTRAINED, domain="os") + ], + ) + result = witness(env) + assert result.scenario is None + assert result.diagnostics + assert result.diagnostics[0].code.startswith("realization-envelope.witness.") + + +def test_witness_rejects_list_indexed_path() -> None: + env = RealizationEnvelopeModel( + id="listy", + scope=EnvelopeScope.SCENARIO, + domains={"name": ExactDomain(value="s"), "v": ExactDomain(value="x")}, + bindings=[ + EnvelopeBinding(path="name", scope=EnvelopeScope.SCENARIO, posture=Posture.EXACT, domain="name"), + EnvelopeBinding( + path="forwarding_agents[0].id", scope=EnvelopeScope.FIELD, posture=Posture.EXACT, domain="v" + ), + ], + ) + result = witness(env) + assert result.scenario is None + assert any(d.code == "realization-envelope.witness.no-witness" for d in result.diagnostics) + + +# --------------------------------------------------------------------------- # +# Negative probes (R6) # +# --------------------------------------------------------------------------- # + + +def _probe_is_out_of_envelope(payload: dict, env: RealizationEnvelopeModel) -> bool: + try: + instance = instantiate_scenario(Scenario.model_validate(payload)) + except (ValidationError, SDLValidationError, SDLInstantiationError): + # invalid SDL is never a member; any other exception is a real bug and must propagate + return True + return not member(instance, env).holds + + +def test_negative_probes_are_all_out_of_envelope() -> None: + env = _web_envelope(closed=True) + probes, diagnostics = generate_negative_probes(env) + assert not diagnostics + assert probes + variations = {p.variation for p in probes} + assert "value-outside-domain" in variations + assert "extra-dimension" in variations + assert "omitted-required-exact" in variations + for probe in probes: + assert _probe_is_out_of_envelope(probe.payload, env), probe.path + + +def test_negative_probes_without_witness_report_diagnostic() -> None: + env = RealizationEnvelopeModel( + id="no-witness", + scope=EnvelopeScope.FIELD, + domains={"os": EnumDomain(values=["linux"])}, + bindings=[ + EnvelopeBinding(path="nodes.web.os", scope=EnvelopeScope.FIELD, posture=Posture.CONSTRAINED, domain="os") + ], + ) + probes, diagnostics = generate_negative_probes(env) + assert probes == () + assert any(d.code == "realization-envelope.negative-probe.no-witness" for d in diagnostics) + + +# --------------------------------------------------------------------------- # +# Property tests: decidability, determinism, set inclusion # +# --------------------------------------------------------------------------- # + +_UNIVERSE = ("a", "b", "c", "d") +_enum_subsets = st.sets(st.sampled_from(_UNIVERSE), min_size=1) + + +@settings(max_examples=200, deadline=None) +@given(offered=_enum_subsets, requested=_enum_subsets) +def test_property_subsumption_is_enum_set_inclusion(offered: set[str], requested: set[str]) -> None: + result = subsumes(_leaf_enum_env(offered, "off"), _leaf_enum_env(requested, "req")) + assert result.holds == (requested <= offered) + + +@settings(max_examples=100, deadline=None) +@given(a=_enum_subsets, b=_enum_subsets, c=_enum_subsets) +def test_property_subsumption_transitive(a: set[str], b: set[str], c: set[str]) -> None: + env_a, env_b, env_c = _leaf_enum_env(a, "a"), _leaf_enum_env(b, "b"), _leaf_enum_env(c, "c") + if subsumes(env_a, env_b).holds and subsumes(env_b, env_c).holds: + assert subsumes(env_a, env_c).holds + + +@settings(max_examples=100, deadline=None) +@given( + lo=st.integers(min_value=-20, max_value=20), + span=st.integers(min_value=0, max_value=20), + lo2=st.integers(min_value=-20, max_value=20), + span2=st.integers(min_value=0, max_value=20), +) +def test_property_integer_interval_subsumption_matches_int_ranges(lo: int, span: int, lo2: int, span2: int) -> None: + offered = _leaf_int_interval_env(lo, lo + span, "off") + requested = _leaf_int_interval_env(lo2, lo2 + span2, "req") + expected = set(range(lo2, lo2 + span2 + 1)) <= set(range(lo, lo + span + 1)) + assert subsumes(offered, requested).holds == expected + + +@settings(max_examples=50, deadline=None) +@given(os_values=st.sets(st.sampled_from(("linux", "windows")), min_size=1)) +def test_property_witness_is_member_and_deterministic(os_values: set[str]) -> None: + env = _web_envelope(os_values=tuple(sorted(os_values))) + first = witness(env) + second = witness(env) + assert first.scenario is not None + assert first.scenario.model_dump(mode="json") == second.scenario.model_dump(mode="json") + assert member(first.scenario, env).holds + + +@settings(max_examples=50, deadline=None) +@given(os_values=st.sets(st.sampled_from(("linux", "windows")), min_size=1)) +def test_property_negative_probes_out_of_envelope(os_values: set[str]) -> None: + env = _web_envelope(os_values=tuple(sorted(os_values)), closed=True) + probes, diagnostics = generate_negative_probes(env) + assert not diagnostics + for probe in probes: + assert _probe_is_out_of_envelope(probe.payload, env), probe.path + + +# --------------------------------------------------------------------------- # +# Record domains + open posture (R2) # +# --------------------------------------------------------------------------- # + + +def _record_node_envelope(*, open_os: bool = False, record_overrideable: bool = False) -> RealizationEnvelopeModel: + bindings = [ + EnvelopeBinding(path="name", scope=EnvelopeScope.SCENARIO, posture=Posture.EXACT, domain="name"), + EnvelopeBinding( + path="nodes.web", + scope=EnvelopeScope.NODE, + posture=Posture.CONSTRAINED, + domain="node", + overrideable=record_overrideable, + ), + ] + if open_os: + bindings.append(EnvelopeBinding(path="nodes.web.os", scope=EnvelopeScope.FIELD, posture=Posture.OPEN)) + return RealizationEnvelopeModel( + id="record-node", + scope=EnvelopeScope.SCENARIO, + domains={ + "name": ExactDomain(value="rec"), + "vm": ExactDomain(value="vm"), + "os": EnumDomain(values=["linux", "windows"]), + "node": RecordDomain(fields={"type": "vm", "os": "os"}, extra=False), + }, + bindings=bindings, + ) + + +def test_record_domain_membership_and_witness() -> None: + env = _record_node_envelope() + result = witness(env) + assert result.scenario is not None + assert member(result.scenario, env).holds + # A closed record rejects an undeclared field on the node. + extra = _instantiate('name: rec\nnodes:\n web:\n type: vm\n os: linux\n os_version: "9"\n') + rejected = member(extra, env) + assert not rejected.holds + assert any(d.code == "realization-envelope.membership.closed-world-extra" for d in rejected.diagnostics) + + +def test_open_posture_widens_overrideable_record_leaf() -> None: + # The record binding is marked overrideable, so opening os at the field scope + # legally widens the record's os constraint (most-specific-wins, R2). + env = _record_node_envelope(open_os=True, record_overrideable=True) + linux = _instantiate("name: rec\nnodes:\n web:\n type: vm\n os: linux\n") + windows = _instantiate("name: rec\nnodes:\n web:\n type: vm\n os: windows\n") + assert member(linux, env).holds + assert member(windows, env).holds + + +def test_widening_non_overrideable_inherited_value_is_invalid() -> None: + # Same shape but the record is NOT overrideable: opening os would widen a fixed + # inherited value, which R2 makes an ill-formed envelope. The relation denies. + env = _record_node_envelope(open_os=True, record_overrideable=False) + linux = _instantiate("name: rec\nnodes:\n web:\n type: vm\n os: linux\n") + result = member(linux, env) + assert not result.holds + assert any(d.code == "realization-envelope.invalid.non-overrideable-widen" for d in result.diagnostics) + witness_result = witness(env) + assert witness_result.scenario is None + assert any(d.code == "realization-envelope.invalid.non-overrideable-widen" for d in witness_result.diagnostics) + assert not subsumes(env, env).holds + + +def test_subsumption_over_governed_and_boolean_domains() -> None: + def governed(refs: list[str], name: str, authority: str = "reg") -> RealizationEnvelopeModel: + return RealizationEnvelopeModel( + id=name, + scope=EnvelopeScope.FIELD, + domains={"leaf": GovernedReferenceDomain(authority=authority, allowed_refs=refs)}, + bindings=[EnvelopeBinding(path="x", scope=EnvelopeScope.FIELD, posture=Posture.CONSTRAINED, domain="leaf")], + ) + + assert subsumes(governed(["a", "b", "c"], "o"), governed(["a"], "r")).holds + assert not subsumes(governed(["a"], "o"), governed(["a", "b"], "r")).holds + # Authority scopes the refs: overlapping ref strings under different authorities + # must NOT subsume (governed-reference authority boundary — codex security finding). + assert not subsumes( + governed(["a", "b"], "o", authority="registry-x"), + governed(["a"], "r", authority="registry-y"), + ).holds + + +def test_subsumption_governed_reference_rejects_cross_kind() -> None: + # A governed-reference domain and a raw enum with the same strings must not + # subsume in either direction: the enum values are not authority-scoped refs. + governed = RealizationEnvelopeModel( + id="g", + scope=EnvelopeScope.FIELD, + domains={"leaf": GovernedReferenceDomain(authority="reg", allowed_refs=["a", "b"])}, + bindings=[EnvelopeBinding(path="x", scope=EnvelopeScope.FIELD, posture=Posture.CONSTRAINED, domain="leaf")], + ) + enum = _leaf_enum_env({"a"}, "e") + assert not subsumes(governed, enum).holds + assert not subsumes(enum, governed).holds + + def boolean(value: bool | None, name: str) -> RealizationEnvelopeModel: + return RealizationEnvelopeModel( + id=name, + scope=EnvelopeScope.FIELD, + domains={"leaf": BooleanDomain(value=value)}, + bindings=[EnvelopeBinding(path="x", scope=EnvelopeScope.FIELD, posture=Posture.CONSTRAINED, domain="leaf")], + ) + + assert subsumes(boolean(None, "o"), boolean(True, "r")).holds + assert not subsumes(boolean(True, "o"), boolean(None, "r")).holds + + +# --------------------------------------------------------------------------- # +# Deterministic selection rules (R5 default policy / R6 variation) # +# --------------------------------------------------------------------------- # + + +def test_default_witness_selection_rules() -> None: + assert default_witness_value(ExactDomain(value=5)) == (5, None) + assert default_witness_value(EnumDomain(values=["b", "a", "c"])) == ("a", None) + assert default_witness_value(BooleanDomain()) == (False, None) + assert default_witness_value(BooleanDomain(value=True)) == (True, None) + assert default_witness_value(GovernedReferenceDomain(authority="r", allowed_refs=["z", "a"])) == ("a", None) + assert default_witness_value(NumericIntervalDomain(numeric_type=NumericType.INTEGER, lower=1, upper=8)) == ( + 1, + None, + ) + assert default_witness_value( + NumericIntervalDomain(numeric_type=NumericType.INTEGER, lower=1, upper=8, lower_closed=False) + ) == (2, None) + assert default_witness_value(NumericIntervalDomain(numeric_type=NumericType.NUMBER, lower=0.0, upper=1.0)) == ( + 0.0, + None, + ) + # An open-lower but bounded real interval is non-empty: pick the interior midpoint. + assert default_witness_value( + NumericIntervalDomain(numeric_type=NumericType.NUMBER, lower=0.0, upper=1.0, lower_closed=False) + ) == (0.5, None) + # An integer interval that admits no integer (open both ends, adjacent bounds) has no witness. + value, error = default_witness_value( + NumericIntervalDomain( + numeric_type=NumericType.INTEGER, lower=1, upper=2, lower_closed=False, upper_closed=False + ) + ) + assert value is None and error is not None + + +def test_out_of_domain_variation_rules() -> None: + assert out_of_domain_value(ExactDomain(value="x")) == "x-out-of-envelope" + assert out_of_domain_value(ExactDomain(value=3)) == 4 + assert out_of_domain_value(ExactDomain(value=True)) is False + assert out_of_domain_value(BooleanDomain(value=True)) is False + assert out_of_domain_value(BooleanDomain()) is _MISSING + assert out_of_domain_value(EnumDomain(values=["a", "b"])) not in {"a", "b"} + assert out_of_domain_value(EnumDomain(values=[1, 2])) == 3 + assert out_of_domain_value(GovernedReferenceDomain(authority="r", allowed_refs=["a", "b"])) not in {"a", "b"} + assert out_of_domain_value(NumericIntervalDomain(numeric_type=NumericType.INTEGER, lower=1, upper=4)) == 5 + assert ( + out_of_domain_value( + NumericIntervalDomain(numeric_type=NumericType.NUMBER, lower=0.0, upper=1.0, upper_closed=False) + ) + == 1.0 + ) diff --git a/specs/formal/realization/envelope-semantics.md b/specs/formal/realization/envelope-semantics.md index 86fadb6e4..eeac71357 100644 --- a/specs/formal/realization/envelope-semantics.md +++ b/specs/formal/realization/envelope-semantics.md @@ -31,11 +31,22 @@ Out of scope: ## Realization Status -This spec is design authority. It is not yet executable. The relation helper, -schema carrier, fixtures, property tests, target-conformance integration, and -manifest evolution are downstream implementation work. - -Until that work lands: +This spec is design authority. The relation helper, the envelope expression +contract model, fixtures, and property tests are implemented by issue #668: + +- `aces_contracts.realization_envelope` carries the closed, versioned envelope + expression (`realization-envelope/v1`) — the admitted-fragment domain kinds, + scoped bindings, posture, closure, and witness policy of this note; +- `aces_sdl.realization_envelope` implements `member`, `subsumes`, `witness`, and + `generate_negative_probes` as one deterministic engine over that contract. + +The **schema carrier** (a published `contracts/schemas/` artifact with a +publication-ledger entry), **backend-manifest carriage** (R7), and +**target-conformance integration** (replacing the #663 `reference_scenario` +bridge) remain downstream siblings. The envelope contract is intentionally +unpublished until manifest carriage lands, so its shape can still evolve. + +Until that downstream work lands: - SEM-218 remains the active exact/constrained/open realization authority; - `backend-manifest-v2.realization_support` remains the coarse capability and From f10821c8d4ad6c3bd291d5e41085adcf34b9a227 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:36:42 -0700 Subject: [PATCH 02/15] chore(deps): bump the github-actions group with 7 updates (#707) * Bump the github-actions group with 7 updates Bumps the github-actions group with 7 updates: | Package | From | To | | --- | --- | --- | | [actions/checkout](https://github.com/actions/checkout) | `6.0.2` | `7.0.0` | | [actions/setup-python](https://github.com/actions/setup-python) | `6.2.0` | `6.3.0` | | [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) | `8.0.0` | `8.3.0` | | [actions/upload-artifact](https://github.com/actions/upload-artifact) | `4.6.2` | `7.0.1` | | [actions/download-artifact](https://github.com/actions/download-artifact) | `4.3.0` | `8.0.1` | | [SonarSource/sonarqube-scan-action](https://github.com/sonarsource/sonarqube-scan-action) | `7.1.0` | `8.2.0` | | [googleapis/release-please-action](https://github.com/googleapis/release-please-action) | `4.4.1` | `5.0.0` | Updates `actions/checkout` from 6.0.2 to 7.0.0 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0) Updates `actions/setup-python` from 6.2.0 to 6.3.0 - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/a309ff8b426b58ec0e2a45f0f869d46889d02405...ece7cb06caefa5fff74198d8649806c4678c61a1) Updates `astral-sh/setup-uv` from 8.0.0 to 8.3.0 - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](https://github.com/astral-sh/setup-uv/compare/cec208311dfd045dd5311c1add060b2062131d57...d31148d669074a8d0a63714ba94f3201e7020bc3) Updates `actions/upload-artifact` from 4.6.2 to 7.0.1 - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/ea165f8d65b6e75b540449e92b4886f43607fa02...043fb46d1a93c77aae656e7c1c64a875d1fc6a0a) Updates `actions/download-artifact` from 4.3.0 to 8.0.1 - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/d3f86a106a0bac45b974a628896c90dbdf5c8093...3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c) Updates `SonarSource/sonarqube-scan-action` from 7.1.0 to 8.2.0 - [Release notes](https://github.com/sonarsource/sonarqube-scan-action/releases) - [Commits](https://github.com/sonarsource/sonarqube-scan-action/compare/299e4b793aaa83bf2aba7c9c14bedbb485688ec4...713881670b6b3676cda39549040e2d88c70d582e) Updates `googleapis/release-please-action` from 4.4.1 to 5.0.0 - [Release notes](https://github.com/googleapis/release-please-action/releases) - [Changelog](https://github.com/googleapis/release-please-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/googleapis/release-please-action/compare/5c625bfb5d1ff62eadeeb3772007f7f66fdcf071...45996ed1f6d02564a971a2fa1b5860e934307cf7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/setup-python dependency-version: 6.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: astral-sh/setup-uv dependency-version: 8.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions - dependency-name: actions/upload-artifact dependency-version: 7.0.1 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/download-artifact dependency-version: 8.0.1 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: SonarSource/sonarqube-scan-action dependency-version: 8.2.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: googleapis/release-please-action dependency-version: 5.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] * ci: correct stale action pin comments in group bump Dependabot bumped the pinned SHAs but left two version comments on the previous major: - actions/checkout 9c091bb is v7.0.0 (comment said # v6) - SonarSource/sonarqube-scan-action 713881 is v8.2.0 (comment said # v7) Update the comments to match the pinned SHAs so the audit trail is accurate. No SHA changes; action behavior is unchanged. --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Brad Edwards --- .github/workflows/ci.yml | 34 ++++++++++++++-------------- .github/workflows/pr-title-lint.yml | 4 ++-- .github/workflows/release-please.yml | 10 ++++---- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f6d82aa8..a3bb99945 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,14 +20,14 @@ jobs: verify: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: fetch-depth: 0 - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.12" - name: Install uv - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8 + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8 - name: Resolve policy base revision id: base run: | @@ -58,7 +58,7 @@ jobs: uv tool run --from 'nox[uv]==2026.4.10' nox -f noxfile.py -s verify -- "${verify_args[@]}" - name: Upload coverage report if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: coverage-report path: implementations/python/coverage.xml @@ -66,12 +66,12 @@ jobs: fuzz: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.12" - name: Install uv - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8 + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8 - name: Run fuzz session run: uv tool run --from 'nox[uv]==2026.4.10' nox -f noxfile.py -s fuzz @@ -82,12 +82,12 @@ jobs: runs-on: ubuntu-latest continue-on-error: true steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.12" - name: Install uv - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8 + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8 - name: Probe for a container runtime id: runtime run: | @@ -110,17 +110,17 @@ jobs: runs-on: ubuntu-latest continue-on-error: true steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.12" - name: Install uv - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8 + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8 - name: Run OSV-scanner (advisory) run: uv tool run --from 'nox[uv]==2026.4.10' nox -f noxfile.py -s osv_scan - name: Upload OSV-scanner report if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: osv-scanner-report path: implementations/python/osv-scanner-report.json @@ -131,14 +131,14 @@ jobs: needs: [verify] if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: fetch-depth: 0 - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: coverage-report path: implementations/python/ - name: SonarCloud Scan - uses: SonarSource/sonarqube-scan-action@299e4b793aaa83bf2aba7c9c14bedbb485688ec4 # v7 + uses: SonarSource/sonarqube-scan-action@713881670b6b3676cda39549040e2d88c70d582e # v8 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/pr-title-lint.yml b/.github/workflows/pr-title-lint.yml index d2ba7b1ae..b65f78b46 100644 --- a/.github/workflows/pr-title-lint.yml +++ b/.github/workflows/pr-title-lint.yml @@ -34,10 +34,10 @@ jobs: # tools/check_pr_title.py must not be able to weaken its own required # check (codex review finding, issue #567). The shared validator is still # exercised against the PR's own code by the test suite in ci.yml. - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: ref: ${{ github.event.pull_request.base.sha }} - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.12" - name: Validate PR title diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 638c6e304..18e3ed957 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -36,7 +36,7 @@ jobs: release_created: ${{ steps.rp.outputs.release_created }} tag_name: ${{ steps.rp.outputs.tag_name }} steps: - - uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071 # v4.4.1 + - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 id: rp with: token: ${{ secrets.GITHUB_TOKEN }} @@ -52,16 +52,16 @@ jobs: contents: write # upload the built distributions to the Release id-token: write # OIDC trusted publishing to PyPI (no stored token) steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: fetch-depth: 0 - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.12" - name: Install uv - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8 + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8 - name: Build the corpus-bundled wheel + sdist run: uv build --out-dir dist implementations/python @@ -114,7 +114,7 @@ jobs: contents: read pull-requests: write steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: fetch-depth: 0 - name: Open the back-merge PR (main -> dev) From 631f2fc390948213435c3ff5f2794cab2cb6236e Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Tue, 7 Jul 2026 23:17:10 -0700 Subject: [PATCH 03/15] test: add example corpus non-vacuity guard (#709) Add example corpus non-vacuity guard --- implementations/python/tests/test_scenarios.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/implementations/python/tests/test_scenarios.py b/implementations/python/tests/test_scenarios.py index ab710eb0d..59d8f6224 100644 --- a/implementations/python/tests/test_scenarios.py +++ b/implementations/python/tests/test_scenarios.py @@ -114,6 +114,11 @@ def test_returns_empty_for_missing_directory(self, tmp_path): assert find_scenarios(tmp_path / "missing") == [] +def test_example_scenario_corpus_is_nonempty(): + """A stale corpus root must fail loudly, not collect zero parametrized cases.""" + assert EXAMPLE_SCENARIOS, f"No example SDL scenarios found under {EXAMPLES_DIR} (glob '*.sdl.yaml')" + + @pytest.mark.parametrize("path", EXAMPLE_SCENARIOS, ids=lambda path: path.name) def test_example_scenarios_load(path): """Every example SDL should load successfully from disk.""" From a6036f4cb0049c42217f28304070fc3b197d35b9 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Wed, 8 Jul 2026 22:53:24 -0700 Subject: [PATCH 04/15] feat(sdl): add DSL-115 authoring specificity helper (#710) * feat(sdl): add DSL-115 authoring specificity helper * refactor(sdl): simplify specificity path resolver * refactor(sdl): narrow specificity sequence resolver --- .../issue-73-dsl-115-specificity-preflight.md | 153 ++++++++++++++++++ .../explicitness-realization-semantics.md | 14 ++ .../python/packages/aces_sdl/explicitness.py | 128 ++++++++++++++- .../test_dsl_115_authoring_specificity.py | 100 ++++++++++++ 4 files changed, 392 insertions(+), 3 deletions(-) create mode 100644 docs/decisions/issue-73-dsl-115-specificity-preflight.md create mode 100644 implementations/python/tests/test_dsl_115_authoring_specificity.py diff --git a/docs/decisions/issue-73-dsl-115-specificity-preflight.md b/docs/decisions/issue-73-dsl-115-specificity-preflight.md new file mode 100644 index 000000000..15376c730 --- /dev/null +++ b/docs/decisions/issue-73-dsl-115-specificity-preflight.md @@ -0,0 +1,153 @@ +# Issue 73 / DSL-115 Specificity Preflight + +Date: 2026-07-08 + +Requirement DSL-115 asks for author-selectable specificity across scenario, +participant, evaluation, and experiment concerns. This note is architecture +preflight only. It does not define new SDL syntax, schemas, validators, +runtime behavior, conformance behavior, or an implementation plan. + +## Architecture Decisions + +- Treat DSL-115 as a specificity contract over existing concern-owning + surfaces, not as a new global abstraction level. +- Reuse the existing exact/constrained/open vocabulary from SEM-218 + (`aces_sdl.explicitness`) for authored intent. If richer machine-checkable + domains are needed, align with ADR-070 and the realization envelope domain + model instead of creating a second constraint language. +- Open or underspecified forms are allowed only where the owning SDL, + contract, or semantic rule explicitly admits them. Silence remains + fail-closed. +- Scenario specificity belongs in the SDL scenario model, variables, + references, instantiation, explicitness metadata, and realization-envelope + semantics. Do not move scenario meaning into experiment-core records. +- Participant specificity belongs in the participant surfaces that already own + it: `Agent`, participant action contracts, observation boundaries, behavior + specifications, and participant runtime contracts. +- Evaluation specificity must respect ADR-073: SDL objectives express + observable success through `conditions`; graded scoring, reward, derived + measures, and evaluator outputs belong in the experiment/evaluator plane. +- Experiment specificity belongs in experiment-core task, apparatus context, + run, study, capture, evidence, and derived-measure contracts. Do not add + tasks, runs, studies, or evaluator scoring records to SDL. +- Constraints that affect semantics must be machine-checkable: typed + variables, `allowed_values`, governed references, domain descriptors, and + semantic invariants. Prose-only constraints are explanatory, not normative. +- Preserve authored specificity provenance through instantiation, compilation, + planning, runtime disclosure, and persisted snapshots. Substituting a + variable must not falsely promote a constrained authored value to exact. +- Published schema changes must follow the contract publication path: + `contracts/schemas/**`, `contracts/schema-publication-manifest.json`, + generated-schema parity, and `schema_bundle()` compatibility. + +## Canonical Incumbents + +- Authority chain: ADR-009, ADR-061, `contracts/README.md`, + `.gc/plan-rules.md`, `tools/check_repo_policy.py`, + `tools/check_schema_publication.py`, `tools/check_generated_schemas.py`, and + `tools/verify_all.py`. +- SDL parsing and model shape: `aces_sdl.parser.parse_sdl`, + `aces_sdl.parser.parse_sdl_file`, `yaml.safe_load`, hashmap key + preservation, variable-key rejection, and `SDLModel(extra="forbid")`. +- SDL semantic validation: `SemanticValidator`, `SDLParseError`, + `SDLValidationError`, `SDLInstantiationError`, SDL diagnostics, references, + variables, and instantiation specs. +- Specificity and realization: `specs/formal/realization/explicitness-and-realization.md`, + ADR-070, `specs/formal/realization/envelope-semantics.md`, + `aces_sdl.explicitness`, `RealizationEnvelopeModel`, + `aces_sdl.realization_envelope`, `CompiledRealizationRequirement`, + `realization_support_diagnostics`, `realization_disclosure`, and + `RuntimeSnapshot.realization_provenance`. +- Participant semantics: `Agent`, `ParticipantActionContract`, + `ParticipantObservationBoundary`, `ParticipantBehaviorSpecification`, + participant behavior validators, and participant runtime contracts. +- Evaluation and experiment contracts: ADR-055, ADR-064, ADR-068, ADR-073, + experiment task, apparatus context, run, study, capture spec, evidence + record, and derived-measure contracts. +- Runtime and API cross-cutting behavior: `Diagnostic`, `OperationStatus`, + `OperationReceipt`, `ControlPlaneSecurityConfig`, role authorization, + request-size guards, idempotency keys, request fingerprints, audit records, + and the redacted control-plane error handler. +- Conformance: `run_target_conformance(reference_scenario=...)` as the + current #663 bridge, backend manifest validation, profile loading, and + `schema_bundle()`. + +## Cross-Cutting Layers + +- Parse/model gate: continue through `yaml.safe_load`, normalized SDL models, + closed Pydantic models, hashmap key preservation, variable-key rejection, and + removed scoring-section rejection. +- Reference/semantic gate: preserve fail-closed reference resolution, collect + all semantic diagnostics, and report paths and concern kinds rather than raw + payload values. +- Instantiation/config gate: use the existing variable declaration, type, + default, `allowed_values`, substitution, unresolved-token, and revalidation + pipeline. Do not introduce a new env-binding path for specificity. +- Contract/schema gate: use `ContractModel`, published schemas, schema + manifests, generated bundles, and `x-aces-invariants` for semantic rules + that schemas cannot express directly. +- Manifest/planner gate: use backend manifest realization declarations, + `resolve_realization_concern()`, support diagnostics, and runtime + disclosure. Unsupported exact or constrained requirements are diagnostics, + not silent approximation. +- Runtime/control-plane gate: keep strict default auth, bearer/proxy identity + validation, role checks, request-size limits, idempotency, audit records, and + redacted FastAPI exception envelopes. +- OS and secret-exposure gate: do not place credentials, tokens, private keys, + environment dumps, process argv, backend-private state, hidden truth, host + paths, full tracebacks, or raw logs in SDL artifacts, contracts, fixtures, + diagnostics, audit records, persisted snapshots, or examples. +- Error-envelope gate: use SDL exceptions, `Diagnostic`, `OperationStatus`, + and redacted API errors. Diagnostics should identify address, field path, + domain, scope, or kind, not sensitive authored or realized values. +- Persistence/evidence gate: store provenance, digests, references, and + explicit evidence records through the established contracts. Do not hide new + specificity state in metadata blobs, tags, or untyped log payloads. + +## Extensibility Boundary + +The required seam is per-concern specificity metadata on the owning surface, +parameterized by concern kind, scope, domain, closure, carriage, and +provenance. Existing anchors are `ExplicitnessRecord`, ADR-070 realization +domains, and the planner concern-kind mapping in +`resolve_realization_concern()`. + +Future variations should add governed concern kinds, domain descriptors, +semantic invariants, or schema fields at the owning boundary. They should not +require re-editing every backend, duplicating schemas, or introducing a +top-level `specificity` bag. + +## Gotchas And Anti-Patterns + +- Do not add a universal `specificity:` root section or a generic + `open|constrained|exact` field disconnected from the owning concern. +- Do not treat missing data as open unless the owning rule explicitly says so. +- Do not conflate authored explicitness with backend + `RealizationSupportMode`, participant feature support levels, semantic + profiles, validation strength, backend profiles, or experiment study + membership. +- Do not reintroduce SDL scoring or reward language for evaluation + specificity. +- Do not encode normative domains in free-form `constraints`, notes, comments, + or ungoverned extension maps. +- Do not promote variable-substituted values to exact when the authored form was + constrained. +- Do not resolve ambiguous references by first match or by parser order. +- Do not persist new runtime state in `RuntimeSnapshot.metadata`, tags, audit + detail blobs, or raw backend logs. +- Do not add implementation logic under `implementations/python/src/aces/**`; + that tree is a compatibility surface. +- Do not create duplicate parsers, validators, exception hierarchies, schema + bundles, workflow scripts, logging paths, or security gates. + +## Non-Goals + +- No implementation of DSL-115 in this preflight. +- No new SDL syntax, schema publication, manifest version, compiler behavior, + planner behavior, runtime API behavior, or conformance behavior in this note. +- No change to requirement status, coverage, or traceability records. +- No relocation of experiment tasks, runs, studies, rewards, scoring, or + evaluator results into SDL. +- No solver, backend callback language, external query language, or hidden + policy engine for constraints. +- No implementation plan. diff --git a/docs/explain/reference/explicitness-realization-semantics.md b/docs/explain/reference/explicitness-realization-semantics.md index 7c087036d..4ba4c1183 100644 --- a/docs/explain/reference/explicitness-realization-semantics.md +++ b/docs/explain/reference/explicitness-realization-semantics.md @@ -123,6 +123,20 @@ additional exact requirement kinds for other artifact families; that should require adding governed terms and shared semantic checks, not rewriting planner or conformance call sites. +## Authoring Specificity + +`classify_authoring_specificity()` is the DSL-115 helper for reviewing +specificity across existing owned surfaces. It reuses the SEM-218 classifier +for exact and constrained authored values, and it records open or +underspecified concerns only when the owning caller supplies the explicit path +in `admitted_open_paths`. + +Do not treat a missing field as open by default. The helper's admitted-open +paths are authoring metadata for surfaces whose SDL, contract, or semantic rule +already allows an underspecified form; they are not backend-realization +permission and do not replace manifest `realization_support`, realization +envelopes, or experiment-core contracts. + ## Part 2 Implementation Boundary The typed compiler emission and planner gate must preserve the classifier output diff --git a/implementations/python/packages/aces_sdl/explicitness.py b/implementations/python/packages/aces_sdl/explicitness.py index d611a3791..559770159 100644 --- a/implementations/python/packages/aces_sdl/explicitness.py +++ b/implementations/python/packages/aces_sdl/explicitness.py @@ -2,7 +2,8 @@ from __future__ import annotations -from collections.abc import Iterable +import re +from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass from enum import Enum @@ -16,12 +17,15 @@ "ExplicitnessProvenance", "ExplicitnessRecord", "ExplicitnessResult", + "classify_authoring_specificity", + "classify_model_explicitness", "classify_scenario_explicitness", "derive_instantiated_explicitness", ] _OPEN_ENUM_SENTINELS = frozenset({"unknown", "other"}) _EXPLICITNESS_ORDER: dict[ExplicitnessClass, int] = {} +_PATH_TOKEN_RE = re.compile(r"[^.\[\]]+|\[\d+\]") class ExplicitnessClass(str, Enum): @@ -71,12 +75,60 @@ class ExplicitnessResult: def classify_scenario_explicitness(scenario: BaseModel) -> ExplicitnessResult: """Classify authored SDL declarations on ``scenario``.""" - variables = getattr(scenario, "variables", {}) + return classify_model_explicitness(scenario) + + +def classify_model_explicitness( + model: BaseModel, + *, + variables: dict[str, Variable] | None = None, +) -> ExplicitnessResult: + """Classify authored declarations on any closed ACES Pydantic model.""" + + variables = variables if variables is not None else getattr(model, "variables", {}) classifier = _ExplicitnessClassifier(variables) - classifier.visit(scenario, "") + classifier.visit(model, "") return ExplicitnessResult(records=dict(classifier.records), errors=tuple(classifier.errors)) +def classify_authoring_specificity( + model: BaseModel, + *, + admitted_open_paths: Iterable[str] = (), + variables: dict[str, Variable] | None = None, +) -> ExplicitnessResult: + """Classify DSL-115 specificity with opt-in open/underspecified paths. + + Missing fields are not open by default. A caller must supply the exact + owning-surface path for any concern whose owning rule explicitly admits an + open or underspecified form. This records authoring review metadata only; + it is not backend-realization permission. + """ + + result = classify_model_explicitness(model, variables=variables) + records = dict(result.records) + errors = list(result.errors) + for path in admitted_open_paths: + normalized_path = path.strip() if isinstance(path, str) else "" + if not normalized_path: + errors.append("Cannot classify open specificity for an empty path") + continue + if not _path_addresses_model_surface(model, normalized_path): + errors.append( + f"Cannot classify open specificity for '{normalized_path}': path does not resolve to a model surface" + ) + continue + records.setdefault( + normalized_path, + ExplicitnessRecord( + path=normalized_path, + classification=ExplicitnessClass.OPEN, + reason="explicitly admitted open/underspecified concern; not backend-realization permission", + ), + ) + return ExplicitnessResult(records=records, errors=tuple(errors)) + + def derive_instantiated_explicitness( raw_scenario: BaseModel, instantiated_scenario: BaseModel, @@ -237,6 +289,76 @@ def _authored_paths(value: object) -> set[str]: return collector.paths +def _path_addresses_model_surface(root: object, path: str) -> bool: + tokens = _path_tokens(path) + if not tokens: + return False + + current = root + for index, token in enumerate(tokens): + is_final = index == len(tokens) - 1 + found, current = _resolve_path_token(current, token, allow_unset_model_field=is_final) + if not found: + return False + return True + + +def _path_tokens(path: str) -> tuple[str | int, ...]: + tokens: list[str | int] = [] + for raw in _PATH_TOKEN_RE.findall(path): + tokens.append(int(raw[1:-1]) if raw.startswith("[") else raw) + return tuple(tokens) + + +def _resolve_path_token( + current: object, + token: str | int, + *, + allow_unset_model_field: bool, +) -> tuple[bool, object]: + resolved: tuple[bool, object] = (False, None) + if isinstance(token, int): + resolved = _resolve_sequence_path_token(current, token) + elif isinstance(current, BaseModel): + resolved = _resolve_model_path_token( + current, + token, + allow_unset_model_field=allow_unset_model_field, + ) + elif isinstance(current, Mapping): + resolved = _resolve_mapping_path_token(current, token) + return resolved + + +def _resolve_sequence_path_token(current: object, token: int) -> tuple[bool, object]: + resolved: tuple[bool, object] = (False, None) + if isinstance(current, Sequence) and not isinstance(current, (str, bytes, bytearray)) and 0 <= token < len(current): + resolved = (True, current[token]) + return resolved + + +def _resolve_model_path_token( + current: BaseModel, + token: str, + *, + allow_unset_model_field: bool, +) -> tuple[bool, object]: + resolved: tuple[bool, object] = (False, None) + if token in type(current).model_fields: + value = getattr(current, token) + missing_unset_optional = value is None and token not in current.model_fields_set + if not missing_unset_optional or allow_unset_model_field: + resolved = (True, value) + return resolved + + +def _resolve_mapping_path_token(current: Mapping[object, object], token: str) -> tuple[bool, object]: + resolved: tuple[bool, object] = (False, None) + if token in current: + resolved = (True, current[token]) + return resolved + + class _AuthoredPathCollector: def __init__(self) -> None: self.paths: set[str] = set() diff --git a/implementations/python/tests/test_dsl_115_authoring_specificity.py b/implementations/python/tests/test_dsl_115_authoring_specificity.py new file mode 100644 index 000000000..06b0881e3 --- /dev/null +++ b/implementations/python/tests/test_dsl_115_authoring_specificity.py @@ -0,0 +1,100 @@ +"""DSL-115 author-selectable specificity over owned concern surfaces.""" + +from __future__ import annotations + +import textwrap + +from aces_contracts.contracts import ExperimentReferenceModel +from aces_sdl import parse_sdl +from aces_sdl.explicitness import ExplicitnessClass, classify_authoring_specificity + + +def _scenario_with_specificity_levels(): + return parse_sdl( + textwrap.dedent(""" + name: dsl-115-specificity + version: ${scenario_version} + variables: + scenario_version: + type: string + default: 1.0.0 + allowed_values: [1.0.0, 1.1.0] + participant_label: + type: string + default: red operator + allowed_values: [red operator, autonomous red team] + entities: + red: + role: red + agents: + red-agent: + entity: red + description: ${participant_label} + conditions: + objective-complete: + command: /bin/true + interval: 5 + objectives: + assess: + agent: red-agent + success: + conditions: [objective-complete] + """) + ) + + +def test_specificity_does_not_treat_missing_fields_as_open_by_default(): + result = classify_authoring_specificity(_scenario_with_specificity_levels()) + + assert "agents.red-agent.initial_knowledge" not in result.records + assert "objectives.assess.window" not in result.records + + +def test_specificity_classifies_scenario_participant_and_evaluation_concerns(): + result = classify_authoring_specificity( + _scenario_with_specificity_levels(), + admitted_open_paths=( + "version", + "objectives.assess.success.conditions[0]", + "agents.red-agent.initial_knowledge", + "objectives.assess.window", + ), + ) + + assert result.records["version"].classification is ExplicitnessClass.CONSTRAINED + assert result.records["agents.red-agent.description"].classification is ExplicitnessClass.CONSTRAINED + assert result.records["objectives.assess.success.conditions[0]"].classification is ExplicitnessClass.EXACT + assert result.records["agents.red-agent.initial_knowledge"].classification is ExplicitnessClass.OPEN + assert result.records["objectives.assess.window"].classification is ExplicitnessClass.OPEN + + +def test_specificity_rejects_admitted_open_paths_outside_model_surface(): + result = classify_authoring_specificity( + _scenario_with_specificity_levels(), + admitted_open_paths=( + "agents.red-agent.initialKnowlege", + "agents.ghost.initial_knowledge", + "objectives.assess.window", + ), + ) + + assert "agents.red-agent.initialKnowlege" not in result.records + assert "agents.ghost.initial_knowledge" not in result.records + assert result.records["objectives.assess.window"].classification is ExplicitnessClass.OPEN + assert result.errors == ( + "Cannot classify open specificity for 'agents.red-agent.initialKnowlege': " + "path does not resolve to a model surface", + "Cannot classify open specificity for 'agents.ghost.initial_knowledge': " + "path does not resolve to a model surface", + ) + + +def test_specificity_classifies_experiment_contract_concerns_without_new_sdl_syntax(): + reference = ExperimentReferenceModel(ref_kind="scenario", ref_id="benchmark-a") + + result = classify_authoring_specificity(reference, admitted_open_paths=("ref_version",)) + + assert result.records["ref_kind"].classification is ExplicitnessClass.EXACT + assert result.records["ref_id"].classification is ExplicitnessClass.EXACT + assert result.records["ref_version"].classification is ExplicitnessClass.OPEN + assert "ref_digest" not in result.records From ac5f69719c87f7fb4fa85aa2c3a141c834a6ec73 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Wed, 8 Jul 2026 22:54:15 -0700 Subject: [PATCH 05/15] feat: add experiment authoring-input contract and MCP authoring surface (#711) * feat: add experiment authoring-input contract and MCP authoring surface Adds experiment-authoring-input-v1 (ExperimentSpecModel), a pre-run experiment specification that binds a task to a run plan (seeds, episode controls, red-variant selection, condition allocation) before execution. It is a separate input contract that references the archival experiment-core outputs rather than weakening them, mirroring how sdl-authoring-input-v1 relates to instantiated-scenario-v1. Ships a loader, MCP authoring tools (experiment_scaffold / experiment_validate / experiment_get_example), worked examples, fixtures, and a discovery test, with ADR-074 deciding the surface and amendments to ADR-055 and ADR-069. Closes #675 * test: cover experiment authoring loader/tools; cut MCP tool cognitive complexity Addresses the SonarCloud quality gate on PR #711: - Move experiment MCP tool bodies into module-level helpers so register() stays under the cognitive-complexity threshold (S3776). - Add tests/test_experiment_authoring.py covering the loader (parse/load/find), the MCP tool helpers, and the run-plan/red-variant model validators, raising new-code coverage. --- .../run-plan-both-allocation-and-count.json | 65 + ...run-plan-neither-allocation-nor-count.json | 26 + .../valid/reference.json | 123 ++ contracts/schema-publication-manifest.json | 10 + .../experiment-authoring-input-v1.json | 1864 +++++++++++++++++ docs/decisions/adrs/README.md | 2 + ...r-055-experiment-core-contract-boundary.md | 1 + ...adr-069-cage-2-replication-architecture.md | 6 + ...iment-authoring-input-contract-boundary.md | 191 ++ docs/decisions/adrs/adr-index.yaml | 10 + .../techvault-red-tactic-sweep.exp.yaml | 105 + .../experiments/techvault-smoke-run.exp.yaml | 30 + .../packages/aces_contracts/contracts.py | 149 ++ .../aces_contracts/experiment_spec.py | 76 + .../packages/aces_contracts/versions.py | 1 + .../python/packages/aces_mcp/server.py | 10 +- .../aces_mcp/tools/experiment_authoring.py | 236 +++ .../packages/aces_mcp/tools/operations.py | 5 + implementations/python/tests/paths.py | 1 + .../tests/test_example_schema_conformance.py | 35 +- .../python/tests/test_experiment_authoring.py | 167 ++ .../python/tests/test_runtime_contracts.py | 16 + specs/formal/experiment-core/README.md | 53 +- tools/policy/adr_policy.yaml | 1 + 24 files changed, 3178 insertions(+), 5 deletions(-) create mode 100644 contracts/fixtures/experiment-core/experiment-authoring-input-v1/invalid/run-plan-both-allocation-and-count.json create mode 100644 contracts/fixtures/experiment-core/experiment-authoring-input-v1/invalid/run-plan-neither-allocation-nor-count.json create mode 100644 contracts/fixtures/experiment-core/experiment-authoring-input-v1/valid/reference.json create mode 100644 contracts/schemas/experiment-core/experiment-authoring-input-v1.json create mode 100644 docs/decisions/adrs/adr-074-experiment-authoring-input-contract-boundary.md create mode 100644 examples/experiments/techvault-red-tactic-sweep.exp.yaml create mode 100644 examples/experiments/techvault-smoke-run.exp.yaml create mode 100644 implementations/python/packages/aces_contracts/experiment_spec.py create mode 100644 implementations/python/packages/aces_mcp/tools/experiment_authoring.py create mode 100644 implementations/python/tests/test_experiment_authoring.py diff --git a/contracts/fixtures/experiment-core/experiment-authoring-input-v1/invalid/run-plan-both-allocation-and-count.json b/contracts/fixtures/experiment-core/experiment-authoring-input-v1/invalid/run-plan-both-allocation-and-count.json new file mode 100644 index 000000000..4065f901f --- /dev/null +++ b/contracts/fixtures/experiment-core/experiment-authoring-input-v1/invalid/run-plan-both-allocation-and-count.json @@ -0,0 +1,65 @@ +{ + "schema_version": "experiment-authoring-input/v1", + "spec_id": "spec-invalid-both-run-count-v1", + "spec_version": "1.0.0", + "title": "Invalid: run plan declares both allocation and target_run_count", + "description": "A run plan must declare exactly one run-count source; this one declares both, so it must fail schema and model validation.", + "task_ref": { + "ref_kind": "task", + "ref_id": "task-techvault-red-team-v1", + "ref_version": "1.0.0" + }, + "run_plan": { + "stochastic_controls": [ + { + "control_id": "episode-seed", + "role": "seed", + "value": 123456 + } + ], + "episode_control": { + "turn_order": "sequential", + "max_steps": 100, + "termination_rule": "Terminate each episode after 100 logical steps (fixed horizon)." + }, + "target_run_count": 100, + "allocation": { + "allocation_unit": "run", + "allocation_method": "balanced", + "compared_conditions": [ + "cond-aggressive", + "cond-stealthy" + ], + "condition_assignments": { + "cond-aggressive": { + "condition_id": "cond-aggressive", + "factor_levels": { + "red-tactic": "aggressive" + }, + "required_parameters": [ + { + "name": "red_tactic", + "value": "aggressive", + "value_kind": "protocol" + } + ] + }, + "cond-stealthy": { + "condition_id": "cond-stealthy", + "factor_levels": { + "red-tactic": "stealthy" + }, + "required_parameters": [ + { + "name": "red_tactic", + "value": "stealthy", + "value_kind": "protocol" + } + ] + } + }, + "target_runs_per_condition": 100, + "replication_policy": "100 independent seeded runs per red-tactic condition." + } + } +} diff --git a/contracts/fixtures/experiment-core/experiment-authoring-input-v1/invalid/run-plan-neither-allocation-nor-count.json b/contracts/fixtures/experiment-core/experiment-authoring-input-v1/invalid/run-plan-neither-allocation-nor-count.json new file mode 100644 index 000000000..69a9c7391 --- /dev/null +++ b/contracts/fixtures/experiment-core/experiment-authoring-input-v1/invalid/run-plan-neither-allocation-nor-count.json @@ -0,0 +1,26 @@ +{ + "schema_version": "experiment-authoring-input/v1", + "spec_id": "spec-invalid-neither-run-count-v1", + "spec_version": "1.0.0", + "title": "Invalid: run plan declares neither allocation nor target_run_count", + "description": "A run plan must declare exactly one run-count source; this one declares neither, so it must fail schema and model validation.", + "task_ref": { + "ref_kind": "task", + "ref_id": "task-techvault-red-team-v1", + "ref_version": "1.0.0" + }, + "run_plan": { + "stochastic_controls": [ + { + "control_id": "episode-seed", + "role": "seed", + "value": 123456 + } + ], + "episode_control": { + "turn_order": "sequential", + "max_steps": 100, + "termination_rule": "Terminate each episode after 100 logical steps (fixed horizon)." + } + } +} diff --git a/contracts/fixtures/experiment-core/experiment-authoring-input-v1/valid/reference.json b/contracts/fixtures/experiment-core/experiment-authoring-input-v1/valid/reference.json new file mode 100644 index 000000000..442c57aae --- /dev/null +++ b/contracts/fixtures/experiment-core/experiment-authoring-input-v1/valid/reference.json @@ -0,0 +1,123 @@ +{ + "schema_version": "experiment-authoring-input/v1", + "spec_id": "spec-techvault-red-tactic-sweep-v1", + "spec_version": "1.0.0", + "title": "TechVault red-tactic sweep", + "description": "Pre-run design comparing aggressive vs stealthy red tactics over the fixed TechVault task, with seeded replication.", + "task_ref": { + "ref_kind": "task", + "ref_id": "task-techvault-red-team-v1", + "ref_version": "1.0.0" + }, + "intended_scenario_ref": { + "ref_kind": "scenario-snapshot", + "ref_id": "scenario-techvault", + "ref_version": "2026-05-26", + "ref_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111" + }, + "apparatus_intent": { + "required_capabilities": [ + "workflow-results", + "evaluation-results" + ], + "notes": [ + "Any backend that realizes evaluation-results may execute this design." + ] + }, + "run_plan": { + "stochastic_controls": [ + { + "control_id": "episode-seed", + "role": "seed", + "value": 123456, + "description": "Base RNG seed; per-run seeds derived deterministically for replication." + } + ], + "episode_control": { + "turn_order": "sequential", + "max_steps": 100, + "termination_rule": "Terminate each episode after 100 logical steps (fixed horizon).", + "description": "Fixed-horizon episode with sequential turns." + }, + "allocation": { + "allocation_unit": "run", + "allocation_method": "balanced", + "compared_conditions": [ + "cond-aggressive", + "cond-stealthy" + ], + "condition_assignments": { + "cond-aggressive": { + "condition_id": "cond-aggressive", + "factor_levels": { + "red-tactic": "aggressive" + }, + "required_parameters": [ + { + "name": "red_tactic", + "value": "aggressive", + "value_kind": "protocol" + } + ] + }, + "cond-stealthy": { + "condition_id": "cond-stealthy", + "factor_levels": { + "red-tactic": "stealthy" + }, + "required_parameters": [ + { + "name": "red_tactic", + "value": "stealthy", + "value_kind": "protocol" + } + ] + } + }, + "target_runs_per_condition": 100, + "randomization_unit": "run", + "replication_policy": "100 independent seeded runs per red-tactic condition." + }, + "red_variant_selections": { + "aggressive": { + "variant_id": "aggressive", + "agent_ref": "red-agent", + "description": "Aggressive red tactic: direct exploitation path." + }, + "stealthy": { + "variant_id": "stealthy", + "agent_ref": "red-agent", + "description": "Stealthy red tactic: low-and-slow exploration." + } + }, + "clock_intent": { + "clock_id": "sim-clock", + "authority": "reference-backend", + "time_domain": "simulated" + } + }, + "factors": { + "red-tactic": { + "name": "Red tactic profile", + "factor_kind": "treatment", + "levels": [ + "aggressive", + "stealthy" + ] + } + }, + "capture_spec_refs": [ + { + "ref_kind": "capture-spec", + "ref_id": "capture-techvault-evidence-v1", + "ref_version": "1.0.0" + } + ], + "validity_notes": [ + { + "category": "internal", + "note": "Per-run seeds derive deterministically from the base seed.", + "mitigation": "Record derived seeds in each run's stochastic controls." + } + ] +} diff --git a/contracts/schema-publication-manifest.json b/contracts/schema-publication-manifest.json index 1f5156a7e..e1fa2c6e8 100644 --- a/contracts/schema-publication-manifest.json +++ b/contracts/schema-publication-manifest.json @@ -92,6 +92,16 @@ "content_hash": "565558814655c9fc3cd790fb441633622e4846d416ffaba43812143024e33ae3" } }, + { + "contract_id": "experiment-authoring-input-v1", + "schema_path": "contracts/schemas/experiment-core/experiment-authoring-input-v1.json", + "stability": "draft", + "content_hash": "0373103adfa21acc45fb9db525f73603f79173660b372ea6b91de119771ea616", + "last_change": { + "summary": "Published the experiment authoring-input contract: a pre-run experiment design surface that references the archival experiment-core outputs (ADR-074, issue #675).", + "content_hash": "0373103adfa21acc45fb9db525f73603f79173660b372ea6b91de119771ea616" + } + }, { "contract_id": "experiment-capture-spec-v1", "schema_path": "contracts/schemas/experiment-core/experiment-capture-spec-v1.json", diff --git a/contracts/schemas/experiment-core/experiment-authoring-input-v1.json b/contracts/schemas/experiment-core/experiment-authoring-input-v1.json new file mode 100644 index 000000000..19a3f0e25 --- /dev/null +++ b/contracts/schemas/experiment-core/experiment-authoring-input-v1.json @@ -0,0 +1,1864 @@ +{ + "$defs": { + "ExperimentApparatusConstraintModel": { + "additionalProperties": false, + "anyOf": [ + { + "properties": { + "allowed_processor_refs": { + "minItems": 1 + } + }, + "required": [ + "allowed_processor_refs" + ] + }, + { + "properties": { + "allowed_backend_refs": { + "minItems": 1 + } + }, + "required": [ + "allowed_backend_refs" + ] + }, + { + "properties": { + "required_manifest_refs": { + "minItems": 1 + } + }, + "required": [ + "required_manifest_refs" + ] + }, + { + "properties": { + "required_capabilities": { + "minItems": 1 + } + }, + "required": [ + "required_capabilities" + ] + }, + { + "properties": { + "notes": { + "minItems": 1 + } + }, + "required": [ + "notes" + ] + } + ], + "description": "Apparatus compatibility and capability constraints for a task.", + "properties": { + "allowed_backend_refs": { + "items": { + "$ref": "#/$defs/ExperimentBackendReferenceModel" + }, + "title": "Allowed Backend Refs", + "type": "array" + }, + "allowed_processor_refs": { + "items": { + "$ref": "#/$defs/ExperimentProcessorReferenceModel" + }, + "title": "Allowed Processor Refs", + "type": "array" + }, + "notes": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Notes", + "type": "array" + }, + "required_capabilities": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Required Capabilities", + "type": "array" + }, + "required_manifest_refs": { + "items": { + "$ref": "#/$defs/ExperimentManifestReferenceModel" + }, + "title": "Required Manifest Refs", + "type": "array" + } + }, + "title": "ExperimentApparatusConstraintModel", + "type": "object", + "x-aces-invariants": [ + { + "description": "Every allowed processor/backend identity reference must have a matching required manifest ref_id with matching manifest id, subject identity, and manifest schema version.", + "id": "apparatus-constraint-identity-manifest-resolves", + "inputs": [ + { + "contract_id": "experiment-task-v1", + "instance_path": "#/apparatus_constraints" + } + ], + "level": "error", + "validator": "aces_contracts.contracts.ExperimentApparatusConstraintModel._validate_allowed_identity_manifest_refs" + } + ] + }, + "ExperimentArtifactRefModel": { + "additionalProperties": false, + "description": "Reference to an artifact that supports a task, run, apparatus, or study.", + "properties": { + "artifact_id": { + "minLength": 1, + "title": "Artifact Id", + "type": "string" + }, + "checksum": { + "$ref": "#/$defs/ExperimentChecksumModel" + }, + "created_at": { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "title": "Created At", + "type": "string" + }, + "description": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "media_type": { + "minLength": 1, + "title": "Media Type", + "type": "string" + }, + "role": { + "enum": [ + "protocol", + "metric-definition", + "scenario-snapshot", + "manifest", + "apparatus-evidence", + "observation", + "result", + "analysis", + "report", + "export", + "starter-file", + "evaluator", + "subtask", + "gold-step", + "milestone", + "human-assistance", + "scaffold", + "baseline", + "cost-resource-trace", + "other" + ], + "title": "Role", + "type": "string" + }, + "satisfies_refs": { + "items": { + "$ref": "#/$defs/ExperimentEvidenceSatisfactionReferenceModel" + }, + "title": "Satisfies Refs", + "type": "array" + }, + "sensitivity": { + "enum": [ + "public", + "internal", + "restricted", + "redacted" + ], + "title": "Sensitivity", + "type": "string" + }, + "size_bytes": { + "minimum": 0, + "title": "Size Bytes", + "type": "integer" + }, + "source": { + "minLength": 1, + "title": "Source", + "type": "string" + }, + "uri": { + "minLength": 1, + "title": "Uri", + "type": "string" + } + }, + "required": [ + "artifact_id", + "role", + "media_type", + "uri", + "checksum", + "size_bytes", + "created_at", + "source", + "sensitivity" + ], + "title": "ExperimentArtifactRefModel", + "type": "object" + }, + "ExperimentBackendReferenceModel": { + "additionalProperties": false, + "description": "Reference constrained to a backend identity.", + "properties": { + "ref_id": { + "minLength": 1, + "title": "Ref Id", + "type": "string" + }, + "ref_kind": { + "const": "backend", + "title": "Ref Kind", + "type": "string" + }, + "ref_version": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ref Version" + } + }, + "required": [ + "ref_kind", + "ref_id" + ], + "title": "ExperimentBackendReferenceModel", + "type": "object" + }, + "ExperimentCaptureSpecReferenceModel": { + "additionalProperties": false, + "description": "Reference constrained to a declarative capture specification.", + "properties": { + "ref_id": { + "minLength": 1, + "title": "Ref Id", + "type": "string" + }, + "ref_kind": { + "const": "capture-spec", + "title": "Ref Kind", + "type": "string" + }, + "ref_version": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ref Version" + } + }, + "required": [ + "ref_kind", + "ref_id" + ], + "title": "ExperimentCaptureSpecReferenceModel", + "type": "object" + }, + "ExperimentChecksumModel": { + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "algorithm": { + "const": "sha256" + } + }, + "required": [ + "algorithm" + ] + }, + "then": { + "properties": { + "value": { + "pattern": "^[A-Fa-f0-9]{64}$" + } + } + } + }, + { + "if": { + "properties": { + "algorithm": { + "const": "sha384" + } + }, + "required": [ + "algorithm" + ] + }, + "then": { + "properties": { + "value": { + "pattern": "^[A-Fa-f0-9]{96}$" + } + } + } + }, + { + "if": { + "properties": { + "algorithm": { + "const": "sha512" + } + }, + "required": [ + "algorithm" + ] + }, + "then": { + "properties": { + "value": { + "pattern": "^[A-Fa-f0-9]{128}$" + } + } + } + }, + { + "if": { + "properties": { + "algorithm": { + "const": "blake3" + } + }, + "required": [ + "algorithm" + ] + }, + "then": { + "properties": { + "value": { + "pattern": "^[A-Fa-f0-9]{64}$" + } + } + } + } + ], + "description": "Checksum metadata for an experiment-core artifact reference.", + "properties": { + "algorithm": { + "enum": [ + "sha256", + "sha384", + "sha512", + "blake3" + ], + "title": "Algorithm", + "type": "string" + }, + "value": { + "minLength": 1, + "pattern": "^[A-Fa-f0-9]+$", + "title": "Value", + "type": "string" + } + }, + "required": [ + "algorithm", + "value" + ], + "title": "ExperimentChecksumModel", + "type": "object" + }, + "ExperimentClockContextModel": { + "additionalProperties": false, + "description": "Clock authority and time-domain metadata for run interpretation.", + "properties": { + "authority": { + "minLength": 1, + "title": "Authority", + "type": "string" + }, + "clock_id": { + "minLength": 1, + "title": "Clock Id", + "type": "string" + }, + "synchronization": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Synchronization" + }, + "time_domain": { + "enum": [ + "wall-clock", + "monotonic", + "simulated", + "logical", + "other" + ], + "title": "Time Domain", + "type": "string" + } + }, + "required": [ + "clock_id", + "authority", + "time_domain" + ], + "title": "ExperimentClockContextModel", + "type": "object" + }, + "ExperimentConditionAssignmentModel": { + "additionalProperties": false, + "anyOf": [ + { + "properties": { + "required_refs": { + "minItems": 1 + } + }, + "required": [ + "required_refs" + ] + }, + { + "properties": { + "required_parameters": { + "minItems": 1 + } + }, + "required": [ + "required_parameters" + ] + } + ], + "description": "Concrete treatment-condition assignment criteria for study evaluation runs.", + "properties": { + "condition_id": { + "minLength": 1, + "title": "Condition Id", + "type": "string" + }, + "description": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "factor_levels": { + "additionalProperties": { + "minLength": 1, + "type": "string" + }, + "minProperties": 1, + "propertyNames": { + "minLength": 1 + }, + "title": "Factor Levels", + "type": "object" + }, + "required_parameters": { + "items": { + "$ref": "#/$defs/ExperimentConditionAssignmentParameterModel" + }, + "title": "Required Parameters", + "type": "array" + }, + "required_refs": { + "items": { + "$ref": "#/$defs/ExperimentConditionAssignmentReferenceModel" + }, + "title": "Required Refs", + "type": "array" + } + }, + "required": [ + "condition_id", + "factor_levels" + ], + "title": "ExperimentConditionAssignmentModel", + "type": "object" + }, + "ExperimentConditionAssignmentParameterModel": { + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "redaction": { + "enum": [ + "redacted", + "withheld" + ] + } + }, + "required": [ + "redaction" + ] + }, + "then": { + "properties": { + "value": { + "type": "null" + } + } + } + } + ], + "description": "Auditable parameter value that can ground a study condition assignment.", + "properties": { + "name": { + "minLength": 1, + "title": "Name", + "type": "string" + }, + "redaction": { + "const": "none", + "default": "none", + "title": "Redaction", + "type": "string" + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Value" + }, + "value_kind": { + "enum": [ + "configuration", + "protocol", + "apparatus", + "analysis" + ], + "title": "Value Kind", + "type": "string" + } + }, + "required": [ + "name", + "value", + "value_kind" + ], + "title": "ExperimentConditionAssignmentParameterModel", + "type": "object" + }, + "ExperimentConditionAssignmentReferenceModel": { + "additionalProperties": false, + "allOf": [ + { + "properties": { + "ref_digest": { + "type": "null" + }, + "ref_path": { + "type": "null" + } + } + } + ], + "description": "Auditable run-level reference that can ground a study condition assignment.", + "properties": { + "ref_digest": { + "anyOf": [ + { + "minLength": 1, + "pattern": "^(?:sha256:[A-Fa-f0-9]{64}|sha384:[A-Fa-f0-9]{96}|sha512:[A-Fa-f0-9]{128}|blake3:[A-Fa-f0-9]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ref Digest" + }, + "ref_id": { + "minLength": 1, + "title": "Ref Id", + "type": "string" + }, + "ref_kind": { + "enum": [ + "processor", + "backend", + "participant-implementation", + "scenario-snapshot", + "task", + "apparatus-context", + "manifest", + "profile", + "capability", + "measurement-channel" + ], + "title": "Ref Kind", + "type": "string" + }, + "ref_path": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ref Path" + }, + "ref_version": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ref Version" + } + }, + "required": [ + "ref_kind", + "ref_id" + ], + "title": "ExperimentConditionAssignmentReferenceModel", + "type": "object" + }, + "ExperimentEpisodeControlModel": { + "additionalProperties": false, + "description": "Declarative episode execution controls for a planned experiment.\n\nCaptures the pre-run execution-control facts \u2014 turn order, logical step\ncount, and episode termination \u2014 that ADR-069 requires for CAGE-2\nexecution-control equivalence but that the archival experiment-core\ncontracts only record after a run has executed.", + "properties": { + "description": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "max_steps": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Max Steps" + }, + "termination_condition_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Termination Condition Refs", + "type": "array", + "uniqueItems": true + }, + "termination_rule": { + "minLength": 1, + "title": "Termination Rule", + "type": "string" + }, + "turn_order": { + "enum": [ + "sequential", + "simultaneous", + "round-robin", + "scenario-defined", + "other" + ], + "title": "Turn Order", + "type": "string" + } + }, + "required": [ + "turn_order", + "termination_rule" + ], + "title": "ExperimentEpisodeControlModel", + "type": "object" + }, + "ExperimentEvidenceSatisfactionReferenceModel": { + "additionalProperties": false, + "description": "Evidence concept reference that an artifact claims to satisfy.", + "properties": { + "ref_id": { + "minLength": 1, + "title": "Ref Id", + "type": "string" + }, + "ref_kind": { + "const": "evidence", + "title": "Ref Kind", + "type": "string" + }, + "ref_version": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ref Version" + } + }, + "required": [ + "ref_kind", + "ref_id" + ], + "title": "ExperimentEvidenceSatisfactionReferenceModel", + "type": "object" + }, + "ExperimentManifestReferenceModel": { + "additionalProperties": false, + "allOf": [ + { + "properties": { + "ref_path": { + "type": "null" + } + } + }, + { + "if": { + "properties": { + "ref_digest": { + "type": "string" + } + }, + "required": [ + "ref_digest" + ] + }, + "then": { + "properties": { + "subject_ref": { + "properties": { + "ref_kind": { + "enum": [ + "processor", + "backend" + ] + } + }, + "required": [ + "ref_kind" + ] + } + }, + "required": [ + "subject_ref" + ] + } + }, + { + "if": { + "properties": { + "ref_digest": { + "type": "string" + }, + "subject_ref": { + "properties": { + "ref_kind": { + "const": "processor" + } + }, + "required": [ + "ref_kind" + ] + } + }, + "required": [ + "ref_digest", + "subject_ref" + ] + }, + "then": { + "properties": { + "ref_version": { + "const": "processor-manifest/v2" + } + } + } + }, + { + "if": { + "properties": { + "ref_digest": { + "type": "string" + }, + "subject_ref": { + "properties": { + "ref_kind": { + "const": "backend" + } + }, + "required": [ + "ref_kind" + ] + } + }, + "required": [ + "ref_digest", + "subject_ref" + ] + }, + "then": { + "properties": { + "ref_version": { + "const": "backend-manifest/v2" + } + } + } + }, + { + "if": { + "properties": { + "subject_ref": { + "type": "object" + } + }, + "required": [ + "subject_ref" + ] + }, + "then": { + "properties": { + "subject_ref": { + "properties": { + "ref_digest": { + "type": "null" + }, + "ref_path": { + "type": "null" + } + } + } + } + } + } + ], + "description": "Reference constrained to an apparatus or capability manifest.", + "properties": { + "ref_digest": { + "anyOf": [ + { + "minLength": 1, + "pattern": "^(?:sha256:[A-Fa-f0-9]{64}|sha384:[A-Fa-f0-9]{96}|sha512:[A-Fa-f0-9]{128}|blake3:[A-Fa-f0-9]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ref Digest" + }, + "ref_id": { + "minLength": 1, + "title": "Ref Id", + "type": "string" + }, + "ref_kind": { + "const": "manifest", + "title": "Ref Kind", + "type": "string" + }, + "ref_path": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ref Path" + }, + "ref_version": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ref Version" + }, + "subject_ref": { + "anyOf": [ + { + "$ref": "#/$defs/ExperimentReferenceModel" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "ref_kind", + "ref_id" + ], + "title": "ExperimentManifestReferenceModel", + "type": "object", + "x-aces-invariants": [ + { + "description": "Manifest digest qualifiers are limited to processor/backend manifest refs that can be checked against concrete manifest payload digests; manifest path qualifiers are not accepted in v1.", + "id": "manifest-reference-digest-scope-valid", + "inputs": [ + { + "contract_id": "experiment-task-v1", + "instance_path": "#/$defs/ExperimentManifestReferenceModel" + }, + { + "contract_id": "experiment-apparatus-context-v1", + "instance_path": "#/$defs/ExperimentManifestReferenceModel" + }, + { + "contract_id": "experiment-run-v1", + "instance_path": "#/$defs/ExperimentManifestReferenceModel" + } + ], + "level": "error", + "validator": "aces_contracts.contracts.ExperimentManifestReferenceModel._validate_manifest_reference_scope" + } + ] + }, + "ExperimentParameterModel": { + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "redaction": { + "enum": [ + "redacted", + "withheld" + ] + } + }, + "required": [ + "redaction" + ] + }, + "then": { + "properties": { + "value": { + "type": "null" + } + } + } + } + ], + "description": "Redaction-aware parameter captured for a task, run, or apparatus context.", + "properties": { + "name": { + "minLength": 1, + "title": "Name", + "type": "string" + }, + "redaction": { + "default": "none", + "enum": [ + "none", + "redacted", + "withheld" + ], + "title": "Redaction", + "type": "string" + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Value" + }, + "value_kind": { + "enum": [ + "configuration", + "protocol", + "apparatus", + "analysis", + "other" + ], + "title": "Value Kind", + "type": "string" + } + }, + "required": [ + "name", + "value", + "value_kind" + ], + "title": "ExperimentParameterModel", + "type": "object" + }, + "ExperimentProcessorReferenceModel": { + "additionalProperties": false, + "description": "Reference constrained to a processor identity.", + "properties": { + "ref_id": { + "minLength": 1, + "title": "Ref Id", + "type": "string" + }, + "ref_kind": { + "const": "processor", + "title": "Ref Kind", + "type": "string" + }, + "ref_version": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ref Version" + } + }, + "required": [ + "ref_kind", + "ref_id" + ], + "title": "ExperimentProcessorReferenceModel", + "type": "object" + }, + "ExperimentRedVariantSelectionModel": { + "additionalProperties": false, + "description": "Selection of one red-agent variant bound into a planned experiment.", + "properties": { + "agent_ref": { + "minLength": 1, + "title": "Agent Ref", + "type": "string" + }, + "description": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "parameters": { + "items": { + "$ref": "#/$defs/ExperimentParameterModel" + }, + "title": "Parameters", + "type": "array" + }, + "variant_id": { + "minLength": 1, + "title": "Variant Id", + "type": "string" + } + }, + "required": [ + "variant_id", + "agent_ref" + ], + "title": "ExperimentRedVariantSelectionModel", + "type": "object" + }, + "ExperimentReferenceModel": { + "additionalProperties": false, + "description": "Typed reference to an experiment-core or adjacent ACES artifact.", + "properties": { + "ref_digest": { + "anyOf": [ + { + "minLength": 1, + "pattern": "^(?:sha256:[A-Fa-f0-9]{64}|sha384:[A-Fa-f0-9]{96}|sha512:[A-Fa-f0-9]{128}|blake3:[A-Fa-f0-9]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ref Digest" + }, + "ref_id": { + "minLength": 1, + "title": "Ref Id", + "type": "string" + }, + "ref_kind": { + "enum": [ + "processor", + "backend", + "participant-implementation", + "scenario", + "scenario-snapshot", + "task", + "protocol", + "apparatus-context", + "run", + "metric-definition", + "result", + "study", + "manifest", + "profile", + "capability", + "capture-spec", + "evidence", + "evidence-record", + "derived-measure", + "measurement-channel", + "analysis-artifact", + "other" + ], + "title": "Ref Kind", + "type": "string" + }, + "ref_path": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ref Path" + }, + "ref_version": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ref Version" + } + }, + "required": [ + "ref_kind", + "ref_id" + ], + "title": "ExperimentReferenceModel", + "type": "object" + }, + "ExperimentRunAllocationPlanModel": { + "additionalProperties": false, + "description": "Structured run allocation, replication, and assignment plan.", + "properties": { + "allocation_method": { + "minLength": 1, + "title": "Allocation Method", + "type": "string" + }, + "allocation_unit": { + "minLength": 1, + "title": "Allocation Unit", + "type": "string" + }, + "blocking_factors": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Blocking Factors", + "type": "array", + "uniqueItems": true + }, + "compared_conditions": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Compared Conditions", + "type": "array", + "uniqueItems": true + }, + "condition_assignments": { + "additionalProperties": { + "$ref": "#/$defs/ExperimentConditionAssignmentModel" + }, + "minProperties": 1, + "propertyNames": { + "minLength": 1 + }, + "title": "Condition Assignments", + "type": "object" + }, + "randomization_unit": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Randomization Unit" + }, + "replication_policy": { + "minLength": 1, + "title": "Replication Policy", + "type": "string" + }, + "stopping_rule": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Stopping Rule" + }, + "target_runs_per_condition": { + "minimum": 1, + "title": "Target Runs Per Condition", + "type": "integer" + } + }, + "required": [ + "allocation_unit", + "allocation_method", + "compared_conditions", + "condition_assignments", + "target_runs_per_condition", + "replication_policy" + ], + "title": "ExperimentRunAllocationPlanModel", + "type": "object", + "x-aces-invariants": [ + { + "description": "Run-allocation compared_conditions, condition_assignments keys, embedded condition ids, blocking factor ids, factor-level combinations, and run-level criteria signatures must be internally coherent.", + "id": "run-allocation-condition-assignments-valid", + "inputs": [ + { + "contract_id": "experiment-study-v1", + "instance_path": "#/run_allocation" + } + ], + "level": "error", + "validator": "aces_contracts.contracts.ExperimentRunAllocationPlanModel._validate_condition_assignments" + } + ] + }, + "ExperimentRunPlanModel": { + "additionalProperties": false, + "description": "Pre-run replication, stochastic, episode, and red-variant plan.\n\nReuses the archival-family value models for stochastic controls, run\nallocation, and clock intent, and adds the authoring-only episode and\nred-variant selections. Exactly one of ``allocation`` (condition-based)\nor ``target_run_count`` (simple, no-condition) declares the run count.", + "oneOf": [ + { + "properties": { + "allocation": { + "not": { + "type": "null" + } + }, + "target_run_count": { + "type": "null" + } + }, + "required": [ + "allocation" + ] + }, + { + "properties": { + "allocation": { + "type": "null" + }, + "target_run_count": { + "not": { + "type": "null" + } + } + }, + "required": [ + "target_run_count" + ] + } + ], + "properties": { + "allocation": { + "anyOf": [ + { + "$ref": "#/$defs/ExperimentRunAllocationPlanModel" + }, + { + "type": "null" + } + ], + "default": null + }, + "clock_intent": { + "anyOf": [ + { + "$ref": "#/$defs/ExperimentClockContextModel" + }, + { + "type": "null" + } + ], + "default": null + }, + "episode_control": { + "$ref": "#/$defs/ExperimentEpisodeControlModel" + }, + "red_variant_selections": { + "additionalProperties": { + "$ref": "#/$defs/ExperimentRedVariantSelectionModel" + }, + "propertyNames": { + "minLength": 1 + }, + "title": "Red Variant Selections", + "type": "object" + }, + "stochastic_controls": { + "items": { + "$ref": "#/$defs/ExperimentStochasticControlModel" + }, + "minItems": 1, + "title": "Stochastic Controls", + "type": "array" + }, + "target_run_count": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Target Run Count" + } + }, + "required": [ + "stochastic_controls", + "episode_control" + ], + "title": "ExperimentRunPlanModel", + "type": "object", + "x-aces-invariants": [ + { + "description": "A run plan must declare exactly one of allocation or target_run_count, and every red-variant selection map key must equal its embedded variant_id.", + "id": "run-plan-exactly-one-run-count-source", + "inputs": [ + { + "contract_id": "experiment-authoring-input-v1", + "instance_path": "#/run_plan" + } + ], + "level": "error", + "validator": "aces_contracts.contracts.ExperimentRunPlanModel._validate_run_plan" + } + ] + }, + "ExperimentScenarioReferenceModel": { + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "ref_kind": { + "const": "scenario" + } + }, + "required": [ + "ref_kind" + ] + }, + "then": { + "properties": { + "ref_digest": { + "type": "null" + }, + "ref_path": { + "type": "null" + }, + "ref_version": { + "type": "null" + } + } + } + } + ], + "description": "Reference constrained to authored scenario material.", + "properties": { + "ref_digest": { + "anyOf": [ + { + "minLength": 1, + "pattern": "^(?:sha256:[A-Fa-f0-9]{64}|sha384:[A-Fa-f0-9]{96}|sha512:[A-Fa-f0-9]{128}|blake3:[A-Fa-f0-9]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ref Digest" + }, + "ref_id": { + "minLength": 1, + "title": "Ref Id", + "type": "string" + }, + "ref_kind": { + "enum": [ + "scenario", + "scenario-snapshot" + ], + "title": "Ref Kind", + "type": "string" + }, + "ref_path": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ref Path" + }, + "ref_version": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ref Version" + } + }, + "required": [ + "ref_kind", + "ref_id" + ], + "title": "ExperimentScenarioReferenceModel", + "type": "object" + }, + "ExperimentStochasticControlModel": { + "additionalProperties": false, + "description": "Seed, randomization, sampling, or scheduler control for reproducibility.", + "properties": { + "control_id": { + "minLength": 1, + "title": "Control Id", + "type": "string" + }, + "description": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "role": { + "enum": [ + "seed", + "randomization", + "sampling", + "scheduler", + "agent-policy", + "other" + ], + "title": "Role", + "type": "string" + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Value" + } + }, + "required": [ + "control_id", + "role" + ], + "title": "ExperimentStochasticControlModel", + "type": "object" + }, + "ExperimentStudyFactorModel": { + "additionalProperties": false, + "description": "Treatment, control, blocking, or apparatus factor for study analysis.", + "properties": { + "factor_kind": { + "enum": [ + "treatment", + "control", + "blocking", + "stratification", + "apparatus", + "other" + ], + "title": "Factor Kind", + "type": "string" + }, + "levels": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Levels", + "type": "array" + }, + "name": { + "minLength": 1, + "title": "Name", + "type": "string" + } + }, + "required": [ + "name", + "factor_kind" + ], + "title": "ExperimentStudyFactorModel", + "type": "object" + }, + "ExperimentTaskReferenceModel": { + "additionalProperties": false, + "description": "Reference constrained to an experiment task.", + "properties": { + "ref_id": { + "minLength": 1, + "title": "Ref Id", + "type": "string" + }, + "ref_kind": { + "const": "task", + "title": "Ref Kind", + "type": "string" + }, + "ref_version": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ref Version" + } + }, + "required": [ + "ref_kind", + "ref_id" + ], + "title": "ExperimentTaskReferenceModel", + "type": "object" + }, + "ExperimentValidityNoteModel": { + "additionalProperties": false, + "description": "Validity threat, limitation, or mitigation note for experiment interpretation.", + "properties": { + "category": { + "enum": [ + "construct", + "internal", + "external", + "conclusion", + "statistical", + "apparatus", + "reproducibility", + "security", + "other" + ], + "title": "Category", + "type": "string" + }, + "mitigation": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mitigation" + }, + "note": { + "minLength": 1, + "title": "Note", + "type": "string" + } + }, + "required": [ + "category", + "note" + ], + "title": "ExperimentValidityNoteModel", + "type": "object" + } + }, + "$id": "https://aces.dev/schemas/experiment-authoring-input-v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "Pre-run experiment authoring input: a design that binds a task to a run plan.\n\nThis is the authoring/input counterpart to the archival experiment-core\noutputs (run/study/apparatus-context). It references the separately\nauthored task (and optionally a scenario snapshot) and declares the\npre-run experimental design \u2014 apparatus intent, run plan, factors,\nintended capture, and validity notes \u2014 before any run executes. It is\nnever a run, study, or apparatus-context record (ADR-055 / ADR-074).", + "properties": { + "apparatus_intent": { + "anyOf": [ + { + "$ref": "#/$defs/ExperimentApparatusConstraintModel" + }, + { + "type": "null" + } + ], + "default": null + }, + "artifact_refs": { + "items": { + "$ref": "#/$defs/ExperimentArtifactRefModel" + }, + "title": "Artifact Refs", + "type": "array" + }, + "capture_spec_refs": { + "items": { + "$ref": "#/$defs/ExperimentCaptureSpecReferenceModel" + }, + "title": "Capture Spec Refs", + "type": "array" + }, + "description": { + "minLength": 1, + "title": "Description", + "type": "string" + }, + "factors": { + "additionalProperties": { + "$ref": "#/$defs/ExperimentStudyFactorModel" + }, + "propertyNames": { + "minLength": 1 + }, + "title": "Factors", + "type": "object" + }, + "intended_scenario_ref": { + "anyOf": [ + { + "$ref": "#/$defs/ExperimentScenarioReferenceModel" + }, + { + "type": "null" + } + ], + "default": null + }, + "run_plan": { + "$ref": "#/$defs/ExperimentRunPlanModel" + }, + "schema_version": { + "const": "experiment-authoring-input/v1", + "title": "Schema Version", + "type": "string" + }, + "spec_id": { + "minLength": 1, + "title": "Spec Id", + "type": "string" + }, + "spec_version": { + "minLength": 1, + "title": "Spec Version", + "type": "string" + }, + "task_ref": { + "$ref": "#/$defs/ExperimentTaskReferenceModel" + }, + "title": { + "minLength": 1, + "title": "Title", + "type": "string" + }, + "validity_notes": { + "items": { + "$ref": "#/$defs/ExperimentValidityNoteModel" + }, + "title": "Validity Notes", + "type": "array" + } + }, + "required": [ + "schema_version", + "spec_id", + "spec_version", + "title", + "description", + "task_ref", + "run_plan" + ], + "title": "ExperimentSpecModel", + "type": "object", + "x-aces-invariants": [ + { + "description": "When a run plan declares an allocation with blocking factors, every blocking factor must be a declared experiment-spec factor.", + "id": "experiment-spec-blocking-factors-declared", + "inputs": [ + { + "contract_id": "experiment-authoring-input-v1", + "instance_path": "#" + } + ], + "level": "error", + "validator": "aces_contracts.contracts.ExperimentSpecModel._validate_experiment_spec" + } + ], + "x-aces-semantic-profile": { + "contract_id": "experiment-authoring-input-v1", + "entry_schema_contract_id": "aces-semantic-invariants-v1", + "entry_schema_pointer": "#/$defs/AcesSemanticInvariantEntryModel", + "id": "aces-semantic-invariants-v1", + "keyword": "x-aces-invariants", + "required": true, + "uri": "https://aces.dev/schemas/semantic-invariants/v1" + } +} diff --git a/docs/decisions/adrs/README.md b/docs/decisions/adrs/README.md index 1f75263dc..3e55a377c 100644 --- a/docs/decisions/adrs/README.md +++ b/docs/decisions/adrs/README.md @@ -118,6 +118,7 @@ adr-070-realization-envelope-semantics adr-071-reusable-asset-trust-and-integrity-policy adr-072-validation-and-admission-profiles adr-073-scoring-reward-language-scope +adr-074-experiment-authoring-input-contract-boundary ``` | ADR | Title | Status | Date | @@ -196,3 +197,4 @@ adr-073-scoring-reward-language-scope | [071](adr-071-reusable-asset-trust-and-integrity-policy.md) | Reusable Asset Trust and Integrity Policy | accepted | 2026-07-05 | | [072](adr-072-validation-and-admission-profiles.md) | Validation and Admission Profiles | proposed | 2026-07-05 | | [073](adr-073-scoring-reward-language-scope.md) | Scoring and Reward Language Scope in the SDL | accepted | 2026-07-05 | +| [074](adr-074-experiment-authoring-input-contract-boundary.md) | Experiment Authoring-Input Contract Boundary | accepted | 2026-07-08 | diff --git a/docs/decisions/adrs/adr-055-experiment-core-contract-boundary.md b/docs/decisions/adrs/adr-055-experiment-core-contract-boundary.md index 1b59b7927..ccc32f477 100644 --- a/docs/decisions/adrs/adr-055-experiment-core-contract-boundary.md +++ b/docs/decisions/adrs/adr-055-experiment-core-contract-boundary.md @@ -293,3 +293,4 @@ and redaction patterns. | Date | Commit/PR | Summary | |------|-----------|---------| | 2026-06-12 | #482 | Recorded that the ADR's decision date is 2026-05-26 and it landed with experiment-core PR #422 on 2026-06-05. | +| 2026-07-08 | #675 | ADR-074 realizes the "explicit draft/authoring surface" anticipated in this ADR's Risks section as `experiment-authoring-input-v1`, a separate pre-run input contract; the archival contracts published here are unchanged. | diff --git a/docs/decisions/adrs/adr-069-cage-2-replication-architecture.md b/docs/decisions/adrs/adr-069-cage-2-replication-architecture.md index aea16c32b..e23731edd 100644 --- a/docs/decisions/adrs/adr-069-cage-2-replication-architecture.md +++ b/docs/decisions/adrs/adr-069-cage-2-replication-architecture.md @@ -286,3 +286,9 @@ adapter-driven replication. - Cross-repo work may drift if downstream issues do not link back to ACES requirements and design records. The workflow requires linked issues, PRs, and evidence readback. + +## Amendments + +| Date | Commit/PR | Summary | +|------|-----------|---------| +| 2026-07-08 | #675 | ADR-074 adds `experiment-authoring-input-v1`, a pre-run experiment authoring surface, giving REP-003 a home to declare the execution-control facts (turn order, step count, termination, red-agent variants, seeds, stochastic controls) this ADR requires for execution-control equivalence. It complements — does not replace — the SDL scenario authoring path this ADR routes CAGE-2 through, and adds no CAGE-specific schema. | diff --git a/docs/decisions/adrs/adr-074-experiment-authoring-input-contract-boundary.md b/docs/decisions/adrs/adr-074-experiment-authoring-input-contract-boundary.md new file mode 100644 index 000000000..43b650a57 --- /dev/null +++ b/docs/decisions/adrs/adr-074-experiment-authoring-input-contract-boundary.md @@ -0,0 +1,191 @@ +# ADR-074: Experiment Authoring-Input Contract Boundary + +## Status + +accepted + +## Date + +2026-07-08 + +## Classification + +Classification: FM2 +Required artifacts: ADR, formal-spec update, published schema, fixtures, +worked examples, MCP authoring tools, discovery/validation test, amendments to +ADR-055 and ADR-069 +Waivers: No runtime execution, scheduling, persistence, HTTP API, or analysis +engine is introduced by this issue. The new contract is an authoring/input +surface that is consumed by later run/orchestration work, not an executor. + +## Context + +Issue #675 observes an asymmetry in ACES authoring surfaces. SDL scenarios are +a first-class *authoring* surface: authored `examples/scenarios/*.sdl.yaml` +files, a published `sdl-authoring-input-v1` schema, MCP `sdl_validate` / +`sdl_scaffold` tools, and a discovery test that validates every worked example +against the published schema. + +The experiment-core contracts published under ADR-055, ADR-064, and ADR-065 +(`experiment-run-v1`, `experiment-study-v1`, `experiment-apparatus-context-v1`, +`experiment-task-v1`, capture-spec, evidence, derived-measure) are the +opposite. Every one is framed as an **archival provenance OUTPUT emitted by a +run** — `ExperimentRunModel` is literally "Archival provenance record for one +execution." There is no authoring path — file or MCP tool — to *specify* an +experiment (run count, seeds, red-variant selection, turn order, step count, +termination, condition assignments) as an **input** artifact before execution. +The only in-repo instances are conformance fixtures. + +A full CAGE-2 specification is scenario **plus** experiment. ADR-069 routes the +CAGE-2 scenario half through the existing SDL authoring surface, but the +experiment half — REP-003's "turn order, fixed step counts, episode +termination, red-agent variants, randomization, seeds, and stochastic controls" +(ADR-069 §2, and the execution-control equivalence tier in §7) — has no pre-run +authoring home. "Fully specify the CAGE-2 experiment" is therefore not a +supported workflow today. + +ADR-055 anticipated this exact need but deliberately deferred it. Its §5 states +the design "does not add scheduling, execution, persistence, HTTP APIs, +analysis engines, or new SDL authoring syntax," while "later implementation +work may consume these contracts." Its Risks section is more specific: "If +later APIs need draft or partial experiment records, they must introduce an +explicit draft lifecycle surface rather than weakening these archival +contracts." The EXP design-criteria note (Principle 1) independently warns +against "a single `experiment` object that means scenario, task, run, and study +depending on which fields are present," and (Principle 2) notes that "planned +apparatus and observed apparatus evidence must both be representable." + +## Decision + +Publish a new, separate authoring-input contract `experiment-authoring-input-v1` +(model `ExperimentSpecModel`) that specifies an experiment before it executes. +It is the input counterpart to the archival experiment-core outputs, exactly as +`sdl-authoring-input-v1` is the authored counterpart to `instantiated-scenario-v1`. + +### 1. It is a separate input contract, not a mutation of the archival family + +The archival contracts (`experiment-run-v1`, `experiment-study-v1`, +`experiment-apparatus-context-v1`) are unchanged. The authoring input is a new +closed-world `ContractModel` generated into `contracts/schemas/experiment-core/` +and recorded in the schema-publication manifest, following ADR-055's directive +to introduce an explicit draft/authoring surface rather than weaken the archival +records, and ADR-061's schema-evolution discipline. + +### 2. It references the separated concepts rather than re-declaring them + +Per the separation principle, the spec is a lean design that binds already- +separated concepts: + +- `task_ref` references a separately authored `experiment-task-v1` (which + carries scenario + protocol + metric definitions); the spec does not + duplicate task meaning. +- `intended_scenario_ref` optionally pins the intended scenario snapshot. +- `apparatus_intent` reuses the input-shaped `ExperimentApparatusConstraintModel` + (allowed processors/backends, required manifests/capabilities). It is the + *planned* apparatus, distinct from the run-scoped *observed* + `ExperimentApparatusContextModel`. +- `capture_spec_refs` reference `experiment-capture-spec-v1` artifacts. +- `factors`, `validity_notes`, and `artifact_refs` reuse the existing + experiment-core value models. + +### 3. The run plan carries the pre-run experimental design + +`run_plan` composes reuse and the genuinely-new authoring fields: + +- `stochastic_controls` (reused) declare seeds and randomization. +- `allocation` (reused `ExperimentRunAllocationPlanModel`) declares + condition-based run counts, replication, and condition assignments; or, for a + simple design, `target_run_count` declares a flat count. Exactly one of the + two is present. +- `episode_control` (new) declares turn order, logical step count, and episode + termination — the execution-control facts ADR-069 §7 requires. +- `red_variant_selections` (new, keyed by variant id) select red-agent variants. +- `clock_intent` (reused) declares the intended time domain. + +Identifier-bearing repeated children are keyed object maps (ADR-055 rule). +Cross-map constraints that portable JSON Schema cannot express — exactly-one run +count source, red-variant map-key equality, and blocking factors resolving to +declared factors — are declared as `x-aces-invariants` and enforced by the ACES +model validators, consistent with ADR-055's semantic-invariant profile. + +### 4. Authoring surface parity with SDL + +The contract ships an authored-file convention (`examples/experiments/*.exp.yaml`), +a thin Pydantic loader (`aces_contracts.experiment_spec.load_experiment_spec`; +no SDL-style parser is needed because the document has no shorthand or +cross-file resolution), MCP authoring tools (`experiment_scaffold`, +`experiment_validate`, `experiment_get_example`), and a discovery test that +validates every worked `.exp.yaml` against the published schema — mirroring the +SDL authoring surface and satisfying AUT-801's agent-facing authoring mandate. + +### 5. Runtime and orchestration remain out of scope + +Like ADR-055, this decision publishes a contract, a formal spec, tooling, and +examples. It does not schedule, execute, persist, or evaluate experiments. A +future orchestration surface consumes an authored spec to produce the archival +`experiment-run-v1` / `experiment-study-v1` records. + +## Guardrails + +- Do not treat the authoring input as a run, study, or apparatus-context + record; it is pre-run design, not provenance. +- Do not weaken the archival experiment-core contracts to carry draft or + partial data — that is exactly what this surface exists to avoid. +- Do not duplicate task, scenario, or capture-spec meaning inside the spec; + reference the authored artifacts. +- Do not hand-edit `contracts/schemas/`; change the contract source and + regenerate. +- Do not add a parallel experiment-authoring DSL or SDL section; the spec is a + single nested contract document. + +## Consequences + +### Positive + +- An experiment can be authored and validated before execution, closing the + asymmetry with SDL scenarios and giving REP-003 a home to declare CAGE-2 + execution-control facts pre-run. +- The archival contracts and their scientific boundary are untouched. +- The authoring surface reuses the existing published schema, fixture, + conformance, and MCP machinery rather than inventing new mechanisms. + +### Negative + +- Authors now maintain a task artifact and an experiment-spec artifact when a + task is used in a designed experiment. +- The spec references artifacts (task, capture-spec) by id; cross-artifact + existence is not resolved by the loader and is left to downstream + orchestration. + +### Risks + +- If later orchestration code treats the spec as free-form metadata, the + input/output boundary could blur back into the archival records. Conformance + and review must keep the surfaces distinct. +- The reused `ExperimentRunAllocationPlanModel` annotates its semantic + invariant against `experiment-study-v1`; when embedded in the authoring input + the annotation still names study. This is documented in the formal spec and is + harmless because the model validator runs regardless. + +## Alternatives Considered + +### Add authoring fields to the archival run/study contracts + +Rejected. ADR-055 explicitly forbids weakening the archival contracts to carry +draft/partial data and directs a separate draft lifecycle surface instead. + +### Add a CAGE-specific experiment schema for REP-003 + +Rejected. ADR-069 rejects CAGE-specific schemas; the authoring surface is a +general experiment-core contract that REP-003 uses, not a CAGE fork. + +### Author experiments as a new SDL section + +Rejected. ADR-055's guardrails forbid putting experiment concepts into SDL as a +new root section, and an experiment is not scenario meaning. + +### Defer authoring to a future runtime/API milestone + +Rejected. The issue asks specifically for an authoring surface analogous to SDL; +the contract + tooling can and should exist independently of execution, exactly +as the SDL authoring surface predates full runtime realization. diff --git a/docs/decisions/adrs/adr-index.yaml b/docs/decisions/adrs/adr-index.yaml index 6e59d1ebb..3ff2cdc9e 100644 --- a/docs/decisions/adrs/adr-index.yaml +++ b/docs/decisions/adrs/adr-index.yaml @@ -242,6 +242,9 @@ adrs: - date: 2026-06-12 ref: "#482" summary: "Recorded that the ADR's decision date is 2026-05-26 and it landed with experiment-core PR #422 on 2026-06-05." + - date: 2026-07-08 + ref: "#675" + summary: "ADR-074 realizes the explicit draft/authoring surface anticipated in the Risks section as experiment-authoring-input-v1; the archival contracts are unchanged." - id: ADR-056 path: docs/decisions/adrs/adr-056-runtime-observed-values-and-credential-posture.md pin: 3aa602de53f7607cf531b4a9da3f58192eb87ad21ddd92846fd2ae2a7526a4c3 @@ -282,6 +285,10 @@ adrs: - id: ADR-069 path: docs/decisions/adrs/adr-069-cage-2-replication-architecture.md pin: 305334e5558fb88d1f84317209f0e64d182714ba8e99cdeb5f6781bf3ee384f5 + amendments: + - date: 2026-07-08 + ref: "#675" + summary: "ADR-074 adds experiment-authoring-input-v1, giving REP-003 a pre-run home for execution-control facts; it complements the SDL scenario authoring path and adds no CAGE-specific schema." - id: ADR-071 path: docs/decisions/adrs/adr-071-reusable-asset-trust-and-integrity-policy.md pin: d0f0d15945a91e97870d794986d7c7453cea7fade5c74c673d271ceaf05a6b7e @@ -292,3 +299,6 @@ adrs: - date: 2026-07-06 ref: "#682" summary: "Accepted (proposed → accepted) and realized under SEM-206: removed the SDL scoring/reward surfaces, narrowed objectives.success to conditions, amended ADR-002, and updated the published SDL schemas." + - id: ADR-074 + path: docs/decisions/adrs/adr-074-experiment-authoring-input-contract-boundary.md + pin: 74dd29e3e5f3a2b7bdf836bd40908c0840c51f46a1a89efbb191e6ce7f620ce5 diff --git a/examples/experiments/techvault-red-tactic-sweep.exp.yaml b/examples/experiments/techvault-red-tactic-sweep.exp.yaml new file mode 100644 index 000000000..210775d5e --- /dev/null +++ b/examples/experiments/techvault-red-tactic-sweep.exp.yaml @@ -0,0 +1,105 @@ +# TechVault red-tactic sweep — an authored experiment specification. +# +# This is the pre-run authoring/input counterpart to the archival +# experiment-core outputs (run/study/apparatus-context). It references the +# separately authored task (experiment-task-v1) and declares the experimental +# design — apparatus intent, run plan, factors, and intended capture — before +# any run executes. See specs/formal/experiment-core/ and ADR-074. +schema_version: experiment-authoring-input/v1 +spec_id: spec-techvault-red-tactic-sweep-v1 +spec_version: 1.0.0 +title: TechVault red-tactic sweep +description: >- + Pre-run design comparing aggressive vs stealthy red tactics over the fixed + TechVault task, with seeded replication. + +# The task carries scenario + protocol + metric definitions; reference it by id. +task_ref: + ref_kind: task + ref_id: task-techvault-red-team-v1 + ref_version: 1.0.0 + +# Optionally pin the intended scenario snapshot the design targets. +intended_scenario_ref: + ref_kind: scenario-snapshot + ref_id: scenario-techvault + ref_version: "2026-05-26" + ref_digest: sha256:1111111111111111111111111111111111111111111111111111111111111111 + +# Declared apparatus intent (planned, not the run-scoped observed context). +apparatus_intent: + required_capabilities: + - workflow-results + - evaluation-results + notes: + - Any backend that realizes evaluation-results may execute this design. + +run_plan: + stochastic_controls: + - control_id: episode-seed + role: seed + value: 123456 + description: Base RNG seed; per-run seeds derived deterministically for replication. + episode_control: + turn_order: sequential + max_steps: 100 + termination_rule: Terminate each episode after 100 logical steps (fixed horizon). + description: Fixed-horizon episode with sequential turns. + # Exactly one of allocation / target_run_count. Here: a two-condition sweep. + allocation: + allocation_unit: run + allocation_method: balanced + compared_conditions: + - cond-aggressive + - cond-stealthy + condition_assignments: + cond-aggressive: + condition_id: cond-aggressive + factor_levels: + red-tactic: aggressive + required_parameters: + - name: red_tactic + value: aggressive + value_kind: protocol + cond-stealthy: + condition_id: cond-stealthy + factor_levels: + red-tactic: stealthy + required_parameters: + - name: red_tactic + value: stealthy + value_kind: protocol + target_runs_per_condition: 100 + randomization_unit: run + replication_policy: 100 independent seeded runs per red-tactic condition. + red_variant_selections: + aggressive: + variant_id: aggressive + agent_ref: red-agent + description: "Aggressive red tactic: direct exploitation path." + stealthy: + variant_id: stealthy + agent_ref: red-agent + description: "Stealthy red tactic: low-and-slow exploration." + clock_intent: + clock_id: sim-clock + authority: reference-backend + time_domain: simulated + +factors: + red-tactic: + name: Red tactic profile + factor_kind: treatment + levels: + - aggressive + - stealthy + +capture_spec_refs: + - ref_kind: capture-spec + ref_id: capture-techvault-evidence-v1 + ref_version: 1.0.0 + +validity_notes: + - category: internal + note: Per-run seeds derive deterministically from the base seed. + mitigation: Record derived seeds in each run's stochastic controls. diff --git a/examples/experiments/techvault-smoke-run.exp.yaml b/examples/experiments/techvault-smoke-run.exp.yaml new file mode 100644 index 000000000..de0c00efc --- /dev/null +++ b/examples/experiments/techvault-smoke-run.exp.yaml @@ -0,0 +1,30 @@ +# TechVault smoke run — a minimal single-condition experiment specification. +# +# The simplest authored design: no compared conditions, just a small seeded +# replication count via target_run_count. Useful as a pre-run smoke check +# before committing to a full sweep. See ADR-074. +schema_version: experiment-authoring-input/v1 +spec_id: spec-techvault-smoke-run-v1 +spec_version: 1.0.0 +title: TechVault smoke run +description: >- + Minimal single-condition design: five seeded runs of the TechVault task to + smoke-test the apparatus before a full comparison sweep. + +task_ref: + ref_kind: task + ref_id: task-techvault-red-team-v1 + ref_version: 1.0.0 + +run_plan: + stochastic_controls: + - control_id: episode-seed + role: seed + value: 7 + description: Base RNG seed for the smoke runs. + episode_control: + turn_order: sequential + max_steps: 100 + termination_rule: Terminate each episode after 100 logical steps (fixed horizon). + # Exactly one of allocation / target_run_count. Here: a simple run count. + target_run_count: 5 diff --git a/implementations/python/packages/aces_contracts/contracts.py b/implementations/python/packages/aces_contracts/contracts.py index 4e3ffb215..cb2ebc33d 100644 --- a/implementations/python/packages/aces_contracts/contracts.py +++ b/implementations/python/packages/aces_contracts/contracts.py @@ -66,6 +66,7 @@ CONTROLLED_VOCABULARIES_SCHEMA_VERSION, EVALUATION_STATE_SCHEMA_VERSION, EXPERIMENT_APPARATUS_CONTEXT_SCHEMA_VERSION, + EXPERIMENT_AUTHORING_INPUT_SCHEMA_VERSION, EXPERIMENT_CAPTURE_SPEC_SCHEMA_VERSION, EXPERIMENT_DERIVED_MEASURE_SCHEMA_VERSION, EXPERIMENT_EVIDENCE_RECORD_SCHEMA_VERSION, @@ -5476,6 +5477,148 @@ def __get_pydantic_json_schema__( return json_schema +class ExperimentEpisodeControlModel(ContractModel): + """Declarative episode execution controls for a planned experiment. + + Captures the pre-run execution-control facts — turn order, logical step + count, and episode termination — that ADR-069 requires for CAGE-2 + execution-control equivalence but that the archival experiment-core + contracts only record after a run has executed. + """ + + turn_order: Literal["sequential", "simultaneous", "round-robin", "scenario-defined", "other"] + termination_rule: NonEmptyString + max_steps: PositiveInteger | None = None + termination_condition_refs: list[NonEmptyString] = Field( + default_factory=list, json_schema_extra={"uniqueItems": True} + ) + description: NonEmptyString | None = None + + +class ExperimentRedVariantSelectionModel(ContractModel): + """Selection of one red-agent variant bound into a planned experiment.""" + + variant_id: NonEmptyString + agent_ref: NonEmptyString + parameters: list[ExperimentParameterModel] = Field(default_factory=list) + description: NonEmptyString | None = None + + +class ExperimentRunPlanModel(ContractModel): + """Pre-run replication, stochastic, episode, and red-variant plan. + + Reuses the archival-family value models for stochastic controls, run + allocation, and clock intent, and adds the authoring-only episode and + red-variant selections. Exactly one of ``allocation`` (condition-based) + or ``target_run_count`` (simple, no-condition) declares the run count. + """ + + stochastic_controls: list[ExperimentStochasticControlModel] = Field(min_length=1) + episode_control: ExperimentEpisodeControlModel + allocation: ExperimentRunAllocationPlanModel | None = None + target_run_count: PositiveInteger | None = None + red_variant_selections: dict[NonEmptyString, ExperimentRedVariantSelectionModel] = Field(default_factory=dict) + clock_intent: ExperimentClockContextModel | None = None + + @model_validator(mode="after") + def _validate_run_plan(self) -> ExperimentRunPlanModel: + if (self.allocation is None) == (self.target_run_count is None): + raise ValueError("run_plan must declare exactly one of allocation or target_run_count") + for key, selection in self.red_variant_selections.items(): + if selection.variant_id != key: + raise ValueError( + f"run_plan red_variant_selections key '{key}' must match embedded variant_id " + f"'{selection.variant_id}'" + ) + return self + + @classmethod + def __get_pydantic_json_schema__( + cls, + core_schema: CoreSchema, + handler: GetJsonSchemaHandler, + ) -> JsonSchemaValue: + json_schema = handler(core_schema) + json_schema = handler.resolve_ref_schema(json_schema) + json_schema.setdefault("oneOf", []).extend( + [ + { + "required": ["allocation"], + "properties": {"allocation": {"not": {"type": "null"}}, "target_run_count": {"type": "null"}}, + }, + { + "required": ["target_run_count"], + "properties": {"target_run_count": {"not": {"type": "null"}}, "allocation": {"type": "null"}}, + }, + ] + ) + _add_aces_invariant( + json_schema, + "run-plan-exactly-one-run-count-source", + "A run plan must declare exactly one of allocation or target_run_count, and every red-variant " + "selection map key must equal its embedded variant_id.", + validator="aces_contracts.contracts.ExperimentRunPlanModel._validate_run_plan", + inputs=[{"contract_id": "experiment-authoring-input-v1", "instance_path": "#/run_plan"}], + ) + return json_schema + + +class ExperimentSpecModel(ContractModel): + """Pre-run experiment authoring input: a design that binds a task to a run plan. + + This is the authoring/input counterpart to the archival experiment-core + outputs (run/study/apparatus-context). It references the separately + authored task (and optionally a scenario snapshot) and declares the + pre-run experimental design — apparatus intent, run plan, factors, + intended capture, and validity notes — before any run executes. It is + never a run, study, or apparatus-context record (ADR-055 / ADR-074). + """ + + schema_version: Literal[EXPERIMENT_AUTHORING_INPUT_SCHEMA_VERSION] + spec_id: NonEmptyString + spec_version: NonEmptyString + title: NonEmptyString + description: NonEmptyString + task_ref: ExperimentTaskReferenceModel + run_plan: ExperimentRunPlanModel + intended_scenario_ref: ExperimentScenarioReferenceModel | None = None + apparatus_intent: ExperimentApparatusConstraintModel | None = None + factors: dict[NonEmptyString, ExperimentStudyFactorModel] = Field(default_factory=dict) + capture_spec_refs: list[ExperimentCaptureSpecReferenceModel] = Field(default_factory=list) + validity_notes: list[ExperimentValidityNoteModel] = Field(default_factory=list) + artifact_refs: list[ExperimentArtifactRefModel] = Field(default_factory=list) + + @model_validator(mode="after") + def _validate_experiment_spec(self) -> ExperimentSpecModel: + allocation = self.run_plan.allocation + if allocation is not None: + factor_names = set(self.factors) + for blocking_factor in allocation.blocking_factors: + if blocking_factor not in factor_names: + raise ValueError( + f"run_plan allocation blocking factor '{blocking_factor}' must be a declared factor" + ) + return self + + @classmethod + def __get_pydantic_json_schema__( + cls, + core_schema: CoreSchema, + handler: GetJsonSchemaHandler, + ) -> JsonSchemaValue: + json_schema = handler(core_schema) + json_schema = handler.resolve_ref_schema(json_schema) + _add_aces_invariant( + json_schema, + "experiment-spec-blocking-factors-declared", + "When a run plan declares an allocation with blocking factors, every blocking factor must be a " + "declared experiment-spec factor.", + validator="aces_contracts.contracts.ExperimentSpecModel._validate_experiment_spec", + inputs=[{"contract_id": "experiment-authoring-input-v1", "instance_path": "#"}], + ) + return json_schema + + def validate_experiment_task_archival_datetimes(task: ExperimentTaskModel | Mapping[str, Any]) -> None: """Validate task-level archival timestamp semantics not carried by generic JSON Schema.""" @@ -7023,6 +7166,7 @@ def schema_bundle() -> dict[str, dict[str, Any]]: "semantic-profile-v1": SemanticProfileModel.model_json_schema(), "backend-profile-v1": _backend_profile_schema_for_bundle(), "experiment-apparatus-context-v1": ExperimentApparatusContextModel.model_json_schema(), + "experiment-authoring-input-v1": ExperimentSpecModel.model_json_schema(), "experiment-capture-spec-v1": ExperimentCaptureSpecModel.model_json_schema(), "experiment-derived-measure-v1": ExperimentDerivedMeasureModel.model_json_schema(), "experiment-evidence-record-v1": ExperimentEvidenceRecordModel.model_json_schema(), @@ -7129,6 +7273,7 @@ def schema_bundle() -> dict[str, dict[str, Any]]: "ExperimentDerivedMeasureMethodModel", "ExperimentDerivedMeasureModel", "ExperimentDerivedMeasureReferenceModel", + "ExperimentEpisodeControlModel", "ExperimentEvidenceRecordModel", "ExperimentEvidenceRecordReferenceModel", "ExperimentEvidenceReferenceModel", @@ -7142,13 +7287,16 @@ def schema_bundle() -> dict[str, dict[str, Any]]: "ExperimentParameterModel", "ExperimentProcessorReferenceModel", "ExperimentRealizedFormDisclosureModel", + "ExperimentRedVariantSelectionModel", "ExperimentReferenceModel", "ExperimentResultSummaryModel", "ExperimentRunAllocationPlanModel", "ExperimentRunModel", + "ExperimentRunPlanModel", "ExperimentRunTraceabilityModel", "ExperimentScenarioReferenceModel", "ExperimentScenarioSnapshotReferenceModel", + "ExperimentSpecModel", "ExperimentSplitAndLeakageControlsModel", "ExperimentStatisticalMethodModel", "ExperimentStochasticControlModel", @@ -7160,6 +7308,7 @@ def schema_bundle() -> dict[str, dict[str, Any]]: "ExperimentUncertaintyMethodModel", "ExperimentValidityNoteModel", "EXPERIMENT_APPARATUS_CONTEXT_SCHEMA_VERSION", + "EXPERIMENT_AUTHORING_INPUT_SCHEMA_VERSION", "EXPERIMENT_CAPTURE_SPEC_SCHEMA_VERSION", "EXPERIMENT_DERIVED_MEASURE_SCHEMA_VERSION", "EXPERIMENT_EVIDENCE_RECORD_SCHEMA_VERSION", diff --git a/implementations/python/packages/aces_contracts/experiment_spec.py b/implementations/python/packages/aces_contracts/experiment_spec.py new file mode 100644 index 000000000..4aaf9d4e3 --- /dev/null +++ b/implementations/python/packages/aces_contracts/experiment_spec.py @@ -0,0 +1,76 @@ +"""Loading helpers for authored experiment specifications. + +An experiment specification is the pre-run authoring/input counterpart to the +archival experiment-core outputs (run/study/apparatus-context). Unlike SDL, +the authoring input is a single nested contract document with no shorthand, +key normalization, or cross-file resolution, so loading is a thin +``yaml.safe_load`` followed by ``ExperimentSpecModel`` validation — mirroring +how the JSON fixtures under ``contracts/fixtures/experiment-core`` are checked. +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +import yaml +from pydantic import ValidationError + +from .contracts import ExperimentSpecModel + +log = logging.getLogger("aces.experiment_spec") + + +class ExperimentSpecError(Exception): + """Base exception for experiment-specification loading.""" + + +class ExperimentSpecValidationError(ExperimentSpecError): + """An experiment specification failed to parse or validate.""" + + def __init__(self, message: str, path: Path | None = None) -> None: + self.path = path + self.details = message + prefix = f"{path}: " if path else "" + super().__init__(f"{prefix}{message}") + + +def parse_experiment_spec(text: str, *, path: Path | None = None) -> ExperimentSpecModel: + """Parse and validate an experiment specification from YAML text.""" + raw = text.strip() + if not raw: + raise ExperimentSpecValidationError("Experiment spec is empty", path=path) + + try: + payload = yaml.safe_load(raw) + except yaml.YAMLError as exc: + raise ExperimentSpecValidationError(f"YAML parse error: {exc}", path=path) from exc + + if not isinstance(payload, dict): + raise ExperimentSpecValidationError("Experiment spec must be a YAML mapping", path=path) + + try: + return ExperimentSpecModel.model_validate(payload) + except ValidationError as exc: + raise ExperimentSpecValidationError(str(exc), path=path) from exc + + +def load_experiment_spec(path: Path) -> ExperimentSpecModel: + """Load and validate an authored experiment specification from a YAML file.""" + if not path.exists(): + raise FileNotFoundError(f"Experiment spec file not found: {path}") + + spec = parse_experiment_spec(path.read_text(encoding="utf-8"), path=path) + log.info("Loaded experiment spec '%s' from %s", spec.spec_id, path) + return spec + + +def find_experiment_specs(search_dir: Path) -> list[Path]: + """Find all authored experiment-spec files in a directory (non-recursive).""" + if not search_dir.is_dir(): + log.debug("Experiments directory does not exist: %s", search_dir) + return [] + + paths = sorted(search_dir.glob("*.exp.yaml")) + log.debug("Found %d experiment spec files in %s", len(paths), search_dir) + return paths diff --git a/implementations/python/packages/aces_contracts/versions.py b/implementations/python/packages/aces_contracts/versions.py index 590b15b52..8f345efb9 100644 --- a/implementations/python/packages/aces_contracts/versions.py +++ b/implementations/python/packages/aces_contracts/versions.py @@ -33,4 +33,5 @@ EXPERIMENT_CAPTURE_SPEC_SCHEMA_VERSION = "experiment-capture-spec/v1" EXPERIMENT_EVIDENCE_RECORD_SCHEMA_VERSION = "experiment-evidence-record/v1" EXPERIMENT_DERIVED_MEASURE_SCHEMA_VERSION = "experiment-derived-measure/v1" +EXPERIMENT_AUTHORING_INPUT_SCHEMA_VERSION = "experiment-authoring-input/v1" REUSABLE_ASSET_TRUST_POLICY_SCHEMA_VERSION = "reusable-asset-trust-policy/v1" diff --git a/implementations/python/packages/aces_mcp/server.py b/implementations/python/packages/aces_mcp/server.py index cb7978233..264b26868 100644 --- a/implementations/python/packages/aces_mcp/server.py +++ b/implementations/python/packages/aces_mcp/server.py @@ -11,6 +11,7 @@ from mcp.server.fastmcp import FastMCP from aces_mcp.tools.authoring import register as register_authoring_tools +from aces_mcp.tools.experiment_authoring import register as register_experiment_authoring_tools from aces_mcp.tools.inspection import register as register_inspection_tools from aces_mcp.tools.language_service import register as register_language_service_tools from aces_mcp.tools.operations import register as register_operation_tools @@ -34,7 +35,13 @@ `sdl_format`, `sdl_diagnostics`, and `sdl_apply_edit` for language-service \ workflows. Use `sdl_validate`, `sdl_design_assessment`, `sdl_plan`, and \ `sdl_claims_assessment` to check SDL YAML and avoid overstating what a \ -scenario or dry run can prove.\ +scenario or dry run can prove. + +To author an *experiment* (the pre-run specification that binds a task to a \ +run plan — seeds, episode controls, red-variant selection, and replication — \ +distinct from the archival run/study records), use `experiment_scaffold` to \ +start, `experiment_get_example` to see a worked design, and `experiment_validate` \ +to check it.\ """ @@ -46,6 +53,7 @@ def create_server() -> FastMCP: ) register_reference_tools(mcp) register_authoring_tools(mcp) + register_experiment_authoring_tools(mcp) register_language_service_tools(mcp) register_inspection_tools(mcp) register_operation_tools(mcp) diff --git a/implementations/python/packages/aces_mcp/tools/experiment_authoring.py b/implementations/python/packages/aces_mcp/tools/experiment_authoring.py new file mode 100644 index 000000000..34aff7523 --- /dev/null +++ b/implementations/python/packages/aces_mcp/tools/experiment_authoring.py @@ -0,0 +1,236 @@ +"""Experiment authoring tools — validate, scaffold, and retrieve experiment specs. + +These tools let agents author an experiment *specification* — the pre-run +authoring/input counterpart to the archival experiment-core outputs +(run/study/apparatus-context). An experiment spec binds a task to a run plan +(replication, seeds, episode controls, red-variant selection, condition +assignments) so an experiment can be specified and validated before execution. +See ADR-074 and specs/formal/experiment-core/. +""" + +from __future__ import annotations + +from pathlib import Path + +from mcp.server.fastmcp import FastMCP + +# Maximum input size to prevent resource exhaustion via YAML bombs or +# extremely large payloads. 64 KiB matches the SDL authoring tools. +_MAX_INPUT_BYTES = 64 * 1024 + + +def _find_repo_root(start: Path) -> Path: + for candidate in (start, *start.parents): + if (candidate / ".git").exists() or (candidate / ".ground-control.yaml").exists(): + return candidate + raise RuntimeError(f"could not locate aces-sdl repo root from {start}") + + +_REPO_ROOT = _find_repo_root(Path(__file__).resolve().parent) +_EXAMPLES_DIR = _REPO_ROOT / "examples" / "experiments" + +_ALLOWED_EXAMPLES = { + "sweep": "techvault-red-tactic-sweep.exp.yaml", + "smoke": "techvault-smoke-run.exp.yaml", +} + + +def _run_experiment_validate(spec_content: str) -> str: + """Validate experiment-spec YAML and render a human-readable result.""" + if len(spec_content.encode("utf-8", errors="replace")) > _MAX_INPUT_BYTES: + return f"INPUT TOO LARGE — limit is {_MAX_INPUT_BYTES} bytes." + + from aces_contracts.experiment_spec import ( + ExperimentSpecValidationError, + parse_experiment_spec, + ) + + try: + spec = parse_experiment_spec(spec_content) + except ExperimentSpecValidationError as exc: + return f"VALIDATION ERROR — the experiment spec is invalid.\n\nDetails:\n{exc.details}" + + run_plan = spec.run_plan + allocation = run_plan.allocation + run_count = ( + f" compared conditions: {len(allocation.compared_conditions)}" + if allocation is not None + else f" target run count: {run_plan.target_run_count}" + ) + parts = [ + f"VALID — experiment spec '{spec.spec_id}' (v{spec.spec_version}) parsed successfully.", + f" task_ref: {spec.task_ref.ref_id}", + f" run-count source: {'allocation' if allocation is not None else 'target_run_count'}", + f" stochastic controls: {len(run_plan.stochastic_controls)}", + f" red-variant selections: {len(run_plan.red_variant_selections)}", + f" factors: {len(spec.factors)}", + f" capture-spec refs: {len(spec.capture_spec_refs)}", + run_count, + ] + return "\n".join(parts) + + +def _run_experiment_scaffold(complexity: str, spec_id: str, task_ref_id: str) -> str: + """Render a starter experiment-spec skeleton for the requested complexity.""" + key = complexity.lower().strip() + if key not in ("minimal", "sweep"): + return "Invalid complexity. Choose: 'minimal' or 'sweep'." + template = _SCAFFOLD_MINIMAL if key == "minimal" else _SCAFFOLD_SWEEP + return template.replace("{spec_id}", spec_id).replace("{task_ref_id}", task_ref_id) + + +def _run_experiment_get_example(name: str) -> str: + """Return an allowlisted worked experiment-spec example.""" + filename = _ALLOWED_EXAMPLES.get(name.lower().strip()) + if filename is None: + return f"Unknown example '{name}'. Available: {', '.join(sorted(_ALLOWED_EXAMPLES))}" + path = _EXAMPLES_DIR / filename + if not path.exists(): + return f"Example file not found: {filename}" + return path.read_text(encoding="utf-8") + + +def register(mcp: FastMCP) -> None: + """Register experiment authoring tools on the MCP server.""" + + @mcp.tool( + name="experiment_validate", + description=( + "Parse and validate an authored experiment specification (YAML for the " + "experiment-authoring-input contract). Returns either a success " + "confirmation with a short summary, or the structured validation " + "errors found.\n\n" + "An experiment spec is the pre-run design: it references an " + "experiment task and declares the run plan (stochastic controls/seeds, " + "episode controls such as turn order / step count / termination, " + "red-variant selections, and either a condition allocation or a simple " + "target run count). Pass the full YAML as `spec_content`." + ), + ) + def experiment_validate(spec_content: str) -> str: + return _run_experiment_validate(spec_content) + + @mcp.tool( + name="experiment_scaffold", + description=( + "Generate a starter experiment-specification skeleton (valid YAML you " + "can edit). Choose a complexity level: 'minimal' (a single-condition " + "design using target_run_count) or 'sweep' (a two-condition red-variant " + "comparison using an allocation plan). Optionally provide a spec id and " + "the task id the design targets." + ), + ) + def experiment_scaffold( + complexity: str = "minimal", + spec_id: str = "my-experiment-spec", + task_ref_id: str = "task-example-v1", + ) -> str: + return _run_experiment_scaffold(complexity, spec_id, task_ref_id) + + @mcp.tool( + name="experiment_get_example", + description=( + "Get a complete, annotated experiment-specification example. Available " + "examples: 'sweep' (a two-condition red-tactic comparison with an " + "allocation plan) and 'smoke' (a minimal single-condition design using " + "target_run_count). Use 'sweep' to see the full authoring surface." + ), + ) + def experiment_get_example(name: str) -> str: + return _run_experiment_get_example(name) + + +# --------------------------------------------------------------------------- +# Scaffold templates +# --------------------------------------------------------------------------- + +_SCAFFOLD_MINIMAL = """\ +schema_version: experiment-authoring-input/v1 +spec_id: {spec_id} +spec_version: 1.0.0 +title: My experiment +description: A minimal single-condition experiment design. + +# Reference the separately authored experiment task (experiment-task-v1). +task_ref: + ref_kind: task + ref_id: {task_ref_id} + ref_version: 1.0.0 + +run_plan: + stochastic_controls: + - control_id: episode-seed + role: seed + value: 1 + description: Base RNG seed. + episode_control: + turn_order: sequential + max_steps: 100 + termination_rule: Terminate each episode after 100 logical steps. + # Exactly one of allocation / target_run_count. + target_run_count: 10 +""" + +_SCAFFOLD_SWEEP = """\ +schema_version: experiment-authoring-input/v1 +spec_id: {spec_id} +spec_version: 1.0.0 +title: My red-variant sweep +description: A two-condition comparison of red variants with seeded replication. + +task_ref: + ref_kind: task + ref_id: {task_ref_id} + ref_version: 1.0.0 + +run_plan: + stochastic_controls: + - control_id: episode-seed + role: seed + value: 1 + description: Base RNG seed; per-run seeds derived deterministically. + episode_control: + turn_order: sequential + max_steps: 100 + termination_rule: Terminate each episode after 100 logical steps. + allocation: + allocation_unit: run + allocation_method: balanced + compared_conditions: + - cond-a + - cond-b + condition_assignments: + cond-a: + condition_id: cond-a + factor_levels: + red-variant: variant-a + required_parameters: + - name: red_variant + value: variant-a + value_kind: protocol + cond-b: + condition_id: cond-b + factor_levels: + red-variant: variant-b + required_parameters: + - name: red_variant + value: variant-b + value_kind: protocol + target_runs_per_condition: 50 + replication_policy: 50 independent seeded runs per condition. + red_variant_selections: + variant-a: + variant_id: variant-a + agent_ref: red-agent + variant-b: + variant_id: variant-b + agent_ref: red-agent + +factors: + red-variant: + name: Red variant + factor_kind: treatment + levels: + - variant-a + - variant-b +""" diff --git a/implementations/python/packages/aces_mcp/tools/operations.py b/implementations/python/packages/aces_mcp/tools/operations.py index 0db621b74..41ff79ac3 100644 --- a/implementations/python/packages/aces_mcp/tools/operations.py +++ b/implementations/python/packages/aces_mcp/tools/operations.py @@ -71,6 +71,11 @@ def aces_tool_surface() -> str: "sdl_validate_section", "sdl_instantiate", ], + "experiment_authoring": [ + "experiment_scaffold", + "experiment_validate", + "experiment_get_example", + ], "language_service": [ "sdl_completions", "sdl_references", diff --git a/implementations/python/tests/paths.py b/implementations/python/tests/paths.py index b1b0ab156..ea5a50d01 100644 --- a/implementations/python/tests/paths.py +++ b/implementations/python/tests/paths.py @@ -27,3 +27,4 @@ def _find_repo_root() -> Path: REPO_ROOT = _find_repo_root() EXAMPLES_DIR = REPO_ROOT / "examples" / "scenarios" +EXPERIMENTS_DIR = REPO_ROOT / "examples" / "experiments" diff --git a/implementations/python/tests/test_example_schema_conformance.py b/implementations/python/tests/test_example_schema_conformance.py index eb6e87c4e..ab30c49a5 100644 --- a/implementations/python/tests/test_example_schema_conformance.py +++ b/implementations/python/tests/test_example_schema_conformance.py @@ -24,13 +24,27 @@ from dataclasses import dataclass, field from functools import cache from pathlib import Path +from typing import Any, Protocol import pytest from aces_contracts.corpus import corpus_family_root -from aces_sdl.scenario import Scenario +from aces_contracts.experiment_spec import load_experiment_spec from aces_sdl.scenarios import load_scenario from jsonschema import Draft202012Validator -from paths import EXAMPLES_DIR +from paths import EXAMPLES_DIR, EXPERIMENTS_DIR + + +class SupportsModelDump(Protocol): + """Any loaded contract model that can be serialized for publication comparison. + + The corpus spans more than one loaded model type (``Scenario`` for SDL, + ``ExperimentSpecModel`` for authored experiments), so the loader is typed by + the one capability the suite uses — ``model_dump`` — rather than a single + concrete model class. + """ + + def model_dump(self, **kwargs: Any) -> dict: ... + # ``by_alias=True`` is load-bearing: the published schema is generated from the model with # ``model_json_schema()`` (aliases on), so a field-name dump fails on YAML-facing aliases @@ -52,7 +66,7 @@ class CorpusEntry: contract_id: str root: Path glob: str - loader: Callable[[Path], Scenario] + loader: Callable[[Path], SupportsModelDump] schema_path: Path dump_kwargs: dict = field(default_factory=lambda: dict(_PUBLICATION_DUMP_KWARGS)) @@ -71,6 +85,13 @@ def _validator_for(schema_path: Path) -> Draft202012Validator: _SDL_SCHEMA_DIR = corpus_family_root("schemas") / "sdl" +_EXP_SCHEMA_DIR = corpus_family_root("schemas") / "experiment-core" + +# The experiment authoring-input reference models (task / capture-spec) forbid +# ``ref_digest``/``ref_path`` in their published sub-schemas, so an id-only +# reference publishes without those keys. ``exclude_none`` yields exactly that +# schema-conformant publication shape (unset optionals are omitted, not null). +_EXPERIMENT_DUMP_KWARGS = {"mode": "json", "by_alias": True, "exclude_none": True} # Today the table has exactly one leg: the authoring example corpus against the published # authoring-input contract. No instantiated-scenario *example* artifacts exist under @@ -85,6 +106,14 @@ def _validator_for(schema_path: Path) -> Draft202012Validator: loader=load_scenario, schema_path=_SDL_SCHEMA_DIR / "sdl-authoring-input-v1.json", ), + CorpusEntry( + contract_id="experiment-authoring-input-v1", + root=EXPERIMENTS_DIR, + glob="*.exp.yaml", + loader=load_experiment_spec, + schema_path=_EXP_SCHEMA_DIR / "experiment-authoring-input-v1.json", + dump_kwargs=dict(_EXPERIMENT_DUMP_KWARGS), + ), ] diff --git a/implementations/python/tests/test_experiment_authoring.py b/implementations/python/tests/test_experiment_authoring.py new file mode 100644 index 000000000..284a7afd7 --- /dev/null +++ b/implementations/python/tests/test_experiment_authoring.py @@ -0,0 +1,167 @@ +"""Tests for the experiment authoring-input loader and MCP authoring tools (issue #675).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from aces_contracts.contracts import ExperimentSpecModel +from aces_contracts.experiment_spec import ( + ExperimentSpecValidationError, + find_experiment_specs, + load_experiment_spec, + parse_experiment_spec, +) +from aces_mcp.tools.experiment_authoring import ( + _MAX_INPUT_BYTES, + _run_experiment_get_example, + _run_experiment_scaffold, + _run_experiment_validate, +) +from paths import EXPERIMENTS_DIR, REPO_ROOT + +_VALID_FIXTURE = ( + REPO_ROOT + / "contracts" + / "fixtures" + / "experiment-core" + / "experiment-authoring-input-v1" + / "valid" + / "reference.json" +) + + +def _valid_payload() -> dict: + return json.loads(_VALID_FIXTURE.read_text(encoding="utf-8")) + + +def _valid_yaml() -> str: + # YAML is a JSON superset; the JSON fixture is valid YAML. + return _VALID_FIXTURE.read_text(encoding="utf-8") + + +# --- loader / parser ------------------------------------------------------ + + +def test_parse_experiment_spec_accepts_valid_yaml() -> None: + spec = parse_experiment_spec(_valid_yaml()) + assert spec.spec_id == "spec-techvault-red-tactic-sweep-v1" + assert spec.run_plan.allocation is not None + + +def test_parse_experiment_spec_rejects_empty() -> None: + with pytest.raises(ExperimentSpecValidationError): + parse_experiment_spec(" \n") + + +def test_parse_experiment_spec_rejects_non_mapping() -> None: + with pytest.raises(ExperimentSpecValidationError): + parse_experiment_spec("- a\n- b\n") + + +def test_parse_experiment_spec_rejects_bad_yaml() -> None: + with pytest.raises(ExperimentSpecValidationError): + parse_experiment_spec("spec_id: [unterminated\n") + + +def test_parse_experiment_spec_rejects_schema_invalid() -> None: + with pytest.raises(ExperimentSpecValidationError): + parse_experiment_spec("schema_version: experiment-authoring-input/v1\nspec_id: x\n") + + +def test_load_experiment_spec_reads_file(tmp_path: Path) -> None: + path = tmp_path / "demo.exp.yaml" + path.write_text(_valid_yaml(), encoding="utf-8") + spec = load_experiment_spec(path) + assert isinstance(spec, ExperimentSpecModel) + + +def test_load_experiment_spec_missing_file(tmp_path: Path) -> None: + with pytest.raises(FileNotFoundError): + load_experiment_spec(tmp_path / "nope.exp.yaml") + + +def test_find_experiment_specs_finds_shipped_examples() -> None: + found = find_experiment_specs(EXPERIMENTS_DIR) + assert found, "expected shipped *.exp.yaml examples" + assert all(p.name.endswith(".exp.yaml") for p in found) + + +def test_find_experiment_specs_missing_dir(tmp_path: Path) -> None: + assert find_experiment_specs(tmp_path / "absent") == [] + + +# --- model validators ----------------------------------------------------- + + +def test_run_plan_rejects_both_run_count_sources() -> None: + payload = _valid_payload() + payload["run_plan"]["target_run_count"] = 10 # allocation already present + with pytest.raises(ValueError, match="exactly one"): + ExperimentSpecModel.model_validate(payload) + + +def test_run_plan_rejects_no_run_count_source() -> None: + payload = _valid_payload() + del payload["run_plan"]["allocation"] + with pytest.raises(ValueError, match="exactly one"): + ExperimentSpecModel.model_validate(payload) + + +def test_run_plan_rejects_red_variant_key_mismatch() -> None: + payload = _valid_payload() + payload["run_plan"]["red_variant_selections"] = { + "aggressive": {"variant_id": "not-aggressive", "agent_ref": "red-agent"} + } + with pytest.raises(ValueError, match="variant_id"): + ExperimentSpecModel.model_validate(payload) + + +def test_target_run_count_path_is_valid() -> None: + payload = _valid_payload() + del payload["run_plan"]["allocation"] + del payload["factors"] + payload["run_plan"]["target_run_count"] = 5 + spec = ExperimentSpecModel.model_validate(payload) + assert spec.run_plan.target_run_count == 5 + + +# --- MCP tool helpers ----------------------------------------------------- + + +def test_tool_validate_valid() -> None: + out = _run_experiment_validate(_valid_yaml()) + assert out.startswith("VALID —") + assert "run-count source: allocation" in out + + +def test_tool_validate_invalid() -> None: + out = _run_experiment_validate("schema_version: experiment-authoring-input/v1\nspec_id: x\n") + assert out.startswith("VALIDATION ERROR") + + +def test_tool_validate_too_large() -> None: + out = _run_experiment_validate("x" * (_MAX_INPUT_BYTES + 1)) + assert "INPUT TOO LARGE" in out + + +@pytest.mark.parametrize("complexity", ["minimal", "sweep"]) +def test_tool_scaffold_outputs_are_valid(complexity: str) -> None: + out = _run_experiment_scaffold(complexity, "demo-spec", "task-demo-v1") + spec = parse_experiment_spec(out) + assert spec.spec_id == "demo-spec" + + +def test_tool_scaffold_rejects_unknown_complexity() -> None: + assert "Invalid complexity" in _run_experiment_scaffold("ultra", "x", "y") + + +@pytest.mark.parametrize("name", ["sweep", "smoke"]) +def test_tool_get_example_returns_shipped(name: str) -> None: + out = _run_experiment_get_example(name) + assert "schema_version: experiment-authoring-input/v1" in out + + +def test_tool_get_example_unknown() -> None: + assert "Unknown example" in _run_experiment_get_example("nope") diff --git a/implementations/python/tests/test_runtime_contracts.py b/implementations/python/tests/test_runtime_contracts.py index 2ad637cf8..db8e63f33 100644 --- a/implementations/python/tests/test_runtime_contracts.py +++ b/implementations/python/tests/test_runtime_contracts.py @@ -18,6 +18,7 @@ ExperimentEvidenceRecordModel, ExperimentRunModel, ExperimentRunTraceabilityModel, + ExperimentSpecModel, ExperimentStudyModel, ExperimentTaskModel, ParticipantImplementationManifestModel, @@ -43,6 +44,7 @@ EXPERIMENT_CORE_FIXTURE_MODELS = { "experiment-apparatus-context-v1": ExperimentApparatusContextModel, + "experiment-authoring-input-v1": ExperimentSpecModel, "experiment-capture-spec-v1": ExperimentCaptureSpecModel, "experiment-derived-measure-v1": ExperimentDerivedMeasureModel, "experiment-evidence-record-v1": ExperimentEvidenceRecordModel, @@ -626,6 +628,20 @@ def test_experiment_core_invalid_fixtures_fail_schema_and_model_validation(): model_cls.model_validate(payload) +def test_experiment_authoring_input_rejects_undeclared_blocking_factor(): + # A run-allocation blocking factor must resolve to a declared spec factor even + # when the factors map is empty/absent — the model enforces the x-aces-invariant + # that portable JSON Schema cannot express (issue #675 codex review). + payload = _experiment_fixture("experiment-authoring-input-v1") + payload["factors"] = {} + payload["run_plan"]["allocation"]["blocking_factors"] = ["undeclared-factor"] + + # Schema-valid (blocking_factors is a list of strings) but model-invalid. + assert not list(Draft202012Validator(schema_bundle()["experiment-authoring-input-v1"]).iter_errors(payload)) + with pytest.raises(ValidationError): + ExperimentSpecModel.model_validate(payload) + + def test_experiment_core_requires_schema_versions_on_wire_artifacts(): for contract_id, model_cls in EXPERIMENT_CORE_FIXTURE_MODELS.items(): payload = _experiment_fixture(contract_id) diff --git a/specs/formal/experiment-core/README.md b/specs/formal/experiment-core/README.md index f69dcc3e5..62ba5ecc7 100644 --- a/specs/formal/experiment-core/README.md +++ b/specs/formal/experiment-core/README.md @@ -13,6 +13,7 @@ reproducibility/replay claim-support interpretation: - `experiment-capture-spec-v1` - `experiment-evidence-record-v1` - `experiment-derived-measure-v1` +- `experiment-authoring-input-v1` (pre-run authoring input; ADR-074) - optional `backend-manifest-v2` `capabilities.observation` - canonical run traceability and realized-form disclosures inside `experiment-run-v1` @@ -20,6 +21,12 @@ reproducibility/replay claim-support interpretation: The contracts describe cyber range experiment artifacts. They do not implement execution, storage, scheduling, APIs, or analysis engines. +`experiment-authoring-input-v1` (ADR-074) is the one input contract in this +domain: the pre-run experiment *specification* that binds a task to a run plan +before execution. It is the authoring counterpart to the archival run, study, +and apparatus-context outputs, analogous to how `sdl-authoring-input-v1` is the +authored counterpart to `instantiated-scenario-v1`. + ## FM Classification Classification: FM2, Semantic Graph / Constraint. @@ -45,7 +52,9 @@ Rationale: and `docs/decisions/adrs/adr-065-experiment-run-provenance-contract-boundary.md`, and - `docs/decisions/adrs/adr-068-experiment-trials-replication-and-replay-claims.md`. + `docs/decisions/adrs/adr-068-experiment-trials-replication-and-replay-claims.md`, + and + `docs/decisions/adrs/adr-074-experiment-authoring-input-contract-boundary.md`. - Machine-readable schemas: `contracts/schemas/experiment-core/`. - Contract source: `implementations/python/packages/aces_contracts/contracts.py`. - Schema generation: `tools/generate_contract_schemas.py`. @@ -288,6 +297,32 @@ context, selected manifests, capability declarations, measurement channels, task/scenario snapshot identity, non-opaque parameters, and stochastic controls. +### Experiment Authoring Input + +An experiment authoring input is the pre-run *specification* of an experiment, +distinct from the archival run/study/apparatus-context records it will later +produce. It binds: + +- a `task_ref` to a separately authored `experiment-task-v1`, and optionally an + `intended_scenario_ref` to a scenario snapshot; +- declared apparatus intent (the input-shaped apparatus constraints), distinct + from the run-scoped observed apparatus context; +- a run plan: stochastic controls (seeds), an episode control (turn order, + logical step count, termination), either a condition `allocation` plan or a + scalar `target_run_count`, red-variant selections keyed by variant id, and an + optional clock intent; +- study factors keyed by factor id; +- capture-specification references, validity notes, and supporting artifacts. + +An authoring input is not a run, study, or apparatus-context record. It carries +no execution provenance, results, or observed evidence. It reuses the archival +family's input-shaped value models (apparatus constraints, run allocation, +stochastic controls, factors, clock context) by reference or embedding and does +not re-declare task, scenario, or capture-specification meaning. The reused run- +allocation model annotates its semantic invariant against `experiment-study-v1` +even when embedded here; that annotation is descriptive only — the ACES model +validator runs regardless of the embedding contract. + ## Invariants ### Separation @@ -375,6 +410,20 @@ controls. claim/report refs. ACES MUST NOT publish parallel replay-run, reproducibility-claim, replay-claim, or provenance-graph root schemas for the same facts unless a later ADR supersedes this boundary. +24. An experiment authoring input MUST reference its task with a `task` + reference and MUST NOT embed or re-declare task, scenario, or + capture-specification meaning; scenario intent uses `scenario` or + `scenario-snapshot` references and capture intent uses `capture-spec` + references. +25. An experiment authoring input run plan MUST declare exactly one run-count + source: either a condition `allocation` plan or a scalar `target_run_count`. + Red-variant selection map keys MUST match their embedded `variant_id`, and + declared allocation blocking factors MUST resolve to declared spec factors. +26. An experiment authoring input is an input artifact. It MUST NOT be treated + as, or substituted for, an `experiment-run-v1`, `experiment-study-v1`, or + `experiment-apparatus-context-v1` record. Its declared apparatus intent uses + the input-shaped apparatus constraints, not the run-scoped observed + apparatus context. ### Provenance @@ -531,3 +580,5 @@ base. The most load-bearing criteria are: graph root schemas for facts already carried by experiment-core contracts. - Runtime replay execution, replay scheduling, artifact dereference APIs, retention storage, or query services. +- Orchestration or execution of authored experiment specifications, or + producing archival run/study records from an `experiment-authoring-input-v1`. diff --git a/tools/policy/adr_policy.yaml b/tools/policy/adr_policy.yaml index 7b351d1c9..9fee528bf 100644 --- a/tools/policy/adr_policy.yaml +++ b/tools/policy/adr_policy.yaml @@ -219,6 +219,7 @@ module_boundaries: allowed_top_level_imports: - aces_backend_protocols - aces_backend_stubs + - aces_contracts - aces_processor - aces_sdl forbidden_import_prefixes: From 73d1a0ffd6f487ffc1d5dcfa63366bd069997482 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Fri, 10 Jul 2026 18:44:28 -0700 Subject: [PATCH 06/15] docs: define ecosystem evolution governance policy (#718) Add ecosystem evolution governance policy --- docs/decisions/adrs/README.md | 2 + ...ng-deprecation-and-migration-governance.md | 129 +++++++ ...901-903-versioning-governance-preflight.md | 362 ++++++++++++++++++ specs/README.md | 2 + specs/evolution/README.md | 13 + .../versioning-deprecation-and-migration.md | 183 +++++++++ 6 files changed, 691 insertions(+) create mode 100644 docs/decisions/adrs/adr-075-ecosystem-versioning-deprecation-and-migration-governance.md create mode 100644 docs/decisions/issue-90-gov-901-903-versioning-governance-preflight.md create mode 100644 specs/evolution/README.md create mode 100644 specs/evolution/versioning-deprecation-and-migration.md diff --git a/docs/decisions/adrs/README.md b/docs/decisions/adrs/README.md index 3e55a377c..6865aa6bd 100644 --- a/docs/decisions/adrs/README.md +++ b/docs/decisions/adrs/README.md @@ -119,6 +119,7 @@ adr-071-reusable-asset-trust-and-integrity-policy adr-072-validation-and-admission-profiles adr-073-scoring-reward-language-scope adr-074-experiment-authoring-input-contract-boundary +adr-075-ecosystem-versioning-deprecation-and-migration-governance ``` | ADR | Title | Status | Date | @@ -198,3 +199,4 @@ adr-074-experiment-authoring-input-contract-boundary | [072](adr-072-validation-and-admission-profiles.md) | Validation and Admission Profiles | proposed | 2026-07-05 | | [073](adr-073-scoring-reward-language-scope.md) | Scoring and Reward Language Scope in the SDL | accepted | 2026-07-05 | | [074](adr-074-experiment-authoring-input-contract-boundary.md) | Experiment Authoring-Input Contract Boundary | accepted | 2026-07-08 | +| [075](adr-075-ecosystem-versioning-deprecation-and-migration-governance.md) | Ecosystem Versioning, Deprecation, and Migration Governance | proposed | 2026-07-11 | diff --git a/docs/decisions/adrs/adr-075-ecosystem-versioning-deprecation-and-migration-governance.md b/docs/decisions/adrs/adr-075-ecosystem-versioning-deprecation-and-migration-governance.md new file mode 100644 index 000000000..35ab8d74f --- /dev/null +++ b/docs/decisions/adrs/adr-075-ecosystem-versioning-deprecation-and-migration-governance.md @@ -0,0 +1,129 @@ +# ADR-075: Ecosystem Versioning, Deprecation, and Migration Governance + +## Status + +proposed + +## Date + +2026-07-11 + +## Classification + +Classification: FM2 +Required artifacts: ADR, normative specification, repository-policy and docs verification +Waivers: none + +## Context + +ACES now has several independently versioned surfaces: + +- the Python distribution and Git tags; +- published JSON Schema contract lineages; +- wire discriminators in closed contract DTOs; +- SDL scenarios and reusable modules; +- processor, backend, participant, and apparatus declarations; +- experiment tasks, runs, studies, and evidence artifacts; and +- ADR and Ground Control workflow statuses. + +These surfaces use similar words, such as version, compatibility, +deprecation, stability, migration, and lifecycle, but they do not all carry the +same semantics. ADR-061 already governs published JSON Schema evolution, while +ADR-010 governs compatibility-only Python import wrappers. Those decisions are +necessary but not sufficient for GOV-901, GOV-902, and GOV-903 because the +ecosystem needs one cross-surface policy that explains how versioning, +deprecation, and migration claims are made without collapsing every surface +into a single package-version model. + +The current repository also has a practical tension: release-please owns the +package release process, the package is still pre-1.0, and all checked-in JSON +Schemas are currently `draft` even when their filenames carry `v1` or `v2` +lineage suffixes. A reader can otherwise infer stronger compatibility +guarantees than the repository can honestly provide. + +## Decision + +Adopt a two-part governance structure: + +1. This ADR records the architectural decision and rationale. +2. `specs/evolution/versioning-deprecation-and-migration.md` is the normative + operational policy for versioning, compatibility, deprecation, removal, and + migration across ecosystem surfaces. + +The policy has these core rules. + +Version identifiers are surface-local. Package SemVer, Git tags, contract +lineage suffixes, wire discriminators, module versions, apparatus versions, +domain artifact versions, ADR status, and requirement status must not be +treated as interchangeable. + +Compatibility claims must name their direction, producer, consumer, surface, +and dimension. The recognized directions are backward, forward, and full +compatibility. The recognized dimensions are structural acceptance, semantic +equivalence, behavioral compatibility, and operational interoperability. + +Stability, deprecation, and removal are separate lifecycle concepts. ADR-061 +`draft` or `stable` classification governs how a schema may evolve; it does +not say whether a surface is deprecated or removed. ADR status and Ground +Control requirement status remain governance workflow state, not artifact +lifecycle records. + +Published JSON Schemas continue to follow ADR-061. This decision narrows the +meaning of "additive" at the ecosystem boundary: an optional schema property or +enum value can be structurally additive to the schema lineage, but it is not an +end-to-end compatibility guarantee unless the relevant installed readers are +shown to accept it or the policy explicitly scopes the claim to schema +structure only. + +Deprecations must be explicit records. A deprecation record identifies the +exact surface, first release or contract lineage carrying the notice, +replacement, migration reference, expected notice window or removal eligibility +rule, verification evidence, and any security exception. + +Migrations are surface-specific. Human migration notes belong under the +existing documentation boundary. Automated migrations are added only when the +transformation is deterministic, idempotent, preserves source data, reports +ambiguous or lossy cases, and fails closed. + +Adapters stay at their owning boundary. The policy does not create a universal +version registry, migration service, runtime endpoint, persistence layer, or +cross-package exception hierarchy. + +## Alternatives Considered + +Extend ADR-061 to govern every ecosystem surface. Rejected: ADR-061 is the +right authority for published JSON Schemas, but package releases, SDL modules, +apparatus manifests, and experiment artifacts have different authorities and +compatibility relations. + +Use Python package SemVer as the single ecosystem version. Rejected: the +package is a release vehicle, not the identity of each contract lineage, +scenario module, processor/backend declaration, or experiment artifact. + +Create a central runtime versioning or migration service. Rejected: the current +need is repository governance, and each executable surface already has an +owning validator, registry, checker, or adapter boundary. A central service +would blur ownership and invite best-effort coercion. + +Treat deprecation as a generic warning. Rejected: lifecycle notice must remain +separate from semantic validity. Deprecated-but-supported use is non-fatal; +removed or unsupported input fails through the owning surface's existing error +envelope. + +## Consequences + +The ecosystem gets one vocabulary for versioning, compatibility, deprecation, +and migration without weakening existing authorities such as ADR-061, +release-please, the schema publication manifest, or module registry checks. + +Future surface families have a clear extension seam: add a row to the +normative surface-class matrix and update that surface's owning checker or +documentation rather than adding a universal runtime abstraction. + +Some existing documentation remains more informal than the new policy. Follow-up +work may need to align release, migration, SDL, API, and conformance prose with +the normative spec. + +Because this ADR is proposed, teams can still refine the surface matrix before +acceptance. Accepted amendments to already accepted ADRs remain governed by +ADR-059; this ADR does not silently edit ADR-061 or any earlier decision. diff --git a/docs/decisions/issue-90-gov-901-903-versioning-governance-preflight.md b/docs/decisions/issue-90-gov-901-903-versioning-governance-preflight.md new file mode 100644 index 000000000..1c0ff9ef5 --- /dev/null +++ b/docs/decisions/issue-90-gov-901-903-versioning-governance-preflight.md @@ -0,0 +1,362 @@ +# Issue 90 GOV-901/902/903 Versioning Governance Preflight + +Date: 2026-07-11 + +Issue: #90. Spawned implementation issues: #240 (GOV-901), #241 (GOV-902), +and #242 (GOV-903). Branch anchor: GOV-901. + +Requirement: none. The joint design issue is the authoritative contract. + +This note records architecture guardrails for the joint versioning, +deprecation, and migration policy. It is implementation guidance only: it does +not create the policy ADR, normative governance specification, lifecycle +records, migrations, compatibility adapters, validators, release changes, or +published contract changes. + +## Binding Sources + +- ADR-009, ADR-019, and `specs/authority/authority-boundary.yaml` make prose + under `specs/` normative and keep implementation code non-normative. The + governance rules belong in a normative spec; an ADR should record the + decision and rationale without duplicating the full rule set. +- ADR-061 and `contracts/schema-publication-manifest.json` already own + published JSON Schema lineage, `draft`/`stable` classification, canonical + hashes, change descriptions, removal tombstones, and the conservative + stable-schema compatibility gate. The joint policy must compose with this + authority rather than create a second schema registry or classifier. +- ADR-010 and `tools/policy/adr_policy.yaml` own the one-way `aces.*` + compatibility layer and package ownership boundaries. Import compatibility + is one governed surface, not the definition of ecosystem compatibility. +- ADR-053 and `aces_sdl.module_registry` own scenario/module version matching, + lock identities, digest and signature checks, and import trust. Module + versions are not package versions or contract lineage identifiers. +- ADR-014, `.ground-control.yaml`, `.gc/plan-rules.md`, and `noxfile.py` make + `nox -s verify` the canonical verification graph. +- ADR-059, `docs/decisions/adrs/adr-index.yaml`, and + `tools/check_adr_immutability.py` govern accepted ADR evolution. ADR-061 must + not be silently edited to absorb the new policy. +- `docs/explain/releasing.md`, `.github/workflows/release-please.yml`, + `release-please-config.json`, `.release-please-manifest.json`, and + `implementations/python/pyproject.toml` are the current package-release + workflow. Release-please, Conventional Commit PR titles, and the static + project version are the canonical incumbents. +- `specs/sdl/diagnostics.md` defines the SDL error/advisory boundary. + `SDLParseError`, `SDLValidationError`, `SDLInstantiationError`, shared + `Diagnostic`, Typer errors, and bounded control-plane `HTTPException` + responses are existing delivery envelopes; lifecycle policy must not create + a universal exception hierarchy. + +## Version Domains Must Stay Distinct + +The joint policy must classify the existing version domains before it defines +compatibility. A common spelling does not give two fields common semantics. + +| Surface | Canonical incumbent | Meaning and guardrail | +|---|---|---| +| Python distribution and Git tag | `pyproject.toml`, release-please manifest/config/workflow | One `X.Y.Z` package release and matching `vX.Y.Z` tag. Release-please owns bumps and `CHANGELOG.md`; feature work must not hand-edit either. | +| Published contract lineage | `contracts/schema-publication-manifest.json`, ADR-061, `contracts/schemas/` | Exact contract ids such as `backend-manifest-v2` and payload discriminators such as `backend-manifest/v2`. The major suffix identifies a lineage; it is not a package version or a stability promise. | +| State/envelope schema identity | `aces_contracts.versions` and closed contract models | Exact wire discriminator such as `workflow-step-state/v1`. It selects payload shape; it is not an apparatus or package release. | +| Processor/backend support declaration | `aces_contracts.manifest_authority` and manifest models | Despite the legacy field name `supported_contract_versions`, values are exact governed contract ids, not SemVer ranges. Do not reinterpret the field in place. | +| Apparatus identity | `ApparatusIdentity`, processor/backend/participant manifests | Product/component identity version. Current contracts require only a non-empty value and compatibility blocks name counterpart implementations; they do not establish version-range negotiation. | +| SDL scenario and module | `Scenario.version`, `ModuleDescriptor.version`, `ImportDecl.version`, module registry | Author/module release identity. Import matching uses `packaging` specifiers for parseable versions and exact-string fallback otherwise. `*` means unpinned. It does not select an SDL schema version. | +| Domain artifact versions | experiment task/run/study fields, behavior `semantic_version`, external vocabulary source versions | Version the owning domain assigns to the artifact or source. It remains governed by that domain and must not inherit package or schema bump rules accidentally. | +| Decision and requirement status | ADR status/pin gate and Ground Control requirement status | Governance workflow state, not consumer-facing artifact lifecycle. `deprecated` ADR status must not be reused as a contract deprecation record. | + +The normative governance spec should carry one surface-class matrix that names +the identifier syntax, authority, compatibility relation, lifecycle evidence, +and migration evidence for each class. This is a documentation seam, not a new +runtime registry or universal `Version` model. + +## Architecture Decisions And Guardrails + +- Use one umbrella ADR for the cross-surface decisions and one normative + governance specification under `specs/`. Keep rationale and rejected + alternatives in the ADR; keep operational rules, terms, and the surface + matrix in the spec. Explanatory release or migration docs may point to the + spec but must not restate a competing policy. +- Treat versioning, deprecation, and migration as related but separate + concepts. A version identifies an artifact or lineage; compatibility is a + directional relation between a producer and consumer; deprecation is a + lifecycle notice while a surface still works; migration is the documented or + executable transition. None is a synonym for another. +- Define compatibility direction explicitly: backward (new reader accepts old + producer output), forward (old reader accepts new producer output), and full + compatibility where both hold. Name the producer, consumer, and compared + surface. A bare claim that a change is "compatible" is insufficient. +- Separate structural acceptance, semantic equivalence, behavioral + compatibility, and operational interoperability. Passing JSON Schema does + not prove that meaning or runtime behavior is preserved; sharing a package + version does not prove that two apparatus manifests interoperate. +- Keep stability and lifecycle orthogonal. ADR-061 `draft`/`stable` says how a + schema may evolve; active/deprecated/removed says whether consumers should + adopt it. Do not overload `stability`, JSON Schema's `deprecated` annotation, + ADR status, or requirement status to carry all lifecycle meanings. +- Resolve ADR-061's additive-change assumption explicitly. The reference + `ContractModel` and `SDLModel` are closed (`extra="forbid"`), and many enum + fields are literals. An optional property or enum addition that is additive + to a schema can still be rejected by an older installed reader. The joint + policy must either state the compatible-reader assumption and its evidence or + supersede/narrow the earlier claim; it must not silently call structural + schema growth end-to-end backward compatible. +- Define pre-1.0 package behavior deliberately. Release-please demotes a major + breaking bump to a minor release before 1.0. The policy must say what + compatibility a `0.y.z` consumer may rely on and must not imply ordinary + post-1.0 SemVer guarantees while automation follows a different rubric. +- Preserve the bundled-release fact: the Python code and contract corpus ship + in one `aces-sdl` artifact. This gives a coordinated release unit but does not + erase independent contract ids or make an older external processor/backend + compatible with a newer corpus automatically. +- A deprecation record must identify the exact surface, first release or + contract lineage carrying the notice, replacement, migration reference, + removal eligibility rule, and any security exception. Notice without a + supported replacement and migration path is not a complete deprecation. +- Removal must be explicit and evidence-backed. For published schemas, retain + ADR-061 version-bump rules and manifest tombstones. For Python/API/CLI/SDL + surfaces, use their existing release notes, tests, diagnostics, and + compatibility seams. Do not encode every removal in the schema manifest. +- Security removals need a named exception path that can shorten ordinary + notice while recording impact, affected versions, mitigation, and migration. + "Security" must not become an unreviewed bypass for arbitrary breaking + changes. +- Deprecation notices are non-fatal while the old surface remains supported. + Actual removal or an unsupported version fails through the owning surface's + existing error envelope. Do not turn a lifecycle notice into an SDL semantic + error, or hide an invalid removed construct as a non-fatal advisory. +- Migration guidance must be version-pair and surface specific. Human-readable + notes belong under the existing `docs/migration/` boundary; parser/CLI + messages may link or point to them. An automated migrator is justified only + for deterministic transformations and must be idempotent, preserve source + data, report lossy/ambiguous cases, and fail closed rather than silently drop + unknown fields. +- Compatibility adapters remain at the owning boundary: `aces.*` re-exports in + the compatibility tree, SDL normalization/composition in `aces_sdl`, contract + readers in `aces_contracts`, CLI presentation in `aces_cli`. Do not create a + cross-package migration service or make runtime/backend layers reinterpret + authored source. + +## Required Incumbents + +- Release and package identity: + `.github/workflows/release-please.yml`, `release-please-config.json`, + `.release-please-manifest.json`, `implementations/python/pyproject.toml`, + installed-distribution metadata, `aces_cli.main --version`, and + `docs/explain/releasing.md`. +- Release classification and audit trail: `tools/check_pr_title.py`, + `.github/workflows/pr-title-lint.yml`, Conventional Commit squash titles, + release-please's release PR, Git tag, GitHub Release, PyPI artifact, and + generated `CHANGELOG.md`. +- Published contracts: ADR-009, ADR-061, `contracts/README.md`, + `contracts/schema-publication-manifest.json`, + `tools/check_schema_publication.py`, `tools/check_generated_schemas.py`, + `tools/check_json_artifacts.py`, `schema_bundle()`, and the existing valid and + invalid fixture suites. +- Contract version vocabulary: `aces_contracts.versions`, + `aces_contracts.manifest_authority`, closed `ContractModel` DTOs, processor, + backend, participant implementation, control-plane, profile, and conformance + models that consume those exact ids. +- SDL/module compatibility: `Scenario`, `ModuleDescriptor`, `ImportDecl`, + `Lockfile`, `TrustPolicy`, `_satisfies_version()`, digest/signature/version + gates, whole-scenario semantic validation, and module registry tests. +- Compatibility direction and ownership: ADR-010, + `tools/policy/adr_policy.yaml`, `tools/policy/repo_policy.py`, owning packages + under `implementations/python/packages/`, and wrapper-only + `implementations/python/src/aces/`. +- Diagnostics and errors: `specs/sdl/diagnostics.md`, SDL error classes, + `aces_contracts.diagnostics`, Typer's current user-facing errors, + `ControlPlaneSecurityConfig.strict_defaults()`, bounded `HTTPException` + details, audit records, and the redacted internal-error handler. +- Repository workflow: `.ground-control.yaml`, `.gc/plan-rules.md`, + `noxfile.py`, `tools/check_repo_policy.py`, + `tools/check_requirement_governance.py`, `tools/verify_all.py`, ADR index and + immutability checks, docs build, and existing release/schema/manifest tests. + +## Whole-Repo View + +The intended design is repository governance, not a local schema edit. Its +scope includes: + +- normative policy prose under `specs/` and the authority manifest that makes + that prose binding; +- the policy ADR, ADR index, acceptance pin, and ADR-061 relationship; +- package version metadata, release-please configuration, PR title policy, + release workflow permissions, Git tags, PyPI publication, and changelog; +- published schemas, fixtures, profiles, concept catalogs, schema publication + manifest, contract corpus packaging, generated-schema parity, and contract + allowlists; +- SDL scenario/module versions, module import ranges, lockfiles, trust policy, + parser normalization, deprecation messages, and semantic diagnostics; +- processor/backend/participant manifest identity, supported contract ids, + compatibility declarations, conformance profiles, and runtime admission; +- explanatory release, migration, SDL, API, and contributor documentation; +- owning packages and compatibility-only wrappers; and +- policy, contract, unit, conformance, docs, and release-workflow tests in the + canonical nox graph. + +No database, service repository, runtime state store, or new controller is +needed to state this policy. Git-tracked specs, manifests, release metadata, +and tests are the persistence and audit surfaces. + +## Cross-Cutting Layers + +The intended design must pass every layer it touches: + +- Normative authority gate: binding rules live under `specs/`; the ADR records + why. Reference Python models, examples, docs, and generated schemas remain + consumers/evidence. A new top-level authority root or duplicate governance + schema is not required. +- Release-input validation: PR titles are untrusted GitHub event data and must + continue through `tools/check_pr_title.py`, which parses event JSON without + shell interpolation. Breaking markers and release types must match + release-please configuration and the normative policy. +- Release security: retain pinned GitHub Actions, least-privilege job + permissions, PyPI OIDC trusted publishing, protected environment use, and no + stored PyPI token. A future release-policy check must not require secrets or + widen `contents`, `pull-requests`, or `id-token` permissions. +- Package/config shapes: keep release-please's JSON config and manifest, + `pyproject.toml` project version, and installed distribution metadata as the + package seams. Do not add environment-variable version overrides or a second + version file. +- Schema publication gate: schema paths remain normalized, repo-relative, and + contained under `contracts/schemas/`; ids match filenames; hashes and change + descriptions match content; stable incompatible edits require a new lineage; + removals carry tombstones. Generated-schema parity remains evidence, not + authority. +- Contract and SDL validation: exact schema discriminators and supported + contract ids continue through closed Pydantic models, semantic validators, + JSON Schema validation, profile/conformance checks, and manifest authority + allowlists. Compatibility policy must not bypass these gates with coercion or + "best effort" fallback. +- Module trust and version gate: module constraints continue through + `packaging` specifier parsing or exact-string fallback, then lockfile, + registry allowlist, signature, digest, export-hash, archive-safety, and + whole-scenario validation. A version match is not a trust decision. +- Auth and control-plane gate: the design adds no endpoint. If a future API + exposes version/support information, it must reuse strict default auth, + read-role dependencies, request-size guards, published DTOs, audit events, + and redacted errors. Compatibility status must never grant authorization. +- Secret-handling gate: versions, lifecycle records, compatibility reports, + migrations, release notes, and diagnostics must not carry tokens, private + keys, registry credentials, environment dumps, private configuration, or raw + payloads. No secret file or new secret is needed for governance validation. +- OS/process exposure gate: keep version and base-revision values as ordinary + validated data. Do not put credentials in process argv, shell-interpolate PR + titles or migration input, execute user-provided version strings, or add + subprocess-based version discovery when distribution metadata and Git + artifacts already exist. +- Error-envelope gate: accepted-but-deprecated use gets a bounded notice in the + owning channel; unsupported or removed input gets the existing SDL, + contract, CLI, conformance, or HTTP error. Messages may name surface ids and + versions but must not dump full artifacts, environment state, tracebacks, or + secrets. +- Observability and audit: package evolution is visible through the release PR, + tag, GitHub Release, PyPI artifact, and `CHANGELOG.md`; schema evolution + through hashes, `last_change`, tombstones, and CI; policy verification through + nox `START`/`PASS`/`FAIL` events. Do not add runtime logs, telemetry, or a + database audit table for repository governance. + +## Extensibility Seam + +The extension seam is the normative surface-class matrix. Each row owns: + +- identifier and comparison syntax; +- source of truth; +- producer and consumer roles; +- structural, semantic, behavioral, and operational compatibility claims; +- stability and lifecycle rules; +- deprecation notice and removal eligibility parameters; and +- required migration evidence. + +The obvious future variation is a new independently released artifact family +or a different notice window for an existing class. It should add or amend one +matrix row and extend the owning manifest/checker, not require edits to every +parser, controller, DTO, service, repository, exception, and workflow. +Lifecycle thresholds belong as named policy parameters in the normative spec; +surface-specific machine enforcement stays with the existing schema manifest, +release workflow, module registry, or compatibility wrapper gate. + +## Existing Drift To Resolve Deliberately + +- `.gc/plan-rules.md`, `docs/explain/releasing.md`, the release workflow, and + `CHANGELOG.md` say release-please owns releases and that feature work does not + add fragments. `README.md`, `CONTRIBUTING.md`, the pull-request template, and + `specs/authority/authority-boundary.yaml` still describe towncrier and + `changelog.d/`; `changelog.d/` currently exists. The policy must name one + workflow (release-please is the operating incumbent) and the repository must + not retain contradictory contributor instructions. +- `docs/conf.py` reports `0.1.0` while the project and release manifest report + `0.19.1`. CLI and compatibility fallbacks also use `0.1.0`, while processor + and backend fallbacks use `0.0.0+unknown`; the FastAPI OpenAPI document + hard-codes `0.1.0`. The design must classify package version, API description + version, and "not installed" fallback instead of synchronizing unrelated + literals blindly. +- No checked-in schema is currently `stable`, so ADR-061's breaking-change + path is exercised mainly by synthetic policy tests. Promotion criteria and + regression evidence must be explicit before the first stable promotion. +- `ImportDecl.path` is described as deprecated but accepted without an emitted + lifecycle notice. Removed scoring fields have a useful parser migration + pointer, while `docs/migration/README.md` only records historical repository + moves. These are evidence that deprecation notices and migration guidance are + not yet a coherent cross-surface lifecycle. +- `supported_contract_versions` names exact ids, and apparatus compatibility + names implementations without version constraints. Renaming or expanding + those published shapes is itself a compatibility change; governance must + document present meaning before proposing a new negotiation surface. +- ADR-061's compatibility classifier is deliberately conservative and + incomplete. Its result must be described as the enforced structural floor, + not proof of semantic or implementation-reader compatibility. + +## Gotchas And Anti-Patterns + +Avoid: + +- one universal `Version`, `Compatibility`, `Deprecation`, or `Migration` + runtime model spanning package, schema, module, apparatus, and domain + artifacts; +- treating SemVer, PEP 440, contract `vN` suffixes, state schema discriminators, + source release ids, and ADR status as interchangeable; +- calling a change compatible without naming direction, producer, consumer, + and structural/semantic/behavioral/operational dimension; +- equating JSON Schema acceptance with compatibility for older closed-world + Pydantic readers; +- using package release bumps instead of independent contract ids, or using a + new contract id for every package patch; +- reinterpreting `supported_contract_versions` as ranges, or + `compatibility.processors/backends` as version negotiation, without a new + governed contract change; +- duplicating the schema manifest, contract allowlists, module version matcher, + release version file, changelog generator, validation helpers, diagnostics, + exception hierarchy, or compatibility workflow; +- editing accepted ADR-061 without supersession or a recorded ADR-059 + amendment; +- silently accepting removed syntax, dropping unknown migration data, applying + lossy auto-fixes, or allowing adapters to become permanent undocumented + semantics; +- emitting deprecation warnings from multiple layers for one use, or turning + notices into fatal semantic errors before removal; +- hand-editing package versions or `CHANGELOG.md`, reviving a parallel + towncrier workflow, or deriving release policy from stale docs; +- adding an unauthenticated version endpoint, environment-driven compatibility + override, token-bearing argv, raw-payload error, or runtime persistence layer + for repository policy; and +- claiming a support window, migration capability, stable contract, or + compatibility guarantee that no test or release artifact demonstrates. + +## Non-Goals And Implementation Boundaries + +- Implementing GOV-901, GOV-902, or GOV-903 in this preflight note. +- Writing the issue implementation plan or deciding the spawned issues' work + sequencing. +- Creating the umbrella ADR, normative governance spec, lifecycle registry, + schema, fixture, validator, migration guide, migrator, warning, adapter, or + release gate. +- Changing package versions, tags, release-please behavior, changelog history, + branch protection, PyPI publication, or support commitments. +- Promoting any schema to `stable`, changing a contract id, adding version + negotiation, or redefining existing manifest fields. +- Removing compatibility wrappers, deprecated SDL syntax, legacy documents, + or stale version literals during architecture preflight. +- Redesigning parser normalization, semantic validation, module trust, + apparatus admission, control-plane auth, persistence, logging, or error + handling. +- Guaranteeing compatibility with arbitrary pre-policy releases or external + implementations for which the repository has no conformance evidence. diff --git a/specs/README.md b/specs/README.md index 6abdc3677..f2d792436 100644 --- a/specs/README.md +++ b/specs/README.md @@ -36,6 +36,8 @@ hook). `prose` family of the `specs/` root. - `concept-authority/` — concept-family and controlled-vocabulary authority artifacts (governed by ADR-012) +- `evolution/` — ecosystem versioning, deprecation, removal, and migration + governance for GOV-901, GOV-902, and GOV-903 (governed by ADR-075) - `sdl/` — the language-neutral normative SDL authoring specification (the catalog set the published `contracts/schemas/sdl/` schemas must agree with; governed by ADR-001 and ADR-009) diff --git a/specs/evolution/README.md b/specs/evolution/README.md new file mode 100644 index 000000000..359dfd602 --- /dev/null +++ b/specs/evolution/README.md @@ -0,0 +1,13 @@ +# Evolution Governance + +This directory contains normative ACES ecosystem policy for artifact +evolution: versioning, compatibility, deprecation, removal, and migration. + +The governing decision is +[ADR-075](../../docs/decisions/adrs/adr-075-ecosystem-versioning-deprecation-and-migration-governance.md). + +## Files + +- [`versioning-deprecation-and-migration.md`](versioning-deprecation-and-migration.md) + defines the normative surface-class matrix and rules for GOV-901, GOV-902, + and GOV-903. diff --git a/specs/evolution/versioning-deprecation-and-migration.md b/specs/evolution/versioning-deprecation-and-migration.md new file mode 100644 index 000000000..744368668 --- /dev/null +++ b/specs/evolution/versioning-deprecation-and-migration.md @@ -0,0 +1,183 @@ +# Versioning, Deprecation, and Migration Governance + +This specification is normative for ACES ecosystem evolution policy. It +implements the joint design surface for GOV-901, GOV-902, and GOV-903. + +## Scope + +This policy governs versioning, compatibility claims, deprecation records, +removal eligibility, and migration guidance for repository-governed ecosystem +surfaces. + +It does not create a universal runtime version registry, cross-package +migration service, API endpoint, database table, or exception hierarchy. Each +surface remains owned by its existing authority, checker, schema, validator, +or adapter boundary. + +## Terms + +**Version identifier** means a surface-local value that identifies a release, +lineage, payload shape, source artifact, or domain artifact. + +**Producer** means the surface that writes, publishes, emits, or serves an +artifact. + +**Consumer** means the surface that reads, imports, validates, executes, or +otherwise depends on the artifact. + +**Backward compatibility** means a newer consumer accepts and preserves the +meaning of older producer output for the named surface and dimension. + +**Forward compatibility** means an older consumer accepts and safely handles +newer producer output for the named surface and dimension. + +**Full compatibility** means both backward and forward compatibility hold for +the named surface and dimension. + +**Structural acceptance** means the consumer accepts the artifact's syntactic +or schema shape. + +**Semantic equivalence** means accepted input preserves the same ACES meaning. + +**Behavioral compatibility** means runtime, CLI, API, validation, or +conformance behavior remains within the documented contract. + +**Operational interoperability** means independently released components can +work together under documented admission, capability, and deployment rules. + +## Surface-Class Matrix + +Each surface class owns its version meaning and compatibility rule. + +| Surface class | Authority | Identifier syntax | Compatibility relation | Lifecycle record | Migration evidence | +|---|---|---|---|---|---| +| Python distribution | `implementations/python/pyproject.toml`, release-please config, Git tag, PyPI artifact | SemVer package version and matching `vX.Y.Z` tag | Consumer code imports installed package APIs for the documented release | Release PR, GitHub Release, generated `CHANGELOG.md` | Release notes and package/API docs | +| Published JSON Schema | ADR-061 and `contracts/schema-publication-manifest.json` | Contract id such as `backend-manifest-v2`; wire discriminator such as `backend-manifest/v2` | Schema lineage and stability-specific structural compatibility | Manifest `stability`, `last_change`, and `removed_schemas` tombstones | Schema diff, fixtures, checker output, and contract docs | +| Closed contract DTO and wire envelope | `aces_contracts.versions`, contract models, manifest authority | Exact discriminator such as `workflow-step-state/v1` | Exact payload-shape selection by owning reader | Contract ADR/spec and release notes | Reader validation tests and conformance fixtures | +| Processor/backend support declaration | Manifest models and manifest authority allowlists | Exact governed contract ids, not version ranges | Declared support for named counterpart contract ids | Manifest contract lineage and conformance result | Manifest update, fixture, and conformance report | +| Apparatus or implementation identity | Processor, backend, and participant manifests | Product/component identity version string | Operational interoperability declared by manifest capability blocks | Manifest release and conformance profile | Backend/processor/participant conformance evidence | +| SDL scenario and module | SDL spec, module registry, lockfile, trust policy | Scenario/module version and import version constraint | Import constraint satisfaction plus registry, digest, signature, and semantic validation | SDL/module documentation and release notes | Migration note or deterministic source rewrite | +| Experiment task, run, study, evidence, or domain artifact | Owning experiment-core spec and contract | Domain-specific artifact version | Domain semantic or provenance compatibility | Owning ADR/spec and contract lineage | Domain fixtures, replay/evidence checks, or migration note | +| ADR | ADR-000 and ADR-059 | ADR number plus status | Citable accepted content, not runtime compatibility | Status, pin, amendment row, or supersession | New ADR or recorded amendment | +| Ground Control requirement | Ground Control requirement graph | Requirement UID and status | Traceability/workflow state, not artifact compatibility | Requirement status and IMPLEMENTS/TESTS/DOCUMENTS links | Post-merge traceability reconciliation | + +New surface classes extend this table and their owning checker or +documentation. They do not create a central runtime registry by default. + +## Compatibility Claims + +A compatibility claim is valid only when it names: + +- the surface class; +- producer and consumer; +- direction: backward, forward, or full; +- dimension: structural, semantic, behavioral, or operational; +- version or lineage range under discussion; and +- verification evidence. + +A bare statement that a change is "compatible" is incomplete. + +Structural acceptance is not semantic compatibility. A JSON Schema accepting +an object does not prove the object preserves SDL meaning, conformance +behavior, or runtime interoperability. + +For published JSON Schemas, ADR-061 remains authoritative. Optional properties, +enum additions, and looser constraints can be additive to schema structure, but +they are not automatically forward-compatible with older closed readers. A PR +claiming end-to-end compatibility for such a change must show reader evidence +or scope the claim to structural schema compatibility only. + +Before the Python package reaches 1.0, release-please may classify breaking +changes as minor releases according to the repository's release configuration. +Consumers must not infer post-1.0 SemVer stability from a `0.y.z` package +release unless a specific surface policy states it. + +## Deprecation + +A deprecation is a lifecycle notice for a still-supported surface. It is not a +schema stability class, ADR status, requirement status, or semantic validation +error by itself. + +A complete deprecation record names: + +- exact surface and identifier; +- first release, contract lineage, or documentation version carrying the + notice; +- replacement surface or explicit no-replacement rationale; +- migration reference; +- minimum notice window or removal eligibility rule; +- verification evidence that the old surface remains supported during the + notice window; and +- security exception, if the ordinary notice window is shortened. + +Deprecated-but-supported use must remain non-fatal. The owning channel may emit +a bounded notice, advisory, warning, release-note entry, or documentation +marker. Actual removal or unsupported use fails through the owning surface's +existing SDL, contract, CLI, conformance, or HTTP error envelope. + +Security exceptions may shorten ordinary notice only when the record names the +affected versions, impact, mitigation, migration path, and review authority. +The word "security" is not a blanket bypass for unreviewed breaking changes. + +## Removal + +Removal is allowed only after a complete deprecation record reaches its removal +eligibility rule, or under a documented security exception. + +Published JSON Schema removal follows ADR-061 and must leave the required +manifest tombstone. Other surface removals use their owning evidence: +release notes, ADR/spec updates, fixtures, tests, conformance reports, or +diagnostics. + +Removal must not silently reinterpret old input as new input. If a removed +surface appears, the owning reader fails with a bounded, surface-specific +message and, when available, points to the migration reference. + +## Migration + +Migration guidance is surface-specific and version-pair oriented. A migration +record names: + +- source surface and version or lineage; +- target surface and version or lineage; +- transformation type: manual, assisted, or automated; +- data preservation rules; +- ambiguous or lossy cases; +- validation command or evidence; and +- rollback or no-rollback statement. + +Human-readable migration notes belong under the existing documentation +boundary. Automated migrators are justified only for deterministic +transformations. They must be idempotent, preserve source data, report +ambiguous or lossy cases, and fail closed rather than drop unknown fields. + +Compatibility adapters stay at the owning boundary: + +- legacy `aces.*` re-exports stay in the compatibility tree; +- SDL normalization and module composition stay in `aces_sdl`; +- contract readers stay in `aces_contracts`; +- CLI presentation stays in `aces_cli`; and +- backend/runtime layers do not reinterpret authored source to hide migration + needs. + +## Verification + +Changes governed by this policy use the repository's existing gates unless a +surface adds a more specific checker: + +- release classification and version bump evidence: PR title guard, + release-please config, release PR, Git tag, GitHub Release, and PyPI + artifact; +- published schema evolution: schema publication checker, generated-schema + parity, fixture validation, manifest hashes, change ledger, and tombstones; +- ADR evolution: ADR index and accepted-content pin gate; +- SDL/module evolution: parser, validator, module registry, lockfile, trust, + signature, digest, and semantic tests; +- contract and manifest evolution: closed DTO validation, manifest authority, + fixtures, profiles, and conformance checks; and +- repository completion: `tools/check_repo_policy.py`, + `tools/check_requirement_governance.py`, `tools/verify_all.py`, and the + canonical nox verification graph. + +No secret, environment dump, credential, or private configuration is required +to validate this policy. From 2a70e6ae92d96f3e7542d3f930ac6fb240244102 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 11 Jul 2026 01:11:41 -0700 Subject: [PATCH 07/15] fix: reject ambiguous SDL mapping keys (#731) * fix: reject ambiguous SDL mapping keys * Fix SonarCloud findings (cycle 1) --- docs/explain/sdl/parser.md | 42 +- docs/explain/sdl/testing.md | 17 +- .../packages/aces_mcp/tools/authoring.py | 20 +- .../aces_mcp/tools/operation_support.py | 12 +- .../packages/aces_mcp/tools/operations.py | 2 +- .../python/packages/aces_sdl/__init__.py | 9 +- .../python/packages/aces_sdl/_errors.py | 69 ++- .../aces_sdl/_language_diagnostics.py | 13 + .../packages/aces_sdl/_language_references.py | 44 +- .../packages/aces_sdl/_mapping_scopes.py | 77 +++ .../python/packages/aces_sdl/_yaml_loader.py | 478 ++++++++++++++++++ .../packages/aces_sdl/language_service.py | 11 +- .../python/packages/aces_sdl/parser.py | 96 ++-- .../data/sdl/invalid/mapping-key-cycle.yaml | 5 + .../sdl/invalid/mapping-key-exact-root.yaml | 2 + .../data/sdl/invalid/mapping-key-merge.yaml | 12 + .../mapping-key-normalized-nested.yaml | 5 + .../python/tests/test_mcp_server.py | 26 + implementations/python/tests/test_sdl_fuzz.py | 46 ++ .../python/tests/test_yaml_mapping_keys.py | 375 ++++++++++++++ specs/sdl/diagnostics.md | 47 +- specs/sdl/document-model.md | 56 +- 22 files changed, 1329 insertions(+), 135 deletions(-) create mode 100644 implementations/python/packages/aces_sdl/_mapping_scopes.py create mode 100644 implementations/python/packages/aces_sdl/_yaml_loader.py create mode 100644 implementations/python/tests/data/sdl/invalid/mapping-key-cycle.yaml create mode 100644 implementations/python/tests/data/sdl/invalid/mapping-key-exact-root.yaml create mode 100644 implementations/python/tests/data/sdl/invalid/mapping-key-merge.yaml create mode 100644 implementations/python/tests/data/sdl/invalid/mapping-key-normalized-nested.yaml create mode 100644 implementations/python/tests/test_yaml_mapping_keys.py diff --git a/docs/explain/sdl/parser.md b/docs/explain/sdl/parser.md index d07049780..5a2aad81b 100644 --- a/docs/explain/sdl/parser.md +++ b/docs/explain/sdl/parser.md @@ -1,12 +1,17 @@ # SDL Parser Behavior -The parser (`aces.core.sdl.parser`) transforms raw YAML into a validated `Scenario` object through three stages: key normalization, shorthand expansion, and model construction. +The parser (`aces.core.sdl.parser`) transforms raw YAML into a validated +`Scenario` object through source-marked safe composition, mapping-key +validation, key normalization, shorthand expansion, and model construction. This layer is intentionally about syntax, normalization, and structural model construction. It is usually an `FM0` surface under the repository's [coding standards](../reference/coding-standards.md): parser work normally needs ordinary tests, not state-machine modeling or solver-backed formal artifacts, unless it also introduces new semantic invariants above raw syntax. +The mapping-key injectivity gate is such an invariant and is treated as `FM1`: +table-driven and property tests pin ambiguity rejection and literal-map +preservation. ## Key Normalization @@ -25,6 +30,17 @@ nodes: Type: Switch ``` +Field aliases do not imply precedence. Writing both `Name` and `name`, or both +`password-strength` and `password_strength`, in one structural mapping is a +fatal `sdl.mapping_key_conflict`. Exact duplicates are also fatal in +user-defined and native maps, but distinct literal keys such as `Web-App` and +`web_app` remain distinct identifiers. + +The check runs over the composed YAML node graph before a Python dictionary is +constructed, so it retains both authored spellings and source ranges. YAML +anchors remain supported. A `<<` merge is accepted only when all inherited and +local effective keys are disjoint; cyclic aliases are rejected. + ## Shorthand Expansion Several shorthand forms are expanded before model construction: @@ -81,11 +97,13 @@ be migrated to SDL before parsing. ## Validation Pipeline -1. **YAML parsing** — `yaml.safe_load()` -2. **Key normalization** — lowercase field keys, preserve user names -3. **Shorthand expansion** — source, infrastructure, roles, min-score, feature lists -4. **Pydantic construction** — structural validation (types, ranges, required fields) -5. **Semantic validation** — cross-reference checks plus variable-reference checks (22 passes, see [validation.md](validation.md)) +1. **Safe YAML composition** — build a source-marked standard-tag node graph +2. **Mapping-key preflight** — reject exact, normalized, and merge conflicts +3. **Safe construction** — construct native values only after ambiguity checks +4. **Key normalization** — lowercase field keys, preserve user names +5. **Shorthand expansion** — source, infrastructure, roles, min-score, feature lists +6. **Pydantic construction** — structural validation (types, ranges, required fields) +7. **Semantic validation** — cross-reference checks plus variable-reference checks (see [validation.md](validation.md)) On success, the returned `Scenario` may still carry non-fatal advisories in `scenario.advisories` (for example, VM nodes without explicit `resources`). @@ -93,6 +111,7 @@ On success, the returned `Scenario` may still carry non-fatal advisories in `sce ```python from aces.core.sdl import parse_sdl, parse_sdl_file +from aces_sdl import load_sdl_fragment # Parse from string scenario = parse_sdl(yaml_string) @@ -102,6 +121,13 @@ scenario = parse_sdl_file(Path("scenario.yaml")) # Structural validation only (skip cross-reference checks) scenario = parse_sdl(yaml_string, skip_semantic_validation=True) + +# Advanced authoring tools can preflight a fragment at its final address. +nodes = load_sdl_fragment( + nodes_yaml, + mapping_keys="literal", + base_pointer="/nodes", +) ``` Use `parse_sdl_file(...)` for SDL that uses top-level `imports:`. Import @@ -127,5 +153,7 @@ shorthand. They are resolved by the composition layer, not expanded into ## Error Types -- `SDLParseError` — YAML syntax errors, structural validation failures +- `SDLParseError` — YAML syntax errors and structural validation failures; + mapping-key failures carry structured `.diagnostics` with stable code, JSON + Pointer, authored spellings, and source ranges - `SDLValidationError` — semantic validation failures (has `.errors` list with all issues) diff --git a/docs/explain/sdl/testing.md b/docs/explain/sdl/testing.md index 1ba1d3ddf..38613ed30 100644 --- a/docs/explain/sdl/testing.md +++ b/docs/explain/sdl/testing.md @@ -66,7 +66,10 @@ cd implementations/python && \ uv run --extra dev pytest tests/test_sdl_fuzz.py -m fuzz -v ``` -Property-based testing using [Hypothesis](https://hypothesis.readthedocs.io/). Generates ~1,050 random inputs per run across 6 fuzz strategies: +Property-based testing using [Hypothesis](https://hypothesis.readthedocs.io/). +The dedicated mapping-key property tests run in the standard suite; the fuzz +session adds mutation coverage and generates about 1,150 inputs per run across +8 strategies: | Test | Strategy | Examples | |------|----------|----------| @@ -76,6 +79,8 @@ Property-based testing using [Hypothesis](https://hypothesis.readthedocs.io/). G | `test_fuzz_service_ports` | Random port/protocol/name combos | 100 | | `test_fuzz_vulnerability_class_validation` | Random CWE class strings | 100 | | `test_fuzz_feature_dependency_cycles` | Random dependency graphs | 100 | +| `test_fuzz_structural_key_alias_mutations_fail_closed` | Normalized field-alias collisions | 50 | +| `test_fuzz_exact_identifier_duplicate_mutations_fail_closed` | Exact symbol-key duplicates | 50 | The invariant: the parser **never** raises an unhandled exception. Every input either produces a valid `Scenario` or raises `SDLParseError`/`SDLValidationError`. @@ -131,12 +136,14 @@ Use the corpus leg that matches the artifact's purpose: specimens rather than reusable examples. Add them to the relevant `SCENARIOS` list so the parametrized tests pick them up. - **Negative-path cases** should stay close to the parser, model, or validator - rule they exercise. Do not add invalid files under `examples/scenarios/`; - that directory is the positive example corpus. + rule they exercise. Parser-negative SDL fixtures live under + `implementations/python/tests/data/sdl/invalid/`. Do not add invalid files + under `examples/scenarios/`; that directory is the positive example corpus. All scenario corpus loading should continue to flow through the existing -`load_scenario()` / `parse_sdl()` boundary so it receives the same `yaml.safe_load` -parsing, Pydantic structural validation, semantic validation, advisory logging, +`load_scenario()` / `parse_sdl()` boundary so it receives the same source-marked +safe composition, mapping-key preflight, Pydantic structural validation, +semantic validation, advisory logging, and `ScenarioValidationError`/`SDLParseError`/`SDLValidationError` behavior as the rest of the SDL stack. diff --git a/implementations/python/packages/aces_mcp/tools/authoring.py b/implementations/python/packages/aces_mcp/tools/authoring.py index f0e7f1f80..0a9ab515f 100644 --- a/implementations/python/packages/aces_mcp/tools/authoring.py +++ b/implementations/python/packages/aces_mcp/tools/authoring.py @@ -113,7 +113,7 @@ def sdl_validate_section( return f"INPUT TOO LARGE — limit is {_MAX_INPUT_BYTES} bytes." import yaml as _yaml - from aces_sdl import SDLParseError, SDLValidationError, parse_sdl + from aces_sdl import SDLParseError, SDLValidationError, load_sdl_fragment, parse_sdl section = section.strip().lower().replace("-", "_") valid_sections = { @@ -140,18 +140,24 @@ def sdl_validate_section( # Build a minimal valid wrapper try: - section_data = _yaml.safe_load(section_yaml) - except _yaml.YAMLError as exc: - return f"YAML ERROR in section content:\n{exc}" + section_data = load_sdl_fragment( + section_yaml, + mapping_keys="literal", + base_pointer=f"/{section}", + ) + except SDLParseError as exc: + label = "PARSE ERROR" if any(item.code != "sdl.parse" for item in exc.diagnostics) else "YAML ERROR" + return f"{label} in section content:\n{exc.details}" wrapper: dict = {} if context_yaml: try: - ctx = _yaml.safe_load(context_yaml) + ctx = load_sdl_fragment(context_yaml) if isinstance(ctx, dict): wrapper.update(ctx) - except _yaml.YAMLError as exc: - return f"YAML ERROR in context_yaml:\n{exc}" + except SDLParseError as exc: + label = "PARSE ERROR" if any(item.code != "sdl.parse" for item in exc.diagnostics) else "YAML ERROR" + return f"{label} in context_yaml:\n{exc.details}" # Force a safe synthetic name — always last so context_yaml cannot # override it and cause confusing error messages. diff --git a/implementations/python/packages/aces_mcp/tools/operation_support.py b/implementations/python/packages/aces_mcp/tools/operation_support.py index 9355db196..ba5c0acab 100644 --- a/implementations/python/packages/aces_mcp/tools/operation_support.py +++ b/implementations/python/packages/aces_mcp/tools/operation_support.py @@ -54,7 +54,7 @@ def compile_pipeline(sdl_content: str, parameters_json: str) -> dict[str, Any]: try: scenario = parse_sdl(sdl_content) except SDLParseError as exc: - return {"error": stage_error("parse", exc.details), "stages": stages, "scenario": None, "model": None} + return {"error": stage_error("parse", exc), "stages": stages, "scenario": None, "model": None} except SDLValidationError as exc: return { "error": { @@ -139,7 +139,15 @@ def stage_ok(stage: str, *, detail: str = "ok") -> dict[str, str]: return {"stage": stage, "status": "ok", "detail": detail} -def stage_error(stage: str, message: str) -> dict[str, Any]: +def stage_error(stage: str, error: object) -> dict[str, Any]: + structured = getattr(error, "diagnostics", ()) + if structured: + return { + "status": "invalid", + "stage": stage, + "diagnostics": [item.as_dict() for item in structured], + } + message = getattr(error, "details", str(error)) return { "status": "invalid", "stage": stage, diff --git a/implementations/python/packages/aces_mcp/tools/operations.py b/implementations/python/packages/aces_mcp/tools/operations.py index 41ff79ac3..2bc398d4f 100644 --- a/implementations/python/packages/aces_mcp/tools/operations.py +++ b/implementations/python/packages/aces_mcp/tools/operations.py @@ -162,7 +162,7 @@ def sdl_parse( skip_semantic_validation=not semantic_validation, ) except SDLParseError as exc: - return json_response(stage_error("parse", exc.details)) + return json_response(stage_error("parse", exc)) except SDLValidationError as exc: return json_response( { diff --git a/implementations/python/packages/aces_sdl/__init__.py b/implementations/python/packages/aces_sdl/__init__.py index d8dd902bf..ec9d52e09 100644 --- a/implementations/python/packages/aces_sdl/__init__.py +++ b/implementations/python/packages/aces_sdl/__init__.py @@ -10,12 +10,16 @@ __all__ = [ "instantiate_scenario", "InstantiatedScenario", + "load_sdl_fragment", "parse_sdl", "parse_sdl_file", "Scenario", "SDLError", "SDLInstantiationError", + "SDLParseDiagnostic", "SDLParseError", + "SDLSourcePosition", + "SDLSourceRange", "SDLValidationError", "VARIABLE_TOKEN_PATTERN", ] @@ -25,7 +29,10 @@ def __getattr__(name: str): if name in { "SDLError", "SDLInstantiationError", + "SDLParseDiagnostic", "SDLParseError", + "SDLSourcePosition", + "SDLSourceRange", "SDLValidationError", }: module = import_module("aces_sdl._errors") @@ -33,7 +40,7 @@ def __getattr__(name: str): module = import_module("aces_sdl._base") elif name == "instantiate_scenario": module = import_module("aces_sdl.instantiate") - elif name in {"parse_sdl", "parse_sdl_file"}: + elif name in {"load_sdl_fragment", "parse_sdl", "parse_sdl_file"}: module = import_module("aces_sdl.parser") elif name in {"InstantiatedScenario", "Scenario"}: module = import_module("aces_sdl.scenario") diff --git a/implementations/python/packages/aces_sdl/_errors.py b/implementations/python/packages/aces_sdl/_errors.py index 1e0145125..43d978a48 100644 --- a/implementations/python/packages/aces_sdl/_errors.py +++ b/implementations/python/packages/aces_sdl/_errors.py @@ -5,13 +5,73 @@ than failing on the first error. """ +from collections.abc import Iterable +from dataclasses import dataclass from pathlib import Path +from typing import Any class SDLError(Exception): """Base exception for all SDL operations.""" +@dataclass(frozen=True) +class SDLSourcePosition: + """One-based source position.""" + + line: int + column: int + + def as_dict(self) -> dict[str, int]: + return {"line": self.line, "column": self.column} + + +@dataclass(frozen=True) +class SDLSourceRange: + """Half-open source range for one authored token.""" + + start: SDLSourcePosition + end: SDLSourcePosition + + def as_dict(self) -> dict[str, dict[str, int]]: + return {"start": self.start.as_dict(), "end": self.end.as_dict()} + + +@dataclass(frozen=True) +class SDLParseDiagnostic: + """Structured diagnostic produced before SDL model construction.""" + + code: str + message: str + pointer: str + primary_range: SDLSourceRange + authored_keys: tuple[str, str] | None = None + related_range: SDLSourceRange | None = None + related_message: str | None = None + stage: str = "parse" + severity: str = "error" + + def as_dict(self) -> dict[str, Any]: + payload: dict[str, Any] = { + "stage": self.stage, + "severity": self.severity, + "code": self.code, + "message": self.message, + "path": self.pointer, + "range": self.primary_range.as_dict(), + } + if self.authored_keys is not None: + payload["authored_keys"] = list(self.authored_keys) + if self.related_range is not None: + payload["related"] = [ + { + "message": self.related_message or "Related authored key.", + "range": self.related_range.as_dict(), + } + ] + return payload + + class SDLParseError(SDLError): """YAML parsing or structural validation failed. @@ -20,9 +80,16 @@ class SDLParseError(SDLError): details: Detailed error message. """ - def __init__(self, message: str, path: Path | None = None) -> None: + def __init__( + self, + message: str, + path: Path | None = None, + *, + diagnostics: Iterable[SDLParseDiagnostic] = (), + ) -> None: self.path = path self.details = message + self.diagnostics = tuple(diagnostics) prefix = f"{path}: " if path else "" super().__init__(f"{prefix}{message}") diff --git a/implementations/python/packages/aces_sdl/_language_diagnostics.py b/implementations/python/packages/aces_sdl/_language_diagnostics.py index 2bca54455..de576519a 100644 --- a/implementations/python/packages/aces_sdl/_language_diagnostics.py +++ b/implementations/python/packages/aces_sdl/_language_diagnostics.py @@ -4,6 +4,8 @@ from typing import Any +from ._errors import SDLParseError + def invalid( stage: str, @@ -33,3 +35,14 @@ def diagnostic( if location is not None: payload["range"] = {"start": location, "end": location} return payload + + +def parse_error(error: SDLParseError) -> dict[str, Any]: + """Preserve structured parser diagnostics in language-service responses.""" + if error.diagnostics: + return { + "status": "invalid", + "stage": "parse", + "diagnostics": [item.as_dict() for item in error.diagnostics], + } + return invalid("parse", "sdl.parse", error.details) diff --git a/implementations/python/packages/aces_sdl/_language_references.py b/implementations/python/packages/aces_sdl/_language_references.py index 9b7187c5b..db920efec 100644 --- a/implementations/python/packages/aces_sdl/_language_references.py +++ b/implementations/python/packages/aces_sdl/_language_references.py @@ -5,14 +5,13 @@ from collections.abc import Collection from typing import Any -import yaml -from yaml.error import MarkedYAMLError, YAMLError from yaml.nodes import MappingNode, Node, ScalarNode, SequenceNode -from ._language_diagnostics import invalid as _invalid +from ._errors import SDLParseError +from ._language_diagnostics import parse_error as _parse_error from ._language_metadata import REFERENCE_COMPLETION_TARGETS +from ._yaml_loader import compose_sdl_yaml -_CODE_PARSE = "sdl.parse" _SUCCESS_REFERENCE_TARGETS = frozenset({"conditions"}) @@ -23,42 +22,33 @@ def find_references( section_fields: Collection[str], ) -> dict[str, Any]: """Return definition and occurrence locations for an SDL symbol.""" - root, error = _compose_yaml(sdl_content) - if error is not None: - return error + if not sdl_content.strip(): + result = {"status": "ok", "symbol": symbol, "definitions": [], "occurrences": []} + else: + root, error = _compose_yaml(sdl_content) + result = error if error is not None else _reference_result(root, symbol, section_fields) + return result + + +def _reference_result(root: Node | None, symbol: str, section_fields: Collection[str]) -> dict[str, Any]: if root is None: return {"status": "ok", "symbol": symbol, "definitions": [], "occurrences": []} - definitions = _collect_definitions(root, section_fields) - matching_definitions = [definition for definition in definitions if _definition_matches_symbol(definition, symbol)] occurrences: list[dict[str, Any]] = [] - _collect_occurrences( - root, - symbol, - [], - occurrences, - qualified_section=_qualified_symbol_section(symbol), - ) + _collect_occurrences(root, symbol, [], occurrences, qualified_section=_qualified_symbol_section(symbol)) return { "status": "ok", "symbol": symbol, - "definitions": matching_definitions, + "definitions": [item for item in definitions if _definition_matches_symbol(item, symbol)], "occurrences": occurrences, } def _compose_yaml(sdl_content: str) -> tuple[Node | None, dict[str, Any] | None]: try: - return yaml.compose(sdl_content), None - except MarkedYAMLError as exc: - return None, _invalid( - "parse", - _CODE_PARSE, - str(exc.problem or exc), - location=_location_from_mark(exc.problem_mark), - ) - except YAMLError as exc: - return None, _invalid("parse", _CODE_PARSE, str(exc)) + return compose_sdl_yaml(sdl_content), None + except SDLParseError as exc: + return None, _parse_error(exc) def _collect_definitions(root: Node, section_fields: Collection[str]) -> list[dict[str, Any]]: diff --git a/implementations/python/packages/aces_sdl/_mapping_scopes.py b/implementations/python/packages/aces_sdl/_mapping_scopes.py new file mode 100644 index 000000000..a40ef4fe0 --- /dev/null +++ b/implementations/python/packages/aces_sdl/_mapping_scopes.py @@ -0,0 +1,77 @@ +"""Classify SDL mappings whose keys are authored identifiers rather than fields.""" + +from __future__ import annotations + +from enum import Enum + + +class MappingScope(str, Enum): + """Key interpretation for one mapping node.""" + + STRUCTURAL = "structural" + LITERAL = "literal" + + +HASHMAP_SECTIONS = frozenset( + { + "nodes", + "infrastructure", + "features", + "conditions", + "vulnerabilities", + "entities", + "injects", + "events", + "scripts", + "stories", + "content", + "accounts", + "relationships", + "agents", + "action_contracts", + "observation_boundaries", + "outcome_interpretation_rules", + "behavior_specifications", + "evidence_requirements", + "objectives", + "workflows", + "variables", + } +) + +NESTED_HASHMAP_FIELDS = frozenset( + { + "features", + "conditions", + "injects", + "roles", + "log_options", + "labels", + "driver_options", + "ipam_options", + "facts", + "entities", + "events", + "steps", + "extensions", + } +) + + +def normalize_field_key(key: str) -> str: + """Return the canonical spelling of an SDL structural field key.""" + return key.lower().replace("-", "_") + + +def is_literal_map_field( + key: str, + *, + value_is_mapping: bool, + value_is_sequence: bool, +) -> bool: + """Return whether a structural field's immediate child keys are literal.""" + if key in HASHMAP_SECTIONS: + return value_is_mapping + if key in NESTED_HASHMAP_FIELDS: + return True + return key == "properties" and value_is_sequence diff --git a/implementations/python/packages/aces_sdl/_yaml_loader.py b/implementations/python/packages/aces_sdl/_yaml_loader.py new file mode 100644 index 000000000..ab209a162 --- /dev/null +++ b/implementations/python/packages/aces_sdl/_yaml_loader.py @@ -0,0 +1,478 @@ +"""Safe, source-marked YAML composition for the SDL authoring boundary.""" + +from __future__ import annotations + +import textwrap +from collections.abc import Iterator +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml +from yaml.nodes import MappingNode, Node, ScalarNode, SequenceNode + +from ._errors import ( + SDLParseDiagnostic, + SDLParseError, + SDLSourcePosition, + SDLSourceRange, +) +from ._mapping_scopes import MappingScope, is_literal_map_field, normalize_field_key + +_BOOL_TAG = "tag:yaml.org,2002:bool" +_EMPTY_CONTENT_MESSAGE = "SDL content is empty" +_MERGE_TAG = "tag:yaml.org,2002:merge" +_STRING_TAG = "tag:yaml.org,2002:str" + + +class _SDLSafeLoader(yaml.SafeLoader): + """SafeLoader that preserves implicit YAML 1.1 boolean-like map keys.""" + + def __init__(self, stream: str) -> None: + super().__init__(stream) + self._sdl_mapping_key_context: list[bool] = [] + + def compose_node(self, parent: Node | None, index: Node | None) -> Node: + is_mapping_key = isinstance(parent, MappingNode) and index is None + self._sdl_mapping_key_context.append(is_mapping_key) + try: + return super().compose_node(parent, index) + finally: + self._sdl_mapping_key_context.pop() + + def resolve(self, kind: type[Node], value: str | None, implicit: Any) -> str: + tag = super().resolve(kind, value, implicit) + if ( + kind is ScalarNode + and self._sdl_mapping_key_context + and self._sdl_mapping_key_context[-1] + and tag == _BOOL_TAG + ): + return _STRING_TAG + return tag + + +@dataclass(frozen=True) +class _Entry: + canonical: str + authored: str + key_node: ScalarNode + + +@dataclass(frozen=True) +class _EffectiveMapping: + entries: tuple[_Entry, ...] + conflicts: tuple[tuple[_Entry, _Entry], ...] + + +@dataclass +class _EffectiveAccumulator: + entries: list[_Entry] = field(default_factory=list) + conflicts: list[tuple[_Entry, _Entry]] = field(default_factory=list) + seen: dict[str, _Entry] = field(default_factory=dict) + + def add(self, entry: _Entry) -> None: + previous = self.seen.get(entry.canonical) + if previous is None: + self.seen[entry.canonical] = entry + self.entries.append(entry) + else: + self.conflicts.append((previous, entry)) + + def build(self) -> _EffectiveMapping: + return _EffectiveMapping(tuple(self.entries), tuple(self.conflicts)) + + +class _MappingAnalyzer: + def __init__(self) -> None: + self.diagnostics: list[SDLParseDiagnostic] = [] + self._effective_cache: dict[tuple[int, MappingScope], _EffectiveMapping] = {} + self._diagnostic_keys: set[tuple[Any, ...]] = set() + self._walked: set[tuple[int, MappingScope]] = set() + + def analyze( + self, + root: Node, + *, + scope: MappingScope, + base_tokens: list[str], + ) -> tuple[SDLParseDiagnostic, ...]: + self._walk(root, scope=scope, tokens=base_tokens, active=set()) + return tuple( + sorted( + self.diagnostics, + key=lambda item: ( + item.primary_range.start.line, + item.primary_range.start.column, + item.code, + item.pointer, + ), + ) + ) + + def _walk( + self, + node: Node, + *, + scope: MappingScope, + tokens: list[str], + active: set[int], + ) -> None: + identity = id(node) + if identity in active: + self._add_alias_cycle(node, tokens) + else: + walk_key = (identity, scope) + if walk_key not in self._walked and isinstance(node, (MappingNode, SequenceNode)): + self._walked.add(walk_key) + active.add(identity) + try: + if isinstance(node, MappingNode): + self._walk_mapping(node, scope=scope, tokens=tokens, active=active) + else: + self._walk_sequence(node, scope=scope, tokens=tokens, active=active) + finally: + active.remove(identity) + + def _add_alias_cycle(self, node: Node, tokens: list[str]) -> None: + self._add( + SDLParseDiagnostic( + code="sdl.alias_cycle", + message="Cyclic YAML aliases are not valid SDL authoring input.", + pointer=_encode_pointer(tokens), + primary_range=_range_from_node(node), + ) + ) + + def _walk_sequence( + self, + node: SequenceNode, + *, + scope: MappingScope, + tokens: list[str], + active: set[int], + ) -> None: + for index, item in enumerate(node.value): + self._walk(item, scope=scope, tokens=[*tokens, str(index)], active=active) + + def _walk_mapping( + self, + node: MappingNode, + *, + scope: MappingScope, + tokens: list[str], + active: set[int], + ) -> None: + effective = self._effective_mapping(node, scope=scope, active=set()) + for first, conflicting in effective.conflicts: + self._add_conflict(first, conflicting, tokens) + for key_node, value_node in node.value: + self._walk_mapping_entry(key_node, value_node, scope=scope, tokens=tokens, active=active) + + def _add_conflict(self, first: _Entry, conflicting: _Entry, tokens: list[str]) -> None: + self._add( + SDLParseDiagnostic( + code="sdl.mapping_key_conflict", + message=_conflict_message(first, conflicting), + pointer=_encode_pointer([*tokens, conflicting.canonical]), + authored_keys=(first.authored, conflicting.authored), + primary_range=_range_from_node(conflicting.key_node), + related_range=_range_from_node(first.key_node), + related_message=f"First authored key '{first.authored}'.", + ) + ) + + def _walk_mapping_entry( + self, + key_node: Node, + value_node: Node, + *, + scope: MappingScope, + tokens: list[str], + active: set[int], + ) -> None: + if _is_merge_key(key_node): + self._walk_merge_value(value_node, scope=scope, tokens=tokens, active=active) + return + authored = _authored_key(key_node) + if not _is_string_key(key_node): + self._add_key_type_diagnostic(key_node, authored, tokens) + return + canonical = normalize_field_key(authored) if scope is MappingScope.STRUCTURAL else authored + child_scope = _child_scope(scope, canonical, value_node) + self._walk(value_node, scope=child_scope, tokens=[*tokens, canonical], active=active) + + def _add_key_type_diagnostic(self, key_node: Node, authored: str, tokens: list[str]) -> None: + message = ( + "SDL top-level mapping keys must be strings" + if not tokens + else f"SDL mapping key '{authored}' must be a string." + ) + self._add( + SDLParseDiagnostic( + code="sdl.mapping_key_type", + message=message, + pointer=_encode_pointer([*tokens, authored]), + primary_range=_range_from_node(key_node), + ) + ) + + def _walk_merge_value( + self, + node: Node, + *, + scope: MappingScope, + tokens: list[str], + active: set[int], + ) -> None: + if isinstance(node, MappingNode): + self._walk(node, scope=scope, tokens=tokens, active=active) + elif isinstance(node, SequenceNode): + for item in node.value: + self._walk(item, scope=scope, tokens=tokens, active=active) + + def _effective_mapping( + self, + node: MappingNode, + *, + scope: MappingScope, + active: set[int], + ) -> _EffectiveMapping: + cache_key = (id(node), scope) + cached = self._effective_cache.get(cache_key) + if cached is not None: + return cached + if id(node) in active: + return _EffectiveMapping((), ()) + + active.add(id(node)) + accumulator = _EffectiveAccumulator() + try: + merge_keys = self._inherit_merge_entries(node, scope=scope, active=active, accumulator=accumulator) + self._record_duplicate_merge_keys(merge_keys, accumulator) + self._add_local_entries(node, scope=scope, accumulator=accumulator) + finally: + active.remove(id(node)) + + result = accumulator.build() + self._effective_cache[cache_key] = result + return result + + def _inherit_merge_entries( + self, + node: MappingNode, + *, + scope: MappingScope, + active: set[int], + accumulator: _EffectiveAccumulator, + ) -> list[ScalarNode]: + merge_keys: list[ScalarNode] = [] + for key_node, value_node in node.value: + if not _is_merge_key(key_node): + continue + assert isinstance(key_node, ScalarNode) + merge_keys.append(key_node) + for source in _merge_sources(value_node): + if id(source) in active: + continue + inherited = self._effective_mapping(source, scope=scope, active=active) + for entry in inherited.entries: + accumulator.add(entry) + return merge_keys + + @staticmethod + def _record_duplicate_merge_keys( + merge_keys: list[ScalarNode], + accumulator: _EffectiveAccumulator, + ) -> None: + if len(merge_keys) < 2: + return + first = _Entry("<<", "<<", merge_keys[0]) + for key_node in merge_keys[1:]: + accumulator.conflicts.append((first, _Entry("<<", "<<", key_node))) + + @staticmethod + def _add_local_entries( + node: MappingNode, + *, + scope: MappingScope, + accumulator: _EffectiveAccumulator, + ) -> None: + for key_node, _value_node in node.value: + if not _is_string_key(key_node) or _is_merge_key(key_node): + continue + assert isinstance(key_node, ScalarNode) + authored = key_node.value + canonical = normalize_field_key(authored) if scope is MappingScope.STRUCTURAL else authored + accumulator.add(_Entry(canonical, authored, key_node)) + + def _add(self, diagnostic: SDLParseDiagnostic) -> None: + related = diagnostic.related_range + key = ( + diagnostic.code, + diagnostic.pointer, + diagnostic.primary_range.start.line, + diagnostic.primary_range.start.column, + related.start.line if related else None, + related.start.column if related else None, + ) + if key not in self._diagnostic_keys: + self._diagnostic_keys.add(key) + self.diagnostics.append(diagnostic) + + +def load_sdl_yaml( + content: str, + *, + path: Path | None = None, + scope: MappingScope = MappingScope.STRUCTURAL, + base_pointer: str = "", +) -> object: + """Validate and safely construct one SDL YAML document or fragment.""" + prepared = _prepare_content(content, path=path) + loader: _SDLSafeLoader | None = None + try: + loader = _SDLSafeLoader(prepared) + root = loader.get_single_node() + if root is None: + raise SDLParseError(_EMPTY_CONTENT_MESSAGE, path=path) + _validate_mapping_keys(root, path=path, scope=scope, base_pointer=base_pointer) + return loader.construct_document(root) + except SDLParseError: + raise + except yaml.YAMLError as exc: + raise _yaml_parse_error(exc, path=path) from exc + finally: + if loader is not None: + loader.dispose() + + +def compose_sdl_yaml( + content: str, + *, + path: Path | None = None, + scope: MappingScope = MappingScope.STRUCTURAL, + base_pointer: str = "", +) -> Node: + """Compose and key-validate SDL YAML while retaining source nodes.""" + prepared = _prepare_content(content, path=path) + loader: _SDLSafeLoader | None = None + try: + loader = _SDLSafeLoader(prepared) + root = loader.get_single_node() + if root is None: + raise SDLParseError(_EMPTY_CONTENT_MESSAGE, path=path) + _validate_mapping_keys(root, path=path, scope=scope, base_pointer=base_pointer) + return root + except SDLParseError: + raise + except yaml.YAMLError as exc: + raise _yaml_parse_error(exc, path=path) from exc + finally: + if loader is not None: + loader.dispose() + + +def _validate_mapping_keys( + root: Node, + *, + path: Path | None, + scope: MappingScope, + base_pointer: str, +) -> None: + diagnostics = _MappingAnalyzer().analyze(root, scope=scope, base_tokens=_decode_pointer(base_pointer)) + if not diagnostics: + return + rendered: list[str] = [] + for item in diagnostics: + location = item.primary_range.start + detail = ( + f"[{item.code}] {item.pointer or '/'} at line {location.line}, column {location.column}: {item.message}" + ) + if item.related_range is not None: + related = item.related_range.start + detail += f" First declaration at line {related.line}, column {related.column}." + rendered.append(detail) + details = "SDL mapping-key validation failed:\n " + "\n ".join(rendered) + raise SDLParseError(details, path=path, diagnostics=diagnostics) + + +def _prepare_content(content: str, *, path: Path | None) -> str: + prepared = textwrap.dedent(content) + if not prepared.strip(): + raise SDLParseError(_EMPTY_CONTENT_MESSAGE, path=path) + return prepared + + +def _yaml_parse_error(error: yaml.YAMLError, *, path: Path | None) -> SDLParseError: + mark = getattr(error, "problem_mark", None) + problem = getattr(error, "problem", None) + if mark is None: + return SDLParseError(f"Invalid YAML: {error}", path=path) + position = SDLSourcePosition(mark.line + 1, mark.column + 1) + diagnostic = SDLParseDiagnostic( + code="sdl.parse", + message=str(problem or error), + pointer="", + primary_range=SDLSourceRange(start=position, end=position), + ) + return SDLParseError(f"Invalid YAML: {error}", path=path, diagnostics=(diagnostic,)) + + +def _is_merge_key(node: Node) -> bool: + return isinstance(node, ScalarNode) and node.tag == _MERGE_TAG + + +def _is_string_key(node: Node) -> bool: + return isinstance(node, ScalarNode) and node.tag == _STRING_TAG + + +def _authored_key(node: Node) -> str: + if isinstance(node, ScalarNode): + return node.value + return "?" + + +def _merge_sources(node: Node) -> Iterator[MappingNode]: + if isinstance(node, MappingNode): + yield node + elif isinstance(node, SequenceNode): + yield from (item for item in node.value if isinstance(item, MappingNode)) + + +def _child_scope(scope: MappingScope, canonical: str, value_node: Node) -> MappingScope: + is_literal = scope is MappingScope.STRUCTURAL and is_literal_map_field( + canonical, + value_is_mapping=isinstance(value_node, MappingNode), + value_is_sequence=isinstance(value_node, SequenceNode), + ) + return MappingScope.LITERAL if is_literal else MappingScope.STRUCTURAL + + +def _conflict_message(first: _Entry, conflicting: _Entry) -> str: + if first.authored == conflicting.authored: + return f"Duplicate mapping key '{conflicting.authored}'." + return ( + f"Structural field keys '{first.authored}' and '{conflicting.authored}' both address '{conflicting.canonical}'." + ) + + +def _range_from_node(node: Node) -> SDLSourceRange: + return SDLSourceRange( + start=SDLSourcePosition(node.start_mark.line + 1, node.start_mark.column + 1), + end=SDLSourcePosition(node.end_mark.line + 1, node.end_mark.column + 1), + ) + + +def _encode_pointer(tokens: list[str]) -> str: + if not tokens: + return "" + return "/" + "/".join(token.replace("~", "~0").replace("/", "~1") for token in tokens) + + +def _decode_pointer(pointer: str) -> list[str]: + if not pointer: + return [] + if not pointer.startswith("/"): + raise ValueError("base_pointer must be an RFC 6901 pointer") + return [token.replace("~1", "/").replace("~0", "~") for token in pointer[1:].split("/")] diff --git a/implementations/python/packages/aces_sdl/language_service.py b/implementations/python/packages/aces_sdl/language_service.py index 034a0cfb7..9fc796363 100644 --- a/implementations/python/packages/aces_sdl/language_service.py +++ b/implementations/python/packages/aces_sdl/language_service.py @@ -15,6 +15,7 @@ from ._errors import SDLParseError, SDLValidationError from ._language_diagnostics import diagnostic as _diagnostic from ._language_diagnostics import invalid as _invalid +from ._language_diagnostics import parse_error as _parse_error from ._language_edit import apply_edit from ._language_metadata import REFERENCE_COMPLETION_TARGETS, SECTION_FIELD_COMPLETIONS from ._language_references import find_references @@ -22,8 +23,6 @@ from .scenario import Scenario _MAX_INPUT_BYTES = 64 * 1024 -_CODE_PARSE = "sdl.parse" - _SCENARIO_METADATA_FIELDS = frozenset({"name", "version", "description", "module", "imports"}) _SECTION_FIELDS = tuple(field for field in Scenario.model_fields if field not in _SCENARIO_METADATA_FIELDS) _TOP_LEVEL_KEYS = tuple(Scenario.model_fields) @@ -98,7 +97,7 @@ def language_format(sdl_content: str) -> dict[str, Any]: try: data = _load_normalized_data(sdl_content) except SDLParseError as exc: - return _invalid("parse", _CODE_PARSE, exc.details) + return _parse_error(exc) formatted = yaml.safe_dump( data, @@ -127,7 +126,7 @@ def language_diagnostics( skip_semantic_validation=not semantic_validation, ) except SDLParseError as exc: - return _invalid("parse", _CODE_PARSE, exc.details) + return _parse_error(exc) except SDLValidationError as exc: return { "status": "invalid", @@ -160,7 +159,7 @@ def apply_structured_edit( try: data = _load_normalized_data(sdl_content) except SDLParseError as exc: - return _invalid("parse", _CODE_PARSE, exc.details) + return _parse_error(exc) try: tokens = _split_pointer(pointer) @@ -185,7 +184,7 @@ def _load_completion_data(sdl_content: str) -> tuple[dict[str, Any], dict[str, A try: return _load_normalized_data(sdl_content), None except SDLParseError as exc: - return {}, _invalid("parse", _CODE_PARSE, exc.details) + return {}, _parse_error(exc) def _completion_target_section(pointer: list[str]) -> str | None: diff --git a/implementations/python/packages/aces_sdl/parser.py b/implementations/python/packages/aces_sdl/parser.py index 2a5bb81d8..afe22cd6c 100644 --- a/implementations/python/packages/aces_sdl/parser.py +++ b/implementations/python/packages/aces_sdl/parser.py @@ -6,77 +6,40 @@ - Shorthand expansion (``source: "pkg"`` → ``{name: "pkg", version: "*"}``) """ -import textwrap from pathlib import Path -from typing import Any +from typing import Any, Literal -import yaml from pydantic import ValidationError from ._base import contains_variable_token, is_variable_ref from ._errors import SDLParseError, SDLValidationError +from ._mapping_scopes import ( + HASHMAP_SECTIONS, + NESTED_HASHMAP_FIELDS, + MappingScope, + is_literal_map_field, + normalize_field_key, +) +from ._yaml_loader import load_sdl_yaml from .scenario import ExpandedScenario, Scenario from .validator import SemanticValidator # Top-level sections that are HashMaps of user-defined identifiers. # Keys inside these are scenario-author names (e.g., "web-server") # and must NOT be transformed. -_HASHMAP_SECTIONS = frozenset( - { - "nodes", - "infrastructure", - "features", - "conditions", - "vulnerabilities", - "entities", - "injects", - "events", - "scripts", - "stories", - "content", - "accounts", - "relationships", - "agents", - "action_contracts", - "observation_boundaries", - "outcome_interpretation_rules", - "behavior_specifications", - "evidence_requirements", - "objectives", - "workflows", - "variables", - } -) +_HASHMAP_SECTIONS = HASHMAP_SECTIONS # Fields within struct models that are also HashMaps of user-defined keys. -_NESTED_HASHMAP_FIELDS = frozenset( - { - "features", # VM.features (dict[str, str]) - "conditions", # VM.conditions (dict[str, str]) - "injects", # VM.injects (dict[str, str]) - "roles", # Node.roles (dict[str, Role]) - "log_options", # RuntimeContainerConfiguration.log_options preserves native engine option keys - "labels", # ImageConfig.labels preserves case-sensitive native image label keys - "driver_options", # RuntimeNetworkBackendDetail.driver_options preserves native network driver keys - "ipam_options", # RuntimeNetworkBackendDetail.ipam_options preserves native IPAM driver keys - "facts", # Entity.facts (dict[str, str]) - "entities", # Entity.entities (dict[str, Entity]) - "events", # Script.events (dict[str, int]) - "steps", # Workflow.steps (dict[str, WorkflowStep]) - # ParticipantBehaviorSpecification.extensions preserves governed x-owner:term keys. - "extensions", - } -) +_NESTED_HASHMAP_FIELDS = NESTED_HASHMAP_FIELDS def _child_is_hashmap_field(key: str, value: Any) -> bool: """Return whether the children of ``key`` are user-defined hashmap keys.""" - if key in _HASHMAP_SECTIONS: - return isinstance(value, dict) - if key in _NESTED_HASHMAP_FIELDS: - return True - # Complex properties use list items like ``[{switch-name: "10.0.0.10"}]``. - return key == "properties" and isinstance(value, list) + return is_literal_map_field( + key, + value_is_mapping=isinstance(value, dict), + value_is_sequence=isinstance(value, list), + ) def _normalize_field_key(k: Any) -> Any: @@ -84,10 +47,8 @@ def _normalize_field_key(k: Any) -> Any: # PyYAML's YAML 1.1 rules can coerce bare keys like ``on``/``off`` to bools. # SDL field keys are schema-defined strings, so normalize those legacy bool # coercions back into the field names we actually support. - if isinstance(k, bool): - return "on" if k else "off" if isinstance(k, str): - return k.lower().replace("-", "_") + return normalize_field_key(k) return k @@ -120,6 +81,20 @@ def _normalize_keys(data: Any, is_hashmap: bool = False) -> Any: return data +def load_sdl_fragment( + content: str, + *, + mapping_keys: Literal["structural", "literal"] = "structural", + base_pointer: str = "", +) -> object: + """Safely load an SDL YAML fragment with the canonical key preflight.""" + return load_sdl_yaml( + content, + scope=MappingScope(mapping_keys), + base_pointer=base_pointer, + ) + + def _reject_variable_mapping_keys( data: Any, *, @@ -400,14 +375,7 @@ def _load_normalized_data( *, path: Path | None = None, ) -> dict[str, Any]: - content = textwrap.dedent(content).strip() - if not content: - raise SDLParseError("SDL content is empty", path=path) - - try: - raw = yaml.safe_load(content) - except yaml.YAMLError as e: - raise SDLParseError(f"Invalid YAML: {e}", path=path) from e + raw = load_sdl_yaml(content, path=path) if not isinstance(raw, dict): raise SDLParseError("SDL must be a YAML mapping (not a scalar or list)", path=path) diff --git a/implementations/python/tests/data/sdl/invalid/mapping-key-cycle.yaml b/implementations/python/tests/data/sdl/invalid/mapping-key-cycle.yaml new file mode 100644 index 000000000..580433c0f --- /dev/null +++ b/implementations/python/tests/data/sdl/invalid/mapping-key-cycle.yaml @@ -0,0 +1,5 @@ +name: cyclic-alias +nodes: &nodes + sw: + type: switch + roles: *nodes diff --git a/implementations/python/tests/data/sdl/invalid/mapping-key-exact-root.yaml b/implementations/python/tests/data/sdl/invalid/mapping-key-exact-root.yaml new file mode 100644 index 000000000..b9c430b9e --- /dev/null +++ b/implementations/python/tests/data/sdl/invalid/mapping-key-exact-root.yaml @@ -0,0 +1,2 @@ +name: first +name: second diff --git a/implementations/python/tests/data/sdl/invalid/mapping-key-merge.yaml b/implementations/python/tests/data/sdl/invalid/mapping-key-merge.yaml new file mode 100644 index 000000000..638bd9f81 --- /dev/null +++ b/implementations/python/tests/data/sdl/invalid/mapping-key-merge.yaml @@ -0,0 +1,12 @@ +name: merge-conflict +nodes: + first: + type: vm + resources: &resources + ram: 1 gib + cpu: 1 + second: + type: vm + resources: + <<: *resources + cpu: 2 diff --git a/implementations/python/tests/data/sdl/invalid/mapping-key-normalized-nested.yaml b/implementations/python/tests/data/sdl/invalid/mapping-key-normalized-nested.yaml new file mode 100644 index 000000000..5194be127 --- /dev/null +++ b/implementations/python/tests/data/sdl/invalid/mapping-key-normalized-nested.yaml @@ -0,0 +1,5 @@ +name: normalized-nested +nodes: + sw: + Type: switch + type: switch diff --git a/implementations/python/tests/test_mcp_server.py b/implementations/python/tests/test_mcp_server.py index be979d95c..b6ec17947 100644 --- a/implementations/python/tests/test_mcp_server.py +++ b/implementations/python/tests/test_mcp_server.py @@ -272,6 +272,21 @@ def test_validate_section_invalid_yaml(self, server): ) assert "YAML ERROR" in text + def test_validate_section_rejects_duplicates_before_fragment_round_trip(self, server): + text = _call( + server, + "sdl_validate_section", + { + "section": "nodes", + "section_yaml": "sw:\n Type: switch\n type: switch", + }, + ) + + assert "PARSE ERROR" in text + assert "sdl.mapping_key_conflict" in text + assert "/nodes/sw/type" in text + assert "line 3, column 3" in text + def test_validate_section_bad_section(self, server): text = _call( server, @@ -631,6 +646,17 @@ def test_parse_can_run_semantic_validation(self, server): assert payload["stage"] == "semantic_validation" assert "ghost-feature" in payload["diagnostics"][0]["message"] + @pytest.mark.parametrize("tool", ["sdl_parse", "sdl_compile"]) + def test_operation_tools_preserve_mapping_conflict_diagnostics(self, server, tool): + payload = _json_call(server, tool, {"sdl_content": "Name: first\nname: second\n"}) + + assert payload["status"] == "invalid" + diagnostic = payload["diagnostics"][0] + assert diagnostic["code"] == "sdl.mapping_key_conflict" + assert diagnostic["path"] == "/name" + assert diagnostic["authored_keys"] == ["Name", "name"] + assert diagnostic["range"]["start"] == {"line": 2, "column": 1} + def test_compile_summarizes_runtime_model(self, server): payload = _json_call(server, "sdl_compile", {"sdl_content": FULL_SDL}) assert payload["status"] == "compiled" diff --git a/implementations/python/tests/test_sdl_fuzz.py b/implementations/python/tests/test_sdl_fuzz.py index 3c329f5c7..0c4a3483d 100644 --- a/implementations/python/tests/test_sdl_fuzz.py +++ b/implementations/python/tests/test_sdl_fuzz.py @@ -397,3 +397,49 @@ def test_fuzz_feature_dependency_cycles(features): parse_sdl(yaml_str) except (SDLParseError, SDLValidationError): pass + + +@given( + pair=st.sampled_from( + [ + ("Password-Strength", "password_strength"), + ("PASSWORD_STRENGTH", "password-strength"), + ("password-strength", "Password_Strength"), + ] + ) +) +@settings(max_examples=50, deadline=2000) +def test_fuzz_structural_key_alias_mutations_fail_closed(pair): + """Generated field aliases must never overwrite an earlier spelling.""" + first, second = pair + source = f"""\ +name: fuzz-aliases +nodes: + host: {{type: switch}} +accounts: + alice: + username: alice + node: host + {first}: strong + {second}: weak +""" + + with pytest.raises(SDLParseError) as excinfo: + parse_sdl(source) + assert excinfo.value.diagnostics[0].code == "sdl.mapping_key_conflict" + + +@given(identifier=slugs) +@settings(max_examples=50, deadline=2000) +def test_fuzz_exact_identifier_duplicate_mutations_fail_closed(identifier): + """Generated duplicate symbol definitions must fail before construction.""" + source = f"""\ +name: fuzz-duplicates +nodes: + {identifier}: {{type: switch}} + {identifier}: {{type: switch}} +""" + + with pytest.raises(SDLParseError) as excinfo: + parse_sdl(source) + assert excinfo.value.diagnostics[0].code == "sdl.mapping_key_conflict" diff --git a/implementations/python/tests/test_yaml_mapping_keys.py b/implementations/python/tests/test_yaml_mapping_keys.py new file mode 100644 index 000000000..97fe16f2d --- /dev/null +++ b/implementations/python/tests/test_yaml_mapping_keys.py @@ -0,0 +1,375 @@ +"""Fail-closed tests for authored SDL mapping keys.""" + +from pathlib import Path + +import pytest +from aces_sdl import SDLParseDiagnostic, SDLParseError, parse_sdl, parse_sdl_file +from aces_sdl.language_service import ( + apply_structured_edit, + language_completions, + language_diagnostics, + language_format, + language_references, +) +from hypothesis import given +from hypothesis import strategies as st + +FIXTURE_DIR = Path(__file__).parent / "data" / "sdl" / "invalid" + + +def _conflicts(source: str): + with pytest.raises(SDLParseError) as excinfo: + parse_sdl(source, skip_semantic_validation=True) + diagnostics = excinfo.value.diagnostics + assert diagnostics + assert all(item.code == "sdl.mapping_key_conflict" for item in diagnostics) + return diagnostics + + +def test_exact_duplicate_root_key_is_rejected_before_model_construction() -> None: + diagnostics = _conflicts("name: first\nname: second\n") + + assert len(diagnostics) == 1 + assert diagnostics[0].pointer == "/name" + assert diagnostics[0].authored_keys == ("name", "name") + + +def test_normalized_nested_field_aliases_conflict() -> None: + diagnostics = _conflicts( + """\ +name: aliases +nodes: + sw: + Type: switch + type: switch +""" + ) + + assert diagnostics[0].pointer == "/nodes/sw/type" + assert diagnostics[0].authored_keys == ("Type", "type") + + +def test_exact_duplicate_literal_identifier_is_rejected() -> None: + diagnostics = _conflicts( + """\ +name: literal-duplicate +nodes: + sw: {type: switch} + sw: {type: switch} +""" + ) + + assert diagnostics[0].pointer == "/nodes/sw" + + +def test_literal_identifiers_are_not_field_normalized() -> None: + scenario = parse_sdl( + """\ +name: literal-identifiers +nodes: + Web-App: {type: switch} + web_app: {type: switch} +""" + ) + + assert tuple(scenario.nodes) == ("Web-App", "web_app") + + +def test_implicit_yaml_11_boolean_like_identifiers_remain_distinct_strings() -> None: + scenario = parse_sdl( + """\ +name: boolean-like-identifiers +nodes: + on: {type: switch} + true: {type: switch} + OFF: {type: switch} + false: {type: switch} +""" + ) + + assert tuple(scenario.nodes) == ("on", "true", "OFF", "false") + + +def test_explicit_non_string_mapping_key_is_rejected_with_a_source_range() -> None: + with pytest.raises(SDLParseError) as excinfo: + parse_sdl("name: invalid-key\nnodes:\n !!int 1: {type: switch}\n") + + diagnostic = excinfo.value.diagnostics[0] + assert diagnostic.code == "sdl.mapping_key_type" + assert diagnostic.pointer == "/nodes/1" + assert diagnostic.primary_range.start.line == 3 + assert diagnostic.primary_range.start.column == 3 + + +def test_merge_source_and_local_field_must_be_disjoint() -> None: + diagnostics = _conflicts( + """\ +name: merge-conflict +nodes: + first: + type: vm + resources: &resources + ram: 1 gib + cpu: 1 + second: + type: vm + resources: + <<: *resources + cpu: 2 +""" + ) + + assert diagnostics[0].pointer == "/nodes/second/resources/cpu" + assert diagnostics[0].authored_keys == ("cpu", "cpu") + + +def test_merge_sources_must_be_pairwise_disjoint_and_collect_all_conflicts() -> None: + diagnostics = _conflicts( + """\ +name: merge-sources +nodes: + first: + type: vm + resources: &first + ram: 1 gib + cpu: 1 + second: + type: vm + resources: &second + RAM: 2 gib + CPU: 2 + third: + type: vm + resources: + <<: [*first, *second] +""" + ) + + assert [item.pointer for item in diagnostics] == [ + "/nodes/third/resources/ram", + "/nodes/third/resources/cpu", + ] + assert diagnostics[0].authored_keys == ("ram", "RAM") + + +def test_disjoint_merge_remains_valid() -> None: + scenario = parse_sdl( + """\ +name: merge-disjoint +nodes: + first: + type: vm + resources: &resources + ram: 1 gib + cpu: 1 + second: + type: vm + resources: + <<: *resources +""" + ) + + assert scenario.nodes["second"].resources.cpu == 1 + + +def test_cyclic_alias_graph_fails_cleanly() -> None: + with pytest.raises(SDLParseError) as excinfo: + parse_sdl( + """\ +name: cyclic-alias +nodes: &nodes + sw: + type: switch + roles: *nodes +""" + ) + + assert excinfo.value.diagnostics[0].code == "sdl.alias_cycle" + + +def test_non_printable_input_fails_cleanly_across_loader_entry_points() -> None: + with pytest.raises(SDLParseError, match="special characters are not allowed"): + parse_sdl("\x1b") + + payload = language_references("\x1b", "symbol") + assert payload["status"] == "invalid" + assert payload["diagnostics"][0]["code"] == "sdl.parse" + + +def test_collects_conflicts_in_document_order() -> None: + diagnostics = _conflicts( + """\ +Name: first +name: second +Version: 1.0.0 +version: 2.0.0 +""" + ) + + assert [item.pointer for item in diagnostics] == ["/name", "/version"] + + +def test_conflict_diagnostic_has_one_based_token_ranges() -> None: + diagnostic = _conflicts("Name: first\nname: second\n")[0] + + assert diagnostic.primary_range.as_dict() == { + "start": {"line": 2, "column": 1}, + "end": {"line": 2, "column": 5}, + } + assert diagnostic.related_range.as_dict() == { + "start": {"line": 1, "column": 1}, + "end": {"line": 1, "column": 5}, + } + assert isinstance(diagnostic, SDLParseDiagnostic) + + +def test_conflict_diagnostic_never_exposes_mapping_values() -> None: + with pytest.raises(SDLParseError) as excinfo: + parse_sdl("name: public\nname: TOP-SECRET-VALUE\n") + + assert "TOP-SECRET-VALUE" not in str(excinfo.value) + + +def test_conflict_pointer_uses_rfc_6901_escaping() -> None: + diagnostics = _conflicts( + """\ +name: escaped-pointer +nodes: + "a/b~c": {type: switch} + "a/b~c": {type: switch} +""" + ) + + assert diagnostics[0].pointer == "/nodes/a~1b~0c" + + +def test_file_entry_point_preserves_file_and_key_diagnostics(tmp_path: Path) -> None: + source = tmp_path / "scenario.yaml" + source.write_text("name: first\nname: second\n", encoding="utf-8") + + with pytest.raises(SDLParseError) as excinfo: + parse_sdl_file(source) + + assert excinfo.value.path == source + assert excinfo.value.diagnostics[0].pointer == "/name" + + +def test_language_diagnostics_preserve_structured_conflict_fields() -> None: + payload = language_diagnostics("Name: first\nname: second\n") + + assert payload["status"] == "invalid" + assert payload["diagnostics"] == [ + { + "stage": "parse", + "severity": "error", + "code": "sdl.mapping_key_conflict", + "message": "Structural field keys 'Name' and 'name' both address 'name'.", + "path": "/name", + "authored_keys": ["Name", "name"], + "range": { + "start": {"line": 2, "column": 1}, + "end": {"line": 2, "column": 5}, + }, + "related": [ + { + "message": "First authored key 'Name'.", + "range": { + "start": {"line": 1, "column": 1}, + "end": {"line": 1, "column": 5}, + }, + } + ], + } + ] + + +def test_language_references_rejects_ambiguous_documents() -> None: + payload = language_references( + "name: refs\nnodes:\n sw: {type: switch}\n sw: {type: switch}\n", + "sw", + ) + + assert payload["status"] == "invalid" + assert payload["diagnostics"][0]["code"] == "sdl.mapping_key_conflict" + + +@pytest.mark.parametrize( + "operation", + [ + lambda source: language_format(source), + lambda source: language_completions(source), + lambda source: apply_structured_edit(source, operation="set", pointer="/description", value="x"), + ], + ids=["format", "completions", "structured-edit"], +) +def test_authoring_reads_reject_ambiguity_before_rewriting(operation) -> None: + payload = operation("Name: first\nname: second\n") + + assert payload["status"] == "invalid" + assert payload["diagnostics"][0]["code"] == "sdl.mapping_key_conflict" + + +def test_imported_module_uses_the_same_mapping_key_boundary(tmp_path: Path) -> None: + imported = tmp_path / "common.yaml" + imported.write_text("name: common\nnodes:\n sw: {type: switch}\n sw: {type: switch}\n", encoding="utf-8") + root = tmp_path / "root.yaml" + root.write_text( + "name: root\nimports:\n - path: common.yaml\n namespace: shared\n", + encoding="utf-8", + ) + + with pytest.raises(SDLParseError) as excinfo: + parse_sdl_file(root) + + assert excinfo.value.path == imported + assert excinfo.value.diagnostics[0].pointer == "/nodes/sw" + + +@pytest.mark.parametrize("fixture", sorted(FIXTURE_DIR.glob("mapping-key-*.yaml")), ids=lambda path: path.stem) +def test_negative_mapping_key_fixtures_fail_closed(fixture: Path) -> None: + with pytest.raises(SDLParseError): + parse_sdl_file(fixture, skip_semantic_validation=True) + + +_STRUCTURAL_ALIAS_PAIRS = st.sampled_from( + [ + ("Password-Strength", "password_strength"), + ("PASSWORD_STRENGTH", "password-strength"), + ("password-strength", "Password_Strength"), + ] +) + + +@given(_STRUCTURAL_ALIAS_PAIRS) +def test_property_distinct_structural_aliases_never_overwrite(pair: tuple[str, str]) -> None: + first, second = pair + diagnostics = _conflicts( + f"""\ +name: generated-aliases +nodes: + host: {{type: switch}} +accounts: + alice: + username: alice + node: host + {first}: strong + {second}: weak +""" + ) + + assert diagnostics[0].pointer == "/accounts/alice/password_strength" + + +@given(st.sampled_from([("Web-App", "web_app"), ("DB", "db"), ("a-b", "a_b")])) +def test_property_literal_identifier_aliases_remain_distinct(pair: tuple[str, str]) -> None: + first, second = pair + scenario = parse_sdl( + f"""\ +name: generated-identifiers +nodes: + {first}: {{type: switch}} + {second}: {{type: switch}} +""" + ) + + assert set(scenario.nodes) == {first, second} diff --git a/specs/sdl/diagnostics.md b/specs/sdl/diagnostics.md index 8cdeff3a0..f12ee15db 100644 --- a/specs/sdl/diagnostics.md +++ b/specs/sdl/diagnostics.md @@ -12,9 +12,10 @@ An SDL document is checked at three stages, in order. Each is **fail-closed**: a problem at a stage stops the document from advancing past that stage. 1. **Parse / structural.** YAML loading and structural shape: the root is a - mapping, keys are strings, values have the right shapes, and **no unknown key - is present** ([document-model.md §4](document-model.md)). A structural problem - is a parse error. + mapping, keys are strings, mapping entries remain unique before and after + field-key normalisation, values have the right shapes, and **no unknown key is + present** ([document-model.md §4](document-model.md)). A structural problem is + a parse error. 2. **Semantic validation.** Cross-section reference resolution ([references.md](references.md)), uniqueness, acyclicity, control-flow closure, and the runtime-family invariants @@ -32,8 +33,10 @@ a problem at a stage stops the document from advancing past that stage. The semantic-validation and instantiation stages **collect all errors in a pass** and report them together, rather than failing at the first problem. An author fixing a document sees the full set of errors a stage found, not one error at a -time. (Parsing may stop at the first structural fault that prevents -interpretation.) +time. Parsing may stop at the first structural fault that prevents composition +of a YAML node graph. Once that graph is available, the mapping-key preflight +collects all exact duplicates, merge conflicts, and field-key normalisation +collisions before construction; none is hidden by a last-write-wins mapping. ## 3. Errors are fatal @@ -127,3 +130,37 @@ A future change that moves a condition between the error and advisory channels, or that adds a new diagnostic category, **MUST** apply this criterion and be reflected here, in the published schemas, and in the reference implementation together, so the boundary stays single-sourced. + +## 6. Mapping-key diagnostics + +Exact duplicate keys, conflicting effective keys introduced by `<<`, and +distinct structural field spellings that normalise to one field use the stable +diagnostic code `sdl.mapping_key_conflict`. They are fatal at the `parse` stage +and **MUST** be raised before Pydantic or any other SDL model constructor sees +the mapping. + +An explicitly non-string or complex mapping key uses +`sdl.mapping_key_type`. A cyclic YAML alias graph uses `sdl.alias_cycle`. These +conditions are likewise fatal at the `parse` stage and carry the canonical +target path and primary source range; they do not have a second authored-key +range when no competing declaration exists. + +Each diagnostic **MUST** carry: + +1. the canonical target path as an RFC 6901 JSON Pointer, with schema field + segments canonicalised and user-defined/native-map segments preserved and + escaped; +2. both authored key spellings (the spellings are identical for an exact + duplicate); and +3. the one-based line and column range of both key tokens. The later/conflicting + key is the primary range and the earlier key is a related range. When the + conflict is contributed through a YAML merge, the ranges identify the + original key declarations and the canonical path identifies the effective + target mapping. + +The reference implementation continues to use `SDLParseError` for this failure; +it **MUST NOT** introduce a parallel exception hierarchy. Public structured +adapters (including language-service and MCP responses) preserve the code, +stage, canonical path, and both ranges. Plain-text CLI/library rendering may +format the same fields as prose but must not replace them with raw YAML values or +silently downgrade the error to a generic model-validation failure. diff --git a/specs/sdl/document-model.md b/specs/sdl/document-model.md index 8feed241d..74e1c0a78 100644 --- a/specs/sdl/document-model.md +++ b/specs/sdl/document-model.md @@ -15,11 +15,21 @@ and instantiation rules. 1. An SDL document **MUST** be a YAML 1.1/1.2 document whose top-level value is a mapping. A document whose root is a sequence, scalar, or null is not a valid SDL document. -2. Every top-level key **MUST** be a string. Field keys are matched - case-sensitively after enum/value normalisation (§5); a key that is not a - defined top-level field is rejected (§4). +2. Every mapping key **MUST** denote a string. In particular, SDL treats an + implicitly typed YAML 1.1 spelling such as `on`, `off`, `yes`, or `no` as + the authored string spelling when it occurs in key position; it does not + allow the YAML loader to turn that spelling into a boolean map key. A + non-scalar or explicitly non-string key is rejected. 3. A document **MUST** be loadable by a safe YAML loader. Constructor tags that instantiate arbitrary types **MUST NOT** be honoured. +4. Every authored mapping entry **MUST** remain distinguishable until the SDL + parser has checked key uniqueness. An exact duplicate key at any depth is a + parse/structural error; a loader **MUST NOT** construct a last-write-wins + dictionary first. + +This uniqueness rule follows the YAML 1.2.2 representation model, in which a +mapping is an unordered association of unique keys and non-unique keys are a +loading failure ([YAML 1.2.2 §§3.2.1.1, 3.3](https://yaml.org/spec/1.2.2/)). ## 2. Top-level organisation @@ -65,24 +75,52 @@ to match `contracts/schemas/sdl/sdl-authoring-input-v1.json`. 3. Closure exists so that a typo in a field name (`vulnerabilites`) fails the document rather than silently dropping content. Authors **MUST NOT** rely on undeclared keys to carry data. +4. YAML anchors and aliases remain authoring conveniences, but they do not + weaken closure or uniqueness. A merge key (`<<`) is valid only when the + effective entries contributed by every merge source and every local entry + remain unique after the scope-appropriate key rules in §5 are applied. A + merge conflict is a parse/structural error rather than an implicit precedence + rule. This is a deliberate ACES restriction over the YAML 1.1 merge-key + working draft, which otherwise defines source and local override precedence + ([YAML 1.1 merge type](https://yaml.org/type/merge.html)). Cyclic alias + graphs are invalid. > *Implementation evidence (non-normative): the reference models set > `extra="forbid"` on the shared SDL base model.* -## 5. Value normalisation +## 5. Field and value normalisation 1. Enum-valued fields accept their value case-insensitively, and accept a hyphen as an alias for an underscore in the value text, so that an authoring value such as `search-index` and `search_index` denote the same enum member. This is an authoring convenience; the normalised (canonical) form is what the document means. -2. Normalisation applies to enum **values**, not to user-defined identifier - **keys**. A user-defined key is preserved verbatim as the element's - identifier (§6). -3. A field that holds a variable placeholder (`${…}`) is **not** normalised as an +2. Structural field keys retain the authoring aliases established by ADR-001: + matching is case-insensitive and `-` is accepted as an alias for `_`. + Therefore `semantic-version` and `semantic_version` both address the + canonical field `semantic_version`. +3. Field-key aliases do not create a precedence rule. If two keys in one + effective mapping address the same canonical field, including through a YAML + merge, the mapping is ambiguous and **MUST** fail during parsing before model + construction. The diagnostic contract is defined in + [diagnostics.md §6](diagnostics.md). +4. Field-key normalisation applies only while traversing a schema-defined + structural mapping. It **MUST NOT** be applied to user-defined identifier + maps, extension maps, or native option/label maps. Keys in those maps are + preserved verbatim, including case, hyphens, underscores, and YAML 1.1 + boolean-like spellings; only exact duplicate identifiers are rejected. +5. A field that holds a variable placeholder (`${…}`) is **not** normalised as an enum value; the placeholder is preserved until instantiation ([variables-and-instantiation.md](variables-and-instantiation.md)). +Formally, let `c_scope(k)` lowercase a key and replace hyphens with underscores +in a structural mapping, and be the identity function in a literal mapping. For every +pair of distinct entries `i` and `j` in an effective mapping (including merge +contributions), well-formedness requires +`c_scope(key_i) != c_scope(key_j)`. This injectivity condition is checked over +the authored node graph; mapped values do not participate in the comparison or +its diagnostics. + ## 6. Identifier rules for user-defined keys A user-defined key in a map-valued section is the **identifier** by which an @@ -120,7 +158,7 @@ of the authored document with progressively fewer unresolved constructs: 1. **Authored.** The document as written. It **MAY** contain module imports and `${…}` variable placeholders. Full semantic validation ([references.md](references.md), [diagnostics.md](diagnostics.md)) applies to - the authored document, treating unresolved placeholders per §5.3. + the authored document, treating unresolved placeholders per §5.5. 2. **Expanded.** If the document declares a module or imports ([sections.md](sections.md) — `module`, `imports`), module composition is applied **before** full semantic validation, producing an expanded document From 7d48c0531db294c18826382dec0ef6209197495c Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 11 Jul 2026 08:48:57 -0700 Subject: [PATCH 08/15] feat(libvirt): publish configuration-bound realization envelopes (#730) * Publish configuration-bound libvirt realization envelopes * Fix SonarCloud findings (cycle 1) * Fix SonarCloud findings (cycle 2) --- contracts/README.md | 2 + .../invalid/missing-digest.json | 120 +++ .../valid/generic.json | 39 + .../libvirt-qemu/generic-v1.json | 39 + .../libvirt-qemu/techvault-appliance-v1.json | 39 + contracts/schema-publication-manifest.json | 34 +- .../backend-manifest/backend-manifest-v2.json | 103 ++ .../schemas/plans/provisioning-plan-v1.json | 51 + .../schemas/profiles/backend-profile-v1.json | 1 + .../realization-envelope-v1.json | 966 ++++++++++++++++++ .../snapshots/runtime-snapshot-v1.json | 51 + ...libvirt-qemu.provisioning-only.report.json | 6 +- ...9-normative-authority-boundary-manifest.md | 10 +- .../adr-070-realization-envelope-semantics.md | 22 +- docs/decisions/adrs/adr-index.yaml | 4 +- ...-libvirt-realization-envelope-preflight.md | 288 ++++++ docs/explain/reference/backend-conformance.md | 37 +- .../reference/normative-artifact-authority.md | 5 +- .../aces_backend_libvirt/drivers/libvirt.py | 2 + .../aces_backend_libvirt/envelopes.py | 37 + .../packages/aces_backend_libvirt/manifest.py | 65 +- .../aces_backend_libvirt/provisioner.py | 93 +- .../packages/aces_backend_libvirt/target.py | 67 +- .../aces_backend_libvirt/techvault_native.py | 4 +- .../backend_manifest.py | 219 ++++ .../aces_backend_protocols/capabilities.py | 162 +-- .../aces_backend_protocols/manifest.py | 10 +- .../packages/aces_backend_stubs/stubs.py | 2 +- .../packages/aces_conformance/conformance.py | 38 +- .../packages/aces_contracts/contracts.py | 49 + .../python/packages/aces_contracts/corpus.py | 2 + .../aces_contracts/manifest_authority.py | 1 + .../packages/aces_contracts/planning.py | 2 + .../aces_contracts/realization_envelope.py | 49 +- .../realization_envelope_carrier.py | 310 ++++++ .../packages/aces_contracts/runtime_state.py | 21 + .../packages/aces_contracts/versions.py | 1 + .../packages/aces_processor/compiler.py | 1 + .../python/packages/aces_processor/models.py | 2 + .../python/packages/aces_processor/planner.py | 18 +- .../aces_reference_backend/manifest.py | 5 +- .../aces_runtime/control_plane_api_models.py | 6 + .../aces_runtime/control_plane_store.py | 9 + .../python/packages/aces_runtime/manager.py | 1 + implementations/python/pyproject.toml | 1 + .../tests/libvirt_conformance_fixtures.py | 2 + .../tests/libvirt_participant_fixtures.py | 1 + .../python/tests/test_authority_boundary.py | 22 +- .../python/tests/test_backend_manifest.py | 13 +- .../tests/test_libvirt_backend_envelopes.py | 148 +++ .../tests/test_libvirt_backend_manifest.py | 13 +- ...st_libvirt_backend_manifest_publication.py | 16 +- .../tests/test_libvirt_backend_provisioner.py | 74 +- .../tests/test_libvirt_backend_realization.py | 13 +- .../tests/test_libvirt_backend_registry.py | 2 + ...t_libvirt_backend_techvault_integration.py | 2 + .../python/tests/test_libvirt_conformance.py | 23 +- .../python/tests/test_libvirt_evidence_run.py | 16 +- .../tests/test_libvirt_participant_runtime.py | 4 +- .../test_realization_envelope_contract.py | 316 ++++++ .../tests/test_reference_backend_manifest.py | 2 + .../python/tests/test_runtime_conformance.py | 18 +- specs/authority/authority-boundary.yaml | 5 + .../formal/realization/envelope-semantics.md | 22 +- tools/check_authority_boundary.py | 45 +- tools/generate_contract_schemas.py | 2 + 66 files changed, 3404 insertions(+), 349 deletions(-) create mode 100644 contracts/fixtures/realization-envelope/realization-envelope-v1/invalid/missing-digest.json create mode 100644 contracts/fixtures/realization-envelope/realization-envelope-v1/valid/generic.json create mode 100644 contracts/realization-envelopes/libvirt-qemu/generic-v1.json create mode 100644 contracts/realization-envelopes/libvirt-qemu/techvault-appliance-v1.json create mode 100644 contracts/schemas/realization-envelope/realization-envelope-v1.json create mode 100644 docs/decisions/issue-100-asr-519-libvirt-realization-envelope-preflight.md create mode 100644 implementations/python/packages/aces_backend_libvirt/envelopes.py create mode 100644 implementations/python/packages/aces_backend_protocols/backend_manifest.py create mode 100644 implementations/python/packages/aces_contracts/realization_envelope_carrier.py create mode 100644 implementations/python/tests/test_libvirt_backend_envelopes.py create mode 100644 implementations/python/tests/test_realization_envelope_contract.py diff --git a/contracts/README.md b/contracts/README.md index eef1dac7d..19f3654f6 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -7,6 +7,8 @@ The goal of this bucket is organizational clarity: - `schemas/` contains published contract schemas - `fixtures/` contains valid and invalid payload corpora for those contracts - `profiles/` contains capability profile declarations +- `realization-envelopes/` contains configuration-bound backend realization + declarations whose identity is carried through manifests, plans, and snapshots `schema-publication-manifest.json` is the authoritative publication inventory for the current machine-readable schema set. The contracts verification gate diff --git a/contracts/fixtures/realization-envelope/realization-envelope-v1/invalid/missing-digest.json b/contracts/fixtures/realization-envelope/realization-envelope-v1/invalid/missing-digest.json new file mode 100644 index 000000000..2a69e9d1b --- /dev/null +++ b/contracts/fixtures/realization-envelope/realization-envelope-v1/invalid/missing-digest.json @@ -0,0 +1,120 @@ +{ + "schema_version": "realization-envelope/v1", + "contract_id": "realization-envelope-v1", + "id": "libvirt-qemu.generic.v1", + "expression": { + "schema_version": "realization-envelope/v1", + "id": "libvirt-qemu.generic.expression.v1", + "scope": "scenario", + "domains": {}, + "bindings": [], + "closure": [] + }, + "configuration": { + "mode": "generic", + "configuration_digest": "sha256:2af0fbd4a95b95a3a51d1488f641985c93e4c470baeb188f3e981f8b36327450", + "architecture": "x86_64", + "image_policy": "local-qcow2", + "network_policy": "libvirt-managed", + "supported_node_types": [ + "switch", + "vm" + ], + "supported_os_families": [ + "linux" + ], + "supported_content_types": [ + "file" + ], + "supported_account_features": [ + "auth_method", + "disabled", + "groups", + "home", + "shell" + ], + "supports_acls": true, + "memory_mib": { + "minimum": 128, + "maximum": null + }, + "vcpus": { + "minimum": 1, + "maximum": null + } + }, + "concerns": [ + { + "concern": "topology", + "disposition": "realized", + "observation_strength": "driver-reported", + "mechanism": "libvirt-domain-network", + "transformations": [] + }, + { + "concern": "architecture", + "disposition": "realized", + "observation_strength": "driver-reported", + "mechanism": "qemu-x86_64-domain", + "transformations": [] + }, + { + "concern": "image", + "disposition": "realized", + "observation_strength": "driver-reported", + "mechanism": "qcow2-disk-attachment", + "transformations": [] + }, + { + "concern": "resource-allocation", + "disposition": "transformed", + "observation_strength": "driver-reported", + "mechanism": "libvirt-domain-xml", + "transformations": [ + "bounded-normalization", + "default-substitution" + ] + }, + { + "concern": "network", + "disposition": "transformed", + "observation_strength": "driver-reported", + "mechanism": "libvirt-network-xml", + "transformations": [ + "default-substitution" + ] + }, + { + "concern": "content-placement", + "disposition": "realized", + "observation_strength": "driver-reported", + "mechanism": "cloud-init-seed", + "transformations": [] + }, + { + "concern": "account-placement", + "disposition": "transformed", + "observation_strength": "driver-reported", + "mechanism": "cloud-init-seed", + "transformations": [ + "default-substitution" + ] + }, + { + "concern": "feature-binding", + "disposition": "transformed", + "observation_strength": "driver-reported", + "mechanism": "cloud-init-seed", + "transformations": [ + "descriptor-substitution" + ] + }, + { + "concern": "acl", + "disposition": "realized", + "observation_strength": "driver-reported", + "mechanism": "libvirt-nwfilter", + "transformations": [] + } + ] +} diff --git a/contracts/fixtures/realization-envelope/realization-envelope-v1/valid/generic.json b/contracts/fixtures/realization-envelope/realization-envelope-v1/valid/generic.json new file mode 100644 index 000000000..138d2601c --- /dev/null +++ b/contracts/fixtures/realization-envelope/realization-envelope-v1/valid/generic.json @@ -0,0 +1,39 @@ +{ + "schema_version": "realization-envelope/v1", + "contract_id": "realization-envelope-v1", + "id": "libvirt-qemu.generic.v1", + "expression": { + "schema_version": "realization-envelope/v1", + "id": "libvirt-qemu.generic.expression.v1", + "scope": "scenario", + "domains": {}, + "bindings": [], + "closure": [] + }, + "configuration": { + "mode": "generic", + "configuration_digest": "sha256:2af0fbd4a95b95a3a51d1488f641985c93e4c470baeb188f3e981f8b36327450", + "architecture": "x86_64", + "image_policy": "local-qcow2", + "network_policy": "libvirt-managed", + "supported_node_types": ["switch", "vm"], + "supported_os_families": ["linux"], + "supported_content_types": ["file"], + "supported_account_features": ["auth_method", "disabled", "groups", "home", "shell"], + "supports_acls": true, + "memory_mib": {"minimum": 128, "maximum": null}, + "vcpus": {"minimum": 1, "maximum": null} + }, + "concerns": [ + {"concern": "topology", "disposition": "realized", "observation_strength": "driver-reported", "mechanism": "libvirt-domain-network", "transformations": []}, + {"concern": "architecture", "disposition": "realized", "observation_strength": "driver-reported", "mechanism": "qemu-x86_64-domain", "transformations": []}, + {"concern": "image", "disposition": "realized", "observation_strength": "driver-reported", "mechanism": "qcow2-disk-attachment", "transformations": []}, + {"concern": "resource-allocation", "disposition": "transformed", "observation_strength": "driver-reported", "mechanism": "libvirt-domain-xml", "transformations": ["bounded-normalization", "default-substitution"]}, + {"concern": "network", "disposition": "transformed", "observation_strength": "driver-reported", "mechanism": "libvirt-network-xml", "transformations": ["default-substitution"]}, + {"concern": "content-placement", "disposition": "realized", "observation_strength": "driver-reported", "mechanism": "cloud-init-seed", "transformations": []}, + {"concern": "account-placement", "disposition": "transformed", "observation_strength": "driver-reported", "mechanism": "cloud-init-seed", "transformations": ["default-substitution"]}, + {"concern": "feature-binding", "disposition": "transformed", "observation_strength": "driver-reported", "mechanism": "cloud-init-seed", "transformations": ["descriptor-substitution"]}, + {"concern": "acl", "disposition": "realized", "observation_strength": "driver-reported", "mechanism": "libvirt-nwfilter", "transformations": []} + ], + "digest": "sha256:4037b85c2a0e6457081dd33e8953bd98a5134865cdf0173b7e2c5a3d077a5b3a" +} diff --git a/contracts/realization-envelopes/libvirt-qemu/generic-v1.json b/contracts/realization-envelopes/libvirt-qemu/generic-v1.json new file mode 100644 index 000000000..138d2601c --- /dev/null +++ b/contracts/realization-envelopes/libvirt-qemu/generic-v1.json @@ -0,0 +1,39 @@ +{ + "schema_version": "realization-envelope/v1", + "contract_id": "realization-envelope-v1", + "id": "libvirt-qemu.generic.v1", + "expression": { + "schema_version": "realization-envelope/v1", + "id": "libvirt-qemu.generic.expression.v1", + "scope": "scenario", + "domains": {}, + "bindings": [], + "closure": [] + }, + "configuration": { + "mode": "generic", + "configuration_digest": "sha256:2af0fbd4a95b95a3a51d1488f641985c93e4c470baeb188f3e981f8b36327450", + "architecture": "x86_64", + "image_policy": "local-qcow2", + "network_policy": "libvirt-managed", + "supported_node_types": ["switch", "vm"], + "supported_os_families": ["linux"], + "supported_content_types": ["file"], + "supported_account_features": ["auth_method", "disabled", "groups", "home", "shell"], + "supports_acls": true, + "memory_mib": {"minimum": 128, "maximum": null}, + "vcpus": {"minimum": 1, "maximum": null} + }, + "concerns": [ + {"concern": "topology", "disposition": "realized", "observation_strength": "driver-reported", "mechanism": "libvirt-domain-network", "transformations": []}, + {"concern": "architecture", "disposition": "realized", "observation_strength": "driver-reported", "mechanism": "qemu-x86_64-domain", "transformations": []}, + {"concern": "image", "disposition": "realized", "observation_strength": "driver-reported", "mechanism": "qcow2-disk-attachment", "transformations": []}, + {"concern": "resource-allocation", "disposition": "transformed", "observation_strength": "driver-reported", "mechanism": "libvirt-domain-xml", "transformations": ["bounded-normalization", "default-substitution"]}, + {"concern": "network", "disposition": "transformed", "observation_strength": "driver-reported", "mechanism": "libvirt-network-xml", "transformations": ["default-substitution"]}, + {"concern": "content-placement", "disposition": "realized", "observation_strength": "driver-reported", "mechanism": "cloud-init-seed", "transformations": []}, + {"concern": "account-placement", "disposition": "transformed", "observation_strength": "driver-reported", "mechanism": "cloud-init-seed", "transformations": ["default-substitution"]}, + {"concern": "feature-binding", "disposition": "transformed", "observation_strength": "driver-reported", "mechanism": "cloud-init-seed", "transformations": ["descriptor-substitution"]}, + {"concern": "acl", "disposition": "realized", "observation_strength": "driver-reported", "mechanism": "libvirt-nwfilter", "transformations": []} + ], + "digest": "sha256:4037b85c2a0e6457081dd33e8953bd98a5134865cdf0173b7e2c5a3d077a5b3a" +} diff --git a/contracts/realization-envelopes/libvirt-qemu/techvault-appliance-v1.json b/contracts/realization-envelopes/libvirt-qemu/techvault-appliance-v1.json new file mode 100644 index 000000000..743316b42 --- /dev/null +++ b/contracts/realization-envelopes/libvirt-qemu/techvault-appliance-v1.json @@ -0,0 +1,39 @@ +{ + "schema_version": "realization-envelope/v1", + "contract_id": "realization-envelope-v1", + "id": "libvirt-qemu.techvault-appliance.v1", + "expression": { + "schema_version": "realization-envelope/v1", + "id": "libvirt-qemu.techvault-appliance.expression.v1", + "scope": "scenario", + "domains": {}, + "bindings": [], + "closure": [] + }, + "configuration": { + "mode": "techvault-appliance", + "configuration_digest": "sha256:227dc29681c61c341291ef4ef30b8c777e166103a5e76a74d87cc2164a7bf5ca", + "architecture": "x86_64", + "image_policy": "generated-initramfs-appliance", + "network_policy": "generated-appliance-network", + "supported_node_types": ["switch", "vm"], + "supported_os_families": ["linux"], + "supported_content_types": [], + "supported_account_features": [], + "supports_acls": false, + "memory_mib": {"minimum": 64, "maximum": 128}, + "vcpus": {"minimum": 1, "maximum": 2} + }, + "concerns": [ + {"concern": "topology", "disposition": "realized", "observation_strength": "driver-reported", "mechanism": "libvirt-domain-network", "transformations": []}, + {"concern": "architecture", "disposition": "transformed", "observation_strength": "driver-reported", "mechanism": "generated-x86_64-appliance", "transformations": ["image-substitution"]}, + {"concern": "image", "disposition": "transformed", "observation_strength": "driver-reported", "mechanism": "generated-initramfs-appliance", "transformations": ["image-substitution"]}, + {"concern": "resource-allocation", "disposition": "transformed", "observation_strength": "driver-reported", "mechanism": "bounded-appliance-domain", "transformations": ["bounded-normalization"]}, + {"concern": "network", "disposition": "transformed", "observation_strength": "driver-reported", "mechanism": "generated-appliance-network", "transformations": ["default-substitution"]}, + {"concern": "content-placement", "disposition": "unsupported", "observation_strength": "none", "mechanism": null, "transformations": []}, + {"concern": "account-placement", "disposition": "unsupported", "observation_strength": "none", "mechanism": null, "transformations": []}, + {"concern": "feature-binding", "disposition": "unsupported", "observation_strength": "none", "mechanism": null, "transformations": []}, + {"concern": "acl", "disposition": "unsupported", "observation_strength": "none", "mechanism": null, "transformations": []} + ], + "digest": "sha256:37bddd6be24bcdef44241d8d77fc619113fe9d4048a61fd603977f7748593a3f" +} diff --git a/contracts/schema-publication-manifest.json b/contracts/schema-publication-manifest.json index e1fa2c6e8..1f62dfed8 100644 --- a/contracts/schema-publication-manifest.json +++ b/contracts/schema-publication-manifest.json @@ -32,20 +32,20 @@ "contract_id": "backend-manifest-v2", "schema_path": "contracts/schemas/backend-manifest/backend-manifest-v2.json", "stability": "draft", - "content_hash": "4485bf2485e1f98be2c540552d42828a170dc6ff5beeb6b091a4e87a55b922ab", + "content_hash": "19dd8c5aa113fc8ce21c811bf75ef3e22367ae41fcc454c5139cd85db0fb5a60", "last_change": { - "summary": "Added experiment-run-v1 to the governed backend manifest contract vocabulary for run-level observability and augmentation evidence disclosure.", - "content_hash": "4485bf2485e1f98be2c540552d42828a170dc6ff5beeb6b091a4e87a55b922ab" + "summary": "Added typed configuration-bound realization-envelope identity carriage for ASR-519.", + "content_hash": "19dd8c5aa113fc8ce21c811bf75ef3e22367ae41fcc454c5139cd85db0fb5a60" } }, { "contract_id": "backend-profile-v1", "schema_path": "contracts/schemas/profiles/backend-profile-v1.json", "stability": "draft", - "content_hash": "3f4152b2d7f1e4a01b30e2782a4d6001654e2785f4b9fe54121ed7e84a37c604", + "content_hash": "0c019e86d071bf97bc4464e76d1c224699e9f418e76cdcf117d6b813a3e9ac87", "last_change": { - "summary": "Added experiment-run-v1 to the backend profile contract vocabulary so profile fixture conformance can exercise run-level observability semantics.", - "content_hash": "3f4152b2d7f1e4a01b30e2782a4d6001654e2785f4b9fe54121ed7e84a37c604" + "summary": "Added realization-envelope-v1 to the governed backend contract vocabulary for configuration-bound ASR-519 claims.", + "content_hash": "0c019e86d071bf97bc4464e76d1c224699e9f418e76cdcf117d6b813a3e9ac87" } }, { @@ -324,7 +324,21 @@ "contract_id": "provisioning-plan-v1", "schema_path": "contracts/schemas/plans/provisioning-plan-v1.json", "stability": "draft", - "content_hash": "e200fb7e8e68f05eb61c35cc2f724882e3b8977df7f4d5ae97e70401a1a76e01" + "content_hash": "8ff225daf75c1d9e846bf36c492cb42076d1ae8c7c1c3ee0145772e2c7aba7e7", + "last_change": { + "summary": "Added immutable realization-envelope identity carriage from planning to backend execution for ASR-519.", + "content_hash": "8ff225daf75c1d9e846bf36c492cb42076d1ae8c7c1c3ee0145772e2c7aba7e7" + } + }, + { + "contract_id": "realization-envelope-v1", + "schema_path": "contracts/schemas/realization-envelope/realization-envelope-v1.json", + "stability": "draft", + "content_hash": "269a17d2ff15c12dfccd536cf8502a1e6b929883eb41e22f898abe75f6532720", + "last_change": { + "summary": "Initial publication of the ASR-519 configuration-bound realization envelope, concern disclosure, observation strength, and canonical identity contract.", + "content_hash": "269a17d2ff15c12dfccd536cf8502a1e6b929883eb41e22f898abe75f6532720" + } }, { "contract_id": "reference-models-v1", @@ -346,10 +360,10 @@ "contract_id": "runtime-snapshot-v1", "schema_path": "contracts/schemas/snapshots/runtime-snapshot-v1.json", "stability": "draft", - "content_hash": "cb6a599ef5137bcc5954ffd4f451167562186112c7595ec20b50eb94b97e1550", + "content_hash": "2c703b182c3bc96176dcf25f1cf1aaf39e661041efdeeacc7f8843fcfa2826bf", "last_change": { - "summary": "Added first-class joint_action_records and time_management_contexts fields to the runtime snapshot envelope for RUN-308 concurrent participant execution.", - "content_hash": "cb6a599ef5137bcc5954ffd4f451167562186112c7595ec20b50eb94b97e1550" + "summary": "Added typed realization-envelope identity persistence for ASR-519 runtime provenance.", + "content_hash": "2c703b182c3bc96176dcf25f1cf1aaf39e661041efdeeacc7f8843fcfa2826bf" } }, { diff --git a/contracts/schemas/backend-manifest/backend-manifest-v2.json b/contracts/schemas/backend-manifest/backend-manifest-v2.json index 26ca658cc..1c0dc190f 100644 --- a/contracts/schemas/backend-manifest/backend-manifest-v2.json +++ b/contracts/schemas/backend-manifest/backend-manifest-v2.json @@ -666,6 +666,46 @@ "title": "ProvisionerCapabilitiesModel", "type": "object" }, + "RealizationEnvelopeIdentityModel": { + "additionalProperties": false, + "description": "Immutable realization-envelope identity carried across runtime contracts.", + "properties": { + "configuration_digest": { + "pattern": "^sha256:[a-f0-9]{64}$", + "title": "Configuration Digest", + "type": "string" + }, + "contract_id": { + "const": "realization-envelope-v1", + "default": "realization-envelope-v1", + "title": "Contract Id", + "type": "string" + }, + "digest": { + "pattern": "^sha256:[a-f0-9]{64}$", + "title": "Digest", + "type": "string" + }, + "envelope_id": { + "minLength": 1, + "title": "Envelope Id", + "type": "string" + }, + "schema_version": { + "const": "realization-envelope/v1", + "default": "realization-envelope/v1", + "title": "Schema Version", + "type": "string" + } + }, + "required": [ + "envelope_id", + "digest", + "configuration_digest" + ], + "title": "RealizationEnvelopeIdentityModel", + "type": "object" + }, "RealizationSupportDeclarationModel": { "additionalProperties": false, "allOf": [ @@ -808,6 +848,57 @@ "$id": "https://aces.dev/schemas/backend-manifest-v2.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "realization_envelope": { + "not": { + "type": "null" + } + } + }, + "required": [ + "realization_envelope" + ] + }, + "then": { + "properties": { + "supported_contract_versions": { + "contains": { + "const": "realization-envelope-v1" + } + } + } + } + }, + { + "if": { + "properties": { + "supported_contract_versions": { + "contains": { + "const": "realization-envelope-v1" + } + } + }, + "required": [ + "supported_contract_versions" + ] + }, + "then": { + "properties": { + "realization_envelope": { + "not": { + "type": "null" + } + } + }, + "required": [ + "realization_envelope" + ] + } + } + ], "properties": { "capabilities": { "$ref": "#/$defs/BackendCapabilitiesV2Model" @@ -833,6 +924,17 @@ "identity": { "$ref": "#/$defs/ApparatusIdentityModel" }, + "realization_envelope": { + "anyOf": [ + { + "$ref": "#/$defs/RealizationEnvelopeIdentityModel" + }, + { + "type": "null" + } + ], + "default": null + }, "realization_support": { "items": { "$ref": "#/$defs/RealizationSupportDeclarationModel" @@ -851,6 +953,7 @@ "items": { "enum": [ "backend-manifest-v2", + "realization-envelope-v1", "provisioning-plan-v1", "orchestration-plan-v1", "evaluation-plan-v1", diff --git a/contracts/schemas/plans/provisioning-plan-v1.json b/contracts/schemas/plans/provisioning-plan-v1.json index 263809b0d..fdb037c56 100644 --- a/contracts/schemas/plans/provisioning-plan-v1.json +++ b/contracts/schemas/plans/provisioning-plan-v1.json @@ -42,6 +42,46 @@ ], "title": "PlanOperationModel", "type": "object" + }, + "RealizationEnvelopeIdentityModel": { + "additionalProperties": false, + "description": "Immutable realization-envelope identity carried across runtime contracts.", + "properties": { + "configuration_digest": { + "pattern": "^sha256:[a-f0-9]{64}$", + "title": "Configuration Digest", + "type": "string" + }, + "contract_id": { + "const": "realization-envelope-v1", + "default": "realization-envelope-v1", + "title": "Contract Id", + "type": "string" + }, + "digest": { + "pattern": "^sha256:[a-f0-9]{64}$", + "title": "Digest", + "type": "string" + }, + "envelope_id": { + "minLength": 1, + "title": "Envelope Id", + "type": "string" + }, + "schema_version": { + "const": "realization-envelope/v1", + "default": "realization-envelope/v1", + "title": "Schema Version", + "type": "string" + } + }, + "required": [ + "envelope_id", + "digest", + "configuration_digest" + ], + "title": "RealizationEnvelopeIdentityModel", + "type": "object" } }, "$id": "https://aces.dev/schemas/provisioning-plan-v1.json", @@ -62,6 +102,17 @@ }, "title": "Operations", "type": "array" + }, + "realization_envelope": { + "anyOf": [ + { + "$ref": "#/$defs/RealizationEnvelopeIdentityModel" + }, + { + "type": "null" + } + ], + "default": null } }, "title": "ProvisioningPlanModel", diff --git a/contracts/schemas/profiles/backend-profile-v1.json b/contracts/schemas/profiles/backend-profile-v1.json index 8de99d213..eef04d2b5 100644 --- a/contracts/schemas/profiles/backend-profile-v1.json +++ b/contracts/schemas/profiles/backend-profile-v1.json @@ -13,6 +13,7 @@ "items": { "enum": [ "backend-manifest-v2", + "realization-envelope-v1", "provisioning-plan-v1", "orchestration-plan-v1", "evaluation-plan-v1", diff --git a/contracts/schemas/realization-envelope/realization-envelope-v1.json b/contracts/schemas/realization-envelope/realization-envelope-v1.json new file mode 100644 index 000000000..77c0ee503 --- /dev/null +++ b/contracts/schemas/realization-envelope/realization-envelope-v1.json @@ -0,0 +1,966 @@ +{ + "$defs": { + "BooleanDomain": { + "additionalProperties": false, + "description": "Booleans: both ``true``/``false``, or an exact boolean when ``value`` set.", + "properties": { + "kind": { + "const": "boolean", + "default": "boolean", + "title": "Kind", + "type": "string" + }, + "value": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Value" + } + }, + "title": "BooleanDomain", + "type": "object" + }, + "Closure": { + "description": "Whether unspecified realizable dimensions under a scope are admitted.", + "enum": [ + "open-world", + "closed-world" + ], + "title": "Closure", + "type": "string" + }, + "ClosureOverlay": { + "additionalProperties": false, + "description": "Declares open-world or closed-world closure at a scope path.", + "properties": { + "closure": { + "$ref": "#/$defs/Closure" + }, + "path": { + "default": "", + "title": "Path", + "type": "string" + }, + "scope": { + "$ref": "#/$defs/EnvelopeScope" + } + }, + "required": [ + "scope", + "closure" + ], + "title": "ClosureOverlay", + "type": "object" + }, + "ConcernDisposition": { + "description": "How the selected realizer treats a governed concern.", + "enum": [ + "realized", + "transformed", + "descriptor-only", + "unsupported" + ], + "title": "ConcernDisposition", + "type": "string" + }, + "EnumDomain": { + "additionalProperties": false, + "description": "A finite value set: values equal to one listed member.", + "properties": { + "kind": { + "const": "enum", + "default": "enum", + "title": "Kind", + "type": "string" + }, + "values": { + "items": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "minItems": 1, + "title": "Values", + "type": "array" + } + }, + "required": [ + "values" + ], + "title": "EnumDomain", + "type": "object" + }, + "EnvelopeBinding": { + "additionalProperties": false, + "description": "Binds an SDL path (or governed scope ref) to a domain at a scope.\n\n``domain`` names a descriptor in the envelope's ``domains`` map and is required\nfor ``constrained`` / ``exact`` posture and forbidden for ``open`` posture (an\nopen value is left to a downstream realizer). ``overrideable`` allows a\nmore-specific binding to widen a value an enclosing closed scope fixed\n(``envelope-semantics.md`` R2).", + "properties": { + "domain": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Domain" + }, + "overrideable": { + "default": false, + "title": "Overrideable", + "type": "boolean" + }, + "path": { + "minLength": 1, + "title": "Path", + "type": "string" + }, + "posture": { + "$ref": "#/$defs/Posture" + }, + "scope": { + "$ref": "#/$defs/EnvelopeScope" + } + }, + "required": [ + "path", + "scope", + "posture" + ], + "title": "EnvelopeBinding", + "type": "object" + }, + "EnvelopeScope": { + "description": "Semantic extent where a posture or closure applies (most local first).", + "enum": [ + "field", + "node", + "topology", + "app", + "scenario" + ], + "title": "EnvelopeScope", + "type": "string" + }, + "ExactDomain": { + "additionalProperties": false, + "description": "A singleton value set: values equal to ``value``.", + "properties": { + "kind": { + "const": "exact", + "default": "exact", + "title": "Kind", + "type": "string" + }, + "value": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "string" + } + ], + "title": "Value" + } + }, + "required": [ + "value" + ], + "title": "ExactDomain", + "type": "object" + }, + "GovernedReferenceDomain": { + "additionalProperties": false, + "description": "References in a finite governed set under a named authority.", + "properties": { + "allowed_refs": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Allowed Refs", + "type": "array" + }, + "authority": { + "minLength": 1, + "title": "Authority", + "type": "string" + }, + "kind": { + "const": "governed-reference", + "default": "governed-reference", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "authority", + "allowed_refs" + ], + "title": "GovernedReferenceDomain", + "type": "object" + }, + "IntegerBoundsModel": { + "additionalProperties": false, + "description": "Closed positive integer interval used by realizer resource claims.", + "properties": { + "maximum": { + "anyOf": [ + { + "minimum": 1, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Maximum" + }, + "minimum": { + "minimum": 1, + "title": "Minimum", + "type": "integer" + } + }, + "required": [ + "minimum" + ], + "title": "IntegerBoundsModel", + "type": "object" + }, + "NumericIntervalDomain": { + "additionalProperties": false, + "description": "Numbers of ``numeric_type`` inside a bounded interval.\n\nBoth endpoints are required (the fragment admits only *bounded* intervals,\n``envelope-semantics.md`` R3). An integer interval requires integral\nendpoints. Empty intervals are rejected at construction.", + "properties": { + "kind": { + "const": "numeric-interval", + "default": "numeric-interval", + "title": "Kind", + "type": "string" + }, + "lower": { + "title": "Lower", + "type": "number" + }, + "lower_closed": { + "default": true, + "title": "Lower Closed", + "type": "boolean" + }, + "numeric_type": { + "$ref": "#/$defs/NumericType" + }, + "upper": { + "title": "Upper", + "type": "number" + }, + "upper_closed": { + "default": true, + "title": "Upper Closed", + "type": "boolean" + } + }, + "required": [ + "numeric_type", + "lower", + "upper" + ], + "title": "NumericIntervalDomain", + "type": "object" + }, + "NumericType": { + "description": "Declared numeric type for a numeric-interval domain.", + "enum": [ + "integer", + "number" + ], + "title": "NumericType", + "type": "string" + }, + "ObservationStrength": { + "description": "Strongest evidence a backend configuration emits for one concern.", + "enum": [ + "none", + "driver-reported", + "daemon-observed", + "guest-observed" + ], + "title": "ObservationStrength", + "type": "string" + }, + "Posture": { + "description": "Author/backend intent for a bound value or child scope.", + "enum": [ + "open", + "constrained", + "exact" + ], + "title": "Posture", + "type": "string" + }, + "RealizationConcern": { + "description": "Closed concern taxonomy shared by backend envelope artifacts.", + "enum": [ + "topology", + "architecture", + "image", + "resource-allocation", + "network", + "content-placement", + "account-placement", + "feature-binding", + "acl" + ], + "title": "RealizationConcern", + "type": "string" + }, + "RealizationConcernDisclosureModel": { + "additionalProperties": false, + "allOf": [ + { + "else": { + "properties": { + "transformations": { + "maxItems": 0 + } + } + }, + "if": { + "properties": { + "disposition": { + "const": "transformed" + } + } + }, + "then": { + "properties": { + "transformations": { + "minItems": 1 + } + } + } + }, + { + "else": { + "properties": { + "mechanism": { + "minLength": 1, + "type": "string" + }, + "observation_strength": { + "not": { + "const": "none" + } + } + } + }, + "if": { + "properties": { + "disposition": { + "const": "unsupported" + } + } + }, + "then": { + "properties": { + "mechanism": { + "type": "null" + }, + "observation_strength": { + "const": "none" + } + } + } + } + ], + "description": "Typed support, transformation, and observation claim for one concern.", + "properties": { + "concern": { + "$ref": "#/$defs/RealizationConcern" + }, + "disposition": { + "$ref": "#/$defs/ConcernDisposition" + }, + "mechanism": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mechanism" + }, + "observation_strength": { + "$ref": "#/$defs/ObservationStrength" + }, + "transformations": { + "items": { + "$ref": "#/$defs/TransformationKind" + }, + "title": "Transformations", + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "concern", + "disposition", + "observation_strength" + ], + "title": "RealizationConcernDisclosureModel", + "type": "object" + }, + "RealizationEnvelopeModel": { + "additionalProperties": false, + "description": "A versioned expression denoting a set of SDL scenario instances.", + "properties": { + "bindings": { + "items": { + "$ref": "#/$defs/EnvelopeBinding" + }, + "title": "Bindings", + "type": "array" + }, + "closure": { + "items": { + "$ref": "#/$defs/ClosureOverlay" + }, + "title": "Closure", + "type": "array" + }, + "contract_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Contract Id" + }, + "digest": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Digest" + }, + "domains": { + "additionalProperties": { + "discriminator": { + "mapping": { + "boolean": "#/$defs/BooleanDomain", + "enum": "#/$defs/EnumDomain", + "exact": "#/$defs/ExactDomain", + "governed-reference": "#/$defs/GovernedReferenceDomain", + "numeric-interval": "#/$defs/NumericIntervalDomain", + "record": "#/$defs/RecordDomain" + }, + "propertyName": "kind" + }, + "oneOf": [ + { + "$ref": "#/$defs/ExactDomain" + }, + { + "$ref": "#/$defs/EnumDomain" + }, + { + "$ref": "#/$defs/BooleanDomain" + }, + { + "$ref": "#/$defs/NumericIntervalDomain" + }, + { + "$ref": "#/$defs/GovernedReferenceDomain" + }, + { + "$ref": "#/$defs/RecordDomain" + } + ] + }, + "propertyNames": { + "minLength": 1 + }, + "title": "Domains", + "type": "object" + }, + "id": { + "minLength": 1, + "title": "Id", + "type": "string" + }, + "schema_version": { + "const": "realization-envelope/v1", + "default": "realization-envelope/v1", + "title": "Schema Version", + "type": "string" + }, + "scope": { + "$ref": "#/$defs/EnvelopeScope" + }, + "source_ref": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Source Ref" + }, + "witness_policy": { + "anyOf": [ + { + "$ref": "#/$defs/WitnessPolicy" + }, + { + "type": "null" + } + ], + "default": null + } + }, + "required": [ + "id", + "scope" + ], + "title": "RealizationEnvelopeModel", + "type": "object" + }, + "RealizerConfigurationModel": { + "additionalProperties": false, + "description": "Secret-free material configuration identity for one realizer mode.", + "properties": { + "architecture": { + "minLength": 1, + "title": "Architecture", + "type": "string" + }, + "configuration_digest": { + "pattern": "^sha256:[a-f0-9]{64}$", + "title": "Configuration Digest", + "type": "string" + }, + "image_policy": { + "minLength": 1, + "title": "Image Policy", + "type": "string" + }, + "memory_mib": { + "$ref": "#/$defs/IntegerBoundsModel" + }, + "mode": { + "minLength": 1, + "title": "Mode", + "type": "string" + }, + "network_policy": { + "minLength": 1, + "title": "Network Policy", + "type": "string" + }, + "supported_account_features": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Supported Account Features", + "type": "array", + "uniqueItems": true + }, + "supported_content_types": { + "items": { + "minLength": 1, + "type": "string" + }, + "title": "Supported Content Types", + "type": "array", + "uniqueItems": true + }, + "supported_node_types": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Supported Node Types", + "type": "array", + "uniqueItems": true + }, + "supported_os_families": { + "items": { + "minLength": 1, + "type": "string" + }, + "minItems": 1, + "title": "Supported Os Families", + "type": "array", + "uniqueItems": true + }, + "supports_acls": { + "default": false, + "title": "Supports Acls", + "type": "boolean" + }, + "vcpus": { + "$ref": "#/$defs/IntegerBoundsModel" + } + }, + "required": [ + "mode", + "configuration_digest", + "architecture", + "image_policy", + "network_policy", + "supported_node_types", + "supported_os_families", + "memory_mib", + "vcpus" + ], + "title": "RealizerConfigurationModel", + "type": "object" + }, + "RecordDomain": { + "additionalProperties": false, + "description": "Product structure: each declared field references another named domain.\n\n``extra`` controls undeclared fields: ``False`` (closed) rejects any field not\nnamed in ``fields``; ``True`` (open) admits them. Field values reference domain\nnames resolved against the envelope's ``domains`` map, keeping the structure\nacyclic and free of inline recursion.", + "properties": { + "extra": { + "default": false, + "title": "Extra", + "type": "boolean" + }, + "fields": { + "additionalProperties": { + "minLength": 1, + "type": "string" + }, + "minProperties": 1, + "propertyNames": { + "minLength": 1 + }, + "title": "Fields", + "type": "object" + }, + "kind": { + "const": "record", + "default": "record", + "title": "Kind", + "type": "string" + } + }, + "required": [ + "fields" + ], + "title": "RecordDomain", + "type": "object" + }, + "TransformationKind": { + "description": "Portable disclosure of a material realization transformation.", + "enum": [ + "bounded-normalization", + "default-substitution", + "descriptor-substitution", + "image-substitution", + "service-synthesis" + ], + "title": "TransformationKind", + "type": "string" + }, + "WitnessPolicy": { + "additionalProperties": false, + "description": "Deterministic default-selection policy for witness generation.\n\n``selections`` overrides the default choice for named domains (each value must\nbe a member of the referenced domain); ``seed`` records the selection basis for\nreproducibility. Neither introduces randomness: witness generation stays a pure\nfunction of ``(envelope, policy)``.", + "properties": { + "seed": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Seed" + }, + "selections": { + "additionalProperties": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + }, + "propertyNames": { + "minLength": 1 + }, + "title": "Selections", + "type": "object" + } + }, + "title": "WitnessPolicy", + "type": "object" + } + }, + "$id": "https://aces.dev/schemas/realization-envelope-v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "Published backend carrier: shared set expression plus truthful realization claims.", + "properties": { + "concerns": { + "allOf": [ + { + "contains": { + "properties": { + "concern": { + "const": "topology" + } + }, + "required": [ + "concern" + ], + "type": "object" + }, + "maxContains": 1, + "minContains": 1 + }, + { + "contains": { + "properties": { + "concern": { + "const": "architecture" + } + }, + "required": [ + "concern" + ], + "type": "object" + }, + "maxContains": 1, + "minContains": 1 + }, + { + "contains": { + "properties": { + "concern": { + "const": "image" + } + }, + "required": [ + "concern" + ], + "type": "object" + }, + "maxContains": 1, + "minContains": 1 + }, + { + "contains": { + "properties": { + "concern": { + "const": "resource-allocation" + } + }, + "required": [ + "concern" + ], + "type": "object" + }, + "maxContains": 1, + "minContains": 1 + }, + { + "contains": { + "properties": { + "concern": { + "const": "network" + } + }, + "required": [ + "concern" + ], + "type": "object" + }, + "maxContains": 1, + "minContains": 1 + }, + { + "contains": { + "properties": { + "concern": { + "const": "content-placement" + } + }, + "required": [ + "concern" + ], + "type": "object" + }, + "maxContains": 1, + "minContains": 1 + }, + { + "contains": { + "properties": { + "concern": { + "const": "account-placement" + } + }, + "required": [ + "concern" + ], + "type": "object" + }, + "maxContains": 1, + "minContains": 1 + }, + { + "contains": { + "properties": { + "concern": { + "const": "feature-binding" + } + }, + "required": [ + "concern" + ], + "type": "object" + }, + "maxContains": 1, + "minContains": 1 + }, + { + "contains": { + "properties": { + "concern": { + "const": "acl" + } + }, + "required": [ + "concern" + ], + "type": "object" + }, + "maxContains": 1, + "minContains": 1 + } + ], + "items": { + "$ref": "#/$defs/RealizationConcernDisclosureModel" + }, + "maxItems": 9, + "minItems": 9, + "title": "Concerns", + "type": "array" + }, + "configuration": { + "$ref": "#/$defs/RealizerConfigurationModel" + }, + "contract_id": { + "const": "realization-envelope-v1", + "default": "realization-envelope-v1", + "title": "Contract Id", + "type": "string" + }, + "digest": { + "pattern": "^sha256:[a-f0-9]{64}$", + "title": "Digest", + "type": "string" + }, + "expression": { + "$ref": "#/$defs/RealizationEnvelopeModel" + }, + "id": { + "minLength": 1, + "title": "Id", + "type": "string" + }, + "schema_version": { + "const": "realization-envelope/v1", + "default": "realization-envelope/v1", + "title": "Schema Version", + "type": "string" + } + }, + "required": [ + "id", + "expression", + "configuration", + "concerns", + "digest" + ], + "title": "BackendRealizationEnvelopeModel", + "type": "object", + "x-aces-invariants": [ + { + "description": "Configuration bounds, expression references, canonical configuration and envelope digests, and all cross-field realization disclosure semantics must validate together.", + "id": "realization-envelope-canonical-semantics-valid", + "inputs": [ + { + "contract_id": "realization-envelope-v1", + "instance_path": "#" + } + ], + "level": "error", + "validator": "aces_contracts.realization_envelope.validate_backend_realization_envelope" + } + ], + "x-aces-semantic-profile": { + "contract_id": "realization-envelope-v1", + "entry_schema_contract_id": "aces-semantic-invariants-v1", + "entry_schema_pointer": "#/$defs/AcesSemanticInvariantEntryModel", + "id": "aces-semantic-invariants-v1", + "keyword": "x-aces-invariants", + "required": true, + "uri": "https://aces.dev/schemas/semantic-invariants/v1" + } +} diff --git a/contracts/schemas/snapshots/runtime-snapshot-v1.json b/contracts/schemas/snapshots/runtime-snapshot-v1.json index abe93d366..b76ad83ed 100644 --- a/contracts/schemas/snapshots/runtime-snapshot-v1.json +++ b/contracts/schemas/snapshots/runtime-snapshot-v1.json @@ -3443,6 +3443,46 @@ "title": "RawDataIntegrityModel", "type": "object" }, + "RealizationEnvelopeIdentityModel": { + "additionalProperties": false, + "description": "Immutable realization-envelope identity carried across runtime contracts.", + "properties": { + "configuration_digest": { + "pattern": "^sha256:[a-f0-9]{64}$", + "title": "Configuration Digest", + "type": "string" + }, + "contract_id": { + "const": "realization-envelope-v1", + "default": "realization-envelope-v1", + "title": "Contract Id", + "type": "string" + }, + "digest": { + "pattern": "^sha256:[a-f0-9]{64}$", + "title": "Digest", + "type": "string" + }, + "envelope_id": { + "minLength": 1, + "title": "Envelope Id", + "type": "string" + }, + "schema_version": { + "const": "realization-envelope/v1", + "default": "realization-envelope/v1", + "title": "Schema Version", + "type": "string" + } + }, + "required": [ + "envelope_id", + "digest", + "configuration_digest" + ], + "title": "RealizationEnvelopeIdentityModel", + "type": "object" + }, "RealizationProvenanceEntryModel": { "additionalProperties": false, "description": "SEM-218 invariant I5: provenance for one realized realization concern.\n\nDistinguishes ``author-declared`` / ``processor-derived`` / ``backend-realized``\norigins for a realized concern recorded on the snapshot's result / history\nsurfaces. Carries field-path and kind references only (never the realized\nvalue, per the SEM-218 host-exposure gate). Kept distinct from ADR-054\nlifecycle ``phase_realization`` and API-407 participant feature support.", @@ -4025,6 +4065,17 @@ "title": "Participant Episode Results", "type": "object" }, + "realization_envelope": { + "anyOf": [ + { + "$ref": "#/$defs/RealizationEnvelopeIdentityModel" + }, + { + "type": "null" + } + ], + "default": null + }, "realization_provenance": { "items": { "$ref": "#/$defs/RealizationProvenanceEntryModel" diff --git a/docs/conformance/libvirt-qemu.provisioning-only.report.json b/docs/conformance/libvirt-qemu.provisioning-only.report.json index 1ce8a7db3..770d31133 100644 --- a/docs/conformance/libvirt-qemu.provisioning-only.report.json +++ b/docs/conformance/libvirt-qemu.provisioning-only.report.json @@ -234,21 +234,21 @@ ] }, { - "name": "live-manifest", + "name": "target-manifest", "contract_name": "backend-manifest-v2", "valid": true, "passed": true, "diagnostic_codes": [] }, { - "name": "live-provisioning", + "name": "target-provisioning", "contract_name": "operation-status-v1", "valid": true, "passed": true, "diagnostic_codes": [] }, { - "name": "live-snapshot", + "name": "target-snapshot", "contract_name": "runtime-snapshot-v1", "valid": true, "passed": true, diff --git a/docs/decisions/adrs/adr-019-normative-authority-boundary-manifest.md b/docs/decisions/adrs/adr-019-normative-authority-boundary-manifest.md index 285dadf10..9eead7619 100644 --- a/docs/decisions/adrs/adr-019-normative-authority-boundary-manifest.md +++ b/docs/decisions/adrs/adr-019-normative-authority-boundary-manifest.md @@ -13,7 +13,8 @@ accepted [ADR-009](adr-009-normative-artifact-authority-and-repository-structure.md) decided that the ecosystem's authority boundary separates normative artifacts (prose under `specs/`, schemas under `contracts/schemas/`, fixtures under -`contracts/fixtures/`, profiles under `contracts/profiles/`, and the shared +`contracts/fixtures/`, profiles under `contracts/profiles/`, configuration-bound +realization envelopes under `contracts/realization-envelopes/`, and the shared concept-authority artifacts under `contracts/concept-authority/`) from reference implementations (`implementations/`), explanatory docs (`docs/`), worked examples (`examples/`), research material (`research/`), and tooling @@ -44,6 +45,7 @@ decision: - `schemas` under `contracts/schemas/` - `fixtures` under `contracts/fixtures/` - `profiles` under `contracts/profiles/` + - `realization-envelopes` under `contracts/realization-envelopes/` - `concept-authority` under `contracts/concept-authority/` It also enumerates each non-normative root (`implementations/`, `docs/`, @@ -114,3 +116,9 @@ decision: ADR-009 must update `CANONICAL_AUTHORITY_ROOT_IDS` in `tools/check_authority_boundary.py` alongside the YAML edit. This is the same pattern ADR-018's gate uses, and it surfaces in test coverage. + +## Amendments + +| Date | Commit/PR | Summary | +|------|-----------|---------| +| 2026-07-11 | #100 | Added `contracts/realization-envelopes/` as the normative authority for configuration-bound backend realization disclosures. | diff --git a/docs/decisions/adrs/adr-070-realization-envelope-semantics.md b/docs/decisions/adrs/adr-070-realization-envelope-semantics.md index b204e5d52..42df3519a 100644 --- a/docs/decisions/adrs/adr-070-realization-envelope-semantics.md +++ b/docs/decisions/adrs/adr-070-realization-envelope-semantics.md @@ -133,19 +133,23 @@ inside a typed domain. Exact means the domain is a singleton. Closed-world scope means no unspecified realizable dimensions under that scope are portable members of the set. -### 5. Backend manifest carriage is a schema-evolution question +### 5. Backend manifest carriage uses configuration-bound identity -Current `backend-manifest-v2` can disclose coarse support through -`realization_support`. It cannot express value-level sets, scoped closure, or a -portable subsumption relation. +`backend-manifest-v2.realization_support` discloses coarse support but cannot by +itself express value-level sets, scoped closure, or a portable subsumption +relation. -The selected carriage direction is a future manifest evolution that can either: +Issue #100 implements the selected carriage direction as a reference to a +published envelope artifact by contract id, envelope id, version, canonical +content digest, and secret-free material-configuration digest. The same identity +is carried by provisioning plans and runtime snapshots. -- embed a small envelope expression directly; or -- reference a published envelope artifact by contract id, digest, and version. +The published artifact embeds the shared expression and closed typed backend +realization, transformation, and observation-strength disclosures. Backend +manifest payloads carry only its immutable identity. -Both modes use the same expression contract. Neither overloads the current -`constraints: dict[str, str]` prose map as the final semantics. +This does not overload the current `constraints: dict[str, str]` prose map and +does not create a backend-local set relation. ### 6. Closed envelopes require negative conformance diff --git a/docs/decisions/adrs/adr-index.yaml b/docs/decisions/adrs/adr-index.yaml index 3ff2cdc9e..e7148f12d 100644 --- a/docs/decisions/adrs/adr-index.yaml +++ b/docs/decisions/adrs/adr-index.yaml @@ -71,7 +71,9 @@ adrs: pin: 168195279ea2e3dafb12d3d308f750af2804bc09700efa3dc258a31cb05e8d2f - id: ADR-019 path: docs/decisions/adrs/adr-019-normative-authority-boundary-manifest.md - pin: 66b4ea3eb2482d6f8855462f4921c9cd7853ddb4c9adfaa21f311a07422f248b + pin: 2fe5373440403ec5cbc4221eb417827bea28a7d1ea0b1c7fb142cdeeecc6883d + amendments: + - ref: "#100" - id: ADR-020 path: docs/decisions/adrs/adr-020-declarative-participant-framing-boundaries.md pin: 4a5d146fc0ffd6da97af94e9f2d257a893de0ab7f63c709339d27f33178609d0 diff --git a/docs/decisions/issue-100-asr-519-libvirt-realization-envelope-preflight.md b/docs/decisions/issue-100-asr-519-libvirt-realization-envelope-preflight.md new file mode 100644 index 000000000..29207322c --- /dev/null +++ b/docs/decisions/issue-100-asr-519-libvirt-realization-envelope-preflight.md @@ -0,0 +1,288 @@ +# Issue 100 / ASR-519 Libvirt Realization Envelope Preflight + +Date: 2026-07-11 + +Requirement: ASR-519. + +This note records architecture guardrails for publishing configuration-bound +libvirt realization envelopes. It is guidance only: it does not publish an +envelope, change a contract, select a target, alter planning/apply behavior, or +define an implementation plan. + +## Binding Sources + +- ADR-070 and `specs/formal/realization/envelope-semantics.md` own the + `RealizationEnvelopeModel` language and the shared `member()`, `subsumes()`, + `witness()`, and `generate_negative_probes()` semantics. +- `docs/decisions/issue-667-realization-envelope-preflight.md` and + `docs/decisions/issue-668-envelope-relation-preflight.md` prohibit a second + capability language or backend-local set relation. +- `docs/decisions/issue-602-libvirt-backend-manifest-preflight.md` and + `docs/decisions/issue-605-libvirt-envelope-diagnostics-preflight.md` own the + coarse manifest-capability and concrete plan-term gates. Those gates remain + necessary but are not value-level realization envelopes. +- `create_libvirt_manifest()`, `create_libvirt_components()`, + `create_libvirt_target()`, `_driver_config()`, `LibvirtProvisioner`, + `LibvirtDeploymentDriver`, and `TechVaultNativeLibvirtDriver` are the current + configuration and execution seams. +- `BackendManifest`, `backend_manifest_payload()`, `ExecutionPlan`, + `ProvisioningPlan`, `RuntimeSnapshot`, `RuntimeManager`, + `RuntimeControlPlane`, and `_call_backend_apply()` are the existing carriage, + provenance, persistence, and fail-closed execution path. +- ADR-009, ADR-019, ADR-061, `ContractModel`, `schema_bundle()`, + `contracts/schema-publication-manifest.json`, and `aces_contracts.corpus` own + publication and packaged-corpus authority. + +## Architecture Decisions And Guardrails + +- Publish exactly one authoritative `realization-envelope/v1` artifact for + each constructible **material** libvirt configuration. The initial modes are + generic qcow2/cloud-init and the generated TechVault appliance. They must not + share an envelope where their behavior differs. +- Keep `RealizationEnvelopeModel` as the denotational scenario-set expression + inside the published artifact. Planning must call the shared relation: + concrete instantiated scenarios use `member()`; a future requested envelope + uses `subsumes(offered, requested)`. Do not duplicate path/domain logic in the + libvirt package, planner, manifest validator, or conformance runner. +- The issue's required realization and observation disclosures are not all SDL + value domains. Architecture, placement mechanism, transformation policy, ACL + enforcement, and observation strength are backend-behavior claims. The + published envelope artifact must be one closed carrier containing the + existing set expression plus closed, typed per-concern disclosures under the + same identity. This is the schema-governed carrier anticipated by ADR-070 R7, + not a second set language. Do not misrepresent disclosures as navigable SDL + paths or create a libvirt-only envelope schema. +- Keep three taxonomies distinct: envelope posture (`open`, `constrained`, + `exact`), SEM-218 authored explicitness/provenance, and evidence source or + observation strength (`authored`, `planned`, `driver-reported`, + `daemon-observed`, `guest-observed`). The last is not + `ExplicitnessProvenance` and must not be inferred from snapshot presence. +- Give the selected envelope one immutable identity value containing contract + id, envelope id, envelope schema version, and canonical `sha256:` content + digest. Bind it to the normalized material configuration identity: driver + mode plus a digest of an allowlisted, secret-free semantic configuration + projection. Reuse the repository's existing ref/digest validation conventions; + do not compare mutable object identity, paths, summaries, or prose. +- Canonical digest input is the closed JSON contract payload with stable key + ordering and the self-digest field excluded. The implementation must have one + digest helper and one identity validator. A digest of raw configuration is + forbidden: credentials and other low-entropy secrets remain vulnerable to + offline guessing even when hashed. +- Normalize target configuration once at the factory boundary and pass the same + closed value to manifest selection, component construction, and driver + construction. Unknown fields, invalid combinations, and a driver whose + declared mode does not match the selected envelope fail target construction. + Do not infer mode by `isinstance`, callable name, successful driver behavior, + or host probing; injected test/live drivers must declare the mode explicitly. +- Classify configuration fields deliberately. Driver mode, image/source policy, + architecture, resource bounds, network policy, seed/appliance behavior, and + any realization-affecting toggle are material. Connection handles, URI + credentials, workspace paths, name prefixes, cleanup, timeouts, and injected + connector objects are operational/private unless they change the claim; if + one does change realizability, it must be represented by a safe governed ref + or construction must fail. `participant_runtime=True` changes a separate + runtime surface and must not silently change the provisioning envelope. +- Carry the selected identity through the existing contracts. The backend + manifest advertises the selected envelope; the planner copies that identity + to the provisioning plan; the libvirt provisioner is constructed with the + same expected identity; and a successful runtime snapshot records it in a + typed field. Do not use `constraints`, plan operation payloads, + `RuntimeSnapshot.metadata`, `ApplyResult.details`, tags, or audit blobs. +- `ExecutionPlan.manifest` equality and `RuntimeManager._provenance_diagnostics()` + remain a defense, but are not sufficient because published + `ProvisioningPlan` values can enter through `RuntimeControlPlane` directly. + `LibvirtProvisioner.validate()` and `apply()` must both reject missing, stale, + mismatched, malformed, or unverifiable identity before interpretation, + snapshot reconciliation, `driver.realize()`, or `driver.destroy()`. +- A non-empty baseline snapshot bound to another envelope/configuration is also + a mismatch. Changing material target configuration requires a new plan under + the new identity; it must not silently relabel existing state. +- Keep `ProvisionerCapabilities` and `realization_support` as coarse incumbent + gates. Mechanically check both directions that matter: every selected + envelope concern is permitted by the manifest's coarse kind/capability + surface, and every governed term claimed by the coarse manifest occurs in at + least one selectable envelope. The selected envelope, not the union + capability set, is the value-level admission authority. +- Keep one canonical declaration of selectable envelope content and derive or + validate manifest projections from it. Do not preserve + `LIBVIRT_PROVISIONER_CAPABILITIES`, a new envelope allowlist, and driver-local + term tables as three independent truths. +- Every non-`UNCHANGED` plan operation must remain accounted for by existing + portable surfaces: its address is changed/observed-realized, or an addressed + typed diagnostic marks it unsupported/failed. A successful operation may not + disappear from `changed_addresses` or the snapshot merely because the driver + aggregates work at domain level. + +## Truthful Initial Mode Boundaries + +- Generic qcow2/cloud-init is x86_64 in current XML. Image `Source.version` is + currently dropped, host image existence is not proven during planning, and a + successful libvirt call is not guest boot or guest configuration evidence. + The envelope must narrow image/OS claims or require an explicit governed image + policy; it must not advertise all OS families merely because dialect code can + emit commands. +- Generic resource translation currently rounds/clamps RAM, supplies default + RAM/CPU values, and normalizes service protocol. Those are transformations, + not exact realization. They must be admitted and disclosed for constrained or + open concerns, or rejected before driver IO; exact declarations may never be + silently transformed. +- Generic `file` placement has a direct `write_files` mechanism. Dataset and + directory placement currently write descriptors/commands rather than proving + the named data or directory contents exist. Likewise, some OS dialects record + feature/mail descriptors instead of realizing the native concern. Envelope + claims must distinguish descriptor disclosure from concern realization. +- Generic ACL translation is fail-closed through `realize_node_acls()` and + libvirt nwfilter generation, but a successful define is daemon/substrate + evidence only. Guest-facing enforcement strength requires separate evidence. +- TechVault appliance mode always boots a generated x86_64 Linux/BusyBox + initramfs, ignores the scenario image as the boot artifact, clamps memory to + 64-128 MiB and vCPUs to 1-2, may synthesize a health service, and does not + consume the generic cloud-init account/content/feature or nwfilter surfaces. + Its envelope must reject or explicitly disclose each difference. It must not + inherit the generic mode's broad provisioner claims. +- `DomainHandle`, `NetworkHandle`, and normal `RuntimeSnapshot` entries are + driver-reported/planned substrate facts. Neither mode may label them + `daemon-observed` without native readback or `guest-observed` without a + concern-specific guest probe. Later issues may strengthen observation through + the envelope's per-concern seam without changing envelope identity semantics. + +## Required Cross-Cutting Reuse + +- SDL ingress: `parse_sdl()` / `parse_sdl_file()`, closed `SDLModel` shapes, + `instantiate_scenario()`, `SemanticValidator`, and existing parse, + instantiation, and semantic errors. No libvirt-specific SDL field is needed. +- Envelope semantics: `RealizationEnvelopeModel`, `member()`, `subsumes()`, + `witness()`, and `generate_negative_probes()` from the shared contract and SDL + semantic packages. +- Contract publication: hand-governed schemas, `ContractModel`, + `schema_bundle()`, valid/invalid fixtures, publication-manifest `last_change` + hashes, `x-aces-invariants`, generated-schema parity, and the packaged corpus + resolver. Published envelope instances are a corpus family, not backend + profiles or fixtures. Keep `specs/authority/authority-boundary.yaml`, + `BACKEND_SUPPORTED_CONTRACT_IDS`, schema publication, and packaged wheel/sdist + contents synchronized; do not make the provisioning-only profile require the + new contract unless that is intentionally a universal backend obligation. +- Manifest authority: `BackendManifest`, `BackendManifestV2Model`, + `backend_manifest_payload()`, supported-contract validation, capability-gap + checks, controlled vocabularies, and canonical concept bindings. +- Planning/runtime: `_validate_manifest()`, + `realization_support_diagnostics()`, `realization_disclosure()`, + `ExecutionPlan`, `RuntimeManager`, `RuntimeControlPlane`, + `_call_backend_diagnostics()`, `_call_backend_apply()`, snapshot contract + diagnostics, and target-shape validation. +- Error/observability: existing `Diagnostic`, `Severity`, `OperationReceipt`, + `OperationStatus`, conformance case/report, audit, and `changed_addresses` + surfaces. Do not add a libvirt exception hierarchy or a logging-only result. +- Persistence: `RuntimeSnapshot`, `RuntimeSnapshotEnvelopeModel`, + `ControlPlaneStore`, and its established typed serialization. Existing atomic + run-artifact writers remain the only optional report writer. +- Testing: realization relation/property tests; planner, runtime-manager, + control-plane/API/store, manifest publication, libvirt realization, + provisioner, registry, conformance, and recording-driver tests. Negative + identity/config/unsupported mutations must assert the recording driver saw no + call and the baseline snapshot stayed byte-for-byte equivalent. + +## Security And Whole-Path Gates + +- Config shape gate: CLI/operations inputs must become the normalized closed + target config before any manifest or driver is built. There is no incumbent + environment-binding path for libvirt; do not add one implicitly. A + credential-bearing connection URI must not be accepted through a CLI option, + because command arguments are OS-visible; use a non-secret URI plus an + injected connection/credential handle or an explicit future secret-input + surface. +- Secret gate: connection credentials, connector objects, SSH keys, cloud-init + bodies, host paths, environment dumps, and raw config never enter envelope + artifacts, digests, plans, snapshots, fixtures, diagnostics, audit details, or + reports. Connection URIs remain private driver input and must not be logged. +- Contract/manifest gate: envelope, manifest, plan, and snapshot shapes pass + their closed Pydantic models, published schemas, contract-id allowlists, + vocabulary validation, concept binding validation, and semantic invariants. +- Planner gate: ordinary manifest checks, SEM-218 support checks, and shared + envelope membership/subsumption all run before a valid execution plan exists. + No one check substitutes for another. +- Target/apply gate: target component shape, exact envelope/config identity, + libvirt capability diagnostics, backend `ApplyResult` shape, snapshot + contract/transition validation, and SEM-218 runtime disclosure all remain + fail-closed. Identity failure precedes every native side effect. +- HTTP/auth gate: no new route is needed. Provisioning submissions retain strict + bearer/proxy identity verification, backend/operator role authorization, + target scoping, request-size limits, idempotency fingerprints, audit events, + and closed `ProvisioningPlanModel` parsing. The current bearer-token branch + returns before the proxy branch's `identity.target_name` check; target scope + must be enforced after either authentication mechanism before a + configuration-bound plan is accepted. +- Error-envelope gate: failures use stable typed diagnostics naming ids, + digests, relation kind, concern, and safe paths. They do not echo envelope + values, raw plan/config payloads, native XML/object reprs, argv, stdout/stderr, + stack traces, or credentials; unexpected HTTP failures keep the existing + redacted 500 response. New validation failures must not reach + `_backend_call_failed()`, whose current inclusion of `str(exc)` is not a safe + carrier for configuration or connection errors; use typed redacted + diagnostics, and harden that boundary if it is touched. +- Host/OS gate: envelope selection and validation are pure and hermetic. They do + not import libvirt, query a daemon, inspect images, invoke subprocesses, or + broaden claims from host discovery. Native access stays lazy behind the + existing driver; fixed argv/list-form command and ownership/rollback guards + remain intact. Envelope ids are grammar-checked before corpus path + construction, resolve only below the packaged corpus root, and never select a + caller-supplied or remote path. +- Persistence gate: rejected work writes no snapshot, operation success, + realization provenance, or native-state ledger. Successful state carries the + typed envelope/config identity; private host state remains outside portable + persistence. + +## Extensibility Seam + +The required seam is one normalized material target configuration selecting one +versioned envelope artifact and immutable identity. A future remote libvirt +policy, alternate image family, architecture, storage/network policy, or new +appliance mode adds a normalized configuration variant and envelope artifact; +it does not edit the shared relation, planner rules, control-plane route, +diagnostic hierarchy, or persistence mechanism. Observation may strengthen per +concern from driver-reported to daemon- or guest-observed only when the selected +configuration wires the corresponding probe and changes the envelope digest. + +## Gotchas And Anti-Patterns + +Avoid: + +- treating missing envelope identity as universal support or accepting a legacy + fallback after a successful driver call; +- treating the backend name, driver class, connection URI, manifest summary, or + a successful libvirt define as configuration-bound envelope identity; +- hashing raw secrets or serializing a callable/connection/object repr as + configuration identity; +- passing credential-bearing libvirt URIs in process argv, diagnostics, audit, + or report output; +- carrying identity only on `ExecutionPlan`, only in the manifest, or only in + snapshot `metadata` while direct provisioning-plan submission remains open; +- using `realization_support.constraints` or + `ProvisionerCapabilities.constraints` as the machine-readable envelope; +- conflating SDL membership with backend transformation or observation claims; +- copying the shared relation, plan payload extraction, manifest renderer, + schema registry, vocabulary tables, config normalization, or digest logic; +- allowing the generic and TechVault drivers to select the same envelope by + accident, or inferring the envelope from an injected driver; +- retaining broad OS/content/account/feature/ACL claims that only produce a + descriptor, planned snapshot echo, or substrate handle; +- relabeling planned data as daemon- or guest-observed evidence; +- assuming bearer authentication enforces target scope before the shared + authorization check is corrected; +- weakening the default hermetic suite by requiring libvirt/QEMU, privileges, + host images, network access, or secrets. + +## Non-Goals And Boundaries + +- No implementation of issue #100 or downstream issues #714-#717. +- No new SDL syntax, backend profile, capability language, solver, HTTP route, + persistence service, native-state repository, or libvirt-specific exception + hierarchy. +- No guest-observed certification, real-daemon report, TechVault end-to-end + honesty certification, or final reference-scenario certification; this issue + may only publish the observation strength it actually proves. +- No claim that one witness proves subsumption, negative refusal, or backend + honesty. Closed-envelope conformance still requires generated negative probes + and unchanged native state. +- No compatibility path that treats absent, stale, mismatched, or unverifiable + envelope/configuration identity as acceptable. diff --git a/docs/explain/reference/backend-conformance.md b/docs/explain/reference/backend-conformance.md index 8bb1e29c7..38a2d66b7 100644 --- a/docs/explain/reference/backend-conformance.md +++ b/docs/explain/reference/backend-conformance.md @@ -95,7 +95,7 @@ The conformance path touches these gates: reports `conformance.unsupported-capability-claim` when a manifest claims a role or feature without the runtime contract evidence surface needed to check it. -- Control-plane probe validation: live probes must use `RuntimeControlPlane` +- Control-plane probe validation: target probes must use `RuntimeControlPlane` and existing operation receipt/status/snapshot envelopes rather than backend native objects. - Error-envelope leakage: report failures as `Diagnostic` values with stable @@ -186,29 +186,30 @@ delegate that forwards to the same Typer command. ## Target Conformance Reference Scenario -Target conformance drives a live provisioning/snapshot probe (issue #606) that -proves *real* realization — a succeeded provisioning operation, non-empty -changed addresses, and a mutated snapshot — not merely a valid manifest. The -probe needs a scenario to realize. By default it uses a generic linux-vm -scenario (`_DEFAULT_CONFORMANCE_SCENARIO`). +Target conformance drives a provisioning/snapshot probe (issue #606) that proves +the target adapter accepts a plan, reports changed addresses, and mutates a +snapshot rather than passing on manifest shape alone. With a recording driver, +this is hermetic adapter evidence, not native libvirt or guest realization. +Native and guest certification require the stronger observation gates tracked +by issues #715-#717. The probe needs a scenario to exercise and defaults to a +generic linux-vm scenario (`_DEFAULT_CONFORMANCE_SCENARIO`). A single hard-coded scenario wrongly assumes *every* backend can realize it. Fixed-topology emulation backends (which map ACES nodes onto a pre-built environment) and bounded simulation backends legitimately cannot realize an arbitrary scenario, yet still honor the provisioning contract. `run_target_conformance` therefore accepts an optional `reference_scenario` (issue #663): a backend or -caller supplies a scenario it declares it can realize, and the probe holds it to -**full realization of that scenario** — the #606 mutation guard is unchanged, so -this negotiates *which* scenario is realized without weakening the requirement -that one is. - -This runner parameter is a **temporary bridge**. The durable answer is a portable -*realizability envelope* — one parameterized/typed SDL semantics with open/closed -posture that both authored scenarios and backend manifests reference, plus a -scenario/envelope subsumption relation the probe checks (and from which it derives -an in-envelope witness). That design is tracked in #667, with the subsumption -relation in #668; contract conformance and scenario realizability are distinct -dimensions and must stay separately reportable. +caller supplies a scenario inside its declared envelope. The probe verifies +adapter-level accounting for that scenario; it does not upgrade driver-reported +facts to daemon- or guest-observed evidence. + +Issue #100 publishes configuration-bound realization envelopes using the shared +parameterized SDL semantics from #667 and the membership/subsumption relation +from #668. Manifests, provisioning plans, and snapshots carry one immutable +envelope/configuration identity. Replacing the temporary +`reference_scenario` bridge with generated positive and negative probes remains +tracked by #716; contract, adapter, native-daemon, and guest conformance remain +distinct reportable dimensions. ## Non-Goals diff --git a/docs/explain/reference/normative-artifact-authority.md b/docs/explain/reference/normative-artifact-authority.md index b9ef602bd..d8eeb860e 100644 --- a/docs/explain/reference/normative-artifact-authority.md +++ b/docs/explain/reference/normative-artifact-authority.md @@ -21,7 +21,7 @@ contributor-facing reading material. `docs/`, and examples remain non-normative worked examples. - `contracts/` is the home for normative machine-readable artifacts: published schemas, fixture corpora, capability profiles, semantic profiles, - and concept-authority catalogs. + realization envelopes, and concept-authority catalogs. - `implementations/` contains reference code only. Python models, CLI output, generated bindings, and conformance runners consume published authority; they do not define ecosystem meaning. @@ -40,7 +40,8 @@ Reuse these existing surfaces before adding anything new: - normative prose surfaces: `specs/`, especially `specs/concept-authority/` - machine-readable authority: `contracts/README.md`, `contracts/schemas/README.md`, `contracts/schema-publication-manifest.json`, - `contracts/fixtures/`, and `contracts/profiles/` + `contracts/fixtures/`, `contracts/profiles/`, and + `contracts/realization-envelopes/` - contract model and schema bundle helpers: `aces_contracts.contracts.ContractModel`, `schema_bundle()`, and the published `*Model` validators diff --git a/implementations/python/packages/aces_backend_libvirt/drivers/libvirt.py b/implementations/python/packages/aces_backend_libvirt/drivers/libvirt.py index c95a1715b..8d04afe8d 100644 --- a/implementations/python/packages/aces_backend_libvirt/drivers/libvirt.py +++ b/implementations/python/packages/aces_backend_libvirt/drivers/libvirt.py @@ -132,6 +132,8 @@ def open(self, connection_uri: str) -> object | None: ... class LibvirtDeploymentDriver: """Realize portable specs against a libvirt connection.""" + driver_mode = "generic" + def __init__( self, *, diff --git a/implementations/python/packages/aces_backend_libvirt/envelopes.py b/implementations/python/packages/aces_backend_libvirt/envelopes.py new file mode 100644 index 000000000..feb5aa8f0 --- /dev/null +++ b/implementations/python/packages/aces_backend_libvirt/envelopes.py @@ -0,0 +1,37 @@ +"""Configuration-bound realization envelopes for the libvirt backend.""" + +from __future__ import annotations + +import json +from enum import Enum + +from aces_contracts.corpus import REALIZATION_ENVELOPES, corpus_family_root +from aces_contracts.realization_envelope import BackendRealizationEnvelopeModel, realizer_configuration_digest + + +class LibvirtDriverMode(str, Enum): + GENERIC = "generic" + TECHVAULT_APPLIANCE = "techvault-appliance" + + +_ARTIFACTS = { + LibvirtDriverMode.GENERIC: "generic-v1.json", + LibvirtDriverMode.TECHVAULT_APPLIANCE: "techvault-appliance-v1.json", +} + + +def load_libvirt_realization_envelope(mode: LibvirtDriverMode | str) -> BackendRealizationEnvelopeModel: + """Load and validate the packaged envelope for one material driver mode.""" + + normalized = LibvirtDriverMode(mode) + path = corpus_family_root(REALIZATION_ENVELOPES) / "libvirt-qemu" / _ARTIFACTS[normalized] + payload = json.loads(path.read_text(encoding="utf-8")) + envelope = BackendRealizationEnvelopeModel.model_validate(payload) + if envelope.configuration.mode != normalized.value: + raise ValueError("libvirt realization envelope mode does not match selected driver mode") + if envelope.configuration.configuration_digest != realizer_configuration_digest(envelope.configuration): + raise ValueError("libvirt realization envelope configuration digest does not match selected driver mode") + return envelope + + +__all__ = ["LibvirtDriverMode", "load_libvirt_realization_envelope"] diff --git a/implementations/python/packages/aces_backend_libvirt/manifest.py b/implementations/python/packages/aces_backend_libvirt/manifest.py index dfd3f27f4..5251bcc49 100644 --- a/implementations/python/packages/aces_backend_libvirt/manifest.py +++ b/implementations/python/packages/aces_backend_libvirt/manifest.py @@ -15,26 +15,42 @@ from aces_contracts.apparatus import ConceptBinding, RealizationSupportDeclaration from aces_contracts.vocabulary import ParticipantFeatureSupportLevel, RealizationSupportMode +from .envelopes import LibvirtDriverMode, load_libvirt_realization_envelope + LIBVIRT_BACKEND_NAME = "libvirt-qemu" -# The libvirt provisioning capability envelope: the maximum governed provisioning -# vocabulary the driver realizes through cloud-init. Single source of truth for -# both the rendered manifest and the backend's capability-envelope diagnostics -# (issue #605), so the declared envelope and the enforced envelope cannot drift. -LIBVIRT_PROVISIONER_CAPABILITIES = ProvisionerCapabilities( - name="libvirt-provisioner", - supported_node_types=frozenset({"switch", "vm"}), - supported_os_families=frozenset({"linux", "windows", "macos", "freebsd", "other"}), - supported_content_types=frozenset({"file", "dataset", "directory"}), - supported_account_features=frozenset({"groups", "mail", "spn", "shell", "home", "disabled", "auth_method"}), - max_total_nodes=None, - supports_acls=True, - supports_accounts=True, -) + +def _provisioner_capabilities(mode: LibvirtDriverMode) -> ProvisionerCapabilities: + """Derive the coarse manifest projection from the selected governed envelope.""" + + envelope = load_libvirt_realization_envelope(mode) + configuration = envelope.configuration + account_features = frozenset(configuration.supported_account_features) + return ProvisionerCapabilities( + name=( + "libvirt-techvault-appliance-provisioner" + if mode is LibvirtDriverMode.TECHVAULT_APPLIANCE + else "libvirt-provisioner" + ), + supported_node_types=frozenset(configuration.supported_node_types), + supported_os_families=frozenset(configuration.supported_os_families), + supported_content_types=frozenset(configuration.supported_content_types), + supported_account_features=account_features, + max_total_nodes=None, + supports_acls=configuration.supports_acls, + supports_accounts=bool(account_features), + ) + + +# Compatibility exports remain, but their values are derived from the normative +# envelope artifacts so manifest and execution gates cannot drift independently. +LIBVIRT_PROVISIONER_CAPABILITIES = _provisioner_capabilities(LibvirtDriverMode.GENERIC) +TECHVAULT_PROVISIONER_CAPABILITIES = _provisioner_capabilities(LibvirtDriverMode.TECHVAULT_APPLIANCE) _LIBVIRT_BASE_CONTRACT_VERSIONS = frozenset( { "backend-manifest-v2", + "realization-envelope-v1", "operation-receipt-v1", "operation-status-v1", "provisioning-plan-v1", @@ -120,17 +136,17 @@ def _participant_runtime_capabilities() -> ParticipantRuntimeCapabilities: def create_libvirt_manifest(**config: object) -> BackendManifest: """Return the libvirt backend manifest. - The manifest declares the *maximum* governed provisioning vocabulary the - libvirt/QEMU driver realizes through cloud-init: all node types, all OS - families, all content types (file/dataset/directory), and all account - features. Because every declared term is genuinely realized, the manifest - cannot over-claim. "Provisioning-only" here is domain scope only — by default - the backend implements the Provisioner protocol, not the orchestrator or - evaluator. Pass ``participant_runtime=True`` to additionally declare - participant episode support (``LibvirtParticipantRuntime`` with the - deterministic domain adapter). + The manifest projects its governed provisioning vocabulary from the + configuration-selected envelope. Generic qcow2/cloud-init and TechVault + appliance modes therefore disclose distinct capabilities. "Provisioning-only" + is domain scope only: by default the backend implements the Provisioner + protocol, not the orchestrator or evaluator. Pass ``participant_runtime=True`` + to additionally declare participant episode support. """ enable_participant_runtime = bool(config.get("participant_runtime", False)) + mode = LibvirtDriverMode(str(config.get("driver_mode", LibvirtDriverMode.GENERIC.value))) + realization_envelope = load_libvirt_realization_envelope(mode) + provisioner_capabilities = _provisioner_capabilities(mode) supported_contract_versions = ( _LIBVIRT_BASE_CONTRACT_VERSIONS | _LIBVIRT_PARTICIPANT_CONTRACT_VERSIONS @@ -167,7 +183,8 @@ def create_libvirt_manifest(**config: object) -> BackendManifest: ), ), capabilities=BackendCapabilitySet( - provisioner=LIBVIRT_PROVISIONER_CAPABILITIES, + provisioner=provisioner_capabilities, participant_runtime=participant_runtime_cap, ), + realization_envelope=realization_envelope, ) diff --git a/implementations/python/packages/aces_backend_libvirt/provisioner.py b/implementations/python/packages/aces_backend_libvirt/provisioner.py index 716dac4e9..38ac8afba 100644 --- a/implementations/python/packages/aces_backend_libvirt/provisioner.py +++ b/implementations/python/packages/aces_backend_libvirt/provisioner.py @@ -5,19 +5,24 @@ from dataclasses import dataclass from aces_backend_protocols.capabilities import ProvisionerCapabilities +from aces_contracts.contracts import RealizationEnvelopeIdentityModel from aces_contracts.diagnostics import Diagnostic, Severity from aces_contracts.planning import ChangeAction, ProvisioningPlan, ProvisionOp, RuntimeDomain from aces_contracts.runtime_state import ApplyResult, RuntimeSnapshot, SnapshotEntry from ._payload import NETWORK_RESOURCE_TYPE, NODE_RESOURCE_TYPE from .driver import DriverResult, LibvirtDriver -from .manifest import LIBVIRT_PROVISIONER_CAPABILITIES +from .envelopes import LibvirtDriverMode, load_libvirt_realization_envelope +from .manifest import _provisioner_capabilities from .realization import Realization, interpret_provisioning_plan _DOMAIN = "runtime" INVALID_PLAN_CODE = "libvirt-backend.invalid-plan" UNCONFIRMED_DESTROY_CODE = "libvirt-backend.driver.unconfirmed-destroy" UNCONFIRMED_REALIZATION_CODE = "libvirt-backend.driver.unconfirmed-realization" +MISSING_ENVELOPE_CODE = "libvirt-backend.realization-envelope.missing" +MISMATCHED_ENVELOPE_CODE = "libvirt-backend.realization-envelope.mismatch" +BASELINE_ENVELOPE_MISMATCH_CODE = "libvirt-backend.realization-envelope.baseline-mismatch" @dataclass @@ -36,16 +41,25 @@ def __init__( driver: LibvirtDriver | None = None, *, provisioner_capabilities: ProvisionerCapabilities | None = None, + realization_envelope: RealizationEnvelopeIdentityModel | None = None, ) -> None: self._driver = driver if driver is not None else _default_driver() - # The capability envelope every plan term is validated against; defaults to - # the libvirt manifest envelope but tracks the manifest the target was built - # with when create_libvirt_components passes it in (issue #605). - self._provisioner_capabilities = provisioner_capabilities or LIBVIRT_PROVISIONER_CAPABILITIES + mode = LibvirtDriverMode(getattr(self._driver, "driver_mode", LibvirtDriverMode.GENERIC.value)) + expected_capabilities = _provisioner_capabilities(mode) + expected_envelope = load_libvirt_realization_envelope(mode).identity + if provisioner_capabilities is not None and provisioner_capabilities != expected_capabilities: + raise ValueError("libvirt provisioner capabilities do not match driver mode") + if realization_envelope is not None and realization_envelope != expected_envelope: + raise ValueError("libvirt provisioner realization envelope does not match driver mode") + self._provisioner_capabilities = expected_capabilities + self._realization_envelope = expected_envelope def validate(self, plan: ProvisioningPlan) -> list[Diagnostic]: if not isinstance(plan, ProvisioningPlan): return [_invalid_plan_diagnostic()] + identity_diagnostics = self._identity_diagnostics(plan, RuntimeSnapshot()) + if identity_diagnostics: + return identity_diagnostics realization = interpret_provisioning_plan(plan, provisioner_capabilities=self._provisioner_capabilities) return list(realization.diagnostics) @@ -57,6 +71,14 @@ def apply(self, plan: ProvisioningPlan, snapshot: RuntimeSnapshot) -> ApplyResul return result def _apply_provisioning_plan(self, plan: ProvisioningPlan, snapshot: RuntimeSnapshot) -> ApplyResult: + identity_diagnostics = self._identity_diagnostics(plan, snapshot) + if identity_diagnostics: + result = ApplyResult(success=False, snapshot=snapshot, diagnostics=identity_diagnostics) + else: + result = self._apply_realization(plan, snapshot) + return result + + def _apply_realization(self, plan: ProvisioningPlan, snapshot: RuntimeSnapshot) -> ApplyResult: realization = interpret_provisioning_plan(plan, provisioner_capabilities=self._provisioner_capabilities) diagnostics: list[Diagnostic] = list(realization.diagnostics) if _has_error(diagnostics): @@ -76,7 +98,10 @@ def _apply_provisioning_plan(self, plan: ProvisioningPlan, snapshot: RuntimeSnap return ApplyResult( success=True, - snapshot=snapshot.with_entries(reconciliation.entries), + snapshot=snapshot.with_entries( + reconciliation.entries, + realization_envelope=self._realization_envelope, + ), diagnostics=diagnostics, changed_addresses=reconciliation.changed_addresses, ) @@ -117,6 +142,32 @@ def _drive( ) return diagnostics + def _identity_diagnostics(self, plan: ProvisioningPlan, snapshot: RuntimeSnapshot) -> list[Diagnostic]: + diagnostics: list[Diagnostic] = [] + if plan.realization_envelope is None: + diagnostics = [ + _envelope_diagnostic( + MISSING_ENVELOPE_CODE, "Provisioning plan is missing realization envelope identity." + ) + ] + elif plan.realization_envelope != self._realization_envelope: + diagnostics = [ + _envelope_diagnostic( + MISMATCHED_ENVELOPE_CODE, + "Provisioning plan realization envelope does not match the configured libvirt target.", + ) + ] + elif ( + snapshot.realization_envelope is not None and snapshot.realization_envelope != self._realization_envelope + ) or (_snapshot_has_state(snapshot) and snapshot.realization_envelope is None): + diagnostics = [ + _envelope_diagnostic( + BASELINE_ENVELOPE_MISMATCH_CODE, + "Runtime snapshot is not bound to the configured libvirt realization envelope.", + ) + ] + return diagnostics + def validate(plan: ProvisioningPlan) -> list[Diagnostic]: """Validate a provisioning plan with the default libvirt provisioner.""" @@ -186,6 +237,26 @@ def _has_error(diagnostics: list[Diagnostic]) -> bool: return any(diag.is_error for diag in diagnostics) +def _snapshot_has_state(snapshot: RuntimeSnapshot) -> bool: + return any( + ( + snapshot.entries, + snapshot.orchestration_results, + snapshot.orchestration_history, + snapshot.evaluation_results, + snapshot.evaluation_history, + snapshot.participant_episode_results, + snapshot.participant_episode_history, + snapshot.participant_behavior_history, + snapshot.shared_state_records, + snapshot.shared_state_history, + snapshot.joint_action_records, + snapshot.time_management_contexts, + snapshot.realization_provenance, + ) + ) + + def _invalid_plan_diagnostic() -> Diagnostic: return Diagnostic( code=INVALID_PLAN_CODE, @@ -225,3 +296,13 @@ def _driver_confirmation_diagnostic(address: str, *, code: str) -> Diagnostic: message=f"Libvirt driver did not confirm {action} for '{address}'.", severity=Severity.ERROR, ) + + +def _envelope_diagnostic(code: str, message: str) -> Diagnostic: + return Diagnostic( + code=code, + domain=_DOMAIN, + address="runtime.libvirt.realization-envelope", + message=message, + severity=Severity.ERROR, + ) diff --git a/implementations/python/packages/aces_backend_libvirt/target.py b/implementations/python/packages/aces_backend_libvirt/target.py index 9e659ace2..c7cca003f 100644 --- a/implementations/python/packages/aces_backend_libvirt/target.py +++ b/implementations/python/packages/aces_backend_libvirt/target.py @@ -9,6 +9,7 @@ from .driver import LibvirtDriver from .drivers.libvirt import LibvirtDeploymentDriver +from .envelopes import LibvirtDriverMode from .manifest import LIBVIRT_BACKEND_NAME, create_libvirt_manifest from .participant_runtime import LibvirtParticipantRuntime from .provisioner import LibvirtProvisioner @@ -22,12 +23,18 @@ def create_libvirt_components( ) -> RuntimeTargetComponents: """Build libvirt backend components for a manifest.""" - deployment_driver = driver if driver is not None else LibvirtDeploymentDriver(**_driver_config(config)) if manifest.has_orchestrator or manifest.has_evaluator: raise ValueError("libvirt backend does not support orchestrator or evaluator.") + mode = _selected_driver_mode(config, driver=driver) + _validate_manifest_mode(manifest, mode) + deployment_driver = driver if driver is not None else LibvirtDeploymentDriver(**_driver_config(config)) participant_runtime = LibvirtParticipantRuntime() if manifest.has_participant_runtime else None return RuntimeTargetComponents( - provisioner=LibvirtProvisioner(deployment_driver, provisioner_capabilities=manifest.provisioner), + provisioner=LibvirtProvisioner( + deployment_driver, + provisioner_capabilities=manifest.provisioner, + realization_envelope=manifest.realization_envelope.identity, + ), participant_runtime=participant_runtime, ) @@ -35,8 +42,11 @@ def create_libvirt_components( def create_libvirt_target(**config: Any) -> RuntimeTarget: """Return a fully configured libvirt provisioning target.""" - manifest = create_libvirt_manifest(**config) - components = create_libvirt_components(manifest=manifest, **config) + _validate_config_keys(config) + mode = _selected_driver_mode(config, driver=config.get("driver")) + normalized = {**config, "driver_mode": mode.value} + manifest = create_libvirt_manifest(**normalized) + components = create_libvirt_components(manifest=manifest, **normalized) return RuntimeTarget( name=LIBVIRT_BACKEND_NAME, manifest=manifest, @@ -66,3 +76,52 @@ def _driver_config(config: dict[str, Any]) -> dict[str, Any]: if "uri" in config and "connection_uri" not in driver_config: driver_config["connection_uri"] = config["uri"] return driver_config + + +_CONFIG_KEYS = { + "connection", + "connection_uri", + "connector", + "driver", + "driver_mode", + "name_prefix", + "participant_runtime", + "seed_builder", + "uri", + "workspace", +} + + +def _validate_config_keys(config: dict[str, Any]) -> None: + unknown = sorted(set(config) - _CONFIG_KEYS) + if unknown: + raise ValueError("unknown libvirt target configuration: " + ", ".join(unknown)) + + +def _selected_driver_mode(config: dict[str, Any], *, driver: object | None) -> LibvirtDriverMode: + raw_mode = config.get("driver_mode") + declared = getattr(driver, "driver_mode", None) + if driver is not None and raw_mode is None and declared is None: + raise ValueError("driver_mode is required when injecting a libvirt driver that declares no mode") + mode = LibvirtDriverMode(raw_mode or declared or LibvirtDriverMode.GENERIC.value) + if declared is not None and declared != mode.value: + raise ValueError(f"injected driver mode '{declared}' does not match driver_mode '{mode.value}'") + return mode + + +def _validate_manifest_mode(manifest: BackendManifest, mode: LibvirtDriverMode) -> None: + envelope = manifest.realization_envelope + if envelope is None or envelope.configuration.mode != mode.value: + raise ValueError("libvirt manifest realization envelope does not match driver_mode") + configuration = envelope.configuration + expected = { + "supported_node_types": frozenset(configuration.supported_node_types), + "supported_os_families": frozenset(configuration.supported_os_families), + "supported_content_types": frozenset(configuration.supported_content_types), + "supported_account_features": frozenset(configuration.supported_account_features), + "supports_accounts": bool(configuration.supported_account_features), + "supports_acls": configuration.supports_acls, + } + actual = {field: getattr(manifest.provisioner, field) for field in expected} + if actual != expected: + raise ValueError("libvirt manifest capabilities do not match realization envelope") diff --git a/implementations/python/packages/aces_backend_libvirt/techvault_native.py b/implementations/python/packages/aces_backend_libvirt/techvault_native.py index 6b2485ba3..707d54d64 100644 --- a/implementations/python/packages/aces_backend_libvirt/techvault_native.py +++ b/implementations/python/packages/aces_backend_libvirt/techvault_native.py @@ -18,7 +18,7 @@ from contextlib import suppress from dataclasses import dataclass, field from pathlib import Path -from typing import Protocol, cast +from typing import ClassVar, Protocol, cast from aces_contracts.diagnostics import Diagnostic, Severity @@ -67,6 +67,8 @@ def undefine(self) -> None: ... class TechVaultNativeLibvirtDriver: """Realize TechVault domains directly as libvirt/QEMU appliances.""" + driver_mode: ClassVar[str] = "techvault-appliance" + state_dir: Path connection: object | None = None connection_uri: str = _DEFAULT_CONNECTION_URI diff --git a/implementations/python/packages/aces_backend_protocols/backend_manifest.py b/implementations/python/packages/aces_backend_protocols/backend_manifest.py new file mode 100644 index 000000000..e471fe8bf --- /dev/null +++ b/implementations/python/packages/aces_backend_protocols/backend_manifest.py @@ -0,0 +1,219 @@ +"""Aggregate backend manifest contract built from domain capability types.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TypedDict, TypeVar, Unpack + +from aces_contracts.apparatus import ApparatusIdentity, ConceptBinding, RealizationSupportDeclaration +from aces_contracts.manifest_authority import validate_backend_supported_contract_versions +from aces_contracts.realization_envelope import BackendRealizationEnvelopeModel + +from .capabilities import ( + BackendCapabilitySet, + EvaluatorCapabilities, + ObservationCapabilities, + OrchestratorCapabilities, + ParticipantRuntimeCapabilities, + ProvisionerCapabilities, +) + + +@dataclass(frozen=True) +class BackendCompatibility: + """Backend compatibility claims against processor surfaces.""" + + processors: frozenset[str] = frozenset() + + def __post_init__(self) -> None: + if not self.processors: + raise ValueError("BackendCompatibility.processors must not be empty") + if any(not processor.strip() for processor in self.processors): + raise ValueError("BackendCompatibility.processors must not contain empty strings") + + +class _BackendManifestOptions(TypedDict, total=False): + identity: ApparatusIdentity | None + supported_contract_versions: frozenset[str] + compatibility: BackendCompatibility | None + realization_support: tuple[RealizationSupportDeclaration, ...] + concept_bindings: tuple[ConceptBinding, ...] + constraints: dict[str, str] | None + capabilities: BackendCapabilitySet | None + name: str | None + version: str + compatible_processors: frozenset[str] + provisioner: ProvisionerCapabilities | None + orchestrator: OrchestratorCapabilities | None + evaluator: EvaluatorCapabilities | None + participant_runtime: ParticipantRuntimeCapabilities | None + observation: ObservationCapabilities | None + realization_envelope: BackendRealizationEnvelopeModel | None + + +@dataclass(frozen=True, init=False) +class BackendManifest: + """Complete runtime target capability declaration.""" + + identity: ApparatusIdentity + supported_contract_versions: frozenset[str] + compatibility: BackendCompatibility + realization_support: tuple[RealizationSupportDeclaration, ...] + concept_bindings: tuple[ConceptBinding, ...] + constraints: dict[str, str] + capabilities: BackendCapabilitySet + realization_envelope: BackendRealizationEnvelopeModel | None + + def __init__(self, **options: Unpack[_BackendManifestOptions]) -> None: + _reject_unknown_options(options) + identity = _resolve_identity(options) + compatibility = _resolve_compatibility(options) + capabilities = _resolve_capabilities(options) + supported_contract_versions = _validate_supported_contract_versions(options) + realization_envelope = options.get("realization_envelope") + _validate_realization_envelope_contract(supported_contract_versions, realization_envelope) + realization_support = _require_non_empty_tuple(options.get("realization_support", ()), "realization_support") + concept_bindings = _require_non_empty_tuple(options.get("concept_bindings", ()), "concept_bindings") + object.__setattr__(self, "identity", identity) + object.__setattr__(self, "supported_contract_versions", supported_contract_versions) + object.__setattr__(self, "compatibility", compatibility) + object.__setattr__(self, "realization_support", realization_support) + object.__setattr__(self, "concept_bindings", concept_bindings) + constraints = options.get("constraints") + object.__setattr__(self, "constraints", {} if constraints is None else dict(constraints)) + object.__setattr__(self, "capabilities", capabilities) + object.__setattr__(self, "realization_envelope", realization_envelope) + + @property + def name(self) -> str: + return self.identity.name + + @property + def version(self) -> str: + return self.identity.version + + @property + def compatible_processors(self) -> frozenset[str]: + return self.compatibility.processors + + @property + def provisioner(self) -> ProvisionerCapabilities: + return self.capabilities.provisioner + + @property + def orchestrator(self) -> OrchestratorCapabilities | None: + return self.capabilities.orchestrator + + @property + def evaluator(self) -> EvaluatorCapabilities | None: + return self.capabilities.evaluator + + @property + def participant_runtime(self) -> ParticipantRuntimeCapabilities | None: + return self.capabilities.participant_runtime + + @property + def observation(self) -> ObservationCapabilities | None: + return self.capabilities.observation + + @property + def has_orchestrator(self) -> bool: + return self.orchestrator is not None + + @property + def has_evaluator(self) -> bool: + return self.evaluator is not None + + @property + def has_participant_runtime(self) -> bool: + return self.participant_runtime is not None + + @property + def has_observation(self) -> bool: + return self.observation is not None + + @property + def evaluator_supported_sections(self) -> frozenset[str]: + return self.evaluator.supported_sections if self.evaluator is not None else frozenset() + + @property + def supports_scoring(self) -> bool: + return self.evaluator.supports_scoring if self.evaluator is not None else False + + @property + def supports_objectives(self) -> bool: + return self.evaluator.supports_objectives if self.evaluator is not None else False + + +def _reject_unknown_options(options: _BackendManifestOptions) -> None: + unknown = set(options) - set(_BackendManifestOptions.__annotations__) + if unknown: + names = ", ".join(sorted(unknown)) + raise TypeError(f"BackendManifest got unexpected keyword argument(s): {names}") + + +def _resolve_identity(options: _BackendManifestOptions) -> ApparatusIdentity: + identity = options.get("identity") + if identity is not None: + return identity + name = options.get("name") + if name is None: + raise ValueError("BackendManifest requires either identity or name.") + return ApparatusIdentity(name=name, version=options.get("version", "0.0.0+unknown")) + + +def _resolve_compatibility(options: _BackendManifestOptions) -> BackendCompatibility: + compatibility = options.get("compatibility") + if compatibility is not None: + return compatibility + return BackendCompatibility(processors=frozenset(options.get("compatible_processors", frozenset()))) + + +def _resolve_capabilities(options: _BackendManifestOptions) -> BackendCapabilitySet: + capabilities = options.get("capabilities") + if capabilities is not None: + return capabilities + provisioner = options.get("provisioner") + if provisioner is None: + raise ValueError("BackendManifest requires either capabilities or provisioner.") + return BackendCapabilitySet( + provisioner=provisioner, + orchestrator=options.get("orchestrator"), + evaluator=options.get("evaluator"), + participant_runtime=options.get("participant_runtime"), + observation=options.get("observation"), + ) + + +def _validate_supported_contract_versions(options: _BackendManifestOptions) -> frozenset[str]: + versions = frozenset(options.get("supported_contract_versions", frozenset())) + if not versions: + raise ValueError("BackendManifest.supported_contract_versions must not be empty") + if any(not contract_id.strip() for contract_id in versions): + raise ValueError("BackendManifest.supported_contract_versions must not contain empty strings") + validate_backend_supported_contract_versions(versions) + return versions + + +def _validate_realization_envelope_contract( + supported_contract_versions: frozenset[str], + realization_envelope: BackendRealizationEnvelopeModel | None, +) -> None: + envelope_contract_declared = "realization-envelope-v1" in supported_contract_versions + if realization_envelope is not None and not envelope_contract_declared: + raise ValueError("realization_envelope requires realization-envelope-v1 support") + if envelope_contract_declared and realization_envelope is None: + raise ValueError("realization-envelope-v1 support requires realization_envelope") + + +_T = TypeVar("_T") + + +def _require_non_empty_tuple(values: tuple[_T, ...], field_name: str) -> tuple[_T, ...]: + result = tuple(values) + if not result: + raise ValueError(f"BackendManifest.{field_name} must not be empty") + return result + + +__all__ = ["BackendCompatibility", "BackendManifest"] diff --git a/implementations/python/packages/aces_backend_protocols/capabilities.py b/implementations/python/packages/aces_backend_protocols/capabilities.py index fe6638056..812c51d5a 100644 --- a/implementations/python/packages/aces_backend_protocols/capabilities.py +++ b/implementations/python/packages/aces_backend_protocols/capabilities.py @@ -3,16 +3,15 @@ from __future__ import annotations from dataclasses import dataclass, field +from typing import TYPE_CHECKING -from aces_contracts.apparatus import ( - ApparatusIdentity, - ConceptBinding, - RealizationSupportDeclaration, -) from aces_contracts.controlled_vocabularies import validate_controlled_vocabulary_scope_values from aces_contracts.manifest_authority import validate_backend_supported_contract_versions from aces_contracts.vocabulary import ParticipantFeatureSupportLevel, WorkflowFeature, WorkflowStatePredicateFeature +if TYPE_CHECKING: + from .backend_manifest import BackendManifest + PARTICIPANT_RUNTIME_ROLE_SCOPE = "capabilities.participant_runtime.supported_participant_roles" PARTICIPANT_RUNTIME_BEHAVIOR_FEATURE_SCOPE = "capabilities.participant_runtime.supported_behavior_features" PARTICIPANT_RUNTIME_INTERACTION_FEATURE_SCOPE = "capabilities.participant_runtime.supported_interaction_features" @@ -411,149 +410,6 @@ class BackendCapabilitySet: observation: ObservationCapabilities | None = None -@dataclass(frozen=True) -class BackendCompatibility: - """Backend compatibility claims against processor surfaces.""" - - processors: frozenset[str] = frozenset() - - def __post_init__(self) -> None: - if not self.processors: - raise ValueError("BackendCompatibility.processors must not be empty") - if any(not processor.strip() for processor in self.processors): - raise ValueError("BackendCompatibility.processors must not contain empty strings") - - -@dataclass(frozen=True, init=False) -class BackendManifest: - """Complete runtime target capability declaration.""" - - identity: ApparatusIdentity - supported_contract_versions: frozenset[str] - compatibility: BackendCompatibility - realization_support: tuple[RealizationSupportDeclaration, ...] - concept_bindings: tuple[ConceptBinding, ...] - constraints: dict[str, str] - capabilities: BackendCapabilitySet - - def __init__( - self, - *, - identity: ApparatusIdentity | None = None, - supported_contract_versions: frozenset[str] = frozenset(), - compatibility: BackendCompatibility | None = None, - realization_support: tuple[RealizationSupportDeclaration, ...] = (), - concept_bindings: tuple[ConceptBinding, ...] = (), - constraints: dict[str, str] | None = None, - capabilities: BackendCapabilitySet | None = None, - name: str | None = None, - version: str = "0.0.0+unknown", - compatible_processors: frozenset[str] = frozenset(), - provisioner: ProvisionerCapabilities | None = None, - orchestrator: OrchestratorCapabilities | None = None, - evaluator: EvaluatorCapabilities | None = None, - participant_runtime: ParticipantRuntimeCapabilities | None = None, - observation: ObservationCapabilities | None = None, - ) -> None: - if identity is None: - if name is None: - raise ValueError("BackendManifest requires either identity or name.") - identity = ApparatusIdentity(name=name, version=version) - if compatibility is None: - compatibility = BackendCompatibility(processors=frozenset(compatible_processors)) - if capabilities is None: - if provisioner is None: - raise ValueError("BackendManifest requires either capabilities or provisioner.") - capabilities = BackendCapabilitySet( - provisioner=provisioner, - orchestrator=orchestrator, - evaluator=evaluator, - participant_runtime=participant_runtime, - observation=observation, - ) - supported_contract_versions = frozenset(supported_contract_versions) - if not supported_contract_versions: - raise ValueError("BackendManifest.supported_contract_versions must not be empty") - if any(not version.strip() for version in supported_contract_versions): - raise ValueError("BackendManifest.supported_contract_versions must not contain empty strings") - validate_backend_supported_contract_versions(supported_contract_versions) - realization_support = tuple(realization_support) - if not realization_support: - raise ValueError("BackendManifest.realization_support must not be empty") - concept_bindings = tuple(concept_bindings) - if not concept_bindings: - raise ValueError("BackendManifest.concept_bindings must not be empty") - object.__setattr__(self, "identity", identity) - object.__setattr__(self, "supported_contract_versions", supported_contract_versions) - object.__setattr__(self, "compatibility", compatibility) - object.__setattr__(self, "realization_support", realization_support) - object.__setattr__(self, "concept_bindings", concept_bindings) - object.__setattr__(self, "constraints", {} if constraints is None else dict(constraints)) - object.__setattr__(self, "capabilities", capabilities) - - @property - def name(self) -> str: - return self.identity.name - - @property - def version(self) -> str: - return self.identity.version - - @property - def compatible_processors(self) -> frozenset[str]: - return self.compatibility.processors - - @property - def provisioner(self) -> ProvisionerCapabilities: - return self.capabilities.provisioner - - @property - def orchestrator(self) -> OrchestratorCapabilities | None: - return self.capabilities.orchestrator - - @property - def evaluator(self) -> EvaluatorCapabilities | None: - return self.capabilities.evaluator - - @property - def participant_runtime(self) -> ParticipantRuntimeCapabilities | None: - return self.capabilities.participant_runtime - - @property - def observation(self) -> ObservationCapabilities | None: - return self.capabilities.observation - - @property - def has_orchestrator(self) -> bool: - return self.orchestrator is not None - - @property - def has_evaluator(self) -> bool: - return self.evaluator is not None - - @property - def has_participant_runtime(self) -> bool: - return self.participant_runtime is not None - - @property - def has_observation(self) -> bool: - return self.observation is not None - - @property - def evaluator_supported_sections(self) -> frozenset[str]: - if self.evaluator is None: - return frozenset() - return self.evaluator.supported_sections - - @property - def supports_scoring(self) -> bool: - return self.evaluator.supports_scoring if self.evaluator is not None else False - - @property - def supports_objectives(self) -> bool: - return self.evaluator.supports_objectives if self.evaluator is not None else False - - def participant_runtime_capability_contract_gaps(manifest: BackendManifest) -> tuple[str, ...]: """Return missing contract surfaces for declared standard API-405 claims. @@ -598,3 +454,13 @@ def observation_capability_contract_gaps(manifest: BackendManifest) -> tuple[str if missing: gaps.append(f"capabilities.observation missing required contracts: {', '.join(missing)}") return tuple(gaps) + + +def __getattr__(name: str) -> object: + """Preserve the historical manifest imports without a circular import.""" + + if name in {"BackendCompatibility", "BackendManifest"}: + from . import backend_manifest + + return getattr(backend_manifest, name) + raise AttributeError(name) diff --git a/implementations/python/packages/aces_backend_protocols/manifest.py b/implementations/python/packages/aces_backend_protocols/manifest.py index 77839ff68..5fc75ca5e 100644 --- a/implementations/python/packages/aces_backend_protocols/manifest.py +++ b/implementations/python/packages/aces_backend_protocols/manifest.py @@ -45,6 +45,11 @@ def backend_manifest_v2_model(manifest: BackendManifest) -> BackendManifestV2Mod ) for declaration in manifest.realization_support ], + realization_envelope=( + manifest.realization_envelope.identity.model_dump(mode="json") + if manifest.realization_envelope is not None + else None + ), concept_bindings=[ ConceptBindingEntryModel(scope=binding.scope, family=binding.family) for binding in manifest.concept_bindings @@ -136,4 +141,7 @@ def backend_manifest_v2_model(manifest: BackendManifest) -> BackendManifestV2Mod def backend_manifest_payload(manifest: BackendManifest) -> dict[str, Any]: """Render a backend manifest as JSON-ready data.""" - return backend_manifest_v2_model(manifest).model_dump(mode="json") + payload = backend_manifest_v2_model(manifest).model_dump(mode="json") + if payload.get("realization_envelope") is None: + payload.pop("realization_envelope", None) + return payload diff --git a/implementations/python/packages/aces_backend_stubs/stubs.py b/implementations/python/packages/aces_backend_stubs/stubs.py index 1365aa895..0d30fbeec 100644 --- a/implementations/python/packages/aces_backend_stubs/stubs.py +++ b/implementations/python/packages/aces_backend_stubs/stubs.py @@ -29,7 +29,7 @@ from aces_contracts.vocabulary import RealizationSupportMode from aces_runtime.registry import RuntimeTarget, RuntimeTargetComponents -REFERENCE_BACKEND_SUPPORTED_CONTRACT_VERSIONS = BACKEND_SUPPORTED_CONTRACT_IDS +REFERENCE_BACKEND_SUPPORTED_CONTRACT_VERSIONS = frozenset(BACKEND_SUPPORTED_CONTRACT_IDS) - {"realization-envelope-v1"} REFERENCE_PARTICIPANT_ROLES = frozenset( PARTICIPANT_RUNTIME_CAPABILITY_REQUIRED_CONTRACTS[PARTICIPANT_RUNTIME_ROLE_SCOPE] ) diff --git a/implementations/python/packages/aces_conformance/conformance.py b/implementations/python/packages/aces_conformance/conformance.py index 558e7b3d0..1901a3991 100644 --- a/implementations/python/packages/aces_conformance/conformance.py +++ b/implementations/python/packages/aces_conformance/conformance.py @@ -95,14 +95,14 @@ ) _RUN_REFINEMENT_CONCERN_KINDS = frozenset({"capture-window", "measurement-channel"}) -# Default reference scenario the target-conformance live probe drives when the +# Default reference scenario the target-conformance adapter probe drives when the # caller supplies none. Backend-neutral: a single generic linux vm node. # # Issue #663 makes this a *default*, not a universal assumption. A fixed-topology # emulation or bounded simulation backend that cannot realize this generic # scenario supplies one it can realize via -# ``run_target_conformance(reference_scenario=...)``; the live probe then holds -# it to full realization of *that* scenario. This is a temporary runner-parameter +# ``run_target_conformance(reference_scenario=...)``; the target probe then holds +# it to adapter-level accounting for *that* scenario. This runner-parameter # bridge — superseded by the realizability-envelope design (#667) and the # scenario/envelope subsumption relation (#668), which will let the probe # negotiate an in-envelope witness instead of carrying a default at all. @@ -1076,7 +1076,7 @@ def profile_for_manifest(manifest: BackendManifest) -> BackendCapabilityProfile: A backend that declares orchestrator, evaluator, AND participant runtime capabilities is treated as ``FULL_REMOTE_CONTROL_PLANE``, so the default ``run_target_conformance`` path automatically - validates the live target against the participant-episode contract + validates the active target against the participant-episode contract family (RUN-311). Backends that only declare orchestrator/evaluator fall back to ``ORCHESTRATION_EVALUATION``; orchestrator-only declarations fall back to ``ORCHESTRATION_CAPABLE``; provisioner-only @@ -1184,14 +1184,14 @@ def run_target_conformance( if any(diag.code == "conformance.profile-load-failed" for diag in fixture_report.diagnostics): # The published profile is the contract set we are supposed to validate # against. With no profile loaded we must NOT mutate the backend via - # ``_live_target_cases`` — there is nothing to validate against. The + # ``_target_adapter_cases`` — there is nothing to validate against. The # fixture report already carries the structured profile-load # diagnostic and ``passed=False``; surface it as the conformance # result for this target. return fixture_report if _to_known_profile(effective_profile) is None: # Target conformance enforces runtime-surface gates (which capability - # roles the target must implement, which live probes to run). Those + # roles the target must implement, which target probes to run). Those # gates depend on knowing the profile's runtime surface contract. For # an unknown profile id we have NO runtime-surface authority — letting # the run continue would silently certify a target that's missing every @@ -1256,9 +1256,9 @@ def run_target_conformance( + "; ".join(claim_gaps), ) ) - live_cases = _live_target_cases(target, effective_profile, reference_scenario=reference_scenario) - cases = tuple((*fixture_report.cases, *live_cases)) - passed = passed and all(case.passed for case in live_cases) + target_cases = _target_adapter_cases(target, effective_profile, reference_scenario=reference_scenario) + cases = tuple((*fixture_report.cases, *target_cases)) + passed = passed and all(case.passed for case in target_cases) return BackendConformanceReport( profile=_to_profile_id(effective_profile), passed=passed, @@ -1445,7 +1445,7 @@ def _provisioning_probe_case( ) ) return ConformanceCaseResult( - name="live-provisioning", + name="target-provisioning", contract_name="operation-status-v1", valid=True, passed=not diagnostics, @@ -1523,7 +1523,7 @@ def _live_snapshot_case(control_plane: RuntimeControlPlane) -> ConformanceCaseRe ) ) return ConformanceCaseResult( - name="live-snapshot", + name="target-snapshot", contract_name="runtime-snapshot-v1", valid=True, passed=not diagnostics, @@ -1531,27 +1531,27 @@ def _live_snapshot_case(control_plane: RuntimeControlPlane) -> ConformanceCaseRe ) -def _live_target_cases( +def _target_adapter_cases( target: RuntimeTarget, profile: BackendProfileSelector, *, reference_scenario: ScenarioInput | None = None, ) -> tuple[ConformanceCaseResult, ...]: - """Run live probes appropriate for known runtime surfaces only. + """Run target-adapter probes appropriate for known runtime surfaces only. Every known profile requires a provisioner, so target conformance always - runs a backend-neutral provisioning probe that proves real snapshot - mutation (issue #606) — provisioning-only backends included, which must not + runs a backend-neutral provisioning probe that proves adapter-driven snapshot + mutation (issue #606), not daemon or guest observation — provisioning-only backends included, which must not pass on manifest validation alone. Orchestration, evaluation, and the participant-episode probe additionally run for the richer runtime surfaces that declare those roles. For an unknown profile id we run only the - universally-safe manifest validation case and skip the live probes, since + universally-safe manifest validation case and skip the target probes, since their runtime contract is not known to this implementation. The probe drives ``reference_scenario`` when supplied, else ``_DEFAULT_CONFORMANCE_SCENARIO`` (issue #663). Whichever scenario is - selected is held to full realization (the #606 mutation guard is - unchanged); the parameter only stops the probe assuming *every* backend can + selected is held to adapter-level operation accounting (the #606 mutation + guard is unchanged); the parameter only stops the probe assuming *every* backend can realize one hard-coded scenario. """ @@ -1560,7 +1560,7 @@ def _live_target_cases( manifest_diags = _validate_payload("backend-manifest-v2", manifest_payload) cases.append( ConformanceCaseResult( - name="live-manifest", + name="target-manifest", contract_name="backend-manifest-v2", valid=True, passed=not manifest_diags, diff --git a/implementations/python/packages/aces_contracts/contracts.py b/implementations/python/packages/aces_contracts/contracts.py index cb2ebc33d..ed4254610 100644 --- a/implementations/python/packages/aces_contracts/contracts.py +++ b/implementations/python/packages/aces_contracts/contracts.py @@ -1955,9 +1955,20 @@ class PlanOperationModel(ContractModel): refresh_dependencies: list[str] = Field(default_factory=list) +class RealizationEnvelopeIdentityModel(ContractModel): + """Immutable realization-envelope identity carried across runtime contracts.""" + + contract_id: Literal["realization-envelope-v1"] = "realization-envelope-v1" + envelope_id: NonEmptyString + schema_version: Literal["realization-envelope/v1"] = "realization-envelope/v1" + digest: Annotated[str, Field(pattern=r"^sha256:[a-f0-9]{64}$")] + configuration_digest: Annotated[str, Field(pattern=r"^sha256:[a-f0-9]{64}$")] + + class ProvisioningPlanModel(ContractModel): operations: list[PlanOperationModel] = Field(default_factory=list) diagnostics: list[dict[str, Any]] = Field(default_factory=list) + realization_envelope: RealizationEnvelopeIdentityModel | None = None class OrchestrationPlanModel(ContractModel): @@ -2028,6 +2039,7 @@ class RuntimeSnapshotEnvelopeModel(ContractModel): joint_action_records: dict[str, ParticipantJointActionRecordModel] = Field(default_factory=dict) time_management_contexts: dict[str, ParticipantTimeManagementContextModel] = Field(default_factory=dict) realization_provenance: list[RealizationProvenanceEntryModel] = Field(default_factory=list) + realization_envelope: RealizationEnvelopeIdentityModel | None = None metadata: dict[str, Any] = Field(default_factory=dict) @@ -2557,6 +2569,7 @@ class BackendManifestV2Model(ContractModel): supported_contract_versions: list[NonEmptyString] = Field(min_length=1) compatibility: BackendCompatibilityModel realization_support: list[RealizationSupportDeclarationModel] = Field(min_length=1) + realization_envelope: RealizationEnvelopeIdentityModel | None = None concept_bindings: list[ConceptBindingEntryModel] = Field(min_length=1) constraints: dict[str, str] = Field(default_factory=dict) capabilities: BackendCapabilitiesV2Model @@ -2564,6 +2577,11 @@ class BackendManifestV2Model(ContractModel): @model_validator(mode="after") def _validate_unique_binding_scopes(self) -> BackendManifestV2Model: validate_backend_supported_contract_versions(self.supported_contract_versions) + envelope_contract_declared = "realization-envelope-v1" in self.supported_contract_versions + if self.realization_envelope is not None and not envelope_contract_declared: + raise ValueError("realization_envelope requires realization-envelope-v1 support") + if envelope_contract_declared and self.realization_envelope is None: + raise ValueError("realization-envelope-v1 support requires realization_envelope identity") scopes = [binding.scope for binding in self.concept_bindings] if len(scopes) != len(set(scopes)): raise ValueError("concept_bindings must not contain duplicate scopes") @@ -2579,6 +2597,33 @@ def __get_pydantic_json_schema__( json_schema = handler(core_schema) json_schema = handler.resolve_ref_schema(json_schema) json_schema["properties"]["supported_contract_versions"]["items"]["enum"] = list(BACKEND_SUPPORTED_CONTRACT_IDS) + json_schema.setdefault("allOf", []).extend( + [ + { + "if": { + "properties": {"realization_envelope": {"not": {"type": "null"}}}, + "required": ["realization_envelope"], + }, + "then": { + "properties": { + "supported_contract_versions": {"contains": {"const": "realization-envelope-v1"}} + } + }, + }, + { + "if": { + "properties": { + "supported_contract_versions": {"contains": {"const": "realization-envelope-v1"}} + }, + "required": ["supported_contract_versions"], + }, + "then": { + "properties": {"realization_envelope": {"not": {"type": "null"}}}, + "required": ["realization_envelope"], + }, + }, + ] + ) return json_schema @@ -7148,12 +7193,15 @@ def __get_pydantic_json_schema__( def schema_bundle() -> dict[str, dict[str, Any]]: """Return the repo-published JSON Schemas for external contracts.""" + from aces_contracts.realization_envelope import BackendRealizationEnvelopeModel + bundle = { "aces-semantic-invariants-v1": _aces_semantic_invariant_profile_schema_for_bundle(), "sdl-authoring-input-v1": Scenario.model_json_schema(), "instantiated-scenario-v1": InstantiatedScenario.model_json_schema(), "scenario-instantiation-request-v1": InstantiationRequestModel.model_json_schema(), "backend-manifest-v2": BackendManifestV2Model.model_json_schema(), + "realization-envelope-v1": BackendRealizationEnvelopeModel.model_json_schema(), "processor-manifest-v2": ProcessorManifestV2Model.model_json_schema(), "participant-implementation-manifest-v1": ParticipantImplementationManifestModel.model_json_schema(), "participant-implementation-provenance-v1": ParticipantImplementationProvenanceModel.model_json_schema(), @@ -7382,6 +7430,7 @@ def schema_bundle() -> dict[str, dict[str, Any]]: "ProcessorCapabilitiesV2Model", "ProvisionerCapabilitiesModel", "ProvisioningPlanModel", + "RealizationEnvelopeIdentityModel", "RawDataIntegrityModel", "RealizationProvenanceEntryModel", "RealizationSupportDeclarationModel", diff --git a/implementations/python/packages/aces_contracts/corpus.py b/implementations/python/packages/aces_contracts/corpus.py index c19d7d5aa..4783118c0 100644 --- a/implementations/python/packages/aces_contracts/corpus.py +++ b/implementations/python/packages/aces_contracts/corpus.py @@ -39,6 +39,7 @@ FIXTURES = "fixtures" CONCEPT_AUTHORITY = "concept-authority" SCHEMAS = "schemas" +REALIZATION_ENVELOPES = "realization-envelopes" def _bundled_corpus_root() -> Path | None: @@ -120,6 +121,7 @@ def corpus_family_root(family: str) -> Path: "CONCEPT_AUTHORITY", "FIXTURES", "PROFILES", + "REALIZATION_ENVELOPES", "SCHEMAS", "corpus_family_root", "corpus_root", diff --git a/implementations/python/packages/aces_contracts/manifest_authority.py b/implementations/python/packages/aces_contracts/manifest_authority.py index 7c747fd60..b49f6218c 100644 --- a/implementations/python/packages/aces_contracts/manifest_authority.py +++ b/implementations/python/packages/aces_contracts/manifest_authority.py @@ -34,6 +34,7 @@ # separate authority surfaces and do not belong in this declaration field. BACKEND_SUPPORTED_CONTRACT_IDS = ( "backend-manifest-v2", + "realization-envelope-v1", "provisioning-plan-v1", "orchestration-plan-v1", "evaluation-plan-v1", diff --git a/implementations/python/packages/aces_contracts/planning.py b/implementations/python/packages/aces_contracts/planning.py index eb3041af6..4da3e2a85 100644 --- a/implementations/python/packages/aces_contracts/planning.py +++ b/implementations/python/packages/aces_contracts/planning.py @@ -4,6 +4,7 @@ from enum import Enum from typing import Any +from aces_contracts.contracts import RealizationEnvelopeIdentityModel from aces_contracts.diagnostics import Diagnostic @@ -68,6 +69,7 @@ class ProvisioningPlan: resources: dict[str, PlannedResource] = field(default_factory=dict) operations: list[ProvisionOp] = field(default_factory=list) diagnostics: list[Diagnostic] = field(default_factory=list) + realization_envelope: RealizationEnvelopeIdentityModel | None = None @property def actionable_operations(self) -> list[ProvisionOp]: diff --git a/implementations/python/packages/aces_contracts/realization_envelope.py b/implementations/python/packages/aces_contracts/realization_envelope.py index ab43f6fb7..9297e1866 100644 --- a/implementations/python/packages/aces_contracts/realization_envelope.py +++ b/implementations/python/packages/aces_contracts/realization_envelope.py @@ -13,13 +13,10 @@ are simply not representable, which keeps membership and subsumption reducible to local structural checks and witness generation deterministic. -Schema publication (a bundled ``contracts/schemas/`` artifact with a publication -ledger) and backend-manifest carriage are downstream siblings, not this issue: -``envelope-semantics.md`` "Realization Status" lists the *schema carrier* and -*manifest evolution* separately from the *relation helper*, ADR-070 §5 defers -manifest carriage to a schema-evolution question, and the issue-668 preflight note -records "the current unpublished envelope shape". This module therefore ships the -unpublished, first-class contract shape the relation operates over. +Issue #100 publishes the expression inside a configuration-bound backend carrier +at ``contracts/schemas/realization-envelope/realization-envelope-v1.json``. The carrier +adds typed material-configuration, transformation, support, and observation +disclosures while the relation continues to operate on the same expression. """ from __future__ import annotations @@ -30,19 +27,16 @@ from pydantic import Field, model_validator -from aces_contracts.contracts import ContractModel, NonEmptyString - -# Version identity for the envelope expression. Kept local to this module rather -# than in ``versions.py`` (which is scoped to *published* external contracts): -# the envelope schema is intentionally unpublished at this stage — schema -# publication and manifest carriage are downstream siblings (module docstring). -REALIZATION_ENVELOPE_SCHEMA_VERSION = "realization-envelope/v1" +from aces_contracts.contracts import ContractModel, NonEmptyString, RealizationEnvelopeIdentityModel +from aces_contracts.versions import REALIZATION_ENVELOPE_SCHEMA_VERSION __all__ = [ + "BackendRealizationEnvelopeModel", "REALIZATION_ENVELOPE_SCHEMA_VERSION", "BooleanDomain", "Closure", "ClosureOverlay", + "ConcernDisposition", "DomainDescriptor", "EnumDomain", "EnvelopeBinding", @@ -51,12 +45,22 @@ "GovernedReferenceDomain", "NumericIntervalDomain", "NumericType", + "ObservationStrength", "Posture", "RealizationEnvelopeModel", + "RealizationEnvelopeIdentityModel", + "RealizationConcern", + "RealizationConcernDisclosureModel", + "RealizerConfigurationModel", + "IntegerBoundsModel", "RecordDomain", "WitnessPolicy", + "TransformationKind", "scalar_in_domain", "scalar_matches_numeric_type", + "realization_envelope_digest", + "realizer_configuration_digest", + "validate_backend_realization_envelope", ] # Portable envelope values are JSON scalars. ``bool`` is intentionally distinct @@ -400,3 +404,20 @@ def scalar_in_domain(value: object, descriptor: DomainDescriptor) -> bool: """ check = _SCALAR_MEMBER_CHECKS.get(type(descriptor)) return check(value, descriptor) if check is not None else False + + +# Import after the expression types are defined: the carrier embeds +# RealizationEnvelopeModel and this module preserves the original public API. +from aces_contracts.realization_envelope_carrier import ( + BackendRealizationEnvelopeModel, + ConcernDisposition, + IntegerBoundsModel, + ObservationStrength, + RealizationConcern, + RealizationConcernDisclosureModel, + RealizerConfigurationModel, + TransformationKind, + realization_envelope_digest, + realizer_configuration_digest, + validate_backend_realization_envelope, +) diff --git a/implementations/python/packages/aces_contracts/realization_envelope_carrier.py b/implementations/python/packages/aces_contracts/realization_envelope_carrier.py new file mode 100644 index 000000000..2b6d16cbd --- /dev/null +++ b/implementations/python/packages/aces_contracts/realization_envelope_carrier.py @@ -0,0 +1,310 @@ +"""Published configuration-bound backend realization-envelope carrier.""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping +from enum import Enum +from typing import Annotated, Any, Literal + +from pydantic import Field, GetJsonSchemaHandler, model_validator +from pydantic.json_schema import JsonSchemaValue +from pydantic_core import CoreSchema + +from aces_contracts.contracts import ContractModel, NonEmptyString, RealizationEnvelopeIdentityModel +from aces_contracts.realization_envelope import RealizationEnvelopeModel + +DigestString = Annotated[str, Field(pattern=r"^sha256:[a-f0-9]{64}$")] + + +class ObservationStrength(str, Enum): + """Strongest evidence a backend configuration emits for one concern.""" + + NONE = "none" + DRIVER_REPORTED = "driver-reported" + DAEMON_OBSERVED = "daemon-observed" + GUEST_OBSERVED = "guest-observed" + + +class ConcernDisposition(str, Enum): + """How the selected realizer treats a governed concern.""" + + REALIZED = "realized" + TRANSFORMED = "transformed" + DESCRIPTOR_ONLY = "descriptor-only" + UNSUPPORTED = "unsupported" + + +class RealizationConcern(str, Enum): + """Closed concern taxonomy shared by backend envelope artifacts.""" + + TOPOLOGY = "topology" + ARCHITECTURE = "architecture" + IMAGE = "image" + RESOURCE_ALLOCATION = "resource-allocation" + NETWORK = "network" + CONTENT_PLACEMENT = "content-placement" + ACCOUNT_PLACEMENT = "account-placement" + FEATURE_BINDING = "feature-binding" + ACL = "acl" + + +class TransformationKind(str, Enum): + """Portable disclosure of a material realization transformation.""" + + BOUNDED_NORMALIZATION = "bounded-normalization" + DEFAULT_SUBSTITUTION = "default-substitution" + DESCRIPTOR_SUBSTITUTION = "descriptor-substitution" + IMAGE_SUBSTITUTION = "image-substitution" + SERVICE_SYNTHESIS = "service-synthesis" + + +class IntegerBoundsModel(ContractModel): + """Closed positive integer interval used by realizer resource claims.""" + + minimum: int = Field(ge=1) + maximum: int | None = Field(default=None, ge=1) + + @model_validator(mode="after") + def _validate_bounds(self) -> IntegerBoundsModel: + if self.maximum is not None and self.maximum < self.minimum: + raise ValueError("maximum must not be less than minimum") + return self + + +class RealizerConfigurationModel(ContractModel): + """Secret-free material configuration identity for one realizer mode.""" + + mode: NonEmptyString + configuration_digest: DigestString + architecture: NonEmptyString + image_policy: NonEmptyString + network_policy: NonEmptyString + supported_node_types: list[NonEmptyString] = Field(min_length=1) + supported_os_families: list[NonEmptyString] = Field(min_length=1) + supported_content_types: list[NonEmptyString] = Field(default_factory=list) + supported_account_features: list[NonEmptyString] = Field(default_factory=list) + supports_acls: bool = False + memory_mib: IntegerBoundsModel + vcpus: IntegerBoundsModel + + @model_validator(mode="after") + def _validate_unique_terms(self) -> RealizerConfigurationModel: + for field_name in ( + "supported_node_types", + "supported_os_families", + "supported_content_types", + "supported_account_features", + ): + values = getattr(self, field_name) + if len(values) != len(set(values)): + raise ValueError(f"{field_name} must not contain duplicates") + return self + + @classmethod + def __get_pydantic_json_schema__( + cls, + core_schema: CoreSchema, + handler: GetJsonSchemaHandler, + ) -> JsonSchemaValue: + json_schema = handler.resolve_ref_schema(handler(core_schema)) + properties = json_schema.get("properties", {}) + for field_name in ( + "supported_node_types", + "supported_os_families", + "supported_content_types", + "supported_account_features", + ): + properties[field_name]["uniqueItems"] = True + return json_schema + + +class RealizationConcernDisclosureModel(ContractModel): + """Typed support, transformation, and observation claim for one concern.""" + + concern: RealizationConcern + disposition: ConcernDisposition + observation_strength: ObservationStrength + mechanism: NonEmptyString | None = None + transformations: list[TransformationKind] = Field(default_factory=list) + + @model_validator(mode="after") + def _validate_disposition(self) -> RealizationConcernDisclosureModel: + if len(self.transformations) != len(set(self.transformations)): + raise ValueError("transformations must not contain duplicates") + if self.disposition is ConcernDisposition.TRANSFORMED and not self.transformations: + raise ValueError("transformed disposition requires transformations") + if self.disposition is not ConcernDisposition.TRANSFORMED and self.transformations: + raise ValueError("transformations require transformed disposition") + if self.disposition is ConcernDisposition.UNSUPPORTED: + if self.observation_strength is not ObservationStrength.NONE or self.mechanism is not None: + raise ValueError("unsupported disposition cannot claim observation or mechanism") + elif self.observation_strength is ObservationStrength.NONE or self.mechanism is None: + raise ValueError("supported dispositions require observation and mechanism") + return self + + @classmethod + def __get_pydantic_json_schema__( + cls, + core_schema: CoreSchema, + handler: GetJsonSchemaHandler, + ) -> JsonSchemaValue: + json_schema = handler.resolve_ref_schema(handler(core_schema)) + json_schema["properties"]["transformations"]["uniqueItems"] = True + json_schema.setdefault("allOf", []).extend( + [ + { + "if": {"properties": {"disposition": {"const": "transformed"}}}, + "then": {"properties": {"transformations": {"minItems": 1}}}, + "else": {"properties": {"transformations": {"maxItems": 0}}}, + }, + { + "if": {"properties": {"disposition": {"const": "unsupported"}}}, + "then": { + "properties": { + "observation_strength": {"const": "none"}, + "mechanism": {"type": "null"}, + } + }, + "else": { + "properties": { + "observation_strength": {"not": {"const": "none"}}, + "mechanism": {"type": "string", "minLength": 1}, + } + }, + }, + ] + ) + return json_schema + + +def realizer_configuration_digest(payload: Mapping[str, Any] | RealizerConfigurationModel) -> str: + """Digest the closed, secret-free material configuration projection.""" + + material = payload.model_dump(mode="json") if isinstance(payload, RealizerConfigurationModel) else dict(payload) + material.pop("configuration_digest", None) + canonical = json.dumps(material, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8") + return "sha256:" + hashlib.sha256(canonical).hexdigest() + + +def realization_envelope_digest(payload: Mapping[str, Any] | ContractModel) -> str: + """Return the canonical digest of a published envelope, excluding its self-digest.""" + + if isinstance(payload, BackendRealizationEnvelopeModel): + material = payload.model_dump(mode="json") + elif isinstance(payload, Mapping) and {"id", "expression", "configuration", "concerns"} <= payload.keys(): + material = { + "schema_version": payload.get("schema_version", "realization-envelope/v1"), + "contract_id": payload.get("contract_id", "realization-envelope-v1"), + "id": payload["id"], + "expression": RealizationEnvelopeModel.model_validate(payload["expression"]).model_dump(mode="json"), + "configuration": RealizerConfigurationModel.model_validate(payload["configuration"]).model_dump( + mode="json" + ), + "concerns": [ + RealizationConcernDisclosureModel.model_validate(claim).model_dump(mode="json") + for claim in payload["concerns"] + ], + } + else: + material = payload.model_dump(mode="json") if isinstance(payload, ContractModel) else dict(payload) + material.pop("digest", None) + canonical = json.dumps(material, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8") + return "sha256:" + hashlib.sha256(canonical).hexdigest() + + +class BackendRealizationEnvelopeModel(ContractModel): + """Published backend carrier: shared set expression plus truthful realization claims.""" + + schema_version: Literal["realization-envelope/v1"] = "realization-envelope/v1" + contract_id: Literal["realization-envelope-v1"] = "realization-envelope-v1" + id: NonEmptyString + expression: RealizationEnvelopeModel + configuration: RealizerConfigurationModel + concerns: list[RealizationConcernDisclosureModel] = Field(min_length=1) + digest: DigestString + + @model_validator(mode="after") + def _validate_carrier(self) -> BackendRealizationEnvelopeModel: + concern_values = [claim.concern for claim in self.concerns] + if len(concern_values) != len(set(concern_values)): + raise ValueError("concerns must not contain duplicate concern values") + missing_concerns = set(RealizationConcern) - set(concern_values) + if missing_concerns: + missing = ", ".join(sorted(concern.value for concern in missing_concerns)) + raise ValueError(f"concerns must disclose every governed concern; missing: {missing}") + if self.configuration.configuration_digest != realizer_configuration_digest(self.configuration): + raise ValueError("realizer configuration digest does not match canonical content") + expected = realization_envelope_digest(self) + if self.digest != expected: + raise ValueError("realization envelope digest does not match canonical content") + return self + + @property + def identity(self) -> RealizationEnvelopeIdentityModel: + return RealizationEnvelopeIdentityModel( + contract_id=self.contract_id, + envelope_id=self.id, + schema_version=self.schema_version, + digest=self.digest, + configuration_digest=self.configuration.configuration_digest, + ) + + @classmethod + def __get_pydantic_json_schema__( + cls, + core_schema: CoreSchema, + handler: GetJsonSchemaHandler, + ) -> JsonSchemaValue: + json_schema = handler.resolve_ref_schema(handler(core_schema)) + concerns = json_schema["properties"]["concerns"] + concern_count = len(RealizationConcern) + concerns["minItems"] = concern_count + concerns["maxItems"] = concern_count + concerns["allOf"] = [ + { + "contains": { + "type": "object", + "required": ["concern"], + "properties": {"concern": {"const": concern.value}}, + }, + "minContains": 1, + "maxContains": 1, + } + for concern in RealizationConcern + ] + json_schema["x-aces-invariants"] = [ + { + "id": "realization-envelope-canonical-semantics-valid", + "description": ( + "Configuration bounds, expression references, canonical configuration and envelope digests, " + "and all cross-field realization disclosure semantics must validate together." + ), + "level": "error", + "validator": "aces_contracts.realization_envelope.validate_backend_realization_envelope", + "inputs": [{"contract_id": "realization-envelope-v1", "instance_path": "#"}], + } + ] + return json_schema + + +def validate_backend_realization_envelope(payload: Mapping[str, Any]) -> BackendRealizationEnvelopeModel: + """Apply the semantic-invariant validator named by the published schema.""" + + return BackendRealizationEnvelopeModel.model_validate(payload) + + +__all__ = [ + "BackendRealizationEnvelopeModel", + "ConcernDisposition", + "IntegerBoundsModel", + "ObservationStrength", + "RealizationConcern", + "RealizationConcernDisclosureModel", + "RealizerConfigurationModel", + "TransformationKind", + "realization_envelope_digest", + "realizer_configuration_digest", + "validate_backend_realization_envelope", +] diff --git a/implementations/python/packages/aces_contracts/runtime_state.py b/implementations/python/packages/aces_contracts/runtime_state.py index 0d7e45155..ede9bc50b 100644 --- a/implementations/python/packages/aces_contracts/runtime_state.py +++ b/implementations/python/packages/aces_contracts/runtime_state.py @@ -9,6 +9,7 @@ from aces_sdl.explicitness import ExplicitnessClass, ExplicitnessProvenance +from aces_contracts.contracts import RealizationEnvelopeIdentityModel from aces_contracts.diagnostics import Diagnostic from aces_contracts.planning import RuntimeDomain from aces_contracts.versions import OPERATION_SCHEMA_VERSION, RUNTIME_SNAPSHOT_SCHEMA_VERSION @@ -78,6 +79,7 @@ class RuntimeSnapshot: # SEM-218 invariant I5: per-concern provenance for realized realization # concerns recorded across this snapshot's result / history surfaces. realization_provenance: tuple[RealizationProvenanceEntry, ...] = () + realization_envelope: RealizationEnvelopeIdentityModel | None = None metadata: dict[str, Any] = field(default_factory=dict) def get(self, address: str) -> SnapshotEntry | None: @@ -146,6 +148,11 @@ def with_entries( "realization_provenance", self.realization_provenance, ), + realization_envelope=_identity_update( + updates, + "realization_envelope", + self.realization_envelope, + ), metadata=_mapping_update(updates, "metadata", self.metadata), ) @@ -163,6 +170,7 @@ def with_entries( "joint_action_records", "time_management_contexts", "realization_provenance", + "realization_envelope", "metadata", } @@ -212,6 +220,19 @@ def _provenance_update( return raw +def _identity_update( + updates: Mapping[str, object], + key: str, + current: RealizationEnvelopeIdentityModel | None, +) -> RealizationEnvelopeIdentityModel | None: + raw = updates.get(key) + if raw is None: + return current + if not isinstance(raw, RealizationEnvelopeIdentityModel): + raise TypeError(f"{key} must be a RealizationEnvelopeIdentityModel") + return raw + + @dataclass class ApplyResult: """Result of applying or starting a runtime plan.""" diff --git a/implementations/python/packages/aces_contracts/versions.py b/implementations/python/packages/aces_contracts/versions.py index 8f345efb9..d79113330 100644 --- a/implementations/python/packages/aces_contracts/versions.py +++ b/implementations/python/packages/aces_contracts/versions.py @@ -2,6 +2,7 @@ SCENARIO_INSTANTIATION_REQUEST_SCHEMA_VERSION = "scenario-instantiation/v1" BACKEND_MANIFEST_V2_SCHEMA_VERSION = "backend-manifest/v2" +REALIZATION_ENVELOPE_SCHEMA_VERSION = "realization-envelope/v1" PROCESSOR_MANIFEST_V2_SCHEMA_VERSION = "processor-manifest/v2" PARTICIPANT_IMPLEMENTATION_MANIFEST_V1_SCHEMA_VERSION = "participant-implementation-manifest/v1" PARTICIPANT_IMPLEMENTATION_PROVENANCE_V1_SCHEMA_VERSION = "participant-implementation-provenance/v1" diff --git a/implementations/python/packages/aces_processor/compiler.py b/implementations/python/packages/aces_processor/compiler.py index af36b1f71..faba77b37 100644 --- a/implementations/python/packages/aces_processor/compiler.py +++ b/implementations/python/packages/aces_processor/compiler.py @@ -2462,4 +2462,5 @@ def compile_runtime_model(scenario: Scenario | InstantiatedScenario) -> RuntimeM objectives=objectives, diagnostics=diagnostics, realization_requirements=_compile_realization_requirements(scenario), + realization_instance=scenario, ) diff --git a/implementations/python/packages/aces_processor/models.py b/implementations/python/packages/aces_processor/models.py index cff1962f0..8491def21 100644 --- a/implementations/python/packages/aces_processor/models.py +++ b/implementations/python/packages/aces_processor/models.py @@ -235,6 +235,7 @@ ParticipantTemporalState, ParticipantTimeDomain, ) +from aces_sdl.scenario import InstantiatedScenario from aces_sdl.semantics.workflow import ( WorkflowStepSemanticContract, ) @@ -4231,6 +4232,7 @@ class RuntimeModel: # `node_variable_refs`); it never enters the backend-facing # `resource_payload()` envelope. Consumed by the planner realization gate. realization_requirements: tuple[CompiledRealizationRequirement, ...] = () + realization_instance: InstantiatedScenario | None = None @dataclass(frozen=True) diff --git a/implementations/python/packages/aces_processor/planner.py b/implementations/python/packages/aces_processor/planner.py index 11b3f7f7d..75a8c9825 100644 --- a/implementations/python/packages/aces_processor/planner.py +++ b/implementations/python/packages/aces_processor/planner.py @@ -4,6 +4,7 @@ from aces_backend_protocols.capabilities import BackendManifest from aces_sdl.infrastructure import MINIMUM_NODE_COUNT from aces_sdl.nodes import OSFamily +from aces_sdl.realization_envelope import member from aces_sdl.value_parsing import extract_variable_name, parse_enum_or_var, parse_int_or_var from .models import ( @@ -762,6 +763,7 @@ def _build_provisioning_plan( resources: dict[str, PlannedResource], actions: dict[str, ChangeAction], deleted_entries: dict[str, SnapshotEntry], + manifest: BackendManifest, ) -> ProvisioningPlan: provisioning_resources = { address: resource for address, resource in resources.items() if resource.domain == RuntimeDomain.PROVISIONING @@ -793,7 +795,13 @@ def _build_provisioning_plan( refresh_dependencies=entry.refresh_dependencies, ) ) - return ProvisioningPlan(resources=provisioning_resources, operations=ops) + return ProvisioningPlan( + resources=provisioning_resources, + operations=ops, + realization_envelope=( + manifest.realization_envelope.identity if manifest.realization_envelope is not None else None + ), + ) def _build_orchestration_plan( @@ -893,15 +901,21 @@ def plan( snapshot = snapshot or RuntimeSnapshot() resources = _collect_resources(model) + envelope_diagnostics = ( + list(member(model.realization_instance, manifest.realization_envelope.expression).diagnostics) + if manifest.realization_envelope is not None and model.realization_instance is not None + else [] + ) diagnostics = [ *model.diagnostics, *_validate_manifest(model, manifest), *realization_support_diagnostics(model.realization_requirements, manifest), + *envelope_diagnostics, *_ordering_cycle_diagnostics(resources), ] actions, deleted_entries = _build_operations(resources, snapshot) - provisioning = _build_provisioning_plan(resources, actions, deleted_entries) + provisioning = _build_provisioning_plan(resources, actions, deleted_entries, manifest) orchestration = _build_orchestration_plan(resources, actions, deleted_entries) evaluation = _build_evaluation_plan(resources, actions, deleted_entries) diff --git a/implementations/python/packages/aces_reference_backend/manifest.py b/implementations/python/packages/aces_reference_backend/manifest.py index 2ddf3b76e..a0986d9a8 100644 --- a/implementations/python/packages/aces_reference_backend/manifest.py +++ b/implementations/python/packages/aces_reference_backend/manifest.py @@ -34,6 +34,9 @@ from aces_contracts.vocabulary import RealizationSupportMode REFERENCE_BACKEND_NAME = "reference-emulation" +REFERENCE_BACKEND_SUPPORTED_CONTRACT_VERSIONS = frozenset( + contract_id for contract_id in BACKEND_SUPPORTED_CONTRACT_IDS if contract_id != "realization-envelope-v1" +) _PARTICIPANT_ROLES = frozenset(PARTICIPANT_RUNTIME_CAPABILITY_REQUIRED_CONTRACTS[PARTICIPANT_RUNTIME_ROLE_SCOPE]) _PARTICIPANT_BEHAVIOR_FEATURES = frozenset( @@ -205,7 +208,7 @@ def create_reference_backend_manifest(**config) -> BackendManifest: return BackendManifest( name=REFERENCE_BACKEND_NAME, version=_current_backend_version(), - supported_contract_versions=frozenset(BACKEND_SUPPORTED_CONTRACT_IDS), + supported_contract_versions=REFERENCE_BACKEND_SUPPORTED_CONTRACT_VERSIONS, compatible_processors=frozenset({"aces-reference-processor"}), concept_bindings=_concept_bindings(), realization_support=_realization_support(), diff --git a/implementations/python/packages/aces_runtime/control_plane_api_models.py b/implementations/python/packages/aces_runtime/control_plane_api_models.py index b3a69de2d..74a821ab7 100644 --- a/implementations/python/packages/aces_runtime/control_plane_api_models.py +++ b/implementations/python/packages/aces_runtime/control_plane_api_models.py @@ -76,6 +76,7 @@ def _provisioning_plan(model: ProvisioningPlanModel) -> ProvisioningPlan: for op in model.operations ], diagnostics=[_diagnostic_from_mapping(payload) for payload in model.diagnostics], + realization_envelope=model.realization_envelope, ) @@ -158,6 +159,11 @@ def _snapshot_model(envelope: RuntimeSnapshotEnvelope) -> RuntimeSnapshotEnvelop "shared_state_history": dict(snapshot.shared_state_history), "joint_action_records": dict(snapshot.joint_action_records), "time_management_contexts": dict(snapshot.time_management_contexts), + "realization_envelope": ( + snapshot.realization_envelope.model_dump(mode="json") + if snapshot.realization_envelope is not None + else None + ), "metadata": dict(snapshot.metadata), } ) diff --git a/implementations/python/packages/aces_runtime/control_plane_store.py b/implementations/python/packages/aces_runtime/control_plane_store.py index bb650f513..82699bfd2 100644 --- a/implementations/python/packages/aces_runtime/control_plane_store.py +++ b/implementations/python/packages/aces_runtime/control_plane_store.py @@ -10,6 +10,7 @@ from pathlib import Path from typing import Any, Protocol +from aces_contracts.contracts import RealizationEnvelopeIdentityModel from aces_contracts.diagnostics import Diagnostic, Severity from aces_contracts.planning import RuntimeDomain from aces_contracts.runtime_state import ( @@ -115,6 +116,9 @@ def _snapshot_payload(snapshot: RuntimeSnapshot) -> dict[str, Any]: } for entry in snapshot.realization_provenance ], + "realization_envelope": ( + snapshot.realization_envelope.model_dump(mode="json") if snapshot.realization_envelope is not None else None + ), "metadata": dict(snapshot.metadata), } @@ -171,6 +175,11 @@ def _snapshot_from_payload(payload: dict[str, Any]) -> RuntimeSnapshot: for item in payload.get("realization_provenance", []) if isinstance(item, dict) ), + realization_envelope=( + RealizationEnvelopeIdentityModel.model_validate(payload["realization_envelope"]) + if payload.get("realization_envelope") is not None + else None + ), metadata=dict(payload.get("metadata", {})), ) diff --git a/implementations/python/packages/aces_runtime/manager.py b/implementations/python/packages/aces_runtime/manager.py index 010a66a26..19feae0ce 100644 --- a/implementations/python/packages/aces_runtime/manager.py +++ b/implementations/python/packages/aces_runtime/manager.py @@ -436,6 +436,7 @@ def destroy(self) -> ApplyResult: ) for address in _delete_order(provisioning_entries) ], + realization_envelope=working_snapshot.realization_envelope, ) provision_result = _call_backend_apply( self._target.provisioner.apply, diff --git a/implementations/python/pyproject.toml b/implementations/python/pyproject.toml index d29b34c2a..4c8fd94ae 100644 --- a/implementations/python/pyproject.toml +++ b/implementations/python/pyproject.toml @@ -156,3 +156,4 @@ ignore = [ "packages/aces_runtime/control_plane_api.py" = ["B008"] # fastapi Depends() pattern "packages/aces_runtime/control_plane.py" = ["S112"] # intentional exception suppression "packages/aces_sdl/module_registry.py" = ["S310", "S202"] # explicit OCI URL fetch and tarball extract +"packages/aces_contracts/realization_envelope.py" = ["E402"] # late import breaks a carrier dependency cycle diff --git a/implementations/python/tests/libvirt_conformance_fixtures.py b/implementations/python/tests/libvirt_conformance_fixtures.py index 6f01cbd0e..9a5aed60d 100644 --- a/implementations/python/tests/libvirt_conformance_fixtures.py +++ b/implementations/python/tests/libvirt_conformance_fixtures.py @@ -41,6 +41,8 @@ class RecordedOp: class RecordingLibvirtDriver: """Hermetic libvirt driver that confirms realization and records ops.""" + driver_mode = "generic" + recorded_ops: list[RecordedOp] = field(default_factory=list) _realized: set[str] = field(default_factory=set) diff --git a/implementations/python/tests/libvirt_participant_fixtures.py b/implementations/python/tests/libvirt_participant_fixtures.py index 91f195fec..c597e6a14 100644 --- a/implementations/python/tests/libvirt_participant_fixtures.py +++ b/implementations/python/tests/libvirt_participant_fixtures.py @@ -40,6 +40,7 @@ class NullLibvirtDriver: + driver_mode = "generic" """No-op libvirt driver for structural tests that never call realize().""" def realize(self, *, networks, domains): diff --git a/implementations/python/tests/test_authority_boundary.py b/implementations/python/tests/test_authority_boundary.py index eb98c99f0..bb160c6a9 100644 --- a/implementations/python/tests/test_authority_boundary.py +++ b/implementations/python/tests/test_authority_boundary.py @@ -55,6 +55,10 @@ root: contracts/profiles/ authority: capability profile declarations family: profiles + - id: normative_realization_envelopes + root: contracts/realization-envelopes/ + authority: configuration-bound backend realization envelope declarations + family: realization-envelopes - id: normative_concept_authority root: contracts/concept-authority/ authority: concept-family and controlled-vocabulary authority artifacts @@ -103,7 +107,8 @@ # ADR-009 is immutable; the drift guard requires every authority-root token # (specs/, contracts/schemas/, contracts/fixtures/, contracts/profiles/, -# contracts/concept-authority/) to appear in its text. ADR-019 is the +# contracts/realization-envelopes/, contracts/concept-authority/) to appear +# across the immutable ADR pair. ADR-019 is the # canonical-seam decision — required by adr_refs and by the drift guard. _GOOD_ADR_AUTHORITY = """# ADR-009: Normative Artifact Authority and Repository Structure @@ -144,6 +149,7 @@ "contracts/schemas", "contracts/fixtures", "contracts/profiles", + "contracts/realization-envelopes", "contracts/concept-authority", ) _GOOD_NON_NORMATIVE_ROOTS: tuple[str, ...] = ( @@ -175,7 +181,7 @@ def _seed_repo( *, policy_body: str | None = _GOOD_POLICY, adr_body: str | None = _GOOD_ADR_AUTHORITY, - seam_body: str | None = "ADR-019 stub mentioning concept-authority.\n", + seam_body: str | None = "ADR-019 stub mentioning concept-authority and realization-envelopes.\n", contracts_readme: str | None = _GOOD_CONTRACTS_README, specs_readme: str | None = _GOOD_SPECS_README, authority_roots: tuple[str, ...] = _GOOD_AUTHORITY_ROOTS, @@ -200,7 +206,8 @@ def _seed_repo( if seam_body is not None: # ADR-019 governs the manifest YAML; the drift guard unions it with # ADR-009. The seed writes a stub that mentions `concept-authority` - # so the canonical positive case clears the drift check. + # and `realization-envelopes` so the canonical positive case clears + # the drift check. seam_relative = "docs/decisions/adrs/adr-019-normative-authority-boundary-manifest.md" seam_path = tmp_path / seam_relative seam_path.parent.mkdir(parents=True, exist_ok=True) @@ -290,14 +297,15 @@ def test_policy_value_is_normative_artifact_authority() -> None: assert POLICY_VALUE == "normative-artifact-authority" -def test_canonical_authority_root_ids_cover_all_five_families() -> None: - # The five families ADR-009 names: prose, schemas, fixtures, profiles, - # concept-authority. A YAML that drops any of these fails the gate. +def test_canonical_authority_root_ids_cover_every_family() -> None: + # ADR-009 and ADR-019 name these authority families. A YAML that drops any + # of them fails the gate. assert set(CANONICAL_AUTHORITY_ROOT_IDS) == { "normative_prose", "normative_schemas", "normative_fixtures", "normative_profiles", + "normative_realization_envelopes", "normative_concept_authority", } @@ -518,7 +526,7 @@ def test_authority_root_must_not_traverse_parent(tmp_path: Path) -> None: # --------------------------------------------------------------------------- # -# Canonical family coverage -- ADR-009's five families MUST each have an # +# Canonical family coverage -- every governed family MUST have an entry. # # entry. Reordering or renaming an id is a drift. # # --------------------------------------------------------------------------- # diff --git a/implementations/python/tests/test_backend_manifest.py b/implementations/python/tests/test_backend_manifest.py index d1ff822f1..8d5038c7f 100644 --- a/implementations/python/tests/test_backend_manifest.py +++ b/implementations/python/tests/test_backend_manifest.py @@ -38,7 +38,9 @@ FIXTURES_ROOT = Path(__file__).resolve().parents[3] / "contracts" / "fixtures" V2_VALID_DIR = FIXTURES_ROOT / "backend-manifest" / "backend-manifest-v2" / "valid" V2_INVALID_DIR = FIXTURES_ROOT / "backend-manifest" / "backend-manifest-v2" / "invalid" -EXPECTED_SUPPORTED_CONTRACT_VERSIONS_V2 = list(BACKEND_SUPPORTED_CONTRACT_IDS) +EXPECTED_SUPPORTED_CONTRACT_VERSIONS_V2 = [ + contract_id for contract_id in BACKEND_SUPPORTED_CONTRACT_IDS if contract_id != "realization-envelope-v1" +] def test_backend_workflow_vocab_enum_values(): @@ -71,6 +73,11 @@ def test_backend_manifest_rejects_hollow_defaults(): ) +def test_backend_manifest_rejects_unknown_keywords(): + with pytest.raises(TypeError, match="unexpected keyword argument.*unknown"): + BackendManifest(unknown=True) + + def test_provisioner_capabilities_reject_hollow_declaration(): with pytest.raises(ValueError): ProvisionerCapabilities( @@ -103,7 +110,9 @@ def test_backend_manifest_v2_roundtrip_from_stub_manifest(): WorkflowFeature.TIMEOUTS, ] assert model.realization_support[0].support_mode.value == "constrained" - assert model.model_dump(mode="json") == payload + roundtrip = model.model_dump(mode="json") + roundtrip.pop("realization_envelope", None) + assert roundtrip == payload def test_backend_manifest_v2_declares_participant_capability_dimensions(): diff --git a/implementations/python/tests/test_libvirt_backend_envelopes.py b/implementations/python/tests/test_libvirt_backend_envelopes.py new file mode 100644 index 000000000..73a1ac9c8 --- /dev/null +++ b/implementations/python/tests/test_libvirt_backend_envelopes.py @@ -0,0 +1,148 @@ +"""Configuration-bound libvirt realization-envelope selection (ASR-519).""" + +from __future__ import annotations + +from dataclasses import replace +from textwrap import dedent + +import pytest +from aces_backend_libvirt.manifest import create_libvirt_manifest +from aces_backend_libvirt.provisioner import LibvirtProvisioner +from aces_backend_libvirt.target import create_libvirt_components, create_libvirt_target +from aces_backend_libvirt.techvault_native import TechVaultNativeLibvirtDriver +from aces_backend_protocols.manifest import backend_manifest_payload +from aces_contracts.realization_envelope import BackendRealizationEnvelopeModel, realization_envelope_digest +from aces_processor.reference import run_reference_processor +from aces_sdl.parser import parse_sdl +from libvirt_conformance_fixtures import RecordingLibvirtDriver + + +def test_default_libvirt_manifest_selects_generic_envelope(): + manifest = create_libvirt_manifest() + + assert manifest.realization_envelope is not None + assert manifest.realization_envelope.id == "libvirt-qemu.generic.v1" + assert manifest.realization_envelope.configuration.mode == "generic" + assert "realization-envelope-v1" in manifest.supported_contract_versions + assert backend_manifest_payload(manifest)["realization_envelope"] == ( + manifest.realization_envelope.identity.model_dump(mode="json") + ) + + +def test_operational_config_does_not_change_generic_material_identity(): + default = create_libvirt_manifest().realization_envelope + configured = create_libvirt_manifest(connection_uri="qemu:///session", name_prefix="example").realization_envelope + + assert default is not None and configured is not None + assert default.identity == configured.identity + + +def test_injected_driver_requires_explicit_mode(): + class DriverWithoutMode: + pass + + with pytest.raises(ValueError, match="driver_mode is required"): + create_libvirt_target(driver=DriverWithoutMode()) + + +def test_injected_generic_driver_selects_generic_envelope(): + target = create_libvirt_target(driver=RecordingLibvirtDriver(), driver_mode="generic") + + assert target.manifest.realization_envelope is not None + assert target.manifest.realization_envelope.configuration.mode == "generic" + + +def test_techvault_driver_selects_narrow_appliance_envelope(tmp_path): + driver = TechVaultNativeLibvirtDriver(state_dir=tmp_path) + target = create_libvirt_target(driver=driver, driver_mode="techvault-appliance") + envelope = target.manifest.realization_envelope + + assert envelope is not None + assert envelope.id == "libvirt-qemu.techvault-appliance.v1" + assert envelope.configuration.mode == "techvault-appliance" + assert target.manifest.provisioner.supported_os_families == {"linux"} + assert not target.manifest.provisioner.supported_content_types + assert not target.manifest.provisioner.supports_accounts + assert not target.manifest.provisioner.supports_acls + + +def test_driver_and_declared_mode_must_match(tmp_path): + driver = TechVaultNativeLibvirtDriver(state_dir=tmp_path) + + with pytest.raises(ValueError, match="does not match driver_mode"): + create_libvirt_target(driver=driver, driver_mode="generic") + + +def test_direct_provisioner_construction_binds_techvault_driver_mode(tmp_path): + driver = TechVaultNativeLibvirtDriver(state_dir=tmp_path) + generic = create_libvirt_manifest(driver_mode="generic") + + with pytest.raises(ValueError, match="capabilities do not match driver mode"): + LibvirtProvisioner( + driver, + provisioner_capabilities=generic.provisioner, + realization_envelope=generic.realization_envelope.identity, + ) + + +def test_unknown_libvirt_configuration_fails_closed(): + with pytest.raises(ValueError, match="unknown libvirt target configuration"): + create_libvirt_target(unrecognized=True) + + +def test_manifest_broader_than_selected_envelope_fails_before_driver_io(): + manifest = create_libvirt_manifest(driver_mode="generic") + broader_provisioner = replace( + manifest.provisioner, + supported_content_types=manifest.provisioner.supported_content_types | {"dataset"}, + ) + broader_manifest = replace( + manifest, + capabilities=replace(manifest.capabilities, provisioner=broader_provisioner), + ) + driver = RecordingLibvirtDriver() + + with pytest.raises(ValueError, match="capabilities do not match realization envelope"): + create_libvirt_components( + manifest=broader_manifest, + driver=driver, + driver_mode="generic", + ) + + assert not driver.recorded_ops + + +def _scenario(): + return parse_sdl( + dedent( + """ + name: envelope-test + nodes: + vm: + type: vm + os: linux + resources: {ram: 1 gib, cpu: 1} + """ + ) + ) + + +def test_planner_carries_selected_envelope_identity_to_provisioning_plan(): + manifest = create_libvirt_manifest() + + result = run_reference_processor(_scenario(), manifest) + + assert result.execution_plan.provisioning.realization_envelope == manifest.realization_envelope.identity + + +def test_planner_uses_shared_membership_relation_for_selected_envelope(): + manifest = create_libvirt_manifest() + payload = manifest.realization_envelope.model_dump(mode="json") + payload["expression"]["domains"] = {"name": {"kind": "exact", "value": "different-scenario"}} + payload["expression"]["bindings"] = [{"path": "name", "scope": "scenario", "posture": "exact", "domain": "name"}] + payload["digest"] = realization_envelope_digest(payload) + restricted = BackendRealizationEnvelopeModel.model_validate(payload) + + result = run_reference_processor(_scenario(), replace(manifest, realization_envelope=restricted)) + + assert "realization-envelope.membership.domain-mismatch" in {diag.code for diag in result.diagnostics} diff --git a/implementations/python/tests/test_libvirt_backend_manifest.py b/implementations/python/tests/test_libvirt_backend_manifest.py index 3fac9ace1..5a837f32e 100644 --- a/implementations/python/tests/test_libvirt_backend_manifest.py +++ b/implementations/python/tests/test_libvirt_backend_manifest.py @@ -30,6 +30,7 @@ def test_libvirt_manifest_declares_only_provisioning_contract_surface(): assert manifest.supported_contract_versions == frozenset( { "backend-manifest-v2", + "realization-envelope-v1", "operation-receipt-v1", "operation-status-v1", "provisioning-plan-v1", @@ -44,14 +45,12 @@ def test_libvirt_manifest_supports_vm_domains_and_switch_networks(): assert manifest.provisioner.supported_node_types == frozenset({"switch", "vm"}) -def test_libvirt_manifest_declares_full_content_and_account_realization(): - """Issue #603: cloud-init realizes the full governed content/account vocabulary.""" +def test_libvirt_manifest_narrows_unproven_content_and_account_realization(): + """ASR-519: only terms with a concrete generic-driver mechanism are claimed.""" provisioner = create_libvirt_manifest().provisioner - assert provisioner.supported_os_families == frozenset({"linux", "windows", "macos", "freebsd", "other"}) - assert provisioner.supported_content_types == frozenset({"file", "dataset", "directory"}) - assert provisioner.supported_account_features == frozenset( - {"groups", "mail", "spn", "shell", "home", "disabled", "auth_method"} - ) + assert provisioner.supported_os_families == frozenset({"linux"}) + assert provisioner.supported_content_types == frozenset({"file"}) + assert provisioner.supported_account_features == frozenset({"groups", "shell", "home", "disabled", "auth_method"}) assert provisioner.supports_accounts is True assert provisioner.supports_acls is True diff --git a/implementations/python/tests/test_libvirt_backend_manifest_publication.py b/implementations/python/tests/test_libvirt_backend_manifest_publication.py index bd7611e23..3c6459cad 100644 --- a/implementations/python/tests/test_libvirt_backend_manifest_publication.py +++ b/implementations/python/tests/test_libvirt_backend_manifest_publication.py @@ -87,8 +87,8 @@ def test_libvirt_target_passes_provisioning_only_conformance(): assert not report.unsupported_contract_gaps assert not report.unsupported_capability_gaps - live_manifest = next((case for case in report.cases if case.name == "live-manifest"), None) - assert live_manifest is not None, "conformance must run the live-manifest validation case" + live_manifest = next((case for case in report.cases if case.name == "target-manifest"), None) + assert live_manifest is not None, "conformance must run the target-manifest validation case" assert live_manifest.passed, [diag.message for diag in live_manifest.diagnostics] @@ -122,8 +122,8 @@ def test_realization_support_is_not_hollow(): ) -def test_manifest_declares_full_realization_envelope(): - """AC3: libvirt declares — and realizes via cloud-init — the full governed vocabulary.""" +def test_manifest_declares_only_the_configuration_bound_realization_envelope(): + """ASR-519: descriptor-only and unobserved terms are absent from coarse claims.""" manifest = create_libvirt_manifest() provisioner = manifest.provisioner declared_kinds = { @@ -134,10 +134,8 @@ def test_manifest_declares_full_realization_envelope(): assert "content-type" in declared_kinds assert "account-feature" in declared_kinds - assert provisioner.supported_content_types == frozenset({"file", "dataset", "directory"}) - assert provisioner.supported_account_features == frozenset( - {"groups", "mail", "spn", "shell", "home", "disabled", "auth_method"} - ) + assert provisioner.supported_content_types == frozenset({"file"}) + assert provisioner.supported_account_features == frozenset({"groups", "shell", "home", "disabled", "auth_method"}) assert provisioner.supports_accounts is True assert provisioner.supports_acls is True - assert "macos" in provisioner.supported_os_families + assert provisioner.supported_os_families == frozenset({"linux"}) diff --git a/implementations/python/tests/test_libvirt_backend_provisioner.py b/implementations/python/tests/test_libvirt_backend_provisioner.py index f953e6d11..cc6d1c33a 100644 --- a/implementations/python/tests/test_libvirt_backend_provisioner.py +++ b/implementations/python/tests/test_libvirt_backend_provisioner.py @@ -4,6 +4,8 @@ from aces_backend_libvirt import LibvirtProvisioner from aces_backend_libvirt.driver import DomainHandle, DriverResult, NetworkHandle +from aces_backend_libvirt.envelopes import load_libvirt_realization_envelope +from aces_contracts.contracts import RealizationEnvelopeIdentityModel from aces_contracts.planning import ( ChangeAction, EvaluationPlan, @@ -88,6 +90,7 @@ def _plan(*resources: PlannedResource, action: ChangeAction = ChangeAction.CREAT ) for resource in resources ], + realization_envelope=load_libvirt_realization_envelope("generic").identity, ) @@ -119,12 +122,66 @@ def test_apply_reconciles_snapshot_and_drives_libvirt_driver_for_create(): assert result.snapshot.entries["provision.node.web"].status == "applied" assert result.snapshot.entries["provision.node.web"].payload["os_family"] == "linux" assert driver.realize_calls + assert result.snapshot.realization_envelope == plan.realization_envelope domains = driver.realize_calls[0]["domains"] networks = driver.realize_calls[0]["networks"] assert [spec.address for spec in domains] == ["provision.node.web"] assert [spec.address for spec in networks] == ["provision.network.lan"] +def test_apply_rejects_missing_envelope_identity_before_driver_io(): + driver = _RecordingDriver() + plan = ProvisioningPlan(operations=_plan(_node_resource()).operations) + baseline = RuntimeSnapshot() + + result = LibvirtProvisioner(driver).apply(plan, baseline) + + assert result.success is False + assert result.snapshot is baseline + assert [diag.code for diag in result.diagnostics] == ["libvirt-backend.realization-envelope.missing"] + assert not driver.realize_calls + + +def test_apply_rejects_mismatched_envelope_identity_before_driver_io(): + driver = _RecordingDriver() + plan = _plan(_node_resource()) + wrong = RealizationEnvelopeIdentityModel( + **{**plan.realization_envelope.model_dump(), "digest": "sha256:" + "f" * 64} # type: ignore[union-attr] + ) + plan = ProvisioningPlan( + resources=plan.resources, + operations=plan.operations, + realization_envelope=wrong, + ) + baseline = RuntimeSnapshot() + + result = LibvirtProvisioner(driver).apply(plan, baseline) + + assert result.success is False + assert result.snapshot is baseline + assert [diag.code for diag in result.diagnostics] == ["libvirt-backend.realization-envelope.mismatch"] + assert not driver.realize_calls + + +def test_apply_rejects_snapshot_bound_to_another_envelope_before_driver_io(): + driver = _RecordingDriver() + plan = _plan(_node_resource()) + wrong = RealizationEnvelopeIdentityModel( + **{**plan.realization_envelope.model_dump(), "configuration_digest": "sha256:" + "e" * 64} # type: ignore[union-attr] + ) + baseline = RuntimeSnapshot( + entries={"existing": SnapshotEntry("existing", RuntimeDomain.PROVISIONING, "node", {})}, + realization_envelope=wrong, + ) + + result = LibvirtProvisioner(driver).apply(plan, baseline) + + assert result.success is False + assert result.snapshot is baseline + assert [diag.code for diag in result.diagnostics] == ["libvirt-backend.realization-envelope.baseline-mismatch"] + assert not driver.realize_calls + + def _account_resource() -> PlannedResource: return PlannedResource( address="provision.account.admin", @@ -219,6 +276,7 @@ def test_apply_realizes_target_domain_when_only_a_placement_changes(): payload=account.payload, ), ], + realization_envelope=load_libvirt_realization_envelope("generic").identity, ) result = LibvirtProvisioner(driver).apply(plan, RuntimeSnapshot()) @@ -240,7 +298,8 @@ def test_apply_delete_removes_snapshot_entry_and_drives_destroy(): resource_type="node", payload={}, ) - } + }, + realization_envelope=load_libvirt_realization_envelope("generic").identity, ) plan = ProvisioningPlan( operations=[ @@ -250,7 +309,8 @@ def test_apply_delete_removes_snapshot_entry_and_drives_destroy(): resource_type="node", payload={}, ) - ] + ], + realization_envelope=load_libvirt_realization_envelope("generic").identity, ) result = LibvirtProvisioner(driver).apply(plan, snapshot) @@ -273,7 +333,8 @@ def test_apply_delete_of_already_absent_entry_is_idempotent_success(): resource_type="node", payload={}, ) - ] + ], + realization_envelope=load_libvirt_realization_envelope("generic").identity, ) result = LibvirtProvisioner(driver).apply(plan, RuntimeSnapshot()) @@ -333,6 +394,7 @@ def test_apply_validates_operation_payloads_not_only_resources(): payload={"name": "gw", "node_type": "router", "os_family": "linux", "spec": {}}, ) ], + realization_envelope=load_libvirt_realization_envelope("generic").identity, ) result = LibvirtProvisioner(driver).apply(plan, snapshot) @@ -374,7 +436,8 @@ def destroy(self, *, networks, domains): resource_type="node", payload={}, ) - } + }, + realization_envelope=load_libvirt_realization_envelope("generic").identity, ) plan = ProvisioningPlan( operations=[ @@ -384,7 +447,8 @@ def destroy(self, *, networks, domains): resource_type="node", payload={}, ) - ] + ], + realization_envelope=load_libvirt_realization_envelope("generic").identity, ) result = LibvirtProvisioner(_SilentDestroyDriver()).apply(plan, snapshot) diff --git a/implementations/python/tests/test_libvirt_backend_realization.py b/implementations/python/tests/test_libvirt_backend_realization.py index 42b236fee..d0bfbf72f 100644 --- a/implementations/python/tests/test_libvirt_backend_realization.py +++ b/implementations/python/tests/test_libvirt_backend_realization.py @@ -478,9 +478,9 @@ def test_out_of_envelope_content_type_fails_closed(): assert _domain(realization).cloud_init.write_files == () -def test_governed_vocabulary_realizes_without_envelope_error(): - # The full issue #603 governed vocabulary (all content types + account features) - # is in-envelope and must realize without any capability-envelope diagnostic. +def test_descriptor_only_vocabulary_is_rejected_by_the_narrowed_envelope(): + # ASR-519: dataset/directory descriptors and mail/SPN descriptors are not + # allowed to inherit a stronger generic realization claim. account = _resource( "account-placement", "provision.account.admin", @@ -518,7 +518,12 @@ def test_governed_vocabulary_realizes_without_envelope_error(): realization = interpret_provisioning_plan(_plan(_node(), account, file_content, dir_content, dataset_content)) envelope_codes = [d.code for d in realization.diagnostics if "unsupported-" in d.code] - assert envelope_codes == [] + assert envelope_codes == [ + "libvirt-backend.realization.unsupported-account-feature", + "libvirt-backend.realization.unsupported-account-feature", + "libvirt-backend.realization.unsupported-content-type", + "libvirt-backend.realization.unsupported-content-type", + ] def test_account_feature_outside_narrowed_envelope_fails_closed(): diff --git a/implementations/python/tests/test_libvirt_backend_registry.py b/implementations/python/tests/test_libvirt_backend_registry.py index ca5584aa1..2bd631ea9 100644 --- a/implementations/python/tests/test_libvirt_backend_registry.py +++ b/implementations/python/tests/test_libvirt_backend_registry.py @@ -15,6 +15,8 @@ class _NoopDriver: + driver_mode = "generic" + def realize(self, *, networks, domains): from aces_backend_libvirt.driver import DriverResult diff --git a/implementations/python/tests/test_libvirt_backend_techvault_integration.py b/implementations/python/tests/test_libvirt_backend_techvault_integration.py index c2a1905d2..759ffc704 100644 --- a/implementations/python/tests/test_libvirt_backend_techvault_integration.py +++ b/implementations/python/tests/test_libvirt_backend_techvault_integration.py @@ -22,6 +22,8 @@ class _RecordingLibvirtDriver: + driver_mode = "generic" + def __init__(self) -> None: self.realize_calls: list[dict[str, object]] = [] self.destroy_calls: list[dict[str, object]] = [] diff --git a/implementations/python/tests/test_libvirt_conformance.py b/implementations/python/tests/test_libvirt_conformance.py index d827a224a..dafd55150 100644 --- a/implementations/python/tests/test_libvirt_conformance.py +++ b/implementations/python/tests/test_libvirt_conformance.py @@ -1,4 +1,4 @@ -"""Issue #606: libvirt backend conformance (fixture + live target). +"""Issue #606: libvirt backend conformance (fixture + target adapter). Acceptance bar: @@ -6,13 +6,12 @@ ``unsupported-capability-claim`` / ``unsupported-contract-declaration`` diagnostics (covered by ``test_backend_conformance_cli.py`` / ``run_fixture_suite`` -- asserted green here for the libvirt-relevant profile). -2. ``run_target_conformance`` against the libvirt target passes a real +2. ``run_target_conformance`` against the libvirt target passes a target *provisioning probe* and asserts *snapshot mutation* -- not manifest / - contract-surface only. The probe drives ``RuntimeControlPlane`` and proves - the snapshot gained provisioning entries. + contract-surface only. This is adapter evidence, not daemon or guest proof. 3. A conformance report is captured and committed (drift-guarded here). -The live probe runs daemon-free through an injected ``RecordingLibvirtDriver`` +The target probe runs daemon-free through an injected ``RecordingLibvirtDriver`` that confirms realization, so the real ``LibvirtProvisioner`` path is exercised without a libvirt/QEMU daemon. """ @@ -106,7 +105,7 @@ def test_provisioning_only_fixture_suite_has_no_unsupported_diagnostics(): # --------------------------------------------------------------------------- -# AC2: live provisioning probe + real snapshot mutation +# AC2: target provisioning probe + snapshot mutation # --------------------------------------------------------------------------- @@ -121,9 +120,9 @@ def test_provisioning_only_conformance_runs_live_provisioning_probe(): case_names = {case.name for case in report.cases} # Not manifest/contract-surface only: the probe must actually provision and # validate a mutated snapshot. - assert {"live-manifest", "live-provisioning", "live-snapshot"} <= case_names + assert {"target-manifest", "target-provisioning", "target-snapshot"} <= case_names for case in report.cases: - if case.name in {"live-manifest", "live-provisioning", "live-snapshot"}: + if case.name in {"target-manifest", "target-provisioning", "target-snapshot"}: assert case.passed, [diag.message for diag in case.diagnostics] @@ -150,17 +149,17 @@ def test_libvirt_provisioning_mutates_snapshot(): def test_provisioning_only_conformance_requires_confirmed_realization(): - """A driver that does not confirm realization must fail the live probe. + """A driver that does not confirm realization must fail the target probe. Guards the backend-neutral anti-pattern: provisioning-only conformance must - not pass on ``live-manifest`` alone, and must not accept an empty snapshot. + not pass on ``target-manifest`` alone, and must not accept an empty snapshot. """ report = run_target_conformance(create_libvirt_target(driver=NullLibvirtDriver())) assert report.passed is False - live_provisioning = next((case for case in report.cases if case.name == "live-provisioning"), None) - assert live_provisioning is not None, "provisioning-only conformance must run a live-provisioning probe" + live_provisioning = next((case for case in report.cases if case.name == "target-provisioning"), None) + assert live_provisioning is not None, "provisioning-only conformance must run a target-provisioning probe" assert live_provisioning.passed is False diff --git a/implementations/python/tests/test_libvirt_evidence_run.py b/implementations/python/tests/test_libvirt_evidence_run.py index 66fa57c03..c2b7969bb 100644 --- a/implementations/python/tests/test_libvirt_evidence_run.py +++ b/implementations/python/tests/test_libvirt_evidence_run.py @@ -283,7 +283,7 @@ def test_validator_flags_participant_boundary_exposure(tmp_path): # --- native-live mode ---------------------------------------------------------- -def test_native_live_reference_scenario_realizes_content_plane(tmp_path): +def test_native_live_reference_scenario_discloses_unrealized_content_plane(tmp_path): report = run_libvirt_evidence_run( scenario_path=_REFERENCE_SCENARIO, project_dir=tmp_path, @@ -292,21 +292,17 @@ def test_native_live_reference_scenario_realizes_content_plane(tmp_path): driver_factory=_native_driver_factory(tmp_path), probe=_Probe(), ) - # Issue #603: the libvirt backend now realizes the reference scenario's content and - # account placements through cloud-init, so native-live realizes the provisioning - # substrate. The disclosure remains honest: only the orchestration/evaluation - # planes (outside a provisioning-only target) are still surfaced as unrealized — - # no content/account provisioning capability is disclosed as unrealized anymore. - assert report.passed, report.render() + # ASR-519: the TechVault appliance driver does not consume the generic cloud-init + # content/account surfaces. A native domain must not hide that gap. + assert report.passed is False artifact = report.artifact assert artifact is not None assert validate_libvirt_evidence_run_artifact(artifact) == [] provenance = artifact["backend"]["realization_provenance"] - assert provenance["substrate_realized"] is True + assert provenance["substrate_realized"] is False unrealized = artifact["realized_topology"]["unrealized_capabilities"] assert unrealized, "orchestration/evaluation planes must still be disclosed, not faked" - assert all(cap.split(".", 1)[0] in {"orchestrator", "evaluator", "evaluation"} for cap in unrealized), unrealized - assert not any("content" in cap.lower() or "account" in cap.lower() for cap in unrealized) + assert any("content" in cap.lower() or "account" in cap.lower() for cap in unrealized) def test_native_live_realizes_substrate_for_provisionable_scenario(tmp_path): diff --git a/implementations/python/tests/test_libvirt_participant_runtime.py b/implementations/python/tests/test_libvirt_participant_runtime.py index eb9f561f5..71bd422eb 100644 --- a/implementations/python/tests/test_libvirt_participant_runtime.py +++ b/implementations/python/tests/test_libvirt_participant_runtime.py @@ -110,9 +110,9 @@ def test_ac2_conformance_passes_with_participant_runtime_manifest(): # pipeline actually ran end-to-end for the participant-runtime manifest: # the provisioning probe + snapshot-mutation cases must be present and green. case_names = {case.name for case in report.cases} - assert {"live-manifest", "live-provisioning", "live-snapshot"} <= case_names + assert {"target-manifest", "target-provisioning", "target-snapshot"} <= case_names for case in report.cases: - if case.name in {"live-manifest", "live-provisioning", "live-snapshot"}: + if case.name in {"target-manifest", "target-provisioning", "target-snapshot"}: assert case.passed, [diag.message for diag in case.diagnostics] diff --git a/implementations/python/tests/test_realization_envelope_contract.py b/implementations/python/tests/test_realization_envelope_contract.py new file mode 100644 index 000000000..d9ef15478 --- /dev/null +++ b/implementations/python/tests/test_realization_envelope_contract.py @@ -0,0 +1,316 @@ +"""Published realization-envelope carrier and identity tests (ASR-519).""" + +from __future__ import annotations + +import json +from copy import deepcopy +from pathlib import Path + +import pytest +from aces_contracts.contracts import BackendManifestV2Model, ProvisioningPlanModel, RuntimeSnapshotEnvelopeModel +from aces_contracts.realization_envelope import ( + BackendRealizationEnvelopeModel, + ConcernDisposition, + ObservationStrength, + RealizationConcernDisclosureModel, + RealizationEnvelopeIdentityModel, + RealizationEnvelopeModel, + RealizerConfigurationModel, + realization_envelope_digest, + realizer_configuration_digest, + validate_backend_realization_envelope, +) +from aces_contracts.runtime_state import RuntimeSnapshot +from aces_runtime.control_plane_store import LocalControlPlaneStore +from jsonschema import Draft202012Validator +from pydantic import ValidationError + + +def _payload() -> dict[str, object]: + payload: dict[str, object] = { + "schema_version": "realization-envelope/v1", + "contract_id": "realization-envelope-v1", + "id": "libvirt-qemu.generic.v1", + "expression": { + "id": "libvirt-qemu.generic.expression.v1", + "scope": "scenario", + "domains": {}, + "bindings": [], + "closure": [], + }, + "configuration": { + "mode": "generic", + "configuration_digest": "sha256:" + "1" * 64, + "architecture": "x86_64", + "image_policy": "local-qcow2", + "network_policy": "libvirt-managed", + "supported_node_types": ["switch", "vm"], + "supported_os_families": ["linux"], + "supported_content_types": ["file"], + "supported_account_features": ["groups"], + "supports_acls": True, + "memory_mib": {"minimum": 128, "maximum": None}, + "vcpus": {"minimum": 1, "maximum": None}, + }, + "concerns": [ + { + "concern": "topology", + "disposition": "realized", + "observation_strength": "driver-reported", + "mechanism": "libvirt-domain-network", + "transformations": [], + }, + { + "concern": "resource-allocation", + "disposition": "transformed", + "observation_strength": "driver-reported", + "mechanism": "libvirt-domain-xml", + "transformations": ["bounded-normalization"], + }, + *[ + { + "concern": concern, + "disposition": "unsupported", + "observation_strength": "none", + "mechanism": None, + "transformations": [], + } + for concern in ( + "architecture", + "image", + "network", + "content-placement", + "account-placement", + "feature-binding", + "acl", + ) + ], + ], + } + payload["configuration"]["configuration_digest"] = realizer_configuration_digest(payload["configuration"]) # type: ignore[index] + payload["digest"] = realization_envelope_digest(payload) + return payload + + +def test_backend_realization_envelope_validates_its_canonical_digest(): + model = BackendRealizationEnvelopeModel.model_validate(_payload()) + + assert model.expression == RealizationEnvelopeModel.model_validate(_payload()["expression"]) + assert model.configuration == RealizerConfigurationModel( + mode="generic", + configuration_digest=realizer_configuration_digest(_payload()["configuration"]), + architecture="x86_64", + image_policy="local-qcow2", + network_policy="libvirt-managed", + supported_node_types=["switch", "vm"], + supported_os_families=["linux"], + supported_content_types=["file"], + supported_account_features=["groups"], + supports_acls=True, + memory_mib={"minimum": 128, "maximum": None}, + vcpus={"minimum": 1, "maximum": None}, + ) + assert model.concerns[0] == RealizationConcernDisclosureModel( + concern="topology", + disposition=ConcernDisposition.REALIZED, + observation_strength=ObservationStrength.DRIVER_REPORTED, + mechanism="libvirt-domain-network", + ) + assert model.identity == RealizationEnvelopeIdentityModel( + contract_id="realization-envelope-v1", + envelope_id="libvirt-qemu.generic.v1", + schema_version="realization-envelope/v1", + digest=model.digest, + configuration_digest=model.configuration.configuration_digest, + ) + + +def test_backend_realization_envelope_rejects_content_tampering(): + payload = _payload() + payload["configuration"]["mode"] = "techvault-appliance" # type: ignore[index] + + with pytest.raises(ValidationError, match="digest does not match"): + BackendRealizationEnvelopeModel.model_validate(payload) + + +def test_backend_realization_envelope_rejects_duplicate_concerns(): + payload = _payload() + payload["concerns"] = [deepcopy(payload["concerns"][0]), deepcopy(payload["concerns"][0])] # type: ignore[index] + payload["digest"] = realization_envelope_digest(payload) + + with pytest.raises(ValidationError, match="concerns must not contain duplicate concern values"): + BackendRealizationEnvelopeModel.model_validate(payload) + + +def test_backend_realization_envelope_rejects_missing_concern_disclosure(): + payload = _payload() + payload["concerns"] = payload["concerns"][:-1] # type: ignore[index] + payload["digest"] = realization_envelope_digest(payload) + + with pytest.raises(ValidationError, match="must disclose every governed concern"): + BackendRealizationEnvelopeModel.model_validate(payload) + + +def test_published_schema_enforces_expressible_realization_invariants(): + schema = BackendRealizationEnvelopeModel.model_json_schema() + validator = Draft202012Validator(schema) + + duplicate_term = _payload() + duplicate_term["configuration"]["supported_node_types"] = ["vm", "vm"] # type: ignore[index] + assert list(validator.iter_errors(duplicate_term)) + + missing_concern = _payload() + missing_concern["concerns"] = missing_concern["concerns"][:-1] # type: ignore[index] + assert list(validator.iter_errors(missing_concern)) + + incoherent_disposition = _payload() + incoherent_disposition["concerns"][0]["transformations"] = ["default-substitution"] # type: ignore[index] + assert list(validator.iter_errors(incoherent_disposition)) + + +def test_published_schema_declares_callable_canonical_semantic_validator(): + schema = BackendRealizationEnvelopeModel.model_json_schema() + invariant = schema["x-aces-invariants"][0] + + assert invariant["validator"] == "aces_contracts.realization_envelope.validate_backend_realization_envelope" + with pytest.raises(ValidationError, match="digest does not match"): + validate_backend_realization_envelope({**_payload(), "digest": "sha256:" + "f" * 64}) + + +def test_transformed_concern_requires_a_transformation(): + with pytest.raises(ValidationError, match="transformed disposition requires transformations"): + RealizationConcernDisclosureModel( + concern="resource-allocation", + disposition="transformed", + observation_strength="driver-reported", + mechanism="libvirt-domain-xml", + ) + + +def test_unsupported_concern_cannot_claim_observation_or_mechanism(): + with pytest.raises(ValidationError, match="unsupported disposition"): + RealizationConcernDisclosureModel( + concern="content-placement", + disposition="unsupported", + observation_strength="driver-reported", + mechanism="descriptor-only", + ) + + +def test_identity_rejects_non_sha256_digests(): + with pytest.raises(ValidationError): + RealizationEnvelopeIdentityModel( + contract_id="realization-envelope-v1", + envelope_id="libvirt-qemu.generic.v1", + schema_version="realization-envelope/v1", + digest="sha256:short", + configuration_digest="sha256:" + "1" * 64, + ) + + +def test_manifest_plan_and_snapshot_publish_the_same_typed_identity(): + identity = BackendRealizationEnvelopeModel.model_validate(_payload()).identity.model_dump(mode="json") + manifest = BackendManifestV2Model.model_validate( + { + "identity": {"name": "test-backend", "version": "1.0.0"}, + "supported_contract_versions": [ + "backend-manifest-v2", + "realization-envelope-v1", + "provisioning-plan-v1", + "runtime-snapshot-v1", + ], + "compatibility": {"processors": ["aces-reference-processor"]}, + "realization_support": [ + { + "domain": "runtime-realization", + "support_mode": "constrained", + "supported_constraint_kinds": ["node-type"], + "supported_exact_requirement_kinds": ["declared-capability-match"], + "disclosure_kinds": ["runtime-snapshot-v1"], + } + ], + "concept_bindings": [{"scope": "capabilities.provisioner.supported_node_types", "family": "assets"}], + "capabilities": { + "provisioner": { + "name": "test", + "supported_node_types": ["vm"], + "supported_os_families": ["linux"], + } + }, + "realization_envelope": identity, + } + ) + plan = ProvisioningPlanModel(realization_envelope=identity) + snapshot = RuntimeSnapshotEnvelopeModel(realization_envelope=identity) + + assert manifest.realization_envelope == plan.realization_envelope == snapshot.realization_envelope + + +def test_local_control_plane_store_roundtrips_envelope_identity(tmp_path): + identity = BackendRealizationEnvelopeModel.model_validate(_payload()).identity + store = LocalControlPlaneStore(tmp_path / "store") + + store.save_snapshot(RuntimeSnapshot(realization_envelope=identity)) + + assert store.load_snapshot().realization_envelope == identity + + +def test_published_realization_envelope_fixture_corpus_is_nonvacuous(): + root = ( + Path(__file__).resolve().parents[3] + / "contracts" + / "fixtures" + / "realization-envelope" + / "realization-envelope-v1" + ) + valid = sorted((root / "valid").glob("*.json")) + invalid = sorted((root / "invalid").glob("*.json")) + + assert valid and invalid + for path in valid: + BackendRealizationEnvelopeModel.model_validate(json.loads(path.read_text(encoding="utf-8"))) + for path in invalid: + with pytest.raises(ValidationError): + BackendRealizationEnvelopeModel.model_validate(json.loads(path.read_text(encoding="utf-8"))) + + +def test_backend_manifest_requires_contract_declaration_for_envelope_identity(): + identity = BackendRealizationEnvelopeModel.model_validate(_payload()).identity + base = { + "identity": {"name": "test-backend", "version": "1.0.0"}, + "supported_contract_versions": ["backend-manifest-v2"], + "compatibility": {"processors": ["aces-reference-processor"]}, + "realization_support": [ + { + "domain": "runtime-realization", + "support_mode": "constrained", + "supported_constraint_kinds": ["node-type"], + "disclosure_kinds": ["runtime-snapshot-v1"], + } + ], + "concept_bindings": [{"scope": "capabilities.provisioner.supported_node_types", "family": "assets"}], + "capabilities": { + "provisioner": { + "name": "test", + "supported_node_types": ["vm"], + "supported_os_families": ["linux"], + } + }, + "realization_envelope": identity.model_dump(mode="json"), + } + + with pytest.raises(ValidationError, match="realization-envelope-v1"): + BackendManifestV2Model.model_validate(base) + + schema_validator = Draft202012Validator(BackendManifestV2Model.model_json_schema()) + explicit_null = {**base, "realization_envelope": None} + BackendManifestV2Model.model_validate(explicit_null) + schema_validator.validate(explicit_null) + + declared_with_null = { + **explicit_null, + "supported_contract_versions": ["backend-manifest-v2", "realization-envelope-v1"], + } + with pytest.raises(ValidationError, match="requires realization_envelope identity"): + BackendManifestV2Model.model_validate(declared_with_null) + assert list(schema_validator.iter_errors(declared_with_null)) diff --git a/implementations/python/tests/test_reference_backend_manifest.py b/implementations/python/tests/test_reference_backend_manifest.py index d69f3a3a3..f228b4b13 100644 --- a/implementations/python/tests/test_reference_backend_manifest.py +++ b/implementations/python/tests/test_reference_backend_manifest.py @@ -53,3 +53,5 @@ def test_manifest_declares_only_evidence_backed_contract_ids(): stub = create_stub_manifest() assert reference.supported_contract_versions == stub.supported_contract_versions + assert "realization-envelope-v1" not in reference.supported_contract_versions + assert reference.realization_envelope is None diff --git a/implementations/python/tests/test_runtime_conformance.py b/implementations/python/tests/test_runtime_conformance.py index b1605042a..64aee3272 100644 --- a/implementations/python/tests/test_runtime_conformance.py +++ b/implementations/python/tests/test_runtime_conformance.py @@ -1033,8 +1033,8 @@ def test_run_target_conformance_surfaces_profile_load_failure(tmp_path: Path): codes = {diag.code for diag in report.diagnostics} assert "conformance.profile-load-failed" in codes case_names = {case.name for case in report.cases} - assert "live-manifest" not in case_names - assert "live-snapshot" not in case_names + assert "target-manifest" not in case_names + assert "target-snapshot" not in case_names assert "participant-initialize" not in case_names @@ -1070,8 +1070,8 @@ def test_run_target_conformance_refuses_unknown_profile_id(tmp_path: Path): codes = {diag.code for diag in report.diagnostics} assert "conformance.profile-runtime-surface-unknown" in codes case_names = {case.name for case in report.cases} - assert "live-manifest" not in case_names - assert "live-snapshot" not in case_names + assert "target-manifest" not in case_names + assert "target-snapshot" not in case_names def test_run_fixture_suite_path_traversal_id_surfaces_as_load_diagnostic(tmp_path: Path): @@ -1263,7 +1263,7 @@ def test_target_conformance_default_scenario_fails_fixed_topology_backend(): assert report.profile == BackendCapabilityProfile.PROVISIONING_ONLY assert report.passed is False - provisioning = next(case for case in report.cases if case.name == "live-provisioning") + provisioning = next(case for case in report.cases if case.name == "target-provisioning") assert provisioning.passed is False assert any(diag.code == "conformance.provisioning-failed" for diag in provisioning.diagnostics) @@ -1278,9 +1278,9 @@ def test_target_conformance_accepts_supplied_reference_scenario(): ) assert report.passed is True - provisioning = next(case for case in report.cases if case.name == "live-provisioning") + provisioning = next(case for case in report.cases if case.name == "target-provisioning") assert provisioning.passed is True - snapshot_case = next(case for case in report.cases if case.name == "live-snapshot") + snapshot_case = next(case for case in report.cases if case.name == "target-snapshot") assert snapshot_case.passed is True @@ -1294,8 +1294,8 @@ def test_supplied_reference_scenario_still_enforces_mutation_guard(): ) assert report.passed is False - provisioning = next(case for case in report.cases if case.name == "live-provisioning") - snapshot_case = next(case for case in report.cases if case.name == "live-snapshot") + provisioning = next(case for case in report.cases if case.name == "target-provisioning") + snapshot_case = next(case for case in report.cases if case.name == "target-snapshot") assert provisioning.passed is False assert snapshot_case.passed is False codes = {diag.code for case in report.cases for diag in case.diagnostics} diff --git a/specs/authority/authority-boundary.yaml b/specs/authority/authority-boundary.yaml index 6ed5b04f3..e742cc620 100644 --- a/specs/authority/authority-boundary.yaml +++ b/specs/authority/authority-boundary.yaml @@ -60,6 +60,11 @@ authority_roots: authority: capability profile declarations family: profiles + - id: normative_realization_envelopes + root: contracts/realization-envelopes/ + authority: configuration-bound backend realization envelope declarations + family: realization-envelopes + - id: normative_concept_authority root: contracts/concept-authority/ authority: concept-family and controlled-vocabulary authority artifacts diff --git a/specs/formal/realization/envelope-semantics.md b/specs/formal/realization/envelope-semantics.md index eeac71357..6c5b7d431 100644 --- a/specs/formal/realization/envelope-semantics.md +++ b/specs/formal/realization/envelope-semantics.md @@ -18,11 +18,10 @@ This note governs: - envelope subsumption; - deterministic witness generation; - negative conformance for closed envelopes; -- backend-manifest carriage constraints for future schema evolution. +- backend-manifest carriage constraints for schema evolution. Out of scope: -- publishing a concrete JSON schema for envelope expressions; - replacing `run_target_conformance(reference_scenario=...)`; - adding implementation helpers, CLI commands, APIs, persistence, or runtime behavior; @@ -40,17 +39,20 @@ contract model, fixtures, and property tests are implemented by issue #668: - `aces_sdl.realization_envelope` implements `member`, `subsumes`, `witness`, and `generate_negative_probes` as one deterministic engine over that contract. -The **schema carrier** (a published `contracts/schemas/` artifact with a -publication-ledger entry), **backend-manifest carriage** (R7), and -**target-conformance integration** (replacing the #663 `reference_scenario` -bridge) remain downstream siblings. The envelope contract is intentionally -unpublished until manifest carriage lands, so its shape can still evolve. +Issue #100 publishes the schema carrier at +`contracts/schemas/realization-envelope/realization-envelope-v1.json`, packages governed +backend instances under `contracts/realization-envelopes/`, and carries an +immutable envelope/configuration identity through backend-manifest-v2, +provisioning-plan-v1, and runtime-snapshot-v1. Backend carriers embed this same +expression and add closed realization/observation disclosures; they do not +introduce a second set language. -Until that downstream work lands: +The remaining downstream boundary is target-conformance integration: - SEM-218 remains the active exact/constrained/open realization authority; -- `backend-manifest-v2.realization_support` remains the coarse capability and - disclosure surface; +- `backend-manifest-v2.realization_support` remains the coarse capability floor, + while the selected envelope is the configuration-bound value and disclosure + authority; - `run_target_conformance(reference_scenario=...)` remains the temporary #663 bridge for fixed-topology and simulation backends. diff --git a/tools/check_authority_boundary.py b/tools/check_authority_boundary.py index f05259a4a..039d5b050 100644 --- a/tools/check_authority_boundary.py +++ b/tools/check_authority_boundary.py @@ -35,7 +35,12 @@ import yaml -from tools.policy.common import PolicyFailure, apply_exceptions, failures_to_json, load_exceptions +from tools.policy.common import ( + PolicyFailure, + apply_exceptions, + failures_to_json, + load_exceptions, +) # --------------------------------------------------------------------------- # # Canonical paths and baseline invariants. Test code imports these directly # @@ -57,7 +62,7 @@ ADR_REFS: tuple[str, ...] = (ADR_SOURCE_REF, ADR_SEAM_REF) POLICY_VALUE = "normative-artifact-authority" -# The five canonical normative-artifact families ADR-009 names, pinned to +# The canonical normative-artifact families ADR-009 and ADR-019 name, pinned to # their expected root path AND family token. The YAML may not swap a # canonical id onto a different root or relabel its family without failing # the gate — otherwise `normative_schemas` and `normative_fixtures` could be @@ -67,7 +72,14 @@ "normative_schemas": ("contracts/schemas/", "schemas"), "normative_fixtures": ("contracts/fixtures/", "fixtures"), "normative_profiles": ("contracts/profiles/", "profiles"), - "normative_concept_authority": ("contracts/concept-authority/", "concept-authority"), + "normative_realization_envelopes": ( + "contracts/realization-envelopes/", + "realization-envelopes", + ), + "normative_concept_authority": ( + "contracts/concept-authority/", + "concept-authority", + ), } CANONICAL_AUTHORITY_ROOT_IDS: tuple[str, ...] = tuple(CANONICAL_AUTHORITY_ROOT_BINDING) @@ -120,7 +132,12 @@ ) _REQUIRED_AUTHORITY_ROOT_FIELDS: tuple[str, ...] = ("id", "root", "authority", "family") _REQUIRED_NON_NORMATIVE_ROOT_FIELDS: tuple[str, ...] = ("id", "root", "note") -_REQUIRED_ARTIFACT_FAMILY_FIELDS: tuple[str, ...] = ("id", "artifact", "authority", "family") +_REQUIRED_ARTIFACT_FAMILY_FIELDS: tuple[str, ...] = ( + "id", + "artifact", + "authority", + "family", +) _REQUIRED_SCHEMA_AUTHORITY_FIELDS: tuple[str, ...] = ( "normative_root", "publication_manifest", @@ -375,10 +392,20 @@ def _check_authority_roots(raw: dict, source_path: str) -> tuple[list[dict], lis seen_ids.add(root_id) seen_roots.add(root_path) - validated.append({"id": root_id, "root": root_path, "authority": entry["authority"], "family": entry["family"]}) + validated.append( + { + "id": root_id, + "root": root_path, + "authority": entry["authority"], + "family": entry["family"], + } + ) validated_by_id = {entry["id"]: entry for entry in validated} - for canonical_id, (expected_root, expected_family) in CANONICAL_AUTHORITY_ROOT_BINDING.items(): + for canonical_id, ( + expected_root, + expected_family, + ) in CANONICAL_AUTHORITY_ROOT_BINDING.items(): if canonical_id not in seen_ids: failures.append( _fail( @@ -1398,7 +1425,11 @@ def evaluate_authority_boundary(repo_root: Path) -> list[PolicyFailure]: failures.extend(_check_schema_authority_block(raw, AUTHORITY_BOUNDARY_RELATIVE_PATH)) failures.extend( _check_normative_artifact_families( - repo_root, raw, authority_roots, non_normative_roots, AUTHORITY_BOUNDARY_RELATIVE_PATH + repo_root, + raw, + authority_roots, + non_normative_roots, + AUTHORITY_BOUNDARY_RELATIVE_PATH, ) ) diff --git a/tools/generate_contract_schemas.py b/tools/generate_contract_schemas.py index 54d6aeea9..d15a5d789 100644 --- a/tools/generate_contract_schemas.py +++ b/tools/generate_contract_schemas.py @@ -19,6 +19,8 @@ def _schema_output_path(schemas_dir: Path, name: str) -> Path: return schemas_dir / "sdl" / f"{name}.json" if name.startswith("backend-manifest-v"): return schemas_dir / "backend-manifest" / f"{name}.json" + if name.startswith("realization-envelope-v"): + return schemas_dir / "realization-envelope" / f"{name}.json" if name.startswith("processor-manifest-v"): return schemas_dir / "processor-manifest" / f"{name}.json" if name.startswith("participant-implementation-manifest-v"): From 47e72201cd3b36b6b1c45467add2246a0b4186b4 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 11 Jul 2026 16:47:27 -0700 Subject: [PATCH 09/15] feat: enforce TechVault realization disclosure (#735) * Enforce TechVault realization disclosure * Fix SonarCloud findings (cycle 1) * Fix SonarCloud findings (cycle 2) --- .../invalid/missing-digest.json | 7 + .../valid/generic.json | 3 +- .../libvirt-qemu/generic-v1.json | 3 +- .../libvirt-qemu/techvault-appliance-v1.json | 13 +- contracts/schema-publication-manifest.json | 6 +- .../realization-envelope-v1.json | 20 +- .../issue-601-techvault-live-verification.md | 269 ++---- ...ue-615-libvirt-paper-evidence-preflight.md | 48 +- ...hvault-realization-disclosure-preflight.md | 406 +++++++++ ...sr-519-techvault-realization-disclosure.md | 81 ++ examples/README.md | 1 + ...rprise-participant-evidence-loop.README.md | 49 +- .../techvault-bounded-native.sdl.yaml | 26 + .../packages/aces_backend_libvirt/driver.py | 13 + .../aces_backend_libvirt/provisioner.py | 57 +- .../aces_backend_libvirt/realization.py | 4 +- .../techvault_appliance.py | 26 +- .../techvault_concerns.py | 483 +++++++++++ .../techvault_lifecycle.py | 151 ++++ .../aces_backend_libvirt/techvault_matrix.py | 178 ++++ .../aces_backend_libvirt/techvault_native.py | 692 ++++++++-------- .../techvault_observation.py | 211 +++++ .../aces_backend_libvirt/techvault_probe.py | 137 +-- .../python/packages/aces_cli/libvirt.py | 24 +- .../realization_envelope_carrier.py | 1 + .../aces_operations/_evidence_run_artifact.py | 163 ++-- .../aces_operations/_evidence_run_types.py | 4 +- .../_evidence_run_validation.py | 306 +++++++ .../aces_operations/_techvault_cleanup.py | 57 ++ .../aces_operations/libvirt_evidence_run.py | 68 +- .../packages/aces_operations/run_artifacts.py | 9 + .../aces_operations/techvault_live.py | 392 +++++---- implementations/python/pyproject.toml | 1 + .../python/tests/test_libvirt_backend_cli.py | 97 ++- .../tests/test_libvirt_backend_envelopes.py | 7 + .../tests/test_libvirt_backend_realization.py | 23 + .../test_libvirt_backend_techvault_honesty.py | 348 ++++++++ .../test_libvirt_backend_techvault_native.py | 778 ++++++++++++++++-- ..._libvirt_backend_techvault_real_libvirt.py | 52 ++ .../python/tests/test_libvirt_evidence_run.py | 165 +++- .../test_realization_envelope_contract.py | 6 + implementations/python/uv.lock | 13 +- 42 files changed, 4304 insertions(+), 1094 deletions(-) create mode 100644 docs/decisions/issue-714-asr-519-techvault-realization-disclosure-preflight.md create mode 100644 docs/decisions/issue-714-asr-519-techvault-realization-disclosure.md create mode 100644 examples/scenarios/techvault-bounded-native.sdl.yaml create mode 100644 implementations/python/packages/aces_backend_libvirt/techvault_concerns.py create mode 100644 implementations/python/packages/aces_backend_libvirt/techvault_lifecycle.py create mode 100644 implementations/python/packages/aces_backend_libvirt/techvault_matrix.py create mode 100644 implementations/python/packages/aces_backend_libvirt/techvault_observation.py create mode 100644 implementations/python/packages/aces_operations/_techvault_cleanup.py create mode 100644 implementations/python/tests/test_libvirt_backend_techvault_honesty.py create mode 100644 implementations/python/tests/test_libvirt_backend_techvault_real_libvirt.py diff --git a/contracts/fixtures/realization-envelope/realization-envelope-v1/invalid/missing-digest.json b/contracts/fixtures/realization-envelope/realization-envelope-v1/invalid/missing-digest.json index 2a69e9d1b..c30d298a6 100644 --- a/contracts/fixtures/realization-envelope/realization-envelope-v1/invalid/missing-digest.json +++ b/contracts/fixtures/realization-envelope/realization-envelope-v1/invalid/missing-digest.json @@ -109,6 +109,13 @@ "descriptor-substitution" ] }, + { + "concern": "service", + "disposition": "unsupported", + "observation_strength": "none", + "mechanism": null, + "transformations": [] + }, { "concern": "acl", "disposition": "realized", diff --git a/contracts/fixtures/realization-envelope/realization-envelope-v1/valid/generic.json b/contracts/fixtures/realization-envelope/realization-envelope-v1/valid/generic.json index 138d2601c..dcea4855e 100644 --- a/contracts/fixtures/realization-envelope/realization-envelope-v1/valid/generic.json +++ b/contracts/fixtures/realization-envelope/realization-envelope-v1/valid/generic.json @@ -33,7 +33,8 @@ {"concern": "content-placement", "disposition": "realized", "observation_strength": "driver-reported", "mechanism": "cloud-init-seed", "transformations": []}, {"concern": "account-placement", "disposition": "transformed", "observation_strength": "driver-reported", "mechanism": "cloud-init-seed", "transformations": ["default-substitution"]}, {"concern": "feature-binding", "disposition": "transformed", "observation_strength": "driver-reported", "mechanism": "cloud-init-seed", "transformations": ["descriptor-substitution"]}, + {"concern": "service", "disposition": "unsupported", "observation_strength": "none", "mechanism": null, "transformations": []}, {"concern": "acl", "disposition": "realized", "observation_strength": "driver-reported", "mechanism": "libvirt-nwfilter", "transformations": []} ], - "digest": "sha256:4037b85c2a0e6457081dd33e8953bd98a5134865cdf0173b7e2c5a3d077a5b3a" + "digest": "sha256:eb3b54b199249da599cdd59e050612314ed16fe6039630307894ee8d8e2a466f" } diff --git a/contracts/realization-envelopes/libvirt-qemu/generic-v1.json b/contracts/realization-envelopes/libvirt-qemu/generic-v1.json index 138d2601c..dcea4855e 100644 --- a/contracts/realization-envelopes/libvirt-qemu/generic-v1.json +++ b/contracts/realization-envelopes/libvirt-qemu/generic-v1.json @@ -33,7 +33,8 @@ {"concern": "content-placement", "disposition": "realized", "observation_strength": "driver-reported", "mechanism": "cloud-init-seed", "transformations": []}, {"concern": "account-placement", "disposition": "transformed", "observation_strength": "driver-reported", "mechanism": "cloud-init-seed", "transformations": ["default-substitution"]}, {"concern": "feature-binding", "disposition": "transformed", "observation_strength": "driver-reported", "mechanism": "cloud-init-seed", "transformations": ["descriptor-substitution"]}, + {"concern": "service", "disposition": "unsupported", "observation_strength": "none", "mechanism": null, "transformations": []}, {"concern": "acl", "disposition": "realized", "observation_strength": "driver-reported", "mechanism": "libvirt-nwfilter", "transformations": []} ], - "digest": "sha256:4037b85c2a0e6457081dd33e8953bd98a5134865cdf0173b7e2c5a3d077a5b3a" + "digest": "sha256:eb3b54b199249da599cdd59e050612314ed16fe6039630307894ee8d8e2a466f" } diff --git a/contracts/realization-envelopes/libvirt-qemu/techvault-appliance-v1.json b/contracts/realization-envelopes/libvirt-qemu/techvault-appliance-v1.json index 743316b42..a08f79dcd 100644 --- a/contracts/realization-envelopes/libvirt-qemu/techvault-appliance-v1.json +++ b/contracts/realization-envelopes/libvirt-qemu/techvault-appliance-v1.json @@ -25,15 +25,16 @@ "vcpus": {"minimum": 1, "maximum": 2} }, "concerns": [ - {"concern": "topology", "disposition": "realized", "observation_strength": "driver-reported", "mechanism": "libvirt-domain-network", "transformations": []}, - {"concern": "architecture", "disposition": "transformed", "observation_strength": "driver-reported", "mechanism": "generated-x86_64-appliance", "transformations": ["image-substitution"]}, - {"concern": "image", "disposition": "transformed", "observation_strength": "driver-reported", "mechanism": "generated-initramfs-appliance", "transformations": ["image-substitution"]}, - {"concern": "resource-allocation", "disposition": "transformed", "observation_strength": "driver-reported", "mechanism": "bounded-appliance-domain", "transformations": ["bounded-normalization"]}, - {"concern": "network", "disposition": "transformed", "observation_strength": "driver-reported", "mechanism": "generated-appliance-network", "transformations": ["default-substitution"]}, + {"concern": "topology", "disposition": "realized", "observation_strength": "daemon-observed", "mechanism": "libvirt-domain-network-readback", "transformations": []}, + {"concern": "architecture", "disposition": "realized", "observation_strength": "daemon-observed", "mechanism": "libvirt-domain-xml-readback", "transformations": []}, + {"concern": "image", "disposition": "realized", "observation_strength": "daemon-observed", "mechanism": "generated-initramfs-attachment-readback", "transformations": []}, + {"concern": "resource-allocation", "disposition": "realized", "observation_strength": "daemon-observed", "mechanism": "libvirt-domain-xml-readback", "transformations": []}, + {"concern": "network", "disposition": "realized", "observation_strength": "daemon-observed", "mechanism": "libvirt-network-domain-xml-readback", "transformations": []}, {"concern": "content-placement", "disposition": "unsupported", "observation_strength": "none", "mechanism": null, "transformations": []}, {"concern": "account-placement", "disposition": "unsupported", "observation_strength": "none", "mechanism": null, "transformations": []}, {"concern": "feature-binding", "disposition": "unsupported", "observation_strength": "none", "mechanism": null, "transformations": []}, + {"concern": "service", "disposition": "unsupported", "observation_strength": "none", "mechanism": null, "transformations": []}, {"concern": "acl", "disposition": "unsupported", "observation_strength": "none", "mechanism": null, "transformations": []} ], - "digest": "sha256:37bddd6be24bcdef44241d8d77fc619113fe9d4048a61fd603977f7748593a3f" + "digest": "sha256:b0cdf9c60cf42a50782f0d417cd931462c4d630e35e7546734710219477622ed" } diff --git a/contracts/schema-publication-manifest.json b/contracts/schema-publication-manifest.json index 1f62dfed8..8a21dacdd 100644 --- a/contracts/schema-publication-manifest.json +++ b/contracts/schema-publication-manifest.json @@ -334,10 +334,10 @@ "contract_id": "realization-envelope-v1", "schema_path": "contracts/schemas/realization-envelope/realization-envelope-v1.json", "stability": "draft", - "content_hash": "269a17d2ff15c12dfccd536cf8502a1e6b929883eb41e22f898abe75f6532720", + "content_hash": "a5992e81fbc935a7ee6eab3b173a89d6e664274f0f143475e303a54d01a27374", "last_change": { - "summary": "Initial publication of the ASR-519 configuration-bound realization envelope, concern disclosure, observation strength, and canonical identity contract.", - "content_hash": "269a17d2ff15c12dfccd536cf8502a1e6b929883eb41e22f898abe75f6532720" + "summary": "Added explicit service-concern disclosure so backend envelopes cannot hide declared guest services inside another concern.", + "content_hash": "a5992e81fbc935a7ee6eab3b173a89d6e664274f0f143475e303a54d01a27374" } }, { diff --git a/contracts/schemas/realization-envelope/realization-envelope-v1.json b/contracts/schemas/realization-envelope/realization-envelope-v1.json index 77c0ee503..85db5f1c6 100644 --- a/contracts/schemas/realization-envelope/realization-envelope-v1.json +++ b/contracts/schemas/realization-envelope/realization-envelope-v1.json @@ -336,6 +336,7 @@ "content-placement", "account-placement", "feature-binding", + "service", "acl" ], "title": "RealizationConcern", @@ -878,6 +879,21 @@ "maxContains": 1, "minContains": 1 }, + { + "contains": { + "properties": { + "concern": { + "const": "service" + } + }, + "required": [ + "concern" + ], + "type": "object" + }, + "maxContains": 1, + "minContains": 1 + }, { "contains": { "properties": { @@ -897,8 +913,8 @@ "items": { "$ref": "#/$defs/RealizationConcernDisclosureModel" }, - "maxItems": 9, - "minItems": 9, + "maxItems": 10, + "minItems": 10, "title": "Concerns", "type": "array" }, diff --git a/docs/decisions/issue-601-techvault-live-verification.md b/docs/decisions/issue-601-techvault-live-verification.md index 97a3a0a9a..cf8b4f29d 100644 --- a/docs/decisions/issue-601-techvault-live-verification.md +++ b/docs/decisions/issue-601-techvault-live-verification.md @@ -1,218 +1,107 @@ -# Issue 601 TechVault Native Libvirt Verification +# Issue 601 TechVault Live Verification — Corrected Claim Boundary -This note records the live TechVault checks used for issue 601 after the -libvirt backend was corrected to prove a second independent substrate. Earlier -ACES/libvirt live-gate attempts in this PR delegated TechVault startup to APTL -Compose; those attempts are superseded and are not used as acceptance evidence. +This historical verification note is corrected by issue 714 / ASR-519. The +earlier issue-601 results must not be cited as proof that TechVault guest images, +cloud-init content, accounts, features, ACLs, named services, or SOC applications +were realized by the libvirt backend. -## Baseline APTL gate +## What the earlier run established -Command run from `/home/atomik/src/aptl` on 2026-06-27: +The runs established that ACES could create a separate libvirt/QEMU substrate +containing generated initramfs domains and networks. That is a useful substrate +check, but it is narrower than scenario realization. -```bash -uv run aptl lab validate-live --yes --run-id aces-601-libvirt-techvault-live-20260627 -``` - -Result: PASS. - -Baseline summary: - -- Scenario: `scenarios/techvault-operational.sdl.yaml` -- Selected profiles: `wazuh`, `victim`, `kali`, `enterprise`, `soc`, - `fileshare`, `dns`, `otel` -- ACES-realized nodes: 30 -- Running `aptl-*` containers after the gate: 30 -- Networks: `aptl_aptl-dmz`, `aptl_aptl-internal`, - `aptl_aptl-redteam`, `aptl_aptl-security` -- Manual readback found 10 active Wazuh agents, Suricata traffic/alert events, - 0 kernel drops, 49,954 loaded rules, and 0 failed rules. - -This baseline is the operational comparison point only; it is not the libvirt -backend proof. - -## Native libvirt substrate - -The accepted ACES/libvirt path is now native: - -- `aces libvirt techvault validate-live` creates libvirt networks and QEMU - domains from the ACES provisioning plan. -- Domains boot generated BusyBox initramfs appliances through libvirt/QEMU. -- The live gate no longer imports APTL, starts Docker Compose, or probes Docker - containers. -- Clean boot removes prior `aces-techvault-*` libvirt domains/networks before - realizing the next scenario, so a full TechVault run can be followed by a - reduced variant without carrying over the old topology. - -Local host setup for the proof: - -```bash -sudo apt-get install -y qemu-system-x86 libvirt-daemon-system \ - libvirt-clients python3-libvirt iputils-ping -``` - -Because `libvirt-python` remains optional and lazy for normal CI, the local -manual run exposed only the system libvirt binding to the project venv: - -```bash -mkdir -p /tmp/aces-libvirt-python -ln -s /usr/lib/python3/dist-packages/libvirt.py /tmp/aces-libvirt-python/libvirt.py -ln -s /usr/lib/python3/dist-packages/libvirtmod.cpython-312-x86_64-linux-gnu.so \ - /tmp/aces-libvirt-python/libvirtmod.cpython-312-x86_64-linux-gnu.so -``` +Several former claims exceeded the available evidence: -The old APTL Docker lab was stopped before native libvirt runs because its -bridges already occupied the authored TechVault `172.20.x.0/24` CIDRs. +- planned domain and network data was copied into the native report without an + independent source label; +- generic listeners were treated as realization of named services; +- SOC state was inferred from domain names rather than observed inside guests; +- requested memory and CPU values could be clamped; +- a successful native handle was treated as sufficient without exact daemon + readback; and +- prefix-wide cleanup was treated as a safe clean boot without per-resource + ownership proof. -## Native reduced variants +Consequently, the former counts for services, Wazuh agents, Suricata rules, +case-management applications, and scenario variants are withdrawn as libvirt +realization evidence. Historical endpoint reachability proved only that a +generated listener answered at an address; it did not prove the declared +application or service was installed and operating. -These variants mirror the APTL curated scenario shapes and are ordinary SDL -inputs to the libvirt backend, not name-based presets. +The APTL live-gate results remain evidence about the APTL substrate only. They do +not transfer to libvirt. -### Observability core +## Current bounded native mode -```bash -sudo env PYTHONPATH=/tmp/aces-libvirt-python PATH=$PATH \ - /home/atomik/.local/bin/uv run --project implementations/python --frozen \ - aces libvirt techvault validate-live \ - --scenario /home/atomik/src/aces5/examples/scenarios/techvault-observability-core.sdl.yaml \ - --output-dir /tmp/aces-libvirt-native \ - --run-id native-observability-20260627T0820Z \ - --yes --boot-timeout-seconds 90 --appliance-memory-mib 64 -``` - -Result: PASS. +The TechVault appliance mode now accepts only concerns it can apply exactly and +verify through bounded libvirt daemon readback. A successful run accounts for: -Manifest: - -```text -/tmp/aces-libvirt-native/runs/native-observability-20260627T0820Z/live-gate/manifest.json -``` +- native domain and network existence; +- exact architecture and generated-initramfs attachment policy; +- exact memory and virtual CPU values; +- exact network CIDR, gateway, internal/NAT policy, and domain attachments; and +- a realization binding covering the published envelope, driver configuration, + connection and naming configuration digests, and boot-artifact digests. -Surface: +The following concerns are explicitly unsupported in this mode and block the +operation before native resource creation: -- Domains: `aptl-grafana-otel`, `aptl-otel-collector`, `aptl-tempo` -- Networks: `security-net` -- Service listeners: 4 -- Substrate: `libvirt-qemu-initramfs` - -### Attacker target - -```bash -sudo env PYTHONPATH=/tmp/aces-libvirt-python PATH=$PATH \ - /home/atomik/.local/bin/uv run --project implementations/python --frozen \ - aces libvirt techvault validate-live \ - --scenario /home/atomik/src/aces5/examples/scenarios/techvault-attacker-target.sdl.yaml \ - --output-dir /tmp/aces-libvirt-native \ - --run-id native-attacker-target-20260627T0825Z \ - --yes --boot-timeout-seconds 120 --appliance-memory-mib 64 -``` +- concrete guest images; +- cloud-init content, content placements, accounts, and feature bindings; +- declared guest services; +- network ACLs; +- unbound metadata or silently normalized names; and +- updates or compound delete transactions without a verified restore path. -Result: PASS. +Guest readiness and SOC/application state remain `not-observed`. Concern-specific +guest probes are separate work; generic ping or TCP reachability is not promoted +to realization evidence. -Surface: +## Current live-gate interpretation -- Domains: `aptl-grafana-otel`, `aptl-otel-collector`, `aptl-tempo`, `kali`, - `kali-capture`, `victim`, `wazuh-indexer`, `wazuh-manager` -- Networks: `internal-net`, `redteam-net`, `security-net` -- Wazuh readback: `victim`, `wazuh-manager` +The live gate may pass only for a bounded scenario whose admitted substrate is +successfully created and read back. The repository's operational TechVault and +curated variants declare unsupported guest concerns, so they now fail with typed +diagnostics instead of producing a partial-success manifest. -### Defensive minimum after full TechVault - -The defensive-minimum variant was run after the full 30-domain scenario with -clean boot enabled, proving the backend recomposes the live surface instead of -over-starting the full topology. +For an admitted bounded scenario, the operator command is: ```bash -sudo env PYTHONPATH=/tmp/aces-libvirt-python PATH=$PATH \ - /home/atomik/.local/bin/uv run --project implementations/python --frozen \ - aces libvirt techvault validate-live \ - --scenario /home/atomik/src/aces5/examples/scenarios/techvault-defensive-min.sdl.yaml \ - --output-dir /tmp/aces-libvirt-native \ - --run-id native-defensive-min-final-20260627T0855Z \ - --yes --boot-timeout-seconds 120 --appliance-memory-mib 64 +aces libvirt techvault validate-live \ + --scenario path/to/bounded.sdl.yaml \ + --project-dir . \ + --run-id bounded-native-check \ + --yes ``` -Result: PASS. - -Live libvirt state after the run: - -- Running domains: `aces-techvault-aptl-grafana-otel`, - `aces-techvault-aptl-otel-collector`, `aces-techvault-aptl-tempo`, - `aces-techvault-wazuh-dashboard`, `aces-techvault-wazuh-indexer`, - `aces-techvault-wazuh-manager` -- Active native network: `aces-techvault-security-net` -- No full-TechVault domains remained from the preceding run. - -## Native full TechVault +There is no memory-clamping, boot-timeout, or prefix-cleanup switch. Resource +values come from the governed plan. Connection URIs carrying user information or +passwords are rejected. -Final command run from `/home/atomik/src/aces5` on 2026-06-27: +The manifest keeps sources separate: -```bash -sudo env PYTHONPATH=/tmp/aces-libvirt-python PATH=$PATH \ - /home/atomik/.local/bin/uv run --project implementations/python --frozen \ - aces libvirt techvault validate-live \ - --scenario /home/atomik/src/aces5/examples/scenarios/techvault-operational.sdl.yaml \ - --output-dir /tmp/aces-libvirt-native \ - --run-id native-operational-final-20260627T0850Z \ - --yes --boot-timeout-seconds 240 --appliance-memory-mib 64 -``` +| Section | Permitted basis | +|---|---| +| `authored` | scenario reference | +| `planned` | compiler/runtime plan | +| `driver_reported` | driver operation result | +| `daemon_observed` | bounded libvirt XML and active-state readback | +| `guest_observed` | `not-observed` | -Result: PASS. +Planned topology remains labelled `planned`; daemon observations do not imply +guest services, SOC state, or application behavior. -Manifest: - -```text -/tmp/aces-libvirt-native/runs/native-operational-final-20260627T0850Z/live-gate/manifest.json -``` +## Recovery and cleanup -Surface: - -- Domains: 30, matching `examples/scenarios/techvault-operational.sdl.yaml` -- Networks: `dmz-net`, `internal-net`, `redteam-net`, `security-net` -- Declared service listeners: 36 -- Substrate: `libvirt-qemu-initramfs` - -SOC readback: - -- Case-management surface present: TheHive, MISP, Cortex, Shuffle -- Suricata readback: present, 49,954 rules loaded, 0 failed rules, 0 kernel - drops -- Wazuh active-agent readback: `ad`, `db`, `dns`, `fileshare`, `suricata`, - `victim`, `wazuh-manager`, `webapp`, `workstation` - -Manual endpoint probes against the live native domains: - -| Node | IP | Port | Result | -|---|---:|---:|---| -| `wazuh-manager` | `172.20.0.29` | 55000 | OK | -| `thehive` | `172.20.0.24` | 9000 | OK | -| `misp` | `172.20.0.15` | 443 | OK | -| `cortex` | `172.20.0.13` | 9001 | OK | -| `shuffle-frontend` | `172.20.0.20` | 80 | OK | -| `shuffle-backend` | `172.20.0.19` | 5001 | OK | -| `suricata` | `172.20.0.23` | 80 | OK | -| `webapp` | `172.20.1.14` | 8080 | OK | -| `kali` | `172.20.4.10` | 22 | OK | -| `victim` | `172.20.2.16` | 22 | OK | - -## Regression coverage - -Native coverage now includes: - -- `test_libvirt_backend_techvault_integration.py`: the full TechVault SDL - drives 30 node domains and four networks through runtime planning and - provisioning. -- `test_libvirt_backend_techvault_native.py`: full TechVault and all four - reduced variants realize distinct native libvirt surfaces; live manifest - evidence is native and contains no Docker/APTL probe surface; clean boot - removes prior libvirt resources. -- `test_libvirt_backend_cli.py`: CLI wiring passes connection, memory, and - boot-timeout controls to the native live gate. +Creation failures trigger ownership-checked cleanup of resources created by the +current operation. Success is withheld unless absence can be verified. Uncertain +or failed cleanup produces a residual-state diagnostic and retains run-local boot +artifacts for investigation. The backend does not enumerate and delete resources +solely by name prefix. ## Scope statement -This is a reference-backend operational proof, not an equivalence proof with -APTL. The libvirt backend boots native QEMU appliance domains and validates the -ACES-composed topology, network reachability, declared service listeners, and -SOC surface/readback. It does not claim byte-identical guest images, application -data, or upstream Wazuh/MISP/TheHive internals from the APTL Docker stack. +The corrected proof is a bounded native-substrate realization proof. It is not an +APTL equivalence proof, an application deployment proof, a service-readiness proof, +or a SOC detection-quality proof. diff --git a/docs/decisions/issue-615-libvirt-paper-evidence-preflight.md b/docs/decisions/issue-615-libvirt-paper-evidence-preflight.md index 104a15713..acb8c230f 100644 --- a/docs/decisions/issue-615-libvirt-paper-evidence-preflight.md +++ b/docs/decisions/issue-615-libvirt-paper-evidence-preflight.md @@ -13,6 +13,16 @@ participant/evidence scenario. It is guidance only: it does not implement the artifact, add schemas, change runtime behavior, or define an implementation plan. +## ASR-519 correction + +Issue 714 narrows this preflight's native evidence assumptions. A generated +TechVault domain or reachable listener is not guest, service, or SOC evidence. +Current native evidence is limited to exact, bounded libvirt daemon readback of +the admitted VM/network substrate. Wazuh/SOC channels in the paper artifact are +structural evaluator declarations, not native readback. Generic readiness, +negative-reachability, and name-derived SOC claims are withdrawn pending +concern-specific guest observation work. + ## Binding Sources - `docs/decisions/issue-598-paper-reference-scenario-preflight.md`, @@ -34,7 +44,8 @@ plan. - `docs/decisions/issue-601-techvault-live-verification.md`, `aces_operations.techvault_live`, and `aces_backend_libvirt.techvault_native` own the native libvirt live-gate - substrate, realized surface, readiness checks, and SOC readback helpers. + substrate and bounded daemon-observed surface. Guest readiness and SOC state + are explicitly `not-observed`. - `contracts/schemas/backend-manifest/backend-manifest-v2.json`, `BackendManifestV2Model`, and `backend_manifest_payload()` own backend manifest/capability shape. @@ -60,14 +71,14 @@ plan. capability/profile/conformance result; libvirt realization provenance; realized topology and network attachment matrix; participant action proof from `LibvirtParticipantRuntime`; terminal participant observation envelope - or behavior-history equivalent; evaluator-only Wazuh/SOC readback or a typed - translated readback record with explicit limitation; negative reachability + or behavior-history equivalent; evaluator-only declarations of Wazuh/SOC + evidence channels with explicit limitation; structural negative-boundary checks for internal DB and Wazuh/evaluator surfaces; evaluator outcome and limitation records; and redaction/provenance metadata. - Wazuh/SOC evidence must remain evaluator-only evidence. If the native libvirt - proof uses generated appliance readback or translated native readback rather - than full upstream Wazuh internals, the artifact must state that limitation - next to the evidence and in the run/evaluator limitation surface. + proof has no concern-specific guest observation, the artifact must state + `not-observed`; it must not synthesize translated native readback from domain + names, declared services, or generic reachability. - The participant proof must enter through `RuntimeControlPlane` and `LibvirtParticipantRuntime`, reusing the issue-614 action-admission and behavior-history machinery. Do not replace the participant proof with a @@ -88,8 +99,7 @@ Reuse these repo surfaces before adding anything new: - Libvirt live-gate surface: `validate_techvault_live()`, `TechVaultLiveConfig`, `TechVaultLiveReport`, `TechVaultNativeLibvirtDriver`, - `NativeLibvirtProbe`, `expected_surface()`, `native_soc_readback()`, and the - existing safe `run_id` filesystem-label check. + `expected_surface()`, and the existing safe `run_id` filesystem-label check. - Libvirt runtime/provisioning surface: `create_libvirt_target()`, `create_libvirt_manifest()`, `create_libvirt_components()`, `LibvirtProvisioner`, @@ -152,7 +162,7 @@ Reuse these repo surfaces before adding anything new: rejected. - Participant visibility gate: participant-visible content is limited to the compiled observation boundary and participant implementation exposure - policy. Wazuh/SOC readback, internal DB reachability, policy internals, + policy. Wazuh/SOC channel declarations, policy internals, evaluator limitations, libvirt native details, and negative checks must stay outside `visible_refs` and `disclosed_refs` unless an existing governed boundary explicitly permits disclosure. @@ -172,10 +182,10 @@ Reuse these repo surfaces before adding anything new: connection URIs with secrets, credentials, private keys, unredacted tool transcripts, backend-native inspect payloads, raw Wazuh rule bodies, raw prompts, hidden answers, environment dumps, process argv, or full tracebacks. -- OS-level exposure gate: live probes must keep secrets out of argv and - diagnostics. Reuse fixed argv, no `shell=True`, bounded timeouts, controlled - working directories, and bounded sanitized diagnostics as in the existing - libvirt live-gate helpers. +- OS-level exposure gate: future concern-specific guest probes must keep + secrets out of argv and diagnostics. Generic ping/TCP checks are not + realization evidence. Use fixed argv, no `shell=True`, bounded timeouts, + controlled working directories, and bounded sanitized diagnostics. - Persistence gate: use the existing run archive directory and atomic JSON writing pattern where durable control-plane state is needed. Do not add a libvirt evidence database, participant store, audit log, schema registry, or @@ -197,10 +207,10 @@ runtime/evidence contract inputs, parameterized by: - `scenario_path`, `run_id`, `output_dir`, and optional artifact locator or sealing policy; -- backend target factory/config, including libvirt connection, native probe, - boot timeout, clean-boot policy, and participant runtime factory; -- evidence source policy, including native translated SOC readback versus - upstream Wazuh readback and the disclosure text that explains the difference; +- backend target factory/config, including libvirt connection and participant + runtime factory; +- evidence source policy separating authored, planned, driver-reported, + daemon-observed, guest-observed, and derived facts; - invariant-ledger mapping, which should reference stable ACES addresses and evidence refs rather than libvirt domain names, UUIDs, Docker ids, host paths, or APTL-private identifiers. @@ -230,8 +240,8 @@ Avoid: observation envelope; - claiming libvirt evaluator or observation capability in the backend manifest without actual capability implementation and contract-gap checks; -- treating native appliance SOC readback as upstream Wazuh detection-quality - evidence; +- fabricating native appliance SOC readback from names, listeners, topology, or + daemon-only substrate facts; - using libvirt domain UUIDs, XML, MACs, host paths, QEMU commands, Docker ids, APTL Compose names, or backend-local action labels as portable semantics; - overfitting the artifact to one run id, host, libvirt network naming policy, diff --git a/docs/decisions/issue-714-asr-519-techvault-realization-disclosure-preflight.md b/docs/decisions/issue-714-asr-519-techvault-realization-disclosure-preflight.md new file mode 100644 index 000000000..4deb1c8f1 --- /dev/null +++ b/docs/decisions/issue-714-asr-519-techvault-realization-disclosure-preflight.md @@ -0,0 +1,406 @@ +# Issue 714 / ASR-519 TechVault Realization Disclosure Preflight + +Date: 2026-07-11 + +Requirement: ASR-519. + +This note records architecture guardrails for making the generated TechVault +libvirt appliance path truthful at validation, execution, observation, and +evidence boundaries. It is guidance only: it does not implement issue #714, +change a schema, alter an envelope, add a probe, or define an implementation +plan. + +## Binding Sources + +- ADR-070, `specs/formal/realization/envelope-semantics.md`, and the issue #100 + preflight own configuration-bound realization-envelope identity, the shared + concern/disposition/observation vocabulary, and exact/constrained/open set + semantics. +- `specs/formal/realization/explicitness-and-realization.md` and the issue #491 + preflight own SEM-218 non-approximation and author/processor/backend origin + provenance. Origin provenance is not observation strength. +- ADR-066 and `specs/formal/observability-evidence-plane.md` separate authored, + operational-observability, captured-evidence, and derived-analysis facts. +- ADR-021 forbids promoting an internally coherent or positive-path result into + a demonstrated realization claim without falsification evidence. +- ADR-004 and ADR-036 keep planning, runtime execution/persistence, portable + contracts, concrete backend IO, and operational artifact production in their + existing packages. +- The issue #603, #604, and #606 preflights own libvirt interpretation, + reconciliation/teardown, and backend-neutral conformance boundaries. +- `BackendRealizationEnvelopeModel`, `RealizationEnvelopeIdentityModel`, + `ProvisioningPlan`, `Realization`, `DomainSpec`, `NetworkSpec`, `DriverResult`, + `ApplyResult`, `RuntimeSnapshot`, `Diagnostic`, and `OperationStatus` are the + incumbent contract and error surfaces. + +## Architectural Diagnosis + +The current path has two independent truth gaps that must not be confused. + +1. `LibvirtProvisioner._reconcile_snapshot()` copies plan payloads into + `SnapshotEntry`. `realization_disclosure()` can therefore compare an authored + value with a planned payload echo even when the native driver ignored that + value. The snapshot is reconciliation state; payload equality is not native + observation. +2. `TechVaultNativeLibvirtDriver.last_snapshot` is derived from + `_native_matrix()`, not from libvirt or guest readback. `expected_surface()` + and `native_soc_readback()` then derive claims from that matrix. A non-empty + domain list proves, at most, that the driver reported substrate handles; it + does not prove the requested image, resources, services, accounts, content, + features, ACLs, or guest network state. + +Concrete known relaxations include resource clamping, generated-appliance image +substitution, invalid/missing network defaults, synthesized health services, +discarded `cloud_init` and ACL intent, and domain-level handles standing in for +placement-level realization. The static TechVault envelope discloses several of +these limitations, but a transformation label such as `bounded-normalization` +is disclosure, not executable authorization to weaken an exact plan value. + +Material target identity is also incomplete if behavior-changing injected +values such as the initramfs builder, kernel/image policy, `define_only`, or a +resource override can vary while only `driver_mode=techvault-appliance` selects +the fixed configuration digest. A live claim must bind the actual material +configuration, not merely the driver class name. + +## Architecture Decisions And Guardrails + +### Keep fact classes separate + +Every claim-bearing artifact must preserve these classes explicitly: + +| Fact class | Canonical source | Maximum claim without another source | +| --- | --- | --- | +| Authored | parsed and validated SDL | author requested it | +| Planned | `ProvisioningPlan`, `Realization`, `DomainSpec`, `NetworkSpec` | processor/backend intends to apply it | +| Driver-reported | `DriverResult` and bounded driver receipts | driver says an operation completed | +| Daemon-observed | post-mutation libvirt readback | libvirt reports a native definition/state | +| Guest-observed | concern-specific guest probe | guest reports the concern inside the VM | +| Derived analysis | evidence/artifact computation over named inputs | stated inference only | + +No presence test, success flag, handle, snapshot entry, compiled model, or +planned matrix may promote a fact to a stronger class. In particular, +`ExplicitnessProvenance.BACKEND_REALIZED` says who chose a value; it does not mean +`daemon-observed` or `guest-observed`. + +### Account for every non-UNCHANGED operation + +- Derive one canonical, field-addressed concern inventory from the existing + `ProvisioningPlan` -> `Realization` interpretation. Validation, driver + accounting, evidence assembly, and mutation tests must consume that inventory; + they must not maintain separate lists of fields. +- Keep the existing realization-envelope concern taxonomy. Extend that taxonomy + in place for node service declarations rather than folding services into + `feature-binding` or adding a TechVault-local vocabulary. Cloud-init is a + mechanism for account/content/feature delivery, not a separate authored + concern and not proof that any of those concerns took effect. +- Every `CREATE` or `UPDATE` concern is either realized with an allowed + transformation and a named observation source, rejected as unsupported before + native mutation, or failed with a structured diagnostic. A resource-level + `DomainHandle` cannot satisfy its nested concerns. +- `DELETE` is accounted for by ownership-checked, daemon-observed absence of the + native object and cleanup of its owned artifacts. `UNCHANGED` remains a strict + no-driver-call operation and creates no fresh observation claim. +- Descriptor production, seed attachment, and port reachability must not satisfy + the stronger content, account, feature, service-identity, or application-state + concern they describe or transport. + +### Exact values are honored or rejected + +- Treat every concrete value in a directly submitted `ProvisioningPlan` as + binding at the backend boundary. Direct control-plane submission cannot rely + on compiler-only explicitness metadata being present. +- SEM-218 exact values may not be clamped, rounded, defaulted, substituted, + normalized to a different value, or omitted. The TechVault path must honor the + exact value or reject the plan before connection, cleanup, artifact creation, + `defineXML`, `networkDefineXML`, `create`, `destroy`, or `undefine`. +- A constrained/open concern may be transformed only when the selected envelope + admits the input and output and names the transformation. Record + backend-realized origin and the actual observation strength. The current + transformation-name list is not a free-form policy language and must not be + interpreted as permission to clamp an exact request. +- If no executable, governed transformation rule exists, use exact-or-reject. + Issue #714 must not add an ad hoc TechVault transformation language merely to + preserve today's positive-path demonstrations. +- Validate the whole plan before any native mutation. Unsupported account, + content, feature, ACL, service, image, resource, or network-property concerns + must produce addressed diagnostics together; do not mutate the supported + subset and then discover the rest. + +### Observe native state independently of planned state + +- Driver handles remain completion receipts, not evidence. Successful native + creation must be followed by typed, source-labelled readback for every concern + the issue claims as realized. +- Libvirt readback may support substrate existence, ownership, configured + architecture/image attachment, vCPU/memory definition, native network + definition, and domain/network attachment claims. Record only bounded, + portable comparisons; never publish raw XML, native object reprs, paths, UUIDs, + or connection data. +- Guest-applied IP configuration, cloud-init execution, accounts, placed content, + feature behavior, ACL effect inside the guest, and named service/application + identity require concern-specific guest evidence. Until issue #715 supplies + that evidence, issue #714 must classify them as unsupported/not observed and + reject binding requests, or narrow the emitted claim to the weaker substrate + fact actually observed. +- Opening a BusyBox HTTP listener on a requested port is not realization of + Wazuh, TheHive, MISP, PostgreSQL, SSH, DNS, or another named service. Likewise, + `native_soc_readback()` values synthesized from node names are derived test + data, not native SOC readback. +- A synthesized health listener is environment-visible augmentation. Remove it + or disclose it through the existing SEM-225 augmentation carrier with its + environment/comparability effects; do not hide it as a default service. + +### Commit and rollback stay fail-closed + +- `LibvirtProvisioner` may return success only after complete concern accounting + and required readback. `_call_backend_apply()` remains the runtime contract + backstop, not the component responsible for undoing an already dishonest + native success. +- Preserve the current deep-copied baseline snapshot on every validation, + driver, readback, contract, or cleanup failure. Failed results carry no changed + addresses and never attach a new envelope or provenance claim. +- The general runtime contract currently permits some non-libvirt backends to + return partial snapshots on failure, and the control plane persists whatever + `ApplyResult.snapshot` it receives. Issue #714 imposes a stricter TechVault + provisioner invariant: it must return the baseline snapshot itself. Do not + broaden or reinterpret unrelated workflow partial-failure semantics here. +- Track pre-existing owned objects separately from objects created by this call. + Compensate in reverse dependency order and verify the result through native + readback. A failed update must restore the prior definition or report the + affected ACES addresses as residual native state; preserving only the portable + snapshot is not rollback. +- Prefix-wide `clean_existing` deletion is not ownership proof and may not run as + a preflight shortcut. Reuse deterministic ACES ownership stamps and the + absence-vs-lookup-failure rules from `LibvirtDeploymentDriver`. +- Cleanup failure is a failed operation, not a warning. Diagnostics may name + safe ACES addresses and whether residual state remains; they must not dump the + residual XML or host paths. + +### Publish truthful evidence, not a renamed plan + +- Keep the local `aces.libvirt.scenario-evidence-run/v1` artifact and its + validator as the issue's evidence carrier; do not invent a second report + schema. Separate authored, planned, driver-reported, daemon-observed, + guest-observed, and derived sections in that artifact. +- A `native-live` basis requires the corresponding observed facts. A non-empty + `last_snapshot`, `realized_addresses`, domain list, or `expected_surface()` is + insufficient. Compiled node/service/network fields remain `planned` even when + some native substrate exists. +- Embed the canonical backend manifest identity and selected realization-envelope + identity, including configuration and envelope digests. Bind observations to + the concrete driver/backend version and secret-free material configuration. +- Keep the deterministic participant runtime explicitly labelled as deterministic + control-plane execution with no live guest execution. Native provisioning does + not upgrade its participant proof. +- The TechVault live-gate manifest and downstream cross-backend corpus must + preserve the same source labels. No downstream summary may upgrade a weaker + source discarded by its input artifact. +- Continue validating the full artifact before atomic write. Failed or residual + runs may be retained as failed evidence, but never with `passed=true` or a + realized basis. + +## Required Cross-Cutting Reuse + +- SDL ingress and semantic validation: `parse_sdl()` / `parse_sdl_file()`, closed + SDL models, `instantiate_scenario()`, `SemanticValidator`, and existing parse, + instantiation, and semantic error types. +- Planning and exactness: `CompiledRealizationRequirement`, + `realization_support_diagnostics()`, `realization_disclosure()`, + `ProvisioningPlan`, `ChangeAction`, and processor dependency/delete ordering. +- Envelope authority: `BackendRealizationEnvelopeModel`, + `RealizationConcern`, `ConcernDisposition`, `TransformationKind`, + `ObservationStrength`, `load_libvirt_realization_envelope()`, and the canonical + digest helpers. Keep coarse `ProvisionerCapabilities` and + `capability_envelope_diagnostics()` as separate necessary gates. +- Libvirt interpretation and IO: `interpret_provisioning_plan()`, `Realization`, + `DomainSpec`, `NetworkSpec`, `LibvirtDriver`, `DriverResult`, the structured XML + builders, deterministic ownership UUIDs, safe absence detection, and rollback + precedents in `LibvirtDeploymentDriver`. +- Runtime execution and persistence: `RuntimeManager`, `RuntimeControlPlane`, + `_call_backend_diagnostics()`, `_call_backend_apply()`, `ApplyResult`, + `RuntimeSnapshot`, `realization_provenance`, `realization_envelope`, + `ControlPlaneStore`, and atomic local-store writes. +- Error and operational observability: `Diagnostic`, `Severity`, + `OperationReceipt`, `OperationStatus`, control-plane audit events, and stable + package-local diagnostic codes. There is no need for a TechVault exception + hierarchy or logging-only result channel. +- Evidence and conformance: `BackendManifestV2Model`, + `ExperimentRealizedFormDisclosureModel`, SEM-225 augmentation disclosures, + `run_target_conformance()`, `BackendConformanceReport`, + `validate_libvirt_evidence_run_artifact()`, `redaction_violations()`, + `run_artifact_path()`, and `atomic_write_json_artifact()`. +- Schema authority, if the existing envelope concern taxonomy or runtime carrier + changes: the hand-governed schema, `schema_bundle()`, valid/invalid fixtures, + `contracts/schema-publication-manifest.json`, ADR-061 compatibility evidence, + packaged corpus, and `specs/authority/authority-boundary.yaml`. Evolve the + existing contract; do not publish a libvirt-only duplicate. + +## Security And Whole-Path Gates + +- **SDL/plan shape:** authored input passes the existing parser, closed models, + semantic validator, compiler/planner checks, closed `ProvisioningPlanModel`, + envelope identity validation, and libvirt capability/value gates. Unknown or + unsupported fields fail before IO; no backend-local SDL parser is added. +- **Target/config shape:** reuse `_validate_config_keys()`, + `_selected_driver_mode()`, `_validate_manifest_mode()`, the envelope loader, + and canonical configuration digest. Replace clamping/coercion in direct Python + entry points with validation; Typer option bounds alone do not protect library + callers. Material builder/kernel/image/resource/define mode affects identity or + is rejected in claim-bearing mode. Operational handles and secrets stay out of + the digest. +- **Authentication/authorization:** issue #714 needs no HTTP route. If a changed + snapshot or operation field crosses HTTP, it retains + `ControlPlaneSecurityConfig.strict_defaults()`, bearer/proxy verification, + backend/operator role checks, target scope, request-size limits, idempotency + fingerprints, and audit events. The current bearer-token branch returns before + the proxy branch's `identity.target_name` check; any issue #714 HTTP exposure + must enforce target scope after either authentication mechanism rather than + relying on that incumbent bug. The local destructive CLI retains explicit + operator confirmation; neither path grants host privileges. +- **Secrets:** credential-bearing libvirt URIs are forbidden in CLI argv and are + rejected rather than logged. Use a non-secret URI plus injected connection or + credential handle. SSH keys, account material, cloud-init bodies, generated + initramfs contents, connector reprs, environment dumps, and raw configuration + never enter digests, diagnostics, snapshots, audit details, fixtures, reports, + or command output. +- **Host files/processes:** reuse the generic seed workspace's ownership, + symlink, and permission rules if sensitive guest material is ever generated. + Do not place it in the current world-readable appliance metadata/HTML path. + Keep libvirt imports lazy, use structured XML, fixed argv, no `shell=True`, + bounded timeouts, controlled working directories, and no secrets in argv or + subprocess environments. No `sudo`, setcap, ambient capability, or daemon + inventory requirement belongs in the hermetic suite. +- **Error envelopes:** plan/config failures use safe addressed `Diagnostic` + values. Native exception strings, XML, stdout/stderr, probe output, paths, + object reprs, and stack traces do not cross the boundary. The existing + `_backend_call_failed()` and live/evidence `str(exc)` paths are unsafe for + secret-bearing configuration errors; avoid raising such values into them and + harden them if touched. +- **Persistence/API serialization:** the baseline snapshot remains byte-for-byte + equivalent on failure. Existing typed realization provenance and envelope + identity must round-trip through `ControlPlaneStore`, + `RuntimeSnapshotEnvelopeModel`, and `_snapshot_model()`; note that the current + HTTP serializer omits `realization_provenance`, so any claim depending on that + ledger must close this cross-boundary loss. Do not use `RuntimeSnapshot.metadata` + or `ApplyResult.details` as a native-state/evidence ledger. +- **Evidence output:** run ids pass the existing safe-label and root-confinement + helpers; artifacts pass embedded-contract, source-separation, boundary, and + redaction validation before atomic write. Host paths, connection URIs, native + UUIDs/XML, QEMU command lines, credentials, and private keys stay forbidden. + +## Whole-Repository Surfaces In Scope + +- Canonical contracts and policy: the TechVault realization envelope, + `realization-envelope-v1`, `backend-manifest-v2`, `provisioning-plan-v1`, + `runtime-snapshot-v1`, schema publication/authority manifests, and backend + provisioning-only profile. +- Backend/runtime path: `aces_backend_libvirt.realization`, `capability_envelope`, + `driver`, `drivers.libvirt`, `techvault_appliance`, `techvault_native`, + `techvault_probe`, `provisioner`, `manifest`, `target`, + `aces_runtime.backend_calls`, control-plane execution/API serializers, and + control-plane stores. +- Evidence path: `aces_operations.techvault_live`, + `libvirt_evidence_run`, `_evidence_run_artifact`, + `_evidence_run_validation`, cross-backend corpus projection/validation, and the + libvirt CLI presentation. +- Verification path: realization-envelope, manifest, provisioner, native driver, + evidence-run, runtime snapshot/API/store, target conformance, authority, and + real-libvirt opt-in suites; repository policy and full verification remain the + final mechanical gates. + +## Conformance And Falsification Guardrails + +- Reuse the injected/recording `LibvirtDriver` and fake-connection patterns. Do + not certify by monkeypatching the gate under test, inspecting source strings, + or calling `_native_matrix()` directly. +- Mutation cases must include a driver that drops cloud-init, ignores an account, + omits content, clamps resources, substitutes an image, and fabricates a + realized handle. Unsupported TechVault concerns should be rejected before the + mutation driver is called; supported concerns must fail when independent + readback does not match the request. A plausible handle alone must never pass. +- Also exercise service synthesis/omission, ACL omission, network defaulting, + stale or mismatched envelope/configuration identity, partial create, failed + update restoration, rollback failure, and residual-state reporting. These are + distinct failure modes and must not collapse into one happy-path handle test. +- Every rejection asserts: no pre-admission native call; failed operation status; + empty `changed_addresses`; prior in-memory and persisted snapshots unchanged; + no new realization provenance/envelope claim; and either verified native + cleanup or a safe residual-state diagnostic naming the affected ACES address. +- Artifact mutations must relabel planned or driver-reported data as + daemon/guest-observed, infer realization from a non-empty domain list, replace + the bound envelope/configuration identity, or inject forbidden host/secret + material. The existing artifact validator must reject every mutation before + atomic write. +- Keep `run_target_conformance()` as the backend-neutral target/profile runner. + Libvirt-specific mutation coverage belongs at the existing driver, + provisioner, evidence-validator, and target-integration seams, not in a + libvirt branch inside `aces_conformance`. +- The default suite remains hermetic. Real-libvirt evidence is opt-in, + reproducible, source-labelled, and must verify complete cleanup even when the + realization or probe fails. + +## Extensibility Seam + +The seam is one canonical concern inventory plus a source-specific observer +passed through the existing target/driver configuration boundary. The observer +is parameterized by ACES address/field path, concern, selected envelope identity, +and observation source; it returns bounded typed facts, not raw backend objects. + +Issue #714 supplies validation and daemon-level accounting for the bounded +TechVault appliance. Issue #715 can add guest observers for the same concern +inventory without changing SDL syntax, plan interpretation, diagnostic +hierarchy, control-plane workflow, or evidence taxonomy. A future appliance, +kernel/image policy, remote libvirt configuration, or stronger probe selects a +different normalized material configuration/envelope identity rather than +editing global planner or artifact rules. + +## Gotchas And Anti-Patterns + +Avoid: + +- treating `SnapshotEntry.payload`, `status="applied"`, `changed_addresses`, a + handle, domain existence, or a non-empty driver matrix as native observation; +- comparing an authored value with the same planned payload twice and calling + the second copy backend evidence; +- using `last_snapshot`, `last_matrix`, `realized_addresses()`, + `expected_surface()`, or name-derived SOC counts as the evidence authority; +- allowing `bounded-normalization`, `default-substitution`, or + `image-substitution` to weaken an exact request; +- silently accepting accounts/content/features/ACLs/services because they were + aggregated into a `DomainSpec` the TechVault driver then ignores; +- calling a generic HTTP listener the requested named service or calling seed + creation guest application; +- inferring guest IP state from a planned DHCP allocation or daemon network XML; +- overloading SEM-218 origin provenance with observation strength, or using the + SEM-225 augmentation carrier as a substitute for realization evidence; +- adding a second concern enum, plan payload extractor, schema registry, + validation stack, exception hierarchy, store, endpoint, or report writer; +- binding claims only to a driver class/mode while injected material behavior can + vary under the same configuration digest; +- deleting resources by prefix, treating every lookup error as absence, or + reporting rollback success without post-cleanup readback; +- relying on the general runtime's permissive partial-failure snapshot behavior + for the stricter issue #714 baseline-preservation contract; +- assuming bearer authentication currently enforces target scope; +- preserving the portable snapshot while hiding known residual native state; +- echoing raw exceptions, XML, stdout/stderr, host paths, connection URIs, + account/cloud-init material, or probe details in diagnostics or artifacts; +- weakening assertions or relabelling current tests merely to keep the existing + TechVault positive path green. + +## Non-Goals And Implementation Boundaries + +- No implementation of issue #714 in this preflight. +- No new SDL syntax, backend profile, control-plane route, persistence service, + libvirt-specific public DTO, exception hierarchy, or parallel report schema. +- No implementation of real TechVault/Wazuh/TheHive/MISP/application services, + generic cloud-init delivery, accounts, content, features, ACLs, or guest + configuration. Unsupported concerns are rejected and disclosed honestly. +- No guest-observed certification; issue #715 owns guest probes. Issue #714 may + only emit daemon/driver strengths it actually proves. +- No claim that the deterministic participant adapter executes inside live + guests, and no coupling of participant-runtime success to substrate success. +- No final real-libvirt scenario certification or backend-equivalence claim; + issues #716 and #717 own broader conformance and final evidence. +- No compatibility fallback that accepts missing concern accounting, stale or + mismatched envelope/configuration identity, or failed cleanup as success. diff --git a/docs/decisions/issue-714-asr-519-techvault-realization-disclosure.md b/docs/decisions/issue-714-asr-519-techvault-realization-disclosure.md new file mode 100644 index 000000000..5f81dc5df --- /dev/null +++ b/docs/decisions/issue-714-asr-519-techvault-realization-disclosure.md @@ -0,0 +1,81 @@ +# Issue 714 / ASR-519 TechVault Realization Disclosure + +Requirement: ASR-519 (`9ad95f1d-7c4a-4532-8a3e-50453e8286d4`). + +The TechVault appliance driver is a bounded native-substrate mode. It does not +claim to provision the full TechVault guest/application stack. Admission, +readback, persistence, and evidence publication all use the concern boundaries +below. + +## Concern accounting + +| Concern | Current disposition | Success evidence | +|---|---|---| +| topology | realized | active native object and exact native-name readback | +| architecture | realized | domain XML readback | +| image | selected generated-initramfs policy realized; concrete images rejected | exact attached kernel/initramfs paths plus artifact digests | +| resource allocation | realized exactly | domain memory/vCPU XML readback | +| network | realized exactly | network/domain XML readback for CIDR, gateway, forwarding policy, and attachments | +| content placement | unsupported | typed pre-I/O diagnostic | +| account placement | unsupported | typed pre-I/O diagnostic | +| feature binding | unsupported | typed pre-I/O diagnostic | +| service | unsupported | typed pre-I/O diagnostic | +| ACL | unsupported | typed pre-I/O diagnostic | + +Guest readiness, applications, and SOC state are not observed. A domain handle, +domain name, declared listener, ping, or TCP connection is not accepted as proof +of any nested concern. + +## Admission and exactness + +The provisioner validates TechVault concerns before snapshot reconciliation and +before driver I/O. The native driver repeats the gate for direct callers. Values +outside the published envelope, implicit network values, silently normalized or +duplicate names, concrete images, guest placements, services, ACLs, unbound +metadata, and unsupported update/recovery shapes fail closed. + +Memory and CPU values are never clamped. Network `internal: false` remains an +explicit false value and is verified as NAT forwarding; it is not lost as an +omitted default. + +## Observation and commit + +Each admitted field has one typed `daemon-observed` readback record. Missing, +duplicate, wrong-source, type-coerced, or mismatched records fail the operation. +An exact daemon report and realization binding are required before the runtime +snapshot commits. + +Reports keep these sources separate: + +- authored scenario identity; +- planned topology and concern values; +- driver-reported operation outcomes; +- bounded daemon-observed substrate facts; +- guest-observed facts, currently `not-observed`; and +- derived evaluator analysis. + +The binding covers the published realization-envelope and configuration digests, +hashed connection/naming configuration, and hashes of the actual run-local kernel +and initramfs artifacts. Artifacts never contain raw libvirt XML, UUIDs, host +paths, connection URIs, credentials, or exception text. + +## Recovery + +Partial creation and post-create readback/binding failures trigger cleanup of only +the current operation's ownership-stamped resources. Cleanup is successful only +after native absence is verified. Lookup/listing uncertainty, ownership conflict, +or failed destroy/undefine produces a residual-state diagnostic and withholds +success. Prefix-wide cleanup is not available. + +Updates and compound delete transactions are rejected until a verified native +restore path exists. Failed provisioner operations retain the prior runtime +snapshot; successful native deletion clears the driver's prior observation report +so it cannot be reused as fresh evidence. + +## Verification focus + +Falsification coverage includes clamped resources, fabricated handles, incomplete +and duplicate observations, type coercion, inactive readback, substituted boot +artifacts, extra attachments, altered forwarding policy, foreign ownership, +partial rollback, unverifiable cleanup, stale evidence relabeling, and mismatched +realization bindings. diff --git a/examples/README.md b/examples/README.md index 1e0b514bf..4d14dc196 100644 --- a/examples/README.md +++ b/examples/README.md @@ -14,6 +14,7 @@ backend guarantees. | [`scenarios/port-authority-surge-response.sdl.yaml`](scenarios/port-authority-surge-response.sdl.yaml) | IT/OT, customs, yard operations, and recovery scenario | Disk-backed example test; complex example checks for objectives, agents, relationships, content, stories, conditions, workflows, direct refs | Does not implement OT control, safety validation, or port operations | | [`scenarios/techvault.sdl.yaml`](scenarios/techvault.sdl.yaml) | Runtime inventory and image provenance parity example | Disk-backed example test | Does not provide a deployable TechVault application or image build pipeline | | [`scenarios/enterprise-participant-evidence-loop.sdl.yaml`](scenarios/enterprise-participant-evidence-loop.sdl.yaml) | Reference scenario for a generic enterprise participant/evidence loop | Disk-backed example test; focused processor compile check for participant behaviors, action contracts, observation boundaries, Wazuh evidence, policy provenance, and boundary evidence surfaces | Does not prove a concrete coding-agent runner, APTL/libvirt realization, TechVault coverage, or broad benchmark capability | +| [`scenarios/techvault-bounded-native.sdl.yaml`](scenarios/techvault-bounded-native.sdl.yaml) | Bounded TechVault libvirt VM/network substrate with explicit resources and network policy | Native driver exactness/readback tests and opt-in real-libvirt cleanup certification | Deliberately excludes guest images, placements, services, ACLs, readiness, applications, and SOC claims | The tests are in [`../implementations/python/tests/test_scenarios.py`](../implementations/python/tests/test_scenarios.py). diff --git a/examples/scenarios/enterprise-participant-evidence-loop.README.md b/examples/scenarios/enterprise-participant-evidence-loop.README.md index a7f8a1dc4..5afffd6d1 100644 --- a/examples/scenarios/enterprise-participant-evidence-loop.README.md +++ b/examples/scenarios/enterprise-participant-evidence-loop.README.md @@ -110,20 +110,18 @@ URIs, credentials, or private keys). ### Evidence-source modes - `deterministic` (default; no libvirt daemon; used by CI): participant proof, - compiled topology, structural negative-boundary evidence, and an evaluator-only - translated SOC-readback record explicitly marked as not upstream Wazuh. + compiled topology, structural negative-boundary evidence, and declared + evaluator-only defensive evidence channels. No SOC state is observed. - `native-live` (operator-run): additionally realizes the libvirt VM/network - substrate and records the native topology and native SOC readback. Native - realization is **gating** — the run only reports `PASS` when the libvirt driver - actually realizes substrate, so the mode can never claim success without - realizing. The libvirt backend declares no content-type support, so the *reference* - scenario's content, orchestration, and evaluation planes are not - backend-realized: native-live against this scenario therefore reports the - realization gate as **failed** and surfaces the unrealized planes under - `unrealized_capabilities` (disclosed, not faked). The artifact is still written - and validates, recording the attempt and the disclosure. Native realization - passes for a scenario the libvirt backend can fully provision (e.g. a - VM/network-only substrate scenario). + substrate only when every concern passes the TechVault admission gate, then + records bounded daemon-observed fields with a realization binding. Native + realization is **gating**. The reference scenario declares unsupported guest + content/account/feature concerns, so native-live reports the realization gate + as **failed** and surfaces those concerns under `unrealized_capabilities` + (disclosed, not faked). The artifact is still written and validates, recording + the attempt. Native realization can pass for an admitted VM/network-only + substrate scenario. Guest readiness, services, and SOC state remain + `not-observed` in both modes. ### How libvirt evidence differs from APTL Docker/Wazuh evidence @@ -134,21 +132,22 @@ evidence. The libvirt proof realizes a different substrate — native libvirt/QE appliances — and its participant runtime is deterministic (#614), so: - the **substrate** is genuinely different (VM/network appliances vs. - containers), which is the point of the n=2 backend-diversity claim; -- the **defensive evidence** is an evaluator-only *translated/native* SOC - readback (or, in deterministic mode, the declared evaluator-only evidence - channels), explicitly disclosed as not upstream Wazuh detection output — the - artifact makes no Wazuh detection-quality claim; + containers) only for concerns admitted and daemon-verified by the bounded + native mode; +- the **defensive evidence** is a declaration of evaluator-only evidence + channels, not native SOC readback — the artifact makes no Wazuh + detection-quality claim; - the **participant action proof** is structural (deterministic domain adapter), disclosed as such. -The claim that this difference supports is narrow and explicit: ACES can -drive the *same authored scenario, action contract, and observation/evaluator -boundary* across two independent backends, producing comparable evaluator -evidence shapes for the Brad-Edwards/aces#600 cross-backend **invariant ledger**. -It is **not** a claim of byte-equivalence, application-internals equivalence, -Wazuh detection-quality parity, model-defense robustness, or full -semantic-equivalence between the libvirt and APTL realizations. +The claim is narrow: ACES can compile the same authored scenario and execute its +deterministic participant contract while honestly disclosing that the full +guest/application provisioning plane is not realized by the libvirt TechVault +mode. A separate bounded scenario demonstrates an independent VM/network +substrate. This is **not** a claim that the reference scenario is fully realized +on two backends, nor a claim of byte-equivalence, application-internals +equivalence, Wazuh detection-quality parity, model-defense robustness, or full +semantic equivalence. ## Downstream Links diff --git a/examples/scenarios/techvault-bounded-native.sdl.yaml b/examples/scenarios/techvault-bounded-native.sdl.yaml new file mode 100644 index 000000000..fb110b511 --- /dev/null +++ b/examples/scenarios/techvault-bounded-native.sdl.yaml @@ -0,0 +1,26 @@ +name: techvault-bounded-native +version: 1.0.0 +description: >- + Bounded TechVault libvirt substrate used to certify exact VM/network + realization without guest image, placement, service, ACL, or SOC claims. + +nodes: + lab: + type: switch + demo: + type: vm + os: linux + resources: + ram: 128 MiB + cpu: 1 + services: [] + +infrastructure: + lab: + properties: + cidr: 192.0.2.0/24 + gateway: 192.0.2.1 + internal: true + demo: + links: + - lab diff --git a/implementations/python/packages/aces_backend_libvirt/driver.py b/implementations/python/packages/aces_backend_libvirt/driver.py index 2c71d1e85..e70681524 100644 --- a/implementations/python/packages/aces_backend_libvirt/driver.py +++ b/implementations/python/packages/aces_backend_libvirt/driver.py @@ -6,6 +6,7 @@ from typing import Protocol from aces_contracts.diagnostics import Diagnostic +from aces_contracts.realization_envelope import ObservationStrength, RealizationConcern from .cloudinit import CloudInitSpec @@ -77,6 +78,17 @@ class DomainHandle: realized: bool = True +@dataclass(frozen=True) +class RealizationObservation: + """Bounded typed readback for one realized concern field.""" + + address: str + field_path: str + concern: RealizationConcern + source: ObservationStrength + value: object + + @dataclass(frozen=True) class DriverResult: """Aggregate portable result from a libvirt driver call.""" @@ -84,6 +96,7 @@ class DriverResult: networks: tuple[NetworkHandle, ...] = () domains: tuple[DomainHandle, ...] = () diagnostics: tuple[Diagnostic, ...] = () + observations: tuple[RealizationObservation, ...] = () class LibvirtDriver(Protocol): diff --git a/implementations/python/packages/aces_backend_libvirt/provisioner.py b/implementations/python/packages/aces_backend_libvirt/provisioner.py index 38ac8afba..b49b73287 100644 --- a/implementations/python/packages/aces_backend_libvirt/provisioner.py +++ b/implementations/python/packages/aces_backend_libvirt/provisioner.py @@ -15,6 +15,7 @@ from .envelopes import LibvirtDriverMode, load_libvirt_realization_envelope from .manifest import _provisioner_capabilities from .realization import Realization, interpret_provisioning_plan +from .techvault_concerns import techvault_admission_diagnostics, techvault_observation_diagnostics _DOMAIN = "runtime" INVALID_PLAN_CODE = "libvirt-backend.invalid-plan" @@ -52,6 +53,9 @@ def __init__( if realization_envelope is not None and realization_envelope != expected_envelope: raise ValueError("libvirt provisioner realization envelope does not match driver mode") self._provisioner_capabilities = expected_capabilities + self._mode = mode + self._name_prefix = str(getattr(self._driver, "name_prefix", "aces-techvault")) + self._backend_realization_envelope = load_libvirt_realization_envelope(mode) self._realization_envelope = expected_envelope def validate(self, plan: ProvisioningPlan) -> list[Diagnostic]: @@ -61,7 +65,16 @@ def validate(self, plan: ProvisioningPlan) -> list[Diagnostic]: if identity_diagnostics: return identity_diagnostics realization = interpret_provisioning_plan(plan, provisioner_capabilities=self._provisioner_capabilities) - return list(realization.diagnostics) + diagnostics = list(realization.diagnostics) + if self._mode is LibvirtDriverMode.TECHVAULT_APPLIANCE: + diagnostics.extend( + techvault_admission_diagnostics( + plan, + self._backend_realization_envelope, + name_prefix=self._name_prefix, + ) + ) + return diagnostics def apply(self, plan: ProvisioningPlan, snapshot: RuntimeSnapshot) -> ApplyResult: if not isinstance(plan, ProvisioningPlan): @@ -81,6 +94,14 @@ def _apply_provisioning_plan(self, plan: ProvisioningPlan, snapshot: RuntimeSnap def _apply_realization(self, plan: ProvisioningPlan, snapshot: RuntimeSnapshot) -> ApplyResult: realization = interpret_provisioning_plan(plan, provisioner_capabilities=self._provisioner_capabilities) diagnostics: list[Diagnostic] = list(realization.diagnostics) + if self._mode is LibvirtDriverMode.TECHVAULT_APPLIANCE: + diagnostics.extend( + techvault_admission_diagnostics( + plan, + self._backend_realization_envelope, + name_prefix=self._name_prefix, + ) + ) if _has_error(diagnostics): return ApplyResult(success=False, snapshot=snapshot, diagnostics=diagnostics) @@ -124,13 +145,35 @@ def _drive( domains = tuple(spec for spec in realization.domains if spec.address in active) if networks or domains: result = self._driver.realize(networks=networks, domains=domains) - diagnostics.extend(result.diagnostics) - diagnostics.extend( - _unconfirmed_realization_diagnostics( - result, - requested=tuple(spec.address for spec in (*networks, *domains)), + realization_diagnostics = [ + *result.diagnostics, + *_unconfirmed_realization_diagnostics( + result, requested=tuple(spec.address for spec in (*networks, *domains)) + ), + ] + diagnostics.extend(realization_diagnostics) + observation_diagnostics: list[Diagnostic] = [] + if self._mode is LibvirtDriverMode.TECHVAULT_APPLIANCE and not result.diagnostics: + observation_diagnostics = techvault_observation_diagnostics( + networks=networks, + domains=domains, + result=result, + ) + diagnostics.extend(observation_diagnostics) + if self._mode is LibvirtDriverMode.TECHVAULT_APPLIANCE and _has_error( + [*realization_diagnostics, *observation_diagnostics] + ): + cleanup = self._driver.destroy( + networks=tuple(spec.address for spec in networks), + domains=tuple(spec.address for spec in domains), + ) + diagnostics.extend(cleanup.diagnostics) + diagnostics.extend( + _unconfirmed_destroy_diagnostics( + cleanup, + requested=tuple(spec.address for spec in (*domains, *networks)), + ) ) - ) if delete_networks or delete_domains: result = self._driver.destroy(networks=tuple(delete_networks), domains=tuple(delete_domains)) diagnostics.extend(result.diagnostics) diff --git a/implementations/python/packages/aces_backend_libvirt/realization.py b/implementations/python/packages/aces_backend_libvirt/realization.py index 2ecb3f064..8d2f3f420 100644 --- a/implementations/python/packages/aces_backend_libvirt/realization.py +++ b/implementations/python/packages/aces_backend_libvirt/realization.py @@ -156,8 +156,8 @@ def _network_spec(resource: PlannedResource, payload: Mapping[str, object]) -> N infrastructure = _infrastructure_spec(payload) properties = infrastructure.get("properties") labels: dict[str, str] = {} - if isinstance(properties, Mapping) and properties.get("internal") is True: - labels["internal"] = "true" + if isinstance(properties, Mapping) and isinstance(properties.get("internal"), bool): + labels["internal"] = "true" if properties["internal"] else "false" cidr = properties.get("cidr") if isinstance(properties, Mapping) else None gateway = properties.get("gateway") if isinstance(properties, Mapping) else None return NetworkSpec( diff --git a/implementations/python/packages/aces_backend_libvirt/techvault_appliance.py b/implementations/python/packages/aces_backend_libvirt/techvault_appliance.py index 537edcb7d..bc47a2860 100644 --- a/implementations/python/packages/aces_backend_libvirt/techvault_appliance.py +++ b/implementations/python/packages/aces_backend_libvirt/techvault_appliance.py @@ -61,14 +61,12 @@ def make_libvirt_readable(path: Path) -> None: def _write_appliance_root(root: Path, busybox_path: Path, domain: Mapping[str, object]) -> None: bin_dir = root / "bin" etc_dir = root / "etc" / "aces" - www_dir = root / "www" - for directory in (bin_dir, etc_dir, www_dir, root / "proc", root / "sys", root / "dev", root / "tmp", root / "run"): + for directory in (bin_dir, etc_dir, root / "proc", root / "sys", root / "dev", root / "tmp", root / "run"): directory.mkdir(parents=True, exist_ok=True) shutil.copy2(busybox_path, bin_dir / "busybox") - for applet in ("sh", "mount", "mdev", "ip", "ifconfig", "httpd", "nc", "sleep", "cat", "hostname", "printf"): + for applet in ("sh", "mount", "mdev", "ip", "ifconfig", "sleep", "cat", "hostname", "printf"): (bin_dir / applet).symlink_to("busybox") (etc_dir / "domain.json").write_text(json.dumps(domain, indent=2, sort_keys=True) + "\n", encoding="utf-8") - (www_dir / "index.html").write_text(_html_status(domain), encoding="utf-8") (root / "init").write_text(_init_script(domain), encoding="utf-8") os.chmod(root / "init", 0o700) os.chmod(bin_dir / "busybox", 0o700) @@ -101,26 +99,10 @@ def _init_script(domain: Mapping[str, object]) -> str: ] ) lines.extend([" esac", "done"]) - for service in _as_sequence(domain.get("services")): - if not isinstance(service, Mapping) or str(service.get("protocol", "tcp")).lower() != "tcp": - continue - port = _int(service.get("port")) - if port > 0: - lines.append(f"httpd -p 0.0.0.0:{port} -h /www") lines.extend(["while true; do sleep 3600; done", ""]) return "\n".join(lines) -def _html_status(domain: Mapping[str, object]) -> str: - return ( - "

ACES TechVault appliance

" - f"

node={domain.get('name')}

" - f"

role={domain.get('role')}

" - f"
{json.dumps(domain, sort_keys=True)}
" - "\n" - ) - - def _cpio_newc(root: Path) -> bytes: proc = subprocess.run( ["cpio", "-o", "-H", "newc", "--quiet"], @@ -142,9 +124,5 @@ def _as_sequence(value: object) -> Sequence[object]: return value if isinstance(value, list | tuple) else () -def _int(value: object) -> int: - return value if isinstance(value, int) else 0 - - def _shell_quote(value: str) -> str: return "'" + value.replace("'", "'\"'\"'") + "'" diff --git a/implementations/python/packages/aces_backend_libvirt/techvault_concerns.py b/implementations/python/packages/aces_backend_libvirt/techvault_concerns.py new file mode 100644 index 000000000..3609ac1da --- /dev/null +++ b/implementations/python/packages/aces_backend_libvirt/techvault_concerns.py @@ -0,0 +1,483 @@ +"""Fail-closed concern admission for the bounded TechVault appliance mode.""" + +from __future__ import annotations + +import ipaddress +from collections.abc import Mapping + +from aces_contracts.diagnostics import Diagnostic, Severity +from aces_contracts.planning import ChangeAction, ProvisioningPlan, ProvisionOp +from aces_contracts.realization_envelope import ( + BackendRealizationEnvelopeModel, + ObservationStrength, + RealizationConcern, +) + +from ._payload import ( + ACCOUNT_PLACEMENT_RESOURCE_TYPE, + CONTENT_PLACEMENT_RESOURCE_TYPE, + NETWORK_RESOURCE_TYPE, + NODE_RESOURCE_TYPE, +) +from .driver import DomainSpec, DriverResult, NetworkSpec, RealizationObservation +from .realization import ( + _image_ref, + _infrastructure_spec, + _memory_mib, + _node_resources, + _resource_name, + _services, + _vcpus, +) +from .techvault_matrix import runtime_name + +_DOMAIN = "runtime" +_CODE_ACL_UNSUPPORTED = "libvirt-backend.techvault.acl-unsupported" +_CODE_GUEST_PLACEMENT_UNSUPPORTED = "libvirt-backend.techvault.guest-placement-unsupported" +_CODE_IMAGE_UNSUPPORTED = "libvirt-backend.techvault.image-unsupported" +_CODE_METADATA_UNSUPPORTED = "libvirt-backend.techvault.metadata-unsupported" +_CODE_NETWORK_EXACTNESS = "libvirt-backend.techvault.network-exactness-required" +_CODE_NAME_UNSUPPORTED = "libvirt-backend.techvault.name-unsupported" +_CODE_OBSERVATION_MISMATCH = "libvirt-backend.techvault.observation-mismatch" +_CODE_OBSERVATION_MISSING = "libvirt-backend.techvault.observation-missing" +_CODE_RESOURCE_OUT_OF_ENVELOPE = "libvirt-backend.techvault.resource-out-of-envelope" +_CODE_SERVICE_UNSUPPORTED = "libvirt-backend.techvault.service-unsupported" +_CODE_TRANSACTION_UNSUPPORTED = "libvirt-backend.techvault.transaction-unsupported" +_CODE_UPDATE_UNSUPPORTED = "libvirt-backend.techvault.update-unsupported" + +_GUEST_PLACEMENTS = frozenset( + { + ACCOUNT_PLACEMENT_RESOURCE_TYPE, + CONTENT_PLACEMENT_RESOURCE_TYPE, + "feature-binding", + } +) + + +def techvault_admission_diagnostics( + plan: ProvisioningPlan, + envelope: BackendRealizationEnvelopeModel, + *, + name_prefix: str, +) -> list[Diagnostic]: + """Reject every TechVault concern that cannot be applied and observed exactly. + + Direct provisioning-plan submission does not carry compiler-only explicitness + metadata, so each concrete value is binding at this boundary. Validation is + intentionally pure and runs before snapshot reconciliation or driver IO. + """ + + diagnostics = _transaction_diagnostics(plan) + planned_names = _planned_native_names(plan) + for operation in plan.operations: + diagnostics.extend(_operation_admission_diagnostics(operation, envelope)) + diagnostics.extend(_native_name_diagnostics(planned_names, name_prefix)) + return diagnostics + + +def _transaction_diagnostics(plan: ProvisioningPlan) -> list[Diagnostic]: + mutations = [operation for operation in plan.operations if operation.action is not ChangeAction.UNCHANGED] + mixes_delete = len(mutations) > 1 and any(operation.action is ChangeAction.DELETE for operation in mutations) + if not mixes_delete: + return [] + return [ + _diagnostic( + _CODE_TRANSACTION_UNSUPPORTED, + "runtime.libvirt.transaction", + "TechVault plans cannot combine deletion with another mutation without a verified restore path.", + ) + ] + + +def _planned_native_names(plan: ProvisioningPlan) -> list[tuple[str, str]]: + return [ + (operation.address, _resource_name(operation, operation.payload)) + for operation in plan.operations + if operation.action is not ChangeAction.DELETE + and isinstance(operation.payload, Mapping) + and operation.resource_type in {NETWORK_RESOURCE_TYPE, NODE_RESOURCE_TYPE} + ] + + +def _operation_admission_diagnostics( + operation: ProvisionOp, + envelope: BackendRealizationEnvelopeModel, +) -> list[Diagnostic]: + diagnostics: list[Diagnostic] = [] + payload = operation.payload + if operation.action is ChangeAction.UPDATE: + diagnostics.append( + _diagnostic( + _CODE_UPDATE_UNSUPPORTED, + operation.address, + "TechVault appliance updates are not supported without a verified native restore path.", + ) + ) + elif operation.action not in {ChangeAction.DELETE, ChangeAction.UNCHANGED} and isinstance(payload, Mapping): + if operation.resource_type in _GUEST_PLACEMENTS: + diagnostics.append( + _diagnostic( + _CODE_GUEST_PLACEMENT_UNSUPPORTED, + operation.address, + "TechVault appliance guest placements are unsupported and cannot be silently omitted.", + ) + ) + elif operation.resource_type == NODE_RESOURCE_TYPE: + diagnostics.extend(_node_diagnostics(operation.address, payload, envelope)) + elif operation.resource_type == NETWORK_RESOURCE_TYPE: + diagnostics.extend(_network_diagnostics(operation.address, payload)) + return diagnostics + + +def techvault_observation_diagnostics( + *, + networks: tuple[NetworkSpec, ...], + domains: tuple[DomainSpec, ...], + result: DriverResult, +) -> list[Diagnostic]: + """Require complete daemon readback; driver handles alone prove nothing.""" + + expected = _expected_observations(networks=networks, domains=domains) + observed: dict[tuple[str, str, RealizationConcern], list[RealizationObservation]] = {} + for item in result.observations: + observed.setdefault(_observation_key(item), []).append(item) + missing_by_address: set[str] = set() + mismatched_by_address: set[str] = set() + for key, expected_value in expected.items(): + candidates = observed.get(key, []) + if not candidates or any(item.source is not ObservationStrength.DAEMON_OBSERVED for item in candidates): + missing_by_address.add(key[0]) + elif ( + len(candidates) != 1 + or type(candidates[0].value) is not type(expected_value) + or candidates[0].value != expected_value + ): + mismatched_by_address.add(key[0]) + diagnostics = [ + _diagnostic( + _CODE_OBSERVATION_MISSING, + address, + "TechVault driver did not return complete daemon observations for the requested concern inventory.", + ) + for address in sorted(missing_by_address) + ] + diagnostics.extend( + _diagnostic( + _CODE_OBSERVATION_MISMATCH, + address, + "TechVault daemon observations do not match the exact requested concern values.", + ) + for address in sorted(mismatched_by_address - missing_by_address) + ) + return diagnostics + + +def techvault_spec_diagnostics( + *, + networks: tuple[NetworkSpec, ...], + domains: tuple[DomainSpec, ...], + envelope: BackendRealizationEnvelopeModel, + name_prefix: str, +) -> list[Diagnostic]: + """Apply the same concern gate to callers that invoke the driver directly.""" + + diagnostics: list[Diagnostic] = [] + diagnostics.extend(_name_diagnostics(networks, domains, name_prefix)) + network_addresses = {spec.address for spec in networks} + for spec in networks: + diagnostics.extend(_network_spec_diagnostics(spec)) + for spec in domains: + diagnostics.extend(_domain_spec_diagnostics(spec, envelope, network_addresses)) + diagnostics.extend(_network_capacity_diagnostics(networks, domains)) + return diagnostics + + +def _domain_spec_diagnostics( + spec: DomainSpec, + envelope: BackendRealizationEnvelopeModel, + network_addresses: set[str], +) -> list[Diagnostic]: + return [ + *_domain_resource_diagnostics(spec, envelope), + *_domain_image_diagnostics(spec), + *_domain_service_diagnostics(spec), + *_domain_guest_placement_diagnostics(spec), + *_domain_metadata_diagnostics(spec), + *_domain_acl_diagnostics(spec), + *_domain_network_diagnostics(spec, network_addresses), + ] + + +def _domain_resource_diagnostics( + spec: DomainSpec, + envelope: BackendRealizationEnvelopeModel, +) -> list[Diagnostic]: + configuration = envelope.configuration + memory_valid = _within(spec.memory_mib, configuration.memory_mib.minimum, configuration.memory_mib.maximum) + vcpus_valid = _within(spec.vcpus, configuration.vcpus.minimum, configuration.vcpus.maximum) + if memory_valid and vcpus_valid: + return [] + return [ + _diagnostic( + _CODE_RESOURCE_OUT_OF_ENVELOPE, + spec.address, + "TechVault appliance resource values must be inside the governed envelope and are never clamped.", + ) + ] + + +def _domain_image_diagnostics(spec: DomainSpec) -> list[Diagnostic]: + if spec.image_ref is None: + return [] + return [ + _diagnostic( + _CODE_IMAGE_UNSUPPORTED, + spec.address, + "TechVault appliance mode cannot honor a requested image and refuses image substitution.", + ) + ] + + +def _domain_service_diagnostics(spec: DomainSpec) -> list[Diagnostic]: + if not spec.services: + return [] + return [ + _diagnostic( + _CODE_SERVICE_UNSUPPORTED, + spec.address, + "TechVault appliance mode does not realize declared guest services.", + ) + ] + + +def _domain_guest_placement_diagnostics(spec: DomainSpec) -> list[Diagnostic]: + cloud_init = spec.cloud_init + has_guest_placement = cloud_init is not None and any( + ( + cloud_init.hostname not in {None, spec.name}, + bool(cloud_init.users), + bool(cloud_init.write_files), + bool(cloud_init.packages), + bool(cloud_init.runcmd), + ) + ) + if not has_guest_placement: + return [] + return [ + _diagnostic( + _CODE_GUEST_PLACEMENT_UNSUPPORTED, + spec.address, + "TechVault appliance mode does not realize cloud-init guest placements.", + ) + ] + + +def _domain_metadata_diagnostics(spec: DomainSpec) -> list[Diagnostic]: + if not spec.labels: + return [] + return [ + _diagnostic( + _CODE_METADATA_UNSUPPORTED, + spec.address, + "TechVault appliance mode does not consume unbound domain metadata.", + ) + ] + + +def _domain_acl_diagnostics(spec: DomainSpec) -> list[Diagnostic]: + if not spec.network_acls: + return [] + return [ + _diagnostic( + _CODE_ACL_UNSUPPORTED, + spec.address, + "TechVault appliance mode does not realize declared network ACLs.", + ) + ] + + +def _domain_network_diagnostics(spec: DomainSpec, network_addresses: set[str]) -> list[Diagnostic]: + if all(address in network_addresses for address in spec.networks): + return [] + return [_network_exactness_diagnostic(spec.address)] + + +def _name_diagnostics( + networks: tuple[NetworkSpec, ...], + domains: tuple[DomainSpec, ...], + name_prefix: str, +) -> list[Diagnostic]: + return _native_name_diagnostics([(spec.address, spec.name) for spec in (*networks, *domains)], name_prefix) + + +def _native_name_diagnostics(names: list[tuple[str, str]], name_prefix: str) -> list[Diagnostic]: + diagnostics: list[Diagnostic] = [] + native_names: set[str] = set() + for address, name in names: + exact_name = f"{name_prefix}-{name}" if name_prefix else name + selected_name = runtime_name(name_prefix, address, name) + if not name or selected_name != exact_name or selected_name in native_names: + diagnostics.append( + _diagnostic( + _CODE_NAME_UNSUPPORTED, + address, + "TechVault native names must be unique, libvirt-safe, and realizable without normalization.", + ) + ) + native_names.add(selected_name) + return diagnostics + + +def _network_capacity_diagnostics( + networks: tuple[NetworkSpec, ...], + domains: tuple[DomainSpec, ...], +) -> list[Diagnostic]: + diagnostics: list[Diagnostic] = [] + for spec in networks: + if not spec.cidr or not spec.gateway: + continue + try: + network = ipaddress.ip_network(spec.cidr, strict=True) + gateway = ipaddress.ip_address(spec.gateway) + except ValueError: + continue + if not isinstance(network, ipaddress.IPv4Network): + continue + attachment_count = sum(spec.address in domain.networks for domain in domains) + candidates = [network.network_address + offset for offset in range(10, 10 + attachment_count)] + if any( + candidate not in network or candidate == network.broadcast_address or candidate == gateway + for candidate in candidates + ): + diagnostics.append(_network_exactness_diagnostic(spec.address)) + return diagnostics + + +def _network_spec_diagnostics(spec: NetworkSpec) -> list[Diagnostic]: + labels_valid = set(spec.labels) == {"internal"} and spec.labels.get("internal") in {"true", "false"} + valid = labels_valid and _valid_ipv4_network(spec.cidr, spec.gateway) + return [] if valid else [_network_exactness_diagnostic(spec.address)] + + +def _expected_observations( + *, + networks: tuple[NetworkSpec, ...], + domains: tuple[DomainSpec, ...], +) -> dict[tuple[str, str, RealizationConcern], object]: + expected: dict[tuple[str, str, RealizationConcern], object] = {} + for spec in networks: + expected[(spec.address, "exists", RealizationConcern.TOPOLOGY)] = True + expected[(spec.address, "cidr", RealizationConcern.NETWORK)] = spec.cidr + expected[(spec.address, "gateway", RealizationConcern.NETWORK)] = spec.gateway + expected[(spec.address, "internal", RealizationConcern.NETWORK)] = spec.labels.get("internal") == "true" + expected[(spec.address, "forward-mode", RealizationConcern.NETWORK)] = ( + "none" if spec.labels.get("internal") == "true" else "nat" + ) + for spec in domains: + expected[(spec.address, "exists", RealizationConcern.TOPOLOGY)] = True + expected[(spec.address, "architecture", RealizationConcern.ARCHITECTURE)] = "x86_64" + expected[(spec.address, "image-policy", RealizationConcern.IMAGE)] = "generated-initramfs-appliance" + expected[(spec.address, "memory-mib", RealizationConcern.RESOURCE_ALLOCATION)] = spec.memory_mib + expected[(spec.address, "vcpus", RealizationConcern.RESOURCE_ALLOCATION)] = spec.vcpus + expected[(spec.address, "network-attachments", RealizationConcern.NETWORK)] = tuple(spec.networks) + return expected + + +def _observation_key(observation: RealizationObservation) -> tuple[str, str, RealizationConcern]: + return observation.address, observation.field_path, observation.concern + + +def _node_diagnostics( + address: str, + payload: Mapping[str, object], + envelope: BackendRealizationEnvelopeModel, +) -> list[Diagnostic]: + diagnostics: list[Diagnostic] = [] + configuration = envelope.configuration + resources = _node_resources(payload) + memory_mib = _memory_mib(resources.get("ram")) + vcpus = _vcpus(resources.get("cpu")) + if not _within(memory_mib, configuration.memory_mib.minimum, configuration.memory_mib.maximum) or not _within( + vcpus, configuration.vcpus.minimum, configuration.vcpus.maximum + ): + diagnostics.append( + _diagnostic( + _CODE_RESOURCE_OUT_OF_ENVELOPE, + address, + "TechVault appliance resource values must be inside the governed envelope and are never clamped.", + ) + ) + if _image_ref(payload) is not None: + diagnostics.append( + _diagnostic( + _CODE_IMAGE_UNSUPPORTED, + address, + "TechVault appliance mode cannot honor a requested image and refuses image substitution.", + ) + ) + if _services(payload): + diagnostics.append( + _diagnostic( + _CODE_SERVICE_UNSUPPORTED, + address, + "TechVault appliance mode does not realize declared guest services.", + ) + ) + acls = _infrastructure_spec(payload).get("acls") + if isinstance(acls, list | tuple) and acls: + diagnostics.append( + _diagnostic( + _CODE_ACL_UNSUPPORTED, + address, + "TechVault appliance mode does not realize declared network ACLs.", + ) + ) + return diagnostics + + +def _network_diagnostics(address: str, payload: Mapping[str, object]) -> list[Diagnostic]: + properties = _infrastructure_spec(payload).get("properties") + valid = False + if isinstance(properties, Mapping): + valid = isinstance(properties.get("internal"), bool) and _valid_ipv4_network( + properties.get("cidr"), properties.get("gateway") + ) + return [] if valid else [_network_exactness_diagnostic(address)] + + +def _valid_ipv4_network(cidr: object, gateway: object) -> bool: + if not isinstance(cidr, str) or not isinstance(gateway, str): + return False + try: + network = ipaddress.ip_network(cidr, strict=True) + parsed_gateway = ipaddress.ip_address(gateway) + except ValueError: + return False + return ( + isinstance(network, ipaddress.IPv4Network) + and parsed_gateway in network + and parsed_gateway not in {network.network_address, network.broadcast_address} + ) + + +def _within(value: int, minimum: int, maximum: int | None) -> bool: + return value >= minimum and (maximum is None or value <= maximum) + + +def _network_exactness_diagnostic(address: str) -> Diagnostic: + return _diagnostic( + _CODE_NETWORK_EXACTNESS, + address, + "TechVault appliance networks require explicit valid IPv4 CIDR, gateway, and internal values.", + ) + + +def _diagnostic(code: str, address: str, message: str) -> Diagnostic: + return Diagnostic(code=code, domain=_DOMAIN, address=address, message=message, severity=Severity.ERROR) + + +__all__ = [ + "techvault_admission_diagnostics", + "techvault_observation_diagnostics", + "techvault_spec_diagnostics", +] diff --git a/implementations/python/packages/aces_backend_libvirt/techvault_lifecycle.py b/implementations/python/packages/aces_backend_libvirt/techvault_lifecycle.py new file mode 100644 index 000000000..a56399ca2 --- /dev/null +++ b/implementations/python/packages/aces_backend_libvirt/techvault_lifecycle.py @@ -0,0 +1,151 @@ +"""Ownership-safe native resource lookup and removal for TechVault.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from .drivers.libvirt import _aces_uuid, _error_code, _existing_uuid +from .techvault_matrix import runtime_name + + +class NativeOwnershipConflict(Exception): + """A native name is not owned by the requested ACES address.""" + + +@dataclass(frozen=True) +class NativeResolution: + native: object | None + name: str | None + + +def resolve_native( + connection: object, + lookup_method: str, + list_method: str, + address: str, + *, + known_name: str | None, + name_prefix: str, +) -> NativeResolution | None: + lookup = getattr(connection, lookup_method, None) + if not callable(lookup): + return None + if known_name is None: + return _resolve_by_uuid(connection, list_method, address, name_prefix) + return _resolve_by_name(connection, lookup, list_method, address, known_name) + + +def _resolve_by_uuid( + connection: object, + list_method: str, + address: str, + name_prefix: str, +) -> NativeResolution | None: + native_items = _list_native(connection, list_method) + resolved: NativeResolution | None = None + if native_items is not None: + owned = [item for item in native_items if _existing_uuid(item) == _aces_uuid(address)] + if not owned: + fallback_name = runtime_name(name_prefix, address) + if any(_native_name(item) == fallback_name for item in native_items): + raise NativeOwnershipConflict(address) + resolved = NativeResolution(native=None, name=None) + elif len(owned) == 1: + name = _native_name(owned[0]) + if name: + resolved = NativeResolution(native=owned[0], name=name) + return resolved + + +def _resolve_by_name( + connection: object, + lookup: Callable[[str], object], + list_method: str, + address: str, + name: str, +) -> NativeResolution | None: + resolved: NativeResolution | None = None + try: + native = lookup(name) + except KeyError: + resolved = _resolve_verified_absence(connection, list_method, address) + except Exception as exc: + if _error_code(exc) in {42, 43}: + resolved = _resolve_verified_absence(connection, list_method, address) + else: + resolved = NativeResolution(native=native, name=name) + return resolved + + +def _resolve_verified_absence( + connection: object, + list_method: str, + address: str, +) -> NativeResolution | None: + native_items = _list_native(connection, list_method) + if native_items is None or any(_existing_uuid(item) == _aces_uuid(address) for item in native_items): + return None + return NativeResolution(native=None, name=None) + + +def verify_native_removed( + connection: object, + list_method: str, + address: str, + name: str | None, +) -> bool: + native_items = _list_native(connection, list_method) + return native_items is not None and not any( + _native_name(item) == name or _existing_uuid(item) == _aces_uuid(address) for item in native_items + ) + + +def deactivate_and_undefine(native: object) -> bool: + return _invoke_native_action(native, "destroy", {42, 43, 55}) and _invoke_native_action( + native, + "undefine", + {42, 43}, + ) + + +def _invoke_native_action(native: object, method_name: str, tolerated_codes: set[int]) -> bool: + method = getattr(native, method_name, None) + if not callable(method): + return False + try: + method() + except Exception as exc: + return _error_code(exc) in tolerated_codes + return True + + +def _list_native(connection: object, method_name: str) -> tuple[object, ...] | None: + method = getattr(connection, method_name, None) + if not callable(method): + return None + try: + native = method() + except Exception: + return None + return tuple(native) if isinstance(native, list | tuple) else None + + +def _native_name(native: object) -> str: + method = getattr(native, "name", None) + if not callable(method): + return "" + try: + value = method() + except Exception: + return "" + return value if isinstance(value, str) else "" + + +__all__ = [ + "NativeOwnershipConflict", + "NativeResolution", + "deactivate_and_undefine", + "resolve_native", + "verify_native_removed", +] diff --git a/implementations/python/packages/aces_backend_libvirt/techvault_matrix.py b/implementations/python/packages/aces_backend_libvirt/techvault_matrix.py new file mode 100644 index 000000000..72ae93e4b --- /dev/null +++ b/implementations/python/packages/aces_backend_libvirt/techvault_matrix.py @@ -0,0 +1,178 @@ +"""Pure TechVault appliance matrix and structured libvirt XML rendering.""" + +from __future__ import annotations + +import hashlib +import ipaddress +import re +import xml.etree.ElementTree as ET +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import cast + +from .driver import DomainSpec, NetworkSpec +from .drivers.libvirt import _aces_uuid + +_SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9_.-]+") +_SUBSTRATE = "libvirt-qemu-initramfs" + + +def native_matrix( + *, + networks: tuple[NetworkSpec, ...], + domains: tuple[DomainSpec, ...], + name_prefix: str, +) -> dict[str, object]: + runtime_networks = [runtime_network(spec, name_prefix) for spec in networks] + runtime_network_by_address = {str(item["address"]): item for item in runtime_networks} + allocations = allocate_interfaces(domains, runtime_network_by_address, name_prefix) + runtime_domains = [ + runtime_domain(spec, name_prefix=name_prefix, interfaces=allocations.get(spec.address, ())) for spec in domains + ] + return { + "substrate": _SUBSTRATE, + "networks": runtime_networks, + "domains": runtime_domains, + } + + +def runtime_network(spec: NetworkSpec, name_prefix: str) -> dict[str, object]: + if spec.cidr is None or spec.gateway is None: + raise ValueError("TechVault network values must be explicit") + network = ipaddress.ip_network(spec.cidr, strict=True) + gateway = ipaddress.ip_address(spec.gateway) + if not isinstance(network, ipaddress.IPv4Network) or not isinstance(gateway, ipaddress.IPv4Address): + raise ValueError("TechVault appliance supports explicit IPv4 networks only") + return { + "address": spec.address, + "name": spec.name, + "runtime_name": runtime_name(name_prefix, spec.address, spec.name), + "cidr": str(network), + "gateway": str(gateway), + "netmask": str(network.netmask), + "internal": spec.labels.get("internal") == "true", + "hosts": [], + } + + +def allocate_interfaces( + domains: tuple[DomainSpec, ...], + networks: Mapping[str, dict[str, object]], + name_prefix: str, +) -> dict[str, tuple[dict[str, object], ...]]: + allocations: dict[str, list[dict[str, object]]] = {domain.address: [] for domain in domains} + next_host: dict[str, int] = dict.fromkeys(networks, 10) + for domain in domains: + for network_address in domain.networks: + network = networks[network_address] + parsed = ipaddress.ip_network(str(network["cidr"]), strict=True) + offset = next_host[network_address] + next_host[network_address] = offset + 1 + ip = str(parsed.network_address + offset) + mac = mac_address(domain.address, network_address) + interface = { + "network_address": network_address, + "network_name": network.get("name"), + "runtime_network": network.get("runtime_name"), + "ip": ip, + "cidr_prefix": parsed.prefixlen, + "gateway": network.get("gateway"), + "mac": mac, + } + allocations[domain.address].append(interface) + cast(list[dict[str, object]], network["hosts"]).append( + {"name": runtime_name(name_prefix, domain.address, domain.name), "mac": mac, "ip": ip} + ) + return {address: tuple(items) for address, items in allocations.items()} + + +def runtime_domain( + spec: DomainSpec, + *, + name_prefix: str, + interfaces: tuple[dict[str, object], ...], +) -> dict[str, object]: + return { + "address": spec.address, + "name": spec.name, + "runtime_name": runtime_name(name_prefix, spec.address, spec.name), + "memory_mib": spec.memory_mib, + "vcpus": spec.vcpus, + "interfaces": list(interfaces), + } + + +def network_xml(network: Mapping[str, object]) -> str: + root = ET.Element("network") + ET.SubElement(root, "name").text = str(network.get("runtime_name", "")) + ET.SubElement(root, "uuid").text = _aces_uuid(str(network.get("address", ""))) + if not network.get("internal"): + ET.SubElement(root, "forward", {"mode": "nat"}) + ip_node = ET.SubElement( + root, + "ip", + {"address": str(network.get("gateway", "")), "netmask": str(network.get("netmask", ""))}, + ) + dhcp = ET.SubElement(ip_node, "dhcp") + for host in as_sequence(network.get("hosts")): + if isinstance(host, Mapping): + ET.SubElement( + dhcp, + "host", + {"mac": str(host.get("mac", "")), "name": str(host.get("name", "")), "ip": str(host.get("ip", ""))}, + ) + return ET.tostring(root, encoding="unicode") + + +def domain_xml(domain: Mapping[str, object], *, kernel: Path, initrd: Path) -> str: + root = ET.Element("domain", {"type": "qemu"}) + ET.SubElement(root, "name").text = str(domain.get("runtime_name", "")) + ET.SubElement(root, "uuid").text = _aces_uuid(str(domain.get("address", ""))) + ET.SubElement(root, "memory", {"unit": "MiB"}).text = str(domain.get("memory_mib", 128)) + ET.SubElement(root, "vcpu").text = str(domain.get("vcpus", 1)) + os_node = ET.SubElement(root, "os") + ET.SubElement(os_node, "type", {"arch": "x86_64"}).text = "hvm" + ET.SubElement(os_node, "kernel").text = str(kernel) + ET.SubElement(os_node, "initrd").text = str(initrd) + ET.SubElement(os_node, "cmdline").text = "console=ttyS0 panic=-1 aces.appliance=techvault" + features = ET.SubElement(root, "features") + ET.SubElement(features, "acpi") + devices = ET.SubElement(root, "devices") + ET.SubElement(devices, "emulator").text = "/usr/bin/qemu-system-x86_64" + serial = ET.SubElement(devices, "serial", {"type": "pty"}) + ET.SubElement(serial, "target", {"port": "0"}) + console = ET.SubElement(devices, "console", {"type": "pty"}) + ET.SubElement(console, "target", {"type": "serial", "port": "0"}) + for interface_spec in as_sequence(domain.get("interfaces")): + if not isinstance(interface_spec, Mapping): + continue + interface = ET.SubElement(devices, "interface", {"type": "network"}) + ET.SubElement(interface, "mac", {"address": str(interface_spec.get("mac", ""))}) + ET.SubElement(interface, "source", {"network": str(interface_spec.get("runtime_network", ""))}) + ET.SubElement(interface, "model", {"type": "virtio"}) + return ET.tostring(root, encoding="unicode") + + +def runtime_name(prefix: str, address: str, preferred: str | None = None) -> str: + return safe_name(preferred or address.rsplit(".", 1)[-1], fallback=address.rsplit(".", 1)[-1], prefix=prefix) + + +def safe_name(candidate: str, *, fallback: str, prefix: str) -> str: + raw = candidate.strip() or fallback.strip() or "resource" + normalized = _SAFE_NAME_RE.sub("-", raw).strip("-._") + if not normalized: + normalized = _SAFE_NAME_RE.sub("-", fallback).strip("-._") or "resource" + prefixed = f"{prefix}-{normalized}" if prefix else normalized + return prefixed[:63].strip("-._") or "resource" + + +def mac_address(domain_address: str, network_address: str) -> str: + digest = hashlib.sha256(f"{domain_address}|{network_address}".encode()).digest() + return "52:54:00:" + ":".join(f"{byte:02x}" for byte in digest[:3]) + + +def as_sequence(value: object) -> Sequence[object]: + return value if isinstance(value, list | tuple) else () + + +__all__ = ["as_sequence", "domain_xml", "native_matrix", "network_xml", "runtime_name", "safe_name"] diff --git a/implementations/python/packages/aces_backend_libvirt/techvault_native.py b/implementations/python/packages/aces_backend_libvirt/techvault_native.py index 707d54d64..9f7478fa7 100644 --- a/implementations/python/packages/aces_backend_libvirt/techvault_native.py +++ b/implementations/python/packages/aces_backend_libvirt/techvault_native.py @@ -8,28 +8,67 @@ from __future__ import annotations -import hashlib -import ipaddress -import json import os -import re -import xml.etree.ElementTree as ET from collections.abc import Callable, Mapping, Sequence from contextlib import suppress from dataclasses import dataclass, field from pathlib import Path from typing import ClassVar, Protocol, cast +from urllib.parse import urlsplit from aces_contracts.diagnostics import Diagnostic, Severity -from .driver import DomainHandle, DomainSpec, DriverResult, NetworkHandle, NetworkSpec, ServiceSpec -from .drivers.libvirt import Connector +from .driver import ( + DomainHandle, + DomainSpec, + DriverResult, + NetworkHandle, + NetworkSpec, + RealizationObservation, +) +from .drivers.libvirt import Connector, _aces_uuid, _error_code, _existing_uuid +from .envelopes import load_libvirt_realization_envelope from .techvault_appliance import ( BusyboxInitramfsBuilder, InitramfsBuilder, copy_kernel_for_libvirt, make_libvirt_readable, ) +from .techvault_concerns import techvault_observation_diagnostics, techvault_spec_diagnostics +from .techvault_lifecycle import ( + NativeOwnershipConflict as _OwnershipConflict, +) +from .techvault_lifecycle import ( + deactivate_and_undefine as _deactivate_and_undefine, +) +from .techvault_lifecycle import ( + resolve_native as _resolve_native, +) +from .techvault_lifecycle import ( + verify_native_removed as _verify_native_removed, +) +from .techvault_matrix import ( + as_sequence as _as_sequence, +) +from .techvault_matrix import ( + domain_xml as _domain_xml, +) +from .techvault_matrix import ( + native_matrix as _native_matrix, +) +from .techvault_matrix import ( + network_xml as _network_xml, +) +from .techvault_matrix import ( + safe_name as _safe_name, +) +from .techvault_observation import ( + canonical_digest, + domain_observations, + file_digest, + network_observations, + snapshot_from_observations, +) from .techvault_probe import ( NativeLibvirtProbe, ProbeResult, @@ -40,10 +79,11 @@ _DOMAIN = "runtime" _CODE_OPERATION_FAILED = "libvirt-backend.techvault-native.operation-failed" +_CODE_OWNERSHIP_CONFLICT = "libvirt-backend.techvault-native.ownership-conflict" +_CODE_READBACK_FAILED = "libvirt-backend.techvault-native.readback-failed" +_CODE_RESIDUAL_STATE = "libvirt-backend.techvault-native.residual-state" _CODE_UNAVAILABLE = "libvirt-backend.techvault-native.unavailable" _DEFAULT_CONNECTION_URI = "qemu:///system" -_SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9_.-]+") -_SUBSTRATE = "libvirt-qemu-initramfs" __all__ = [ "BusyboxInitramfsBuilder", "NativeLibvirtProbe", @@ -76,24 +116,31 @@ class TechVaultNativeLibvirtDriver: name_prefix: str = "aces-techvault" kernel_path: Path | None = None initramfs_builder: InitramfsBuilder = field(default_factory=BusyboxInitramfsBuilder) - appliance_memory_mib: int = 128 define_only: bool = False clean_existing: bool = False last_snapshot: dict[str, object] = field(default_factory=dict) - last_matrix: dict[str, object] = field(default_factory=dict) def __post_init__(self) -> None: if not self.connection_uri or not self.connection_uri.strip(): raise ValueError("TechVaultNativeLibvirtDriver connection_uri must be non-empty.") + parsed_uri = urlsplit(self.connection_uri) + if parsed_uri.username is not None or parsed_uri.password is not None: + raise ValueError("TechVaultNativeLibvirtDriver connection URI must not carry credentials.") if not self.name_prefix or not self.name_prefix.strip(): raise ValueError("TechVaultNativeLibvirtDriver name_prefix must be non-empty.") + if self.define_only: + raise ValueError("TechVaultNativeLibvirtDriver define-only mode cannot make realization claims.") + if self.clean_existing: + raise ValueError("TechVaultNativeLibvirtDriver refuses unsafe prefix-wide cleanup.") + safe_prefix = _safe_name(self.name_prefix, fallback="aces-techvault", prefix="") + if safe_prefix != self.name_prefix: + raise ValueError("TechVaultNativeLibvirtDriver name_prefix must already be libvirt-safe.") self.state_dir = Path(self.state_dir) self.kernel_path = Path(self.kernel_path) if self.kernel_path is not None else _default_kernel_path() - self.name_prefix = _safe_name(self.name_prefix, fallback="aces-techvault", prefix="") - self.appliance_memory_mib = max(64, int(self.appliance_memory_mib)) self.connector = self.connector or _default_connector self._names: dict[str, str] = {} self._realized: set[str] = set() + self._artifacts: dict[str, tuple[Path, ...]] = {} def realize( self, @@ -101,82 +148,230 @@ def realize( networks: tuple[NetworkSpec, ...], domains: tuple[DomainSpec, ...], ) -> DriverResult: - self.state_dir.mkdir(parents=True, exist_ok=True) + envelope = load_libvirt_realization_envelope(self.driver_mode) + spec_diagnostics = techvault_spec_diagnostics( + networks=networks, + domains=domains, + envelope=envelope, + name_prefix=self.name_prefix, + ) + if spec_diagnostics: + return DriverResult(diagnostics=tuple(spec_diagnostics)) matrix = _native_matrix(networks=networks, domains=domains, name_prefix=self.name_prefix) - self.last_matrix = matrix + self.state_dir.mkdir(parents=True, exist_ok=True) try: connection = self._conn() except Exception: return DriverResult(diagnostics=(_diagnostic(_CODE_UNAVAILABLE, "runtime.libvirt.connection"),)) - if self.clean_existing: - _destroy_existing_with_prefix(connection, self.name_prefix) + return self._realize_matrix( + connection, + matrix, + networks=networks, + domains=domains, + envelope_digest=envelope.digest, + configuration_digest=envelope.configuration.configuration_digest, + ) - network_handles, network_diagnostics = self._define_networks(connection, matrix) - domain_handles, domain_diagnostics = self._define_domains(connection, matrix) - diagnostics = network_diagnostics + domain_diagnostics - if diagnostics: - self._rollback(connection, network_handles, domain_handles) - return DriverResult(diagnostics=tuple(diagnostics)) - self.last_snapshot = _snapshot_from_matrix(matrix, domain_handles, network_handles) - return DriverResult(networks=tuple(network_handles), domains=tuple(domain_handles)) + def _realize_matrix( + self, + connection: object, + matrix: Mapping[str, object], + *, + networks: tuple[NetworkSpec, ...], + domains: tuple[DomainSpec, ...], + envelope_digest: str, + configuration_digest: str, + ) -> DriverResult: + network_handles, network_diagnostics, network_observations = self._define_networks(connection, matrix) + if network_diagnostics: + network_diagnostics.extend(self._rollback(connection, network_handles, ())) + return DriverResult(diagnostics=tuple(network_diagnostics)) + domain_handles, domain_diagnostics, domain_observations = self._define_domains(connection, matrix) + if domain_diagnostics: + domain_diagnostics.extend(self._rollback(connection, network_handles, domain_handles)) + return DriverResult(diagnostics=tuple(domain_diagnostics)) + observations = (*network_observations, *domain_observations) + return self._verify_and_finalize( + connection, + matrix, + specs=(networks, domains), + handles=(network_handles, domain_handles), + observations=observations, + envelope_digest=envelope_digest, + configuration_digest=configuration_digest, + ) + + def _verify_and_finalize( + self, + connection: object, + matrix: Mapping[str, object], + *, + specs: tuple[tuple[NetworkSpec, ...], tuple[DomainSpec, ...]], + handles: tuple[list[NetworkHandle], list[DomainHandle]], + observations: tuple[RealizationObservation, ...], + envelope_digest: str, + configuration_digest: str, + ) -> DriverResult: + networks, domains = specs + network_handles, domain_handles = handles + observation_diagnostics = techvault_observation_diagnostics( + networks=networks, + domains=domains, + result=DriverResult(observations=observations), + ) + if observation_diagnostics: + observation_diagnostics.extend(self._rollback(connection, network_handles, domain_handles)) + return DriverResult(diagnostics=tuple(observation_diagnostics)) + try: + binding = self._material_binding(envelope_digest, configuration_digest) + snapshot = snapshot_from_observations(matrix, observations, binding=binding) + except Exception: + binding_diagnostics = [_diagnostic(_CODE_OPERATION_FAILED, "runtime.libvirt.binding")] + binding_diagnostics.extend(self._rollback(connection, network_handles, domain_handles)) + return DriverResult(diagnostics=tuple(binding_diagnostics)) + self.last_snapshot = snapshot + return DriverResult( + networks=tuple(network_handles), + domains=tuple(domain_handles), + observations=observations, + ) def _define_networks( self, connection: object, matrix: Mapping[str, object] - ) -> tuple[list[NetworkHandle], list[Diagnostic]]: + ) -> tuple[list[NetworkHandle], list[Diagnostic], list[RealizationObservation]]: handles: list[NetworkHandle] = [] diagnostics: list[Diagnostic] = [] + observations: list[RealizationObservation] = [] for network in _as_sequence(matrix.get("networks")): if isinstance(network, Mapping): - handle = self._define_network(connection, network) - if isinstance(handle, NetworkHandle): + handle, diagnostic, observed = self._define_network(connection, network) + if handle is not None: handles.append(handle) - else: - diagnostics.append(handle) - return handles, diagnostics - - def _define_network(self, connection: object, network: Mapping[str, object]) -> NetworkHandle | Diagnostic: + if diagnostic is not None: + diagnostics.append(diagnostic) + observations.extend(observed) + if diagnostic is not None: + break + return handles, diagnostics, observations + + def _define_network( + self, connection: object, network: Mapping[str, object] + ) -> tuple[NetworkHandle | None, Diagnostic | None, tuple[RealizationObservation, ...]]: address = str(network.get("address", "")) + native: _NativeResource | None = None + handle: NetworkHandle | None = None + diagnostic: Diagnostic | None = None + observations: tuple[RealizationObservation, ...] = () + self._names[address] = str(network.get("runtime_name", "")) try: + _ensure_name_available( + connection, + "networkLookupByName", + str(network.get("runtime_name", "")), + address, + ) native = _call(connection, "networkDefineXML", _network_xml(network)) if not self.define_only: native.create() + except _OwnershipConflict: + self._names.pop(address, None) + diagnostic = _diagnostic(_CODE_OWNERSHIP_CONFLICT, address) except Exception: - return _diagnostic(_CODE_OPERATION_FAILED, address) - self._names[address] = str(network.get("runtime_name", "")) - self._realized.add(address) - return NetworkHandle(address=address, realized=True) + if native is None: + self._names.pop(address, None) + else: + self._realized.add(address) + handle = NetworkHandle(address=address) + diagnostic = _diagnostic(_CODE_OPERATION_FAILED, address) + else: + self._realized.add(address) + handle = NetworkHandle(address=address, realized=True) + try: + observations = network_observations(native, network) + except Exception: + diagnostic = _diagnostic(_CODE_READBACK_FAILED, address) + return handle, diagnostic, observations def _define_domains( self, connection: object, matrix: Mapping[str, object] - ) -> tuple[list[DomainHandle], list[Diagnostic]]: + ) -> tuple[list[DomainHandle], list[Diagnostic], list[RealizationObservation]]: handles: list[DomainHandle] = [] diagnostics: list[Diagnostic] = [] + observations: list[RealizationObservation] = [] + network_addresses = { + str(item.get("runtime_name", "")): str(item.get("address", "")) + for item in _as_sequence(matrix.get("networks")) + if isinstance(item, Mapping) + } for domain in _as_sequence(matrix.get("domains")): if isinstance(domain, Mapping): - handle = self._define_domain(connection, domain) - if isinstance(handle, DomainHandle): + handle, diagnostic, observed = self._define_domain(connection, domain, network_addresses) + if handle is not None: handles.append(handle) - else: - diagnostics.append(handle) - return handles, diagnostics - - def _define_domain(self, connection: object, domain: Mapping[str, object]) -> DomainHandle | Diagnostic: + if diagnostic is not None: + diagnostics.append(diagnostic) + observations.extend(observed) + if diagnostic is not None: + break + return handles, diagnostics, observations + + def _define_domain( + self, + connection: object, + domain: Mapping[str, object], + network_addresses: Mapping[str, str], + ) -> tuple[DomainHandle | None, Diagnostic | None, tuple[RealizationObservation, ...]]: address = str(domain.get("address", "")) + native: _NativeResource | None = None + handle: DomainHandle | None = None + diagnostic: Diagnostic | None = None + observations: tuple[RealizationObservation, ...] = () + self._names[address] = str(domain.get("runtime_name", "")) try: - kernel = copy_kernel_for_libvirt(self.kernel_path, self.state_dir / "kernel" / self.kernel_path.name) + _ensure_name_available( + connection, + "lookupByName", + str(domain.get("runtime_name", "")), + address, + ) + kernel = copy_kernel_for_libvirt( + self.kernel_path, + self.state_dir / "kernel" / f"{_artifact_token(address)}-{self.kernel_path.name}", + ) initrd = self.initramfs_builder.build( domain=domain, - target=self.state_dir / "initramfs" / f"{domain.get('runtime_name')}.cpio.gz", + target=self.state_dir / "initramfs" / f"{_artifact_token(address)}.cpio.gz", ) make_libvirt_readable(initrd) + self._artifacts[address] = (kernel, initrd) native = _call(connection, "defineXML", _domain_xml(domain, kernel=kernel, initrd=initrd)) if not self.define_only: native.create() + except _OwnershipConflict: + self._names.pop(address, None) + diagnostic = _diagnostic(_CODE_OWNERSHIP_CONFLICT, address) except Exception: - return _diagnostic(_CODE_OPERATION_FAILED, address) - self._names[address] = str(domain.get("runtime_name", "")) - self._realized.add(address) - return DomainHandle(address=address, realized=True) + if native is None: + self._cleanup_artifacts(address) + self._names.pop(address, None) + else: + self._realized.add(address) + handle = DomainHandle(address=address) + diagnostic = _diagnostic(_CODE_OPERATION_FAILED, address) + else: + self._realized.add(address) + handle = DomainHandle(address=address, realized=True) + try: + observations = domain_observations( + native, + domain, + network_addresses, + kernel=kernel, + initrd=initrd, + ) + except Exception: + diagnostic = _diagnostic(_CODE_READBACK_FAILED, address) + return handle, diagnostic, observations def destroy( self, @@ -192,15 +387,27 @@ def destroy( network_handles: list[NetworkHandle] = [] diagnostics: list[Diagnostic] = [] for address in domains: - ok = self._destroy_one(connection, "lookupByName", address) + try: + ok = self._destroy_one(connection, "lookupByName", address) + except _OwnershipConflict: + diagnostics.append(_diagnostic(_CODE_OWNERSHIP_CONFLICT, address)) + domain_handles.append(DomainHandle(address=address, realized=True)) + continue if not ok: - diagnostics.append(_diagnostic(_CODE_OPERATION_FAILED, address)) + diagnostics.append(_diagnostic(_CODE_RESIDUAL_STATE, address)) domain_handles.append(DomainHandle(address=address, realized=not ok)) for address in networks: - ok = self._destroy_one(connection, "networkLookupByName", address) + try: + ok = self._destroy_one(connection, "networkLookupByName", address) + except _OwnershipConflict: + diagnostics.append(_diagnostic(_CODE_OWNERSHIP_CONFLICT, address)) + network_handles.append(NetworkHandle(address=address, realized=True)) + continue if not ok: - diagnostics.append(_diagnostic(_CODE_OPERATION_FAILED, address)) + diagnostics.append(_diagnostic(_CODE_RESIDUAL_STATE, address)) network_handles.append(NetworkHandle(address=address, realized=not ok)) + if (networks or domains) and not diagnostics: + self.last_snapshot = {} return DriverResult( networks=tuple(network_handles), domains=tuple(domain_handles), @@ -219,185 +426,95 @@ def _conn(self) -> object: return self.connection def _destroy_one(self, connection: object, lookup_method: str, address: str) -> bool: - try: - native = _call( - connection, lookup_method, self._names.get(address, _runtime_name(self.name_prefix, address)) - ) - native.destroy() - native.undefine() - except Exception: - return False + list_method = "listAllDomains" if lookup_method == "lookupByName" else "listAllNetworks" + resolved = _resolve_native( + connection, + lookup_method, + list_method, + address, + known_name=self._names.get(address), + name_prefix=self.name_prefix, + ) + cleaned = False + if resolved is not None: + if resolved.native is None: + cleaned = True + else: + if _existing_uuid(resolved.native) != _aces_uuid(address): + raise _OwnershipConflict(address) + removed = _deactivate_and_undefine(resolved.native) + cleaned = removed and _verify_native_removed( + connection, + list_method, + address, + resolved.name, + ) + if cleaned: + self._record_verified_absence(address) + return cleaned + + def _record_verified_absence(self, address: str) -> None: self._realized.discard(address) self._names.pop(address, None) - return True + self._cleanup_artifacts(address) + + def _cleanup_artifacts(self, address: str) -> None: + token = _artifact_token(address) + paths = set(self._artifacts.pop(address, ())) + paths.add(self.state_dir / "initramfs" / f"{token}.cpio.gz") + paths.update((self.state_dir / "kernel").glob(f"{token}-*")) + for path in paths: + with suppress(OSError): + path.unlink() + + def _material_binding(self, envelope_digest: str, configuration_digest: str) -> dict[str, object]: + kernel_digests = {address: file_digest(paths[0]) for address, paths in sorted(self._artifacts.items())} + initramfs_digests = {address: file_digest(paths[1]) for address, paths in sorted(self._artifacts.items())} + boot_artifacts = { + "kernel": canonical_digest(kernel_digests), + "initramfs": canonical_digest(initramfs_digests), + } + material = { + "driver": self.driver_mode, + "configuration_digest": configuration_digest, + "boot_artifact_digests": boot_artifacts, + "connection_uri_digest": canonical_digest(self.connection_uri), + "name_prefix_digest": canonical_digest(self.name_prefix), + } + return { + **material, + "realization_envelope_digest": envelope_digest, + "driver_configuration_digest": canonical_digest(material), + } def _rollback( self, connection: object, networks: Sequence[NetworkHandle], domains: Sequence[DomainHandle], - ) -> None: - for handle in domains: - if handle.realized: - self._destroy_one(connection, "lookupByName", handle.address) - for handle in networks: - if handle.realized: - self._destroy_one(connection, "networkLookupByName", handle.address) - - -def _native_matrix( - *, - networks: tuple[NetworkSpec, ...], - domains: tuple[DomainSpec, ...], - name_prefix: str, -) -> dict[str, object]: - runtime_networks = [_runtime_network(spec, index, name_prefix) for index, spec in enumerate(networks)] - runtime_network_by_address = {str(item["address"]): item for item in runtime_networks} - allocations = _allocate_interfaces(domains, runtime_network_by_address, name_prefix) - runtime_domains = [ - _runtime_domain(spec, name_prefix=name_prefix, interfaces=allocations.get(spec.address, ())) for spec in domains - ] - return { - "substrate": _SUBSTRATE, - "networks": runtime_networks, - "domains": runtime_domains, - } - - -def _runtime_network(spec: NetworkSpec, index: int, name_prefix: str) -> dict[str, object]: - network = _network(spec.cidr, index) - gateway = _gateway(spec.gateway, network) - return { - "address": spec.address, - "name": spec.name, - "runtime_name": _runtime_name(name_prefix, spec.address, spec.name), - "cidr": str(network), - "gateway": str(gateway), - "netmask": str(network.netmask), - "internal": spec.labels.get("internal") == "true", - "hosts": [], - } - - -def _allocate_interfaces( - domains: tuple[DomainSpec, ...], - networks: Mapping[str, dict[str, object]], - name_prefix: str, -) -> dict[str, tuple[dict[str, object], ...]]: - allocations: dict[str, list[dict[str, object]]] = {domain.address: [] for domain in domains} - next_host: dict[str, int] = dict.fromkeys(networks, 10) - for domain in domains: - for network_address in domain.networks: - network = networks.get(network_address) - if network is None: - continue - parsed = ipaddress.ip_network(str(network["cidr"]), strict=False) - offset = next_host[network_address] - next_host[network_address] = offset + 1 - ip = str(parsed.network_address + offset) - mac = _mac(domain.address, network_address) - interface = { - "network_address": network_address, - "network_name": network.get("name"), - "runtime_network": network.get("runtime_name"), - "ip": ip, - "cidr_prefix": parsed.prefixlen, - "gateway": network.get("gateway"), - "mac": mac, - } - allocations[domain.address].append(interface) - cast(list[dict[str, object]], network["hosts"]).append( - {"name": _runtime_name(name_prefix, domain.address, domain.name), "mac": mac, "ip": ip} - ) - return {address: tuple(items) for address, items in allocations.items()} - - -def _runtime_domain( - spec: DomainSpec, - *, - name_prefix: str, - interfaces: tuple[dict[str, object], ...], -) -> dict[str, object]: - services = _services_for_domain(spec) - return { - "address": spec.address, - "name": spec.name, - "runtime_name": _runtime_name(name_prefix, spec.address, spec.name), - "memory_mib": max(64, min(spec.memory_mib, 128)), - "vcpus": max(1, min(spec.vcpus, 2)), - "interfaces": list(interfaces), - "services": [service.__dict__ for service in services], - "role": _role(spec.name), - } - - -def _services_for_domain(spec: DomainSpec) -> tuple[ServiceSpec, ...]: - services = list(spec.services) - if not services and spec.networks: - services.append(ServiceSpec(name="aces-health", port=80, protocol="tcp")) - return tuple(sorted(services, key=lambda item: (item.protocol, item.port, item.name))) - - -def _network_xml(network: Mapping[str, object]) -> str: - root = ET.Element("network") - ET.SubElement(root, "name").text = str(network.get("runtime_name", "")) - if not network.get("internal"): - ET.SubElement(root, "forward", {"mode": "nat"}) - ip_node = ET.SubElement( - root, - "ip", - {"address": str(network.get("gateway", "")), "netmask": str(network.get("netmask", ""))}, - ) - dhcp = ET.SubElement(ip_node, "dhcp") - for host in _as_sequence(network.get("hosts")): - if isinstance(host, Mapping): - ET.SubElement( - dhcp, - "host", - {"mac": str(host.get("mac", "")), "name": str(host.get("name", "")), "ip": str(host.get("ip", ""))}, - ) - return ET.tostring(root, encoding="unicode") - - -def _domain_xml(domain: Mapping[str, object], *, kernel: Path, initrd: Path) -> str: - root = ET.Element("domain", {"type": "qemu"}) - ET.SubElement(root, "name").text = str(domain.get("runtime_name", "")) - ET.SubElement(root, "memory", {"unit": "MiB"}).text = str(domain.get("memory_mib", 128)) - ET.SubElement(root, "vcpu").text = str(domain.get("vcpus", 1)) - os_node = ET.SubElement(root, "os") - ET.SubElement(os_node, "type", {"arch": "x86_64"}).text = "hvm" - ET.SubElement(os_node, "kernel").text = str(kernel) - ET.SubElement(os_node, "initrd").text = str(initrd) - ET.SubElement(os_node, "cmdline").text = "console=ttyS0 panic=-1 aces.appliance=techvault" - features = ET.SubElement(root, "features") - ET.SubElement(features, "acpi") - devices = ET.SubElement(root, "devices") - ET.SubElement(devices, "emulator").text = "/usr/bin/qemu-system-x86_64" - serial = ET.SubElement(devices, "serial", {"type": "pty"}) - ET.SubElement(serial, "target", {"port": "0"}) - console = ET.SubElement(devices, "console", {"type": "pty"}) - ET.SubElement(console, "target", {"type": "serial", "port": "0"}) - for interface_spec in _as_sequence(domain.get("interfaces")): - if not isinstance(interface_spec, Mapping): - continue - interface = ET.SubElement(devices, "interface", {"type": "network"}) - ET.SubElement(interface, "mac", {"address": str(interface_spec.get("mac", ""))}) - ET.SubElement(interface, "source", {"network": str(interface_spec.get("runtime_network", ""))}) - ET.SubElement(interface, "model", {"type": "virtio"}) - return ET.tostring(root, encoding="unicode") - - -def _snapshot_from_matrix( - matrix: Mapping[str, object], - domains: Sequence[DomainHandle], - networks: Sequence[NetworkHandle], -) -> dict[str, object]: - realized = {handle.address for handle in (*domains, *networks) if handle.realized} - snapshot = json.loads(json.dumps(matrix)) - snapshot["realized_addresses"] = sorted(realized) - snapshot["substrate"] = _SUBSTRATE - snapshot["containers"] = [] - return snapshot + ) -> list[Diagnostic]: + return [ + *self._rollback_handles(connection, domains, "lookupByName"), + *self._rollback_handles(connection, networks, "networkLookupByName"), + ] + + def _rollback_handles( + self, + connection: object, + handles: Sequence[DomainHandle | NetworkHandle], + lookup_method: str, + ) -> list[Diagnostic]: + diagnostics: list[Diagnostic] = [] + for handle in handles: + if handle.realized and not self._try_destroy(connection, lookup_method, handle.address): + diagnostics.append(_diagnostic(_CODE_RESIDUAL_STATE, handle.address)) + return diagnostics + + def _try_destroy(self, connection: object, lookup_method: str, address: str) -> bool: + try: + return self._destroy_one(connection, lookup_method, address) + except Exception: + return False def _default_connector(connection_uri: str) -> object | None: @@ -412,44 +529,25 @@ def _call(connection: object, method_name: str, payload: str) -> _NativeResource return method(payload) -def _destroy_existing_with_prefix(connection: object, prefix: str) -> None: - for native in _list_native(connection, "listAllDomains"): - if _native_name(native).startswith(f"{prefix}-"): - _destroy_native(native) - for native in _list_native(connection, "listAllNetworks"): - if _native_name(native).startswith(f"{prefix}-"): - _destroy_native(native) - - -def _list_native(connection: object, method_name: str) -> tuple[object, ...]: +def _ensure_name_available(connection: object, method_name: str, name: str, address: str) -> None: method = getattr(connection, method_name, None) if not callable(method): - return () + raise RuntimeError("native lookup is unavailable") try: - native = method() - except Exception: - return () - return tuple(native) if isinstance(native, list | tuple) else () + native = method(name) + except KeyError: + return + except Exception as exc: + if _error_code(exc) in {42, 43}: + return + raise + if _existing_uuid(native) != _aces_uuid(address): + raise _OwnershipConflict(address) + raise RuntimeError("owned native object already exists for CREATE") -def _native_name(native: object) -> str: - method = getattr(native, "name", None) - if not callable(method): - return "" - try: - value = method() - except Exception: - return "" - return value if isinstance(value, str) else "" - - -def _destroy_native(native: object) -> None: - for method_name in ("destroy", "undefine"): - method = getattr(native, method_name, None) - if not callable(method): - continue - with suppress(Exception): - method() +def _artifact_token(address: str) -> str: + return _aces_uuid(address).replace("-", "") def _default_kernel_path() -> Path: @@ -460,71 +558,15 @@ def _default_kernel_path() -> Path: return candidates[-1] if candidates else Path("/boot/vmlinuz") -def _network(cidr: str | None, index: int) -> ipaddress.IPv4Network: - if cidr: - try: - parsed = ipaddress.ip_network(cidr, strict=False) - if isinstance(parsed, ipaddress.IPv4Network): - return parsed - except ValueError: - pass - return ipaddress.ip_network(f"192.168.{100 + index}.0/24") - - -def _gateway(gateway: str | None, network: ipaddress.IPv4Network) -> ipaddress.IPv4Address: - if gateway: - try: - parsed = ipaddress.ip_address(gateway) - if isinstance(parsed, ipaddress.IPv4Address): - return parsed - except ValueError: - pass - return network.network_address + 1 - - -def _role(name: str) -> str: - role = "enterprise" - if name in {"misp", "thehive", "cortex"} or name.startswith("shuffle-"): - role = "soc-case-management" - elif name.startswith("wazuh") or name == "suricata": - role = "soc-monitoring" - elif name in {"kali", "kali-capture"}: - role = "red-team" - elif name.startswith("aptl-"): - role = "observability" - return role - - -def _runtime_name(prefix: str, address: str, preferred: str | None = None) -> str: - return _safe_name(preferred or address.rsplit(".", 1)[-1], fallback=address.rsplit(".", 1)[-1], prefix=prefix) - - -def _safe_name(candidate: str, *, fallback: str, prefix: str) -> str: - raw = candidate.strip() or fallback.strip() or "resource" - normalized = _SAFE_NAME_RE.sub("-", raw).strip("-._") - if not normalized: - normalized = _SAFE_NAME_RE.sub("-", fallback).strip("-._") or "resource" - prefixed = f"{prefix}-{normalized}" if prefix else normalized - return prefixed[:63].strip("-._") or "resource" - - -def _mac(domain_address: str, network_address: str) -> str: - digest = hashlib.sha256(f"{domain_address}|{network_address}".encode()).digest() - return "52:54:00:" + ":".join(f"{byte:02x}" for byte in digest[:3]) - - -def _as_sequence(value: object) -> Sequence[object]: - return value if isinstance(value, list | tuple) else () - - -def _int(value: object) -> int: - return value if isinstance(value, int) else 0 - - def _diagnostic(code: str, address: str) -> Diagnostic: - message = ( - "Libvirt connection is unavailable for native TechVault realization." - if code == _CODE_UNAVAILABLE - else f"Native libvirt TechVault operation for '{address}' did not succeed." - ) + if code == _CODE_UNAVAILABLE: + message = "Libvirt connection is unavailable for native TechVault realization." + elif code == _CODE_RESIDUAL_STATE: + message = f"Native TechVault rollback could not verify cleanup for '{address}'; residual state may remain." + elif code == _CODE_OWNERSHIP_CONFLICT: + message = f"Native object for '{address}' is not owned by that ACES address; refusing mutation." + elif code == _CODE_READBACK_FAILED: + message = f"Native libvirt TechVault readback for '{address}' did not succeed." + else: + message = f"Native libvirt TechVault operation for '{address}' did not succeed." return Diagnostic(code=code, domain=_DOMAIN, address=address, message=message, severity=Severity.ERROR) diff --git a/implementations/python/packages/aces_backend_libvirt/techvault_observation.py b/implementations/python/packages/aces_backend_libvirt/techvault_observation.py new file mode 100644 index 000000000..655ce5261 --- /dev/null +++ b/implementations/python/packages/aces_backend_libvirt/techvault_observation.py @@ -0,0 +1,211 @@ +"""Bounded daemon observations and source-separated TechVault reports.""" + +from __future__ import annotations + +import hashlib +import ipaddress +import json +import xml.etree.ElementTree as StdET +from collections.abc import Mapping, Sequence +from pathlib import Path + +from aces_contracts.realization_envelope import ObservationStrength, RealizationConcern +from defusedxml import ElementTree as SafeET + +from .driver import RealizationObservation +from .techvault_matrix import as_sequence + +_SUBSTRATE = "libvirt-qemu-initramfs" +_MAX_NATIVE_XML_CHARS = 1_000_000 + + +def network_observations( + native: object, + expected: Mapping[str, object], +) -> tuple[RealizationObservation, ...]: + root = native_xml(native) + address = str(expected.get("address", "")) + active = native_active(native) + actual_name = root.findtext("name", default="") + ip_node = root.find("ip") + gateway = "" if ip_node is None else ip_node.get("address", "") + netmask = "" if ip_node is None else ip_node.get("netmask", "") + cidr = str(ipaddress.ip_network(f"{gateway}/{netmask}", strict=False)) if gateway and netmask else "" + forward = root.find("forward") + internal = forward is None + forward_mode = "none" if forward is None else forward.get("mode", "") + return ( + observation( + address, + "exists", + RealizationConcern.TOPOLOGY, + active and actual_name == expected.get("runtime_name"), + ), + observation(address, "native-name", RealizationConcern.TOPOLOGY, actual_name), + observation(address, "cidr", RealizationConcern.NETWORK, cidr), + observation(address, "gateway", RealizationConcern.NETWORK, gateway), + observation(address, "internal", RealizationConcern.NETWORK, internal), + observation(address, "forward-mode", RealizationConcern.NETWORK, forward_mode), + ) + + +def domain_observations( + native: object, + expected: Mapping[str, object], + network_addresses: Mapping[str, str], + *, + kernel: Path, + initrd: Path, +) -> tuple[RealizationObservation, ...]: + root = native_xml(native) + address = str(expected.get("address", "")) + active = native_active(native) + actual_name = root.findtext("name", default="") + os_type = root.find("./os/type") + architecture = "" if os_type is None else os_type.get("arch", "") + image_policy = ( + "generated-initramfs-appliance" + if root.findtext("./os/kernel") == str(kernel) + and root.findtext("./os/initrd") == str(initrd) + and root.find("./os/loader") is None + and root.find("./os/nvram") is None + and root.find("./devices/disk") is None + else "" + ) + attachments = tuple( + network_addresses.get(source.get("network", "")) for source in root.findall("./devices/interface/source") + ) + return ( + observation( + address, + "exists", + RealizationConcern.TOPOLOGY, + active and actual_name == expected.get("runtime_name"), + ), + observation(address, "native-name", RealizationConcern.TOPOLOGY, actual_name), + observation(address, "architecture", RealizationConcern.ARCHITECTURE, architecture), + observation(address, "image-policy", RealizationConcern.IMAGE, image_policy), + observation(address, "memory-mib", RealizationConcern.RESOURCE_ALLOCATION, memory_mib(root)), + observation(address, "vcpus", RealizationConcern.RESOURCE_ALLOCATION, xml_int(root.findtext("vcpu"))), + observation(address, "network-attachments", RealizationConcern.NETWORK, attachments), + ) + + +def snapshot_from_observations( + matrix: Mapping[str, object], + observations: Sequence[RealizationObservation], + *, + binding: Mapping[str, object], +) -> dict[str, object]: + values = {(item.address, item.field_path): item.value for item in observations} + networks = [ + { + "address": str(item.get("address", "")), + "name": values.get((str(item.get("address", "")), "native-name")), + "cidr": values.get((str(item.get("address", "")), "cidr")), + "gateway": values.get((str(item.get("address", "")), "gateway")), + "internal": values.get((str(item.get("address", "")), "internal")), + "forward_mode": values.get((str(item.get("address", "")), "forward-mode")), + "observation_source": ObservationStrength.DAEMON_OBSERVED.value, + } + for item in as_sequence(matrix.get("networks")) + if isinstance(item, Mapping) and values.get((str(item.get("address", "")), "exists")) is True + ] + domains = [ + { + "address": str(item.get("address", "")), + "name": values.get((str(item.get("address", "")), "native-name")), + "architecture": values.get((str(item.get("address", "")), "architecture")), + "image_policy": values.get((str(item.get("address", "")), "image-policy")), + "memory_mib": values.get((str(item.get("address", "")), "memory-mib")), + "vcpus": values.get((str(item.get("address", "")), "vcpus")), + "network_attachments": values.get((str(item.get("address", "")), "network-attachments"), ()), + "observation_source": ObservationStrength.DAEMON_OBSERVED.value, + } + for item in as_sequence(matrix.get("domains")) + if isinstance(item, Mapping) and values.get((str(item.get("address", "")), "exists")) is True + ] + return { + "substrate": _SUBSTRATE, + "source": ObservationStrength.DAEMON_OBSERVED.value, + "domains": domains, + "networks": networks, + "realized_addresses": sorted([item["address"] for item in (*networks, *domains)]), + "containers": [], + "guest_observed": [], + "binding": dict(binding), + } + + +def file_digest(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return "sha256:" + digest.hexdigest() + + +def canonical_digest(payload: object) -> str: + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8") + return "sha256:" + hashlib.sha256(encoded).hexdigest() + + +def native_xml(native: object) -> StdET.Element: + reader = getattr(native, "XMLDesc", None) + if not callable(reader): + raise TypeError("native resource does not expose XML readback") + payload = reader(0) + if not isinstance(payload, str): + raise TypeError("native XML readback is not text") + if len(payload) > _MAX_NATIVE_XML_CHARS: + raise ValueError("native XML readback exceeds the bounded size limit") + return SafeET.fromstring(payload) + + +def native_active(native: object) -> bool: + reader = getattr(native, "isActive", None) + if not callable(reader): + return False + return reader() == 1 + + +def memory_mib(root: StdET.Element) -> int: + memory = root.find("memory") + if memory is None: + return 0 + value = xml_int(memory.text) + unit = memory.get("unit", "KiB").lower() + factors = {"b": 1 / (1024 * 1024), "kib": 1 / 1024, "mib": 1, "gib": 1024} + factor = factors.get(unit) + return int(value * factor) if factor is not None else 0 + + +def xml_int(value: object) -> int: + try: + return int(str(value)) + except (TypeError, ValueError): + return 0 + + +def observation( + address: str, + field_path: str, + concern: RealizationConcern, + value: object, +) -> RealizationObservation: + return RealizationObservation( + address=address, + field_path=field_path, + concern=concern, + source=ObservationStrength.DAEMON_OBSERVED, + value=value, + ) + + +__all__ = [ + "canonical_digest", + "domain_observations", + "file_digest", + "network_observations", + "snapshot_from_observations", +] diff --git a/implementations/python/packages/aces_backend_libvirt/techvault_probe.py b/implementations/python/packages/aces_backend_libvirt/techvault_probe.py index fb9066142..1eafcefda 100644 --- a/implementations/python/packages/aces_backend_libvirt/techvault_probe.py +++ b/implementations/python/packages/aces_backend_libvirt/techvault_probe.py @@ -4,7 +4,6 @@ import socket import subprocess -import time from collections.abc import Mapping, Sequence from dataclasses import dataclass @@ -24,33 +23,36 @@ class NativeLibvirtProbe: timeout_seconds: float = 1.5 def ping(self, ip: str) -> ProbeResult: - proc = subprocess.run( - ["ping", "-c", "1", "-W", str(max(1, int(self.timeout_seconds))), ip], - text=True, - capture_output=True, - timeout=max(2, int(self.timeout_seconds) + 1), - check=False, - ) - return ProbeResult(proc.returncode == 0, _short_process_output(proc)) + try: + proc = subprocess.run( + ["ping", "-c", "1", "-W", str(max(1, int(self.timeout_seconds))), ip], + text=True, + capture_output=True, + timeout=max(2, int(self.timeout_seconds) + 1), + check=False, + ) + except (OSError, subprocess.TimeoutExpired): + return ProbeResult(False, "probe failed") + return ProbeResult(proc.returncode == 0, "" if proc.returncode == 0 else "probe failed") def tcp(self, ip: str, port: int) -> ProbeResult: try: with socket.create_connection((ip, port), timeout=self.timeout_seconds): return ProbeResult(True) - except OSError as exc: - return ProbeResult(False, str(exc)) + except OSError: + return ProbeResult(False, "connection failed") def expected_surface(snapshot: Mapping[str, object]) -> dict[str, object]: - """Return the model-derived runtime surface recorded by the native driver.""" + """Return bounded native names from the driver's daemon-observed report.""" domains = [domain for domain in _as_sequence(snapshot.get("domains")) if isinstance(domain, Mapping)] networks = [network for network in _as_sequence(snapshot.get("networks")) if isinstance(network, Mapping)] return { + "source": snapshot.get("source"), "substrate": snapshot.get("substrate"), "domains": tuple(sorted(str(domain.get("name", "")) for domain in domains if domain.get("name"))), "networks": tuple(sorted(str(network.get("name", "")) for network in networks if network.get("name"))), - "service_count": sum(len(_as_sequence(domain.get("services"))) for domain in domains), } @@ -61,115 +63,22 @@ def check_native_readiness( timeout_seconds: int = 180, poll_seconds: int = 5, ) -> tuple[bool, list[str]]: - """Probe domain reachability and declared TCP service listeners.""" + """Decline guest-readiness inference from the daemon-observed substrate.""" - deadline = time.monotonic() + max(1, timeout_seconds) - diagnostics: list[str] = [] - while time.monotonic() < deadline: - diagnostics = _readiness_diagnostics(snapshot, probe) - if not diagnostics: - return True, [] - time.sleep(max(1, poll_seconds)) - return False, diagnostics + del snapshot, probe, timeout_seconds, poll_seconds + return False, ["guest readiness requires concern-specific guest observation"] def native_soc_readback(snapshot: Mapping[str, object]) -> dict[str, object]: - """Return SOC readback derived from the native scenario surface.""" + """Disclose that daemon substrate state is not guest SOC observation.""" - names = { - str(domain.get("name", "")) for domain in _as_sequence(snapshot.get("domains")) if isinstance(domain, Mapping) - } - active_agents = tuple(sorted(name for name in names if name in _wazuh_agent_names(names))) + del snapshot return { - "wazuh_active_agents": active_agents, - "suricata": { - "present": "suricata" in names, - "rules_loaded": 49954 if "suricata" in names else 0, - "rules_failed": 0, - "kernel_drops": 0, - }, - "case_management": { - "thehive": "thehive" in names, - "misp": "misp" in names, - "cortex": "cortex" in names, - "shuffle": any(name.startswith("shuffle-") for name in names), - }, - } - - -def _readiness_diagnostics(snapshot: Mapping[str, object], probe: NativeLibvirtProbe) -> list[str]: - diagnostics: list[str] = [] - for domain in _as_sequence(snapshot.get("domains")): - if isinstance(domain, Mapping): - diagnostics.extend(_domain_readiness_diagnostics(domain, probe)) - return diagnostics - - -def _domain_readiness_diagnostics(domain: Mapping[str, object], probe: NativeLibvirtProbe) -> list[str]: - addresses = _domain_ips(domain) - if not addresses: - return [] - first_ip = addresses[0] - ping = probe.ping(first_ip) - if not ping.ok: - return [f"{domain.get('name')} is not reachable at {first_ip}: {ping.detail}"] - return _service_readiness_diagnostics(domain, first_ip, probe) - - -def _service_readiness_diagnostics( - domain: Mapping[str, object], - ip_address: str, - probe: NativeLibvirtProbe, -) -> list[str]: - diagnostics: list[str] = [] - for service in _as_sequence(domain.get("services")): - if isinstance(service, Mapping) and _is_tcp_service(service): - port = _int(service.get("port")) - result = probe.tcp(ip_address, port) - if not result.ok: - diagnostics.append( - f"{domain.get('name')} service {service.get('name')}:{port}/tcp not reachable: {result.detail}" - ) - return diagnostics - - -def _is_tcp_service(service: Mapping[str, object]) -> bool: - protocol = str(service.get("protocol", "tcp")).lower() - port = _int(service.get("port")) - return protocol == "tcp" and port > 0 - - -def _domain_ips(domain: Mapping[str, object]) -> list[str]: - ips: list[str] = [] - for interface in _as_sequence(domain.get("interfaces")): - if isinstance(interface, Mapping) and interface.get("ip"): - ips.append(str(interface["ip"])) - return ips - - -def _wazuh_agent_names(names: set[str]) -> set[str]: - agents = { - "wazuh-manager", - "dns", - "fileshare", - "ad", - "webapp", - "suricata", - "db", - "victim", - "workstation", + "status": "not-observed", + "observation_source": "none", + "reason": "guest SOC state requires concern-specific guest observation", } - return agents & names def _as_sequence(value: object) -> Sequence[object]: return value if isinstance(value, list | tuple) else () - - -def _int(value: object) -> int: - return value if isinstance(value, int) else 0 - - -def _short_process_output(proc: subprocess.CompletedProcess[str]) -> str: - text = (proc.stderr or proc.stdout or "").strip().replace("\n", " ") - return text[:200] diff --git a/implementations/python/packages/aces_cli/libvirt.py b/implementations/python/packages/aces_cli/libvirt.py index af2a79933..ad1613e0c 100644 --- a/implementations/python/packages/aces_cli/libvirt.py +++ b/implementations/python/packages/aces_cli/libvirt.py @@ -4,6 +4,7 @@ from datetime import UTC, datetime from pathlib import Path +from urllib.parse import urlsplit import typer from aces_operations.libvirt_evidence_run import LibvirtEvidenceRunConfig, run_libvirt_evidence_run @@ -21,6 +22,13 @@ """ +def _noncredential_connection_uri(value: str) -> str: + parsed = urlsplit(value) + if parsed.username is not None or parsed.password is not None: + raise typer.BadParameter("connection URI must not contain credentials") + return value + + @techvault_app.command("validate-live") def validate_live( scenario: Path = typer.Option( @@ -48,20 +56,9 @@ def validate_live( connection_uri: str = typer.Option( "qemu:///system", "--connection-uri", + callback=_noncredential_connection_uri, help="libvirt connection URI.", ), - appliance_memory_mib: int = typer.Option( - 128, - "--appliance-memory-mib", - min=64, - help="Memory per generated TechVault appliance VM.", - ), - boot_timeout_seconds: int = typer.Option( - 180, - "--boot-timeout-seconds", - min=1, - help="Maximum native appliance readiness wait.", - ), ) -> None: """Boot TechVault through native ACES/libvirt and run the live validation gate.""" @@ -77,8 +74,6 @@ def validate_live( run_id=resolved_run_id, config=TechVaultLiveConfig( connection_uri=connection_uri, - appliance_memory_mib=appliance_memory_mib, - boot_timeout_seconds=boot_timeout_seconds, ), ) typer.echo(report.render()) @@ -112,6 +107,7 @@ def validate_evidence( connection_uri: str = typer.Option( "qemu:///system", "--connection-uri", + callback=_noncredential_connection_uri, help="libvirt connection URI (native-live only).", ), ) -> None: diff --git a/implementations/python/packages/aces_contracts/realization_envelope_carrier.py b/implementations/python/packages/aces_contracts/realization_envelope_carrier.py index 2b6d16cbd..2420f546f 100644 --- a/implementations/python/packages/aces_contracts/realization_envelope_carrier.py +++ b/implementations/python/packages/aces_contracts/realization_envelope_carrier.py @@ -47,6 +47,7 @@ class RealizationConcern(str, Enum): CONTENT_PLACEMENT = "content-placement" ACCOUNT_PLACEMENT = "account-placement" FEATURE_BINDING = "feature-binding" + SERVICE = "service" ACL = "acl" diff --git a/implementations/python/packages/aces_operations/_evidence_run_artifact.py b/implementations/python/packages/aces_operations/_evidence_run_artifact.py index c93e18985..8171fc774 100644 --- a/implementations/python/packages/aces_operations/_evidence_run_artifact.py +++ b/implementations/python/packages/aces_operations/_evidence_run_artifact.py @@ -20,7 +20,7 @@ from pathlib import Path from typing import Any -from aces_backend_libvirt.techvault_native import NativeLibvirtProbe, expected_surface, native_soc_readback +from aces_backend_libvirt.techvault_native import expected_surface from aces_backend_protocols.capabilities import ( observation_capability_contract_gaps, participant_runtime_capability_contract_gaps, @@ -40,6 +40,7 @@ RealizedNetwork, TerminalSnapshot, ) +from aces_operations.run_artifacts import portable_artifact_ref EVIDENCE_RUN_SCHEMA = "aces.libvirt.scenario-evidence-run/v1" _LIBVIRT_BACKEND_NAME = "libvirt-qemu" @@ -68,7 +69,7 @@ def assemble_artifact(inputs: EvidenceArtifactInputs) -> dict[str, Any]: manifest = inputs.manifest proof = inputs.proof native_snapshot = inputs.native_snapshot - probe = inputs.probe + native_cleanup_verified = inputs.native_cleanup_verified unrealized_capabilities = inputs.unrealized_capabilities substrate_realized = native_snapshot is not None @@ -82,12 +83,13 @@ def assemble_artifact(inputs: EvidenceArtifactInputs) -> dict[str, Any]: "evidence_source_mode": mode, "scenario": scenario_section, "compiled_artifact": _compiled_artifact_section(model), - "backend": _backend_section(manifest, mode, substrate_realized), + "backend": _backend_section(manifest, mode, substrate_realized, native_cleanup_verified), + "realization_facts": _realization_facts_section(model, native_snapshot, native_cleanup_verified), "realized_topology": _topology_section(model, native_snapshot, unrealized_capabilities), "participant_action_proof": _participant_proof_section(proof), "terminal_observation": _terminal_observation_section(proof["snapshot"]), "defensive_evidence": _defensive_evidence_section(native_snapshot, model, recorded_at), - "negative_boundary_checks": _negative_boundary_section(boundary_refs, native_snapshot, probe), + "negative_boundary_checks": _negative_boundary_section(boundary_refs), "evaluator_outcome": _evaluator_outcome_section(proof["lifecycle_clean"], recorded_at), "realized_form_disclosures": _realized_form_disclosures(manifest, substrate_realized), "limitations": _limitations(mode, unrealized_capabilities), @@ -111,7 +113,12 @@ def _manifest_version(manifest: BackendManifest) -> str: return str(getattr(manifest, "version", "0.0.0+unknown")) -def _backend_section(manifest: BackendManifest, mode: str, substrate_realized: bool) -> dict[str, Any]: +def _backend_section( + manifest: BackendManifest, + mode: str, + substrate_realized: bool, + cleanup_verified: bool | None, +) -> dict[str, Any]: """Embed the canonical BackendManifestV2 payload + capability-gap report. The manifest is rendered through ``backend_manifest_payload`` — the same @@ -132,7 +139,8 @@ def _backend_section(manifest: BackendManifest, mode: str, substrate_realized: b "backend": _manifest_name(manifest), "evidence_source_mode": mode, "substrate_realized": substrate_realized, - "basis": "native-realized" if substrate_realized else "planned-not-realized", + "basis": "daemon-observed-substrate" if substrate_realized else "planned-not-realized", + "cleanup_verified": cleanup_verified, }, } @@ -149,20 +157,11 @@ def _scenario_section(scenario_path: Path, model: CompiledModel) -> dict[str, An return { "name": model.scenario_name, "version": version, - "relative_path": _portable_scenario_ref(scenario_path), + "relative_path": portable_artifact_ref(scenario_path), "content_sha256": "sha256:" + hashlib.sha256(content).hexdigest(), } -def _portable_scenario_ref(scenario_path: Path) -> str: - """Return a repo-portable scenario reference, never the absolute host path.""" - parts = scenario_path.parts - for anchor in ("examples", "scenarios"): - if anchor in parts: - return "/".join(parts[parts.index(anchor) :]) - return scenario_path.name - - def _compiled_artifact_section(model: CompiledModel) -> dict[str, Any]: addresses = { "participant_behaviors": sorted(model.participant_behaviors), @@ -207,6 +206,52 @@ def _network_properties(network: RealizedNetwork) -> dict[str, Any]: return {"cidr": props.get("cidr"), "gateway": props.get("gateway"), "internal": props.get("internal")} +def _realization_facts_section( + model: CompiledModel, + native_snapshot: Mapping[str, Any] | None, + cleanup_verified: bool | None, +) -> dict[str, Any]: + observed = native_snapshot if isinstance(native_snapshot, Mapping) else {} + daemon_domains = observed.get("domains", ()) + daemon_networks = observed.get("networks", ()) + realized_addresses = observed.get("realized_addresses", ()) + return { + "authored": { + "source": "authored", + "scenario_name": model.scenario_name, + }, + "planned": { + "source": "planned", + "node_addresses": sorted(model.node_deployments), + "network_addresses": sorted(model.networks), + }, + "driver_reported": { + "source": "driver-reported", + "realized_addresses": list(realized_addresses) if isinstance(realized_addresses, list | tuple) else [], + }, + "daemon_observed": { + "source": "daemon-observed", + "domains": list(daemon_domains) if isinstance(daemon_domains, list | tuple) else [], + "networks": list(daemon_networks) if isinstance(daemon_networks, list | tuple) else [], + }, + "guest_observed": { + "source": "guest-observed", + "status": "not-observed", + }, + "cleanup": { + "source": "driver-reported", + "status": _cleanup_status(cleanup_verified), + }, + "binding": observed.get("binding"), + } + + +def _cleanup_status(cleanup_verified: bool | None) -> str: + if cleanup_verified is None: + return "not-required" + return "verified" if cleanup_verified else "failed" + + def _topology_section( model: CompiledModel, native_snapshot: Mapping[str, Any] | None, @@ -215,6 +260,7 @@ def _topology_section( substrate_realized = native_snapshot is not None nodes = [ { + "source": "planned", "address": node.address, "name": node.name, "node_type": getattr(node, "node_type", None), @@ -225,12 +271,14 @@ def _topology_section( for node in model.node_deployments.values() ] networks = [ - {"address": net.address, "name": net.name, **_network_properties(net)} for net in model.networks.values() + {"source": "planned", "address": net.address, "name": net.name, **_network_properties(net)} + for net in model.networks.values() ] section: dict[str, Any] = { - "basis": "native-realized" if substrate_realized else "planned-not-realized", + "basis": "mixed-source" if substrate_realized else "planned", "disclosure": ( - "Topology realized through the native libvirt driver." + "Compiled topology remains planned; the native surface contains only independently daemon-observed " + "substrate fields." if substrate_realized else "Compiled/planned topology from the authored scenario; no live substrate realized. Network CIDRs and " "gateways are authored values, not host-private libvirt addresses." @@ -327,21 +375,11 @@ def _defensive_evidence_section( # assembly, not a freshly synthesized one, so every section shares one # consistent run timestamp and the artifact stays reproducible. evidence_channels = _boundary_evidence_refs(model) - if native_snapshot is not None: - return { - "evidence_kind": "telemetry", - "evidence_source": "native-translated-readback", - "visibility": "evaluator-only", - "sensitivity": "restricted", - "redaction_state": "redacted", - "loss_disclosure": ( - "Native libvirt SOC readback is a translated native readback of generated appliance state, not full " - "upstream Wazuh internals; no detection-quality claim is made." - ), - "evaluator_evidence_channels": evidence_channels, - "soc_readback": native_soc_readback(native_snapshot), - "captured_at": recorded_at, - } + substrate_note = ( + " Daemon-observed libvirt substrate state is present, but it is not guest SOC observation." + if native_snapshot is not None + else "" + ) return { "evidence_kind": "telemetry", "evidence_source": "structural-evaluator-channel", @@ -351,25 +389,21 @@ def _defensive_evidence_section( "loss_disclosure": ( "Deterministic mode: no live SOC substrate is booted. Wazuh/SOC defensive evidence is reported as the " "evaluator-only evidence channels declared by the scenario observation boundary, not upstream Wazuh " - "detection output; no detection-quality claim is made." + f"detection output; no detection-quality claim is made.{substrate_note}" ), "evaluator_evidence_channels": evidence_channels, "payload_summary": ( "Evaluator-only Wazuh/SOC and policy-decision evidence channels are declared and kept off the participant " - "view; live SOC readback is available only under native-live mode." + "view; neither evidence mode claims guest SOC readback." ), "captured_at": recorded_at, } -def _negative_boundary_section( - boundary_refs: Sequence[str], - native_snapshot: Mapping[str, Any] | None, - probe: NativeLibvirtProbe | None, -) -> dict[str, Any]: +def _negative_boundary_section(boundary_refs: Sequence[str]) -> dict[str, Any]: internal_refs = [ref for ref in boundary_refs if any(kw in ref for kw in _INTERNAL_SURFACE_KEYWORDS)] checks = [{"ref": ref, "exposed_to_participant": False} for ref in internal_refs] - section: dict[str, Any] = { + return { "method": ( "Structural boundary analysis over the compiled observation boundary (hidden_refs) and the participant " "exposure policy (empty visible/disclosed refs). The participant action surface does not expose the " @@ -380,36 +414,6 @@ def _negative_boundary_section( "checks": checks, "disclosure": "Negative boundary checks are evaluator-side derived analysis, not participant observations.", } - if native_snapshot is not None and probe is not None: - section["native_reachability"] = _native_reachability_summary(native_snapshot, probe) - return section - - -def _native_reachability_summary(native_snapshot: Mapping[str, Any], probe: NativeLibvirtProbe) -> dict[str, Any]: - summary: dict[str, Any] = {"reachable_surface_domains": []} - raw_domains = native_snapshot.get("domains", ()) - if not isinstance(raw_domains, list | tuple): - return summary - for domain in raw_domains: - if not isinstance(domain, Mapping): - continue - name = str(domain.get("name", "")) - if any(kw in name for kw in _INTERNAL_SURFACE_KEYWORDS): - continue - ip = _first_ip(domain) - if ip and probe.ping(ip).ok: - summary["reachable_surface_domains"].append(name) - return summary - - -def _first_ip(domain: Mapping[str, Any]) -> str | None: - interfaces = domain.get("interfaces", ()) - if not isinstance(interfaces, list | tuple): - return None - for interface in interfaces: - if isinstance(interface, Mapping) and isinstance(interface.get("ip"), str) and interface.get("ip"): - return str(interface["ip"]) - return None def _evaluator_outcome_section(lifecycle_clean: bool, recorded_at: str) -> dict[str, Any]: @@ -459,9 +463,12 @@ def _realized_form_disclosures(manifest: BackendManifest, substrate_realized: bo "realized_by_ref": backend_ref, "realized_value_summary": ( f"{backend_name} backend ({backend_version}); substrate " - f"{'realized natively' if substrate_realized else 'planned, not realized'}." + f"{'daemon-observed at bounded fields' if substrate_realized else 'planned, not realized'}." + ), + "disclosure": ( + "The libvirt-qemu backend supplied the run; live claims are limited to independently " + "daemon-observed substrate fields." ), - "disclosure": "The libvirt-qemu backend realized this scenario-evidence run.", } ), ExperimentRealizedFormDisclosureModel.model_validate( @@ -487,13 +494,13 @@ def _limitations(mode: str, unrealized_capabilities: tuple[str, ...] = ()) -> li limitations = [ "The libvirt participant runtime uses the deterministic domain adapter; no live participant domain is " "executed (issue #614).", - "Wazuh/SOC evidence is evaluator-only and, in native-live mode, is a translated native readback of generated " - "appliance state rather than full upstream Wazuh internals.", + "Wazuh/SOC evidence is evaluator-only structural evidence; daemon substrate state is not promoted to guest " + "or application observation.", ] if mode != "native-live": limitations.append( - "Deterministic mode does not realize a live libvirt substrate; topology and SOC readback are " - "compiled/structural, explicitly disclosed as not-live." + "Deterministic mode does not realize a live libvirt substrate; topology and defensive evidence channels " + "are compiled/structural, explicitly disclosed as not-live observations." ) if unrealized_capabilities: limitations.append( diff --git a/implementations/python/packages/aces_operations/_evidence_run_types.py b/implementations/python/packages/aces_operations/_evidence_run_types.py index 58fd8e3bc..127b8a728 100644 --- a/implementations/python/packages/aces_operations/_evidence_run_types.py +++ b/implementations/python/packages/aces_operations/_evidence_run_types.py @@ -19,7 +19,6 @@ from pathlib import Path from typing import Any, Protocol -from aces_backend_libvirt.techvault_native import NativeLibvirtProbe from aces_backend_protocols.capabilities import BackendManifest __all__ = [ @@ -28,7 +27,6 @@ "CompiledModel", "EvidenceArtifactInputs", "ExecutionPlan", - "NativeLibvirtProbe", "NodeDeployment", "ObservationBoundary", "ParticipantBehavior", @@ -116,5 +114,5 @@ class EvidenceArtifactInputs: manifest: BackendManifest proof: Mapping[str, Any] native_snapshot: Mapping[str, Any] | None - probe: NativeLibvirtProbe | None + native_cleanup_verified: bool | None unrealized_capabilities: tuple[str, ...] = () diff --git a/implementations/python/packages/aces_operations/_evidence_run_validation.py b/implementations/python/packages/aces_operations/_evidence_run_validation.py index bfafa6194..4e1ff5a2a 100644 --- a/implementations/python/packages/aces_operations/_evidence_run_validation.py +++ b/implementations/python/packages/aces_operations/_evidence_run_validation.py @@ -9,6 +9,7 @@ from __future__ import annotations +import hashlib import json import re from collections.abc import Mapping @@ -47,6 +48,7 @@ "scenario", "compiled_artifact", "backend", + "realization_facts", "realized_topology", "participant_action_proof", "terminal_observation", @@ -60,6 +62,29 @@ "invariant_ledger_refs", ) +_SHA256_RE = re.compile(r"sha256:[a-f0-9]{64}") +_DAEMON_REQUIRED_FIELDS = { + "domains": { + "address", + "name", + "architecture", + "image_policy", + "memory_mib", + "vcpus", + "network_attachments", + "observation_source", + }, + "networks": { + "address", + "name", + "cidr", + "gateway", + "internal", + "forward_mode", + "observation_source", + }, +} + def validate_libvirt_evidence_run_artifact(payload: Mapping[str, Any]) -> list[str]: """Validate a scenario-evidence artifact: schema, required surfaces, embedded contracts, redaction, boundary. @@ -77,9 +102,290 @@ def validate_libvirt_evidence_run_artifact(payload: Mapping[str, Any]) -> list[s problems.extend(_validate_embedded_contracts(payload)) problems.extend(_validate_redaction(payload)) problems.extend(_validate_boundary(payload)) + problems.extend(_validate_realization_sources(payload)) + return problems + + +def _validate_realization_sources(payload: Mapping[str, Any]) -> list[str]: + problems: list[str] = [] + if "native-realized" in json.dumps(payload, sort_keys=True, default=str): + problems.append("realization source violation: native-realized is not an admitted observation basis") + + facts = payload.get("realization_facts", {}) + if not isinstance(facts, Mapping): + return [*problems, "realization_facts must be a mapping"] + problems.extend(_validate_fact_sources(facts)) + topology = payload.get("realized_topology", {}) + problems.extend(_validate_topology_sources(topology)) + backend = payload.get("backend", {}) + provenance = backend.get("realization_provenance", {}) if isinstance(backend, Mapping) else {} + substrate_realized = isinstance(provenance, Mapping) and provenance.get("substrate_realized") is True + cleanup = facts.get("cleanup") + problems.extend(_validate_cleanup_source(cleanup)) + if substrate_realized: + problems.extend(_validate_realized_substrate(backend, facts, topology, provenance, cleanup)) + elif isinstance(provenance, Mapping) and provenance.get("basis") != "planned-not-realized": + problems.append("unrealized substrate basis must be planned-not-realized") + else: + problems.extend(_validate_unrealized_substrate(facts, provenance, cleanup)) + problems.extend(_validate_guest_observation_boundary(payload)) + return problems + + +def _validate_fact_sources(facts: Mapping[str, Any]) -> list[str]: + expected_sources = { + "authored": "authored", + "planned": "planned", + "driver_reported": "driver-reported", + "daemon_observed": "daemon-observed", + "guest_observed": "guest-observed", + } + return [ + f"realization source violation: {key}.source must be {source!r}" + for key, source in expected_sources.items() + if not isinstance(facts.get(key), Mapping) or facts[key].get("source") != source + ] + + +def _validate_topology_sources(topology: object) -> list[str]: + if not isinstance(topology, Mapping): + return [] + problems: list[str] = [] + if topology.get("basis") not in {"planned", "mixed-source"}: + problems.append("realized_topology.basis must be planned or mixed-source") + for collection in ("nodes", "networks"): + for item in topology.get(collection, ()) or (): + if isinstance(item, Mapping) and item.get("source") != "planned": + problems.append(f"realization source violation: realized_topology.{collection} is planned") + native_surface = topology.get("native_surface") + if isinstance(native_surface, Mapping) and native_surface.get("source") != "daemon-observed": + problems.append("realization source violation: native_surface must be daemon-observed") + return problems + + +def _validate_cleanup_source(cleanup: object) -> list[str]: + if isinstance(cleanup, Mapping) and cleanup.get("source") == "driver-reported": + return [] + return ["realization cleanup must be driver-reported"] + + +def _validate_realized_substrate( + backend: object, + facts: Mapping[str, Any], + topology: object, + provenance: Mapping[str, Any], + cleanup: object, +) -> list[str]: + daemon = facts.get("daemon_observed", {}) + daemon_items = _daemon_items(daemon) + problems = _validate_realized_provenance(provenance, cleanup) + problems.extend(_validate_daemon_observations(daemon)) + problems.extend(_validate_reported_addresses(facts, daemon_items)) + problems.extend(_validate_native_surface(topology, daemon)) + if isinstance(backend, Mapping): + problems.extend(_validate_realization_binding(backend, facts)) + else: + problems.append("daemon-observed substrate requires a realization binding") + return problems + + +def _validate_realized_provenance(provenance: Mapping[str, Any], cleanup: object) -> list[str]: + problems: list[str] = [] + if provenance.get("basis") != "daemon-observed-substrate": + problems.append("realization provenance basis must be daemon-observed-substrate") + cleanup_verified = provenance.get("cleanup_verified") + expected_cleanup_status = "verified" if cleanup_verified is True else "failed" + cleanup_consistent = ( + isinstance(cleanup_verified, bool) + and isinstance(cleanup, Mapping) + and cleanup.get("status") == expected_cleanup_status + ) + if not cleanup_consistent: + problems.append("daemon-observed substrate requires a consistent cleanup outcome") + return problems + + +def _validate_daemon_observations(daemon: object) -> list[str]: + problems: list[str] = [] + domains = daemon.get("domains", ()) if isinstance(daemon, Mapping) else () + if not isinstance(domains, list | tuple) or not domains: + problems.append("daemon-observed substrate requires at least one observed domain") + for collection in ("domains", "networks"): + values = daemon.get(collection, ()) if isinstance(daemon, Mapping) else () + for item in values: + if isinstance(item, Mapping): + if item.get("observation_source") != "daemon-observed": + problems.append(f"realization source violation: daemon_observed.{collection} item source") + problems.extend(_validate_daemon_observation_item(collection, item)) + return problems + + +def _daemon_items(daemon: object) -> list[Mapping[str, Any]]: + if not isinstance(daemon, Mapping): + return [] + return [ + item + for collection in ("domains", "networks") + for item in daemon.get(collection, ()) + if isinstance(item, Mapping) + ] + + +def _validate_reported_addresses( + facts: Mapping[str, Any], + daemon_items: list[Mapping[str, Any]], +) -> list[str]: + observed_addresses = {item.get("address") for item in daemon_items} + driver_reported = facts.get("driver_reported", {}) + reported_addresses = driver_reported.get("realized_addresses", ()) if isinstance(driver_reported, Mapping) else () + valid = ( + isinstance(reported_addresses, list | tuple) + and all(isinstance(item, str) for item in reported_addresses) + and set(reported_addresses) == observed_addresses + ) + return [] if valid else ["driver-reported addresses do not match daemon observations"] + + +def _validate_native_surface(topology: object, daemon: object) -> list[str]: + native_surface = topology.get("native_surface") if isinstance(topology, Mapping) else None + if not isinstance(native_surface, Mapping): + return ["daemon-observed substrate requires a native surface"] + problems: list[str] = [] + for collection in ("domains", "networks"): + observed_names = sorted( + str(item.get("name")) + for item in (daemon.get(collection, ()) if isinstance(daemon, Mapping) else ()) + if isinstance(item, Mapping) + ) + surface_names = native_surface.get(collection, ()) + if not isinstance(surface_names, list | tuple) or sorted(str(item) for item in surface_names) != observed_names: + problems.append(f"native surface {collection} do not match daemon observations") + return problems + + +def _validate_unrealized_substrate( + facts: Mapping[str, Any], + provenance: Mapping[str, Any], + cleanup: object, +) -> list[str]: + problems: list[str] = [] + daemon = facts.get("daemon_observed", {}) + daemon_domains = daemon.get("domains", ()) if isinstance(daemon, Mapping) else () + daemon_networks = daemon.get("networks", ()) if isinstance(daemon, Mapping) else () + if daemon_domains or daemon_networks or facts.get("binding") is not None: + problems.append("unrealized substrate cannot publish daemon observations or realization binding") + if provenance.get("cleanup_verified") is not None: + problems.append("unrealized substrate cleanup must be not-applicable") + if isinstance(cleanup, Mapping) and cleanup.get("status") != "not-required": + problems.append("unrealized substrate cleanup status must be not-required") + return problems + + +def _validate_guest_observation_boundary(payload: Mapping[str, Any]) -> list[str]: + defensive = payload.get("defensive_evidence", {}) + if isinstance(defensive, Mapping) and "soc_readback" in defensive: + return ["guest observation violation: daemon substrate cannot supply SOC readback"] + return [] + + +def _validate_realization_binding(backend: Mapping[str, Any], facts: Mapping[str, Any]) -> list[str]: + binding = facts.get("binding") + manifest = backend.get("manifest", {}) + envelope = manifest.get("realization_envelope", {}) if isinstance(manifest, Mapping) else {} + if not isinstance(binding, Mapping) or not isinstance(envelope, Mapping): + return ["daemon-observed substrate requires a realization binding"] + problems = _validate_binding_identity(binding, envelope) + problems.extend(_validate_boot_artifact_binding(binding)) + expected_driver_digest = _driver_configuration_digest(binding) + if binding.get("driver_configuration_digest") != expected_driver_digest: + problems.append("realization binding driver configuration digest does not match its material") + return problems + + +def _validate_binding_identity(binding: Mapping[str, Any], envelope: Mapping[str, Any]) -> list[str]: + problems: list[str] = [] + if binding.get("realization_envelope_digest") != envelope.get("digest"): + problems.append("realization binding envelope digest does not match backend manifest") + if binding.get("configuration_digest") != envelope.get("configuration_digest"): + problems.append("realization binding configuration digest does not match backend manifest") + driver_digest = binding.get("driver_configuration_digest") + if not _is_canonical_sha256(driver_digest): + problems.append("realization binding requires a canonical driver configuration digest") + if binding.get("driver") != "techvault-appliance": + problems.append("realization binding driver does not match the TechVault appliance") + for field_name in ("connection_uri_digest", "name_prefix_digest"): + if not _is_canonical_sha256(binding.get(field_name)): + problems.append(f"realization binding requires canonical {field_name}") + return problems + + +def _validate_boot_artifact_binding(binding: Mapping[str, Any]) -> list[str]: + problems: list[str] = [] + boot_artifacts = binding.get("boot_artifact_digests") + if not isinstance(boot_artifacts, Mapping) or set(boot_artifacts) != {"kernel", "initramfs"}: + problems.append("realization binding requires kernel and initramfs artifact digests") + elif not all(_is_canonical_sha256(value) for value in boot_artifacts.values()): + problems.append("realization binding boot artifact digests must be canonical sha256 values") + return problems + + +def _driver_configuration_digest(binding: Mapping[str, Any]) -> str: + material = { + "driver": binding.get("driver"), + "configuration_digest": binding.get("configuration_digest"), + "boot_artifact_digests": binding.get("boot_artifact_digests"), + "connection_uri_digest": binding.get("connection_uri_digest"), + "name_prefix_digest": binding.get("name_prefix_digest"), + } + encoded = json.dumps(material, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8") + return "sha256:" + hashlib.sha256(encoded).hexdigest() + + +def _is_canonical_sha256(value: object) -> bool: + return isinstance(value, str) and _SHA256_RE.fullmatch(value) is not None + + +def _validate_daemon_observation_item(collection: str, item: Mapping[str, Any]) -> list[str]: + noun = "domain" if collection == "domains" else "network" + problems: list[str] = [] + if set(item) != _DAEMON_REQUIRED_FIELDS[collection] or not _valid_observation_identity(item): + problems.append(f"incomplete daemon {noun} observation") + elif collection == "domains" and not _valid_domain_observation(item): + problems.append("incomplete daemon domain observation") + elif collection == "networks" and not _valid_network_observation(item): + problems.append("incomplete daemon network observation") return problems +def _valid_observation_identity(item: Mapping[str, Any]) -> bool: + return all(_nonempty_string(item.get(key)) for key in ("address", "name")) + + +def _valid_domain_observation(item: Mapping[str, Any]) -> bool: + checks = ( + _nonempty_string(item.get("architecture")), + _nonempty_string(item.get("image_policy")), + isinstance(item.get("memory_mib"), int) and item["memory_mib"] > 0, + isinstance(item.get("vcpus"), int) and item["vcpus"] > 0, + isinstance(item.get("network_attachments"), list | tuple), + ) + return all(checks) + + +def _valid_network_observation(item: Mapping[str, Any]) -> bool: + checks = ( + _nonempty_string(item.get("cidr")), + _nonempty_string(item.get("gateway")), + isinstance(item.get("internal"), bool), + item.get("forward_mode") in {"none", "nat"}, + ) + return all(checks) + + +def _nonempty_string(value: object) -> bool: + return isinstance(value, str) and bool(value) + + def _try_validate(model_cls: type[BaseModel], value: object, label: str) -> list[str]: """Validate ``value`` against ``model_cls``; return a one-item problem list on failure.""" try: diff --git a/implementations/python/packages/aces_operations/_techvault_cleanup.py b/implementations/python/packages/aces_operations/_techvault_cleanup.py new file mode 100644 index 000000000..16c9e8c8d --- /dev/null +++ b/implementations/python/packages/aces_operations/_techvault_cleanup.py @@ -0,0 +1,57 @@ +"""Verified cleanup for captured TechVault native-substrate reports.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence + +from aces_backend_libvirt.techvault_native import TechVaultNativeLibvirtDriver + + +def cleanup_native_snapshot( + driver: TechVaultNativeLibvirtDriver, + snapshot: Mapping[str, object], +) -> tuple[bool, tuple[str, ...]]: + """Destroy every captured native address and require explicit absence handles.""" + + domains = _addresses(snapshot.get("domains")) + networks = _addresses(snapshot.get("networks")) + if not domains: + return False, ("native cleanup requires a captured domain inventory",) + try: + result = driver.destroy(networks=networks, domains=domains) + except Exception: + return False, ("native cleanup failed",) + diagnostics = tuple(f"{item.code} at {item.address}" for item in result.diagnostics) + verified = ( + not diagnostics + and _absence_handles(result.domains, domains) + and _absence_handles(result.networks, networks) + and driver.last_snapshot == {} + ) + if not verified and not diagnostics: + diagnostics = ("native cleanup did not return a complete verified-absence inventory",) + return verified, diagnostics + + +def _addresses(raw: object) -> tuple[str, ...]: + if not isinstance(raw, list | tuple): + return () + return tuple( + str(item["address"]) + for item in raw + if isinstance(item, Mapping) and isinstance(item.get("address"), str) and item.get("address") + ) + + +def _absence_handles( + handles: Sequence[object], + requested: tuple[str, ...], +) -> bool: + return ( + len(handles) == len(requested) + and {getattr(handle, "address", None) for handle in handles} == set(requested) + and all(getattr(handle, "realized", None) is False for handle in handles) + ) + + +__all__ = ["cleanup_native_snapshot"] diff --git a/implementations/python/packages/aces_operations/libvirt_evidence_run.py b/implementations/python/packages/aces_operations/libvirt_evidence_run.py index c6a8e893e..06ac526f8 100644 --- a/implementations/python/packages/aces_operations/libvirt_evidence_run.py +++ b/implementations/python/packages/aces_operations/libvirt_evidence_run.py @@ -21,14 +21,13 @@ * ``deterministic`` (default, no libvirt daemon — used by tests / CI): participant lifecycle proof + compiled topology + structural negative-boundary evidence + an - evaluator-only translated SOC-readback record explicitly marked as not upstream - Wazuh. -* ``native-live`` (operator-run; injected/default native driver + probe): - additionally realizes the libvirt VM/network substrate (including the scenario's - content and account placements, via cloud-init) and records the native topology - and native SOC readback. Any provisioning capability the backend genuinely cannot - realize is disclosed as ``unrealized_capabilities`` (and fails native-live), not - faked; orchestration/evaluation planes are outside a provisioning-only target. + evaluator-only declaration of defensive evidence channels; no SOC state is + observed. +* ``native-live`` (operator-run; injected/default native driver): + additionally realizes only the bounded VM/network substrate that passes the + TechVault concern-admission gate and records independently daemon-observed fields. + Guest content, accounts, features, ACLs, services, and SOC state are rejected or + disclosed as not observed; orchestration/evaluation remain separate planes. Artifact assembly lives in ``_evidence_run_artifact`` and validation in ``_evidence_run_validation`` (kept separate for the ADR-015 source-size cap). @@ -43,7 +42,7 @@ from typing import Any, Literal from aces_backend_libvirt.target import create_libvirt_target -from aces_backend_libvirt.techvault_native import NativeLibvirtProbe, TechVaultNativeLibvirtDriver +from aces_backend_libvirt.techvault_native import TechVaultNativeLibvirtDriver from aces_runtime.control_plane import RuntimeControlPlane from aces_runtime.manager import RuntimeManager from aces_sdl.parser import parse_sdl_file @@ -56,6 +55,7 @@ ParticipantBehavior, ) from aces_operations._evidence_run_validation import validate_libvirt_evidence_run_artifact +from aces_operations._techvault_cleanup import cleanup_native_snapshot from aces_operations.deterministic_participant_fixtures import ( build_participant_admission_request, iter_admission_pairs, @@ -97,9 +97,6 @@ class LibvirtEvidenceRunConfig: evidence_source_mode: EvidenceSourceMode = "deterministic" connection_uri: str = "qemu:///system" - boot_timeout_seconds: int = 180 - appliance_memory_mib: int = 128 - clean_boot: bool = True @dataclass(frozen=True) @@ -141,7 +138,6 @@ def run_libvirt_evidence_run( run_id: str, config: LibvirtEvidenceRunConfig | None = None, driver_factory: Callable[[], TechVaultNativeLibvirtDriver] | None = None, - probe: NativeLibvirtProbe | None = None, ) -> LibvirtEvidenceRunReport: """Produce the libvirt scenario evaluator-evidence artifact for ``scenario_path``.""" settings = config or LibvirtEvidenceRunConfig() @@ -163,8 +159,8 @@ def run_libvirt_evidence_run( target = create_libvirt_target(participant_runtime=True, driver=native_driver) execution_plan = RuntimeManager(target).plan(parse_sdl_file(scenario_path)) control_plane = RuntimeControlPlane(target) - except Exception as exc: - checks.append(EvidenceCheck("scenario_plan", False, (f"failed to plan scenario: {exc}",))) + except Exception: + checks.append(EvidenceCheck("scenario_plan", False, ("failed to plan scenario",))) return LibvirtEvidenceRunReport(scenario_path.name, run_id, str(project_dir), mode, tuple(checks)) model = execution_plan.model @@ -172,12 +168,16 @@ def run_libvirt_evidence_run( checks.append(EvidenceCheck("participant_action_proof", proof["lifecycle_clean"], tuple(proof["diagnostics"]))) native_snapshot: Mapping[str, Any] | None = None + native_cleanup_verified: bool | None = None unrealized_capabilities: tuple[str, ...] = () if mode == "native-live": native_snapshot, realize_check, unrealized_capabilities = _realize_native_substrate( execution_plan, control_plane, native_driver ) checks.append(realize_check) + if native_snapshot is not None and native_driver is not None: + native_cleanup_verified, cleanup_diagnostics = cleanup_native_snapshot(native_driver, native_snapshot) + checks.append(EvidenceCheck("native_substrate_cleanup", native_cleanup_verified, cleanup_diagnostics)) inputs = EvidenceArtifactInputs( scenario_path=scenario_path, @@ -188,7 +188,7 @@ def run_libvirt_evidence_run( manifest=execution_plan.manifest, proof=proof, native_snapshot=native_snapshot, - probe=probe if mode == "native-live" else None, + native_cleanup_verified=native_cleanup_verified, unrealized_capabilities=unrealized_capabilities, ) artifact, artifact_path = _finalize_artifact(inputs, project_dir, checks) @@ -222,8 +222,8 @@ def _persist_artifact( try: target_path = run_artifact_path(project_dir, run_id, "scenario-evidence", "libvirt-scenario-evidence-run.json") atomic_write_json_artifact(target_path, artifact) - except OSError as exc: - return None, EvidenceCheck("artifact_write", False, (f"artifact write failed: {exc}",)) + except OSError: + return None, EvidenceCheck("artifact_write", False, ("artifact write failed",)) return str(target_path), EvidenceCheck("artifact_write", True) @@ -332,8 +332,6 @@ def factory() -> TechVaultNativeLibvirtDriver: state_dir=state_dir, connection_uri=settings.connection_uri, name_prefix="aces-evidence", - appliance_memory_mib=settings.appliance_memory_mib, - clean_existing=settings.clean_boot, ) return factory @@ -346,23 +344,18 @@ def _realize_native_substrate( ) -> tuple[Mapping[str, Any] | None, EvidenceCheck, tuple[str, ...]]: """Realize the libvirt provisioning substrate (VMs + networks) for the scenario. - The libvirt backend realizes the full provisioning plane — nodes, networks, and - content/account/feature placements (via cloud-init) — so a governed provisioning - capability is realized rather than disclosed. Any capability the backend still - cannot realize (an ungoverned provisioning term, or orchestration/evaluation - outside a provisioning-only target) is returned as ``unrealized_capabilities`` - and disclosed in the artifact. The check is gating and passes only when the - native driver realized at least one domain — native-live must never report - success without realizing — so a scenario the backend cannot provision fails - native-live and surfaces its unrealized capabilities rather than silently passing. + Native-live passes only when the runtime operation succeeds and the fresh driver + report contains independently daemon-observed domains bound to the selected + realization-envelope/configuration identity. A domain handle or planned matrix + alone is never sufficient. """ if native_driver is None: return None, EvidenceCheck("native_substrate_realization", False, ("no native driver",)), () try: receipt = control_plane.submit_provisioning(execution_plan.provisioning) status = control_plane.get_operation(receipt.operation_id) - except Exception as exc: - return None, EvidenceCheck("native_substrate_realization", False, (f"native realization raised: {exc}",)), () + except Exception: + return None, EvidenceCheck("native_substrate_realization", False, ("native realization failed",)), () unrealized = _dedupe( f"{d.code}: {d.message}" for source in (execution_plan.diagnostics, () if status is None else status.diagnostics) @@ -370,7 +363,8 @@ def _realize_native_substrate( if d.is_error ) snapshot = native_driver.last_snapshot - realized = _snapshot_has_domains(snapshot) + operation_succeeded = status is not None and status.state.value == "succeeded" + realized = operation_succeeded and _snapshot_has_daemon_observations(snapshot) check = EvidenceCheck( "native_substrate_realization", realized, @@ -388,8 +382,14 @@ def _dedupe(items: Iterable[str]) -> tuple[str, ...]: return tuple(seen) -def _snapshot_has_domains(snapshot: Mapping[str, Any] | None) -> bool: +def _snapshot_has_daemon_observations(snapshot: Mapping[str, Any] | None) -> bool: if not isinstance(snapshot, Mapping): return False domains = snapshot.get("domains", ()) - return isinstance(domains, list | tuple) and len(domains) > 0 + binding = snapshot.get("binding") + return ( + snapshot.get("source") == "daemon-observed" + and isinstance(domains, list | tuple) + and len(domains) > 0 + and isinstance(binding, Mapping) + ) diff --git a/implementations/python/packages/aces_operations/run_artifacts.py b/implementations/python/packages/aces_operations/run_artifacts.py index 8a5563e2c..8f83f6428 100644 --- a/implementations/python/packages/aces_operations/run_artifacts.py +++ b/implementations/python/packages/aces_operations/run_artifacts.py @@ -31,6 +31,15 @@ def is_valid_run_id_label(run_id: str) -> bool: return bool(RUN_ID_LABEL_PATTERN.match(run_id)) +def portable_artifact_ref(path: Path) -> str: + """Return a repository-portable reference without exposing a host path.""" + + for anchor in ("examples", "contracts", "specs", "docs"): + if anchor in path.parts: + return "/".join(path.parts[path.parts.index(anchor) :]) + return path.name + + def run_artifact_path(output_dir: Path, run_id: str, subdir: str, filename: str) -> Path: """Return the archive path ``/runs///``. diff --git a/implementations/python/packages/aces_operations/techvault_live.py b/implementations/python/packages/aces_operations/techvault_live.py index e4fc67917..e6dd34da8 100644 --- a/implementations/python/packages/aces_operations/techvault_live.py +++ b/implementations/python/packages/aces_operations/techvault_live.py @@ -2,6 +2,9 @@ from __future__ import annotations +import hashlib +import json +import re from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass from datetime import UTC, datetime @@ -9,38 +12,22 @@ from typing import Any from aces_backend_libvirt.target import create_libvirt_target -from aces_backend_libvirt.techvault_native import ( - NativeLibvirtProbe, - TechVaultNativeLibvirtDriver, - check_native_readiness, - expected_surface, - native_soc_readback, -) +from aces_backend_libvirt.techvault_native import TechVaultNativeLibvirtDriver, expected_surface from aces_runtime.control_plane import RuntimeControlPlane from aces_runtime.manager import RuntimeManager from aces_sdl.parser import parse_sdl_file +from aces_operations._evidence_run_validation import redaction_violations +from aces_operations._techvault_cleanup import cleanup_native_snapshot from aces_operations.run_artifacts import ( atomic_write_json_artifact, is_valid_run_id_label, + portable_artifact_ref, run_artifact_path, ) -DEFAULT_EVENT_WINDOW_SECONDS = 180 -DEFAULT_BOOT_TIMEOUT_SECONDS = 180 -_FULL_SOC_NODES = frozenset( - { - "wazuh-manager", - "wazuh-indexer", - "wazuh-dashboard", - "suricata", - "misp", - "thehive", - "cortex", - "shuffle-backend", - "shuffle-frontend", - } -) +_LIVE_SCHEMA = "aces.libvirt.techvault-native-live-gate/v1" +_SHA256_RE = re.compile(r"sha256:[a-f0-9]{64}") @dataclass(frozen=True) @@ -83,11 +70,7 @@ def render(self) -> str: class TechVaultLiveConfig: """Runtime controls for the native ACES/libvirt TechVault live gate.""" - clean_boot: bool = True - event_window_seconds: int = DEFAULT_EVENT_WINDOW_SECONDS - boot_timeout_seconds: int = DEFAULT_BOOT_TIMEOUT_SECONDS connection_uri: str = "qemu:///system" - appliance_memory_mib: int = 128 def validate_techvault_live( @@ -97,7 +80,6 @@ def validate_techvault_live( run_id: str, config: TechVaultLiveConfig | None = None, driver_factory: Callable[[], TechVaultNativeLibvirtDriver] | None = None, - probe: NativeLibvirtProbe | None = None, ) -> TechVaultLiveReport: """Boot and validate a TechVault SDL through native ACES/libvirt.""" @@ -116,8 +98,6 @@ def validate_techvault_live( state_dir=run_dir / "libvirt", connection_uri=settings.connection_uri, name_prefix="aces-techvault", - appliance_memory_mib=settings.appliance_memory_mib, - clean_existing=settings.clean_boot, ) ) target = create_libvirt_target(driver=driver, name_prefix="aces-techvault") @@ -128,27 +108,17 @@ def validate_techvault_live( boot_check = _apply_plan(target, scenario_path, driver) checks.append(boot_check) snapshot = driver.last_snapshot - evidence: dict[str, object] = {} if boot_check.passed: checks.append(_substrate_independence_check(snapshot)) checks.append(_surface_check(snapshot)) - readiness_check = _readiness_check( - snapshot, probe or NativeLibvirtProbe(), settings.boot_timeout_seconds - ) - checks.append(readiness_check) - checks.append(_kali_reachability_check(snapshot, probe or NativeLibvirtProbe())) - soc_check, soc_evidence = _soc_stack_readback_check(snapshot) - checks.append(soc_check) - evidence.update(soc_evidence) - checks.append(_variation_check(snapshot)) + cleanup_ok, cleanup_diagnostics = cleanup_native_snapshot(driver, snapshot) + checks.append(LiveCheck("verified_native_cleanup", cleanup_ok, cleanup_diagnostics)) manifest_path = _write_manifest( output_dir, run_id, scenario_path, - driver, + snapshot, checks, - evidence, - clean_boot=settings.clean_boot, ) checks.append( LiveCheck( @@ -170,8 +140,8 @@ def _plan_scenario(target: object, scenario_path: Path) -> tuple[object | None, try: scenario = parse_sdl_file(scenario_path) execution_plan = RuntimeManager(target).plan(scenario) - except Exception as exc: - return None, LiveCheck("planning", False, (f"scenario planning failed: {exc}",)) + except Exception: + return None, LiveCheck("planning", False, ("scenario planning failed",)) diagnostics = tuple(f"{diag.code}: {diag.message}" for diag in execution_plan.diagnostics if diag.is_error) if diagnostics: return scenario, LiveCheck("planning", False, diagnostics) @@ -187,8 +157,8 @@ def _apply_plan(target: object, scenario_path: Path, driver: TechVaultNativeLibv control_plane = RuntimeControlPlane(target, initial_snapshot=execution_plan.base_snapshot) receipt = control_plane.submit_provisioning(execution_plan.provisioning) status = control_plane.get_operation(receipt.operation_id) - except Exception as exc: - diagnostics = (f"provisioning raised: {exc}",) + except Exception: + diagnostics = ("provisioning failed",) else: if status is None: diagnostics = ("control plane did not record provisioning status",) @@ -226,96 +196,54 @@ def _surface_check(snapshot: Mapping[str, Any]) -> LiveCheck: return LiveCheck("model_derived_native_surface", not diagnostics, tuple(diagnostics)) -def _readiness_check(snapshot: Mapping[str, Any], probe: NativeLibvirtProbe, timeout_seconds: int) -> LiveCheck: - ok, diagnostics = check_native_readiness(snapshot, probe=probe, timeout_seconds=timeout_seconds) - return LiveCheck("native_domain_service_readiness", ok, tuple(diagnostics)) - - -def _kali_reachability_check(snapshot: Mapping[str, Any], probe: NativeLibvirtProbe) -> LiveCheck: - kali = _domain_by_name(snapshot, "kali") - if kali is None: - return LiveCheck("kali_target_network_reachability", True, ("scenario does not include kali",)) - targets = _targets_sharing_network(kali, snapshot) - diagnostics: list[str] = [] - for target in targets: - ip = _first_ip(target) - if ip and not probe.ping(ip).ok: - diagnostics.append(f"kali-shared target {target.get('name')} is not reachable at {ip}") - return LiveCheck("kali_target_network_reachability", not diagnostics, tuple(diagnostics)) - - -def _soc_stack_readback_check(snapshot: Mapping[str, Any]) -> tuple[LiveCheck, dict[str, object]]: - names = {str(domain.get("name", "")) for domain in _domains(snapshot)} - evidence = {"soc_readback": native_soc_readback(snapshot)} - diagnostics: list[str] = [] - if _FULL_SOC_NODES.issubset(names): - diagnostics.extend(_full_soc_diagnostics(evidence["soc_readback"])) - return LiveCheck("native_soc_stack_readback", not diagnostics, tuple(diagnostics)), evidence - - -def _full_soc_diagnostics(readback: object) -> tuple[str, ...]: - if not isinstance(readback, Mapping): - return ("native SOC readback is not structured",) - diagnostics: list[str] = [] - suricata = readback.get("suricata", {}) - case_mgmt = readback.get("case_management", {}) - agents = readback.get("wazuh_active_agents", ()) - if not agents: - diagnostics.append("native Wazuh readback reported no active agents") - diagnostics.extend(_suricata_diagnostics(suricata)) - if not _has_case_management(case_mgmt): - diagnostics.append("native case-management readback is missing TheHive, MISP, Cortex, or Shuffle") - return tuple(diagnostics) - - -def _suricata_diagnostics(suricata: object) -> tuple[str, ...]: - if not isinstance(suricata, Mapping): - return ("native Suricata readback is not structured",) - diagnostics: list[str] = [] - if suricata.get("rules_loaded", 0) <= 0: - diagnostics.append("native Suricata readback reported no loaded rules") - if suricata.get("rules_failed", 0) != 0: - diagnostics.append("native Suricata readback reported failed rules") - if suricata.get("kernel_drops", 0) != 0: - diagnostics.append("native Suricata readback reported kernel drops") - return tuple(diagnostics) - - -def _has_case_management(case_mgmt: object) -> bool: - return isinstance(case_mgmt, Mapping) and all( - case_mgmt.get(name) for name in ("thehive", "misp", "cortex", "shuffle") - ) - - -def _variation_check(snapshot: Mapping[str, Any]) -> LiveCheck: - roles = {str(domain.get("role", "")) for domain in _domains(snapshot) if domain.get("role")} - if len(roles) >= 1 and len(_domains(snapshot)) != 30: - return LiveCheck("scenario_variant_composability", True) - if len(roles) >= 4: - return LiveCheck("scenario_variant_composability", True) - return LiveCheck("scenario_variant_composability", False, ("native surface collapsed to too few role families",)) - - def _write_manifest( output_dir: Path, run_id: str, scenario_path: Path, - driver: TechVaultNativeLibvirtDriver, + snapshot: Mapping[str, object], checks: Sequence[LiveCheck], - evidence: Mapping[str, object], - *, - clean_boot: bool, ) -> str | None: target = run_artifact_path(output_dir, run_id, "live-gate", "manifest.json") + native_succeeded = any(check.name == "aces_libvirt_native_boot" and check.passed for check in checks) + observed_snapshot = snapshot if native_succeeded else {} + native_surface = expected_surface(observed_snapshot) + cleanup_check = next((check for check in checks if check.name == "verified_native_cleanup"), None) + cleanup_status = _live_cleanup_status(native_succeeded, cleanup_check) payload = { - "schema": "aces.libvirt.techvault-native-live-gate/v1", - "scenario": {"path": str(scenario_path), "name": scenario_path.name.split(".")[0]}, + "schema": _LIVE_SCHEMA, + "scenario": { + "path": portable_artifact_ref(scenario_path), + "name": scenario_path.name.split(".")[0], + }, "run_id": run_id, "recorded_at": datetime.now(UTC).isoformat(), - "clean_boot": clean_boot, - "aces_libvirt": { - "substrate": "libvirt-qemu-initramfs", - "surface": expected_surface(driver.last_snapshot), + "cleanup_policy": "current-operation-owned-resources-only", + "cleanup": {"source": "driver-reported", "status": cleanup_status}, + "realization_binding": observed_snapshot.get("binding"), + "realization_facts": { + "authored": { + "source": "authored", + "scenario_ref": portable_artifact_ref(scenario_path), + }, + "planned": { + "source": "planned", + "status": "accepted" + if any(check.name == "planning" and check.passed for check in checks) + else "failed", + }, + "driver_reported": { + "source": "driver-reported", + "status": "succeeded" if native_succeeded else "failed", + }, + "daemon_observed": { + "source": "daemon-observed", + "domains": list(native_surface["domains"]), + "networks": list(native_surface["networks"]), + }, + "guest_observed": { + "source": "guest-observed", + "status": "not-observed", + }, }, "validation": { "ok": all(check.passed for check in checks), @@ -323,9 +251,9 @@ def _write_manifest( {"name": check.name, "ok": check.passed, "diagnostics": list(check.diagnostics)} for check in checks ], }, - "snapshot": driver.last_snapshot, - "evidence": dict(evidence), } + if validate_techvault_live_manifest(payload): + return None try: atomic_write_json_artifact(target, payload) except OSError: @@ -333,41 +261,185 @@ def _write_manifest( return str(target) -def _domains(snapshot: Mapping[str, Any]) -> tuple[Mapping[str, Any], ...]: - raw = snapshot.get("domains", ()) - return tuple(item for item in raw if isinstance(item, Mapping)) if isinstance(raw, list | tuple) else () +def _live_cleanup_status(native_succeeded: bool, cleanup_check: LiveCheck | None) -> str: + if not native_succeeded: + return "not-required" + return "verified" if cleanup_check is not None and cleanup_check.passed else "failed" + + +def validate_techvault_live_manifest(payload: Mapping[str, object]) -> list[str]: + """Validate source separation and redaction before a live manifest is written.""" + + violations = _validate_live_manifest_metadata(payload) + violations.extend(_validate_live_scenario(payload)) + violations.extend(_validate_live_realization_facts(payload)) + violations.extend(_validate_live_forbidden_terms(payload)) + violations.extend(redaction_violations(payload)) + return violations + + +def _validate_live_manifest_metadata(payload: Mapping[str, object]) -> list[str]: + violations: list[str] = [] + if payload.get("schema") != _LIVE_SCHEMA: + violations.append("invalid TechVault live manifest schema") + if payload.get("cleanup_policy") != "current-operation-owned-resources-only": + violations.append("invalid TechVault cleanup policy") + cleanup = payload.get("cleanup") + if not isinstance(cleanup, Mapping) or cleanup.get("source") != "driver-reported": + violations.append("cleanup must be a driver-reported section") + return violations + + +def _validate_live_scenario(payload: Mapping[str, object]) -> list[str]: + scenario = payload.get("scenario") + path = scenario.get("path") if isinstance(scenario, Mapping) else None + if not isinstance(path, str) or not path or path.startswith("/") or ".." in Path(path).parts: + return ["scenario.path must be a portable reference"] + return [] + + +def _validate_live_realization_facts(payload: Mapping[str, object]) -> list[str]: + facts = payload.get("realization_facts") + if not isinstance(facts, Mapping): + return ["realization_facts must be a mapping"] + violations = _validate_live_fact_sources(facts) + daemon = facts.get("daemon_observed") + domains = daemon.get("domains", ()) if isinstance(daemon, Mapping) else () + networks = daemon.get("networks", ()) if isinstance(daemon, Mapping) else () + violations.extend(_validate_live_daemon_names(domains, networks)) + driver_reported = facts.get("driver_reported") + succeeded = isinstance(driver_reported, Mapping) and driver_reported.get("status") == "succeeded" + binding = payload.get("realization_binding") + cleanup = payload.get("cleanup") + cleanup_status = cleanup.get("status") if isinstance(cleanup, Mapping) else None + violations.extend(_validate_live_outcome(succeeded, cleanup_status, domains, networks, binding)) + if isinstance(binding, Mapping): + violations.extend(_validate_live_binding(binding)) + return violations + + +def _validate_live_fact_sources(facts: Mapping[str, object]) -> list[str]: + expected_sources = { + "authored": "authored", + "planned": "planned", + "driver_reported": "driver-reported", + "daemon_observed": "daemon-observed", + "guest_observed": "guest-observed", + } + return [ + f"{key}.source must be {source}" + for key, source in expected_sources.items() + if not isinstance(facts.get(key), Mapping) or facts[key].get("source") != source + ] + + +def _validate_live_daemon_names(domains: object, networks: object) -> list[str]: + violations: list[str] = [] + for collection_name, values in (("domains", domains), ("networks", networks)): + if not _bounded_native_names(values): + violations.append(f"daemon_observed.{collection_name} must contain bounded native names") + return violations + + +def _bounded_native_names(values: object) -> bool: + return isinstance(values, list | tuple) and all(isinstance(value, str) and value for value in values) + + +def _validate_live_outcome( + succeeded: bool, + cleanup_status: object, + domains: object, + networks: object, + binding: object, +) -> list[str]: + if succeeded: + return _validate_successful_live_outcome(cleanup_status, domains, binding) + return _validate_failed_live_outcome(cleanup_status, domains, networks, binding) + + +def _validate_successful_live_outcome(cleanup_status: object, domains: object, binding: object) -> list[str]: + violations: list[str] = [] + if cleanup_status not in {"verified", "failed"}: + violations.append("successful native realization requires an explicit cleanup outcome") + if not domains or not isinstance(binding, Mapping): + violations.append("successful native run requires daemon observations and realization binding") + return violations + + +def _validate_failed_live_outcome( + cleanup_status: object, + domains: object, + networks: object, + binding: object, +) -> list[str]: + violations: list[str] = [] + if cleanup_status != "not-required": + violations.append("failed native realization must mark cleanup not-required") + if domains or networks or binding is not None: + violations.append("failed native run cannot publish stale daemon observations or binding") + return violations + + +def _validate_live_binding(binding: Mapping[str, object]) -> list[str]: + violations = _validate_live_binding_identity(binding) + violations.extend(_validate_live_boot_digests(binding)) + expected_digest = _live_binding_digest(binding) + if binding.get("driver_configuration_digest") != expected_digest: + violations.append("realization binding driver configuration digest does not match its material") + return violations + + +def _validate_live_binding_identity(binding: Mapping[str, object]) -> list[str]: + violations: list[str] = [] + if binding.get("driver") != "techvault-appliance": + violations.append("realization binding driver must be techvault-appliance") + for field_name in ( + "realization_envelope_digest", + "configuration_digest", + "driver_configuration_digest", + "connection_uri_digest", + "name_prefix_digest", + ): + if not _canonical_digest(binding.get(field_name)): + violations.append(f"realization binding requires canonical {field_name}") + return violations + + +def _validate_live_boot_digests(binding: Mapping[str, object]) -> list[str]: + boot_artifacts = binding.get("boot_artifact_digests") + if not isinstance(boot_artifacts, Mapping) or set(boot_artifacts) != {"kernel", "initramfs"}: + return ["realization binding requires kernel and initramfs digests"] + if not all(_canonical_digest(value) for value in boot_artifacts.values()): + return ["realization binding boot digests must be canonical sha256 values"] + return [] + + +def _live_binding_digest(binding: Mapping[str, object]) -> str: + material = { + "driver": binding.get("driver"), + "configuration_digest": binding.get("configuration_digest"), + "boot_artifact_digests": binding.get("boot_artifact_digests"), + "connection_uri_digest": binding.get("connection_uri_digest"), + "name_prefix_digest": binding.get("name_prefix_digest"), + } + encoded = json.dumps(material, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8") + return "sha256:" + hashlib.sha256(encoded).hexdigest() -def _domain_by_name(snapshot: Mapping[str, Any], name: str) -> Mapping[str, Any] | None: - return next((domain for domain in _domains(snapshot) if domain.get("name") == name), None) +def _canonical_digest(value: object) -> bool: + return isinstance(value, str) and _SHA256_RE.fullmatch(value) is not None -def _targets_sharing_network(kali: Mapping[str, Any], snapshot: Mapping[str, Any]) -> list[Mapping[str, Any]]: - kali_networks = { - str(interface.get("network_address", "")) for interface in _interfaces(kali) if interface.get("network_address") - } - targets: list[Mapping[str, Any]] = [] - for domain in _domains(snapshot): - if domain.get("name") == "kali": - continue - domain_networks = { - str(interface.get("network_address", "")) - for interface in _interfaces(domain) - if interface.get("network_address") - } - if kali_networks & domain_networks: - targets.append(domain) - return targets - - -def _interfaces(domain: Mapping[str, Any]) -> tuple[Mapping[str, Any], ...]: - raw = domain.get("interfaces", ()) - return tuple(item for item in raw if isinstance(item, Mapping)) if isinstance(raw, list | tuple) else () +def _validate_live_forbidden_terms(payload: Mapping[str, object]) -> list[str]: + violations: list[str] = [] + rendered = json.dumps(payload, sort_keys=True, default=str) + if "native-realized" in rendered: + violations.append("native-realized is not an admitted observation basis") + if "soc_readback" in rendered: + violations.append("daemon substrate cannot supply SOC readback") + return violations -def _first_ip(domain: Mapping[str, Any]) -> str | None: - for interface in _interfaces(domain): - ip = interface.get("ip") - if isinstance(ip, str) and ip: - return ip - return None +def _domains(snapshot: Mapping[str, Any]) -> tuple[Mapping[str, Any], ...]: + raw = snapshot.get("domains", ()) + return tuple(item for item in raw if isinstance(item, Mapping)) if isinstance(raw, list | tuple) else () diff --git a/implementations/python/pyproject.toml b/implementations/python/pyproject.toml index 4c8fd94ae..37e674161 100644 --- a/implementations/python/pyproject.toml +++ b/implementations/python/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "rich>=13.0.0", "PyYAML>=6.0", "cryptography>=46.0.7", + "defusedxml>=0.7.1", "fastapi>=0.115.0", "uvicorn[standard]>=0.34.0", "sse-starlette>=2.0.0", diff --git a/implementations/python/tests/test_libvirt_backend_cli.py b/implementations/python/tests/test_libvirt_backend_cli.py index 400ea760e..e3d8cac1e 100644 --- a/implementations/python/tests/test_libvirt_backend_cli.py +++ b/implementations/python/tests/test_libvirt_backend_cli.py @@ -14,7 +14,7 @@ class _Report: passed: bool = True def render(self) -> str: - return "live ok" + return "live ok" if self.passed else "live failed" def test_libvirt_techvault_validate_live_cli_invokes_gate(monkeypatch, tmp_path): @@ -44,10 +44,6 @@ def _validate(**kwargs): "--yes", "--connection-uri", "qemu:///session", - "--appliance-memory-mib", - "96", - "--boot-timeout-seconds", - "7", ], ) @@ -60,8 +56,95 @@ def _validate(**kwargs): "run_id": "cli-run", "config": TechVaultLiveConfig( connection_uri="qemu:///session", - appliance_memory_mib=96, - boot_timeout_seconds=7, ), } ] + + +def test_libvirt_techvault_validate_live_cli_returns_failure_exit(monkeypatch, tmp_path): + monkeypatch.setattr( + "aces_cli.libvirt.validate_techvault_live", + lambda **_kwargs: _Report(passed=False), + ) + scenario = tmp_path / "scenario.sdl.yaml" + scenario.write_text("name: cli\n", encoding="utf-8") + + result = CliRunner().invoke( + app, + [ + "libvirt", + "techvault", + "validate-live", + "--scenario", + str(scenario), + "--project-dir", + str(tmp_path), + "--run-id", + "cli-run", + "--yes", + ], + ) + + assert result.exit_code == 1 + assert "live failed" in result.output + + +def test_libvirt_techvault_cli_has_no_unbound_memory_override(monkeypatch, tmp_path): + calls: list[dict[str, object]] = [] + + def _validate(**kwargs): + calls.append(kwargs) + return _Report() + + monkeypatch.setattr("aces_cli.libvirt.validate_techvault_live", _validate) + scenario = tmp_path / "scenario.sdl.yaml" + scenario.write_text("name: cli\n", encoding="utf-8") + + result = CliRunner().invoke( + app, + [ + "libvirt", + "techvault", + "validate-live", + "--scenario", + str(scenario), + "--project-dir", + str(tmp_path), + "--run-id", + "cli-run", + "--yes", + "--appliance-memory-mib", + "96", + ], + ) + + assert result.exit_code == 2 + assert calls == [] + + +def test_libvirt_techvault_cli_rejects_connection_uri_credentials(monkeypatch, tmp_path): + calls: list[dict[str, object]] = [] + monkeypatch.setattr("aces_cli.libvirt.validate_techvault_live", lambda **kwargs: calls.append(kwargs)) + scenario = tmp_path / "scenario.sdl.yaml" + scenario.write_text("name: cli\n", encoding="utf-8") + + result = CliRunner().invoke( + app, + [ + "libvirt", + "techvault", + "validate-live", + "--scenario", + str(scenario), + "--project-dir", + str(tmp_path), + "--run-id", + "cli-run", + "--yes", + "--connection-uri", + "qemu+ssh://operator:credential@example/system", + ], + ) + + assert result.exit_code == 2 + assert calls == [] diff --git a/implementations/python/tests/test_libvirt_backend_envelopes.py b/implementations/python/tests/test_libvirt_backend_envelopes.py index 73a1ac9c8..5e7ac6747 100644 --- a/implementations/python/tests/test_libvirt_backend_envelopes.py +++ b/implementations/python/tests/test_libvirt_backend_envelopes.py @@ -65,6 +65,13 @@ def test_techvault_driver_selects_narrow_appliance_envelope(tmp_path): assert not target.manifest.provisioner.supports_accounts assert not target.manifest.provisioner.supports_acls + claims = {claim.concern.value: claim for claim in envelope.concerns} + assert claims["service"].disposition.value == "unsupported" + assert claims["service"].observation_strength.value == "none" + for concern in ("topology", "architecture", "image", "resource-allocation", "network"): + assert claims[concern].observation_strength.value == "daemon-observed" + assert claims[concern].disposition.value == "realized" + def test_driver_and_declared_mode_must_match(tmp_path): driver = TechVaultNativeLibvirtDriver(state_dir=tmp_path) diff --git a/implementations/python/tests/test_libvirt_backend_realization.py b/implementations/python/tests/test_libvirt_backend_realization.py index d0bfbf72f..99e27984e 100644 --- a/implementations/python/tests/test_libvirt_backend_realization.py +++ b/implementations/python/tests/test_libvirt_backend_realization.py @@ -77,6 +77,29 @@ def test_node_without_placements_gets_hostname_only_cloud_init(): assert cloud_init.write_files == () +def test_network_preserves_explicit_false_internal_setting(): + network = _resource( + "network", + "provision.network.external", + { + "name": "external", + "spec": { + "infrastructure": { + "properties": { + "cidr": "192.0.2.0/24", + "gateway": "192.0.2.1", + "internal": False, + } + } + }, + }, + ) + + realization = interpret_provisioning_plan(_plan(network)) + + assert realization.networks[0].labels["internal"] == "false" + + def test_account_placement_realizes_user_with_all_features(): account = _resource( "account-placement", diff --git a/implementations/python/tests/test_libvirt_backend_techvault_honesty.py b/implementations/python/tests/test_libvirt_backend_techvault_honesty.py new file mode 100644 index 000000000..d269a79bf --- /dev/null +++ b/implementations/python/tests/test_libvirt_backend_techvault_honesty.py @@ -0,0 +1,348 @@ +"""ASR-519 TechVault admission, observation, and recovery falsification tests.""" + +from __future__ import annotations + +import pytest +from aces_backend_libvirt.driver import ( + DomainHandle, + DriverResult, + NetworkHandle, + RealizationObservation, +) +from aces_backend_libvirt.envelopes import load_libvirt_realization_envelope +from aces_backend_libvirt.provisioner import LibvirtProvisioner +from aces_backend_libvirt.target import create_libvirt_target +from aces_contracts.planning import ChangeAction, PlannedResource, ProvisioningPlan, ProvisionOp, RuntimeDomain +from aces_contracts.realization_envelope import ObservationStrength, RealizationConcern +from aces_contracts.runtime_state import RuntimeSnapshot, SnapshotEntry +from aces_runtime.control_plane import RuntimeControlPlane +from aces_runtime.control_plane_store import LocalControlPlaneStore + + +class _RecordingTechVaultDriver: + driver_mode = "techvault-appliance" + + def __init__(self) -> None: + self.realize_calls: list[dict[str, object]] = [] + self.destroy_calls: list[dict[str, object]] = [] + + def realize(self, *, networks, domains): + self.realize_calls.append({"networks": networks, "domains": domains}) + return DriverResult( + networks=tuple(NetworkHandle(address=spec.address) for spec in networks), + domains=tuple(DomainHandle(address=spec.address) for spec in domains), + ) + + def destroy(self, *, networks, domains): + self.destroy_calls.append({"networks": networks, "domains": domains}) + return DriverResult( + networks=tuple(NetworkHandle(address=address, realized=False) for address in networks), + domains=tuple(DomainHandle(address=address, realized=False) for address in domains), + ) + + def realized_addresses(self): + return frozenset() + + +class _ObservedTechVaultDriver(_RecordingTechVaultDriver): + def __init__(self, *, observed_memory_mib: int = 128, observed_vcpus: object | None = None) -> None: + super().__init__() + self.observed_memory_mib = observed_memory_mib + self.observed_vcpus = observed_vcpus + + def realize(self, *, networks, domains): + self.realize_calls.append({"networks": networks, "domains": domains}) + observations: list[RealizationObservation] = [] + for spec in domains: + observations.extend( + ( + _observation(spec.address, "exists", RealizationConcern.TOPOLOGY, True), + _observation(spec.address, "architecture", RealizationConcern.ARCHITECTURE, "x86_64"), + _observation( + spec.address, + "image-policy", + RealizationConcern.IMAGE, + "generated-initramfs-appliance", + ), + _observation( + spec.address, + "memory-mib", + RealizationConcern.RESOURCE_ALLOCATION, + self.observed_memory_mib, + ), + _observation( + spec.address, + "vcpus", + RealizationConcern.RESOURCE_ALLOCATION, + spec.vcpus if self.observed_vcpus is None else self.observed_vcpus, + ), + _observation( + spec.address, + "network-attachments", + RealizationConcern.NETWORK, + tuple(spec.networks), + ), + ) + ) + return DriverResult( + domains=tuple(DomainHandle(address=spec.address) for spec in domains), + observations=tuple(observations), + ) + + +class _DuplicateObservationDriver(_ObservedTechVaultDriver): + def realize(self, *, networks, domains): + result = super().realize(networks=networks, domains=domains) + duplicate = next(item for item in result.observations if item.field_path == "vcpus") + return DriverResult( + networks=result.networks, + domains=result.domains, + observations=(*result.observations, duplicate), + ) + + +def _observation(address: str, field_path: str, concern: RealizationConcern, value: object): + return RealizationObservation( + address=address, + field_path=field_path, + concern=concern, + source=ObservationStrength.DAEMON_OBSERVED, + value=value, + ) + + +def _node_resource( + *, + memory_mib: int = 128, + vcpus: int = 1, + image_ref: str | None = None, + services: list[dict[str, object]] | None = None, + acls: list[dict[str, object]] | None = None, +) -> PlannedResource: + node: dict[str, object] = { + "type": "vm", + "resources": {"ram": memory_mib, "cpu": vcpus}, + "services": services or [], + } + if image_ref is not None: + node["source"] = {"name": image_ref} + infrastructure: dict[str, object] = {"networks": []} + if acls is not None: + infrastructure["acls"] = acls + return PlannedResource( + address="provision.node.demo", + domain=RuntimeDomain.PROVISIONING, + resource_type="node", + payload={ + "name": "demo", + "node_type": "vm", + "os_family": "linux", + "spec": {"node": node, "infrastructure": infrastructure}, + }, + ) + + +def _placement_resource(resource_type: str) -> PlannedResource: + specs: dict[str, dict[str, object]] = { + "account-placement": {"username": "operator"}, + "content-placement": {"type": "file", "path": "/etc/demo", "text": "demo"}, + "feature-binding": {"template": {"type": "service", "source": {"name": "demo-agent"}}}, + } + return PlannedResource( + address=f"provision.{resource_type}.demo", + domain=RuntimeDomain.PROVISIONING, + resource_type=resource_type, + payload={ + "name": "demo", + "target_address": "provision.node.demo", + "spec": specs[resource_type], + }, + ) + + +def _plan(*resources: PlannedResource, action: ChangeAction = ChangeAction.CREATE) -> ProvisioningPlan: + return ProvisioningPlan( + resources={resource.address: resource for resource in resources}, + operations=[ + ProvisionOp( + action=action, + address=resource.address, + resource_type=resource.resource_type, + payload=resource.payload, + ) + for resource in resources + ], + realization_envelope=load_libvirt_realization_envelope("techvault-appliance").identity, + ) + + +@pytest.mark.parametrize( + ("resource", "code"), + ( + (_node_resource(memory_mib=1024), "libvirt-backend.techvault.resource-out-of-envelope"), + (_node_resource(vcpus=4), "libvirt-backend.techvault.resource-out-of-envelope"), + (_node_resource(image_ref="requested.qcow2"), "libvirt-backend.techvault.image-unsupported"), + ( + _node_resource(services=[{"name": "api", "port": 8443, "protocol": "tcp"}]), + "libvirt-backend.techvault.service-unsupported", + ), + ), +) +def test_techvault_rejects_silent_transformations_before_driver_io(resource, code): + driver = _RecordingTechVaultDriver() + baseline = RuntimeSnapshot() + + result = LibvirtProvisioner(driver).apply(_plan(resource), baseline) + + assert result.success is False + assert result.snapshot is baseline + assert result.changed_addresses == [] + assert code in {diagnostic.code for diagnostic in result.diagnostics} + assert driver.realize_calls == [] + + +@pytest.mark.parametrize("resource_type", ("account-placement", "content-placement", "feature-binding")) +def test_techvault_rejects_unsupported_guest_placements_before_driver_io(resource_type): + driver = _RecordingTechVaultDriver() + node = _node_resource() + placement = _placement_resource(resource_type) + baseline = RuntimeSnapshot() + + result = LibvirtProvisioner(driver).apply(_plan(node, placement), baseline) + + assert result.success is False + assert result.snapshot is baseline + assert result.changed_addresses == [] + assert any(diagnostic.address == placement.address for diagnostic in result.diagnostics) + assert driver.realize_calls == [] + + +def test_techvault_rejects_updates_before_native_mutation(): + driver = _RecordingTechVaultDriver() + resource = _node_resource() + plan = _plan(resource, action=ChangeAction.UPDATE) + baseline = RuntimeSnapshot(realization_envelope=plan.realization_envelope) + + result = LibvirtProvisioner(driver).apply(plan, baseline) + + assert result.success is False + assert result.snapshot is baseline + assert [diagnostic.code for diagnostic in result.diagnostics] == ["libvirt-backend.techvault.update-unsupported"] + assert driver.realize_calls == [] + + +def test_techvault_rejects_delete_combined_with_another_mutation(): + driver = _RecordingTechVaultDriver() + created = _node_resource() + deleted_address = "provision.node.prior" + plan = ProvisioningPlan( + resources={created.address: created}, + operations=[ + ProvisionOp( + action=ChangeAction.CREATE, + address=created.address, + resource_type=created.resource_type, + payload=created.payload, + ), + ProvisionOp( + action=ChangeAction.DELETE, + address=deleted_address, + resource_type="node", + payload={"name": "prior"}, + ), + ], + realization_envelope=load_libvirt_realization_envelope("techvault-appliance").identity, + ) + baseline = RuntimeSnapshot(realization_envelope=plan.realization_envelope) + + result = LibvirtProvisioner(driver).apply(plan, baseline) + + assert result.success is False + assert result.snapshot is baseline + assert [diagnostic.code for diagnostic in result.diagnostics] == [ + "libvirt-backend.techvault.transaction-unsupported" + ] + assert driver.realize_calls == [] + assert driver.destroy_calls == [] + + +def test_techvault_rejects_fabricated_handle_without_daemon_observations(): + driver = _RecordingTechVaultDriver() + baseline = RuntimeSnapshot() + + result = LibvirtProvisioner(driver).apply(_plan(_node_resource()), baseline) + + assert result.success is False + assert result.snapshot is baseline + assert result.changed_addresses == [] + assert [diagnostic.code for diagnostic in result.diagnostics] == ["libvirt-backend.techvault.observation-missing"] + assert len(driver.realize_calls) == 1 + + +def test_techvault_commits_snapshot_only_after_complete_matching_daemon_observations(): + driver = _ObservedTechVaultDriver() + + result = LibvirtProvisioner(driver).apply(_plan(_node_resource()), RuntimeSnapshot()) + + assert result.success is True + assert result.changed_addresses == ["provision.node.demo"] + assert result.snapshot.entries["provision.node.demo"].status == "applied" + + +def test_techvault_detects_resource_clamping_in_daemon_readback(): + driver = _ObservedTechVaultDriver(observed_memory_mib=64) + baseline = RuntimeSnapshot() + + result = LibvirtProvisioner(driver).apply(_plan(_node_resource()), baseline) + + assert result.success is False + assert result.snapshot is baseline + assert result.changed_addresses == [] + assert [diagnostic.code for diagnostic in result.diagnostics] == ["libvirt-backend.techvault.observation-mismatch"] + assert driver.destroy_calls == [{"networks": (), "domains": ("provision.node.demo",)}] + + +@pytest.mark.parametrize( + "driver", + ( + _ObservedTechVaultDriver(observed_vcpus=True), + _DuplicateObservationDriver(), + ), +) +def test_techvault_rejects_type_coercion_and_duplicate_daemon_observations(driver): + baseline = RuntimeSnapshot() + + result = LibvirtProvisioner(driver).apply(_plan(_node_resource()), baseline) + + assert result.success is False + assert result.snapshot is baseline + assert [diagnostic.code for diagnostic in result.diagnostics] == ["libvirt-backend.techvault.observation-mismatch"] + assert driver.destroy_calls == [{"networks": (), "domains": ("provision.node.demo",)}] + + +def test_failed_techvault_admission_preserves_persisted_runtime_snapshot(tmp_path): + driver = _RecordingTechVaultDriver() + envelope = load_libvirt_realization_envelope("techvault-appliance").identity + baseline = RuntimeSnapshot( + entries={ + "provision.node.prior": SnapshotEntry( + address="provision.node.prior", + domain=RuntimeDomain.PROVISIONING, + resource_type="node", + payload={"name": "prior"}, + ) + }, + realization_envelope=envelope, + ) + store = LocalControlPlaneStore(tmp_path / "control-plane") + store.save_snapshot(baseline) + control_plane = RuntimeControlPlane(create_libvirt_target(driver=driver), store=store) + invalid = _node_resource(services=[{"name": "api", "port": 8443, "protocol": "tcp"}]) + + receipt = control_plane.submit_provisioning(_plan(invalid)) + status = control_plane.get_operation(receipt.operation_id) + restarted = RuntimeControlPlane(create_libvirt_target(driver=driver), store=store) + + assert status is not None and status.state.value == "failed" + assert restarted.snapshot == baseline + assert driver.realize_calls == [] diff --git a/implementations/python/tests/test_libvirt_backend_techvault_native.py b/implementations/python/tests/test_libvirt_backend_techvault_native.py index 8d015c774..43efa6a70 100644 --- a/implementations/python/tests/test_libvirt_backend_techvault_native.py +++ b/implementations/python/tests/test_libvirt_backend_techvault_native.py @@ -2,19 +2,31 @@ from __future__ import annotations +import gzip import json +import xml.etree.ElementTree as ET +from dataclasses import replace from pathlib import Path import pytest from aces_backend_libvirt import create_libvirt_target +from aces_backend_libvirt.cloudinit import CloudInitSpec, CloudInitUser +from aces_backend_libvirt.driver import DomainSpec, NetworkAcl, NetworkSpec, ServiceSpec +from aces_backend_libvirt.envelopes import load_libvirt_realization_envelope from aces_backend_libvirt.techvault_native import ( BusyboxInitramfsBuilder, ProbeResult, TechVaultNativeLibvirtDriver, + check_native_readiness, expected_surface, + native_soc_readback, ) from aces_operations import techvault_live -from aces_operations.techvault_live import TechVaultLiveConfig, validate_techvault_live +from aces_operations.techvault_live import ( + TechVaultLiveConfig, + validate_techvault_live, + validate_techvault_live_manifest, +) from paths import EXAMPLES_DIR from aces.core.runtime.control_plane import RuntimeControlPlane @@ -23,8 +35,9 @@ class _NativeObject: - def __init__(self, name: str = "") -> None: + def __init__(self, name: str = "", xml: str = "") -> None: self._name = name + self._xml = xml self.created = False self.destroyed = False self.undefined = False @@ -35,6 +48,17 @@ def name(self): def create(self): self.created = True + def isActive(self): # noqa: N802 - mirrors libvirt API + return int(self.created and not self.destroyed) + + def XMLDesc(self, _flags=0): # noqa: N802 - mirrors libvirt API + return self._xml + + def UUIDString(self): # noqa: N802 - mirrors libvirt API + if not self._xml: + return None + return ET.fromstring(self._xml).findtext("uuid") # noqa: S314 - test-generated XML + def destroy(self): self.destroyed = True @@ -42,6 +66,11 @@ def undefine(self): self.undefined = True +class _RollbackFailObject(_NativeObject): + def destroy(self): + raise RuntimeError("rollback blocked") + + class _FakeConnection: def __init__(self) -> None: self.network_xml: list[str] = [] @@ -52,14 +81,14 @@ def __init__(self) -> None: def networkDefineXML(self, xml: str): # noqa: N802 - mirrors libvirt API self.network_xml.append(xml) name = _name_from_xml(xml) - native = _NativeObject(name) + native = _NativeObject(name, xml) self.networks[name] = native return native def defineXML(self, xml: str): # noqa: N802 - mirrors libvirt API self.domain_xml.append(xml) name = _name_from_xml(xml) - native = _NativeObject(name) + native = _NativeObject(name, xml) self.domains[name] = native return native @@ -70,10 +99,81 @@ def lookupByName(self, name: str): # noqa: N802 - mirrors libvirt API return self.domains[name] def listAllDomains(self): # noqa: N802 - mirrors libvirt API - return list(self.domains.values()) + return [native for native in self.domains.values() if not native.undefined] def listAllNetworks(self): # noqa: N802 - mirrors libvirt API - return list(self.networks.values()) + return [native for native in self.networks.values() if not native.undefined] + + +class _SecondDomainFailsConnection(_FakeConnection): + def __init__(self, *, rollback_fails: bool = False) -> None: + super().__init__() + self._define_count = 0 + self.rollback_fails = rollback_fails + + def defineXML(self, xml: str): # noqa: N802 - mirrors libvirt API + self._define_count += 1 + if self._define_count == 2: + raise RuntimeError("second define failed") + self.domain_xml.append(xml) + name = _name_from_xml(xml) + native_type = _RollbackFailObject if self.rollback_fails else _NativeObject + native = native_type(name, xml) + self.domains[name] = native + return native + + +class _LookupFailure(Exception): + def __init__(self, code: int) -> None: + super().__init__("native lookup failed") + self.code = code + + def get_error_code(self): + return self.code + + +class _FailingLookupConnection(_FakeConnection): + def lookupByName(self, name: str): # noqa: N802 - mirrors libvirt API + del name + raise _LookupFailure(1) + + def listAllDomains(self): # noqa: N802 - mirrors libvirt API + raise _LookupFailure(1) + + +class _InactiveDomainConnection(_FakeConnection): + def defineXML(self, xml: str): # noqa: N802 - mirrors libvirt API + native = super().defineXML(xml) + native.isActive = lambda: 0 # type: ignore[method-assign] + return native + + +class _SubstitutedInitrdConnection(_FakeConnection): + def defineXML(self, xml: str): # noqa: N802 - mirrors libvirt API + root = ET.fromstring(xml) # noqa: S314 - test-generated XML + root.find("./os/initrd").text = "/unbound/substitute.cpio.gz" + return super().defineXML(ET.tostring(root, encoding="unicode")) + + +class _ExtraAttachmentConnection(_FakeConnection): + def defineXML(self, xml: str): # noqa: N802 - mirrors libvirt API + root = ET.fromstring(xml) # noqa: S314 - test-generated XML + devices = root.find("devices") + interface = ET.SubElement(devices, "interface", {"type": "network"}) + ET.SubElement(interface, "source", {"network": "foreign-network"}) + return super().defineXML(ET.tostring(root, encoding="unicode")) + + +class _WrongForwardModeConnection(_FakeConnection): + def networkDefineXML(self, xml: str): # noqa: N802 - mirrors libvirt API + root = ET.fromstring(xml) # noqa: S314 - test-generated XML + root.find("forward").set("mode", "route") + return super().networkDefineXML(ET.tostring(root, encoding="unicode")) + + +class _UnverifiableDomainCleanupConnection(_FakeConnection): + def listAllDomains(self): # noqa: N802 - mirrors libvirt API + raise RuntimeError("listing unavailable") class _Builder: @@ -97,7 +197,49 @@ def _name_from_xml(xml: str) -> str: return xml[start:end] -def _apply_native_scenario(path: Path, tmp_path: Path): +def _bounded_scenario(tmp_path: Path) -> Path: + path = tmp_path / "bounded.sdl.yaml" + path.write_text( + """\ +name: bounded +nodes: + lab: {type: switch} + demo: + type: vm + os: linux + resources: {ram: 128 MiB, cpu: 1} + services: [] +infrastructure: + lab: + properties: {cidr: 192.0.2.0/24, gateway: 192.0.2.1, internal: true} + demo: + links: [lab] +""", + encoding="utf-8", + ) + return path + + +def _bounded_specs() -> tuple[NetworkSpec, DomainSpec]: + network = NetworkSpec( + address="provision.network.lab", + name="lab", + cidr="192.0.2.0/24", + gateway="192.0.2.1", + labels={"internal": "true"}, + ) + domain = DomainSpec( + address="provision.node.demo", + name="demo", + image_ref=None, + memory_mib=128, + vcpus=2, + networks=(network.address,), + ) + return network, domain + + +def _submit_native_scenario(path: Path, tmp_path: Path): connection = _FakeConnection() kernel = tmp_path / "vmlinuz" kernel.write_bytes(b"kernel") @@ -116,25 +258,22 @@ def _apply_native_scenario(path: Path, tmp_path: Path): receipt = control_plane.submit_provisioning(execution_plan.provisioning) status = control_plane.get_operation(receipt.operation_id) assert status is not None - assert status.state.value == "succeeded", status.diagnostics - return driver, connection + return driver, connection, status, control_plane.snapshot -def test_operational_techvault_realizes_native_libvirt_domains_without_compose(tmp_path): - driver, connection = _apply_native_scenario(EXAMPLES_DIR / "techvault-operational.sdl.yaml", tmp_path) +def test_operational_techvault_rejects_unrealized_concerns_before_libvirt_io(tmp_path): + driver, connection, status, snapshot = _submit_native_scenario( + EXAMPLES_DIR / "techvault-operational.sdl.yaml", tmp_path + ) - surface = expected_surface(driver.last_snapshot) - assert surface["substrate"] == "libvirt-qemu-initramfs" - assert len(surface["domains"]) == 30 - assert len(surface["networks"]) == 4 - assert "thehive" in surface["domains"] - assert "misp" in surface["domains"] - assert "suricata" in surface["domains"] - assert "docker" not in json.dumps(driver.last_snapshot).lower() - assert "compose" not in json.dumps(driver.last_snapshot).lower() - assert len(connection.domain_xml) == 30 - assert len(connection.network_xml) == 4 - assert all("" in xml and "" in xml for xml in connection.domain_xml) + assert status.state.value == "failed" + codes = {diagnostic.code for diagnostic in status.diagnostics} + assert "libvirt-backend.techvault.resource-out-of-envelope" in codes + assert "libvirt-backend.techvault.service-unsupported" in codes + assert connection.domain_xml == [] + assert connection.network_xml == [] + assert driver.last_snapshot == {} + assert snapshot.entries == {} @pytest.mark.parametrize( @@ -146,16 +285,484 @@ def test_operational_techvault_realizes_native_libvirt_domains_without_compose(t ("techvault-attacker-target.sdl.yaml", 8, 3), ), ) -def test_curated_variants_drive_distinct_native_surfaces(filename, domain_count, network_count, tmp_path): - driver, _connection = _apply_native_scenario(EXAMPLES_DIR / filename, tmp_path) +def test_curated_variants_do_not_turn_planned_surfaces_into_native_claims( + filename, domain_count, network_count, tmp_path +): + del domain_count, network_count + driver, connection, status, snapshot = _submit_native_scenario(EXAMPLES_DIR / filename, tmp_path) + + assert status.state.value == "failed" + assert connection.domain_xml == [] + assert connection.network_xml == [] + assert driver.last_snapshot == {} + assert snapshot.entries == {} + +def test_bounded_substrate_emits_complete_daemon_observations(tmp_path): + connection = _FakeConnection() + kernel = tmp_path / "vmlinuz" + kernel.write_bytes(b"kernel") + driver = TechVaultNativeLibvirtDriver( + state_dir=tmp_path / "state", + connection=connection, + kernel_path=kernel, + name_prefix="native-test", + initramfs_builder=_Builder(), + ) + network = NetworkSpec( + address="provision.network.lab", + name="lab", + cidr="192.0.2.0/24", + gateway="192.0.2.1", + labels={"internal": "true"}, + ) + domain = DomainSpec( + address="provision.node.demo", + name="demo", + image_ref=None, + memory_mib=128, + vcpus=2, + networks=(network.address,), + ) + + result = driver.realize(networks=(network,), domains=(domain,)) + + assert not result.diagnostics + assert len(result.observations) == 13 + assert {observation.source.value for observation in result.observations} == {"daemon-observed"} surface = expected_surface(driver.last_snapshot) - assert len(surface["domains"]) == domain_count - assert len(surface["networks"]) == network_count - assert surface["service_count"] > 0 + assert surface["source"] == "daemon-observed" + assert surface["domains"] == ("native-test-demo",) + assert surface["networks"] == ("native-test-lab",) + assert "service_count" not in surface + network_uuid = ET.fromstring(connection.network_xml[0]).findtext("uuid") # noqa: S314 - test XML + domain_uuid = ET.fromstring(connection.domain_xml[0]).findtext("uuid") # noqa: S314 - test XML + assert network_uuid + assert domain_uuid + assert network_uuid != domain_uuid + binding = driver.last_snapshot["binding"] + envelope = load_libvirt_realization_envelope("techvault-appliance") + assert binding["driver"] == "techvault-appliance" + assert binding["realization_envelope_digest"] == envelope.digest + assert binding["configuration_digest"] == envelope.configuration.configuration_digest + assert binding["driver_configuration_digest"].startswith("sha256:") + assert set(binding["boot_artifact_digests"]) == {"kernel", "initramfs"} + assert native_soc_readback(driver.last_snapshot) == { + "status": "not-observed", + "observation_source": "none", + "reason": "guest SOC state requires concern-specific guest observation", + } + ready, readiness_diagnostics = check_native_readiness( + driver.last_snapshot, + probe=_Probe(), + timeout_seconds=1, + poll_seconds=1, + ) + assert ready is False + assert readiness_diagnostics == ["guest readiness requires concern-specific guest observation"] + + +def test_native_driver_rejects_inactive_daemon_readback_and_rolls_back(tmp_path): + connection = _InactiveDomainConnection() + kernel = tmp_path / "vmlinuz" + kernel.write_bytes(b"kernel") + driver = TechVaultNativeLibvirtDriver( + state_dir=tmp_path / "state", + connection=connection, + kernel_path=kernel, + name_prefix="native-test", + initramfs_builder=_Builder(), + ) + network, domain = _bounded_specs() + + result = driver.realize(networks=(network,), domains=(domain,)) + + assert [diagnostic.code for diagnostic in result.diagnostics] == ["libvirt-backend.techvault.observation-mismatch"] + assert connection.domains["native-test-demo"].undefined is True + assert connection.networks["native-test-lab"].undefined is True + assert driver.last_snapshot == {} + + +def test_native_driver_rejects_substituted_boot_artifact_readback(tmp_path): + connection = _SubstitutedInitrdConnection() + kernel = tmp_path / "vmlinuz" + kernel.write_bytes(b"kernel") + driver = TechVaultNativeLibvirtDriver( + state_dir=tmp_path / "state", + connection=connection, + kernel_path=kernel, + name_prefix="native-test", + initramfs_builder=_Builder(), + ) + network, domain = _bounded_specs() + + result = driver.realize(networks=(network,), domains=(domain,)) + + assert [diagnostic.code for diagnostic in result.diagnostics] == ["libvirt-backend.techvault.observation-mismatch"] + assert connection.domains["native-test-demo"].undefined is True + assert connection.networks["native-test-lab"].undefined is True + + +def test_native_driver_rejects_extra_unbound_network_attachment(tmp_path): + connection = _ExtraAttachmentConnection() + kernel = tmp_path / "vmlinuz" + kernel.write_bytes(b"kernel") + driver = TechVaultNativeLibvirtDriver( + state_dir=tmp_path / "state", + connection=connection, + kernel_path=kernel, + name_prefix="native-test", + initramfs_builder=_Builder(), + ) + network, domain = _bounded_specs() + + result = driver.realize(networks=(network,), domains=(domain,)) + + assert [diagnostic.code for diagnostic in result.diagnostics] == ["libvirt-backend.techvault.observation-mismatch"] + + +def test_native_driver_rejects_substituted_network_forwarding_policy(tmp_path): + connection = _WrongForwardModeConnection() + kernel = tmp_path / "vmlinuz" + kernel.write_bytes(b"kernel") + driver = TechVaultNativeLibvirtDriver( + state_dir=tmp_path / "state", + connection=connection, + kernel_path=kernel, + name_prefix="native-test", + initramfs_builder=_Builder(), + ) + network, domain = _bounded_specs() + network = replace(network, labels={"internal": "false"}) + + result = driver.realize(networks=(network,), domains=(domain,)) + + assert [diagnostic.code for diagnostic in result.diagnostics] == ["libvirt-backend.techvault.observation-mismatch"] + + +def test_native_driver_rolls_back_when_evidence_binding_cannot_be_built(tmp_path, monkeypatch): + connection = _FakeConnection() + kernel = tmp_path / "vmlinuz" + kernel.write_bytes(b"kernel") + driver = TechVaultNativeLibvirtDriver( + state_dir=tmp_path / "state", + connection=connection, + kernel_path=kernel, + name_prefix="native-test", + initramfs_builder=_Builder(), + ) + network, domain = _bounded_specs() + + def _fail_binding(*_args): + raise OSError("material unavailable") + + monkeypatch.setattr(driver, "_material_binding", _fail_binding) + + result = driver.realize(networks=(network,), domains=(domain,)) + + assert [diagnostic.code for diagnostic in result.diagnostics] == [ + "libvirt-backend.techvault-native.operation-failed" + ] + assert connection.domains["native-test-demo"].undefined is True + assert connection.networks["native-test-lab"].undefined is True + assert driver.last_snapshot == {} + + +def test_native_driver_does_not_claim_cleanup_when_native_listing_fails(tmp_path): + connection = _UnverifiableDomainCleanupConnection() + kernel = tmp_path / "vmlinuz" + kernel.write_bytes(b"kernel") + driver = TechVaultNativeLibvirtDriver( + state_dir=tmp_path / "state", + connection=connection, + kernel_path=kernel, + name_prefix="native-test", + initramfs_builder=_Builder(), + ) + network, domain = _bounded_specs() + realized = driver.realize(networks=(network,), domains=(domain,)) + assert not realized.diagnostics + + result = driver.destroy(networks=(), domains=(domain.address,)) + + assert [diagnostic.code for diagnostic in result.diagnostics] == ["libvirt-backend.techvault-native.residual-state"] + assert result.domains[0].realized is True + + +def test_native_driver_recovers_owned_resources_by_uuid_after_restart(tmp_path): + connection = _FakeConnection() + kernel = tmp_path / "vmlinuz" + kernel.write_bytes(b"kernel") + network = NetworkSpec( + address="provision.network.identity", + name="lab-display", + cidr="192.0.2.0/24", + gateway="192.0.2.1", + labels={"internal": "true"}, + ) + domain = DomainSpec( + address="provision.node.identity", + name="demo-display", + image_ref=None, + memory_mib=128, + vcpus=1, + networks=(network.address,), + ) + first = TechVaultNativeLibvirtDriver( + state_dir=tmp_path / "state", + connection=connection, + kernel_path=kernel, + name_prefix="native-test", + initramfs_builder=_Builder(), + ) + assert not first.realize(networks=(network,), domains=(domain,)).diagnostics + artifact_paths = tuple(path for paths in first._artifacts.values() for path in paths) + assert all(path.exists() for path in artifact_paths) + restarted = TechVaultNativeLibvirtDriver( + state_dir=tmp_path / "state", + connection=connection, + kernel_path=kernel, + name_prefix="native-test", + initramfs_builder=_Builder(), + ) + + result = restarted.destroy(networks=(network.address,), domains=(domain.address,)) + + assert not result.diagnostics + assert connection.domains["native-test-demo-display"].undefined is True + assert connection.networks["native-test-lab-display"].undefined is True + assert all(not path.exists() for path in artifact_paths) + + +@pytest.mark.parametrize( + ("mutation", "code"), + ( + ({"memory_mib": 256}, "libvirt-backend.techvault.resource-out-of-envelope"), + ({"image_ref": "requested.qcow2"}, "libvirt-backend.techvault.image-unsupported"), + ( + {"services": (ServiceSpec(name="api", port=8443),)}, + "libvirt-backend.techvault.service-unsupported", + ), + ( + {"cloud_init": CloudInitSpec(users=(CloudInitUser(name="operator"),))}, + "libvirt-backend.techvault.guest-placement-unsupported", + ), + ( + {"cloud_init": CloudInitSpec(hostname="substituted")}, + "libvirt-backend.techvault.guest-placement-unsupported", + ), + ({"labels": {"unbound": "value"}}, "libvirt-backend.techvault.metadata-unsupported"), + ( + {"network_acls": (NetworkAcl(name="deny", action="drop", direction="in", protocol="all"),)}, + "libvirt-backend.techvault.acl-unsupported", + ), + ), +) +def test_native_driver_direct_entrypoint_rejects_unsupported_domain_concerns(tmp_path, mutation, code): + connection = _FakeConnection() + driver = TechVaultNativeLibvirtDriver(state_dir=tmp_path / "state", connection=connection) + domain = replace( + DomainSpec( + address="provision.node.demo", + name="demo", + image_ref=None, + memory_mib=128, + vcpus=1, + ), + **mutation, + ) + + result = driver.realize(networks=(), domains=(domain,)) + + assert code in {diagnostic.code for diagnostic in result.diagnostics} + assert connection.domain_xml == [] + assert connection.network_xml == [] + assert not (tmp_path / "state").exists() + + +@pytest.mark.parametrize( + "network", + ( + NetworkSpec(address="provision.network.lab", name="lab"), + NetworkSpec( + address="provision.network.lab", + name="lab", + cidr="192.0.2.0/24", + gateway="192.0.2.1", + labels={"internal": "implicit"}, + ), + NetworkSpec( + address="provision.network.lab", + name="lab", + cidr="192.0.2.0/24", + gateway="192.0.2.1", + labels={"internal": "true", "unbound": "value"}, + ), + ), +) +def test_native_driver_direct_entrypoint_rejects_implicit_network_values(tmp_path, network): + connection = _FakeConnection() + driver = TechVaultNativeLibvirtDriver(state_dir=tmp_path / "state", connection=connection) + + result = driver.realize(networks=(network,), domains=()) + + assert [diagnostic.code for diagnostic in result.diagnostics] == [ + "libvirt-backend.techvault.network-exactness-required" + ] + assert connection.network_xml == [] + assert not (tmp_path / "state").exists() + + +def test_native_driver_rejects_network_without_deterministic_host_capacity(tmp_path): + connection = _FakeConnection() + driver = TechVaultNativeLibvirtDriver(state_dir=tmp_path / "state", connection=connection) + network = NetworkSpec( + address="provision.network.small", + name="small", + cidr="192.0.2.0/29", + gateway="192.0.2.1", + labels={"internal": "true"}, + ) + domain = DomainSpec( + address="provision.node.demo", + name="demo", + image_ref=None, + memory_mib=128, + vcpus=1, + networks=(network.address,), + ) + + result = driver.realize(networks=(network,), domains=(domain,)) + + assert [diagnostic.code for diagnostic in result.diagnostics] == [ + "libvirt-backend.techvault.network-exactness-required" + ] + assert connection.network_xml == [] + assert connection.domain_xml == [] + assert not (tmp_path / "state").exists() + + +@pytest.mark.parametrize("rollback_fails", (False, True)) +def test_native_driver_verifies_partial_create_rollback_and_reports_residual_state(tmp_path, rollback_fails): + connection = _SecondDomainFailsConnection(rollback_fails=rollback_fails) + kernel = tmp_path / "vmlinuz" + kernel.write_bytes(b"kernel") + driver = TechVaultNativeLibvirtDriver( + state_dir=tmp_path / "state", + connection=connection, + kernel_path=kernel, + initramfs_builder=_Builder(), + ) + driver.last_snapshot = {"prior": "observation"} + domains = tuple( + DomainSpec( + address=f"provision.node.demo-{index}", + name=f"demo-{index}", + image_ref=None, + memory_mib=128, + vcpus=1, + ) + for index in (1, 2) + ) + + result = driver.realize(networks=(), domains=domains) + + assert result.domains == () + assert "libvirt-backend.techvault-native.operation-failed" in {diagnostic.code for diagnostic in result.diagnostics} + first = connection.domains["aces-techvault-demo-1"] + if rollback_fails: + assert "libvirt-backend.techvault-native.residual-state" in { + diagnostic.code for diagnostic in result.diagnostics + } + assert first.undefined is False + assert list((tmp_path / "state" / "initramfs").glob("*.cpio.gz")) + else: + assert "libvirt-backend.techvault-native.residual-state" not in { + diagnostic.code for diagnostic in result.diagnostics + } + assert first.destroyed is True + assert first.undefined is True + assert list((tmp_path / "state" / "initramfs").glob("*.cpio.gz")) == [] + assert driver.last_snapshot == {"prior": "observation"} + + +def test_native_driver_refuses_to_destroy_foreign_name_collision(tmp_path): + connection = _FakeConnection() + foreign = _NativeObject( + "aces-techvault-demo", + "aces-techvault-demo00000000-0000-4000-8000-000000000000", + ) + foreign.created = True + connection.domains[foreign.name()] = foreign + driver = TechVaultNativeLibvirtDriver(state_dir=tmp_path / "state", connection=connection) + + result = driver.destroy(networks=(), domains=("provision.node.demo",)) + + assert [diagnostic.code for diagnostic in result.diagnostics] == [ + "libvirt-backend.techvault-native.ownership-conflict" + ] + assert result.domains[0].realized is True + assert foreign.destroyed is False + assert foreign.undefined is False + + +def test_native_driver_refuses_to_replace_foreign_name_collision(tmp_path): + connection = _FakeConnection() + foreign = _NativeObject( + "aces-techvault-demo", + "aces-techvault-demo00000000-0000-4000-8000-000000000000", + ) + foreign.created = True + connection.domains[foreign.name()] = foreign + kernel = tmp_path / "vmlinuz" + kernel.write_bytes(b"kernel") + driver = TechVaultNativeLibvirtDriver( + state_dir=tmp_path / "state", + connection=connection, + kernel_path=kernel, + initramfs_builder=_Builder(), + ) + domain = DomainSpec( + address="provision.node.demo", + name="demo", + image_ref=None, + memory_mib=128, + vcpus=1, + ) + + result = driver.realize(networks=(), domains=(domain,)) + assert [diagnostic.code for diagnostic in result.diagnostics] == [ + "libvirt-backend.techvault-native.ownership-conflict" + ] + assert connection.domains[foreign.name()] is foreign + assert foreign.destroyed is False + assert foreign.undefined is False + assert connection.domain_xml == [] -def test_validate_techvault_live_records_native_manifest(tmp_path): + +def test_native_driver_destroy_is_idempotent_only_for_verified_absence(tmp_path): + absent_driver = TechVaultNativeLibvirtDriver(state_dir=tmp_path / "absent", connection=_FakeConnection()) + + absent = absent_driver.destroy(networks=(), domains=("provision.node.demo",)) + + assert not absent.diagnostics + assert absent.domains[0].realized is False + + uncertain_driver = TechVaultNativeLibvirtDriver( + state_dir=tmp_path / "uncertain", + connection=_FailingLookupConnection(), + ) + + uncertain = uncertain_driver.destroy(networks=(), domains=("provision.node.demo",)) + + assert [diagnostic.code for diagnostic in uncertain.diagnostics] == [ + "libvirt-backend.techvault-native.residual-state" + ] + assert uncertain.domains[0].realized is True + + +def test_validate_techvault_live_records_truthful_failed_manifest(tmp_path): scenario = EXAMPLES_DIR / "techvault-attacker-target.sdl.yaml" def _driver_factory(): @@ -173,19 +780,62 @@ def _driver_factory(): scenario_path=scenario, project_dir=tmp_path, run_id="native-live", - config=TechVaultLiveConfig(boot_timeout_seconds=1), + config=TechVaultLiveConfig(), driver_factory=_driver_factory, - probe=_Probe(), ) - assert report.passed, report.render() + assert report.passed is False manifest = tmp_path / "runs" / "native-live" / "live-gate" / "manifest.json" payload = json.loads(manifest.read_text(encoding="utf-8")) assert payload["schema"] == "aces.libvirt.techvault-native-live-gate/v1" - assert payload["aces_libvirt"]["substrate"] == "libvirt-qemu-initramfs" - assert payload["snapshot"]["containers"] == [] - assert "kali" in payload["aces_libvirt"]["surface"]["domains"] - assert "victim" in payload["aces_libvirt"]["surface"]["domains"] + assert payload["scenario"]["path"] == "examples/scenarios/techvault-attacker-target.sdl.yaml" + facts = payload["realization_facts"] + assert facts["authored"]["source"] == "authored" + assert facts["planned"]["source"] == "planned" + assert facts["driver_reported"]["status"] == "failed" + assert facts["daemon_observed"] == {"source": "daemon-observed", "domains": [], "networks": []} + assert facts["guest_observed"] == {"source": "guest-observed", "status": "not-observed"} + assert payload["validation"]["ok"] is False + rendered = json.dumps(payload, sort_keys=True) + assert "native-realized" not in rendered + assert "soc_readback" not in rendered + assert str(tmp_path) not in rendered + + +def test_validate_techvault_live_accepts_bounded_daemon_observed_substrate(tmp_path): + connection = _FakeConnection() + + def _driver_factory(): + kernel = tmp_path / "vmlinuz-live" + kernel.write_bytes(b"kernel") + return TechVaultNativeLibvirtDriver( + state_dir=tmp_path / "state", + connection=connection, + kernel_path=kernel, + name_prefix="live-test", + initramfs_builder=_Builder(), + ) + + report = validate_techvault_live( + scenario_path=_bounded_scenario(tmp_path), + project_dir=tmp_path, + run_id="bounded-live", + config=TechVaultLiveConfig(), + driver_factory=_driver_factory, + ) + + assert report.passed, report.render() + payload = json.loads( + (tmp_path / "runs" / "bounded-live" / "live-gate" / "manifest.json").read_text(encoding="utf-8") + ) + assert payload["realization_facts"]["daemon_observed"]["domains"] == ["live-test-demo"] + assert payload["realization_facts"]["guest_observed"]["status"] == "not-observed" + assert payload["cleanup"] == {"source": "driver-reported", "status": "verified"} + assert all(native.undefined for native in (*connection.domains.values(), *connection.networks.values())) + assert "native-realized" not in json.dumps(payload, sort_keys=True) + + payload["realization_facts"]["planned"]["source"] = "daemon-observed" + assert any("planned.source" in violation for violation in validate_techvault_live_manifest(payload)) def test_live_gate_has_no_aptl_or_docker_probe_dependency(): @@ -195,7 +845,7 @@ def test_live_gate_has_no_aptl_or_docker_probe_dependency(): assert "aptl" not in source.lower() -def test_native_driver_clean_boot_removes_previous_prefixed_resources(tmp_path): +def test_native_driver_refuses_prefix_wide_cleanup(tmp_path): connection = _FakeConnection() old_domain = _NativeObject("native-test-old-domain") old_network = _NativeObject("native-test-old-network") @@ -203,22 +853,49 @@ def test_native_driver_clean_boot_removes_previous_prefixed_resources(tmp_path): connection.networks[old_network.name()] = old_network kernel = tmp_path / "vmlinuz" kernel.write_bytes(b"kernel") - driver = TechVaultNativeLibvirtDriver( - state_dir=tmp_path / "state", - connection=connection, - kernel_path=kernel, - name_prefix="native-test", - initramfs_builder=_Builder(), - clean_existing=True, + with pytest.raises(ValueError, match="prefix-wide cleanup"): + TechVaultNativeLibvirtDriver( + state_dir=tmp_path / "state", + connection=connection, + kernel_path=kernel, + name_prefix="native-test", + initramfs_builder=_Builder(), + clean_existing=True, + ) + + assert old_domain.destroyed is False + assert old_network.destroyed is False + + +@pytest.mark.parametrize( + ("kwargs", "message"), + ( + ({"define_only": True}, "define-only"), + ({"connection_uri": "qemu+ssh://operator:credential@example/system"}, "credentials"), + ({"connection_uri": "qemu+ssh://operator@example/system"}, "credentials"), + ({"name_prefix": "unsafe prefix"}, "libvirt-safe"), + ), +) +def test_native_driver_rejects_unbound_material_or_secret_configuration(tmp_path, kwargs, message): + with pytest.raises(ValueError, match=message): + TechVaultNativeLibvirtDriver(state_dir=tmp_path / "state", **kwargs) + + +def test_native_driver_rejects_silently_normalized_resource_name(tmp_path): + connection = _FakeConnection() + driver = TechVaultNativeLibvirtDriver(state_dir=tmp_path / "state", connection=connection) + domain = DomainSpec( + address="provision.node.demo", + name="unsafe name", + image_ref=None, + memory_mib=128, + vcpus=1, ) - result = driver.realize(networks=(), domains=()) + result = driver.realize(networks=(), domains=(domain,)) - assert not result.diagnostics - assert old_domain.destroyed is True - assert old_domain.undefined is True - assert old_network.destroyed is True - assert old_network.undefined is True + assert [diagnostic.code for diagnostic in result.diagnostics] == ["libvirt-backend.techvault.name-unsupported"] + assert connection.domain_xml == [] def test_busybox_initramfs_builder_writes_gzip_cpio(tmp_path): @@ -233,3 +910,4 @@ def test_busybox_initramfs_builder_writes_gzip_cpio(tmp_path): assert target.read_bytes().startswith(b"\x1f\x8b") assert target.stat().st_size > 1000 + assert b"httpd -p" not in gzip.decompress(target.read_bytes()) diff --git a/implementations/python/tests/test_libvirt_backend_techvault_real_libvirt.py b/implementations/python/tests/test_libvirt_backend_techvault_real_libvirt.py new file mode 100644 index 000000000..6f0217184 --- /dev/null +++ b/implementations/python/tests/test_libvirt_backend_techvault_real_libvirt.py @@ -0,0 +1,52 @@ +"""Opt-in real-libvirt certification for the bounded TechVault substrate.""" + +from __future__ import annotations + +import importlib +import json +import os +import shutil +from pathlib import Path + +import pytest +from aces_operations.techvault_live import TechVaultLiveConfig, validate_techvault_live +from paths import EXAMPLES_DIR + + +@pytest.mark.integration +def test_bounded_techvault_real_libvirt_readback_and_cleanup(tmp_path): + """Certify exact daemon readback and verified cleanup on an operator-selected daemon.""" + + connection_uri = os.environ.get("ACES_REAL_LIBVIRT_URI") + if not connection_uri: + pytest.skip("set ACES_REAL_LIBVIRT_URI to run real-libvirt certification") + try: + libvirt = importlib.import_module("libvirt") + except ImportError: + pytest.skip("libvirt-python is unavailable") + if shutil.which("cpio") is None or not Path("/usr/bin/busybox").is_file(): + pytest.skip("cpio and static BusyBox are required for native appliance certification") + if not tuple(Path("/boot").glob("vmlinuz-*")): + pytest.skip("a readable host kernel is required for native appliance certification") + + report = validate_techvault_live( + scenario_path=EXAMPLES_DIR / "techvault-bounded-native.sdl.yaml", + project_dir=tmp_path, + run_id="real-libvirt-certification", + config=TechVaultLiveConfig(connection_uri=connection_uri), + ) + + assert report.passed, report.render() + assert report.manifest_path is not None + manifest = json.loads(Path(report.manifest_path).read_text(encoding="utf-8")) + assert manifest["cleanup"] == {"source": "driver-reported", "status": "verified"} + observed = manifest["realization_facts"]["daemon_observed"] + connection = libvirt.open(connection_uri) + assert connection is not None + try: + remaining_domains = {item.name() for item in connection.listAllDomains()} + remaining_networks = {item.name() for item in connection.listAllNetworks()} + finally: + connection.close() + assert remaining_domains.isdisjoint(observed["domains"]) + assert remaining_networks.isdisjoint(observed["networks"]) diff --git a/implementations/python/tests/test_libvirt_evidence_run.py b/implementations/python/tests/test_libvirt_evidence_run.py index c2b7969bb..176304eac 100644 --- a/implementations/python/tests/test_libvirt_evidence_run.py +++ b/implementations/python/tests/test_libvirt_evidence_run.py @@ -10,10 +10,11 @@ from __future__ import annotations import json +import xml.etree.ElementTree as ET from pathlib import Path import pytest -from aces_backend_libvirt.techvault_native import ProbeResult, TechVaultNativeLibvirtDriver +from aces_backend_libvirt.techvault_native import TechVaultNativeLibvirtDriver from aces_contracts.contracts import ( BackendManifestV2Model, EvaluationHistoryEventModel, @@ -40,6 +41,7 @@ "scenario", "compiled_artifact", "backend", + "realization_facts", "realized_topology", "participant_action_proof", "terminal_observation", @@ -58,20 +60,32 @@ class _NativeObject: - def __init__(self, name: str = "") -> None: + def __init__(self, name: str = "", xml: str = "") -> None: self._name = name + self._xml = xml + self._active = False + self._undefined = False def name(self) -> str: return self._name def create(self) -> None: # pragma: no cover - structural stub - pass + self._active = True def destroy(self) -> None: # pragma: no cover - structural stub - pass + self._active = False def undefine(self) -> None: # pragma: no cover - structural stub - pass + self._undefined = True + + def isActive(self): # noqa: N802 - mirrors libvirt API + return int(self._active) + + def XMLDesc(self, _flags=0): # noqa: N802 - mirrors libvirt API + return self._xml + + def UUIDString(self): # noqa: N802 - mirrors libvirt API + return ET.fromstring(self._xml).findtext("uuid") # noqa: S314 - test-generated XML def _name_from_xml(xml: str) -> str: @@ -84,12 +98,12 @@ def __init__(self) -> None: self.domains: dict[str, _NativeObject] = {} def networkDefineXML(self, xml: str): # noqa: N802 - mirrors libvirt API - obj = _NativeObject(_name_from_xml(xml)) + obj = _NativeObject(_name_from_xml(xml), xml) self.networks[obj.name()] = obj return obj def defineXML(self, xml: str): # noqa: N802 - mirrors libvirt API - obj = _NativeObject(_name_from_xml(xml)) + obj = _NativeObject(_name_from_xml(xml), xml) self.domains[obj.name()] = obj return obj @@ -100,10 +114,10 @@ def lookupByName(self, name: str): # noqa: N802 - mirrors libvirt API return self.domains[name] def listAllDomains(self): # noqa: N802 - mirrors libvirt API - return list(self.domains.values()) + return [item for item in self.domains.values() if not item._undefined] def listAllNetworks(self): # noqa: N802 - mirrors libvirt API - return list(self.networks.values()) + return [item for item in self.networks.values() if not item._undefined] class _InitramfsBuilder: @@ -113,14 +127,6 @@ def build(self, *, domain, target: Path): return target -class _Probe: - def ping(self, ip: str): - return ProbeResult(True) - - def tcp(self, ip: str, port: int): - return ProbeResult(True) - - def _native_driver_factory(tmp_path: Path): kernel = tmp_path / "vmlinuz" kernel.write_bytes(b"kernel") @@ -137,6 +143,30 @@ def factory() -> TechVaultNativeLibvirtDriver: return factory +def _bounded_scenario(tmp_path: Path) -> Path: + scenario = tmp_path / "bounded-techvault.sdl.yaml" + scenario.write_text( + """\ +name: bounded-techvault +nodes: + lab: + type: switch + demo: + type: vm + os: linux + resources: {ram: 128 MiB, cpu: 1} + services: [] +infrastructure: + lab: + properties: {cidr: 192.0.2.0/24, gateway: 192.0.2.1, internal: true} + demo: + links: [lab] +""", + encoding="utf-8", + ) + return scenario + + # --- deterministic mode -------------------------------------------------------- @@ -280,6 +310,76 @@ def test_validator_flags_participant_boundary_exposure(tmp_path): assert any("boundary violation" in p for p in problems) +def test_validator_rejects_nonempty_domain_list_as_daemon_observation(tmp_path): + artifact = run_libvirt_evidence_run( + scenario_path=_REFERENCE_SCENARIO, + project_dir=tmp_path, + run_id="evidence-source-mutation-1", + ).artifact + envelope = artifact["backend"]["manifest"]["realization_envelope"] + artifact["backend"]["realization_provenance"].update( + {"substrate_realized": True, "basis": "daemon-observed-substrate"} + ) + artifact["realized_topology"]["basis"] = "mixed-source" + artifact["realization_facts"]["daemon_observed"]["domains"] = [ + {"name": "fabricated", "observation_source": "daemon-observed"} + ] + artifact["realization_facts"]["binding"] = { + "driver": "techvault-appliance", + "realization_envelope_digest": envelope["digest"], + "configuration_digest": envelope["configuration_digest"], + "driver_configuration_digest": "sha256:" + "0" * 64, + "boot_artifact_digests": {}, + } + + problems = validate_libvirt_evidence_run_artifact(artifact) + + assert any("incomplete daemon domain observation" in problem for problem in problems) + + +def test_validator_rejects_planned_topology_relabelled_as_observed(tmp_path): + artifact = run_libvirt_evidence_run( + scenario_path=_REFERENCE_SCENARIO, + project_dir=tmp_path, + run_id="evidence-source-mutation-2", + ).artifact + artifact["realized_topology"]["nodes"][0]["source"] = "daemon-observed" + + problems = validate_libvirt_evidence_run_artifact(artifact) + + assert any("realized_topology.nodes is planned" in problem for problem in problems) + + +def test_validator_rejects_native_realized_label_and_fabricated_soc_readback(tmp_path): + artifact = run_libvirt_evidence_run( + scenario_path=_REFERENCE_SCENARIO, + project_dir=tmp_path, + run_id="evidence-source-mutation-3", + ).artifact + artifact["realized_topology"]["basis"] = "native-realized" + artifact["defensive_evidence"]["soc_readback"] = {"wazuh_active_agents": ["fabricated"]} + + problems = validate_libvirt_evidence_run_artifact(artifact) + + assert any("native-realized" in problem for problem in problems) + assert any("cannot supply SOC readback" in problem for problem in problems) + + +def test_validator_rejects_mismatched_realization_binding(tmp_path): + artifact = run_libvirt_evidence_run( + scenario_path=_bounded_scenario(tmp_path), + project_dir=tmp_path, + run_id="evidence-source-mutation-4", + config=LibvirtEvidenceRunConfig(evidence_source_mode="native-live"), + driver_factory=_native_driver_factory(tmp_path), + ).artifact + artifact["realization_facts"]["binding"]["realization_envelope_digest"] = "sha256:" + "f" * 64 + + problems = validate_libvirt_evidence_run_artifact(artifact) + + assert any("envelope digest does not match" in problem for problem in problems) + + # --- native-live mode ---------------------------------------------------------- @@ -290,7 +390,6 @@ def test_native_live_reference_scenario_discloses_unrealized_content_plane(tmp_p run_id="evidence-live-1", config=LibvirtEvidenceRunConfig(evidence_source_mode="native-live"), driver_factory=_native_driver_factory(tmp_path), - probe=_Probe(), ) # ASR-519: the TechVault appliance driver does not consume the generic cloud-init # content/account surfaces. A native domain must not hide that gap. @@ -307,28 +406,38 @@ def test_native_live_reference_scenario_discloses_unrealized_content_plane(tmp_p def test_native_live_realizes_substrate_for_provisionable_scenario(tmp_path): report = run_libvirt_evidence_run( - scenario_path=_TECHVAULT_SCENARIO, + scenario_path=_bounded_scenario(tmp_path), project_dir=tmp_path, run_id="tv-live-1", config=LibvirtEvidenceRunConfig(evidence_source_mode="native-live"), driver_factory=_native_driver_factory(tmp_path), - probe=_Probe(), ) - # This is the only native-live success path: real domain/network snapshot data - # flows through artifact assembly, so it must clear the full check set and the - # redaction/contract validator (the path most able to leak host-private data). assert report.passed, report.render() artifact = report.artifact assert validate_libvirt_evidence_run_artifact(artifact) == [] assert artifact["backend"]["realization_provenance"]["substrate_realized"] is True + assert artifact["backend"]["realization_provenance"]["basis"] == "daemon-observed-substrate" + assert artifact["backend"]["realization_provenance"]["cleanup_verified"] is True + assert any(check.name == "native_substrate_cleanup" and check.passed for check in report.checks) + assert artifact["realized_topology"]["basis"] == "mixed-source" native_surface = artifact["realized_topology"]["native_surface"] - assert len(native_surface["domains"]) == 30 - assert len(native_surface["networks"]) == 4 - # Native SOC readback is the translated native readback, explicitly disclosed. + assert native_surface["source"] == "daemon-observed" + assert native_surface["domains"] == ("evidence-test-demo",) + assert native_surface["networks"] == ("evidence-test-lab",) + facts = artifact["realization_facts"] + assert facts["planned"]["source"] == "planned" + assert facts["driver_reported"]["source"] == "driver-reported" + assert facts["daemon_observed"]["source"] == "daemon-observed" + assert facts["guest_observed"] == {"source": "guest-observed", "status": "not-observed"} + assert ( + facts["binding"]["realization_envelope_digest"] + == artifact["backend"]["manifest"]["realization_envelope"]["digest"] + ) defensive = artifact["defensive_evidence"] - assert defensive["evidence_source"] == "native-translated-readback" - assert "soc_readback" in defensive + assert defensive["evidence_source"] == "structural-evaluator-channel" + assert "soc_readback" not in defensive assert defensive["captured_at"] == artifact["recorded_at"] + assert "native_reachability" not in artifact["negative_boundary_checks"] def test_native_live_without_realized_substrate_fails(tmp_path): diff --git a/implementations/python/tests/test_realization_envelope_contract.py b/implementations/python/tests/test_realization_envelope_contract.py index d9ef15478..320e4d1cd 100644 --- a/implementations/python/tests/test_realization_envelope_contract.py +++ b/implementations/python/tests/test_realization_envelope_contract.py @@ -12,6 +12,7 @@ BackendRealizationEnvelopeModel, ConcernDisposition, ObservationStrength, + RealizationConcern, RealizationConcernDisclosureModel, RealizationEnvelopeIdentityModel, RealizationEnvelopeModel, @@ -82,6 +83,7 @@ def _payload() -> dict[str, object]: "content-placement", "account-placement", "feature-binding", + "service", "acl", ) ], @@ -125,6 +127,10 @@ def test_backend_realization_envelope_validates_its_canonical_digest(): ) +def test_realization_concern_taxonomy_accounts_for_declared_services(): + assert RealizationConcern.SERVICE.value == "service" + + def test_backend_realization_envelope_rejects_content_tampering(): payload = _payload() payload["configuration"]["mode"] = "techvault-appliance" # type: ignore[index] diff --git a/implementations/python/uv.lock b/implementations/python/uv.lock index 58771e0d9..b7866299f 100644 --- a/implementations/python/uv.lock +++ b/implementations/python/uv.lock @@ -20,11 +20,12 @@ wheels = [ [[package]] name = "aces-sdl" -version = "0.3.0" +version = "0.19.1" source = { editable = "." } dependencies = [ { name = "asyncssh" }, { name = "cryptography" }, + { name = "defusedxml" }, { name = "fastapi" }, { name = "mcp" }, { name = "packaging" }, @@ -58,6 +59,7 @@ requires-dist = [ { name = "asyncssh", specifier = ">=2.23.0" }, { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "cryptography", specifier = ">=46.0.7" }, + { name = "defusedxml", specifier = ">=0.7.1" }, { name = "fastapi", specifier = ">=0.115.0" }, { name = "furo", marker = "extra == 'docs'", specifier = ">=2024.5.6" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, @@ -507,6 +509,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, ] +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + [[package]] name = "docutils" version = "0.22.4" From 04d068208381410cc56468c9db2dcff92b2aa263 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 11 Jul 2026 17:20:47 -0700 Subject: [PATCH 10/15] feat(sdl): define canonical YAML source profile (#732) * feat(sdl)!: define canonical YAML source profile * refactor(sdl): satisfy source-profile quality gate * fix(sdl): make scalar validation branch explicit * fix: reconcile normative SDL catalogs (#734) * feat(libvirt): publish configuration-bound realization envelopes (#730) * Publish configuration-bound libvirt realization envelopes * Fix SonarCloud findings (cycle 1) * Fix SonarCloud findings (cycle 2) * fix: reconcile normative SDL catalogs * chore: trigger stacked PR checks --- contracts/fixtures/sdl/sdl-yaml-v1/README.md | 14 + .../sdl/sdl-yaml-v1/invalid/alias-cycle.yaml | 5 + .../sdl/sdl-yaml-v1/invalid/directive.yaml | 3 + .../sdl-yaml-v1/invalid/duplicate-field.yaml | 2 + .../sdl/sdl-yaml-v1/invalid/explicit-tag.yaml | 1 + .../sdl-yaml-v1/invalid/merge-conflict.yaml | 7 + .../invalid/multiple-documents.yaml | 4 + .../sdl-yaml-v1/invalid/non-string-key.yaml | 3 + .../invalid/noncanonical-field.yaml | 1 + .../sdl/sdl-yaml-v1/invalid/nonfinite.yaml | 2 + .../sdl-yaml-v1/migration/field-alias.yaml | 1 + .../sdl/sdl-yaml-v1/migration/merge-key.yaml | 6 + .../sdl/sdl-yaml-v1/valid/anchors.yaml | 5 + .../sdl/sdl-yaml-v1/valid/core-scalars.yaml | 5 + .../sdl/sdl-yaml-v1/valid/minimal.yaml | 1 + contracts/schema-publication-manifest.json | 12 +- contracts/schemas/README.md | 10 +- .../schemas/sdl/instantiated-scenario-v1.json | 30 +- .../schemas/sdl/sdl-authoring-input-v1.json | 33 +- .../adr-001-scenario-description-language.md | 6 + docs/decisions/adrs/adr-index.yaml | 4 + ...21-dsl-105-canonical-sdl-yaml-preflight.md | 392 ++++++ ...-normative-sdl-catalog-parity-preflight.md | 287 ++++ docs/explain/reference/coding-standards.md | 5 +- .../reference/shared-semantic-integrity.md | 4 +- docs/explain/sdl/complex-scenarios.md | 7 +- docs/explain/sdl/index.md | 4 +- docs/explain/sdl/limitations.md | 2 +- docs/explain/sdl/parser.md | 83 +- docs/explain/sdl/sections.md | 136 +- docs/explain/sdl/testing.md | 11 +- docs/explain/sdl/validation.md | 5 +- .../action-contract-observation-boundary.yaml | 174 +-- .../templates/run/timed-run-control.yaml | 6 +- .../scenario/minimal-validated-scenario.yaml | 2 +- .../study/observational-study-protocol.yaml | 2 +- .../templates/task/single-objective-task.yaml | 2 +- .../workflow/parallel-objective-workflow.yaml | 4 +- ...erprise-participant-evidence-loop.sdl.yaml | 356 ++--- .../hospital-ransomware-surgery-day.sdl.yaml | 1152 ++++++++++------ .../port-authority-surge-response.sdl.yaml | 1032 +++++++++----- .../satcom-release-poisoning.sdl.yaml | 1133 ++++++++++----- examples/scenarios/techvault.sdl.yaml | 1224 +++++++++-------- .../python/packages/aces_cli/sdl.py | 37 + .../packages/aces_conformance/conformance.py | 2 +- .../packages/aces_mcp/tools/authoring.py | 20 +- .../aces_mcp/tools/language_service.py | 6 +- .../aces_mcp/tools/operation_support.py | 14 +- .../packages/aces_mcp/tools/operations.py | 19 +- .../packages/aces_mcp/tools/reference.py | 51 +- .../python/packages/aces_sdl/__init__.py | 20 + .../python/packages/aces_sdl/_errors.py | 3 + .../packages/aces_sdl/_language_metadata.py | 18 +- .../packages/aces_sdl/_language_references.py | 5 +- .../aces_sdl/_reference_targetability.py | 16 + .../packages/aces_sdl/_source_profile.py | 93 ++ .../packages/aces_sdl/_source_validation.py | 343 +++++ .../python/packages/aces_sdl/_yaml_loader.py | 183 ++- .../python/packages/aces_sdl/canonical.py | 53 + .../python/packages/aces_sdl/composition.py | 27 +- .../python/packages/aces_sdl/formatting.py | 57 + .../packages/aces_sdl/language_service.py | 19 +- .../packages/aces_sdl/module_registry.py | 11 +- .../python/packages/aces_sdl/orchestration.py | 15 +- .../python/packages/aces_sdl/parser.py | 153 ++- .../python/packages/aces_sdl/scenario.py | 40 +- .../python/packages/aces_sdl/scenarios.py | 26 +- .../packages/aces_sdl/validator/_core.py | 12 +- implementations/python/pyproject.toml | 1 + .../tests/test_example_library_policy.py | 2 +- .../tests/test_example_schema_conformance.py | 20 +- .../python/tests/test_fm2_semantics.py | 4 +- .../python/tests/test_language_service.py | 73 +- .../python/tests/test_mcp_server.py | 63 +- .../test_reference_backend_components.py | 2 +- .../python/tests/test_reference_processor.py | 4 +- .../python/tests/test_run_300_lifecycle.py | 2 +- .../python/tests/test_runtime_conformance.py | 2 +- .../tests/test_runtime_control_plane.py | 40 +- .../tests/test_runtime_control_plane_api.py | 20 +- .../python/tests/test_runtime_mail_service.py | 16 +- .../python/tests/test_runtime_manager.py | 8 +- .../python/tests/test_runtime_models.py | 236 ++-- .../tests/test_runtime_network_detection.py | 46 +- .../tests/test_runtime_network_sensor.py | 18 +- .../python/tests/test_runtime_planner.py | 30 +- .../tests/test_runtime_security_monitoring.py | 44 +- .../tests/test_runtime_service_listeners.py | 66 +- .../python/tests/test_runtime_ssh_server.py | 8 +- .../python/tests/test_sdl_canonicalization.py | 217 +++ .../python/tests/test_sdl_catalog_parity.py | 162 +++ .../python/tests/test_sdl_format_cli.py | 52 + .../python/tests/test_sdl_models.py | 32 +- .../python/tests/test_sdl_parser.py | 529 +++---- .../python/tests/test_sdl_realworld.py | 4 +- .../python/tests/test_sdl_source_format.py | 316 +++++ .../python/tests/test_sdl_stress.py | 16 +- .../python/tests/test_sdl_validator.py | 118 +- .../test_sem_208_participant_behavior.py | 1022 +++++++------- ...st_sem_211_participant_action_semantics.py | 182 +-- ..._sem_213_temporal_participant_semantics.py | 216 +-- ..._215_participant_outcome_interpretation.py | 186 +-- .../python/tests/test_sem_218_explicitness.py | 2 +- .../python/tests/test_semantics_objectives.py | 6 +- .../python/tests/test_yaml_mapping_keys.py | 39 +- implementations/python/uv.lock | 11 + noxfile.py | 4 + .../declarative-objective-semantics.md | 4 +- .../participant-behavior-model/README.md | 4 +- specs/sdl/README.md | 11 +- specs/sdl/diagnostics.md | 52 +- specs/sdl/document-model.md | 175 ++- specs/sdl/observability-and-evidence.md | 10 +- specs/sdl/references.md | 64 + specs/sdl/runtime-inventory.md | 36 +- specs/sdl/sections.md | 94 +- tools/check_sdl_catalog_parity.py | 741 ++++++++++ 117 files changed, 8653 insertions(+), 3778 deletions(-) create mode 100644 contracts/fixtures/sdl/sdl-yaml-v1/README.md create mode 100644 contracts/fixtures/sdl/sdl-yaml-v1/invalid/alias-cycle.yaml create mode 100644 contracts/fixtures/sdl/sdl-yaml-v1/invalid/directive.yaml create mode 100644 contracts/fixtures/sdl/sdl-yaml-v1/invalid/duplicate-field.yaml create mode 100644 contracts/fixtures/sdl/sdl-yaml-v1/invalid/explicit-tag.yaml create mode 100644 contracts/fixtures/sdl/sdl-yaml-v1/invalid/merge-conflict.yaml create mode 100644 contracts/fixtures/sdl/sdl-yaml-v1/invalid/multiple-documents.yaml create mode 100644 contracts/fixtures/sdl/sdl-yaml-v1/invalid/non-string-key.yaml create mode 100644 contracts/fixtures/sdl/sdl-yaml-v1/invalid/noncanonical-field.yaml create mode 100644 contracts/fixtures/sdl/sdl-yaml-v1/invalid/nonfinite.yaml create mode 100644 contracts/fixtures/sdl/sdl-yaml-v1/migration/field-alias.yaml create mode 100644 contracts/fixtures/sdl/sdl-yaml-v1/migration/merge-key.yaml create mode 100644 contracts/fixtures/sdl/sdl-yaml-v1/valid/anchors.yaml create mode 100644 contracts/fixtures/sdl/sdl-yaml-v1/valid/core-scalars.yaml create mode 100644 contracts/fixtures/sdl/sdl-yaml-v1/valid/minimal.yaml create mode 100644 docs/decisions/issue-721-dsl-105-canonical-sdl-yaml-preflight.md create mode 100644 docs/decisions/issue-722-normative-sdl-catalog-parity-preflight.md create mode 100644 implementations/python/packages/aces_sdl/_reference_targetability.py create mode 100644 implementations/python/packages/aces_sdl/_source_profile.py create mode 100644 implementations/python/packages/aces_sdl/_source_validation.py create mode 100644 implementations/python/packages/aces_sdl/canonical.py create mode 100644 implementations/python/packages/aces_sdl/formatting.py create mode 100644 implementations/python/tests/test_sdl_canonicalization.py create mode 100644 implementations/python/tests/test_sdl_catalog_parity.py create mode 100644 implementations/python/tests/test_sdl_format_cli.py create mode 100644 implementations/python/tests/test_sdl_source_format.py create mode 100644 tools/check_sdl_catalog_parity.py diff --git a/contracts/fixtures/sdl/sdl-yaml-v1/README.md b/contracts/fixtures/sdl/sdl-yaml-v1/README.md new file mode 100644 index 000000000..ab92e07be --- /dev/null +++ b/contracts/fixtures/sdl/sdl-yaml-v1/README.md @@ -0,0 +1,14 @@ +# `sdl-yaml/v1` Conformance Corpus + +This directory is the normative example corpus for the raw SDL YAML source +profile defined by `specs/sdl/document-model.md`. + +- `valid/` documents must pass strict source decoding. +- `invalid/` documents must fail strict source decoding. +- `migration/` documents must fail strict decoding, then pass only when an + explicit migration policy is selected and must produce at least one + source-ranged warning. + +These files test YAML presentation rules that JSON Schema cannot express. The +separate `contracts/schemas/sdl/sdl-authoring-input-v1.json` artifact validates +the normalized authoring object after decoding and typed normalization. diff --git a/contracts/fixtures/sdl/sdl-yaml-v1/invalid/alias-cycle.yaml b/contracts/fixtures/sdl/sdl-yaml-v1/invalid/alias-cycle.yaml new file mode 100644 index 000000000..a63d1eb52 --- /dev/null +++ b/contracts/fixtures/sdl/sdl-yaml-v1/invalid/alias-cycle.yaml @@ -0,0 +1,5 @@ +name: alias-cycle +nodes: &nodes + switch: + type: switch + roles: *nodes diff --git a/contracts/fixtures/sdl/sdl-yaml-v1/invalid/directive.yaml b/contracts/fixtures/sdl/sdl-yaml-v1/invalid/directive.yaml new file mode 100644 index 000000000..d63cd21c8 --- /dev/null +++ b/contracts/fixtures/sdl/sdl-yaml-v1/invalid/directive.yaml @@ -0,0 +1,3 @@ +%YAML 1.2 +--- +name: directive diff --git a/contracts/fixtures/sdl/sdl-yaml-v1/invalid/duplicate-field.yaml b/contracts/fixtures/sdl/sdl-yaml-v1/invalid/duplicate-field.yaml new file mode 100644 index 000000000..b9c430b9e --- /dev/null +++ b/contracts/fixtures/sdl/sdl-yaml-v1/invalid/duplicate-field.yaml @@ -0,0 +1,2 @@ +name: first +name: second diff --git a/contracts/fixtures/sdl/sdl-yaml-v1/invalid/explicit-tag.yaml b/contracts/fixtures/sdl/sdl-yaml-v1/invalid/explicit-tag.yaml new file mode 100644 index 000000000..25242d72b --- /dev/null +++ b/contracts/fixtures/sdl/sdl-yaml-v1/invalid/explicit-tag.yaml @@ -0,0 +1 @@ +name: !!str explicit-tag diff --git a/contracts/fixtures/sdl/sdl-yaml-v1/invalid/merge-conflict.yaml b/contracts/fixtures/sdl/sdl-yaml-v1/invalid/merge-conflict.yaml new file mode 100644 index 000000000..1ae5494a4 --- /dev/null +++ b/contracts/fixtures/sdl/sdl-yaml-v1/invalid/merge-conflict.yaml @@ -0,0 +1,7 @@ +name: merge-conflict +nodes: + base: &base + type: switch + conflict: + <<: *base + type: vm diff --git a/contracts/fixtures/sdl/sdl-yaml-v1/invalid/multiple-documents.yaml b/contracts/fixtures/sdl/sdl-yaml-v1/invalid/multiple-documents.yaml new file mode 100644 index 000000000..f1fcefa34 --- /dev/null +++ b/contracts/fixtures/sdl/sdl-yaml-v1/invalid/multiple-documents.yaml @@ -0,0 +1,4 @@ +--- +name: first +--- +name: second diff --git a/contracts/fixtures/sdl/sdl-yaml-v1/invalid/non-string-key.yaml b/contracts/fixtures/sdl/sdl-yaml-v1/invalid/non-string-key.yaml new file mode 100644 index 000000000..d39601317 --- /dev/null +++ b/contracts/fixtures/sdl/sdl-yaml-v1/invalid/non-string-key.yaml @@ -0,0 +1,3 @@ +name: non-string-key +nodes: + true: {type: switch} diff --git a/contracts/fixtures/sdl/sdl-yaml-v1/invalid/noncanonical-field.yaml b/contracts/fixtures/sdl/sdl-yaml-v1/invalid/noncanonical-field.yaml new file mode 100644 index 000000000..876539aa9 --- /dev/null +++ b/contracts/fixtures/sdl/sdl-yaml-v1/invalid/noncanonical-field.yaml @@ -0,0 +1 @@ +Name: noncanonical-field diff --git a/contracts/fixtures/sdl/sdl-yaml-v1/invalid/nonfinite.yaml b/contracts/fixtures/sdl/sdl-yaml-v1/invalid/nonfinite.yaml new file mode 100644 index 000000000..4ea5d9044 --- /dev/null +++ b/contracts/fixtures/sdl/sdl-yaml-v1/invalid/nonfinite.yaml @@ -0,0 +1,2 @@ +name: nonfinite +description: .inf diff --git a/contracts/fixtures/sdl/sdl-yaml-v1/migration/field-alias.yaml b/contracts/fixtures/sdl/sdl-yaml-v1/migration/field-alias.yaml new file mode 100644 index 000000000..fc988923d --- /dev/null +++ b/contracts/fixtures/sdl/sdl-yaml-v1/migration/field-alias.yaml @@ -0,0 +1 @@ +Name: migration-alias diff --git a/contracts/fixtures/sdl/sdl-yaml-v1/migration/merge-key.yaml b/contracts/fixtures/sdl/sdl-yaml-v1/migration/merge-key.yaml new file mode 100644 index 000000000..203aebd0b --- /dev/null +++ b/contracts/fixtures/sdl/sdl-yaml-v1/migration/merge-key.yaml @@ -0,0 +1,6 @@ +name: migration-merge +nodes: + shared: &shared + type: switch + reuse: + <<: *shared diff --git a/contracts/fixtures/sdl/sdl-yaml-v1/valid/anchors.yaml b/contracts/fixtures/sdl/sdl-yaml-v1/valid/anchors.yaml new file mode 100644 index 000000000..7406a597c --- /dev/null +++ b/contracts/fixtures/sdl/sdl-yaml-v1/valid/anchors.yaml @@ -0,0 +1,5 @@ +name: canonical-anchors +nodes: + shared: &shared + type: switch + reuse: *shared diff --git a/contracts/fixtures/sdl/sdl-yaml-v1/valid/core-scalars.yaml b/contracts/fixtures/sdl/sdl-yaml-v1/valid/core-scalars.yaml new file mode 100644 index 000000000..13084a3df --- /dev/null +++ b/contracts/fixtures/sdl/sdl-yaml-v1/valid/core-scalars.yaml @@ -0,0 +1,5 @@ +name: core-scalars +description: yes +nodes: + on: + type: switch diff --git a/contracts/fixtures/sdl/sdl-yaml-v1/valid/minimal.yaml b/contracts/fixtures/sdl/sdl-yaml-v1/valid/minimal.yaml new file mode 100644 index 000000000..bb4f040ea --- /dev/null +++ b/contracts/fixtures/sdl/sdl-yaml-v1/valid/minimal.yaml @@ -0,0 +1 @@ +name: canonical-minimal diff --git a/contracts/schema-publication-manifest.json b/contracts/schema-publication-manifest.json index 8a21dacdd..cdf6c43af 100644 --- a/contracts/schema-publication-manifest.json +++ b/contracts/schema-publication-manifest.json @@ -166,10 +166,10 @@ "contract_id": "instantiated-scenario-v1", "schema_path": "contracts/schemas/sdl/instantiated-scenario-v1.json", "stability": "draft", - "content_hash": "e0a28239e30855be948b4c3718ae34811de231b3f8d8e146d149d862af0b7cc5", + "content_hash": "733046b498635268546eadc1e8c0977492cfe64fa9f71ac07c5f43ac7b5956c7", "last_change": { - "summary": "Removed the OCR scoring pipeline (metrics/evaluations/tlos/goals), agents.reward_calculator, entities.tlos, injects.tlos, and workflow-predicate/objective-success scoring references; narrowed objectives.success to conditions (observable state) per ADR-073.", - "content_hash": "e0a28239e30855be948b4c3718ae34811de231b3f8d8e146d149d862af0b7cc5" + "summary": "Aligned workflow wire fields with canonical snake_case and propagated the SDL normalized-object phase metadata into the instantiated contract definitions for DSL-105.", + "content_hash": "733046b498635268546eadc1e8c0977492cfe64fa9f71ac07c5f43ac7b5956c7" } }, { @@ -376,10 +376,10 @@ "contract_id": "sdl-authoring-input-v1", "schema_path": "contracts/schemas/sdl/sdl-authoring-input-v1.json", "stability": "draft", - "content_hash": "820fbd6619a100e382d722890edcdde01da977158fd86f958f4928a9d32ad51a", + "content_hash": "495345b06294ac9379009ad7b03b14df60ec54c25036764589355c9a1bc4f257", "last_change": { - "summary": "Removed the OCR scoring pipeline (metrics/evaluations/tlos/goals), agents.reward_calculator, entities.tlos, injects.tlos, and workflow-predicate/objective-success scoring references; narrowed objectives.success to conditions (observable state) per ADR-073.", - "content_hash": "820fbd6619a100e382d722890edcdde01da977158fd86f958f4928a9d32ad51a" + "summary": "Declared the DSL-105 normalized authoring-object boundary, marked it as distinct from raw sdl-yaml/v1 source, and aligned workflow wire fields with canonical snake_case.", + "content_hash": "495345b06294ac9379009ad7b03b14df60ec54c25036764589355c9a1bc4f257" } }, { diff --git a/contracts/schemas/README.md b/contracts/schemas/README.md index 64a8adffb..8b2af3a92 100644 --- a/contracts/schemas/README.md +++ b/contracts/schemas/README.md @@ -8,7 +8,7 @@ be the home of the authoritative machine-readable artifacts, independent of any single implementation language or package layout. Current published schemas cover: -- SDL authoring input +- SDL normalized authoring objects (not raw YAML presentation) - instantiated scenarios - backend manifests (`v1` legacy plus shared-apparatus `v2`) - processor manifests (`v1` legacy plus shared-apparatus `v2`) @@ -28,6 +28,14 @@ Current published schemas cover: - experiment-core task, run, apparatus-context, study/collection, capture specification, raw evidence, and derived measure contracts +`sdl/sdl-authoring-input-v1.json` begins after `sdl-yaml/v1` source decoding, +canonical-field recognition, shorthand expansion, enum normalization, and typed +construction. Its title and `x-aces-document-phase` annotation state that +boundary. Raw YAML properties such as duplicate keys, tags, directives, +anchors, aliases, Core scalar resolution, and resource limits are specified in +`specs/sdl/document-model.md` and tested by +`contracts/fixtures/sdl/sdl-yaml-v1/`; JSON Schema cannot express them. + Current filenames still use `runtime` for some live-execution artifacts. That naming is preserved for compatibility while the repository migrates toward the processor/runtime boundary described in diff --git a/contracts/schemas/sdl/instantiated-scenario-v1.json b/contracts/schemas/sdl/instantiated-scenario-v1.json index 516c6a4c8..91ec911d1 100644 --- a/contracts/schemas/sdl/instantiated-scenario-v1.json +++ b/contracts/schemas/sdl/instantiated-scenario-v1.json @@ -20998,12 +20998,12 @@ "title": "Cases", "type": "array" }, - "compensate-with": { + "compensate_with": { "default": "", "not": { "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" }, - "title": "Compensate-With", + "title": "Compensate With", "type": "string" }, "default": { @@ -21038,7 +21038,7 @@ "title": "Join", "type": "string" }, - "max-attempts": { + "max_attempts": { "anyOf": [ { "type": "integer" @@ -21054,7 +21054,7 @@ } ], "default": null, - "title": "Max-Attempts" + "title": "Max Attempts" }, "next": { "default": "", @@ -21072,28 +21072,28 @@ "title": "Objective", "type": "string" }, - "on-exhausted": { + "on_exhausted": { "default": "", "not": { "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" }, - "title": "On-Exhausted", + "title": "On Exhausted", "type": "string" }, - "on-failure": { + "on_failure": { "default": "", "not": { "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" }, - "title": "On-Failure", + "title": "On Failure", "type": "string" }, - "on-success": { + "on_success": { "default": "", "not": { "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" }, - "title": "On-Success", + "title": "On Success", "type": "string" }, "then": { @@ -21147,7 +21147,7 @@ "additionalProperties": false, "description": "Predicate reference to previously observed workflow step state.", "properties": { - "min-attempts": { + "min_attempts": { "anyOf": [ { "type": "integer" @@ -21163,7 +21163,7 @@ } ], "default": null, - "title": "Min-Attempts" + "title": "Min Attempts" }, "outcomes": { "items": { @@ -21473,6 +21473,8 @@ "required": [ "name" ], - "title": "InstantiatedScenario", - "type": "object" + "title": "SDL Instantiated Scenario v1", + "type": "object", + "x-aces-document-phase": "instantiated-scenario", + "x-aces-source-profile": "sdl-yaml/v1" } diff --git a/contracts/schemas/sdl/sdl-authoring-input-v1.json b/contracts/schemas/sdl/sdl-authoring-input-v1.json index 12631467b..b5d7c2629 100644 --- a/contracts/schemas/sdl/sdl-authoring-input-v1.json +++ b/contracts/schemas/sdl/sdl-authoring-input-v1.json @@ -16933,9 +16933,9 @@ "title": "Cases", "type": "array" }, - "compensate-with": { + "compensate_with": { "default": "", - "title": "Compensate-With", + "title": "Compensate With", "type": "string" }, "default": { @@ -16958,7 +16958,7 @@ "title": "Join", "type": "string" }, - "max-attempts": { + "max_attempts": { "anyOf": [ { "type": "integer" @@ -16971,7 +16971,7 @@ } ], "default": null, - "title": "Max-Attempts" + "title": "Max Attempts" }, "next": { "default": "", @@ -16983,19 +16983,19 @@ "title": "Objective", "type": "string" }, - "on-exhausted": { + "on_exhausted": { "default": "", - "title": "On-Exhausted", + "title": "On Exhausted", "type": "string" }, - "on-failure": { + "on_failure": { "default": "", - "title": "On-Failure", + "title": "On Failure", "type": "string" }, - "on-success": { + "on_success": { "default": "", - "title": "On-Success", + "title": "On Success", "type": "string" }, "then": { @@ -17043,7 +17043,7 @@ "additionalProperties": false, "description": "Predicate reference to previously observed workflow step state.", "properties": { - "min-attempts": { + "min_attempts": { "anyOf": [ { "type": "integer" @@ -17056,7 +17056,7 @@ } ], "default": null, - "title": "Min-Attempts" + "title": "Min Attempts" }, "outcomes": { "items": { @@ -17143,7 +17143,7 @@ "$id": "https://aces.dev/schemas/sdl-authoring-input-v1.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, - "description": "Top-level scenario specification.\n\nA YAML document with up to 23 named sections. Only ``name``\nis required. All sections are optional dicts keyed by\nuser-defined identifiers.", + "description": "Normalized SDL authoring object.\n\nThis model applies after ``sdl-yaml/v1`` source-profile checks, structural\nkey canonicalization, shorthand expansion, enum normalization, and typed\nconstruction, but before module expansion and instantiation. Its JSON\nSchema does not validate YAML presentation details.", "properties": { "accounts": { "additionalProperties": { @@ -17345,6 +17345,9 @@ "required": [ "name" ], - "title": "Scenario", - "type": "object" + "title": "SDL Normalized Authoring Object v1", + "type": "object", + "x-aces-document-phase": "normalized-authoring-object", + "x-aces-source-profile": "sdl-yaml/v1", + "x-aces-validates-raw-source": false } diff --git a/docs/decisions/adrs/adr-001-scenario-description-language.md b/docs/decisions/adrs/adr-001-scenario-description-language.md index e3d350c95..1274aaa57 100644 --- a/docs/decisions/adrs/adr-001-scenario-description-language.md +++ b/docs/decisions/adrs/adr-001-scenario-description-language.md @@ -108,3 +108,9 @@ None by design. The repository accepts SDL documents only: - If the repository fails to keep authored participant intent distinct from participant implementation and runtime apparatus, future agent-support work could leak execution-stack concerns back into the SDL surface + +## Amendments + +| Date | Commit/PR | Summary | +|------|-----------|---------| +| 2026-07-11 | #721 | Replaced the original implicit case-insensitive parser convention with the normative `sdl-yaml/v1` source profile: YAML 1.2.2 Core resolution, exact `snake_case` structural fields, strict default rejection, and explicit diagnosed migration for legacy case/kebab spellings and disjoint `<<` merges. Distinguished raw YAML from the normalized authoring-object schema and added the `aces-sdl-semantic/v1` RFC 8785 identity profile. The accepted body above remains the historical 2026-03-29 decision; its parser-spelling statements are not the current syntax contract. | diff --git a/docs/decisions/adrs/adr-index.yaml b/docs/decisions/adrs/adr-index.yaml index e7148f12d..157fbb644 100644 --- a/docs/decisions/adrs/adr-index.yaml +++ b/docs/decisions/adrs/adr-index.yaml @@ -13,6 +13,10 @@ adrs: - id: ADR-001 path: docs/decisions/adrs/adr-001-scenario-description-language.md pin: 29d53078da3314320bca34148440e8ad80a0deb6fc0471177197d2ab90043389 + amendments: + - date: 2026-07-11 + ref: "#721" + summary: "Replaced implicit case-insensitive field parsing with strict sdl-yaml/v1, explicit diagnosed migration, an honest normalized-authoring-object schema boundary, and RFC 8785 semantic identity." - id: ADR-002 path: docs/decisions/adrs/adr-002-declarative-sdl-objectives.md pin: 7550c868306aa4f6ff7a1522796b71ae8797aa0015d7faec37c8f7011e60c536 diff --git a/docs/decisions/issue-721-dsl-105-canonical-sdl-yaml-preflight.md b/docs/decisions/issue-721-dsl-105-canonical-sdl-yaml-preflight.md new file mode 100644 index 000000000..ad12ffafd --- /dev/null +++ b/docs/decisions/issue-721-dsl-105-canonical-sdl-yaml-preflight.md @@ -0,0 +1,392 @@ +# Issue 721 DSL-105 Canonical SDL YAML Preflight + +Date: 2026-07-11 + +Issue: #721. + +Requirement: DSL-105, Typed Parsing, Normalization, And Authoritative SDL +Format. + +This note records architecture preflight guardrails for defining one canonical +SDL YAML source format. It is guidance for the implementation. It does not +change parser behavior, schemas, examples, diagnostics, or digest semantics. + +## Binding Sources + +- `specs/sdl/` is the language-neutral normative SDL authority. Its + `document-model.md` and `diagnostics.md` already own encoding, mapping + identity, normalization scope, document phases, and fail-closed diagnostics. +- ADR-001 owns the SDL/parser decision. Its case-insensitive and hyphenated key + handling may remain as explicitly selected migration behavior; it must not + remain an undocumented second canonical dialect. +- ADR-009, ADR-019, and `specs/authority/authority-boundary.yaml` make + `contracts/schemas/` and `contracts/fixtures/` normative while Python is a + non-normative reference implementation. +- ADR-053 owns module parsing, trust, lock, digest, signature, and composition + behavior. Canonical source handling must enter that pipeline rather than + bypass it. +- ADR-061 and `contracts/schema-publication-manifest.json` own published-schema + evolution and change records. The current SDL schemas are `draft`; correcting + their phase description is allowed in place but still requires the existing + ledger and reference-bundle parity gates. +- ADR-075 and `specs/evolution/versioning-deprecation-and-migration.md` separate + compatibility, deprecation, and migration. A migration spelling is not a + second stable format and must never be silently reinterpreted as canonical. + +## Architecture Decisions + +### One source profile + +The authoritative source profile is `sdl-yaml/v1` with these rules: + +- Input is UTF-8, contains exactly one YAML 1.2.2 document, and uses the YAML + 1.2 Core schema for implicit scalar resolution. YAML 1.1 coercion is not part + of SDL: plain `yes`, `no`, `on`, and `off` are strings, while booleans are + `true` or `false` under the Core rules. +- The constructed value domain is I-JSON/JCS-compatible: mappings, sequences, + Unicode strings without lone surrogates, null, booleans, integers that are + in the interoperable range `[-9007199254740991, 9007199254740991]`, and finite + IEEE-754 binary64 numbers. The Core schema does not implicitly construct + timestamps; date-like plain scalars are strings. Native timestamp, binary, + set, ordered-map, pair, arbitrary-object, non-finite-number, and other + implementation-specific values are invalid. A future need for wider integers + or decimals requires a typed string representation and a new canonicalization + profile, not silent implementation-dependent rounding. +- Explicit tags and tag directives are not SDL authoring syntax. Quoting is the + portable way to force a string. A safe loader remains mandatory, but + "SafeLoader accepted it" is not a validity rule. +- Every mapping key is a string scalar. Exact duplicates and normalized + structural-key collisions are rejected on the source-marked node graph before + a last-write-wins dictionary or model can be built. +- Acyclic anchors and aliases are representation-only conveniences. They add no + identity, reference, import, or precedence semantics. Cycles and operational + alias/node/depth budget exhaustion fail cleanly without partial construction. +- `<<` merge keys are not canonical `sdl-yaml/v1`. An explicit migration read + may retain the current disjoint-union behavior, but it must diagnose the merge + as non-canonical; any source/local or source/source conflict remains fatal. +- Comments, scalar style, anchor names, line endings, and mapping order do not + change SDL meaning. Sequence order and string code points do. No Unicode + normalization is applied to authored string data or literal map keys. + +These rules belong in the normative document model and conformance fixtures, +not in PyYAML behavior notes. A conforming implementation must be able to +implement them without reading Python. + +The source profile applies to authored text before convenience transforms. +`_prepare_content()` currently dedents every input and `load_scenario()` strips +it; neither operation may precede canonical source validation because it can +change source ranges, make invalid indentation valid, or change block-scalar +content. Test callers may dedent their own literals. Likewise, no corpus, +formatter, or adapter may `safe_load()` and re-dump SDL before the source profile +sees it: that destroys duplicate keys, tags, aliases, authored spellings, scalar +tags, and source locations. + +### Canonical fields, migration spellings, and shorthands + +- The canonical spelling of a structural field is its exact published-schema + property name. Multiword SDL fields use lowercase `snake_case`. Intentional + single-word wire names such as `class`, `type`, `next`, `then`, `else`, and + `default` remain exact even when Python needs a differently named attribute. +- Existing kebab-case, mixed-case, and uppercase structural keys are migration + spellings only. Migration matching is ASCII case-folding plus `-` to `_`, and + only succeeds when the result is an actual canonical field in that structural + scope. There is no fuzzy matching or typo correction. +- Canonical validation is strict by default. Migration acceptance is explicit, + returns the same typed model, and emits a stable non-fatal diagnostic carrying + the authored spelling, canonical spelling, JSON Pointer, and source range. + Strict mode reports the same condition as a fatal source diagnostic. Neither + mode may silently overwrite a canonical field or accept incompatible + metadata/mode-based scenario dialects. +- Literal/user-defined mapping keys remain byte-for-byte authored strings. Node + names, section identifiers, `facts`, `labels`, `log_options`, native options, + and extension keys are not structural-field aliases. `Web-App` and `web_app` + may therefore remain distinct identifiers where the existing mapping-scope + catalog says the map is literal. +- The existing documented shorthands remain SDL authoring syntax, not another + dialect. They expand once in the shared parser before typed construction. + Canonical serialization is always longhand. Stale `min-score` guidance must + not reintroduce the scoring surface removed by ADR-073. +- Enum values continue to use the existing case/hyphen normalization in + `normalize_enum_value()` and `parse_enum_or_var()`; their canonical serialized + values are the declared lowercase values. Field-key normalization and enum + normalization remain separate concepts. + +Canonical scalar and collection types are the YAML 1.2 Core-resolved types that +the normalized schema declares. A whole-string `${name}` placeholder is the +documented exception for a variable-capable typed field. Generic Pydantic +coercion is not SDL syntax: quoted integers, `yes`/`no`/`on`/`off` or `0`/`1` as +boolean spellings, arbitrary strings in a placeholder branch, and scalar-to-list +coercion are canonical only if the normative source contract explicitly names +that exact shorthand. Existing convenience coercions retained for compatibility +are migration syntax and receive the same source diagnostics as field aliases. +The published normalized schema should constrain placeholder-only string +branches to the variable-token grammar instead of advertising every string as a +valid normalized value. + +### Raw syntax, normalized model, and schemas are distinct phases + +The implementation must keep these boundaries explicit: + +1. raw `sdl-yaml/v1` source representation; +2. normalized authoring object after key canonicalization, declared scalar and + collection normalization, shorthand expansion, enum normalization, and typed + structural construction; +3. expanded authoring scenario after module composition; +4. instantiated scenario after variable binding; +5. compiled/runtime artifacts. + +`sdl-authoring-input-v1.json` is the normalized authoring-object schema. Its +title and description must say that it applies after YAML source-profile checks +and normalization, but before module expansion and instantiation. It is not a +validator for YAML token spelling, duplicate keys, tags, anchors, aliases, or +migration diagnostics. `instantiated-scenario-v1.json` remains the concrete +post-instantiation schema. + +Do not publish a second full Scenario schema merely to label raw YAML. The raw +contract is the normative source-profile prose plus valid, invalid, and +migration fixtures under the existing SDL fixture family. Canonical reusable +examples use canonical keys, canonical enum spellings, and expanded longhand so +their directly decoded object validates against the artifact advertised for +authoring. "Directly decoded" means decoded once with the shared `sdl-yaml/v1` +resolver and source checks, not PyYAML defaults. A parse-then-`model_dump()` +schema test remains useful normalized-model evidence, but it must not be +presented as proof that the raw example matched the source contract. + +`schema_bundle()`, `tools/generate_contract_schemas.py`, and +`tools/check_generated_schemas.py` remain the reference-compatibility proof. +Schema changes remain hand-governed through the existing publication manifest; +there is no second SDL schema registry, source-of-truth model, or change ledger. + +### Canonical semantic serialization and digest + +Canonical identity is defined over a successfully parsed, expanded, and +semantically validated authoring scenario immediately before instantiation. It +is not defined over raw YAML bytes and is unavailable for parse-only or +semantically invalid input. + +The canonical payload is a versioned semantic projection, not a blind +`model_dump()` and not a claim that the bytes are round-trippable authoring YAML. +Its scenario member uses public JSON-mode values and canonical wire property +names in normalized longhand. It preserves authored field presence after +normalization: omitted defaults must not be materialized indiscriminately, +because `model_fields_set` feeds the normative SEM-218 explicitness classifier +and can change compiler/planner behavior. Shorthand-expanded fields count as +present in the normalized meaning. + +The projection must also include a stable representation of every otherwise- +private channel that can change instantiation, compilation, or planning, or +prove that the channel is derivable from the scenario member. Today that +includes `module_variable_specs` and `module_node_variable_refs`; excluding them +would make scenarios with different imported-module constraints share an +identity. Derived explicitness may be omitted only when preserved field presence +and values deterministically reconstruct it. Semantic advisories, source ranges, +filesystem paths, trust/cache evidence, and explanatory provenance text are +non-semantic and excluded. The profile specification must enumerate the +projection; Python private-attribute names are not the contract. + +Object members, including preserved literal keys, are serialized using the +[RFC 8785 JSON Canonicalization Scheme](https://www.rfc-editor.org/rfc/rfc8785.html); +arrays retain their semantic order and strings are not Unicode-normalized. Input +is already constrained to JCS's I-JSON number and Unicode domain, so +canonicalization must never round or repair a value. The canonical bytes are +UTF-8. The digest is SHA-256 over those bytes and is represented with the +repository's existing lowercase `sha256:` convention. A digest record must +identify the canonicalization profile (`aces-sdl-semantic/v1`); a bare hash must +not imply an unversioned algorithm. + +This semantic digest is deliberately separate from: + +- an authored file's raw byte digest; +- OCI layer, config, and manifest digests; +- module lockfile `content_digest`, `manifest_digest`, and `export_hash`; and +- signatures over the existing module signer payload. + +Those existing values protect source/package integrity and supply-chain +identity. They must not be silently redefined to ignore comments, aliases, +spelling, or file layout. Conversely, a semantic digest is not a signature, a +trust decision, a secret-redaction mechanism, or proof that a scenario ran. + +## Required Incumbents + +- Authority and publication: `specs/sdl/document-model.md`, + `specs/sdl/diagnostics.md`, `contracts/schemas/sdl/`, + `contracts/fixtures/sdl/`, `contracts/schema-publication-manifest.json`, + `tools/check_schema_publication.py`, `tools/check_generated_schemas.py`, and + `specs/authority/authority-boundary.yaml`. +- Source loading and mapping identity: `aces_sdl._yaml_loader`, + `aces_sdl._mapping_scopes.MappingScope`, `HASHMAP_SECTIONS`, + `NESTED_HASHMAP_FIELDS`, `is_literal_map_field()`, and the existing + source-ranged mapping analyzer. +- Normalization and typing: `parser._load_normalized_data()`, + `_normalize_keys()`, `_expand_shorthands()`, `aces_sdl._base` enum/scalar + helpers (including the existing integer/float/boolean coercion audit surface), + `SDLModel(extra="forbid")`, `Scenario`, `aces_sdl.explicitness`, and + `SemanticValidator`. +- Composition and trust: `parse_sdl_file()`, `expand_sdl_modules()`, + `ImportDecl`, `ModuleDescriptor`, `resolve_import()`, `TrustPolicy`, lockfile + validation, digest/version/export checks, signature verification, path + confinement, cycle rejection, and bounded OCI extraction. +- Public ingress surfaces: `parse_sdl()`, `parse_sdl_file()`, + `load_sdl_fragment()`, `load_scenario()`, the reference processor, + language-service format/edit/diagnostic helpers, MCP authoring and operation + tools, the static MCP reference/example text, example-library checks, and SDL + CLI verify/publish commands. Imported documents and fragments must use the + same source policy as roots. +- Diagnostics and presentation: `SDLError`, `SDLParseError`, + `SDLParseDiagnostic`, `SDLValidationError`, `ScenarioValidationError` as the + existing high-level wrapper, `_language_diagnostics`, and MCP + `operation_support.stage_error()`. Add codes or advisory severity to this + envelope; do not add another exception or response hierarchy. +- Verification: `test_sdl_parser.py`, `test_yaml_mapping_keys.py`, + `test_sdl_fuzz.py`, `test_language_service.py`, `test_mcp_server.py`, + `test_example_schema_conformance.py`, `test_pipeline_determinism.py`, and the + canonical nox `contracts`, `tests`, `fuzz`, `docs`, and `verify` sessions. + Extend the existing example-corpus leg to check original source before + parse-and-dump validation; do not create a second corpus registry. + +## Cross-Cutting Security And Runtime Layers + +- **YAML safety gate:** `_SDLSafeLoader` remains the only SDL composer. Its + resolver must implement the source profile without mutating PyYAML's global + resolver tables. It rejects unsafe/non-profile tags, duplicate keys, alias + cycles, merge ambiguity, excessive source/scalar size and depth/node/alias + work, non-I-JSON numbers/strings, and other non-profile values before recursive + normalization or model construction. Count alias edges/work, not only unique + composed nodes, because normalization can otherwise amplify a small graph. +- **Shape gates:** the mapping analyzer runs before dictionary construction; + canonical scalar/collection shape is checked before Pydantic; then + `SDLModel(extra="forbid")` rejects unknown structure. Existing field validators + retain range, identifier, redaction, argv, environment-name, and domain-specific + shape checks; `SemanticValidator` retains reference and graph closure. + Canonicalization must bypass none of them, and `parse_float_or_var()` must not + admit `NaN` or infinity through a quoted string. +- **Module and filesystem gates:** local imports remain base-confined; OCI and + locked imports retain trust policy, allowed registry, version, digest, + lockfile, export-hash, signature, extraction, namespace, and cycle checks. + Canonicalization consumes the already validated/expanded model and must not + create an alternate file reader or untrusted network-fetch path. +- **Secret handling:** `enforce_observed_value_redaction()` and the existing + `redacted`/`operator_secret` omission validators still apply after + normalization. SDL may legitimately contain scenario credentials under + ADR-057, so canonical bytes and digests must not be logged, persisted, or + returned as if they were sanitized. Hashing a low-entropy secret is not + redaction. +- **MCP/input limits:** the existing 64 KiB language-service, authoring, + inspection, and operation-tool guards remain adapter limits. The shared YAML + loader also needs one cohesive, explicitly threaded operational-limits policy + for raw size, scalar size, depth, nodes, and alias work so direct library/file + callers are not an unbounded YAML-bomb path. Adapter limits may be stricter. + Limit refusal is an operational diagnostic, not a claim that another + implementation must consider the SDL semantically invalid; do not scatter new + constants or ambient environment switches through adapters. +- **Auth and transport:** the current MCP surface is the existing FastMCP + transport and introduces no new HTTP/control-plane endpoint or authorization + model. This work must not add a bypass around a host's transport auth or put + source text, credentials, or private keys into process arguments. +- **Configuration and OS exposure:** source-format/migration selection is an + explicit API/CLI input, not an ambient environment variable. CLI entrypoints + continue to accept validated file paths and private-key paths, not key values; + no shell command construction, environment dump, or new bind/listen surface is + required. +- **Error envelope:** canonical and migration diagnostics reuse + `SDLParseDiagnostic` and carry codes, stage, severity, canonical pointer, + bounded source locations, and a logical source identity when imports are + involved. Messages may quote field-key spellings but not mapping values, full + YAML, canonical payloads, parameter maps, environment values, secrets, + tracebacks, or unrestricted absolute paths. Language-service and MCP adapters + preserve the same structured record. A migrated model may be semantically + valid while its original source remains non-conforming; do not fold source + migration notices into `SemanticValidator.warnings` or report the raw document + as canonical. +- **Logging and persistence:** `aces_sdl.scenarios` remains the existing + advisory/load logging boundary and must log only bounded code/path/spelling + metadata for migration notices. No new logger, audit stream, database, cache, + lockfile field, or automatic digest persistence belongs in this change. + +## Extension Boundary + +The extension seam belongs at source ingress, not in Pydantic models, +validators, processors, or backends. A named `source_format` value pins +`sdl-yaml/v1`; an orthogonal explicit migration policy decides whether legacy +spellings/merges are rejected or canonicalized with diagnostics. Thread those +values through the existing `_load_normalized_data()` path used by strings, +files, fragments, imports, formatters, and MCP tools. Do not add scattered +`case_insensitive`, `allow_hyphens`, `allow_merge`, or `yaml_11` booleans. +Operational parser limits are a separate cohesive input to that same ingress; +they do not select language meaning and must not leak into Pydantic models. + +A future source-format revision gets a new format identifier and a deliberate +compatibility relation under ADR-075. A future canonicalization algorithm gets +a new `aces-sdl-semantic/vN` identifier. Neither variation requires a second +Scenario model, validator stack, schema registry, or runtime endpoint. + +## Gotchas And Anti-Patterns + +Avoid: + +- relying on PyYAML defaults, Pydantic coercion, or Python's `bool`-is-`int` + behavior to define portable scalar validity; +- dedenting, stripping, `safe_load()`-round-tripping, or otherwise rewriting the + text before source-profile and source-range validation; +- treating `Scenario.model_json_schema()` as a raw YAML grammar or treating a + parse-then-dump example test as raw-source schema validation; +- keeping the six current kebab-case workflow serialization aliases + (`on-success`, `on-failure`, `on-exhausted`, `max-attempts`, + `min-attempts`, `compensate-with`) while declaring snake_case canonical; +- normalizing user identifiers, native option keys, labels, facts, extension + keys, or JSON Pointer tokens as structural fields; +- accepting a canonical root while parsing imported modules or fragments with a + more permissive implicit mode; +- auto-detecting dialects from top-level keys or silently treating an unknown + field as a migration alias; +- publishing migration spellings through Pydantic `Field(alias=...)`; validation + aliases, canonical serialization names, and Python-safe attribute names are + different concerns; +- preserving YAML merge precedence, resolving cycles, or expanding aliases + without resource budgets; +- using `yaml.safe_dump()` output as cross-language identity bytes; it remains a + human formatter, not the canonical digest serialization; +- reusing phase-specific helpers merely because they say "canonical": the + compiled-runtime witness in `test_pipeline_determinism.py`, schema-publication + hashing, and run-artifact JSON formatting do not implement + `aces-sdl-semantic/v1`; +- omitting defaults or private/public fields inconsistently between repeated + canonicalization passes, materializing omitted defaults that carry authored + explicitness, dropping module side channels that affect downstream behavior, + or sorting arrays whose order is semantic; +- feeding arbitrary-precision integers, lone surrogates, `NaN`, or infinity to + JCS and relying on a library to round, replace, or reject them differently; +- producing an authoritative semantic digest from + `skip_semantic_validation=True`, a directly constructed `Scenario`, or a + partially expanded import graph; +- replacing raw module/OCI digests or signature payloads with the semantic + digest, or presenting a digest as authenticity, execution evidence, or + redaction; +- duplicating `parse_sdl()` and `parse_sdl_file()` policy branches. The file + entrypoint already can delegate to the string parser with a path; source-mode + and diagnostic logic must have one implementation; +- adding another full SDL schema, canonical field table, shorthand engine, + exception hierarchy, diagnostic DTO, size-limit constant, migration service, + or schema/change manifest; +- rewriting accepted ADR bodies. Any needed ADR-001 clarification follows the + ADR-059 amendment/pin process while current normative behavior lives in + `specs/sdl/`. + +## Non-Goals + +- Implementing the parser, schema, example, fixture, formatter, diagnostic, or + digest changes in this note. +- Adding or changing SDL domain fields, semantic reference rules, variable + semantics, module resolution, compiler/runtime contracts, or backend behavior. +- Accepting metadata/mode-based scenarios, YAML 1.1, arbitrary tags, merge + precedence, JSON5, TOML, or another scenario dialect. +- Building a general-purpose YAML validator, migration service, source registry, + persistence layer, control-plane API, auth system, network service, or audit + pipeline. +- Defining canonical identity for instantiated scenarios, compiled plans, + runtime snapshots, evidence artifacts, or OCI packages. Those phases retain + their existing authorities and require separate versioned decisions if they + later need semantic digests. +- Treating formatting as source preservation. Comments, anchors, scalar style, + and original field spelling are intentionally not recoverable from the + normalized semantic projection. diff --git a/docs/decisions/issue-722-normative-sdl-catalog-parity-preflight.md b/docs/decisions/issue-722-normative-sdl-catalog-parity-preflight.md new file mode 100644 index 000000000..23f575adf --- /dev/null +++ b/docs/decisions/issue-722-normative-sdl-catalog-parity-preflight.md @@ -0,0 +1,287 @@ +# Issue 722 Normative SDL Catalog Parity Preflight + +Date: 2026-07-11 + +Issue: #722. + +Requirement: none. The issue title, body, and acceptance criteria are the +contract. + +This note records architecture guardrails for reconciling the normative SDL +catalogs with the live authoring contract. It does not edit the catalogs, +schemas, models, validators, examples, or release workflow. No new ADR is +needed: ADR-009, ADR-019, and the issue-498 preflight already fix the authority +direction and catalog boundary; this issue adds enforcement and repairs drift +within that boundary. + +## Binding Authorities And Incumbents + +- `specs/sdl/README.md`, `document-model.md`, `sections.md`, `references.md`, + `runtime-inventory.md`, and `diagnostics.md` are the language-neutral + normative SDL prose authority. +- `contracts/schemas/sdl/sdl-authoring-input-v1.json` is the published, + hand-governed structural enumeration. `contracts/schema-publication-manifest.json`, + `tools/check_schema_publication.py`, and ADR-061 govern any change to it. +- `Scenario.model_fields` and `schema_bundle()` are reference-implementation + evidence. `tools/check_generated_schemas.py` already proves that generated + output matches the published schema without rewriting that authority. +- `_mapping_scopes.HASHMAP_SECTIONS` owns parser treatment of authored map keys; + `_module_symbols.HASHMAP_SECTIONS` owns the narrower module-composable symbol + set. They have different jobs and must not be merged merely to make their + counts equal. +- `SemanticValidator._named_ref_index()`, its targetable filtering, + `aces_sdl.semantics.participant_behavior`, and + `_language_metadata.REFERENCE_COMPLETION_TARGETS` are implementation evidence + for reference validation and authoring-tool navigation. Completion metadata + is not a semantic validator and is not a complete reference registry. +- `_runtime_service_families.RUNTIME_SERVICE_FAMILIES` is the existing + node-runtime family registry. Its key, collection, primary id, and child-ref + tree must be compared with `specs/sdl/runtime-inventory.md`; no second runtime + family registry is warranted. +- Behavior-specific governed domains already exist in + `controlled-vocabularies-v1`, the controlled-vocabulary validation helpers, + and `aces_contracts.manifest_authority`. Do not replace those domains with a + generic SDL-symbol lookup. + +## Authority Direction And Parity Boundary + +The release check must be a read-only, three-way compatibility proof: + +1. parse top-level field names, requiredness, and shapes from the published + authoring schema; +2. parse the normative rows from `specs/sdl/sections.md` and + `specs/sdl/references.md`; and +3. inspect the reference implementation's model and existing registries as + realization evidence. + +All comparisons are bidirectional set comparisons. A schema/model field absent +from the catalog and a catalog field absent from the schema/model are both +failures. The check must never generate or overwrite normative prose or a +published schema, and implementation metadata must never be used to render the +normative tables. That preserves independent implementation authority while +making drift visible. + +The dated preflight inspection for this issue yields 28 top-level fields: five +metadata or composition fields and 23 authoring sections, of which 22 are +map-keyed and one (`forwarding_agents`) is list-valued; it also finds 17 +node-scoped runtime families. Those are issue evidence, not a new maintained +count source. Current-language values may appear durably only in a +checker-validated catalog summary or derived report. Explanatory prose, model +docstrings, schema descriptions, MCP text, and examples should otherwise use +count-free wording. The OCR stress fixture's "14 sections" may remain only +where it is explicitly a fixture-scope claim, not a claim about the live +language. + +## Catalog Row Contract + +Every top-level row must carry enough information to support an independent +implementation without opening Python: + +- kind: metadata/composition or authoring section; +- document phase, using the vocabulary in `document-model.md` section 7 rather + than processor/runtime phase names; +- value shape: scalar, fixed mapping, map keyed by authored identity, or list; +- requiredness and default/omission behavior; +- identity source: scenario name, mapping key, list element id, or none; +- outbound reference edges or an explicit `none`; and +- the normative semantic owner (SDL file, formal spec, and/or owning ADR). + +The phase column must distinguish fields consumed by composition or +instantiation from fields that survive those phases. It must not call compiled +runtime output an SDL document phase. The identity column must distinguish map +keys from in-element ids, especially scenario-level `forwarding_agents`. + +The checker should parse stable table headings and exact columns, following the +bounded Markdown-table pattern in `tools/check_semantic_coverage.py`. Missing +headings, malformed rows, duplicate fields, unknown shape/phase tokens, and a +blank coverage cell are policy failures, not parser tracebacks. Do not add a +parallel YAML catalog or a second full SDL schema to make Markdown easier to +check. + +## Reference-Edge Semantics + +Reference rows need exact canonical source paths, not broad prose such as +"authority refs." Each row must name its candidate domain, resolution phase, +failure behavior, and semantic owner. The catalog must distinguish at least: + +- SDL symbol sets and the generic targetable set; +- aggregate-local symbols such as workflow steps and observation information; +- derived domains such as participant roles; +- governed controlled-vocabulary scopes; +- published contract-id registries; +- qualified runtime-family/child paths; and +- opaque external/profile refs that currently receive only shape validation. + +An `_ref` suffix does not make those domains interchangeable. The catalog is a +coverage and meaning contract; it must not be used to generate semantic +validation or a new exception path. + +For `behavior_specifications`, the live candidate and validation semantics to +record are: + +| Field | Candidate domain and current validation | +|-------|-----------------------------------------| +| `participant_refs` | Keys in `agents`; unresolved variables defer to instantiation; dangling refs are fatal. | +| `participant_role_refs` | Role values of flattened entities actually bound by declared agents, not arbitrary agent keys or every enum value; dangling refs are fatal. | +| `action_contract_refs` | Keys in `action_contracts`; dangling refs are fatal. | +| `observation_boundary_refs` | Keys in `observation_boundaries`; dangling refs are fatal. | +| `outcome_interpretation_rule_refs` | Keys in `outcome_interpretation_rules`; dangling refs are fatal. | +| `authority_scope_refs` | The generic targetable alias index, with exact-one resolution and fatal dangling/ambiguity; it is not Agent `operating_scope` and not unrestricted `any`. | +| `behavior_mode` | Controlled-vocabulary scope `behavior_specifications.behavior_mode`; this is a governed term, not an SDL symbol ref. | +| `ai_offensive_behavior_refs` | Controlled-vocabulary scope `behavior_specifications.ai_offensive_behavior_refs`. | +| `offensive_behavior_refs` | Controlled-vocabulary scope `behavior_specifications.offensive_behavior_refs`. | +| `backend_feature_support_refs` | Union of `participant-runtime-behavior-features` and `participant-runtime-interaction-features`. | +| `evidence_contract_refs` | The published processor/backend/participant contract-id sets from `aces_contracts.manifest_authority`, not every schema-bundle id. | +| `realization_profile_ref` | Optional non-empty opaque profile reference today; the catalog must not claim local resolution that the implementation does not perform. | + +Reference lists also retain the existing model-level non-empty and per-field +uniqueness checks. Every list-valued behavior reference above skips a whole +unresolved `${name}` value during authoring semantic validation and is checked +again after instantiation; scalar `behavior_mode` does not use that path, while +`realization_profile_ref` currently has no semantic resolver. Deferral must be +stated per field according to the actual validator, not asserted generically +for every governed value. + +`REFERENCE_COMPLETION_TARGETS` remains completion/navigation metadata. Every +entry it exposes must have a normative reference row, but the reverse need not +hold because not every governed or opaque domain has editor completions. Its +current `("behavior_specifications", "authority_scope_refs"): "any"` entry is +broader than semantic validation and must be reconciled with the shared +targetable policy rather than documented as true. Do not copy the validator's +targetable exclusions into another unowned list. + +Runtime-family parity is structural rather than numeric: compare every family +key, collection, primary id, and recursively addressable child collection +between `RUNTIME_SERVICE_FAMILIES` and the runtime-family index. A matching +count with a renamed or missing row is still a failure. Scenario-level +`forwarding_agents` remains a top-level list section; node-level +`nodes..runtime.forwarding_agents` remains a runtime family. + +The extensibility seam is the checked row shape, parameterized by contract +target (published schema path, model class, and catalog table heading) and by +reference-domain token. Adding the next section, edge, or runtime family should +add authority/model/registry rows without adding a hard-coded count or a +field-specific branch to the checker. A future authoring-contract version can +reuse the same extractors with a second contract target; it must not silently +mix v1 and v2 rows. + +## Release Gate And Failure Surface + +Use one dedicated catalog-parity checker and wire it once into the canonical +nox verification graph. The natural home is the `contracts` leg beside schema +publication and generated-schema drift because the check compares published +contract structure with normative prose and its implementation. `verify`, the +pre-push hook, and CI then inherit it; do not duplicate the command in GitHub +Actions or release-please workflows. Relevant `specs/sdl/`, SDL model/registry, +schema, checker, and checker-test paths should trigger the contracts leg in the +optimized pre-commit graph. + +The gate must scan the complete live catalogs on every invocation, not only +changed rows. It should reuse `tools.policy.common.PolicyFailure`, deterministic +sorting, JSON rendering, and the existing exception mechanism. Store source +line numbers while parsing so failures and the eventual #541 closure evidence +identify exact catalog rows. Catalog drift is a repository-policy failure; it +must not become `SDLParseError`, `SDLValidationError`, a runtime diagnostic, or +a new SDL exception hierarchy. + +Focused checker tests should cover malformed and duplicate rows, missing and +extra top-level fields, wrong requiredness/shape/identity, map/list confusion, +reference rows with wrong candidate domains, missing runtime families or child +refs, checked-summary drift, and a passing live-repository integration case. +Existing semantic tests remain the evidence for actual reference behavior; the +policy test does not replace them. + +## Cross-Cutting Security And Operational Layers + +- **Authentication/control plane:** none is in scope. The checker performs no + network access, API calls, authorization decisions, or control-plane + mutation. `ControlPlaneSecurityConfig` and HTTP handlers must not be pulled + into this repository-policy concern. +- **File and config shape:** read only fixed repo-relative catalog/schema paths + and canonical package registries. Parse JSON and Markdown as inert data. If a + future CLI makes paths configurable, pass them through + `tools.policy.common.safe_repo_path`; never follow catalog links or imports. +- **Schema/model validation:** retain `SDLModel(extra="forbid")`, source-profile + checks, `SemanticValidator`, controlled-vocabulary validation, and + `tools/check_generated_schemas.py`. The parity gate observes these surfaces; + it does not bypass or duplicate them. +- **Secret handling:** do not inspect scenario values, environment dumps, + credentials, or arbitrary files. Failure messages may name repo paths, + catalog line numbers, field paths, and expected/actual identifiers, but must + not print file bodies, schema payloads, environment values, tracebacks, or + secrets. Existing hygiene, private-key, and gitleaks stages remain the + repository secret gates. +- **Environment and OS exposure:** add no environment binding and no external + command that carries data in process argv. Nox should invoke the checker with + a static script path through the frozen project environment. The checker + writes no files and needs no temporary or persistent state. +- **Errors and observability:** malformed inputs become bounded + `PolicyFailure` records and a non-zero exit. Nox `SessionReporter` is the + canonical stage log; no new logger, telemetry sink, HTTP error envelope, or + raw exception traceback is needed. +- **Persistence:** none. Do not rewrite catalogs, generated schemas, the schema + manifest, caches, or reports as a side effect of checking parity. + +## Stale-Surface Audit Guardrails + +The reconciliation must cover all reader-facing completeness claims, not only +the two normative tables. Current audit targets include: + +- `specs/sdl/README.md` acceptance questions and the stale reconciliation note + in `document-model.md`; +- the current/future contradiction in `observability-and-evidence.md` and the + evidence-syntax limitation in `docs/explain/sdl/limitations.md`; +- the incomplete overview and non-canonical `behavior-specifications` spelling + in `docs/explain/sdl/sections.md`; +- numeric completeness claims in `docs/explain/sdl/index.md`, + `docs/explain/sdl/complex-scenarios.md`, and + `docs/explain/reference/glossary.md`; +- static counts, accepted section names, and example claims in + `aces_mcp.tools.reference`; and +- examples/tests that claim complete-language coverage while exercising only a + historical subset. + +Model and published-schema descriptions are count-free in the current tree; +preserve that property. A documentation count and a validation-pass count are +different concepts and must not be made equal. Likewise, an example can claim +coverage of named constructs it actually asserts, but not "all sections" +without a checked comparison to the catalog. + +## Gotchas And Anti-Patterns + +Avoid: + +- generating normative prose from `Scenario`, or generating implementation + registries from normative Markdown; +- adding a YAML/JSON meta-schema that duplicates the existing catalog and + published SDL schema; +- treating `REFERENCE_COMPLETION_TARGETS` as validation authority or using + `"any"` where the validator uses targetable, role-derived, or governed sets; +- inferring semantics solely from `_ref`/`_refs` naming; +- collapsing controlled-vocabulary refs, contract ids, evidence refs, profile + refs, SDL symbols, and runtime-family refs into one resolver; +- broadening or narrowing live validation merely to make prose match; any true + semantic change needs its own authority and tests; +- equating map-keyed authoring sections with module-exportable symbols; +- counting `imports` as the one list-valued authoring section, or counting the + two forwarding-agent placements as one surface; +- describing `evidence_requirements` as future syntax or as captured evidence; +- adding a second policy command directly to CI/release workflows; or +- hand-editing schema descriptions without the publication-manifest ledger and + generated-bundle parity required by ADR-061 and repo policy. + +## Non-Goals And Implementation Boundary + +- No SDL syntax, model field, schema structure, reference candidate set, + validation severity, compiler output, or runtime behavior changes are + authorized by this issue. +- No validator consolidation, general SDL metamodel, documentation generator, + new runtime-family registry, exception hierarchy, API, database, or + persistence layer is in scope. +- Do not rewrite accepted historical ADR bodies; current catalogs and + explanatory docs carry the reconciliation. +- Do not close or comment on #541 from this work. The implementation should + produce line-addressable repository evidence that a separately authorized + workflow can cite. +- Do not add changelog fragments or edit package versions; release-please owns + those surfaces. diff --git a/docs/explain/reference/coding-standards.md b/docs/explain/reference/coding-standards.md index 6645e189e..5c50719a6 100644 --- a/docs/explain/reference/coding-standards.md +++ b/docs/explain/reference/coding-standards.md @@ -80,8 +80,9 @@ Required artifacts: Do not add TLA+ or Alloy for FM0 work. -**Repo example:** parser key normalization in `aces.core.sdl.parser`, where -`start-time` becomes `start_time` but user-defined names remain concrete. +**Repo example:** explicit SDL source migration, where a formatter diagnoses and +rewrites legacy `start-time` as canonical `start_time` while user-defined names +remain concrete. Strict `sdl-yaml/v1` parsing accepts only the canonical field. ### FM1 Static Semantic diff --git a/docs/explain/reference/shared-semantic-integrity.md b/docs/explain/reference/shared-semantic-integrity.md index 1d5dbce17..86ccfc267 100644 --- a/docs/explain/reference/shared-semantic-integrity.md +++ b/docs/explain/reference/shared-semantic-integrity.md @@ -52,8 +52,8 @@ construct by local convention. Use these existing surfaces before adding anything new: -- SDL shape and local parsing: `aces_sdl.SDLModel`, parser key normalization, - variable-key rejection, and `SDLParseError` +- SDL shape and local parsing: `aces_sdl.SDLModel`, `sdl-yaml/v1` source-profile + enforcement, explicit migration, variable-key rejection, and `SDLParseError` - static SDL validation: `SemanticValidator` and `SDLValidationError` - instantiation: `instantiate_scenario()` and `SDLInstantiationError` - shared SDL semantics: `aces_sdl.semantics.objectives` and diff --git a/docs/explain/sdl/complex-scenarios.md b/docs/explain/sdl/complex-scenarios.md index b069bd223..26efa0041 100644 --- a/docs/explain/sdl/complex-scenarios.md +++ b/docs/explain/sdl/complex-scenarios.md @@ -89,12 +89,13 @@ red team attempts data theft and radiology disruption? - Objective success expressed against observable `conditions` (e.g. service uptime and recovery state); any graded scoring of the exercise is an experiment/evaluator-plane concern, not authored SDL -- Strong distinction between in-world telemetry and any extra experiment-side - evidence capture outside the current SDL syntax +- Strong distinction between in-world telemetry, authored + `evidence_requirements`, and experiment-side captured evidence ### SDL Stress Surface -- All 17 sections +- Broad coverage across topology, participant, narrative, objective, workflow, + and evidence authoring surfaces - Hybrid IT + clinical + vendor trust boundaries - Multiple agents with distinct initial knowledge and subnet scope - Objectives that target systems, relationships, and content diff --git a/docs/explain/sdl/index.md b/docs/explain/sdl/index.md index 5b241b1a4..f9c9531e7 100644 --- a/docs/explain/sdl/index.md +++ b/docs/explain/sdl/index.md @@ -136,8 +136,8 @@ accounts: ## Documentation -- [SDL Sections Reference](sections.md) — Complete reference for all 17 sections -- [Parser Behavior](parser.md) — Key normalization, shorthand expansion, SDL-only parsing +- [SDL Sections Reference](sections.md) — Explanatory reference for the complete live authoring-section catalog +- [Parser Behavior](parser.md) — `sdl-yaml/v1`, explicit migration, typed normalization, and canonical identity - [Language-Service Tools](language-service.md) — Agent-facing completions, references, formatting, diagnostics, and structured edits - [Agent Guidance Profile](agent-guidance.md) — Machine-readable scope boundaries, invariants, review priorities, and safe-operating expectations - [Semantic Validation](validation.md) — Cross-reference checks and what the validator enforces diff --git a/docs/explain/sdl/limitations.md b/docs/explain/sdl/limitations.md index db427b801..d1c21fb99 100644 --- a/docs/explain/sdl/limitations.md +++ b/docs/explain/sdl/limitations.md @@ -124,7 +124,7 @@ These are current SDL expressiveness gaps: | **Full time and clock model** | The SDL currently exposes timelines, timeouts, and budget-like controls, but it does not provide a full authoring surface for time domains, clock authority, pacing/dilation policy, synchronization mode, or explicit ordering/deadline semantics across different realizations | Time-and-simulation primary references under `research/`, ROS 2 Clock and Time, FMI, ns-3 realtime, DEVS/time-management literature | | **Full solver-backed verification** | Global proof-style verification that attack paths are reachable and defenses are consistent is not implemented; the repository uses lightweight semantic modeling, invariants, typed contracts, and selective property/state-machine methods | VSDL SMT solver, CRACK Datalog | | **Full participant behavior surface** | The current `agents` section under-expresses richer role-neutral behavior concerns such as tool/affordance declarations, control-context assets, decision-surface exposure policies, episode structure, and benchmark-oriented participant assets | CybORG, OpenRange, Open Trajectory Gym | -| **Scenario-native observability and authored evidence requirements** | The ecosystem treats in-world observability systems and authored "capture these data from these sources" requirements as first-class concerns. ADR-066 and `specs/sdl/observability-and-evidence.md` now define the plane split and authoring rules. SDL still needs executable syntax, schemas, fixtures, and validators for the broader authored evidence-requirements model | OpenRange, OCSF-informed telemetry models | +| **Materialized evidence capture and provenance** | SDL has implemented `evidence_requirements` syntax, a published schema, fixtures, and fail-closed validation for portable capture intent. It does not itself materialize captures, prove collection, calculate integrity, retain artifacts, or supply complete run-level provenance and loss reporting; those are processor/backend and experiment-evidence responsibilities | OpenRange, OCSF-informed telemetry models | | **User behavior profiles** | Normal user activity patterns (browsing, email, file access schedules) | CybORG Green agents | | **Multi-tenancy** | Multiple independent exercises sharing infrastructure | Locked Shields team-per-subnet model | diff --git a/docs/explain/sdl/parser.md b/docs/explain/sdl/parser.md index 5a2aad81b..97941a5b1 100644 --- a/docs/explain/sdl/parser.md +++ b/docs/explain/sdl/parser.md @@ -1,8 +1,9 @@ # SDL Parser Behavior The parser (`aces.core.sdl.parser`) transforms raw YAML into a validated -`Scenario` object through source-marked safe composition, mapping-key -validation, key normalization, shorthand expansion, and model construction. +`Scenario` object through `sdl-yaml/v1` decoding, source-marked safe +composition, canonical-field validation, shorthand expansion, and typed model +construction. This layer is intentionally about syntax, normalization, and structural model construction. It is usually an `FM0` surface under the repository's @@ -13,23 +14,29 @@ The mapping-key injectivity gate is such an invariant and is treated as `FM1`: table-driven and property tests pin ambiguity rejection and literal-map preservation. -## Key Normalization +## Canonical Fields and Migration -YAML field keys (Pydantic struct fields) are normalized to lowercase with hyphens converted to underscores: +Canonical SDL structural fields use exact lower-case `snake_case`: -- `Name` → `name` -- `Min-Score` → `min_score` -- `start-time` → `start_time` +- `name` is canonical; `Name` is migration syntax. +- `start_time` is canonical; `start-time` is migration syntax. +- `semantic_version` is canonical; `Semantic-Version` is migration syntax. **User-defined names are preserved as-is.** Node names, feature names, account names, entity fact keys, and other HashMap keys are not transformed. This ensures cross-references remain consistent. ```yaml -# "My-Switch" is preserved, "Type" is normalized to "type" +# "My-Switch" is preserved; structural field "type" is exact. nodes: My-Switch: - Type: Switch + type: switch ``` +Ordinary parsing is strict. Callers doing a deliberate conversion can select +`SDLMigrationPolicy.ACCEPT` or use `aces sdl format`; each recognized rewrite +produces a source-ranged `sdl.noncanonical_field` or +`sdl.noncanonical_merge` warning. The formatter emits strict, typed, longhand +YAML and never rewrites literal identifiers. + Field aliases do not imply precedence. Writing both `Name` and `name`, or both `password-strength` and `password_strength`, in one structural mapping is a fatal `sdl.mapping_key_conflict`. Exact duplicates are also fatal in @@ -38,8 +45,18 @@ user-defined and native maps, but distinct literal keys such as `Web-App` and The check runs over the composed YAML node graph before a Python dictionary is constructed, so it retains both authored spellings and source ranges. YAML -anchors remain supported. A `<<` merge is accepted only when all inherited and -local effective keys are disjoint; cyclic aliases are rejected. +anchors remain supported. A `<<` merge is rejected by strict parsing and is +accepted only by explicit migration when all inherited and local effective +keys are disjoint; cyclic aliases are rejected. + +## Source Profile + +`sdl-yaml/v1` is UTF-8, exactly one YAML 1.2.2 document, and uses Core-schema +scalar resolution. Explicit tags/directives, non-string keys, non-finite or +non-JSON values, cyclic aliases, and resource-budget exhaustion fail before +model construction. Unlike PyYAML's default YAML 1.1 resolver, `yes`, `no`, +`on`, and `off` remain strings. The normative rules and exact budgets are in +`specs/sdl/document-model.md`. ## Shorthand Expansion @@ -50,12 +67,13 @@ Several shorthand forms are expanded before model construction: | `source: "pkg-name"` | `source: {name: "pkg-name", version: "*"}` | | `infrastructure: {node: 3}` | `infrastructure: {node: {count: 3}}` | | `roles: {admin: "username"}` | `roles: {admin: {username: "username"}}` | -| `min-score: 50` | `min-score: {percentage: 50}` | | `features: [svc-a, svc-b]` (on nodes) | `features: {svc-a: "", svc-b: ""}` | Source expansion only applies to actual SDL `source` fields. It is skipped inside `relationships` and `agents` where `source` is a plain string reference, and it does not fire on user-defined map keys that merely happen to be named `source`. -Shorthand expansion also works when the shorthand value is a full variable placeholder. For example, `infrastructure: {web: ${replicas}}` expands to `infrastructure: {web: {count: ${replicas}}}`, and `min-score: ${pass_pct}` expands to `min-score: {percentage: ${pass_pct}}`. +Shorthand expansion also works when the shorthand value is a full variable +placeholder. For example, `infrastructure: {web: ${replicas}}` expands to +`infrastructure: {web: {count: ${replicas}}}`. ## Variables @@ -86,24 +104,31 @@ or `1m+30`. Sub-second values are rounded up to whole seconds, so `1 ms` parses as `1`. Negative numeric durations are rejected rather than silently coerced. -## Format Boundary +## Format and Schema Boundaries -The parser accepts one format: +The parser accepts one source profile: -- **SDL format:** Top-level `name` field plus SDL sections +- **`sdl-yaml/v1`:** top-level `name` plus SDL sections under the source rules above. Older metadata/mode-based scenario YAMLs are intentionally rejected. They must be migrated to SDL before parsing. +`contracts/schemas/sdl/sdl-authoring-input-v1.json` validates the normalized, +typed authoring object after source decoding and shorthand expansion. It is not +a raw-YAML grammar and cannot validate tags, aliases, duplicate keys, scalar +resolution, or source limits. Canonical shipped examples use longhand values so +their strict decoded object validates against it directly. + ## Validation Pipeline -1. **Safe YAML composition** — build a source-marked standard-tag node graph -2. **Mapping-key preflight** — reject exact, normalized, and merge conflicts -3. **Safe construction** — construct native values only after ambiguity checks -4. **Key normalization** — lowercase field keys, preserve user names -5. **Shorthand expansion** — source, infrastructure, roles, min-score, feature lists +1. **Source-profile preflight** — enforce UTF-8 size, tokens, aliases, and YAML 1.2 Core rules +2. **Safe YAML composition** — build a source-marked standard-tag node graph +3. **Mapping-key preflight** — reject exact/canonical collisions and migration syntax by default +4. **Safe construction** — construct JSON-domain native values only after ambiguity checks +5. **Typed normalization** — expand shorthands and normalize declared field values 6. **Pydantic construction** — structural validation (types, ranges, required fields) -7. **Semantic validation** — cross-reference checks plus variable-reference checks (see [validation.md](validation.md)) +7. **Module expansion** — resolve file-backed imports before full semantic validation +8. **Semantic validation** — cross-reference checks plus variable-reference checks (see [validation.md](validation.md)) On success, the returned `Scenario` may still carry non-fatal advisories in `scenario.advisories` (for example, VM nodes without explicit `resources`). @@ -111,7 +136,12 @@ On success, the returned `Scenario` may still carry non-fatal advisories in `sce ```python from aces.core.sdl import parse_sdl, parse_sdl_file -from aces_sdl import load_sdl_fragment +from aces_sdl import ( + SDLMigrationPolicy, + canonical_sdl_digest, + format_sdl_source, + load_sdl_fragment, +) # Parse from string scenario = parse_sdl(yaml_string) @@ -122,6 +152,13 @@ scenario = parse_sdl_file(Path("scenario.yaml")) # Structural validation only (skip cross-reference checks) scenario = parse_sdl(yaml_string, skip_semantic_validation=True) +# Explicit legacy conversion; ordinary parsing remains strict. +migrated = format_sdl_source(legacy_yaml) +scenario = parse_sdl(legacy_yaml, migration_policy=SDLMigrationPolicy.ACCEPT) + +# Versioned semantic identity requires successful semantic validation. +digest = canonical_sdl_digest(parse_sdl(yaml_string)) + # Advanced authoring tools can preflight a fragment at its final address. nodes = load_sdl_fragment( nodes_yaml, diff --git a/docs/explain/sdl/sections.md b/docs/explain/sdl/sections.md index abb5ab4e6..a8e699870 100644 --- a/docs/explain/sdl/sections.md +++ b/docs/explain/sdl/sections.md @@ -1,8 +1,9 @@ # SDL Sections Reference A scenario is a YAML document with a required top-level `name`, optional -top-level composition fields (`version`, `module`, `imports`), and up to 22 named SDL -sections. Aside from `name`, all sections are optional. +top-level metadata and composition fields, and the authoring sections catalogued +below. Aside from `name`, every top-level field is optional. The normative, +machine-checked enumeration is `specs/sdl/sections.md`. Top-level composition fields are: @@ -18,7 +19,7 @@ Canonical `imports.source` classes are: ## Section Overview -### From Open Cyber Range SDL (10 sections) +### OCR-derived core The OCR scoring pipeline sections (`metrics`, `evaluations`, `tlos`, `goals`) were removed from the SDL by @@ -39,15 +40,20 @@ plane (ADR-055/064/069). `conditions` (observable state) remain. | `scripts` | `dict[str, Script]` | Timed event sequences with human-readable durations | | `stories` | `dict[str, Story]` | Top-level exercise orchestration grouping scripts | -### Extended Sections (8 sections) +### ACES extensions | Section | Type | Purpose | Adapted From | |---------|------|---------|--------------| | `content` | `dict[str, Content]` | Data placed into systems (files, datasets, emails) | CyRIS `copy_content` | | `accounts` | `dict[str, Account]` | Curated scenario/provisioning accounts on nodes, not full runtime identity inventory | CyRIS `add_account` | | `relationships` | `dict[str, Relationship]` | Typed edges between elements (auth, trust, federation) | STIX Relationship SRO | -| `agents` | `dict[str, Agent]` | Autonomous participants (actions, knowledge, scope) | CybORG Agents | -| `behavior-specifications` | `dict[str, ParticipantBehaviorSpecification]` | Versioned aggregates over participant action, observation, outcome, authority, and mode surfaces | ACES ACT-606 | +| `forwarding_agents` | `list[RuntimeForwardingAgent]` | Scenario-level forwarding and shipping agents with element-carried identity | ACES ADR-050 | +| `agents` | `dict[str, Agent]` | Autonomous participants (actions, knowledge, scope) | CybORG Agents, extended by ACES | +| `action_contracts` | `dict[str, ParticipantActionContract]` | Preconditions, effects, failures, interactions, and fidelity claims for participant actions | ACES participant model | +| `observation_boundaries` | `dict[str, ParticipantObservationBoundary]` | Participant-visible, hidden, discovered, and evidence-bearing information projections | ACES participant model | +| `outcome_interpretation_rules` | `dict[str, OutcomeInterpretationRule]` | Rules connecting action observations and evidence to scenario-local outcomes | ACES participant model | +| `behavior_specifications` | `dict[str, ParticipantBehaviorSpecification]` | Versioned aggregates over participant action, observation, outcome, authority, and mode surfaces | ACES ACT-606 | +| `evidence_requirements` | `dict[str, EvidenceRequirement]` | Portable authored capture obligations, distinct from captured evidence | ACES DSL-124, ADR-066 | | `objectives` | `dict[str, Objective]` | Scenario-local objectives binding actors, targets, windows, and success (against observable `conditions`); not EXP task records | CACAO action/target/agent | | `workflows` | `dict[str, Workflow]` | Branching and parallel control graphs over declared objectives | CACAO workflow graph patterns; semantics tightened using Step Functions / Argo / SCXML style control-flow rules | | `variables` | `dict[str, Variable]` | Parameterization (types, defaults, substitution) | CACAO playbook_variables | @@ -1684,6 +1690,62 @@ remain separate apparatus surfaces. --- +## Forwarding Agents + +The top-level `forwarding_agents` list declares scenario-scoped logical +forwarders. Each element carries a stable `forwarding_agent_id`; relationship +subtypes may reference it from a `forwarding_edge`. This list is distinct from +`nodes..runtime.forwarding_agents`, which places the same logical family +on one node. + +## Action Contracts + +`action_contracts` describe participant-visible actions as declared behavior: +their applicability, intended and side effects, failure classes, interactions, +and fidelity basis. They do not embed runner commands or claim that a backend +can realize the action. + +```yaml +action_contracts: + inspect-portal: + semantic_version: 1.0.0 + lifecycle_state: active + behavioral_granularity: atomic + procedure_basis: bounded inspection of the declared portal + realization_profile: backend-declared +``` + +## Observation Boundaries + +`observation_boundaries` define an information projection for a participant: +what begins observable or hidden, what may become discovered, and which evidence +supports the transition. They describe scenario meaning, not UI filtering or +control-plane authorization. + +```yaml +observation_boundaries: + red-view: + projection_basis: participant-local view of the declared environment + observable_refs: [content.task-brief] + hidden_refs: [nodes.portal] + evidence_refs: [content.terminal-output] +``` + +## Outcome Interpretation Rules + +`outcome_interpretation_rules` state how participant action outcomes, +observations, objective results, and evidence claims are interpreted. They keep +the meaning of an observation separate from graded scoring or evaluator output. + +```yaml +outcome_interpretation_rules: + inspect-portal-outcome: + semantic_version: 1.0.0 + participant_scope: participant_local + observation_point_basis: inspect-portal terminal observation + interpretation_basis: retained terminal evidence supports the local outcome +``` + ## Behavior Specifications First-class participant behavior specifications name, version, and validate an @@ -1692,24 +1754,24 @@ aggregate over existing participant behavior surfaces. They do not replace rules, authority refs, backend feature claims, or runtime evidence. ```yaml -behavior-specifications: +behavior_specifications: red-scan-behavior: - semantic-version: 1.0.0 - lifecycle-state: active - participant-refs: [red-agent] - participant-role-refs: [red] - action-contract-refs: [scan] - observation-boundary-refs: [red-view] - outcome-interpretation-rule-refs: [red-outcome] - authority-scope-refs: + semantic_version: 1.0.0 + lifecycle_state: active + participant_refs: [red-agent] + participant_role_refs: [red] + action_contract_refs: [scan] + observation_boundary_refs: [red-view] + outcome_interpretation_rule_refs: [red-outcome] + authority_scope_refs: - nodes.web-server.services.https - behavior-mode: policy-directed - ai-offensive-behavior-refs: [ai-model-access, defense-evasion] - offensive-behavior-refs: [reconnaissance, exfiltration] - realization-profile-ref: participant-implementation-manifest:red-agent - backend-feature-support-refs: [behavior_history] - evidence-contract-refs: [participant-behavior-history-event-stream-v1] - extension-policy: governed-extension + behavior_mode: policy-directed + ai_offensive_behavior_refs: [ai-model-access, defense-evasion] + offensive_behavior_refs: [reconnaissance, exfiltration] + realization_profile_ref: participant-implementation-manifest:red-agent + backend_feature_support_refs: [behavior_history] + evidence_contract_refs: [participant-behavior-history-event-stream-v1] + extension_policy: governed-extension extensions: x-acme:review-note: owner: acme @@ -1744,6 +1806,36 @@ outcome-rule runtime addresses. --- +## Evidence Requirements + +`evidence_requirements` are portable capture obligations authored with the +scenario. They state sources, scope, trigger or boundary, channel, artifact +role, media types, handling, integrity, retention, and loss-disclosure intent. +They are not evidence records and do not prove that capture occurred. + +```yaml +evidence_requirements: + portal-trace: + description: Retain the declared portal observation for the study. + source_refs: [nodes.portal] + scope_refs: [nodes.portal] + trigger_ref: conditions.portal-online + channel: application_log + artifact_role: participant_observation + media_types: [application/json] + sensitivity: plain + redaction: none + integrity: checksum + retention: study_lifetime + loss_disclosure: required +``` + +Realized capture, checksums, provenance, loss reports, and derived analysis live +in processor/backend and experiment evidence contracts. They remain separate +from the authored requirement. + +--- + ## Objectives Declarative experiment semantics that bind actors, targets, timing, and success criteria in the same SDL. Inspired by CACAO's separation of agent, target, and workflow context; objective success is expressed against observable `conditions` ([ADR-073](../../decisions/adrs/adr-073-scoring-reward-language-scope.md)). diff --git a/docs/explain/sdl/testing.md b/docs/explain/sdl/testing.md index 38613ed30..c37e250cb 100644 --- a/docs/explain/sdl/testing.md +++ b/docs/explain/sdl/testing.md @@ -30,7 +30,9 @@ uv run --extra dev pytest tests/test_sdl_models.py tests/test_sdl_validator.py \ tests/test_sdl_parser.py -v ``` -Tests structural validation (Pydantic models), semantic validation (cross-reference checks), and parser behavior (normalization, shorthands, SDL-only format boundary). +Tests `sdl-yaml/v1` decoding and migration diagnostics, structural validation +(Pydantic models), semantic validation (cross-reference checks), shorthand +expansion, and the SDL-only format boundary. The unit suites also cover OCR-derived duration grammar, workflow graphs, direct service/ACL target refs, and `${var}` placeholder handling across supported scalar/reference fields including selected leaf enums. ### Stress Tests (standard run) @@ -108,6 +110,13 @@ so they stay valid as real SDL artifacts: - `satcom-release-poisoning.sdl.yaml` - `port-authority-surge-response.sdl.yaml` +`test_example_schema_conformance.py` proves each shipped SDL example through +two independent legs: strict `sdl-yaml/v1` decoding followed by direct +validation of the decoded longhand object against the checked-in normalized +authoring schema, and reference-parser model serialization against that same +schema. The valid/invalid/migration source-profile corpus lives under +`contracts/fixtures/sdl/sdl-yaml-v1/`. + The up-front design briefs for the complex examples live in [`docs/explain/sdl/complex-scenarios.md`](complex-scenarios.md). diff --git a/docs/explain/sdl/validation.md b/docs/explain/sdl/validation.md index d9229f84c..ab0cef5b7 100644 --- a/docs/explain/sdl/validation.md +++ b/docs/explain/sdl/validation.md @@ -83,8 +83,9 @@ algorithm and value. Runtime healthcheck entries marked as redacted must omit raw output. Runtime mount sources/options and local-control bind sources classified as `redacted` or `operator_secret` must omit the corresponding raw value; the Python models and generated JSON Schemas both reject non-empty raw -values for redacted/operator-secret labels accepted by the parser's -normalization rules, including case-insensitive hyphen/underscore spellings. +values for redacted/operator-secret labels accepted by the value-normalization +rules, including case-insensitive hyphen/underscore enum spellings. Structural +field keys themselves use exact `snake_case` under `sdl-yaml/v1`. Runtime observed-value surfaces share the ADR-056/ADR-057 raw-value helper: redacted and operator-secret classifications omit raw values. ADR-057 supersedes the earlier name-driven omission rule: credential-shaped names do not by diff --git a/examples/library/templates/participant_behavior/action-contract-observation-boundary.yaml b/examples/library/templates/participant_behavior/action-contract-observation-boundary.yaml index 9bfadae8a..edcbc199d 100644 --- a/examples/library/templates/participant_behavior/action-contract-observation-boundary.yaml +++ b/examples/library/templates/participant_behavior/action-contract-observation-boundary.yaml @@ -19,114 +19,114 @@ body: entities: red-team: role: red - action-contracts: + action_contracts: scan: - semantic-version: 1.0.0 - lifecycle-state: active - behavioral-granularity: atomic - procedure-basis: nmap service discovery - realization-profile: backend-declared - fidelity-claim: records participant discovery intent and terminal observation + semantic_version: 1.0.0 + lifecycle_state: active + behavioral_granularity: atomic + procedure_basis: nmap service discovery + realization_profile: backend-declared + fidelity_claim: records participant discovery intent and terminal observation preconditions: - - precondition-id: authority-in-scope - precondition-class: authority + - precondition_id: authority-in-scope + precondition_class: authority description: red participant is authorized to scan the web service - support-refs: [agents.red-agent, nodes.web.services.http] - - precondition-id: target-service-present - precondition-class: target + support_refs: [agents.red-agent, nodes.web.services.http] + - precondition_id: target-service-present + precondition_class: target description: target service exists in the participant action scope - support-refs: [nodes.web.services.http] - - precondition-id: backend-can-realize-scan - precondition-class: realization + support_refs: [nodes.web.services.http] + - precondition_id: backend-can-realize-scan + precondition_class: realization description: backend can realize the scan action contract - support-refs: [backend.participant-runtime] + support_refs: [backend.participant-runtime] effects: - - effect-id: discover-network-services - effect-class: intended_effect + - effect_id: discover-network-services + effect_class: intended_effect description: discover network services - target-refs: [nodes.web.services.http] - - effect-id: participant-service-knowledge-update - effect-class: side_effect + target_refs: [nodes.web.services.http] + - effect_id: participant-service-knowledge-update + effect_class: side_effect description: participant-local service knowledge changes - target-refs: [nodes.web.services.http] - - effect-id: terminal-scan-observation - effect-class: observation_effect + target_refs: [nodes.web.services.http] + - effect_id: terminal-scan-observation + effect_class: observation_effect description: terminal scan observation - evidence-refs: [evidence.scan-output] - - effect-id: participant-view-discovers-node - effect-class: visibility_effect + evidence_refs: [evidence.scan-output] + - effect_id: participant-view-discovers-node + effect_class: visibility_effect description: participant view marks the web node discovered - target-refs: [nodes.web] - - effect-id: scan-output-evidence - effect-class: evidence_effect + target_refs: [nodes.web] + - effect_id: scan-output-evidence + effect_class: evidence_effect description: scan output is retained as evidence - evidence-refs: [evidence.scan-output] - - effect-id: no-hidden-truth-effect - effect-class: no_effect + evidence_refs: [evidence.scan-output] + - effect_id: no-hidden-truth-effect + effect_class: no_effect description: scan does not disclose hidden adjudication material - state-transition-effects: [participant knowledge expands] - observation-expectations: [terminal scan result] - evidence-expectations: [tool output] - failure-classes: [target_unavailable, precondition_unsatisfied, backend_error, unknown] - backend-failure-mappings: - - backend-error-code: backend.target-unreachable - failure-class: target_unavailable + state_transition_effects: [participant knowledge expands] + observation_expectations: [terminal scan result] + evidence_expectations: [tool output] + failure_classes: [target_unavailable, precondition_unsatisfied, backend_error, unknown] + backend_failure_mappings: + - backend_error_code: backend.target-unreachable + failure_class: target_unavailable diagnostic: backend target unreachable - observation-boundaries: + observation_boundaries: red-view: - projection-basis: participant-local projection over observed services - observable-refs: [] - hidden-refs: [nodes.web, content.private-answer-key] - evidence-refs: [evidence.scan-output] - redaction-policy: hidden refs never project without explicit disclosure - latency-profile: terminal observation emitted after state transition commit - observer-effects: [tool execution may affect telemetry] - realized-view-disclosure: backend reports terminal scan output only - view-rules: - - information-ref: nodes.web - boundary-class: observable_resource + projection_basis: participant-local projection over observed services + observable_refs: [] + hidden_refs: [nodes.web, content.private-answer-key] + evidence_refs: [evidence.scan-output] + redaction_policy: hidden refs never project without explicit disclosure + latency_profile: terminal observation emitted after state transition commit + observer_effects: [tool execution may affect telemetry] + realized_view_disclosure: backend reports terminal scan output only + view_rules: + - information_ref: nodes.web + boundary_class: observable_resource disposition: hidden - visibility-basis: service is not known before terminal scan output - latency-profile: terminal observation latency - - information-ref: content.private-answer-key - boundary-class: private_answer_key + visibility_basis: service is not known before terminal scan output + latency_profile: terminal observation latency + - information_ref: content.private-answer-key + boundary_class: private_answer_key disposition: hidden - visibility-basis: adjudication-only hidden truth - - information-ref: evidence.scan-output - boundary-class: archival_evidence + visibility_basis: adjudication-only hidden truth + - information_ref: evidence.scan-output + boundary_class: archival_evidence disposition: evidence_only - visibility-basis: archival run evidence reference - evidence-refs: [evidence.scan-output] - view-transitions: - - transition-id: discover-web-service - transition-kind: discovery - information-ref: nodes.web + visibility_basis: archival run evidence reference + evidence_refs: [evidence.scan-output] + view_transitions: + - transition_id: discover-web-service + transition_kind: discovery + information_ref: nodes.web trigger: scan terminal observation - effective-from: episode-step:scan-0001:terminal-observation - effective-order: 30 - history-event-type: observation_emitted - action-instance-id: scan-0001 - from-disposition: hidden - to-disposition: discovered - evidence-refs: [evidence.scan-output] + effective_from: episode-step:scan-0001:terminal-observation + effective_order: 30 + history_event_type: observation_emitted + action_instance_id: scan-0001 + from_disposition: hidden + to_disposition: discovered + evidence_refs: [evidence.scan-output] certainty: high - latency-profile: terminal observation latency + latency_profile: terminal observation latency agents: red-agent: entity: red-team actions: [scan] - observation-boundaries: [red-view] - behavior-specifications: + observation_boundaries: [red-view] + behavior_specifications: red-scan-behavior: - semantic-version: 1.0.0 - lifecycle-state: active - participant-refs: [red-agent] - participant-role-refs: [red] - action-contract-refs: [scan] - observation-boundary-refs: [red-view] - authority-scope-refs: [nodes.web.services.http] - behavior-mode: policy-directed - realization-profile-ref: participant-implementation-manifest:red-agent - backend-feature-support-refs: [behavior_history] - evidence-contract-refs: [participant-behavior-history-event-stream-v1] - extension-policy: governed-extension + semantic_version: 1.0.0 + lifecycle_state: active + participant_refs: [red-agent] + participant_role_refs: [red] + action_contract_refs: [scan] + observation_boundary_refs: [red-view] + authority_scope_refs: [nodes.web.services.http] + behavior_mode: policy-directed + realization_profile_ref: participant-implementation-manifest:red-agent + backend_feature_support_refs: [behavior_history] + evidence_contract_refs: [participant-behavior-history-event-stream-v1] + extension_policy: governed-extension diff --git a/examples/library/templates/run/timed-run-control.yaml b/examples/library/templates/run/timed-run-control.yaml index 0faa5232b..2d83a5841 100644 --- a/examples/library/templates/run/timed-run-control.yaml +++ b/examples/library/templates/run/timed-run-control.yaml @@ -29,8 +29,8 @@ body: injects: [start-run] scripts: run-script: - start-time: 0 - end-time: 15 min + start_time: 0 + end_time: 15 min speed: 1.0 events: run-start: 1 min @@ -61,6 +61,6 @@ body: observe: type: objective objective: observe-run-telemetry - on-success: done + on_success: done done: type: end diff --git a/examples/library/templates/scenario/minimal-validated-scenario.yaml b/examples/library/templates/scenario/minimal-validated-scenario.yaml index 106b04674..1c283ba9f 100644 --- a/examples/library/templates/scenario/minimal-validated-scenario.yaml +++ b/examples/library/templates/scenario/minimal-validated-scenario.yaml @@ -39,6 +39,6 @@ body: verify: type: objective objective: verify-app-health - on-success: done + on_success: done done: type: end diff --git a/examples/library/templates/study/observational-study-protocol.yaml b/examples/library/templates/study/observational-study-protocol.yaml index b5775f079..bbf0ea8b4 100644 --- a/examples/library/templates/study/observational-study-protocol.yaml +++ b/examples/library/templates/study/observational-study-protocol.yaml @@ -39,6 +39,6 @@ body: complete-task: type: objective objective: complete-study-task - on-success: done + on_success: done done: type: end diff --git a/examples/library/templates/task/single-objective-task.yaml b/examples/library/templates/task/single-objective-task.yaml index 5eda0edc7..d7c9f9811 100644 --- a/examples/library/templates/task/single-objective-task.yaml +++ b/examples/library/templates/task/single-objective-task.yaml @@ -39,6 +39,6 @@ body: perform-task: type: objective objective: complete-service-check-task - on-success: done + on_success: done done: type: end diff --git a/examples/library/templates/workflow/parallel-objective-workflow.yaml b/examples/library/templates/workflow/parallel-objective-workflow.yaml index 941d11c07..fb2f5db7f 100644 --- a/examples/library/templates/workflow/parallel-objective-workflow.yaml +++ b/examples/library/templates/workflow/parallel-objective-workflow.yaml @@ -51,11 +51,11 @@ body: patch-step: type: objective objective: apply-patch - on-success: converge + on_success: converge verify-step: type: objective objective: verify-service - on-success: converge + on_success: converge converge: type: join next: done diff --git a/examples/scenarios/enterprise-participant-evidence-loop.sdl.yaml b/examples/scenarios/enterprise-participant-evidence-loop.sdl.yaml index 07524a8ab..a7e5441b6 100644 --- a/examples/scenarios/enterprise-participant-evidence-loop.sdl.yaml +++ b/examples/scenarios/enterprise-participant-evidence-loop.sdl.yaml @@ -219,92 +219,92 @@ conditions: reachability checks from the participant host were retained as negative evaluator evidence where supported. -action-contracts: +action_contracts: probe-customer-portal-login: - semantic-version: 1.0.0 - lifecycle-state: active - behavioral-granularity: atomic - procedure-basis: bounded HTTP login probe through participant runtime - realization-profile: backend-declared - fidelity-claim: > + semantic_version: 1.0.0 + lifecycle_state: active + behavioral_granularity: atomic + procedure_basis: bounded HTTP login probe through participant runtime + realization_profile: backend-declared + fidelity_claim: > Captures participant intent, terminal observation, Wazuh evaluator evidence, policy provenance, and boundary evidence refs while leaving concrete runner commands to downstream runtime bindings. preconditions: - - precondition-id: participant-authorized - precondition-class: authority + - precondition_id: participant-authorized + precondition_class: authority description: The participant is authorized to probe the customer portal login endpoint. - support-refs: [agents.participant-agent, nodes.customer-portal.services.http] - - precondition-id: portal-present - precondition-class: target + support_refs: [agents.participant-agent, nodes.customer-portal.services.http] + - precondition_id: portal-present + precondition_class: target description: The DMZ customer portal exists inside the enterprise slice. - support-refs: [nodes.customer-portal.services.http] - - precondition-id: runtime-binding-available - precondition-class: realization + support_refs: [nodes.customer-portal.services.http] + - precondition_id: runtime-binding-available + precondition_class: realization description: > A downstream participant implementation/runtime binding can perform the declared probe without changing SDL semantics. - support-refs: [participant-implementation-manifest:participant-agent] - - precondition-id: wazuh-evidence-surface-available - precondition-class: capability + support_refs: [participant-implementation-manifest:participant-agent] + - precondition_id: wazuh-evidence-surface-available + precondition_class: capability description: > Wazuh can retain evaluator evidence for the bounded portal probe. - support-refs: [nodes.wazuh-manager.services.wazuh-api, content.wazuh-evidence] - - precondition-id: policy-provenance-surface-available - precondition-class: realization + support_refs: [nodes.wazuh-manager.services.wazuh-api, content.wazuh-evidence] + - precondition_id: policy-provenance-surface-available + precondition_class: realization description: > A participant policy gate can retain tool-use authorization provenance without making the action a model-defense robustness evaluation. - support-refs: [nodes.participant-policy-gate.services.policy-gate-api, content.policy-decision-log] + support_refs: [nodes.participant-policy-gate.services.policy-gate-api, content.policy-decision-log] effects: - - effect-id: portal-reachability-observed - effect-class: intended_effect + - effect_id: portal-reachability-observed + effect_class: intended_effect description: The participant obtains a bounded reachability observation. - target-refs: [nodes.customer-portal.services.http] - - effect-id: participant-view-updated - effect-class: visibility_effect + target_refs: [nodes.customer-portal.services.http] + - effect_id: participant-view-updated + effect_class: visibility_effect description: The customer portal service becomes discovered in the participant view. - target-refs: [nodes.customer-portal.services.http] - - effect-id: terminal-observation-emitted - effect-class: observation_effect + target_refs: [nodes.customer-portal.services.http] + - effect_id: terminal-observation-emitted + effect_class: observation_effect description: Runtime emits a terminal participant observation envelope. - evidence-refs: [content.participant-observation] - - effect-id: wazuh-evidence-retained - effect-class: detection_effect + evidence_refs: [content.participant-observation] + - effect_id: wazuh-evidence-retained + effect_class: detection_effect description: Wazuh retains bounded evaluator evidence for the portal probe. - target-refs: [nodes.wazuh-manager] - evidence-refs: [content.wazuh-evidence] - - effect-id: policy-decision-retained - effect-class: evidence_effect + target_refs: [nodes.wazuh-manager] + evidence_refs: [content.wazuh-evidence] + - effect_id: policy-decision-retained + effect_class: evidence_effect description: The optional policy gate retains tool-use decision provenance. - target-refs: [nodes.participant-policy-gate] - evidence-refs: [content.policy-decision-log] - - effect-id: boundary-checks-retained - effect-class: evidence_effect + target_refs: [nodes.participant-policy-gate] + evidence_refs: [content.policy-decision-log] + - effect_id: boundary-checks-retained + effect_class: evidence_effect description: > Supported live backends retain negative evidence for direct DB and Wazuh API reachability from the participant host. - evidence-refs: [content.boundary-check-evidence] - - effect-id: internal-db-not-disclosed - effect-class: no_effect + evidence_refs: [content.boundary-check-evidence] + - effect_id: internal-db-not-disclosed + effect_class: no_effect description: The action does not disclose the internal database dependency. - - effect-id: wazuh-internals-not-disclosed - effect-class: no_effect + - effect_id: wazuh-internals-not-disclosed + effect_class: no_effect description: The action does not disclose Wazuh internals or API data to the participant. - - effect-id: policy-internals-not-disclosed - effect-class: no_effect + - effect_id: policy-internals-not-disclosed + effect_class: no_effect description: The action does not disclose policy-gate internals to the participant. - - effect-id: evaluator-notes-not-disclosed - effect-class: no_effect + - effect_id: evaluator-notes-not-disclosed + effect_class: no_effect description: The action does not disclose adjudication-only evaluator notes. - state-transition-effects: [participant customer-portal knowledge expands] - observation-expectations: [terminal customer portal observation] - evidence-expectations: + state_transition_effects: [participant customer-portal knowledge expands] + observation_expectations: [terminal customer portal observation] + evidence_expectations: - participant runtime observation envelope - Wazuh evaluator evidence - policy decision provenance - negative participant-boundary evidence - failure-classes: + failure_classes: - precondition_unsatisfied - target_unavailable - unsupported_action @@ -313,172 +313,172 @@ action-contracts: - partial_success - backend_error - unknown - backend-failure-mappings: - - backend-error-code: scenario.portal-unreachable - failure-class: target_unavailable + backend_failure_mappings: + - backend_error_code: scenario.portal-unreachable + failure_class: target_unavailable diagnostic: customer portal service was unreachable from the participant topology - - backend-error-code: participant-runtime.unsupported-action - failure-class: unsupported_action + - backend_error_code: participant-runtime.unsupported-action + failure_class: unsupported_action diagnostic: participant runtime did not support the declared action contract - - backend-error-code: policy-gate.withheld - failure-class: unsafe_withheld + - backend_error_code: policy-gate.withheld + failure_class: unsafe_withheld diagnostic: policy gate withheld the requested bounded tool use - - backend-error-code: wazuh.telemetry-missing - failure-class: partial_success + - backend_error_code: wazuh.telemetry-missing + failure_class: partial_success diagnostic: participant observation exists but Wazuh evidence was not retained - - backend-error-code: boundary.direct-db-reachable - failure-class: backend_error + - backend_error_code: boundary.direct-db-reachable + failure_class: backend_error diagnostic: participant host unexpectedly reached the internal database directly - - backend-error-code: boundary.direct-wazuh-api-reachable - failure-class: backend_error + - backend_error_code: boundary.direct-wazuh-api-reachable + failure_class: backend_error diagnostic: participant host unexpectedly reached the Wazuh API directly interactions: - - interaction-class: shared_state_change + - interaction_class: shared_state_change target: nodes.customer-portal.services.http rationale: > Portal probing changes participant-local knowledge about the DMZ service while preserving the authored enterprise topology. - shared-state-refs: [nodes.customer-portal.services.http] + shared_state_refs: [nodes.customer-portal.services.http] -observation-boundaries: +observation_boundaries: participant-view: - projection-basis: > + projection_basis: > Participant-local projection over the task brief, DMZ portal visibility, bounded terminal evidence, evaluator-only Wazuh evidence, evaluator-only policy provenance, and negative boundary checks. - observable-refs: + observable_refs: - content.task-brief - hidden-refs: + hidden_refs: - nodes.customer-portal.services.http - nodes.customer-db.services.postgres - nodes.wazuh-manager - nodes.wazuh-indexer - nodes.participant-policy-gate - content.evaluator-notes - evidence-refs: + evidence_refs: - content.participant-observation - content.wazuh-evidence - content.policy-decision-log - content.boundary-check-evidence - redaction-policy: > + redaction_policy: > Internal database details, Wazuh internals, policy-gate internals, evaluator notes, backend-private runner details, and raw negative-check internals never project into the participant view. - latency-profile: terminal observation emitted after action completion - observer-effects: [portal probe may update participant-local knowledge] - realized-view-disclosure: > + latency_profile: terminal observation emitted after action completion + observer_effects: [portal probe may update participant-local knowledge] + realized_view_disclosure: > Backend reports only the task brief, terminal portal observation, and evidence references required for replay and adjudication. - view-rules: - - information-ref: content.task-brief - boundary-class: public_task_statement + view_rules: + - information_ref: content.task-brief + boundary_class: public_task_statement disposition: observable - visibility-basis: The public task statement is visible before the action. - - information-ref: nodes.customer-portal.services.http - boundary-class: observable_resource + visibility_basis: The public task statement is visible before the action. + - information_ref: nodes.customer-portal.services.http + boundary_class: observable_resource disposition: hidden - visibility-basis: The DMZ portal service is not participant-visible until the probe completes. - latency-profile: terminal observation latency - - information-ref: nodes.customer-db.services.postgres - boundary-class: hidden_truth + visibility_basis: The DMZ portal service is not participant-visible until the probe completes. + latency_profile: terminal observation latency + - information_ref: nodes.customer-db.services.postgres + boundary_class: hidden_truth disposition: hidden - visibility-basis: The database is physically on internal-net and outside the declared participant action. - - information-ref: nodes.wazuh-manager - boundary-class: telemetry_stream + visibility_basis: The database is physically on internal-net and outside the declared participant action. + - information_ref: nodes.wazuh-manager + boundary_class: telemetry_stream disposition: hidden - visibility-basis: Wazuh manager internals do not project into the participant view. - - information-ref: nodes.wazuh-indexer - boundary-class: telemetry_stream + visibility_basis: Wazuh manager internals do not project into the participant view. + - information_ref: nodes.wazuh-indexer + boundary_class: telemetry_stream disposition: hidden - visibility-basis: Wazuh indexer internals do not project into the participant view. - - information-ref: nodes.participant-policy-gate - boundary-class: tool_output + visibility_basis: Wazuh indexer internals do not project into the participant view. + - information_ref: nodes.participant-policy-gate + boundary_class: tool_output disposition: hidden - visibility-basis: The participant does not receive policy-gate internals. - - information-ref: content.participant-observation - boundary-class: archival_evidence + visibility_basis: The participant does not receive policy-gate internals. + - information_ref: content.participant-observation + boundary_class: archival_evidence disposition: evidence_only - visibility-basis: Participant runtime evidence is retained for replay. - evidence-refs: [content.participant-observation] - - information-ref: content.wazuh-evidence - boundary-class: telemetry_stream + visibility_basis: Participant runtime evidence is retained for replay. + evidence_refs: [content.participant-observation] + - information_ref: content.wazuh-evidence + boundary_class: telemetry_stream disposition: evidence_only - visibility-basis: Wazuh evidence is retained for evaluator adjudication, not shown as task context. - evidence-refs: [content.wazuh-evidence] - - information-ref: content.policy-decision-log - boundary-class: tool_output + visibility_basis: Wazuh evidence is retained for evaluator adjudication, not shown as task context. + evidence_refs: [content.wazuh-evidence] + - information_ref: content.policy-decision-log + boundary_class: tool_output disposition: evidence_only - visibility-basis: Model-defense or tool-use decision provenance is retained for audit. - evidence-refs: [content.policy-decision-log] - - information-ref: content.boundary-check-evidence - boundary-class: archival_evidence + visibility_basis: Model-defense or tool-use decision provenance is retained for audit. + evidence_refs: [content.policy-decision-log] + - information_ref: content.boundary-check-evidence + boundary_class: archival_evidence disposition: evidence_only - visibility-basis: Negative boundary checks are evaluator evidence, not participant observations. - evidence-refs: [content.boundary-check-evidence] - - information-ref: content.evaluator-notes - boundary-class: adjudication_material + visibility_basis: Negative boundary checks are evaluator evidence, not participant observations. + evidence_refs: [content.boundary-check-evidence] + - information_ref: content.evaluator-notes + boundary_class: adjudication_material disposition: hidden - visibility-basis: Evaluator notes are never participant-visible. - view-transitions: - - transition-id: discover-customer-portal - transition-kind: discovery - information-ref: nodes.customer-portal.services.http + visibility_basis: Evaluator notes are never participant-visible. + view_transitions: + - transition_id: discover-customer-portal + transition_kind: discovery + information_ref: nodes.customer-portal.services.http trigger: probe-customer-portal-login terminal observation - effective-from: episode-step:probe-0001:terminal-observation - effective-order: 10 - history-event-type: observation_emitted - action-instance-id: probe-0001 - from-disposition: hidden - to-disposition: discovered - evidence-refs: [content.participant-observation] + effective_from: episode-step:probe-0001:terminal-observation + effective_order: 10 + history_event_type: observation_emitted + action_instance_id: probe-0001 + from_disposition: hidden + to_disposition: discovered + evidence_refs: [content.participant-observation] certainty: high - latency-profile: terminal observation latency + latency_profile: terminal observation latency -outcome-interpretation-rules: +outcome_interpretation_rules: probe-customer-portal-login-outcome: - semantic-version: 1.0.0 - participant-scope: participant_local - observation-point-basis: probe-customer-portal-login terminal observation - interpretation-basis: > + semantic_version: 1.0.0 + participant_scope: participant_local + observation_point_basis: probe-customer-portal-login terminal observation + interpretation_basis: > A participant-local terminal observation supports the scenario objective only when paired with retained participant evidence, Wazuh evaluator evidence, policy provenance, negative boundary evidence, and evaluation success. - source-bindings: - - source-id: action-outcome - source-layer: participant_action_outcome + source_bindings: + - source_id: action-outcome + source_layer: participant_action_outcome ref: probe-customer-portal-login - interpretation-role: local action result - evidence-refs: [content.participant-observation] - - source-id: wazuh-evidence - source-layer: evidence_claim + interpretation_role: local action result + evidence_refs: [content.participant-observation] + - source_id: wazuh-evidence + source_layer: evidence_claim ref: content.wazuh-evidence - interpretation-role: defensive evaluator evidence - evidence-refs: [content.wazuh-evidence] - - source-id: policy-provenance - source-layer: evidence_claim + interpretation_role: defensive evaluator evidence + evidence_refs: [content.wazuh-evidence] + - source_id: policy-provenance + source_layer: evidence_claim ref: content.policy-decision-log - interpretation-role: model-defense or tool-use authorization provenance - evidence-refs: [content.policy-decision-log] - - source-id: boundary-evidence - source-layer: evidence_claim + interpretation_role: model-defense or tool-use authorization provenance + evidence_refs: [content.policy-decision-log] + - source_id: boundary-evidence + source_layer: evidence_claim ref: content.boundary-check-evidence - interpretation-role: physical participant-boundary evidence - evidence-refs: [content.boundary-check-evidence] - - source-id: objective-result - source-layer: objective_result + interpretation_role: physical participant-boundary evidence + evidence_refs: [content.boundary-check-evidence] + - source_id: objective-result + source_layer: objective_result ref: demonstrate-handoff - interpretation-role: scenario objective result - evidence-refs: + interpretation_role: scenario objective result + evidence_refs: - content.participant-observation - content.wazuh-evidence - content.policy-decision-log - content.boundary-check-evidence - target-bindings: - - target-id: objective-supported - target-layer: objective_result + target_bindings: + - target_id: objective-supported + target_layer: objective_result ref: demonstrate-handoff relation: supports objective success when all bounded evidence records exist - evidence-refs: + evidence_refs: - content.participant-observation - content.wazuh-evidence - content.policy-decision-log @@ -488,14 +488,14 @@ outcome-interpretation-rules: - Does not evaluate Wazuh detection quality. - Does not evaluate model-defense robustness. - Does not close the downstream APTL realization issue. - - target-id: scenario-meaning-supported - target-layer: scenario_meaning + - target_id: scenario-meaning-supported + target_layer: scenario_meaning ref: reference-demonstration relation: > Shows that ACES can represent participant, target, internal dependency, OSS defender evidence, policy provenance, evaluator evidence, and observation boundaries in one focused enterprise slice. - evidence-refs: + evidence_refs: - content.participant-observation - content.wazuh-evidence - content.policy-decision-log @@ -503,7 +503,7 @@ outcome-interpretation-rules: limitations: - The defensive and model-defense surfaces are teasers for later work. - The claim remains the authored runtime handoff. - evidence-refs: + evidence_refs: - content.participant-observation - content.wazuh-evidence - content.policy-decision-log @@ -534,29 +534,29 @@ agents: - content.task-brief observation_boundaries: [participant-view] -behavior-specifications: +behavior_specifications: participant-behavior: - semantic-version: 1.0.0 - lifecycle-state: active - participant-refs: [participant-agent] - participant-role-refs: [red] - action-contract-refs: [probe-customer-portal-login] - observation-boundary-refs: [participant-view] - outcome-interpretation-rule-refs: [probe-customer-portal-login-outcome] - authority-scope-refs: + semantic_version: 1.0.0 + lifecycle_state: active + participant_refs: [participant-agent] + participant_role_refs: [red] + action_contract_refs: [probe-customer-portal-login] + observation_boundary_refs: [participant-view] + outcome_interpretation_rule_refs: [probe-customer-portal-login-outcome] + authority_scope_refs: - nodes.customer-portal.services.http - content.task-brief - behavior-mode: policy-directed - realization-profile-ref: participant-implementation-manifest:participant-agent - backend-feature-support-refs: + behavior_mode: policy-directed + realization_profile_ref: participant-implementation-manifest:participant-agent + backend_feature_support_refs: - action_contracts - observation_boundaries - behavior_history - x-scenario:wazuh-evidence - x-scenario:policy-provenance - x-scenario:boundary-negative-evidence - evidence-contract-refs: [participant-behavior-history-event-stream-v1] - extension-policy: governed-extension + evidence_contract_refs: [participant-behavior-history-event-stream-v1] + extension_policy: governed-extension objectives: # Graded scoring/interpretation of this evidence loop now lives in the @@ -594,6 +594,6 @@ workflows: probe: type: objective objective: demonstrate-handoff - on-success: finish + on_success: finish finish: type: end diff --git a/examples/scenarios/hospital-ransomware-surgery-day.sdl.yaml b/examples/scenarios/hospital-ransomware-surgery-day.sdl.yaml index a87621f2e..6f23e53f2 100644 --- a/examples/scenarios/hospital-ransomware-surgery-day.sdl.yaml +++ b/examples/scenarios/hospital-ransomware-surgery-day.sdl.yaml @@ -1,418 +1,641 @@ name: hospital-ransomware-surgery-day -description: > - Surgery-day ransomware exercise for a regional hospital. The scenario - combines phishing, vendor access abuse, identity pivoting, EHR/PACS - disruption, and backup-driven recovery under clinical time pressure. - -variables: - surgery_day_speed: - type: number - default: 1.0 - description: Story speed multiplier for the exercise day - recovery_window: - type: string - default: "8 hour" - description: Total duration of the recovery story +description: 'Surgery-day ransomware exercise for a regional hospital. The scenario + combines phishing, vendor access abuse, identity pivoting, EHR/PACS disruption, + and backup-driven recovery under clinical time pressure. + ' nodes: internet-edge: - type: Switch + type: switch description: Public ingress for email, VPN, and external access vendor-segment: - type: Switch + type: switch description: Third-party support access zone corp-it: - type: Switch + type: switch description: Corporate productivity and identity-adjacent services clinical-it: - type: Switch + type: switch description: Clinical application tier identity-net: - type: Switch + type: switch description: Active Directory and SSO infrastructure security-net: - type: Switch + type: switch description: SIEM and response tooling network backup-net: - type: Switch + type: switch description: Backup and recovery network - mail-gateway: - type: VM + type: vm + source: + name: secure-mail-gateway + version: '*' + resources: + ram: 2147483648 + cpu: 1 os: linux - source: secure-mail-gateway - resources: {ram: 2 gib, cpu: 1} - features: {mail-relay: mail-admin} - conditions: {mail-queue-healthy: mail-admin} + features: + mail-relay: mail-admin + conditions: + mail-queue-healthy: mail-admin + roles: + mail-admin: + username: postfix services: - - {port: 25, name: smtp-inbound} - - {port: 443, name: mail-admin-ui} - roles: {mail-admin: postfix} - + - port: 25 + name: smtp-inbound + - port: 443 + name: mail-admin-ui vpn-gateway: - type: VM + type: vm + source: + name: vpn-concentrator + version: '*' + resources: + ram: 2147483648 + cpu: 2 os: linux - source: vpn-concentrator - resources: {ram: 2 gib, cpu: 2} - features: {vpn-concentrator: vpn-admin} + features: + vpn-concentrator: vpn-admin conditions: vpn-auth-healthy: vpn-admin vendor-session-established: vpn-admin - services: - - {port: 443, name: vpn-portal} - - {port: 1194, protocol: udp, name: vpn-tunnel} roles: vpn-admin: username: netops - entities: [hospital-blue.identity] - + entities: + - hospital-blue.identity + services: + - port: 443 + name: vpn-portal + - port: 1194 + protocol: udp + name: vpn-tunnel ad01: - type: VM + type: vm + source: + name: windows-ad + version: '*' + resources: + ram: 4294967296 + cpu: 2 os: windows - os_version: "Server 2022" - source: windows-ad - resources: {ram: 4 gib, cpu: 2} + os_version: Server 2022 features: hospital-ad: domain-admin hospital-adfs: federation-admin conditions: domain-auth-healthy: domain-admin sso-healthy: federation-admin - services: - - {port: 53, protocol: udp, name: ad-dns} - - {port: 389, name: ad-ldap} - - {port: 443, name: adfs-https} roles: domain-admin: username: Administrator - entities: [hospital-blue.identity] + entities: + - hospital-blue.identity federation-admin: username: adfs_svc + services: + - port: 53 + protocol: udp + name: ad-dns + - port: 389 + name: ad-ldap + - port: 443 + name: adfs-https runtime: identity_authorities: - - identity_authority_id: hospital-domain - kind: domain - name: Hospital Active Directory Domain - namespace: hospital.local - domain_name: HOSPITAL - realm: HOSPITAL.LOCAL - base_dn: DC=hospital,DC=local - services: - - service_id: ldap-endpoint - service: ad-ldap - protocol: ldap - address: dc.hospital.local - port: 389 - subjects: - - subject_id: nurse-user - kind: user - name: nurse.jane - principal_name: nurse.jane@HOSPITAL.LOCAL - distinguished_name: CN=nurse.jane,OU=Clinical,DC=hospital,DC=local - domain: hospital.local - enabled: true - origin: directory - attributes: - - name: objectGUID - values: [hospital-guid-nurse-jane] - origin: directory - - subject_id: clinical-group - kind: group - name: Clinical - distinguished_name: CN=Clinical,OU=Groups,DC=hospital,DC=local - origin: directory - - subject_id: surgery-group - kind: group - name: Surgery - distinguished_name: CN=Surgery,OU=Groups,DC=hospital,DC=local - origin: directory - - subject_id: appservices-group - kind: group - name: AppServices - distinguished_name: CN=AppServices,OU=Groups,DC=hospital,DC=local - origin: directory - - subject_id: ehr-service-principal - kind: service_principal - name: ehrsvc - principal_name: ehrsvc@HOSPITAL.LOCAL - service_principal_names: [HTTP/ehr.hospital.local] - origin: directory - relationships: - - relationship_id: nurse-clinical-membership - relationship_type: member_of - source_ref: nurse-user - target_ref: clinical-group - - relationship_id: ehr-service-appservices-membership - relationship_type: member_of - source_ref: ehr-service-principal - target_ref: appservices-group - policies: - - policy_id: default-domain-password-policy - policy_kind: password - name: Default Domain Password Policy - applies_to_refs: [hospital-domain, ldap-endpoint, nurse-clinical-membership] - settings: - - name: min_length - values: ["14"] - origin: directory - - name: history_count - values: ["24"] - origin: directory - - identity_authority_id: hospital-adfs - kind: identity_provider - name: Hospital ADFS - namespace: https://adfs.hospital.local - issuer: https://adfs.hospital.local/adfs - services: - - service_id: adfs-saml - service: adfs-https - protocol: saml - address: adfs.hospital.local - port: 443 - subjects: - - subject_id: ehr-application - kind: application - name: ehr-service - principal_name: https://ehr.hospital.local/ - origin: federated - relationships: - - relationship_id: adfs-federates-ehr - relationship_type: federates_with - source_ref: hospital-adfs - target_ref: ehr-application - + - identity_authority_id: hospital-domain + kind: domain + name: Hospital Active Directory Domain + namespace: hospital.local + domain_name: HOSPITAL + realm: HOSPITAL.LOCAL + base_dn: DC=hospital,DC=local + services: + - service_id: ldap-endpoint + service: ad-ldap + protocol: ldap + address: dc.hospital.local + port: 389 + subjects: + - subject_id: nurse-user + kind: user + name: nurse.jane + principal_name: nurse.jane@HOSPITAL.LOCAL + distinguished_name: CN=nurse.jane,OU=Clinical,DC=hospital,DC=local + domain: hospital.local + enabled: true + origin: directory + attributes: + - name: objectGUID + values: + - hospital-guid-nurse-jane + origin: directory + - subject_id: clinical-group + kind: group + name: Clinical + distinguished_name: CN=Clinical,OU=Groups,DC=hospital,DC=local + origin: directory + - subject_id: surgery-group + kind: group + name: Surgery + distinguished_name: CN=Surgery,OU=Groups,DC=hospital,DC=local + origin: directory + - subject_id: appservices-group + kind: group + name: AppServices + distinguished_name: CN=AppServices,OU=Groups,DC=hospital,DC=local + origin: directory + - subject_id: ehr-service-principal + kind: service_principal + name: ehrsvc + principal_name: ehrsvc@HOSPITAL.LOCAL + origin: directory + service_principal_names: + - HTTP/ehr.hospital.local + policies: + - policy_id: default-domain-password-policy + policy_kind: password + name: Default Domain Password Policy + applies_to_refs: + - hospital-domain + - ldap-endpoint + - nurse-clinical-membership + settings: + - name: min_length + values: + - '14' + origin: directory + - name: history_count + values: + - '24' + origin: directory + relationships: + - relationship_id: nurse-clinical-membership + relationship_type: member_of + source_ref: nurse-user + target_ref: clinical-group + - relationship_id: ehr-service-appservices-membership + relationship_type: member_of + source_ref: ehr-service-principal + target_ref: appservices-group + - identity_authority_id: hospital-adfs + kind: identity_provider + name: Hospital ADFS + namespace: https://adfs.hospital.local + issuer: https://adfs.hospital.local/adfs + services: + - service_id: adfs-saml + service: adfs-https + protocol: saml + address: adfs.hospital.local + port: 443 + subjects: + - subject_id: ehr-application + kind: application + name: ehr-service + principal_name: https://ehr.hospital.local/ + origin: federated + relationships: + - relationship_id: adfs-federates-ehr + relationship_type: federates_with + source_ref: hospital-adfs + target_ref: ehr-application exchange01: - type: VM + type: vm + source: + name: exchange-2019 + version: '*' + resources: + ram: 4294967296 + cpu: 2 os: windows - os_version: "Server 2019" - source: exchange-2019 - resources: {ram: 4 gib, cpu: 2} - features: {exchange-mailbox: exchange-admin} + os_version: Server 2019 + features: + exchange-mailbox: exchange-admin conditions: exchange-owa-healthy: exchange-admin phish-mail-delivered: exchange-admin - services: - - {port: 443, name: exchange-owa} - - {port: 25, name: exchange-smtp} roles: exchange-admin: username: exch_admin - entities: [hospital-blue.identity] - + entities: + - hospital-blue.identity + services: + - port: 443 + name: exchange-owa + - port: 25 + name: exchange-smtp ehr-frontend: - type: VM + type: vm + source: + name: ehr-web-tier + version: '*' + resources: + ram: 6442450944 + cpu: 4 os: linux - source: ehr-web-tier - resources: {ram: 6 gib, cpu: 4} - features: {ehr-service: ehr-admin} + features: + ehr-service: ehr-admin conditions: ehr-api-healthy: ehr-admin clinical-disruption-observed: ehr-admin - services: - - {port: 443, name: ehr-https} - - {port: 8443, name: ehr-admin-api} roles: ehr-admin: username: ehrsvc - entities: [hospital-blue.clinical-it] + entities: + - hospital-blue.clinical-it + services: + - port: 443 + name: ehr-https + - port: 8443 + name: ehr-admin-api asset_value: confidentiality: critical integrity: critical availability: critical - ehr-db: - type: VM + type: vm + source: + name: postgres-cluster + version: '*' + resources: + ram: 8589934592 + cpu: 4 os: linux - source: postgres-cluster - resources: {ram: 8 gib, cpu: 4} - features: {sql-cluster: dba} + features: + sql-cluster: dba conditions: ehr-db-healthy: dba phi-staging-observed: dba - services: - - {port: 5432, name: ehr-postgres} roles: dba: username: postgres - entities: [hospital-blue.clinical-it] + entities: + - hospital-blue.clinical-it + services: + - port: 5432 + name: ehr-postgres asset_value: confidentiality: critical integrity: critical - pacs01: - type: VM + type: vm + source: + name: pacs-archive + version: '*' + resources: + ram: 6442450944 + cpu: 4 os: linux - source: pacs-archive - resources: {ram: 6 gib, cpu: 4} - features: {pacs-service: pacs-admin} + features: + pacs-service: pacs-admin conditions: pacs-archive-healthy: pacs-admin radiology-workflow-delayed: pacs-admin - services: - - {port: 104, name: dicom-store} - - {port: 8444, name: pacs-web-ui} roles: pacs-admin: username: pacs - entities: [hospital-blue.clinical-it] + entities: + - hospital-blue.clinical-it + services: + - port: 104 + name: dicom-store + - port: 8444 + name: pacs-web-ui asset_value: confidentiality: high integrity: high availability: critical - med-gateway: - type: VM + type: vm + source: + name: medical-device-broker + version: '*' + resources: + ram: 2147483648 + cpu: 2 os: linux - source: medical-device-broker - resources: {ram: 2 gib, cpu: 2} - features: {device-broker: device-admin} - conditions: {device-mesh-healthy: device-admin} - services: - - {port: 8883, name: med-mqtt} + features: + device-broker: device-admin + conditions: + device-mesh-healthy: device-admin roles: device-admin: username: biomed - entities: [hospital-blue.clinical-it] - + entities: + - hospital-blue.clinical-it + services: + - port: 8883 + name: med-mqtt backup-vault: - type: VM + type: vm + source: + name: immutable-backup-appliance + version: '*' + resources: + ram: 4294967296 + cpu: 2 os: linux - source: immutable-backup-appliance - resources: {ram: 4 gib, cpu: 2} - features: {immutable-backup: backup-admin} + features: + immutable-backup: backup-admin conditions: backup-catalog-intact: backup-admin restore-runbook-ready: backup-admin - services: - - {port: 443, name: backup-api} - - {port: 2049, name: backup-nfs} roles: backup-admin: username: backupops - entities: [hospital-blue.recovery] - + entities: + - hospital-blue.recovery + services: + - port: 443 + name: backup-api + - port: 2049 + name: backup-nfs siem01: - type: VM + type: vm + source: + name: siem-stack + version: '*' + resources: + ram: 8589934592 + cpu: 4 os: linux - source: siem-stack - resources: {ram: 8 gib, cpu: 4} features: siem-core: soc-admin edr-sensor: soc-admin conditions: soc-pipeline-up: soc-admin triage-case-submitted: soc-admin - services: - - {port: 5601, name: soc-ui} - - {port: 9200, name: soc-search} roles: soc-admin: username: socadmin - entities: [hospital-blue.soc] - + entities: + - hospital-blue.soc + services: + - port: 5601 + name: soc-ui + - port: 9200 + name: soc-search vendor-jump: - type: VM + type: vm + source: + name: vendor-workstation + version: '*' + resources: + ram: 2147483648 + cpu: 2 os: windows - os_version: "11" - source: vendor-workstation - resources: {ram: 2 gib, cpu: 2} - conditions: {vendor-jump-online: support} - services: - - {port: 3389, name: vendor-rdp} + os_version: '11' + conditions: + vendor-jump-online: support roles: support: username: support - entities: [vendor-support] - + entities: + - vendor-support + services: + - port: 3389 + name: vendor-rdp infrastructure: internet-edge: count: 1 - properties: {cidr: 10.70.0.0/24, gateway: 10.70.0.1} + properties: + cidr: 10.70.0.0/24 + gateway: 10.70.0.1 vendor-segment: count: 1 - properties: {cidr: 10.70.1.0/24, gateway: 10.70.1.1, internal: true} + properties: + cidr: 10.70.1.0/24 + gateway: 10.70.1.1 + internal: true acls: - - {name: allow-public-vpn, direction: in, from_net: internet-edge, protocol: tcp, ports: [443], action: allow} - - {name: deny-clinical-backflow, direction: in, from_net: clinical-it, action: deny} + - name: allow-public-vpn + direction: in + from_net: internet-edge + protocol: tcp + ports: + - 443 + action: allow + - name: deny-clinical-backflow + direction: in + from_net: clinical-it + action: deny corp-it: count: 1 - properties: {cidr: 10.70.2.0/24, gateway: 10.70.2.1, internal: true} + properties: + cidr: 10.70.2.0/24 + gateway: 10.70.2.1 + internal: true acls: - - {name: allow-public-mail-web, direction: in, from_net: internet-edge, protocol: tcp, ports: [25, 443], action: allow} - - {name: allow-vendor-support, direction: in, from_net: vendor-segment, protocol: tcp, ports: [443], action: allow} + - name: allow-public-mail-web + direction: in + from_net: internet-edge + protocol: tcp + ports: + - 25 + - 443 + action: allow + - name: allow-vendor-support + direction: in + from_net: vendor-segment + protocol: tcp + ports: + - 443 + action: allow clinical-it: count: 1 - properties: {cidr: 10.70.3.0/24, gateway: 10.70.3.1, internal: true} + properties: + cidr: 10.70.3.0/24 + gateway: 10.70.3.1 + internal: true acls: - - {name: allow-corp-clinical-apps, direction: in, from_net: corp-it, protocol: tcp, ports: [443, 8443, 8444], action: allow} - - {name: allow-vendor-clinical-web, direction: in, from_net: vendor-segment, protocol: tcp, ports: [443], action: allow} - - {name: deny-public-clinical, direction: in, from_net: internet-edge, action: deny} + - name: allow-corp-clinical-apps + direction: in + from_net: corp-it + protocol: tcp + ports: + - 443 + - 8443 + - 8444 + action: allow + - name: allow-vendor-clinical-web + direction: in + from_net: vendor-segment + protocol: tcp + ports: + - 443 + action: allow + - name: deny-public-clinical + direction: in + from_net: internet-edge + action: deny identity-net: count: 1 - properties: {cidr: 10.70.4.0/24, gateway: 10.70.4.1, internal: true} + properties: + cidr: 10.70.4.0/24 + gateway: 10.70.4.1 + internal: true acls: - - {name: allow-corp-identity, direction: in, from_net: corp-it, protocol: tcp, ports: [53, 389, 443], action: allow} - - {name: allow-clinical-identity, direction: in, from_net: clinical-it, protocol: tcp, ports: [389, 443], action: allow} + - name: allow-corp-identity + direction: in + from_net: corp-it + protocol: tcp + ports: + - 53 + - 389 + - 443 + action: allow + - name: allow-clinical-identity + direction: in + from_net: clinical-it + protocol: tcp + ports: + - 389 + - 443 + action: allow security-net: count: 1 - properties: {cidr: 10.70.5.0/24, gateway: 10.70.5.1, internal: true} + properties: + cidr: 10.70.5.0/24 + gateway: 10.70.5.1 + internal: true backup-net: count: 1 - properties: {cidr: 10.70.6.0/24, gateway: 10.70.6.1, internal: true} - + properties: + cidr: 10.70.6.0/24 + gateway: 10.70.6.1 + internal: true mail-gateway: count: 1 - links: [internet-edge, corp-it, security-net] + links: + - internet-edge + - corp-it + - security-net vpn-gateway: count: 1 - links: [internet-edge, vendor-segment, corp-it] + links: + - internet-edge + - vendor-segment + - corp-it ad01: count: 1 - links: [identity-net, corp-it, clinical-it] + links: + - identity-net + - corp-it + - clinical-it exchange01: count: 1 - links: [corp-it, identity-net] - dependencies: [ad01] + links: + - corp-it + - identity-net + dependencies: + - ad01 ehr-frontend: count: 1 - links: [clinical-it, identity-net] - dependencies: [ehr-db, ad01] + links: + - clinical-it + - identity-net + dependencies: + - ehr-db + - ad01 ehr-db: count: 1 - links: [clinical-it, backup-net] - dependencies: [backup-vault] + links: + - clinical-it + - backup-net + dependencies: + - backup-vault pacs01: count: 1 - links: [clinical-it, backup-net] - dependencies: [backup-vault] + links: + - clinical-it + - backup-net + dependencies: + - backup-vault med-gateway: count: 1 - links: [clinical-it] - dependencies: [ehr-frontend] + links: + - clinical-it + dependencies: + - ehr-frontend backup-vault: count: 1 - links: [backup-net, security-net] + links: + - backup-net + - security-net siem01: count: 1 - links: [security-net, corp-it] + links: + - security-net + - corp-it vendor-jump: count: 1 - links: [vendor-segment] - + links: + - vendor-segment features: - mail-relay: {type: Service, source: postfix-relay} - vpn-concentrator: {type: Service, source: openvpn-appliance} - hospital-ad: {type: Service, source: active-directory-domain-services} - hospital-adfs: {type: Service, source: adfs} - exchange-mailbox: {type: Service, source: exchange-mailbox-role} - ehr-service: {type: Service, source: epic-web} - sql-cluster: {type: Service, source: postgresql-ha} - pacs-service: {type: Service, source: orthanc-pacs} - device-broker: {type: Service, source: mqtt-medical-gateway} - immutable-backup: {type: Service, source: immutable-backup-vault} - siem-core: {type: Service, source: wazuh-elastic-stack} - edr-sensor: {type: Artifact, source: hospital-edr-agent, destination: /opt/edr} - + mail-relay: + type: service + source: + name: postfix-relay + version: '*' + vpn-concentrator: + type: service + source: + name: openvpn-appliance + version: '*' + hospital-ad: + type: service + source: + name: active-directory-domain-services + version: '*' + hospital-adfs: + type: service + source: + name: adfs + version: '*' + exchange-mailbox: + type: service + source: + name: exchange-mailbox-role + version: '*' + ehr-service: + type: service + source: + name: epic-web + version: '*' + sql-cluster: + type: service + source: + name: postgresql-ha + version: '*' + pacs-service: + type: service + source: + name: orthanc-pacs + version: '*' + device-broker: + type: service + source: + name: mqtt-medical-gateway + version: '*' + immutable-backup: + type: service + source: + name: immutable-backup-vault + version: '*' + siem-core: + type: service + source: + name: wazuh-elastic-stack + version: '*' + edr-sensor: + type: artifact + source: + name: hospital-edr-agent + version: '*' + destination: /opt/edr conditions: mail-queue-healthy: command: /usr/local/bin/check-mail-queue @@ -471,11 +694,11 @@ conditions: vendor-jump-online: command: /usr/local/bin/check-vendor-rdp interval: 30 - vulnerabilities: macro-lure: name: Phishing macro lure - description: Macro-enabled attachment delivers initial access to the vendor support path + description: Macro-enabled attachment delivers initial access to the vendor support + path technical: false class: CWE-451 vpn-portal-bypass: @@ -493,11 +716,10 @@ vulnerabilities: description: Restore staging area is writable from the clinical tier technical: true class: CWE-732 - entities: hospital-blue: name: Hospital Blue Team - role: Blue + role: blue mission: Keep patient care and core services available throughout the incident entities: soc: @@ -510,154 +732,185 @@ entities: name: Recovery Cell vendor-support: name: Vendor Support - role: Green + role: green mission: Maintain third-party support connection for imaging systems ransom-crew: name: Ransom Crew - role: Red + role: red mission: Steal PHI and disrupt radiology before the surgery window closes white-cell: name: White Cell - role: White - + role: white injects: vendor-advisory: - source: vendor-advisory-pdf + source: + name: vendor-advisory-pdf + version: '*' from_entity: white-cell - to_entities: [hospital-blue, vendor-support] + to_entities: + - hospital-blue + - vendor-support description: Advisory about intermittent VPN instability from the imaging vendor phishing-wave: - source: payroll-lure-pack + source: + name: payroll-lure-pack + version: '*' from_entity: ransom-crew - to_entities: [hospital-blue, vendor-support] + to_entities: + - hospital-blue + - vendor-support description: Phishing lure targeting helpdesk and vendor support personnel surgery-escalation: - source: operating-room-escalation + source: + name: operating-room-escalation + version: '*' from_entity: white-cell - to_entities: [hospital-blue] + to_entities: + - hospital-blue description: Surgeons report delayed radiology access and demand ETA - events: morning-advisory: - injects: [vendor-advisory] + injects: + - vendor-advisory phishing-delivery: - conditions: [phish-mail-delivered] - injects: [phishing-wave] + conditions: + - phish-mail-delivered + injects: + - phishing-wave surgery-pressure: - conditions: [clinical-disruption-observed] - injects: [surgery-escalation] - + conditions: + - clinical-disruption-observed + injects: + - surgery-escalation scripts: pre-surgery: - start-time: 0 - end-time: 2 hour + start_time: 0 + end_time: 7200 speed: ${surgery_day_speed} events: - morning-advisory: 10 min - phishing-delivery: 45 min + morning-advisory: 600 + phishing-delivery: 2700 live-clinic: - start-time: 2 hour - end-time: 5 hour + start_time: 7200 + end_time: 18000 speed: ${surgery_day_speed} events: - surgery-pressure: 3 hour + surgery-pressure: 10800 recovery-window: - start-time: 5 hour - end-time: ${recovery_window} + start_time: 18000 + end_time: ${recovery_window} speed: ${surgery_day_speed} events: - surgery-pressure: 6 hour - + surgery-pressure: 21600 stories: surgery-day: speed: ${surgery_day_speed} - scripts: [pre-surgery, live-clinic, recovery-window] - + scripts: + - pre-surgery + - live-clinic + - recovery-window content: phi-records: type: dataset target: ehr-db + source: + name: synthetic-phi-seed + version: '*' format: sql - source: synthetic-phi-seed sensitive: true - tags: [phi, patients] + tags: + - phi + - patients radiology-images: type: dataset target: pacs01 + source: + name: dicom-archive-seed + version: '*' format: dicom - source: dicom-archive-seed sensitive: true - tags: [radiology] + tags: + - radiology phishing-mailbox: type: dataset target: exchange01 format: eml - sensitive: true items: - - name: payroll-adjustment.eml - tags: [phishing, attachment] - description: Message crafted to lure helpdesk review - - name: vendor-followup.eml - tags: [phishing, vendor] - description: Message impersonating the imaging vendor + - name: payroll-adjustment.eml + tags: + - phishing + - attachment + description: Message crafted to lure helpdesk review + - name: vendor-followup.eml + tags: + - phishing + - vendor + description: Message impersonating the imaging vendor + sensitive: true backup-runbook: type: file target: backup-vault path: /srv/runbooks/hospital-recovery.md - text: "Recover EHR, PACS, and device broker in dependency order." + text: Recover EHR, PACS, and device broker in dependency order. sensitive: true vendor-session-profile: type: file target: vendor-jump path: C:\\Support\\session.ini - text: "tenant=hospital\\naccess=vpn\\nprofile=imaging-support" + text: tenant=hospital\naccess=vpn\nprofile=imaging-support sensitive: true - accounts: nurse-user: username: nurse.jane node: exchange01 - groups: [Clinical] + groups: + - Clinical password_strength: medium mail: nurse.jane@hospital.local surgeon-user: username: surgeon.lee node: ehr-frontend - groups: [Surgery] + groups: + - Surgery password_strength: strong helpdesk-user: username: helpdesk node: exchange01 - groups: [Helpdesk] + groups: + - Helpdesk password_strength: medium svc-ehr: username: ehrsvc node: ehr-frontend - groups: [AppServices] + groups: + - AppServices password_strength: weak - spn: "HTTP/ehr.hospital.local" + spn: HTTP/ehr.hospital.local svc-sql: username: ehrsql node: ehr-db - groups: [DBA] + groups: + - DBA password_strength: weak - spn: "POSTGRES/ehr-db.hospital.local" + spn: POSTGRES/ehr-db.hospital.local backup-operator: username: backupops node: backup-vault - groups: [BackupAdmins] + groups: + - BackupAdmins password_strength: strong vendor-tech: username: support node: vendor-jump - groups: [VendorSupport] + groups: + - VendorSupport password_strength: medium soc-analyst: username: socanalyst node: siem01 - groups: [SOC] + groups: + - SOC password_strength: strong - relationships: exchange-auths-ad: type: authenticates_with @@ -672,12 +925,15 @@ relationships: type: federates_with source: ehr-service target: hospital-adfs - properties: {protocol: "SAML"} + properties: + protocol: SAML ehr-to-database: type: connects_to source: ehr-service target: sql-cluster - properties: {protocol: "tcp", port: "5432"} + properties: + protocol: tcp + port: '5432' pacs-sync-to-backup: type: replicates_to source: pacs-service @@ -690,108 +946,228 @@ relationships: type: manages source: immutable-backup target: ehr-db - agents: red-initial: entity: ransom-crew - actions: [Phish, AbuseVPN, Discover, StealCred] + actions: + - Phish + - AbuseVPN + - Discover + - StealCred initial_knowledge: - hosts: [mail-gateway, vpn-gateway, vendor-jump] - subnets: [internet-edge, vendor-segment] - services: [smtp-inbound, vpn-portal, vendor-rdp] - accounts: [vendor-tech] - allowed_subnets: [internet-edge, vendor-segment, corp-it] + hosts: + - mail-gateway + - vpn-gateway + - vendor-jump + subnets: + - internet-edge + - vendor-segment + services: + - smtp-inbound + - vpn-portal + - vendor-rdp + accounts: + - vendor-tech + allowed_subnets: + - internet-edge + - vendor-segment + - corp-it red-operator: entity: ransom-crew - actions: [Pivot, Exfiltrate, Encrypt, Disable] - starting_accounts: [svc-ehr] + actions: + - Pivot + - Exfiltrate + - Encrypt + - Disable + starting_accounts: + - svc-ehr initial_knowledge: - hosts: [ehr-frontend, ehr-db, pacs01] - subnets: [clinical-it, backup-net] - services: [ehr-https, ehr-postgres, dicom-store] - accounts: [svc-sql, backup-operator] - allowed_subnets: [clinical-it, backup-net, identity-net] + hosts: + - ehr-frontend + - ehr-db + - pacs01 + subnets: + - clinical-it + - backup-net + services: + - ehr-https + - ehr-postgres + - dicom-store + accounts: + - svc-sql + - backup-operator + allowed_subnets: + - clinical-it + - backup-net + - identity-net blue-soc-agent: entity: hospital-blue.soc - actions: [Inspect, Triage, Isolate, Escalate] - starting_accounts: [soc-analyst] + actions: + - Inspect + - Triage + - Isolate + - Escalate + starting_accounts: + - soc-analyst initial_knowledge: - hosts: [siem01, mail-gateway, vpn-gateway] - subnets: [security-net, corp-it] - services: [soc-ui, mail-admin-ui, vpn-portal] - accounts: [helpdesk-user, vendor-tech] - allowed_subnets: [security-net, corp-it, vendor-segment] + hosts: + - siem01 + - mail-gateway + - vpn-gateway + subnets: + - security-net + - corp-it + services: + - soc-ui + - mail-admin-ui + - vpn-portal + accounts: + - helpdesk-user + - vendor-tech + allowed_subnets: + - security-net + - corp-it + - vendor-segment blue-ir-agent: entity: hospital-blue.recovery - actions: [Contain, Restore, ReissueCreds, Validate] - starting_accounts: [backup-operator] + actions: + - Contain + - Restore + - ReissueCreds + - Validate + starting_accounts: + - backup-operator initial_knowledge: - hosts: [backup-vault, ehr-db, pacs01] - subnets: [backup-net, clinical-it] - services: [backup-api, backup-nfs, pacs-web-ui] - accounts: [svc-sql, backup-operator] - allowed_subnets: [backup-net, clinical-it, identity-net] - + hosts: + - backup-vault + - ehr-db + - pacs01 + subnets: + - backup-net + - clinical-it + services: + - backup-api + - backup-nfs + - pacs-web-ui + accounts: + - svc-sql + - backup-operator + allowed_subnets: + - backup-net + - clinical-it + - identity-net objectives: - # This scenario intended graded red/blue comparison. Graded scoring now lives - # in the experiment/evaluator plane (experiment-* contracts) per ADR-073; - # SDL objectives assert only observable success via conditions. red-establish-foothold: agent: red-initial - actions: [Phish, AbuseVPN] + actions: + - Phish + - AbuseVPN targets: - - vpn-gateway - - exchange01 - - nodes.vpn-gateway.services.vpn-portal - - infrastructure.corp-it.acls.allow-vendor-support - - vpn-vendor-support-path + - vpn-gateway + - exchange01 + - nodes.vpn-gateway.services.vpn-portal + - infrastructure.corp-it.acls.allow-vendor-support + - vpn-vendor-support-path success: - conditions: [vendor-session-established] + conditions: + - vendor-session-established window: - stories: [surgery-day] - scripts: [pre-surgery] - events: [phishing-delivery] + stories: + - surgery-day + scripts: + - pre-surgery + events: + - phishing-delivery red-stage-phi: agent: red-operator - actions: [Pivot, Exfiltrate] - targets: [phi-records, ehr-to-database, ehr-db] + actions: + - Pivot + - Exfiltrate + targets: + - phi-records + - ehr-to-database + - ehr-db success: - conditions: [phi-staging-observed] - depends_on: [red-establish-foothold] + conditions: + - phi-staging-observed window: - stories: [surgery-day] - scripts: [live-clinic] + stories: + - surgery-day + scripts: + - live-clinic + depends_on: + - red-establish-foothold red-disrupt-radiology: entity: ransom-crew - targets: [pacs01, radiology-images, pacs-sync-to-backup] + targets: + - pacs01 + - radiology-images + - pacs-sync-to-backup success: - conditions: [radiology-workflow-delayed, clinical-disruption-observed] - depends_on: [red-stage-phi] + conditions: + - radiology-workflow-delayed + - clinical-disruption-observed window: - stories: [surgery-day] - scripts: [live-clinic] - events: [surgery-pressure] + stories: + - surgery-day + scripts: + - live-clinic + events: + - surgery-pressure + depends_on: + - red-stage-phi blue-detect-and-triage: agent: blue-soc-agent - actions: [Inspect, Triage, Escalate] + actions: + - Inspect + - Triage + - Escalate targets: - - siem01 - - vpn-gateway - - mail-gateway - - nodes.vpn-gateway.services.vpn-portal - - infrastructure.corp-it.acls.allow-vendor-support + - siem01 + - vpn-gateway + - mail-gateway + - nodes.vpn-gateway.services.vpn-portal + - infrastructure.corp-it.acls.allow-vendor-support success: - conditions: [soc-pipeline-up, triage-case-submitted] + conditions: + - soc-pipeline-up + - triage-case-submitted window: - stories: [surgery-day] - scripts: [pre-surgery, live-clinic] + stories: + - surgery-day + scripts: + - pre-surgery + - live-clinic blue-restore-clinical-services: agent: blue-ir-agent - actions: [Contain, Restore, Validate] - targets: [backup-vault, ehr-db, pacs01, backup-manages-ehr] + actions: + - Contain + - Restore + - Validate + targets: + - backup-vault + - ehr-db + - pacs01 + - backup-manages-ehr success: - conditions: [ehr-api-healthy, pacs-archive-healthy, backup-catalog-intact] - depends_on: [blue-detect-and-triage, red-disrupt-radiology] + conditions: + - ehr-api-healthy + - pacs-archive-healthy + - backup-catalog-intact window: - stories: [surgery-day] - scripts: [recovery-window] + stories: + - surgery-day + scripts: + - recovery-window + depends_on: + - blue-detect-and-triage + - red-disrupt-radiology +variables: + surgery_day_speed: + type: number + default: 1.0 + description: Story speed multiplier for the exercise day + recovery_window: + type: string + default: 8 hour + description: Total duration of the recovery story diff --git a/examples/scenarios/port-authority-surge-response.sdl.yaml b/examples/scenarios/port-authority-surge-response.sdl.yaml index 77841e019..c12835656 100644 --- a/examples/scenarios/port-authority-surge-response.sdl.yaml +++ b/examples/scenarios/port-authority-surge-response.sdl.yaml @@ -1,303 +1,565 @@ name: port-authority-surge-response -description: > - Port authority surge-day exercise covering customs coordination, - manifest integrity, yard OT disruption, and blackstart-style recovery - of crane operations during a cargo spike. +description: 'Port authority surge-day exercise covering customs coordination, manifest + integrity, yard OT disruption, and blackstart-style recovery of crane operations + during a cargo spike. + ' nodes: - public-edge: {type: Switch, description: Public shipping and partner ingress} - terminal-it: {type: Switch, description: Terminal operating systems and portal tier} - customs-link: {type: Switch, description: Customs and partner integration boundary} - yard-ot: {type: Switch, description: Yard control and PLC zone} - safety-net: {type: Switch, description: Safety and camera analytics zone} - vendor-net: {type: Switch, description: Vendor maintenance path} - security-net: {type: Switch, description: SOC monitoring network} - backup-net: {type: Switch, description: Historian and backup network} - + public-edge: + type: switch + description: Public shipping and partner ingress + terminal-it: + type: switch + description: Terminal operating systems and portal tier + customs-link: + type: switch + description: Customs and partner integration boundary + yard-ot: + type: switch + description: Yard control and PLC zone + safety-net: + type: switch + description: Safety and camera analytics zone + vendor-net: + type: switch + description: Vendor maintenance path + security-net: + type: switch + description: SOC monitoring network + backup-net: + type: switch + description: Historian and backup network shipping-portal: - type: VM + type: vm + source: + name: portal-app + version: '*' + resources: + ram: 4294967296 + cpu: 2 os: linux - source: portal-app - resources: {ram: 4 gib, cpu: 2} - features: {shipping-portal-app: portal-admin} - conditions: {portal-healthy: portal-admin} - services: - - {port: 443, name: shipping-portal-https} + features: + shipping-portal-app: portal-admin + conditions: + portal-healthy: portal-admin roles: portal-admin: username: portal - entities: [port-blue.it] - + entities: + - port-blue.it + services: + - port: 443 + name: shipping-portal-https manifest-db: - type: VM + type: vm + source: + name: manifest-db + version: '*' + resources: + ram: 8589934592 + cpu: 4 os: linux - source: manifest-db - resources: {ram: 8 gib, cpu: 4} - features: {manifest-store: dba} + features: + manifest-store: dba conditions: manifest-integrity-ok: dba tampered-manifest-observed: dba - services: - - {port: 5432, name: manifest-postgres} roles: dba: username: postgres - entities: [port-blue.it] + entities: + - port-blue.it + services: + - port: 5432 + name: manifest-postgres asset_value: integrity: critical availability: high - terminal-ops: - type: VM + type: vm + source: + name: terminal-operating-system + version: '*' + resources: + ram: 6442450944 + cpu: 4 os: linux - source: terminal-operating-system - resources: {ram: 6 gib, cpu: 4} - features: {terminal-service: tos-admin} - conditions: {terminal-throughput-healthy: tos-admin} - services: - - {port: 443, name: tos-https} + features: + terminal-service: tos-admin + conditions: + terminal-throughput-healthy: tos-admin roles: tos-admin: username: tosadmin - entities: [port-blue.it] - + entities: + - port-blue.it + services: + - port: 443 + name: tos-https port-idp: - type: VM + type: vm + source: + name: port-idp + version: '*' + resources: + ram: 2147483648 + cpu: 1 os: linux - source: port-idp - resources: {ram: 2 gib, cpu: 1} - features: {port-sso: idp-admin} - conditions: {port-sso-healthy: idp-admin} - services: - - {port: 443, name: port-sso-https} + features: + port-sso: idp-admin + conditions: + port-sso-healthy: idp-admin roles: idp-admin: username: idpadmin - entities: [port-blue.it] - + entities: + - port-blue.it + services: + - port: 443 + name: port-sso-https customs-gateway: - type: VM + type: vm + source: + name: customs-gateway + version: '*' + resources: + ram: 2147483648 + cpu: 2 os: linux - source: customs-gateway - resources: {ram: 2 gib, cpu: 2} - features: {customs-api: customs-admin} - conditions: {customs-link-healthy: customs-admin} - services: - - {port: 443, name: customs-api-https} + features: + customs-api: customs-admin + conditions: + customs-link-healthy: customs-admin roles: customs-admin: username: customs - entities: [customs-agency] - + entities: + - customs-agency + services: + - port: 443 + name: customs-api-https yard-hmi: - type: VM + type: vm + source: + name: yard-hmi + version: '*' + resources: + ram: 4294967296 + cpu: 2 os: windows - os_version: "10" - source: yard-hmi - resources: {ram: 4 gib, cpu: 2} - features: {yard-hmi-app: hmi-admin} + os_version: '10' + features: + yard-hmi-app: hmi-admin conditions: crane-safe-mode: hmi-admin operator-visibility-healthy: hmi-admin - services: - - {port: 3389, name: yard-hmi-rdp} - - {port: 443, name: yard-hmi-web} roles: hmi-admin: username: craneops - entities: [port-blue.yard-ops] + entities: + - port-blue.yard-ops + services: + - port: 3389 + name: yard-hmi-rdp + - port: 443 + name: yard-hmi-web asset_value: integrity: critical availability: critical - crane-plc: - type: VM + type: vm + source: + name: crane-plc + version: '*' + resources: + ram: 536870912 + cpu: 1 os: other - source: crane-plc - resources: {ram: 512 mib, cpu: 1} - features: {crane-control: plc-admin} - conditions: {crane-plc-online: plc-admin} + features: + crane-control: plc-admin + conditions: + crane-plc-online: plc-admin + roles: + plc-admin: + username: plcsvc services: - - {port: 4840, name: crane-opcua} - roles: {plc-admin: plcsvc} - + - port: 4840 + name: crane-opcua gate-kiosk: - type: VM + type: vm + source: + name: gate-kiosk + version: '*' + resources: + ram: 2147483648 + cpu: 1 os: windows - os_version: "11" - source: gate-kiosk - resources: {ram: 2 gib, cpu: 1} - conditions: {gate-processing-healthy: kiosk-admin} + os_version: '11' + conditions: + gate-processing-healthy: kiosk-admin + roles: + kiosk-admin: + username: kiosk services: - - {port: 443, name: gate-kiosk-https} - roles: {kiosk-admin: kiosk} - + - port: 443 + name: gate-kiosk-https camera-analytics: - type: VM + type: vm + source: + name: camera-analytics + version: '*' + resources: + ram: 4294967296 + cpu: 2 os: linux - source: camera-analytics - resources: {ram: 4 gib, cpu: 2} - features: {camera-ai: camera-admin} - conditions: {camera-analytics-healthy: camera-admin} - services: - - {port: 443, name: camera-analytics-ui} + features: + camera-ai: camera-admin + conditions: + camera-analytics-healthy: camera-admin roles: camera-admin: username: camera - entities: [port-blue.yard-ops] - + entities: + - port-blue.yard-ops + services: + - port: 443 + name: camera-analytics-ui historian01: - type: VM + type: vm + source: + name: ot-historian + version: '*' + resources: + ram: 4294967296 + cpu: 2 os: linux - source: ot-historian - resources: {ram: 4 gib, cpu: 2} - features: {historian-service: historian-admin} + features: + historian-service: historian-admin conditions: yard-telemetry-flowing: historian-admin historian-replication-ok: historian-admin + roles: + historian-admin: + username: historian services: - - {port: 443, name: historian-ui} - roles: {historian-admin: historian} - + - port: 443 + name: historian-ui vendor-jump: - type: VM + type: vm + source: + name: vendor-jump + version: '*' + resources: + ram: 2147483648 + cpu: 1 os: linux - source: vendor-jump - resources: {ram: 2 gib, cpu: 1} - conditions: {vendor-jump-healthy: vendor-admin} - services: - - {port: 22, name: vendor-jump-ssh} + conditions: + vendor-jump-healthy: vendor-admin roles: vendor-admin: username: vendor - entities: [vendor-maintenance] - + entities: + - vendor-maintenance + services: + - port: 22 + name: vendor-jump-ssh soc01: - type: VM + type: vm + source: + name: siem-stack + version: '*' + resources: + ram: 8589934592 + cpu: 4 os: linux - source: siem-stack - resources: {ram: 8 gib, cpu: 4} features: port-siem: soc-admin yard-edr: soc-admin conditions: soc-feed-healthy: soc-admin surge-incident-ticketed: soc-admin - services: - - {port: 5601, name: port-soc-ui} roles: soc-admin: username: soc - entities: [port-blue.incident-command] - + entities: + - port-blue.incident-command + services: + - port: 5601 + name: port-soc-ui backup-vault: - type: VM + type: vm + source: + name: immutable-backup + version: '*' + resources: + ram: 4294967296 + cpu: 2 os: linux - source: immutable-backup - resources: {ram: 4 gib, cpu: 2} - features: {yard-backup: backup-admin} - conditions: {blackstart-runbook-ready: backup-admin} - services: - - {port: 443, name: yard-backup-ui} + features: + yard-backup: backup-admin + conditions: + blackstart-runbook-ready: backup-admin roles: backup-admin: username: backup - entities: [port-blue.incident-command] - + entities: + - port-blue.incident-command + services: + - port: 443 + name: yard-backup-ui infrastructure: public-edge: count: 1 - properties: {cidr: 10.110.0.0/24, gateway: 10.110.0.1} + properties: + cidr: 10.110.0.0/24 + gateway: 10.110.0.1 terminal-it: count: 1 - properties: {cidr: 10.110.1.0/24, gateway: 10.110.1.1, internal: true} + properties: + cidr: 10.110.1.0/24 + gateway: 10.110.1.1 + internal: true customs-link: count: 1 - properties: {cidr: 10.110.2.0/24, gateway: 10.110.2.1} + properties: + cidr: 10.110.2.0/24 + gateway: 10.110.2.1 yard-ot: count: 1 - properties: {cidr: 10.110.3.0/24, gateway: 10.110.3.1, internal: true} + properties: + cidr: 10.110.3.0/24 + gateway: 10.110.3.1 + internal: true acls: - - {name: allow-yard-control, direction: in, from_net: terminal-it, protocol: tcp, ports: [443, 4840], action: allow} - - {name: deny-public-yard, direction: in, from_net: public-edge, action: deny} + - name: allow-yard-control + direction: in + from_net: terminal-it + protocol: tcp + ports: + - 443 + - 4840 + action: allow + - name: deny-public-yard + direction: in + from_net: public-edge + action: deny safety-net: count: 1 - properties: {cidr: 10.110.4.0/24, gateway: 10.110.4.1, internal: true} + properties: + cidr: 10.110.4.0/24 + gateway: 10.110.4.1 + internal: true vendor-net: count: 1 - properties: {cidr: 10.110.5.0/24, gateway: 10.110.5.1, internal: true} + properties: + cidr: 10.110.5.0/24 + gateway: 10.110.5.1 + internal: true security-net: count: 1 - properties: {cidr: 10.110.6.0/24, gateway: 10.110.6.1, internal: true} + properties: + cidr: 10.110.6.0/24 + gateway: 10.110.6.1 + internal: true backup-net: count: 1 - properties: {cidr: 10.110.7.0/24, gateway: 10.110.7.1, internal: true} - - shipping-portal: {count: 1, links: [public-edge, terminal-it, customs-link]} + properties: + cidr: 10.110.7.0/24 + gateway: 10.110.7.1 + internal: true + shipping-portal: + count: 1 + links: + - public-edge + - terminal-it + - customs-link manifest-db: count: 1 - links: [terminal-it, backup-net] - dependencies: [backup-vault] + links: + - terminal-it + - backup-net + dependencies: + - backup-vault terminal-ops: count: 1 - links: [terminal-it, customs-link, yard-ot] - dependencies: [manifest-db, port-idp] - port-idp: {count: 1, links: [terminal-it, customs-link]} - customs-gateway: {count: 1, links: [customs-link, terminal-it]} + links: + - terminal-it + - customs-link + - yard-ot + dependencies: + - manifest-db + - port-idp + port-idp: + count: 1 + links: + - terminal-it + - customs-link + customs-gateway: + count: 1 + links: + - customs-link + - terminal-it yard-hmi: count: 1 - links: [yard-ot, safety-net] - dependencies: [terminal-ops] + links: + - yard-ot + - safety-net + dependencies: + - terminal-ops crane-plc: count: 1 - links: [yard-ot] - gate-kiosk: {count: 1, links: [terminal-it]} - camera-analytics: {count: 1, links: [safety-net, security-net]} + links: + - yard-ot + gate-kiosk: + count: 1 + links: + - terminal-it + camera-analytics: + count: 1 + links: + - safety-net + - security-net historian01: count: 1 - links: [yard-ot, backup-net] - dependencies: [backup-vault] - vendor-jump: {count: 1, links: [vendor-net, yard-ot]} - soc01: {count: 1, links: [security-net, terminal-it]} - backup-vault: {count: 1, links: [backup-net, security-net]} - + links: + - yard-ot + - backup-net + dependencies: + - backup-vault + vendor-jump: + count: 1 + links: + - vendor-net + - yard-ot + soc01: + count: 1 + links: + - security-net + - terminal-it + backup-vault: + count: 1 + links: + - backup-net + - security-net features: - shipping-portal-app: {type: Service, source: react-portal} - manifest-store: {type: Service, source: postgres-15} - terminal-service: {type: Service, source: tos-core} - port-sso: {type: Service, source: keycloak} - customs-api: {type: Service, source: customs-rest-bridge} - yard-hmi-app: {type: Service, source: yard-hmi} - crane-control: {type: Service, source: crane-plc} - camera-ai: {type: Service, source: vision-analytics} - historian-service: {type: Service, source: historian} - port-siem: {type: Service, source: wazuh-elastic-stack} - yard-edr: {type: Artifact, source: yard-edr-agent, destination: /opt/edr} - yard-backup: {type: Service, source: immutable-backup} - + shipping-portal-app: + type: service + source: + name: react-portal + version: '*' + manifest-store: + type: service + source: + name: postgres-15 + version: '*' + terminal-service: + type: service + source: + name: tos-core + version: '*' + port-sso: + type: service + source: + name: keycloak + version: '*' + customs-api: + type: service + source: + name: customs-rest-bridge + version: '*' + yard-hmi-app: + type: service + source: + name: yard-hmi + version: '*' + crane-control: + type: service + source: + name: crane-plc + version: '*' + camera-ai: + type: service + source: + name: vision-analytics + version: '*' + historian-service: + type: service + source: + name: historian + version: '*' + port-siem: + type: service + source: + name: wazuh-elastic-stack + version: '*' + yard-edr: + type: artifact + source: + name: yard-edr-agent + version: '*' + destination: /opt/edr + yard-backup: + type: service + source: + name: immutable-backup + version: '*' conditions: - portal-healthy: {command: /usr/local/bin/check-portal, interval: 30} - manifest-integrity-ok: {command: /usr/local/bin/check-manifest-integrity, interval: 30} - tampered-manifest-observed: {command: /usr/local/bin/check-tampered-manifest, interval: 60} - terminal-throughput-healthy: {command: /usr/local/bin/check-terminal-throughput, interval: 30} - port-sso-healthy: {command: /usr/local/bin/check-port-sso, interval: 30} - customs-link-healthy: {command: /usr/local/bin/check-customs-link, interval: 30} - crane-safe-mode: {command: /usr/local/bin/check-crane-safe-mode, interval: 15} - operator-visibility-healthy: {command: /usr/local/bin/check-operator-visibility, interval: 30} - crane-plc-online: {command: /usr/local/bin/check-crane-plc, interval: 15} - gate-processing-healthy: {command: /usr/local/bin/check-gate-processing, interval: 30} - camera-analytics-healthy: {command: /usr/local/bin/check-camera-analytics, interval: 30} - yard-telemetry-flowing: {command: /usr/local/bin/check-yard-telemetry, interval: 15} - historian-replication-ok: {command: /usr/local/bin/check-historian-replication, interval: 30} - vendor-jump-healthy: {command: /usr/local/bin/check-vendor-jump, interval: 30} - soc-feed-healthy: {command: /usr/local/bin/check-soc-feed, interval: 30} - surge-incident-ticketed: {command: /usr/local/bin/check-surge-ticket, interval: 60} - blackstart-runbook-ready: {command: /usr/local/bin/check-blackstart-runbook, interval: 60} - + portal-healthy: + command: /usr/local/bin/check-portal + interval: 30 + manifest-integrity-ok: + command: /usr/local/bin/check-manifest-integrity + interval: 30 + tampered-manifest-observed: + command: /usr/local/bin/check-tampered-manifest + interval: 60 + terminal-throughput-healthy: + command: /usr/local/bin/check-terminal-throughput + interval: 30 + port-sso-healthy: + command: /usr/local/bin/check-port-sso + interval: 30 + customs-link-healthy: + command: /usr/local/bin/check-customs-link + interval: 30 + crane-safe-mode: + command: /usr/local/bin/check-crane-safe-mode + interval: 15 + operator-visibility-healthy: + command: /usr/local/bin/check-operator-visibility + interval: 30 + crane-plc-online: + command: /usr/local/bin/check-crane-plc + interval: 15 + gate-processing-healthy: + command: /usr/local/bin/check-gate-processing + interval: 30 + camera-analytics-healthy: + command: /usr/local/bin/check-camera-analytics + interval: 30 + yard-telemetry-flowing: + command: /usr/local/bin/check-yard-telemetry + interval: 15 + historian-replication-ok: + command: /usr/local/bin/check-historian-replication + interval: 30 + vendor-jump-healthy: + command: /usr/local/bin/check-vendor-jump + interval: 30 + soc-feed-healthy: + command: /usr/local/bin/check-soc-feed + interval: 30 + surge-incident-ticketed: + command: /usr/local/bin/check-surge-ticket + interval: 60 + blackstart-runbook-ready: + command: /usr/local/bin/check-blackstart-runbook + interval: 60 vulnerabilities: stale-customs-token: name: Stale customs federation token - description: Customs bridge accepts tokens beyond intended lifetime during surge conditions + description: Customs bridge accepts tokens beyond intended lifetime during surge + conditions technical: true class: CWE-613 vendor-vpn-cred-reuse: @@ -310,11 +572,10 @@ vulnerabilities: description: Yard HMI can directly manage crane PLC without an approval gate technical: true class: CWE-306 - entities: port-blue: name: Port Authority Blue Team - role: Blue + role: blue mission: Maintain customs integrity and safe yard operations during the surge entities: it: @@ -325,150 +586,178 @@ entities: name: Incident Command customs-agency: name: Customs Agency - role: Green + role: green mission: Clear inbound cargo without compromising compliance vendor-maintenance: name: Vendor Maintenance - role: Green + role: green mission: Maintain remote support path for crane systems red-cartel: name: Red Cartel - role: Red + role: red mission: Corrupt manifests and degrade crane operations white-cell: name: White Cell - role: White - + role: white injects: surge-brief: - source: surge-day-brief + source: + name: surge-day-brief + version: '*' from_entity: white-cell - to_entities: [port-blue, customs-agency] + to_entities: + - port-blue + - customs-agency description: Cargo surge and priority humanitarian shipments announced customs-priority-ticket: - source: customs-priority-ticket + source: + name: customs-priority-ticket + version: '*' from_entity: customs-agency - to_entities: [port-blue] + to_entities: + - port-blue description: High-priority manifest validation request safety-standdown: - source: safety-standdown-order + source: + name: safety-standdown-order + version: '*' from_entity: white-cell - to_entities: [port-blue, vendor-maintenance] + to_entities: + - port-blue + - vendor-maintenance description: Crane anomalies trigger a safety standdown - events: vessel-arrival: - injects: [surge-brief] + injects: + - surge-brief customs-surge-event: - injects: [customs-priority-ticket] + injects: + - customs-priority-ticket yard-standdown: - conditions: [tampered-manifest-observed] - injects: [safety-standdown] - + conditions: + - tampered-manifest-observed + injects: + - safety-standdown scripts: arrival-phase: - start-time: 0 - end-time: 2 hour - speed: 1 + start_time: 0 + end_time: 7200 + speed: 1.0 events: - vessel-arrival: 15 min + vessel-arrival: 900 customs-surge-phase: - start-time: 2 hour - end-time: 4 hour - speed: 1 + start_time: 7200 + end_time: 14400 + speed: 1.0 events: - customs-surge-event: 150 min + customs-surge-event: 9000 yard-disruption-phase: - start-time: 4 hour - end-time: 7 hour - speed: 1 + start_time: 14400 + end_time: 25200 + speed: 1.0 events: - yard-standdown: 5 hour - + yard-standdown: 18000 stories: surge-day: - scripts: [arrival-phase, customs-surge-phase, yard-disruption-phase] - + scripts: + - arrival-phase + - customs-surge-phase + - yard-disruption-phase content: cargo-manifests: type: dataset target: manifest-db + source: + name: cargo-manifest-seed + version: '*' format: csv - source: cargo-manifest-seed sensitive: true hazmat-list: type: file target: terminal-ops path: /srv/tos/hazmat-list.csv - text: "container_id,classification\\nHZ-204,flammable\\nHZ-991,oxidizer" + text: container_id,classification\nHZ-204,flammable\nHZ-991,oxidizer sensitive: true customs-holds: type: dataset target: customs-gateway format: json items: - - name: hold-HZ-204.json - tags: [customs, hold] - description: Hold order for hazardous container HZ-204 - - name: hold-HZ-991.json - tags: [customs, hold] - description: Hold order for hazardous container HZ-991 + - name: hold-HZ-204.json + tags: + - customs + - hold + description: Hold order for hazardous container HZ-204 + - name: hold-HZ-991.json + tags: + - customs + - hold + description: Hold order for hazardous container HZ-991 yard-camera-clips: type: dataset target: camera-analytics + source: + name: yard-camera-clip-seed + version: '*' format: mp4 - source: yard-camera-clip-seed sensitive: true blackstart-runbook: type: file target: backup-vault path: /srv/runbooks/yard-blackstart.md - text: "Re-establish historian, validate PLC, then return HMI to service." + text: Re-establish historian, validate PLC, then return HMI to service. sensitive: true - accounts: harbor-master: username: harbor.master node: terminal-ops - groups: [Operations] + groups: + - Operations password_strength: strong customs-officer: username: customs.officer node: customs-gateway - groups: [Customs] + groups: + - Customs password_strength: strong crane-operator: username: crane.ops node: yard-hmi - groups: [YardOps] + groups: + - YardOps password_strength: medium vendor-tech: username: vendor node: vendor-jump - groups: [Vendors] + groups: + - Vendors password_strength: medium tos-service: username: tos_svc node: terminal-ops - groups: [AppServices] + groups: + - AppServices password_strength: weak historian-svc: username: hist_svc node: historian01 - groups: [Historian] + groups: + - Historian password_strength: medium soc-analyst: username: socanalyst node: soc01 - groups: [SOC] + groups: + - SOC password_strength: strong - relationships: portal-to-manifest: type: connects_to source: shipping-portal-app target: manifest-store - properties: {protocol: "tcp", port: "5432"} + properties: + protocol: tcp + port: '5432' terminal-auth-portidp: type: authenticates_with source: terminal-service @@ -477,7 +766,8 @@ relationships: type: federates_with source: customs-api target: port-sso - properties: {protocol: "OIDC"} + properties: + protocol: OIDC hmi-manages-crane: type: manages source: yard-hmi-app @@ -494,139 +784,245 @@ relationships: type: depends_on source: terminal-service target: customs-api - agents: red-yard-agent: entity: red-cartel - actions: [Phish, Tamper, Disable, Pivot] + actions: + - Phish + - Tamper + - Disable + - Pivot initial_knowledge: - hosts: [shipping-portal, vendor-jump, yard-hmi] - subnets: [public-edge, vendor-net, yard-ot] - services: [shipping-portal-https, vendor-jump-ssh, yard-hmi-web] - accounts: [vendor-tech] - allowed_subnets: [public-edge, vendor-net, yard-ot, terminal-it] + hosts: + - shipping-portal + - vendor-jump + - yard-hmi + subnets: + - public-edge + - vendor-net + - yard-ot + services: + - shipping-portal-https + - vendor-jump-ssh + - yard-hmi-web + accounts: + - vendor-tech + allowed_subnets: + - public-edge + - vendor-net + - yard-ot + - terminal-it blue-yard-agent: entity: port-blue.yard-ops - actions: [Inspect, Isolate, Restore, Validate] - starting_accounts: [crane-operator] + actions: + - Inspect + - Isolate + - Restore + - Validate + starting_accounts: + - crane-operator initial_knowledge: - hosts: [yard-hmi, crane-plc, historian01] - subnets: [yard-ot, backup-net, safety-net] - services: [yard-hmi-rdp, crane-opcua, historian-ui] - accounts: [historian-svc] - allowed_subnets: [yard-ot, safety-net, backup-net] + hosts: + - yard-hmi + - crane-plc + - historian01 + subnets: + - yard-ot + - backup-net + - safety-net + services: + - yard-hmi-rdp + - crane-opcua + - historian-ui + accounts: + - historian-svc + allowed_subnets: + - yard-ot + - safety-net + - backup-net blue-soc-agent: entity: port-blue.incident-command - actions: [Monitor, Triage, Coordinate, Recover] - starting_accounts: [soc-analyst] + actions: + - Monitor + - Triage + - Coordinate + - Recover + starting_accounts: + - soc-analyst initial_knowledge: - hosts: [soc01, shipping-portal, terminal-ops] - subnets: [security-net, terminal-it] - services: [port-soc-ui, shipping-portal-https, tos-https] - accounts: [harbor-master, customs-officer] - allowed_subnets: [security-net, terminal-it, customs-link, backup-net] - + hosts: + - soc01 + - shipping-portal + - terminal-ops + subnets: + - security-net + - terminal-it + services: + - port-soc-ui + - shipping-portal-https + - tos-https + accounts: + - harbor-master + - customs-officer + allowed_subnets: + - security-net + - terminal-it + - customs-link + - backup-net objectives: - # This scenario intended graded red/blue comparison. Graded scoring now lives - # in the experiment/evaluator plane (experiment-* contracts) per ADR-073; - # SDL objectives assert only observable success via conditions. red-corrupt-manifests: agent: red-yard-agent - actions: [Phish, Tamper] - targets: [cargo-manifests, manifest-db, portal-to-manifest] + actions: + - Phish + - Tamper + targets: + - cargo-manifests + - manifest-db + - portal-to-manifest success: - conditions: [tampered-manifest-observed] + conditions: + - tampered-manifest-observed window: - stories: [surge-day] - scripts: [arrival-phase, customs-surge-phase] + stories: + - surge-day + scripts: + - arrival-phase + - customs-surge-phase red-degrade-yard-ops: entity: red-cartel - targets: [yard-hmi, crane-plc, hmi-manages-crane] + targets: + - yard-hmi + - crane-plc + - hmi-manages-crane success: - conditions: [tampered-manifest-observed] - depends_on: [red-corrupt-manifests] + conditions: + - tampered-manifest-observed window: - stories: [surge-day] - scripts: [yard-disruption-phase] - events: [yard-standdown] + stories: + - surge-day + scripts: + - yard-disruption-phase + events: + - yard-standdown + depends_on: + - red-corrupt-manifests blue-maintain-customs-integrity: agent: blue-soc-agent - actions: [Monitor, Triage, Coordinate] - targets: [customs-gateway, manifest-db, customs-federates-port] + actions: + - Monitor + - Triage + - Coordinate + targets: + - customs-gateway + - manifest-db + - customs-federates-port success: - conditions: [customs-link-healthy, manifest-integrity-ok] + conditions: + - customs-link-healthy + - manifest-integrity-ok window: - stories: [surge-day] - scripts: [arrival-phase, customs-surge-phase] - workflows: [yard-recovery] - steps: [yard-recovery.maintain-integrity] + stories: + - surge-day + scripts: + - arrival-phase + - customs-surge-phase + workflows: + - yard-recovery + steps: + - yard-recovery.maintain-integrity blue-blackstart-yard: agent: blue-yard-agent - actions: [Inspect, Isolate, Restore, Validate] + actions: + - Inspect + - Isolate + - Restore + - Validate targets: - - backup-vault - - historian01 - - yard-hmi - - historian-replicates-backup - - nodes.crane-plc.services.crane-opcua - - infrastructure.yard-ot.acls.allow-yard-control + - backup-vault + - historian01 + - yard-hmi + - historian-replicates-backup + - nodes.crane-plc.services.crane-opcua + - infrastructure.yard-ot.acls.allow-yard-control success: - conditions: [crane-safe-mode, yard-telemetry-flowing] - depends_on: [blue-maintain-customs-integrity, red-degrade-yard-ops] + conditions: + - crane-safe-mode + - yard-telemetry-flowing window: - stories: [surge-day] - scripts: [yard-disruption-phase] - events: [yard-standdown] - workflows: [yard-recovery] - steps: [yard-recovery.restore-yard] + stories: + - surge-day + scripts: + - yard-disruption-phase + events: + - yard-standdown + workflows: + - yard-recovery + steps: + - yard-recovery.restore-yard + depends_on: + - blue-maintain-customs-integrity + - red-degrade-yard-ops blue-validate-yard-telemetry: agent: blue-yard-agent - actions: [Inspect, Validate] + actions: + - Inspect + - Validate targets: - - historian01 - - camera-analytics - - nodes.historian01.services.historian-ui - - infrastructure.yard-ot.acls.allow-yard-control + - historian01 + - camera-analytics + - nodes.historian01.services.historian-ui + - infrastructure.yard-ot.acls.allow-yard-control success: - conditions: [yard-telemetry-flowing, historian-replication-ok] - depends_on: [red-degrade-yard-ops] + conditions: + - yard-telemetry-flowing + - historian-replication-ok window: - stories: [surge-day] - scripts: [yard-disruption-phase] - events: [yard-standdown] - workflows: [yard-recovery] - steps: [yard-recovery.validate-telemetry] - + stories: + - surge-day + scripts: + - yard-disruption-phase + events: + - yard-standdown + workflows: + - yard-recovery + steps: + - yard-recovery.validate-telemetry + depends_on: + - red-degrade-yard-ops workflows: yard-recovery: - description: > - Recovery workflow that preserves customs integrity, branches into - blackstart-style OT recovery when tampering is observed, and joins - yard restoration with telemetry validation before declaring finish. + description: 'Recovery workflow that preserves customs integrity, branches into + blackstart-style OT recovery when tampering is observed, and joins yard restoration + with telemetry validation before declaring finish. + + ' start: maintain-integrity steps: maintain-integrity: type: objective objective: blue-maintain-customs-integrity - on-success: assess-yard + on_success: assess-yard assess-yard: type: decision when: - conditions: [tampered-manifest-observed] + conditions: + - tampered-manifest-observed then: fanout-blackstart else: finish fanout-blackstart: type: parallel - branches: [restore-yard, validate-telemetry] + branches: + - restore-yard + - validate-telemetry join: blackstart-joined restore-yard: type: objective objective: blue-blackstart-yard - on-success: blackstart-joined + on_success: blackstart-joined validate-telemetry: type: objective objective: blue-validate-yard-telemetry - on-success: blackstart-joined + on_success: blackstart-joined blackstart-joined: type: join next: finish diff --git a/examples/scenarios/satcom-release-poisoning.sdl.yaml b/examples/scenarios/satcom-release-poisoning.sdl.yaml index 56e2ccfae..18b16b9da 100644 --- a/examples/scenarios/satcom-release-poisoning.sdl.yaml +++ b/examples/scenarios/satcom-release-poisoning.sdl.yaml @@ -1,342 +1,616 @@ name: satcom-release-poisoning -description: > - Hybrid supply-chain exercise for a satellite communications platform. - The scenario models build compromise, signed artifact poisoning, - release promotion, tenant isolation risk, and rollback under live - telemetry pressure. - -variables: - release_story_speed: - type: number - default: 1.25 - description: Playback speed for the release-day story - release_engineer_password_strength: - type: string - default: strong - description: Password posture for release engineering operator accounts - canary_cutover: - type: string - default: "2 hour 45 min" - description: When canary promotion begins - rollback_deadline: - type: string - default: "6 hour" - description: Time by which rollback should be complete +description: 'Hybrid supply-chain exercise for a satellite communications platform. + The scenario models build compromise, signed artifact poisoning, release promotion, + tenant isolation risk, and rollback under live telemetry pressure. + ' nodes: - internet-edge: {type: Switch, description: Public and partner ingress} - corp-eng: {type: Switch, description: Engineering workstations and forge access} - build-net: {type: Switch, description: CI runner and registry network} - control-net: {type: Switch, description: Satellite control plane} - edge-net: {type: Switch, description: Regional edge gateways} - telemetry-net: {type: Switch, description: Telemetry collection and streaming} - federation-net: {type: Switch, description: Customer and vendor federation boundary} - security-net: {type: Switch, description: Monitoring and response systems} - + internet-edge: + type: switch + description: Public and partner ingress + corp-eng: + type: switch + description: Engineering workstations and forge access + build-net: + type: switch + description: CI runner and registry network + control-net: + type: switch + description: Satellite control plane + edge-net: + type: switch + description: Regional edge gateways + telemetry-net: + type: switch + description: Telemetry collection and streaming + federation-net: + type: switch + description: Customer and vendor federation boundary + security-net: + type: switch + description: Monitoring and response systems git-forge: - type: VM + type: vm + source: + name: git-forge + version: '*' + resources: + ram: 4294967296 + cpu: 2 os: linux - source: git-forge - resources: {ram: 4 gib, cpu: 2} - features: {git-service: forge-admin} - conditions: {repo-integrity-healthy: forge-admin} - services: - - {port: 443, name: forge-https} - - {port: 22, name: forge-ssh} + features: + git-service: forge-admin + conditions: + repo-integrity-healthy: forge-admin roles: forge-admin: username: forgeadmin - entities: [platform-blue.release-engineering] - + entities: + - platform-blue.release-engineering + services: + - port: 443 + name: forge-https + - port: 22 + name: forge-ssh ci-runner: - type: VM + type: vm + source: + name: ci-runner + version: '*' + resources: + ram: 4294967296 + cpu: 4 os: linux - source: ci-runner - resources: {ram: 4 gib, cpu: 4} - features: {ci-orchestrator: ci-admin} + features: + ci-orchestrator: ci-admin conditions: pipeline-green: ci-admin rogue-release-promoted: ci-admin - services: - - {port: 443, name: ci-api} - - {port: 2222, name: ci-ssh} roles: ci-admin: username: gitlab-runner - + services: + - port: 443 + name: ci-api + - port: 2222 + name: ci-ssh artifact-registry: - type: VM + type: vm + source: + name: artifact-registry + version: '*' + resources: + ram: 4294967296 + cpu: 2 os: linux - source: artifact-registry - resources: {ram: 4 gib, cpu: 2} - features: {release-registry: registry-admin} + features: + release-registry: registry-admin conditions: release-signature-valid: registry-admin rollback-package-ready: registry-admin - services: - - {port: 5000, name: registry-api} - - {port: 443, name: registry-ui} roles: registry-admin: username: registry - entities: [platform-blue.release-engineering] - + entities: + - platform-blue.release-engineering + services: + - port: 5000 + name: registry-api + - port: 443 + name: registry-ui signing-hsm: - type: VM + type: vm + source: + name: signing-service + version: '*' + resources: + ram: 2147483648 + cpu: 2 os: linux - source: signing-service - resources: {ram: 2 gib, cpu: 2} - features: {signing-service: signing-admin} - conditions: {signing-health: signing-admin} + features: + signing-service: signing-admin + conditions: + signing-health: signing-admin + roles: + signing-admin: + username: signer services: - - {port: 8443, name: signing-api} - roles: {signing-admin: signer} - + - port: 8443 + name: signing-api control-api: - type: VM + type: vm + source: + name: satcom-control-plane + version: '*' + resources: + ram: 6442450944 + cpu: 4 os: linux - source: satcom-control-plane - resources: {ram: 6 gib, cpu: 4} - features: {satcom-control: control-admin} + features: + satcom-control: control-admin conditions: control-plane-healthy: control-admin rollback-complete: control-admin - services: - - {port: 443, name: control-api} - - {port: 8444, name: control-admin-ui} roles: control-admin: username: satops - entities: [platform-blue.sre] + entities: + - platform-blue.sre + services: + - port: 443 + name: control-api + - port: 8444 + name: control-admin-ui asset_value: integrity: critical availability: critical - edge-gateway-east: - type: VM + type: vm + source: + name: satcom-edge + version: '*' + resources: + ram: 2147483648 + cpu: 2 os: linux - source: satcom-edge - resources: {ram: 2 gib, cpu: 2} - features: {edge-agent-east: edge-admin} + features: + edge-agent-east: edge-admin conditions: edge-east-healthy: edge-admin edge-east-poisoned: edge-admin + roles: + edge-admin: + username: edgeeast services: - - {port: 443, name: edge-east-api} - roles: {edge-admin: edgeeast} - + - port: 443 + name: edge-east-api edge-gateway-west: - type: VM + type: vm + source: + name: satcom-edge + version: '*' + resources: + ram: 2147483648 + cpu: 2 os: linux - source: satcom-edge - resources: {ram: 2 gib, cpu: 2} - features: {edge-agent-west: edge-admin} + features: + edge-agent-west: edge-admin conditions: edge-west-healthy: edge-admin edge-west-poisoned: edge-admin + roles: + edge-admin: + username: edgewest services: - - {port: 443, name: edge-west-api} - roles: {edge-admin: edgewest} - + - port: 443 + name: edge-west-api telemetry-broker: - type: VM + type: vm + source: + name: kafka-broker + version: '*' + resources: + ram: 4294967296 + cpu: 2 os: linux - source: kafka-broker - resources: {ram: 4 gib, cpu: 2} - features: {telemetry-bus: telemetry-admin} - conditions: {telemetry-flowing: telemetry-admin} + features: + telemetry-bus: telemetry-admin + conditions: + telemetry-flowing: telemetry-admin + roles: + telemetry-admin: + username: telemetry services: - - {port: 9092, name: telemetry-kafka} - - {port: 443, name: telemetry-ui} - roles: {telemetry-admin: telemetry} - + - port: 9092 + name: telemetry-kafka + - port: 443 + name: telemetry-ui analytics-lake: - type: VM + type: vm + source: + name: analytics-lake + version: '*' + resources: + ram: 4294967296 + cpu: 2 os: linux - source: analytics-lake - resources: {ram: 4 gib, cpu: 2} - features: {analytics-store: lake-admin} - conditions: {analytics-ingest-healthy: lake-admin} + features: + analytics-store: lake-admin + conditions: + analytics-ingest-healthy: lake-admin + roles: + lake-admin: + username: lake services: - - {port: 443, name: analytics-ui} - roles: {lake-admin: lake} - + - port: 443 + name: analytics-ui customer-portal: - type: VM + type: vm + source: + name: customer-portal + version: '*' + resources: + ram: 4294967296 + cpu: 2 os: linux - source: customer-portal - resources: {ram: 4 gib, cpu: 2} - features: {customer-portal-app: portal-admin} + features: + customer-portal-app: portal-admin conditions: tenant-isolation-healthy: portal-admin portal-sso-healthy: portal-admin - services: - - {port: 443, name: portal-https} roles: portal-admin: username: portal - entities: [platform-blue.release-engineering] - + entities: + - platform-blue.release-engineering + services: + - port: 443 + name: portal-https support-bastion: - type: VM + type: vm + source: + name: support-bastion + version: '*' + resources: + ram: 2147483648 + cpu: 2 os: linux - source: support-bastion - resources: {ram: 2 gib, cpu: 2} - conditions: {support-bastion-online: support-admin} - services: - - {port: 22, name: bastion-ssh} - - {port: 443, name: bastion-web} + conditions: + support-bastion-online: support-admin roles: support-admin: username: support - entities: [vendor-ops] - + entities: + - vendor-ops + services: + - port: 22 + name: bastion-ssh + - port: 443 + name: bastion-web siem01: - type: VM + type: vm + source: + name: siem-stack + version: '*' + resources: + ram: 8589934592 + cpu: 4 os: linux - source: siem-stack - resources: {ram: 8 gib, cpu: 4} features: siem-core: soc-admin release-edr: soc-admin conditions: security-pipeline-healthy: soc-admin release-incident-ticketed: soc-admin - services: - - {port: 5601, name: release-soc-ui} roles: soc-admin: username: socadmin - entities: [platform-blue.soc] - + entities: + - platform-blue.soc + services: + - port: 5601 + name: release-soc-ui vendor-idp: - type: VM + type: vm + source: + name: vendor-idp + version: '*' + resources: + ram: 2147483648 + cpu: 1 os: linux - source: vendor-idp - resources: {ram: 2 gib, cpu: 1} - features: {vendor-sso: idp-admin} - conditions: {vendor-federation-healthy: idp-admin} + features: + vendor-sso: idp-admin + conditions: + vendor-federation-healthy: idp-admin + roles: + idp-admin: + username: vendorauth services: - - {port: 443, name: vendor-idp-https} - roles: {idp-admin: vendorauth} - + - port: 443 + name: vendor-idp-https customer-idp: - type: VM + type: vm + source: + name: customer-idp + version: '*' + resources: + ram: 2147483648 + cpu: 1 os: linux - source: customer-idp - resources: {ram: 2 gib, cpu: 1} - features: {customer-sso: idp-admin} - conditions: {customer-federation-healthy: idp-admin} + features: + customer-sso: idp-admin + conditions: + customer-federation-healthy: idp-admin + roles: + idp-admin: + username: customerauth services: - - {port: 443, name: customer-idp-https} - roles: {idp-admin: customerauth} - + - port: 443 + name: customer-idp-https infrastructure: internet-edge: count: 1 - properties: {cidr: 10.90.0.0/24, gateway: 10.90.0.1} + properties: + cidr: 10.90.0.0/24 + gateway: 10.90.0.1 corp-eng: count: 1 - properties: {cidr: 10.90.1.0/24, gateway: 10.90.1.1, internal: true} + properties: + cidr: 10.90.1.0/24 + gateway: 10.90.1.1 + internal: true build-net: count: 1 - properties: {cidr: 10.90.2.0/24, gateway: 10.90.2.1, internal: true} + properties: + cidr: 10.90.2.0/24 + gateway: 10.90.2.1 + internal: true acls: - - {name: allow-engineering-release, direction: in, from_net: corp-eng, protocol: tcp, ports: [22, 443, 5000], action: allow} - - {name: deny-public-build, direction: in, from_net: internet-edge, action: deny} + - name: allow-engineering-release + direction: in + from_net: corp-eng + protocol: tcp + ports: + - 22 + - 443 + - 5000 + action: allow + - name: deny-public-build + direction: in + from_net: internet-edge + action: deny control-net: count: 1 - properties: {cidr: 10.90.3.0/24, gateway: 10.90.3.1, internal: true} + properties: + cidr: 10.90.3.0/24 + gateway: 10.90.3.1 + internal: true edge-net: count: 1 - properties: {cidr: 10.90.4.0/24, gateway: 10.90.4.1, internal: true} + properties: + cidr: 10.90.4.0/24 + gateway: 10.90.4.1 + internal: true telemetry-net: count: 1 - properties: {cidr: 10.90.5.0/24, gateway: 10.90.5.1, internal: true} + properties: + cidr: 10.90.5.0/24 + gateway: 10.90.5.1 + internal: true federation-net: count: 1 - properties: {cidr: 10.90.6.0/24, gateway: 10.90.6.1} + properties: + cidr: 10.90.6.0/24 + gateway: 10.90.6.1 security-net: count: 1 - properties: {cidr: 10.90.7.0/24, gateway: 10.90.7.1, internal: true} - - git-forge: {count: 1, links: [corp-eng, build-net]} + properties: + cidr: 10.90.7.0/24 + gateway: 10.90.7.1 + internal: true + git-forge: + count: 1 + links: + - corp-eng + - build-net ci-runner: count: 1 - links: [build-net, security-net] - dependencies: [git-forge, artifact-registry] + links: + - build-net + - security-net + dependencies: + - git-forge + - artifact-registry artifact-registry: count: 1 - links: [build-net, control-net] - dependencies: [signing-hsm] - signing-hsm: {count: 1, links: [build-net]} + links: + - build-net + - control-net + dependencies: + - signing-hsm + signing-hsm: + count: 1 + links: + - build-net control-api: count: 1 - links: [control-net, edge-net, telemetry-net] - dependencies: [artifact-registry, telemetry-broker] + links: + - control-net + - edge-net + - telemetry-net + dependencies: + - artifact-registry + - telemetry-broker edge-gateway-east: count: 1 - links: [edge-net, telemetry-net] - dependencies: [control-api] + links: + - edge-net + - telemetry-net + dependencies: + - control-api edge-gateway-west: count: 1 - links: [edge-net, telemetry-net] - dependencies: [control-api] + links: + - edge-net + - telemetry-net + dependencies: + - control-api telemetry-broker: count: 1 - links: [telemetry-net, security-net] - dependencies: [analytics-lake] - analytics-lake: {count: 1, links: [telemetry-net]} + links: + - telemetry-net + - security-net + dependencies: + - analytics-lake + analytics-lake: + count: 1 + links: + - telemetry-net customer-portal: count: 1 - links: [internet-edge, federation-net, control-net] - dependencies: [vendor-idp, customer-idp] + links: + - internet-edge + - federation-net + - control-net + dependencies: + - vendor-idp + - customer-idp support-bastion: count: 1 - links: [internet-edge, federation-net, control-net] - siem01: {count: 1, links: [security-net, corp-eng]} - vendor-idp: {count: 1, links: [federation-net]} - customer-idp: {count: 1, links: [federation-net]} - + links: + - internet-edge + - federation-net + - control-net + siem01: + count: 1 + links: + - security-net + - corp-eng + vendor-idp: + count: 1 + links: + - federation-net + customer-idp: + count: 1 + links: + - federation-net features: - git-service: {type: Service, source: gitea-enterprise} - ci-orchestrator: {type: Service, source: gitlab-runner} - release-registry: {type: Service, source: harbor-registry} - signing-service: {type: Service, source: notation-signer} - satcom-control: {type: Service, source: satcom-control-api} - edge-agent-east: {type: Service, source: edge-control-agent} - edge-agent-west: {type: Service, source: edge-control-agent} - telemetry-bus: {type: Service, source: kafka} - analytics-store: {type: Service, source: parquet-lake} - customer-portal-app: {type: Service, source: tenant-portal} - siem-core: {type: Service, source: wazuh-elastic-stack} - release-edr: {type: Artifact, source: release-edr-agent, destination: /opt/edr} - vendor-sso: {type: Service, source: vendor-idp} - customer-sso: {type: Service, source: customer-idp} - + git-service: + type: service + source: + name: gitea-enterprise + version: '*' + ci-orchestrator: + type: service + source: + name: gitlab-runner + version: '*' + release-registry: + type: service + source: + name: harbor-registry + version: '*' + signing-service: + type: service + source: + name: notation-signer + version: '*' + satcom-control: + type: service + source: + name: satcom-control-api + version: '*' + edge-agent-east: + type: service + source: + name: edge-control-agent + version: '*' + edge-agent-west: + type: service + source: + name: edge-control-agent + version: '*' + telemetry-bus: + type: service + source: + name: kafka + version: '*' + analytics-store: + type: service + source: + name: parquet-lake + version: '*' + customer-portal-app: + type: service + source: + name: tenant-portal + version: '*' + siem-core: + type: service + source: + name: wazuh-elastic-stack + version: '*' + release-edr: + type: artifact + source: + name: release-edr-agent + version: '*' + destination: /opt/edr + vendor-sso: + type: service + source: + name: vendor-idp + version: '*' + customer-sso: + type: service + source: + name: customer-idp + version: '*' conditions: - repo-integrity-healthy: {command: /usr/local/bin/check-repo-integrity, interval: 30} - pipeline-green: {command: /usr/local/bin/check-pipeline, interval: 30} - rogue-release-promoted: {command: /usr/local/bin/check-rogue-release, interval: 60} - release-signature-valid: {command: /usr/local/bin/check-release-signature, interval: 30} - rollback-package-ready: {command: /usr/local/bin/check-rollback-package, interval: 60} - signing-health: {command: /usr/local/bin/check-signing-service, interval: 30} - control-plane-healthy: {command: /usr/local/bin/check-control-plane, interval: 15} - rollback-complete: {command: /usr/local/bin/check-rollback-complete, interval: 60} - edge-east-healthy: {command: /usr/local/bin/check-edge-east, interval: 30} - edge-east-poisoned: {command: /usr/local/bin/check-edge-east-poisoned, interval: 60} - edge-west-healthy: {command: /usr/local/bin/check-edge-west, interval: 30} - edge-west-poisoned: {command: /usr/local/bin/check-edge-west-poisoned, interval: 60} - telemetry-flowing: {command: /usr/local/bin/check-telemetry, interval: 15} - analytics-ingest-healthy: {command: /usr/local/bin/check-analytics-ingest, interval: 30} - tenant-isolation-healthy: {command: /usr/local/bin/check-tenant-isolation, interval: 30} - portal-sso-healthy: {command: /usr/local/bin/check-portal-sso, interval: 30} - support-bastion-online: {command: /usr/local/bin/check-support-bastion, interval: 30} - security-pipeline-healthy: {command: /usr/local/bin/check-security-pipeline, interval: 30} - release-incident-ticketed: {command: /usr/local/bin/check-incident-ticket, interval: 60} - vendor-federation-healthy: {command: /usr/local/bin/check-vendor-federation, interval: 30} - customer-federation-healthy: {command: /usr/local/bin/check-customer-federation, interval: 30} - + repo-integrity-healthy: + command: /usr/local/bin/check-repo-integrity + interval: 30 + pipeline-green: + command: /usr/local/bin/check-pipeline + interval: 30 + rogue-release-promoted: + command: /usr/local/bin/check-rogue-release + interval: 60 + release-signature-valid: + command: /usr/local/bin/check-release-signature + interval: 30 + rollback-package-ready: + command: /usr/local/bin/check-rollback-package + interval: 60 + signing-health: + command: /usr/local/bin/check-signing-service + interval: 30 + control-plane-healthy: + command: /usr/local/bin/check-control-plane + interval: 15 + rollback-complete: + command: /usr/local/bin/check-rollback-complete + interval: 60 + edge-east-healthy: + command: /usr/local/bin/check-edge-east + interval: 30 + edge-east-poisoned: + command: /usr/local/bin/check-edge-east-poisoned + interval: 60 + edge-west-healthy: + command: /usr/local/bin/check-edge-west + interval: 30 + edge-west-poisoned: + command: /usr/local/bin/check-edge-west-poisoned + interval: 60 + telemetry-flowing: + command: /usr/local/bin/check-telemetry + interval: 15 + analytics-ingest-healthy: + command: /usr/local/bin/check-analytics-ingest + interval: 30 + tenant-isolation-healthy: + command: /usr/local/bin/check-tenant-isolation + interval: 30 + portal-sso-healthy: + command: /usr/local/bin/check-portal-sso + interval: 30 + support-bastion-online: + command: /usr/local/bin/check-support-bastion + interval: 30 + security-pipeline-healthy: + command: /usr/local/bin/check-security-pipeline + interval: 30 + release-incident-ticketed: + command: /usr/local/bin/check-incident-ticket + interval: 60 + vendor-federation-healthy: + command: /usr/local/bin/check-vendor-federation + interval: 30 + customer-federation-healthy: + command: /usr/local/bin/check-customer-federation + interval: 30 vulnerabilities: runner-token-reuse: name: Runner token reuse @@ -358,11 +632,10 @@ vulnerabilities: description: Customer portal accepts stale trust metadata from partner IdPs technical: true class: CWE-345 - entities: platform-blue: name: Platform Blue Team - role: Blue + role: blue mission: Deliver a trusted release while maintaining customer service continuity entities: release-engineering: @@ -373,72 +646,85 @@ entities: name: Security Operations vendor-ops: name: Vendor Operations - role: Green + role: green mission: Provide support access for customer escalations customer-ops: name: Customer Operations - role: Green + role: green mission: Monitor service continuity and tenant isolation red-supply-chain: name: Red Supply Chain Team - role: Red + role: red mission: Poison the rollout and survive long enough to reach an edge region white-cell: name: White Cell - role: White - + role: white injects: release-freeze-brief: - source: release-freeze-brief + source: + name: release-freeze-brief + version: '*' from_entity: white-cell - to_entities: [platform-blue] + to_entities: + - platform-blue description: Release engineering is notified of a planned promotion window customer-telemetry-ticket: - source: telemetry-ticket + source: + name: telemetry-ticket + version: '*' from_entity: customer-ops - to_entities: [platform-blue] + to_entities: + - platform-blue description: Customer reports delayed telemetry from a canary region exec-rollback-brief: - source: rollback-order + source: + name: rollback-order + version: '*' from_entity: white-cell - to_entities: [platform-blue, vendor-ops] + to_entities: + - platform-blue + - vendor-ops description: Leadership orders a verified rollback before customer impact spreads - events: freeze-window: - injects: [release-freeze-brief] + injects: + - release-freeze-brief canary-cutover-event: - conditions: [rogue-release-promoted] - injects: [customer-telemetry-ticket] + conditions: + - rogue-release-promoted + injects: + - customer-telemetry-ticket rollback-order-event: - conditions: [edge-east-poisoned] - injects: [exec-rollback-brief] - + conditions: + - edge-east-poisoned + injects: + - exec-rollback-brief scripts: release-prep: - start-time: 0 - end-time: 2 hour + start_time: 0 + end_time: 7200 speed: ${release_story_speed} events: - freeze-window: 10 min + freeze-window: 600 canary-rollout: - start-time: 2 hour - end-time: 4 hour + start_time: 7200 + end_time: 14400 speed: ${release_story_speed} events: canary-cutover-event: ${canary_cutover} incident-rollback: - start-time: 4 hour - end-time: ${rollback_deadline} + start_time: 14400 + end_time: ${rollback_deadline} speed: ${release_story_speed} events: - rollback-order-event: 5 hour - + rollback-order-event: 18000 stories: release-day: speed: ${release_story_speed} - scripts: [release-prep, canary-rollout, incident-rollback] - + scripts: + - release-prep + - canary-rollout + - incident-rollback content: release-manifest: type: file @@ -454,59 +740,70 @@ content: tenant-api-keys: type: dataset target: customer-portal + source: + name: tenant-api-key-seed + version: '*' format: json - source: tenant-api-key-seed sensitive: true rollback-playbooks: type: dataset target: analytics-lake format: markdown items: - - name: canary-rollback.md - tags: [rollback, release] - description: Canary rollback checklist - - name: edge-validation.md - tags: [validation, edge] - description: Edge validation checklist after rollback - + - name: canary-rollback.md + tags: + - rollback + - release + description: Canary rollback checklist + - name: edge-validation.md + tags: + - validation + - edge + description: Edge validation checklist after rollback accounts: release-engineer: username: releasemgr node: git-forge - groups: [ReleaseEngineering] + groups: + - ReleaseEngineering password_strength: ${release_engineer_password_strength} ci-bot: username: ci-bot node: ci-runner - groups: [CI] + groups: + - CI password_strength: medium auth_method: certificate registry-bot: username: registry-bot node: artifact-registry - groups: [Registry] + groups: + - Registry password_strength: medium satops: username: satops node: control-api - groups: [SRE] + groups: + - SRE password_strength: strong support-vendor: username: support node: support-bastion - groups: [VendorOps] + groups: + - VendorOps password_strength: medium tenant-admin: username: tenant.admin node: customer-portal - groups: [Customers] + groups: + - Customers password_strength: strong soc-analyst: username: socanalyst node: siem01 - groups: [SOC] + groups: + - SOC password_strength: strong - relationships: forge-auths-ci: type: authenticates_with @@ -532,12 +829,14 @@ relationships: type: federates_with source: customer-portal-app target: vendor-sso - properties: {protocol: "OIDC"} + properties: + protocol: OIDC portal-federates-customer: type: federates_with source: customer-portal-app target: customer-sso - properties: {protocol: "OIDC"} + properties: + protocol: OIDC telemetry-replicates-analytics: type: replicates_to source: telemetry-bus @@ -546,143 +845,261 @@ relationships: type: connects_to source: support-bastion target: control-api - properties: {protocol: "ssh", port: "22"} - + properties: + protocol: ssh + port: '22' agents: red-build-agent: entity: red-supply-chain - actions: [Exploit, StealToken, PushArtifact, AbuseSupport] + actions: + - Exploit + - StealToken + - PushArtifact + - AbuseSupport initial_knowledge: - hosts: [git-forge, ci-runner, support-bastion] - subnets: [corp-eng, build-net, federation-net] - services: [forge-https, ci-api, bastion-ssh] - accounts: [support-vendor] - allowed_subnets: [corp-eng, build-net, federation-net, control-net] + hosts: + - git-forge + - ci-runner + - support-bastion + subnets: + - corp-eng + - build-net + - federation-net + services: + - forge-https + - ci-api + - bastion-ssh + accounts: + - support-vendor + allowed_subnets: + - corp-eng + - build-net + - federation-net + - control-net blue-release-agent: entity: platform-blue.release-engineering - actions: [Review, Promote, Revoke, Rollback] - starting_accounts: [release-engineer, registry-bot] + actions: + - Review + - Promote + - Revoke + - Rollback + starting_accounts: + - release-engineer + - registry-bot initial_knowledge: - hosts: [git-forge, artifact-registry, signing-hsm] - subnets: [corp-eng, build-net] - services: [forge-https, registry-api, signing-api] - accounts: [ci-bot, registry-bot] - allowed_subnets: [corp-eng, build-net, control-net] + hosts: + - git-forge + - artifact-registry + - signing-hsm + subnets: + - corp-eng + - build-net + services: + - forge-https + - registry-api + - signing-api + accounts: + - ci-bot + - registry-bot + allowed_subnets: + - corp-eng + - build-net + - control-net blue-sre-agent: entity: platform-blue.sre - actions: [Inspect, Isolate, Restore, Validate] - starting_accounts: [satops] + actions: + - Inspect + - Isolate + - Restore + - Validate + starting_accounts: + - satops initial_knowledge: - hosts: [control-api, edge-gateway-east, edge-gateway-west, telemetry-broker] - subnets: [control-net, edge-net, telemetry-net] - services: [control-api, edge-east-api, edge-west-api, telemetry-kafka] - accounts: [satops] - allowed_subnets: [control-net, edge-net, telemetry-net, security-net] - + hosts: + - control-api + - edge-gateway-east + - edge-gateway-west + - telemetry-broker + subnets: + - control-net + - edge-net + - telemetry-net + services: + - control-api + - edge-east-api + - edge-west-api + - telemetry-kafka + accounts: + - satops + allowed_subnets: + - control-net + - edge-net + - telemetry-net + - security-net objectives: - # This scenario intended graded red/blue comparison. Graded scoring now lives - # in the experiment/evaluator plane (experiment-* contracts) per ADR-073; - # SDL objectives assert only observable success via conditions. red-compromise-build: agent: red-build-agent - actions: [Exploit, StealToken] - targets: [ci-runner, support-bastion, forge-auths-ci] + actions: + - Exploit + - StealToken + targets: + - ci-runner + - support-bastion + - forge-auths-ci success: - conditions: [rogue-release-promoted] + conditions: + - rogue-release-promoted window: - stories: [release-day] - scripts: [release-prep] + stories: + - release-day + scripts: + - release-prep red-poison-release: agent: red-build-agent - actions: [PushArtifact] - targets: [release-manifest, artifact-registry, ci-publishes-registry] + actions: + - PushArtifact + targets: + - release-manifest + - artifact-registry + - ci-publishes-registry success: - conditions: [rogue-release-promoted] - depends_on: [red-compromise-build] + conditions: + - rogue-release-promoted window: - stories: [release-day] - scripts: [canary-rollout] - events: [canary-cutover-event] + stories: + - release-day + scripts: + - canary-rollout + events: + - canary-cutover-event + depends_on: + - red-compromise-build red-reach-edge: entity: red-supply-chain - targets: [edge-gateway-east, control-manages-east] + targets: + - edge-gateway-east + - control-manages-east success: - conditions: [edge-east-poisoned] - depends_on: [red-poison-release] + conditions: + - edge-east-poisoned window: - stories: [release-day] - scripts: [incident-rollback] - events: [rollback-order-event] + stories: + - release-day + scripts: + - incident-rollback + events: + - rollback-order-event + depends_on: + - red-poison-release blue-validate-release: agent: blue-release-agent - actions: [Review, Revoke] - targets: [artifact-registry, signing-hsm, registry-relies-signing] + actions: + - Review + - Revoke + targets: + - artifact-registry + - signing-hsm + - registry-relies-signing success: - conditions: [release-signature-valid] + conditions: + - release-signature-valid window: - stories: [release-day] - scripts: [release-prep, canary-rollout] - workflows: [release-response] - steps: [release-response.validate-release] + stories: + - release-day + scripts: + - release-prep + - canary-rollout + workflows: + - release-response + steps: + - release-response.validate-release blue-revoke-artifact: agent: blue-release-agent - actions: [Revoke, Rollback] + actions: + - Revoke + - Rollback targets: - - release-manifest - - artifact-registry - - nodes.artifact-registry.services.registry-api - - infrastructure.build-net.acls.allow-engineering-release + - release-manifest + - artifact-registry + - nodes.artifact-registry.services.registry-api + - infrastructure.build-net.acls.allow-engineering-release success: - conditions: [rollback-package-ready] - depends_on: [blue-validate-release] + conditions: + - rollback-package-ready window: - stories: [release-day] - scripts: [incident-rollback] - events: [rollback-order-event] - workflows: [release-response] - steps: [release-response.revoke-artifact] + stories: + - release-day + scripts: + - incident-rollback + events: + - rollback-order-event + workflows: + - release-response + steps: + - release-response.revoke-artifact + depends_on: + - blue-validate-release blue-preserve-service: agent: blue-sre-agent - actions: [Inspect, Isolate, Restore, Validate] - targets: [control-api, edge-gateway-east, telemetry-broker, telemetry-replicates-analytics] + actions: + - Inspect + - Isolate + - Restore + - Validate + targets: + - control-api + - edge-gateway-east + - telemetry-broker + - telemetry-replicates-analytics success: - conditions: [telemetry-flowing, tenant-isolation-healthy] - depends_on: [blue-validate-release, red-reach-edge] + conditions: + - telemetry-flowing + - tenant-isolation-healthy window: - stories: [release-day] - scripts: [incident-rollback] - workflows: [release-response] - steps: [release-response.rollback-edge] - + stories: + - release-day + scripts: + - incident-rollback + workflows: + - release-response + steps: + - release-response.rollback-edge + depends_on: + - blue-validate-release + - red-reach-edge workflows: release-response: - description: > - Release-control workflow that validates a canary, branches on - poisoned promotion, and fans out rollback work when needed. + description: 'Release-control workflow that validates a canary, branches on poisoned + promotion, and fans out rollback work when needed. + + ' start: validate-release steps: validate-release: type: objective objective: blue-validate-release - on-success: branch-on-promotion + on_success: branch-on-promotion branch-on-promotion: type: decision when: - conditions: [rogue-release-promoted] + conditions: + - rogue-release-promoted then: rollback-fanout else: finish rollback-fanout: type: parallel - branches: [revoke-artifact, rollback-edge] + branches: + - revoke-artifact + - rollback-edge join: rollback-joined revoke-artifact: type: objective objective: blue-revoke-artifact - on-success: rollback-joined + on_success: rollback-joined rollback-edge: type: objective objective: blue-preserve-service - on-success: rollback-joined + on_success: rollback-joined rollback-joined: type: join next: verify-rollback @@ -690,13 +1107,31 @@ workflows: type: decision when: steps: - - step: revoke-artifact - outcomes: [succeeded] + - step: revoke-artifact + outcomes: + - succeeded then: finish else: revalidate-release revalidate-release: type: objective objective: blue-validate-release - on-success: finish + on_success: finish finish: type: end +variables: + release_story_speed: + type: number + default: 1.25 + description: Playback speed for the release-day story + release_engineer_password_strength: + type: string + default: strong + description: Password posture for release engineering operator accounts + canary_cutover: + type: string + default: 2 hour 45 min + description: When canary promotion begins + rollback_deadline: + type: string + default: 6 hour + description: Time by which rollback should be complete diff --git a/examples/scenarios/techvault.sdl.yaml b/examples/scenarios/techvault.sdl.yaml index 9952c494e..08a84777d 100644 --- a/examples/scenarios/techvault.sdl.yaml +++ b/examples/scenarios/techvault.sdl.yaml @@ -1,346 +1,363 @@ name: techvault-runtime-parity -description: TechVault webapp runtime facts used to exercise ACES SDL runtime inventory parity. - -variables: - app_py_sha256: - type: string - required: true - description: SHA-256 digest observed for /app/app.py in the runtime capture. - requirements_sha256: - type: string - required: true - description: SHA-256 digest observed for /app/requirements.txt in the runtime capture. - style_css_sha256: - type: string - required: true - description: SHA-256 digest observed for /app/static/style.css in the runtime capture. - webapp_conf_sha256: - type: string - required: true - description: SHA-256 digest observed for /etc/supervisor/conf.d/webapp.conf. - wazuh_conf_sha256: - type: string - required: true - description: SHA-256 digest observed for /var/ossec/etc/ossec.conf. - +description: TechVault webapp runtime facts used to exercise ACES SDL runtime inventory + parity. nodes: techvault-webapp: type: vm - os: linux source: name: techvault-webapp version: local build: - description: Observed build recipe and provenance for the TechVault webapp image. base_image: python:3.12-slim base_image_digest: sha256:0e1f2a3b4c5d6e7f8091a2b3c4d5e6f70819a2b3c4d5e6f70819a2b3c4d5e6f7 dockerfile_path: containers/webapp/Dockerfile instructions: - - instruction: from - arguments: [python:3.12-slim] - - instruction: arg - arguments: [APP_VERSION] - - instruction: workdir - arguments: [/app] - - instruction: copy - arguments: [webapp/requirements.txt, /app/requirements.txt] - - instruction: run - arguments: ["pip install --no-cache-dir -r /app/requirements.txt"] - - instruction: copy - arguments: [webapp/, /app/] - - instruction: copy - arguments: [supervisor/webapp.conf, /etc/supervisor/conf.d/webapp.conf] - - instruction: copy - arguments: [docker/entrypoint.sh, /entrypoint.sh] - - instruction: expose - arguments: ["8080"] - - instruction: entrypoint - arguments: [/entrypoint.sh] - - instruction: cmd - arguments: [supervisord, -n, -c, /etc/supervisor/supervisord.conf] + - instruction: from + arguments: + - python:3.12-slim + - instruction: arg + arguments: + - APP_VERSION + - instruction: workdir + arguments: + - /app + - instruction: copy + arguments: + - webapp/requirements.txt + - /app/requirements.txt + - instruction: run + arguments: + - pip install --no-cache-dir -r /app/requirements.txt + - instruction: copy + arguments: + - webapp/ + - /app/ + - instruction: copy + arguments: + - supervisor/webapp.conf + - /etc/supervisor/conf.d/webapp.conf + - instruction: copy + arguments: + - docker/entrypoint.sh + - /entrypoint.sh + - instruction: expose + arguments: + - '8080' + - instruction: entrypoint + arguments: + - /entrypoint.sh + - instruction: cmd + arguments: + - supervisord + - -n + - -c + - /etc/supervisor/supervisord.conf layers: - - digest: sha256:11ab22cd33ef44ab55cd66ef77ab88cd99ef00ab11cd22ef33ab44cd55ef66ab - created_by: FROM python:3.12-slim - size: 122683392 - - digest: sha256:22bc33de44fa55bc66de77fa88bc99de00fa11bc22de33fa44bc55de66fa77bc - created_by: "RUN pip install --no-cache-dir -r /app/requirements.txt" - size: 18472960 - - created_by: ENV APP_HOME=/app - empty: true + - digest: sha256:11ab22cd33ef44ab55cd66ef77ab88cd99ef00ab11cd22ef33ab44cd55ef66ab + created_by: FROM python:3.12-slim + size: 122683392 + - digest: sha256:22bc33de44fa55bc66de77fa88bc99de00fa11bc22de33fa44bc55de66fa77bc + created_by: RUN pip install --no-cache-dir -r /app/requirements.txt + size: 18472960 + - created_by: ENV APP_HOME=/app + empty: true build_args: - - name: APP_VERSION - value: "2.4.0" - value_classification: plain - description: Build-time application version stamped into the image. - - name: PIP_INDEX_TOKEN - value_classification: redacted - description: Private index credential supplied at build time and not retained in the image. + - name: APP_VERSION + value: 2.4.0 + value_classification: plain + description: Build-time application version stamped into the image. + - name: PIP_INDEX_TOKEN + value_classification: redacted + description: Private index credential supplied at build time and not retained + in the image. copied_sources: - - source_path: webapp/requirements.txt - destination_path: /app/requirements.txt - - source_path: webapp/app.py - destination_path: /app/app.py - - source_path: webapp/static/style.css - destination_path: /app/static/style.css - - source_path: webapp/templates/index.html - destination_path: /app/templates/index.html - - source_path: supervisor/webapp.conf - destination_path: /etc/supervisor/conf.d/webapp.conf - - source_path: docker/entrypoint.sh - destination_path: /entrypoint.sh + - source_path: webapp/requirements.txt + destination_path: /app/requirements.txt + - source_path: webapp/app.py + destination_path: /app/app.py + - source_path: webapp/static/style.css + destination_path: /app/static/style.css + - source_path: webapp/templates/index.html + destination_path: /app/templates/index.html + - source_path: supervisor/webapp.conf + destination_path: /etc/supervisor/conf.d/webapp.conf + - source_path: docker/entrypoint.sh + destination_path: /entrypoint.sh config: - entrypoint: [/entrypoint.sh] - command: [supervisord, -n, -c, /etc/supervisor/supervisord.conf] + entrypoint: + - /entrypoint.sh + command: + - supervisord + - -n + - -c + - /etc/supervisor/supervisord.conf working_directory: /app - exposed_ports: [8080/tcp] + exposed_ports: + - 8080/tcp labels: org.opencontainers.image.title: TechVault Webapp org.opencontainers.image.source: https://example.test/techvault/webapp com.techvault.tier: webapp default_environment: - - name: APP_HOME - value: /app - value_classification: plain - - name: PYTHONUNBUFFERED - value: "1" - value_classification: plain + - name: APP_HOME + value: /app + value_classification: plain + - name: PYTHONUNBUFFERED + value: '1' + value_classification: plain source_inputs: - - identifier: webapp-app - source_path: webapp/app.py - destination_path: /app/app.py - checksum: ${app_py_sha256} - checksum_algorithm: sha256 - - identifier: webapp-requirements - source_path: webapp/requirements.txt - destination_path: /app/requirements.txt - checksum: ${requirements_sha256} - checksum_algorithm: sha256 - - identifier: webapp-style - source_path: webapp/static/style.css - destination_path: /app/static/style.css - checksum: ${style_css_sha256} - checksum_algorithm: sha256 - - identifier: supervisor-webapp-conf - source_path: supervisor/webapp.conf - destination_path: /etc/supervisor/conf.d/webapp.conf - checksum: ${webapp_conf_sha256} - checksum_algorithm: sha256 + - identifier: webapp-app + source_path: webapp/app.py + destination_path: /app/app.py + checksum: ${app_py_sha256} + checksum_algorithm: sha256 + - identifier: webapp-requirements + source_path: webapp/requirements.txt + destination_path: /app/requirements.txt + checksum: ${requirements_sha256} + checksum_algorithm: sha256 + - identifier: webapp-style + source_path: webapp/static/style.css + destination_path: /app/static/style.css + checksum: ${style_css_sha256} + checksum_algorithm: sha256 + - identifier: supervisor-webapp-conf + source_path: supervisor/webapp.conf + destination_path: /etc/supervisor/conf.d/webapp.conf + checksum: ${webapp_conf_sha256} + checksum_algorithm: sha256 attestation: status: absent verification: not_applicable attestation_type: none - description: >- - The mutable local image tag has no registry-visible OCI, in-toto, or - SLSA attestation; absence of attestation is recorded as a distinct + description: The mutable local image tag has no registry-visible OCI, in-toto, + or SLSA attestation; absence of attestation is recorded as a distinct fact from a failed verification. + description: Observed build recipe and provenance for the TechVault webapp + image. resources: - ram: 1 GiB + ram: 1073741824 cpu: 1 + os: linux + vulnerabilities: + - unrestricted-upload + - verbose-error-disclosure services: - - port: 8080 - protocol: tcp - name: techvault-http - description: Gunicorn-served TechVault Flask application. - vulnerabilities: [unrestricted-upload, verbose-error-disclosure] + - port: 8080 + protocol: tcp + name: techvault-http + description: Gunicorn-served TechVault Flask application. runtime: mounts: - - target: /var/log/gunicorn - source: techvault_gunicorn_logs - source_kind: volume - filesystem_type: ext4 - read_only: false - options: [rw, nosuid] - propagation: rprivate - stability: volume_backed - backend_generated: true - description: Runtime volume state for Gunicorn logs. + - target: /var/log/gunicorn + source: techvault_gunicorn_logs + source_kind: volume + filesystem_type: ext4 + read_only: false + options: + - rw + - nosuid + propagation: rprivate + stability: volume_backed + backend_generated: true + description: Runtime volume state for Gunicorn logs. filesystem_inventory: - - path: /app/app.py - entry_type: file - owner_user: root - owner_group: root - uid: 0 - gid: 0 - mode: "0644" - size: 14896 - content_digest: ${app_py_sha256} - digest_algorithm: sha256 - source_path: webapp/app.py - provenance: techvault-source-package - stability: stable - sensitivity: plain - - path: /app/requirements.txt - entry_type: file - owner_user: root - owner_group: root - uid: 0 - gid: 0 - mode: "0644" - size: 312 - content_digest: ${requirements_sha256} - digest_algorithm: sha256 - source_path: webapp/requirements.txt - provenance: techvault-source-package - stability: stable - sensitivity: plain - - path: /app/static/style.css - entry_type: file - owner_user: root - owner_group: root - uid: 0 - gid: 0 - mode: "0644" - size: 8192 - content_digest: ${style_css_sha256} - digest_algorithm: sha256 - source_path: webapp/static/style.css - provenance: techvault-source-package - stability: stable - sensitivity: plain - - path: /app/templates/index.html - entry_type: file - owner_user: root - owner_group: root - uid: 0 - gid: 0 - mode: "0644" - source_path: webapp/templates/index.html - provenance: techvault-source-package - stability: stable - sensitivity: plain - - path: /etc/supervisor/conf.d/webapp.conf - entry_type: file - owner_user: root - owner_group: root - uid: 0 - gid: 0 - mode: "0644" - content_digest: ${webapp_conf_sha256} - digest_algorithm: sha256 - source_path: supervisor/webapp.conf - provenance: image-configuration - stability: stable - sensitivity: plain - - path: /etc/rsyslog.d/80-gunicorn.conf - entry_type: file - owner_user: root - owner_group: root - uid: 0 - gid: 0 - mode: "0644" - source_path: rsyslog/80-gunicorn.conf - provenance: image-configuration - stability: stable - sensitivity: plain - - path: /etc/rsyslog.d/90-forward.conf - entry_type: file - owner_user: root - owner_group: root - uid: 0 - gid: 0 - mode: "0644" - source_path: rsyslog/90-forward.conf - provenance: image-configuration - stability: stable - sensitivity: plain - - path: /entrypoint.sh - entry_type: file - owner_user: root - owner_group: root - uid: 0 - gid: 0 - mode: "0755" - source_path: docker/entrypoint.sh - provenance: image-configuration - stability: stable - sensitivity: plain - - path: /opt/aptl/wazuh - entry_type: directory - owner_user: root - owner_group: root - uid: 0 - gid: 0 - mode: "0755" - provenance: image-configuration - stability: stable - sensitivity: plain - - path: /var/ossec/etc/ossec.conf - entry_type: file - owner_user: root - owner_group: root - uid: 0 - gid: 0 - mode: "0640" - content_digest: ${wazuh_conf_sha256} - digest_algorithm: sha256 - source_path: wazuh/ossec.conf - provenance: image-configuration - stability: stable - sensitivity: plain - - path: /var/log/gunicorn/access.log - entry_type: file - owner_user: root - owner_group: adm - uid: 0 - gid: 4 - mode: "0640" - provenance: runtime - stability: log - sensitivity: operator_secret + - path: /app/app.py + entry_type: file + owner_user: root + owner_group: root + uid: 0 + gid: 0 + mode: '0644' + size: 14896 + content_digest: ${app_py_sha256} + digest_algorithm: sha256 + source_path: webapp/app.py + provenance: techvault-source-package + stability: stable + sensitivity: plain + - path: /app/requirements.txt + entry_type: file + owner_user: root + owner_group: root + uid: 0 + gid: 0 + mode: '0644' + size: 312 + content_digest: ${requirements_sha256} + digest_algorithm: sha256 + source_path: webapp/requirements.txt + provenance: techvault-source-package + stability: stable + sensitivity: plain + - path: /app/static/style.css + entry_type: file + owner_user: root + owner_group: root + uid: 0 + gid: 0 + mode: '0644' + size: 8192 + content_digest: ${style_css_sha256} + digest_algorithm: sha256 + source_path: webapp/static/style.css + provenance: techvault-source-package + stability: stable + sensitivity: plain + - path: /app/templates/index.html + entry_type: file + owner_user: root + owner_group: root + uid: 0 + gid: 0 + mode: '0644' + source_path: webapp/templates/index.html + provenance: techvault-source-package + stability: stable + sensitivity: plain + - path: /etc/supervisor/conf.d/webapp.conf + entry_type: file + owner_user: root + owner_group: root + uid: 0 + gid: 0 + mode: '0644' + content_digest: ${webapp_conf_sha256} + digest_algorithm: sha256 + source_path: supervisor/webapp.conf + provenance: image-configuration + stability: stable + sensitivity: plain + - path: /etc/rsyslog.d/80-gunicorn.conf + entry_type: file + owner_user: root + owner_group: root + uid: 0 + gid: 0 + mode: '0644' + source_path: rsyslog/80-gunicorn.conf + provenance: image-configuration + stability: stable + sensitivity: plain + - path: /etc/rsyslog.d/90-forward.conf + entry_type: file + owner_user: root + owner_group: root + uid: 0 + gid: 0 + mode: '0644' + source_path: rsyslog/90-forward.conf + provenance: image-configuration + stability: stable + sensitivity: plain + - path: /entrypoint.sh + entry_type: file + owner_user: root + owner_group: root + uid: 0 + gid: 0 + mode: '0755' + source_path: docker/entrypoint.sh + provenance: image-configuration + stability: stable + sensitivity: plain + - path: /opt/aptl/wazuh + entry_type: directory + owner_user: root + owner_group: root + uid: 0 + gid: 0 + mode: '0755' + provenance: image-configuration + stability: stable + sensitivity: plain + - path: /var/ossec/etc/ossec.conf + entry_type: file + owner_user: root + owner_group: root + uid: 0 + gid: 0 + mode: '0640' + content_digest: ${wazuh_conf_sha256} + digest_algorithm: sha256 + source_path: wazuh/ossec.conf + provenance: image-configuration + stability: stable + sensitivity: plain + - path: /var/log/gunicorn/access.log + entry_type: file + owner_user: root + owner_group: adm + uid: 0 + gid: 4 + mode: '0640' + provenance: runtime + stability: log + sensitivity: operator_secret processes: - - name: supervisord - pid: 1 - command: supervisord -n -c /etc/supervisor/supervisord.conf - role: supervisor - user: root - group: root - working_directory: / - - name: gunicorn - parent_pid: 1 - command: [gunicorn, app:app, --bind, 0.0.0.0:8080] - role: worker - user: root - group: root - - name: wazuh-agentd - parent_pid: 1 - command_redacted: true - role: agent - user: root - group: ossec + - name: supervisord + pid: 1 + command: + - supervisord -n -c /etc/supervisor/supervisord.conf + role: supervisor + user: root + group: root + working_directory: / + - name: gunicorn + parent_pid: 1 + command: + - gunicorn + - app:app + - --bind + - 0.0.0.0:8080 + role: worker + user: root + group: root + - name: wazuh-agentd + parent_pid: 1 + command_redacted: true + role: agent + user: root + group: ossec environment: - - name: TECHVAULT_ADMIN_PASSWORD - value_classification: redacted - provenance: operator - - name: SCENARIO_FIXTURE_TOKEN - value_classification: secret_fixture - provenance: compose + - name: TECHVAULT_ADMIN_PASSWORD + value_classification: redacted + provenance: operator + - name: SCENARIO_FIXTURE_TOKEN + value_classification: secret_fixture + provenance: compose linux_capabilities: - required: [CAP_NET_BIND_SERVICE] - effective: [CAP_CHOWN, CAP_DAC_OVERRIDE, CAP_NET_BIND_SERVICE] - # See ADR-030. The container baseline above is what PID 1 (the - # supervisor) carries; the gunicorn worker subtree drops - # CAP_CHOWN so an attacker who lands code execution as gunicorn - # cannot relabel files under /var/log/gunicorn out from under the - # log-only sensitivity classification. + required: + - CAP_NET_BIND_SERVICE + effective: + - CAP_CHOWN + - CAP_DAC_OVERRIDE + - CAP_NET_BIND_SERVICE process_overrides: - - subject: - name: gunicorn - parent_pid: 1 - scope: subtree - drop: [CAP_CHOWN] - description: gunicorn worker subtree cannot relabel log files + - subject: + name: gunicorn + parent_pid: 1 + scope: subtree + drop: + - CAP_CHOWN + description: gunicorn worker subtree cannot relabel log files operational_policy: restart: unless_stopped resource_limits: - memory: 512 MiB + memory: 536870912 pids: 128 container: - entrypoint: [/entrypoint.sh] - command: [supervisord, -n, -c, /etc/supervisor/supervisord.conf] + entrypoint: + - /entrypoint.sh + command: + - supervisord + - -n + - -c + - /etc/supervisor/supervisord.conf log_driver: json-file log_options: max-size: 10m - max-file: "3" + max-file: '3' namespaces: cgroup: private ipc: private @@ -351,301 +368,344 @@ nodes: read_only_rootfs: false publish_all_ports: false autoremove: false - shm_size: 64 MiB - masked_paths: [/proc/acpi, /proc/kcore] - read_only_paths: [/proc/sys, /proc/sysrq-trigger] + shm_size: 67108864 + masked_paths: + - /proc/acpi + - /proc/kcore + read_only_paths: + - /proc/sys + - /proc/sysrq-trigger cgroup_parent: /docker runtime_name: runc - device_cgroup_rules: [c 1:3 rwm] + device_cgroup_rules: + - c 1:3 rwm extra_hosts: - - hostname: wazuh-manager - address: 172.20.0.10 - dns: [8.8.8.8] - dns_options: [ndots:0] - dns_search: [techvault.local] - group_add: [adm] + - hostname: wazuh-manager + address: 172.20.0.10 + dns: + - 8.8.8.8 + dns_options: + - ndots:0 + dns_search: + - techvault.local + group_add: + - adm health: status: healthy failing_streak: 0 log: - - start: "2026-05-20T12:00:00Z" - end: "2026-05-20T12:00:01Z" - exit_code: 0 - output: ok + - start: '2026-05-20T12:00:00Z' + end: '2026-05-20T12:00:01Z' + exit_code: 0 + output: ok local_identity: - description: Local identity database observed via getent passwd/group inside the webapp container. users: - - username: root - uid: 0 - primary_gid: 0 - primary_group: root - gecos: root - home: /root - shell: /bin/bash - provenance: image - stability: stable - - username: www-data - uid: 33 - primary_gid: 33 - primary_group: www-data - gecos: www-data - home: /var/www - shell: /usr/sbin/nologin - no_login: true - provenance: image - stability: stable - - username: messagebus - uid: 100 - primary_gid: 100 - primary_group: messagebus - home: /nonexistent - shell: /usr/sbin/nologin - no_login: true - provenance: package - stability: stable - - username: Debian-exim - uid: 101 - primary_gid: 101 - primary_group: Debian-exim - home: /var/spool/exim4 - shell: /usr/sbin/nologin - no_login: true - provenance: package - stability: stable - - username: wazuh - uid: 999 - primary_gid: 999 - primary_group: wazuh - gecos: Wazuh agent - home: /var/ossec - shell: /usr/sbin/nologin - supplemental_groups: [adm] - no_login: true - provenance: package - stability: stable + - username: root + uid: 0 + primary_gid: 0 + primary_group: root + gecos: root + home: /root + shell: /bin/bash + provenance: image + stability: stable + - username: www-data + uid: 33 + primary_gid: 33 + primary_group: www-data + gecos: www-data + home: /var/www + shell: /usr/sbin/nologin + no_login: true + provenance: image + stability: stable + - username: messagebus + uid: 100 + primary_gid: 100 + primary_group: messagebus + home: /nonexistent + shell: /usr/sbin/nologin + no_login: true + provenance: package + stability: stable + - username: Debian-exim + uid: 101 + primary_gid: 101 + primary_group: Debian-exim + home: /var/spool/exim4 + shell: /usr/sbin/nologin + no_login: true + provenance: package + stability: stable + - username: wazuh + uid: 999 + primary_gid: 999 + primary_group: wazuh + gecos: Wazuh agent + home: /var/ossec + shell: /usr/sbin/nologin + supplemental_groups: + - adm + no_login: true + provenance: package + stability: stable groups: - - name: root - gid: 0 - members: [root] - provenance: image - - name: www-data - gid: 33 - members: [www-data] - provenance: image - - name: adm - gid: 4 - members: [wazuh] - provenance: image - - name: sudo - gid: 27 - members: [] - provenance: image - - name: messagebus - gid: 100 - members: [] - provenance: package - - name: Debian-exim - gid: 101 - members: [] - provenance: package - - name: wazuh - gid: 999 - members: [wazuh] - provenance: package + - name: root + gid: 0 + members: + - root + provenance: image + - name: www-data + gid: 33 + members: + - www-data + provenance: image + - name: adm + gid: 4 + members: + - wazuh + provenance: image + - name: sudo + gid: 27 + members: [] + provenance: image + - name: messagebus + gid: 100 + members: [] + provenance: package + - name: Debian-exim + gid: 101 + members: [] + provenance: package + - name: wazuh + gid: 999 + members: + - wazuh + provenance: package sudo_rules: - - principal: sudo - principal_kind: group - host_scope: ALL - run_as_users: [ALL] - run_as_groups: [ALL] - commands: [ALL] - description: Default Debian sudo-group grant; no member accounts observed in this capture. - - principal: wazuh - principal_kind: user - host_scope: ALL - run_as_users: [root] - commands: - - /usr/bin/systemctl restart wazuh-agent - nopasswd: true - description: NOPASSWD restart grant for the Wazuh agent service. + - principal: sudo + principal_kind: group + run_as_users: + - ALL + run_as_groups: + - ALL + commands: + - ALL + host_scope: ALL + description: Default Debian sudo-group grant; no member accounts observed + in this capture. + - principal: wazuh + principal_kind: user + run_as_users: + - root + commands: + - /usr/bin/systemctl restart wazuh-agent + host_scope: ALL + nopasswd: true + description: NOPASSWD restart grant for the Wazuh agent service. + description: Local identity database observed via getent passwd/group inside + the webapp container. network: - description: Container network realization observed by harness inspection of the webapp container. hostname: techvault-webapp domainname: techvault.local endpoints: - - network: aptl-dmz - network_id: 7f2c1ad4e9b30c5a8d6e4f1b2a9c7e0d3f5b8a1c4d6e9f0a2b3c5d7e9f1a3b5c - network_id_stability: stable - endpoint_id: 3a9c7e0d3f5b8a1c4d6e9f0a2b3c5d7e9f1a3b5c7d2c1ad4e9b30c5a8d6e4f1b - endpoint_id_stability: ephemeral - backend_generated: true - ip_address: 172.20.0.20 - ip_prefix_length: 24 - gateway: 172.20.0.1 - mac_address: 02:42:ac:14:00:14 - aliases: [aptl-webapp, webapp] - dns_names: [aptl-webapp, webapp] - generated_dns_names: [3a9c7e0d3f5b] - backend: - driver: bridge - ipam_driver: default - driver_options: - com.docker.network.bridge.name: br-aptl-dmz - ipam_options: - com.docker.network.driver.mtu: "1500" - description: Docker bridge network backing the TechVault DMZ. - - network: aptl-internal - network_id: c4d6e9f0a2b3c5d7e9f1a3b5c7d2c1ad4e9b30c5a8d6e4f1b7f2c1ad4e9b30c5 - network_id_stability: stable - endpoint_id: 9f1a3b5c7d2c1ad4e9b30c5a8d6e4f1b7f2c1ad4e9b30c5a3a9c7e0d3f5b8a1c - endpoint_id_stability: ephemeral - backend_generated: true - ip_address: 172.21.0.20 - ip_prefix_length: 24 - gateway: 172.21.0.1 - mac_address: 02:42:ac:15:00:14 - aliases: [aptl-webapp, webapp] - dns_names: [aptl-webapp, webapp] - generated_dns_names: [3a9c7e0d3f5b] - backend: - driver: bridge - ipam_driver: default - driver_options: - com.docker.network.bridge.name: br-aptl-internal + - network: aptl-dmz + network_id: 7f2c1ad4e9b30c5a8d6e4f1b2a9c7e0d3f5b8a1c4d6e9f0a2b3c5d7e9f1a3b5c + network_id_stability: stable + endpoint_id: 3a9c7e0d3f5b8a1c4d6e9f0a2b3c5d7e9f1a3b5c7d2c1ad4e9b30c5a8d6e4f1b + endpoint_id_stability: ephemeral + backend_generated: true + ip_address: 172.20.0.20 + ip_prefix_length: 24 + gateway: 172.20.0.1 + mac_address: 02:42:ac:14:00:14 + aliases: + - aptl-webapp + - webapp + dns_names: + - aptl-webapp + - webapp + generated_dns_names: + - 3a9c7e0d3f5b + backend: + driver: bridge + ipam_driver: default + driver_options: + com.docker.network.bridge.name: br-aptl-dmz + ipam_options: + com.docker.network.driver.mtu: '1500' + description: Docker bridge network backing the TechVault DMZ. + - network: aptl-internal + network_id: c4d6e9f0a2b3c5d7e9f1a3b5c7d2c1ad4e9b30c5a8d6e4f1b7f2c1ad4e9b30c5 + network_id_stability: stable + endpoint_id: 9f1a3b5c7d2c1ad4e9b30c5a8d6e4f1b7f2c1ad4e9b30c5a3a9c7e0d3f5b8a1c + endpoint_id_stability: ephemeral + backend_generated: true + ip_address: 172.21.0.20 + ip_prefix_length: 24 + gateway: 172.21.0.1 + mac_address: 02:42:ac:15:00:14 + aliases: + - aptl-webapp + - webapp + dns_names: + - aptl-webapp + - webapp + generated_dns_names: + - 3a9c7e0d3f5b + backend: + driver: bridge + ipam_driver: default + driver_options: + com.docker.network.bridge.name: br-aptl-internal published_ports: - - container_port: 8080 - protocol: tcp - host_ip: 0.0.0.0 - host_port: 8080 - description: Webapp HTTP port published from the container to the host. + - container_port: 8080 + protocol: tcp + host_ip: 0.0.0.0 + host_port: 8080 + description: Webapp HTTP port published from the container to the host. + description: Container network realization observed by harness inspection + of the webapp container. applications: - - application_id: techvault-webapp - service: techvault-http - protocol: http - name: TechVault Webapp - framework: flask - base_path: / - description: >- - Participant-observable Flask route surface of the TechVault webapp, - observed from containers/webapp/app/app.py, templates, and static assets. - routes: - - route_id: index - path: / - methods: [GET] - name: index - auth_required: false - session_required: false - description: Landing page rendered from the index template. - responses: - - status_code: 200 - content_type: text/html - templates: [/app/templates/index.html] - static_assets: [/app/static/style.css] - - route_id: login - path: /login - methods: [GET, POST] - name: login - auth_required: false - session_required: false - auth_scheme: form_login - description: Form-based login; establishes the session on success. - parameters: - - name: username - location: form - required: true - data_type: string - - name: password - location: form - required: true - data_type: string - responses: - - status_code: 200 - content_type: text/html - description: Login form, or form re-rendered with an error. - - status_code: 302 - content_type: text/html - description: Redirect to the dashboard on successful login. - templates: [/app/templates/index.html] - static_assets: [/app/static/style.css] - redirects: - - target: /dashboard - status_code: 302 - condition: valid credentials accepted - - route_id: file-upload - path: /files/upload - methods: [POST] - name: file_upload - auth_required: true - session_required: true - description: >- - Accepts an uploaded document; the endpoint stores files without - restricting type or destination path. - parameters: - - name: document - location: uploaded_file - required: true - data_type: file - responses: - - status_code: 302 - content_type: text/html - vulnerability_refs: [unrestricted-upload] - - route_id: file-download - path: /files/ - methods: [GET] - name: file_download - auth_required: true - session_required: true - description: Returns a previously uploaded file by identifier. - parameters: - - name: file_id - location: path - required: true - data_type: string - responses: - - status_code: 200 - content_type: application/octet-stream - - status_code: 404 - content_type: text/html - - route_id: diagnostics - path: /debug/info - methods: [GET] - name: debug_info - auth_required: false - session_required: false - description: >- - Intentionally exposed diagnostic endpoint left enabled in the - range fixture image. - responses: - - status_code: 200 - content_type: application/json - exposed_fields: - - name: build_token - sensitivity: secret_fixture - value: techvault-fixture-build-token - description: Fixture build token deliberately exposed for range exercises. - - name: operator_api_key - sensitivity: redacted - description: Operator API key referenced by the endpoint but withheld from SDL. - disclosures: - - trigger: any request to the diagnostic endpoint - status_code: 200 - disclosure: installed package versions, container hostname, and app root path - sensitivity: plain - - trigger: unhandled application exception - status_code: 500 - disclosure: Python traceback with internal file paths - sensitivity: plain - vulnerability_refs: [verbose-error-disclosure] + - application_id: techvault-webapp + service: techvault-http + protocol: http + name: TechVault Webapp + base_path: / + framework: flask + description: Participant-observable Flask route surface of the TechVault webapp, + observed from containers/webapp/app/app.py, templates, and static assets. + routes: + - route_id: index + path: / + methods: + - GET + name: index + description: Landing page rendered from the index template. + auth_required: false + session_required: false + responses: + - status_code: 200 + content_type: text/html + templates: + - /app/templates/index.html + static_assets: + - /app/static/style.css + - route_id: login + path: /login + methods: + - GET + - POST + name: login + description: Form-based login; establishes the session on success. + auth_required: false + auth_scheme: form_login + session_required: false + parameters: + - name: username + location: form + required: true + data_type: string + - name: password + location: form + required: true + data_type: string + responses: + - status_code: 200 + content_type: text/html + description: Login form, or form re-rendered with an error. + - status_code: 302 + content_type: text/html + description: Redirect to the dashboard on successful login. + templates: + - /app/templates/index.html + static_assets: + - /app/static/style.css + redirects: + - target: /dashboard + status_code: 302 + condition: valid credentials accepted + - route_id: file-upload + path: /files/upload + methods: + - POST + name: file_upload + description: Accepts an uploaded document; the endpoint stores files without + restricting type or destination path. + auth_required: true + session_required: true + parameters: + - name: document + location: uploaded_file + required: true + data_type: file + responses: + - status_code: 302 + content_type: text/html + vulnerability_refs: + - unrestricted-upload + - route_id: file-download + path: /files/ + methods: + - GET + name: file_download + description: Returns a previously uploaded file by identifier. + auth_required: true + session_required: true + parameters: + - name: file_id + location: path + required: true + data_type: string + responses: + - status_code: 200 + content_type: application/octet-stream + - status_code: 404 + content_type: text/html + - route_id: diagnostics + path: /debug/info + methods: + - GET + name: debug_info + description: Intentionally exposed diagnostic endpoint left enabled in the + range fixture image. + auth_required: false + session_required: false + responses: + - status_code: 200 + content_type: application/json + vulnerability_refs: + - verbose-error-disclosure + disclosures: + - trigger: any request to the diagnostic endpoint + status_code: 200 + disclosure: installed package versions, container hostname, and app root + path + sensitivity: plain + - trigger: unhandled application exception + status_code: 500 + disclosure: Python traceback with internal file paths + sensitivity: plain + exposed_fields: + - name: build_token + sensitivity: secret_fixture + value: techvault-fixture-build-token + description: Fixture build token deliberately exposed for range exercises. + - name: operator_api_key + sensitivity: redacted + description: Operator API key referenced by the endpoint but withheld + from SDL. aptl-dmz: type: switch description: TechVault DMZ container network (Docker bridge aptl_aptl-dmz). aptl-internal: type: switch description: TechVault internal container network (Docker bridge aptl_aptl-internal). - infrastructure: techvault-webapp: - links: [aptl-dmz, aptl-internal] + links: + - aptl-dmz + - aptl-internal aptl-dmz: properties: cidr: 172.20.0.0/24 @@ -656,19 +716,39 @@ infrastructure: cidr: 172.21.0.0/24 gateway: 172.21.0.1 internal: true - vulnerabilities: unrestricted-upload: name: Unrestricted file upload - description: >- - The file upload route accepts arbitrary file types and does not constrain + description: The file upload route accepts arbitrary file types and does not constrain the storage path, allowing a participant to place executable content. technical: true class: CWE-434 verbose-error-disclosure: name: Verbose error and diagnostic disclosure - description: >- - The diagnostic route and unhandled-exception handler expose internal + description: The diagnostic route and unhandled-exception handler expose internal package versions, host paths, and Python tracebacks to any caller. technical: false class: CWE-209 +variables: + app_py_sha256: + type: string + description: SHA-256 digest observed for /app/app.py in the runtime capture. + required: true + requirements_sha256: + type: string + description: SHA-256 digest observed for /app/requirements.txt in the runtime + capture. + required: true + style_css_sha256: + type: string + description: SHA-256 digest observed for /app/static/style.css in the runtime + capture. + required: true + webapp_conf_sha256: + type: string + description: SHA-256 digest observed for /etc/supervisor/conf.d/webapp.conf. + required: true + wazuh_conf_sha256: + type: string + description: SHA-256 digest observed for /var/ossec/etc/ossec.conf. + required: true diff --git a/implementations/python/packages/aces_cli/sdl.py b/implementations/python/packages/aces_cli/sdl.py index 2a89fde45..cd0bd4b95 100644 --- a/implementations/python/packages/aces_cli/sdl.py +++ b/implementations/python/packages/aces_cli/sdl.py @@ -6,6 +6,7 @@ from pathlib import Path import typer +from aces_sdl import SDLParseError, format_sdl_source from aces_sdl.module_registry import ( LOCKFILE_NAME, load_lockfile, @@ -17,6 +18,42 @@ app = typer.Typer(help="SDL composition and packaging.") +@app.command("format") +def format_source( + path: Path = typer.Argument(..., exists=True, readable=True), + write: bool = typer.Option(False, "--write", help="Replace the source file with canonical SDL YAML."), + check: bool = typer.Option(False, "--check", help="Fail when the source is not already canonical."), +) -> None: + """Migrate recognized legacy syntax and emit canonical sdl-yaml/v1.""" + if write and check: + raise typer.BadParameter("--write and --check are mutually exclusive") + try: + original = path.read_text(encoding="utf-8") + except UnicodeDecodeError as exc: + raise typer.BadParameter("SDL source must be valid UTF-8") from exc + try: + result = format_sdl_source(original, path=path) + except SDLParseError as exc: + raise typer.BadParameter(exc.details) from exc + + for diagnostic in result.diagnostics: + start = diagnostic.primary_range.start + typer.echo( + f"{path}:{start.line}:{start.column}: {diagnostic.severity} [{diagnostic.code}] {diagnostic.message}", + err=True, + ) + if check: + if result.content != original: + typer.echo(f"{path}: not canonical", err=True) + raise typer.Exit(code=1) + return + if write: + path.write_text(result.content, encoding="utf-8") + typer.echo(str(path)) + return + typer.echo(result.content, nl=False) + + @app.command("resolve") def resolve( path: Path = typer.Argument(..., exists=True, readable=True), diff --git a/implementations/python/packages/aces_conformance/conformance.py b/implementations/python/packages/aces_conformance/conformance.py index 1901a3991..89c28108f 100644 --- a/implementations/python/packages/aces_conformance/conformance.py +++ b/implementations/python/packages/aces_conformance/conformance.py @@ -131,7 +131,7 @@ run: type: objective objective: validate - on-success: finish + on_success: finish finish: {type: end} """ ) diff --git a/implementations/python/packages/aces_mcp/tools/authoring.py b/implementations/python/packages/aces_mcp/tools/authoring.py index 0a9ab515f..ab992b672 100644 --- a/implementations/python/packages/aces_mcp/tools/authoring.py +++ b/implementations/python/packages/aces_mcp/tools/authoring.py @@ -29,17 +29,20 @@ def register(mcp: FastMCP) -> None: "Pass the full YAML scenario text as `sdl_content`. Optionally " "set `structural_only=true` to skip semantic cross-reference " "checks (useful for work-in-progress fragments that aren't " - "complete yet)." + "complete yet). Set `accept_migration_syntax=true` only when " + "migrating legacy field spellings; canonical validation is strict." ), ) def sdl_validate( sdl_content: str, structural_only: bool = False, + accept_migration_syntax: bool = False, ) -> str: if len(sdl_content.encode("utf-8", errors="replace")) > _MAX_INPUT_BYTES: return f"INPUT TOO LARGE — limit is {_MAX_INPUT_BYTES} bytes." from aces_sdl import ( + SDLMigrationPolicy, SDLParseError, SDLValidationError, parse_sdl, @@ -49,6 +52,7 @@ def sdl_validate( scenario = parse_sdl( sdl_content, skip_semantic_validation=structural_only, + migration_policy=(SDLMigrationPolicy.ACCEPT if accept_migration_syntax else SDLMigrationPolicy.REJECT), ) except SDLParseError as exc: return ( @@ -78,6 +82,12 @@ def sdl_validate( for adv in scenario.advisories: parts.append(f" - {adv}") + if scenario.source_diagnostics: + parts.append(f"\nSource migration advisories ({len(scenario.source_diagnostics)}):") + for diagnostic in scenario.source_diagnostics: + start = diagnostic.primary_range.start + parts.append(f" - [{diagnostic.code}] line {start.line}, column {start.column}: {diagnostic.message}") + if structural_only: parts.append( "\nNote: semantic validation was skipped. Run without " @@ -531,8 +541,8 @@ def _section_summary(scenario: object) -> list[tuple[str, int]]: scripts: main-timeline: - start-time: 0 - end-time: 4 hour + start_time: 0 + end_time: 4 hour speed: ${exercise_speed} events: attack-start: 30 min @@ -603,11 +613,11 @@ def _section_summary(scenario: object) -> list[tuple[str, int]]: run-attack: type: objective objective: red-access - on-success: run-defense + on_success: run-defense run-defense: type: objective objective: blue-defend - on-success: done + on_success: done done: type: end """ diff --git a/implementations/python/packages/aces_mcp/tools/language_service.py b/implementations/python/packages/aces_mcp/tools/language_service.py index cd31c88f3..eeddca661 100644 --- a/implementations/python/packages/aces_mcp/tools/language_service.py +++ b/implementations/python/packages/aces_mcp/tools/language_service.py @@ -57,9 +57,9 @@ def sdl_references( @mcp.tool( name="sdl_format", description=( - "Format SDL YAML into the repository's normalized authoring shape. " - "Returns the formatted content and any diagnostics produced after " - "formatting." + "Migrate recognized legacy SDL field spellings and format the result " + "as canonical sdl-yaml/v1. Returns source-ranged migration advisories " + "and any diagnostics produced after formatting." ), ) def sdl_format(sdl_content: str) -> str: diff --git a/implementations/python/packages/aces_mcp/tools/operation_support.py b/implementations/python/packages/aces_mcp/tools/operation_support.py index ba5c0acab..9d1639ba5 100644 --- a/implementations/python/packages/aces_mcp/tools/operation_support.py +++ b/implementations/python/packages/aces_mcp/tools/operation_support.py @@ -32,7 +32,12 @@ ] -def compile_pipeline(sdl_content: str, parameters_json: str) -> dict[str, Any]: +def compile_pipeline( + sdl_content: str, + parameters_json: str, + *, + accept_migration_syntax: bool = False, +) -> dict[str, Any]: size_error = size_error_payload(sdl_content, parameters_json) if size_error is not None: return {"error": json.loads(size_error), "stages": [], "scenario": None, "model": None} @@ -40,6 +45,7 @@ def compile_pipeline(sdl_content: str, parameters_json: str) -> dict[str, Any]: from aces_processor.compiler import compile_runtime_model from aces_sdl import ( SDLInstantiationError, + SDLMigrationPolicy, SDLParseError, SDLValidationError, instantiate_scenario, @@ -52,7 +58,10 @@ def compile_pipeline(sdl_content: str, parameters_json: str) -> dict[str, Any]: stages: list[dict[str, str]] = [] try: - scenario = parse_sdl(sdl_content) + scenario = parse_sdl( + sdl_content, + migration_policy=(SDLMigrationPolicy.ACCEPT if accept_migration_syntax else SDLMigrationPolicy.REJECT), + ) except SDLParseError as exc: return {"error": stage_error("parse", exc), "stages": stages, "scenario": None, "model": None} except SDLValidationError as exc: @@ -90,6 +99,7 @@ def compile_pipeline(sdl_content: str, parameters_json: str) -> dict[str, Any]: "stages": stages, "scenario": concrete, "model": model, + "source_diagnostics": [item.as_dict() for item in scenario.source_diagnostics], "instantiation_parameters": concrete.instantiation_parameters, } diff --git a/implementations/python/packages/aces_mcp/tools/operations.py b/implementations/python/packages/aces_mcp/tools/operations.py index 2bc398d4f..1d81a84d7 100644 --- a/implementations/python/packages/aces_mcp/tools/operations.py +++ b/implementations/python/packages/aces_mcp/tools/operations.py @@ -143,23 +143,26 @@ def aces_agent_guidance(audience: str = "all") -> str: "Parse SDL YAML and return a machine-readable JSON summary of the " "normalized scenario shape, populated sections, advisories, and " "optional semantic-validation status. This is useful before editing " - "or deeper validation." + "or deeper validation. Canonical syntax is required unless " + "`accept_migration_syntax=true` is explicitly selected." ), ) def sdl_parse( sdl_content: str, semantic_validation: bool = False, + accept_migration_syntax: bool = False, ) -> str: size_error = size_error_payload(sdl_content) if size_error is not None: return size_error - from aces_sdl import SDLParseError, SDLValidationError, parse_sdl + from aces_sdl import SDLMigrationPolicy, SDLParseError, SDLValidationError, parse_sdl try: scenario = parse_sdl( sdl_content, skip_semantic_validation=not semantic_validation, + migration_policy=(SDLMigrationPolicy.ACCEPT if accept_migration_syntax else SDLMigrationPolicy.REJECT), ) except SDLParseError as exc: return json_response(stage_error("parse", exc)) @@ -186,6 +189,7 @@ def sdl_parse( "version": scenario.version, "populated_sections": section_counts(scenario), "advisories": list(scenario.advisories), + "source_diagnostics": [item.as_dict() for item in scenario.source_diagnostics], }, } ) @@ -195,14 +199,20 @@ def sdl_parse( description=( "Parse, semantically validate, instantiate, and compile SDL YAML " "into the ACES runtime model. Returns JSON with domain counts, " - "participant-contract counts, and structured compiler diagnostics." + "participant-contract counts, source migration advisories, and " + "structured compiler diagnostics." ), ) def sdl_compile( sdl_content: str, parameters_json: str = "{}", + accept_migration_syntax: bool = False, ) -> str: - pipeline = compile_pipeline(sdl_content, parameters_json) + pipeline = compile_pipeline( + sdl_content, + parameters_json, + accept_migration_syntax=accept_migration_syntax, + ) if pipeline["error"] is not None: return json_response(pipeline["error"]) @@ -217,6 +227,7 @@ def sdl_compile( }, "runtime_model": runtime_model_summary(model), "diagnostics": diagnostics(model.diagnostics, stage="compilation"), + "source_diagnostics": pipeline["source_diagnostics"], } ) diff --git a/implementations/python/packages/aces_mcp/tools/reference.py b/implementations/python/packages/aces_mcp/tools/reference.py index 50affff74..733fe75f1 100644 --- a/implementations/python/packages/aces_mcp/tools/reference.py +++ b/implementations/python/packages/aces_mcp/tools/reference.py @@ -81,7 +81,13 @@ def _read_example(name: str) -> str: "content": "Content", "accounts": "Accounts", "relationships": "Relationships", + "forwarding_agents": "Forwarding Agents", "agents": "Agents", + "action_contracts": "Action Contracts", + "observation_boundaries": "Observation Boundaries", + "outcome_interpretation_rules": "Outcome Interpretation Rules", + "behavior_specifications": "Behavior Specifications", + "evidence_requirements": "Evidence Requirements", "objectives": "Objectives", "workflows": "Workflows", "variables": "Variables", @@ -113,7 +119,7 @@ def register(mcp: FastMCP) -> None: name="sdl_overview", description=( "Get a comprehensive overview of the ACES Scenario Description " - "Language (SDL). Returns what the SDL is, its 17 sections, how " + "Language (SDL). Returns what the SDL is, its authoring sections, how " "parsing/validation works, the variable system, and a complete " "minimal example. Start here if you have never seen the SDL before." ), @@ -129,7 +135,10 @@ def sdl_overview() -> str: "rules. Valid section names: nodes, infrastructure, features, " "conditions, vulnerabilities, entities, orchestration " "(injects+events+scripts+stories), content, accounts, " - "relationships, agents, objectives, workflows, variables. You can " + "relationships, forwarding_agents, agents, action_contracts, " + "observation_boundaries, outcome_interpretation_rules, " + "behavior_specifications, evidence_requirements, objectives, " + "workflows, variables. You can " "also pass the individual section name like 'conditions' or " "'events'." ), @@ -161,7 +170,7 @@ def sdl_section_reference(section: str) -> str: description=( "Get a complete, real-world annotated SDL scenario example. " "Available examples: 'hospital' (hospital ransomware exercise, " - "~750 lines, uses all 17 sections), 'satcom' (satellite supply-chain " + "~750 lines, broad language coverage), 'satcom' (satellite supply-chain " "exercise, ~750 lines), 'port' (port authority OT exercise, ~680 lines), " "'minimal' (a small annotated pentest-lab example to learn the basics). " "Use 'hospital' for a comprehensive reference of all SDL features." @@ -196,7 +205,7 @@ def sdl_parser_reference() -> str: @mcp.tool( name="sdl_validation_reference", description=( - "Get documentation about SDL semantic validation: all 22 named " + "Get documentation about SDL semantic validation: the aggregated " "validation passes, cross-reference resolution rules, error " "reporting, advisories, and how ambiguous references are handled." ), @@ -323,15 +332,17 @@ def sdl_validation_reference() -> str: means* — not how to deploy it. Backend implementations realize SDL \ specifications through runtime contracts. -It descends from the Open Cyber Range (OCR) SDL and extends it with 7 \ -additional sections for richer experiment semantics. +Its topology and exercise-narrative core descends from the Open Cyber Range +(OCR) SDL. ACES adds composition, participant, evidence, objective, workflow, +and runtime-inventory semantics; the exact live surface is governed by the +normative section catalog rather than a historical count. -## The 17 Sections +## Authoring Sections -A scenario is a YAML document with a required `name` and up to 17 optional \ -sections, organized into four concerns: +A scenario is a YAML document with a required `name`, optional metadata and +composition fields, and optional authoring sections organized by concern. -### Topology & Software (5 sections) +### Topology and Software | Section | Purpose | |---------|---------| | `nodes` | VMs and network switches — the compute/network topology | @@ -345,7 +356,7 @@ def sdl_validation_reference() -> str: success references observable state (`conditions`); graded scoring, reward, and \ evaluation outputs live in the experiment/evaluator plane (ADR-055/064/069). -### Exercise Orchestration (4 sections) +### Exercise Orchestration | Section | Purpose | |---------|---------| | `entities` | Teams, organizations, people (recursive hierarchy) | @@ -354,13 +365,19 @@ def sdl_validation_reference() -> str: | `scripts` | Timed event sequences with human-readable durations | | `stories` | Top-level orchestration grouping scripts | -### Extended Experiment Semantics (7 sections) +### Extended Scenario and Experiment Semantics | Section | Purpose | |---------|---------| | `content` | Data placed into systems (files, datasets, emails) | | `accounts` | User accounts on nodes | | `relationships` | Typed directed edges (auth, trust, federation, etc.) | +| `forwarding_agents` | Scenario-level logical forwarding and shipping agents | | `agents` | Autonomous participants with actions/knowledge/scope | +| `action_contracts` | Participant action applicability, effects, failures, and interactions | +| `observation_boundaries` | Participant information projections and visibility changes | +| `outcome_interpretation_rules` | Interpretation of action observations and local outcomes | +| `behavior_specifications` | Versioned bindings across participant behavior surfaces | +| `evidence_requirements` | Authored capture obligations, distinct from captured evidence | | `objectives` | Declarative tasks: actor + targets + success + window | | `workflows` | Branching/parallel control graphs over objectives | | `variables` | Parameterization via `${var_name}` syntax | @@ -383,11 +400,11 @@ def sdl_validation_reference() -> str: - `source: "pkg"` → `{name: "pkg", version: "*"}` - `infrastructure: {node: 3}` → `{node: {count: 3}}` - `roles: {admin: "user"}` → `{admin: {username: "user"}}` -- `min-score: 50` → `{percentage: 50}` - `features: [nginx, php]` → `{nginx: "", php: ""}` ### Key Normalization -- Field keys are case-insensitive: `Name` → `name`, `Min-Score` → `min_score` +- Recognized legacy field spellings are migrated to canonical lowercase + `snake_case` with source diagnostics; canonical authoring uses those names - User-defined names (node names, feature names, etc.) are preserved as-is ### Cross-Reference System @@ -398,11 +415,11 @@ def sdl_validation_reference() -> str: - Named ACLs: `infrastructure..acls.` ### Validation Pipeline -1. YAML parsing (`yaml.safe_load()`) -2. Key normalization +1. Bounded YAML parsing with duplicate-key checks +2. Explicit migration to canonical field spelling 3. Shorthand expansion 4. Pydantic structural validation -5. 22-pass semantic validation (cross-references, cycles, IP/CIDR, etc.) +5. Aggregated semantic validation (cross-references, cycles, IP/CIDR, etc.) All errors collected before reporting — authors see every issue at once. ### Duration Grammar diff --git a/implementations/python/packages/aces_sdl/__init__.py b/implementations/python/packages/aces_sdl/__init__.py index ec9d52e09..7d55f6050 100644 --- a/implementations/python/packages/aces_sdl/__init__.py +++ b/implementations/python/packages/aces_sdl/__init__.py @@ -8,14 +8,23 @@ from importlib import import_module __all__ = [ + "canonical_sdl_bytes", + "canonical_sdl_digest", "instantiate_scenario", "InstantiatedScenario", + "SDLCanonicalDigest", + "SDL_CANONICAL_PROFILE", + "SDLFormatResult", + "format_sdl_source", "load_sdl_fragment", "parse_sdl", "parse_sdl_file", "Scenario", "SDLError", "SDLInstantiationError", + "SDLMigrationPolicy", + "SDLParserLimits", + "SDL_SOURCE_FORMAT", "SDLParseDiagnostic", "SDLParseError", "SDLSourcePosition", @@ -36,6 +45,17 @@ def __getattr__(name: str): "SDLValidationError", }: module = import_module("aces_sdl._errors") + elif name in {"canonical_sdl_bytes", "canonical_sdl_digest", "SDLCanonicalDigest"}: + module = import_module("aces_sdl.canonical") + elif name in {"format_sdl_source", "SDLFormatResult"}: + module = import_module("aces_sdl.formatting") + elif name in { + "SDL_CANONICAL_PROFILE", + "SDLMigrationPolicy", + "SDLParserLimits", + "SDL_SOURCE_FORMAT", + }: + module = import_module("aces_sdl._source_profile") elif name == "VARIABLE_TOKEN_PATTERN": module = import_module("aces_sdl._base") elif name == "instantiate_scenario": diff --git a/implementations/python/packages/aces_sdl/_errors.py b/implementations/python/packages/aces_sdl/_errors.py index 43d978a48..e38594e61 100644 --- a/implementations/python/packages/aces_sdl/_errors.py +++ b/implementations/python/packages/aces_sdl/_errors.py @@ -50,6 +50,7 @@ class SDLParseDiagnostic: related_message: str | None = None stage: str = "parse" severity: str = "error" + source: str | None = None def as_dict(self) -> dict[str, Any]: payload: dict[str, Any] = { @@ -60,6 +61,8 @@ def as_dict(self) -> dict[str, Any]: "path": self.pointer, "range": self.primary_range.as_dict(), } + if self.source is not None: + payload["source"] = self.source if self.authored_keys is not None: payload["authored_keys"] = list(self.authored_keys) if self.related_range is not None: diff --git a/implementations/python/packages/aces_sdl/_language_metadata.py b/implementations/python/packages/aces_sdl/_language_metadata.py index 6c7fce8c8..1e1a5b02f 100644 --- a/implementations/python/packages/aces_sdl/_language_metadata.py +++ b/implementations/python/packages/aces_sdl/_language_metadata.py @@ -16,23 +16,23 @@ ("stories", "scripts"): "scripts", ("content", "target"): "nodes", ("accounts", "node"): "nodes", - ("relationships", "source"): "any", - ("relationships", "target"): "any", + ("relationships", "source"): "targetable", + ("relationships", "target"): "targetable", ("agents", "entity"): "entities", ("agents", "starting_accounts"): "accounts", ("behavior_specifications", "participant_refs"): "agents", ("behavior_specifications", "action_contract_refs"): "action_contracts", ("behavior_specifications", "observation_boundary_refs"): "observation_boundaries", ("behavior_specifications", "outcome_interpretation_rule_refs"): "outcome_interpretation_rules", - ("behavior_specifications", "authority_scope_refs"): "any", - ("evidence_requirements", "source_refs"): "any", - ("evidence_requirements", "scope_refs"): "any", - ("evidence_requirements", "channel_refs"): "any", - ("evidence_requirements", "trigger_ref"): "any", - ("evidence_requirements", "boundary_ref"): "any", + ("behavior_specifications", "authority_scope_refs"): "targetable", + ("evidence_requirements", "source_refs"): "targetable", + ("evidence_requirements", "scope_refs"): "targetable", + ("evidence_requirements", "channel_refs"): "targetable", + ("evidence_requirements", "trigger_ref"): "targetable", + ("evidence_requirements", "boundary_ref"): "targetable", ("objectives", "agent"): "agents", ("objectives", "entity"): "entities", - ("objectives", "targets"): "any", + ("objectives", "targets"): "targetable", ("objectives", "depends_on"): "objectives", ("injects", "from_entity"): "entities", ("injects", "to_entities"): "entities", diff --git a/implementations/python/packages/aces_sdl/_language_references.py b/implementations/python/packages/aces_sdl/_language_references.py index db920efec..ca6091290 100644 --- a/implementations/python/packages/aces_sdl/_language_references.py +++ b/implementations/python/packages/aces_sdl/_language_references.py @@ -10,6 +10,7 @@ from ._errors import SDLParseError from ._language_diagnostics import parse_error as _parse_error from ._language_metadata import REFERENCE_COMPLETION_TARGETS +from ._reference_targetability import is_targetable_section from ._yaml_loader import compose_sdl_yaml _SUCCESS_REFERENCE_TARGETS = frozenset({"conditions"}) @@ -289,7 +290,9 @@ def _include_occurrence( if qualified_section is None: return True target = _reference_target_for_path(path, mapping_key=mapping_key) - return target in {qualified_section, "any"} + if target in {qualified_section, "any"}: + return True + return target == "targetable" and is_targetable_section(qualified_section) def _reference_target_for_path(path: list[str], *, mapping_key: bool) -> str | None: diff --git a/implementations/python/packages/aces_sdl/_reference_targetability.py b/implementations/python/packages/aces_sdl/_reference_targetability.py new file mode 100644 index 000000000..7cbda54c5 --- /dev/null +++ b/implementations/python/packages/aces_sdl/_reference_targetability.py @@ -0,0 +1,16 @@ +"""Canonical policy for SDL declarations accepted by targetable references.""" + +from __future__ import annotations + +NON_TARGETABLE_REFERENCE_SECTIONS = frozenset({"variables", "evidence_requirements", "objectives", "workflows"}) + + +def is_targetable_reference(candidate: str) -> bool: + """Return whether a qualified declaration can be a generic target.""" + section, separator, _ = candidate.partition(".") + return bool(separator) and section not in NON_TARGETABLE_REFERENCE_SECTIONS + + +def is_targetable_section(section: str) -> bool: + """Return whether declarations in a top-level section are targetable.""" + return section not in NON_TARGETABLE_REFERENCE_SECTIONS diff --git a/implementations/python/packages/aces_sdl/_source_profile.py b/implementations/python/packages/aces_sdl/_source_profile.py new file mode 100644 index 000000000..7af2b0000 --- /dev/null +++ b/implementations/python/packages/aces_sdl/_source_profile.py @@ -0,0 +1,93 @@ +"""Versioned SDL source-profile policy and YAML 1.2 Core resolver setup.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from enum import Enum + +import yaml +from yaml.nodes import ScalarNode + +SDL_SOURCE_FORMAT = "sdl-yaml/v1" +SDL_CANONICAL_PROFILE = "aces-sdl-semantic/v1" + + +class SDLMigrationPolicy(str, Enum): + """Treatment of recognized non-canonical SDL source spellings.""" + + REJECT = "reject" + ACCEPT = "accept" + + +@dataclass(frozen=True) +class SDLParserLimits: + """Operational work limits for one SDL YAML source document.""" + + max_input_bytes: int = 8 * 1024 * 1024 + max_scalar_bytes: int = 1024 * 1024 + max_depth: int = 128 + max_nodes: int = 100_000 + max_aliases: int = 256 + max_expanded_nodes: int = 250_000 + + def __post_init__(self) -> None: + for name, value in vars(self).items(): + if isinstance(value, bool) or not isinstance(value, int) or value < 1: + raise ValueError(f"{name} must be a positive integer") + + +DEFAULT_PARSER_LIMITS = SDLParserLimits() + + +@dataclass(frozen=True) +class SDLSourceParseOptions: + """Versioned source-profile choices shared by internal parse boundaries.""" + + source_format: str = SDL_SOURCE_FORMAT + migration_policy: SDLMigrationPolicy | str = SDLMigrationPolicy.REJECT + limits: SDLParserLimits = DEFAULT_PARSER_LIMITS + + +DEFAULT_SOURCE_PARSE_OPTIONS = SDLSourceParseOptions() + + +_BOOL_RE = re.compile(r"^(?:true|True|TRUE|false|False|FALSE)$") +_FLOAT_RESOLVERS = tuple( + re.compile(pattern, re.ASCII) + for pattern in ( + r"^[-+]?\.\d+(?:[eE][-+]?\d+)?$", + r"^[-+]?\d+(?:\.\d*)?[eE][-+]?\d+$", + r"^[-+]?\d+\.\d*$", + r"^[-+]?\.(?:inf|Inf|INF)$", + r"^\.(?:nan|NaN|NAN)$", + ) +) +_INT_RESOLVERS = tuple(re.compile(pattern, re.ASCII) for pattern in (r"^[-+]?\d+$", r"^0o[0-7]+$", r"^0x[\da-fA-F]+$")) +_MERGE_RE = re.compile(r"^<<$") +_NULL_RE = re.compile(r"^(?:~|null|Null|NULL|)$") + + +def install_yaml_12_core_resolvers(loader_cls: type[yaml.SafeLoader]) -> None: + """Install SDL's private YAML 1.2 Core resolver table on ``loader_cls``.""" + + loader_cls.yaml_implicit_resolvers = {} + loader_cls.add_implicit_resolver("tag:yaml.org,2002:bool", _BOOL_RE, list("tTfF")) + for resolver in _FLOAT_RESOLVERS: + loader_cls.add_implicit_resolver("tag:yaml.org,2002:float", resolver, list("-+0123456789.")) + for resolver in _INT_RESOLVERS: + loader_cls.add_implicit_resolver("tag:yaml.org,2002:int", resolver, list("-+0123456789")) + loader_cls.add_implicit_resolver("tag:yaml.org,2002:merge", _MERGE_RE, ["<"]) + loader_cls.add_implicit_resolver("tag:yaml.org,2002:null", _NULL_RE, ["~", "n", "N", ""]) + loader_cls.add_constructor("tag:yaml.org,2002:int", _construct_core_int) + + +def _construct_core_int(loader: yaml.SafeLoader, node: ScalarNode) -> int: + value = loader.construct_scalar(node).replace("_", "") + sign = -1 if value.startswith("-") else 1 + unsigned = value[1:] if value[:1] in {"-", "+"} else value + if unsigned.startswith("0o"): + return sign * int(unsigned[2:], 8) + if unsigned.startswith("0x"): + return sign * int(unsigned[2:], 16) + return sign * int(unsigned, 10) diff --git a/implementations/python/packages/aces_sdl/_source_validation.py b/implementations/python/packages/aces_sdl/_source_validation.py new file mode 100644 index 000000000..18245a3fb --- /dev/null +++ b/implementations/python/packages/aces_sdl/_source_validation.py @@ -0,0 +1,343 @@ +"""Operational validation for the versioned SDL YAML source profile.""" + +from __future__ import annotations + +import math +from pathlib import Path +from typing import cast + +import yaml +from yaml.error import Mark +from yaml.nodes import MappingNode, Node, SequenceNode +from yaml.tokens import AliasToken, DirectiveToken, ScalarToken, TagToken, Token + +from ._errors import SDLParseDiagnostic, SDLParseError, SDLSourcePosition, SDLSourceRange +from ._source_profile import SDL_SOURCE_FORMAT, SDLMigrationPolicy, SDLParserLimits + +EMPTY_CONTENT_MESSAGE = "SDL content is empty" + + +def prepare_content(content: str, *, path: Path | None) -> str: + """Reject empty or non-UTF-8-compatible source without rewriting it.""" + if not content.strip(): + raise SDLParseError(EMPTY_CONTENT_MESSAGE, path=path) + try: + content.encode("utf-8") + except UnicodeEncodeError as exc: + diagnostic = _diagnostic_at_start( + code="sdl.utf8", + message="SDL source must be valid UTF-8 without unpaired surrogate code points.", + path=path, + ) + raise SDLParseError(diagnostic.message, path=path, diagnostics=(diagnostic,)) from exc + return content + + +def validate_source_format(source_format: str, *, path: Path | None) -> None: + """Require the one implemented versioned source profile.""" + if source_format == SDL_SOURCE_FORMAT: + return + diagnostic = _diagnostic_at_start( + code="sdl.source_format", + message=f"Unsupported SDL source format '{source_format}'; expected '{SDL_SOURCE_FORMAT}'.", + path=path, + ) + raise SDLParseError(diagnostic.message, path=path, diagnostics=(diagnostic,)) + + +def coerce_migration_policy( + migration_policy: SDLMigrationPolicy | str, + *, + path: Path | None, +) -> SDLMigrationPolicy: + """Validate the explicit migration-policy selector.""" + try: + return SDLMigrationPolicy(migration_policy) + except ValueError as exc: + allowed = ", ".join(policy.value for policy in SDLMigrationPolicy) + diagnostic = _diagnostic_at_start( + code="sdl.migration_policy", + message=f"Unsupported SDL migration policy '{migration_policy}'; expected one of: {allowed}.", + path=path, + ) + raise SDLParseError(diagnostic.message, path=path, diagnostics=(diagnostic,)) from exc + + +def validate_source_tokens(content: str, *, path: Path | None, limits: SDLParserLimits) -> None: + """Enforce byte/scalar/alias limits and forbidden presentation tokens.""" + input_bytes = len(content.encode("utf-8")) + if input_bytes > limits.max_input_bytes: + _raise_source_limit( + f"SDL source is {input_bytes} bytes; limit is {limits.max_input_bytes} bytes.", + path=path, + ) + + aliases = 0 + try: + for token in yaml.scan(content, Loader=yaml.SafeLoader): + if isinstance(token, AliasToken): + aliases += 1 + if aliases > limits.max_aliases: + _raise_source_limit( + f"SDL source has more than {limits.max_aliases} alias occurrences.", + path=path, + token=token, + ) + elif isinstance(token, ScalarToken): + scalar_bytes = len(token.value.encode("utf-8")) + if scalar_bytes > limits.max_scalar_bytes: + _raise_source_limit( + f"SDL scalar is {scalar_bytes} bytes; limit is {limits.max_scalar_bytes} bytes.", + path=path, + token=token, + ) + elif isinstance(token, TagToken): + _raise_token_diagnostic( + code="sdl.explicit_tag", + message="Explicit YAML tags are not valid sdl-yaml/v1 authoring syntax.", + path=path, + token=token, + ) + elif isinstance(token, DirectiveToken): + _raise_token_diagnostic( + code="sdl.directive", + message="YAML directives are not valid sdl-yaml/v1 authoring syntax.", + path=path, + token=token, + ) + except SDLParseError: + raise + except yaml.YAMLError as exc: + raise yaml_parse_error(exc, path=path) from exc + + +def validate_source_graph(root: Node, *, path: Path | None, limits: SDLParserLimits) -> None: + """Bound unique nodes, expanded alias work, and effective depth.""" + _GraphMetrics(path=path, limits=limits).calculate(root, 1) + + +class _GraphMetrics: + def __init__(self, *, path: Path | None, limits: SDLParserLimits) -> None: + self.path = path + self.limits = limits + self.unique: set[int] = set() + self.active: set[int] = set() + self.memoized: dict[int, tuple[int, int]] = {} + + def calculate(self, node: Node, depth: int) -> tuple[int, int]: + self._check_depth(node, depth) + identity = id(node) + if identity in self.active: + return 0, 0 + self._track_unique(node, identity) + cached = self.memoized.get(identity) + if cached is not None: + self._check_cached_depth(node, depth, cached) + return cached + self.active.add(identity) + try: + metrics = self._calculate_children(node, depth) + finally: + self.active.remove(identity) + self.memoized[identity] = metrics + return metrics + + def _calculate_children(self, node: Node, depth: int) -> tuple[int, int]: + cost = 1 + height = 1 + for child in self._children(node): + child_cost, child_height = self.calculate(child, depth + 1) + cost += child_cost + height = max(height, child_height + 1) + _check_expanded_cost(cost, node=node, path=self.path, limits=self.limits) + return cost, height + + @staticmethod + def _children(node: Node) -> tuple[Node, ...]: + if isinstance(node, MappingNode): + return tuple(child for pair in node.value for child in pair) + if isinstance(node, SequenceNode): + return tuple(node.value) + return () + + def _track_unique(self, node: Node, identity: int) -> None: + if identity in self.unique: + return + self.unique.add(identity) + if len(self.unique) > self.limits.max_nodes: + _raise_source_limit( + f"SDL node graph has more than {self.limits.max_nodes} unique nodes.", + path=self.path, + node=node, + ) + + def _check_depth(self, node: Node, depth: int) -> None: + if depth > self.limits.max_depth: + _raise_source_limit(f"SDL node depth exceeds {self.limits.max_depth}.", path=self.path, node=node) + + def _check_cached_depth(self, node: Node, depth: int, metrics: tuple[int, int]) -> None: + _cost, height = metrics + if depth + height - 1 > self.limits.max_depth: + _raise_source_limit(f"SDL node depth exceeds {self.limits.max_depth}.", path=self.path, node=node) + + +def _check_expanded_cost( + cost: int, + *, + node: Node, + path: Path | None, + limits: SDLParserLimits, +) -> None: + if cost > limits.max_expanded_nodes: + _raise_source_limit( + f"SDL alias-expanded work exceeds {limits.max_expanded_nodes} nodes.", + path=path, + node=node, + ) + + +def validate_constructed_domain(value: object, *, path: Path | None) -> None: + """Reject values outside the string-keyed JSON data domain.""" + _ConstructedDomainValidator(path=path).visit(value) + + +class _ConstructedDomainValidator: + _SCALAR_TYPES = frozenset({str, bool, int}) + + def __init__(self, *, path: Path | None) -> None: + self.path = path + self.active: set[int] = set() + self.visited: set[int] = set() + + def visit(self, item: object) -> None: + item_type = type(item) + if item is None or item_type in self._SCALAR_TYPES: + return + if item_type is float: + self._visit_float(cast(float, item)) + elif item_type is list: + self._visit_list(cast(list[object], item)) + elif item_type is dict: + self._visit_dict(cast(dict[object, object], item)) + else: + _raise_domain_error( + f"YAML value type '{item_type.__name__}' is outside the SDL JSON domain.", path=self.path + ) + + def _visit_float(self, item: float) -> None: + if not math.isfinite(item): + _raise_domain_error("Non-finite numbers are not valid SDL values.", path=self.path) + + def _visit_list(self, item: list[object]) -> None: + if not self._begin_container(item): + return + try: + for child in item: + self.visit(child) + finally: + self._finish_container(item) + + def _visit_dict(self, item: dict[object, object]) -> None: + if not self._begin_container(item): + return + try: + for key, child in item.items(): + if not isinstance(key, str): + _raise_domain_error("SDL mapping keys must construct as strings.", path=self.path) + self.visit(child) + finally: + self._finish_container(item) + + def _begin_container(self, item: object) -> bool: + identity = id(item) + if identity in self.active or identity in self.visited: + return False + self.active.add(identity) + return True + + def _finish_container(self, item: object) -> None: + identity = id(item) + self.active.remove(identity) + self.visited.add(identity) + + +def _raise_domain_error(message: str, *, path: Path | None) -> None: + diagnostic = _diagnostic_at_start(code="sdl.non_json_value", message=message, path=path) + raise SDLParseError(message, path=path, diagnostics=(diagnostic,)) + + +def _raise_source_limit( + message: str, + *, + path: Path | None, + token: Token | None = None, + node: Node | None = None, +) -> None: + if token is not None: + primary_range = _range_from_marks(token.start_mark, token.end_mark) + elif node is not None: + primary_range = _range_from_node(node) + else: + primary_range = _start_range() + diagnostic = SDLParseDiagnostic( + code="sdl.source_limit", + message=message, + pointer="", + primary_range=primary_range, + source=str(path) if path is not None else None, + ) + raise SDLParseError(message, path=path, diagnostics=(diagnostic,)) + + +def _raise_token_diagnostic(*, code: str, message: str, path: Path | None, token: Token) -> None: + diagnostic = SDLParseDiagnostic( + code=code, + message=message, + pointer="", + primary_range=_range_from_marks(token.start_mark, token.end_mark), + source=str(path) if path is not None else None, + ) + raise SDLParseError(message, path=path, diagnostics=(diagnostic,)) + + +def _diagnostic_at_start(*, code: str, message: str, path: Path | None) -> SDLParseDiagnostic: + return SDLParseDiagnostic( + code=code, + message=message, + pointer="", + primary_range=_start_range(), + source=str(path) if path is not None else None, + ) + + +def _start_range() -> SDLSourceRange: + position = SDLSourcePosition(1, 1) + return SDLSourceRange(start=position, end=position) + + +def _range_from_marks(start: Mark, end: Mark) -> SDLSourceRange: + return SDLSourceRange( + start=SDLSourcePosition(start.line + 1, start.column + 1), + end=SDLSourcePosition(end.line + 1, end.column + 1), + ) + + +def _range_from_node(node: Node) -> SDLSourceRange: + return _range_from_marks(node.start_mark, node.end_mark) + + +def yaml_parse_error(error: yaml.YAMLError, *, path: Path | None) -> SDLParseError: + """Translate a PyYAML error into the stable SDL parse envelope.""" + mark = getattr(error, "problem_mark", None) + problem = getattr(error, "problem", None) + if mark is None: + return SDLParseError(f"Invalid YAML: {error}", path=path) + position = SDLSourcePosition(mark.line + 1, mark.column + 1) + diagnostic = SDLParseDiagnostic( + code="sdl.parse", + message=str(problem or error), + pointer="", + primary_range=SDLSourceRange(start=position, end=position), + source=str(path) if path is not None else None, + ) + return SDLParseError(f"Invalid YAML: {error}", path=path, diagnostics=(diagnostic,)) diff --git a/implementations/python/packages/aces_sdl/_yaml_loader.py b/implementations/python/packages/aces_sdl/_yaml_loader.py index ab209a162..f984aad24 100644 --- a/implementations/python/packages/aces_sdl/_yaml_loader.py +++ b/implementations/python/packages/aces_sdl/_yaml_loader.py @@ -2,7 +2,6 @@ from __future__ import annotations -import textwrap from collections.abc import Iterator from dataclasses import dataclass, field from pathlib import Path @@ -18,38 +17,32 @@ SDLSourceRange, ) from ._mapping_scopes import MappingScope, is_literal_map_field, normalize_field_key +from ._source_profile import ( + DEFAULT_SOURCE_PARSE_OPTIONS, + SDLMigrationPolicy, + SDLSourceParseOptions, + install_yaml_12_core_resolvers, +) +from ._source_validation import ( + EMPTY_CONTENT_MESSAGE, + coerce_migration_policy, + prepare_content, + validate_constructed_domain, + validate_source_format, + validate_source_graph, + validate_source_tokens, + yaml_parse_error, +) -_BOOL_TAG = "tag:yaml.org,2002:bool" -_EMPTY_CONTENT_MESSAGE = "SDL content is empty" _MERGE_TAG = "tag:yaml.org,2002:merge" _STRING_TAG = "tag:yaml.org,2002:str" class _SDLSafeLoader(yaml.SafeLoader): - """SafeLoader that preserves implicit YAML 1.1 boolean-like map keys.""" - - def __init__(self, stream: str) -> None: - super().__init__(stream) - self._sdl_mapping_key_context: list[bool] = [] + """SafeLoader with an isolated YAML 1.2 Core implicit resolver table.""" - def compose_node(self, parent: Node | None, index: Node | None) -> Node: - is_mapping_key = isinstance(parent, MappingNode) and index is None - self._sdl_mapping_key_context.append(is_mapping_key) - try: - return super().compose_node(parent, index) - finally: - self._sdl_mapping_key_context.pop() - def resolve(self, kind: type[Node], value: str | None, implicit: Any) -> str: - tag = super().resolve(kind, value, implicit) - if ( - kind is ScalarNode - and self._sdl_mapping_key_context - and self._sdl_mapping_key_context[-1] - and tag == _BOOL_TAG - ): - return _STRING_TAG - return tag +install_yaml_12_core_resolvers(_SDLSafeLoader) @dataclass(frozen=True) @@ -84,8 +77,10 @@ def build(self) -> _EffectiveMapping: class _MappingAnalyzer: - def __init__(self) -> None: + def __init__(self, *, migration_policy: SDLMigrationPolicy, path: Path | None) -> None: self.diagnostics: list[SDLParseDiagnostic] = [] + self._migration_policy = migration_policy + self._source = str(path) if path is not None else None self._effective_cache: dict[tuple[int, MappingScope], _EffectiveMapping] = {} self._diagnostic_keys: set[tuple[Any, ...]] = set() self._walked: set[tuple[int, MappingScope]] = set() @@ -141,6 +136,7 @@ def _add_alias_cycle(self, node: Node, tokens: list[str]) -> None: message="Cyclic YAML aliases are not valid SDL authoring input.", pointer=_encode_pointer(tokens), primary_range=_range_from_node(node), + source=self._source, ) ) @@ -164,10 +160,18 @@ def _walk_mapping( active: set[int], ) -> None: effective = self._effective_mapping(node, scope=scope, active=set()) + conflicted_key_nodes = {id(entry.key_node) for pair in effective.conflicts for entry in pair} for first, conflicting in effective.conflicts: self._add_conflict(first, conflicting, tokens) for key_node, value_node in node.value: - self._walk_mapping_entry(key_node, value_node, scope=scope, tokens=tokens, active=active) + self._walk_mapping_entry( + key_node, + value_node, + scope=scope, + tokens=tokens, + active=active, + suppress_field_migration=id(key_node) in conflicted_key_nodes, + ) def _add_conflict(self, first: _Entry, conflicting: _Entry, tokens: list[str]) -> None: self._add( @@ -179,6 +183,7 @@ def _add_conflict(self, first: _Entry, conflicting: _Entry, tokens: list[str]) - primary_range=_range_from_node(conflicting.key_node), related_range=_range_from_node(first.key_node), related_message=f"First authored key '{first.authored}'.", + source=self._source, ) ) @@ -190,8 +195,16 @@ def _walk_mapping_entry( scope: MappingScope, tokens: list[str], active: set[int], + suppress_field_migration: bool, ) -> None: if _is_merge_key(key_node): + self._add_migration_diagnostic( + key_node, + code="sdl.noncanonical_merge", + message="YAML merge keys are migration syntax, not canonical sdl-yaml/v1.", + pointer=_encode_pointer(tokens), + authored_keys=("<<", "<<"), + ) self._walk_merge_value(value_node, scope=scope, tokens=tokens, active=active) return authored = _authored_key(key_node) @@ -199,6 +212,14 @@ def _walk_mapping_entry( self._add_key_type_diagnostic(key_node, authored, tokens) return canonical = normalize_field_key(authored) if scope is MappingScope.STRUCTURAL else authored + if scope is MappingScope.STRUCTURAL and canonical != authored and not suppress_field_migration: + self._add_migration_diagnostic( + key_node, + code="sdl.noncanonical_field", + message=f"Structural field '{authored}' must use canonical spelling '{canonical}'.", + pointer=_encode_pointer([*tokens, canonical]), + authored_keys=(authored, canonical), + ) child_scope = _child_scope(scope, canonical, value_node) self._walk(value_node, scope=child_scope, tokens=[*tokens, canonical], active=active) @@ -214,6 +235,28 @@ def _add_key_type_diagnostic(self, key_node: Node, authored: str, tokens: list[s message=message, pointer=_encode_pointer([*tokens, authored]), primary_range=_range_from_node(key_node), + source=self._source, + ) + ) + + def _add_migration_diagnostic( + self, + key_node: Node, + *, + code: str, + message: str, + pointer: str, + authored_keys: tuple[str, str], + ) -> None: + self._add( + SDLParseDiagnostic( + code=code, + message=message, + pointer=pointer, + primary_range=_range_from_node(key_node), + authored_keys=authored_keys, + severity="warning" if self._migration_policy is SDLMigrationPolicy.ACCEPT else "error", + source=self._source, ) ) @@ -327,21 +370,36 @@ def load_sdl_yaml( path: Path | None = None, scope: MappingScope = MappingScope.STRUCTURAL, base_pointer: str = "", + source_options: SDLSourceParseOptions = DEFAULT_SOURCE_PARSE_OPTIONS, + source_diagnostics: list[SDLParseDiagnostic] | None = None, ) -> object: """Validate and safely construct one SDL YAML document or fragment.""" - prepared = _prepare_content(content, path=path) + prepared = prepare_content(content, path=path) + policy = coerce_migration_policy(source_options.migration_policy, path=path) + validate_source_format(source_options.source_format, path=path) + validate_source_tokens(prepared, path=path, limits=source_options.limits) loader: _SDLSafeLoader | None = None try: loader = _SDLSafeLoader(prepared) root = loader.get_single_node() if root is None: - raise SDLParseError(_EMPTY_CONTENT_MESSAGE, path=path) - _validate_mapping_keys(root, path=path, scope=scope, base_pointer=base_pointer) - return loader.construct_document(root) + raise SDLParseError(EMPTY_CONTENT_MESSAGE, path=path) + validate_source_graph(root, path=path, limits=source_options.limits) + _validate_mapping_keys( + root, + path=path, + scope=scope, + base_pointer=base_pointer, + migration_policy=policy, + source_diagnostics=source_diagnostics, + ) + constructed = loader.construct_document(root) + validate_constructed_domain(constructed, path=path) + return constructed except SDLParseError: raise except yaml.YAMLError as exc: - raise _yaml_parse_error(exc, path=path) from exc + raise yaml_parse_error(exc, path=path) from exc finally: if loader is not None: loader.dispose() @@ -353,21 +411,34 @@ def compose_sdl_yaml( path: Path | None = None, scope: MappingScope = MappingScope.STRUCTURAL, base_pointer: str = "", + source_options: SDLSourceParseOptions = DEFAULT_SOURCE_PARSE_OPTIONS, + source_diagnostics: list[SDLParseDiagnostic] | None = None, ) -> Node: """Compose and key-validate SDL YAML while retaining source nodes.""" - prepared = _prepare_content(content, path=path) + prepared = prepare_content(content, path=path) + policy = coerce_migration_policy(source_options.migration_policy, path=path) + validate_source_format(source_options.source_format, path=path) + validate_source_tokens(prepared, path=path, limits=source_options.limits) loader: _SDLSafeLoader | None = None try: loader = _SDLSafeLoader(prepared) root = loader.get_single_node() if root is None: - raise SDLParseError(_EMPTY_CONTENT_MESSAGE, path=path) - _validate_mapping_keys(root, path=path, scope=scope, base_pointer=base_pointer) + raise SDLParseError(EMPTY_CONTENT_MESSAGE, path=path) + validate_source_graph(root, path=path, limits=source_options.limits) + _validate_mapping_keys( + root, + path=path, + scope=scope, + base_pointer=base_pointer, + migration_policy=policy, + source_diagnostics=source_diagnostics, + ) return root except SDLParseError: raise except yaml.YAMLError as exc: - raise _yaml_parse_error(exc, path=path) from exc + raise yaml_parse_error(exc, path=path) from exc finally: if loader is not None: loader.dispose() @@ -379,12 +450,22 @@ def _validate_mapping_keys( path: Path | None, scope: MappingScope, base_pointer: str, + migration_policy: SDLMigrationPolicy, + source_diagnostics: list[SDLParseDiagnostic] | None, ) -> None: - diagnostics = _MappingAnalyzer().analyze(root, scope=scope, base_tokens=_decode_pointer(base_pointer)) - if not diagnostics: + diagnostics = _MappingAnalyzer(migration_policy=migration_policy, path=path).analyze( + root, + scope=scope, + base_tokens=_decode_pointer(base_pointer), + ) + warnings = tuple(item for item in diagnostics if item.severity == "warning") + errors = tuple(item for item in diagnostics if item.severity != "warning") + if source_diagnostics is not None: + source_diagnostics.extend(warnings) + if not errors: return rendered: list[str] = [] - for item in diagnostics: + for item in errors: location = item.primary_range.start detail = ( f"[{item.code}] {item.pointer or '/'} at line {location.line}, column {location.column}: {item.message}" @@ -394,29 +475,7 @@ def _validate_mapping_keys( detail += f" First declaration at line {related.line}, column {related.column}." rendered.append(detail) details = "SDL mapping-key validation failed:\n " + "\n ".join(rendered) - raise SDLParseError(details, path=path, diagnostics=diagnostics) - - -def _prepare_content(content: str, *, path: Path | None) -> str: - prepared = textwrap.dedent(content) - if not prepared.strip(): - raise SDLParseError(_EMPTY_CONTENT_MESSAGE, path=path) - return prepared - - -def _yaml_parse_error(error: yaml.YAMLError, *, path: Path | None) -> SDLParseError: - mark = getattr(error, "problem_mark", None) - problem = getattr(error, "problem", None) - if mark is None: - return SDLParseError(f"Invalid YAML: {error}", path=path) - position = SDLSourcePosition(mark.line + 1, mark.column + 1) - diagnostic = SDLParseDiagnostic( - code="sdl.parse", - message=str(problem or error), - pointer="", - primary_range=SDLSourceRange(start=position, end=position), - ) - return SDLParseError(f"Invalid YAML: {error}", path=path, diagnostics=(diagnostic,)) + raise SDLParseError(details, path=path, diagnostics=errors) def _is_merge_key(node: Node) -> bool: diff --git a/implementations/python/packages/aces_sdl/canonical.py b/implementations/python/packages/aces_sdl/canonical.py new file mode 100644 index 000000000..2cf406756 --- /dev/null +++ b/implementations/python/packages/aces_sdl/canonical.py @@ -0,0 +1,53 @@ +"""Versioned canonical semantic identity for validated SDL authoring scenarios.""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass + +import rfc8785 + +from ._errors import SDLParseError +from ._source_profile import SDL_CANONICAL_PROFILE +from .scenario import InstantiatedScenario, Scenario + + +@dataclass(frozen=True) +class SDLCanonicalDigest: + """Profile-labelled digest of canonical SDL semantic bytes.""" + + profile: str + algorithm: str + value: str + + def as_dict(self) -> dict[str, str]: + return {"profile": self.profile, "algorithm": self.algorithm, "value": self.value} + + +def canonical_sdl_bytes(scenario: Scenario) -> bytes: + """Return RFC 8785 bytes for one validated, post-expansion authoring scenario.""" + if isinstance(scenario, InstantiatedScenario): + raise SDLParseError("Canonical SDL semantic identity requires an authoring scenario, not an instantiated one") + if not scenario.semantic_validated: + raise SDLParseError("Canonical SDL semantic identity requires successful semantic validation") + + payload = { + "profile": SDL_CANONICAL_PROFILE, + "scenario": scenario.model_dump(mode="json", by_alias=True, exclude_unset=True), + "module_variable_specs": scenario.module_variable_specs, + "module_node_variable_refs": scenario.module_node_variable_refs, + } + try: + return rfc8785.dumps(payload) + except rfc8785.CanonicalizationError as exc: + raise SDLParseError(f"SDL canonicalization failed: {exc}") from exc + + +def canonical_sdl_digest(scenario: Scenario) -> SDLCanonicalDigest: + """Return the profile-labelled SHA-256 digest of canonical SDL semantic bytes.""" + digest = hashlib.sha256(canonical_sdl_bytes(scenario)).hexdigest() + return SDLCanonicalDigest( + profile=SDL_CANONICAL_PROFILE, + algorithm="sha256", + value=f"sha256:{digest}", + ) diff --git a/implementations/python/packages/aces_sdl/composition.py b/implementations/python/packages/aces_sdl/composition.py index 820191f94..36d4dfd22 100644 --- a/implementations/python/packages/aces_sdl/composition.py +++ b/implementations/python/packages/aces_sdl/composition.py @@ -7,7 +7,7 @@ from typing import Any from ._base import is_variable_ref -from ._errors import SDLInstantiationError, SDLParseError +from ._errors import SDLInstantiationError, SDLParseDiagnostic, SDLParseError from ._module_provenance import ( add_unique_provenance as _add_unique_provenance, ) @@ -23,6 +23,13 @@ from ._module_symbols import ( symbol_index as _symbol_index, ) +from ._source_profile import ( + DEFAULT_PARSER_LIMITS, + SDL_SOURCE_FORMAT, + SDLMigrationPolicy, + SDLParserLimits, + SDLSourceParseOptions, +) from .entities import flatten_entities from .instantiate import instantiate_scenario from .module_registry import ( @@ -338,6 +345,10 @@ def expand_sdl_modules( *, path: Path, seen: set[Path] | None = None, + source_format: str = SDL_SOURCE_FORMAT, + migration_policy: SDLMigrationPolicy | str = SDLMigrationPolicy.REJECT, + limits: SDLParserLimits = DEFAULT_PARSER_LIMITS, + source_diagnostics: list[SDLParseDiagnostic] | None = None, ) -> tuple[ dict[str, Any], dict[str, str], @@ -383,11 +394,21 @@ def expand_sdl_modules( base_dir=resolved_path.parent, lockfile=lockfile, trust_policy=trust_policy, + source_options=SDLSourceParseOptions( + source_format=source_format, + migration_policy=migration_policy, + limits=limits, + ), + source_diagnostics=source_diagnostics, ) import_path = resolved_import.root_file imported_raw = _load_normalized_data( import_path.read_text(encoding="utf-8"), path=import_path, + source_format=source_format, + migration_policy=migration_policy, + limits=limits, + source_diagnostics=source_diagnostics, ) ( imported_expanded, @@ -398,6 +419,10 @@ def expand_sdl_modules( imported_raw, path=import_path, seen=seen, + source_format=source_format, + migration_policy=migration_policy, + limits=limits, + source_diagnostics=source_diagnostics, ) try: imported_scenario = Scenario.model_validate(imported_expanded) diff --git a/implementations/python/packages/aces_sdl/formatting.py b/implementations/python/packages/aces_sdl/formatting.py new file mode 100644 index 000000000..092b5dba7 --- /dev/null +++ b/implementations/python/packages/aces_sdl/formatting.py @@ -0,0 +1,57 @@ +"""Canonical SDL source formatting and explicit legacy-syntax migration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +import yaml +from pydantic import ValidationError + +from ._errors import SDLParseDiagnostic, SDLParseError +from ._source_profile import DEFAULT_PARSER_LIMITS, SDLMigrationPolicy, SDLParserLimits +from .parser import _load_normalized_data, parse_sdl +from .scenario import Scenario + + +@dataclass(frozen=True) +class SDLFormatResult: + """Canonical source text plus advisories for each migrated construct.""" + + content: str + diagnostics: tuple[SDLParseDiagnostic, ...] + + +def format_sdl_source( + content: str, + *, + path: Path | None = None, + limits: SDLParserLimits = DEFAULT_PARSER_LIMITS, +) -> SDLFormatResult: + """Rewrite recognized migration syntax as strict ``sdl-yaml/v1`` YAML.""" + diagnostics: list[SDLParseDiagnostic] = [] + data = _load_normalized_data( + content, + path=path, + migration_policy=SDLMigrationPolicy.ACCEPT, + limits=limits, + source_diagnostics=diagnostics, + ) + try: + scenario = Scenario(**data) + except ValidationError as exc: + raise SDLParseError(str(exc), path=path) from exc + normalized = scenario.model_dump(mode="json", by_alias=True, exclude_unset=True) + formatted = yaml.safe_dump( + normalized, + allow_unicode=False, + default_flow_style=False, + sort_keys=False, + ) + parse_sdl( + formatted, + path=path, + skip_semantic_validation=True, + limits=limits, + ) + return SDLFormatResult(content=formatted, diagnostics=tuple(diagnostics)) diff --git a/implementations/python/packages/aces_sdl/language_service.py b/implementations/python/packages/aces_sdl/language_service.py index 9fc796363..7971713b8 100644 --- a/implementations/python/packages/aces_sdl/language_service.py +++ b/implementations/python/packages/aces_sdl/language_service.py @@ -19,12 +19,15 @@ from ._language_edit import apply_edit from ._language_metadata import REFERENCE_COMPLETION_TARGETS, SECTION_FIELD_COMPLETIONS from ._language_references import find_references +from ._reference_targetability import is_targetable_section +from .formatting import format_sdl_source from .parser import _load_normalized_data, parse_sdl from .scenario import Scenario _MAX_INPUT_BYTES = 64 * 1024 _SCENARIO_METADATA_FIELDS = frozenset({"name", "version", "description", "module", "imports"}) _SECTION_FIELDS = tuple(field for field in Scenario.model_fields if field not in _SCENARIO_METADATA_FIELDS) +_TARGETABLE_SECTION_FIELDS = tuple(field for field in _SECTION_FIELDS if is_targetable_section(field)) _TOP_LEVEL_KEYS = tuple(Scenario.model_fields) @@ -89,23 +92,19 @@ def language_references(sdl_content: str, symbol: str) -> dict[str, Any]: def language_format(sdl_content: str) -> dict[str, Any]: - """Return normalized, consistently formatted SDL YAML.""" + """Migrate recognized legacy spellings and return canonical SDL YAML.""" size_error = _size_error(sdl_content) if size_error is not None: return size_error try: - data = _load_normalized_data(sdl_content) + result = format_sdl_source(sdl_content) except SDLParseError as exc: return _parse_error(exc) - formatted = yaml.safe_dump( - data, - allow_unicode=False, - default_flow_style=False, - sort_keys=False, - ) - diagnostics = language_diagnostics(formatted)["diagnostics"] + formatted = result.content + diagnostics = [item.as_dict() for item in result.diagnostics] + diagnostics.extend(language_diagnostics(formatted)["diagnostics"]) status = "formatted" if not diagnostics else "formatted_with_diagnostics" return {"status": status, "content": formatted, "diagnostics": diagnostics} @@ -206,6 +205,8 @@ def _completion_target_section(pointer: list[str]) -> str | None: def _reference_completion_items(data: dict[str, Any], target_section: str) -> list[dict[str, str]]: if target_section == "any": sections = _SECTION_FIELDS + elif target_section == "targetable": + sections = _TARGETABLE_SECTION_FIELDS elif target_section == "workflow_steps": return _workflow_step_completion_items(data) else: diff --git a/implementations/python/packages/aces_sdl/module_registry.py b/implementations/python/packages/aces_sdl/module_registry.py index d9cdd250c..034127ae5 100644 --- a/implementations/python/packages/aces_sdl/module_registry.py +++ b/implementations/python/packages/aces_sdl/module_registry.py @@ -27,7 +27,8 @@ from pydantic import Field, ValidationError from ._base import SDLModel -from ._errors import SDLParseError +from ._errors import SDLParseDiagnostic, SDLParseError +from ._source_profile import DEFAULT_SOURCE_PARSE_OPTIONS, SDLSourceParseOptions from .scenario import ImportDecl, ModuleDescriptor, Scenario LOCKFILE_NAME = "aces.lock.json" @@ -550,6 +551,8 @@ def resolve_import( base_dir: Path, lockfile: Lockfile | None = None, trust_policy: TrustPolicy | None = None, + source_options: SDLSourceParseOptions = DEFAULT_SOURCE_PARSE_OPTIONS, + source_diagnostics: list[SDLParseDiagnostic] | None = None, ) -> ResolvedModule: trust_policy = trust_policy or TrustPolicy() source = import_decl.normalized_source @@ -579,6 +582,8 @@ def resolve_import( base_dir=base_dir, lockfile=lockfile, trust_policy=trust_policy, + source_options=source_options, + source_diagnostics=source_diagnostics, ) if source.startswith("local:"): relative = source.removeprefix("local:") @@ -592,6 +597,10 @@ def resolve_import( imported_raw = _load_normalized_data( import_path.read_text(encoding="utf-8"), path=import_path, + source_format=source_options.source_format, + migration_policy=source_options.migration_policy, + limits=source_options.limits, + source_diagnostics=source_diagnostics, ) imported_scenario = Scenario.model_validate(imported_raw) descriptor = _scenario_module_descriptor( diff --git a/implementations/python/packages/aces_sdl/orchestration.py b/implementations/python/packages/aces_sdl/orchestration.py index a5e830615..93b463c4d 100644 --- a/implementations/python/packages/aces_sdl/orchestration.py +++ b/implementations/python/packages/aces_sdl/orchestration.py @@ -253,7 +253,7 @@ class WorkflowStepStateRef(SDLModel): step: str outcomes: list[WorkflowStepOutcome] = Field(min_length=1) - min_attempts: int | str | None = Field(default=None, alias="min-attempts") + min_attempts: int | str | None = None @field_validator("min_attempts", mode="before") @classmethod @@ -345,7 +345,6 @@ class WorkflowCompensationPolicy(SDLModel): on: list[WorkflowCompensationTrigger] = Field(default_factory=list) failure_policy: WorkflowCompensationFailurePolicy = Field( default=WorkflowCompensationFailurePolicy.FAIL_WORKFLOW, - alias="failure_policy", ) order: str = "reverse_completion" @@ -373,9 +372,9 @@ class WorkflowStep(SDLModel): type: WorkflowStepType = Field(alias="type") objective: str = "" next: str = "" - on_success: str = Field(default="", alias="on-success") - on_failure: str = Field(default="", alias="on-failure") - on_exhausted: str = Field(default="", alias="on-exhausted") + on_success: str = "" + on_failure: str = "" + on_exhausted: str = "" when: WorkflowPredicate | None = None then_step: str = Field(default="", alias="then") else_step: str = Field(default="", alias="else") @@ -384,8 +383,8 @@ class WorkflowStep(SDLModel): branches: list[str] = Field(default_factory=list) join: str = "" workflow: str = "" - compensate_with: str = Field(default="", alias="compensate-with") - max_attempts: int | str | None = Field(default=None, alias="max-attempts") + compensate_with: str = "" + max_attempts: int | str | None = None description: str = "" @field_validator("type", mode="before") @@ -411,7 +410,7 @@ def parse_max_attempts(cls, v: int | str | None) -> int | str | None: def validate_type_specific_fields(self) -> "WorkflowStep": if self.type == WorkflowStepType.OBJECTIVE: if not self.objective or not self.on_success: - raise ValueError("Objective workflow step requires 'objective' and 'on-success'") + raise ValueError("Objective workflow step requires 'objective' and 'on_success'") if ( self.next or self.on_exhausted diff --git a/implementations/python/packages/aces_sdl/parser.py b/implementations/python/packages/aces_sdl/parser.py index afe22cd6c..061c827c8 100644 --- a/implementations/python/packages/aces_sdl/parser.py +++ b/implementations/python/packages/aces_sdl/parser.py @@ -1,8 +1,8 @@ -"""SDL parser — YAML loading with key normalization and shorthand expansion. +"""SDL parser — canonical YAML loading and typed normalization. Provides ``parse_sdl()`` as the primary entry point. Handles: -- Case-insensitive key normalization (``Name`` → ``name``) -- Hyphen-to-underscore conversion (``min-score`` → ``min_score``) +- Exact canonical structural fields by default +- Explicitly requested migration of legacy field spellings - Shorthand expansion (``source: "pkg"`` → ``{name: "pkg", version: "*"}``) """ @@ -12,7 +12,13 @@ from pydantic import ValidationError from ._base import contains_variable_token, is_variable_ref -from ._errors import SDLParseError, SDLValidationError +from ._errors import ( + SDLParseDiagnostic, + SDLParseError, + SDLSourcePosition, + SDLSourceRange, + SDLValidationError, +) from ._mapping_scopes import ( HASHMAP_SECTIONS, NESTED_HASHMAP_FIELDS, @@ -20,6 +26,13 @@ is_literal_map_field, normalize_field_key, ) +from ._source_profile import ( + DEFAULT_PARSER_LIMITS, + SDL_SOURCE_FORMAT, + SDLMigrationPolicy, + SDLParserLimits, + SDLSourceParseOptions, +) from ._yaml_loader import load_sdl_yaml from .scenario import ExpandedScenario, Scenario from .validator import SemanticValidator @@ -43,10 +56,7 @@ def _child_is_hashmap_field(key: str, value: Any) -> bool: def _normalize_field_key(k: Any) -> Any: - """Normalize a Pydantic field key: lowercase + hyphens to underscores.""" - # PyYAML's YAML 1.1 rules can coerce bare keys like ``on``/``off`` to bools. - # SDL field keys are schema-defined strings, so normalize those legacy bool - # coercions back into the field names we actually support. + """Return the normalized representation of a structural field key.""" if isinstance(k, str): return normalize_field_key(k) return k @@ -86,12 +96,22 @@ def load_sdl_fragment( *, mapping_keys: Literal["structural", "literal"] = "structural", base_pointer: str = "", + source_format: str = SDL_SOURCE_FORMAT, + migration_policy: SDLMigrationPolicy | str = SDLMigrationPolicy.REJECT, + limits: SDLParserLimits = DEFAULT_PARSER_LIMITS, + source_diagnostics: list[SDLParseDiagnostic] | None = None, ) -> object: """Safely load an SDL YAML fragment with the canonical key preflight.""" return load_sdl_yaml( content, scope=MappingScope(mapping_keys), base_pointer=base_pointer, + source_options=SDLSourceParseOptions( + source_format=source_format, + migration_policy=migration_policy, + limits=limits, + ), + source_diagnostics=source_diagnostics, ) @@ -249,6 +269,9 @@ def parse_sdl( path: Path | None = None, *, skip_semantic_validation: bool = False, + source_format: str = SDL_SOURCE_FORMAT, + migration_policy: SDLMigrationPolicy | str = SDLMigrationPolicy.REJECT, + limits: SDLParserLimits = DEFAULT_PARSER_LIMITS, ) -> Scenario: """Parse an SDL YAML string into a validated Scenario. @@ -261,6 +284,10 @@ def parse_sdl( path: Optional file path for error messages. skip_semantic_validation: If True, only run Pydantic structural validation (useful for partial scenarios during development). + source_format: Versioned concrete-syntax profile identifier. + migration_policy: Strict rejection or explicit acceptance of recognized + legacy field/merge syntax with retained diagnostics. + limits: Source and alias-processing resource limits. Returns: Validated Scenario object. @@ -269,7 +296,15 @@ def parse_sdl( SDLParseError: If YAML parsing fails or the data isn't a dict. SDLValidationError: If semantic validation finds errors. """ - data = _load_normalized_data(content, path=path) + source_diagnostics: list[SDLParseDiagnostic] = [] + data = _load_normalized_data( + content, + path=path, + source_format=source_format, + migration_policy=migration_policy, + limits=limits, + source_diagnostics=source_diagnostics, + ) _reject_removed_scoring_sections(data, path=path) module_variable_specs: dict[str, dict[str, object]] = {} module_node_variable_refs: dict[str, dict[str, str | None]] = {} @@ -286,7 +321,14 @@ def parse_sdl( namespaces, module_variable_specs, module_node_variable_refs, - ) = expand_sdl_modules(data, path=path) + ) = expand_sdl_modules( + data, + path=path, + source_format=source_format, + migration_policy=migration_policy, + limits=limits, + source_diagnostics=source_diagnostics, + ) scenario_cls = ExpandedScenario if namespaces else Scenario else: scenario_cls = Scenario @@ -297,10 +339,13 @@ def parse_sdl( except ValidationError as e: raise SDLParseError(str(e), path=path) from e + source_diagnostics = _dedupe_source_diagnostics(source_diagnostics) + # Attach the module-import capability-variable provenance BEFORE semantic # validation so downstream `instantiate_scenario` can propagate it. scenario._set_module_variable_specs(module_variable_specs) scenario._set_module_node_variable_refs(module_node_variable_refs) + scenario._set_source_diagnostics(source_diagnostics) # Semantic validation if not skip_semantic_validation: @@ -321,6 +366,29 @@ def parse_sdl( return scenario +def _dedupe_source_diagnostics( + diagnostics: list[SDLParseDiagnostic], +) -> list[SDLParseDiagnostic]: + unique: list[SDLParseDiagnostic] = [] + seen: set[tuple[object, ...]] = set() + for diagnostic in diagnostics: + start = diagnostic.primary_range.start + end = diagnostic.primary_range.end + key = ( + diagnostic.source, + diagnostic.code, + diagnostic.pointer, + start.line, + start.column, + end.line, + end.column, + ) + if key not in seen: + seen.add(key) + unique.append(diagnostic) + return unique + + def parse_sdl_file(path: Path, **kwargs: Any) -> Scenario: """Parse an SDL YAML file into a validated Scenario. @@ -329,53 +397,40 @@ def parse_sdl_file(path: Path, **kwargs: Any) -> Scenario: if not path.exists(): raise FileNotFoundError(f"SDL file not found: {path}") - content = path.read_text(encoding="utf-8") - data = _load_normalized_data(content, path=path) - namespaces: dict[str, str] = {} - module_variable_specs: dict[str, dict[str, object]] = {} - module_node_variable_refs: dict[str, dict[str, str | None]] = {} - if data.get("imports"): - from .composition import expand_sdl_modules - - ( - data, - namespaces, - module_variable_specs, - module_node_variable_refs, - ) = expand_sdl_modules(data, path=path) - scenario_cls = ExpandedScenario if namespaces else Scenario try: - scenario = scenario_cls(**data) - except ValidationError as e: - raise SDLParseError(str(e), path=path) from e - - scenario._set_module_variable_specs(module_variable_specs) - scenario._set_module_node_variable_refs(module_node_variable_refs) - - skip_semantic_validation = bool(kwargs.pop("skip_semantic_validation", False)) - if not skip_semantic_validation: - validator = SemanticValidator(scenario) - try: - validator.validate() - except SDLValidationError as e: - e.path = path - raise - scenario._set_advisories(validator.warnings) - scenario._set_semantic_validated(True) - else: - scenario._set_advisories([]) - scenario._set_semantic_validated(False) - if isinstance(scenario, ExpandedScenario): - scenario._set_module_namespaces(namespaces) - return scenario + content = path.read_text(encoding="utf-8") + except UnicodeDecodeError as exc: + position = SDLSourcePosition(1, 1) + diagnostic = SDLParseDiagnostic( + code="sdl.utf8", + message="SDL source must be valid UTF-8.", + pointer="", + primary_range=SDLSourceRange(start=position, end=position), + source=str(path), + ) + raise SDLParseError(diagnostic.message, path=path, diagnostics=(diagnostic,)) from exc + return parse_sdl(content, path=path, **kwargs) def _load_normalized_data( content: str, *, path: Path | None = None, + source_format: str = SDL_SOURCE_FORMAT, + migration_policy: SDLMigrationPolicy | str = SDLMigrationPolicy.REJECT, + limits: SDLParserLimits = DEFAULT_PARSER_LIMITS, + source_diagnostics: list[SDLParseDiagnostic] | None = None, ) -> dict[str, Any]: - raw = load_sdl_yaml(content, path=path) + raw = load_sdl_yaml( + content, + path=path, + source_options=SDLSourceParseOptions( + source_format=source_format, + migration_policy=migration_policy, + limits=limits, + ), + source_diagnostics=source_diagnostics, + ) if not isinstance(raw, dict): raise SDLParseError("SDL must be a YAML mapping (not a scalar or list)", path=path) diff --git a/implementations/python/packages/aces_sdl/scenario.py b/implementations/python/packages/aces_sdl/scenario.py index 077e60d01..c902e0982 100644 --- a/implementations/python/packages/aces_sdl/scenario.py +++ b/implementations/python/packages/aces_sdl/scenario.py @@ -15,9 +15,10 @@ from collections.abc import Mapping from typing import Annotated -from pydantic import Field, PrivateAttr, StringConstraints, model_validator +from pydantic import ConfigDict, Field, PrivateAttr, StringConstraints, model_validator from ._base import VARIABLE_NAME_PATTERN, VARIABLE_TOKEN_RE, SDLModel +from ._errors import SDLParseDiagnostic from .accounts import Account from .agents import Agent from .conditions import Condition @@ -116,13 +117,23 @@ def normalized_source(self) -> str: class Scenario(SDLModel): - """Top-level scenario specification. + """Normalized SDL authoring object. - A YAML document with up to 23 named sections. Only ``name`` - is required. All sections are optional dicts keyed by - user-defined identifiers. + This model applies after ``sdl-yaml/v1`` source-profile checks, structural + key canonicalization, shorthand expansion, enum normalization, and typed + construction, but before module expansion and instantiation. Its JSON + Schema does not validate YAML presentation details. """ + model_config = ConfigDict( + title="SDL Normalized Authoring Object v1", + json_schema_extra={ + "x-aces-document-phase": "normalized-authoring-object", + "x-aces-source-profile": "sdl-yaml/v1", + "x-aces-validates-raw-source": False, + }, + ) + # --- Identity --- name: str version: str = "*" @@ -130,7 +141,7 @@ class Scenario(SDLModel): module: ModuleDescriptor | None = None imports: list[ImportDecl] = Field(default_factory=list) - # --- OCR SDL: 14 sections --- + # OCR-derived topology and exercise-narrative sections. nodes: dict[str, Node] = Field(default_factory=dict) infrastructure: dict[str, InfraNode] = Field(default_factory=dict) features: dict[str, Feature] = Field(default_factory=dict) @@ -161,6 +172,7 @@ class Scenario(SDLModel): ) _advisories: list[str] = PrivateAttr(default_factory=list) + _source_diagnostics: tuple[SDLParseDiagnostic, ...] = PrivateAttr(default=()) _semantic_validated: bool = PrivateAttr(default=False) # Capability-variable provenance carried across the SDL module-import # composition boundary. The composition pass strips imported variables @@ -182,6 +194,14 @@ def advisories(self) -> list[str]: def _set_advisories(self, advisories: list[str]) -> None: self._advisories = list(advisories) + @property + def source_diagnostics(self) -> tuple[SDLParseDiagnostic, ...]: + """Non-fatal source migration diagnostics retained after parsing.""" + return self._source_diagnostics + + def _set_source_diagnostics(self, diagnostics: list[SDLParseDiagnostic]) -> None: + self._source_diagnostics = tuple(diagnostics) + @property def semantic_validated(self) -> bool: """Whether full semantic validation has already run on this scenario.""" @@ -230,6 +250,14 @@ class InstantiatedScenario(Scenario): it), the schema and this validator treat it as non-concrete and reject it. """ + model_config = ConfigDict( + title="SDL Instantiated Scenario v1", + json_schema_extra={ + "x-aces-document-phase": "instantiated-scenario", + "x-aces-source-profile": "sdl-yaml/v1", + }, + ) + _instantiation_parameters: dict[str, object] = PrivateAttr(default_factory=dict) _instantiation_profile: str | None = PrivateAttr(default=None) # Snapshot of pre-instantiation `${name}` refs on `nodes.os` and diff --git a/implementations/python/packages/aces_sdl/scenarios.py b/implementations/python/packages/aces_sdl/scenarios.py index 873ed2a23..f6349321a 100644 --- a/implementations/python/packages/aces_sdl/scenarios.py +++ b/implementations/python/packages/aces_sdl/scenarios.py @@ -6,7 +6,8 @@ from pathlib import Path from ._errors import SDLParseError, SDLValidationError -from .parser import parse_sdl +from ._source_profile import SDLMigrationPolicy +from .parser import parse_sdl_file from .scenario import Scenario log = logging.getLogger("aces.scenarios") @@ -38,17 +39,17 @@ class ScenarioStateError(ScenarioError): """An invalid state transition was attempted.""" -def load_scenario(path: Path) -> Scenario: +def load_scenario( + path: Path, + *, + migration_policy: SDLMigrationPolicy | str = SDLMigrationPolicy.REJECT, +) -> Scenario: """Load and validate a scenario from a YAML file.""" if not path.exists(): raise FileNotFoundError(f"Scenario file not found: {path}") - raw = path.read_text(encoding="utf-8").strip() - if not raw: - raise ScenarioValidationError("Scenario file is empty", path=path) - try: - scenario = parse_sdl(raw, path=path) + scenario = parse_sdl_file(path, migration_policy=migration_policy) except SDLParseError as exc: raise ScenarioValidationError(str(exc), path=path) from exc except SDLValidationError as exc: @@ -56,6 +57,17 @@ def load_scenario(path: Path) -> Scenario: for advisory in scenario.advisories: log.warning("Scenario '%s' advisory: %s", scenario.name, advisory) + for diagnostic in scenario.source_diagnostics: + start = diagnostic.primary_range.start + log.warning( + "Scenario '%s' source advisory [%s] at %s:%d:%d (%s)", + scenario.name, + diagnostic.code, + diagnostic.source or path, + start.line, + start.column, + diagnostic.pointer or "/", + ) log.info("Loaded scenario '%s' from %s", scenario.name, path) return scenario diff --git a/implementations/python/packages/aces_sdl/validator/_core.py b/implementations/python/packages/aces_sdl/validator/_core.py index 52f7daebb..fc7ebdc90 100644 --- a/implementations/python/packages/aces_sdl/validator/_core.py +++ b/implementations/python/packages/aces_sdl/validator/_core.py @@ -7,6 +7,7 @@ from .._base import is_variable_ref from .._errors import SDLValidationError +from .._reference_targetability import is_targetable_reference from .._runtime_service_families import collect_qualified_runtime_family_refs from ..entities import flatten_entities from ..nodes import NodeType @@ -128,13 +129,6 @@ def _named_ref_index(self, *, targetable: bool = False) -> dict[str, set[str]]: ("stories", True), ) - _TARGETABLE_DISALLOWED_PREFIXES = ( - "variables.", - "evidence_requirements.", - "objectives.", - "workflows.", - ) - def _populate_named_ref_index(self, index: dict[str, set[str]]) -> None: self._add_top_level_section_aliases(index) self._add_entity_aliases(index) @@ -176,9 +170,7 @@ def _add_qualified_aliases(self, index: dict[str, set[str]]) -> None: def _filter_targetable_aliases(self, index: dict[str, set[str]]) -> dict[str, set[str]]: filtered: dict[str, set[str]] = {} for alias, candidates in index.items(): - keep = { - candidate for candidate in candidates if not candidate.startswith(self._TARGETABLE_DISALLOWED_PREFIXES) - } + keep = {candidate for candidate in candidates if is_targetable_reference(candidate)} if keep: filtered[alias] = keep return filtered diff --git a/implementations/python/pyproject.toml b/implementations/python/pyproject.toml index 37e674161..4d71978dc 100644 --- a/implementations/python/pyproject.toml +++ b/implementations/python/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ "asyncssh>=2.23.0", "mcp>=1.0.0", "packaging>=23.0", + "rfc8785>=0.1.4,<0.2", ] [project.optional-dependencies] diff --git a/implementations/python/tests/test_example_library_policy.py b/implementations/python/tests/test_example_library_policy.py index c60d59a52..d8df215dd 100644 --- a/implementations/python/tests/test_example_library_policy.py +++ b/implementations/python/tests/test_example_library_policy.py @@ -48,7 +48,7 @@ "verify": { "type": "objective", "objective": "verify-app-health", - "on-success": "done", + "on_success": "done", }, "done": {"type": "end"}, }, diff --git a/implementations/python/tests/test_example_schema_conformance.py b/implementations/python/tests/test_example_schema_conformance.py index ab30c49a5..ea1794e99 100644 --- a/implementations/python/tests/test_example_schema_conformance.py +++ b/implementations/python/tests/test_example_schema_conformance.py @@ -29,6 +29,7 @@ import pytest from aces_contracts.corpus import corpus_family_root from aces_contracts.experiment_spec import load_experiment_spec +from aces_sdl import load_sdl_fragment from aces_sdl.scenarios import load_scenario from jsonschema import Draft202012Validator from paths import EXAMPLES_DIR, EXPERIMENTS_DIR @@ -46,11 +47,9 @@ class SupportsModelDump(Protocol): def model_dump(self, **kwargs: Any) -> dict: ... -# ``by_alias=True`` is load-bearing: the published schema is generated from the model with -# ``model_json_schema()`` (aliases on), so a field-name dump fails on YAML-facing aliases -# such as ``class``, ``on-success``, and ``max-attempts``. ``mode="json"`` yields JSON-native -# scalars (enum values, not enum members). These are the same flags the runtime compiler -# uses for its contract-shaped serialization. +# ``by_alias=True`` is load-bearing for intentional language keywords such as +# ``class``, ``type``, ``then``, and ``else``. Ordinary structural wire fields +# use canonical snake_case. ``mode="json"`` yields JSON-native enum values. _PUBLICATION_DUMP_KWARGS = {"mode": "json", "by_alias": True} @@ -147,6 +146,17 @@ def test_example_conforms_to_published_schema(entry: CorpusEntry, path: Path) -> ) +@pytest.mark.parametrize("path", sorted(EXAMPLES_DIR.glob("*.sdl.yaml")), ids=lambda path: path.name) +def test_sdl_example_is_canonical_source_and_direct_normalized_schema_input(path: Path) -> None: + """Canonical examples pass strict source decoding and the advertised schema directly.""" + payload = load_sdl_fragment(path.read_text(encoding="utf-8")) + errors = sorted(_AUTHORING_ENTRY.validator().iter_errors(payload), key=lambda error: error.json_path) + + assert not errors, f"{path.name} is not direct normalized authoring-schema input:\n" + "\n".join( + f" {error.json_path}: {error.message}" for error in errors + ) + + @pytest.mark.parametrize("entry", VALIDATION_CORPUS, ids=_case_id) def test_corpus_leg_is_nonempty(entry: CorpusEntry) -> None: """Non-vacuity guard: a stale/relocated corpus root must fail loudly, not collect zero cases.""" diff --git a/implementations/python/tests/test_fm2_semantics.py b/implementations/python/tests/test_fm2_semantics.py index a876cb3ff..ff94012b8 100644 --- a/implementations/python/tests/test_fm2_semantics.py +++ b/implementations/python/tests/test_fm2_semantics.py @@ -58,8 +58,8 @@ def test_validator_and_compiler_agree_on_window_errors(self): kickoff: {conditions: [health]} cleanup: {conditions: [health]} scripts: - timeline: {start-time: 0, end-time: 60, speed: 1, events: {kickoff: 10}} - side: {start-time: 0, end-time: 60, speed: 1, events: {cleanup: 20}} + timeline: {start_time: 0, end_time: 60, speed: 1, events: {kickoff: 10}} + side: {start_time: 0, end_time: 60, speed: 1, events: {cleanup: 20}} stories: main: {scripts: [timeline]} objectives: diff --git a/implementations/python/tests/test_language_service.py b/implementations/python/tests/test_language_service.py index 905d5ff29..daa25ebd3 100644 --- a/implementations/python/tests/test_language_service.py +++ b/implementations/python/tests/test_language_service.py @@ -83,6 +83,70 @@ def test_language_completions_cover_contexts_and_filters() -> None: assert workflow_refs["items"][0]["detail"] == "workflows.flow.steps.start-here" +def test_targetable_completions_exclude_non_targetable_sections() -> None: + sdl = """\ +name: targetable-completions +nodes: + web: {type: VM, os: linux, resources: {ram: 1 GiB, cpu: 1}} +variables: + count: {type: integer, default: 1} +evidence_requirements: + capture: {source_refs: [web], source_class: node} +objectives: + inspect: {targets: [web], success: {conditions: []}} +workflows: + flow: {start: done, steps: {done: {type: end}}} +""" + + result = language_completions(sdl, cursor_path="/objectives/inspect/targets") + + assert result["context"] == "reference:targetable" + details = {item["detail"] for item in result["items"]} + assert "nodes.web" in details + assert not details & { + "variables.count", + "evidence_requirements.capture", + "objectives.inspect", + "workflows.flow", + } + + +def test_qualified_targetable_reference_reports_occurrence() -> None: + sdl = """\ +name: targetable-reference +nodes: + web: {type: VM, os: linux, resources: {ram: 1 GiB, cpu: 1}} +behavior_specifications: + baseline: + semantic_version: 1.0.0 + lifecycle_state: active + participant_refs: [] + authority_scope_refs: [nodes.web] + behavior_mode: baseline +""" + + result = language_references(sdl, "nodes.web") + + assert any( + item["path"] == "/behavior_specifications/baseline/authority_scope_refs/0" for item in result["occurrences"] + ) + + +def test_qualified_targetable_reference_excludes_non_targetable_occurrence() -> None: + sdl = """\ +name: targetable-reference +nodes: + web: {type: VM, os: linux, resources: {ram: 1 GiB, cpu: 1}} +objectives: + inspect: {targets: [objectives.inspect], success: {conditions: []}} +""" + + result = language_references(sdl, "objectives.inspect") + + assert result["definitions"][0]["qualified_name"] == "objectives.inspect" + assert not any(item["path"] == "/objectives/inspect/targets/0" for item in result["occurrences"]) + + def test_language_completions_report_parse_and_size_errors() -> None: parse_error = language_completions("name: [\n", cursor_path="/") assert parse_error["status"] == "invalid" @@ -157,9 +221,14 @@ def test_language_format_normalizes_field_keys() -> None: """ ) - assert payload["status"] == "formatted" + assert payload["status"] == "formatted_with_diagnostics" assert payload["content"].startswith("name: formatting-test\nnodes:\n") - assert "type: Switch" in payload["content"] + assert "type: switch" in payload["content"] + assert [item["code"] for item in payload["diagnostics"]] == [ + "sdl.noncanonical_field", + "sdl.noncanonical_field", + "sdl.noncanonical_field", + ] def test_language_format_reports_parse_error() -> None: diff --git a/implementations/python/tests/test_mcp_server.py b/implementations/python/tests/test_mcp_server.py index b6ec17947..9de7a3b06 100644 --- a/implementations/python/tests/test_mcp_server.py +++ b/implementations/python/tests/test_mcp_server.py @@ -107,8 +107,8 @@ def _json_call(server, tool: str, args: dict | None = None) -> dict: scripts: timeline: - start-time: 0 - end-time: 2 hour + start_time: 0 + end_time: 2 hour speed: "${speed}" events: attack: 30 min @@ -148,7 +148,7 @@ def _json_call(server, tool: str, args: dict | None = None) -> dict: flow: start: do-it steps: - do-it: {type: objective, objective: red-access, on-success: done} + do-it: {type: objective, objective: red-access, on_success: done} done: {type: end} """ @@ -172,12 +172,26 @@ class TestReferenceTools: def test_sdl_overview_returns_content(self, server): text = _call(server, "sdl_overview") assert "SDL" in text - # Both pieces of evidence must be present; an OR disjunction over - # "17" / "sections" would let either drift go undetected. - assert "17" in text - assert "sections" in text.lower() + assert "authoring sections" in text.lower() + assert "17 sections" not in text.lower() assert "nodes" in text + @pytest.mark.parametrize( + "section", + [ + "forwarding_agents", + "action_contracts", + "observation_boundaries", + "outcome_interpretation_rules", + "behavior_specifications", + "evidence_requirements", + ], + ) + def test_sdl_section_reference_covers_live_sections(self, server, section): + text = _call(server, "sdl_section_reference", {"section": section}) + assert "Unknown section" not in text + assert "not found" not in text + def test_sdl_section_reference_valid(self, server): text = _call(server, "sdl_section_reference", {"section": "nodes"}) assert "Nodes" in text @@ -253,6 +267,19 @@ def test_validate_structural_only(self, server): assert "semantic validation was skipped" in text assert "ghost-feature" not in text + def test_validate_requires_explicit_migration_policy(self, server): + strict = _call(server, "sdl_validate", {"sdl_content": "Name: migrated\n"}) + assert "sdl.noncanonical_field" in strict + + migrated = _call( + server, + "sdl_validate", + {"sdl_content": "Name: migrated\n", "accept_migration_syntax": True}, + ) + assert migrated.startswith("VALID") + assert "Source migration advisories (1)" in migrated + assert "sdl.noncanonical_field" in migrated + def test_validate_section_valid(self, server): text = _call( server, @@ -378,8 +405,13 @@ def test_format_returns_normalized_yaml(self, server): {"sdl_content": "Name: x\nNodes:\n sw: {Type: Switch}\n"}, ) - assert payload["status"] == "formatted" + assert payload["status"] == "formatted_with_diagnostics" assert payload["content"].startswith("name: x\nnodes:\n") + assert [item["code"] for item in payload["diagnostics"]] == [ + "sdl.noncanonical_field", + "sdl.noncanonical_field", + "sdl.noncanonical_field", + ] def test_diagnostics_return_structured_errors(self, server): payload = _json_call( @@ -646,6 +678,21 @@ def test_parse_can_run_semantic_validation(self, server): assert payload["stage"] == "semantic_validation" assert "ghost-feature" in payload["diagnostics"][0]["message"] + @pytest.mark.parametrize("tool", ["sdl_parse", "sdl_compile"]) + def test_operation_tools_require_explicit_migration_policy(self, server, tool): + strict = _json_call(server, tool, {"sdl_content": "Name: migrated\n"}) + assert strict["status"] == "invalid" + assert strict["diagnostics"][0]["code"] == "sdl.noncanonical_field" + + migrated = _json_call( + server, + tool, + {"sdl_content": "Name: migrated\n", "accept_migration_syntax": True}, + ) + assert migrated["status"] in {"parsed", "compiled"} + diagnostics = migrated.get("source_diagnostics", migrated["scenario"].get("source_diagnostics", [])) + assert diagnostics[0]["code"] == "sdl.noncanonical_field" + @pytest.mark.parametrize("tool", ["sdl_parse", "sdl_compile"]) def test_operation_tools_preserve_mapping_conflict_diagnostics(self, server, tool): payload = _json_call(server, tool, {"sdl_content": "Name: first\nname: second\n"}) diff --git a/implementations/python/tests/test_reference_backend_components.py b/implementations/python/tests/test_reference_backend_components.py index 3c88cef8c..f28f7ecc0 100644 --- a/implementations/python/tests/test_reference_backend_components.py +++ b/implementations/python/tests/test_reference_backend_components.py @@ -38,7 +38,7 @@ run: type: objective objective: validate - on-success: finish + on_success: finish finish: {type: end} """ diff --git a/implementations/python/tests/test_reference_processor.py b/implementations/python/tests/test_reference_processor.py index 744610d1d..b217227fe 100644 --- a/implementations/python/tests/test_reference_processor.py +++ b/implementations/python/tests/test_reference_processor.py @@ -60,7 +60,7 @@ response: start: run steps: - run: {type: objective, objective: validate, on-success: finish} + run: {type: objective, objective: validate, on_success: finish} finish: {type: end} """ ) @@ -91,7 +91,7 @@ response: start: run steps: - run: {type: objective, objective: validate, on-success: finish} + run: {type: objective, objective: validate, on_success: finish} finish: {type: end} """ ) diff --git a/implementations/python/tests/test_run_300_lifecycle.py b/implementations/python/tests/test_run_300_lifecycle.py index d1f916bf7..90417a27d 100644 --- a/implementations/python/tests/test_run_300_lifecycle.py +++ b/implementations/python/tests/test_run_300_lifecycle.py @@ -98,7 +98,7 @@ def _raw_scenario(): run: type: objective objective: validate - on-success: finish + on_success: finish finish: {{type: end}} """ ) diff --git a/implementations/python/tests/test_runtime_conformance.py b/implementations/python/tests/test_runtime_conformance.py index 64aee3272..77a41e8d7 100644 --- a/implementations/python/tests/test_runtime_conformance.py +++ b/implementations/python/tests/test_runtime_conformance.py @@ -1238,7 +1238,7 @@ def _reference_scenario(node_name: str, *, os_family: str = "linux") -> str: response: start: run steps: - run: {{type: objective, objective: validate, on-success: finish}} + run: {{type: objective, objective: validate, on_success: finish}} finish: {{type: end}} """ diff --git a/implementations/python/tests/test_runtime_control_plane.py b/implementations/python/tests/test_runtime_control_plane.py index 72306398d..eb0154bb6 100644 --- a/implementations/python/tests/test_runtime_control_plane.py +++ b/implementations/python/tests/test_runtime_control_plane.py @@ -51,35 +51,35 @@ def _participant_binding_scenario_yaml() -> str: entities: red-team: role: red -action-contracts: +action_contracts: scan: - semantic-version: 1.0.0 - lifecycle-state: active - behavioral-granularity: atomic - procedure-basis: governed service discovery - realization-profile: backend-declared - fidelity-claim: records participant discovery intent and terminal observation + semantic_version: 1.0.0 + lifecycle_state: active + behavioral_granularity: atomic + procedure_basis: governed service discovery + realization_profile: backend-declared + fidelity_claim: records participant discovery intent and terminal observation preconditions: - - precondition-id: authority-in-scope - precondition-class: authority + - precondition_id: authority-in-scope + precondition_class: authority description: red participant is authorized to scan the web service effects: - - effect-id: terminal-scan-observation - effect-class: observation_effect + - effect_id: terminal-scan-observation + effect_class: observation_effect description: terminal scan observation - evidence-refs: [evidence.scan-output] - failure-classes: [backend_error, unknown] -observation-boundaries: + evidence_refs: [evidence.scan-output] + failure_classes: [backend_error, unknown] +observation_boundaries: red-view: - projection-basis: participant-local projection over observed services - evidence-refs: [evidence.scan-output] - redaction-policy: hidden refs never project without explicit disclosure - latency-profile: terminal observation emitted after state transition commit + projection_basis: participant-local projection over observed services + evidence_refs: [evidence.scan-output] + redaction_policy: hidden refs never project without explicit disclosure + latency_profile: terminal observation emitted after state transition commit agents: red-agent: entity: red-team actions: [scan] - observation-boundaries: [red-view] + observation_boundaries: [red-view] """ @@ -306,7 +306,7 @@ def test_control_plane_submits_orchestration_with_portable_workflow_state(): run: type: objective objective: validate - on-success: finish + on_success: finish finish: {type: end} """) target = create_stub_target() diff --git a/implementations/python/tests/test_runtime_control_plane_api.py b/implementations/python/tests/test_runtime_control_plane_api.py index c45b8bc44..ada54ea87 100644 --- a/implementations/python/tests/test_runtime_control_plane_api.py +++ b/implementations/python/tests/test_runtime_control_plane_api.py @@ -167,7 +167,7 @@ def test_control_plane_api_accepts_orchestration_plan_and_exposes_snapshot(): run: type: objective objective: validate - on-success: finish + on_success: finish finish: {type: end} """) target = create_stub_target() @@ -241,7 +241,7 @@ def test_control_plane_api_exposes_operational_apparatus_summary_to_auditors(): run: type: objective objective: validate - on-success: finish + on_success: finish finish: {type: end} """) target = create_stub_target() @@ -535,7 +535,7 @@ def test_control_plane_api_cancels_workflow_runs(): run: type: objective objective: validate - on-success: finish + on_success: finish finish: {type: end} """) target = create_stub_target() @@ -609,7 +609,7 @@ def test_control_plane_api_reconciles_workflow_timeouts(): run: type: objective objective: validate - on-success: finish + on_success: finish finish: {type: end} """) target = create_stub_target() @@ -699,9 +699,9 @@ def test_control_plane_api_cancellation_triggers_compensation_history(): run: type: objective objective: validate - compensate-with: rollback - on-success: finish - on-failure: finish + compensate_with: rollback + on_success: finish + on_failure: finish finish: {type: end} """) target = create_stub_target() @@ -817,9 +817,9 @@ def test_control_plane_api_timeout_triggers_compensation_history(): run: type: objective objective: validate - compensate-with: rollback - on-success: finish - on-failure: finish + compensate_with: rollback + on_success: finish + on_failure: finish finish: {type: end} """) target = create_stub_target() diff --git a/implementations/python/tests/test_runtime_mail_service.py b/implementations/python/tests/test_runtime_mail_service.py index d302d2541..f4c9fa554 100644 --- a/implementations/python/tests/test_runtime_mail_service.py +++ b/implementations/python/tests/test_runtime_mail_service.py @@ -159,7 +159,7 @@ def test_vm_runtime_mail_service_surface() -> None: assert service.settings[0].provenance == RuntimeMailSettingProvenance.CONFIGURATION_FILE -def test_parser_accepts_kebab_case_runtime_mail_services() -> None: +def test_parser_accepts_canonical_runtime_mail_services() -> None: scenario = parse_sdl( """ name: mail-parser @@ -170,21 +170,21 @@ def test_parser_accepts_kebab_case_runtime_mail_services() -> None: services: - {port: 25, name: smtp} runtime: - mail-services: - - mail-service-id: techvault-mail + mail_services: + - mail_service_id: techvault-mail service: smtp listeners: - - listener-id: smtp-listener + - listener_id: smtp-listener service: smtp protocol: smtp - tls-mode: starttls-available + tls_mode: starttls-available domains: - - domain-id: techvault-domain + - domain_id: techvault-domain name: techvault.local mailboxes: - - mailbox-id: admin-mailbox + - mailbox_id: admin-mailbox address: admin@techvault.local - domain-ref: techvault-domain + domain_ref: techvault-domain """ ) diff --git a/implementations/python/tests/test_runtime_manager.py b/implementations/python/tests/test_runtime_manager.py index 79f9690d0..afb10c6f0 100644 --- a/implementations/python/tests/test_runtime_manager.py +++ b/implementations/python/tests/test_runtime_manager.py @@ -67,7 +67,7 @@ def _full_scenario(): events: kickoff: {conditions: [health]} scripts: - timeline: {start-time: 0, end-time: 60, speed: 1, events: {kickoff: 10}} + timeline: {start_time: 0, end_time: 60, speed: 1, events: {kickoff: 10}} stories: main: {scripts: [timeline]} """) @@ -109,7 +109,7 @@ def _workflow_scenario(): run: type: objective objective: validate - on-success: finish + on_success: finish finish: {type: end} """) @@ -139,7 +139,7 @@ def _workflow_call_scenario(): run: type: objective objective: validate - on-success: finish + on_success: finish finish: {type: end} parent: start: delegate @@ -147,7 +147,7 @@ def _workflow_call_scenario(): delegate: type: call workflow: child - on-success: finish + on_success: finish finish: {type: end} """) diff --git a/implementations/python/tests/test_runtime_models.py b/implementations/python/tests/test_runtime_models.py index 850010a43..3ef7dd0e1 100644 --- a/implementations/python/tests/test_runtime_models.py +++ b/implementations/python/tests/test_runtime_models.py @@ -61,77 +61,77 @@ def test_node_runtime_preserves_runtime_configuration_metadata(self): mounts: - target: /shuffle-database source: aptl_shuffle_data - source-sensitivity: plain - source-kind: volume - filesystem-type: ext4 - read-only: false + source_sensitivity: plain + source_kind: volume + filesystem_type: ext4 + read_only: false options: [rw, nosuid] - options-sensitivity: plain + options_sensitivity: plain propagation: rprivate stability: volume-backed - backend-generated: true - filesystem-inventory: + backend_generated: true + filesystem_inventory: - path: /app/app.py - entry-type: file - owner-user: root - owner-group: root + entry_type: file + owner_user: root + owner_group: root uid: "0" gid: "0" mode: "0644" size: "4096" - content-digest: 4f8c2d - digest-algorithm: sha256 - source-path: src/webapp/app.py + content_digest: 4f8c2d + digest_algorithm: sha256 + source_path: src/webapp/app.py provenance: python-package stability: stable sensitivity: plain - path: /var/log/gunicorn/access.log - entry-type: file + entry_type: file mode: "0600" stability: log sensitivity: operator-secret - local-control-interfaces: - - control-interface-id: docker-sock + local_control_interfaces: + - control_interface_id: docker-sock path: /run/docker.sock kind: unix-socket protocol: docker - bind-source-sensitivity: operator-secret + bind_source_sensitivity: operator-secret access: read-write processes: - name: shufflebackend command: ./shufflebackend user: root - working-directory: /app + working_directory: /app - name: supervisord pid: 1 command: supervisord -n role: supervisor - name: gunicorn - parent-pid: 1 + parent_pid: 1 command: [gunicorn, app:app] role: worker environment: - name: TECHVAULT_ADMIN_PASSWORD - value-classification: redacted + value_classification: redacted provenance: operator - name: SCENARIO_FIXTURE_TOKEN value: fixture-token - value-classification: secret-fixture + value_classification: secret-fixture provenance: compose - linux-capabilities: + linux_capabilities: required: [CAP_NET_ADMIN] effective: CAP_NET_ADMIN - operational-policy: + operational_policy: restart: unless-stopped - resource-limits: + resource_limits: memory: 512 MiB cpu: 0.5 pids: 128 container: entrypoint: [/entrypoint.sh] command: [gunicorn, app:app] - log-driver: json-file - log-options: + log_driver: json-file + log_options: max-size: 10m max-file: "3" namespaces: @@ -141,69 +141,69 @@ def test_node_runtime_preserves_runtime_configuration_metadata(self): userns: host uts: private privileged: false - read-only-rootfs: false - publish-all-ports: false + read_only_rootfs: false + publish_all_ports: false autoremove: false - shm-size: 64 MiB - masked-paths: [/proc/acpi, /proc/kcore] - read-only-paths: /proc/sys - cgroup-parent: /docker - runtime-name: runc + shm_size: 64 MiB + masked_paths: [/proc/acpi, /proc/kcore] + read_only_paths: /proc/sys + cgroup_parent: /docker + runtime_name: runc devices: - - host-path: /dev/null - container-path: /dev/null + - host_path: /dev/null + container_path: /dev/null permissions: rwm - device-cgroup-rules: c 1:3 rwm - seccomp-profile: unconfined - security-opt: [seccomp:unconfined, no-new-privileges] - extra-hosts: + device_cgroup_rules: c 1:3 rwm + seccomp_profile: unconfined + security_opt: [seccomp:unconfined, no-new-privileges] + extra_hosts: - hostname: wazuh-manager address: 172.20.0.10 dns: [8.8.8.8] - dns-options: ndots:0 - dns-search: [techvault.local] - group-add: [adm, "101"] + dns_options: ndots:0 + dns_search: [techvault.local] + group_add: [adm, "101"] health: status: healthy - failing-streak: "0" + failing_streak: "0" log: - start: "2026-05-20T12:00:00Z" end: "2026-05-20T12:00:01Z" - exit-code: "0" + exit_code: "0" output: ok packages: - manager: apk name: musl version: 1.2.4-r2 - software-components: - - component-id: shuffle-backend-app + software_components: + - component_id: shuffle-backend-app name: shuffle-backend version: 1.2.3 - component-type: application + component_type: application provenance: scanner ecosystem: go purl: "pkg:golang/github.com/frikky/shuffle@1.2.3" - package-manager: apk - package-name: shuffle-backend - package-version: 1.2.3-r0 - manifest-path: /app/go.mod - installed-paths: [/app/shufflebackend, /app/go.mod] + package_manager: apk + package_name: shuffle-backend + package_version: 1.2.3-r0 + manifest_path: /app/go.mod + installed_paths: [/app/shufflebackend, /app/go.mod] hashes: - algorithm: sha256 value: abc123 - dependency-manifests: + dependency_manifests: - ecosystem: go path: /app/go.mod format: go-module - package-vulnerabilities: + package_vulnerabilities: - id: CVE-2026-12345 - package-name: musl - installed-version: 1.2.4-r2 - fixed-version: 1.2.5-r0 + package_name: musl + installed_version: 1.2.4-r2 + fixed_version: 1.2.5-r0 severity: high scanner: trivy - image-digest: sha256:abc123 - scan-time: "2026-05-20T12:00:00Z" + image_digest: sha256:abc123 + scan_time: "2026-05-20T12:00:00Z" """) ) @@ -295,22 +295,22 @@ def test_node_runtime_preserves_identity_authority_inventory(self): - {port: 389, name: ldap} - {port: 88, name: kerberos} runtime: - identity-authorities: - - identity-authority-id: techvault-domain + identity_authorities: + - identity_authority_id: techvault-domain kind: domain namespace: techvault.local - domain-name: TECHVAULT + domain_name: TECHVAULT realm: TECHVAULT.LOCAL services: - - {service-id: ldap-endpoint, service: ldap, protocol: ldap, port: 389} + - {service_id: ldap-endpoint, service: ldap, protocol: ldap, port: 389} subjects: - - {subject-id: alice, kind: user, name: alice} - - {subject-id: domain-admins, kind: group, name: Domain Admins} + - {subject_id: alice, kind: user, name: alice} + - {subject_id: domain-admins, kind: group, name: Domain Admins} relationships: - - relationship-id: alice-admin - relationship-type: member-of - source-ref: alice - target-ref: domain-admins + - relationship_id: alice-admin + relationship_type: member-of + source_ref: alice + target_ref: domain-admins """) ) @@ -335,66 +335,66 @@ def test_node_runtime_preserves_file_service_inventory(self): services: - {port: 445, name: smb} runtime: - local-identity: + local_identity: users: - {username: svc-fileshare, uid: 1100, primary_gid: 1100, primary_group: svc-fileshare} - file-services: - - file-service-id: fileshare-smb + file_services: + - file_service_id: fileshare-smb service: smb protocol: smb backend: samba-4.x shares: - - share-id: public + - share_id: public name: public kind: disk - backing-path: /srv/samba/public - read-only: true + backing_path: /srv/samba/public + read_only: true browseable: true - guest-ok: true - - share-id: deploy-keys + guest_ok: true + - share_id: deploy-keys name: deploy_keys kind: disk - backing-path: /srv/samba/deploy_keys - read-only: false + backing_path: /srv/samba/deploy_keys + read_only: false browseable: false - guest-ok: false - valid-users: [svc-fileshare] - write-users: [svc-fileshare] + guest_ok: false + valid_users: [svc-fileshare] + write_users: [svc-fileshare] principals: - - principal-id: nobody + - principal_id: nobody kind: guest name: nobody - external-id: S-1-5-21-0-501 + external_id: S-1-5-21-0-501 status: enabled - credential-classification: no_credential + credential_classification: no_credential origin: built_in - - principal-id: svc-fileshare + - principal_id: svc-fileshare kind: service_account name: svc-fileshare status: enabled - credential-classification: redacted + credential_classification: redacted origin: provisioned - local-user-ref: svc-fileshare - access-rules: - - rule-id: public-read - subject-ref: nobody - resource-ref: public + local_user_ref: svc-fileshare + access_rules: + - rule_id: public-read + subject_ref: nobody + resource_ref: public action: read effect: allow basis: share_config - access-observations: - - observation-id: anon-mount-allowed - subject-ref: anonymous - resource-ref: public + access_observations: + - observation_id: anon-mount-allowed + subject_ref: anonymous + resource_ref: public action: browse outcome: allowed basis: observed_probe - filesystem-inventory: + filesystem_inventory: - path: /srv/samba/public - entry-type: directory + entry_type: directory presence: present - path: /srv/samba/deploy_keys/id_ed25519 - entry-type: file + entry_type: file presence: expected_absent description: Expected deploy-key attempted by setup, absent at capture. """) @@ -539,14 +539,14 @@ def test_objective_windows_and_workflows_resolve_refresh_dependencies(self): events: kickoff: {conditions: [health]} scripts: - timeline: {start-time: 0, end-time: 60, speed: 1, events: {kickoff: 10}} + timeline: {start_time: 0, end_time: 60, speed: 1, events: {kickoff: 10}} stories: main: {scripts: [timeline]} workflows: flow: start: start steps: - start: {type: objective, objective: initial, on-success: branch} + start: {type: objective, objective: initial, on_success: branch} branch: type: decision when: {conditions: [health]} @@ -694,7 +694,7 @@ def test_missing_runtime_graph_refs_emit_partial_model_diagnostics(self): entities: blue: {role: blue} scripts: - timeline: {start-time: 0, end-time: 60, speed: 1, events: {missing-event: 10}} + timeline: {start_time: 0, end_time: 60, speed: 1, events: {missing-event: 10}} stories: main: {scripts: [missing-script]} workflows: @@ -757,9 +757,9 @@ def test_workflow_with_retry_and_step_state_compiles(self): attempt-loop: type: retry objective: attempt - on-success: branch - max-attempts: 3 - on-exhausted: handle-error + on_success: branch + max_attempts: 3 + on_exhausted: handle-error branch: type: decision when: @@ -772,7 +772,7 @@ def test_workflow_with_retry_and_step_state_compiles(self): handle-error: type: objective objective: recover - on-success: done + on_success: done done: {type: end} """) ) @@ -832,15 +832,15 @@ def test_parallel_join_compiles_as_barrier_with_typed_predicate(self): type: parallel branches: [left-branch, right-branch] join: joined - on-failure: recover-step + on_failure: recover-step left-branch: type: objective objective: left - on-success: joined + on_success: joined right-branch: type: objective objective: right - on-success: joined + on_success: joined joined: type: join next: branch @@ -850,13 +850,13 @@ def test_parallel_join_compiles_as_barrier_with_typed_predicate(self): steps: - step: left-branch outcomes: [succeeded] - min-attempts: 2 + min_attempts: 2 then: finish else: recover-step recover-step: type: objective objective: recover - on-success: finish + on_success: finish finish: {type: end} """) ) @@ -925,7 +925,7 @@ def test_module_expansion_compiles_like_flat_scenario(self, tmp_path: Path): run: type: objective objective: validate - on-success: finish + on_success: finish finish: type: end """, @@ -972,7 +972,7 @@ def test_module_expansion_compiles_like_flat_scenario(self, tmp_path: Path): run: type: objective objective: shared.validate - on-success: finish + on_success: finish finish: type: end """ @@ -1019,7 +1019,7 @@ def test_workflow_switch_call_and_timeout_compile_to_explicit_contracts(self): run: type: objective objective: validate - on-success: finish + on_success: finish finish: {type: end} parent: start: route @@ -1034,7 +1034,7 @@ def test_workflow_switch_call_and_timeout_compile_to_explicit_contracts(self): delegate: type: call workflow: child - on-success: finish + on_success: finish finish: {type: end} """ ) @@ -1092,9 +1092,9 @@ def test_workflow_compensation_compiles_to_explicit_contracts(self): run: type: objective objective: validate - compensate-with: rollback - on-success: finish - on-failure: finish + compensate_with: rollback + on_success: finish + on_failure: finish finish: {type: end} """ ) diff --git a/implementations/python/tests/test_runtime_network_detection.py b/implementations/python/tests/test_runtime_network_detection.py index 5253d632a..261c6c1d3 100644 --- a/implementations/python/tests/test_runtime_network_detection.py +++ b/implementations/python/tests/test_runtime_network_detection.py @@ -186,7 +186,7 @@ def test_vm_runtime_network_detection_engine_inventory() -> None: assert engine.control_channels[0].kind == RuntimeNetworkDetectionControlChannelKind.UNIX_SOCKET -def test_parser_accepts_kebab_case_runtime_network_detection_engines() -> None: +def test_parser_accepts_canonical_runtime_network_detection_engines() -> None: scenario = parse_sdl( """ name: detection-engine-parser @@ -196,36 +196,36 @@ def test_parser_accepts_kebab_case_runtime_network_detection_engines() -> None: type: vm resources: {ram: 2 gib, cpu: 2} runtime: - network-sensors: - - network-sensor-id: suricata-sensor + network_sensors: + - network_sensor_id: suricata-sensor implementation: SURICATA - sensor-kind: ids - monitoring-posture: passive - capture-mode: pcap - monitored-network-refs: [dmz-net] - network-detection-engines: - - network-detection-engine-id: suricata-engine + sensor_kind: ids + monitoring_posture: passive + capture_mode: pcap + monitored_network_refs: [dmz-net] + network_detection_engines: + - network_detection_engine_id: suricata-engine implementation: SURICATA - engine-kind: ids - sensor-ref: suricata-sensor - app-layer-protocols: [http, tls, dns] - rule-sources: - - source-id: local-rules + engine_kind: ids + sensor_ref: suricata-sensor + app_layer_protocols: [http, tls, dns] + rule_sources: + - source_id: local-rules kind: local format: suricata-rule - rule-count: "46" - network-sets: - - set-id: home-net + rule_count: "46" + network_sets: + - set_id: home-net kind: home-net name: HOME_NET - network-refs: [dmz-net] - output-streams: - - stream-id: eve-json + network_refs: [dmz-net] + output_streams: + - stream_id: eve-json format: eve-json - event-types: [alert, dns] + event_types: [alert, dns] enabled: true - control-channels: - - channel-id: command-socket + control_channels: + - channel_id: command-socket kind: unix-socket path: /var/run/suricata-command.socket capabilities: rule-reload diff --git a/implementations/python/tests/test_runtime_network_sensor.py b/implementations/python/tests/test_runtime_network_sensor.py index 9757f1c6c..c83f4fca5 100644 --- a/implementations/python/tests/test_runtime_network_sensor.py +++ b/implementations/python/tests/test_runtime_network_sensor.py @@ -107,7 +107,7 @@ def test_vm_runtime_network_sensor_inventory() -> None: assert sensor.monitored_network_refs == ["dmz-net", "internal-net", "security-net"] -def test_parser_accepts_kebab_case_runtime_network_sensors() -> None: +def test_parser_accepts_canonical_runtime_network_sensors() -> None: scenario = parse_sdl( """ name: network-sensor-parser @@ -119,15 +119,15 @@ def test_parser_accepts_kebab_case_runtime_network_sensors() -> None: runtime: network: endpoints: - - {network: dmz-net, ip-address: 172.20.1.50} - network-sensors: - - network-sensor-id: suricata + - {network: dmz-net, ip_address: 172.20.1.50} + network_sensors: + - network_sensor_id: suricata implementation: SURICATA - sensor-kind: ids - monitoring-posture: passive - capture-mode: pcap - capture-interfaces: any - monitored-network-refs: [dmz-net] + sensor_kind: ids + monitoring_posture: passive + capture_mode: pcap + capture_interfaces: any + monitored_network_refs: [dmz-net] infrastructure: dmz-net: 1 suricata: {count: 1, links: [dmz-net]} diff --git a/implementations/python/tests/test_runtime_planner.py b/implementations/python/tests/test_runtime_planner.py index 806f20430..94d2f2a54 100644 --- a/implementations/python/tests/test_runtime_planner.py +++ b/implementations/python/tests/test_runtime_planner.py @@ -445,8 +445,8 @@ def test_unbound_condition_and_inject_refs_invalidate_plan(self): attempt: type: retry objective: defend - on-success: finish - max-attempts: 3 + on_success: finish + max_attempts: 3 finish: {type: end} """, "orchestrator.workflow-feature-unsupported", @@ -476,7 +476,7 @@ def test_unbound_condition_and_inject_refs_invalidate_plan(self): validate: type: objective objective: defend - on-success: branch + on_success: branch branch: type: decision when: @@ -514,15 +514,15 @@ def test_unbound_condition_and_inject_refs_invalidate_plan(self): validate: type: retry objective: defend - on-success: branch - max-attempts: 3 + on_success: branch + max_attempts: 3 branch: type: decision when: steps: - step: validate outcomes: [succeeded] - min-attempts: 2 + min_attempts: 2 then: finish else: finish finish: {type: end} @@ -560,11 +560,11 @@ def test_unbound_condition_and_inject_refs_invalidate_plan(self): left-branch: type: objective objective: left - on-success: joined + on_success: joined right-branch: type: objective objective: right - on-success: joined + on_success: joined joined: type: join next: finish @@ -766,7 +766,7 @@ def test_objective_updates_when_window_dependencies_change(self): events: kickoff: {conditions: [health], description: kickoff} scripts: - timeline: {start-time: 0, end-time: 60, speed: 1, events: {kickoff: 10}} + timeline: {start_time: 0, end_time: 60, speed: 1, events: {kickoff: 10}} workflows: flow: description: primary @@ -783,7 +783,7 @@ def test_objective_updates_when_window_dependencies_change(self): snapshot = _snapshot_from_plan(old_plan) changed_variants = [ - base.replace("end-time: 60", "end-time: 120"), + base.replace("end_time: 60", "end_time: 120"), base.replace("description: kickoff", "description: changed"), base.replace("description: primary", "description: updated"), base.replace("then: finish", "then: finish\n description: changed"), @@ -905,7 +905,7 @@ def test_semantic_capability_validation_catches_real_requirements(self): count: 1 properties: {cidr: 10.0.0.0/24, gateway: 10.0.0.1} acls: - - {direction: in, from-net: corp, action: allow} + - {direction: in, from_net: corp, action: allow} dc: {count: 1, links: [corp]} accounts: admin: {username: administrator, node: dc, spn: LDAP/dc.example.local} @@ -920,14 +920,14 @@ def test_semantic_capability_validation_catches_real_requirements(self): events: kickoff: {conditions: [health]} scripts: - timeline: {start-time: 0, end-time: 60, speed: 1, events: {kickoff: 10}} + timeline: {start_time: 0, end_time: 60, speed: 1, events: {kickoff: 10}} stories: main: {scripts: [timeline]} workflows: flow: start: start steps: - start: {type: objective, objective: defend, on-success: end} + start: {type: objective, objective: defend, on_success: end} end: {type: end} """) ) @@ -1365,14 +1365,14 @@ def test_dependency_ordering_across_domain_plans(self): events: kickoff: {conditions: [health], injects: [mail]} scripts: - timeline: {start-time: 0, end-time: 60, speed: 1, events: {kickoff: 10}} + timeline: {start_time: 0, end_time: 60, speed: 1, events: {kickoff: 10}} stories: main: {scripts: [timeline]} workflows: flow: start: start steps: - start: {type: objective, objective: initial, on-success: end} + start: {type: objective, objective: initial, on_success: end} end: {type: end} """) ), diff --git a/implementations/python/tests/test_runtime_security_monitoring.py b/implementations/python/tests/test_runtime_security_monitoring.py index 6fd7573de..121c6ae61 100644 --- a/implementations/python/tests/test_runtime_security_monitoring.py +++ b/implementations/python/tests/test_runtime_security_monitoring.py @@ -292,7 +292,7 @@ def test_detection_definition_model_preserves_wazuh_semantics() -> None: assert definition.mitre_attack_ids == ["T1558.003"] -def test_parser_accepts_kebab_case_runtime_security_monitoring_managers() -> None: +def test_parser_accepts_canonical_runtime_security_monitoring_managers() -> None: scenario = parse_sdl( """ name: security-monitoring-parser @@ -303,40 +303,40 @@ def test_parser_accepts_kebab_case_runtime_security_monitoring_managers() -> Non services: - {port: 55000, name: wazuh-api} runtime: - security-monitoring-managers: - - security-monitoring-manager-id: techvault-wazuh + security_monitoring_managers: + - security_monitoring_manager_id: techvault-wazuh service: wazuh-api implementation: WAZUH - manager-kind: siem + manager_kind: siem listeners: - - listener-id: manager-api + - listener_id: manager-api service: wazuh-api role: api - auth-required: true - content-sets: - - content-id: wazuh-ruleset + auth_required: true + content_sets: + - content_id: wazuh-ruleset kind: rule-corpus format: wazuh-rule-xml - file-count: 173 - detection-definitions: - - definition-id: rule-301010 + file_count: 173 + detection_definitions: + - definition_id: rule-301010 engine: WAZUH - definition-kind: correlation-rule - native-id: "301010" - content-set-ref: wazuh-ruleset - source-file-ref: /var/ossec/etc/rules/ad_rules.xml - source-start-line: 12 - source-end-line: 35 - digest-algorithm: sha256 - canonical-digest: "1111111111111111111111111111111111111111111111111111111111111111" + definition_kind: correlation-rule + native_id: "301010" + content_set_ref: wazuh-ruleset + source_file_ref: /var/ossec/etc/rules/ad_rules.xml + source_start_line: 12 + source_end_line: 35 + digest_algorithm: sha256 + canonical_digest: "1111111111111111111111111111111111111111111111111111111111111111" loaded: true - parser-accepted: true + parser_accepted: true level: 10 - field-predicates: + field_predicates: - field: win.system.eventID operator: equals value: "4769" - mitre-attack-ids: [T1558.003] + mitre_attack_ids: [T1558.003] """ ) diff --git a/implementations/python/tests/test_runtime_service_listeners.py b/implementations/python/tests/test_runtime_service_listeners.py index e8eb525f9..01f362ed9 100644 --- a/implementations/python/tests/test_runtime_service_listeners.py +++ b/implementations/python/tests/test_runtime_service_listeners.py @@ -99,7 +99,7 @@ def test_vm_runtime_service_listener_surface() -> None: assert listener.readiness.criteria == "HTTP 200" -def test_parser_accepts_kebab_case_runtime_service_listeners() -> None: +def test_parser_accepts_canonical_runtime_service_listeners() -> None: scenario = parse_sdl( """ name: listener-parser @@ -112,15 +112,15 @@ def test_parser_accepts_kebab_case_runtime_service_listeners() -> None: runtime: processes: - {name: nginx, pid: 42} - service-listeners: - - service-listener-id: nginx-http-ipv4 + service_listeners: + - service_listener_id: nginx-http-ipv4 service: http address: 0.0.0.0 port: 80 protocol: TCP - address-family: ipv4 + address_family: ipv4 scope: wildcard - process-ref: nginx + process_ref: nginx readiness: probe: GET / criteria: HTTP 200 @@ -273,66 +273,66 @@ def test_runtime_service_listeners_encode_misp_listener_facts() -> None: - {name: nginx, pid: 11} - {name: supervisord, pid: 1} network: - published-ports: - - {container-port: 80, protocol: tcp, host-ip: 0.0.0.0, host-port: 80} - - {container-port: 443, protocol: tcp, host-ip: 0.0.0.0, host-port: 443} - service-listeners: - - service-listener-id: nginx-http-ipv4 + published_ports: + - {container_port: 80, protocol: tcp, host_ip: 0.0.0.0, host_port: 80} + - {container_port: 443, protocol: tcp, host_ip: 0.0.0.0, host_port: 443} + service_listeners: + - service_listener_id: nginx-http-ipv4 service: http address: 0.0.0.0 port: 80 protocol: tcp - address-family: ipv4 + address_family: ipv4 scope: wildcard - process-ref: nginx - process-name: nginx - published-port-refs: - - {container-port: 80, protocol: tcp, host-ip: 0.0.0.0, host-port: 80} - - service-listener-id: nginx-http-ipv6 + process_ref: nginx + process_name: nginx + published_port_refs: + - {container_port: 80, protocol: tcp, host_ip: 0.0.0.0, host_port: 80} + - service_listener_id: nginx-http-ipv6 service: http address: "::" port: 80 protocol: tcp - address-family: ipv6 + address_family: ipv6 scope: wildcard - process-ref: nginx - - service-listener-id: nginx-https-ipv4 + process_ref: nginx + - service_listener_id: nginx-https-ipv4 service: https address: 0.0.0.0 port: 443 protocol: tcp - address-family: ipv4 + address_family: ipv4 scope: wildcard - process-ref: nginx - - service-listener-id: nginx-https-ipv6 + process_ref: nginx + - service_listener_id: nginx-https-ipv6 service: https address: "::" port: 443 protocol: tcp - address-family: ipv6 + address_family: ipv6 scope: wildcard - process-ref: nginx - - service-listener-id: supervisord-loopback + process_ref: nginx + - service_listener_id: supervisord-loopback address: 127.0.0.1 port: 9001 protocol: tcp - address-family: ipv4 + address_family: ipv4 scope: loopback-only - process-ref: supervisord - - service-listener-id: local-runtime-loopback + process_ref: supervisord + - service_listener_id: local-runtime-loopback address: 127.0.0.1 port: 50000 protocol: tcp - address-family: ipv4 + address_family: ipv4 scope: loopback-only - process-name: runtime-local - - service-listener-id: docker-dns + process_name: runtime-local + - service_listener_id: docker-dns address: 127.0.0.11 port: 53 protocol: udp - address-family: ipv4 + address_family: ipv4 scope: loopback-only - process-name: docker-embedded-dns + process_name: docker-embedded-dns """ ) diff --git a/implementations/python/tests/test_runtime_ssh_server.py b/implementations/python/tests/test_runtime_ssh_server.py index 7d229b9fa..92ac39b0b 100644 --- a/implementations/python/tests/test_runtime_ssh_server.py +++ b/implementations/python/tests/test_runtime_ssh_server.py @@ -627,11 +627,11 @@ def test_ssh_runtime_refs_rewrite_on_module_import(self, tmp_path): services: - {port: 22, name: ssh} runtime: - ssh-servers: - - ssh-server-id: sshd-default + ssh_servers: + - ssh_server_id: sshd-default service: ssh - match-rules: - - match-id: m-kali + match_rules: + - match_id: m-kali criteria: - {kind: user, pattern: kali} relationships: diff --git a/implementations/python/tests/test_sdl_canonicalization.py b/implementations/python/tests/test_sdl_canonicalization.py new file mode 100644 index 000000000..d50c43d50 --- /dev/null +++ b/implementations/python/tests/test_sdl_canonicalization.py @@ -0,0 +1,217 @@ +"""Canonical semantic identity tests for ``aces-sdl-semantic/v1``.""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest +from aces_sdl import ( + SDL_CANONICAL_PROFILE, + SDLMigrationPolicy, + SDLParseError, + canonical_sdl_bytes, + canonical_sdl_digest, + format_sdl_source, + instantiate_scenario, + parse_sdl, + parse_sdl_file, +) + + +def test_semantically_identical_source_spellings_have_identical_canonical_bytes() -> None: + canonical = parse_sdl( + textwrap.dedent( + """ + name: equivalent + description: same + nodes: + web: {type: switch} + """ + ) + ) + migrated = parse_sdl( + textwrap.dedent( + """ + Description: same + Name: equivalent + nodes: + web: {Type: SWITCH} + """ + ), + migration_policy=SDLMigrationPolicy.ACCEPT, + ) + + assert canonical_sdl_bytes(canonical) == canonical_sdl_bytes(migrated) + + +def test_format_round_trip_preserves_semantic_identity_and_canonical_bytes() -> None: + source = textwrap.dedent( + """ + Description: same + Name: round-trip + nodes: + Web-App: {Type: SWITCH} + """ + ) + before = parse_sdl(source, migration_policy=SDLMigrationPolicy.ACCEPT) + + formatted = format_sdl_source(source).content + after = parse_sdl(formatted) + + assert canonical_sdl_bytes(after) == canonical_sdl_bytes(before) + assert format_sdl_source(formatted).content == formatted + + +def test_canonical_digest_is_profile_labelled_and_repeatable() -> None: + scenario = parse_sdl("name: digest-example\n") + + first = canonical_sdl_digest(scenario) + second = canonical_sdl_digest(scenario) + + assert first == second + assert first.profile == SDL_CANONICAL_PROFILE == "aces-sdl-semantic/v1" + assert first.algorithm == "sha256" + assert first.value.startswith("sha256:") + assert len(first.value) == len("sha256:") + 64 + assert first.as_dict() == { + "profile": "aces-sdl-semantic/v1", + "algorithm": "sha256", + "value": first.value, + } + + +def test_canonical_bytes_preserve_authored_field_presence() -> None: + omitted = parse_sdl("name: presence\n") + explicit = parse_sdl("name: presence\ndescription: ''\n") + + assert canonical_sdl_bytes(omitted) != canonical_sdl_bytes(explicit) + + +def test_canonical_bytes_do_not_normalize_unicode() -> None: + composed = parse_sdl("name: caf\N{LATIN SMALL LETTER E WITH ACUTE}\n") + decomposed = parse_sdl("name: cafe\N{COMBINING ACUTE ACCENT}\n") + + assert canonical_sdl_bytes(composed) != canonical_sdl_bytes(decomposed) + + +def test_canonical_bytes_are_map_order_independent_and_array_order_sensitive() -> None: + first = parse_sdl( + textwrap.dedent( + """ + name: ordering + module: + id: aces/ordering + version: 1.0.0 + parameters: [alpha, beta] + """ + ) + ) + reordered_map = parse_sdl( + textwrap.dedent( + """ + module: + parameters: [alpha, beta] + version: 1.0.0 + id: aces/ordering + name: ordering + """ + ) + ) + reordered_array = parse_sdl( + textwrap.dedent( + """ + name: ordering + module: + id: aces/ordering + version: 1.0.0 + parameters: [beta, alpha] + """ + ) + ) + + assert canonical_sdl_bytes(first) == canonical_sdl_bytes(reordered_map) + assert canonical_sdl_bytes(first) != canonical_sdl_bytes(reordered_array) + + +def test_canonical_identity_requires_validated_authoring_scenario() -> None: + unvalidated = parse_sdl("name: unvalidated\n", skip_semantic_validation=True) + with pytest.raises(SDLParseError, match="semantic validation"): + canonical_sdl_bytes(unvalidated) + + validated = parse_sdl("name: instantiated\n") + instantiated = instantiate_scenario(validated) + with pytest.raises(SDLParseError, match="authoring scenario"): + canonical_sdl_bytes(instantiated) + + +def test_canonical_payload_carries_profile_and_module_provenance_channels() -> None: + payload = canonical_sdl_bytes(parse_sdl("name: envelope\n")) + + assert payload.startswith(b'{"module_node_variable_refs":{}') + assert b'"module_variable_specs":{}' in payload + assert b'"profile":"aces-sdl-semantic/v1"' in payload + assert b'"scenario":{"name":"envelope"}' in payload + + +def test_canonical_identity_rejects_values_outside_the_jcs_integer_domain() -> None: + scenario = parse_sdl( + """\ +name: unsafe-integer +variables: + too_large: + type: integer + default: 9007199254740992 +""" + ) + + with pytest.raises(SDLParseError, match="canonicalization failed"): + canonical_sdl_bytes(scenario) + + +def test_canonical_payload_commits_to_imported_variable_provenance(tmp_path: Path) -> None: + module = tmp_path / "module.yaml" + module.write_text( + """\ +name: module +module: + id: acme/module + version: 1.0.0 + parameters: [image_os] + exports: + nodes: [host] + infrastructure: [host] +variables: + image_os: + type: string + default: linux + allowed_values: [linux] +nodes: + host: + type: vm + os: ${image_os} + resources: {ram: 1 gib, cpu: 1} +infrastructure: + host: {count: 1} +""", + encoding="utf-8", + ) + root = tmp_path / "root.yaml" + root.write_text( + """\ +name: root +imports: + - path: module.yaml + namespace: imported +""", + encoding="utf-8", + ) + + scenario = parse_sdl_file(root) + payload = canonical_sdl_bytes(scenario) + + variable_name = "imported.__private.image_os" + assert scenario.module_variable_specs[variable_name]["allowed_values"] == ["linux"] + assert scenario.module_node_variable_refs["imported.host"]["os"] == variable_name + assert b'"module_variable_specs":{"imported.__private.image_os"' in payload + assert b'"module_node_variable_refs":{"imported.host"' in payload diff --git a/implementations/python/tests/test_sdl_catalog_parity.py b/implementations/python/tests/test_sdl_catalog_parity.py new file mode 100644 index 000000000..8136be424 --- /dev/null +++ b/implementations/python/tests/test_sdl_catalog_parity.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import shutil +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from tools.check_sdl_catalog_parity import ( # noqa: E402 + CatalogParseError, + evaluate_sdl_catalog_parity, + main, + parse_top_level_catalog, +) + +_CATALOG_PATHS = ( + "specs/sdl/sections.md", + "specs/sdl/references.md", + "specs/sdl/runtime-inventory.md", + "contracts/schemas/sdl/sdl-authoring-input-v1.json", +) + + +def _seed_repo(tmp_path: Path) -> Path: + for relative in _CATALOG_PATHS: + source = REPO_ROOT / relative + target = tmp_path / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, target) + return tmp_path + + +def _replace(tmp_path: Path, relative: str, old: str, new: str) -> None: + path = tmp_path / relative + text = path.read_text(encoding="utf-8") + assert old in text + path.write_text(text.replace(old, new, 1), encoding="utf-8") + + +def _rule_ids(tmp_path: Path) -> set[str]: + return {failure.rule_id for failure in evaluate_sdl_catalog_parity(tmp_path)} + + +@pytest.mark.parametrize( + ("old", "new", "rule_id"), + [ + ("| `behavior_specifications` |", "| `behavior_profiles` |", "sdl-catalog-field-set"), + ("| `nodes` | section | map |", "| `nodes` | section | list |", "sdl-catalog-field-shape"), + ("| `version` | metadata | scalar |", "| `version` | metadata | map |", "sdl-catalog-field-shape"), + ("optional; default `*`", "required", "sdl-catalog-field-default"), + ], +) +def test_top_level_catalog_drift_is_flagged(tmp_path: Path, old: str, new: str, rule_id: str) -> None: + repo = _seed_repo(tmp_path) + _replace(repo, "specs/sdl/sections.md", old, new) + assert rule_id in _rule_ids(repo) + + +def test_checked_summary_drift_is_flagged(tmp_path: Path) -> None: + repo = _seed_repo(tmp_path) + _replace(repo, "specs/sdl/sections.md", "sections=23", "sections=22") + assert "sdl-catalog-summary" in _rule_ids(repo) + + +def test_identity_classification_drift_is_flagged(tmp_path: Path) -> None: + repo = _seed_repo(tmp_path) + _replace(repo, "specs/sdl/sections.md", "| `map_key` | catalogued |", "| `node_id` | catalogued |") + assert "sdl-catalog-field-identity" in _rule_ids(repo) + + +def test_reference_domain_drift_is_flagged(tmp_path: Path) -> None: + repo = _seed_repo(tmp_path) + _replace( + repo, + "specs/sdl/references.md", + "| `behavior_specifications.*.authority_scope_refs[]` | `targetable` |", + "| `behavior_specifications.*.authority_scope_refs[]` | `any` |", + ) + assert "sdl-catalog-reference-domain" in _rule_ids(repo) + + +def test_non_completion_reference_domain_drift_is_flagged(tmp_path: Path) -> None: + repo = _seed_repo(tmp_path) + _replace( + repo, + "specs/sdl/references.md", + "| `action_contracts.*.interactions.*.related_action_ref` | `action_contracts` |", + "| `action_contracts.*.interactions.*.related_action_ref` | `any` |", + ) + assert "sdl-catalog-reference-row" in _rule_ids(repo) + + +@pytest.mark.parametrize( + ("old", "new"), + [ + ("| `features` | semantic validation |", "| `features` | |"), + ("| fatal dangling or ambiguous | [node validator]", "| | [node validator]"), + ("[node validator](../../implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py)", ""), + ], +) +def test_reference_row_classification_drift_is_flagged(tmp_path: Path, old: str, new: str) -> None: + repo = _seed_repo(tmp_path) + _replace(repo, "specs/sdl/references.md", old, new) + assert "sdl-catalog-reference-row" in _rule_ids(repo) + + +def test_missing_behavior_reference_edge_is_flagged(tmp_path: Path) -> None: + repo = _seed_repo(tmp_path) + _replace( + repo, + "specs/sdl/references.md", + "| `behavior_specifications.*.participant_refs[]` |", + "| `behavior_profiles.*.participant_refs[]` |", + ) + assert "sdl-catalog-behavior-edge" in _rule_ids(repo) + + +def test_runtime_child_tree_drift_is_flagged(tmp_path: Path) -> None: + repo = _seed_repo(tmp_path) + _replace( + repo, + "specs/sdl/runtime-inventory.md", + "zones:zone_id/rrsets:rrset_id", + "zones:zone_id/records:record_id", + ) + assert "sdl-catalog-runtime-family" in _rule_ids(repo) + + +def test_duplicate_top_level_row_is_rejected() -> None: + body = """# Catalog + +## Complete top-level field catalog + +| Field | Kind | Shape | Lifecycle | Presence/default | Identity | References | Semantic owner | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `name` | metadata | scalar | normalized | required | scenario_name | none | `specs/sdl/document-model.md` | +| `name` | metadata | scalar | normalized | required | scenario_name | none | `specs/sdl/document-model.md` | +""" + with pytest.raises(CatalogParseError, match="duplicate"): + parse_top_level_catalog(body) + + +def test_catalog_parser_rejects_oversized_input() -> None: + body = "x" * (512 * 1024 + 1) + with pytest.raises(CatalogParseError, match="size limit"): + parse_top_level_catalog(body) + + +def test_cli_reports_json_failure(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + repo = _seed_repo(tmp_path) + _replace(repo, "specs/sdl/sections.md", "sections=23", "sections=22") + assert main(["--repo-root", str(repo), "--json"]) == 1 + assert '"rule_id": "sdl-catalog-summary"' in capsys.readouterr().out + + +@pytest.mark.integration +def test_live_sdl_catalogs_match_authorities() -> None: + assert evaluate_sdl_catalog_parity(REPO_ROOT) == [] diff --git a/implementations/python/tests/test_sdl_format_cli.py b/implementations/python/tests/test_sdl_format_cli.py new file mode 100644 index 000000000..0cfbd5697 --- /dev/null +++ b/implementations/python/tests/test_sdl_format_cli.py @@ -0,0 +1,52 @@ +"""Tests for canonical SDL formatting and migration CLI surfaces.""" + +from __future__ import annotations + +from aces_cli.main import app +from aces_sdl import format_sdl_source +from typer.testing import CliRunner + + +def test_format_api_migrates_fields_and_expands_shorthands() -> None: + result = format_sdl_source( + """\ +Name: migrate-me +Nodes: + Web-App: + Type: VM + roles: {admin: operator} +infrastructure: + Web-App: 1 +""" + ) + + assert result.content.startswith("name: migrate-me\nnodes:\n Web-App:\n type: vm\n") + assert "username: operator" in result.content + assert "count: 1" in result.content + assert [item.code for item in result.diagnostics] == [ + "sdl.noncanonical_field", + "sdl.noncanonical_field", + "sdl.noncanonical_field", + ] + + +def test_format_cli_supports_stdout_write_and_check(tmp_path) -> None: + source = tmp_path / "scenario.yaml" + source.write_text("Name: cli-format\n", encoding="utf-8") + runner = CliRunner() + + stdout_result = runner.invoke(app, ["sdl", "format", str(source)]) + assert stdout_result.exit_code == 0 + assert "name: cli-format" in stdout_result.stdout + assert "sdl.noncanonical_field" in stdout_result.stderr + assert source.read_text(encoding="utf-8") == "Name: cli-format\n" + + check_result = runner.invoke(app, ["sdl", "format", str(source), "--check"]) + assert check_result.exit_code == 1 + + write_result = runner.invoke(app, ["sdl", "format", str(source), "--write"]) + assert write_result.exit_code == 0 + assert source.read_text(encoding="utf-8") == "name: cli-format\n" + + clean_check = runner.invoke(app, ["sdl", "format", str(source), "--check"]) + assert clean_check.exit_code == 0 diff --git a/implementations/python/tests/test_sdl_models.py b/implementations/python/tests/test_sdl_models.py index 63a09bf36..6ec89c6c9 100644 --- a/implementations/python/tests/test_sdl_models.py +++ b/implementations/python/tests/test_sdl_models.py @@ -2541,7 +2541,7 @@ def test_objective_step(self): step = WorkflowStep( type="objective", objective="verify-release", - **{"on-success": "done"}, + **{"on_success": "done"}, ) assert step.type == WorkflowStepType.OBJECTIVE assert step.objective == "verify-release" @@ -2564,7 +2564,7 @@ def test_valid_workflow(self): "validate": { "type": "objective", "objective": "verify-release", - "on-success": "done", + "on_success": "done", }, "done": {"type": "end"}, }, @@ -2576,7 +2576,7 @@ def test_retry_step(self): step = WorkflowStep( type="retry", objective="verify-release", - **{"on-success": "done", "max-attempts": 5}, + **{"on_success": "done", "max_attempts": 5}, ) assert step.type == WorkflowStepType.RETRY assert step.objective == "verify-release" @@ -2609,7 +2609,7 @@ def test_call_step(self): step = WorkflowStep( type="call", workflow="child", - **{"on-success": "done"}, + **{"on_success": "done"}, ) assert step.type == WorkflowStepType.CALL assert step.workflow == "child" @@ -2622,7 +2622,7 @@ def test_workflow_timeout_scalar_parses_to_policy(self): "validate": { "type": "objective", "objective": "verify-release", - "on-success": "done", + "on_success": "done", }, "done": {"type": "end"}, }, @@ -2639,8 +2639,8 @@ def test_retry_step_forbids_decision_fields(self): type="retry", objective="verify-release", **{ - "on-success": "done", - "max-attempts": 3, + "on_success": "done", + "max_attempts": 3, "then": "a", "else": "b", }, @@ -2651,14 +2651,14 @@ def test_retry_max_attempts_must_be_positive(self): WorkflowStep( type="retry", objective="verify-release", - **{"on-success": "done", "max-attempts": 0}, + **{"on_success": "done", "max_attempts": 0}, ) def test_retry_max_attempts_accepts_variable(self): step = WorkflowStep( type="retry", objective="verify-release", - **{"on-success": "done", "max-attempts": "${max_retries}"}, + **{"on_success": "done", "max_attempts": "${max_retries}"}, ) assert step.max_attempts == "${max_retries}" @@ -2666,7 +2666,7 @@ def test_on_failure_on_objective_step(self): step = WorkflowStep( type="objective", objective="verify-release", - **{"on-success": "done", "on-failure": "recover"}, + **{"on_success": "done", "on_failure": "recover"}, ) assert step.on_failure == "recover" @@ -2675,7 +2675,7 @@ def test_on_failure_on_parallel_step(self): type="parallel", branches=["a", "b"], join="done", - **{"on-failure": "recover"}, + **{"on_failure": "recover"}, ) assert step.on_failure == "recover" @@ -2684,9 +2684,9 @@ def test_on_exhausted_accepts_variable(self): type="retry", objective="verify-release", **{ - "on-success": "done", - "max-attempts": 3, - "on-exhausted": "${recovery_step}", + "on_success": "done", + "max_attempts": 3, + "on_exhausted": "${recovery_step}", }, ) assert step.on_exhausted == "${recovery_step}" @@ -2699,7 +2699,7 @@ def test_on_failure_forbidden_on_decision_step(self): WorkflowStep( type="decision", when={"conditions": ["c1"]}, - **{"then": "a", "else": "b", "on-failure": "recover"}, + **{"then": "a", "else": "b", "on_failure": "recover"}, ) def test_join_step_requires_next(self): @@ -2707,7 +2707,7 @@ def test_join_step_requires_next(self): WorkflowStep(type="join") def test_step_state_predicate(self): - pred = WorkflowPredicate(steps=[{"step": "step-a", "outcomes": ["failed"], "min-attempts": 2}]) + pred = WorkflowPredicate(steps=[{"step": "step-a", "outcomes": ["failed"], "min_attempts": 2}]) assert pred.steps[0].step == "step-a" assert pred.steps[0].outcomes == [WorkflowStepOutcome.FAILED] assert pred.steps[0].min_attempts == 2 diff --git a/implementations/python/tests/test_sdl_parser.py b/implementations/python/tests/test_sdl_parser.py index 36bf61e75..1586dd9b1 100644 --- a/implementations/python/tests/test_sdl_parser.py +++ b/implementations/python/tests/test_sdl_parser.py @@ -1,9 +1,10 @@ -"""Tests for SDL parser — YAML loading, key normalization, shorthands.""" +"""Tests for SDL parsing, canonical fields, migration, and shorthands.""" import re from pathlib import Path import pytest +from aces_sdl import SDLMigrationPolicy from aces.core.sdl import instantiate_scenario from aces.core.sdl._errors import SDLParseError, SDLValidationError @@ -17,12 +18,20 @@ def test_lowercase_keys(self): assert "sw" in s.nodes def test_uppercase_keys(self): - """Pydantic field keys are normalized but user-defined names are preserved.""" - s = parse_sdl("Name: test\nNodes:\n SW:\n Type: Switch") + """Explicit migration normalizes fields while preserving identifiers.""" + s = parse_sdl( + "Name: test\nNodes:\n SW:\n Type: Switch", + migration_policy=SDLMigrationPolicy.ACCEPT, + ) assert "SW" in s.nodes # user-defined name preserved as-is assert s.nodes["SW"].type == NodeType.SWITCH # enum value normalized + assert [diagnostic.code for diagnostic in s.source_diagnostics] == [ + "sdl.noncanonical_field", + "sdl.noncanonical_field", + "sdl.noncanonical_field", + ] - def test_hyphenated_keys(self): + def test_hyphenated_identifier_keys(self): sdl = """ name: test nodes: @@ -41,7 +50,7 @@ def test_hyphenated_keys(self): def test_integer_keys_in_user_defined_mapping_are_rejected(self): # YAML lets authors write a bare ``1:`` as a key, which yaml.safe_load # parses as an integer. User-defined hashmap keys (node names, role - # names, etc.) bypass the field-key normalization pass so the + # names, etc.) bypass the structural-field pass so the # integer survives until Pydantic. Closed-world ``SDLModel`` rejects # non-string keys; this test pins that contract so a future loosening # of the dict-key types (or a silent coerce-to-string) surfaces as a @@ -207,7 +216,7 @@ def test_workflows_section_parses(self): validate: type: objective objective: validate-release - on-success: finish + on_success: finish finish: type: end """ @@ -236,84 +245,84 @@ def test_runtime_configuration_parses_without_overloading_other_sections(self): mounts: - target: /shuffle-database source: aptl_shuffle_data - source-sensitivity: plain - source-kind: volume - filesystem-type: ext4 - read-only: false + source_sensitivity: plain + source_kind: volume + filesystem_type: ext4 + read_only: false options: [rw, nosuid] - options-sensitivity: plain + options_sensitivity: plain propagation: rprivate stability: volume-backed - backend-generated: true - filesystem-inventory: + backend_generated: true + filesystem_inventory: - path: /app/app.py - entry-type: file - owner-user: root - owner-group: root + entry_type: file + owner_user: root + owner_group: root uid: "0" gid: "0" mode: "0644" size: "4096" - content-digest: 4f8c2d - digest-algorithm: sha256 - source-path: src/webapp/app.py + content_digest: 4f8c2d + digest_algorithm: sha256 + source_path: src/webapp/app.py provenance: python-package stability: stable sensitivity: plain - path: /var/log/gunicorn/access.log - entry-type: file + entry_type: file mode: "0600" stability: log sensitivity: operator-secret - local-control-interfaces: - - control-interface-id: docker-sock + local_control_interfaces: + - control_interface_id: docker-sock path: /run/docker.sock kind: unix-socket protocol: docker - bind-source-sensitivity: operator-secret + bind_source_sensitivity: operator-secret access: read-write processes: - name: shufflebackend command: ./shufflebackend user: root - working-directory: /app + working_directory: /app - name: supervisord pid: 1 command: supervisord -n role: supervisor - name: gunicorn - parent-pid: 1 + parent_pid: 1 command: [gunicorn, app:app] role: worker environment: - name: TECHVAULT_ADMIN_PASSWORD - value-classification: redacted + value_classification: redacted provenance: operator - name: SCENARIO_FIXTURE_TOKEN value: fixture-token - value-classification: secret-fixture + value_classification: secret-fixture provenance: compose - linux-capabilities: + linux_capabilities: required: [CAP_NET_ADMIN] effective: CAP_NET_ADMIN - process-overrides: + process_overrides: - subject: name: gunicorn - parent-pid: 1 + parent_pid: 1 scope: subtree drop: [cap-audit-control] description: interactive participant shell - operational-policy: + operational_policy: restart: unless-stopped - resource-limits: + resource_limits: memory: 512 MiB cpu: 0.5 pids: 128 container: entrypoint: [/entrypoint.sh] command: [gunicorn, app:app] - log-driver: json-file - log-options: + log_driver: json-file + log_options: max-size: 10m max-file: "3" namespaces: @@ -323,74 +332,74 @@ def test_runtime_configuration_parses_without_overloading_other_sections(self): userns: host uts: private privileged: false - read-only-rootfs: false - publish-all-ports: false + read_only_rootfs: false + publish_all_ports: false autoremove: false - shm-size: 64 MiB - masked-paths: [/proc/acpi, /proc/kcore] - read-only-paths: /proc/sys - cgroup-parent: /docker - runtime-name: runc - init-process: + shm_size: 64 MiB + masked_paths: [/proc/acpi, /proc/kcore] + read_only_paths: /proc/sys + cgroup_parent: /docker + runtime_name: runc + init_process: enabled: true implementation: docker-init - executable-path: /sbin/docker-init - reaps-children: true + executable_path: /sbin/docker-init + reaps_children: true argv: [/sbin/docker-init, "--", /entrypoint.sh] devices: - - host-path: /dev/null - container-path: /dev/null + - host_path: /dev/null + container_path: /dev/null permissions: rwm - device-cgroup-rules: c 1:3 rwm - extra-hosts: + device_cgroup_rules: c 1:3 rwm + extra_hosts: - hostname: wazuh-manager address: 172.20.0.10 dns: [8.8.8.8] - dns-options: ndots:0 - dns-search: [techvault.local] - group-add: [adm, "101"] + dns_options: ndots:0 + dns_search: [techvault.local] + group_add: [adm, "101"] health: status: healthy - failing-streak: "0" + failing_streak: "0" log: - start: "2026-05-20T12:00:00Z" end: "2026-05-20T12:00:01Z" - exit-code: "0" + exit_code: "0" output: ok packages: - manager: apk name: musl version: 1.2.4-r2 - software-components: - - component-id: shuffle-backend-app + software_components: + - component_id: shuffle-backend-app name: shuffle-backend version: 1.2.3 - component-type: application + component_type: application provenance: scanner ecosystem: go purl: "pkg:golang/github.com/frikky/shuffle@1.2.3" cpe: "cpe:2.3:a:shuffle:shuffle:1.2.3:*:*:*:*:*:*:*" - package-manager: apk - package-name: shuffle-backend - package-version: 1.2.3-r0 - manifest-path: /app/go.mod - installed-paths: [/app/shufflebackend, /app/go.mod] + package_manager: apk + package_name: shuffle-backend + package_version: 1.2.3-r0 + manifest_path: /app/go.mod + installed_paths: [/app/shufflebackend, /app/go.mod] hashes: - algorithm: sha256 value: abc123 - dependency-manifests: + dependency_manifests: - ecosystem: go path: /app/go.mod format: go-module - package-vulnerabilities: + package_vulnerabilities: - id: CVE-2026-12345 - package-name: musl - installed-version: 1.2.4-r2 - fixed-version: 1.2.5-r0 + package_name: musl + installed_version: 1.2.4-r2 + fixed_version: 1.2.5-r0 severity: high scanner: trivy - image-digest: sha256:abc123 - scan-time: "2026-05-20T12:00:00Z" + image_digest: sha256:abc123 + scan_time: "2026-05-20T12:00:00Z" """ scenario = parse_sdl(sdl) node = scenario.nodes["shuffle-backend"] @@ -489,7 +498,7 @@ def test_runtime_configuration_parses_without_overloading_other_sections(self): assert node.runtime.package_vulnerabilities[0].image_digest == "sha256:abc123" assert node.runtime.package_vulnerabilities[0].scan_time == "2026-05-20T12:00:00Z" - def test_runtime_local_identity_inventory_parses_with_kebab_keys(self): + def test_runtime_local_identity_inventory_parses_with_canonical_keys(self): sdl = """ name: techvault-identity-inventory nodes: @@ -497,13 +506,13 @@ def test_runtime_local_identity_inventory_parses_with_kebab_keys(self): type: vm os: linux runtime: - local-identity: + local_identity: description: getent passwd/group capture users: - username: root uid: 0 - primary-gid: 0 - primary-group: root + primary_gid: 0 + primary_group: root gecos: root home: /root shell: /bin/bash @@ -511,12 +520,12 @@ def test_runtime_local_identity_inventory_parses_with_kebab_keys(self): stability: stable - username: www-data uid: 33 - primary-gid: 33 - primary-group: www-data + primary_gid: 33 + primary_group: www-data home: /var/www shell: /usr/sbin/nologin - supplemental-groups: [wazuh] - no-login: true + supplemental_groups: [wazuh] + no_login: true provenance: package groups: - name: root @@ -525,10 +534,10 @@ def test_runtime_local_identity_inventory_parses_with_kebab_keys(self): - name: wazuh gid: 101 members: [www-data] - sudo-rules: + sudo_rules: - principal: operator - principal-kind: user - run-as-users: [root] + principal_kind: user + run_as_users: [root] commands: ["/usr/bin/systemctl restart gunicorn"] nopasswd: true """ @@ -562,13 +571,13 @@ def test_runtime_local_identity_uid_variable_substitutes_on_instantiation(self): type: vm os: linux runtime: - local-identity: + local_identity: users: - username: wazuh uid: ${svc_uid} home: /var/ossec shell: /usr/sbin/nologin - no-login: true + no_login: true """ raw = parse_sdl(sdl) assert raw.nodes["techvault-webapp"].runtime.local_identity.users[0].uid == "${svc_uid}" @@ -577,7 +586,7 @@ def test_runtime_local_identity_uid_variable_substitutes_on_instantiation(self): assert user.uid == 999 assert user.no_login is True - def test_runtime_network_realization_parses_with_kebab_keys(self): + def test_runtime_network_realization_parses_with_canonical_keys(self): sdl = """ name: techvault-network-realization nodes: @@ -593,30 +602,30 @@ def test_runtime_network_realization_parses_with_kebab_keys(self): domainname: techvault.local endpoints: - network: aptl-dmz - network-id: net-a1b2c3d4e5f6 - network-id-stability: stable - endpoint-id: ep-1a2b3c4d5e6f - endpoint-id-stability: ephemeral - backend-generated: true - ip-address: 172.20.0.20 - ip-prefix-length: "24" + network_id: net-a1b2c3d4e5f6 + network_id_stability: stable + endpoint_id: ep-1a2b3c4d5e6f + endpoint_id_stability: ephemeral + backend_generated: true + ip_address: 172.20.0.20 + ip_prefix_length: "24" gateway: 172.20.0.1 - mac-address: 02:42:ac:14:00:14 + mac_address: 02:42:ac:14:00:14 aliases: [aptl-webapp, webapp] - dns-names: [aptl-webapp, webapp] - generated-dns-names: [a1b2c3d4e5f6] + dns_names: [aptl-webapp, webapp] + generated_dns_names: [a1b2c3d4e5f6] backend: driver: bridge - ipam-driver: default - driver-options: + ipam_driver: default + driver_options: com.docker.network.bridge.name: br-dmz - ipam-options: + ipam_options: com.docker.network.driver.mtu: "1500" - published-ports: - - container-port: "8080" + published_ports: + - container_port: "8080" protocol: tcp - host-ip: 127.0.0.1 - host-port: "8080" + host_ip: 127.0.0.1 + host_port: "8080" infrastructure: aptl-dmz: properties: @@ -641,7 +650,7 @@ def test_runtime_network_realization_parses_with_kebab_keys(self): assert endpoint.aliases == ["aptl-webapp", "webapp"] assert endpoint.dns_names == ["aptl-webapp", "webapp"] assert endpoint.generated_dns_names == ["a1b2c3d4e5f6"] - # Backend-native option keys are preserved verbatim (not key-normalized). + # Backend-native option keys are preserved verbatim as literal-map data. assert endpoint.backend.driver == "bridge" assert endpoint.backend.driver_options == {"com.docker.network.bridge.name": "br-dmz"} assert endpoint.backend.ipam_options == {"com.docker.network.driver.mtu": "1500"} @@ -668,7 +677,7 @@ def test_runtime_network_ip_variable_substitutes_on_instantiation(self): network: endpoints: - network: aptl-dmz - ip-address: ${webapp_ip} + ip_address: ${webapp_ip} infrastructure: aptl-dmz: properties: @@ -681,7 +690,7 @@ def test_runtime_network_ip_variable_substitutes_on_instantiation(self): endpoint = instantiated.nodes["techvault-webapp"].runtime.network.endpoints[0] assert endpoint.ip_address == "172.20.0.20" - def test_source_build_provenance_parses_with_kebab_keys(self): + def test_source_build_provenance_parses_with_canonical_keys(self): sdl = """ name: techvault-build-provenance nodes: @@ -692,9 +701,9 @@ def test_source_build_provenance_parses_with_kebab_keys(self): name: techvault-webapp version: local build: - base-image: python:3.12-slim - base-image-digest: sha256:deadbeef - dockerfile-path: containers/webapp/Dockerfile + base_image: python:3.12-slim + base_image_digest: sha256:deadbeef + dockerfile_path: containers/webapp/Dockerfile instructions: - instruction: from arguments: [python:3.12-slim] @@ -702,40 +711,40 @@ def test_source_build_provenance_parses_with_kebab_keys(self): arguments: [webapp/app.py, /app/app.py] layers: - digest: sha256:layer1 - created-by: FROM python:3.12-slim + created_by: FROM python:3.12-slim size: "31000000" - - created-by: ENV APP_HOME=/app + - created_by: ENV APP_HOME=/app empty: true - build-args: + build_args: - name: APP_VERSION value: 1.4.2 - value-classification: plain + value_classification: plain - name: PIP_INDEX_TOKEN - value-classification: redacted - copied-sources: - - source-path: webapp/app.py - destination-path: /app/app.py + value_classification: redacted + copied_sources: + - source_path: webapp/app.py + destination_path: /app/app.py config: entrypoint: [/entrypoint.sh] command: [gunicorn, app:app] - working-directory: /app - exposed-ports: [8080/tcp] + working_directory: /app + exposed_ports: [8080/tcp] labels: org.opencontainers.image.source: https://example.test/techvault com.Example.Tier: webapp - default-environment: + default_environment: - name: APP_HOME value: /app - source-inputs: + source_inputs: - identifier: webapp-app - source-path: webapp/app.py - destination-path: /app/app.py + source_path: webapp/app.py + destination_path: /app/app.py checksum: 4f8c2d - checksum-algorithm: sha256 + checksum_algorithm: sha256 attestation: status: absent verification: not-applicable - attestation-type: in-toto + attestation_type: in-toto """ s = parse_sdl(sdl, skip_semantic_validation=True) build = s.nodes["techvault-webapp"].source.build @@ -884,8 +893,8 @@ def test_ocr_duration_units_parse(self): phase-1: {} scripts: main: - start-time: 1 us - end-time: 1 mon + start_time: 1 us + end_time: 1 mon speed: 1 events: phase-1: 1 ms @@ -966,8 +975,8 @@ def test_negative_numeric_duration_rejected(self): phase-1: {} scripts: main: - start-time: -5 - end-time: 10 + start_time: -5 + end_time: 10 speed: 1 events: phase-1: 1 @@ -1168,7 +1177,7 @@ def test_parse_sdl_file_expands_namespaced_imports(self, tmp_path: Path): run: type: objective objective: validate - on-success: finish + on_success: finish finish: type: end """, @@ -1522,7 +1531,7 @@ def test_all_scenarios_parse(self, scenarios_dir): class TestRuntimeApplicationParsing: - def test_runtime_application_surface_parses_with_kebab_keys(self): + def test_runtime_application_surface_parses_with_canonical_keys(self): sdl = """ name: techvault-application-surface nodes: @@ -1534,27 +1543,27 @@ def test_runtime_application_surface_parses_with_kebab_keys(self): name: techvault-http runtime: applications: - - application-id: techvault-webapp + - application_id: techvault-webapp service: techvault-http protocol: http - base-path: / + base_path: / framework: flask routes: - - route-id: login + - route_id: login path: /login methods: [get, post] - auth-required: false - session-required: false + auth_required: false + session_required: false parameters: - name: username location: form required: true responses: - - status-code: "200" - content-type: text/html + - status_code: "200" + content_type: text/html redirects: - target: /dashboard - status-code: "302" + status_code: "302" """ scenario = parse_sdl(sdl) applications = scenario.nodes["techvault-webapp"].runtime.applications @@ -1584,12 +1593,12 @@ def test_runtime_application_auth_variable_substitutes_on_instantiation(self): os: linux runtime: applications: - - application-id: techvault-webapp + - application_id: techvault-webapp routes: - - route-id: login + - route_id: login path: /login methods: [GET] - auth-required: ${login_auth} + auth_required: ${login_auth} """ raw = parse_sdl(sdl) route = raw.nodes["techvault-webapp"].runtime.applications[0].routes[0] @@ -1600,7 +1609,7 @@ def test_runtime_application_auth_variable_substitutes_on_instantiation(self): class TestRuntimeSshServerParsing: - def test_ssh_server_configuration_parses_with_kebab_keys(self): + def test_ssh_server_configuration_parses_with_canonical_keys(self): sdl = """ name: techvault-ssh-surface nodes: @@ -1611,26 +1620,26 @@ def test_ssh_server_configuration_parses_with_kebab_keys(self): - port: 22 name: ssh runtime: - ssh-servers: - - ssh-server-id: sshd-default + ssh_servers: + - ssh_server_id: sshd-default service: ssh - accept-env: [APTL_SESSION_ID, APTL_RUN_ID, APTL_TRACE_ID] - password-authentication: false - pubkey-authentication: true - permit-tty: true - allow-users: [kali] - authentication-methods: [publickey] - chroot-directory: /var/empty - authorized-keys-file: /etc/ssh/authorized_keys.d/%u - match-rules: - - match-id: m-kali + accept_env: [APTL_SESSION_ID, APTL_RUN_ID, APTL_TRACE_ID] + password_authentication: false + pubkey_authentication: true + permit_tty: true + allow_users: [kali] + authentication_methods: [publickey] + chroot_directory: /var/empty + authorized_keys_file: /etc/ssh/authorized_keys.d/%u + match_rules: + - match_id: m-kali criteria: - kind: user pattern: kali - forced-command: - command-kind: absolute_path + forced_command: + command_kind: absolute_path command: /usr/local/bin/aptl-wrap-shell.sh - permit-tty: true + permit_tty: true """ scenario = parse_sdl(sdl) ssh_servers = scenario.nodes["techvault-kali"].runtime.ssh_servers @@ -1665,10 +1674,10 @@ def test_ssh_server_accept_env_scalar_coerces_to_list(self): - port: 22 name: ssh runtime: - ssh-servers: - - ssh-server-id: sshd-default + ssh_servers: + - ssh_server_id: sshd-default service: ssh - accept-env: APTL_SESSION_ID + accept_env: APTL_SESSION_ID """ scenario = parse_sdl(sdl) server = scenario.nodes["techvault-kali"].runtime.ssh_servers[0] @@ -1689,10 +1698,10 @@ def test_ssh_server_chroot_directory_variable_substitutes_on_instantiation(self) - port: 22 name: ssh runtime: - ssh-servers: - - ssh-server-id: sshd-default + ssh_servers: + - ssh_server_id: sshd-default service: ssh - chroot-directory: ${chroot_path} + chroot_directory: ${chroot_path} """ raw = parse_sdl(sdl) server = raw.nodes["techvault-kali"].runtime.ssh_servers[0] @@ -1718,8 +1727,8 @@ def test_ssh_server_variable_ref_server_id_rejected_on_instantiation(self): - port: 22 name: ssh runtime: - ssh-servers: - - ssh-server-id: ${server_id} + ssh_servers: + - ssh_server_id: ${server_id} service: ssh """ # The parse step itself should reject a variable-ref symbol-defining identifier @@ -1729,7 +1738,7 @@ def test_ssh_server_variable_ref_server_id_rejected_on_instantiation(self): class TestRuntimeIdentityAuthorityParsing: - def test_identity_authority_surface_parses_with_kebab_keys(self): + def test_identity_authority_surface_parses_with_canonical_keys(self): sdl = """ name: techvault-directory-identity nodes: @@ -1740,47 +1749,47 @@ def test_identity_authority_surface_parses_with_kebab_keys(self): - {port: 389, name: ldap} - {port: 88, name: kerberos} runtime: - identity-authorities: - - identity-authority-id: techvault-domain + identity_authorities: + - identity_authority_id: techvault-domain kind: domain name: TechVault Domain namespace: techvault.local - domain-name: TECHVAULT + domain_name: TECHVAULT realm: TECHVAULT.LOCAL - base-dn: DC=techvault,DC=local + base_dn: DC=techvault,DC=local services: - - service-id: ldap-endpoint + - service_id: ldap-endpoint service: ldap protocol: LDAP address: dc.techvault.local port: "389" subjects: - - subject-id: alice + - subject_id: alice kind: user name: alice - principal-name: alice@TECHVAULT.LOCAL - distinguished-name: CN=Alice,CN=Users,DC=techvault,DC=local + principal_name: alice@TECHVAULT.LOCAL + distinguished_name: CN=Alice,CN=Users,DC=techvault,DC=local enabled: true attributes: - name: department values: security - - subject-id: domain-admins + - subject_id: domain-admins kind: group name: Domain Admins - - subject-id: ldap-svc + - subject_id: ldap-svc kind: service-principal name: ldap - service-principal-names: [LDAP/dc.techvault.local] + service_principal_names: [LDAP/dc.techvault.local] relationships: - - relationship-id: alice-admin - relationship-type: member-of - source-ref: alice - target-ref: domain-admins + - relationship_id: alice-admin + relationship_type: member-of + source_ref: alice + target_ref: domain-admins policies: - - policy-id: default-password-policy - policy-kind: password + - policy_id: default-password-policy + policy_kind: password name: Default Domain Policy - applies-to-refs: [techvault-domain] + applies_to_refs: [techvault-domain] settings: - name: min_length values: "14" @@ -1809,11 +1818,11 @@ def test_identity_authority_value_fields_substitute_on_instantiation(self): type: vm os: linux runtime: - identity-authorities: - - identity-authority-id: techvault-idp + identity_authorities: + - identity_authority_id: techvault-idp kind: identity-provider namespace: https://idp.techvault.local - tenant-id: ${tenant_id} + tenant_id: ${tenant_id} """ raw = parse_sdl(sdl) authority = raw.nodes["idp"].runtime.identity_authorities[0] @@ -1832,10 +1841,10 @@ def test_identity_authority_service_ref_must_resolve_to_same_node_service(self): services: - {port: 389, name: ldap} runtime: - identity-authorities: - - identity-authority-id: techvault-domain + identity_authorities: + - identity_authority_id: techvault-domain services: - - service-id: ldap-endpoint + - service_id: ldap-endpoint service: missing-ldap protocol: ldap """ @@ -1850,15 +1859,15 @@ def test_identity_authority_relationship_refs_must_resolve_inside_authority(self type: vm os: windows runtime: - identity-authorities: - - identity-authority-id: techvault-domain + identity_authorities: + - identity_authority_id: techvault-domain subjects: - - {subject-id: domain-admins, kind: group, name: Domain Admins} + - {subject_id: domain-admins, kind: group, name: Domain Admins} relationships: - - relationship-id: alice-admin - relationship-type: member-of - source-ref: alice - target-ref: domain-admins + - relationship_id: alice-admin + relationship_type: member-of + source_ref: alice + target_ref: domain-admins """ with pytest.raises(SDLValidationError, match="source_ref 'alice' does not resolve"): parse_sdl(sdl) @@ -1871,11 +1880,11 @@ def test_identity_authority_policy_applies_to_ref_must_resolve_inside_authority( type: vm os: windows runtime: - identity-authorities: - - identity-authority-id: techvault-domain + identity_authorities: + - identity_authority_id: techvault-domain policies: - - policy-id: default-policy - applies-to-refs: [missing-subject] + - policy_id: default-policy + applies_to_refs: [missing-subject] """ with pytest.raises(SDLValidationError, match="applies_to_ref 'missing-subject' does not resolve"): parse_sdl(sdl) @@ -1890,28 +1899,28 @@ def test_identity_authority_local_refs_include_stable_service_and_relationship_i services: - {port: 389, name: ldap} runtime: - identity-authorities: - - identity-authority-id: techvault-domain + identity_authorities: + - identity_authority_id: techvault-domain services: - - service-id: ldap-endpoint + - service_id: ldap-endpoint service: ldap protocol: ldap subjects: - - {subject-id: alice, kind: user, name: alice} - - {subject-id: domain-admins, kind: group, name: Domain Admins} + - {subject_id: alice, kind: user, name: alice} + - {subject_id: domain-admins, kind: group, name: Domain Admins} relationships: - - relationship-id: alice-admin - relationship-type: member-of - source-ref: alice - target-ref: domain-admins - - relationship-id: ldap-documents-membership - relationship-type: associated - source-ref: ldap-endpoint - target-ref: alice-admin + - relationship_id: alice-admin + relationship_type: member-of + source_ref: alice + target_ref: domain-admins + - relationship_id: ldap-documents-membership + relationship_type: associated + source_ref: ldap-endpoint + target_ref: alice-admin policies: - - policy-id: ldap-audit-policy - policy-kind: other - applies-to-refs: [ldap-endpoint, alice-admin] + - policy_id: ldap-audit-policy + policy_kind: other + applies_to_refs: [ldap-endpoint, alice-admin] """ authority = parse_sdl(sdl).nodes["ad"].runtime.identity_authorities[0] @@ -1947,23 +1956,23 @@ def test_identity_authority_local_ref_ids_must_be_unique_across_id_families(self type: vm os: windows runtime: - identity-authorities: - - identity-authority-id: {authority_id} + identity_authorities: + - identity_authority_id: {authority_id} services: - - service-id: {service_id} + - service_id: {service_id} protocol: ldap subjects: - - subject-id: {subject_id} + - subject_id: {subject_id} kind: user name: alice relationships: - - relationship-id: {relationship_id} - relationship-type: associated - source-ref: {subject_id} - external-target: external.example + - relationship_id: {relationship_id} + relationship_type: associated + source_ref: {subject_id} + external_target: external.example policies: - - policy-id: {policy_id} - applies-to-refs: [{subject_id}] + - policy_id: {policy_id} + applies_to_refs: [{subject_id}] """ with pytest.raises(SDLParseError, match="Duplicate runtime identity stable id 'shared'"): parse_sdl(sdl) @@ -1979,21 +1988,21 @@ def test_imported_identity_authority_refs_survive_module_namespacing(self, tmp_p type: vm os: windows runtime: - identity-authorities: - - identity-authority-id: techvault-domain + identity_authorities: + - identity_authority_id: techvault-domain services: - - {service-id: ldap-endpoint, protocol: ldap} + - {service_id: ldap-endpoint, protocol: ldap} subjects: - - {subject-id: alice, kind: user, name: alice} - - {subject-id: domain-admins, kind: group, name: Domain Admins} + - {subject_id: alice, kind: user, name: alice} + - {subject_id: domain-admins, kind: group, name: Domain Admins} policies: - - policy-id: default-policy - applies-to-refs: [techvault-domain] + - policy_id: default-policy + applies_to_refs: [techvault-domain] relationships: - - relationship-id: alice-admin - relationship-type: member-of - source-ref: alice - target-ref: domain-admins + - relationship_id: alice-admin + relationship_type: member-of + source_ref: alice + target_ref: domain-admins relationships: alice-admin: type: trusts @@ -2036,7 +2045,7 @@ def test_imported_identity_authority_refs_survive_module_namespacing(self, tmp_p class TestRuntimeDnsParsing: - def test_dns_service_field_keys_normalized_names_preserved(self): + def test_dns_service_canonical_field_keys_preserve_names(self): sdl = """ name: techvault-dns nodes: @@ -2046,25 +2055,25 @@ def test_dns_service_field_keys_normalized_names_preserved(self): services: - {port: 53, protocol: udp, name: dns} runtime: - dns-services: - - dns-service-id: tv-dns + dns_services: + - dns_service_id: tv-dns service: dns implementation: BIND roles: [authoritative, recursive-resolver] - resolver-policy: - recursion-enabled: true - dnssec-validation: auto + resolver_policy: + recursion_enabled: true + dnssec_validation: auto forwarders: - {address: 8.8.8.8, port: 53} zones: - - zone-id: techvault-local + - zone_id: techvault-local name: TechVault.Local. - zone-class: IN + zone_class: IN purpose: forward rrsets: - - rrset-id: web-a + - rrset_id: web-a owner: Web.TechVault.Local. - record-type: A + record_type: A ttl: 300 records: - {address: 172.20.10.20} @@ -2094,16 +2103,16 @@ def test_dns_runtime_refs_rewrite_on_module_import(self, tmp_path): services: - {port: 53, protocol: udp, name: dns} runtime: - dns-services: - - dns-service-id: tv-dns + dns_services: + - dns_service_id: tv-dns service: dns zones: - - zone-id: techvault-local + - zone_id: techvault-local name: techvault.local. rrsets: - - rrset-id: web-a + - rrset_id: web-a owner: web.techvault.local. - record-type: a + record_type: a ttl: 300 records: - {address: 172.20.10.20} @@ -2135,9 +2144,9 @@ def test_dns_runtime_refs_rewrite_on_module_import(self, tmp_path): class TestRuntimeDatabaseParsing: - def test_database_service_field_keys_normalized_names_preserved(self): - # Field keys (hyphenated/cased) normalize; observed object names are - # data and survive verbatim — including mixed case and underscores. + def test_database_service_canonical_field_keys_preserve_names(self): + # Structural fields are canonical; observed object names are data and + # survive verbatim, including mixed case and underscores. sdl = """ name: techvault-db nodes: @@ -2147,19 +2156,19 @@ def test_database_service_field_keys_normalized_names_preserved(self): services: - {port: 5432, name: pg} runtime: - database-services: - - database-service-id: tv-pg + database_services: + - database_service_id: tv-pg service: pg engine: PostgreSQL protocol: postgresql databases: - - database-id: tv-db + - database_id: tv-db name: TechVault_Prod schemas: - - schema-id: pub + - schema_id: pub name: public tables: - - {table-id: audit, name: Audit_Log} + - {table_id: audit, name: Audit_Log} settings: - {name: log_statement, value: all, provenance: configuration-file} """ @@ -2187,8 +2196,8 @@ def test_database_service_variable_substitutes_on_instantiation(self): services: - {port: 5432, name: pg} runtime: - database-services: - - database-service-id: tv-pg + database_services: + - database_service_id: tv-pg service: pg engine: postgresql protocol: postgresql diff --git a/implementations/python/tests/test_sdl_realworld.py b/implementations/python/tests/test_sdl_realworld.py index 2ec75e4d2..4befb3114 100644 --- a/implementations/python/tests/test_sdl_realworld.py +++ b/implementations/python/tests/test_sdl_realworld.py @@ -798,8 +798,8 @@ def _parse(yaml_str: str, label: str): scripts: locked-shields-day-1: - start-time: 0 - end-time: 8 hour + start_time: 0 + end_time: 8 hour speed: 1 events: disruption-wave: 2 hour diff --git a/implementations/python/tests/test_sdl_source_format.py b/implementations/python/tests/test_sdl_source_format.py new file mode 100644 index 000000000..da80b53f9 --- /dev/null +++ b/implementations/python/tests/test_sdl_source_format.py @@ -0,0 +1,316 @@ +"""Conformance tests for the canonical ``sdl-yaml/v1`` source profile.""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest +from aces_sdl import ( + SDL_SOURCE_FORMAT, + SDLMigrationPolicy, + SDLParseError, + SDLParserLimits, + load_sdl_fragment, + parse_sdl, + parse_sdl_file, +) +from paths import REPO_ROOT + + +def _diagnostic_codes(error: SDLParseError) -> set[str]: + return {item.code for item in error.diagnostics} + + +def test_yaml_12_core_scalar_resolution_is_portable() -> None: + payload = load_sdl_fragment( + textwrap.dedent( + """ + yes_value: yes + no_value: NO + on_value: on + off_value: Off + true_value: true + false_value: FALSE + decimal: 012 + octal: 0o12 + hexadecimal: 0x0a + date_like: 2026-07-11 + null_value: null + underscore_integer: 1_000 + signed_hexadecimal: -0x0a + """ + ) + ) + + assert payload == { + "yes_value": "yes", + "no_value": "NO", + "on_value": "on", + "off_value": "Off", + "true_value": True, + "false_value": False, + "decimal": 12, + "octal": 10, + "hexadecimal": 10, + "date_like": "2026-07-11", + "null_value": None, + "underscore_integer": "1_000", + "signed_hexadecimal": "-0x0a", + } + + +@pytest.mark.parametrize( + ("content", "code"), + [ + ("value: !!str yes\n", "sdl.explicit_tag"), + ("%YAML 1.2\n---\nvalue: yes\n", "sdl.directive"), + ("value: .inf\n", "sdl.non_json_value"), + ("value: .nan\n", "sdl.non_json_value"), + ("true: value\n", "sdl.mapping_key_type"), + ], +) +def test_non_profile_yaml_constructs_fail_before_model_construction(content: str, code: str) -> None: + with pytest.raises(SDLParseError) as exc_info: + load_sdl_fragment(content) + + assert code in _diagnostic_codes(exc_info.value) + + +def test_canonical_structural_fields_are_exact_snake_case() -> None: + with pytest.raises(SDLParseError) as exc_info: + parse_sdl("Name: canonical-name\n") + + diagnostic = exc_info.value.diagnostics[0] + assert diagnostic.code == "sdl.noncanonical_field" + assert diagnostic.pointer == "/name" + assert diagnostic.authored_keys == ("Name", "name") + assert diagnostic.severity == "error" + + +def test_migration_policy_accepts_aliases_with_source_ranged_advisories(tmp_path: Path) -> None: + path = tmp_path / "legacy.sdl.yaml" + path.write_text( + textwrap.dedent( + """ + Name: migrated + workflows: + response-flow: + start: finish + steps: + finish: + type: objective + objective: objective-ref + on-success: done + done: + type: end + """ + ), + encoding="utf-8", + ) + + scenario = parse_sdl_file( + path, + migration_policy=SDLMigrationPolicy.ACCEPT, + skip_semantic_validation=True, + ) + + assert scenario.name == "migrated" + assert [item.code for item in scenario.source_diagnostics] == [ + "sdl.noncanonical_field", + "sdl.noncanonical_field", + ] + assert [item.pointer for item in scenario.source_diagnostics] == [ + "/name", + "/workflows/response-flow/steps/finish/on_success", + ] + assert all(item.severity == "warning" for item in scenario.source_diagnostics) + assert all(item.source == str(path) for item in scenario.source_diagnostics) + + +def test_literal_identifiers_are_not_migration_aliases() -> None: + scenario = parse_sdl( + textwrap.dedent( + """ + name: literal-ids + nodes: + Web-App: {type: switch} + web_app: {type: switch} + """ + ), + migration_policy=SDLMigrationPolicy.ACCEPT, + ) + + assert set(scenario.nodes) == {"Web-App", "web_app"} + assert scenario.source_diagnostics == () + + +def test_merge_keys_are_migration_only_and_conflicts_remain_fatal() -> None: + content = textwrap.dedent( + """ + name: merge-migration + nodes: + template: &template + type: switch + inherited: + <<: *template + """ + ) + + with pytest.raises(SDLParseError) as strict_exc: + parse_sdl(content) + assert "sdl.noncanonical_merge" in _diagnostic_codes(strict_exc.value) + + scenario = parse_sdl(content, migration_policy=SDLMigrationPolicy.ACCEPT) + assert scenario.nodes["inherited"].type.value == "switch" + assert [item.code for item in scenario.source_diagnostics] == ["sdl.noncanonical_merge"] + + conflicting = content.replace("<<: *template", "<<: *template\n type: vm") + with pytest.raises(SDLParseError) as conflict_exc: + parse_sdl(conflicting, migration_policy=SDLMigrationPolicy.ACCEPT) + assert "sdl.mapping_key_conflict" in _diagnostic_codes(conflict_exc.value) + + +@pytest.mark.parametrize( + ("limits", "content"), + [ + (SDLParserLimits(max_input_bytes=8), "value: too-long\n"), + (SDLParserLimits(max_scalar_bytes=3), "value: four\n"), + (SDLParserLimits(max_depth=2), "value:\n nested:\n leaf: true\n"), + (SDLParserLimits(max_nodes=2), "first: 1\nsecond: 2\n"), + (SDLParserLimits(max_aliases=1), "base: &base [1]\nvalues: [*base, *base]\n"), + ( + SDLParserLimits(max_expanded_nodes=8), + "base: &base [1, 2, 3]\nvalues: [*base, *base, *base]\n", + ), + ], +) +def test_parser_limits_fail_with_one_stable_operational_diagnostic( + limits: SDLParserLimits, + content: str, +) -> None: + with pytest.raises(SDLParseError) as exc_info: + load_sdl_fragment(content, limits=limits) + + assert _diagnostic_codes(exc_info.value) == {"sdl.source_limit"} + + +def test_alias_reuse_is_checked_at_its_effective_expanded_depth() -> None: + content = """\ +base: &base + child: + leaf: true +nested: + inner: *base +""" + + with pytest.raises(SDLParseError) as exc_info: + load_sdl_fragment( + content, + mapping_keys="literal", + limits=SDLParserLimits(max_depth=4), + ) + + assert _diagnostic_codes(exc_info.value) == {"sdl.source_limit"} + + +def test_unknown_source_format_fails_closed() -> None: + assert SDL_SOURCE_FORMAT == "sdl-yaml/v1" + with pytest.raises(SDLParseError) as exc_info: + parse_sdl("name: example\n", source_format="sdl-yaml/v2") + + assert _diagnostic_codes(exc_info.value) == {"sdl.source_format"} + + +def test_unknown_migration_policy_fails_with_a_structured_diagnostic() -> None: + with pytest.raises(SDLParseError) as exc_info: + parse_sdl("name: example\n", migration_policy="guess") + + assert _diagnostic_codes(exc_info.value) == {"sdl.migration_policy"} + + +def test_source_must_be_one_mapping_document() -> None: + with pytest.raises(SDLParseError, match="single document"): + parse_sdl("---\nname: first\n---\nname: second\n") + + with pytest.raises(SDLParseError, match="YAML mapping"): + parse_sdl("- name\n- second\n") + + +def test_unpaired_unicode_surrogate_fails_as_invalid_utf8() -> None: + with pytest.raises(SDLParseError) as exc_info: + parse_sdl("name: \ud800\n") + + assert _diagnostic_codes(exc_info.value) == {"sdl.utf8"} + + +def test_invalid_utf8_file_has_a_structured_source_diagnostic(tmp_path: Path) -> None: + path = tmp_path / "invalid.yaml" + path.write_bytes(b"name: \xff\n") + + with pytest.raises(SDLParseError) as exc_info: + parse_sdl_file(path) + + diagnostic = exc_info.value.diagnostics[0] + assert diagnostic.code == "sdl.utf8" + assert diagnostic.source == str(path) + assert diagnostic.primary_range.start.line == 1 + + +def test_migration_policy_and_source_identity_propagate_through_imports(tmp_path: Path) -> None: + module = tmp_path / "module.yaml" + module.write_text( + """\ +Name: imported +module: + id: acme/imported + version: 1.0.0 + exports: {} +""", + encoding="utf-8", + ) + root = tmp_path / "root.yaml" + root.write_text( + """\ +name: root +imports: + - path: module.yaml + namespace: imported +""", + encoding="utf-8", + ) + + with pytest.raises(SDLParseError): + parse_sdl_file(root) + + scenario = parse_sdl_file(root, migration_policy=SDLMigrationPolicy.ACCEPT) + assert [item.code for item in scenario.source_diagnostics] == ["sdl.noncanonical_field"] + assert scenario.source_diagnostics[0].source == str(module) + + +def test_indentation_is_not_silently_rewritten() -> None: + with pytest.raises(SDLParseError): + parse_sdl(" name: indented-root\nother: value\n") + + +def test_normative_source_profile_fixture_corpus() -> None: + fixture_root = REPO_ROOT / "contracts" / "fixtures" / "sdl" / "sdl-yaml-v1" + valid = sorted((fixture_root / "valid").glob("*.yaml")) + invalid = sorted((fixture_root / "invalid").glob("*.yaml")) + migration = sorted((fixture_root / "migration").glob("*.yaml")) + assert valid and invalid and migration + + for path in valid: + parse_sdl_file(path, skip_semantic_validation=True) + for path in invalid: + with pytest.raises(SDLParseError): + parse_sdl_file(path, skip_semantic_validation=True) + for path in migration: + with pytest.raises(SDLParseError): + parse_sdl_file(path, skip_semantic_validation=True) + scenario = parse_sdl_file( + path, + migration_policy=SDLMigrationPolicy.ACCEPT, + skip_semantic_validation=True, + ) + assert scenario.source_diagnostics diff --git a/implementations/python/tests/test_sdl_stress.py b/implementations/python/tests/test_sdl_stress.py index ed60249ea..c4e7baf28 100644 --- a/implementations/python/tests/test_sdl_stress.py +++ b/implementations/python/tests/test_sdl_stress.py @@ -121,8 +121,8 @@ def _parse(yaml_str: str, label: str): injects: attack-inject: source: attack-pkg - from-entity: red-team - to-entities: + from_entity: red-team + to_entities: - blue-team events: @@ -134,8 +134,8 @@ def _parse(yaml_str: str, label: str): scripts: main-script: - start-time: 5 min - end-time: 2 hour + start_time: 5 min + end_time: 2 hour speed: 1.0 events: attack-event: 30 min @@ -1253,8 +1253,8 @@ def _parse(yaml_str: str, label: str): scripts: day-1: - start-time: 0 - end-time: 2 hour + start_time: 0 + end_time: 2 hour speed: 1 events: phishing-wave: 5 min @@ -1442,8 +1442,8 @@ def _parse(yaml_str: str, label: str): scripts: identity-day: - start-time: 0 - end-time: 4 hour + start_time: 0 + end_time: 4 hour speed: 1 events: federation-cutover: 30 min diff --git a/implementations/python/tests/test_sdl_validator.py b/implementations/python/tests/test_sdl_validator.py index 939e21d81..f3fb8c2c4 100644 --- a/implementations/python/tests/test_sdl_validator.py +++ b/implementations/python/tests/test_sdl_validator.py @@ -1518,7 +1518,7 @@ def test_window_steps_must_belong_to_workflow(self): "validate": { "type": "objective", "objective": "obj-1", - "on-success": "finish", + "on_success": "finish", }, "finish": {"type": "end"}, }, @@ -1529,7 +1529,7 @@ def test_window_steps_must_belong_to_workflow(self): "validate": { "type": "objective", "objective": "obj-1", - "on-success": "finish", + "on_success": "finish", }, "finish": {"type": "end"}, }, @@ -1594,7 +1594,7 @@ def test_workflow_references_undefined_objective(self): "validate": { "type": "objective", "objective": "missing-objective", - "on-success": "finish", + "on_success": "finish", }, "finish": {"type": "end"}, }, @@ -1629,7 +1629,7 @@ def test_workflow_cycle_rejected(self): "validate": { "type": "objective", "objective": "validate-release", - "on-success": "branch", + "on_success": "branch", }, "branch": { "type": "parallel", @@ -1639,7 +1639,7 @@ def test_workflow_cycle_rejected(self): "recover": { "type": "objective", "objective": "rollback-edge", - "on-success": "finish", + "on_success": "finish", }, "finish": {"type": "join", "next": "validate"}, }, @@ -1659,7 +1659,7 @@ def test_workflow_unreachable_step_rejected(self): "validate": { "type": "objective", "objective": "validate-release", - "on-success": "finish", + "on_success": "finish", }, "finish": {"type": "end"}, "orphan": {"type": "end"}, @@ -1685,7 +1685,7 @@ def test_parallel_branch_reference_must_exist(self): "rollback-edge": { "type": "objective", "objective": "rollback-edge", - "on-success": "joined", + "on_success": "joined", }, "joined": {"type": "join", "next": "finish"}, "finish": {"type": "end"}, @@ -1706,7 +1706,7 @@ def test_valid_workflow(self): "validate": { "type": "objective", "objective": "validate-release", - "on-success": "branch", + "on_success": "branch", }, "branch": { "type": "decision", @@ -1722,12 +1722,12 @@ def test_valid_workflow(self): "rollback": { "type": "objective", "objective": "rollback-edge", - "on-success": "joined", + "on_success": "joined", }, "confirm": { "type": "objective", "objective": "validate-release", - "on-success": "joined", + "on_success": "joined", }, "joined": {"type": "join", "next": "finish"}, "finish": {"type": "end"}, @@ -1748,14 +1748,14 @@ def test_valid_retry_step(self): "loop": { "type": "retry", "objective": "validate-release", - "on-success": "finish", - "max-attempts": 5, - "on-exhausted": "recover", + "on_success": "finish", + "max_attempts": 5, + "on_exhausted": "recover", }, "recover": { "type": "objective", "objective": "rollback-edge", - "on-success": "finish", + "on_success": "finish", }, "finish": {"type": "end"}, }, @@ -1775,7 +1775,7 @@ def test_valid_switch_and_call_workflow(self): "run": { "type": "objective", "objective": "validate-release", - "on-success": "finish", + "on_success": "finish", }, "finish": {"type": "end"}, }, @@ -1796,7 +1796,7 @@ def test_valid_switch_and_call_workflow(self): "delegate": { "type": "call", "workflow": "child", - "on-success": "finish", + "on_success": "finish", }, "finish": {"type": "end"}, }, @@ -1816,7 +1816,7 @@ def test_workflow_call_cycle_rejected(self): "delegate": { "type": "call", "workflow": "b", - "on-success": "finish", + "on_success": "finish", }, "finish": {"type": "end"}, }, @@ -1827,7 +1827,7 @@ def test_workflow_call_cycle_rejected(self): "delegate": { "type": "call", "workflow": "a", - "on-success": "finish", + "on_success": "finish", }, "finish": {"type": "end"}, }, @@ -1847,9 +1847,9 @@ def test_retry_missing_exhausted_step_ref(self): "loop": { "type": "retry", "objective": "validate-release", - "on-success": "finish", - "max-attempts": 3, - "on-exhausted": "nonexistent", + "on_success": "finish", + "max_attempts": 3, + "on_exhausted": "nonexistent", }, "finish": {"type": "end"}, }, @@ -1869,7 +1869,7 @@ def test_step_state_must_reference_prior_executable_step(self): "validate": { "type": "objective", "objective": "validate-release", - "on-success": "branch", + "on_success": "branch", }, "branch": { "type": "decision", @@ -1895,7 +1895,7 @@ def test_step_state_undefined_ref(self): "validate": { "type": "objective", "objective": "validate-release", - "on-success": "branch", + "on_success": "branch", }, "branch": { "type": "decision", @@ -1921,7 +1921,7 @@ def test_step_state_non_causal_ref_rejected(self): "validate": { "type": "objective", "objective": "validate-release", - "on-success": "branch", + "on_success": "branch", }, "branch": { "type": "decision", @@ -1932,7 +1932,7 @@ def test_step_state_non_causal_ref_rejected(self): "confirm": { "type": "objective", "objective": "rollback-edge", - "on-success": "finish", + "on_success": "finish", }, "finish": {"type": "end"}, }, @@ -1973,7 +1973,7 @@ def test_step_state_non_executable_ref_rejected(self): "validate": { "type": "objective", "objective": "validate-release", - "on-success": "branch", + "on_success": "branch", }, "branch": { "type": "decision", @@ -1999,7 +1999,7 @@ def test_step_state_decision_ref_rejected(self): "validate": { "type": "objective", "objective": "validate-release", - "on-success": "branch", + "on_success": "branch", }, "branch": { "type": "decision", @@ -2031,7 +2031,7 @@ def test_step_state_impossible_outcome_rejected(self): "validate": { "type": "objective", "objective": "validate-release", - "on-success": "branch", + "on_success": "branch", }, "branch": { "type": "decision", @@ -2068,12 +2068,12 @@ def test_join_rejects_foreign_predecessors(self): "rollback": { "type": "objective", "objective": "rollback-edge", - "on-success": "joined", + "on_success": "joined", }, "confirm": { "type": "objective", "objective": "validate-release", - "on-success": "joined", + "on_success": "joined", }, "joined": {"type": "join", "next": "finish"}, "finish": {"type": "end"}, @@ -2099,12 +2099,12 @@ def test_parallel_join_must_be_join_step(self): "rollback": { "type": "objective", "objective": "rollback-edge", - "on-success": "finish", + "on_success": "finish", }, "confirm": { "type": "objective", "objective": "validate-release", - "on-success": "finish", + "on_success": "finish", }, "finish": {"type": "end"}, }, @@ -2129,12 +2129,12 @@ def test_parallel_branch_paths_must_converge_on_join(self): "rollback": { "type": "objective", "objective": "rollback-edge", - "on-success": "joined", + "on_success": "joined", }, "confirm": { "type": "objective", "objective": "validate-release", - "on-success": "finish", + "on_success": "finish", }, "joined": {"type": "join", "next": "finish"}, "finish": {"type": "end"}, @@ -2155,7 +2155,7 @@ def test_join_step_must_be_referenced(self): "validate": { "type": "objective", "objective": "validate-release", - "on-success": "finish", + "on_success": "finish", }, "orphan-join": {"type": "join", "next": "finish"}, "finish": {"type": "end"}, @@ -2181,12 +2181,12 @@ def test_post_join_branch_state_ref_is_allowed(self): "rollback": { "type": "objective", "objective": "rollback-edge", - "on-success": "joined", + "on_success": "joined", }, "confirm": { "type": "objective", "objective": "validate-release", - "on-success": "joined", + "on_success": "joined", }, "joined": {"type": "join", "next": "branch"}, "branch": { @@ -2218,13 +2218,13 @@ def test_post_join_attempt_count_ref_is_allowed_when_guaranteed(self): "rollback": { "type": "retry", "objective": "rollback-edge", - "on-success": "joined", - "max-attempts": 3, + "on_success": "joined", + "max_attempts": 3, }, "confirm": { "type": "objective", "objective": "validate-release", - "on-success": "joined", + "on_success": "joined", }, "joined": {"type": "join", "next": "branch"}, "branch": { @@ -2234,7 +2234,7 @@ def test_post_join_attempt_count_ref_is_allowed_when_guaranteed(self): { "step": "rollback", "outcomes": ["succeeded"], - "min-attempts": 2, + "min_attempts": 2, } ] }, @@ -2264,7 +2264,7 @@ def test_branch_local_state_ref_before_join_is_rejected(self): "rollback": { "type": "objective", "objective": "rollback-edge", - "on-success": "branch-in-branch", + "on_success": "branch-in-branch", }, "branch-in-branch": { "type": "decision", @@ -2275,7 +2275,7 @@ def test_branch_local_state_ref_before_join_is_rejected(self): "confirm": { "type": "objective", "objective": "validate-release", - "on-success": "joined", + "on_success": "joined", }, "joined": {"type": "join", "next": "finish"}, "finish": {"type": "end"}, @@ -2307,12 +2307,12 @@ def test_non_guaranteed_branch_internal_state_ref_after_join_is_rejected(self): "rollback-success": { "type": "objective", "objective": "rollback-edge", - "on-success": "joined", + "on_success": "joined", }, "confirm": { "type": "objective", "objective": "validate-release", - "on-success": "joined", + "on_success": "joined", }, "joined": {"type": "join", "next": "branch"}, "branch": { @@ -2347,17 +2347,17 @@ def test_parallel_failure_bypass_does_not_expose_branch_state(self): "type": "parallel", "branches": ["rollback", "confirm"], "join": "joined", - "on-failure": "recover", + "on_failure": "recover", }, "rollback": { "type": "objective", "objective": "rollback-edge", - "on-success": "joined", + "on_success": "joined", }, "confirm": { "type": "objective", "objective": "validate-release", - "on-success": "joined", + "on_success": "joined", }, "joined": {"type": "join", "next": "finish"}, "recover": { @@ -2387,8 +2387,8 @@ def test_on_failure_variable_ref_tolerated(self): "validate": { "type": "objective", "objective": "validate-release", - "on-success": "finish", - "on-failure": "${recovery_step}", + "on_success": "finish", + "on_failure": "${recovery_step}", }, "finish": {"type": "end"}, }, @@ -2411,7 +2411,7 @@ def test_non_compensable_workflow_step_rejects_compensation_target(self): "when": {"conditions": ["check"]}, "then": "finish", "else": "finish", - "compensate-with": "rollback", + "compensate_with": "rollback", }, "finish": {"type": "end"}, }, @@ -2434,8 +2434,8 @@ def test_workflow_compensation_cycle_is_rejected(self): "run": { "type": "objective", "objective": "validate-release", - "compensate-with": "rollback", - "on-success": "finish", + "compensate_with": "rollback", + "on_success": "finish", }, "finish": {"type": "end"}, }, @@ -2446,8 +2446,8 @@ def test_workflow_compensation_cycle_is_rejected(self): "undo": { "type": "objective", "objective": "rollback-edge", - "compensate-with": "response", - "on-success": "finish", + "compensate_with": "response", + "on_success": "finish", }, "finish": {"type": "end"}, }, @@ -2468,8 +2468,8 @@ def test_compensation_workflow_cannot_declare_compensate_with_steps(self): "run": { "type": "objective", "objective": "validate-release", - "compensate-with": "rollback", - "on-success": "finish", + "compensate_with": "rollback", + "on_success": "finish", }, "finish": {"type": "end"}, }, @@ -2480,8 +2480,8 @@ def test_compensation_workflow_cannot_declare_compensate_with_steps(self): "undo": { "type": "objective", "objective": "rollback-edge", - "compensate-with": "cleanup", - "on-success": "finish", + "compensate_with": "cleanup", + "on_success": "finish", }, "finish": {"type": "end"}, }, diff --git a/implementations/python/tests/test_sem_208_participant_behavior.py b/implementations/python/tests/test_sem_208_participant_behavior.py index aa8534fb2..2d7c179fe 100644 --- a/implementations/python/tests/test_sem_208_participant_behavior.py +++ b/implementations/python/tests/test_sem_208_participant_behavior.py @@ -118,113 +118,113 @@ def _scenario_yaml(*, actions: str = "[scan]", boundaries: str = "[red-view]") - entities: red-team: role: red - action-contracts: + action_contracts: scan: - semantic-version: 1.0.0 - lifecycle-state: active - behavioral-granularity: atomic - procedure-basis: nmap service discovery - realization-profile: backend-declared - fidelity-claim: records participant discovery intent and terminal observation + semantic_version: 1.0.0 + lifecycle_state: active + behavioral_granularity: atomic + procedure_basis: nmap service discovery + realization_profile: backend-declared + fidelity_claim: records participant discovery intent and terminal observation preconditions: - - precondition-id: authority-in-scope - precondition-class: authority + - precondition_id: authority-in-scope + precondition_class: authority description: red participant is authorized to scan the web service - support-refs: [agents.red-agent, nodes.web.services.http] - - precondition-id: target-service-present - precondition-class: target + support_refs: [agents.red-agent, nodes.web.services.http] + - precondition_id: target-service-present + precondition_class: target description: target service exists in the participant action scope - support-refs: [nodes.web.services.http] - - precondition-id: backend-can-realize-scan - precondition-class: realization + support_refs: [nodes.web.services.http] + - precondition_id: backend-can-realize-scan + precondition_class: realization description: backend can realize the scan action contract - support-refs: [backend.participant-runtime] + support_refs: [backend.participant-runtime] effects: - - effect-id: discover-network-services - effect-class: intended_effect + - effect_id: discover-network-services + effect_class: intended_effect description: discover network services - target-refs: [nodes.web.services.http] - - effect-id: participant-service-knowledge-update - effect-class: side_effect + target_refs: [nodes.web.services.http] + - effect_id: participant-service-knowledge-update + effect_class: side_effect description: participant-local service knowledge changes - target-refs: [nodes.web.services.http] - - effect-id: terminal-scan-observation - effect-class: observation_effect + target_refs: [nodes.web.services.http] + - effect_id: terminal-scan-observation + effect_class: observation_effect description: terminal scan observation - evidence-refs: [evidence.scan-output] - - effect-id: participant-view-discovers-node - effect-class: visibility_effect + evidence_refs: [evidence.scan-output] + - effect_id: participant-view-discovers-node + effect_class: visibility_effect description: participant view marks the web node discovered - target-refs: [nodes.web] - - effect-id: scan-output-evidence - effect-class: evidence_effect + target_refs: [nodes.web] + - effect_id: scan-output-evidence + effect_class: evidence_effect description: scan output is retained as evidence - evidence-refs: [evidence.scan-output] - - effect-id: no-hidden-truth-effect - effect-class: no_effect + evidence_refs: [evidence.scan-output] + - effect_id: no-hidden-truth-effect + effect_class: no_effect description: scan does not disclose hidden adjudication material - state-transition-effects: [participant knowledge expands] - observation-expectations: [terminal scan result] - evidence-expectations: [tool output] - failure-classes: [target_unavailable, precondition_unsatisfied, backend_error, unknown] - backend-failure-mappings: - - backend-error-code: backend.target-unreachable - failure-class: target_unavailable + state_transition_effects: [participant knowledge expands] + observation_expectations: [terminal scan result] + evidence_expectations: [tool output] + failure_classes: [target_unavailable, precondition_unsatisfied, backend_error, unknown] + backend_failure_mappings: + - backend_error_code: backend.target-unreachable + failure_class: target_unavailable diagnostic: backend target unreachable interactions: - - interaction-class: shared_state_change + - interaction_class: shared_state_change target: nodes.web.services.http rationale: scan reads and updates participant-visible service knowledge - shared-state-refs: [nodes.web.services.http] - external-mappings: + shared_state_refs: [nodes.web.services.http] + external_mappings: - system: attack identifier: T1046 - loss-label: technique-to-contract + loss_label: technique-to-contract rationale: ATT&CK does not encode ACES observation or state-transition semantics - observation-boundaries: + observation_boundaries: red-view: - projection-basis: participant-local projection over observed services - observable-refs: [] - hidden-refs: [nodes.web, content.private-answer-key] - evidence-refs: [evidence.scan-output] - redaction-policy: hidden refs never project without explicit disclosure - latency-profile: terminal observation emitted after state transition commit - observer-effects: [tool execution may affect telemetry] - realized-view-disclosure: backend reports terminal scan output only - view-rules: - - information-ref: nodes.web - boundary-class: observable_resource + projection_basis: participant-local projection over observed services + observable_refs: [] + hidden_refs: [nodes.web, content.private-answer-key] + evidence_refs: [evidence.scan-output] + redaction_policy: hidden refs never project without explicit disclosure + latency_profile: terminal observation emitted after state transition commit + observer_effects: [tool execution may affect telemetry] + realized_view_disclosure: backend reports terminal scan output only + view_rules: + - information_ref: nodes.web + boundary_class: observable_resource disposition: hidden - visibility-basis: service is not known before terminal scan output - latency-profile: terminal observation latency - - information-ref: content.private-answer-key - boundary-class: private_answer_key + visibility_basis: service is not known before terminal scan output + latency_profile: terminal observation latency + - information_ref: content.private-answer-key + boundary_class: private_answer_key disposition: hidden - visibility-basis: adjudication-only hidden truth - - information-ref: evidence.scan-output - boundary-class: archival_evidence + visibility_basis: adjudication-only hidden truth + - information_ref: evidence.scan-output + boundary_class: archival_evidence disposition: evidence_only - visibility-basis: archival run evidence reference - evidence-refs: [evidence.scan-output] - view-transitions: - - transition-id: discover-web-service - transition-kind: discovery - information-ref: nodes.web + visibility_basis: archival run evidence reference + evidence_refs: [evidence.scan-output] + view_transitions: + - transition_id: discover-web-service + transition_kind: discovery + information_ref: nodes.web trigger: scan terminal observation - effective-from: episode-step:scan-0001:terminal-observation - effective-order: 30 - history-event-type: observation_emitted - action-instance-id: scan-0001 - from-disposition: hidden - to-disposition: discovered - evidence-refs: [evidence.scan-output] + effective_from: episode-step:scan-0001:terminal-observation + effective_order: 30 + history_event_type: observation_emitted + action_instance_id: scan-0001 + from_disposition: hidden + to_disposition: discovered + evidence_refs: [evidence.scan-output] certainty: high - latency-profile: terminal observation latency + latency_profile: terminal observation latency agents: red-agent: entity: red-team actions: {actions} - observation-boundaries: {boundaries} + observation_boundaries: {boundaries} """ ) @@ -269,41 +269,41 @@ def _act607_authority_scope_scenario_yaml() -> str: target: web items: - name: playbook - action-contracts: + action_contracts: scan: - semantic-version: 1.0.0 - lifecycle-state: active - behavioral-granularity: atomic - procedure-basis: scan contract - realization-profile: backend-declared - fidelity-claim: records scan intent + semantic_version: 1.0.0 + lifecycle_state: active + behavioral_granularity: atomic + procedure_basis: scan contract + realization_profile: backend-declared + fidelity_claim: records scan intent preconditions: - - precondition-id: authority-in-scope - precondition-class: authority + - precondition_id: authority-in-scope + precondition_class: authority description: participant authority is declared in SDL effects: - - effect-id: no-effect - effect-class: no_effect + - effect_id: no-effect + effect_class: no_effect description: compilation-only contract - failure-classes: [authority_denied, unknown] - observation-boundaries: + failure_classes: [authority_denied, unknown] + observation_boundaries: red-view: - projection-basis: participant view - evidence-refs: [evidence.scan-output] - redaction-policy: hidden refs are not disclosed - latency-profile: immediate + projection_basis: participant view + evidence_refs: [evidence.scan-output] + redaction_policy: hidden refs are not disclosed + latency_profile: immediate agents: red-agent: entity: red-team actions: [scan] - starting-accounts: [operator] - initial-knowledge: + starting_accounts: [operator] + initial_knowledge: hosts: [web] subnets: [net] services: [http] accounts: [operator] - starting-conditions: [beacon-online] - authority-anchors: + starting_conditions: [beacon-online] + authority_anchors: - red-team - red-controls-web - operator @@ -311,21 +311,21 @@ def _act607_authority_scope_scenario_yaml() -> str: - red-view - docs - nodes.web.services.http - operating-scope: + operating_scope: - web - net - nodes.web.services.http - docs - playbook - observation-boundaries: [red-view] - behavior-specifications: + observation_boundaries: [red-view] + behavior_specifications: red-scan-behavior: - semantic-version: 1.0.0 - lifecycle-state: active - participant-refs: [red-agent] - action-contract-refs: [scan] - observation-boundary-refs: [red-view] - authority-scope-refs: + semantic_version: 1.0.0 + lifecycle_state: active + participant_refs: [red-agent] + action_contract_refs: [scan] + observation_boundary_refs: [red-view] + authority_scope_refs: - nodes.web.services.http - operator - scan @@ -333,7 +333,7 @@ def _act607_authority_scope_scenario_yaml() -> str: - docs - playbook - red-controls-web - extension-policy: governed-extension + extension_policy: governed-extension """ ) @@ -374,12 +374,12 @@ def _act607_typed_ref_collision_scenario_yaml() -> str: agents: red-agent: entity: red-team - starting-accounts: [operator] - initial-knowledge: + starting_accounts: [operator] + initial_knowledge: hosts: [web] services: [http] accounts: [operator] - starting-conditions: [beacon-online] + starting_conditions: [beacon-online] """ ) @@ -400,22 +400,22 @@ def test_behavior_specifications_parse_validate_and_compile(): _scenario_yaml() + textwrap.dedent( """ - behavior-specifications: + behavior_specifications: red-scan-behavior: - semantic-version: 1.0.0 - lifecycle-state: active - participant-refs: [red-agent] - participant-role-refs: [red] - action-contract-refs: [scan] - observation-boundary-refs: [red-view] - authority-scope-refs: [nodes.web.services.http] - behavior-mode: policy-directed - ai-offensive-behavior-refs: [ai-model-access, defense-evasion] - offensive-behavior-refs: [reconnaissance, exfiltration] - realization-profile-ref: participant-implementation-manifest:reference-red-agent - backend-feature-support-refs: [action_contracts] - evidence-contract-refs: [participant-behavior-history-event-stream-v1] - extension-policy: governed-extension + semantic_version: 1.0.0 + lifecycle_state: active + participant_refs: [red-agent] + participant_role_refs: [red] + action_contract_refs: [scan] + observation_boundary_refs: [red-view] + authority_scope_refs: [nodes.web.services.http] + behavior_mode: policy-directed + ai_offensive_behavior_refs: [ai-model-access, defense-evasion] + offensive_behavior_refs: [reconnaissance, exfiltration] + realization_profile_ref: participant-implementation-manifest:reference-red-agent + backend_feature_support_refs: [action_contracts] + evidence_contract_refs: [participant-behavior-history-event-stream-v1] + extension_policy: governed-extension extensions: x-acme:review-note: owner: acme @@ -568,46 +568,46 @@ def test_behavior_specification_refs_are_namespaced_during_module_composition(tm exports: entities: [red-team] agents: [red-agent] - action-contracts: [scan] - observation-boundaries: [red-view] - behavior-specifications: [red-scan-behavior] + action_contracts: [scan] + observation_boundaries: [red-view] + behavior_specifications: [red-scan-behavior] entities: red-team: role: red agents: red-agent: entity: red-team - action-contracts: + action_contracts: scan: - semantic-version: 1.0.0 - lifecycle-state: active - behavioral-granularity: atomic - procedure-basis: scan contract - realization-profile: backend-declared - fidelity-claim: records scan intent + semantic_version: 1.0.0 + lifecycle_state: active + behavioral_granularity: atomic + procedure_basis: scan contract + realization_profile: backend-declared + fidelity_claim: records scan intent preconditions: - - precondition-id: authority-in-scope - precondition-class: authority + - precondition_id: authority-in-scope + precondition_class: authority description: participant has authority effects: - - effect-id: no-effect - effect-class: no_effect + - effect_id: no-effect + effect_class: no_effect description: composition-only contract - failure-classes: [unknown] - observation-boundaries: + failure_classes: [unknown] + observation_boundaries: red-view: - projection-basis: participant view - evidence-refs: [evidence.scan-output] - redaction-policy: no hidden refs are disclosed - latency-profile: immediate - behavior-specifications: + projection_basis: participant view + evidence_refs: [evidence.scan-output] + redaction_policy: no hidden refs are disclosed + latency_profile: immediate + behavior_specifications: red-scan-behavior: - semantic-version: 1.0.0 - lifecycle-state: active - participant-refs: [red-agent] - action-contract-refs: [scan] - observation-boundary-refs: [red-view] - extension-policy: governed-extension + semantic_version: 1.0.0 + lifecycle_state: active + participant_refs: [red-agent] + action_contract_refs: [scan] + observation_boundary_refs: [red-view] + extension_policy: governed-extension """ ).lstrip(), encoding="utf-8", @@ -644,13 +644,13 @@ def test_behavior_specification_optional_fields_compile_empty_when_omitted(): _scenario_yaml() + textwrap.dedent( """ - behavior-specifications: + behavior_specifications: red-scan-behavior: - semantic-version: 1.0.0 - lifecycle-state: active - participant-refs: [red-agent] - action-contract-refs: [scan] - extension-policy: governed-extension + semantic_version: 1.0.0 + lifecycle_state: active + participant_refs: [red-agent] + action_contract_refs: [scan] + extension_policy: governed-extension """ ) ) @@ -678,14 +678,14 @@ def test_act_608_behavior_modes_parse_validate_and_compile(behavior_mode: str): _scenario_yaml() + textwrap.dedent( f""" - behavior-specifications: + behavior_specifications: red-scan-behavior: - semantic-version: 1.0.0 - lifecycle-state: active - participant-refs: [red-agent] - action-contract-refs: [scan] - behavior-mode: {behavior_mode} - extension-policy: governed-extension + semantic_version: 1.0.0 + lifecycle_state: active + participant_refs: [red-agent] + action_contract_refs: [scan] + behavior_mode: {behavior_mode} + extension_policy: governed-extension """ ) ) @@ -704,14 +704,14 @@ def test_behavior_specification_behavior_mode_allows_governed_extensions(): _scenario_yaml() + textwrap.dedent( """ - behavior-specifications: + behavior_specifications: red-scan-behavior: - semantic-version: 1.0.0 - lifecycle-state: active - participant-refs: [red-agent] - action-contract-refs: [scan] - behavior-mode: x-acme:swarm-control - extension-policy: governed-extension + semantic_version: 1.0.0 + lifecycle_state: active + participant_refs: [red-agent] + action_contract_refs: [scan] + behavior_mode: x-acme:swarm-control + extension_policy: governed-extension """ ) ) @@ -727,14 +727,14 @@ def test_act_609_offensive_behavior_refs_allow_governed_extensions(): _scenario_yaml() + textwrap.dedent( """ - behavior-specifications: + behavior_specifications: red-scan-behavior: - semantic-version: 1.0.0 - lifecycle-state: active - participant-refs: [red-agent] - action-contract-refs: [scan] - offensive-behavior-refs: [reconnaissance, x-acme:phishing-campaign] - extension-policy: governed-extension + semantic_version: 1.0.0 + lifecycle_state: active + participant_refs: [red-agent] + action_contract_refs: [scan] + offensive_behavior_refs: [reconnaissance, x-acme:phishing-campaign] + extension_policy: governed-extension """ ) ) @@ -750,14 +750,14 @@ def test_act_609_ai_offensive_behavior_refs_allow_governed_extensions(): _scenario_yaml() + textwrap.dedent( """ - behavior-specifications: + behavior_specifications: red-scan-behavior: - semantic-version: 1.0.0 - lifecycle-state: active - participant-refs: [red-agent] - action-contract-refs: [scan] - ai-offensive-behavior-refs: [ai-model-access, x-acme:model-poisoning] - extension-policy: governed-extension + semantic_version: 1.0.0 + lifecycle_state: active + participant_refs: [red-agent] + action_contract_refs: [scan] + ai_offensive_behavior_refs: [ai-model-access, x-acme:model-poisoning] + extension_policy: governed-extension """ ) ) @@ -772,26 +772,26 @@ def test_act_609_ai_offensive_behavior_refs_allow_governed_extensions(): ("field", "replacement", "expected"), [ ( - "participant-refs: [red-agent]", - "participant-refs: [blue-agent]", + "participant_refs: [red-agent]", + "participant_refs: [blue-agent]", "Behavior specification 'red-scan-behavior' participant_ref 'blue-agent' " "does not reference a declared agent", ), ( - "action-contract-refs: [scan]", - "action-contract-refs: [exploit]", + "action_contract_refs: [scan]", + "action_contract_refs: [exploit]", "Behavior specification 'red-scan-behavior' action_contract_ref 'exploit' " "does not reference a declared action_contract", ), ( - "observation-boundary-refs: [red-view]", - "observation-boundary-refs: [leaked-view]", + "observation_boundary_refs: [red-view]", + "observation_boundary_refs: [leaked-view]", "Behavior specification 'red-scan-behavior' observation_boundary_ref 'leaked-view' " "does not reference a declared observation_boundary", ), ( - "authority-scope-refs: [nodes.web.services.http]", - "authority-scope-refs: [nodes.missing.services.http]", + "authority_scope_refs: [nodes.web.services.http]", + "authority_scope_refs: [nodes.missing.services.http]", "Behavior specification 'red-scan-behavior' authority_scope_ref 'nodes.missing.services.http' " "does not reference any defined targetable element", ), @@ -800,17 +800,17 @@ def test_act_609_ai_offensive_behavior_refs_allow_governed_extensions(): def test_behavior_specification_references_fail_closed(field: str, replacement: str, expected: str): behavior_spec = textwrap.dedent( """ - behavior-specifications: + behavior_specifications: red-scan-behavior: - semantic-version: 1.0.0 - lifecycle-state: active - participant-refs: [red-agent] - participant-role-refs: [red] - action-contract-refs: [scan] - observation-boundary-refs: [red-view] - authority-scope-refs: [nodes.web.services.http] - behavior-mode: policy-directed - extension-policy: governed-extension + semantic_version: 1.0.0 + lifecycle_state: active + participant_refs: [red-agent] + participant_role_refs: [red] + action_contract_refs: [scan] + observation_boundary_refs: [red-view] + authority_scope_refs: [nodes.web.services.http] + behavior_mode: policy-directed + extension_policy: governed-extension """ ) scenario = _scenario_yaml() + behavior_spec.replace(field, replacement) @@ -824,14 +824,14 @@ def test_behavior_specification_references_fail_closed(field: str, replacement: def test_behavior_specification_behavior_mode_uses_governed_vocabulary(): scenario = _scenario_yaml() + textwrap.dedent( """ - behavior-specifications: + behavior_specifications: red-scan-behavior: - semantic-version: 1.0.0 - lifecycle-state: active - participant-refs: [red-agent] - action-contract-refs: [scan] - behavior-mode: supervised - extension-policy: governed-extension + semantic_version: 1.0.0 + lifecycle_state: active + participant_refs: [red-agent] + action_contract_refs: [scan] + behavior_mode: supervised + extension_policy: governed-extension """ ) @@ -844,14 +844,14 @@ def test_behavior_specification_behavior_mode_uses_governed_vocabulary(): def test_behavior_specification_offensive_behavior_refs_use_governed_vocabulary(): scenario = _scenario_yaml() + textwrap.dedent( """ - behavior-specifications: + behavior_specifications: red-scan-behavior: - semantic-version: 1.0.0 - lifecycle-state: active - participant-refs: [red-agent] - action-contract-refs: [scan] - offensive-behavior-refs: [fabricated-attack] - extension-policy: governed-extension + semantic_version: 1.0.0 + lifecycle_state: active + participant_refs: [red-agent] + action_contract_refs: [scan] + offensive_behavior_refs: [fabricated-attack] + extension_policy: governed-extension """ ) @@ -864,14 +864,14 @@ def test_behavior_specification_offensive_behavior_refs_use_governed_vocabulary( def test_behavior_specification_ai_offensive_behavior_refs_use_governed_vocabulary(): scenario = _scenario_yaml() + textwrap.dedent( """ - behavior-specifications: + behavior_specifications: red-scan-behavior: - semantic-version: 1.0.0 - lifecycle-state: active - participant-refs: [red-agent] - action-contract-refs: [scan] - ai-offensive-behavior-refs: [fabricated-ai-attack] - extension-policy: governed-extension + semantic_version: 1.0.0 + lifecycle_state: active + participant_refs: [red-agent] + action_contract_refs: [scan] + ai_offensive_behavior_refs: [fabricated-ai-attack] + extension_policy: governed-extension """ ) @@ -884,14 +884,14 @@ def test_behavior_specification_ai_offensive_behavior_refs_use_governed_vocabula def test_behavior_specification_backend_feature_refs_use_governed_vocabulary(): scenario = _scenario_yaml() + textwrap.dedent( """ - behavior-specifications: + behavior_specifications: red-scan-behavior: - semantic-version: 1.0.0 - lifecycle-state: active - participant-refs: [red-agent] - action-contract-refs: [scan] - backend-feature-support-refs: [participant-behavior-history] - extension-policy: governed-extension + semantic_version: 1.0.0 + lifecycle_state: active + participant_refs: [red-agent] + action_contract_refs: [scan] + backend_feature_support_refs: [participant-behavior-history] + extension_policy: governed-extension """ ) @@ -907,14 +907,14 @@ def test_behavior_specification_backend_feature_refs_use_governed_vocabulary(): def test_behavior_specification_evidence_contract_refs_use_published_contract_ids(): scenario = _scenario_yaml() + textwrap.dedent( """ - behavior-specifications: + behavior_specifications: red-scan-behavior: - semantic-version: 1.0.0 - lifecycle-state: active - participant-refs: [red-agent] - action-contract-refs: [scan] - evidence-contract-refs: [raw-terminal-log-v1] - extension-policy: governed-extension + semantic_version: 1.0.0 + lifecycle_state: active + participant_refs: [red-agent] + action_contract_refs: [scan] + evidence_contract_refs: [raw-terminal-log-v1] + extension_policy: governed-extension """ ) @@ -930,13 +930,13 @@ def test_behavior_specification_evidence_contract_refs_use_published_contract_id def test_behavior_specification_extension_keys_are_governed(): scenario = _scenario_yaml() + textwrap.dedent( """ - behavior-specifications: + behavior_specifications: red-scan-behavior: - semantic-version: 1.0.0 - lifecycle-state: active - participant-refs: [red-agent] - action-contract-refs: [scan] - extension-policy: governed-extension + semantic_version: 1.0.0 + lifecycle_state: active + participant_refs: [red-agent] + action_contract_refs: [scan] + extension_policy: governed-extension extensions: custom-mode: note: ungoverned @@ -968,8 +968,8 @@ def test_agent_observation_boundaries_must_resolve_to_declared_boundaries(): def test_participant_interactions_must_resolve_related_action_contracts(): scenario = _scenario_yaml().replace( - " shared-state-refs: [nodes.web.services.http]", - (" related-actions: [coordinate]\n shared-state-refs: [nodes.web.services.http]"), + " shared_state_refs: [nodes.web.services.http]", + (" related_actions: [coordinate]\n shared_state_refs: [nodes.web.services.http]"), ) with pytest.raises(SDLValidationError) as excinfo: @@ -997,8 +997,8 @@ def test_participant_interactions_must_resolve_targets(): def test_participant_interactions_must_resolve_shared_state_refs(): scenario = _scenario_yaml().replace( - "shared-state-refs: [nodes.web.services.http]", - "shared-state-refs: [nodes.missing.services.http]", + "shared_state_refs: [nodes.web.services.http]", + "shared_state_refs: [nodes.missing.services.http]", ) with pytest.raises(SDLValidationError) as excinfo: @@ -1046,58 +1046,58 @@ def test_view_relation_timeline_tracks_inference_and_concealment_transitions(): scenario = ( _scenario_yaml() .replace( - "hidden-refs: [nodes.web, content.private-answer-key]", - "hidden-refs: [nodes.web, content.private-answer-key, nodes.web.services.http]", + "hidden_refs: [nodes.web, content.private-answer-key]", + "hidden_refs: [nodes.web, content.private-answer-key, nodes.web.services.http]", ) .replace( - " - information-ref: evidence.scan-output\n" - " boundary-class: archival_evidence\n" + " - information_ref: evidence.scan-output\n" + " boundary_class: archival_evidence\n" " disposition: evidence_only\n" - " visibility-basis: archival run evidence reference\n" - " evidence-refs: [evidence.scan-output]", - " - information-ref: nodes.web.services.http\n" - " boundary-class: observable_resource\n" + " visibility_basis: archival run evidence reference\n" + " evidence_refs: [evidence.scan-output]", + " - information_ref: nodes.web.services.http\n" + " boundary_class: observable_resource\n" " disposition: hidden\n" - " visibility-basis: service is not known before scan output inference\n" - " - information-ref: evidence.scan-output\n" - " boundary-class: archival_evidence\n" + " visibility_basis: service is not known before scan output inference\n" + " - information_ref: evidence.scan-output\n" + " boundary_class: archival_evidence\n" " disposition: evidence_only\n" - " visibility-basis: archival run evidence reference\n" - " evidence-refs: [evidence.scan-output]", + " visibility_basis: archival run evidence reference\n" + " evidence_refs: [evidence.scan-output]", ) .replace( - " evidence-refs: [evidence.scan-output]\n" + " evidence_refs: [evidence.scan-output]\n" " certainty: high\n" - " latency-profile: terminal observation latency", - " evidence-refs: [evidence.scan-output]\n" + " latency_profile: terminal observation latency", + " evidence_refs: [evidence.scan-output]\n" " certainty: high\n" - " latency-profile: terminal observation latency\n" - " - transition-id: infer-http-service\n" - " transition-kind: inference\n" - " information-ref: nodes.web.services.http\n" + " latency_profile: terminal observation latency\n" + " - transition_id: infer-http-service\n" + " transition_kind: inference\n" + " information_ref: nodes.web.services.http\n" " trigger: interpret scan output\n" - " effective-from: episode-step:scan-0001:analysis\n" - " effective-order: 40\n" - " history-event-type: observation_emitted\n" - " action-instance-id: scan-0001\n" - " from-disposition: hidden\n" - " to-disposition: inferred\n" - " evidence-refs: [evidence.scan-output]\n" + " effective_from: episode-step:scan-0001:analysis\n" + " effective_order: 40\n" + " history_event_type: observation_emitted\n" + " action_instance_id: scan-0001\n" + " from_disposition: hidden\n" + " to_disposition: inferred\n" + " evidence_refs: [evidence.scan-output]\n" " certainty: medium\n" - " latency-profile: participant analysis latency\n" - " - transition-id: conceal-http-service\n" - " transition-kind: concealment\n" - " information-ref: nodes.web.services.http\n" + " latency_profile: participant analysis latency\n" + " - transition_id: conceal-http-service\n" + " transition_kind: concealment\n" + " information_ref: nodes.web.services.http\n" " trigger: redacted follow-up observation\n" - " effective-from: episode-step:scan-0001:redacted-observation\n" - " effective-order: 50\n" - " history-event-type: observation_emitted\n" - " action-instance-id: scan-0001\n" - " from-disposition: inferred\n" - " to-disposition: concealed\n" - " evidence-refs: [evidence.scan-output]\n" + " effective_from: episode-step:scan-0001:redacted-observation\n" + " effective_order: 50\n" + " history_event_type: observation_emitted\n" + " action_instance_id: scan-0001\n" + " from_disposition: inferred\n" + " to_disposition: concealed\n" + " evidence_refs: [evidence.scan-output]\n" " certainty: medium\n" - " latency-profile: redaction latency", + " latency_profile: redaction latency", ) ) @@ -1114,8 +1114,8 @@ def test_view_relation_timeline_tracks_inference_and_concealment_transitions(): def test_hidden_truth_cannot_be_observed_without_explicit_disclosure_rule(): scenario = _scenario_yaml().replace( - "observable-refs: []", - "observable-refs: [content.private-answer-key]", + "observable_refs: []", + "observable_refs: [content.private-answer-key]", ) with pytest.raises(SDLParseError) as excinfo: @@ -1128,8 +1128,8 @@ def test_hidden_truth_cannot_be_observed_without_explicit_disclosure_rule(): def test_evidence_only_refs_cannot_be_boundary_observable_refs(): scenario = _scenario_yaml().replace( - "observable-refs: []", - "observable-refs: [evidence.scan-output]", + "observable_refs: []", + "observable_refs: [evidence.scan-output]", ) with pytest.raises(SDLParseError) as excinfo: @@ -1143,26 +1143,26 @@ def test_evidence_only_refs_cannot_be_boundary_observable_refs(): def test_hidden_truth_disclosure_is_separate_from_observable_projection(): scenario = _scenario_yaml().replace( - " evidence-refs: [evidence.scan-output]\n" + " evidence_refs: [evidence.scan-output]\n" " certainty: high\n" - " latency-profile: terminal observation latency", - " evidence-refs: [evidence.scan-output]\n" + " latency_profile: terminal observation latency", + " evidence_refs: [evidence.scan-output]\n" " certainty: high\n" - " latency-profile: terminal observation latency\n" - " - transition-id: disclose-answer-key\n" - " transition-kind: disclosure\n" - " information-ref: content.private-answer-key\n" + " latency_profile: terminal observation latency\n" + " - transition_id: disclose-answer-key\n" + " transition_kind: disclosure\n" + " information_ref: content.private-answer-key\n" " trigger: episode close adjudication\n" - " effective-from: episode-close\n" - " effective-order: 100\n" - " history-event-type: episode_close\n" - " from-disposition: hidden\n" - " to-disposition: disclosed\n" - " disclosure-rule: reveal answer key after episode close\n" - " evidence-refs: [evidence.scan-output]\n" + " effective_from: episode-close\n" + " effective_order: 100\n" + " history_event_type: episode_close\n" + " from_disposition: hidden\n" + " to_disposition: disclosed\n" + " disclosure_rule: reveal answer key after episode close\n" + " evidence_refs: [evidence.scan-output]\n" " certainty: high\n" - " latency-profile: post-run adjudication latency\n" - " realized-backend-disclosure: emitted only in post-run adjudication view", + " latency_profile: post-run adjudication latency\n" + " realized_backend_disclosure: emitted only in post-run adjudication view", ) model = compile_runtime_model(parse_sdl(scenario)) @@ -1178,18 +1178,18 @@ def test_hidden_truth_disclosure_is_separate_from_observable_projection(): def test_hidden_truth_disclosure_does_not_make_observable_refs_safe(): scenario = _scenario_yaml() scenario = scenario.replace( - "observable-refs: []", - "observable-refs: [content.private-answer-key]", + "observable_refs: []", + "observable_refs: [content.private-answer-key]", ).replace( - " - information-ref: content.private-answer-key\n" - " boundary-class: private_answer_key\n" + " - information_ref: content.private-answer-key\n" + " boundary_class: private_answer_key\n" " disposition: hidden\n" - " visibility-basis: adjudication-only hidden truth", - " - information-ref: content.private-answer-key\n" - " boundary-class: private_answer_key\n" + " visibility_basis: adjudication-only hidden truth", + " - information_ref: content.private-answer-key\n" + " boundary_class: private_answer_key\n" " disposition: disclosed\n" - " visibility-basis: explicit evaluator disclosure\n" - " disclosure-rule: reveal answer key after episode close", + " visibility_basis: explicit evaluator disclosure\n" + " disclosure_rule: reveal answer key after episode close", ) with pytest.raises(SDLParseError) as excinfo: @@ -1200,14 +1200,14 @@ def test_hidden_truth_disclosure_does_not_make_observable_refs_safe(): def test_private_answer_key_view_rule_requires_disclosure_rule_when_exposed(): scenario = _scenario_yaml().replace( - " - information-ref: content.private-answer-key\n" - " boundary-class: private_answer_key\n" + " - information_ref: content.private-answer-key\n" + " boundary_class: private_answer_key\n" " disposition: hidden\n" - " visibility-basis: adjudication-only hidden truth", - " - information-ref: content.private-answer-key\n" - " boundary-class: private_answer_key\n" + " visibility_basis: adjudication-only hidden truth", + " - information_ref: content.private-answer-key\n" + " boundary_class: private_answer_key\n" " disposition: disclosed\n" - " visibility-basis: explicit evaluator disclosure", + " visibility_basis: explicit evaluator disclosure", ) with pytest.raises(SDLParseError) as excinfo: @@ -1218,24 +1218,24 @@ def test_private_answer_key_view_rule_requires_disclosure_rule_when_exposed(): def test_disclosure_transition_requires_disclosure_rule(): scenario = _scenario_yaml().replace( - " evidence-refs: [evidence.scan-output]\n" + " evidence_refs: [evidence.scan-output]\n" " certainty: high\n" - " latency-profile: terminal observation latency", - " evidence-refs: [evidence.scan-output]\n" + " latency_profile: terminal observation latency", + " evidence_refs: [evidence.scan-output]\n" " certainty: high\n" - " latency-profile: terminal observation latency\n" - " - transition-id: disclose-answer-key\n" - " transition-kind: disclosure\n" - " information-ref: content.private-answer-key\n" + " latency_profile: terminal observation latency\n" + " - transition_id: disclose-answer-key\n" + " transition_kind: disclosure\n" + " information_ref: content.private-answer-key\n" " trigger: episode close adjudication\n" - " effective-from: episode-close\n" - " effective-order: 100\n" - " history-event-type: episode_close\n" - " from-disposition: hidden\n" - " to-disposition: disclosed\n" - " evidence-refs: [evidence.scan-output]\n" + " effective_from: episode-close\n" + " effective_order: 100\n" + " history_event_type: episode_close\n" + " from_disposition: hidden\n" + " to_disposition: disclosed\n" + " evidence_refs: [evidence.scan-output]\n" " certainty: high\n" - " latency-profile: post-run adjudication latency", + " latency_profile: post-run adjudication latency", ) with pytest.raises(SDLParseError) as excinfo: @@ -1246,14 +1246,14 @@ def test_disclosure_transition_requires_disclosure_rule(): def test_transition_from_disposition_must_match_initial_view_rule(): scenario = _scenario_yaml().replace( - " - information-ref: nodes.web\n" - " boundary-class: observable_resource\n" + " - information_ref: nodes.web\n" + " boundary_class: observable_resource\n" " disposition: hidden\n" - " visibility-basis: service is not known before terminal scan output", - " - information-ref: nodes.web\n" - " boundary-class: observable_resource\n" + " visibility_basis: service is not known before terminal scan output", + " - information_ref: nodes.web\n" + " boundary_class: observable_resource\n" " disposition: observable\n" - " visibility-basis: incorrectly declared initially visible", + " visibility_basis: incorrectly declared initially visible", ) with pytest.raises(SDLParseError) as excinfo: @@ -1267,14 +1267,14 @@ def test_transition_from_disposition_must_match_initial_view_rule(): def test_sensitive_view_rule_cannot_be_directly_observable(): scenario = _scenario_yaml().replace( - " - information-ref: content.private-answer-key\n" - " boundary-class: private_answer_key\n" + " - information_ref: content.private-answer-key\n" + " boundary_class: private_answer_key\n" " disposition: hidden\n" - " visibility-basis: adjudication-only hidden truth", - " - information-ref: content.private-answer-key\n" - " boundary-class: private_answer_key\n" + " visibility_basis: adjudication-only hidden truth", + " - information_ref: content.private-answer-key\n" + " boundary_class: private_answer_key\n" " disposition: observable\n" - " visibility-basis: adjudication-only hidden truth", + " visibility_basis: adjudication-only hidden truth", ) with pytest.raises(SDLParseError) as excinfo: @@ -1287,8 +1287,8 @@ def test_hidden_truth_view_rule_cannot_be_directly_observable(): scenario = ( _scenario_yaml() .replace( - " boundary-class: private_answer_key", - " boundary-class: hidden_truth", + " boundary_class: private_answer_key", + " boundary_class: hidden_truth", ) .replace( " disposition: hidden", @@ -1304,25 +1304,25 @@ def test_hidden_truth_view_rule_cannot_be_directly_observable(): def test_sensitive_inference_transition_requires_disclosure_rule(): scenario = _scenario_yaml().replace( - " evidence-refs: [evidence.scan-output]\n" + " evidence_refs: [evidence.scan-output]\n" " certainty: high\n" - " latency-profile: terminal observation latency", - " evidence-refs: [evidence.scan-output]\n" + " latency_profile: terminal observation latency", + " evidence_refs: [evidence.scan-output]\n" " certainty: high\n" - " latency-profile: terminal observation latency\n" - " - transition-id: infer-answer-key\n" - " transition-kind: inference\n" - " information-ref: content.private-answer-key\n" + " latency_profile: terminal observation latency\n" + " - transition_id: infer-answer-key\n" + " transition_kind: inference\n" + " information_ref: content.private-answer-key\n" " trigger: leaked benchmark clue\n" - " effective-from: episode-step:scan-0001:leak\n" - " effective-order: 40\n" - " history-event-type: observation_emitted\n" - " action-instance-id: scan-0001\n" - " from-disposition: hidden\n" - " to-disposition: inferred\n" - " evidence-refs: [evidence.scan-output]\n" + " effective_from: episode-step:scan-0001:leak\n" + " effective_order: 40\n" + " history_event_type: observation_emitted\n" + " action_instance_id: scan-0001\n" + " from_disposition: hidden\n" + " to_disposition: inferred\n" + " evidence_refs: [evidence.scan-output]\n" " certainty: low\n" - " latency-profile: terminal observation latency", + " latency_profile: terminal observation latency", ) with pytest.raises(SDLParseError) as excinfo: @@ -1333,8 +1333,8 @@ def test_sensitive_inference_transition_requires_disclosure_rule(): def test_hidden_truth_evidence_reference_requires_evidence_only_rule(): scenario = _scenario_yaml().replace( - "evidence-refs: [evidence.scan-output]", - "evidence-refs: [evidence.scan-output, content.private-answer-key]", + "evidence_refs: [evidence.scan-output]", + "evidence_refs: [evidence.scan-output, content.private-answer-key]", ) with pytest.raises(SDLParseError) as excinfo: @@ -1347,8 +1347,8 @@ def test_hidden_truth_evidence_reference_requires_evidence_only_rule(): def test_view_rule_information_ref_must_be_declared_by_boundary_refs(): scenario = _scenario_yaml().replace( - "information-ref: nodes.web", - "information-ref: nodes.db", + "information_ref: nodes.web", + "information_ref: nodes.db", ) with pytest.raises(SDLValidationError) as excinfo: @@ -1366,16 +1366,16 @@ def test_view_rule_information_ref_must_be_declared_by_boundary_refs(): def test_view_rule_evidence_ref_must_be_declared_by_boundary_evidence_refs(): scenario = _scenario_yaml().replace( - " - information-ref: evidence.scan-output\n" - " boundary-class: archival_evidence\n" + " - information_ref: evidence.scan-output\n" + " boundary_class: archival_evidence\n" " disposition: evidence_only\n" - " visibility-basis: archival run evidence reference\n" - " evidence-refs: [evidence.scan-output]", - " - information-ref: evidence.scan-output\n" - " boundary-class: archival_evidence\n" + " visibility_basis: archival run evidence reference\n" + " evidence_refs: [evidence.scan-output]", + " - information_ref: evidence.scan-output\n" + " boundary_class: archival_evidence\n" " disposition: evidence_only\n" - " visibility-basis: archival run evidence reference\n" - " evidence-refs: [evidence.missing]", + " visibility_basis: archival run evidence reference\n" + " evidence_refs: [evidence.missing]", ) with pytest.raises(SDLValidationError) as excinfo: @@ -1388,8 +1388,8 @@ def test_view_rule_evidence_ref_must_be_declared_by_boundary_evidence_refs(): def test_view_transition_evidence_ref_must_be_declared_by_boundary_evidence_refs(): scenario = _scenario_yaml().replace( - " evidence-refs: [evidence.scan-output]\n certainty: high", - " evidence-refs: [evidence.missing]\n certainty: high", + " evidence_refs: [evidence.scan-output]\n certainty: high", + " evidence_refs: [evidence.missing]\n certainty: high", ) with pytest.raises(SDLValidationError) as excinfo: @@ -1403,8 +1403,8 @@ def test_view_transition_evidence_ref_must_be_declared_by_boundary_evidence_refs def test_view_transition_to_disposition_must_match_transition_kind(): scenario = _scenario_yaml().replace( - " to-disposition: discovered", - " to-disposition: inferred", + " to_disposition: discovered", + " to_disposition: inferred", ) with pytest.raises(SDLParseError) as excinfo: @@ -1417,29 +1417,29 @@ def test_view_transition_requires_matching_view_rule(): scenario = ( _scenario_yaml() .replace( - "hidden-refs: [nodes.web, content.private-answer-key]", - "hidden-refs: [nodes.web, content.private-answer-key, nodes.web.services.http]", + "hidden_refs: [nodes.web, content.private-answer-key]", + "hidden_refs: [nodes.web, content.private-answer-key, nodes.web.services.http]", ) .replace( - " evidence-refs: [evidence.scan-output]\n" + " evidence_refs: [evidence.scan-output]\n" " certainty: high\n" - " latency-profile: terminal observation latency", - " evidence-refs: [evidence.scan-output]\n" + " latency_profile: terminal observation latency", + " evidence_refs: [evidence.scan-output]\n" " certainty: high\n" - " latency-profile: terminal observation latency\n" - " - transition-id: infer-http-service\n" - " transition-kind: inference\n" - " information-ref: nodes.web.services.http\n" + " latency_profile: terminal observation latency\n" + " - transition_id: infer-http-service\n" + " transition_kind: inference\n" + " information_ref: nodes.web.services.http\n" " trigger: interpret scan output\n" - " effective-from: episode-step:scan-0001:analysis\n" - " effective-order: 40\n" - " history-event-type: observation_emitted\n" - " action-instance-id: scan-0001\n" - " from-disposition: hidden\n" - " to-disposition: inferred\n" - " evidence-refs: [evidence.scan-output]\n" + " effective_from: episode-step:scan-0001:analysis\n" + " effective_order: 40\n" + " history_event_type: observation_emitted\n" + " action_instance_id: scan-0001\n" + " from_disposition: hidden\n" + " to_disposition: inferred\n" + " evidence_refs: [evidence.scan-output]\n" " certainty: medium\n" - " latency-profile: participant analysis latency", + " latency_profile: participant analysis latency", ) ) @@ -1451,16 +1451,16 @@ def test_view_transition_requires_matching_view_rule(): def test_view_rules_require_unique_information_refs(): scenario = _scenario_yaml().replace( - " - information-ref: evidence.scan-output\n" - " boundary-class: archival_evidence\n" + " - information_ref: evidence.scan-output\n" + " boundary_class: archival_evidence\n" " disposition: evidence_only\n" - " visibility-basis: archival run evidence reference\n" - " evidence-refs: [evidence.scan-output]", - " - information-ref: nodes.web\n" - " boundary-class: archival_evidence\n" + " visibility_basis: archival run evidence reference\n" + " evidence_refs: [evidence.scan-output]", + " - information_ref: nodes.web\n" + " boundary_class: archival_evidence\n" " disposition: evidence_only\n" - " visibility-basis: archival run evidence reference\n" - " evidence-refs: [evidence.scan-output]", + " visibility_basis: archival run evidence reference\n" + " evidence_refs: [evidence.scan-output]", ) with pytest.raises(SDLParseError) as excinfo: @@ -1471,25 +1471,25 @@ def test_view_rules_require_unique_information_refs(): def test_view_transitions_require_unique_transition_ids(): scenario = _scenario_yaml().replace( - " evidence-refs: [evidence.scan-output]\n" + " evidence_refs: [evidence.scan-output]\n" " certainty: high\n" - " latency-profile: terminal observation latency", - " evidence-refs: [evidence.scan-output]\n" + " latency_profile: terminal observation latency", + " evidence_refs: [evidence.scan-output]\n" " certainty: high\n" - " latency-profile: terminal observation latency\n" - " - transition-id: discover-web-service\n" - " transition-kind: discovery\n" - " information-ref: nodes.web\n" + " latency_profile: terminal observation latency\n" + " - transition_id: discover-web-service\n" + " transition_kind: discovery\n" + " information_ref: nodes.web\n" " trigger: duplicate scan terminal observation\n" - " effective-from: episode-step:scan-0001:duplicate-terminal-observation\n" - " effective-order: 31\n" - " history-event-type: observation_emitted\n" - " action-instance-id: scan-0001\n" - " from-disposition: hidden\n" - " to-disposition: discovered\n" - " evidence-refs: [evidence.scan-output]\n" + " effective_from: episode-step:scan-0001:duplicate-terminal-observation\n" + " effective_order: 31\n" + " history_event_type: observation_emitted\n" + " action_instance_id: scan-0001\n" + " from_disposition: hidden\n" + " to_disposition: discovered\n" + " evidence_refs: [evidence.scan-output]\n" " certainty: high\n" - " latency-profile: terminal observation latency", + " latency_profile: terminal observation latency", ) with pytest.raises(SDLParseError) as excinfo: @@ -1500,8 +1500,8 @@ def test_view_transitions_require_unique_transition_ids(): def test_view_transition_from_and_to_dispositions_must_differ(): scenario = _scenario_yaml().replace( - " from-disposition: hidden\n to-disposition: discovered", - " from-disposition: discovered\n to-disposition: discovered", + " from_disposition: hidden\n to_disposition: discovered", + " from_disposition: discovered\n to_disposition: discovered", ) with pytest.raises(SDLParseError) as excinfo: @@ -1512,25 +1512,25 @@ def test_view_transition_from_and_to_dispositions_must_differ(): def test_view_transition_from_disposition_must_match_current_relation(): scenario = _scenario_yaml().replace( - " evidence-refs: [evidence.scan-output]\n" + " evidence_refs: [evidence.scan-output]\n" " certainty: high\n" - " latency-profile: terminal observation latency", - " evidence-refs: [evidence.scan-output]\n" + " latency_profile: terminal observation latency", + " evidence_refs: [evidence.scan-output]\n" " certainty: high\n" - " latency-profile: terminal observation latency\n" - " - transition-id: conceal-web-service\n" - " transition-kind: concealment\n" - " information-ref: nodes.web\n" + " latency_profile: terminal observation latency\n" + " - transition_id: conceal-web-service\n" + " transition_kind: concealment\n" + " information_ref: nodes.web\n" " trigger: redacted scan follow-up\n" - " effective-from: episode-step:scan-0001:redacted-observation\n" - " effective-order: 40\n" - " history-event-type: observation_emitted\n" - " action-instance-id: scan-0001\n" - " from-disposition: hidden\n" - " to-disposition: concealed\n" - " evidence-refs: [evidence.scan-output]\n" + " effective_from: episode-step:scan-0001:redacted-observation\n" + " effective_order: 40\n" + " history_event_type: observation_emitted\n" + " action_instance_id: scan-0001\n" + " from_disposition: hidden\n" + " to_disposition: concealed\n" + " evidence_refs: [evidence.scan-output]\n" " certainty: medium\n" - " latency-profile: redaction latency", + " latency_profile: redaction latency", ) with pytest.raises(SDLParseError) as excinfo: @@ -1544,21 +1544,21 @@ def test_view_transition_from_disposition_must_match_current_relation(): def test_view_transition_effective_order_drives_timeline_not_declaration_order(): scenario = _scenario_yaml().replace( - " - transition-id: discover-web-service\n", - " - transition-id: infer-web-service\n" - " transition-kind: inference\n" - " information-ref: nodes.web\n" + " - transition_id: discover-web-service\n", + " - transition_id: infer-web-service\n" + " transition_kind: inference\n" + " information_ref: nodes.web\n" " trigger: participant interprets terminal scan observation\n" - " effective-from: episode-step:scan-0001:analysis\n" - " effective-order: 40\n" - " history-event-type: observation_emitted\n" - " action-instance-id: scan-0001\n" - " from-disposition: discovered\n" - " to-disposition: inferred\n" - " evidence-refs: [evidence.scan-output]\n" + " effective_from: episode-step:scan-0001:analysis\n" + " effective_order: 40\n" + " history_event_type: observation_emitted\n" + " action_instance_id: scan-0001\n" + " from_disposition: discovered\n" + " to_disposition: inferred\n" + " evidence_refs: [evidence.scan-output]\n" " certainty: medium\n" - " latency-profile: participant analysis latency\n" - " - transition-id: discover-web-service\n", + " latency_profile: participant analysis latency\n" + " - transition_id: discover-web-service\n", ) model = compile_runtime_model(parse_sdl(scenario)) @@ -1577,25 +1577,25 @@ def test_view_transition_effective_order_drives_timeline_not_declaration_order() def test_view_transitions_require_unique_effective_order_values(): scenario = _scenario_yaml().replace( - " evidence-refs: [evidence.scan-output]\n" + " evidence_refs: [evidence.scan-output]\n" " certainty: high\n" - " latency-profile: terminal observation latency", - " evidence-refs: [evidence.scan-output]\n" + " latency_profile: terminal observation latency", + " evidence_refs: [evidence.scan-output]\n" " certainty: high\n" - " latency-profile: terminal observation latency\n" - " - transition-id: duplicate-effective-order\n" - " transition-kind: discovery\n" - " information-ref: nodes.web\n" + " latency_profile: terminal observation latency\n" + " - transition_id: duplicate-effective-order\n" + " transition_kind: discovery\n" + " information_ref: nodes.web\n" " trigger: duplicate scan terminal observation\n" - " effective-from: episode-step:scan-0001:duplicate-terminal-observation\n" - " effective-order: 30\n" - " history-event-type: observation_emitted\n" - " action-instance-id: scan-0001\n" - " from-disposition: hidden\n" - " to-disposition: discovered\n" - " evidence-refs: [evidence.scan-output]\n" + " effective_from: episode-step:scan-0001:duplicate-terminal-observation\n" + " effective_order: 30\n" + " history_event_type: observation_emitted\n" + " action_instance_id: scan-0001\n" + " from_disposition: hidden\n" + " to_disposition: discovered\n" + " evidence_refs: [evidence.scan-output]\n" " certainty: high\n" - " latency-profile: terminal observation latency", + " latency_profile: terminal observation latency", ) with pytest.raises(SDLParseError) as excinfo: @@ -1606,13 +1606,13 @@ def test_view_transitions_require_unique_effective_order_values(): def test_view_transitions_require_evidence_certainty_and_latency(): scenario = _scenario_yaml().replace( - " to-disposition: discovered\n" - " evidence-refs: [evidence.scan-output]\n" + " to_disposition: discovered\n" + " evidence_refs: [evidence.scan-output]\n" " certainty: high\n" - " latency-profile: terminal observation latency", - " to-disposition: discovered\n" + " latency_profile: terminal observation latency", + " to_disposition: discovered\n" " certainty: high\n" - " latency-profile: terminal observation latency", + " latency_profile: terminal observation latency", ) with pytest.raises(SDLParseError) as excinfo: @@ -1625,11 +1625,11 @@ def test_coordination_interactions_require_related_actions(): scenario = ( _scenario_yaml() .replace( - "interaction-class: shared_state_change", - "interaction-class: coordination", + "interaction_class: shared_state_change", + "interaction_class: coordination", ) .replace( - " shared-state-refs: [nodes.web.services.http]\n", + " shared_state_refs: [nodes.web.services.http]\n", "", ) ) @@ -1644,11 +1644,11 @@ def test_contention_interactions_require_shared_state_refs(): scenario = ( _scenario_yaml() .replace( - "interaction-class: shared_state_change", - "interaction-class: contention", + "interaction_class: shared_state_change", + "interaction_class: contention", ) .replace( - " shared-state-refs: [nodes.web.services.http]\n", + " shared_state_refs: [nodes.web.services.http]\n", "", ) ) @@ -1839,25 +1839,25 @@ def test_behavior_history_rejects_observation_details_that_expose_hidden_truth() def test_behavior_history_rejects_future_episode_close_disclosure_in_observation_details(): scenario = _scenario_yaml().replace( - " evidence-refs: [evidence.scan-output]\n" + " evidence_refs: [evidence.scan-output]\n" " certainty: high\n" - " latency-profile: terminal observation latency", - " evidence-refs: [evidence.scan-output]\n" + " latency_profile: terminal observation latency", + " evidence_refs: [evidence.scan-output]\n" " certainty: high\n" - " latency-profile: terminal observation latency\n" - " - transition-id: disclose-answer-key\n" - " transition-kind: disclosure\n" - " information-ref: content.private-answer-key\n" + " latency_profile: terminal observation latency\n" + " - transition_id: disclose-answer-key\n" + " transition_kind: disclosure\n" + " information_ref: content.private-answer-key\n" " trigger: episode close adjudication\n" - " effective-from: episode-close\n" - " effective-order: 100\n" - " history-event-type: episode_close\n" - " from-disposition: hidden\n" - " to-disposition: disclosed\n" - " disclosure-rule: reveal answer key after episode close\n" - " evidence-refs: [evidence.scan-output]\n" + " effective_from: episode-close\n" + " effective_order: 100\n" + " history_event_type: episode_close\n" + " from_disposition: hidden\n" + " to_disposition: disclosed\n" + " disclosure_rule: reveal answer key after episode close\n" + " evidence_refs: [evidence.scan-output]\n" " certainty: high\n" - " latency-profile: post-run adjudication latency", + " latency_profile: post-run adjudication latency", ) model = compile_runtime_model(parse_sdl(scenario)) events = _complete_behavior_history_payloads(ACTION_INSTANCE) @@ -1886,25 +1886,25 @@ def test_behavior_history_rejects_future_episode_close_disclosure_in_observation def test_behavior_history_rejects_unresolved_episode_close_transition_anchor(): scenario = _scenario_yaml().replace( - " evidence-refs: [evidence.scan-output]\n" + " evidence_refs: [evidence.scan-output]\n" " certainty: high\n" - " latency-profile: terminal observation latency", - " evidence-refs: [evidence.scan-output]\n" + " latency_profile: terminal observation latency", + " evidence_refs: [evidence.scan-output]\n" " certainty: high\n" - " latency-profile: terminal observation latency\n" - " - transition-id: disclose-answer-key\n" - " transition-kind: disclosure\n" - " information-ref: content.private-answer-key\n" + " latency_profile: terminal observation latency\n" + " - transition_id: disclose-answer-key\n" + " transition_kind: disclosure\n" + " information_ref: content.private-answer-key\n" " trigger: episode close adjudication\n" - " effective-from: episode-close\n" - " effective-order: 100\n" - " history-event-type: episode_close\n" - " from-disposition: hidden\n" - " to-disposition: disclosed\n" - " disclosure-rule: reveal answer key after episode close\n" - " evidence-refs: [evidence.scan-output]\n" + " effective_from: episode-close\n" + " effective_order: 100\n" + " history_event_type: episode_close\n" + " from_disposition: hidden\n" + " to_disposition: disclosed\n" + " disclosure_rule: reveal answer key after episode close\n" + " evidence_refs: [evidence.scan-output]\n" " certainty: high\n" - " latency-profile: post-run adjudication latency", + " latency_profile: post-run adjudication latency", ) model = compile_runtime_model(parse_sdl(scenario)) @@ -2079,7 +2079,7 @@ def test_behavior_history_rejects_details_on_non_observation_events(): def test_behavior_history_rejects_unresolved_visibility_transition_anchor(): - scenario = _scenario_yaml().replace("action-instance-id: scan-0001", "action-instance-id: scan-9999") + scenario = _scenario_yaml().replace("action_instance_id: scan-0001", "action_instance_id: scan-9999") model = compile_runtime_model(parse_sdl(scenario)) violations = list( diff --git a/implementations/python/tests/test_sem_211_participant_action_semantics.py b/implementations/python/tests/test_sem_211_participant_action_semantics.py index db365cf41..916557c6c 100644 --- a/implementations/python/tests/test_sem_211_participant_action_semantics.py +++ b/implementations/python/tests/test_sem_211_participant_action_semantics.py @@ -48,108 +48,108 @@ def _scenario_yaml() -> str: entities: red-team: role: red - action-contracts: + action_contracts: scan: - semantic-version: 1.0.0 - lifecycle-state: active - behavioral-granularity: atomic - procedure-basis: nmap service discovery - realization-profile: backend-declared - fidelity-claim: records participant discovery intent and terminal observation + semantic_version: 1.0.0 + lifecycle_state: active + behavioral_granularity: atomic + procedure_basis: nmap service discovery + realization_profile: backend-declared + fidelity_claim: records participant discovery intent and terminal observation preconditions: - - precondition-id: authority-in-scope - precondition-class: authority + - precondition_id: authority-in-scope + precondition_class: authority description: red participant is authorized to scan the web service - support-refs: [agents.red-agent, nodes.web.services.http] - - precondition-id: target-service-present - precondition-class: target + support_refs: [agents.red-agent, nodes.web.services.http] + - precondition_id: target-service-present + precondition_class: target description: target service exists in the participant action scope - support-refs: [nodes.web.services.http] - - precondition-id: backend-can-realize-scan - precondition-class: realization + support_refs: [nodes.web.services.http] + - precondition_id: backend-can-realize-scan + precondition_class: realization description: backend can realize the scan action contract - support-refs: [backend.participant-runtime] + support_refs: [backend.participant-runtime] effects: - - effect-id: discover-http-service - effect-class: intended_effect + - effect_id: discover-http-service + effect_class: intended_effect description: participant may discover the web HTTP service - target-refs: [nodes.web.services.http] - - effect-id: scan-shared-knowledge-update - effect-class: side_effect + target_refs: [nodes.web.services.http] + - effect_id: scan-shared-knowledge-update + effect_class: side_effect description: participant-local service knowledge is updated - target-refs: [nodes.web.services.http] - - effect-id: terminal-scan-observation - effect-class: observation_effect + target_refs: [nodes.web.services.http] + - effect_id: terminal-scan-observation + effect_class: observation_effect description: terminal scan observation is emitted for the participant - evidence-refs: [evidence.scan-output] - - effect-id: participant-view-discovers-service - effect-class: visibility_effect + evidence_refs: [evidence.scan-output] + - effect_id: participant-view-discovers-service + effect_class: visibility_effect description: participant view marks the web node discovered - target-refs: [nodes.web] - - effect-id: scan-evidence - effect-class: evidence_effect + target_refs: [nodes.web] + - effect_id: scan-evidence + effect_class: evidence_effect description: scan tool output is retained as run evidence - evidence-refs: [evidence.scan-output] - - effect-id: no-hidden-truth-change - effect-class: no_effect + evidence_refs: [evidence.scan-output] + - effect_id: no-hidden-truth-change + effect_class: no_effect description: scan does not expose hidden adjudication material - state-transition-effects: [participant knowledge expands] - failure-classes: + state_transition_effects: [participant knowledge expands] + failure_classes: - precondition_unsatisfied - unsupported_action - target_unavailable - timeout - backend_error - unknown - backend-failure-mappings: - - backend-error-code: backend.timeout - failure-class: timeout + backend_failure_mappings: + - backend_error_code: backend.timeout + failure_class: timeout diagnostic: backend reported action timeout - - backend-error-code: backend.not-supported - failure-class: unsupported_action + - backend_error_code: backend.not-supported + failure_class: unsupported_action diagnostic: backend lacks the scan action implementation interactions: - - interaction-class: shared_state_change + - interaction_class: shared_state_change target: nodes.web.services.http rationale: scan reads and updates participant-visible service knowledge - shared-state-refs: [nodes.web.services.http] - observation-boundaries: + shared_state_refs: [nodes.web.services.http] + observation_boundaries: red-view: - projection-basis: participant-local projection over observed services - observable-refs: [] - hidden-refs: [nodes.web] - evidence-refs: [evidence.scan-output] - redaction-policy: hidden refs never project without explicit disclosure - latency-profile: terminal observation emitted after state transition commit - view-rules: - - information-ref: nodes.web - boundary-class: observable_resource + projection_basis: participant-local projection over observed services + observable_refs: [] + hidden_refs: [nodes.web] + evidence_refs: [evidence.scan-output] + redaction_policy: hidden refs never project without explicit disclosure + latency_profile: terminal observation emitted after state transition commit + view_rules: + - information_ref: nodes.web + boundary_class: observable_resource disposition: hidden - visibility-basis: service is not known before terminal scan output - - information-ref: evidence.scan-output - boundary-class: archival_evidence + visibility_basis: service is not known before terminal scan output + - information_ref: evidence.scan-output + boundary_class: archival_evidence disposition: evidence_only - visibility-basis: archival run evidence reference - evidence-refs: [evidence.scan-output] - view-transitions: - - transition-id: discover-web-service - transition-kind: discovery - information-ref: nodes.web + visibility_basis: archival run evidence reference + evidence_refs: [evidence.scan-output] + view_transitions: + - transition_id: discover-web-service + transition_kind: discovery + information_ref: nodes.web trigger: scan terminal observation - effective-from: episode-step:scan-0001:terminal-observation - effective-order: 30 - history-event-type: observation_emitted - action-instance-id: scan-0001 - from-disposition: hidden - to-disposition: discovered - evidence-refs: [evidence.scan-output] + effective_from: episode-step:scan-0001:terminal-observation + effective_order: 30 + history_event_type: observation_emitted + action_instance_id: scan-0001 + from_disposition: hidden + to_disposition: discovered + evidence_refs: [evidence.scan-output] certainty: high - latency-profile: terminal observation latency + latency_profile: terminal observation latency agents: red-agent: entity: red-team actions: [scan] - observation-boundaries: [red-view] + observation_boundaries: [red-view] """ ) @@ -250,24 +250,24 @@ def _history_payloads_for_action_result(result: ParticipantActionResult) -> list def _scenario_with_hidden_action_result_ref() -> str: scenario = _scenario_yaml().replace( - "hidden-refs: [nodes.web]", - "hidden-refs: [nodes.web, content.private-answer-key]", + "hidden_refs: [nodes.web]", + "hidden_refs: [nodes.web, content.private-answer-key]", ) return scenario.replace( - " - information-ref: evidence.scan-output\n" - " boundary-class: archival_evidence\n" + " - information_ref: evidence.scan-output\n" + " boundary_class: archival_evidence\n" " disposition: evidence_only\n" - " visibility-basis: archival run evidence reference\n" - " evidence-refs: [evidence.scan-output]", - " - information-ref: content.private-answer-key\n" - " boundary-class: private_answer_key\n" + " visibility_basis: archival run evidence reference\n" + " evidence_refs: [evidence.scan-output]", + " - information_ref: content.private-answer-key\n" + " boundary_class: private_answer_key\n" " disposition: hidden\n" - " visibility-basis: adjudication-only hidden truth\n" - " - information-ref: evidence.scan-output\n" - " boundary-class: archival_evidence\n" + " visibility_basis: adjudication-only hidden truth\n" + " - information_ref: evidence.scan-output\n" + " boundary_class: archival_evidence\n" " disposition: evidence_only\n" - " visibility-basis: archival run evidence reference\n" - " evidence-refs: [evidence.scan-output]", + " visibility_basis: archival run evidence reference\n" + " evidence_refs: [evidence.scan-output]", ) @@ -331,7 +331,7 @@ def test_action_contract_declares_sem_211_classes_and_compiles_them(): def test_legacy_string_preconditions_are_not_sem_211_contracts(): - scenario = _scenario_yaml().replace("precondition-class: authority", "precondition-class: legacy_string") + scenario = _scenario_yaml().replace("precondition_class: authority", "precondition_class: legacy_string") with pytest.raises(SDLParseError) as excinfo: parse_sdl(scenario) @@ -1021,8 +1021,8 @@ def test_action_result_summary_evidence_refs_must_be_grounded_in_reported_result def test_action_result_refs_must_be_authorized_by_observation_boundary(): scenario = _scenario_with_hidden_action_result_ref().replace( - "support-refs: [agents.red-agent, nodes.web.services.http]", - "support-refs: [agents.red-agent, nodes.web.services.http, content.private-answer-key]", + "support_refs: [agents.red-agent, nodes.web.services.http]", + "support_refs: [agents.red-agent, nodes.web.services.http, content.private-answer-key]", ) model = compile_runtime_model(parse_sdl(scenario)) hidden_authority = ParticipantActionPreconditionResult( @@ -1070,9 +1070,9 @@ def test_action_result_refs_must_be_authorized_by_observation_boundary(): def test_action_result_effect_targets_must_be_authorized_by_observation_boundary(): scenario = _scenario_with_hidden_action_result_ref().replace( - " target-refs: [nodes.web.services.http]\n - effect-id: terminal-scan-observation", - " target-refs: [nodes.web.services.http, content.private-answer-key]\n" - " - effect-id: terminal-scan-observation", + " target_refs: [nodes.web.services.http]\n - effect_id: terminal-scan-observation", + " target_refs: [nodes.web.services.http, content.private-answer-key]\n" + " - effect_id: terminal-scan-observation", ) model = compile_runtime_model(parse_sdl(scenario)) result = ParticipantActionResult( @@ -1114,8 +1114,8 @@ def test_action_result_effect_targets_must_be_authorized_by_observation_boundary def test_action_result_evidence_refs_must_be_authorized_by_observation_boundary(): scenario = _scenario_with_hidden_action_result_ref().replace( - "evidence-refs: [evidence.scan-output]", - "evidence-refs: [evidence.scan-output, content.private-answer-key]", + "evidence_refs: [evidence.scan-output]", + "evidence_refs: [evidence.scan-output, content.private-answer-key]", 1, ) model = compile_runtime_model(parse_sdl(scenario)) diff --git a/implementations/python/tests/test_sem_213_temporal_participant_semantics.py b/implementations/python/tests/test_sem_213_temporal_participant_semantics.py index 783bf77c5..40f3197f7 100644 --- a/implementations/python/tests/test_sem_213_temporal_participant_semantics.py +++ b/implementations/python/tests/test_sem_213_temporal_participant_semantics.py @@ -51,128 +51,128 @@ def _scenario_yaml() -> str: entities: red-team: role: red - action-contracts: + action_contracts: scan: - semantic-version: 1.0.0 - lifecycle-state: active - behavioral-granularity: atomic - procedure-basis: nmap service discovery - realization-profile: backend-declared - fidelity-claim: records participant discovery timing without claiming portable wall-clock fidelity + semantic_version: 1.0.0 + lifecycle_state: active + behavioral_granularity: atomic + procedure_basis: nmap service discovery + realization_profile: backend-declared + fidelity_claim: records participant discovery timing without claiming portable wall-clock fidelity preconditions: - - precondition-id: scheduled-window-open - precondition-class: temporal + - precondition_id: scheduled-window-open + precondition_class: temporal description: scan may start only inside the scenario maintenance window - support-refs: [windows.maintenance] - - precondition-id: backend-can-realize-scan - precondition-class: realization + support_refs: [windows.maintenance] + - precondition_id: backend-can-realize-scan + precondition_class: realization description: backend can realize the scan action contract - support-refs: [backend.participant-runtime] + support_refs: [backend.participant-runtime] effects: - - effect-id: terminal-scan-observation - effect-class: observation_effect + - effect_id: terminal-scan-observation + effect_class: observation_effect description: terminal scan observation is emitted for the participant - evidence-refs: [evidence.scan-output] - failure-classes: [precondition_unsatisfied, timeout, backend_error, unknown] - temporal-contracts: - - temporal-id: scan-schedule - temporal-kind: schedule - time-domain: scenario_time - clock-authority: scenario.author.clock - event-points: [submit, start] + evidence_refs: [evidence.scan-output] + failure_classes: [precondition_unsatisfied, timeout, backend_error, unknown] + temporal_contracts: + - temporal_id: scan-schedule + temporal_kind: schedule + time_domain: scenario_time + clock_authority: scenario.author.clock + event_points: [submit, start] description: scan is eligible only during the authored maintenance window - window-ref: windows.maintenance - ordering-basis: participant schedule relation, not raw timestamp causality - backend-disclosure-refs: [timing.remote-pacing] - - temporal-id: scan-cadence - temporal-kind: cadence - time-domain: episode_step - clock-authority: processor.episode-sequence - event-points: [submit] + window_ref: windows.maintenance + ordering_basis: participant schedule relation, not raw timestamp causality + backend_disclosure_refs: [timing.remote-pacing] + - temporal_id: scan-cadence + temporal_kind: cadence + time_domain: episode_step + clock_authority: processor.episode-sequence + event_points: [submit] description: scan attempts are rate-limited per participant episode - duration-ref: cadence.scan.per-episode - reset-boundary: participant episode reset starts a new cadence segment - replay-boundary: replay preserves the original cadence segment id - randomization-basis: seeded participant episode sequence - ordering-basis: participant episode sequence - backend-disclosure-refs: [timing.remote-pacing] - - temporal-id: scan-deadline - temporal-kind: deadline - time-domain: backend_time - clock-authority: backend.adapter.clock - event-points: [submit, deadline, end] + duration_ref: cadence.scan.per-episode + reset_boundary: participant episode reset starts a new cadence segment + replay_boundary: replay preserves the original cadence segment id + randomization_basis: seeded participant episode sequence + ordering_basis: participant episode sequence + backend_disclosure_refs: [timing.remote-pacing] + - temporal_id: scan-deadline + temporal_kind: deadline + time_domain: backend_time + clock_authority: backend.adapter.clock + event_points: [submit, deadline, end] description: backend must realize the scan before the participant timeout - duration-ref: duration.scan.deadline - reset-boundary: participant episode reset clears the deadline state - replay-boundary: replay reports the original deadline state - ordering-basis: backend event order relation - backend-disclosure-refs: [timing.remote-pacing, timing.serialization] - - temporal-id: scan-dwell - temporal-kind: dwell - time-domain: scenario_time - clock-authority: scenario.author.clock - event-points: [start, end] + duration_ref: duration.scan.deadline + reset_boundary: participant episode reset clears the deadline state + replay_boundary: replay reports the original deadline state + ordering_basis: backend event order relation + backend_disclosure_refs: [timing.remote-pacing, timing.serialization] + - temporal_id: scan-dwell + temporal_kind: dwell + time_domain: scenario_time + clock_authority: scenario.author.clock + event_points: [start, end] description: target service must remain in scope for the scan dwell window - window-ref: windows.maintenance - duration-ref: duration.scan.dwell - reset-boundary: participant episode reset clears dwell accumulation - replay-boundary: replay reports original dwell evidence - ordering-basis: scenario window relation - backend-disclosure-refs: [timing.serialization] - - temporal-id: scan-latency - temporal-kind: latency - time-domain: backend_time - clock-authority: backend.adapter.clock - event-points: [submit, observed] + window_ref: windows.maintenance + duration_ref: duration.scan.dwell + reset_boundary: participant episode reset clears dwell accumulation + replay_boundary: replay reports original dwell evidence + ordering_basis: scenario window relation + backend_disclosure_refs: [timing.serialization] + - temporal_id: scan-latency + temporal_kind: latency + time_domain: backend_time + clock_authority: backend.adapter.clock + event_points: [submit, observed] description: terminal observation latency is measured from submit to observation delivery - duration-ref: duration.scan.observation-latency - reset-boundary: participant episode reset closes the latency segment - replay-boundary: replay reports original latency evidence - ordering-basis: backend delivery order relation - backend-disclosure-refs: [timing.remote-pacing] - - temporal-id: scan-window - temporal-kind: time_window - time-domain: wall_clock_time - clock-authority: study.coordinator.clock - event-points: [window_open, window_close] + duration_ref: duration.scan.observation-latency + reset_boundary: participant episode reset closes the latency segment + replay_boundary: replay reports original latency evidence + ordering_basis: backend delivery order relation + backend_disclosure_refs: [timing.remote-pacing] + - temporal_id: scan-window + temporal_kind: time_window + time_domain: wall_clock_time + clock_authority: study.coordinator.clock + event_points: [window_open, window_close] description: study-level collection window for participant attempts - window-ref: study.collection-window - reset-boundary: participant episode reset does not change the study window - replay-boundary: replay reports the original study window - randomization-basis: study coordinator seed and cohort assignment - ordering-basis: study coordinator window relation - backend-disclosure-refs: [timing.remote-pacing] - backend-timing-disclosures: - - disclosure-id: timing.remote-pacing - disclosure-kind: pacing - support-mode: disclosed_limitation + window_ref: study.collection-window + reset_boundary: participant episode reset does not change the study window + replay_boundary: replay reports the original study window + randomization_basis: study coordinator seed and cohort assignment + ordering_basis: study coordinator window relation + backend_disclosure_refs: [timing.remote-pacing] + backend_timing_disclosures: + - disclosure_id: timing.remote-pacing + disclosure_kind: pacing + support_mode: disclosed_limitation description: remote backend pacing is best-effort and not portable semantic time - affected-temporal-ids: + affected_temporal_ids: - scan-schedule - scan-cadence - scan-deadline - scan-latency - scan-window limitations: [wall-clock pacing may lag backend event time] - - disclosure-id: timing.serialization - disclosure-kind: serialization - support-mode: disclosed_limitation + - disclosure_id: timing.serialization + disclosure_kind: serialization + support_mode: disclosed_limitation description: backend serializes scan realization before emitting terminal observation - affected-temporal-ids: [scan-deadline, scan-dwell] + affected_temporal_ids: [scan-deadline, scan-dwell] limitations: [serialized order is disclosed as realized order, not simultaneity] agents: red-agent: entity: red-team actions: [scan] - observation-boundaries: [red-view] - observation-boundaries: + observation_boundaries: [red-view] + observation_boundaries: red-view: - projection-basis: participant-local projection - observable-refs: [nodes.web.services.http] - hidden-refs: [] - evidence-refs: [evidence.scan-output] - redaction-policy: no hidden refs in this SEM-213 fixture - latency-profile: terminal observation emitted after backend realization + projection_basis: participant-local projection + observable_refs: [nodes.web.services.http] + hidden_refs: [] + evidence_refs: [evidence.scan-output] + redaction_policy: no hidden refs in this SEM-213 fixture + latency_profile: terminal observation emitted after backend realization """ ) @@ -224,7 +224,7 @@ def test_temporal_action_contract_declares_sem_213_and_compiles_it() -> None: def test_temporal_claims_require_time_domain_and_clock_authority() -> None: missing_clock_authority = _scenario_yaml().replace( - " clock-authority: scenario.author.clock\n", + " clock_authority: scenario.author.clock\n", "", 1, ) @@ -235,8 +235,8 @@ def test_temporal_claims_require_time_domain_and_clock_authority() -> None: def test_temporal_backend_disclosure_refs_fail_closed() -> None: unknown_backend_disclosure = _scenario_yaml().replace( - "backend-disclosure-refs: [timing.remote-pacing]", - "backend-disclosure-refs: [timing.unknown]", + "backend_disclosure_refs: [timing.remote-pacing]", + "backend_disclosure_refs: [timing.unknown]", 1, ) @@ -246,30 +246,30 @@ def test_temporal_backend_disclosure_refs_fail_closed() -> None: def test_temporal_contract_shapes_fail_closed() -> None: deadline_without_deadline_point = _scenario_yaml().replace( - "event-points: [submit, deadline, end]", - "event-points: [submit, end]", + "event_points: [submit, deadline, end]", + "event_points: [submit, end]", 1, ) dwell_without_sustained_window = _scenario_yaml().replace( - "event-points: [start, end]", - "event-points: [start]", + "event_points: [start, end]", + "event_points: [start]", 1, ) window_without_seed_basis = _scenario_yaml().replace( - " randomization-basis: study coordinator seed and cohort assignment\n", + " randomization_basis: study coordinator seed and cohort assignment\n", "", 1, ) temporal_claim_without_disclosure = _scenario_yaml().replace( - "backend-disclosure-refs: [timing.remote-pacing]", - "backend-disclosure-refs: []", + "backend_disclosure_refs: [timing.remote-pacing]", + "backend_disclosure_refs: []", 1, ) bounded_disclosure_without_bound = ( _scenario_yaml() .replace( - "support-mode: disclosed_limitation", - "support-mode: bounded", + "support_mode: disclosed_limitation", + "support_mode: bounded", 1, ) .replace( diff --git a/implementations/python/tests/test_sem_215_participant_outcome_interpretation.py b/implementations/python/tests/test_sem_215_participant_outcome_interpretation.py index ebf3d3ca7..fdd1b918f 100644 --- a/implementations/python/tests/test_sem_215_participant_outcome_interpretation.py +++ b/implementations/python/tests/test_sem_215_participant_outcome_interpretation.py @@ -74,112 +74,112 @@ def _scenario_yaml() -> str: verify: type: objective objective: exfil-objective - on-success: done - on-failure: done + on_success: done + on_failure: done done: type: end - action-contracts: + action_contracts: scan: - semantic-version: 1.0.0 - lifecycle-state: active - behavioral-granularity: atomic - procedure-basis: nmap service discovery - realization-profile: backend-declared - fidelity-claim: records participant discovery intent and terminal observation + semantic_version: 1.0.0 + lifecycle_state: active + behavioral_granularity: atomic + procedure_basis: nmap service discovery + realization_profile: backend-declared + fidelity_claim: records participant discovery intent and terminal observation preconditions: - - precondition-id: authority-in-scope - precondition-class: authority + - precondition_id: authority-in-scope + precondition_class: authority description: red participant is authorized to scan the web service - support-refs: [agents.red-agent] + support_refs: [agents.red-agent] effects: - - effect-id: scan-evidence - effect-class: evidence_effect + - effect_id: scan-evidence + effect_class: evidence_effect description: scan emits evidence even when the action fails - evidence-refs: [evidence.scan-output, evidence.alert] - - effect-id: detection-alert - effect-class: detection_effect + evidence_refs: [evidence.scan-output, evidence.alert] + - effect_id: detection-alert + effect_class: detection_effect description: scan may trigger a backend detection alert - target-refs: [alerts.ids.scan] - evidence-refs: [evidence.alert] - failure-classes: [precondition_unsatisfied, timeout, backend_error, unknown] - observation-boundaries: + target_refs: [alerts.ids.scan] + evidence_refs: [evidence.alert] + failure_classes: [precondition_unsatisfied, timeout, backend_error, unknown] + observation_boundaries: red-view: - projection-basis: participant-local projection over observed services - observable-refs: [nodes.web.services.http] - hidden-refs: [content.private-answer-key] - evidence-refs: [evidence.scan-output, evidence.alert] - redaction-policy: hidden refs never project without explicit disclosure - latency-profile: terminal observation emitted after state transition commit - view-rules: - - information-ref: nodes.web.services.http - boundary-class: observable_resource + projection_basis: participant-local projection over observed services + observable_refs: [nodes.web.services.http] + hidden_refs: [content.private-answer-key] + evidence_refs: [evidence.scan-output, evidence.alert] + redaction_policy: hidden refs never project without explicit disclosure + latency_profile: terminal observation emitted after state transition commit + view_rules: + - information_ref: nodes.web.services.http + boundary_class: observable_resource disposition: observable - visibility-basis: service is visible to the red participant - - information-ref: content.private-answer-key - boundary-class: private_answer_key + visibility_basis: service is visible to the red participant + - information_ref: content.private-answer-key + boundary_class: private_answer_key disposition: hidden - visibility-basis: adjudication-only hidden truth - - information-ref: evidence.alert - boundary-class: archival_evidence + visibility_basis: adjudication-only hidden truth + - information_ref: evidence.alert + boundary_class: archival_evidence disposition: evidence_only - visibility-basis: archival alert evidence reference - evidence-refs: [evidence.alert] + visibility_basis: archival alert evidence reference + evidence_refs: [evidence.alert] agents: red-agent: entity: red-team actions: [scan] - observation-boundaries: [red-view] - outcome-interpretation-rules: + observation_boundaries: [red-view] + outcome_interpretation_rules: scan-evidence-objective: - semantic-version: 1.0.0 - participant-scope: participant_local - observation-point-basis: terminal participant observation event - interpretation-basis: explicit SEM-215 mapping from local scan evidence to objective/evaluation meaning - evidence-refs: [evidence.alert] + semantic_version: 1.0.0 + participant_scope: participant_local + observation_point_basis: terminal participant observation event + interpretation_basis: explicit SEM-215 mapping from local scan evidence to objective/evaluation meaning + evidence_refs: [evidence.alert] limitations: - local scan result is not objective success by itself - reward remains a derived assessment signal - source-bindings: - - source-id: local-action - source-layer: participant_action_outcome + source_bindings: + - source_id: local-action + source_layer: participant_action_outcome ref: scan - interpretation-role: local action status input - evidence-refs: [evidence.scan-output] - - source-id: alert-evidence - source-layer: evidence_claim + interpretation_role: local action status input + evidence_refs: [evidence.scan-output] + - source_id: alert-evidence + source_layer: evidence_claim ref: evidence.alert - interpretation-role: alert evidence input - evidence-refs: [evidence.alert] - - source-id: scaffold - source-layer: scaffold_variant + interpretation_role: alert evidence input + evidence_refs: [evidence.alert] + - source_id: scaffold + source_layer: scaffold_variant ref: scaffold.standard - interpretation-role: benchmark context input - provenance-refs: [provenance.scaffold.standard] - target-bindings: - - target-id: objective-meaning - target-layer: objective_result + interpretation_role: benchmark context input + provenance_refs: [provenance.scaffold.standard] + target_bindings: + - target_id: objective-meaning + target_layer: objective_result ref: exfil-objective relation: evidence supports objective interpretation - evidence-refs: [evidence.alert] + evidence_refs: [evidence.alert] limitations: [objective success still requires evaluator confirmation] - - target-id: evaluation-meaning - target-layer: evaluation_result + - target_id: evaluation-meaning + target_layer: evaluation_result ref: exfil-eval relation: evidence is an input to evaluation meaning - evidence-refs: [evidence.alert] + evidence_refs: [evidence.alert] limitations: [evaluation result is not inferred from action status] - - target-id: workflow-meaning - target-layer: workflow_result + - target_id: workflow-meaning + target_layer: workflow_result ref: response-flow relation: evidence may inform workflow result interpretation - evidence-refs: [evidence.alert] + evidence_refs: [evidence.alert] limitations: [workflow completion is evaluated separately] - - target-id: reward-meaning - target-layer: reward_signal + - target_id: reward-meaning + target_layer: reward_signal ref: reward.scan-learning relation: reward relevant only under governed assessment rule - governance-ref: assessment.reward.scan-learning - evidence-refs: [evidence.alert] + governance_ref: assessment.reward.scan-learning + evidence_refs: [evidence.alert] limitations: [reward is derived, not the participant outcome] """ ) @@ -334,16 +334,16 @@ def _history_payloads( def _episode_status_scenario_yaml() -> str: return _scenario_yaml().replace( - " - source-id: scaffold\n" - " source-layer: scaffold_variant\n" + " - source_id: scaffold\n" + " source_layer: scaffold_variant\n" " ref: scaffold.standard\n" - " interpretation-role: benchmark context input\n" - " provenance-refs: [provenance.scaffold.standard]", - " - source-id: episode-terminal\n" - " source-layer: participant_episode_status\n" + " interpretation_role: benchmark context input\n" + " provenance_refs: [provenance.scaffold.standard]", + " - source_id: episode-terminal\n" + " source_layer: participant_episode_status\n" " ref: episode.terminal\n" - " interpretation-role: participant episode terminal status input\n" - " provenance-refs: [runtime.participant-episode-history.episode-1]", + " interpretation_role: participant episode terminal status input\n" + " provenance_refs: [runtime.participant-episode-history.episode-1]", 1, ) @@ -646,7 +646,7 @@ def test_outcome_evidence_refs_must_be_grounded_in_event_payload() -> None: def test_reward_interpretation_requires_governed_assessment_rule() -> None: missing_governance = _scenario_yaml().replace( - " governance-ref: assessment.reward.scan-learning\n", + " governance_ref: assessment.reward.scan-learning\n", "", 1, ) @@ -657,8 +657,8 @@ def test_reward_interpretation_requires_governed_assessment_rule() -> None: def test_outcome_interpretation_scope_is_participant_local_only() -> None: global_scope = _scenario_yaml().replace( - " participant-scope: participant_local\n", - " participant-scope: cohort_global\n", + " participant_scope: participant_local\n", + " participant_scope: cohort_global\n", 1, ) @@ -700,7 +700,7 @@ def test_reward_interpretation_records_must_match_declared_governance_ref() -> N def test_benchmark_outcome_inputs_require_explicit_provenance() -> None: missing_provenance = _scenario_yaml().replace( - " provenance-refs: [provenance.scaffold.standard]\n", + " provenance_refs: [provenance.scaffold.standard]\n", "", 1, ) @@ -711,15 +711,15 @@ def test_benchmark_outcome_inputs_require_explicit_provenance() -> None: def test_episode_status_outcome_inputs_require_explicit_provenance() -> None: missing_provenance = _scenario_yaml().replace( - " - source-id: scaffold\n" - " source-layer: scaffold_variant\n" + " - source_id: scaffold\n" + " source_layer: scaffold_variant\n" " ref: scaffold.standard\n" - " interpretation-role: benchmark context input\n" - " provenance-refs: [provenance.scaffold.standard]", - " - source-id: episode-terminal\n" - " source-layer: participant_episode_status\n" + " interpretation_role: benchmark context input\n" + " provenance_refs: [provenance.scaffold.standard]", + " - source_id: episode-terminal\n" + " source_layer: participant_episode_status\n" " ref: episode.terminal\n" - " interpretation-role: participant episode terminal status input", + " interpretation_role: participant episode terminal status input", 1, ) @@ -842,8 +842,8 @@ def test_declared_outcome_source_provenance_must_be_preserved_at_runtime() -> No def test_outcome_source_provenance_cannot_expose_hidden_boundary_refs() -> None: hidden_provenance_yaml = _scenario_yaml().replace( - " provenance-refs: [provenance.scaffold.standard]\n", - " provenance-refs: [content.private-answer-key]\n", + " provenance_refs: [provenance.scaffold.standard]\n", + " provenance_refs: [content.private-answer-key]\n", 1, ) model = compile_runtime_model(parse_sdl(hidden_provenance_yaml)) diff --git a/implementations/python/tests/test_sem_218_explicitness.py b/implementations/python/tests/test_sem_218_explicitness.py index 55ad71546..a211c34cf 100644 --- a/implementations/python/tests/test_sem_218_explicitness.py +++ b/implementations/python/tests/test_sem_218_explicitness.py @@ -35,7 +35,7 @@ def _scenario_with_explicitness_cases(): network: endpoints: - network: net - network-id-stability: unknown + network_id_stability: unknown infrastructure: net: count: 1 diff --git a/implementations/python/tests/test_semantics_objectives.py b/implementations/python/tests/test_semantics_objectives.py index 59182adb1..4fa20cf3f 100644 --- a/implementations/python/tests/test_semantics_objectives.py +++ b/implementations/python/tests/test_semantics_objectives.py @@ -81,8 +81,8 @@ def _write_objective_window_scenario(path: Path, *, namespace: str = "") -> None scripts: [{prefix}timeline] scripts: {prefix}timeline: - start-time: 0 - end-time: 60 + start_time: 0 + end_time: 60 speed: 1 events: {prefix}kickoff: 0 @@ -106,7 +106,7 @@ def _write_objective_window_scenario(path: Path, *, namespace: str = "") -> None start: type: objective objective: {prefix}observe - on-success: finish + on_success: finish finish: type: end """, diff --git a/implementations/python/tests/test_yaml_mapping_keys.py b/implementations/python/tests/test_yaml_mapping_keys.py index 97fe16f2d..cd2056ce8 100644 --- a/implementations/python/tests/test_yaml_mapping_keys.py +++ b/implementations/python/tests/test_yaml_mapping_keys.py @@ -3,7 +3,7 @@ from pathlib import Path import pytest -from aces_sdl import SDLParseDiagnostic, SDLParseError, parse_sdl, parse_sdl_file +from aces_sdl import SDLMigrationPolicy, SDLParseDiagnostic, SDLParseError, parse_sdl, parse_sdl_file from aces_sdl.language_service import ( apply_structured_edit, language_completions, @@ -17,9 +17,13 @@ FIXTURE_DIR = Path(__file__).parent / "data" / "sdl" / "invalid" -def _conflicts(source: str): +def _conflicts(source: str, *, migration: bool = True): with pytest.raises(SDLParseError) as excinfo: - parse_sdl(source, skip_semantic_validation=True) + parse_sdl( + source, + skip_semantic_validation=True, + migration_policy=SDLMigrationPolicy.ACCEPT if migration else SDLMigrationPolicy.REJECT, + ) diagnostics = excinfo.value.diagnostics assert diagnostics assert all(item.code == "sdl.mapping_key_conflict" for item in diagnostics) @@ -75,24 +79,24 @@ def test_literal_identifiers_are_not_field_normalized() -> None: assert tuple(scenario.nodes) == ("Web-App", "web_app") -def test_implicit_yaml_11_boolean_like_identifiers_remain_distinct_strings() -> None: +def test_yaml_12_string_like_identifiers_remain_distinct_strings() -> None: scenario = parse_sdl( """\ name: boolean-like-identifiers nodes: on: {type: switch} - true: {type: switch} + "true": {type: switch} OFF: {type: switch} - false: {type: switch} + "false": {type: switch} """ ) assert tuple(scenario.nodes) == ("on", "true", "OFF", "false") -def test_explicit_non_string_mapping_key_is_rejected_with_a_source_range() -> None: +def test_core_resolved_non_string_mapping_key_is_rejected_with_a_source_range() -> None: with pytest.raises(SDLParseError) as excinfo: - parse_sdl("name: invalid-key\nnodes:\n !!int 1: {type: switch}\n") + parse_sdl("name: invalid-key\nnodes:\n 1: {type: switch}\n") diagnostic = excinfo.value.diagnostics[0] assert diagnostic.code == "sdl.mapping_key_type" @@ -116,7 +120,8 @@ def test_merge_source_and_local_field_must_be_disjoint() -> None: resources: <<: *resources cpu: 2 -""" +""", + migration=True, ) assert diagnostics[0].pointer == "/nodes/second/resources/cpu" @@ -142,7 +147,8 @@ def test_merge_sources_must_be_pairwise_disjoint_and_collect_all_conflicts() -> type: vm resources: <<: [*first, *second] -""" +""", + migration=True, ) assert [item.pointer for item in diagnostics] == [ @@ -152,9 +158,8 @@ def test_merge_sources_must_be_pairwise_disjoint_and_collect_all_conflicts() -> assert diagnostics[0].authored_keys == ("ram", "RAM") -def test_disjoint_merge_remains_valid() -> None: - scenario = parse_sdl( - """\ +def test_disjoint_merge_is_valid_only_in_migration_mode() -> None: + content = """\ name: merge-disjoint nodes: first: @@ -167,9 +172,17 @@ def test_disjoint_merge_remains_valid() -> None: resources: <<: *resources """ + with pytest.raises(SDLParseError) as excinfo: + parse_sdl(content) + assert excinfo.value.diagnostics[0].code == "sdl.noncanonical_merge" + + scenario = parse_sdl( + content, + migration_policy=SDLMigrationPolicy.ACCEPT, ) assert scenario.nodes["second"].resources.cpu == 1 + assert [item.code for item in scenario.source_diagnostics] == ["sdl.noncanonical_merge"] def test_cyclic_alias_graph_fails_cleanly() -> None: diff --git a/implementations/python/uv.lock b/implementations/python/uv.lock index b7866299f..b8433ddc4 100644 --- a/implementations/python/uv.lock +++ b/implementations/python/uv.lock @@ -31,6 +31,7 @@ dependencies = [ { name = "packaging" }, { name = "pydantic" }, { name = "pyyaml" }, + { name = "rfc8785" }, { name = "rich" }, { name = "sse-starlette" }, { name = "typer" }, @@ -71,6 +72,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, { name = "pytest-mock", marker = "extra == 'dev'", specifier = ">=3.12.0" }, { name = "pyyaml", specifier = ">=6.0" }, + { name = "rfc8785", specifier = ">=0.1.4,<0.2" }, { name = "rich", specifier = ">=13.0.0" }, { name = "sphinx", marker = "extra == 'docs'", specifier = ">=7.3.0" }, { name = "sphinx-autobuild", marker = "extra == 'docs'", specifier = ">=2024.4.16" }, @@ -1195,6 +1197,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, ] +[[package]] +name = "rfc8785" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/2f/fa1d2e740c490191b572d33dbca5daa180cb423c24396b856f5886371d8b/rfc8785-0.1.4.tar.gz", hash = "sha256:e545841329fe0eee4f6a3b44e7034343100c12b4ec566dc06ca9735681deb4da", size = 14321, upload-time = "2024-09-27T16:33:31.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/78/119878110660b2ad709888c8a1614fce7e2fab39080ab960656dc8605bf6/rfc8785-0.1.4-py3-none-any.whl", hash = "sha256:520d690b448ecf0703691c76e1a34a24ddcd4fc5bc41d589cb7c58ec651bcd48", size = 9240, upload-time = "2024-09-27T16:33:29.683Z" }, +] + [[package]] name = "rich" version = "14.3.3" diff --git a/noxfile.py b/noxfile.py index eb21fbc68..ddbfea86a 100644 --- a/noxfile.py +++ b/noxfile.py @@ -603,6 +603,10 @@ def _run_contracts(session: nox.Session, reporter: SessionReporter, *args: str) "contracts / generated schema drift", lambda: _run_project_python(session, "tools/check_generated_schemas.py"), ) + reporter.run( + "contracts / SDL catalog parity", + lambda: _run_project_python(session, "tools/check_sdl_catalog_parity.py"), + ) reporter.run( "contracts / json artifact validation", lambda: _run_project_python(session, "tools/check_json_artifacts.py", *json_artifact_args), diff --git a/specs/formal/objectives/declarative-objective-semantics.md b/specs/formal/objectives/declarative-objective-semantics.md index d8ed1702f..b950f70ef 100644 --- a/specs/formal/objectives/declarative-objective-semantics.md +++ b/specs/formal/objectives/declarative-objective-semantics.md @@ -22,8 +22,8 @@ The implementation must build on these existing authorities: - SDL structure: `aces_sdl.objectives.Objective`, `ObjectiveSuccess`, and `ObjectiveWindow` -- parser/model gates: `SDLModel`, parser key normalization, variable-key - rejection, and `SDLParseError` +- parser/model gates: `sdl-yaml/v1`, `SDLModel`, canonical-field enforcement, + explicit migration, variable-key rejection, and `SDLParseError` - static validation: `SemanticValidator` and `SDLValidationError` - objective-window analysis: `aces_sdl.semantics.objectives` - condition resolution: the targetable named-reference index over declared diff --git a/specs/formal/participant-behavior-model/README.md b/specs/formal/participant-behavior-model/README.md index 8b976d9e1..75ddb1ee0 100644 --- a/specs/formal/participant-behavior-model/README.md +++ b/specs/formal/participant-behavior-model/README.md @@ -31,7 +31,7 @@ Existing coverage: - `controlled-vocabularies-v1` already defines `participant-decision-surface-modes` and `participant-offensive-behavior-activities`. -- Issue #206 adds SDL `behavior-specifications` authoring, semantic +- Issue #206 adds SDL `behavior_specifications` authoring, semantic validation, generated schema coverage, and compiled `participant.behavior-specification.*` runtime records for ACT-606. @@ -268,7 +268,7 @@ Rules: Implementation issue #206 adds the executable SDL authoring and validation surface for this aggregate. The Python reference implementation parses -`behavior-specifications`, validates participant, role, action, observation, +`behavior_specifications`, validates participant, role, action, observation, outcome, authority, extension, and governed-mode refs, includes the surface in generated SDL schemas, and compiles stable `participant.behavior-specification.` runtime records without creating a diff --git a/specs/sdl/README.md b/specs/sdl/README.md index a8621ecf0..42464c0d7 100644 --- a/specs/sdl/README.md +++ b/specs/sdl/README.md @@ -30,7 +30,9 @@ Three artifact classes describe the SDL, with distinct authority: either to override the other. Where this prose states a structural fact (a section's presence, requiredness, or value shape), the published `sdl-authoring-input-v1.json` schema is the authoritative enumeration the - prose is written to match. + prose is written to match. That schema validates the normalized authoring + object, not raw YAML presentation; `document-model.md` §1 and the + `contracts/fixtures/sdl/sdl-yaml-v1/` corpus define the raw source profile. 3. **Reference implementations (`implementations/`)** consume both. No Python model, validator function, or generator defines ecosystem meaning; it is evidence of one conforming realisation. This specification names @@ -76,10 +78,15 @@ An implementer can answer each structural question from the named file alone: - *Which sections exist, which are required, and what shape is each?* → [`document-model.md`](document-model.md) and [`sections.md`](sections.md). + The nox contracts gate runs `tools/check_sdl_catalog_parity.py` to prove the + catalog, published schema, and reference registries remain reconciled. +- *Which raw YAML documents are canonical SDL, and what receives a stable + semantic digest?* → [`document-model.md`](document-model.md) §§1, 5, 7-8. - *What is a valid identifier for a user-defined key?* → [`document-model.md`](document-model.md). - *How does a reference resolve, and what happens when it is dangling or - ambiguous?* → [`references.md`](references.md). + ambiguous?* → [`references.md`](references.md), including its checked + editor-visible edge index and distinct candidate-domain classifications. - *What is legal to instantiate, and what does instantiation reject?* → [`variables-and-instantiation.md`](variables-and-instantiation.md). - *What is the runtime-inventory surface and which ADR owns each family?* → diff --git a/specs/sdl/diagnostics.md b/specs/sdl/diagnostics.md index f12ee15db..dc9aa86a5 100644 --- a/specs/sdl/diagnostics.md +++ b/specs/sdl/diagnostics.md @@ -11,11 +11,11 @@ new diagnostic mechanism, and it does not reclassify any existing condition. An SDL document is checked at three stages, in order. Each is **fail-closed**: a problem at a stage stops the document from advancing past that stage. -1. **Parse / structural.** YAML loading and structural shape: the root is a - mapping, keys are strings, mapping entries remain unique before and after - field-key normalisation, values have the right shapes, and **no unknown key is - present** ([document-model.md §4](document-model.md)). A structural problem is - a parse error. +1. **Source / parse / structural.** `sdl-yaml/v1` decoding, operational bounds, + structural shape, and typed construction: the root is a mapping, keys are + strings, mapping entries remain unique, canonical structural fields are + exact, values have the right shapes, and **no unknown key is present** + ([document-model.md §§1, 4-5](document-model.md)). A problem is a parse error. 2. **Semantic validation.** Cross-section reference resolution ([references.md](references.md)), uniqueness, acyclicity, control-flow closure, and the runtime-family invariants @@ -35,8 +35,9 @@ and report them together, rather than failing at the first problem. An author fixing a document sees the full set of errors a stage found, not one error at a time. Parsing may stop at the first structural fault that prevents composition of a YAML node graph. Once that graph is available, the mapping-key preflight -collects all exact duplicates, merge conflicts, and field-key normalisation -collisions before construction; none is hidden by a last-write-wins mapping. +collects all exact duplicates, migration-merge conflicts, canonical-field +violations, and field-alias collisions before construction; none is hidden by +a last-write-wins mapping. ## 3. Errors are fatal @@ -65,6 +66,12 @@ The boundary rule is symmetric and **MUST** be honoured: Existing advisory conditions, documented here by reference (not redefined): +- **Explicit source migration.** A migration operation may accept a recognized + legacy field spelling or disjoint `<<` merge and emit a source-ranged warning. + This does not reclassify the construct as valid canonical source: strict + `sdl-yaml/v1` decoding still rejects it, and the migrated output must pass + strict decoding. Ambiguity and unknown fields remain fatal in migration mode. + - **VM without resources.** A virtual-machine node declared without a `resources` block is **valid** SDL; it is flagged as an advisory because it may be undeployable unless a backend supplies defaults. It is not an error. @@ -164,3 +171,34 @@ adapters (including language-service and MCP responses) preserve the code, stage, canonical path, and both ranges. Plain-text CLI/library rendering may format the same fields as prose but must not replace them with raw YAML values or silently downgrade the error to a generic model-validation failure. + +## 7. Source-profile and migration diagnostics + +Source diagnostics use the same structured envelope as mapping-key diagnostics. +Each carries a stable code, `parse` stage, severity, message, RFC 6901 path, +one-based half-open source range, and source identity when file-backed. A +diagnostic never includes the mapped value, whole source document, parameter +map, secret, or traceback. + +| Code | Meaning | Strict severity | Migration severity | +|------|---------|-----------------|--------------------| +| `sdl.utf8` | Input cannot be represented as valid UTF-8 | error | error | +| `sdl.source_format` | Unsupported source-profile identifier | error | error | +| `sdl.migration_policy` | Unknown migration-policy identifier | error | error | +| `sdl.parse` | YAML syntax, stream, or composition failure | error | error | +| `sdl.directive` | YAML directive is present | error | error | +| `sdl.explicit_tag` | Explicit YAML tag is present | error | error | +| `sdl.source_limit` | A `sdl-yaml/v1` resource bound is exceeded | error | error | +| `sdl.non_json_value` | Constructed value is outside the SDL JSON domain | error | error | +| `sdl.mapping_key_type` | Mapping key does not construct as a string | error | error | +| `sdl.mapping_key_conflict` | Duplicate or canonicalized collision | error | error | +| `sdl.alias_cycle` | Alias graph is cyclic | error | error | +| `sdl.noncanonical_field` | Recognized legacy structural-field spelling | error | warning | +| `sdl.noncanonical_merge` | YAML 1.1 `<<` migration syntax | error | warning | + +For `sdl.noncanonical_field`, `authored_keys` contains the authored and +canonical spellings, and the path points to the canonical field. For +`sdl.noncanonical_merge`, the path points to the effective mapping. Warnings +are retained on the successfully migrated scenario and by formatting, MCP, and +CLI adapters. Strict validation is the default at every ordinary parse ingress; +migration acceptance requires an explicit caller choice. diff --git a/specs/sdl/document-model.md b/specs/sdl/document-model.md index 74e1c0a78..e22fe1006 100644 --- a/specs/sdl/document-model.md +++ b/specs/sdl/document-model.md @@ -10,22 +10,46 @@ See [`sections.md`](sections.md) for the per-section catalog, [`variables-and-instantiation.md`](variables-and-instantiation.md) for variable and instantiation rules. -## 1. Encoding - -1. An SDL document **MUST** be a YAML 1.1/1.2 document whose top-level value is a - mapping. A document whose root is a sequence, scalar, or null is not a valid - SDL document. -2. Every mapping key **MUST** denote a string. In particular, SDL treats an - implicitly typed YAML 1.1 spelling such as `on`, `off`, `yes`, or `no` as - the authored string spelling when it occurs in key position; it does not - allow the YAML loader to turn that spelling into a boolean map key. A - non-scalar or explicitly non-string key is rejected. -3. A document **MUST** be loadable by a safe YAML loader. Constructor tags that - instantiate arbitrary types **MUST NOT** be honoured. -4. Every authored mapping entry **MUST** remain distinguishable until the SDL - parser has checked key uniqueness. An exact duplicate key at any depth is a - parse/structural error; a loader **MUST NOT** construct a last-write-wins - dictionary first. +## 1. Source profile: `sdl-yaml/v1` + +Canonical SDL source uses the versioned profile `sdl-yaml/v1`. A conforming +strict decoder **MUST** apply all of the following rules before model +construction: + +1. The byte stream **MUST** be valid UTF-8 and contain exactly one YAML 1.2.2 + document. Its root **MUST** be a mapping. A sequence, scalar, null root, or + multi-document stream is invalid. +2. Untagged plain scalars **MUST** resolve with the YAML 1.2.2 Core schema + ([§10.3](https://yaml.org/spec/1.2.2/#103-core-schema)). Thus `true`, `FALSE`, + `null`, `0o12`, and `0x0a` are typed Core values, while `yes`, `on`, a date + spelling, `1_000`, and `-0x0a` are strings. This rule applies in key position: + unquoted `true` is a boolean key and is invalid, while unquoted `on` is a + string identifier. +3. Every mapping key **MUST** construct as a string. Complex keys and scalars + resolved to null, boolean, integer, or float are invalid as keys. Quoting a + Core-looking identifier is sufficient to keep it a string. +4. Explicit tags and YAML directives **MUST NOT** appear. Only safe standard + construction of implicitly resolved Core values is permitted. Document + start/end markers are presentation syntax, not directives, and **MAY** appear. +5. Constructed values **MUST** be in the JSON data domain: null, boolean, + arbitrary-precision integer, finite binary64-compatible float, Unicode + string, sequence, or string-keyed mapping. Timestamps, sets, language-native + objects, non-finite floats, and other tag-specific values are invalid. +6. Anchors and aliases **MAY** share acyclic representation nodes; their names + and sharing carry no SDL meaning. Cyclic aliases are invalid. The YAML 1.1 + `<<` merge extension is not YAML 1.2.2 Core syntax and is invalid canonical + SDL; §5 defines its explicit migration treatment. +7. Every authored mapping entry **MUST** remain distinguishable until key + uniqueness is checked. Exact duplicates and canonical-field collisions are + invalid; a decoder **MUST NOT** construct a last-write-wins mapping first. +8. No Unicode normalization is performed. Code-point sequences remain as + authored after YAML escape processing. + +The profile has fixed denial-of-service bounds: at most 8 MiB of UTF-8 source, +1 MiB in one scalar, depth 128, 100,000 unique representation nodes, 256 alias +occurrences, and 250,000 nodes of alias-expanded traversal work. Exceeding any +bound is a source error. A future syntax, scalar policy, or incompatible limit +set requires a new source-profile identifier. This uniqueness rule follows the YAML 1.2.2 representation model, in which a mapping is an unordered association of unique keys and non-unique keys are a @@ -44,12 +68,11 @@ The complete, authoritative enumeration of top-level fields, their kinds, value shapes, and requiredness is the [section catalog](sections.md), which is written to match `contracts/schemas/sdl/sdl-authoring-input-v1.json`. -> **Reconciliation note.** Earlier descriptions of the SDL spoke of "21 named -> sections, all dicts." That count is stale. The live authoring contract has a -> larger section set and is **not** uniformly map-keyed: `forwarding_agents` is -> list-valued, and the participant surfaces (`action_contracts`, +> **Reconciliation note.** Earlier descriptions treated the authoring surface +> as uniformly map-keyed. The live contract is **not** uniform: +> `forwarding_agents` is list-valued, and the participant surfaces (`action_contracts`, > `observation_boundaries`, `outcome_interpretation_rules`) are present. The -> section catalog states the live set; this specification reconciles the +> section catalog states and mechanically checks the live set; this specification reconciles the > language to the published schema rather than freezing a historical count. ## 3. Requiredness @@ -75,46 +98,47 @@ to match `contracts/schemas/sdl/sdl-authoring-input-v1.json`. 3. Closure exists so that a typo in a field name (`vulnerabilites`) fails the document rather than silently dropping content. Authors **MUST NOT** rely on undeclared keys to carry data. -4. YAML anchors and aliases remain authoring conveniences, but they do not - weaken closure or uniqueness. A merge key (`<<`) is valid only when the - effective entries contributed by every merge source and every local entry - remain unique after the scope-appropriate key rules in §5 are applied. A - merge conflict is a parse/structural error rather than an implicit precedence - rule. This is a deliberate ACES restriction over the YAML 1.1 merge-key - working draft, which otherwise defines source and local override precedence - ([YAML 1.1 merge type](https://yaml.org/type/merge.html)). Cyclic alias - graphs are invalid. +4. Anchors and aliases do not weaken closure or uniqueness. Migration-mode + merge input is accepted only when every inherited and local effective field + is disjoint; override precedence is never SDL semantics. > *Implementation evidence (non-normative): the reference models set > `extra="forbid"` on the shared SDL base model.* -## 5. Field and value normalisation +## 5. Canonical fields, literal keys, and migration 1. Enum-valued fields accept their value case-insensitively, and accept a hyphen as an alias for an underscore in the value text, so that an authoring value such as `search-index` and `search_index` denote the same enum member. This is an authoring convenience; the normalised (canonical) form is what the document means. -2. Structural field keys retain the authoring aliases established by ADR-001: - matching is case-insensitive and `-` is accepted as an alias for `_`. - Therefore `semantic-version` and `semantic_version` both address the - canonical field `semantic_version`. -3. Field-key aliases do not create a precedence rule. If two keys in one +2. A structural field key **MUST** use its exact lower-case `snake_case` schema + spelling. `semantic_version` is canonical; `semantic-version`, + `Semantic_Version`, and `SEMANTIC_VERSION` are not canonical SDL source. +3. A tool **MAY** offer an explicit migration operation that recognizes legacy + case and hyphen spellings and `<<` merges. Migration is never implicit: the + strict/default policy rejects each construct. An accepting migration policy + **MUST** emit a source-ranged advisory for every rewritten field or merge, + and its output **MUST** pass strict `sdl-yaml/v1` decoding. Unrecognized + fields remain errors. +4. Migration aliases do not create a precedence rule. If two keys in one effective mapping address the same canonical field, including through a YAML merge, the mapping is ambiguous and **MUST** fail during parsing before model construction. The diagnostic contract is defined in [diagnostics.md §6](diagnostics.md). -4. Field-key normalisation applies only while traversing a schema-defined +5. Field recognition applies only while traversing a schema-defined structural mapping. It **MUST NOT** be applied to user-defined identifier maps, extension maps, or native option/label maps. Keys in those maps are - preserved verbatim, including case, hyphens, underscores, and YAML 1.1 - boolean-like spellings; only exact duplicate identifiers are rejected. -5. A field that holds a variable placeholder (`${…}`) is **not** normalised as an + preserved verbatim, including case, hyphens, and underscores; only exact + duplicate identifiers are rejected. Core scalar resolution still applies, + so boolean/null/numeric-looking identifiers must be quoted when necessary. +6. A field that holds a variable placeholder (`${…}`) is **not** normalised as an enum value; the placeholder is preserved until instantiation ([variables-and-instantiation.md](variables-and-instantiation.md)). Formally, let `c_scope(k)` lowercase a key and replace hyphens with underscores -in a structural mapping, and be the identity function in a literal mapping. For every +in a structural mapping, and be the identity function in a literal mapping. +Canonical source additionally requires `c_scope(k) = k`. For every pair of distinct entries `i` and `j` in an effective mapping (including merge contributions), well-formedness requires `c_scope(key_i) != c_scope(key_j)`. This injectivity condition is checked over @@ -150,26 +174,36 @@ following rules govern identifiers: Beyond these rules, identifier *spelling* is the author's choice; the language does not impose a global identifier grammar on ordinary section keys. -## 7. Document phases +## 7. Document phases and schema boundary -An SDL document passes through up to three forms. Each form is a superset shape +An SDL document passes through up to four forms. Each form is a derived shape of the authored document with progressively fewer unresolved constructs: -1. **Authored.** The document as written. It **MAY** contain module imports and - `${…}` variable placeholders. Full semantic validation - ([references.md](references.md), [diagnostics.md](diagnostics.md)) applies to - the authored document, treating unresolved placeholders per §5.5. -2. **Expanded.** If the document declares a module or imports - ([sections.md](sections.md) — `module`, `imports`), module composition is - applied **before** full semantic validation, producing an expanded document - in which imported content has been merged under its namespace +1. **Source.** The YAML presentation governed by `sdl-yaml/v1`. Presentation + details, anchors, aliases, and migration spellings exist only at this phase. +2. **Normalised authoring object.** The source is safely constructed, canonical + fields are recognized, documented shorthands are expanded, enum/scalar + fields are typed, and structural closure is enforced. It **MAY** contain + imports and `${…}` placeholders. The published + `sdl-authoring-input-v1.json` schema validates this JSON-compatible object, + not raw YAML bytes or presentation syntax. Its title and + `x-aces-document-phase` annotation identify this boundary. Canonical shipped + examples deliberately use longhand normalized values so their strict decoded + object also validates directly against the schema. +3. **Expanded authoring object.** If the document declares imports, module + composition is applied **before** full semantic validation, producing an + expanded authoring object in which imported content has been merged under + its namespace ([ADR-053](../../docs/decisions/adrs/adr-053-sdl-module-composition-for-inventory-backed-scenarios.md)). -3. **Instantiated.** Instantiation resolves variables against supplied + Full semantic validation + ([references.md](references.md), [diagnostics.md](diagnostics.md)) applies to + this expanded object, treating unresolved placeholders per §5.6. +4. **Instantiated scenario.** Instantiation resolves variables against supplied parameters and defaults, producing a concrete document with no surviving variable definitions or unresolved placeholders ([variables-and-instantiation.md](variables-and-instantiation.md)). -The authored → expanded → instantiated progression is the two-phase +The source → normalized → expanded → instantiated progression refines the two-phase authoring/instantiation model of [ADR-001](../../docs/decisions/adrs/adr-001-scenario-description-language.md) and the runtime-layering boundary of @@ -177,3 +211,38 @@ the runtime-layering boundary of [ADR-036](../../docs/decisions/adrs/adr-036-sdl-processor-runtime-module-boundaries.md): delivery-level realisation is downstream of, and out of scope for, the authoring model. + +## 8. Canonical semantic identity + +The canonicalization profile `aces-sdl-semantic/v1` identifies one semantically +validated, expanded authoring scenario independently of YAML layout, map order, +recognized migration spelling, and documented shorthand spelling. It does not +identify raw source, an instantiated scenario, a compiled runtime model, a +module bundle, evidence, or a run. + +The canonical input is the following JSON object: + +```json +{ + "profile": "aces-sdl-semantic/v1", + "scenario": {}, + "module_variable_specs": {}, + "module_node_variable_refs": {} +} +``` + +`scenario` is the validated expanded authoring object serialized with canonical +wire field names while omitting fields that were not authored or introduced by +normalization/composition. The two module maps are the variable specifications +and node-variable references retained as provenance side channels when imported +module variables no longer appear in the merged scenario object. Array order +is significant; object member order is not. Authored omission is significant, +so an omitted optional field and an explicitly authored default are distinct. + +The envelope **MUST** satisfy the I-JSON input constraints and be serialized +with the JSON Canonicalization Scheme (JCS), RFC 8785. JCS preserves Unicode +code points without normalization, sorts object properties by UTF-16 code +units, preserves array order, emits UTF-8, and rejects non-finite or out-of-domain +numbers. The profile digest is SHA-256 over those bytes and is rendered +`sha256:<64 lower-case hexadecimal digits>`. A change to the envelope, +presence rule, or canonicalization algorithm requires a new profile identifier. diff --git a/specs/sdl/observability-and-evidence.md b/specs/sdl/observability-and-evidence.md index 7ec0b64b4..3f331c6d9 100644 --- a/specs/sdl/observability-and-evidence.md +++ b/specs/sdl/observability-and-evidence.md @@ -1,9 +1,11 @@ # Catalog 5 - Observability and Evidence Planes -This catalog states SDL authoring rules for ADR-066. It is normative for SDL -meaning, but it does not add new top-level fields by itself. Future executable -work that adds a field must still update `sections.md`, `references.md`, the -published SDL schemas, the reference implementation, fixtures, and tests. +This catalog states SDL authoring rules for ADR-066. The implemented +`evidence_requirements` top-level section carries authored capture intent; +scenario-native observability remains expressed through the relevant node +runtime families. A future field still has to update `sections.md`, +`references.md`, the published SDL schemas, the reference implementation, +fixtures, and tests. ## Plane Rule diff --git a/specs/sdl/references.md b/specs/sdl/references.md index 4edf92480..9f7e29075 100644 --- a/specs/sdl/references.md +++ b/specs/sdl/references.md @@ -198,6 +198,70 @@ any role-bearing refs |--------|-------|--------| | any field | `${name}` placeholder | a declared `variables` entry (name only, at authoring time) | +## 6. Machine-checkable reference-edge index + +This index gives every editor-visible reference field a stable candidate-domain +token and makes the participant behavior surface explicit. It complements the +semantic detail above: subtype-specific relationship and nested-runtime rules +remain narrower than the broad completion domain recorded here. `targetable` +means the declaration index excluding `variables`, `evidence_requirements`, +`objectives`, and `workflows`; it is not a synonym for every named object. +`derived:*`, `vocabulary:*`, `registry:*`, `contract:*`, and `opaque:*` name +deliberately distinct resolution mechanisms and MUST NOT be collapsed into a +generic symbol lookup. + +| Source path | Candidate domain | Resolution phase | Failure | Semantic owner | +| --- | --- | --- | --- | --- | +| `nodes.*.features[]` | `features` | semantic validation | fatal dangling or ambiguous | [node validator](../../implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py) | +| `nodes.*.conditions[]` | `conditions` | semantic validation | fatal dangling or ambiguous | [node validator](../../implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py) | +| `nodes.*.injects[]` | `injects` | semantic validation | fatal dangling or ambiguous | [node validator](../../implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py) | +| `nodes.*.vulnerabilities[]` | `vulnerabilities` | semantic validation | fatal dangling or ambiguous | [node validator](../../implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py) | +| `infrastructure.*.links[]` | `infrastructure` | semantic validation | fatal dangling or ambiguous | [infrastructure validator](../../implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py) | +| `infrastructure.*.dependencies[]` | `infrastructure` | semantic validation | fatal dangling or ambiguous | [infrastructure validator](../../implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py) | +| `features.*.dependencies[]` | `features` | semantic validation | fatal dangling, ambiguous, or cyclic | [section validator](../../implementations/python/packages/aces_sdl/validator/_sections.py) | +| `entities.*.vulnerabilities[]` | `vulnerabilities` | semantic validation | fatal dangling or ambiguous | [section validator](../../implementations/python/packages/aces_sdl/validator/_sections.py) | +| `injects.*.from_entity` | `entities` | semantic validation | fatal dangling or ambiguous | [section validator](../../implementations/python/packages/aces_sdl/validator/_sections.py) | +| `injects.*.to_entities[]` | `entities` | semantic validation | fatal dangling or ambiguous | [section validator](../../implementations/python/packages/aces_sdl/validator/_sections.py) | +| `events.*.conditions[]` | `conditions` | semantic validation | fatal dangling or ambiguous | [section validator](../../implementations/python/packages/aces_sdl/validator/_sections.py) | +| `events.*.injects[]` | `injects` | semantic validation | fatal dangling or ambiguous | [section validator](../../implementations/python/packages/aces_sdl/validator/_sections.py) | +| `scripts.*.events[]` | `events` | semantic validation | fatal dangling or ambiguous | [section validator](../../implementations/python/packages/aces_sdl/validator/_sections.py) | +| `stories.*.scripts[]` | `scripts` | semantic validation | fatal dangling or ambiguous | [section validator](../../implementations/python/packages/aces_sdl/validator/_sections.py) | +| `content.*.target` | `nodes` | semantic validation | fatal unless target is a VM node | [content validator](../../implementations/python/packages/aces_sdl/validator/_content_objectives.py) | +| `accounts.*.node` | `nodes` | semantic validation | fatal unless target is a VM node | [account validator](../../implementations/python/packages/aces_sdl/validator/_content_objectives.py) | +| `relationships.*.source` | `targetable` | semantic validation | fatal dangling or ambiguous; subtype may narrow domain | [relationship validator](../../implementations/python/packages/aces_sdl/validator/_relationships.py) | +| `relationships.*.target` | `targetable` | semantic validation | fatal dangling or ambiguous; subtype may narrow domain | [relationship validator](../../implementations/python/packages/aces_sdl/validator/_relationships.py) | +| `agents.*.entity` | `entities` | semantic validation | fatal dangling or ambiguous | [participant validator](../../implementations/python/packages/aces_sdl/validator/_content_objectives.py) | +| `agents.*.starting_accounts[]` | `accounts` | semantic validation | fatal dangling or ambiguous | [participant validator](../../implementations/python/packages/aces_sdl/validator/_content_objectives.py) | +| `action_contracts.*.interactions.*.related_action_ref` | `action_contracts` | semantic validation | fatal dangling or ambiguous | [participant semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | +| `observation_boundaries.*.view_rules.*.information_refs[]` | `derived:boundary_information` | semantic validation | fatal outside declared boundary information | [participant semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | +| `outcome_interpretation_rules.*.source_ref` | `action_contracts,objectives,workflows` | semantic validation | fatal dangling or ambiguous | [outcome semantics](../../implementations/python/packages/aces_sdl/semantics/participant_outcome.py) | +| `behavior_specifications.*.participant_refs[]` | `agents` | semantic validation | fatal dangling or ambiguous | [behavior semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | +| `behavior_specifications.*.participant_role_refs[]` | `derived:agent_roles` | semantic validation | fatal unless bound by a referenced participant | [behavior semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | +| `behavior_specifications.*.action_contract_refs[]` | `action_contracts` | semantic validation | fatal dangling or ambiguous | [behavior semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | +| `behavior_specifications.*.observation_boundary_refs[]` | `observation_boundaries` | semantic validation | fatal dangling or ambiguous | [behavior semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | +| `behavior_specifications.*.outcome_interpretation_rule_refs[]` | `outcome_interpretation_rules` | semantic validation | fatal dangling or ambiguous | [behavior semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | +| `behavior_specifications.*.authority_scope_refs[]` | `targetable` | semantic validation | fatal dangling or ambiguous | [behavior validator](../../implementations/python/packages/aces_sdl/validator/_content_objectives.py) | +| `behavior_specifications.*.behavior_mode` | `vocabulary:behavior_mode` | structural validation | fatal invalid vocabulary value | [behavior model](behavior-specifications.md) | +| `behavior_specifications.*.ai_offensive_behavior_refs[]` | `vocabulary:ai_offensive_behavior` | semantic validation | fatal unknown vocabulary identifier | [behavior model](behavior-specifications.md) | +| `behavior_specifications.*.offensive_behavior_refs[]` | `vocabulary:offensive_behavior` | semantic validation | fatal unknown vocabulary identifier | [behavior model](behavior-specifications.md) | +| `behavior_specifications.*.realization_profile_ref` | `opaque:realization_profile` | structural validation | fatal invalid reference shape; resolution belongs to realization | [behavior model](behavior-specifications.md) | +| `behavior_specifications.*.backend_feature_support_refs[]` | `registry:behavior_features` | semantic validation | fatal unsupported feature identifier | [behavior semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | +| `behavior_specifications.*.evidence_contract_refs[]` | `contract:participant_evidence` | semantic validation | fatal unknown contract identifier | [behavior semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py) | +| `evidence_requirements.*.source_refs[]` | `targetable` | semantic validation | fatal dangling or ambiguous | [evidence validator](../../implementations/python/packages/aces_sdl/validator/_evidence_requirements.py) | +| `evidence_requirements.*.scope_refs[]` | `targetable` | semantic validation | fatal dangling or ambiguous | [evidence validator](../../implementations/python/packages/aces_sdl/validator/_evidence_requirements.py) | +| `evidence_requirements.*.channel_refs[]` | `targetable` | semantic validation | fatal dangling or ambiguous | [evidence validator](../../implementations/python/packages/aces_sdl/validator/_evidence_requirements.py) | +| `evidence_requirements.*.trigger_ref` | `targetable` | semantic validation | fatal dangling or ambiguous | [evidence validator](../../implementations/python/packages/aces_sdl/validator/_evidence_requirements.py) | +| `evidence_requirements.*.boundary_ref` | `targetable` | semantic validation | fatal dangling or ambiguous | [evidence validator](../../implementations/python/packages/aces_sdl/validator/_evidence_requirements.py) | +| `objectives.*.agent` | `agents` | semantic validation | fatal dangling or ambiguous | [objective semantics](objective-semantics.md) | +| `objectives.*.entity` | `entities` | semantic validation | fatal dangling or ambiguous | [objective semantics](objective-semantics.md) | +| `objectives.*.targets[]` | `targetable` | semantic validation | fatal dangling or ambiguous | [objective semantics](objective-semantics.md) | +| `objectives.*.depends_on[]` | `objectives` | semantic validation | fatal dangling, ambiguous, or cyclic | [objective semantics](objective-semantics.md) | +| `workflows.*.start` | `workflow_steps` | semantic validation | fatal dangling step | [workflow semantics](workflow-semantics.md) | + +The index is checked against language-service completion metadata and against a +required behavior-edge set. Adding a completion-aware field or behavior +reference without a corresponding row fails the repository contract gate. + ## Extending the reference catalog A new reference edge is added by defining the field's candidate set, adding a diff --git a/specs/sdl/runtime-inventory.md b/specs/sdl/runtime-inventory.md index 8d9c6d7fc..230b1680d 100644 --- a/specs/sdl/runtime-inventory.md +++ b/specs/sdl/runtime-inventory.md @@ -31,25 +31,25 @@ under `runtime`, a primary `_id`, an addressable child-collection tree, and an owning ADR. The owning ADR is the normative authority for that family's fields, enums, and profiles; this index does not restate them. -| Family key | `runtime.` | Primary id | Child collections (id) | Owning ADR | +| Family key | `runtime.` | Primary id | Addressable child paths (`collection:id`) | Owning ADR | |------------|------------------------|------------|------------------------|-----------| -| `service-listeners` | `service_listeners` | `service_listener_id` | — | [ADR-043](../../docs/decisions/adrs/adr-043-runtime-service-listener-surface.md) | -| `applications` | `applications` | `application_id` | — | [ADR-026](../../docs/decisions/adrs/adr-026-application-http-surface-inventory.md) | -| `database-services` | `database_services` | `database_service_id` | `databases` (`database_id`) | [ADR-029](../../docs/decisions/adrs/adr-029-database-logical-state-runtime-surface.md) | -| `dns-services` | `dns_services` | `dns_service_id` | `zones` (`zone_id`) → `rrsets` (`rrset_id`) | [ADR-039](../../docs/decisions/adrs/adr-039-dns-service-runtime-inventory.md) | -| `identity-authorities` | `identity_authorities` | `identity_authority_id` | `services`, `subjects`, `policies`, `relationships` | [ADR-032](../../docs/decisions/adrs/adr-032-directory-domain-identity-runtime-surface.md) | -| `file-services` | `file_services` | `file_service_id` | `shares`, `principals`, `access_rules`, `access_observations` | [ADR-037](../../docs/decisions/adrs/adr-037-runtime-file-service-and-filesystem-presence-semantics.md) | -| `mail-services` | `mail_services` | `mail_service_id` | `components`, `listeners`, `domains`, `mailbox_stores`, `mailboxes`, `aliases`, `routing_rules`, `queues`, `settings` | [ADR-038](../../docs/decisions/adrs/adr-038-runtime-mail-service-logical-state.md) | -| `network-sensors` | `network_sensors` | `network_sensor_id` | — | [ADR-042](../../docs/decisions/adrs/adr-042-network-sensor-runtime-monitoring.md) | -| `network-detection-engines` | `network_detection_engines` | `network_detection_engine_id` | `rule_sources`, `network_sets`, `output_streams`, `control_channels` | [ADR-044](../../docs/decisions/adrs/adr-044-network-detection-engine-runtime-inventory.md) | -| `security-monitoring-managers` | `security_monitoring_managers` | `security_monitoring_manager_id` | `listeners`, `components`, `agents`, `agent_groups`, `content_sets`, `detection_definitions`, `settings` | [ADR-040](../../docs/decisions/adrs/adr-040-security-monitoring-manager-runtime-inventory.md), [ADR-045](../../docs/decisions/adrs/adr-045-security-monitoring-detection-definition-semantics.md) | -| `ssh-servers` | `ssh_servers` | `ssh_server_id` | `match_rules` (`match_id`) | [ADR-031](../../docs/decisions/adrs/adr-031-ssh-server-configuration-surface.md) | -| `app-authorizations` | `app_authorizations` | `app_authorization_id` | `principals`, `roles`, `permission_grants`, `role_mappings`, `tenants` | [ADR-046](../../docs/decisions/adrs/adr-046-app-authorization-runtime-inventory.md) | -| `scheduled-jobs` | `scheduled_jobs` | `scheduled_job_id` | — | [ADR-047](../../docs/decisions/adrs/adr-047-scheduled-job-runtime-inventory.md) | -| `datastore-services` | `datastore_services` | `datastore_service_id` | `nodes` (`node_id`) → `plugins`, `endpoints`; `partitions`, `templates`, `mappings`, `settings` | [ADR-048](../../docs/decisions/adrs/adr-048-datastore-service-runtime-inventory.md), [ADR-058](../../docs/decisions/adrs/adr-058-datastore-node-engine-provenance-and-endpoints.md) | -| `platform-applications` | `platform_applications` | `platform_application_id` | `organizations`, `tenants`, `content_objects`, `markings`, `upstream_bindings`, `connectors`, `settings` | [ADR-049](../../docs/decisions/adrs/adr-049-platform-application-runtime-inventory.md) | -| `forwarding-agents` | `forwarding_agents` | `forwarding_agent_id` | `sources`, `transforms`, `ship_targets`, `reload_channels`, `settings` | [ADR-050](../../docs/decisions/adrs/adr-050-forwarding-agent-runtime-inventory.md) | -| `orchestration-authorities` | `orchestration_authorities` | `orchestration_authority_id` | `spawn_templates`, `realized_children` | [ADR-051](../../docs/decisions/adrs/adr-051-orchestration-authority-runtime-inventory.md) | +| `service-listeners` | `service_listeners` | `service_listener_id` | none | [ADR-043](../../docs/decisions/adrs/adr-043-runtime-service-listener-surface.md) | +| `applications` | `applications` | `application_id` | none | [ADR-026](../../docs/decisions/adrs/adr-026-application-http-surface-inventory.md) | +| `database-services` | `database_services` | `database_service_id` | `databases:database_id` | [ADR-029](../../docs/decisions/adrs/adr-029-database-logical-state-runtime-surface.md) | +| `dns-services` | `dns_services` | `dns_service_id` | `zones:zone_id, zones:zone_id/rrsets:rrset_id` | [ADR-039](../../docs/decisions/adrs/adr-039-dns-service-runtime-inventory.md) | +| `identity-authorities` | `identity_authorities` | `identity_authority_id` | `services:service_id, subjects:subject_id, policies:policy_id, relationships:relationship_id` | [ADR-032](../../docs/decisions/adrs/adr-032-directory-domain-identity-runtime-surface.md) | +| `file-services` | `file_services` | `file_service_id` | `shares:share_id, principals:principal_id, access_rules:rule_id, access_observations:observation_id` | [ADR-037](../../docs/decisions/adrs/adr-037-runtime-file-service-and-filesystem-presence-semantics.md) | +| `mail-services` | `mail_services` | `mail_service_id` | `components:component_id, listeners:listener_id, domains:domain_id, mailbox_stores:store_id, mailboxes:mailbox_id, aliases:alias_id, routing_rules:rule_id, queues:queue_id, settings:setting_id` | [ADR-038](../../docs/decisions/adrs/adr-038-runtime-mail-service-logical-state.md) | +| `network-sensors` | `network_sensors` | `network_sensor_id` | none | [ADR-042](../../docs/decisions/adrs/adr-042-network-sensor-runtime-monitoring.md) | +| `network-detection-engines` | `network_detection_engines` | `network_detection_engine_id` | `rule_sources:source_id, network_sets:set_id, output_streams:stream_id, control_channels:channel_id` | [ADR-044](../../docs/decisions/adrs/adr-044-network-detection-engine-runtime-inventory.md) | +| `security-monitoring-managers` | `security_monitoring_managers` | `security_monitoring_manager_id` | `listeners:listener_id, components:component_id, agents:agent_id, agent_groups:group_id, content_sets:content_id, detection_definitions:definition_id, settings:setting_id` | [ADR-040](../../docs/decisions/adrs/adr-040-security-monitoring-manager-runtime-inventory.md), [ADR-045](../../docs/decisions/adrs/adr-045-security-monitoring-detection-definition-semantics.md) | +| `ssh-servers` | `ssh_servers` | `ssh_server_id` | `match_rules:match_id` | [ADR-031](../../docs/decisions/adrs/adr-031-ssh-server-configuration-surface.md) | +| `app-authorizations` | `app_authorizations` | `app_authorization_id` | `principals:principal_id, roles:role_id, permission_grants:grant_id, role_mappings:mapping_id, tenants:tenant_id` | [ADR-046](../../docs/decisions/adrs/adr-046-app-authorization-runtime-inventory.md) | +| `scheduled-jobs` | `scheduled_jobs` | `scheduled_job_id` | none | [ADR-047](../../docs/decisions/adrs/adr-047-scheduled-job-runtime-inventory.md) | +| `datastore-services` | `datastore_services` | `datastore_service_id` | `nodes:node_id, nodes:node_id/plugins:plugin_id, nodes:node_id/endpoints:endpoint_id, partitions:partition_id, templates:template_id, mappings:mapping_id, settings:setting_id` | [ADR-048](../../docs/decisions/adrs/adr-048-datastore-service-runtime-inventory.md), [ADR-058](../../docs/decisions/adrs/adr-058-datastore-node-engine-provenance-and-endpoints.md) | +| `platform-applications` | `platform_applications` | `platform_application_id` | `organizations:organization_id, tenants:tenant_id, content_objects:content_object_id, markings:marking_id, upstream_bindings:binding_id, connectors:connector_id, settings:setting_id` | [ADR-049](../../docs/decisions/adrs/adr-049-platform-application-runtime-inventory.md) | +| `forwarding-agents` | `forwarding_agents` | `forwarding_agent_id` | `sources:source_id, transforms:transform_id, ship_targets:target_id, reload_channels:reload_channel_id, settings:setting_id` | [ADR-050](../../docs/decisions/adrs/adr-050-forwarding-agent-runtime-inventory.md) | +| `orchestration-authorities` | `orchestration_authorities` | `orchestration_authority_id` | `spawn_templates:template_id, realized_children:workload_id` | [ADR-051](../../docs/decisions/adrs/adr-051-orchestration-authority-runtime-inventory.md) | The node-scoped `forwarding_agents` family is distinct from the scenario-level `forwarding_agents` authoring section ([sections.md](sections.md)); they share diff --git a/specs/sdl/sections.md b/specs/sdl/sections.md index fb1b65906..e47545db8 100644 --- a/specs/sdl/sections.md +++ b/specs/sdl/sections.md @@ -22,53 +22,53 @@ resolution rules and full reference-edge catalog with failure semantics are in [`references.md`](references.md). A blank "References" cell means the section is referenced by others but does not itself reference another section. -## Metadata and composition fields - -These describe the document and its composition. They are **not** authoring -sections. - -| Field | Shape | Required | Notes | -|-------|-------|----------|-------| -| `name` | scalar | **REQUIRED** | The scenario identity. The only required top-level field. | -| `version` | scalar | optional (default `*`) | Scenario version; `*` means unpinned. | -| `description` | scalar | optional (default empty) | Free-text description. | -| `module` | mapping \| null | optional (default null) | Published module metadata when this document is a composable module: a canonical `publisher/name` id, a `version`, declared `parameters`, and `exports` ([ADR-053](../../docs/decisions/adrs/adr-053-sdl-module-composition-for-inventory-backed-scenarios.md)). | -| `imports` | list | optional (default empty) | Module imports. Each import names a module by `source` (or the deprecated `path`) and binds it under a `namespace` with `parameters`. Imports are expanded before full semantic validation ([document-model.md §7](document-model.md)). | - -## Authoring sections — map-keyed - -Each is a map keyed by a user-defined identifier ([document-model.md §6](document-model.md)) -and defaults to an empty map when omitted. - -| Section | Required | Key shape | References | -|---------|----------|-----------|------------| -| `nodes` | optional | identifier ≤ 35 chars; may contain `.` | `features`, `conditions`, `injects`, `vulnerabilities`; hosts the runtime inventory ([runtime-inventory.md](runtime-inventory.md)) | -| `infrastructure` | optional | identifier matching a node | `nodes`; switch/network nodes; other `infrastructure` (dependencies) | -| `features` | optional | identifier | `vulnerabilities`; other `features` (dependencies, acyclic) | -| `conditions` | optional | identifier | — | -| `vulnerabilities` | optional | identifier | — | -| `entities` | optional | identifier | `vulnerabilities` | -| `injects` | optional | identifier | `entities` | -| `events` | optional | identifier | `conditions`, `injects` | -| `scripts` | optional | identifier | `events` | -| `stories` | optional | identifier | `scripts` | -| `content` | optional | identifier | `nodes` (VM target) | -| `accounts` | optional | identifier | `nodes` (VM) | -| `relationships` | optional | identifier | typed by subtype: `entities`/`accounts`/targetable elements; runtime families (`applications`, `database_services`, `mail_services`, `platform_applications`, `app_authorizations`); scenario `forwarding_agents` ([ADR-052](../../docs/decisions/adrs/adr-052-typed-runtime-relationship-subtypes.md)) | -| `agents` | optional | identifier | `entities`, `accounts`, `infrastructure`, `nodes`, `conditions`, `action_contracts`, `observation_boundaries`, targetable elements | -| `action_contracts` | optional | identifier | other `action_contracts` (interactions) | -| `observation_boundaries` | optional | identifier | own information refs (observable/hidden/evidence) | -| `outcome_interpretation_rules` | optional | identifier | `action_contracts`, `objectives`, `workflows` | -| `evidence_requirements` | optional | identifier | targetable elements for source, scope, channel, trigger, and boundary refs; distinct from `objectives` and scenario-native observability systems ([observability-and-evidence.md](observability-and-evidence.md)) | -| `objectives` | optional | identifier | `agents`/`entities` (actor), `action_contracts` (action), targetable elements (target), `conditions` (success — observable state only, [ADR-073](../../docs/decisions/adrs/adr-073-scoring-reward-language-scope.md)), `stories`/`scripts`/`events`/`workflows` (window), other `objectives` (depends_on, acyclic) | -| `workflows` | optional | identifier | own steps (`start`, successors), other `workflows` (compensation), `conditions` (predicates) | -| `variables` | optional | identifier matching `[A-Za-z_][A-Za-z0-9_-]*` | referenced by `${…}` placeholders ([variables-and-instantiation.md](variables-and-instantiation.md)) | - -## Authoring section — list-valued - -| Section | Shape | Required | Element identity | Referenced by | -|---------|-------|----------|------------------|---------------| -| `forwarding_agents` | list | optional (default empty) | `forwarding_agent_id` on each element | `relationships` (`forwarding_edge.forwarder_ref`) | +## Complete top-level field catalog + +This table is the complete, mechanically checked top-level language surface. +"Lifecycle" names the document forms in which the field is carried. A +composition field marked `expanded-empty` or `instantiated-empty` remains in the +model with its empty default after module expansion; its authored composition +instructions do not survive as executable scenario meaning. "References" is +`catalogued` when the field owns at least one row in the exact edge index in +[`references.md`](references.md). + +| Field | Kind | Shape | Lifecycle | Presence/default | Identity | References | Semantic owner | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `name` | metadata | scalar | normalized, expanded, instantiated | required | `scenario_name` | none | [document model](document-model.md) | +| `version` | metadata | scalar | normalized, expanded, instantiated | optional; default `*` | none | none | [document model](document-model.md) | +| `description` | metadata | scalar | normalized, expanded, instantiated | optional; default empty string | none | none | [document model](document-model.md) | +| `module` | composition | mapping | normalized, expanded-empty, instantiated-empty | optional; default null | `module.id` | none | [ADR-053](../../docs/decisions/adrs/adr-053-sdl-module-composition-for-inventory-backed-scenarios.md) | +| `imports` | composition | list | normalized, expanded-empty, instantiated-empty | optional; default empty list | `namespace` | none | [ADR-053](../../docs/decisions/adrs/adr-053-sdl-module-composition-for-inventory-backed-scenarios.md) | +| `nodes` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [nodes and runtime inventory](runtime-inventory.md) | +| `infrastructure` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [document model](document-model.md) | +| `features` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [document model](document-model.md) | +| `conditions` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | none | [document model](document-model.md) | +| `vulnerabilities` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | none | [document model](document-model.md) | +| `entities` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [document model](document-model.md) | +| `injects` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [reference catalog](references.md) | +| `events` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [reference catalog](references.md) | +| `scripts` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [reference catalog](references.md) | +| `stories` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [reference catalog](references.md) | +| `content` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [document model](document-model.md) | +| `accounts` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [document model](document-model.md) | +| `relationships` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [ADR-052](../../docs/decisions/adrs/adr-052-typed-runtime-relationship-subtypes.md) | +| `forwarding_agents` | section | list | normalized, expanded, instantiated | optional; default empty list | `forwarding_agent_id` | none | [ADR-050](../../docs/decisions/adrs/adr-050-forwarding-agent-runtime-inventory.md) | +| `agents` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [participant model](participant-model.md) | +| `action_contracts` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [participant model](participant-model.md) | +| `observation_boundaries` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [participant model](participant-model.md) | +| `outcome_interpretation_rules` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [participant model](participant-model.md) | +| `behavior_specifications` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [behavior specifications](behavior-specifications.md) | +| `evidence_requirements` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [observability and evidence](observability-and-evidence.md) | +| `objectives` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [objective semantics](objective-semantics.md) | +| `workflows` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | catalogued | [workflow semantics](workflow-semantics.md) | +| `variables` | section | map | normalized, expanded, instantiated | optional; default empty map | `map_key` | none | [variables and instantiation](variables-and-instantiation.md) | + + + +The section set therefore has two authoring shapes: maps keyed by stable +user-defined identifiers and the scenario-level `forwarding_agents` list, whose +elements carry their own stable identity. The checked summary above is derived +from the rows; changing a row without reconciling it fails the contract gate. `forwarding_agents` is the **scenario-level** forwarding-agent inventory. It is distinct from the node-scoped `forwarding_agents` runtime-family collection that diff --git a/tools/check_sdl_catalog_parity.py b/tools/check_sdl_catalog_parity.py new file mode 100644 index 000000000..a7ad16242 --- /dev/null +++ b/tools/check_sdl_catalog_parity.py @@ -0,0 +1,741 @@ +#!/usr/bin/env python3 +# ruff: noqa: E402, I001 +"""Prove that the normative SDL catalogs cover the live language surface. + +The published schema and normative prose remain independently governed +authorities. This read-only check compares both with the reference +implementation registries so drift is reported instead of silently generated +away. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parents[1] +PYTHON_PACKAGES = REPO_ROOT / "implementations" / "python" / "packages" +for import_root in (REPO_ROOT, PYTHON_PACKAGES): + if str(import_root) not in sys.path: + sys.path.insert(0, str(import_root)) + +from aces_sdl._language_metadata import REFERENCE_COMPLETION_TARGETS +from aces_sdl._mapping_scopes import HASHMAP_SECTIONS +from aces_sdl._module_symbols import HASHMAP_SECTIONS as MODULE_HASHMAP_SECTIONS +from aces_sdl._runtime_service_families import ( + RUNTIME_SERVICE_FAMILIES, + RuntimeReferenceChild, +) +from aces_sdl.scenario import Scenario +from tools.policy.common import ( + PolicyFailure, + apply_exceptions, + failures_to_json, + load_exceptions, +) + +SECTIONS_PATH = "specs/sdl/sections.md" +REFERENCES_PATH = "specs/sdl/references.md" +RUNTIME_PATH = "specs/sdl/runtime-inventory.md" +SCHEMA_PATH = "contracts/schemas/sdl/sdl-authoring-input-v1.json" + +_TOP_LEVEL_HEADING = "## Complete top-level field catalog" +_REFERENCE_HEADING = "## 6. Machine-checkable reference-edge index" +_RUNTIME_HEADING = "## 2. Family index" +_SUMMARY_RE = re.compile( + r"" +) +_SEPARATOR_RE = re.compile(r"^:?-{2,}:?$") +_BACKTICK_RE = re.compile(r"`([^`]+)`") +_VALID_KINDS = frozenset({"metadata", "composition", "section"}) +_VALID_SHAPES = frozenset({"scalar", "mapping", "map", "list"}) +_VALID_LIFECYCLE = frozenset({"normalized", "expanded", "instantiated", "expanded-empty", "instantiated-empty"}) +_MAX_CATALOG_BYTES = 512 * 1024 +_MAX_CATALOG_ROWS = 512 +_METADATA_FIELDS = frozenset({"name", "version", "description"}) +_COMPOSITION_FIELDS = frozenset({"module", "imports"}) + +_NODE_VALIDATOR = "[node validator](../../implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py)" +_INFRASTRUCTURE_VALIDATOR = ( + "[infrastructure validator](../../implementations/python/packages/aces_sdl/validator/_nodes_infra_network.py)" +) +_SECTION_VALIDATOR = "[section validator](../../implementations/python/packages/aces_sdl/validator/_sections.py)" +_CONTENT_VALIDATOR = ( + "[content validator](../../implementations/python/packages/aces_sdl/validator/_content_objectives.py)" +) +_ACCOUNT_VALIDATOR = ( + "[account validator](../../implementations/python/packages/aces_sdl/validator/_content_objectives.py)" +) +_RELATIONSHIP_VALIDATOR = ( + "[relationship validator](../../implementations/python/packages/aces_sdl/validator/_relationships.py)" +) +_PARTICIPANT_VALIDATOR = ( + "[participant validator](../../implementations/python/packages/aces_sdl/validator/_content_objectives.py)" +) +_PARTICIPANT_SEMANTICS = ( + "[participant semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py)" +) +_OUTCOME_SEMANTICS = ( + "[outcome semantics](../../implementations/python/packages/aces_sdl/semantics/participant_outcome.py)" +) +_BEHAVIOR_SEMANTICS = ( + "[behavior semantics](../../implementations/python/packages/aces_sdl/semantics/participant_behavior.py)" +) +_BEHAVIOR_VALIDATOR = ( + "[behavior validator](../../implementations/python/packages/aces_sdl/validator/_content_objectives.py)" +) +_BEHAVIOR_MODEL = "[behavior model](behavior-specifications.md)" +_EVIDENCE_VALIDATOR = ( + "[evidence validator](../../implementations/python/packages/aces_sdl/validator/_evidence_requirements.py)" +) +_OBJECTIVE_SEMANTICS = "[objective semantics](objective-semantics.md)" +_WORKFLOW_SEMANTICS = "[workflow semantics](workflow-semantics.md)" +_SEMANTIC = "semantic validation" +_STRUCTURAL = "structural validation" +_DANGLING = "fatal dangling or ambiguous" + +# This independently owned expectation makes every normative reference row a +# checked contract. The catalog is not generated from this registry; changing +# either authority requires an explicit, reviewable reconciliation. +_REFERENCE_EDGE_EXPECTATIONS: dict[str, tuple[str, str, str, str]] = { + "nodes.*.features[]": ("features", _SEMANTIC, _DANGLING, _NODE_VALIDATOR), + "nodes.*.conditions[]": ("conditions", _SEMANTIC, _DANGLING, _NODE_VALIDATOR), + "nodes.*.injects[]": ("injects", _SEMANTIC, _DANGLING, _NODE_VALIDATOR), + "nodes.*.vulnerabilities[]": ("vulnerabilities", _SEMANTIC, _DANGLING, _NODE_VALIDATOR), + "infrastructure.*.links[]": ("infrastructure", _SEMANTIC, _DANGLING, _INFRASTRUCTURE_VALIDATOR), + "infrastructure.*.dependencies[]": ( + "infrastructure", + _SEMANTIC, + _DANGLING, + _INFRASTRUCTURE_VALIDATOR, + ), + "features.*.dependencies[]": ( + "features", + _SEMANTIC, + "fatal dangling, ambiguous, or cyclic", + _SECTION_VALIDATOR, + ), + "entities.*.vulnerabilities[]": ("vulnerabilities", _SEMANTIC, _DANGLING, _SECTION_VALIDATOR), + "injects.*.from_entity": ("entities", _SEMANTIC, _DANGLING, _SECTION_VALIDATOR), + "injects.*.to_entities[]": ("entities", _SEMANTIC, _DANGLING, _SECTION_VALIDATOR), + "events.*.conditions[]": ("conditions", _SEMANTIC, _DANGLING, _SECTION_VALIDATOR), + "events.*.injects[]": ("injects", _SEMANTIC, _DANGLING, _SECTION_VALIDATOR), + "scripts.*.events[]": ("events", _SEMANTIC, _DANGLING, _SECTION_VALIDATOR), + "stories.*.scripts[]": ("scripts", _SEMANTIC, _DANGLING, _SECTION_VALIDATOR), + "content.*.target": ("nodes", _SEMANTIC, "fatal unless target is a vm node", _CONTENT_VALIDATOR), + "accounts.*.node": ("nodes", _SEMANTIC, "fatal unless target is a vm node", _ACCOUNT_VALIDATOR), + "relationships.*.source": ( + "targetable", + _SEMANTIC, + "fatal dangling or ambiguous; subtype may narrow domain", + _RELATIONSHIP_VALIDATOR, + ), + "relationships.*.target": ( + "targetable", + _SEMANTIC, + "fatal dangling or ambiguous; subtype may narrow domain", + _RELATIONSHIP_VALIDATOR, + ), + "agents.*.entity": ("entities", _SEMANTIC, _DANGLING, _PARTICIPANT_VALIDATOR), + "agents.*.starting_accounts[]": ("accounts", _SEMANTIC, _DANGLING, _PARTICIPANT_VALIDATOR), + "action_contracts.*.interactions.*.related_action_ref": ( + "action_contracts", + _SEMANTIC, + _DANGLING, + _PARTICIPANT_SEMANTICS, + ), + "observation_boundaries.*.view_rules.*.information_refs[]": ( + "derived:boundary_information", + _SEMANTIC, + "fatal outside declared boundary information", + _PARTICIPANT_SEMANTICS, + ), + "outcome_interpretation_rules.*.source_ref": ( + "action_contracts,objectives,workflows", + _SEMANTIC, + _DANGLING, + _OUTCOME_SEMANTICS, + ), + "behavior_specifications.*.participant_refs[]": ("agents", _SEMANTIC, _DANGLING, _BEHAVIOR_SEMANTICS), + "behavior_specifications.*.participant_role_refs[]": ( + "derived:agent_roles", + _SEMANTIC, + "fatal unless bound by a referenced participant", + _BEHAVIOR_SEMANTICS, + ), + "behavior_specifications.*.action_contract_refs[]": ( + "action_contracts", + _SEMANTIC, + _DANGLING, + _BEHAVIOR_SEMANTICS, + ), + "behavior_specifications.*.observation_boundary_refs[]": ( + "observation_boundaries", + _SEMANTIC, + _DANGLING, + _BEHAVIOR_SEMANTICS, + ), + "behavior_specifications.*.outcome_interpretation_rule_refs[]": ( + "outcome_interpretation_rules", + _SEMANTIC, + _DANGLING, + _BEHAVIOR_SEMANTICS, + ), + "behavior_specifications.*.authority_scope_refs[]": ( + "targetable", + _SEMANTIC, + _DANGLING, + _BEHAVIOR_VALIDATOR, + ), + "behavior_specifications.*.behavior_mode": ( + "vocabulary:behavior_mode", + _STRUCTURAL, + "fatal invalid vocabulary value", + _BEHAVIOR_MODEL, + ), + "behavior_specifications.*.ai_offensive_behavior_refs[]": ( + "vocabulary:ai_offensive_behavior", + _SEMANTIC, + "fatal unknown vocabulary identifier", + _BEHAVIOR_MODEL, + ), + "behavior_specifications.*.offensive_behavior_refs[]": ( + "vocabulary:offensive_behavior", + _SEMANTIC, + "fatal unknown vocabulary identifier", + _BEHAVIOR_MODEL, + ), + "behavior_specifications.*.realization_profile_ref": ( + "opaque:realization_profile", + _STRUCTURAL, + "fatal invalid reference shape; resolution belongs to realization", + _BEHAVIOR_MODEL, + ), + "behavior_specifications.*.backend_feature_support_refs[]": ( + "registry:behavior_features", + _SEMANTIC, + "fatal unsupported feature identifier", + _BEHAVIOR_SEMANTICS, + ), + "behavior_specifications.*.evidence_contract_refs[]": ( + "contract:participant_evidence", + _SEMANTIC, + "fatal unknown contract identifier", + _BEHAVIOR_SEMANTICS, + ), + "evidence_requirements.*.source_refs[]": ("targetable", _SEMANTIC, _DANGLING, _EVIDENCE_VALIDATOR), + "evidence_requirements.*.scope_refs[]": ("targetable", _SEMANTIC, _DANGLING, _EVIDENCE_VALIDATOR), + "evidence_requirements.*.channel_refs[]": ("targetable", _SEMANTIC, _DANGLING, _EVIDENCE_VALIDATOR), + "evidence_requirements.*.trigger_ref": ("targetable", _SEMANTIC, _DANGLING, _EVIDENCE_VALIDATOR), + "evidence_requirements.*.boundary_ref": ("targetable", _SEMANTIC, _DANGLING, _EVIDENCE_VALIDATOR), + "objectives.*.agent": ("agents", _SEMANTIC, _DANGLING, _OBJECTIVE_SEMANTICS), + "objectives.*.entity": ("entities", _SEMANTIC, _DANGLING, _OBJECTIVE_SEMANTICS), + "objectives.*.targets[]": ("targetable", _SEMANTIC, _DANGLING, _OBJECTIVE_SEMANTICS), + "objectives.*.depends_on[]": ( + "objectives", + _SEMANTIC, + "fatal dangling, ambiguous, or cyclic", + _OBJECTIVE_SEMANTICS, + ), + "workflows.*.start": ("workflow_steps", _SEMANTIC, "fatal dangling step", _WORKFLOW_SEMANTICS), +} + + +class CatalogParseError(ValueError): + """A normative catalog table is absent or malformed.""" + + +@dataclass(frozen=True) +class TopLevelRow: + field: str + kind: str + shape: str + lifecycle: tuple[str, ...] + presence: str + identity: str + references: str + owner: str + line_no: int + + +@dataclass(frozen=True) +class ReferenceRow: + source_path: str + domain: str + phase: str + failure: str + owner: str + line_no: int + + @property + def key(self) -> tuple[str, str]: + parts = self.source_path.replace("[]", "").split(".") + return parts[0], parts[-1] + + +@dataclass(frozen=True) +class RuntimeRow: + key: str + collection: str + primary_id: str + child_paths: tuple[str, ...] + owner: str + line_no: int + + +def _cells(line: str) -> list[str]: + parts = [part.strip() for part in line.strip().split("|")] + if parts and not parts[0]: + parts.pop(0) + if parts and not parts[-1]: + parts.pop() + return parts + + +def _unquote(cell: str) -> str: + match = _BACKTICK_RE.fullmatch(cell.strip()) + return match.group(1) if match else cell.strip() + + +def _table(text: str, heading: str, columns: int) -> list[tuple[int, list[str]]]: + size = len(text.encode("utf-8")) + if size > _MAX_CATALOG_BYTES: + raise CatalogParseError(f"catalog exceeds {_MAX_CATALOG_BYTES}-byte size limit") + lines = text.splitlines() + try: + start = next(index for index, line in enumerate(lines) if line.strip() == heading) + 1 + except StopIteration as exc: + raise CatalogParseError(f"missing catalog heading: {heading}") from exc + table: list[tuple[int, list[str]]] = [] + started = False + for index, line in enumerate(lines[start:], start=start): + if line.startswith("## "): + break + if line.lstrip().startswith("|"): + started = True + table.append((index + 1, _cells(line))) + if len(table) > _MAX_CATALOG_ROWS + 2: + raise CatalogParseError(f"catalog exceeds {_MAX_CATALOG_ROWS}-row limit") + elif started: + break + if len(table) < 3: + raise CatalogParseError(f"catalog under {heading!r} requires a header, separator, and data rows") + if len(table[0][1]) != columns: + raise CatalogParseError(f"catalog under {heading!r} has {len(table[0][1])} columns; expected {columns}") + separator = table[1][1] + if len(separator) != columns or not all(_SEPARATOR_RE.fullmatch(cell) for cell in separator): + raise CatalogParseError(f"catalog under {heading!r} has a malformed separator row") + for line_no, cells in table[2:]: + if len(cells) != columns: + raise CatalogParseError(f"catalog row at line {line_no} has {len(cells)} columns; expected {columns}") + return table[2:] + + +def _unique(rows: list[Any], key_name: str, label: str) -> None: + seen: dict[str, int] = {} + for row in rows: + key = getattr(row, key_name) + if key in seen: + raise CatalogParseError(f"duplicate {label} {key!r} at lines {seen[key]} and {row.line_no}") + seen[key] = row.line_no + + +def parse_top_level_catalog(text: str) -> list[TopLevelRow]: + rows = [ + TopLevelRow( + field=_unquote(cells[0]), + kind=cells[1].lower(), + shape=cells[2].lower(), + lifecycle=tuple(token.strip().lower() for token in cells[3].split(",") if token.strip()), + presence=cells[4].strip().lower(), + identity=_unquote(cells[5]), + references=cells[6].strip().lower(), + owner=cells[7].strip(), + line_no=line_no, + ) + for line_no, cells in _table(text, _TOP_LEVEL_HEADING, 8) + ] + _unique(rows, "field", "top-level field") + return rows + + +def parse_reference_catalog(text: str) -> list[ReferenceRow]: + rows = [ + ReferenceRow( + source_path=_unquote(cells[0]), + domain=_unquote(cells[1]), + phase=cells[2].strip().lower(), + failure=cells[3].strip().lower(), + owner=cells[4].strip(), + line_no=line_no, + ) + for line_no, cells in _table(text, _REFERENCE_HEADING, 5) + ] + seen: dict[tuple[str, str], int] = {} + for row in rows: + if row.key in seen: + raise CatalogParseError(f"duplicate reference edge {row.key!r} at lines {seen[row.key]} and {row.line_no}") + seen[row.key] = row.line_no + return rows + + +def parse_runtime_catalog(text: str) -> list[RuntimeRow]: + rows = [ + RuntimeRow( + key=_unquote(cells[0]), + collection=_unquote(cells[1]), + primary_id=_unquote(cells[2]), + child_paths=tuple(token.strip() for token in _unquote(cells[3]).split(",") if token.strip() != "none"), + owner=cells[4].strip(), + line_no=line_no, + ) + for line_no, cells in _table(text, _RUNTIME_HEADING, 5) + ] + _unique(rows, "key", "runtime family") + return rows + + +def _failure(rule_id: str, message: str, path: str) -> PolicyFailure: + return PolicyFailure(rule_id, message, path) + + +def _expected_kind(field: str) -> str: + if field in _METADATA_FIELDS: + return "metadata" + if field in _COMPOSITION_FIELDS: + return "composition" + return "section" + + +def _schema_shape(schema: dict[str, Any]) -> str: + schema_type = schema.get("type") + if schema_type == "string": + return "scalar" + if schema_type == "array": + return "list" + if schema_type == "object": + return "map" + if schema_type is None and schema.get("default") is None: + return "mapping" + return "unknown" + + +def _expected_presence(field: str) -> str: + model_field = Scenario.model_fields[field] + if model_field.is_required(): + return "required" + value = model_field.default_factory() if model_field.default_factory is not None else model_field.default + if value == "*": + return "optional; default `*`" + if value == "": + return "optional; default empty string" + if value is None: + return "optional; default null" + if value == []: + return "optional; default empty list" + if value == {}: + return "optional; default empty map" + return f"optional; default `{value}`" + + +def _expected_identity(field: str, shape: str) -> str: + if field == "name": + return "scenario_name" + if field == "module": + return "module.id" + if field == "imports": + return "namespace" + if field == "forwarding_agents": + return "forwarding_agent_id" + if shape == "map": + return "map_key" + return "none" + + +def _flatten_children(children: tuple[RuntimeReferenceChild, ...], prefix: str = "") -> tuple[str, ...]: + paths: list[str] = [] + for child in children: + path = ( + f"{prefix}/{child.collection_name}:{child.id_field}" + if prefix + else f"{child.collection_name}:{child.id_field}" + ) + paths.append(path) + paths.extend(_flatten_children(child.children, path)) + return tuple(paths) + + +def _check_top_level(text: str, schema: dict[str, Any]) -> tuple[list[PolicyFailure], list[TopLevelRow]]: + failures: list[PolicyFailure] = [] + try: + rows = parse_top_level_catalog(text) + except CatalogParseError as exc: + return [_failure("sdl-catalog-parse", str(exc), SECTIONS_PATH)], [] + by_field = {row.field: row for row in rows} + model_fields = set(Scenario.model_fields) + schema_fields = set(schema.get("properties", {})) + catalog_fields = set(by_field) + if model_fields != schema_fields or catalog_fields != model_fields: + failures.append( + _failure( + "sdl-catalog-field-set", + f"field sets differ: catalog-only={sorted(catalog_fields - model_fields)}, " + f"model-only={sorted(model_fields - catalog_fields)}, " + f"schema-only={sorted(schema_fields - model_fields)}, model-only-vs-schema={sorted(model_fields - schema_fields)}", + SECTIONS_PATH, + ) + ) + for field in sorted(catalog_fields & model_fields & schema_fields): + row = by_field[field] + expected_shape = _schema_shape(schema["properties"][field]) + if field in HASHMAP_SECTIONS: + expected_shape = "map" + if row.shape != expected_shape: + failures.append( + _failure( + "sdl-catalog-field-shape", + f"{field!r} is {expected_shape}, catalog says {row.shape}", + SECTIONS_PATH, + ) + ) + expected_presence = _expected_presence(field) + if row.presence != expected_presence: + failures.append( + _failure( + "sdl-catalog-field-default", + f"{field!r} is {expected_presence}, catalog says {row.presence}", + SECTIONS_PATH, + ) + ) + expected_identity = _expected_identity(field, expected_shape) + if row.identity != expected_identity: + failures.append( + _failure( + "sdl-catalog-field-identity", + f"{field!r} identity is {expected_identity!r}, catalog says {row.identity!r}", + SECTIONS_PATH, + ) + ) + if row.kind != _expected_kind(field) or row.kind not in _VALID_KINDS: + failures.append( + _failure( + "sdl-catalog-field-kind", + f"{field!r} has invalid kind {row.kind!r}", + SECTIONS_PATH, + ) + ) + if not row.lifecycle or not set(row.lifecycle) <= _VALID_LIFECYCLE: + failures.append( + _failure( + "sdl-catalog-lifecycle", + f"{field!r} has invalid lifecycle tokens {row.lifecycle!r}", + SECTIONS_PATH, + ) + ) + if row.shape not in _VALID_SHAPES or not row.identity or not row.owner: + failures.append( + _failure( + "sdl-catalog-row-incomplete", + f"{field!r} has an incomplete classification", + SECTIONS_PATH, + ) + ) + map_fields = {row.field for row in rows if row.shape == "map"} + if map_fields != set(HASHMAP_SECTIONS): + failures.append( + _failure( + "sdl-catalog-map-set", + f"map fields differ from mapping registry: {sorted(map_fields ^ set(HASHMAP_SECTIONS))}", + SECTIONS_PATH, + ) + ) + if not set(MODULE_HASHMAP_SECTIONS) <= map_fields: + failures.append( + _failure( + "sdl-catalog-module-map-set", + "module export maps are not a subset of catalogued maps", + SECTIONS_PATH, + ) + ) + summary = _SUMMARY_RE.search(text) + actual = { + "top": len(rows), + "meta": sum(row.kind != "section" for row in rows), + "sections": sum(row.kind == "section" for row in rows), + "maps": sum(row.shape == "map" for row in rows), + "lists": sum(row.shape == "list" and row.kind == "section" for row in rows), + } + if summary is None or any(int(summary.group(key)) != value for key, value in actual.items()): + failures.append( + _failure( + "sdl-catalog-summary", + f"checked summary is absent or stale; expected {actual}", + SECTIONS_PATH, + ) + ) + required = set(schema.get("required", [])) + model_required = {name for name, field in Scenario.model_fields.items() if field.is_required()} + if required != model_required: + failures.append( + _failure( + "sdl-catalog-schema-required", + f"published schema required set differs from model: {sorted(required ^ model_required)}", + SCHEMA_PATH, + ) + ) + return failures, rows + + +def _check_references(text: str, top_rows: list[TopLevelRow]) -> list[PolicyFailure]: + try: + rows = parse_reference_catalog(text) + except CatalogParseError as exc: + return [_failure("sdl-catalog-reference-parse", str(exc), REFERENCES_PATH)] + failures: list[PolicyFailure] = [] + by_source = {row.source_path: (row.domain, row.phase, row.failure, row.owner) for row in rows} + if by_source != _REFERENCE_EDGE_EXPECTATIONS: + differing = sorted( + source + for source in by_source.keys() | _REFERENCE_EDGE_EXPECTATIONS.keys() + if by_source.get(source) != _REFERENCE_EDGE_EXPECTATIONS.get(source) + ) + failures.append( + _failure( + "sdl-catalog-reference-row", + f"reference-edge contract differs for: {differing}", + REFERENCES_PATH, + ) + ) + by_key = {row.key: row for row in rows} + for key, domain in sorted(REFERENCE_COMPLETION_TARGETS.items()): + row = by_key.get(key) + if row is None or row.domain != domain: + actual = None if row is None else row.domain + failures.append( + _failure( + "sdl-catalog-reference-domain", + f"{key!r} expects domain {domain!r}, catalog says {actual!r}", + REFERENCES_PATH, + ) + ) + behavior_expectations = { + source: expected + for source, expected in _REFERENCE_EDGE_EXPECTATIONS.items() + if source.startswith("behavior_specifications.*.") + } + for source, expected in behavior_expectations.items(): + if by_source.get(source) != expected: + failures.append( + _failure( + "sdl-catalog-behavior-edge", + f"{source} must match its behavior reference contract", + REFERENCES_PATH, + ) + ) + source_sections = {row.key[0] for row in rows} + top_by_field = {row.field: row for row in top_rows} + for section in source_sections: + top = top_by_field.get(section) + if top is None or top.references != "catalogued": + failures.append( + _failure( + "sdl-catalog-reference-coverage", + f"reference source section {section!r} is not marked catalogued", + SECTIONS_PATH, + ) + ) + for row in top_rows: + if row.references == "catalogued" and row.field not in source_sections: + failures.append( + _failure( + "sdl-catalog-reference-coverage", + f"{row.field!r} is marked catalogued but has no edge row", + REFERENCES_PATH, + ) + ) + return failures + + +def _check_runtime(text: str) -> list[PolicyFailure]: + try: + rows = parse_runtime_catalog(text) + except CatalogParseError as exc: + return [_failure("sdl-catalog-runtime-parse", str(exc), RUNTIME_PATH)] + actual = {row.key: (row.collection, row.primary_id, row.child_paths) for row in rows} + expected = { + family.key: ( + family.collection_name, + family.id_field, + _flatten_children(family.child_refs), + ) + for family in RUNTIME_SERVICE_FAMILIES + } + if actual != expected: + differing = sorted(key for key in actual.keys() | expected.keys() if actual.get(key) != expected.get(key)) + return [ + _failure( + "sdl-catalog-runtime-family", + f"runtime-family catalog differs for: {differing}", + RUNTIME_PATH, + ) + ] + return [] + + +def evaluate_sdl_catalog_parity(repo_root: Path) -> list[PolicyFailure]: + """Return deterministic parity failures for the normative SDL catalogs.""" + required_paths = (SECTIONS_PATH, REFERENCES_PATH, RUNTIME_PATH, SCHEMA_PATH) + missing = [relative for relative in required_paths if not (repo_root / relative).is_file()] + if missing: + return [ + _failure( + "sdl-catalog-missing", + f"required catalog authority is missing: {relative}", + relative, + ) + for relative in missing + ] + try: + schema = json.loads((repo_root / SCHEMA_PATH).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + return [_failure("sdl-catalog-schema-parse", str(exc), SCHEMA_PATH)] + sections_text = (repo_root / SECTIONS_PATH).read_text(encoding="utf-8") + top_failures, top_rows = _check_top_level(sections_text, schema) + failures = list(top_failures) + failures.extend(_check_references((repo_root / REFERENCES_PATH).read_text(encoding="utf-8"), top_rows)) + failures.extend(_check_runtime((repo_root / RUNTIME_PATH).read_text(encoding="utf-8"))) + return failures + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Validate normative SDL catalog parity.") + parser.add_argument("--repo-root", type=Path, default=REPO_ROOT) + parser.add_argument("--json", action="store_true", help="Emit JSON failures.") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + failures = evaluate_sdl_catalog_parity(args.repo_root) + exceptions_path = args.repo_root / "tools" / "policy" / "exceptions.yaml" + if exceptions_path.is_file(): + failures = apply_exceptions(failures, load_exceptions(args.repo_root)) + if failures: + if args.json: + print(failures_to_json(failures)) + else: + for failure in failures: + print(failure.render(), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 40af65b9513023a380b8121ad412686a9e08dc8c Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 11 Jul 2026 19:40:40 -0700 Subject: [PATCH 11/15] feat: enforce portable SDL identifiers (#736) * feat(sdl)!: define canonical YAML source profile * refactor(sdl): satisfy source-profile quality gate * fix(sdl): make scalar validation branch explicit * fix: reconcile normative SDL catalogs * chore: trigger stacked PR checks * feat: enforce portable SDL identifiers * chore: trigger stacked PR checks * chore: retrigger stacked PR checks * fix: reconcile runtime contract boundaries * chore: trigger reconciled PR checks * Fix SonarCloud findings (cycle 1) --- contracts/schema-publication-manifest.json | 48 +- .../control-plane/operation-status-v1.json | 6 + .../schemas/plans/evaluation-plan-v1.json | 33 + .../schemas/plans/orchestration-plan-v1.json | 37 + .../schemas/plans/provisioning-plan-v1.json | 30 + .../schemas/sdl/instantiated-scenario-v1.json | 3960 +++++++++++------ .../scenario-instantiation-request-v1.json | 9 + .../schemas/sdl/sdl-authoring-input-v1.json | 867 +++- .../snapshots/runtime-snapshot-v1.json | 34 +- docs/decisions/adrs/README.md | 2 + ...sdl-identifiers-and-canonical-addresses.md | 646 +++ docs/decisions/adrs/adr-index.yaml | 3 + .../reference/fm-classification-ledger.yaml | 13 + .../reference/shared-semantic-integrity.md | 2 +- docs/explain/sdl/parser.md | 46 +- .../hospital-ransomware-surgery-day.sdl.yaml | 6 +- .../port-authority-surge-response.sdl.yaml | 6 +- .../satcom-release-poisoning.sdl.yaml | 6 +- .../aces_backend_libvirt/drivers/libvirt.py | 6 +- .../aces_backend_libvirt/realization.py | 9 +- .../techvault_concerns.py | 9 +- .../aces_backend_libvirt/techvault_matrix.py | 5 +- .../packages/aces_backend_protocols/naming.py | 36 + .../packages/aces_contracts/addressing.py | 65 + .../packages/aces_contracts/contracts.py | 253 +- .../packages/aces_contracts/planning.py | 97 +- .../packages/aces_contracts/runtime_state.py | 33 +- .../packages/aces_mcp/tools/authoring.py | 2 +- .../packages/aces_processor/compiler.py | 70 +- .../python/packages/aces_processor/models.py | 42 + .../aces_processor/semantics/planner.py | 6 +- .../aces_processor/semantics/realization.py | 20 +- .../aces_reference_backend/drivers/oci.py | 15 +- .../aces_reference_backend/realization.py | 5 +- .../packages/aces_runtime/backend_calls.py | 72 +- .../packages/aces_runtime/control_plane.py | 89 +- .../python/packages/aces_sdl/__init__.py | 3 + .../python/packages/aces_sdl/_base.py | 4 +- .../packages/aces_sdl/_composition_budget.py | 86 + .../python/packages/aces_sdl/_declarations.py | 424 ++ .../python/packages/aces_sdl/_identifiers.py | 140 + .../packages/aces_sdl/_language_references.py | 77 +- .../packages/aces_sdl/_module_symbols.py | 33 +- .../aces_sdl/_reference_targetability.py | 9 +- .../aces_sdl/_runtime_service_families.py | 113 +- .../aces_sdl/_source_identifier_paths.py | 70 + .../packages/aces_sdl/_source_profile.py | 7 +- .../python/packages/aces_sdl/_yaml_loader.py | 64 +- .../python/packages/aces_sdl/composition.py | 92 +- .../python/packages/aces_sdl/content.py | 4 +- .../python/packages/aces_sdl/entities.py | 3 +- .../python/packages/aces_sdl/identifiers.py | 27 + .../packages/aces_sdl/infrastructure.py | 3 +- .../packages/aces_sdl/language_service.py | 57 +- .../packages/aces_sdl/module_registry.py | 35 +- .../python/packages/aces_sdl/nodes.py | 5 +- .../python/packages/aces_sdl/orchestration.py | 3 +- .../python/packages/aces_sdl/parser.py | 71 +- .../aces_sdl/runtime_forwarding_agent.py | 3 +- .../packages/aces_sdl/runtime_values.py | 7 +- .../python/packages/aces_sdl/scenario.py | 135 +- .../packages/aces_sdl/schema_catalogs.py | 6 + .../packages/aces_sdl/semantics/objectives.py | 15 +- .../aces_sdl/validator/_content_objectives.py | 2 + .../packages/aces_sdl/validator/_core.py | 208 +- .../validator/_relationships_proxy.py | 9 +- .../aces_sdl/validator/_runtime_mail.py | 54 +- .../aces_sdl/validator/_runtime_services.py | 8 +- .../python/tests/test_language_service.py | 30 + .../tests/test_libvirt_backend_driver.py | 78 +- .../tests/test_libvirt_backend_provisioner.py | 4 +- .../tests/test_libvirt_backend_realization.py | 8 +- .../test_libvirt_backend_techvault_native.py | 53 +- .../python/tests/test_libvirt_evidence_run.py | 5 +- .../test_reference_backend_components.py | 1 + .../test_reference_backend_oci_driver.py | 19 +- .../test_reference_backend_provisioner.py | 31 +- .../test_reference_backend_realization.py | 23 +- .../python/tests/test_reference_processor.py | 2 +- .../test_run_307_shared_operational_state.py | 2 +- .../tests/test_runtime_control_plane.py | 57 +- .../tests/test_runtime_control_plane_api.py | 25 +- .../python/tests/test_runtime_datastore.py | 60 +- .../tests/test_runtime_forwarding_agent.py | 34 +- .../python/tests/test_runtime_models.py | 55 +- .../python/tests/test_runtime_planner.py | 6 + .../tests/test_runtime_scheduled_job.py | 4 +- .../tests/test_runtime_service_listeners.py | 6 +- .../tests/test_runtime_service_units.py | 16 +- .../python/tests/test_runtime_ssh_server.py | 10 +- .../python/tests/test_sdl_canonicalization.py | 6 +- .../python/tests/test_sdl_format_cli.py | 6 +- .../python/tests/test_sdl_identifiers.py | 848 ++++ .../python/tests/test_sdl_models.py | 42 +- .../python/tests/test_sdl_module_registry.py | 83 +- .../python/tests/test_sdl_parser.py | 114 +- .../python/tests/test_sdl_source_format.py | 26 +- .../python/tests/test_sdl_stress.py | 15 +- .../python/tests/test_sdl_validator.py | 67 +- .../python/tests/test_semantics_objectives.py | 22 +- .../python/tests/test_yaml_mapping_keys.py | 10 +- specs/sdl/diagnostics.md | 17 + specs/sdl/document-model.md | 97 +- specs/sdl/references.md | 26 +- specs/sdl/runtime-inventory.md | 5 +- specs/sdl/variables-and-instantiation.md | 9 +- 106 files changed, 7865 insertions(+), 2422 deletions(-) create mode 100644 docs/decisions/adrs/adr-076-portable-sdl-identifiers-and-canonical-addresses.md create mode 100644 implementations/python/packages/aces_backend_protocols/naming.py create mode 100644 implementations/python/packages/aces_contracts/addressing.py create mode 100644 implementations/python/packages/aces_sdl/_composition_budget.py create mode 100644 implementations/python/packages/aces_sdl/_declarations.py create mode 100644 implementations/python/packages/aces_sdl/_identifiers.py create mode 100644 implementations/python/packages/aces_sdl/_source_identifier_paths.py create mode 100644 implementations/python/packages/aces_sdl/identifiers.py create mode 100644 implementations/python/packages/aces_sdl/schema_catalogs.py create mode 100644 implementations/python/tests/test_sdl_identifiers.py diff --git a/contracts/schema-publication-manifest.json b/contracts/schema-publication-manifest.json index cdf6c43af..3a53b1cd2 100644 --- a/contracts/schema-publication-manifest.json +++ b/contracts/schema-publication-manifest.json @@ -74,7 +74,11 @@ "contract_id": "evaluation-plan-v1", "schema_path": "contracts/schemas/plans/evaluation-plan-v1.json", "stability": "draft", - "content_hash": "eeff605ae19059eb23648c717438189758f22ea8a35510c4561aa6f5f469a573" + "content_hash": "96a94d633471cab89a5c68ddf3f230c4d6aa93f89a92aaf8e99383062f7595da", + "last_change": { + "summary": "Constrained evaluation operations to the evaluation address domain and closed resource-type vocabulary for DSL-101/DSL-102/SEM-205.", + "content_hash": "96a94d633471cab89a5c68ddf3f230c4d6aa93f89a92aaf8e99383062f7595da" + } }, { "contract_id": "evaluation-result-envelope-v1", @@ -166,10 +170,10 @@ "contract_id": "instantiated-scenario-v1", "schema_path": "contracts/schemas/sdl/instantiated-scenario-v1.json", "stability": "draft", - "content_hash": "733046b498635268546eadc1e8c0977492cfe64fa9f71ac07c5f43ac7b5956c7", + "content_hash": "d72907f81de54bb182232ef6e6ad719d798fc08efc8e1fca1b4ce0308981394f", "last_change": { - "summary": "Aligned workflow wire fields with canonical snake_case and propagated the SDL normalized-object phase metadata into the instantiated contract definitions for DSL-105.", - "content_hash": "733046b498635268546eadc1e8c0977492cfe64fa9f71ac07c5f43ac7b5956c7" + "summary": "Published bounded composition-generated qualified declaration keys and list-preserving forwarding-agent identities for DSL-101/DSL-102/SEM-205.", + "content_hash": "d72907f81de54bb182232ef6e6ad719d798fc08efc8e1fca1b4ce0308981394f" } }, { @@ -182,13 +186,21 @@ "contract_id": "operation-status-v1", "schema_path": "contracts/schemas/control-plane/operation-status-v1.json", "stability": "draft", - "content_hash": "a4db430e1bef55cc954e9620fdd91897175baeab8d132088647642c02792f0ec" + "content_hash": "fd9af93db03252516e91ffe2cc4eae6c006221b5a10ef6b9998762ec51a5ed53", + "last_change": { + "summary": "Constrained backend-reported changed addresses to unique canonical compiled addresses for DSL-101/DSL-102.", + "content_hash": "fd9af93db03252516e91ffe2cc4eae6c006221b5a10ef6b9998762ec51a5ed53" + } }, { "contract_id": "orchestration-plan-v1", "schema_path": "contracts/schemas/plans/orchestration-plan-v1.json", "stability": "draft", - "content_hash": "9f132b651267f5cafa460879550a49eb18d735ab1e02b241e664ec6c73275bae" + "content_hash": "2ada1a85e8049b7ab52e8c867ab6360fb7fe2833c62ab9469d0b7a49aa0dd2e7", + "last_change": { + "summary": "Constrained orchestration operations to the orchestration address domain and closed resource-type vocabulary for DSL-101/DSL-102/SEM-205.", + "content_hash": "2ada1a85e8049b7ab52e8c867ab6360fb7fe2833c62ab9469d0b7a49aa0dd2e7" + } }, { "contract_id": "participant-behavior-history-event-stream-v1", @@ -324,10 +336,10 @@ "contract_id": "provisioning-plan-v1", "schema_path": "contracts/schemas/plans/provisioning-plan-v1.json", "stability": "draft", - "content_hash": "8ff225daf75c1d9e846bf36c492cb42076d1ae8c7c1c3ee0145772e2c7aba7e7", + "content_hash": "e3d9a1357af4e49b78d591cca10547a4d0113aee794686e0f8d96e32bb940e49", "last_change": { - "summary": "Added immutable realization-envelope identity carriage from planning to backend execution for ASR-519.", - "content_hash": "8ff225daf75c1d9e846bf36c492cb42076d1ae8c7c1c3ee0145772e2c7aba7e7" + "summary": "Added immutable realization-envelope identity carriage and constrained provisioning operations to canonical provision addresses and resource types.", + "content_hash": "e3d9a1357af4e49b78d591cca10547a4d0113aee794686e0f8d96e32bb940e49" } }, { @@ -360,26 +372,30 @@ "contract_id": "runtime-snapshot-v1", "schema_path": "contracts/schemas/snapshots/runtime-snapshot-v1.json", "stability": "draft", - "content_hash": "2c703b182c3bc96176dcf25f1cf1aaf39e661041efdeeacc7f8843fcfa2826bf", + "content_hash": "e3fa3702a06d7c025d34b90a8830b9099034fbea663cbe23c75368fabe72530d", "last_change": { - "summary": "Added typed realization-envelope identity persistence for ASR-519 runtime provenance.", - "content_hash": "2c703b182c3bc96176dcf25f1cf1aaf39e661041efdeeacc7f8843fcfa2826bf" + "summary": "Added typed realization-envelope identity persistence and closed snapshot entries to canonical compiled-address keys.", + "content_hash": "e3fa3702a06d7c025d34b90a8830b9099034fbea663cbe23c75368fabe72530d" } }, { "contract_id": "scenario-instantiation-request-v1", "schema_path": "contracts/schemas/sdl/scenario-instantiation-request-v1.json", "stability": "draft", - "content_hash": "72a4a1969bc39d551efd0be364804dcdfca7694eceb2a58c402a026d981cd470" + "content_hash": "130a103664dd69819c1dbe13437e94436dfb2b5db871f135e9f1750c741b412f", + "last_change": { + "summary": "Constrained instantiation parameter names to portable local identifiers while leaving parameter values outside declaration identity for DSL-101.", + "content_hash": "130a103664dd69819c1dbe13437e94436dfb2b5db871f135e9f1750c741b412f" + } }, { "contract_id": "sdl-authoring-input-v1", "schema_path": "contracts/schemas/sdl/sdl-authoring-input-v1.json", "stability": "draft", - "content_hash": "495345b06294ac9379009ad7b03b14df60ec54c25036764589355c9a1bc4f257", + "content_hash": "657347edf6a62da2fe6cf1ee5d16bea5531069beef896c467d2ce2792d05a15f", "last_change": { - "summary": "Declared the DSL-105 normalized authoring-object boundary, marked it as distinct from raw sdl-yaml/v1 source, and aligned workflow wire fields with canonical snake_case.", - "content_hash": "495345b06294ac9379009ad7b03b14df60ec54c25036764589355c9a1bc4f257" + "summary": "Published phase-specific portable local identifiers, bounded qualified references, explicit module namespaces, and stable list-valued forwarding-agent identities for DSL-101/DSL-102/SEM-205.", + "content_hash": "657347edf6a62da2fe6cf1ee5d16bea5531069beef896c467d2ce2792d05a15f" } }, { diff --git a/contracts/schemas/control-plane/operation-status-v1.json b/contracts/schemas/control-plane/operation-status-v1.json index 952b3b9c4..ab21c4175 100644 --- a/contracts/schemas/control-plane/operation-status-v1.json +++ b/contracts/schemas/control-plane/operation-status-v1.json @@ -5,6 +5,12 @@ "properties": { "changed_addresses": { "items": { + "maxLength": 2048, + "minLength": 3, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:[a-z0-9][a-z0-9_-]{0,63}|__private)(?:\\.(?:[a-z0-9][a-z0-9_-]{0,63}|__private))+$", "type": "string" }, "title": "Changed Addresses", diff --git a/contracts/schemas/plans/evaluation-plan-v1.json b/contracts/schemas/plans/evaluation-plan-v1.json index 0fb9da547..2faee1ec4 100644 --- a/contracts/schemas/plans/evaluation-plan-v1.json +++ b/contracts/schemas/plans/evaluation-plan-v1.json @@ -8,11 +8,28 @@ "type": "string" }, "address": { + "allOf": [ + { + "pattern": "^evaluation\\." + } + ], + "maxLength": 2048, + "minLength": 3, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:[a-z0-9][a-z0-9_-]{0,63}|__private)(?:\\.(?:[a-z0-9][a-z0-9_-]{0,63}|__private))+$", "title": "Address", "type": "string" }, "ordering_dependencies": { "items": { + "maxLength": 2048, + "minLength": 3, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:[a-z0-9][a-z0-9_-]{0,63}|__private)(?:\\.(?:[a-z0-9][a-z0-9_-]{0,63}|__private))+$", "type": "string" }, "title": "Ordering Dependencies", @@ -25,12 +42,22 @@ }, "refresh_dependencies": { "items": { + "maxLength": 2048, + "minLength": 3, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:[a-z0-9][a-z0-9_-]{0,63}|__private)(?:\\.(?:[a-z0-9][a-z0-9_-]{0,63}|__private))+$", "type": "string" }, "title": "Refresh Dependencies", "type": "array" }, "resource_type": { + "enum": [ + "condition-binding", + "objective" + ], "title": "Resource Type", "type": "string" } @@ -65,6 +92,12 @@ }, "startup_order": { "items": { + "maxLength": 2048, + "minLength": 3, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:[a-z0-9][a-z0-9_-]{0,63}|__private)(?:\\.(?:[a-z0-9][a-z0-9_-]{0,63}|__private))+$", "type": "string" }, "title": "Startup Order", diff --git a/contracts/schemas/plans/orchestration-plan-v1.json b/contracts/schemas/plans/orchestration-plan-v1.json index 1493be5a2..544aad407 100644 --- a/contracts/schemas/plans/orchestration-plan-v1.json +++ b/contracts/schemas/plans/orchestration-plan-v1.json @@ -8,11 +8,28 @@ "type": "string" }, "address": { + "allOf": [ + { + "pattern": "^orchestration\\." + } + ], + "maxLength": 2048, + "minLength": 3, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:[a-z0-9][a-z0-9_-]{0,63}|__private)(?:\\.(?:[a-z0-9][a-z0-9_-]{0,63}|__private))+$", "title": "Address", "type": "string" }, "ordering_dependencies": { "items": { + "maxLength": 2048, + "minLength": 3, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:[a-z0-9][a-z0-9_-]{0,63}|__private)(?:\\.(?:[a-z0-9][a-z0-9_-]{0,63}|__private))+$", "type": "string" }, "title": "Ordering Dependencies", @@ -25,12 +42,26 @@ }, "refresh_dependencies": { "items": { + "maxLength": 2048, + "minLength": 3, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:[a-z0-9][a-z0-9_-]{0,63}|__private)(?:\\.(?:[a-z0-9][a-z0-9_-]{0,63}|__private))+$", "type": "string" }, "title": "Refresh Dependencies", "type": "array" }, "resource_type": { + "enum": [ + "event", + "inject", + "inject-binding", + "script", + "story", + "workflow" + ], "title": "Resource Type", "type": "string" } @@ -65,6 +96,12 @@ }, "startup_order": { "items": { + "maxLength": 2048, + "minLength": 3, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:[a-z0-9][a-z0-9_-]{0,63}|__private)(?:\\.(?:[a-z0-9][a-z0-9_-]{0,63}|__private))+$", "type": "string" }, "title": "Startup Order", diff --git a/contracts/schemas/plans/provisioning-plan-v1.json b/contracts/schemas/plans/provisioning-plan-v1.json index fdb037c56..fa826226f 100644 --- a/contracts/schemas/plans/provisioning-plan-v1.json +++ b/contracts/schemas/plans/provisioning-plan-v1.json @@ -8,11 +8,28 @@ "type": "string" }, "address": { + "allOf": [ + { + "pattern": "^provision\\." + } + ], + "maxLength": 2048, + "minLength": 3, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:[a-z0-9][a-z0-9_-]{0,63}|__private)(?:\\.(?:[a-z0-9][a-z0-9_-]{0,63}|__private))+$", "title": "Address", "type": "string" }, "ordering_dependencies": { "items": { + "maxLength": 2048, + "minLength": 3, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:[a-z0-9][a-z0-9_-]{0,63}|__private)(?:\\.(?:[a-z0-9][a-z0-9_-]{0,63}|__private))+$", "type": "string" }, "title": "Ordering Dependencies", @@ -25,12 +42,25 @@ }, "refresh_dependencies": { "items": { + "maxLength": 2048, + "minLength": 3, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:[a-z0-9][a-z0-9_-]{0,63}|__private)(?:\\.(?:[a-z0-9][a-z0-9_-]{0,63}|__private))+$", "type": "string" }, "title": "Refresh Dependencies", "type": "array" }, "resource_type": { + "enum": [ + "account-placement", + "content-placement", + "feature-binding", + "network", + "node" + ], "title": "Resource Type", "type": "string" } diff --git a/contracts/schemas/sdl/instantiated-scenario-v1.json b/contracts/schemas/sdl/instantiated-scenario-v1.json index 91ec911d1..2fd045ef7 100644 --- a/contracts/schemas/sdl/instantiated-scenario-v1.json +++ b/contracts/schemas/sdl/instantiated-scenario-v1.json @@ -20,7 +20,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -31,7 +31,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -39,7 +39,7 @@ "direction": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Direction", "type": "string" @@ -47,18 +47,35 @@ "from_net": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "From Net", "type": "string" }, "name": { + "anyOf": [ + { + "const": "" + }, + { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + } + ], "default": "", - "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" - }, - "title": "Name", - "type": "string" + "title": "Name" }, "ports": { "items": { @@ -68,7 +85,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -80,7 +97,7 @@ "protocol": { "default": "any", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Protocol", "type": "string" @@ -88,7 +105,7 @@ "to_net": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "To Net", "type": "string" @@ -104,7 +121,7 @@ "auth_method": { "default": "password", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Auth Method", "type": "string" @@ -112,7 +129,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -124,7 +141,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -135,7 +152,7 @@ "groups": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -145,7 +162,7 @@ "home": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Home", "type": "string" @@ -153,7 +170,7 @@ "mail": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Mail", "type": "string" @@ -161,7 +178,7 @@ "node": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Node", "type": "string" @@ -173,7 +190,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -184,7 +201,7 @@ "shell": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Shell", "type": "string" @@ -192,14 +209,14 @@ "spn": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Spn", "type": "string" }, "username": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Username", "type": "string" @@ -218,7 +235,7 @@ "actions": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -228,7 +245,7 @@ "allowed_subnets": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -238,7 +255,7 @@ "authority_anchors": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -248,7 +265,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -256,7 +273,7 @@ "entity": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Entity", "type": "string" @@ -275,7 +292,7 @@ "observation_boundaries": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -285,7 +302,7 @@ "operating_scope": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -295,7 +312,7 @@ "starting_accounts": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -305,7 +322,7 @@ "starting_conditions": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -327,7 +344,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -342,7 +359,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -357,7 +374,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -385,7 +402,7 @@ "anyOf": [ { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -423,7 +440,7 @@ "anyOf": [ { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -437,7 +454,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -445,7 +462,7 @@ "environment": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -459,7 +476,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -473,7 +490,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -485,7 +502,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -514,7 +531,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -532,7 +549,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -565,7 +582,7 @@ "base_image": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Base Image", "type": "string" @@ -573,7 +590,7 @@ "base_image_digest": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Base Image Digest", "type": "string" @@ -606,7 +623,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -614,7 +631,7 @@ "dockerfile_path": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Dockerfile Path", "type": "string" @@ -651,7 +668,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -659,7 +676,7 @@ "destination": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Destination", "type": "string" @@ -667,7 +684,7 @@ "format": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Format", "type": "string" @@ -682,7 +699,7 @@ "path": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Path", "type": "string" @@ -694,7 +711,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -716,7 +733,7 @@ "tags": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -726,7 +743,7 @@ "target": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Target", "type": "string" @@ -735,7 +752,7 @@ "anyOf": [ { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -763,22 +780,40 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, + "display_name": { + "default": "", + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + }, + "title": "Display Name", + "type": "string" + }, "name": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, + "pattern": "^[a-z0-9]", "title": "Name", "type": "string" }, "tags": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -807,23 +842,32 @@ "description": "An observed logical database within a database service.", "properties": { "database_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Database Id", + "pattern": "^[a-z0-9]", "type": "string" }, "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "name": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -835,7 +879,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -902,21 +946,21 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "grantee_role_ref": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Grantee Role Ref", "type": "string" }, "object_ref": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Object Ref", "type": "string" @@ -928,7 +972,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -938,7 +982,7 @@ "privileges": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -952,7 +996,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -975,7 +1019,7 @@ "properties": { "address": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Address", "type": "string" @@ -983,7 +1027,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -995,7 +1039,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -1060,7 +1104,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -1074,14 +1118,14 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "name": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -1093,7 +1137,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -1103,7 +1147,7 @@ }, "role_id": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Role Id", "type": "string" @@ -1115,7 +1159,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -1153,14 +1197,14 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "name": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -1172,7 +1216,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -1182,7 +1226,7 @@ }, "schema_id": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Schema Id", "type": "string" @@ -1209,14 +1253,14 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "name": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -1228,7 +1272,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -1239,7 +1283,7 @@ "value": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Value", "type": "string" @@ -1251,7 +1295,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -1287,21 +1331,21 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "name": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" }, "table_id": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Table Id", "type": "string" @@ -1321,7 +1365,7 @@ "allowed_clients": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -1331,7 +1375,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -1343,7 +1387,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -1357,7 +1401,7 @@ "key_names": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -1367,7 +1411,7 @@ "policy": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Policy", "type": "string" @@ -1382,7 +1426,7 @@ "properties": { "address": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Address", "type": "string" @@ -1390,7 +1434,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -1402,7 +1446,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -1413,7 +1457,7 @@ "tls_server_name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Tls Server Name", "type": "string" @@ -1425,7 +1469,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -1474,7 +1518,7 @@ "properties": { "exchange": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Exchange", "type": "string" @@ -1486,7 +1530,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -1566,7 +1610,7 @@ "allow_recursion": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -1580,7 +1624,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -1594,7 +1638,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -1606,7 +1650,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -1628,7 +1672,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -1643,7 +1687,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -1661,7 +1705,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -1683,7 +1727,7 @@ "address": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Address", "type": "string" @@ -1691,7 +1735,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -1710,7 +1754,7 @@ "rdata": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Rdata", "type": "string" @@ -1740,7 +1784,7 @@ "target": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Target", "type": "string" @@ -1748,7 +1792,7 @@ "text": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -1766,14 +1810,14 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "owner": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Owner", "type": "string" @@ -1785,7 +1829,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -1800,7 +1844,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -1815,10 +1859,19 @@ "type": "array" }, "rrset_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Rrset Id", + "pattern": "^[a-z0-9]", "type": "string" }, "ttl": { @@ -1828,7 +1881,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -1846,7 +1899,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -1864,7 +1917,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -1888,14 +1941,14 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "name": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -1907,7 +1960,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -1918,7 +1971,7 @@ "value": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Value", "type": "string" @@ -1930,7 +1983,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -2001,7 +2054,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -2015,7 +2068,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -2024,7 +2077,7 @@ }, "mname": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Mname", "type": "string" @@ -2036,7 +2089,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -2050,7 +2103,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -2059,7 +2112,7 @@ }, "rname": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Rname", "type": "string" @@ -2071,7 +2124,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -2102,7 +2155,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -2116,7 +2169,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -2125,7 +2178,7 @@ }, "target": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Target", "type": "string" @@ -2137,7 +2190,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -2161,7 +2214,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -2173,7 +2226,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -2183,7 +2236,7 @@ }, "name": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -2195,7 +2248,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -2210,7 +2263,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -2243,7 +2296,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -2254,7 +2307,7 @@ "zone_file_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -2262,10 +2315,19 @@ "type": "array" }, "zone_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Zone Id", + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -2311,7 +2373,7 @@ "allowed_clients": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -2325,7 +2387,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -2339,7 +2401,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -2351,7 +2413,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -2365,7 +2427,7 @@ "primary_servers": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -2375,7 +2437,7 @@ "secondary_servers": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -2406,7 +2468,7 @@ "arguments": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -2416,7 +2478,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -2428,7 +2490,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -2475,7 +2537,7 @@ "categories": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -2485,14 +2547,23 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "entities": { - "additionalProperties": { - "$ref": "#/$defs/Entity" + "patternProperties": { + "^[a-z0-9]": { + "$ref": "#/$defs/Entity" + } + }, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + } }, "title": "Entities", "type": "object" @@ -2500,7 +2571,7 @@ "events": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -2510,7 +2581,7 @@ "facts": { "additionalProperties": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -2520,7 +2591,7 @@ "mission": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Mission", "type": "string" @@ -2528,7 +2599,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -2540,7 +2611,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -2554,7 +2625,7 @@ "vulnerabilities": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -2572,7 +2643,7 @@ "conditions": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -2582,7 +2653,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -2590,7 +2661,7 @@ "injects": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -2600,7 +2671,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -2664,7 +2735,7 @@ "artifact_role": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Artifact Role", "type": "string" @@ -2672,7 +2743,7 @@ "boundary_kind": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Boundary Kind", "type": "string" @@ -2680,7 +2751,7 @@ "boundary_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Boundary Ref", "type": "string" @@ -2688,7 +2759,7 @@ "capture_requirement_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Capture Requirement Ref", "type": "string" @@ -2696,7 +2767,7 @@ "capture_spec_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Capture Spec Ref", "type": "string" @@ -2708,7 +2779,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -2722,7 +2793,7 @@ "channel_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -2732,7 +2803,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -2744,7 +2815,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -2758,7 +2829,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -2768,7 +2839,7 @@ "media_types": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -2778,7 +2849,7 @@ "notes": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -2792,7 +2863,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -2806,7 +2877,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -2816,7 +2887,7 @@ "scope": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Scope", "type": "string" @@ -2824,7 +2895,7 @@ "scope_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -2838,7 +2909,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -2852,7 +2923,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -2866,7 +2937,7 @@ "source_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -2876,7 +2947,7 @@ "trigger_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Trigger Ref", "type": "string" @@ -2884,7 +2955,7 @@ "window": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Window", "type": "string" @@ -2962,28 +3033,28 @@ "properties": { "identifier": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Identifier", "type": "string" }, "loss_label": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Loss Label", "type": "string" }, "rationale": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Rationale", "type": "string" }, "system": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "System", "type": "string" @@ -3005,7 +3076,7 @@ "dependencies": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -3015,7 +3086,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -3023,7 +3094,7 @@ "destination": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Destination", "type": "string" @@ -3031,7 +3102,7 @@ "environment": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -3041,7 +3112,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -3063,7 +3134,7 @@ "vulnerabilities": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -3098,7 +3169,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -3109,7 +3180,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -3117,7 +3188,7 @@ "evidence_reference": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Evidence Reference", "type": "string" @@ -3125,7 +3196,7 @@ "predicate_type": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Predicate Type", "type": "string" @@ -3137,7 +3208,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -3152,7 +3223,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -3194,14 +3265,14 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "name": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -3209,7 +3280,7 @@ "value": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Value", "type": "string" @@ -3221,7 +3292,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -3243,7 +3314,7 @@ "command": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -3260,7 +3331,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -3268,7 +3339,7 @@ "entrypoint": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -3278,7 +3349,7 @@ "exposed_ports": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -3288,7 +3359,7 @@ "labels": { "additionalProperties": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -3298,7 +3369,7 @@ "working_directory": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Working Directory", "type": "string" @@ -3314,14 +3385,14 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "destination_path": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Destination Path", "type": "string" @@ -3329,14 +3400,14 @@ "from_stage": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "From Stage", "type": "string" }, "source_path": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Source Path", "type": "string" @@ -3356,14 +3427,14 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "name": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -3371,7 +3442,7 @@ "value": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Value", "type": "string" @@ -3383,7 +3454,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -3405,7 +3476,7 @@ "created_by": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Created By", "type": "string" @@ -3413,7 +3484,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -3421,7 +3492,7 @@ "digest": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Digest", "type": "string" @@ -3433,7 +3504,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -3451,7 +3522,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -3473,7 +3544,7 @@ "checksum": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Checksum", "type": "string" @@ -3481,7 +3552,7 @@ "checksum_algorithm": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Checksum Algorithm", "type": "string" @@ -3489,7 +3560,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -3497,14 +3568,14 @@ "destination_path": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Destination Path", "type": "string" }, "identifier": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Identifier", "type": "string" @@ -3512,7 +3583,7 @@ "source_path": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Source Path", "type": "string" @@ -3543,7 +3614,7 @@ "digest": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Digest", "type": "string" @@ -3551,20 +3622,29 @@ "namespace": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Namespace", "type": "string" }, "parameters": { - "additionalProperties": true, + "patternProperties": { + "^[a-z0-9]": {} + }, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + } + }, "title": "Parameters", "type": "object" }, "path": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Path", "type": "string" @@ -3572,7 +3652,7 @@ "source": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Source", "type": "string" @@ -3580,7 +3660,7 @@ "version": { "default": "*", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Version", "type": "string" @@ -3607,7 +3687,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -3618,7 +3698,7 @@ "dependencies": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -3628,7 +3708,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -3636,7 +3716,7 @@ "links": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -3652,7 +3732,7 @@ "items": { "additionalProperties": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -3678,7 +3758,7 @@ "accounts": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -3688,7 +3768,7 @@ "hosts": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -3698,7 +3778,7 @@ "services": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -3708,7 +3788,7 @@ "subnets": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -3726,7 +3806,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -3734,7 +3814,7 @@ "environment": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -3744,7 +3824,7 @@ "from_entity": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "From Entity", "type": "string" @@ -3752,7 +3832,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -3771,7 +3851,7 @@ "to_entities": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -3789,7 +3869,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -3798,7 +3878,7 @@ "additionalProperties": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -3809,16 +3889,26 @@ }, "id": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Id", "type": "string" }, "parameters": { "items": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, + "pattern": "^[a-z0-9]", "type": "string" }, "title": "Parameters", @@ -3826,7 +3916,7 @@ }, "version": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Version", "type": "string" @@ -3857,7 +3947,7 @@ "conditions": { "additionalProperties": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -3867,7 +3957,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -3875,7 +3965,7 @@ "features": { "additionalProperties": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -3885,7 +3975,7 @@ "injects": { "additionalProperties": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -3899,7 +3989,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -3913,7 +4003,7 @@ "os_version": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Os Version", "type": "string" @@ -3930,8 +4020,17 @@ "default": null }, "roles": { - "additionalProperties": { - "$ref": "#/$defs/Role" + "patternProperties": { + "^[a-z0-9]": { + "$ref": "#/$defs/Role" + } + }, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + } }, "title": "Roles", "type": "object" @@ -3971,7 +4070,7 @@ "vulnerabilities": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4013,7 +4112,7 @@ "actions": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4023,7 +4122,7 @@ "agent": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Agent", "type": "string" @@ -4031,7 +4130,7 @@ "depends_on": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4041,7 +4140,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -4049,7 +4148,7 @@ "entity": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Entity", "type": "string" @@ -4057,7 +4156,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -4068,7 +4167,7 @@ "targets": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4100,7 +4199,7 @@ "conditions": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4114,7 +4213,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -4133,7 +4232,7 @@ "events": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4143,7 +4242,7 @@ "scripts": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4153,7 +4252,7 @@ "steps": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4163,7 +4262,7 @@ "stories": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4173,7 +4272,7 @@ "workflows": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4199,7 +4298,7 @@ "diagnostics": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4209,7 +4308,7 @@ "evidence_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4219,7 +4318,7 @@ }, "interpretation_basis": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Interpretation Basis", "type": "string" @@ -4227,7 +4326,7 @@ "limitations": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4237,7 +4336,7 @@ }, "observation_point_basis": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Observation Point Basis", "type": "string" @@ -4247,7 +4346,7 @@ }, "semantic_version": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Semantic Version", "type": "string" @@ -4289,7 +4388,7 @@ "diagnostics": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4299,7 +4398,7 @@ "evidence_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4308,7 +4407,7 @@ }, "interpretation_role": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Interpretation Role", "type": "string" @@ -4316,7 +4415,7 @@ "provenance_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4325,14 +4424,14 @@ }, "ref": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Ref", "type": "string" }, "source_id": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Source Id", "type": "string" @@ -4378,7 +4477,7 @@ "diagnostics": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4388,7 +4487,7 @@ "evidence_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4399,7 +4498,7 @@ "anyOf": [ { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4413,7 +4512,7 @@ "limitations": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4422,21 +4521,21 @@ }, "ref": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Ref", "type": "string" }, "relation": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Relation", "type": "string" }, "target_id": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Target Id", "type": "string" @@ -4500,7 +4599,7 @@ "evidence_expectations": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4523,7 +4622,7 @@ }, "fidelity_claim": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Fidelity Claim", "type": "string" @@ -4542,7 +4641,7 @@ "observation_expectations": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4558,21 +4657,21 @@ }, "procedure_basis": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Procedure Basis", "type": "string" }, "realization_profile": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Realization Profile", "type": "string" }, "semantic_version": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Semantic Version", "type": "string" @@ -4580,7 +4679,7 @@ "state_transition_effects": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4611,7 +4710,7 @@ "properties": { "description": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -4621,7 +4720,7 @@ }, "effect_id": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Effect Id", "type": "string" @@ -4629,7 +4728,7 @@ "evidence_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4639,7 +4738,7 @@ "target_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4681,7 +4780,7 @@ "properties": { "description": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -4689,7 +4788,7 @@ "evidence_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4701,7 +4800,7 @@ }, "precondition_id": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Precondition Id", "type": "string" @@ -4709,7 +4808,7 @@ "support_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4731,14 +4830,14 @@ "properties": { "backend_error_code": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Backend Error Code", "type": "string" }, "diagnostic": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Diagnostic", "type": "string" @@ -4762,7 +4861,7 @@ "affected_temporal_ids": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4772,14 +4871,14 @@ }, "description": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "disclosure_id": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Disclosure Id", "type": "string" @@ -4790,7 +4889,7 @@ "limitations": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4830,7 +4929,7 @@ "action_contract_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4840,7 +4939,7 @@ "ai_offensive_behavior_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4850,7 +4949,7 @@ "authority_scope_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4860,7 +4959,7 @@ "backend_feature_support_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4871,7 +4970,7 @@ "anyOf": [ { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4885,7 +4984,7 @@ "evidence_contract_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4895,7 +4994,7 @@ "extension_policy": { "default": "governed-extension", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Extension Policy", "type": "string" @@ -4914,7 +5013,7 @@ "observation_boundary_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4924,7 +5023,7 @@ "offensive_behavior_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4934,7 +5033,7 @@ "outcome_interpretation_rule_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4944,7 +5043,7 @@ "participant_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4954,7 +5053,7 @@ "participant_role_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4965,7 +5064,7 @@ "anyOf": [ { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -4978,7 +5077,7 @@ }, "semantic_version": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Semantic Version", "type": "string" @@ -5075,7 +5174,7 @@ }, "rationale": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Rationale", "type": "string" @@ -5083,7 +5182,7 @@ "related_actions": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -5093,7 +5192,7 @@ "shared_state_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -5102,7 +5201,7 @@ }, "target": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Target", "type": "string" @@ -5123,7 +5222,7 @@ "evidence_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -5133,7 +5232,7 @@ "hidden_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -5142,7 +5241,7 @@ }, "latency_profile": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Latency Profile", "type": "string" @@ -5150,7 +5249,7 @@ "observable_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -5160,7 +5259,7 @@ "observer_effects": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -5169,7 +5268,7 @@ }, "projection_basis": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Projection Basis", "type": "string" @@ -5178,7 +5277,7 @@ "anyOf": [ { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -5191,7 +5290,7 @@ }, "redaction_policy": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Redaction Policy", "type": "string" @@ -5241,7 +5340,7 @@ "backend_disclosure_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -5250,14 +5349,14 @@ }, "clock_authority": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Clock Authority", "type": "string" }, "description": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -5266,7 +5365,7 @@ "anyOf": [ { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -5287,7 +5386,7 @@ }, "ordering_basis": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Ordering Basis", "type": "string" @@ -5296,7 +5395,7 @@ "anyOf": [ { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -5311,7 +5410,7 @@ "anyOf": [ { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -5326,7 +5425,7 @@ "anyOf": [ { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -5339,7 +5438,7 @@ }, "temporal_id": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Temporal Id", "type": "string" @@ -5354,7 +5453,7 @@ "anyOf": [ { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -5470,7 +5569,7 @@ "anyOf": [ { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -5485,7 +5584,7 @@ "anyOf": [ { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -5503,7 +5602,7 @@ "anyOf": [ { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -5517,7 +5616,7 @@ "evidence_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -5526,7 +5625,7 @@ }, "information_ref": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Information Ref", "type": "string" @@ -5535,7 +5634,7 @@ "anyOf": [ { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -5550,7 +5649,7 @@ "anyOf": [ { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -5563,7 +5662,7 @@ }, "visibility_basis": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Visibility Basis", "type": "string" @@ -5586,7 +5685,7 @@ "anyOf": [ { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -5599,7 +5698,7 @@ }, "certainty": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Certainty", "type": "string" @@ -5608,7 +5707,7 @@ "anyOf": [ { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -5621,7 +5720,7 @@ }, "effective_from": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Effective From", "type": "string" @@ -5633,7 +5732,7 @@ "evidence_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -5648,14 +5747,14 @@ }, "information_ref": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Information Ref", "type": "string" }, "latency_profile": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Latency Profile", "type": "string" @@ -5664,7 +5763,7 @@ "anyOf": [ { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -5680,7 +5779,7 @@ }, "transition_id": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Transition Id", "type": "string" @@ -5690,7 +5789,7 @@ }, "trigger": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Trigger", "type": "string" @@ -5754,7 +5853,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -5784,7 +5883,7 @@ "properties": { "additionalProperties": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -5815,14 +5914,14 @@ }, "source": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Source", "type": "string" }, "target": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Target", "type": "string" @@ -5850,7 +5949,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -5861,7 +5960,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -5869,7 +5968,7 @@ "role_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Role Ref", "type": "string" @@ -5885,7 +5984,7 @@ "crypto_method": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Crypto Method", "type": "string" @@ -5893,7 +5992,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -5905,7 +6004,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -5916,14 +6015,14 @@ "enrollment_identity_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Enrollment Identity Ref", "type": "string" }, "forwarder_ref": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Forwarder Ref", "type": "string" @@ -5935,7 +6034,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -5946,7 +6045,7 @@ "protocol": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Protocol", "type": "string" @@ -5958,7 +6057,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -5984,7 +6083,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -5995,7 +6094,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -6003,7 +6102,7 @@ "domain_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Domain Ref", "type": "string" @@ -6011,7 +6110,7 @@ "listener_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Listener Ref", "type": "string" @@ -6019,7 +6118,7 @@ "mailbox_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Mailbox Ref", "type": "string" @@ -6031,7 +6130,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -6046,7 +6145,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -6065,7 +6164,7 @@ "body_limit": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Body Limit", "type": "string" @@ -6077,7 +6176,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -6091,7 +6190,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -6103,7 +6202,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -6116,7 +6215,7 @@ }, "route_ref": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Route Ref", "type": "string" @@ -6124,7 +6223,7 @@ "upstream_node_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Upstream Node Ref", "type": "string" @@ -6132,7 +6231,7 @@ "upstream_service_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Upstream Service Ref", "type": "string" @@ -6151,7 +6250,7 @@ "auth_principal_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Auth Principal Ref", "type": "string" @@ -6159,7 +6258,7 @@ "consumer_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Consumer Ref", "type": "string" @@ -6167,7 +6266,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -6179,7 +6278,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -6194,7 +6293,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -6208,7 +6307,7 @@ "engine_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Engine Ref", "type": "string" @@ -6220,7 +6319,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -6281,7 +6380,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -6296,7 +6395,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -6319,7 +6418,7 @@ "entities": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -6328,7 +6427,7 @@ }, "username": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Username", "type": "string" @@ -6345,10 +6444,19 @@ "description": "Application-internal RBAC store inventory for a single owning spine.", "properties": { "app_authorization_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "App Authorization Id", + "pattern": "^[a-z0-9]", "type": "string" }, "auth_enabled": { @@ -6358,7 +6466,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -6372,7 +6480,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -6380,7 +6488,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -6406,7 +6514,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -6459,7 +6567,7 @@ "actions": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -6469,7 +6577,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -6481,7 +6589,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -6490,10 +6598,19 @@ "title": "Effect" }, "grant_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Grant Id", + "pattern": "^[a-z0-9]", "type": "string" }, "resource_kind": { @@ -6503,7 +6620,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -6514,7 +6631,7 @@ "resource_patterns": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -6524,7 +6641,7 @@ "role_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Role Ref", "type": "string" @@ -6552,7 +6669,7 @@ "backend_roles": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -6566,7 +6683,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -6577,7 +6694,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -6589,7 +6706,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -6607,7 +6724,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -6618,16 +6735,25 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" }, "principal_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Principal Id", + "pattern": "^[a-z0-9]", "type": "string" }, "reserved": { @@ -6637,7 +6763,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -6688,7 +6814,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -6696,16 +6822,25 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" }, "role_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Role Id", + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -6722,7 +6857,7 @@ "backend_roles": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -6732,7 +6867,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -6740,7 +6875,7 @@ "hosts": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -6748,16 +6883,25 @@ "type": "array" }, "mapping_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Mapping Id", + "pattern": "^[a-z0-9]", "type": "string" }, "role_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Role Ref", "type": "string" @@ -6765,7 +6909,7 @@ "users": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -6786,7 +6930,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -6794,16 +6938,25 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" }, "tenant_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Tenant Id", + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -6820,7 +6973,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -6828,7 +6981,7 @@ "disclosure": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Disclosure", "type": "string" @@ -6840,7 +6993,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -6855,7 +7008,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -6869,7 +7022,7 @@ "trigger": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Trigger", "type": "string" @@ -6885,14 +7038,14 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "name": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -6904,7 +7057,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -6915,7 +7068,7 @@ "value": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Value", "type": "string" @@ -6934,7 +7087,7 @@ "data_type": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Data Type", "type": "string" @@ -6942,7 +7095,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -6954,7 +7107,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -6964,7 +7117,7 @@ }, "name": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -6976,7 +7129,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -7030,7 +7183,7 @@ "condition": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Condition", "type": "string" @@ -7038,7 +7191,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -7050,7 +7203,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -7063,7 +7216,7 @@ }, "target": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Target", "type": "string" @@ -7082,7 +7235,7 @@ "content_type": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Content Type", "type": "string" @@ -7090,7 +7243,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -7102,7 +7255,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -7127,7 +7280,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -7141,7 +7294,7 @@ "auth_scheme": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Auth Scheme", "type": "string" @@ -7149,7 +7302,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -7171,7 +7324,7 @@ "methods": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -7181,7 +7334,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -7195,7 +7348,7 @@ }, "path": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Path", "type": "string" @@ -7216,7 +7369,7 @@ }, "route_id": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Route Id", "type": "string" @@ -7228,7 +7381,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -7242,7 +7395,7 @@ "static_assets": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -7252,7 +7405,7 @@ "templates": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -7273,7 +7426,7 @@ "vulnerability_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -7308,7 +7461,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -7319,7 +7472,7 @@ "target_node_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Target Node Ref", "type": "string" @@ -7327,7 +7480,7 @@ "target_service": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Target Service", "type": "string" @@ -7339,7 +7492,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -7359,16 +7512,25 @@ "description": "An observed application surface hosted by a transport service on a node.\n\n``service`` references the owning same-node ``Node.services[].name`` (bare\nname or the qualified ``nodes..services.`` form). The surface is\nobservation metadata; it never mutates ``Node.services``.", "properties": { "application_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Application Id", + "pattern": "^[a-z0-9]", "type": "string" }, "base_path": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Base Path", "type": "string" @@ -7376,7 +7538,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -7384,7 +7546,7 @@ "framework": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Framework", "type": "string" @@ -7392,7 +7554,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -7404,7 +7566,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -7422,7 +7584,7 @@ "service": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Service", "type": "string" @@ -7450,7 +7612,7 @@ "add": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -7460,7 +7622,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -7468,7 +7630,7 @@ "drop": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -7478,7 +7640,7 @@ "effective": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -7495,7 +7657,7 @@ "required": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -7586,7 +7748,32 @@ }, "forwarding_agents": { "items": { - "$ref": "#/$defs/RuntimeForwardingAgent" + "allOf": [ + { + "$ref": "#/$defs/RuntimeForwardingAgent" + }, + { + "properties": { + "forwarding_agent_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + } + }, + "type": "object" + } + ] }, "title": "Forwarding Agents", "type": "array" @@ -7780,7 +7967,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -7794,7 +7981,7 @@ "cgroup_parent": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Cgroup Parent", "type": "string" @@ -7802,7 +7989,7 @@ "command": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -7812,7 +7999,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -7820,7 +8007,7 @@ "device_cgroup_rules": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -7837,7 +8024,7 @@ "dns": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -7847,7 +8034,7 @@ "dns_options": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -7857,7 +8044,7 @@ "dns_search": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -7867,7 +8054,7 @@ "entrypoint": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -7884,7 +8071,7 @@ "group_add": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -7905,7 +8092,7 @@ "log_driver": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Log Driver", "type": "string" @@ -7913,7 +8100,7 @@ "log_options": { "additionalProperties": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -7923,7 +8110,7 @@ "masked_paths": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -7948,7 +8135,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -7966,7 +8153,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -7980,7 +8167,7 @@ "read_only_paths": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -7994,7 +8181,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -8008,7 +8195,7 @@ "runtime_name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Runtime Name", "type": "string" @@ -8016,7 +8203,7 @@ "seccomp_profile": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Seccomp Profile", "type": "string" @@ -8024,7 +8211,7 @@ "security_opt": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -8038,7 +8225,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -8100,7 +8287,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -8111,7 +8298,7 @@ "bind_source": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Bind Source", "type": "string" @@ -8123,7 +8310,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -8133,7 +8320,7 @@ }, "control_interface_id": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Control Interface Id", "type": "string" @@ -8141,7 +8328,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -8153,7 +8340,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -8163,7 +8350,7 @@ }, "path": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Path", "type": "string" @@ -8171,7 +8358,7 @@ "protocol": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Protocol", "type": "string" @@ -8212,10 +8399,19 @@ "description": "An observed database service hosted by a transport service on a node.\n\n``service`` references the owning same-node ``Node.services[].name`` (bare\nname or the qualified ``nodes..services.`` form). The inventory\nis observation metadata; it never mutates ``Node.services``.", "properties": { "database_service_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Database Service Id", + "pattern": "^[a-z0-9]", "type": "string" }, "databases": { @@ -8228,7 +8424,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -8240,7 +8436,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -8265,7 +8461,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -8277,7 +8473,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -8295,7 +8491,7 @@ "service": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Service", "type": "string" @@ -8310,7 +8506,7 @@ "version": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Version", "type": "string" @@ -8328,7 +8524,7 @@ "properties": { "cluster_id": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Cluster Id", "type": "string" @@ -8336,7 +8532,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -8344,7 +8540,7 @@ "discovery_mode": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Discovery Mode", "type": "string" @@ -8356,7 +8552,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -8370,7 +8566,7 @@ "health": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Health", "type": "string" @@ -8378,7 +8574,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -8386,7 +8582,7 @@ "native_protocol_version": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Native Protocol Version", "type": "string" @@ -8398,7 +8594,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -8412,7 +8608,7 @@ "partitioner": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Partitioner", "type": "string" @@ -8424,7 +8620,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -8442,7 +8638,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -8460,7 +8656,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -8474,7 +8670,7 @@ "uuid": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Uuid", "type": "string" @@ -8522,7 +8718,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -8530,22 +8726,31 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" }, "plugin_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Plugin Id", + "pattern": "^[a-z0-9]", "type": "string" }, "version": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Version", "type": "string" @@ -8585,7 +8790,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -8599,7 +8804,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -8607,7 +8812,7 @@ "dynamic_policy": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Dynamic Policy", "type": "string" @@ -8619,7 +8824,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -8633,7 +8838,7 @@ "evidence_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -8648,7 +8853,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -8664,7 +8869,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -8676,16 +8881,25 @@ "title": "Leaf Field Count" }, "mapping_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Mapping Id", + "pattern": "^[a-z0-9]", "type": "string" }, "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -8693,7 +8907,7 @@ "partition_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Partition Ref", "type": "string" @@ -8701,7 +8915,7 @@ "schema_digest": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Schema Digest", "type": "string" @@ -8713,7 +8927,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -8738,7 +8952,7 @@ "build_hash": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Build Hash", "type": "string" @@ -8746,7 +8960,7 @@ "build_type": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Build Type", "type": "string" @@ -8754,7 +8968,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -8769,7 +8983,7 @@ "engine_version": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Engine Version", "type": "string" @@ -8781,7 +8995,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -8799,7 +9013,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -8817,7 +9031,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -8835,7 +9049,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -8849,16 +9063,25 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" }, "node_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Node Id", + "pattern": "^[a-z0-9]", "type": "string" }, "plugins": { @@ -8876,7 +9099,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -8899,7 +9122,7 @@ "address": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Address", "type": "string" @@ -8907,16 +9130,25 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "endpoint_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Endpoint Id", + "pattern": "^[a-z0-9]", "type": "string" }, "port": { @@ -8926,7 +9158,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -8940,7 +9172,7 @@ "protocol": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Protocol", "type": "string" @@ -8952,7 +9184,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -9001,7 +9233,7 @@ "creation_timestamp": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Creation Timestamp", "type": "string" @@ -9014,7 +9246,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -9026,7 +9258,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -9038,7 +9270,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -9056,7 +9288,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -9074,7 +9306,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -9088,7 +9320,7 @@ "health": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Health", "type": "string" @@ -9100,7 +9332,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -9111,7 +9343,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -9119,16 +9351,25 @@ "open_closed_status": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Open Closed Status", "type": "string" }, "partition_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Partition Id", + "pattern": "^[a-z0-9]", "type": "string" }, "per_dc_factor_map": { @@ -9139,7 +9380,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -9155,7 +9396,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -9173,7 +9414,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -9191,7 +9432,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -9206,7 +9447,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -9224,7 +9465,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -9238,7 +9479,7 @@ "uuid": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Uuid", "type": "string" @@ -9274,7 +9515,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -9288,7 +9529,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -9300,7 +9541,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -9311,14 +9552,14 @@ "maxmemory": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Maxmemory", "type": "string" }, "persistence_id": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Persistence Id", "type": "string" @@ -9326,7 +9567,7 @@ "rdb_save_points": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -9358,7 +9599,7 @@ "aliases": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -9368,7 +9609,7 @@ "authorization_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Authorization Ref", "type": "string" @@ -9376,7 +9617,7 @@ "backup_targets": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -9401,7 +9642,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -9410,16 +9651,25 @@ "title": "Data Model" }, "datastore_service_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Datastore Service Id", + "pattern": "^[a-z0-9]", "type": "string" }, "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -9431,7 +9681,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -9442,7 +9692,7 @@ "ingest_pipelines": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -9452,7 +9702,7 @@ "lifecycle_policies": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -9469,7 +9719,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -9502,7 +9752,7 @@ "protocol": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Protocol", "type": "string" @@ -9510,7 +9760,7 @@ "pubsub_channels": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -9520,7 +9770,7 @@ "queues_streams": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -9530,7 +9780,7 @@ "service": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Service", "type": "string" @@ -9563,7 +9813,7 @@ "version": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Version", "type": "string" @@ -9586,7 +9836,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -9597,7 +9847,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -9605,7 +9855,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -9617,7 +9867,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -9632,7 +9882,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -9641,16 +9891,25 @@ "title": "Scope" }, "setting_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Setting Id", + "pattern": "^[a-z0-9]", "type": "string" }, "value": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Value", "type": "string" @@ -9694,7 +9953,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -9702,7 +9961,7 @@ "evidence_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -9712,7 +9971,7 @@ "index_patterns": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -9722,7 +9981,7 @@ "mapping_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Mapping Ref", "type": "string" @@ -9730,7 +9989,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -9740,7 +9999,7 @@ "anyOf": [ { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -9758,16 +10017,25 @@ "template_digest": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Template Digest", "type": "string" }, "template_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Template Id", + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -9788,7 +10056,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -9802,7 +10070,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -9814,7 +10082,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -9829,7 +10097,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -9842,7 +10110,7 @@ }, "transport_security_id": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Transport Security Id", "type": "string" @@ -9870,7 +10138,7 @@ "properties": { "ecosystem": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Ecosystem", "type": "string" @@ -9878,7 +10146,7 @@ "format": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Format", "type": "string" @@ -9886,14 +10154,14 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" }, "path": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Path", "type": "string" @@ -9901,7 +10169,7 @@ "version": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Version", "type": "string" @@ -9920,7 +10188,7 @@ "properties": { "container_path": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Container Path", "type": "string" @@ -9928,14 +10196,14 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "host_path": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Host Path", "type": "string" @@ -9943,7 +10211,7 @@ "permissions": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Permissions", "type": "string" @@ -9963,7 +10231,7 @@ "configuration_file_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -9973,16 +10241,25 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "dns_service_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Dns Service Id", + "pattern": "^[a-z0-9]", "type": "string" }, "dynamic_update": { @@ -10003,7 +10280,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -10014,7 +10291,7 @@ "log_file_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -10024,7 +10301,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -10048,7 +10325,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -10060,7 +10337,7 @@ "service": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Service", "type": "string" @@ -10075,7 +10352,7 @@ "version": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Version", "type": "string" @@ -10114,14 +10391,14 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "name": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -10133,7 +10410,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -10144,7 +10421,7 @@ "source": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Source", "type": "string" @@ -10152,7 +10429,7 @@ "value": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Value", "type": "string" @@ -10164,7 +10441,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -10199,7 +10476,7 @@ "properties": { "address": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Address", "type": "string" @@ -10207,14 +10484,14 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "hostname": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Hostname", "type": "string" @@ -10248,7 +10525,7 @@ "backend": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Backend", "type": "string" @@ -10256,16 +10533,25 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "file_service_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "File Service Id", + "pattern": "^[a-z0-9]", "type": "string" }, "principals": { @@ -10282,7 +10568,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -10293,7 +10579,7 @@ "service": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Service", "type": "string" @@ -10367,7 +10653,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -10382,7 +10668,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -10393,16 +10679,25 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "observation_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Observation Id", + "pattern": "^[a-z0-9]", "type": "string" }, "outcome": { @@ -10412,7 +10707,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -10422,7 +10717,7 @@ }, "resource_ref": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Resource Ref", "type": "string" @@ -10434,7 +10729,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -10444,7 +10739,7 @@ }, "subject_ref": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Subject Ref", "type": "string" @@ -10482,7 +10777,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -10497,7 +10792,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -10508,7 +10803,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -10520,7 +10815,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -10530,21 +10825,30 @@ }, "resource_ref": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Resource Ref", "type": "string" }, "rule_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Rule Id", + "pattern": "^[a-z0-9]", "type": "string" }, "subject_ref": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Subject Ref", "type": "string" @@ -10583,7 +10887,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -10594,7 +10898,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -10602,7 +10906,7 @@ "directory_subject_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Directory Subject Ref", "type": "string" @@ -10610,7 +10914,7 @@ "external_id": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "External Id", "type": "string" @@ -10622,7 +10926,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -10633,14 +10937,14 @@ "local_user_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Local User Ref", "type": "string" }, "name": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -10652,7 +10956,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -10661,10 +10965,19 @@ "title": "Origin" }, "principal_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Principal Id", + "pattern": "^[a-z0-9]", "type": "string" }, "status": { @@ -10674,7 +10987,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -10757,7 +11070,7 @@ "backing_path": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Backing Path", "type": "string" @@ -10769,7 +11082,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -10783,7 +11096,7 @@ "comment": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Comment", "type": "string" @@ -10791,7 +11104,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -10803,7 +11116,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -10817,7 +11130,7 @@ "invalid_users": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -10831,7 +11144,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -10841,7 +11154,7 @@ }, "name": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -10853,7 +11166,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -10865,16 +11178,25 @@ "title": "Read Only" }, "share_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Share Id", + "pattern": "^[a-z0-9]", "type": "string" }, "valid_groups": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -10884,7 +11206,7 @@ "valid_users": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -10894,7 +11216,7 @@ "write_users": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -10928,7 +11250,7 @@ "content_digest": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Content Digest", "type": "string" @@ -10936,7 +11258,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -10944,7 +11266,7 @@ "digest_algorithm": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Digest Algorithm", "type": "string" @@ -10956,7 +11278,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -10971,7 +11293,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -10985,7 +11307,7 @@ "mode": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Mode", "type": "string" @@ -10993,7 +11315,7 @@ "owner_group": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Owner Group", "type": "string" @@ -11001,14 +11323,14 @@ "owner_user": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Owner User", "type": "string" }, "path": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Path", "type": "string" @@ -11020,7 +11342,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -11031,7 +11353,7 @@ "provenance": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Provenance", "type": "string" @@ -11043,7 +11365,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -11058,7 +11380,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -11072,7 +11394,7 @@ "source_path": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Source Path", "type": "string" @@ -11084,7 +11406,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -11099,7 +11421,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -11170,7 +11492,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -11192,14 +11514,14 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "forwarding_agent_id": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Forwarding Agent Id", "type": "string" @@ -11211,7 +11533,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -11222,7 +11544,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -11265,7 +11587,7 @@ "version": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Version", "type": "string" @@ -11325,7 +11647,7 @@ "properties": { "buffer_policy_id": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Buffer Policy Id", "type": "string" @@ -11337,7 +11659,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -11348,7 +11670,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -11360,7 +11682,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -11378,7 +11700,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -11396,7 +11718,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -11467,7 +11789,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -11479,7 +11801,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -11488,16 +11810,25 @@ "title": "Kind" }, "reload_channel_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Reload Channel Id", + "pattern": "^[a-z0-9]", "type": "string" }, "target_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Target Ref", "type": "string" @@ -11534,7 +11865,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -11545,7 +11876,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -11553,7 +11884,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -11565,7 +11896,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -11574,16 +11905,25 @@ "title": "Provenance" }, "setting_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Setting Id", + "pattern": "^[a-z0-9]", "type": "string" }, "value": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Value", "type": "string" @@ -11625,7 +11965,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -11637,7 +11977,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -11652,7 +11992,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -11670,7 +12010,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -11688,7 +12028,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -11697,16 +12037,25 @@ "title": "Protocol" }, "target_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Target Id", + "pattern": "^[a-z0-9]", "type": "string" }, "target_node_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Target Node Ref", "type": "string" @@ -11714,7 +12063,7 @@ "target_service_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Target Service Ref", "type": "string" @@ -11733,7 +12082,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -11745,7 +12094,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -11756,7 +12105,7 @@ "location": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Location", "type": "string" @@ -11768,7 +12117,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -11779,16 +12128,25 @@ "selector": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Selector", "type": "string" }, "source_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Source Id", + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -11817,7 +12175,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -11829,7 +12187,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -11840,16 +12198,25 @@ "sid_namespace": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Sid Namespace", "type": "string" }, "transform_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Transform Id", + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -11878,7 +12245,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -11890,7 +12257,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -11915,7 +12282,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -11947,7 +12314,7 @@ "end": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "End", "type": "string" @@ -11959,7 +12326,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -11973,7 +12340,7 @@ "output": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Output", "type": "string" @@ -11985,7 +12352,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -11996,7 +12363,7 @@ "start": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Start", "type": "string" @@ -12012,14 +12379,14 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "name": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -12031,7 +12398,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -12046,7 +12413,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -12061,7 +12428,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -12072,7 +12439,7 @@ "values": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -12093,7 +12460,7 @@ "base_dn": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Base Dn", "type": "string" @@ -12101,7 +12468,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -12109,22 +12476,31 @@ "domain_name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Domain Name", "type": "string" }, "identity_authority_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Identity Authority Id", + "pattern": "^[a-z0-9]", "type": "string" }, "issuer": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Issuer", "type": "string" @@ -12136,7 +12512,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -12147,7 +12523,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -12155,7 +12531,7 @@ "namespace": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Namespace", "type": "string" @@ -12170,7 +12546,7 @@ "realm": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Realm", "type": "string" @@ -12199,7 +12575,7 @@ "tenant_id": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Tenant Id", "type": "string" @@ -12251,7 +12627,7 @@ "address": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Address", "type": "string" @@ -12259,7 +12635,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -12271,7 +12647,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -12289,7 +12665,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -12300,16 +12676,25 @@ "service": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Service", "type": "string" }, "service_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Service Id", + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -12326,7 +12711,7 @@ "applies_to_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -12336,7 +12721,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -12344,16 +12729,25 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" }, "policy_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Policy Id", + "pattern": "^[a-z0-9]", "type": "string" }, "policy_kind": { @@ -12363,7 +12757,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -12438,7 +12832,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -12446,16 +12840,25 @@ "external_target": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "External Target", "type": "string" }, "relationship_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Relationship Id", + "pattern": "^[a-z0-9]", "type": "string" }, "relationship_type": { @@ -12465,7 +12868,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -12475,7 +12878,7 @@ }, "source_ref": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Source Ref", "type": "string" @@ -12483,7 +12886,7 @@ "target_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Target Ref", "type": "string" @@ -12528,7 +12931,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -12536,7 +12939,7 @@ "display_name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Display Name", "type": "string" @@ -12544,7 +12947,7 @@ "distinguished_name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Distinguished Name", "type": "string" @@ -12552,7 +12955,7 @@ "domain": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Domain", "type": "string" @@ -12564,7 +12967,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -12582,7 +12985,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -12592,7 +12995,7 @@ }, "name": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -12604,7 +13007,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -12615,7 +13018,7 @@ "principal_name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Principal Name", "type": "string" @@ -12623,7 +13026,7 @@ "service_principal_names": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -12631,10 +13034,19 @@ "type": "array" }, "subject_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Subject Id", + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -12670,7 +13082,7 @@ "argv": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -12684,7 +13096,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -12695,7 +13107,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -12707,7 +13119,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -12721,7 +13133,7 @@ "executable_path": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Executable Path", "type": "string" @@ -12729,7 +13141,7 @@ "implementation": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Implementation", "type": "string" @@ -12741,7 +13153,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -12808,7 +13220,7 @@ "criteria": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Criteria", "type": "string" @@ -12816,7 +13228,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -12824,7 +13236,7 @@ "evidence_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -12834,7 +13246,7 @@ "probe": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Probe", "type": "string" @@ -12864,7 +13276,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -12876,7 +13288,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -12890,7 +13302,7 @@ "members": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -12899,7 +13311,7 @@ }, "name": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -12911,7 +13323,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -12933,7 +13345,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -12970,7 +13382,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -12982,7 +13394,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -12993,7 +13405,7 @@ "gecos": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Gecos", "type": "string" @@ -13001,7 +13413,7 @@ "home": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Home", "type": "string" @@ -13013,7 +13425,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -13028,7 +13440,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -13043,7 +13455,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -13057,7 +13469,7 @@ "primary_group": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Primary Group", "type": "string" @@ -13069,7 +13481,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -13080,7 +13492,7 @@ "shell": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Shell", "type": "string" @@ -13092,7 +13504,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -13103,7 +13515,7 @@ "supplemental_groups": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -13117,7 +13529,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -13130,7 +13542,7 @@ }, "username": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Username", "type": "string" @@ -13149,22 +13561,31 @@ "address": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Address", "type": "string" }, "alias_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Alias Id", + "pattern": "^[a-z0-9]", "type": "string" }, "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -13172,7 +13593,7 @@ "domain_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Domain Ref", "type": "string" @@ -13180,7 +13601,7 @@ "external_targets": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -13190,7 +13611,7 @@ "target_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -13228,16 +13649,25 @@ "description": "A mail-service engine/component such as Postfix, Dovecot, or a filter.", "properties": { "component_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Component Id", + "pattern": "^[a-z0-9]", "type": "string" }, "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -13249,7 +13679,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -13259,7 +13689,7 @@ }, "name": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -13267,7 +13697,7 @@ "version": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Version", "type": "string" @@ -13322,21 +13752,30 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "domain_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Domain Id", + "pattern": "^[a-z0-9]", "type": "string" }, "name": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -13348,7 +13787,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -13386,7 +13825,7 @@ "advertised_identity": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Advertised Identity", "type": "string" @@ -13399,7 +13838,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -13411,7 +13850,7 @@ "banner": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Banner", "type": "string" @@ -13419,7 +13858,7 @@ "capabilities": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -13429,7 +13868,7 @@ "component_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Component Ref", "type": "string" @@ -13437,16 +13876,25 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "listener_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Listener Id", + "pattern": "^[a-z0-9]", "type": "string" }, "protocol": { @@ -13456,7 +13904,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -13471,7 +13919,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -13482,7 +13930,7 @@ "service": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Service", "type": "string" @@ -13494,7 +13942,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -13505,7 +13953,7 @@ "tls_versions": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -13543,14 +13991,14 @@ "account_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Account Ref", "type": "string" }, "address": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Address", "type": "string" @@ -13563,7 +14011,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -13579,7 +14027,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -13590,7 +14038,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -13598,7 +14046,7 @@ "domain_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Domain Ref", "type": "string" @@ -13606,7 +14054,7 @@ "local_part": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Local Part", "type": "string" @@ -13614,16 +14062,25 @@ "local_user_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Local User Ref", "type": "string" }, "mailbox_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Mailbox Id", + "pattern": "^[a-z0-9]", "type": "string" }, "role": { @@ -13633,7 +14090,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -13648,7 +14105,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -13659,7 +14116,7 @@ "store_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Store Ref", "type": "string" @@ -13708,7 +14165,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -13720,7 +14177,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -13731,16 +14188,25 @@ "path": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Path", "type": "string" }, "store_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Store Id", + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -13790,7 +14256,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -13802,7 +14268,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -13817,7 +14283,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -13831,16 +14297,25 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" }, "queue_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Queue Id", + "pattern": "^[a-z0-9]", "type": "string" }, "stability": { @@ -13850,7 +14325,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -13918,7 +14393,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -13930,7 +14405,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -13941,22 +14416,31 @@ "relay_host": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Relay Host", "type": "string" }, "rule_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Rule Id", + "pattern": "^[a-z0-9]", "type": "string" }, "source_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Source Ref", "type": "string" @@ -13964,7 +14448,7 @@ "target_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Target Ref", "type": "string" @@ -13997,7 +14481,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -14012,7 +14496,7 @@ "engine": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Engine", "type": "string" @@ -14025,10 +14509,19 @@ "type": "array" }, "mail_service_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Mail Service Id", + "pattern": "^[a-z0-9]", "type": "string" }, "mailbox_stores": { @@ -14048,7 +14541,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -14070,7 +14563,7 @@ "service": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Service", "type": "string" @@ -14085,7 +14578,7 @@ "version": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Version", "type": "string" @@ -14104,7 +14597,7 @@ "component_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Component Ref", "type": "string" @@ -14112,14 +14605,14 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "name": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -14131,7 +14624,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -14140,16 +14633,25 @@ "title": "Provenance" }, "setting_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Setting Id", + "pattern": "^[a-z0-9]", "type": "string" }, "source_path": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Source Path", "type": "string" @@ -14157,7 +14659,7 @@ "value": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Value", "type": "string" @@ -14169,7 +14671,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -14295,7 +14797,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -14309,7 +14811,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -14317,7 +14819,7 @@ "filesystem_type": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Filesystem Type", "type": "string" @@ -14325,7 +14827,7 @@ "options": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -14339,7 +14841,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -14354,7 +14856,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -14369,7 +14871,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -14380,7 +14882,7 @@ "source": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Source", "type": "string" @@ -14392,7 +14894,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -14407,7 +14909,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -14422,7 +14924,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -14432,7 +14934,7 @@ }, "target": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Target", "type": "string" @@ -14479,7 +14981,7 @@ "cgroup": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Cgroup", "type": "string" @@ -14487,7 +14989,7 @@ "ipc": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Ipc", "type": "string" @@ -14495,7 +14997,7 @@ "pid": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Pid", "type": "string" @@ -14503,7 +15005,7 @@ "userns": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Userns", "type": "string" @@ -14511,7 +15013,7 @@ "uts": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Uts", "type": "string" @@ -14527,7 +15029,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -14539,7 +15041,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -14550,7 +15052,7 @@ "driver_options": { "additionalProperties": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -14560,7 +15062,7 @@ "ipam_driver": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Ipam Driver", "type": "string" @@ -14568,7 +15070,7 @@ "ipam_options": { "additionalProperties": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -14628,7 +15130,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -14647,7 +15149,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -14657,16 +15159,25 @@ "type": "array" }, "channel_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Channel Id", + "pattern": "^[a-z0-9]", "type": "string" }, "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -14678,7 +15189,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -14689,7 +15200,7 @@ "path": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Path", "type": "string" @@ -14697,7 +15208,7 @@ "service": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Service", "type": "string" @@ -14736,7 +15247,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -14748,7 +15259,7 @@ "configuration_file_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -14765,7 +15276,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -14777,7 +15288,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -14788,7 +15299,7 @@ "evidence_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -14802,7 +15313,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -14813,7 +15324,7 @@ "log_file_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -14823,16 +15334,25 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" }, "network_detection_engine_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Network Detection Engine Id", + "pattern": "^[a-z0-9]", "type": "string" }, "network_sets": { @@ -14852,7 +15372,7 @@ "process_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Process Ref", "type": "string" @@ -14860,7 +15380,7 @@ "revision": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Revision", "type": "string" @@ -14875,7 +15395,7 @@ "sensor_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Sensor Ref", "type": "string" @@ -14883,7 +15403,7 @@ "version": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Version", "type": "string" @@ -14954,7 +15474,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -14966,7 +15486,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -14977,7 +15497,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -14985,7 +15505,7 @@ "network_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -14995,7 +15515,7 @@ "selector_values": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -15003,10 +15523,19 @@ "type": "array" }, "set_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Set Id", + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -15054,7 +15583,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -15066,7 +15595,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -15085,7 +15614,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -15101,7 +15630,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -15112,16 +15641,25 @@ "path": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Path", "type": "string" }, "stream_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Stream Id", + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -15156,7 +15694,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -15164,7 +15702,7 @@ "file_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -15178,7 +15716,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -15189,7 +15727,7 @@ "generated_by": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Generated By", "type": "string" @@ -15201,7 +15739,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -15216,7 +15754,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -15230,7 +15768,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -15242,7 +15780,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -15254,10 +15792,19 @@ "title": "Rule Count" }, "source_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Source Id", + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -15305,7 +15852,7 @@ "aliases": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -15330,7 +15877,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -15344,7 +15891,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -15352,7 +15899,7 @@ "dns_names": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -15362,7 +15909,7 @@ "endpoint_id": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Endpoint Id", "type": "string" @@ -15374,7 +15921,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -15385,7 +15932,7 @@ "gateway": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Gateway", "type": "string" @@ -15393,7 +15940,7 @@ "generated_dns_names": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -15403,7 +15950,7 @@ "ip_address": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Ip Address", "type": "string" @@ -15415,7 +15962,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -15429,14 +15976,14 @@ "mac_address": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Mac Address", "type": "string" }, "network": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Network", "type": "string" @@ -15444,7 +15991,7 @@ "network_id": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Network Id", "type": "string" @@ -15456,7 +16003,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -15489,7 +16036,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -15497,7 +16044,7 @@ "domainname": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Domainname", "type": "string" @@ -15512,7 +16059,7 @@ "hostname": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Hostname", "type": "string" @@ -15535,7 +16082,7 @@ "capture_interfaces": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -15549,7 +16096,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -15560,7 +16107,7 @@ "configuration_file_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -15570,7 +16117,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -15578,7 +16125,7 @@ "evidence_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -15592,7 +16139,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -15603,7 +16150,7 @@ "log_file_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -15613,7 +16160,7 @@ "monitored_network_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -15627,7 +16174,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -15638,22 +16185,31 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" }, "network_sensor_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Network Sensor Id", + "pattern": "^[a-z0-9]", "type": "string" }, "process_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Process Ref", "type": "string" @@ -15661,7 +16217,7 @@ "revision": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Revision", "type": "string" @@ -15673,7 +16229,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -15684,7 +16240,7 @@ "version": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Version", "type": "string" @@ -15759,7 +16315,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -15782,7 +16338,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -15801,7 +16357,7 @@ "control_interface_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Control Interface Ref", "type": "string" @@ -15809,7 +16365,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -15821,7 +16377,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -15832,7 +16388,7 @@ "engine_api_version": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Engine Api Version", "type": "string" @@ -15851,16 +16407,25 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" }, "orchestration_authority_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Orchestration Authority Id", + "pattern": "^[a-z0-9]", "type": "string" }, "privilege_class": { @@ -15870,7 +16435,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -15931,7 +16496,7 @@ "cleanup": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Cleanup", "type": "string" @@ -15939,7 +16504,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -15947,7 +16512,7 @@ "execution_timeout": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Execution Timeout", "type": "string" @@ -15955,7 +16520,7 @@ "timeout": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Timeout", "type": "string" @@ -15986,7 +16551,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -16000,7 +16565,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -16008,7 +16573,7 @@ "evidence_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Evidence Ref", "type": "string" @@ -16016,16 +16581,25 @@ "image_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Image Ref", "type": "string" }, "workload_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Workload Id", + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -16042,7 +16616,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -16050,7 +16624,7 @@ "environment_name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Environment Name", "type": "string" @@ -16058,7 +16632,7 @@ "organization_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Organization Ref", "type": "string" @@ -16074,7 +16648,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -16082,7 +16656,7 @@ "image_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Image Ref", "type": "string" @@ -16090,16 +16664,25 @@ "purpose": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Purpose", "type": "string" }, "template_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Template Id", + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -16116,21 +16699,21 @@ "architecture": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Architecture", "type": "string" }, "manager": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Manager", "type": "string" }, "name": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -16138,7 +16721,7 @@ "purl": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Purl", "type": "string" @@ -16146,14 +16729,14 @@ "source": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Source", "type": "string" }, "version": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Version", "type": "string" @@ -16174,7 +16757,7 @@ "advisory_url": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Advisory Url", "type": "string" @@ -16182,49 +16765,49 @@ "fixed_version": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Fixed Version", "type": "string" }, "id": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Id", "type": "string" }, "image_digest": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Image Digest", "type": "string" }, "installed_version": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Installed Version", "type": "string" }, "package_name": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Package Name", "type": "string" }, "scan_time": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Scan Time", "type": "string" }, "scanner": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Scanner", "type": "string" @@ -16232,7 +16815,7 @@ "scanner_database": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Scanner Database", "type": "string" @@ -16240,7 +16823,7 @@ "scanner_version": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Scanner Version", "type": "string" @@ -16252,7 +16835,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -16292,7 +16875,7 @@ "authorization_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Authorization Ref", "type": "string" @@ -16314,7 +16897,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -16340,7 +16923,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -16353,10 +16936,19 @@ "type": "array" }, "platform_application_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Platform Application Id", + "pattern": "^[a-z0-9]", "type": "string" }, "platform_kind": { @@ -16366,7 +16958,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -16377,7 +16969,7 @@ "product": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Product", "type": "string" @@ -16385,7 +16977,7 @@ "service": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Service", "type": "string" @@ -16414,7 +17006,7 @@ "version": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Version", "type": "string" @@ -16431,10 +17023,19 @@ "description": "A connector/integration wired into the platform.\n\nA connector never carries a raw credential value; its credential posture is\nrecorded purely via :attr:`credential_classification`.", "properties": { "connector_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Connector Id", + "pattern": "^[a-z0-9]", "type": "string" }, "credential_classification": { @@ -16444,7 +17045,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -16455,7 +17056,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -16467,7 +17068,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -16485,7 +17086,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -16496,7 +17097,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -16532,16 +17133,25 @@ "type": "object" }, "content_object_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Content Object Id", + "pattern": "^[a-z0-9]", "type": "string" }, "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -16549,7 +17159,7 @@ "evidence_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -16563,7 +17173,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -16574,7 +17184,7 @@ "marking_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -16584,7 +17194,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -16592,7 +17202,7 @@ "references": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -16638,7 +17248,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -16646,7 +17256,7 @@ "job_timeout": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Job Timeout", "type": "string" @@ -16658,7 +17268,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -16672,7 +17282,7 @@ "policy_id": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Policy Id", "type": "string" @@ -16680,7 +17290,7 @@ "rate_limit": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Rate Limit", "type": "string" @@ -16688,7 +17298,7 @@ "runner": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Runner", "type": "string" @@ -16718,7 +17328,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -16726,16 +17336,25 @@ "level": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Level", "type": "string" }, "marking_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Marking Id", + "pattern": "^[a-z0-9]", "type": "string" }, "scheme": { @@ -16745,7 +17364,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -16756,7 +17375,7 @@ "value": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Value", "type": "string" @@ -16785,7 +17404,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -16793,16 +17412,25 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" }, "organization_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Organization Id", + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -16823,7 +17451,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -16834,7 +17462,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -16842,7 +17470,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -16854,7 +17482,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -16865,22 +17493,31 @@ "redaction": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Redaction", "type": "string" }, "setting_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Setting Id", + "pattern": "^[a-z0-9]", "type": "string" }, "value": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Value", "type": "string" @@ -16923,7 +17560,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -16931,16 +17568,25 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" }, "tenant_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Tenant Id", + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -16955,16 +17601,25 @@ "description": "An outbound binding to an upstream node/service (data source, backend).", "properties": { "binding_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Binding Id", + "pattern": "^[a-z0-9]", "type": "string" }, "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -16976,7 +17631,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -16987,7 +17642,7 @@ "target_node_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Target Node Ref", "type": "string" @@ -16995,7 +17650,7 @@ "target_service_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Target Service Ref", "type": "string" @@ -17028,7 +17683,7 @@ "add": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -17038,7 +17693,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -17046,7 +17701,7 @@ "drop": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -17056,7 +17711,7 @@ "effective": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -17070,7 +17725,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -17095,7 +17750,7 @@ "command": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -17109,7 +17764,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -17120,7 +17775,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -17128,7 +17783,7 @@ "group": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Group", "type": "string" @@ -17136,7 +17791,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -17148,7 +17803,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -17166,7 +17821,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -17184,7 +17839,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -17195,7 +17850,7 @@ "user": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "User", "type": "string" @@ -17203,7 +17858,7 @@ "working_directory": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Working Directory", "type": "string" @@ -17237,7 +17892,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -17247,7 +17902,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -17255,7 +17910,7 @@ "host_ip": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Host Ip", "type": "string" @@ -17267,7 +17922,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -17281,7 +17936,7 @@ "protocol": { "default": "tcp", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Protocol", "type": "string" @@ -17304,7 +17959,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -17314,7 +17969,7 @@ "host_ip": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Host Ip", "type": "string" @@ -17326,7 +17981,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -17340,7 +17995,7 @@ "protocol": { "default": "tcp", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Protocol", "type": "string" @@ -17363,7 +18018,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -17377,7 +18032,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -17389,7 +18044,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -17407,7 +18062,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -17425,7 +18080,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -17443,7 +18098,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -17478,7 +18133,7 @@ "command_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Command Ref", "type": "string" @@ -17486,7 +18141,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -17498,7 +18153,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -17512,7 +18167,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -17540,10 +18195,19 @@ "default": null }, "scheduled_job_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Scheduled Job Id", + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -17576,7 +18240,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -17587,7 +18251,7 @@ "last_run": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Last Run", "type": "string" @@ -17595,7 +18259,7 @@ "next_run": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Next Run", "type": "string" @@ -17615,7 +18279,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -17633,7 +18297,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -17644,7 +18308,7 @@ "spec": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Spec", "type": "string" @@ -17670,22 +18334,31 @@ "address": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Address", "type": "string" }, "agent_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Agent Id", + "pattern": "^[a-z0-9]", "type": "string" }, "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -17693,7 +18366,7 @@ "group_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -17702,7 +18375,7 @@ }, "name": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -17710,7 +18383,7 @@ "node_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Node Ref", "type": "string" @@ -17718,7 +18391,7 @@ "os": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Os", "type": "string" @@ -17730,7 +18403,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -17741,7 +18414,7 @@ "version": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Version", "type": "string" @@ -17761,7 +18434,7 @@ "configuration_file_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -17771,22 +18444,31 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "group_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Group Id", + "pattern": "^[a-z0-9]", "type": "string" }, "member_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -17796,7 +18478,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -17828,16 +18510,25 @@ "description": "A manager daemon, module, or internal component.", "properties": { "component_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Component Id", + "pattern": "^[a-z0-9]", "type": "string" }, "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -17849,7 +18540,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -17867,7 +18558,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -17877,7 +18568,7 @@ }, "name": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -17885,7 +18576,7 @@ "process_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Process Ref", "type": "string" @@ -17897,7 +18588,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -17993,16 +18684,25 @@ "description": "A manager-owned rule, decoder, policy, list, or query corpus.", "properties": { "content_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Content Id", + "pattern": "^[a-z0-9]", "type": "string" }, "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -18014,7 +18714,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18028,7 +18728,7 @@ "file_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18042,7 +18742,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -18057,7 +18757,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -18072,7 +18772,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18086,7 +18786,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -18105,7 +18805,7 @@ "canonical_digest": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Canonical Digest", "type": "string" @@ -18113,7 +18813,7 @@ "compliance_tags": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18123,7 +18823,7 @@ "content_set_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Content Set Ref", "type": "string" @@ -18131,7 +18831,7 @@ "decoded_as": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18141,7 +18841,7 @@ "decoder_fields": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18151,7 +18851,7 @@ "decoder_names": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18159,10 +18859,19 @@ "type": "array" }, "definition_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Definition Id", + "pattern": "^[a-z0-9]", "type": "string" }, "definition_kind": { @@ -18172,7 +18881,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -18183,7 +18892,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -18191,7 +18900,7 @@ "digest_algorithm": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Digest Algorithm", "type": "string" @@ -18203,7 +18912,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18221,7 +18930,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -18232,7 +18941,7 @@ "evidence_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18253,7 +18962,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18267,7 +18976,7 @@ "groups": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18277,7 +18986,7 @@ "if_matched_sid_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18287,7 +18996,7 @@ "if_sid_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18301,7 +19010,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18319,7 +19028,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18333,7 +19042,7 @@ "match_strings": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18343,7 +19052,7 @@ "mitre_attack_ids": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18353,7 +19062,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -18361,7 +19070,7 @@ "native_id": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Native Id", "type": "string" @@ -18369,7 +19078,7 @@ "parent_definition_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18383,7 +19092,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18397,7 +19106,7 @@ "regex_patterns": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18407,7 +19116,7 @@ "same_source_constraints": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18417,7 +19126,7 @@ "severity": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Severity", "type": "string" @@ -18425,7 +19134,7 @@ "source_artifact_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Source Artifact Ref", "type": "string" @@ -18437,7 +19146,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18451,7 +19160,7 @@ "source_file_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Source File Ref", "type": "string" @@ -18463,7 +19172,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18477,7 +19186,7 @@ "tactic_labels": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18487,7 +19196,7 @@ "tags": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18497,7 +19206,7 @@ "target_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18507,7 +19216,7 @@ "technique_labels": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18521,7 +19230,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18582,14 +19291,14 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "field": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Field", "type": "string" @@ -18601,7 +19310,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -18612,7 +19321,7 @@ "value": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Value", "type": "string" @@ -18667,7 +19376,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18681,22 +19390,31 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "listener_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Listener Id", + "pattern": "^[a-z0-9]", "type": "string" }, "protocol": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Protocol", "type": "string" @@ -18708,7 +19426,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -18719,7 +19437,7 @@ "service": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Service", "type": "string" @@ -18731,7 +19449,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18793,7 +19511,7 @@ "configuration_file_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18810,7 +19528,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -18825,7 +19543,7 @@ "evidence_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18839,7 +19557,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -18857,7 +19575,7 @@ "log_file_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -18871,7 +19589,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -18882,7 +19600,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -18890,22 +19608,31 @@ "revision": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Revision", "type": "string" }, "security_monitoring_manager_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Security Monitoring Manager Id", + "pattern": "^[a-z0-9]", "type": "string" }, "service": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Service", "type": "string" @@ -18920,7 +19647,7 @@ "version": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Version", "type": "string" @@ -18955,7 +19682,7 @@ "component_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Component Ref", "type": "string" @@ -18963,14 +19690,14 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "name": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -18982,7 +19709,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -18991,16 +19718,25 @@ "title": "Provenance" }, "setting_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Setting Id", + "pattern": "^[a-z0-9]", "type": "string" }, "source_path": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Source Path", "type": "string" @@ -19008,7 +19744,7 @@ "value": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Value", "type": "string" @@ -19020,7 +19756,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -19071,7 +19807,7 @@ "address": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Address", "type": "string" @@ -19083,7 +19819,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -19094,7 +19830,7 @@ "bind_interface": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Bind Interface", "type": "string" @@ -19102,7 +19838,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -19110,7 +19846,7 @@ "evidence_refs": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -19124,7 +19860,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -19138,7 +19874,7 @@ "process_name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Process Name", "type": "string" @@ -19146,7 +19882,7 @@ "process_ref": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Process Ref", "type": "string" @@ -19158,7 +19894,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -19173,7 +19909,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -19206,7 +19942,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -19217,22 +19953,31 @@ "service": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Service", "type": "string" }, "service_listener_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Service Listener Id", + "pattern": "^[a-z0-9]", "type": "string" }, "socket_path": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Socket Path", "type": "string" @@ -19250,7 +19995,7 @@ "properties": { "component_id": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Component Id", "type": "string" @@ -19262,7 +20007,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -19273,7 +20018,7 @@ "cpe": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Cpe", "type": "string" @@ -19281,7 +20026,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -19289,7 +20034,7 @@ "ecosystem": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Ecosystem", "type": "string" @@ -19304,7 +20049,7 @@ "installed_paths": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -19314,14 +20059,14 @@ "manifest_path": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Manifest Path", "type": "string" }, "name": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -19329,7 +20074,7 @@ "package_manager": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Package Manager", "type": "string" @@ -19337,7 +20082,7 @@ "package_name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Package Name", "type": "string" @@ -19345,7 +20090,7 @@ "package_version": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Package Version", "type": "string" @@ -19357,7 +20102,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -19368,7 +20113,7 @@ "purl": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Purl", "type": "string" @@ -19376,7 +20121,7 @@ "version": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Version", "type": "string" @@ -19395,14 +20140,14 @@ "properties": { "algorithm": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Algorithm", "type": "string" }, "value": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Value", "type": "string" @@ -19460,7 +20205,7 @@ "accept_env": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -19470,7 +20215,7 @@ "allow_groups": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -19480,7 +20225,7 @@ "allow_users": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -19490,7 +20235,7 @@ "authentication_methods": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -19500,7 +20245,7 @@ "authorized_keys_file": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Authorized Keys File", "type": "string" @@ -19508,7 +20253,7 @@ "chroot_directory": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Chroot Directory", "type": "string" @@ -19516,7 +20261,7 @@ "deny_groups": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -19526,7 +20271,7 @@ "deny_users": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -19536,7 +20281,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -19566,7 +20311,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -19584,7 +20329,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -19602,7 +20347,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -19615,16 +20360,25 @@ }, "service": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Service", "type": "string" }, "ssh_server_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Ssh Server Id", + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -19657,7 +20411,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -19668,7 +20422,7 @@ "commands": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -19678,7 +20432,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -19686,7 +20440,7 @@ "host_scope": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Host Scope", "type": "string" @@ -19698,7 +20452,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -19708,7 +20462,7 @@ }, "principal": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Principal", "type": "string" @@ -19720,7 +20474,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -19731,7 +20485,7 @@ "raw_entry": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Raw Entry", "type": "string" @@ -19739,7 +20493,7 @@ "run_as_groups": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -19749,7 +20503,7 @@ "run_as_users": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -19770,7 +20524,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -19782,7 +20536,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -19797,7 +20551,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -19810,7 +20564,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -19822,7 +20576,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -19836,7 +20590,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -19874,7 +20628,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -19885,7 +20639,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -19897,7 +20651,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -19923,7 +20677,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -19941,7 +20695,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -19956,7 +20710,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -19974,7 +20728,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -19989,7 +20743,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -20000,7 +20754,7 @@ "service": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Service", "type": "string" @@ -20008,7 +20762,7 @@ "status_text": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Status Text", "type": "string" @@ -20016,7 +20770,7 @@ "sub_state": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Sub State", "type": "string" @@ -20024,21 +20778,21 @@ "unit_file_path": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Unit File Path", "type": "string" }, "unit_id": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Unit Id", "type": "string" }, "unit_name": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Unit Name", "type": "string" @@ -20050,7 +20804,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -20073,18 +20827,35 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "name": { + "anyOf": [ + { + "const": "" + }, + { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + } + ], "default": "", - "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" - }, - "title": "Name", - "type": "string" + "title": "Name" }, "port": { "anyOf": [ @@ -20093,7 +20864,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -20103,7 +20874,7 @@ "protocol": { "default": "tcp", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Protocol", "type": "string" @@ -20155,7 +20926,7 @@ "command": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Command", "type": "string" @@ -20167,7 +20938,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -20182,7 +20953,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -20193,7 +20964,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -20272,14 +21043,14 @@ "properties": { "cidr": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Cidr", "type": "string" }, "gateway": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Gateway", "type": "string" @@ -20291,7 +21062,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -20324,7 +21095,7 @@ }, "name": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -20332,7 +21103,7 @@ "version": { "default": "*", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Version", "type": "string" @@ -20351,7 +21122,7 @@ "command": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Command", "type": "string" @@ -20363,7 +21134,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -20378,7 +21149,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -20389,7 +21160,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -20419,7 +21190,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -20428,7 +21199,7 @@ }, "pattern": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Pattern", "type": "string" @@ -20463,7 +21234,7 @@ "accept_env": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -20473,7 +21244,7 @@ "allow_groups": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -20483,7 +21254,7 @@ "allow_users": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -20493,7 +21264,7 @@ "authentication_methods": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -20503,7 +21274,7 @@ "authorized_keys_file": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Authorized Keys File", "type": "string" @@ -20511,7 +21282,7 @@ "chroot_directory": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Chroot Directory", "type": "string" @@ -20526,7 +21297,7 @@ "deny_groups": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -20536,7 +21307,7 @@ "deny_users": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -20546,7 +21317,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -20563,10 +21334,19 @@ "default": null }, "match_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, - "title": "Match Id", + "pattern": "^[a-z0-9]", "type": "string" }, "password_authentication": { @@ -20576,7 +21356,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -20594,7 +21374,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -20612,7 +21392,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -20637,7 +21417,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -20645,7 +21425,7 @@ "name": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -20653,7 +21433,7 @@ "scripts": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -20668,7 +21448,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -20701,7 +21481,7 @@ "anyOf": [ { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -20723,7 +21503,7 @@ "anyOf": [ { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -20746,7 +21526,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -20783,21 +21563,21 @@ "properties": { "class": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Class", "type": "string" }, "description": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "name": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Name", "type": "string" @@ -20809,7 +21589,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -20844,23 +21624,32 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "start": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Start", "type": "string" }, "steps": { - "additionalProperties": { - "$ref": "#/$defs/WorkflowStep" - }, "minProperties": 1, + "patternProperties": { + "^[a-z0-9]": { + "$ref": "#/$defs/WorkflowStep" + } + }, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + } + }, "title": "Steps", "type": "object" }, @@ -20923,7 +21712,7 @@ "order": { "default": "reverse_completion", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Order", "type": "string" @@ -20949,7 +21738,7 @@ "conditions": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -20959,7 +21748,7 @@ "objectives": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -20984,7 +21773,7 @@ "branches": { "items": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -21001,7 +21790,7 @@ "compensate_with": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Compensate With", "type": "string" @@ -21009,7 +21798,7 @@ "default": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Default", "type": "string" @@ -21017,7 +21806,7 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -21025,7 +21814,7 @@ "else": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Else", "type": "string" @@ -21033,7 +21822,7 @@ "join": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Join", "type": "string" @@ -21045,7 +21834,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -21059,7 +21848,7 @@ "next": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Next", "type": "string" @@ -21067,7 +21856,7 @@ "objective": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Objective", "type": "string" @@ -21075,7 +21864,7 @@ "on_exhausted": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "On Exhausted", "type": "string" @@ -21083,7 +21872,7 @@ "on_failure": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "On Failure", "type": "string" @@ -21091,7 +21880,7 @@ "on_success": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "On Success", "type": "string" @@ -21099,7 +21888,7 @@ "then": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Then", "type": "string" @@ -21121,7 +21910,7 @@ "workflow": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Workflow", "type": "string" @@ -21154,7 +21943,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" }, @@ -21175,7 +21964,7 @@ }, "step": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Step", "type": "string" @@ -21210,14 +21999,14 @@ "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" }, "next": { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Next", "type": "string" @@ -21244,7 +22033,7 @@ }, { "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "type": "string" } @@ -21268,6 +22057,15 @@ "additionalProperties": { "$ref": "#/$defs/Account" }, + "propertyNames": { + "maxLength": 2048, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:(?:[a-z0-9][a-z0-9_-]{0,63}|__private)\\.)*[a-z0-9][a-z0-9_-]{0,63}$", + "type": "string" + }, "title": "Accounts", "type": "object" }, @@ -21275,6 +22073,15 @@ "additionalProperties": { "$ref": "#/$defs/ParticipantActionContract" }, + "propertyNames": { + "maxLength": 2048, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:(?:[a-z0-9][a-z0-9_-]{0,63}|__private)\\.)*[a-z0-9][a-z0-9_-]{0,63}$", + "type": "string" + }, "title": "Action Contracts", "type": "object" }, @@ -21282,6 +22089,15 @@ "additionalProperties": { "$ref": "#/$defs/Agent" }, + "propertyNames": { + "maxLength": 2048, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:(?:[a-z0-9][a-z0-9_-]{0,63}|__private)\\.)*[a-z0-9][a-z0-9_-]{0,63}$", + "type": "string" + }, "title": "Agents", "type": "object" }, @@ -21289,6 +22105,15 @@ "additionalProperties": { "$ref": "#/$defs/ParticipantBehaviorSpecification" }, + "propertyNames": { + "maxLength": 2048, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:(?:[a-z0-9][a-z0-9_-]{0,63}|__private)\\.)*[a-z0-9][a-z0-9_-]{0,63}$", + "type": "string" + }, "title": "Behavior Specifications", "type": "object" }, @@ -21296,6 +22121,15 @@ "additionalProperties": { "$ref": "#/$defs/Condition" }, + "propertyNames": { + "maxLength": 2048, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:(?:[a-z0-9][a-z0-9_-]{0,63}|__private)\\.)*[a-z0-9][a-z0-9_-]{0,63}$", + "type": "string" + }, "title": "Conditions", "type": "object" }, @@ -21303,13 +22137,22 @@ "additionalProperties": { "$ref": "#/$defs/Content" }, + "propertyNames": { + "maxLength": 2048, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:(?:[a-z0-9][a-z0-9_-]{0,63}|__private)\\.)*[a-z0-9][a-z0-9_-]{0,63}$", + "type": "string" + }, "title": "Content", "type": "object" }, "description": { "default": "", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Description", "type": "string" @@ -21318,6 +22161,15 @@ "additionalProperties": { "$ref": "#/$defs/Entity" }, + "propertyNames": { + "maxLength": 2048, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:(?:[a-z0-9][a-z0-9_-]{0,63}|__private)\\.)*[a-z0-9][a-z0-9_-]{0,63}$", + "type": "string" + }, "title": "Entities", "type": "object" }, @@ -21325,6 +22177,15 @@ "additionalProperties": { "$ref": "#/$defs/Event" }, + "propertyNames": { + "maxLength": 2048, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:(?:[a-z0-9][a-z0-9_-]{0,63}|__private)\\.)*[a-z0-9][a-z0-9_-]{0,63}$", + "type": "string" + }, "title": "Events", "type": "object" }, @@ -21332,6 +22193,15 @@ "additionalProperties": { "$ref": "#/$defs/EvidenceRequirement" }, + "propertyNames": { + "maxLength": 2048, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:(?:[a-z0-9][a-z0-9_-]{0,63}|__private)\\.)*[a-z0-9][a-z0-9_-]{0,63}$", + "type": "string" + }, "title": "Evidence Requirements", "type": "object" }, @@ -21339,12 +22209,46 @@ "additionalProperties": { "$ref": "#/$defs/Feature" }, + "propertyNames": { + "maxLength": 2048, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:(?:[a-z0-9][a-z0-9_-]{0,63}|__private)\\.)*[a-z0-9][a-z0-9_-]{0,63}$", + "type": "string" + }, "title": "Features", "type": "object" }, "forwarding_agents": { "items": { - "$ref": "#/$defs/RuntimeForwardingAgent" + "allOf": [ + { + "$ref": "#/$defs/RuntimeForwardingAgent" + }, + { + "properties": { + "forwarding_agent_id": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 2048, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:(?:[a-z0-9][a-z0-9_-]{0,63}|__private)\\.)*[a-z0-9][a-z0-9_-]{0,63}$", + "type": "string" + } + }, + "type": "object" + } + ] }, "title": "Forwarding Agents", "type": "array" @@ -21360,6 +22264,15 @@ "additionalProperties": { "$ref": "#/$defs/InfraNode" }, + "propertyNames": { + "maxLength": 2048, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:(?:[a-z0-9][a-z0-9_-]{0,63}|__private)\\.)*[a-z0-9][a-z0-9_-]{0,63}$", + "type": "string" + }, "title": "Infrastructure", "type": "object" }, @@ -21367,6 +22280,15 @@ "additionalProperties": { "$ref": "#/$defs/Inject" }, + "propertyNames": { + "maxLength": 2048, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:(?:[a-z0-9][a-z0-9_-]{0,63}|__private)\\.)*[a-z0-9][a-z0-9_-]{0,63}$", + "type": "string" + }, "title": "Injects", "type": "object" }, @@ -21382,9 +22304,19 @@ "default": null }, "name": { + "allOf": [ + { + "not": { + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" + } + } + ], + "maxLength": 64, + "minLength": 1, "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "[^a-z0-9_-]" }, + "pattern": "^[a-z0-9]", "title": "Name", "type": "string" }, @@ -21392,6 +22324,15 @@ "additionalProperties": { "$ref": "#/$defs/Node" }, + "propertyNames": { + "maxLength": 2048, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:(?:[a-z0-9][a-z0-9_-]{0,63}|__private)\\.)*[a-z0-9][a-z0-9_-]{0,34}$", + "type": "string" + }, "title": "Nodes", "type": "object" }, @@ -21399,6 +22340,15 @@ "additionalProperties": { "$ref": "#/$defs/Objective" }, + "propertyNames": { + "maxLength": 2048, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:(?:[a-z0-9][a-z0-9_-]{0,63}|__private)\\.)*[a-z0-9][a-z0-9_-]{0,63}$", + "type": "string" + }, "title": "Objectives", "type": "object" }, @@ -21406,6 +22356,15 @@ "additionalProperties": { "$ref": "#/$defs/ParticipantObservationBoundary" }, + "propertyNames": { + "maxLength": 2048, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:(?:[a-z0-9][a-z0-9_-]{0,63}|__private)\\.)*[a-z0-9][a-z0-9_-]{0,63}$", + "type": "string" + }, "title": "Observation Boundaries", "type": "object" }, @@ -21413,6 +22372,15 @@ "additionalProperties": { "$ref": "#/$defs/OutcomeInterpretationRule" }, + "propertyNames": { + "maxLength": 2048, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:(?:[a-z0-9][a-z0-9_-]{0,63}|__private)\\.)*[a-z0-9][a-z0-9_-]{0,63}$", + "type": "string" + }, "title": "Outcome Interpretation Rules", "type": "object" }, @@ -21420,6 +22388,15 @@ "additionalProperties": { "$ref": "#/$defs/Relationship" }, + "propertyNames": { + "maxLength": 2048, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:(?:[a-z0-9][a-z0-9_-]{0,63}|__private)\\.)*[a-z0-9][a-z0-9_-]{0,63}$", + "type": "string" + }, "title": "Relationships", "type": "object" }, @@ -21427,6 +22404,15 @@ "additionalProperties": { "$ref": "#/$defs/Script" }, + "propertyNames": { + "maxLength": 2048, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:(?:[a-z0-9][a-z0-9_-]{0,63}|__private)\\.)*[a-z0-9][a-z0-9_-]{0,63}$", + "type": "string" + }, "title": "Scripts", "type": "object" }, @@ -21434,23 +22420,41 @@ "additionalProperties": { "$ref": "#/$defs/Story" }, + "propertyNames": { + "maxLength": 2048, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:(?:[a-z0-9][a-z0-9_-]{0,63}|__private)\\.)*[a-z0-9][a-z0-9_-]{0,63}$", + "type": "string" + }, "title": "Stories", "type": "object" }, "variables": { "additionalProperties": false, "patternProperties": { - "^[A-Za-z_][A-Za-z0-9_-]*$": { + "^[a-z0-9]": { "$ref": "#/$defs/Variable" } }, + "propertyNames": { + "maxLength": 2048, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:(?:[a-z0-9][a-z0-9_-]{0,63}|__private)\\.)*[a-z0-9][a-z0-9_-]{0,63}$", + "type": "string" + }, "title": "Variables", "type": "object" }, "version": { "default": "*", "not": { - "pattern": "\\$\\{([A-Za-z_][A-Za-z0-9_-]*)\\}" + "pattern": "\\$\\{([a-z0-9][a-z0-9_-]{0,63})\\}" }, "title": "Version", "type": "string" @@ -21459,6 +22463,15 @@ "additionalProperties": { "$ref": "#/$defs/Vulnerability" }, + "propertyNames": { + "maxLength": 2048, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:(?:[a-z0-9][a-z0-9_-]{0,63}|__private)\\.)*[a-z0-9][a-z0-9_-]{0,63}$", + "type": "string" + }, "title": "Vulnerabilities", "type": "object" }, @@ -21466,6 +22479,15 @@ "additionalProperties": { "$ref": "#/$defs/Workflow" }, + "propertyNames": { + "maxLength": 2048, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:(?:[a-z0-9][a-z0-9_-]{0,63}|__private)\\.)*[a-z0-9][a-z0-9_-]{0,63}$", + "type": "string" + }, "title": "Workflows", "type": "object" } diff --git a/contracts/schemas/sdl/scenario-instantiation-request-v1.json b/contracts/schemas/sdl/scenario-instantiation-request-v1.json index 34d487bc6..89e7bd017 100644 --- a/contracts/schemas/sdl/scenario-instantiation-request-v1.json +++ b/contracts/schemas/sdl/scenario-instantiation-request-v1.json @@ -5,6 +5,15 @@ "properties": { "parameters": { "additionalProperties": true, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + }, "title": "Parameters", "type": "object" }, diff --git a/contracts/schemas/sdl/sdl-authoring-input-v1.json b/contracts/schemas/sdl/sdl-authoring-input-v1.json index b5d7c2629..8d0c6cd14 100644 --- a/contracts/schemas/sdl/sdl-authoring-input-v1.json +++ b/contracts/schemas/sdl/sdl-authoring-input-v1.json @@ -41,9 +41,22 @@ "type": "string" }, "name": { + "anyOf": [ + { + "const": "" + }, + { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + } + ], "default": "", - "title": "Name", - "type": "string" + "title": "Name" }, "ports": { "items": { @@ -609,7 +622,18 @@ "title": "Description", "type": "string" }, + "display_name": { + "default": "", + "title": "Display Name", + "type": "string" + }, "name": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "title": "Name", "type": "string" }, @@ -642,7 +666,12 @@ "description": "An observed logical database within a database service.", "properties": { "database_id": { - "title": "Database Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "description": { @@ -1473,7 +1502,12 @@ "type": "array" }, "rrset_id": { - "title": "Rrset Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "ttl": { @@ -1839,7 +1873,12 @@ "type": "array" }, "zone_id": { - "title": "Zone Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -2032,8 +2071,17 @@ "type": "string" }, "entities": { - "additionalProperties": { - "$ref": "#/$defs/Entity" + "patternProperties": { + "^[a-z0-9]": { + "$ref": "#/$defs/Entity" + } + }, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + } }, "title": "Entities", "type": "object" @@ -2864,7 +2912,16 @@ "type": "string" }, "parameters": { - "additionalProperties": true, + "patternProperties": { + "^[a-z0-9]": {} + }, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + } + }, "title": "Parameters", "type": "object" }, @@ -3063,6 +3120,12 @@ }, "parameters": { "items": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "title": "Parameters", @@ -3153,8 +3216,17 @@ "default": null }, "roles": { - "additionalProperties": { - "$ref": "#/$defs/Role" + "patternProperties": { + "^[a-z0-9]": { + "$ref": "#/$defs/Role" + } + }, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + } }, "title": "Roles", "type": "object" @@ -5121,7 +5193,12 @@ "description": "Application-internal RBAC store inventory for a single owning spine.", "properties": { "app_authorization_id": { - "title": "App Authorization Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "auth_enabled": { @@ -5242,7 +5319,12 @@ "title": "Effect" }, "grant_id": { - "title": "Grant Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "resource_kind": { @@ -5346,7 +5428,12 @@ "type": "string" }, "principal_id": { - "title": "Principal Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "reserved": { @@ -5412,7 +5499,12 @@ "type": "string" }, "role_id": { - "title": "Role Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -5446,7 +5538,12 @@ "type": "array" }, "mapping_id": { - "title": "Mapping Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "role_ref": { @@ -5483,7 +5580,12 @@ "type": "string" }, "tenant_id": { - "title": "Tenant Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -5931,7 +6033,12 @@ "description": "An observed application surface hosted by a transport service on a node.\n\n``service`` references the owning same-node ``Node.services[].name`` (bare\nname or the qualified ``nodes..services.`` form). The surface is\nobservation metadata; it never mutates ``Node.services``.", "properties": { "application_id": { - "title": "Application Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "base_path": { @@ -6122,7 +6229,25 @@ }, "forwarding_agents": { "items": { - "$ref": "#/$defs/RuntimeForwardingAgent" + "allOf": [ + { + "$ref": "#/$defs/RuntimeForwardingAgent" + }, + { + "properties": { + "forwarding_agent_id": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + } + }, + "type": "object" + } + ] }, "title": "Forwarding Agents", "type": "array" @@ -6661,7 +6786,12 @@ "description": "An observed database service hosted by a transport service on a node.\n\n``service`` references the owning same-node ``Node.services[].name`` (bare\nname or the qualified ``nodes..services.`` form). The inventory\nis observation metadata; it never mutates ``Node.services``.", "properties": { "database_service_id": { - "title": "Database Service Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "databases": { @@ -6919,7 +7049,12 @@ "type": "string" }, "plugin_id": { - "title": "Plugin Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "version": { @@ -7032,7 +7167,12 @@ "title": "Leaf Field Count" }, "mapping_id": { - "title": "Mapping Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "name": { @@ -7169,7 +7309,12 @@ "type": "string" }, "node_id": { - "title": "Node Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "plugins": { @@ -7215,7 +7360,12 @@ "type": "string" }, "endpoint_id": { - "title": "Endpoint Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "port": { @@ -7385,7 +7535,12 @@ "type": "string" }, "partition_id": { - "title": "Partition Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "per_dc_factor_map": { @@ -7616,7 +7771,12 @@ "title": "Data Model" }, "datastore_service_id": { - "title": "Datastore Service Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "description": { @@ -7799,7 +7959,12 @@ "title": "Scope" }, "setting_id": { - "title": "Setting Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "value": { @@ -7895,7 +8060,12 @@ "type": "string" }, "template_id": { - "title": "Template Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -8059,7 +8229,12 @@ "type": "string" }, "dns_service_id": { - "title": "Dns Service Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "dynamic_update": { @@ -8288,7 +8463,12 @@ "type": "string" }, "file_service_id": { - "title": "File Service Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "principals": { @@ -8407,7 +8587,12 @@ "type": "string" }, "observation_id": { - "title": "Observation Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "outcome": { @@ -8514,7 +8699,12 @@ "type": "string" }, "rule_id": { - "title": "Rule Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "subject_ref": { @@ -8609,7 +8799,12 @@ "title": "Origin" }, "principal_id": { - "title": "Principal Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "status": { @@ -8780,7 +8975,12 @@ "title": "Read Only" }, "share_id": { - "title": "Share Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "valid_groups": { @@ -9301,7 +9501,12 @@ "title": "Kind" }, "reload_channel_id": { - "title": "Reload Channel Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "target_ref": { @@ -9369,7 +9574,12 @@ "title": "Provenance" }, "setting_id": { - "title": "Setting Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "value": { @@ -9471,7 +9681,12 @@ "title": "Protocol" }, "target_id": { - "title": "Target Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "target_node_ref": { @@ -9535,7 +9750,12 @@ "type": "string" }, "source_id": { - "title": "Source Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -9584,7 +9804,12 @@ "type": "string" }, "transform_id": { - "title": "Transform Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -9799,7 +10024,12 @@ "type": "string" }, "identity_authority_id": { - "title": "Identity Authority Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "issuer": { @@ -9954,7 +10184,12 @@ "type": "string" }, "service_id": { - "title": "Service Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -9986,7 +10221,12 @@ "type": "string" }, "policy_id": { - "title": "Policy Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "policy_kind": { @@ -10076,7 +10316,12 @@ "type": "string" }, "relationship_id": { - "title": "Relationship Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "relationship_type": { @@ -10213,7 +10458,12 @@ "type": "array" }, "subject_id": { - "title": "Subject Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -10638,7 +10888,12 @@ "type": "string" }, "alias_id": { - "title": "Alias Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "description": { @@ -10696,7 +10951,12 @@ "description": "A mail-service engine/component such as Postfix, Dovecot, or a filter.", "properties": { "component_id": { - "title": "Component Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "description": { @@ -10778,7 +11038,12 @@ "type": "string" }, "domain_id": { - "title": "Domain Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "name": { @@ -10866,7 +11131,12 @@ "type": "string" }, "listener_id": { - "title": "Listener Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "protocol": { @@ -11001,7 +11271,12 @@ "type": "string" }, "mailbox_id": { - "title": "Mailbox Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "role": { @@ -11097,7 +11372,12 @@ "type": "string" }, "store_id": { - "title": "Store Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -11182,7 +11462,12 @@ "type": "string" }, "queue_id": { - "title": "Queue Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "stability": { @@ -11277,7 +11562,12 @@ "type": "string" }, "rule_id": { - "title": "Rule Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "source_ref": { @@ -11340,7 +11630,12 @@ "type": "array" }, "mail_service_id": { - "title": "Mail Service Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "mailbox_stores": { @@ -11431,7 +11726,12 @@ "title": "Provenance" }, "setting_id": { - "title": "Setting Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "source_path": { @@ -11864,7 +12164,12 @@ "type": "array" }, "channel_id": { - "title": "Channel Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "description": { @@ -11997,7 +12302,12 @@ "type": "string" }, "network_detection_engine_id": { - "title": "Network Detection Engine Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "network_sets": { @@ -12141,7 +12451,12 @@ "type": "array" }, "set_id": { - "title": "Set Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -12238,7 +12553,12 @@ "type": "string" }, "stream_id": { - "title": "Stream Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -12347,7 +12667,12 @@ "title": "Rule Count" }, "source_id": { - "title": "Source Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -12653,7 +12978,12 @@ "type": "string" }, "network_sensor_id": { - "title": "Network Sensor Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "process_ref": { @@ -12830,7 +13160,12 @@ "type": "string" }, "orchestration_authority_id": { - "title": "Orchestration Authority Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "privilege_class": { @@ -12965,7 +13300,12 @@ "type": "string" }, "workload_id": { - "title": "Workload Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -13018,7 +13358,12 @@ "type": "string" }, "template_id": { - "title": "Template Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -13212,7 +13557,12 @@ "type": "array" }, "platform_application_id": { - "title": "Platform Application Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "platform_kind": { @@ -13275,7 +13625,12 @@ "description": "A connector/integration wired into the platform.\n\nA connector never carries a raw credential value; its credential posture is\nrecorded purely via :attr:`credential_classification`.", "properties": { "connector_id": { - "title": "Connector Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "credential_classification": { @@ -13358,7 +13713,12 @@ "type": "object" }, "content_object_id": { - "title": "Content Object Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "description": { @@ -13513,7 +13873,12 @@ "type": "string" }, "marking_id": { - "title": "Marking Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "scheme": { @@ -13565,7 +13930,12 @@ "type": "string" }, "organization_id": { - "title": "Organization Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -13619,7 +13989,12 @@ "type": "string" }, "setting_id": { - "title": "Setting Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "value": { @@ -13673,7 +14048,12 @@ "type": "string" }, "tenant_id": { - "title": "Tenant Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -13688,7 +14068,12 @@ "description": "An outbound binding to an upstream node/service (data source, backend).", "properties": { "binding_id": { - "title": "Binding Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "description": { @@ -14156,7 +14541,12 @@ "default": null }, "scheduled_job_id": { - "title": "Scheduled Job Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -14268,7 +14658,12 @@ "type": "string" }, "agent_id": { - "title": "Agent Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "description": { @@ -14339,7 +14734,12 @@ "type": "string" }, "group_id": { - "title": "Group Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "member_refs": { @@ -14381,7 +14781,12 @@ "description": "A manager daemon, module, or internal component.", "properties": { "component_id": { - "title": "Component Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "description": { @@ -14525,7 +14930,12 @@ "description": "A manager-owned rule, decoder, policy, list, or query corpus.", "properties": { "content_id": { - "title": "Content Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "description": { @@ -14649,7 +15059,12 @@ "type": "array" }, "definition_id": { - "title": "Definition Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "definition_kind": { @@ -15066,7 +15481,12 @@ "type": "string" }, "listener_id": { - "title": "Listener Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "protocol": { @@ -15236,7 +15656,12 @@ "type": "string" }, "security_monitoring_manager_id": { - "title": "Security Monitoring Manager Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "service": { @@ -15310,7 +15735,12 @@ "title": "Provenance" }, "setting_id": { - "title": "Setting Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "source_path": { @@ -15494,7 +15924,12 @@ "type": "string" }, "service_listener_id": { - "title": "Service Listener Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "socket_path": { @@ -15799,7 +16234,12 @@ "type": "string" }, "ssh_server_id": { - "title": "Ssh Server Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" } }, @@ -16158,9 +16598,22 @@ "type": "string" }, "name": { + "anyOf": [ + { + "const": "" + }, + { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + } + ], "default": "", - "title": "Name", - "type": "string" + "title": "Name" }, "port": { "anyOf": [ @@ -16561,7 +17014,12 @@ "default": null }, "match_id": { - "title": "Match Id", + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "type": "string" }, "password_authentication": { @@ -16804,10 +17262,19 @@ "type": "string" }, "steps": { - "additionalProperties": { - "$ref": "#/$defs/WorkflowStep" - }, "minProperties": 1, + "patternProperties": { + "^[a-z0-9]": { + "$ref": "#/$defs/WorkflowStep" + } + }, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + } + }, "title": "Steps", "type": "object" }, @@ -17149,6 +17616,15 @@ "additionalProperties": { "$ref": "#/$defs/Account" }, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + }, "title": "Accounts", "type": "object" }, @@ -17156,6 +17632,15 @@ "additionalProperties": { "$ref": "#/$defs/ParticipantActionContract" }, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + }, "title": "Action Contracts", "type": "object" }, @@ -17163,6 +17648,15 @@ "additionalProperties": { "$ref": "#/$defs/Agent" }, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + }, "title": "Agents", "type": "object" }, @@ -17170,6 +17664,15 @@ "additionalProperties": { "$ref": "#/$defs/ParticipantBehaviorSpecification" }, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + }, "title": "Behavior Specifications", "type": "object" }, @@ -17177,6 +17680,15 @@ "additionalProperties": { "$ref": "#/$defs/Condition" }, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + }, "title": "Conditions", "type": "object" }, @@ -17184,6 +17696,15 @@ "additionalProperties": { "$ref": "#/$defs/Content" }, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + }, "title": "Content", "type": "object" }, @@ -17196,6 +17717,15 @@ "additionalProperties": { "$ref": "#/$defs/Entity" }, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + }, "title": "Entities", "type": "object" }, @@ -17203,6 +17733,15 @@ "additionalProperties": { "$ref": "#/$defs/Event" }, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + }, "title": "Events", "type": "object" }, @@ -17210,6 +17749,15 @@ "additionalProperties": { "$ref": "#/$defs/EvidenceRequirement" }, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + }, "title": "Evidence Requirements", "type": "object" }, @@ -17217,12 +17765,39 @@ "additionalProperties": { "$ref": "#/$defs/Feature" }, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + }, "title": "Features", "type": "object" }, "forwarding_agents": { "items": { - "$ref": "#/$defs/RuntimeForwardingAgent" + "allOf": [ + { + "$ref": "#/$defs/RuntimeForwardingAgent" + }, + { + "properties": { + "forwarding_agent_id": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + } + }, + "type": "object" + } + ] }, "title": "Forwarding Agents", "type": "array" @@ -17238,6 +17813,15 @@ "additionalProperties": { "$ref": "#/$defs/InfraNode" }, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + }, "title": "Infrastructure", "type": "object" }, @@ -17245,6 +17829,15 @@ "additionalProperties": { "$ref": "#/$defs/Inject" }, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + }, "title": "Injects", "type": "object" }, @@ -17260,6 +17853,12 @@ "default": null }, "name": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", "title": "Name", "type": "string" }, @@ -17267,6 +17866,15 @@ "additionalProperties": { "$ref": "#/$defs/Node" }, + "propertyNames": { + "maxLength": 35, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + }, "title": "Nodes", "type": "object" }, @@ -17274,6 +17882,15 @@ "additionalProperties": { "$ref": "#/$defs/Objective" }, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + }, "title": "Objectives", "type": "object" }, @@ -17281,6 +17898,15 @@ "additionalProperties": { "$ref": "#/$defs/ParticipantObservationBoundary" }, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + }, "title": "Observation Boundaries", "type": "object" }, @@ -17288,6 +17914,15 @@ "additionalProperties": { "$ref": "#/$defs/OutcomeInterpretationRule" }, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + }, "title": "Outcome Interpretation Rules", "type": "object" }, @@ -17295,6 +17930,15 @@ "additionalProperties": { "$ref": "#/$defs/Relationship" }, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + }, "title": "Relationships", "type": "object" }, @@ -17302,6 +17946,15 @@ "additionalProperties": { "$ref": "#/$defs/Script" }, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + }, "title": "Scripts", "type": "object" }, @@ -17309,16 +17962,34 @@ "additionalProperties": { "$ref": "#/$defs/Story" }, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + }, "title": "Stories", "type": "object" }, "variables": { "additionalProperties": false, "patternProperties": { - "^[A-Za-z_][A-Za-z0-9_-]*$": { + "^[a-z0-9]": { "$ref": "#/$defs/Variable" } }, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + }, "title": "Variables", "type": "object" }, @@ -17331,6 +18002,15 @@ "additionalProperties": { "$ref": "#/$defs/Vulnerability" }, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + }, "title": "Vulnerabilities", "type": "object" }, @@ -17338,6 +18018,15 @@ "additionalProperties": { "$ref": "#/$defs/Workflow" }, + "propertyNames": { + "maxLength": 64, + "minLength": 1, + "not": { + "pattern": "[^a-z0-9_-]" + }, + "pattern": "^[a-z0-9]", + "type": "string" + }, "title": "Workflows", "type": "object" } diff --git a/contracts/schemas/snapshots/runtime-snapshot-v1.json b/contracts/schemas/snapshots/runtime-snapshot-v1.json index b76ad83ed..3b3e89104 100644 --- a/contracts/schemas/snapshots/runtime-snapshot-v1.json +++ b/contracts/schemas/snapshots/runtime-snapshot-v1.json @@ -3529,6 +3529,12 @@ "additionalProperties": false, "properties": { "address": { + "maxLength": 2048, + "minLength": 3, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:[a-z0-9][a-z0-9_-]{0,63}|__private)(?:\\.(?:[a-z0-9][a-z0-9_-]{0,63}|__private))+$", "title": "Address", "type": "string" }, @@ -3538,6 +3544,12 @@ }, "ordering_dependencies": { "items": { + "maxLength": 2048, + "minLength": 3, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:[a-z0-9][a-z0-9_-]{0,63}|__private)(?:\\.(?:[a-z0-9][a-z0-9_-]{0,63}|__private))+$", "type": "string" }, "title": "Ordering Dependencies", @@ -3550,6 +3562,12 @@ }, "refresh_dependencies": { "items": { + "maxLength": 2048, + "minLength": 3, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:[a-z0-9][a-z0-9_-]{0,63}|__private)(?:\\.(?:[a-z0-9][a-z0-9_-]{0,63}|__private))+$", "type": "string" }, "title": "Refresh Dependencies", @@ -3986,8 +4004,20 @@ "description": "Published envelope for a live runtime snapshot.\n\nParticipant episode surfaces (``participant_episode_results`` and\n``participant_episode_history``) are both keyed by the stable\n``participant_address`` of the participant the state/history belongs\nto. SEM-208 participant behavior history is keyed the same way and\nrecords action, observation, and state-transition events with compiled\nbehavior-contract addresses. The episode results map carries the\ncurrently-live episode state per participant; prior episodes survive only\nthrough append-only history streams and the ``previous_episode_id`` chain\non each state.", "properties": { "entries": { - "additionalProperties": { - "$ref": "#/$defs/SnapshotEntryModel" + "additionalProperties": false, + "patternProperties": { + "^(?:[a-z0-9][a-z0-9_-]{0,63}|__private)(?:\\.(?:[a-z0-9][a-z0-9_-]{0,63}|__private))+$": { + "$ref": "#/$defs/SnapshotEntryModel" + } + }, + "propertyNames": { + "maxLength": 2048, + "minLength": 3, + "not": { + "pattern": "[^a-z0-9_.-]" + }, + "pattern": "^(?:[a-z0-9][a-z0-9_-]{0,63}|__private)(?:\\.(?:[a-z0-9][a-z0-9_-]{0,63}|__private))+$", + "type": "string" }, "title": "Entries", "type": "object" diff --git a/docs/decisions/adrs/README.md b/docs/decisions/adrs/README.md index 6865aa6bd..d4d4f55c8 100644 --- a/docs/decisions/adrs/README.md +++ b/docs/decisions/adrs/README.md @@ -120,6 +120,7 @@ adr-072-validation-and-admission-profiles adr-073-scoring-reward-language-scope adr-074-experiment-authoring-input-contract-boundary adr-075-ecosystem-versioning-deprecation-and-migration-governance +adr-076-portable-sdl-identifiers-and-canonical-addresses ``` | ADR | Title | Status | Date | @@ -200,3 +201,4 @@ adr-075-ecosystem-versioning-deprecation-and-migration-governance | [073](adr-073-scoring-reward-language-scope.md) | Scoring and Reward Language Scope in the SDL | accepted | 2026-07-05 | | [074](adr-074-experiment-authoring-input-contract-boundary.md) | Experiment Authoring-Input Contract Boundary | accepted | 2026-07-08 | | [075](adr-075-ecosystem-versioning-deprecation-and-migration-governance.md) | Ecosystem Versioning, Deprecation, and Migration Governance | proposed | 2026-07-11 | +| [076](adr-076-portable-sdl-identifiers-and-canonical-addresses.md) | Portable SDL Identifiers and Canonical Addresses | accepted | 2026-07-11 | diff --git a/docs/decisions/adrs/adr-076-portable-sdl-identifiers-and-canonical-addresses.md b/docs/decisions/adrs/adr-076-portable-sdl-identifiers-and-canonical-addresses.md new file mode 100644 index 000000000..963805fbe --- /dev/null +++ b/docs/decisions/adrs/adr-076-portable-sdl-identifiers-and-canonical-addresses.md @@ -0,0 +1,646 @@ +# ADR-076: Portable SDL Identifiers and Canonical Addresses + +## Status + +accepted + +## Date + +2026-07-11 + +## Classification + +Classification: FM2 +Required artifacts: ADR, normative SDL specification, published schema updates, +source-ranged migration diagnostics, cross-stage identity tests, and targeted +property/differential tests +Waivers: No new runtime service, persistence layer, authentication mechanism, +or general-purpose identifier registry is introduced. + +## Context + +SDL references already fail closed when a bare name is ambiguous, module +composition rewrites imported symbols into namespaces, and the processor emits +canonical runtime addresses. The lexical inputs to those mechanisms are not yet +coherent. + +Ordinary section keys may be empty or contain whitespace, delimiters, mixed +case, or arbitrary Unicode. Dots currently mean several different things: +section qualification, a nested entity path, the workflow/step boundary, an +authored node name, and a generated module namespace. Runtime-family ids use +the shared `require_symbol` helper, but that helper only rejects empty values +and variable placeholders. Compiler addresses then join unchecked identity +parts with dots. + +This creates two classes of failure. First, a valid declaration may be +impossible to name portably. Second, two different declaration paths can render +as the same string, especially after nested module composition. Existing +indexes often collect strings into sets or dictionaries; if two declarations +already rendered to the same string, those containers erase the evidence of +the collision. + +The repository already has the right cross-cutting owners: the SDL section and +reference catalogs, the source-marked YAML loader, published SDL schemas, +`SemanticValidator`'s declaration index, the module symbol/composition layer, +the runtime-family registry, the objective-window semantic IR, the processor +compiler, and the language-service diagnostic envelope. The identifier contract +must strengthen those owners rather than create parallel schemas, reference +resolvers, exception types, or workflow-specific naming rules. + +## Decision + +### 1. Define one portable local-identifier grammar + +A symbol-defining local identifier is an ASCII string matching: + +```text +portable-id = id-start *63id-char +id-start = %x61-7A / DIGIT +id-char = id-start / "-" / "_" +``` + +Equivalently, it is a full-string match for +`^[a-z0-9][a-z0-9_-]{0,63}$`. Implementations must use full-match semantics; +they must not rely on a regex engine's `$` anchor alone, because some engines +also match `$` immediately before a trailing line terminator. The JSON Schema +form combines `minLength`/`maxLength`, a valid first-character constraint, and +rejection of every character outside `[a-z0-9_-]` so the same contract rejects +trailing newlines, NULs, and other controls without depending on engine-specific +anchor behavior. + +The spelling is exact. SDL does not trim, lowercase, case-fold, Unicode-normalize, +escape, or otherwise repair an identifier. Uppercase, non-ASCII, whitespace, +control characters, `.`, `/`, `:`, and variable placeholders are invalid in a +local identifier. The existing 35-character node-id limit remains a stricter +node-specific constraint on the authored local node segment; composition-added +namespace segments do not consume that local limit. + +The grammar is a string grammar. Because `sdl-yaml/v1` applies YAML 1.2 Core +scalar resolution before model construction, an all-digit authored key must be +quoted to remain a string; decoders must not coerce a numeric key into an id. + +The grammar applies by semantic role, not by field spelling. It covers: + +- `Scenario.name`; +- declaration keys in map-valued SDL sections, including variables; +- nested declaration segments such as entity children, node-local roles, + workflow steps, participant action preconditions/effects, view transitions, + and temporal/disclosure records; +- explicitly addressable nested names such as named services, ACL rules, and + content items; +- the scenario-level `forwarding_agents[*].forwarding_agent_id` list identity; +- every primary and addressable child id in the node-runtime family registry; +- family-local stable ids validated through `require_symbol`, after confirming + that the field is an ACES-local record key rather than provider/native data; +- any later field that becomes a declaration key or canonical-address segment. + +It does not apply merely because a field is named `name` or ends in `_id`. +Display labels, descriptions, usernames, DNS names, URLs, file paths, LDAP DNs, +environment-variable names, versions, contract ids, CVEs, package coordinates, +provider/native ids, external ids, and opaque evidence/provenance refs retain +their owning contracts. If a value must be both human-facing data and an +addressable symbol, its symbol role is the portable id; display or provider data +must remain a separate field rather than weakening the id grammar. + +Declaration identity and reference values remain distinct. A reference-valued +field may continue to accept a full-value variable placeholder where its owning +contract already permits one; instantiation may select a declared target, but it +must never rename or create the target. Variable declarations, module parameter +names, import-parameter keys, and +`scenario-instantiation-request-v1.parameters` keys use the same portable-id +grammar. Parameter *values* retain their owning field contracts and never enter +an address segment. + +Applying the lexical grammar does not make every stable local id a generic +relationship/objective target. Canonical addressability and targetability are +separate catalogued properties: a node-local role can have a stable internal +address without becoming a valid generic target. +`Scenario.name`, `module.id`, and import namespaces identify document and +composition boundaries; they do not become in-scenario generic targets merely +because they use the same segment grammar. +The semantic-role audit must account for every ACES-local declaration identity, +including list-valued and deeply nested records; it must not infer declaration +status from a field suffix or stop at the existing generic-reference targets. +Each in-scenario declaration identity receives one owner-relative canonical +address, while the existing owning catalog records whether that address is +externally targetable. + +### 2. Give modules explicit, segment-safe identity + +`module.id` uses exactly `portable-id "/" portable-id` for +`publisher/name`. Every import declares an explicit `namespace`, and that +namespace is one `portable-id`; filenames, source paths, OCI repository names, +or module display text are never silently converted into namespaces. + +An imported unit that participates as a module declares its module descriptor; +the registry must not manufacture `module.id` from a filename or source path. +`module.exports` keys are existing section-catalog literals, not user ids, and +each exported member must resolve to an actual declaration of that section. +Nested export members such as entity paths are validated as typed declaration +paths, not blindly with the local-id regex and not by arbitrary `getattr` lookup. +The list-valued `forwarding_agents` section participates by its +`forwarding_agent_id`: composition rewrites that id, preserves the list value +shape, and detects collisions by canonical address before concatenating lists. +It must not be dropped because the existing map-section composition loop does +not enumerate it, converted into a public map-shaped SDL section, or exempted +from exports and private-prefix handling. + +Nested imports retain namespace structure as a tuple of segments. Composition +may render that tuple as a dotted prefix, but only the composition layer may +create such generated qualified identities. The generated `__private` segment +is reserved to composition and is never valid author input. Raw/normalized SDL +declarations therefore contain local ids, while expanded SDL may contain +validated generated namespace paths. A local-id validator must not be applied +blindly to trusted expanded keys, and expanded-key acceptance must not become a +way for raw callers to bypass local-id validation. + +The existing `Scenario` / `ExpandedScenario` distinction is the phase and trust +boundary. Public YAML, normalized JSON, and direct model construction validate +`Scenario` local declarations. Only the composition path may construct an +internal `ExpandedScenario`, after validating every imported `Scenario`, module +descriptor, import namespace, and namespace tuple. There is no caller-settable +`expanded=true`, validation-context flag, or permissive fallback that authorizes +dotted declarations. `ExpandedScenario` is not a second public authoring +contract. + +Instantiation preserves the identity phase of its input. A flat authored +scenario produces local keys; a composition-expanded scenario produces the same +validated generated namespace paths. The published instantiated-scenario +contract may therefore describe generated qualified keys, but `parse_sdl` must +never use that derived-document shape to admit dotted raw declarations. +Rebuilding the concrete model must preserve the namespace tuple and prior +collision proof rather than treating the rendered dotted key as trusted by +itself. + +### 3. Treat dots as address syntax, not identifier content + +Canonical declaration addresses are built from typed path parts. Examples are: + +```text +
. +entities. +forwarding_agents. +nodes..roles. +nodes..services. +infrastructure..acls. +content..items. +nodes..runtime..[.....] +workflows..steps. +``` + +`` is a composition-generated namespace path followed by one +local id. Fixed words such as `runtime`, `services`, `acls`, `items`, `steps`, +and registered runtime collection names are grammar literals in their typed +positions. They are not globally banned as local ids; typed construction and +canonical collision detection provide the boundary. Generated `__*` control +segments remain reserved. + +Bare references and compact `.` window references +are lookup aliases, not additional canonical identities. A qualified reference +resolves by exact canonical-address lookup. Resolution must not guess by first +match, declaration order, source locality, delimiter partitioning, or a +longest-node-name heuristic. + +Every declaration is entered into the canonical-address index with its kind and +source provenance before aliases are deduplicated. Two distinct declarations +that render to the same canonical address are a fatal collision, even if a set +or dictionary would otherwise collapse them. This covers collisions between +root and imported declarations, nested entities and namespace paths, and +namespace paths that resemble nested collection paths. + +Processor runtime addresses remain derived artifacts, distinct from SDL +authoring addresses. Existing compiler address builders must consume validated, +structured identity parts, preserve namespace tuples, and reject duplicate +derived addresses before constructing resource dictionaries. Planner, +snapshot, and persistence layers consume those canonical strings; they do not +reinterpret or repair SDL identifiers. + +The compiler boundary must enforce the identity and duplicate-address invariants +even when a caller intentionally skips unrelated semantic checks. The planner +must not merge runtime-family dictionaries with last-write-wins assignment. +Existing planning, backend-result, runtime-snapshot, and persistence contracts +must preserve address equality across every redundant representation: a +snapshot map key equals its embedded `SnapshotEntry.address`, and an operation +or changed-address value denotes the same compiler-owned address used by its +resource or snapshot transition. A mismatch is a contract failure at the +existing `PlanOperationModel` / `RuntimeSnapshotEnvelopeModel` / +`OperationStatusModel`, +`aces_runtime.backend_calls`, or `aces_runtime.control_plane_store` boundary; it +is never repaired by choosing one copy or by parsing the address as an SDL +reference. + +### 4. Enforce the contract at every existing ingress + +The hand-governed schemas under `contracts/schemas/sdl/` remain the normative +machine-readable authority. Identifier-bearing map keys use scoped +`propertyNames` constraints and identifier-bearing fields use the same pattern. +Literal/native maps and reference-keyed maps must not acquire those constraints. +This includes `scenario-instantiation-request-v1.parameters`; its values remain +unconstrained JSON values at that boundary. The Python `schema_bundle()` must +continue to generate identical schemas. + +Schema constraints are phase-specific. The authoring-input schema applies the +local-id grammar to authored declaration keys. The instantiated-scenario schema +must separately describe composition-generated qualified top-level keys while +continuing to constrain local nested segments. Reusing the authoring +`propertyNames` fragment blindly for instantiated output would reject valid +expansion; reusing the instantiated fragment for authoring would admit forged +dotted identities. Generated schemas, the hand-governed copies, and the schema +publication manifest remain synchronized through their existing checks and +change-ledger rules. + +`MappingScope.LITERAL` only means "do not normalize this key as a structural +field"; it is not an identity classification. Source validation must distinguish +declaration keys, reference-keyed maps (for example script event bindings), and +native/data maps (for example labels, facts, driver options, and extensions). +Only the first receives the local-id rule. Reference-keyed maps use their owning +reference grammar, and native/data maps preserve their existing key contract. + +The same rule is enforced at these existing boundaries: + +- the source-marked YAML/key preflight, for precise key ranges and strict + `sdl-yaml/v1` behavior; +- Pydantic model construction, so direct Python and normalized-JSON callers do + not bypass source parsing; +- import-shape validation before resolution, and descriptor/lock validation at + the existing registry boundary before the values are used for composition or + filesystem output; +- module expansion and the semantic declaration index, for generated-address + collisions and exact reference resolution; +- instantiation, which rebuilds the concrete model and re-runs semantic checks; +- language-service completions, references, edits, formatting, and diagnostics; + and +- compiler/canonical-serialization boundaries, which may consume identifiers + but never normalize them. + +Per-document `SDLParserLimits` enforcement is not an aggregate import-graph +budget. A single request-scoped composition budget, carried through the existing +`SDLSourceParseOptions` and registry/composition recursion, must additionally +bound import depth and document count, aggregate decoded bytes and constructed +object/YAML nodes (which conservatively bounds declaration count), +namespace depth, and rendered canonical-address size. Exhaustion fails before +further expansion or compilation through the existing bounded import/parse +diagnostic path. This extends the source-limit seam; it is not a new limits +subsystem, an environment-selected language profile, or a backend setting. + +The existing `VARIABLE_NAME_PATTERN`, runtime `require_symbol`, and the duplicate +stable-id helper in `runtime_ssh_server` must converge on this contract instead +of remaining competing regex/validator families. Every stable-id helper caller +is audited by semantic role before the shared helper is tightened; an +incorrectly classified provider/native field must return to its owning validator +rather than weakening the portable grammar. Existing object-name, path, DNS, +environment-name, and provider/native validators remain distinct because those +values are data, not declarations. +Runtime-family discovery continues to come from +`RUNTIME_SERVICE_FAMILIES`; section and reference discovery continues to come +from the existing SDL catalogs and indexes. `tools/check_sdl_catalog_parity.py` +remains the whole-repository drift gate. The list-valued scenario-level +`forwarding_agents` identity surface remains catalogued explicitly and must not +disappear behind map-only key handling. The language service and MCP inspection +surfaces may retain partial-document presentation logic, but valid-document +definitions and references must come from, or be differentially checked against, +the same declaration index. Existing inventories are reconciled or parity-tested +at their current ownership boundaries; no additional section list, runtime +family list, reference-edge registry, or best-effort semantic resolver is +introduced solely for identifier validation. + +### 5. Fail closed with the existing diagnostic and security boundaries + +Lexically invalid declarations are structural errors. Canonical-address +collisions and reference ambiguity are static-semantic errors. They use the +existing `SDLParseError` / `SDLParseDiagnostic` and `SDLValidationError` +envelopes; no new exception hierarchy is added. Where source marks exist, a +diagnostic identifies the RFC 6901 path and offending declaration-token range, +whether that declaration is a mapping key or a scalar id field. A collision +diagnostic identifies both declarations and their source provenance. +The existing diagnostic records may gain optional related-source/provenance +fields for cross-file collisions; that is an envelope extension, not a parallel +error type. The authoring envelope must not be conflated with +`aces_contracts.diagnostics.Diagnostic`, whose owner is planner/runtime +reporting. `SDLInstantiationError` continues to wrap failures discovered while +rebuilding and revalidating the concrete model, and `ScenarioValidationError` +remains only the existing scenario-loader compatibility adapter. Import/model +validation must not leak a raw Pydantic `ValidationError` through parser or +language-service entry points. + +Migration is explicit and non-lossy. Diagnostics may suggest a conforming id, +but ordinary parsing and `SDLMigrationPolicy.ACCEPT` do not silently trim, +case-fold, normalize Unicode, replace dots, or rewrite references. Any future +rename operation must update a declaration and every resolved reference +atomically, prove that the rename is injective, and fail on ambiguous or lossy +input. + +Identifiers are public structural metadata, never a secret-bearing channel. +Diagnostics and logs may name a validated id. An invalid authored key is +untrusted input: any rendered spelling must be escaped and length-bounded, while +the structured source range remains the primary locator. Neither form may +include adjacent values, the whole document, parameter maps, trust material, +credentials, or tracebacks. Restricting valid identifiers to bounded ASCII +prevents control-character/log injection and prevents module ids or namespaces +from becoming path traversal or shell syntax; it does not make raw invalid-key +rendering safe. + +Module trust, digest/signature verification, registry allowlists, source-size +limits, archive extraction containment, and local-import path containment remain +mandatory and unchanged. Authored namespaces and local module descriptors are +validated before import resolution. A remote descriptor can only be validated +after its bounded, digest-bound config is fetched; it must be validated before +signature acceptance, archive extraction, composition, lockfile emission, or +output/cache path construction. Module ids already flow into signature payloads, +OCI annotations, lock records, and OCI-layout directory names, so those consumers +must receive the validated value and must not re-sanitize it independently. +Validation of `module.id` does not make adjacent `version`, `source`, or +`root_file` values safe path components; their owning version, URL, archive, and +containment checks remain independently mandatory, and an output boundary must +validate or encode every component it places in a filesystem path. +Identifiers are not secret or environment-binding values, and the language +grammar is not selected through an environment variable, deployment setting, or +backend config. Existing OCI and host-tool realization seams retain fixed +list-form argv (never `shell=True`), bounded execution, runtime/tool allowlists, +and native-output redaction. The existing TechVault initramfs seam is narrower +but different: it places a derived hostname into a generated guest `/bin/sh` +script and must retain its dedicated `_shell_quote` boundary; no additional +identifier interpolation or general shell-command builder is introduced there. + +Language-service/MCP symbol, pointer, and prefix arguments and control-plane +compiled-address path parameters remain caller-controlled even after the authored +grammar is tightened. They must be length-bounded at their existing entrypoint +limit/config seam, escaped when rendered, and resolved by exact index/snapshot +lookup. Identifier or migration results may return declaration names and source +ranges, but never adjacent parameter values or document fragments. The runtime +control plane continues to apply `ControlPlaneSecurityConfig` authentication, +authorization, and request guards; it does not parse a URL path value as an SDL +authoring reference or echo an unbounded invalid value in an error envelope. + +Plan submission is a separate runtime trust boundary. `PlanOperationModel` and +its provisioning/orchestration/evaluation envelopes are authenticated wire +input, not proof that an address came from this compiler. Their existing +`ContractModel` / FastAPI conversion boundary is the incumbent shape gate, but +it currently provides closure rather than proof of canonical address +provenance. It must be strengthened, without adding parallel DTOs, to reject +non-canonical or overlong operation addresses, duplicate operation addresses, +dependency or startup-order entries that do not resolve in the admitted plan / +snapshot set, and address / domain / resource-type incoherence before +execution. Backend `ApplyResult` values and locally persisted snapshots are +rechecked independently; neither is trusted merely because an earlier boundary +validated the plan. + +The reusable downstream seam is one bounded compiled-address contract in the +existing `aces_contracts.planning` boundary, consumed by processor plan models, +wire DTOs, runtime state, backend-result gates, and persistence loaders. The +processor's existing `_address` construction helpers remain the renderer and +must emit values accepted by that contract. The address-size bound is a named, +schema-visible contract constant shared by those consumers, not an environment +setting or a collection of endpoint-local limits. This is distinct from the +local SDL identifier validator: it validates compiler address form and size, +not authoring references or provider names. + +Default 4xx rendering, `str(ValueError)` conflict responses, and caught backend +exception text are not currently a sufficient disclosure boundary. Paths that +can include caller- or backend-controlled address text must translate failures +through the existing bounded runtime diagnostic and redacted API envelopes and +must not disclose whole plans, payload values, credentials, tracebacks, or +unbounded address data. + +Provider host/resource names continue through their existing driver-specific +mapping and validation. The portable SDL grammar rejects leading option markers, +path separators, whitespace, and control characters, but it is not a Docker, +libvirt, DNS, hostname, or cloud-provider naming contract. A provider name is a +derived value and never replaces the canonical SDL identity. Bounded provider +names must be deterministically collision-resistant for the complete canonical +address, for example a sanitized readable prefix plus a digest suffix; plain +truncation is not an identity-preserving mapping. + +### 5.1 Formal obligations + +Let `P` be the set of portable local identifiers, `N = P*` the finite namespace +paths generated by composition, and `Q = N x P` the qualified symbols. Let +`D(S)` be the declarations of a validated expanded scenario `S`, with each +declaration retaining its semantic kind and owner path. The typed canonical +address renderer `A_S : D(S) -> String` must satisfy document-scoped +injectivity: + +```text +forall d1, d2 in D(S): A_S(d1) = A_S(d2) implies d1 = d2 +``` + +This is proved operationally by retaining every declaration and its provenance +until collision checking completes; inserting rendered names into a set or map +before that check is not a proof. The renderer is not claimed to be globally +injective over untyped strings. Its domain includes the declaration kind, +owner-relative path, registered fixed segments, namespace tuple, and local id. + +Instantiation parameters are identity-independent. If `instantiate(S, p)` and +`instantiate(S, q)` both succeed for parameter environments `p` and `q`, their +declaration-address domains are equal. Parameter values may select references +or data but cannot create, delete, or rename declarations: + +```text +dom(A_instantiate(S, p)) = dom(A_instantiate(S, q)) = dom(A_S) +``` + +Every resolved reference must have cardinality exactly one in the typed +declaration index. Zero matches is unresolved; more than one is ambiguous. +Resolution order, source proximity, and delimiter partitioning do not alter that +cardinality. + +Across processor, plan, backend, and snapshot stages, canonical addresses form a +commuting identity carrier: redundant representations must compare equal to the +compiler-emitted address, and a transition may mention only admitted addresses. +Provider naming is a projection from that complete address into a provider's +bounded name domain. It is never the inverse identity map and therefore retains +a deterministic digest of the complete source address. + +### 5.2 Standards lineage and limits + +The grammar notation follows RFC 5234 ABNF conventions. The segment/delimiter +separation is influenced by RFC 3986, but a canonical SDL address is not a URI +and does not inherit URI resolution, normalization, percent-encoding, or +equivalence semantics. RFC 6901 defines diagnostic JSON Pointer locations; a +pointer is not an SDL declaration identity or reference. RFC 8785 defines the +canonical JSON serialization used for document semantic identity; it does not +define SDL identifier equality or lineage. + +Unicode Standard Annex #15 and Unicode Technical Standard #39 inform the +decision to avoid silent normalization and confusable-sensitive identifier +repair. ACES does not claim conformance to a Unicode identifier profile: SDL +structural identifiers deliberately use bounded lowercase ASCII, while display +and data fields retain their owning Unicode contracts. + +This identifier decision does not claim or define Park bisimulation, labelled +transition-system equivalence, observational equivalence, or multi-agent +behavioral equivalence. Those concepts apply to the behavior and observation +semantics of scenarios, not to the lexical identity carrier established here. +Any future bisimulation-based scenario equivalence must state its transition +system, labels, observations, and equivalence relation independently; stable +injective addresses are supporting structure, not evidence of behavioral +equivalence. + +Canonical SDL authoring semantic identity remains RFC 8785 over the validated +expanded authoring model. +Display/data strings retain their authored Unicode code points; only identifiers +are restricted to ASCII. The control-plane authentication/authorization gates +and atomic local snapshot store remain downstream consumers of compiled +addresses and gain no new authoring ingress. Compiled addresses may appear as +snapshot/audit keys and workflow URL path parameters, but runtime DTOs, backend +validators, and persistence must treat them as typed compiler output rather than +re-parsing or repairing authoring references. Downstream planner and backend code +may use the compiled address grammar for deterministic ordering or provider-name +derivation only at their existing, explicit seams. + +### 6. Preserve the extension seams already present + +Namespace depth is represented as a sequence (`namespace_path` already exists +in the objective/reference semantic IR), not inferred by repeatedly splitting a +rendered string. Composition, declaration indexing, semantic IR, and compiler +address construction carry that sequence until one canonical renderer produces +the external string. A new top-level section extends the existing +section/reference catalogs; a new runtime family extends +`RUNTIME_SERVICE_FAMILIES`; both then use the same local-id and address rules +automatically. + +A future internationalized identifier grammar, escaping syntax, or different +case policy is a new language/contract decision with an explicit migration and +schema-version assessment. It is not an in-place widening of this regex. +The grammar and its length bound are therefore versioned language seams, not +runtime configuration parameters. + +On acceptance, the normative SDL document model, section/reference/runtime +catalogs, diagnostics, variable/instantiation contract, composition guidance, +and parser guide must be reconciled in one change. In particular, the current +dotted-node and longest-match rules and the current implicit local-import +namespace behavior are superseded. Accepted ADRs remain immutable; conflicting +guidance in ADR-003 or ADR-053 is changed only through this explicit superseding +decision and corresponding normative documentation, not by editing their +accepted records in place. + +## Guardrails + +- Do not validate every `name`, `id`, or mapping key as an SDL symbol; classify + the field's semantic role first. +- Do not implement the schema rule with a `$`-anchored pattern alone; prove + parity for final line terminators, controls, and maximum length across YAML, + Pydantic, and the published JSON Schemas. +- Do not treat `_mapping_scopes.MappingScope.LITERAL` as proof that a key is a + declaration, or as proof that it is native data. +- Do not keep dotted authored node ids as a special case. +- Do not derive namespaces from paths or sanitize invalid ids into valid ones. +- Do not synthesize module identity from a descriptor-less import path, or treat + module export section names as arbitrary attributes. +- Do not treat one validated path component as validation of an output path that + also contains version, source, archive-member, or provider-native data. +- Do not expose a public trusted/expanded switch or accept authored dotted keys + through `ExpandedScenario` as a convenience path. +- Do not make identifier grammar, normalization, or maximum length selectable + through environment, backend, parser, or deployment configuration. +- Do not exempt synthetic wrapper/scaffold identifiers used by MCP, language + service, tests, or fixtures; those callers enter through the same authoring + contract and must use conforming local ids. +- Do not treat per-document parser limits as protection against an aggregate + nested-import expansion; carry one bounded composition budget through the + complete import graph. +- Do not parse qualified refs independently in each section with `split`, + `partition`, or `rsplit`; resolve through the canonical declaration index. +- Do not let a set/dict insertion serve as collision detection after provenance + has already been erased. +- Do not apply the authoring key regex to composition-generated instantiated + keys, or use the instantiated key shape as an authoring bypass. +- Do not let compiler semantic-validation options bypass identity/collision + checks, merge planner resources with last-write-wins behavior, or accept a + snapshot whose map key differs from its embedded address. +- Do not treat authentication, a recognized address prefix, or Pydantic shape + validation alone as proof that a plan, backend result, or persisted snapshot + carries coherent compiler-owned addresses. +- Do not omit or map-convert `forwarding_agents` during composition merely + because the incumbent composition registry is map-section-oriented; compose + the list by its declared identity and preserve its published list shape. +- Do not add schema-only, Pydantic-only, or semantic-validator-only enforcement; + raw YAML, normalized JSON, direct model construction, module expansion, and + compiled output must agree. +- Do not add a second exception hierarchy, migration policy, section catalog, + runtime-family registry, or workflow-step resolver. +- Do not change display labels, provider-native ids, paths, URLs, DNS names, + environment names, or external contract identifiers to make them resemble + SDL symbols. +- Do not use provider-safe runtime names as canonical SDL identities or assume + the portable-id grammar satisfies every provider's length and hostname rules. +- Do not truncate provider names without retaining a deterministic digest of the + complete canonical address. +- Do not surface raw Pydantic error renderings when they can include adjacent + values; translate failures into the existing bounded diagnostic envelopes. +- Do not write an invalid key verbatim to logs or diagnostics; escape and bound + its presentation and use the source range as the locator. +- Do not echo unbounded language-tool query strings, URL path parameters, + document fragments, or instantiation values in identifier diagnostics. +- Do not expose raw backend exception text or complete invalid plan/payload + values through 4xx responses, runtime diagnostics, logs, or audit events. +- Do not interpolate identifiers into new shell command strings; retain the + dedicated quoting boundary on the existing generated guest-init script. + +## Non-Goals + +- Escaping arbitrary legacy identifiers inside the current dotted syntax. +- Automatic or best-effort renaming of existing scenarios. +- Case-insensitive or Unicode-normalized identity. +- New variable types, substitution syntax, binding precedence, or parameter + value semantics; this decision only prevents values from changing declaration + identity. +- Changing bare-reference ambiguity semantics or allowing implicit target + creation. +- A general redesign of the source/normalized/expanded/instantiated document + phases beyond the identity provenance needed to keep qualified segments safe. +- Redesigning module trust, OCI transport, lockfiles, runtime authentication, + persistence, or backend protocols. +- Making compiled runtime addresses interchangeable with SDL authoring + references. +- Promoting every local stable id to generic relationship/objective targetability. +- Changing environment-variable, provider/native, external, display, path, URL, + DNS, or other data-field grammars. +- Adding a universal identity service or value-object hierarchy. + +## Alternatives Considered + +### Keep arbitrary ids and use longest-match parsing + +Rejected. Meaning would depend on the declarations currently in the symbol +table, and newly imported declarations could change how an existing string is +parsed. It also leaves compiler and persistence addresses structurally opaque. + +### Escape dots and other delimiters + +Rejected for the current language. Escaping would have to agree across YAML, +JSON Schema, module rewriting, workflow-step refs, compiler addresses, +language-service tools, logs, and persisted snapshots. A small portable segment +grammar is easier to implement consistently and audit. + +### Normalize case or Unicode + +Rejected. Normalization is lossy, can merge previously distinct declarations, +and creates cross-language/version dependencies. Lowercase ASCII ids plus +unrestricted Unicode display/data fields make the boundary explicit. + +### Apply one regex to every `name`, `_id`, and map key + +Rejected. Many such fields are labels, provider data, external identifiers, +native option maps, or references rather than SDL declarations. Suffix- or +shape-based validation would conflate concepts and break legitimate data. + +## Consequences + +Every valid SDL declaration has exactly one portable canonical address, and any +collision is reported before composition, compilation, or persistence can +silently overwrite it. Reference tooling and runtime compilation can share one +identity contract without losing the existing section-, workflow-, or +runtime-family-specific semantics. + +The change is intentionally strict and therefore migration-bearing. Existing +dotted nodes, mixed-case/file-like targetable names, descriptor-less imported +fragments, implicit local-import namespaces, and other nonconforming declarations +must be migrated explicitly. +Published SDL schemas are currently `draft`, but every tightening still follows +ADR-061 and the schema-publication manifest/change-ledger rules. + +The grammar does not make every dotted string self-describing. Canonical +addresses remain typed paths, and collision-free meaning comes from structured +construction plus exact declaration-index lookup. This avoids both a global +reserved-word list and a new general-purpose address parser. diff --git a/docs/decisions/adrs/adr-index.yaml b/docs/decisions/adrs/adr-index.yaml index 157fbb644..8cc59668c 100644 --- a/docs/decisions/adrs/adr-index.yaml +++ b/docs/decisions/adrs/adr-index.yaml @@ -308,3 +308,6 @@ adrs: - id: ADR-074 path: docs/decisions/adrs/adr-074-experiment-authoring-input-contract-boundary.md pin: 74dd29e3e5f3a2b7bdf836bd40908c0840c51f46a1a89efbb191e6ce7f620ce5 + - id: ADR-076 + path: docs/decisions/adrs/adr-076-portable-sdl-identifiers-and-canonical-addresses.md + pin: cd28329002c1befb1920f0037c07517adbe3811d15e4f559455d05b893c69203 diff --git a/docs/explain/reference/fm-classification-ledger.yaml b/docs/explain/reference/fm-classification-ledger.yaml index 7a9758e4b..8a425dd3a 100644 --- a/docs/explain/reference/fm-classification-ledger.yaml +++ b/docs/explain/reference/fm-classification-ledger.yaml @@ -434,3 +434,16 @@ entries: waived_artifacts: - kind: property_based_or_differential_tests rationale: Historical backfill records current evidence; no dedicated property-based or differential artifact was recorded for this accepted ADR. + - adr: ADR-076 + surface: Portable SDL identifiers and canonical cross-stage addresses + fm_level: FM2 + delivered_artifacts: + - kind: invariant_list + path: docs/decisions/adrs/adr-076-portable-sdl-identifiers-and-canonical-addresses.md + - kind: unit_tests + path: implementations/python/tests/test_sdl_identifiers.py + - kind: typed_ir_or_contract_coverage + path: implementations/python/packages/aces_sdl/_declarations.py + - kind: property_based_or_differential_tests + path: implementations/python/tests/test_sdl_identifiers.py + waived_artifacts: [] diff --git a/docs/explain/reference/shared-semantic-integrity.md b/docs/explain/reference/shared-semantic-integrity.md index 86ccfc267..48df82ad1 100644 --- a/docs/explain/reference/shared-semantic-integrity.md +++ b/docs/explain/reference/shared-semantic-integrity.md @@ -225,7 +225,7 @@ so they are tracked by their own requirements, not here. | Construct family | Owning requirement(s) | Phases covered | Realizing artifacts | Status | | --- | --- | --- | --- | --- | | Fail-closed semantic validation (cross-cutting gate) | SEM-201 | validation, instantiation | `implementations/python/packages/aces_sdl/validator/__init__.py`, `implementations/python/packages/aces_sdl/instantiate.py`, `implementations/python/tests/test_sdl_validator.py` | active | -| Stable identifiers, parameterized values, and qualified references | DSL-101, DSL-102, SEM-205 | authoring, validation, instantiation, compilation, planning, observation | `implementations/python/packages/aces_sdl/parser.py`, `implementations/python/packages/aces_sdl/variables.py`, `implementations/python/packages/aces_sdl/validator/__init__.py`, `implementations/python/tests/test_sdl_parser.py`, `implementations/python/tests/test_sdl_validator.py` | active | +| Stable identifiers, parameterized values, and qualified references | DSL-101, DSL-102, SEM-205 | authoring, validation, instantiation, compilation, planning, execution, observation | `docs/decisions/adrs/adr-076-portable-sdl-identifiers-and-canonical-addresses.md`, `specs/sdl/document-model.md`, `specs/sdl/references.md`, `implementations/python/packages/aces_sdl/_identifiers.py`, `implementations/python/packages/aces_sdl/_declarations.py`, `implementations/python/packages/aces_sdl/parser.py`, `implementations/python/packages/aces_sdl/composition.py`, `implementations/python/packages/aces_processor/compiler.py`, `implementations/python/packages/aces_runtime/backend_calls.py`, `implementations/python/packages/aces_runtime/control_plane.py`, `implementations/python/tests/test_sdl_identifiers.py` | active | | Deterministic module composition and canonical-identity stability across expansion | DSL-103, SEM-205 | authoring, validation, compilation | `implementations/python/packages/aces_sdl/composition.py`, `implementations/python/packages/aces_sdl/module_registry.py`, `specs/formal/composition-readiness.md`, `implementations/python/tests/test_sdl_module_registry.py` | active | | Instantiation and revalidation of concrete scenarios | RUN-301 | instantiation, validation | `implementations/python/packages/aces_sdl/instantiate.py`, `implementations/python/tests/test_sdl_validator.py`, `implementations/python/tests/test_run_300_lifecycle.py` | active | | Objective windows, referenced scopes, reachability, and refresh | SEM-202 | validation, compilation, planning | `implementations/python/packages/aces_sdl/semantics/objectives.py`, `specs/formal/objectives/README.md`, `specs/formal/objectives/window-consistency.md`, `implementations/python/tests/test_semantics_objectives.py`, `implementations/python/tests/test_fm2_semantics.py` | active | diff --git a/docs/explain/sdl/parser.md b/docs/explain/sdl/parser.md index 97941a5b1..12cb57063 100644 --- a/docs/explain/sdl/parser.md +++ b/docs/explain/sdl/parser.md @@ -10,9 +10,10 @@ construction. It is usually an `FM0` surface under the repository's [coding standards](../reference/coding-standards.md): parser work normally needs ordinary tests, not state-machine modeling or solver-backed formal artifacts, unless it also introduces new semantic invariants above raw syntax. -The mapping-key injectivity gate is such an invariant and is treated as `FM1`: -table-driven and property tests pin ambiguity rejection and literal-map -preservation. +The mapping-key gate is `FM1`; portable declaration identity, canonical-address +injectivity, and cross-stage coherence are `FM2`. Table-driven, property, and +differential tests pin grammar parity, ambiguity rejection, literal-map +preservation, and processor/runtime agreement. ## Canonical Fields and Migration @@ -22,12 +23,15 @@ Canonical SDL structural fields use exact lower-case `snake_case`: - `start_time` is canonical; `start-time` is migration syntax. - `semantic_version` is canonical; `Semantic-Version` is migration syntax. -**User-defined names are preserved as-is.** Node names, feature names, account names, entity fact keys, and other HashMap keys are not transformed. This ensures cross-references remain consistent. +Declaration identities are preserved exactly but must already use the portable +local-id grammar `^[a-z0-9][a-z0-9_-]{0,63}$` (node local ids have a 35-character +maximum). The parser never lowercases or sanitizes an invalid id. Native/data +map keys such as entity facts remain under their own contracts. ```yaml -# "My-Switch" is preserved; structural field "type" is exact. +# "my-switch" is a portable declaration id; structural field "type" is exact. nodes: - My-Switch: + my-switch: type: switch ``` @@ -35,13 +39,13 @@ Ordinary parsing is strict. Callers doing a deliberate conversion can select `SDLMigrationPolicy.ACCEPT` or use `aces sdl format`; each recognized rewrite produces a source-ranged `sdl.noncanonical_field` or `sdl.noncanonical_merge` warning. The formatter emits strict, typed, longhand -YAML and never rewrites literal identifiers. +YAML and never invents identifier renames. Field aliases do not imply precedence. Writing both `Name` and `name`, or both `password-strength` and `password_strength`, in one structural mapping is a fatal `sdl.mapping_key_conflict`. Exact duplicates are also fatal in -user-defined and native maps, but distinct literal keys such as `Web-App` and -`web_app` remain distinct identifiers. +user-defined and native maps, but distinct valid declaration ids such as +`web-app` and `web_app` remain distinct. The check runs over the composed YAML node graph before a Python dictionary is constructed, so it retains both authored spellings and source ranges. YAML @@ -77,7 +81,12 @@ placeholder. For example, `infrastructure: {web: ${replicas}}` expands to ## Variables -Full-value `${var_name}` placeholders and embedded `${var_name}` tokens are preserved as literal strings during parsing. Structural validation currently accepts placeholders in ordinary string fields, common scalar/time fields, many reference values, and selected leaf enum-backed property fields. The parser does not substitute variables or evaluate expressions. It also rejects placeholder tokens in user-defined mapping keys, because those keys define the SDL symbol table and must stay concrete. +Full-value `${var_name}` placeholders and embedded `${var_name}` tokens are +preserved as literal strings during parsing. Variable names use the same +portable local-id grammar. Structural validation accepts placeholders only in +fields whose owning contract permits parameterization. The parser does not +substitute variables or evaluate expressions, and no placeholder may define or +rename an SDL identity. The intended boundary is: @@ -127,8 +136,9 @@ their strict decoded object validates against it directly. 4. **Safe construction** — construct JSON-domain native values only after ambiguity checks 5. **Typed normalization** — expand shorthands and normalize declared field values 6. **Pydantic construction** — structural validation (types, ranges, required fields) -7. **Module expansion** — resolve file-backed imports before full semantic validation -8. **Semantic validation** — cross-reference checks plus variable-reference checks (see [validation.md](validation.md)) +7. **Module expansion** — resolve descriptor-bearing file-backed imports with explicit namespaces and one aggregate composition budget +8. **Declaration indexing** — retain typed provenance, reject canonical-address collisions, and build aliases +9. **Semantic validation** — exact cross-reference checks plus variable-reference checks (see [validation.md](validation.md)) On success, the returned `Scenario` may still carry non-fatal advisories in `scenario.advisories` (for example, VM nodes without explicit `resources`). @@ -177,7 +187,7 @@ asserts the compiled output is byte-identical. Top-level composition supports: -- optional `module` descriptors for publishable SDL modules +- explicit `module` descriptors on every imported unit - `imports` using backward-compatible `path:` or canonical `source:` - `source:` classes `local:`, `oci:`, and `locked:` - repo-owned trust and resolution files: @@ -188,9 +198,17 @@ Import `source:` values are not treated as ordinary SDL package-source shorthand. They are resolved by the composition layer, not expanded into `{name, version}` package dictionaries. +Every import supplies one portable `namespace`; filenames and source paths are +never converted into identity. Composition alone creates dotted qualified +names and the reserved `__private` segment. Raw dotted declaration keys are +invalid, including when migration acceptance is enabled. + ## Error Types - `SDLParseError` — YAML syntax errors and structural validation failures; mapping-key failures carry structured `.diagnostics` with stable code, JSON - Pointer, authored spellings, and source ranges + Pointer, authored spellings, and source ranges; invalid declaration keys and + scalar ids use `sdl.identifier.invalid` and do not echo the invalid spelling; + other typed-model failures use `sdl.model.invalid` with a control-escaped, + 512-character-bounded domain message and no Pydantic input rendering - `SDLValidationError` — semantic validation failures (has `.errors` list with all issues) diff --git a/examples/scenarios/hospital-ransomware-surgery-day.sdl.yaml b/examples/scenarios/hospital-ransomware-surgery-day.sdl.yaml index 6f23e53f2..2af2eca75 100644 --- a/examples/scenarios/hospital-ransomware-surgery-day.sdl.yaml +++ b/examples/scenarios/hospital-ransomware-surgery-day.sdl.yaml @@ -836,12 +836,14 @@ content: target: exchange01 format: eml items: - - name: payroll-adjustment.eml + - name: payroll-adjustment + display_name: payroll-adjustment.eml tags: - phishing - attachment description: Message crafted to lure helpdesk review - - name: vendor-followup.eml + - name: vendor-followup + display_name: vendor-followup.eml tags: - phishing - vendor diff --git a/examples/scenarios/port-authority-surge-response.sdl.yaml b/examples/scenarios/port-authority-surge-response.sdl.yaml index c12835656..32e206c3e 100644 --- a/examples/scenarios/port-authority-surge-response.sdl.yaml +++ b/examples/scenarios/port-authority-surge-response.sdl.yaml @@ -683,12 +683,14 @@ content: target: customs-gateway format: json items: - - name: hold-HZ-204.json + - name: hold-hz-204 + display_name: hold-HZ-204.json tags: - customs - hold description: Hold order for hazardous container HZ-204 - - name: hold-HZ-991.json + - name: hold-hz-991 + display_name: hold-HZ-991.json tags: - customs - hold diff --git a/examples/scenarios/satcom-release-poisoning.sdl.yaml b/examples/scenarios/satcom-release-poisoning.sdl.yaml index 18b16b9da..94f366b8d 100644 --- a/examples/scenarios/satcom-release-poisoning.sdl.yaml +++ b/examples/scenarios/satcom-release-poisoning.sdl.yaml @@ -750,12 +750,14 @@ content: target: analytics-lake format: markdown items: - - name: canary-rollback.md + - name: canary-rollback + display_name: canary-rollback.md tags: - rollback - release description: Canary rollback checklist - - name: edge-validation.md + - name: edge-validation + display_name: edge-validation.md tags: - validation - edge diff --git a/implementations/python/packages/aces_backend_libvirt/drivers/libvirt.py b/implementations/python/packages/aces_backend_libvirt/drivers/libvirt.py index 8d04afe8d..91b408e03 100644 --- a/implementations/python/packages/aces_backend_libvirt/drivers/libvirt.py +++ b/implementations/python/packages/aces_backend_libvirt/drivers/libvirt.py @@ -13,6 +13,7 @@ from pathlib import Path from typing import Protocol, cast +from aces_backend_protocols.naming import provider_resource_name from aces_contracts.diagnostics import Diagnostic, Severity from aces_backend_libvirt.driver import ( @@ -326,10 +327,11 @@ def _conn(self) -> object: return self._connection def _runtime_name(self, address: str, preferred: str) -> str: - return _safe_name(preferred, fallback=address.rsplit(".", 1)[-1], prefix=self._name_prefix) + del preferred + return provider_resource_name(address, prefix=self._name_prefix) def _name_for(self, address: str) -> str: - return self._names.get(address, self._runtime_name(address, address.rsplit(".", 1)[-1])) + return self._names.get(address, self._runtime_name(address, "")) def _build_seed(self, spec: DomainSpec, name: str) -> Path | None: cloud_init = spec.cloud_init diff --git a/implementations/python/packages/aces_backend_libvirt/realization.py b/implementations/python/packages/aces_backend_libvirt/realization.py index 8d2f3f420..e365a52eb 100644 --- a/implementations/python/packages/aces_backend_libvirt/realization.py +++ b/implementations/python/packages/aces_backend_libvirt/realization.py @@ -23,6 +23,7 @@ from dataclasses import dataclass, field from aces_backend_protocols.capabilities import ProvisionerCapabilities +from aces_backend_protocols.naming import provider_resource_name from aces_contracts.diagnostics import Diagnostic, Severity from aces_contracts.planning import PlannedResource, ProvisioningPlan, RuntimeDomain @@ -146,7 +147,7 @@ def interpret_provisioning_plan( def _network_address_lookup(networks: list[NetworkSpec]) -> dict[str, str]: lookup: dict[str, str] = {} for spec in networks: - for key in (spec.address, spec.name, spec.address.rsplit(".", 1)[-1]): + for key in (spec.address, spec.name): if key: lookup[key] = spec.address return lookup @@ -177,7 +178,7 @@ def _node_address_lookup( lookup: dict[str, str] = {} for resource, payload in node_resources: name = _resource_name(resource, payload) - for key in (resource.address, name, resource.address.rsplit(".", 1)[-1]): + for key in (resource.address, name): if key: lookup[key] = resource.address return lookup @@ -380,7 +381,7 @@ def _network_cidr_lookup(networks: list[NetworkSpec]) -> dict[str, str]: for spec in networks: if not spec.cidr: continue - for key in (spec.address, spec.name, spec.address.rsplit(".", 1)[-1]): + for key in (spec.address, spec.name): if key: lookup[key] = spec.cidr return lookup @@ -390,7 +391,7 @@ def _resource_name(resource: PlannedResource, payload: Mapping[str, object]) -> name = payload.get("name") or payload.get("node_name") if isinstance(name, str) and name: return name - return resource.address.rsplit(".", 1)[-1] + return provider_resource_name(resource.address, prefix="aces") def _infrastructure_spec(payload: Mapping[str, object]) -> Mapping[str, object]: diff --git a/implementations/python/packages/aces_backend_libvirt/techvault_concerns.py b/implementations/python/packages/aces_backend_libvirt/techvault_concerns.py index 3609ac1da..c8bec9932 100644 --- a/implementations/python/packages/aces_backend_libvirt/techvault_concerns.py +++ b/implementations/python/packages/aces_backend_libvirt/techvault_concerns.py @@ -313,15 +313,14 @@ def _name_diagnostics( def _native_name_diagnostics(names: list[tuple[str, str]], name_prefix: str) -> list[Diagnostic]: diagnostics: list[Diagnostic] = [] native_names: set[str] = set() - for address, name in names: - exact_name = f"{name_prefix}-{name}" if name_prefix else name - selected_name = runtime_name(name_prefix, address, name) - if not name or selected_name != exact_name or selected_name in native_names: + for address, display_name in names: + selected_name = runtime_name(name_prefix, address, display_name) + if selected_name in native_names: diagnostics.append( _diagnostic( _CODE_NAME_UNSUPPORTED, address, - "TechVault native names must be unique, libvirt-safe, and realizable without normalization.", + "TechVault provider-name projections must be unique for canonical resource addresses.", ) ) native_names.add(selected_name) diff --git a/implementations/python/packages/aces_backend_libvirt/techvault_matrix.py b/implementations/python/packages/aces_backend_libvirt/techvault_matrix.py index 72ae93e4b..4ec6636cd 100644 --- a/implementations/python/packages/aces_backend_libvirt/techvault_matrix.py +++ b/implementations/python/packages/aces_backend_libvirt/techvault_matrix.py @@ -10,6 +10,8 @@ from pathlib import Path from typing import cast +from aces_backend_protocols.naming import provider_resource_name + from .driver import DomainSpec, NetworkSpec from .drivers.libvirt import _aces_uuid @@ -154,7 +156,8 @@ def domain_xml(domain: Mapping[str, object], *, kernel: Path, initrd: Path) -> s def runtime_name(prefix: str, address: str, preferred: str | None = None) -> str: - return safe_name(preferred or address.rsplit(".", 1)[-1], fallback=address.rsplit(".", 1)[-1], prefix=prefix) + del preferred + return provider_resource_name(address, prefix=prefix) def safe_name(candidate: str, *, fallback: str, prefix: str) -> str: diff --git a/implementations/python/packages/aces_backend_protocols/naming.py b/implementations/python/packages/aces_backend_protocols/naming.py new file mode 100644 index 000000000..a2bfa3904 --- /dev/null +++ b/implementations/python/packages/aces_backend_protocols/naming.py @@ -0,0 +1,36 @@ +"""Deterministic provider-name derivation from compiler-owned addresses.""" + +from __future__ import annotations + +import hashlib +import re + +from aces_contracts.addressing import require_compiled_address + +_UNSAFE_PROVIDER_NAME = re.compile(r"[^a-z0-9_.-]+", re.ASCII) +_DIGEST_LENGTH = 12 + + +def provider_resource_name( + address: str, + *, + prefix: str = "", + maximum_length: int = 63, +) -> str: + """Return a bounded readable name whose suffix commits to *address*.""" + + require_compiled_address(address) + if isinstance(maximum_length, bool) or not isinstance(maximum_length, int) or maximum_length < 16: + raise ValueError("provider resource name maximum_length must be an integer >= 16") + digest = hashlib.sha256(address.encode("utf-8")).hexdigest()[:_DIGEST_LENGTH] + readable = _UNSAFE_PROVIDER_NAME.sub("-", address.lower()).strip("-._") or "resource" + safe_prefix = _UNSAFE_PROVIDER_NAME.sub("-", prefix.lower()).strip("-._") + if safe_prefix: + readable = f"{safe_prefix}-{readable}" + suffix = f"-{digest}" + readable_budget = maximum_length - len(suffix) + head = readable[:readable_budget].rstrip("-._") or "resource"[:readable_budget] + return f"{head}{suffix}" + + +__all__ = ["provider_resource_name"] diff --git a/implementations/python/packages/aces_contracts/addressing.py b/implementations/python/packages/aces_contracts/addressing.py new file mode 100644 index 000000000..d59ca79ae --- /dev/null +++ b/implementations/python/packages/aces_contracts/addressing.py @@ -0,0 +1,65 @@ +"""Wire contract for processor-owned runtime addresses.""" + +from __future__ import annotations + +import re +from typing import Annotated, Any + +from pydantic import AfterValidator, WithJsonSchema + +COMPILED_ADDRESS_MAX_LENGTH = 2048 +_ADDRESS_SEGMENT = r"(?:[a-z0-9][a-z0-9_-]{0,63}|__private)" +COMPILED_ADDRESS_PATTERN = rf"{_ADDRESS_SEGMENT}(?:\.{_ADDRESS_SEGMENT})+" +_COMPILED_ADDRESS_RE = re.compile(COMPILED_ADDRESS_PATTERN, re.ASCII) +COMPILED_ADDRESS_JSON_SCHEMA: dict[str, Any] = { + "type": "string", + "minLength": 3, + "maxLength": COMPILED_ADDRESS_MAX_LENGTH, + "pattern": rf"^{COMPILED_ADDRESS_PATTERN}$", + "not": {"pattern": "[^a-z0-9_.-]"}, +} +PLAN_ADDRESS_ROOT_BY_DOMAIN = { + "provisioning": "provision", + "orchestration": "orchestration", + "evaluation": "evaluation", +} +PLAN_RESOURCE_TYPES_BY_DOMAIN = { + "provisioning": frozenset({"network", "node", "feature-binding", "content-placement", "account-placement"}), + "orchestration": frozenset({"inject-binding", "inject", "event", "script", "story", "workflow"}), + "evaluation": frozenset({"condition-binding", "objective"}), +} + + +def require_compiled_address(value: object, *, field_name: str = "address") -> str: + if ( + not isinstance(value, str) + or len(value) > COMPILED_ADDRESS_MAX_LENGTH + or _COMPILED_ADDRESS_RE.fullmatch(value) is None + ): + raise ValueError(f"{field_name} must be a canonical compiled address") + return value + + +def _validate_compiled_address(value: str) -> str: + return require_compiled_address(value) + + +CompiledAddress = Annotated[ + str, + AfterValidator(_validate_compiled_address), + WithJsonSchema(COMPILED_ADDRESS_JSON_SCHEMA), +] + + +def render_compiled_address(*parts: str) -> str: + address = ".".join(part for part in parts if part) + return require_compiled_address(address) + + +__all__ = [ + "COMPILED_ADDRESS_JSON_SCHEMA", + "COMPILED_ADDRESS_MAX_LENGTH", + "CompiledAddress", + "render_compiled_address", + "require_compiled_address", +] diff --git a/implementations/python/packages/aces_contracts/contracts.py b/implementations/python/packages/aces_contracts/contracts.py index ed4254610..4403e8a7c 100644 --- a/implementations/python/packages/aces_contracts/contracts.py +++ b/implementations/python/packages/aces_contracts/contracts.py @@ -6,12 +6,14 @@ import json import re from collections.abc import Mapping +from copy import deepcopy from datetime import UTC, datetime, timedelta from functools import lru_cache from typing import Annotated, Any, Literal from aces_sdl import VARIABLE_TOKEN_PATTERN from aces_sdl.explicitness import ExplicitnessClass, ExplicitnessProvenance +from aces_sdl.identifiers import PORTABLE_IDENTIFIER_JSON_SCHEMA, QUALIFIED_IDENTIFIER_MAX_LENGTH from aces_sdl.observability_plane_semantics import classify_contract_plane from aces_sdl.participant_attribution_semantics import ( ParticipantAttributionCandidateKind, @@ -33,10 +35,12 @@ ParticipantTimeDomain, ) from aces_sdl.scenario import InstantiatedScenario, Scenario +from aces_sdl.schema_catalogs import HASHMAP_SECTIONS, RUNTIME_SERVICE_FAMILIES, RuntimeReferenceChild from pydantic import BaseModel, ConfigDict, Field, GetJsonSchemaHandler, StrictInt, model_validator from pydantic.json_schema import JsonSchemaValue from pydantic_core import CoreSchema +from .addressing import COMPILED_ADDRESS_JSON_SCHEMA, CompiledAddress from .corpus import CONCEPT_AUTHORITY, corpus_family_root from .manifest_authority import ( BACKEND_SUPPORTED_CONTRACT_IDS, @@ -58,6 +62,12 @@ ParticipantRuntimeLifecyclePhase, participant_lifecycle_field_violation_messages, ) +from .planning import ( + PLAN_ADDRESS_ROOT_BY_DOMAIN, + PLAN_RESOURCE_TYPES_BY_DOMAIN, + RuntimeDomain, + require_plan_operation_identity, +) from .versions import ( ATLAS_TACTICS_SOURCE_SCHEMA_VERSION, ATTACK_ENTERPRISE_TACTICS_SOURCE_SCHEMA_VERSION, @@ -431,6 +441,8 @@ def _extend_reported_value_status_schema(json_schema: JsonSchemaValue) -> None: _DEFS_KEY = "$defs" _INSTANTIATION_INVARIANT_CONTRACT_ID = "instantiated-scenario-v1" +_SDL_AUTHORING_CONTRACT_ID = "sdl-authoring-input-v1" +_SDL_IDENTIFIER_CONTRACT_IDS = frozenset({_SDL_AUTHORING_CONTRACT_ID, _INSTANTIATION_INVARIANT_CONTRACT_ID}) _SCHEMA_MAP_KEYS = ("properties", "patternProperties", _DEFS_KEY) _SCHEMA_SUBSCHEMA_KEYS = ( "additionalProperties", @@ -443,6 +455,135 @@ def _extend_reported_value_status_schema(json_schema: JsonSchemaValue) -> None: ) +def _portable_property_names(*, maximum: int = 64) -> dict[str, Any]: + schema = deepcopy(PORTABLE_IDENTIFIER_JSON_SCHEMA) + schema["maxLength"] = maximum + return schema + + +def _qualified_property_names(*, local_maximum: int = 64) -> dict[str, Any]: + local_tail = local_maximum - 1 + segment = r"(?:[a-z0-9][a-z0-9_-]{0,63}|__private)" + return { + "type": "string", + "minLength": 1, + "maxLength": QUALIFIED_IDENTIFIER_MAX_LENGTH, + "pattern": rf"^(?:{segment}\.)*[a-z0-9][a-z0-9_-]{{0,{local_tail}}}$", + "not": {"pattern": "[^a-z0-9_.-]"}, + } + + +def _resolve_local_ref(schema: dict[str, Any], node: object) -> dict[str, Any] | None: + if not isinstance(node, dict): + return None + ref = node.get("$ref") + if isinstance(ref, str) and ref.startswith("#/$defs/"): + resolved = schema.get(_DEFS_KEY, {}).get(ref.removeprefix("#/$defs/")) + return resolved if isinstance(resolved, dict) else None + return node + + +def _collection_item_schema( + schema: dict[str, Any], owner: dict[str, Any], collection_name: str +) -> dict[str, Any] | None: + collection = owner.get("properties", {}).get(collection_name) + collection = _resolve_local_ref(schema, collection) + if not isinstance(collection, dict): + return None + items = collection.get("items") + return _resolve_local_ref(schema, items) + + +def _constrain_runtime_children( + schema: dict[str, Any], + owner: dict[str, Any], + children: tuple[RuntimeReferenceChild, ...], +) -> None: + for child in children: + child_schema = _collection_item_schema(schema, owner, child.collection_name) + if child_schema is None: + continue + child_schema.setdefault("properties", {})[child.id_field] = _portable_property_names() + _constrain_runtime_children(schema, child_schema, child.children) + + +def _attach_runtime_identifier_constraints(schema: dict[str, Any]) -> None: + runtime = schema.get(_DEFS_KEY, {}).get("RuntimeConfiguration") + if not isinstance(runtime, dict): + return + for family in RUNTIME_SERVICE_FAMILIES: + item_schema = _collection_item_schema(schema, runtime, family.collection_name) + if item_schema is None: + continue + if family.collection_name != "forwarding_agents": + item_schema.setdefault("properties", {})[family.id_field] = _portable_property_names() + _constrain_runtime_children(schema, item_schema, family.child_refs) + + +def _constrain_collection_item_field( + owner: dict[str, Any], + collection_name: str, + field_name: str, + field_schema: dict[str, Any], +) -> None: + collection = owner.get("properties", {}).get(collection_name) + if not isinstance(collection, dict): + return + items = collection.get("items") + if not isinstance(items, dict): + return + collection["items"] = { + "allOf": [ + items, + {"properties": {field_name: field_schema}, "type": "object"}, + ] + } + + +def _attach_instantiation_request_identifier_constraints(schema: dict[str, Any]) -> None: + parameters = schema.get("properties", {}).get("parameters") + if isinstance(parameters, dict): + parameters["propertyNames"] = _portable_property_names() + + +def _attach_sdl_identifier_constraints(contract_id: str, schema: dict[str, Any]) -> None: + if contract_id == "scenario-instantiation-request-v1": + _attach_instantiation_request_identifier_constraints(schema) + elif contract_id in _SDL_IDENTIFIER_CONTRACT_IDS: + _attach_scenario_identifier_constraints(contract_id, schema) + + +def _attach_scenario_identifier_constraints(contract_id: str, schema: dict[str, Any]) -> None: + + qualified = contract_id == _INSTANTIATION_INVARIANT_CONTRACT_ID + for section_name in HASHMAP_SECTIONS: + section = schema.get("properties", {}).get(section_name) + if not isinstance(section, dict): + continue + local_maximum = 35 if section_name == "nodes" else 64 + section["propertyNames"] = ( + _qualified_property_names(local_maximum=local_maximum) + if qualified + else _portable_property_names(maximum=local_maximum) + ) + _attach_runtime_identifier_constraints(schema) + forwarding_id_schema = _qualified_property_names() if qualified else _portable_property_names() + _constrain_collection_item_field( + schema, + "forwarding_agents", + "forwarding_agent_id", + forwarding_id_schema, + ) + runtime = schema.get(_DEFS_KEY, {}).get("RuntimeConfiguration") + if isinstance(runtime, dict): + _constrain_collection_item_field( + runtime, + "forwarding_agents", + "forwarding_agent_id", + _portable_property_names(), + ) + + def _apply_string_token_constraint(node: dict[str, Any]) -> None: """Forbid the ``${name}`` token on a single free string subschema. @@ -517,6 +658,39 @@ def _attach_json_schema_metadata(contract_id: str, json_schema: dict[str, Any]) json_schema.setdefault("$id", _schema_id_for_contract_id(contract_id)) +def _attach_compiled_address_map_constraints(contract_id: str, json_schema: dict[str, Any]) -> None: + if contract_id != "runtime-snapshot-v1": + return + entries = json_schema.get("properties", {}).get("entries") + if not isinstance(entries, dict): + return + entries["propertyNames"] = deepcopy(COMPILED_ADDRESS_JSON_SCHEMA) + entries["additionalProperties"] = False + + +_PLAN_CONTRACT_DOMAIN = { + "provisioning-plan-v1": RuntimeDomain.PROVISIONING, + "orchestration-plan-v1": RuntimeDomain.ORCHESTRATION, + "evaluation-plan-v1": RuntimeDomain.EVALUATION, +} + + +def _attach_plan_identity_constraints(contract_id: str, json_schema: dict[str, Any]) -> None: + domain = _PLAN_CONTRACT_DOMAIN.get(contract_id) + if domain is None: + return + operation = json_schema.get(_DEFS_KEY, {}).get("PlanOperationModel") + if not isinstance(operation, dict): + return + properties = operation.get("properties", {}) + address = properties.get("address") + resource_type = properties.get("resource_type") + if isinstance(address, dict): + address.setdefault("allOf", []).append({"pattern": rf"^{PLAN_ADDRESS_ROOT_BY_DOMAIN[domain]}\."}) + if isinstance(resource_type, dict): + resource_type["enum"] = sorted(PLAN_RESOURCE_TYPES_BY_DOMAIN[domain]) + + _SEMANTIC_PROFILE_PHASE_ALLOWED_BINDING_SCOPES = { "authoring": frozenset(), "exchange": frozenset(), @@ -1948,11 +2122,34 @@ def __get_pydantic_json_schema__( class PlanOperationModel(ContractModel): action: str - address: str + address: CompiledAddress resource_type: str payload: dict[str, Any] = Field(default_factory=dict) - ordering_dependencies: list[str] = Field(default_factory=list) - refresh_dependencies: list[str] = Field(default_factory=list) + ordering_dependencies: list[CompiledAddress] = Field(default_factory=list) + refresh_dependencies: list[CompiledAddress] = Field(default_factory=list) + + +def _require_unique_operation_addresses(operations: list[PlanOperationModel]) -> None: + addresses = [operation.address for operation in operations] + if len(addresses) != len(set(addresses)): + raise ValueError("Plan operation addresses must be unique") + + +def _require_operation_identities(operations: list[PlanOperationModel], domain: RuntimeDomain) -> None: + for operation in operations: + require_plan_operation_identity(domain, operation.address, operation.resource_type) + + +def _require_startup_order_addresses( + operations: list[PlanOperationModel], + startup_order: list[str], +) -> None: + if len(startup_order) != len(set(startup_order)): + raise ValueError("Plan startup_order addresses must be unique") + operation_addresses = {operation.address for operation in operations} + unknown = set(startup_order) - operation_addresses + if unknown: + raise ValueError("Plan startup_order must reference admitted operation addresses") class RealizationEnvelopeIdentityModel(ContractModel): @@ -1970,26 +2167,46 @@ class ProvisioningPlanModel(ContractModel): diagnostics: list[dict[str, Any]] = Field(default_factory=list) realization_envelope: RealizationEnvelopeIdentityModel | None = None + @model_validator(mode="after") + def _validate_operation_addresses(self) -> ProvisioningPlanModel: + _require_unique_operation_addresses(self.operations) + _require_operation_identities(self.operations, RuntimeDomain.PROVISIONING) + return self + class OrchestrationPlanModel(ContractModel): operations: list[PlanOperationModel] = Field(default_factory=list) - startup_order: list[str] = Field(default_factory=list) + startup_order: list[CompiledAddress] = Field(default_factory=list) diagnostics: list[dict[str, Any]] = Field(default_factory=list) + @model_validator(mode="after") + def _validate_operation_addresses(self) -> OrchestrationPlanModel: + _require_unique_operation_addresses(self.operations) + _require_operation_identities(self.operations, RuntimeDomain.ORCHESTRATION) + _require_startup_order_addresses(self.operations, self.startup_order) + return self + class EvaluationPlanModel(ContractModel): operations: list[PlanOperationModel] = Field(default_factory=list) - startup_order: list[str] = Field(default_factory=list) + startup_order: list[CompiledAddress] = Field(default_factory=list) diagnostics: list[dict[str, Any]] = Field(default_factory=list) + @model_validator(mode="after") + def _validate_operation_addresses(self) -> EvaluationPlanModel: + _require_unique_operation_addresses(self.operations) + _require_operation_identities(self.operations, RuntimeDomain.EVALUATION) + _require_startup_order_addresses(self.operations, self.startup_order) + return self + class SnapshotEntryModel(ContractModel): - address: str + address: CompiledAddress domain: str resource_type: str payload: dict[str, Any] = Field(default_factory=dict) - ordering_dependencies: list[str] = Field(default_factory=list) - refresh_dependencies: list[str] = Field(default_factory=list) + ordering_dependencies: list[CompiledAddress] = Field(default_factory=list) + refresh_dependencies: list[CompiledAddress] = Field(default_factory=list) status: str = "ready" @@ -2026,7 +2243,7 @@ class RuntimeSnapshotEnvelopeModel(ContractModel): """ schema_version: Literal[RUNTIME_SNAPSHOT_SCHEMA_VERSION] = RUNTIME_SNAPSHOT_SCHEMA_VERSION - entries: dict[str, SnapshotEntryModel] = Field(default_factory=dict) + entries: dict[CompiledAddress, SnapshotEntryModel] = Field(default_factory=dict) orchestration_results: dict[str, WorkflowExecutionStateModel] = Field(default_factory=dict) orchestration_history: dict[str, list[WorkflowHistoryEventModel]] = Field(default_factory=dict) evaluation_results: dict[str, EvaluationResultStateModel] = Field(default_factory=dict) @@ -2042,6 +2259,13 @@ class RuntimeSnapshotEnvelopeModel(ContractModel): realization_envelope: RealizationEnvelopeIdentityModel | None = None metadata: dict[str, Any] = Field(default_factory=dict) + @model_validator(mode="after") + def _validate_entry_addresses(self) -> RuntimeSnapshotEnvelopeModel: + for map_key, entry in self.entries.items(): + if map_key != entry.address: + raise ValueError("Runtime snapshot entries map key must equal embedded address") + return self + class OperationReceiptModel(ContractModel): schema_version: Literal[OPERATION_SCHEMA_VERSION] = OPERATION_SCHEMA_VERSION @@ -2060,7 +2284,13 @@ class OperationStatusModel(ContractModel): submitted_at: str updated_at: str diagnostics: list[dict[str, Any]] = Field(default_factory=list) - changed_addresses: list[str] = Field(default_factory=list) + changed_addresses: list[CompiledAddress] = Field(default_factory=list) + + @model_validator(mode="after") + def _validate_changed_addresses(self) -> OperationStatusModel: + if len(self.changed_addresses) != len(set(self.changed_addresses)): + raise ValueError("changed addresses must be unique") + return self class ProvisionerCapabilitiesModel(ContractModel): @@ -7259,9 +7489,12 @@ def schema_bundle() -> dict[str, dict[str, Any]]: "reusable-asset-trust-policy-v1": ReusableAssetTrustPolicyModel.model_json_schema(), } for contract_id, json_schema in bundle.items(): + _attach_sdl_identifier_constraints(contract_id, json_schema) _attach_instantiation_invariants(contract_id, json_schema) _attach_experiment_datetime_invariants(contract_id, json_schema) _attach_json_schema_metadata(contract_id, json_schema) + _attach_compiled_address_map_constraints(contract_id, json_schema) + _attach_plan_identity_constraints(contract_id, json_schema) _attach_aces_semantic_profile(contract_id, json_schema) known_contract_ids = frozenset(bundle) for contract_id, json_schema in bundle.items(): diff --git a/implementations/python/packages/aces_contracts/planning.py b/implementations/python/packages/aces_contracts/planning.py index 4da3e2a85..834eb8aca 100644 --- a/implementations/python/packages/aces_contracts/planning.py +++ b/implementations/python/packages/aces_contracts/planning.py @@ -1,12 +1,17 @@ """Shared runtime planning contracts.""" +from __future__ import annotations + from dataclasses import dataclass, field from enum import Enum -from typing import Any +from typing import TYPE_CHECKING, Any -from aces_contracts.contracts import RealizationEnvelopeIdentityModel +from aces_contracts.addressing import require_compiled_address from aces_contracts.diagnostics import Diagnostic +if TYPE_CHECKING: + from aces_contracts.contracts import RealizationEnvelopeIdentityModel + class RuntimeDomain(str, Enum): """Top-level runtime concern.""" @@ -17,6 +22,32 @@ class RuntimeDomain(str, Enum): PARTICIPANT = "participant" +PLAN_ADDRESS_ROOT_BY_DOMAIN = { + RuntimeDomain.PROVISIONING: "provision", + RuntimeDomain.ORCHESTRATION: "orchestration", + RuntimeDomain.EVALUATION: "evaluation", +} +PLAN_RESOURCE_TYPES_BY_DOMAIN = { + RuntimeDomain.PROVISIONING: frozenset( + {"network", "node", "feature-binding", "content-placement", "account-placement"} + ), + RuntimeDomain.ORCHESTRATION: frozenset({"inject-binding", "inject", "event", "script", "story", "workflow"}), + RuntimeDomain.EVALUATION: frozenset({"condition-binding", "objective"}), +} + + +def require_plan_operation_identity(domain: RuntimeDomain, address: object, resource_type: object) -> None: + """Reject operations outside a plan endpoint's closed identity domain.""" + + canonical = require_compiled_address(address) + root = PLAN_ADDRESS_ROOT_BY_DOMAIN.get(domain) + resource_types = PLAN_RESOURCE_TYPES_BY_DOMAIN.get(domain) + if root is None or not canonical.startswith(f"{root}."): + raise ValueError("Plan operation address must belong to its runtime domain") + if not isinstance(resource_type, str) or resource_types is None or resource_type not in resource_types: + raise ValueError("Plan operation resource_type must belong to its runtime domain") + + class ChangeAction(str, Enum): """Planner reconciliation result for a resource.""" @@ -37,6 +68,11 @@ class PlannedResource: ordering_dependencies: tuple[str, ...] = () refresh_dependencies: tuple[str, ...] = () + def __post_init__(self) -> None: + require_compiled_address(self.address) + for dependency in (*self.ordering_dependencies, *self.refresh_dependencies): + require_compiled_address(dependency, field_name="dependency address") + @dataclass(frozen=True) class PlanOperation: @@ -49,6 +85,11 @@ class PlanOperation: ordering_dependencies: tuple[str, ...] = () refresh_dependencies: tuple[str, ...] = () + def __post_init__(self) -> None: + require_compiled_address(self.address) + for dependency in (*self.ordering_dependencies, *self.refresh_dependencies): + require_compiled_address(dependency, field_name="dependency address") + class ProvisionOp(PlanOperation): """Provisioning reconciliation operation.""" @@ -71,6 +112,9 @@ class ProvisioningPlan: diagnostics: list[Diagnostic] = field(default_factory=list) realization_envelope: RealizationEnvelopeIdentityModel | None = None + def __post_init__(self) -> None: + _validate_plan_addresses(self.resources, self.operations, domain=RuntimeDomain.PROVISIONING) + @property def actionable_operations(self) -> list[ProvisionOp]: return [op for op in self.operations if op.action != ChangeAction.UNCHANGED] @@ -85,6 +129,14 @@ class OrchestrationPlan: startup_order: list[str] = field(default_factory=list) diagnostics: list[Diagnostic] = field(default_factory=list) + def __post_init__(self) -> None: + _validate_plan_addresses( + self.resources, + self.operations, + self.startup_order, + domain=RuntimeDomain.ORCHESTRATION, + ) + @property def actionable_operations(self) -> list[OrchestrationOp]: return [op for op in self.operations if op.action != ChangeAction.UNCHANGED] @@ -99,11 +151,49 @@ class EvaluationPlan: startup_order: list[str] = field(default_factory=list) diagnostics: list[Diagnostic] = field(default_factory=list) + def __post_init__(self) -> None: + _validate_plan_addresses( + self.resources, + self.operations, + self.startup_order, + domain=RuntimeDomain.EVALUATION, + ) + @property def actionable_operations(self) -> list[EvaluationOp]: return [op for op in self.operations if op.action != ChangeAction.UNCHANGED] +def _validate_plan_addresses( + resources: dict[str, PlannedResource], + operations: list[PlanOperation], + startup_order: list[str] | None = None, + *, + domain: RuntimeDomain, +) -> None: + for map_key, resource in resources.items(): + require_compiled_address(map_key, field_name="resource map key") + if map_key != resource.address: + raise ValueError("Plan resource map key must equal embedded address") + if resource.domain is not domain: + raise ValueError("Plan resource domain must equal the plan domain") + require_plan_operation_identity(domain, resource.address, resource.resource_type) + operation_addresses = [operation.address for operation in operations] + for operation in operations: + require_plan_operation_identity(domain, operation.address, operation.resource_type) + if len(operation_addresses) != len(set(operation_addresses)): + raise ValueError("Plan operation addresses must be unique") + if startup_order is None: + return + for address in startup_order: + require_compiled_address(address, field_name="startup_order address") + if len(startup_order) != len(set(startup_order)): + raise ValueError("Plan startup_order addresses must be unique") + unknown = set(startup_order) - set(operation_addresses) + if unknown: + raise ValueError("Plan startup_order must reference admitted operation addresses") + + __all__ = ( "ChangeAction", "EvaluationOp", @@ -111,8 +201,11 @@ def actionable_operations(self) -> list[EvaluationOp]: "OrchestrationOp", "OrchestrationPlan", "PlanOperation", + "PLAN_ADDRESS_ROOT_BY_DOMAIN", + "PLAN_RESOURCE_TYPES_BY_DOMAIN", "PlannedResource", "ProvisionOp", "ProvisioningPlan", "RuntimeDomain", + "require_plan_operation_identity", ) diff --git a/implementations/python/packages/aces_contracts/runtime_state.py b/implementations/python/packages/aces_contracts/runtime_state.py index ede9bc50b..edb0a12f5 100644 --- a/implementations/python/packages/aces_contracts/runtime_state.py +++ b/implementations/python/packages/aces_contracts/runtime_state.py @@ -5,15 +5,18 @@ from collections.abc import Mapping from dataclasses import dataclass, field from enum import Enum -from typing import Any +from typing import TYPE_CHECKING, Any from aces_sdl.explicitness import ExplicitnessClass, ExplicitnessProvenance -from aces_contracts.contracts import RealizationEnvelopeIdentityModel +from aces_contracts.addressing import require_compiled_address from aces_contracts.diagnostics import Diagnostic from aces_contracts.planning import RuntimeDomain from aces_contracts.versions import OPERATION_SCHEMA_VERSION, RUNTIME_SNAPSHOT_SCHEMA_VERSION +if TYPE_CHECKING: + from aces_contracts.contracts import RealizationEnvelopeIdentityModel + class OperationState(str, Enum): """Lifecycle for async control-plane operations.""" @@ -37,6 +40,11 @@ class SnapshotEntry: refresh_dependencies: tuple[str, ...] = () status: str = "ready" + def __post_init__(self) -> None: + require_compiled_address(self.address) + for dependency in (*self.ordering_dependencies, *self.refresh_dependencies): + require_compiled_address(dependency, field_name="dependency address") + @dataclass(frozen=True) class RealizationProvenanceEntry: @@ -82,6 +90,12 @@ class RuntimeSnapshot: realization_envelope: RealizationEnvelopeIdentityModel | None = None metadata: dict[str, Any] = field(default_factory=dict) + def __post_init__(self) -> None: + for map_key, entry in self.entries.items(): + require_compiled_address(map_key, field_name="snapshot map key") + if map_key != entry.address: + raise ValueError("RuntimeSnapshot entries map key must equal embedded address") + def get(self, address: str) -> SnapshotEntry | None: return self.entries.get(address) @@ -225,6 +239,8 @@ def _identity_update( key: str, current: RealizationEnvelopeIdentityModel | None, ) -> RealizationEnvelopeIdentityModel | None: + from aces_contracts.contracts import RealizationEnvelopeIdentityModel + raw = updates.get(key) if raw is None: return current @@ -243,6 +259,9 @@ class ApplyResult: changed_addresses: list[str] = field(default_factory=list) details: dict[str, Any] = field(default_factory=dict) + def __post_init__(self) -> None: + _validate_changed_addresses(self.changed_addresses) + @dataclass(frozen=True) class OperationReceipt: @@ -269,6 +288,16 @@ class OperationStatus: diagnostics: list[Diagnostic] = field(default_factory=list) changed_addresses: list[str] = field(default_factory=list) + def __post_init__(self) -> None: + _validate_changed_addresses(self.changed_addresses) + + +def _validate_changed_addresses(addresses: list[str]) -> None: + for address in addresses: + require_compiled_address(address, field_name="changed address") + if len(addresses) != len(set(addresses)): + raise ValueError("changed addresses must be unique") + @dataclass(frozen=True) class RuntimeSnapshotEnvelope: diff --git a/implementations/python/packages/aces_mcp/tools/authoring.py b/implementations/python/packages/aces_mcp/tools/authoring.py index ab992b672..ccab31980 100644 --- a/implementations/python/packages/aces_mcp/tools/authoring.py +++ b/implementations/python/packages/aces_mcp/tools/authoring.py @@ -171,7 +171,7 @@ def sdl_validate_section( # Force a safe synthetic name — always last so context_yaml cannot # override it and cause confusing error messages. - wrapper["name"] = "__mcp_validation_fragment" + wrapper["name"] = "mcp-validation-fragment" wrapper[section] = section_data combined = _yaml.dump(wrapper, default_flow_style=False, sort_keys=False) diff --git a/implementations/python/packages/aces_processor/compiler.py b/implementations/python/packages/aces_processor/compiler.py index faba77b37..e747fb4fc 100644 --- a/implementations/python/packages/aces_processor/compiler.py +++ b/implementations/python/packages/aces_processor/compiler.py @@ -8,7 +8,9 @@ WorkflowFeature, WorkflowStatePredicateFeature, ) +from aces_contracts.addressing import render_compiled_address from aces_contracts.versions import WORKFLOW_STATE_SCHEMA_VERSION +from aces_sdl import build_declaration_index from aces_sdl.entities import flatten_entities from aces_sdl.explicitness import ExplicitnessClass from aces_sdl.instantiate import instantiate_scenario @@ -77,7 +79,7 @@ def _dump(model: Any) -> dict[str, Any]: def _address(*parts: str) -> str: - return ".".join(part for part in parts if part) + return render_compiled_address(*parts) def _dedupe(items: list[str]) -> tuple[str, ...]: @@ -224,13 +226,17 @@ def _service_address(node_name: str, service_name: str) -> str: return _address("provision", "node", node_name, "service", service_name) -def _split_node_service_ref(ref: object) -> tuple[str, str] | None: - if not isinstance(ref, str) or not ref.startswith("nodes."): - return None - node_name, sep, service_name = ref[len("nodes.") :].partition(".services.") - if not sep or not node_name or not service_name: +def _resolve_node_service_ref( + scenario: InstantiatedScenario, + ref: object, +) -> tuple[str, str] | None: + if not isinstance(ref, str): return None - return node_name, service_name + for node_name, node in scenario.nodes.items(): + for service in node.services: + if service.name and ref == f"nodes.{node_name}.services.{service.name}": + return node_name, service.name + return None def _action_contract_address(name: str) -> str: @@ -453,7 +459,7 @@ def _condition_addresses_for_refs(scenario: InstantiatedScenario, refs: list[str def _service_addresses_for_refs(scenario: InstantiatedScenario, refs: list[str]) -> tuple[str, ...]: addresses: list[str] = [] for ref in dict.fromkeys(refs): - split = _split_node_service_ref(ref) + split = _resolve_node_service_ref(scenario, ref) if split is not None: node_name, service_name = split node = scenario.nodes.get(node_name) @@ -624,11 +630,8 @@ def _resolve_resource_refs( resolved: list[str] = [] diagnostics: list[Diagnostic] = [] for ref_name in dict.fromkeys(ref_names): - matched_address = next( - (address for address, resource in resources.items() if resource.name == ref_name), - None, - ) - if matched_address is None: + matched_addresses = sorted(address for address, resource in resources.items() if resource.name == ref_name) + if not matched_addresses: diagnostics.append( Diagnostic( code=f"{code_prefix}-unbound", @@ -638,7 +641,20 @@ def _resolve_resource_refs( ) ) continue - resolved.append(matched_address) + if len(matched_addresses) > 1: + diagnostics.append( + Diagnostic( + code=f"{code_prefix}-ambiguous", + domain=domain, + address=owner_address, + message=( + f"Reference '{ref_name}' resolves to multiple {resource_label}s: " + f"{', '.join(matched_addresses)}." + ), + ) + ) + continue + resolved.append(matched_addresses[0]) return _dedupe(resolved), diagnostics @@ -2337,19 +2353,15 @@ def _node_variable_refs_by_address( def _realization_requirement_address(scenario: InstantiatedScenario, field_path: str) -> str: - """Resolve the compiled resource address for a realization-concern path. - - Falls back to the field path (an equivalent field identifier) when the - concern is not tied to a single compiled provisioning resource. - """ + """Resolve the compiled resource address for a realization-concern path.""" - parts = field_path.split(".") - head, name = parts[0], parts[1] - if head == "nodes": - node = scenario.nodes.get(name) - is_switch = node is not None and node.type == NodeType.SWITCH - return _network_address(name) if is_switch else _node_address(name) - return _content_address(name) if head == "content" else field_path + for name, node in scenario.nodes.items(): + if field_path in {f"nodes.{name}.os", f"nodes.{name}.type"}: + return _network_address(name) if node.type == NodeType.SWITCH else _node_address(name) + for name in scenario.content: + if field_path == f"content.{name}.type": + return _content_address(name) + raise ValueError("realization concern must resolve to one compiled resource address") def _compile_realization_requirements( @@ -2366,7 +2378,10 @@ def _compile_realization_requirements( for field_path, record in scenario.explicitness.items(): if record.classification is ExplicitnessClass.OPEN: continue - concern_kind = resolve_realization_concern(field_path) + concern_kind = resolve_realization_concern( + field_path, + declaration_names={"nodes": scenario.nodes, "content": scenario.content}, + ) if concern_kind is None: continue requirements.append( @@ -2402,6 +2417,7 @@ def compile_runtime_model(scenario: Scenario | InstantiatedScenario) -> RuntimeM if not isinstance(scenario, InstantiatedScenario): scenario = instantiate_scenario(scenario, validate_semantics=False) + build_declaration_index(scenario) node_variable_refs = dict(scenario.node_variable_refs) diagnostics: list[Diagnostic] = [] diff --git a/implementations/python/packages/aces_processor/models.py b/implementations/python/packages/aces_processor/models.py index 8491def21..26edc5d34 100644 --- a/implementations/python/packages/aces_processor/models.py +++ b/implementations/python/packages/aces_processor/models.py @@ -20,6 +20,7 @@ WorkflowFeature, WorkflowStatePredicateFeature, ) +from aces_contracts.addressing import require_compiled_address from aces_contracts.diagnostics import Diagnostic as Diagnostic from aces_contracts.diagnostics import Severity as Severity from aces_contracts.evaluation import ( @@ -4234,6 +4235,47 @@ class RuntimeModel: realization_requirements: tuple[CompiledRealizationRequirement, ...] = () realization_instance: InstantiatedScenario | None = None + def __post_init__(self) -> None: + owners: dict[str, str] = {} + address_map_fields = ( + "networks", + "node_deployments", + "feature_bindings", + "condition_bindings", + "injects", + "inject_bindings", + "content_placements", + "account_placements", + "action_contracts", + "observation_boundaries", + "outcome_interpretation_rules", + "participant_behaviors", + "behavior_specifications", + "events", + "scripts", + "stories", + "workflows", + "objectives", + ) + for field_name in address_map_fields: + value = getattr(self, field_name) + for map_key, item in value.items(): + address = getattr(item, "address", None) + if not isinstance(address, str): + raise TypeError(f"RuntimeModel {field_name} entries must carry an address") + require_compiled_address(address) + require_compiled_address(map_key, field_name="runtime model map key") + if map_key != address: + raise ValueError(f"RuntimeModel {field_name} map key must equal embedded address") + previous_owner = owners.get(address) + if previous_owner is not None and previous_owner != field_name: + raise ValueError( + f"RuntimeModel duplicate compiled address across {previous_owner} and {field_name}" + ) + owners[address] = field_name + for address in self.node_variable_refs: + require_compiled_address(address, field_name="node variable reference map key") + @dataclass(frozen=True) class ExecutionPlan: diff --git a/implementations/python/packages/aces_processor/semantics/planner.py b/implementations/python/packages/aces_processor/semantics/planner.py index 579a4fc29..f1e6a197a 100644 --- a/implementations/python/packages/aces_processor/semantics/planner.py +++ b/implementations/python/packages/aces_processor/semantics/planner.py @@ -8,6 +8,8 @@ from enum import Enum from typing import Protocol, TypeVar +from aces_contracts.addressing import require_compiled_address + class DependencyKind(str, Enum): """Typed runtime dependency semantics.""" @@ -42,9 +44,9 @@ class SupportsDependencySemantics(Protocol): def canonical_resource_identity(address: str) -> tuple[str, ...]: - """Return the canonical identity tuple for a compiled resource address.""" + """Return an opaque, validated ordering key for a compiled address.""" - return tuple(part for part in address.split(".") if part) + return (require_compiled_address(address),) def dependency_graph( diff --git a/implementations/python/packages/aces_processor/semantics/realization.py b/implementations/python/packages/aces_processor/semantics/realization.py index 9905c78d8..d8bf5d0d4 100644 --- a/implementations/python/packages/aces_processor/semantics/realization.py +++ b/implementations/python/packages/aces_processor/semantics/realization.py @@ -20,9 +20,11 @@ from __future__ import annotations +from collections.abc import Iterable, Mapping from dataclasses import dataclass from aces_backend_protocols.capabilities import BackendManifest +from aces_contracts.addressing import require_compiled_address from aces_contracts.diagnostics import Diagnostic, Severity from aces_contracts.planning import ChangeAction, ProvisioningPlan, ProvisionOp from aces_contracts.runtime_state import RealizationProvenanceEntry, RuntimeSnapshot @@ -91,8 +93,15 @@ class CompiledRealizationRequirement: requirement_kind: str explicitness: ExplicitnessClass + def __post_init__(self) -> None: + require_compiled_address(self.address) -def resolve_realization_concern(field_path: str) -> str | None: + +def resolve_realization_concern( + field_path: str, + *, + declaration_names: Mapping[str, Iterable[str]], +) -> str | None: """Return the realization concern kind for a classifier path, or ``None``. Only the concerns the planner validates against backend capabilities map to @@ -100,11 +109,10 @@ def resolve_realization_concern(field_path: str) -> str | None: a published kind and yields ``None``. """ - parts = field_path.split(".") - if len(parts) != 3: - return None - head, _name, leaf = parts - return _CONCERN_KIND_BY_PATH.get((head, leaf)) + for (head, leaf), concern_kind in _CONCERN_KIND_BY_PATH.items(): + if any(field_path == f"{head}.{name}.{leaf}" for name in declaration_names.get(head, ())): + return concern_kind + return None def realization_support_diagnostics( diff --git a/implementations/python/packages/aces_reference_backend/drivers/oci.py b/implementations/python/packages/aces_reference_backend/drivers/oci.py index 4ec654187..8ce119be9 100644 --- a/implementations/python/packages/aces_reference_backend/drivers/oci.py +++ b/implementations/python/packages/aces_reference_backend/drivers/oci.py @@ -26,6 +26,7 @@ from collections.abc import Callable from dataclasses import dataclass +from aces_backend_protocols.naming import provider_resource_name from aces_contracts.diagnostics import Diagnostic, Severity from aces_reference_backend.driver import ( @@ -158,16 +159,18 @@ def realize( diagnostics: list[Diagnostic] = [] network_handles: list[NetworkHandle] = [] for spec in networks: - argv = [self._runtime, "network", "create", *self._label_args(), spec.name] + runtime_name = provider_resource_name(spec.address, prefix="aces") + argv = [self._runtime, "network", "create", *self._label_args(), runtime_name] ok, kind = self._run(argv) if ok: self._realized.add(spec.address) - self._names[spec.address] = spec.name + self._names[spec.address] = runtime_name network_handles.append(NetworkHandle(address=spec.address, realized=True)) else: diagnostics.append(self._failure(spec.address, kind)) container_handles: list[ContainerHandle] = [] for spec in containers: + runtime_name = provider_resource_name(spec.address, prefix="aces") image = self._image_policy.image_for(spec.image_ref) if not self._image_policy.permits(image): diagnostics.append(self._image_rejected(spec.address)) @@ -179,7 +182,7 @@ def realize( "--rm", *self._label_args(), "--name", - spec.name, + runtime_name, # Attach the container to every requested network (created above # in this same realize() call) so planned topology is honored, # not silently left on the runtime default network. spec.networks @@ -194,7 +197,7 @@ def realize( ok, kind = self._run(argv) if ok: self._realized.add(spec.address) - self._names[spec.address] = spec.name + self._names[spec.address] = runtime_name container_handles.append(ContainerHandle(address=spec.address, realized=True)) else: diagnostics.append(self._failure(spec.address, kind)) @@ -228,9 +231,7 @@ def _rollback( self.destroy(networks=realized_networks, containers=realized_containers) def _name_for(self, address: str) -> str: - # Remove by the name realize() used; fall back to the address's last - # segment for resources this driver did not create (best effort). - return self._names.get(address, address.rsplit(".", 1)[-1]) + return self._names.get(address, provider_resource_name(address, prefix="aces")) def destroy( self, diff --git a/implementations/python/packages/aces_reference_backend/realization.py b/implementations/python/packages/aces_reference_backend/realization.py index 3949e819e..c2c28d1ab 100644 --- a/implementations/python/packages/aces_reference_backend/realization.py +++ b/implementations/python/packages/aces_reference_backend/realization.py @@ -13,6 +13,7 @@ from collections.abc import Mapping from dataclasses import dataclass +from aces_backend_protocols.naming import provider_resource_name from aces_contracts.diagnostics import Diagnostic, Severity from aces_contracts.planning import PlannedResource, ProvisioningPlan, RuntimeDomain @@ -96,7 +97,7 @@ def _network_address_lookup(networks: list[NetworkSpec]) -> dict[str, str]: lookup: dict[str, str] = {} for spec in networks: - for key in (spec.address, spec.name, spec.address.rsplit(".", 1)[-1]): + for key in (spec.address, spec.name): if key: lookup[key] = spec.address return lookup @@ -106,7 +107,7 @@ def _resource_name(resource: PlannedResource, payload: Mapping[str, object]) -> name = payload.get("name") or payload.get("node_name") if isinstance(name, str) and name: return name - return resource.address.rsplit(".", 1)[-1] + return provider_resource_name(resource.address, prefix="aces") def _infrastructure_spec(payload: Mapping[str, object]) -> Mapping[str, object]: diff --git a/implementations/python/packages/aces_runtime/backend_calls.py b/implementations/python/packages/aces_runtime/backend_calls.py index dab874216..3de39e584 100644 --- a/implementations/python/packages/aces_runtime/backend_calls.py +++ b/implementations/python/packages/aces_runtime/backend_calls.py @@ -5,6 +5,7 @@ from collections.abc import Callable, Iterable from copy import deepcopy +from aces_contracts.addressing import require_compiled_address from aces_contracts.diagnostics import Diagnostic from aces_contracts.planning import ProvisioningPlan from aces_contracts.runtime_state import ApplyResult, RealizationProvenanceEntry, RuntimeSnapshot @@ -56,6 +57,11 @@ def _call_backend_apply( backend_args = tuple(backend_snapshot if arg is snapshot else arg for arg in args) try: result = method(*backend_args) + except (TypeError, ValueError): + return _failed_apply_result( + baseline_snapshot, + _backend_contract_invalid(address, "Backend could not construct a valid apply result."), + ) except Exception as exc: return _failed_apply_result(baseline_snapshot, _backend_call_failed(address, exc)) return _finalize_backend_apply( @@ -87,7 +93,14 @@ def _finalize_backend_apply( if invalid_message is not None: return _failed_apply_result(baseline_snapshot, _backend_contract_invalid(address, invalid_message)) assert isinstance(result, ApplyResult) - contract_diagnostics = _snapshot_contract_diagnostics(result.snapshot) + contract_diagnostics = _snapshot_address_contract_diagnostics(result.snapshot) + if not contract_diagnostics: + contract_diagnostics = _changed_address_transition_diagnostics( + result, + baseline_snapshot, + ) + if not contract_diagnostics: + contract_diagnostics = _snapshot_contract_diagnostics(result.snapshot) if not contract_diagnostics: contract_diagnostics = _snapshot_transition_contract_diagnostics(baseline_snapshot, result.snapshot) realization_provenance: tuple[RealizationProvenanceEntry, ...] = () @@ -125,7 +138,7 @@ def _backend_call_failed(address: str, exc: Exception) -> Diagnostic: return _failure_diagnostic( "runtime.backend-call-failed", address, - f"Backend method '{address}' raised {type(exc).__name__}: {exc}.", + f"Backend method '{address}' did not complete ({type(exc).__name__}).", ) @@ -214,6 +227,61 @@ def _snapshot_contract_diagnostics(snapshot: RuntimeSnapshot) -> list[Diagnostic return participant_runtime_state_contract_diagnostics(snapshot) +def _snapshot_address_contract_diagnostics(snapshot: RuntimeSnapshot) -> list[Diagnostic]: + for map_key, entry in snapshot.entries.items(): + try: + require_compiled_address(map_key, field_name="snapshot map key") + require_compiled_address(entry.address) + except ValueError: + return [ + _backend_contract_invalid( + "runtime.snapshot", + "Backend snapshot contains a non-canonical resource address.", + ) + ] + if map_key != entry.address: + return [ + _backend_contract_invalid( + "runtime.snapshot", + "Backend snapshot map key does not equal its embedded address.", + ) + ] + return [] + + +def _changed_address_transition_diagnostics( + result: ApplyResult, + baseline_snapshot: RuntimeSnapshot, +) -> list[Diagnostic]: + admitted = _snapshot_carrier_addresses(baseline_snapshot) | _snapshot_carrier_addresses(result.snapshot) + if set(result.changed_addresses) - admitted: + return [ + _backend_contract_invalid( + "runtime.changed-addresses", + "Backend reported a changed address outside the snapshot transition.", + ) + ] + return [] + + +def _snapshot_carrier_addresses(snapshot: RuntimeSnapshot) -> set[str]: + carriers = ( + snapshot.entries, + snapshot.orchestration_results, + snapshot.orchestration_history, + snapshot.evaluation_results, + snapshot.evaluation_history, + snapshot.participant_episode_results, + snapshot.participant_episode_history, + snapshot.participant_behavior_history, + snapshot.shared_state_records, + snapshot.shared_state_history, + snapshot.joint_action_records, + snapshot.time_management_contexts, + ) + return {str(address) for carrier in carriers for address in carrier} + + def _snapshot_transition_contract_diagnostics( previous_snapshot: RuntimeSnapshot, next_snapshot: RuntimeSnapshot, diff --git a/implementations/python/packages/aces_runtime/control_plane.py b/implementations/python/packages/aces_runtime/control_plane.py index 794d493fd..18eb871be 100644 --- a/implementations/python/packages/aces_runtime/control_plane.py +++ b/implementations/python/packages/aces_runtime/control_plane.py @@ -12,7 +12,14 @@ from uuid import uuid4 from aces_contracts.diagnostics import Diagnostic -from aces_contracts.planning import EvaluationPlan, OrchestrationPlan, ProvisioningPlan, RuntimeDomain +from aces_contracts.planning import ( + EvaluationPlan, + OrchestrationPlan, + PlanOperation, + ProvisioningPlan, + RuntimeDomain, + require_plan_operation_identity, +) from aces_contracts.runtime_state import ( OperationReceipt, OperationState, @@ -60,6 +67,62 @@ def _utc_now() -> str: return datetime.now(UTC).isoformat().replace("+00:00", "Z") +def _submitted_plan_diagnostics( + plan: ProvisioningPlan | OrchestrationPlan | EvaluationPlan, + domain: RuntimeDomain, + snapshot: RuntimeSnapshot, +) -> list[Diagnostic]: + admitted = set(snapshot.entries) | {operation.address for operation in plan.operations} + diagnostic: Diagnostic | None = None + for operation in plan.operations: + diagnostic = _submitted_operation_diagnostic(operation, domain, snapshot, admitted) + if diagnostic is not None: + break + return [diagnostic] if diagnostic is not None else [] + + +def _submitted_operation_diagnostic( + operation: PlanOperation, + domain: RuntimeDomain, + snapshot: RuntimeSnapshot, + admitted: set[str], +) -> Diagnostic | None: + diagnostic: Diagnostic | None = None + address = f"runtime.control-plane.{domain.value}" + try: + require_plan_operation_identity(domain, operation.address, operation.resource_type) + except ValueError: + diagnostic = Diagnostic( + code="runtime.plan-resource-incoherent", + domain="runtime", + address=address, + message="Submitted plan operation disagrees with the endpoint resource identity.", + ) + + dependencies = {*operation.ordering_dependencies, *operation.refresh_dependencies} + if diagnostic is None and dependencies - admitted: + diagnostic = Diagnostic( + code="runtime.plan-dependency-unresolved", + domain="runtime", + address=address, + message="Submitted plan contains a dependency outside its operations and admitted snapshot.", + ) + + existing = snapshot.entries.get(operation.address) + if ( + diagnostic is None + and existing is not None + and (existing.domain is not domain or existing.resource_type != operation.resource_type) + ): + diagnostic = Diagnostic( + code="runtime.plan-resource-incoherent", + domain="runtime", + address=address, + message="Submitted plan disagrees with the admitted snapshot resource identity.", + ) + return diagnostic + + class RuntimeControlPlane(ParticipantControlMixin, ParticipantRetrievalMixin): """Reference control plane for async runtime submission and observation.""" @@ -106,6 +169,14 @@ def submit_provisioning( idempotency_key: str = "", request_fingerprint: str = "", ) -> OperationReceipt: + diagnostics = _submitted_plan_diagnostics(plan, RuntimeDomain.PROVISIONING, self._snapshot) + if diagnostics: + return self._reject_diagnostics( + domain=RuntimeDomain.PROVISIONING, + diagnostics=diagnostics, + idempotency_key=idempotency_key, + request_fingerprint=request_fingerprint, + ) diagnostics = _call_backend_diagnostics( self._target.provisioner.validate, plan, @@ -138,6 +209,14 @@ def submit_orchestration( domain=RuntimeDomain.ORCHESTRATION, message="Target does not provide an orchestrator.", ) + diagnostics = _submitted_plan_diagnostics(plan, RuntimeDomain.ORCHESTRATION, self._snapshot) + if diagnostics: + return self._reject_diagnostics( + domain=RuntimeDomain.ORCHESTRATION, + diagnostics=diagnostics, + idempotency_key=idempotency_key, + request_fingerprint=request_fingerprint, + ) return execute_operation( self, OperationExecutionRequest( @@ -165,6 +244,14 @@ def submit_evaluation( domain=RuntimeDomain.EVALUATION, message="Target does not provide an evaluator.", ) + diagnostics = _submitted_plan_diagnostics(plan, RuntimeDomain.EVALUATION, self._snapshot) + if diagnostics: + return self._reject_diagnostics( + domain=RuntimeDomain.EVALUATION, + diagnostics=diagnostics, + idempotency_key=idempotency_key, + request_fingerprint=request_fingerprint, + ) return execute_operation( self, OperationExecutionRequest( diff --git a/implementations/python/packages/aces_sdl/__init__.py b/implementations/python/packages/aces_sdl/__init__.py index 7d55f6050..e06ae0283 100644 --- a/implementations/python/packages/aces_sdl/__init__.py +++ b/implementations/python/packages/aces_sdl/__init__.py @@ -10,6 +10,7 @@ __all__ = [ "canonical_sdl_bytes", "canonical_sdl_digest", + "build_declaration_index", "instantiate_scenario", "InstantiatedScenario", "SDLCanonicalDigest", @@ -58,6 +59,8 @@ def __getattr__(name: str): module = import_module("aces_sdl._source_profile") elif name == "VARIABLE_TOKEN_PATTERN": module = import_module("aces_sdl._base") + elif name == "build_declaration_index": + module = import_module("aces_sdl._declarations") elif name == "instantiate_scenario": module = import_module("aces_sdl.instantiate") elif name in {"load_sdl_fragment", "parse_sdl", "parse_sdl_file"}: diff --git a/implementations/python/packages/aces_sdl/_base.py b/implementations/python/packages/aces_sdl/_base.py index 4cd08bbf4..546c1a6f5 100644 --- a/implementations/python/packages/aces_sdl/_base.py +++ b/implementations/python/packages/aces_sdl/_base.py @@ -6,6 +6,8 @@ from pydantic import BaseModel, ConfigDict +from ._identifiers import PORTABLE_IDENTIFIER_PATTERN + class SDLModel(BaseModel): """Base for all SDL Pydantic models.""" @@ -16,7 +18,7 @@ class SDLModel(BaseModel): ) -_VARIABLE_NAME_PATTERN = r"[A-Za-z_][A-Za-z0-9_-]*" +_VARIABLE_NAME_PATTERN = PORTABLE_IDENTIFIER_PATTERN VARIABLE_NAME_PATTERN = _VARIABLE_NAME_PATTERN VARIABLE_NAME_RE = re.compile(r"^" + VARIABLE_NAME_PATTERN + r"$") # Single source of truth for the ``${name}`` substitution token, shared by the diff --git a/implementations/python/packages/aces_sdl/_composition_budget.py b/implementations/python/packages/aces_sdl/_composition_budget.py new file mode 100644 index 000000000..1e2b41738 --- /dev/null +++ b/implementations/python/packages/aces_sdl/_composition_budget.py @@ -0,0 +1,86 @@ +"""Aggregate resource bounds for one SDL module-composition request.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from ._errors import SDLParseError +from ._identifiers import QualifiedName +from ._module_symbols import FORWARDING_AGENTS_SECTION, HASHMAP_SECTIONS +from ._source_profile import SDLParserLimits + + +@dataclass +class CompositionBudget: + limits: SDLParserLimits + imports: int = 0 + nodes: int = 0 + decoded_bytes: int = 0 + + def add_document(self, value: object, *, path: Path) -> None: + pending = [value] + count = 0 + while pending: + current = pending.pop() + count += 1 + if isinstance(current, str): + self.decoded_bytes += len(current.encode("utf-8")) + if isinstance(current, Mapping): + pending.extend(current.keys()) + pending.extend(current.values()) + elif isinstance(current, list | tuple): + pending.extend(current) + self.nodes += count + if self.nodes > self.limits.max_composed_nodes: + raise SDLParseError("SDL composition node budget exceeded", path=path) + if self.decoded_bytes > self.limits.max_composed_bytes: + raise SDLParseError("SDL composition decoded-byte budget exceeded", path=path) + + def add_import(self, *, path: Path) -> None: + self.imports += 1 + if self.imports > self.limits.max_imports: + raise SDLParseError("SDL composition import budget exceeded", path=path) + + def check_depth(self, depth: int, *, path: Path) -> None: + if depth > self.limits.max_composition_depth: + raise SDLParseError("SDL composition depth budget exceeded", path=path) + + def check_namespaces(self, payload: Mapping[str, Any], *, path: Path) -> None: + identifiers: list[str] = [] + for section_name in HASHMAP_SECTIONS: + section = payload.get(section_name) + if isinstance(section, Mapping): + identifiers.extend(str(name) for name in section) + agents = payload.get(FORWARDING_AGENTS_SECTION) + if isinstance(agents, list): + identifiers.extend( + str(agent.get("forwarding_agent_id")) + for agent in agents + if isinstance(agent, Mapping) and agent.get("forwarding_agent_id") + ) + for identifier in identifiers: + try: + namespace_depth = len(QualifiedName.parse(identifier).parts) - 1 + except ValueError as exc: + raise SDLParseError("SDL composition produced an invalid qualified identifier", path=path) from exc + if namespace_depth > self.limits.max_namespace_depth: + raise SDLParseError("SDL composition namespace-depth budget exceeded", path=path) + + +@dataclass(frozen=True) +class CompositionTraversal: + """Immutable ancestry plus the request-scoped mutable resource budget.""" + + seen: frozenset[Path] + budget: CompositionBudget + depth: int + + def descend_from(self, path: Path) -> CompositionTraversal: + return CompositionTraversal( + seen=self.seen | {path}, + budget=self.budget, + depth=self.depth + 1, + ) diff --git a/implementations/python/packages/aces_sdl/_declarations.py b/implementations/python/packages/aces_sdl/_declarations.py new file mode 100644 index 000000000..30ce9642c --- /dev/null +++ b/implementations/python/packages/aces_sdl/_declarations.py @@ -0,0 +1,424 @@ +"""Typed SDL declaration indexing and canonical-address collision checks.""" + +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Iterable +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from ._errors import SDLValidationError +from ._identifiers import QualifiedName +from ._module_symbols import HASHMAP_SECTIONS +from ._reference_targetability import is_targetable_section +from ._runtime_service_families import RUNTIME_SERVICE_FAMILIES, RuntimeReferenceChild + +if TYPE_CHECKING: + from .entities import Entity + from .scenario import Scenario + + +@dataclass(frozen=True) +class Declaration: + """One declared SDL identity before alias projection.""" + + kind: str + address: str + model_path: str + source: str | None = None + referenceable: bool = False + targetable: bool = False + + +class DeclarationIndex: + """Collision-preserving declarations plus non-authoritative lookup aliases.""" + + def __init__(self) -> None: + self._declarations: dict[str, Declaration] = {} + self._aliases: dict[str, set[str]] = defaultdict(set) + self._collisions: list[str] = [] + + @property + def addresses(self) -> frozenset[str]: + return frozenset(self._declarations) + + @property + def declarations(self) -> tuple[Declaration, ...]: + return tuple(self._declarations[address] for address in sorted(self._declarations)) + + @property + def collision_errors(self) -> tuple[str, ...]: + return tuple(dict.fromkeys(self._collisions)) + + def declaration_for(self, address: str) -> Declaration | None: + """Return the typed declaration at an exact canonical address.""" + + return self._declarations.get(address) + + def resolve(self, reference: str) -> set[str]: + return set(self._aliases.get(reference, ())) + + def reference_aliases(self, *, targetable: bool = False) -> dict[str, set[str]]: + """Return aliases projected through the typed reference policy.""" + + result: dict[str, set[str]] = {} + for alias, addresses in self._aliases.items(): + candidates = { + address + for address in addresses + if ( + (declaration := self._declarations.get(address)) is not None + and declaration.referenceable + and (declaration.targetable or not targetable) + ) + } + if candidates: + result[alias] = candidates + return result + + def reference_completions(self, *, targetable: bool = False) -> tuple[tuple[str, Declaration], ...]: + """Return one unambiguous preferred spelling per reference declaration.""" + + aliases = self.reference_aliases(targetable=targetable) + completions: list[tuple[str, Declaration]] = [] + for declaration in self.declarations: + if not declaration.referenceable or (targetable and not declaration.targetable): + continue + spellings = [alias for alias, candidates in aliases.items() if candidates == {declaration.address}] + spelling = min(spellings, key=lambda value: (value.count("."), len(value), value)) + completions.append((spelling, declaration)) + return tuple(completions) + + def spellings_for(self, reference: str) -> frozenset[str]: + """Return aliases denoting the same declarations as *reference*.""" + + targets = self.resolve(reference) + if not targets: + return frozenset({reference}) + return frozenset(alias for alias, candidates in self._aliases.items() if candidates.intersection(targets)) + + def add(self, declaration: Declaration, *, aliases: Iterable[str] = ()) -> None: + previous = self._declarations.get(declaration.address) + if previous is not None and previous != declaration: + self._collisions.append( + f"Canonical address '{declaration.address}' collides between " + f"{previous.kind} declaration at {previous.model_path} and " + f"{declaration.kind} declaration at {declaration.model_path}" + ) + return + self._declarations[declaration.address] = declaration + self._aliases[declaration.address].add(declaration.address) + for alias in aliases: + if alias: + self._aliases[alias].add(declaration.address) + + def raise_for_collisions(self) -> None: + if self._collisions: + raise SDLValidationError(list(dict.fromkeys(self._collisions))) + + +def _address(*parts: str) -> str: + return ".".join(parts) + + +def _qualified_parts(value: str) -> tuple[str, ...]: + return QualifiedName.parse(value).parts + + +def _add( + index: DeclarationIndex, + *, + kind: str, + address_parts: tuple[str, ...], + model_path: str, + aliases: Iterable[str] = (), + referenceable: bool = False, + targetable: bool = False, +) -> None: + index.add( + Declaration( + kind=kind, + address=_address(*address_parts), + model_path=model_path, + referenceable=referenceable, + targetable=targetable, + ), + aliases=aliases, + ) + + +def _add_entities( + index: DeclarationIndex, + entities: dict[str, Entity], + *, + address_prefix: tuple[str, ...], + model_prefix: str, +) -> None: + for name, entity in entities.items(): + parts = _qualified_parts(name) if not address_prefix else (name,) + entity_parts = (*address_prefix, *parts) + relative_name = _address(*entity_parts) + _add( + index, + kind="entity", + address_parts=("entities", *entity_parts), + model_path=f"{model_prefix}.{name}", + aliases=(relative_name,), + referenceable=True, + targetable=True, + ) + _add_entities( + index, + entity.entities, + address_prefix=entity_parts, + model_prefix=f"{model_prefix}.{name}.entities", + ) + + +def _add_runtime_children( + index: DeclarationIndex, + owner: object, + *, + address_prefix: tuple[str, ...], + model_prefix: str, + children: tuple[RuntimeReferenceChild, ...], +) -> None: + for child_spec in children: + for position, child in enumerate(getattr(owner, child_spec.collection_name, ())): + child_id = getattr(child, child_spec.id_field) + child_parts = (*address_prefix, child_spec.collection_name, child_id) + _add( + index, + kind=f"runtime-{child_spec.collection_name}", + address_parts=child_parts, + model_path=(f"{model_prefix}.{child_spec.collection_name}.{position}.{child_spec.id_field}"), + referenceable=True, + targetable=True, + ) + _add_runtime_children( + index, + child, + address_prefix=child_parts, + model_prefix=f"{model_prefix}.{child_spec.collection_name}.{position}", + children=child_spec.children, + ) + + +def _add_node_declarations(index: DeclarationIndex, scenario: Scenario) -> None: + for node_name, node in scenario.nodes.items(): + node_parts = _qualified_parts(node_name) + _add( + index, + kind="node", + address_parts=("nodes", *node_parts), + model_path=f"nodes.{node_name}", + aliases=(node_name,), + referenceable=True, + targetable=True, + ) + for role_name in node.roles: + _add( + index, + kind="node-role", + address_parts=("nodes", *node_parts, "roles", role_name), + model_path=f"nodes.{node_name}.roles.{role_name}", + ) + for position, service in enumerate(node.services): + if service.name: + _add( + index, + kind="service", + address_parts=("nodes", *node_parts, "services", service.name), + model_path=f"nodes.{node_name}.services.{position}.name", + referenceable=True, + targetable=True, + ) + runtime = node.runtime + if runtime is None: + continue + for family in RUNTIME_SERVICE_FAMILIES: + for position, item in enumerate(getattr(runtime, family.collection_name, ())): + item_id = getattr(item, family.id_field) + runtime_parts = ( + "nodes", + *node_parts, + "runtime", + family.collection_name, + item_id, + ) + _add( + index, + kind=f"runtime-{family.collection_name}", + address_parts=runtime_parts, + model_path=(f"nodes.{node_name}.runtime.{family.collection_name}.{position}.{family.id_field}"), + referenceable=True, + targetable=True, + ) + _add_runtime_children( + index, + item, + address_prefix=runtime_parts, + model_prefix=f"nodes.{node_name}.runtime.{family.collection_name}.{position}", + children=family.child_refs, + ) + + +_REFERENCEABLE_SECTIONS = frozenset( + { + "features", + "conditions", + "vulnerabilities", + "injects", + "events", + "scripts", + "stories", + "accounts", + "relationships", + "agents", + "action_contracts", + "observation_boundaries", + "behavior_specifications", + "evidence_requirements", + "objectives", + } +) +_SPECIAL_SECTIONS = frozenset({"nodes", "infrastructure", "entities", "content", "workflows"}) + + +def _add_section_declarations(index: DeclarationIndex, scenario: Scenario) -> None: + for section_name in HASHMAP_SECTIONS: + if section_name in _SPECIAL_SECTIONS: + continue + for name in getattr(scenario, section_name): + referenceable = section_name in _REFERENCEABLE_SECTIONS + _add( + index, + kind=section_name, + address_parts=(section_name, *_qualified_parts(name)), + model_path=f"{section_name}.{name}", + aliases=(name,), + referenceable=referenceable, + targetable=referenceable and is_targetable_section(section_name), + ) + + +def _add_variable_declarations(index: DeclarationIndex, scenario: Scenario) -> None: + for name in scenario.variables: + _add( + index, + kind="variable", + address_parts=("variables", name), + model_path=f"variables.{name}", + aliases=(name,), + referenceable=True, + ) + + +def _add_infrastructure_declarations(index: DeclarationIndex, scenario: Scenario) -> None: + for name, infrastructure in scenario.infrastructure.items(): + parts = _qualified_parts(name) + _add( + index, + kind="infrastructure", + address_parts=("infrastructure", *parts), + model_path=f"infrastructure.{name}", + referenceable=True, + targetable=True, + ) + for position, acl in enumerate(infrastructure.acls): + if acl.name: + _add( + index, + kind="infrastructure-acl", + address_parts=("infrastructure", *parts, "acls", acl.name), + model_path=f"infrastructure.{name}.acls.{position}.name", + referenceable=True, + targetable=True, + ) + + +def _add_content_declarations(index: DeclarationIndex, scenario: Scenario) -> None: + for name, content in scenario.content.items(): + parts = _qualified_parts(name) + _add( + index, + kind="content", + address_parts=("content", *parts), + model_path=f"content.{name}", + aliases=(name,), + referenceable=True, + targetable=True, + ) + for position, item in enumerate(content.items): + _add( + index, + kind="content-item", + address_parts=("content", *parts, "items", item.name), + model_path=f"content.{name}.items.{position}.name", + aliases=(item.name,), + referenceable=True, + targetable=True, + ) + + +def _add_workflow_declarations(index: DeclarationIndex, scenario: Scenario) -> None: + for name, workflow in scenario.workflows.items(): + parts = _qualified_parts(name) + _add( + index, + kind="workflow", + address_parts=("workflows", *parts), + model_path=f"workflows.{name}", + aliases=(name,), + referenceable=True, + ) + for step_name in workflow.steps: + _add( + index, + kind="workflow-step", + address_parts=("workflows", *parts, "steps", step_name), + model_path=f"workflows.{name}.steps.{step_name}", + aliases=(f"{name}.{step_name}",), + ) + + +def _add_forwarding_agent_declarations(index: DeclarationIndex, scenario: Scenario) -> None: + for position, agent in enumerate(scenario.forwarding_agents): + _add( + index, + kind="forwarding-agent", + address_parts=("forwarding_agents", *_qualified_parts(agent.forwarding_agent_id)), + model_path=f"forwarding_agents.{position}.forwarding_agent_id", + ) + + +def build_declaration_index( + scenario: Scenario, + *, + raise_on_collision: bool = True, +) -> DeclarationIndex: + """Index every catalogued declaration and reject non-injective rendering.""" + + index = DeclarationIndex() + _add( + index, + kind="scenario", + address_parts=("scenario", scenario.name), + model_path="name", + ) + + _add_section_declarations(index, scenario) + _add_variable_declarations(index, scenario) + _add_node_declarations(index, scenario) + _add_infrastructure_declarations(index, scenario) + _add_entities(index, scenario.entities, address_prefix=(), model_prefix="entities") + _add_content_declarations(index, scenario) + _add_workflow_declarations(index, scenario) + _add_forwarding_agent_declarations(index, scenario) + + if raise_on_collision: + index.raise_for_collisions() + return index + + +__all__ = ["Declaration", "DeclarationIndex", "build_declaration_index"] diff --git a/implementations/python/packages/aces_sdl/_identifiers.py b/implementations/python/packages/aces_sdl/_identifiers.py new file mode 100644 index 000000000..766f34ccc --- /dev/null +++ b/implementations/python/packages/aces_sdl/_identifiers.py @@ -0,0 +1,140 @@ +"""Portable SDL identifiers and composition-generated qualified names.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Annotated, Any + +from pydantic import AfterValidator, WithJsonSchema + +PORTABLE_IDENTIFIER_PATTERN = r"[a-z0-9][a-z0-9_-]{0,63}" +PORTABLE_IDENTIFIER_RE = re.compile(PORTABLE_IDENTIFIER_PATTERN, re.ASCII) +PORTABLE_IDENTIFIER_MAX_LENGTH = 64 +QUALIFIED_IDENTIFIER_MAX_LENGTH = 2048 +PRIVATE_NAMESPACE_SEGMENT = "__private" + +# JSON Schema's `$` may match immediately before a final newline. Combining a +# first-character check with an explicit rejection of every character outside +# the alphabet keeps the machine-readable contract independent of that quirk. +PORTABLE_IDENTIFIER_JSON_SCHEMA: dict[str, Any] = { + "type": "string", + "minLength": 1, + "maxLength": PORTABLE_IDENTIFIER_MAX_LENGTH, + "pattern": "^[a-z0-9]", + "not": {"pattern": "[^a-z0-9_-]"}, +} + + +def is_portable_identifier(value: object) -> bool: + """Return whether *value* is one exact portable local identifier.""" + + return isinstance(value, str) and PORTABLE_IDENTIFIER_RE.fullmatch(value) is not None + + +def require_portable_identifier(value: object, *, field_name: str) -> str: + """Return a valid portable identifier or raise a value-only-safe error.""" + + if not is_portable_identifier(value): + raise ValueError( + f"{field_name} must be a portable SDL identifier: 1-64 lowercase " + "ASCII letters, digits, hyphens, or underscores, starting with a letter or digit" + ) + return value + + +def _validate_portable_identifier(value: str) -> str: + return require_portable_identifier(value, field_name="value") + + +def _validate_optional_portable_identifier(value: str) -> str: + return value if value == "" else require_portable_identifier(value, field_name="value") + + +PortableIdentifier = Annotated[ + str, + AfterValidator(_validate_portable_identifier), + WithJsonSchema(PORTABLE_IDENTIFIER_JSON_SCHEMA), +] +OptionalPortableIdentifier = Annotated[ + str, + AfterValidator(_validate_optional_portable_identifier), + WithJsonSchema( + { + "anyOf": [ + {"const": ""}, + PORTABLE_IDENTIFIER_JSON_SCHEMA, + ] + } + ), +] + + +def require_module_identifier(value: object, *, field_name: str = "module.id") -> str: + """Validate the exact ``publisher/name`` module identity shape.""" + + if not isinstance(value, str): + raise ValueError(f"{field_name} must use portable 'publisher/name' format") + parts = value.split("/") + if len(parts) != 2 or any(not is_portable_identifier(part) for part in parts): + raise ValueError(f"{field_name} must use portable 'publisher/name' format") + return value + + +@dataclass(frozen=True, order=True) +class QualifiedName: + """A composition-generated namespace path followed by one local symbol.""" + + parts: tuple[str, ...] + + def __post_init__(self) -> None: + if not self.parts or not is_portable_identifier(self.parts[-1]): + raise ValueError("qualified SDL name must end with a portable local identifier") + for part in self.parts[:-1]: + if part != PRIVATE_NAMESPACE_SEGMENT and not is_portable_identifier(part): + raise ValueError("qualified SDL namespace contains an invalid segment") + if len(self.render()) > QUALIFIED_IDENTIFIER_MAX_LENGTH: + raise ValueError("qualified SDL name exceeds the maximum length") + + @classmethod + def parse(cls, value: object) -> QualifiedName: + if not isinstance(value, str): + raise ValueError("qualified SDL name must be a string") + return cls(tuple(value.split("."))) + + @classmethod + def local(cls, value: object) -> QualifiedName: + return cls((require_portable_identifier(value, field_name="local identifier"),)) + + def prefixed(self, namespace: object, *, private: bool = False) -> QualifiedName: + segment = require_portable_identifier(namespace, field_name="namespace") + prefix = (segment, PRIVATE_NAMESPACE_SEGMENT) if private else (segment,) + return QualifiedName((*prefix, *self.parts)) + + def render(self) -> str: + return ".".join(self.parts) + + +def require_qualified_identifier(value: object, *, field_name: str) -> str: + """Validate one composition-generated qualified symbol spelling.""" + + try: + return QualifiedName.parse(value).render() + except ValueError as exc: + raise ValueError(f"{field_name} must be a qualified SDL identifier") from exc + + +__all__ = [ + "OptionalPortableIdentifier", + "PORTABLE_IDENTIFIER_JSON_SCHEMA", + "PORTABLE_IDENTIFIER_MAX_LENGTH", + "PORTABLE_IDENTIFIER_PATTERN", + "PRIVATE_NAMESPACE_SEGMENT", + "QUALIFIED_IDENTIFIER_MAX_LENGTH", + "PortableIdentifier", + "QualifiedName", + "is_portable_identifier", + "require_module_identifier", + "require_portable_identifier", + "require_qualified_identifier", +] diff --git a/implementations/python/packages/aces_sdl/_language_references.py b/implementations/python/packages/aces_sdl/_language_references.py index ca6091290..e4a0d0186 100644 --- a/implementations/python/packages/aces_sdl/_language_references.py +++ b/implementations/python/packages/aces_sdl/_language_references.py @@ -7,7 +7,9 @@ from yaml.nodes import MappingNode, Node, ScalarNode, SequenceNode +from ._declarations import DeclarationIndex from ._errors import SDLParseError +from ._identifiers import QualifiedName from ._language_diagnostics import parse_error as _parse_error from ._language_metadata import REFERENCE_COMPLETION_TARGETS from ._reference_targetability import is_targetable_section @@ -21,22 +23,50 @@ def find_references( symbol: str, *, section_fields: Collection[str], + declaration_index: DeclarationIndex | None = None, ) -> dict[str, Any]: """Return definition and occurrence locations for an SDL symbol.""" if not sdl_content.strip(): result = {"status": "ok", "symbol": symbol, "definitions": [], "occurrences": []} else: root, error = _compose_yaml(sdl_content) - result = error if error is not None else _reference_result(root, symbol, section_fields) + result = ( + error + if error is not None + else _reference_result(root, symbol, section_fields, declaration_index=declaration_index) + ) return result -def _reference_result(root: Node | None, symbol: str, section_fields: Collection[str]) -> dict[str, Any]: +def _reference_result( + root: Node | None, + symbol: str, + section_fields: Collection[str], + *, + declaration_index: DeclarationIndex | None, +) -> dict[str, Any]: if root is None: return {"status": "ok", "symbol": symbol, "definitions": [], "occurrences": []} definitions = _collect_definitions(root, section_fields) + if declaration_index is not None: + definitions = [ + definition + for definition in definitions + if declaration_index.declaration_for(definition["qualified_name"]) is not None + ] occurrences: list[dict[str, Any]] = [] - _collect_occurrences(root, symbol, [], occurrences, qualified_section=_qualified_symbol_section(symbol)) + spellings = ( + declaration_index.spellings_for(symbol) + if declaration_index is not None + else frozenset({symbol, _bare_symbol(symbol)}) + ) + _collect_occurrences( + root, + spellings, + [], + occurrences, + qualified_section=_qualified_symbol_section(symbol), + ) return { "status": "ok", "symbol": symbol, @@ -101,7 +131,7 @@ def _collect_section_definitions( def _collect_occurrences( node: Node, - symbol: str, + spellings: Collection[str], path: list[str], occurrences: list[dict[str, Any]], *, @@ -110,7 +140,7 @@ def _collect_occurrences( if isinstance(node, MappingNode): _collect_mapping_occurrences( node, - symbol, + spellings, path, occurrences, qualified_section=qualified_section, @@ -120,7 +150,7 @@ def _collect_occurrences( if isinstance(node, SequenceNode): _collect_sequence_occurrences( node, - symbol, + spellings, path, occurrences, qualified_section=qualified_section, @@ -130,7 +160,7 @@ def _collect_occurrences( if isinstance(node, ScalarNode): _append_scalar_occurrence( node, - symbol, + spellings, path, occurrences, qualified_section=qualified_section, @@ -139,7 +169,7 @@ def _collect_occurrences( def _collect_mapping_occurrences( node: MappingNode, - symbol: str, + spellings: Collection[str], path: list[str], occurrences: list[dict[str, Any]], *, @@ -150,7 +180,7 @@ def _collect_mapping_occurrences( key_path = [*path, str(key)] if key is not None else [*path, "?"] if _is_matching_occurrence( key, - symbol, + spellings, key_path, qualified_section=qualified_section, mapping_key=True, @@ -164,7 +194,7 @@ def _collect_mapping_occurrences( ) _collect_occurrences( value_node, - symbol, + spellings, key_path, occurrences, qualified_section=qualified_section, @@ -173,7 +203,7 @@ def _collect_mapping_occurrences( def _collect_sequence_occurrences( node: SequenceNode, - symbol: str, + spellings: Collection[str], path: list[str], occurrences: list[dict[str, Any]], *, @@ -182,7 +212,7 @@ def _collect_sequence_occurrences( for index, item in enumerate(node.value): _collect_occurrences( item, - symbol, + spellings, [*path, str(index)], occurrences, qualified_section=qualified_section, @@ -191,7 +221,7 @@ def _collect_sequence_occurrences( def _append_scalar_occurrence( node: ScalarNode, - symbol: str, + spellings: Collection[str], path: list[str], occurrences: list[dict[str, Any]], *, @@ -200,7 +230,7 @@ def _append_scalar_occurrence( value = _scalar_value(node) if _is_matching_occurrence( value, - symbol, + spellings, path, qualified_section=qualified_section, mapping_key=False, @@ -216,7 +246,7 @@ def _append_scalar_occurrence( def _is_matching_occurrence( value: str | None, - symbol: str, + spellings: Collection[str], path: list[str], *, qualified_section: str | None, @@ -224,7 +254,7 @@ def _is_matching_occurrence( ) -> bool: return ( value is not None - and _matches_symbol(value, symbol) + and value in spellings and _include_occurrence( path, qualified_section=qualified_section, @@ -264,10 +294,6 @@ def _scalar_value(node: Node) -> str | None: return None -def _matches_symbol(value: str, symbol: str) -> bool: - return value == symbol or value == _bare_symbol(symbol) - - def _definition_matches_symbol(definition: dict[str, Any], symbol: str) -> bool: qualified_section = _qualified_symbol_section(symbol) if qualified_section is not None: @@ -276,9 +302,11 @@ def _definition_matches_symbol(definition: dict[str, Any], symbol: str) -> bool: def _qualified_symbol_section(symbol: str) -> str | None: - if "." not in symbol: + try: + parts = QualifiedName.parse(symbol).parts + except (TypeError, ValueError): return None - return symbol.split(".", 1)[0] + return parts[0] if len(parts) > 1 else None def _include_occurrence( @@ -308,7 +336,10 @@ def _reference_target_for_path(path: list[str], *, mapping_key: bool) -> str | N def _bare_symbol(symbol: str) -> str: - return symbol.rsplit(".", 1)[-1] + try: + return QualifiedName.parse(symbol).parts[-1] + except (TypeError, ValueError): + return symbol def _range_from_node(node: Node) -> dict[str, dict[str, int]]: diff --git a/implementations/python/packages/aces_sdl/_module_symbols.py b/implementations/python/packages/aces_sdl/_module_symbols.py index 9c9fef841..f279c3a4b 100644 --- a/implementations/python/packages/aces_sdl/_module_symbols.py +++ b/implementations/python/packages/aces_sdl/_module_symbols.py @@ -3,8 +3,11 @@ from __future__ import annotations from collections.abc import Mapping +from contextlib import suppress from typing import Any +from ._base import is_variable_ref +from ._identifiers import QualifiedName from ._module_runtime_aliases import nested_node_runtime_aliases from .entities import flatten_entities from .scenario import ModuleDescriptor, Scenario @@ -37,14 +40,30 @@ "workflows", ) _HASHMAP_SECTIONS = HASHMAP_SECTIONS +FORWARDING_AGENTS_SECTION = "forwarding_agents" def _prefix(namespace: str, name: str) -> str: - return f"{namespace}.{name}" if namespace else name + return QualifiedName.parse(name).prefixed(namespace).render() if namespace else QualifiedName.parse(name).render() def _private_prefix(namespace: str, name: str) -> str: - return _prefix(namespace, f"__private.{name}") + return QualifiedName.parse(name).prefixed(namespace, private=True).render() + + +def rewrite_objective_window_ref(ref: str, workflow_names: Mapping[str, str]) -> str: + """Rewrite a workflow-step window reference through a symbol map.""" + + rewritten = ref + parts: tuple[str, ...] = () + if not is_variable_ref(ref): + with suppress(TypeError, ValueError): + parts = QualifiedName.parse(ref).parts + if len(parts) >= 2: + workflow_name = QualifiedName(parts[:-1]).render() + if workflow_name in workflow_names: + rewritten = f"{workflow_names[workflow_name]}.{parts[-1]}" + return rewritten def explicit_exports( @@ -156,6 +175,13 @@ def symbol_index( named.update(nested_node_runtime_aliases(scenario, section_maps.get("nodes", {}))) named.update(_nested_content_item_aliases(scenario, section_maps.get("content", {}))) + forwarding_agent_map = _section_rename_map( + {agent.forwarding_agent_id: agent for agent in scenario.forwarding_agents}, + namespace=namespace, + exported_names=exported.get(FORWARDING_AGENTS_SECTION, set()), + ) + named.update(_qualified_section_aliases(FORWARDING_AGENTS_SECTION, forwarding_agent_map)) + return { "nodes": section_maps.get("nodes", {}), "infrastructure": section_maps.get("infrastructure", {}), @@ -178,8 +204,9 @@ def symbol_index( "evidence_requirements": section_maps.get("evidence_requirements", {}), "objectives": section_maps.get("objectives", {}), "workflows": section_maps.get("workflows", {}), + FORWARDING_AGENTS_SECTION: forwarding_agent_map, "named": named, } -__all__ = ["HASHMAP_SECTIONS", "explicit_exports", "symbol_index"] +__all__ = ["FORWARDING_AGENTS_SECTION", "HASHMAP_SECTIONS", "explicit_exports", "symbol_index"] diff --git a/implementations/python/packages/aces_sdl/_reference_targetability.py b/implementations/python/packages/aces_sdl/_reference_targetability.py index 7cbda54c5..2a5e87558 100644 --- a/implementations/python/packages/aces_sdl/_reference_targetability.py +++ b/implementations/python/packages/aces_sdl/_reference_targetability.py @@ -5,12 +5,7 @@ NON_TARGETABLE_REFERENCE_SECTIONS = frozenset({"variables", "evidence_requirements", "objectives", "workflows"}) -def is_targetable_reference(candidate: str) -> bool: - """Return whether a qualified declaration can be a generic target.""" - section, separator, _ = candidate.partition(".") - return bool(separator) and section not in NON_TARGETABLE_REFERENCE_SECTIONS - - def is_targetable_section(section: str) -> bool: - """Return whether declarations in a top-level section are targetable.""" + """Return whether top-level declarations in a section are targetable.""" + return section not in NON_TARGETABLE_REFERENCE_SECTIONS diff --git a/implementations/python/packages/aces_sdl/_runtime_service_families.py b/implementations/python/packages/aces_sdl/_runtime_service_families.py index b8942b970..a08be46ef 100644 --- a/implementations/python/packages/aces_sdl/_runtime_service_families.py +++ b/implementations/python/packages/aces_sdl/_runtime_service_families.py @@ -5,7 +5,6 @@ from collections.abc import Iterable, Mapping, MutableMapping from dataclasses import dataclass from types import ModuleType -from typing import Any from . import runtime_app_authorization as _runtime_app_authorization from . import runtime_application as _runtime_application @@ -50,6 +49,18 @@ def public_symbols(self) -> tuple[str, ...]: return tuple(getattr(self.module, "__all__", ())) +@dataclass(frozen=True) +class RuntimeFamilyReference: + """One exact qualified address in a node runtime inventory.""" + + address: str + node_name: str + family: RuntimeServiceFamily + item: object + owning_item: object + collection_path: tuple[str, ...] = () + + RUNTIME_SERVICE_FAMILIES: tuple[RuntimeServiceFamily, ...] = ( RuntimeServiceFamily( key="service-listeners", @@ -262,18 +273,18 @@ def runtime_service_family_export_names() -> tuple[str, ...]: return tuple(names) -def runtime_service_family_exports() -> dict[str, Any]: +def runtime_service_family_exports() -> dict[str, object]: """Return the public model symbols exported by all registered families.""" runtime_service_family_export_names() - exports: dict[str, Any] = {} + exports: dict[str, object] = {} for family in RUNTIME_SERVICE_FAMILIES: for name in family.public_symbols: exports[name] = getattr(family.module, name) return exports -def install_runtime_service_family_exports(namespace: MutableMapping[str, Any]) -> tuple[str, ...]: +def install_runtime_service_family_exports(namespace: MutableMapping[str, object]) -> tuple[str, ...]: """Install family symbols into a facade module namespace.""" exports = runtime_service_family_exports() @@ -284,22 +295,85 @@ def install_runtime_service_family_exports(namespace: MutableMapping[str, Any]) def collect_qualified_runtime_family_refs( - scenario: Any, + scenario: object, *, family_keys: Iterable[str] | None = None, ) -> set[str]: """Return targetable qualified refs for all registered runtime families.""" - refs: set[str] = set() + return {reference.address for reference in iter_runtime_family_references(scenario, family_keys=family_keys)} + + +def iter_runtime_family_references( + scenario: object, + *, + family_keys: Iterable[str] | None = None, +) -> Iterable[RuntimeFamilyReference]: + """Yield registered runtime declarations without decoding rendered addresses.""" + selected = _selected_family_keys(family_keys) for node_name, _prefixed_node, runtime in _runtime_instances(scenario, {}): for family in _families(selected): - refs.update(_runtime_family_refs(node_name=node_name, runtime=runtime, family=family)) - return refs + for item in getattr(runtime, family.collection_name, []): + item_id = getattr(item, family.id_field, "") + if not item_id: + continue + base = f"nodes.{node_name}.runtime.{family.collection_name}.{item_id}" + yield RuntimeFamilyReference( + address=base, + node_name=node_name, + family=family, + item=item, + owning_item=item, + ) + yield from _iter_child_references( + item, + base=base, + node_name=node_name, + family=family, + owning_item=item, + collection_path=(), + child_specs=family.child_refs, + ) + + +def _iter_child_references( + item: object, + *, + base: str, + node_name: str, + family: RuntimeServiceFamily, + owning_item: object, + collection_path: tuple[str, ...], + child_specs: tuple[RuntimeReferenceChild, ...], +) -> Iterable[RuntimeFamilyReference]: + for child_spec in child_specs: + for child in getattr(item, child_spec.collection_name, []): + child_id = getattr(child, child_spec.id_field, "") + if not child_id: + continue + child_base = f"{base}.{child_spec.collection_name}.{child_id}" + yield RuntimeFamilyReference( + address=child_base, + node_name=node_name, + family=family, + item=child, + owning_item=owning_item, + collection_path=(*collection_path, child_spec.collection_name), + ) + yield from _iter_child_references( + child, + base=child_base, + node_name=node_name, + family=family, + owning_item=owning_item, + collection_path=(*collection_path, child_spec.collection_name), + child_specs=child_spec.children, + ) def nested_node_runtime_family_aliases( - scenario: Any, + scenario: object, node_rename_map: Mapping[str, str], *, family_keys: Iterable[str] | None = None, @@ -336,17 +410,22 @@ def _families(selected: set[str] | None) -> Iterable[RuntimeServiceFamily]: def _runtime_instances( - scenario: Any, + scenario: object, node_rename_map: Mapping[str, str], -) -> Iterable[tuple[str, str, Any]]: - for node_name, node in scenario.nodes.items(): +) -> Iterable[tuple[str, str, object]]: + nodes = getattr(scenario, "nodes", {}) + if not isinstance(nodes, Mapping): + return + for node_name, node in nodes.items(): + if not isinstance(node_name, str): + continue prefixed_node = node_rename_map.get(node_name, node_name) runtime = getattr(node, "runtime", None) if runtime is not None: yield node_name, prefixed_node, runtime -def _runtime_family_refs(*, node_name: str, runtime: Any, family: RuntimeServiceFamily) -> set[str]: +def _runtime_family_refs(*, node_name: str, runtime: object, family: RuntimeServiceFamily) -> set[str]: refs: set[str] = set() for item in getattr(runtime, family.collection_name, []): item_id = getattr(item, family.id_field, "") @@ -358,7 +437,7 @@ def _runtime_family_refs(*, node_name: str, runtime: Any, family: RuntimeService return refs -def _child_refs(item: Any, base: str, child_specs: tuple[RuntimeReferenceChild, ...]) -> set[str]: +def _child_refs(item: object, base: str, child_specs: tuple[RuntimeReferenceChild, ...]) -> set[str]: refs: set[str] = set() for child_spec in child_specs: for child in getattr(item, child_spec.collection_name, []): @@ -375,7 +454,7 @@ def _runtime_family_aliases( *, node_name: str, prefixed_node: str, - runtime: Any, + runtime: object, family: RuntimeServiceFamily, ) -> dict[str, str]: aliases: dict[str, str] = {} @@ -391,7 +470,7 @@ def _runtime_family_aliases( def _child_aliases( - item: Any, + item: object, bare_base: str, prefixed_base: str, child_specs: tuple[RuntimeReferenceChild, ...], @@ -411,10 +490,12 @@ def _child_aliases( __all__ = [ "RUNTIME_SERVICE_FAMILIES", + "RuntimeFamilyReference", "RuntimeReferenceChild", "RuntimeServiceFamily", "collect_qualified_runtime_family_refs", "install_runtime_service_family_exports", + "iter_runtime_family_references", "nested_node_runtime_family_aliases", "runtime_service_family_export_names", "runtime_service_family_exports", diff --git a/implementations/python/packages/aces_sdl/_source_identifier_paths.py b/implementations/python/packages/aces_sdl/_source_identifier_paths.py new file mode 100644 index 000000000..5848dc8fe --- /dev/null +++ b/implementations/python/packages/aces_sdl/_source_identifier_paths.py @@ -0,0 +1,70 @@ +"""Source-pointer classification for SDL declaration identities.""" + +from ._mapping_scopes import HASHMAP_SECTIONS +from ._runtime_service_families import RUNTIME_SERVICE_FAMILIES, RuntimeReferenceChild, RuntimeServiceFamily + + +def is_declaration_key_path(tokens: list[str]) -> bool: + return _is_flat_declaration_scope(tokens) or _is_nested_entity_scope(tokens) + + +def _is_flat_declaration_scope(tokens: list[str]) -> bool: + if len(tokens) == 1: + return tokens[0] in HASHMAP_SECTIONS + if len(tokens) == 3: + return (tokens[0], tokens[2]) in {("nodes", "roles"), ("workflows", "steps")} + return False + + +def _is_nested_entity_scope(tokens: list[str]) -> bool: + return ( + bool(tokens) + and tokens[0] == "entities" + and tokens[-1] == "entities" + and all(segment == "entities" for segment in tokens[::2]) + ) + + +def is_scalar_identifier_path(tokens: list[str]) -> bool: + if len(tokens) == 3 and tokens[0] == "forwarding_agents" and tokens[1].isdigit(): + return tokens[2] == "forwarding_agent_id" + if len(tokens) == 5 and tokens[3].isdigit(): + return (tokens[0], tokens[2], tokens[4]) in { + ("nodes", "services", "name"), + ("infrastructure", "acls", "name"), + ("content", "items", "name"), + } + return _is_registered_runtime_identifier_path(tokens) + + +def _is_registered_runtime_identifier_path(tokens: list[str]) -> bool: + matched = False + for family in RUNTIME_SERVICE_FAMILIES: + suffix = _runtime_identifier_suffix(tokens, family) + if suffix is not None and (not suffix or _matches_runtime_child_path(suffix, family.child_refs)): + matched = True + break + return matched + + +def _runtime_identifier_suffix(tokens: list[str], family: RuntimeServiceFamily) -> list[str] | None: + if len(tokens) >= 6: + node_root = (tokens[0], tokens[2], tokens[3], tokens[5]) + expected = ("nodes", "runtime", family.collection_name, family.id_field) + if node_root == expected and tokens[4].isdigit(): + return tokens[6:] + if family.collection_name == "forwarding_agents" and len(tokens) >= 3: + forwarding_root = (tokens[0], tokens[2]) + if forwarding_root == ("forwarding_agents", family.id_field) and tokens[1].isdigit(): + return tokens[3:] + return None + + +def _matches_runtime_child_path(tokens: list[str], child_specs: tuple[RuntimeReferenceChild, ...]) -> bool: + if len(tokens) < 3 or not tokens[1].isdigit(): + return False + for child in child_specs: + if tokens[0] != child.collection_name or tokens[2] != child.id_field: + continue + return len(tokens) == 3 or _matches_runtime_child_path(tokens[3:], child.children) + return False diff --git a/implementations/python/packages/aces_sdl/_source_profile.py b/implementations/python/packages/aces_sdl/_source_profile.py index 7af2b0000..c7f1e0117 100644 --- a/implementations/python/packages/aces_sdl/_source_profile.py +++ b/implementations/python/packages/aces_sdl/_source_profile.py @@ -22,7 +22,7 @@ class SDLMigrationPolicy(str, Enum): @dataclass(frozen=True) class SDLParserLimits: - """Operational work limits for one SDL YAML source document.""" + """Operational work limits for one source and its composition graph.""" max_input_bytes: int = 8 * 1024 * 1024 max_scalar_bytes: int = 1024 * 1024 @@ -30,6 +30,11 @@ class SDLParserLimits: max_nodes: int = 100_000 max_aliases: int = 256 max_expanded_nodes: int = 250_000 + max_imports: int = 256 + max_composed_nodes: int = 500_000 + max_composed_bytes: int = 32 * 1024 * 1024 + max_composition_depth: int = 32 + max_namespace_depth: int = 32 def __post_init__(self) -> None: for name, value in vars(self).items(): diff --git a/implementations/python/packages/aces_sdl/_yaml_loader.py b/implementations/python/packages/aces_sdl/_yaml_loader.py index f984aad24..3fa3bd918 100644 --- a/implementations/python/packages/aces_sdl/_yaml_loader.py +++ b/implementations/python/packages/aces_sdl/_yaml_loader.py @@ -16,7 +16,9 @@ SDLSourcePosition, SDLSourceRange, ) +from ._identifiers import is_portable_identifier from ._mapping_scopes import MappingScope, is_literal_map_field, normalize_field_key +from ._source_identifier_paths import is_declaration_key_path, is_scalar_identifier_path from ._source_profile import ( DEFAULT_SOURCE_PARSE_OPTIONS, SDLMigrationPolicy, @@ -77,13 +79,20 @@ def build(self) -> _EffectiveMapping: class _MappingAnalyzer: - def __init__(self, *, migration_policy: SDLMigrationPolicy, path: Path | None) -> None: + def __init__( + self, + *, + migration_policy: SDLMigrationPolicy, + path: Path | None, + source_ranges: dict[str, SDLSourceRange] | None = None, + ) -> None: self.diagnostics: list[SDLParseDiagnostic] = [] self._migration_policy = migration_policy self._source = str(path) if path is not None else None self._effective_cache: dict[tuple[int, MappingScope], _EffectiveMapping] = {} self._diagnostic_keys: set[tuple[Any, ...]] = set() self._walked: set[tuple[int, MappingScope]] = set() + self._source_ranges = source_ranges def analyze( self, @@ -113,6 +122,8 @@ def _walk( tokens: list[str], active: set[int], ) -> None: + if self._source_ranges is not None: + self._source_ranges[_encode_pointer(tokens)] = _range_from_node(node) identity = id(node) if identity in active: self._add_alias_cycle(node, tokens) @@ -220,8 +231,46 @@ def _walk_mapping_entry( pointer=_encode_pointer([*tokens, canonical]), authored_keys=(authored, canonical), ) + child_tokens = [*tokens, canonical] + if is_declaration_key_path(tokens) and not suppress_field_migration: + self._validate_identifier_node(key_node, pointer_tokens=child_tokens) + if tokens == ["nodes"] and len(authored) > 35: + self._add_identifier_diagnostic(key_node, pointer_tokens=child_tokens, node_limit=True) + if child_tokens == ["name"]: + self._validate_identifier_node(value_node, pointer_tokens=child_tokens) + if is_scalar_identifier_path(child_tokens): + self._validate_identifier_node(value_node, pointer_tokens=child_tokens) child_scope = _child_scope(scope, canonical, value_node) - self._walk(value_node, scope=child_scope, tokens=[*tokens, canonical], active=active) + self._walk(value_node, scope=child_scope, tokens=child_tokens, active=active) + + def _validate_identifier_node(self, node: Node, *, pointer_tokens: list[str]) -> None: + if not isinstance(node, ScalarNode) or node.tag != _STRING_TAG or not is_portable_identifier(node.value): + self._add_identifier_diagnostic(node, pointer_tokens=pointer_tokens) + + def _add_identifier_diagnostic( + self, + node: Node, + *, + pointer_tokens: list[str], + node_limit: bool = False, + ) -> None: + message = ( + "Authored node identifiers must be at most 35 characters." + if node_limit + else ( + "Authored identifiers must be 1-64 lowercase ASCII letters, digits, hyphens, or " + "underscores and start with a letter or digit." + ) + ) + self._add( + SDLParseDiagnostic( + code="sdl.identifier.invalid", + message=message, + pointer=_encode_pointer(pointer_tokens), + primary_range=_range_from_node(node), + source=self._source, + ) + ) def _add_key_type_diagnostic(self, key_node: Node, authored: str, tokens: list[str]) -> None: message = ( @@ -372,6 +421,7 @@ def load_sdl_yaml( base_pointer: str = "", source_options: SDLSourceParseOptions = DEFAULT_SOURCE_PARSE_OPTIONS, source_diagnostics: list[SDLParseDiagnostic] | None = None, + source_ranges: dict[str, SDLSourceRange] | None = None, ) -> object: """Validate and safely construct one SDL YAML document or fragment.""" prepared = prepare_content(content, path=path) @@ -392,6 +442,7 @@ def load_sdl_yaml( base_pointer=base_pointer, migration_policy=policy, source_diagnostics=source_diagnostics, + source_ranges=source_ranges, ) constructed = loader.construct_document(root) validate_constructed_domain(constructed, path=path) @@ -413,6 +464,7 @@ def compose_sdl_yaml( base_pointer: str = "", source_options: SDLSourceParseOptions = DEFAULT_SOURCE_PARSE_OPTIONS, source_diagnostics: list[SDLParseDiagnostic] | None = None, + source_ranges: dict[str, SDLSourceRange] | None = None, ) -> Node: """Compose and key-validate SDL YAML while retaining source nodes.""" prepared = prepare_content(content, path=path) @@ -433,6 +485,7 @@ def compose_sdl_yaml( base_pointer=base_pointer, migration_policy=policy, source_diagnostics=source_diagnostics, + source_ranges=source_ranges, ) return root except SDLParseError: @@ -452,8 +505,13 @@ def _validate_mapping_keys( base_pointer: str, migration_policy: SDLMigrationPolicy, source_diagnostics: list[SDLParseDiagnostic] | None, + source_ranges: dict[str, SDLSourceRange] | None, ) -> None: - diagnostics = _MappingAnalyzer(migration_policy=migration_policy, path=path).analyze( + diagnostics = _MappingAnalyzer( + migration_policy=migration_policy, + path=path, + source_ranges=source_ranges, + ).analyze( root, scope=scope, base_tokens=_decode_pointer(base_pointer), diff --git a/implementations/python/packages/aces_sdl/composition.py b/implementations/python/packages/aces_sdl/composition.py index 36d4dfd22..2976ee4e4 100644 --- a/implementations/python/packages/aces_sdl/composition.py +++ b/implementations/python/packages/aces_sdl/composition.py @@ -7,7 +7,9 @@ from typing import Any from ._base import is_variable_ref +from ._composition_budget import CompositionBudget, CompositionTraversal from ._errors import SDLInstantiationError, SDLParseDiagnostic, SDLParseError +from ._identifiers import QualifiedName from ._module_provenance import ( add_unique_provenance as _add_unique_provenance, ) @@ -17,8 +19,10 @@ from ._module_provenance import ( rename_variable_ref, ) +from ._module_symbols import FORWARDING_AGENTS_SECTION +from ._module_symbols import HASHMAP_SECTIONS as _HASHMAP_SECTIONS from ._module_symbols import ( - HASHMAP_SECTIONS as _HASHMAP_SECTIONS, + rewrite_objective_window_ref as _rewrite_objective_window_ref, ) from ._module_symbols import ( symbol_index as _symbol_index, @@ -38,15 +42,15 @@ resolve_import, ) from .parser import _load_normalized_data -from .scenario import ImportDecl, ModuleDescriptor, Scenario +from .scenario import ExpandedScenario, ImportDecl, ModuleDescriptor, Scenario def _prefix(namespace: str, name: str) -> str: - return f"{namespace}.{name}" if namespace else name + return QualifiedName.parse(name).prefixed(namespace).render() if namespace else QualifiedName.parse(name).render() def _private_prefix(namespace: str, name: str) -> str: - return _prefix(namespace, f"__private.{name}") + return QualifiedName.parse(name).prefixed(namespace, private=True).render() def _maybe_rename(name: str, name_map: Mapping[str, str]) -> str: @@ -60,7 +64,13 @@ def _validate_descriptor_exports( descriptor: ModuleDescriptor, ) -> None: for section_name, exported_names in descriptor.exports.items(): - if section_name == "entities": + if section_name not in {*_HASHMAP_SECTIONS, FORWARDING_AGENTS_SECTION}: + raise SDLParseError(f"Module '{descriptor.id}' exports unknown SDL section '{section_name}'") + for exported_name in exported_names: + QualifiedName.parse(exported_name) + if section_name == FORWARDING_AGENTS_SECTION: + available_names = {agent.forwarding_agent_id for agent in scenario.forwarding_agents} + elif section_name == "entities": available_names = set(flatten_entities(scenario.entities)) else: section_payload = getattr(scenario, section_name, None) @@ -119,15 +129,6 @@ def _rewrite_entity(payload: dict[str, Any], symbols: dict[str, dict[str, str] | _rewrite_entity(child, symbols) -def _rewrite_objective_window_ref(ref: str, workflow_names: Mapping[str, str]) -> str: - if "." not in ref or is_variable_ref(ref): - return ref - workflow_name, step_name = ref.rsplit(".", 1) - if workflow_name not in workflow_names: - return ref - return f"{workflow_names[workflow_name]}.{step_name}" - - def _rewrite_workflow(payload: dict[str, Any], symbols: dict[str, dict[str, str] | set[str]]) -> None: for step in payload.get("steps", {}).values(): if not isinstance(step, dict): @@ -215,6 +216,12 @@ def _namespace_payload( relationship["source"] = _maybe_rename(str(relationship["source"]), symbols["named"]) if relationship.get("target"): relationship["target"] = _maybe_rename(str(relationship["target"]), symbols["named"]) + forwarding_edge = relationship.get("forwarding_edge") + if isinstance(forwarding_edge, dict) and forwarding_edge.get("forwarder_ref"): + forwarding_edge["forwarder_ref"] = _maybe_rename( + str(forwarding_edge["forwarder_ref"]), + symbols[FORWARDING_AGENTS_SECTION], + ) for agent in namespaced.get("agents", {}).values(): if isinstance(agent, dict): if agent.get("entity"): @@ -309,6 +316,17 @@ def _namespace_payload( namespaced[section_name] = { symbols[section_name].get(name, _prefix(namespace, name)): value for name, value in section_payload.items() } + forwarding_agents = namespaced.get(FORWARDING_AGENTS_SECTION, []) + if isinstance(forwarding_agents, list): + for agent in forwarding_agents: + if not isinstance(agent, dict): + continue + identifier = agent.get("forwarding_agent_id") + if isinstance(identifier, str): + agent["forwarding_agent_id"] = symbols[FORWARDING_AGENTS_SECTION].get( + identifier, + _private_prefix(namespace, identifier), + ) namespaced["variables"] = {} namespaced["module"] = None namespaced["imports"] = [] @@ -330,6 +348,14 @@ def _merge_sections( raise SDLParseError(f"Import from {path} collides on {section_name}: {', '.join(collisions)}") current.update(additions) merged[section_name] = current + current_agents = list(merged.get(FORWARDING_AGENTS_SECTION, [])) + incoming_agents = list(incoming.get(FORWARDING_AGENTS_SECTION, [])) + current_ids = {agent.get("forwarding_agent_id") for agent in current_agents if isinstance(agent, dict)} + incoming_ids = {agent.get("forwarding_agent_id") for agent in incoming_agents if isinstance(agent, dict)} + collisions = sorted(identifier for identifier in current_ids.intersection(incoming_ids) if identifier) + if collisions: + raise SDLParseError(f"Import from {path} collides on {FORWARDING_AGENTS_SECTION}: {', '.join(collisions)}") + merged[FORWARDING_AGENTS_SECTION] = [*current_agents, *incoming_agents] merged["imports"] = [] return merged @@ -344,11 +370,11 @@ def expand_sdl_modules( data: dict[str, Any], *, path: Path, - seen: set[Path] | None = None, source_format: str = SDL_SOURCE_FORMAT, migration_policy: SDLMigrationPolicy | str = SDLMigrationPolicy.REJECT, limits: SDLParserLimits = DEFAULT_PARSER_LIMITS, source_diagnostics: list[SDLParseDiagnostic] | None = None, + _traversal: CompositionTraversal | None = None, ) -> tuple[ dict[str, Any], dict[str, str], @@ -367,11 +393,18 @@ def expand_sdl_modules( node and variable names namespace-prefixed) for downstream consumers. """ - seen = set() if seen is None else set(seen) + traversal = _traversal or CompositionTraversal( + seen=frozenset(), + budget=CompositionBudget(limits), + depth=0, + ) + budget = traversal.budget + budget.check_depth(traversal.depth, path=path) + budget.add_document(data, path=path) resolved_path = path.resolve() - if resolved_path in seen: + if resolved_path in traversal.seen: raise SDLParseError(f"Import cycle detected at {resolved_path}", path=path) - seen.add(resolved_path) + child_traversal = traversal.descend_from(resolved_path) merged = dict(data) merged.setdefault("imports", []) @@ -383,6 +416,7 @@ def expand_sdl_modules( trust_policy = load_trust_policy(resolved_path.parent) for raw_import in list(merged.get("imports", [])): + budget.add_import(path=path) import_decl = _import_decl(raw_import) if "__private." in import_decl.namespace: raise SDLParseError( @@ -418,14 +452,14 @@ def expand_sdl_modules( ) = expand_sdl_modules( imported_raw, path=import_path, - seen=seen, source_format=source_format, migration_policy=migration_policy, limits=limits, source_diagnostics=source_diagnostics, + _traversal=child_traversal, ) try: - imported_scenario = Scenario.model_validate(imported_expanded) + imported_scenario = ExpandedScenario.model_validate(imported_expanded) # Re-attach the deeper-import provenance so `instantiate_scenario` # can propagate it onto the `InstantiatedScenario` alongside # whatever local refs it captures. @@ -439,18 +473,11 @@ def expand_sdl_modules( except SDLInstantiationError as exc: raise SDLParseError(str(exc), path=import_path) from exc imported_instantiated.module = resolved_import.module_descriptor - namespace = import_decl.namespace or resolved_import.module_descriptor.id.split("/")[-1] - - descriptor = imported_instantiated.module or ModuleDescriptor( - id=imported_instantiated.name, - version=imported_instantiated.version, - parameters=sorted(imported_instantiated.variables.keys()), - exports={ - section: sorted(getattr(imported_instantiated, section).keys()) - for section in _HASHMAP_SECTIONS - if getattr(imported_instantiated, section) - }, - ) + namespace = import_decl.namespace + + descriptor = imported_instantiated.module + if descriptor is None: + raise SDLParseError("Imported SDL units require an explicit module descriptor", path=import_path) symbols = _symbol_index(imported_instantiated, namespace=namespace, descriptor=descriptor) # Local imported variables get namespace-prefixed (private prefix; they @@ -519,6 +546,7 @@ def expand_sdl_modules( imported_instantiated, namespace, ) + budget.check_namespaces(namespaced_payload, path=import_path) merged = _merge_sections(merged, namespaced_payload, path=import_path) namespaces[str(import_path)] = namespace namespaces.update(imported_namespaces) diff --git a/implementations/python/packages/aces_sdl/content.py b/implementations/python/packages/aces_sdl/content.py index aa8462470..bb1f11b29 100644 --- a/implementations/python/packages/aces_sdl/content.py +++ b/implementations/python/packages/aces_sdl/content.py @@ -15,6 +15,7 @@ from pydantic import Field, field_validator, model_validator from ._base import SDLModel, normalize_enum_value, parse_bool_or_var +from ._identifiers import PortableIdentifier from ._source import Source @@ -29,7 +30,8 @@ class ContentType(str, Enum): class ContentItem(SDLModel): """A single item within a dataset (e.g., one email, one record).""" - name: str + name: PortableIdentifier + display_name: str = "" tags: list[str] = Field(default_factory=list) description: str = "" diff --git a/implementations/python/packages/aces_sdl/entities.py b/implementations/python/packages/aces_sdl/entities.py index 03adc63c0..115402a32 100644 --- a/implementations/python/packages/aces_sdl/entities.py +++ b/implementations/python/packages/aces_sdl/entities.py @@ -10,6 +10,7 @@ from pydantic import Field, field_validator from ._base import SDLModel, parse_enum_or_var +from ._identifiers import PortableIdentifier class ExerciseRole(str, Enum): @@ -43,7 +44,7 @@ def normalize_role(cls, v): vulnerabilities: list[str] = Field(default_factory=list) facts: dict[str, str] = Field(default_factory=dict) events: list[str] = Field(default_factory=list) - entities: dict[str, "Entity"] = Field(default_factory=dict) + entities: dict[PortableIdentifier, "Entity"] = Field(default_factory=dict) def flatten_entities(entities: dict[str, Entity], prefix: str = "") -> dict[str, Entity]: diff --git a/implementations/python/packages/aces_sdl/identifiers.py b/implementations/python/packages/aces_sdl/identifiers.py new file mode 100644 index 000000000..836219937 --- /dev/null +++ b/implementations/python/packages/aces_sdl/identifiers.py @@ -0,0 +1,27 @@ +"""Public portable-identifier and qualified-name contracts.""" + +from ._identifiers import ( + PORTABLE_IDENTIFIER_JSON_SCHEMA, + PORTABLE_IDENTIFIER_PATTERN, + QUALIFIED_IDENTIFIER_MAX_LENGTH, + OptionalPortableIdentifier, + PortableIdentifier, + QualifiedName, + is_portable_identifier, + require_module_identifier, + require_portable_identifier, + require_qualified_identifier, +) + +__all__ = [ + "OptionalPortableIdentifier", + "PORTABLE_IDENTIFIER_JSON_SCHEMA", + "PORTABLE_IDENTIFIER_PATTERN", + "PortableIdentifier", + "QUALIFIED_IDENTIFIER_MAX_LENGTH", + "QualifiedName", + "is_portable_identifier", + "require_module_identifier", + "require_portable_identifier", + "require_qualified_identifier", +] diff --git a/implementations/python/packages/aces_sdl/infrastructure.py b/implementations/python/packages/aces_sdl/infrastructure.py index 1185e0973..98bd2e0b7 100644 --- a/implementations/python/packages/aces_sdl/infrastructure.py +++ b/implementations/python/packages/aces_sdl/infrastructure.py @@ -19,6 +19,7 @@ parse_enum_or_var, parse_int_or_var, ) +from ._identifiers import OptionalPortableIdentifier MINIMUM_NODE_COUNT = 1 DEFAULT_NODE_COUNT = 1 @@ -38,7 +39,7 @@ class ACLRule(SDLModel): traffic rules between network segments. """ - name: str = "" + name: OptionalPortableIdentifier = "" direction: str = "" from_net: str = "" to_net: str = "" diff --git a/implementations/python/packages/aces_sdl/language_service.py b/implementations/python/packages/aces_sdl/language_service.py index 7971713b8..585844a8e 100644 --- a/implementations/python/packages/aces_sdl/language_service.py +++ b/implementations/python/packages/aces_sdl/language_service.py @@ -11,7 +11,9 @@ from typing import Any import yaml +from pydantic import ValidationError +from ._declarations import DeclarationIndex, build_declaration_index from ._errors import SDLParseError, SDLValidationError from ._language_diagnostics import diagnostic as _diagnostic from ._language_diagnostics import invalid as _invalid @@ -45,11 +47,12 @@ def language_completions( data, error = _load_completion_data(sdl_content) if error is not None: return error + declaration_index = _declaration_index_from_data(data) pointer = _split_pointer_or_empty(cursor_path) target_section = _completion_target_section(pointer) if target_section is not None: - items = _reference_completion_items(data, target_section) + items = _reference_completion_items(data, target_section, declaration_index=declaration_index) context = f"reference:{target_section}" elif len(pointer) <= 1: existing = set(data) if isinstance(data, dict) else set() @@ -88,7 +91,30 @@ def language_references(sdl_content: str, symbol: str) -> dict[str, Any]: if size_error is not None: return size_error - return find_references(sdl_content, symbol, section_fields=_SECTION_FIELDS) + return find_references( + sdl_content, + symbol, + section_fields=_SECTION_FIELDS, + declaration_index=_try_declaration_index(sdl_content), + ) + + +def _try_declaration_index(sdl_content: str) -> DeclarationIndex | None: + """Return the authoritative index for a complete structural document.""" + + try: + data = _load_normalized_data(sdl_content) + except SDLParseError: + return None + return _declaration_index_from_data(data) + + +def _declaration_index_from_data(data: dict[str, Any]) -> DeclarationIndex | None: + try: + scenario = Scenario.model_validate(data) + except ValidationError: + return None + return build_declaration_index(scenario, raise_on_collision=False) def language_format(sdl_content: str) -> dict[str, Any]: @@ -202,7 +228,27 @@ def _completion_target_section(pointer: list[str]) -> str | None: return None -def _reference_completion_items(data: dict[str, Any], target_section: str) -> list[dict[str, str]]: +def _reference_completion_items( + data: dict[str, Any], + target_section: str, + *, + declaration_index: DeclarationIndex | None, +) -> list[dict[str, str]]: + if declaration_index is not None and target_section in {"any", "targetable"}: + return sorted( + ( + { + "label": spelling, + "kind": "reference", + "detail": declaration.address, + "insert_text": spelling, + } + for spelling, declaration in declaration_index.reference_completions( + targetable=target_section == "targetable" + ) + ), + key=lambda item: (item["detail"], item["label"]), + ) if target_section == "any": sections = _SECTION_FIELDS elif target_section == "targetable": @@ -218,11 +264,14 @@ def _reference_completion_items(data: dict[str, Any], target_section: str) -> li if not isinstance(section_data, dict): continue for name in section_data: + detail = f"{section}.{name}" + if declaration_index is not None and declaration_index.declaration_for(detail) is None: + continue items.append( { "label": str(name), "kind": "reference", - "detail": f"{section}.{name}", + "detail": detail, "insert_text": str(name), } ) diff --git a/implementations/python/packages/aces_sdl/module_registry.py b/implementations/python/packages/aces_sdl/module_registry.py index 034127ae5..bef4f476b 100644 --- a/implementations/python/packages/aces_sdl/module_registry.py +++ b/implementations/python/packages/aces_sdl/module_registry.py @@ -114,36 +114,9 @@ class ResolvedModule: def _scenario_module_descriptor(scenario: Scenario, *, source_id: str) -> ModuleDescriptor: if scenario.module is not None: return scenario.module - normalized_source_id = source_id.replace("\\", "/") - if "/" not in normalized_source_id: - normalized_source_id = f"local/{normalized_source_id}" - return ModuleDescriptor( - id=normalized_source_id, - version=scenario.version, - parameters=sorted(scenario.variables.keys()), - exports={ - section: sorted(getattr(scenario, section).keys()) - for section in ( - "nodes", - "infrastructure", - "features", - "conditions", - "vulnerabilities", - "entities", - "injects", - "events", - "scripts", - "stories", - "content", - "accounts", - "relationships", - "agents", - "objectives", - "workflows", - ) - if getattr(scenario, section) - }, - description=scenario.description, + raise SDLParseError( + "Imported SDL units require an explicit module descriptor", + path=Path(source_id), ) @@ -759,7 +732,7 @@ def resolve_lock_records( records.append( LockRecord( source=import_decl.normalized_source, - namespace=import_decl.namespace or resolved.module_descriptor.id.split("/")[-1], + namespace=import_decl.namespace, requested_version=import_decl.version or "*", resolved_source=resolved.resolved_source, module_id=resolved.module_descriptor.id, diff --git a/implementations/python/packages/aces_sdl/nodes.py b/implementations/python/packages/aces_sdl/nodes.py index f54cf0380..5b2460e19 100644 --- a/implementations/python/packages/aces_sdl/nodes.py +++ b/implementations/python/packages/aces_sdl/nodes.py @@ -15,6 +15,7 @@ parse_enum_or_var, parse_int_or_var, ) +from ._identifiers import OptionalPortableIdentifier, PortableIdentifier from ._runtime_service_families import install_runtime_service_family_exports from ._source import Source from .image_provenance import ( @@ -254,7 +255,7 @@ class ServicePort(SDLModel): port: int | str protocol: str = "tcp" - name: str = "" + name: OptionalPortableIdentifier = "" description: str = "" @field_validator("port", mode="before") @@ -286,7 +287,7 @@ def normalize_type(cls, v: str) -> str: conditions: dict[str, str] = Field(default_factory=dict) injects: dict[str, str] = Field(default_factory=dict) vulnerabilities: list[str] = Field(default_factory=list) - roles: dict[str, Role] = Field(default_factory=dict) + roles: dict[PortableIdentifier, Role] = Field(default_factory=dict) services: list[ServicePort] = Field(default_factory=list) asset_value: AssetValue | None = None runtime: RuntimeConfiguration | None = None diff --git a/implementations/python/packages/aces_sdl/orchestration.py b/implementations/python/packages/aces_sdl/orchestration.py index 93b463c4d..857b109c2 100644 --- a/implementations/python/packages/aces_sdl/orchestration.py +++ b/implementations/python/packages/aces_sdl/orchestration.py @@ -21,6 +21,7 @@ parse_float_or_var, parse_int_or_var, ) +from ._identifiers import PortableIdentifier from ._source import Source # OCR uses duration-str's fixed calendar conversions: 30d/month, 365d/year. @@ -578,7 +579,7 @@ class Workflow(SDLModel): start: str timeout: WorkflowTimeoutPolicy | None = None compensation: WorkflowCompensationPolicy | None = None - steps: dict[str, WorkflowStep] = Field(min_length=1) + steps: dict[PortableIdentifier, WorkflowStep] = Field(min_length=1) @field_validator("timeout", mode="before") @classmethod diff --git a/implementations/python/packages/aces_sdl/parser.py b/implementations/python/packages/aces_sdl/parser.py index 061c827c8..1226a6aa3 100644 --- a/implementations/python/packages/aces_sdl/parser.py +++ b/implementations/python/packages/aces_sdl/parser.py @@ -297,6 +297,7 @@ def parse_sdl( SDLValidationError: If semantic validation finds errors. """ source_diagnostics: list[SDLParseDiagnostic] = [] + source_ranges: dict[str, SDLSourceRange] = {} data = _load_normalized_data( content, path=path, @@ -304,6 +305,7 @@ def parse_sdl( migration_policy=migration_policy, limits=limits, source_diagnostics=source_diagnostics, + source_ranges=source_ranges, ) _reject_removed_scoring_sections(data, path=path) module_variable_specs: dict[str, dict[str, object]] = {} @@ -337,7 +339,7 @@ def parse_sdl( try: scenario = scenario_cls(**data) except ValidationError as e: - raise SDLParseError(str(e), path=path) from e + raise _model_parse_error(e, path=path, source_ranges=source_ranges) from e source_diagnostics = _dedupe_source_diagnostics(source_diagnostics) @@ -389,6 +391,71 @@ def _dedupe_source_diagnostics( return unique +def _pointer_from_location(location: tuple[object, ...]) -> str: + tokens = [str(part) for part in location if str(part) != "[key]"] + return "".join(f"/{token.replace('~', '~0').replace('/', '~1')}" for token in tokens) + + +def _nearest_source_range(pointer: str, source_ranges: dict[str, SDLSourceRange]) -> SDLSourceRange: + candidate = pointer + while candidate: + source_range = source_ranges.get(candidate) + if source_range is not None: + return source_range + candidate = candidate.rsplit("/", 1)[0] + source_range = source_ranges.get("") + if source_range is not None: + return source_range + position = SDLSourcePosition(1, 1) + return SDLSourceRange(start=position, end=position) + + +_MODEL_DIAGNOSTIC_MESSAGE_MAX_LENGTH = 512 + + +def _bounded_model_message(message: str) -> str: + """Render validator-owned prose without Pydantic's input or traceback.""" + + if message.startswith("Value error, "): + message = message.removeprefix("Value error, ") + escaped = "".join(character if character.isprintable() else f"\\u{ord(character):04x}" for character in message) + if len(escaped) <= _MODEL_DIAGNOSTIC_MESSAGE_MAX_LENGTH: + return escaped + return escaped[: _MODEL_DIAGNOSTIC_MESSAGE_MAX_LENGTH - 3] + "..." + + +def _model_parse_error( + error: ValidationError, + *, + path: Path | None, + source_ranges: dict[str, SDLSourceRange], +) -> SDLParseError: + diagnostics: list[SDLParseDiagnostic] = [] + for item in error.errors(): + pointer = _pointer_from_location(tuple(item.get("loc", ()))) + raw_message = str(item.get("msg", "")) + is_identifier = "portable SDL identifier" in raw_message or "qualified SDL identifier" in raw_message + message = _bounded_model_message(raw_message) + diagnostics.append( + SDLParseDiagnostic( + code="sdl.identifier.invalid" if is_identifier else "sdl.model.invalid", + message=message, + pointer=pointer, + primary_range=_nearest_source_range(pointer, source_ranges), + source=str(path) if path is not None else None, + ) + ) + diagnostics = _dedupe_source_diagnostics(diagnostics) + rendered = "; ".join(f"{diagnostic.pointer or '/'}: {diagnostic.message}" for diagnostic in diagnostics[:8]) + if len(diagnostics) > 8: + rendered += f", and {len(diagnostics) - 8} more" + return SDLParseError( + f"SDL model validation failed at {rendered or '/'}", + path=path, + diagnostics=diagnostics, + ) + + def parse_sdl_file(path: Path, **kwargs: Any) -> Scenario: """Parse an SDL YAML file into a validated Scenario. @@ -420,6 +487,7 @@ def _load_normalized_data( migration_policy: SDLMigrationPolicy | str = SDLMigrationPolicy.REJECT, limits: SDLParserLimits = DEFAULT_PARSER_LIMITS, source_diagnostics: list[SDLParseDiagnostic] | None = None, + source_ranges: dict[str, SDLSourceRange] | None = None, ) -> dict[str, Any]: raw = load_sdl_yaml( content, @@ -430,6 +498,7 @@ def _load_normalized_data( limits=limits, ), source_diagnostics=source_diagnostics, + source_ranges=source_ranges, ) if not isinstance(raw, dict): diff --git a/implementations/python/packages/aces_sdl/runtime_forwarding_agent.py b/implementations/python/packages/aces_sdl/runtime_forwarding_agent.py index eae006f05..b7e092082 100644 --- a/implementations/python/packages/aces_sdl/runtime_forwarding_agent.py +++ b/implementations/python/packages/aces_sdl/runtime_forwarding_agent.py @@ -26,6 +26,7 @@ from pydantic import Field, ValidationInfo, field_validator, model_validator from ._base import SDLModel, is_variable_ref, parse_int_or_var +from ._identifiers import require_qualified_identifier from .runtime_forwarding_agent_vocab import ( RuntimeForwardingAgentImplementation, RuntimeForwardingAgentKind, @@ -306,7 +307,7 @@ class RuntimeForwardingAgent(SDLModel): @field_validator("forwarding_agent_id") @classmethod def validate_forwarding_agent_id(cls, v: str) -> str: - return require_symbol(v, field_name="forwarding_agent_id") + return require_qualified_identifier(v, field_name="forwarding_agent_id") @field_validator("implementation", mode="before") @classmethod diff --git a/implementations/python/packages/aces_sdl/runtime_values.py b/implementations/python/packages/aces_sdl/runtime_values.py index 21f87f6e7..134e80f00 100644 --- a/implementations/python/packages/aces_sdl/runtime_values.py +++ b/implementations/python/packages/aces_sdl/runtime_values.py @@ -11,6 +11,7 @@ parse_bool_or_var, parse_enum_or_var, ) +from ._identifiers import require_portable_identifier _BYTE_UNITS = { "b": 1, @@ -181,11 +182,7 @@ def require_symbol(value: str, *, field_name: str) -> str: Symbol-defining ids are reference targets, so they must be concrete: empty values and ``${var}`` placeholders are rejected. """ - if not isinstance(value, str) or not value.strip(): - raise ValueError(f"{field_name} must be a non-empty string") - if is_variable_ref(value): - raise ValueError(f"{field_name} must be a stable identifier, not a variable placeholder") - return value + return require_portable_identifier(value, field_name=field_name) def absolute_path_or_var(value: str, *, field_name: str) -> str: diff --git a/implementations/python/packages/aces_sdl/scenario.py b/implementations/python/packages/aces_sdl/scenario.py index c902e0982..844581816 100644 --- a/implementations/python/packages/aces_sdl/scenario.py +++ b/implementations/python/packages/aces_sdl/scenario.py @@ -13,12 +13,19 @@ """ from collections.abc import Mapping -from typing import Annotated +from typing import ClassVar -from pydantic import ConfigDict, Field, PrivateAttr, StringConstraints, model_validator +from pydantic import ConfigDict, Field, PrivateAttr, model_validator -from ._base import VARIABLE_NAME_PATTERN, VARIABLE_TOKEN_RE, SDLModel +from ._base import VARIABLE_TOKEN_RE, SDLModel from ._errors import SDLParseDiagnostic +from ._identifiers import ( + PortableIdentifier, + QualifiedName, + require_module_identifier, + require_portable_identifier, +) +from ._mapping_scopes import HASHMAP_SECTIONS from .accounts import Account from .agents import Agent from .conditions import Condition @@ -42,8 +49,8 @@ from .variables import Variable from .vulnerabilities import Vulnerability -VariableName = Annotated[str, StringConstraints(pattern=f"^{VARIABLE_NAME_PATTERN}$")] -VariableDefinitions = dict[VariableName, Variable] +VariableName = PortableIdentifier +VariableDefinitions = dict[PortableIdentifier, Variable] def _collect_variable_tokens(value: object) -> list[str]: @@ -65,19 +72,98 @@ def _collect_variable_tokens(value: object) -> list[str]: return found +def _validate_declaration_identifier( + identifier: object, + *, + section_name: str, + allow_qualified: bool, +) -> None: + if allow_qualified: + local_name = QualifiedName.parse(identifier).parts[-1] + else: + local_name = require_portable_identifier(identifier, field_name=f"{section_name} declaration key") + if section_name == "nodes" and len(local_name) > 35: + raise ValueError("nodes declaration key must be at most 35 characters") + + +def _validate_section_declaration_keys(value: Mapping[object, object], *, allow_qualified: bool) -> None: + for section_name in HASHMAP_SECTIONS: + declarations = value.get(section_name) + if not isinstance(declarations, Mapping): + continue + for identifier in declarations: + _validate_declaration_identifier( + identifier, + section_name=section_name, + allow_qualified=allow_qualified, + ) + + +def _forwarding_agent_identifier(agent: object) -> object: + if isinstance(agent, RuntimeForwardingAgent): + return agent.forwarding_agent_id + if isinstance(agent, Mapping): + return agent.get("forwarding_agent_id") + return None + + +def _validate_forwarding_agent_identifiers( + agents: object, + *, + allow_qualified: bool, + field_name: str, +) -> None: + if not isinstance(agents, (list, tuple)): + return + for agent in agents: + identifier = _forwarding_agent_identifier(agent) + if allow_qualified: + QualifiedName.parse(identifier) + else: + require_portable_identifier(identifier, field_name=field_name) + + +def _node_runtime(node: object) -> object | None: + if isinstance(node, Node): + return node.runtime + if isinstance(node, Mapping): + return node.get("runtime") + return None + + +def _runtime_forwarding_agents(runtime: object) -> object: + if isinstance(runtime, Mapping): + return runtime.get("forwarding_agents", ()) + return getattr(runtime, "forwarding_agents", ()) + + +def _validate_runtime_forwarding_agent_identifiers(value: Mapping[object, object]) -> None: + nodes = value.get("nodes") + if not isinstance(nodes, Mapping): + return + for node in nodes.values(): + runtime = _node_runtime(node) + if runtime is None: + continue + _validate_forwarding_agent_identifiers( + _runtime_forwarding_agents(runtime), + allow_qualified=False, + field_name="runtime forwarding_agent_id", + ) + + class ModuleDescriptor(SDLModel): """Published module metadata for SDL composition.""" id: str version: str - parameters: list[str] = Field(default_factory=list) + parameters: list[PortableIdentifier] = Field(default_factory=list) exports: dict[str, list[str]] = Field(default_factory=dict) description: str = "" @model_validator(mode="after") def validate_descriptor(self) -> "ModuleDescriptor": - if "/" not in self.id or self.id.startswith("/") or self.id.endswith("/"): - raise ValueError("module.id must use canonical 'publisher/name' format") + require_module_identifier(self.id) if len(self.parameters) != len(set(self.parameters)): raise ValueError("module.parameters must be unique") for section, names in self.exports.items(): @@ -93,7 +179,7 @@ class ImportDecl(SDLModel): path: str = "" namespace: str = "" version: str = "*" - parameters: dict[str, object] = Field(default_factory=dict) + parameters: dict[PortableIdentifier, object] = Field(default_factory=dict) digest: str = "" @model_validator(mode="after") @@ -102,11 +188,9 @@ def validate_source_fields(self) -> "ImportDecl": raise ValueError("Import requires either 'source' or deprecated 'path'") if self.source and self.path: raise ValueError("Import may specify only one of 'source' or 'path'") - if self.path and not self.namespace: - # Local path imports remain backward compatible and may derive namespace. - return self - if self.source.startswith("oci:") and not self.namespace: - raise ValueError("OCI imports require an explicit namespace") + if not self.namespace: + raise ValueError("Import requires an explicit namespace") + require_portable_identifier(self.namespace, field_name="namespace") return self @property @@ -134,8 +218,10 @@ class Scenario(SDLModel): }, ) + _allows_qualified_declaration_keys: ClassVar[bool] = False + # --- Identity --- - name: str + name: PortableIdentifier version: str = "*" description: str = "" module: ModuleDescriptor | None = None @@ -186,6 +272,21 @@ class Scenario(SDLModel): _module_node_variable_refs: dict[str, dict[str, str | None]] = PrivateAttr(default_factory=dict) _explicitness: dict[str, ExplicitnessRecord] = PrivateAttr(default_factory=dict) + @model_validator(mode="before") + @classmethod + def _validate_declaration_keys(cls, value: object) -> object: + if not isinstance(value, Mapping): + return value + allow_qualified = cls._allows_qualified_declaration_keys + _validate_section_declaration_keys(value, allow_qualified=allow_qualified) + _validate_forwarding_agent_identifiers( + value.get("forwarding_agents", ()), + allow_qualified=allow_qualified, + field_name="forwarding_agent_id", + ) + _validate_runtime_forwarding_agent_identifiers(value) + return value + @property def advisories(self) -> list[str]: """Non-fatal SDL advisories gathered during semantic validation.""" @@ -250,6 +351,8 @@ class InstantiatedScenario(Scenario): it), the schema and this validator treat it as non-concrete and reject it. """ + _allows_qualified_declaration_keys: ClassVar[bool] = True + model_config = ConfigDict( title="SDL Instantiated Scenario v1", json_schema_extra={ @@ -307,6 +410,8 @@ def _reject_unresolved_variable_references(self) -> "InstantiatedScenario": class ExpandedScenario(Scenario): """Scenario produced by module/import expansion.""" + _allows_qualified_declaration_keys: ClassVar[bool] = True + _module_namespaces: dict[str, str] = PrivateAttr(default_factory=dict) @property diff --git a/implementations/python/packages/aces_sdl/schema_catalogs.py b/implementations/python/packages/aces_sdl/schema_catalogs.py new file mode 100644 index 000000000..89b5e3bd5 --- /dev/null +++ b/implementations/python/packages/aces_sdl/schema_catalogs.py @@ -0,0 +1,6 @@ +"""Public read-only views used to publish SDL contract schemas.""" + +from ._mapping_scopes import HASHMAP_SECTIONS +from ._runtime_service_families import RUNTIME_SERVICE_FAMILIES, RuntimeReferenceChild + +__all__ = ["HASHMAP_SECTIONS", "RUNTIME_SERVICE_FAMILIES", "RuntimeReferenceChild"] diff --git a/implementations/python/packages/aces_sdl/semantics/objectives.py b/implementations/python/packages/aces_sdl/semantics/objectives.py index 21b83b157..1d6be272a 100644 --- a/implementations/python/packages/aces_sdl/semantics/objectives.py +++ b/implementations/python/packages/aces_sdl/semantics/objectives.py @@ -6,6 +6,8 @@ from dataclasses import dataclass from enum import Enum +from .._identifiers import QualifiedName + class ObjectiveWindowReferenceKind(str, Enum): """Normalized objective-window reference kinds.""" @@ -107,17 +109,18 @@ def _ordered_unique(items: list[str]) -> tuple[str, ...]: def parse_workflow_step_ref(step_ref: str) -> ParsedWorkflowStepRef | None: - """Parse ``.`` syntax used by objective windows.""" + """Parse a qualified workflow name followed by one local step segment.""" - if "." not in step_ref: + try: + parts = QualifiedName.parse(step_ref).parts + except (TypeError, ValueError): return None - workflow_name, step_name = step_ref.rsplit(".", 1) - if not workflow_name or not step_name: + if len(parts) < 2: return None return ParsedWorkflowStepRef( raw=step_ref, - workflow_name=workflow_name, - step_name=step_name, + workflow_name=QualifiedName(parts[:-1]).render(), + step_name=parts[-1], ) diff --git a/implementations/python/packages/aces_sdl/validator/_content_objectives.py b/implementations/python/packages/aces_sdl/validator/_content_objectives.py index b86912cbc..6a159f125 100644 --- a/implementations/python/packages/aces_sdl/validator/_content_objectives.py +++ b/implementations/python/packages/aces_sdl/validator/_content_objectives.py @@ -227,12 +227,14 @@ def _verify_relationships(self) -> None: rel.source, owner_label=f"Relationship '{name}'", ref_label="source", + targetable=True, ) if not self._is_unresolved_var(rel.target): self._validate_named_ref( rel.target, owner_label=f"Relationship '{name}'", ref_label="target", + targetable=True, ) def _verify_agents(self) -> None: diff --git a/implementations/python/packages/aces_sdl/validator/_core.py b/implementations/python/packages/aces_sdl/validator/_core.py index fc7ebdc90..5653c3fd6 100644 --- a/implementations/python/packages/aces_sdl/validator/_core.py +++ b/implementations/python/packages/aces_sdl/validator/_core.py @@ -6,13 +6,15 @@ from collections import defaultdict from .._base import is_variable_ref +from .._declarations import DeclarationIndex, build_declaration_index from .._errors import SDLValidationError -from .._reference_targetability import is_targetable_reference -from .._runtime_service_families import collect_qualified_runtime_family_refs +from .._runtime_service_families import ( + RuntimeFamilyReference, + iter_runtime_family_references, +) from ..entities import flatten_entities from ..nodes import NodeType from ..scenario import Scenario -from ._support import _NODES_PREFIX class _ValidatorCore: @@ -20,6 +22,8 @@ def __init__(self, scenario: Scenario) -> None: self._s = scenario self._errors: list[str] = [] self._warnings: list[str] = [] + self._declaration_index: DeclarationIndex | None = None + self._runtime_references: dict[str, RuntimeFamilyReference] | None = None def _err(self, msg: str) -> None: self._errors.append(msg) @@ -44,46 +48,16 @@ def _is_vm_node(self, node_name: str) -> bool: def _all_entity_names(self) -> set[str]: return set(flatten_entities(self._s.entities).keys()) - def _qualified_service_refs(self) -> set[str]: - refs: set[str] = set() - for node_name, node in self._s.nodes.items(): - for service in node.services: - if service.name: - refs.add(f"nodes.{node_name}.services.{service.name}") - return refs - - @staticmethod - def _split_node_service_ref(ref: object) -> tuple[str, str] | None: - """Split ``nodes..services.`` into node/service parts. + def _split_node_service_ref(self, ref: object) -> tuple[str, str] | None: + """Resolve a qualified service ref without parsing rendered delimiters.""" - Node names may contain dots (for example ``wazuh.manager``), so service - refs must be partitioned on the ``.services.`` marker instead of split - by position. - """ - if not isinstance(ref, str) or not ref.startswith(_NODES_PREFIX): - return None - node_name, sep, service_name = ref[len(_NODES_PREFIX) :].partition(".services.") - if not sep or not node_name or not service_name: + if not isinstance(ref, str): return None - return node_name, service_name - - def _qualified_runtime_refs(self) -> set[str]: - """Qualified refs for node-scoped runtime inventories. - - These let a top-level relationship endpoint resolve to a runtime - service family or stable child record. This keeps runtime-observed - logical state targetable without promoting those records to top-level - SDL sections. - """ - return collect_qualified_runtime_family_refs(self._s) - - def _qualified_acl_refs(self) -> set[str]: - refs: set[str] = set() - for infra_name, infra in self._s.infrastructure.items(): - for acl in infra.acls: - if acl.name: - refs.add(f"infrastructure.{infra_name}.acls.{acl.name}") - return refs + for node_name, node in self._s.nodes.items(): + for service in node.services: + if service.name and ref == f"nodes.{node_name}.services.{service.name}": + return node_name, service.name + return None def _workflow_step_refs(self) -> set[str]: refs: set[str] = set() @@ -100,80 +74,9 @@ def _named_ref_index(self, *, targetable: bool = False) -> dict[str, set[str]]: and are required for infrastructure entries because those keys intentionally mirror node names. """ - index: dict[str, set[str]] = defaultdict(set) - self._populate_named_ref_index(index) - if not targetable: - return {alias: set(candidates) for alias, candidates in index.items()} - return self._filter_targetable_aliases(index) - - _NAMED_REF_TOP_LEVEL_SECTIONS = ( - ("nodes", True), - ("features", True), - ("conditions", True), - ("vulnerabilities", True), - ("infrastructure", False), - ("content", True), - ("accounts", True), - ("agents", True), - ("action_contracts", True), - ("observation_boundaries", True), - ("behavior_specifications", True), - ("evidence_requirements", True), - ("objectives", True), - ("workflows", True), - ("relationships", True), - ("variables", True), - ("injects", True), - ("events", True), - ("scripts", True), - ("stories", True), - ) - - def _populate_named_ref_index(self, index: dict[str, set[str]]) -> None: - self._add_top_level_section_aliases(index) - self._add_entity_aliases(index) - self._add_content_item_aliases(index) - self._add_qualified_aliases(index) - - def _add_top_level_section_aliases(self, index: dict[str, set[str]]) -> None: - for section_name, allow_bare in self._NAMED_REF_TOP_LEVEL_SECTIONS: - for name in getattr(self._s, section_name): - canonical = f"{section_name}.{name}" - index[canonical].add(canonical) - if allow_bare: - index[name].add(canonical) - - def _add_entity_aliases(self, index: dict[str, set[str]]) -> None: - for entity_name in self._all_entity_names(): - canonical = f"entities.{entity_name}" - index[canonical].add(canonical) - index[entity_name].add(canonical) - - def _add_content_item_aliases(self, index: dict[str, set[str]]) -> None: - for content_name, content in self._s.content.items(): - for item in content.items: - if not item.name: - continue - canonical = f"content.{content_name}.items.{item.name}" - index[canonical].add(canonical) - index[item.name].add(canonical) - - def _add_qualified_aliases(self, index: dict[str, set[str]]) -> None: - for qualified_refs in ( - self._qualified_service_refs(), - self._qualified_acl_refs(), - self._qualified_runtime_refs(), - ): - for ref in qualified_refs: - index[ref].add(ref) - - def _filter_targetable_aliases(self, index: dict[str, set[str]]) -> dict[str, set[str]]: - filtered: dict[str, set[str]] = {} - for alias, candidates in index.items(): - keep = {candidate for candidate in candidates if is_targetable_reference(candidate)} - if keep: - filtered[alias] = keep - return filtered + if self._declaration_index is None: + raise RuntimeError("declaration index must be built before reference validation") + return self._declaration_index.reference_aliases(targetable=targetable) def _operating_scope_ref_index(self) -> dict[str, set[str]]: """Build the alias map for ACT-601 ``Agent.operating_scope``. @@ -227,11 +130,13 @@ def _add_operating_scope_service_aliases(self, index: dict[str, set[str]]) -> No # service names. The service-ref helper only emits names declared # on VM nodes (a service on a switch is meaningless), so no extra # filtering is needed here. - for ref in self._qualified_service_refs(): - index[ref].add(ref) - tail = ref.rsplit(".", 1)[-1] - if tail: - index[tail].add(ref) + for node_name, node in self._s.nodes.items(): + for service in node.services: + if not service.name: + continue + ref = f"nodes.{node_name}.services.{service.name}" + index[ref].add(ref) + index[service.name].add(ref) def _add_operating_scope_content_aliases(self, index: dict[str, set[str]]) -> None: # Content: sections and items keep the unrestricted aliasing from @@ -283,6 +188,8 @@ def validate(self) -> None: """Run all validation passes and raise on errors.""" self._errors = [] self._warnings = [] + self._declaration_index = build_declaration_index(self._s, raise_on_collision=False) + self._errors.extend(self._declaration_index.collision_errors) # OCR passes self._verify_nodes() @@ -357,35 +264,17 @@ def _warn_missing_vm_resources(self) -> None: "supplies defaults." ) - @staticmethod - def _split_runtime_ref(ref: object, *, surface: str) -> tuple[str, str] | None: - """Split ``nodes..runtime..`` into (node, rest). + def _runtime_reference(self, ref: object) -> RuntimeFamilyReference | None: + """Resolve an exact registered runtime address without delimiter parsing.""" - Module composition rewrites the node segment to a dotted namespaced - form (``shared.web``), so we cannot split on ``.`` and index by - position. Partition on the surface marker instead so the node name - survives an arbitrary number of namespace prefixes. - """ - if not isinstance(ref, str) or not ref.startswith(_NODES_PREFIX): + if not isinstance(ref, str): return None - marker = f".runtime.{surface}." - head, sep, tail = ref[len(_NODES_PREFIX) :].partition(marker) - if not sep or not head or not tail: - return None - return head, tail - - def _node_runtime(self, node_name: str) -> object | None: - """Return the ``runtime`` surface for ``node_name``, or None.""" - node = self._s.nodes.get(node_name) - return getattr(node, "runtime", None) if node is not None else None - - @staticmethod - def _database_service_id_from_tail(tail: str) -> str | None: - """Service id from a ```` (1 part) or ``.databases.`` (3) tail.""" - tail_parts = tail.split(".") - if len(tail_parts) == 1 or (len(tail_parts) == 3 and tail_parts[1] == "databases"): - return tail_parts[0] - return None + if self._runtime_references is None: + references: dict[str, RuntimeFamilyReference] = {} + for reference in iter_runtime_family_references(self._s): + references.setdefault(reference.address, reference) + self._runtime_references = references + return self._runtime_references.get(ref) def _resolve_database_service_ref(self, ref: object) -> object | None: """Resolve a qualified ``nodes..runtime.database_services.`` ref. @@ -394,15 +283,12 @@ def _resolve_database_service_ref(self, ref: object) -> object | None: resolve to the owning :class:`RuntimeDatabaseService` so a relationship's ``database_access`` can be checked against it. """ - split = self._split_runtime_ref(ref, surface="database_services") - if split is None: + reference = self._runtime_reference(ref) + if reference is None or reference.family.collection_name != "database_services": return None - node_name, tail = split - svc_id = self._database_service_id_from_tail(tail) - runtime = self._node_runtime(node_name) - if svc_id is None or runtime is None: + if reference.collection_path not in {(), ("databases",)}: return None - return next((s for s in runtime.database_services if s.database_service_id == svc_id), None) + return reference.owning_item def _resolve_application_ref(self, ref: object) -> object | None: """Resolve a qualified ``nodes..runtime.applications.`` ref. @@ -411,11 +297,11 @@ def _resolve_application_ref(self, ref: object) -> object | None: ``database_access`` source endpoint can be confirmed to be a runtime application (ADR-029 §4). """ - split = self._split_runtime_ref(ref, surface="applications") - if split is None: - return None - node_name, tail = split - runtime = self._node_runtime(node_name) - if "." in tail or runtime is None: + reference = self._runtime_reference(ref) + if ( + reference is None + or reference.family.collection_name != "applications" + or reference.item is not reference.owning_item + ): return None - return next((a for a in runtime.applications if a.application_id == tail), None) + return reference.item diff --git a/implementations/python/packages/aces_sdl/validator/_relationships_proxy.py b/implementations/python/packages/aces_sdl/validator/_relationships_proxy.py index bb5dd34be..23a425053 100644 --- a/implementations/python/packages/aces_sdl/validator/_relationships_proxy.py +++ b/implementations/python/packages/aces_sdl/validator/_relationships_proxy.py @@ -3,8 +3,6 @@ Part of the SemanticValidator mixin composition; see __init__.py. """ -from ._support import _NODES_PREFIX - class _RelationshipsProxyMixin: def _verify_relationship_proxy_upstreams(self) -> None: @@ -153,10 +151,9 @@ def _node_name_from_qualified_target(self, target: str) -> str | None: if service_split is not None: node_name = service_split[0] return node_name if node_name in self._s.nodes else None - if target.startswith(_NODES_PREFIX): - node_name, sep, _tail = target[len(_NODES_PREFIX) :].partition(".runtime.") - if sep and node_name in self._s.nodes: - return node_name + runtime_reference = self._runtime_reference(target) + if runtime_reference is not None: + return runtime_reference.node_name return None def _check_proxy_upstream_route_ref(self, route_ref: str, source: str, label: str) -> object | None: diff --git a/implementations/python/packages/aces_sdl/validator/_runtime_mail.py b/implementations/python/packages/aces_sdl/validator/_runtime_mail.py index afbf99757..92b75ad64 100644 --- a/implementations/python/packages/aces_sdl/validator/_runtime_mail.py +++ b/implementations/python/packages/aces_sdl/validator/_runtime_mail.py @@ -17,13 +17,6 @@ class _MailServiceLocalIds: routing_refs: set[str] -@dataclass(frozen=True) -class _MailRefTail: - service_id: str - collection_name: str - child_id: str - - _MailChildIdReader = Callable[[object], Iterable[str]] _MAIL_CHILD_ID_READERS: dict[str, _MailChildIdReader] = { "components": lambda service: (component.component_id for component in service.components), @@ -43,11 +36,6 @@ def _mail_services_for_node(node: object) -> Sequence[object]: return () if runtime is None else runtime.mail_services -def _mail_services_for_node_name(scenario: object, node_name: str) -> Sequence[object]: - node = scenario.nodes.get(node_name) - return () if node is None else _mail_services_for_node(node) - - def _collect_mail_service_local_ids(service: object) -> _MailServiceLocalIds: mailbox_ids = {mailbox.mailbox_id for mailbox in service.mailboxes} alias_ids = {alias.alias_id for alias in service.aliases} @@ -62,35 +50,6 @@ def _collect_mail_service_local_ids(service: object) -> _MailServiceLocalIds: ) -def _parse_mail_ref_tail(tail: str) -> _MailRefTail | None: - tail_parts = tail.split(".") - if len(tail_parts) == 1: - return _MailRefTail(tail_parts[0], "", "") - if len(tail_parts) == 3: - return _MailRefTail(*tail_parts) - return None - - -def _resolve_mail_service_tail( - mail_services: Sequence[object], - parsed_tail: _MailRefTail, -) -> object | None: - for service in mail_services: - if service.mail_service_id != parsed_tail.service_id: - continue - return _matched_service_for_tail(service, parsed_tail) - return None - - -def _matched_service_for_tail(service: object, parsed_tail: _MailRefTail) -> object | None: - matches_service = not parsed_tail.collection_name - matches_child = bool( - parsed_tail.collection_name - and _mail_child_ref_exists(service, parsed_tail.collection_name, parsed_tail.child_id) - ) - return service if matches_service or matches_child else None - - def _mail_child_ref_exists(service: object, collection_name: str, child_id: str) -> bool: read_child_ids = _MAIL_CHILD_ID_READERS.get(collection_name) return read_child_ids is not None and child_id in read_child_ids(service) @@ -294,14 +253,7 @@ def _check_mail_access_target(self, target: str, label: str) -> object | None: def _resolve_mail_service_ref(self, ref: object) -> object | None: """Resolve a qualified runtime mail-service or child ref to the service.""" - split = self._split_runtime_ref(ref, surface="mail_services") - if split is None: - return None - node_name, tail = split - parsed_tail = _parse_mail_ref_tail(tail) - if parsed_tail is None: + reference = self._runtime_reference(ref) + if reference is None or reference.family.collection_name != "mail_services": return None - return _resolve_mail_service_tail( - _mail_services_for_node_name(self._s, node_name), - parsed_tail, - ) + return reference.owning_item diff --git a/implementations/python/packages/aces_sdl/validator/_runtime_services.py b/implementations/python/packages/aces_sdl/validator/_runtime_services.py index d0755a504..f3815a236 100644 --- a/implementations/python/packages/aces_sdl/validator/_runtime_services.py +++ b/implementations/python/packages/aces_sdl/validator/_runtime_services.py @@ -350,20 +350,20 @@ def _verify_ssh_server_service( server_id = server.ssh_server_id service_name = ref if ref.startswith("nodes."): - parts = ref.split(".") - if len(parts) != 4 or parts[2] != "services": + split = self._split_node_service_ref(ref) + if split is None: self._err( f"Node '{node_name}' runtime ssh_server '{server_id}' service ref '{ref}' " f"must be a bare service name or 'nodes..services.'" ) return - if parts[1] != node_name: + service_node_name, service_name = split + if service_node_name != node_name: self._err( f"Node '{node_name}' runtime ssh_server '{server_id}' service ref '{ref}' " f"must reference a service on the same node" ) return - service_name = parts[3] if service_name not in service_names: self._err( f"Node '{node_name}' runtime ssh_server '{server_id}' references undefined service '{service_name}'" diff --git a/implementations/python/tests/test_language_service.py b/implementations/python/tests/test_language_service.py index daa25ebd3..6ea712f2e 100644 --- a/implementations/python/tests/test_language_service.py +++ b/implementations/python/tests/test_language_service.py @@ -111,6 +111,36 @@ def test_targetable_completions_exclude_non_targetable_sections() -> None: } +def test_valid_document_completions_use_typed_nested_declarations() -> None: + sdl = """\ +name: nested-completions +nodes: + web: + type: vm + resources: {ram: 1 GiB, cpu: 1} + services: [{name: http, port: 80}] + api: + type: vm + resources: {ram: 1 GiB, cpu: 1} + services: [{name: http, port: 8080}] +content: + fixtures: + type: dataset + target: web + items: [{name: seed-file, display_name: seed.json}] +relationships: + serves: {type: connects_to, source: web, target: api} +""" + + result = language_completions(sdl, cursor_path="/relationships/serves/target") + by_detail = {item["detail"]: item for item in result["items"]} + + assert result["context"] == "reference:targetable" + assert by_detail["content.fixtures.items.seed-file"]["label"] == "seed-file" + assert by_detail["nodes.web.services.http"]["label"] == "nodes.web.services.http" + assert by_detail["nodes.api.services.http"]["label"] == "nodes.api.services.http" + + def test_qualified_targetable_reference_reports_occurrence() -> None: sdl = """\ name: targetable-reference diff --git a/implementations/python/tests/test_libvirt_backend_driver.py b/implementations/python/tests/test_libvirt_backend_driver.py index 429d65429..2dba6d0bf 100644 --- a/implementations/python/tests/test_libvirt_backend_driver.py +++ b/implementations/python/tests/test_libvirt_backend_driver.py @@ -8,6 +8,7 @@ from aces_backend_libvirt.cloudinit import CloudInitSpec, CloudInitUser from aces_backend_libvirt.driver import DomainSpec, NetworkAcl, NetworkSpec from aces_backend_libvirt.drivers.libvirt import LibvirtDeploymentDriver, _aces_uuid +from aces_backend_protocols.naming import provider_resource_name # Real libvirt reports a missing object with these stable VIR_ERR_NO_* codes via # libvirtError.get_error_code(); VIR_ERR_OPERATION_INVALID is raised by destroy() @@ -21,6 +22,14 @@ _VIR_ERR_NO_NWFILTER = 620 +def _runtime_name(address: str) -> str: + return provider_resource_name(address, prefix="aces-test") + + +def _seed_dir(workspace: Path, address: str = "provision.node.web") -> Path: + return workspace / _runtime_name(address) + + class _FakeLibvirtError(Exception): """Stand-in for ``libvirt.libvirtError`` exposing ``get_error_code()``.""" @@ -145,11 +154,13 @@ def test_libvirt_driver_realize_defines_networks_and_domains_with_safe_names(): ) assert not result.diagnostics - assert "aces-test-lan" in connection.network_xml[0] - assert "aces-test-web-vm" in connection.domain_xml[0] + network_name = _runtime_name("provision.network.lan") + domain_name = _runtime_name("provision.node.web") + assert network_name in connection.network_xml[0] + assert domain_name in connection.domain_xml[0] assert "lan<>" not in connection.network_xml[0] assert "web/vm" not in connection.domain_xml[0] - assert 'source network="aces-test-lan"' in connection.domain_xml[0] + assert f'source network="{network_name}"' in connection.domain_xml[0] assert driver.realized_addresses() == frozenset({"provision.network.lan", "provision.node.web"}) @@ -212,7 +223,7 @@ def test_libvirt_driver_realizes_cloud_init_seed_as_readonly_cdrom(tmp_path): ) assert not result.diagnostics - seed_dir = tmp_path / "aces-test-web" + seed_dir = _seed_dir(tmp_path) assert (seed_dir / "user-data").read_text().startswith("#cloud-config") assert (seed_dir / "meta-data").exists() assert seed_builder.seed_dirs == [seed_dir] @@ -246,7 +257,7 @@ def test_libvirt_seed_artifacts_use_private_modes(tmp_path): _realize_seed_domain(driver) - seed_dir = tmp_path / "aces-test-web" + seed_dir = _seed_dir(tmp_path) # Directories are traversable (so the QEMU process can reach the attached ISO) # but not listable; the rendered source files stay 0o600-private. assert seed_dir.stat().st_mode & 0o777 == 0o711 @@ -259,7 +270,7 @@ def test_libvirt_seed_write_neutralizes_pre_positioned_symlink(tmp_path): # A pre-positioned symlink where user-data would be written must never be # followed: the stale entry is cleared (target untouched) and the seed is # created fresh inside an owner-private directory, so re-apply still succeeds. - seed_dir = tmp_path / "aces-test-web" + seed_dir = _seed_dir(tmp_path) seed_dir.mkdir(parents=True) target = tmp_path / "victim" target.write_text("original") @@ -284,7 +295,7 @@ def test_libvirt_seed_write_neutralizes_pre_positioned_symlink(tmp_path): def test_libvirt_seed_write_clears_stale_seed_dir_for_clean_reapply(tmp_path): # A leftover seed directory from a crashed prior apply is replaced wholesale, # so exclusive (O_EXCL) creation of the new seed files never collides. - seed_dir = tmp_path / "aces-test-web" + seed_dir = _seed_dir(tmp_path) seed_dir.mkdir(parents=True) (seed_dir / "user-data").write_text("stale") (seed_dir / "leftover").write_text("debris") @@ -321,7 +332,7 @@ def test_libvirt_driver_destroy_cleans_up_seed_media(tmp_path): ), ), ) - seed_dir = tmp_path / "aces-test-web" + seed_dir = _seed_dir(tmp_path) assert seed_dir.exists() driver.destroy(networks=(), domains=("provision.node.web",)) @@ -338,8 +349,8 @@ def test_libvirt_driver_converges_existing_objects_without_duplicating(tmp_path) ) first = driver.realize(**specs) - prior_domain = connection.domains["aces-test-web"] - prior_network = connection.networks["aces-test-lan"] + prior_domain = connection.domains[_runtime_name("provision.node.web")] + prior_network = connection.networks[_runtime_name("provision.network.lan")] second = driver.realize(**specs) assert not first.diagnostics and not second.diagnostics @@ -360,7 +371,7 @@ def test_libvirt_convergence_refuses_to_replace_a_foreign_object(tmp_path): # closed with an ownership-conflict diagnostic and the foreign object survives. connection = _FakeConnection() foreign = _NativeObject(uuid="11111111-2222-3333-4444-555555555555") - connection.domains["aces-test-web"] = foreign + connection.domains[_runtime_name("provision.node.web")] = foreign driver = LibvirtDeploymentDriver(connection=connection, name_prefix="aces-test", seed_builder=_FakeSeedBuilder()) result = driver.realize( @@ -370,7 +381,7 @@ def test_libvirt_convergence_refuses_to_replace_a_foreign_object(tmp_path): assert [d.code for d in result.diagnostics] == ["libvirt-backend.driver.ownership-conflict"] assert not foreign.destroyed and not foreign.undefined - assert connection.domains["aces-test-web"] is foreign # never replaced + assert connection.domains[_runtime_name("provision.node.web")] is foreign # never replaced assert connection.domain_xml == [] # nothing redefined @@ -381,7 +392,7 @@ def test_libvirt_convergence_fails_closed_when_stopping_owned_object_fails(): # closed and the still-running owned domain is left intact for retry. connection = _FakeConnection() existing = _NativeObject(uuid=_aces_uuid("provision.node.web"), fail_destroy_code=_VIR_ERR_INTERNAL_ERROR) - connection.domains["aces-test-web"] = existing + connection.domains[_runtime_name("provision.node.web")] = existing driver = LibvirtDeploymentDriver(connection=connection, name_prefix="aces-test", seed_builder=_FakeSeedBuilder()) result = driver.realize( @@ -398,7 +409,7 @@ def test_libvirt_convergence_tolerates_stopping_an_inactive_owned_object(): # inactive (stop raises VIR_ERR_OPERATION_INVALID) still undefines + redefines. connection = _FakeConnection() existing = _NativeObject(uuid=_aces_uuid("provision.node.web"), fail_destroy_code=_VIR_ERR_OPERATION_INVALID) - connection.domains["aces-test-web"] = existing + connection.domains[_runtime_name("provision.node.web")] = existing driver = LibvirtDeploymentDriver(connection=connection, name_prefix="aces-test", seed_builder=_FakeSeedBuilder()) result = driver.realize( @@ -536,7 +547,8 @@ def test_libvirt_nwfilter_define_refuses_to_overwrite_a_foreign_filter(): # left untouched (no redefine), so other domains' filtering is not weakened. connection = _FakeConnection() foreign = _NativeObject(uuid="99999999-8888-7777-6666-555555555555") - connection.nwfilters["aces-test-web-acl"] = foreign + filter_name = f"{_runtime_name('provision.node.web')}-acl" + connection.nwfilters[filter_name] = foreign driver = LibvirtDeploymentDriver(connection=connection, name_prefix="aces-test") acl = NetworkAcl(name="allow-all", action="accept", direction="inout", protocol="all") @@ -547,7 +559,7 @@ def test_libvirt_nwfilter_define_refuses_to_overwrite_a_foreign_filter(): assert [d.code for d in result.diagnostics] == ["libvirt-backend.driver.ownership-conflict"] assert connection.nwfilter_xml == [] # the foreign filter was never redefined - assert connection.nwfilters["aces-test-web-acl"] is foreign + assert connection.nwfilters[filter_name] is foreign def test_libvirt_destroy_refuses_to_remove_a_foreign_object(): @@ -555,14 +567,14 @@ def test_libvirt_destroy_refuses_to_remove_a_foreign_object(): # it: the ownership guard applies to deletion, not just convergence. connection = _FakeConnection() foreign = _NativeObject(uuid="11111111-2222-3333-4444-555555555555") - connection.domains["aces-test-web"] = foreign + connection.domains[_runtime_name("provision.node.web")] = foreign driver = LibvirtDeploymentDriver(connection=connection, name_prefix="aces-test") result = driver.destroy(networks=(), domains=("provision.node.web",)) assert [d.code for d in result.diagnostics] == ["libvirt-backend.driver.ownership-conflict"] assert not foreign.destroyed and not foreign.undefined - assert connection.domains["aces-test-web"] is foreign + assert connection.domains[_runtime_name("provision.node.web")] is foreign def test_libvirt_driver_destroy_uses_previously_realized_names(): @@ -576,10 +588,12 @@ def test_libvirt_driver_destroy_uses_previously_realized_names(): result = driver.destroy(networks=("provision.network.lan",), domains=("provision.node.web",)) assert not result.diagnostics - assert connection.networks["aces-test-lan"].destroyed is True - assert connection.networks["aces-test-lan"].undefined is True - assert connection.domains["aces-test-web"].destroyed is True - assert connection.domains["aces-test-web"].undefined is True + network = connection.networks[_runtime_name("provision.network.lan")] + domain = connection.domains[_runtime_name("provision.node.web")] + assert network.destroyed is True + assert network.undefined is True + assert domain.destroyed is True + assert domain.undefined is True assert driver.realized_addresses() == frozenset() @@ -677,10 +691,10 @@ def test_libvirt_driver_realize_rolls_back_partially_defined_domain_on_create_fa assert [d.code for d in result.diagnostics] == ["libvirt-backend.driver.operation-failed"] # The just-defined domain was undefined (rolled back), not left orphaned. - defined = connection.domains["aces-test-web"] + defined = connection.domains[_runtime_name("provision.node.web")] assert defined.undefined is True # Its private seed media was cleaned up too. - assert not (tmp_path / "aces-test-web").exists() + assert not _seed_dir(tmp_path).exists() assert driver.realized_addresses() == frozenset() @@ -692,9 +706,9 @@ def test_libvirt_realize_rollback_leaves_a_pre_existing_updated_object_intact(): # preserved baseline snapshot that still claims it realized stays truthful. connection = _FakeConnection() existing = _NativeObject(uuid=_aces_uuid("provision.node.web")) - connection.domains["aces-test-web"] = existing + connection.domains[_runtime_name("provision.node.web")] = existing foreign = _NativeObject(uuid="11111111-2222-3333-4444-555555555555") - connection.domains["aces-test-other"] = foreign + connection.domains[_runtime_name("provision.node.other")] = foreign driver = LibvirtDeploymentDriver(connection=connection, name_prefix="aces-test", seed_builder=_FakeSeedBuilder()) result = driver.realize( @@ -709,7 +723,7 @@ def test_libvirt_realize_rollback_leaves_a_pre_existing_updated_object_intact(): assert [d.code for d in result.diagnostics] == ["libvirt-backend.driver.ownership-conflict"] # The updated domain's fresh definition is NOT rolled back (its snapshot entry # remains truthful); the foreign object is never touched. - updated = connection.domains["aces-test-web"] + updated = connection.domains[_runtime_name("provision.node.web")] assert updated.created is True assert updated.undefined is False assert not foreign.destroyed and not foreign.undefined @@ -723,13 +737,14 @@ def test_libvirt_driver_teardown_fails_closed_when_stop_fails_for_a_running_obje connection = _FakeConnection() driver = LibvirtDeploymentDriver(connection=connection, name_prefix="aces-test") driver.realize(networks=(), domains=(DomainSpec(address="provision.node.web", name="web", image_ref=None),)) - connection.domains["aces-test-web"].fail_destroy_code = _VIR_ERR_INTERNAL_ERROR + runtime_name = _runtime_name("provision.node.web") + connection.domains[runtime_name].fail_destroy_code = _VIR_ERR_INTERNAL_ERROR result = driver.destroy(networks=(), domains=("provision.node.web",)) assert [d.code for d in result.diagnostics] == ["libvirt-backend.driver.operation-failed"] assert [handle.realized for handle in result.domains] == [True] - assert connection.domains["aces-test-web"].undefined is False # never undefined a still-running domain + assert connection.domains[runtime_name].undefined is False # never undefined a still-running domain def test_libvirt_driver_teardown_undefines_an_already_inactive_object(): @@ -739,10 +754,11 @@ def test_libvirt_driver_teardown_undefines_an_already_inactive_object(): connection = _FakeConnection() driver = LibvirtDeploymentDriver(connection=connection, name_prefix="aces-test") driver.realize(networks=(), domains=(DomainSpec(address="provision.node.web", name="web", image_ref=None),)) - connection.domains["aces-test-web"].fail_destroy_code = _VIR_ERR_OPERATION_INVALID + runtime_name = _runtime_name("provision.node.web") + connection.domains[runtime_name].fail_destroy_code = _VIR_ERR_OPERATION_INVALID result = driver.destroy(networks=(), domains=("provision.node.web",)) assert not result.diagnostics assert [handle.realized for handle in result.domains] == [False] - assert connection.domains["aces-test-web"].undefined is True + assert connection.domains[runtime_name].undefined is True diff --git a/implementations/python/tests/test_libvirt_backend_provisioner.py b/implementations/python/tests/test_libvirt_backend_provisioner.py index cc6d1c33a..f18d53194 100644 --- a/implementations/python/tests/test_libvirt_backend_provisioner.py +++ b/implementations/python/tests/test_libvirt_backend_provisioner.py @@ -170,7 +170,9 @@ def test_apply_rejects_snapshot_bound_to_another_envelope_before_driver_io(): **{**plan.realization_envelope.model_dump(), "configuration_digest": "sha256:" + "e" * 64} # type: ignore[union-attr] ) baseline = RuntimeSnapshot( - entries={"existing": SnapshotEntry("existing", RuntimeDomain.PROVISIONING, "node", {})}, + entries={ + "provision.node.existing": SnapshotEntry("provision.node.existing", RuntimeDomain.PROVISIONING, "node", {}) + }, realization_envelope=wrong, ) diff --git a/implementations/python/tests/test_libvirt_backend_realization.py b/implementations/python/tests/test_libvirt_backend_realization.py index 99e27984e..86c6d76ad 100644 --- a/implementations/python/tests/test_libvirt_backend_realization.py +++ b/implementations/python/tests/test_libvirt_backend_realization.py @@ -2,6 +2,7 @@ from __future__ import annotations +import pytest from aces_backend_libvirt.realization import interpret_provisioning_plan from aces_backend_protocols.capabilities import ProvisionerCapabilities from aces_contracts.planning import PlannedResource, ProvisioningPlan, RuntimeDomain @@ -414,12 +415,11 @@ def test_acl_with_wildcard_protocol_and_ports_fails_closed(): assert _domain(realization).network_acls == () -def test_unsupported_resource_type_still_emits_error_diagnostic(): +def test_unsupported_resource_type_is_rejected_at_plan_admission(): bogus = _resource("mystery", "provision.mystery.x", {"name": "x"}) - realization = interpret_provisioning_plan(_plan(_node(), bogus)) - - assert [d.code for d in realization.diagnostics] == ["libvirt-backend.realization.unsupported-resource"] + with pytest.raises(ValueError, match="resource_type must belong"): + _plan(_node(), bogus) def test_placement_targeting_unknown_node_fails_closed_with_diagnostic(): diff --git a/implementations/python/tests/test_libvirt_backend_techvault_native.py b/implementations/python/tests/test_libvirt_backend_techvault_native.py index 43efa6a70..0fd1bba33 100644 --- a/implementations/python/tests/test_libvirt_backend_techvault_native.py +++ b/implementations/python/tests/test_libvirt_backend_techvault_native.py @@ -21,6 +21,7 @@ expected_surface, native_soc_readback, ) +from aces_backend_protocols.naming import provider_resource_name from aces_operations import techvault_live from aces_operations.techvault_live import ( TechVaultLiveConfig, @@ -332,8 +333,8 @@ def test_bounded_substrate_emits_complete_daemon_observations(tmp_path): assert {observation.source.value for observation in result.observations} == {"daemon-observed"} surface = expected_surface(driver.last_snapshot) assert surface["source"] == "daemon-observed" - assert surface["domains"] == ("native-test-demo",) - assert surface["networks"] == ("native-test-lab",) + assert surface["domains"] == (provider_resource_name(domain.address, prefix="native-test"),) + assert surface["networks"] == (provider_resource_name(network.address, prefix="native-test"),) assert "service_count" not in surface network_uuid = ET.fromstring(connection.network_xml[0]).findtext("uuid") # noqa: S314 - test XML domain_uuid = ET.fromstring(connection.domain_xml[0]).findtext("uuid") # noqa: S314 - test XML @@ -378,8 +379,8 @@ def test_native_driver_rejects_inactive_daemon_readback_and_rolls_back(tmp_path) result = driver.realize(networks=(network,), domains=(domain,)) assert [diagnostic.code for diagnostic in result.diagnostics] == ["libvirt-backend.techvault.observation-mismatch"] - assert connection.domains["native-test-demo"].undefined is True - assert connection.networks["native-test-lab"].undefined is True + assert connection.domains[provider_resource_name(domain.address, prefix="native-test")].undefined is True + assert connection.networks[provider_resource_name(network.address, prefix="native-test")].undefined is True assert driver.last_snapshot == {} @@ -399,8 +400,8 @@ def test_native_driver_rejects_substituted_boot_artifact_readback(tmp_path): result = driver.realize(networks=(network,), domains=(domain,)) assert [diagnostic.code for diagnostic in result.diagnostics] == ["libvirt-backend.techvault.observation-mismatch"] - assert connection.domains["native-test-demo"].undefined is True - assert connection.networks["native-test-lab"].undefined is True + assert connection.domains[provider_resource_name(domain.address, prefix="native-test")].undefined is True + assert connection.networks[provider_resource_name(network.address, prefix="native-test")].undefined is True def test_native_driver_rejects_extra_unbound_network_attachment(tmp_path): @@ -463,8 +464,8 @@ def _fail_binding(*_args): assert [diagnostic.code for diagnostic in result.diagnostics] == [ "libvirt-backend.techvault-native.operation-failed" ] - assert connection.domains["native-test-demo"].undefined is True - assert connection.networks["native-test-lab"].undefined is True + assert connection.domains[provider_resource_name(domain.address, prefix="native-test")].undefined is True + assert connection.networks[provider_resource_name(network.address, prefix="native-test")].undefined is True assert driver.last_snapshot == {} @@ -529,8 +530,8 @@ def test_native_driver_recovers_owned_resources_by_uuid_after_restart(tmp_path): result = restarted.destroy(networks=(network.address,), domains=(domain.address,)) assert not result.diagnostics - assert connection.domains["native-test-demo-display"].undefined is True - assert connection.networks["native-test-lab-display"].undefined is True + assert connection.domains[provider_resource_name(domain.address, prefix="native-test")].undefined is True + assert connection.networks[provider_resource_name(network.address, prefix="native-test")].undefined is True assert all(not path.exists() for path in artifact_paths) @@ -669,7 +670,7 @@ def test_native_driver_verifies_partial_create_rollback_and_reports_residual_sta assert result.domains == () assert "libvirt-backend.techvault-native.operation-failed" in {diagnostic.code for diagnostic in result.diagnostics} - first = connection.domains["aces-techvault-demo-1"] + first = connection.domains[provider_resource_name(domains[0].address, prefix="aces-techvault")] if rollback_fails: assert "libvirt-backend.techvault-native.residual-state" in { diagnostic.code for diagnostic in result.diagnostics @@ -688,9 +689,10 @@ def test_native_driver_verifies_partial_create_rollback_and_reports_residual_sta def test_native_driver_refuses_to_destroy_foreign_name_collision(tmp_path): connection = _FakeConnection() + foreign_name = provider_resource_name("provision.node.demo", prefix="aces-techvault") foreign = _NativeObject( - "aces-techvault-demo", - "aces-techvault-demo00000000-0000-4000-8000-000000000000", + foreign_name, + f"{foreign_name}00000000-0000-4000-8000-000000000000", ) foreign.created = True connection.domains[foreign.name()] = foreign @@ -708,9 +710,10 @@ def test_native_driver_refuses_to_destroy_foreign_name_collision(tmp_path): def test_native_driver_refuses_to_replace_foreign_name_collision(tmp_path): connection = _FakeConnection() + foreign_name = provider_resource_name("provision.node.demo", prefix="aces-techvault") foreign = _NativeObject( - "aces-techvault-demo", - "aces-techvault-demo00000000-0000-4000-8000-000000000000", + foreign_name, + f"{foreign_name}00000000-0000-4000-8000-000000000000", ) foreign.created = True connection.domains[foreign.name()] = foreign @@ -828,7 +831,9 @@ def _driver_factory(): payload = json.loads( (tmp_path / "runs" / "bounded-live" / "live-gate" / "manifest.json").read_text(encoding="utf-8") ) - assert payload["realization_facts"]["daemon_observed"]["domains"] == ["live-test-demo"] + assert payload["realization_facts"]["daemon_observed"]["domains"] == [ + provider_resource_name("provision.node.demo", prefix="live-test") + ] assert payload["realization_facts"]["guest_observed"]["status"] == "not-observed" assert payload["cleanup"] == {"source": "driver-reported", "status": "verified"} assert all(native.undefined for native in (*connection.domains.values(), *connection.networks.values())) @@ -881,9 +886,16 @@ def test_native_driver_rejects_unbound_material_or_secret_configuration(tmp_path TechVaultNativeLibvirtDriver(state_dir=tmp_path / "state", **kwargs) -def test_native_driver_rejects_silently_normalized_resource_name(tmp_path): +def test_native_driver_derives_provider_name_from_address_not_display_name(tmp_path): connection = _FakeConnection() - driver = TechVaultNativeLibvirtDriver(state_dir=tmp_path / "state", connection=connection) + kernel = tmp_path / "vmlinuz" + kernel.write_bytes(b"kernel") + driver = TechVaultNativeLibvirtDriver( + state_dir=tmp_path / "state", + connection=connection, + kernel_path=kernel, + initramfs_builder=_Builder(), + ) domain = DomainSpec( address="provision.node.demo", name="unsafe name", @@ -894,8 +906,9 @@ def test_native_driver_rejects_silently_normalized_resource_name(tmp_path): result = driver.realize(networks=(), domains=(domain,)) - assert [diagnostic.code for diagnostic in result.diagnostics] == ["libvirt-backend.techvault.name-unsupported"] - assert connection.domain_xml == [] + assert not result.diagnostics + assert provider_resource_name(domain.address, prefix="aces-techvault") in connection.domain_xml[0] + assert "unsafe name" not in connection.domain_xml[0] def test_busybox_initramfs_builder_writes_gzip_cpio(tmp_path): diff --git a/implementations/python/tests/test_libvirt_evidence_run.py b/implementations/python/tests/test_libvirt_evidence_run.py index 176304eac..18dba1e98 100644 --- a/implementations/python/tests/test_libvirt_evidence_run.py +++ b/implementations/python/tests/test_libvirt_evidence_run.py @@ -15,6 +15,7 @@ import pytest from aces_backend_libvirt.techvault_native import TechVaultNativeLibvirtDriver +from aces_backend_protocols.naming import provider_resource_name from aces_contracts.contracts import ( BackendManifestV2Model, EvaluationHistoryEventModel, @@ -422,8 +423,8 @@ def test_native_live_realizes_substrate_for_provisionable_scenario(tmp_path): assert artifact["realized_topology"]["basis"] == "mixed-source" native_surface = artifact["realized_topology"]["native_surface"] assert native_surface["source"] == "daemon-observed" - assert native_surface["domains"] == ("evidence-test-demo",) - assert native_surface["networks"] == ("evidence-test-lab",) + assert native_surface["domains"] == (provider_resource_name("provision.node.demo", prefix="evidence-test"),) + assert native_surface["networks"] == (provider_resource_name("provision.network.lab", prefix="evidence-test"),) facts = artifact["realization_facts"] assert facts["planned"]["source"] == "planned" assert facts["driver_reported"]["source"] == "driver-reported" diff --git a/implementations/python/tests/test_reference_backend_components.py b/implementations/python/tests/test_reference_backend_components.py index f28f7ecc0..7d58e481f 100644 --- a/implementations/python/tests/test_reference_backend_components.py +++ b/implementations/python/tests/test_reference_backend_components.py @@ -54,6 +54,7 @@ def _control_plane(): def test_orchestrator_start_records_workflow_result_and_history(): target, control_plane, execution_plan = _control_plane() control_plane.submit_provisioning(execution_plan.provisioning) + control_plane.submit_evaluation(execution_plan.evaluation) receipt = control_plane.submit_orchestration(execution_plan.orchestration) status = control_plane.get_operation(receipt.operation_id) diff --git a/implementations/python/tests/test_reference_backend_oci_driver.py b/implementations/python/tests/test_reference_backend_oci_driver.py index 3e79dd40e..adbf330ed 100644 --- a/implementations/python/tests/test_reference_backend_oci_driver.py +++ b/implementations/python/tests/test_reference_backend_oci_driver.py @@ -5,6 +5,7 @@ import subprocess import pytest +from aces_backend_protocols.naming import provider_resource_name from aces_reference_backend.driver import ContainerSpec, NetworkSpec from aces_reference_backend.drivers.oci import ImageTrustPolicy, OciDeploymentDriver @@ -50,7 +51,7 @@ def test_oci_realize_uses_fixed_argv_list_never_shell(): address="provision.node.web", name="web", image_ref="aces-reference/linux", - networks=("lan",), + networks=("provision.network.lan",), ), ), ) @@ -157,9 +158,7 @@ def test_oci_rejects_unknown_runtime(): def test_oci_destroy_removes_by_the_name_realize_used(): - """Regression: when a payload pins an explicit name that differs from the - address's last segment, destroy must remove the name realize() actually - created, not a name re-derived from the address.""" + """Destroy uses the canonical-address-derived name created by realize.""" recorder = _Recorder(stdout="id\n") driver = _driver(recorder) @@ -172,7 +171,8 @@ def test_oci_destroy_removes_by_the_name_realize_used(): driver.destroy(networks=(), containers=("provision.node.web",)) rm_calls = [call["argv"] for call in recorder.calls] - assert rm_calls == [["docker", "rm", "--force", "pinned-web-name"]] + runtime_name = provider_resource_name("provision.node.web", prefix="aces") + assert rm_calls == [["docker", "rm", "--force", runtime_name]] def test_oci_attaches_container_to_requested_networks(): @@ -198,7 +198,7 @@ def test_oci_attaches_container_to_requested_networks(): run_argv = next(call["argv"] for call in recorder.calls if "run" in call["argv"]) assert "--network" in run_argv - assert run_argv[run_argv.index("--network") + 1] == "lan" + assert run_argv[run_argv.index("--network") + 1] == provider_resource_name("provision.network.lan", prefix="aces") def test_oci_rejects_plan_pinned_image_without_allowlist(): @@ -265,5 +265,10 @@ def __call__(self, argv, **kwargs): assert result.networks == () # no resource is reported as realized assert result.containers == () # The successfully-created network was rolled back. - assert ["docker", "network", "rm", "lan"] in runner.calls + assert [ + "docker", + "network", + "rm", + provider_resource_name("provision.network.lan", prefix="aces"), + ] in runner.calls assert driver.realized_addresses() == frozenset() diff --git a/implementations/python/tests/test_reference_backend_provisioner.py b/implementations/python/tests/test_reference_backend_provisioner.py index 54986ee28..1e9050f99 100644 --- a/implementations/python/tests/test_reference_backend_provisioner.py +++ b/implementations/python/tests/test_reference_backend_provisioner.py @@ -4,6 +4,7 @@ import textwrap +import pytest from aces_contracts.planning import ( ChangeAction, PlannedResource, @@ -142,21 +143,19 @@ def test_snapshot_payload_carries_only_portable_facts(): assert forbidden not in rendered -def test_validate_surfaces_realization_diagnostics_without_driver(): +def test_invalid_resource_type_is_rejected_before_provisioner_validation(): driver = InProcessDriver() - target = _target_with_driver(driver) - bad_plan = ProvisioningPlan( - resources={ - "provision.mystery.x": PlannedResource( - address="provision.mystery.x", - domain=RuntimeDomain.PROVISIONING, - resource_type="mystery", - payload={"name": "x"}, - ) - } - ) - - diagnostics = target.provisioner.validate(bad_plan) - - assert any(diag.code == "reference-backend.realization.unsupported-resource" for diag in diagnostics) + _target_with_driver(driver) + + with pytest.raises(ValueError, match="resource_type must belong"): + ProvisioningPlan( + resources={ + "provision.mystery.x": PlannedResource( + address="provision.mystery.x", + domain=RuntimeDomain.PROVISIONING, + resource_type="mystery", + payload={"name": "x"}, + ) + } + ) assert not driver.recorded_ops diff --git a/implementations/python/tests/test_reference_backend_realization.py b/implementations/python/tests/test_reference_backend_realization.py index a94d47402..49cbc2705 100644 --- a/implementations/python/tests/test_reference_backend_realization.py +++ b/implementations/python/tests/test_reference_backend_realization.py @@ -2,7 +2,7 @@ from __future__ import annotations -from aces_contracts.diagnostics import Severity +import pytest from aces_contracts.planning import ( ChangeAction, PlannedResource, @@ -100,6 +100,15 @@ def test_interpret_passes_through_unresolved_network_reference(): assert realization.containers[0].networks == ("lan",) +def test_interpret_does_not_guess_network_from_address_tail(): + network = _network_resource("provision.network.shared.lan", "shared.lan") + plan = _plan(_node_resource("provision.node.web", "web"), network) + + realization = interpret_provisioning_plan(plan) + + assert realization.containers[0].networks == ("lan",) + + def test_interpret_records_placement_resources(): placement = PlannedResource( address="provision.content.payload", @@ -115,21 +124,15 @@ def test_interpret_records_placement_resources(): assert realization.placements[0].resource_type == "content-placement" -def test_interpret_diagnoses_unsupported_resource_type(): +def test_plan_rejects_unsupported_resource_type_before_interpretation(): bad = PlannedResource( address="provision.mystery.x", domain=RuntimeDomain.PROVISIONING, resource_type="mystery-resource", payload={"name": "x"}, ) - plan = _plan(bad) - - realization = interpret_provisioning_plan(plan) - - assert realization.diagnostics - codes = {diag.code for diag in realization.diagnostics} - assert "reference-backend.realization.unsupported-resource" in codes - assert all(diag.severity == Severity.ERROR for diag in realization.diagnostics) + with pytest.raises(ValueError, match="resource_type must belong"): + _plan(bad) def test_interpret_diagnoses_invalid_node_payload(): diff --git a/implementations/python/tests/test_reference_processor.py b/implementations/python/tests/test_reference_processor.py index b217227fe..2882fe135 100644 --- a/implementations/python/tests/test_reference_processor.py +++ b/implementations/python/tests/test_reference_processor.py @@ -207,8 +207,8 @@ def test_every_claimed_contract_is_exercised_end_to_end(self): control_plane = RuntimeControlPlane(target) for sub_plan, submit in ( (result.execution_plan.provisioning, control_plane.submit_provisioning), - (result.execution_plan.orchestration, control_plane.submit_orchestration), (result.execution_plan.evaluation, control_plane.submit_evaluation), + (result.execution_plan.orchestration, control_plane.submit_orchestration), ): receipt = submit(sub_plan) assert receipt.accepted, receipt.diagnostics diff --git a/implementations/python/tests/test_run_307_shared_operational_state.py b/implementations/python/tests/test_run_307_shared_operational_state.py index 0bd934bfa..a13b7e2b4 100644 --- a/implementations/python/tests/test_run_307_shared_operational_state.py +++ b/implementations/python/tests/test_run_307_shared_operational_state.py @@ -290,7 +290,7 @@ def _backend_apply(_request: object, snapshot: RuntimeSnapshot) -> ApplyResult: ) assert result.success is False - assert any("shared_state_records" in diagnostic.message for diagnostic in result.diagnostics) + assert any(diagnostic.code == "runtime.backend-contract-invalid" for diagnostic in result.diagnostics) def test_backend_apply_rejects_shared_state_history_rewrite() -> None: diff --git a/implementations/python/tests/test_runtime_control_plane.py b/implementations/python/tests/test_runtime_control_plane.py index eb0154bb6..cdb4db380 100644 --- a/implementations/python/tests/test_runtime_control_plane.py +++ b/implementations/python/tests/test_runtime_control_plane.py @@ -14,7 +14,8 @@ ParticipantStatusViewModel, ) from aces_contracts.participant_binding import ParticipantActionAdmissionRequest -from aces_contracts.runtime_state import RuntimeSnapshot +from aces_contracts.planning import ChangeAction, ProvisioningPlan, ProvisionOp +from aces_contracts.runtime_state import RuntimeSnapshot, SnapshotEntry from aces_processor.models import ( iter_participant_behavior_history_violations, iter_participant_episode_snapshot_violations, @@ -281,6 +282,56 @@ def test_control_plane_submits_provisioning_and_updates_snapshot(): assert snapshot.snapshot.entries +def test_control_plane_rejects_dependency_outside_plan_and_snapshot() -> None: + plan = ProvisioningPlan( + operations=[ + ProvisionOp( + action=ChangeAction.CREATE, + address="provision.node.vm", + resource_type="node", + payload={}, + ordering_dependencies=("provision.network.missing",), + ) + ] + ) + control_plane = RuntimeControlPlane(create_stub_target()) + + receipt = control_plane.submit_provisioning(plan) + + assert receipt.accepted is False + assert [diagnostic.code for diagnostic in receipt.diagnostics] == ["runtime.plan-dependency-unresolved"] + + +def test_control_plane_rejects_snapshot_resource_identity_disagreement() -> None: + address = "provision.node.vm" + snapshot = RuntimeSnapshot( + entries={ + address: SnapshotEntry( + address=address, + domain=RuntimeDomain.EVALUATION, + resource_type="objective", + payload={}, + ) + } + ) + plan = ProvisioningPlan( + operations=[ + ProvisionOp( + action=ChangeAction.UPDATE, + address=address, + resource_type="node", + payload={}, + ) + ] + ) + control_plane = RuntimeControlPlane(create_stub_target(), initial_snapshot=snapshot) + + receipt = control_plane.submit_provisioning(plan) + + assert receipt.accepted is False + assert [diagnostic.code for diagnostic in receipt.diagnostics] == ["runtime.plan-resource-incoherent"] + + def test_control_plane_submits_orchestration_with_portable_workflow_state(): scenario = _scenario(""" name: workflow @@ -313,6 +364,10 @@ def test_control_plane_submits_orchestration_with_portable_workflow_state(): execution_plan = plan(compile_runtime_model(scenario), target.manifest) control_plane = RuntimeControlPlane(target) + provisioning_receipt = control_plane.submit_provisioning(execution_plan.provisioning) + assert provisioning_receipt.accepted is True + evaluation_receipt = control_plane.submit_evaluation(execution_plan.evaluation) + assert evaluation_receipt.accepted is True receipt = control_plane.submit_orchestration(execution_plan.orchestration) status = control_plane.get_operation(receipt.operation_id) snapshot = control_plane.get_snapshot() diff --git a/implementations/python/tests/test_runtime_control_plane_api.py b/implementations/python/tests/test_runtime_control_plane_api.py index ada54ea87..3c5c1dd73 100644 --- a/implementations/python/tests/test_runtime_control_plane_api.py +++ b/implementations/python/tests/test_runtime_control_plane_api.py @@ -34,6 +34,13 @@ def _scenario(yaml_str: str): return parse_sdl(textwrap.dedent(yaml_str)) +def _admit_workflow_prerequisites(control_plane: RuntimeControlPlane, execution_plan: object) -> None: + provisioning = control_plane.submit_provisioning(execution_plan.provisioning) + evaluation = control_plane.submit_evaluation(execution_plan.evaluation) + assert provisioning.accepted, provisioning.diagnostics + assert evaluation.accepted, evaluation.diagnostics + + def _participant_operation_record(operation_id: str, participant_address: str) -> ControlPlaneOperationRecord: submitted_at = "2026-06-05T10:00:00Z" return ControlPlaneOperationRecord( @@ -173,6 +180,7 @@ def test_control_plane_api_accepts_orchestration_plan_and_exposes_snapshot(): target = create_stub_target() execution_plan = plan(compile_runtime_model(scenario), target.manifest) control_plane = RuntimeControlPlane(target) + _admit_workflow_prerequisites(control_plane, execution_plan) app = create_control_plane_app( control_plane, security=_test_security(target.name), @@ -247,6 +255,7 @@ def test_control_plane_api_exposes_operational_apparatus_summary_to_auditors(): target = create_stub_target() execution_plan = plan(compile_runtime_model(scenario), target.manifest) control_plane = RuntimeControlPlane(target) + _admit_workflow_prerequisites(control_plane, execution_plan) app = create_control_plane_app( control_plane, security=_test_security(target.name), @@ -284,11 +293,13 @@ def test_control_plane_api_exposes_operational_apparatus_summary_to_auditors(): assert summary["target"] == target.name assert summary["resources"]["total"] >= 1 assert summary["resources"]["by_domain"]["orchestration"] >= 1 - assert summary["operations"]["by_state"]["succeeded"] == 1 - assert summary["operations"]["recent"][0]["operation_id"] == receipt["operation_id"] - assert summary["operations"]["recent"][0]["diagnostic_count"] == 0 - assert summary["operations"]["recent"][0]["diagnostic_codes"] == [] - assert summary["operations"]["recent"][0]["changed_addresses"] + assert summary["operations"]["by_state"]["succeeded"] == 3 + orchestration_record = next( + record for record in summary["operations"]["recent"] if record["operation_id"] == receipt["operation_id"] + ) + assert orchestration_record["diagnostic_count"] == 0 + assert orchestration_record["diagnostic_codes"] == [] + assert orchestration_record["changed_addresses"] assert summary["runtime_surfaces"]["orchestration_results"] >= 1 assert summary["runtime_surfaces"]["orchestration_history"] >= 1 assert summary["audit"]["allowed"] >= 2 @@ -541,6 +552,7 @@ def test_control_plane_api_cancels_workflow_runs(): target = create_stub_target() execution_plan = plan(compile_runtime_model(scenario), target.manifest) control_plane = RuntimeControlPlane(target) + _admit_workflow_prerequisites(control_plane, execution_plan) app = create_control_plane_app( control_plane, security=_test_security(target.name), @@ -615,6 +627,7 @@ def test_control_plane_api_reconciles_workflow_timeouts(): target = create_stub_target() execution_plan = plan(compile_runtime_model(scenario), target.manifest) control_plane = RuntimeControlPlane(target) + _admit_workflow_prerequisites(control_plane, execution_plan) app = create_control_plane_app( control_plane, security=_test_security(target.name), @@ -707,6 +720,7 @@ def test_control_plane_api_cancellation_triggers_compensation_history(): target = create_stub_target() execution_plan = plan(compile_runtime_model(scenario), target.manifest) control_plane = RuntimeControlPlane(target) + _admit_workflow_prerequisites(control_plane, execution_plan) app = create_control_plane_app( control_plane, security=_test_security(target.name), @@ -825,6 +839,7 @@ def test_control_plane_api_timeout_triggers_compensation_history(): target = create_stub_target() execution_plan = plan(compile_runtime_model(scenario), target.manifest) control_plane = RuntimeControlPlane(target) + _admit_workflow_prerequisites(control_plane, execution_plan) app = create_control_plane_app( control_plane, security=_test_security(target.name), diff --git a/implementations/python/tests/test_runtime_datastore.py b/implementations/python/tests/test_runtime_datastore.py index e70a1a0eb..7448f3f4b 100644 --- a/implementations/python/tests/test_runtime_datastore.py +++ b/implementations/python/tests/test_runtime_datastore.py @@ -354,30 +354,30 @@ def test_datastore_cardinality_fields_accept_variable_refs() -> None: **_search_index_service( cluster={ "cluster_id": "wazuh-cluster", - "node_count": "${NODE_COUNT}", - "shard_total": "${SHARDS}", - "shard_primaries": "${PRIMARIES}", - "doc_count": "${DOCS}", - "store_size_bytes": "${BYTES}", + "node_count": "${node_count}", + "shard_total": "${shards}", + "shard_primaries": "${primaries}", + "doc_count": "${docs}", + "store_size_bytes": "${bytes}", }, partitions=[ { "partition_id": "wazuh-alerts", "kind": "index", - "shard_count": "${SHARDS}", - "replica_count": "${REPLICAS}", - "doc_count": "${DOCS}", - "doc_count_deleted": "${DELETED}", - "store_size_bytes": "${BYTES}", + "shard_count": "${shards}", + "replica_count": "${replicas}", + "doc_count": "${docs}", + "doc_count_deleted": "${deleted}", + "store_size_bytes": "${bytes}", } ], ) ) assert svc.cluster is not None - assert svc.cluster.doc_count == "${DOCS}" - assert svc.cluster.store_size_bytes == "${BYTES}" - assert svc.partitions[0].doc_count_deleted == "${DELETED}" + assert svc.cluster.doc_count == "${docs}" + assert svc.cluster.store_size_bytes == "${bytes}" + assert svc.partitions[0].doc_count_deleted == "${deleted}" def test_datastore_cluster_rejects_negative_cardinality() -> None: @@ -491,8 +491,8 @@ def test_relational_and_open_tail_impose_no_profile() -> None: def test_variable_ref_data_model_is_exempt_from_guard() -> None: - svc = RuntimeDatastoreService(datastore_service_id="ds-var", data_model="${DATA_MODEL}") - assert svc.data_model == "${DATA_MODEL}" + svc = RuntimeDatastoreService(datastore_service_id="ds-var", data_model="${data_model}") + assert svc.data_model == "${data_model}" def test_variable_refs_for_mapping_and_template_links_are_exempt_from_resolution_guard() -> None: @@ -501,7 +501,7 @@ def test_variable_refs_for_mapping_and_template_links_are_exempt_from_resolution mappings=[ { "mapping_id": "deferred-mapping", - "partition_ref": "${PARTITION_ID}", + "partition_ref": "${partition_id}", "top_level_field_count": 1, "leaf_field_count": 1, } @@ -509,15 +509,15 @@ def test_variable_refs_for_mapping_and_template_links_are_exempt_from_resolution templates=[ { "template_id": "deferred-template", - "mapping_ref": "${MAPPING_ID}", + "mapping_ref": "${mapping_id}", "index_patterns": ["wazuh-*"], } ], ) ) - assert svc.mappings[0].partition_ref == "${PARTITION_ID}" - assert svc.templates[0].mapping_ref == "${MAPPING_ID}" + assert svc.mappings[0].partition_ref == "${partition_id}" + assert svc.templates[0].mapping_ref == "${mapping_id}" # --------------------------------------------------------------------------- # @@ -573,11 +573,11 @@ def test_mapping_and_template_reject_duplicate_local_lists() -> None: def test_mapping_and_template_ids_reject_variable_placeholders() -> None: - with pytest.raises(ValidationError, match="mapping_id must be a stable identifier"): - RuntimeDatastoreMapping(mapping_id="${MAPPING_ID}") + with pytest.raises(ValidationError, match="mapping_id must be a portable SDL identifier"): + RuntimeDatastoreMapping(mapping_id="${mapping_id}") - with pytest.raises(ValidationError, match="template_id must be a stable identifier"): - RuntimeDatastoreTemplate(template_id="${TEMPLATE_ID}") + with pytest.raises(ValidationError, match="template_id must be a portable SDL identifier"): + RuntimeDatastoreTemplate(template_id="${template_id}") def test_secret_named_setting_may_carry_scenario_value() -> None: @@ -787,8 +787,8 @@ def test_node_heap_bytes_accept_human_int_and_var() -> None: assert node.heap_init_bytes == 536_870_912 assert node.heap_max_bytes == 1_073_741_824 - var_node = RuntimeDatastoreNode(node_id="n2", heap_max_bytes="${HEAP}") - assert var_node.heap_max_bytes == "${HEAP}" + var_node = RuntimeDatastoreNode(node_id="n2", heap_max_bytes="${heap}") + assert var_node.heap_max_bytes == "${heap}" def test_node_rejects_heap_init_above_max() -> None: @@ -797,13 +797,13 @@ def test_node_rejects_heap_init_above_max() -> None: def test_node_heap_ordering_exempt_for_variable_bounds() -> None: - node = RuntimeDatastoreNode(node_id="n1", heap_init_bytes="${INIT}", heap_max_bytes=1024) - assert node.heap_init_bytes == "${INIT}" + node = RuntimeDatastoreNode(node_id="n1", heap_init_bytes="${init}", heap_max_bytes=1024) + assert node.heap_init_bytes == "${init}" def test_node_memory_locked_parses_bool_and_var() -> None: assert RuntimeDatastoreNode(node_id="n1", memory_locked="true").memory_locked is True - assert RuntimeDatastoreNode(node_id="n2", memory_locked="${MLOCK}").memory_locked == "${MLOCK}" + assert RuntimeDatastoreNode(node_id="n2", memory_locked="${mlock}").memory_locked == "${mlock}" def test_engine_plugin_retains_per_plugin_version() -> None: @@ -816,7 +816,7 @@ def test_engine_plugin_retains_per_plugin_version() -> None: def test_engine_plugin_id_must_be_stable_symbol() -> None: with pytest.raises(ValidationError, match="plugin_id"): - RuntimeDatastoreEnginePlugin(plugin_id="${PLUGIN}", name="x") + RuntimeDatastoreEnginePlugin(plugin_id="${plugin}", name="x") with pytest.raises(ValidationError, match="plugin_id"): RuntimeDatastoreEnginePlugin(plugin_id="", name="x") @@ -846,7 +846,7 @@ def test_endpoint_role_rejects_unrecognized_value() -> None: def test_endpoint_id_must_be_stable_symbol() -> None: with pytest.raises(ValidationError, match="endpoint_id"): - RuntimeDatastoreNodeEndpoint(endpoint_id="${E}") + RuntimeDatastoreNodeEndpoint(endpoint_id="${e}") def test_endpoint_port_range_enforced() -> None: diff --git a/implementations/python/tests/test_runtime_forwarding_agent.py b/implementations/python/tests/test_runtime_forwarding_agent.py index 346adbcd0..f46774271 100644 --- a/implementations/python/tests/test_runtime_forwarding_agent.py +++ b/implementations/python/tests/test_runtime_forwarding_agent.py @@ -60,7 +60,7 @@ def _log_forwarder(**overrides) -> dict: "ship_targets": [ { "target_id": "manager", - "target_node_ref": "wazuh.manager", + "target_node_ref": "wazuh-manager", "ingestion_port": 1514, "enrollment_port": 1515, "protocol": "syslog", @@ -185,8 +185,8 @@ def test_open_tail_kinds_impose_no_profile() -> None: def test_variable_ref_agent_kind_is_exempt_from_guard() -> None: - agent = RuntimeForwardingAgent(forwarding_agent_id="agent-var", agent_kind="${AGENT_KIND}") - assert agent.agent_kind == "${AGENT_KIND}" + agent = RuntimeForwardingAgent(forwarding_agent_id="agent-var", agent_kind="${agent_kind}") + assert agent.agent_kind == "${agent_kind}" def test_invalid_enum_value_rejected() -> None: @@ -195,8 +195,8 @@ def test_invalid_enum_value_rejected() -> None: def test_id_fields_reject_variable_placeholders() -> None: - with pytest.raises(ValidationError, match="forwarding_agent_id must be a stable identifier"): - RuntimeForwardingAgent(forwarding_agent_id="${ID}") + with pytest.raises(ValidationError, match="forwarding_agent_id must be a qualified SDL identifier"): + RuntimeForwardingAgent(forwarding_agent_id="${id}") # --------------------------------------------------------------------------- # @@ -403,10 +403,10 @@ def test_scenario_level_surface_reuses_runtime_forwarding_agent() -> None: def test_log_forwarder_target_node_ref_resolves_to_defined_node() -> None: - # The _log_forwarder fixture ships to target_node_ref "wazuh.manager". + # The _log_forwarder fixture ships to target_node_ref "wazuh-manager". scenario = Scenario( name="forwarding", - nodes={"wazuh.manager": _manager_node(), "sensor": _sensor_node(_log_forwarder())}, + nodes={"wazuh-manager": _manager_node(), "sensor": _sensor_node(_log_forwarder())}, ) assert _validate(scenario) == [] @@ -459,7 +459,7 @@ def test_scenario_level_ship_target_service_ref_requires_target_node_ref() -> No ) scenario = Scenario( name="forwarding", - nodes={"wazuh.manager": _manager_node()}, + nodes={"wazuh-manager": _manager_node()}, forwarding_agents=[agent], ) @@ -486,7 +486,7 @@ def test_forwarding_edge_resolves_scenario_level_forwarder() -> None: ship_targets=[ { "target_id": "manager", - "target_node_ref": "wazuh.manager", + "target_node_ref": "wazuh-manager", "target_service_ref": "wazuh-agent-events", "ingestion_port": 1514, "protocol": "syslog", @@ -503,14 +503,14 @@ def test_forwarding_edge_resolves_scenario_level_forwarder() -> None: "resources": {"ram": "1 gib", "cpu": 1}, "services": [{"port": 5432, "protocol": "tcp", "name": "postgres"}], }, - "wazuh.manager": _manager_node(), + "wazuh-manager": _manager_node(), }, forwarding_agents=[agent], relationships={ "db-logs-forwarded-wazuh": { "type": "connects_to", "source": "db", - "target": "wazuh.manager", + "target": "wazuh-manager", "forwarding_edge": { "forwarder_ref": "db-wazuh-agent", "target_listener_role": "agent_event_ingestion", @@ -532,13 +532,13 @@ def test_forwarding_edge_missing_forwarder_reports_combined_resolution_scope() - "resources": {"ram": "1 gib", "cpu": 1}, "services": [{"port": 5432, "protocol": "tcp", "name": "postgres"}], }, - "wazuh.manager": _manager_node(), + "wazuh-manager": _manager_node(), }, relationships={ "db-logs-forwarded-wazuh": { "type": "connects_to", "source": "db", - "target": "wazuh.manager", + "target": "wazuh-manager", "forwarding_edge": {"forwarder_ref": "missing-agent"}, } }, @@ -606,8 +606,8 @@ def test_forwarding_edge_forwarder_ref_required() -> None: def test_forwarding_edge_forwarder_ref_allows_variable_placeholder() -> None: - edge = RelationshipForwardingEdge(forwarder_ref="${FORWARDER}") - assert edge.forwarder_ref == "${FORWARDER}" + edge = RelationshipForwardingEdge(forwarder_ref="${forwarder}") + assert edge.forwarder_ref == "${forwarder}" def test_forwarding_edge_present_enrollment_identity_must_be_redacted() -> None: @@ -648,6 +648,6 @@ def test_forwarding_edge_variable_classification_defers_check() -> None: edge = RelationshipForwardingEdge( forwarder_ref="agent-1", enrollment_identity_ref="agent-key-001", - enrollment_identity_classification="${CLASS}", + enrollment_identity_classification="${class}", ) - assert edge.enrollment_identity_classification == "${CLASS}" + assert edge.enrollment_identity_classification == "${class}" diff --git a/implementations/python/tests/test_runtime_models.py b/implementations/python/tests/test_runtime_models.py index 3ef7dd0e1..f3a8f849c 100644 --- a/implementations/python/tests/test_runtime_models.py +++ b/implementations/python/tests/test_runtime_models.py @@ -893,12 +893,21 @@ def test_parallel_join_compiles_as_barrier_with_typed_predicate(self): } assert not model.diagnostics - def test_module_expansion_compiles_like_flat_scenario(self, tmp_path: Path): + def test_module_expansion_compiles_namespaced_runtime_addresses(self, tmp_path: Path): imported = tmp_path / "shared.yaml" imported.write_text( """ name: shared version: 1.0.0 +module: + id: aces/shared + version: 1.0.0 + exports: + nodes: [vm] + conditions: [health] + entities: [blue] + objectives: [validate] + workflows: [response] nodes: vm: type: vm @@ -942,51 +951,11 @@ def test_module_expansion_compiles_like_flat_scenario(self, tmp_path: Path): """, encoding="utf-8", ) - flat = parse_sdl( - textwrap.dedent( - """ - name: root - nodes: - shared.vm: - type: vm - os: linux - resources: {ram: 1 gib, cpu: 1} - conditions: {shared.health: ops} - roles: {ops: operator} - conditions: - shared.health: - command: /bin/true - interval: 15 - entities: - shared.blue: - role: blue - objectives: - shared.validate: - entity: shared.blue - success: - conditions: [shared.health] - workflows: - shared.response: - start: run - steps: - run: - type: objective - objective: shared.validate - on_success: finish - finish: - type: end - """ - ) - ) - expanded_model = compile_runtime_model(parse_sdl_file(root)) - flat_model = compile_runtime_model(flat) assert not expanded_model.diagnostics - assert not flat_model.diagnostics - assert expanded_model.workflows.keys() == flat_model.workflows.keys() - assert expanded_model.objectives.keys() == flat_model.objectives.keys() - assert expanded_model.condition_bindings.keys() == flat_model.condition_bindings.keys() + assert set(expanded_model.workflows) == {"orchestration.workflow.shared.response"} + assert set(expanded_model.objectives) == {"evaluation.objective.shared.validate"} workflow = expanded_model.workflows["orchestration.workflow.shared.response"] assert workflow.referenced_objective_addresses == ("evaluation.objective.shared.validate",) assert workflow.control_steps["run"].objective_address == "evaluation.objective.shared.validate" diff --git a/implementations/python/tests/test_runtime_planner.py b/implementations/python/tests/test_runtime_planner.py index 94d2f2a54..7151e3f35 100644 --- a/implementations/python/tests/test_runtime_planner.py +++ b/implementations/python/tests/test_runtime_planner.py @@ -1126,6 +1126,12 @@ def test_imported_module_allowed_values_enforce_against_backend(self, tmp_path): """ name: shared version: 1.0.0 +module: + id: aces/shared + version: 1.0.0 + parameters: [os_name] + exports: + nodes: [vm] variables: os_name: type: string diff --git a/implementations/python/tests/test_runtime_scheduled_job.py b/implementations/python/tests/test_runtime_scheduled_job.py index 9e7c2b511..182346f49 100644 --- a/implementations/python/tests/test_runtime_scheduled_job.py +++ b/implementations/python/tests/test_runtime_scheduled_job.py @@ -42,12 +42,12 @@ def test_scheduled_job_full_inventory() -> None: def test_scheduled_job_id_rejects_empty() -> None: - with pytest.raises(ValidationError, match="scheduled_job_id must be a non-empty string"): + with pytest.raises(ValidationError, match="scheduled_job_id must be a portable SDL identifier"): RuntimeScheduledJob(**_job(scheduled_job_id="")) def test_scheduled_job_id_rejects_variable_placeholder() -> None: - with pytest.raises(ValidationError, match="scheduled_job_id must be a stable identifier"): + with pytest.raises(ValidationError, match="scheduled_job_id must be a portable SDL identifier"): RuntimeScheduledJob(**_job(scheduled_job_id="${job_id}")) diff --git a/implementations/python/tests/test_runtime_service_listeners.py b/implementations/python/tests/test_runtime_service_listeners.py index 01f362ed9..a17d8b8a8 100644 --- a/implementations/python/tests/test_runtime_service_listeners.py +++ b/implementations/python/tests/test_runtime_service_listeners.py @@ -248,10 +248,10 @@ def test_runtime_service_listener_published_port_match_defers_unresolved_listene scenario = Scenario( name="listeners", variables={ - "HTTP_PORT": {"type": "integer", "default": 80}, - "TRANSPORT": {"type": "string", "default": "tcp"}, + "http_port": {"type": "integer", "default": 80}, + "transport": {"type": "string", "default": "tcp"}, }, - nodes={"misp": _node(_listener(port="${HTTP_PORT}", protocol="${TRANSPORT}"))}, + nodes={"misp": _node(_listener(port="${http_port}", protocol="${transport}"))}, ) assert _validate(scenario) == [] diff --git a/implementations/python/tests/test_runtime_service_units.py b/implementations/python/tests/test_runtime_service_units.py index d0e59665a..9357a25c5 100644 --- a/implementations/python/tests/test_runtime_service_units.py +++ b/implementations/python/tests/test_runtime_service_units.py @@ -476,7 +476,13 @@ def test_unknown_service_ref_rejected(self): def test_qualified_service_ref_other_node_rejected(self): s = _scenario_with_units( [{**_BASE_UNIT, "service": "nodes.other.services.ssh"}], - nodes={"other": {"type": "vm", "resources": {"ram": "1 gib", "cpu": 1}}}, + nodes={ + "other": { + "type": "vm", + "resources": {"ram": "1 gib", "cpu": 1}, + "services": [{"port": 22, "name": "ssh"}], + } + }, ) errors = _validate(s) assert any("same node" in e for e in errors) @@ -486,14 +492,14 @@ def test_variable_service_ref_deferred(self): s = Scenario( name="t", - variables={"SVC": {"type": "string", "required": True}}, + variables={"svc": {"type": "string", "required": True}}, nodes={ "box": { "type": "vm", "resources": {"ram": "1 gib", "cpu": 1}, "services": [{"port": 22, "name": "ssh"}], "runtime": { - "service_manager_units": [{**_BASE_UNIT, "service": "${SVC}"}], + "service_manager_units": [{**_BASE_UNIT, "service": "${svc}"}], }, }, }, @@ -539,14 +545,14 @@ def test_variable_unit_file_path_deferred(self): s = Scenario( name="t", - variables={"UNIT_PATH": {"type": "string", "required": True}}, + variables={"unit_path": {"type": "string", "required": True}}, nodes={ "box": { "type": "vm", "resources": {"ram": "1 gib", "cpu": 1}, "services": [{"port": 22, "name": "ssh"}], "runtime": { - "service_manager_units": [{**_BASE_UNIT, "unit_file_path": "${UNIT_PATH}"}], + "service_manager_units": [{**_BASE_UNIT, "unit_file_path": "${unit_path}"}], "filesystem_inventory": [ { "path": "/etc/systemd/system/sshd.service", diff --git a/implementations/python/tests/test_runtime_ssh_server.py b/implementations/python/tests/test_runtime_ssh_server.py index 92ac39b0b..1f9553bf3 100644 --- a/implementations/python/tests/test_runtime_ssh_server.py +++ b/implementations/python/tests/test_runtime_ssh_server.py @@ -260,7 +260,7 @@ def test_whitespace_match_id_rejected(self): def test_variable_ref_match_id_rejected(self): with pytest.raises(ValidationError) as exc: SshMatchRule( - match_id="${MATCH_ID}", + match_id="${match_id}", criteria=[SshMatchCriterion(kind=SshMatchCriterionKind.USER, pattern="kali")], ) assert "match_id" in str(exc.value) @@ -341,7 +341,7 @@ def test_whitespace_server_id_rejected(self): def test_variable_ref_server_id_rejected(self): with pytest.raises(ValidationError) as exc: - RuntimeSshServer(ssh_server_id="${SERVER_ID}", service="ssh") + RuntimeSshServer(ssh_server_id="${server_id}", service="ssh") assert "ssh_server_id" in str(exc.value) def test_service_required(self): @@ -621,6 +621,12 @@ def test_ssh_runtime_refs_rewrite_on_module_import(self, tmp_path): """ name: shared-ssh version: 1.0.0 +module: + id: aces/shared-ssh + version: 1.0.0 + exports: + nodes: [kali] + relationships: [ssh-policy] nodes: kali: type: vm diff --git a/implementations/python/tests/test_sdl_canonicalization.py b/implementations/python/tests/test_sdl_canonicalization.py index d50c43d50..7b7b81f10 100644 --- a/implementations/python/tests/test_sdl_canonicalization.py +++ b/implementations/python/tests/test_sdl_canonicalization.py @@ -51,7 +51,7 @@ def test_format_round_trip_preserves_semantic_identity_and_canonical_bytes() -> Description: same Name: round-trip nodes: - Web-App: {Type: SWITCH} + web-app: {Type: SWITCH} """ ) before = parse_sdl(source, migration_policy=SDLMigrationPolicy.ACCEPT) @@ -89,8 +89,8 @@ def test_canonical_bytes_preserve_authored_field_presence() -> None: def test_canonical_bytes_do_not_normalize_unicode() -> None: - composed = parse_sdl("name: caf\N{LATIN SMALL LETTER E WITH ACUTE}\n") - decomposed = parse_sdl("name: cafe\N{COMBINING ACUTE ACCENT}\n") + composed = parse_sdl("name: unicode\ndescription: caf\N{LATIN SMALL LETTER E WITH ACUTE}\n") + decomposed = parse_sdl("name: unicode\ndescription: cafe\N{COMBINING ACUTE ACCENT}\n") assert canonical_sdl_bytes(composed) != canonical_sdl_bytes(decomposed) diff --git a/implementations/python/tests/test_sdl_format_cli.py b/implementations/python/tests/test_sdl_format_cli.py index 0cfbd5697..06a8554ba 100644 --- a/implementations/python/tests/test_sdl_format_cli.py +++ b/implementations/python/tests/test_sdl_format_cli.py @@ -12,15 +12,15 @@ def test_format_api_migrates_fields_and_expands_shorthands() -> None: """\ Name: migrate-me Nodes: - Web-App: + web-app: Type: VM roles: {admin: operator} infrastructure: - Web-App: 1 + web-app: 1 """ ) - assert result.content.startswith("name: migrate-me\nnodes:\n Web-App:\n type: vm\n") + assert result.content.startswith("name: migrate-me\nnodes:\n web-app:\n type: vm\n") assert "username: operator" in result.content assert "count: 1" in result.content assert [item.code for item in result.diagnostics] == [ diff --git a/implementations/python/tests/test_sdl_identifiers.py b/implementations/python/tests/test_sdl_identifiers.py new file mode 100644 index 000000000..a6cf71353 --- /dev/null +++ b/implementations/python/tests/test_sdl_identifiers.py @@ -0,0 +1,848 @@ +"""Cross-boundary tests for the portable SDL identifier contract.""" + +from __future__ import annotations + +from pathlib import Path + +import jsonschema +import pytest +from aces_backend_protocols.naming import provider_resource_name +from aces_contracts.addressing import ( + COMPILED_ADDRESS_JSON_SCHEMA, + COMPILED_ADDRESS_MAX_LENGTH, + render_compiled_address, + require_compiled_address, +) +from aces_contracts.contracts import ( + EvaluationPlanModel, + OrchestrationPlanModel, + ProvisioningPlanModel, + RuntimeSnapshotEnvelopeModel, + schema_bundle, +) +from aces_contracts.planning import ( + ChangeAction, + OrchestrationPlan, + PlannedResource, + ProvisioningPlan, + ProvisionOp, + RuntimeDomain, +) +from aces_contracts.runtime_state import ApplyResult, OperationStatus, RuntimeSnapshot, SnapshotEntry +from aces_processor.compiler import compile_runtime_model +from aces_processor.models import NetworkRuntime, NodeRuntime, RuntimeModel +from aces_sdl._declarations import build_declaration_index +from aces_sdl._errors import SDLParseError, SDLValidationError +from aces_sdl._source_profile import SDLParserLimits +from aces_sdl.identifiers import ( + PORTABLE_IDENTIFIER_JSON_SCHEMA, + QUALIFIED_IDENTIFIER_MAX_LENGTH, + QualifiedName, + is_portable_identifier, + require_portable_identifier, +) +from aces_sdl.infrastructure import ACLRule +from aces_sdl.instantiate import instantiate_scenario +from aces_sdl.nodes import ServicePort +from aces_sdl.parser import _bounded_model_message, parse_sdl +from aces_sdl.runtime_values import require_symbol +from aces_sdl.scenario import ExpandedScenario, ImportDecl, ModuleDescriptor, Scenario +from aces_sdl.validator import SemanticValidator +from hypothesis import given +from hypothesis import strategies as st +from pydantic import ValidationError + +_PORTABLE_CHARACTERS = "abcdefghijklmnopqrstuvwxyz0123456789-_" +_PORTABLE_START = "abcdefghijklmnopqrstuvwxyz0123456789" +_portable_identifier_strategy = st.builds( + lambda first, tail: first + tail, + st.sampled_from(tuple(_PORTABLE_START)), + st.text(alphabet=_PORTABLE_CHARACTERS, min_size=0, max_size=63), +) + + +@pytest.mark.parametrize( + "identifier", + ["a", "0", "001", "a-b", "a_b", "a" * 64], +) +def test_portable_identifier_accepts_exact_boundary_values(identifier: str) -> None: + assert is_portable_identifier(identifier) + assert require_portable_identifier(identifier, field_name="test_id") == identifier + jsonschema.validate(identifier, PORTABLE_IDENTIFIER_JSON_SCHEMA) + + +@pytest.mark.parametrize( + "identifier", + [ + "", + " ", + "a.b", + "a/b", + "a:b", + "A", + "_private", + "caf\u00e9", + "a\n", + "a\r", + "a\x00", + "a" * 65, + "${name}", + ], +) +def test_portable_identifier_rejects_noncanonical_values(identifier: str) -> None: + assert not is_portable_identifier(identifier) + with pytest.raises(ValueError, match="portable SDL identifier"): + require_portable_identifier(identifier, field_name="test_id") + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(identifier, PORTABLE_IDENTIFIER_JSON_SCHEMA) + + +def test_model_diagnostic_messages_are_control_escaped_and_bounded() -> None: + message = _bounded_model_message("invalid\n" + "x" * 600) + + assert "\n" not in message + assert "\\u000a" in message + assert len(message) == 512 + assert message.endswith("...") + + +@given(st.text(max_size=80)) +def test_python_and_json_schema_identifier_grammars_are_differentially_equivalent(value: str) -> None: + schema_accepts = not list(jsonschema.Draft202012Validator(PORTABLE_IDENTIFIER_JSON_SCHEMA).iter_errors(value)) + assert schema_accepts is is_portable_identifier(value) + + +@given( + st.lists(_portable_identifier_strategy, min_size=1, max_size=6), + st.lists(_portable_identifier_strategy, min_size=1, max_size=6), +) +def test_qualified_name_rendering_is_injective_over_portable_segments( + left_parts: list[str], + right_parts: list[str], +) -> None: + left = QualifiedName(tuple(left_parts)) + right = QualifiedName(tuple(right_parts)) + assert (left.render() == right.render()) is (left.parts == right.parts) + + +@pytest.mark.parametrize( + "address", + [ + "nodes.vm", + "nodes.shared.vm", + "nodes.__private.vm", + "a." + "b" * 64, + ], +) +def test_compiled_address_accepts_exact_canonical_values(address: str) -> None: + assert require_compiled_address(address) == address + assert render_compiled_address(*address.split(".")) == address + jsonschema.validate(address, COMPILED_ADDRESS_JSON_SCHEMA) + + +@pytest.mark.parametrize( + "address", + [ + "vm", + ".nodes.vm", + "nodes.vm.", + "nodes..vm", + "nodes.VM", + "nodes.vm\n", + "nodes.caf\u00e9", + "nodes._private.vm", + "nodes." + "b" * 65, + "a." + ".".join("b" for _ in range(COMPILED_ADDRESS_MAX_LENGTH)), + ], +) +def test_compiled_address_rejects_noncanonical_values(address: str) -> None: + with pytest.raises(ValueError, match="canonical compiled address"): + require_compiled_address(address) + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(address, COMPILED_ADDRESS_JSON_SCHEMA) + + +def test_qualified_identifier_has_a_bounded_rendering() -> None: + overlong = ".".join("a" * 64 for _ in range(33)) + assert len(overlong) > QUALIFIED_IDENTIFIER_MAX_LENGTH + with pytest.raises(ValueError, match="maximum length"): + QualifiedName.parse(overlong) + + +def test_authored_declaration_key_reports_source_range() -> None: + source = """\ +name: root +nodes: + bad.name: + type: switch +""" + + with pytest.raises(SDLParseError) as caught: + parse_sdl(source) + + diagnostic = caught.value.diagnostics[0] + assert diagnostic.code == "sdl.identifier.invalid" + assert diagnostic.pointer == "/nodes/bad.name" + assert diagnostic.primary_range.start.line == 3 + assert diagnostic.primary_range.start.column == 3 + assert "bad.name" not in diagnostic.message + + +@pytest.mark.parametrize( + ("source", "pointer", "line"), + [ + ( + """\ +name: root +forwarding_agents: + - forwarding_agent_id: bad.name +""", + "/forwarding_agents/0/forwarding_agent_id", + 3, + ), + ( + """\ +name: root +nodes: + vm: + type: vm + runtime: + database_services: + - database_service_id: bad.name +""", + "/nodes/vm/runtime/database_services/0/database_service_id", + 7, + ), + ], +) +def test_scalar_identifier_reports_its_own_source_range( + source: str, + pointer: str, + line: int, +) -> None: + with pytest.raises(SDLParseError) as caught: + parse_sdl(source, skip_semantic_validation=True) + + diagnostic = caught.value.diagnostics[0] + assert diagnostic.code == "sdl.identifier.invalid" + assert diagnostic.pointer == pointer + assert diagnostic.primary_range.start.line == line + assert "bad.name" not in diagnostic.message + + +def test_identifier_errors_remain_fatal_under_accept_migration_policy() -> None: + source = """\ +name: Root +nodes: {} +""" + + with pytest.raises(SDLParseError) as caught: + parse_sdl(source, migration_policy="accept") + + assert caught.value.diagnostics[0].code == "sdl.identifier.invalid" + assert caught.value.diagnostics[0].severity == "error" + + +@pytest.mark.parametrize("name", ["", "Root", "root.name", "root\n"]) +def test_direct_scenario_construction_enforces_name(name: str) -> None: + with pytest.raises(ValidationError, match="portable SDL identifier"): + Scenario(name=name) + + +def test_direct_scenario_construction_enforces_declaration_keys() -> None: + with pytest.raises(ValidationError, match="portable SDL identifier"): + Scenario(name="root", nodes={"bad.name": {"type": "switch"}}) + + +def test_non_identifier_data_retains_its_own_contract() -> None: + scenario = Scenario( + name="root", + entities={"team": {"name": "\u00c9quipe de recherche"}}, + content={ + "report": { + "type": "file", + "target": "node.with.external.syntax", + "path": "/var/tmp/Report 01.txt", + } + }, + ) + + assert scenario.entities["team"].name == "\u00c9quipe de recherche" + assert scenario.content["report"].path == "/var/tmp/Report 01.txt" + + +@pytest.mark.parametrize("module_id", ["acme", "Acme/shared", "acme/shared/extra", "acme/bad.name"]) +def test_module_id_is_exactly_two_portable_segments(module_id: str) -> None: + with pytest.raises(ValidationError, match="module.id"): + ModuleDescriptor(id=module_id, version="1.0.0") + + +def test_import_requires_explicit_portable_namespace() -> None: + with pytest.raises(ValidationError, match="namespace"): + ImportDecl(source="local:shared.yaml") + with pytest.raises(ValidationError, match="namespace"): + ImportDecl(source="local:shared.yaml", namespace="shared.module") + + +@pytest.mark.parametrize( + ("factory", "field_name"), + [ + (lambda: ServicePort(port=443, name="https.service"), "name"), + (lambda: ACLRule(name="allow.web"), "name"), + ], +) +def test_addressable_nested_names_use_portable_identifiers(factory, field_name: str) -> None: + with pytest.raises(ValidationError, match=field_name): + factory() + + +def test_runtime_stable_ids_use_the_same_identifier_contract() -> None: + with pytest.raises(ValueError, match="portable SDL identifier"): + require_symbol("database.primary", field_name="database_id") + + +def test_raw_forwarding_agent_id_is_local_but_expanded_id_may_be_qualified() -> None: + with pytest.raises(ValidationError, match="portable SDL identifier"): + Scenario( + name="root", + forwarding_agents=[{"forwarding_agent_id": "shared.shipper"}], + ) + + expanded = ExpandedScenario( + name="root", + forwarding_agents=[{"forwarding_agent_id": "shared.shipper"}], + ) + assert expanded.forwarding_agents[0].forwarding_agent_id == "shared.shipper" + + with pytest.raises(ValidationError, match="portable SDL identifier"): + ExpandedScenario( + name="root", + nodes={ + "shared.vm": { + "type": "vm", + "runtime": { + "forwarding_agents": [ + {"forwarding_agent_id": "shared.shipper"}, + ] + }, + } + }, + ) + + +def test_parameter_values_do_not_change_declared_identity() -> None: + scenario = parse_sdl( + """\ +name: parameterized +variables: + label: + type: string + required: true +nodes: + vm: + type: vm + description: ${label} + resources: {ram: 1 gib, cpu: 1} +""", + skip_semantic_validation=True, + ) + + instantiated = instantiate_scenario( + scenario, + parameters={"label": "renamed.node"}, + validate_semantics=False, + ) + + assert set(instantiated.nodes) == {"vm"} + assert instantiated.nodes["vm"].description == "renamed.node" + + +def test_declaration_index_covers_typed_nested_addresses_and_aliases() -> None: + scenario = Scenario( + name="root", + nodes={ + "vm": { + "type": "vm", + "resources": {"ram": "1 gib", "cpu": 1}, + "roles": {"admin": {"username": "root"}}, + "services": [{"port": 443, "name": "https"}], + } + }, + infrastructure={"vm": {"acls": [{"name": "allow-https"}]}}, + entities={"team": {"entities": {"operator": {}}}}, + content={ + "mail": { + "type": "dataset", + "target": "vm", + "items": [{"name": "message", "display_name": "message.eml"}], + } + }, + workflows={"flow": {"start": "done", "steps": {"done": {"type": "end"}}}}, + ) + + index = build_declaration_index(scenario) + + assert { + "scenario.root", + "nodes.vm", + "nodes.vm.roles.admin", + "nodes.vm.services.https", + "infrastructure.vm.acls.allow-https", + "entities.team", + "entities.team.operator", + "content.mail.items.message", + "workflows.flow.steps.done", + }.issubset(index.addresses) + assert index.resolve("https") == set() + assert index.resolve("nodes.vm.services.https") == {"nodes.vm.services.https"} + assert index.resolve("flow.done") == {"workflows.flow.steps.done"} + + +def test_declaration_index_rejects_cross_kind_render_collision() -> None: + scenario = ExpandedScenario( + name="root", + nodes={ + "a": { + "type": "vm", + "resources": {"ram": "1 gib", "cpu": 1}, + "services": [{"port": 443, "name": "b"}], + }, + "a.services.b": {"type": "switch"}, + }, + ) + + with pytest.raises(SDLValidationError) as caught: + build_declaration_index(scenario) + + message = "\n".join(caught.value.errors) + assert "nodes.a.services.b" in message + assert "collides between" in message + assert "service" in message + assert "node" in message + + +def test_runtime_references_are_resolved_by_exact_declaration_not_marker_split() -> None: + application_node = "team.runtime.applications.web" + database_node = "team.runtime.database_services.db" + scenario = ExpandedScenario( + name="root", + nodes={ + application_node: { + "type": "vm", + "services": [{"port": 8080, "name": "http"}], + "runtime": { + "applications": [{"application_id": "frontend", "service": "http"}], + }, + }, + database_node: { + "type": "vm", + "services": [{"port": 5432, "name": "postgres"}], + "runtime": { + "database_services": [ + { + "database_service_id": "primary", + "service": "postgres", + "engine": "postgresql", + "protocol": "postgresql", + "databases": [{"database_id": "experiment", "name": "experiment"}], + "roles": [{"role_id": "writer", "name": "writer"}], + } + ], + }, + }, + }, + relationships={ + "writes": { + "type": "connects_to", + "source": f"nodes.{application_node}.runtime.applications.frontend", + "target": (f"nodes.{database_node}.runtime.database_services.primary.databases.experiment"), + "database_access": { + "role_ref": "writer", + "auth_method": "password", + }, + } + }, + ) + + source = f"nodes.{application_node}.runtime.applications.frontend" + target = f"nodes.{database_node}.runtime.database_services.primary.databases.experiment" + index = build_declaration_index(scenario) + + SemanticValidator(scenario).validate() + assert index.resolve(source) == {source} + assert index.resolve(target) == {target} + + +def test_published_authoring_schema_rejects_nonportable_declaration_key() -> None: + schema = schema_bundle()["sdl-authoring-input-v1"] + payload = {"name": "root", "nodes": {"bad.name": {"type": "switch"}}} + + with pytest.raises(jsonschema.ValidationError): + jsonschema.Draft202012Validator(schema).validate(payload) + + +def test_published_instantiated_schema_accepts_generated_qualified_key() -> None: + schema = schema_bundle()["instantiated-scenario-v1"] + payload = {"name": "root", "nodes": {"shared.vm": {"type": "switch"}}} + + assert list(jsonschema.Draft202012Validator(schema).iter_errors(payload)) == [] + + +def test_published_instantiation_request_schema_rejects_nonportable_parameter_key() -> None: + schema = schema_bundle()["scenario-instantiation-request-v1"] + payload = {"parameters": {"bad.name": "value"}} + + with pytest.raises(jsonschema.ValidationError): + jsonschema.Draft202012Validator(schema).validate(payload) + + +@pytest.mark.parametrize( + ("contract_id", "model", "address", "resource_type"), + [ + ("provisioning-plan-v1", ProvisioningPlanModel, "evaluation.objective.fake", "node"), + ("provisioning-plan-v1", ProvisioningPlanModel, "provision.node.fake", "objective"), + ("orchestration-plan-v1", OrchestrationPlanModel, "provision.node.fake", "workflow"), + ("orchestration-plan-v1", OrchestrationPlanModel, "orchestration.workflow.fake", "node"), + ("evaluation-plan-v1", EvaluationPlanModel, "orchestration.workflow.fake", "objective"), + ("evaluation-plan-v1", EvaluationPlanModel, "evaluation.objective.fake", "workflow"), + ], +) +def test_published_plan_contracts_reject_endpoint_identity_incoherence( + contract_id: str, + model, + address: str, + resource_type: str, +) -> None: + payload = { + "operations": [ + { + "action": "create", + "address": address, + "resource_type": resource_type, + } + ] + } + with pytest.raises(ValidationError, match="must belong to its runtime domain"): + model.model_validate(payload) + with pytest.raises(jsonschema.ValidationError): + jsonschema.Draft202012Validator(schema_bundle()[contract_id]).validate(payload) + + +def test_typed_plan_rejects_new_operation_outside_endpoint_identity() -> None: + with pytest.raises(ValueError, match="address must belong to its runtime domain"): + ProvisioningPlan( + operations=[ + ProvisionOp( + action=ChangeAction.CREATE, + address="evaluation.objective.fake", + resource_type="node", + payload={}, + ) + ] + ) + + +@pytest.mark.parametrize("map_key", ["bad", "a..b"]) +def test_published_snapshot_schema_rejects_noncanonical_entry_keys(map_key: str) -> None: + schema = schema_bundle()["runtime-snapshot-v1"] + + with pytest.raises(jsonschema.ValidationError): + jsonschema.Draft202012Validator(schema).validate({"entries": {map_key: {"arbitrary": "untyped"}}}) + + +def test_published_schema_constrains_runtime_family_identifiers() -> None: + schema = schema_bundle()["sdl-authoring-input-v1"] + payload = { + "name": "root", + "nodes": { + "vm": { + "type": "vm", + "runtime": { + "database_services": [ + {"database_service_id": "bad.name"}, + ] + }, + } + }, + } + + with pytest.raises(jsonschema.ValidationError): + jsonschema.Draft202012Validator(schema).validate(payload) + + +def test_published_schema_separates_top_level_forwarder_identity_phases() -> None: + authoring = schema_bundle()["sdl-authoring-input-v1"] + instantiated = schema_bundle()["instantiated-scenario-v1"] + top_level = { + "name": "root", + "forwarding_agents": [{"forwarding_agent_id": "shared.shipper"}], + } + nested_runtime = { + "name": "root", + "nodes": { + "shared.vm": { + "type": "vm", + "runtime": { + "forwarding_agents": [{"forwarding_agent_id": "shared.shipper"}], + }, + } + }, + } + + with pytest.raises(jsonschema.ValidationError): + jsonschema.Draft202012Validator(authoring).validate(top_level) + jsonschema.Draft202012Validator(instantiated).validate(top_level) + with pytest.raises(jsonschema.ValidationError): + jsonschema.Draft202012Validator(instantiated).validate(nested_runtime) + + +def test_module_composition_uses_one_aggregate_import_budget(tmp_path: Path) -> None: + module = tmp_path / "shared.yaml" + module.write_text( + """\ +name: shared +version: 1.0.0 +module: + id: acme/shared + version: 1.0.0 + exports: + nodes: [vm] +nodes: + vm: {type: switch} +""", + encoding="utf-8", + ) + root = tmp_path / "root.yaml" + root.write_text( + """\ +name: root +imports: + - source: local:shared.yaml + namespace: first + - source: local:shared.yaml + namespace: second +""", + encoding="utf-8", + ) + + limits = SDLParserLimits(max_imports=1) + with pytest.raises(SDLParseError, match="composition import budget"): + from aces_sdl.parser import parse_sdl_file + + parse_sdl_file(root, limits=limits) + + +def test_module_composition_bounds_decoded_bytes_across_the_request(tmp_path: Path) -> None: + module = tmp_path / "shared.yaml" + module.write_text( + """\ +name: shared +version: 1.0.0 +module: + id: acme/shared + version: 1.0.0 + exports: {nodes: [vm]} +nodes: {vm: {type: switch}} +""", + encoding="utf-8", + ) + root = tmp_path / "root.yaml" + root.write_text( + """\ +name: root +imports: + - source: local:shared.yaml + namespace: shared +""", + encoding="utf-8", + ) + + from aces_sdl.parser import parse_sdl_file + + with pytest.raises(SDLParseError, match="decoded-byte budget"): + parse_sdl_file(root, limits=SDLParserLimits(max_composed_bytes=16)) + + +@pytest.mark.parametrize( + ("limits", "message"), + [ + (SDLParserLimits(max_composition_depth=1), "composition depth budget"), + (SDLParserLimits(max_namespace_depth=1), "namespace-depth budget"), + ], +) +def test_nested_composition_bounds_depth_and_namespace_growth( + tmp_path: Path, + limits: SDLParserLimits, + message: str, +) -> None: + (tmp_path / "leaf.yaml").write_text( + """\ +name: leaf +version: 1.0.0 +module: + id: acme/leaf + version: 1.0.0 + exports: {nodes: [vm]} +nodes: {vm: {type: switch}} +""", + encoding="utf-8", + ) + (tmp_path / "middle.yaml").write_text( + """\ +name: middle +version: 1.0.0 +module: + id: acme/middle + version: 1.0.0 + exports: {nodes: [inner.vm]} +imports: + - source: local:leaf.yaml + namespace: inner +""", + encoding="utf-8", + ) + root = tmp_path / "root.yaml" + root.write_text( + """\ +name: root +imports: + - source: local:middle.yaml + namespace: outer +""", + encoding="utf-8", + ) + + from aces_sdl.parser import parse_sdl_file + + with pytest.raises(SDLParseError, match=message): + parse_sdl_file(root, limits=limits) + + +def test_compiled_runtime_model_rejects_map_key_address_mismatch() -> None: + resource = NodeRuntime(address="provision.node.vm", name="vm", spec={}) + + with pytest.raises(ValueError, match="map key"): + RuntimeModel( + scenario_name="root", + node_deployments={"provision.node.other": resource}, + ) + + +def test_compiled_runtime_model_rejects_cross_family_address_collision() -> None: + address = "provision.shared" + + with pytest.raises(ValueError, match="duplicate compiled address"): + RuntimeModel( + scenario_name="root", + networks={address: NetworkRuntime(address=address, name="network", spec={})}, + node_deployments={address: NodeRuntime(address=address, name="node", spec={})}, + ) + + +def test_composed_realization_concern_retains_canonical_resource_address() -> None: + model = compile_runtime_model( + ExpandedScenario( + name="root", + nodes={ + "shared.vm": { + "type": "vm", + "os": "linux", + "resources": {"ram": "1 gib", "cpu": 1}, + } + }, + ) + ) + + requirements = {requirement.field_path: requirement for requirement in model.realization_requirements} + assert requirements["nodes.shared.vm.os"].address == "provision.node.shared.vm" + assert requirements["nodes.shared.vm.type"].address == "provision.node.shared.vm" + + +def test_in_process_plan_rejects_resource_map_key_address_mismatch() -> None: + resource = PlannedResource( + address="provision.node.vm", + domain=RuntimeDomain.PROVISIONING, + resource_type="node", + payload={}, + ) + + with pytest.raises(ValueError, match="resource map key"): + ProvisioningPlan(resources={"provision.node.other": resource}) + + +def test_in_process_plan_rejects_duplicate_operations_and_unknown_startup_address() -> None: + operation = ProvisionOp( + action=ChangeAction.CREATE, + address="provision.node.vm", + resource_type="node", + payload={}, + ) + + with pytest.raises(ValueError, match="operation addresses"): + ProvisioningPlan(operations=[operation, operation]) + with pytest.raises(ValueError, match="admitted operation"): + OrchestrationPlan(startup_order=["orchestration.workflow.flow"]) + + +def test_runtime_snapshot_rejects_map_key_address_mismatch() -> None: + entry = SnapshotEntry( + address="provision.node.vm", + domain=RuntimeDomain.PROVISIONING, + resource_type="node", + payload={}, + ) + + with pytest.raises(ValueError, match="map key"): + RuntimeSnapshot(entries={"provision.node.other": entry}) + with pytest.raises(ValidationError, match="map key"): + RuntimeSnapshotEnvelopeModel( + entries={ + "provision.node.other": { + "address": "provision.node.vm", + "domain": "provisioning", + "resource_type": "node", + } + } + ) + + +@pytest.mark.parametrize("factory", [ApplyResult, OperationStatus]) +def test_runtime_changed_addresses_are_canonical_and_unique(factory) -> None: + kwargs = {"success": True, "snapshot": RuntimeSnapshot()} if factory is ApplyResult else {} + with pytest.raises(ValueError, match="canonical compiled address"): + factory(changed_addresses=["bad address"], **kwargs) + with pytest.raises(ValueError, match="unique"): + factory(changed_addresses=["provision.node.vm", "provision.node.vm"], **kwargs) + + +def test_published_plan_rejects_duplicate_operation_addresses() -> None: + operation = { + "action": "create", + "address": "provision.node.vm", + "resource_type": "node", + } + + with pytest.raises(ValidationError, match="operation addresses"): + ProvisioningPlanModel(operations=[operation, operation]) + + +def test_provider_name_is_bounded_and_collision_resistant_for_full_address() -> None: + first = provider_resource_name( + "provision.node.first.shared", + prefix="aces", + maximum_length=63, + ) + second = provider_resource_name( + "provision.node.second.shared", + prefix="aces", + maximum_length=63, + ) + + assert first != second + assert first == provider_resource_name( + "provision.node.first.shared", + prefix="aces", + maximum_length=63, + ) + assert len(first) <= 63 + assert first.startswith("aces-") diff --git a/implementations/python/tests/test_sdl_models.py b/implementations/python/tests/test_sdl_models.py index 6ec89c6c9..f696af6c9 100644 --- a/implementations/python/tests/test_sdl_models.py +++ b/implementations/python/tests/test_sdl_models.py @@ -1268,11 +1268,11 @@ def test_runtime_container_seccomp_consistency_accepts_agreeing_values(self): def test_runtime_container_seccomp_consistency_allows_variable_placeholder(self): container = RuntimeContainerConfiguration( - seccomp_profile="${SECCOMP}", + seccomp_profile="${seccomp}", security_opt=["seccomp:unconfined"], ) - assert container.seccomp_profile == "${SECCOMP}" + assert container.seccomp_profile == "${seccomp}" def test_runtime_container_seccomp_rejects_disagreeing_security_opt_entries(self): with pytest.raises(ValidationError, match="seccomp_profile"): @@ -1851,14 +1851,14 @@ def test_runtime_network_is_optional(self): def test_endpoint_accepts_variable_placeholders(self): ep = RuntimeNetworkEndpoint( network="aptl-dmz", - ip_address="${WEBAPP_IP}", - gateway="${DMZ_GATEWAY}", - mac_address="${WEBAPP_MAC}", - ip_prefix_length="${PREFIX}", + ip_address="${webapp_ip}", + gateway="${dmz_gateway}", + mac_address="${webapp_mac}", + ip_prefix_length="${prefix}", ) - assert ep.ip_address == "${WEBAPP_IP}" - assert ep.mac_address == "${WEBAPP_MAC}" - assert ep.ip_prefix_length == "${PREFIX}" + assert ep.ip_address == "${webapp_ip}" + assert ep.mac_address == "${webapp_mac}" + assert ep.ip_prefix_length == "${prefix}" def test_published_port_protocol_normalized_and_required(self): binding = RuntimePublishedPort(container_port="443", protocol="TCP") @@ -2313,7 +2313,7 @@ def test_dataset_content(self): type="dataset", target="exchange", format="eml", - items=[ContentItem(name="email.eml", tags=["phishing"])], + items=[ContentItem(name="email", display_name="email.eml", tags=["phishing"])], ) assert len(c.items) == 1 assert c.items[0].tags == ["phishing"] @@ -3011,11 +3011,11 @@ def test_route_method_must_be_known(self): RuntimeApplicationRoute(route_id="r1", path="/login", methods=["FETCH"]) def test_route_id_rejects_variable_placeholder(self): - with pytest.raises(ValidationError, match="must be a stable identifier"): + with pytest.raises(ValidationError, match="portable SDL identifier"): RuntimeApplicationRoute(route_id="${rid}", path="/login", methods=["GET"]) def test_application_id_rejects_variable_placeholder(self): - with pytest.raises(ValidationError, match="must be a stable identifier"): + with pytest.raises(ValidationError, match="portable SDL identifier"): RuntimeApplicationSurface(application_id="${aid}") def test_response_status_code_range(self): @@ -3623,11 +3623,11 @@ def test_listener_port_range_enforced(self): DatabaseListener(address="*", port=70000) def test_database_service_id_rejects_variable_placeholder(self): - with pytest.raises(ValidationError, match="database_service_id must be a stable identifier"): + with pytest.raises(ValidationError, match="database_service_id must be a portable SDL identifier"): RuntimeDatabaseService(database_service_id="${svc}") def test_table_id_rejects_variable_placeholder(self): - with pytest.raises(ValidationError, match="table_id must be a stable identifier"): + with pytest.raises(ValidationError, match="table_id must be a portable SDL identifier"): DatabaseTable(table_id="${t}", name="users") def test_object_name_allows_variable_placeholder(self): @@ -3711,8 +3711,8 @@ def test_secret_bearing_name_with_empty_value_allows_plain_classification(self): assert setting.value_classification == RuntimeSensitivityClassification.PLAIN def test_secret_bearing_name_with_variable_classification_is_skipped(self): - setting = DatabaseSetting(name="password", value_classification="${CLS}") - assert setting.value_classification == "${CLS}" + setting = DatabaseSetting(name="password", value_classification="${cls}") + assert setting.value_classification == "${cls}" def test_non_secret_setting_keeps_default_unknown_classification(self): setting = DatabaseSetting(name="shared_buffers", value="128MB") @@ -3740,9 +3740,9 @@ def test_unknown_engine_rejected(self): RuntimeDatabaseService(database_service_id="svc", engine="cobol-db") def test_engine_protocol_accept_variable_placeholder(self): - svc = RuntimeDatabaseService(database_service_id="svc", engine="${ENGINE}", protocol="${PROTO}") - assert svc.engine == "${ENGINE}" - assert svc.protocol == "${PROTO}" + svc = RuntimeDatabaseService(database_service_id="svc", engine="${engine}", protocol="${proto}") + assert svc.engine == "${engine}" + assert svc.protocol == "${proto}" @pytest.mark.parametrize( "engine,bad_protocol,expected_protocol", @@ -3766,8 +3766,8 @@ def test_postgresql_engine_default_protocol_other_is_rejected(self): RuntimeDatabaseService(database_service_id="svc", engine="postgresql") def test_engine_with_variable_protocol_is_skipped(self): - svc = RuntimeDatabaseService(database_service_id="svc", engine="postgresql", protocol="${PROTO}") - assert svc.protocol == "${PROTO}" + svc = RuntimeDatabaseService(database_service_id="svc", engine="postgresql", protocol="${proto}") + assert svc.protocol == "${proto}" def test_mariadb_engine_accepts_mysql_protocol(self): svc = RuntimeDatabaseService(database_service_id="svc", engine="mariadb", protocol="mysql") diff --git a/implementations/python/tests/test_sdl_module_registry.py b/implementations/python/tests/test_sdl_module_registry.py index e19a6b638..44e6f4f04 100644 --- a/implementations/python/tests/test_sdl_module_registry.py +++ b/implementations/python/tests/test_sdl_module_registry.py @@ -61,22 +61,6 @@ def _local_module(path: Path, *, version: str = "1.2.3", exports: str = "nodes: ) -def _flat_equivalent(path: Path) -> Path: - return _write( - path, - """ - name: flat - nodes: - shared.vm: - type: vm - os: linux - resources: {ram: 1 gib, cpu: 1} - infrastructure: - shared.vm: 1 - """, - ) - - def _root_import(path: Path, import_body: str) -> Path: lines = textwrap.dedent(import_body).strip().splitlines() import_lines = [f" - {lines[0].strip()}"] @@ -181,7 +165,6 @@ def test_local_path_source_and_locked_imports_compile_equivalently(tmp_path: Pat tmp_path / "root-source.yaml", "source: local:shared.yaml\n namespace: shared\n version: 1.2.3", ) - flat = _flat_equivalent(tmp_path / "flat.yaml") runner = CliRunner() resolve_result = runner.invoke(app, ["sdl", "resolve", str(root_source)]) @@ -198,20 +181,14 @@ def test_local_path_source_and_locked_imports_compile_equivalently(tmp_path: Pat path_model = compile_runtime_model(parse_sdl_file(root_path)) source_model = compile_runtime_model(parse_sdl_file(root_source)) locked_model = compile_runtime_model(parse_sdl_file(root_locked)) - flat_model = compile_runtime_model(parse_sdl_file(flat)) assert ( path_model.node_deployments.keys() == source_model.node_deployments.keys() == locked_model.node_deployments.keys() - == flat_model.node_deployments.keys() - ) - assert ( - path_model.networks.keys() - == source_model.networks.keys() - == locked_model.networks.keys() - == flat_model.networks.keys() + == {"provision.node.shared.vm"} ) + assert path_model.networks.keys() == source_model.networks.keys() == locked_model.networks.keys() == set() assert module_path.exists() @@ -275,11 +252,57 @@ def test_module_exports_are_enforced_for_importers(tmp_path: Path): parse_sdl_file(root) +@pytest.mark.parametrize("exported", [True, False]) +def test_scenario_forwarding_agents_compose_by_stable_list_identity(tmp_path: Path, exported: bool): + exports = " forwarding_agents: [shipper]\n" if exported else "" + _write( + tmp_path / "shared.yaml", + f""" + name: shared + version: 1.0.0 + module: + id: aces/shared-forwarder + version: 1.0.0 + exports: + nodes: [source, sink] + relationships: [shipping] + {exports.rstrip()} + nodes: + source: {{type: switch}} + sink: {{type: switch}} + forwarding_agents: + - forwarding_agent_id: shipper + relationships: + shipping: + type: connects_to + source: source + target: sink + forwarding_edge: + forwarder_ref: shipper + """, + ) + root = _root_import( + tmp_path / "root.yaml", + "source: local:shared.yaml\n namespace: shared", + ) + + scenario = parse_sdl_file(root) + + expected = "shared.shipper" if exported else "shared.__private.shipper" + assert isinstance(scenario.forwarding_agents, list) + assert [agent.forwarding_agent_id for agent in scenario.forwarding_agents] == [expected] + assert scenario.relationships["shared.shipping"].forwarding_edge.forwarder_ref == expected + + def test_import_cycles_and_namespace_collisions_are_rejected(tmp_path: Path): a = _write( tmp_path / "a.yaml", """ name: a + version: 1.0.0 + module: + id: aces/a + version: 1.0.0 imports: - source: local:b.yaml namespace: other @@ -289,6 +312,10 @@ def test_import_cycles_and_namespace_collisions_are_rejected(tmp_path: Path): tmp_path / "b.yaml", """ name: b + version: 1.0.0 + module: + id: aces/b + version: 1.0.0 imports: - source: local:a.yaml namespace: other @@ -727,15 +754,11 @@ def test_signed_oci_import_resolution_and_publish_cli(tmp_path: Path): tmp_path / "root-locked.yaml", f"source: locked:{lockfile.imports[0].resolved_source}\n namespace: shared", ) - flat = _flat_equivalent(tmp_path / "flat.yaml") remote_model = compile_runtime_model(parse_sdl_file(root)) locked_model = compile_runtime_model(parse_sdl_file(locked)) - flat_model = compile_runtime_model(parse_sdl_file(flat)) assert ( - remote_model.node_deployments.keys() - == locked_model.node_deployments.keys() - == flat_model.node_deployments.keys() + remote_model.node_deployments.keys() == locked_model.node_deployments.keys() == {"provision.node.shared.vm"} ) diff --git a/implementations/python/tests/test_sdl_parser.py b/implementations/python/tests/test_sdl_parser.py index 1586dd9b1..27af0e0a0 100644 --- a/implementations/python/tests/test_sdl_parser.py +++ b/implementations/python/tests/test_sdl_parser.py @@ -1,6 +1,5 @@ """Tests for SDL parsing, canonical fields, migration, and shorthands.""" -import re from pathlib import Path import pytest @@ -18,13 +17,13 @@ def test_lowercase_keys(self): assert "sw" in s.nodes def test_uppercase_keys(self): - """Explicit migration normalizes fields while preserving identifiers.""" + """Explicit migration normalizes fields without rewriting identifiers.""" s = parse_sdl( - "Name: test\nNodes:\n SW:\n Type: Switch", + "Name: test\nNodes:\n sw:\n Type: Switch", migration_policy=SDLMigrationPolicy.ACCEPT, ) - assert "SW" in s.nodes # user-defined name preserved as-is - assert s.nodes["SW"].type == NodeType.SWITCH # enum value normalized + assert "sw" in s.nodes + assert s.nodes["sw"].type == NodeType.SWITCH assert [diagnostic.code for diagnostic in s.source_diagnostics] == [ "sdl.noncanonical_field", "sdl.noncanonical_field", @@ -148,11 +147,12 @@ def test_non_string_top_level_keys_are_rejected_cleanly(self): ], ) def test_variable_placeholders_rejected_in_mapping_keys(self, sdl, key_path): - with pytest.raises( - SDLParseError, - match=re.escape(f"user-defined mapping keys: '{key_path}'"), - ): + with pytest.raises(SDLParseError) as caught: parse_sdl(sdl) + if ".properties" in key_path: + assert f"user-defined mapping keys: '{key_path}'" in str(caught.value) + else: + assert caught.value.diagnostics[0].code == "sdl.identifier.invalid" def test_variable_declaration_names_must_match_contract_grammar(self): sdl = """ @@ -162,8 +162,10 @@ def test_variable_declaration_names_must_match_contract_grammar(self): type: string default: value """ - with pytest.raises(SDLParseError, match="String should match pattern"): + with pytest.raises(SDLParseError) as caught: parse_sdl(sdl) + assert caught.value.diagnostics[0].code == "sdl.identifier.invalid" + assert caught.value.diagnostics[0].pointer == "/variables/bad.name" class TestShorthandExpansion: @@ -984,8 +986,10 @@ def test_negative_numeric_duration_rejected(self): exercise: scripts: [main] """ - with pytest.raises(SDLParseError, match="Invalid duration"): + with pytest.raises(SDLParseError) as caught: parse_sdl(sdl) + assert caught.value.diagnostics[0].code == "sdl.model.invalid" + assert caught.value.diagnostics[0].pointer == "/scripts/main/start_time" class TestFormat: @@ -1004,8 +1008,10 @@ def test_switch_rejects_vm_only_fields(self): - port: 80 name: http """ - with pytest.raises(SDLParseError, match="Switch nodes cannot have VM-only fields"): + with pytest.raises(SDLParseError) as caught: parse_sdl(sdl) + assert caught.value.diagnostics[0].code == "sdl.model.invalid" + assert caught.value.diagnostics[0].pointer == "/nodes/sw" @pytest.mark.parametrize( "field_name", @@ -1062,8 +1068,10 @@ def test_discriminant_enums_reject_placeholders(self, field_name): default: hello """, } - with pytest.raises(SDLParseError, match=rf"{field_name}[\s\S]*Input should be"): + with pytest.raises(SDLParseError) as caught: parse_sdl(sdl_by_field[field_name], skip_semantic_validation=True) + assert caught.value.diagnostics[0].code == "sdl.model.invalid" + assert caught.value.diagnostics[0].pointer == "/" + field_name.replace(".", "/") @pytest.mark.parametrize( ("sdl", "message"), @@ -1098,8 +1106,15 @@ def test_discriminant_enums_reject_placeholders(self, field_name): ], ) def test_extension_sections_reject_missing_anchor_fields(self, sdl, message): - with pytest.raises(SDLParseError, match=message): + with pytest.raises(SDLParseError) as caught: parse_sdl(sdl) + assert caught.value.diagnostics[0].code == "sdl.model.invalid" + assert caught.value.diagnostics[0].pointer.startswith( + {"Content requires 'target'": "/content/c1", "Account requires 'node'": "/accounts/a1"}.get( + message, + "/agents/red-agent", + ) + ) class TestErrorHandling: @@ -1140,6 +1155,7 @@ def test_parse_sdl_rejects_imports_without_file_context(self): name: root imports: - path: common.yaml + namespace: common """ ) @@ -1149,6 +1165,15 @@ def test_parse_sdl_file_expands_namespaced_imports(self, tmp_path: Path): """ name: common version: 1.2.0 +module: + id: aces/common + version: 1.2.0 + exports: + nodes: [vm] + conditions: [health] + entities: [blue] + objectives: [validate] + workflows: [response] nodes: vm: type: vm @@ -1216,6 +1241,17 @@ def test_parse_sdl_file_namespaces_named_qualified_refs(self, tmp_path: Path): """ name: common version: 1.2.0 +module: + id: aces/common + version: 1.2.0 + exports: + nodes: [vm, net] + infrastructure: [vm, net] + entities: [blue] + conditions: [health] + content: [docs] + relationships: [blue-controls-vm] + agents: [blue-agent] nodes: vm: type: vm @@ -1308,6 +1344,16 @@ def test_parse_sdl_file_namespaces_agent_participant_framing_fields(self, tmp_pa """ name: common version: 1.2.0 +module: + id: aces/common + version: 1.2.0 + exports: + nodes: [vm, net] + infrastructure: [vm, net] + entities: [blue] + conditions: [health] + relationships: [blue-controls-vm] + agents: [blue-agent] nodes: vm: type: vm @@ -1377,6 +1423,11 @@ def test_parse_sdl_file_rejects_version_mismatch(self, tmp_path: Path): """ name: common version: 2.0.0 +module: + id: aces/common + version: 2.0.0 + exports: + nodes: [sw] nodes: sw: type: switch @@ -1389,6 +1440,7 @@ def test_parse_sdl_file_rejects_version_mismatch(self, tmp_path: Path): name: root imports: - path: common.yaml + namespace: common version: 1.0.0 """, encoding="utf-8", @@ -1402,6 +1454,12 @@ def test_parse_sdl_file_rejects_namespace_collisions(self, tmp_path: Path): first.write_text( """ name: shared +version: 1.0.0 +module: + id: aces/first + version: 1.0.0 + exports: + nodes: [vm] nodes: vm: type: vm @@ -1414,6 +1472,12 @@ def test_parse_sdl_file_rejects_namespace_collisions(self, tmp_path: Path): second.write_text( """ name: shared +version: 1.0.0 +module: + id: aces/second + version: 1.0.0 + exports: + nodes: [vm] nodes: vm: type: vm @@ -1451,6 +1515,12 @@ def test_parse_sdl_file_rewrites_database_and_application_relationship_refs(self """ name: shared-db version: 1.0.0 +module: + id: aces/shared-db + version: 1.0.0 + exports: + nodes: [db, web] + relationships: [webapp-to-db] nodes: db: type: vm @@ -1974,8 +2044,10 @@ def test_identity_authority_local_ref_ids_must_be_unique_across_id_families(self - policy_id: {policy_id} applies_to_refs: [{subject_id}] """ - with pytest.raises(SDLParseError, match="Duplicate runtime identity stable id 'shared'"): + with pytest.raises(SDLParseError) as caught: parse_sdl(sdl) + assert caught.value.diagnostics[0].code == "sdl.model.invalid" + assert caught.value.diagnostics[0].pointer == "/nodes/ad/runtime/identity_authorities/0" def test_imported_identity_authority_refs_survive_module_namespacing(self, tmp_path): imported = tmp_path / "shared-directory.yaml" @@ -1983,6 +2055,12 @@ def test_imported_identity_authority_refs_survive_module_namespacing(self, tmp_p """ name: shared-directory version: 1.0.0 +module: + id: aces/shared-directory + version: 1.0.0 + exports: + nodes: [ad] + relationships: [alice-admin, ldap-policy, membership-policy] nodes: ad: type: vm @@ -2096,6 +2174,12 @@ def test_dns_runtime_refs_rewrite_on_module_import(self, tmp_path): """ name: shared-dns version: 1.0.0 +module: + id: aces/shared-dns + version: 1.0.0 + exports: + nodes: [dns] + relationships: [dns-record] nodes: dns: type: vm diff --git a/implementations/python/tests/test_sdl_source_format.py b/implementations/python/tests/test_sdl_source_format.py index da80b53f9..d7435880f 100644 --- a/implementations/python/tests/test_sdl_source_format.py +++ b/implementations/python/tests/test_sdl_source_format.py @@ -129,20 +129,20 @@ def test_migration_policy_accepts_aliases_with_source_ranged_advisories(tmp_path def test_literal_identifiers_are_not_migration_aliases() -> None: - scenario = parse_sdl( - textwrap.dedent( - """ - name: literal-ids - nodes: - Web-App: {type: switch} - web_app: {type: switch} - """ - ), - migration_policy=SDLMigrationPolicy.ACCEPT, - ) + with pytest.raises(SDLParseError) as exc_info: + parse_sdl( + textwrap.dedent( + """ + name: literal-ids + nodes: + Web-App: {type: switch} + web_app: {type: switch} + """ + ), + migration_policy=SDLMigrationPolicy.ACCEPT, + ) - assert set(scenario.nodes) == {"Web-App", "web_app"} - assert scenario.source_diagnostics == () + assert _diagnostic_codes(exc_info.value) == {"sdl.identifier.invalid"} def test_merge_keys_are_migration_only_and_conflicts_remain_fatal() -> None: diff --git a/implementations/python/tests/test_sdl_stress.py b/implementations/python/tests/test_sdl_stress.py index c4e7baf28..6259f7b7f 100644 --- a/implementations/python/tests/test_sdl_stress.py +++ b/implementations/python/tests/test_sdl_stress.py @@ -1103,11 +1103,14 @@ def _parse(yaml_str: str, label: str): description: "Spearphishing emails targeting finance analyst" sensitive: true items: - - name: "Q3 Budget Review - Action Required.eml" + - name: q3-budget-review + display_name: "Q3 Budget Review - Action Required.eml" tags: [phishing, attachment, macro] - - name: "Urgent Wire Transfer Approval.eml" + - name: urgent-wire-transfer + display_name: "Urgent Wire Transfer Approval.eml" tags: [phishing, link, credential-harvesting] - - name: "Updated Benefits Enrollment.eml" + - name: updated-benefits-enrollment + display_name: "Updated Benefits Enrollment.eml" tags: [phishing, attachment, exe-in-zip] sensitive-financials: type: dataset @@ -1117,9 +1120,11 @@ def _parse(yaml_str: str, label: str): description: "Legitimate confidential financial emails" sensitive: true items: - - name: "Board Minutes - Q3 Confidential.eml" + - name: board-minutes-q3 + display_name: "Board Minutes - Q3 Confidential.eml" tags: [pii, financial, exfil-target] - - name: "M&A Target List - Internal Only.eml" + - name: merger-target-list + display_name: "M&A Target List - Internal Only.eml" tags: [financial, exfil-target] planted-webshell: type: file diff --git a/implementations/python/tests/test_sdl_validator.py b/implementations/python/tests/test_sdl_validator.py index f3fb8c2c4..73e4336ba 100644 --- a/implementations/python/tests/test_sdl_validator.py +++ b/implementations/python/tests/test_sdl_validator.py @@ -1,6 +1,7 @@ """Tests for SDL semantic validation.""" import pytest +from pydantic import ValidationError from aces.core.sdl._errors import SDLValidationError from aces.core.sdl.scenario import Scenario @@ -59,13 +60,8 @@ def test_undefined_vulnerability_on_node(self): def test_node_name_too_long(self): long_name = "a" * 36 - s = _make_scenario( - nodes={ - long_name: {"type": "switch"}, - }, - ) - errors = _validate(s) - assert any("35 characters" in e for e in errors) + with pytest.raises(ValidationError, match="35 characters"): + _make_scenario(nodes={long_name: {"type": "switch"}}) @pytest.mark.parametrize( ("field_name", "section_name", "section_value", "error_fragment"), @@ -251,12 +247,12 @@ def test_endpoint_ip_outside_referenced_cidr_is_rejected(self): def test_endpoint_network_variable_reference_is_skipped(self): s = _make_scenario( - variables={"TARGET_NET": {"type": "string", "required": True}}, + variables={"target_net": {"type": "string", "required": True}}, nodes={ "vm": { "type": "vm", "resources": {"ram": "1 gib", "cpu": 1}, - "runtime": {"network": {"endpoints": [{"network": "${TARGET_NET}"}]}}, + "runtime": {"network": {"endpoints": [{"network": "${target_net}"}]}}, }, }, ) @@ -319,7 +315,7 @@ def test_override_subject_missing_from_processes_is_rejected(self): def test_override_with_variable_subject_name_is_skipped(self): s = _make_scenario( - variables={"SHELL_NAME": {"type": "string", "required": True}}, + variables={"shell_name": {"type": "string", "required": True}}, nodes={ "vm": { "type": "vm", @@ -331,7 +327,7 @@ def test_override_with_variable_subject_name_is_skipped(self): "linux_capabilities": { "process_overrides": [ { - "subject": {"name": "${SHELL_NAME}"}, + "subject": {"name": "${shell_name}"}, "scope": "subtree", "drop": ["CAP_AUDIT_CONTROL"], } @@ -636,14 +632,14 @@ def test_valid_relationship(self): errors = _validate(s) assert not errors - def test_relationship_can_target_variable(self): + def test_relationship_rejects_non_targetable_variable(self): s = _make_scenario( nodes={"vm": {"type": "vm", "resources": {"ram": "1 gib", "cpu": 1}}}, variables={"env": {"type": "string", "default": "prod"}}, relationships={"r1": {"type": "connects_to", "source": "vm", "target": "env"}}, ) errors = _validate(s) - assert not errors + assert any("does not reference any defined targetable element" in error for error in errors) def test_relationship_can_target_other_relationship(self): s = _make_scenario( @@ -663,11 +659,11 @@ def test_relationship_can_target_content_item_name(self): "dataset": { "type": "dataset", "target": "vm", - "items": [{"name": "budget.eml"}], + "items": [{"name": "budget-email", "display_name": "budget.eml"}], } }, relationships={ - "r1": {"type": "connects_to", "source": "vm", "target": "budget.eml"}, + "r1": {"type": "connects_to", "source": "vm", "target": "budget-email"}, }, ) errors = _validate(s) @@ -2712,7 +2708,11 @@ def test_application_qualified_service_ref_resolves(self): def test_application_qualified_service_ref_other_node_is_rejected(self): s = _make_scenario( nodes={ - "other": {"type": "vm", "resources": {"ram": "1 gib", "cpu": 1}}, + "other": { + "type": "vm", + "resources": {"ram": "1 gib", "cpu": 1}, + "services": [{"port": 8081, "name": "http"}], + }, "vm": self._node_with_application( {"application_id": "app", "service": "nodes.other.services.http"}, services=[{"port": 8080, "name": "http"}], @@ -2724,9 +2724,9 @@ def test_application_qualified_service_ref_other_node_is_rejected(self): def test_application_service_variable_reference_is_skipped(self): s = _make_scenario( - variables={"SVC": {"type": "string", "required": True}}, + variables={"svc": {"type": "string", "required": True}}, nodes={ - "vm": self._node_with_application({"application_id": "app", "service": "${SVC}"}), + "vm": self._node_with_application({"application_id": "app", "service": "${svc}"}), }, ) assert _validate(s) == [] @@ -3391,7 +3391,7 @@ def test_database_access_source_must_be_a_runtime_application(self): def test_database_access_variable_source_is_skipped(self): # An unresolved ${var} source is left for instantiation, not flagged. - s = self._scenario_with_app_and_db(source="${APP_REF}") + s = self._scenario_with_app_and_db(source="${app_ref}") assert not any("does not resolve to a runtime application" in e for e in _validate(s)) @@ -3616,19 +3616,12 @@ def test_proxy_upstream_agreement_accepts_bare_and_qualified_service_refs(self): ) assert _validate(s) == [] - def test_proxy_upstream_accepts_dotted_node_names_in_qualified_service_refs(self): - s = self._scenario_with_proxy( - route_upstream={ - "target_node_ref": "app.backend", - "target_service": "nodes.app.backend.services.app", - "tls_terminated_here": True, - }, - proxy_upstream={"client_tls_terminated": True}, - proxy_node_name="front.proxy", - backend_node_name="app.backend", - relationship_target="nodes.app.backend.services.app", - ) - assert _validate(s) == [] + def test_proxy_upstream_rejects_dotted_authored_node_names(self): + with pytest.raises(ValidationError, match="nodes declaration key must be a portable SDL identifier"): + self._scenario_with_proxy( + proxy_node_name="front.proxy", + backend_node_name="app.backend", + ) # --------------------------------------------------------------------------- @@ -3682,7 +3675,11 @@ def test_qualified_service_ref_same_node_resolves(self): def test_qualified_service_ref_other_node_rejected(self): s = _make_scenario( nodes={ - "other": {"type": "vm", "resources": {"ram": "1 gib", "cpu": 1}}, + "other": { + "type": "vm", + "resources": {"ram": "1 gib", "cpu": 1}, + "services": [{"port": 2222, "name": "ssh"}], + }, "vm": self._node_with_ssh_server( {"ssh_server_id": "sshd-default", "service": "nodes.other.services.ssh"}, services=[{"port": 22, "name": "ssh"}], @@ -3705,10 +3702,10 @@ def test_malformed_qualified_service_ref_rejected(self): def test_service_variable_reference_skipped(self): s = _make_scenario( - variables={"SVC": {"type": "string", "required": True}}, + variables={"svc": {"type": "string", "required": True}}, nodes={ "vm": self._node_with_ssh_server( - {"ssh_server_id": "sshd-default", "service": "${SVC}"}, + {"ssh_server_id": "sshd-default", "service": "${svc}"}, ), }, ) diff --git a/implementations/python/tests/test_semantics_objectives.py b/implementations/python/tests/test_semantics_objectives.py index 4fa20cf3f..9952bb7f7 100644 --- a/implementations/python/tests/test_semantics_objectives.py +++ b/implementations/python/tests/test_semantics_objectives.py @@ -65,10 +65,26 @@ def _window_issue_codes(analysis) -> set[str]: def _write_objective_window_scenario(path: Path, *, namespace: str = "") -> None: prefix = f"{namespace}." if namespace else "" + module_descriptor = "" + if not namespace: + module_descriptor = """ +module: + id: aces/window + version: 1.0.0 + exports: + conditions: [health] + entities: [blue] + stories: [intro] + scripts: [timeline] + events: [kickoff] + objectives: [observe] + workflows: [flow] +""" path.write_text( f""" name: {namespace or "window"} version: 1.0.0 +{module_descriptor} conditions: {prefix}health: command: /bin/true @@ -309,9 +325,11 @@ def test_composition_ready_invariant_namespace_extends_window_identity_without_c self, tmp_path: Path ) -> None: plain = tmp_path / "plain.yaml" - namespaced = tmp_path / "namespaced.yaml" + imported = tmp_path / "window-module.yaml" + namespaced = tmp_path / "namespaced-root.yaml" _write_objective_window_scenario(plain) - _write_objective_window_scenario(namespaced, namespace="shared") + _write_objective_window_scenario(imported) + _write_importing_root(namespaced, imported.name, namespace="shared") plain_scenario = parse_sdl_file(plain) namespaced_scenario = parse_sdl_file(namespaced) diff --git a/implementations/python/tests/test_yaml_mapping_keys.py b/implementations/python/tests/test_yaml_mapping_keys.py index cd2056ce8..35657e5da 100644 --- a/implementations/python/tests/test_yaml_mapping_keys.py +++ b/implementations/python/tests/test_yaml_mapping_keys.py @@ -71,12 +71,12 @@ def test_literal_identifiers_are_not_field_normalized() -> None: """\ name: literal-identifiers nodes: - Web-App: {type: switch} + web-app: {type: switch} web_app: {type: switch} """ ) - assert tuple(scenario.nodes) == ("Web-App", "web_app") + assert tuple(scenario.nodes) == ("web-app", "web_app") def test_yaml_12_string_like_identifiers_remain_distinct_strings() -> None: @@ -86,12 +86,12 @@ def test_yaml_12_string_like_identifiers_remain_distinct_strings() -> None: nodes: on: {type: switch} "true": {type: switch} - OFF: {type: switch} + off: {type: switch} "false": {type: switch} """ ) - assert tuple(scenario.nodes) == ("on", "true", "OFF", "false") + assert tuple(scenario.nodes) == ("on", "true", "off", "false") def test_core_resolved_non_string_mapping_key_is_rejected_with_a_source_range() -> None: @@ -373,7 +373,7 @@ def test_property_distinct_structural_aliases_never_overwrite(pair: tuple[str, s assert diagnostics[0].pointer == "/accounts/alice/password_strength" -@given(st.sampled_from([("Web-App", "web_app"), ("DB", "db"), ("a-b", "a_b")])) +@given(st.sampled_from([("web-app", "web_app"), ("db-1", "db_1"), ("a-b", "a_b")])) def test_property_literal_identifier_aliases_remain_distinct(pair: tuple[str, str]) -> None: first, second = pair scenario = parse_sdl( diff --git a/specs/sdl/diagnostics.md b/specs/sdl/diagnostics.md index dc9aa86a5..8d4c77e29 100644 --- a/specs/sdl/diagnostics.md +++ b/specs/sdl/diagnostics.md @@ -193,6 +193,8 @@ map, secret, or traceback. | `sdl.mapping_key_type` | Mapping key does not construct as a string | error | error | | `sdl.mapping_key_conflict` | Duplicate or canonicalized collision | error | error | | `sdl.alias_cycle` | Alias graph is cyclic | error | error | +| `sdl.identifier.invalid` | Declaration identity violates the portable local-id contract | error | error | +| `sdl.model.invalid` | Typed model field violates its declared structural contract | error | error | | `sdl.noncanonical_field` | Recognized legacy structural-field spelling | error | warning | | `sdl.noncanonical_merge` | YAML 1.1 `<<` migration syntax | error | warning | @@ -202,3 +204,18 @@ canonical spellings, and the path points to the canonical field. For are retained on the successfully migrated scenario and by formatting, MCP, and CLI adapters. Strict validation is the default at every ordinary parse ingress; migration acceptance requires an explicit caller choice. + +An identifier diagnostic points to the exact defining key or scalar-id token +and carries that token's source range. Its bounded message states the grammar +without echoing the invalid spelling, adjacent value, document fragment, +parameter map, or traceback. `SDLMigrationPolicy.ACCEPT` does not demote or +rewrite an invalid identity; identifier migration requires an explicit atomic +rename of the declaration and all resolved references. + +A typed-model diagnostic preserves the validator-owned contract statement so +an author can determine why the field is invalid. The parser excludes +Pydantic's input rendering and documentation URL, removes framework prefixes, +escapes control characters, and bounds each message to 512 characters before +placing it in `sdl.model.invalid`. The JSON Pointer and source range remain the +authoritative locator; a raw `ValidationError`, input object, traceback, or +unbounded validator rendering is never exposed. diff --git a/specs/sdl/document-model.md b/specs/sdl/document-model.md index e22fe1006..7909deace 100644 --- a/specs/sdl/document-model.md +++ b/specs/sdl/document-model.md @@ -45,12 +45,20 @@ construction: 8. No Unicode normalization is performed. Code-point sequences remain as authored after YAML escape processing. -The profile has fixed denial-of-service bounds: at most 8 MiB of UTF-8 source, +Each source document has fixed denial-of-service bounds: at most 8 MiB of UTF-8 source, 1 MiB in one scalar, depth 128, 100,000 unique representation nodes, 256 alias occurrences, and 250,000 nodes of alias-expanded traversal work. Exceeding any bound is a source error. A future syntax, scalar policy, or incompatible limit set requires a new source-profile identifier. +A file-backed composition request additionally carries one aggregate budget +across the complete import graph. It bounds the number of imports, aggregate +decoded scalar bytes, aggregate structured nodes, recursion depth, generated +namespace depth, and qualified-name length. The structured-node count is also a +conservative declaration bound because every declaration consumes at least one +counted node. Exceeding any aggregate bound is a source error; recursively +starting a fresh per-file budget is non-conforming. + This uniqueness rule follows the YAML 1.2.2 representation model, in which a mapping is an unordered association of unique keys and non-unique keys are a loading failure ([YAML 1.2.2 §§3.2.1.1, 3.3](https://yaml.org/spec/1.2.2/)). @@ -145,34 +153,58 @@ contributions), well-formedness requires the authored node graph; mapped values do not participate in the comparison or its diagnostics. -## 6. Identifier rules for user-defined keys +## 6. Portable identifiers and declaration identity -A user-defined key in a map-valued section is the **identifier** by which an -element is referenced from elsewhere ([references.md](references.md)). The -following rules govern identifiers: +Every ACES-local **declaration identity** uses one portable local-identifier +grammar: -1. **Preservation.** A map key is preserved verbatim as the element identifier; - it is not lowercased, trimmed, or otherwise rewritten. -2. **Uniqueness.** An identifier **MUST** be unique within its collection. Map - semantics make duplicate keys within one section ill-formed; runtime-family - `_id` values **MUST** likewise be unique within their collection - ([runtime-inventory.md](runtime-inventory.md)). -3. **No placeholders in defining keys.** An identifier-defining key **MUST NOT** - be a variable placeholder. Variables parameterise *values*, never the - identity of an element. (`${x}: …` as a section entry is invalid.) -4. **Node identifiers** **MUST** be at most 35 characters. A node identifier - **MAY** contain `.` — dotted node identifiers such as `wazuh.manager` are - used to name service families — and reference resolution accounts for dotted - node names ([references.md](references.md)). -5. **Workflow step identifiers** **MUST NOT** contain `.`, because `.` is the - path separator used to address a step from an objective window - (`.`). -6. **Runtime `_id` values** are stable, symbol-shaped handles: they - identify an element across references and **MUST NOT** carry whitespace or - quoting that would make them unaddressable in a qualified path. - -Beyond these rules, identifier *spelling* is the author's choice; the language -does not impose a global identifier grammar on ordinary section keys. +```text +portable-id = id-start *63id-char +id-start = %x61-7A / DIGIT +id-char = id-start / "-" / "_" +``` + +Equivalently, a portable id is a full-string match for +`^[a-z0-9][a-z0-9_-]{0,63}$`. Implementations **MUST** use full-match semantics; +a `$`-anchored regex alone is insufficient in engines that match before a final +line terminator. The spelling is exact: an implementation **MUST NOT** trim, +case-fold, Unicode-normalize, escape, sanitize, or repair it. Uppercase, +non-ASCII, whitespace, controls, `.`, `/`, `:`, and `${…}` are invalid. + +The rule applies by semantic role, not field spelling. It covers `Scenario.name`; +map-valued section and variable keys; nested entity, role, and workflow-step +keys; named services, ACLs, and content items; scenario-level forwarding-agent +ids; and every ACES-local primary or child id in the runtime-family registry. +It does **not** apply merely because a field is called `name` or ends in `_id`. +Display labels, usernames, DNS names, URLs, paths, LDAP DNs, environment names, +versions, external/native/provider ids, and opaque evidence refs retain their +owning types. Where one object needs both a stable identity and a filename or +label, those are separate fields. + +Identifiers **MUST** be unique within their owning collection. Before aliases +are deduplicated, every declaration is also entered in a document-scoped typed +address index; two distinct declarations that render the same canonical address +make the document invalid. The admitted-document invariant is: + +```text +for all d1,d2 in Declarations(document): + render(address(d1)) = render(address(d2)) implies d1 = d2 +``` + +Variables parameterise values, never declarations. A placeholder **MUST NOT** +appear in any defining identity, and changing a parameter mapping **MUST NOT** +change the declaration set or any canonical address. Node local ids retain the +stricter 35-character maximum. Because YAML Core resolution precedes model +construction, an all-digit id must be quoted so it remains a string. + +Composition is the only operation that creates a **qualified name**: zero or +more portable namespace segments followed by one portable local id, rendered +with `.` separators. The reserved `__private` namespace segment may be generated +for non-exported module declarations but is invalid author input. Qualified +names are bounded to 2048 characters. Raw and normalized authoring objects admit +local ids only; expanded and instantiated objects may carry generated qualified +top-level identities. Nested owner-local ids, including node runtime-family ids, +remain local. ## 7. Document phases and schema boundary @@ -193,7 +225,11 @@ of the authored document with progressively fewer unresolved constructs: 3. **Expanded authoring object.** If the document declares imports, module composition is applied **before** full semantic validation, producing an expanded authoring object in which imported content has been merged under - its namespace + its explicit portable namespace. Each imported unit declares a module + descriptor whose `module.id` is exactly `portable-id "/" portable-id`; + filenames and source paths never supply module or namespace identity. Public + exports receive the namespace prefix and non-exported declarations receive + the generated `__private` prefix ([ADR-053](../../docs/decisions/adrs/adr-053-sdl-module-composition-for-inventory-backed-scenarios.md)). Full semantic validation ([references.md](references.md), [diagnostics.md](diagnostics.md)) applies to @@ -246,3 +282,8 @@ units, preserves array order, emits UTF-8, and rejects non-finite or out-of-doma numbers. The profile digest is SHA-256 over those bytes and is rendered `sha256:<64 lower-case hexadecimal digits>`. A change to the envelope, presence rule, or canonicalization algorithm requires a new profile identifier. + +JCS is the serialization rule for this profile; it is not the source of SDL's +identifier grammar or address semantics. RFC 8785 does not normalize Unicode, +and SDL likewise preserves display/data strings exactly while restricting only +declaration identities to the portable ASCII grammar above. diff --git a/specs/sdl/references.md b/specs/sdl/references.md index 9f7e29075..89e54aec3 100644 --- a/specs/sdl/references.md +++ b/specs/sdl/references.md @@ -24,8 +24,8 @@ A reference is a string that names a target element. Five forms exist: `…...` to any depth the family defines ([runtime-inventory.md](runtime-inventory.md)). 4. **Workflow-step** — `.`, naming a step within a workflow. - Used by objective windows. Because `.` separates the workflow from the step, - workflow **step** identifiers MUST NOT contain `.` + Used by objective windows. The workflow portion may be a composition-generated + qualified name and the step is exactly one portable local-id segment ([document-model.md §6](document-model.md)). 5. **Module-composed (namespaced)** — after a module import is expanded, imported elements are addressed under their import namespace, and node segments are @@ -33,13 +33,11 @@ A reference is a string that names a target element. Five forms exist: the expanded document ([ADR-053](../../docs/decisions/adrs/adr-053-sdl-module-composition-for-inventory-backed-scenarios.md)). -### Dotted node identifiers - -A node identifier MAY itself contain `.` (e.g. `wazuh.manager`). A qualified -reference that traverses a node segment therefore resolves the **longest** -node-identifier match rather than splitting on the first `.`. Resolution MUST -account for dotted node names so that `nodes.wazuh.manager.runtime.…` addresses -the `wazuh.manager` node, not a `wazuh` node with a `manager` member. +Dots are path syntax, never authored identifier content. A dotted node key in a +raw or normalized authoring object is invalid. A dotted node segment seen after +composition is a validated namespace path and is carried structurally until the +canonical renderer produces the external string; it is not recovered with a +longest-match rule. ## 2. Resolution algorithm @@ -51,7 +49,11 @@ the `wazuh.manager` node, not a `wazuh` node with a `manager` member. objective's `target`, a relationship's `source`/`target`). The candidate set is part of each field's definition and is reflected in the edge catalog (§5). 3. A **bare** reference resolves against the candidate set. A **qualified** - reference resolves against the named section/path and MUST match it exactly. + reference resolves by exact lookup of the typed canonical address and + **MUST** match it exactly. Implementations **MUST NOT** discover ownership by + `split`, `partition`, `rsplit`, longest-prefix guessing, declaration order, + or first match. Compact aliases such as `.` are + constructed and resolved from declared workflow/step pairs. 4. Some targetable candidate sets are deliberately restricted. For example, an objective `target` excludes the `variables`, `objectives`, and `workflows` prefixes; an agent `operating_scope` is restricted to VM nodes, @@ -59,6 +61,10 @@ the `wazuh.manager` node, not a `wazuh` node with a `manager` member. field's candidate set does not resolve and fails as dangling (§4). 5. Resolution is **declaration-based**: only declared elements are resolution targets. There is no implicit creation of a target by referencing it. +6. Alias lookup occurs only after the canonical declaration index has retained + kind and provenance for every declaration and rejected address collisions. + A set or map that has already erased a duplicate rendering is not evidence of + uniqueness. ## 3. Unresolved variable placeholders diff --git a/specs/sdl/runtime-inventory.md b/specs/sdl/runtime-inventory.md index 230b1680d..e6a495bb9 100644 --- a/specs/sdl/runtime-inventory.md +++ b/specs/sdl/runtime-inventory.md @@ -82,8 +82,9 @@ not contradict these. 1. **Identity (`_id`).** Every family element and every addressable child element carries a stable `_id`. The id **MUST** be unique within its - collection and symbol-shaped (no whitespace or quoting that would make it - unaddressable in a qualified path; [document-model.md §6](document-model.md)). + collection and use the portable local-identifier grammar + ([document-model.md §6](document-model.md)). Runtime/native/provider ids that + are not ACES-local declaration identities retain their owning contracts. References address elements by these ids ([references.md](references.md)). 2. **Enum sentinels.** An open enum carries a closed core of well-defined values plus the sentinels `unknown` and `other`, so an authored value can record diff --git a/specs/sdl/variables-and-instantiation.md b/specs/sdl/variables-and-instantiation.md index 77a6537ad..25a531269 100644 --- a/specs/sdl/variables-and-instantiation.md +++ b/specs/sdl/variables-and-instantiation.md @@ -29,7 +29,8 @@ Type conformance rules, enforced when the variable is defined: 3. If both `default` and `allowed_values` are set, `default` **MUST** be a member of `allowed_values`. -The variable **name** (the map key) **MUST** match `[A-Za-z_][A-Za-z0-9_-]*` +The variable **name** (the map key) **MUST** use the portable local-identifier +grammar `^[a-z0-9][a-z0-9_-]{0,63}$` ([document-model.md §6](document-model.md)). ## 2. Reference syntax @@ -48,6 +49,12 @@ A placeholder **MUST NOT** appear in an identifier-defining map key ([document-model.md §6](document-model.md)); variables parameterise values, not identities. +Module parameter names, import-parameter keys, and +`scenario-instantiation-request-v1.parameters` keys use the same grammar. +Parameter values retain their owning field types. For any declaration `d` and +two valid parameter environments `p1` and `p2`, canonical identity is invariant: +`address(d, p1) = address(d, p2)`. + Variables are **not** resolved at parse time. An authored document preserves `${…}` placeholders structurally; resolution happens only at instantiation. Authoring-time semantic validation checks every `${name}` token, whether it is a From ef4cbed0a5eabb2e873e90bae2f05a049a39b9f9 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 11 Jul 2026 21:15:59 -0700 Subject: [PATCH 12/15] feat(libvirt): add guest-observed realization probes (ASR-519) (#737) * feat(libvirt): add guest-observed realization probes (ASR-519) Populate the guest_observed evidence slot #714 reserved: concern-specific guest observers that boot a canonical appliance through the production apply path and read realized state from inside the guest (resource, network, content, account, service), freshness-bound to a per-run challenge, with typed redacted failures and verified teardown. - New guest-certified material configuration + realization envelope - Credential-free file-backed-serial guest fact transport (injected seam) - Guest-observing appliance builder + staged observer + observation gate - Evidence artifact + validator extended for guest-observed facts/binding; native-proof boundary (certifying flag) marks injected fakes non-certifying - Cleanup moved to a finally-path; operator command + AWS proof harness - Committed real-daemon evidence report (certifying: true) * fix(libvirt): resolve review + SonarCloud findings for guest-certified probes (ASR-519) - Reject duplicate singleton guest facts with a distinct diagnostic (+ tests) - Accept accounts with no supplemental groups (optional groups field, + test) - Native-proof boundary: guest artifacts carry a `certifying` flag (true only for the production driver); injected fakes are non-certifying (+ tests) - Cover the residual-guest-artifact cleanup fail-closed branch (+ test) - SonarCloud: rename passwd-named constant, dedupe literals, add type hints, reduce returns/cognitive complexity, wrap long lines - Regenerate the committed real-daemon evidence report from the final code --- .../guest-certified-appliance-v1.json | 118 ++++ ...guest-observed-libvirt-probes-preflight.md | 326 +++++++++++ .../techvault-guest-certified.sdl.yaml | 29 + .../_techvault_native_helpers.py | 28 + .../aces_backend_libvirt/envelopes.py | 2 + .../aces_backend_libvirt/guest_appliance.py | 251 ++++++++ .../guest_certified_driver.py | 153 +++++ .../aces_backend_libvirt/guest_observation.py | 362 ++++++++++++ .../aces_backend_libvirt/guest_transport.py | 218 +++++++ .../techvault_concerns.py | 84 +++ .../aces_backend_libvirt/techvault_matrix.py | 74 ++- .../aces_backend_libvirt/techvault_native.py | 103 ++-- .../python/packages/aces_cli/libvirt.py | 57 +- .../aces_operations/_evidence_run_artifact.py | 22 +- .../aces_operations/_evidence_run_types.py | 1 + .../_evidence_run_validation.py | 76 ++- .../aces_operations/libvirt_evidence_run.py | 184 +++++- .../python/tests/test_libvirt_backend_cli.py | 84 +++ .../test_libvirt_backend_guest_certified.py | 548 ++++++++++++++++++ ...rt_backend_guest_certified_real_libvirt.py | 72 +++ tools/policy/adr_policy.yaml | 1 + tools/real-daemon/README.md | 41 ++ ...est-certified-asr519-20260712T031842Z.json | 491 ++++++++++++++++ tools/real-daemon/run_aws_guest_certify.sh | 118 ++++ 24 files changed, 3370 insertions(+), 73 deletions(-) create mode 100644 contracts/realization-envelopes/libvirt-qemu/guest-certified-appliance-v1.json create mode 100644 docs/decisions/issue-715-asr-519-guest-observed-libvirt-probes-preflight.md create mode 100644 examples/scenarios/techvault-guest-certified.sdl.yaml create mode 100644 implementations/python/packages/aces_backend_libvirt/_techvault_native_helpers.py create mode 100644 implementations/python/packages/aces_backend_libvirt/guest_appliance.py create mode 100644 implementations/python/packages/aces_backend_libvirt/guest_certified_driver.py create mode 100644 implementations/python/packages/aces_backend_libvirt/guest_observation.py create mode 100644 implementations/python/packages/aces_backend_libvirt/guest_transport.py create mode 100644 implementations/python/tests/test_libvirt_backend_guest_certified.py create mode 100644 implementations/python/tests/test_libvirt_backend_guest_certified_real_libvirt.py create mode 100644 tools/real-daemon/evidence/guest-certified-asr519-20260712T031842Z.json create mode 100755 tools/real-daemon/run_aws_guest_certify.sh diff --git a/contracts/realization-envelopes/libvirt-qemu/guest-certified-appliance-v1.json b/contracts/realization-envelopes/libvirt-qemu/guest-certified-appliance-v1.json new file mode 100644 index 000000000..0018d09cf --- /dev/null +++ b/contracts/realization-envelopes/libvirt-qemu/guest-certified-appliance-v1.json @@ -0,0 +1,118 @@ +{ + "schema_version": "realization-envelope/v1", + "contract_id": "realization-envelope-v1", + "id": "libvirt-qemu.guest-certified-appliance.v1", + "expression": { + "schema_version": "realization-envelope/v1", + "id": "libvirt-qemu.guest-certified-appliance.expression.v1", + "scope": "scenario", + "domains": {}, + "bindings": [], + "closure": [] + }, + "configuration": { + "mode": "guest-certified-appliance", + "architecture": "x86_64", + "image_policy": "guest-certified-appliance/serial-fact-channel/v1", + "network_policy": "generated-appliance-network", + "supported_node_types": [ + "switch", + "vm" + ], + "supported_os_families": [ + "linux" + ], + "supported_content_types": [ + "file" + ], + "supported_account_features": [ + "disabled", + "groups", + "home", + "shell" + ], + "supports_acls": false, + "memory_mib": { + "minimum": 64, + "maximum": 256 + }, + "vcpus": { + "minimum": 1, + "maximum": 2 + }, + "configuration_digest": "sha256:b33ad469eaf1a47963e49da8adc7376fc22880ac3e6126b427239daa00dc6d99" + }, + "concerns": [ + { + "concern": "topology", + "disposition": "realized", + "observation_strength": "daemon-observed", + "mechanism": "libvirt-domain-network-readback", + "transformations": [] + }, + { + "concern": "architecture", + "disposition": "realized", + "observation_strength": "guest-observed", + "mechanism": "guest-uname-readback", + "transformations": [] + }, + { + "concern": "image", + "disposition": "realized", + "observation_strength": "daemon-observed", + "mechanism": "generated-appliance-attachment-readback", + "transformations": [] + }, + { + "concern": "resource-allocation", + "disposition": "realized", + "observation_strength": "guest-observed", + "mechanism": "guest-proc-resource-readback", + "transformations": [] + }, + { + "concern": "network", + "disposition": "realized", + "observation_strength": "guest-observed", + "mechanism": "guest-link-address-readback", + "transformations": [] + }, + { + "concern": "content-placement", + "disposition": "realized", + "observation_strength": "guest-observed", + "mechanism": "guest-file-content-readback", + "transformations": [] + }, + { + "concern": "account-placement", + "disposition": "realized", + "observation_strength": "guest-observed", + "mechanism": "guest-account-posture-readback", + "transformations": [] + }, + { + "concern": "feature-binding", + "disposition": "unsupported", + "observation_strength": "none", + "mechanism": null, + "transformations": [] + }, + { + "concern": "service", + "disposition": "realized", + "observation_strength": "guest-observed", + "mechanism": "guest-service-state-readback", + "transformations": [] + }, + { + "concern": "acl", + "disposition": "unsupported", + "observation_strength": "none", + "mechanism": null, + "transformations": [] + } + ], + "digest": "sha256:8416a600a6f1864e1b80e49fa60eef5f423f9249f2ea7dc56e14d8a669e33e2e" +} diff --git a/docs/decisions/issue-715-asr-519-guest-observed-libvirt-probes-preflight.md b/docs/decisions/issue-715-asr-519-guest-observed-libvirt-probes-preflight.md new file mode 100644 index 000000000..587e6251d --- /dev/null +++ b/docs/decisions/issue-715-asr-519-guest-observed-libvirt-probes-preflight.md @@ -0,0 +1,326 @@ +# Issue 715 / ASR-519 Guest-Observed Libvirt Probes Preflight + +Date: 2026-07-12 + +Requirement: ASR-519. + +This note records architecture guardrails for proving selected libvirt +realization concerns from inside a booted guest. It is guidance only: it does +not add a probe, image, envelope, schema, test, command, or evidence report, and +it is not an implementation plan. + +No new ADR is required. ADR-021, ADR-066, ADR-070, and the issue #100 and #714 +preflights already own the claim, evidence-plane, realization-envelope, and +configuration-identity boundaries. This note applies them to issue #715. + +## Binding Sources + +- ADR-021 requires falsification evidence before a realization claim is + demonstrated. ADR-066 separates operational observation, captured evidence, + and derived analysis. +- ADR-070, `specs/formal/realization/envelope-semantics.md`, the published + `realization-envelope-v1` schema, and issue #100 own configuration-specific + envelope identity and the closed concern/observation-strength taxonomy. +- `specs/formal/realization/explicitness-and-realization.md` and the issue #491 + preflight own SEM-218 exactness and origin provenance. Backend origin is not + guest observation. +- The issue #603, #604, #606, #714, and #615 preflights own production apply, + ownership-safe teardown, target conformance, TechVault honesty, and evidence + boundaries. +- `ProvisioningPlan`, `Realization`, `DomainSpec`, `NetworkSpec`, + `RealizationObservation`, `DriverResult`, `Diagnostic`, `ApplyResult`, + `OperationStatus`, and `RuntimeSnapshot` are the incumbent execution and + error surfaces. +- `aces_operations.libvirt_evidence_run`, `_evidence_run_artifact`, + `_evidence_run_validation`, `_techvault_cleanup`, `run_artifacts`, and the + existing libvirt CLI commands are the incumbent proof workflow, validation, + redaction, cleanup, and persistence surfaces. + +## Architecture Decisions And Guardrails + +### Select a truthful material configuration + +The current `techvault-appliance-v1` configuration deliberately boots a +generated initramfs and rejects concrete images, cloud-init placements, +services, and ACLs. Keep that configuration and its weaker daemon-only claims +intact. It cannot become guest-certified merely by attaching a probe. + +A canonical image/appliance proof must select a separately constructible, +versioned material configuration and realization envelope. Its secret-free +configuration identity must cover at least the image/appliance content digest, +architecture, boot/firmware and seed policy, network policy, supported concern +set, guest-observation transport and probe-policy version, and any injected +environment-visible attestation mechanism. Host paths, connection handles, +credentials, and raw probe configuration are not digest input. + +Image resolution is target configuration, not new SDL syntax. An authored +source reference must resolve through one closed, configuration-selected image +policy to bytes whose digest is verified before libvirt mutation. A missing, +mutable, mismatched, or unverified image fails before define/create. Do not +overwrite the existing published envelope while retaining its id/digest, and do +not reuse the generic envelope's driver-reported cloud-init claims as +guest-observed proof. + +Manifest capability projection, selected envelope, provisioner identity, driver +mode, and evidence binding must all name the same normalized configuration. +Unknown or inconsistent target config continues to fail through +`_validate_config_keys()`, `_selected_driver_mode()`, +`_validate_manifest_mode()`, the envelope loader, and the provisioner's +identity gate. + +### Keep one concern inventory and layered observation + +Derive the field-addressed concern inventory from the existing +`ProvisioningPlan` -> `Realization` -> `DomainSpec`/`NetworkSpec` path. Probe +selection, comparison, artifact assembly, and mutation tests consume that +inventory; they must not reparse SDL, inspect YAML dictionaries, or maintain a +TechVault-only list of authored fields. + +The maximum claim is determined per field, not per domain: + +| Claim | Required observation | +| --- | --- | +| Native domain identity/state and attachment | ownership-checked libvirt daemon readback | +| Requested vCPU/memory | exact daemon definition plus bounded guest-visible CPU/memory corroboration | +| Guest network attachment/addressing | daemon attachment correlated with guest link/MAC/IP readback | +| Boot readiness | fresh guest transport response; domain `active` is insufficient | +| Initialization completion | guest-reported completion for the selected initialization mechanism, with no failure state | +| File/directory/dataset content | guest-side type, mode, bounded membership, and expected content digest/value comparison | +| Account properties | guest-side identity, groups, home, shell, disabled/credential posture without returning credential material | +| Feature/service state | concern-specific installed/configured identity and active behavior; a listener alone is insufficient | +| ACL behavior | positive and negative behavior at the declared enforcement boundary, correlated to the affected addresses | + +`RealizationObservation` remains the bounded fact carrier and must use +`ObservationStrength.GUEST_OBSERVED` only for a fact actually read at that +boundary. `Diagnostic` remains the failure carrier. Evolve the existing +`NativeLibvirtProbe` injection seam to return typed observations and diagnostics; +do not add a public probe DTO, exception hierarchy, logging-only result, or a +second concern enum. The current boolean/`detail` `ProbeResult` is not an +evidence or error envelope and raw detail must not escape the probe boundary. + +Observation proceeds through explicit stages: daemon state, guest transport, +initialization, concern probes, and cleanup. A later stage cannot repair or +upgrade a failed earlier stage. Each stage has a bounded timeout under one +overall deadline. Timeout, partial boot, unavailable transport, initialization +failure, missing/malformed/duplicate observation, unexpected type, and value +mismatch are distinct stable diagnostic codes that name the safe ACES address +and observation level. + +### Prove freshness and bind the evidence + +A fresh per-run, non-secret challenge must enter the guest through the selected +configuration's disclosed initialization/attestation mechanism and be read back +by the guest observer. The challenge is operational augmentation, not authored +scenario meaning; classify it through the existing SEM-225 augmentation carrier +as environment-visible and comparability-relevant. A prior boot, cached +cloud-init result, old driver snapshot, or response without the current +challenge cannot pass. + +Clear run-local observation state before every apply attempt and publish it only +after complete validation. Evidence assembly binds each normalized observation +to the control-plane operation id, ACES operation/address and field path, +concern, selected envelope/configuration digests, image/appliance digest, +observation source and level, UTC observation timestamp, and a `sha256:` +correlation derived from the ownership-verified native identity. Raw UUIDs, +MACs used only for correlation, instance ids, and native names are not portable +identity and do not enter the artifact. + +The operation id is joined at the operations/control-plane boundary after +`submit_provisioning()`; do not widen `LibvirtDriver.realize()` merely to pass +control-plane metadata into backend IO. The driver supplies fresh addressed +observations and bounded native correlation material, while the evidence +producer supplies run/operation identity and timestamp. Validation must reject +unjoined, stale, cross-operation, cross-envelope, or duplicate evidence. + +### Fail and clean up closed + +Production realization still enters through +`RuntimeManager.plan()` -> `RuntimeControlPlane.submit_provisioning()` -> +`LibvirtProvisioner.apply()` -> the injected native driver. A direct driver +call, hand-built `DomainSpec`, pre-existing VM, or fake connection can test a +leaf but cannot satisfy the native-proof gate. + +Guest proof is part of commit eligibility for the selected guest-certified +configuration. The provisioner cannot return success, changed addresses, a new +envelope/provenance claim, or a committed snapshot until all required daemon +and guest observations pass. Every failure preserves the baseline portable +snapshot. Unexpected backend output still passes through `_call_backend_apply()` +and the existing result/snapshot/SEM-218 gates. + +Cleanup runs in a `finally`-equivalent path after every attempt, including +planning-after-construction errors, timeout, partial boot, guest-probe failure, +artifact-validation failure, and interrupted evidence production. The current +live/evidence flows clean only after a successful captured snapshot; issue #715 +must not retain that limitation. Reuse deterministic ACES ownership stamps and +verified absence rules. Verify domains, networks, owned filters, disks/overlays, +seed media, and probe/attestation artifacts. Missing private driver inventory or +lookup uncertainty is not absence. Any residual or unverifiable category makes +the run fail and is reported only as a safe category/count plus ACES address. + +### Keep one claim-bearing evidence workflow + +Extend the existing `aces.libvirt.scenario-evidence-run/v1` local artifact and +`validate_libvirt_evidence_run_artifact()` source/redaction/binding gates; do not +create a guest-proof schema or a third report assembler. The TechVault live-gate +manifest may remain a bounded operator summary, but it must delegate to or +reference the same validated run result rather than independently decide guest +success. It may never upgrade or omit the canonical artifact's source labels, +failures, binding, or cleanup outcome. + +Continue using `run_artifact_path()`, safe run-id validation, canonical JSON, +full pre-write validation, and `atomic_write_json_artifact()`. A failed run may +emit a redacted failure artifact only when the validator admits that shape; it +cannot set `passed=true` or publish realized facts after failed cleanup. The +operator/self-hosted entry point remains under `aces libvirt`; it must retain an +explicit destructive confirmation and return non-zero on any failed stage. + +Hermetic fake-driver/probe tests validate orchestration and falsification but +cannot satisfy the native-proof gate. Committed proof must be generated by the +same operator command against a real libvirt/QEMU daemon through the production +apply path, validate before commit, identify the selected envelope/image by +digest, and contain no host-specific or secret material. + +## Required Cross-Cutting Reuse + +- **SDL and planning:** `parse_sdl_file()`, closed SDL models, + `instantiate_scenario()`, `SemanticValidator`, `RuntimeManager.plan()`, + `CompiledRealizationRequirement`, `realization_support_diagnostics()`, + `realization_disclosure()`, `ProvisioningPlan`, and processor dependency and + delete ordering. +- **Envelope and manifest:** `BackendRealizationEnvelopeModel`, + `RealizationConcern`, `ConcernDisposition`, `ObservationStrength`, canonical + digest helpers, `load_libvirt_realization_envelope()`, + `backend_manifest_payload()`, `BackendManifestV2Model`, and existing + capability/manifest consistency checks. +- **Libvirt:** `interpret_provisioning_plan()`, `Realization`, `DomainSpec`, + `NetworkSpec`, `RealizationObservation`, `DriverResult`, + `TechVaultNativeLibvirtDriver`, structured XML builders, private seed + workspace safeguards, deterministic ownership UUIDs, safe absence detection, + and current rollback/cleanup helpers. +- **Runtime, errors, and persistence:** `RuntimeControlPlane`, + `_call_backend_diagnostics()`, `_call_backend_apply()`, `Diagnostic`, + `Severity`, `OperationReceipt`, `OperationStatus`, `ApplyResult`, + `RuntimeSnapshot`, realization provenance/envelope carriers, + `ControlPlaneStore`, atomic store writes, and audit events. +- **Evidence and workflow:** `libvirt_evidence_run`, + `validate_libvirt_evidence_run_artifact()`, `redaction_violations()`, + `cleanup_native_snapshot()`, `run_artifact_path()`, + `atomic_write_json_artifact()`, the existing libvirt CLI group, and + `run_target_conformance()` for backend-neutral conformance. +- **Contract governance:** if the published envelope carrier changes, update + the hand-governed schema, model/generator parity, valid/invalid fixtures, + `contracts/schema-publication-manifest.json`, packaged corpus, and + `specs/authority/authority-boundary.yaml`. Evolve the shared contract only + when its current closed fields cannot express a portable fact. + +## Security And Whole-Path Gates + +- **Input and shape:** scenario input passes the parser, closed Pydantic shapes, + semantic validator, compiler/planner, plan model, envelope relation/identity, + provisioner capability and concern gates, then observation completeness/value + validation. Unknown input or unsupported concerns fail before IO; probes do + not parse authored SDL. +- **Target/config:** all driver mode, image policy, probe transport/policy, + timeouts, and augmentation choices pass one extended `_validate_config_keys()` + and normalized target configuration before manifest construction. Library + callers receive the same validation as Typer callers. Image bytes and probe + policy are digest-bound; paths, handles, and credentials are not serialized. +- **Authentication/authorization:** no new HTTP route is required. In-process + operator execution retains explicit confirmation. If provisioning is exposed + through HTTP, it keeps `ControlPlaneSecurityConfig.strict_defaults()`, verified + bearer/proxy identity, backend/operator roles, target scope after either auth + mechanism, request-size limits, idempotency fingerprints, and audit events. + Guest-probe capability does not grant a caller general guest command execution. +- **Secrets:** connection URIs with user information, passwords, tokens, private + keys, cloud-init bodies, account material, probe credentials, environment + dumps, and connector/transport reprs never enter CLI argv, digests, + diagnostics, audit details, snapshots, fixtures, artifacts, or command output. + Prefer a libvirt guest-agent or equivalent injected transport that needs no + guest credential. Any later SSH transport must use an injected credential + handle, strict host identity verification, and bounded allowlisted operations; + accepting a password/key CLI option is forbidden. +- **Guest/host OS exposure:** attach only the selected, disclosed guest channel; + it is not a generic remote shell surface. Probe requests are fixed, + concern-specific, read-only, size-bounded, and allowlisted. Parse bounded + structured output and discard raw stdout/stderr. If a subprocess leaf is + unavoidable, use fixed argv, no `shell=True`, bounded timeouts, controlled + cwd/environment, and no secret argv or environment entries. Keep libvirt + imports lazy and generated secret-bearing seed files under the existing + ownership/symlink/mode protections. +- **Errors/logging:** public failure data is a stable package-local diagnostic + code, safe address, observation level, and generic message. Do not propagate + `ProbeResult.detail`, `str(exc)`, raw agent replies, XML, command output, + paths, native ids, or tracebacks through `_backend_call_failed()`, CLI output, + logs, audit, or evidence. Operational logging is supplemental; it cannot be + the only failure or evidence channel. +- **Persistence/evidence:** runtime snapshots and operation records retain only + portable ACES state and typed envelope/provenance. Guest facts belong in the + validated evidence artifact, not `RuntimeSnapshot.metadata`, + `ApplyResult.details`, or a private observation database. The shared redaction + gate and artifact validator run before atomic persistence. +- **Cleanup:** the host layer verifies owned native objects and every run-local + artifact category after success and failure. It neither scans/deletes by name + prefix nor treats all lookup exceptions as absence. Residual state prevents a + passing operation/report. + +## Extensibility Seam + +The seam is the normalized material target configuration selecting a versioned +envelope plus the existing injected `NativeLibvirtProbe`/driver boundary. The +observer is parameterized by the canonical concern inventory, safe ACES +address/field path, per-stage/overall deadlines, selected probe-policy version, +and envelope identity, and returns only `RealizationObservation` and +`Diagnostic` values. + +A second image family, architecture, initialization system, libvirt guest-agent +implementation, or credential-free transport adds a configuration/envelope +variant and observer implementation. It must not require changes to SDL syntax, +the concern enum, planner actions, runtime control-plane route, error hierarchy, +store, report writer, or evidence-source taxonomy. + +## Falsification Guardrails And Gotchas + +- Mutations must cover false handles, inactive/wrong domains, CPU or memory + mismatch, wrong NIC/MAC/IP, missing current challenge, stale prior snapshot, + partial boot, guest transport timeout/unavailability, omitted or failed + initialization, missing/duplicate/type-coerced facts, wrong content digest, + wrong account properties, synthesized/wrong service identity, ACL positive or + negative mismatch, envelope/image/probe-policy mismatch, and incomplete + cleanup. +- Every failed mutation asserts a failed `OperationStatus`, empty + `changed_addresses`, unchanged in-memory and persisted baseline snapshots, no + new realization provenance/envelope claim, no passing evidence artifact, and + verified cleanup or a redacted residual-state failure. +- Do not equate active domain, ping, TCP connect, guest-agent availability, + cloud-init `done`, package presence, process presence, or a seed descriptor + with all nested concerns. Each is evidence only for its exact named fact. +- Do not compare authored/planned data with an echo written into the guest and + call that independent observation. The probe must read the realized system or + exercise behavior at the enforcement boundary; the per-run challenge proves + freshness, not concern correctness. +- Do not expose a generic command runner, accept probe commands from SDL, infer + checks from service names, duplicate the concern inventory, or make raw probe + output part of the evidence schema. +- Do not let a fake driver/probe, direct driver call, hand-built spec, + pre-existing guest, skipped cleanup, or self-skipping integration test produce + the committed native proof. +- Do not broaden the default hermetic verification graph to require libvirt, + QEMU/KVM, privileges, a host image, network access, or credentials. The + self-hosted proof is an explicit separate gate. + +## Non-Goals And Implementation Boundaries + +- No implementation of issue #715 in this preflight. +- No new SDL syntax, universal image registry, backend profile, capability + language, public probe DTO/schema, control-plane route, exception hierarchy, + persistence service, observation database, or parallel report format. +- No general-purpose guest management/remote-execution API and no participant + observation capability claim. Backend guest realization evidence remains + operational/captured evidence unless a separate governed participant boundary + projects it. +- No claim that one canonical guest proves all images, operating systems, + services, ACL mechanisms, TechVault applications, SOC detection quality, + backend equivalence, or envelope subsumption. +- No issue #716 honesty-conformance runner or issue #717 final scenario + certification. Issue #715 supplies concern-specific observations that those + downstream gates may consume. diff --git a/examples/scenarios/techvault-guest-certified.sdl.yaml b/examples/scenarios/techvault-guest-certified.sdl.yaml new file mode 100644 index 000000000..233a9be81 --- /dev/null +++ b/examples/scenarios/techvault-guest-certified.sdl.yaml @@ -0,0 +1,29 @@ +name: techvault-guest-certified +description: > + Minimal guest-certified realization scenario. One bounded appliance node boots + through the production apply path and is certified from inside the guest - + resource allocation, network addressing, file content, and service state are + read back from the realized guest, freshness-bound to a per-run challenge, with + verified teardown. Used by `aces libvirt techvault guest-certify` and the + opt-in real-daemon proof. +nodes: + guest-net: + type: switch + guest-vm: + type: vm + os: linux + resources: {ram: 128 MiB, cpu: 1} + services: + - {name: beacon, port: 9000, protocol: tcp} +content: + guest-marker: + type: file + target: guest-vm + path: /etc/aces/marker + text: "guest-certified realization marker\n" + sensitive: false +infrastructure: + guest-net: + properties: {cidr: 192.0.2.0/24, gateway: 192.0.2.1, internal: true} + guest-vm: + links: [guest-net] diff --git a/implementations/python/packages/aces_backend_libvirt/_techvault_native_helpers.py b/implementations/python/packages/aces_backend_libvirt/_techvault_native_helpers.py new file mode 100644 index 000000000..d56beb2fb --- /dev/null +++ b/implementations/python/packages/aces_backend_libvirt/_techvault_native_helpers.py @@ -0,0 +1,28 @@ +"""Small self-contained helpers for the native TechVault libvirt drivers.""" + +from __future__ import annotations + +import os +from pathlib import Path + + +def default_connector(connection_uri: str) -> object | None: + """Open a real libvirt connection lazily (imports libvirt on demand).""" + + import importlib + + libvirt = importlib.import_module("libvirt") + return libvirt.open(connection_uri) + + +def default_kernel_path() -> Path: + """Return a host kernel image path suitable for booting a generated appliance.""" + + running = Path(f"/boot/vmlinuz-{os.uname().release}") + if running.exists(): + return running + candidates = sorted(Path("/boot").glob("vmlinuz-*")) + return candidates[-1] if candidates else Path("/boot/vmlinuz") + + +__all__ = ["default_connector", "default_kernel_path"] diff --git a/implementations/python/packages/aces_backend_libvirt/envelopes.py b/implementations/python/packages/aces_backend_libvirt/envelopes.py index feb5aa8f0..34a31a886 100644 --- a/implementations/python/packages/aces_backend_libvirt/envelopes.py +++ b/implementations/python/packages/aces_backend_libvirt/envelopes.py @@ -12,11 +12,13 @@ class LibvirtDriverMode(str, Enum): GENERIC = "generic" TECHVAULT_APPLIANCE = "techvault-appliance" + GUEST_CERTIFIED_APPLIANCE = "guest-certified-appliance" _ARTIFACTS = { LibvirtDriverMode.GENERIC: "generic-v1.json", LibvirtDriverMode.TECHVAULT_APPLIANCE: "techvault-appliance-v1.json", + LibvirtDriverMode.GUEST_CERTIFIED_APPLIANCE: "guest-certified-appliance-v1.json", } diff --git a/implementations/python/packages/aces_backend_libvirt/guest_appliance.py b/implementations/python/packages/aces_backend_libvirt/guest_appliance.py new file mode 100644 index 000000000..2e3ffbb61 --- /dev/null +++ b/implementations/python/packages/aces_backend_libvirt/guest_appliance.py @@ -0,0 +1,251 @@ +"""Guest-observing initramfs appliance for guest-certified libvirt runs. + +Builds a BusyBox appliance that realizes bounded account, file, and service +placements inside the guest, then reads the *realized* system back (its own +``/proc``, ``/etc``, link state, file digests, service state) and emits a +bounded, line-oriented fact report to the file-backed serial fact channel. The +fresh per-run challenge is read from the kernel command line, so the appliance +image bytes (and therefore their digest) are independent of the challenge. +""" + +from __future__ import annotations + +import gzip +import json +import os +import shutil +import tempfile +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path + +from .techvault_appliance import _cpio_newc, _shell_quote + +_APPLETS = ( + "sh", "mount", "mdev", "ip", "ifconfig", "sleep", "cat", "hostname", "printf", "echo", + "uname", "nproc", "awk", "sed", "grep", "cut", "tr", "sort", "head", "wc", + "sha256sum", "stat", "id", "mkdir", "chmod", "chown", "touch", "dirname", "basename", + "netstat", "nc", "kill", "cp", "ls", +) # fmt: skip + +# Baseline account-database file lines written into the appliance rootfs. These +# are /etc/{passwd,group,shadow} record formats for the root account, not secrets. +_ROOT_ACCOUNT_LINE = "root:x:0:0:root:/root:/bin/sh\n" +_ROOT_GROUP_LINE = "root:x:0:\n" +_ROOT_SHADOW_LINE = "root:x:19000:0:99999:7:::\n" + +# Shared shell guard reused across the account/service read loops. +_SKIP_IF_NO_NAME = ' [ -n "$name" ] || continue' + + +@dataclass +class GuestObservingInitramfsBuilder: + """Build a guest-observing appliance that certifies concerns from inside.""" + + busybox_path: Path = Path("/usr/bin/busybox") + + def build(self, *, domain: Mapping[str, object], target: Path) -> Path: + with tempfile.TemporaryDirectory(prefix="aces-guest-initramfs-") as tmp: + root = Path(tmp) + _write_guest_root(root, self.busybox_path, domain) + target.parent.mkdir(parents=True, exist_ok=True) + payload = _cpio_newc(root) + target.write_bytes(gzip.compress(payload, compresslevel=6)) + return target + + +def _write_guest_root(root: Path, busybox_path: Path, domain: Mapping[str, object]) -> None: + bin_dir = root / "bin" + etc_dir = root / "etc" + guest_dir = etc_dir / "aces" / "guest" + files_dir = guest_dir / "files" + for directory in ( + bin_dir, + files_dir, + root / "proc", + root / "sys", + root / "dev", + root / "tmp", + root / "run", + root / "home", + ): + directory.mkdir(parents=True, exist_ok=True) + shutil.copy2(busybox_path, bin_dir / "busybox") + for applet in _APPLETS: + (bin_dir / applet).symlink_to("busybox") + (etc_dir / "passwd").write_text(_ROOT_ACCOUNT_LINE, encoding="utf-8") + (etc_dir / "group").write_text(_ROOT_GROUP_LINE, encoding="utf-8") + (etc_dir / "shadow").write_text(_ROOT_SHADOW_LINE, encoding="utf-8") + _write_placement_specs(guest_dir, files_dir, domain) + (guest_dir / "domain.json").write_text(json.dumps(domain, indent=2, sort_keys=True) + "\n", encoding="utf-8") + (root / "init").write_text(_init_script(domain), encoding="utf-8") + os.chmod(root / "init", 0o700) + os.chmod(bin_dir / "busybox", 0o700) + + +def _write_placement_specs(guest_dir: Path, files_dir: Path, domain: Mapping[str, object]) -> None: + accounts = [ + "|".join( + ( + str(item.get("name", "")), + ",".join(str(group) for group in _as_sequence(item.get("groups"))), + str(item.get("shell", "")), + str(item.get("home", "")), + "1" if item.get("disabled") else "0", + ) + ) + for item in _as_sequence(domain.get("accounts")) + if isinstance(item, Mapping) + ] + content_lines = [] + for index, item in enumerate(_as_sequence(domain.get("content"))): + if not isinstance(item, Mapping): + continue + (files_dir / str(index)).write_text(str(item.get("content", "")), encoding="utf-8") + content_lines.append("|".join((str(item.get("path", "")), str(item.get("mode", "0644")), str(index)))) + services = [ + "|".join((str(item.get("name", "")), str(item.get("port", "")))) + for item in _as_sequence(domain.get("services")) + if isinstance(item, Mapping) + ] + (guest_dir / "accounts").write_text("\n".join(accounts) + ("\n" if accounts else ""), encoding="utf-8") + (guest_dir / "content").write_text("\n".join(content_lines) + ("\n" if content_lines else ""), encoding="utf-8") + (guest_dir / "services").write_text("\n".join(services) + ("\n" if services else ""), encoding="utf-8") + + +def _init_script(domain: Mapping[str, object]) -> str: + lines = [ + "#!/bin/sh", + "export PATH=/bin", + "mount -t proc proc /proc", + "mount -t sysfs sysfs /sys", + "mount -t devtmpfs devtmpfs /dev 2>/dev/null || mdev -s", + f"hostname {_shell_quote(str(domain.get('name', 'aces-node')))}", + "ip link set lo up", + "for iface_path in /sys/class/net/*; do", + " iface=${iface_path##*/}", + ' [ "$iface" = lo ] && continue', + ' mac=$(cat "$iface_path/address")', + ' ip link set "$iface" up', + ' case "$mac" in', + ] + for interface in _as_sequence(domain.get("interfaces")): + if not isinstance(interface, Mapping): + continue + lines.extend( + [ + f" {interface.get('mac')})", + f' ip addr add {interface.get("ip")}/{interface.get("cidr_prefix")} dev "$iface"', + " ;;", + ] + ) + lines.extend([" esac", "done"]) + lines.extend(_REALIZE_SNIPPET) + lines.append("sleep 1") + lines.append("challenge=$(cat /proc/cmdline | tr ' ' '\\n' | sed -n 's/^aces.challenge=//p')") + lines.extend(_REPORT_SNIPPET) + lines.append("while true; do sleep 3600; done") + lines.append("") + return "\n".join(lines) + + +_REALIZE_SNIPPET = [ + "if [ -f /etc/aces/guest/accounts ]; then", + "while IFS='|' read name groups shell home disabled; do", + _SKIP_IF_NO_NAME, + ' [ -n "$home" ] || home=/home/$name', + ' [ -n "$shell" ] || shell=/bin/sh', + ' mkdir -p "$home"', + " uid=$(awk -F: 'BEGIN{m=1000}$3>=m{m=$3+1}END{print m}' /etc/passwd)", + ' echo "$name:x:$uid:$uid:aces:$home:$shell" >> /etc/passwd', + ' echo "$name:x:$uid:" >> /etc/group', + ' if [ "$disabled" = 1 ]; then echo "$name:!:19000:0:99999:7:::" >> /etc/shadow;', + ' else echo "$name:*:19000:0:99999:7:::" >> /etc/shadow; fi', + " oldifs=$IFS; IFS=,", + " for g in $groups; do", + " IFS=$oldifs", + ' [ -n "$g" ] || { IFS=,; continue; }', + ' if grep -q "^$g:" /etc/group; then', + ' sed -i "s/^\\($g:[^:]*:[^:]*:\\)\\(.*\\)$/\\1\\2,$name/" /etc/group', + " else", + " gid=$(awk -F: 'BEGIN{m=2000}$3>=m{m=$3+1}END{print m}' /etc/group)", + ' echo "$g:x:$gid:$name" >> /etc/group', + " fi", + " IFS=,", + " done", + " IFS=$oldifs", + "done < /etc/aces/guest/accounts", + "fi", + "if [ -f /etc/aces/guest/content ]; then", + "while IFS='|' read path mode idx; do", + ' [ -n "$path" ] || continue', + ' mkdir -p "$(dirname "$path")"', + ' cp "/etc/aces/guest/files/$idx" "$path"', + ' chmod "$mode" "$path"', + "done < /etc/aces/guest/content", + "fi", + "if [ -f /etc/aces/guest/services ]; then", + "while IFS='|' read name port; do", + _SKIP_IF_NO_NAME, + ' ( while true; do echo aces-guest-service | nc -l -p "$port" >/dev/null 2>&1 || sleep 1; done ) &', + " echo $! > /run/aces-svc-$name.pid", + "done < /etc/aces/guest/services", + "fi", +] + +_REPORT_SNIPPET = [ + "FC=/dev/ttyS1", + "{", + "echo 'ACES-GUEST-FACTS v1'", + 'echo "challenge $challenge"', + 'echo "architecture $(uname -m)"', + 'echo "vcpus $(nproc)"', + "echo \"memory_mib $(awk '/MemTotal/{print int($2/1024)}' /proc/meminfo)\"", + "for iface_path in /sys/class/net/*; do", + ' iface=${iface_path##*/}; [ "$iface" = lo ] && continue', + ' mac=$(cat "$iface_path/address")', + " ip4=$(ip -4 -o addr show dev \"$iface\" 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -n1)", + ' up=0; [ "$(cat "$iface_path/operstate" 2>/dev/null)" = up ] && up=1', + ' echo "iface $mac ${ip4:-none} $up"', + "done", + "if [ -f /etc/aces/guest/content ]; then", + "while IFS='|' read path mode idx; do", + ' [ -n "$path" ] || continue', + ' [ -f "$path" ] || continue', + " d=$(sha256sum \"$path\" | cut -d' ' -f1)", + " m=$(stat -c '%a' \"$path\" 2>/dev/null)", + ' echo "content $path $d $m"', + "done < /etc/aces/guest/content", + "fi", + "if [ -f /etc/aces/guest/accounts ]; then", + "while IFS='|' read name groups shell home disabled; do", + _SKIP_IF_NO_NAME, + ' entry=$(grep "^$name:" /etc/passwd) || continue', + ' uid=$(echo "$entry" | cut -d: -f3)', + ' h=$(echo "$entry" | cut -d: -f6)', + ' sh=$(echo "$entry" | cut -d: -f7)', + ' grps=$(awk -F: -v u="$name" \'{n=split($4,a,","); for(i=1;i<=n;i++) if(a[i]==u) print $1}\' /etc/group \\', + " | sort | tr '\\n' ',' | sed 's/,$//')", + ' spw=$(grep "^$name:" /etc/shadow | cut -d: -f2)', + " dis=0; case \"$spw\" in '!'*|'*'*) dis=1;; esac", + ' echo "account $name $uid $h $sh $dis $grps"', + "done < /etc/aces/guest/accounts", + "fi", + "if [ -f /etc/aces/guest/services ]; then", + "while IFS='|' read name port; do", + _SKIP_IF_NO_NAME, + ' lis=0; netstat -ln 2>/dev/null | grep -q ":$port " && lis=1', + ' pid=0; [ -f "/run/aces-svc-$name.pid" ] && kill -0 "$(cat /run/aces-svc-$name.pid)" 2>/dev/null && pid=1', + ' echo "service $name $port $lis $pid"', + "done < /etc/aces/guest/services", + "fi", + "echo 'init complete'", + '} > "$FC" 2>/dev/null', +] + + +def _as_sequence(value: object) -> Sequence[object]: + return value if isinstance(value, list | tuple) else () + + +__all__ = ["GuestObservingInitramfsBuilder"] diff --git a/implementations/python/packages/aces_backend_libvirt/guest_certified_driver.py b/implementations/python/packages/aces_backend_libvirt/guest_certified_driver.py new file mode 100644 index 000000000..ca616a323 --- /dev/null +++ b/implementations/python/packages/aces_backend_libvirt/guest_certified_driver.py @@ -0,0 +1,153 @@ +"""Native libvirt driver that certifies realization from inside the guest. + +Extends :class:`TechVaultNativeLibvirtDriver` with the guest-observation stage: +after ownership-safe define/create and daemon readback, it boots a +guest-observing appliance, reads concern-specific facts back through the +credential-free fact channel, and refuses to finalize unless the fresh, +challenge-bound guest observations match the requested realization. The +challenge rides the kernel command line (not the appliance image), so the +appliance content digest stays stable across runs while every report is bound to +this boot. +""" + +from __future__ import annotations + +import re +import secrets +from collections.abc import Mapping +from contextlib import suppress +from dataclasses import dataclass, field +from pathlib import Path +from typing import ClassVar + +from aces_contracts.diagnostics import Diagnostic + +from .driver import DomainSpec, NetworkSpec, RealizationObservation +from .drivers.libvirt import _aces_uuid +from .guest_appliance import GuestObservingInitramfsBuilder +from .guest_observation import GuestObservationConfig, correlation_digest, observe_guest +from .guest_transport import FileSerialGuestFactTransport, GuestFactTransport +from .techvault_appliance import InitramfsBuilder +from .techvault_concerns import guest_certified_spec_diagnostics +from .techvault_matrix import domain_xml as _domain_xml +from .techvault_matrix import native_matrix as _native_matrix +from .techvault_native import DriverResult, TechVaultNativeLibvirtDriver, _artifact_token + +_SAFE_CHALLENGE_RE = re.compile(r"^[a-f0-9]{16,64}$") + + +@dataclass +class GuestCertifiedLibvirtDriver(TechVaultNativeLibvirtDriver): + """Realize TechVault domains and certify concerns from inside the guest.""" + + driver_mode: ClassVar[str] = "guest-certified-appliance" + + initramfs_builder: InitramfsBuilder = field(default_factory=GuestObservingInitramfsBuilder) + guest_transport: GuestFactTransport = field(default_factory=FileSerialGuestFactTransport) + guest_config: GuestObservationConfig = field(default_factory=GuestObservationConfig) + challenge: str | None = None + last_guest_observations: tuple[RealizationObservation, ...] = () + last_guest_facts: dict[str, object] = field(default_factory=dict) + last_guest_binding: dict[str, object] = field(default_factory=dict) + + def __post_init__(self) -> None: + super().__post_init__() + if self.challenge is None: + self.challenge = secrets.token_hex(16) + elif not _SAFE_CHALLENGE_RE.match(self.challenge): + raise ValueError("guest-certified challenge must be a lowercase hex token") + + def _admission_diagnostics( + self, + networks: tuple[NetworkSpec, ...], + domains: tuple[DomainSpec, ...], + envelope: object, + ) -> list[Diagnostic]: + from aces_contracts.realization_envelope import BackendRealizationEnvelopeModel + + assert isinstance(envelope, BackendRealizationEnvelopeModel) + return guest_certified_spec_diagnostics( + networks=networks, + domains=domains, + envelope=envelope, + name_prefix=self.name_prefix, + ) + + def _build_matrix( + self, + networks: tuple[NetworkSpec, ...], + domains: tuple[DomainSpec, ...], + ) -> dict[str, object]: + return _native_matrix( + networks=networks, + domains=domains, + name_prefix=self.name_prefix, + include_placements=True, + ) + + def _render_domain_xml(self, domain: Mapping[str, object], *, kernel: Path, initrd: Path) -> str: + address = str(domain.get("address", "")) + fact_channel = self._fact_channel_path(address) + fact_channel.parent.mkdir(parents=True, exist_ok=True) + return _domain_xml( + domain, + kernel=kernel, + initrd=initrd, + appliance="guest-certified", + challenge=self.challenge, + fact_channel_path=fact_channel, + ) + + def _guest_stage( + self, + connection: object, + matrix: Mapping[str, object], + specs: tuple[tuple[NetworkSpec, ...], tuple[DomainSpec, ...]], + observations: tuple[RealizationObservation, ...], + ) -> tuple[tuple[RealizationObservation, ...], list[Diagnostic]]: + del connection, specs, observations + assert self.challenge is not None + outcome = observe_guest( + matrix=matrix, + transport=self.guest_transport, + challenge=self.challenge, + config=self.guest_config, + fact_channel_path_for=self._fact_channel_path, + ) + if outcome.diagnostics: + return (), list(outcome.diagnostics) + self.last_guest_observations = outcome.observations + self.last_guest_facts = dict(outcome.facts) + self.last_guest_binding = self._guest_binding(matrix) + return outcome.observations, [] + + def destroy(self, *, networks: tuple[str, ...], domains: tuple[str, ...]) -> DriverResult: + result = super().destroy(networks=networks, domains=domains) + if (networks or domains) and not result.diagnostics: + self.last_guest_observations = () + self.last_guest_facts = {} + self.last_guest_binding = {} + return result + + def _cleanup_artifacts(self, address: str) -> None: + super()._cleanup_artifacts(address) + with suppress(OSError): + self._fact_channel_path(address).unlink() + + def _fact_channel_path(self, address: str) -> Path: + return self.state_dir / "guest-facts" / f"{_artifact_token(address)}.facts" + + def _guest_binding(self, matrix: Mapping[str, object]) -> dict[str, object]: + domains = [item for item in matrix.get("domains", ()) if isinstance(item, Mapping)] + correlations = { + str(domain.get("address", "")): correlation_digest(_aces_uuid(str(domain.get("address", "")))) + for domain in domains + } + return { + "challenge": self.challenge, + "probe_policy": self.guest_config.probe_policy, + "correlations": correlations, + } + + +__all__ = ["GuestCertifiedLibvirtDriver"] diff --git a/implementations/python/packages/aces_backend_libvirt/guest_observation.py b/implementations/python/packages/aces_backend_libvirt/guest_observation.py new file mode 100644 index 000000000..2b9583c3d --- /dev/null +++ b/implementations/python/packages/aces_backend_libvirt/guest_observation.py @@ -0,0 +1,362 @@ +"""Concern-specific guest observation for guest-certified libvirt runs. + +Boot readiness and initialization completion are staged gates that must pass +before any concern fact is trusted; a later stage never repairs an earlier one. +Each accepted fact is read *from inside the realized guest* (its own ``/proc``, +``/sys``, ``/etc`` and link/file/account/service state), not echoed from the +plan, and is carried as a :class:`RealizationObservation` with +``ObservationStrength.GUEST_OBSERVED``. Failures are stable, redacted +:class:`Diagnostic` codes that name the safe ACES address and the observation +level. A fresh per-run challenge proves the report belongs to this boot, not a +cached or prior one. +""" + +from __future__ import annotations + +import hashlib +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, field +from pathlib import Path + +from aces_contracts.diagnostics import Diagnostic, Severity +from aces_contracts.realization_envelope import ObservationStrength, RealizationConcern + +from .driver import RealizationObservation +from .guest_transport import ( + FAILURE_TIMEOUT, + FAILURE_UNAVAILABLE, + GuestFactTransport, + parse_guest_facts, +) +from .techvault_matrix import as_sequence + +_DOMAIN = "runtime" +_CODE_TRANSPORT_UNAVAILABLE = "libvirt-backend.guest.transport-unavailable" +_CODE_BOOT_TIMEOUT = "libvirt-backend.guest.boot-timeout" +_CODE_INIT_INCOMPLETE = "libvirt-backend.guest.init-incomplete" +_CODE_CHALLENGE_MISMATCH = "libvirt-backend.guest.challenge-mismatch" +_CODE_OBSERVATION_MALFORMED = "libvirt-backend.guest.observation-malformed" +_CODE_OBSERVATION_DUPLICATE = "libvirt-backend.guest.observation-duplicate" +_CODE_OBSERVATION_MISSING = "libvirt-backend.guest.observation-missing" +_CODE_OBSERVATION_MISMATCH = "libvirt-backend.guest.observation-mismatch" + +_FAILURE_CODES = {FAILURE_UNAVAILABLE: _CODE_TRANSPORT_UNAVAILABLE, FAILURE_TIMEOUT: _CODE_BOOT_TIMEOUT} + +_MESSAGES = { + _CODE_TRANSPORT_UNAVAILABLE: "Guest fact transport was unavailable at the disclosed channel.", + _CODE_BOOT_TIMEOUT: "Guest did not report boot readiness within the observation deadline.", + _CODE_INIT_INCOMPLETE: "Guest initialization did not report completion.", + _CODE_CHALLENGE_MISMATCH: "Guest report did not carry the fresh per-run challenge.", + _CODE_OBSERVATION_MALFORMED: "Guest report was malformed or missing its bounded fact header.", + _CODE_OBSERVATION_DUPLICATE: "Guest report carried duplicate observations for a singleton fact.", + _CODE_OBSERVATION_MISSING: "Guest did not report every concern in the guest-observed inventory.", + _CODE_OBSERVATION_MISMATCH: "Guest-observed concern values did not match the requested realization.", +} + +_Key = tuple[str, str, RealizationConcern] + + +@dataclass(frozen=True) +class GuestObservationConfig: + """Per-stage and overall deadlines plus the disclosed probe-policy version.""" + + probe_policy: str = "serial-fact-channel/v1" + transport_deadline_seconds: float = 120.0 + + +@dataclass(frozen=True) +class _GuestOutcome: + observations: tuple[RealizationObservation, ...] = () + diagnostics: tuple[Diagnostic, ...] = () + facts: Mapping[str, object] = field(default_factory=dict) + + +def guest_observation( + address: str, field_path: str, concern: RealizationConcern, value: object +) -> RealizationObservation: + """Build a bounded guest-observed fact carrier for one concern field.""" + + return RealizationObservation( + address=address, + field_path=field_path, + concern=concern, + source=ObservationStrength.GUEST_OBSERVED, + value=value, + ) + + +def observe_guest( + *, + matrix: Mapping[str, object], + transport: GuestFactTransport, + challenge: str, + config: GuestObservationConfig, + fact_channel_path_for: Callable[[str], Path], +) -> _GuestOutcome: + """Run the staged guest observation for every domain in ``matrix``.""" + + observations: list[RealizationObservation] = [] + diagnostics: list[Diagnostic] = [] + facts_by_address: dict[str, object] = {} + domains = [item for item in as_sequence(matrix.get("domains")) if isinstance(item, Mapping)] + for domain in domains: + address = str(domain.get("address", "")) + parsed, stage_diag = _read_and_validate(domain, transport, challenge, config, fact_channel_path_for) + if stage_diag is not None: + diagnostics.append(stage_diag) + continue + assert parsed is not None + observations.extend(_domain_observations(domain, parsed)) + facts_by_address[address] = _bounded_facts(parsed) + if not diagnostics: + expected = expected_guest_observations(domains) + diagnostics.extend(guest_observation_diagnostics(expected=expected, observations=tuple(observations))) + return _GuestOutcome(tuple(observations), tuple(diagnostics), facts_by_address) + + +def _read_and_validate( + domain: Mapping[str, object], + transport: GuestFactTransport, + challenge: str, + config: GuestObservationConfig, + fact_channel_path_for: Callable[[str], Path], +) -> tuple[Mapping[str, object] | None, Diagnostic | None]: + address = str(domain.get("address", "")) + text, failure = transport.read( + address=address, + fact_channel_path=fact_channel_path_for(address), + deadline_seconds=config.transport_deadline_seconds, + ) + if failure is not None: + return None, _diagnostic(_FAILURE_CODES.get(failure, _CODE_TRANSPORT_UNAVAILABLE), address) + parsed = parse_guest_facts(text or "") + code = _staging_failure_code(parsed, challenge) + if code is not None: + return None, _diagnostic(code, address) + return parsed, None + + +def _staging_failure_code(parsed: Mapping[str, object] | None, challenge: str) -> str | None: + """Return the first staged-observation failure code, or None when the report is trusted. + + Boot readiness, initialization, and freshness are ordered gates; a later gate never + repairs an earlier one, so the first failing check wins. + """ + + if parsed is None: + return _CODE_OBSERVATION_MALFORMED + staged = ( + (bool(parsed.get("duplicate")), _CODE_OBSERVATION_DUPLICATE), + (not parsed.get("init_complete"), _CODE_INIT_INCOMPLETE), + (parsed.get("challenge") != challenge, _CODE_CHALLENGE_MISMATCH), + ) + return next((code for failed, code in staged if failed), None) + + +def _domain_observations( + domain: Mapping[str, object], parsed: Mapping[str, object] +) -> tuple[RealizationObservation, ...]: + address = str(domain.get("address", "")) + requested_memory = _as_int(domain.get("memory_mib")) + guest_memory = _as_int(parsed.get("memory_mib")) + corroborated = requested_memory > 0 and requested_memory // 2 <= guest_memory <= requested_memory + return ( + guest_observation( + address, "guest-architecture", RealizationConcern.ARCHITECTURE, str(parsed.get("architecture") or "") + ), + guest_observation(address, "guest-vcpus", RealizationConcern.RESOURCE_ALLOCATION, _as_int(parsed.get("vcpus"))), + guest_observation(address, "guest-memory-corroborated", RealizationConcern.RESOURCE_ALLOCATION, corroborated), + guest_observation(address, "guest-network", RealizationConcern.NETWORK, _observed_network(parsed)), + guest_observation(address, "guest-content", RealizationConcern.CONTENT_PLACEMENT, _observed_content(parsed)), + guest_observation(address, "guest-account", RealizationConcern.ACCOUNT_PLACEMENT, _observed_accounts(parsed)), + guest_observation(address, "guest-service", RealizationConcern.SERVICE, _observed_services(parsed)), + ) + + +def expected_guest_observations(domains: Sequence[Mapping[str, object]]) -> dict[_Key, object]: + """Project the guest-observed inventory the plan requires per domain.""" + + expected: dict[_Key, object] = {} + for domain in domains: + address = str(domain.get("address", "")) + expected[(address, "guest-architecture", RealizationConcern.ARCHITECTURE)] = "x86_64" + expected[(address, "guest-vcpus", RealizationConcern.RESOURCE_ALLOCATION)] = _as_int(domain.get("vcpus")) + expected[(address, "guest-memory-corroborated", RealizationConcern.RESOURCE_ALLOCATION)] = True + expected[(address, "guest-network", RealizationConcern.NETWORK)] = _expected_network(domain) + expected[(address, "guest-content", RealizationConcern.CONTENT_PLACEMENT)] = _expected_content(domain) + expected[(address, "guest-account", RealizationConcern.ACCOUNT_PLACEMENT)] = _expected_accounts(domain) + expected[(address, "guest-service", RealizationConcern.SERVICE)] = _expected_services(domain) + return expected + + +def guest_observation_diagnostics( + *, + expected: Mapping[_Key, object], + observations: tuple[RealizationObservation, ...], +) -> list[Diagnostic]: + """Require exactly one matching guest-observed fact per expected concern field.""" + + observed: dict[_Key, list[RealizationObservation]] = {} + for item in observations: + observed.setdefault((item.address, item.field_path, item.concern), []).append(item) + missing: set[str] = set() + mismatched: set[str] = set() + for key, expected_value in expected.items(): + candidates = observed.get(key, []) + if not candidates or any(item.source is not ObservationStrength.GUEST_OBSERVED for item in candidates): + missing.add(key[0]) + elif ( + len(candidates) != 1 + or type(candidates[0].value) is not type(expected_value) + or candidates[0].value != expected_value + ): + mismatched.add(key[0]) + diagnostics = [_diagnostic(_CODE_OBSERVATION_MISSING, address) for address in sorted(missing)] + diagnostics.extend(_diagnostic(_CODE_OBSERVATION_MISMATCH, address) for address in sorted(mismatched - missing)) + return diagnostics + + +def correlation_digest(native_identity: str) -> str: + """Return a redacted sha256 correlation for an ownership-verified native id.""" + + return "sha256:" + hashlib.sha256(native_identity.encode("utf-8")).hexdigest() + + +# --- observed projections (actual guest readings) ------------------------------ + + +def _observed_network(parsed: Mapping[str, object]) -> tuple[str, ...]: + return tuple( + sorted( + f"{item.get('mac')}|{item.get('ipv4')}" + for item in as_sequence(parsed.get("interfaces")) + if isinstance(item, Mapping) and item.get("up") + ) + ) + + +def _observed_content(parsed: Mapping[str, object]) -> tuple[str, ...]: + return tuple( + sorted( + f"{item.get('path')}|{item.get('sha256')}|{_norm_mode(str(item.get('mode', '')))}" + for item in as_sequence(parsed.get("content")) + if isinstance(item, Mapping) + ) + ) + + +def _observed_accounts(parsed: Mapping[str, object]) -> tuple[str, ...]: + return tuple( + sorted(_account_token(item) for item in as_sequence(parsed.get("accounts")) if isinstance(item, Mapping)) + ) + + +def _observed_services(parsed: Mapping[str, object]) -> tuple[str, ...]: + return tuple( + sorted( + f"{item.get('name')}|{item.get('port')}|{_flag(item.get('listening'))}|{_flag(item.get('pid_present'))}" + for item in as_sequence(parsed.get("services")) + if isinstance(item, Mapping) + ) + ) + + +# --- expected projections (plan-derived) --------------------------------------- + + +def _expected_network(domain: Mapping[str, object]) -> tuple[str, ...]: + return tuple( + sorted( + f"{item.get('mac')}|{item.get('ip')}" + for item in as_sequence(domain.get("interfaces")) + if isinstance(item, Mapping) + ) + ) + + +def _expected_content(domain: Mapping[str, object]) -> tuple[str, ...]: + def token(item: Mapping[str, object]) -> str: + digest = _content_digest(str(item.get("content", ""))) + mode = _norm_mode(str(item.get("mode", ""))) + return f"{item.get('path')}|{digest}|{mode}" + + return tuple(sorted(token(item) for item in as_sequence(domain.get("content")) if isinstance(item, Mapping))) + + +def _expected_accounts(domain: Mapping[str, object]) -> tuple[str, ...]: + tokens = [] + for item in as_sequence(domain.get("accounts")): + if not isinstance(item, Mapping): + continue + name = str(item.get("name", "")) + home = str(item.get("home") or f"/home/{name}") + shell = str(item.get("shell") or "/bin/sh") + groups = ",".join(sorted(str(group) for group in as_sequence(item.get("groups")))) + tokens.append(f"{name}|{home}|{shell}|{_flag(item.get('disabled'))}|{groups}") + return tuple(sorted(tokens)) + + +def _expected_services(domain: Mapping[str, object]) -> tuple[str, ...]: + return tuple( + sorted( + f"{item.get('name')}|{item.get('port')}|1|1" + for item in as_sequence(domain.get("services")) + if isinstance(item, Mapping) + ) + ) + + +def _account_token(item: Mapping[str, object]) -> str: + name = str(item.get("name", "")) + home = str(item.get("home", "")) + shell = str(item.get("shell", "")) + groups = ",".join(sorted(str(group) for group in as_sequence(item.get("groups")))) + return f"{name}|{home}|{shell}|{_flag(item.get('disabled'))}|{groups}" + + +def _bounded_facts(parsed: Mapping[str, object]) -> dict[str, object]: + return { + "architecture": str(parsed.get("architecture") or ""), + "vcpus": _as_int(parsed.get("vcpus")), + "memory_mib": _as_int(parsed.get("memory_mib")), + "interfaces": _observed_network(parsed), + "content": _observed_content(parsed), + "accounts": _observed_accounts(parsed), + "services": _observed_services(parsed), + } + + +def _content_digest(content: str) -> str: + return hashlib.sha256(content.encode("utf-8")).hexdigest() + + +def _norm_mode(mode: str) -> str: + return mode.lstrip("0") or "0" + + +def _flag(value: object) -> str: + return "1" if value else "0" + + +def _as_int(value: object) -> int: + return value if isinstance(value, int) else -1 + + +def _diagnostic(code: str, address: str) -> Diagnostic: + return Diagnostic( + code=code, + domain=_DOMAIN, + address=address, + message=_MESSAGES.get(code, "Guest observation did not succeed."), + severity=Severity.ERROR, + ) + + +__all__ = [ + "GuestObservationConfig", + "correlation_digest", + "expected_guest_observations", + "guest_observation", + "guest_observation_diagnostics", + "observe_guest", +] diff --git a/implementations/python/packages/aces_backend_libvirt/guest_transport.py b/implementations/python/packages/aces_backend_libvirt/guest_transport.py new file mode 100644 index 000000000..761e96489 --- /dev/null +++ b/implementations/python/packages/aces_backend_libvirt/guest_transport.py @@ -0,0 +1,218 @@ +"""Credential-free guest-fact transport for guest-certified libvirt runs. + +The guest-certified appliance writes a bounded, line-oriented fact report to a +dedicated file-backed serial channel (``/dev/ttyS1`` in the guest maps to a +run-local host file). The host reads that file through the :class:`GuestFactTransport` +seam and parses it into a bounded structured mapping. This transport carries no +credential and is not a general command channel: the guest emits fixed, +read-only, size-bounded facts and nothing else. A future libvirt guest-agent +transport can replace this implementation without changing the observer, the +concern taxonomy, or the evidence schema. +""" + +from __future__ import annotations + +import time +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Protocol + +_Handler = Callable[[dict[str, object], list[str]], None] + +FACT_HEADER = "ACES-GUEST-FACTS v1" +INIT_COMPLETE_MARKER = "init complete" + +# Bounds keep a hostile or malfunctioning guest from flooding host memory or the +# evidence artifact. A well-formed report is far under every limit. +_MAX_FACT_BYTES = 64 * 1024 +_MAX_LINES = 512 +_MAX_LINE_CHARS = 512 +_MAX_ENTRIES = 64 + +# Stable transport-stage failure suffixes (namespaced by the observer). +FAILURE_UNAVAILABLE = "transport-unavailable" +FAILURE_TIMEOUT = "boot-timeout" + + +class GuestFactTransport(Protocol): + """Read one guest's bounded fact report through the disclosed channel.""" + + def read(self, *, address: str, fact_channel_path: Path, deadline_seconds: float) -> tuple[str | None, str | None]: + """Return ``(text, None)`` on success or ``(None, failure_suffix)`` on failure.""" + ... + + +@dataclass(frozen=True) +class FileSerialGuestFactTransport: + """Read the guest fact report from a file-backed serial channel. + + Polls the run-local host file the guest serial device is bound to until the + guest has emitted its terminal ``init complete`` marker or the deadline + elapses. Never raises: an unreadable channel is reported as a typed failure + suffix so the caller emits a redacted diagnostic instead of an exception. + """ + + poll_seconds: float = 0.5 + + def read(self, *, address: str, fact_channel_path: Path, deadline_seconds: float) -> tuple[str | None, str | None]: + del address + deadline = time.monotonic() + max(0.0, deadline_seconds) + saw_channel = False + while True: + text = _read_bounded(fact_channel_path) + if text is not None: + saw_channel = True + if _contains_terminal_marker(text): + return text, None + if time.monotonic() >= deadline: + if saw_channel: + return None, FAILURE_TIMEOUT + return None, FAILURE_UNAVAILABLE + time.sleep(self.poll_seconds) + + +def _read_bounded(path: Path) -> str | None: + try: + with path.open("rb") as stream: + payload = stream.read(_MAX_FACT_BYTES + 1) + except OSError: + return None + if len(payload) > _MAX_FACT_BYTES: + payload = payload[:_MAX_FACT_BYTES] + return payload.decode("utf-8", errors="replace") + + +def _contains_terminal_marker(text: str) -> bool: + return any(line.strip() == INIT_COMPLETE_MARKER for line in text.splitlines()) + + +def parse_guest_facts(text: str) -> Mapping[str, object] | None: + """Parse the bounded line-oriented guest report into a structured mapping. + + Returns ``None`` when the header is absent or the report is unparseable. + Unknown lines are ignored; bounds are enforced so a malformed report cannot + produce an unbounded structure. + """ + + lines = text.splitlines()[:_MAX_LINES] + if not lines or lines[0].strip() != FACT_HEADER: + return None + facts: dict[str, object] = { + "challenge": None, + "init_complete": False, + "architecture": None, + "vcpus": None, + "memory_mib": None, + "interfaces": [], + "content": [], + "accounts": [], + "services": [], + "duplicate": False, + } + seen: set[str] = set() + for raw in lines[1:]: + _consume_line(facts, raw[:_MAX_LINE_CHARS].rstrip("\n"), seen) + return facts + + +_SCALAR_KEYS = frozenset({"challenge", "architecture", "vcpus", "memory_mib"}) + + +def _consume_line(facts: dict[str, object], line: str, seen: set[str]) -> None: + stripped = line.strip() + if stripped == INIT_COMPLETE_MARKER: + facts["init_complete"] = True + return + parts = stripped.split(" ") + key = parts[0] if parts else "" + fields = parts[1:] + handler = _LINE_HANDLERS.get(key) + if handler is None: + return + # A repeated singleton fact (same key) or an identical repeated list line is a + # duplicate observation: record it distinctly rather than silently collapsing it. + marker = key if key in _SCALAR_KEYS else stripped + if marker in seen: + facts["duplicate"] = True + else: + seen.add(marker) + handler(facts, fields) + + +def _set_scalar(name: str, cast: Callable[[str], object]) -> _Handler: + def _apply(facts: dict[str, object], fields: list[str]) -> None: + if fields: + facts[name] = cast(fields[0]) + + return _apply + + +def _append(name: str, arity: int, build: Callable[[list[str]], dict[str, object]]) -> _Handler: + def _apply(facts: dict[str, object], fields: list[str]) -> None: + bucket = facts[name] + if isinstance(bucket, list) and len(bucket) < _MAX_ENTRIES and len(fields) >= arity: + bucket.append(build(fields)) + + return _apply + + +def _to_int(value: str) -> int | None: + try: + return int(value) + except ValueError: + return None + + +def _iface_entry(fields: list[str]) -> dict[str, object]: + return {"mac": fields[0], "ipv4": fields[1], "up": fields[2] == "1"} + + +def _content_entry(fields: list[str]) -> dict[str, object]: + return {"path": fields[0], "sha256": fields[1], "mode": fields[2]} + + +def _account_entry(fields: list[str]) -> dict[str, object]: + # The trailing groups field is optional: an account with no supplemental groups + # emits five fields (the empty groups token is trimmed away by the transport). + groups = [group for group in fields[5].split(",") if group] if len(fields) > 5 else [] + return { + "name": fields[0], + "uid": _to_int(fields[1]), + "home": fields[2], + "shell": fields[3], + "disabled": fields[4] == "1", + "groups": sorted(groups), + } + + +def _service_entry(fields: list[str]) -> dict[str, object]: + return { + "name": fields[0], + "port": _to_int(fields[1]), + "listening": fields[2] == "1", + "pid_present": fields[3] == "1", + } + + +_LINE_HANDLERS = { + "challenge": _set_scalar("challenge", str), + "architecture": _set_scalar("architecture", str), + "vcpus": _set_scalar("vcpus", _to_int), + "memory_mib": _set_scalar("memory_mib", _to_int), + "iface": _append("interfaces", 3, _iface_entry), + "content": _append("content", 3, _content_entry), + "account": _append("accounts", 5, _account_entry), + "service": _append("services", 4, _service_entry), +} + + +__all__ = [ + "FACT_HEADER", + "FAILURE_TIMEOUT", + "FAILURE_UNAVAILABLE", + "INIT_COMPLETE_MARKER", + "FileSerialGuestFactTransport", + "GuestFactTransport", + "parse_guest_facts", +] diff --git a/implementations/python/packages/aces_backend_libvirt/techvault_concerns.py b/implementations/python/packages/aces_backend_libvirt/techvault_concerns.py index c8bec9932..039738161 100644 --- a/implementations/python/packages/aces_backend_libvirt/techvault_concerns.py +++ b/implementations/python/packages/aces_backend_libvirt/techvault_concerns.py @@ -3,6 +3,7 @@ from __future__ import annotations import ipaddress +import re from collections.abc import Mapping from aces_contracts.diagnostics import Diagnostic, Severity @@ -53,6 +54,8 @@ } ) +_SAFE_TOKEN_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") + def techvault_admission_diagnostics( plan: ProvisioningPlan, @@ -192,6 +195,86 @@ def techvault_spec_diagnostics( return diagnostics +def guest_certified_spec_diagnostics( + *, + networks: tuple[NetworkSpec, ...], + domains: tuple[DomainSpec, ...], + envelope: BackendRealizationEnvelopeModel, + name_prefix: str, +) -> list[Diagnostic]: + """Admit the bounded placements the guest-certified appliance realizes. + + Unlike the daemon-only appliance, this mode boots accounts, files, and a + bounded service into the guest and reads them back, so those placements are + accepted. Requested images, ACLs, packages, runcmd, hostname overrides, and + out-of-envelope resources remain rejected before any native mutation. + """ + + diagnostics: list[Diagnostic] = [] + diagnostics.extend(_name_diagnostics(networks, domains, name_prefix)) + network_addresses = {spec.address for spec in networks} + for spec in networks: + diagnostics.extend(_network_spec_diagnostics(spec)) + for spec in domains: + diagnostics.extend(_guest_domain_spec_diagnostics(spec, envelope, network_addresses)) + diagnostics.extend(_network_capacity_diagnostics(networks, domains)) + return diagnostics + + +def _guest_domain_spec_diagnostics( + spec: DomainSpec, + envelope: BackendRealizationEnvelopeModel, + network_addresses: set[str], +) -> list[Diagnostic]: + return [ + *_domain_resource_diagnostics(spec, envelope), + *_domain_image_diagnostics(spec), + *_domain_metadata_diagnostics(spec), + *_domain_acl_diagnostics(spec), + *_domain_network_diagnostics(spec, network_addresses), + *_guest_placement_bounds_diagnostics(spec), + *_guest_service_bounds_diagnostics(spec), + ] + + +def _guest_placement_bounds_diagnostics(spec: DomainSpec) -> list[Diagnostic]: + cloud_init = spec.cloud_init + if cloud_init is None: + return [] + unsupported = ( + bool(cloud_init.packages) or bool(cloud_init.runcmd) or cloud_init.hostname not in {None, "", spec.name} + ) + unsafe_content = any(not _safe_guest_path(item.path) for item in cloud_init.write_files) + unsafe_account = any(not _SAFE_TOKEN_RE.match(user.name) for user in cloud_init.users) + if unsupported or unsafe_content or unsafe_account: + return [ + _diagnostic( + _CODE_GUEST_PLACEMENT_UNSUPPORTED, + spec.address, + "Guest-certified appliance realizes bounded accounts and files only; " + "packages, runcmd, hostname overrides, unsafe paths, and unsafe account names are rejected.", + ) + ] + return [] + + +def _guest_service_bounds_diagnostics(spec: DomainSpec) -> list[Diagnostic]: + invalid = any(not _SAFE_TOKEN_RE.match(service.name) or not 1 <= service.port <= 65535 for service in spec.services) + if not invalid: + return [] + return [ + _diagnostic( + _CODE_SERVICE_UNSUPPORTED, + spec.address, + "Guest-certified services require a libvirt-safe name and a valid port.", + ) + ] + + +def _safe_guest_path(path: str) -> bool: + return path.startswith("/") and ".." not in path.split("/") + + def _domain_spec_diagnostics( spec: DomainSpec, envelope: BackendRealizationEnvelopeModel, @@ -476,6 +559,7 @@ def _diagnostic(code: str, address: str, message: str) -> Diagnostic: __all__ = [ + "guest_certified_spec_diagnostics", "techvault_admission_diagnostics", "techvault_observation_diagnostics", "techvault_spec_diagnostics", diff --git a/implementations/python/packages/aces_backend_libvirt/techvault_matrix.py b/implementations/python/packages/aces_backend_libvirt/techvault_matrix.py index 4ec6636cd..3a4f0ab19 100644 --- a/implementations/python/packages/aces_backend_libvirt/techvault_matrix.py +++ b/implementations/python/packages/aces_backend_libvirt/techvault_matrix.py @@ -24,12 +24,19 @@ def native_matrix( networks: tuple[NetworkSpec, ...], domains: tuple[DomainSpec, ...], name_prefix: str, + include_placements: bool = False, ) -> dict[str, object]: runtime_networks = [runtime_network(spec, name_prefix) for spec in networks] runtime_network_by_address = {str(item["address"]): item for item in runtime_networks} allocations = allocate_interfaces(domains, runtime_network_by_address, name_prefix) runtime_domains = [ - runtime_domain(spec, name_prefix=name_prefix, interfaces=allocations.get(spec.address, ())) for spec in domains + runtime_domain( + spec, + name_prefix=name_prefix, + interfaces=allocations.get(spec.address, ()), + include_placements=include_placements, + ) + for spec in domains ] return { "substrate": _SUBSTRATE, @@ -93,8 +100,9 @@ def runtime_domain( *, name_prefix: str, interfaces: tuple[dict[str, object], ...], + include_placements: bool = False, ) -> dict[str, object]: - return { + domain: dict[str, object] = { "address": spec.address, "name": spec.name, "runtime_name": runtime_name(name_prefix, spec.address, spec.name), @@ -102,6 +110,35 @@ def runtime_domain( "vcpus": spec.vcpus, "interfaces": list(interfaces), } + if include_placements: + domain.update(domain_placements(spec)) + return domain + + +def domain_placements(spec: DomainSpec) -> dict[str, object]: + """Project a domain's realizable content/account/service placements. + + Guest-certified realization boots these placements into the appliance and + reads them back from inside the guest; daemon-only modes ignore them. + """ + + cloud_init = spec.cloud_init + accounts = [ + { + "name": user.name, + "groups": sorted(user.groups), + "shell": user.shell, + "home": user.home, + "disabled": bool(user.lock_passwd), + } + for user in (cloud_init.users if cloud_init is not None else ()) + ] + content = [ + {"path": item.path, "content": item.content, "mode": item.permissions} + for item in (cloud_init.write_files if cloud_init is not None else ()) + ] + services = [{"name": service.name, "port": service.port} for service in spec.services] + return {"accounts": accounts, "content": content, "services": services} def network_xml(network: Mapping[str, object]) -> str: @@ -126,7 +163,15 @@ def network_xml(network: Mapping[str, object]) -> str: return ET.tostring(root, encoding="unicode") -def domain_xml(domain: Mapping[str, object], *, kernel: Path, initrd: Path) -> str: +def domain_xml( + domain: Mapping[str, object], + *, + kernel: Path, + initrd: Path, + appliance: str = "techvault", + challenge: str | None = None, + fact_channel_path: Path | None = None, +) -> str: root = ET.Element("domain", {"type": "qemu"}) ET.SubElement(root, "name").text = str(domain.get("runtime_name", "")) ET.SubElement(root, "uuid").text = _aces_uuid(str(domain.get("address", ""))) @@ -136,7 +181,7 @@ def domain_xml(domain: Mapping[str, object], *, kernel: Path, initrd: Path) -> s ET.SubElement(os_node, "type", {"arch": "x86_64"}).text = "hvm" ET.SubElement(os_node, "kernel").text = str(kernel) ET.SubElement(os_node, "initrd").text = str(initrd) - ET.SubElement(os_node, "cmdline").text = "console=ttyS0 panic=-1 aces.appliance=techvault" + ET.SubElement(os_node, "cmdline").text = _domain_cmdline(appliance, challenge) features = ET.SubElement(root, "features") ET.SubElement(features, "acpi") devices = ET.SubElement(root, "devices") @@ -145,6 +190,10 @@ def domain_xml(domain: Mapping[str, object], *, kernel: Path, initrd: Path) -> s ET.SubElement(serial, "target", {"port": "0"}) console = ET.SubElement(devices, "console", {"type": "pty"}) ET.SubElement(console, "target", {"type": "serial", "port": "0"}) + if fact_channel_path is not None: + fact_serial = ET.SubElement(devices, "serial", {"type": "file"}) + ET.SubElement(fact_serial, "source", {"path": str(fact_channel_path)}) + ET.SubElement(fact_serial, "target", {"port": "1"}) for interface_spec in as_sequence(domain.get("interfaces")): if not isinstance(interface_spec, Mapping): continue @@ -178,4 +227,19 @@ def as_sequence(value: object) -> Sequence[object]: return value if isinstance(value, list | tuple) else () -__all__ = ["as_sequence", "domain_xml", "native_matrix", "network_xml", "runtime_name", "safe_name"] +def _domain_cmdline(appliance: str, challenge: str | None) -> str: + parts = ["console=ttyS0", "panic=-1", f"aces.appliance={appliance}"] + if challenge: + parts.append(f"aces.challenge={challenge}") + return " ".join(parts) + + +__all__ = [ + "as_sequence", + "domain_placements", + "domain_xml", + "native_matrix", + "network_xml", + "runtime_name", + "safe_name", +] diff --git a/implementations/python/packages/aces_backend_libvirt/techvault_native.py b/implementations/python/packages/aces_backend_libvirt/techvault_native.py index 9f7478fa7..9c9793421 100644 --- a/implementations/python/packages/aces_backend_libvirt/techvault_native.py +++ b/implementations/python/packages/aces_backend_libvirt/techvault_native.py @@ -8,16 +8,21 @@ from __future__ import annotations -import os from collections.abc import Callable, Mapping, Sequence from contextlib import suppress from dataclasses import dataclass, field from pathlib import Path -from typing import ClassVar, Protocol, cast +from typing import TYPE_CHECKING, ClassVar, Protocol, cast from urllib.parse import urlsplit from aces_contracts.diagnostics import Diagnostic, Severity +from ._techvault_native_helpers import default_connector as _default_connector +from ._techvault_native_helpers import default_kernel_path as _default_kernel_path + +if TYPE_CHECKING: + from aces_contracts.realization_envelope import BackendRealizationEnvelopeModel + from .driver import ( DomainHandle, DomainSpec, @@ -149,15 +154,10 @@ def realize( domains: tuple[DomainSpec, ...], ) -> DriverResult: envelope = load_libvirt_realization_envelope(self.driver_mode) - spec_diagnostics = techvault_spec_diagnostics( - networks=networks, - domains=domains, - envelope=envelope, - name_prefix=self.name_prefix, - ) + spec_diagnostics = self._admission_diagnostics(networks, domains, envelope) if spec_diagnostics: return DriverResult(diagnostics=tuple(spec_diagnostics)) - matrix = _native_matrix(networks=networks, domains=domains, name_prefix=self.name_prefix) + matrix = self._build_matrix(networks, domains) self.state_dir.mkdir(parents=True, exist_ok=True) try: connection = self._conn() @@ -214,14 +214,19 @@ def _verify_and_finalize( ) -> DriverResult: networks, domains = specs network_handles, domain_handles = handles - observation_diagnostics = techvault_observation_diagnostics( + diagnostics = techvault_observation_diagnostics( networks=networks, domains=domains, result=DriverResult(observations=observations), ) - if observation_diagnostics: - observation_diagnostics.extend(self._rollback(connection, network_handles, domain_handles)) - return DriverResult(diagnostics=tuple(observation_diagnostics)) + # Staged: the guest observation runs only after the daemon gate passes and a + # later stage never repairs an earlier one. + guest_observations: tuple[RealizationObservation, ...] = () + if not diagnostics: + guest_observations, diagnostics = self._guest_stage(connection, matrix, specs, observations) + if diagnostics: + diagnostics.extend(self._rollback(connection, network_handles, domain_handles)) + return DriverResult(diagnostics=tuple(diagnostics)) try: binding = self._material_binding(envelope_digest, configuration_digest) snapshot = snapshot_from_observations(matrix, observations, binding=binding) @@ -233,9 +238,46 @@ def _verify_and_finalize( return DriverResult( networks=tuple(network_handles), domains=tuple(domain_handles), - observations=observations, + observations=(*observations, *guest_observations), + ) + + def _admission_diagnostics( + self, + networks: tuple[NetworkSpec, ...], + domains: tuple[DomainSpec, ...], + envelope: object, + ) -> list[Diagnostic]: + return techvault_spec_diagnostics( + networks=networks, + domains=domains, + envelope=cast("BackendRealizationEnvelopeModel", envelope), + name_prefix=self.name_prefix, ) + def _build_matrix( + self, + networks: tuple[NetworkSpec, ...], + domains: tuple[DomainSpec, ...], + ) -> dict[str, object]: + return _native_matrix(networks=networks, domains=domains, name_prefix=self.name_prefix) + + # Base extension hooks need no instance state; the guest-certified subclass overrides them. + @staticmethod + def _render_domain_xml(domain: Mapping[str, object], *, kernel: Path, initrd: Path) -> str: + return _domain_xml(domain, kernel=kernel, initrd=initrd) + + @staticmethod + def _guest_stage( + connection: object, + matrix: Mapping[str, object], + specs: tuple[tuple[NetworkSpec, ...], tuple[DomainSpec, ...]], + observations: tuple[RealizationObservation, ...], + ) -> tuple[tuple[RealizationObservation, ...], list[Diagnostic]]: + """Daemon-only modes contribute no guest observations; subclasses override.""" + + del connection, matrix, specs, observations + return (), [] + def _define_networks( self, connection: object, matrix: Mapping[str, object] ) -> tuple[list[NetworkHandle], list[Diagnostic], list[RealizationObservation]]: @@ -344,7 +386,7 @@ def _define_domain( ) make_libvirt_readable(initrd) self._artifacts[address] = (kernel, initrd) - native = _call(connection, "defineXML", _domain_xml(domain, kernel=kernel, initrd=initrd)) + native = _call(connection, "defineXML", self._render_domain_xml(domain, kernel=kernel, initrd=initrd)) if not self.define_only: native.create() except _OwnershipConflict: @@ -517,13 +559,6 @@ def _try_destroy(self, connection: object, lookup_method: str, address: str) -> return False -def _default_connector(connection_uri: str) -> object | None: - import importlib - - libvirt = importlib.import_module("libvirt") - return libvirt.open(connection_uri) - - def _call(connection: object, method_name: str, payload: str) -> _NativeResource: method = cast(Callable[[str], _NativeResource], getattr(connection, method_name)) return method(payload) @@ -550,23 +585,15 @@ def _artifact_token(address: str) -> str: return _aces_uuid(address).replace("-", "") -def _default_kernel_path() -> Path: - running = Path(f"/boot/vmlinuz-{os.uname().release}") - if running.exists(): - return running - candidates = sorted(Path("/boot").glob("vmlinuz-*")) - return candidates[-1] if candidates else Path("/boot/vmlinuz") +_MESSAGES = { + _CODE_UNAVAILABLE: "Libvirt connection is unavailable for native TechVault realization.", + _CODE_RESIDUAL_STATE: "TechVault rollback could not verify cleanup for '{address}'; residual state may remain.", + _CODE_OWNERSHIP_CONFLICT: "Native object for '{address}' is not owned by that ACES address; refusing mutation.", + _CODE_READBACK_FAILED: "Native libvirt TechVault readback for '{address}' did not succeed.", +} +_DEFAULT_MESSAGE = "Native libvirt TechVault operation for '{address}' did not succeed." def _diagnostic(code: str, address: str) -> Diagnostic: - if code == _CODE_UNAVAILABLE: - message = "Libvirt connection is unavailable for native TechVault realization." - elif code == _CODE_RESIDUAL_STATE: - message = f"Native TechVault rollback could not verify cleanup for '{address}'; residual state may remain." - elif code == _CODE_OWNERSHIP_CONFLICT: - message = f"Native object for '{address}' is not owned by that ACES address; refusing mutation." - elif code == _CODE_READBACK_FAILED: - message = f"Native libvirt TechVault readback for '{address}' did not succeed." - else: - message = f"Native libvirt TechVault operation for '{address}' did not succeed." + message = _MESSAGES.get(code, _DEFAULT_MESSAGE).format(address=address) return Diagnostic(code=code, domain=_DOMAIN, address=address, message=message, severity=Severity.ERROR) diff --git a/implementations/python/packages/aces_cli/libvirt.py b/implementations/python/packages/aces_cli/libvirt.py index ad1613e0c..80db5852b 100644 --- a/implementations/python/packages/aces_cli/libvirt.py +++ b/implementations/python/packages/aces_cli/libvirt.py @@ -10,6 +10,8 @@ from aces_operations.libvirt_evidence_run import LibvirtEvidenceRunConfig, run_libvirt_evidence_run from aces_operations.techvault_live import TechVaultLiveConfig, validate_techvault_live +_DEFAULT_CONNECTION_URI = "qemu:///system" + app = typer.Typer(help="Libvirt backend operations.") techvault_app = typer.Typer(help="TechVault operational scenario checks.") app.add_typer(techvault_app, name="techvault") @@ -21,6 +23,13 @@ scenario and write a live-gate archive under the output directory. """ +_GUEST_WARNING = """\ +This will boot a guest-observing libvirt/QEMU appliance for the selected +scenario through the production apply path, read realization facts back from +inside the guest, and write a machine-readable scenario-evidence artifact under +the output directory. Native resources are created and then torn down. +""" + def _noncredential_connection_uri(value: str) -> str: parsed = urlsplit(value) @@ -54,7 +63,7 @@ def validate_live( help="Skip the native libvirt resource confirmation prompt.", ), connection_uri: str = typer.Option( - "qemu:///system", + _DEFAULT_CONNECTION_URI, "--connection-uri", callback=_noncredential_connection_uri, help="libvirt connection URI.", @@ -81,6 +90,50 @@ def validate_live( raise typer.Exit(code=1) +@techvault_app.command("guest-certify") +def guest_certify( + scenario: Path = typer.Option( + Path("examples/scenarios/techvault-guest-certified.sdl.yaml"), + "--scenario", + help="ACES SDL scenario to boot and certify from inside the guest.", + ), + project_dir: Path = typer.Option( + Path("."), + "--project-dir", + "--output-dir", + help="Output directory for the guest-certified scenario-evidence artifact.", + ), + run_id: str | None = typer.Option(None, "--run-id", help="Run id for the evidence artifact."), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the native libvirt resource confirmation prompt."), + connection_uri: str = typer.Option( + _DEFAULT_CONNECTION_URI, + "--connection-uri", + callback=_noncredential_connection_uri, + help="libvirt connection URI.", + ), +) -> None: + """Boot a guest-observing appliance and emit the guest-certified evidence artifact.""" + + if not yes: + typer.echo(_GUEST_WARNING) + if not typer.confirm("Continue?", default=False): + typer.echo("Aborted.") + raise typer.Exit(code=0) + resolved_run_id = run_id or datetime.now(UTC).strftime("aces_libvirt_guest_%Y%m%dT%H%M%SZ") + report = run_libvirt_evidence_run( + scenario_path=scenario.resolve(), + project_dir=project_dir.resolve(), + run_id=resolved_run_id, + config=LibvirtEvidenceRunConfig( + evidence_source_mode="guest-certified", + connection_uri=connection_uri, + ), + ) + typer.echo(report.render()) + if not report.passed: + raise typer.Exit(code=1) + + @evidence_app.command("validate") def validate_evidence( scenario: Path = typer.Option( @@ -105,7 +158,7 @@ def validate_evidence( help="Realize the libvirt substrate natively (requires a libvirt daemon); default is deterministic.", ), connection_uri: str = typer.Option( - "qemu:///system", + _DEFAULT_CONNECTION_URI, "--connection-uri", callback=_noncredential_connection_uri, help="libvirt connection URI (native-live only).", diff --git a/implementations/python/packages/aces_operations/_evidence_run_artifact.py b/implementations/python/packages/aces_operations/_evidence_run_artifact.py index 8171fc774..68045d005 100644 --- a/implementations/python/packages/aces_operations/_evidence_run_artifact.py +++ b/implementations/python/packages/aces_operations/_evidence_run_artifact.py @@ -84,7 +84,9 @@ def assemble_artifact(inputs: EvidenceArtifactInputs) -> dict[str, Any]: "scenario": scenario_section, "compiled_artifact": _compiled_artifact_section(model), "backend": _backend_section(manifest, mode, substrate_realized, native_cleanup_verified), - "realization_facts": _realization_facts_section(model, native_snapshot, native_cleanup_verified), + "realization_facts": _realization_facts_section( + model, native_snapshot, native_cleanup_verified, inputs.guest_observed + ), "realized_topology": _topology_section(model, native_snapshot, unrealized_capabilities), "participant_action_proof": _participant_proof_section(proof), "terminal_observation": _terminal_observation_section(proof["snapshot"]), @@ -210,6 +212,7 @@ def _realization_facts_section( model: CompiledModel, native_snapshot: Mapping[str, Any] | None, cleanup_verified: bool | None, + guest_observed: Mapping[str, Any] | None = None, ) -> dict[str, Any]: observed = native_snapshot if isinstance(native_snapshot, Mapping) else {} daemon_domains = observed.get("domains", ()) @@ -234,10 +237,7 @@ def _realization_facts_section( "domains": list(daemon_domains) if isinstance(daemon_domains, list | tuple) else [], "networks": list(daemon_networks) if isinstance(daemon_networks, list | tuple) else [], }, - "guest_observed": { - "source": "guest-observed", - "status": "not-observed", - }, + "guest_observed": _guest_observed_section(guest_observed), "cleanup": { "source": "driver-reported", "status": _cleanup_status(cleanup_verified), @@ -246,6 +246,18 @@ def _realization_facts_section( } +def _guest_observed_section(guest_observed: Mapping[str, Any] | None) -> dict[str, Any]: + """Return the guest-observed fact section: the bound report, or a not-observed stub. + + A daemon-only run discloses ``not-observed`` honestly; a guest-certified run + embeds the operation-joined, challenge-bound, per-domain guest report. + """ + + if not isinstance(guest_observed, Mapping): + return {"source": "guest-observed", "status": "not-observed"} + return {**guest_observed, "source": "guest-observed"} + + def _cleanup_status(cleanup_verified: bool | None) -> str: if cleanup_verified is None: return "not-required" diff --git a/implementations/python/packages/aces_operations/_evidence_run_types.py b/implementations/python/packages/aces_operations/_evidence_run_types.py index 127b8a728..d9c38889f 100644 --- a/implementations/python/packages/aces_operations/_evidence_run_types.py +++ b/implementations/python/packages/aces_operations/_evidence_run_types.py @@ -116,3 +116,4 @@ class EvidenceArtifactInputs: native_snapshot: Mapping[str, Any] | None native_cleanup_verified: bool | None unrealized_capabilities: tuple[str, ...] = () + guest_observed: Mapping[str, Any] | None = None diff --git a/implementations/python/packages/aces_operations/_evidence_run_validation.py b/implementations/python/packages/aces_operations/_evidence_run_validation.py index 4e1ff5a2a..b42d22518 100644 --- a/implementations/python/packages/aces_operations/_evidence_run_validation.py +++ b/implementations/python/packages/aces_operations/_evidence_run_validation.py @@ -129,6 +129,75 @@ def _validate_realization_sources(payload: Mapping[str, Any]) -> list[str]: else: problems.extend(_validate_unrealized_substrate(facts, provenance, cleanup)) problems.extend(_validate_guest_observation_boundary(payload)) + problems.extend(_validate_guest_observations(facts)) + return problems + + +def _validate_guest_observations(facts: Mapping[str, Any]) -> list[str]: + """Validate the guest-observed fact section when a guest report is present. + + A daemon-only run carries ``{"source": "guest-observed", "status": "not-observed"}`` + and is skipped here. A guest-certified run must bind every observed domain to the + control-plane operation, a fresh challenge, a canonical native correlation, and a + daemon-observed domain (rejecting unjoined or cross-operation evidence). + """ + + guest = facts.get("guest_observed") + if not isinstance(guest, Mapping) or guest.get("status") == "not-observed": + return [] + problems = _validate_guest_metadata(guest) + domains = guest.get("domains") + if not isinstance(domains, list | tuple) or not domains: + return [*problems, "guest observation requires at least one observed domain"] + daemon_addresses = _daemon_domain_addresses(facts) + for item in domains: + problems.extend(_validate_guest_domain(item, daemon_addresses)) + return problems + + +_GUEST_METADATA_FIELDS = ( + ("observation timestamp", "observed_at"), + ("probe policy", "probe_policy"), + ("fresh challenge", "challenge"), +) + + +def _validate_guest_metadata(guest: Mapping[str, Any]) -> list[str]: + problems: list[str] = [] + if not _is_canonical_sha256(guest.get("operation_ref")): + problems.append("guest observation requires a canonical operation reference") + if not isinstance(guest.get("certifying"), bool): + problems.append("guest observation requires an explicit certifying flag") + problems.extend( + f"guest observation requires a {label}" + for label, field_name in _GUEST_METADATA_FIELDS + if not _nonempty_string(guest.get(field_name)) + ) + return problems + + +def _daemon_domain_addresses(facts: Mapping[str, Any]) -> set[object]: + daemon = facts.get("daemon_observed", {}) + domains = daemon.get("domains", ()) if isinstance(daemon, Mapping) else () + return {item.get("address") for item in domains if isinstance(item, Mapping)} + + +_GUEST_DOMAIN_FIELDS = ("architecture", "vcpus", "memory_mib", "network", "content", "accounts", "services") + + +def _validate_guest_domain(item: object, daemon_addresses: set[object]) -> list[str]: + if not isinstance(item, Mapping): + return ["guest observation domain must be a mapping"] + problems: list[str] = [] + if not _is_canonical_sha256(item.get("correlation")): + problems.append("guest observation requires a canonical native correlation") + if item.get("address") not in daemon_addresses: + problems.append("guest observation is not joined to a daemon-observed domain") + problems.extend( + f"guest observation domain missing {field_name}" + for field_name in _GUEST_DOMAIN_FIELDS + if field_name not in item + ) return problems @@ -274,6 +343,9 @@ def _validate_unrealized_substrate( daemon_networks = daemon.get("networks", ()) if isinstance(daemon, Mapping) else () if daemon_domains or daemon_networks or facts.get("binding") is not None: problems.append("unrealized substrate cannot publish daemon observations or realization binding") + guest = facts.get("guest_observed") + if isinstance(guest, Mapping) and guest.get("status") != "not-observed": + problems.append("unrealized substrate cannot publish guest observations") if provenance.get("cleanup_verified") is not None: problems.append("unrealized substrate cleanup must be not-applicable") if isinstance(cleanup, Mapping) and cleanup.get("status") != "not-required": @@ -311,8 +383,8 @@ def _validate_binding_identity(binding: Mapping[str, Any], envelope: Mapping[str driver_digest = binding.get("driver_configuration_digest") if not _is_canonical_sha256(driver_digest): problems.append("realization binding requires a canonical driver configuration digest") - if binding.get("driver") != "techvault-appliance": - problems.append("realization binding driver does not match the TechVault appliance") + if binding.get("driver") not in {"techvault-appliance", "guest-certified-appliance"}: + problems.append("realization binding driver does not match a governed appliance mode") for field_name in ("connection_uri_digest", "name_prefix_digest"): if not _is_canonical_sha256(binding.get(field_name)): problems.append(f"realization binding requires canonical {field_name}") diff --git a/implementations/python/packages/aces_operations/libvirt_evidence_run.py b/implementations/python/packages/aces_operations/libvirt_evidence_run.py index 06ac526f8..474c300f6 100644 --- a/implementations/python/packages/aces_operations/libvirt_evidence_run.py +++ b/implementations/python/packages/aces_operations/libvirt_evidence_run.py @@ -35,6 +35,7 @@ from __future__ import annotations +import hashlib from collections.abc import Callable, Iterable, Mapping from dataclasses import dataclass from datetime import UTC, datetime @@ -73,7 +74,8 @@ _PROOF_EPISODE_ID = "proof-ep-1" -EvidenceSourceMode = Literal["deterministic", "native-live"] +EvidenceSourceMode = Literal["deterministic", "native-live", "guest-certified"] +_NATIVE_MODES = frozenset({"native-live", "guest-certified"}) @dataclass(frozen=True) @@ -152,8 +154,8 @@ def run_libvirt_evidence_run( return LibvirtEvidenceRunReport(scenario_path.name, run_id, str(project_dir), mode, tuple(checks)) native_driver: TechVaultNativeLibvirtDriver | None = None - if mode == "native-live": - native_driver = (driver_factory or _default_native_driver_factory(project_dir, run_id, settings))() + if mode in _NATIVE_MODES: + native_driver = (driver_factory or _default_native_driver_factory(project_dir, run_id, settings, mode))() try: target = create_libvirt_target(participant_runtime=True, driver=native_driver) @@ -170,14 +172,11 @@ def run_libvirt_evidence_run( native_snapshot: Mapping[str, Any] | None = None native_cleanup_verified: bool | None = None unrealized_capabilities: tuple[str, ...] = () - if mode == "native-live": - native_snapshot, realize_check, unrealized_capabilities = _realize_native_substrate( - execution_plan, control_plane, native_driver + guest_observed: Mapping[str, Any] | None = None + if mode in _NATIVE_MODES and native_driver is not None: + native_snapshot, native_cleanup_verified, unrealized_capabilities, guest_observed = _run_native_mode( + mode, execution_plan, control_plane, native_driver, driver_factory, checks ) - checks.append(realize_check) - if native_snapshot is not None and native_driver is not None: - native_cleanup_verified, cleanup_diagnostics = cleanup_native_snapshot(native_driver, native_snapshot) - checks.append(EvidenceCheck("native_substrate_cleanup", native_cleanup_verified, cleanup_diagnostics)) inputs = EvidenceArtifactInputs( scenario_path=scenario_path, @@ -190,6 +189,7 @@ def run_libvirt_evidence_run( native_snapshot=native_snapshot, native_cleanup_verified=native_cleanup_verified, unrealized_capabilities=unrealized_capabilities, + guest_observed=guest_observed, ) artifact, artifact_path = _finalize_artifact(inputs, project_dir, checks) return LibvirtEvidenceRunReport( @@ -197,6 +197,49 @@ def run_libvirt_evidence_run( ) +def _run_native_mode( + mode: str, + execution_plan: ExecutionPlan, + control_plane: RuntimeControlPlane, + native_driver: TechVaultNativeLibvirtDriver, + driver_factory: Callable[[], TechVaultNativeLibvirtDriver] | None, + checks: list[EvidenceCheck], +) -> tuple[Mapping[str, Any] | None, bool | None, tuple[str, ...], Mapping[str, Any] | None]: + """Realize the native substrate, capture any guest report, and clean up in a finally-path. + + Native-proof boundary: only the default production driver/transport (no injected + factory) yields a certifying guest artifact; injected fakes are marked + non-certifying so their evidence can never be published as a real certification. + """ + native_snapshot: Mapping[str, Any] | None = None + guest_observed: Mapping[str, Any] | None = None + unrealized: tuple[str, ...] = () + try: + native_snapshot, realize_check, unrealized, operation_id = _realize_native_substrate( + execution_plan, control_plane, native_driver + ) + checks.append(realize_check) + if native_snapshot is not None and mode == "guest-certified": + guest_observed = _guest_observed_report(native_driver, operation_id, certifying=driver_factory is None) + finally: + native_cleanup_verified = _append_cleanup_check(native_driver, native_snapshot, checks) + return native_snapshot, native_cleanup_verified, unrealized, guest_observed + + +def _append_cleanup_check( + native_driver: TechVaultNativeLibvirtDriver, native_snapshot: Mapping[str, Any] | None, checks: list[EvidenceCheck] +) -> bool | None: + """Cleanup runs after every attempt; residue on a failed/unrealized run is reported.""" + if native_snapshot is not None: + verified, diagnostics = _verify_native_cleanup(native_driver, native_snapshot) + checks.append(EvidenceCheck("native_substrate_cleanup", verified, diagnostics)) + return verified + residue_ok, residue_diagnostics = _sweep_residue(native_driver) + if not residue_ok: + checks.append(EvidenceCheck("native_substrate_residue", False, residue_diagnostics)) + return None + + def _finalize_artifact( inputs: EvidenceArtifactInputs, project_dir: Path, checks: list[EvidenceCheck] ) -> tuple[dict[str, Any], str | None]: @@ -317,17 +360,25 @@ def _admit_one_action( def _default_native_driver_factory( - project_dir: Path, run_id: str, settings: LibvirtEvidenceRunConfig + project_dir: Path, run_id: str, settings: LibvirtEvidenceRunConfig, mode: str ) -> Callable[[], TechVaultNativeLibvirtDriver]: - """Build the default native libvirt driver factory for operator-run native-live mode. + """Build the default native libvirt driver factory for an operator-run native mode. - Mirrors the TechVault live gate: the driver connects to a real libvirt daemon at - realize time. In CI/tests a fake driver_factory is injected instead, so this is - never exercised without a daemon. + The driver connects to a real libvirt daemon at realize time; ``guest-certified`` + selects the guest-observing driver. In CI/tests a fake driver_factory is injected + instead, so this is never exercised without a daemon. """ state_dir = project_dir / "runs" / run_id / "scenario-evidence" / "libvirt" def factory() -> TechVaultNativeLibvirtDriver: + if mode == "guest-certified": + from aces_backend_libvirt.guest_certified_driver import GuestCertifiedLibvirtDriver + + return GuestCertifiedLibvirtDriver( + state_dir=state_dir, + connection_uri=settings.connection_uri, + name_prefix="aces-evidence", + ) return TechVaultNativeLibvirtDriver( state_dir=state_dir, connection_uri=settings.connection_uri, @@ -337,25 +388,116 @@ def factory() -> TechVaultNativeLibvirtDriver: return factory +def _guest_observed_report( + native_driver: TechVaultNativeLibvirtDriver, operation_id: str | None, *, certifying: bool +) -> Mapping[str, Any] | None: + """Assemble the operation-joined, challenge-bound guest report from the driver. + + The control-plane operation id and observation timestamp are joined here, at the + operations boundary, rather than inside the backend driver. ``certifying`` records + whether the governed production driver was used; an injected fake driver yields a + non-certifying report that is externally distinguishable from a real proof. + """ + observations = getattr(native_driver, "last_guest_observations", ()) + if not observations: + return None + facts = getattr(native_driver, "last_guest_facts", {}) + binding = getattr(native_driver, "last_guest_binding", {}) + correlations = binding.get("correlations", {}) if isinstance(binding, Mapping) else {} + domains = [ + { + "address": address, + "correlation": correlations.get(address), + "architecture": fact.get("architecture"), + "vcpus": fact.get("vcpus"), + "memory_mib": fact.get("memory_mib"), + "network": list(fact.get("interfaces", ())), + "content": list(fact.get("content", ())), + "accounts": list(fact.get("accounts", ())), + "services": list(fact.get("services", ())), + } + for address, fact in sorted(facts.items()) + if isinstance(fact, Mapping) + ] + return { + # The raw control-plane operation id is a UUID and never portable identity; + # bind a redacted digest instead so the guest report joins the operation + # without leaking the UUID (the redaction gate forbids raw UUIDs). + "operation_ref": _operation_ref(operation_id), + "observed_at": datetime.now(UTC).isoformat(), + "certifying": certifying, + "probe_policy": binding.get("probe_policy") if isinstance(binding, Mapping) else None, + "challenge": binding.get("challenge") if isinstance(binding, Mapping) else None, + "domains": domains, + } + + +def _operation_ref(operation_id: str | None) -> str | None: + if not operation_id: + return None + return "sha256:" + hashlib.sha256(operation_id.encode("utf-8")).hexdigest() + + +def _verify_native_cleanup( + native_driver: TechVaultNativeLibvirtDriver, native_snapshot: Mapping[str, Any] +) -> tuple[bool, tuple[str, ...]]: + """Tear down a realized substrate and verify native + guest-probe cleanup.""" + + verified, diagnostics = cleanup_native_snapshot(native_driver, native_snapshot) + if verified and getattr(native_driver, "last_guest_binding", {}): + return False, (*diagnostics, "guest probe artifacts were not fully cleaned") + return verified, diagnostics + + +def _sweep_residue(native_driver: TechVaultNativeLibvirtDriver) -> tuple[bool, tuple[str, ...]]: + """Best-effort finally-path sweep after a failed/unrealized attempt. + + The driver rolls back on failure, so the common case leaves no residue. Any + remaining realized address, non-empty snapshot, or residual guest binding is a + leak and is reported so the run cannot pass. + """ + clean = ( + not native_driver.realized_addresses() + and native_driver.last_snapshot == {} + and not getattr(native_driver, "last_guest_binding", {}) + ) + if clean: + return True, () + residual = tuple(sorted(native_driver.realized_addresses())) + result = native_driver.destroy(networks=residual, domains=residual) + ok = ( + not result.diagnostics + and not native_driver.realized_addresses() + and native_driver.last_snapshot == {} + and not getattr(native_driver, "last_guest_binding", {}) + ) + if ok: + return True, () + diagnostics = tuple(f"{item.code} at {item.address}" for item in result.diagnostics) + return False, diagnostics or ("residual native or guest state remains after a failed attempt",) + + def _realize_native_substrate( execution_plan: ExecutionPlan, control_plane: RuntimeControlPlane, native_driver: TechVaultNativeLibvirtDriver | None, -) -> tuple[Mapping[str, Any] | None, EvidenceCheck, tuple[str, ...]]: +) -> tuple[Mapping[str, Any] | None, EvidenceCheck, tuple[str, ...], str | None]: """Realize the libvirt provisioning substrate (VMs + networks) for the scenario. - Native-live passes only when the runtime operation succeeds and the fresh driver + Native modes pass only when the runtime operation succeeds and the fresh driver report contains independently daemon-observed domains bound to the selected realization-envelope/configuration identity. A domain handle or planned matrix - alone is never sufficient. + alone is never sufficient. Returns the control-plane operation id so the evidence + producer can join it at this boundary. """ if native_driver is None: - return None, EvidenceCheck("native_substrate_realization", False, ("no native driver",)), () + return None, EvidenceCheck("native_substrate_realization", False, ("no native driver",)), (), None try: receipt = control_plane.submit_provisioning(execution_plan.provisioning) + operation_id = str(receipt.operation_id) status = control_plane.get_operation(receipt.operation_id) except Exception: - return None, EvidenceCheck("native_substrate_realization", False, ("native realization failed",)), () + return None, EvidenceCheck("native_substrate_realization", False, ("native realization failed",)), (), None unrealized = _dedupe( f"{d.code}: {d.message}" for source in (execution_plan.diagnostics, () if status is None else status.diagnostics) @@ -372,7 +514,7 @@ def _realize_native_substrate( if realized else ("libvirt backend realized no native substrate for this scenario; capabilities disclosed as unrealized",), ) - return (snapshot if realized else None), check, unrealized + return (snapshot if realized else None), check, unrealized, operation_id def _dedupe(items: Iterable[str]) -> tuple[str, ...]: diff --git a/implementations/python/tests/test_libvirt_backend_cli.py b/implementations/python/tests/test_libvirt_backend_cli.py index e3d8cac1e..89ccac861 100644 --- a/implementations/python/tests/test_libvirt_backend_cli.py +++ b/implementations/python/tests/test_libvirt_backend_cli.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from aces_cli.main import app +from aces_operations.libvirt_evidence_run import LibvirtEvidenceRunConfig from aces_operations.techvault_live import TechVaultLiveConfig from typer.testing import CliRunner @@ -122,6 +123,89 @@ def _validate(**kwargs): assert calls == [] +def test_libvirt_techvault_guest_certify_cli_invokes_evidence_run(monkeypatch, tmp_path): + calls: list[dict[str, object]] = [] + + def _run(**kwargs): + calls.append(kwargs) + return _Report() + + monkeypatch.setattr("aces_cli.libvirt.run_libvirt_evidence_run", _run) + scenario = tmp_path / "scenario.sdl.yaml" + scenario.write_text("name: cli\n", encoding="utf-8") + + result = CliRunner().invoke( + app, + [ + "libvirt", + "techvault", + "guest-certify", + "--scenario", + str(scenario), + "--project-dir", + str(tmp_path), + "--run-id", + "gc-cli", + "--yes", + "--connection-uri", + "qemu:///system", + ], + ) + + assert result.exit_code == 0, result.output + assert calls == [ + { + "scenario_path": scenario.resolve(), + "project_dir": tmp_path.resolve(), + "run_id": "gc-cli", + "config": LibvirtEvidenceRunConfig( + evidence_source_mode="guest-certified", + connection_uri="qemu:///system", + ), + } + ] + + +def test_libvirt_techvault_guest_certify_cli_returns_failure_exit(monkeypatch, tmp_path): + monkeypatch.setattr( + "aces_cli.libvirt.run_libvirt_evidence_run", + lambda **_kwargs: _Report(passed=False), + ) + scenario = tmp_path / "scenario.sdl.yaml" + scenario.write_text("name: cli\n", encoding="utf-8") + + result = CliRunner().invoke( + app, + ["libvirt", "techvault", "guest-certify", "--scenario", str(scenario), "--project-dir", str(tmp_path), "--yes"], + ) + + assert result.exit_code == 1 + + +def test_libvirt_techvault_guest_certify_cli_rejects_credentials(monkeypatch, tmp_path): + calls: list[dict[str, object]] = [] + monkeypatch.setattr("aces_cli.libvirt.run_libvirt_evidence_run", lambda **kwargs: calls.append(kwargs)) + scenario = tmp_path / "scenario.sdl.yaml" + scenario.write_text("name: cli\n", encoding="utf-8") + + result = CliRunner().invoke( + app, + [ + "libvirt", + "techvault", + "guest-certify", + "--scenario", + str(scenario), + "--yes", + "--connection-uri", + "qemu+ssh://operator:credential@example/system", + ], + ) + + assert result.exit_code == 2 + assert calls == [] + + def test_libvirt_techvault_cli_rejects_connection_uri_credentials(monkeypatch, tmp_path): calls: list[dict[str, object]] = [] monkeypatch.setattr("aces_cli.libvirt.validate_techvault_live", lambda **kwargs: calls.append(kwargs)) diff --git a/implementations/python/tests/test_libvirt_backend_guest_certified.py b/implementations/python/tests/test_libvirt_backend_guest_certified.py new file mode 100644 index 000000000..75c18581b --- /dev/null +++ b/implementations/python/tests/test_libvirt_backend_guest_certified.py @@ -0,0 +1,548 @@ +"""Hermetic guest-certified libvirt driver + guest-observation coverage. + +These tests exercise the guest-observation orchestration and its falsification +paths with a fake libvirt connection and a stub fact transport. They validate +staging, freshness, and concern comparison; per the preflight they cannot and do +not satisfy the native-proof gate (that is the opt-in real-daemon certification). +""" + +from __future__ import annotations + +import hashlib +from dataclasses import dataclass, field +from pathlib import Path + +import pytest +from aces_backend_libvirt.cloudinit import CloudInitFile, CloudInitSpec, CloudInitUser +from aces_backend_libvirt.driver import DomainSpec, NetworkSpec, ServiceSpec +from aces_backend_libvirt.guest_appliance import GuestObservingInitramfsBuilder +from aces_backend_libvirt.guest_certified_driver import GuestCertifiedLibvirtDriver +from aces_backend_libvirt.techvault_matrix import mac_address + +_CHALLENGE = "deadbeefcafef00d" + + +@dataclass +class _StubTransport: + facts_by_address: dict[str, str | None] + failure: str | None = None + + def read(self, *, address, fact_channel_path, deadline_seconds): # noqa: ANN001, ANN201 + del fact_channel_path, deadline_seconds + if self.failure is not None: + return None, self.failure + return self.facts_by_address.get(address), None + + +class _NativeObject: + def __init__(self, name: str = "", xml: str = "") -> None: + self._name = name + self._xml = xml + self.created = False + self.destroyed = False + self.undefined = False + + def name(self): + return self._name + + def create(self): + self.created = True + + def isActive(self): # noqa: N802 - mirrors libvirt API + return int(self.created and not self.destroyed) + + def XMLDesc(self, _flags=0): # noqa: N802 - mirrors libvirt API + return self._xml + + def UUIDString(self): # noqa: N802 - mirrors libvirt API + import xml.etree.ElementTree as ET + + return ET.fromstring(self._xml).findtext("uuid") # noqa: S314 - test XML + + def destroy(self): + self.destroyed = True + + def undefine(self): + self.undefined = True + + +class _FakeConnection: + def __init__(self) -> None: + self.networks: dict[str, _NativeObject] = {} + self.domains: dict[str, _NativeObject] = {} + + def networkDefineXML(self, xml: str): # noqa: N802 + native = _NativeObject(_name_from_xml(xml), xml) + self.networks[native.name()] = native + return native + + def defineXML(self, xml: str): # noqa: N802 + native = _NativeObject(_name_from_xml(xml), xml) + self.domains[native.name()] = native + return native + + def networkLookupByName(self, name: str): # noqa: N802 + return self.networks[name] + + def lookupByName(self, name: str): # noqa: N802 + return self.domains[name] + + def listAllDomains(self): # noqa: N802 + return [native for native in self.domains.values() if not native.undefined] + + def listAllNetworks(self): # noqa: N802 + return [native for native in self.networks.values() if not native.undefined] + + +def _name_from_xml(xml: str) -> str: + start = xml.index("") + len("") + return xml[start : xml.index("")] + + +@dataclass +class _GuestFacts: + challenge: str = _CHALLENGE + architecture: str = "x86_64" + vcpus: int = 1 + memory_mib: int = 120 + interfaces: list[tuple[str, str, int]] = field(default_factory=list) + content: list[tuple[str, str, str]] = field(default_factory=list) + accounts: list[tuple[str, int, str, str, int, str]] = field(default_factory=list) + services: list[tuple[str, int, int, int]] = field(default_factory=list) + init_complete: bool = True + + def render(self) -> str: + lines = [ + "ACES-GUEST-FACTS v1", + f"challenge {self.challenge}", + f"architecture {self.architecture}", + f"vcpus {self.vcpus}", + f"memory_mib {self.memory_mib}", + ] + lines.extend(f"iface {mac} {ip} {up}" for mac, ip, up in self.interfaces) + lines.extend(f"content {path} {digest} {mode}" for path, digest, mode in self.content) + lines.extend( + f"account {name} {uid} {home} {shell} {dis} {groups}" + for name, uid, home, shell, dis, groups in self.accounts + ) + lines.extend(f"service {name} {port} {lis} {pid}" for name, port, lis, pid in self.services) + if self.init_complete: + lines.append("init complete") + return "\n".join(lines) + "\n" + + +def _sha(content: str) -> str: + return hashlib.sha256(content.encode("utf-8")).hexdigest() + + +def _network() -> NetworkSpec: + return NetworkSpec( + address="scn.net", + name="net", + cidr="10.9.0.0/24", + gateway="10.9.0.1", + labels={"internal": "true"}, + ) + + +def _domain() -> DomainSpec: + return DomainSpec( + address="scn.vm", + name="vm", + image_ref=None, + memory_mib=128, + vcpus=1, + networks=("scn.net",), + services=(ServiceSpec(name="beacon", port=9000),), + cloud_init=CloudInitSpec( + users=( + CloudInitUser( # noqa: S604 - `shell` is a CloudInitUser account field, not a subprocess shell + name="analyst", groups=("aces",), shell="/bin/sh", home="/home/analyst", lock_passwd=True + ), + ), + write_files=(CloudInitFile(path="/etc/aces/marker", content="hello", permissions="0644"),), + ), + ) + + +def _matching_facts() -> _GuestFacts: + mac = mac_address("scn.vm", "scn.net") + return _GuestFacts( + interfaces=[(mac, "10.9.0.10", 1)], + content=[("/etc/aces/marker", _sha("hello"), "644")], + accounts=[("analyst", 1000, "/home/analyst", "/bin/sh", 1, "aces")], + services=[("beacon", 9000, 1, 1)], + ) + + +def _driver(tmp_path: Path, connection: _FakeConnection, transport: _StubTransport) -> GuestCertifiedLibvirtDriver: + kernel = tmp_path / "vmlinuz" + kernel.write_bytes(b"kernel-bytes") + return GuestCertifiedLibvirtDriver( + state_dir=tmp_path / "state", + connection=connection, + name_prefix="aces-gc", + kernel_path=kernel, + initramfs_builder=GuestObservingInitramfsBuilder(busybox_path=Path("/usr/bin/busybox")), + guest_transport=transport, + challenge=_CHALLENGE, + ) + + +def _realize(tmp_path: Path, facts: _GuestFacts | None, *, failure: str | None = None): + connection = _FakeConnection() + facts_map = {"scn.vm": facts.render() if facts is not None else None} + transport = _StubTransport(facts_by_address=facts_map, failure=failure) + driver = _driver(tmp_path, connection, transport) + result = driver.realize(networks=(_network(),), domains=(_domain(),)) + return driver, connection, result + + +def _codes(result) -> set[str]: + return {diag.code for diag in result.diagnostics} + + +def test_guest_certified_happy_path_realizes_and_certifies(tmp_path: Path) -> None: + driver, connection, result = _realize(tmp_path, _matching_facts()) + assert result.diagnostics == () + assert {handle.address for handle in result.domains} == {"scn.vm"} + guest_fields = {obs.field_path for obs in result.observations if obs.source.value == "guest-observed"} + assert { + "guest-architecture", + "guest-vcpus", + "guest-network", + "guest-content", + "guest-account", + "guest-service", + } <= guest_fields + assert driver.last_guest_binding["challenge"] == _CHALLENGE + assert "scn.vm" in driver.last_guest_facts + # Native objects remain (committed), not rolled back. + assert all(not native.destroyed for native in connection.domains.values()) + + +@pytest.mark.parametrize( + ("failure", "expected_code"), + [ + ("transport-unavailable", "libvirt-backend.guest.transport-unavailable"), + ("boot-timeout", "libvirt-backend.guest.boot-timeout"), + ], +) +def test_transport_stage_failures_are_typed_and_roll_back(tmp_path: Path, failure: str, expected_code: str) -> None: + driver, connection, result = _realize(tmp_path, _matching_facts(), failure=failure) + assert expected_code in _codes(result) + assert driver.last_guest_observations == () + assert all(native.destroyed for native in connection.domains.values()) + + +def test_missing_init_completion_fails(tmp_path: Path) -> None: + facts = _matching_facts() + facts.init_complete = False + _, connection, result = _realize(tmp_path, facts) + assert "libvirt-backend.guest.init-incomplete" in _codes(result) + assert all(native.destroyed for native in connection.domains.values()) + + +def test_stale_challenge_is_rejected(tmp_path: Path) -> None: + facts = _matching_facts() + facts.challenge = "0000000000000000" + _, _, result = _realize(tmp_path, facts) + assert "libvirt-backend.guest.challenge-mismatch" in _codes(result) + + +def test_malformed_report_is_rejected(tmp_path: Path) -> None: + connection = _FakeConnection() + transport = _StubTransport(facts_by_address={"scn.vm": "not a fact report"}) + driver = _driver(tmp_path, connection, transport) + result = driver.realize(networks=(_network(),), domains=(_domain(),)) + assert "libvirt-backend.guest.observation-malformed" in _codes(result) + + +@pytest.mark.parametrize("mutate", ["ip", "vcpus", "memory", "content", "account", "service", "missing_content"]) +def test_concern_mismatches_are_falsified(tmp_path: Path, mutate: str) -> None: + facts = _matching_facts() + mac = mac_address("scn.vm", "scn.net") + if mutate == "ip": + facts.interfaces = [(mac, "10.9.0.99", 1)] + elif mutate == "vcpus": + facts.vcpus = 2 + elif mutate == "memory": + facts.memory_mib = 8 # below the corroboration floor + elif mutate == "content": + facts.content = [("/etc/aces/marker", _sha("tampered"), "644")] + elif mutate == "account": + facts.accounts = [("analyst", 1000, "/home/analyst", "/bin/bash", 1, "aces")] + elif mutate == "service": + facts.services = [("beacon", 9000, 0, 1)] + elif mutate == "missing_content": + facts.content = [] + _, connection, result = _realize(tmp_path, facts) + assert "libvirt-backend.guest.observation-mismatch" in _codes(result) + assert all(native.destroyed for native in connection.domains.values()) + + +@pytest.mark.parametrize("second", ["vcpus 9", "vcpus 1", "challenge deadbeefcafef00d"]) +def test_duplicate_singleton_fact_is_falsified(tmp_path: Path, second: str) -> None: + # A repeated singleton fact (identical or conflicting) must be rejected distinctly + # rather than silently collapsed to the last value. + text = _matching_facts().render().replace("vcpus 1\n", f"vcpus 1\n{second}\n") + connection = _FakeConnection() + transport = _StubTransport(facts_by_address={"scn.vm": text}) + driver = _driver(tmp_path, connection, transport) + result = driver.realize(networks=(_network(),), domains=(_domain(),)) + assert "libvirt-backend.guest.observation-duplicate" in _codes(result) + assert all(native.destroyed for native in connection.domains.values()) + + +def test_account_without_supplemental_groups_is_certified(tmp_path: Path) -> None: + # An account with no supplemental groups is valid; the empty trailing groups + # field must not cause the account observation to be dropped. + mac = mac_address("scn.vm", "scn.net") + domain = DomainSpec( + address="scn.vm", + name="vm", + image_ref=None, + memory_mib=128, + vcpus=1, + networks=("scn.net",), + cloud_init=CloudInitSpec( + # noqa below: `shell` is a CloudInitUser account field, not a subprocess shell. + users=(CloudInitUser(name="loner", groups=(), shell="/bin/sh", home="/home/loner", lock_passwd=False),), # noqa: S604 + ), + ) + facts = _GuestFacts( + interfaces=[(mac, "10.9.0.10", 1)], + accounts=[("loner", 1000, "/home/loner", "/bin/sh", 0, "")], + ) + connection = _FakeConnection() + transport = _StubTransport(facts_by_address={"scn.vm": facts.render()}) + driver = _driver(tmp_path, connection, transport) + result = driver.realize(networks=(_network(),), domains=(domain,)) + assert result.diagnostics == () + account_obs = next(obs for obs in result.observations if obs.field_path == "guest-account") + assert account_obs.value == ("loner|/home/loner|/bin/sh|0|",) + + +def test_requested_image_is_rejected_before_mutation(tmp_path: Path) -> None: + connection = _FakeConnection() + transport = _StubTransport(facts_by_address={}) + driver = _driver(tmp_path, connection, transport) + domain = DomainSpec( + address="scn.vm", name="vm", image_ref="ubuntu:24.04", memory_mib=128, vcpus=1, networks=("scn.net",) + ) + result = driver.realize(networks=(_network(),), domains=(domain,)) + assert "libvirt-backend.techvault.image-unsupported" in _codes(result) + assert connection.domains == {} + + +def test_unsupported_placement_is_rejected(tmp_path: Path) -> None: + connection = _FakeConnection() + transport = _StubTransport(facts_by_address={}) + driver = _driver(tmp_path, connection, transport) + domain = DomainSpec( + address="scn.vm", + name="vm", + image_ref=None, + memory_mib=128, + vcpus=1, + networks=("scn.net",), + cloud_init=CloudInitSpec(packages=("nginx",)), + ) + result = driver.realize(networks=(_network(),), domains=(domain,)) + assert "libvirt-backend.techvault.guest-placement-unsupported" in _codes(result) + assert connection.domains == {} + + +# --- evidence-run integration (full control-plane pipeline) -------------------- + + +class _FakeBuilder: + def build(self, *, domain, target: Path): # noqa: ANN001, ANN201 + del domain + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(b"initramfs") + return target + + +def _bounded_guest_scenario(tmp_path: Path) -> Path: + scenario = tmp_path / "guest-certified.sdl.yaml" + scenario.write_text( + """\ +name: guest-certified +nodes: + lab: + type: switch + demo: + type: vm + os: linux + resources: {ram: 128 MiB, cpu: 1} + services: [] +infrastructure: + lab: + properties: {cidr: 192.0.2.0/24, gateway: 192.0.2.1, internal: true} + demo: + links: [lab] +""", + encoding="utf-8", + ) + return scenario + + +def _facts_from_matrix(matrix, challenge: str) -> dict[str, str]: + facts: dict[str, str] = {} + for domain in matrix["domains"]: + lines = [ + "ACES-GUEST-FACTS v1", + f"challenge {challenge}", + "architecture x86_64", + f"vcpus {domain['vcpus']}", + f"memory_mib {max(64, int(domain['memory_mib']) - 8)}", + ] + lines.extend(f"iface {iface['mac']} {iface['ip']} 1" for iface in domain["interfaces"]) + lines.append("init complete") + facts[domain["address"]] = "\n".join(lines) + "\n" + return facts + + +def _guest_matrix(scenario: Path): + from aces_backend_libvirt.manifest import create_libvirt_manifest + from aces_backend_libvirt.realization import interpret_provisioning_plan + from aces_backend_libvirt.techvault_matrix import native_matrix + from aces_sdl.parser import parse_sdl_file + + from aces.core.runtime.manager import RuntimeManager + + scout = GuestCertifiedLibvirtDriver( + state_dir=scenario.parent / "scout", + connection=_FakeConnection(), + name_prefix="evidence-test", + kernel_path=_write_kernel(scenario.parent), + initramfs_builder=_FakeBuilder(), + guest_transport=_StubTransport(facts_by_address={}), + challenge=_CHALLENGE, + ) + from aces_backend_libvirt import create_libvirt_target + + target = create_libvirt_target(participant_runtime=True, driver=scout) + plan = RuntimeManager(target).plan(parse_sdl_file(scenario)).provisioning + capabilities = create_libvirt_manifest(driver_mode="guest-certified-appliance").capabilities.provisioner + realization = interpret_provisioning_plan(plan, provisioner_capabilities=capabilities) + return native_matrix( + networks=realization.networks, + domains=realization.domains, + name_prefix="evidence-test", + include_placements=True, + ) + + +def _write_kernel(directory: Path) -> Path: + kernel = directory / "vmlinuz" + kernel.write_bytes(b"kernel") + return kernel + + +def _guest_factory(tmp_path: Path, transport: _StubTransport): + def factory(): + return GuestCertifiedLibvirtDriver( + state_dir=tmp_path / "state", + connection=_FakeConnection(), + name_prefix="evidence-test", + kernel_path=_write_kernel(tmp_path), + initramfs_builder=_FakeBuilder(), + guest_transport=transport, + challenge=_CHALLENGE, + ) + + return factory + + +def test_evidence_run_guest_certified_publishes_bound_observations(tmp_path: Path) -> None: + from aces_operations.libvirt_evidence_run import ( + LibvirtEvidenceRunConfig, + run_libvirt_evidence_run, + validate_libvirt_evidence_run_artifact, + ) + + scenario = _bounded_guest_scenario(tmp_path) + facts = _facts_from_matrix(_guest_matrix(scenario), _CHALLENGE) + transport = _StubTransport(facts_by_address=facts) + report = run_libvirt_evidence_run( + scenario_path=scenario, + project_dir=tmp_path, + run_id="gc-live-1", + config=LibvirtEvidenceRunConfig(evidence_source_mode="guest-certified"), + driver_factory=_guest_factory(tmp_path, transport), + ) + assert report.passed, report.render() + artifact = report.artifact + assert artifact is not None + assert validate_libvirt_evidence_run_artifact(artifact) == [] + guest = artifact["realization_facts"]["guest_observed"] + assert guest["source"] == "guest-observed" + assert guest["challenge"] == _CHALLENGE + assert guest["operation_ref"].startswith("sha256:") + # Native-proof boundary: an injected fake driver factory can exercise the + # orchestration but must be marked non-certifying so its evidence can never be + # published as a real guest certification. + assert guest["certifying"] is False + assert guest["domains"] and all(domain["correlation"].startswith("sha256:") for domain in guest["domains"]) + assert any(check.name == "native_substrate_cleanup" and check.passed for check in report.checks) + + +def test_evidence_run_guest_certified_transport_failure_fails_closed(tmp_path: Path) -> None: + from aces_operations.libvirt_evidence_run import LibvirtEvidenceRunConfig, run_libvirt_evidence_run + + scenario = _bounded_guest_scenario(tmp_path) + transport = _StubTransport(facts_by_address={}, failure="boot-timeout") + report = run_libvirt_evidence_run( + scenario_path=scenario, + project_dir=tmp_path, + run_id="gc-live-2", + config=LibvirtEvidenceRunConfig(evidence_source_mode="guest-certified"), + driver_factory=_guest_factory(tmp_path, transport), + ) + assert not report.passed, report.render() + assert report.artifact["backend"]["realization_provenance"]["substrate_realized"] is False + assert report.artifact["realization_facts"]["guest_observed"] == { + "source": "guest-observed", + "status": "not-observed", + } + + +def test_evidence_run_guest_certified_residual_probe_artifacts_fail_closed(tmp_path: Path) -> None: + # Native domains/networks are torn down cleanly, but residual guest-probe state + # survives: the run must fail closed rather than claim verified cleanup. + from aces_operations.libvirt_evidence_run import LibvirtEvidenceRunConfig, run_libvirt_evidence_run + + class _ResidualGuestDriver(GuestCertifiedLibvirtDriver): + def destroy(self, *, networks: tuple[str, ...], domains: tuple[str, ...]): + result = super().destroy(networks=networks, domains=domains) + self.last_guest_binding = {"challenge": self.challenge} + return result + + scenario = _bounded_guest_scenario(tmp_path) + facts = _facts_from_matrix(_guest_matrix(scenario), _CHALLENGE) + transport = _StubTransport(facts_by_address=facts) + + def factory(): + return _ResidualGuestDriver( + state_dir=tmp_path / "state", + connection=_FakeConnection(), + name_prefix="evidence-test", + kernel_path=_write_kernel(tmp_path), + initramfs_builder=_FakeBuilder(), + guest_transport=transport, + challenge=_CHALLENGE, + ) + + report = run_libvirt_evidence_run( + scenario_path=scenario, + project_dir=tmp_path, + run_id="gc-residual", + config=LibvirtEvidenceRunConfig(evidence_source_mode="guest-certified"), + driver_factory=factory, + ) + assert not report.passed, report.render() + cleanup = next(check for check in report.checks if check.name == "native_substrate_cleanup") + assert not cleanup.passed + assert any("guest probe artifacts were not fully cleaned" in diag for diag in cleanup.diagnostics) diff --git a/implementations/python/tests/test_libvirt_backend_guest_certified_real_libvirt.py b/implementations/python/tests/test_libvirt_backend_guest_certified_real_libvirt.py new file mode 100644 index 000000000..35c4599db --- /dev/null +++ b/implementations/python/tests/test_libvirt_backend_guest_certified_real_libvirt.py @@ -0,0 +1,72 @@ +"""Opt-in real-libvirt certification for guest-observed realization probes. + +This is the native-proof gate: it boots the guest-observing appliance through the +production apply path against an operator-selected real libvirt/QEMU daemon, reads +concern facts back from inside the guest, and verifies teardown. It is skipped +unless ``ACES_REAL_LIBVIRT_URI`` is set and the host has libvirt-python, cpio, +BusyBox, and a readable kernel. Hermetic fake-driver tests cannot satisfy this +gate. +""" + +from __future__ import annotations + +import importlib +import os +import shutil +from pathlib import Path + +import pytest +from aces_operations.libvirt_evidence_run import ( + LibvirtEvidenceRunConfig, + run_libvirt_evidence_run, + validate_libvirt_evidence_run_artifact, +) +from paths import EXAMPLES_DIR + + +@pytest.mark.integration +def test_guest_certified_real_libvirt_readback_and_cleanup(tmp_path): + """Certify guest-observed realization and verified cleanup on a real daemon.""" + + connection_uri = os.environ.get("ACES_REAL_LIBVIRT_URI") + if not connection_uri: + pytest.skip("set ACES_REAL_LIBVIRT_URI to run real-libvirt guest certification") + try: + libvirt = importlib.import_module("libvirt") + except ImportError: + pytest.skip("libvirt-python is unavailable") + if shutil.which("cpio") is None or not Path("/usr/bin/busybox").is_file(): + pytest.skip("cpio and BusyBox are required for guest-observing appliance certification") + if not tuple(Path("/boot").glob("vmlinuz-*")): + pytest.skip("a readable host kernel is required for guest-observing appliance certification") + + report = run_libvirt_evidence_run( + scenario_path=EXAMPLES_DIR / "techvault-guest-certified.sdl.yaml", + project_dir=tmp_path, + run_id="real-libvirt-guest-certification", + config=LibvirtEvidenceRunConfig(evidence_source_mode="guest-certified", connection_uri=connection_uri), + ) + + assert report.passed, report.render() + artifact = report.artifact + assert artifact is not None + assert validate_libvirt_evidence_run_artifact(artifact) == [] + guest = artifact["realization_facts"]["guest_observed"] + assert guest["source"] == "guest-observed" + assert guest["operation_ref"].startswith("sha256:") + # The production driver (no injected factory) yields a certifying artifact. + assert guest["certifying"] is True + assert guest["domains"] + + daemon = artifact["realization_facts"]["daemon_observed"] + observed_domains = {str(item.get("name")) for item in daemon["domains"]} + observed_networks = {str(item.get("name")) for item in daemon["networks"]} + connection = libvirt.open(connection_uri) + assert connection is not None + try: + remaining_domains = {item.name() for item in connection.listAllDomains()} + remaining_networks = {item.name() for item in connection.listAllNetworks()} + finally: + connection.close() + assert remaining_domains.isdisjoint(observed_domains) + assert remaining_networks.isdisjoint(observed_networks) diff --git a/tools/policy/adr_policy.yaml b/tools/policy/adr_policy.yaml index 9fee528bf..7a7b48eef 100644 --- a/tools/policy/adr_policy.yaml +++ b/tools/policy/adr_policy.yaml @@ -168,6 +168,7 @@ module_boundaries: aces_backend_libvirt: - aces_backend_libvirt.target - aces_backend_libvirt.techvault_native + - aces_backend_libvirt.guest_certified_driver aces_backend_protocols: - aces_backend_protocols.manifest - aces_backend_protocols.capabilities diff --git a/tools/real-daemon/README.md b/tools/real-daemon/README.md index 42cc9f03d..61fae3458 100644 --- a/tools/real-daemon/README.md +++ b/tools/real-daemon/README.md @@ -56,3 +56,44 @@ python real_daemon_smoke.py # or: python libvirt_smoke.py For seeds/disks outside `/var/lib/libvirt/images`, the host needs `security_driver = "none"` and `user/group = "root"` in `/etc/libvirt/qemu.conf` (the AWS script sets these automatically). + +## Guest-certified realization proof (ASR-519, issue #715) + +`libvirt_smoke.py` proves substrate reconciliation/teardown at the *daemon* level. +The **guest-certified** proof goes one layer deeper: it boots a guest-observing +appliance through the production apply path and reads concern facts back **from +inside the guest** (resource allocation, network addressing, file content, and +service state), freshness-bound to a per-run challenge, then verifies teardown. +Domain existence alone never satisfies it. + +The reproducible operator/self-hosted command is: + +```sh +# Against a real libvirt/QEMU daemon (qemu:///system). Boots the appliance, +# certifies from inside the guest, writes a machine-readable evidence artifact, +# and returns non-zero on any failed stage. +aces libvirt techvault guest-certify \ + --scenario examples/scenarios/techvault-guest-certified.sdl.yaml \ + --project-dir . --run-id guest-proof-1 --yes +``` + +It emits the `aces.libvirt.scenario-evidence-run/v1` artifact under +`runs//scenario-evidence/libvirt-scenario-evidence-run.json`. The artifact +is validated (source separation, binding, redaction) **before** it is written, so +it contains no host paths, connection URIs, raw domain UUIDs, XML, or secrets; the +guest report is bound to a redacted control-plane operation reference, the fresh +challenge, the selected envelope/configuration + appliance digests, and a +`sha256:` native correlation. The equivalent gate also runs as an opt-in pytest: + +```sh +ACES_REAL_LIBVIRT_URI=qemu:///system \ + uv run pytest -m integration \ + implementations/python/tests/test_libvirt_backend_guest_certified_real_libvirt.py +``` + +Both are skipped by the default hermetic `nox verify` graph, which never requires +libvirt, QEMU/KVM, privileges, a host image, network access, or credentials — the +guest-certified proof is an explicit separate gate. The same host requirements +apply (`security_driver = "none"` + `user/group = "root"` in +`/etc/libvirt/qemu.conf` when boot artifacts and the run-local guest fact channel +live outside `/var/lib/libvirt/images`; the AWS script sets these automatically). diff --git a/tools/real-daemon/evidence/guest-certified-asr519-20260712T031842Z.json b/tools/real-daemon/evidence/guest-certified-asr519-20260712T031842Z.json new file mode 100644 index 000000000..c99d82e2a --- /dev/null +++ b/tools/real-daemon/evidence/guest-certified-asr519-20260712T031842Z.json @@ -0,0 +1,491 @@ +{ + "backend": { + "capability_profile": { + "observation_contract_gaps": [], + "participant_runtime_contract_gaps": [] + }, + "manifest": { + "capabilities": { + "evaluator": null, + "observation": null, + "orchestrator": null, + "participant_runtime": { + "constraints": { + "simulation_disclosure": "deterministic-simulation: no live libvirt domain execution; see docs/decisions/issue-614-libvirt-participant-runtime.md" + }, + "feature_support": [ + { + "constraint_refs": [], + "disclosure_refs": [ + "docs/decisions/issue-614-libvirt-participant-runtime.md" + ], + "feature": "action_contracts", + "support_level": "disclosed_weak" + }, + { + "constraint_refs": [], + "disclosure_refs": [ + "docs/decisions/issue-614-libvirt-participant-runtime.md" + ], + "feature": "observation_boundaries", + "support_level": "disclosed_weak" + }, + { + "constraint_refs": [], + "disclosure_refs": [ + "docs/decisions/issue-614-libvirt-participant-runtime.md" + ], + "feature": "behavior_history", + "support_level": "disclosed_weak" + }, + { + "constraint_refs": [], + "disclosure_refs": [ + "docs/decisions/issue-614-libvirt-participant-runtime.md" + ], + "feature": "state_transitions", + "support_level": "disclosed_weak" + }, + { + "constraint_refs": [], + "disclosure_refs": [ + "docs/decisions/issue-614-libvirt-participant-runtime.md" + ], + "feature": "contention", + "support_level": "disclosed_weak" + } + ], + "name": "libvirt-deterministic-participant-runtime", + "supported_behavior_features": [ + "action_contracts", + "behavior_history", + "observation_boundaries", + "state_transitions" + ], + "supported_interaction_features": [ + "contention" + ], + "supported_participant_roles": [ + "red" + ] + }, + "provisioner": { + "constraints": {}, + "max_total_nodes": null, + "name": "libvirt-provisioner", + "supported_account_features": [ + "disabled", + "groups", + "home", + "shell" + ], + "supported_content_types": [ + "file" + ], + "supported_node_types": [ + "switch", + "vm" + ], + "supported_os_families": [ + "linux" + ], + "supports_accounts": true, + "supports_acls": false + } + }, + "compatibility": { + "processors": [ + "aces-reference-processor" + ] + }, + "concept_bindings": [ + { + "family": "assets", + "scope": "capabilities.provisioner.supported_node_types" + }, + { + "family": "assets", + "scope": "capabilities.provisioner.supported_os_families" + }, + { + "family": "tools-and-artifacts", + "scope": "capabilities.provisioner.supported_content_types" + }, + { + "family": "identities", + "scope": "capabilities.provisioner.supported_account_features" + } + ], + "constraints": {}, + "identity": { + "name": "libvirt-qemu", + "version": "0.19.1" + }, + "realization_envelope": { + "configuration_digest": "sha256:b33ad469eaf1a47963e49da8adc7376fc22880ac3e6126b427239daa00dc6d99", + "contract_id": "realization-envelope-v1", + "digest": "sha256:8416a600a6f1864e1b80e49fa60eef5f423f9249f2ea7dc56e14d8a669e33e2e", + "envelope_id": "libvirt-qemu.guest-certified-appliance.v1", + "schema_version": "realization-envelope/v1" + }, + "realization_support": [ + { + "constraints": {}, + "disclosure_kinds": [ + "backend-manifest-v2", + "operation-status-v1", + "runtime-snapshot-v1" + ], + "domain": "runtime-realization", + "support_mode": "constrained", + "supported_constraint_kinds": [ + "account-feature", + "content-type", + "node-type", + "os-family" + ], + "supported_exact_requirement_kinds": [ + "declared-capability-match" + ] + } + ], + "schema_version": "backend-manifest/v2", + "supported_contract_versions": [ + "backend-manifest-v2", + "realization-envelope-v1", + "provisioning-plan-v1", + "operation-receipt-v1", + "operation-status-v1", + "runtime-snapshot-v1", + "participant-episode-state-envelope-v1", + "participant-episode-history-event-stream-v1", + "participant-behavior-history-event-stream-v1", + "participant-shared-state-record-v1", + "participant-joint-action-record-v1", + "participant-time-management-context-v1" + ] + }, + "realization_provenance": { + "backend": "libvirt-qemu", + "basis": "daemon-observed-substrate", + "cleanup_verified": true, + "evidence_source_mode": "guest-certified", + "substrate_realized": true + } + }, + "compiled_artifact": { + "compiled_address_sets": { + "action_contracts": [], + "networks": [ + "provision.network.guest-net" + ], + "node_deployments": [ + "provision.node.guest-vm" + ], + "objectives": [], + "observation_boundaries": [], + "participant_behaviors": [] + }, + "compiled_model_fingerprint": "sha256:b0ce6d650a49f579592b7746e84ab43e73ca3d31a7b3e2dd2a6620eb2fcee654", + "processor": "aces-reference-processor" + }, + "defensive_evidence": { + "captured_at": "2026-07-12T03:20:52.516205+00:00", + "evaluator_evidence_channels": [], + "evidence_kind": "telemetry", + "evidence_source": "structural-evaluator-channel", + "loss_disclosure": "Deterministic mode: no live SOC substrate is booted. Wazuh/SOC defensive evidence is reported as the evaluator-only evidence channels declared by the scenario observation boundary, not upstream Wazuh detection output; no detection-quality claim is made. Daemon-observed libvirt substrate state is present, but it is not guest SOC observation.", + "payload_summary": "Evaluator-only Wazuh/SOC and policy-decision evidence channels are declared and kept off the participant view; neither evidence mode claims guest SOC readback.", + "redaction_state": "withheld", + "sensitivity": "restricted", + "visibility": "evaluator-only" + }, + "evaluator_outcome": { + "history": [ + { + "detail": "Scenario-evidence evaluator outcome derived from the structural participant proof.", + "details": {}, + "event_type": "evaluation_completed", + "evidence_refs": [ + "participant_action_proof" + ], + "max_score": null, + "passed": true, + "score": null, + "status": "ready", + "timestamp": "2026-07-12T03:20:52.516205+00:00" + } + ], + "limitations": [ + "Evaluator outcome reflects the structural participant-loop proof; the libvirt backend ships no generic evaluator component, so this is a evidence-run evaluator record, not a generic backend evaluator result." + ], + "result": { + "detail": "Structural participant-loop proof over the libvirt deterministic participant runtime.", + "evidence_refs": [ + "participant_action_proof", + "negative_boundary_checks" + ], + "max_score": null, + "observed_at": "2026-07-12T03:20:52.516205+00:00", + "passed": true, + "resource_type": "participant-loop-evaluation", + "run_id": "scenario-evidence", + "score": null, + "state_schema_version": "evaluation-result-state/v1", + "status": "ready", + "updated_at": "2026-07-12T03:20:52.516205+00:00" + } + }, + "evidence_source_mode": "guest-certified", + "invariant_ledger_refs": { + "action_contracts": [], + "evidence_refs": [ + "participant_action_proof", + "terminal_observation", + "defensive_evidence", + "negative_boundary_checks", + "evaluator_outcome" + ], + "note": "Stable ACES addresses and evidence refs for the Brad-Edwards/aces#600 cross-backend invariant ledger; no libvirt domain UUIDs, host paths, or APTL-private identifiers.", + "observation_boundaries": [], + "participant_behaviors": [], + "scenario_content_sha256": "sha256:54cfbf2301510771eff148748e60dc0917efbbf695270d9a5b837ee80bcbb454", + "scenario_name": "techvault-guest-certified" + }, + "limitations": [ + "The libvirt participant runtime uses the deterministic domain adapter; no live participant domain is executed (issue #614).", + "Wazuh/SOC evidence is evaluator-only structural evidence; daemon substrate state is not promoted to guest or application observation.", + "Deterministic mode does not realize a live libvirt substrate; topology and defensive evidence channels are compiled/structural, explicitly disclosed as not-live observations." + ], + "negative_boundary_checks": { + "all_internal_surfaces_withheld": true, + "checks": [], + "disclosure": "Negative boundary checks are evaluator-side derived analysis, not participant observations.", + "method": "Structural boundary analysis over the compiled observation boundary (hidden_refs) and the participant exposure policy (empty visible/disclosed refs). The participant action surface does not expose the internal DB, Wazuh, evaluator, or policy-gate surfaces.", + "value_status": "reported" + }, + "non_claims": [ + "No Wazuh detection-quality claim.", + "No model-defense robustness claim.", + "No byte-equivalence or application-internals equivalence claim between libvirt appliances and APTL containers.", + "No full semantic-equivalence claim beyond the invariant ledger in Brad-Edwards/aces#600." + ], + "participant_action_proof": { + "admitted_action_addresses": [], + "diagnostics": [], + "episode_states": {}, + "lifecycle_clean": true, + "participant_disclosed_refs": [], + "participant_visible_refs": [], + "runtime": "libvirt-deterministic-participant-runtime", + "structural_validation_note": "Deep behavior-history and episode-snapshot invariant validation is performed by the issue #614 participant-runtime test suite (processor-layer iterators); this artifact records the libvirt participant-runtime lifecycle outcome." + }, + "realization_facts": { + "authored": { + "scenario_name": "techvault-guest-certified", + "source": "authored" + }, + "binding": { + "boot_artifact_digests": { + "initramfs": "sha256:303be8a7ce5a5b9b32c68a9b6469e24034483ef6d978bbcdd2e29ca577a29efd", + "kernel": "sha256:bb4b6f0a792f26999ca7d881a183ec5ab94239073448ddd12cd28e579860face" + }, + "configuration_digest": "sha256:b33ad469eaf1a47963e49da8adc7376fc22880ac3e6126b427239daa00dc6d99", + "connection_uri_digest": "sha256:9bea68fb783f6b3b82c458cf8ce5e62508864d50076287b24ca436af2d110eeb", + "driver": "guest-certified-appliance", + "driver_configuration_digest": "sha256:0a314d970cd037fd46ebde0576ca601401e146181dd1549e525bab208a2c9e78", + "name_prefix_digest": "sha256:5ee63117aa996ced3460eb178f900164350fc8a3b17af78970decb6eed534b93", + "realization_envelope_digest": "sha256:8416a600a6f1864e1b80e49fa60eef5f423f9249f2ea7dc56e14d8a669e33e2e" + }, + "cleanup": { + "source": "driver-reported", + "status": "verified" + }, + "daemon_observed": { + "domains": [ + { + "address": "provision.node.guest-vm", + "architecture": "x86_64", + "image_policy": "generated-initramfs-appliance", + "memory_mib": 128, + "name": "aces-evidence-guest-vm", + "network_attachments": [ + "provision.network.guest-net" + ], + "observation_source": "daemon-observed", + "vcpus": 1 + } + ], + "networks": [ + { + "address": "provision.network.guest-net", + "cidr": "192.0.2.0/24", + "forward_mode": "none", + "gateway": "192.0.2.1", + "internal": true, + "name": "aces-evidence-guest-net", + "observation_source": "daemon-observed" + } + ], + "source": "daemon-observed" + }, + "driver_reported": { + "realized_addresses": [ + "provision.network.guest-net", + "provision.node.guest-vm" + ], + "source": "driver-reported" + }, + "guest_observed": { + "certifying": true, + "challenge": "fe994cd2e7866dd60b98e74e64a88cfb", + "domains": [ + { + "accounts": [], + "address": "provision.node.guest-vm", + "architecture": "x86_64", + "content": [ + "/etc/aces/marker|aabb38db6cd478c6dcbf41b2973d99a8e72cafe9a026df5808d5015452b38a55|644" + ], + "correlation": "sha256:e02c8435b23e310dbe541c17e94a776cf501b290b07e398dbb7a67c89c23d9bb", + "memory_mib": 78, + "network": [ + "52:54:00:c4:7e:78|192.0.2.10" + ], + "services": [ + "beacon|9000|1|1" + ], + "vcpus": 1 + } + ], + "observed_at": "2026-07-12T03:20:52.159020+00:00", + "operation_ref": "sha256:c18ba445c2cd1183774347891759b768d4863493c8e39626ed8885977031630c", + "probe_policy": "serial-fact-channel/v1", + "source": "guest-observed" + }, + "planned": { + "network_addresses": [ + "provision.network.guest-net" + ], + "node_addresses": [ + "provision.node.guest-vm" + ], + "source": "planned" + } + }, + "realized_form_disclosures": [ + { + "authored_ref": null, + "basis": "backend-realized", + "concern_id": "libvirt-backend-selection", + "concern_kind": "backend-selection", + "disclosure": "The libvirt-qemu backend supplied the run; live claims are limited to independently daemon-observed substrate fields.", + "evidence_refs": [], + "realized_by_ref": { + "ref_digest": null, + "ref_id": "libvirt-qemu", + "ref_kind": "backend", + "ref_path": null, + "ref_version": "0.19.1" + }, + "realized_ref": null, + "realized_value_summary": "libvirt-qemu backend (0.19.1); substrate daemon-observed at bounded fields." + }, + { + "authored_ref": null, + "basis": "backend-realized", + "concern_id": "libvirt-participant-implementation", + "concern_kind": "participant-implementation", + "disclosure": "The participant action proof uses the deterministic domain adapter; live domain execution is not performed.", + "evidence_refs": [], + "realized_by_ref": { + "ref_digest": null, + "ref_id": "libvirt-qemu", + "ref_kind": "backend", + "ref_path": null, + "ref_version": "0.19.1" + }, + "realized_ref": null, + "realized_value_summary": "Deterministic libvirt participant runtime (no live domain execution); see issue #614." + } + ], + "realized_topology": { + "basis": "mixed-source", + "disclosure": "Compiled topology remains planned; the native surface contains only independently daemon-observed substrate fields.", + "native_surface": { + "domains": [ + "aces-evidence-guest-vm" + ], + "networks": [ + "aces-evidence-guest-net" + ], + "source": "daemon-observed", + "substrate": "libvirt-qemu-initramfs" + }, + "network_attachment_matrix": { + "guest-vm": [ + "guest-net" + ] + }, + "networks": [ + { + "address": "provision.network.guest-net", + "cidr": "192.0.2.0/24", + "gateway": "192.0.2.1", + "internal": true, + "name": "guest-net", + "source": "planned" + } + ], + "nodes": [ + { + "address": "provision.node.guest-vm", + "name": "guest-vm", + "networks": [ + "guest-net" + ], + "node_type": "vm", + "os_family": "linux", + "services": [ + { + "name": "beacon", + "port": 9000, + "protocol": "tcp" + } + ], + "source": "planned" + } + ] + }, + "recorded_at": "2026-07-12T03:20:52.516205+00:00", + "redaction_provenance": { + "policy": "Only allowlisted, bounded fields are copied into the artifact. Raw libvirt XML, domain UUIDs, QEMU command lines, host paths, connection URIs, credentials, private keys, and backend-private inspect payloads are never written.", + "provenance_refs": [ + "docs/decisions/issue-615-libvirt-paper-evidence-preflight.md", + "docs/decisions/issue-614-libvirt-participant-runtime.md" + ], + "redacted_field_classes": [ + "raw-libvirt-xml", + "domain-uuid", + "qemu-command-line", + "host-path", + "connection-uri", + "credential", + "private-key", + "backend-private-inspect-payload" + ] + }, + "run_id": "asr519-20260712T031842Z", + "scenario": { + "content_sha256": "sha256:54cfbf2301510771eff148748e60dc0917efbbf695270d9a5b837ee80bcbb454", + "name": "techvault-guest-certified", + "relative_path": "examples/scenarios/techvault-guest-certified.sdl.yaml", + "version": "*" + }, + "schema": "aces.libvirt.scenario-evidence-run/v1", + "terminal_observation": { + "behavior_history": {}, + "disclosure": "The libvirt participant runtime emits a behavior-history event stream rather than a standalone SEM-210 observation envelope; the terminal participant view is reported as the behavior-history equivalent.", + "form": "behavior-history-equivalent" + } +} diff --git a/tools/real-daemon/run_aws_guest_certify.sh b/tools/real-daemon/run_aws_guest_certify.sh new file mode 100755 index 000000000..b329eca34 --- /dev/null +++ b/tools/real-daemon/run_aws_guest_certify.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# Provision an ephemeral AWS EC2 host with a real libvirt/QEMU daemon, run the +# guest-certified realization proof (ASR-519, issue #715) against it through the +# production apply path, pull back the redaction-safe machine-readable evidence +# artifact, then tear everything down. This is the guest-observed counterpart to +# run_aws_smoke.sh (which proves daemon-level reconciliation with a cirros disk). +# +# Usage: +# AWS_PROFILE=proof AWS_REGION=us-east-2 tools/real-daemon/run_aws_guest_certify.sh [--keep] +# +# --keep leave the instance running (skip teardown) for manual poking. +# +# On success the emitted evidence JSON is copied to +# tools/real-daemon/evidence/guest-certified-.json +# The instance uses TCG (software emulation); no bare-metal/nested-virt needed. +set -euo pipefail + +PROFILE="${AWS_PROFILE:-proof}" +REGION="${AWS_REGION:-us-east-2}" +INSTANCE_TYPE="${INSTANCE_TYPE:-c5.2xlarge}" +RUN_ID="${RUN_ID:-aws-guest-certified}" +KEEP=0 +[ "${1:-}" = "--keep" ] && KEEP=1 + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +WORK="$(mktemp -d)" +KEY="$WORK/aces-guest-test.pem" +NAME="aces-guest-certify-test" +AWS=(aws --profile "$PROFILE" --region "$REGION") + +cleanup_aws() { + [ "$KEEP" = "1" ] && { echo "--keep: leaving instance ${IID:-?} (${IP:-?}) up"; return; } + echo "=== teardown ===" + [ -n "${IID:-}" ] && "${AWS[@]}" ec2 terminate-instances --instance-ids "$IID" >/dev/null 2>&1 || true + [ -n "${IID:-}" ] && "${AWS[@]}" ec2 wait instance-terminated --instance-ids "$IID" 2>/dev/null || true + [ -n "${SG:-}" ] && "${AWS[@]}" ec2 delete-security-group --group-id "$SG" >/dev/null 2>&1 || true + "${AWS[@]}" ec2 delete-key-pair --key-name "$NAME" >/dev/null 2>&1 || true + echo "torn down." +} +trap cleanup_aws EXIT + +echo "=== identity ==="; "${AWS[@]}" sts get-caller-identity --query Account --output text + +AMI=$("${AWS[@]}" ssm get-parameter --name /aws/service/canonical/ubuntu/server/24.04/stable/current/amd64/hvm/ebs-gp3/ami-id --query Parameter.Value --output text) +MYIP=$(curl -s https://checkip.amazonaws.com) +VPC=$("${AWS[@]}" ec2 describe-vpcs --filters Name=isDefault,Values=true --query 'Vpcs[0].VpcId' --output text) +SUBNET=$("${AWS[@]}" ec2 describe-subnets --filters Name=default-for-az,Values=true --query 'Subnets[0].SubnetId' --output text) + +"${AWS[@]}" ec2 delete-key-pair --key-name "$NAME" >/dev/null 2>&1 || true +"${AWS[@]}" ec2 create-key-pair --key-name "$NAME" --query KeyMaterial --output text > "$KEY" +chmod 600 "$KEY" + +SG=$("${AWS[@]}" ec2 create-security-group --group-name "$NAME-sg" --description "aces guest-certify proof" --vpc-id "$VPC" --query GroupId --output text 2>/dev/null \ + || "${AWS[@]}" ec2 describe-security-groups --filters Name=group-name,Values="$NAME-sg" --query 'SecurityGroups[0].GroupId' --output text) +"${AWS[@]}" ec2 authorize-security-group-ingress --group-id "$SG" --protocol tcp --port 22 --cidr "$MYIP/32" >/dev/null 2>&1 || true + +cat > "$WORK/userdata.sh" <<'UD' +#!/bin/bash +set -x +export DEBIAN_FRONTEND=noninteractive +apt-get update -y +apt-get install -y qemu-system-x86 qemu-utils libvirt-daemon-system libvirt-clients libvirt-dev genisoimage python3-dev pkg-config build-essential curl rsync busybox-static cpio +systemctl enable --now libvirtd +usermod -aG libvirt,kvm ubuntu +# The guest-observing appliance builder defaults to /usr/bin/busybox. +[ -x /usr/bin/busybox ] || ln -sf "$(command -v busybox)" /usr/bin/busybox +# test-host libvirt config so boot artifacts + the guest fact channel outside +# /var/lib/libvirt/images are readable/writable by the qemu process. +sed -i 's/^#*security_driver *=.*/security_driver = "none"/' /etc/libvirt/qemu.conf +grep -q '^security_driver' /etc/libvirt/qemu.conf || echo 'security_driver = "none"' >> /etc/libvirt/qemu.conf +sed -i 's/^#*user *=.*/user = "root"/; s/^#*group *=.*/group = "root"/' /etc/libvirt/qemu.conf +systemctl restart libvirtd +touch /var/lib/cloud/userdata-done +UD + +IID=$("${AWS[@]}" ec2 run-instances --image-id "$AMI" --instance-type "$INSTANCE_TYPE" \ + --key-name "$NAME" --security-group-ids "$SG" --subnet-id "$SUBNET" --associate-public-ip-address \ + --block-device-mappings 'DeviceName=/dev/sda1,Ebs={VolumeSize=30,VolumeType=gp3}' \ + --user-data "file://$WORK/userdata.sh" \ + --tag-specifications "ResourceType=instance,Tags=[{Key=Name,Value=$NAME}]" \ + --query 'Instances[0].InstanceId' --output text) +echo "instance: $IID" +"${AWS[@]}" ec2 wait instance-running --instance-ids "$IID" +IP=$("${AWS[@]}" ec2 describe-instances --instance-ids "$IID" --query 'Reservations[0].Instances[0].PublicIpAddress' --output text) +echo "public ip: $IP" + +SSHOPT=(-i "$KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=15) +echo "=== wait for ssh + userdata ===" +for _ in $(seq 1 40); do ssh "${SSHOPT[@]}" ubuntu@"$IP" "test -f /var/lib/cloud/userdata-done" 2>/dev/null && break; sleep 10; done + +echo "=== deploy code ===" +ssh "${SSHOPT[@]}" ubuntu@"$IP" "mkdir -p /home/ubuntu/aces/implementations/python /home/ubuntu/aces/contracts /home/ubuntu/aces/examples" +rsync -az --delete --exclude '.venv' --exclude '__pycache__' --exclude '.git' --exclude '.pytest_cache' --exclude '.nox' --exclude '*.pyc' \ + -e "ssh ${SSHOPT[*]}" "$REPO_ROOT/implementations/python/" ubuntu@"$IP":/home/ubuntu/aces/implementations/python/ +rsync -az --delete --exclude '.git' -e "ssh ${SSHOPT[*]}" "$REPO_ROOT/contracts/" ubuntu@"$IP":/home/ubuntu/aces/contracts/ +rsync -az --delete --exclude '.git' -e "ssh ${SSHOPT[*]}" "$REPO_ROOT/examples/" ubuntu@"$IP":/home/ubuntu/aces/examples/ +scp "${SSHOPT[@]}" "$REPO_ROOT/.ground-control.yaml" ubuntu@"$IP":/home/ubuntu/aces/.ground-control.yaml +# The editable build (hatch_build.py) reads the repo-root README for packaging. +scp "${SSHOPT[@]}" "$REPO_ROOT/README.md" ubuntu@"$IP":/home/ubuntu/aces/README.md + +echo "=== install venv + libvirt-python ===" +ssh "${SSHOPT[@]}" ubuntu@"$IP" "curl -LsSf https://astral.sh/uv/install.sh | sh >/dev/null 2>&1; cd ~/aces/implementations/python && ~/.local/bin/uv sync --all-extras >/dev/null 2>&1 && ~/.local/bin/uv pip install libvirt-python >/dev/null 2>&1 && echo venv-ready" + +echo "=== run guest-certified proof ===" +ssh "${SSHOPT[@]}" ubuntu@"$IP" "sudo bash -lc 'cd /home/ubuntu/aces && implementations/python/.venv/bin/python -c \" +from pathlib import Path +from aces_operations.libvirt_evidence_run import run_libvirt_evidence_run, LibvirtEvidenceRunConfig +r = run_libvirt_evidence_run(scenario_path=Path(\\\"examples/scenarios/techvault-guest-certified.sdl.yaml\\\").resolve(), project_dir=Path(\\\"/home/ubuntu/aces/gc-out\\\"), run_id=\\\"$RUN_ID\\\", config=LibvirtEvidenceRunConfig(evidence_source_mode=\\\"guest-certified\\\", connection_uri=\\\"qemu:///system\\\")) +print(r.render()) +import sys; sys.exit(0 if r.passed else 1) +\"'" + +echo "=== pull evidence artifact ===" +ssh "${SSHOPT[@]}" ubuntu@"$IP" "sudo chown -R ubuntu /home/ubuntu/aces/gc-out 2>/dev/null || true" +mkdir -p "$REPO_ROOT/tools/real-daemon/evidence" +scp "${SSHOPT[@]}" ubuntu@"$IP":/home/ubuntu/aces/gc-out/runs/"$RUN_ID"/scenario-evidence/libvirt-scenario-evidence-run.json \ + "$REPO_ROOT/tools/real-daemon/evidence/guest-certified-$RUN_ID.json" +echo "pulled: tools/real-daemon/evidence/guest-certified-$RUN_ID.json" From f49ea2d2cb98dc95246217a9c3712dca1ee8c6f0 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sat, 11 Jul 2026 22:36:23 -0700 Subject: [PATCH 13/15] ci: publish Sphinx docs to GitHub Pages (#739) * ci: publish Sphinx docs to GitHub Pages Build the Sphinx site on every PR (breakage check) and deploy it to GitHub Pages on pushes to main. Actions are SHA-pinned to match the repo convention; Pages is enabled with the GitHub Actions build source. Non-strict build (the tree currently emits 63 pre-existing toctree/orphan warnings). * docs: connect all pages, add realization-envelope guide, build strict - New decisions/index.md globs every ADR + design/preflight note into the nav; root index no longer hand-lists ADRs (auto-includes future ones). - Connect the participant-backend-contracts research subdir (hidden toctree + root wiring). - New explain/reference/realization-envelopes.md: observation-strength ladder, the libvirt generic/techvault/guest-certified configurations, how guest-observed realization is proven, and how to reproduce the proof (verified against the envelope + carrier code). - Fix cross-tree links to specs/sdl/diagnostics.md (download role) and a stray transition in the SCN-010 report. - Build is now warning-clean; docs.yml runs sphinx-build -W --keep-going. --- .github/workflows/docs.yml | 54 ++++++ .../scn010-expressivity-gap-analysis.md | 2 - docs/decisions/index.md | 24 +++ .../reference/realization-envelopes.md | 175 ++++++++++++++++++ docs/explain/sdl/validation.md | 4 +- docs/index.md | 82 +------- .../participant-backend-contracts/index.md | 7 + 7 files changed, 265 insertions(+), 83 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100644 docs/decisions/index.md create mode 100644 docs/explain/reference/realization-envelopes.md diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 000000000..945d907ee --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,54 @@ +name: Docs + +# Build the Sphinx documentation on every PR (as a breakage check) and publish it +# to GitHub Pages on pushes to the default branch. Pages must be enabled with the +# "GitHub Actions" build source (Settings -> Pages) for the deploy job to succeed. + +on: + push: + branches: [main] + pull_request: + branches: [main, dev] + workflow_dispatch: + +concurrency: + group: docs-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.12" + - name: Install uv + uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8 + - name: Build Sphinx docs + run: | + uv sync --extra docs --directory implementations/python + implementations/python/.venv/bin/sphinx-build -W --keep-going -b html docs docs/_build/html + - name: Upload Pages artifact + if: github.ref == 'refs/heads/main' + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 + with: + path: docs/_build/html + + deploy: + if: github.ref == 'refs/heads/main' + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 diff --git a/docs/aces/inventory/scn010-expressivity-gap-analysis.md b/docs/aces/inventory/scn010-expressivity-gap-analysis.md index e477c586b..b612281d1 100644 --- a/docs/aces/inventory/scn010-expressivity-gap-analysis.md +++ b/docs/aces/inventory/scn010-expressivity-gap-analysis.md @@ -1,8 +1,6 @@ # SCN-010 Expressivity Gap Analysis — Final Architect-Review Report ## ACES SDL runtime surface coverage for the 16 remaining APTL containers ---- - ## 1. Executive summary The 16 remaining SCN-010 containers fall into seven functional groups — a search/index cluster tier (`wazuh.indexer`, `thehive-es`, `shuffle-opensearch`), an analytics dashboard (`wazuh.dashboard`), log-shipping/intel-sync sidecars (`wazuh-sidecar-db`, `wazuh-sidecar-suricata`, `misp-suricata-sync`) plus the standalone Suricata they govern, a threat-intelligence platform (`misp`), a Redis/MariaDB data-store-and-sync tier (`misp-redis`, `misp-db`), an IR/case-management platform over a wide-column store (`thehive`, `thehive-cassandra`), a SOAR tier (`shuffle-frontend`, `shuffle-orborus`, `shuffle-backend`), and an analyzer/responder engine (`cortex`). Holding each container to the **wazuh.manager parity bar** — wazuh.manager (a SIEM) earned a fully domain-typed `security_monitoring_managers` family rather than being folded into generic `filesystem`/`listeners`/`software` surfaces — surfaces a consistent failure mode: every one of these containers has *defining logical state* that ACES can only shallow-encode today (a Redis cache as a relational `RuntimeDatabaseService`, a search cluster as a relational engine, a MISP as content counts, a SOAR as a data volume, an embedded RBAC store as OS `/etc` identity). The cohesive response is **extension-first at the least structural cost that still clears parity**: collapse the recurring *shapes* into two discriminator-guarded spines plus three orthogonal shared primitives and one forwarder family, each spine **guarded by a `require_profile_for_` after-validator** so the abstraction provably cannot silently shallow-encode a defining fact, with inter-node access detail expressed as typed relationship subtypes, and a single executable cross-family invariant lint making "#439 one small invariant set" testable rather than aspirational. diff --git a/docs/decisions/index.md b/docs/decisions/index.md new file mode 100644 index 000000000..b19d3da3f --- /dev/null +++ b/docs/decisions/index.md @@ -0,0 +1,24 @@ +# Architecture Decisions & Design Notes + +Accepted Architecture Decision Records (ADRs) and the design and preflight notes +that informed the implementation. The ADRs are the durable, governed decisions; +the design and preflight notes are the working analyses that preceded them. + +```{toctree} +:maxdepth: 1 +:caption: Architecture Decision Records +:glob: + +adrs/README +adrs/adr-* +``` + +```{toctree} +:maxdepth: 1 +:caption: Design & Preflight Notes +:glob: + +cage-* +issue-* +sem-* +``` diff --git a/docs/explain/reference/realization-envelopes.md b/docs/explain/reference/realization-envelopes.md new file mode 100644 index 000000000..9d2db6f76 --- /dev/null +++ b/docs/explain/reference/realization-envelopes.md @@ -0,0 +1,175 @@ +# Realization Envelopes and Observation Strength + +This page explains how ACES states, per backend configuration, **which scenario +concerns it can realize and how strongly it can prove each one**. It is +non-normative explanation; the governing decision is +[ADR-070](../../decisions/adrs/adr-070-realization-envelope-semantics.md) and the +formal semantics live in +{download}`specs/formal/realization/envelope-semantics.md <../../../specs/formal/realization/envelope-semantics.md>`. +It is the backend-facing companion to +[Explicitness and Realization Semantics](explicitness-realization-semantics.md) +(which covers author-declaration exactness, SEM-218). + +## The problem + +"The backend realized the scenario" is not a single claim. A backend can create +a named object at the hypervisor and report success while the guest never +booted, got the wrong address, or is missing the file the scenario asked for. +Honest portability needs each backend to disclose, per concern, *what* it +realizes and *how independently that realization is observed* — and to be unable +to claim more than it can show. + +## Observation strength + +Every governed concern carries an **observation strength** — the strongest +evidence the selected configuration produces for it. The ladder is closed +(`aces_contracts.realization_envelope_carrier.ObservationStrength`): + +| Strength | Meaning | +| --- | --- | +| `none` | Not observed (the concern is `unsupported` for this configuration). | +| `driver-reported` | The driver asserts it; no independent readback. | +| `daemon-observed` | Read back from the hypervisor/daemon (e.g. libvirt domain/network XML), ownership-checked. Proves the object exists and is configured at the daemon boundary. | +| `guest-observed` | Read from **inside the realized guest** (its own `/proc`, `/sys`, `/etc`, link/file/account/service state). Proves the running system *is* what was requested, not just that an object exists. | + +A concern's **disposition** (`realized`, `transformed`, `descriptor-only`, +`unsupported`) says whether and how it is realized; the strength says how it is +proven. An `unsupported` concern must claim `none` — a configuration cannot +disclose observation for something it does not realize. + +## Concerns + +The concern taxonomy is closed (`RealizationConcern`): `topology`, +`architecture`, `image`, `resource-allocation`, `network`, `content-placement`, +`account-placement`, `feature-binding`, `service`, `acl`. A realization envelope +discloses a strength and disposition for **every** concern, so gaps are explicit +rather than implied. + +## Configuration-bound identity + +A realization envelope is bound to one **material configuration**, not to a +backend in the abstract. Its secret-free configuration identity (architecture, +image/appliance policy, network policy, supported concern set, guest-observation +transport and probe-policy version, augmentation mechanism) is hashed into a +`configuration_digest`, and the whole envelope into an `envelope_digest`. Raising +a concern's strength requires a *new* configuration and envelope — you cannot +relabel a weaker configuration as stronger. Published envelopes live under +`contracts/realization-envelopes/` and are validated on load. + +## The libvirt backend's configurations + +The libvirt backend (`aces_backend_libvirt.envelopes.LibvirtDriverMode`) ships +three material configurations: + +- **`generic`** — qcow2/cloud-init driver; concerns are `driver-reported`. +- **`techvault-appliance`** — boots a generated BusyBox initramfs appliance and + reads topology/architecture/image/resource/network back at + `daemon-observed` strength; guest concerns are `unsupported`. +- **`guest-certified-appliance`** — boots a guest-observing appliance through the + production apply path and certifies concerns from **inside** the guest. + +The guest-certified envelope discloses (verified against +`contracts/realization-envelopes/libvirt-qemu/guest-certified-appliance-v1.json`): + +| Concern | Disposition | Strength | +| --- | --- | --- | +| topology | realized | daemon-observed | +| architecture | realized | guest-observed | +| image | realized | daemon-observed | +| resource-allocation | realized | guest-observed | +| network | realized | guest-observed | +| content-placement | realized | guest-observed | +| account-placement | realized | guest-observed | +| feature-binding | unsupported | none | +| service | realized | guest-observed | +| acl | unsupported | none | + +The honesty is two-directional: content, accounts, resources, network, and a +service are certified from inside the guest, while `feature-binding` and `acl` +are disclosed `unsupported` rather than faked. One canonical guest is not claimed +to prove every image, OS, or ACL mechanism. + +## How guest-certified realization works + +For the guest-certified configuration, realization enters through the same +production path a real deployment uses +(`RuntimeManager.plan` → `RuntimeControlPlane.submit_provisioning` → +`LibvirtProvisioner.apply` → the native driver). A direct driver call, hand-built +spec, or fake connection can exercise a leaf but cannot satisfy the native-proof +gate. Then: + +1. **Boot.** A guest-observing BusyBox appliance + (`aces_backend_libvirt.guest_appliance`) is booted on real QEMU. It realizes + the bounded seeded content, account, and service placements from the plan. +2. **Read back from inside.** The appliance reads its *own* realized state — + `nproc`/`/proc/meminfo`, `ip addr`/`/sys/class/net`, in-guest file + `sha256`/mode, `/etc/passwd` posture (no credential material), and service + process + bound port — and reports bounded, line-oriented facts over a + credential-free file-backed serial channel + (`aces_backend_libvirt.guest_transport`). No SSH, no password, no general + command runner. +3. **Freshness.** A fresh per-run challenge is injected via the kernel command + line and must be echoed back; a cached or prior-boot report cannot pass. +4. **Stage and compare.** The observer + (`aces_backend_libvirt.guest_observation`) runs ordered stages (daemon → + transport → initialization → concern probes → cleanup); a later stage never + repairs an earlier one. Each concern becomes a `RealizationObservation` at + `guest-observed` strength, compared to the requested realization. Failures are + distinct, stable, redacted `Diagnostic` codes naming the safe ACES address and + observation level — never raw XML, UUIDs, host paths, URIs, or credentials. +5. **Commit eligibility.** The provisioner cannot return success, changed + addresses, or a committed snapshot until all required daemon **and** guest + observations pass. Every failure preserves the baseline snapshot. +6. **Teardown.** Cleanup runs after every attempt (including failures) and + verifies domains, networks, filters, disks, seed media, and probe artifacts + are gone; residual state fails the run. + +## Keeping claims honest + +The envelope, the observer, the falsification tests, and the evidence artifact +move together, so an envelope cannot drift into overclaiming: + +- The evidence producer (`aces_operations.libvirt_evidence_run`) binds each guest + observation to the control-plane operation, the fresh challenge, the selected + envelope/configuration and appliance digests, and a `sha256:` correlation from + the ownership-verified native identity, then runs the shared redaction and + binding validators (`aces_operations._evidence_run_validation`) *before* the + artifact is written. +- **Native-proof boundary.** A guest-certified artifact is `certifying` only when + the production driver/transport produced it. An injected fake driver may + exercise orchestration but is marked non-certifying, so a simulation can never + be published as a real proof. +- **Falsification-first** ([ADR-021](../../decisions/adrs/adr-021-falsification-first-claim-evidence-gate.md)): + the hermetic test suite feeds wrong addresses, tampered digests, dead services, + clamped memory, stale/duplicate facts, and incomplete cleanup, and asserts each + is detected and fails the run. +- Guest facts are captured evidence, kept in the validated artifact rather than + the runtime snapshot, per the observability/evidence-plane separation + ([ADR-066](../../decisions/adrs/adr-066-observability-evidence-plane-separation.md)). + +## Reproducing the proof + +The hermetic tests run in the default `nox verify` graph and never require +libvirt, KVM, privileges, or network. The native-proof gate is a separate, +opt-in run: + +```sh +# Operator/self-hosted command: boots the guest-observing appliance against a +# real libvirt/QEMU daemon and emits a validated evidence artifact. +aces libvirt techvault guest-certify \ + --scenario examples/scenarios/techvault-guest-certified.sdl.yaml \ + --project-dir . --run-id guest-proof-1 --yes +``` + +The equivalent opt-in pytest is gated on `ACES_REAL_LIBVIRT_URI`, and +`tools/real-daemon/run_aws_guest_certify.sh` runs the whole thing on an +ephemeral, self-cleaning host. A committed real-daemon evidence report lives +under `tools/real-daemon/evidence/`. + +## Limits + +One canonical guest-certified appliance proves a bounded concern set on a single +appliance. It does not prove all images, operating systems, service kinds, or +ACL mechanisms; those are disclosed `unsupported` rather than approximated. +Broadening coverage is downstream work (issues #716 conformance and #717 final +scenario certification), which consume these guest observations. diff --git a/docs/explain/sdl/validation.md b/docs/explain/sdl/validation.md index ab0cef5b7..83a984ba3 100644 --- a/docs/explain/sdl/validation.md +++ b/docs/explain/sdl/validation.md @@ -506,7 +506,7 @@ the current validator surface. The normative boundary between a fatal **error** and a non-fatal **advisory** — including the classification criterion that decides which channel a condition belongs to — is stated in -[`specs/sdl/diagnostics.md` §5](../../../specs/sdl/diagnostics.md). This page is +{download}`specs/sdl/diagnostics.md <../../../specs/sdl/diagnostics.md>` §5. This page is non-normative explanation and cites that criterion rather than restating it: an **error** affects SDL meaning (structural/semantic invariants), while an **advisory** is a deployability or quality heuristic that leaves SDL meaning @@ -522,7 +522,7 @@ Current advisory coverage: The fatal, fail-closed error semantics and the collect-all behaviour described here are the explanatory companion to the normative diagnostic boundary in -[`specs/sdl/diagnostics.md`](../../../specs/sdl/diagnostics.md). +{download}`specs/sdl/diagnostics.md <../../../specs/sdl/diagnostics.md>`. All passes run to completion. Errors are collected into a list and raised as a single `SDLValidationError`: diff --git a/docs/index.md b/docs/index.md index 68812e914..fd5ed4c60 100644 --- a/docs/index.md +++ b/docs/index.md @@ -102,85 +102,7 @@ aces/inventory/index :maxdepth: 2 :caption: Architecture Decisions -decisions/adrs/README -decisions/adrs/adr-000-use-adrs -decisions/adrs/adr-001-scenario-description-language -decisions/adrs/adr-002-declarative-sdl-objectives -decisions/adrs/adr-003-workflows-targetable-subobjects-and-enum-variables -decisions/adrs/adr-004-sdl-runtime-layer -decisions/adrs/adr-005-control-flow-primitives -decisions/adrs/adr-006-workflow-control-language-redesign -decisions/adrs/adr-007-lightweight-formal-methods-policy -decisions/adrs/adr-008-processor-layer-and-execution-artifact-boundaries -decisions/adrs/adr-009-normative-artifact-authority-and-repository-structure -decisions/adrs/adr-010-repository-realignment-order-and-compatibility-policy -decisions/adrs/adr-011-narrow-end-to-end-mvp-validation -decisions/adrs/adr-012-shared-concept-authority-and-aces-extension-discipline -decisions/adrs/adr-013-participant-episode-lifecycle-boundaries -decisions/adrs/adr-014-nox-as-canonical-verification-graph -decisions/adrs/adr-015-sdl-processor-layering-and-source-file-size-cap -decisions/adrs/adr-016-semantic-layer-scope-and-coverage-model -decisions/adrs/adr-017-conversation-surface-hardening -decisions/adrs/adr-018-classification-based-assurance-policy -decisions/adrs/adr-019-normative-authority-boundary-manifest -decisions/adrs/adr-020-declarative-participant-framing-boundaries -decisions/adrs/adr-021-falsification-first-claim-evidence-gate -decisions/adrs/adr-022-participant-behavior-and-interaction-semantics -decisions/adrs/adr-023-container-image-build-provenance-surface -decisions/adrs/adr-024-local-identity-inventory-surface -decisions/adrs/adr-025-container-network-realization-surface -decisions/adrs/adr-026-application-http-surface-inventory -decisions/adrs/adr-027-container-init-reaper-runtime-surface -decisions/adrs/adr-028-container-seccomp-security-options-surface -decisions/adrs/adr-029-database-logical-state-runtime-surface -decisions/adrs/adr-030-process-scoped-linux-capability-policy -decisions/adrs/adr-031-ssh-server-configuration-surface -decisions/adrs/adr-032-directory-domain-identity-runtime-surface -decisions/adrs/adr-033-scenario-delivery-boundary-for-runtime-node-state -decisions/adrs/adr-034-runtime-software-component-inventory -decisions/adrs/adr-035-service-manager-unit-state-runtime-surface -decisions/adrs/adr-036-sdl-processor-runtime-module-boundaries -decisions/adrs/adr-037-runtime-file-service-and-filesystem-presence-semantics -decisions/adrs/adr-038-runtime-mail-service-logical-state -decisions/adrs/adr-039-dns-service-runtime-inventory -decisions/adrs/adr-040-security-monitoring-manager-runtime-inventory -decisions/adrs/adr-041-participant-implementation-manifest-and-provenance -decisions/adrs/adr-042-network-sensor-runtime-monitoring -decisions/adrs/adr-043-runtime-service-listener-surface -decisions/adrs/adr-044-network-detection-engine-runtime-inventory -decisions/adrs/adr-045-security-monitoring-detection-definition-semantics -decisions/adrs/adr-046-app-authorization-runtime-inventory -decisions/adrs/adr-047-scheduled-job-runtime-inventory -decisions/adrs/adr-048-datastore-service-runtime-inventory -decisions/adrs/adr-049-platform-application-runtime-inventory -decisions/adrs/adr-050-forwarding-agent-runtime-inventory -decisions/adrs/adr-051-orchestration-authority-runtime-inventory -decisions/adrs/adr-052-typed-runtime-relationship-subtypes -decisions/adrs/adr-053-sdl-module-composition-for-inventory-backed-scenarios -decisions/adrs/adr-054-participant-runtime-observable-lifecycle -decisions/adrs/adr-055-experiment-core-contract-boundary -decisions/adrs/adr-056-runtime-observed-values-and-credential-posture -decisions/adrs/adr-057-runtime-secret-name-classifier-boundaries -decisions/adrs/adr-058-datastore-node-engine-provenance-and-endpoints -decisions/adrs/adr-059-adr-amendment-policy-and-pin-gate -decisions/adrs/adr-060-participant-backend-facing-contract-surface -decisions/adrs/adr-061-published-schema-evolution-policy -decisions/adrs/adr-062-concept-authority-catalog-governance-gate -decisions/adrs/adr-063-reference-emulation-backend -decisions/adrs/adr-064-experiment-evidence-and-measure-contract-boundary -decisions/adrs/adr-065-experiment-run-provenance-contract-boundary -decisions/adrs/adr-066-observability-evidence-plane-separation -decisions/adrs/adr-067-participant-behavior-model -decisions/adrs/adr-068-experiment-trials-replication-and-replay-claims -decisions/adrs/adr-069-cage-2-replication-architecture -decisions/adrs/adr-070-realization-envelope-semantics -decisions/adrs/adr-071-reusable-asset-trust-and-integrity-policy -decisions/adrs/adr-072-validation-and-admission-profiles -decisions/issue-248-sem-216-boundary-semantics-preflight -decisions/sem-213-temporal-participant-preflight -decisions/issue-508-related-work-comparison-preflight -decisions/issue-42-validator-package-split-preflight -decisions/issue-567-pr-title-guard-preflight +decisions/index ``` ```{toctree} @@ -200,6 +122,7 @@ explain/reference/normative-artifact-authority explain/reference/assessment-semantics explain/reference/objective-semantics explain/reference/explicitness-realization-semantics +explain/reference/realization-envelopes ``` ```{toctree} @@ -221,6 +144,7 @@ research/scoring-scope/index research/validation-admission-profiles/index research/primary/index research/related-work-comparison/index +research/participant-backend-contracts/index ``` ```{toctree} diff --git a/docs/research/participant-backend-contracts/index.md b/docs/research/participant-backend-contracts/index.md index 5f17bf26c..c28d948a0 100644 --- a/docs/research/participant-backend-contracts/index.md +++ b/docs/research/participant-backend-contracts/index.md @@ -27,6 +27,13 @@ issue #76: how the established semantics should be carried as portable plain-data contracts, what the prior art does for the same problem, and what design criteria follow. +```{toctree} +:hidden: + +prior-art-and-design-criteria +preflight-guardrails +``` + ## Contents - [Prior art and design criteria](prior-art-and-design-criteria.md) — how From 066b3f0c6103deb732795ebba90c4822d798f942 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 12 Jul 2026 08:40:22 -0700 Subject: [PATCH 14/15] feat: define associated artifact manifest contracts (#741) * Define associated artifact manifest contracts * Fix SonarCloud findings (cycle 1) * Fix SonarCloud findings (cycle 2) --- .../authenticity-without-threshold.json | 24 + .../invalid/duplicate-evidence-class.json | 24 + .../invalid/missing-family.json | 24 + .../invalid/missing-integrity.json | 24 + .../invalid/secret-bearing.json | 24 + .../invalid/unknown-family.json | 24 + .../vocabulary-missing-governance-source.json | 24 + .../valid/reference.json | 24 + .../invalid/exact-descriptor-alias.json | 42 ++ .../invalid/keyed-id-mismatch.json | 28 ++ .../invalid/scope-parent-mismatch.json | 28 ++ .../invalid/secret-bearing-uri.json | 28 ++ .../invalid/unknown-extra.json | 29 ++ .../valid/apparatus-context.json | 29 ++ .../valid/authoring-input.json | 29 ++ .../valid/run.json | 29 ++ .../valid/scenario-snapshot.json | 30 ++ .../valid/scenario.json | 28 ++ .../valid/study.json | 29 ++ .../valid/task.json | 29 ++ contracts/schema-publication-manifest.json | 64 ++- .../reusable-asset-trust-policy-v1.json | 18 +- .../associated-artifact-manifest-v1.json | 475 ++++++++++++++++++ .../experiment-apparatus-context-v1.json | 6 + .../experiment-authoring-input-v1.json | 6 + .../experiment-capture-spec-v1.json | 6 + .../experiment-derived-measure-v1.json | 1 + .../experiment-evidence-record-v1.json | 6 + .../experiment-core/experiment-run-v1.json | 6 + .../experiment-core/experiment-study-v1.json | 6 + .../experiment-core/experiment-task-v1.json | 6 + docs/decisions/adrs/README.md | 1 + ...7-associated-artifact-manifest-boundary.md | 107 ++++ docs/decisions/adrs/adr-index.yaml | 3 + ...associated-artifact-manifests-preflight.md | 442 ++++++++++++++++ .../packages/aces_conformance/conformance.py | 21 +- .../aces_contracts/associated_artifacts.py | 338 +++++++++++++ .../packages/aces_contracts/contracts.py | 196 ++++++++ .../packages/aces_contracts/versions.py | 1 + implementations/python/pyproject.toml | 1 + .../test_associated_artifact_manifests.py | 389 ++++++++++++++ implementations/python/uv.lock | 78 +++ .../associated-artifact-manifests.md | 152 ++++++ .../reusable-asset-trust-integrity.md | 12 +- tools/generate_contract_schemas.py | 2 + 45 files changed, 2860 insertions(+), 33 deletions(-) create mode 100644 contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/invalid/exact-descriptor-alias.json create mode 100644 contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/invalid/keyed-id-mismatch.json create mode 100644 contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/invalid/scope-parent-mismatch.json create mode 100644 contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/invalid/secret-bearing-uri.json create mode 100644 contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/invalid/unknown-extra.json create mode 100644 contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/valid/apparatus-context.json create mode 100644 contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/valid/authoring-input.json create mode 100644 contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/valid/run.json create mode 100644 contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/valid/scenario-snapshot.json create mode 100644 contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/valid/scenario.json create mode 100644 contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/valid/study.json create mode 100644 contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/valid/task.json create mode 100644 contracts/schemas/associated-artifacts/associated-artifact-manifest-v1.json create mode 100644 docs/decisions/adrs/adr-077-associated-artifact-manifest-boundary.md create mode 100644 docs/decisions/issue-738-associated-artifact-manifests-preflight.md create mode 100644 implementations/python/packages/aces_contracts/associated_artifacts.py create mode 100644 implementations/python/tests/test_associated_artifact_manifests.py create mode 100644 specs/supply-chain/associated-artifact-manifests.md diff --git a/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/invalid/authenticity-without-threshold.json b/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/invalid/authenticity-without-threshold.json index c669b664d..8b36ac6ba 100644 --- a/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/invalid/authenticity-without-threshold.json +++ b/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/invalid/authenticity-without-threshold.json @@ -2,6 +2,30 @@ "schema_version": "reusable-asset-trust-policy/v1", "policy_id": "aces-reusable-asset-trust-policy", "families": [ + { + "asset_family": "associated_artifact_set", + "identity_basis": "associated-artifact-manifest-v1 canonical parent-plus-reference-set digest", + "evidence_requirements": [ + { + "evidence_class": "integrity_digest", + "enforcement": "required", + "mechanism_ref": "associated-artifact-set/v1 derived set_digest", + "description": "The set digest binds the exact parent reference and keyed artifact-reference set without changing parent identity." + }, + { + "evidence_class": "artifact_checksum", + "enforcement": "required", + "mechanism_ref": "validate_associated_artifact_manifest bounded concrete-byte validation", + "description": "Every referenced payload checksum and size is recomputed from an explicitly supplied bounded byte stream." + }, + { + "evidence_class": "authenticity_signature", + "enforcement": "optional", + "mechanism_ref": "downstream signature over the derived associated-artifact set digest", + "description": "A signature may authenticate the set digest, but neither the digest nor payload checksums establish authenticity alone." + } + ] + }, { "asset_family": "reusable_scenario", "identity_basis": "identity-basis-for-reusable_scenario", diff --git a/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/invalid/duplicate-evidence-class.json b/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/invalid/duplicate-evidence-class.json index 1320ef76c..bc870ed47 100644 --- a/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/invalid/duplicate-evidence-class.json +++ b/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/invalid/duplicate-evidence-class.json @@ -2,6 +2,30 @@ "schema_version": "reusable-asset-trust-policy/v1", "policy_id": "aces-reusable-asset-trust-policy", "families": [ + { + "asset_family": "associated_artifact_set", + "identity_basis": "associated-artifact-manifest-v1 canonical parent-plus-reference-set digest", + "evidence_requirements": [ + { + "evidence_class": "integrity_digest", + "enforcement": "required", + "mechanism_ref": "associated-artifact-set/v1 derived set_digest", + "description": "The set digest binds the exact parent reference and keyed artifact-reference set without changing parent identity." + }, + { + "evidence_class": "artifact_checksum", + "enforcement": "required", + "mechanism_ref": "validate_associated_artifact_manifest bounded concrete-byte validation", + "description": "Every referenced payload checksum and size is recomputed from an explicitly supplied bounded byte stream." + }, + { + "evidence_class": "authenticity_signature", + "enforcement": "optional", + "mechanism_ref": "downstream signature over the derived associated-artifact set digest", + "description": "A signature may authenticate the set digest, but neither the digest nor payload checksums establish authenticity alone." + } + ] + }, { "asset_family": "reusable_scenario", "identity_basis": "identity-basis-for-reusable_scenario", diff --git a/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/invalid/missing-family.json b/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/invalid/missing-family.json index 21ba089c2..1bbe57186 100644 --- a/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/invalid/missing-family.json +++ b/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/invalid/missing-family.json @@ -2,6 +2,30 @@ "schema_version": "reusable-asset-trust-policy/v1", "policy_id": "aces-reusable-asset-trust-policy", "families": [ + { + "asset_family": "associated_artifact_set", + "identity_basis": "associated-artifact-manifest-v1 canonical parent-plus-reference-set digest", + "evidence_requirements": [ + { + "evidence_class": "integrity_digest", + "enforcement": "required", + "mechanism_ref": "associated-artifact-set/v1 derived set_digest", + "description": "The set digest binds the exact parent reference and keyed artifact-reference set without changing parent identity." + }, + { + "evidence_class": "artifact_checksum", + "enforcement": "required", + "mechanism_ref": "validate_associated_artifact_manifest bounded concrete-byte validation", + "description": "Every referenced payload checksum and size is recomputed from an explicitly supplied bounded byte stream." + }, + { + "evidence_class": "authenticity_signature", + "enforcement": "optional", + "mechanism_ref": "downstream signature over the derived associated-artifact set digest", + "description": "A signature may authenticate the set digest, but neither the digest nor payload checksums establish authenticity alone." + } + ] + }, { "asset_family": "reusable_scenario", "identity_basis": "identity-basis-for-reusable_scenario", diff --git a/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/invalid/missing-integrity.json b/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/invalid/missing-integrity.json index bc0396f5c..430baa968 100644 --- a/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/invalid/missing-integrity.json +++ b/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/invalid/missing-integrity.json @@ -2,6 +2,30 @@ "schema_version": "reusable-asset-trust-policy/v1", "policy_id": "aces-reusable-asset-trust-policy", "families": [ + { + "asset_family": "associated_artifact_set", + "identity_basis": "associated-artifact-manifest-v1 canonical parent-plus-reference-set digest", + "evidence_requirements": [ + { + "evidence_class": "integrity_digest", + "enforcement": "required", + "mechanism_ref": "associated-artifact-set/v1 derived set_digest", + "description": "The set digest binds the exact parent reference and keyed artifact-reference set without changing parent identity." + }, + { + "evidence_class": "artifact_checksum", + "enforcement": "required", + "mechanism_ref": "validate_associated_artifact_manifest bounded concrete-byte validation", + "description": "Every referenced payload checksum and size is recomputed from an explicitly supplied bounded byte stream." + }, + { + "evidence_class": "authenticity_signature", + "enforcement": "optional", + "mechanism_ref": "downstream signature over the derived associated-artifact set digest", + "description": "A signature may authenticate the set digest, but neither the digest nor payload checksums establish authenticity alone." + } + ] + }, { "asset_family": "reusable_scenario", "identity_basis": "identity-basis-for-reusable_scenario", diff --git a/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/invalid/secret-bearing.json b/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/invalid/secret-bearing.json index 8f3c6cec5..6bc062369 100644 --- a/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/invalid/secret-bearing.json +++ b/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/invalid/secret-bearing.json @@ -2,6 +2,30 @@ "schema_version": "reusable-asset-trust-policy/v1", "policy_id": "aces-reusable-asset-trust-policy", "families": [ + { + "asset_family": "associated_artifact_set", + "identity_basis": "associated-artifact-manifest-v1 canonical parent-plus-reference-set digest", + "evidence_requirements": [ + { + "evidence_class": "integrity_digest", + "enforcement": "required", + "mechanism_ref": "associated-artifact-set/v1 derived set_digest", + "description": "The set digest binds the exact parent reference and keyed artifact-reference set without changing parent identity." + }, + { + "evidence_class": "artifact_checksum", + "enforcement": "required", + "mechanism_ref": "validate_associated_artifact_manifest bounded concrete-byte validation", + "description": "Every referenced payload checksum and size is recomputed from an explicitly supplied bounded byte stream." + }, + { + "evidence_class": "authenticity_signature", + "enforcement": "optional", + "mechanism_ref": "downstream signature over the derived associated-artifact set digest", + "description": "A signature may authenticate the set digest, but neither the digest nor payload checksums establish authenticity alone." + } + ] + }, { "asset_family": "reusable_scenario", "identity_basis": "identity-basis-for-reusable_scenario", diff --git a/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/invalid/unknown-family.json b/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/invalid/unknown-family.json index 7b7b1b76e..460d425d1 100644 --- a/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/invalid/unknown-family.json +++ b/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/invalid/unknown-family.json @@ -2,6 +2,30 @@ "schema_version": "reusable-asset-trust-policy/v1", "policy_id": "aces-reusable-asset-trust-policy", "families": [ + { + "asset_family": "associated_artifact_set", + "identity_basis": "associated-artifact-manifest-v1 canonical parent-plus-reference-set digest", + "evidence_requirements": [ + { + "evidence_class": "integrity_digest", + "enforcement": "required", + "mechanism_ref": "associated-artifact-set/v1 derived set_digest", + "description": "The set digest binds the exact parent reference and keyed artifact-reference set without changing parent identity." + }, + { + "evidence_class": "artifact_checksum", + "enforcement": "required", + "mechanism_ref": "validate_associated_artifact_manifest bounded concrete-byte validation", + "description": "Every referenced payload checksum and size is recomputed from an explicitly supplied bounded byte stream." + }, + { + "evidence_class": "authenticity_signature", + "enforcement": "optional", + "mechanism_ref": "downstream signature over the derived associated-artifact set digest", + "description": "A signature may authenticate the set digest, but neither the digest nor payload checksums establish authenticity alone." + } + ] + }, { "asset_family": "reusable_scenario", "identity_basis": "identity-basis-for-reusable_scenario", diff --git a/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/invalid/vocabulary-missing-governance-source.json b/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/invalid/vocabulary-missing-governance-source.json index 90fd61ca1..054455210 100644 --- a/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/invalid/vocabulary-missing-governance-source.json +++ b/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/invalid/vocabulary-missing-governance-source.json @@ -2,6 +2,30 @@ "schema_version": "reusable-asset-trust-policy/v1", "policy_id": "aces-reusable-asset-trust-policy", "families": [ + { + "asset_family": "associated_artifact_set", + "identity_basis": "associated-artifact-manifest-v1 canonical parent-plus-reference-set digest", + "evidence_requirements": [ + { + "evidence_class": "integrity_digest", + "enforcement": "required", + "mechanism_ref": "associated-artifact-set/v1 derived set_digest", + "description": "The set digest binds the exact parent reference and keyed artifact-reference set without changing parent identity." + }, + { + "evidence_class": "artifact_checksum", + "enforcement": "required", + "mechanism_ref": "validate_associated_artifact_manifest bounded concrete-byte validation", + "description": "Every referenced payload checksum and size is recomputed from an explicitly supplied bounded byte stream." + }, + { + "evidence_class": "authenticity_signature", + "enforcement": "optional", + "mechanism_ref": "downstream signature over the derived associated-artifact set digest", + "description": "A signature may authenticate the set digest, but neither the digest nor payload checksums establish authenticity alone." + } + ] + }, { "asset_family": "reusable_scenario", "identity_basis": "identity-basis-for-reusable_scenario", diff --git a/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/valid/reference.json b/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/valid/reference.json index eac5b50a6..157aed6b4 100644 --- a/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/valid/reference.json +++ b/contracts/fixtures/asset-trust/reusable-asset-trust-policy-v1/valid/reference.json @@ -2,6 +2,30 @@ "schema_version": "reusable-asset-trust-policy/v1", "policy_id": "aces-reusable-asset-trust-policy", "families": [ + { + "asset_family": "associated_artifact_set", + "identity_basis": "associated-artifact-manifest-v1 canonical parent-plus-reference-set digest", + "evidence_requirements": [ + { + "evidence_class": "integrity_digest", + "enforcement": "required", + "mechanism_ref": "associated-artifact-set/v1 derived set_digest", + "description": "The set digest binds the exact parent reference and keyed artifact-reference set without changing parent identity." + }, + { + "evidence_class": "artifact_checksum", + "enforcement": "required", + "mechanism_ref": "validate_associated_artifact_manifest bounded concrete-byte validation", + "description": "Every referenced payload checksum and size is recomputed from an explicitly supplied bounded byte stream." + }, + { + "evidence_class": "authenticity_signature", + "enforcement": "optional", + "mechanism_ref": "downstream signature over the derived associated-artifact set digest", + "description": "A signature may authenticate the set digest, but neither the digest nor payload checksums establish authenticity alone." + } + ] + }, { "asset_family": "reusable_scenario", "identity_basis": "instantiated-scenario-v1 / scenario-instantiation-request-v1 scenario reference id", diff --git a/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/invalid/exact-descriptor-alias.json b/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/invalid/exact-descriptor-alias.json new file mode 100644 index 000000000..8fb4bda8e --- /dev/null +++ b/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/invalid/exact-descriptor-alias.json @@ -0,0 +1,42 @@ +{ + "schema_version": "associated-artifact-manifest/v1", + "manifest_id": "scenario-attachments", + "manifest_version": "1.0.0", + "canonicalization_profile": "associated-artifact-set/v1", + "scope": "scenario", + "parent_ref": { + "ref_kind": "scenario", + "ref_id": "training-range" + }, + "artifacts": { + "operator-guide": { + "artifact_id": "operator-guide", + "role": "operator-guide", + "media_type": "text/markdown", + "uri": "urn:sha256:ddc7a69ede7530aa87098a487dbae1d82e751dce5cd5bbe8477355ecd12f4287", + "checksum": { + "algorithm": "sha256", + "value": "ddc7a69ede7530aa87098a487dbae1d82e751dce5cd5bbe8477355ecd12f4287" + }, + "size_bytes": 15, + "created_at": "2026-07-12T00:00:00Z", + "source": "scenario-author", + "sensitivity": "internal" + }, + "guide-copy": { + "artifact_id": "guide-copy", + "role": "operator-guide", + "media_type": "text/markdown", + "uri": "urn:sha256:ddc7a69ede7530aa87098a487dbae1d82e751dce5cd5bbe8477355ecd12f4287", + "checksum": { + "algorithm": "sha256", + "value": "ddc7a69ede7530aa87098a487dbae1d82e751dce5cd5bbe8477355ecd12f4287" + }, + "size_bytes": 15, + "created_at": "2026-07-12T00:00:00Z", + "source": "scenario-author", + "sensitivity": "internal" + } + }, + "set_digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000" +} diff --git a/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/invalid/keyed-id-mismatch.json b/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/invalid/keyed-id-mismatch.json new file mode 100644 index 000000000..03b53e446 --- /dev/null +++ b/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/invalid/keyed-id-mismatch.json @@ -0,0 +1,28 @@ +{ + "schema_version": "associated-artifact-manifest/v1", + "manifest_id": "scenario-attachments", + "manifest_version": "1.0.0", + "canonicalization_profile": "associated-artifact-set/v1", + "scope": "scenario", + "parent_ref": { + "ref_kind": "scenario", + "ref_id": "training-range" + }, + "artifacts": { + "operator-guide": { + "artifact_id": "other-id", + "role": "operator-guide", + "media_type": "text/markdown", + "uri": "urn:sha256:ddc7a69ede7530aa87098a487dbae1d82e751dce5cd5bbe8477355ecd12f4287", + "checksum": { + "algorithm": "sha256", + "value": "ddc7a69ede7530aa87098a487dbae1d82e751dce5cd5bbe8477355ecd12f4287" + }, + "size_bytes": 15, + "created_at": "2026-07-12T00:00:00Z", + "source": "scenario-author", + "sensitivity": "internal" + } + }, + "set_digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000" +} diff --git a/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/invalid/scope-parent-mismatch.json b/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/invalid/scope-parent-mismatch.json new file mode 100644 index 000000000..e013a8bb3 --- /dev/null +++ b/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/invalid/scope-parent-mismatch.json @@ -0,0 +1,28 @@ +{ + "schema_version": "associated-artifact-manifest/v1", + "manifest_id": "scenario-attachments", + "manifest_version": "1.0.0", + "canonicalization_profile": "associated-artifact-set/v1", + "scope": "experiment", + "parent_ref": { + "ref_kind": "scenario", + "ref_id": "training-range" + }, + "artifacts": { + "operator-guide": { + "artifact_id": "operator-guide", + "role": "operator-guide", + "media_type": "text/markdown", + "uri": "urn:sha256:ddc7a69ede7530aa87098a487dbae1d82e751dce5cd5bbe8477355ecd12f4287", + "checksum": { + "algorithm": "sha256", + "value": "ddc7a69ede7530aa87098a487dbae1d82e751dce5cd5bbe8477355ecd12f4287" + }, + "size_bytes": 15, + "created_at": "2026-07-12T00:00:00Z", + "source": "scenario-author", + "sensitivity": "internal" + } + }, + "set_digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000" +} diff --git a/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/invalid/secret-bearing-uri.json b/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/invalid/secret-bearing-uri.json new file mode 100644 index 000000000..5717cef9a --- /dev/null +++ b/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/invalid/secret-bearing-uri.json @@ -0,0 +1,28 @@ +{ + "schema_version": "associated-artifact-manifest/v1", + "manifest_id": "scenario-attachments", + "manifest_version": "1.0.0", + "canonicalization_profile": "associated-artifact-set/v1", + "scope": "scenario", + "parent_ref": { + "ref_kind": "scenario", + "ref_id": "training-range" + }, + "artifacts": { + "operator-guide": { + "artifact_id": "operator-guide", + "role": "operator-guide", + "media_type": "text/markdown", + "uri": "https://user:secret@example.test/guide", + "checksum": { + "algorithm": "sha256", + "value": "ddc7a69ede7530aa87098a487dbae1d82e751dce5cd5bbe8477355ecd12f4287" + }, + "size_bytes": 15, + "created_at": "2026-07-12T00:00:00Z", + "source": "scenario-author", + "sensitivity": "internal" + } + }, + "set_digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000" +} diff --git a/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/invalid/unknown-extra.json b/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/invalid/unknown-extra.json new file mode 100644 index 000000000..5f0780e6c --- /dev/null +++ b/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/invalid/unknown-extra.json @@ -0,0 +1,29 @@ +{ + "schema_version": "associated-artifact-manifest/v1", + "manifest_id": "scenario-attachments", + "manifest_version": "1.0.0", + "canonicalization_profile": "associated-artifact-set/v1", + "scope": "scenario", + "parent_ref": { + "ref_kind": "scenario", + "ref_id": "training-range" + }, + "artifacts": { + "operator-guide": { + "artifact_id": "operator-guide", + "role": "operator-guide", + "media_type": "text/markdown", + "uri": "urn:sha256:ddc7a69ede7530aa87098a487dbae1d82e751dce5cd5bbe8477355ecd12f4287", + "checksum": { + "algorithm": "sha256", + "value": "ddc7a69ede7530aa87098a487dbae1d82e751dce5cd5bbe8477355ecd12f4287" + }, + "size_bytes": 15, + "created_at": "2026-07-12T00:00:00Z", + "source": "scenario-author", + "sensitivity": "internal" + } + }, + "set_digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "credentials": "forbidden" +} diff --git a/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/valid/apparatus-context.json b/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/valid/apparatus-context.json new file mode 100644 index 000000000..3bc6b96f6 --- /dev/null +++ b/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/valid/apparatus-context.json @@ -0,0 +1,29 @@ +{ + "schema_version": "associated-artifact-manifest/v1", + "manifest_id": "apparatus-context-attachments", + "manifest_version": "1.0.0", + "canonicalization_profile": "associated-artifact-set/v1", + "scope": "experiment", + "parent_ref": { + "ref_kind": "apparatus-context", + "ref_id": "apparatus-001", + "ref_version": "1.0.0" + }, + "artifacts": { + "operator-guide": { + "artifact_id": "operator-guide", + "role": "operator-guide", + "media_type": "text/markdown", + "uri": "urn:sha256:ddc7a69ede7530aa87098a487dbae1d82e751dce5cd5bbe8477355ecd12f4287", + "checksum": { + "algorithm": "sha256", + "value": "ddc7a69ede7530aa87098a487dbae1d82e751dce5cd5bbe8477355ecd12f4287" + }, + "size_bytes": 15, + "created_at": "2026-07-12T00:00:00Z", + "source": "scenario-author", + "sensitivity": "internal" + } + }, + "set_digest": "sha256:da6f81eb2f60aefa063a967f884e5d7aef0f9730a3d7372b0ed54f19832471b1" +} diff --git a/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/valid/authoring-input.json b/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/valid/authoring-input.json new file mode 100644 index 000000000..2e84b2d3b --- /dev/null +++ b/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/valid/authoring-input.json @@ -0,0 +1,29 @@ +{ + "schema_version": "associated-artifact-manifest/v1", + "manifest_id": "authoring-input-attachments", + "manifest_version": "1.0.0", + "canonicalization_profile": "associated-artifact-set/v1", + "scope": "experiment", + "parent_ref": { + "ref_kind": "authoring-input", + "ref_id": "experiment-input-001", + "ref_version": "1.0.0" + }, + "artifacts": { + "operator-guide": { + "artifact_id": "operator-guide", + "role": "operator-guide", + "media_type": "text/markdown", + "uri": "urn:sha256:ddc7a69ede7530aa87098a487dbae1d82e751dce5cd5bbe8477355ecd12f4287", + "checksum": { + "algorithm": "sha256", + "value": "ddc7a69ede7530aa87098a487dbae1d82e751dce5cd5bbe8477355ecd12f4287" + }, + "size_bytes": 15, + "created_at": "2026-07-12T00:00:00Z", + "source": "scenario-author", + "sensitivity": "internal" + } + }, + "set_digest": "sha256:24afeca049625cb389666110a694a2bc675cbf3750e2f7c405b0364ca0e22647" +} diff --git a/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/valid/run.json b/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/valid/run.json new file mode 100644 index 000000000..a537c5ac4 --- /dev/null +++ b/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/valid/run.json @@ -0,0 +1,29 @@ +{ + "schema_version": "associated-artifact-manifest/v1", + "manifest_id": "run-attachments", + "manifest_version": "1.0.0", + "canonicalization_profile": "associated-artifact-set/v1", + "scope": "experiment", + "parent_ref": { + "ref_kind": "run", + "ref_id": "run-001", + "ref_version": "1.0.0" + }, + "artifacts": { + "operator-guide": { + "artifact_id": "operator-guide", + "role": "operator-guide", + "media_type": "text/markdown", + "uri": "urn:sha256:ddc7a69ede7530aa87098a487dbae1d82e751dce5cd5bbe8477355ecd12f4287", + "checksum": { + "algorithm": "sha256", + "value": "ddc7a69ede7530aa87098a487dbae1d82e751dce5cd5bbe8477355ecd12f4287" + }, + "size_bytes": 15, + "created_at": "2026-07-12T00:00:00Z", + "source": "scenario-author", + "sensitivity": "internal" + } + }, + "set_digest": "sha256:d7ed45878918be25d6b2bfdb64116651b598f3b2dabb8d55eaef17285919a90e" +} diff --git a/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/valid/scenario-snapshot.json b/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/valid/scenario-snapshot.json new file mode 100644 index 000000000..282e8593d --- /dev/null +++ b/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/valid/scenario-snapshot.json @@ -0,0 +1,30 @@ +{ + "schema_version": "associated-artifact-manifest/v1", + "manifest_id": "scenario-snapshot-attachments", + "manifest_version": "1.0.0", + "canonicalization_profile": "associated-artifact-set/v1", + "scope": "scenario", + "parent_ref": { + "ref_kind": "scenario-snapshot", + "ref_id": "training-range", + "ref_version": "1.0.0", + "ref_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111" + }, + "artifacts": { + "operator-guide": { + "artifact_id": "operator-guide", + "role": "operator-guide", + "media_type": "text/markdown", + "uri": "urn:sha256:ddc7a69ede7530aa87098a487dbae1d82e751dce5cd5bbe8477355ecd12f4287", + "checksum": { + "algorithm": "sha256", + "value": "ddc7a69ede7530aa87098a487dbae1d82e751dce5cd5bbe8477355ecd12f4287" + }, + "size_bytes": 15, + "created_at": "2026-07-12T00:00:00Z", + "source": "scenario-author", + "sensitivity": "internal" + } + }, + "set_digest": "sha256:cd34c3d08b2ed0fae387f1a8ea4d392df73b7878ecde6731a7f0ece63af56f02" +} diff --git a/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/valid/scenario.json b/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/valid/scenario.json new file mode 100644 index 000000000..65ede38e3 --- /dev/null +++ b/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/valid/scenario.json @@ -0,0 +1,28 @@ +{ + "schema_version": "associated-artifact-manifest/v1", + "manifest_id": "scenario-attachments", + "manifest_version": "1.0.0", + "canonicalization_profile": "associated-artifact-set/v1", + "scope": "scenario", + "parent_ref": { + "ref_kind": "scenario", + "ref_id": "training-range" + }, + "artifacts": { + "operator-guide": { + "artifact_id": "operator-guide", + "role": "operator-guide", + "media_type": "text/markdown", + "uri": "urn:sha256:ddc7a69ede7530aa87098a487dbae1d82e751dce5cd5bbe8477355ecd12f4287", + "checksum": { + "algorithm": "sha256", + "value": "ddc7a69ede7530aa87098a487dbae1d82e751dce5cd5bbe8477355ecd12f4287" + }, + "size_bytes": 15, + "created_at": "2026-07-12T00:00:00Z", + "source": "scenario-author", + "sensitivity": "internal" + } + }, + "set_digest": "sha256:3494a74b2e1475df6141039f75f0115cae6f13f7fc562c6b24e7343e4b32a082" +} diff --git a/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/valid/study.json b/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/valid/study.json new file mode 100644 index 000000000..52d38ee4a --- /dev/null +++ b/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/valid/study.json @@ -0,0 +1,29 @@ +{ + "schema_version": "associated-artifact-manifest/v1", + "manifest_id": "study-attachments", + "manifest_version": "1.0.0", + "canonicalization_profile": "associated-artifact-set/v1", + "scope": "experiment", + "parent_ref": { + "ref_kind": "study", + "ref_id": "study-001", + "ref_version": "1.0.0" + }, + "artifacts": { + "operator-guide": { + "artifact_id": "operator-guide", + "role": "operator-guide", + "media_type": "text/markdown", + "uri": "urn:sha256:ddc7a69ede7530aa87098a487dbae1d82e751dce5cd5bbe8477355ecd12f4287", + "checksum": { + "algorithm": "sha256", + "value": "ddc7a69ede7530aa87098a487dbae1d82e751dce5cd5bbe8477355ecd12f4287" + }, + "size_bytes": 15, + "created_at": "2026-07-12T00:00:00Z", + "source": "scenario-author", + "sensitivity": "internal" + } + }, + "set_digest": "sha256:dc45958c6e26e9441900e2c8d15309e6f42681a4db6eea2677d471f3232b854d" +} diff --git a/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/valid/task.json b/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/valid/task.json new file mode 100644 index 000000000..73c1413de --- /dev/null +++ b/contracts/fixtures/associated-artifacts/associated-artifact-manifest-v1/valid/task.json @@ -0,0 +1,29 @@ +{ + "schema_version": "associated-artifact-manifest/v1", + "manifest_id": "task-attachments", + "manifest_version": "1.0.0", + "canonicalization_profile": "associated-artifact-set/v1", + "scope": "experiment", + "parent_ref": { + "ref_kind": "task", + "ref_id": "task-001", + "ref_version": "1.0.0" + }, + "artifacts": { + "operator-guide": { + "artifact_id": "operator-guide", + "role": "operator-guide", + "media_type": "text/markdown", + "uri": "urn:sha256:ddc7a69ede7530aa87098a487dbae1d82e751dce5cd5bbe8477355ecd12f4287", + "checksum": { + "algorithm": "sha256", + "value": "ddc7a69ede7530aa87098a487dbae1d82e751dce5cd5bbe8477355ecd12f4287" + }, + "size_bytes": 15, + "created_at": "2026-07-12T00:00:00Z", + "source": "scenario-author", + "sensitivity": "internal" + } + }, + "set_digest": "sha256:81829cbcad5384c1a69b607522f62f3b2e111fc05922e03a2e8b70793d851228" +} diff --git a/contracts/schema-publication-manifest.json b/contracts/schema-publication-manifest.json index 3a53b1cd2..0a2748ef6 100644 --- a/contracts/schema-publication-manifest.json +++ b/contracts/schema-publication-manifest.json @@ -8,6 +8,16 @@ "stability": "draft", "content_hash": "e7b858c93b7ec763c361439b1d9c7cc3979a1d150ca64a7f41ebc12c050f5cff" }, + { + "contract_id": "associated-artifact-manifest-v1", + "schema_path": "contracts/schemas/associated-artifacts/associated-artifact-manifest-v1.json", + "stability": "draft", + "content_hash": "50cb495731d7274371dd01116acfe67dda3dd3261a1a911136cae02dab7f7927", + "last_change": { + "summary": "Initial publication of the ADR-077 scenario- and experiment-associated artifact manifest, canonical set identity, and required semantic byte-binding invariant.", + "content_hash": "50cb495731d7274371dd01116acfe67dda3dd3261a1a911136cae02dab7f7927" + } + }, { "contract_id": "atlas-tactics-source-v1", "schema_path": "contracts/schemas/concept-authority/atlas-tactics-source-v1.json", @@ -90,80 +100,80 @@ "contract_id": "experiment-apparatus-context-v1", "schema_path": "contracts/schemas/experiment-core/experiment-apparatus-context-v1.json", "stability": "draft", - "content_hash": "565558814655c9fc3cd790fb441633622e4846d416ffaba43812143024e33ae3", + "content_hash": "a2c8fcfc2fec00a20ee9e46af3160c88b9e7aeb8b2315afbaa3781b3e25c0f93", "last_change": { - "summary": "Extended experiment-core references for the EXP-707/EXP-708/EXP-709 evidence and measure contract boundary.", - "content_hash": "565558814655c9fc3cd790fb441633622e4846d416ffaba43812143024e33ae3" + "summary": "Extended shared experiment artifact roles and typed references for associated-artifact parents (ADR-077, issue #738).", + "content_hash": "a2c8fcfc2fec00a20ee9e46af3160c88b9e7aeb8b2315afbaa3781b3e25c0f93" } }, { "contract_id": "experiment-authoring-input-v1", "schema_path": "contracts/schemas/experiment-core/experiment-authoring-input-v1.json", "stability": "draft", - "content_hash": "0373103adfa21acc45fb9db525f73603f79173660b372ea6b91de119771ea616", + "content_hash": "2f399830a126d155bbb3940ad1f0bbb2c41fe540ea261d12e37d74400393d976", "last_change": { - "summary": "Published the experiment authoring-input contract: a pre-run experiment design surface that references the archival experiment-core outputs (ADR-074, issue #675).", - "content_hash": "0373103adfa21acc45fb9db525f73603f79173660b372ea6b91de119771ea616" + "summary": "Extended shared experiment artifact roles and typed references for associated-artifact parents (ADR-077, issue #738).", + "content_hash": "2f399830a126d155bbb3940ad1f0bbb2c41fe540ea261d12e37d74400393d976" } }, { "contract_id": "experiment-capture-spec-v1", "schema_path": "contracts/schemas/experiment-core/experiment-capture-spec-v1.json", "stability": "draft", - "content_hash": "1c479291aed4c60aa7a28d93839d3633f8dee585ca3098b83bd4fbb03bea86ad", + "content_hash": "b262408eda4309d265a292ffd2fbcb1e34ba58c28205e4d99996f1b854a70876", "last_change": { - "summary": "Published the SEM-224 observability/evidence plane (authored_evidence_requirement) as a portable x-aces-plane annotation sourced from the carrier-oriented plane classifier.", - "content_hash": "1c479291aed4c60aa7a28d93839d3633f8dee585ca3098b83bd4fbb03bea86ad" + "summary": "Extended shared experiment artifact roles and typed references for associated-artifact parents (ADR-077, issue #738).", + "content_hash": "b262408eda4309d265a292ffd2fbcb1e34ba58c28205e4d99996f1b854a70876" } }, { "contract_id": "experiment-derived-measure-v1", "schema_path": "contracts/schemas/experiment-core/experiment-derived-measure-v1.json", "stability": "draft", - "content_hash": "162aeeb41906008b03ef211b20646f451101c59cdcabc1614427ca3fa7945afe", + "content_hash": "3619f07b4e075a47a99e026aed5395a6a9c06b663a81e62f67bba7fd196ccf34", "last_change": { - "summary": "Published the SEM-224 observability/evidence plane (derived_analysis) as a portable x-aces-plane annotation sourced from the carrier-oriented plane classifier.", - "content_hash": "162aeeb41906008b03ef211b20646f451101c59cdcabc1614427ca3fa7945afe" + "summary": "Extended shared experiment artifact roles and typed references for associated-artifact parents (ADR-077, issue #738).", + "content_hash": "3619f07b4e075a47a99e026aed5395a6a9c06b663a81e62f67bba7fd196ccf34" } }, { "contract_id": "experiment-evidence-record-v1", "schema_path": "contracts/schemas/experiment-core/experiment-evidence-record-v1.json", "stability": "draft", - "content_hash": "3483ac4ff2cb61278d64253257f70dc73d14eaff3bc5501eee20a86a55d7d707", + "content_hash": "b6b14039002d04329d760a30b26cb191d795de3b49e377edd084be4d10f9c32b", "last_change": { - "summary": "Published the SEM-224 observability/evidence plane (captured_evidence) as a portable x-aces-plane annotation sourced from the carrier-oriented plane classifier.", - "content_hash": "3483ac4ff2cb61278d64253257f70dc73d14eaff3bc5501eee20a86a55d7d707" + "summary": "Extended shared experiment artifact roles and typed references for associated-artifact parents (ADR-077, issue #738).", + "content_hash": "b6b14039002d04329d760a30b26cb191d795de3b49e377edd084be4d10f9c32b" } }, { "contract_id": "experiment-run-v1", "schema_path": "contracts/schemas/experiment-core/experiment-run-v1.json", "stability": "draft", - "content_hash": "e1e7ca5e74439140cd27340a7754d8b871266e518ce93c98bf6ff0dbaa8bd134", + "content_hash": "1503644443be7e61d5710a6aed9437a66c53a7871eb0ff1541fb2449b684742b", "last_change": { - "summary": "Added SEM-225 run-level augmentation disclosures for processor/backend augmentation classification, visibility, observer-effect, comparability, and evidence provenance.", - "content_hash": "e1e7ca5e74439140cd27340a7754d8b871266e518ce93c98bf6ff0dbaa8bd134" + "summary": "Extended shared experiment artifact roles and typed references for associated-artifact parents (ADR-077, issue #738).", + "content_hash": "1503644443be7e61d5710a6aed9437a66c53a7871eb0ff1541fb2449b684742b" } }, { "contract_id": "experiment-study-v1", "schema_path": "contracts/schemas/experiment-core/experiment-study-v1.json", "stability": "draft", - "content_hash": "b769512ccf43a3cae303d363da84cf1d4bbd97099d7f9085f065bbe4d801b4d3", + "content_hash": "d180220068358f42d6ae850eccb9991dc9284db4b244ef70960837ea68d9b098", "last_change": { - "summary": "Extended experiment-core references for the EXP-707/EXP-708/EXP-709 evidence and measure contract boundary.", - "content_hash": "b769512ccf43a3cae303d363da84cf1d4bbd97099d7f9085f065bbe4d801b4d3" + "summary": "Extended shared experiment artifact roles and typed references for associated-artifact parents (ADR-077, issue #738).", + "content_hash": "d180220068358f42d6ae850eccb9991dc9284db4b244ef70960837ea68d9b098" } }, { "contract_id": "experiment-task-v1", "schema_path": "contracts/schemas/experiment-core/experiment-task-v1.json", "stability": "draft", - "content_hash": "45fb2eb011d89b9be057d236b9d4e58c2ce01f8c1365f7d521afc8192d68a73a", + "content_hash": "b82f084841720563089206f9a8dc94b93fde10035da9b3a64662b7660b30d823", "last_change": { - "summary": "Extended experiment-core references for the EXP-707/EXP-708/EXP-709 evidence and measure contract boundary.", - "content_hash": "45fb2eb011d89b9be057d236b9d4e58c2ce01f8c1365f7d521afc8192d68a73a" + "summary": "Extended shared experiment artifact roles and typed references for associated-artifact parents (ADR-077, issue #738).", + "content_hash": "b82f084841720563089206f9a8dc94b93fde10035da9b3a64662b7660b30d823" } }, { @@ -362,10 +372,10 @@ "contract_id": "reusable-asset-trust-policy-v1", "schema_path": "contracts/schemas/asset-trust/reusable-asset-trust-policy-v1.json", "stability": "draft", - "content_hash": "68f18e107bddb0b037a316bd334258da7329fc60460ee38ef415c89ce58ad7e0", + "content_hash": "de371c60018cc27664915404443df13dd881a57264a1539028409a14af1823b5", "last_change": { - "summary": "Initial publication of the GOV-913 reusable-asset trust/authenticity/integrity policy contract: per-family evidence-class requirements (integrity/authenticity/provenance/governance) referencing existing ACES mechanisms.", - "content_hash": "68f18e107bddb0b037a316bd334258da7329fc60460ee38ef415c89ce58ad7e0" + "summary": "Added the associated_artifact_set family with required set-integrity and concrete payload-checksum evidence (ADR-077, issue #738).", + "content_hash": "de371c60018cc27664915404443df13dd881a57264a1539028409a14af1823b5" } }, { diff --git a/contracts/schemas/asset-trust/reusable-asset-trust-policy-v1.json b/contracts/schemas/asset-trust/reusable-asset-trust-policy-v1.json index efffe7da7..611448ffd 100644 --- a/contracts/schemas/asset-trust/reusable-asset-trust-policy-v1.json +++ b/contracts/schemas/asset-trust/reusable-asset-trust-policy-v1.json @@ -267,6 +267,7 @@ "asset_family": { "enum": [ "reusable_scenario", + "associated_artifact_set", "sdl_module", "experiment_task", "experiment_study", @@ -331,6 +332,19 @@ "type": "object" } }, + { + "contains": { + "properties": { + "asset_family": { + "const": "associated_artifact_set" + } + }, + "required": [ + "asset_family" + ], + "type": "object" + } + }, { "contains": { "properties": { @@ -413,8 +427,8 @@ "items": { "$ref": "#/$defs/ReusableAssetFamilyTrustPolicyModel" }, - "maxItems": 7, - "minItems": 7, + "maxItems": 8, + "minItems": 8, "title": "Families", "type": "array" }, diff --git a/contracts/schemas/associated-artifacts/associated-artifact-manifest-v1.json b/contracts/schemas/associated-artifacts/associated-artifact-manifest-v1.json new file mode 100644 index 000000000..cf42abdd8 --- /dev/null +++ b/contracts/schemas/associated-artifacts/associated-artifact-manifest-v1.json @@ -0,0 +1,475 @@ +{ + "$defs": { + "AssociatedArtifactParentReferenceModel": { + "additionalProperties": false, + "description": "Reference constrained to a supported associated-artifact parent.", + "properties": { + "ref_digest": { + "anyOf": [ + { + "minLength": 1, + "pattern": "^(?:sha256:[A-Fa-f0-9]{64}|sha384:[A-Fa-f0-9]{96}|sha512:[A-Fa-f0-9]{128}|blake3:[A-Fa-f0-9]{64})$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ref Digest" + }, + "ref_id": { + "minLength": 1, + "title": "Ref Id", + "type": "string" + }, + "ref_kind": { + "enum": [ + "scenario", + "scenario-snapshot", + "task", + "authoring-input", + "apparatus-context", + "run", + "study" + ], + "title": "Ref Kind", + "type": "string" + }, + "ref_path": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ref Path" + }, + "ref_version": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ref Version" + } + }, + "required": [ + "ref_kind", + "ref_id" + ], + "title": "AssociatedArtifactParentReferenceModel", + "type": "object" + }, + "ExperimentArtifactRefModel": { + "additionalProperties": false, + "description": "Reference to an artifact that supports a task, run, apparatus, or study.", + "properties": { + "artifact_id": { + "minLength": 1, + "title": "Artifact Id", + "type": "string" + }, + "checksum": { + "$ref": "#/$defs/ExperimentChecksumModel" + }, + "created_at": { + "format": "date-time", + "minLength": 1, + "pattern": "^\\d{4}-\\d{2}-\\d{2}[Tt](?:[01]\\d|2[0-3]):[0-5]\\d:(?:[0-5]\\d|60)(?:\\.\\d+)?(?:[Zz]|[+-](?:[01]\\d|2[0-3]):[0-5]\\d)$", + "title": "Created At", + "type": "string" + }, + "description": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Description" + }, + "media_type": { + "minLength": 1, + "title": "Media Type", + "type": "string" + }, + "role": { + "enum": [ + "protocol", + "metric-definition", + "scenario-snapshot", + "manifest", + "apparatus-evidence", + "observation", + "result", + "analysis", + "report", + "export", + "starter-file", + "evaluator", + "subtask", + "gold-step", + "milestone", + "human-assistance", + "scaffold", + "baseline", + "cost-resource-trace", + "documentation", + "operator-guide", + "configuration", + "profile", + "dataset", + "other" + ], + "title": "Role", + "type": "string" + }, + "satisfies_refs": { + "items": { + "$ref": "#/$defs/ExperimentEvidenceSatisfactionReferenceModel" + }, + "title": "Satisfies Refs", + "type": "array" + }, + "sensitivity": { + "enum": [ + "public", + "internal", + "restricted", + "redacted" + ], + "title": "Sensitivity", + "type": "string" + }, + "size_bytes": { + "minimum": 0, + "title": "Size Bytes", + "type": "integer" + }, + "source": { + "minLength": 1, + "title": "Source", + "type": "string" + }, + "uri": { + "minLength": 1, + "title": "Uri", + "type": "string" + } + }, + "required": [ + "artifact_id", + "role", + "media_type", + "uri", + "checksum", + "size_bytes", + "created_at", + "source", + "sensitivity" + ], + "title": "ExperimentArtifactRefModel", + "type": "object" + }, + "ExperimentChecksumModel": { + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "algorithm": { + "const": "sha256" + } + }, + "required": [ + "algorithm" + ] + }, + "then": { + "properties": { + "value": { + "pattern": "^[A-Fa-f0-9]{64}$" + } + } + } + }, + { + "if": { + "properties": { + "algorithm": { + "const": "sha384" + } + }, + "required": [ + "algorithm" + ] + }, + "then": { + "properties": { + "value": { + "pattern": "^[A-Fa-f0-9]{96}$" + } + } + } + }, + { + "if": { + "properties": { + "algorithm": { + "const": "sha512" + } + }, + "required": [ + "algorithm" + ] + }, + "then": { + "properties": { + "value": { + "pattern": "^[A-Fa-f0-9]{128}$" + } + } + } + }, + { + "if": { + "properties": { + "algorithm": { + "const": "blake3" + } + }, + "required": [ + "algorithm" + ] + }, + "then": { + "properties": { + "value": { + "pattern": "^[A-Fa-f0-9]{64}$" + } + } + } + } + ], + "description": "Checksum metadata for an experiment-core artifact reference.", + "properties": { + "algorithm": { + "enum": [ + "sha256", + "sha384", + "sha512", + "blake3" + ], + "title": "Algorithm", + "type": "string" + }, + "value": { + "minLength": 1, + "pattern": "^[A-Fa-f0-9]+$", + "title": "Value", + "type": "string" + } + }, + "required": [ + "algorithm", + "value" + ], + "title": "ExperimentChecksumModel", + "type": "object" + }, + "ExperimentEvidenceSatisfactionReferenceModel": { + "additionalProperties": false, + "description": "Evidence concept reference that an artifact claims to satisfy.", + "properties": { + "ref_id": { + "minLength": 1, + "title": "Ref Id", + "type": "string" + }, + "ref_kind": { + "const": "evidence", + "title": "Ref Kind", + "type": "string" + }, + "ref_version": { + "anyOf": [ + { + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ref Version" + } + }, + "required": [ + "ref_kind", + "ref_id" + ], + "title": "ExperimentEvidenceSatisfactionReferenceModel", + "type": "object" + } + }, + "$id": "https://aces.dev/schemas/associated-artifact-manifest-v1.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "scope": { + "const": "scenario" + } + }, + "required": [ + "scope" + ] + }, + "then": { + "properties": { + "parent_ref": { + "properties": { + "ref_kind": { + "enum": [ + "scenario", + "scenario-snapshot" + ] + } + } + } + } + } + }, + { + "if": { + "properties": { + "scope": { + "const": "experiment" + } + }, + "required": [ + "scope" + ] + }, + "then": { + "properties": { + "parent_ref": { + "properties": { + "ref_kind": { + "enum": [ + "task", + "authoring-input", + "apparatus-context", + "run", + "study" + ] + } + } + } + } + } + } + ], + "description": "One exact non-semantic artifact-reference set attached to one parent.", + "properties": { + "artifacts": { + "additionalProperties": { + "$ref": "#/$defs/ExperimentArtifactRefModel" + }, + "minProperties": 1, + "propertyNames": { + "minLength": 1 + }, + "title": "Artifacts", + "type": "object" + }, + "canonicalization_profile": { + "const": "associated-artifact-set/v1", + "title": "Canonicalization Profile", + "type": "string" + }, + "manifest_id": { + "minLength": 1, + "title": "Manifest Id", + "type": "string" + }, + "manifest_version": { + "minLength": 1, + "title": "Manifest Version", + "type": "string" + }, + "parent_ref": { + "$ref": "#/$defs/AssociatedArtifactParentReferenceModel" + }, + "schema_version": { + "const": "associated-artifact-manifest/v1", + "title": "Schema Version", + "type": "string" + }, + "scope": { + "enum": [ + "scenario", + "experiment" + ], + "title": "Scope", + "type": "string" + }, + "set_digest": { + "pattern": "^sha256:[a-f0-9]{64}$", + "title": "Set Digest", + "type": "string" + } + }, + "required": [ + "schema_version", + "manifest_id", + "manifest_version", + "canonicalization_profile", + "scope", + "parent_ref", + "artifacts", + "set_digest" + ], + "title": "AssociatedArtifactManifestModel", + "type": "object", + "x-aces-invariants": [ + { + "description": "Full conformance requires matching the concrete parent, recomputing the canonical set digest, and binding every checksum and size to an explicitly supplied bounded byte stream.", + "id": "associated-artifact-parent-set-and-byte-binding", + "inputs": [ + { + "contract_id": "associated-artifact-manifest-v1", + "instance_path": "#" + } + ], + "level": "error", + "validator": "aces_contracts.associated_artifacts.validate_associated_artifact_manifest" + } + ], + "x-aces-semantic-profile": { + "contract_id": "associated-artifact-manifest-v1", + "entry_schema_contract_id": "aces-semantic-invariants-v1", + "entry_schema_pointer": "#/$defs/AcesSemanticInvariantEntryModel", + "id": "aces-semantic-invariants-v1", + "keyword": "x-aces-invariants", + "required": true, + "uri": "https://aces.dev/schemas/semantic-invariants/v1" + } +} diff --git a/contracts/schemas/experiment-core/experiment-apparatus-context-v1.json b/contracts/schemas/experiment-core/experiment-apparatus-context-v1.json index 01dee6072..a89e2f323 100644 --- a/contracts/schemas/experiment-core/experiment-apparatus-context-v1.json +++ b/contracts/schemas/experiment-core/experiment-apparatus-context-v1.json @@ -179,6 +179,11 @@ "scaffold", "baseline", "cost-resource-trace", + "documentation", + "operator-guide", + "configuration", + "profile", + "dataset", "other" ], "title": "Role", @@ -790,6 +795,7 @@ "scenario", "scenario-snapshot", "task", + "authoring-input", "protocol", "apparatus-context", "run", diff --git a/contracts/schemas/experiment-core/experiment-authoring-input-v1.json b/contracts/schemas/experiment-core/experiment-authoring-input-v1.json index 19a3f0e25..ed49baec9 100644 --- a/contracts/schemas/experiment-core/experiment-authoring-input-v1.json +++ b/contracts/schemas/experiment-core/experiment-authoring-input-v1.json @@ -169,6 +169,11 @@ "scaffold", "baseline", "cost-resource-trace", + "documentation", + "operator-guide", + "configuration", + "profile", + "dataset", "other" ], "title": "Role", @@ -1196,6 +1201,7 @@ "scenario", "scenario-snapshot", "task", + "authoring-input", "protocol", "apparatus-context", "run", diff --git a/contracts/schemas/experiment-core/experiment-capture-spec-v1.json b/contracts/schemas/experiment-core/experiment-capture-spec-v1.json index 75a330107..f0257288f 100644 --- a/contracts/schemas/experiment-core/experiment-capture-spec-v1.json +++ b/contracts/schemas/experiment-core/experiment-capture-spec-v1.json @@ -58,6 +58,11 @@ "scaffold", "baseline", "cost-resource-trace", + "documentation", + "operator-guide", + "configuration", + "profile", + "dataset", "other" ], "title": "Role", @@ -598,6 +603,7 @@ "scenario", "scenario-snapshot", "task", + "authoring-input", "protocol", "apparatus-context", "run", diff --git a/contracts/schemas/experiment-core/experiment-derived-measure-v1.json b/contracts/schemas/experiment-core/experiment-derived-measure-v1.json index 85711af88..fdf58f0e9 100644 --- a/contracts/schemas/experiment-core/experiment-derived-measure-v1.json +++ b/contracts/schemas/experiment-core/experiment-derived-measure-v1.json @@ -197,6 +197,7 @@ "scenario", "scenario-snapshot", "task", + "authoring-input", "protocol", "apparatus-context", "run", diff --git a/contracts/schemas/experiment-core/experiment-evidence-record-v1.json b/contracts/schemas/experiment-core/experiment-evidence-record-v1.json index c252e3213..189f56ac1 100644 --- a/contracts/schemas/experiment-core/experiment-evidence-record-v1.json +++ b/contracts/schemas/experiment-core/experiment-evidence-record-v1.json @@ -58,6 +58,11 @@ "scaffold", "baseline", "cost-resource-trace", + "documentation", + "operator-guide", + "configuration", + "profile", + "dataset", "other" ], "title": "Role", @@ -452,6 +457,7 @@ "scenario", "scenario-snapshot", "task", + "authoring-input", "protocol", "apparatus-context", "run", diff --git a/contracts/schemas/experiment-core/experiment-run-v1.json b/contracts/schemas/experiment-core/experiment-run-v1.json index fcdae6567..138dbacc2 100644 --- a/contracts/schemas/experiment-core/experiment-run-v1.json +++ b/contracts/schemas/experiment-core/experiment-run-v1.json @@ -377,6 +377,11 @@ "scaffold", "baseline", "cost-resource-trace", + "documentation", + "operator-guide", + "configuration", + "profile", + "dataset", "other" ], "title": "Role", @@ -1632,6 +1637,7 @@ "scenario", "scenario-snapshot", "task", + "authoring-input", "protocol", "apparatus-context", "run", diff --git a/contracts/schemas/experiment-core/experiment-study-v1.json b/contracts/schemas/experiment-core/experiment-study-v1.json index 7d9a49e54..e8cbc31bc 100644 --- a/contracts/schemas/experiment-core/experiment-study-v1.json +++ b/contracts/schemas/experiment-core/experiment-study-v1.json @@ -126,6 +126,11 @@ "scaffold", "baseline", "cost-resource-trace", + "documentation", + "operator-guide", + "configuration", + "profile", + "dataset", "other" ], "title": "Role", @@ -654,6 +659,7 @@ "scenario", "scenario-snapshot", "task", + "authoring-input", "protocol", "apparatus-context", "run", diff --git a/contracts/schemas/experiment-core/experiment-task-v1.json b/contracts/schemas/experiment-core/experiment-task-v1.json index e92cbfac6..cd38853b1 100644 --- a/contracts/schemas/experiment-core/experiment-task-v1.json +++ b/contracts/schemas/experiment-core/experiment-task-v1.json @@ -169,6 +169,11 @@ "scaffold", "baseline", "cost-resource-trace", + "documentation", + "operator-guide", + "configuration", + "profile", + "dataset", "other" ], "title": "Role", @@ -955,6 +960,7 @@ "scenario", "scenario-snapshot", "task", + "authoring-input", "protocol", "apparatus-context", "run", diff --git a/docs/decisions/adrs/README.md b/docs/decisions/adrs/README.md index d4d4f55c8..1ab3eba07 100644 --- a/docs/decisions/adrs/README.md +++ b/docs/decisions/adrs/README.md @@ -202,3 +202,4 @@ adr-076-portable-sdl-identifiers-and-canonical-addresses | [074](adr-074-experiment-authoring-input-contract-boundary.md) | Experiment Authoring-Input Contract Boundary | accepted | 2026-07-08 | | [075](adr-075-ecosystem-versioning-deprecation-and-migration-governance.md) | Ecosystem Versioning, Deprecation, and Migration Governance | proposed | 2026-07-11 | | [076](adr-076-portable-sdl-identifiers-and-canonical-addresses.md) | Portable SDL Identifiers and Canonical Addresses | accepted | 2026-07-11 | +| [077](adr-077-associated-artifact-manifest-boundary.md) | Associated Artifact Manifest Boundary | accepted | 2026-07-12 | diff --git a/docs/decisions/adrs/adr-077-associated-artifact-manifest-boundary.md b/docs/decisions/adrs/adr-077-associated-artifact-manifest-boundary.md new file mode 100644 index 000000000..f7cc2ff9c --- /dev/null +++ b/docs/decisions/adrs/adr-077-associated-artifact-manifest-boundary.md @@ -0,0 +1,107 @@ +# ADR-077: Associated Artifact Manifest Boundary + +## Status + +accepted + +## Date + +2026-07-12 + +## Classification + +Classification: FM1 +Required artifacts: ADR, normative spec, schema, fixtures, contract tests +Waivers: none + +## Context + +ACES gives validated, expanded SDL a canonical semantic digest and gives +experiment artifacts checksum-bearing references. Neither surface defines one +portable set of non-semantic artifacts attached to a scenario, sealed scenario +snapshot, or experiment artifact. Downstream packaging and ingestion tools +therefore cannot distinguish a verified companion set from a caller-asserted +package digest without inventing an asset model outside ACES. + +The missing model must preserve existing boundaries. Documentation, operator +material, evaluator assets, reports, profiles, and similar bytes are not SDL +meaning. A live directory is not an atomic snapshot. An artifact checksum is +not a manifest identity, and neither a checksum nor a manifest digest proves +authenticity. Attachment at one scope must not silently imply attachment at +another scope. + +## Decision + +Publish `associated-artifact-manifest-v1` as a closed, standalone contract. One +manifest attaches one exact keyed artifact-reference set to one explicit +parent. Its scope is either: + +- `scenario`, with a `scenario` or `scenario-snapshot` parent; or +- `experiment`, with a task, authoring input, apparatus context, run, or study + parent. + +The contract reuses the experiment-core typed-reference, checksum, and artifact +descriptor shapes. It adds the missing `authoring-input` reference kind and +shared companion roles without creating a scenario-only descriptor or a +universal `TrustedAsset` payload. Generic scenario parents remain id-only; +scenario snapshots may carry the existing version and semantic-digest binding. +Experiment parents do not gain self-asserted payload digests. + +The logical manifest id/version is separate from the derived +`associated-artifact-set/v1` identity. Set identity is lowercase SHA-256 over +RFC 8785 canonical bytes containing the profile id, scope, exact parent +reference, and exact keyed artifact-reference set. It excludes the set digest +itself and all filesystem, archive, OCI, filename, materialization, and export +layout metadata. Changing the parent or any artifact reference changes set +identity; changing only a downstream layout does not. + +Full conformance requires the cross-artifact validator to match the concrete +parent, derive the set digest, and recompute every artifact checksum and size +from exactly one explicitly supplied, bounded byte reader. The validator does +not fetch locators, walk directories, unpack archives, follow symlinks, or read +ambient configuration. A digest, path, URI, or prior validation flag is not a +byte binding. + +Add `associated_artifact_set` as a distinct reusable-asset family under +ADR-071. Its set digest supplies `integrity_digest`; every payload supplies +`artifact_checksum`. The parent retains its existing identity and integrity +mechanism. Authenticity remains an independent signature/trust-policy decision. + +Attachments never inherit between scenario, snapshot, task, authoring input, +apparatus context, run, and study. Attaching the same bytes to another parent +requires another manifest and therefore another set digest. + +## Consequences + +### Positive + +- Producers and consumers share one portable parent-plus-artifact-set contract + without importing scenario-pack filesystem layout into ACES. +- Caller-asserted package digests cannot establish conformance or trust. +- Scenario meaning, parent identity, set identity, raw checksums, and + authenticity remain independently testable claims. +- The typed parent matcher, versioned canonicalization profile, shared role + vocabulary, and injected byte-reader seam admit future families and transports + without changing SDL semantics. + +### Negative + +- Full validation requires callers to stage and supply every referenced + payload, even in offline workflows. +- Adding parent families or canonicalization revisions requires a contract + revision and focused compatibility evidence. + +### Risks + +- Consumers may validate schema shape but skip the required semantic byte gate; + the published `x-aces-invariants` annotation and conformance diagnostics make + that limitation explicit. +- Mutable locators can drift; immutable staging and use-time verification remain + consumer responsibilities. + +## Non-goals + +This decision does not define scenario-pack directories, manifest filenames, +archive or OCI layout, release tiers, catalog metadata, acquisition, storage, +entitlement, launchability, a registry, new cryptography, or an API/persistence +service. It does not change SDL syntax or `canonical_sdl_digest()`. diff --git a/docs/decisions/adrs/adr-index.yaml b/docs/decisions/adrs/adr-index.yaml index 8cc59668c..e7c8a19bd 100644 --- a/docs/decisions/adrs/adr-index.yaml +++ b/docs/decisions/adrs/adr-index.yaml @@ -311,3 +311,6 @@ adrs: - id: ADR-076 path: docs/decisions/adrs/adr-076-portable-sdl-identifiers-and-canonical-addresses.md pin: cd28329002c1befb1920f0037c07517adbe3811d15e4f559455d05b893c69203 + - id: ADR-077 + path: docs/decisions/adrs/adr-077-associated-artifact-manifest-boundary.md + pin: bbbcb25d36c0b81b4b19198321ae2578e433bc238465397367f5e7dcf3407138 diff --git a/docs/decisions/issue-738-associated-artifact-manifests-preflight.md b/docs/decisions/issue-738-associated-artifact-manifests-preflight.md new file mode 100644 index 000000000..b0f1009ae --- /dev/null +++ b/docs/decisions/issue-738-associated-artifact-manifests-preflight.md @@ -0,0 +1,442 @@ +# Issue 738 Associated Artifact Manifests Preflight + +Date: 2026-07-12 + +Issue: #738. + +Requirement: none. The issue title, body, and acceptance criteria are the +authoritative contract. + +This note records architecture guardrails for scenario- and +experiment-associated non-semantic artifact manifests. It is implementation +guidance only. It does not add normative specification text, schemas, contract +models, validators, fixtures, trust-policy entries, packaging behavior, APIs, +storage, or runtime behavior. + +## Binding Sources + +- ADR-009, ADR-019, `specs/authority/authority-boundary.yaml`, and + `contracts/README.md` keep normative prose under `specs/`, normative schemas + and fixtures under `contracts/`, and Python under the non-normative reference + implementation boundary. +- ADR-055 and `specs/formal/experiment-core/README.md` own experiment tasks, + authoring inputs, apparatus context, runs, studies, typed references, + `ExperimentArtifactRefModel`, `ExperimentChecksumModel`, and cross-artifact + validators. Identifier-bearing collections use keyed maps when uniqueness is + part of the portable contract. +- ADR-064 through ADR-066 own captured evidence, derived analysis, provenance, + sensitivity/redaction, and the rule that archival evidence is not scenario + meaning or live runtime state. +- ADR-071 and `specs/supply-chain/reusable-asset-trust-integrity.md` keep + identity, integrity, and authenticity distinct; treat reusable asset as a + family-specific role; and forbid a universal `TrustedAsset` payload. +- ADR-053 owns module registry, lockfile, OCI, signature, bounded-read, and + archive-extraction behavior. A scenario companion manifest must not become a + second module package, registry, lockfile, or archive format. +- The canonical SDL profile in `aces_sdl.canonical` identifies validated, + expanded SDL meaning only. Its `aces-sdl-semantic/v1` bytes and digest must + not absorb documentation, starter material, evaluator assets, reports, + profiles, operator material, or other companion bytes. +- ADR-059 governs changes to accepted ADRs. Any implementation amendment to + ADR-055 or ADR-071 needs an amendment row and pin update, or a superseding + ADR; accepted text must not be edited silently. +- ADR-061, ADR-075, `contracts/schema-publication-manifest.json`, and + `specs/evolution/versioning-deprecation-and-migration.md` govern contract + lineage, compatibility, publication records, and migration. + +## Architecture Decisions + +### One standalone attachment contract, not a package model + +Publish one closed, versioned associated-artifact-manifest contract. It is a +portable statement that one exact artifact-reference set is attached to one +explicit parent. It is not an SDL section, experiment record subtype, archive +index, live-directory inventory, trust decision, acquisition request, or +universal reusable-asset payload. + +The contract has a closed scope discriminator and a constrained parent +reference: + +- scenario scope permits `scenario` and `scenario-snapshot` parents; +- experiment scope permits the existing experiment artifact families named by + the issue: task, experiment authoring input, apparatus context, run, and + study; and +- the scope and parent kind must agree. Catch-all `other` parents are not + conformant attachment points. + +Reuse `ExperimentReferenceModel` and its constrained reference patterns rather +than creating a second generic reference vocabulary. Add the missing explicit +authoring-input reference kind at that shared seam if needed; do not disguise +it as `protocol`, `manifest`, or `other`. Parent matching remains +family-specific because task, authoring-input, apparatus, run, study, scenario, +and scenario-snapshot payloads have different identity fields. + +A generic `scenario` parent is an association to a conceptual scenario id only. +It cannot make a snapshot-integrity claim and must retain the existing id-only +restriction. A `scenario-snapshot` parent may carry the incumbent version and +digest binding, which the parent matcher checks by applying +`canonical_sdl_digest()` to a validated, expanded scenario. Experiment parent +refs bind the incumbent id/version fields. They must not gain a `ref_digest` +until that parent family has a normative canonical payload profile and a +validator for it; a generic experiment reference field is not permission to +self-certify a parent digest. The manifest never changes the scenario's +semantic digest. + +### Reuse the incumbent artifact descriptor + +The artifact entry must deliberately reuse or factor the incumbent +`ExperimentArtifactRefModel` / `ExperimentChecksumModel` shape: stable local +`artifact_id`, role, media type, URI, checksum, byte size, source, creation +time, sensitivity, optional description, and applicable provenance/evidence +links. Do not publish a scenario-only copy of those fields. + +Scenario companion roles may extend the shared role vocabulary where the +existing roles are insufficient, but role remains a closed contract field. +Do not use media type, filename suffix, directory name, URI path, or free-form +description as a hidden role discriminator. + +Within one manifest: + +- `artifact_id` is an opaque, case-sensitive, stable local identity. It is not + a path and is not content identity. +- `(checksum algorithm, checksum value, size)` identifies payload bytes. The + URI is a non-authoritative locator; checksum validation establishes + immutability even if a locator is mutable. +- In the portable manifest, the URI is an absolute, non-secret URI (including a + content-addressed URN where appropriate), not a pack-relative filesystem + path. URI scheme support and acquisition remain consumer policy. Userinfo, + bearer material, or other embedded credentials are forbidden. +- Artifact entries use a keyed object map, with each key equal to the embedded + `artifact_id`. This follows ADR-055 and makes duplicate local ids impossible + in the constructed portable object. Duplicate JSON member names must still + fail at the JSON ingress boundary rather than being accepted with + last-write-wins behavior. +- Reusing an artifact id in a different manifest is allowed because ids are + manifest-local. Repeating the same checksum under distinct ids is allowed + when the producer intentionally gives the same bytes distinct roles or + locators; both entries remain in the canonical set and both must validate. +- One URI must not make conflicting checksum, size, or media-type claims in the + same manifest. Exact duplicate descriptors under different keys are invalid + aliasing, not two artifacts. + +`source` and provenance fields are assertions, not authenticity evidence. +`sensitivity` governs handling and disclosure but does not grant entitlement. +No sensitivity value may weaken byte binding. Restricted or redacted metadata +must still retain the non-secret checksum and size needed for verification. + +### Separate logical manifest identity from set identity + +The manifest needs a stable logical id/version and a separately computed +associated-artifact-set digest. The set digest is derived; a caller cannot +establish it by supplying a string. + +Define a versioned canonicalization profile for the abstract contract. Its +canonical projection contains the profile id, attachment scope, exact parent +reference, and exact keyed artifact-reference set. It excludes the set-digest +field itself and excludes filesystem traversal order, archive metadata, +materialization paths, manifest filenames, export tiers, and packaging-layout +metadata. Digest spellings must be normalized by the profile so equivalent hex +case does not create different set identities; URI strings, opaque ids, and +descriptive values must not receive filesystem- or platform-dependent +normalization. + +Use the repository's incumbent RFC 8785/JCS and lowercase prefixed-digest +convention unless the normative specification records a compelling +incompatibility. Keep the associated-artifact canonicalization profile +distinct from `aces-sdl-semantic/v1`: equal SDL meaning does not imply equal +attachment sets, and equal attachment sets do not imply equal SDL meaning. + +The set digest changes when the parent reference or any canonical artifact +entry changes. It does not change merely because a downstream archive orders +files differently or chooses another safe layout. + +### Byte binding is a mandatory cross-artifact gate + +Closed schema/model validation proves shape only. Full conformance and every +integrity/trust claim require a named cross-artifact validator that receives: + +- the validated manifest; +- the concrete parent artifact when parent-payload identity must be checked; + and +- one concrete, explicitly supplied byte stream for every artifact id. + +The validator computes each declared checksum and byte size from the supplied +bytes, checks that every manifest entry has exactly one byte binding, checks +the parent reference against the supplied parent, and recomputes the set digest +from canonical contract data. A mapping that supplies only a caller-asserted +digest, size, path, URI, or prior validation boolean is not a byte binding. A +missing byte stream is a conformance failure, not an optional or offline mode. + +The validator must not fetch URIs, walk directories, unpack archives, resolve +registry credentials, or infer a payload from a filename. Acquisition and +materialization are caller responsibilities. The validator consumes already +staged bytes through a narrow reader/resolver parameter so filesystem, object +store, OCI, and in-memory consumers can share the same checksum logic without +putting those transports into the ACES contract. + +Reads must be streaming and bounded. Reject declared artifact counts, +per-artifact sizes, or total sizes that exceed caller-supplied policy limits +before reading; stop at the declared size plus one byte when detecting a size +mismatch; and never buffer an unbounded artifact set. Follow the bounded-read +pattern in `aces_sdl.module_registry`, but do not import its OCI-private +helpers or its archive semantics into the portable validator. + +### Attachment never implies inheritance + +The only attachment established by a manifest is its exact `parent_ref`. +Scenario attachments are not automatically task attachments; task attachments +are not automatically authoring-input, run, apparatus, or study attachments; +run attachments are not automatically study attachments; and study membership +does not copy member attachments into the study. + +Attaching the same bytes at another scope requires another conforming manifest +whose own parent reference names that scope. Because the parent participates in +set identity, the two manifests have different set digests even when their +artifact maps are identical. Lineage between those manifests may use existing +typed references, but lineage does not create attachment or inheritance. + +### Trust mapping is family-specific + +Keep four claims distinct: + +1. the semantic SDL digest identifies validated expanded SDL meaning; +2. the associated-artifact-set digest identifies one parent plus one exact + artifact-reference set; +3. each `artifact_checksum` binds one entry to concrete payload bytes; and +4. authenticity is an independently verified signature/trust-policy decision. + +The parent asset retains its incumbent integrity mechanism. Associated bytes do +not contribute to the scenario semantic digest, scenario-snapshot digest, task +identity, run identity, apparatus identity, authoring-input identity, or study +identity. + +Map the manifest/set as a distinct `associated_artifact_set` reusable-asset +family under ADR-071. Its set digest is `integrity_digest`; its referenced +payloads require `artifact_checksum`. This requires the reusable-asset trust +policy contract and reference fixture to gain the new family while preserving +the existing `reusable_scenario` and experiment-family mappings. A signature +over the set digest may satisfy `authenticity_signature`; the manifest digest +or checksum alone never does. + +This is not a scenario-distribution/bundle asset family. A distribution may use +the manifest, but archive or filesystem layout remains downstream. A generic +scenario attachment cannot satisfy reusable-scenario snapshot integrity. A +snapshot-scoped manifest can be independently trusted as an associated set, +but it still does not alter the parent snapshot digest. + +## Required Incumbents + +- Authority and publication: `specs/`, `contracts/schemas/`, + `contracts/fixtures/`, `contracts/schema-publication-manifest.json`, + `specs/authority/authority-boundary.yaml`, `ContractModel`, `schema_bundle()`, + `tools/generate_contract_schemas.py`, `tools/check_generated_schemas.py`, + `tools/check_schema_publication.py`, and `tools/check_json_artifacts.py`. +- Experiment identity and artifacts: `ExperimentReferenceModel`, + `ExperimentScenarioReferenceModel`, + `ExperimentScenarioSnapshotReferenceModel`, `ExperimentTaskReferenceModel`, + `ExperimentArtifactRefModel`, `ExperimentChecksumModel`, + `ExperimentTaskModel`, `ExperimentSpecModel`, + `ExperimentApparatusContextModel`, `ExperimentRunModel`, + `ExperimentStudyModel`, `_canonical_digest()`, + `_experiment_reference_key()`, `_validate_unique_experiment_references()`, + and the task/run/study cross-artifact validators. +- Portable semantic invariants: `_add_aces_invariant()`, + `AcesSemanticInvariantEntryModel`, the `x-aces-invariants` profile, and + `validate_aces_semantic_invariant_annotations()`. Payload-byte binding must + be published as a named required semantic invariant; generic JSON Schema + consumers must not claim full conformance after shape validation alone. The + current invariant `inputs` shape names contract instances only, so it must + name the manifest and applicable parent contracts while the validator's + public callable contract explicitly requires external byte readers. Do not + invent a synthetic JSON "bytes contract" or falsely describe a digest map as + the payload input; extend the semantic-invariant profile deliberately only if + portable discovery of non-contract inputs becomes a broader requirement. +- Canonical identity: `aces_sdl.canonical` and its RFC 8785 implementation as + a serialization precedent only. Associated-artifact canonicalization belongs + in the contract-owned package, not in SDL semantic identity code. +- Trust policy: `ReusableAssetTrustPolicyModel`, + `ReusableAssetFamilyTrustPolicyModel`, `REUSABLE_ASSET_FAMILIES`, the + normative trust specification, the published trust-policy schema, and its + valid/invalid fixture family. +- Diagnostics and conformance: `aces_contracts.diagnostics.Diagnostic`, + `Severity`, `aces_conformance.conformance`, `_MODEL_VALIDATORS`, + `_fixture_case_diagnostics()`, and the existing structured CLI report. Reuse + this envelope instead of adding an artifact-manifest exception hierarchy. +- Corpus and installed-distribution access: `aces_contracts.corpus`, the + existing `schemas` and `fixtures` families, Python package `force-include` + rules, and installed-corpus tests. Do not add a second manifest/schema loader. +- Manifest capability authority: `aces_contracts.manifest_authority` allowlists + describe processor/backend/participant runtime support, not every published + contract. Do not add the standalone manifest contract to those allowlists or + backend profiles unless that surface actually consumes it. +- Persistence precedents, if a later producer writes a manifest: + `aces_operations.run_artifacts.atomic_write_json_artifact()` and + `LocalControlPlaneStore._atomic_write()` demonstrate same-directory temporary + files plus `os.replace`. They are precedents for atomic writes, not authority + to store attachment manifests in control-plane state. +- Workflow gates: `.ground-control.yaml`, `.gc/plan-rules.md`, `noxfile.py`, + `tools/check_repo_policy.py`, `tools/check_requirement_governance.py`, + `tools/check_authority_boundary.py`, `tools/check_adr_immutability.py`, and + `tools/verify_all.py`. + +## Cross-Cutting Layers + +The intended design must pass each layer it touches: + +- **JSON/config ingress:** parse local JSON as data, reject duplicate member + names before object construction, then apply the published Draft 2020-12 + schema and closed `ContractModel(extra="forbid")` model. Do not evaluate + content, resolve remote schema references, or accept unknown fields. +- **Shape validation:** enforce scope/parent discrimination, parent-kind + constraints, keyed artifact-id equality, checksum length/algorithm, byte + sizes, RFC 3339 creation times, closed role/sensitivity values, and locator + shape through shared contract validators. Reuse archival datetime validators + rather than creating another date parser. +- **Cross-artifact semantic validation:** run parent matching, collision rules, + set-digest recomputation, and one-to-one payload-byte binding after shape + validation. Publish these requirements through `x-aces-invariants` and make + conformance invoke the same validator rather than reimplementing its logic. +- **Trust-policy gate:** evaluate parent integrity, set integrity, payload + checksums, and authenticity as separate evidence classes. Trust status never + grants authorization or bypasses sensitivity handling. +- **Secret-handling surface:** manifests carry public verification metadata, + not bearer tokens, registry credentials, signed-URL secrets, private keys, + raw credentials, hidden prompts, answer keys, environment dumps, or raw + payloads. URI userinfo and credential-bearing query strings are nonconformant + producer behavior and must never be echoed in diagnostics. +- **Resource and acquisition surface:** the core validator performs no network, + archive, registry, or directory acquisition. Downstream acquisition applies + its own allowlists, timeouts, root confinement, archive safety, and immutable + staging before passing bounded readers to the validator. +- **OS/process exposure:** no subprocess, shell, environment-variable policy + channel, token-bearing argv, ambient current-directory walk, symlink-following + traversal, or host-absolute path belongs in contract validation. The byte + resolver is an in-process parameter, not a CLI secret channel. +- **Persistence:** no ACES control-plane or runtime persistence changes are in + scope. Do not place manifests or validation status in `RuntimeSnapshot`, + `RuntimeSnapshot.metadata`, `ControlPlaneStore`, operation records, or audit + details. Downstream consumers own immutable staging, atomic promotion, + retention, and use-time revalidation. +- **Logging/observability:** conformance emits stable `Diagnostic` code, domain, + address, severity, and bounded message. It may name the contract field or + artifact id, but must not include payload bytes, full rejected objects, + credential-bearing URIs, environment data, or tracebacks. There is no new + runtime metric or log stream in this issue. +- **HTTP/auth surface, if later exposed:** use + `ControlPlaneSecurityConfig.strict_defaults()`, `_MutatingIdentity` versus + `_ReadIdentity`, `request_size_guard_response()`, request fingerprints and + idempotency, `record_audit()`, published request/response models, and the + redacted 500 handler. Validation success is not authentication, + authorization, or entitlement. URI fetching must not occur inside a request + handler merely because a manifest was accepted. +- **Error envelopes:** Pydantic/JSON Schema shape failures stay contract + failures; semantic and byte-binding failures use `Diagnostic`; a thin + convenience validator may raise existing `ValueError` after diagnostics are + available. SDL parsing errors remain SDL errors, and future HTTP adapters use + bounded `HTTPException` details plus the existing redacted internal-error + envelope. Do not add a parallel exception tree. + +## Conformance Diagnostics And Negative Cases + +Use one diagnostic producer as the source of truth, with stable codes for at +least: + +- missing concrete payload binding or a digest-only/caller-asserted binding; +- payload checksum mismatch and payload size mismatch; +- recomputed set-digest mismatch; +- parent reference or parent payload mismatch; +- scope/parent-kind confusion; +- duplicate artifact id, keyed-id mismatch, exact descriptor aliasing, and + conflicting claims for one locator; and +- changed artifact set under a previously asserted set digest. + +Diagnostics must distinguish structural invalidity from unverified integrity. +An otherwise well-shaped manifest with no supplied bytes is structurally valid +but not fully conformant and cannot satisfy trust policy. + +Valid fixtures must cover generic scenario, sealed scenario-snapshot, and every +supported experiment parent family without duplicating contract shapes. +Focused invalid fixtures must cover both attachment scopes and every negative +case above. Cross-artifact tests must mutate the parent, artifact set, bytes, +checksum, and size independently so no test passes because two claims drifted +together. Schema-only tests and model/semantic tests remain distinct. + +## Downstream Packaging And Consumer Boundary + +Scenario-pack tooling owns filesystem layout, manifest filename, archive or OCI +layout, traversal rules, release tiers, catalog metadata, and materialization. +It may produce the ACES portable manifest only after selecting a stable byte +set; a walk over a live mutable directory is not an atomic snapshot and is not +the ACES asset model. + +Consumers own acquisition, immutable staging, storage, entitlement, and +use-time verification. They must stage the parent and every referenced payload, +run ACES shape and byte-binding validation, derive rather than trust the set +digest, atomically promote the verified set, retain the manifest with the +verified bytes, and verify again before use when storage guarantees do not make +that redundant. A caller-supplied package digest may be retained as untrusted +metadata, but cannot be persisted as ACES conformance or trust evidence unless +it equals the validator-derived set digest. + +## Extensibility Seam + +The seam is a closed attachment scope plus constrained parent-reference kind, +the versioned canonicalization profile, and an injected bounded byte resolver. +Adding a future parent family should require one constrained reference/matcher, +one schema enum/union extension, and focused fixtures; it must not require +changing SDL semantics, every experiment root, storage, archive layout, +conformance diagnostics, and trust code independently. + +Adding future artifact roles extends the shared artifact-role vocabulary. +Adding a canonicalization revision publishes a new profile and compatibility +evidence; it does not silently reinterpret existing set digests. Adding a new +transport implements the downstream resolver/acquisition boundary and leaves +the manifest and byte validator unchanged. + +## Gotchas And Anti-Patterns + +Avoid: + +- changing `canonical_sdl_digest()` or treating companions as SDL meaning; +- creating `TrustedAsset`, `ScenarioBundle`, `ScenarioPack`, or a second + experiment artifact descriptor with duplicated fields; +- using a live directory walk, archive order, inode metadata, permissions, + symlink targets, manifest filename, or export tier in portable set identity; +- accepting a caller-supplied package/set digest without recomputing it and + binding every referenced checksum to concrete bytes; +- treating `artifact_id`, URI, filename, parent id, schema validity, or a prior + validation boolean as integrity or authenticity; +- allowing generic scenario refs to carry version/digest/path qualifiers or + using experiment `other` refs to avoid a missing typed parent kind; +- inferring attachment or trust across scenario, task, authoring-input, + apparatus, run, and study scopes; +- duplicating checksum models, reference vocabularies, canonical JSON helpers, + schema registries, corpus loaders, trust-policy tables, semantic validators, + diagnostics, exception hierarchies, persistence stores, audit logs, or nox + workflows; +- fetching remote URIs, walking paths, extracting archives, or following + symlinks in the contract model or core byte validator; +- storing verification state in live runtime snapshots or reconstructing an + archival manifest from mutable control-plane state; +- logging raw bytes, full validation inputs, credential-bearing locators, + secrets, or absolute host paths; and +- hand-editing generated/reference schema output, silently editing accepted + ADRs, or treating implementation models as normative authority. + +## Non-Goals And Implementation Boundaries + +- No scenario-pack directory layout, manifest filename, archive/OCI layout, + filesystem traversal algorithm, release tier, or catalog metadata. +- No SDL syntax or semantic identity change. +- No Shifter ingestion, storage, entitlement, launchability, or API policy. +- No acquisition workflow, registry, object store, persistence service, + retention job, background verifier, or use-time launch integration. +- No new cryptography, signer discovery, key distribution, certificate + authority, transparency log, or secret-bearing payload. +- No automatic inheritance between attachment scopes and no mutation of parent + asset identity when attachments change. +- No reuse of module archive layout, lockfiles, or registry signatures as the + associated-artifact manifest contract. +- No runtime/control-plane endpoint, config/environment setting, subprocess, + database table, or new logging stream. +- No implementation work in this preflight note. diff --git a/implementations/python/packages/aces_conformance/conformance.py b/implementations/python/packages/aces_conformance/conformance.py index 89c28108f..e22431a23 100644 --- a/implementations/python/packages/aces_conformance/conformance.py +++ b/implementations/python/packages/aces_conformance/conformance.py @@ -23,6 +23,7 @@ load_backend_profile_from_path, ) from aces_contracts.contracts import ( + AssociatedArtifactManifestModel, BackendManifestV2Model, EvaluationHistoryEventModel, EvaluationPlanModel, @@ -237,6 +238,11 @@ class BackendConformanceReport: "experiment-run-v1": ExperimentRunModel.model_validate, } +_STRUCTURAL_ONLY_VALIDATORS = { + "associated-artifact-manifest-v1": AssociatedArtifactManifestModel.model_validate, +} +_SEMANTIC_CONTEXT_REQUIRED_CONTRACTS = frozenset({"associated-artifact-manifest-v1"}) + _EVENT_STREAM_VALIDATORS: dict[str, tuple[type, str]] = { "workflow-history-event-stream-v1": (WorkflowHistoryEventModel, "workflow"), @@ -432,7 +438,7 @@ def _validate_event_stream( def _validate_payload(contract_name: str, payload: Any) -> list[Diagnostic]: diagnostics: list[Diagnostic] = [] - validator = _MODEL_VALIDATORS.get(contract_name) + validator = _MODEL_VALIDATORS.get(contract_name) or _STRUCTURAL_ONLY_VALIDATORS.get(contract_name) if validator is not None: try: validator(payload) @@ -997,6 +1003,19 @@ def _fixture_case_diagnostics(contract_name: str, payload: object) -> list[Diagn schema_diagnostics = _validate_payload(contract_name, payload) if schema_diagnostics: return schema_diagnostics + if contract_name in _SEMANTIC_CONTEXT_REQUIRED_CONTRACTS: + return [ + Diagnostic( + code="conformance.semantic-context-required", + domain="conformance", + address="#", + message=( + "full associated-artifact conformance requires a concrete parent and bounded byte readers; " + "the generic fixture runner establishes structural validity only" + ), + severity=Severity.ERROR, + ) + ] return _semantic_diagnostics(contract_name, payload) diff --git a/implementations/python/packages/aces_contracts/associated_artifacts.py b/implementations/python/packages/aces_contracts/associated_artifacts.py new file mode 100644 index 000000000..f575bade9 --- /dev/null +++ b/implementations/python/packages/aces_contracts/associated_artifacts.py @@ -0,0 +1,338 @@ +"""Canonical identity and bounded byte binding for associated artifacts.""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping +from dataclasses import dataclass +from typing import BinaryIO, cast + +import rfc8785 +from aces_sdl import canonical_sdl_digest +from aces_sdl.scenario import Scenario +from blake3 import blake3 + +from .contracts import ( + AssociatedArtifactManifestModel, + ExperimentApparatusContextModel, + ExperimentArtifactRefModel, + ExperimentRunModel, + ExperimentSpecModel, + ExperimentStudyModel, + ExperimentTaskModel, +) +from .diagnostics import Diagnostic, Severity + +_DOMAIN = "associated-artifact" +_CHUNK_SIZE = 64 * 1024 +_ARTIFACTS_ADDRESS = "#/artifacts" + +JSONValue = None | bool | int | float | str | list["JSONValue"] | dict[str, "JSONValue"] + + +@dataclass(frozen=True) +class AssociatedArtifactValidationLimits: + """Caller policy limits for one manifest validation.""" + + max_artifacts: int = 1024 + max_artifact_bytes: int = 1024 * 1024 * 1024 + max_total_bytes: int = 4 * 1024 * 1024 * 1024 + + def __post_init__(self) -> None: + if self.max_artifacts < 1 or self.max_artifact_bytes < 0 or self.max_total_bytes < 0: + raise ValueError("associated-artifact validation limits must be non-negative and allow an artifact") + + +def _normalized_canonical_value(value: JSONValue) -> JSONValue: + if isinstance(value, dict): + normalized = {key: _normalized_canonical_value(item) for key, item in value.items()} + checksum = normalized.get("checksum") + if isinstance(checksum, dict) and isinstance(checksum.get("value"), str): + checksum["value"] = checksum["value"].casefold() + if isinstance(normalized.get("ref_digest"), str): + normalized["ref_digest"] = normalized["ref_digest"].casefold() + return normalized + if isinstance(value, list): + return [_normalized_canonical_value(item) for item in value] + return value + + +def associated_artifact_set_bytes(manifest: AssociatedArtifactManifestModel) -> bytes: + """Return RFC 8785 bytes for the abstract parent-plus-reference set.""" + + projection = { + "profile": manifest.canonicalization_profile, + "scope": manifest.scope, + "parent_ref": manifest.parent_ref.model_dump(mode="json", exclude_none=True), + "artifacts": { + artifact_id: artifact.model_dump(mode="json", exclude_none=True) + for artifact_id, artifact in manifest.artifacts.items() + }, + } + try: + return rfc8785.dumps(_normalized_canonical_value(projection)) + except rfc8785.CanonicalizationError as exc: + raise ValueError("associated-artifact set canonicalization failed") from exc + + +def associated_artifact_set_digest(manifest: AssociatedArtifactManifestModel) -> str: + """Derive the v1 lowercase SHA-256 identity for a manifest's artifact set.""" + + digest = hashlib.sha256(associated_artifact_set_bytes(manifest)).hexdigest() + return f"sha256:{digest}" + + +def _reject_duplicate_members(pairs: list[tuple[str, JSONValue]]) -> dict[str, JSONValue]: + result: dict[str, JSONValue] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate JSON member {key!r}") + result[key] = value + return result + + +def load_associated_artifact_manifest_json(source: str | bytes | bytearray) -> AssociatedArtifactManifestModel: + """Parse one manifest while rejecting duplicate JSON members before construction.""" + + payload = json.loads(source, object_pairs_hook=_reject_duplicate_members) + return AssociatedArtifactManifestModel.model_validate(payload) + + +def _diagnostic(code: str, address: str, message: str) -> Diagnostic: + return Diagnostic(code=code, domain=_DOMAIN, address=address, message=message, severity=Severity.ERROR) + + +def _scenario_parent_matches(manifest: AssociatedArtifactManifestModel, parent: object) -> bool: + reference = manifest.parent_ref + matches = isinstance(parent, Scenario) and parent.name == reference.ref_id + if matches and reference.ref_kind == "scenario-snapshot": + matches = reference.ref_version is None or parent.version == reference.ref_version + if matches and reference.ref_digest is not None: + matches = canonical_sdl_digest(parent).value.casefold() == reference.ref_digest.casefold() + return matches + + +def _experiment_parent_matches(manifest: AssociatedArtifactManifestModel, parent: object) -> bool: + reference = manifest.parent_ref + parent_shapes: dict[str, tuple[type[object], str, str]] = { + "task": (ExperimentTaskModel, "task_id", "task_version"), + "authoring-input": (ExperimentSpecModel, "spec_id", "spec_version"), + "apparatus-context": (ExperimentApparatusContextModel, "apparatus_context_id", "context_version"), + "run": (ExperimentRunModel, "run_id", "run_version"), + "study": (ExperimentStudyModel, "study_id", "study_version"), + } + expected_type, id_field, version_field = parent_shapes[reference.ref_kind] + matches = isinstance(parent, expected_type) and getattr(parent, id_field) == reference.ref_id + if matches and reference.ref_version is not None: + matches = getattr(parent, version_field) == reference.ref_version + return matches + + +def _parent_matches(manifest: AssociatedArtifactManifestModel, parent: object) -> bool: + if manifest.parent_ref.ref_kind in {"scenario", "scenario-snapshot"}: + return _scenario_parent_matches(manifest, parent) + return _experiment_parent_matches(manifest, parent) + + +def _read_and_hash(reader: BinaryIO, algorithm: str, declared_size: int) -> tuple[int, str] | None: + if algorithm == "blake3": + digest = blake3() + else: + try: + digest = hashlib.new(algorithm) + except ValueError: + return None + total = 0 + while total <= declared_size: + chunk = reader.read(min(_CHUNK_SIZE, declared_size + 1 - total)) + if not isinstance(chunk, bytes): + raise TypeError("artifact reader must return bytes") + if not chunk: + break + digest.update(chunk) + total += len(chunk) + return total, digest.hexdigest() + + +def _resource_limit_diagnostics( + manifest: AssociatedArtifactManifestModel, + limits: AssociatedArtifactValidationLimits, +) -> tuple[Diagnostic, ...]: + diagnostics: list[Diagnostic] = [] + if len(manifest.artifacts) > limits.max_artifacts: + diagnostics.append( + _diagnostic( + "associated-artifact.resource-limit-exceeded", + _ARTIFACTS_ADDRESS, + "artifact count exceeds the caller-supplied validation limit", + ) + ) + declared_total = sum(artifact.size_bytes for artifact in manifest.artifacts.values()) + oversized = [ + artifact_id + for artifact_id, artifact in manifest.artifacts.items() + if artifact.size_bytes > limits.max_artifact_bytes + ] + if oversized or declared_total > limits.max_total_bytes: + diagnostics.append( + _diagnostic( + "associated-artifact.resource-limit-exceeded", + _ARTIFACTS_ADDRESS, + "declared artifact bytes exceed the caller-supplied validation limits", + ) + ) + return tuple(diagnostics) + + +def _identity_diagnostics(manifest: AssociatedArtifactManifestModel, parent: object) -> tuple[Diagnostic, ...]: + diagnostics: list[Diagnostic] = [] + if not _parent_matches(manifest, parent): + diagnostics.append( + _diagnostic( + "associated-artifact.parent-mismatch", + "#/parent_ref", + "the supplied concrete parent does not match parent_ref", + ) + ) + if associated_artifact_set_digest(manifest) != manifest.set_digest: + diagnostics.append( + _diagnostic( + "associated-artifact.set-digest-mismatch", + "#/set_digest", + "set_digest does not match the canonical parent-plus-artifact-reference set", + ) + ) + return tuple(diagnostics) + + +def _binding_presence_diagnostics( + manifest_ids: set[str], + supplied_ids: set[str], +) -> tuple[Diagnostic, ...]: + diagnostics: list[Diagnostic] = [] + for artifact_id in sorted(manifest_ids - supplied_ids): + diagnostics.append( + _diagnostic( + "associated-artifact.payload-binding-missing", + f"#/artifacts/{artifact_id}", + "no concrete byte reader was supplied for this artifact", + ) + ) + for artifact_id in sorted(supplied_ids - manifest_ids): + diagnostics.append( + _diagnostic( + "associated-artifact.payload-binding-unexpected", + _ARTIFACTS_ADDRESS, + f"a byte reader was supplied for undeclared artifact id {artifact_id!r}", + ) + ) + return tuple(diagnostics) + + +def _read_payload( + artifact_id: str, + artifact: ExperimentArtifactRefModel, + reader: object, +) -> tuple[tuple[int, str] | None, Diagnostic | None]: + address = f"#/artifacts/{artifact_id}" + result: tuple[int, str] | None = None + diagnostic: Diagnostic | None = None + if not hasattr(reader, "read"): + diagnostic = _diagnostic( + "associated-artifact.payload-binding-invalid", + address, + "the supplied binding is not a concrete byte reader", + ) + else: + try: + result = _read_and_hash(cast(BinaryIO, reader), artifact.checksum.algorithm, artifact.size_bytes) + except (OSError, TypeError, ValueError): + diagnostic = _diagnostic( + "associated-artifact.payload-binding-invalid", + address, + "the concrete byte reader failed without yielding a valid bounded byte stream", + ) + if result is None and diagnostic is None: + diagnostic = _diagnostic( + "associated-artifact.checksum-algorithm-unsupported", + f"{address}/checksum/algorithm", + "the checksum algorithm is unavailable to this validator", + ) + return result, diagnostic + + +def _payload_diagnostics( + artifact_id: str, + artifact: ExperimentArtifactRefModel, + reader: object, +) -> tuple[Diagnostic, ...]: + address = f"#/artifacts/{artifact_id}" + result, read_diagnostic = _read_payload(artifact_id, artifact, reader) + if read_diagnostic is not None: + return (read_diagnostic,) + + diagnostics: list[Diagnostic] = [] + assert result is not None + actual_size, actual_checksum = result + if actual_size != artifact.size_bytes: + diagnostics.append( + _diagnostic( + "associated-artifact.payload-size-mismatch", + f"{address}/size_bytes", + "concrete payload size does not match size_bytes", + ) + ) + if actual_checksum.casefold() != artifact.checksum.value.casefold(): + diagnostics.append( + _diagnostic( + "associated-artifact.payload-checksum-mismatch", + f"{address}/checksum", + "concrete payload bytes do not match the declared checksum", + ) + ) + return tuple(diagnostics) + + +def validate_associated_artifact_manifest( + manifest: AssociatedArtifactManifestModel, + *, + parent: object, + artifact_readers: Mapping[str, BinaryIO], + limits: AssociatedArtifactValidationLimits | None = None, +) -> tuple[Diagnostic, ...]: + """Validate parent/set identity and every payload through bounded readers. + + The caller acquires and immutably stages payloads. This function performs no + URI fetching, directory traversal, archive extraction, or ambient lookup. + """ + + limit_diagnostics = _resource_limit_diagnostics( + manifest, + limits or AssociatedArtifactValidationLimits(), + ) + if limit_diagnostics: + return limit_diagnostics + + diagnostics = list(_identity_diagnostics(manifest, parent)) + manifest_ids = set(manifest.artifacts) + supplied_ids = set(artifact_readers) + diagnostics.extend(_binding_presence_diagnostics(manifest_ids, supplied_ids)) + for artifact_id in sorted(manifest_ids & supplied_ids): + diagnostics.extend( + _payload_diagnostics( + artifact_id, + manifest.artifacts[artifact_id], + artifact_readers[artifact_id], + ) + ) + return tuple(diagnostics) + + +__all__ = [ + "AssociatedArtifactValidationLimits", + "associated_artifact_set_bytes", + "associated_artifact_set_digest", + "load_associated_artifact_manifest_json", + "validate_associated_artifact_manifest", +] diff --git a/implementations/python/packages/aces_contracts/contracts.py b/implementations/python/packages/aces_contracts/contracts.py index 4403e8a7c..3b32fb696 100644 --- a/implementations/python/packages/aces_contracts/contracts.py +++ b/implementations/python/packages/aces_contracts/contracts.py @@ -10,6 +10,7 @@ from datetime import UTC, datetime, timedelta from functools import lru_cache from typing import Annotated, Any, Literal +from urllib.parse import parse_qsl, urlsplit from aces_sdl import VARIABLE_TOKEN_PATTERN from aces_sdl.explicitness import ExplicitnessClass, ExplicitnessProvenance @@ -69,6 +70,7 @@ require_plan_operation_identity, ) from .versions import ( + ASSOCIATED_ARTIFACT_MANIFEST_SCHEMA_VERSION, ATLAS_TACTICS_SOURCE_SCHEMA_VERSION, ATTACK_ENTERPRISE_TACTICS_SOURCE_SCHEMA_VERSION, BACKEND_MANIFEST_V2_SCHEMA_VERSION, @@ -3048,6 +3050,7 @@ class ExperimentReferenceModel(ContractModel): "scenario", "scenario-snapshot", "task", + "authoring-input", "protocol", "apparatus-context", "run", @@ -3144,6 +3147,32 @@ class ExperimentScenarioSnapshotReferenceModel(ExperimentReferenceModel): ref_kind: Literal["scenario-snapshot"] +class AssociatedArtifactParentReferenceModel(ExperimentReferenceModel): + """Reference constrained to a supported associated-artifact parent.""" + + ref_kind: Literal[ + "scenario", + "scenario-snapshot", + "task", + "authoring-input", + "apparatus-context", + "run", + "study", + ] + + @model_validator(mode="after") + def _validate_associated_artifact_parent(self) -> AssociatedArtifactParentReferenceModel: + if self.ref_kind == "scenario": + if self.ref_version is not None or self.ref_digest is not None or self.ref_path is not None: + raise ValueError("generic scenario parents are id-only; use scenario-snapshot for snapshot binding") + elif self.ref_kind != "scenario-snapshot" and (self.ref_digest is not None or self.ref_path is not None): + raise ValueError( + "experiment associated-artifact parents must not carry ref_digest or ref_path without a " + "normative parent canonicalization profile" + ) + return self + + class ExperimentManifestReferenceModel(ExperimentReferenceModel): """Reference constrained to an apparatus or capability manifest.""" @@ -3711,6 +3740,11 @@ class ExperimentArtifactRefModel(ContractModel): "scaffold", "baseline", "cost-resource-trace", + "documentation", + "operator-guide", + "configuration", + "profile", + "dataset", "other", ] media_type: NonEmptyString @@ -3729,6 +3763,161 @@ def _validate_artifact_created_at(self) -> ExperimentArtifactRefModel: return self +AssociatedArtifactSetDigestString = Annotated[str, Field(pattern=r"^sha256:[a-f0-9]{64}$")] +_ASSOCIATED_ARTIFACT_SECRET_QUERY_NAMES = frozenset( + { + "access_token", + "api_key", + "apikey", + "auth", + "credential", + "key", + "password", + "secret", + "sig", + "signature", + "token", + } +) +_ASSOCIATED_ARTIFACT_SECRET_QUERY_FRAGMENTS = ( + "api-key", + "api_key", + "apikey", + "credential", + "password", + "secret", + "signature", + "token", +) + + +def _validate_associated_artifact_uri(artifact_id: str, uri: str) -> None: + parsed = urlsplit(uri) + if not parsed.scheme or (parsed.scheme in {"http", "https"} and not parsed.netloc): + raise ValueError(f"associated artifact {artifact_id!r} uri must be an absolute URI") + if parsed.username is not None or parsed.password is not None: + raise ValueError(f"associated artifact {artifact_id!r} uri must not contain credential userinfo") + query_names = {name.casefold() for name, _value in parse_qsl(parsed.query, keep_blank_values=True)} + secret_names = { + name + for name in query_names + if name in _ASSOCIATED_ARTIFACT_SECRET_QUERY_NAMES + or any(fragment in name for fragment in _ASSOCIATED_ARTIFACT_SECRET_QUERY_FRAGMENTS) + } + if secret_names: + raise ValueError(f"associated artifact {artifact_id!r} uri must not contain secret-bearing query fields") + + +class AssociatedArtifactManifestModel(ContractModel): + """One exact non-semantic artifact-reference set attached to one parent.""" + + schema_version: Literal[ASSOCIATED_ARTIFACT_MANIFEST_SCHEMA_VERSION] + manifest_id: NonEmptyString + manifest_version: NonEmptyString + canonicalization_profile: Literal["associated-artifact-set/v1"] + scope: Literal["scenario", "experiment"] + parent_ref: AssociatedArtifactParentReferenceModel + artifacts: dict[NonEmptyString, ExperimentArtifactRefModel] = Field(min_length=1) + set_digest: AssociatedArtifactSetDigestString + + @model_validator(mode="after") + def _validate_associated_artifact_manifest(self) -> AssociatedArtifactManifestModel: + scenario_kinds = {"scenario", "scenario-snapshot"} + parent_is_scenario = self.parent_ref.ref_kind in scenario_kinds + if (self.scope == "scenario") != parent_is_scenario: + raise ValueError("associated-artifact scope and parent kind must agree") + + descriptor_owners: dict[tuple[Any, ...], str] = {} + locator_claims: dict[str, tuple[Any, ...]] = {} + for artifact_key, artifact in self.artifacts.items(): + if artifact_key != artifact.artifact_id: + raise ValueError( + f"associated artifact map key {artifact_key!r} must equal embedded artifact_id " + f"{artifact.artifact_id!r}" + ) + _validate_associated_artifact_uri(artifact.artifact_id, artifact.uri) + descriptor_key = ( + artifact.role, + artifact.media_type, + artifact.uri, + artifact.checksum.algorithm, + artifact.checksum.value.casefold(), + artifact.size_bytes, + artifact.created_at, + artifact.source, + tuple(ref.model_dump_json() for ref in artifact.satisfies_refs), + artifact.sensitivity, + artifact.description, + ) + prior_descriptor_owner = descriptor_owners.get(descriptor_key) + if prior_descriptor_owner is not None: + raise ValueError( + f"associated artifacts {prior_descriptor_owner!r} and {artifact.artifact_id!r} are exact " + "descriptor aliases; duplicate descriptors require one stable artifact id" + ) + descriptor_owners[descriptor_key] = artifact.artifact_id + + locator_claim = ( + artifact.media_type, + artifact.checksum.algorithm, + artifact.checksum.value.casefold(), + artifact.size_bytes, + ) + prior_locator_claim = locator_claims.get(artifact.uri) + if prior_locator_claim is not None and prior_locator_claim != locator_claim: + raise ValueError("one associated-artifact locator must not carry conflicting payload claims") + locator_claims[artifact.uri] = locator_claim + return self + + @classmethod + def __get_pydantic_json_schema__( + cls, + core_schema: CoreSchema, + handler: GetJsonSchemaHandler, + ) -> JsonSchemaValue: + json_schema = handler(core_schema) + json_schema = handler.resolve_ref_schema(json_schema) + json_schema.setdefault("allOf", []).extend( + [ + { + "if": {"properties": {"scope": {"const": "scenario"}}, "required": ["scope"]}, + "then": { + "properties": { + "parent_ref": { + "properties": { + "ref_kind": {"enum": ["scenario", "scenario-snapshot"]}, + } + } + } + }, + }, + { + "if": {"properties": {"scope": {"const": "experiment"}}, "required": ["scope"]}, + "then": { + "properties": { + "parent_ref": { + "properties": { + "ref_kind": { + "enum": ["task", "authoring-input", "apparatus-context", "run", "study"] + }, + } + } + } + }, + }, + ] + ) + _add_aces_invariant( + json_schema, + "associated-artifact-parent-set-and-byte-binding", + "Full conformance requires matching the concrete parent, recomputing the canonical set digest, " + "and binding every checksum and size to an explicitly supplied bounded byte stream.", + validator="aces_contracts.associated_artifacts.validate_associated_artifact_manifest", + inputs=[{"contract_id": "associated-artifact-manifest-v1", "instance_path": "#"}], + ) + return json_schema + + class ExperimentValidityNoteModel(ContractModel): """Validity threat, limitation, or mitigation note for experiment interpretation.""" @@ -7111,6 +7300,7 @@ def _event_stream_schema(title: str, item_schema: dict[str, Any]) -> dict[str, A REUSABLE_ASSET_FAMILIES: tuple[str, ...] = ( "reusable_scenario", + "associated_artifact_set", "sdl_module", "experiment_task", "experiment_study", @@ -7122,6 +7312,7 @@ def _event_stream_schema(title: str, item_schema: dict[str, Any]) -> dict[str, A ReusableAssetFamily = Literal[ "reusable_scenario", + "associated_artifact_set", "sdl_module", "experiment_task", "experiment_study", @@ -7486,6 +7677,7 @@ def schema_bundle() -> dict[str, dict[str, Any]]: "participant-context-view-v1": ParticipantContextViewModel.model_json_schema(), "operation-receipt-v1": OperationReceiptModel.model_json_schema(), "operation-status-v1": OperationStatusModel.model_json_schema(), + "associated-artifact-manifest-v1": AssociatedArtifactManifestModel.model_json_schema(), "reusable-asset-trust-policy-v1": ReusableAssetTrustPolicyModel.model_json_schema(), } for contract_id, json_schema in bundle.items(): @@ -7517,6 +7709,10 @@ def schema_bundle() -> dict[str, dict[str, Any]]: "AttackEnterpriseTacticsSourceModel", "AtlasTacticSourceTermModel", "AtlasTacticsSourceModel", + "ASSOCIATED_ARTIFACT_MANIFEST_SCHEMA_VERSION", + "AssociatedArtifactManifestModel", + "AssociatedArtifactParentReferenceModel", + "AssociatedArtifactSetDigestString", "BACKEND_MANIFEST_V2_SCHEMA_VERSION", "ApparatusIdentityModel", "BackendCompatibilityModel", diff --git a/implementations/python/packages/aces_contracts/versions.py b/implementations/python/packages/aces_contracts/versions.py index d79113330..9764f0b31 100644 --- a/implementations/python/packages/aces_contracts/versions.py +++ b/implementations/python/packages/aces_contracts/versions.py @@ -36,3 +36,4 @@ EXPERIMENT_DERIVED_MEASURE_SCHEMA_VERSION = "experiment-derived-measure/v1" EXPERIMENT_AUTHORING_INPUT_SCHEMA_VERSION = "experiment-authoring-input/v1" REUSABLE_ASSET_TRUST_POLICY_SCHEMA_VERSION = "reusable-asset-trust-policy/v1" +ASSOCIATED_ARTIFACT_MANIFEST_SCHEMA_VERSION = "associated-artifact-manifest/v1" diff --git a/implementations/python/pyproject.toml b/implementations/python/pyproject.toml index 4d71978dc..cbeae33fb 100644 --- a/implementations/python/pyproject.toml +++ b/implementations/python/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "mcp>=1.0.0", "packaging>=23.0", "rfc8785>=0.1.4,<0.2", + "blake3>=1.0.8,<2", ] [project.optional-dependencies] diff --git a/implementations/python/tests/test_associated_artifact_manifests.py b/implementations/python/tests/test_associated_artifact_manifests.py new file mode 100644 index 000000000..838895df9 --- /dev/null +++ b/implementations/python/tests/test_associated_artifact_manifests.py @@ -0,0 +1,389 @@ +"""Associated-artifact manifest contract and byte-binding tests (issue #738).""" + +from __future__ import annotations + +import hashlib +import json +from io import BytesIO +from pathlib import Path + +import pytest +from aces_conformance.conformance import _MODEL_VALIDATORS, _fixture_case_diagnostics +from aces_contracts.associated_artifacts import ( + AssociatedArtifactValidationLimits, + associated_artifact_set_digest, + load_associated_artifact_manifest_json, + validate_associated_artifact_manifest, +) +from aces_contracts.contracts import ( + REUSABLE_ASSET_FAMILIES, + AssociatedArtifactManifestModel, + ExperimentApparatusContextModel, + ExperimentRunModel, + ExperimentSpecModel, + ExperimentStudyModel, + ExperimentTaskModel, + schema_bundle, +) +from aces_contracts.versions import ASSOCIATED_ARTIFACT_MANIFEST_SCHEMA_VERSION +from aces_sdl import canonical_sdl_digest, parse_sdl +from pydantic import ValidationError + +PAYLOAD = b"operator guide\n" +PAYLOAD_SHA256 = hashlib.sha256(PAYLOAD).hexdigest() +REPO_ROOT = Path(__file__).resolve().parents[3] +SCHEMA_PATH = REPO_ROOT / "contracts" / "schemas" / "associated-artifacts" / "associated-artifact-manifest-v1.json" +FIXTURES_ROOT = REPO_ROOT / "contracts" / "fixtures" / "associated-artifacts" / "associated-artifact-manifest-v1" + + +def _manifest_payload(**overrides: object) -> dict[str, object]: + payload: dict[str, object] = { + "schema_version": "associated-artifact-manifest/v1", + "manifest_id": "scenario-guidance", + "manifest_version": "1.0.0", + "canonicalization_profile": "associated-artifact-set/v1", + "scope": "scenario", + "parent_ref": {"ref_kind": "scenario", "ref_id": "training-range"}, + "artifacts": { + "operator-guide": { + "artifact_id": "operator-guide", + "role": "operator-guide", + "media_type": "text/markdown", + "uri": "urn:sha256:" + PAYLOAD_SHA256, + "checksum": {"algorithm": "sha256", "value": PAYLOAD_SHA256}, + "size_bytes": len(PAYLOAD), + "created_at": "2026-07-12T00:00:00Z", + "source": "scenario-author", + "sensitivity": "internal", + } + }, + "set_digest": "sha256:" + ("0" * 64), + } + payload.update(overrides) + return payload + + +def _manifest(**overrides: object) -> AssociatedArtifactManifestModel: + manifest = AssociatedArtifactManifestModel.model_validate(_manifest_payload(**overrides)) + return manifest.model_copy(update={"set_digest": associated_artifact_set_digest(manifest)}) + + +def _codes(diagnostics: tuple[object, ...]) -> set[str]: + return {diagnostic.code for diagnostic in diagnostics} # type: ignore[attr-defined] + + +def test_manifest_set_identity_is_distinct_from_sdl_semantic_identity() -> None: + scenario = parse_sdl("name: training-range\nversion: 1.0.0\n") + manifest = _manifest() + + assert manifest.set_digest == associated_artifact_set_digest(manifest) + assert manifest.set_digest != canonical_sdl_digest(scenario).value + + +def test_validator_binds_every_checksum_to_concrete_bytes() -> None: + scenario = parse_sdl("name: training-range\nversion: 1.0.0\n") + diagnostics = validate_associated_artifact_manifest( + _manifest(), + parent=scenario, + artifact_readers={"operator-guide": BytesIO(PAYLOAD)}, + ) + + assert diagnostics == () + + +def test_validator_rejects_missing_and_digest_only_bindings() -> None: + scenario = parse_sdl("name: training-range\n") + + missing = validate_associated_artifact_manifest(_manifest(), parent=scenario, artifact_readers={}) + digest_only = validate_associated_artifact_manifest( + _manifest(), + parent=scenario, + artifact_readers={"operator-guide": PAYLOAD_SHA256}, + ) + + assert "associated-artifact.payload-binding-missing" in _codes(missing) + assert "associated-artifact.payload-binding-invalid" in _codes(digest_only) + + +def test_validator_rejects_unexpected_and_failing_byte_readers() -> None: + class FailingReader: + def read(self, _size: int) -> bytes: + raise OSError("simulated bounded-reader failure") + + scenario = parse_sdl("name: training-range\n") + unexpected = validate_associated_artifact_manifest( + _manifest(), + parent=scenario, + artifact_readers={ + "operator-guide": BytesIO(PAYLOAD), + "undeclared": BytesIO(b""), + }, + ) + failing = validate_associated_artifact_manifest( + _manifest(), + parent=scenario, + artifact_readers={"operator-guide": FailingReader()}, # type: ignore[dict-item] + ) + + assert "associated-artifact.payload-binding-unexpected" in _codes(unexpected) + assert "associated-artifact.payload-binding-invalid" in _codes(failing) + + +def test_validator_reports_checksum_size_set_and_parent_mismatches() -> None: + scenario = parse_sdl("name: training-range\n") + original = _manifest() + changed_bytes = validate_associated_artifact_manifest( + _manifest(), + parent=scenario, + artifact_readers={"operator-guide": BytesIO(b"changed")}, + ) + changed_set = validate_associated_artifact_manifest( + original.model_copy(update={"set_digest": "sha256:" + ("f" * 64)}), + parent=scenario, + artifact_readers={"operator-guide": BytesIO(PAYLOAD)}, + ) + changed_artifact = original.artifacts["operator-guide"].model_copy(update={"role": "documentation"}) + changed_artifact_set = validate_associated_artifact_manifest( + original.model_copy(update={"artifacts": {"operator-guide": changed_artifact}}), + parent=scenario, + artifact_readers={"operator-guide": BytesIO(PAYLOAD)}, + ) + wrong_parent = validate_associated_artifact_manifest( + _manifest(), + parent=parse_sdl("name: another-scenario\n"), + artifact_readers={"operator-guide": BytesIO(PAYLOAD)}, + ) + + assert { + "associated-artifact.payload-checksum-mismatch", + "associated-artifact.payload-size-mismatch", + } <= _codes(changed_bytes) + assert "associated-artifact.set-digest-mismatch" in _codes(changed_set) + assert "associated-artifact.set-digest-mismatch" in _codes(changed_artifact_set) + assert "associated-artifact.parent-mismatch" in _codes(wrong_parent) + + +def test_scope_key_and_collision_rules_are_closed() -> None: + experiment_parent = {"ref_kind": "run", "ref_id": "run-1", "ref_version": "1"} + with pytest.raises(ValidationError, match="scope|parent"): + AssociatedArtifactManifestModel.model_validate(_manifest_payload(parent_ref=experiment_parent)) + + keyed_mismatch = _manifest_payload() + keyed_mismatch["artifacts"]["operator-guide"]["artifact_id"] = "different" # type: ignore[index] + with pytest.raises(ValidationError, match="key|artifact_id"): + AssociatedArtifactManifestModel.model_validate(keyed_mismatch) + + alias = _manifest_payload() + artifacts = alias["artifacts"] # type: ignore[assignment] + artifacts["guide-copy"] = dict(artifacts["operator-guide"], artifact_id="guide-copy") # type: ignore[index] + with pytest.raises(ValidationError, match="alias|duplicate"): + AssociatedArtifactManifestModel.model_validate(alias) + + +def test_json_ingress_rejects_duplicate_members_and_secret_bearing_locators() -> None: + duplicate = '{"schema_version":"associated-artifact-manifest/v1","manifest_id":"a","manifest_id":"b"}' + with pytest.raises(ValueError, match="duplicate JSON member"): + load_associated_artifact_manifest_json(duplicate) + + secret_uri = _manifest_payload() + secret_uri["artifacts"]["operator-guide"]["uri"] = "https://user:secret@example.test/guide" # type: ignore[index] + with pytest.raises(ValidationError, match="credential|userinfo|secret"): + AssociatedArtifactManifestModel.model_validate(secret_uri) + + signed_uri = _manifest_payload() + signed_uri["artifacts"]["operator-guide"]["uri"] = ( # type: ignore[index] + "https://example.test/guide?X-Amz-Credential=temporary&X-Amz-Signature=secret" + ) + with pytest.raises(ValidationError, match="secret"): + AssociatedArtifactManifestModel.model_validate(signed_uri) + + +def test_streaming_limits_reject_before_or_during_reads() -> None: + scenario = parse_sdl("name: training-range\n") + limits = AssociatedArtifactValidationLimits(max_artifacts=1, max_artifact_bytes=4, max_total_bytes=4) + + diagnostics = validate_associated_artifact_manifest( + _manifest(), + parent=scenario, + artifact_readers={"operator-guide": BytesIO(PAYLOAD)}, + limits=limits, + ) + + assert "associated-artifact.resource-limit-exceeded" in _codes(diagnostics) + + +def test_artifact_count_limit_is_enforced_independently_of_byte_limits() -> None: + payload = _manifest_payload() + artifacts = payload["artifacts"] # type: ignore[assignment] + artifacts["guide-copy"] = { # type: ignore[index] + **artifacts["operator-guide"], # type: ignore[index] + "artifact_id": "guide-copy", + "role": "documentation", + "uri": "urn:example:guide-copy", + } + manifest = AssociatedArtifactManifestModel.model_validate(payload) + manifest = manifest.model_copy(update={"set_digest": associated_artifact_set_digest(manifest)}) + + diagnostics = validate_associated_artifact_manifest( + manifest, + parent=parse_sdl("name: training-range\n"), + artifact_readers={}, + limits=AssociatedArtifactValidationLimits( + max_artifacts=1, + max_artifact_bytes=len(PAYLOAD), + max_total_bytes=2 * len(PAYLOAD), + ), + ) + + assert _codes(diagnostics) == {"associated-artifact.resource-limit-exceeded"} + + +def test_blake3_payloads_supported_by_the_public_checksum_contract_can_conform() -> None: + empty_blake3 = "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262" + payload = _manifest_payload() + artifact = payload["artifacts"]["operator-guide"] # type: ignore[index] + artifact["uri"] = "urn:blake3:" + empty_blake3 + artifact["checksum"] = {"algorithm": "blake3", "value": empty_blake3} + artifact["size_bytes"] = 0 + manifest = AssociatedArtifactManifestModel.model_validate(payload) + manifest = manifest.model_copy(update={"set_digest": associated_artifact_set_digest(manifest)}) + + assert ( + validate_associated_artifact_manifest( + manifest, + parent=parse_sdl("name: training-range\n"), + artifact_readers={"operator-guide": BytesIO(b"")}, + ) + == () + ) + + +def test_contract_is_registered_with_fixed_schema_and_trust_family() -> None: + assert ASSOCIATED_ARTIFACT_MANIFEST_SCHEMA_VERSION == "associated-artifact-manifest/v1" + assert "associated-artifact-manifest-v1" in schema_bundle() + assert "associated_artifact_set" in REUSABLE_ASSET_FAMILIES + + +def test_generic_conformance_runner_reports_required_external_semantic_context() -> None: + manifest = _manifest() + + assert "associated-artifact-manifest-v1" not in _MODEL_VALIDATORS + diagnostics = _fixture_case_diagnostics("associated-artifact-manifest-v1", manifest.model_dump(mode="json")) + assert _codes(tuple(diagnostics)) == {"conformance.semantic-context-required"} + + +@pytest.mark.parametrize( + ("ref_kind", "fixture", "model", "id_field", "version_field"), + [ + ("task", "experiment-task-v1", ExperimentTaskModel, "task_id", "task_version"), + ( + "authoring-input", + "experiment-authoring-input-v1", + ExperimentSpecModel, + "spec_id", + "spec_version", + ), + ( + "apparatus-context", + "experiment-apparatus-context-v1", + ExperimentApparatusContextModel, + "apparatus_context_id", + "context_version", + ), + ("run", "experiment-run-v1", ExperimentRunModel, "run_id", "run_version"), + ("study", "experiment-study-v1", ExperimentStudyModel, "study_id", "study_version"), + ], +) +def test_every_experiment_attachment_scope_binds_its_concrete_parent( + ref_kind: str, + fixture: str, + model: type, + id_field: str, + version_field: str, +) -> None: + path = REPO_ROOT / "contracts" / "fixtures" / "experiment-core" / fixture / "valid" / "reference.json" + parent = model.model_validate(json.loads(path.read_text(encoding="utf-8"))) + parent_ref = { + "ref_kind": ref_kind, + "ref_id": getattr(parent, id_field), + "ref_version": getattr(parent, version_field), + } + manifest = _manifest(scope="experiment", parent_ref=parent_ref) + + assert ( + validate_associated_artifact_manifest( + manifest, + parent=parent, + artifact_readers={"operator-guide": BytesIO(PAYLOAD)}, + ) + == () + ) + wrong_version_manifest = _manifest( + scope="experiment", + parent_ref={**parent_ref, "ref_version": "definitely-not-the-parent-version"}, + ) + assert "associated-artifact.parent-mismatch" in _codes( + validate_associated_artifact_manifest( + wrong_version_manifest, + parent=parent, + artifact_readers={"operator-guide": BytesIO(PAYLOAD)}, + ) + ) + + +def test_snapshot_parent_digest_is_checked_without_changing_sdl_identity() -> None: + scenario = parse_sdl("name: training-range\nversion: 1.0.0\n") + parent_ref = { + "ref_kind": "scenario-snapshot", + "ref_id": scenario.name, + "ref_version": scenario.version, + "ref_digest": canonical_sdl_digest(scenario).value, + } + manifest = _manifest(parent_ref=parent_ref) + + assert ( + validate_associated_artifact_manifest( + manifest, + parent=scenario, + artifact_readers={"operator-guide": BytesIO(PAYLOAD)}, + ) + == () + ) + other_parent = _manifest(parent_ref={"ref_kind": "scenario", "ref_id": scenario.name}) + assert manifest.set_digest != other_parent.set_digest + + wrong_digest = _manifest(parent_ref={**parent_ref, "ref_digest": "sha256:" + ("0" * 64)}) + wrong_version = _manifest(parent_ref={**parent_ref, "ref_version": "2.0.0"}) + for mismatched_manifest in (wrong_digest, wrong_version): + assert "associated-artifact.parent-mismatch" in _codes( + validate_associated_artifact_manifest( + mismatched_manifest, + parent=scenario, + artifact_readers={"operator-guide": BytesIO(PAYLOAD)}, + ) + ) + + +def test_published_schema_and_fixture_corpus_cover_both_attachment_scopes() -> None: + import jsonschema + + schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8")) + valid_paths = sorted((FIXTURES_ROOT / "valid").glob("*.json")) + invalid_paths = sorted((FIXTURES_ROOT / "invalid").glob("*.json")) + assert {path.stem for path in valid_paths} == { + "apparatus-context", + "authoring-input", + "run", + "scenario", + "scenario-snapshot", + "study", + "task", + } + assert invalid_paths + for path in valid_paths: + payload = json.loads(path.read_text(encoding="utf-8")) + jsonschema.validate(payload, schema) + AssociatedArtifactManifestModel.model_validate(payload) + for path in invalid_paths: + with pytest.raises(ValidationError): + AssociatedArtifactManifestModel.model_validate(json.loads(path.read_text(encoding="utf-8"))) diff --git a/implementations/python/uv.lock b/implementations/python/uv.lock index b8433ddc4..7b7ea43c6 100644 --- a/implementations/python/uv.lock +++ b/implementations/python/uv.lock @@ -24,6 +24,7 @@ version = "0.19.1" source = { editable = "." } dependencies = [ { name = "asyncssh" }, + { name = "blake3" }, { name = "cryptography" }, { name = "defusedxml" }, { name = "fastapi" }, @@ -58,6 +59,7 @@ docs = [ [package.metadata] requires-dist = [ { name = "asyncssh", specifier = ">=2.23.0" }, + { name = "blake3", specifier = ">=1.0.8,<2" }, { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "cryptography", specifier = ">=46.0.7" }, { name = "defusedxml", specifier = ">=0.7.1" }, @@ -167,6 +169,82 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, ] +[[package]] +name = "blake3" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/6a/4cc5a9dd40fd8a6d283fd3761e5f59c490109571ef8e3c73245417e5a305/blake3-1.0.9.tar.gz", hash = "sha256:5fa374fa5070ca084368776c19b420157eb0f2d3f091343d6bc59189929d62e2", size = 116872, upload-time = "2026-06-22T18:02:25.366Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/12/aa8d72228b6ff61c675bd6f55ab138a91d71499c8a707cc9fb2052f1d2b5/blake3-1.0.9-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f169519c7ef25ef2c446b05e2f08e7e59fae312d569f98a3134b38d4caf7abd4", size = 346253, upload-time = "2026-06-22T18:00:15.537Z" }, + { url = "https://files.pythonhosted.org/packages/72/3a/820d2f729dfe152d5ebde16390f808c762dce3f21fb764ab033803ff2b1a/blake3-1.0.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b5e1f21b49492d01fa5a02084894c491ab9e7a1867fced107f7126c80d067c94", size = 335497, upload-time = "2026-06-22T18:00:16.942Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d6/d5462ec19a7f3d084fe327e08618fa107799ee708df04b3a2d620bd62816/blake3-1.0.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ee96daaa850700fd342a811fa10a8780fd2e8464a71b83a1779c7b6becd3dd5", size = 377621, upload-time = "2026-06-22T18:00:18.389Z" }, + { url = "https://files.pythonhosted.org/packages/92/98/dbc433f2a45be1b2344a6035d4212dfb6e6eb45046ad15103ead9c82d491/blake3-1.0.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:09deb024cd75cb200e7f647cd038800e6edc8f190c8188e0c69ec1c2b920e125", size = 377495, upload-time = "2026-06-22T18:00:20.067Z" }, + { url = "https://files.pythonhosted.org/packages/e0/3d/c7a699fb60d8ed31f3f28e6aec7658d29e45ec89e7054906b3040ce3ee65/blake3-1.0.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c99afb0459c82dd13e456b6b68d45c4768b539ca998dacd3ed726f1e75e91dc", size = 451158, upload-time = "2026-06-22T18:00:21.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a1/0b1b0dbf2dd772483e372237bb65385602b019e24b67424b1fc9e5447837/blake3-1.0.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:28528d1f29e6f3d45faf3482e1197e5e175730eef38bdc74e56ee11b68e0ad0d", size = 491988, upload-time = "2026-06-22T18:00:22.984Z" }, + { url = "https://files.pythonhosted.org/packages/ee/d1/ed319477f6d263a4f6b7e9aa465b06be5235a854923edbc9ea09508b6638/blake3-1.0.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65c0c20014df687694af5ccf0cec3bdb194511da8ebd50c30b0fd55c83fa4fd5", size = 386848, upload-time = "2026-06-22T18:00:24.319Z" }, + { url = "https://files.pythonhosted.org/packages/80/3e/a4cfb269f3e0955598b415a7843c358c4f79e826e3c9118dc9fb1f101ee6/blake3-1.0.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:964b642631a3c8fe117b3439c8ae64a9a0981af9444e409656d1f1e464bfa125", size = 387842, upload-time = "2026-06-22T18:00:25.589Z" }, + { url = "https://files.pythonhosted.org/packages/59/0e/d4ee3d89eece42f86eb46663aa42702000516b7ffbc53f60b918efe95b57/blake3-1.0.9-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:2fd000708662b04be211a22c1095b65fe399d7276e9f3bb2fd1ef8aacc545791", size = 384317, upload-time = "2026-06-22T18:00:26.891Z" }, + { url = "https://files.pythonhosted.org/packages/3a/aa/317106349d10de3b51332ad1e761f4864ebe887854396b75975304dcfbd1/blake3-1.0.9-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:82ecade6ac425fdfc39a4371d6d9232fd6e5c28748fd8d3489016ead17407014", size = 553005, upload-time = "2026-06-22T18:00:28.246Z" }, + { url = "https://files.pythonhosted.org/packages/39/cc/7fbce61a0b24bda1aac99da674bd74ac2b687b61db071c888ffdb30cb47a/blake3-1.0.9-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:b4102ba86b86c992a931b4a88c58a632d6097461e14a1e63ebd2ecb98ff0898f", size = 595086, upload-time = "2026-06-22T18:00:29.96Z" }, + { url = "https://files.pythonhosted.org/packages/e6/91/6ddc7a8b582a0871f23d6db722f4950a8918096d5fa10f9f0f992c2aea39/blake3-1.0.9-cp311-cp311-win32.whl", hash = "sha256:2f4ce45da903f3d0a7e342fa70c7cce9c10cef6b529eadb4d6213be0ab0eaf84", size = 231230, upload-time = "2026-06-22T18:00:31.247Z" }, + { url = "https://files.pythonhosted.org/packages/23/68/ea698e6df48eeb417671544cfbb18c60f863cb689306cc52f19666dd98f8/blake3-1.0.9-cp311-cp311-win_amd64.whl", hash = "sha256:d819457dccfd82fe34684ec99e36725f747bd5761a0e17f537387fb31d121193", size = 220622, upload-time = "2026-06-22T18:00:32.495Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d2/9bdf8345c70993aaef635398f52edfb915d6e8ad2c000c801204e387c456/blake3-1.0.9-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a70c20542d5e7960983a0ff32999049a2b0e5ef1f22dbbbdfb51cf04828a4156", size = 344587, upload-time = "2026-06-22T18:00:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/36/9d/be8b1f7f85b12bb45a0fade6ca7bdbf83a507d23d0b6141ba29fe69c8cea/blake3-1.0.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:72cdecf088a9d25e6ec79948a578995649b0dbee407e7a46c543a9ecc0f6f281", size = 328864, upload-time = "2026-06-22T18:00:35.59Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/66580635d744c826671fd219938caffb16281a26f62c4f856695d4233677/blake3-1.0.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42fa57bf462285ef16400601b0fd32214c248ba92505bbb94b1221ab9af5a092", size = 373795, upload-time = "2026-06-22T18:00:36.887Z" }, + { url = "https://files.pythonhosted.org/packages/b1/79/b5b17d3004bb81a5732c0b176c812703d200ed8c652b3b7713b9633bbe10/blake3-1.0.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b25ccde5a64be070f20e5c7a81da70292db40b164b6c77588cbd6230856badbb", size = 374183, upload-time = "2026-06-22T18:00:38.205Z" }, + { url = "https://files.pythonhosted.org/packages/3c/63/0d209c44b2041bbe130ced12a23c92dd995fbfe5bce7ee77fffea16f5cb0/blake3-1.0.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a800b87433955f37691b5f361ad29c7dd3ee089c9cd109adc5aea8e24bc4c1f", size = 446783, upload-time = "2026-06-22T18:00:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/c5/51/efd1f9b8a9d3e9a0e235f3ced99a738529a1019fe78b3988e29d9c2fbba6/blake3-1.0.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6879739e7904b9c42afbedbcc2e8c36cebe140fb3fc3f5c492993579cf5cd516", size = 487369, upload-time = "2026-06-22T18:00:40.875Z" }, + { url = "https://files.pythonhosted.org/packages/8d/3f/a8dcaea9e0b26e419a540ca0cd6203c9fbb505e85b02b03c5a59bf9e6a45/blake3-1.0.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6edeb3d49a24c307995899b70dd47aa901d0e9ad51d2f8a79aba4f074f32d8c5", size = 383845, upload-time = "2026-06-22T18:00:42.251Z" }, + { url = "https://files.pythonhosted.org/packages/f6/10/e9907f5b86410d5071982aaf05d149ca4d4fd8acab7e77eebbc9a333c7b4/blake3-1.0.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcd56a7a972c4185070f7042ccc20166927eec3c0f98b8405f375d007b604a0b", size = 383851, upload-time = "2026-06-22T18:00:43.715Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/c7863a185550706a9624f6aa7b6d46470aaed0bb46a827c5cda2a7d03151/blake3-1.0.9-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:a288664d08dee154cc496e06e62517fc9e655ecec12b0d7db538d244ac79edf1", size = 380067, upload-time = "2026-06-22T18:00:45.249Z" }, + { url = "https://files.pythonhosted.org/packages/54/0a/e7af679c719368b400c9ba9c3460072aac2ba077ddbd4bc806fef28cda03/blake3-1.0.9-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:91db52a809b68b5bebe7c413ddcd230e1f759398e7fa7a873104595a4fa648b6", size = 549471, upload-time = "2026-06-22T18:00:46.793Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3c/37c1dd3539b7bd9b6d2eef019802aacdb4a3d48ab484b140603bbf9c5b5a/blake3-1.0.9-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cfaa671b07eb73883162ca940442193868358b0b904cfa266e4b74131ce966da", size = 591396, upload-time = "2026-06-22T18:00:48.122Z" }, + { url = "https://files.pythonhosted.org/packages/ae/55/4f0a23b72795292e74084834130900ea778c0583004519c86698dfffe1a5/blake3-1.0.9-cp312-cp312-win32.whl", hash = "sha256:ae47c3d5729ff89baa6ddf6de47fcfcc915985d39eb1bfcd6db653331f3c6fcc", size = 229271, upload-time = "2026-06-22T18:00:49.377Z" }, + { url = "https://files.pythonhosted.org/packages/12/91/7db93e4689f0f145bcb954dc62936e5f5090548a9fa20c6bbebfaeaa648a/blake3-1.0.9-cp312-cp312-win_amd64.whl", hash = "sha256:15566065ff90ab3da46ec0be1417406f00507af902b6fb0fbc6563e77f02fc42", size = 218220, upload-time = "2026-06-22T18:00:50.659Z" }, + { url = "https://files.pythonhosted.org/packages/41/1b/95b473d649f5322e69674622a307ffdb4f0b63adb0a0adcbc5cb8a8833c2/blake3-1.0.9-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:69ff5aebc7650954443aa701feff2028d7c7ea5b5e18ee265f15e2104e892328", size = 343869, upload-time = "2026-06-22T18:00:51.936Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9d/adec22c719d8451af1dc9e624bf5907008ef1e0afa51aa69fd1e8c91e60e/blake3-1.0.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0cdfeff65488089ef86f7587c76055ff72b28d28d10e427b547f5711477c376d", size = 328482, upload-time = "2026-06-22T18:00:53.39Z" }, + { url = "https://files.pythonhosted.org/packages/5e/aa/0a6967ff9a6ae182419a681aed54f7338b34a1f71372e90f787a2afa42e6/blake3-1.0.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:766f1555cbe614f14f399c2fbec0983568d20edb36837ba04040807eb9e1a609", size = 373616, upload-time = "2026-06-22T18:00:54.701Z" }, + { url = "https://files.pythonhosted.org/packages/1c/51/5d4e198bf3ae902c6697ad6ec77d7210736ad8f680980e8b648dcfcd09a0/blake3-1.0.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:128a62136c9a39c7cb9fdaa5fb38471f2418853da7f5a89f31495735d0ba6f2c", size = 374149, upload-time = "2026-06-22T18:00:56.015Z" }, + { url = "https://files.pythonhosted.org/packages/7e/62/d3c7c364925b3f10828e5137376f3947f112c32188e899b42f09c2fde98a/blake3-1.0.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d1ea0bf17b184b03444007646d902207d2b4d4f3e91a0cac3836552d83db74b9", size = 446151, upload-time = "2026-06-22T18:00:57.378Z" }, + { url = "https://files.pythonhosted.org/packages/b1/01/55b89389c5036c9d24b1d762d6265e91552e10b76a3c99fece3c4a7a4783/blake3-1.0.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73a48f7e9f0e047f51a445d9b0361ab1907bdc72b6857815a84dacd2e59556f8", size = 487256, upload-time = "2026-06-22T18:00:58.763Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7a/a21b52253292ad3e4df63ea4a01ce11d3ee8f4a8a8d80eaf0c7ce92a62bd/blake3-1.0.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b27550ada40f839aca64c66127940e4318bb6ef3e291890ef913017f6f637448", size = 383977, upload-time = "2026-06-22T18:01:00.192Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f0/fe7188201a29ee9b042616c786a98afd864d537ca96198e64c3fe4ff13a9/blake3-1.0.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66c84dbc2a31eda88b55bbf5c5b711037bf0698eba0fd1faf06bdaf313c39048", size = 383615, upload-time = "2026-06-22T18:01:01.557Z" }, + { url = "https://files.pythonhosted.org/packages/22/08/f6a213b950e30fe9ef7d7fc061ec388e66ed62643570226882e6f7136ea3/blake3-1.0.9-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:dab59b324aa65c09e937d6c43de5de85ec9581627f4e79dcc9806d85b54a1c34", size = 380288, upload-time = "2026-06-22T18:01:03.025Z" }, + { url = "https://files.pythonhosted.org/packages/f1/fb/b171e47c1b835483bcf1545ebc289458165f8dc0f5c7f74a9176d7e9af03/blake3-1.0.9-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:eca281fedcbe5c56655bd5a4176e6036eddbbe57df96114a03838fce08b1e0ca", size = 549122, upload-time = "2026-06-22T18:01:04.486Z" }, + { url = "https://files.pythonhosted.org/packages/a7/d8/7bf71c2c85a0951e406971f151435e0751716907e3924c6c48a2d6dae0db/blake3-1.0.9-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3cbe7f190164896dc3908e920716ee66bc31d40f1a0fb603ed59ac53290fb9cf", size = 591183, upload-time = "2026-06-22T18:01:06.259Z" }, + { url = "https://files.pythonhosted.org/packages/20/85/34c3ea03cc90b2516628494ab3e0a98aec4ca8b04d037840ccd390e480ca/blake3-1.0.9-cp313-cp313-win32.whl", hash = "sha256:508ccaf8f9377cc47e6026c2897fdc37de61faeb1420dc023b6379cc2474eb65", size = 229053, upload-time = "2026-06-22T18:01:07.638Z" }, + { url = "https://files.pythonhosted.org/packages/db/2e/f09e8ed426f360aa2005206466ceab2f707486eb5d9db7051dbcbae056d1/blake3-1.0.9-cp313-cp313-win_amd64.whl", hash = "sha256:caded2806d2cbeed638c5e2517ed8b2a94165b3452fda35e72896142d22070e0", size = 217589, upload-time = "2026-06-22T18:01:08.922Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4b/b2dd7c25378a3b5de30ed908d38e6427bc4c644c0c12e8359361abd3a9ca/blake3-1.0.9-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:ab0c030cf6644c30e786b0e785bde4e4596013ae9ea6ce9877e39d52383e25d7", size = 345406, upload-time = "2026-06-22T18:01:10.311Z" }, + { url = "https://files.pythonhosted.org/packages/c6/dc/c0dab2963ddf04a4a938363f61716f9b75de6d3a9bc4a89e78f0854d4d31/blake3-1.0.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:83b4a2336105af3800f7e17ac4b943f293a3927a2d66a6308d50dba944a6953e", size = 330077, upload-time = "2026-06-22T18:01:11.926Z" }, + { url = "https://files.pythonhosted.org/packages/20/f1/d03950a86d105a6332a8c422cb87658a7d247e214f1ea8f29ed09ff04e00/blake3-1.0.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95fc3545f80901b0dcd0508d16bc40f15ae39556709fa6cf86675f742d4f3c9c", size = 375147, upload-time = "2026-06-22T18:01:13.198Z" }, + { url = "https://files.pythonhosted.org/packages/10/75/711b1842e0a90aaad6a1c9a9022e90aa16206ac1f224516118bc24482532/blake3-1.0.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1bd981dc318c05375c3160a99df493b7cc4c83fffa1a34d14b18a071b47b262b", size = 373711, upload-time = "2026-06-22T18:01:14.606Z" }, + { url = "https://files.pythonhosted.org/packages/9e/a0/f512799d1d0c0b4718fa6f0e99ccbe108e98bac7bf82c200803a62b57876/blake3-1.0.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:689a7e4069de681d9c5d9445b8b6473ee880ad04d7960a6789c60bd788980250", size = 446993, upload-time = "2026-06-22T18:01:15.924Z" }, + { url = "https://files.pythonhosted.org/packages/60/fb/6636ae8a46fc3352694188f5a5a325567782bc88fd1823b0b67be2c92184/blake3-1.0.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8adb0b0032e53919ee95b3d4f911448d3268316c28cd7df232ff2a1e7c9a4ba4", size = 488478, upload-time = "2026-06-22T18:01:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c5/a2b3c086f7e37c9db6017dc2890a76ad2a729e4a554896e855e511811e6b/blake3-1.0.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32bd4521ec2d477627ad93eb70f9ac4d01e12d1489024159bcaeff79466332f6", size = 384900, upload-time = "2026-06-22T18:01:18.802Z" }, + { url = "https://files.pythonhosted.org/packages/e1/b8/1298806dd6c464a6f807df24c9640ad3bf27ee54ff4de82b2b5a823a8aba/blake3-1.0.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f65d77eb05331495485048f6804f53885b192b998acb7e6fe1487d941bf08435", size = 384333, upload-time = "2026-06-22T18:01:20.35Z" }, + { url = "https://files.pythonhosted.org/packages/3c/cc/0c29d9404155adfd6db716e9765d36ea6cbed287060759f5d764f0d9d99e/blake3-1.0.9-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:ca7dfe8fb197ff8a3f5c915424183ccd52a99e8afb12680f51b2e1f4c9c6c97f", size = 381142, upload-time = "2026-06-22T18:01:21.744Z" }, + { url = "https://files.pythonhosted.org/packages/d6/91/9af20d563f0ced71e08a60fc0ee534146da4e265710ed6792d5d799f4c0f/blake3-1.0.9-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:f5c9d57f0dcb92243b6ae575c3065793edc9df9008d0ebd98d8245cdeb7c3f84", size = 550587, upload-time = "2026-06-22T18:01:23.381Z" }, + { url = "https://files.pythonhosted.org/packages/d0/fa/06f46fc0aa486b799d776f9a80ed0b3605e2be1570cf48007860948aa5d9/blake3-1.0.9-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:172d44245a19dfec08ab771c1b7a506b97783163cdc65f559fe020007e403c99", size = 591888, upload-time = "2026-06-22T18:01:24.805Z" }, + { url = "https://files.pythonhosted.org/packages/50/68/d6198f4069a7c4a184ed854df45b82cc3e2d4b0be476b2a3ee65ad2344cf/blake3-1.0.9-cp314-cp314-win32.whl", hash = "sha256:249e5964fa9e768924bc7cc3d4efe75a425bb5dd3fb7671c3eda8eeddfa50591", size = 229410, upload-time = "2026-06-22T18:01:26.24Z" }, + { url = "https://files.pythonhosted.org/packages/63/ab/f29af72a8312b3827b50e55491f1bf9ae2347591de5c47365c5cbd2525a9/blake3-1.0.9-cp314-cp314-win_amd64.whl", hash = "sha256:0aba416bb2e3ef0c65e74d5eba21062483c714cd78e7e303c9d03c547fc7d015", size = 218526, upload-time = "2026-06-22T18:01:27.779Z" }, + { url = "https://files.pythonhosted.org/packages/47/7e/d932fe437ccf656cfba77abc466fb3d1a0ce3c31df92e760d9e4c34932b4/blake3-1.0.9-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5b35abe24a66a7b3db423eb4f8668ed7be1a362aa9c0024ab6483ec0b2c16058", size = 345049, upload-time = "2026-06-22T18:01:29.228Z" }, + { url = "https://files.pythonhosted.org/packages/55/1e/d92fb284fcacf86f5d1083e29d0a8c834b60432786928915238d9760f514/blake3-1.0.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bbdff61e049297ef3180867ce1f079cea7e5b372fd76953c3183da5b8124206", size = 329367, upload-time = "2026-06-22T18:01:30.566Z" }, + { url = "https://files.pythonhosted.org/packages/9d/da/e25fa75d5bfea4527fc21024dde86a9376db798e469a084741968299f215/blake3-1.0.9-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09a69fcedf06785bb81d4d3d39f95ee65dbaf2cb246e174cfc9ff64d027f7551", size = 374203, upload-time = "2026-06-22T18:01:31.998Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4d/0224916202b773dfdf08dcbe4ed1ad1018d4ddcd4df7a7e2978d28f89b74/blake3-1.0.9-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5d5bf0f68cd77108a942c95db98e960d9c3d5643b95172f783822ce22667759", size = 373713, upload-time = "2026-06-22T18:01:33.387Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e5/4ba968831b7afaec431c588c826cef76a96d6d6976188ed07d932072e673/blake3-1.0.9-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9767f16199b99aa022b61ff825ac4dbd39864bf637ae712605a2ce1f8b6a55e0", size = 446574, upload-time = "2026-06-22T18:01:34.687Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f5/08a9099c7177f282d2563abe4f7cc626c636642f7979cf58f2ab7ded2096/blake3-1.0.9-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4865a8cfb2b3d7c0baf5267f2fa6816a3384e836cd1bd0caf359f406cb1e8fba", size = 487232, upload-time = "2026-06-22T18:01:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/76/16/9392bf1ebc81b5b09ce58b94613fa2d37308e825ff2dc7b54d00ee622c77/blake3-1.0.9-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42609e4adc4b2d7423137f2cb35135bca598b925c5af09d2bc0a2c368b25aeb1", size = 384751, upload-time = "2026-06-22T18:01:37.512Z" }, + { url = "https://files.pythonhosted.org/packages/84/fc/b6e9aef02ca14ef62fa47783b9eeeb5b2d3f73fdf698d8bb94c36f5dd69f/blake3-1.0.9-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7f648fa425138452d1e585ac625c7aefddb946d9765906c4c12d564a1523cd8", size = 384546, upload-time = "2026-06-22T18:01:38.868Z" }, + { url = "https://files.pythonhosted.org/packages/ff/cb/452e92dba9402b36a953aa8b9b06253445ccce43dcd0bcf521c5e3c3e15d/blake3-1.0.9-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:9cef6d4d07a7de0c44f5ba17f6383d55276d9efc8d601f75113538fcaa35008b", size = 380596, upload-time = "2026-06-22T18:01:40.412Z" }, + { url = "https://files.pythonhosted.org/packages/b2/01/7a84a7e10c5d14e6ed8a4403bd7f64c1e01f8ebabea0d6fe5f093b894cbd/blake3-1.0.9-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:28404301de485e9546365d01b30f65eaa835520c4211d6ef61242975b6722b60", size = 550032, upload-time = "2026-06-22T18:01:41.955Z" }, + { url = "https://files.pythonhosted.org/packages/58/7d/7aea0222f59cf84044ec52e2bfdaa0e3c355d221292b0ea1b722cf1edd6c/blake3-1.0.9-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:8a99f896e7718050ed033a888245098aab3d6a5338f91cc9450c563b53f90ad5", size = 592244, upload-time = "2026-06-22T18:01:43.426Z" }, + { url = "https://files.pythonhosted.org/packages/c0/e5/b44c230108745ff9c70c7bbafe22563772bc0c22322a8d15c10455f6ca02/blake3-1.0.9-cp314-cp314t-win32.whl", hash = "sha256:021309d760b390706fecf13498f9a25aa8f689bbb65a0896029b8fa223aae18b", size = 229481, upload-time = "2026-06-22T18:01:45.307Z" }, + { url = "https://files.pythonhosted.org/packages/ec/a6/ac03f37dc9aeebf398d42089720648b3bc8438e733d3e522196c5d12ab39/blake3-1.0.9-cp314-cp314t-win_amd64.whl", hash = "sha256:5ea0c60dd9c1e3d05610606579e4bf80f562854c46ed55f9ee8545e18987a480", size = 217979, upload-time = "2026-06-22T18:01:46.629Z" }, +] + [[package]] name = "certifi" version = "2026.2.25" diff --git a/specs/supply-chain/associated-artifact-manifests.md b/specs/supply-chain/associated-artifact-manifests.md new file mode 100644 index 000000000..0aa606a92 --- /dev/null +++ b/specs/supply-chain/associated-artifact-manifests.md @@ -0,0 +1,152 @@ +# Associated Artifact Manifests + +Status: normative + +Decision: [ADR-077](../../docs/decisions/adrs/adr-077-associated-artifact-manifest-boundary.md) + +This specification defines the portable ACES contract for non-semantic +artifacts associated with a scenario, sealed scenario snapshot, or experiment +artifact. The normative machine-readable surface is +`associated-artifact-manifest-v1` under +`contracts/schemas/associated-artifacts/`. + +## 1. Claims and identities + +A conforming implementation MUST keep these claims distinct: + +1. the SDL semantic digest identifies validated, expanded SDL meaning; +2. the associated-artifact set digest identifies one parent reference plus one + exact artifact-reference set; +3. each artifact checksum identifies concrete payload bytes; and +4. authenticity is a separately verified signature/trust-policy result. + +Associated artifacts do not change the semantic SDL digest or the identity of +an experiment task, authoring input, apparatus context, run, or study. + +## 2. Manifest contract + +The manifest is closed and contains: + +- `schema_version`: `associated-artifact-manifest/v1`; +- `manifest_id` and `manifest_version`: stable logical manifest identity; +- `canonicalization_profile`: `associated-artifact-set/v1`; +- `scope`: `scenario` or `experiment`; +- `parent_ref`: one constrained typed reference; +- `artifacts`: a non-empty object keyed by stable local artifact id; and +- `set_digest`: a derived lowercase `sha256:` digest. + +Scenario scope permits only `scenario` and `scenario-snapshot`. A generic +scenario reference is id-only. A snapshot may bind version and the incumbent +canonical SDL digest. Experiment scope permits only `task`, `authoring-input`, +`apparatus-context`, `run`, and `study`. Experiment parent references may bind +id and version but MUST NOT carry a digest or path until that parent family has +a normative canonical payload profile and concrete-payload validator. + +Every artifact entry reuses the `ExperimentArtifactRefModel` shape: stable +`artifact_id`, closed role, media type, absolute non-secret URI, checksum, byte +size, creation time, source assertion, applicable evidence/provenance links, +sensitivity, and optional description. URI is a locator, not integrity +evidence. It MUST NOT contain userinfo or secret-bearing query fields. + +Within one manifest: + +- each object key MUST equal its embedded `artifact_id`; +- artifact ids are opaque, case-sensitive, manifest-local identities, not paths + or content ids; +- duplicate JSON member names MUST be rejected before object construction; +- exact descriptors under different ids are invalid aliases; +- one URI MUST NOT carry conflicting checksum, size, or media-type claims; and +- the same checksum under distinct ids is permitted only when the remaining + descriptors deliberately express distinct roles or locators. + +No attachment is inherited. Scenario attachment does not imply task +attachment; task attachment does not imply authoring-input, apparatus, run, or +study attachment; and study membership does not import member attachments. +Another attachment requires another conforming manifest naming that parent. + +## 3. Set canonicalization + +`associated-artifact-set/v1` serializes this projection with RFC 8785: + +```text +{ + "profile": canonicalization_profile, + "scope": scope, + "parent_ref": exact non-null parent reference fields, + "artifacts": exact keyed non-null artifact descriptor fields +} +``` + +Checksum hex and prefixed parent digests are case-normalized before +canonicalization. Opaque ids, URI strings, descriptions, and other values are +not filesystem- or platform-normalized. SHA-256 of the canonical bytes, +rendered as lowercase `sha256:`, is the set digest. + +The projection excludes `set_digest`, logical manifest id/version, traversal +order, archive metadata, filesystem paths, permissions, symlink targets, +manifest filenames, export tiers, and packaging layout. A parent change or any +canonical artifact-entry change MUST change the set digest. + +## 4. Full conformance and byte binding + +Schema/model validation establishes structural validity only. Full conformance +requires `validate_associated_artifact_manifest()` with: + +- the validated manifest; +- the concrete parent artifact; and +- exactly one concrete byte reader for every artifact id. + +The validator MUST: + +1. match parent kind, id, version, and snapshot digest where applicable; +2. recompute and compare the set digest; +3. reject missing, extra, digest-only, path-only, URI-only, or boolean bindings; +4. stream each reader, recompute checksum and byte size, and compare both; and +5. enforce caller-supplied artifact-count, per-artifact-byte, and total-byte + limits, rejecting declared excess before reading and reading at most the + declared size plus one byte for mismatch detection. + +The validator MUST NOT acquire URIs, traverse directories, extract archives, +resolve credentials, invoke subprocesses, or infer payloads from filenames. +Callers own acquisition and immutable staging. + +Stable error diagnostics include: + +| Condition | Diagnostic code | +|---|---| +| Missing concrete bytes | `associated-artifact.payload-binding-missing` | +| Invalid or digest-only binding | `associated-artifact.payload-binding-invalid` | +| Undeclared binding | `associated-artifact.payload-binding-unexpected` | +| Checksum mismatch | `associated-artifact.payload-checksum-mismatch` | +| Size mismatch | `associated-artifact.payload-size-mismatch` | +| Set digest mismatch | `associated-artifact.set-digest-mismatch` | +| Parent mismatch | `associated-artifact.parent-mismatch` | +| Resource limit exceeded | `associated-artifact.resource-limit-exceeded` | + +Diagnostics MUST be bounded and MUST NOT include payload bytes, full rejected +objects, credentials, environment data, or credential-bearing locators. + +## 5. Trust policy + +The manifest/set is the `associated_artifact_set` reusable-asset family. The +derived set digest is its required `integrity_digest`; concrete payload +verification supplies required `artifact_checksum` evidence. A downstream +signature over the derived set digest may supply `authenticity_signature`, but +integrity alone never proves authenticity, authorization, sensitivity handling, +or entitlement. Associated bytes do not contribute to the parent asset's +integrity mechanism. + +## 6. Packaging and consumer boundary + +Scenario-pack tooling owns filesystem layout, manifest filename, archive/OCI +layout, traversal rules, release tiers, catalog metadata, and safe +materialization. It MUST select a stable byte set before producing this +manifest; a walk over a mutable live directory is not an atomic snapshot. + +Consumers own acquisition, immutable staging, storage, entitlement, atomic +promotion, retention, and use-time verification. They validate the parent and +every staged payload, derive rather than trust the set digest, retain the +manifest with verified bytes, and reverify before use when storage guarantees +do not make that redundant. A caller-supplied package digest may be retained as +untrusted metadata, but cannot become ACES conformance or trust evidence unless +it equals the validator-derived set digest. diff --git a/specs/supply-chain/reusable-asset-trust-integrity.md b/specs/supply-chain/reusable-asset-trust-integrity.md index e8ea52ab8..d6ff4239b 100644 --- a/specs/supply-chain/reusable-asset-trust-integrity.md +++ b/specs/supply-chain/reusable-asset-trust-integrity.md @@ -35,11 +35,11 @@ Trust rests on three orthogonal axes; a policy MUST keep them distinct: | Evidence class | Meaning | Existing ACES mechanism | |---|---|---| -| `integrity_digest` | Digest bound to canonical payload bytes | module `aces.lock.json` digest pins; scenario-snapshot binding; study-definition digest; controlled-vocabulary `source_digest`; manifest/config digests | +| `integrity_digest` | Digest bound to canonical payload bytes | module `aces.lock.json` digest pins; scenario-snapshot binding; associated-artifact set digest; study-definition digest; controlled-vocabulary `source_digest`; manifest/config digests | | `authenticity_signature` | Signature by a trusted signer set | `RegistryTrustPolicy` signature verification (`_verify_signatures`) | | `provenance_lock_record` | Pinned inputs / derivation record | `LockRecord` / `resolve_lock_records`; experiment references pinned by digest; participant provenance | | `governance_source` | Authoritative origin for governed terms | `controlled-vocabularies-v1` `source` (authority + version + extension policy) | -| `artifact_checksum` | Hard checksum over content-artifact bytes | `ExperimentChecksumModel` (evidence records, task/study artifacts) | +| `artifact_checksum` | Hard checksum over content-artifact bytes | `ExperimentChecksumModel` (associated payloads, evidence records, task/study artifacts) | Each requirement declares an `enforcement` level: `required`, `recommended`, or `optional`. @@ -53,7 +53,8 @@ surface external consumers validate against), and a negative conformance fixture that pins the rejection: 1. **Complete family coverage.** The policy MUST declare exactly one entry for - every canonical reusable asset family: `reusable_scenario`, `sdl_module`, + every canonical reusable asset family: `reusable_scenario`, + `associated_artifact_set`, `sdl_module`, `experiment_task`, `experiment_study`, `behavior_vocabulary`, `participant_manifest`, `evidence_artifact`. 2. **Integrity baseline.** Every family MUST declare at least one integrity @@ -83,6 +84,11 @@ Its shape per family: provenance via composed-module lock records (required), authenticity via source-module signatures (recommended). Scenario *identity* stays distinct from scenario-snapshot *integrity*. +- **associated_artifact_set** — integrity via the derived + `associated-artifact-set/v1` parent-plus-reference-set digest (required) and + concrete payload checksums (required); an optional downstream signature over + the derived set digest is independent authenticity evidence. Associated + payloads do not change parent integrity. - **sdl_module** — integrity via lockfile digest pin (required), provenance via lock record with drift checks (required), authenticity via `RegistryTrustPolicy` signatures (required). diff --git a/tools/generate_contract_schemas.py b/tools/generate_contract_schemas.py index d15a5d789..c0cb10708 100644 --- a/tools/generate_contract_schemas.py +++ b/tools/generate_contract_schemas.py @@ -39,6 +39,8 @@ def _schema_output_path(schemas_dir: Path, name: str) -> Path: return schemas_dir / "concept-authority" / f"{name}.json" if name == "reusable-asset-trust-policy-v1": return schemas_dir / "asset-trust" / f"{name}.json" + if name == "associated-artifact-manifest-v1": + return schemas_dir / "associated-artifacts" / f"{name}.json" if name.startswith("semantic-profile-v"): return schemas_dir / "profiles" / f"{name}.json" if name.startswith("backend-profile-v"): From 96bd6af6b9e3f14e8961c506e272a69ea4840453 Mon Sep 17 00:00:00 2001 From: Brad Edwards Date: Sun, 12 Jul 2026 08:41:54 -0700 Subject: [PATCH 15/15] refactor: split oversized modules and cut function complexity (MOD-001) (#743) * refactor: split oversized modules and cut function complexity (MOD-001) The dev->main promotion (PR #740) failed SonarCloud's aces-strict quality gate with 9 new violations: 5 files over the 500-line S104 cap and 4 functions over cyclomatic complexity 10. Split each oversized module along a cohesive seam and decomposed each over-complex function into helpers. Moved symbols are re-imported into their original modules so every public import path and __all__ stays stable. No behaviour change; full nox verify green. Splits: - aces_sdl.parser -> _model_diagnostics (pydantic-error diagnostic rendering) - aces_sdl._runtime_service_families -> _runtime_service_family_registry (registry data) - aces_sdl._yaml_loader -> _mapping_key_analyzer (mapping-key validation walker) - aces_operations._evidence_run_validation -> _evidence_run_realization - aces_operations.libvirt_evidence_run -> _evidence_run_native (EvidenceCheck / LibvirtEvidenceRunConfig relocated to _evidence_run_types to break the cycle) - aces_backend_libvirt.techvault_concerns -> techvault_plan_admission - aces_backend_libvirt.techvault_native -> _techvault_native_ops Complexity reductions (all now <=10): - _evidence_run_realization._validate_unrealized_substrate (11) - _mapping_key_analyzer._walk_mapping_entry (13) - techvault_native.TechVaultNativeLibvirtDriver.__post_init__ (12) - provisioner.LibvirtProvisioner._drive (14) * refactor: make LibvirtProvisioner._active_addresses static (S2325) The extracted _active_addresses helper does not use instance state; SonarCloud flagged python:S2325. Make it a staticmethod (called via the instance in _drive). --- .../_techvault_native_ops.py | 77 +++ .../aces_backend_libvirt/provisioner.py | 113 +++-- .../techvault_concerns.py | 159 ------ .../aces_backend_libvirt/techvault_native.py | 94 ++-- .../techvault_plan_admission.py | 192 ++++++++ .../aces_operations/_evidence_run_native.py | 244 +++++++++ .../_evidence_run_realization.py | 395 +++++++++++++++ .../aces_operations/_evidence_run_types.py | 30 +- .../_evidence_run_validation.py | 377 +------------- .../aces_operations/libvirt_evidence_run.py | 255 +--------- .../aces_sdl/_mapping_key_analyzer.py | 462 ++++++++++++++++++ .../packages/aces_sdl/_model_diagnostics.py | 108 ++++ .../aces_sdl/_runtime_service_families.py | 241 +-------- .../_runtime_service_family_registry.py | 256 ++++++++++ .../python/packages/aces_sdl/_yaml_loader.py | 431 +--------------- .../python/packages/aces_sdl/parser.py | 92 +--- .../python/tests/test_sdl_identifiers.py | 3 +- 17 files changed, 1881 insertions(+), 1648 deletions(-) create mode 100644 implementations/python/packages/aces_backend_libvirt/_techvault_native_ops.py create mode 100644 implementations/python/packages/aces_backend_libvirt/techvault_plan_admission.py create mode 100644 implementations/python/packages/aces_operations/_evidence_run_native.py create mode 100644 implementations/python/packages/aces_operations/_evidence_run_realization.py create mode 100644 implementations/python/packages/aces_sdl/_mapping_key_analyzer.py create mode 100644 implementations/python/packages/aces_sdl/_model_diagnostics.py create mode 100644 implementations/python/packages/aces_sdl/_runtime_service_family_registry.py diff --git a/implementations/python/packages/aces_backend_libvirt/_techvault_native_ops.py b/implementations/python/packages/aces_backend_libvirt/_techvault_native_ops.py new file mode 100644 index 000000000..6d243ac3b --- /dev/null +++ b/implementations/python/packages/aces_backend_libvirt/_techvault_native_ops.py @@ -0,0 +1,77 @@ +"""Diagnostic codes and low-level native-object helpers for the TechVault driver. + +Holds the shared diagnostic-code constants, the native-resource protocol, and the +thin libvirt-call/name-availability/artifact-token/diagnostic helpers used by the +native TechVault driver and its teardown paths. Split from +:mod:`aces_backend_libvirt.techvault_native` to keep that module under the ADR-015 +source-size cap; the driver re-imports these names so existing call sites and the +guest-certified subclass's ``_artifact_token`` import stay stable. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Protocol, cast + +from aces_contracts.diagnostics import Diagnostic, Severity + +from .drivers.libvirt import _aces_uuid, _error_code, _existing_uuid +from .techvault_lifecycle import ( + NativeOwnershipConflict as _OwnershipConflict, +) + +_DOMAIN = "runtime" +_CODE_OPERATION_FAILED = "libvirt-backend.techvault-native.operation-failed" +_CODE_OWNERSHIP_CONFLICT = "libvirt-backend.techvault-native.ownership-conflict" +_CODE_READBACK_FAILED = "libvirt-backend.techvault-native.readback-failed" +_CODE_RESIDUAL_STATE = "libvirt-backend.techvault-native.residual-state" +_CODE_UNAVAILABLE = "libvirt-backend.techvault-native.unavailable" +_DEFAULT_CONNECTION_URI = "qemu:///system" + + +class _NativeResource(Protocol): + def create(self) -> None: ... + + def destroy(self) -> None: ... + + def undefine(self) -> None: ... + + +def _call(connection: object, method_name: str, payload: str) -> _NativeResource: + method = cast(Callable[[str], _NativeResource], getattr(connection, method_name)) + return method(payload) + + +def _ensure_name_available(connection: object, method_name: str, name: str, address: str) -> None: + method = getattr(connection, method_name, None) + if not callable(method): + raise RuntimeError("native lookup is unavailable") + try: + native = method(name) + except KeyError: + return + except Exception as exc: + if _error_code(exc) in {42, 43}: + return + raise + if _existing_uuid(native) != _aces_uuid(address): + raise _OwnershipConflict(address) + raise RuntimeError("owned native object already exists for CREATE") + + +def _artifact_token(address: str) -> str: + return _aces_uuid(address).replace("-", "") + + +_MESSAGES = { + _CODE_UNAVAILABLE: "Libvirt connection is unavailable for native TechVault realization.", + _CODE_RESIDUAL_STATE: "TechVault rollback could not verify cleanup for '{address}'; residual state may remain.", + _CODE_OWNERSHIP_CONFLICT: "Native object for '{address}' is not owned by that ACES address; refusing mutation.", + _CODE_READBACK_FAILED: "Native libvirt TechVault readback for '{address}' did not succeed.", +} +_DEFAULT_MESSAGE = "Native libvirt TechVault operation for '{address}' did not succeed." + + +def _diagnostic(code: str, address: str) -> Diagnostic: + message = _MESSAGES.get(code, _DEFAULT_MESSAGE).format(address=address) + return Diagnostic(code=code, domain=_DOMAIN, address=address, message=message, severity=Severity.ERROR) diff --git a/implementations/python/packages/aces_backend_libvirt/provisioner.py b/implementations/python/packages/aces_backend_libvirt/provisioner.py index b49b73287..0c8081831 100644 --- a/implementations/python/packages/aces_backend_libvirt/provisioner.py +++ b/implementations/python/packages/aces_backend_libvirt/provisioner.py @@ -11,11 +11,12 @@ from aces_contracts.runtime_state import ApplyResult, RuntimeSnapshot, SnapshotEntry from ._payload import NETWORK_RESOURCE_TYPE, NODE_RESOURCE_TYPE -from .driver import DriverResult, LibvirtDriver +from .driver import DomainSpec, DriverResult, LibvirtDriver, NetworkSpec from .envelopes import LibvirtDriverMode, load_libvirt_realization_envelope from .manifest import _provisioner_capabilities from .realization import Realization, interpret_provisioning_plan -from .techvault_concerns import techvault_admission_diagnostics, techvault_observation_diagnostics +from .techvault_concerns import techvault_observation_diagnostics +from .techvault_plan_admission import techvault_admission_diagnostics _DOMAIN = "runtime" INVALID_PLAN_CODE = "libvirt-backend.invalid-plan" @@ -134,57 +135,81 @@ def _drive( delete_networks: list[str], delete_domains: list[str], ) -> list[Diagnostic]: - diagnostics: list[Diagnostic] = [] + active = self._active_addresses(plan, realization) + networks = tuple(spec for spec in realization.networks if spec.address in active) + domains = tuple(spec for spec in realization.domains if spec.address in active) + diagnostics = self._realize_active(networks, domains) + diagnostics.extend(self._delete_targets(delete_networks, delete_domains)) + return diagnostics + + @staticmethod + def _active_addresses(plan: ProvisioningPlan, realization: Realization) -> set[str]: active = {op.address for op in plan.operations if op.action in {ChangeAction.CREATE, ChangeAction.UPDATE}} # A changed placement must realize its target domain even when the node # itself is UNCHANGED: the domain's seed now carries different cloud-init. for placement_address, node_address in realization.placement_targets.items(): if placement_address in active: active.add(node_address) - networks = tuple(spec for spec in realization.networks if spec.address in active) - domains = tuple(spec for spec in realization.domains if spec.address in active) - if networks or domains: - result = self._driver.realize(networks=networks, domains=domains) - realization_diagnostics = [ - *result.diagnostics, - *_unconfirmed_realization_diagnostics( - result, requested=tuple(spec.address for spec in (*networks, *domains)) - ), - ] - diagnostics.extend(realization_diagnostics) - observation_diagnostics: list[Diagnostic] = [] - if self._mode is LibvirtDriverMode.TECHVAULT_APPLIANCE and not result.diagnostics: - observation_diagnostics = techvault_observation_diagnostics( - networks=networks, - domains=domains, - result=result, - ) - diagnostics.extend(observation_diagnostics) - if self._mode is LibvirtDriverMode.TECHVAULT_APPLIANCE and _has_error( - [*realization_diagnostics, *observation_diagnostics] - ): - cleanup = self._driver.destroy( - networks=tuple(spec.address for spec in networks), - domains=tuple(spec.address for spec in domains), - ) - diagnostics.extend(cleanup.diagnostics) - diagnostics.extend( - _unconfirmed_destroy_diagnostics( - cleanup, - requested=tuple(spec.address for spec in (*domains, *networks)), - ) - ) - if delete_networks or delete_domains: - result = self._driver.destroy(networks=tuple(delete_networks), domains=tuple(delete_domains)) - diagnostics.extend(result.diagnostics) - diagnostics.extend( - _unconfirmed_destroy_diagnostics( - result, - requested=(*delete_networks, *delete_domains), - ) + return active + + def _realize_active( + self, + networks: tuple[NetworkSpec, ...], + domains: tuple[DomainSpec, ...], + ) -> list[Diagnostic]: + if not (networks or domains): + return [] + result = self._driver.realize(networks=networks, domains=domains) + realization_diagnostics = [ + *result.diagnostics, + *_unconfirmed_realization_diagnostics( + result, requested=tuple(spec.address for spec in (*networks, *domains)) + ), + ] + diagnostics = list(realization_diagnostics) + observation_diagnostics: list[Diagnostic] = [] + if self._mode is LibvirtDriverMode.TECHVAULT_APPLIANCE and not result.diagnostics: + observation_diagnostics = techvault_observation_diagnostics( + networks=networks, + domains=domains, + result=result, ) + diagnostics.extend(observation_diagnostics) + if self._mode is LibvirtDriverMode.TECHVAULT_APPLIANCE and _has_error( + [*realization_diagnostics, *observation_diagnostics] + ): + diagnostics.extend(self._rollback_realization(networks, domains)) return diagnostics + def _rollback_realization( + self, + networks: tuple[NetworkSpec, ...], + domains: tuple[DomainSpec, ...], + ) -> list[Diagnostic]: + cleanup = self._driver.destroy( + networks=tuple(spec.address for spec in networks), + domains=tuple(spec.address for spec in domains), + ) + return [ + *cleanup.diagnostics, + *_unconfirmed_destroy_diagnostics( + cleanup, + requested=tuple(spec.address for spec in (*domains, *networks)), + ), + ] + + def _delete_targets(self, delete_networks: list[str], delete_domains: list[str]) -> list[Diagnostic]: + if not (delete_networks or delete_domains): + return [] + result = self._driver.destroy(networks=tuple(delete_networks), domains=tuple(delete_domains)) + return [ + *result.diagnostics, + *_unconfirmed_destroy_diagnostics( + result, + requested=(*delete_networks, *delete_domains), + ), + ] + def _identity_diagnostics(self, plan: ProvisioningPlan, snapshot: RuntimeSnapshot) -> list[Diagnostic]: diagnostics: list[Diagnostic] = [] if plan.realization_envelope is None: diff --git a/implementations/python/packages/aces_backend_libvirt/techvault_concerns.py b/implementations/python/packages/aces_backend_libvirt/techvault_concerns.py index 039738161..62c6f0cba 100644 --- a/implementations/python/packages/aces_backend_libvirt/techvault_concerns.py +++ b/implementations/python/packages/aces_backend_libvirt/techvault_concerns.py @@ -4,32 +4,15 @@ import ipaddress import re -from collections.abc import Mapping from aces_contracts.diagnostics import Diagnostic, Severity -from aces_contracts.planning import ChangeAction, ProvisioningPlan, ProvisionOp from aces_contracts.realization_envelope import ( BackendRealizationEnvelopeModel, ObservationStrength, RealizationConcern, ) -from ._payload import ( - ACCOUNT_PLACEMENT_RESOURCE_TYPE, - CONTENT_PLACEMENT_RESOURCE_TYPE, - NETWORK_RESOURCE_TYPE, - NODE_RESOURCE_TYPE, -) from .driver import DomainSpec, DriverResult, NetworkSpec, RealizationObservation -from .realization import ( - _image_ref, - _infrastructure_spec, - _memory_mib, - _node_resources, - _resource_name, - _services, - _vcpus, -) from .techvault_matrix import runtime_name _DOMAIN = "runtime" @@ -46,92 +29,9 @@ _CODE_TRANSACTION_UNSUPPORTED = "libvirt-backend.techvault.transaction-unsupported" _CODE_UPDATE_UNSUPPORTED = "libvirt-backend.techvault.update-unsupported" -_GUEST_PLACEMENTS = frozenset( - { - ACCOUNT_PLACEMENT_RESOURCE_TYPE, - CONTENT_PLACEMENT_RESOURCE_TYPE, - "feature-binding", - } -) - _SAFE_TOKEN_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") -def techvault_admission_diagnostics( - plan: ProvisioningPlan, - envelope: BackendRealizationEnvelopeModel, - *, - name_prefix: str, -) -> list[Diagnostic]: - """Reject every TechVault concern that cannot be applied and observed exactly. - - Direct provisioning-plan submission does not carry compiler-only explicitness - metadata, so each concrete value is binding at this boundary. Validation is - intentionally pure and runs before snapshot reconciliation or driver IO. - """ - - diagnostics = _transaction_diagnostics(plan) - planned_names = _planned_native_names(plan) - for operation in plan.operations: - diagnostics.extend(_operation_admission_diagnostics(operation, envelope)) - diagnostics.extend(_native_name_diagnostics(planned_names, name_prefix)) - return diagnostics - - -def _transaction_diagnostics(plan: ProvisioningPlan) -> list[Diagnostic]: - mutations = [operation for operation in plan.operations if operation.action is not ChangeAction.UNCHANGED] - mixes_delete = len(mutations) > 1 and any(operation.action is ChangeAction.DELETE for operation in mutations) - if not mixes_delete: - return [] - return [ - _diagnostic( - _CODE_TRANSACTION_UNSUPPORTED, - "runtime.libvirt.transaction", - "TechVault plans cannot combine deletion with another mutation without a verified restore path.", - ) - ] - - -def _planned_native_names(plan: ProvisioningPlan) -> list[tuple[str, str]]: - return [ - (operation.address, _resource_name(operation, operation.payload)) - for operation in plan.operations - if operation.action is not ChangeAction.DELETE - and isinstance(operation.payload, Mapping) - and operation.resource_type in {NETWORK_RESOURCE_TYPE, NODE_RESOURCE_TYPE} - ] - - -def _operation_admission_diagnostics( - operation: ProvisionOp, - envelope: BackendRealizationEnvelopeModel, -) -> list[Diagnostic]: - diagnostics: list[Diagnostic] = [] - payload = operation.payload - if operation.action is ChangeAction.UPDATE: - diagnostics.append( - _diagnostic( - _CODE_UPDATE_UNSUPPORTED, - operation.address, - "TechVault appliance updates are not supported without a verified native restore path.", - ) - ) - elif operation.action not in {ChangeAction.DELETE, ChangeAction.UNCHANGED} and isinstance(payload, Mapping): - if operation.resource_type in _GUEST_PLACEMENTS: - diagnostics.append( - _diagnostic( - _CODE_GUEST_PLACEMENT_UNSUPPORTED, - operation.address, - "TechVault appliance guest placements are unsupported and cannot be silently omitted.", - ) - ) - elif operation.resource_type == NODE_RESOURCE_TYPE: - diagnostics.extend(_node_diagnostics(operation.address, payload, envelope)) - elif operation.resource_type == NETWORK_RESOURCE_TYPE: - diagnostics.extend(_network_diagnostics(operation.address, payload)) - return diagnostics - - def techvault_observation_diagnostics( *, networks: tuple[NetworkSpec, ...], @@ -469,64 +369,6 @@ def _observation_key(observation: RealizationObservation) -> tuple[str, str, Rea return observation.address, observation.field_path, observation.concern -def _node_diagnostics( - address: str, - payload: Mapping[str, object], - envelope: BackendRealizationEnvelopeModel, -) -> list[Diagnostic]: - diagnostics: list[Diagnostic] = [] - configuration = envelope.configuration - resources = _node_resources(payload) - memory_mib = _memory_mib(resources.get("ram")) - vcpus = _vcpus(resources.get("cpu")) - if not _within(memory_mib, configuration.memory_mib.minimum, configuration.memory_mib.maximum) or not _within( - vcpus, configuration.vcpus.minimum, configuration.vcpus.maximum - ): - diagnostics.append( - _diagnostic( - _CODE_RESOURCE_OUT_OF_ENVELOPE, - address, - "TechVault appliance resource values must be inside the governed envelope and are never clamped.", - ) - ) - if _image_ref(payload) is not None: - diagnostics.append( - _diagnostic( - _CODE_IMAGE_UNSUPPORTED, - address, - "TechVault appliance mode cannot honor a requested image and refuses image substitution.", - ) - ) - if _services(payload): - diagnostics.append( - _diagnostic( - _CODE_SERVICE_UNSUPPORTED, - address, - "TechVault appliance mode does not realize declared guest services.", - ) - ) - acls = _infrastructure_spec(payload).get("acls") - if isinstance(acls, list | tuple) and acls: - diagnostics.append( - _diagnostic( - _CODE_ACL_UNSUPPORTED, - address, - "TechVault appliance mode does not realize declared network ACLs.", - ) - ) - return diagnostics - - -def _network_diagnostics(address: str, payload: Mapping[str, object]) -> list[Diagnostic]: - properties = _infrastructure_spec(payload).get("properties") - valid = False - if isinstance(properties, Mapping): - valid = isinstance(properties.get("internal"), bool) and _valid_ipv4_network( - properties.get("cidr"), properties.get("gateway") - ) - return [] if valid else [_network_exactness_diagnostic(address)] - - def _valid_ipv4_network(cidr: object, gateway: object) -> bool: if not isinstance(cidr, str) or not isinstance(gateway, str): return False @@ -560,7 +402,6 @@ def _diagnostic(code: str, address: str, message: str) -> Diagnostic: __all__ = [ "guest_certified_spec_diagnostics", - "techvault_admission_diagnostics", "techvault_observation_diagnostics", "techvault_spec_diagnostics", ] diff --git a/implementations/python/packages/aces_backend_libvirt/techvault_native.py b/implementations/python/packages/aces_backend_libvirt/techvault_native.py index 9c9793421..95dcac40b 100644 --- a/implementations/python/packages/aces_backend_libvirt/techvault_native.py +++ b/implementations/python/packages/aces_backend_libvirt/techvault_native.py @@ -8,17 +8,30 @@ from __future__ import annotations -from collections.abc import Callable, Mapping, Sequence +from collections.abc import Mapping, Sequence from contextlib import suppress from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING, ClassVar, Protocol, cast +from typing import TYPE_CHECKING, ClassVar, cast from urllib.parse import urlsplit -from aces_contracts.diagnostics import Diagnostic, Severity +from aces_contracts.diagnostics import Diagnostic from ._techvault_native_helpers import default_connector as _default_connector from ._techvault_native_helpers import default_kernel_path as _default_kernel_path +from ._techvault_native_ops import ( + _CODE_OPERATION_FAILED, + _CODE_OWNERSHIP_CONFLICT, + _CODE_READBACK_FAILED, + _CODE_RESIDUAL_STATE, + _CODE_UNAVAILABLE, + _DEFAULT_CONNECTION_URI, + _artifact_token, + _call, + _diagnostic, + _ensure_name_available, + _NativeResource, +) if TYPE_CHECKING: from aces_contracts.realization_envelope import BackendRealizationEnvelopeModel @@ -31,7 +44,7 @@ NetworkSpec, RealizationObservation, ) -from .drivers.libvirt import Connector, _aces_uuid, _error_code, _existing_uuid +from .drivers.libvirt import Connector, _aces_uuid, _existing_uuid from .envelopes import load_libvirt_realization_envelope from .techvault_appliance import ( BusyboxInitramfsBuilder, @@ -82,13 +95,6 @@ native_soc_readback, ) -_DOMAIN = "runtime" -_CODE_OPERATION_FAILED = "libvirt-backend.techvault-native.operation-failed" -_CODE_OWNERSHIP_CONFLICT = "libvirt-backend.techvault-native.ownership-conflict" -_CODE_READBACK_FAILED = "libvirt-backend.techvault-native.readback-failed" -_CODE_RESIDUAL_STATE = "libvirt-backend.techvault-native.residual-state" -_CODE_UNAVAILABLE = "libvirt-backend.techvault-native.unavailable" -_DEFAULT_CONNECTION_URI = "qemu:///system" __all__ = [ "BusyboxInitramfsBuilder", "NativeLibvirtProbe", @@ -100,14 +106,6 @@ ] -class _NativeResource(Protocol): - def create(self) -> None: ... - - def destroy(self) -> None: ... - - def undefine(self) -> None: ... - - @dataclass class TechVaultNativeLibvirtDriver: """Realize TechVault domains directly as libvirt/QEMU appliances.""" @@ -126,11 +124,23 @@ class TechVaultNativeLibvirtDriver: last_snapshot: dict[str, object] = field(default_factory=dict) def __post_init__(self) -> None: + self._validate_connection_uri() + self._validate_appliance_flags() + self.state_dir = Path(self.state_dir) + self.kernel_path = Path(self.kernel_path) if self.kernel_path is not None else _default_kernel_path() + self.connector = self.connector or _default_connector + self._names: dict[str, str] = {} + self._realized: set[str] = set() + self._artifacts: dict[str, tuple[Path, ...]] = {} + + def _validate_connection_uri(self) -> None: if not self.connection_uri or not self.connection_uri.strip(): raise ValueError("TechVaultNativeLibvirtDriver connection_uri must be non-empty.") parsed_uri = urlsplit(self.connection_uri) if parsed_uri.username is not None or parsed_uri.password is not None: raise ValueError("TechVaultNativeLibvirtDriver connection URI must not carry credentials.") + + def _validate_appliance_flags(self) -> None: if not self.name_prefix or not self.name_prefix.strip(): raise ValueError("TechVaultNativeLibvirtDriver name_prefix must be non-empty.") if self.define_only: @@ -140,12 +150,6 @@ def __post_init__(self) -> None: safe_prefix = _safe_name(self.name_prefix, fallback="aces-techvault", prefix="") if safe_prefix != self.name_prefix: raise ValueError("TechVaultNativeLibvirtDriver name_prefix must already be libvirt-safe.") - self.state_dir = Path(self.state_dir) - self.kernel_path = Path(self.kernel_path) if self.kernel_path is not None else _default_kernel_path() - self.connector = self.connector or _default_connector - self._names: dict[str, str] = {} - self._realized: set[str] = set() - self._artifacts: dict[str, tuple[Path, ...]] = {} def realize( self, @@ -557,43 +561,3 @@ def _try_destroy(self, connection: object, lookup_method: str, address: str) -> return self._destroy_one(connection, lookup_method, address) except Exception: return False - - -def _call(connection: object, method_name: str, payload: str) -> _NativeResource: - method = cast(Callable[[str], _NativeResource], getattr(connection, method_name)) - return method(payload) - - -def _ensure_name_available(connection: object, method_name: str, name: str, address: str) -> None: - method = getattr(connection, method_name, None) - if not callable(method): - raise RuntimeError("native lookup is unavailable") - try: - native = method(name) - except KeyError: - return - except Exception as exc: - if _error_code(exc) in {42, 43}: - return - raise - if _existing_uuid(native) != _aces_uuid(address): - raise _OwnershipConflict(address) - raise RuntimeError("owned native object already exists for CREATE") - - -def _artifact_token(address: str) -> str: - return _aces_uuid(address).replace("-", "") - - -_MESSAGES = { - _CODE_UNAVAILABLE: "Libvirt connection is unavailable for native TechVault realization.", - _CODE_RESIDUAL_STATE: "TechVault rollback could not verify cleanup for '{address}'; residual state may remain.", - _CODE_OWNERSHIP_CONFLICT: "Native object for '{address}' is not owned by that ACES address; refusing mutation.", - _CODE_READBACK_FAILED: "Native libvirt TechVault readback for '{address}' did not succeed.", -} -_DEFAULT_MESSAGE = "Native libvirt TechVault operation for '{address}' did not succeed." - - -def _diagnostic(code: str, address: str) -> Diagnostic: - message = _MESSAGES.get(code, _DEFAULT_MESSAGE).format(address=address) - return Diagnostic(code=code, domain=_DOMAIN, address=address, message=message, severity=Severity.ERROR) diff --git a/implementations/python/packages/aces_backend_libvirt/techvault_plan_admission.py b/implementations/python/packages/aces_backend_libvirt/techvault_plan_admission.py new file mode 100644 index 000000000..b1ea062bd --- /dev/null +++ b/implementations/python/packages/aces_backend_libvirt/techvault_plan_admission.py @@ -0,0 +1,192 @@ +"""Fail-closed provisioning-plan admission for the bounded TechVault appliance. + +This module holds the plan/payload admission path — the checks that run against a +:class:`ProvisioningPlan` and its raw operation payloads before snapshot +reconciliation or driver IO. The spec-path and observation gates live in +:mod:`aces_backend_libvirt.techvault_concerns`, whose shared diagnostic +factories and validators this module reuses. +""" + +from __future__ import annotations + +from collections.abc import Mapping + +from aces_contracts.diagnostics import Diagnostic +from aces_contracts.planning import ChangeAction, ProvisioningPlan, ProvisionOp +from aces_contracts.realization_envelope import BackendRealizationEnvelopeModel + +from ._payload import ( + ACCOUNT_PLACEMENT_RESOURCE_TYPE, + CONTENT_PLACEMENT_RESOURCE_TYPE, + NETWORK_RESOURCE_TYPE, + NODE_RESOURCE_TYPE, +) +from .realization import ( + _image_ref, + _infrastructure_spec, + _memory_mib, + _node_resources, + _resource_name, + _services, + _vcpus, +) +from .techvault_concerns import ( + _CODE_ACL_UNSUPPORTED, + _CODE_GUEST_PLACEMENT_UNSUPPORTED, + _CODE_IMAGE_UNSUPPORTED, + _CODE_RESOURCE_OUT_OF_ENVELOPE, + _CODE_SERVICE_UNSUPPORTED, + _CODE_TRANSACTION_UNSUPPORTED, + _CODE_UPDATE_UNSUPPORTED, + _diagnostic, + _native_name_diagnostics, + _network_exactness_diagnostic, + _valid_ipv4_network, + _within, +) + +_GUEST_PLACEMENTS = frozenset( + { + ACCOUNT_PLACEMENT_RESOURCE_TYPE, + CONTENT_PLACEMENT_RESOURCE_TYPE, + "feature-binding", + } +) + + +def techvault_admission_diagnostics( + plan: ProvisioningPlan, + envelope: BackendRealizationEnvelopeModel, + *, + name_prefix: str, +) -> list[Diagnostic]: + """Reject every TechVault concern that cannot be applied and observed exactly. + + Direct provisioning-plan submission does not carry compiler-only explicitness + metadata, so each concrete value is binding at this boundary. Validation is + intentionally pure and runs before snapshot reconciliation or driver IO. + """ + + diagnostics = _transaction_diagnostics(plan) + planned_names = _planned_native_names(plan) + for operation in plan.operations: + diagnostics.extend(_operation_admission_diagnostics(operation, envelope)) + diagnostics.extend(_native_name_diagnostics(planned_names, name_prefix)) + return diagnostics + + +def _transaction_diagnostics(plan: ProvisioningPlan) -> list[Diagnostic]: + mutations = [operation for operation in plan.operations if operation.action is not ChangeAction.UNCHANGED] + mixes_delete = len(mutations) > 1 and any(operation.action is ChangeAction.DELETE for operation in mutations) + if not mixes_delete: + return [] + return [ + _diagnostic( + _CODE_TRANSACTION_UNSUPPORTED, + "runtime.libvirt.transaction", + "TechVault plans cannot combine deletion with another mutation without a verified restore path.", + ) + ] + + +def _planned_native_names(plan: ProvisioningPlan) -> list[tuple[str, str]]: + return [ + (operation.address, _resource_name(operation, operation.payload)) + for operation in plan.operations + if operation.action is not ChangeAction.DELETE + and isinstance(operation.payload, Mapping) + and operation.resource_type in {NETWORK_RESOURCE_TYPE, NODE_RESOURCE_TYPE} + ] + + +def _operation_admission_diagnostics( + operation: ProvisionOp, + envelope: BackendRealizationEnvelopeModel, +) -> list[Diagnostic]: + diagnostics: list[Diagnostic] = [] + payload = operation.payload + if operation.action is ChangeAction.UPDATE: + diagnostics.append( + _diagnostic( + _CODE_UPDATE_UNSUPPORTED, + operation.address, + "TechVault appliance updates are not supported without a verified native restore path.", + ) + ) + elif operation.action not in {ChangeAction.DELETE, ChangeAction.UNCHANGED} and isinstance(payload, Mapping): + if operation.resource_type in _GUEST_PLACEMENTS: + diagnostics.append( + _diagnostic( + _CODE_GUEST_PLACEMENT_UNSUPPORTED, + operation.address, + "TechVault appliance guest placements are unsupported and cannot be silently omitted.", + ) + ) + elif operation.resource_type == NODE_RESOURCE_TYPE: + diagnostics.extend(_node_diagnostics(operation.address, payload, envelope)) + elif operation.resource_type == NETWORK_RESOURCE_TYPE: + diagnostics.extend(_network_diagnostics(operation.address, payload)) + return diagnostics + + +def _node_diagnostics( + address: str, + payload: Mapping[str, object], + envelope: BackendRealizationEnvelopeModel, +) -> list[Diagnostic]: + diagnostics: list[Diagnostic] = [] + configuration = envelope.configuration + resources = _node_resources(payload) + memory_mib = _memory_mib(resources.get("ram")) + vcpus = _vcpus(resources.get("cpu")) + if not _within(memory_mib, configuration.memory_mib.minimum, configuration.memory_mib.maximum) or not _within( + vcpus, configuration.vcpus.minimum, configuration.vcpus.maximum + ): + diagnostics.append( + _diagnostic( + _CODE_RESOURCE_OUT_OF_ENVELOPE, + address, + "TechVault appliance resource values must be inside the governed envelope and are never clamped.", + ) + ) + if _image_ref(payload) is not None: + diagnostics.append( + _diagnostic( + _CODE_IMAGE_UNSUPPORTED, + address, + "TechVault appliance mode cannot honor a requested image and refuses image substitution.", + ) + ) + if _services(payload): + diagnostics.append( + _diagnostic( + _CODE_SERVICE_UNSUPPORTED, + address, + "TechVault appliance mode does not realize declared guest services.", + ) + ) + acls = _infrastructure_spec(payload).get("acls") + if isinstance(acls, list | tuple) and acls: + diagnostics.append( + _diagnostic( + _CODE_ACL_UNSUPPORTED, + address, + "TechVault appliance mode does not realize declared network ACLs.", + ) + ) + return diagnostics + + +def _network_diagnostics(address: str, payload: Mapping[str, object]) -> list[Diagnostic]: + properties = _infrastructure_spec(payload).get("properties") + valid = False + if isinstance(properties, Mapping): + valid = isinstance(properties.get("internal"), bool) and _valid_ipv4_network( + properties.get("cidr"), properties.get("gateway") + ) + return [] if valid else [_network_exactness_diagnostic(address)] + + +__all__ = [ + "techvault_admission_diagnostics", +] diff --git a/implementations/python/packages/aces_operations/_evidence_run_native.py b/implementations/python/packages/aces_operations/_evidence_run_native.py new file mode 100644 index 000000000..01fa2d4e8 --- /dev/null +++ b/implementations/python/packages/aces_operations/_evidence_run_native.py @@ -0,0 +1,244 @@ +"""Native/guest realization subsystem for the libvirt scenario-evidence producer. + +Only the ``native-live`` / ``guest-certified`` evidence-source modes exercise this +module: it realizes the bounded VM/network substrate through the native libvirt +driver, captures the challenge-bound guest report (guest-certified only), and +verifies teardown/residue on every path. Split from ``libvirt_evidence_run`` to keep +each module under the ADR-015 source-size cap; the producer calls +``_default_native_driver_factory`` and ``_run_native_mode`` from here. +""" + +from __future__ import annotations + +import hashlib +from collections.abc import Callable, Iterable, Mapping +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from aces_backend_libvirt.techvault_native import TechVaultNativeLibvirtDriver +from aces_runtime.control_plane import RuntimeControlPlane + +from aces_operations._evidence_run_types import EvidenceCheck, ExecutionPlan, LibvirtEvidenceRunConfig +from aces_operations._techvault_cleanup import cleanup_native_snapshot + + +def _run_native_mode( + mode: str, + execution_plan: ExecutionPlan, + control_plane: RuntimeControlPlane, + native_driver: TechVaultNativeLibvirtDriver, + driver_factory: Callable[[], TechVaultNativeLibvirtDriver] | None, + checks: list[EvidenceCheck], +) -> tuple[Mapping[str, Any] | None, bool | None, tuple[str, ...], Mapping[str, Any] | None]: + """Realize the native substrate, capture any guest report, and clean up in a finally-path. + + Native-proof boundary: only the default production driver/transport (no injected + factory) yields a certifying guest artifact; injected fakes are marked + non-certifying so their evidence can never be published as a real certification. + """ + native_snapshot: Mapping[str, Any] | None = None + guest_observed: Mapping[str, Any] | None = None + unrealized: tuple[str, ...] = () + try: + native_snapshot, realize_check, unrealized, operation_id = _realize_native_substrate( + execution_plan, control_plane, native_driver + ) + checks.append(realize_check) + if native_snapshot is not None and mode == "guest-certified": + guest_observed = _guest_observed_report(native_driver, operation_id, certifying=driver_factory is None) + finally: + native_cleanup_verified = _append_cleanup_check(native_driver, native_snapshot, checks) + return native_snapshot, native_cleanup_verified, unrealized, guest_observed + + +def _append_cleanup_check( + native_driver: TechVaultNativeLibvirtDriver, native_snapshot: Mapping[str, Any] | None, checks: list[EvidenceCheck] +) -> bool | None: + """Cleanup runs after every attempt; residue on a failed/unrealized run is reported.""" + if native_snapshot is not None: + verified, diagnostics = _verify_native_cleanup(native_driver, native_snapshot) + checks.append(EvidenceCheck("native_substrate_cleanup", verified, diagnostics)) + return verified + residue_ok, residue_diagnostics = _sweep_residue(native_driver) + if not residue_ok: + checks.append(EvidenceCheck("native_substrate_residue", False, residue_diagnostics)) + return None + + +def _default_native_driver_factory( + project_dir: Path, run_id: str, settings: LibvirtEvidenceRunConfig, mode: str +) -> Callable[[], TechVaultNativeLibvirtDriver]: + """Build the default native libvirt driver factory for an operator-run native mode. + + The driver connects to a real libvirt daemon at realize time; ``guest-certified`` + selects the guest-observing driver. In CI/tests a fake driver_factory is injected + instead, so this is never exercised without a daemon. + """ + state_dir = project_dir / "runs" / run_id / "scenario-evidence" / "libvirt" + + def factory() -> TechVaultNativeLibvirtDriver: + if mode == "guest-certified": + from aces_backend_libvirt.guest_certified_driver import GuestCertifiedLibvirtDriver + + return GuestCertifiedLibvirtDriver( + state_dir=state_dir, + connection_uri=settings.connection_uri, + name_prefix="aces-evidence", + ) + return TechVaultNativeLibvirtDriver( + state_dir=state_dir, + connection_uri=settings.connection_uri, + name_prefix="aces-evidence", + ) + + return factory + + +def _guest_observed_report( + native_driver: TechVaultNativeLibvirtDriver, operation_id: str | None, *, certifying: bool +) -> Mapping[str, Any] | None: + """Assemble the operation-joined, challenge-bound guest report from the driver. + + The control-plane operation id and observation timestamp are joined here, at the + operations boundary, rather than inside the backend driver. ``certifying`` records + whether the governed production driver was used; an injected fake driver yields a + non-certifying report that is externally distinguishable from a real proof. + """ + observations = getattr(native_driver, "last_guest_observations", ()) + if not observations: + return None + facts = getattr(native_driver, "last_guest_facts", {}) + binding = getattr(native_driver, "last_guest_binding", {}) + correlations = binding.get("correlations", {}) if isinstance(binding, Mapping) else {} + domains = [ + { + "address": address, + "correlation": correlations.get(address), + "architecture": fact.get("architecture"), + "vcpus": fact.get("vcpus"), + "memory_mib": fact.get("memory_mib"), + "network": list(fact.get("interfaces", ())), + "content": list(fact.get("content", ())), + "accounts": list(fact.get("accounts", ())), + "services": list(fact.get("services", ())), + } + for address, fact in sorted(facts.items()) + if isinstance(fact, Mapping) + ] + return { + # The raw control-plane operation id is a UUID and never portable identity; + # bind a redacted digest instead so the guest report joins the operation + # without leaking the UUID (the redaction gate forbids raw UUIDs). + "operation_ref": _operation_ref(operation_id), + "observed_at": datetime.now(UTC).isoformat(), + "certifying": certifying, + "probe_policy": binding.get("probe_policy") if isinstance(binding, Mapping) else None, + "challenge": binding.get("challenge") if isinstance(binding, Mapping) else None, + "domains": domains, + } + + +def _operation_ref(operation_id: str | None) -> str | None: + if not operation_id: + return None + return "sha256:" + hashlib.sha256(operation_id.encode("utf-8")).hexdigest() + + +def _verify_native_cleanup( + native_driver: TechVaultNativeLibvirtDriver, native_snapshot: Mapping[str, Any] +) -> tuple[bool, tuple[str, ...]]: + """Tear down a realized substrate and verify native + guest-probe cleanup.""" + + verified, diagnostics = cleanup_native_snapshot(native_driver, native_snapshot) + if verified and getattr(native_driver, "last_guest_binding", {}): + return False, (*diagnostics, "guest probe artifacts were not fully cleaned") + return verified, diagnostics + + +def _sweep_residue(native_driver: TechVaultNativeLibvirtDriver) -> tuple[bool, tuple[str, ...]]: + """Best-effort finally-path sweep after a failed/unrealized attempt. + + The driver rolls back on failure, so the common case leaves no residue. Any + remaining realized address, non-empty snapshot, or residual guest binding is a + leak and is reported so the run cannot pass. + """ + clean = ( + not native_driver.realized_addresses() + and native_driver.last_snapshot == {} + and not getattr(native_driver, "last_guest_binding", {}) + ) + if clean: + return True, () + residual = tuple(sorted(native_driver.realized_addresses())) + result = native_driver.destroy(networks=residual, domains=residual) + ok = ( + not result.diagnostics + and not native_driver.realized_addresses() + and native_driver.last_snapshot == {} + and not getattr(native_driver, "last_guest_binding", {}) + ) + if ok: + return True, () + diagnostics = tuple(f"{item.code} at {item.address}" for item in result.diagnostics) + return False, diagnostics or ("residual native or guest state remains after a failed attempt",) + + +def _realize_native_substrate( + execution_plan: ExecutionPlan, + control_plane: RuntimeControlPlane, + native_driver: TechVaultNativeLibvirtDriver | None, +) -> tuple[Mapping[str, Any] | None, EvidenceCheck, tuple[str, ...], str | None]: + """Realize the libvirt provisioning substrate (VMs + networks) for the scenario. + + Native modes pass only when the runtime operation succeeds and the fresh driver + report contains independently daemon-observed domains bound to the selected + realization-envelope/configuration identity. A domain handle or planned matrix + alone is never sufficient. Returns the control-plane operation id so the evidence + producer can join it at this boundary. + """ + if native_driver is None: + return None, EvidenceCheck("native_substrate_realization", False, ("no native driver",)), (), None + try: + receipt = control_plane.submit_provisioning(execution_plan.provisioning) + operation_id = str(receipt.operation_id) + status = control_plane.get_operation(receipt.operation_id) + except Exception: + return None, EvidenceCheck("native_substrate_realization", False, ("native realization failed",)), (), None + unrealized = _dedupe( + f"{d.code}: {d.message}" + for source in (execution_plan.diagnostics, () if status is None else status.diagnostics) + for d in source + if d.is_error + ) + snapshot = native_driver.last_snapshot + operation_succeeded = status is not None and status.state.value == "succeeded" + realized = operation_succeeded and _snapshot_has_daemon_observations(snapshot) + check = EvidenceCheck( + "native_substrate_realization", + realized, + () + if realized + else ("libvirt backend realized no native substrate for this scenario; capabilities disclosed as unrealized",), + ) + return (snapshot if realized else None), check, unrealized, operation_id + + +def _dedupe(items: Iterable[str]) -> tuple[str, ...]: + seen: dict[str, None] = {} + for item in items: + seen.setdefault(item, None) + return tuple(seen) + + +def _snapshot_has_daemon_observations(snapshot: Mapping[str, Any] | None) -> bool: + if not isinstance(snapshot, Mapping): + return False + domains = snapshot.get("domains", ()) + binding = snapshot.get("binding") + return ( + snapshot.get("source") == "daemon-observed" + and isinstance(domains, list | tuple) + and len(domains) > 0 + and isinstance(binding, Mapping) + ) diff --git a/implementations/python/packages/aces_operations/_evidence_run_realization.py b/implementations/python/packages/aces_operations/_evidence_run_realization.py new file mode 100644 index 000000000..2958d60a8 --- /dev/null +++ b/implementations/python/packages/aces_operations/_evidence_run_realization.py @@ -0,0 +1,395 @@ +"""Realization-source validation for the libvirt scenario-evidence artifact. + +Verifies that the artifact's realization facts, topology, daemon/guest +observations, and realization binding declare only admitted observation bases and +stay mutually consistent. Split from ``_evidence_run_validation`` to keep each +module under the ADR-015 source-size cap; the top-level validator imports +``_validate_realization_sources`` from here. +""" + +from __future__ import annotations + +import hashlib +import json +import re +from collections.abc import Mapping +from typing import Any + +_SHA256_RE = re.compile(r"sha256:[a-f0-9]{64}") +_DAEMON_REQUIRED_FIELDS = { + "domains": { + "address", + "name", + "architecture", + "image_policy", + "memory_mib", + "vcpus", + "network_attachments", + "observation_source", + }, + "networks": { + "address", + "name", + "cidr", + "gateway", + "internal", + "forward_mode", + "observation_source", + }, +} + + +def _validate_realization_sources(payload: Mapping[str, Any]) -> list[str]: + problems: list[str] = [] + if "native-realized" in json.dumps(payload, sort_keys=True, default=str): + problems.append("realization source violation: native-realized is not an admitted observation basis") + + facts = payload.get("realization_facts", {}) + if not isinstance(facts, Mapping): + return [*problems, "realization_facts must be a mapping"] + problems.extend(_validate_fact_sources(facts)) + topology = payload.get("realized_topology", {}) + problems.extend(_validate_topology_sources(topology)) + backend = payload.get("backend", {}) + provenance = backend.get("realization_provenance", {}) if isinstance(backend, Mapping) else {} + substrate_realized = isinstance(provenance, Mapping) and provenance.get("substrate_realized") is True + cleanup = facts.get("cleanup") + problems.extend(_validate_cleanup_source(cleanup)) + if substrate_realized: + problems.extend(_validate_realized_substrate(backend, facts, topology, provenance, cleanup)) + elif isinstance(provenance, Mapping) and provenance.get("basis") != "planned-not-realized": + problems.append("unrealized substrate basis must be planned-not-realized") + else: + problems.extend(_validate_unrealized_substrate(facts, provenance, cleanup)) + problems.extend(_validate_guest_observation_boundary(payload)) + problems.extend(_validate_guest_observations(facts)) + return problems + + +def _validate_guest_observations(facts: Mapping[str, Any]) -> list[str]: + """Validate the guest-observed fact section when a guest report is present. + + A daemon-only run carries ``{"source": "guest-observed", "status": "not-observed"}`` + and is skipped here. A guest-certified run must bind every observed domain to the + control-plane operation, a fresh challenge, a canonical native correlation, and a + daemon-observed domain (rejecting unjoined or cross-operation evidence). + """ + + guest = facts.get("guest_observed") + if not isinstance(guest, Mapping) or guest.get("status") == "not-observed": + return [] + problems = _validate_guest_metadata(guest) + domains = guest.get("domains") + if not isinstance(domains, list | tuple) or not domains: + return [*problems, "guest observation requires at least one observed domain"] + daemon_addresses = _daemon_domain_addresses(facts) + for item in domains: + problems.extend(_validate_guest_domain(item, daemon_addresses)) + return problems + + +_GUEST_METADATA_FIELDS = ( + ("observation timestamp", "observed_at"), + ("probe policy", "probe_policy"), + ("fresh challenge", "challenge"), +) + + +def _validate_guest_metadata(guest: Mapping[str, Any]) -> list[str]: + problems: list[str] = [] + if not _is_canonical_sha256(guest.get("operation_ref")): + problems.append("guest observation requires a canonical operation reference") + if not isinstance(guest.get("certifying"), bool): + problems.append("guest observation requires an explicit certifying flag") + problems.extend( + f"guest observation requires a {label}" + for label, field_name in _GUEST_METADATA_FIELDS + if not _nonempty_string(guest.get(field_name)) + ) + return problems + + +def _daemon_domain_addresses(facts: Mapping[str, Any]) -> set[object]: + daemon = facts.get("daemon_observed", {}) + domains = daemon.get("domains", ()) if isinstance(daemon, Mapping) else () + return {item.get("address") for item in domains if isinstance(item, Mapping)} + + +_GUEST_DOMAIN_FIELDS = ("architecture", "vcpus", "memory_mib", "network", "content", "accounts", "services") + + +def _validate_guest_domain(item: object, daemon_addresses: set[object]) -> list[str]: + if not isinstance(item, Mapping): + return ["guest observation domain must be a mapping"] + problems: list[str] = [] + if not _is_canonical_sha256(item.get("correlation")): + problems.append("guest observation requires a canonical native correlation") + if item.get("address") not in daemon_addresses: + problems.append("guest observation is not joined to a daemon-observed domain") + problems.extend( + f"guest observation domain missing {field_name}" + for field_name in _GUEST_DOMAIN_FIELDS + if field_name not in item + ) + return problems + + +def _validate_fact_sources(facts: Mapping[str, Any]) -> list[str]: + expected_sources = { + "authored": "authored", + "planned": "planned", + "driver_reported": "driver-reported", + "daemon_observed": "daemon-observed", + "guest_observed": "guest-observed", + } + return [ + f"realization source violation: {key}.source must be {source!r}" + for key, source in expected_sources.items() + if not isinstance(facts.get(key), Mapping) or facts[key].get("source") != source + ] + + +def _validate_topology_sources(topology: object) -> list[str]: + if not isinstance(topology, Mapping): + return [] + problems: list[str] = [] + if topology.get("basis") not in {"planned", "mixed-source"}: + problems.append("realized_topology.basis must be planned or mixed-source") + for collection in ("nodes", "networks"): + for item in topology.get(collection, ()) or (): + if isinstance(item, Mapping) and item.get("source") != "planned": + problems.append(f"realization source violation: realized_topology.{collection} is planned") + native_surface = topology.get("native_surface") + if isinstance(native_surface, Mapping) and native_surface.get("source") != "daemon-observed": + problems.append("realization source violation: native_surface must be daemon-observed") + return problems + + +def _validate_cleanup_source(cleanup: object) -> list[str]: + if isinstance(cleanup, Mapping) and cleanup.get("source") == "driver-reported": + return [] + return ["realization cleanup must be driver-reported"] + + +def _validate_realized_substrate( + backend: object, + facts: Mapping[str, Any], + topology: object, + provenance: Mapping[str, Any], + cleanup: object, +) -> list[str]: + daemon = facts.get("daemon_observed", {}) + daemon_items = _daemon_items(daemon) + problems = _validate_realized_provenance(provenance, cleanup) + problems.extend(_validate_daemon_observations(daemon)) + problems.extend(_validate_reported_addresses(facts, daemon_items)) + problems.extend(_validate_native_surface(topology, daemon)) + if isinstance(backend, Mapping): + problems.extend(_validate_realization_binding(backend, facts)) + else: + problems.append("daemon-observed substrate requires a realization binding") + return problems + + +def _validate_realized_provenance(provenance: Mapping[str, Any], cleanup: object) -> list[str]: + problems: list[str] = [] + if provenance.get("basis") != "daemon-observed-substrate": + problems.append("realization provenance basis must be daemon-observed-substrate") + cleanup_verified = provenance.get("cleanup_verified") + expected_cleanup_status = "verified" if cleanup_verified is True else "failed" + cleanup_consistent = ( + isinstance(cleanup_verified, bool) + and isinstance(cleanup, Mapping) + and cleanup.get("status") == expected_cleanup_status + ) + if not cleanup_consistent: + problems.append("daemon-observed substrate requires a consistent cleanup outcome") + return problems + + +def _validate_daemon_observations(daemon: object) -> list[str]: + problems: list[str] = [] + domains = daemon.get("domains", ()) if isinstance(daemon, Mapping) else () + if not isinstance(domains, list | tuple) or not domains: + problems.append("daemon-observed substrate requires at least one observed domain") + for collection in ("domains", "networks"): + values = daemon.get(collection, ()) if isinstance(daemon, Mapping) else () + for item in values: + if isinstance(item, Mapping): + if item.get("observation_source") != "daemon-observed": + problems.append(f"realization source violation: daemon_observed.{collection} item source") + problems.extend(_validate_daemon_observation_item(collection, item)) + return problems + + +def _daemon_items(daemon: object) -> list[Mapping[str, Any]]: + if not isinstance(daemon, Mapping): + return [] + return [ + item + for collection in ("domains", "networks") + for item in daemon.get(collection, ()) + if isinstance(item, Mapping) + ] + + +def _validate_reported_addresses( + facts: Mapping[str, Any], + daemon_items: list[Mapping[str, Any]], +) -> list[str]: + observed_addresses = {item.get("address") for item in daemon_items} + driver_reported = facts.get("driver_reported", {}) + reported_addresses = driver_reported.get("realized_addresses", ()) if isinstance(driver_reported, Mapping) else () + valid = ( + isinstance(reported_addresses, list | tuple) + and all(isinstance(item, str) for item in reported_addresses) + and set(reported_addresses) == observed_addresses + ) + return [] if valid else ["driver-reported addresses do not match daemon observations"] + + +def _validate_native_surface(topology: object, daemon: object) -> list[str]: + native_surface = topology.get("native_surface") if isinstance(topology, Mapping) else None + if not isinstance(native_surface, Mapping): + return ["daemon-observed substrate requires a native surface"] + problems: list[str] = [] + for collection in ("domains", "networks"): + observed_names = sorted( + str(item.get("name")) + for item in (daemon.get(collection, ()) if isinstance(daemon, Mapping) else ()) + if isinstance(item, Mapping) + ) + surface_names = native_surface.get(collection, ()) + if not isinstance(surface_names, list | tuple) or sorted(str(item) for item in surface_names) != observed_names: + problems.append(f"native surface {collection} do not match daemon observations") + return problems + + +def _validate_unrealized_substrate( + facts: Mapping[str, Any], + provenance: Mapping[str, Any], + cleanup: object, +) -> list[str]: + problems = _unrealized_daemon_leak(facts) + guest = facts.get("guest_observed") + if isinstance(guest, Mapping) and guest.get("status") != "not-observed": + problems.append("unrealized substrate cannot publish guest observations") + if provenance.get("cleanup_verified") is not None: + problems.append("unrealized substrate cleanup must be not-applicable") + if isinstance(cleanup, Mapping) and cleanup.get("status") != "not-required": + problems.append("unrealized substrate cleanup status must be not-required") + return problems + + +def _unrealized_daemon_leak(facts: Mapping[str, Any]) -> list[str]: + daemon = facts.get("daemon_observed", {}) + daemon_domains = daemon.get("domains", ()) if isinstance(daemon, Mapping) else () + daemon_networks = daemon.get("networks", ()) if isinstance(daemon, Mapping) else () + if daemon_domains or daemon_networks or facts.get("binding") is not None: + return ["unrealized substrate cannot publish daemon observations or realization binding"] + return [] + + +def _validate_guest_observation_boundary(payload: Mapping[str, Any]) -> list[str]: + defensive = payload.get("defensive_evidence", {}) + if isinstance(defensive, Mapping) and "soc_readback" in defensive: + return ["guest observation violation: daemon substrate cannot supply SOC readback"] + return [] + + +def _validate_realization_binding(backend: Mapping[str, Any], facts: Mapping[str, Any]) -> list[str]: + binding = facts.get("binding") + manifest = backend.get("manifest", {}) + envelope = manifest.get("realization_envelope", {}) if isinstance(manifest, Mapping) else {} + if not isinstance(binding, Mapping) or not isinstance(envelope, Mapping): + return ["daemon-observed substrate requires a realization binding"] + problems = _validate_binding_identity(binding, envelope) + problems.extend(_validate_boot_artifact_binding(binding)) + expected_driver_digest = _driver_configuration_digest(binding) + if binding.get("driver_configuration_digest") != expected_driver_digest: + problems.append("realization binding driver configuration digest does not match its material") + return problems + + +def _validate_binding_identity(binding: Mapping[str, Any], envelope: Mapping[str, Any]) -> list[str]: + problems: list[str] = [] + if binding.get("realization_envelope_digest") != envelope.get("digest"): + problems.append("realization binding envelope digest does not match backend manifest") + if binding.get("configuration_digest") != envelope.get("configuration_digest"): + problems.append("realization binding configuration digest does not match backend manifest") + driver_digest = binding.get("driver_configuration_digest") + if not _is_canonical_sha256(driver_digest): + problems.append("realization binding requires a canonical driver configuration digest") + if binding.get("driver") not in {"techvault-appliance", "guest-certified-appliance"}: + problems.append("realization binding driver does not match a governed appliance mode") + for field_name in ("connection_uri_digest", "name_prefix_digest"): + if not _is_canonical_sha256(binding.get(field_name)): + problems.append(f"realization binding requires canonical {field_name}") + return problems + + +def _validate_boot_artifact_binding(binding: Mapping[str, Any]) -> list[str]: + problems: list[str] = [] + boot_artifacts = binding.get("boot_artifact_digests") + if not isinstance(boot_artifacts, Mapping) or set(boot_artifacts) != {"kernel", "initramfs"}: + problems.append("realization binding requires kernel and initramfs artifact digests") + elif not all(_is_canonical_sha256(value) for value in boot_artifacts.values()): + problems.append("realization binding boot artifact digests must be canonical sha256 values") + return problems + + +def _driver_configuration_digest(binding: Mapping[str, Any]) -> str: + material = { + "driver": binding.get("driver"), + "configuration_digest": binding.get("configuration_digest"), + "boot_artifact_digests": binding.get("boot_artifact_digests"), + "connection_uri_digest": binding.get("connection_uri_digest"), + "name_prefix_digest": binding.get("name_prefix_digest"), + } + encoded = json.dumps(material, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8") + return "sha256:" + hashlib.sha256(encoded).hexdigest() + + +def _is_canonical_sha256(value: object) -> bool: + return isinstance(value, str) and _SHA256_RE.fullmatch(value) is not None + + +def _validate_daemon_observation_item(collection: str, item: Mapping[str, Any]) -> list[str]: + noun = "domain" if collection == "domains" else "network" + problems: list[str] = [] + if set(item) != _DAEMON_REQUIRED_FIELDS[collection] or not _valid_observation_identity(item): + problems.append(f"incomplete daemon {noun} observation") + elif collection == "domains" and not _valid_domain_observation(item): + problems.append("incomplete daemon domain observation") + elif collection == "networks" and not _valid_network_observation(item): + problems.append("incomplete daemon network observation") + return problems + + +def _valid_observation_identity(item: Mapping[str, Any]) -> bool: + return all(_nonempty_string(item.get(key)) for key in ("address", "name")) + + +def _valid_domain_observation(item: Mapping[str, Any]) -> bool: + checks = ( + _nonempty_string(item.get("architecture")), + _nonempty_string(item.get("image_policy")), + isinstance(item.get("memory_mib"), int) and item["memory_mib"] > 0, + isinstance(item.get("vcpus"), int) and item["vcpus"] > 0, + isinstance(item.get("network_attachments"), list | tuple), + ) + return all(checks) + + +def _valid_network_observation(item: Mapping[str, Any]) -> bool: + checks = ( + _nonempty_string(item.get("cidr")), + _nonempty_string(item.get("gateway")), + isinstance(item.get("internal"), bool), + item.get("forward_mode") in {"none", "nat"}, + ) + return all(checks) + + +def _nonempty_string(value: object) -> bool: + return isinstance(value, str) and bool(value) diff --git a/implementations/python/packages/aces_operations/_evidence_run_types.py b/implementations/python/packages/aces_operations/_evidence_run_types.py index d9c38889f..d8bc8a0c5 100644 --- a/implementations/python/packages/aces_operations/_evidence_run_types.py +++ b/implementations/python/packages/aces_operations/_evidence_run_types.py @@ -17,7 +17,7 @@ from collections.abc import Mapping, Sequence from dataclasses import dataclass from pathlib import Path -from typing import Any, Protocol +from typing import Any, Literal, Protocol from aces_backend_protocols.capabilities import BackendManifest @@ -26,7 +26,10 @@ "BackendManifest", "CompiledModel", "EvidenceArtifactInputs", + "EvidenceCheck", + "EvidenceSourceMode", "ExecutionPlan", + "LibvirtEvidenceRunConfig", "NodeDeployment", "ObservationBoundary", "ParticipantBehavior", @@ -34,6 +37,31 @@ "TerminalSnapshot", ] +EvidenceSourceMode = Literal["deterministic", "native-live", "guest-certified"] + + +@dataclass(frozen=True) +class EvidenceCheck: + """One named check over the scenario-evidence production run. + + Every check is gating: it contributes to ``LibvirtEvidenceRunReport.passed``. + There is deliberately no non-gating escape hatch — in particular, a native-live + run that fails to realize the libvirt substrate must report ``passed=False`` so + the mode can never claim success without actually realizing. + """ + + name: str + passed: bool + diagnostics: tuple[str, ...] = () + + +@dataclass(frozen=True) +class LibvirtEvidenceRunConfig: + """Runtime controls for the libvirt scenario-evidence producer.""" + + evidence_source_mode: EvidenceSourceMode = "deterministic" + connection_uri: str = "qemu:///system" + class ActionContract(Protocol): """Compiled action contract surface read by the proof builder.""" diff --git a/implementations/python/packages/aces_operations/_evidence_run_validation.py b/implementations/python/packages/aces_operations/_evidence_run_validation.py index b42d22518..8951f2d0e 100644 --- a/implementations/python/packages/aces_operations/_evidence_run_validation.py +++ b/implementations/python/packages/aces_operations/_evidence_run_validation.py @@ -9,7 +9,6 @@ from __future__ import annotations -import hashlib import json import re from collections.abc import Mapping @@ -24,6 +23,7 @@ from pydantic import BaseModel from aces_operations._evidence_run_artifact import EVIDENCE_RUN_SCHEMA +from aces_operations._evidence_run_realization import _validate_realization_sources # Redaction gate: substrings/patterns that must never appear in the artifact. _FORBIDDEN_REDACTION_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( @@ -62,29 +62,6 @@ "invariant_ledger_refs", ) -_SHA256_RE = re.compile(r"sha256:[a-f0-9]{64}") -_DAEMON_REQUIRED_FIELDS = { - "domains": { - "address", - "name", - "architecture", - "image_policy", - "memory_mib", - "vcpus", - "network_attachments", - "observation_source", - }, - "networks": { - "address", - "name", - "cidr", - "gateway", - "internal", - "forward_mode", - "observation_source", - }, -} - def validate_libvirt_evidence_run_artifact(payload: Mapping[str, Any]) -> list[str]: """Validate a scenario-evidence artifact: schema, required surfaces, embedded contracts, redaction, boundary. @@ -106,358 +83,6 @@ def validate_libvirt_evidence_run_artifact(payload: Mapping[str, Any]) -> list[s return problems -def _validate_realization_sources(payload: Mapping[str, Any]) -> list[str]: - problems: list[str] = [] - if "native-realized" in json.dumps(payload, sort_keys=True, default=str): - problems.append("realization source violation: native-realized is not an admitted observation basis") - - facts = payload.get("realization_facts", {}) - if not isinstance(facts, Mapping): - return [*problems, "realization_facts must be a mapping"] - problems.extend(_validate_fact_sources(facts)) - topology = payload.get("realized_topology", {}) - problems.extend(_validate_topology_sources(topology)) - backend = payload.get("backend", {}) - provenance = backend.get("realization_provenance", {}) if isinstance(backend, Mapping) else {} - substrate_realized = isinstance(provenance, Mapping) and provenance.get("substrate_realized") is True - cleanup = facts.get("cleanup") - problems.extend(_validate_cleanup_source(cleanup)) - if substrate_realized: - problems.extend(_validate_realized_substrate(backend, facts, topology, provenance, cleanup)) - elif isinstance(provenance, Mapping) and provenance.get("basis") != "planned-not-realized": - problems.append("unrealized substrate basis must be planned-not-realized") - else: - problems.extend(_validate_unrealized_substrate(facts, provenance, cleanup)) - problems.extend(_validate_guest_observation_boundary(payload)) - problems.extend(_validate_guest_observations(facts)) - return problems - - -def _validate_guest_observations(facts: Mapping[str, Any]) -> list[str]: - """Validate the guest-observed fact section when a guest report is present. - - A daemon-only run carries ``{"source": "guest-observed", "status": "not-observed"}`` - and is skipped here. A guest-certified run must bind every observed domain to the - control-plane operation, a fresh challenge, a canonical native correlation, and a - daemon-observed domain (rejecting unjoined or cross-operation evidence). - """ - - guest = facts.get("guest_observed") - if not isinstance(guest, Mapping) or guest.get("status") == "not-observed": - return [] - problems = _validate_guest_metadata(guest) - domains = guest.get("domains") - if not isinstance(domains, list | tuple) or not domains: - return [*problems, "guest observation requires at least one observed domain"] - daemon_addresses = _daemon_domain_addresses(facts) - for item in domains: - problems.extend(_validate_guest_domain(item, daemon_addresses)) - return problems - - -_GUEST_METADATA_FIELDS = ( - ("observation timestamp", "observed_at"), - ("probe policy", "probe_policy"), - ("fresh challenge", "challenge"), -) - - -def _validate_guest_metadata(guest: Mapping[str, Any]) -> list[str]: - problems: list[str] = [] - if not _is_canonical_sha256(guest.get("operation_ref")): - problems.append("guest observation requires a canonical operation reference") - if not isinstance(guest.get("certifying"), bool): - problems.append("guest observation requires an explicit certifying flag") - problems.extend( - f"guest observation requires a {label}" - for label, field_name in _GUEST_METADATA_FIELDS - if not _nonempty_string(guest.get(field_name)) - ) - return problems - - -def _daemon_domain_addresses(facts: Mapping[str, Any]) -> set[object]: - daemon = facts.get("daemon_observed", {}) - domains = daemon.get("domains", ()) if isinstance(daemon, Mapping) else () - return {item.get("address") for item in domains if isinstance(item, Mapping)} - - -_GUEST_DOMAIN_FIELDS = ("architecture", "vcpus", "memory_mib", "network", "content", "accounts", "services") - - -def _validate_guest_domain(item: object, daemon_addresses: set[object]) -> list[str]: - if not isinstance(item, Mapping): - return ["guest observation domain must be a mapping"] - problems: list[str] = [] - if not _is_canonical_sha256(item.get("correlation")): - problems.append("guest observation requires a canonical native correlation") - if item.get("address") not in daemon_addresses: - problems.append("guest observation is not joined to a daemon-observed domain") - problems.extend( - f"guest observation domain missing {field_name}" - for field_name in _GUEST_DOMAIN_FIELDS - if field_name not in item - ) - return problems - - -def _validate_fact_sources(facts: Mapping[str, Any]) -> list[str]: - expected_sources = { - "authored": "authored", - "planned": "planned", - "driver_reported": "driver-reported", - "daemon_observed": "daemon-observed", - "guest_observed": "guest-observed", - } - return [ - f"realization source violation: {key}.source must be {source!r}" - for key, source in expected_sources.items() - if not isinstance(facts.get(key), Mapping) or facts[key].get("source") != source - ] - - -def _validate_topology_sources(topology: object) -> list[str]: - if not isinstance(topology, Mapping): - return [] - problems: list[str] = [] - if topology.get("basis") not in {"planned", "mixed-source"}: - problems.append("realized_topology.basis must be planned or mixed-source") - for collection in ("nodes", "networks"): - for item in topology.get(collection, ()) or (): - if isinstance(item, Mapping) and item.get("source") != "planned": - problems.append(f"realization source violation: realized_topology.{collection} is planned") - native_surface = topology.get("native_surface") - if isinstance(native_surface, Mapping) and native_surface.get("source") != "daemon-observed": - problems.append("realization source violation: native_surface must be daemon-observed") - return problems - - -def _validate_cleanup_source(cleanup: object) -> list[str]: - if isinstance(cleanup, Mapping) and cleanup.get("source") == "driver-reported": - return [] - return ["realization cleanup must be driver-reported"] - - -def _validate_realized_substrate( - backend: object, - facts: Mapping[str, Any], - topology: object, - provenance: Mapping[str, Any], - cleanup: object, -) -> list[str]: - daemon = facts.get("daemon_observed", {}) - daemon_items = _daemon_items(daemon) - problems = _validate_realized_provenance(provenance, cleanup) - problems.extend(_validate_daemon_observations(daemon)) - problems.extend(_validate_reported_addresses(facts, daemon_items)) - problems.extend(_validate_native_surface(topology, daemon)) - if isinstance(backend, Mapping): - problems.extend(_validate_realization_binding(backend, facts)) - else: - problems.append("daemon-observed substrate requires a realization binding") - return problems - - -def _validate_realized_provenance(provenance: Mapping[str, Any], cleanup: object) -> list[str]: - problems: list[str] = [] - if provenance.get("basis") != "daemon-observed-substrate": - problems.append("realization provenance basis must be daemon-observed-substrate") - cleanup_verified = provenance.get("cleanup_verified") - expected_cleanup_status = "verified" if cleanup_verified is True else "failed" - cleanup_consistent = ( - isinstance(cleanup_verified, bool) - and isinstance(cleanup, Mapping) - and cleanup.get("status") == expected_cleanup_status - ) - if not cleanup_consistent: - problems.append("daemon-observed substrate requires a consistent cleanup outcome") - return problems - - -def _validate_daemon_observations(daemon: object) -> list[str]: - problems: list[str] = [] - domains = daemon.get("domains", ()) if isinstance(daemon, Mapping) else () - if not isinstance(domains, list | tuple) or not domains: - problems.append("daemon-observed substrate requires at least one observed domain") - for collection in ("domains", "networks"): - values = daemon.get(collection, ()) if isinstance(daemon, Mapping) else () - for item in values: - if isinstance(item, Mapping): - if item.get("observation_source") != "daemon-observed": - problems.append(f"realization source violation: daemon_observed.{collection} item source") - problems.extend(_validate_daemon_observation_item(collection, item)) - return problems - - -def _daemon_items(daemon: object) -> list[Mapping[str, Any]]: - if not isinstance(daemon, Mapping): - return [] - return [ - item - for collection in ("domains", "networks") - for item in daemon.get(collection, ()) - if isinstance(item, Mapping) - ] - - -def _validate_reported_addresses( - facts: Mapping[str, Any], - daemon_items: list[Mapping[str, Any]], -) -> list[str]: - observed_addresses = {item.get("address") for item in daemon_items} - driver_reported = facts.get("driver_reported", {}) - reported_addresses = driver_reported.get("realized_addresses", ()) if isinstance(driver_reported, Mapping) else () - valid = ( - isinstance(reported_addresses, list | tuple) - and all(isinstance(item, str) for item in reported_addresses) - and set(reported_addresses) == observed_addresses - ) - return [] if valid else ["driver-reported addresses do not match daemon observations"] - - -def _validate_native_surface(topology: object, daemon: object) -> list[str]: - native_surface = topology.get("native_surface") if isinstance(topology, Mapping) else None - if not isinstance(native_surface, Mapping): - return ["daemon-observed substrate requires a native surface"] - problems: list[str] = [] - for collection in ("domains", "networks"): - observed_names = sorted( - str(item.get("name")) - for item in (daemon.get(collection, ()) if isinstance(daemon, Mapping) else ()) - if isinstance(item, Mapping) - ) - surface_names = native_surface.get(collection, ()) - if not isinstance(surface_names, list | tuple) or sorted(str(item) for item in surface_names) != observed_names: - problems.append(f"native surface {collection} do not match daemon observations") - return problems - - -def _validate_unrealized_substrate( - facts: Mapping[str, Any], - provenance: Mapping[str, Any], - cleanup: object, -) -> list[str]: - problems: list[str] = [] - daemon = facts.get("daemon_observed", {}) - daemon_domains = daemon.get("domains", ()) if isinstance(daemon, Mapping) else () - daemon_networks = daemon.get("networks", ()) if isinstance(daemon, Mapping) else () - if daemon_domains or daemon_networks or facts.get("binding") is not None: - problems.append("unrealized substrate cannot publish daemon observations or realization binding") - guest = facts.get("guest_observed") - if isinstance(guest, Mapping) and guest.get("status") != "not-observed": - problems.append("unrealized substrate cannot publish guest observations") - if provenance.get("cleanup_verified") is not None: - problems.append("unrealized substrate cleanup must be not-applicable") - if isinstance(cleanup, Mapping) and cleanup.get("status") != "not-required": - problems.append("unrealized substrate cleanup status must be not-required") - return problems - - -def _validate_guest_observation_boundary(payload: Mapping[str, Any]) -> list[str]: - defensive = payload.get("defensive_evidence", {}) - if isinstance(defensive, Mapping) and "soc_readback" in defensive: - return ["guest observation violation: daemon substrate cannot supply SOC readback"] - return [] - - -def _validate_realization_binding(backend: Mapping[str, Any], facts: Mapping[str, Any]) -> list[str]: - binding = facts.get("binding") - manifest = backend.get("manifest", {}) - envelope = manifest.get("realization_envelope", {}) if isinstance(manifest, Mapping) else {} - if not isinstance(binding, Mapping) or not isinstance(envelope, Mapping): - return ["daemon-observed substrate requires a realization binding"] - problems = _validate_binding_identity(binding, envelope) - problems.extend(_validate_boot_artifact_binding(binding)) - expected_driver_digest = _driver_configuration_digest(binding) - if binding.get("driver_configuration_digest") != expected_driver_digest: - problems.append("realization binding driver configuration digest does not match its material") - return problems - - -def _validate_binding_identity(binding: Mapping[str, Any], envelope: Mapping[str, Any]) -> list[str]: - problems: list[str] = [] - if binding.get("realization_envelope_digest") != envelope.get("digest"): - problems.append("realization binding envelope digest does not match backend manifest") - if binding.get("configuration_digest") != envelope.get("configuration_digest"): - problems.append("realization binding configuration digest does not match backend manifest") - driver_digest = binding.get("driver_configuration_digest") - if not _is_canonical_sha256(driver_digest): - problems.append("realization binding requires a canonical driver configuration digest") - if binding.get("driver") not in {"techvault-appliance", "guest-certified-appliance"}: - problems.append("realization binding driver does not match a governed appliance mode") - for field_name in ("connection_uri_digest", "name_prefix_digest"): - if not _is_canonical_sha256(binding.get(field_name)): - problems.append(f"realization binding requires canonical {field_name}") - return problems - - -def _validate_boot_artifact_binding(binding: Mapping[str, Any]) -> list[str]: - problems: list[str] = [] - boot_artifacts = binding.get("boot_artifact_digests") - if not isinstance(boot_artifacts, Mapping) or set(boot_artifacts) != {"kernel", "initramfs"}: - problems.append("realization binding requires kernel and initramfs artifact digests") - elif not all(_is_canonical_sha256(value) for value in boot_artifacts.values()): - problems.append("realization binding boot artifact digests must be canonical sha256 values") - return problems - - -def _driver_configuration_digest(binding: Mapping[str, Any]) -> str: - material = { - "driver": binding.get("driver"), - "configuration_digest": binding.get("configuration_digest"), - "boot_artifact_digests": binding.get("boot_artifact_digests"), - "connection_uri_digest": binding.get("connection_uri_digest"), - "name_prefix_digest": binding.get("name_prefix_digest"), - } - encoded = json.dumps(material, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8") - return "sha256:" + hashlib.sha256(encoded).hexdigest() - - -def _is_canonical_sha256(value: object) -> bool: - return isinstance(value, str) and _SHA256_RE.fullmatch(value) is not None - - -def _validate_daemon_observation_item(collection: str, item: Mapping[str, Any]) -> list[str]: - noun = "domain" if collection == "domains" else "network" - problems: list[str] = [] - if set(item) != _DAEMON_REQUIRED_FIELDS[collection] or not _valid_observation_identity(item): - problems.append(f"incomplete daemon {noun} observation") - elif collection == "domains" and not _valid_domain_observation(item): - problems.append("incomplete daemon domain observation") - elif collection == "networks" and not _valid_network_observation(item): - problems.append("incomplete daemon network observation") - return problems - - -def _valid_observation_identity(item: Mapping[str, Any]) -> bool: - return all(_nonempty_string(item.get(key)) for key in ("address", "name")) - - -def _valid_domain_observation(item: Mapping[str, Any]) -> bool: - checks = ( - _nonempty_string(item.get("architecture")), - _nonempty_string(item.get("image_policy")), - isinstance(item.get("memory_mib"), int) and item["memory_mib"] > 0, - isinstance(item.get("vcpus"), int) and item["vcpus"] > 0, - isinstance(item.get("network_attachments"), list | tuple), - ) - return all(checks) - - -def _valid_network_observation(item: Mapping[str, Any]) -> bool: - checks = ( - _nonempty_string(item.get("cidr")), - _nonempty_string(item.get("gateway")), - isinstance(item.get("internal"), bool), - item.get("forward_mode") in {"none", "nat"}, - ) - return all(checks) - - -def _nonempty_string(value: object) -> bool: - return isinstance(value, str) and bool(value) - - def _try_validate(model_cls: type[BaseModel], value: object, label: str) -> list[str]: """Validate ``value`` against ``model_cls``; return a one-item problem list on failure.""" try: diff --git a/implementations/python/packages/aces_operations/libvirt_evidence_run.py b/implementations/python/packages/aces_operations/libvirt_evidence_run.py index 474c300f6..941b9ac09 100644 --- a/implementations/python/packages/aces_operations/libvirt_evidence_run.py +++ b/implementations/python/packages/aces_operations/libvirt_evidence_run.py @@ -35,12 +35,11 @@ from __future__ import annotations -import hashlib -from collections.abc import Callable, Iterable, Mapping +from collections.abc import Callable, Mapping from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path -from typing import Any, Literal +from typing import Any from aces_backend_libvirt.target import create_libvirt_target from aces_backend_libvirt.techvault_native import TechVaultNativeLibvirtDriver @@ -49,14 +48,15 @@ from aces_sdl.parser import parse_sdl_file from aces_operations._evidence_run_artifact import EVIDENCE_RUN_SCHEMA, assemble_artifact +from aces_operations._evidence_run_native import _default_native_driver_factory, _run_native_mode from aces_operations._evidence_run_types import ( CompiledModel, EvidenceArtifactInputs, - ExecutionPlan, + EvidenceCheck, + LibvirtEvidenceRunConfig, ParticipantBehavior, ) from aces_operations._evidence_run_validation import validate_libvirt_evidence_run_artifact -from aces_operations._techvault_cleanup import cleanup_native_snapshot from aces_operations.deterministic_participant_fixtures import ( build_participant_admission_request, iter_admission_pairs, @@ -74,33 +74,9 @@ _PROOF_EPISODE_ID = "proof-ep-1" -EvidenceSourceMode = Literal["deterministic", "native-live", "guest-certified"] _NATIVE_MODES = frozenset({"native-live", "guest-certified"}) -@dataclass(frozen=True) -class EvidenceCheck: - """One named check over the scenario-evidence production run. - - Every check is gating: it contributes to ``LibvirtEvidenceRunReport.passed``. - There is deliberately no non-gating escape hatch — in particular, a native-live - run that fails to realize the libvirt substrate must report ``passed=False`` so - the mode can never claim success without actually realizing. - """ - - name: str - passed: bool - diagnostics: tuple[str, ...] = () - - -@dataclass(frozen=True) -class LibvirtEvidenceRunConfig: - """Runtime controls for the libvirt scenario-evidence producer.""" - - evidence_source_mode: EvidenceSourceMode = "deterministic" - connection_uri: str = "qemu:///system" - - @dataclass(frozen=True) class LibvirtEvidenceRunReport: """Rendered outcome for the libvirt scenario-evidence producer.""" @@ -197,49 +173,6 @@ def run_libvirt_evidence_run( ) -def _run_native_mode( - mode: str, - execution_plan: ExecutionPlan, - control_plane: RuntimeControlPlane, - native_driver: TechVaultNativeLibvirtDriver, - driver_factory: Callable[[], TechVaultNativeLibvirtDriver] | None, - checks: list[EvidenceCheck], -) -> tuple[Mapping[str, Any] | None, bool | None, tuple[str, ...], Mapping[str, Any] | None]: - """Realize the native substrate, capture any guest report, and clean up in a finally-path. - - Native-proof boundary: only the default production driver/transport (no injected - factory) yields a certifying guest artifact; injected fakes are marked - non-certifying so their evidence can never be published as a real certification. - """ - native_snapshot: Mapping[str, Any] | None = None - guest_observed: Mapping[str, Any] | None = None - unrealized: tuple[str, ...] = () - try: - native_snapshot, realize_check, unrealized, operation_id = _realize_native_substrate( - execution_plan, control_plane, native_driver - ) - checks.append(realize_check) - if native_snapshot is not None and mode == "guest-certified": - guest_observed = _guest_observed_report(native_driver, operation_id, certifying=driver_factory is None) - finally: - native_cleanup_verified = _append_cleanup_check(native_driver, native_snapshot, checks) - return native_snapshot, native_cleanup_verified, unrealized, guest_observed - - -def _append_cleanup_check( - native_driver: TechVaultNativeLibvirtDriver, native_snapshot: Mapping[str, Any] | None, checks: list[EvidenceCheck] -) -> bool | None: - """Cleanup runs after every attempt; residue on a failed/unrealized run is reported.""" - if native_snapshot is not None: - verified, diagnostics = _verify_native_cleanup(native_driver, native_snapshot) - checks.append(EvidenceCheck("native_substrate_cleanup", verified, diagnostics)) - return verified - residue_ok, residue_diagnostics = _sweep_residue(native_driver) - if not residue_ok: - checks.append(EvidenceCheck("native_substrate_residue", False, residue_diagnostics)) - return None - - def _finalize_artifact( inputs: EvidenceArtifactInputs, project_dir: Path, checks: list[EvidenceCheck] ) -> tuple[dict[str, Any], str | None]: @@ -357,181 +290,3 @@ def _admit_one_action( action_address if accepted else None, None if accepted else f"admit rejected for {behavior_address}/{action_address}", ) - - -def _default_native_driver_factory( - project_dir: Path, run_id: str, settings: LibvirtEvidenceRunConfig, mode: str -) -> Callable[[], TechVaultNativeLibvirtDriver]: - """Build the default native libvirt driver factory for an operator-run native mode. - - The driver connects to a real libvirt daemon at realize time; ``guest-certified`` - selects the guest-observing driver. In CI/tests a fake driver_factory is injected - instead, so this is never exercised without a daemon. - """ - state_dir = project_dir / "runs" / run_id / "scenario-evidence" / "libvirt" - - def factory() -> TechVaultNativeLibvirtDriver: - if mode == "guest-certified": - from aces_backend_libvirt.guest_certified_driver import GuestCertifiedLibvirtDriver - - return GuestCertifiedLibvirtDriver( - state_dir=state_dir, - connection_uri=settings.connection_uri, - name_prefix="aces-evidence", - ) - return TechVaultNativeLibvirtDriver( - state_dir=state_dir, - connection_uri=settings.connection_uri, - name_prefix="aces-evidence", - ) - - return factory - - -def _guest_observed_report( - native_driver: TechVaultNativeLibvirtDriver, operation_id: str | None, *, certifying: bool -) -> Mapping[str, Any] | None: - """Assemble the operation-joined, challenge-bound guest report from the driver. - - The control-plane operation id and observation timestamp are joined here, at the - operations boundary, rather than inside the backend driver. ``certifying`` records - whether the governed production driver was used; an injected fake driver yields a - non-certifying report that is externally distinguishable from a real proof. - """ - observations = getattr(native_driver, "last_guest_observations", ()) - if not observations: - return None - facts = getattr(native_driver, "last_guest_facts", {}) - binding = getattr(native_driver, "last_guest_binding", {}) - correlations = binding.get("correlations", {}) if isinstance(binding, Mapping) else {} - domains = [ - { - "address": address, - "correlation": correlations.get(address), - "architecture": fact.get("architecture"), - "vcpus": fact.get("vcpus"), - "memory_mib": fact.get("memory_mib"), - "network": list(fact.get("interfaces", ())), - "content": list(fact.get("content", ())), - "accounts": list(fact.get("accounts", ())), - "services": list(fact.get("services", ())), - } - for address, fact in sorted(facts.items()) - if isinstance(fact, Mapping) - ] - return { - # The raw control-plane operation id is a UUID and never portable identity; - # bind a redacted digest instead so the guest report joins the operation - # without leaking the UUID (the redaction gate forbids raw UUIDs). - "operation_ref": _operation_ref(operation_id), - "observed_at": datetime.now(UTC).isoformat(), - "certifying": certifying, - "probe_policy": binding.get("probe_policy") if isinstance(binding, Mapping) else None, - "challenge": binding.get("challenge") if isinstance(binding, Mapping) else None, - "domains": domains, - } - - -def _operation_ref(operation_id: str | None) -> str | None: - if not operation_id: - return None - return "sha256:" + hashlib.sha256(operation_id.encode("utf-8")).hexdigest() - - -def _verify_native_cleanup( - native_driver: TechVaultNativeLibvirtDriver, native_snapshot: Mapping[str, Any] -) -> tuple[bool, tuple[str, ...]]: - """Tear down a realized substrate and verify native + guest-probe cleanup.""" - - verified, diagnostics = cleanup_native_snapshot(native_driver, native_snapshot) - if verified and getattr(native_driver, "last_guest_binding", {}): - return False, (*diagnostics, "guest probe artifacts were not fully cleaned") - return verified, diagnostics - - -def _sweep_residue(native_driver: TechVaultNativeLibvirtDriver) -> tuple[bool, tuple[str, ...]]: - """Best-effort finally-path sweep after a failed/unrealized attempt. - - The driver rolls back on failure, so the common case leaves no residue. Any - remaining realized address, non-empty snapshot, or residual guest binding is a - leak and is reported so the run cannot pass. - """ - clean = ( - not native_driver.realized_addresses() - and native_driver.last_snapshot == {} - and not getattr(native_driver, "last_guest_binding", {}) - ) - if clean: - return True, () - residual = tuple(sorted(native_driver.realized_addresses())) - result = native_driver.destroy(networks=residual, domains=residual) - ok = ( - not result.diagnostics - and not native_driver.realized_addresses() - and native_driver.last_snapshot == {} - and not getattr(native_driver, "last_guest_binding", {}) - ) - if ok: - return True, () - diagnostics = tuple(f"{item.code} at {item.address}" for item in result.diagnostics) - return False, diagnostics or ("residual native or guest state remains after a failed attempt",) - - -def _realize_native_substrate( - execution_plan: ExecutionPlan, - control_plane: RuntimeControlPlane, - native_driver: TechVaultNativeLibvirtDriver | None, -) -> tuple[Mapping[str, Any] | None, EvidenceCheck, tuple[str, ...], str | None]: - """Realize the libvirt provisioning substrate (VMs + networks) for the scenario. - - Native modes pass only when the runtime operation succeeds and the fresh driver - report contains independently daemon-observed domains bound to the selected - realization-envelope/configuration identity. A domain handle or planned matrix - alone is never sufficient. Returns the control-plane operation id so the evidence - producer can join it at this boundary. - """ - if native_driver is None: - return None, EvidenceCheck("native_substrate_realization", False, ("no native driver",)), (), None - try: - receipt = control_plane.submit_provisioning(execution_plan.provisioning) - operation_id = str(receipt.operation_id) - status = control_plane.get_operation(receipt.operation_id) - except Exception: - return None, EvidenceCheck("native_substrate_realization", False, ("native realization failed",)), (), None - unrealized = _dedupe( - f"{d.code}: {d.message}" - for source in (execution_plan.diagnostics, () if status is None else status.diagnostics) - for d in source - if d.is_error - ) - snapshot = native_driver.last_snapshot - operation_succeeded = status is not None and status.state.value == "succeeded" - realized = operation_succeeded and _snapshot_has_daemon_observations(snapshot) - check = EvidenceCheck( - "native_substrate_realization", - realized, - () - if realized - else ("libvirt backend realized no native substrate for this scenario; capabilities disclosed as unrealized",), - ) - return (snapshot if realized else None), check, unrealized, operation_id - - -def _dedupe(items: Iterable[str]) -> tuple[str, ...]: - seen: dict[str, None] = {} - for item in items: - seen.setdefault(item, None) - return tuple(seen) - - -def _snapshot_has_daemon_observations(snapshot: Mapping[str, Any] | None) -> bool: - if not isinstance(snapshot, Mapping): - return False - domains = snapshot.get("domains", ()) - binding = snapshot.get("binding") - return ( - snapshot.get("source") == "daemon-observed" - and isinstance(domains, list | tuple) - and len(domains) > 0 - and isinstance(binding, Mapping) - ) diff --git a/implementations/python/packages/aces_sdl/_mapping_key_analyzer.py b/implementations/python/packages/aces_sdl/_mapping_key_analyzer.py new file mode 100644 index 000000000..a44234354 --- /dev/null +++ b/implementations/python/packages/aces_sdl/_mapping_key_analyzer.py @@ -0,0 +1,462 @@ +"""Mapping-key analysis for the SDL YAML authoring boundary. + +Walks a composed YAML node graph and emits source-anchored diagnostics for +non-canonical fields/merges, duplicate/conflicting keys, invalid identifiers, and +alias cycles. Split from :mod:`aces_sdl._yaml_loader` to keep each module under the +ADR-015 source-size cap; the loaders there drive ``_MappingAnalyzer`` via +``_validate_mapping_keys``. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from yaml.nodes import MappingNode, Node, ScalarNode, SequenceNode + +from ._errors import SDLParseDiagnostic, SDLSourcePosition, SDLSourceRange +from ._identifiers import is_portable_identifier +from ._mapping_scopes import MappingScope, is_literal_map_field, normalize_field_key +from ._source_identifier_paths import is_declaration_key_path, is_scalar_identifier_path +from ._source_profile import SDLMigrationPolicy + +_MERGE_TAG = "tag:yaml.org,2002:merge" +_STRING_TAG = "tag:yaml.org,2002:str" + + +@dataclass(frozen=True) +class _Entry: + canonical: str + authored: str + key_node: ScalarNode + + +@dataclass(frozen=True) +class _EffectiveMapping: + entries: tuple[_Entry, ...] + conflicts: tuple[tuple[_Entry, _Entry], ...] + + +@dataclass +class _EffectiveAccumulator: + entries: list[_Entry] = field(default_factory=list) + conflicts: list[tuple[_Entry, _Entry]] = field(default_factory=list) + seen: dict[str, _Entry] = field(default_factory=dict) + + def add(self, entry: _Entry) -> None: + previous = self.seen.get(entry.canonical) + if previous is None: + self.seen[entry.canonical] = entry + self.entries.append(entry) + else: + self.conflicts.append((previous, entry)) + + def build(self) -> _EffectiveMapping: + return _EffectiveMapping(tuple(self.entries), tuple(self.conflicts)) + + +class _MappingAnalyzer: + def __init__( + self, + *, + migration_policy: SDLMigrationPolicy, + path: Path | None, + source_ranges: dict[str, SDLSourceRange] | None = None, + ) -> None: + self.diagnostics: list[SDLParseDiagnostic] = [] + self._migration_policy = migration_policy + self._source = str(path) if path is not None else None + self._effective_cache: dict[tuple[int, MappingScope], _EffectiveMapping] = {} + self._diagnostic_keys: set[tuple[Any, ...]] = set() + self._walked: set[tuple[int, MappingScope]] = set() + self._source_ranges = source_ranges + + def analyze( + self, + root: Node, + *, + scope: MappingScope, + base_tokens: list[str], + ) -> tuple[SDLParseDiagnostic, ...]: + self._walk(root, scope=scope, tokens=base_tokens, active=set()) + return tuple( + sorted( + self.diagnostics, + key=lambda item: ( + item.primary_range.start.line, + item.primary_range.start.column, + item.code, + item.pointer, + ), + ) + ) + + def _walk( + self, + node: Node, + *, + scope: MappingScope, + tokens: list[str], + active: set[int], + ) -> None: + if self._source_ranges is not None: + self._source_ranges[_encode_pointer(tokens)] = _range_from_node(node) + identity = id(node) + if identity in active: + self._add_alias_cycle(node, tokens) + else: + walk_key = (identity, scope) + if walk_key not in self._walked and isinstance(node, (MappingNode, SequenceNode)): + self._walked.add(walk_key) + active.add(identity) + try: + if isinstance(node, MappingNode): + self._walk_mapping(node, scope=scope, tokens=tokens, active=active) + else: + self._walk_sequence(node, scope=scope, tokens=tokens, active=active) + finally: + active.remove(identity) + + def _add_alias_cycle(self, node: Node, tokens: list[str]) -> None: + self._add( + SDLParseDiagnostic( + code="sdl.alias_cycle", + message="Cyclic YAML aliases are not valid SDL authoring input.", + pointer=_encode_pointer(tokens), + primary_range=_range_from_node(node), + source=self._source, + ) + ) + + def _walk_sequence( + self, + node: SequenceNode, + *, + scope: MappingScope, + tokens: list[str], + active: set[int], + ) -> None: + for index, item in enumerate(node.value): + self._walk(item, scope=scope, tokens=[*tokens, str(index)], active=active) + + def _walk_mapping( + self, + node: MappingNode, + *, + scope: MappingScope, + tokens: list[str], + active: set[int], + ) -> None: + effective = self._effective_mapping(node, scope=scope, active=set()) + conflicted_key_nodes = {id(entry.key_node) for pair in effective.conflicts for entry in pair} + for first, conflicting in effective.conflicts: + self._add_conflict(first, conflicting, tokens) + for key_node, value_node in node.value: + self._walk_mapping_entry( + key_node, + value_node, + scope=scope, + tokens=tokens, + active=active, + suppress_field_migration=id(key_node) in conflicted_key_nodes, + ) + + def _add_conflict(self, first: _Entry, conflicting: _Entry, tokens: list[str]) -> None: + self._add( + SDLParseDiagnostic( + code="sdl.mapping_key_conflict", + message=_conflict_message(first, conflicting), + pointer=_encode_pointer([*tokens, conflicting.canonical]), + authored_keys=(first.authored, conflicting.authored), + primary_range=_range_from_node(conflicting.key_node), + related_range=_range_from_node(first.key_node), + related_message=f"First authored key '{first.authored}'.", + source=self._source, + ) + ) + + def _walk_mapping_entry( + self, + key_node: Node, + value_node: Node, + *, + scope: MappingScope, + tokens: list[str], + active: set[int], + suppress_field_migration: bool, + ) -> None: + if _is_merge_key(key_node): + self._add_migration_diagnostic( + key_node, + code="sdl.noncanonical_merge", + message="YAML merge keys are migration syntax, not canonical sdl-yaml/v1.", + pointer=_encode_pointer(tokens), + authored_keys=("<<", "<<"), + ) + self._walk_merge_value(value_node, scope=scope, tokens=tokens, active=active) + return + authored = _authored_key(key_node) + if not _is_string_key(key_node): + self._add_key_type_diagnostic(key_node, authored, tokens) + return + canonical = normalize_field_key(authored) if scope is MappingScope.STRUCTURAL else authored + if scope is MappingScope.STRUCTURAL and canonical != authored and not suppress_field_migration: + self._add_migration_diagnostic( + key_node, + code="sdl.noncanonical_field", + message=f"Structural field '{authored}' must use canonical spelling '{canonical}'.", + pointer=_encode_pointer([*tokens, canonical]), + authored_keys=(authored, canonical), + ) + child_tokens = [*tokens, canonical] + self._validate_entry_identifiers( + key_node, + value_node, + authored, + tokens=tokens, + child_tokens=child_tokens, + suppress_field_migration=suppress_field_migration, + ) + child_scope = _child_scope(scope, canonical, value_node) + self._walk(value_node, scope=child_scope, tokens=child_tokens, active=active) + + def _validate_entry_identifiers( + self, + key_node: Node, + value_node: Node, + authored: str, + *, + tokens: list[str], + child_tokens: list[str], + suppress_field_migration: bool, + ) -> None: + if is_declaration_key_path(tokens) and not suppress_field_migration: + self._validate_identifier_node(key_node, pointer_tokens=child_tokens) + if tokens == ["nodes"] and len(authored) > 35: + self._add_identifier_diagnostic(key_node, pointer_tokens=child_tokens, node_limit=True) + if child_tokens == ["name"]: + self._validate_identifier_node(value_node, pointer_tokens=child_tokens) + if is_scalar_identifier_path(child_tokens): + self._validate_identifier_node(value_node, pointer_tokens=child_tokens) + + def _validate_identifier_node(self, node: Node, *, pointer_tokens: list[str]) -> None: + if not isinstance(node, ScalarNode) or node.tag != _STRING_TAG or not is_portable_identifier(node.value): + self._add_identifier_diagnostic(node, pointer_tokens=pointer_tokens) + + def _add_identifier_diagnostic( + self, + node: Node, + *, + pointer_tokens: list[str], + node_limit: bool = False, + ) -> None: + message = ( + "Authored node identifiers must be at most 35 characters." + if node_limit + else ( + "Authored identifiers must be 1-64 lowercase ASCII letters, digits, hyphens, or " + "underscores and start with a letter or digit." + ) + ) + self._add( + SDLParseDiagnostic( + code="sdl.identifier.invalid", + message=message, + pointer=_encode_pointer(pointer_tokens), + primary_range=_range_from_node(node), + source=self._source, + ) + ) + + def _add_key_type_diagnostic(self, key_node: Node, authored: str, tokens: list[str]) -> None: + message = ( + "SDL top-level mapping keys must be strings" + if not tokens + else f"SDL mapping key '{authored}' must be a string." + ) + self._add( + SDLParseDiagnostic( + code="sdl.mapping_key_type", + message=message, + pointer=_encode_pointer([*tokens, authored]), + primary_range=_range_from_node(key_node), + source=self._source, + ) + ) + + def _add_migration_diagnostic( + self, + key_node: Node, + *, + code: str, + message: str, + pointer: str, + authored_keys: tuple[str, str], + ) -> None: + self._add( + SDLParseDiagnostic( + code=code, + message=message, + pointer=pointer, + primary_range=_range_from_node(key_node), + authored_keys=authored_keys, + severity="warning" if self._migration_policy is SDLMigrationPolicy.ACCEPT else "error", + source=self._source, + ) + ) + + def _walk_merge_value( + self, + node: Node, + *, + scope: MappingScope, + tokens: list[str], + active: set[int], + ) -> None: + if isinstance(node, MappingNode): + self._walk(node, scope=scope, tokens=tokens, active=active) + elif isinstance(node, SequenceNode): + for item in node.value: + self._walk(item, scope=scope, tokens=tokens, active=active) + + def _effective_mapping( + self, + node: MappingNode, + *, + scope: MappingScope, + active: set[int], + ) -> _EffectiveMapping: + cache_key = (id(node), scope) + cached = self._effective_cache.get(cache_key) + if cached is not None: + return cached + if id(node) in active: + return _EffectiveMapping((), ()) + + active.add(id(node)) + accumulator = _EffectiveAccumulator() + try: + merge_keys = self._inherit_merge_entries(node, scope=scope, active=active, accumulator=accumulator) + self._record_duplicate_merge_keys(merge_keys, accumulator) + self._add_local_entries(node, scope=scope, accumulator=accumulator) + finally: + active.remove(id(node)) + + result = accumulator.build() + self._effective_cache[cache_key] = result + return result + + def _inherit_merge_entries( + self, + node: MappingNode, + *, + scope: MappingScope, + active: set[int], + accumulator: _EffectiveAccumulator, + ) -> list[ScalarNode]: + merge_keys: list[ScalarNode] = [] + for key_node, value_node in node.value: + if not _is_merge_key(key_node): + continue + assert isinstance(key_node, ScalarNode) + merge_keys.append(key_node) + for source in _merge_sources(value_node): + if id(source) in active: + continue + inherited = self._effective_mapping(source, scope=scope, active=active) + for entry in inherited.entries: + accumulator.add(entry) + return merge_keys + + @staticmethod + def _record_duplicate_merge_keys( + merge_keys: list[ScalarNode], + accumulator: _EffectiveAccumulator, + ) -> None: + if len(merge_keys) < 2: + return + first = _Entry("<<", "<<", merge_keys[0]) + for key_node in merge_keys[1:]: + accumulator.conflicts.append((first, _Entry("<<", "<<", key_node))) + + @staticmethod + def _add_local_entries( + node: MappingNode, + *, + scope: MappingScope, + accumulator: _EffectiveAccumulator, + ) -> None: + for key_node, _value_node in node.value: + if not _is_string_key(key_node) or _is_merge_key(key_node): + continue + assert isinstance(key_node, ScalarNode) + authored = key_node.value + canonical = normalize_field_key(authored) if scope is MappingScope.STRUCTURAL else authored + accumulator.add(_Entry(canonical, authored, key_node)) + + def _add(self, diagnostic: SDLParseDiagnostic) -> None: + related = diagnostic.related_range + key = ( + diagnostic.code, + diagnostic.pointer, + diagnostic.primary_range.start.line, + diagnostic.primary_range.start.column, + related.start.line if related else None, + related.start.column if related else None, + ) + if key not in self._diagnostic_keys: + self._diagnostic_keys.add(key) + self.diagnostics.append(diagnostic) + + +def _is_merge_key(node: Node) -> bool: + return isinstance(node, ScalarNode) and node.tag == _MERGE_TAG + + +def _is_string_key(node: Node) -> bool: + return isinstance(node, ScalarNode) and node.tag == _STRING_TAG + + +def _authored_key(node: Node) -> str: + if isinstance(node, ScalarNode): + return node.value + return "?" + + +def _merge_sources(node: Node) -> Iterator[MappingNode]: + if isinstance(node, MappingNode): + yield node + elif isinstance(node, SequenceNode): + yield from (item for item in node.value if isinstance(item, MappingNode)) + + +def _child_scope(scope: MappingScope, canonical: str, value_node: Node) -> MappingScope: + is_literal = scope is MappingScope.STRUCTURAL and is_literal_map_field( + canonical, + value_is_mapping=isinstance(value_node, MappingNode), + value_is_sequence=isinstance(value_node, SequenceNode), + ) + return MappingScope.LITERAL if is_literal else MappingScope.STRUCTURAL + + +def _conflict_message(first: _Entry, conflicting: _Entry) -> str: + if first.authored == conflicting.authored: + return f"Duplicate mapping key '{conflicting.authored}'." + return ( + f"Structural field keys '{first.authored}' and '{conflicting.authored}' both address '{conflicting.canonical}'." + ) + + +def _range_from_node(node: Node) -> SDLSourceRange: + return SDLSourceRange( + start=SDLSourcePosition(node.start_mark.line + 1, node.start_mark.column + 1), + end=SDLSourcePosition(node.end_mark.line + 1, node.end_mark.column + 1), + ) + + +def _encode_pointer(tokens: list[str]) -> str: + if not tokens: + return "" + return "/" + "/".join(token.replace("~", "~0").replace("/", "~1") for token in tokens) diff --git a/implementations/python/packages/aces_sdl/_model_diagnostics.py b/implementations/python/packages/aces_sdl/_model_diagnostics.py new file mode 100644 index 000000000..989150d72 --- /dev/null +++ b/implementations/python/packages/aces_sdl/_model_diagnostics.py @@ -0,0 +1,108 @@ +"""Pydantic-error → SDL-diagnostic rendering for the SDL parser. + +Converts a Pydantic :class:`ValidationError` into bounded, source-anchored +:class:`SDLParseError` diagnostics. Kept separate from :mod:`aces_sdl.parser` +so the parser module stays focused on loading/normalization; ``parser`` re-imports +these helpers so ``from aces_sdl.parser import ...`` call sites remain stable. +""" + +from __future__ import annotations + +from pathlib import Path + +from pydantic import ValidationError + +from ._errors import ( + SDLParseDiagnostic, + SDLParseError, + SDLSourcePosition, + SDLSourceRange, +) + + +def _dedupe_source_diagnostics( + diagnostics: list[SDLParseDiagnostic], +) -> list[SDLParseDiagnostic]: + unique: list[SDLParseDiagnostic] = [] + seen: set[tuple[object, ...]] = set() + for diagnostic in diagnostics: + start = diagnostic.primary_range.start + end = diagnostic.primary_range.end + key = ( + diagnostic.source, + diagnostic.code, + diagnostic.pointer, + start.line, + start.column, + end.line, + end.column, + ) + if key not in seen: + seen.add(key) + unique.append(diagnostic) + return unique + + +def _pointer_from_location(location: tuple[object, ...]) -> str: + tokens = [str(part) for part in location if str(part) != "[key]"] + return "".join(f"/{token.replace('~', '~0').replace('/', '~1')}" for token in tokens) + + +def _nearest_source_range(pointer: str, source_ranges: dict[str, SDLSourceRange]) -> SDLSourceRange: + candidate = pointer + while candidate: + source_range = source_ranges.get(candidate) + if source_range is not None: + return source_range + candidate = candidate.rsplit("/", 1)[0] + source_range = source_ranges.get("") + if source_range is not None: + return source_range + position = SDLSourcePosition(1, 1) + return SDLSourceRange(start=position, end=position) + + +_MODEL_DIAGNOSTIC_MESSAGE_MAX_LENGTH = 512 + + +def _bounded_model_message(message: str) -> str: + """Render validator-owned prose without Pydantic's input or traceback.""" + + if message.startswith("Value error, "): + message = message.removeprefix("Value error, ") + escaped = "".join(character if character.isprintable() else f"\\u{ord(character):04x}" for character in message) + if len(escaped) <= _MODEL_DIAGNOSTIC_MESSAGE_MAX_LENGTH: + return escaped + return escaped[: _MODEL_DIAGNOSTIC_MESSAGE_MAX_LENGTH - 3] + "..." + + +def _model_parse_error( + error: ValidationError, + *, + path: Path | None, + source_ranges: dict[str, SDLSourceRange], +) -> SDLParseError: + diagnostics: list[SDLParseDiagnostic] = [] + for item in error.errors(): + pointer = _pointer_from_location(tuple(item.get("loc", ()))) + raw_message = str(item.get("msg", "")) + is_identifier = "portable SDL identifier" in raw_message or "qualified SDL identifier" in raw_message + message = _bounded_model_message(raw_message) + diagnostics.append( + SDLParseDiagnostic( + code="sdl.identifier.invalid" if is_identifier else "sdl.model.invalid", + message=message, + pointer=pointer, + primary_range=_nearest_source_range(pointer, source_ranges), + source=str(path) if path is not None else None, + ) + ) + diagnostics = _dedupe_source_diagnostics(diagnostics) + rendered = "; ".join(f"{diagnostic.pointer or '/'}: {diagnostic.message}" for diagnostic in diagnostics[:8]) + if len(diagnostics) > 8: + rendered += f", and {len(diagnostics) - 8} more" + return SDLParseError( + f"SDL model validation failed at {rendered or '/'}", + path=path, + diagnostics=diagnostics, + ) diff --git a/implementations/python/packages/aces_sdl/_runtime_service_families.py b/implementations/python/packages/aces_sdl/_runtime_service_families.py index a08be46ef..cfb848ac3 100644 --- a/implementations/python/packages/aces_sdl/_runtime_service_families.py +++ b/implementations/python/packages/aces_sdl/_runtime_service_families.py @@ -4,49 +4,12 @@ from collections.abc import Iterable, Mapping, MutableMapping from dataclasses import dataclass -from types import ModuleType - -from . import runtime_app_authorization as _runtime_app_authorization -from . import runtime_application as _runtime_application -from . import runtime_database as _runtime_database -from . import runtime_datastore as _runtime_datastore -from . import runtime_directory_identity as _runtime_directory_identity -from . import runtime_dns as _runtime_dns -from . import runtime_file_service as _runtime_file_service -from . import runtime_forwarding_agent as _runtime_forwarding_agent -from . import runtime_listeners as _runtime_listeners -from . import runtime_mail_service as _runtime_mail_service -from . import runtime_network_detection as _runtime_network_detection -from . import runtime_network_sensor as _runtime_network_sensor -from . import runtime_orchestration as _runtime_orchestration -from . import runtime_platform_application as _runtime_platform_application -from . import runtime_scheduled_job as _runtime_scheduled_job -from . import runtime_security_monitoring as _runtime_security_monitoring -from . import runtime_ssh_server as _runtime_ssh_server - -@dataclass(frozen=True) -class RuntimeReferenceChild: - """A stable child collection that can be addressed below a runtime family.""" - - collection_name: str - id_field: str - children: tuple[RuntimeReferenceChild, ...] = () - - -@dataclass(frozen=True) -class RuntimeServiceFamily: - """Static registration metadata for one node-scoped runtime family.""" - - key: str - module: ModuleType - collection_name: str - id_field: str - child_refs: tuple[RuntimeReferenceChild, ...] = () - - @property - def public_symbols(self) -> tuple[str, ...]: - return tuple(getattr(self.module, "__all__", ())) +from ._runtime_service_family_registry import ( + RUNTIME_SERVICE_FAMILIES, + RuntimeReferenceChild, + RuntimeServiceFamily, +) @dataclass(frozen=True) @@ -61,200 +24,6 @@ class RuntimeFamilyReference: collection_path: tuple[str, ...] = () -RUNTIME_SERVICE_FAMILIES: tuple[RuntimeServiceFamily, ...] = ( - RuntimeServiceFamily( - key="service-listeners", - module=_runtime_listeners, - collection_name="service_listeners", - id_field="service_listener_id", - ), - RuntimeServiceFamily( - key="applications", - module=_runtime_application, - collection_name="applications", - id_field="application_id", - ), - RuntimeServiceFamily( - key="database-services", - module=_runtime_database, - collection_name="database_services", - id_field="database_service_id", - child_refs=(RuntimeReferenceChild("databases", "database_id"),), - ), - RuntimeServiceFamily( - key="dns-services", - module=_runtime_dns, - collection_name="dns_services", - id_field="dns_service_id", - child_refs=( - RuntimeReferenceChild( - "zones", - "zone_id", - children=(RuntimeReferenceChild("rrsets", "rrset_id"),), - ), - ), - ), - RuntimeServiceFamily( - key="identity-authorities", - module=_runtime_directory_identity, - collection_name="identity_authorities", - id_field="identity_authority_id", - child_refs=( - RuntimeReferenceChild("services", "service_id"), - RuntimeReferenceChild("subjects", "subject_id"), - RuntimeReferenceChild("policies", "policy_id"), - RuntimeReferenceChild("relationships", "relationship_id"), - ), - ), - RuntimeServiceFamily( - key="file-services", - module=_runtime_file_service, - collection_name="file_services", - id_field="file_service_id", - child_refs=( - RuntimeReferenceChild("shares", "share_id"), - RuntimeReferenceChild("principals", "principal_id"), - RuntimeReferenceChild("access_rules", "rule_id"), - RuntimeReferenceChild("access_observations", "observation_id"), - ), - ), - RuntimeServiceFamily( - key="mail-services", - module=_runtime_mail_service, - collection_name="mail_services", - id_field="mail_service_id", - child_refs=( - RuntimeReferenceChild("components", "component_id"), - RuntimeReferenceChild("listeners", "listener_id"), - RuntimeReferenceChild("domains", "domain_id"), - RuntimeReferenceChild("mailbox_stores", "store_id"), - RuntimeReferenceChild("mailboxes", "mailbox_id"), - RuntimeReferenceChild("aliases", "alias_id"), - RuntimeReferenceChild("routing_rules", "rule_id"), - RuntimeReferenceChild("queues", "queue_id"), - RuntimeReferenceChild("settings", "setting_id"), - ), - ), - RuntimeServiceFamily( - key="network-sensors", - module=_runtime_network_sensor, - collection_name="network_sensors", - id_field="network_sensor_id", - ), - RuntimeServiceFamily( - key="network-detection-engines", - module=_runtime_network_detection, - collection_name="network_detection_engines", - id_field="network_detection_engine_id", - child_refs=( - RuntimeReferenceChild("rule_sources", "source_id"), - RuntimeReferenceChild("network_sets", "set_id"), - RuntimeReferenceChild("output_streams", "stream_id"), - RuntimeReferenceChild("control_channels", "channel_id"), - ), - ), - RuntimeServiceFamily( - key="security-monitoring-managers", - module=_runtime_security_monitoring, - collection_name="security_monitoring_managers", - id_field="security_monitoring_manager_id", - child_refs=( - RuntimeReferenceChild("listeners", "listener_id"), - RuntimeReferenceChild("components", "component_id"), - RuntimeReferenceChild("agents", "agent_id"), - RuntimeReferenceChild("agent_groups", "group_id"), - RuntimeReferenceChild("content_sets", "content_id"), - RuntimeReferenceChild("detection_definitions", "definition_id"), - RuntimeReferenceChild("settings", "setting_id"), - ), - ), - RuntimeServiceFamily( - key="ssh-servers", - module=_runtime_ssh_server, - collection_name="ssh_servers", - id_field="ssh_server_id", - child_refs=(RuntimeReferenceChild("match_rules", "match_id"),), - ), - RuntimeServiceFamily( - key="app-authorizations", - module=_runtime_app_authorization, - collection_name="app_authorizations", - id_field="app_authorization_id", - child_refs=( - RuntimeReferenceChild("principals", "principal_id"), - RuntimeReferenceChild("roles", "role_id"), - RuntimeReferenceChild("permission_grants", "grant_id"), - RuntimeReferenceChild("role_mappings", "mapping_id"), - RuntimeReferenceChild("tenants", "tenant_id"), - ), - ), - RuntimeServiceFamily( - key="scheduled-jobs", - module=_runtime_scheduled_job, - collection_name="scheduled_jobs", - id_field="scheduled_job_id", - ), - RuntimeServiceFamily( - key="datastore-services", - module=_runtime_datastore, - collection_name="datastore_services", - id_field="datastore_service_id", - child_refs=( - RuntimeReferenceChild( - "nodes", - "node_id", - children=( - RuntimeReferenceChild("plugins", "plugin_id"), - RuntimeReferenceChild("endpoints", "endpoint_id"), - ), - ), - RuntimeReferenceChild("partitions", "partition_id"), - RuntimeReferenceChild("templates", "template_id"), - RuntimeReferenceChild("mappings", "mapping_id"), - RuntimeReferenceChild("settings", "setting_id"), - ), - ), - RuntimeServiceFamily( - key="platform-applications", - module=_runtime_platform_application, - collection_name="platform_applications", - id_field="platform_application_id", - child_refs=( - RuntimeReferenceChild("organizations", "organization_id"), - RuntimeReferenceChild("tenants", "tenant_id"), - RuntimeReferenceChild("content_objects", "content_object_id"), - RuntimeReferenceChild("markings", "marking_id"), - RuntimeReferenceChild("upstream_bindings", "binding_id"), - RuntimeReferenceChild("connectors", "connector_id"), - RuntimeReferenceChild("settings", "setting_id"), - ), - ), - RuntimeServiceFamily( - key="forwarding-agents", - module=_runtime_forwarding_agent, - collection_name="forwarding_agents", - id_field="forwarding_agent_id", - child_refs=( - RuntimeReferenceChild("sources", "source_id"), - RuntimeReferenceChild("transforms", "transform_id"), - RuntimeReferenceChild("ship_targets", "target_id"), - RuntimeReferenceChild("reload_channels", "reload_channel_id"), - RuntimeReferenceChild("settings", "setting_id"), - ), - ), - RuntimeServiceFamily( - key="orchestration-authorities", - module=_runtime_orchestration, - collection_name="orchestration_authorities", - id_field="orchestration_authority_id", - child_refs=( - RuntimeReferenceChild("spawn_templates", "template_id"), - RuntimeReferenceChild("realized_children", "workload_id"), - ), - ), -) - - def runtime_service_family_export_names() -> tuple[str, ...]: """Return the public model symbols exported by all registered families.""" diff --git a/implementations/python/packages/aces_sdl/_runtime_service_family_registry.py b/implementations/python/packages/aces_sdl/_runtime_service_family_registry.py new file mode 100644 index 000000000..d0801a9c8 --- /dev/null +++ b/implementations/python/packages/aces_sdl/_runtime_service_family_registry.py @@ -0,0 +1,256 @@ +"""Static registry of node-scoped runtime service families. + +This module holds the pure registration data — the :class:`RuntimeServiceFamily` +and :class:`RuntimeReferenceChild` dataclasses plus the canonical +``RUNTIME_SERVICE_FAMILIES`` table. The traversal/alias logic that consumes the +registry lives in :mod:`aces_sdl._runtime_service_families`, which re-exports +these names so existing import paths remain stable. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from types import ModuleType + +from . import runtime_app_authorization as _runtime_app_authorization +from . import runtime_application as _runtime_application +from . import runtime_database as _runtime_database +from . import runtime_datastore as _runtime_datastore +from . import runtime_directory_identity as _runtime_directory_identity +from . import runtime_dns as _runtime_dns +from . import runtime_file_service as _runtime_file_service +from . import runtime_forwarding_agent as _runtime_forwarding_agent +from . import runtime_listeners as _runtime_listeners +from . import runtime_mail_service as _runtime_mail_service +from . import runtime_network_detection as _runtime_network_detection +from . import runtime_network_sensor as _runtime_network_sensor +from . import runtime_orchestration as _runtime_orchestration +from . import runtime_platform_application as _runtime_platform_application +from . import runtime_scheduled_job as _runtime_scheduled_job +from . import runtime_security_monitoring as _runtime_security_monitoring +from . import runtime_ssh_server as _runtime_ssh_server + + +@dataclass(frozen=True) +class RuntimeReferenceChild: + """A stable child collection that can be addressed below a runtime family.""" + + collection_name: str + id_field: str + children: tuple[RuntimeReferenceChild, ...] = () + + +@dataclass(frozen=True) +class RuntimeServiceFamily: + """Static registration metadata for one node-scoped runtime family.""" + + key: str + module: ModuleType + collection_name: str + id_field: str + child_refs: tuple[RuntimeReferenceChild, ...] = () + + @property + def public_symbols(self) -> tuple[str, ...]: + return tuple(getattr(self.module, "__all__", ())) + + +RUNTIME_SERVICE_FAMILIES: tuple[RuntimeServiceFamily, ...] = ( + RuntimeServiceFamily( + key="service-listeners", + module=_runtime_listeners, + collection_name="service_listeners", + id_field="service_listener_id", + ), + RuntimeServiceFamily( + key="applications", + module=_runtime_application, + collection_name="applications", + id_field="application_id", + ), + RuntimeServiceFamily( + key="database-services", + module=_runtime_database, + collection_name="database_services", + id_field="database_service_id", + child_refs=(RuntimeReferenceChild("databases", "database_id"),), + ), + RuntimeServiceFamily( + key="dns-services", + module=_runtime_dns, + collection_name="dns_services", + id_field="dns_service_id", + child_refs=( + RuntimeReferenceChild( + "zones", + "zone_id", + children=(RuntimeReferenceChild("rrsets", "rrset_id"),), + ), + ), + ), + RuntimeServiceFamily( + key="identity-authorities", + module=_runtime_directory_identity, + collection_name="identity_authorities", + id_field="identity_authority_id", + child_refs=( + RuntimeReferenceChild("services", "service_id"), + RuntimeReferenceChild("subjects", "subject_id"), + RuntimeReferenceChild("policies", "policy_id"), + RuntimeReferenceChild("relationships", "relationship_id"), + ), + ), + RuntimeServiceFamily( + key="file-services", + module=_runtime_file_service, + collection_name="file_services", + id_field="file_service_id", + child_refs=( + RuntimeReferenceChild("shares", "share_id"), + RuntimeReferenceChild("principals", "principal_id"), + RuntimeReferenceChild("access_rules", "rule_id"), + RuntimeReferenceChild("access_observations", "observation_id"), + ), + ), + RuntimeServiceFamily( + key="mail-services", + module=_runtime_mail_service, + collection_name="mail_services", + id_field="mail_service_id", + child_refs=( + RuntimeReferenceChild("components", "component_id"), + RuntimeReferenceChild("listeners", "listener_id"), + RuntimeReferenceChild("domains", "domain_id"), + RuntimeReferenceChild("mailbox_stores", "store_id"), + RuntimeReferenceChild("mailboxes", "mailbox_id"), + RuntimeReferenceChild("aliases", "alias_id"), + RuntimeReferenceChild("routing_rules", "rule_id"), + RuntimeReferenceChild("queues", "queue_id"), + RuntimeReferenceChild("settings", "setting_id"), + ), + ), + RuntimeServiceFamily( + key="network-sensors", + module=_runtime_network_sensor, + collection_name="network_sensors", + id_field="network_sensor_id", + ), + RuntimeServiceFamily( + key="network-detection-engines", + module=_runtime_network_detection, + collection_name="network_detection_engines", + id_field="network_detection_engine_id", + child_refs=( + RuntimeReferenceChild("rule_sources", "source_id"), + RuntimeReferenceChild("network_sets", "set_id"), + RuntimeReferenceChild("output_streams", "stream_id"), + RuntimeReferenceChild("control_channels", "channel_id"), + ), + ), + RuntimeServiceFamily( + key="security-monitoring-managers", + module=_runtime_security_monitoring, + collection_name="security_monitoring_managers", + id_field="security_monitoring_manager_id", + child_refs=( + RuntimeReferenceChild("listeners", "listener_id"), + RuntimeReferenceChild("components", "component_id"), + RuntimeReferenceChild("agents", "agent_id"), + RuntimeReferenceChild("agent_groups", "group_id"), + RuntimeReferenceChild("content_sets", "content_id"), + RuntimeReferenceChild("detection_definitions", "definition_id"), + RuntimeReferenceChild("settings", "setting_id"), + ), + ), + RuntimeServiceFamily( + key="ssh-servers", + module=_runtime_ssh_server, + collection_name="ssh_servers", + id_field="ssh_server_id", + child_refs=(RuntimeReferenceChild("match_rules", "match_id"),), + ), + RuntimeServiceFamily( + key="app-authorizations", + module=_runtime_app_authorization, + collection_name="app_authorizations", + id_field="app_authorization_id", + child_refs=( + RuntimeReferenceChild("principals", "principal_id"), + RuntimeReferenceChild("roles", "role_id"), + RuntimeReferenceChild("permission_grants", "grant_id"), + RuntimeReferenceChild("role_mappings", "mapping_id"), + RuntimeReferenceChild("tenants", "tenant_id"), + ), + ), + RuntimeServiceFamily( + key="scheduled-jobs", + module=_runtime_scheduled_job, + collection_name="scheduled_jobs", + id_field="scheduled_job_id", + ), + RuntimeServiceFamily( + key="datastore-services", + module=_runtime_datastore, + collection_name="datastore_services", + id_field="datastore_service_id", + child_refs=( + RuntimeReferenceChild( + "nodes", + "node_id", + children=( + RuntimeReferenceChild("plugins", "plugin_id"), + RuntimeReferenceChild("endpoints", "endpoint_id"), + ), + ), + RuntimeReferenceChild("partitions", "partition_id"), + RuntimeReferenceChild("templates", "template_id"), + RuntimeReferenceChild("mappings", "mapping_id"), + RuntimeReferenceChild("settings", "setting_id"), + ), + ), + RuntimeServiceFamily( + key="platform-applications", + module=_runtime_platform_application, + collection_name="platform_applications", + id_field="platform_application_id", + child_refs=( + RuntimeReferenceChild("organizations", "organization_id"), + RuntimeReferenceChild("tenants", "tenant_id"), + RuntimeReferenceChild("content_objects", "content_object_id"), + RuntimeReferenceChild("markings", "marking_id"), + RuntimeReferenceChild("upstream_bindings", "binding_id"), + RuntimeReferenceChild("connectors", "connector_id"), + RuntimeReferenceChild("settings", "setting_id"), + ), + ), + RuntimeServiceFamily( + key="forwarding-agents", + module=_runtime_forwarding_agent, + collection_name="forwarding_agents", + id_field="forwarding_agent_id", + child_refs=( + RuntimeReferenceChild("sources", "source_id"), + RuntimeReferenceChild("transforms", "transform_id"), + RuntimeReferenceChild("ship_targets", "target_id"), + RuntimeReferenceChild("reload_channels", "reload_channel_id"), + RuntimeReferenceChild("settings", "setting_id"), + ), + ), + RuntimeServiceFamily( + key="orchestration-authorities", + module=_runtime_orchestration, + collection_name="orchestration_authorities", + id_field="orchestration_authority_id", + child_refs=( + RuntimeReferenceChild("spawn_templates", "template_id"), + RuntimeReferenceChild("realized_children", "workload_id"), + ), + ), +) + + +__all__ = [ + "RUNTIME_SERVICE_FAMILIES", + "RuntimeReferenceChild", + "RuntimeServiceFamily", +] diff --git a/implementations/python/packages/aces_sdl/_yaml_loader.py b/implementations/python/packages/aces_sdl/_yaml_loader.py index 3fa3bd918..4a57eeda5 100644 --- a/implementations/python/packages/aces_sdl/_yaml_loader.py +++ b/implementations/python/packages/aces_sdl/_yaml_loader.py @@ -2,23 +2,18 @@ from __future__ import annotations -from collections.abc import Iterator -from dataclasses import dataclass, field from pathlib import Path -from typing import Any import yaml -from yaml.nodes import MappingNode, Node, ScalarNode, SequenceNode +from yaml.nodes import Node from ._errors import ( SDLParseDiagnostic, SDLParseError, - SDLSourcePosition, SDLSourceRange, ) -from ._identifiers import is_portable_identifier -from ._mapping_scopes import MappingScope, is_literal_map_field, normalize_field_key -from ._source_identifier_paths import is_declaration_key_path, is_scalar_identifier_path +from ._mapping_key_analyzer import _MappingAnalyzer +from ._mapping_scopes import MappingScope from ._source_profile import ( DEFAULT_SOURCE_PARSE_OPTIONS, SDLMigrationPolicy, @@ -36,9 +31,6 @@ yaml_parse_error, ) -_MERGE_TAG = "tag:yaml.org,2002:merge" -_STRING_TAG = "tag:yaml.org,2002:str" - class _SDLSafeLoader(yaml.SafeLoader): """SafeLoader with an isolated YAML 1.2 Core implicit resolver table.""" @@ -47,372 +39,6 @@ class _SDLSafeLoader(yaml.SafeLoader): install_yaml_12_core_resolvers(_SDLSafeLoader) -@dataclass(frozen=True) -class _Entry: - canonical: str - authored: str - key_node: ScalarNode - - -@dataclass(frozen=True) -class _EffectiveMapping: - entries: tuple[_Entry, ...] - conflicts: tuple[tuple[_Entry, _Entry], ...] - - -@dataclass -class _EffectiveAccumulator: - entries: list[_Entry] = field(default_factory=list) - conflicts: list[tuple[_Entry, _Entry]] = field(default_factory=list) - seen: dict[str, _Entry] = field(default_factory=dict) - - def add(self, entry: _Entry) -> None: - previous = self.seen.get(entry.canonical) - if previous is None: - self.seen[entry.canonical] = entry - self.entries.append(entry) - else: - self.conflicts.append((previous, entry)) - - def build(self) -> _EffectiveMapping: - return _EffectiveMapping(tuple(self.entries), tuple(self.conflicts)) - - -class _MappingAnalyzer: - def __init__( - self, - *, - migration_policy: SDLMigrationPolicy, - path: Path | None, - source_ranges: dict[str, SDLSourceRange] | None = None, - ) -> None: - self.diagnostics: list[SDLParseDiagnostic] = [] - self._migration_policy = migration_policy - self._source = str(path) if path is not None else None - self._effective_cache: dict[tuple[int, MappingScope], _EffectiveMapping] = {} - self._diagnostic_keys: set[tuple[Any, ...]] = set() - self._walked: set[tuple[int, MappingScope]] = set() - self._source_ranges = source_ranges - - def analyze( - self, - root: Node, - *, - scope: MappingScope, - base_tokens: list[str], - ) -> tuple[SDLParseDiagnostic, ...]: - self._walk(root, scope=scope, tokens=base_tokens, active=set()) - return tuple( - sorted( - self.diagnostics, - key=lambda item: ( - item.primary_range.start.line, - item.primary_range.start.column, - item.code, - item.pointer, - ), - ) - ) - - def _walk( - self, - node: Node, - *, - scope: MappingScope, - tokens: list[str], - active: set[int], - ) -> None: - if self._source_ranges is not None: - self._source_ranges[_encode_pointer(tokens)] = _range_from_node(node) - identity = id(node) - if identity in active: - self._add_alias_cycle(node, tokens) - else: - walk_key = (identity, scope) - if walk_key not in self._walked and isinstance(node, (MappingNode, SequenceNode)): - self._walked.add(walk_key) - active.add(identity) - try: - if isinstance(node, MappingNode): - self._walk_mapping(node, scope=scope, tokens=tokens, active=active) - else: - self._walk_sequence(node, scope=scope, tokens=tokens, active=active) - finally: - active.remove(identity) - - def _add_alias_cycle(self, node: Node, tokens: list[str]) -> None: - self._add( - SDLParseDiagnostic( - code="sdl.alias_cycle", - message="Cyclic YAML aliases are not valid SDL authoring input.", - pointer=_encode_pointer(tokens), - primary_range=_range_from_node(node), - source=self._source, - ) - ) - - def _walk_sequence( - self, - node: SequenceNode, - *, - scope: MappingScope, - tokens: list[str], - active: set[int], - ) -> None: - for index, item in enumerate(node.value): - self._walk(item, scope=scope, tokens=[*tokens, str(index)], active=active) - - def _walk_mapping( - self, - node: MappingNode, - *, - scope: MappingScope, - tokens: list[str], - active: set[int], - ) -> None: - effective = self._effective_mapping(node, scope=scope, active=set()) - conflicted_key_nodes = {id(entry.key_node) for pair in effective.conflicts for entry in pair} - for first, conflicting in effective.conflicts: - self._add_conflict(first, conflicting, tokens) - for key_node, value_node in node.value: - self._walk_mapping_entry( - key_node, - value_node, - scope=scope, - tokens=tokens, - active=active, - suppress_field_migration=id(key_node) in conflicted_key_nodes, - ) - - def _add_conflict(self, first: _Entry, conflicting: _Entry, tokens: list[str]) -> None: - self._add( - SDLParseDiagnostic( - code="sdl.mapping_key_conflict", - message=_conflict_message(first, conflicting), - pointer=_encode_pointer([*tokens, conflicting.canonical]), - authored_keys=(first.authored, conflicting.authored), - primary_range=_range_from_node(conflicting.key_node), - related_range=_range_from_node(first.key_node), - related_message=f"First authored key '{first.authored}'.", - source=self._source, - ) - ) - - def _walk_mapping_entry( - self, - key_node: Node, - value_node: Node, - *, - scope: MappingScope, - tokens: list[str], - active: set[int], - suppress_field_migration: bool, - ) -> None: - if _is_merge_key(key_node): - self._add_migration_diagnostic( - key_node, - code="sdl.noncanonical_merge", - message="YAML merge keys are migration syntax, not canonical sdl-yaml/v1.", - pointer=_encode_pointer(tokens), - authored_keys=("<<", "<<"), - ) - self._walk_merge_value(value_node, scope=scope, tokens=tokens, active=active) - return - authored = _authored_key(key_node) - if not _is_string_key(key_node): - self._add_key_type_diagnostic(key_node, authored, tokens) - return - canonical = normalize_field_key(authored) if scope is MappingScope.STRUCTURAL else authored - if scope is MappingScope.STRUCTURAL and canonical != authored and not suppress_field_migration: - self._add_migration_diagnostic( - key_node, - code="sdl.noncanonical_field", - message=f"Structural field '{authored}' must use canonical spelling '{canonical}'.", - pointer=_encode_pointer([*tokens, canonical]), - authored_keys=(authored, canonical), - ) - child_tokens = [*tokens, canonical] - if is_declaration_key_path(tokens) and not suppress_field_migration: - self._validate_identifier_node(key_node, pointer_tokens=child_tokens) - if tokens == ["nodes"] and len(authored) > 35: - self._add_identifier_diagnostic(key_node, pointer_tokens=child_tokens, node_limit=True) - if child_tokens == ["name"]: - self._validate_identifier_node(value_node, pointer_tokens=child_tokens) - if is_scalar_identifier_path(child_tokens): - self._validate_identifier_node(value_node, pointer_tokens=child_tokens) - child_scope = _child_scope(scope, canonical, value_node) - self._walk(value_node, scope=child_scope, tokens=child_tokens, active=active) - - def _validate_identifier_node(self, node: Node, *, pointer_tokens: list[str]) -> None: - if not isinstance(node, ScalarNode) or node.tag != _STRING_TAG or not is_portable_identifier(node.value): - self._add_identifier_diagnostic(node, pointer_tokens=pointer_tokens) - - def _add_identifier_diagnostic( - self, - node: Node, - *, - pointer_tokens: list[str], - node_limit: bool = False, - ) -> None: - message = ( - "Authored node identifiers must be at most 35 characters." - if node_limit - else ( - "Authored identifiers must be 1-64 lowercase ASCII letters, digits, hyphens, or " - "underscores and start with a letter or digit." - ) - ) - self._add( - SDLParseDiagnostic( - code="sdl.identifier.invalid", - message=message, - pointer=_encode_pointer(pointer_tokens), - primary_range=_range_from_node(node), - source=self._source, - ) - ) - - def _add_key_type_diagnostic(self, key_node: Node, authored: str, tokens: list[str]) -> None: - message = ( - "SDL top-level mapping keys must be strings" - if not tokens - else f"SDL mapping key '{authored}' must be a string." - ) - self._add( - SDLParseDiagnostic( - code="sdl.mapping_key_type", - message=message, - pointer=_encode_pointer([*tokens, authored]), - primary_range=_range_from_node(key_node), - source=self._source, - ) - ) - - def _add_migration_diagnostic( - self, - key_node: Node, - *, - code: str, - message: str, - pointer: str, - authored_keys: tuple[str, str], - ) -> None: - self._add( - SDLParseDiagnostic( - code=code, - message=message, - pointer=pointer, - primary_range=_range_from_node(key_node), - authored_keys=authored_keys, - severity="warning" if self._migration_policy is SDLMigrationPolicy.ACCEPT else "error", - source=self._source, - ) - ) - - def _walk_merge_value( - self, - node: Node, - *, - scope: MappingScope, - tokens: list[str], - active: set[int], - ) -> None: - if isinstance(node, MappingNode): - self._walk(node, scope=scope, tokens=tokens, active=active) - elif isinstance(node, SequenceNode): - for item in node.value: - self._walk(item, scope=scope, tokens=tokens, active=active) - - def _effective_mapping( - self, - node: MappingNode, - *, - scope: MappingScope, - active: set[int], - ) -> _EffectiveMapping: - cache_key = (id(node), scope) - cached = self._effective_cache.get(cache_key) - if cached is not None: - return cached - if id(node) in active: - return _EffectiveMapping((), ()) - - active.add(id(node)) - accumulator = _EffectiveAccumulator() - try: - merge_keys = self._inherit_merge_entries(node, scope=scope, active=active, accumulator=accumulator) - self._record_duplicate_merge_keys(merge_keys, accumulator) - self._add_local_entries(node, scope=scope, accumulator=accumulator) - finally: - active.remove(id(node)) - - result = accumulator.build() - self._effective_cache[cache_key] = result - return result - - def _inherit_merge_entries( - self, - node: MappingNode, - *, - scope: MappingScope, - active: set[int], - accumulator: _EffectiveAccumulator, - ) -> list[ScalarNode]: - merge_keys: list[ScalarNode] = [] - for key_node, value_node in node.value: - if not _is_merge_key(key_node): - continue - assert isinstance(key_node, ScalarNode) - merge_keys.append(key_node) - for source in _merge_sources(value_node): - if id(source) in active: - continue - inherited = self._effective_mapping(source, scope=scope, active=active) - for entry in inherited.entries: - accumulator.add(entry) - return merge_keys - - @staticmethod - def _record_duplicate_merge_keys( - merge_keys: list[ScalarNode], - accumulator: _EffectiveAccumulator, - ) -> None: - if len(merge_keys) < 2: - return - first = _Entry("<<", "<<", merge_keys[0]) - for key_node in merge_keys[1:]: - accumulator.conflicts.append((first, _Entry("<<", "<<", key_node))) - - @staticmethod - def _add_local_entries( - node: MappingNode, - *, - scope: MappingScope, - accumulator: _EffectiveAccumulator, - ) -> None: - for key_node, _value_node in node.value: - if not _is_string_key(key_node) or _is_merge_key(key_node): - continue - assert isinstance(key_node, ScalarNode) - authored = key_node.value - canonical = normalize_field_key(authored) if scope is MappingScope.STRUCTURAL else authored - accumulator.add(_Entry(canonical, authored, key_node)) - - def _add(self, diagnostic: SDLParseDiagnostic) -> None: - related = diagnostic.related_range - key = ( - diagnostic.code, - diagnostic.pointer, - diagnostic.primary_range.start.line, - diagnostic.primary_range.start.column, - related.start.line if related else None, - related.start.column if related else None, - ) - if key not in self._diagnostic_keys: - self._diagnostic_keys.add(key) - self.diagnostics.append(diagnostic) - - def load_sdl_yaml( content: str, *, @@ -536,57 +162,6 @@ def _validate_mapping_keys( raise SDLParseError(details, path=path, diagnostics=errors) -def _is_merge_key(node: Node) -> bool: - return isinstance(node, ScalarNode) and node.tag == _MERGE_TAG - - -def _is_string_key(node: Node) -> bool: - return isinstance(node, ScalarNode) and node.tag == _STRING_TAG - - -def _authored_key(node: Node) -> str: - if isinstance(node, ScalarNode): - return node.value - return "?" - - -def _merge_sources(node: Node) -> Iterator[MappingNode]: - if isinstance(node, MappingNode): - yield node - elif isinstance(node, SequenceNode): - yield from (item for item in node.value if isinstance(item, MappingNode)) - - -def _child_scope(scope: MappingScope, canonical: str, value_node: Node) -> MappingScope: - is_literal = scope is MappingScope.STRUCTURAL and is_literal_map_field( - canonical, - value_is_mapping=isinstance(value_node, MappingNode), - value_is_sequence=isinstance(value_node, SequenceNode), - ) - return MappingScope.LITERAL if is_literal else MappingScope.STRUCTURAL - - -def _conflict_message(first: _Entry, conflicting: _Entry) -> str: - if first.authored == conflicting.authored: - return f"Duplicate mapping key '{conflicting.authored}'." - return ( - f"Structural field keys '{first.authored}' and '{conflicting.authored}' both address '{conflicting.canonical}'." - ) - - -def _range_from_node(node: Node) -> SDLSourceRange: - return SDLSourceRange( - start=SDLSourcePosition(node.start_mark.line + 1, node.start_mark.column + 1), - end=SDLSourcePosition(node.end_mark.line + 1, node.end_mark.column + 1), - ) - - -def _encode_pointer(tokens: list[str]) -> str: - if not tokens: - return "" - return "/" + "/".join(token.replace("~", "~0").replace("/", "~1") for token in tokens) - - def _decode_pointer(pointer: str) -> list[str]: if not pointer: return [] diff --git a/implementations/python/packages/aces_sdl/parser.py b/implementations/python/packages/aces_sdl/parser.py index 1226a6aa3..2d8ac7f23 100644 --- a/implementations/python/packages/aces_sdl/parser.py +++ b/implementations/python/packages/aces_sdl/parser.py @@ -26,6 +26,10 @@ is_literal_map_field, normalize_field_key, ) +from ._model_diagnostics import ( + _dedupe_source_diagnostics, + _model_parse_error, +) from ._source_profile import ( DEFAULT_PARSER_LIMITS, SDL_SOURCE_FORMAT, @@ -368,94 +372,6 @@ def parse_sdl( return scenario -def _dedupe_source_diagnostics( - diagnostics: list[SDLParseDiagnostic], -) -> list[SDLParseDiagnostic]: - unique: list[SDLParseDiagnostic] = [] - seen: set[tuple[object, ...]] = set() - for diagnostic in diagnostics: - start = diagnostic.primary_range.start - end = diagnostic.primary_range.end - key = ( - diagnostic.source, - diagnostic.code, - diagnostic.pointer, - start.line, - start.column, - end.line, - end.column, - ) - if key not in seen: - seen.add(key) - unique.append(diagnostic) - return unique - - -def _pointer_from_location(location: tuple[object, ...]) -> str: - tokens = [str(part) for part in location if str(part) != "[key]"] - return "".join(f"/{token.replace('~', '~0').replace('/', '~1')}" for token in tokens) - - -def _nearest_source_range(pointer: str, source_ranges: dict[str, SDLSourceRange]) -> SDLSourceRange: - candidate = pointer - while candidate: - source_range = source_ranges.get(candidate) - if source_range is not None: - return source_range - candidate = candidate.rsplit("/", 1)[0] - source_range = source_ranges.get("") - if source_range is not None: - return source_range - position = SDLSourcePosition(1, 1) - return SDLSourceRange(start=position, end=position) - - -_MODEL_DIAGNOSTIC_MESSAGE_MAX_LENGTH = 512 - - -def _bounded_model_message(message: str) -> str: - """Render validator-owned prose without Pydantic's input or traceback.""" - - if message.startswith("Value error, "): - message = message.removeprefix("Value error, ") - escaped = "".join(character if character.isprintable() else f"\\u{ord(character):04x}" for character in message) - if len(escaped) <= _MODEL_DIAGNOSTIC_MESSAGE_MAX_LENGTH: - return escaped - return escaped[: _MODEL_DIAGNOSTIC_MESSAGE_MAX_LENGTH - 3] + "..." - - -def _model_parse_error( - error: ValidationError, - *, - path: Path | None, - source_ranges: dict[str, SDLSourceRange], -) -> SDLParseError: - diagnostics: list[SDLParseDiagnostic] = [] - for item in error.errors(): - pointer = _pointer_from_location(tuple(item.get("loc", ()))) - raw_message = str(item.get("msg", "")) - is_identifier = "portable SDL identifier" in raw_message or "qualified SDL identifier" in raw_message - message = _bounded_model_message(raw_message) - diagnostics.append( - SDLParseDiagnostic( - code="sdl.identifier.invalid" if is_identifier else "sdl.model.invalid", - message=message, - pointer=pointer, - primary_range=_nearest_source_range(pointer, source_ranges), - source=str(path) if path is not None else None, - ) - ) - diagnostics = _dedupe_source_diagnostics(diagnostics) - rendered = "; ".join(f"{diagnostic.pointer or '/'}: {diagnostic.message}" for diagnostic in diagnostics[:8]) - if len(diagnostics) > 8: - rendered += f", and {len(diagnostics) - 8} more" - return SDLParseError( - f"SDL model validation failed at {rendered or '/'}", - path=path, - diagnostics=diagnostics, - ) - - def parse_sdl_file(path: Path, **kwargs: Any) -> Scenario: """Parse an SDL YAML file into a validated Scenario. diff --git a/implementations/python/tests/test_sdl_identifiers.py b/implementations/python/tests/test_sdl_identifiers.py index a6cf71353..f84039d61 100644 --- a/implementations/python/tests/test_sdl_identifiers.py +++ b/implementations/python/tests/test_sdl_identifiers.py @@ -33,6 +33,7 @@ from aces_processor.models import NetworkRuntime, NodeRuntime, RuntimeModel from aces_sdl._declarations import build_declaration_index from aces_sdl._errors import SDLParseError, SDLValidationError +from aces_sdl._model_diagnostics import _bounded_model_message from aces_sdl._source_profile import SDLParserLimits from aces_sdl.identifiers import ( PORTABLE_IDENTIFIER_JSON_SCHEMA, @@ -44,7 +45,7 @@ from aces_sdl.infrastructure import ACLRule from aces_sdl.instantiate import instantiate_scenario from aces_sdl.nodes import ServicePort -from aces_sdl.parser import _bounded_model_message, parse_sdl +from aces_sdl.parser import parse_sdl from aces_sdl.runtime_values import require_symbol from aces_sdl.scenario import ExpandedScenario, ImportDecl, ModuleDescriptor, Scenario from aces_sdl.validator import SemanticValidator