diff --git a/CHANGELOG.md b/CHANGELOG.md index 589a3a5..bda8601 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). Thi ## [Unreleased] ### Added +- **Consent-governance family — opt-in, explained, revocable, self-sovereign.** `ConsentSurfaceRegistry` (the governed inventory of every telemetry surface a deployment *could* observe: category, sensitivity, `pii`, `projectionMode` reusing the semantic-serdes SEM243 vocabulary, optional residency, a required purpose, and the consent state — default `denied`) and `CapabilityConsentPolicy` (the consent standard for ACTIVE capabilities: camera, microphone, screen capture, computer control, remote compute, file writes, network egress, acting as the user). Both are **classification-and-consent contracts with no capture mechanics** — no collector, no transport, no payload shape, no invocation path — so a surface can be fully registered and permanently unobserved, which is how a sensitive surface is meant to appear here: as a default-deny row that makes the *non*-collection auditable rather than merely absent. The inversion is structural: `deploymentScope` is `const: "self-sovereign"` and `collectorPrincipal` must equal `subjectPrincipal`, so a document describing one party collecting from another is **not expressible**. The deliberate asymmetry — passive telemetry ships `standing-persistent`, active capabilities ship `per-use` (only `microphone` at `standing-session`, since a per-utterance prompt makes continuous listening unusable) — is pinned as a canonical matrix in the gate, not merely in the example. New `validate-consent-plane-examples` target enforces five invariants: **(a)** self-sovereign, collector ≡ subject; **(b)** default-deny (the schema must *declare* the `denied` default, a granted/revoked surface must carry the evidence of that transition so a state cannot be edited into existence, and every per-use capability ships `disabled`); **(c)** the canonical matrix, including that a canonical capability may not be *absent* (absence is not a safe default, it is ungoverned) and that `effectiveMode` may diverge from `defaultStandard` only under an attributed `userOverride`; **(d)** one-shot — a per-use capability may never carry a persistable/standing grant, *not even behind `userOverride`* (the subject may tighten, nothing may loosen), closing the hole through which one "allow" becomes a permanent permission; **(e)** a non-trivial explanation on every governed thing, because consent to something unexplained is not consent. All 16 negative controls verified biting, built from synthetic in-memory documents that never touch `examples/`. Example set grants **benign surfaces only** (`model:tokens_used`, `policy:gate_verdict`); sensitive and personal surfaces appear as off, classification-only rows. See `schemas/consent-plane.README.md`. - Image-trust contracts — the Assay's measured-not-asserted discipline applied to images, for both OS and agents: `ImageTrustReport` (per-dimension trust checks — reproducibility, attestation_verify, sbom_completeness, signature_verify, measured_boot, provenance_depth — each carrying a `Measurement` via `$ref`, plus a render-time `projectedTrust` ok/sad/bad verdict; unifies OS and agent images through `subjectKind`) and `AgentImage` (agent runtime-artifact identity + provenance parallel to `OSImage`, distinct from process-level `AgentPassport`). `ImagePromotionGate` now requires a `trustReportRef` when `decision: approved` (non-breaking if/then), and the new `validate-image-trust-examples` target enforces projection soundness plus the cross-document rule that an approval must reference a report projecting `ok` — closing, for images, the self-assertable-approval gap the Assay hardening closed for verifiers. Reuses `Measurement`'s gate-eligibility invariants for free via `$ref` (a declared/assumed check cannot reach `ok`). All invariants verified biting. See `schemas/image-trust.README.md`. - Assay fleet-tier contracts (cloud-mesh): `AssayRollup` (fleet/cohort aggregate of `ReasoningAssay` verdicts over a window — ok/sad/bad distribution, calibration-drift view of which `AssayStandard` versions are live across the fleet, unassayed-reason breakdown) and `AssayStandardRollout` (canary→widen/halt promotion of a new `AssayStandard` version across cohorts, gated on an observed `AssayRollup`; rides the release-bundle + lifecycle machinery). New `validate-assay-fleet-examples` target enforces aggregation/rollout soundness (distribution sums, drift-flag agreement, rolloutPct vs node share, decision/phase consistency, and no-promotion-by-hope), all verified biting. This is the boundary between prophet-mesh single-user-local (node self-view, no fleet dashboards, structural) and cloud-mesh (rollup + rollout + dashboards). See `schemas/assay-fleet.README.md`. - **Six-layer fingerprint stack (SP-FPRINT-STACK-001)** — classification that can distinguish "I don't know" from "my evidence contradicts itself". `ClassificationStance` over FOUR (POS/NEG/**ZERO**/**INADMISSIBLE**) with the support-vs-plausibility reading tag in the TYPE (DR-5); `FingerprintLayerEvidence` with per-layer discriminated witnesses for L1 OntoDT / L2 OntoDQ / L3 glossary / L4 operational semantics / L5 table topic / L6 key graph; `ColumnFingerprint` (pool -> guard -> quantize, in that order); `ColumnDriftObservation` (L5-D1/L5-D2, blocking); `EstateAdmissibilityReport` (the phase-0 deliverable — which layers this estate can trust, shipped BEFORE any classification); `ClassificationEnforcementPolicy` (**DR-4 settled**: POS/NEG not knobs, INADMISSIBLE never fail-open, ZERO an attested per-resource-class knob unavailable at confidential/restricted). Recompute-don't-trust throughout: stance from evidence, pooling from admissible layers, `n_eff` from the covariance spectrum (participation ratio — Herfindahl demoted to a cheap precheck, since two perfectly correlated layers give H=0.5 while supplying one layer's worth of information), drift from the measured distance. Axiom **X1** (parthood and subtyping disjoint; closure traverses subtyping only; open-world counts are intervals with INADMISSIBLE reported separately) enforced as a CI invariant. `DataClass.classifier.kind` const **retired**: TensorFlow Lattice was archived by its owner in April 2026, so the pin now enumerates the structurally-monotone constructions with the dead name deprecated-but-accepted, and gains a `fingerprintStack` binding. 81 checks + 9 schema negative vectors + **25 gates proven to bite** by mutation harness + an M5 property test verified against a deliberately non-monotone aggregator (the #265 vacuous-constraint defect, guarded against structurally this time). diff --git a/Makefile b/Makefile index a524c37..40ff4a5 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ -.PHONY: validate validate-resource-contract validate-measurement validate-value-type validate-source-locator validate-sourceos-repo-manifest validate-mesh-action-registry validate-control-plane-examples validate-nlboot-examples validate-lattice-data-governai-examples validate-ops-history-examples validate-runtime-observability-examples validate-interpretability-examples validate-lifecycle-boundary-examples validate-svf-contracts validate-sync-cycle-receipts validate-onboarding-examples validate-runtime-causality-examples validate-agentic-os-examples validate-triparty-examples validate-labor-market-examples validate-supply-chain-risk-examples validate-reasoning-examples validate-mpcc-event-examples validate-knowledge-nugget-examples validate-semantic-action-examples validate-epistemic-kernel-examples validate-ab-update-examples validate-device-service-examples validate-duplicate-schema-ids validate-lawful-dispatch-receipt validate-architectural-building-block validate-agent-passport-examples validate-seam-definition-examples validate-agent-system-vocabulary validate-genesis-inception-examples validate-measurement validate-world-model-examples validate-eval-item-examples validate-ingestion-pipeline-examples validate-data-acquisition-examples validate-glossary-alignment-examples validate-data-class-examples validate-table-keys validate-dag-loop validate-vocab-currency-loop vocab-currency-dogfood validate-glossary-promotion validate-agreement validate-stopword-analysis stopword-analysis-live validate-kgram-differential validate-learned-dictionary train-dataclass-classifiers validate-assay-fleet-examples validate-image-trust-examples validate-schema-references validate-fingerprint-stack +.PHONY: validate validate-resource-contract validate-measurement validate-value-type validate-source-locator validate-sourceos-repo-manifest validate-mesh-action-registry validate-control-plane-examples validate-nlboot-examples validate-lattice-data-governai-examples validate-ops-history-examples validate-runtime-observability-examples validate-interpretability-examples validate-lifecycle-boundary-examples validate-svf-contracts validate-sync-cycle-receipts validate-onboarding-examples validate-runtime-causality-examples validate-agentic-os-examples validate-triparty-examples validate-labor-market-examples validate-supply-chain-risk-examples validate-reasoning-examples validate-mpcc-event-examples validate-knowledge-nugget-examples validate-semantic-action-examples validate-epistemic-kernel-examples validate-ab-update-examples validate-device-service-examples validate-duplicate-schema-ids validate-lawful-dispatch-receipt validate-architectural-building-block validate-agent-passport-examples validate-seam-definition-examples validate-agent-system-vocabulary validate-genesis-inception-examples validate-measurement validate-world-model-examples validate-eval-item-examples validate-ingestion-pipeline-examples validate-data-acquisition-examples validate-glossary-alignment-examples validate-data-class-examples validate-table-keys validate-dag-loop validate-vocab-currency-loop vocab-currency-dogfood validate-glossary-promotion validate-agreement validate-stopword-analysis stopword-analysis-live validate-kgram-differential validate-learned-dictionary train-dataclass-classifiers validate-assay-fleet-examples validate-image-trust-examples validate-consent-plane-examples validate-schema-references validate-fingerprint-stack -validate: validate-data-class-examples validate-glossary-alignment-examples validate-data-acquisition-examples validate-ingestion-pipeline-examples validate-control-plane-examples validate-nlboot-examples validate-lattice-data-governai-examples validate-ops-history-examples validate-runtime-observability-examples validate-interpretability-examples validate-lifecycle-boundary-examples validate-svf-contracts validate-sync-cycle-receipts validate-onboarding-examples validate-runtime-causality-examples validate-agentic-os-examples validate-triparty-examples validate-labor-market-examples validate-supply-chain-risk-examples validate-reasoning-examples validate-mpcc-event-examples validate-knowledge-nugget-examples validate-semantic-action-examples validate-epistemic-kernel-examples validate-ab-update-examples validate-device-service-examples validate-duplicate-schema-ids validate-value-type validate-source-locator validate-sourceos-repo-manifest validate-mesh-action-registry validate-lawful-dispatch-receipt validate-architectural-building-block validate-agent-passport-examples validate-seam-definition-examples validate-agent-system-vocabulary validate-genesis-inception-examples validate-measurement validate-world-model-examples validate-eval-item-examples validate-resource-contract validate-table-keys validate-dag-loop validate-vocab-currency-loop validate-glossary-promotion validate-agreement validate-stopword-analysis validate-kgram-differential validate-learned-dictionary validate-trained-classifiers validate-multiverseal-twin-examples validate-assay-fleet-examples validate-image-trust-examples validate-schema-references validate-fingerprint-stack +validate: validate-data-class-examples validate-glossary-alignment-examples validate-data-acquisition-examples validate-ingestion-pipeline-examples validate-control-plane-examples validate-nlboot-examples validate-lattice-data-governai-examples validate-ops-history-examples validate-runtime-observability-examples validate-interpretability-examples validate-lifecycle-boundary-examples validate-svf-contracts validate-sync-cycle-receipts validate-onboarding-examples validate-runtime-causality-examples validate-agentic-os-examples validate-triparty-examples validate-labor-market-examples validate-supply-chain-risk-examples validate-reasoning-examples validate-mpcc-event-examples validate-knowledge-nugget-examples validate-semantic-action-examples validate-epistemic-kernel-examples validate-ab-update-examples validate-device-service-examples validate-duplicate-schema-ids validate-value-type validate-source-locator validate-sourceos-repo-manifest validate-mesh-action-registry validate-lawful-dispatch-receipt validate-architectural-building-block validate-agent-passport-examples validate-seam-definition-examples validate-agent-system-vocabulary validate-genesis-inception-examples validate-measurement validate-world-model-examples validate-eval-item-examples validate-resource-contract validate-table-keys validate-dag-loop validate-vocab-currency-loop validate-glossary-promotion validate-agreement validate-stopword-analysis validate-kgram-differential validate-learned-dictionary validate-trained-classifiers validate-multiverseal-twin-examples validate-assay-fleet-examples validate-image-trust-examples validate-consent-plane-examples validate-schema-references validate-fingerprint-stack @echo "OK: validate" validate-assay-fleet-examples: @@ -11,6 +11,16 @@ validate-image-trust-examples: python3 -m pip install --user jsonschema referencing >/dev/null python3 tools/validate_image_trust_examples.py +# Consent-governance family: ConsentSurfaceRegistry + CapabilityConsentPolicy. Schema +# conformance plus the five invariants that make an opt-in claim mean something — +# self-sovereign (collector == subject), default-deny, the canonical capability default +# matrix, per-use one-shot, and a non-trivial explanation on every governed thing. Each +# invariant is pinned by a synthetic negative control that runs every time; if a control +# fails to trip the gate certifies nothing. +validate-consent-plane-examples: + python3 -m pip install --user jsonschema >/dev/null + python3 tools/validate_consent_plane_examples.py + # General referential-integrity gate: every schema is a valid JSON Schema and every # $ref resolves. The per-example validators only exercise schemas that ship examples; # this catches a dangling/renamed/typo'd $ref in ANY of the 300+ schemas — a diff --git a/examples/capability_consent_policy.json b/examples/capability_consent_policy.json new file mode 100644 index 0000000..3ecced1 --- /dev/null +++ b/examples/capability_consent_policy.json @@ -0,0 +1,94 @@ +{ + "id": "urn:srcos:consent-policy:workstation-default", + "type": "CapabilityConsentPolicy", + "specVersion": "2.0.0", + "deploymentScope": "self-sovereign", + "subjectPrincipal": "urn:srcos:principal:local-operator", + "capabilities": [ + { + "capabilityId": "camera", + "riskClass": "sensor-capture", + "defaultStandard": "per-use", + "effectiveMode": "per-use", + "userOverride": false, + "defaultState": "disabled", + "explanation": "Takes a picture through your camera. Asks every single time, and the answer is never remembered.", + "residency": { "ephemeral": true, "onDeviceOnly": true }, + "oneShot": true + }, + { + "capabilityId": "microphone", + "riskClass": "sensor-capture", + "defaultStandard": "standing-session", + "effectiveMode": "off", + "userOverride": true, + "defaultState": "disabled", + "explanation": "Listens through your microphone for as long as the session lasts, then stops.", + "residency": { "ephemeral": true, "onDeviceOnly": true }, + "oneShot": false + }, + { + "capabilityId": "screen_capture", + "riskClass": "sensor-capture", + "defaultStandard": "per-use", + "effectiveMode": "per-use", + "userOverride": false, + "defaultState": "disabled", + "explanation": "Takes a picture of what is on your screen right now, including anything else open.", + "residency": { "ephemeral": true, "onDeviceOnly": true }, + "oneShot": true + }, + { + "capabilityId": "control_my_computer", + "riskClass": "device-control", + "defaultStandard": "per-use", + "effectiveMode": "per-use", + "userOverride": false, + "defaultState": "disabled", + "explanation": "Moves your pointer and types on your behalf, as if you were doing it yourself.", + "oneShot": true + }, + { + "capabilityId": "skycomputer", + "riskClass": "remote-compute", + "defaultStandard": "per-use", + "effectiveMode": "per-use", + "userOverride": false, + "defaultState": "disabled", + "explanation": "Sends this piece of work to a remote machine to run, so it leaves your device.", + "residency": { "ephemeral": true, "onDeviceOnly": false }, + "oneShot": true + }, + { + "capabilityId": "file_write", + "riskClass": "data-write", + "defaultStandard": "per-use", + "effectiveMode": "per-use", + "userOverride": false, + "defaultState": "disabled", + "explanation": "Creates or changes files on your disk. Asks before each write, naming the path.", + "residency": { "ephemeral": false, "onDeviceOnly": true }, + "oneShot": true + }, + { + "capabilityId": "network_egress", + "riskClass": "data-egress", + "defaultStandard": "per-use", + "effectiveMode": "per-use", + "userOverride": false, + "defaultState": "disabled", + "explanation": "Sends data out to the network, naming the destination before anything leaves.", + "oneShot": true + }, + { + "capabilityId": "send_on_behalf", + "riskClass": "act-as-user", + "defaultStandard": "per-use", + "effectiveMode": "per-use", + "userOverride": false, + "defaultState": "disabled", + "explanation": "Sends a message that will appear to come from you, shown to you in full beforehand.", + "oneShot": true + } + ] +} diff --git a/examples/consent_surface_registry.json b/examples/consent_surface_registry.json new file mode 100644 index 0000000..2bc67d7 --- /dev/null +++ b/examples/consent_surface_registry.json @@ -0,0 +1,81 @@ +{ + "id": "urn:srcos:consent-registry:workstation-default", + "type": "ConsentSurfaceRegistry", + "specVersion": "2.0.0", + "deploymentScope": "self-sovereign", + "subjectPrincipal": "urn:srcos:principal:local-operator", + "collectorPrincipal": "urn:srcos:principal:local-operator", + "surfaces": [ + { + "surfaceId": "telemetry:model:tokens_used", + "category": "model-usage", + "sensitivity": "benign", + "pii": false, + "defaultStandard": "standing-persistent", + "effectiveMode": "standing-persistent", + "userOverride": false, + "explanation": "Counts how many tokens each run used, so you can see what your own work costs.", + "projectionMode": "LOSSLESS", + "residency": { "ephemeral": false, "onDeviceOnly": true }, + "purpose": "local-cost-and-capacity-visibility", + "consent": { + "state": "granted", + "grantedAt": "2026-07-20T09:12:00Z", + "grantRef": "urn:srcos:consent-grant:workstation-tokens-used-2026-07-20" + } + }, + { + "surfaceId": "telemetry:policy:gate_verdict", + "category": "governance-outcome", + "sensitivity": "benign", + "pii": false, + "defaultStandard": "standing-persistent", + "effectiveMode": "standing-session", + "userOverride": true, + "explanation": "Records whether each governance gate passed or failed, so you can see which rules actually fired.", + "projectionMode": "LOSSLESS", + "residency": { "ephemeral": false, "onDeviceOnly": true }, + "purpose": "gate-effectiveness-review", + "consent": { + "state": "granted", + "grantedAt": "2026-07-20T09:12:00Z", + "grantRef": "urn:srcos:consent-grant:workstation-gate-verdict-2026-07-20" + } + }, + { + "surfaceId": "telemetry:device:hardware_id", + "category": "hardware-identity", + "sensitivity": "sensitive", + "pii": true, + "defaultStandard": "standing-persistent", + "effectiveMode": "off", + "userOverride": false, + "explanation": "Would tie every run to this specific machine's hardware identity. Off, and nothing reads it.", + "projectionMode": "OPAQUE_HANDLE_ONLY", + "residency": { "ephemeral": true, "onDeviceOnly": true }, + "purpose": "device-binding-for-fleet-attribution", + "consent": { + "state": "denied" + } + }, + { + "surfaceId": "telemetry:app:session_start", + "category": "app-activity", + "sensitivity": "personal", + "pii": false, + "defaultStandard": "standing-persistent", + "effectiveMode": "off", + "userOverride": false, + "explanation": "Notes the time each working session begins, which reveals when you are at the machine.", + "projectionMode": "LOSSY", + "residency": { "ephemeral": true, "onDeviceOnly": true }, + "purpose": "session-continuity-diagnostics", + "consent": { + "state": "revoked", + "grantedAt": "2026-07-20T09:12:00Z", + "revokedAt": "2026-07-28T17:40:00Z", + "grantRef": "urn:srcos:consent-grant:workstation-session-start-2026-07-20" + } + } + ] +} diff --git a/registry/contract-registry.json b/registry/contract-registry.json index 9653392..777863a 100644 --- a/registry/contract-registry.json +++ b/registry/contract-registry.json @@ -191,6 +191,11 @@ "path": "schemas/BuildValidationEvidenceBundle.json", "sha256": "sha256:117e4d8974d7dbb3eae939de0a39d71f48d61baff462293ffe75622b92261dd7" }, + "CapabilityConsentPolicy": { + "$id": "https://schemas.srcos.ai/v2/CapabilityConsentPolicy.json", + "path": "schemas/CapabilityConsentPolicy.json", + "sha256": "sha256:35a6fce322a3b8466ea6265f30de0e5825c6b3336e14b24f62aaa66dd0eb1a61" + }, "CapabilityContract": { "$id": "https://schemas.srcos.ai/v2/CapabilityContract.json", "path": "schemas/CapabilityContract.json", @@ -311,6 +316,11 @@ "path": "schemas/ConnectorActionScope.json", "sha256": "sha256:8683bc21b0d8377561e776aa91c5d43155a52e8cf8d58798c253d5f49ec28ca2" }, + "ConsentSurfaceRegistry": { + "$id": "https://schemas.srcos.ai/v2/ConsentSurfaceRegistry.json", + "path": "schemas/ConsentSurfaceRegistry.json", + "sha256": "sha256:5e14a351b934b5c3bb1ca566b852ebe3114197f172b9c542fb9e70b51af28594" + }, "ContentRef": { "$id": "https://schemas.srcos.ai/v2/ContentRef.json", "path": "schemas/ContentRef.json", @@ -1827,5 +1837,5 @@ "sha256": "sha256:eadc32085987ffda647b090e3eba3726edd102a66d4736e239457bb74867004b" } }, - "count": 365 + "count": 367 } diff --git a/schemas/CapabilityConsentPolicy.json b/schemas/CapabilityConsentPolicy.json new file mode 100644 index 0000000..67c4500 --- /dev/null +++ b/schemas/CapabilityConsentPolicy.json @@ -0,0 +1,88 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.srcos.ai/v2/CapabilityConsentPolicy.json", + "title": "CapabilityConsentPolicy", + "description": "The consent policy governing ACTIVE capabilities — camera, microphone, screen capture, computer control, remote compute, file writes, network egress, acting as the user. Where ConsentSurfaceRegistry governs what may be observed about the subject, this governs what may be DONE on the subject's behalf, and it holds those to a deliberately stricter standard: the shipped default for an active capability is per-use, not standing. A per-use capability is one-shot by construction — the grant authorises a single use and is not persistable — so the ordinary failure mode of capability consent, where a one-time 'allow' silently becomes a standing permission, is not expressible. This is a POLICY contract: it declares the consent standard, the default state, and the sentence the subject is shown. It contains no capability implementation, no device access, and no invocation path — a capability is named and governed here whether or not anything in the deployment can perform it. Like the registry, it is deployment-scoped to 'self-sovereign': the subject is the principal granting and the principal protected.", + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "specVersion", "deploymentScope", "subjectPrincipal", "capabilities"], + "properties": { + "id": { + "type": "string", + "pattern": "^urn:srcos:consent-policy:", + "description": "Stable URN. Pattern: urn:srcos:consent-policy:." + }, + "type": { "const": "CapabilityConsentPolicy" }, + "specVersion": { "type": "string" }, + "deploymentScope": { + "const": "self-sovereign", + "description": "Fixed. The subject grants their own capabilities; there is no shape of this document in which a third party holds the grant." + }, + "subjectPrincipal": { + "type": "string", + "description": "The principal whose device and authority these capabilities act upon — the only principal who can grant them." + }, + "capabilities": { + "type": "array", + "minItems": 1, + "description": "The governed capabilities. The shipped canonical set and its defaults are pinned by tools/validate_consent_plane_examples.py, so a default cannot be weakened in a file edit without failing the gate.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["capabilityId", "riskClass", "defaultStandard", "effectiveMode", "userOverride", "defaultState", "explanation"], + "properties": { + "capabilityId": { + "type": "string", + "pattern": "^[a-z][a-z0-9_]*$", + "description": "Canonical capability token (e.g. camera, screen_capture, control_my_computer, skycomputer, file_write, network_egress, send_on_behalf, microphone)." + }, + "riskClass": { + "enum": ["sensor-capture", "device-control", "remote-compute", "data-write", "data-egress", "act-as-user"], + "description": "What KIND of risk the capability carries, not how much — a magnitude ('high') compresses away the thing a subject actually needs to decide about. sensor-capture = reads the physical or on-screen environment. device-control = drives the host. remote-compute = sends work off-device. data-write = mutates the subject's data. data-egress = moves data off the device. act-as-user = takes an action attributable to the subject." + }, + "defaultStandard": { + "enum": ["off", "per-use", "standing-session", "standing-persistent"], + "description": "The shipped consent standard for this capability. For the canonical set this is per-use everywhere except microphone (standing-session, because a per-utterance prompt would make continuous listening unusable and the honest mitigation is a session bound, not a prompt). The validator pins each value against the canonical matrix." + }, + "effectiveMode": { + "enum": ["off", "per-use", "standing-session", "standing-persistent"], + "description": "What is actually in force. May differ from defaultStandard ONLY when userOverride is true — and a per-use capability may never reach a standing mode even then, because one-shot forbids the persistable grant such a mode would require. The subject may always tighten; nothing may loosen a per-use capability into a standing one." + }, + "userOverride": { + "type": "boolean", + "description": "True when effectiveMode reflects the subject's own decision rather than the shipped standard. The attributability flag: a divergence that is not marked as the subject's is treated as a silently changed default and rejected." + }, + "defaultState": { + "enum": ["enabled", "disabled"], + "description": "The state the capability ships in before the subject has decided anything. Default-deny: the validator requires 'disabled' for every per-use capability, so an unanswered active capability is never live." + }, + "explanation": { + "type": "string", + "minLength": 12, + "description": "REQUIRED. The plain sentence shown to the subject BEFORE this capability may be enabled — what it would do, in ordinary language. Structural, not cosmetic: an unexplained capability cannot be registered. The validator additionally rejects trivial or placeholder text." + }, + "residency": { + "type": "object", + "additionalProperties": false, + "description": "Optional residency constraints on anything the capability produces or touches.", + "properties": { + "ephemeral": { + "type": "boolean", + "description": "True when nothing is retained past the moment of use." + }, + "onDeviceOnly": { + "type": "boolean", + "description": "True when nothing may leave the subject's own device." + } + } + }, + "oneShot": { + "type": "boolean", + "default": false, + "description": "True when a grant authorises exactly one use and is NOT persistable — it cannot be stored, re-presented, or carried into a later invocation. Required true for every per-use capability (enforced): per-use without one-shot is the exact hole through which a single 'allow' becomes a standing permission, so the two are bound together rather than left as independent settings that can drift apart." + } + } + } + } + } +} diff --git a/schemas/ConsentSurfaceRegistry.json b/schemas/ConsentSurfaceRegistry.json new file mode 100644 index 0000000..5cb4919 --- /dev/null +++ b/schemas/ConsentSurfaceRegistry.json @@ -0,0 +1,128 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.srcos.ai/v2/ConsentSurfaceRegistry.json", + "title": "ConsentSurfaceRegistry", + "description": "The governed inventory of every telemetry surface a self-sovereign deployment could observe, and the consent standing on each. This is a CLASSIFICATION AND CONSENT contract, not a capture contract: it records what a surface WOULD be, how sensitive it is, what the subject was told before enabling it, and whether consent currently stands — it defines no collection mechanism, no transport, and no payload shape. A surface may therefore be registered (named, classified, explained) while remaining permanently unobserved, which is precisely how a sensitive surface is meant to appear here: as a default-deny row that makes the non-collection auditable rather than merely absent. The inversion the registry encodes is deployment-scoped by construction — deploymentScope is fixed to 'self-sovereign' and collectorPrincipal must equal subjectPrincipal, so the observed party and the observing party are the same principal and there is no shape in which this document describes one party collecting from another. Consent defaults to 'denied': an unanswered surface is not a permitted one. defaultStandard records the shipped standard for telemetry surfaces (standing-persistent) while effectiveMode records what is actually in force, so a divergence between the two is visible as a fact rather than hidden in a runtime flag — and, for a granted surface, it is only legitimate when userOverride marks it as the subject's own decision.", + "type": "object", + "additionalProperties": false, + "required": ["id", "type", "specVersion", "deploymentScope", "subjectPrincipal", "collectorPrincipal", "surfaces"], + "properties": { + "id": { + "type": "string", + "pattern": "^urn:srcos:consent-registry:", + "description": "Stable URN. Pattern: urn:srcos:consent-registry:." + }, + "type": { "const": "ConsentSurfaceRegistry" }, + "specVersion": { "type": "string" }, + "deploymentScope": { + "const": "self-sovereign", + "description": "Fixed. This contract has exactly one lawful deployment shape: the subject governs their own observation. There is no multi-tenant or vendor-collection variant of this document — a registry describing a third party observing a subject is not expressible here." + }, + "subjectPrincipal": { + "type": "string", + "description": "The principal whose activity the registered surfaces describe — the person or node being observed." + }, + "collectorPrincipal": { + "type": "string", + "description": "The principal that would hold anything observed. MUST equal subjectPrincipal (enforced by tools/validate_consent_plane_examples.py): self-sovereign means the observer and the observed are the same principal. This field exists so the identity is stated and checkable rather than assumed." + }, + "surfaces": { + "type": "array", + "minItems": 1, + "description": "The registered surfaces. Registration is classification, not permission: a row here means the surface is NAMED and GOVERNED, and its consent block says whether anything may stand on it.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["surfaceId", "category", "sensitivity", "pii", "defaultStandard", "effectiveMode", "userOverride", "explanation", "projectionMode", "purpose", "consent"], + "properties": { + "surfaceId": { + "type": "string", + "pattern": "^telemetry:(model|policy|app|device):[a-z_]+$", + "description": "Canonical surface identifier, namespaced by plane. model = the reasoning/inference layer; policy = gate and governance outcomes; app = product surfaces; device = the host itself (the most sensitive plane, and the one most often defaulted off)." + }, + "category": { + "type": "string", + "minLength": 1, + "description": "Short grouping label used to present related surfaces together in the consent UI (e.g. 'model-usage', 'governance-outcome', 'hardware-identity'). Presentation grouping only — sensitivity, not category, carries the governance weight." + }, + "sensitivity": { + "enum": ["benign", "personal", "sensitive"], + "description": "benign = carries no information about the person (counters, verdicts). personal = attributable to the subject's activity. sensitive = identity-bearing or otherwise high-consequence. Sensitivity does not by itself permit or forbid anything; it sets the standard of explanation and is the signal a review reads first." + }, + "pii": { + "type": "boolean", + "description": "Whether the surface would carry personally identifying information. Declared independently of sensitivity so the two can disagree visibly — a 'personal' surface with pii:false is a real and useful combination, and a pii:true row demands a projectionMode that does not carry the identifier in the clear." + }, + "defaultStandard": { + "const": "standing-persistent", + "description": "The shipped consent standard for TELEMETRY surfaces: once opted into, a telemetry grant stands until revoked. Fixed here so the standard cannot be quietly restated per-surface; what actually applies is effectiveMode, and any divergence must be attributable (see userOverride). Note the deliberate asymmetry with CapabilityConsentPolicy, whose active capabilities default to per-use: passive classification-level telemetry and an active sensor are not governed to the same standard." + }, + "effectiveMode": { + "enum": ["off", "per-use", "standing-session", "standing-persistent"], + "description": "What is actually in force for this surface now. For a GRANTED surface this may differ from defaultStandard only when userOverride is true — the subject's own decision is the only legitimate source of divergence. When consent is denied or revoked the mode is 'off' by consequence of consent, not by override." + }, + "userOverride": { + "type": "boolean", + "description": "True when effectiveMode reflects a decision the subject made, rather than the shipped standard. This is what makes a divergence attributable: without it, a mode that quietly differs from the standard is indistinguishable from a silently changed default." + }, + "explanation": { + "type": "string", + "minLength": 12, + "description": "REQUIRED. The plain sentence shown to the subject BEFORE enabling this surface — what would be observed, in ordinary language. Consent to something unexplained is not consent, so this contract makes the sentence a structural precondition of registering the surface at all rather than a UI concern. The validator additionally rejects trivial or placeholder text." + }, + "projectionMode": { + "enum": ["LOSSLESS", "LOSSY", "OPAQUE_HANDLE_ONLY"], + "description": "How the surface would be projected if it were ever observed. LOSSLESS = as-is. LOSSY = reduced/aggregated, the original not reconstructable. OPAQUE_HANDLE_ONLY = only a non-reversible handle, never the underlying value — the standing choice for an identity-bearing surface. Aligned with the semantic-serdes SEM243 projection vocabulary so the consent board and the codec speak the same word." + }, + "residency": { + "type": "object", + "additionalProperties": false, + "description": "Optional residency constraints on anything observed. Absent means unconstrained by this document, which for a pii:true surface is itself a review finding.", + "properties": { + "ephemeral": { + "type": "boolean", + "description": "True when nothing is retained past the moment of use." + }, + "onDeviceOnly": { + "type": "boolean", + "description": "True when nothing may leave the subject's own device." + } + } + }, + "purpose": { + "type": "string", + "minLength": 1, + "description": "The single purpose this surface is registered for. Required: a purpose-free consent is an open-ended one, and open-ended consent is what this family exists to make unrepresentable. A second purpose is a second registration, re-explained and re-consented." + }, + "consent": { + "type": "object", + "additionalProperties": false, + "required": ["state"], + "description": "The consent standing on this surface. Default-deny: absence of an answer is a denial, never a permission.", + "properties": { + "state": { + "enum": ["granted", "denied", "revoked"], + "default": "denied", + "description": "granted = the subject affirmatively enabled this surface. denied = never enabled (the default, including for a surface never presented). revoked = previously granted and since withdrawn — retained as a distinct state so a withdrawal remains legible instead of decaying back into an indistinguishable 'denied'." + }, + "grantedAt": { + "type": "string", + "format": "date-time", + "description": "When the grant was given. Required by the validator whenever state is 'granted' — a grant with no moment is an assumed one." + }, + "revokedAt": { + "type": "string", + "format": "date-time", + "description": "When the grant was withdrawn. Required by the validator whenever state is 'revoked'." + }, + "grantRef": { + "type": "string", + "description": "URN of the grant record evidencing this consent. Required by the validator for 'granted' and 'revoked': consent must point at something that happened, so that a state cannot be edited into existence in this file alone." + } + } + } + } + } + } + } +} diff --git a/schemas/consent-plane.README.md b/schemas/consent-plane.README.md new file mode 100644 index 0000000..0e540d9 --- /dev/null +++ b/schemas/consent-plane.README.md @@ -0,0 +1,91 @@ +# Consent plane — opt-in, explained, revocable, self-sovereign + +Two contracts that record **how consent gates observation and action** in a self-sovereign +deployment. They are governance documents: they classify surfaces, pin defaults, and hold the +sentence a person is shown before anything is enabled. **They contain no capture code** — no +collector, no transport, no payload shape, no invocation path. A surface can be fully registered +here and permanently unobserved, which is exactly how a sensitive surface is meant to appear: as a +default-deny row that makes the *non*-collection auditable instead of merely absent. + +The inversion is structural, not aspirational. `deploymentScope` is `const: "self-sovereign"` and +`collectorPrincipal` must equal `subjectPrincipal`, so **a document describing one party collecting +from another is not expressible in this family.** + +## Schemas + +| Schema | URN prefix | Governs | +|---|---|---| +| `ConsentSurfaceRegistry.json` | `urn:srcos:consent-registry:` | Passive telemetry surfaces — what *may be observed*. Classification + consent state only. | +| `CapabilityConsentPolicy.json` | `urn:srcos:consent-policy:` | Active capabilities — what *may be done* on the subject's behalf. | + +### The deliberate asymmetry + +Telemetry surfaces ship at `standing-persistent`; active capabilities ship at `per-use`. That gap is +the design, not an inconsistency. A passive counter you opted into once should not re-interrupt you +forever, but a camera that asks once and remembers the answer has stopped being consent. The one +exception is `microphone` (`standing-session`): a per-utterance prompt makes continuous listening +unusable, so the honest mitigation is a session bound rather than a prompt nobody reads. + +### Canonical shipped defaults (pinned in the gate) + +| Capability | riskClass | defaultStandard | defaultState | oneShot | +|---|---|---|---|---| +| `camera` | sensor-capture | `per-use` | disabled | ✅ | +| `screen_capture` | sensor-capture | `per-use` | disabled | ✅ | +| `microphone` | sensor-capture | `standing-session` | disabled | — | +| `control_my_computer` | device-control | `per-use` | disabled | ✅ | +| `skycomputer` | remote-compute | `per-use` | disabled | ✅ | +| `file_write` | data-write | `per-use` | disabled | ✅ | +| `network_egress` | data-egress | `per-use` | disabled | ✅ | +| `send_on_behalf` | act-as-user | `per-use` | disabled | ✅ | + +The matrix lives in `tools/validate_consent_plane_examples.py`, not only in the example. An example +is a file anyone can edit; a gate is a thing you have to argue with. + +## The five invariants (CI-enforced, each proven to bite) + +`make validate-consent-plane-examples` runs schema conformance plus: + +- **(a) self-sovereign** — `collectorPrincipal == subjectPrincipal`. +- **(b) default-deny** — the registry schema must *declare* `consent.state` default `denied`; a + `granted` surface must carry `grantedAt` + `grantRef` and a `revoked` one `revokedAt` + `grantRef` + (a state cannot be edited into existence — it must point at something that happened); and every + per-use capability must ship `defaultState: disabled`. +- **(c) canonical defaults** — every capability in the matrix must be **present** (absence from the + policy is not a safe default, it is *ungoverned*) with its pinned `defaultStandard`; and + `effectiveMode` may diverge from `defaultStandard` only when `userOverride` is true. The same + attributability rule is applied to a **granted** surface; a denied or revoked surface is `off` by + consequence of consent, not by override, and is exempt. +- **(d) one-shot** — a per-use capability must be `oneShot: true` and may never sit in a standing + mode, *not even behind `userOverride`*. The subject may always tighten; nothing may loosen a + per-use capability into a standing one. Per-use without one-shot is the exact hole through which + one "allow" becomes a permanent permission. +- **(e) explanation** — every surface and capability carries a non-trivial plain sentence. Consent + to something unexplained is not consent, so the sentence is a structural precondition of + registering the thing at all. Length alone does not satisfy it: placeholder text and + single-word-repeated filler are rejected. + +Each invariant is pinned by a **synthetic negative control that runs on every invocation** (16 of +them). If any control fails to trip, the validator exits non-zero and certifies nothing — the +`tools/validate_schema_references.py` discipline. The controls are built from in-memory documents +only and never read or mutate `examples/`, so the proof cannot pollute the thing being proven. + +## Examples + +| Example | Shows | +|---|---| +| `examples/consent_surface_registry.json` | Two **benign** granted surfaces (`model:tokens_used`, `policy:gate_verdict` — the latter demonstrating an attributed `userOverride` divergence), one **sensitive** surface (`device:hardware_id`) present purely as a default-deny classification row with `OPAQUE_HANDLE_ONLY` projection and nothing reading it, and one **revoked** surface (`app:session_start`) showing that a withdrawal stays legible rather than decaying back into an indistinguishable "denied". | +| `examples/capability_consent_policy.json` | The full canonical set at shipped defaults, all `disabled`, with `microphone` tightened to `off` under an attributed `userOverride` — the only direction an override is allowed to move. | + +Only benign surfaces are granted anywhere in the example set. The sensitive and personal rows exist +to demonstrate the classification, and both are off. + +## What this family deliberately does not do + +- It defines **no capture mechanism**. Nothing here says how a value would be read, encoded, or + moved; `projectionMode` describes what a projection *would be permitted to be*, not a codec. +- It grants nothing at runtime. `grantRef` points at a grant record held elsewhere + (`mcp-a2a-zero-trust` owns `Grant`/`AttestationBundle` — this family conforms to that authority + rather than re-inventing it) so that a consent state in this file cannot be self-issued. +- `projectionMode` reuses the semantic-serdes SEM243 vocabulary (`LOSSLESS` / `LOSSY` / + `OPAQUE_HANDLE_ONLY`) so the consent board and the codec speak the same word. diff --git a/tools/validate_consent_plane_examples.py b/tools/validate_consent_plane_examples.py new file mode 100644 index 0000000..fc8d935 --- /dev/null +++ b/tools/validate_consent_plane_examples.py @@ -0,0 +1,453 @@ +#!/usr/bin/env python3 +"""Validate the consent-governance family: ConsentSurfaceRegistry + CapabilityConsentPolicy. + +These two contracts record how consent GATES observation and action. They describe no +capture mechanism — no collector, no transport, no payload — so the only thing that can +make them mean anything is a gate that refuses to certify a weakened default. This is +that gate. + +Five invariants, each proven to bite on every run by a synthetic negative control: + + (a) self-sovereign — collectorPrincipal MUST equal subjectPrincipal. The observed + and the observing are one principal, or this is not the + contract it claims to be. + (b) default-deny — the registry schema must DECLARE consent.state default + 'denied'; a 'granted'/'revoked' surface must carry the + evidence of that transition (grantedAt/revokedAt + grantRef), + so a state cannot be edited into existence; and every per-use + capability must ship defaultState 'disabled'. + (c) canonical defaults — every capability in the canonical matrix must be present + (absence from the policy IS ungoverned, the state this family + exists to eliminate) with its pinned defaultStandard; and + effectiveMode may diverge from defaultStandard only when + userOverride marks it as the subject's own decision. The same + attributability rule applies to a GRANTED surface — a denied or + revoked surface is 'off' by consequence of consent, not by + override, and is exempt. + (d) one-shot — a per-use capability must be oneShot:true and may never sit in + a standing mode, not even behind userOverride. Per-use without + one-shot is the exact hole through which a single 'allow' + becomes a standing permission. + (e) explanation — every surface and capability carries a non-trivial plain + sentence: consent to something unexplained is not consent. + Length alone is not enough, so placeholder and + single-word-repeated text is rejected too. + +Self-exclusion discipline (see tools/validate_schema_references.py): the negative control +is built from SYNTHETIC in-memory documents only. It never reads, writes, or mutates +anything in examples/ or schemas/, so the proof can never pollute the thing being proven. +If any control fails to trip, the validator exits non-zero and certifies nothing. + + validate_consent_plane_examples.py # full run (default) + validate_consent_plane_examples.py --self-test # negative control only +""" +from __future__ import annotations + +import argparse +import copy +import json +import sys +from pathlib import Path + +import jsonschema + +ROOT = Path(__file__).resolve().parents[1] + +PAIRS = [ + ("ConsentSurfaceRegistry.json", "consent_surface_registry.json"), + ("CapabilityConsentPolicy.json", "capability_consent_policy.json"), +] + +# The canonical shipped consent standard per capability. Pinned HERE, in the gate, rather +# than only in the example: an example is a file anyone can edit, a gate is a thing that +# has to be argued with. Active capabilities are per-use; microphone is the single +# deliberate exception (a per-utterance prompt makes continuous listening unusable, so the +# honest mitigation is a session bound, not a prompt). +CANONICAL_CAPABILITY_DEFAULTS = { + "camera": "per-use", + "screen_capture": "per-use", + "control_my_computer": "per-use", + "skycomputer": "per-use", + "file_write": "per-use", + "network_egress": "per-use", + "send_on_behalf": "per-use", + "microphone": "standing-session", +} + +STANDING_MODES = {"standing-session", "standing-persistent"} + +MIN_EXPLANATION_CHARS = 12 +MIN_EXPLANATION_WORDS = 3 +PLACEHOLDER_EXPLANATIONS = { + "todo", "tbd", "n/a", "na", "none", "null", "-", "--", "xxx", "placeholder", + "fixme", "?", "see docs", "as above", +} + + +# ── pure checks (no I/O; these are exactly what the negative control pins) ──────────── +def check_a_self_sovereign(registry: dict) -> list[str]: + """(a) The observer and the observed must be the same principal.""" + subject = registry.get("subjectPrincipal") + collector = registry.get("collectorPrincipal") + if collector != subject: + return [ + f"(a) not self-sovereign: collectorPrincipal={collector!r} != " + f"subjectPrincipal={subject!r} — a third party holding the observation is " + f"not expressible in this contract" + ] + return [] + + +def consent_state_default(registry_schema: dict) -> str | None: + """Read the DECLARED default of surfaces[].consent.state out of the schema.""" + try: + node = registry_schema["properties"]["surfaces"]["items"]["properties"] + return node["consent"]["properties"]["state"].get("default") + except (KeyError, TypeError): + return None + + +def check_b_default_deny(registry: dict, policy: dict, registry_schema: dict) -> list[str]: + """(b) Denial is the resting state, and a departure from it must be evidenced.""" + out: list[str] = [] + + declared = consent_state_default(registry_schema) + if declared != "denied": + out.append( + f"(b) ConsentSurfaceRegistry declares consent.state default {declared!r}, " + f"not 'denied' — an unanswered surface would be a permitted one" + ) + + for surface in registry.get("surfaces", []): + sid = surface.get("surfaceId") + consent = surface.get("consent") or {} + state = consent.get("state", "denied") + if state == "granted": + for field in ("grantedAt", "grantRef"): + if not consent.get(field): + out.append( + f"(b) surface {sid!r} is 'granted' with no {field} — a grant with " + f"no evidence is an assumed one, which default-deny forbids" + ) + elif state == "revoked": + for field in ("revokedAt", "grantRef"): + if not consent.get(field): + out.append( + f"(b) surface {sid!r} is 'revoked' with no {field} — a withdrawal " + f"must point at the grant it withdrew" + ) + + for cap in policy.get("capabilities", []): + cid = cap.get("capabilityId") + if cap.get("defaultStandard") == "per-use" and cap.get("defaultState") != "disabled": + out.append( + f"(b) per-use capability {cid!r} ships defaultState=" + f"{cap.get('defaultState')!r}, not 'disabled' — an unanswered active " + f"capability would be live" + ) + return out + + +def check_c_canonical_defaults(policy: dict, registry: dict) -> list[str]: + """(c) The shipped matrix is pinned, and any divergence must be attributable.""" + out: list[str] = [] + present = {c.get("capabilityId") for c in policy.get("capabilities", [])} + + for cap_id, expected in CANONICAL_CAPABILITY_DEFAULTS.items(): + if cap_id not in present: + out.append( + f"(c) canonical capability {cap_id!r} is absent from the policy — absence " + f"is not a safe default, it is ungoverned" + ) + + for cap in policy.get("capabilities", []): + cid = cap.get("capabilityId") + expected = CANONICAL_CAPABILITY_DEFAULTS.get(cid) + standard = cap.get("defaultStandard") + if expected is not None and standard != expected: + out.append( + f"(c) capability {cid!r} declares defaultStandard={standard!r} but the " + f"canonical shipped default is {expected!r}" + ) + if cap.get("effectiveMode") != standard and not cap.get("userOverride"): + out.append( + f"(c) capability {cid!r} runs effectiveMode={cap.get('effectiveMode')!r} " + f"against defaultStandard={standard!r} with userOverride=false — an " + f"unattributed divergence is a silently changed default" + ) + + # Same attributability rule for a GRANTED surface. A denied/revoked surface is 'off' + # by consequence of consent, not by override, so it is exempt. + for surface in registry.get("surfaces", []): + consent = surface.get("consent") or {} + if consent.get("state", "denied") != "granted": + continue + standard = surface.get("defaultStandard") + if surface.get("effectiveMode") != standard and not surface.get("userOverride"): + out.append( + f"(c) granted surface {surface.get('surfaceId')!r} runs effectiveMode=" + f"{surface.get('effectiveMode')!r} against defaultStandard={standard!r} " + f"with userOverride=false — an unattributed divergence" + ) + return out + + +def check_d_one_shot(policy: dict) -> list[str]: + """(d) A per-use grant is single-use and not persistable — no exceptions.""" + out: list[str] = [] + for cap in policy.get("capabilities", []): + cid = cap.get("capabilityId") + standard = cap.get("defaultStandard") + effective = cap.get("effectiveMode") + if "per-use" not in (standard, effective): + continue + if cap.get("oneShot") is not True: + out.append( + f"(d) per-use capability {cid!r} is not oneShot — a per-use grant that can " + f"be persisted is how one 'allow' becomes a standing permission" + ) + if effective in STANDING_MODES: + out.append( + f"(d) per-use capability {cid!r} sits in standing mode {effective!r} — " + f"one-shot forbids the persistable grant a standing mode requires, and " + f"userOverride cannot licence it (the subject may tighten, never loosen)" + ) + return out + + +def explanation_defect(label: str, text: object) -> str | None: + """Is this a real sentence a person could act on, or a placeholder?""" + if not isinstance(text, str): + return f"(e) {label} has no explanation — an unexplained surface cannot be consented to" + stripped = text.strip() + if len(stripped) < MIN_EXPLANATION_CHARS: + return f"(e) {label} explanation is {len(stripped)} chars, under the {MIN_EXPLANATION_CHARS} minimum" + if stripped.lower().rstrip(".") in PLACEHOLDER_EXPLANATIONS: + return f"(e) {label} explanation is placeholder text: {stripped!r}" + if len({w.lower() for w in stripped.split() if w}) < MIN_EXPLANATION_WORDS: + return ( + f"(e) {label} explanation has under {MIN_EXPLANATION_WORDS} distinct words: " + f"{stripped!r} — length alone is not an explanation" + ) + return None + + +def check_e_explanations(registry: dict, policy: dict) -> list[str]: + """(e) Every governed thing carries the plain sentence shown before enabling.""" + out: list[str] = [] + for surface in registry.get("surfaces", []): + defect = explanation_defect(f"surface {surface.get('surfaceId')!r}", surface.get("explanation")) + if defect: + out.append(defect) + for cap in policy.get("capabilities", []): + defect = explanation_defect(f"capability {cap.get('capabilityId')!r}", cap.get("explanation")) + if defect: + out.append(defect) + return out + + +def run_all_checks(registry: dict, policy: dict, registry_schema: dict) -> dict[str, list[str]]: + return { + "a_self_sovereign": check_a_self_sovereign(registry), + "b_default_deny": check_b_default_deny(registry, policy, registry_schema), + "c_canonical_defaults": check_c_canonical_defaults(policy, registry), + "d_one_shot": check_d_one_shot(policy), + "e_explanation": check_e_explanations(registry, policy), + } + + +# ── negative control: synthetic only, never touches examples/ or schemas/ ───────────── +def _nc_registry() -> dict: + return { + "id": "urn:srcos:consent-registry:_nc", + "type": "ConsentSurfaceRegistry", + "specVersion": "0.0.0", + "deploymentScope": "self-sovereign", + "subjectPrincipal": "urn:srcos:principal:_nc", + "collectorPrincipal": "urn:srcos:principal:_nc", + "surfaces": [ + { + "surfaceId": "telemetry:model:nc_surface", + "category": "negative-control", + "sensitivity": "benign", + "pii": False, + "defaultStandard": "standing-persistent", + "effectiveMode": "standing-persistent", + "userOverride": False, + "explanation": "Synthetic negative-control row, never shipped anywhere.", + "projectionMode": "LOSSLESS", + "purpose": "negative-control", + "consent": { + "state": "granted", + "grantedAt": "2026-01-01T00:00:00Z", + "grantRef": "urn:srcos:consent-grant:_nc", + }, + } + ], + } + + +def _nc_policy() -> dict: + return { + "id": "urn:srcos:consent-policy:_nc", + "type": "CapabilityConsentPolicy", + "specVersion": "0.0.0", + "deploymentScope": "self-sovereign", + "subjectPrincipal": "urn:srcos:principal:_nc", + "capabilities": [ + { + "capabilityId": cap_id, + "riskClass": "sensor-capture", + "defaultStandard": standard, + "effectiveMode": standard, + "userOverride": False, + "defaultState": "disabled", + "explanation": "Synthetic negative-control capability, never shipped.", + "oneShot": standard == "per-use", + } + for cap_id, standard in CANONICAL_CAPABILITY_DEFAULTS.items() + ], + } + + +_NC_SCHEMA_DENIED = { + "properties": {"surfaces": {"items": {"properties": { + "consent": {"properties": {"state": {"default": "denied"}}}}}}} +} +_NC_SCHEMA_GRANTED = { + "properties": {"surfaces": {"items": {"properties": { + "consent": {"properties": {"state": {"default": "granted"}}}}}}} +} + + +def _mutate(doc: dict, path: list, value: object) -> dict: + """Deep-copy ``doc`` and set ``path`` to ``value`` (list indices allowed).""" + out = copy.deepcopy(doc) + node = out + for key in path[:-1]: + node = node[key] + node[path[-1]] = value + return out + + +def _drop(doc: dict, path: list) -> dict: + out = copy.deepcopy(doc) + node = out + for key in path[:-1]: + node = node[key] + del node[path[-1]] + return out + + +def _cap_index(policy: dict, cap_id: str) -> int: + return next(i for i, c in enumerate(policy["capabilities"]) if c["capabilityId"] == cap_id) + + +def negative_control() -> bool: + """Every invariant, shown failing on a synthetic defect and passing when clean.""" + reg, pol = _nc_registry(), _nc_policy() + cam = _cap_index(pol, "camera") + mic = _cap_index(pol, "microphone") + + controls: list[tuple[str, bool]] = [ + # baseline: the clean synthetic pair must produce NO findings, or every + # "it bites" result below would be meaningless noise. + ("clean synthetic pair passes every check", + not any(run_all_checks(reg, pol, _NC_SCHEMA_DENIED).values())), + + # (a) self-sovereign + ("(a) bites: collector is a third party", + bool(check_a_self_sovereign(_mutate(reg, ["collectorPrincipal"], "urn:srcos:principal:vendor")))), + + # (b) default-deny — all three prongs + ("(b) bites: schema declares default 'granted'", + bool(check_b_default_deny(reg, pol, _NC_SCHEMA_GRANTED))), + ("(b) bites: granted surface with no grantRef", + bool(check_b_default_deny(_drop(reg, ["surfaces", 0, "consent", "grantRef"]), pol, _NC_SCHEMA_DENIED))), + ("(b) bites: revoked surface with no revokedAt", + bool(check_b_default_deny(_mutate(reg, ["surfaces", 0, "consent"], + {"state": "revoked", "grantRef": "urn:srcos:consent-grant:_nc"}), + pol, _NC_SCHEMA_DENIED))), + ("(b) bites: per-use capability ships enabled", + bool(check_b_default_deny(reg, _mutate(pol, ["capabilities", cam, "defaultState"], "enabled"), + _NC_SCHEMA_DENIED))), + + # (c) canonical defaults + ("(c) bites: camera weakened to standing-persistent", + bool(check_c_canonical_defaults(_mutate(pol, ["capabilities", cam, "defaultStandard"], "standing-persistent"), reg))), + ("(c) bites: a canonical capability deleted from the policy", + bool(check_c_canonical_defaults(_mutate(pol, ["capabilities"], + [c for c in pol["capabilities"] if c["capabilityId"] != "camera"]), reg))), + ("(c) bites: effectiveMode diverges with userOverride=false", + bool(check_c_canonical_defaults(_mutate(pol, ["capabilities", mic, "effectiveMode"], "off"), reg))), + ("(c) bites: granted surface diverges with userOverride=false", + bool(check_c_canonical_defaults(pol, _mutate(reg, ["surfaces", 0, "effectiveMode"], "standing-session")))), + ("(c) exempts: denied surface sits 'off' without an override", + not check_c_canonical_defaults(pol, _mutate(_mutate(reg, ["surfaces", 0, "effectiveMode"], "off"), + ["surfaces", 0, "consent"], {"state": "denied"}))), + + # (d) one-shot + ("(d) bites: per-use capability is not oneShot", + bool(check_d_one_shot(_mutate(pol, ["capabilities", cam, "oneShot"], False)))), + ("(d) bites: per-use capability in a standing mode even with userOverride", + bool(check_d_one_shot(_mutate(_mutate(pol, ["capabilities", cam, "effectiveMode"], "standing-persistent"), + ["capabilities", cam, "userOverride"], True)))), + + # (e) explanation + ("(e) bites: explanation too short", + bool(check_e_explanations(_mutate(reg, ["surfaces", 0, "explanation"], "TODO"), pol))), + ("(e) bites: long but trivial explanation", + bool(check_e_explanations(reg, _mutate(pol, ["capabilities", cam, "explanation"], "todo todo todo todo")))), + ("(e) bites: explanation missing entirely", + bool(check_e_explanations(_drop(reg, ["surfaces", 0, "explanation"]), pol))), + ] + + for name, ok in controls: + print(f" {'OK ' if ok else 'FAIL'} negative control: {name}") + return all(ok for _, ok in controls) + + +def _load(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description="Consent-governance gate for the consent plane.") + ap.add_argument("--self-test", action="store_true", help="run only the negative control") + args = ap.parse_args(argv) + + # A gate that cannot fail certifies nothing. + if not negative_control(): + print("FAIL: negative control did not trip — the gate has no teeth; refusing to certify") + return 2 + if args.self_test: + print("OK: negative control passed") + return 0 + + checks: dict[str, bool] = {} + for schema_name, example_name in PAIRS: + schema = _load(ROOT / "schemas" / schema_name) + jsonschema.Draft202012Validator.check_schema(schema) + jsonschema.Draft202012Validator(schema).validate(_load(ROOT / "examples" / example_name)) + checks[f"schema:{example_name}"] = True + + registry_schema = _load(ROOT / "schemas" / "ConsentSurfaceRegistry.json") + registry = _load(ROOT / "examples" / "consent_surface_registry.json") + policy = _load(ROOT / "examples" / "capability_consent_policy.json") + + findings = run_all_checks(registry, policy, registry_schema) + for name, violations in findings.items(): + checks[name] = not violations + flat = [v for violations in findings.values() for v in violations] + if flat: + print(f"FAIL: {len(flat)} consent-governance violation(s):", file=sys.stderr) + for violation in flat: + print(f" {violation}", file=sys.stderr) + return 1 + + print(json.dumps({"ok": all(checks.values()), "checks": checks}, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())