From 510dfefc9a6254913d3ae1b941643dacccc4db2b Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:43:20 -0400 Subject: [PATCH] feat(catalog): SourceOS model/adapter catalog entry admission contract v0.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the ModelCatalogEntry admission contract for model-router. Designed from forensic analysis of Apple Foundation Models delivery, Claude Code/Codex lifecycle failures, and SourceOS differentiators (SAE interpretability, SCOPE-D epistemic labeling, guardrail-fabric policy, Ontogenesis ontologies, TriTRPC provenance wire). Seven hard admission gates — a single failure denies, no silent admission: 1. content_hash_mismatch — sha256 format + payload verification; encrypted=true invariant 2. attestation_invalid — signer identity, signature, hash-chain (provenance anchor) 3. base_version_mismatch — adapters/steering/guardrail must declare exact base binding 4. capability_not_granted — highPrivilege requires non-empty requiredPermissions 5. missing_epistemic_label — SCOPE-D level required; no label = inadmissible 6. epistemic_rejected — retained for audit, never loadable 7. steering_diff_unsupported — steeringTier full/local requires emitsSteeringDiff=true Artifacts: contracts/sourceos/model-catalog-entry.v0.1.ts — TypeScript source of truth schemas/model-catalog-entry.v0.1.schema.json — JSON Schema (draft-07) examples/model-catalog-entry.admitted.json — valid admitted entry examples/model-catalog-entry.denied.epistemic-rejected.json examples/model-catalog-entry.denied.steering-diff-unsupported.json tools/validate_model_catalog_entry.py — Python admission implementation tools/tests/test_model_catalog_entry.py — 35 tests, all gates covered Makefile: validate-model-catalog-entry wired into make validate .github/workflows/model-catalog-entry.yml — path-scoped CI --- .github/workflows/model-catalog-entry.yml | 28 ++ Makefile | 10 +- .../sourceos/model-catalog-entry.v0.1.ts | 173 +++++++++++ examples/model-catalog-entry.admitted.json | 63 ++++ ...talog-entry.denied.epistemic-rejected.json | 58 ++++ ...ntry.denied.steering-diff-unsupported.json | 62 ++++ schemas/model-catalog-entry.v0.1.schema.json | 131 ++++++++ tools/tests/test_model_catalog_entry.py | 289 ++++++++++++++++++ tools/validate_model_catalog_entry.py | 192 ++++++++++++ 9 files changed, 1004 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/model-catalog-entry.yml create mode 100644 contracts/sourceos/model-catalog-entry.v0.1.ts create mode 100644 examples/model-catalog-entry.admitted.json create mode 100644 examples/model-catalog-entry.denied.epistemic-rejected.json create mode 100644 examples/model-catalog-entry.denied.steering-diff-unsupported.json create mode 100644 schemas/model-catalog-entry.v0.1.schema.json create mode 100644 tools/tests/test_model_catalog_entry.py create mode 100644 tools/validate_model_catalog_entry.py diff --git a/.github/workflows/model-catalog-entry.yml b/.github/workflows/model-catalog-entry.yml new file mode 100644 index 0000000..a7f36d8 --- /dev/null +++ b/.github/workflows/model-catalog-entry.yml @@ -0,0 +1,28 @@ +name: model-catalog-entry + +on: + pull_request: + push: + branches: [main] + paths: + - "contracts/sourceos/**" + - "schemas/model-catalog-entry.v0.1.schema.json" + - "examples/model-catalog-entry.*.json" + - "tools/validate_model_catalog_entry.py" + - "tools/tests/test_model_catalog_entry.py" + - ".github/workflows/model-catalog-entry.yml" + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install test dependency + run: python3 -m pip install pytest + - name: Validate catalog entry examples + run: make validate-model-catalog-entry + - name: Run admission tests + run: python3 -m pytest tools/tests/test_model_catalog_entry.py -v diff --git a/Makefile b/Makefile index 773d4fd..6ed97ef 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ -.PHONY: validate test emit-demo-decision release-dry-run validate-superconscious-reasoning-route validate-svf-receipt-state-routing validate-trust-chain-model-route-decision validate-prophet-mesh-model-routing +.PHONY: validate test emit-demo-decision release-dry-run validate-superconscious-reasoning-route validate-svf-receipt-state-routing validate-trust-chain-model-route-decision validate-prophet-mesh-model-routing validate-model-catalog-entry -validate: validate-superconscious-reasoning-route validate-svf-receipt-state-routing validate-trust-chain-model-route-decision validate-prophet-mesh-model-routing +validate: validate-superconscious-reasoning-route validate-svf-receipt-state-routing validate-trust-chain-model-route-decision validate-prophet-mesh-model-routing validate-model-catalog-entry python3 tools/validate_route_examples.py validate-superconscious-reasoning-route: @@ -20,6 +20,12 @@ validate-prophet-mesh-model-routing: python3 -m json.tool contracts/prophet-mesh/prophet-mesh-model-routing.v0.1.json >/dev/null python3 tools/validate_prophet_mesh_model_routing.py +validate-model-catalog-entry: + python3 -m json.tool schemas/model-catalog-entry.v0.1.schema.json >/dev/null + python3 tools/validate_model_catalog_entry.py examples/model-catalog-entry.admitted.json + python3 tools/validate_model_catalog_entry.py examples/model-catalog-entry.denied.epistemic-rejected.json --expect-denied + python3 tools/validate_model_catalog_entry.py examples/model-catalog-entry.denied.steering-diff-unsupported.json --expect-denied + test: python3 -m pytest -q tools/tests diff --git a/contracts/sourceos/model-catalog-entry.v0.1.ts b/contracts/sourceos/model-catalog-entry.v0.1.ts new file mode 100644 index 0000000..ada5408 --- /dev/null +++ b/contracts/sourceos/model-catalog-entry.v0.1.ts @@ -0,0 +1,173 @@ +/** + * SourceOS — Model/Adapter Catalog Entry Contract + * Target: model-router (authority for routing + catalog resolution) + * + * Design basis: + * - Apple Foundation Models delivery (observed): frozen base + LoRA adapter overlays, + * content-addressed encrypted assets, pre-stage + atomic swap, cache-delete GC governor. + * - 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. + * - 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. + */ + +// ── Enumerations ──────────────────────────────────────────────────────────── + +/** What kind of artifact this entry carries. Mirrors Apple's base/adapter split. */ +export type ArtifactKind = + | "base" // full base model (the frozen general model) + | "adapter" // LoRA-style overlay bound to a specific base version + | "steering" // SAE steering vectors only (no weights) + | "guardrail"; // policy/classifier artifact only + +/** SAE steering capability tier (from Noetica: full | local | none, never boolean). */ +export type SteeringTier = "full" | "local" | "none"; + +/** SCOPE-D epistemic level. An entry is inadmissible without one. */ +export type EpistemicLevel = + | "proved" + | "bounded" + | "empirical" + | "synthetic" + | "speculative" + | "rejected"; // rejected entries are retained for audit but never loadable + +/** Transport wire. TriTRPC is the SourceOS default (AEAD, byte-exact, ternary-native). */ +export type CarryWire = "tritrpc" | "https-fallback"; + +// ── Sub-records ───────────────────────────────────────────────────────────── + +/** Exact base-version binding. Forces adapter re-delivery on base change (Apple discipline). */ +export interface BaseBinding { + baseModelId: string; // e.g. "sourceos.base.v3" + baseVersion: string; // exact semver; adapter is INVALID against any other + baseContentHash: string; // sha256 of the base this entry was trained/verified against +} + +/** Content-addressed, encrypted artifact reference carried over the wire. */ +export interface ArtifactRef { + contentHash: string; // sha256 — admission rejects on mismatch (hard stop) + sizeBytes: number; + encoding: "appleencryptedarchive-equiv" | "tritpack243" | "raw"; + encrypted: true; // encryption-at-rest is an INVARIANT, not a flag to toggle + wire: CarryWire; +} + +/** Interpretability surface. Apple ships none of this; it is a SourceOS primitive. */ +export interface InterpretabilitySurface { + saeFeatureDictRef?: string; // content hash of the SAE feature dictionary + steeringVectors?: string[]; // content hashes of steering vectors (Neuronpedia-governed) + steeringTier: SteeringTier; // governs whether/how SAE steering may be applied + // Hard rule carried from Noetica: when steering is applied, the steered-vs-baseline + // diff MUST be surfaced. This flag asserts the entry supports diff emission. + emitsSteeringDiff: boolean; +} + +/** Governance bindings. Travel WITH the artifact, hash-bound — safety is part of identity. */ +export interface GovernanceBinding { + guardrailPolicyRef: string; // policy-as-code admitted by guardrail-fabric + ontologyRef?: string; // Ontogenesis ontology for guided/constrained generation +} + +/** + * Declared capability surface (forensic lesson #2). + * guardrail-fabric admits/denies on THIS, not on what a bundle registers at runtime. + * Nothing may exercise a capability absent from declaredCapabilities. + */ +export interface CapabilityManifest { + declaredCapabilities: string[]; // e.g. ["inference.text", "tool.read", "tool.computer-use"] + requiredPermissions: string[]; // e.g. ["fs.read:/scoped", "net.egress:none"] + highPrivilege: boolean; // true ⇒ guardrail-fabric requires explicit policy grant +} + +/** + * Attestation (forensic lesson #3 — the codesign/TeamIdentifier analogue). + * Checked at admission. Hash-chain anchors provenance across the TriTRPC boundary. + */ +export interface Attestation { + signer: string; // signing identity (the SourceOS analogue of TeamIdentifier) + signature: string; // detached signature over {contentHash, hashChain, capabilityManifest} + hashChain: string[]; // ordered provenance hashes (assetId → content → policy → url) + hardenedRuntime: boolean; // mirrors the vendor hardened-runtime posture +} + +/** Evaluation results. An entry without eval + epistemic label is inadmissible. */ +export interface EvaluationRecord { + evalFabricRunRef: string; // content hash of the eval-fabric result set + epistemicLevel: EpistemicLevel; + evaluatedAt: string; // RFC 3339 +} + +/** + * Lifecycle (forensic lessons #1 and #4). + * The entry OWNS its install/uninstall and retention. Do not leave GC to the OS registry — + * that is exactly how Claude Code accumulated 15 versions and Codex left orphan LS rows. + */ +export interface Lifecycle { + installManifest: { + placements: string[]; // exact on-disk locations this entry writes + registryRows: string[]; // exact registry/route rows it creates (for clean removal) + }; + retentionPolicy: { + keepVersions: number; // e.g. 2 — reap older (Apple cache-delete discipline) + reapOrphanRows: boolean; // deregister + remove on uninstall; never accumulate + }; +} + +// ── Top-level entry ───────────────────────────────────────────────────────── + +export interface ModelCatalogEntry { + // Identity + id: string; // e.g. "sourceos.adapter.summarize" + version: string; // exact semver + displayName: string; + kind: ArtifactKind; + + // Carry + baseBinding: BaseBinding; // omit baseModelId only when kind === "base" + artifact: ArtifactRef; + + // SourceOS-distinct surfaces (strictly more than Apple's {weights, adapter, hash}) + interpretability: InterpretabilitySurface; + governance: GovernanceBinding; + capability: CapabilityManifest; + + // Provenance + admission gates + attestation: Attestation; + evaluation: EvaluationRecord; + + // Operational + lifecycle: Lifecycle; + createdAt: string; // RFC 3339 + sourceCommit?: string; // originating repo commit, if applicable +} + +// ── Admission ─────────────────────────────────────────────────────────────── + +export type AdmissionDenialReason = + | "content_hash_mismatch" + | "attestation_invalid" + | "base_version_mismatch" + | "capability_not_granted" + | "missing_epistemic_label" + | "epistemic_rejected" + | "steering_diff_unsupported"; // entry claims steering but can't emit the diff + +export interface AdmissionResult { + admitted: boolean; + entryId: string; + denials: AdmissionDenialReason[]; // empty iff admitted + evidenceRef?: string; // agentplane provenance URI for the admission decision +} + +/** + * 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). + */ +export type AdmitEntry = (entry: ModelCatalogEntry) => Promise; diff --git a/examples/model-catalog-entry.admitted.json b/examples/model-catalog-entry.admitted.json new file mode 100644 index 0000000..da759bc --- /dev/null +++ b/examples/model-catalog-entry.admitted.json @@ -0,0 +1,63 @@ +{ + "id": "sourceos.adapter.summarize.v1", + "version": "1.0.0", + "displayName": "SourceOS Summarization Adapter v1", + "kind": "adapter", + "baseBinding": { + "baseModelId": "sourceos.base.v3", + "baseVersion": "3.0.0", + "baseContentHash": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + }, + "artifact": { + "contentHash": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "sizeBytes": 524288000, + "encoding": "tritpack243", + "encrypted": true, + "wire": "tritrpc" + }, + "interpretability": { + "steeringTier": "local", + "emitsSteeringDiff": true, + "saeFeatureDictRef": "cafe1234cafe1234cafe1234cafe1234cafe1234cafe1234cafe1234cafe1234", + "steeringVectors": [ + "1111111111111111111111111111111111111111111111111111111111111111" + ] + }, + "governance": { + "guardrailPolicyRef": "guardrail-fabric:policy:summarize-safe-v1", + "ontologyRef": "ontogenesis:summarization-domain:v1" + }, + "capability": { + "declaredCapabilities": ["inference.text"], + "requiredPermissions": ["fs.read:none", "net.egress:none"], + "highPrivilege": false + }, + "attestation": { + "signer": "sourceos.release.authority", + "signature": "3045022100f3a2b1c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2", + "hashChain": [ + "sourceos.adapter.summarize.v1", + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "guardrail-fabric:policy:summarize-safe-v1", + "tritrpc://sourceos.artifacts/adapters/summarize.v1.0.0.pack" + ], + "hardenedRuntime": true + }, + "evaluation": { + "evalFabricRunRef": "eval-fabric:run:summarize-v1-20260610:abc123def456", + "epistemicLevel": "empirical", + "evaluatedAt": "2026-06-10T00:00:00Z" + }, + "lifecycle": { + "installManifest": { + "placements": ["/var/sourceos/adapters/summarize.v1.0.0/"], + "registryRows": ["model-router:adapters:sourceos.adapter.summarize.v1"] + }, + "retentionPolicy": { + "keepVersions": 2, + "reapOrphanRows": true + } + }, + "createdAt": "2026-06-10T00:00:00Z", + "sourceCommit": "9431a9a" +} diff --git a/examples/model-catalog-entry.denied.epistemic-rejected.json b/examples/model-catalog-entry.denied.epistemic-rejected.json new file mode 100644 index 0000000..f0dccc7 --- /dev/null +++ b/examples/model-catalog-entry.denied.epistemic-rejected.json @@ -0,0 +1,58 @@ +{ + "_comment": "Denial reason: epistemic_rejected — epistemicLevel is 'rejected'; entry retained for audit only, never loadable.", + "id": "sourceos.adapter.summarize.v0-failed-eval", + "version": "0.9.0", + "displayName": "SourceOS Summarization Adapter v0 (failed eval)", + "kind": "adapter", + "baseBinding": { + "baseModelId": "sourceos.base.v3", + "baseVersion": "3.0.0", + "baseContentHash": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + }, + "artifact": { + "contentHash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "sizeBytes": 524288000, + "encoding": "tritpack243", + "encrypted": true, + "wire": "tritrpc" + }, + "interpretability": { + "steeringTier": "none", + "emitsSteeringDiff": false + }, + "governance": { + "guardrailPolicyRef": "guardrail-fabric:policy:summarize-safe-v1" + }, + "capability": { + "declaredCapabilities": ["inference.text"], + "requiredPermissions": ["fs.read:none", "net.egress:none"], + "highPrivilege": false + }, + "attestation": { + "signer": "sourceos.release.authority", + "signature": "3045022100f3a2b1c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2", + "hashChain": [ + "sourceos.adapter.summarize.v0-failed-eval", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "guardrail-fabric:policy:summarize-safe-v1", + "tritrpc://sourceos.artifacts/adapters/summarize.v0.9.0.pack" + ], + "hardenedRuntime": true + }, + "evaluation": { + "evalFabricRunRef": "eval-fabric:run:summarize-v0-20260601:failed001", + "epistemicLevel": "rejected", + "evaluatedAt": "2026-06-01T00:00:00Z" + }, + "lifecycle": { + "installManifest": { + "placements": ["/var/sourceos/adapters/summarize.v0.9.0/"], + "registryRows": ["model-router:adapters:sourceos.adapter.summarize.v0-failed-eval"] + }, + "retentionPolicy": { + "keepVersions": 2, + "reapOrphanRows": true + } + }, + "createdAt": "2026-06-01T00:00:00Z" +} diff --git a/examples/model-catalog-entry.denied.steering-diff-unsupported.json b/examples/model-catalog-entry.denied.steering-diff-unsupported.json new file mode 100644 index 0000000..4c915b7 --- /dev/null +++ b/examples/model-catalog-entry.denied.steering-diff-unsupported.json @@ -0,0 +1,62 @@ +{ + "_comment": "Denial reason: steering_diff_unsupported — steeringTier is 'full' but emitsSteeringDiff is false. Contract requires diff emission when steering is active.", + "id": "sourceos.steering.concept-suppressor.v1", + "version": "1.0.0", + "displayName": "SourceOS Concept Suppressor Steering v1 (missing diff support)", + "kind": "steering", + "baseBinding": { + "baseModelId": "sourceos.base.v3", + "baseVersion": "3.0.0", + "baseContentHash": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + }, + "artifact": { + "contentHash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "sizeBytes": 2097152, + "encoding": "tritpack243", + "encrypted": true, + "wire": "tritrpc" + }, + "interpretability": { + "steeringTier": "full", + "emitsSteeringDiff": false, + "saeFeatureDictRef": "cafe1234cafe1234cafe1234cafe1234cafe1234cafe1234cafe1234cafe1234", + "steeringVectors": [ + "2222222222222222222222222222222222222222222222222222222222222222" + ] + }, + "governance": { + "guardrailPolicyRef": "guardrail-fabric:policy:steering-safe-v1" + }, + "capability": { + "declaredCapabilities": ["inference.text", "steering.apply"], + "requiredPermissions": ["fs.read:none", "net.egress:none"], + "highPrivilege": false + }, + "attestation": { + "signer": "sourceos.release.authority", + "signature": "3045022100f3a2b1c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2", + "hashChain": [ + "sourceos.steering.concept-suppressor.v1", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "guardrail-fabric:policy:steering-safe-v1", + "tritrpc://sourceos.artifacts/steering/concept-suppressor.v1.0.0.pack" + ], + "hardenedRuntime": true + }, + "evaluation": { + "evalFabricRunRef": "eval-fabric:run:steering-v1-20260610:steer001", + "epistemicLevel": "empirical", + "evaluatedAt": "2026-06-10T00:00:00Z" + }, + "lifecycle": { + "installManifest": { + "placements": ["/var/sourceos/steering/concept-suppressor.v1.0.0/"], + "registryRows": ["model-router:steering:sourceos.steering.concept-suppressor.v1"] + }, + "retentionPolicy": { + "keepVersions": 2, + "reapOrphanRows": true + } + }, + "createdAt": "2026-06-10T00:00:00Z" +} diff --git a/schemas/model-catalog-entry.v0.1.schema.json b/schemas/model-catalog-entry.v0.1.schema.json new file mode 100644 index 0000000..c768682 --- /dev/null +++ b/schemas/model-catalog-entry.v0.1.schema.json @@ -0,0 +1,131 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "sourceos.model-catalog-entry.v0.1", + "title": "ModelCatalogEntry", + "description": "SourceOS model/adapter catalog entry contract v0.1. Derived from contracts/sourceos/model-catalog-entry.v0.1.ts.", + "type": "object", + "required": [ + "id", "version", "displayName", "kind", + "baseBinding", "artifact", + "interpretability", "governance", "capability", + "attestation", "evaluation", "lifecycle", + "createdAt" + ], + "additionalProperties": false, + "properties": { + "id": { "type": "string", "minLength": 1 }, + "version": { "type": "string", "minLength": 1 }, + "displayName": { "type": "string", "minLength": 1 }, + "kind": { "type": "string", "enum": ["base", "adapter", "steering", "guardrail"] }, + "baseBinding": { + "type": "object", + "required": ["baseModelId", "baseVersion", "baseContentHash"], + "additionalProperties": false, + "properties": { + "baseModelId": { "type": "string" }, + "baseVersion": { "type": "string" }, + "baseContentHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + } + }, + "artifact": { + "type": "object", + "required": ["contentHash", "sizeBytes", "encoding", "encrypted", "wire"], + "additionalProperties": false, + "properties": { + "contentHash": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "sizeBytes": { "type": "integer", "minimum": 1 }, + "encoding": { + "type": "string", + "enum": ["appleencryptedarchive-equiv", "tritpack243", "raw"] + }, + "encrypted": { "type": "boolean", "const": true }, + "wire": { "type": "string", "enum": ["tritrpc", "https-fallback"] } + } + }, + "interpretability": { + "type": "object", + "required": ["steeringTier", "emitsSteeringDiff"], + "additionalProperties": false, + "properties": { + "saeFeatureDictRef": { "type": "string" }, + "steeringVectors": { "type": "array", "items": { "type": "string" } }, + "steeringTier": { "type": "string", "enum": ["full", "local", "none"] }, + "emitsSteeringDiff": { "type": "boolean" } + } + }, + "governance": { + "type": "object", + "required": ["guardrailPolicyRef"], + "additionalProperties": false, + "properties": { + "guardrailPolicyRef": { "type": "string", "minLength": 1 }, + "ontologyRef": { "type": "string" } + } + }, + "capability": { + "type": "object", + "required": ["declaredCapabilities", "requiredPermissions", "highPrivilege"], + "additionalProperties": false, + "properties": { + "declaredCapabilities": { "type": "array", "items": { "type": "string" } }, + "requiredPermissions": { "type": "array", "items": { "type": "string" } }, + "highPrivilege": { "type": "boolean" } + } + }, + "attestation": { + "type": "object", + "required": ["signer", "signature", "hashChain", "hardenedRuntime"], + "additionalProperties": false, + "properties": { + "signer": { "type": "string", "minLength": 1 }, + "signature": { "type": "string", "minLength": 1 }, + "hashChain": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "hardenedRuntime": { "type": "boolean" } + } + }, + "evaluation": { + "type": "object", + "required": ["evalFabricRunRef", "epistemicLevel", "evaluatedAt"], + "additionalProperties": false, + "properties": { + "evalFabricRunRef": { "type": "string", "minLength": 1 }, + "epistemicLevel": { + "type": "string", + "enum": ["proved", "bounded", "empirical", "synthetic", "speculative", "rejected"] + }, + "evaluatedAt": { "type": "string" } + } + }, + "lifecycle": { + "type": "object", + "required": ["installManifest", "retentionPolicy"], + "additionalProperties": false, + "properties": { + "installManifest": { + "type": "object", + "required": ["placements", "registryRows"], + "additionalProperties": false, + "properties": { + "placements": { "type": "array", "items": { "type": "string" } }, + "registryRows": { "type": "array", "items": { "type": "string" } } + } + }, + "retentionPolicy": { + "type": "object", + "required": ["keepVersions", "reapOrphanRows"], + "additionalProperties": false, + "properties": { + "keepVersions": { "type": "integer", "minimum": 1 }, + "reapOrphanRows": { "type": "boolean" } + } + } + } + }, + "createdAt": { "type": "string" }, + "sourceCommit": { "type": "string" } + } +} diff --git a/tools/tests/test_model_catalog_entry.py b/tools/tests/test_model_catalog_entry.py new file mode 100644 index 0000000..b6add54 --- /dev/null +++ b/tools/tests/test_model_catalog_entry.py @@ -0,0 +1,289 @@ +"""Tests for the SourceOS model catalog entry admission validator.""" +from __future__ import annotations + +import copy +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +MODULE_PATH = ROOT / "tools" / "validate_model_catalog_entry.py" + +spec = importlib.util.spec_from_file_location("validate_model_catalog_entry", MODULE_PATH) +assert spec and spec.loader +_mod = importlib.util.module_from_spec(spec) +sys.modules["validate_model_catalog_entry"] = _mod +spec.loader.exec_module(_mod) + +admit_entry = _mod.admit_entry +validate_file = _mod.validate_file + +ADMITTED_PATH = ROOT / "examples" / "model-catalog-entry.admitted.json" +DENIED_EPISTEMIC_PATH = ROOT / "examples" / "model-catalog-entry.denied.epistemic-rejected.json" +DENIED_STEERING_PATH = ROOT / "examples" / "model-catalog-entry.denied.steering-diff-unsupported.json" + + +def _load(path: Path) -> dict: + raw = json.loads(path.read_text()) + return {k: v for k, v in raw.items() if not k.startswith("_")} + + +def _admitted() -> dict: + return _load(ADMITTED_PATH) + + +# ── Admitted fixture ───────────────────────────────────────────────────────── + +def test_admitted_fixture_passes(): + result = validate_file(ADMITTED_PATH) + assert result.admitted, result.denials + assert result.denials == [] + assert result.evidence_ref is not None + assert "admitted" in result.evidence_ref + + +# ── Gate 1: content_hash_mismatch ──────────────────────────────────────────── + +def test_content_hash_not_hex_denies(): + entry = _admitted() + entry["artifact"]["contentHash"] = "not-a-hash" + result = admit_entry(entry) + assert not result.admitted + assert "content_hash_mismatch" in result.denials + + +def test_content_hash_too_short_denies(): + entry = _admitted() + entry["artifact"]["contentHash"] = "deadbeef" + result = admit_entry(entry) + assert not result.admitted + assert "content_hash_mismatch" in result.denials + + +def test_content_hash_uppercase_denies(): + entry = _admitted() + entry["artifact"]["contentHash"] = "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF" + result = admit_entry(entry) + assert not result.admitted + assert "content_hash_mismatch" in result.denials + + +def test_encrypted_false_denies(): + entry = _admitted() + entry["artifact"]["encrypted"] = False + result = admit_entry(entry) + assert not result.admitted + assert "content_hash_mismatch" in result.denials + + +def test_delivered_bytes_hash_match_admits(): + import hashlib + payload = b"example artifact payload" + digest = hashlib.sha256(payload).hexdigest() + entry = _admitted() + entry["artifact"]["contentHash"] = digest + result = admit_entry(entry, delivered_bytes=payload) + assert result.admitted, result.denials + + +def test_delivered_bytes_hash_mismatch_denies(): + entry = _admitted() + result = admit_entry(entry, delivered_bytes=b"wrong payload bytes") + assert not result.admitted + assert "content_hash_mismatch" in result.denials + + +# ── Gate 2: attestation_invalid ────────────────────────────────────────────── + +def test_empty_signer_denies(): + entry = _admitted() + entry["attestation"]["signer"] = "" + result = admit_entry(entry) + assert not result.admitted + assert "attestation_invalid" in result.denials + + +def test_empty_signature_denies(): + entry = _admitted() + entry["attestation"]["signature"] = "" + result = admit_entry(entry) + assert not result.admitted + assert "attestation_invalid" in result.denials + + +def test_empty_hash_chain_denies(): + entry = _admitted() + entry["attestation"]["hashChain"] = [] + result = admit_entry(entry) + assert not result.admitted + assert "attestation_invalid" in result.denials + + +# ── Gate 3: base_version_mismatch ──────────────────────────────────────────── + +def test_adapter_missing_base_model_id_denies(): + entry = _admitted() + assert entry["kind"] == "adapter" + entry["baseBinding"]["baseModelId"] = "" + result = admit_entry(entry) + assert not result.admitted + assert "base_version_mismatch" in result.denials + + +def test_adapter_missing_base_version_denies(): + entry = _admitted() + entry["baseBinding"]["baseVersion"] = "" + result = admit_entry(entry) + assert not result.admitted + assert "base_version_mismatch" in result.denials + + +def test_adapter_invalid_base_content_hash_denies(): + entry = _admitted() + entry["baseBinding"]["baseContentHash"] = "not-a-hash" + result = admit_entry(entry) + assert not result.admitted + assert "base_version_mismatch" in result.denials + + +def test_base_kind_skips_base_version_check(): + entry = _admitted() + entry["kind"] = "base" + entry["baseBinding"]["baseModelId"] = "" + result = admit_entry(entry) + assert "base_version_mismatch" not in result.denials + + +# ── Gate 4: capability_not_granted ─────────────────────────────────────────── + +def test_high_privilege_empty_permissions_denies(): + entry = _admitted() + entry["capability"]["highPrivilege"] = True + entry["capability"]["requiredPermissions"] = [] + result = admit_entry(entry) + assert not result.admitted + assert "capability_not_granted" in result.denials + + +def test_high_privilege_with_permissions_admits(): + entry = _admitted() + entry["capability"]["highPrivilege"] = True + entry["capability"]["requiredPermissions"] = ["fs.read:/scoped"] + result = admit_entry(entry) + assert "capability_not_granted" not in result.denials + + +def test_low_privilege_empty_permissions_does_not_deny(): + entry = _admitted() + entry["capability"]["highPrivilege"] = False + entry["capability"]["requiredPermissions"] = [] + result = admit_entry(entry) + assert "capability_not_granted" not in result.denials + + +# ── Gate 5: missing_epistemic_label ────────────────────────────────────────── + +def test_missing_epistemic_label_denies(): + entry = _admitted() + del entry["evaluation"]["epistemicLevel"] + result = admit_entry(entry) + assert not result.admitted + assert "missing_epistemic_label" in result.denials + + +def test_empty_epistemic_label_denies(): + entry = _admitted() + entry["evaluation"]["epistemicLevel"] = "" + result = admit_entry(entry) + assert not result.admitted + assert "missing_epistemic_label" in result.denials + + +# ── Gate 6: epistemic_rejected ─────────────────────────────────────────────── + +def test_epistemic_rejected_fixture_denies(): + result = validate_file(DENIED_EPISTEMIC_PATH) + assert not result.admitted + assert "epistemic_rejected" in result.denials + + +def test_epistemic_rejected_value_denies(): + entry = _admitted() + entry["evaluation"]["epistemicLevel"] = "rejected" + result = admit_entry(entry) + assert not result.admitted + assert "epistemic_rejected" in result.denials + + +@pytest.mark.parametrize("level", ["proved", "bounded", "empirical", "synthetic", "speculative"]) +def test_valid_epistemic_levels_do_not_deny(level: str): + entry = _admitted() + entry["evaluation"]["epistemicLevel"] = level + result = admit_entry(entry) + assert "epistemic_rejected" not in result.denials + assert "missing_epistemic_label" not in result.denials + + +# ── Gate 7: steering_diff_unsupported ──────────────────────────────────────── + +def test_steering_diff_unsupported_fixture_denies(): + result = validate_file(DENIED_STEERING_PATH) + assert not result.admitted + assert "steering_diff_unsupported" in result.denials + + +@pytest.mark.parametrize("tier", ["full", "local"]) +def test_active_steering_without_diff_denies(tier: str): + entry = _admitted() + entry["interpretability"]["steeringTier"] = tier + entry["interpretability"]["emitsSteeringDiff"] = False + result = admit_entry(entry) + assert not result.admitted + assert "steering_diff_unsupported" in result.denials + + +@pytest.mark.parametrize("tier", ["full", "local"]) +def test_active_steering_with_diff_admits(tier: str): + entry = _admitted() + entry["interpretability"]["steeringTier"] = tier + entry["interpretability"]["emitsSteeringDiff"] = True + result = admit_entry(entry) + assert "steering_diff_unsupported" not in result.denials + + +def test_steering_none_without_diff_does_not_deny(): + entry = _admitted() + entry["interpretability"]["steeringTier"] = "none" + entry["interpretability"]["emitsSteeringDiff"] = False + result = admit_entry(entry) + assert "steering_diff_unsupported" not in result.denials + + +# ── Admission result shape ──────────────────────────────────────────────────── + +def test_admission_result_has_evidence_ref(): + result = validate_file(ADMITTED_PATH) + assert isinstance(result.evidence_ref, str) + assert result.entry_id in result.evidence_ref + + +def test_denial_result_evidence_ref_says_denied(): + entry = _admitted() + entry["evaluation"]["epistemicLevel"] = "rejected" + result = admit_entry(entry) + assert result.evidence_ref is not None + assert "denied" in result.evidence_ref + + +def test_multiple_failures_emit_all_denials(): + entry = _admitted() + entry["evaluation"]["epistemicLevel"] = "rejected" + entry["interpretability"]["steeringTier"] = "full" + entry["interpretability"]["emitsSteeringDiff"] = False + result = admit_entry(entry) + assert not result.admitted + assert "epistemic_rejected" in result.denials + assert "steering_diff_unsupported" in result.denials diff --git a/tools/validate_model_catalog_entry.py b/tools/validate_model_catalog_entry.py new file mode 100644 index 0000000..7753bc5 --- /dev/null +++ b/tools/validate_model_catalog_entry.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +""" +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. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +SCHEMA_PATH = ROOT / "schemas" / "model-catalog-entry.v0.1.schema.json" + +SHA256_RE = re.compile(r"^[0-9a-f]{64}$") + +# Denial reasons match AdmissionDenialReason in the TypeScript contract. +CONTENT_HASH_MISMATCH = "content_hash_mismatch" +ATTESTATION_INVALID = "attestation_invalid" +BASE_VERSION_MISMATCH = "base_version_mismatch" +CAPABILITY_NOT_GRANTED = "capability_not_granted" +MISSING_EPISTEMIC_LABEL = "missing_epistemic_label" +EPISTEMIC_REJECTED = "epistemic_rejected" +STEERING_DIFF_UNSUPPORTED = "steering_diff_unsupported" + +INADMISSIBLE_EPISTEMIC = {"rejected"} +REQUIRES_DIFF = {"full", "local"} + + +@dataclass +class AdmissionResult: + admitted: bool + entry_id: str + denials: list[str] = field(default_factory=list) + evidence_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, + } + if self.evidence_ref: + result["evidenceRef"] = self.evidence_ref + return result + + +def _is_valid_sha256(value: Any) -> bool: + return isinstance(value, str) and bool(SHA256_RE.match(value)) + + +def admit_entry( + entry: dict[str, Any], + *, + delivered_bytes: bytes | None = None, +) -> AdmissionResult: + """ + Admit or deny a catalog entry. All checks are hard gates. + + delivered_bytes: if provided, the artifact content hash is verified against + the delivered payload. Omit for static/structural admission (e.g. CI validation). + The structural checks always run; hash verification runs only when bytes are present. + """ + entry_id = entry.get("id", "") + denials: list[str] = [] + + # ── Gate 1: content_hash_mismatch ────────────────────────────────────── + # Structural: contentHash must be a valid 64-char lowercase hex sha256. + # Runtime: when delivered_bytes are present, verify hash against payload. + artifact = entry.get("artifact", {}) + content_hash = artifact.get("contentHash", "") + if not _is_valid_sha256(content_hash): + denials.append(CONTENT_HASH_MISMATCH) + elif delivered_bytes is not None: + actual = hashlib.sha256(delivered_bytes).hexdigest() + if actual != content_hash: + denials.append(CONTENT_HASH_MISMATCH) + + # Encryption is an invariant, not a flag. + if artifact.get("encrypted") is not True: + denials.append(CONTENT_HASH_MISMATCH) # artifact integrity failure + + # ── 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(): + attest_failures = True + if not isinstance(attestation.get("signature"), str) or not attestation["signature"].strip(): + attest_failures = True + hash_chain = attestation.get("hashChain", []) + if not isinstance(hash_chain, list) or len(hash_chain) < 1: + attest_failures = True + if attest_failures: + denials.append(ATTESTATION_INVALID) + + # ── 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). + kind = entry.get("kind", "") + base_binding = entry.get("baseBinding", {}) + if kind != "base": + if not isinstance(base_binding.get("baseModelId"), str) or not base_binding["baseModelId"].strip(): + denials.append(BASE_VERSION_MISMATCH) + elif not isinstance(base_binding.get("baseVersion"), str) or not base_binding["baseVersion"].strip(): + denials.append(BASE_VERSION_MISMATCH) + elif not _is_valid_sha256(base_binding.get("baseContentHash", "")): + denials.append(BASE_VERSION_MISMATCH) + + # ── 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", []) + if not isinstance(perms, list) or len(perms) == 0: + denials.append(CAPABILITY_NOT_GRANTED) + + # ── Gate 5: missing_epistemic_label ──────────────────────────────────── + evaluation = entry.get("evaluation", {}) + epistemic = evaluation.get("epistemicLevel") + if not isinstance(epistemic, str) or not epistemic.strip(): + denials.append(MISSING_EPISTEMIC_LABEL) + + # ── Gate 6: epistemic_rejected ───────────────────────────────────────── + # Rejected entries are retained for audit but are never loadable. + elif epistemic in INADMISSIBLE_EPISTEMIC: + denials.append(EPISTEMIC_REJECTED) + + # ── 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. + 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) + + admitted = len(denials) == 0 + evidence_ref = ( + f"model-router:admission:{entry_id}:{'admitted' if admitted else 'denied'}" + ) + return AdmissionResult( + admitted=admitted, + entry_id=entry_id, + denials=denials, + evidence_ref=evidence_ref, + ) + + +def validate_file(path: Path, *, delivered_bytes: bytes | None = None) -> AdmissionResult: + entry = json.loads(path.read_text(encoding="utf-8")) + # Strip _comment keys (used in denial fixtures) before processing. + entry = {k: v for k, v in entry.items() if not k.startswith("_")} + return admit_entry(entry, delivered_bytes=delivered_bytes) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Admit or deny a model catalog entry") + parser.add_argument("entry", type=Path, help="Path to the JSON entry file") + parser.add_argument( + "--artifact", type=Path, default=None, + help="Path to the raw artifact bytes for content-hash verification (optional)" + ) + parser.add_argument("--expect-denied", action="store_true", help="Exit 0 only if denied") + args = parser.parse_args() + + delivered_bytes: bytes | None = None + if args.artifact: + delivered_bytes = args.artifact.read_bytes() + + result = validate_file(args.entry, delivered_bytes=delivered_bytes) + print(json.dumps(result.to_dict(), indent=2)) + + if args.expect_denied: + return 0 if not result.admitted else 1 + return 0 if result.admitted else 1 + + +if __name__ == "__main__": + sys.exit(main())