Files
ThothII/scripts/test-verify-workspace-install-docs.sh
T

1086 lines
55 KiB
Bash
Executable File

#!/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
"$root/scripts/verify-workspace-install-docs.sh" --fixtures-only >"$output"
for fixture in \
"internal semantic infrastructure documentation 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_<NAMESPACE>_''VECTOR_TRANSPORT'
legacy_embedding_binding='THT_WS_<NAMESPACE>_''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 -Fq 'prepare-server-pi-state.sh' "$root/docs/install/server-workspace-registry.md" || {
echo "server workspace-registry guide omits the Pi-state clean-install precondition" >&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 '"$THTCTL" --help' "$server_guide" || {
echo "server guide lacks plain thothctl --help" >&2
exit 1
}
if grep -Fq '"$THTCTL" --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
}
grep -Fq 'export THT_WORKSPACE_BINDINGS_ENV_FILE=' "$manual" || {
echo "installation manual does not publish a self-contained bindings 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 'THTCTL=/srv/thothii/operator/thothctl' \
"$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"
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 reserves a single Qdrant collection. | Schema, Evidence, and Memory stay in that one collection and remain isolated by payload `kind`. |",
"| 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 reserves a single Qdrant collection. | Schema, Evidence, and Memory stay in that one collection and remain isolated by payload `kind`. |",
"| 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 reserves a single Qdrant collection. | Schema, Evidence, and Memory stay in that one collection and remain isolated by payload `kind`. |\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 reserves a single Qdrant collection. | Schema, Evidence, and Memory stay in that one collection and remain isolated by payload `kind`. |\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("<id>/workspace.yaml", "workspaces/<id>.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/<id>/{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 `<id>/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 `<id>/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_<NAMESPACE>_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_<NAMESPACE>_EVIDENCE_ACCESS_KEY_FILE` and `THT_WS_<NAMESPACE>_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_<NAMESPACE>_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,\n`ACTIVE` publication, 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 <path>", "tht -c <path> 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 `<id>/workspace.yaml` and any embedded `<id>/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/<id>.yaml <id>/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"
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 frontend upstream location bypasses complete authentication contract"
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 frontend upstream location bypasses complete authentication contract"
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 thothctl 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 `<id>/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 "generated docs in workspace directory" docs/contracts/workspace-evidence-v3.md wrong-docs-directory "generated docs path invalid"
expect_evidence_fixture_rejected "catalog metadata not authoritative" docs/install/local-workspace-registry.md catalog-authority-omitted "missing catalog authority"
expect_evidence_fixture_rejected "bootstrap create-once rule omitted" docs/install/local-workspace-registry.md bootstrap-omitted "curator flow missing registry rule"
expect_evidence_fixture_rejected "API updates existing descriptors claim" docs/install/local-workspace-registry.md api-updates-existing "curator flow missing registry rule"
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"
expect_evidence_fixture_rejected \
"local curator flow reordered" docs/install/local-workspace-registry.md curator-order \
"curator flow out of order"
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"