diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f02a8c..e0cf1a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,6 +163,32 @@ jobs: git config --global user.name "ci" bash scripts/test/v2-check-rename.test.sh + - name: Portable core contract tests + env: + # jq 1.6 exactly — core/v1/contracts.jq's canonical-JSON rules depend on this + # exact release's formatting. The ubuntu-latest preinstalled jq is newer, so a + # coder testing locally with a different jq version could pass locally and fail + # here (or vice versa); this pin plus AGENTS.md's local setup keep them in sync. + # SHA-256 is the official jq-1.6 release asset's digest for jq-linux64. + JQ16_SHA256: "af986793a515d500ab2d35f8d2aecd656e764504b789b66d7e1a0b727a124c44" + run: | + set -euo pipefail + mkdir -p /tmp/ystack-jq-1.6 + curl --proto '=https' --tlsv1.2 -fsSL \ + https://github.com/jqlang/jq/releases/download/jq-1.6/jq-linux64 \ + -o /tmp/ystack-jq-1.6/jq + printf '%s %s\n' "$JQ16_SHA256" /tmp/ystack-jq-1.6/jq | sha256sum -c - + chmod 0555 /tmp/ystack-jq-1.6/jq + export PATH="/tmp/ystack-jq-1.6:$PATH" + got="$(jq --version)" + if [ "$got" != "jq-1.6" ]; then + echo "jq version mismatch: expected jq-1.6, got $got"; exit 1 + fi + echo "jq pinned to $got — ok" + # This PATH prepend is scoped to this one step; every other step (including + # Shellcheck's own script sweep above) keeps using the runner's system jq. + bash scripts/test/core-contract.test.sh + - name: Rename gate run: | # Patrols tracked files for old names left over from the rename. diff --git a/AGENTS.md b/AGENTS.md index 432663a..ec9a1cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,9 +30,24 @@ Two goals drive the backlog: (runs the Codex reviewer against a PR). ## Stack & commands -- Markdown + shell. The setup/reviewer tooling lives in `scripts/*.sh`; validators are - still to come. -- CI: `.github/workflows/ci.yml` (structure check + shellcheck). **CI must stay green — +- Markdown + shell. The setup/reviewer tooling lives in `scripts/*.sh`. The first real + validator has landed: `core/v1/contracts.jq` (the portable core contract, a pure jq + schema/relation checker) plus its front door `scripts/core-contract.sh` and its test + suite `scripts/test/core-contract.test.sh` / `scripts/test/core-contract-fixtures.jq`. + It is manual and inactive — nothing in the live profile calls it yet. Run its tests + locally with: + + ```sh + bash scripts/test/core-contract.test.sh + ``` + + This needs **jq 1.6 exactly** on `PATH` first (a newer jq formats JSON differently, + which breaks the canonical-byte checks). Grab the pinned release for your platform + from the jq GitHub releases (e.g. `jq-osx-amd64` on Apple Silicon, run through + Rosetta) and verify its SHA-256 before use; see the header of + `scripts/test/core-contract.test.sh` for the exact download/verify steps. +- CI: `.github/workflows/ci.yml` (structure check + shellcheck + the test suites above, + including the portable core contract's own jq-1.6-pinned step). **CI must stay green — it is the hard merge gate.** Add real tests as code lands. - **Shellcheck is pinned to `0.11.0`** (the `SHELLCHECK_VERSION` constant in `ci.yml` is the single source of truth). CI downloads that exact static release and diff --git a/README.md b/README.md index b45dc7f..ccb5213 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,35 @@ so yshifu's decisions rest on evidence. This is a **prompt-level** wiring: it ta effect once `scripts/install.sh` regenerates the live `/yshifu` command, not merely by merging the doc change — `doctor.sh`'s static validation is unaffected. +## Portable core contract + +`core/v1/contracts.jq` defines the smallest vendor-neutral record family later +adapters can share: five documents (`adapter_manifest`, `profile`, `resolved_profile`, +`stage_request`, `stage_result`), three capabilities, and five permissions — see +[`work/portable-core-contracts/spec.md`](work/portable-core-contracts/spec.md). It is a +**pure, offline validator**: it checks shapes, references, and relations between +caller-supplied documents. It never reads Git, launches a process, calls a model, or +proves that a claim is true — a passing check is not trust or authority. + +Run it through the one public front door, `scripts/core-contract.sh`: + +```sh +scripts/core-contract.sh validate-document DOCUMENT +scripts/core-contract.sh validate-profile-set PROFILE RESOLVED_PROFILE MANIFEST... +scripts/core-contract.sh validate-stage-run REQUEST RESOLVED_PROFILE RESULT +``` + +Every argument is a path to one canonical-JSON document. Success is exit 0 with empty +output; failure is a nonzero exit with one `E_*` token on stderr (see the header comment +in `core/v1/contracts.jq` for the full list). The wrapper requires **jq 1.6 exactly** — +see `scripts/test/core-contract.test.sh` for the pinned-binary setup on Apple Silicon. +Local test command: `bash scripts/test/core-contract.test.sh`. + +This is a **manual, inactive validator** today: nothing in the live profile, `/yshifu`, +or any target template calls it. It does not select, activate, or change any current +behavior — it exists so later profile-resolution and adapter-contract-test work has a +stable, tested vocabulary to build on. + ## Layout ``` @@ -260,6 +289,8 @@ scripts/merge-pr.sh Safe merge harness for the OPERATOR's own use (yshifu scripts/setup-target-repo.sh Bootstrap a target repo's loop labels (idempotent) scripts/lib/north-star.sh Resolver: returns the active target repo's committed .ystack/north-star.md (or root NORTH_STAR.md when ystack itself is the target) scripts/doctor.sh Read-only restore + readiness self-check (install, auth, restore-critical files, north star, model config, ...) +core/v1/contracts.jq Portable core contract v1 (pure jq validator; manual, inactive — see "Portable core contract" above) +scripts/core-contract.sh Shell front door to core/v1/contracts.jq: validate-document / validate-profile-set / validate-stage-run config/models.conf Shipped model-tiering defaults (coder/hands ceilings, gate models/effort) — see "Model policy" below templates/yshifu-command.md Template for the /yshifu command (path placeholder) templates/target-CLAUDE.md Drop into each target repo (conventions + PR-size rule) diff --git a/RESTORE.md b/RESTORE.md index 5220eb0..6f75d14 100644 --- a/RESTORE.md +++ b/RESTORE.md @@ -29,7 +29,10 @@ Accounts and access you need before starting: - **GitHub access** to each target repo, plus the **`gh` CLI authenticated** locally (`gh auth status` should show you logged in) for labels and the loop's `gh` calls. - **`jq` on `PATH`** — the review/debate gates validate Codex's `--json` event stream with it, - and the merge helper parses GitHub check JSON with it. + and the merge helper parses GitHub check JSON with it. The portable core contract + validator (`scripts/core-contract.sh`, section 7 below) additionally requires **jq 1.6 + exactly** — a different version is refused (`E_RUNTIME`), since a newer jq can format or + canonicalize JSON differently. This does not change the `jq` version used elsewhere. - **The personal config you must supply** (keep it parameterized — see the note above): - the **target repo name(s)**, e.g. `/` — the repo(s) the team works in. (ystack is its own target repo; add others as you adopt the team elsewhere.) @@ -324,6 +327,27 @@ These are load-bearing — per the self-modification safety section of --- +## 7. Optional: verify the portable core contract validator + +`core/v1/contracts.jq` + `scripts/core-contract.sh` are a **manual, inactive** +validator — nothing in the restored `/yshifu` command or any target profile calls +them, so this step is optional and does not affect the restore path above. + +To confirm the restored copy still works: + +1. Install **jq 1.6 exactly** (not whatever version your OS ships — see + `scripts/test/core-contract.test.sh` for the pinned `jq-osx-amd64` asset and its + SHA-256, or use the matching official release for your platform). Put it first on + `PATH` for this check. +2. Run `bash scripts/test/core-contract.test.sh`. It builds one small valid + document graph, runs it through `scripts/core-contract.sh`, and asserts at least + 60 positive and mutation cases with zero failures — no Git, network, or `gh` + needed. +3. If it fails, the validator itself is broken; nothing else in the restored team + depends on it, so this does not block the rest of the rebuild. + +--- + ## Troubleshooting / gotchas Real lessons from setting this up: diff --git a/ci/required-files.txt b/ci/required-files.txt index 8a0deee..584629d 100644 --- a/ci/required-files.txt +++ b/ci/required-files.txt @@ -47,6 +47,7 @@ scripts/manager-review.sh scripts/merge-pr.sh scripts/setup-target-repo.sh scripts/doctor.sh +scripts/core-contract.sh # Sourced libraries (not executed directly; sourced by the scripts above) scripts/lib/north-star.sh @@ -85,3 +86,9 @@ scripts/test/north-star-resolver.test.sh scripts/test/north-star-gate.test.sh scripts/test/models-conf-parser.test.sh scripts/test/codex-degraded-gate.test.sh +scripts/test/core-contract.test.sh + +# Portable core contract v1 (manual validator; not part of any live profile — +# see work/portable-core-contracts/plan.md) +core/v1/contracts.jq +scripts/test/core-contract-fixtures.jq diff --git a/core/v1/contracts.jq b/core/v1/contracts.jq new file mode 100644 index 0000000..dc22e67 --- /dev/null +++ b/core/v1/contracts.jq @@ -0,0 +1,952 @@ +# core/v1/contracts.jq — v1 portable core contract validator (pure, offline). +# +# The only product source for v1 shapes, registries, and relational validation +# (spec: work/portable-core-contracts/spec.md; plan: work/portable-core-contracts/plan.md). +# Consumed only through scripts/core-contract.sh, never invoked directly with a +# filesystem path. This file performs no I/O, launches nothing, and never +# dereferences a ref's target — refs are checked as claims (R6), not proven. +# +# Driver contract, built entirely by the shell wrapper from already-parsed JSON and +# externally computed SHA-256 digests (never a raw path): +# {mode: "document" | "profile-set" | "stage-run", +# docs: [{content: , sha256: "<64-hex digest of canonical bytes>"}, ...]} +# docs order by mode: +# document -> [DOCUMENT] +# profile-set -> [PROFILE, RESOLVED_PROFILE, MANIFEST...] (1-8 manifests) +# stage-run -> [REQUEST, RESOLVED_PROFILE, RESULT] +# +# Exit contract: valid input produces no output (this program prints nothing and +# exits 0). Invalid input prints exactly one line with one allowlisted E_* token. +# Validation order is fixed so one mutation has one stable expected token: parsed +# limits -> shape -> ref -> relation. +# E_LIMIT a structural bound is violated (depth/members/string bytes/integer range) +# E_SHAPE a value's own fields/types/enums don't match its schema +# E_REF a reference's target-document identity (kind/id/digest) is wrong +# E_RELATION a rule combining two or more documents/fields is violated +# A real jq runtime error (a bug, not a validation failure) is caught and reported +# as E_RUNTIME rather than crashing — the shell wrapper treats any other jq exit +# or extra output as E_RUNTIME too. + +def utf8_len: explode | map(if . < 128 then 1 elif . < 2048 then 2 elif . < 65536 then 3 else 4 end) | (add // 0); +def is_id: type=="string" and test("^[a-z0-9][a-z0-9._:-]{0,127}$"); +def is_sha256: type=="string" and test("^[0-9a-f]{64}$"); +def is_shorttext: type=="string" and (utf8_len as $l | $l>=1 and $l<=1024); +# is_time: shape (^...$) plus real UTC calendar/field ranges — a well-formed-looking +# string like "2026-99-99T99:99:99Z" or a non-existent calendar date such as +# "2026-02-31T00:00:00Z" / "2025-02-29T00:00:00Z" (non-leap year) must not pass, +# since it later participates in lexical time ordering (R4/time_le) as if it were +# a genuine instant. +def is_time: + type=="string" and + test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$") and + (capture("^(?[0-9]{4})-(?[0-9]{2})-(?[0-9]{2})T(?[0-9]{2}):(?[0-9]{2}):(?[0-9]{2})Z$") as $c | + ($c.y|tonumber) as $y | ($c.mo|tonumber) as $mo | ($c.d|tonumber) as $d | + ($c.h|tonumber) as $h | ($c.mi|tonumber) as $mi | ($c.s|tonumber) as $s | + ($y % 4 == 0 and ($y % 100 != 0 or $y % 400 == 0)) as $leap | + ([31,(if $leap then 29 else 28 end),31,30,31,30,31,31,30,31,30,31]) as $days_in_month | + ($mo>=1 and $mo<=12) and ($d>=1 and $d<=$days_in_month[$mo-1]) and + ($h>=0 and $h<=23) and ($mi>=0 and $mi<=59) and ($s>=0 and $s<=59)); +def is_gitoid: type=="string" and (test("^[0-9a-f]{40}$") or test("^[0-9a-f]{64}$")); +def has_exact_fields(req; opt): + . as $obj | + ($obj|type=="object") and + ((($obj|keys_unsorted)-(req+opt))==[]) and + (all(req[]; . as $k | $obj|has($k))); +def is_bounded_set(mn; mx; item_ok; keyf): + (type=="array") and (length>=mn) and (length<=mx) and (all(.[]; item_ok)) and + ((map(keyf)) as $ks | ($ks|unique|length)==($ks|length) and ($ks==($ks|sort))); +def is_present(item_ok): + (type=="object") and + ((has_exact_fields(["state"];[]) and .state=="absent") or + (has_exact_fields(["state","value"];[]) and .state=="present" and (.value|item_ok))); +def is_version: is_id; +def is_mediatype: type=="string" and (length<=127) and test("^[a-z0-9][a-z0-9!#$&^_.+-]*/[a-z0-9][a-z0-9!#$&^_.+-]*$"); +def is_patch_mediatype: type=="string" and .=="text/x-diff"; +def is_reverse_dns: + type=="string" and + (split(".") as $labels | ($labels|length)>=2 and all($labels[]; test("^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$"))); +# \x{...} codepoint-brace syntax, not bare \xHH: Oniguruma's bare \xHH matches a +# raw byte, so it only ever matches single-byte (ASCII) input under UTF-8 mode — +# \x80-\x9f as bare bytes silently never matches the 2-byte-encoded C1 codepoints +# it's meant to reject. The brace form matches by actual Unicode codepoint. +def is_repopath: + type=="string" and (length>0) and + (test("[\\x{0000}-\\x{001f}\\x{007f}\\x{0080}-\\x{009f}]")|not) and (contains("\\")|not) and + (split("/") as $s | all($s[]; .!="" and .!="." and .!="..")); +def is_bounded_enum_set(mn; mx; allowed): + (type=="array") and (length>=mn) and (length<=mx) and + (all(.[]; . as $v | allowed | index($v)!=null)) and ((unique|length)==length) and (.==(sort)); + +# .as $k | [...]|index($k), not [...]|index(.): the latter reads the piped array +# back as its own needle, which jq's array-in-array indices search always finds at +# 0 — this pre-existing form always returned true and never actually checked +# membership. Same defect class as is_stale_selector above and is_observed_capability below. +def is_document_kind: type=="string" and (. as $k | ["adapter_manifest","profile","resolved_profile","stage_request","stage_result"]|index($k)!=null); +def document_ref_shape: + has_exact_fields(["schema_version","kind","id","sha256"];[]) and + (.schema_version==1) and (.kind|is_document_kind) and (.id|is_id) and (.sha256|is_sha256); +def is_document_ref_kind(k): document_ref_shape and (.kind==k); + +def is_git_revision_ref: + has_exact_fields(["repository_id","hash_algorithm","commit_id"];[]) and + (.repository_id|is_id) and (.hash_algorithm as $h | $h=="sha1" or $h=="sha256") and (.commit_id|is_gitoid) and + (if .hash_algorithm=="sha1" then (.commit_id|test("^[0-9a-f]{40}$")) else (.commit_id|test("^[0-9a-f]{64}$")) end); + +def is_git_location: + (type=="object") and + ((has_exact_fields(["kind"];[]) and .kind=="root") or + (has_exact_fields(["kind","value"];[]) and .kind=="path" and (.value|is_repopath))); + +def is_git_object_ref: + has_exact_fields(["revision","location","object_type","object_id","mode"];[]) and + (.revision|is_git_revision_ref) and (.location|is_git_location) and + (.object_type as $t | $t=="blob" or $t=="tree") and (.object_id|is_gitoid) and + (.mode as $m | ["100644","100755","040000"]|index($m)!=null) and + (if .location.kind=="root" then .object_type=="tree" else true end) and + (if .revision.hash_algorithm=="sha1" then (.object_id|test("^[0-9a-f]{40}$")) else (.object_id|test("^[0-9a-f]{64}$")) end) and + (if .object_type=="tree" then .mode=="040000" else (.mode=="100644" or .mode=="100755") end); + +def is_content_ref: + has_exact_fields(["content_id","media_type","sha256"];[]) and + (.content_id|is_id) and (.content_id|test("/")|not) and (.content_id|test(":")|not) and + (.media_type|is_mediatype) and (.sha256|is_sha256); + +def is_artifact_ref: + (type=="object") and + ((has_exact_fields(["type","value"];[]) and .type=="git-object" and (.value|is_git_object_ref)) or + (has_exact_fields(["type","value"];[]) and .type=="content" and (.value|is_content_ref))); + +def is_input_ref: + (type=="object") and + ((has_exact_fields(["type","value"];[]) and .type=="artifact" and (.value|is_artifact_ref)) or + (has_exact_fields(["type","value"];[]) and .type=="document" and (.value|document_ref_shape))); + +def is_evidence_ref: + has_exact_fields(["stage_result_ref","evidence_id"];[]) and + (.stage_result_ref|is_document_ref_kind("stage_result")) and (.evidence_id|is_id); + +def is_scope_subject: + (type=="object") and + ((has_exact_fields(["type","value"];[]) and .type=="artifact" and (.value|is_artifact_ref)) or + (has_exact_fields(["type","value"];[]) and .type=="document" and (.value|document_ref_shape))); + +def scope_purposes: + ["selection","repository-context","qualification","grant","policy","authority","gate-requirement", + "gate-decision","config-contract","output-contract","allowed-delta","verification-plan", + "review-policy","finish-condition","verification-instructions"]; + +def is_scope_ref: + has_exact_fields(["purpose","decision_record_ref","subject_ref","scope_sha256"];[]) and + (.purpose as $p | scope_purposes|index($p)!=null) and + (.decision_record_ref|is_content_ref) and (.subject_ref|is_scope_subject) and (.scope_sha256|is_sha256); +def is_scope_ref_purpose(p): is_scope_ref and (.purpose==p); + +def adapter_roles: ["producer","verifier","reviewer","publisher","forge","ci","execution","identity"]; +def actor_roles_all: adapter_roles + ["operator","manager","orchestrator","observer"]; +def execution_kinds: ["model","deterministic"]; + +def is_actor_ref: + has_exact_fields( + ["role","implementation_id","implementation_version","adapter_instance_id","principal_id","execution_boundary_id"]; + ["authority_ref"]) and + (.role as $r | actor_roles_all|index($r)!=null) and (.implementation_id|is_id) and + (.implementation_version|is_version) and (.adapter_instance_id|is_id) and (.principal_id|is_id) and + (.execution_boundary_id|is_id) and + ((has("authority_ref")|not) or (.authority_ref|is_scope_ref_purpose("authority"))); + +def is_environment_ref: + has_exact_fields(["environment_id","fingerprint_sha256"];[]) and (.environment_id|is_id) and (.fingerprint_sha256|is_sha256); + +def is_tool_ref: + has_exact_fields(["tool_id","tool_version","package_ref","config_ref"];[]) and + (.tool_id|is_id) and (.tool_version|is_version) and (.package_ref|is_git_object_ref) and + (.config_ref|is_present(is_git_object_ref)); + +def is_git_patch_ref: is_content_ref and (.media_type|is_patch_mediatype); + +def is_change_ref: + has_exact_fields(["repository_id","base","head","delta_ref"];[]) and + (.repository_id|is_id) and (.base|is_present(is_git_revision_ref)) and (.head|is_git_revision_ref) and + (.delta_ref|is_git_patch_ref) and (.head.repository_id==.repository_id) and + (if (.base.state=="present") then (.base.value.repository_id==.repository_id) else true end); + +def is_source_value_ref: + has_exact_fields(["source","value_format","value_sha256"];[]) and (.source|is_git_object_ref) and + (.value_format as $f | $f=="raw-bytes" or $f=="canonical-json") and (.value_sha256|is_sha256) and + (if .value_format=="canonical-json" then .source.object_type=="blob" else true end); + +def git_key: [.revision.repository_id,.revision.hash_algorithm,.revision.commit_id,.location.kind,(.location.value // ""),.object_type,.object_id,.mode]; +def source_git_key: .source|git_key; + + +def capability_ids: ["core.harness.produce.v1","core.verify.run.v1","core.review.change.v1"]; +def permission_ids: + ["core.perm.target.read.v1","core.perm.scratch.write.v1","core.perm.candidate.execute.v1", + "core.perm.evidence.write.v1","core.perm.model.invoke.v1"]; +def protected_roles: ["producer","verifier","reviewer","publisher"]; +def capability_for_role(role): + if role=="producer" then "core.harness.produce.v1" + elif role=="verifier" then "core.verify.run.v1" + elif role=="reviewer" then "core.review.change.v1" + else null end; +def permissions_for_capability(cap; is_model): + if cap=="core.harness.produce.v1" then + (["core.perm.target.read.v1","core.perm.scratch.write.v1","core.perm.evidence.write.v1"] + + (if is_model then ["core.perm.model.invoke.v1"] else [] end)) + elif cap=="core.verify.run.v1" then + ["core.perm.target.read.v1","core.perm.candidate.execute.v1","core.perm.evidence.write.v1"] + elif cap=="core.review.change.v1" then + (["core.perm.target.read.v1","core.perm.evidence.write.v1"] + + (if is_model then ["core.perm.model.invoke.v1"] else [] end)) + else null end; +def required_evidence_kinds_for_capability(cap): + if cap=="core.harness.produce.v1" then ["deterministic"] + elif cap=="core.review.change.v1" then ["independent-review"] + else null end; + +def is_model_request: + has_exact_fields(["provider_id","model_id","effort_id"];[]) and + (.provider_id|is_id) and (.model_id|is_id) and (.effort_id|is_id); + +def is_delivered_scope(purpose): + has_exact_fields(["ref","input_id"];[]) and (.ref|is_scope_ref_purpose(purpose)) and (.input_id|is_id) and + (.ref.subject_ref.type=="artifact") and (.ref.subject_ref.value.type=="content"); + +# delivered_scope_input_ok($ds; $ins): closes a delivered_scope over its request's +# named inputs (R9's launch seam) — ds.input_id must select exactly one named +# input whose value equals the scope's complete subject, and the underlying +# payload media type must be one the launcher can deliver as text/JSON bytes. +# $-bound (value, not filter) params: this is called from inside all(.[]; ...), +# and a plain filter param would be re-evaluated against that inner "." instead +# of staying pinned to the scope/inputs passed at the call site. +def instruction_media_type_ok: .=="text/plain" or .=="application/json"; +def delivered_scope_input_ok($ds; $ins): + ($ins | map(select(.input_id == $ds.input_id))) as $matches | + (($matches|length) == 1) and ($matches[0].value == $ds.ref.subject_ref) and + (($ds.ref.subject_ref.value.value.media_type) | instruction_media_type_ok); + +def is_capability_args(cap): + (type=="object") and + (if cap=="core.harness.produce.v1" then + (has_exact_fields(["artifact_kind","output_contract"];[]) and + (.artifact_kind as $k | $k=="plan" or $k=="structured-artifact") and + (.output_contract|is_delivered_scope("output-contract"))) + or + (has_exact_fields(["artifact_kind","allowed_delta"];[]) and (.artifact_kind=="git-patch") and + (.allowed_delta|is_delivered_scope("allowed-delta"))) + elif cap=="core.verify.run.v1" then + has_exact_fields(["candidate_input_id","verification_plan","network_mode"];[]) and + (.candidate_input_id|is_id) and (.verification_plan|is_delivered_scope("verification-plan")) and + (.network_mode=="deny") + elif cap=="core.review.change.v1" then + has_exact_fields(["change_ref","review_policy"];[]) and (.change_ref|is_change_ref) and + (.review_policy|is_delivered_scope("review-policy")) + else false end); + +def is_adapter_manifest_body: + has_exact_fields( + ["adapter_version","package_ref","offered_roles","offered_execution_kinds", + "offered_capabilities","offered_permissions","offered_tools"]; + ["config_contract_ref"]) and + (.adapter_version|is_version) and (.package_ref|is_git_object_ref) and + (.offered_roles|is_bounded_enum_set(1;8;adapter_roles)) and + (.offered_execution_kinds|is_bounded_enum_set(1;2;execution_kinds)) and + (.offered_capabilities|is_bounded_enum_set(0;3;capability_ids)) and + (.offered_permissions|is_bounded_enum_set(0;5;permission_ids)) and + (.offered_tools|is_bounded_set(0;32;is_tool_ref;.tool_id)) and + ((has("config_contract_ref")|not) or (.config_contract_ref|is_scope_ref_purpose("config-contract"))); + + +def is_profile_binding: + has_exact_fields( + ["binding_id","role","manifest_ref","execution_kind","adapter_instance_id","principal_id", + "execution_boundary_id","package_ref","skill_refs","requested_tools", + "requested_capabilities","requested_permissions"]; + ["authority_ref","config_ref","prompt_ref","model_request"]) and + (.binding_id|is_id) and (.role as $r | adapter_roles|index($r)!=null) and + (.manifest_ref|is_document_ref_kind("adapter_manifest")) and + (.execution_kind as $e | execution_kinds|index($e)!=null) and + (.adapter_instance_id|is_id) and (.principal_id|is_id) and (.execution_boundary_id|is_id) and + ((has("authority_ref")|not) or (.authority_ref|is_scope_ref_purpose("authority"))) and + (.package_ref|is_git_object_ref) and + ((has("config_ref")|not) or (.config_ref|is_git_object_ref)) and + ((has("prompt_ref")|not) or (.prompt_ref|is_git_object_ref)) and + (.skill_refs|is_bounded_set(0;32;is_git_object_ref;git_key)) and + (.requested_tools|is_bounded_set(0;32;is_tool_ref;.tool_id)) and + ((has("model_request")|not) or (.model_request|is_model_request)) and + (.requested_capabilities|is_bounded_enum_set(0;1;capability_ids)) and + (.requested_permissions|is_bounded_enum_set(0;5;permission_ids)) and + (if .execution_kind=="model" then (has("model_request") and has("prompt_ref")) + else ((has("model_request")|not) and (has("prompt_ref")|not) and (.skill_refs==[])) end) and + # The contract permits model backing only for producer and reviewer; every other + # (dormant) role — verifier, publisher, forge, ci, execution, identity — must be + # deterministic. + (if .role=="producer" or .role=="reviewer" then true else .execution_kind=="deterministic" end); + +def profile_binding_capability_ok: + (capability_for_role(.role)) as $expected | + if $expected!=null then + (.requested_capabilities==[$expected]) and + ((.requested_permissions|sort)==(permissions_for_capability($expected; .execution_kind=="model")|sort)) + else (.requested_capabilities==[]) and (.requested_permissions==[]) end; + +def is_profile_body: + has_exact_fields(["profile_version","bindings"];[]) and (.profile_version|is_version) and + (.bindings|is_bounded_set(4;8;is_profile_binding;.binding_id)) and + (.bindings|all(.[]; profile_binding_capability_ok)); + +def profile_protected_roles_ok: + (.bindings) as $bs | + (all(protected_roles[]; . as $role | ([$bs[]|select(.role==$role)]|length)==1)) and + ([$bs[]|select(.role as $r | protected_roles|index($r)!=null)]) as $ps | + (all($ps[]; has("authority_ref"))) and + (($ps|map(.binding_id)|unique|length)==($ps|length)) and + (($ps|map(.adapter_instance_id)|unique|length)==($ps|length)) and + (($ps|map(.principal_id)|unique|length)==($ps|length)) and + (($ps|map(.execution_boundary_id)|unique|length)==($ps|length)) and + (($ps|map(.authority_ref.scope_sha256)|unique|length)==($ps|length)) and + (($bs|map(select(.role as $r | protected_roles|index($r)==null))|map(.role))|(unique|length)==length); + +# resolved_bindings_profile_invariants_ok: closes the gap where a resolved profile +# is checked (or trusted, inside a stage-run) on its own, without ever passing +# through mode_profile_set's checks against the plain profile it was resolved +# from. Re-applies the same capability and protected-role invariants +# (profile_binding_capability_ok / profile_protected_roles_ok) to the resolved +# profile's own embedded binding set, so an embedded producer binding with empty +# requested_capabilities/requested_permissions, or protected bindings sharing a +# principal/execution boundary, cannot slip past validate-document or +# validate-stage-run just because they arrived pre-resolved. Reuses the existing +# predicates rather than duplicating them. +def resolved_bindings_profile_invariants_ok(resolved_body): + (resolved_body.bindings | map(.binding)) as $embedded_bindings | + ({bindings: $embedded_bindings} | profile_protected_roles_ok) and + ($embedded_bindings | all(.[]; profile_binding_capability_ok)); + +def is_tool_source: + has_exact_fields(["tool_id","package_source","config_source"];[]) and (.tool_id|is_id) and + (.package_source|is_source_value_ref) and (.config_source|is_present(is_source_value_ref)); + +def is_resolved_binding: + has_exact_fields( + ["binding","adapter_implementation","manifest_source","package_source","config_source", + "prompt_source","skill_sources","tool_sources"]; []) and + (.binding|is_profile_binding) and + (.adapter_implementation|has_exact_fields(["id","version"];[]) and (.id|is_id) and (.version|is_version)) and + (.manifest_source|is_source_value_ref) and (.manifest_source.value_format=="canonical-json") and + (.package_source|is_source_value_ref) and + (.config_source|is_present(is_source_value_ref)) and + (.prompt_source|is_present(is_source_value_ref)) and + (.skill_sources|is_bounded_set(0;32;is_source_value_ref;source_git_key)) and + (.tool_sources|is_bounded_set(0;32;is_tool_source;.tool_id)); + +def is_resolved_profile_body: + has_exact_fields(["profile_ref","profile_source","selection_ref","repository_context_ref","bindings"];[]) and + (.profile_ref|is_document_ref_kind("profile")) and + (.profile_source|is_source_value_ref) and (.profile_source.value_format=="canonical-json") and + (.selection_ref|is_scope_ref_purpose("selection")) and + (.repository_context_ref|is_scope_ref_purpose("repository-context")) and + (.bindings|is_bounded_set(4;8;is_resolved_binding;.binding.binding_id)); + +# --- validate-profile-set relations (E_RELATION) --- + +def find_one(arr; pred): [arr[] | select(pred)]; + +# tool_ref's config_ref uses present; profile_binding/resolved's config_ref is a +# plain optional field (present only via `has`). Keep the two conventions distinct. +def binding_manifest_relation_ok(b; m; rb): + (m.body.offered_roles | index(b.role) != null) and + (m.body.offered_execution_kinds | index(b.execution_kind) != null) and + (b.requested_capabilities | all(.[]; . as $c | m.body.offered_capabilities | index($c) != null)) and + (b.requested_permissions | all(.[]; . as $p | m.body.offered_permissions | index($p) != null)) and + (b.package_ref == m.body.package_ref) and + (rb.package_source.source == b.package_ref) and + (b.requested_tools | all(.[]; . as $rt | m.body.offered_tools | any(.[]; . == $rt))) and + ((rb.tool_sources | map(.tool_id) | sort) == (b.requested_tools | map(.tool_id) | sort)) and + (rb.tool_sources | all(.[]; + . as $ts | + (b.requested_tools[] | select(.tool_id == $ts.tool_id)) as $rt | + ($ts.package_source.source == $rt.package_ref) and + (($ts.config_source.state == "present") == ($rt.config_ref.state == "present")) and + (if $ts.config_source.state == "present" then $ts.config_source.value.source == $rt.config_ref.value else true end))) and + (if (b | has("config_ref")) then (m.body | has("config_contract_ref")) else true end) and + ((rb.config_source.state == "present") == (b | has("config_ref"))) and + (if (b | has("config_ref")) then rb.config_source.value.source == b.config_ref else true end) and + ((rb.prompt_source.state == "present") == (b | has("prompt_ref"))) and + (if (b | has("prompt_ref")) then rb.prompt_source.value.source == b.prompt_ref else true end) and + ((rb.skill_sources | map(source_git_key) | sort) == (b.skill_refs | map(git_key) | sort)) and + (rb.adapter_implementation.id == m.id) and (rb.adapter_implementation.version == m.body.adapter_version); + +def present_source_value(p): if p.state=="present" then [p.value] else [] end; + +# resolved_binding_source_claims: every source_value_ref a resolved binding makes +# (manifest/package/optional config/optional prompt/skills/each tool's optional +# package+config), used to enforce one format/digest claim per exact source Git +# object across the whole resolved profile (R6/one-claim-per-source), not just +# within a single binding. +def resolved_binding_source_claims(rb): + [rb.manifest_source, rb.package_source] + + present_source_value(rb.config_source) + present_source_value(rb.prompt_source) + + rb.skill_sources + + (rb.tool_sources | map([.package_source] + present_source_value(.config_source)) | add // []); + +def resolved_profile_source_claims(resolved_body): + [resolved_body.profile_source] + + (resolved_body.bindings | map(resolved_binding_source_claims(.)) | add // []); + +def source_claims_agree(resolved_body): + (resolved_profile_source_claims(resolved_body) | group_by(source_git_key)) as $groups | + all($groups[]; (map(.value_format) | unique | length) == 1 and (map(.value_sha256) | unique | length) == 1); + +def profile_set_relations_ok(profile_pair; resolved_body; manifests): + (profile_pair.content.body) as $profile_body | + ($profile_body.bindings) as $bindings | + (resolved_body.bindings) as $rbindings | + (resolved_body.profile_source.value_sha256 == profile_pair.sha256) and + (source_claims_agree(resolved_body)) and + (all($bindings[]; . as $b | + (find_one(manifests; .content.id == $b.manifest_ref.id and .sha256 == $b.manifest_ref.sha256) | length) == 1)) and + (all(manifests[]; . as $m | + $bindings | any(.[]; .manifest_ref.id == $m.content.id and .manifest_ref.sha256 == $m.sha256))) and + ((($rbindings | map(.binding.binding_id) | sort)) == (($bindings | map(.binding_id) | sort))) and + (all($bindings[]; + . as $b | + (find_one(manifests; .content.id == $b.manifest_ref.id and .sha256 == $b.manifest_ref.sha256)[0]) as $mpair | + ($mpair.content) as $mdoc | + (find_one($rbindings; .binding.binding_id == $b.binding_id)[0]) as $rb | + ($rb.binding == $b) and ($rb.manifest_source.value_sha256 == $mpair.sha256) and + binding_manifest_relation_ok($b; $mdoc; $rb))); + + + + +def is_named_input: + has_exact_fields(["input_id","value"];[]) and (.input_id|is_id) and (.value|is_input_ref); + +def risk_tier_ok: + (type=="object") and + ((has_exact_fields(["namespace","name"];[]) and .namespace=="core" and (.name=="routine" or .name=="high" or .name=="bootstrap")) or + (has_exact_fields(["namespace","name"];[]) and (.namespace|is_reverse_dns) and (.name|is_id))); + +def is_risk_claim: + has_exact_fields(["tier","reason_ids","policy_ref","required_gate_refs"];[]) and + (.tier|risk_tier_ok) and (.reason_ids|is_bounded_set(1;256;is_id;.)) and + (.policy_ref|is_scope_ref_purpose("policy")) and + (.required_gate_refs|is_bounded_set(0;256;is_scope_ref_purpose("gate-requirement");.scope_sha256)); + +def is_operation: + has_exact_fields(["role","binding_id","capability_id","permissions","arguments"];[]) and + (.role as $r | adapter_roles|index($r)!=null) and (.binding_id|is_id) and + (.capability_id as $c | capability_ids|index($c)!=null) and + (.permissions|is_bounded_enum_set(1;5;permission_ids)) and + ((.capability_id) as $cap | .arguments | is_capability_args($cap)); + +def is_stage_request_body: + has_exact_fields( + ["initiative_id","workflow_id","stage_id","task_class_id","requested_by","target_repository_id", + "target_revision","source","base","inputs","prior_evidence_refs","risk","resolved_profile_ref", + "selection_ref","repository_context_ref","gate_decision_refs","environment_ref","operation", + "finish_condition","verification_instruction","required_evidence_kinds","requested_at"]; + ["qualification_ref","grant_ref"]) and + (.initiative_id|is_id) and (.workflow_id|is_id) and (.stage_id|is_id) and (.task_class_id|is_id) and + (.requested_by|is_actor_ref) and (.target_repository_id|is_id) and + (.target_revision|is_present(is_git_revision_ref)) and (.source|is_present(is_artifact_ref)) and + (.base|is_present(is_git_revision_ref)) and + (.inputs|is_bounded_set(0;256;is_named_input;.input_id)) and + (.prior_evidence_refs|is_bounded_set(0;256;is_evidence_ref;[.stage_result_ref.sha256,.evidence_id])) and + (.risk|is_risk_claim) and + (.resolved_profile_ref|is_document_ref_kind("resolved_profile")) and + (.selection_ref|is_scope_ref_purpose("selection")) and + (.repository_context_ref|is_scope_ref_purpose("repository-context")) and + ((has("qualification_ref")|not) or (.qualification_ref|is_scope_ref_purpose("qualification"))) and + ((has("grant_ref")|not) or (.grant_ref|is_scope_ref_purpose("grant"))) and + (.gate_decision_refs|is_bounded_set(0;256;is_scope_ref_purpose("gate-decision");.scope_sha256)) and + (.environment_ref|is_environment_ref) and (.operation|is_operation) and + (.finish_condition|is_delivered_scope("finish-condition")) and + (.verification_instruction|is_delivered_scope("verification-instructions")) and + (.required_evidence_kinds|is_bounded_enum_set(1;3;["deterministic","behavioral","architecture","independent-review"])) and + (.requested_at|is_time) and + (if (.target_revision.state=="absent") then + (.operation.role=="producer") and (.risk.tier=={namespace:"core",name:"bootstrap"}) + else true end) and + (if .operation.capability_id=="core.verify.run.v1" then + (.operation.arguments) as $args | + (.inputs|any(.[]; .input_id==$args.candidate_input_id and .value.type=="artifact" and .value.value.type=="git-object" and + .value.value.value.object_type=="tree")) and + (.target_revision.state=="present") and + (((.inputs[]|select(.input_id==$args.candidate_input_id)).value.value.value.revision)==.target_revision.value) + else true end) and + (if .operation.capability_id=="core.review.change.v1" then + (.target_revision.state=="present") and (.operation.arguments.change_ref.head==.target_revision.value) and + (.operation.arguments.change_ref.base==.base) + else true end) and + (if .operation.capability_id=="core.verify.run.v1" then (.required_evidence_kinds|index("deterministic")!=null) + else ((.required_evidence_kinds|sort)==(required_evidence_kinds_for_capability(.operation.capability_id)|sort)) end) and + (.target_repository_id) as $repo | + (if .target_revision.state=="present" then .target_revision.value.repository_id==$repo else true end) and + (if .base.state=="present" then .base.value.repository_id==$repo else true end) and + (if .source.state=="present" and .source.value.type=="git-object" then .source.value.value.revision.repository_id==$repo else true end) and + (.inputs | all(.[]; + if .value.type=="artifact" and .value.value.type=="git-object" then .value.value.value.revision.repository_id==$repo else true end)) and + (if .operation.capability_id=="core.review.change.v1" then .operation.arguments.change_ref.repository_id==$repo else true end) and + (.inputs) as $ins | + ((.operation.arguments) as $args | + (if ($args|has("output_contract")) then $args.output_contract + elif ($args|has("allowed_delta")) then $args.allowed_delta + elif ($args|has("verification_plan")) then $args.verification_plan + else $args.review_policy end) as $cap_scope | + [.finish_condition, .verification_instruction, $cap_scope] as $scopes | + ($scopes | map(.input_id)) as $ids | + (($ids|unique|length) == ($ids|length)) and + (if .operation.capability_id=="core.verify.run.v1" then ($ids|index($args.candidate_input_id)==null) else true end) and + ($scopes | all(.[]; delivered_scope_input_ok(.; $ins)))); + +# resolved_profile_pair is the driver's {content,sha256} pair for the supplied +# resolved_profile document (never dereferenced beyond its own supplied bytes). +def stage_request_relations_ok(request_body; resolved_profile_pair): + (resolved_profile_pair.content) as $rp | + (request_body.resolved_profile_ref.id == $rp.id) and + (request_body.resolved_profile_ref.sha256 == resolved_profile_pair.sha256) and + (request_body.selection_ref == $rp.body.selection_ref) and + (request_body.repository_context_ref == $rp.body.repository_context_ref) and + ((find_one($rp.body.bindings; .binding.binding_id == request_body.operation.binding_id) | length) == 1) and + (find_one($rp.body.bindings; .binding.binding_id == request_body.operation.binding_id)[0].binding) as $b | + ($b.role == request_body.operation.role) and + (capability_for_role($b.role) == request_body.operation.capability_id) and + ((request_body.operation.permissions | sort) == (permissions_for_capability(request_body.operation.capability_id; $b.execution_kind == "model") | sort)); + + + + +def is_actual_binding: + has_exact_fields( + ["binding_id","role","adapter_implementation","manifest_ref","package_ref","config_ref", + "execution_kind","adapter_instance_id","principal_id","execution_boundary_id"]; + ["authority_ref"]) and + (.binding_id|is_id) and (.role as $r | adapter_roles|index($r)!=null) and + (.adapter_implementation|has_exact_fields(["id","version"];[]) and (.id|is_id) and (.version|is_version)) and + (.manifest_ref|is_document_ref_kind("adapter_manifest")) and (.package_ref|is_git_object_ref) and + (.config_ref|is_present(is_git_object_ref)) and + (.execution_kind as $e | execution_kinds|index($e)!=null) and + (.adapter_instance_id|is_id) and (.principal_id|is_id) and (.execution_boundary_id|is_id) and + ((has("authority_ref")|not) or (.authority_ref|is_scope_ref_purpose("authority"))); + +def is_observed_capability: + (type=="object") and + ((has_exact_fields(["kind","id"];[]) and .kind=="registered" and (.id as $c | capability_ids|index($c)!=null)) or + (has_exact_fields(["kind","id"];[]) and .kind=="unclassified" and (.id|is_id) and (.id as $c | capability_ids|index($c)==null))); + +def is_fact(item_ok): + (type=="object") and + ((has_exact_fields(["state","value","source_ref"];[]) and .state=="recorded" and (.value|item_ok) and (.source_ref|is_content_ref)) or + (has_exact_fields(["state","value","source_ref"];[]) and .state=="computed" and (.value|item_ok) and (.source_ref|is_content_ref)) or + (has_exact_fields(["state","reason_id"];[]) and .state=="unavailable" and (.reason_id|is_id)) or + (has_exact_fields(["state"];[]) and .state=="not-applicable")); + +def is_execution_metadata: + has_exact_fields(["kind","provider","model","snapshot","effort","prompt","skills","tools"];[]) and + (.kind as $k | execution_kinds|index($k)!=null) and + (.provider|is_fact(is_id)) and (.model|is_fact(is_id)) and (.snapshot|is_fact(is_id)) and + (.effort|is_fact(is_id)) and (.prompt|is_fact(is_git_object_ref)) and + (.skills|is_fact(is_bounded_set(0;32;is_git_object_ref;git_key))) and + (.tools|is_fact(is_bounded_set(0;32;is_tool_ref;.tool_id))) and + (.tools.state != "not-applicable") and + (if .kind=="deterministic" then + ([.provider,.model,.snapshot,.effort,.prompt,.skills] | all(.[]; .state=="not-applicable")) + else + ([.provider,.model,.snapshot,.effort,.prompt,.skills] | + all(.[]; .state=="recorded" or .state=="computed" or .state=="unavailable")) + end); + +def is_execution: + has_exact_fields(["performer","actual_binding","environment","used_capability","metadata"];[]) and + (.performer|is_actor_ref) and (.actual_binding|is_actual_binding) and (.environment|is_environment_ref) and + (.used_capability|is_observed_capability) and (.metadata|is_execution_metadata) and + (.metadata.kind==.actual_binding.execution_kind) and (.performer.role==.actual_binding.role); + +def evidence_kinds: ["deterministic","behavioral","architecture","independent-review"]; +def v_verdicts: ["passed","failed","inconclusive"]; + +def is_evidence: + has_exact_fields(["evidence_id","kind","verdict","proof_ref"];[]) and + (.evidence_id|is_id) and (.kind as $k | evidence_kinds|index($k)!=null) and + (.verdict as $v | v_verdicts|index($v)!=null) and (.proof_ref|is_content_ref); + +def terminal_statuses: ["completed","skipped","stale","blocked","failed","cancelled"]; + +def is_reason: + has_exact_fields(["reason_id"];["summary"]) and (.reason_id|is_id) and + ((has("summary")|not) or (.summary|is_shorttext)); + +def is_output: + has_exact_fields(["output_id","ref"];[]) and (.output_id|is_id) and (.ref|is_content_ref); + +# Note the `.kind as $k | [...] | index($k)` form below, not `[...] | index(.kind)`: +# the latter reads .kind off the piped-in array (a pre-existing latent bug — every +# selector using this branch, i.e. everything but "input"/"gate-decision", crashed +# with E_RUNTIME instead of validating), since a pipe rebinds "." to its left side. +def is_stale_selector: + (type=="object") and + ((has_exact_fields(["kind"];[]) and (.kind as $k | ["target","source","base","resolved-profile","qualification","environment"]|index($k)!=null)) or + (has_exact_fields(["kind","input_id"];[]) and .kind=="input" and (.input_id|is_id)) or + (has_exact_fields(["kind","scope_sha256"];[]) and .kind=="gate-decision" and (.scope_sha256|is_sha256))); + +def stale_observed_ok: + (.selector.kind) as $k | + if $k=="target" then (.observed|is_present(is_git_revision_ref)) + elif $k=="source" then (.observed|is_present(is_artifact_ref)) + elif $k=="base" then (.observed|is_present(is_git_revision_ref)) + elif $k=="resolved-profile" then (.observed|is_present(is_document_ref_kind("resolved_profile"))) + elif $k=="qualification" then (.observed|is_present(is_scope_ref_purpose("qualification"))) + elif $k=="environment" then (.observed|is_present(is_environment_ref)) + elif $k=="input" then (.observed|is_present(is_input_ref)) + elif $k=="gate-decision" then (.observed|is_present(is_scope_ref_purpose("gate-decision"))) + else false end; + +def is_stale_observation: + has_exact_fields(["selector","observed"];[]) and (.selector|is_stale_selector) and stale_observed_ok; + +# stale_observation_ok($req_body; $obs): derives the expected value from the exact +# request slot/set member the selector names and requires the claimed observation +# to differ from it (Design > "Stage result and evidence"). Also requires an +# input/gate-decision selector to actually name something the request carries — +# a selector for nothing is not a stale claim about anything. $-bound params for +# the same reason as delivered_scope_input_ok above (called from all(.[]; ...)). +def stale_observation_ok($req_body; $obs): + ($obs.selector.kind) as $k | + if $k=="target" then + ($req_body.target_revision) as $exp | + ($obs.observed != $exp) and + (if $obs.observed.state=="present" then $obs.observed.value.repository_id==$req_body.target_repository_id else true end) + elif $k=="base" then + ($req_body.base) as $exp | + ($obs.observed != $exp) and + (if $obs.observed.state=="present" then + ($obs.observed.value.repository_id==$req_body.target_repository_id) and + (if $exp.state=="present" then $obs.observed.value.hash_algorithm==$exp.value.hash_algorithm else true end) + else true end) + elif $k=="source" then + ($req_body.source) as $exp | + ($obs.observed != $exp) and + (if ($obs.observed.state=="present" and $obs.observed.value.type=="git-object") then + $obs.observed.value.value.revision.repository_id==$req_body.target_repository_id + else true end) + elif $k=="resolved-profile" then + ({state:"present", value: $req_body.resolved_profile_ref}) as $exp | + ($obs.observed != $exp) and + (if $obs.observed.state=="present" then + $obs.observed.value.kind=="resolved_profile" and $obs.observed.value.id==$req_body.resolved_profile_ref.id + else true end) + elif $k=="qualification" then + ((if ($req_body|has("qualification_ref")) then {state:"present", value: $req_body.qualification_ref} else {state:"absent"} end)) as $exp | + ($obs.observed != $exp) + elif $k=="environment" then + ({state:"present", value: $req_body.environment_ref}) as $exp | + ($obs.observed != $exp) and + (if $obs.observed.state=="present" then $obs.observed.value.environment_id==$req_body.environment_ref.environment_id else true end) + elif $k=="input" then + ($req_body.inputs | map(select(.input_id==$obs.selector.input_id))) as $matches | + (($matches|length)==1) and + (({state:"present", value:$matches[0].value}) as $exp | $obs.observed != $exp) + elif $k=="gate-decision" then + ($req_body.gate_decision_refs | map(select(.scope_sha256==$obs.selector.scope_sha256))) as $matches | + (($matches|length)==1) and + (({state:"present", value:$matches[0]}) as $exp | $obs.observed != $exp) + else false end; + +def outcome_family_values(family): + if family=="change" then ["changed","no-change","inconclusive"] + elif family=="check" then ["passed","failed","inconclusive"] + else null end; + +def is_outcome: + has_exact_fields(["family","value"];[]) and (.family as $f | $f=="change" or $f=="check") and + (.value as $v | outcome_family_values(.family)|index($v)!=null); + +def is_id_int: type=="number" and (.==(.|floor)) and .>=1 and .<=2147483647; + +def is_stage_result_body: + has_exact_fields( + ["request_ref","resolved_profile_ref","attempt_id","attempt_number","reported_by","status", + "outputs","diagnostics","evidence","recorded_at"]; + ["outcome","reason","stale_observations","delta_ref","execution","started_at","finished_at"]) and + (.request_ref|is_document_ref_kind("stage_request")) and + (.resolved_profile_ref|is_document_ref_kind("resolved_profile")) and + (.attempt_id|is_id) and (.attempt_number|is_id_int) and (.reported_by|is_actor_ref) and + (.status as $s | terminal_statuses|index($s)!=null) and + ((has("outcome")|not) or (.outcome|is_outcome)) and + ((has("reason")|not) or (.reason|is_reason)) and + ((has("stale_observations")|not) or + (.stale_observations|is_bounded_set(1;256;is_stale_observation;[.selector.kind,(.selector.input_id // .selector.scope_sha256 // "")]))) and + (.outputs|is_bounded_set(0;256;is_output;.output_id)) and + ((has("delta_ref")|not) or (.delta_ref|is_git_patch_ref)) and + (.diagnostics|is_bounded_set(0;256;is_content_ref;.content_id)) and + ((has("execution")|not) or (.execution|is_execution)) and + (.evidence|is_bounded_set(0;256;is_evidence;.evidence_id)) and + ((has("started_at")|not) or (.started_at|is_time)) and + ((has("finished_at")|not) or (.finished_at|is_time)) and + (.recorded_at|is_time) and + ((.evidence|map(.kind)|unique|length)==(.evidence|length)); + + +def outcome_family_for_role(role): if role=="producer" then "change" else "check" end; + +# Status presence matrix (Design > "Stage result and evidence" table). cap_family is +# the requesting operation's outcome family ("change" for producer, "check" otherwise). +def status_presence_ok(cap_family): + .status as $s | + if $s=="completed" then + has("execution") and has("outcome") and has("started_at") and has("finished_at") and + (.diagnostics==[]) and (has("stale_observations")|not) and + ((.outcome.value=="inconclusive") == has("reason")) + elif $s=="skipped" then + has("reason") and (.outputs==[]) and (.diagnostics==[]) and (.evidence==[]) and + (has("stale_observations")|not) and (has("execution")|not) and (has("outcome")|not) and + (has("delta_ref")|not) and (has("started_at")|not) and (has("finished_at")|not) + elif $s=="stale" then + has("reason") and has("stale_observations") and (.outputs==[]) and (.diagnostics==[]) and (.evidence==[]) and + (has("execution")|not) and (has("outcome")|not) and (has("delta_ref")|not) and + (has("started_at")|not) and (has("finished_at")|not) + elif $s=="blocked" then + has("reason") and (.outputs==[]) and (.evidence==[]) and (has("execution")|not) and (has("outcome")|not) and + (has("delta_ref")|not) and (has("stale_observations")|not) and (has("started_at")|not) and (has("finished_at")|not) + elif $s=="failed" or $s=="cancelled" then + has("reason") and (.outputs==[]) and (has("delta_ref")|not) and (has("stale_observations")|not) and + (if $s=="failed" then (.diagnostics|length)>0 else true end) and + (if has("execution") then + has("outcome") and has("started_at") and has("finished_at") and (.evidence|length)>0 and + (.outcome.family==cap_family) and (.outcome.value=="inconclusive") and + (.evidence|all(.[]; .verdict=="failed" or .verdict=="inconclusive")) + else + (has("outcome")|not) and (has("started_at")|not) and (has("finished_at")|not) and (.evidence==[]) + end) + else false end; + +def evidence_kind_allowed_for_role(role; kind): + if role=="producer" then kind=="deterministic" + elif role=="verifier" then kind=="deterministic" or kind=="behavioral" or kind=="architecture" + elif role=="reviewer" then kind=="independent-review" + else false end; + +def time_le(a; b): a <= b; + +def completed_outcome_relation_ok(op; result): + (op.role) as $role | + if $role=="producer" then + (result.outputs|length) as $n | + if $n==0 then (result.outcome=={family:"change",value:"no-change"}) and (result|has("delta_ref")|not) + elif $n==1 then + (if (op.arguments|has("allowed_delta")) then + (result.outcome=={family:"change",value:"changed"}) and (result|has("delta_ref")) and + (result.delta_ref==result.outputs[0].ref) + else + (result.outcome=={family:"change",value:"changed"}) and (result|has("delta_ref")|not) + end) + else false + end + else + ((result.outputs==[]) and (result|has("delta_ref")|not)) and + (if (result.evidence|any(.[]; .verdict=="failed")) then result.outcome=={family:"check",value:"failed"} + elif (result.evidence|any(.[]; .verdict=="inconclusive")) then result.outcome=={family:"check",value:"inconclusive"} + else result.outcome=={family:"check",value:"passed"} end) + end; + +def is_fact_value_ok(expected): + if .state=="recorded" or .state=="computed" then .value==expected + elif .state=="unavailable" then true + else false end; + +# metadata_requested_unavailable: true when a requested provider/model/effort/ +# prompt/skills/tools fact is unavailable. The contract forces a completed result +# whose execution carries such a fact to be inconclusive — availability is an +# input to the outcome relation, not a presence check that passes independently +# of it. tools is included via this same path: a completed result cannot stay +# conclusive when the actual tool use it evidences cannot be established, exactly +# like the other requested facts. Deliberately excludes snapshot (not a requested +# fact — it has no corresponding binding field to be honest about). +def metadata_requested_unavailable(meta): + [meta.provider, meta.model, meta.effort, meta.prompt, meta.skills, meta.tools] | any(.[]; .state=="unavailable"); + +# actual_facts_ok: model bindings additionally require recorded/computed facts to +# equal the resolved binding's own model_request/prompt_ref/skill_refs (R12 — an +# actual fact is an honest claim about what actually ran, not just a syntactically +# valid one), and recorded/computed tools must be full tool_ref matches from the +# binding's requested tools, not merely same-ID matches. +def actual_facts_ok(binding; meta): + (if binding.execution_kind=="deterministic" then true + else + (meta.provider|is_fact_value_ok(binding.model_request.provider_id)) and + (meta.model|is_fact_value_ok(binding.model_request.model_id)) and + (meta.effort|is_fact_value_ok(binding.model_request.effort_id)) and + (meta.prompt|is_fact_value_ok(binding.prompt_ref)) and + (meta.skills|is_fact_value_ok(binding.skill_refs)) + end) and + (if (meta.tools.state=="recorded" or meta.tools.state=="computed") then + (meta.tools.value) as $used | + (binding.requested_tools) as $req_tools | + all($used[]; . as $u | $req_tools | any(.[]; . == $u)) + else true end); + +# completed_execution_matches_binding: for completed non-inconclusive execution the +# spec requires actual binding to equal "the corresponding projection of the +# selected resolved binding" across implementation, manifest ref, config, and +# authority, plus a matching performer identity/boundary and the request's own +# environment — not just the handful of fields checked before this fix. +def completed_execution_matches_binding(op; rb; exec_; env_ref): + (rb != null) and + (exec_.actual_binding.binding_id == rb.binding.binding_id) and + (exec_.actual_binding.role == rb.binding.role) and + (exec_.actual_binding.adapter_implementation == rb.adapter_implementation) and + (exec_.actual_binding.manifest_ref == rb.binding.manifest_ref) and + (exec_.actual_binding.package_ref == rb.binding.package_ref) and + (if (rb.binding|has("config_ref")) then + (exec_.actual_binding.config_ref.state=="present") and (exec_.actual_binding.config_ref.value==rb.binding.config_ref) + else exec_.actual_binding.config_ref.state=="absent" end) and + (exec_.actual_binding.execution_kind == rb.binding.execution_kind) and + (exec_.actual_binding.adapter_instance_id == rb.binding.adapter_instance_id) and + (exec_.actual_binding.principal_id == rb.binding.principal_id) and + (exec_.actual_binding.execution_boundary_id == rb.binding.execution_boundary_id) and + ((exec_.actual_binding|has("authority_ref")) == (rb.binding|has("authority_ref"))) and + (if (rb.binding|has("authority_ref")) then exec_.actual_binding.authority_ref==rb.binding.authority_ref else true end) and + (exec_.performer.role == rb.binding.role) and + (exec_.performer.implementation_id == exec_.actual_binding.adapter_implementation.id) and + (exec_.performer.implementation_version == exec_.actual_binding.adapter_implementation.version) and + (exec_.performer.adapter_instance_id == rb.binding.adapter_instance_id) and + (exec_.performer.principal_id == rb.binding.principal_id) and + (exec_.performer.execution_boundary_id == rb.binding.execution_boundary_id) and + ((exec_.performer|has("authority_ref")) == (rb.binding|has("authority_ref"))) and + (if (rb.binding|has("authority_ref")) then exec_.performer.authority_ref==rb.binding.authority_ref else true end) and + (exec_.environment == env_ref) and + (exec_.used_capability.kind == "registered") and (exec_.used_capability.id == op.capability_id) and + actual_facts_ok(rb.binding; exec_.metadata); + +# request_pair/resolved_pair/result_body: request_pair and resolved_pair are the +# driver's {content,sha256} pairs; result_body is the already-shape-checked stage_result. +def stage_result_relations_ok(request_pair; resolved_pair; result_body): + (request_pair.content) as $req | (resolved_pair.content) as $rp | + (result_body.request_ref.id == $req.id) and (result_body.request_ref.sha256 == request_pair.sha256) and + (result_body.resolved_profile_ref.id == $rp.id) and (result_body.resolved_profile_ref.sha256 == resolved_pair.sha256) and + ($req.body.resolved_profile_ref.id == $rp.id) and ($req.body.resolved_profile_ref.sha256 == resolved_pair.sha256) and + ($req.body.operation) as $op | + (find_one($rp.body.bindings; .binding.binding_id == $op.binding_id)) as $rbm | + (($rbm | length) == 1) and ($rbm[0]) as $rb | + (result_body.evidence | all(.[]; evidence_kind_allowed_for_role($op.role; .kind))) and + ($req.body.requested_at) as $t0 | + (if (result_body | has("execution")) then + time_le($t0; result_body.started_at) and time_le(result_body.started_at; result_body.finished_at) and + time_le(result_body.finished_at; result_body.recorded_at) + else time_le($t0; result_body.recorded_at) end) and + (result_body.attempt_number >= 1) and + (if result_body.status=="stale" then + (result_body.stale_observations | all(.[]; stale_observation_ok($req.body; .))) + else true end) and + (if result_body.status=="completed" then + (($req.body.required_evidence_kinds | sort) == (result_body.evidence | map(.kind) | sort)) and + (if ((result_body.evidence | any(.[]; .verdict != "passed")) or + metadata_requested_unavailable(result_body.execution.metadata)) then + (result_body.outcome.family == outcome_family_for_role($op.role)) and + (result_body.outcome.value == "inconclusive") and + (result_body.outputs == []) and (result_body | has("delta_ref") | not) and + (result_body | has("reason")) + else completed_outcome_relation_ok($op; result_body) end) and + # The contract allows a completed-inconclusive record's execution facts to + # differ from the resolved binding, so the record can preserve what actually + # went wrong; only a completed non-inconclusive result requires the equality. + (if result_body.outcome.value != "inconclusive" then + completed_execution_matches_binding($op; $rb; result_body.execution; $req.body.environment_ref) + else true end) and + (result_body.evidence | all(.[]; select(.kind=="independent-review") | + (.verdict != "passed") or + (result_body.execution.performer.role=="reviewer" and + result_body.execution.used_capability.kind=="registered" and + result_body.execution.used_capability.id=="core.review.change.v1"))) + else true end); + + + +# --------------------------------------------------------------------------- +# Global structural limits (R5) — every numeric leaf in this closed schema is +# meant to be an Int (0..2147483647); a float or a negative number is a limit +# violation here rather than a separate float-shape rule. +# --------------------------------------------------------------------------- + +def limits_violated: + def bad(d): + if d > 32 then true + elif type=="object" then + (keys_unsorted|length) > 256 or + (keys_unsorted|any(.[]; utf8_len > 8192)) or + any(.[]; bad(d+1)) + elif type=="array" then (length) > 256 or any(.[]; bad(d+1)) + elif type=="string" then (utf8_len) > 8192 + elif type=="number" then (. != (.|floor)) or . < 0 or . > 2147483647 + else false end; + bad(0); + +def any_doc_limits_violated(docs): any(docs[]; .content|limits_violated); + +# --------------------------------------------------------------------------- +# Dispatch (validate-document / validate-profile-set / validate-stage-run) +# --------------------------------------------------------------------------- + +def envelope_ok(kind; body_ok): + has_exact_fields(["schema_version","kind","id","body"];[]) and + (.schema_version==1) and (.kind==kind) and (.id|is_id) and (.body|body_ok); + +def document_shape_ok: + (.kind) as $k | + if $k=="adapter_manifest" then envelope_ok("adapter_manifest"; is_adapter_manifest_body) + elif $k=="profile" then envelope_ok("profile"; is_profile_body) + elif $k=="resolved_profile" then envelope_ok("resolved_profile"; is_resolved_profile_body) + elif $k=="stage_request" then envelope_ok("stage_request"; is_stage_request_body) + elif $k=="stage_result" then envelope_ok("stage_result"; is_stage_result_body) + else false end; + +def document_ref_ok: + (type=="object") and (has("kind")) and (.kind|is_document_kind) and document_shape_ok; + +def mode_document(docs): + (docs[0].content) as $doc | + if ($doc|document_ref_ok|not) then "E_SHAPE" + elif ($doc.kind=="profile" and ($doc.body|profile_protected_roles_ok|not)) then "E_RELATION" + elif ($doc.kind=="resolved_profile" and (resolved_bindings_profile_invariants_ok($doc.body)|not)) then "E_RELATION" + else null end; + +def mode_profile_set(docs): + (docs[0]) as $profile | (docs[1]) as $resolved | (docs[2:]) as $manifests | + if ($manifests|length) < 1 or ($manifests|length) > 8 then "E_USAGE" + elif ($profile.content|document_ref_ok|not) or ($profile.content.kind != "profile") then "E_SHAPE" + elif ($resolved.content|document_ref_ok|not) or ($resolved.content.kind != "resolved_profile") then "E_SHAPE" + elif (any($manifests[]; (.content|document_ref_ok|not) or (.content.kind != "adapter_manifest"))) then "E_SHAPE" + elif ($profile.content.body|profile_protected_roles_ok|not) then "E_RELATION" + elif ($resolved.content.body.profile_ref.id != $profile.content.id) or + ($resolved.content.body.profile_ref.sha256 != $profile.sha256) then "E_REF" + elif (($manifests|map(.content.id)|unique|length) != ($manifests|length)) then "E_REF" + elif (profile_set_relations_ok($profile; $resolved.content.body; $manifests)|not) then "E_RELATION" + else null end; + +def mode_stage_run(docs): + (docs[0]) as $request | (docs[1]) as $resolved | (docs[2]) as $result | + if ($request.content|document_ref_ok|not) or ($request.content.kind != "stage_request") then "E_SHAPE" + elif ($resolved.content|document_ref_ok|not) or ($resolved.content.kind != "resolved_profile") then "E_SHAPE" + elif ($result.content|document_ref_ok|not) or ($result.content.kind != "stage_result") then "E_SHAPE" + elif (resolved_bindings_profile_invariants_ok($resolved.content.body)|not) then "E_RELATION" + elif ($request.content.body.resolved_profile_ref.id != $resolved.content.id) or + ($request.content.body.resolved_profile_ref.sha256 != $resolved.sha256) then "E_REF" + elif (stage_request_relations_ok($request.content.body; $resolved)|not) then "E_RELATION" + elif ($result.content.body.request_ref.id != $request.content.id) or + ($result.content.body.request_ref.sha256 != $request.sha256) or + ($result.content.body.resolved_profile_ref.id != $resolved.content.id) or + ($result.content.body.resolved_profile_ref.sha256 != $resolved.sha256) then "E_REF" + elif ($result.content.body|status_presence_ok(outcome_family_for_role($request.content.body.operation.role))|not) then "E_RELATION" + elif (stage_result_relations_ok($request; $resolved; $result.content.body)|not) then "E_RELATION" + else null end; + +def dispatch: + if .mode=="document" then (if (.docs|length) != 1 then "E_USAGE" else mode_document(.docs) end) + elif .mode=="profile-set" then (if (.docs|length) < 3 then "E_USAGE" else mode_profile_set(.docs) end) + elif .mode=="stage-run" then (if (.docs|length) != 3 then "E_USAGE" else mode_stage_run(.docs) end) + else "E_USAGE" end; + +def main: if any_doc_limits_violated(.docs) then "E_LIMIT" else dispatch end; + +try (main as $r | if $r == null then empty else $r end) catch "E_RUNTIME" diff --git a/scripts/core-contract.sh b/scripts/core-contract.sh new file mode 100755 index 0000000..5e8b6ef --- /dev/null +++ b/scripts/core-contract.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# scripts/core-contract.sh — the only public front door to core/v1/contracts.jq. +# +# Three exact commands, no others: +# core-contract.sh validate-document DOCUMENT +# core-contract.sh validate-profile-set PROFILE RESOLVED_PROFILE MANIFEST... (1-8 manifests) +# core-contract.sh validate-stage-run REQUEST RESOLVED_PROFILE RESULT +# +# Success: exit 0, empty stdout. Failure: nonzero exit, stderr starts with one +# allowlisted E_USAGE|E_RUNTIME|E_PARSE|E_CANONICAL|E_LIMIT|E_SHAPE|E_REF|E_RELATION +# token. Input bytes and the caller-supplied paths are never echoed back. +# +# This script never proves Git/content existence — it only checks that each +# input is exactly one canonical-JSON document and asks core/v1/contracts.jq to +# validate shapes/refs/relations. See work/portable-core-contracts/spec.md. + +set -euo pipefail +LC_ALL=C +export LC_ALL +umask 077 + +fail() { + # fail CODE — the only place stderr is written; never echoes a path or byte. + printf '%s\n' "$1" >&2 + exit 1 +} + +# Parse and validate the exact command form FIRST — including the 1-8 manifest +# count — before any runtime-dependency check. A wrong command or argument count +# must always report E_USAGE, even on a host missing the pinned jq (plan.md). +cmd="${1:-}" +case "$cmd" in + validate-document) + [ "$#" -eq 2 ] || fail "E_USAGE" + mode="document" + docs=("$2") + ;; + validate-profile-set) + # cmd + PROFILE + RESOLVED_PROFILE + 1..8 manifests = 4..11 total args. + [ "$#" -ge 4 ] && [ "$#" -le 11 ] || fail "E_USAGE" + mode="profile-set" + docs=("${@:2}") + ;; + validate-stage-run) + [ "$#" -eq 4 ] || fail "E_USAGE" + mode="stage-run" + docs=("$2" "$3" "$4") + ;; + *) + fail "E_USAGE" + ;; +esac + +# Resolve the schema from this script's own repo root, never a caller path. +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +repo_root="$(cd "$script_dir/.." && pwd -P)" +schema="$repo_root/core/v1/contracts.jq" +[ -f "$schema" ] || fail "E_RUNTIME" + +command -v jq >/dev/null 2>&1 || fail "E_RUNTIME" +jq_version="$(jq --version 2>/dev/null || true)" +[ "$jq_version" = "jq-1.6" ] || fail "E_RUNTIME" + +sha_tool="" +if command -v sha256sum >/dev/null 2>&1; then + sha_tool="sha256sum" +elif command -v shasum >/dev/null 2>&1; then + sha_tool="shasum -a 256" +else + fail "E_RUNTIME" +fi + +# mktemp's own stderr diagnostic and the local path it names must never reach our +# stderr (the public E_* contract), so its failure is caught explicitly instead of +# letting `set -e` abort past fail(). +tmpdir="$(mktemp -d 2>/dev/null)" || fail "E_RUNTIME" +trap 'rm -rf "$tmpdir"' EXIT +byte_limit=1048576 + +# snapshot_one INPUT OUT_CANON_VAR OUT_SHA_VAR — bound-read, canonicalize, hash one +# input file. Populates the two named variables on success; calls fail() otherwise. +snapshot_one() { + input="$1" + [ -r "$input" ] || fail "E_RUNTIME" + raw="$tmpdir/raw.$$.$RANDOM" + if ! head -c $((byte_limit + 1)) -- "$input" > "$raw" 2>/dev/null; then + fail "E_RUNTIME" + fi + size="$(wc -c < "$raw" | tr -d ' ')" + [ "$size" -le "$byte_limit" ] || fail "E_LIMIT" + + canon="$tmpdir/canon.$$.$RANDOM" + if ! jq -s -S -c 'if length == 1 then .[0] else error("root-count") end' \ + -- "$raw" > "$canon" 2>/dev/null; then + fail "E_PARSE" + fi + cmp -s "$raw" "$canon" || fail "E_CANONICAL" + + digest="$($sha_tool "$raw" 2>/dev/null | awk '{print $1}')" + [ -n "$digest" ] || fail "E_RUNTIME" + + snap_canon="$canon" + snap_sha="$digest" +} + +# validate MODE INPUT... — snapshot every input in order, assemble the driver +# value, and run it through the pure jq validator. +validate() { + mode="$1" + shift + contents_file="$tmpdir/contents.ndjson" + : > "$contents_file" + shas_json="[]" + for f in "$@"; do + snapshot_one "$f" + cat -- "$snap_canon" >> "$contents_file" + shas_json="$(printf '%s' "$shas_json" | jq -c --arg s "$snap_sha" '. + [$s]')" + done + + driver="$tmpdir/driver.json" + jq -n --arg mode "$mode" --argjson shas "$shas_json" --slurpfile contents "$contents_file" \ + '{mode: $mode, docs: ([range(0; ($contents|length))] | map({content: $contents[.], sha256: $shas[.]}))}' \ + > "$driver" 2>/dev/null || fail "E_RUNTIME" + + out="$tmpdir/out.txt" + if ! jq -r -f "$schema" "$driver" > "$out" 2>/dev/null; then + fail "E_RUNTIME" + fi + lines="$(wc -l < "$out" | tr -d ' ')" + if [ "$lines" -eq 0 ] && [ ! -s "$out" ]; then + exit 0 + fi + [ "$lines" -eq 1 ] || fail "E_RUNTIME" + token="$(cat -- "$out")" + case "$token" in + E_USAGE|E_RUNTIME|E_PARSE|E_CANONICAL|E_LIMIT|E_SHAPE|E_REF|E_RELATION) fail "$token" ;; + *) fail "E_RUNTIME" ;; + esac +} + +validate "$mode" "${docs[@]}" diff --git a/scripts/test/core-contract-fixtures.jq b/scripts/test/core-contract-fixtures.jq new file mode 100644 index 0000000..c313d0a --- /dev/null +++ b/scripts/test/core-contract-fixtures.jq @@ -0,0 +1,168 @@ +# scripts/test/core-contract-fixtures.jq — readable builders for one valid +# five-document graph (manifest -> profile -> resolved profile -> request -> +# result), used only by scripts/test/core-contract.test.sh. +# +# These builders create DATA only. They contain no validation predicate, +# acceptance rule, or expected-verdict logic, and are never loaded by +# core/v1/contracts.jq or scripts/core-contract.sh. The test script — not this +# file — independently canonicalizes each document with pinned jq and hashes it +# with an external SHA tool before wiring that digest into the next document's +# ref, so no digest here is ever self-referential. + +def gitrev: {repository_id: "repo-a", hash_algorithm: "sha1", commit_id: ("a" * 40)}; +def gitobj(loc; ot; oid; mode): {revision: gitrev, location: loc, object_type: ot, object_id: oid, mode: mode}; +def rootref(oid): gitobj({kind: "root"}; "tree"; oid; "040000"); +def blobref(path; oid): gitobj({kind: "path", value: path}; "blob"; oid; "100644"); +def contentref(id; sha): {content_id: id, media_type: "application/json", sha256: sha}; +def scoperef(purpose; subj_id; sha): + {purpose: purpose, decision_record_ref: contentref("dec-" + subj_id; sha), + subject_ref: {type: "artifact", value: {type: "content", value: contentref(subj_id; sha)}}, + scope_sha256: sha}; +def docref(kind; id; sha): {schema_version: 1, kind: kind, id: id, sha256: sha}; +def envelope(kind; id; body): {schema_version: 1, kind: kind, id: id, body: body}; +def actorref(role): {role: role, implementation_id: ("impl-" + role), implementation_version: "v1", + adapter_instance_id: ("inst-" + role), principal_id: ("pri-" + role), execution_boundary_id: ("bnd-" + role)}; + +def role_capability(role): + if role == "producer" then "core.harness.produce.v1" + elif role == "verifier" then "core.verify.run.v1" + elif role == "reviewer" then "core.review.change.v1" + else null end; +def role_permissions(role): + (if role == "producer" then ["core.perm.target.read.v1", "core.perm.scratch.write.v1", "core.perm.evidence.write.v1"] + elif role == "verifier" then ["core.perm.target.read.v1", "core.perm.candidate.execute.v1", "core.perm.evidence.write.v1"] + elif role == "reviewer" then ["core.perm.target.read.v1", "core.perm.evidence.write.v1"] + else [] end) | sort; + +def manifest_body(role): + {adapter_version: "v1", package_ref: rootref("3" * 40), + offered_roles: [role], offered_execution_kinds: ["deterministic"], + offered_capabilities: (if role_capability(role) != null then [role_capability(role)] else [] end), + offered_permissions: role_permissions(role), offered_tools: []}; +def manifest_doc(role): envelope("adapter_manifest"; "manifest-" + role; manifest_body(role)); + +def binding(role; bid; auth_sha; manifest_sha): + {binding_id: bid, role: role, manifest_ref: docref("adapter_manifest"; "manifest-" + role; manifest_sha), + execution_kind: "deterministic", adapter_instance_id: ("inst-" + bid), principal_id: ("pri-" + bid), + execution_boundary_id: ("bnd-" + bid), package_ref: rootref("3" * 40), + skill_refs: [], requested_tools: [], + requested_capabilities: (if role_capability(role) != null then [role_capability(role)] else [] end), + requested_permissions: role_permissions(role)} + + (if ["producer", "verifier", "reviewer", "publisher"] | index(role) != null + then {authority_ref: scoperef("authority"; "auth-" + bid; auth_sha)} else {} end); + +# manifest_shas: {producer:sha, verifier:sha, reviewer:sha, publisher:sha} +def profile_body(manifest_shas): + {profile_version: "v1", + bindings: ([ + binding("producer"; "b-producer"; "4" * 64; manifest_shas.producer), + binding("verifier"; "b-verifier"; "5" * 64; manifest_shas.verifier), + binding("reviewer"; "b-reviewer"; "6" * 64; manifest_shas.reviewer), + binding("publisher"; "b-publisher"; "7" * 64; manifest_shas.publisher)] | sort_by(.binding_id))}; +def profile_doc(manifest_shas): envelope("profile"; "profile-1"; profile_body(manifest_shas)); + +# manifest_object_id(role): each role's manifest.json is a genuinely different +# blob (different offered_roles/capabilities), so its resolved manifest_source +# must claim a distinct git object identity, not the same one for every role — +# one exact Git object gets only one format/digest claim (core/v1/contracts.jq's +# source_claims_agree), and four different real digests against one shared fake +# object id would itself be the violation this fixture is meant to be free of. +def manifest_object_id(role): + (if role == "producer" then "80" + elif role == "verifier" then "81" + elif role == "reviewer" then "82" + elif role == "publisher" then "83" + else "8f" end) * 20; + +def resolved_binding_for(b; manifest_shas): + {binding: b, + adapter_implementation: {id: ("manifest-" + b.role), version: "v1"}, + manifest_source: {source: blobref("manifest.json"; manifest_object_id(b.role)), value_format: "canonical-json", value_sha256: manifest_shas[b.role]}, + package_source: {source: b.package_ref, value_format: "raw-bytes", value_sha256: ("9" * 64)}, + config_source: {state: "absent"}, prompt_source: {state: "absent"}, + skill_sources: [], tool_sources: []}; + +def resolved_profile_body(profile_sha; manifest_shas): + {profile_ref: docref("profile"; "profile-1"; profile_sha), + profile_source: {source: blobref("profile.json"; "b" * 40), value_format: "canonical-json", value_sha256: profile_sha}, + selection_ref: scoperef("selection"; "sel-1"; "0" * 64), + repository_context_ref: scoperef("repository-context"; "rc-1"; "1" * 64), + bindings: (profile_body(manifest_shas).bindings | map(resolved_binding_for(.; manifest_shas)))}; +def resolved_profile_doc(profile_sha; manifest_shas): + envelope("resolved_profile"; "resolved-1"; resolved_profile_body(profile_sha; manifest_shas)); + +def named_input(id; sha): {input_id: id, value: {type: "artifact", value: {type: "content", value: contentref("in-" + id; sha)}}}; +# delivered's scope subject must be the *same* content ref as the named input it +# selects (core/v1/contracts.jq's delivered_scope_input_ok closes over this), so it +# reuses the "in-"+input_id content id rather than a separate "scope-"+input_id one. +def delivered(purpose; input_id; sha): {ref: scoperef(purpose; "in-" + input_id; sha), input_id: input_id}; + +def stage_request_body(resolved_sha): + {initiative_id: "init-1", workflow_id: "wf-1", stage_id: "stage-1", task_class_id: "tc-1", + requested_by: actorref("orchestrator"), target_repository_id: "repo-a", + target_revision: {state: "absent"}, + source: {state: "present", value: {type: "content", value: contentref("src-1"; "d" * 64)}}, + base: {state: "absent"}, + inputs: ([named_input("out-1"; "e" * 64), named_input("fin-1"; "e" * 64), named_input("ver-1"; "e" * 64)] | sort_by(.input_id)), + prior_evidence_refs: [], + # An absent target is allowed only for bootstrap producer work (core/v1/contracts.jq), + # so this fixture's absent-target producer request must carry the bootstrap tier. + risk: {tier: {namespace: "core", name: "bootstrap"}, reason_ids: ["r1"], + policy_ref: scoperef("policy"; "pol-1"; "4" * 64), required_gate_refs: []}, + resolved_profile_ref: docref("resolved_profile"; "resolved-1"; resolved_sha), + selection_ref: scoperef("selection"; "sel-1"; "0" * 64), + repository_context_ref: scoperef("repository-context"; "rc-1"; "1" * 64), + gate_decision_refs: [], + environment_ref: {environment_id: "env-1", fingerprint_sha256: ("3" * 64)}, + operation: {role: "producer", binding_id: "b-producer", capability_id: "core.harness.produce.v1", + permissions: role_permissions("producer"), + arguments: {artifact_kind: "plan", output_contract: delivered("output-contract"; "out-1"; "e" * 64)}}, + finish_condition: delivered("finish-condition"; "fin-1"; "e" * 64), + verification_instruction: delivered("verification-instructions"; "ver-1"; "e" * 64), + required_evidence_kinds: ["deterministic"], + requested_at: "2026-08-28T00:00:00Z"}; +def stage_request_doc(resolved_sha): envelope("stage_request"; "req-1"; stage_request_body(resolved_sha)); + +def output_for(id; sha): {output_id: id, ref: contentref("out-content-" + id; sha)}; + +def producer_resolved_binding(manifest_shas): + resolved_binding_for(binding("producer"; "b-producer"; "4" * 64; manifest_shas.producer); manifest_shas); + +# execution_for(rb): derives performer/actual_binding from the resolved binding +# itself, so the "completed non-inconclusive execution equals the corresponding +# projection of the selected resolved binding" relation +# (core/v1/contracts.jq's completed_execution_matches_binding) holds by +# construction instead of by two independently hand-typed literals drifting apart. +def execution_for(rb): + (rb.binding) as $b | + {performer: ( + {role: $b.role, implementation_id: rb.adapter_implementation.id, + implementation_version: rb.adapter_implementation.version, + adapter_instance_id: $b.adapter_instance_id, principal_id: $b.principal_id, + execution_boundary_id: $b.execution_boundary_id} + + (if ($b|has("authority_ref")) then {authority_ref: $b.authority_ref} else {} end)), + actual_binding: ( + {binding_id: $b.binding_id, role: $b.role, adapter_implementation: rb.adapter_implementation, + manifest_ref: $b.manifest_ref, package_ref: $b.package_ref, + config_ref: (if ($b|has("config_ref")) then {state: "present", value: $b.config_ref} else {state: "absent"} end), + execution_kind: $b.execution_kind, adapter_instance_id: $b.adapter_instance_id, + principal_id: $b.principal_id, execution_boundary_id: $b.execution_boundary_id} + + (if ($b|has("authority_ref")) then {authority_ref: $b.authority_ref} else {} end)), + environment: {environment_id: "env-1", fingerprint_sha256: ("3" * 64)}, + used_capability: {kind: "registered", id: role_capability($b.role)}, + metadata: {kind: "deterministic", + provider: {state: "not-applicable"}, model: {state: "not-applicable"}, snapshot: {state: "not-applicable"}, + effort: {state: "not-applicable"}, prompt: {state: "not-applicable"}, skills: {state: "not-applicable"}, + tools: {state: "computed", value: [], source_ref: contentref("tools-fact"; "9" * 64)}}}; + +def stage_result_body(request_sha; resolved_sha; manifest_shas): + {request_ref: docref("stage_request"; "req-1"; request_sha), + resolved_profile_ref: docref("resolved_profile"; "resolved-1"; resolved_sha), + attempt_id: "attempt-1", attempt_number: 1, reported_by: actorref("orchestrator"), + status: "completed", outcome: {family: "change", value: "changed"}, + outputs: [output_for("o1"; "7" * 64)], + diagnostics: [], execution: execution_for(producer_resolved_binding(manifest_shas)), + evidence: [{evidence_id: "ev-1", kind: "deterministic", verdict: "passed", proof_ref: contentref("proof-1"; "8" * 64)}], + started_at: "2026-08-28T00:00:01Z", finished_at: "2026-08-28T00:00:02Z", recorded_at: "2026-08-28T00:00:03Z"}; +def stage_result_doc(request_sha; resolved_sha; manifest_shas): + envelope("stage_result"; "result-1"; stage_result_body(request_sha; resolved_sha; manifest_shas)); diff --git a/scripts/test/core-contract.test.sh b/scripts/test/core-contract.test.sh new file mode 100755 index 0000000..8a9921d --- /dev/null +++ b/scripts/test/core-contract.test.sh @@ -0,0 +1,599 @@ +#!/usr/bin/env bash +# scripts/test/core-contract.test.sh — hermetic positive, raw-byte, shape, +# relation, and status tests for core/v1/contracts.jq + scripts/core-contract.sh. +# +# Builds one valid five-document graph with scripts/test/core-contract-fixtures.jq, +# independently canonicalizing and hashing each document (pinned jq + an external +# SHA tool — never the product wrapper) before wiring its digest into the next +# document's ref, so no fixture digest is ever self-referential. Runs the real +# `scripts/core-contract.sh` end to end (no Git, no process launch, no network) and +# asserts exit status, empty success stdout, the exact first stderr token, and the +# absence of a distinctive fixture path/secret from stderr. Requires jq 1.6 exactly +# on PATH (see AGENTS.md for the pinned local binary). +# +# Run: scripts/test/core-contract.test.sh + +set -euo pipefail +LC_ALL=C +export LC_ALL + +test_dir="$(cd "$(dirname "$0")" && pwd -P)" +repo_root="$(cd "$test_dir/../.." && pwd -P)" +wrapper="$repo_root/scripts/core-contract.sh" +fixtures="$test_dir/core-contract-fixtures.jq" +for f in "$wrapper" "$fixtures"; do + [ -f "$f" ] || { echo "FAIL: missing $f" >&2; exit 1; } +done + +if [ "$(jq --version 2>/dev/null || true)" != "jq-1.6" ]; then + echo "FAIL: this test requires jq 1.6 exactly on PATH (see AGENTS.md)" >&2 + exit 1 +fi + +sha_tool="sha256sum" +command -v sha256sum >/dev/null 2>&1 || sha_tool="shasum -a 256" + +tmpdir="$(mktemp -d)" +cleanup() { rm -rf "$tmpdir"; } +trap cleanup EXIT + +passed=0 +failed=0 +assert_eq() { + if [ "$2" = "$3" ]; then passed=$((passed + 1)); echo "pass: $1" + else failed=$((failed + 1)); echo "FAIL: $1"; echo " expected: [$2]"; echo " actual: [$3]"; fi +} +assert_not_contains() { + case "$3" in + *"$2"*) failed=$((failed + 1)); echo "FAIL: $1 (unexpectedly contains [$2])" ;; + *) passed=$((passed + 1)); echo "pass: $1" ;; + esac +} + +run_expr() { + # run_expr EXPR [jq-args...] -> canonical JSON on stdout + local expr="$1"; shift + local prog="$tmpdir/prog.$$.$RANDOM.jq" + cat "$fixtures" > "$prog" + printf '%s\n' "$expr" >> "$prog" + jq -n "$@" -f "$prog" | jq -S -c . +} +hash_of() { $sha_tool "$1" | awk '{print $1}'; } + +# mutate BASE_FILE JQ_FILTER OUT_FILE — apply a jq filter to an existing canonical +# doc and re-canonicalize (used for shape/relation mutations; raw-byte mutations +# are crafted directly instead, since jq cannot emit non-canonical JSON on purpose). +mutate() { jq -S -c "$2" "$1" > "$3"; } + +# stderr_of/status_of CMD... — run scripts/core-contract.sh, capturing stderr text +# and exit status into globals so a single invocation can be asserted on both axes. +LAST_STDERR="" +LAST_STATUS=0 +run_wrapper() { + local out + out="$(bash "$wrapper" "$@" 2>&1 1>/dev/null)" && LAST_STATUS=0 || LAST_STATUS=$? + LAST_STDERR="$out" +} +assert_fail() { + # assert_fail LABEL EXPECTED_TOKEN ARGS... + local label="$1" expected="$2"; shift 2 + run_wrapper "$@" + assert_eq "$label (exit nonzero)" "1" "$([ "$LAST_STATUS" -ne 0 ] && echo 1 || echo 0)" + assert_eq "$label (token)" "$expected" "$LAST_STDERR" +} +assert_ok() { + local label="$1"; shift + local out + out="$(bash "$wrapper" "$@" 2>&1)" && LAST_STATUS=0 || LAST_STATUS=$? + assert_eq "$label (exit 0)" "0" "$LAST_STATUS" + assert_eq "$label (empty stdout+stderr)" "" "$out" +} + +echo "== building the valid five-document graph ==" + +for role in producer verifier reviewer publisher; do + run_expr "manifest_doc(\"$role\")" > "$tmpdir/manifest-$role.json" +done +sha_producer=$(hash_of "$tmpdir/manifest-producer.json") +sha_verifier=$(hash_of "$tmpdir/manifest-verifier.json") +sha_reviewer=$(hash_of "$tmpdir/manifest-reviewer.json") +sha_publisher=$(hash_of "$tmpdir/manifest-publisher.json") +manifest_shas=$(jq -n --arg p "$sha_producer" --arg v "$sha_verifier" --arg r "$sha_reviewer" --arg u "$sha_publisher" \ + '{producer:$p, verifier:$v, reviewer:$r, publisher:$u}') + +# shellcheck disable=SC2016 # single-quoted jq $-vars on purpose, not shell vars +run_expr 'profile_doc($manifest_shas)' --argjson manifest_shas "$manifest_shas" > "$tmpdir/profile.json" +sha_profile=$(hash_of "$tmpdir/profile.json") + +# shellcheck disable=SC2016 # single-quoted jq $-vars on purpose, not shell vars +run_expr 'resolved_profile_doc($profile_sha; $manifest_shas)' \ + --arg profile_sha "$sha_profile" --argjson manifest_shas "$manifest_shas" > "$tmpdir/resolved_profile.json" +sha_resolved=$(hash_of "$tmpdir/resolved_profile.json") + +# shellcheck disable=SC2016 # single-quoted jq $-vars on purpose, not shell vars +run_expr 'stage_request_doc($resolved_sha)' --arg resolved_sha "$sha_resolved" > "$tmpdir/request.json" +sha_request=$(hash_of "$tmpdir/request.json") + +# shellcheck disable=SC2016 # single-quoted jq $-vars on purpose, not shell vars +run_expr 'stage_result_doc($request_sha; $resolved_sha; $manifest_shas)' \ + --arg request_sha "$sha_request" --arg resolved_sha "$sha_resolved" --argjson manifest_shas "$manifest_shas" > "$tmpdir/result.json" + +echo "== (a) positive path: every document, profile-set, and stage-run validate clean ==" +for f in manifest-producer manifest-verifier manifest-reviewer manifest-publisher profile resolved_profile request result; do + assert_ok "(a) validate-document $f" validate-document "$tmpdir/$f.json" +done +assert_ok "(a) validate-profile-set" validate-profile-set "$tmpdir/profile.json" "$tmpdir/resolved_profile.json" \ + "$tmpdir/manifest-producer.json" "$tmpdir/manifest-verifier.json" "$tmpdir/manifest-reviewer.json" "$tmpdir/manifest-publisher.json" +assert_ok "(a) validate-stage-run" validate-stage-run "$tmpdir/request.json" "$tmpdir/resolved_profile.json" "$tmpdir/result.json" + +echo "== (b) CLI usage / arity ==" +assert_fail "(b) unknown command" "E_USAGE" bogus-command +assert_fail "(b) no command" "E_USAGE" +assert_fail "(b) validate-document no arg" "E_USAGE" validate-document +assert_fail "(b) validate-document extra arg" "E_USAGE" validate-document "$tmpdir/profile.json" extra +assert_fail "(b) validate-stage-run too few args" "E_USAGE" validate-stage-run "$tmpdir/request.json" "$tmpdir/resolved_profile.json" +assert_fail "(b) validate-stage-run too many args" "E_USAGE" validate-stage-run "$tmpdir/request.json" "$tmpdir/resolved_profile.json" "$tmpdir/result.json" extra +assert_fail "(b) validate-profile-set zero manifests" "E_USAGE" validate-profile-set "$tmpdir/profile.json" "$tmpdir/resolved_profile.json" +assert_fail "(b) validate-profile-set nine manifests" "E_USAGE" validate-profile-set "$tmpdir/profile.json" "$tmpdir/resolved_profile.json" \ + "$tmpdir/manifest-producer.json" "$tmpdir/manifest-producer.json" "$tmpdir/manifest-producer.json" "$tmpdir/manifest-producer.json" \ + "$tmpdir/manifest-producer.json" "$tmpdir/manifest-producer.json" "$tmpdir/manifest-producer.json" "$tmpdir/manifest-producer.json" "$tmpdir/manifest-producer.json" +for i in 1 2 3 4; do + mutate "$tmpdir/manifest-producer.json" ".id = \"extra-manifest-$i\" | .body.package_ref.object_id = (\"$i\"*40)" "$tmpdir/extra-manifest-$i.json" +done +# The accepted contract requires the supplied manifest set to be exact — extras +# unreferenced by any binding widen a supposedly closed profile set, so eight +# manifests (four referenced, four not) is syntactically within the 1-8 CLI bound +# but must still be rejected at the relation level, not accepted. +assert_fail "(b) validate-profile-set eight manifests (boundary, 4 extra unreferenced) is rejected" "E_RELATION" validate-profile-set "$tmpdir/profile.json" "$tmpdir/resolved_profile.json" \ + "$tmpdir/manifest-producer.json" "$tmpdir/manifest-verifier.json" "$tmpdir/manifest-reviewer.json" "$tmpdir/manifest-publisher.json" \ + "$tmpdir/extra-manifest-1.json" "$tmpdir/extra-manifest-2.json" "$tmpdir/extra-manifest-3.json" "$tmpdir/extra-manifest-4.json" +assert_fail "(b) validate-profile-set unreadable input" "E_RUNTIME" validate-profile-set "$tmpdir/profile.json" "$tmpdir/resolved_profile.json" "$tmpdir/does-not-exist.json" + +echo "== (c) raw-byte and canonical boundary (direct-crafted bytes, validate-document) ==" +printf '' > "$tmpdir/rb-empty.json" +assert_fail "(c) empty input" "E_PARSE" validate-document "$tmpdir/rb-empty.json" +printf '{"a":1}\n{"b":2}\n' > "$tmpdir/rb-multiroot.json" +assert_fail "(c) multi-root stream" "E_PARSE" validate-document "$tmpdir/rb-multiroot.json" +printf '\xef\xbb\xbf{"a":1}\n' > "$tmpdir/rb-bom.json" +assert_fail "(c) BOM prefix" "E_CANONICAL" validate-document "$tmpdir/rb-bom.json" +printf '\xff\xfe{"a":1}\n' > "$tmpdir/rb-badutf8.json" +assert_fail "(c) invalid UTF-8" "E_PARSE" validate-document "$tmpdir/rb-badutf8.json" +printf '{"a":1,"a":2}\n' > "$tmpdir/rb-dupkeys.json" +assert_fail "(c) duplicate keys (non-canonical)" "E_CANONICAL" validate-document "$tmpdir/rb-dupkeys.json" +printf '{ "a": 1 }\n' > "$tmpdir/rb-altwhitespace.json" +assert_fail "(c) alternate whitespace (non-canonical)" "E_CANONICAL" validate-document "$tmpdir/rb-altwhitespace.json" +printf '{"a":"\\u0041"}\n' > "$tmpdir/rb-altescape.json" +assert_fail "(c) alternate escaping (non-canonical)" "E_CANONICAL" validate-document "$tmpdir/rb-altescape.json" +printf '{"a":1}' > "$tmpdir/rb-nolf.json" +assert_fail "(c) missing final LF (non-canonical)" "E_CANONICAL" validate-document "$tmpdir/rb-nolf.json" +printf '{"a":1}\n\n' > "$tmpdir/rb-extralf.json" +assert_fail "(c) extra final LF (non-canonical)" "E_CANONICAL" validate-document "$tmpdir/rb-extralf.json" +printf '{"b":1,"a":2}\n' > "$tmpdir/rb-unsortedkeys.json" +assert_fail "(c) unsorted keys (non-canonical)" "E_CANONICAL" validate-document "$tmpdir/rb-unsortedkeys.json" +python3 -c " +def build(bump): + K = 256 + lengths = [4093]*248 + [4092]*8 + lengths[0] += bump + return '{\"a\":[' + ','.join('\"' + ('x'*L) + '\"' for L in lengths) + ']}\n' +import sys +with open(sys.argv[1], 'w') as f: f.write(build(0)) +with open(sys.argv[2], 'w') as f: f.write(build(1)) +" "$tmpdir/rb-atlimit.json" "$tmpdir/rb-overlimit.json" +[ "$(wc -c < "$tmpdir/rb-atlimit.json" | tr -d ' ')" -eq 1048576 ] || { echo "FAIL: at-limit fixture miscounted" >&2; exit 1; } +[ "$(wc -c < "$tmpdir/rb-overlimit.json" | tr -d ' ')" -eq 1048577 ] || { echo "FAIL: over-limit fixture miscounted" >&2; exit 1; } +assert_fail "(c) at exact 1,048,576-byte boundary is still just a shape failure, not E_LIMIT" "E_SHAPE" validate-document "$tmpdir/rb-atlimit.json" +assert_fail "(c) one byte over the 1,048,576 limit" "E_LIMIT" validate-document "$tmpdir/rb-overlimit.json" +python3 -c " +depth = 33 +s = '{\"a\":' * depth + '1' + '}' * depth +print(s) +" > "$tmpdir/rb-toodeep.json" +assert_fail "(c) depth 33 (one over the 32 limit)" "E_LIMIT" validate-document "$tmpdir/rb-toodeep.json" +python3 -c " +import json +obj = {('k%d' % i): 1 for i in range(257)} +print(json.dumps(obj, separators=(',', ':'), sort_keys=True)) +" > "$tmpdir/rb-toomanymembers.json" +assert_fail "(c) 257 object members (one over the 256 limit)" "E_LIMIT" validate-document "$tmpdir/rb-toomanymembers.json" +python3 -c " +print('{\"a\":\"' + ('y' * 8193) + '\"}') +" > "$tmpdir/rb-toolongstring.json" +assert_fail "(c) decoded string 8,193 bytes (one over the 8,192 limit)" "E_LIMIT" validate-document "$tmpdir/rb-toolongstring.json" +# The recursive limit walker must also count object keys, not only values, or an +# oversized key escapes E_LIMIT and surfaces later as E_SHAPE instead. +python3 -c " +print('{\"' + ('k' * 8193) + '\":1}') +" > "$tmpdir/rb-toolongkey.json" +assert_fail "(c) decoded object key 8,193 bytes (one over the 8,192 limit)" "E_LIMIT" validate-document "$tmpdir/rb-toolongkey.json" +python3 -c " +print('{\"' + ('k' * 8192) + '\":1}') +" > "$tmpdir/rb-keyatlimit.json" +assert_fail "(c) 8,192-byte object key is at the limit, not over it (shape failure, not E_LIMIT)" "E_SHAPE" validate-document "$tmpdir/rb-keyatlimit.json" +printf '{"a":1.5}\n' > "$tmpdir/rb-float.json" +assert_fail "(c) float value" "E_LIMIT" validate-document "$tmpdir/rb-float.json" +printf '{"a":-1}\n' > "$tmpdir/rb-negative.json" +assert_fail "(c) negative integer" "E_LIMIT" validate-document "$tmpdir/rb-negative.json" +printf '{"a":9999999999}\n' > "$tmpdir/rb-hugeint.json" +assert_fail "(c) integer over 2147483647" "E_LIMIT" validate-document "$tmpdir/rb-hugeint.json" + +secret="zzz-super-secret-path-marker-zzz" +printf '{"%s":1}\n' "$secret" > "$tmpdir/rb-secretpath.json" +run_wrapper validate-document "$tmpdir/rb-secretpath.json" +assert_not_contains "(c) stderr never echoes the input path or a distinctive fixture byte" "$secret" "$LAST_STDERR" +assert_not_contains "(c) stderr never echoes the tmp input path itself" "$tmpdir" "$LAST_STDERR" + +echo "== (d) manifest shape (validate-document) ==" +mutate "$tmpdir/manifest-producer.json" '.body.offered_roles = []' "$tmpdir/m-d1.json" +assert_fail "(d) offered_roles below minimum" "E_SHAPE" validate-document "$tmpdir/m-d1.json" +mutate "$tmpdir/manifest-producer.json" '.body.offered_roles = ["not-a-role"]' "$tmpdir/m-d2.json" +assert_fail "(d) offered_roles unknown enum" "E_SHAPE" validate-document "$tmpdir/m-d2.json" +mutate "$tmpdir/manifest-producer.json" '.body.extra_field = 1' "$tmpdir/m-d3.json" +assert_fail "(d) unknown top-level field" "E_SHAPE" validate-document "$tmpdir/m-d3.json" +mutate "$tmpdir/manifest-producer.json" 'del(.body.adapter_version)' "$tmpdir/m-d4.json" +assert_fail "(d) missing required field" "E_SHAPE" validate-document "$tmpdir/m-d4.json" +mutate "$tmpdir/manifest-producer.json" '.body.offered_tools = [{tool_id:"t1",tool_version:"v1",package_ref:.body.package_ref,config_ref:{state:"absent"}},{tool_id:"t1",tool_version:"v1",package_ref:.body.package_ref,config_ref:{state:"absent"}}]' "$tmpdir/m-d5.json" +assert_fail "(d) duplicate tool_id in offered_tools" "E_SHAPE" validate-document "$tmpdir/m-d5.json" +mutate "$tmpdir/manifest-producer.json" '.kind = "not-a-kind"' "$tmpdir/m-d6.json" +assert_fail "(d) unknown document kind" "E_SHAPE" validate-document "$tmpdir/m-d6.json" +mutate "$tmpdir/manifest-producer.json" '.schema_version = 2' "$tmpdir/m-d7.json" +assert_fail "(d) wrong schema_version" "E_SHAPE" validate-document "$tmpdir/m-d7.json" +mutate "$tmpdir/manifest-producer.json" '.body.offered_permissions = (.body.offered_permissions | sort | reverse)' "$tmpdir/m-d8.json" +assert_fail "(d) offered_permissions enum set not in canonical sorted order" "E_SHAPE" validate-document "$tmpdir/m-d8.json" + +echo "== (e) profile shape + protected-role relations (validate-document) ==" +mutate "$tmpdir/profile.json" '.body.bindings = [.body.bindings[0]]' "$tmpdir/p-e1.json" +assert_fail "(e) below 4-binding minimum" "E_SHAPE" validate-document "$tmpdir/p-e1.json" +mutate "$tmpdir/profile.json" '.body.bindings[0].requested_capabilities = ["core.verify.run.v1"]' "$tmpdir/p-e2.json" +assert_fail "(e) producer requesting verifier's capability" "E_SHAPE" validate-document "$tmpdir/p-e2.json" +mutate "$tmpdir/profile.json" 'del(.body.bindings[3].authority_ref)' "$tmpdir/p-e3.json" +assert_fail "(e) protected role missing authority_ref" "E_RELATION" validate-document "$tmpdir/p-e3.json" +mutate "$tmpdir/profile.json" '.body.bindings[1].authority_ref.scope_sha256 = .body.bindings[0].authority_ref.scope_sha256' "$tmpdir/p-e4.json" +assert_fail "(e) two protected roles share one authority scope" "E_RELATION" validate-document "$tmpdir/p-e4.json" +mutate "$tmpdir/profile.json" '.body.bindings[1].principal_id = .body.bindings[0].principal_id' "$tmpdir/p-e5.json" +assert_fail "(e) two protected roles share one principal_id" "E_RELATION" validate-document "$tmpdir/p-e5.json" +# Selects the verifier binding by role, not array position — canonical sorted +# order (core/v1/contracts.jq's is_bounded_set) no longer places it at a fixed index. +mutate "$tmpdir/profile.json" '.body.bindings |= map(if .role=="verifier" then .execution_kind="model" else . end)' "$tmpdir/p-e6.json" +assert_fail "(e) verifier forced to model execution" "E_SHAPE" validate-document "$tmpdir/p-e6.json" +mutate "$tmpdir/profile.json" '.body.bindings[0].skill_refs = [.body.bindings[0].package_ref]' "$tmpdir/p-e7.json" +assert_fail "(e) deterministic binding with non-empty skill_refs" "E_SHAPE" validate-document "$tmpdir/p-e7.json" +mutate "$tmpdir/profile.json" '.body.bindings[0].binding_id = .body.bindings[1].binding_id' "$tmpdir/p-e8.json" +assert_fail "(e) duplicate binding_id" "E_SHAPE" validate-document "$tmpdir/p-e8.json" +mutate "$tmpdir/profile.json" '.body.bindings |= reverse' "$tmpdir/p-e9.json" +assert_fail "(e) bindings not in canonical binding_id-sorted order" "E_SHAPE" validate-document "$tmpdir/p-e9.json" +# Model backing is permitted only for producer and reviewer; every other (dormant) +# role must stay deterministic. +model_bits='.execution_kind="model" | .model_request={provider_id:"prov-1",model_id:"model-1",effort_id:"effort-1"} | .prompt_ref={revision:{repository_id:"repo-a",hash_algorithm:"sha1",commit_id:("a"*40)},location:{kind:"path",value:"prompt.json"},object_type:"blob",object_id:("c"*40),mode:"100644"}' +mutate "$tmpdir/profile.json" ".body.bindings |= map(if .role==\"producer\" then ($model_bits | .requested_permissions=([\"core.perm.target.read.v1\",\"core.perm.scratch.write.v1\",\"core.perm.evidence.write.v1\",\"core.perm.model.invoke.v1\"]|sort)) else . end)" "$tmpdir/p-e10.json" +assert_ok "(e) producer allowed to use model execution" validate-document "$tmpdir/p-e10.json" +mutate "$tmpdir/profile.json" ".body.bindings |= map(if .role==\"publisher\" then ($model_bits) else . end)" "$tmpdir/p-e11.json" +assert_fail "(e) dormant role (publisher) forced to model execution" "E_SHAPE" validate-document "$tmpdir/p-e11.json" + +echo "== (f) resolved_profile shape (validate-document) ==" +mutate "$tmpdir/resolved_profile.json" 'del(.body.selection_ref)' "$tmpdir/rp-f1.json" +assert_fail "(f) missing selection_ref" "E_SHAPE" validate-document "$tmpdir/rp-f1.json" +mutate "$tmpdir/resolved_profile.json" '.body.selection_ref.purpose = "grant"' "$tmpdir/rp-f2.json" +assert_fail "(f) selection_ref carries the wrong purpose" "E_SHAPE" validate-document "$tmpdir/rp-f2.json" +mutate "$tmpdir/resolved_profile.json" '.body.bindings = [.body.bindings[0],.body.bindings[1],.body.bindings[2]]' "$tmpdir/rp-f3.json" +assert_fail "(f) resolved bindings below the 4 minimum" "E_SHAPE" validate-document "$tmpdir/rp-f3.json" +mutate "$tmpdir/resolved_profile.json" '.body.profile_source.value_format = "canonical-json" | .body.profile_source.source.object_type = "tree"' "$tmpdir/rp-f4.json" +assert_fail "(f) canonical-json source pointing at a tree, not a blob" "E_SHAPE" validate-document "$tmpdir/rp-f4.json" +mutate "$tmpdir/resolved_profile.json" '.body.bindings[0].tool_sources = [{tool_id:"t1",package_source:.body.bindings[0].package_source,config_source:{state:"absent"}},{tool_id:"t1",package_source:.body.bindings[0].package_source,config_source:{state:"absent"}}]' "$tmpdir/rp-f5.json" +assert_fail "(f) duplicate tool_id in tool_sources" "E_SHAPE" validate-document "$tmpdir/rp-f5.json" + +echo "== (n) RepoPath rejects DEL and C1 control characters, not just C0 (validate-document) ==" +mutate "$tmpdir/resolved_profile.json" '.body.profile_source.source.location.value = "profile.json"' "$tmpdir/rp-n1.json" +assert_fail "(n) repository path containing DEL (U+007F)" "E_SHAPE" validate-document "$tmpdir/rp-n1.json" +mutate "$tmpdir/resolved_profile.json" '.body.profile_source.source.location.value = "profile€.json"' "$tmpdir/rp-n2.json" +assert_fail "(n) repository path containing a C1 control character (U+0080)" "E_SHAPE" validate-document "$tmpdir/rp-n2.json" +mutate "$tmpdir/resolved_profile.json" '.body.profile_source.source.location.value = "profileŸ.json"' "$tmpdir/rp-n3.json" +assert_fail "(n) repository path containing a C1 control character (U+009F, top of range)" "E_SHAPE" validate-document "$tmpdir/rp-n3.json" +# Pair: a genuinely non-ASCII but non-control path segment stays legal — the fix +# extends control-character rejection, it does not reject all non-ASCII bytes. +mutate "$tmpdir/resolved_profile.json" '.body.profile_source.source.location.value = "profile-é.json"' "$tmpdir/rp-n4.json" +assert_ok "(n) repository path with a non-control non-ASCII character stays legal" validate-document "$tmpdir/rp-n4.json" + +echo "== (g) stage_request shape (validate-document) ==" +mutate "$tmpdir/request.json" 'del(.body.risk)' "$tmpdir/r-g1.json" +assert_fail "(g) missing risk" "E_SHAPE" validate-document "$tmpdir/r-g1.json" +mutate "$tmpdir/request.json" '.body.risk.tier = {namespace:"core",name:"not-a-tier"}' "$tmpdir/r-g2.json" +assert_fail "(g) unknown core risk tier" "E_SHAPE" validate-document "$tmpdir/r-g2.json" +mutate "$tmpdir/request.json" '.body.operation.permissions = []' "$tmpdir/r-g3.json" +assert_fail "(g) operation permissions below the 1 minimum" "E_SHAPE" validate-document "$tmpdir/r-g3.json" +mutate "$tmpdir/request.json" '.body.operation.arguments = {artifact_kind:"git-patch"}' "$tmpdir/r-g4.json" +assert_fail "(g) git-patch arguments missing allowed_delta" "E_SHAPE" validate-document "$tmpdir/r-g4.json" +mutate "$tmpdir/request.json" '.body.required_evidence_kinds = []' "$tmpdir/r-g5.json" +assert_fail "(g) required_evidence_kinds below minimum" "E_SHAPE" validate-document "$tmpdir/r-g5.json" +mutate "$tmpdir/request.json" '.body.required_evidence_kinds = ["independent-review"]' "$tmpdir/r-g6.json" +assert_fail "(g) producer required_evidence_kinds must be exactly deterministic" "E_SHAPE" validate-document "$tmpdir/r-g6.json" +mutate "$tmpdir/request.json" '.body.finish_condition.input_id = .body.verification_instruction.input_id' "$tmpdir/r-g7.json" +assert_fail "(g) finish_condition and verification_instruction share one input_id" "E_SHAPE" validate-document "$tmpdir/r-g7.json" +mutate "$tmpdir/request.json" '.body.requested_at = "not-a-time"' "$tmpdir/r-g8.json" +assert_fail "(g) malformed requested_at" "E_SHAPE" validate-document "$tmpdir/r-g8.json" +mutate "$tmpdir/request.json" '.body.operation.role = "publisher"' "$tmpdir/r-g9.json" +assert_fail "(g) bootstrap producer-only rule violated by a non-producer role" "E_SHAPE" validate-document "$tmpdir/r-g9.json" +mutate "$tmpdir/request.json" '.body.risk.tier = {namespace:"core",name:"routine"}' "$tmpdir/r-g10.json" +assert_fail "(g) absent target requires bootstrap risk tier, not merely a producer role" "E_SHAPE" validate-document "$tmpdir/r-g10.json" +mutate "$tmpdir/request.json" '.body.requested_at = "2026-99-99T99:99:99Z"' "$tmpdir/r-g11.json" +assert_fail "(g) requested_at with out-of-range month/day/time components" "E_SHAPE" validate-document "$tmpdir/r-g11.json" +# named_input's "document" variant (input_ref -> document_ref_shape) is the one +# spot with no accompanying exact-kind check next to it (unlike every document_ref(K) +# use, which also compares .kind==K) — an unknown .kind here only is_document_kind +# itself can catch, so it is the mutation that actually exercises that fix. +mutate "$tmpdir/request.json" '.body.inputs[0].value = {type:"document", value:{schema_version:1,kind:"not-a-document-kind",id:"x",sha256:("0"*64)}}' "$tmpdir/r-g12.json" +assert_fail "(g) named input document ref with an unknown document kind" "E_SHAPE" validate-document "$tmpdir/r-g12.json" +# is_time must validate the calendar, not just per-field ranges: a well-formed-looking +# non-existent date (Feb 31; Feb 29 in a non-leap year) must still fail, while a real +# leap day must still pass. +mutate "$tmpdir/request.json" '.body.requested_at = "2026-02-31T00:00:00Z"' "$tmpdir/r-g13.json" +assert_fail "(g) requested_at names a day the month does not have" "E_SHAPE" validate-document "$tmpdir/r-g13.json" +mutate "$tmpdir/request.json" '.body.requested_at = "2025-02-29T00:00:00Z"' "$tmpdir/r-g14.json" +assert_fail "(g) requested_at names Feb 29 in a non-leap year" "E_SHAPE" validate-document "$tmpdir/r-g14.json" +mutate "$tmpdir/request.json" '.body.requested_at = "2024-02-29T00:00:00Z"' "$tmpdir/r-g15.json" +assert_ok "(g) requested_at names Feb 29 in a real leap year" validate-document "$tmpdir/r-g15.json" + +echo "== (h) stage_result shape (validate-document) ==" +mutate "$tmpdir/result.json" '.body.evidence = [.body.evidence[0], (.body.evidence[0] | .evidence_id = "ev-2")]' "$tmpdir/s-h1.json" +assert_fail "(h) two evidence items share one kind" "E_SHAPE" validate-document "$tmpdir/s-h1.json" +mutate "$tmpdir/result.json" '.body.status = "not-a-status"' "$tmpdir/s-h2.json" +assert_fail "(h) unknown terminal status" "E_SHAPE" validate-document "$tmpdir/s-h2.json" +mutate "$tmpdir/result.json" '.body.attempt_number = 0' "$tmpdir/s-h3.json" +assert_fail "(h) attempt_number below 1" "E_SHAPE" validate-document "$tmpdir/s-h3.json" +mutate "$tmpdir/result.json" '.body.execution.used_capability = {kind:"registered", id:"not-a-capability"}' "$tmpdir/s-h4.json" +assert_fail "(h) registered capability outside the closed set" "E_SHAPE" validate-document "$tmpdir/s-h4.json" +mutate "$tmpdir/result.json" '.body.execution.metadata.tools.state = "not-applicable"' "$tmpdir/s-h5.json" +assert_fail "(h) tools fact cannot be not-applicable for any execution" "E_SHAPE" validate-document "$tmpdir/s-h5.json" +mutate "$tmpdir/result.json" 'del(.body.outputs[0].ref)' "$tmpdir/s-h6.json" +assert_fail "(h) output missing its content ref" "E_SHAPE" validate-document "$tmpdir/s-h6.json" +mutate "$tmpdir/result.json" '.body.execution.used_capability = {kind:"unclassified", id:"core.harness.produce.v1"}' "$tmpdir/s-h7.json" +assert_fail "(h) unclassified used_capability id equals a registered capability id" "E_SHAPE" validate-document "$tmpdir/s-h7.json" + +echo "== (i) profile-set relations (validate-profile-set) ==" +mutate "$tmpdir/resolved_profile.json" '.body.bindings[0].package_source.source.object_id = ("f"*40)' "$tmpdir/rp-i1.json" +assert_fail "(i) resolved package_source does not match the binding's package_ref" "E_RELATION" validate-profile-set "$tmpdir/profile.json" "$tmpdir/rp-i1.json" \ + "$tmpdir/manifest-producer.json" "$tmpdir/manifest-verifier.json" "$tmpdir/manifest-reviewer.json" "$tmpdir/manifest-publisher.json" +mutate "$tmpdir/resolved_profile.json" '.body.bindings[0].adapter_implementation.version = "not-v1"' "$tmpdir/rp-i2.json" +assert_fail "(i) resolved adapter_implementation.version does not match the manifest" "E_RELATION" validate-profile-set "$tmpdir/profile.json" "$tmpdir/rp-i2.json" \ + "$tmpdir/manifest-producer.json" "$tmpdir/manifest-verifier.json" "$tmpdir/manifest-reviewer.json" "$tmpdir/manifest-publisher.json" +# Renames (rather than reorders) the last binding so the array stays in the +# required canonical sorted-by-binding_id order (core/v1/contracts.jq's +# is_bounded_set now enforces that order) while still breaking the binding_id-set +# match against the profile. +mutate "$tmpdir/resolved_profile.json" '.body.bindings[-1].binding.binding_id = "b-zzz-no-such-binding"' "$tmpdir/rp-i3.json" +assert_fail "(i) resolved bindings do not cover the same binding_id set as the profile" "E_RELATION" validate-profile-set "$tmpdir/profile.json" "$tmpdir/rp-i3.json" \ + "$tmpdir/manifest-producer.json" "$tmpdir/manifest-verifier.json" "$tmpdir/manifest-reviewer.json" "$tmpdir/manifest-publisher.json" +mutate "$tmpdir/profile.json" '.body.bindings[0].manifest_ref.id = "no-such-manifest"' "$tmpdir/p-i4.json" +assert_fail "(i) mutated profile's own digest no longer matches the resolved profile's profile_ref" "E_REF" validate-profile-set "$tmpdir/p-i4.json" "$tmpdir/resolved_profile.json" \ + "$tmpdir/manifest-producer.json" "$tmpdir/manifest-verifier.json" "$tmpdir/manifest-reviewer.json" "$tmpdir/manifest-publisher.json" +mutate "$tmpdir/manifest-producer.json" '.id = "extra-manifest-unrelated"' "$tmpdir/extra-manifest-unrelated.json" +assert_fail "(i) a referenced manifest is simply not supplied (profile/resolved digests untouched)" "E_RELATION" validate-profile-set "$tmpdir/profile.json" "$tmpdir/resolved_profile.json" \ + "$tmpdir/manifest-producer.json" "$tmpdir/extra-manifest-unrelated.json" "$tmpdir/manifest-reviewer.json" "$tmpdir/manifest-publisher.json" +mutate "$tmpdir/resolved_profile.json" '.body.profile_ref.sha256 = ("0"*64)' "$tmpdir/rp-i5.json" +assert_fail "(i) resolved profile_ref digest does not match the supplied profile" "E_REF" validate-profile-set "$tmpdir/profile.json" "$tmpdir/rp-i5.json" \ + "$tmpdir/manifest-producer.json" "$tmpdir/manifest-verifier.json" "$tmpdir/manifest-reviewer.json" "$tmpdir/manifest-publisher.json" +cp "$tmpdir/manifest-producer.json" "$tmpdir/manifest-producer-dup.json" +assert_fail "(i) two supplied manifests share one document id" "E_REF" validate-profile-set "$tmpdir/profile.json" "$tmpdir/resolved_profile.json" \ + "$tmpdir/manifest-producer.json" "$tmpdir/manifest-producer-dup.json" "$tmpdir/manifest-reviewer.json" "$tmpdir/manifest-publisher.json" +mutate "$tmpdir/resolved_profile.json" '.body.bindings[0].config_source = {state:"present", value:{source:.body.bindings[0].package_source.source, value_format:"raw-bytes", value_sha256:("1"*64)}}' "$tmpdir/rp-i6.json" +assert_fail "(i) resolved config_source present without a config_ref on the binding" "E_RELATION" validate-profile-set "$tmpdir/profile.json" "$tmpdir/rp-i6.json" \ + "$tmpdir/manifest-producer.json" "$tmpdir/manifest-verifier.json" "$tmpdir/manifest-reviewer.json" "$tmpdir/manifest-publisher.json" +mutate "$tmpdir/resolved_profile.json" '.body.profile_source.value_sha256 = ("0"*64)' "$tmpdir/rp-i7.json" +assert_fail "(i) resolved profile_source digest does not match the supplied profile's real bytes" "E_RELATION" validate-profile-set "$tmpdir/profile.json" "$tmpdir/rp-i7.json" \ + "$tmpdir/manifest-producer.json" "$tmpdir/manifest-verifier.json" "$tmpdir/manifest-reviewer.json" "$tmpdir/manifest-publisher.json" +mutate "$tmpdir/resolved_profile.json" '.body.bindings[0].manifest_source.value_sha256 = ("0"*64)' "$tmpdir/rp-i8.json" +assert_fail "(i) resolved manifest_source digest does not match the supplied manifest's real bytes" "E_RELATION" validate-profile-set "$tmpdir/profile.json" "$tmpdir/rp-i8.json" \ + "$tmpdir/manifest-producer.json" "$tmpdir/manifest-verifier.json" "$tmpdir/manifest-reviewer.json" "$tmpdir/manifest-publisher.json" +# These two rebuild a fully hash-consistent profile+resolved_profile pair around +# the mutated manifest (mutate() alone can't: changing a referenced manifest's +# bytes changes its digest, and profile.json/resolved_profile.json embed digests +# of each other, so a bare byte-level mutation would only trip the earlier +# "referenced manifest not supplied" check instead of the offer relation itself). +mutate "$tmpdir/manifest-producer.json" '.body.offered_roles = ["verifier"]' "$tmpdir/m-i9.json" +sha_i9=$(hash_of "$tmpdir/m-i9.json") +manifest_shas_i9=$(jq -n --arg p "$sha_i9" --arg v "$sha_verifier" --arg r "$sha_reviewer" --arg u "$sha_publisher" \ + '{producer:$p, verifier:$v, reviewer:$r, publisher:$u}') +# shellcheck disable=SC2016 # single-quoted jq $-vars on purpose, not shell vars +run_expr 'profile_doc($manifest_shas)' --argjson manifest_shas "$manifest_shas_i9" > "$tmpdir/p-i9.json" +sha_p_i9=$(hash_of "$tmpdir/p-i9.json") +# shellcheck disable=SC2016 # single-quoted jq $-vars on purpose, not shell vars +run_expr 'resolved_profile_doc($profile_sha; $manifest_shas)' --arg profile_sha "$sha_p_i9" --argjson manifest_shas "$manifest_shas_i9" > "$tmpdir/rp-i9.json" +assert_fail "(i) manifest does not offer the binding's role" "E_RELATION" validate-profile-set "$tmpdir/p-i9.json" "$tmpdir/rp-i9.json" \ + "$tmpdir/m-i9.json" "$tmpdir/manifest-verifier.json" "$tmpdir/manifest-reviewer.json" "$tmpdir/manifest-publisher.json" +mutate "$tmpdir/manifest-producer.json" '.body.offered_capabilities = []' "$tmpdir/m-i10.json" +sha_i10=$(hash_of "$tmpdir/m-i10.json") +manifest_shas_i10=$(jq -n --arg p "$sha_i10" --arg v "$sha_verifier" --arg r "$sha_reviewer" --arg u "$sha_publisher" \ + '{producer:$p, verifier:$v, reviewer:$r, publisher:$u}') +# shellcheck disable=SC2016 # single-quoted jq $-vars on purpose, not shell vars +run_expr 'profile_doc($manifest_shas)' --argjson manifest_shas "$manifest_shas_i10" > "$tmpdir/p-i10.json" +sha_p_i10=$(hash_of "$tmpdir/p-i10.json") +# shellcheck disable=SC2016 # single-quoted jq $-vars on purpose, not shell vars +run_expr 'resolved_profile_doc($profile_sha; $manifest_shas)' --arg profile_sha "$sha_p_i10" --argjson manifest_shas "$manifest_shas_i10" > "$tmpdir/rp-i10.json" +assert_fail "(i) manifest does not offer the binding's requested capability" "E_RELATION" validate-profile-set "$tmpdir/p-i10.json" "$tmpdir/rp-i10.json" \ + "$tmpdir/m-i10.json" "$tmpdir/manifest-verifier.json" "$tmpdir/manifest-reviewer.json" "$tmpdir/manifest-publisher.json" +# One exact source Git object gets only one format/digest claim across the whole +# resolved profile — every binding's package_source points at the same shared +# package_ref object, so two different value_sha256 claims for it must be rejected +# even though each binding's own manifest-relation check passes in isolation. +mutate "$tmpdir/resolved_profile.json" '.body.bindings[0].package_source.value_sha256 = ("f" * 64)' "$tmpdir/rp-i11.json" +assert_fail "(i) two bindings claim different digests for the same source Git object" "E_RELATION" validate-profile-set "$tmpdir/profile.json" "$tmpdir/rp-i11.json" \ + "$tmpdir/manifest-producer.json" "$tmpdir/manifest-verifier.json" "$tmpdir/manifest-reviewer.json" "$tmpdir/manifest-publisher.json" + +echo "== (j) stage-run relations (validate-stage-run) ==" +mutate "$tmpdir/result.json" '.body.outcome.value = "no-change"' "$tmpdir/s-j1.json" +assert_fail "(j) producer outcome mismatches non-empty outputs" "E_RELATION" validate-stage-run "$tmpdir/request.json" "$tmpdir/resolved_profile.json" "$tmpdir/s-j1.json" +mutate "$tmpdir/result.json" '.body.execution.performer.role = "verifier" | .body.execution.actual_binding.role = "verifier"' "$tmpdir/s-j2.json" +assert_fail "(j) execution performer role does not match the request's binding role" "E_RELATION" validate-stage-run "$tmpdir/request.json" "$tmpdir/resolved_profile.json" "$tmpdir/s-j2.json" +mutate "$tmpdir/result.json" '.body.evidence = [{evidence_id:"ev-1",kind:"independent-review",verdict:"passed",proof_ref:{content_id:"p1",media_type:"application/json",sha256:("2"*64)}}]' "$tmpdir/s-j3.json" +assert_fail "(j) producer result carries reviewer-only evidence kind" "E_RELATION" validate-stage-run "$tmpdir/request.json" "$tmpdir/resolved_profile.json" "$tmpdir/s-j3.json" +mutate "$tmpdir/result.json" '.body.finished_at = "2020-01-01T00:00:00Z"' "$tmpdir/s-j4.json" +assert_fail "(j) finished_at precedes started_at" "E_RELATION" validate-stage-run "$tmpdir/request.json" "$tmpdir/resolved_profile.json" "$tmpdir/s-j4.json" +mutate "$tmpdir/result.json" '.body.attempt_number = 0' "$tmpdir/s-j5.json" +assert_fail "(j) attempt_number below 1 (shape catches it first)" "E_SHAPE" validate-stage-run "$tmpdir/request.json" "$tmpdir/resolved_profile.json" "$tmpdir/s-j5.json" +mutate "$tmpdir/result.json" '.body.request_ref.sha256 = ("0"*64)' "$tmpdir/s-j6.json" +assert_fail "(j) result request_ref digest does not match the supplied request" "E_REF" validate-stage-run "$tmpdir/request.json" "$tmpdir/resolved_profile.json" "$tmpdir/s-j6.json" +mutate "$tmpdir/result.json" '.body.resolved_profile_ref.sha256 = ("0"*64)' "$tmpdir/s-j7.json" +assert_fail "(j) result resolved_profile_ref digest does not match the supplied resolved profile" "E_REF" validate-stage-run "$tmpdir/request.json" "$tmpdir/resolved_profile.json" "$tmpdir/s-j7.json" +mutate "$tmpdir/result.json" 'del(.body.finished_at)' "$tmpdir/s-j8.json" +assert_fail "(j) completed status missing finished_at (status presence matrix)" "E_RELATION" validate-stage-run "$tmpdir/request.json" "$tmpdir/resolved_profile.json" "$tmpdir/s-j8.json" +mutate "$tmpdir/result.json" '.body.status = "skipped" | .body.reason = {reason_id:"r1"} | del(.body.outcome, .body.execution, .body.started_at, .body.finished_at) | .body.evidence = [] | .body.outputs = []' "$tmpdir/s-j9.json" +assert_ok "(j) skipped status with matching empty fields is a legal terminal state" validate-stage-run "$tmpdir/request.json" "$tmpdir/resolved_profile.json" "$tmpdir/s-j9.json" +mutate "$tmpdir/result.json" '.body.status = "skipped" | .body.reason = {reason_id:"r1"} | del(.body.execution, .body.started_at, .body.finished_at) | .body.evidence = [] | .body.outputs = []' "$tmpdir/s-j10.json" +assert_fail "(j) skipped status still carrying an outcome" "E_RELATION" validate-stage-run "$tmpdir/request.json" "$tmpdir/resolved_profile.json" "$tmpdir/s-j10.json" +mutate "$tmpdir/request.json" '.body.operation.binding_id = "no-such-binding"' "$tmpdir/r-j11.json" +# shellcheck disable=SC2016 # single-quoted jq $-vars on purpose, not shell vars +run_expr 'stage_result_doc($request_sha; $resolved_sha; $manifest_shas)' --arg request_sha "$(hash_of "$tmpdir/r-j11.json")" --arg resolved_sha "$sha_resolved" --argjson manifest_shas "$manifest_shas" > "$tmpdir/s-j11.json" +assert_fail "(j) request operation names a binding absent from the resolved profile" "E_RELATION" validate-stage-run "$tmpdir/r-j11.json" "$tmpdir/resolved_profile.json" "$tmpdir/s-j11.json" +mutate "$tmpdir/result.json" '.body.outputs = [.body.outputs[0], (.body.outputs[0] | .output_id = "o2")]' "$tmpdir/s-j12.json" +assert_fail "(j) completed producer change with more than one output" "E_RELATION" validate-stage-run "$tmpdir/request.json" "$tmpdir/resolved_profile.json" "$tmpdir/s-j12.json" +mutate "$tmpdir/result.json" '.body.status = "stale" | .body.reason = {reason_id:"r1"} | del(.body.outcome, .body.execution, .body.started_at, .body.finished_at) | .body.evidence = [] | .body.outputs = [] | .body.stale_observations = [{selector:{kind:"target"},observed:{state:"absent"}}]' "$tmpdir/s-j13.json" +assert_fail "(j) stale observation repeats the request's own unchanged (absent) target" "E_RELATION" validate-stage-run "$tmpdir/request.json" "$tmpdir/resolved_profile.json" "$tmpdir/s-j13.json" +mutate "$tmpdir/result.json" '.body.status = "stale" | .body.reason = {reason_id:"r1"} | del(.body.outcome, .body.execution, .body.started_at, .body.finished_at) | .body.evidence = [] | .body.outputs = [] | .body.stale_observations = [{selector:{kind:"input",input_id:"no-such-input"},observed:{state:"present",value:{type:"document",value:{schema_version:1,kind:"profile",id:"profile-1",sha256:("0"*64)}}}}]' "$tmpdir/s-j14.json" +assert_fail "(j) stale observation names an input absent from the request" "E_RELATION" validate-stage-run "$tmpdir/request.json" "$tmpdir/resolved_profile.json" "$tmpdir/s-j14.json" +mutate "$tmpdir/result.json" '.body.status = "stale" | .body.reason = {reason_id:"r1"} | del(.body.outcome, .body.execution, .body.started_at, .body.finished_at) | .body.evidence = [] | .body.outputs = [] | .body.stale_observations = [{selector:{kind:"environment"},observed:{state:"present",value:{environment_id:"env-1",fingerprint_sha256:("4"*64)}}}]' "$tmpdir/s-j15.json" +assert_ok "(j) stale observation with a genuinely different environment fingerprint is a legal terminal state" validate-stage-run "$tmpdir/request.json" "$tmpdir/resolved_profile.json" "$tmpdir/s-j15.json" +# A stale resolved-profile observation must pin the expected ID, not just the kind: +# the same kind with an unrelated ID names a different document entirely, not a +# staleness claim about this one. +mutate "$tmpdir/result.json" '.body.status = "stale" | .body.reason = {reason_id:"r1"} | del(.body.outcome, .body.execution, .body.started_at, .body.finished_at) | .body.evidence = [] | .body.outputs = [] | .body.stale_observations = [{selector:{kind:"resolved-profile"},observed:{state:"present",value:{schema_version:1,kind:"resolved_profile",id:"resolved-1",sha256:("5"*64)}}}]' "$tmpdir/s-j16.json" +assert_ok "(j) stale resolved-profile observation keeps the request's own ID with a genuinely different digest" validate-stage-run "$tmpdir/request.json" "$tmpdir/resolved_profile.json" "$tmpdir/s-j16.json" +mutate "$tmpdir/result.json" '.body.status = "stale" | .body.reason = {reason_id:"r1"} | del(.body.outcome, .body.execution, .body.started_at, .body.finished_at) | .body.evidence = [] | .body.outputs = [] | .body.stale_observations = [{selector:{kind:"resolved-profile"},observed:{state:"present",value:{schema_version:1,kind:"resolved_profile",id:"some-other-resolved-profile",sha256:("5"*64)}}}]' "$tmpdir/s-j17.json" +assert_fail "(j) stale resolved-profile observation names an unrelated resolved profile ID" "E_RELATION" validate-stage-run "$tmpdir/request.json" "$tmpdir/resolved_profile.json" "$tmpdir/s-j17.json" +# Regression guard (round-0 over-corrected this to an unconditional equality): a +# completed record with failing evidence is explicitly allowed to report execution +# facts that differ from the resolved binding, so it can preserve what went wrong; +# a completed non-inconclusive record must still match exactly. +mutate "$tmpdir/result.json" '.body.execution.environment.environment_id = "env-mismatch"' "$tmpdir/s-j18.json" +assert_fail "(j) completed non-inconclusive execution environment mismatches the request's environment" "E_RELATION" validate-stage-run "$tmpdir/request.json" "$tmpdir/resolved_profile.json" "$tmpdir/s-j18.json" +mutate "$tmpdir/result.json" '.body.evidence[0].verdict = "failed" | .body.outcome = {family:"change",value:"inconclusive"} | .body.outputs = [] | .body.reason = {reason_id:"r1"} | .body.execution.environment.environment_id = "env-mismatch"' "$tmpdir/s-j19.json" +assert_ok "(j) completed-inconclusive execution environment may differ from the resolved binding" validate-stage-run "$tmpdir/request.json" "$tmpdir/resolved_profile.json" "$tmpdir/s-j19.json" + +echo "== (l) unavailable requested facts force a completed result inconclusive (validate-stage-run) ==" +# Builds one alternate model-backed producer binding (base fixtures are all +# deterministic) and rewires the ref chain the same way (i9)/(i10) do: mutate, +# rehash, and feed the new digest into the next document. +model_perms='(["core.perm.target.read.v1","core.perm.scratch.write.v1","core.perm.evidence.write.v1","core.perm.model.invoke.v1"]|sort)' +prompt_ref_literal='{revision:{repository_id:"repo-a",hash_algorithm:"sha1",commit_id:("a"*40)},location:{kind:"path",value:"prompt.json"},object_type:"blob",object_id:("c"*40),mode:"100644"}' +mutate "$tmpdir/resolved_profile.json" ".body.bindings |= map(if .binding.role==\"producer\" then + .binding.execution_kind=\"model\" | .binding.model_request={provider_id:\"prov-1\",model_id:\"model-1\",effort_id:\"effort-1\"} | + .binding.prompt_ref=$prompt_ref_literal | .binding.requested_permissions=$model_perms + else . end)" "$tmpdir/rp-model.json" +sha_rp_model=$(hash_of "$tmpdir/rp-model.json") +mutate "$tmpdir/request.json" ".body.resolved_profile_ref.sha256=\"$sha_rp_model\" | .body.operation.permissions=$model_perms" "$tmpdir/req-model.json" +sha_req_model=$(hash_of "$tmpdir/req-model.json") +mutate "$tmpdir/result.json" ".body.request_ref.sha256=\"$sha_req_model\" | .body.resolved_profile_ref.sha256=\"$sha_rp_model\" | + .body.execution.actual_binding.execution_kind=\"model\" | .body.execution.metadata.kind=\"model\" | + .body.execution.metadata.snapshot={state:\"unavailable\",reason_id:\"no-snapshot\"} | + .body.execution.metadata.provider={state:\"recorded\",value:\"prov-1\",source_ref:{content_id:\"cf-provider\",media_type:\"application/json\",sha256:(\"9\"*64)}} | + .body.execution.metadata.model={state:\"recorded\",value:\"model-1\",source_ref:{content_id:\"cf-model\",media_type:\"application/json\",sha256:(\"9\"*64)}} | + .body.execution.metadata.effort={state:\"recorded\",value:\"effort-1\",source_ref:{content_id:\"cf-effort\",media_type:\"application/json\",sha256:(\"9\"*64)}} | + .body.execution.metadata.prompt={state:\"recorded\",value:$prompt_ref_literal,source_ref:{content_id:\"cf-prompt\",media_type:\"application/json\",sha256:(\"9\"*64)}} | + .body.execution.metadata.skills={state:\"recorded\",value:[],source_ref:{content_id:\"cf-skills\",media_type:\"application/json\",sha256:(\"9\"*64)}} + " "$tmpdir/result-model.json" +assert_ok "(l) model-backed completed producer change with all requested facts recorded" validate-stage-run "$tmpdir/req-model.json" "$tmpdir/rp-model.json" "$tmpdir/result-model.json" +mutate "$tmpdir/result-model.json" '.body.execution.metadata.prompt = {state:"unavailable", reason_id:"prompt-store-unreachable"}' "$tmpdir/result-model-bad.json" +assert_fail "(l) unavailable prompt fact still claims a conclusive outcome" "E_RELATION" validate-stage-run "$tmpdir/req-model.json" "$tmpdir/rp-model.json" "$tmpdir/result-model-bad.json" +mutate "$tmpdir/result-model.json" '.body.execution.metadata.prompt = {state:"unavailable", reason_id:"prompt-store-unreachable"} | + .body.outcome = {family:"change", value:"inconclusive"} | .body.outputs = [] | .body.reason = {reason_id:"r1"}' "$tmpdir/result-model-ok.json" +assert_ok "(l) unavailable prompt fact correctly reported as a completed-inconclusive record" validate-stage-run "$tmpdir/req-model.json" "$tmpdir/rp-model.json" "$tmpdir/result-model-ok.json" +# tools follows the same forced-inconclusive path as provider/model/effort/prompt/ +# skills (round-2 routed those five through metadata_requested_unavailable but +# missed tools) — a completed record whose actual tool use cannot be established +# must not stay conclusive, and must still validate once it honestly reports +# inconclusive instead. +mutate "$tmpdir/result-model.json" '.body.execution.metadata.tools = {state:"unavailable", reason_id:"tool-inventory-unreachable"}' "$tmpdir/result-model-tools-bad.json" +assert_fail "(l) unavailable tools fact still claims a conclusive outcome" "E_RELATION" validate-stage-run "$tmpdir/req-model.json" "$tmpdir/rp-model.json" "$tmpdir/result-model-tools-bad.json" +mutate "$tmpdir/result-model.json" '.body.execution.metadata.tools = {state:"unavailable", reason_id:"tool-inventory-unreachable"} | + .body.outcome = {family:"change", value:"inconclusive"} | .body.outputs = [] | .body.reason = {reason_id:"r1"}' "$tmpdir/result-model-tools-ok.json" +assert_ok "(l) unavailable tools fact correctly reported as a completed-inconclusive record" validate-stage-run "$tmpdir/req-model.json" "$tmpdir/rp-model.json" "$tmpdir/result-model-tools-ok.json" + +echo "== (m) resolved-profile embedded bindings must still satisfy the profile invariants (validate-document, validate-stage-run) ==" +# validate-document on a bare resolved_profile: shape alone lets an embedded +# producer binding through with an empty requested_capabilities/requested_permissions +# set, which profile validation would reject. Reuses profile_binding_capability_ok +# via resolved_bindings_profile_invariants_ok, not a parallel check. +mutate "$tmpdir/resolved_profile.json" '.body.bindings |= map(if .binding.role=="producer" then .binding.requested_capabilities=[] | .binding.requested_permissions=[] else . end)' "$tmpdir/rp-m1.json" +assert_fail "(m) embedded producer binding with emptied capability closure" "E_RELATION" validate-document "$tmpdir/rp-m1.json" +# shellcheck disable=SC2016 # single-quoted jq $-var on purpose, not a shell var +mutate "$tmpdir/resolved_profile.json" '(.body.bindings[] | select(.binding.role=="publisher") | .binding.principal_id) as $shared | .body.bindings |= map(if .binding.role=="reviewer" then .binding.principal_id=$shared else . end)' "$tmpdir/rp-m2.json" +assert_fail "(m) two embedded protected-role bindings share one principal_id" "E_RELATION" validate-document "$tmpdir/rp-m2.json" +assert_ok "(m) unmutated resolved_profile still validates on its own" validate-document "$tmpdir/resolved_profile.json" +# The same gap exists behind validate-stage-run: the request/result relations +# never look at a binding's own requested_capabilities/requested_permissions, only +# its role, so without this fix a stage run is approved for an operation the +# embedded binding never actually requested. +mutate "$tmpdir/resolved_profile.json" '.body.bindings |= map(if .binding.role=="producer" then .binding.requested_capabilities=[] | .binding.requested_permissions=[] else . end)' "$tmpdir/rp-m3.json" +sha_rp_m3=$(hash_of "$tmpdir/rp-m3.json") +mutate "$tmpdir/request.json" ".body.resolved_profile_ref.sha256=\"$sha_rp_m3\"" "$tmpdir/req-m3.json" +sha_req_m3=$(hash_of "$tmpdir/req-m3.json") +mutate "$tmpdir/result.json" ".body.request_ref.sha256=\"$sha_req_m3\" | .body.resolved_profile_ref.sha256=\"$sha_rp_m3\"" "$tmpdir/result-m3.json" +assert_fail "(m) stage-run approves an operation its embedded binding never requested" "E_RELATION" validate-stage-run "$tmpdir/req-m3.json" "$tmpdir/rp-m3.json" "$tmpdir/result-m3.json" +assert_ok "(m) unmutated request/resolved_profile/result triple still validates as a stage-run" validate-stage-run "$tmpdir/request.json" "$tmpdir/resolved_profile.json" "$tmpdir/result.json" + +echo "== (k) jq version pin and SHA-tool fallback (environment) ==" +fakebin="$tmpdir/fakebin" +mkdir -p "$fakebin" +cat > "$fakebin/jq" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = "--version" ]; then echo "jq-1.7"; exit 0; fi +exec /usr/bin/env jq "$@" +EOF +chmod +x "$fakebin/jq" +out="$(PATH="$fakebin:$PATH" bash "$wrapper" validate-document "$tmpdir/profile.json" 2>&1 1>/dev/null)" && rc=0 || rc=$? +assert_eq "(k) non-1.6 jq on PATH is rejected (exit)" "1" "$([ "$rc" -ne 0 ] && echo 1 || echo 0)" +assert_eq "(k) non-1.6 jq on PATH is rejected (token)" "E_RUNTIME" "$out" +# The exact command form (including the 1-8 manifest count) must be checked before +# any runtime dependency, so a bad command/arity still reports E_USAGE even on a +# host without the pinned jq — the well-formed case just above still hits E_RUNTIME, +# proving this isn't just a deleted check. +out="$(PATH="$fakebin:$PATH" bash "$wrapper" bogus-command 2>&1 1>/dev/null)" && rc=0 || rc=$? +assert_eq "(k) usage checked before the jq pin: unknown command (exit)" "1" "$([ "$rc" -ne 0 ] && echo 1 || echo 0)" +assert_eq "(k) usage checked before the jq pin: unknown command (token)" "E_USAGE" "$out" +out="$(PATH="$fakebin:$PATH" bash "$wrapper" validate-document 2>&1 1>/dev/null)" && rc=0 || rc=$? +assert_eq "(k) usage checked before the jq pin: missing arg (token)" "E_USAGE" "$out" + +nojqbin="$tmpdir/nojqbin" +mkdir -p "$nojqbin" +for b in bash sh cat head wc awk tr mktemp rm dirname cmp env printf sha256sum shasum; do + p="$(command -v "$b" 2>/dev/null || true)" + [ -n "$p" ] && ln -sf "$p" "$nojqbin/$b" +done +rm -f "$nojqbin/sha256sum" "$nojqbin/shasum" +out="$(PATH="$nojqbin" bash "$wrapper" validate-document "$tmpdir/profile.json" 2>&1 1>/dev/null)" && rc=0 || rc=$? +assert_eq "(k) missing SHA tool -> E_RUNTIME (exit)" "1" "$([ "$rc" -ne 0 ] && echo 1 || echo 0)" +assert_eq "(k) missing SHA tool -> E_RUNTIME (token)" "E_RUNTIME" "$out" + +noshasumbin="$tmpdir/noshasumbin" +mkdir -p "$noshasumbin" +for b in bash sh jq cat head wc awk tr mktemp rm dirname cmp env printf shasum; do + p="$(command -v "$b" 2>/dev/null || true)" + [ -n "$p" ] && ln -sf "$p" "$noshasumbin/$b" +done +rm -f "$noshasumbin/sha256sum" +out2="$(PATH="$noshasumbin" bash "$wrapper" validate-document "$tmpdir/profile.json" 2>&1)" +assert_eq "(k) falls back to shasum -a 256 when sha256sum is absent (empty output)" "" "$out2" + +echo "== (p) sanitized mktemp -d failure mapping (environment) ==" +# A failing `mktemp -d` (read-only/full/unavailable temp dir) must map to sanitized +# E_RUNTIME, not leak mktemp's own raw diagnostic/path past `set -e`. The stub only +# shadows mktemp; every other tool still resolves off the real PATH behind it. +mktemp_diagnostic="mktemp: failed to create a temp directory (stub)" +failmktempbin="$tmpdir/failmktempbin" +mkdir -p "$failmktempbin" +cat > "$failmktempbin/mktemp" <&2 +exit 1 +EOF +chmod +x "$failmktempbin/mktemp" +out3="$(PATH="$failmktempbin:$PATH" bash "$wrapper" validate-document "$tmpdir/profile.json" 2>&1 1>/dev/null)" && rc3=0 || rc3=$? +assert_eq "(p) failing mktemp -d (exit nonzero)" "1" "$([ "$rc3" -ne 0 ] && echo 1 || echo 0)" +assert_eq "(p) failing mktemp -d maps to sanitized E_RUNTIME" "E_RUNTIME" "$out3" +assert_not_contains "(p) failing mktemp -d never leaks its raw diagnostic" "$mktemp_diagnostic" "$out3" +# Pair: the normal (succeeding) mktemp path this fix must not regress. +assert_ok "(p) unmutated mktemp path still succeeds" validate-document "$tmpdir/profile.json" + +echo "-- $passed passed, $failed failed --" +if [ "$failed" -ne 0 ]; then + exit 1 +fi