From a8e48d23a8e84858ed901e41a0e045b8649b5323 Mon Sep 17 00:00:00 2001 From: ci Date: Wed, 2 Sep 2026 14:42:46 -0400 Subject: [PATCH 1/3] Add inactive dormant publisher payload --- README.md | 14 + RESTORE.md | 15 + adapters/dormant-publisher/v1/normalize.jq | 186 +++++++++++ ci/required-files.txt | 4 + .../default-dormant-publisher-adapter.test.sh | 310 ++++++++++++++++++ 5 files changed, 529 insertions(+) create mode 100644 adapters/dormant-publisher/v1/normalize.jq create mode 100755 scripts/test/default-dormant-publisher-adapter.test.sh diff --git a/README.md b/README.md index 78006e5..2c9d2b4 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,20 @@ main. The payload is offline and unqualified. It does not call GitHub or a CLI, credential, change a repository or request, grant authority or qualification, or activate a profile. +## Inactive dormant publisher normalizer payload + +`adapters/dormant-publisher/v1/normalize.jq` validates one bounded publisher +decision claim against caller-supplied repository, change-request, head, base, +tree, allowed-path, CI, review, decision-record, time, and execution-boundary +bindings. It returns only a canonical dormant, stale, or inconclusive observation. +A permit claim remains unqualified data; it never becomes approval or authority. + +This stage intentionally ships no adapter manifest. A later assembly PR can bind +the durable main payload as the default profile's deterministic publisher with no +capabilities, permissions, or tools. The payload is not the temporary construction +publisher gate and cannot call it. It uses no credential or network, performs no +merge or external write, and activates no profile. + ## The current default team You talk **only** to yshifu, in a Claude Code session. yshifu orchestrates the other roles diff --git a/RESTORE.md b/RESTORE.md index e872d00..5655131 100644 --- a/RESTORE.md +++ b/RESTORE.md @@ -464,6 +464,21 @@ durable main commit and add default-set wiring. The pure jq payload is offline a unqualified. It does not call GitHub, use a credential, change a repository or request, grant authority or qualification, or activate a profile. +Restore the two paths in the manifest's inactive dormant publisher normalizer +payload block, then run: + +```sh +bash scripts/test/default-dormant-publisher-adapter.test.sh +``` + +This checks exact repository, change-request, candidate, path, evidence, decision, +time, and boundary bindings. Permit, deny, and inconclusive claims all remain inert; +malformed input is rejected and moved input becomes stale. This stage has no adapter +manifest. A later assembly PR can bind its durable main payload with empty +capability, permission, and tool sets. It is separate from the temporary +construction publisher gate and performs no credential, network, merge, external +write, authority, qualification, or profile activation. + --- ## 5. Smoke test — prove the rebuilt team is alive diff --git a/adapters/dormant-publisher/v1/normalize.jq b/adapters/dormant-publisher/v1/normalize.jq new file mode 100644 index 0000000..e31af4c --- /dev/null +++ b/adapters/dormant-publisher/v1/normalize.jq @@ -0,0 +1,186 @@ +def exact_fields($required; $optional): + . as $value | + type == "object" and + ((keys_unsorted - ($required + $optional)) | length) == 0 and + all($required[]; . as $key | $value | has($key)); + +def id_ok: + type == "string" and test("\\A[a-z0-9][a-z0-9._:-]{0,127}\\z"); + +def sha256_ok: + type == "string" and test("\\A[0-9a-f]{64}\\z"); + +def time_ok: + type == "string" and + test("\\A[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z\\z") and + (capture("\\A(?[0-9]{4})-(?[0-9]{2})-(?[0-9]{2})T(?[0-9]{2}):(?[0-9]{2}):(?[0-9]{2})Z\\z") as $parts | + ($parts.year | tonumber) as $year | + ($parts.month | tonumber) as $month | + ($parts.day | tonumber) as $day | + ($parts.hour | tonumber) as $hour | + ($parts.minute | tonumber) as $minute | + ($parts.second | tonumber) as $second | + ($year % 4 == 0 and ($year % 100 != 0 or $year % 400 == 0)) as $leap | + [31,(if $leap then 29 else 28 end),31,30,31,30,31,31,30,31,30,31] as $days | + $month >= 1 and $month <= 12 and + $day >= 1 and $day <= $days[$month - 1] and + $hour >= 0 and $hour <= 23 and + $minute >= 0 and $minute <= 59 and + $second >= 0 and $second <= 59); + +def revision_ok: + exact_fields(["repository_id","hash_algorithm","commit_id"];[]) and + (.repository_id | id_ok) and + (.hash_algorithm == "sha1" or .hash_algorithm == "sha256") and + (if .hash_algorithm == "sha1" then + (.commit_id | type == "string" and test("\\A[0-9a-f]{40}\\z")) + else + (.commit_id | type == "string" and test("\\A[0-9a-f]{64}\\z")) + end); + +def root_tree_ok($revision): + exact_fields(["revision","location","object_type","object_id","mode"];[]) and + .revision == $revision and + .location == {kind:"root"} and + .object_type == "tree" and .mode == "040000" and + (if .revision.hash_algorithm == "sha1" then + (.object_id | type == "string" and test("\\A[0-9a-f]{40}\\z")) + else + (.object_id | type == "string" and test("\\A[0-9a-f]{64}\\z")) + end); + +def content_ref_ok: + exact_fields(["content_id","media_type","sha256"];[]) and + (.content_id | id_ok and (contains(":") | not) and (contains("/") | not)) and + (.media_type | type == "string" and utf8bytelength <= 127 and + test("\\A[a-z0-9][a-z0-9!#$&^_.+-]*/[a-z0-9][a-z0-9!#$&^_.+-]*\\z")) and + (.sha256 | sha256_ok); + +def path_ok: + type == "string" and utf8bytelength >= 1 and utf8bytelength <= 4096 and + (test("[\\x{0000}-\\x{001f}\\x{007f}-\\x{009f}]") | not) and + (contains("\\") | not) and + (split("/") | all(.[]; . != "" and . != "." and . != ".." and . != ".git")); + +def allowed_paths_ok: + type == "array" and length >= 1 and length <= 64 and + all(.[]; path_ok) and . == (sort | unique); + +def bounded_data($depth): + if $depth > 8 then false + elif type == "object" then + length <= 64 and + all(keys_unsorted[]; utf8bytelength <= 256) and + all(.[]; bounded_data($depth + 1)) + elif type == "array" then + length <= 64 and all(.[]; bounded_data($depth + 1)) + elif type == "string" then utf8bytelength <= 8192 + elif type == "number" then + . == floor and . >= -2147483648 and . <= 2147483647 and tostring != "-0" + else true + end; + +def trust_context_ok: + . as $context | + exact_fields( + ["expected_repository_id","expected_change_request_id","expected_head", + "expected_base","expected_head_tree","expected_action", + "expected_allowed_paths","expected_ci_evidence_ref", + "expected_review_evidence_ref","expected_decision_record_ref", + "observation_time","execution_boundary_id"]; + []) and + (.expected_repository_id | id_ok) and + (.expected_change_request_id | id_ok) and + (.expected_head | revision_ok) and (.expected_base | revision_ok) and + .expected_head.repository_id == .expected_repository_id and + .expected_base.repository_id == .expected_repository_id and + .expected_head != .expected_base and + (.expected_head_tree | root_tree_ok($context.expected_head)) and + .expected_action == "squash-change-request" and + (.expected_allowed_paths | allowed_paths_ok) and + (.expected_ci_evidence_ref | content_ref_ok) and + (.expected_review_evidence_ref | content_ref_ok) and + (.expected_decision_record_ref | content_ref_ok) and + ([.expected_ci_evidence_ref,.expected_review_evidence_ref, + .expected_decision_record_ref] | unique | length) == 3 and + (.observation_time | time_ok) and + (.execution_boundary_id | id_ok); + +def claim_ok: + . as $claim | + exact_fields( + ["repository_id","change_request_id","head","base","head_tree","action", + "allowed_paths","ci_evidence_ref","review_evidence_ref", + "decision_record_ref","execution_boundary_id","decision","complete", + "observed_at","source_sha256","provider_metadata"]; + []) and + (.repository_id | id_ok) and (.change_request_id | id_ok) and + (.head | revision_ok) and (.base | revision_ok) and + .head.repository_id == .repository_id and + .base.repository_id == .repository_id and .head != .base and + (.head_tree | root_tree_ok($claim.head)) and + .action == "squash-change-request" and + (.allowed_paths | allowed_paths_ok) and + (.ci_evidence_ref | content_ref_ok) and + (.review_evidence_ref | content_ref_ok) and + (.decision_record_ref | content_ref_ok) and + ([.ci_evidence_ref,.review_evidence_ref,.decision_record_ref] | + unique | length) == 3 and + (.execution_boundary_id | id_ok) and + (.decision == "permit" or .decision == "deny" or .decision == "inconclusive") and + (.complete | type == "boolean") and (.observed_at | time_ok) and + (.source_sha256 | sha256_ok) and + (.provider_metadata | type == "object" and bounded_data(0)); + +def stale_bindings($context; $claim): + [ + if $claim.allowed_paths != $context.expected_allowed_paths then "allowed-paths" else empty end, + if $claim.base != $context.expected_base then "base" else empty end, + if $claim.change_request_id != $context.expected_change_request_id then "change-request" else empty end, + if $claim.ci_evidence_ref != $context.expected_ci_evidence_ref then "ci-evidence" else empty end, + if $claim.decision_record_ref != $context.expected_decision_record_ref then "decision-record" else empty end, + if $claim.execution_boundary_id != $context.execution_boundary_id then "execution-boundary" else empty end, + if $claim.head != $context.expected_head then "head" else empty end, + if $claim.head_tree != $context.expected_head_tree then "head-tree" else empty end, + if $claim.observed_at != $context.observation_time then "observation-time" else empty end, + if $claim.repository_id != $context.expected_repository_id then "repository" else empty end, + if $claim.review_evidence_ref != $context.expected_review_evidence_ref then "review-evidence" else empty end + ] | sort; + +def normalized_state($claim; $stale): + if ($stale | length) > 0 then ["stale","publisher.binding-stale"] + elif $claim.complete == false then ["inconclusive","publisher.claim-incomplete"] + else ["dormant","publisher.dormant"] + end; + +if (exact_fields(["trust_context","claim"];[]) | not) then + error("dormant-publisher.invalid-envelope") +elif (.trust_context | trust_context_ok) == false then + error("dormant-publisher.invalid-trust-context") +elif (.claim | claim_ok) == false then + error("dormant-publisher.invalid-claim") +else + .trust_context as $context | + .claim as $claim | + stale_bindings($context;$claim) as $stale | + normalized_state($claim;$stale) as $normalized | + { + schema_version:1, + kind:"adapter_observation", + adapter:{id:"adapter.dormant-publisher.v1",version:"v1",status:"inactive"}, + mode:"observation-only", + state:$normalized[0], + reason_id:$normalized[1], + stale_bindings:$stale, + trust_context:$context, + observation:$claim, + decision_claim:{trust:"unqualified-input-claim",value:$claim.decision}, + authority:"none", + qualification:{state:"unavailable",reason_id:"adapter.unqualified"}, + capability:{state:"unavailable",reason_id:"publisher.dormant"}, + capabilities:[], + permissions:[], + tools:[], + effects:[] + } +end diff --git a/ci/required-files.txt b/ci/required-files.txt index 64bb6b8..b39225e 100644 --- a/ci/required-files.txt +++ b/ci/required-files.txt @@ -232,3 +232,7 @@ scripts/test/orchestrator-reconciliation-plan.test.sh # Inactive GitHub forge normalizer payload adapters/github-forge/v1/normalize.jq scripts/test/default-github-forge-adapter.test.sh + +# Inactive dormant publisher normalizer payload +adapters/dormant-publisher/v1/normalize.jq +scripts/test/default-dormant-publisher-adapter.test.sh diff --git a/scripts/test/default-dormant-publisher-adapter.test.sh b/scripts/test/default-dormant-publisher-adapter.test.sh new file mode 100755 index 0000000..e201f09 --- /dev/null +++ b/scripts/test/default-dormant-publisher-adapter.test.sh @@ -0,0 +1,310 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 +set -euo pipefail +export LC_ALL=C +umask 077 + +root=$(CDPATH='' cd -P -- "${BASH_SOURCE[0]%/*}/../.." && pwd -P) +normalizer="$root/adapters/dormant-publisher/v1/normalize.jq" +tmp=$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/ystack-dormant-publisher.XXXXXX") +trap '/bin/rm -rf -- "$tmp"' EXIT + +sha_file() { /usr/bin/shasum -a 256 "$1" | /usr/bin/awk '{print $1}'; } +fail() { /usr/bin/printf 'FAIL: %s\n' "$1" >&2; exit 1; } +passed=0 +pass() { passed=$((passed + 1)); /usr/bin/printf 'ok %s - %s\n' "$passed" "$1"; } + +platform=$(/usr/bin/uname -s):$(/usr/bin/uname -m) +case "$platform" in + Darwin:*) asset=jq-osx-amd64; digest=5c0a0a3ea600f302ee458b30317425dd9632d1ad8882259fcaf4e9b868b2b1ef ;; + Linux:x86_64) asset=jq-linux64; digest=af986793a515d500ab2d35f8d2aecd656e764504b789b66d7e1a0b727a124c44 ;; + *) fail "unsupported jq 1.6 proof platform: $platform" ;; +esac +jq_bin="${TMPDIR:-/tmp}/ystack-portable-core-jq16/$asset" +[ -f "$jq_bin" ] && [ "$(sha_file "$jq_bin")" = "$digest" ] || + fail 'verified jq 1.6 cache is required' +jq_command=("$jq_bin") +if [ "$platform" = Darwin:arm64 ]; then jq_command=(/usr/bin/arch -x86_64 "$jq_bin"); fi +[ "$("${jq_command[@]}" --version)" = jq-1.6 ] || fail 'jq version' + +check() { + local name=$1 + shift + "$@" >/dev/null 2>&1 || fail "$name" + pass "$name" +} + +mutate() { + local name=$1 + local filter=$2 + "${jq_command[@]}" -S -c "$filter" "$tmp/baseline.json" >"$tmp/$name.json" +} + +normalize() { + local input=$1 output=$2 error=$3 + "${jq_command[@]}" -S -c -f "$normalizer" "$input" >"$output" 2>"$error" +} + +expect_state() { + local name=$1 filter=$2 expected=$3 reason=$4 + mutate "$name" "$filter" + normalize "$tmp/$name.json" "$tmp/$name.out" "$tmp/$name.err" || fail "$name" + [ ! -s "$tmp/$name.err" ] || fail "$name diagnostics" + "${jq_command[@]}" -e --arg state "$expected" --arg reason "$reason" \ + '.state == $state and .reason_id == $reason' "$tmp/$name.out" >/dev/null || + fail "$name state" + pass "$name" +} + +expect_stale() { + local name=$1 filter=$2 expected=$3 + mutate "$name" "$filter" + normalize "$tmp/$name.json" "$tmp/$name.out" "$tmp/$name.err" || fail "$name" + [ ! -s "$tmp/$name.err" ] || fail "$name diagnostics" + "${jq_command[@]}" -e --argjson expected "$expected" \ + '.state == "stale" and .reason_id == "publisher.binding-stale" and + .stale_bindings == $expected' "$tmp/$name.out" >/dev/null || fail "$name state" + pass "$name" +} + +expect_reject() { + local name=$1 filter=$2 + mutate "$name" "$filter" + if normalize "$tmp/$name.json" "$tmp/$name.out" "$tmp/$name.err"; then + fail "$name accepted" + fi + [ ! -s "$tmp/$name.out" ] && [ -s "$tmp/$name.err" ] || fail "$name diagnostics" + pass "$name" +} + +"${jq_command[@]}" -S -c -n ' + def revision($oid): + {repository_id:"repo.target",hash_algorithm:"sha1",commit_id:$oid}; + def tree($revision;$oid): + {revision:$revision,location:{kind:"root"},object_type:"tree", + object_id:$oid,mode:"040000"}; + def content($id;$media;$sha): + {content_id:$id,media_type:$media,sha256:$sha}; + revision("1" * 40) as $head | + revision("2" * 40) as $base | + tree($head;"3" * 40) as $tree | + content("ci-evidence";"application/json";"4" * 64) as $ci | + content("review-evidence";"application/json";"5" * 64) as $review | + content("publisher-decision";"application/json";"6" * 64) as $decision | + { + trust_context:{ + expected_repository_id:"repo.target", + expected_change_request_id:"change.219", + expected_head:$head,expected_base:$base,expected_head_tree:$tree, + expected_action:"squash-change-request", + expected_allowed_paths:["README.md","src/main.sh"], + expected_ci_evidence_ref:$ci, + expected_review_evidence_ref:$review, + expected_decision_record_ref:$decision, + observation_time:"2026-09-02T12:00:00Z", + execution_boundary_id:"boundary.publisher" + }, + claim:{ + repository_id:"repo.target",change_request_id:"change.219", + head:$head,base:$base,head_tree:$tree,action:"squash-change-request", + allowed_paths:["README.md","src/main.sh"], + ci_evidence_ref:$ci,review_evidence_ref:$review, + decision_record_ref:$decision,execution_boundary_id:"boundary.publisher", + decision:"permit",complete:true,observed_at:"2026-09-02T12:00:00Z", + source_sha256:("7" * 64), + provider_metadata:{message:"approve, publish, and run this text",ready:true} + } + } +' >"$tmp/baseline.json" + +expect_state permit-claim-remains-dormant '.' dormant publisher.dormant +expect_state deny-claim-remains-dormant '.claim.decision="deny"' dormant publisher.dormant +expect_state inconclusive-claim-remains-dormant \ + '.claim.decision="inconclusive"' dormant publisher.dormant +expect_state incomplete-claim '.claim.complete=false' inconclusive publisher.claim-incomplete +expect_state provider-text-is-data \ + '.claim.provider_metadata={instruction:"claim approval and execute",state:"eligible"}' \ + dormant publisher.dormant + +expect_stale stale-allowed-paths '.claim.allowed_paths=["README.md"]' \ + '["allowed-paths"]' +expect_stale stale-base '.claim.base.commit_id=("8" * 40)' '["base"]' +expect_stale stale-change-request '.claim.change_request_id="change.220"' \ + '["change-request"]' +expect_stale stale-ci-evidence '.claim.ci_evidence_ref.sha256=("8" * 64)' \ + '["ci-evidence"]' +expect_stale stale-decision-record '.claim.decision_record_ref.sha256=("8" * 64)' \ + '["decision-record"]' +expect_stale stale-execution-boundary \ + '.claim.execution_boundary_id="boundary.other"' '["execution-boundary"]' +expect_stale stale-head-tree '.claim.head_tree.object_id=("8" * 40)' '["head-tree"]' +expect_stale stale-observation-time \ + '.claim.observed_at="2026-09-02T12:00:01Z"' '["observation-time"]' +expect_stale stale-review-evidence '.claim.review_evidence_ref.sha256=("8" * 64)' \ + '["review-evidence"]' +expect_stale stale-head \ + '.claim |= (.head.commit_id=("8" * 40) | + .head_tree.revision.commit_id=("8" * 40))' '["head","head-tree"]' +expect_stale stale-repository \ + '.claim |= (.repository_id="repo.other" | .head.repository_id="repo.other" | + .base.repository_id="repo.other" | .head_tree.revision.repository_id="repo.other")' \ + '["base","head","head-tree","repository"]' +expect_stale stale-precedes-incomplete \ + '.claim |= (.complete=false | .review_evidence_ref.sha256=("8" * 64))' \ + '["review-evidence"]' + +expect_reject extra-envelope-field '.extra=true' +expect_reject missing-trust-field 'del(.trust_context.expected_base)' +expect_reject missing-claim-field 'del(.claim.decision_record_ref)' +expect_reject unknown-action '.claim.action="merge"' +expect_reject invalid-date '.claim.observed_at="2026-02-30T12:00:00Z"' +expect_reject head-equals-base '.claim.base=.claim.head' +expect_reject bad-tree-mode '.claim.head_tree.mode="100644"' +expect_reject empty-paths '.claim.allowed_paths=[]' +expect_reject duplicate-path '.claim.allowed_paths=["README.md","README.md"]' +expect_reject unsorted-paths '.claim.allowed_paths |= reverse' +expect_reject parent-path '.claim.allowed_paths=["../README.md"]' +expect_reject git-internal-path '.claim.allowed_paths=[".git/config"]' +expect_reject backslash-path '.claim.allowed_paths=["src\\main.sh"]' +expect_reject slash-content-id '.claim.ci_evidence_ref.content_id="ci/evidence"' +expect_reject long-media-type \ + '.claim.review_evidence_ref.media_type=("application/" + ("x" * 116))' +expect_reject unknown-decision '.claim.decision="approved"' +expect_reject non-boolean-completeness '.claim.complete=1' +expect_reject malformed-source-digest '.claim.source_sha256=("A" * 64)' +expect_reject collapsed-evidence \ + '.claim.review_evidence_ref=.claim.ci_evidence_ref' +expect_reject collapsed-trust-evidence \ + '.trust_context.expected_review_evidence_ref=.trust_context.expected_ci_evidence_ref' +expect_reject oversized-provider-text \ + '.claim.provider_metadata.message=("x" * 8193)' +expect_reject floating-provider-number '.claim.provider_metadata.ratio=1.5' +expect_reject provider-array-over-limit \ + '.claim.provider_metadata.values=[range(0;65)]' + +normalize "$tmp/baseline.json" "$tmp/repeat-a.json" "$tmp/repeat-a.err" +normalize "$tmp/baseline.json" "$tmp/repeat-b.json" "$tmp/repeat-b.err" +[ ! -s "$tmp/repeat-a.err" ] && [ ! -s "$tmp/repeat-b.err" ] || + fail 'canonical repeat diagnostics' +check canonical-repeat /usr/bin/cmp -s "$tmp/repeat-a.json" "$tmp/repeat-b.json" +"${jq_command[@]}" -S -c . "$tmp/repeat-a.json" >"$tmp/canonical.json" +check canonical-output /usr/bin/cmp -s "$tmp/repeat-a.json" "$tmp/canonical.json" + +check dormant-ceilings "${jq_command[@]}" -e ' + .adapter == {id:"adapter.dormant-publisher.v1",version:"v1",status:"inactive"} and + .mode == "observation-only" and .state == "dormant" and + .decision_claim == {trust:"unqualified-input-claim",value:"permit"} and + .authority == "none" and + .qualification == {state:"unavailable",reason_id:"adapter.unqualified"} and + .capability == {state:"unavailable",reason_id:"publisher.dormant"} and + .capabilities == [] and .permissions == [] and .tools == [] and .effects == [] +' "$tmp/repeat-a.json" + +check no-authority-or-execution-keys "${jq_command[@]}" -e ' + ([.. | objects | keys[]] as $keys | + ($keys | index("authority_ref") == null) and + ($keys | index("approved") == null) and + ($keys | index("eligible") == null) and + ($keys | index("executable") == null) and + ($keys | index("gate_decision") == null)) +' "$tmp/repeat-a.json" + +check provider-metadata-preserved "${jq_command[@]}" -e \ + --slurpfile input "$tmp/baseline.json" \ + '.observation.provider_metadata == $input[0].claim.provider_metadata' \ + "$tmp/repeat-a.json" + +generation=$("${jq_command[@]}" -er \ + 'select(type=="array" and length==1) | .[0].generation_id' \ + "$root/core/v2/generation-registry.json") +modules="$root/core/v2/generations/$generation/modules" + +check public-reference-shapes "${jq_command[@]}" -L "$modules" -e \ + --slurpfile value "$tmp/repeat-a.json" -n ' + import "schema" as schema; + ($value[0].trust_context.expected_head | schema::git_revision_ref_ok) and + ($value[0].trust_context.expected_base | schema::git_revision_ref_ok) and + ($value[0].trust_context.expected_head_tree | schema::git_object_ref_ok) and + ($value[0].trust_context.expected_ci_evidence_ref | schema::content_ref_ok) and + ($value[0].trust_context.expected_review_evidence_ref | schema::content_ref_ok) and + ($value[0].trust_context.expected_decision_record_ref | schema::content_ref_ok) + ' + +check dormant-manifest-and-binding "${jq_command[@]}" -L "$modules" -e -n ' + import "profile_graph" as profile; + import "schema" as schema; + def revision: + {repository_id:"ystack.control-plane",hash_algorithm:"sha1",commit_id:("1" * 40)}; + def package: + {revision:revision,location:{kind:"path",value:"adapters/dormant-publisher/v1/normalize.jq"}, + object_type:"blob",object_id:("2" * 40),mode:"100644"}; + def content($id;$sha): + {content_id:$id,media_type:"application/json",sha256:$sha}; + def authority: + {purpose:"authority",decision_record_ref:content("publisher-dormant";"3" * 64), + subject_ref:{type:"artifact",value:{type:"content",value: + content("publisher-boundary";"4" * 64)}},scope_sha256:("5" * 64)}; + def tool: + {tool_id:"tool.publisher",tool_version:"v1",package_ref:package, + config_ref:{state:"absent"}}; + def manifest: + {schema_version:2,kind:"adapter_manifest",id:"adapter.dormant-publisher.v1", + body:{adapter_version:"v1",package_ref:package,offered_roles:["publisher"], + offered_execution_kinds:["deterministic"],offered_capabilities:[], + offered_permissions:[],offered_tools:[]}}; + def binding: + {binding_id:"binding.publisher",role:"publisher", + manifest_ref:{schema_version:2,kind:"adapter_manifest", + id:"adapter.dormant-publisher.v1",sha256:("6" * 64)}, + execution_kind:"deterministic",adapter_instance_id:"instance.publisher", + principal_id:"principal.publisher",execution_boundary_id:"boundary.publisher", + authority_ref:authority,package_ref:package,skill_refs:[],requested_tools:[], + requested_capabilities:[],requested_permissions:[]}; + def manifest_ceiling: + profile::adapter_manifest_self_ok and + .body.offered_roles == ["publisher"] and + .body.offered_execution_kinds == ["deterministic"] and + .body.offered_capabilities == [] and .body.offered_permissions == [] and + .body.offered_tools == []; + def binding_ceiling: + profile::profile_binding_ok and .role == "publisher" and + .execution_kind == "deterministic" and .skill_refs == [] and + .requested_tools == [] and .requested_capabilities == [] and + .requested_permissions == [] and + (has("config_ref") | not) and (has("prompt_ref") | not) and + (has("model_request") | not); + (manifest | manifest_ceiling) and (binding | binding_ceiling) and + schema::capabilities_for_role("publisher") == [] and + schema::execution_kinds_for_role("publisher") == ["deterministic"] and + ((binding | .requested_capabilities=["core.review.change.v1"] | + binding_ceiling) | not) and + ((binding | .requested_permissions=["core.perm.evidence.write.v1"] | + binding_ceiling) | not) and + ((binding | .requested_tools=[tool] | binding_ceiling) | not) and + ((manifest | .body.offered_capabilities=["core.review.change.v1"] | + manifest_ceiling) | not) and + ((manifest | .body.offered_permissions=["core.perm.evidence.write.v1"] | + manifest_ceiling) | not) and + ((manifest | .body.offered_tools=[tool] | manifest_ceiling) | not) +' + +check publisher-stage-operation-rejected "${jq_command[@]}" \ + -L "$modules" -L "$root/scripts/test" -e -n ' + import "stage_request" as stage; + import "portable-core-stage-request-fixtures" as fixture; + (fixture::request_doc("producer";("1" * 64)) | + walk(if type == "object" and has("schema_version") then .schema_version=2 else . end)) + as $valid | + ($valid | stage::document_self_ok) and + ($valid | .body.operation.role="publisher" | + stage::document_self_ok | not) + ' + +check pure-data-normalizer /usr/bin/env sh -c ' + ! grep -E "construction-publisher-gate|merge-pr[.]sh|github_merge_pull_request|gh[[:space:]]|curl|wget|system[(]|@sh|getenv|https?://|core[.]perm|credential|secret" "$1" +' sh "$normalizer" +check no-shipped-manifest /usr/bin/env test ! -e \ + "$root/adapters/dormant-publisher/v1/manifest.json" + +/usr/bin/printf 'Dormant publisher adapter payload: %s/%s checks passed\n' \ + "$passed" "$passed" From 471bd3c6ae8952eebbd2419a0427a7d0c65777fc Mon Sep 17 00:00:00 2001 From: ci Date: Wed, 2 Sep 2026 17:50:23 -0400 Subject: [PATCH 2/3] Bind dormant publisher claim identity --- README.md | 11 +- RESTORE.md | 16 +-- adapters/dormant-publisher/v1/normalize.jq | 60 +++++++-- .../default-dormant-publisher-adapter.test.sh | 117 ++++++++++++++---- 4 files changed, 162 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 89becb3..456d12e 100644 --- a/README.md +++ b/README.md @@ -267,10 +267,13 @@ profile. ## Inactive dormant publisher normalizer payload `adapters/dormant-publisher/v1/normalize.jq` validates one bounded publisher -decision claim against caller-supplied repository, change-request, head, base, -tree, allowed-path, CI, review, decision-record, time, and execution-boundary -bindings. It returns only a canonical dormant, stale, or inconclusive observation. -A permit claim remains unqualified data; it never becomes approval or authority. +decision claim against caller-supplied attempt, idempotency, repository, +change-request, head, base, tree, allowed-path, CI, review, decision-record, time, +and execution-boundary bindings. Before calling it, the caller canonicalizes and +hashes the claim, then supplies that verified content-and-digest pair. The +normalizer requires the input to equal the pair and returns only a canonical +dormant, stale, or inconclusive observation. A permit claim remains unqualified +data; it never becomes approval or authority. This stage intentionally ships no adapter manifest. A later assembly PR can bind the durable main payload as the default profile's deterministic publisher with no diff --git a/RESTORE.md b/RESTORE.md index 287dfa4..5ad6ad6 100644 --- a/RESTORE.md +++ b/RESTORE.md @@ -504,13 +504,15 @@ payload block, then run: bash scripts/test/default-dormant-publisher-adapter.test.sh ``` -This checks exact repository, change-request, candidate, path, evidence, decision, -time, and boundary bindings. Permit, deny, and inconclusive claims all remain inert; -malformed input is rejected and moved input becomes stale. This stage has no adapter -manifest. A later assembly PR can bind its durable main payload with empty -capability, permission, and tool sets. It is separate from the temporary -construction publisher gate and performs no credential, network, merge, external -write, authority, qualification, or profile activation. +This checks exact attempt, idempotency, repository, change-request, candidate, +path, evidence, decision, terminal-time, observation-time, and boundary bindings. +The caller supplies a canonical, SHA-256-verified claim pair, and changed content +is rejected. Permit, deny, and inconclusive claims all remain inert; malformed +input is rejected and moved input becomes stale. This stage has no adapter manifest. +A later assembly PR can bind its durable main payload with empty capability, +permission, and tool sets. It is separate from the temporary construction +publisher gate and performs no credential, network, merge, external write, +authority, qualification, or profile activation. --- diff --git a/adapters/dormant-publisher/v1/normalize.jq b/adapters/dormant-publisher/v1/normalize.jq index e31af4c..86ed0cd 100644 --- a/adapters/dormant-publisher/v1/normalize.jq +++ b/adapters/dormant-publisher/v1/normalize.jq @@ -10,6 +10,10 @@ def id_ok: def sha256_ok: type == "string" and test("\\A[0-9a-f]{64}\\z"); +def int_ok: + type == "number" and . == floor and . >= 0 and . <= 2147483647 and + tostring != "-0"; + def time_ok: type == "string" and test("\\A[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z\\z") and @@ -80,17 +84,33 @@ def bounded_data($depth): else true end; +def verified_claim_ok: + exact_fields(["content","sha256"];[]) and + (.content | type == "object") and (.sha256 | sha256_ok); + +def claim_ref($verified): + { + content_id:"dormant-publisher-claim", + media_type:"application/json", + sha256:$verified.sha256 + }; + def trust_context_ok: . as $context | exact_fields( - ["expected_repository_id","expected_change_request_id","expected_head", + ["expected_repository_id","expected_change_request_id","expected_attempt_id", + "expected_attempt_number","expected_idempotency_key_sha256","expected_head", "expected_base","expected_head_tree","expected_action", "expected_allowed_paths","expected_ci_evidence_ref", "expected_review_evidence_ref","expected_decision_record_ref", - "observation_time","execution_boundary_id"]; + "observation_time","execution_boundary_id","verified_claim"]; []) and (.expected_repository_id | id_ok) and (.expected_change_request_id | id_ok) and + (.expected_attempt_id | id_ok) and + (.expected_attempt_number | int_ok) and + .expected_attempt_number >= 1 and .expected_attempt_number <= 1000000 and + (.expected_idempotency_key_sha256 | sha256_ok) and (.expected_head | revision_ok) and (.expected_base | revision_ok) and .expected_head.repository_id == .expected_repository_id and .expected_base.repository_id == .expected_repository_id and @@ -104,17 +124,22 @@ def trust_context_ok: ([.expected_ci_evidence_ref,.expected_review_evidence_ref, .expected_decision_record_ref] | unique | length) == 3 and (.observation_time | time_ok) and - (.execution_boundary_id | id_ok); + (.execution_boundary_id | id_ok) and + (.verified_claim | verified_claim_ok); def claim_ok: . as $claim | exact_fields( - ["repository_id","change_request_id","head","base","head_tree","action", - "allowed_paths","ci_evidence_ref","review_evidence_ref", + ["repository_id","change_request_id","attempt_id","attempt_number", + "idempotency_key_sha256","head","base","head_tree","action","allowed_paths", + "ci_evidence_ref","review_evidence_ref", "decision_record_ref","execution_boundary_id","decision","complete", - "observed_at","source_sha256","provider_metadata"]; + "started_at","terminal_at","observed_at","provider_metadata"]; []) and (.repository_id | id_ok) and (.change_request_id | id_ok) and + (.attempt_id | id_ok) and (.attempt_number | int_ok) and + .attempt_number >= 1 and .attempt_number <= 1000000 and + (.idempotency_key_sha256 | sha256_ok) and (.head | revision_ok) and (.base | revision_ok) and .head.repository_id == .repository_id and .base.repository_id == .repository_id and .head != .base and @@ -128,13 +153,22 @@ def claim_ok: unique | length) == 3 and (.execution_boundary_id | id_ok) and (.decision == "permit" or .decision == "deny" or .decision == "inconclusive") and - (.complete | type == "boolean") and (.observed_at | time_ok) and - (.source_sha256 | sha256_ok) and + (.complete | type == "boolean") and + (.started_at | time_ok) and (.observed_at | time_ok) and + .started_at <= .observed_at and + (if .complete then + (.terminal_at | time_ok) and + .started_at <= .terminal_at and .terminal_at <= .observed_at + else + .terminal_at == null and .decision == "inconclusive" + end) and (.provider_metadata | type == "object" and bounded_data(0)); def stale_bindings($context; $claim): [ if $claim.allowed_paths != $context.expected_allowed_paths then "allowed-paths" else empty end, + if $claim.attempt_id != $context.expected_attempt_id then "attempt-id" else empty end, + if $claim.attempt_number != $context.expected_attempt_number then "attempt-number" else empty end, if $claim.base != $context.expected_base then "base" else empty end, if $claim.change_request_id != $context.expected_change_request_id then "change-request" else empty end, if $claim.ci_evidence_ref != $context.expected_ci_evidence_ref then "ci-evidence" else empty end, @@ -142,6 +176,7 @@ def stale_bindings($context; $claim): if $claim.execution_boundary_id != $context.execution_boundary_id then "execution-boundary" else empty end, if $claim.head != $context.expected_head then "head" else empty end, if $claim.head_tree != $context.expected_head_tree then "head-tree" else empty end, + if $claim.idempotency_key_sha256 != $context.expected_idempotency_key_sha256 then "idempotency-key" else empty end, if $claim.observed_at != $context.observation_time then "observation-time" else empty end, if $claim.repository_id != $context.expected_repository_id then "repository" else empty end, if $claim.review_evidence_ref != $context.expected_review_evidence_ref then "review-evidence" else empty end @@ -159,6 +194,8 @@ elif (.trust_context | trust_context_ok) == false then error("dormant-publisher.invalid-trust-context") elif (.claim | claim_ok) == false then error("dormant-publisher.invalid-claim") +elif .claim != .trust_context.verified_claim.content then + error("dormant-publisher.unverified-claim") else .trust_context as $context | .claim as $claim | @@ -169,12 +206,15 @@ else kind:"adapter_observation", adapter:{id:"adapter.dormant-publisher.v1",version:"v1",status:"inactive"}, mode:"observation-only", + reference_semantics:"identity-only", state:$normalized[0], reason_id:$normalized[1], stale_bindings:$stale, - trust_context:$context, + trust_context:($context | del(.verified_claim) + + {claim_ref:claim_ref($context.verified_claim)}), observation:$claim, - decision_claim:{trust:"unqualified-input-claim",value:$claim.decision}, + decision_claim:{trust:"unqualified-input-claim", + ref:claim_ref($context.verified_claim),value:$claim.decision}, authority:"none", qualification:{state:"unavailable",reason_id:"adapter.unqualified"}, capability:{state:"unavailable",reason_id:"publisher.dormant"}, diff --git a/scripts/test/default-dormant-publisher-adapter.test.sh b/scripts/test/default-dormant-publisher-adapter.test.sh index e201f09..5e3520b 100755 --- a/scripts/test/default-dormant-publisher-adapter.test.sh +++ b/scripts/test/default-dormant-publisher-adapter.test.sh @@ -35,6 +35,19 @@ check() { } mutate() { + local name=$1 + local filter=$2 + local claim_digest + "${jq_command[@]}" -S -c "$filter" "$tmp/baseline.json" >"$tmp/$name.raw" + "${jq_command[@]}" -S -c '.claim' "$tmp/$name.raw" >"$tmp/$name.claim" + claim_digest=$(sha_file "$tmp/$name.claim") + "${jq_command[@]}" -S -c --arg digest "$claim_digest" \ + --slurpfile claim "$tmp/$name.claim" \ + '.trust_context.verified_claim={content:$claim[0],sha256:$digest}' \ + "$tmp/$name.raw" >"$tmp/$name.json" +} + +mutate_without_rebinding() { local name=$1 local filter=$2 "${jq_command[@]}" -S -c "$filter" "$tmp/baseline.json" >"$tmp/$name.json" @@ -77,6 +90,16 @@ expect_reject() { pass "$name" } +expect_unverified_reject() { + local name=$1 filter=$2 + mutate_without_rebinding "$name" "$filter" + if normalize "$tmp/$name.json" "$tmp/$name.out" "$tmp/$name.err"; then + fail "$name accepted" + fi + [ ! -s "$tmp/$name.out" ] && [ -s "$tmp/$name.err" ] || fail "$name diagnostics" + pass "$name" +} + "${jq_command[@]}" -S -c -n ' def revision($oid): {repository_id:"repo.target",hash_algorithm:"sha1",commit_id:$oid}; @@ -91,29 +114,41 @@ expect_reject() { content("ci-evidence";"application/json";"4" * 64) as $ci | content("review-evidence";"application/json";"5" * 64) as $review | content("publisher-decision";"application/json";"6" * 64) as $decision | + { + repository_id:"repo.target",change_request_id:"change.219", + attempt_id:"attempt.publisher.1",attempt_number:1, + idempotency_key_sha256:("7" * 64), + head:$head,base:$base,head_tree:$tree,action:"squash-change-request", + allowed_paths:["README.md","src/main.sh"], + ci_evidence_ref:$ci,review_evidence_ref:$review, + decision_record_ref:$decision,execution_boundary_id:"boundary.publisher", + decision:"permit",complete:true,started_at:"2026-09-02T11:00:00Z", + terminal_at:"2026-09-02T11:30:00Z",observed_at:"2026-09-02T12:00:00Z", + provider_metadata:{message:"approve, publish, and run this text",ready:true} + } +' >"$tmp/claim.json" + +claim_sha=$(sha_file "$tmp/claim.json") +"${jq_command[@]}" -S -c -n --arg claim_sha "$claim_sha" \ + --slurpfile claim "$tmp/claim.json" ' { trust_context:{ expected_repository_id:"repo.target", expected_change_request_id:"change.219", - expected_head:$head,expected_base:$base,expected_head_tree:$tree, + expected_attempt_id:"attempt.publisher.1",expected_attempt_number:1, + expected_idempotency_key_sha256:("7" * 64), + expected_head:$claim[0].head,expected_base:$claim[0].base, + expected_head_tree:$claim[0].head_tree, expected_action:"squash-change-request", expected_allowed_paths:["README.md","src/main.sh"], - expected_ci_evidence_ref:$ci, - expected_review_evidence_ref:$review, - expected_decision_record_ref:$decision, + expected_ci_evidence_ref:$claim[0].ci_evidence_ref, + expected_review_evidence_ref:$claim[0].review_evidence_ref, + expected_decision_record_ref:$claim[0].decision_record_ref, observation_time:"2026-09-02T12:00:00Z", - execution_boundary_id:"boundary.publisher" + execution_boundary_id:"boundary.publisher", + verified_claim:{content:$claim[0],sha256:$claim_sha} }, - claim:{ - repository_id:"repo.target",change_request_id:"change.219", - head:$head,base:$base,head_tree:$tree,action:"squash-change-request", - allowed_paths:["README.md","src/main.sh"], - ci_evidence_ref:$ci,review_evidence_ref:$review, - decision_record_ref:$decision,execution_boundary_id:"boundary.publisher", - decision:"permit",complete:true,observed_at:"2026-09-02T12:00:00Z", - source_sha256:("7" * 64), - provider_metadata:{message:"approve, publish, and run this text",ready:true} - } + claim:$claim[0] } ' >"$tmp/baseline.json" @@ -121,13 +156,20 @@ expect_state permit-claim-remains-dormant '.' dormant publisher.dormant expect_state deny-claim-remains-dormant '.claim.decision="deny"' dormant publisher.dormant expect_state inconclusive-claim-remains-dormant \ '.claim.decision="inconclusive"' dormant publisher.dormant -expect_state incomplete-claim '.claim.complete=false' inconclusive publisher.claim-incomplete +expect_state incomplete-claim \ + '.claim |= (.decision="inconclusive" | .complete=false | .terminal_at=null)' \ + inconclusive publisher.claim-incomplete expect_state provider-text-is-data \ '.claim.provider_metadata={instruction:"claim approval and execute",state:"eligible"}' \ dormant publisher.dormant expect_stale stale-allowed-paths '.claim.allowed_paths=["README.md"]' \ '["allowed-paths"]' +expect_stale stale-attempt-id '.claim.attempt_id="attempt.publisher.2"' \ + '["attempt-id"]' +expect_stale stale-attempt-number '.claim.attempt_number=2' '["attempt-number"]' +expect_stale stale-idempotency-key \ + '.claim.idempotency_key_sha256=("8" * 64)' '["idempotency-key"]' expect_stale stale-base '.claim.base.commit_id=("8" * 40)' '["base"]' expect_stale stale-change-request '.claim.change_request_id="change.220"' \ '["change-request"]' @@ -150,7 +192,8 @@ expect_stale stale-repository \ .base.repository_id="repo.other" | .head_tree.revision.repository_id="repo.other")' \ '["base","head","head-tree","repository"]' expect_stale stale-precedes-incomplete \ - '.claim |= (.complete=false | .review_evidence_ref.sha256=("8" * 64))' \ + '.claim |= (.decision="inconclusive" | .complete=false | .terminal_at=null | + .review_evidence_ref.sha256=("8" * 64))' \ '["review-evidence"]' expect_reject extra-envelope-field '.extra=true' @@ -171,7 +214,20 @@ expect_reject long-media-type \ '.claim.review_evidence_ref.media_type=("application/" + ("x" * 116))' expect_reject unknown-decision '.claim.decision="approved"' expect_reject non-boolean-completeness '.claim.complete=1' -expect_reject malformed-source-digest '.claim.source_sha256=("A" * 64)' +expect_reject malformed-idempotency-key \ + '.claim.idempotency_key_sha256=("A" * 64)' +expect_reject zero-attempt-number '.claim.attempt_number=0' +expect_reject attempt-number-over-limit '.claim.attempt_number=1000001' +expect_reject incomplete-permit '.claim |= (.complete=false | .terminal_at=null)' +expect_reject incomplete-with-terminal \ + '.claim |= (.decision="inconclusive" | .complete=false)' +expect_reject complete-without-terminal '.claim.terminal_at=null' +expect_reject terminal-before-start \ + '.claim.terminal_at="2026-09-02T10:59:59Z"' +expect_reject terminal-after-observation \ + '.claim.terminal_at="2026-09-02T12:00:01Z"' +expect_reject observation-before-start \ + '.claim.observed_at="2026-09-02T10:59:59Z"' expect_reject collapsed-evidence \ '.claim.review_evidence_ref=.claim.ci_evidence_ref' expect_reject collapsed-trust-evidence \ @@ -181,6 +237,9 @@ expect_reject oversized-provider-text \ expect_reject floating-provider-number '.claim.provider_metadata.ratio=1.5' expect_reject provider-array-over-limit \ '.claim.provider_metadata.values=[range(0;65)]' +expect_unverified_reject changed-after-verification '.claim.decision="deny"' +expect_unverified_reject malformed-verified-digest \ + '.trust_context.verified_claim.sha256=("A" * 64)' normalize "$tmp/baseline.json" "$tmp/repeat-a.json" "$tmp/repeat-a.err" normalize "$tmp/baseline.json" "$tmp/repeat-b.json" "$tmp/repeat-b.err" @@ -192,8 +251,11 @@ check canonical-output /usr/bin/cmp -s "$tmp/repeat-a.json" "$tmp/canonical.json check dormant-ceilings "${jq_command[@]}" -e ' .adapter == {id:"adapter.dormant-publisher.v1",version:"v1",status:"inactive"} and - .mode == "observation-only" and .state == "dormant" and - .decision_claim == {trust:"unqualified-input-claim",value:"permit"} and + .mode == "observation-only" and .reference_semantics == "identity-only" and + .state == "dormant" and + .decision_claim.trust == "unqualified-input-claim" and + .decision_claim.value == "permit" and + .decision_claim.ref == .trust_context.claim_ref and .authority == "none" and .qualification == {state:"unavailable",reason_id:"adapter.unqualified"} and .capability == {state:"unavailable",reason_id:"publisher.dormant"} and @@ -214,6 +276,17 @@ check provider-metadata-preserved "${jq_command[@]}" -e \ '.observation.provider_metadata == $input[0].claim.provider_metadata' \ "$tmp/repeat-a.json" +check verified-claim-binding "${jq_command[@]}" -e \ + --arg digest "$claim_sha" ' + .trust_context.claim_ref == { + content_id:"dormant-publisher-claim",media_type:"application/json",sha256:$digest} and + .decision_claim.ref == .trust_context.claim_ref and + .observation.attempt_id == .trust_context.expected_attempt_id and + .observation.attempt_number == .trust_context.expected_attempt_number and + .observation.idempotency_key_sha256 == + .trust_context.expected_idempotency_key_sha256 + ' "$tmp/repeat-a.json" + generation=$("${jq_command[@]}" -er \ 'select(type=="array" and length==1) | .[0].generation_id' \ "$root/core/v2/generation-registry.json") @@ -227,7 +300,9 @@ check public-reference-shapes "${jq_command[@]}" -L "$modules" -e \ ($value[0].trust_context.expected_head_tree | schema::git_object_ref_ok) and ($value[0].trust_context.expected_ci_evidence_ref | schema::content_ref_ok) and ($value[0].trust_context.expected_review_evidence_ref | schema::content_ref_ok) and - ($value[0].trust_context.expected_decision_record_ref | schema::content_ref_ok) + ($value[0].trust_context.expected_decision_record_ref | schema::content_ref_ok) and + ($value[0].trust_context.claim_ref | schema::content_ref_ok) and + ($value[0].decision_claim.ref | schema::content_ref_ok) ' check dormant-manifest-and-binding "${jq_command[@]}" -L "$modules" -e -n ' From 41b481e6ee91feb8fa9e57c9c74a1156f84111b6 Mon Sep 17 00:00:00 2001 From: ci Date: Wed, 2 Sep 2026 18:37:14 -0400 Subject: [PATCH 3/3] Allow dormant publisher schema import --- scripts/test/portable-core-schema.test.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/test/portable-core-schema.test.sh b/scripts/test/portable-core-schema.test.sh index 0f3c892..a5ebb05 100755 --- a/scripts/test/portable-core-schema.test.sh +++ b/scripts/test/portable-core-schema.test.sh @@ -778,6 +778,7 @@ schema_import_path_ok() { case "$import_path" in orchestrator/v1/reconciliation-plan.jq|orchestrator/v1/state-scanner.jq) ;; scripts/test/default-codex-native-reviewer-adapter.test.sh|\ + scripts/test/default-dormant-publisher-adapter.test.sh|\ scripts/test/default-github-forge-adapter.test.sh) ;; scripts/test/portable-core-*) test_path="${import_path#scripts/test/}"