#!/usr/bin/env bash # Regression test for copyable installation examples and secret-path validation. set -euo pipefail root="$(cd "$(dirname "$0")/.." && pwd -P)" output="$(mktemp "${TMPDIR:-/tmp}/thoth-install-docs-test.XXXXXX")" verifier_functions="$(mktemp "${TMPDIR:-/tmp}/thoth-install-docs-functions.XXXXXX")" negative_root="$(mktemp -d "${TMPDIR:-/tmp}/thoth-install-docs-negative.XXXXXX")" trap 'rm -f "$output" "$verifier_functions"; rm -rf "$negative_root"' EXIT HUP INT TERM env -u TMPDIR -u THT_AUTH_CONFIG_ROOT \ "$root/scripts/verify-workspace-install-docs.sh" --fixtures-only >"$output" env -u THT_AUTH_CONFIG_ROOT TMPDIR=/tmp/ \ "$root/scripts/verify-workspace-install-docs.sh" --fixtures-only >"$output" for fixture in \ "internal semantic infrastructure documentation contract" \ "read-only workspace repository and encrypted runtime-secret contract" \ "workspace Evidence documentation contract" \ "local installation guide contract" \ "source update fail-closed semantics" \ "Windows line-ending recovery guide contract" \ "Pi management guide contract" \ "server installation guide contract" \ "Nginx reverse-proxy guide contract" \ "Caddy reverse-proxy guide contract" \ "local installation example rendered from path with spaces" \ "server installation example rendered from path with spaces" \ "server pinned migration image fixture" \ "server backup checksum root-only fixture" \ "local manual canonical base+override references" \ "server manual canonical base+override references" \ "canonical local base+override fixture" \ "canonical server base+override fixture" \ "relative secret-source fixture rejected" \ "CRLF recovery rewrites bytes and preserves mode-120000 symlinks"; do grep -Fqx "$fixture passed" "$output" >/dev/null || { echo "missing fixture verification: $fixture" >&2 cat "$output" >&2 exit 1 } done grep -Fq '## Internal Qdrant + Ollama semantic infrastructure' "$root/PROJECT_STATE.md" || { echo "PROJECT_STATE.md does not record the internal Qdrant/Ollama snapshot" >&2 exit 1 } grep -Fq 'Qdrant and Ollama are internal Compose services' "$root/AGENTS.md" || { echo "AGENTS.md does not record the stable internal semantic-service guidance" >&2 exit 1 } grep -Fq 'Do not add vector or embedding endpoint credentials to the bundle.' \ "$root/deploy/secrets/README.md" || { echo "secret bundle guide still permits vector/embedding runtime secrets" >&2 exit 1 } legacy_pg_vector='engine: pg''vector' legacy_ollama='provider: ollama''_compatible' legacy_vector_binding='THT_WS__''VECTOR_TRANSPORT' legacy_embedding_binding='THT_WS__''EMBEDDING_BASE_URL' if rg -n "${legacy_pg_vector}|${legacy_ollama}|${legacy_vector_binding}|${legacy_embedding_binding}" \ "$root/docs/workspace-diagnostic-protocol.md"; then echo "workspace diagnostic protocol still documents external vector or embedding contracts" >&2 exit 1 fi server_guide="$root/docs/install/server.md" grep -Fq 'scripts/prepare-server-pi-state.sh /srv/thothii/pi-state 10001 10001' "$server_guide" || { echo "server guide does not initialize nested Pi-state targets before Compose" >&2 exit 1 } grep -Eq '^sudo install -d -o 10001 -g thothii-ops -m 2750 /srv/thothii$' "$server_guide" || { echo "server operations guide does not set the parent traversal boundary" >&2 exit 1 } for required in \ 'thothii-ops' \ 'THT_BACKUP_ROOT=/srv/thothii-backups' \ 'sessions migrate --yes' \ '"pending":[]' \ '"drifted":[]' \ 'remove --yes' \ 'sha256sum --check SHA256SUMS' \ 'DOCKER-USER' \ 'iptables -I INPUT' \ 'com.docker.network.bridge.name'; do grep -Fq -- "$required" "$server_guide" || { echo "server operations guide lacks executable contract: $required" >&2 exit 1 } done grep -Fq '"$THT_BIN" --help' "$server_guide" || { echo "server guide lacks plain tht --help" >&2 exit 1 } if grep -Fq '"$THT_BIN" --installation "$INSTALLATION" --help' "$server_guide"; then echo "server guide still uses installation-scoped --help" >&2 exit 1 fi for manual in "$root/docs/install/local-workspace-registry.md"; do grep -Fq 'export THT_SOURCE_ROOT=/absolute/path/to/ThothII' "$manual" || { echo "installation manual does not publish a self-contained THT_SOURCE_ROOT export: $manual" >&2 exit 1 } if rg -n 'source[[:space:]]+\.env' "$manual"; then echo "installation manual unsafely imports operator .env: $manual" >&2 exit 1 fi done grep -Fq 'THT_BIN=/srv/thothii/operator/tht' \ "$root/docs/install/server-workspace-registry.md" || { echo "server installation manual does not use the installation-aware operator CLI" >&2 exit 1 } grep -Fq 'INSTALLATION=/srv/thothii/operator/thothii-installation.yaml' \ "$root/docs/install/server-workspace-registry.md" || { echo "server installation manual does not identify the server installation descriptor" >&2 exit 1 } if rg -n 'local-compose\.workspace-registry|server-compose\.workspace-registry|connector-secrets\.workspace-registry|docker compose' \ "$root/docs/install/local-workspace-registry.md" \ "$root/docs/install/server-workspace-registry.md"; then echo "installation manuals still document a bypassed Compose or copied connector override path" >&2 exit 1 fi # Load only the verifier's function definitions so each deliberately unsafe guide can be checked # in isolation without invoking Docker-backed Compose fixtures. sed '/^case "\$mode" in/,$d' "$root/scripts/verify-workspace-install-docs.sh" >"$verifier_functions" # shellcheck source=/dev/null source "$verifier_functions" prefix_tmpdir="$(TMPDIR=/tmp bash -c 'source "$1"; printf "%s" "$tmp_prefix"' _ "$verifier_functions")" prefix_root="$(TMPDIR=/ bash -c 'source "$1"; printf "%s" "$tmp_prefix"' _ "$verifier_functions")" [[ "$prefix_tmpdir" == "/tmp/" ]] || { echo "normalized /tmp prefix must be slash-terminated: $prefix_tmpdir" >&2 exit 1 } [[ "$prefix_root" == "/" ]] || { echo "normalized root prefix must remain /: $prefix_root" >&2 exit 1 } [[ "${prefix_root}thoth-probe.XXXXXX" == "/thoth-probe.XXXXXX" ]] || { echo "root prefix concatenation produced an invalid path" >&2 exit 1 } PYTHONDONTWRITEBYTECODE=1 python3 "$root/scripts/test_workspace_descriptor_doc_contract.py" project_topology_fixture="$negative_root/project-topology-contradiction.md" python3 - "$root/PROJECT_STATE.md" "$project_topology_fixture" <<'PY' import pathlib, sys source = pathlib.Path(sys.argv[1]).read_text() marker = source.index("# Historical archive") contradiction = ( "The supported Compose stack is exactly `frontend` plus `core`.\n" "DWH, vector DB, embedding, LLM, and reverse-proxy services are external endpoints.\n\n" ) pathlib.Path(sys.argv[2]).write_text(source[:marker] + contradiction + source[marker:]) PY project_topology_output="$negative_root/project-topology-output" set +e verify_project_state_current_contract "$project_topology_fixture" contradictory-project-topology \ >"$project_topology_output" 2>&1 project_topology_status=$? set -e if [[ $project_topology_status -eq 0 ]] || \ ! grep -Fq "contradictory active text" "$project_topology_output"; then echo "contradictory current PROJECT_STATE topology was not rejected" >&2 cat "$project_topology_output" >&2 exit 1 fi workspace_fixture="$negative_root/workspace-invalid.yaml" python3 - "$root/deploy/workspaces/example.yaml" "$workspace_fixture" <<'PY' import pathlib, sys, yaml doc = yaml.safe_load(pathlib.Path(sys.argv[1]).read_text()) doc["semantic_index"]["embedding"]["dimensions"] = 768 pathlib.Path(sys.argv[2]).write_text(yaml.safe_dump(doc, sort_keys=False)) PY workspace_output="$negative_root/workspace-output" set +e verify_workspace_descriptor_semantic_contract "$workspace_fixture" invalid-workspace >"$workspace_output" 2>&1 workspace_status=$? set -e if [[ $workspace_status -eq 0 ]] || ! grep -Fq "embedding dimensions must be 1024" "$workspace_output"; then echo "semantic workspace fixture was not rejected correctly" >&2 cat "$workspace_output" >&2 exit 1 fi local_manual_paraphrase="$negative_root/local-manual-paraphrase.md" cp "$root/docs/install/local-workspace-registry.md" "$local_manual_paraphrase" python3 - "$local_manual_paraphrase" <<'PY' import pathlib, sys path = pathlib.Path(sys.argv[1]) text = path.read_text() text = text.replace( "| Workspace semantic index | Each workspace keeps exactly one Qdrant collection reserved for itself. | Schema, Evidence, and memory records share that one collection and are separated by the `kind` payload. |", "| Workspace semantic index | A workspace keeps exactly one Qdrant collection reserved for itself. | Schema, Evidence, and Memory remain together in that collection and are still separated by payload `kind`. |", ) path.write_text(text) PY semantic_index_spec="$(semantic_index_relationship_spec)" verify_markdown_table_relationships "$local_manual_paraphrase" "local manual paraphrase" "Semantic index ownership contract" "$semantic_index_spec" >/dev/null production_paraphrase_root="$negative_root/production-paraphrase-root" mkdir -p "$production_paraphrase_root" rsync -a \ --exclude '.git' \ --exclude '.pytest_cache' \ --exclude 'node_modules' \ --exclude 'backend/node_modules' \ --exclude 'frontend/node_modules' \ --exclude 'harness/.venv' \ "$root/" "$production_paraphrase_root/" python3 - "$production_paraphrase_root/docs/install/local-workspace-registry.md" <<'PY' import pathlib, sys path = pathlib.Path(sys.argv[1]) text = path.read_text() text = text.replace( "| Workspace semantic index | Each workspace keeps exactly one Qdrant collection reserved for itself. | Schema, Evidence, and memory records share that one collection and are separated by the `kind` payload. |", "| Workspace semantic index | A workspace keeps exactly one Qdrant collection reserved for itself. | Schema, Evidence, and Memory remain together in that collection and are still separated by payload `kind`. |", ) path.write_text(text) PY set +e "$production_paraphrase_root/scripts/verify-workspace-install-docs.sh" --fixtures-only \ >"$workspace_output" 2>&1 workspace_status=$? set -e if [[ $workspace_status -ne 0 ]]; then echo "production verifier rejected the accepted semantic-index paraphrase" >&2 cat "$workspace_output" >&2 exit 1 fi local_manual_missing="$negative_root/local-manual-missing.md" cp "$root/docs/install/local-workspace-registry.md" "$local_manual_missing" python3 - "$local_manual_missing" <<'PY' import pathlib, sys path = pathlib.Path(sys.argv[1]) text = path.read_text() text = text.replace( "| Workspace semantic index | Each workspace keeps exactly one Qdrant collection reserved for itself. | Schema, Evidence, and memory records share that one collection and are separated by the `kind` payload. |\n", "", ) path.write_text(text) PY set +e verify_markdown_table_relationships "$local_manual_missing" "local manual missing ownership" "Semantic index ownership contract" "$semantic_index_spec" >"$workspace_output" 2>&1 workspace_status=$? set -e if [[ $workspace_status -eq 0 ]]; then echo "ownership omission fixture was not rejected correctly" >&2 cat "$workspace_output" >&2 exit 1 fi local_manual_scattered="$negative_root/local-manual-scattered.md" cp "$root/docs/install/local-workspace-registry.md" "$local_manual_scattered" python3 - "$local_manual_scattered" <<'PY' import pathlib, sys path = pathlib.Path(sys.argv[1]) text = path.read_text() text = text.replace( "| Workspace semantic index | Each workspace keeps exactly one Qdrant collection reserved for itself. | Schema, Evidence, and memory records share that one collection and are separated by the `kind` payload. |\n", "", ) text += "\nWorkspace. Qdrant. Collection. Schema. Evidence. Memory. Payload kind.\n" path.write_text(text) PY set +e verify_markdown_table_relationships "$local_manual_scattered" "local manual scattered ownership" "Semantic index ownership contract" "$semantic_index_spec" >"$workspace_output" 2>&1 workspace_status=$? set -e if [[ $workspace_status -eq 0 ]]; then echo "scattered ownership tokens fixture was not rejected correctly" >&2 cat "$workspace_output" >&2 exit 1 fi compact_paraphrase="$negative_root/compact-paraphrase.md" cp "$root/docs/installazione-docker-4-contesti.md" "$compact_paraphrase" python3 - "$compact_paraphrase" <<'PY' import pathlib, sys path = pathlib.Path(sys.argv[1]) text = path.read_text() text = text.replace("| DWH | Esterno | Endpoint esterno configurato dall'installazione. |", "| DWH | Esterno | Endpoint esterno deciso dall'installazione. |") text = text.replace("| LLM | Esterno | Endpoint o policy esterna all'infrastruttura semantica interna. |", "| LLM | Esterno | Endpoint o policy che resta esterna all'infrastruttura semantica interna. |") text = text.replace("| Qdrant | Interno | Servizio Compose interno obbligatorio con volume persistente `qdrant-data`. |", "| Qdrant | Interno | Servizio Compose interno obbligatorio con il volume persistente `qdrant-data`. |") text = text.replace("| Ollama embedding | Interno | Servizio Compose interno obbligatorio per `qwen3-embedding:0.6b`. |", "| Ollama embedding | Interno | Servizio Compose interno obbligatorio dedicato a `qwen3-embedding:0.6b`. |") path.write_text(text) PY compact_spec='{"rows":[ {"componente":"^DWH$","ownership":"^Esterno$","contratto operativo":"endpoint.*estern"}, {"componente":"^LLM$","ownership":"^Esterno$","contratto operativo":"esterna|esterno"}, {"componente":"^Qdrant$","ownership":"^Interno$","contratto operativo":"interno.*obbligatorio.*qdrant-data"}, {"componente":"^Ollama embedding$","ownership":"^Interno$","contratto operativo":"interno.*obbligatorio.*qwen3-embedding:0\\.6b"} ]}' verify_markdown_table_relationships "$compact_paraphrase" "compact manual paraphrase" "Contratto sintetico di ownership" "$compact_spec" >/dev/null compact_inversion="$negative_root/compact-inversion.md" cp "$root/docs/installazione-docker-4-contesti.md" "$compact_inversion" python3 - "$compact_inversion" <<'PY' import pathlib, sys path = pathlib.Path(sys.argv[1]) text = path.read_text() text = text.replace("| Qdrant | Interno | Servizio Compose interno obbligatorio con volume persistente `qdrant-data`. |", "| Qdrant | Esterno | Servizio esterno condiviso. |") path.write_text(text) PY set +e verify_markdown_table_relationships "$compact_inversion" "compact inversion" "Contratto sintetico di ownership" "$compact_spec" >"$workspace_output" 2>&1 workspace_status=$? set -e if [[ $workspace_status -eq 0 ]]; then echo "compact inversion fixture was not rejected correctly" >&2 cat "$workspace_output" >&2 exit 1 fi compact_scattered="$negative_root/compact-scattered.md" cp "$root/docs/installazione-docker-4-contesti.md" "$compact_scattered" python3 - "$compact_scattered" <<'PY' import pathlib, sys path = pathlib.Path(sys.argv[1]) text = path.read_text() start = text.index("## Contratto sintetico di ownership") end = text.index("## Comando standard locale") text = text[:start] + "Qdrant Interno DWH Esterno LLM Esterno Ollama embedding Interno.\n\n" + text[end:] path.write_text(text) PY set +e verify_markdown_table_relationships "$compact_scattered" "compact scattered tokens" "Contratto sintetico di ownership" "$compact_spec" >"$workspace_output" 2>&1 workspace_status=$? set -e if [[ $workspace_status -eq 0 ]]; then echo "compact scattered-token fixture was not rejected correctly" >&2 cat "$workspace_output" >&2 exit 1 fi adapted_reorder="$negative_root/caddy-adapted-reorder.json" adapted_bypass="$negative_root/caddy-adapted-bypass.json" node - "$adapted_reorder" "$adapted_bypass" <<'NODE' const fs = require("fs"); const publicHeaders = [ "X-Thoth-Principal-Issuer", "X-Thoth-Principal-Subject", "X-Thoth-Principal-Display-Name", "X-Thoth-Is-Admin", ]; const trustedHeaders = [ "X-Thoth-Trusted-Principal-Issuer", "X-Thoth-Trusted-Principal-Subject", "X-Thoth-Trusted-Principal-Display-Name", "X-Thoth-Trusted-Is-Admin", ]; const clear = (name) => ({handler: "headers", request: {delete: [name]}}); const auth = { handler: "reverse_proxy", upstreams: [{dial: "auth-gateway:4180"}], handle_response: [{match: {status_code: [2]}, routes: [{handle: trustedHeaders.map((name, index) => ({ handler: "headers", request: {set: {[name]: [`{http.reverse_proxy.header.${publicHeaders[index]}}`]}}, }))}]}], }; const document = {routes: [{handle: [ ...publicHeaders.map(clear), auth, ...trustedHeaders.map(clear), {handler: "reverse_proxy", upstreams: [{dial: "127.0.0.1:8080"}]}, ]}]}; fs.writeFileSync(process.argv[2], JSON.stringify(document)); const frontend = {handler: "reverse_proxy", upstreams: [{dial: "127.0.0.1:8080"}]}; const validChain = [...publicHeaders, ...trustedHeaders].map(clear).concat(auth, frontend); fs.writeFileSync(process.argv[3], JSON.stringify({routes: [ {handle: validChain}, {handle: [frontend]}, ]})); NODE adapted_output="$negative_root/caddy-adapted-output" set +e verify_caddy_adapted_identity_order "$adapted_reorder" >"$adapted_output" 2>&1 adapted_status=$? set -e if [[ $adapted_status -eq 0 ]] || ! grep -Fq "Caddy adapted identity clears must execute before authentication" "$adapted_output"; then echo "Caddy reordered adapted-handler fixture was not rejected correctly" >&2 cat "$adapted_output" >&2 exit 1 fi set +e verify_caddy_adapted_identity_order "$adapted_bypass" >"$adapted_output" 2>&1 adapted_status=$? set -e if [[ $adapted_status -eq 0 ]] || ! grep -Fq "Caddy adapted frontend path bypasses complete authentication contract" "$adapted_output"; then echo "Caddy additional direct frontend route fixture was not rejected correctly" >&2 cat "$adapted_output" >&2 exit 1 fi negative_failures=0 expect_evidence_fixture_rejected() { local label="$1" target="$2" mutation="$3" expected_error="$4" local fixture_root="$negative_root/evidence-${label// /-}" local fixture_output="$fixture_root/output" # Before Task 7's dedicated verifier exists, every mutation is deliberately accepted. This # makes the complete Evidence test matrix RED without allowing command-not-found to abort it. if ! declare -F verify_workspace_evidence_contract >/dev/null; then echo "negative fixture accepted: $label (Evidence verifier missing)" >&2 negative_failures=$((negative_failures + 1)) return fi mkdir -p \ "$fixture_root/deploy/workspaces" \ "$fixture_root/docs/contracts" \ "$fixture_root/docs/install/examples" \ "$fixture_root/docs/install" \ "$fixture_root/docs/migrations" cp "$root/deploy/workspaces/example.yaml" "$fixture_root/deploy/workspaces/example.yaml" cp "$root/deploy/workspaces/psd.yaml.example" "$fixture_root/deploy/workspaces/psd.yaml.example" cp "$root/docs/contracts/workspace-evidence-v3.md" \ "$fixture_root/docs/contracts/workspace-evidence-v3.md" cp "$root/docs/install/local-workspace-registry.md" \ "$fixture_root/docs/install/local-workspace-registry.md" cp "$root/docs/install/server-workspace-registry.md" \ "$fixture_root/docs/install/server-workspace-registry.md" cp "$root/README.md" "$fixture_root/README.md" cp "$root/docs/migrations/p1-to-p1-1-registry-layout.md" \ "$fixture_root/docs/migrations/p1-to-p1-1-registry-layout.md" cp "$root/docs/install/examples/workspace-bindings.env.example" \ "$fixture_root/docs/install/examples/workspace-bindings.env.example" python3 - "$fixture_root/$target" "$mutation" <<'PY' import pathlib, sys, yaml path = pathlib.Path(sys.argv[1]) mutation = sys.argv[2] original = path.read_text() changed = original if mutation == "layout-omitted": changed = original.replace("├── thoth-workspaces.yaml\n", "", 1) elif mutation == "same-commit-omitted": changed = original.replace( "| Revision identity | The catalog blob, descriptor blob, and filesystem Evidence root tree are checked at the same 40-hex Git commit. |\n", "", 1, ) elif mutation == "flat-descriptor-path": changed = original.replace("/workspace.yaml", "workspaces/.yaml", 1) elif mutation in {"absolute-filesystem", "cross-workspace", "old-filesystem-layout"}: document = yaml.safe_load(original) document["evidence"]["source"]["uri"] = { "absolute-filesystem": "/srv/evidence", "cross-workspace": "other-workspace/evidence", "old-filesystem-layout": "workspace-content/example/evidence", }[mutation] changed = yaml.safe_dump(document, sort_keys=False) elif mutation == "wrong-docs-directory": changed = original.replace( "workspace-docs//{contract.env.example,README.md}", "example/README.md and example/contract.env.example", 1, ) elif mutation == "catalog-authority-omitted": changed = original.replace( "authoritative for workspace ID,", "descriptor metadata may override workspace ID,", 1, ) elif mutation == "bootstrap-omitted": changed = original.replace( "5. The API may create `/workspace.yaml` only when the catalog slot already exists and no Git\n object exists at that path in the exact pulled base commit.\n", "", 1, ) elif mutation == "api-updates-existing": changed = original.replace( "6. After bootstrap, existing descriptors change only through curator Git commit/push and\n installation pull. The API never writes `thoth-workspaces.yaml` or `/evidence/**`.\n", "6. After bootstrap, use the API to update or delete existing descriptors directly from ThothII.\n", 1, ) elif mutation == "public-http-mode-omitted": changed = original.replace( "Public HTTP (`authentication: none`) uses the declared query-free\nURIs directly and requires no Evidence credential file.", "", 1, ) elif mutation == "ambient-s3-mode-omitted": changed = original.replace( "Ambient S3\n(`credentials: ambient`) uses the runtime provider chain and requires no Evidence credential file.", "", 1, ) elif mutation == "numeric-domains-omitted": changed = original.replace("positive safe integers", "positive integers", 1) changed = changed.replace("nonnegative safe integer", "nonnegative integer", 1) elif mutation == "endpoint-without-url-invariant-omitted": changed = original.replace( "Endpoint-policy flags cannot be enabled without `endpoint_url`.", "", 1 ) elif mutation == "http-file-boundary-omitted": changed = original.replace( "| Signed HTTP | `THT_WS__EVIDENCE_SIGNED_URLS_FILE` | Required for `signed_urls_file`; at most 1048576 bytes; nonempty UTF-8 JSON string array in declared-URI order; query-stripped identities must match `uris` one-to-one. |\n", "", 1, ) elif mutation == "s3-pair-boundary-omitted": changed = original.replace( "| Static S3 pair | `THT_WS__EVIDENCE_ACCESS_KEY_FILE` and `THT_WS__EVIDENCE_SECRET_KEY_FILE` | Required together for `static_files`; each file is at most 65536 bytes. |\n", "", 1, ) elif mutation == "s3-token-boundary-omitted": changed = original.replace( "| Static S3 session | `THT_WS__EVIDENCE_SESSION_TOKEN_FILE` | Optional, valid only with the required access/secret pair, and at most 65536 bytes. |\n", "", 1, ) elif mutation == "credential-literal": changed = original + "\nTHT_WS_STATIC_S3_EVIDENCE_SECRET_KEY=AKIAEXAMPLECREDENTIAL\n" elif mutation == "credential-literal-public-prose": changed = original + "\nPublic credential example: AKIAABCDEFGHIJKLMNOP\n" elif mutation == "credential-literal-public-yaml": document = yaml.safe_load(original) document["public_credential_example"] = "AKIAABCDEFGHIJKLMNOP" changed = yaml.safe_dump(document, sort_keys=False) elif mutation == "signed-query-example": signed_query = "https://evidence.example.invalid/report" + "?X-Amz-Signature=unsafe" changed = original + f"\nTHT_EVIDENCE_URI={signed_query}\n" elif mutation == "unsafe-placeholder": changed = original.replace( "/run/secrets/signed-http-evidence-urls.json", "changeme", 1 ) elif mutation == "p1-scope-inversion": changed = original.replace( "P1.1 performs no acquisition, extraction, preprocessing/indexing, embeddings, Qdrant writes,\nactive-snapshot retention, or GC.", "P1.1 materializes, extracts, and indexes Evidence before publication.", 1, ) elif mutation.startswith("p1-append-"): operations = { "acquisition": ("acquire Evidence", "acquires Evidence", "Evidence acquisition"), "materialization": ( "materialize Evidence", "materializes Evidence", "Evidence materialization" ), "extraction": ("extract Evidence", "extracts Evidence", "Evidence extraction"), "preprocessing": ( "preprocess Evidence", "preprocesses Evidence", "Evidence preprocessing" ), "embeddings": ("create embeddings", "creates embeddings", "Evidence embeddings"), "qdrant-writes": ( "write embeddings to Qdrant", "writes embeddings to Qdrant", "Evidence Qdrant writes", ), "indexing": ("index Evidence", "indexes Evidence", "Evidence indexing"), "active": ("publish `ACTIVE`", "publishes `ACTIVE`", "Evidence `ACTIVE` publication"), "retention": ("retain Evidence", "retains Evidence", "Evidence retention"), "gc": ("garbage-collect Evidence", "garbage-collects Evidence", "Evidence GC"), } forms = ( "base", "third-person", "can", "may", "must", "will", "should", "adverb-before-modal", "adverb-after-modal", "ownership", ) suffix = mutation.removeprefix("p1-append-") operation = next((name for name in operations if suffix.startswith(f"{name}-")), None) form = suffix.removeprefix(f"{operation}-") if operation else "" if operation is None or form not in forms: raise SystemExit(f"unknown P1 append mutation: {mutation}") base, third_person, ownership = operations[operation] claims = { "base": f"P1.1 does {base}.", "third-person": f"P1.1 {third_person}.", "can": f"P1.1 can {base}.", "may": f"P1.1 may {base}.", "must": f"P1.1 must {base}.", "will": f"P1.1 will {base}.", "should": f"P1.1 should {base}.", "adverb-before-modal": f"P1.1 directly may {base}.", "adverb-after-modal": f"P1.1 may directly {base}.", "ownership": f"P1.1 owns {ownership}.", } changed = original + f"\n{claims[form]}\n" elif mutation == "config-ordering": changed = original.replace( "tht config check -c ", "tht -c config check", 1 ) elif mutation == "acceptance-conflation": changed = original.replace("manual acceptance: PENDING\n", "", 1) elif mutation == "curator-order": second = "2. Keep `thoth-workspaces.yaml` curator-owned. It uses the `schema_version` value `1` and the ordered\n `workspaces` list of `{id, name, description?}` entries; it is authoritative for workspace ID,\n name, description, and display order." third = "3. For an existing workspace, edit `/workspace.yaml` and any embedded `/evidence/**`, then\n commit and push." changed = original.replace(second + "\n" + third, third + "\n" + second, 1) elif mutation == "migration-commit-omitted": changed = original.replace("git mv workspaces/.yaml /workspace.yaml\n", "", 1) elif mutation == "migration-upgrade-omitted": changed = original.replace("3. Upgrade ThothII only after that migration commit is pushed.\n", "", 1) elif mutation == "migration-rollback-omitted": changed = original.replace("Roll back the application revision and registry commit together.", "Roll back only the application revision.", 1) else: raise SystemExit(f"unknown Evidence mutation: {mutation}") if changed == original: raise SystemExit(f"Evidence mutation made no change: {mutation}") path.write_text(changed) PY set +e verify_workspace_evidence_contract "$fixture_root" >"$fixture_output" 2>&1 local status=$? set -e if [[ $status -eq 0 ]]; then echo "negative fixture accepted: $label" >&2 cat "$fixture_output" >&2 negative_failures=$((negative_failures + 1)) elif ! grep -Fq -- "$expected_error" "$fixture_output"; then echo "negative fixture failed for the wrong reason: $label" >&2 cat "$fixture_output" >&2 negative_failures=$((negative_failures + 1)) fi } expect_evidence_claim_accepted() { local label="$1" claim="$2" local fixture_root="$negative_root/evidence-safe-${label// /-}" local fixture_output="$fixture_root/output" mkdir -p \ "$fixture_root/deploy/workspaces" \ "$fixture_root/docs/contracts" \ "$fixture_root/docs/install/examples" \ "$fixture_root/docs/install" \ "$fixture_root/docs/migrations" cp "$root/deploy/workspaces/example.yaml" "$fixture_root/deploy/workspaces/example.yaml" cp "$root/deploy/workspaces/psd.yaml.example" "$fixture_root/deploy/workspaces/psd.yaml.example" cp "$root/docs/contracts/workspace-evidence-v3.md" \ "$fixture_root/docs/contracts/workspace-evidence-v3.md" cp "$root/docs/install/local-workspace-registry.md" \ "$fixture_root/docs/install/local-workspace-registry.md" cp "$root/docs/install/server-workspace-registry.md" \ "$fixture_root/docs/install/server-workspace-registry.md" cp "$root/README.md" "$fixture_root/README.md" cp "$root/docs/migrations/p1-to-p1-1-registry-layout.md" \ "$fixture_root/docs/migrations/p1-to-p1-1-registry-layout.md" cp "$root/docs/install/examples/workspace-bindings.env.example" \ "$fixture_root/docs/install/examples/workspace-bindings.env.example" printf '\n%s\n' "$claim" >>"$fixture_root/docs/contracts/workspace-evidence-v3.md" set +e verify_workspace_evidence_contract "$fixture_root" >"$fixture_output" 2>&1 local status=$? set -e if [[ $status -ne 0 ]]; then echo "safe Evidence fixture rejected: $label" >&2 cat "$fixture_output" >&2 negative_failures=$((negative_failures + 1)) fi } expect_guide_rejected() { local label="$1" validator="$2" source_guide="$3" relative_path="$4" local mutation="$5" expected_error="$6" local fixture_root="$negative_root/${label// /-}" local fixture_output="$fixture_root/output" mkdir -p "$fixture_root/$(dirname "$relative_path")" cp "$source_guide" "$fixture_root/$relative_path" if [[ "$validator" == verify_windows_line_endings_guide ]]; then mkdir -p "$fixture_root/scripts" cp "$root/scripts/verify-line-endings.sh" "$fixture_root/scripts/verify-line-endings.sh" elif [[ "$validator" == verify_pi_management_guide ]]; then mkdir -p "$fixture_root/docs/contracts" cp "$root/docs/contracts/tht-pi.md" "$fixture_root/docs/contracts/tht-pi.md" fi node - "$fixture_root/$relative_path" "$mutation" <<'NODE' const fs = require("fs"); const [path, mutation] = process.argv.slice(2); const original = fs.readFileSync(path, "utf8"); let changed = original; switch (mutation) { case "durable-selector": changed = original.replaceAll("--source build", "--source stale-build"); break; case "dangerous-volumes": changed = original.replace("Do **not** run `docker compose down --volumes`", "Run `docker compose down --volumes`"); break; case "incomplete-powershell": changed = original.replaceAll("icacls.exe", "Write-Output"); break; case "broken-crlf": changed = original.replaceAll("git checkout-index --all --force --prefix=", "git add --renormalize . # "); break; case "raw-pi": changed += "\n```sh\ndocker compose exec core pi --version\n```\n"; break; case "server-secret-env": changed += "\n```dotenv\nTHT_MODEL_API_KEY=unsafe-secret-value\n```\n"; break; case "server-docker-socket": changed += "\nMount /var/run/docker.sock into core for management.\n"; break; case "server-coupling": changed += "\nAttach core to the omics_portal application network.\n"; break; case "server-host-loopback": changed += "\nFor host-gateway, keep the external service listening on 127.0.0.1.\n"; break; case "server-parent-traversal": changed = original.replace("sudo install -d -o 10001 -g thothii-ops -m 2750 /srv/thothii\n", ""); break; case "server-raw-remove": changed += "\n```sh\ndocker rm thothii-core thothii-frontend\n```\n"; break; case "server-pinned-migrator-mismatch": changed += "\n```yaml\nservices:\n core:\n image: registry.invalid/core@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n session-migrate:\n image: thothii-core:local\n```\n"; break; case "server-pinned-frontend-missing": changed = original.replace(' frontend:\n build: !reset null\n image: registry.example.com/thothii/frontend@sha256:<64-lowercase-hex-digits>\n', ''); break; case "nginx-no-auth": changed = original.replace(" auth_request /_authenticate;", " # authentication omitted"); break; case "nginx-core-upstream": changed = original.replaceAll("http://127.0.0.1:8080", "http://127.0.0.1:8787"); break; case "nginx-no-sse": changed = original.replace(" proxy_buffering off;", " proxy_buffering on;"); break; case "nginx-no-issuer-clear": changed = original.replaceAll('proxy_set_header X-Thoth-Principal-Issuer "";', 'proxy_set_header X-Thoth-Principal-Issuer $http_x_thoth_principal_issuer;'); break; case "nginx-no-subject-capture": changed = original.replace("auth_request_set $thoth_principal_subject", "# missing auth capture $thoth_principal_subject"); break; case "nginx-no-display-map": changed = original.replace("proxy_set_header X-Thoth-Trusted-Principal-Display-Name $thoth_principal_display_name;", "proxy_set_header X-Thoth-Trusted-Principal-Display-Name \"\";"); break; case "nginx-no-admin-map": changed = original.replace("proxy_set_header X-Thoth-Trusted-Is-Admin $thoth_is_admin;", "proxy_set_header X-Thoth-Trusted-Is-Admin \"\";"); break; case "nginx-admin-clear-wrong-scope": { const clear = ' proxy_set_header X-Thoth-Is-Admin "";'; const authAt = original.indexOf(clear); changed = original.slice(0, authAt) + original.slice(authAt + clear.length + 1); const frontendAt = changed.indexOf(clear); changed = changed.slice(0, frontendAt) + clear + "\n" + clear + changed.slice(frontendAt + clear.length); break; } case "nginx-additional-bypass": changed = original.replace(" location / {", " location /bypass {\n proxy_pass http://127.0.0.1:8080;\n }\n\n location / {"); break; case "nginx-comment-only-auth": changed = original.replace(" location / {", ` location /comment-only-auth { # auth_request /_authenticate; # auth_request_set $thoth_principal_issuer $upstream_http_x_thoth_principal_issuer; # auth_request_set $thoth_principal_subject $upstream_http_x_thoth_principal_subject; # auth_request_set $thoth_principal_display_name $upstream_http_x_thoth_principal_display_name; # auth_request_set $thoth_is_admin $upstream_http_x_thoth_is_admin; # proxy_set_header X-Thoth-Principal-Issuer ""; # proxy_set_header X-Thoth-Principal-Subject ""; # proxy_set_header X-Thoth-Principal-Display-Name ""; # proxy_set_header X-Thoth-Is-Admin ""; # proxy_set_header X-Thoth-Trusted-Principal-Issuer $thoth_principal_issuer; # proxy_set_header X-Thoth-Trusted-Principal-Subject $thoth_principal_subject; # proxy_set_header X-Thoth-Trusted-Principal-Display-Name $thoth_principal_display_name; # proxy_set_header X-Thoth-Trusted-Is-Admin $thoth_is_admin; proxy_set_header X-Comment-Literal "quoted#value"; # preserve the quoted hash proxy_pass http://127.0.0.1:8080; # active frontend path } location / {`); break; case "caddy-no-auth": changed = original.replace("forward_auth auth-gateway:4180 {", "# forward authentication omitted"); break; case "caddy-client-identity": changed = original.replace("X-Thoth-Principal-Subject>X-Thoth-Trusted-Principal-Subject", "X-Thoth-Principal-Subject"); break; case "caddy-core-upstream": changed = original.replaceAll("127.0.0.1:8080", "127.0.0.1:8787"); break; case "caddy-no-issuer-public-clear": changed = original.replace("request_header -X-Thoth-Principal-Issuer", "request_header X-Thoth-Principal-Issuer {header.X-Thoth-Principal-Issuer}"); break; case "caddy-no-subject-trusted-clear": changed = original.replace("request_header -X-Thoth-Trusted-Principal-Subject", "request_header X-Thoth-Trusted-Principal-Subject {header.X-Thoth-Trusted-Principal-Subject}"); break; case "caddy-no-display-map": changed = original.replace("X-Thoth-Principal-Display-Name>X-Thoth-Trusted-Principal-Display-Name", "X-Thoth-Principal-Display-Name"); break; case "caddy-no-admin-map": changed = original.replace("X-Thoth-Is-Admin>X-Thoth-Trusted-Is-Admin", "X-Thoth-Is-Admin"); break; case "caddy-clears-after-auth": { const clearPattern = /(?:\t\trequest_header -X-(?:Authenticated-User|Thoth-[^\n]+)\n)+/; const clears = original.match(clearPattern)?.[0] || ""; changed = original.replace(clearPattern, ""); changed = changed.replace("\n\t\treverse_proxy 127.0.0.1:8080 {", "\n" + clears + "\n\t\treverse_proxy 127.0.0.1:8080 {"); break; } case "dirty-source": changed = original.replaceAll("git status --porcelain --untracked-files=all", "git status --short"); break; case "failed-pull": changed = original.replace("if ! git pull --ff-only; then abort_update", "if git pull --ff-only; then abort_update"); break; case "failed-status": changed = original.replace("if ! RUNNING_PI_VERSION=", "if RUNNING_PI_VERSION="); break; case "failed-build": changed = original.replace("if ! bash scripts/build-local.sh; then", "if bash scripts/build-local.sh; then"); break; case "same-version-no-selector": changed = original.replace("TRANSACTIONAL_PI_UPDATE=false", "TRANSACTIONAL_PI_UPDATE=true # unsafe same-version no-op"); break; case "powershell-source-failure": changed = original.replace("Assert-NativeSuccess 'Pi status'", "Write-Output 'Pi status unchecked'"); break; case "failed-export": changed = original.replace("if ! git checkout-index --all --force", "if git checkout-index --all --force"); break; case "partial-export": changed = original.replace("if ! validate_index_export; then", "if validate_index_export; then"); break; case "mode-120000": changed = original.replaceAll("120000", "100644-no-symlink-mode"); break; case "powershell-crlf-failure": changed = original.replace("Assert-NativeSuccess 'index export'", "Write-Output 'index export unchecked'"); break; default: throw new Error(`unknown negative-fixture mutation: ${mutation}`); } if (changed === original) throw new Error(`negative-fixture mutation made no change: ${mutation}`); fs.writeFileSync(path, changed); NODE set +e (root="$fixture_root"; set -e; "$validator") >"$fixture_output" 2>&1 local status=$? set -e if [[ $status -eq 0 ]]; then echo "negative fixture accepted: $label" >&2 cat "$fixture_output" >&2 negative_failures=$((negative_failures + 1)) elif ! grep -Fq -- "$expected_error" "$fixture_output"; then echo "negative fixture failed for the wrong reason: $label" >&2 cat "$fixture_output" >&2 negative_failures=$((negative_failures + 1)) fi } expect_guide_rejected \ "durable selector keeps old core" verify_local_guide \ "$root/docs/install/local.md" docs/install/local.md durable-selector \ "installation-aware source update lacks structural token: --source build" expect_guide_rejected \ "dangerous down volumes instruction" verify_local_guide \ "$root/docs/install/local.md" docs/install/local.md dangerous-volumes \ "docker compose down --volumes must appear only in an explicit prose prohibition" expect_guide_rejected \ "incomplete native PowerShell path" verify_local_guide \ "$root/docs/install/local.md" docs/install/local.md incomplete-powershell \ "native PowerShell setup lacks structural token: icacls.exe" expect_guide_rejected \ "renormalize leaves CRLF worktree bytes" verify_windows_line_endings_guide \ "$root/docs/install/windows-line-endings.md" docs/install/windows-line-endings.md broken-crlf \ "Windows line-ending guide lacks required instruction: git checkout-index --all --force" expect_guide_rejected \ "raw non-installation-aware Pi access" verify_pi_management_guide \ "$root/docs/install/pi-management.md" docs/install/pi-management.md raw-pi \ "raw non-installation-aware Compose Pi access is forbidden" expect_guide_rejected \ "server secret in environment" verify_server_guide \ "$root/docs/install/server.md" docs/install/server.md server-secret-env \ "server installation guide embeds a secret value" expect_guide_rejected \ "server Docker socket mount" verify_server_guide \ "$root/docs/install/server.md" docs/install/server.md server-docker-socket \ "server installation guide introduces a Docker socket dependency" expect_guide_rejected \ "server application coupling" verify_server_guide \ "$root/docs/install/server.md" docs/install/server.md server-coupling \ "server installation guide introduces forbidden application coupling" expect_guide_rejected \ "server host-gateway loopback listener" verify_server_guide \ "$root/docs/install/server.md" docs/install/server.md server-host-loopback \ "server host-gateway guidance assumes a host loopback listener" expect_guide_rejected \ "server parent traversal boundary" verify_server_guide \ "$root/docs/install/server.md" docs/install/server.md server-parent-traversal \ "server installation guide does not set parent traversal boundary" expect_guide_rejected \ "server raw container removal" verify_server_guide \ "$root/docs/install/server.md" docs/install/server.md server-raw-remove \ "server uninstall bypasses installation-aware removal" expect_guide_rejected \ "server pinned migrator differs from core" verify_server_guide \ "$root/docs/install/server.md" docs/install/server.md server-pinned-migrator-mismatch \ "server pinned migration image must equal the pinned core image" expect_guide_rejected \ "server pinned frontend is missing" verify_server_guide \ "$root/docs/install/server.md" docs/install/server.md server-pinned-frontend-missing \ "server pinned image override must pin core, session-migrate, and frontend without builds" expect_guide_rejected \ "Nginx identity without authentication" verify_reverse_proxy_nginx_guide \ "$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-no-auth \ "Nginx proxy lacks structural token: auth_request /_authenticate;" expect_guide_rejected \ "Nginx direct core exposure" verify_reverse_proxy_nginx_guide \ "$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-core-upstream \ "Nginx proxy must forward only to frontend on 127.0.0.1:8080" expect_guide_rejected \ "Nginx buffered SSE" verify_reverse_proxy_nginx_guide \ "$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-no-sse \ "Nginx proxy lacks structural token: proxy_buffering off;" expect_guide_rejected \ "Nginx issuer inbound claim not cleared" verify_reverse_proxy_nginx_guide \ "$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-no-issuer-clear \ "Nginx auth location does not clear inbound issuer identity" expect_guide_rejected \ "Nginx subject auth response not captured" verify_reverse_proxy_nginx_guide \ "$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-no-subject-capture \ "Nginx frontend location does not capture authenticated subject identity" expect_guide_rejected \ "Nginx display identity not mapped to private hop" verify_reverse_proxy_nginx_guide \ "$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-no-display-map \ "Nginx frontend location does not map authenticated display identity" expect_guide_rejected \ "Nginx admin identity not mapped to private hop" verify_reverse_proxy_nginx_guide \ "$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-no-admin-map \ "Nginx frontend location does not map authenticated admin identity" expect_guide_rejected \ "Nginx admin clear moved out of auth scope" verify_reverse_proxy_nginx_guide \ "$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-admin-clear-wrong-scope \ "Nginx auth location does not clear inbound admin identity" expect_guide_rejected \ "Nginx additional frontend bypass location" verify_reverse_proxy_nginx_guide \ "$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-additional-bypass \ "Nginx direct OIDC mode contains an additional frontend bypass location" expect_guide_rejected \ "Nginx frontend auth directives only in comments" verify_reverse_proxy_nginx_guide \ "$root/docs/install/reverse-proxy-nginx.md" docs/install/reverse-proxy-nginx.md nginx-comment-only-auth \ "Nginx direct OIDC mode contains an additional frontend bypass location" expect_guide_rejected \ "Caddy identity without authentication" verify_reverse_proxy_caddy_guide \ "$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-no-auth \ "Caddy proxy lacks structural token: forward_auth auth-gateway:4180 {" expect_guide_rejected \ "Caddy untrusted identity forwarding" verify_reverse_proxy_caddy_guide \ "$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-client-identity \ "Caddy proxy does not map authenticated subject identity" expect_guide_rejected \ "Caddy direct core exposure" verify_reverse_proxy_caddy_guide \ "$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-core-upstream \ "Caddy proxy must forward only to frontend on 127.0.0.1:8080" expect_guide_rejected \ "Caddy issuer inbound claim not cleared" verify_reverse_proxy_caddy_guide \ "$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-no-issuer-public-clear \ "Caddy proxy does not clear inbound issuer identity" expect_guide_rejected \ "Caddy subject private-hop claim not cleared" verify_reverse_proxy_caddy_guide \ "$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-no-subject-trusted-clear \ "Caddy proxy does not clear inbound trusted subject identity" expect_guide_rejected \ "Caddy display identity not mapped to private hop" verify_reverse_proxy_caddy_guide \ "$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-no-display-map \ "Caddy proxy does not map authenticated display identity" expect_guide_rejected \ "Caddy admin identity not mapped to private hop" verify_reverse_proxy_caddy_guide \ "$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-no-admin-map \ "Caddy proxy does not map authenticated admin identity" expect_guide_rejected \ "Caddy identity clears reordered after auth" verify_reverse_proxy_caddy_guide \ "$root/docs/install/reverse-proxy-caddy.md" docs/install/reverse-proxy-caddy.md caddy-clears-after-auth \ "Caddy identity clears must precede forward_auth" expect_guide_rejected \ "dirty or untracked source tree" verify_local_guide \ "$root/docs/install/local.md" docs/install/local.md dirty-source \ "installation-aware source update lacks structural token: git status --porcelain --untracked-files=all" expect_guide_rejected \ "failed source pull" verify_local_guide \ "$root/docs/install/local.md" docs/install/local.md failed-pull \ "POSIX source update does not fail closed: source pull" expect_guide_rejected \ "failed tht Pi status" verify_local_guide \ "$root/docs/install/local.md" docs/install/local.md failed-status \ "POSIX source update does not fail closed: Pi status" expect_guide_rejected \ "failed local build" verify_local_guide \ "$root/docs/install/local.md" docs/install/local.md failed-build \ "POSIX source update does not fail closed: local build" expect_guide_rejected \ "same Pi version without durable selector" verify_local_guide \ "$root/docs/install/local.md" docs/install/local.md same-version-no-selector \ "POSIX source update lacks the same-version/no-selector path" expect_guide_rejected \ "PowerShell source command failure propagation" verify_local_guide \ "$root/docs/install/local.md" docs/install/local.md powershell-source-failure \ "PowerShell source update does not propagate failure: Pi status" expect_guide_rejected \ "failed index export" verify_windows_line_endings_guide \ "$root/docs/install/windows-line-endings.md" docs/install/windows-line-endings.md failed-export \ "POSIX CRLF repair lacks fail-closed semantic: if ! git checkout-index" expect_guide_rejected \ "partial index export" verify_windows_line_endings_guide \ "$root/docs/install/windows-line-endings.md" docs/install/windows-line-endings.md partial-export \ "POSIX CRLF repair does not prove a complete export before destructive rewrite" expect_guide_rejected \ "mode 120000 symlink preservation" verify_windows_line_endings_guide \ "$root/docs/install/windows-line-endings.md" docs/install/windows-line-endings.md mode-120000 \ "POSIX CRLF repair lacks fail-closed semantic: 120000" expect_guide_rejected \ "PowerShell CRLF command failure propagation" verify_windows_line_endings_guide \ "$root/docs/install/windows-line-endings.md" docs/install/windows-line-endings.md powershell-crlf-failure \ "PowerShell CRLF repair lacks failure propagation: Assert-NativeSuccess 'index export'" expect_evidence_fixture_rejected "root catalog omitted" docs/contracts/workspace-evidence-v3.md layout-omitted "missing canonical Evidence layout" expect_evidence_fixture_rejected "same revision ownership omitted" docs/contracts/workspace-evidence-v3.md same-commit-omitted "missing same-revision ownership" expect_evidence_fixture_rejected "flat descriptor path" docs/contracts/workspace-evidence-v3.md flat-descriptor-path 'The descriptor at `/workspace.yaml` must match the' expect_evidence_fixture_rejected "absolute filesystem Evidence path" deploy/workspaces/example.yaml absolute-filesystem "noncanonical filesystem Evidence URI" expect_evidence_fixture_rejected "cross-workspace Evidence path" deploy/workspaces/psd.yaml.example cross-workspace "Evidence namespace mismatch" expect_evidence_fixture_rejected "old filesystem Evidence layout" deploy/workspaces/example.yaml old-filesystem-layout "Evidence namespace mismatch" expect_evidence_fixture_rejected "public HTTP mode omitted" docs/contracts/workspace-evidence-v3.md public-http-mode-omitted "missing public HTTP mode" expect_evidence_fixture_rejected "ambient S3 mode omitted" docs/contracts/workspace-evidence-v3.md ambient-s3-mode-omitted "missing ambient S3 mode" expect_evidence_fixture_rejected "strict Evidence numeric domains omitted" docs/contracts/workspace-evidence-v3.md numeric-domains-omitted "missing strict Evidence numeric domains" expect_evidence_fixture_rejected "S3 endpoint policy without endpoint invariant omitted" docs/contracts/workspace-evidence-v3.md endpoint-without-url-invariant-omitted "missing S3 endpoint policy without endpoint invariant" expect_evidence_fixture_rejected "signed HTTP file boundary omitted" docs/contracts/workspace-evidence-v3.md http-file-boundary-omitted "missing signed HTTP file boundary" expect_evidence_fixture_rejected "static S3 pair boundary omitted" docs/contracts/workspace-evidence-v3.md s3-pair-boundary-omitted "missing static S3 file boundary" expect_evidence_fixture_rejected "static S3 optional token boundary omitted" docs/contracts/workspace-evidence-v3.md s3-token-boundary-omitted "missing static S3 session-token boundary" expect_evidence_fixture_rejected "credential literal in public bindings" docs/install/examples/workspace-bindings.env.example credential-literal "credential literal forbidden" expect_evidence_fixture_rejected "credential literal in public prose" docs/contracts/workspace-evidence-v3.md credential-literal-public-prose "credential literal forbidden" expect_evidence_fixture_rejected "credential literal in public YAML" deploy/workspaces/example.yaml credential-literal-public-yaml "credential literal forbidden" expect_evidence_fixture_rejected "signed query in public bindings" docs/install/examples/workspace-bindings.env.example signed-query-example "query-bearing public URI forbidden" expect_evidence_fixture_rejected "unsafe Evidence file placeholder" docs/install/examples/workspace-bindings.env.example unsafe-placeholder "unsafe file placeholder/path" expect_evidence_fixture_rejected "P1.1 Evidence scope inversion" docs/contracts/workspace-evidence-v3.md p1-scope-inversion "P1.1 scope violation" expect_evidence_fixture_rejected "migration commit step omitted" docs/migrations/p1-to-p1-1-registry-layout.md migration-commit-omitted "migration guide missing commit step" expect_evidence_fixture_rejected "migration upgrade ordering omitted" docs/migrations/p1-to-p1-1-registry-layout.md migration-upgrade-omitted "migration guide missing upgrade ordering" expect_evidence_fixture_rejected "migration rollback rule omitted" docs/migrations/p1-to-p1-1-registry-layout.md migration-rollback-omitted "migration guide missing rollback rule" p1_operations=( acquisition materialization extraction preprocessing embeddings qdrant-writes indexing active retention gc ) p1_positive_forms=( base third-person can may must will should adverb-before-modal adverb-after-modal ownership ) for operation in "${p1_operations[@]}"; do for form in "${p1_positive_forms[@]}"; do expect_evidence_fixture_rejected \ "appended P1.1 ${operation} ${form} claim" \ docs/contracts/workspace-evidence-v3.md "p1-append-${operation}-${form}" \ "P1.1 scope violation" done done p1_safe_bases=( "acquire Evidence" "materialize Evidence" "extract Evidence" "preprocess Evidence" "create embeddings" "write embeddings to Qdrant" "index Evidence" 'publish `ACTIVE`' "retain Evidence" "garbage-collect Evidence" ) p1_safe_third_person=( "acquires Evidence" "materializes Evidence" "extracts Evidence" "preprocesses Evidence" "creates embeddings" "writes embeddings to Qdrant" "indexes Evidence" 'publishes `ACTIVE`' "retains Evidence" "garbage-collects Evidence" ) p1_safe_ownership=( "Evidence acquisition" "Evidence materialization" "Evidence extraction" "Evidence preprocessing" "Evidence embeddings" "Evidence Qdrant writes" "Evidence indexing" 'Evidence `ACTIVE` publication' "Evidence retention" "Evidence GC" ) for index in "${!p1_operations[@]}"; do operation="${p1_operations[$index]}" base="${p1_safe_bases[$index]}" third_person="${p1_safe_third_person[$index]}" ownership="${p1_safe_ownership[$index]}" expect_evidence_claim_accepted "negative P1 ${operation} cannot" "P1 cannot ${base}." expect_evidence_claim_accepted "negative P1 ${operation} must not" "P1 must not ${base}." expect_evidence_claim_accepted "negative P1 ${operation} does not" "P1 does not ${base}." expect_evidence_claim_accepted "negative P1 ${operation} never" "P1 never ${third_person}." expect_evidence_claim_accepted \ "later plan ${operation}" "A later plan may assign ${ownership} to P1." expect_evidence_claim_accepted "P2 ${operation}" "P2 may directly ${base}." expect_evidence_claim_accepted "P6 ${operation}" "P6 ${third_person}." done expect_evidence_fixture_rejected \ "config check option reordered" docs/contracts/workspace-evidence-v3.md config-ordering \ "exact config-check ordering missing" expect_evidence_fixture_rejected \ "acceptance states conflated" docs/contracts/workspace-evidence-v3.md acceptance-conflation \ "separate automated/manual states missing" if (( negative_failures != 0 )); then echo "$negative_failures unsafe installation-document fixtures were accepted" >&2 exit 1 fi echo "unsafe installation-document fixtures rejected passed"