From 06251f26146db16f99dc3bbd7ca0d762ac198f44 Mon Sep 17 00:00:00 2001 From: aidankhogg Date: Sun, 2 Aug 2026 12:00:32 +0100 Subject: [PATCH 1/3] feat: add sovereign change planning --- CHANGELOG.md | 4 + README.md | 8 +- docs/roadmap.md | 44 ++- pyproject.toml | 2 +- src/netsovereign/__init__.py | 22 +- src/netsovereign/cli.py | 55 +++- src/netsovereign/planning.py | 519 +++++++++++++++++++++++++++++++++++ tests/test_planning.py | 83 ++++++ uv.lock | 2 +- 9 files changed, 729 insertions(+), 10 deletions(-) create mode 100644 src/netsovereign/planning.py create mode 100644 tests/test_planning.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 69eed5a..0bdf99a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ # Changelog +## 0.2.0 - 2026-08-02 +- Added canonical world and manifest digests, revision lineage, semantic change sets, offline observed-state evidence, mandate-aware admission, explicit risks and approvals, and deterministic provider-neutral reconciliation plans. +- Added `diff`, `admit`, and `plan` commands. Planning is deliberately non-executable and keeps canonical intent, capabilities, provider bindings, and observed drift separate. + ## 0.1.0 - 2026-08-01 - Added the v0alpha2 provider-neutral world model, semantic diagnostics, defaults, manifest, graph, resolver derivation, CLI, examples, schema, and documentation. diff --git a/README.md b/README.md index 71e15b5..024e994 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # NetSovereign -> **Experimental / pre-alpha:** v0.1 models intent only. It provides no operational infrastructure or security guarantees. +> **Experimental / pre-alpha:** v0.2 models intent and plans change only. It provides no operational infrastructure or security guarantees. NetSovereign defines sovereign digital worlds. **NetEngine** is the future compiler and reconciliation runtime that may materialise declared authorities through replaceable providers. Sovereignty means that recognised institutions can govern a world's naming, numbering, registry, trust, identity, transit, mail, and catalogue authority without making any particular service canonical. @@ -12,6 +12,12 @@ Requires Python 3.12+. `uv sync --all-extras`, or install the wheel with pip. Co netsovereign validate examples/minimal/world.yaml netsovereign manifest examples/minimal/world.yaml netsovereign explain examples/minimal/world.yaml +netsovereign diff current.yaml proposed.yaml +netsovereign admit current.yaml proposed.yaml --observed observed.json +netsovereign plan current.yaml proposed.yaml --observed observed.json ``` +The change commands compare meaning, evaluate declared mandates, classify approval risks and offline +drift, and describe convergence without executing providers or touching infrastructure. + See [the domain guide](docs/domain-model.md), [v0alpha2 schema guide](docs/schema-v0alpha2.md), and [roadmap](docs/roadmap.md). The broader example is illustrative: assurance claims and provider-shaped seams are declarations, not implemented guarantees. diff --git a/docs/roadmap.md b/docs/roadmap.md index 686a807..106387e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,4 +1,40 @@ -# Alpha roadmap -- **v0.1 (implemented):** offline sovereign domain declaration, validation, manifest, graph, explanation. -- **Proposed later:** admission against observed state, durable operations, compilation and reconciliation. -- **Deferred:** containers, DNS/PKI/database/identity/gateway implementations, firewall/routing/NAT/BGP, SMTP, object storage, Kubernetes, WireGuard, public ingress, HA, DNSSEC rotation, OIDC federation, CA cross-signing, management API/UI, and provider execution. +# NetSovereign roadmap + +NetSovereign develops authority and intent before operational adapters. Versions v0.2 through v0.4 +are a single dependency chain: real DNS, PKI, identity, or gateway providers must not begin until +change planning, the runtime core, and durable local control have established their boundaries. + +| Version | Development outcome | Major boundary | +| --- | --- | --- | +| **v0.1 — Sovereign Domain Foundation** | Offline declarations, validation, manifests, authority graphs, and explanations. | No planning or infrastructure mutation. | +| **v0.2 — Sovereign Change Planning** | Compare current, proposed, and observed worlds; admit or reject changes; generate deterministic reconciliation plans. | No provider execution or infrastructure mutation. | +| **v0.3 — NetEngine Runtime Core** | Provider contracts, compilation pipeline, execution state machine, dry-run/mock provider, evidence, and rollback semantics. | Only simulated or non-operational providers. | +| **v0.4 — Durable Local Control Plane** | Persistent accepted state, journals, checkpoints, locks, recovery, drift detection, and repeated reconciliation. | Local single-node operation; no HA. | +| **v0.5 — Authoritative Naming Slice** | Registry/registrar to naming capability to generated authoritative DNS configuration. | Isolated local development environment only. | +| **v0.6 — Trust Authority** | Certificate policy, issuance lifecycle, revocation, and a replaceable step-ca-style provider. | No cross-world CA trust. | +| **v0.7 — Identity Authorities** | Separate platform and in-world identity; subjects, claims, credentials, and OIDC provider materialisation. | Federation remains explicitly opt-in. | +| **v0.8 — Registry and Numbering** | Organisation, name, and number allocation; registration, grant, delegation, and revocation workflows. | No real BGP or public-number claims. | +| **v0.9 — Boundary and Transit** | Materialise isolated, shadowed, mirrored, and exposed postures through resolver, routing, and policy providers. | Destructive or public exposure requires approval gates. | +| **v0.10 — Peering and Federation** | Cross-world discovery, signed exchange artefacts, and selective authority import/export. | Peering never implies trust. | +| **v0.11 — Mail and Service Catalogue** | Activate deferred authority families and their protocol surfaces. | Optional capabilities, not minimal-world requirements. | +| **v0.12 — Operational Product Surface** | Management API, CLI operations, audit views, lifecycle workflows, backup/restore, and multi-world administration. | Pre-1.0 until security and upgrade guarantees mature. | +| **v1.0 — Sovereign Runtime** | Stable schemas, migrations, conformance suite, hardened providers, upgrade guarantees, recovery, and documented security model. | Production compatibility commitment begins. | + +## v0.2 acceptance boundary + +Given an accepted world, a proposed declaration, and optional offline observations, v0.2 returns a +deterministic account of what changed, whether declared authority admits it, the applicable mandate, +risk and approval gates, existing drift, and provider-neutral convergence steps. Observations are +evidence rather than authority, admission does not authenticate a proposer, and plans cannot execute. + +The planner distinguishes canonical intent, capabilities, replaceable provider bindings, and observed +drift. Stable world, authority, and resource identities cannot silently change meaning; retirement is +preferred to deletion; imported authority cannot silently become local; peering cannot gain trust by +ordinary update; imports and exports must remain explicit; and reductions in autonomy or new required +external dependencies are visible approval risks. + +## Explicitly deferred from v0.2 + +Provider SDKs and concrete provider packages; subprocess, container, or network execution; CoreDNS, +step-ca, Keycloak, nftables, and PostgreSQL; durable databases and daemons; secrets; actual rollback; +provider discovery; live observation; and management APIs or user interfaces. diff --git a/pyproject.toml b/pyproject.toml index acfb918..22a21c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "netsovereign" -version = "0.1.0" +version = "0.2.0" description = "Provider-neutral domain foundation for sovereign digital worlds" readme = "README.md" requires-python = ">=3.12" diff --git a/src/netsovereign/__init__.py b/src/netsovereign/__init__.py index 8f1525b..2595736 100644 --- a/src/netsovereign/__init__.py +++ b/src/netsovereign/__init__.py @@ -1,8 +1,26 @@ """NetSovereign sovereign domain foundation.""" from .manifest import WorldManifest, build_manifest +from .planning import ( + AdmissionDecision, + ReconciliationPlan, + admit_change, + build_plan, + compare_worlds, +) from .specification import WorldSpec from .validation import Diagnostic, validate_spec -__all__ = ["Diagnostic", "WorldManifest", "WorldSpec", "build_manifest", "validate_spec"] -__version__ = "0.1.0" +__all__ = [ + "AdmissionDecision", + "Diagnostic", + "ReconciliationPlan", + "WorldManifest", + "WorldSpec", + "admit_change", + "build_manifest", + "build_plan", + "compare_worlds", + "validate_spec", +] +__version__ = "0.2.0" diff --git a/src/netsovereign/cli.py b/src/netsovereign/cli.py index 1e52ea4..990bc03 100644 --- a/src/netsovereign/cli.py +++ b/src/netsovereign/cli.py @@ -4,12 +4,15 @@ import json from pathlib import Path +from typing import Annotated import typer -from pydantic import ValidationError +import yaml +from pydantic import BaseModel, ValidationError from .io import load_spec from .manifest import build_manifest, explain_manifest +from .planning import ObservedStateSnapshot, admit_change, build_plan, compare_worlds from .specification import WorldSpec from .validation import has_errors, validate_spec @@ -24,6 +27,28 @@ def _parse(path: Path) -> WorldSpec: raise typer.Exit(2) from exc +def _observed(path: Path | None) -> ObservedStateSnapshot | None: + if path is None: + return None + try: + return ObservedStateSnapshot.model_validate( + yaml.safe_load(path.read_text(encoding="utf-8")) + ) + except (OSError, ValidationError, ValueError) as exc: + typer.echo(f"OBSERVED_STRUCTURE_ERROR {path}: {exc}", err=True) + raise typer.Exit(2) from exc + + +def _json(value: object) -> None: + if isinstance(value, BaseModel): + value = value.model_dump(mode="json") + elif isinstance(value, list): + value = [ + item.model_dump(mode="json") if isinstance(item, BaseModel) else item for item in value + ] + typer.echo(json.dumps(value, indent=2, sort_keys=True)) + + @app.command() def validate(path: Path) -> None: """Structurally and semantically validate a world declaration.""" @@ -56,5 +81,33 @@ def explain(path: Path) -> None: typer.echo(explain_manifest(build_manifest(spec))) +@app.command("diff") +def diff_command(current: Path, proposed: Path) -> None: + """Emit a deterministic semantic change set; provider bindings remain separate.""" + _json(compare_worlds(_parse(current), _parse(proposed))) + + +@app.command() +def admit( + current: Path, proposed: Path, observed: Annotated[Path | None, typer.Option()] = None +) -> None: + """Evaluate declared authority and emit a stable admission decision.""" + decision = admit_change(_parse(current), _parse(proposed), _observed(observed)) + _json(decision) + if not decision.admitted: + raise typer.Exit(1) + + +@app.command() +def plan( + current: Path, proposed: Path, observed: Annotated[Path | None, typer.Option()] = None +) -> None: + """Emit a non-executable, provider-neutral convergence plan.""" + decision = admit_change(_parse(current), _parse(proposed), _observed(observed)) + _json(build_plan(decision)) + if not decision.admitted: + raise typer.Exit(1) + + if __name__ == "__main__": app() diff --git a/src/netsovereign/planning.py b/src/netsovereign/planning.py new file mode 100644 index 0000000..e311999 --- /dev/null +++ b/src/netsovereign/planning.py @@ -0,0 +1,519 @@ +"""Pure, provider-neutral sovereign change admission and reconciliation planning.""" + +from __future__ import annotations + +import hashlib +import json +from datetime import datetime +from enum import StrEnum +from typing import Any + +from pydantic import Field + +from .base import DomainModel +from .manifest import build_manifest +from .specification import WorldSpec +from .validation import has_errors, validate_spec + + +class ChangeClassification(StrEnum): + CANONICAL_INTENT = "canonical_intent" + CAPABILITY = "capability" + PROVIDER_BINDING = "provider_binding" + OBSERVED_DRIFT = "observed_drift" + + +class ChangeOperation(StrEnum): + ADD = "add" + MODIFY = "modify" + REMOVE = "remove" + + +class Risk(StrEnum): + LOW = "low" + GOVERNED = "governed" + AUTONOMY_REGRESSION = "autonomy_regression" + EXTERNAL_DEPENDENCY = "external_dependency" + TRUST = "trust" + EXPOSURE = "exposure" + + +class ObservedFact(DomainModel): + """A non-canonical observation addressed by a stable semantic path.""" + + path: str + value: Any + + +class ObservedStateSnapshot(DomainModel): + api_version: str = Field(default="netsovereign.io/observed/v0.2", alias="apiVersion") + world_id: str + observed_at: datetime + provenance: str + facts: list[ObservedFact] = Field(default_factory=list) + + +class AcceptedWorldRevision(DomainModel): + world_id: str + revision: str + parent_revision: str | None = None + world_digest: str + manifest_digest: str + + +class Change(DomainModel): + id: str + classification: ChangeClassification + operation: ChangeOperation + path: str + subject_id: str | None = None + before: Any = None + after: Any = None + authority_id: str | None = None + mandate_id: str | None = None + risk: Risk = Risk.LOW + approval_required: bool = False + explanation: str + + +class AdmissionIssue(DomainModel): + code: str + message: str + path: str + + +class AdmissionDecision(DomainModel): + api_version: str = "netsovereign.io/admission/v0.2" + admitted: bool + current: AcceptedWorldRevision + proposed: AcceptedWorldRevision + changes: list[Change] + drift: list[Change] + issues: list[AdmissionIssue] + approval_gates: list[str] + explanation: str + + +class PlanStep(DomainModel): + id: str + change_id: str + action: str + target: str + depends_on: list[str] = Field(default_factory=list) + preconditions: list[str] + expected_outcomes: list[str] + reversible: bool + authority_id: str | None = None + mandate_id: str | None = None + + +class ReconciliationPlan(DomainModel): + api_version: str = "netsovereign.io/plan/v0.2" + world_id: str + from_revision: str + to_revision: str + admitted: bool + plan_digest: str + steps: list[PlanStep] + drift: list[Change] + approval_gates: list[str] + execution: str = "not_permitted" + explanation: str = "Provider-neutral plan only; no infrastructure was touched." + + +def _normalise(value: Any) -> Any: + if isinstance(value, dict): + return {key: _normalise(value[key]) for key in sorted(value)} + if isinstance(value, list): + items = [_normalise(item) for item in value] + return sorted( + items, key=lambda item: json.dumps(item, sort_keys=True, separators=(",", ":")) + ) + return value + + +def canonical_json(value: Any) -> str: + """Return stable JSON independent of declaration ordering and formatting.""" + + if isinstance(value, DomainModel): + value = value.model_dump(mode="json", by_alias=True) + return json.dumps(_normalise(value), sort_keys=True, separators=(",", ":"), ensure_ascii=False) + + +def digest(value: Any) -> str: + return "sha256:" + hashlib.sha256(canonical_json(value).encode()).hexdigest() + + +def accepted_revision(spec: WorldSpec, parent_revision: str | None = None) -> AcceptedWorldRevision: + diagnostics = validate_spec(spec) + manifest_value: Any = ( + {"invalid_world": spec.model_dump(mode="json")} + if has_errors(diagnostics) + else build_manifest(spec) + ) + return AcceptedWorldRevision( + world_id=spec.world.id, + revision=spec.world.revision, + parent_revision=parent_revision, + world_digest=digest(spec), + manifest_digest=digest(manifest_value), + ) + + +_COLLECTIONS = ( + "institutions", + "authorities", + "mandates", + "resources", + "registrations", + "allocations", + "grants", + "delegations", + "capabilities", + "provider_bindings", + "external_dependencies", +) + + +def _key(collection: str, item: dict[str, Any]) -> str: + if collection == "provider_bindings": + return str(item["capability"]) + return str(item["id"]) + + +def _classification(path: str) -> ChangeClassification: + if path.startswith("capabilities/"): + return ChangeClassification.CAPABILITY + if path.startswith("provider_bindings/"): + return ChangeClassification.PROVIDER_BINDING + return ChangeClassification.CANONICAL_INTENT + + +def _authority_for(spec: WorldSpec, collection: str, item: dict[str, Any]) -> str | None: + if collection == "authorities": + return str(item.get("id")) + for field in ("authority_id", "authority", "from_authority_id", "registry_authority_id"): + if item.get(field): + return str(item[field]) + if collection in {"institutions", "capabilities", "external_dependencies"}: + roots = [a.id for a in spec.authorities if a.kind == "world_root"] + return roots[0] if roots else None + return None + + +def _mandate_for(spec: WorldSpec, authority_id: str | None) -> str | None: + candidates = [ + m.id for m in spec.mandates if m.authority_id == authority_id and m.status == "active" + ] + return sorted(candidates)[0] if candidates else None + + +def compare_worlds(current: WorldSpec, proposed: WorldSpec) -> list[Change]: + """Semantically compare declarations while keeping bindings out of canonical meaning.""" + + before = current.model_dump(mode="json") + after = proposed.model_dump(mode="json") + changes: list[Change] = [] + + def add( + path: str, + operation: ChangeOperation, + old: Any, + new: Any, + subject: str | None = None, + collection: str = "world", + ) -> None: + classification = _classification(path) + authority = _authority_for( + proposed if operation != ChangeOperation.REMOVE else current, + collection, + new if isinstance(new, dict) else old if isinstance(old, dict) else {}, + ) + authority_spec = proposed if operation != ChangeOperation.REMOVE else current + if path.startswith("boundary/"): + authority = next( + (a.id for a in authority_spec.authorities if a.kind == "transit"), None + ) + elif path.startswith(("autonomy/", "world/")): + authority = next( + (a.id for a in authority_spec.authorities if a.kind == "world_root"), None + ) + mandate = _mandate_for(current, authority) + risk = ( + Risk.GOVERNED if classification == ChangeClassification.CANONICAL_INTENT else Risk.LOW + ) + approval = risk != Risk.LOW + if path == "autonomy/target" and old != new: + risk, approval = Risk.AUTONOMY_REGRESSION, True + if path.startswith("external_dependencies/") and operation != ChangeOperation.REMOVE: + risk, approval = Risk.EXTERNAL_DEPENDENCY, bool((new or {}).get("required", True)) + if path.startswith("boundary/") and ( + "federat" in canonical_json(new) or "exposed" in canonical_json(new) + ): + risk, approval = ( + (Risk.TRUST if "federat" in canonical_json(new) else Risk.EXPOSURE), + True, + ) + ident = digest({"path": path, "operation": operation})[7:19] + changes.append( + Change( + id=f"change-{ident}", + classification=classification, + operation=operation, + path=path, + subject_id=subject, + before=old, + after=new, + authority_id=authority, + mandate_id=mandate, + risk=risk, + approval_required=approval, + explanation=f"{operation.value.title()} {path} ({classification.value}).", + ) + ) + + # World metadata (revision is lineage metadata, not an institutional change). + for field in ("id", "name", "lifecycle", "description"): + if before["world"].get(field) != after["world"].get(field): + add( + f"world/{field}", + ChangeOperation.MODIFY, + before["world"].get(field), + after["world"].get(field), + ) + if before["autonomy"] != after["autonomy"]: + for field in sorted(set(before["autonomy"]) | set(after["autonomy"])): + if before["autonomy"].get(field) != after["autonomy"].get(field): + add( + f"autonomy/{field}", + ChangeOperation.MODIFY, + before["autonomy"].get(field), + after["autonomy"].get(field), + ) + for collection in _COLLECTIONS: + old_items = {_key(collection, item): item for item in before[collection]} + new_items = {_key(collection, item): item for item in after[collection]} + for item_id in sorted(set(old_items) | set(new_items)): + path = f"{collection}/{item_id}" + if item_id not in old_items: + add(path, ChangeOperation.ADD, None, new_items[item_id], item_id, collection) + elif item_id not in new_items: + add(path, ChangeOperation.REMOVE, old_items[item_id], None, item_id, collection) + elif _normalise(old_items[item_id]) != _normalise(new_items[item_id]): + add( + path, + ChangeOperation.MODIFY, + old_items[item_id], + new_items[item_id], + item_id, + collection, + ) + for field in sorted(set(before["boundary"]) | set(after["boundary"])): + if _normalise(before["boundary"].get(field)) != _normalise(after["boundary"].get(field)): + add( + f"boundary/{field}", + ChangeOperation.MODIFY, + before["boundary"].get(field), + after["boundary"].get(field), + ) + return sorted(changes, key=lambda change: (change.path, change.operation)) + + +def _observed_drift(proposed: WorldSpec, observed: ObservedStateSnapshot | None) -> list[Change]: + if observed is None: + return [] + declared = proposed.model_dump(mode="json") + drift: list[Change] = [] + for fact in sorted(observed.facts, key=lambda item: item.path): + value: Any = declared + try: + for part in fact.path.strip("/").split("/"): + value = value[int(part)] if isinstance(value, list) else value[part] + except (KeyError, IndexError, ValueError, TypeError): + value = None + if _normalise(value) != _normalise(fact.value): + drift.append( + Change( + id=f"drift-{digest(fact.model_dump(mode='json'))[7:19]}", + classification=ChangeClassification.OBSERVED_DRIFT, + operation=ChangeOperation.MODIFY, + path=fact.path, + before=fact.value, + after=value, + explanation=f"Observed evidence from {observed.provenance} differs from declared intent.", + ) + ) + return drift + + +def admit_change( + current: WorldSpec, proposed: WorldSpec, observed: ObservedStateSnapshot | None = None +) -> AdmissionDecision: + changes = compare_worlds(current, proposed) + issues: list[AdmissionIssue] = [] + if current.world.id != proposed.world.id: + issues.append( + AdmissionIssue( + code="world_id_changed", + path="world/id", + message="A world ID cannot be changed in place.", + ) + ) + if current.world.revision == proposed.world.revision and changes: + issues.append( + AdmissionIssue( + code="revision_not_advanced", + path="world/revision", + message="A meaningful change requires a new revision.", + ) + ) + for diagnostic in validate_spec(proposed): + if diagnostic.severity == "error": + issues.append( + AdmissionIssue( + code=diagnostic.code, path=diagnostic.location, message=diagnostic.message + ) + ) + for change in changes: + if ( + change.operation == ChangeOperation.REMOVE + and change.classification == ChangeClassification.CANONICAL_INTENT + ): + issues.append( + AdmissionIssue( + code="implicit_deletion", + path=change.path, + message="Retire stable objects explicitly instead of deleting them.", + ) + ) + if change.path.startswith("authorities/") and change.operation == ChangeOperation.MODIFY: + old, new = change.before or {}, change.after or {} + if old.get("kind") != new.get("kind") or old.get("scope") != new.get("scope"): + issues.append( + AdmissionIssue( + code="authority_identity_redefined", + path=change.path, + message="A stable authority identity cannot silently change meaning.", + ) + ) + if ( + old.get("source") != new.get("source") + and old.get("source") != "local" + and new.get("source") == "local" + ): + issues.append( + AdmissionIssue( + code="import_became_local", + path=change.path, + message="Imported authority cannot be converted silently into local authority.", + ) + ) + if ( + change.path.startswith("resources/") + and change.operation == ChangeOperation.MODIFY + and (change.before or {}).get("class") != (change.after or {}).get("class") + ): + issues.append( + AdmissionIssue( + code="resource_identity_redefined", + path=change.path, + message="A stable resource identity cannot silently change class.", + ) + ) + governed = ( + change.classification == ChangeClassification.CANONICAL_INTENT + and not change.path.startswith("world/") + ) + if governed and not change.mandate_id and not change.path.startswith("mandates/"): + issues.append( + AdmissionIssue( + code="missing_applicable_mandate", + path=change.path, + message="No applicable active mandate permits this governed change.", + ) + ) + if ( + change.path == "boundary/authority_imports" + and "peered" in canonical_json(change.before) + and "federated" in canonical_json(change.after) + ): + issues.append( + AdmissionIssue( + code="peering_acquired_trust", + path=change.path, + message="Peering cannot acquire trust through an ordinary update.", + ) + ) + if observed and observed.world_id != proposed.world.id: + issues.append( + AdmissionIssue( + code="observed_world_mismatch", + path="observed/world_id", + message="Observed evidence belongs to another world.", + ) + ) + gates = sorted( + { + f"approve:{str(change.risk)}:{change.id}" + for change in changes + if change.approval_required + } + ) + drift = _observed_drift(proposed, observed) + admitted = not issues + return AdmissionDecision( + admitted=admitted, + current=accepted_revision(current), + proposed=accepted_revision(proposed, current.world.revision), + changes=changes, + drift=drift, + issues=sorted(issues, key=lambda item: (item.path, item.code)), + approval_gates=gates, + explanation=( + "Proposal is coherent and admitted subject to listed approvals." + if admitted + else "Proposal is rejected; resolve every admission issue before planning convergence." + ), + ) + + +def build_plan(decision: AdmissionDecision) -> ReconciliationPlan: + steps: list[PlanStep] = [] + if decision.admitted: + for index, change in enumerate(decision.changes, 1): + step_id = f"step-{index:04d}" + preconditions = [f"accepted revision is {decision.current.revision}"] + if change.mandate_id: + preconditions.append(f"mandate {change.mandate_id} remains active") + if change.approval_required: + preconditions.append(f"approval approve:{str(change.risk)}:{change.id} is recorded") + steps.append( + PlanStep( + id=step_id, + change_id=change.id, + action=str(change.operation), + target=change.path, + depends_on=[steps[-1].id] if steps else [], + preconditions=preconditions, + expected_outcomes=[f"declared {change.path} equals the proposed revision"], + reversible=change.operation != ChangeOperation.REMOVE, + authority_id=change.authority_id, + mandate_id=change.mandate_id, + ) + ) + payload = { + "world": decision.proposed.world_id, + "from": decision.current.revision, + "to": decision.proposed.revision, + "steps": [s.model_dump(mode="json") for s in steps], + } + return ReconciliationPlan( + world_id=decision.proposed.world_id, + from_revision=decision.current.revision, + to_revision=decision.proposed.revision, + admitted=decision.admitted, + plan_digest=digest(payload), + steps=steps, + drift=decision.drift, + approval_gates=decision.approval_gates, + ) diff --git a/tests/test_planning.py b/tests/test_planning.py new file mode 100644 index 0000000..7929cec --- /dev/null +++ b/tests/test_planning.py @@ -0,0 +1,83 @@ +import json +from copy import deepcopy +from pathlib import Path + +import yaml +from typer.testing import CliRunner + +from netsovereign.cli import app +from netsovereign.planning import ( + ObservedStateSnapshot, + admit_change, + build_plan, + canonical_json, + compare_worlds, + digest, +) +from netsovereign.specification import WorldSpec + +ROOT = Path(__file__).parents[1] + + +def worlds(): + current_data = yaml.safe_load((ROOT / "examples/minimal/world.yaml").read_text()) + proposed_data = deepcopy(current_data) + proposed_data["world"]["revision"] = "2" + proposed_data["providerBindings"][0]["provider"] = "another-replaceable-provider" + return WorldSpec.model_validate(current_data), WorldSpec.model_validate(proposed_data) + + +def test_canonical_digest_is_order_independent(): + assert canonical_json({"b": [2, 1], "a": 1}) == canonical_json({"a": 1, "b": [1, 2]}) + assert digest({"b": [2, 1], "a": 1}) == digest({"a": 1, "b": [1, 2]}) + + +def test_binding_change_is_separate_and_admitted(): + current, proposed = worlds() + changes = compare_worlds(current, proposed) + assert [change.classification for change in changes] == ["provider_binding"] + decision = admit_change(current, proposed) + assert decision.admitted and not decision.approval_gates + plan = build_plan(decision) + assert plan.execution == "not_permitted" and len(plan.steps) == 1 + assert plan.plan_digest == build_plan(decision).plan_digest + + +def test_identity_change_and_deletion_are_rejected(): + current, proposed = worlds() + proposed.authorities[0].kind = "root_naming" + proposed.resources = [] + decision = admit_change(current, proposed) + codes = {issue.code for issue in decision.issues} + assert {"authority_identity_redefined", "implicit_deletion"} <= codes + assert not decision.admitted and build_plan(decision).steps == [] + + +def test_observation_is_drift_not_authority(): + current, proposed = worlds() + observed = ObservedStateSnapshot.model_validate( + { + "world_id": "minimal", + "observed_at": "2026-01-01T00:00:00Z", + "provenance": "offline-test", + "facts": [{"path": "world/name", "value": "Wrong"}], + } + ) + decision = admit_change(current, proposed, observed) + assert decision.admitted and decision.drift[0].classification == "observed_drift" + + +def test_cli_diff_admit_and_plan(tmp_path): + current, proposed = worlds() + current_path, proposed_path = tmp_path / "current.yaml", tmp_path / "proposed.yaml" + current_path.write_text(yaml.safe_dump(current.model_dump(mode="json", by_alias=True))) + proposed_path.write_text(yaml.safe_dump(proposed.model_dump(mode="json", by_alias=True))) + runner = CliRunner() + diff = runner.invoke(app, ["diff", str(current_path), str(proposed_path)]) + assert ( + diff.exit_code == 0 and json.loads(diff.stdout)[0]["classification"] == "provider_binding" + ) + admitted = runner.invoke(app, ["admit", str(current_path), str(proposed_path)]) + assert admitted.exit_code == 0 and json.loads(admitted.stdout)["admitted"] + plan = runner.invoke(app, ["plan", str(current_path), str(proposed_path)]) + assert plan.exit_code == 0 and json.loads(plan.stdout)["execution"] == "not_permitted" diff --git a/uv.lock b/uv.lock index 2fe689e..ae46510 100644 --- a/uv.lock +++ b/uv.lock @@ -236,7 +236,7 @@ wheels = [ [[package]] name = "netsovereign" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "pydantic" }, From 3fbb5dbaf6e01759e1d1377c0f218195a6b3e100 Mon Sep 17 00:00:00 2001 From: aidankhogg Date: Sun, 2 Aug 2026 12:34:31 +0100 Subject: [PATCH 2/3] fix: enforce stable resource identity in admission --- src/netsovereign/planning.py | 227 +++++++++++++++++++++++++++++++---- tests/test_planning.py | 77 +++++++++++- 2 files changed, 277 insertions(+), 27 deletions(-) diff --git a/src/netsovereign/planning.py b/src/netsovereign/planning.py index e311999..748bfb8 100644 --- a/src/netsovereign/planning.py +++ b/src/netsovereign/planning.py @@ -121,14 +121,46 @@ class ReconciliationPlan(DomainModel): explanation: str = "Provider-neutral plan only; no infrastructure was touched." -def _normalise(value: Any) -> Any: +_SET_LIKE_LIST_FIELDS = { + "accepted_audiences", + "actions", + "authority_exports", + "claims", + "controls", + "dns_suffixes", + "egress", + "ingress", + "mail_domains", + "resource_classes", + "resources", +} + + +def _normalise(value: Any, path: tuple[str, ...] = ()) -> Any: + """Normalise maps and domain sets without changing JSON-array semantics. + + In particular, arrays below a provider binding's free-form ``configuration`` are + deliberately order-sensitive. + """ + if isinstance(value, dict): - return {key: _normalise(value[key]) for key in sorted(value)} + return {key: _normalise(value[key], (*path, key)) for key in sorted(value)} if isinstance(value, list): - items = [_normalise(item) for item in value] - return sorted( - items, key=lambda item: json.dumps(item, sort_keys=True, separators=(",", ":")) + items = [_normalise(item, (*path, "[]")) for item in value] + in_provider_configuration = "configuration" in path and any( + part in {"providerBindings", "provider_bindings"} for part in path ) + collection_is_set = bool(path) and ( + path[-1] in _COLLECTIONS + or path[-1] in {"providerBindings", "externalDependencies"} + or path[-1] in _SET_LIKE_LIST_FIELDS + or path[-1] in {"peers", "authority_imports", "mirrors"} + ) + if collection_is_set and not in_provider_configuration: + return sorted( + items, key=lambda item: json.dumps(item, sort_keys=True, separators=(",", ":")) + ) + return items return value @@ -191,7 +223,7 @@ def _classification(path: str) -> ChangeClassification: def _authority_for(spec: WorldSpec, collection: str, item: dict[str, Any]) -> str | None: if collection == "authorities": - return str(item.get("id")) + return next((a.id for a in spec.authorities if a.kind == "world_root"), None) for field in ("authority_id", "authority", "from_authority_id", "registry_authority_id"): if item.get(field): return str(item[field]) @@ -201,14 +233,109 @@ def _authority_for(spec: WorldSpec, collection: str, item: dict[str, Any]) -> st return None -def _mandate_for(spec: WorldSpec, authority_id: str | None) -> str | None: - candidates = [ - m.id for m in spec.mandates if m.authority_id == authority_id and m.status == "active" - ] +def _governed_dimensions( + spec: WorldSpec, + collection: str, + path: str, + operation: ChangeOperation, + item: Any, +) -> tuple[str, list[str], str]: + """Derive mandate action, resource classes, and jurisdiction for a change.""" + + action = "revoke" if operation == ChangeOperation.REMOVE else "declare" + resource_classes: list[str] = [] + if collection == "resources" and isinstance(item, dict) and item.get("resource_class"): + resource_classes = [str(item["resource_class"])] + elif collection == "registrations": + action, resource_classes = "admit", ["registration"] + elif collection == "allocations" and isinstance(item, dict): + action, resource_classes = ( + "allocate", + [ + next( + ( + str(resource.resource_class) + for resource in spec.resources + if resource.id == item.get("resource_id") + ), + "number", + ) + ], + ) + elif collection == "grants": + action = "delegate" + elif collection in {"delegations", "mandates"} and isinstance(item, dict): + action, resource_classes = ( + "delegate", + [str(value) for value in item.get("resource_classes", [])], + ) + elif collection == "institutions": + action, resource_classes = "admit", ["organisation"] + elif collection in {"authorities", "capabilities", "external_dependencies"}: + resource_classes = ["world"] + elif path.startswith("boundary/"): + action, resource_classes = ( + ("expose", ["route"]) if "exposed" in canonical_json(item) else ("route", ["route"]) + ) + elif path.startswith(("autonomy/", "world/")): + resource_classes = ["world"] + return action, resource_classes, spec.world.id + + +def _constraints_allow( + constraints: dict[str, Any], operation: ChangeOperation, path: str, subject: str | None +) -> bool: + """Evaluate the generic constraint vocabulary; unknown constraints fail closed.""" + + known = {"operations", "paths", "subject_ids"} + if set(constraints) - known: + return False + if constraints.get("operations") and str(operation) not in constraints["operations"]: + return False + if constraints.get("paths") and path not in constraints["paths"]: + return False + return not constraints.get("subject_ids") or subject in constraints["subject_ids"] + + +def _mandate_for( + spec: WorldSpec, + authority_id: str | None, + action: str, + resource_classes: list[str], + jurisdiction: str, + operation: ChangeOperation, + path: str, + subject: str | None, + observed_at: datetime | None = None, +) -> str | None: + """Select an active mandate applicable to every governed dimension.""" + + candidates: list[str] = [] + for mandate in spec.mandates: + if mandate.authority_id != authority_id or mandate.status != "active": + continue + if action not in mandate.actions or not set(resource_classes) <= set( + mandate.resource_classes + ): + continue + if mandate.jurisdiction not in {jurisdiction, "*"}: + continue + if mandate.validity: + if observed_at is None: + continue + if mandate.validity.not_before and observed_at < mandate.validity.not_before: + continue + if mandate.validity.not_after and observed_at > mandate.validity.not_after: + continue + if not _constraints_allow(mandate.constraints, operation, path, subject): + continue + candidates.append(mandate.id) return sorted(candidates)[0] if candidates else None -def compare_worlds(current: WorldSpec, proposed: WorldSpec) -> list[Change]: +def compare_worlds( + current: WorldSpec, proposed: WorldSpec, evaluated_at: datetime | None = None +) -> list[Change]: """Semantically compare declarations while keeping bindings out of canonical meaning.""" before = current.model_dump(mode="json") @@ -238,7 +365,21 @@ def add( authority = next( (a.id for a in authority_spec.authorities if a.kind == "world_root"), None ) - mandate = _mandate_for(current, authority) + governed_item = new if new is not None else old + action, resource_classes, jurisdiction = _governed_dimensions( + authority_spec, collection, path, operation, governed_item + ) + mandate = _mandate_for( + current, + authority, + action, + resource_classes, + jurisdiction, + operation, + path, + subject, + evaluated_at, + ) risk = ( Risk.GOVERNED if classification == ChangeClassification.CANONICAL_INTENT else Risk.LOW ) @@ -254,7 +395,7 @@ def add( (Risk.TRUST if "federat" in canonical_json(new) else Risk.EXPOSURE), True, ) - ident = digest({"path": path, "operation": operation})[7:19] + ident = digest({"path": path, "operation": operation, "before": old, "after": new})[7:19] changes.append( Change( id=f"change-{ident}", @@ -299,7 +440,9 @@ def add( add(path, ChangeOperation.ADD, None, new_items[item_id], item_id, collection) elif item_id not in new_items: add(path, ChangeOperation.REMOVE, old_items[item_id], None, item_id, collection) - elif _normalise(old_items[item_id]) != _normalise(new_items[item_id]): + elif _normalise(old_items[item_id], (collection, item_id)) != _normalise( + new_items[item_id], (collection, item_id) + ): add( path, ChangeOperation.MODIFY, @@ -319,17 +462,32 @@ def add( return sorted(changes, key=lambda change: (change.path, change.operation)) +def _resolve_semantic_path(document: Any, path: str) -> Any: + """Resolve list members by stable ``id`` (or binding capability), never position alone.""" + + value = document + segments = path.strip("/").split("/") + for index, part in enumerate(segments): + if isinstance(value, list): + collection = segments[index - 1] if index else "" + key = "capability" if collection in {"providerBindings", "provider_bindings"} else "id" + value = next( + item for item in value if isinstance(item, dict) and str(item.get(key)) == part + ) + else: + value = value[part] + return value + + def _observed_drift(proposed: WorldSpec, observed: ObservedStateSnapshot | None) -> list[Change]: if observed is None: return [] declared = proposed.model_dump(mode="json") drift: list[Change] = [] for fact in sorted(observed.facts, key=lambda item: item.path): - value: Any = declared try: - for part in fact.path.strip("/").split("/"): - value = value[int(part)] if isinstance(value, list) else value[part] - except (KeyError, IndexError, ValueError, TypeError): + value = _resolve_semantic_path(declared, fact.path) + except (KeyError, StopIteration, TypeError): value = None if _normalise(value) != _normalise(fact.value): drift.append( @@ -349,7 +507,7 @@ def _observed_drift(proposed: WorldSpec, observed: ObservedStateSnapshot | None) def admit_change( current: WorldSpec, proposed: WorldSpec, observed: ObservedStateSnapshot | None = None ) -> AdmissionDecision: - changes = compare_worlds(current, proposed) + changes = compare_worlds(current, proposed, observed.observed_at if observed else None) issues: list[AdmissionIssue] = [] if current.world.id != proposed.world.id: issues.append( @@ -411,7 +569,8 @@ def admit_change( if ( change.path.startswith("resources/") and change.operation == ChangeOperation.MODIFY - and (change.before or {}).get("class") != (change.after or {}).get("class") + and (change.before or {}).get("resource_class") + != (change.after or {}).get("resource_class") ): issues.append( AdmissionIssue( @@ -480,9 +639,16 @@ def admit_change( def build_plan(decision: AdmissionDecision) -> ReconciliationPlan: steps: list[PlanStep] = [] if decision.admitted: - for index, change in enumerate(decision.changes, 1): + convergence = [*decision.changes, *decision.drift] + for index, change in enumerate(convergence, 1): step_id = f"step-{index:04d}" - preconditions = [f"accepted revision is {decision.current.revision}"] + is_drift = change.classification == ChangeClassification.OBSERVED_DRIFT + preconditions = [ + f"accepted revision is {decision.current.revision}", + f"proposed world digest is {decision.proposed.world_digest}", + ] + if is_drift: + preconditions.append(f"observed {change.path} still equals recorded evidence") if change.mandate_id: preconditions.append(f"mandate {change.mandate_id} remains active") if change.approval_required: @@ -491,12 +657,18 @@ def build_plan(decision: AdmissionDecision) -> ReconciliationPlan: PlanStep( id=step_id, change_id=change.id, - action=str(change.operation), + action="reconcile_drift" if is_drift else str(change.operation), target=change.path, depends_on=[steps[-1].id] if steps else [], preconditions=preconditions, - expected_outcomes=[f"declared {change.path} equals the proposed revision"], - reversible=change.operation != ChangeOperation.REMOVE, + expected_outcomes=[ + ( + f"observed {change.path} converges to declared value" + if is_drift + else f"declared {change.path} equals the proposed revision" + ) + ], + reversible=not is_drift and change.operation != ChangeOperation.REMOVE, authority_id=change.authority_id, mandate_id=change.mandate_id, ) @@ -505,6 +677,11 @@ def build_plan(decision: AdmissionDecision) -> ReconciliationPlan: "world": decision.proposed.world_id, "from": decision.current.revision, "to": decision.proposed.revision, + "current_world_digest": decision.current.world_digest, + "proposed_world_digest": decision.proposed.world_digest, + "proposed_manifest_digest": decision.proposed.manifest_digest, + "changes": [change.model_dump(mode="json") for change in decision.changes], + "drift": [change.model_dump(mode="json") for change in decision.drift], "steps": [s.model_dump(mode="json") for s in steps], } return ReconciliationPlan( diff --git a/tests/test_planning.py b/tests/test_planning.py index 7929cec..deaef4d 100644 --- a/tests/test_planning.py +++ b/tests/test_planning.py @@ -28,8 +28,10 @@ def worlds(): def test_canonical_digest_is_order_independent(): - assert canonical_json({"b": [2, 1], "a": 1}) == canonical_json({"a": 1, "b": [1, 2]}) - assert digest({"b": [2, 1], "a": 1}) == digest({"a": 1, "b": [1, 2]}) + assert canonical_json({"b": 2, "a": 1}) == canonical_json({"a": 1, "b": 2}) + assert digest({"controls": ["delegate", "declare"]}) == digest( + {"controls": ["declare", "delegate"]} + ) def test_binding_change_is_separate_and_admitted(): @@ -53,6 +55,13 @@ def test_identity_change_and_deletion_are_rejected(): assert not decision.admitted and build_plan(decision).steps == [] +def test_resource_class_change_redefines_stable_identity(): + current, proposed = worlds() + proposed.resources[0].resource_class = "domain" + decision = admit_change(current, proposed) + assert "resource_identity_redefined" in {issue.code for issue in decision.issues} + + def test_observation_is_drift_not_authority(): current, proposed = worlds() observed = ObservedStateSnapshot.model_validate( @@ -67,6 +76,70 @@ def test_observation_is_drift_not_authority(): assert decision.admitted and decision.drift[0].classification == "observed_drift" +def test_drift_only_plan_contains_a_convergence_step(): + current, _ = worlds() + observed = ObservedStateSnapshot.model_validate( + { + "world_id": "minimal", + "observed_at": "2026-01-01T00:00:00Z", + "provenance": "offline-test", + "facts": [{"path": "world/name", "value": "Stale materialised name"}], + } + ) + decision = admit_change(current, current, observed) + plan = build_plan(decision) + assert decision.changes == [] and len(decision.drift) == 1 + assert plan.steps[0].action == "reconcile_drift" + assert "converges to declared value" in plan.steps[0].expected_outcomes[0] + + +def test_observed_collection_path_resolves_stable_id(): + current, _ = worlds() + resource = current.resources[0].model_dump(mode="json") + observed = ObservedStateSnapshot.model_validate( + { + "world_id": "minimal", + "observed_at": "2026-01-01T00:00:00Z", + "provenance": "offline-test", + "facts": [{"path": "resources/root-zone", "value": resource}], + } + ) + assert admit_change(current, current, observed).drift == [] + + +def test_provider_configuration_arrays_remain_ordered(): + current, proposed = worlds() + current.provider_bindings[0].configuration = {"fallbacks": ["primary", "secondary"]} + proposed.provider_bindings[0].configuration = {"fallbacks": ["secondary", "primary"]} + changes = compare_worlds(current, proposed) + assert len(changes) == 1 and changes[0].classification == "provider_binding" + assert digest(current) != digest(proposed) + + +def test_mandate_must_cover_action_resource_and_jurisdiction(): + current, proposed = worlds() + proposed.resources.append( + proposed.resources[0].model_copy(update={"id": "domain", "resource_class": "domain"}) + ) + decision = admit_change(current, proposed) + change = next(change for change in decision.changes if change.path == "resources/domain") + assert change.authority_id == "root-naming" and change.mandate_id is None + assert "missing_applicable_mandate" in {issue.code for issue in decision.issues} + + +def test_change_and_plan_digests_bind_exact_proposed_content(): + current, first = worlds() + second = first.model_copy(deep=True) + first.world.name = "First proposed name" + second.world.name = "Second proposed name" + first_decision = admit_change(current, first) + second_decision = admit_change(current, second) + first_name = next(change for change in first_decision.changes if change.path == "world/name") + second_name = next(change for change in second_decision.changes if change.path == "world/name") + assert first_name.id != second_name.id + assert build_plan(first_decision).plan_digest != build_plan(second_decision).plan_digest + + def test_cli_diff_admit_and_plan(tmp_path): current, proposed = worlds() current_path, proposed_path = tmp_path / "current.yaml", tmp_path / "proposed.yaml" From 547a19d7bfbb5077d36e7b83102a6c4462b668c9 Mon Sep 17 00:00:00 2001 From: aidankhogg Date: Sun, 2 Aug 2026 13:11:27 +0100 Subject: [PATCH 3/3] feat: publish planning artifacts and CLI contracts --- CHANGELOG.md | 1 + README.md | 2 +- docs/change-planning-v0.2.md | 61 +++ schemas/admission-v0.2.schema.json | 400 ++++++++++++++++++++ schemas/observed-v0.2.schema.json | 94 +++++ schemas/plan-v0.2.schema.json | 489 ++++++++++++++++++++++++ schemas/world-v0alpha2.schema.json | 64 +++- src/netsovereign/__init__.py | 8 + src/netsovereign/authority.py | 26 +- src/netsovereign/canonical.py | 83 ++++ src/netsovereign/cli.py | 120 +++++- src/netsovereign/defaults.py | 3 +- src/netsovereign/planning.py | 588 +++++++++++++++++++++-------- src/netsovereign/validation.py | 11 + tests/test_planning.py | 195 +++++++++- 15 files changed, 1952 insertions(+), 193 deletions(-) create mode 100644 docs/change-planning-v0.2.md create mode 100644 schemas/admission-v0.2.schema.json create mode 100644 schemas/observed-v0.2.schema.json create mode 100644 schemas/plan-v0.2.schema.json create mode 100644 src/netsovereign/canonical.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bdf99a..fae87d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 0.2.0 - 2026-08-02 - Added canonical world and manifest digests, revision lineage, semantic change sets, offline observed-state evidence, mandate-aware admission, explicit risks and approvals, and deterministic provider-neutral reconciliation plans. - Added `diff`, `admit`, and `plan` commands. Planning is deliberately non-executable and keeps canonical intent, capabilities, provider bindings, and observed drift separate. +- Added explicit admission/approval states, typed mandate constraints and observation outcomes, lineage validation, separate intent/materialization digests, lifecycle policy, typed plan predicates, semantic dependency graphs, and published planning artifact schemas. ## 0.1.0 - 2026-08-01 - Added the v0alpha2 provider-neutral world model, semantic diagnostics, defaults, manifest, graph, resolver derivation, CLI, examples, schema, and documentation. diff --git a/README.md b/README.md index 024e994..ab6a4ca 100644 --- a/README.md +++ b/README.md @@ -20,4 +20,4 @@ netsovereign plan current.yaml proposed.yaml --observed observed.json The change commands compare meaning, evaluate declared mandates, classify approval risks and offline drift, and describe convergence without executing providers or touching infrastructure. -See [the domain guide](docs/domain-model.md), [v0alpha2 schema guide](docs/schema-v0alpha2.md), and [roadmap](docs/roadmap.md). The broader example is illustrative: assurance claims and provider-shaped seams are declarations, not implemented guarantees. +See [the domain guide](docs/domain-model.md), [v0.2 change-planning guide](docs/change-planning-v0.2.md), [v0alpha2 schema guide](docs/schema-v0alpha2.md), and [roadmap](docs/roadmap.md). The broader example is illustrative: assurance claims and provider-shaped seams are declarations, not implemented guarantees. diff --git a/docs/change-planning-v0.2.md b/docs/change-planning-v0.2.md new file mode 100644 index 0000000..398e32e --- /dev/null +++ b/docs/change-planning-v0.2.md @@ -0,0 +1,61 @@ +# Sovereign change planning v0.2 + +v0.2 is an offline decision surface. It compares declarations, evaluates the authority already +accepted by the current world, classifies evidence, and emits a provider-neutral plan. It never +loads providers, contacts a network, invokes a subprocess, or mutates infrastructure. + +## Artifact identities + +Every revision exposes separate SHA-256 identities: + +- **declaration digest** covers the normalized complete declaration; +- **canonical-intent digest** excludes revision metadata and replaceable provider bindings; +- **materialization digest** covers capabilities and provider bindings; +- **manifest digest** exists only for a semantically valid manifest. + +A proposal may provide its expected parent revision and declaration digest. A mismatch is rejected +as stale lineage. Change and drift identifiers use full content digests, so approvals bind to the +exact reviewed before/after values. + +## Admission states and time + +Admission has three outcomes: `rejected`, `pending_approval`, and `admitted`. An approval-gated +proposal is not admitted until matching approval evidence is supplied. `evaluated_at` is an explicit +input used for mandate validity; observation timestamps describe evidence and never select the +admission time. + +Mandates are matched against authority, governed action, resource classes, jurisdiction, validity, +and typed constraints (`operations`, `paths`, and `subject_ids`). Current accepted state is validated +before it can authorise a proposal. + +## Observations and plans + +Facts use stable semantic paths such as `resources/root-zone`, and distinguish `present`, `absent`, +`unknown`, and `unreadable`. Unknown or unreadable evidence does not manufacture drift. Actionable +drift carries its authority and mandate into a convergence step. + +Plan preconditions and expected outcomes are typed JSON objects rather than prose. Dependencies form +a deterministic semantic DAG: for example, a changed provider binding depends on its changed +capability, while unrelated changes remain independent. Reversibility is explicitly classified and +remains `unknown` unless v0.2 can justify a stronger provider-neutral statement. + +Schemas are published at: + +- `schemas/observed-v0.2.schema.json`; +- `schemas/admission-v0.2.schema.json`; +- `schemas/plan-v0.2.schema.json`. + +## CLI contract + +```console +netsovereign diff current.yaml proposed.yaml +netsovereign admit current.yaml proposed.yaml \ + --evaluated-at 2026-08-02T12:00:00Z \ + --parent-revision 7 --parent-digest sha256:... \ + --observed observed.json --approval approval.yaml +netsovereign plan current.yaml proposed.yaml --format yaml --output plan.yaml +``` + +`admit` and `plan` exit with `0` when admitted, `1` when rejected, `2` for malformed input or CLI +usage, and `3` while approval is pending. `--format json|yaml`, `--compact`, and `--output` control +serialization without changing artifact identity. diff --git a/schemas/admission-v0.2.schema.json b/schemas/admission-v0.2.schema.json new file mode 100644 index 0000000..8048ed9 --- /dev/null +++ b/schemas/admission-v0.2.schema.json @@ -0,0 +1,400 @@ +{ + "$defs": { + "AcceptedWorldRevision": { + "additionalProperties": false, + "properties": { + "canonical_intent_digest": { + "title": "Canonical Intent Digest", + "type": "string" + }, + "declaration_digest": { + "title": "Declaration Digest", + "type": "string" + }, + "manifest_digest": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Manifest Digest" + }, + "materialization_digest": { + "title": "Materialization Digest", + "type": "string" + }, + "parent_revision": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Parent Revision" + }, + "revision": { + "title": "Revision", + "type": "string" + }, + "world_digest": { + "title": "World Digest", + "type": "string" + }, + "world_id": { + "title": "World Id", + "type": "string" + } + }, + "required": [ + "world_id", + "revision", + "declaration_digest", + "canonical_intent_digest", + "materialization_digest", + "world_digest", + "manifest_digest" + ], + "title": "AcceptedWorldRevision", + "type": "object" + }, + "AdmissionIssue": { + "additionalProperties": false, + "properties": { + "code": { + "title": "Code", + "type": "string" + }, + "message": { + "title": "Message", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + } + }, + "required": [ + "code", + "message", + "path" + ], + "title": "AdmissionIssue", + "type": "object" + }, + "AdmissionStatus": { + "enum": [ + "rejected", + "pending_approval", + "admitted" + ], + "title": "AdmissionStatus", + "type": "string" + }, + "ApprovalEvidence": { + "additionalProperties": false, + "properties": { + "approval_id": { + "title": "Approval Id", + "type": "string" + }, + "approved_at": { + "format": "date-time", + "title": "Approved At", + "type": "string" + }, + "provenance": { + "title": "Provenance", + "type": "string" + } + }, + "required": [ + "approval_id", + "approved_at", + "provenance" + ], + "title": "ApprovalEvidence", + "type": "object" + }, + "Change": { + "additionalProperties": false, + "properties": { + "actionable": { + "default": true, + "title": "Actionable", + "type": "boolean" + }, + "after": { + "default": null, + "title": "After" + }, + "approval_required": { + "default": false, + "title": "Approval Required", + "type": "boolean" + }, + "authority_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Authority Id" + }, + "before": { + "default": null, + "title": "Before" + }, + "classification": { + "$ref": "#/$defs/ChangeClassification" + }, + "explanation": { + "title": "Explanation", + "type": "string" + }, + "governance": { + "anyOf": [ + { + "$ref": "#/$defs/GovernanceRequirement" + }, + { + "type": "null" + } + ], + "default": null + }, + "id": { + "title": "Id", + "type": "string" + }, + "mandate_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mandate Id" + }, + "operation": { + "$ref": "#/$defs/ChangeOperation" + }, + "path": { + "title": "Path", + "type": "string" + }, + "risk": { + "$ref": "#/$defs/Risk", + "default": "low" + }, + "subject_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Subject Id" + } + }, + "required": [ + "id", + "classification", + "operation", + "path", + "explanation" + ], + "title": "Change", + "type": "object" + }, + "ChangeClassification": { + "enum": [ + "canonical_intent", + "capability", + "provider_binding", + "observed_drift" + ], + "title": "ChangeClassification", + "type": "string" + }, + "ChangeOperation": { + "enum": [ + "add", + "modify", + "remove" + ], + "title": "ChangeOperation", + "type": "string" + }, + "GovernanceRequirement": { + "additionalProperties": false, + "properties": { + "action": { + "$ref": "#/$defs/GovernedAction" + }, + "jurisdiction": { + "title": "Jurisdiction", + "type": "string" + }, + "resource_classes": { + "items": { + "$ref": "#/$defs/ResourceClass" + }, + "title": "Resource Classes", + "type": "array" + } + }, + "required": [ + "action", + "resource_classes", + "jurisdiction" + ], + "title": "GovernanceRequirement", + "type": "object" + }, + "GovernedAction": { + "enum": [ + "declare", + "admit", + "allocate", + "delegate", + "revoke", + "route", + "expose" + ], + "title": "GovernedAction", + "type": "string" + }, + "ResourceClass": { + "enum": [ + "world", + "name", + "number", + "organisation", + "domain", + "registration", + "certificate", + "platform_identity", + "inworld_identity", + "route", + "mail_domain", + "service" + ], + "title": "ResourceClass", + "type": "string" + }, + "Risk": { + "enum": [ + "low", + "governed", + "autonomy_regression", + "external_dependency", + "trust", + "exposure" + ], + "title": "Risk", + "type": "string" + } + }, + "additionalProperties": false, + "properties": { + "admitted": { + "title": "Admitted", + "type": "boolean" + }, + "api_version": { + "default": "netsovereign.io/admission/v0.2", + "title": "Api Version", + "type": "string" + }, + "approval_gates": { + "items": { + "type": "string" + }, + "title": "Approval Gates", + "type": "array" + }, + "approvals": { + "items": { + "$ref": "#/$defs/ApprovalEvidence" + }, + "title": "Approvals", + "type": "array" + }, + "changes": { + "items": { + "$ref": "#/$defs/Change" + }, + "title": "Changes", + "type": "array" + }, + "current": { + "$ref": "#/$defs/AcceptedWorldRevision" + }, + "drift": { + "items": { + "$ref": "#/$defs/Change" + }, + "title": "Drift", + "type": "array" + }, + "evaluated_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Evaluated At" + }, + "explanation": { + "title": "Explanation", + "type": "string" + }, + "issues": { + "items": { + "$ref": "#/$defs/AdmissionIssue" + }, + "title": "Issues", + "type": "array" + }, + "proposed": { + "$ref": "#/$defs/AcceptedWorldRevision" + }, + "status": { + "$ref": "#/$defs/AdmissionStatus" + } + }, + "required": [ + "admitted", + "status", + "current", + "proposed", + "changes", + "drift", + "issues", + "approval_gates", + "explanation" + ], + "title": "AdmissionDecision", + "type": "object" +} diff --git a/schemas/observed-v0.2.schema.json b/schemas/observed-v0.2.schema.json new file mode 100644 index 0000000..4a22be1 --- /dev/null +++ b/schemas/observed-v0.2.schema.json @@ -0,0 +1,94 @@ +{ + "$defs": { + "ObservedFact": { + "additionalProperties": false, + "description": "A non-canonical observation addressed by a stable semantic path.", + "properties": { + "observed_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Observed At" + }, + "path": { + "title": "Path", + "type": "string" + }, + "provenance": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Provenance" + }, + "status": { + "default": "present", + "enum": [ + "present", + "absent", + "unknown", + "unreadable" + ], + "title": "Status", + "type": "string" + }, + "value": { + "default": null, + "title": "Value" + } + }, + "required": [ + "path" + ], + "title": "ObservedFact", + "type": "object" + } + }, + "additionalProperties": false, + "properties": { + "apiVersion": { + "default": "netsovereign.io/observed/v0.2", + "title": "Apiversion", + "type": "string" + }, + "facts": { + "items": { + "$ref": "#/$defs/ObservedFact" + }, + "title": "Facts", + "type": "array" + }, + "observed_at": { + "format": "date-time", + "title": "Observed At", + "type": "string" + }, + "provenance": { + "title": "Provenance", + "type": "string" + }, + "world_id": { + "title": "World Id", + "type": "string" + } + }, + "required": [ + "world_id", + "observed_at", + "provenance" + ], + "title": "ObservedStateSnapshot", + "type": "object" +} diff --git a/schemas/plan-v0.2.schema.json b/schemas/plan-v0.2.schema.json new file mode 100644 index 0000000..b41c0a0 --- /dev/null +++ b/schemas/plan-v0.2.schema.json @@ -0,0 +1,489 @@ +{ + "$defs": { + "Change": { + "additionalProperties": false, + "properties": { + "actionable": { + "default": true, + "title": "Actionable", + "type": "boolean" + }, + "after": { + "default": null, + "title": "After" + }, + "approval_required": { + "default": false, + "title": "Approval Required", + "type": "boolean" + }, + "authority_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Authority Id" + }, + "before": { + "default": null, + "title": "Before" + }, + "classification": { + "$ref": "#/$defs/ChangeClassification" + }, + "explanation": { + "title": "Explanation", + "type": "string" + }, + "governance": { + "anyOf": [ + { + "$ref": "#/$defs/GovernanceRequirement" + }, + { + "type": "null" + } + ], + "default": null + }, + "id": { + "title": "Id", + "type": "string" + }, + "mandate_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mandate Id" + }, + "operation": { + "$ref": "#/$defs/ChangeOperation" + }, + "path": { + "title": "Path", + "type": "string" + }, + "risk": { + "$ref": "#/$defs/Risk", + "default": "low" + }, + "subject_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Subject Id" + } + }, + "required": [ + "id", + "classification", + "operation", + "path", + "explanation" + ], + "title": "Change", + "type": "object" + }, + "ChangeClassification": { + "enum": [ + "canonical_intent", + "capability", + "provider_binding", + "observed_drift" + ], + "title": "ChangeClassification", + "type": "string" + }, + "ChangeOperation": { + "enum": [ + "add", + "modify", + "remove" + ], + "title": "ChangeOperation", + "type": "string" + }, + "ExpectedOutcome": { + "additionalProperties": false, + "properties": { + "kind": { + "const": "semantic_path_equals", + "default": "semantic_path_equals", + "title": "Kind", + "type": "string" + }, + "path": { + "title": "Path", + "type": "string" + }, + "value": { + "title": "Value" + }, + "value_digest": { + "title": "Value Digest", + "type": "string" + } + }, + "required": [ + "path", + "value", + "value_digest" + ], + "title": "ExpectedOutcome", + "type": "object" + }, + "GovernanceRequirement": { + "additionalProperties": false, + "properties": { + "action": { + "$ref": "#/$defs/GovernedAction" + }, + "jurisdiction": { + "title": "Jurisdiction", + "type": "string" + }, + "resource_classes": { + "items": { + "$ref": "#/$defs/ResourceClass" + }, + "title": "Resource Classes", + "type": "array" + } + }, + "required": [ + "action", + "resource_classes", + "jurisdiction" + ], + "title": "GovernanceRequirement", + "type": "object" + }, + "GovernedAction": { + "enum": [ + "declare", + "admit", + "allocate", + "delegate", + "revoke", + "route", + "expose" + ], + "title": "GovernedAction", + "type": "string" + }, + "PlanPredicate": { + "additionalProperties": false, + "properties": { + "approval_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Approval Id" + }, + "digest": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Digest" + }, + "kind": { + "$ref": "#/$defs/PredicateKind" + }, + "mandate_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mandate Id" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "revision": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Revision" + }, + "value_digest": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Value Digest" + } + }, + "required": [ + "kind" + ], + "title": "PlanPredicate", + "type": "object" + }, + "PlanStep": { + "additionalProperties": false, + "properties": { + "action": { + "title": "Action", + "type": "string" + }, + "authority_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Authority Id" + }, + "change_id": { + "title": "Change Id", + "type": "string" + }, + "depends_on": { + "items": { + "type": "string" + }, + "title": "Depends On", + "type": "array" + }, + "expected_outcomes": { + "items": { + "$ref": "#/$defs/ExpectedOutcome" + }, + "title": "Expected Outcomes", + "type": "array" + }, + "id": { + "title": "Id", + "type": "string" + }, + "mandate_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mandate Id" + }, + "preconditions": { + "items": { + "$ref": "#/$defs/PlanPredicate" + }, + "title": "Preconditions", + "type": "array" + }, + "reversibility": { + "$ref": "#/$defs/Reversibility" + }, + "reversibility_reason": { + "title": "Reversibility Reason", + "type": "string" + }, + "reversible": { + "title": "Reversible", + "type": "boolean" + }, + "target": { + "title": "Target", + "type": "string" + } + }, + "required": [ + "id", + "change_id", + "action", + "target", + "preconditions", + "expected_outcomes", + "reversible", + "reversibility", + "reversibility_reason" + ], + "title": "PlanStep", + "type": "object" + }, + "PredicateKind": { + "enum": [ + "accepted_revision_equals", + "proposed_digest_equals", + "observation_equals", + "mandate_active", + "approval_present" + ], + "title": "PredicateKind", + "type": "string" + }, + "ResourceClass": { + "enum": [ + "world", + "name", + "number", + "organisation", + "domain", + "registration", + "certificate", + "platform_identity", + "inworld_identity", + "route", + "mail_domain", + "service" + ], + "title": "ResourceClass", + "type": "string" + }, + "Reversibility": { + "enum": [ + "reversible", + "conditionally_reversible", + "irreversible", + "unknown" + ], + "title": "Reversibility", + "type": "string" + }, + "Risk": { + "enum": [ + "low", + "governed", + "autonomy_regression", + "external_dependency", + "trust", + "exposure" + ], + "title": "Risk", + "type": "string" + } + }, + "additionalProperties": false, + "properties": { + "admitted": { + "title": "Admitted", + "type": "boolean" + }, + "api_version": { + "default": "netsovereign.io/plan/v0.2", + "title": "Api Version", + "type": "string" + }, + "approval_gates": { + "items": { + "type": "string" + }, + "title": "Approval Gates", + "type": "array" + }, + "drift": { + "items": { + "$ref": "#/$defs/Change" + }, + "title": "Drift", + "type": "array" + }, + "execution": { + "default": "not_permitted", + "title": "Execution", + "type": "string" + }, + "explanation": { + "default": "Provider-neutral plan only; no infrastructure was touched.", + "title": "Explanation", + "type": "string" + }, + "from_revision": { + "title": "From Revision", + "type": "string" + }, + "plan_digest": { + "title": "Plan Digest", + "type": "string" + }, + "steps": { + "items": { + "$ref": "#/$defs/PlanStep" + }, + "title": "Steps", + "type": "array" + }, + "to_revision": { + "title": "To Revision", + "type": "string" + }, + "world_id": { + "title": "World Id", + "type": "string" + } + }, + "required": [ + "world_id", + "from_revision", + "to_revision", + "admitted", + "plan_digest", + "steps", + "drift", + "approval_gates" + ], + "title": "ReconciliationPlan", + "type": "object" +} diff --git a/schemas/world-v0alpha2.schema.json b/schemas/world-v0alpha2.schema.json index e116f42..aa8e691 100644 --- a/schemas/world-v0alpha2.schema.json +++ b/schemas/world-v0alpha2.schema.json @@ -444,9 +444,8 @@ "type": "string" }, "status": { - "default": "active", - "title": "Status", - "type": "string" + "$ref": "#/$defs/LifecycleStatus", + "default": "active" } }, "required": [ @@ -496,14 +495,24 @@ "title": "Since" }, "status": { - "default": "active", - "title": "Status", - "type": "string" + "$ref": "#/$defs/LifecycleStatus", + "default": "active" } }, "title": "LifecycleMetadata", "type": "object" }, + "LifecycleStatus": { + "enum": [ + "proposed", + "deferred", + "active", + "suspended", + "retired" + ], + "title": "LifecycleStatus", + "type": "string" + }, "Mandate": { "additionalProperties": false, "properties": { @@ -520,9 +529,7 @@ "type": "string" }, "constraints": { - "additionalProperties": true, - "title": "Constraints", - "type": "object" + "$ref": "#/$defs/MandateConstraints" }, "id": { "title": "Id", @@ -541,9 +548,8 @@ "type": "array" }, "status": { - "default": "active", - "title": "Status", - "type": "string" + "$ref": "#/$defs/LifecycleStatus", + "default": "active" }, "validity": { "anyOf": [ @@ -567,6 +573,40 @@ "title": "Mandate", "type": "object" }, + "MandateConstraints": { + "additionalProperties": false, + "description": "Portable v0.2 admission constraints.", + "properties": { + "operations": { + "items": { + "enum": [ + "add", + "modify", + "remove" + ], + "type": "string" + }, + "title": "Operations", + "type": "array" + }, + "paths": { + "items": { + "type": "string" + }, + "title": "Paths", + "type": "array" + }, + "subject_ids": { + "items": { + "type": "string" + }, + "title": "Subject Ids", + "type": "array" + } + }, + "title": "MandateConstraints", + "type": "object" + }, "Mirror": { "additionalProperties": false, "properties": { diff --git a/src/netsovereign/__init__.py b/src/netsovereign/__init__.py index 2595736..36f6936 100644 --- a/src/netsovereign/__init__.py +++ b/src/netsovereign/__init__.py @@ -3,6 +3,10 @@ from .manifest import WorldManifest, build_manifest from .planning import ( AdmissionDecision, + AdmissionStatus, + ApprovalEvidence, + ObservedStateSnapshot, + ParentRevisionReference, ReconciliationPlan, admit_change, build_plan, @@ -13,7 +17,11 @@ __all__ = [ "AdmissionDecision", + "AdmissionStatus", + "ApprovalEvidence", "Diagnostic", + "ObservedStateSnapshot", + "ParentRevisionReference", "ReconciliationPlan", "WorldManifest", "WorldSpec", diff --git a/src/netsovereign/authority.py b/src/netsovereign/authority.py index 7d7755d..0d5607c 100644 --- a/src/netsovereign/authority.py +++ b/src/netsovereign/authority.py @@ -4,7 +4,7 @@ from datetime import datetime from enum import StrEnum -from typing import Any +from typing import Literal from pydantic import Field @@ -44,11 +44,19 @@ class AuthoritySource(StrEnum): EXTERNAL = "external" +class LifecycleStatus(StrEnum): + PROPOSED = "proposed" + DEFERRED = "deferred" + ACTIVE = "active" + SUSPENDED = "suspended" + RETIRED = "retired" + + class Institution(DomainModel): id: str name: str description: str | None = None - status: str = "active" + status: LifecycleStatus = LifecycleStatus.ACTIVE class AuthorityScope(DomainModel): @@ -57,7 +65,7 @@ class AuthorityScope(DomainModel): class LifecycleMetadata(DomainModel): - status: str = "active" + status: LifecycleStatus = LifecycleStatus.ACTIVE since: datetime | None = None revision: str | None = None @@ -93,14 +101,22 @@ class Validity(DomainModel): not_after: datetime | None = None +class MandateConstraints(DomainModel): + """Portable v0.2 admission constraints.""" + + operations: list[Literal["add", "modify", "remove"]] = Field(default_factory=list) + paths: list[str] = Field(default_factory=list) + subject_ids: list[str] = Field(default_factory=list) + + class Mandate(DomainModel): id: str authority_id: str = Field(alias="authority") actions: list[str] = Field(min_length=1) resource_classes: list[ResourceClass] = Field(alias="resources", min_length=1) jurisdiction: str - status: str = "active" - constraints: dict[str, Any] = Field(default_factory=dict) + status: LifecycleStatus = LifecycleStatus.ACTIVE + constraints: MandateConstraints = Field(default_factory=MandateConstraints) validity: Validity | None = None diff --git a/src/netsovereign/canonical.py b/src/netsovereign/canonical.py new file mode 100644 index 0000000..52dc9eb --- /dev/null +++ b/src/netsovereign/canonical.py @@ -0,0 +1,83 @@ +"""Versioned canonical JSON and digest primitives for v0.2 artifacts.""" + +from __future__ import annotations + +import hashlib +import json +from typing import Any + +from .base import DomainModel + +SET_LIKE_COLLECTIONS = { + "institutions", + "authorities", + "mandates", + "resources", + "registrations", + "allocations", + "grants", + "delegations", + "capabilities", + "provider_bindings", + "external_dependencies", + "providerBindings", + "externalDependencies", + "peers", + "authority_imports", + "mirrors", +} + +SET_LIKE_FIELDS = { + "accepted_audiences", + "actions", + "authority_exports", + "claims", + "controls", + "dns_suffixes", + "egress", + "ingress", + "mail_domains", + "resource_classes", + "resources", +} + + +def normalise(value: Any, path: tuple[str, ...] = ()) -> Any: + """Normalise maps and declared sets while retaining ordered JSON arrays.""" + + if isinstance(value, float) and (value != value or value in {float("inf"), float("-inf")}): + raise ValueError("non-finite numbers are not canonical JSON") + if isinstance(value, dict): + return {key: normalise(value[key], (*path, key)) for key in sorted(value)} + if isinstance(value, list): + items = [normalise(item, (*path, "[]")) for item in value] + in_provider_configuration = "configuration" in path and any( + part in {"providerBindings", "provider_bindings"} for part in path + ) + collection_is_set = bool(path) and ( + path[-1] in SET_LIKE_COLLECTIONS or path[-1] in SET_LIKE_FIELDS + ) + if collection_is_set and not in_provider_configuration: + return sorted( + items, key=lambda item: json.dumps(item, sort_keys=True, separators=(",", ":")) + ) + return items + return value + + +def canonical_json(value: Any) -> str: + """Return stable JSON under the NetSovereign v0.2 canonicalization profile.""" + + if isinstance(value, DomainModel): + value = value.model_dump(mode="json", by_alias=True) + return json.dumps( + normalise(value), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + allow_nan=False, + ) + + +def digest(value: Any) -> str: + return "sha256:" + hashlib.sha256(canonical_json(value).encode()).hexdigest() diff --git a/src/netsovereign/cli.py b/src/netsovereign/cli.py index 990bc03..54ac478 100644 --- a/src/netsovereign/cli.py +++ b/src/netsovereign/cli.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from datetime import datetime from pathlib import Path from typing import Annotated @@ -12,7 +13,14 @@ from .io import load_spec from .manifest import build_manifest, explain_manifest -from .planning import ObservedStateSnapshot, admit_change, build_plan, compare_worlds +from .planning import ( + ApprovalEvidence, + ObservedStateSnapshot, + ParentRevisionReference, + admit_change, + build_plan, + compare_worlds, +) from .specification import WorldSpec from .validation import has_errors, validate_spec @@ -39,14 +47,58 @@ def _observed(path: Path | None) -> ObservedStateSnapshot | None: raise typer.Exit(2) from exc -def _json(value: object) -> None: +def _plain(value: object) -> object: if isinstance(value, BaseModel): - value = value.model_dump(mode="json") - elif isinstance(value, list): - value = [ - item.model_dump(mode="json") if isinstance(item, BaseModel) else item for item in value - ] - typer.echo(json.dumps(value, indent=2, sort_keys=True)) + return value.model_dump(mode="json") + if isinstance(value, list): + return [_plain(item) for item in value] + return value + + +def _emit( + value: object, + output_format: str = "json", + compact: bool = False, + output: Path | None = None, +) -> None: + plain = _plain(value) + if output_format == "json": + rendered = json.dumps(plain, indent=None if compact else 2, sort_keys=True) + elif output_format == "yaml": + rendered = yaml.safe_dump(plain, sort_keys=True) + else: + typer.echo("output format must be json or yaml", err=True) + raise typer.Exit(2) + if output: + output.write_text(rendered + ("" if rendered.endswith("\n") else "\n"), encoding="utf-8") + else: + typer.echo(rendered) + + +def _json(value: object) -> None: + _emit(value) + + +def _approvals(paths: list[Path] | None) -> list[ApprovalEvidence]: + evidence: list[ApprovalEvidence] = [] + for path in paths or []: + try: + evidence.append( + ApprovalEvidence.model_validate(yaml.safe_load(path.read_text(encoding="utf-8"))) + ) + except (OSError, ValidationError, ValueError) as exc: + typer.echo(f"APPROVAL_STRUCTURE_ERROR {path}: {exc}", err=True) + raise typer.Exit(2) from exc + return evidence + + +def _parent(revision: str | None, declaration_digest: str | None) -> ParentRevisionReference | None: + if bool(revision) != bool(declaration_digest): + typer.echo("--parent-revision and --parent-digest must be supplied together", err=True) + raise typer.Exit(2) + if revision and declaration_digest: + return ParentRevisionReference(revision=revision, declaration_digest=declaration_digest) + return None @app.command() @@ -89,24 +141,60 @@ def diff_command(current: Path, proposed: Path) -> None: @app.command() def admit( - current: Path, proposed: Path, observed: Annotated[Path | None, typer.Option()] = None + current: Path, + proposed: Path, + observed: Annotated[Path | None, typer.Option()] = None, + evaluated_at: Annotated[datetime | None, typer.Option()] = None, + approval: Annotated[list[Path] | None, typer.Option("--approval")] = None, + parent_revision: Annotated[str | None, typer.Option()] = None, + parent_digest: Annotated[str | None, typer.Option()] = None, + output_format: Annotated[str, typer.Option("--format")] = "json", + compact: Annotated[bool, typer.Option()] = False, + output: Annotated[Path | None, typer.Option()] = None, ) -> None: """Evaluate declared authority and emit a stable admission decision.""" - decision = admit_change(_parse(current), _parse(proposed), _observed(observed)) - _json(decision) - if not decision.admitted: + decision = admit_change( + _parse(current), + _parse(proposed), + _observed(observed), + evaluated_at=evaluated_at, + approvals=_approvals(approval), + parent=_parent(parent_revision, parent_digest), + ) + _emit(decision, output_format, compact, output) + if decision.status == "rejected": raise typer.Exit(1) + if decision.status == "pending_approval": + raise typer.Exit(3) @app.command() def plan( - current: Path, proposed: Path, observed: Annotated[Path | None, typer.Option()] = None + current: Path, + proposed: Path, + observed: Annotated[Path | None, typer.Option()] = None, + evaluated_at: Annotated[datetime | None, typer.Option()] = None, + approval: Annotated[list[Path] | None, typer.Option("--approval")] = None, + parent_revision: Annotated[str | None, typer.Option()] = None, + parent_digest: Annotated[str | None, typer.Option()] = None, + output_format: Annotated[str, typer.Option("--format")] = "json", + compact: Annotated[bool, typer.Option()] = False, + output: Annotated[Path | None, typer.Option()] = None, ) -> None: """Emit a non-executable, provider-neutral convergence plan.""" - decision = admit_change(_parse(current), _parse(proposed), _observed(observed)) - _json(build_plan(decision)) - if not decision.admitted: + decision = admit_change( + _parse(current), + _parse(proposed), + _observed(observed), + evaluated_at=evaluated_at, + approvals=_approvals(approval), + parent=_parent(parent_revision, parent_digest), + ) + _emit(build_plan(decision), output_format, compact, output) + if decision.status == "rejected": raise typer.Exit(1) + if decision.status == "pending_approval": + raise typer.Exit(3) if __name__ == "__main__": diff --git a/src/netsovereign/defaults.py b/src/netsovereign/defaults.py index 671d15d..6348a75 100644 --- a/src/netsovereign/defaults.py +++ b/src/netsovereign/defaults.py @@ -10,6 +10,7 @@ AuthorityScope, Institution, LifecycleMetadata, + LifecycleStatus, ScopeKind, ) @@ -41,7 +42,7 @@ def default_authorities() -> list[Authority]: operator="world-administration", scope=AuthorityScope(kind=cast(ScopeKind, scope)), controls=controls, - lifecycle=LifecycleMetadata(status=status), + lifecycle=LifecycleMetadata(status=cast(LifecycleStatus, status)), ) for id_, kind, scope, controls, status in definitions ] diff --git a/src/netsovereign/planning.py b/src/netsovereign/planning.py index 748bfb8..1d6d3e3 100644 --- a/src/netsovereign/planning.py +++ b/src/netsovereign/planning.py @@ -2,19 +2,22 @@ from __future__ import annotations -import hashlib -import json from datetime import datetime from enum import StrEnum -from typing import Any +from typing import Any, Literal -from pydantic import Field +from pydantic import Field, model_validator +from .authority import MandateConstraints, ResourceClass from .base import DomainModel +from .canonical import canonical_json, digest +from .canonical import normalise as _normalise from .manifest import build_manifest from .specification import WorldSpec from .validation import has_errors, validate_spec +__all__ = ["canonical_json", "digest"] + class ChangeClassification(StrEnum): CANONICAL_INTENT = "canonical_intent" @@ -38,11 +41,42 @@ class Risk(StrEnum): EXPOSURE = "exposure" +class GovernedAction(StrEnum): + DECLARE = "declare" + ADMIT = "admit" + ALLOCATE = "allocate" + DELEGATE = "delegate" + REVOKE = "revoke" + ROUTE = "route" + EXPOSE = "expose" + + +class GovernanceRequirement(DomainModel): + action: GovernedAction + resource_classes: list[ResourceClass] + jurisdiction: str + + +class AdmissionStatus(StrEnum): + REJECTED = "rejected" + PENDING_APPROVAL = "pending_approval" + ADMITTED = "admitted" + + +class ApprovalEvidence(DomainModel): + approval_id: str + approved_at: datetime + provenance: str + + class ObservedFact(DomainModel): """A non-canonical observation addressed by a stable semantic path.""" path: str - value: Any + status: Literal["present", "absent", "unknown", "unreadable"] = "present" + value: Any = None + observed_at: datetime | None = None + provenance: str | None = None class ObservedStateSnapshot(DomainModel): @@ -52,13 +86,32 @@ class ObservedStateSnapshot(DomainModel): provenance: str facts: list[ObservedFact] = Field(default_factory=list) + @model_validator(mode="after") + def coherent_snapshot(self) -> ObservedStateSnapshot: + if self.observed_at.tzinfo is None: + raise ValueError("observed_at must include a timezone") + paths = [fact.path for fact in self.facts] + if len(paths) != len(set(paths)): + raise ValueError("observed fact paths must be unique") + if any(fact.observed_at and fact.observed_at.tzinfo is None for fact in self.facts): + raise ValueError("fact observed_at must include a timezone") + return self + class AcceptedWorldRevision(DomainModel): world_id: str revision: str parent_revision: str | None = None + declaration_digest: str + canonical_intent_digest: str + materialization_digest: str world_digest: str - manifest_digest: str + manifest_digest: str | None + + +class ParentRevisionReference(DomainModel): + revision: str + declaration_digest: str class Change(DomainModel): @@ -71,8 +124,10 @@ class Change(DomainModel): after: Any = None authority_id: str | None = None mandate_id: str | None = None + governance: GovernanceRequirement | None = None risk: Risk = Risk.LOW approval_required: bool = False + actionable: bool = True explanation: str @@ -85,24 +140,61 @@ class AdmissionIssue(DomainModel): class AdmissionDecision(DomainModel): api_version: str = "netsovereign.io/admission/v0.2" admitted: bool + status: AdmissionStatus + evaluated_at: datetime | None = None current: AcceptedWorldRevision proposed: AcceptedWorldRevision changes: list[Change] drift: list[Change] issues: list[AdmissionIssue] approval_gates: list[str] + approvals: list[ApprovalEvidence] = Field(default_factory=list) explanation: str +class PredicateKind(StrEnum): + ACCEPTED_REVISION_EQUALS = "accepted_revision_equals" + PROPOSED_DIGEST_EQUALS = "proposed_digest_equals" + OBSERVATION_EQUALS = "observation_equals" + MANDATE_ACTIVE = "mandate_active" + APPROVAL_PRESENT = "approval_present" + + +class PlanPredicate(DomainModel): + kind: PredicateKind + revision: str | None = None + digest: str | None = None + mandate_id: str | None = None + approval_id: str | None = None + path: str | None = None + value_digest: str | None = None + + +class ExpectedOutcome(DomainModel): + kind: Literal["semantic_path_equals"] = "semantic_path_equals" + path: str + value: Any + value_digest: str + + +class Reversibility(StrEnum): + REVERSIBLE = "reversible" + CONDITIONALLY_REVERSIBLE = "conditionally_reversible" + IRREVERSIBLE = "irreversible" + UNKNOWN = "unknown" + + class PlanStep(DomainModel): id: str change_id: str action: str target: str depends_on: list[str] = Field(default_factory=list) - preconditions: list[str] - expected_outcomes: list[str] + preconditions: list[PlanPredicate] + expected_outcomes: list[ExpectedOutcome] reversible: bool + reversibility: Reversibility + reversibility_reason: str authority_id: str | None = None mandate_id: str | None = None @@ -121,74 +213,25 @@ class ReconciliationPlan(DomainModel): explanation: str = "Provider-neutral plan only; no infrastructure was touched." -_SET_LIKE_LIST_FIELDS = { - "accepted_audiences", - "actions", - "authority_exports", - "claims", - "controls", - "dns_suffixes", - "egress", - "ingress", - "mail_domains", - "resource_classes", - "resources", -} - - -def _normalise(value: Any, path: tuple[str, ...] = ()) -> Any: - """Normalise maps and domain sets without changing JSON-array semantics. - - In particular, arrays below a provider binding's free-form ``configuration`` are - deliberately order-sensitive. - """ - - if isinstance(value, dict): - return {key: _normalise(value[key], (*path, key)) for key in sorted(value)} - if isinstance(value, list): - items = [_normalise(item, (*path, "[]")) for item in value] - in_provider_configuration = "configuration" in path and any( - part in {"providerBindings", "provider_bindings"} for part in path - ) - collection_is_set = bool(path) and ( - path[-1] in _COLLECTIONS - or path[-1] in {"providerBindings", "externalDependencies"} - or path[-1] in _SET_LIKE_LIST_FIELDS - or path[-1] in {"peers", "authority_imports", "mirrors"} - ) - if collection_is_set and not in_provider_configuration: - return sorted( - items, key=lambda item: json.dumps(item, sort_keys=True, separators=(",", ":")) - ) - return items - return value - - -def canonical_json(value: Any) -> str: - """Return stable JSON independent of declaration ordering and formatting.""" - - if isinstance(value, DomainModel): - value = value.model_dump(mode="json", by_alias=True) - return json.dumps(_normalise(value), sort_keys=True, separators=(",", ":"), ensure_ascii=False) - - -def digest(value: Any) -> str: - return "sha256:" + hashlib.sha256(canonical_json(value).encode()).hexdigest() - - def accepted_revision(spec: WorldSpec, parent_revision: str | None = None) -> AcceptedWorldRevision: diagnostics = validate_spec(spec) - manifest_value: Any = ( - {"invalid_world": spec.model_dump(mode="json")} - if has_errors(diagnostics) - else build_manifest(spec) - ) + declaration = spec.model_dump(mode="json", by_alias=True) + intent = {key: value for key, value in declaration.items() if key != "providerBindings"} + intent["world"] = {key: value for key, value in intent["world"].items() if key != "revision"} + materialization = { + "capabilities": declaration["capabilities"], + "providerBindings": declaration["providerBindings"], + } + declaration_digest = digest(declaration) return AcceptedWorldRevision( world_id=spec.world.id, revision=spec.world.revision, parent_revision=parent_revision, - world_digest=digest(spec), - manifest_digest=digest(manifest_value), + declaration_digest=declaration_digest, + canonical_intent_digest=digest(intent), + materialization_digest=digest(materialization), + world_digest=declaration_digest, + manifest_digest=None if has_errors(diagnostics) else digest(build_manifest(spec)), ) @@ -206,6 +249,26 @@ def accepted_revision(spec: WorldSpec, parent_revision: str | None = None) -> Ac "external_dependencies", ) +_LIFECYCLE_TRANSITIONS = { + "proposed": {"active", "retired"}, + "deferred": {"active", "retired"}, + "active": {"suspended", "retired"}, + "suspended": {"active", "retired"}, + "retired": set(), +} + +_AUTONOMY_LEVEL = { + "externally_dependent": 0, + "partially_autonomous": 1, + "authority_autonomous": 2, +} + + +def _lifecycle_status(collection: str, value: dict[str, Any]) -> str: + if collection == "authorities": + return str((value.get("lifecycle") or {}).get("status", "active")) + return str(value.get("status", "active")) + def _key(collection: str, item: dict[str, Any]) -> str: if collection == "provider_bindings": @@ -239,70 +302,83 @@ def _governed_dimensions( path: str, operation: ChangeOperation, item: Any, -) -> tuple[str, list[str], str]: +) -> GovernanceRequirement: """Derive mandate action, resource classes, and jurisdiction for a change.""" - action = "revoke" if operation == ChangeOperation.REMOVE else "declare" - resource_classes: list[str] = [] + action = ( + GovernedAction.REVOKE if operation == ChangeOperation.REMOVE else GovernedAction.DECLARE + ) + retiring = (path == "world/lifecycle" and item == "retired") or ( + isinstance(item, dict) + and ( + item.get("status") == "retired" + or (item.get("lifecycle") or {}).get("status") == "retired" + ) + ) + if retiring: + action = GovernedAction.REVOKE + resource_classes: list[ResourceClass] = [] if collection == "resources" and isinstance(item, dict) and item.get("resource_class"): - resource_classes = [str(item["resource_class"])] + resource_classes = [ResourceClass(item["resource_class"])] elif collection == "registrations": - action, resource_classes = "admit", ["registration"] + action, resource_classes = GovernedAction.ADMIT, [ResourceClass.REGISTRATION] elif collection == "allocations" and isinstance(item, dict): action, resource_classes = ( - "allocate", + GovernedAction.ALLOCATE, [ next( ( - str(resource.resource_class) + ResourceClass(resource.resource_class) for resource in spec.resources if resource.id == item.get("resource_id") ), - "number", + ResourceClass.NUMBER, ) ], ) elif collection == "grants": - action = "delegate" - elif collection in {"delegations", "mandates"} and isinstance(item, dict): + action = GovernedAction.DELEGATE + elif collection in {"delegations", "mandates"} and isinstance(item, dict) and not retiring: action, resource_classes = ( - "delegate", - [str(value) for value in item.get("resource_classes", [])], + GovernedAction.DELEGATE, + [ResourceClass(value) for value in item.get("resource_classes", [])], ) - elif collection == "institutions": - action, resource_classes = "admit", ["organisation"] + elif collection == "institutions" and not retiring: + action, resource_classes = GovernedAction.ADMIT, [ResourceClass.ORGANISATION] elif collection in {"authorities", "capabilities", "external_dependencies"}: - resource_classes = ["world"] + resource_classes = [ResourceClass.WORLD] elif path.startswith("boundary/"): action, resource_classes = ( - ("expose", ["route"]) if "exposed" in canonical_json(item) else ("route", ["route"]) + (GovernedAction.EXPOSE, [ResourceClass.ROUTE]) + if path == "boundary/real_internet" and item == "exposed" + else (GovernedAction.ROUTE, [ResourceClass.ROUTE]) ) elif path.startswith(("autonomy/", "world/")): - resource_classes = ["world"] - return action, resource_classes, spec.world.id + resource_classes = [ResourceClass.WORLD] + return GovernanceRequirement( + action=action, resource_classes=resource_classes, jurisdiction=spec.world.id + ) def _constraints_allow( - constraints: dict[str, Any], operation: ChangeOperation, path: str, subject: str | None + constraints: MandateConstraints, + operation: ChangeOperation, + path: str, + subject: str | None, ) -> bool: - """Evaluate the generic constraint vocabulary; unknown constraints fail closed.""" + """Evaluate the versioned, provider-neutral mandate constraints.""" - known = {"operations", "paths", "subject_ids"} - if set(constraints) - known: - return False - if constraints.get("operations") and str(operation) not in constraints["operations"]: + if constraints.operations and str(operation) not in constraints.operations: return False - if constraints.get("paths") and path not in constraints["paths"]: + if constraints.paths and path not in constraints.paths: return False - return not constraints.get("subject_ids") or subject in constraints["subject_ids"] + return not constraints.subject_ids or subject in constraints.subject_ids def _mandate_for( spec: WorldSpec, authority_id: str | None, - action: str, - resource_classes: list[str], - jurisdiction: str, + requirement: GovernanceRequirement, operation: ChangeOperation, path: str, subject: str | None, @@ -314,11 +390,11 @@ def _mandate_for( for mandate in spec.mandates: if mandate.authority_id != authority_id or mandate.status != "active": continue - if action not in mandate.actions or not set(resource_classes) <= set( - mandate.resource_classes - ): + if requirement.action not in mandate.actions or not set( + requirement.resource_classes + ) <= set(mandate.resource_classes): continue - if mandate.jurisdiction not in {jurisdiction, "*"}: + if mandate.jurisdiction not in {requirement.jurisdiction, "*"}: continue if mandate.validity: if observed_at is None: @@ -366,15 +442,13 @@ def add( (a.id for a in authority_spec.authorities if a.kind == "world_root"), None ) governed_item = new if new is not None else old - action, resource_classes, jurisdiction = _governed_dimensions( + governance = _governed_dimensions( authority_spec, collection, path, operation, governed_item ) mandate = _mandate_for( current, authority, - action, - resource_classes, - jurisdiction, + governance, operation, path, subject, @@ -384,21 +458,21 @@ def add( Risk.GOVERNED if classification == ChangeClassification.CANONICAL_INTENT else Risk.LOW ) approval = risk != Risk.LOW - if path == "autonomy/target" and old != new: + if path == "autonomy/target" and _AUTONOMY_LEVEL[str(new)] < _AUTONOMY_LEVEL[str(old)]: risk, approval = Risk.AUTONOMY_REGRESSION, True if path.startswith("external_dependencies/") and operation != ChangeOperation.REMOVE: risk, approval = Risk.EXTERNAL_DEPENDENCY, bool((new or {}).get("required", True)) - if path.startswith("boundary/") and ( - "federat" in canonical_json(new) or "exposed" in canonical_json(new) - ): - risk, approval = ( - (Risk.TRUST if "federat" in canonical_json(new) else Risk.EXPOSURE), - True, - ) - ident = digest({"path": path, "operation": operation, "before": old, "after": new})[7:19] + federated_import = path == "boundary/authority_imports" and any( + item.get("mode") == "federated" for item in (new or []) + ) + if (path == "boundary/cross_world" and new == "federated") or federated_import: + risk, approval = Risk.TRUST, True + if path == "boundary/real_internet" and new == "exposed": + risk, approval = Risk.EXPOSURE, True + ident = digest({"path": path, "operation": operation, "before": old, "after": new}) changes.append( Change( - id=f"change-{ident}", + id=f"change-{ident[7:]}", classification=classification, operation=operation, path=path, @@ -407,6 +481,9 @@ def add( after=new, authority_id=authority, mandate_id=mandate, + governance=( + governance if classification == ChangeClassification.CANONICAL_INTENT else None + ), risk=risk, approval_required=approval, explanation=f"{operation.value.title()} {path} ({classification.value}).", @@ -485,30 +562,95 @@ def _observed_drift(proposed: WorldSpec, observed: ObservedStateSnapshot | None) declared = proposed.model_dump(mode="json") drift: list[Change] = [] for fact in sorted(observed.facts, key=lambda item: item.path): + if fact.status in {"unknown", "unreadable"}: + continue + declared_exists = True try: value = _resolve_semantic_path(declared, fact.path) except (KeyError, StopIteration, TypeError): + declared_exists = False value = None - if _normalise(value) != _normalise(fact.value): + differs = fact.status == "absent" and declared_exists + differs = differs or ( + fact.status == "present" + and (not declared_exists or _normalise(value) != _normalise(fact.value)) + ) + if differs: + collection = fact.path.strip("/").split("/", 1)[0] + governed_item = value if isinstance(value, dict) else {} + authority = _authority_for(proposed, collection, governed_item) + if fact.path.startswith("boundary/"): + authority = next((a.id for a in proposed.authorities if a.kind == "transit"), None) + elif fact.path.startswith(("world/", "autonomy/")): + authority = next( + (a.id for a in proposed.authorities if a.kind == "world_root"), None + ) + governance = _governed_dimensions( + proposed, collection, fact.path, ChangeOperation.MODIFY, value + ) + mandate = _mandate_for( + proposed, + authority, + governance, + ChangeOperation.MODIFY, + fact.path, + fact.path.split("/", 2)[1] if "/" in fact.path else None, + fact.observed_at or observed.observed_at, + ) + evidence_value = {"status": "absent"} if fact.status == "absent" else fact.value + provenance = fact.provenance or observed.provenance drift.append( Change( - id=f"drift-{digest(fact.model_dump(mode='json'))[7:19]}", + id=f"drift-{digest(fact.model_dump(mode='json'))[7:]}", classification=ChangeClassification.OBSERVED_DRIFT, - operation=ChangeOperation.MODIFY, + operation=( + ChangeOperation.ADD if fact.status == "absent" else ChangeOperation.MODIFY + ), path=fact.path, - before=fact.value, + before=evidence_value, after=value, - explanation=f"Observed evidence from {observed.provenance} differs from declared intent.", + authority_id=authority, + mandate_id=mandate, + governance=governance, + actionable=declared_exists, + explanation=f"Observed evidence from {provenance} differs from declared intent.", ) ) return drift def admit_change( - current: WorldSpec, proposed: WorldSpec, observed: ObservedStateSnapshot | None = None + current: WorldSpec, + proposed: WorldSpec, + observed: ObservedStateSnapshot | None = None, + *, + evaluated_at: datetime | None = None, + approvals: list[ApprovalEvidence] | None = None, + parent: ParentRevisionReference | None = None, ) -> AdmissionDecision: - changes = compare_worlds(current, proposed, observed.observed_at if observed else None) + changes = compare_worlds(current, proposed, evaluated_at) issues: list[AdmissionIssue] = [] + for diagnostic in validate_spec(current): + if diagnostic.severity == "error": + issues.append( + AdmissionIssue( + code=f"invalid_current_{diagnostic.code}", + path=diagnostic.location, + message=f"Accepted world is invalid: {diagnostic.message}", + ) + ) + current_reference = accepted_revision(current) + if parent and ( + parent.revision != current_reference.revision + or parent.declaration_digest != current_reference.declaration_digest + ): + issues.append( + AdmissionIssue( + code="stale_parent_revision", + path="proposal/parent", + message="Proposal parent revision and digest do not match the accepted world.", + ) + ) if current.world.id != proposed.world.id: issues.append( AdmissionIssue( @@ -546,7 +688,11 @@ def admit_change( ) if change.path.startswith("authorities/") and change.operation == ChangeOperation.MODIFY: old, new = change.before or {}, change.after or {} - if old.get("kind") != new.get("kind") or old.get("scope") != new.get("scope"): + identity_fields = ("kind", "scope", "operator_institution_id", "controls", "source") + if any( + _normalise(old.get(field)) != _normalise(new.get(field)) + for field in identity_fields + ): issues.append( AdmissionIssue( code="authority_identity_redefined", @@ -554,11 +700,7 @@ def admit_change( message="A stable authority identity cannot silently change meaning.", ) ) - if ( - old.get("source") != new.get("source") - and old.get("source") != "local" - and new.get("source") == "local" - ): + if old.get("source") != "local" and new.get("source") == "local": issues.append( AdmissionIssue( code="import_became_local", @@ -569,8 +711,10 @@ def admit_change( if ( change.path.startswith("resources/") and change.operation == ChangeOperation.MODIFY - and (change.before or {}).get("resource_class") - != (change.after or {}).get("resource_class") + and any( + (change.before or {}).get(field) != (change.after or {}).get(field) + for field in ("resource_class", "authority_id") + ) ): issues.append( AdmissionIssue( @@ -579,11 +723,8 @@ def admit_change( message="A stable resource identity cannot silently change class.", ) ) - governed = ( - change.classification == ChangeClassification.CANONICAL_INTENT - and not change.path.startswith("world/") - ) - if governed and not change.mandate_id and not change.path.startswith("mandates/"): + governed = change.classification == ChangeClassification.CANONICAL_INTENT + if governed and not change.mandate_id: issues.append( AdmissionIssue( code="missing_applicable_mandate", @@ -591,10 +732,37 @@ def admit_change( message="No applicable active mandate permits this governed change.", ) ) + collection = change.path.split("/", 1)[0] + if change.operation == ChangeOperation.MODIFY and collection in { + "authorities", + "institutions", + "mandates", + }: + old_status = _lifecycle_status(collection, change.before or {}) + new_status = _lifecycle_status(collection, change.after or {}) + if old_status != new_status and new_status not in _LIFECYCLE_TRANSITIONS[old_status]: + issues.append( + AdmissionIssue( + code="invalid_lifecycle_transition", + path=change.path, + message=f"Lifecycle transition {old_status} -> {new_status} is not permitted.", + ) + ) + if ( + change.path == "world/lifecycle" + and str(change.after) not in _LIFECYCLE_TRANSITIONS[str(change.before)] + ): + issues.append( + AdmissionIssue( + code="invalid_lifecycle_transition", + path=change.path, + message=f"Lifecycle transition {change.before} -> {change.after} is not permitted.", + ) + ) if ( change.path == "boundary/authority_imports" - and "peered" in canonical_json(change.before) - and "federated" in canonical_json(change.after) + and any(item.get("mode") == "peered" for item in (change.before or [])) + and any(item.get("mode") == "federated" for item in (change.after or [])) ): issues.append( AdmissionIssue( @@ -618,57 +786,165 @@ def admit_change( if change.approval_required } ) + supplied_approvals = sorted(approvals or [], key=lambda item: item.approval_id) + approved_ids = {item.approval_id for item in supplied_approvals} + outstanding_gates = [gate for gate in gates if gate not in approved_ids] drift = _observed_drift(proposed, observed) - admitted = not issues + status = ( + AdmissionStatus.REJECTED + if issues + else AdmissionStatus.PENDING_APPROVAL + if outstanding_gates + else AdmissionStatus.ADMITTED + ) + admitted = status == AdmissionStatus.ADMITTED return AdmissionDecision( admitted=admitted, - current=accepted_revision(current), + status=status, + evaluated_at=evaluated_at, + current=current_reference, proposed=accepted_revision(proposed, current.world.revision), changes=changes, drift=drift, issues=sorted(issues, key=lambda item: (item.path, item.code)), - approval_gates=gates, + approval_gates=outstanding_gates, + approvals=supplied_approvals, explanation=( - "Proposal is coherent and admitted subject to listed approvals." + "Proposal is coherent, authorised, and admitted." if admitted + else "Proposal is coherent and authorised but awaits required approval." + if status == AdmissionStatus.PENDING_APPROVAL else "Proposal is rejected; resolve every admission issue before planning convergence." ), ) +def _dependency_targets(change: Change) -> set[str]: + """Return changed semantic targets that must converge before this change.""" + + item = change.after if isinstance(change.after, dict) else {} + collection = change.path.split("/", 1)[0] + dependencies: set[str] = set() + if collection == "authorities" and item.get("operator_institution_id"): + dependencies.add(f"institutions/{item['operator_institution_id']}") + elif collection == "mandates" and item.get("authority_id"): + dependencies.add(f"authorities/{item['authority_id']}") + elif collection == "resources": + for field in ("authority_id", "registry_authority_id", "registrar_authority_id"): + if item.get(field): + dependencies.add(f"authorities/{item[field]}") + elif collection == "registrations": + if item.get("resource_id"): + dependencies.add(f"resources/{item['resource_id']}") + for field in ("registry_authority_id", "registrar_authority_id"): + if item.get(field): + dependencies.add(f"authorities/{item[field]}") + elif collection == "allocations": + if item.get("resource_id"): + dependencies.add(f"resources/{item['resource_id']}") + if item.get("authority_id"): + dependencies.add(f"authorities/{item['authority_id']}") + elif collection == "grants" and item.get("authority_id"): + dependencies.add(f"authorities/{item['authority_id']}") + elif collection == "delegations": + for field in ("from_authority_id", "to_authority_id"): + if item.get(field): + dependencies.add(f"authorities/{item[field]}") + elif collection == "provider_bindings" and item.get("capability"): + dependencies.add(f"capabilities/{item['capability']}") + return dependencies + + def build_plan(decision: AdmissionDecision) -> ReconciliationPlan: steps: list[PlanStep] = [] - if decision.admitted: - convergence = [*decision.changes, *decision.drift] + if decision.status != AdmissionStatus.REJECTED: + convergence = [ + *decision.changes, + *(change for change in decision.drift if change.actionable), + ] + step_ids = {change.id: f"step-{index:04d}" for index, change in enumerate(convergence, 1)} + target_steps: dict[str, list[str]] = {} + for change in convergence: + target_steps.setdefault(change.path, []).append(step_ids[change.id]) for index, change in enumerate(convergence, 1): step_id = f"step-{index:04d}" is_drift = change.classification == ChangeClassification.OBSERVED_DRIFT preconditions = [ - f"accepted revision is {decision.current.revision}", - f"proposed world digest is {decision.proposed.world_digest}", + PlanPredicate( + kind=PredicateKind.ACCEPTED_REVISION_EQUALS, + revision=decision.current.revision, + digest=decision.current.declaration_digest, + ), + PlanPredicate( + kind=PredicateKind.PROPOSED_DIGEST_EQUALS, + digest=decision.proposed.declaration_digest, + ), ] if is_drift: - preconditions.append(f"observed {change.path} still equals recorded evidence") + preconditions.append( + PlanPredicate( + kind=PredicateKind.OBSERVATION_EQUALS, + path=change.path, + value_digest=digest(change.before), + ) + ) if change.mandate_id: - preconditions.append(f"mandate {change.mandate_id} remains active") + preconditions.append( + PlanPredicate( + kind=PredicateKind.MANDATE_ACTIVE, + mandate_id=change.mandate_id, + ) + ) if change.approval_required: - preconditions.append(f"approval approve:{str(change.risk)}:{change.id} is recorded") + preconditions.append( + PlanPredicate( + kind=PredicateKind.APPROVAL_PRESENT, + approval_id=f"approve:{str(change.risk)}:{change.id}", + ) + ) + reversibility = ( + Reversibility.IRREVERSIBLE + if change.operation == ChangeOperation.REMOVE + else Reversibility.CONDITIONALLY_REVERSIBLE + if change.classification == ChangeClassification.PROVIDER_BINDING + else Reversibility.UNKNOWN + ) + dependency_ids = { + dependency_step + for target in _dependency_targets(change) + for dependency_step in target_steps.get(target, []) + if dependency_step != step_id + } + if is_drift: + dependency_ids.update( + dependency_step + for dependency_step in target_steps.get(change.path, []) + if dependency_step != step_id + ) steps.append( PlanStep( id=step_id, change_id=change.id, action="reconcile_drift" if is_drift else str(change.operation), target=change.path, - depends_on=[steps[-1].id] if steps else [], + depends_on=sorted(dependency_ids), preconditions=preconditions, expected_outcomes=[ - ( - f"observed {change.path} converges to declared value" - if is_drift - else f"declared {change.path} equals the proposed revision" + ExpectedOutcome( + path=change.path, + value=change.after, + value_digest=digest(change.after), ) ], - reversible=not is_drift and change.operation != ChangeOperation.REMOVE, + reversible=reversibility == Reversibility.CONDITIONALLY_REVERSIBLE, + reversibility=reversibility, + reversibility_reason=( + "Removal has no provider-neutral inverse." + if reversibility == Reversibility.IRREVERSIBLE + else "A prior replaceable binding can be restored if retained." + if reversibility == Reversibility.CONDITIONALLY_REVERSIBLE + else "v0.2 cannot prove runtime reversibility without a provider contract." + ), authority_id=change.authority_id, mandate_id=change.mandate_id, ) diff --git a/src/netsovereign/validation.py b/src/netsovereign/validation.py index 6b6a7ea..b497969 100644 --- a/src/netsovereign/validation.py +++ b/src/netsovereign/validation.py @@ -73,6 +73,17 @@ def validate_spec(spec: WorldSpec) -> list[Diagnostic]: institutions = {x.id for x in spec.institutions} authorities = {x.id: x for x in spec.authorities} capability_ids = {capability.id for capability in spec.capabilities} + binding_capabilities = [binding.capability for binding in spec.provider_bindings] + if len(binding_capabilities) != len(set(binding_capabilities)): + diagnostics.append( + Diagnostic( + code="duplicate_provider_binding", + severity=Severity.ERROR, + location="providerBindings", + message="provider binding capabilities must be unique", + ) + ) + diagnostics.extend(_duplicates(spec.external_dependencies, "externalDependencies")) for i, binding in enumerate(spec.provider_bindings): if binding.capability not in capability_ids: diagnostics.append( diff --git a/tests/test_planning.py b/tests/test_planning.py index deaef4d..e6f6fde 100644 --- a/tests/test_planning.py +++ b/tests/test_planning.py @@ -1,20 +1,28 @@ import json from copy import deepcopy +from datetime import UTC, datetime from pathlib import Path +import pytest import yaml +from pydantic import ValidationError from typer.testing import CliRunner from netsovereign.cli import app from netsovereign.planning import ( + AdmissionDecision, + ApprovalEvidence, ObservedStateSnapshot, + ParentRevisionReference, + ReconciliationPlan, + accepted_revision, admit_change, build_plan, canonical_json, compare_worlds, digest, ) -from netsovereign.specification import WorldSpec +from netsovereign.specification import ExternalDependency, WorldSpec ROOT = Path(__file__).parents[1] @@ -32,6 +40,8 @@ def test_canonical_digest_is_order_independent(): assert digest({"controls": ["delegate", "declare"]}) == digest( {"controls": ["declare", "delegate"]} ) + with pytest.raises(ValueError, match="non-finite"): + digest({"invalid": float("nan")}) def test_binding_change_is_separate_and_admitted(): @@ -43,6 +53,8 @@ def test_binding_change_is_separate_and_admitted(): plan = build_plan(decision) assert plan.execution == "not_permitted" and len(plan.steps) == 1 assert plan.plan_digest == build_plan(decision).plan_digest + assert decision.current.canonical_intent_digest == decision.proposed.canonical_intent_digest + assert decision.current.materialization_digest != decision.proposed.materialization_digest def test_identity_change_and_deletion_are_rejected(): @@ -62,6 +74,25 @@ def test_resource_class_change_redefines_stable_identity(): assert "resource_identity_redefined" in {issue.code for issue in decision.issues} +def test_lifecycle_resurrection_and_authority_redefinition_are_rejected(): + current, proposed = worlds() + current.world.lifecycle = "retired" + proposed.world.lifecycle = "active" + proposed.authorities[1].operator_institution_id = "different-operator" + codes = {issue.code for issue in admit_change(current, proposed).issues} + assert {"invalid_lifecycle_transition", "authority_identity_redefined"} <= codes + + +def test_autonomy_improvement_is_not_a_regression(): + current, proposed = worlds() + current.autonomy.target = "externally_dependent" + proposed.autonomy.target = "authority_autonomous" + change = next( + change for change in compare_worlds(current, proposed) if change.path == "autonomy/target" + ) + assert change.risk != "autonomy_regression" + + def test_observation_is_drift_not_authority(): current, proposed = worlds() observed = ObservedStateSnapshot.model_validate( @@ -90,7 +121,9 @@ def test_drift_only_plan_contains_a_convergence_step(): plan = build_plan(decision) assert decision.changes == [] and len(decision.drift) == 1 assert plan.steps[0].action == "reconcile_drift" - assert "converges to declared value" in plan.steps[0].expected_outcomes[0] + assert plan.steps[0].expected_outcomes[0].path == "world/name" + assert plan.steps[0].expected_outcomes[0].value == current.world.name + assert plan.steps[0].preconditions[0].kind == "accepted_revision_equals" def test_observed_collection_path_resolves_stable_id(): @@ -107,6 +140,37 @@ def test_observed_collection_path_resolves_stable_id(): assert admit_change(current, current, observed).drift == [] +def test_typed_observation_distinguishes_absent_unknown_and_null(): + current, _ = worlds() + absent = ObservedStateSnapshot.model_validate( + { + "world_id": "minimal", + "observed_at": "2026-01-01T00:00:00Z", + "provenance": "offline-test", + "facts": [{"path": "world/name", "status": "absent"}], + } + ) + decision = admit_change(current, current, absent) + assert decision.drift[0].operation == "add" + assert decision.drift[0].authority_id == "world-root" + unknown = absent.model_copy( + update={"facts": [absent.facts[0].model_copy(update={"status": "unknown"})]} + ) + assert admit_change(current, current, unknown).drift == [] + with pytest.raises(ValidationError, match="must be unique"): + ObservedStateSnapshot.model_validate( + { + "world_id": "minimal", + "observed_at": "2026-01-01T00:00:00Z", + "provenance": "offline-test", + "facts": [ + {"path": "world/name", "value": None}, + {"path": "world/name", "status": "absent"}, + ], + } + ) + + def test_provider_configuration_arrays_remain_ordered(): current, proposed = worlds() current.provider_bindings[0].configuration = {"fallbacks": ["primary", "secondary"]} @@ -127,6 +191,26 @@ def test_mandate_must_cover_action_resource_and_jurisdiction(): assert "missing_applicable_mandate" in {issue.code for issue in decision.issues} +def test_mandate_constraints_are_typed_and_governance_is_explained(): + current, proposed = worlds() + proposed.resources[0].registrar_authority_id = None + change = next( + change + for change in compare_worlds(current, proposed) + if change.path == "resources/root-zone" + ) + assert change.governance is not None + assert change.governance.action == "declare" + assert change.governance.resource_classes == ["name"] + with pytest.raises(ValidationError, match="unknown_constraint"): + current.mandates[0].__class__.model_validate( + { + **current.mandates[0].model_dump(mode="json"), + "constraints": {"unknown_constraint": True}, + } + ) + + def test_change_and_plan_digests_bind_exact_proposed_content(): current, first = worlds() second = first.model_copy(deep=True) @@ -140,6 +224,77 @@ def test_change_and_plan_digests_bind_exact_proposed_content(): assert build_plan(first_decision).plan_digest != build_plan(second_decision).plan_digest +def test_plan_dependencies_form_a_semantic_dag_not_a_linear_chain(): + current, _ = worlds() + proposed = current.model_copy(deep=True) + proposed.world.revision = "2" + proposed.capabilities.append( + proposed.capabilities[0].model_copy(update={"id": "new-capability"}) + ) + proposed.provider_bindings.append( + proposed.provider_bindings[0].model_copy(update={"capability": "new-capability"}) + ) + plan = build_plan(admit_change(current, proposed)) + capability_step = next( + step for step in plan.steps if step.target == "capabilities/new-capability" + ) + binding_step = next( + step for step in plan.steps if step.target == "provider_bindings/new-capability" + ) + assert capability_step.depends_on == [] + assert binding_step.depends_on == [capability_step.id] + + +def test_approval_is_a_distinct_admission_state(): + current, proposed = worlds() + proposed.world.name = "Approval-gated name" + pending = admit_change(current, proposed) + assert pending.status == "pending_approval" and not pending.admitted + evidence = ApprovalEvidence( + approval_id=pending.approval_gates[0], + approved_at=datetime(2026, 1, 1, tzinfo=UTC), + provenance="offline-review", + ) + admitted = admit_change(current, proposed, approvals=[evidence]) + assert admitted.status == "admitted" and admitted.approval_gates == [] + + +def test_invalid_current_world_cannot_authorise_change(): + current, proposed = worlds() + current.provider_bindings[0].capability = "missing" + decision = admit_change(current, proposed) + assert decision.status == "rejected" + assert any(issue.code.startswith("invalid_current_") for issue in decision.issues) + + +def test_duplicate_binding_and_dependency_keys_are_rejected(): + current, _ = worlds() + current.provider_bindings.append(current.provider_bindings[0].model_copy()) + current.external_dependencies.extend( + [ + ExternalDependency(id="duplicate", description="one"), + ExternalDependency(id="duplicate", description="two"), + ] + ) + current = WorldSpec.model_validate(current.model_dump(mode="json", by_alias=True)) + codes = {issue.code for issue in admit_change(current, current).issues} + assert "invalid_current_duplicate_provider_binding" in codes + assert "invalid_current_duplicate_id" in codes + + +def test_stale_parent_digest_is_rejected_and_change_ids_are_full_length(): + current, proposed = worlds() + decision = admit_change( + current, + proposed, + parent=ParentRevisionReference(revision="1", declaration_digest="sha256:stale"), + ) + assert "stale_parent_revision" in {issue.code for issue in decision.issues} + assert len(decision.changes[0].id.removeprefix("change-")) == 64 + reference = accepted_revision(current) + assert reference.declaration_digest == reference.world_digest + + def test_cli_diff_admit_and_plan(tmp_path): current, proposed = worlds() current_path, proposed_path = tmp_path / "current.yaml", tmp_path / "proposed.yaml" @@ -154,3 +309,39 @@ def test_cli_diff_admit_and_plan(tmp_path): assert admitted.exit_code == 0 and json.loads(admitted.stdout)["admitted"] plan = runner.invoke(app, ["plan", str(current_path), str(proposed_path)]) assert plan.exit_code == 0 and json.loads(plan.stdout)["execution"] == "not_permitted" + + +def test_cli_pending_exit_code_and_yaml_output(tmp_path): + current, proposed = worlds() + proposed.world.name = "Approval required" + current_path, proposed_path = tmp_path / "current.yaml", tmp_path / "proposed.yaml" + output_path = tmp_path / "decision.yaml" + current_path.write_text(yaml.safe_dump(current.model_dump(mode="json", by_alias=True))) + proposed_path.write_text(yaml.safe_dump(proposed.model_dump(mode="json", by_alias=True))) + result = CliRunner().invoke( + app, + [ + "admit", + str(current_path), + str(proposed_path), + "--format", + "yaml", + "--output", + str(output_path), + ], + ) + assert result.exit_code == 3 + assert yaml.safe_load(output_path.read_text())["status"] == "pending_approval" + + +@pytest.mark.parametrize( + ("filename", "model"), + [ + ("observed-v0.2.schema.json", ObservedStateSnapshot), + ("admission-v0.2.schema.json", AdmissionDecision), + ("plan-v0.2.schema.json", ReconciliationPlan), + ], +) +def test_planning_artifact_schemas_are_stable(filename, model): + expected = json.loads((ROOT / "schemas" / filename).read_text()) + assert expected == model.model_json_schema(by_alias=True)