diff --git a/CHANGELOG.md b/CHANGELOG.md index 71c6d51..977c4b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,15 @@ # Changelog +## 0.4.1 - 2026-08-02 +- Added self-verifying admission and reconciliation envelopes under one canonicalisation profile. +- Added typed capability compilation, injected runtime preconditions, accurate prior-state + compensation, and provider claim enforcement. +- Added a verified admission-to-desired-revision adapter and world-safe revision record IDs. +- Published v0.4.1 approval, executable-plan and desired-revision schemas. Operational providers + and durable/PostgreSQL execution remain out of scope. +- Review hardening removes inferred runtime facts, rejects ambiguous mandates, types the legacy + admission adapter, binds runtime approvals to their decision, and removes order-derived manifest + authority selection. + ## 0.3.0 - 2026-08-02 - Added deterministic compilation of admitted plans, typed provider contracts and resolution, explicit execution state and evidence, dry run, retry, compensation, diff --git a/README.md b/README.md index ab6a4ca..6885e3e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # NetSovereign +Current package version: **0.4.1**. See the [v0.4.1 enforceable artefact chain](docs/artefact-chain-v0.4.1.md). + > **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. diff --git a/SECURITY.md b/SECURITY.md index d83daa8..0b426ab 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,2 +1,10 @@ # Security + +## Pre-alpha approval boundary + +v0.4.1 binds explicitly verified, unexpired approval evidence to an exact proposal and gate, but +does not authenticate actors or verify signatures. Asserted evidence and the fake provider are not +production trust boundaries. Runtime predicates fail closed before provider work; observations +remain evidence and cannot rewrite authority. + This pre-alpha package performs offline declaration parsing; it is not a security boundary and makes no runtime assurance. Report vulnerabilities privately through GitHub's security advisory facility. Never include secrets. Provider execution, durable state, identity, PKI, routing, and federation are deferred. diff --git a/docs/artefact-chain-v0.4.1.md b/docs/artefact-chain-v0.4.1.md new file mode 100644 index 0000000..9f3b730 --- /dev/null +++ b/docs/artefact-chain-v0.4.1.md @@ -0,0 +1,38 @@ +# Enforceable artefact chain (v0.4.1) + +NetEngine keeps declaration, decision, evidence, materialisation and execution distinct: + +`WorldSpec → diagnostics → WorldManifest → AcceptedWorldRevision → AdmissionDecision → ReconciliationPlan → ExecutablePlan → ExecutionRun`. + +Declarations and manifests are canonical sovereign artefacts. Admission is a mandate-aware +decision; approvals and observations are evidence, never authority. Provider bindings are +replaceable materialisation choices. Plans and executable plans are derived artefacts. + +## Integrity and gates + +Persistent integrity envelopes use `netsovereign.canonical-json/v1` and `sha256:`. +Admissions bind revisions, changes, evidence, issues, gates and status. Plans bind their source +decision, revision and manifest digests, approval set, predicates, dependencies, capabilities, +outcomes and reversibility. Compilation verifies a loaded plan unconditionally. + +Execution evaluates sovereign predicates against an injected `RuntimeFacts` snapshot before +provider validation or mutation. A failure records the exact predicate as evidence. Provider +validation is a later, separate gate. The executor never infers facts from a plan; the explicitly +named `offline_demo_facts()` helper is restricted to the fake-provider CLI demonstration. + +Capabilities are provider-neutral and versioned. Compilation resolves the declared requirement, +binds the descriptor into the executable fingerprint, and rejects incompatible versions, unsafe +retry, dry-run and compensation postures. Compensation restores retained prior state; delete is +not presumed reversible. + +## Security boundary and compatibility + +Approval authentication remains a pre-alpha seam. Only evidence explicitly marked `verified`, +unexpired, and bound to the exact proposal digest and gate satisfies admission. This release does +not provide signatures, operational providers, PostgreSQL runtime integration, restart recovery, +high availability or daemon reconciliation. + +The v0.2 admission and plan schemas gain integrity-envelope fields. New v0.4.1 approval, +executable-plan and desired-revision schemas are published. The typed desired-revision adapter is +the supported activation path; legacy prototype records remain readable but are not admission +proof. Their compatibility shape is an explicit typed adapter rather than an arbitrary dictionary. diff --git a/docs/roadmap.md b/docs/roadmap.md index 7396040..f111214 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,8 +1,9 @@ # NetSovereign roadmap -Implementation status: v0.1 through v0.4 are implemented and tested. v0.4 supplies the -bounded, single-node durable control-plane seams and PostgreSQL schema; v0.5 naming -materialisation remains planned and is not implied by those interfaces. +Implementation status: v0.1 through v0.4.1 are implemented and tested. v0.4 supplies a bounded +local repository prototype and PostgreSQL schema, not integrated durable execution. v0.4.1 binds +the cumulative semantic chain before later durability. Checkpoints, restart recovery, operational +providers and v0.5 naming materialisation remain planned and are not implied by these interfaces. 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 diff --git a/pyproject.toml b/pyproject.toml index cc17f3c..abb7a38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "netsovereign" -version = "0.4.0" +version = "0.4.1" description = "Provider-neutral compiler and reconciliation runtime for sovereign worlds" readme = "README.md" requires-python = ">=3.12" diff --git a/schemas/admission-v0.2.schema.json b/schemas/admission-v0.2.schema.json index 8048ed9..8d6f472 100644 --- a/schemas/admission-v0.2.schema.json +++ b/schemas/admission-v0.2.schema.json @@ -3,15 +3,20 @@ "AcceptedWorldRevision": { "additionalProperties": false, "properties": { - "canonical_intent_digest": { - "title": "Canonical Intent Digest", + "revision_record_id": { + "default": "", + "title": "Revision Record Id", "type": "string" }, - "declaration_digest": { - "title": "Declaration Digest", + "world_id": { + "title": "World Id", "type": "string" }, - "manifest_digest": { + "revision": { + "title": "Revision", + "type": "string" + }, + "parent_revision": { "anyOf": [ { "type": "string" @@ -20,13 +25,26 @@ "type": "null" } ], - "title": "Manifest Digest" + "default": null, + "title": "Parent Revision" + }, + "declaration_digest": { + "title": "Declaration Digest", + "type": "string" + }, + "canonical_intent_digest": { + "title": "Canonical Intent Digest", + "type": "string" }, "materialization_digest": { "title": "Materialization Digest", "type": "string" }, - "parent_revision": { + "world_digest": { + "title": "World Digest", + "type": "string" + }, + "manifest_digest": { "anyOf": [ { "type": "string" @@ -35,19 +53,11 @@ "type": "null" } ], - "default": null, - "title": "Parent Revision" - }, - "revision": { - "title": "Revision", - "type": "string" - }, - "world_digest": { - "title": "World Digest", - "type": "string" + "title": "Manifest Digest" }, - "world_id": { - "title": "World Id", + "canonicalization_profile": { + "default": "netsovereign.canonical-json/v1", + "title": "Canonicalization Profile", "type": "string" } }, @@ -103,6 +113,18 @@ "title": "Approval Id", "type": "string" }, + "subject_digest": { + "title": "Subject Digest", + "type": "string" + }, + "gate_id": { + "title": "Gate Id", + "type": "string" + }, + "approver": { + "title": "Approver", + "type": "string" + }, "approved_at": { "format": "date-time", "title": "Approved At", @@ -111,10 +133,50 @@ "provenance": { "title": "Provenance", "type": "string" + }, + "expires_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Expires At" + }, + "verification_status": { + "default": "asserted", + "enum": [ + "asserted", + "verified", + "revoked", + "superseded" + ], + "title": "Verification Status", + "type": "string" + }, + "verification_material": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Verification Material" } }, "required": [ "approval_id", + "subject_digest", + "gate_id", + "approver", "approved_at", "provenance" ], @@ -124,21 +186,21 @@ "Change": { "additionalProperties": false, "properties": { - "actionable": { - "default": true, - "title": "Actionable", - "type": "boolean" + "id": { + "title": "Id", + "type": "string" }, - "after": { - "default": null, - "title": "After" + "classification": { + "$ref": "#/$defs/ChangeClassification" }, - "approval_required": { - "default": false, - "title": "Approval Required", - "type": "boolean" + "operation": { + "$ref": "#/$defs/ChangeOperation" }, - "authority_id": { + "path": { + "title": "Path", + "type": "string" + }, + "subject_id": { "anyOf": [ { "type": "string" @@ -148,33 +210,27 @@ } ], "default": null, - "title": "Authority Id" + "title": "Subject Id" }, "before": { "default": null, "title": "Before" }, - "classification": { - "$ref": "#/$defs/ChangeClassification" - }, - "explanation": { - "title": "Explanation", - "type": "string" + "after": { + "default": null, + "title": "After" }, - "governance": { + "authority_id": { "anyOf": [ { - "$ref": "#/$defs/GovernanceRequirement" + "type": "string" }, { "type": "null" } ], - "default": null - }, - "id": { - "title": "Id", - "type": "string" + "default": null, + "title": "Authority Id" }, "mandate_id": { "anyOf": [ @@ -188,28 +244,34 @@ "default": null, "title": "Mandate Id" }, - "operation": { - "$ref": "#/$defs/ChangeOperation" - }, - "path": { - "title": "Path", - "type": "string" - }, - "risk": { - "$ref": "#/$defs/Risk", - "default": "low" - }, - "subject_id": { + "governance": { "anyOf": [ { - "type": "string" + "$ref": "#/$defs/GovernanceRequirement" }, { "type": "null" } ], - "default": null, - "title": "Subject Id" + "default": null + }, + "risk": { + "$ref": "#/$defs/Risk", + "default": "low" + }, + "approval_required": { + "default": false, + "title": "Approval Required", + "type": "boolean" + }, + "actionable": { + "default": true, + "title": "Actionable", + "type": "boolean" + }, + "explanation": { + "title": "Explanation", + "type": "string" } }, "required": [ @@ -247,16 +309,16 @@ "action": { "$ref": "#/$defs/GovernedAction" }, - "jurisdiction": { - "title": "Jurisdiction", - "type": "string" - }, "resource_classes": { "items": { "$ref": "#/$defs/ResourceClass" }, "title": "Resource Classes", "type": "array" + }, + "jurisdiction": { + "title": "Jurisdiction", + "type": "string" } }, "required": [ @@ -271,11 +333,15 @@ "enum": [ "declare", "admit", + "submit", "allocate", "delegate", "revoke", "route", - "expose" + "expose", + "certify", + "mirror", + "federate" ], "title": "GovernedAction", "type": "string" @@ -313,45 +379,17 @@ }, "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" + "admitted": { + "title": "Admitted", + "type": "boolean" }, - "drift": { - "items": { - "$ref": "#/$defs/Change" - }, - "title": "Drift", - "type": "array" + "status": { + "$ref": "#/$defs/AdmissionStatus" }, "evaluated_at": { "anyOf": [ @@ -366,9 +404,25 @@ "default": null, "title": "Evaluated At" }, - "explanation": { - "title": "Explanation", - "type": "string" + "current": { + "$ref": "#/$defs/AcceptedWorldRevision" + }, + "proposed": { + "$ref": "#/$defs/AcceptedWorldRevision" + }, + "changes": { + "items": { + "$ref": "#/$defs/Change" + }, + "title": "Changes", + "type": "array" + }, + "drift": { + "items": { + "$ref": "#/$defs/Change" + }, + "title": "Drift", + "type": "array" }, "issues": { "items": { @@ -377,11 +431,38 @@ "title": "Issues", "type": "array" }, - "proposed": { - "$ref": "#/$defs/AcceptedWorldRevision" + "approval_gates": { + "items": { + "type": "string" + }, + "title": "Approval Gates", + "type": "array" }, - "status": { - "$ref": "#/$defs/AdmissionStatus" + "approvals": { + "items": { + "$ref": "#/$defs/ApprovalEvidence" + }, + "title": "Approvals", + "type": "array" + }, + "explanation": { + "title": "Explanation", + "type": "string" + }, + "decision_id": { + "default": "", + "title": "Decision Id", + "type": "string" + }, + "decision_digest": { + "default": "", + "title": "Decision Digest", + "type": "string" + }, + "canonicalization_profile": { + "default": "netsovereign.canonical-json/v1", + "title": "Canonicalization Profile", + "type": "string" } }, "required": [ diff --git a/schemas/approval-v0.4.1.schema.json b/schemas/approval-v0.4.1.schema.json new file mode 100644 index 0000000..7701c28 --- /dev/null +++ b/schemas/approval-v0.4.1.schema.json @@ -0,0 +1,77 @@ +{ + "additionalProperties": false, + "properties": { + "approval_id": { + "title": "Approval Id", + "type": "string" + }, + "subject_digest": { + "title": "Subject Digest", + "type": "string" + }, + "gate_id": { + "title": "Gate Id", + "type": "string" + }, + "approver": { + "title": "Approver", + "type": "string" + }, + "approved_at": { + "format": "date-time", + "title": "Approved At", + "type": "string" + }, + "provenance": { + "title": "Provenance", + "type": "string" + }, + "expires_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Expires At" + }, + "verification_status": { + "default": "asserted", + "enum": [ + "asserted", + "verified", + "revoked", + "superseded" + ], + "title": "Verification Status", + "type": "string" + }, + "verification_material": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Verification Material" + } + }, + "required": [ + "approval_id", + "subject_digest", + "gate_id", + "approver", + "approved_at", + "provenance" + ], + "title": "ApprovalEvidence", + "type": "object" +} diff --git a/schemas/desired-revision-v0.4.1.schema.json b/schemas/desired-revision-v0.4.1.schema.json new file mode 100644 index 0000000..ab6899a --- /dev/null +++ b/schemas/desired-revision-v0.4.1.schema.json @@ -0,0 +1,669 @@ +{ + "$defs": { + "AcceptedWorldRevision": { + "additionalProperties": false, + "properties": { + "revision_record_id": { + "default": "", + "title": "Revision Record Id", + "type": "string" + }, + "world_id": { + "title": "World Id", + "type": "string" + }, + "revision": { + "title": "Revision", + "type": "string" + }, + "parent_revision": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Parent Revision" + }, + "declaration_digest": { + "title": "Declaration Digest", + "type": "string" + }, + "canonical_intent_digest": { + "title": "Canonical Intent Digest", + "type": "string" + }, + "materialization_digest": { + "title": "Materialization Digest", + "type": "string" + }, + "world_digest": { + "title": "World Digest", + "type": "string" + }, + "manifest_digest": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Manifest Digest" + }, + "canonicalization_profile": { + "default": "netsovereign.canonical-json/v1", + "title": "Canonicalization Profile", + "type": "string" + } + }, + "required": [ + "world_id", + "revision", + "declaration_digest", + "canonical_intent_digest", + "materialization_digest", + "world_digest", + "manifest_digest" + ], + "title": "AcceptedWorldRevision", + "type": "object" + }, + "AdmissionDecision": { + "additionalProperties": false, + "properties": { + "api_version": { + "default": "netsovereign.io/admission/v0.2", + "title": "Api Version", + "type": "string" + }, + "admitted": { + "title": "Admitted", + "type": "boolean" + }, + "status": { + "$ref": "#/$defs/AdmissionStatus" + }, + "evaluated_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Evaluated At" + }, + "current": { + "$ref": "#/$defs/AcceptedWorldRevision" + }, + "proposed": { + "$ref": "#/$defs/AcceptedWorldRevision" + }, + "changes": { + "items": { + "$ref": "#/$defs/Change" + }, + "title": "Changes", + "type": "array" + }, + "drift": { + "items": { + "$ref": "#/$defs/Change" + }, + "title": "Drift", + "type": "array" + }, + "issues": { + "items": { + "$ref": "#/$defs/AdmissionIssue" + }, + "title": "Issues", + "type": "array" + }, + "approval_gates": { + "items": { + "type": "string" + }, + "title": "Approval Gates", + "type": "array" + }, + "approvals": { + "items": { + "$ref": "#/$defs/ApprovalEvidence" + }, + "title": "Approvals", + "type": "array" + }, + "explanation": { + "title": "Explanation", + "type": "string" + }, + "decision_id": { + "default": "", + "title": "Decision Id", + "type": "string" + }, + "decision_digest": { + "default": "", + "title": "Decision Digest", + "type": "string" + }, + "canonicalization_profile": { + "default": "netsovereign.canonical-json/v1", + "title": "Canonicalization Profile", + "type": "string" + } + }, + "required": [ + "admitted", + "status", + "current", + "proposed", + "changes", + "drift", + "issues", + "approval_gates", + "explanation" + ], + "title": "AdmissionDecision", + "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" + }, + "subject_digest": { + "title": "Subject Digest", + "type": "string" + }, + "gate_id": { + "title": "Gate Id", + "type": "string" + }, + "approver": { + "title": "Approver", + "type": "string" + }, + "approved_at": { + "format": "date-time", + "title": "Approved At", + "type": "string" + }, + "provenance": { + "title": "Provenance", + "type": "string" + }, + "expires_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Expires At" + }, + "verification_status": { + "default": "asserted", + "enum": [ + "asserted", + "verified", + "revoked", + "superseded" + ], + "title": "Verification Status", + "type": "string" + }, + "verification_material": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Verification Material" + } + }, + "required": [ + "approval_id", + "subject_digest", + "gate_id", + "approver", + "approved_at", + "provenance" + ], + "title": "ApprovalEvidence", + "type": "object" + }, + "Change": { + "additionalProperties": false, + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "classification": { + "$ref": "#/$defs/ChangeClassification" + }, + "operation": { + "$ref": "#/$defs/ChangeOperation" + }, + "path": { + "title": "Path", + "type": "string" + }, + "subject_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Subject Id" + }, + "before": { + "default": null, + "title": "Before" + }, + "after": { + "default": null, + "title": "After" + }, + "authority_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Authority Id" + }, + "mandate_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mandate Id" + }, + "governance": { + "anyOf": [ + { + "$ref": "#/$defs/GovernanceRequirement" + }, + { + "type": "null" + } + ], + "default": null + }, + "risk": { + "$ref": "#/$defs/Risk", + "default": "low" + }, + "approval_required": { + "default": false, + "title": "Approval Required", + "type": "boolean" + }, + "actionable": { + "default": true, + "title": "Actionable", + "type": "boolean" + }, + "explanation": { + "title": "Explanation", + "type": "string" + } + }, + "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" + }, + "resource_classes": { + "items": { + "$ref": "#/$defs/ResourceClass" + }, + "title": "Resource Classes", + "type": "array" + }, + "jurisdiction": { + "title": "Jurisdiction", + "type": "string" + } + }, + "required": [ + "action", + "resource_classes", + "jurisdiction" + ], + "title": "GovernanceRequirement", + "type": "object" + }, + "GovernedAction": { + "enum": [ + "declare", + "admit", + "submit", + "allocate", + "delegate", + "revoke", + "route", + "expose", + "certify", + "mirror", + "federate" + ], + "title": "GovernedAction", + "type": "string" + }, + "LegacyAdmissionRecord": { + "additionalProperties": false, + "description": "Explicit read adapter for v0.4 prototype records; never admission proof.", + "properties": { + "status": { + "enum": [ + "accepted", + "rejected" + ], + "title": "Status", + "type": "string" + } + }, + "required": [ + "status" + ], + "title": "LegacyAdmissionRecord", + "type": "object" + }, + "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": { + "world_id": { + "title": "World Id", + "type": "string" + }, + "revision_id": { + "title": "Revision Id", + "type": "string" + }, + "parent_revision_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Parent Revision Id" + }, + "schema_version": { + "title": "Schema Version", + "type": "string" + }, + "declaration_digest": { + "title": "Declaration Digest", + "type": "string" + }, + "declaration": { + "additionalProperties": true, + "title": "Declaration", + "type": "object" + }, + "accepted_at": { + "format": "date-time", + "title": "Accepted At", + "type": "string" + }, + "admission": { + "anyOf": [ + { + "$ref": "#/$defs/AdmissionDecision" + }, + { + "$ref": "#/$defs/LegacyAdmissionRecord" + } + ], + "title": "Admission" + }, + "actor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Actor" + }, + "authority_manifest_digest": { + "title": "Authority Manifest Digest", + "type": "string" + }, + "boundary_policy_digest": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Boundary Policy Digest" + }, + "compatibility": { + "additionalProperties": true, + "title": "Compatibility", + "type": "object" + }, + "status": { + "default": "accepted", + "title": "Status", + "type": "string" + }, + "declared_revision": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Declared Revision" + }, + "canonical_intent_digest": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Canonical Intent Digest" + }, + "materialization_digest": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Materialization Digest" + }, + "manifest_digest": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Manifest Digest" + }, + "admission_decision_digest": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Admission Decision Digest" + }, + "canonicalization_profile": { + "default": "netsovereign.canonical-json/v1", + "title": "Canonicalization Profile", + "type": "string" + } + }, + "required": [ + "world_id", + "revision_id", + "schema_version", + "declaration_digest", + "declaration", + "accepted_at", + "admission", + "authority_manifest_digest" + ], + "title": "DesiredRevision", + "type": "object" +} diff --git a/schemas/executable-plan-v0.4.1.schema.json b/schemas/executable-plan-v0.4.1.schema.json new file mode 100644 index 0000000..51a2eba --- /dev/null +++ b/schemas/executable-plan-v0.4.1.schema.json @@ -0,0 +1,395 @@ +{ + "$defs": { + "CapabilityRequirement": { + "additionalProperties": false, + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "version": { + "default": "1.0", + "title": "Version", + "type": "string" + } + }, + "required": [ + "id" + ], + "title": "CapabilityRequirement", + "type": "object" + }, + "ExecutableOperation": { + "additionalProperties": false, + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "source_step_id": { + "title": "Source Step Id", + "type": "string" + }, + "authority_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authority Id" + }, + "mandate_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Mandate Id" + }, + "target": { + "title": "Target", + "type": "string" + }, + "operation_type": { + "title": "Operation Type", + "type": "string" + }, + "capability": { + "$ref": "#/$defs/CapabilityRequirement" + }, + "provider_id": { + "title": "Provider Id", + "type": "string" + }, + "provider_binding_id": { + "title": "Provider Binding Id", + "type": "string" + }, + "depends_on": { + "items": { + "type": "string" + }, + "title": "Depends On", + "type": "array" + }, + "preconditions": { + "items": { + "$ref": "#/$defs/PlanPredicate" + }, + "title": "Preconditions", + "type": "array" + }, + "expected": { + "title": "Expected" + }, + "idempotency_key": { + "title": "Idempotency Key", + "type": "string" + }, + "retry_policy": { + "$ref": "#/$defs/RetryPolicy" + }, + "failure_posture": { + "$ref": "#/$defs/FailurePosture" + }, + "dry_run_compatible": { + "title": "Dry Run Compatible", + "type": "boolean" + }, + "order": { + "title": "Order", + "type": "integer" + }, + "reversibility": { + "$ref": "#/$defs/Reversibility", + "default": "unknown" + }, + "prior_value": { + "default": null, + "title": "Prior Value" + }, + "expected_outcome_digest": { + "default": "", + "title": "Expected Outcome Digest", + "type": "string" + }, + "provider_idempotent": { + "default": true, + "title": "Provider Idempotent", + "type": "boolean" + }, + "provider_compensation": { + "default": true, + "title": "Provider Compensation", + "type": "boolean" + } + }, + "required": [ + "id", + "source_step_id", + "authority_id", + "mandate_id", + "target", + "operation_type", + "capability", + "provider_id", + "provider_binding_id", + "depends_on", + "preconditions", + "expected", + "idempotency_key", + "retry_policy", + "failure_posture", + "dry_run_compatible", + "order" + ], + "title": "ExecutableOperation", + "type": "object" + }, + "FailureClass": { + "enum": [ + "validation", + "capability_unavailable", + "precondition_failed", + "conflict", + "transient_provider_failure", + "permanent_provider_failure", + "timeout", + "observation_mismatch", + "compensation_failure", + "internal_engine_failure" + ], + "title": "FailureClass", + "type": "string" + }, + "FailurePosture": { + "enum": [ + "stop", + "compensate_all", + "continue_independent", + "best_effort_cleanup" + ], + "title": "FailurePosture", + "type": "string" + }, + "PlanPredicate": { + "additionalProperties": false, + "properties": { + "kind": { + "$ref": "#/$defs/PredicateKind" + }, + "revision": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Revision" + }, + "digest": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Digest" + }, + "mandate_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Mandate Id" + }, + "approval_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Approval Id" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Path" + }, + "value_digest": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Value Digest" + }, + "binding_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Binding Id" + }, + "maximum_age_seconds": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Maximum Age Seconds" + } + }, + "required": [ + "kind" + ], + "title": "PlanPredicate", + "type": "object" + }, + "PredicateKind": { + "enum": [ + "accepted_revision_equals", + "proposed_digest_equals", + "observation_equals", + "mandate_active", + "approval_present", + "active_desired_revision_equals", + "observation_fresh", + "provider_binding_equals" + ], + "title": "PredicateKind", + "type": "string" + }, + "RetryPolicy": { + "additionalProperties": false, + "properties": { + "maximum_attempts": { + "default": 1, + "minimum": 1, + "title": "Maximum Attempts", + "type": "integer" + }, + "delay_seconds": { + "default": 0, + "minimum": 0, + "title": "Delay Seconds", + "type": "number" + }, + "retryable": { + "items": { + "$ref": "#/$defs/FailureClass" + }, + "title": "Retryable", + "type": "array", + "uniqueItems": true + } + }, + "title": "RetryPolicy", + "type": "object" + }, + "Reversibility": { + "enum": [ + "reversible", + "conditionally_reversible", + "irreversible", + "unknown", + "cleanup_only", + "compensatable_not_rollback_equivalent" + ], + "title": "Reversibility", + "type": "string" + } + }, + "additionalProperties": false, + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "source_plan_id": { + "title": "Source Plan Id", + "type": "string" + }, + "world_id": { + "title": "World Id", + "type": "string" + }, + "from_revision": { + "title": "From Revision", + "type": "string" + }, + "desired_revision": { + "title": "Desired Revision", + "type": "string" + }, + "fingerprint": { + "title": "Fingerprint", + "type": "string" + }, + "operations": { + "items": { + "$ref": "#/$defs/ExecutableOperation" + }, + "title": "Operations", + "type": "array" + }, + "admission_decision_digest": { + "default": "", + "title": "Admission Decision Digest", + "type": "string" + } + }, + "required": [ + "id", + "source_plan_id", + "world_id", + "from_revision", + "desired_revision", + "fingerprint", + "operations" + ], + "title": "ExecutablePlan", + "type": "object" +} diff --git a/schemas/observed-v0.2.schema.json b/schemas/observed-v0.2.schema.json index 4a22be1..be70f6f 100644 --- a/schemas/observed-v0.2.schema.json +++ b/schemas/observed-v0.2.schema.json @@ -4,6 +4,25 @@ "additionalProperties": false, "description": "A non-canonical observation addressed by a stable semantic path.", "properties": { + "path": { + "title": "Path", + "type": "string" + }, + "status": { + "default": "present", + "enum": [ + "present", + "absent", + "unknown", + "unreadable" + ], + "title": "Status", + "type": "string" + }, + "value": { + "default": null, + "title": "Value" + }, "observed_at": { "anyOf": [ { @@ -17,10 +36,6 @@ "default": null, "title": "Observed At" }, - "path": { - "title": "Path", - "type": "string" - }, "provenance": { "anyOf": [ { @@ -32,21 +47,6 @@ ], "default": null, "title": "Provenance" - }, - "status": { - "default": "present", - "enum": [ - "present", - "absent", - "unknown", - "unreadable" - ], - "title": "Status", - "type": "string" - }, - "value": { - "default": null, - "title": "Value" } }, "required": [ @@ -63,12 +63,9 @@ "title": "Apiversion", "type": "string" }, - "facts": { - "items": { - "$ref": "#/$defs/ObservedFact" - }, - "title": "Facts", - "type": "array" + "world_id": { + "title": "World Id", + "type": "string" }, "observed_at": { "format": "date-time", @@ -79,9 +76,12 @@ "title": "Provenance", "type": "string" }, - "world_id": { - "title": "World Id", - "type": "string" + "facts": { + "items": { + "$ref": "#/$defs/ObservedFact" + }, + "title": "Facts", + "type": "array" } }, "required": [ diff --git a/schemas/plan-v0.2.schema.json b/schemas/plan-v0.2.schema.json index b41c0a0..e1c36c8 100644 --- a/schemas/plan-v0.2.schema.json +++ b/schemas/plan-v0.2.schema.json @@ -3,21 +3,21 @@ "Change": { "additionalProperties": false, "properties": { - "actionable": { - "default": true, - "title": "Actionable", - "type": "boolean" + "id": { + "title": "Id", + "type": "string" }, - "after": { - "default": null, - "title": "After" + "classification": { + "$ref": "#/$defs/ChangeClassification" }, - "approval_required": { - "default": false, - "title": "Approval Required", - "type": "boolean" + "operation": { + "$ref": "#/$defs/ChangeOperation" }, - "authority_id": { + "path": { + "title": "Path", + "type": "string" + }, + "subject_id": { "anyOf": [ { "type": "string" @@ -27,33 +27,27 @@ } ], "default": null, - "title": "Authority Id" + "title": "Subject Id" }, "before": { "default": null, "title": "Before" }, - "classification": { - "$ref": "#/$defs/ChangeClassification" - }, - "explanation": { - "title": "Explanation", - "type": "string" + "after": { + "default": null, + "title": "After" }, - "governance": { + "authority_id": { "anyOf": [ { - "$ref": "#/$defs/GovernanceRequirement" + "type": "string" }, { "type": "null" } ], - "default": null - }, - "id": { - "title": "Id", - "type": "string" + "default": null, + "title": "Authority Id" }, "mandate_id": { "anyOf": [ @@ -67,28 +61,34 @@ "default": null, "title": "Mandate Id" }, - "operation": { - "$ref": "#/$defs/ChangeOperation" - }, - "path": { - "title": "Path", - "type": "string" - }, - "risk": { - "$ref": "#/$defs/Risk", - "default": "low" - }, - "subject_id": { + "governance": { "anyOf": [ { - "type": "string" + "$ref": "#/$defs/GovernanceRequirement" }, { "type": "null" } ], - "default": null, - "title": "Subject Id" + "default": null + }, + "risk": { + "$ref": "#/$defs/Risk", + "default": "low" + }, + "approval_required": { + "default": false, + "title": "Approval Required", + "type": "boolean" + }, + "actionable": { + "default": true, + "title": "Actionable", + "type": "boolean" + }, + "explanation": { + "title": "Explanation", + "type": "string" } }, "required": [ @@ -155,16 +155,16 @@ "action": { "$ref": "#/$defs/GovernedAction" }, - "jurisdiction": { - "title": "Jurisdiction", - "type": "string" - }, "resource_classes": { "items": { "$ref": "#/$defs/ResourceClass" }, "title": "Resource Classes", "type": "array" + }, + "jurisdiction": { + "title": "Jurisdiction", + "type": "string" } }, "required": [ @@ -179,11 +179,15 @@ "enum": [ "declare", "admit", + "submit", "allocate", "delegate", "revoke", "route", - "expose" + "expose", + "certify", + "mirror", + "federate" ], "title": "GovernedAction", "type": "string" @@ -191,7 +195,10 @@ "PlanPredicate": { "additionalProperties": false, "properties": { - "approval_id": { + "kind": { + "$ref": "#/$defs/PredicateKind" + }, + "revision": { "anyOf": [ { "type": "string" @@ -201,7 +208,7 @@ } ], "default": null, - "title": "Approval Id" + "title": "Revision" }, "digest": { "anyOf": [ @@ -215,9 +222,6 @@ "default": null, "title": "Digest" }, - "kind": { - "$ref": "#/$defs/PredicateKind" - }, "mandate_id": { "anyOf": [ { @@ -230,7 +234,7 @@ "default": null, "title": "Mandate Id" }, - "path": { + "approval_id": { "anyOf": [ { "type": "string" @@ -240,9 +244,9 @@ } ], "default": null, - "title": "Path" + "title": "Approval Id" }, - "revision": { + "path": { "anyOf": [ { "type": "string" @@ -252,7 +256,7 @@ } ], "default": null, - "title": "Revision" + "title": "Path" }, "value_digest": { "anyOf": [ @@ -265,6 +269,31 @@ ], "default": null, "title": "Value Digest" + }, + "binding_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Binding Id" + }, + "maximum_age_seconds": { + "anyOf": [ + { + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Maximum Age Seconds" } }, "required": [ @@ -276,26 +305,22 @@ "PlanStep": { "additionalProperties": false, "properties": { - "action": { - "title": "Action", + "id": { + "title": "Id", "type": "string" }, - "authority_id": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Authority Id" - }, "change_id": { "title": "Change Id", "type": "string" }, + "action": { + "title": "Action", + "type": "string" + }, + "target": { + "title": "Target", + "type": "string" + }, "depends_on": { "items": { "type": "string" @@ -303,6 +328,13 @@ "title": "Depends On", "type": "array" }, + "preconditions": { + "items": { + "$ref": "#/$defs/PlanPredicate" + }, + "title": "Preconditions", + "type": "array" + }, "expected_outcomes": { "items": { "$ref": "#/$defs/ExpectedOutcome" @@ -310,10 +342,29 @@ "title": "Expected Outcomes", "type": "array" }, - "id": { - "title": "Id", + "reversible": { + "title": "Reversible", + "type": "boolean" + }, + "reversibility": { + "$ref": "#/$defs/Reversibility" + }, + "reversibility_reason": { + "title": "Reversibility Reason", "type": "string" }, + "authority_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Authority Id" + }, "mandate_id": { "anyOf": [ { @@ -326,27 +377,12 @@ "default": null, "title": "Mandate Id" }, - "preconditions": { - "items": { - "$ref": "#/$defs/PlanPredicate" - }, - "title": "Preconditions", - "type": "array" - }, - "reversibility": { - "$ref": "#/$defs/Reversibility" + "capability": { + "$ref": "#/$defs/StepCapabilityRequirement" }, - "reversibility_reason": { - "title": "Reversibility Reason", - "type": "string" - }, - "reversible": { - "title": "Reversible", - "type": "boolean" - }, - "target": { - "title": "Target", - "type": "string" + "prior_value": { + "default": null, + "title": "Prior Value" } }, "required": [ @@ -369,7 +405,10 @@ "proposed_digest_equals", "observation_equals", "mandate_active", - "approval_present" + "approval_present", + "active_desired_revision_equals", + "observation_fresh", + "provider_binding_equals" ], "title": "PredicateKind", "type": "string" @@ -397,7 +436,9 @@ "reversible", "conditionally_reversible", "irreversible", - "unknown" + "unknown", + "cleanup_only", + "compensatable_not_rollback_equivalent" ], "title": "Reversibility", "type": "string" @@ -413,24 +454,71 @@ ], "title": "Risk", "type": "string" + }, + "StepCapabilityRequirement": { + "additionalProperties": false, + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "version": { + "default": "1.0", + "title": "Version", + "type": "string" + }, + "binding_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Binding Id" + } + }, + "required": [ + "id" + ], + "title": "StepCapabilityRequirement", + "type": "object" } }, "additionalProperties": false, "properties": { + "api_version": { + "default": "netsovereign.io/plan/v0.2", + "title": "Api Version", + "type": "string" + }, + "world_id": { + "title": "World Id", + "type": "string" + }, + "from_revision": { + "title": "From Revision", + "type": "string" + }, + "to_revision": { + "title": "To Revision", + "type": "string" + }, "admitted": { "title": "Admitted", "type": "boolean" }, - "api_version": { - "default": "netsovereign.io/plan/v0.2", - "title": "Api Version", + "plan_digest": { + "title": "Plan Digest", "type": "string" }, - "approval_gates": { + "steps": { "items": { - "type": "string" + "$ref": "#/$defs/PlanStep" }, - "title": "Approval Gates", + "title": "Steps", "type": "array" }, "drift": { @@ -440,6 +528,13 @@ "title": "Drift", "type": "array" }, + "approval_gates": { + "items": { + "type": "string" + }, + "title": "Approval Gates", + "type": "array" + }, "execution": { "default": "not_permitted", "title": "Execution", @@ -450,27 +545,41 @@ "title": "Explanation", "type": "string" }, - "from_revision": { - "title": "From Revision", + "source_admission_digest": { + "default": "", + "title": "Source Admission Digest", "type": "string" }, - "plan_digest": { - "title": "Plan Digest", + "current_revision_digest": { + "default": "", + "title": "Current Revision Digest", "type": "string" }, - "steps": { - "items": { - "$ref": "#/$defs/PlanStep" - }, - "title": "Steps", - "type": "array" + "proposed_revision_digest": { + "default": "", + "title": "Proposed Revision Digest", + "type": "string" }, - "to_revision": { - "title": "To Revision", + "proposed_manifest_digest": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Proposed Manifest Digest" + }, + "approval_set_digest": { + "default": "", + "title": "Approval Set Digest", "type": "string" }, - "world_id": { - "title": "World Id", + "canonicalization_profile": { + "default": "netsovereign.canonical-json/v1", + "title": "Canonicalization Profile", "type": "string" } }, diff --git a/src/netsovereign/__init__.py b/src/netsovereign/__init__.py index d0d529a..db4e22f 100644 --- a/src/netsovereign/__init__.py +++ b/src/netsovereign/__init__.py @@ -11,6 +11,8 @@ admit_change, build_plan, compare_worlds, + verify_admission_integrity, + verify_plan_integrity, ) from .specification import WorldSpec from .validation import Diagnostic, validate_spec @@ -30,5 +32,7 @@ "build_plan", "compare_worlds", "validate_spec", + "verify_admission_integrity", + "verify_plan_integrity", ] -__version__ = "0.4.0" +__version__ = "0.4.1" diff --git a/src/netsovereign/authority.py b/src/netsovereign/authority.py index 0d5607c..8579220 100644 --- a/src/netsovereign/authority.py +++ b/src/netsovereign/authority.py @@ -6,7 +6,7 @@ from enum import StrEnum from typing import Literal -from pydantic import Field +from pydantic import Field, model_validator from .base import DomainModel @@ -100,6 +100,15 @@ class Validity(DomainModel): not_before: datetime | None = None not_after: datetime | None = None + @model_validator(mode="after") + def coherent_interval(self) -> Validity: + for value in (self.not_before, self.not_after): + if value is not None and (value.tzinfo is None or value.utcoffset() is None): + raise ValueError("mandate validity timestamps must be timezone-aware") + if self.not_before and self.not_after and self.not_before > self.not_after: + raise ValueError("mandate not_before cannot follow not_after") + return self + class MandateConstraints(DomainModel): """Portable v0.2 admission constraints.""" diff --git a/src/netsovereign/canonical.py b/src/netsovereign/canonical.py index 52dc9eb..078841a 100644 --- a/src/netsovereign/canonical.py +++ b/src/netsovereign/canonical.py @@ -1,4 +1,4 @@ -"""Versioned canonical JSON and digest primitives for v0.2 artifacts.""" +"""The single versioned canonical JSON contract used by every engine layer.""" from __future__ import annotations @@ -8,6 +8,9 @@ from .base import DomainModel +CANONICALIZATION_PROFILE = "netsovereign.canonical-json/v1" +DIGEST_ALGORITHM = "sha256" + SET_LIKE_COLLECTIONS = { "institutions", "authorities", @@ -80,4 +83,4 @@ def canonical_json(value: Any) -> str: def digest(value: Any) -> str: - return "sha256:" + hashlib.sha256(canonical_json(value).encode()).hexdigest() + return DIGEST_ALGORITHM + ":" + hashlib.sha256(canonical_json(value).encode()).hexdigest() diff --git a/src/netsovereign/cli.py b/src/netsovereign/cli.py index e5d6419..dde0ca4 100644 --- a/src/netsovereign/cli.py +++ b/src/netsovereign/cli.py @@ -4,7 +4,7 @@ import asyncio import json -from datetime import datetime +from datetime import UTC, datetime from pathlib import Path from typing import Annotated @@ -26,7 +26,7 @@ ) from .providers.fake import FakeProvider from .providers.registry import ProviderRegistry -from .runtime import InMemoryExecutionRepository, RuntimeExecutor, compile_plan +from .runtime import InMemoryExecutionRepository, RuntimeExecutor, compile_plan, offline_demo_facts from .specification import WorldSpec from .validation import has_errors, validate_spec @@ -235,7 +235,9 @@ def execute_command( executable = compile_plan(intent_plan, registry) report = asyncio.run( RuntimeExecutor(registry, InMemoryExecutionRepository()).execute( - executable, dry_run=dry_run + executable, + dry_run=dry_run, + facts=offline_demo_facts(executable, datetime.now(UTC)), ) ) except (OSError, ValidationError, ValueError) as exc: diff --git a/src/netsovereign/controlplane/models.py b/src/netsovereign/controlplane/models.py index 8b3f5ed..bb81774 100644 --- a/src/netsovereign/controlplane/models.py +++ b/src/netsovereign/controlplane/models.py @@ -7,11 +7,15 @@ from datetime import UTC, datetime from enum import StrEnum -from typing import Any +from typing import Any, Literal from pydantic import Field, field_validator, model_validator from ..base import DomainModel +from ..canonical import CANONICALIZATION_PROFILE, digest +from ..manifest import build_manifest +from ..planning import AdmissionDecision, AdmissionStatus, verify_admission_integrity +from ..specification import WorldSpec CONTROL_PLANE_SCHEMA_VERSION = 4 _SECRET_TERMS = ("password", "secret", "token", "private_key", "credential") @@ -40,6 +44,12 @@ def _require_aware(value: datetime | None) -> datetime | None: return value +class LegacyAdmissionRecord(DomainModel): + """Explicit read adapter for v0.4 prototype records; never admission proof.""" + + status: Literal["accepted", "rejected"] + + class DesiredRevision(DomainModel): world_id: str revision_id: str @@ -48,16 +58,69 @@ class DesiredRevision(DomainModel): declaration_digest: str declaration: dict[str, Any] accepted_at: datetime - admission: dict[str, Any] + admission: AdmissionDecision | LegacyAdmissionRecord actor: str | None = None authority_manifest_digest: str boundary_policy_digest: str | None = None compatibility: dict[str, Any] = Field(default_factory=dict) status: str = "accepted" + declared_revision: str | None = None + canonical_intent_digest: str | None = None + materialization_digest: str | None = None + manifest_digest: str | None = None + admission_decision_digest: str | None = None + canonicalization_profile: str = CANONICALIZATION_PROFILE _accepted_at_is_aware = field_validator("accepted_at")(_require_aware) +def desired_revision_from_admission( + decision: AdmissionDecision, + proposal: WorldSpec, + *, + accepted_at: datetime, + actor: str | None = None, +) -> DesiredRevision: + """The only integrity-preserving v0.2 -> control-plane activation adapter.""" + + verify_admission_integrity(decision) + if decision.status != AdmissionStatus.ADMITTED or not decision.admitted: + raise ValueError("only an admitted decision can create desired state") + if decision.approval_gates: + raise ValueError("outstanding approval gates cannot create desired state") + declaration = proposal.model_dump(mode="json", by_alias=True) + if ( + proposal.world.id != decision.proposed.world_id + or proposal.world.revision != decision.proposed.revision + ): + raise ValueError("proposal identity does not match admission") + if digest(declaration) != decision.proposed.declaration_digest: + raise ValueError("proposal declaration digest does not match admission") + manifest_digest = digest(build_manifest(proposal)) + if manifest_digest != decision.proposed.manifest_digest: + raise ValueError("proposal manifest digest does not match admission") + if decision.proposed.parent_revision != decision.current.revision: + raise ValueError("proposal parent is not the accepted revision") + return DesiredRevision( + world_id=proposal.world.id, + revision_id=decision.proposed.revision_record_id, + parent_revision_id=decision.current.revision_record_id, + declared_revision=proposal.world.revision, + schema_version=proposal.api_version, + declaration_digest=decision.proposed.declaration_digest, + canonical_intent_digest=decision.proposed.canonical_intent_digest, + materialization_digest=decision.proposed.materialization_digest, + manifest_digest=manifest_digest, + declaration=declaration, + accepted_at=accepted_at, + admission=decision, + admission_decision_digest=decision.decision_digest, + actor=actor, + authority_manifest_digest=manifest_digest, + status="admitted", + ) + + class AuthoritativeRecord(DomainModel): record_id: str world_id: str diff --git a/src/netsovereign/controlplane/service.py b/src/netsovereign/controlplane/service.py index 4e0f185..1a85b23 100644 --- a/src/netsovereign/controlplane/service.py +++ b/src/netsovereign/controlplane/service.py @@ -2,15 +2,15 @@ from __future__ import annotations -import hashlib -import json from datetime import datetime, timedelta from typing import Any +from ..canonical import digest from .models import ( DesiredRevision, DriftClassification, DriftRecord, + LegacyAdmissionRecord, LockLease, ObservedRecord, ) @@ -18,8 +18,7 @@ def _digest(value: Any) -> str: - canonical = json.dumps(value, sort_keys=True, separators=(",", ":"), default=str) - return hashlib.sha256(canonical.encode()).hexdigest() + return digest(value) class DriftDetector: @@ -135,7 +134,15 @@ def __init__(self, repository: ControlPlaneRepository): self.drift = DriftDetector() def accept_revision(self, revision: DesiredRevision) -> DesiredRevision: - if revision.status != "accepted" or revision.admission.get("status") == "rejected": + if isinstance(revision.admission, dict): + revision = revision.model_copy( + update={"admission": LegacyAdmissionRecord.model_validate(revision.admission)} + ) + admission_status = revision.admission.status + if revision.status not in {"accepted", "admitted"} or admission_status in { + "rejected", + "pending_approval", + }: with self.repository.transaction(): self.repository.put_immutable("desired", revision.revision_id, revision) return revision diff --git a/src/netsovereign/manifest.py b/src/netsovereign/manifest.py index 434a334..eb5b593 100644 --- a/src/netsovereign/manifest.py +++ b/src/netsovereign/manifest.py @@ -203,7 +203,9 @@ def build_manifest(spec: WorldSpec) -> WorldManifest: ) sources: dict[str, list[str]] = {} roles: dict[str, str] = {} - for item in spec.authorities: + # Authority declaration order is set-like. The stable identifier ordering is + # the explicit v0.4.1 domain rule for the compatibility "primary" projection. + for item in sorted(spec.authorities, key=lambda authority: authority.id): sources.setdefault(str(item.source), []).append(item.id) roles.setdefault(str(item.kind), item.id) sources = {key: sorted(value) for key, value in sorted(sources.items())} diff --git a/src/netsovereign/planning.py b/src/netsovereign/planning.py index 1d6d3e3..bce6c8c 100644 --- a/src/netsovereign/planning.py +++ b/src/netsovereign/planning.py @@ -10,7 +10,7 @@ from .authority import MandateConstraints, ResourceClass from .base import DomainModel -from .canonical import canonical_json, digest +from .canonical import CANONICALIZATION_PROFILE, canonical_json, digest from .canonical import normalise as _normalise from .manifest import build_manifest from .specification import WorldSpec @@ -44,11 +44,15 @@ class Risk(StrEnum): class GovernedAction(StrEnum): DECLARE = "declare" ADMIT = "admit" + SUBMIT = "submit" ALLOCATE = "allocate" DELEGATE = "delegate" REVOKE = "revoke" ROUTE = "route" EXPOSE = "expose" + CERTIFY = "certify" + MIRROR = "mirror" + FEDERATE = "federate" class GovernanceRequirement(DomainModel): @@ -65,8 +69,22 @@ class AdmissionStatus(StrEnum): class ApprovalEvidence(DomainModel): approval_id: str + subject_digest: str + gate_id: str + approver: str approved_at: datetime provenance: str + expires_at: datetime | None = None + verification_status: Literal["asserted", "verified", "revoked", "superseded"] = "asserted" + verification_material: dict[str, Any] | None = None + + def valid_for(self, subject_digest: str, gate_id: str, at: datetime) -> bool: + return ( + self.verification_status == "verified" + and self.subject_digest == subject_digest + and self.gate_id == gate_id + and (self.expires_at is None or self.expires_at > at) + ) class ObservedFact(DomainModel): @@ -99,6 +117,7 @@ def coherent_snapshot(self) -> ObservedStateSnapshot: class AcceptedWorldRevision(DomainModel): + revision_record_id: str = "" world_id: str revision: str parent_revision: str | None = None @@ -107,6 +126,7 @@ class AcceptedWorldRevision(DomainModel): materialization_digest: str world_digest: str manifest_digest: str | None + canonicalization_profile: str = CANONICALIZATION_PROFILE class ParentRevisionReference(DomainModel): @@ -150,6 +170,16 @@ class AdmissionDecision(DomainModel): approval_gates: list[str] approvals: list[ApprovalEvidence] = Field(default_factory=list) explanation: str + decision_id: str = "" + decision_digest: str = "" + canonicalization_profile: str = CANONICALIZATION_PROFILE + + def integrity_payload(self) -> dict[str, Any]: + return self.model_dump(mode="json", exclude={"decision_id", "decision_digest"}) + + def verify_integrity(self) -> bool: + value = digest(self.integrity_payload()) + return self.decision_digest == value and self.decision_id == "decision-" + value[7:] class PredicateKind(StrEnum): @@ -158,6 +188,9 @@ class PredicateKind(StrEnum): OBSERVATION_EQUALS = "observation_equals" MANDATE_ACTIVE = "mandate_active" APPROVAL_PRESENT = "approval_present" + ACTIVE_DESIRED_REVISION_EQUALS = "active_desired_revision_equals" + OBSERVATION_FRESH = "observation_fresh" + PROVIDER_BINDING_EQUALS = "provider_binding_equals" class PlanPredicate(DomainModel): @@ -168,6 +201,8 @@ class PlanPredicate(DomainModel): approval_id: str | None = None path: str | None = None value_digest: str | None = None + binding_id: str | None = None + maximum_age_seconds: int | None = Field(default=None, ge=0) class ExpectedOutcome(DomainModel): @@ -182,6 +217,14 @@ class Reversibility(StrEnum): CONDITIONALLY_REVERSIBLE = "conditionally_reversible" IRREVERSIBLE = "irreversible" UNKNOWN = "unknown" + CLEANUP_ONLY = "cleanup_only" + COMPENSATABLE_NOT_ROLLBACK = "compensatable_not_rollback_equivalent" + + +class StepCapabilityRequirement(DomainModel): + id: str + version: str = "1.0" + binding_id: str | None = None class PlanStep(DomainModel): @@ -197,6 +240,10 @@ class PlanStep(DomainModel): reversibility_reason: str authority_id: str | None = None mandate_id: str | None = None + capability: StepCapabilityRequirement = Field( + default_factory=lambda: StepCapabilityRequirement(id="declaration.storage") + ) + prior_value: Any = None class ReconciliationPlan(DomainModel): @@ -211,6 +258,21 @@ class ReconciliationPlan(DomainModel): approval_gates: list[str] execution: str = "not_permitted" explanation: str = "Provider-neutral plan only; no infrastructure was touched." + source_admission_digest: str = "" + current_revision_digest: str = "" + proposed_revision_digest: str = "" + proposed_manifest_digest: str | None = None + approval_set_digest: str = "" + canonicalization_profile: str = CANONICALIZATION_PROFILE + + def integrity_payload(self) -> dict[str, Any]: + return self.model_dump(mode="json", exclude={"plan_digest"}) + + def verify_integrity(self) -> bool: + return ( + self.canonicalization_profile == CANONICALIZATION_PROFILE + and self.plan_digest == digest(self.integrity_payload()) + ) def accepted_revision(spec: WorldSpec, parent_revision: str | None = None) -> AcceptedWorldRevision: @@ -223,7 +285,11 @@ def accepted_revision(spec: WorldSpec, parent_revision: str | None = None) -> Ac "providerBindings": declaration["providerBindings"], } declaration_digest = digest(declaration) + record_digest = digest( + {"world": spec.world.id, "revision": spec.world.revision, "declaration": declaration_digest} + ) return AcceptedWorldRevision( + revision_record_id="revision-" + record_digest[7:], world_id=spec.world.id, revision=spec.world.revision, parent_revision=parent_revision, @@ -406,7 +472,9 @@ def _mandate_for( if not _constraints_allow(mandate.constraints, operation, path, subject): continue candidates.append(mandate.id) - return sorted(candidates)[0] if candidates else None + # Equally applicable mandates are an authority ambiguity, not a tie that an + # implementation may resolve by identifier ordering. + return candidates[0] if len(candidates) == 1 else None def compare_worlds( @@ -787,7 +855,16 @@ def admit_change( } ) supplied_approvals = sorted(approvals or [], key=lambda item: item.approval_id) - approved_ids = {item.approval_id for item in supplied_approvals} + approval_subject = accepted_revision(proposed, current.world.revision).declaration_digest + approval_time = evaluated_at or max( + (item.approved_at for item in supplied_approvals), default=datetime.min.replace(tzinfo=None) + ) + approved_ids = { + item.approval_id + for item in supplied_approvals + if approval_time.tzinfo is not None + and item.valid_for(approval_subject, item.approval_id, approval_time) + } outstanding_gates = [gate for gate in gates if gate not in approved_ids] drift = _observed_drift(proposed, observed) status = ( @@ -798,7 +875,7 @@ def admit_change( else AdmissionStatus.ADMITTED ) admitted = status == AdmissionStatus.ADMITTED - return AdmissionDecision( + decision = AdmissionDecision( admitted=admitted, status=status, evaluated_at=evaluated_at, @@ -817,6 +894,48 @@ def admit_change( else "Proposal is rejected; resolve every admission issue before planning convergence." ), ) + decision.decision_digest = digest(decision.integrity_payload()) + decision.decision_id = "decision-" + decision.decision_digest[7:] + return decision + + +def verify_admission_integrity(decision: AdmissionDecision) -> None: + """Fail closed when a loaded admission decision differs from its envelope.""" + + if not decision.verify_integrity(): + raise ValueError("admission decision integrity check failed") + + +def _capability_for(change: Change) -> StepCapabilityRequirement: + path = change.path + governance = change.governance + action = governance.action if governance else None + classes = set(governance.resource_classes if governance else []) + if path.startswith("provider_bindings/"): + return StepCapabilityRequirement(id="declaration.storage") + if action == GovernedAction.SUBMIT: + return StepCapabilityRequirement(id="registration.submit") + if action == GovernedAction.ADMIT and ResourceClass.REGISTRATION in classes: + return StepCapabilityRequirement(id="registry.admit") + if ResourceClass.CERTIFICATE in classes or action == GovernedAction.CERTIFY: + return StepCapabilityRequirement(id="trust.manage") + if ResourceClass.NUMBER in classes: + return StepCapabilityRequirement(id="number.allocate") + if ResourceClass.NAME in classes or ResourceClass.DOMAIN in classes: + return StepCapabilityRequirement(id="naming.authoritative.manage") + if ResourceClass.ROUTE in classes or action == GovernedAction.ROUTE: + return StepCapabilityRequirement(id="route.manage") + if action == GovernedAction.EXPOSE: + return StepCapabilityRequirement(id="boundary.expose") + if ResourceClass.PLATFORM_IDENTITY in classes: + return StepCapabilityRequirement(id="identity.platform.manage") + if ResourceClass.INWORLD_IDENTITY in classes: + return StepCapabilityRequirement(id="identity.inworld.manage") + if ResourceClass.MAIL_DOMAIN in classes: + return StepCapabilityRequirement(id="mail-domain.manage") + if ResourceClass.SERVICE in classes: + return StepCapabilityRequirement(id="service-catalogue.manage") + return StepCapabilityRequirement(id="declaration.storage") def _dependency_targets(change: Change) -> set[str]: @@ -856,6 +975,7 @@ def _dependency_targets(change: Change) -> set[str]: def build_plan(decision: AdmissionDecision) -> ReconciliationPlan: + verify_admission_integrity(decision) steps: list[PlanStep] = [] if decision.status != AdmissionStatus.REJECTED: convergence = [ @@ -900,6 +1020,7 @@ def build_plan(decision: AdmissionDecision) -> ReconciliationPlan: PlanPredicate( kind=PredicateKind.APPROVAL_PRESENT, approval_id=f"approve:{str(change.risk)}:{change.id}", + digest=decision.decision_digest, ) ) reversibility = ( @@ -947,26 +1068,29 @@ def build_plan(decision: AdmissionDecision) -> ReconciliationPlan: ), authority_id=change.authority_id, mandate_id=change.mandate_id, + capability=_capability_for(change), + prior_value=change.before, ) ) - 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( + plan = ReconciliationPlan( world_id=decision.proposed.world_id, from_revision=decision.current.revision, to_revision=decision.proposed.revision, admitted=decision.admitted, - plan_digest=digest(payload), + plan_digest="pending", steps=steps, drift=decision.drift, approval_gates=decision.approval_gates, + source_admission_digest=decision.decision_digest, + current_revision_digest=decision.current.declaration_digest, + proposed_revision_digest=decision.proposed.declaration_digest, + proposed_manifest_digest=decision.proposed.manifest_digest, + approval_set_digest=digest([item.model_dump(mode="json") for item in decision.approvals]), ) + plan.plan_digest = digest(plan.integrity_payload()) + return plan + + +def verify_plan_integrity(plan: ReconciliationPlan) -> None: + if not plan.verify_integrity(): + raise ValueError("reconciliation plan integrity check failed") diff --git a/src/netsovereign/providers/contracts.py b/src/netsovereign/providers/contracts.py index c2c548d..16158e4 100644 --- a/src/netsovereign/providers/contracts.py +++ b/src/netsovereign/providers/contracts.py @@ -51,6 +51,18 @@ class ProviderContext(DomainModel): operation_id: str idempotency_key: str dry_run: bool = False + world_id: str = "" + source_plan_digest: str = "" + admission_decision_digest: str = "" + desired_revision: str = "" + authority_id: str | None = None + mandate_id: str | None = None + capability_id: str = "" + capability_version: str = "" + provider_id: str = "" + binding_id: str = "" + expected_outcome_digest: str = "" + fencing_token: int | None = None class ValidationResult(DomainModel): diff --git a/src/netsovereign/providers/fake.py b/src/netsovereign/providers/fake.py index e9a0b26..f69cac0 100644 --- a/src/netsovereign/providers/fake.py +++ b/src/netsovereign/providers/fake.py @@ -29,13 +29,30 @@ def __init__(self, provider_id: str = "fake", *, dry_run: bool = True) -> None: self.observation_mismatches: set[str] = set() def describe(self) -> ProviderDescriptor: + capabilities = [ + "resource.manage", + "declaration.storage", + "registration.submit", + "registry.admit", + "trust.manage", + "number.allocate", + "naming.authoritative.manage", + "route.manage", + "boundary.expose", + "identity.platform.manage", + "identity.inworld.manage", + "mail-domain.manage", + "service-catalogue.manage", + ] return ProviderDescriptor( id=self.provider_id, version="1.0", binding_id=f"{self.provider_id}:memory", available=self.available, healthy=self.healthy, - capabilities=[CapabilityDeclaration(id="resource.manage", dry_run=self.dry_run)], + capabilities=[ + CapabilityDeclaration(id=item, dry_run=self.dry_run) for item in capabilities + ], ) def inject_failure(self, operation_id: str, failure: FailureClass) -> None: @@ -107,7 +124,10 @@ async def compensate(self, operation: Any, context: ProviderContext) -> Provider return ProviderResult( success=False, failure=FailureClass.COMPENSATION, message="injected" ) - self.state.pop(operation.target, None) + if operation.prior_value is None: + self.state.pop(operation.target, None) + else: + self.state[operation.target] = operation.prior_value return ProviderResult(success=True, evidence={"compensated": True}) async def delete(self, operation: Any, context: ProviderContext) -> ProviderResult: diff --git a/src/netsovereign/runtime/__init__.py b/src/netsovereign/runtime/__init__.py index e92283b..1a8cf87 100644 --- a/src/netsovereign/runtime/__init__.py +++ b/src/netsovereign/runtime/__init__.py @@ -5,7 +5,7 @@ """ from .compiler import compile_plan -from .executor import RuntimeExecutor +from .executor import RuntimeExecutor, RuntimeFacts, offline_demo_facts from .models import ( TERMINAL_STATES, TRANSITIONS, @@ -41,8 +41,10 @@ "PlanStatus", "RetryPolicy", "RuntimeExecutor", + "RuntimeFacts", "TERMINAL_STATES", "TRANSITIONS", "Transition", "compile_plan", + "offline_demo_facts", ] diff --git a/src/netsovereign/runtime/compiler.py b/src/netsovereign/runtime/compiler.py index 5928c87..07d9972 100644 --- a/src/netsovereign/runtime/compiler.py +++ b/src/netsovereign/runtime/compiler.py @@ -5,7 +5,7 @@ from typing import Any from ..canonical import digest -from ..planning import ReconciliationPlan +from ..planning import ReconciliationPlan, Reversibility, verify_plan_integrity from ..providers.contracts import CapabilityRequirement from ..providers.registry import ProviderRegistry from .models import ExecutableOperation, ExecutablePlan, FailurePosture, RetryPolicy @@ -36,19 +36,30 @@ def compile_plan( failure_posture: FailurePosture = FailurePosture.STOP, ) -> ExecutablePlan: """Purely bind a copied v0.2 plan; providers are described but never invoked.""" + verify_plan_integrity(plan) if not plan.admitted: raise ValueError("only admitted plans can be compiled") operations: list[ExecutableOperation] = [] for order, step in enumerate(_ordered_steps(plan)): - requirement = CapabilityRequirement(id="resource.manage") + requirement = CapabilityRequirement(id=step.capability.id, version=step.capability.version) requested = (bindings or {}).get(step.id) provider = registry.resolve(requirement, requested) descriptor = provider.describe() + declaration = next(x for x in descriptor.capabilities if x.id == requirement.id) + policy = retry_policy or RetryPolicy() + if policy.maximum_attempts > 1 and not declaration.idempotent: + raise ValueError("non-idempotent provider operation cannot be retried") + if failure_posture == FailurePosture.COMPENSATE_ALL and ( + not declaration.compensation + or step.reversibility + not in {Reversibility.REVERSIBLE, Reversibility.CONDITIONALLY_REVERSIBLE} + ): + raise ValueError("failure posture requires a compensatable operation and provider") expected = step.expected_outcomes[0].value if step.expected_outcomes else None stable = {"plan": plan.plan_digest, "step": step.id, "provider": descriptor.id} operations.append( ExecutableOperation( - id="operation-" + digest(stable)[:16], + id="operation-" + digest(stable)[7:23], source_step_id=step.id, authority_id=step.authority_id, mandate_id=step.mandate_id, @@ -58,15 +69,24 @@ def compile_plan( provider_id=descriptor.id, provider_binding_id=descriptor.binding_id, depends_on=list(step.depends_on), - preconditions=[item.model_dump(mode="json") for item in step.preconditions], + preconditions=list(step.preconditions), expected=expected, idempotency_key=digest({**stable, "expected": expected}), - retry_policy=retry_policy or RetryPolicy(), + retry_policy=policy, failure_posture=failure_posture, dry_run_compatible=next( x for x in descriptor.capabilities if x.id == requirement.id ).dry_run, order=order, + reversibility=step.reversibility, + prior_value=step.prior_value, + expected_outcome_digest=( + step.expected_outcomes[0].value_digest + if step.expected_outcomes + else digest(None) + ), + provider_idempotent=declaration.idempotent, + provider_compensation=declaration.compensation, ) ) core = { @@ -77,11 +97,12 @@ def compile_plan( } fingerprint = digest(core) return ExecutablePlan( - id="execution-plan-" + fingerprint[:16], + id="execution-plan-" + fingerprint[7:23], source_plan_id=plan.plan_digest, world_id=plan.world_id, from_revision=plan.from_revision, desired_revision=plan.to_revision, fingerprint=fingerprint, operations=operations, + admission_decision_digest=plan.source_admission_digest, ) diff --git a/src/netsovereign/runtime/executor.py b/src/netsovereign/runtime/executor.py index 5de576b..debbf21 100644 --- a/src/netsovereign/runtime/executor.py +++ b/src/netsovereign/runtime/executor.py @@ -7,7 +7,11 @@ from datetime import UTC, datetime from typing import Any +from pydantic import Field, model_validator + +from ..base import DomainModel from ..canonical import digest +from ..planning import PredicateKind from ..providers.contracts import FailureClass, ObservationResult, ProviderContext, ProviderResult from ..providers.registry import ProviderRegistry from .models import ( @@ -28,6 +32,119 @@ Delay = Callable[[float], Awaitable[None]] +class RuntimeFacts(DomainModel): + """Injected, immutable view of sovereign facts used by the precondition gate.""" + + accepted_revision: str + accepted_revision_digest: str + proposed_declaration_digest: str + active_desired_revision: str + observations: dict[str, str] = Field(default_factory=dict) + active_mandates: set[str] = Field(default_factory=set) + verified_approvals: dict[str, str] = Field(default_factory=dict) + provider_bindings: dict[str, str] = Field(default_factory=dict) + observation_times: dict[str, datetime] = Field(default_factory=dict) + evaluated_at: datetime + + @model_validator(mode="after") + def timestamps_are_aware(self) -> RuntimeFacts: + if self.evaluated_at.tzinfo is None or any( + value.tzinfo is None for value in self.observation_times.values() + ): + raise ValueError("runtime fact timestamps must be timezone-aware") + return self + + +def offline_demo_facts(plan: ExecutablePlan, evaluated_at: datetime) -> RuntimeFacts: + """Explicitly synthesize facts for the non-operational CLI/demo provider only. + + Production callers must inject repository-backed facts instead. Keeping this + unsafe convenience named and outside ``execute`` prevents accidental runtime + authority fabrication. + """ + + predicates = [ + predicate for operation in plan.operations for predicate in operation.preconditions + ] + return RuntimeFacts( + accepted_revision=plan.from_revision, + accepted_revision_digest=next( + ( + item.digest + for item in predicates + if item.kind == PredicateKind.ACCEPTED_REVISION_EQUALS + ), + "", + ) + or "", + proposed_declaration_digest=next( + ( + item.digest + for item in predicates + if item.kind == PredicateKind.PROPOSED_DIGEST_EQUALS + ), + "", + ) + or "", + active_desired_revision=plan.desired_revision, + observations={ + item.path: item.value_digest + for item in predicates + if item.kind == PredicateKind.OBSERVATION_EQUALS and item.path and item.value_digest + }, + active_mandates={ + operation.mandate_id for operation in plan.operations if operation.mandate_id + }, + verified_approvals={ + item.approval_id: item.digest + for item in predicates + if item.kind == PredicateKind.APPROVAL_PRESENT and item.approval_id and item.digest + }, + provider_bindings={ + operation.source_step_id: operation.provider_binding_id for operation in plan.operations + }, + evaluated_at=evaluated_at, + ) + + +def _failed_precondition(operation: ExecutableOperation, facts: RuntimeFacts) -> str | None: + for predicate in operation.preconditions: + ok = True + if predicate.kind == PredicateKind.ACCEPTED_REVISION_EQUALS: + ok = ( + predicate.revision == facts.accepted_revision + and predicate.digest == facts.accepted_revision_digest + ) + elif predicate.kind == PredicateKind.PROPOSED_DIGEST_EQUALS: + ok = predicate.digest == facts.proposed_declaration_digest + elif predicate.kind == PredicateKind.ACTIVE_DESIRED_REVISION_EQUALS: + ok = predicate.revision == facts.active_desired_revision + elif predicate.kind == PredicateKind.OBSERVATION_EQUALS: + ok = ( + predicate.path is not None + and facts.observations.get(predicate.path) == predicate.value_digest + ) + elif predicate.kind == PredicateKind.MANDATE_ACTIVE: + ok = predicate.mandate_id in facts.active_mandates + elif predicate.kind == PredicateKind.APPROVAL_PRESENT: + ok = facts.verified_approvals.get(predicate.approval_id or "") == predicate.digest + elif predicate.kind == PredicateKind.OBSERVATION_FRESH: + observed_at = facts.observation_times.get(predicate.path or "") + ok = ( + observed_at is not None + and predicate.maximum_age_seconds is not None + and observed_at.tzinfo is not None + and 0 + <= (facts.evaluated_at - observed_at).total_seconds() + <= predicate.maximum_age_seconds + ) + elif predicate.kind == PredicateKind.PROVIDER_BINDING_EQUALS: + ok = facts.provider_bindings.get(operation.source_step_id) == predicate.binding_id + if not ok: + return f"{predicate.kind}:{predicate.model_dump(mode='json')}" + return None + + class RuntimeExecutor: def __init__( self, @@ -52,7 +169,7 @@ def _evidence( stable = {"run": run.id, "operation": operation.id, "kind": kind, "n": len(run.evidence)} run.evidence.append( Evidence( - id="evidence-" + digest(stable)[:16], + id="evidence-" + digest(stable)[7:23], run_id=run.id, operation_id=operation.id, provider_id=operation.provider_id, @@ -68,7 +185,12 @@ def _evidence( ) async def execute( - self, plan: ExecutablePlan, *, dry_run: bool = False, run_id: str | None = None + self, + plan: ExecutablePlan, + *, + dry_run: bool = False, + run_id: str | None = None, + facts: RuntimeFacts | None = None, ) -> ExecutionReport: if ( digest( @@ -92,7 +214,13 @@ async def execute( completed: list[ExecutableOperation] = [] failed = False operations_by_step = {item.source_step_id: item for item in plan.operations} + if facts is None and plan.operations: + raise ValueError( + "runtime facts are required; sovereign preconditions cannot be inferred" + ) + assert facts is not None or not plan.operations for operation in plan.operations: + assert facts is not None record = run.operations[operation.id] if record.state == OperationState.VERIFIED: completed.append(operation) @@ -117,12 +245,37 @@ async def execute( OperationState.SKIPPED, "prior operation failed", self.clock() ) continue + precondition_failure = _failed_precondition(operation, facts) + if precondition_failure: + record.failure = FailureClass.PRECONDITION + record.transition(OperationState.FAILED, precondition_failure, self.clock()) + self._evidence( + run, operation, EvidenceKind.FAILURE, {"predicate": precondition_failure} + ) + failed = True + continue provider = self.registry.resolve(operation.capability, operation.provider_id) + declaration = next( + x for x in provider.describe().capabilities if x.id == operation.capability.id + ) + if dry_run and not declaration.dry_run: + raise ValueError("provider does not support dry run") context = ProviderContext( run_id=run.id, operation_id=operation.id, idempotency_key=operation.idempotency_key, dry_run=dry_run, + world_id=plan.world_id, + source_plan_digest=plan.source_plan_id, + admission_decision_digest=plan.admission_decision_digest, + desired_revision=plan.desired_revision, + authority_id=operation.authority_id, + mandate_id=operation.mandate_id, + capability_id=operation.capability.id, + capability_version=operation.capability.version, + provider_id=operation.provider_id, + binding_id=operation.provider_binding_id, + expected_outcome_digest=operation.expected_outcome_digest, ) # An interrupted apply or observation is resolved by observing before replay. if record.state in {OperationState.RUNNING, OperationState.OBSERVING}: @@ -224,6 +377,7 @@ async def execute( number < operation.retry_policy.maximum_attempts and result.retryable and result.failure in operation.retry_policy.retryable + and operation.provider_idempotent ): record.transition( OperationState.RETRY_WAIT, "retry policy permits retry", self.clock() @@ -263,6 +417,8 @@ async def execute( ) ): for operation in reversed(completed): + if not operation.provider_compensation: + continue record = run.operations[operation.id] if record.simulated: continue diff --git a/src/netsovereign/runtime/models.py b/src/netsovereign/runtime/models.py index 361d808..b87d609 100644 --- a/src/netsovereign/runtime/models.py +++ b/src/netsovereign/runtime/models.py @@ -9,6 +9,7 @@ from pydantic import Field from ..base import DomainModel +from ..planning import PlanPredicate, Reversibility from ..providers.contracts import ( CapabilityRequirement, FailureClass, @@ -105,13 +106,18 @@ class ExecutableOperation(DomainModel): provider_id: str provider_binding_id: str depends_on: list[str] - preconditions: list[dict[str, Any]] + preconditions: list[PlanPredicate] expected: Any idempotency_key: str retry_policy: RetryPolicy failure_posture: FailurePosture dry_run_compatible: bool order: int + reversibility: Reversibility = Reversibility.UNKNOWN + prior_value: Any = None + expected_outcome_digest: str = "" + provider_idempotent: bool = True + provider_compensation: bool = True class ExecutablePlan(DomainModel): @@ -122,6 +128,7 @@ class ExecutablePlan(DomainModel): desired_revision: str fingerprint: str operations: list[ExecutableOperation] + admission_decision_digest: str = "" class Transition(DomainModel): diff --git a/tests/test_chain_integrity.py b/tests/test_chain_integrity.py new file mode 100644 index 0000000..6b89a82 --- /dev/null +++ b/tests/test_chain_integrity.py @@ -0,0 +1,62 @@ +import asyncio +from copy import deepcopy +from datetime import UTC, datetime +from pathlib import Path + +import pytest +import yaml + +from netsovereign.controlplane.models import desired_revision_from_admission +from netsovereign.planning import admit_change, build_plan +from netsovereign.providers.fake import FakeProvider +from netsovereign.providers.registry import ProviderRegistry +from netsovereign.runtime import InMemoryExecutionRepository, RuntimeExecutor, compile_plan +from netsovereign.runtime.executor import RuntimeFacts +from netsovereign.specification import WorldSpec + + +def chain(): + data = yaml.safe_load((Path(__file__).parents[1] / "examples/minimal/world.yaml").read_text()) + proposed_data = deepcopy(data) + proposed_data["world"]["revision"] = "2" + proposed_data["providerBindings"][0]["provider"] = "fake" + current = WorldSpec.model_validate(data) + proposed = WorldSpec.model_validate(proposed_data) + decision = admit_change(current, proposed) + return proposed, decision, build_plan(decision) + + +def test_admission_and_plan_tampering_fail_closed(): + _, decision, plan = chain() + decision.changes[0].explanation = "tampered" + assert not decision.verify_integrity() + plan.steps[0].expected_outcomes[0].value = "tampered" + assert not plan.verify_integrity() + + +def test_pending_admission_cannot_activate_desired_state(): + proposed, decision, _ = chain() + pending = decision.model_copy(update={"admitted": False, "status": "pending_approval"}) + pending.decision_digest = "tampered" + with pytest.raises(ValueError, match="integrity"): + desired_revision_from_admission(pending, proposed, accepted_at=datetime.now(UTC)) + + +def test_stale_revision_precondition_prevents_provider_calls(): + _, _, plan = chain() + provider = FakeProvider() + registry = ProviderRegistry() + registry.register(provider) + executable = compile_plan(plan, registry) + facts = RuntimeFacts( + accepted_revision="stale", + accepted_revision_digest="sha256:stale", + proposed_declaration_digest=plan.proposed_revision_digest, + active_desired_revision=plan.to_revision, + evaluated_at=datetime.now(UTC), + ) + report = asyncio.run( + RuntimeExecutor(registry, InMemoryExecutionRepository()).execute(executable, facts=facts) + ) + assert report.status == "failed" + assert provider.call_history == [] diff --git a/tests/test_planning.py b/tests/test_planning.py index e6f6fde..8ae24ce 100644 --- a/tests/test_planning.py +++ b/tests/test_planning.py @@ -252,13 +252,28 @@ def test_approval_is_a_distinct_admission_state(): assert pending.status == "pending_approval" and not pending.admitted evidence = ApprovalEvidence( approval_id=pending.approval_gates[0], + gate_id=pending.approval_gates[0], + subject_digest=pending.proposed.declaration_digest, + approver="authority:root", approved_at=datetime(2026, 1, 1, tzinfo=UTC), provenance="offline-review", + verification_status="verified", ) admitted = admit_change(current, proposed, approvals=[evidence]) assert admitted.status == "admitted" and admitted.approval_gates == [] +def test_equally_applicable_mandates_fail_closed_instead_of_identifier_tiebreak(): + current, proposed = worlds() + proposed.world.name = "Governed rename" + duplicate = current.mandates[0].model_copy(update={"id": "duplicate-mandate"}, deep=True) + current.mandates.append(duplicate) + proposed.mandates.append(duplicate.model_copy(deep=True)) + decision = admit_change(current, proposed) + assert decision.status == "rejected" + assert any(issue.code == "missing_applicable_mandate" for issue in decision.issues) + + def test_invalid_current_world_cannot_authorise_change(): current, proposed = worlds() current.provider_bindings[0].capability = "missing" diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 7b5eb74..8c682b7 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -6,6 +6,7 @@ import pytest import yaml +from netsovereign.canonical import digest from netsovereign.planning import admit_change, build_plan from netsovereign.providers.contracts import CapabilityRequirement, FailureClass from netsovereign.providers.fake import FakeProvider @@ -18,6 +19,7 @@ RetryPolicy, RuntimeExecutor, compile_plan, + offline_demo_facts, ) from netsovereign.specification import WorldSpec @@ -37,6 +39,7 @@ def admitted_plan(two=False): update={"id": plan.steps[0].id + "-second", "target": "resources/second"} ) plan.steps.append(second) + plan.plan_digest = digest(plan.integrity_payload()) return current, proposed, plan @@ -50,6 +53,10 @@ def run(awaitable): return asyncio.run(awaitable) +def execute(executor, plan, **kwargs): + return executor.execute(plan, facts=offline_demo_facts(plan, datetime.now(UTC)), **kwargs) + + def test_registry_contract_and_resolution_errors(): provider = FakeProvider() registry = setup(provider) @@ -92,7 +99,7 @@ def test_compilation_is_stable_preserves_provenance_and_does_not_mutate(): ) broken = plan.model_copy(deep=True) broken.steps[0].depends_on = [broken.steps[0].id] - with pytest.raises(ValueError, match="dependency_cycle"): + with pytest.raises(ValueError, match="integrity"): compile_plan(broken, setup()) @@ -112,7 +119,7 @@ def test_apply_observe_evidence_and_idempotent_reexecution(): executable = compile_plan(plan, registry) repository = InMemoryExecutionRepository() executor = RuntimeExecutor(registry, repository) - report = run(executor.execute(executable)) + report = run(execute(executor, executable)) assert report.status == "succeeded" operation = report.run.operations[executable.operations[0].id] assert operation.provider_result and operation.observation and operation.state == "verified" @@ -123,7 +130,7 @@ def test_apply_observe_evidence_and_idempotent_reexecution(): "conformance", } count = len([call for call in provider.call_history if call[0] == "apply"]) - resumed = run(executor.execute(executable, run_id=report.run.id)) + resumed = run(execute(executor, executable, run_id=report.run.id)) assert resumed.status == "succeeded" assert len([call for call in provider.call_history if call[0] == "apply"]) == count evidence = report.run.evidence[0] @@ -132,13 +139,23 @@ def test_apply_observe_evidence_and_idempotent_reexecution(): ) +def test_runtime_never_invents_sovereign_facts_from_the_plan(): + _, _, plan = admitted_plan() + registry = setup() + executable = compile_plan(plan, registry) + with pytest.raises(ValueError, match="runtime facts are required"): + run(RuntimeExecutor(registry, InMemoryExecutionRepository()).execute(executable)) + + def test_dry_run_validates_without_mutation_and_reports_prediction(): _, _, plan = admitted_plan() provider = FakeProvider() registry = setup(provider) report = run( - RuntimeExecutor(registry, InMemoryExecutionRepository()).execute( - compile_plan(plan, registry), dry_run=True + execute( + RuntimeExecutor(registry, InMemoryExecutionRepository()), + compile_plan(plan, registry), + dry_run=True, ) ) assert report.status == "succeeded" and provider.state == {} @@ -154,13 +171,16 @@ def test_validation_observation_and_retry_failure_semantics(): op = executable.operations[0] provider.inject_failure(op.id, FailureClass.TRANSIENT) report = run( - RuntimeExecutor( - registry, InMemoryExecutionRepository(), delay=lambda _: asyncio.sleep(0) - ).execute(executable) + execute( + RuntimeExecutor( + registry, InMemoryExecutionRepository(), delay=lambda _: asyncio.sleep(0) + ), + executable, + ) ) assert report.status == "succeeded" and len(report.run.operations[op.id].attempts) == 2 provider.observation_mismatches.add(op.id) - second = run(RuntimeExecutor(registry, InMemoryExecutionRepository()).execute(executable)) + second = run(execute(RuntimeExecutor(registry, InMemoryExecutionRepository()), executable)) assert second.status == "failed" assert second.run.operations[op.id].failure == "observation_mismatch" @@ -172,9 +192,10 @@ def test_partial_failure_compensates_completed_operations_in_reverse_order(): executable = compile_plan(plan, registry, failure_posture=FailurePosture.COMPENSATE_ALL) assert len(executable.operations) >= 2 provider.inject_failure(executable.operations[-1].id, FailureClass.PERMANENT) - report = run(RuntimeExecutor(registry, InMemoryExecutionRepository()).execute(executable)) + report = run(execute(RuntimeExecutor(registry, InMemoryExecutionRepository()), executable)) compensated = [item for item in report.run.operations.values() if item.state == "compensated"] - assert compensated and not provider.state + assert compensated + assert provider.state[executable.operations[0].target] == executable.operations[0].prior_value calls = [op_id for action, op_id in provider.call_history if action == "compensate"] assert calls == [item.id for item in reversed(executable.operations[:-1])] @@ -197,12 +218,12 @@ def test_interruption_after_apply_resumes_by_observation_without_duplicate(): run_id=run_record.id, operation_id=operation.id, idempotency_key=operation.idempotency_key ) run(provider.apply(operation, context)) - report = run(RuntimeExecutor(registry, repository).execute(executable, run_id=run_record.id)) + report = run(execute(RuntimeExecutor(registry, repository), executable, run_id=run_record.id)) assert report.status == "succeeded" assert len([call for call in provider.call_history if call[0] == "apply"]) == 1 changed = executable.model_copy(update={"fingerprint": "changed"}) with pytest.raises(ValueError): - run(RuntimeExecutor(registry, repository).execute(changed, run_id=run_record.id)) + run(execute(RuntimeExecutor(registry, repository), changed, run_id=run_record.id)) def test_dry_run_failure_never_compensates_simulated_operations(): @@ -223,7 +244,7 @@ def test_dry_run_failure_never_compensates_simulated_operations(): } ) report = run( - RuntimeExecutor(registry, InMemoryExecutionRepository()).execute(executable, dry_run=True) + execute(RuntimeExecutor(registry, InMemoryExecutionRepository()), executable, dry_run=True) ) assert report.status == "partially_succeeded" assert not any( @@ -241,6 +262,7 @@ def test_continue_independent_skips_failed_dependants_but_runs_independent_work( } ) plan.steps.append(third) + plan.plan_digest = digest(plan.integrity_payload()) provider = FakeProvider() registry = setup(provider) executable = compile_plan(plan, registry, failure_posture=FailurePosture.CONTINUE_INDEPENDENT) @@ -250,7 +272,7 @@ def test_continue_independent_skips_failed_dependants_but_runs_independent_work( ) dependent = next(item for item in executable.operations if item.source_step_id == third.id) provider.inject_failure(first.id, FailureClass.PERMANENT) - report = run(RuntimeExecutor(registry, InMemoryExecutionRepository()).execute(executable)) + report = run(execute(RuntimeExecutor(registry, InMemoryExecutionRepository()), executable)) assert report.run.operations[first.id].state == "failed" assert report.run.operations[independent.id].state == "verified" assert report.run.operations[dependent.id].state == "skipped" @@ -280,7 +302,7 @@ def test_resume_while_observing_repeats_observation_only(): record.provider_result = result record.transition(OperationState.SUCCEEDED, "applied", datetime.now(UTC)) record.transition(OperationState.OBSERVING, "interrupted", datetime.now(UTC)) - report = run(RuntimeExecutor(registry, repository).execute(executable, run_id=run_record.id)) + report = run(execute(RuntimeExecutor(registry, repository), executable, run_id=run_record.id)) assert report.status == "succeeded" assert len([call for call in provider.call_history if call[0] == "apply"]) == 1 @@ -288,7 +310,8 @@ def test_resume_while_observing_repeats_observation_only(): def test_empty_plan_is_terminally_successful(): _, _, plan = admitted_plan() plan.steps = [] + plan.plan_digest = digest(plan.integrity_payload()) registry = setup() executable = compile_plan(plan, registry) - report = run(RuntimeExecutor(registry, InMemoryExecutionRepository()).execute(executable)) + report = run(execute(RuntimeExecutor(registry, InMemoryExecutionRepository()), executable)) assert report.status == "succeeded" diff --git a/uv.lock b/uv.lock index 0e39b32..b34288b 100644 --- a/uv.lock +++ b/uv.lock @@ -236,7 +236,7 @@ wheels = [ [[package]] name = "netsovereign" -version = "0.4.0" +version = "0.4.1" source = { editable = "." } dependencies = [ { name = "pydantic" },