diff --git a/CHANGELOG.md b/CHANGELOG.md index 69eed5a..fae87d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ # 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. +- 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 71e15b5..ab6a4ca 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 ``` -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. +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), [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/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/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 8f1525b..36f6936 100644 --- a/src/netsovereign/__init__.py +++ b/src/netsovereign/__init__.py @@ -1,8 +1,34 @@ """NetSovereign sovereign domain foundation.""" from .manifest import WorldManifest, build_manifest +from .planning import ( + AdmissionDecision, + AdmissionStatus, + ApprovalEvidence, + ObservedStateSnapshot, + ParentRevisionReference, + 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", + "AdmissionStatus", + "ApprovalEvidence", + "Diagnostic", + "ObservedStateSnapshot", + "ParentRevisionReference", + "ReconciliationPlan", + "WorldManifest", + "WorldSpec", + "admit_change", + "build_manifest", + "build_plan", + "compare_worlds", + "validate_spec", +] +__version__ = "0.2.0" 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 1e52ea4..54ac478 100644 --- a/src/netsovereign/cli.py +++ b/src/netsovereign/cli.py @@ -3,13 +3,24 @@ from __future__ import annotations import json +from datetime import datetime 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 ( + ApprovalEvidence, + ObservedStateSnapshot, + ParentRevisionReference, + admit_change, + build_plan, + compare_worlds, +) from .specification import WorldSpec from .validation import has_errors, validate_spec @@ -24,6 +35,72 @@ 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 _plain(value: object) -> object: + if isinstance(value, BaseModel): + 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() def validate(path: Path) -> None: """Structurally and semantically validate a world declaration.""" @@ -56,5 +133,69 @@ 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, + 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), + 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, + 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), + 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__": app() 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 new file mode 100644 index 0000000..1d6d3e3 --- /dev/null +++ b/src/netsovereign/planning.py @@ -0,0 +1,972 @@ +"""Pure, provider-neutral sovereign change admission and reconciliation planning.""" + +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from typing import Any, Literal + +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" + 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 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 + status: Literal["present", "absent", "unknown", "unreadable"] = "present" + value: Any = None + observed_at: datetime | None = None + provenance: str | None = None + + +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) + + @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 | None + + +class ParentRevisionReference(DomainModel): + revision: str + declaration_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 + governance: GovernanceRequirement | None = None + risk: Risk = Risk.LOW + approval_required: bool = False + actionable: bool = True + explanation: str + + +class AdmissionIssue(DomainModel): + code: str + message: str + path: str + + +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[PlanPredicate] + expected_outcomes: list[ExpectedOutcome] + reversible: bool + reversibility: Reversibility + reversibility_reason: str + 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 accepted_revision(spec: WorldSpec, parent_revision: str | None = None) -> AcceptedWorldRevision: + diagnostics = validate_spec(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, + 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)), + ) + + +_COLLECTIONS = ( + "institutions", + "authorities", + "mandates", + "resources", + "registrations", + "allocations", + "grants", + "delegations", + "capabilities", + "provider_bindings", + "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": + 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 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]) + 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 _governed_dimensions( + spec: WorldSpec, + collection: str, + path: str, + operation: ChangeOperation, + item: Any, +) -> GovernanceRequirement: + """Derive mandate action, resource classes, and jurisdiction for a change.""" + + 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 = [ResourceClass(item["resource_class"])] + elif collection == "registrations": + action, resource_classes = GovernedAction.ADMIT, [ResourceClass.REGISTRATION] + elif collection == "allocations" and isinstance(item, dict): + action, resource_classes = ( + GovernedAction.ALLOCATE, + [ + next( + ( + ResourceClass(resource.resource_class) + for resource in spec.resources + if resource.id == item.get("resource_id") + ), + ResourceClass.NUMBER, + ) + ], + ) + elif collection == "grants": + action = GovernedAction.DELEGATE + elif collection in {"delegations", "mandates"} and isinstance(item, dict) and not retiring: + action, resource_classes = ( + GovernedAction.DELEGATE, + [ResourceClass(value) for value in item.get("resource_classes", [])], + ) + elif collection == "institutions" and not retiring: + action, resource_classes = GovernedAction.ADMIT, [ResourceClass.ORGANISATION] + elif collection in {"authorities", "capabilities", "external_dependencies"}: + resource_classes = [ResourceClass.WORLD] + elif path.startswith("boundary/"): + action, resource_classes = ( + (GovernedAction.EXPOSE, [ResourceClass.ROUTE]) + if path == "boundary/real_internet" and item == "exposed" + else (GovernedAction.ROUTE, [ResourceClass.ROUTE]) + ) + elif path.startswith(("autonomy/", "world/")): + resource_classes = [ResourceClass.WORLD] + return GovernanceRequirement( + action=action, resource_classes=resource_classes, jurisdiction=spec.world.id + ) + + +def _constraints_allow( + constraints: MandateConstraints, + operation: ChangeOperation, + path: str, + subject: str | None, +) -> bool: + """Evaluate the versioned, provider-neutral mandate constraints.""" + + if constraints.operations and str(operation) not in constraints.operations: + return False + if constraints.paths and path not in constraints.paths: + return False + return not constraints.subject_ids or subject in constraints.subject_ids + + +def _mandate_for( + spec: WorldSpec, + authority_id: str | None, + requirement: GovernanceRequirement, + 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 requirement.action not in mandate.actions or not set( + requirement.resource_classes + ) <= set(mandate.resource_classes): + continue + if mandate.jurisdiction not in {requirement.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, evaluated_at: datetime | None = None +) -> 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 + ) + governed_item = new if new is not None else old + governance = _governed_dimensions( + authority_spec, collection, path, operation, governed_item + ) + mandate = _mandate_for( + current, + authority, + governance, + operation, + path, + subject, + evaluated_at, + ) + risk = ( + Risk.GOVERNED if classification == ChangeClassification.CANONICAL_INTENT else Risk.LOW + ) + approval = risk != Risk.LOW + 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)) + 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[7:]}", + classification=classification, + operation=operation, + path=path, + subject_id=subject, + before=old, + 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}).", + ) + ) + + # 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], (collection, item_id)) != _normalise( + new_items[item_id], (collection, 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 _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): + 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 + 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:]}", + classification=ChangeClassification.OBSERVED_DRIFT, + operation=( + ChangeOperation.ADD if fact.status == "absent" else ChangeOperation.MODIFY + ), + path=fact.path, + before=evidence_value, + after=value, + 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, + *, + evaluated_at: datetime | None = None, + approvals: list[ApprovalEvidence] | None = None, + parent: ParentRevisionReference | None = None, +) -> AdmissionDecision: + 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( + 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 {} + 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", + path=change.path, + message="A stable authority identity cannot silently change meaning.", + ) + ) + if 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 any( + (change.before or {}).get(field) != (change.after or {}).get(field) + for field in ("resource_class", "authority_id") + ) + ): + 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 + if governed and not change.mandate_id: + issues.append( + AdmissionIssue( + code="missing_applicable_mandate", + path=change.path, + 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 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( + 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 + } + ) + 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) + status = ( + AdmissionStatus.REJECTED + if issues + else AdmissionStatus.PENDING_APPROVAL + if outstanding_gates + else AdmissionStatus.ADMITTED + ) + admitted = status == AdmissionStatus.ADMITTED + return AdmissionDecision( + admitted=admitted, + 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=outstanding_gates, + approvals=supplied_approvals, + explanation=( + "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.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 = [ + 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( + PlanPredicate( + kind=PredicateKind.OBSERVATION_EQUALS, + path=change.path, + value_digest=digest(change.before), + ) + ) + if change.mandate_id: + preconditions.append( + PlanPredicate( + kind=PredicateKind.MANDATE_ACTIVE, + mandate_id=change.mandate_id, + ) + ) + if change.approval_required: + 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=sorted(dependency_ids), + preconditions=preconditions, + expected_outcomes=[ + ExpectedOutcome( + path=change.path, + value=change.after, + value_digest=digest(change.after), + ) + ], + 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, + ) + ) + payload = { + "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( + 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/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 new file mode 100644 index 0000000..e6f6fde --- /dev/null +++ b/tests/test_planning.py @@ -0,0 +1,347 @@ +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 ExternalDependency, 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, "a": 1}) == canonical_json({"a": 1, "b": 2}) + 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(): + 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 + 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(): + 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_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_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( + { + "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_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 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(): + 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_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"]} + 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_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) + 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_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" + 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" + + +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) 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" },