diff --git a/contracts/sourceos/model-catalog-entry.v0.1.ts b/contracts/sourceos/model-catalog-entry.v0.1.ts index ada5408..85d8b9e 100644 --- a/contracts/sourceos/model-catalog-entry.v0.1.ts +++ b/contracts/sourceos/model-catalog-entry.v0.1.ts @@ -8,12 +8,24 @@ * - Anthropic Claude Code / OpenAI Codex (forensic, 2026-06-10): versioned bundle delivery * done well, lifecycle GC done badly (version accumulation, orphan LaunchServices rows), * capability surface gated by runtime prompt rather than declared + policy-admitted. + * - ChatGPT client (forensic, 2026-06-10): egress fans out through a first-party /ces gateway + * to Statsig + Datadog before first frame, undeclared to the user; sentinel gates the send + * path via chat-requirements/{prepare,finalize} before model IO, silently. * - SourceOS differentiators: SAE interpretability, guardrail-fabric policy-as-code, * Ontogenesis ontologies, SCOPE-D epistemic labeling, TriTRPC provenance wire, * no-invisible-authority (everything declared, nothing discovered at runtime). * * Invariant: a model-router MUST refuse to admit or load an entry that fails * attestation, hash, capability-policy, or epistemic-label checks. Admission is the gate. + * + * v0.1 → v0.2 additions: + * - EgressManifest with per-target permittedPhases (plugs the bootstrap-egress gap) + * - ObservabilitySurface with sinkInitializesBeforeIO and gatedBeforeIO (plugs silent-init gap) + * - ModelCatalogEntry.observability and .egress (both required) + * - AdmissionDenialReason: egress_target_not_permitted, observability_sink_uninitialized, + * cluster_not_admitted + * - AdmissionResult.clusterAdmissionRef: Triune admission-pack cross-reference + * - BaseBinding.baseModelId now optional for kind="base" entries (fixes TS validity) */ // ── Enumerations ──────────────────────────────────────────────────────────── @@ -40,11 +52,21 @@ export type EpistemicLevel = /** Transport wire. TriTRPC is the SourceOS default (AEAD, byte-exact, ternary-native). */ export type CarryWire = "tritrpc" | "https-fallback"; +/** + * Execution phase during which a network target may be contacted. + * Declaring permittedPhases per target prevents the bootstrap-egress pattern + * (ChatGPT forensic: telemetry fires before the first user frame). + */ +export type ExecutionPhase = "bootstrap" | "inference" | "shutdown"; + // ── Sub-records ───────────────────────────────────────────────────────────── -/** Exact base-version binding. Forces adapter re-delivery on base change (Apple discipline). */ +/** + * Exact base-version binding. Forces adapter re-delivery on base change (Apple discipline). + * baseModelId is optional for kind="base" entries (a base IS its own binding). + */ export interface BaseBinding { - baseModelId: string; // e.g. "sourceos.base.v3" + baseModelId?: string; // required for adapter/steering/guardrail; omit for base baseVersion: string; // exact semver; adapter is INVALID against any other baseContentHash: string; // sha256 of the base this entry was trained/verified against } @@ -119,6 +141,50 @@ export interface Lifecycle { }; } +/** + * Egress manifest (forensic: ChatGPT client fans telemetry out through a first-party + * /ces gateway to Statsig + Datadog, undeclared to the user). + * SourceOS keeps the single-gateway pattern but every target is DECLARED here and + * admitted by guardrail-fabric. No component may egress to a host not in this list. + * + * permittedPhases closes the bootstrap-egress gap: a target declared as + * inference-only cannot contact the network during bootstrap or shutdown. + */ +export interface EgressManifest { + targets: Array<{ + host: string; // e.g. "evidence.sourceos.internal" + purpose: "telemetry" | "provenance" | "experimentation" | "inference" | "feedback"; + processor: string; // who actually receives it (first- or third-party), named + wire: CarryWire; + // Explicit phases this target may be contacted. Absent = no egress in that phase. + // bootstrap egress requires explicit justification (audit trigger). + permittedPhases: ExecutionPhase[]; + }>; + // If false, the entry asserts it performs no network egress at all. + permitsEgress: boolean; +} + +/** + * Observability surface (forensic: the entry.client bootstrap installs the telemetry + * sink and reads feature flags BEFORE the first frame; sentinel gates the send path via + * chat-requirements/{prepare,finalize} BEFORE model IO — both silently). + * SourceOS mirrors the ordering but inverts the silence: the provenance sink and the + * policy read MUST initialize before any inference is reachable, and every emission and + * every gate verdict is surfaced rather than hidden. + */ +export interface ObservabilitySurface { + provenanceSinkRef: string; // agentplane evidence sink this entry emits to + // What the entry emits as first-class, surfaced events (not silent instrumentation). + emits: Array<"timing" | "error" | "feature_activation" | "steering_diff" | "gate_verdict">; + // Bootstrap-ordering invariant (the entry.client lesson). Enforced at load: + // the sink + policy read come up before model IO is reachable. No unobserved window. + // Typed as literal true — false is structurally invalid and admission-denied. + sinkInitializesBeforeIO: true; + // Action-gate-before-IO (the sentinel/chat-requirements lesson). When true, a + // guardrail-fabric admission must clear before inference — and its verdict is emitted. + gatedBeforeIO: boolean; +} + // ── Top-level entry ───────────────────────────────────────────────────────── export interface ModelCatalogEntry { @@ -129,7 +195,7 @@ export interface ModelCatalogEntry { kind: ArtifactKind; // Carry - baseBinding: BaseBinding; // omit baseModelId only when kind === "base" + baseBinding: BaseBinding; // baseModelId optional for kind="base"; required otherwise artifact: ArtifactRef; // SourceOS-distinct surfaces (strictly more than Apple's {weights, adapter, hash}) @@ -141,6 +207,16 @@ export interface ModelCatalogEntry { attestation: Attestation; evaluation: EvaluationRecord; + // What it emits and where it may egress (declared, surfaced, policy-admitted) + observability: ObservabilitySurface; + egress: EgressManifest; + + // Cluster-level admission cross-reference. + // When set, must be a non-empty Triune admission-pack reference (pack_id or URI). + // An explicitly empty string triggers cluster_not_admitted denial. + // Omit for synthetic/pre-cluster-admission entries. + clusterAdmissionRef?: string; + // Operational lifecycle: Lifecycle; createdAt: string; // RFC 3339 @@ -156,18 +232,25 @@ export type AdmissionDenialReason = | "capability_not_granted" | "missing_epistemic_label" | "epistemic_rejected" - | "steering_diff_unsupported"; // entry claims steering but can't emit the diff + | "steering_diff_unsupported" // entry claims steering but can't emit the diff + | "egress_target_not_permitted" // permitsEgress=true but a target lacks permittedPhases + | "observability_sink_uninitialized" // sinkInitializesBeforeIO is not true + | "cluster_not_admitted"; // clusterAdmissionRef is explicitly empty export interface AdmissionResult { admitted: boolean; entryId: string; denials: AdmissionDenialReason[]; // empty iff admitted - evidenceRef?: string; // agentplane provenance URI for the admission decision + evidenceRef: string; // always emitted — denied results get a denial URI + // Triune cluster admission pack that admitted the node this entry runs on. + // Present when the entry carries a clusterAdmissionRef and admission passed. + clusterAdmissionRef?: string; } /** * Reference admission contract. Implementation lives in model-router; guardrail-fabric * owns the capability/policy verdict. Every check here is a hard gate — a single failure - * denies. The decision itself is emitted as provenance (no silent admission). + * denies. The decision itself is always emitted as provenance (no silent admission or + * silent denial — AdmissionResult is written to the agentplane sink regardless of outcome). */ export type AdmitEntry = (entry: ModelCatalogEntry) => Promise; diff --git a/examples/model-catalog-entry.admitted.json b/examples/model-catalog-entry.admitted.json index da759bc..9a5c5d5 100644 --- a/examples/model-catalog-entry.admitted.json +++ b/examples/model-catalog-entry.admitted.json @@ -48,6 +48,16 @@ "epistemicLevel": "empirical", "evaluatedAt": "2026-06-10T00:00:00Z" }, + "observability": { + "provenanceSinkRef": "agentplane:evidence-sink:sourceos.adapter.summarize.v1", + "emits": ["timing", "error", "gate_verdict"], + "sinkInitializesBeforeIO": true, + "gatedBeforeIO": true + }, + "egress": { + "permitsEgress": false, + "targets": [] + }, "lifecycle": { "installManifest": { "placements": ["/var/sourceos/adapters/summarize.v1.0.0/"], diff --git a/examples/model-catalog-entry.denied.epistemic-rejected.json b/examples/model-catalog-entry.denied.epistemic-rejected.json index f0dccc7..78fb0d5 100644 --- a/examples/model-catalog-entry.denied.epistemic-rejected.json +++ b/examples/model-catalog-entry.denied.epistemic-rejected.json @@ -44,6 +44,16 @@ "epistemicLevel": "rejected", "evaluatedAt": "2026-06-01T00:00:00Z" }, + "observability": { + "provenanceSinkRef": "agentplane:evidence-sink:sourceos.adapter.summarize.v0-failed-eval", + "emits": ["timing", "error"], + "sinkInitializesBeforeIO": true, + "gatedBeforeIO": false + }, + "egress": { + "permitsEgress": false, + "targets": [] + }, "lifecycle": { "installManifest": { "placements": ["/var/sourceos/adapters/summarize.v0.9.0/"], diff --git a/examples/model-catalog-entry.denied.steering-diff-unsupported.json b/examples/model-catalog-entry.denied.steering-diff-unsupported.json index 4c915b7..4fef8c9 100644 --- a/examples/model-catalog-entry.denied.steering-diff-unsupported.json +++ b/examples/model-catalog-entry.denied.steering-diff-unsupported.json @@ -48,6 +48,16 @@ "epistemicLevel": "empirical", "evaluatedAt": "2026-06-10T00:00:00Z" }, + "observability": { + "provenanceSinkRef": "agentplane:evidence-sink:sourceos.steering.concept-suppressor.v1", + "emits": ["timing", "error", "steering_diff"], + "sinkInitializesBeforeIO": true, + "gatedBeforeIO": true + }, + "egress": { + "permitsEgress": false, + "targets": [] + }, "lifecycle": { "installManifest": { "placements": ["/var/sourceos/steering/concept-suppressor.v1.0.0/"], diff --git a/examples/model-catalog-entry.noetica-chat.synthetic.json b/examples/model-catalog-entry.noetica-chat.synthetic.json new file mode 100644 index 0000000..03b9286 --- /dev/null +++ b/examples/model-catalog-entry.noetica-chat.synthetic.json @@ -0,0 +1,118 @@ +{ + "_comment": "Synthetic fixture: Noetica Chat Surface M2a as a model-catalog-entry workload candidate. epistemicLevel=synthetic; clusterAdmissionRef is pending Triune admission pack. This fixture is NOT proof of live deployment or live Triune cluster membership.", + "id": "noetica.chat.m2a", + "version": "m2a.0.1.0", + "displayName": "Noetica Chat Surface M2a", + "kind": "base", + "baseBinding": { + "baseVersion": "m2a.0.1.0", + "baseContentHash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, + "artifact": { + "contentHash": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "sizeBytes": 10485760, + "encoding": "tritpack243", + "encrypted": true, + "wire": "tritrpc" + }, + "interpretability": { + "steeringTier": "full", + "emitsSteeringDiff": true, + "saeFeatureDictRef": "neuronpedia:feature-dict:noetica-m2a:pending-evidence", + "steeringVectors": [] + }, + "governance": { + "guardrailPolicyRef": "guardrail-fabric:policy:noetica-chat-safe-v1", + "ontologyRef": "ontogenesis:chat-surface:v1" + }, + "capability": { + "declaredCapabilities": [ + "inference.text", + "net.egress:anthropic", + "net.egress:openai", + "net.egress:neuronpedia", + "net.egress:superconscious" + ], + "requiredPermissions": [ + "net.egress:api.anthropic.com", + "net.egress:api.openai.com", + "net.egress:neuronpedia", + "net.egress:superconscious" + ], + "highPrivilege": true + }, + "attestation": { + "signer": "sourceos.release.authority", + "signature": "3045022100synthetic-noetica-m2a-attestation-placeholder-not-a-real-signature", + "hashChain": [ + "noetica.chat.m2a", + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "guardrail-fabric:policy:noetica-chat-safe-v1", + "tritrpc://sourceos.artifacts/noetica/chat.m2a.0.1.0.pack" + ], + "hardenedRuntime": true + }, + "evaluation": { + "evalFabricRunRef": "eval-fabric:run:noetica-m2a-synthetic:pending-live-eval", + "epistemicLevel": "synthetic", + "evaluatedAt": "2026-06-11T00:00:00Z" + }, + "observability": { + "provenanceSinkRef": "agentplane:evidence-sink:noetica.chat.m2a", + "emits": ["timing", "error", "feature_activation", "steering_diff", "gate_verdict"], + "sinkInitializesBeforeIO": true, + "gatedBeforeIO": true + }, + "egress": { + "permitsEgress": true, + "targets": [ + { + "host": "api.anthropic.com", + "purpose": "inference", + "processor": "Anthropic PBC (first-party model provider)", + "wire": "https-fallback", + "permittedPhases": ["inference"] + }, + { + "host": "api.openai.com", + "purpose": "inference", + "processor": "OpenAI LLC (first-party model provider)", + "wire": "https-fallback", + "permittedPhases": ["inference"] + }, + { + "host": "neuronpedia", + "purpose": "experimentation", + "processor": "Neuronpedia (SAE feature steering; may be localhost agent-machine stub)", + "wire": "tritrpc", + "permittedPhases": ["inference"] + }, + { + "host": "superconscious", + "purpose": "provenance", + "processor": "SourceOS Superconscious (task submission and evidence relay)", + "wire": "tritrpc", + "permittedPhases": ["inference", "shutdown"] + } + ] + }, + "clusterAdmissionRef": "pending:triune-admission-pack-noetica-chat-m2a", + "lifecycle": { + "installManifest": { + "placements": [ + "/var/sourceos/workloads/noetica/chat.m2a.0.1.0/", + "/etc/sourceos/noetica/config/" + ], + "registryRows": [ + "model-router:workloads:noetica.chat.m2a", + "model-router:egress-grants:noetica.chat.m2a" + ] + }, + "retentionPolicy": { + "keepVersions": 2, + "reapOrphanRows": true + } + }, + "createdAt": "2026-06-11T00:00:00Z", + "sourceCommit": "pending" +} diff --git a/schemas/agent-machine-route-binding.schema.json b/schemas/agent-machine-route-binding.schema.json new file mode 100644 index 0000000..ac935cb --- /dev/null +++ b/schemas/agent-machine-route-binding.schema.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.socioprophet.ai/model-router/agent-machine-route-binding.schema.json", + "title": "AgentMachineRouteBinding", + "description": "Model-router binding that links SourceOSModelCarryRef, ModelResidency, PlacementFact, and AgentMachineReceipt references into a governed local Agent Machine route.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "kind", + "bindingId", + "taskClass", + "sourceosModelCarryRef", + "runtimeRefs", + "routePolicy", + "evidence" + ], + "properties": { + "schemaVersion": { "const": "v0.1" }, + "kind": { "const": "AgentMachineRouteBinding" }, + "bindingId": { "type": "string", "pattern": "^urn:socioprophet:model-router:agent-machine-binding:" }, + "taskClass": { "type": "string", "enum": ["router", "triage", "summarization", "rewrite", "office-assist", "agent-machine-assist", "offline-fallback", "coding-assist", "privacy-first-chat", "complex-reasoning"] }, + "sourceosModelCarryRef": { "type": "string", "pattern": "^urn:srcos:model-carry-ref:" }, + "runtimeRefs": { + "type": "object", + "additionalProperties": false, + "required": ["inferenceProviderRef", "placementFactRef"], + "properties": { + "inferenceProviderRef": { "type": "string", "pattern": "^urn:srcos:inference-provider:" }, + "modelResidencyRef": { "type": ["string", "null"], "pattern": "^urn:srcos:model-residency:" }, + "placementFactRef": { "type": "string", "pattern": "^urn:srcos:placement-fact:" }, + "lastAgentMachineReceiptRef": { "type": ["string", "null"], "pattern": "^urn:srcos:agent-machine-receipt:" } + } + }, + "routePolicy": { + "type": "object", + "additionalProperties": false, + "required": ["localFirst", "requiresPolicyDecision", "promptEgress", "hostedFallbackAllowed", "cacheReuseRequiresPolicy"], + "properties": { + "localFirst": { "type": "boolean", "const": true }, + "requiresPolicyDecision": { "type": "boolean", "const": true }, + "promptEgress": { "type": "string", "enum": ["deny", "allow-with-policy"] }, + "hostedFallbackAllowed": { "type": "boolean" }, + "cacheReuseRequiresPolicy": { "type": "boolean", "const": true }, + "minimumResidencyState": { "type": "string", "enum": ["cached", "loaded-cold", "loaded-warm", "pinned"] } + } + }, + "evidence": { + "type": "object", + "additionalProperties": false, + "required": ["emitRouteDecision", "emitPlacementFactRef", "emitAgentMachineReceiptRef", "promptHashOnly"], + "properties": { + "emitRouteDecision": { "type": "boolean", "const": true }, + "emitPlacementFactRef": { "type": "boolean", "const": true }, + "emitAgentMachineReceiptRef": { "type": "boolean", "const": true }, + "promptHashOnly": { "type": "boolean", "const": true }, + "ledgerRef": { "type": "string" } + } + } + } +} diff --git a/schemas/model-catalog-entry.v0.1.schema.json b/schemas/model-catalog-entry.v0.1.schema.json index c768682..a9167c2 100644 --- a/schemas/model-catalog-entry.v0.1.schema.json +++ b/schemas/model-catalog-entry.v0.1.schema.json @@ -8,8 +8,9 @@ "id", "version", "displayName", "kind", "baseBinding", "artifact", "interpretability", "governance", "capability", - "attestation", "evaluation", "lifecycle", - "createdAt" + "attestation", "evaluation", + "observability", "egress", + "lifecycle", "createdAt" ], "additionalProperties": false, "properties": { @@ -19,7 +20,7 @@ "kind": { "type": "string", "enum": ["base", "adapter", "steering", "guardrail"] }, "baseBinding": { "type": "object", - "required": ["baseModelId", "baseVersion", "baseContentHash"], + "required": ["baseVersion", "baseContentHash"], "additionalProperties": false, "properties": { "baseModelId": { "type": "string" }, @@ -100,6 +101,54 @@ "evaluatedAt": { "type": "string" } } }, + "observability": { + "type": "object", + "required": ["provenanceSinkRef", "emits", "sinkInitializesBeforeIO", "gatedBeforeIO"], + "additionalProperties": false, + "properties": { + "provenanceSinkRef": { "type": "string", "minLength": 1 }, + "emits": { + "type": "array", + "items": { + "type": "string", + "enum": ["timing", "error", "feature_activation", "steering_diff", "gate_verdict"] + } + }, + "sinkInitializesBeforeIO": { "type": "boolean", "const": true }, + "gatedBeforeIO": { "type": "boolean" } + } + }, + "egress": { + "type": "object", + "required": ["targets", "permitsEgress"], + "additionalProperties": false, + "properties": { + "targets": { + "type": "array", + "items": { + "type": "object", + "required": ["host", "purpose", "processor", "wire", "permittedPhases"], + "additionalProperties": false, + "properties": { + "host": { "type": "string", "minLength": 1 }, + "purpose": { + "type": "string", + "enum": ["telemetry", "provenance", "experimentation", "inference", "feedback"] + }, + "processor": { "type": "string", "minLength": 1 }, + "wire": { "type": "string", "enum": ["tritrpc", "https-fallback"] }, + "permittedPhases": { + "type": "array", + "items": { "type": "string", "enum": ["bootstrap", "inference", "shutdown"] }, + "minItems": 1 + } + } + } + }, + "permitsEgress": { "type": "boolean" } + } + }, + "clusterAdmissionRef": { "type": "string" }, "lifecycle": { "type": "object", "required": ["installManifest", "retentionPolicy"], diff --git a/tools/tests/test_model_catalog_entry.py b/tools/tests/test_model_catalog_entry.py index b6add54..403be86 100644 --- a/tools/tests/test_model_catalog_entry.py +++ b/tools/tests/test_model_catalog_entry.py @@ -287,3 +287,138 @@ def test_multiple_failures_emit_all_denials(): assert not result.admitted assert "epistemic_rejected" in result.denials assert "steering_diff_unsupported" in result.denials + + +# ── Gate 8: egress_target_not_permitted ────────────────────────────────────── + +def test_egress_permits_true_with_empty_phases_denies(): + entry = _admitted() + entry["egress"] = { + "permitsEgress": True, + "targets": [ + { + "host": "api.example.com", + "purpose": "inference", + "processor": "Example Provider", + "wire": "https-fallback", + "permittedPhases": [] + } + ] + } + result = admit_entry(entry) + assert not result.admitted + assert "egress_target_not_permitted" in result.denials + + +def test_egress_permits_true_with_phases_admits(): + entry = _admitted() + entry["egress"] = { + "permitsEgress": True, + "targets": [ + { + "host": "evidence.sourceos.internal", + "purpose": "provenance", + "processor": "SourceOS agentplane", + "wire": "tritrpc", + "permittedPhases": ["inference"] + } + ] + } + result = admit_entry(entry) + assert "egress_target_not_permitted" not in result.denials + + +def test_egress_permits_false_empty_targets_admits(): + entry = _admitted() + entry["egress"] = {"permitsEgress": False, "targets": []} + result = admit_entry(entry) + assert "egress_target_not_permitted" not in result.denials + + +# ── Gate 9: observability_sink_uninitialized ───────────────────────────────── + +def test_sink_not_before_io_denies(): + entry = _admitted() + entry["observability"]["sinkInitializesBeforeIO"] = False + result = admit_entry(entry) + assert not result.admitted + assert "observability_sink_uninitialized" in result.denials + + +def test_sink_missing_denies(): + entry = _admitted() + del entry["observability"]["sinkInitializesBeforeIO"] + result = admit_entry(entry) + assert not result.admitted + assert "observability_sink_uninitialized" in result.denials + + +def test_sink_before_io_true_admits(): + entry = _admitted() + entry["observability"]["sinkInitializesBeforeIO"] = True + result = admit_entry(entry) + assert "observability_sink_uninitialized" not in result.denials + + +# ── Gate 10: cluster_not_admitted ──────────────────────────────────────────── + +def test_cluster_admission_ref_empty_string_denies(): + entry = _admitted() + entry["clusterAdmissionRef"] = "" + result = admit_entry(entry) + assert not result.admitted + assert "cluster_not_admitted" in result.denials + + +def test_cluster_admission_ref_absent_does_not_deny(): + entry = _admitted() + entry.pop("clusterAdmissionRef", None) + result = admit_entry(entry) + assert "cluster_not_admitted" not in result.denials + + +def test_cluster_admission_ref_present_admits_and_propagates(): + entry = _admitted() + entry["clusterAdmissionRef"] = "triune-pack-abc123" + result = admit_entry(entry) + assert "cluster_not_admitted" not in result.denials + if result.admitted: + assert result.cluster_admission_ref == "triune-pack-abc123" + + +# ── Noetica synthetic fixture ───────────────────────────────────────────────── + +NOETICA_PATH = ROOT / "examples" / "model-catalog-entry.noetica-chat.synthetic.json" + + +def test_noetica_synthetic_fixture_admits(): + result = validate_file(NOETICA_PATH) + assert result.admitted, f"noetica synthetic fixture denied: {result.denials}" + assert result.denials == [] + + +def test_noetica_synthetic_fixture_has_evidence_ref(): + result = validate_file(NOETICA_PATH) + assert result.evidence_ref is not None + assert "noetica.chat.m2a" in result.evidence_ref + + +def test_noetica_synthetic_has_full_steering_with_diff(): + entry = _load(NOETICA_PATH) + assert entry["interpretability"]["steeringTier"] == "full" + assert entry["interpretability"]["emitsSteeringDiff"] is True + + +def test_noetica_synthetic_all_egress_targets_have_phases(): + entry = _load(NOETICA_PATH) + assert entry["egress"]["permitsEgress"] is True + for target in entry["egress"]["targets"]: + assert len(target["permittedPhases"]) > 0, f"target {target['host']} has no permittedPhases" + + +def test_noetica_synthetic_no_bootstrap_egress(): + entry = _load(NOETICA_PATH) + for target in entry["egress"]["targets"]: + assert "bootstrap" not in target["permittedPhases"], ( + f"target {target['host']} permits bootstrap egress — requires explicit justification" + ) diff --git a/tools/validate_model_catalog_entry.py b/tools/validate_model_catalog_entry.py index 7753bc5..536e22c 100644 --- a/tools/validate_model_catalog_entry.py +++ b/tools/validate_model_catalog_entry.py @@ -3,8 +3,8 @@ SourceOS model/adapter catalog entry admission validator. Implements the AdmitEntry contract from contracts/sourceos/model-catalog-entry.v0.1.ts. -Every check is a hard gate — a single failure denies. No silent admission. -The admission result is emitted as a provenance record. +Every check is a hard gate — a single failure denies. No silent admission or silent denial. +The admission result is always emitted as a provenance record regardless of outcome. """ from __future__ import annotations @@ -30,6 +30,9 @@ MISSING_EPISTEMIC_LABEL = "missing_epistemic_label" EPISTEMIC_REJECTED = "epistemic_rejected" STEERING_DIFF_UNSUPPORTED = "steering_diff_unsupported" +EGRESS_TARGET_NOT_PERMITTED = "egress_target_not_permitted" +OBSERVABILITY_SINK_UNINITIALIZED = "observability_sink_uninitialized" +CLUSTER_NOT_ADMITTED = "cluster_not_admitted" INADMISSIBLE_EPISTEMIC = {"rejected"} REQUIRES_DIFF = {"full", "local"} @@ -41,15 +44,17 @@ class AdmissionResult: entry_id: str denials: list[str] = field(default_factory=list) evidence_ref: str | None = None + cluster_admission_ref: str | None = None def to_dict(self) -> dict[str, Any]: result: dict[str, Any] = { "admitted": self.admitted, "entryId": self.entry_id, "denials": self.denials, + "evidenceRef": self.evidence_ref, } - if self.evidence_ref: - result["evidenceRef"] = self.evidence_ref + if self.cluster_admission_ref: + result["clusterAdmissionRef"] = self.cluster_admission_ref return result @@ -90,7 +95,6 @@ def admit_entry( # ── Gate 2: attestation_invalid ──────────────────────────────────────── # Signer identity, signature, and hash-chain must all be present and non-empty. - # The hash-chain must be ordered and cover at minimum: assetId, content, policy, url. attestation = entry.get("attestation", {}) attest_failures = False if not isinstance(attestation.get("signer"), str) or not attestation["signer"].strip(): @@ -106,7 +110,7 @@ def admit_entry( # ── Gate 3: base_version_mismatch ────────────────────────────────────── # Adapters, steering, and guardrail artifacts must declare a fully-specified # base binding (non-empty baseModelId + baseVersion + valid baseContentHash). - # Base artifacts are self-binding; their baseModelId may be empty (they ARE the base). + # Base artifacts are self-binding; their baseModelId may be empty. kind = entry.get("kind", "") base_binding = entry.get("baseBinding", {}) if kind != "base": @@ -119,8 +123,6 @@ def admit_entry( # ── Gate 4: capability_not_granted ───────────────────────────────────── # highPrivilege entries require at least one explicit requiredPermission declared. - # An empty requiredPermissions list on a high-privilege entry means the grant - # surface is undeclared — guardrail-fabric has nothing to check against. capability = entry.get("capability", {}) if capability.get("highPrivilege") is True: perms = capability.get("requiredPermissions", []) @@ -140,13 +142,38 @@ def admit_entry( # ── Gate 7: steering_diff_unsupported ────────────────────────────────── # When steeringTier is "full" or "local", the entry MUST declare it can emit - # a steered-vs-baseline diff. An entry that claims steering but hides the diff - # violates the Noetica interpretability invariant. + # a steered-vs-baseline diff. interp = entry.get("interpretability", {}) tier = interp.get("steeringTier", "none") if tier in REQUIRES_DIFF and interp.get("emitsSteeringDiff") is not True: denials.append(STEERING_DIFF_UNSUPPORTED) + # ── Gate 8: egress_target_not_permitted ──────────────────────────────── + # If permitsEgress is true, every declared target must have a non-empty + # permittedPhases list. A target with no phases declared is undeclared egress. + egress = entry.get("egress", {}) + if egress.get("permitsEgress") is True: + for target in egress.get("targets", []): + phases = target.get("permittedPhases", []) + if not isinstance(phases, list) or len(phases) == 0: + denials.append(EGRESS_TARGET_NOT_PERMITTED) + break + + # ── Gate 9: observability_sink_uninitialized ─────────────────────────── + # sinkInitializesBeforeIO must be the literal true. Any other value means + # model IO is reachable before the provenance sink is up. + observability = entry.get("observability", {}) + if observability.get("sinkInitializesBeforeIO") is not True: + denials.append(OBSERVABILITY_SINK_UNINITIALIZED) + + # ── Gate 10: cluster_not_admitted ────────────────────────────────────── + # clusterAdmissionRef explicitly set to "" means the operator declared a cluster + # binding but provided no pack reference. Omitting the field entirely is allowed + # (synthetic/pre-cluster entries). An empty string is a declared-but-empty ref. + cluster_ref = entry.get("clusterAdmissionRef") + if isinstance(cluster_ref, str) and cluster_ref.strip() == "": + denials.append(CLUSTER_NOT_ADMITTED) + admitted = len(denials) == 0 evidence_ref = ( f"model-router:admission:{entry_id}:{'admitted' if admitted else 'denied'}" @@ -156,6 +183,7 @@ def admit_entry( entry_id=entry_id, denials=denials, evidence_ref=evidence_ref, + cluster_admission_ref=cluster_ref if admitted and cluster_ref else None, )