diff --git a/.github/hooks/scan-secrets.sh b/.github/hooks/scan-secrets.sh index 3ecb23f..c3b0ebb 100755 --- a/.github/hooks/scan-secrets.sh +++ b/.github/hooks/scan-secrets.sh @@ -7,13 +7,15 @@ # ── Why this is NOT duplicated work ───────────────────────────────────────── # The estate runs a TWO-TIER secret defence: # -# CI gitleaks, via the standards `secret-scanner-reusable.yml` -# (gitleaks + rust-secrets + shell-secrets). Catches what reaches the +# CI gitleaks, rust-secrets and shell-secrets, inline in +# `.github/workflows/secret-scanner.yml` (the same three scanner +# contracts the standards reusable publishes, kept inline so the three +# check names are this repository's own). Catches what reaches the # remote, on every pull_request and push to main. # LOCAL TruffleHog — this script — before the push leaves the machine. # -# Different detection engines at different checkpoints. The reusable's header -# retires TruffleHog "as redundant with gitleaks"; that judgement is scoped to +# Different detection engines at different checkpoints. The estate retired +# TruffleHog "as redundant with gitleaks"; that judgement is scoped to # CI, where running both cost twice over one population. It is not a reason to # leave the local tier unguarded. DO NOT delete this script as duplication — # see .github/workflows/README.adoc, "Secret scanning is single-sourced". diff --git a/.github/hooks/validate-deed.sh b/.github/hooks/validate-deed.sh index 770ba69..733fa92 100755 --- a/.github/hooks/validate-deed.sh +++ b/.github/hooks/validate-deed.sh @@ -9,34 +9,60 @@ # live here were a second validator. This runs 1-formats/deed/tools/deed_lint.js # (Bun) from a standards checkout over the tracked .deed files, as the # deed-conformance lane does in CI (.github/workflows/dogfood-gate.yml). The -# lane is the gate; this is the early warning. A tracked .a2ml file is an -# error on sight: the format is retired estate-wide (D99, D312) and its facts -# belong in the repo deed. +# lane is the gate; this is the early warning. +# +# A2ML is retired estate-wide (D99, D312); its facts belong in the repo deed. +# Without a ledger, any tracked A2ML file is an error on sight. A repository +# that still carries legacy A2ML lists each file, by exact path, in the +# shrink-only ledger .machine_readable/a2ml-legacy-allow.txt (owner decision +# 2026-11-04), and the A2ML check is then the canonical gate, +# scripts/check-a2ml-archived.sh from the same standards checkout: a file +# outside the ledger and outside 9-archive/ fails, and so does an entry whose +# file is gone. With A2ML_BASE set to a ref (CI sets the PR base), an entry +# the base ledger did not list also fails, so the ledger can only shrink. # # Environment variables: # INPUT_PATH — Repository to check (default: .) # STANDARDS_DIR — A hyperpolymath/standards checkout # (default: ../standards beside the repository) +# A2ML_BASE — Ref whose ledger this one may not grow beyond (optional) # # Exit codes: -# 0 — Every tracked deed lints, or no linter is available (skipped with a -# notice: CI still runs the lane) -# 1 — A deed failed, or a .a2ml file is tracked +# 0 — Every tracked deed lints and the A2ML check passes, or no linter is +# available (skipped with a notice: CI still runs the lane) +# 1 — A deed failed, or an A2ML file is not accounted for set -euo pipefail REPO="${INPUT_PATH:-.}" STANDARDS_DIR="${STANDARDS_DIR:-$(cd "$REPO/.." && pwd)/standards}" +case "$STANDARDS_DIR" in + /*) ;; + *) STANDARDS_DIR="$PWD/$STANDARDS_DIR" ;; +esac LINTER="$STANDARDS_DIR/1-formats/deed/tools/deed_lint.js" +A2ML_GATE="$STANDARDS_DIR/scripts/check-a2ml-archived.sh" +LEDGER=".machine_readable/a2ml-legacy-allow.txt" cd "$REPO" errors=0 -mapfile -t a2ml < <(git ls-files -- '*.a2ml') -for f in "${a2ml[@]}"; do - echo "::error file=$f::.a2ml is retired; move its facts into the repo deed and delete it" - errors=$((errors + 1)) -done +if [ -f "$LEDGER" ]; then + if [ -f "$A2ML_GATE" ]; then + echo " A2ML: checking against the shrink-only ledger $LEDGER" + if ! bash "$A2ML_GATE" ${A2ML_BASE:+--base "$A2ML_BASE"} .; then + errors=$((errors + 1)) + fi + else + echo "::notice::A2ML: $LEDGER is present but the canonical gate is not ($A2ML_GATE); the deed-conformance lane checks it in CI" + fi +else + mapfile -t a2ml < <(git ls-files | grep -E '\.a2ml(\.in|\.template|\.v[0-9]+\.template)?$' | grep -v '^9-archive/' || true) + for f in "${a2ml[@]}"; do + echo "::error file=$f::.a2ml is retired; move its facts into the repo deed and delete it, or list it in $LEDGER" + errors=$((errors + 1)) + done +fi if [ ! -f "$LINTER" ] || ! command -v bun >/dev/null 2>&1; then echo "::notice::DEED: no deed linter here (needs Bun and a standards checkout at $STANDARDS_DIR; set STANDARDS_DIR); the deed-conformance lane lints in CI" diff --git a/.github/hooks/validate-k9.sh b/.github/hooks/validate-k9.sh index 4fff253..955c6c8 100755 --- a/.github/hooks/validate-k9.sh +++ b/.github/hooks/validate-k9.sh @@ -26,6 +26,10 @@ set -euo pipefail REPO="${INPUT_PATH:-.}" STANDARDS_DIR="${STANDARDS_DIR:-$(cd "$REPO/.." && pwd)/standards}" +case "$STANDARDS_DIR" in + /*) ;; + *) STANDARDS_DIR="$PWD/$STANDARDS_DIR" ;; +esac VALIDATOR="$STANDARDS_DIR/1-formats/k9/tools/k9-validate.sh" if [ ! -f "$VALIDATOR" ]; then diff --git a/.github/rulesets/README.adoc b/.github/rulesets/README.adoc index a7794bc..5b0fdf3 100644 --- a/.github/rulesets/README.adoc +++ b/.github/rulesets/README.adoc @@ -79,14 +79,36 @@ gh api "/repos/{owner}/{repo}/rulesets" --jq '.[] | "\(.id)\t\(.name)\t\(.enforc ---- |=== -| Live ruleset | Enforcement | Target | Covers +| Live ruleset | Enforcement | Target | Rules reported by the API -| `Base` (`23218684`) | active | branch | deletion, required signatures, code scanning (CodeQL/Hypatia), Copilot review, non-fast-forward -| `Require-Linear-History` (`24258228`) | active | branch | required linear history -| `Secret-Scan-Floor` (`24309812`) | active | branch | required status checks (`scan / gitleaks`) +| `Require-Linear-History` (`24258228`) | active | branch | `required_linear_history` +| `Secret-Scan-Floor` (`24309812`) | active | branch | *none* — `rules: []` | `Immutable-Tags` (`22959911`) | active | tag | creation, deletion, non-fast-forward, update, required signatures |=== +[IMPORTANT] +==== +Measured 2026-11-08. Two corrections against the previous version of this table: + +. `Base` (`23218684`) no longer exists. `GET /rulesets/23218684` returns +*404 Not Found*, and the repository's ruleset list does not include it. Every +claim in the drift notes below about what `Base` enforces — code scanning, +Copilot review, `deletion`, `non_fast_forward` — describes a ruleset that is +gone. `GET /rules/branches/main` now returns exactly one rule +(`required_linear_history`, from `24258228`). +. `Secret-Scan-Floor` (`24309812`) is `active` on `target: branch` with +`conditions.ref_name.include: ["~DEFAULT_BRANCH"]`, but it carries **zero +rules**. It therefore requires *no* status check. `scan / gitleaks` is +*produced* by `.github/workflows/secret-scanner.yml`, not required by anything. + +So as of 2026-11-08 `main` is protected only by `required_linear_history`. +This is a record of the live state, not an endorsement: a status-check floor +that enforces nothing is not a floor. Restoring it is an owner action on the +live ruleset and a deliberate decision about which contexts to require, not +something these files can do on their own — `base.json` is upstream-sourced and +its `required_status_checks` is deliberately empty. +==== + === Known drift between these files and what is live The files below are *not* a transcript of the live rulesets. This is the @@ -95,19 +117,28 @@ divergence as of 2026-10-25, recorded rather than silently reconciled because *Live rulesets that no synced file describes* :: -`code_scanning` (with CodeQL and Hypatia tools) and `copilot_code_review` are -enforced live in `Base` (`23218684`), and `Secret-Scan-Floor` (`24309812`) -enforces `required_status_checks` for `scan / gitleaks` (`integration_id: 15368`), -yet neither appears in `base.json` nor any upstream file synced into this -directory. Upstream's nearest equivalent for code scanning, +CORRECTED 2026-11-08: the previous text here stated that `code_scanning` (CodeQL +and Hypatia) and `copilot_code_review` are "enforced live in `Base` +(`23218684`)", and that `Secret-Scan-Floor` (`24309812`) "enforces +`required_status_checks` for `scan / gitleaks`". Both statements are now false. +`Base` returns 404 and is absent from the ruleset list; `Secret-Scan-Floor` is +active but has `rules: []`. No live branch ruleset carries a `code_scanning` +rule either, so CodeQL and Hypatia currently *report* results without gating +merges. What remains true is the file-vs-live gap: upstream's nearest +equivalent for code scanning, `config/rulesets/Optimus-Extras.json`, declares `code_scanning` with an *empty* -`code_scanning_tools` list, so it does not describe what is live here either. -Conversely, `branch-floor.json` is retained here as the irreducible recovery -payload even though the separate `Branch-Floor` live ruleset is not currently -mounted (`Base` covers both `deletion` and `non_fast_forward`). +`code_scanning_tools` list, so it would not describe a populated live rule +either. Conversely, `branch-floor.json` is retained here as the irreducible +recovery payload even though `Branch-Floor` is not mounted as a live ruleset and +nothing currently supplies its `deletion` / `non_fast_forward` coverage. *Removed from live `Base` on 2026-10-02 (owner decision)* :: +NOTE (2026-11-08): the history below is retained, but `Base` was subsequently +deleted outright — the ruleset returns 404 and is absent from the ruleset list. +Every rule discussed here is now unenforced, including the `deletion` and +`non_fast_forward` coverage noted further down. + `Scorecard` was dropped from `code_scanning_tools`, and the `code_coverage` rule (minimum 80%, max drop 5) was deleted. Neither could ever be satisfied by a pull request. `scorecard.yml` runs only on `schedule` and `workflow_dispatch`, and @@ -123,9 +154,9 @@ so they stay. Do not add either rule back without a producer that runs on `required_linear_history` is in `base.json` but not in live `Base`; it is enforced by the separate `Require-Linear-History` ruleset. `required_status_checks` (an empty list, so a no-op) and the -`pull_request` rule are likewise absent from live `Base`. (`non_fast_forward` is +`pull_request` rule are likewise absent from live `Base`. (`non_fast_forward` was live in `Base` as of 2026-10-02; `branch-floor.json` also specifies it as a -retained recovery payload, though `Branch-Floor` is not mounted as a separate live ruleset.) +retained recovery payload, though `Branch-Floor` is not mounted as a separate live ruleset. With `Base` deleted as of 2026-11-08, no live ruleset supplies `non_fast_forward` or `deletion` on branches.) *`Immutable-Tags.json` lags the live ruleset* :: diff --git a/.github/workflows/README.adoc b/.github/workflows/README.adoc index 50e7741..728bcf3 100644 --- a/.github/workflows/README.adoc +++ b/.github/workflows/README.adoc @@ -11,18 +11,36 @@ not "tidied away" into a real coverage gap. Independent; this is the repo's own Hypatia coverage. * `static-analysis-gate.yml` -> `hypatia-scan` job — runs Hypatia as part of the **gate**, then the `deposit-findings` job hands the results to the - **gitbot-fleet learning** pipeline. This job is also a *required status check*. + **gitbot-fleet learning** pipeline. This job is *documented here as a + required status check*, but as of 2026-11-08 no live rule requires it: + `GET /rules/branches/main` returns only `required_linear_history`. It is a + gate in the sense that it reddens a run; it is not a merge blocker until a + ruleset says so. Same tool, two different consumers (security scan vs fleet learning). Removing either loses real function — keep both. == Secret scanning is single-sourced *in CI* — and two-tier overall -`secret-scanner.yml` calls the standards reusable (gitleaks + a Rust-secrets -check). An inline TruffleHog job was removed: the reusable deliberately retired +`secret-scanner.yml` runs all three estate scanners inline: `scan / gitleaks`, +`scan / rust-secrets` and `scan / shell-secrets`. An inline TruffleHog job was +removed earlier: the standards reusable it was paired with deliberately retired TruffleHog as redundant with gitleaks, so re-adding it was duplicated work, not extra coverage. +The three jobs are inline rather than one `uses:` of the standards +`secret-scanner-reusable.yml`, and that is deliberate. A job-level reusable call +reports ` / `, so the wrapper's three public check names +would depend on job names inside a *remote* workflow: rename one there and three +required contexts move in every generated repository. Inline jobs whose `name:` +is the context make the names this repository's own, and the whole file stays +inside `gh actions-lock`'s model — every `uses:` is a step-level action the +official generator can lock, so regenerating never leaves a dangling edge. Do +not "simplify" this back into a single wrapper call; + +`metadatastician/standards` does it as a wrapper because it *owns* the reusable +and can keep `./`-local, so its own names cannot re-stale. + **That retirement is scoped to CI.** The estate runs a deliberate *two-tier* secret defence, and the second tier is not in this directory: @@ -32,7 +50,7 @@ secret defence, and the second tier is not in this directory: | CI | gitleaks (+ rust-secrets, shell-secrets) -| `secret-scanner.yml` -> standards reusable. Runs on pull_request and on push +| `secret-scanner.yml`, three inline jobs. Runs on pull_request and on push to main — i.e. on what has *already reached* the remote. | Local diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index 1b212df..f84a996 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -82,7 +82,7 @@ workflows: '.github/workflows/scorecard.yml': - 'metadatastician/standards@46ee18323305d5b36e2eef936e47dd54c39ea736' '.github/workflows/secret-scanner.yml': - - 'metadatastician/standards@46ee18323305d5b36e2eef936e47dd54c39ea736' + - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '.github/workflows/security-policy.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '.github/workflows/sonarqube.yml': diff --git a/.github/workflows/dogfood-gate.yml b/.github/workflows/dogfood-gate.yml index 70275ba..a4bf9b5 100644 --- a/.github/workflows/dogfood-gate.yml +++ b/.github/workflows/dogfood-gate.yml @@ -41,9 +41,11 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: metadatastician/standards - ref: 8e48b1caade08a552646adeab66558ac43b4360d + ref: e064539116c2dcfdf282a4749dbd58c69961c7e6 path: .standards-deed - sparse-checkout: 1-formats/deed + sparse-checkout: | + 1-formats/deed + scripts sparse-checkout-cone-mode: true persist-credentials: false - name: Setup Bun runtime @@ -55,6 +57,10 @@ jobs: set -euo pipefail rm -rf "$RUNNER_TEMP/standards-deed" mv "$PWD/.standards-deed" "$RUNNER_TEMP/standards-deed" + if [ -f .machine_readable/a2ml-legacy-allow.txt ] && [ ! -f "$RUNNER_TEMP/standards-deed/scripts/check-a2ml-archived.sh" ]; then + echo "::error::A2ML ledger present but scripts/check-a2ml-archived.sh is missing from the standards checkout" + exit 1 + fi STANDARDS_DIR="$RUNNER_TEMP/standards-deed" bash .github/hooks/validate-deed.sh # --------------------------------------------------------------------------- @@ -79,7 +85,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: metadatastician/standards - ref: 8e48b1caade08a552646adeab66558ac43b4360d + ref: e064539116c2dcfdf282a4749dbd58c69961c7e6 path: .standards-k9 sparse-checkout: 1-formats/k9 sparse-checkout-cone-mode: true @@ -103,6 +109,14 @@ jobs: set -euo pipefail rm -rf "$RUNNER_TEMP/standards-k9" mv "$PWD/.standards-k9" "$RUNNER_TEMP/standards-k9" + if [ ! -f "$RUNNER_TEMP/standards-k9/1-formats/k9/tools/k9-validate.sh" ]; then + echo "::error::K9 validator is missing from the standards checkout" + exit 1 + fi + if ! command -v nickel >/dev/null 2>&1; then + echo "::error::nickel is required for K9 layer L2 validation" + exit 1 + fi STANDARDS_DIR="$RUNNER_TEMP/standards-k9" bash .github/hooks/validate-k9.sh # The state -> badge mapping lives in scripts/governance-badge.js and @@ -567,7 +581,7 @@ jobs: else GROOVE_STATUS=":ballot_box_with_check:" fi - if grep -rl 'verisimdb\|VeriSimDB' --include='*.toml' --include='*.yaml' --include='*.yml' --include='*.json' --include='*.rs' --include='*.ex' . 2>/dev/null | head -1 | grep -q .; then + if grep -rl 'verisimdb\|VeriSimDB' --include='*.toml' --include='*.yaml' --include='*.yml' --include='*.kyaml' --include='*.json' --include='*.rs' --include='*.ex' . 2>/dev/null | head -1 | grep -q .; then SCORE=$((SCORE + 1)); VSDB_STATUS=":white_check_mark:" else VSDB_STATUS=":ballot_box_with_check:" @@ -605,7 +619,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: metadatastician/standards - ref: 8e48b1caade08a552646adeab66558ac43b4360d + ref: e064539116c2dcfdf282a4749dbd58c69961c7e6 path: _standards persist-credentials: false - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/dogfood-summary.yml b/.github/workflows/dogfood-summary.yml index 9ff1f47..e0c5cae 100644 --- a/.github/workflows/dogfood-summary.yml +++ b/.github/workflows/dogfood-summary.yml @@ -64,7 +64,7 @@ jobs: fi # VeriSimDB integration? - if grep -rl 'verisimdb\|VeriSimDB' --include='*.toml' --include='*.yaml' --include='*.yml' --include='*.json' --include='*.rs' --include='*.ex' . 2>/dev/null | head -1 | grep -q .; then + if grep -rl 'verisimdb\|VeriSimDB' --include='*.toml' --include='*.yaml' --include='*.yml' --include='*.kyaml' --include='*.json' --include='*.rs' --include='*.ex' . 2>/dev/null | head -1 | grep -q .; then SCORE=$((SCORE + 1)) VSDB_STATUS=":white_check_mark:" else diff --git a/.github/workflows/estate-rules.yml b/.github/workflows/estate-rules.yml index a25eaf4..82843c6 100644 --- a/.github/workflows/estate-rules.yml +++ b/.github/workflows/estate-rules.yml @@ -105,12 +105,37 @@ jobs: echo "a1bc93654f31669fd964ea3011a5e5e9676b9b6f8adcd762606e5140632ea72d $RUNNER_TEMP/just.tgz" | sha256sum --check --strict tar -xzf "$RUNNER_TEMP/just.tgz" -C "$RUNNER_TEMP/just-bin" just echo "$RUNNER_TEMP/just-bin" >> "$GITHUB_PATH" + # Installed before the mint steps: repo-init and container-init reformat the + # container KYAML they fill (scripts/format-container-kyaml.sh), which needs + # a yq that can write KYAML. The KYAML ratchet below uses the same install. + - name: Install verified yq 4.53.3 and kyaml-format.sh (standards 8e48b1c) + run: | + mkdir -p "$RUNNER_TEMP/yq-bin" + curl --proto '=https' --proto-redir '=https' --fail --silent --show-error --location \ + https://github.com/mikefarah/yq/releases/download/v4.53.3/yq_linux_amd64 \ + --output "$RUNNER_TEMP/yq-bin/yq" + echo "fa52a4e758c63d38299163fbdd1edfb4c4963247918bf9c1c5d31d84789eded4 $RUNNER_TEMP/yq-bin/yq" | sha256sum --check --strict + chmod +x "$RUNNER_TEMP/yq-bin/yq" + echo "$RUNNER_TEMP/yq-bin" >> "$GITHUB_PATH" + curl --proto '=https' --proto-redir '=https' --fail --silent --show-error --location \ + https://raw.githubusercontent.com/metadatastician/standards/e064539116c2dcfdf282a4749dbd58c69961c7e6/scripts/kyaml-format.sh \ + --output "$RUNNER_TEMP/kyaml-format.sh" + echo "5fddd58fc8cbd2b68766ef5b4b5ba39f299da3f27252ae6cdcff0a641bbaef18 $RUNNER_TEMP/kyaml-format.sh" | sha256sum --check --strict - name: Non-interactive mint resolves every prompt from the environment run: bash tests/workflows/non_interactive_mint_test.sh # repo-init recorded the CHILD's own HEAD as the template commit whenever # a repo was created with "Use this template" (metadatastician/lintel). - name: Template pin resolves by tree equality (controls) run: bash tests/workflows/resolve_template_pin_test.sh + # A replay trusts RSR_PACKAGE_UUID; only recorded answers may supply it. + - name: Replay passes a package uuid only from recorded answers + run: bash tests/workflows/replay_mint_env_test.sh + # chain-mint reads checkouts through git: gitlinks kept, no ignored file read. + - name: Chain snapshots keep gitlinks and read no ignored file + run: bash tests/workflows/chain_tree_of_test.sh + # The repository map is line-based: a path holding a newline is refused by name. + - name: Repository map refuses a path holding a newline + run: bash tests/workflows/repo_map_newline_test.sh # The first CI job to run a FULL mint: a Use-this-template child, checked # for provenance, untouched fixtures and ignored files, the map and the # closing advice. A fresh mint of main aborted on 2026-10-20 and nothing @@ -119,6 +144,11 @@ jobs: run: | command -v uuidgen >/dev/null || { sudo apt-get update -q && sudo apt-get install -y -q uuid-runtime; } bash tests/e2e/use_template_mint_test.sh + # A repo made from a MINTED repo: its parent's mint is replayed, the + # template is minted with the new answers, and the parent's own work is + # carried by a three-way merge (Codex review of #251; owner ruling). + - name: Chain mint (a minted repo minted again) end to end + run: bash tests/e2e/chain_mint_test.sh # An archetype overlay once shipped its own actions.lock, which replaced # the spine's at mint and left every Julia mint with Lock Sync red. - name: Archetype lock fragments merge into a valid lock @@ -174,19 +204,6 @@ jobs: # step 6 (standards#1025); files a third-party bot or tool rewrites or # finds by schema (Dependabot, FUNDING, issue forms, settings, CodeRabbit, # Copilot, GitLab CI, pre-commit) are not listed, as in standards. - - name: Install verified yq 4.53.3 and kyaml-format.sh (standards 8e48b1c) - run: | - mkdir -p "$RUNNER_TEMP/yq-bin" - curl --proto '=https' --proto-redir '=https' --fail --silent --show-error --location \ - https://github.com/mikefarah/yq/releases/download/v4.53.3/yq_linux_amd64 \ - --output "$RUNNER_TEMP/yq-bin/yq" - echo "fa52a4e758c63d38299163fbdd1edfb4c4963247918bf9c1c5d31d84789eded4 $RUNNER_TEMP/yq-bin/yq" | sha256sum --check --strict - chmod +x "$RUNNER_TEMP/yq-bin/yq" - echo "$RUNNER_TEMP/yq-bin" >> "$GITHUB_PATH" - curl --proto '=https' --proto-redir '=https' --fail --silent --show-error --location \ - https://raw.githubusercontent.com/metadatastician/standards/8e48b1caade08a552646adeab66558ac43b4360d/scripts/kyaml-format.sh \ - --output "$RUNNER_TEMP/kyaml-format.sh" - echo "5fddd58fc8cbd2b68766ef5b4b5ba39f299da3f27252ae6cdcff0a641bbaef18 $RUNNER_TEMP/kyaml-format.sh" | sha256sum --check --strict # Container narrowing at mint (build/just/container.just) can delete any # of these files. The template (archetypes/ present) must keep all of # them; a minted repository checks the ones it kept and names the rest. @@ -267,3 +284,9 @@ jobs: set -euo pipefail bash tests/workflows/squabble_gate_test.sh bash scripts/check-required-contexts.sh . + # The three scanner contexts, their least privilege and their pinning are + # this repository's own: a job-level reusable wrapper would make them the + # CALLEE's job names, and `secrets: inherit` would forward every secret to + # a scanner that declares none. Every property is mutated in the test. + - name: Secret scanner contract (three stable contexts) + run: bash tests/workflows/secret_scanner_contract_test.sh diff --git a/.github/workflows/fork-governance.yml b/.github/workflows/fork-governance.yml index f00165d..f243ef1 100644 --- a/.github/workflows/fork-governance.yml +++ b/.github/workflows/fork-governance.yml @@ -4,7 +4,7 @@ # # fork-governance.yml — PR-time entry point for DEED syntax, K9-DEED L0–L2 # conformance, the gitbot-fleet report-backs, and THE Hypatia verdict grid. -# Everything is pinned to metadatastician/standards@8e48b1caade08a552646adeab66558ac43b4360d. +# Everything is pinned to metadatastician/standards@e064539116c2dcfdf282a4749dbd58c69961c7e6. # # ── HOW THIS FILE RELATES TO THE SCANNERS (read before editing) ────────────── # @@ -90,9 +90,11 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: metadatastician/standards - ref: 8e48b1caade08a552646adeab66558ac43b4360d + ref: e064539116c2dcfdf282a4749dbd58c69961c7e6 path: .standards-deed - sparse-checkout: 1-formats/deed + sparse-checkout: | + 1-formats/deed + scripts sparse-checkout-cone-mode: true persist-credentials: false @@ -102,12 +104,30 @@ jobs: bun-version: '1.3.12' - name: Validate DEED manifests and block retired .a2ml files + env: + BASE_REF: ${{ github.base_ref }} run: | set -euo pipefail STANDARDS_ABS="$PWD/.standards-deed" rm -rf "$RUNNER_TEMP/standards-deed" mv "$STANDARDS_ABS" "$RUNNER_TEMP/standards-deed" - STANDARDS_DIR="$RUNNER_TEMP/standards-deed" bash .github/hooks/validate-deed.sh + # The lane is the gate: a ledger with no canonical gate to read it fails. + if [ -f .machine_readable/a2ml-legacy-allow.txt ] && [ ! -f "$RUNNER_TEMP/standards-deed/scripts/check-a2ml-archived.sh" ]; then + echo "::error::A2ML ledger present but scripts/check-a2ml-archived.sh is missing from the standards checkout" + exit 1 + fi + # Shrink-only: the base branch's ledger bounds this one. A base that + # cannot be fetched fails the step: skipping the comparison would let + # a new ledger entry through exactly when it cannot be checked. + A2ML_BASE="" + if [ -n "$BASE_REF" ]; then + if ! git fetch -q --depth=1 origin "$BASE_REF"; then + echo "::error::could not fetch the base branch $BASE_REF to bound the A2ML ledger" + exit 1 + fi + A2ML_BASE=FETCH_HEAD + fi + A2ML_BASE="$A2ML_BASE" STANDARDS_DIR="$RUNNER_TEMP/standards-deed" bash .github/hooks/validate-deed.sh k9: name: K9-DEED conformance @@ -128,7 +148,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: metadatastician/standards - ref: 8e48b1caade08a552646adeab66558ac43b4360d + ref: e064539116c2dcfdf282a4749dbd58c69961c7e6 path: .standards-k9 sparse-checkout: 1-formats/k9 sparse-checkout-cone-mode: true @@ -148,6 +168,14 @@ jobs: set -euo pipefail rm -rf "$RUNNER_TEMP/standards-k9" mv "$PWD/.standards-k9" "$RUNNER_TEMP/standards-k9" + if [ ! -f "$RUNNER_TEMP/standards-k9/1-formats/k9/tools/k9-validate.sh" ]; then + echo "::error::K9 validator is missing from the standards checkout" + exit 1 + fi + if ! command -v nickel >/dev/null 2>&1; then + echo "::error::nickel is required for K9 layer L2 validation" + exit 1 + fi STANDARDS_DIR="$RUNNER_TEMP/standards-k9" bash .github/hooks/validate-k9.sh # Fleet report-backs: the evidence behind each gitbot-fleet row of the verdict @@ -165,12 +193,14 @@ jobs: fleet: name: Fleet report-backs runs-on: ubuntu-24.04 - timeout-minutes: 45 + # Worst case, so a stalled phase never kills the job before the upload: the + # 2400 s build budget + seven adapters at 300 s + checkout, cache and upload. + timeout-minutes: 85 permissions: contents: read env: # hyperpolymath/gitbot-fleet commit whose driver and adapters run. - FLEET_REF: e85b71bf139a61f956c7e0717a2912cd6e347cc5 + FLEET_REF: e44af8144789594fcce891ccc7b77fc9a464a520 # panic-attack commit for panicbot. Deliberately the SAME commit # static-analysis-gate.yml builds: panicbot is not enrolled today (the # grid's own panic-attack row already grades that scanner, so a second @@ -322,7 +352,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: metadatastician/standards - ref: 8e48b1caade08a552646adeab66558ac43b4360d + ref: e064539116c2dcfdf282a4749dbd58c69961c7e6 path: .standards-grid persist-credentials: false sparse-checkout: | diff --git a/.github/workflows/governance.yml b/.github/workflows/governance.yml index 38f2547..5563c81 100644 --- a/.github/workflows/governance.yml +++ b/.github/workflows/governance.yml @@ -6,7 +6,6 @@ on: push: branches: [main, master] pull_request: - branches: [main, master] workflow_dispatch: concurrency: diff --git a/.github/workflows/proofs.yml b/.github/workflows/proofs.yml index 7a8540e..17db74f 100644 --- a/.github/workflows/proofs.yml +++ b/.github/workflows/proofs.yml @@ -80,4 +80,36 @@ jobs: - name: Emit the receipt for this commit run: | bash scripts/grade-receipt.sh --write --commit "$(git rev-parse --short=12 HEAD)" - sed -n '1,80p' verification/receipts/agda-pwg_praxis.deed + sed -n '1,80p' verification/receipts/agda_praxis.deed + + # The Rocq and Lean recheck legs (PROOF-POLICY §2), re-proved by hand from + # the Agda statements (verification/legs/README.adoc gives the route and the + # statement correspondence). Both provers are pinned: Coq by its Ubuntu + # package version, Lean by its release tarball's sha256. Neither leg may use + # an axiom or a library; scripts/check-legs.sh refuses either. + legs: + name: Rocq and Lean recheck legs (no axioms, no libraries) + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Install the pinned Coq + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends -y coq=8.18.0+dfsg-1build2 zstd + - name: Install the pinned Lean (release tarball, sha256-checked) + env: + LEAN_URL: https://github.com/leanprover/lean4/releases/download/v4.24.0/lean-4.24.0-linux.tar.zst + LEAN_SHA256: b14f5e5159219dd1a1956c3b806813319f5e94ccd5bdfd56f54520609a5bb5ec + run: | + set -euo pipefail + curl --proto '=https' --proto-redir '=https' --tlsv1.2 -fsSL "$LEAN_URL" -o "$RUNNER_TEMP/lean.tar.zst" + echo "$LEAN_SHA256 $RUNNER_TEMP/lean.tar.zst" | sha256sum --check --strict + mkdir -p "$RUNNER_TEMP/lean" + tar --zstd -xf "$RUNNER_TEMP/lean.tar.zst" -C "$RUNNER_TEMP/lean" + echo "$RUNNER_TEMP/lean/lean-4.24.0-linux/bin" >> "$GITHUB_PATH" + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Both legs check, with no axioms + run: bash scripts/check-legs.sh diff --git a/.github/workflows/proven-tests.yml b/.github/workflows/proven-tests.yml index 8135e18..40654bb 100644 --- a/.github/workflows/proven-tests.yml +++ b/.github/workflows/proven-tests.yml @@ -40,7 +40,7 @@ jobs: git -C "$RUNNER_TEMP/standards" remote add origin https://github.com/metadatastician/standards.git git -C "$RUNNER_TEMP/standards" config core.sparseCheckout true printf '1-formats/deed/\n1-formats/k9/\n' > "$RUNNER_TEMP/standards/.git/info/sparse-checkout" - git -C "$RUNNER_TEMP/standards" fetch -q --depth 1 origin 8e48b1caade08a552646adeab66558ac43b4360d + git -C "$RUNNER_TEMP/standards" fetch -q --depth 1 origin e064539116c2dcfdf282a4749dbd58c69961c7e6 git -C "$RUNNER_TEMP/standards" checkout -q FETCH_HEAD - name: Set up Bun diff --git a/.github/workflows/secret-scanner.yml b/.github/workflows/secret-scanner.yml index f31be13..5419b49 100644 --- a/.github/workflows/secret-scanner.yml +++ b/.github/workflows/secret-scanner.yml @@ -1,29 +1,365 @@ # This workflow is managed by gh actions-lock. # SPDX-License-Identifier: MPL-2.0 name: Secret Scanner + on: pull_request: push: branches: [main] + workflow_dispatch: + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true + permissions: - actions: read contents: read -# Single secret scanner. The standards reusable runs gitleaks (+ a Rust-secrets -# check). An inline TruffleHog job previously lived here, but the reusable -# DELIBERATELY retired TruffleHog as redundant (gitleaks gives sufficient -# coverage at lower cost — see the reusable's header). Re-adding it was -# duplicated work, not extra coverage, so it has been removed. See -# .github/workflows/README.adoc. + +# Self-contained execution of the estate secret-scanning contract pinned to +# metadatastician/standards@e064539116c2dcfdf282a4749dbd58c69961c7e6. +# Produces the `scan / gitleaks` status check that the `Secret-Scan-Floor` +# ruleset (24309812) was created to require on `main`. +# +# HONEST STATUS OF THAT RULE (measured 2026-11-08): the ruleset is `active` +# with `conditions.ref_name.include: ["~DEFAULT_BRANCH"]` but carries +# `rules: []` and a repository-role-5 `bypass_actors` entry with +# `bypass_mode: always`. It currently requires NOTHING, so this check is +# PRODUCED and reported, not enforced. `GET /rules/branches/main` returns only +# `required_linear_history`. Do not describe these contexts as required without +# re-reading the live ruleset; see `.github/rulesets/README.adoc`. +# +# ─── WHY THREE JOBS, AND WHY THEY ARE INLINE ───────────────────────────────── +# +# The estate canonical wraps all three scanners in ONE reusable workflow call: +# +# jobs: +# scan: +# uses: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@ +# +# That shape cannot satisfy this repository's contract, and the reason is +# measured, not theoretical. A job-level reusable call produces exactly one +# check, named ` / ` — so the wrapper above +# yields `scan / gitleaks`, `scan / rust-secrets` and `scan / shell-secrets` +# only while the called workflow happens to define jobs with those names. The +# wrapper's own job is named `scan`, so any rename inside the callee silently +# changes three public check names in every generated repository. Three +# required contexts that move when a remote repository is edited is not a +# contract. +# +# This repository therefore runs the same three scanners as three ordinary +# jobs whose `name:` IS the context: +# +# scan / gitleaks (gitleaks, estate baseline + repo config, +# working tree + full history + AsciiDoc mirror) +# scan / rust-secrets (hardcoded literals in .rs files; self-skips +# without a Cargo.toml) +# scan / shell-secrets (hardcoded literals in shell scripts) +# +# All three declare `permissions: contents: read` and NO `secrets:` line. The +# reusable declares no `workflow_call.secrets` and references no `secrets.*` +# context, so `secrets: inherit` on a caller buys nothing and forwards every +# repository and organisation secret to a scanner that needs none (CWE-250, +# standards#1108 §6b). Do not add it back. +# +# ─── actions.lock AND THIS FILE ────────────────────────────────────────────── +# +# Every `uses:` here is a step-level action that `gh actions-lock` knows how to +# lock, so the whole file is inside the generator's model: `gh actions-lock` +# records each ref and cannot produce a dangling edge for anything in it. That +# is deliberate. A job-level call to a remote reusable is NOT lockable by the +# tool, and a hand-added record for one is worse than no record: an entry that +# the runner cannot resolve fails the run at startup with zero jobs, while an +# absent one is merely unverified. +# +# Regenerate with the official generator only: +# +# gh actions-lock # writes .github/workflows/actions.lock +# gh actions-lock --verify # read-only check +# scripts/check-lock-sync.sh .github/workflows # transitive-closure gate +# +# The scanner's own pin is a full 40-hex SHA in `STANDARDS_SHA` below and in the +# estate-baseline checkout, and it must match the standards pin every other +# workflow in this directory uses. jobs: - scan: - # The reusable installs and executes a pinned gitleaks binary directly. - # Since standards#500 it neither comments on PRs nor reads workflow-run - # metadata, so contents: read is the complete permission contract. Keep - # this job-level block explicit: it replaces the workflow-level grant and - # is therefore the cap GitHub applies to the called workflow. + gitleaks: + name: "scan / gitleaks" + runs-on: ubuntu-24.04 + timeout-minutes: 15 permissions: contents: read - uses: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@46ee18323305d5b36e2eef936e47dd54c39ea736 + env: + GITLEAKS_VERSION: '8.18.4' + GITLEAKS_SHA256: 'ba6dbb656933921c775ee5a2d1c13a91046e7952e9d919f9bac4cec61d628e7d' + STANDARDS_SHA: 'e064539116c2dcfdf282a4749dbd58c69961c7e6' + steps: + - name: Checkout repository with full history + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Checkout estate gitleaks baseline + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: metadatastician/standards + ref: e064539116c2dcfdf282a4749dbd58c69961c7e6 + path: .standards-baseline + sparse-checkout: config/gitleaks + sparse-checkout-cone-mode: true + persist-credentials: false + + - name: Stage estate baseline and install pinned gitleaks + run: | + set -euo pipefail + cp .standards-baseline/config/gitleaks/estate-baseline.toml .gitleaks-estate.toml + rm -rf .standards-baseline + test -s .gitleaks-estate.toml + + TARBALL="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + URL="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/${TARBALL}" + curl -sSfL --proto '=https' --proto-redir '=https' --tlsv1.2 \ + --retry 3 --retry-delay 2 \ + -o "$RUNNER_TEMP/$TARBALL" "$URL" + echo "${GITLEAKS_SHA256} ${RUNNER_TEMP}/${TARBALL}" | sha256sum -c - + tar -xzf "$RUNNER_TEMP/$TARBALL" -C "$RUNNER_TEMP" gitleaks + chmod +x "$RUNNER_TEMP/gitleaks" + rm -f "$RUNNER_TEMP/$TARBALL" + + - name: Gitleaks working-tree pass + run: | + set -euo pipefail + # The estate baseline always runs; a repository .gitleaks.toml adds a + # second pass and can never stand in for the baseline. + CONFIGS=(".gitleaks-estate.toml") + if [ -f .gitleaks.toml ]; then + CONFIGS+=(".gitleaks.toml") + fi + for CONFIG in "${CONFIGS[@]}"; do + "$RUNNER_TEMP/gitleaks" detect \ + --source . \ + --no-git \ + --redact \ + --no-banner \ + --verbose \ + --config "$CONFIG" \ + --exit-code 1 + done + + - name: Gitleaks full-history pass + run: | + set -euo pipefail + if [ "$(git rev-parse --is-shallow-repository)" != "false" ]; then + echo "::error::checkout is shallow; refusing vacuous history scan" + exit 1 + fi + # The estate baseline always runs; a repository .gitleaks.toml adds a + # second pass and can never stand in for the baseline. + CONFIGS=(".gitleaks-estate.toml") + if [ -f .gitleaks.toml ]; then + CONFIGS+=(".gitleaks.toml") + fi + for CONFIG in "${CONFIGS[@]}"; do + "$RUNNER_TEMP/gitleaks" detect \ + --source . \ + --redact \ + --no-banner \ + --verbose \ + --config "$CONFIG" \ + --exit-code 1 + done + + - name: Gitleaks AsciiDoc mirror pass + run: | + set -euo pipefail + MIRROR="$RUNNER_TEMP/adoc-mirror" + rm -rf "$MIRROR" + mkdir -p "$MIRROR" + found=0 + while IFS= read -r -d '' f; do + dest="$MIRROR/$(dirname "$f")" + mkdir -p "$dest" + cp "$f" "$dest/$(basename "$f").txt" + found=$((found + 1)) + done < <(find . -path ./.git -prune -o \( -name '*.adoc' -o -name '*.asciidoc' \) -type f -print0) + + if [ "$found" -eq 0 ]; then + echo "No AsciiDoc files present." + exit 0 + fi + # The estate baseline always runs; a repository .gitleaks.toml adds a + # second pass and can never stand in for the baseline. + CONFIGS=("$PWD/.gitleaks-estate.toml") + if [ -f .gitleaks.toml ]; then + CONFIGS+=("$PWD/.gitleaks.toml") + fi + for CONFIG in "${CONFIGS[@]}"; do + "$RUNNER_TEMP/gitleaks" detect \ + --source "$MIRROR" \ + --no-git \ + --redact \ + --no-banner \ + --config "$CONFIG" \ + --exit-code 1 + done + + rust-secrets: + name: "scan / rust-secrets" + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + env: + # Findings outside ./src (newly visible — see the scan-scope note in the + # step below) warn until this date, then block. Same warn-then-enforce + # idiom and same cutoff as the estate canonical reusable + # (metadatastician/standards .github/workflows/secret-scanner-reusable.yml + # at the pin named in this file's header), so this job and the reusable + # cannot disagree about when the widened scan starts blocking. + ENFORCE_RUST_WIDE_SCAN_FROM: '2026-08-21' + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Check for hardcoded secrets in Rust + run: | + set -euo pipefail + TODAY="${RUST_TODAY:-$(date -u +%Y-%m-%d)}" + + # An unparseable cutoff would pick the warn branch forever, silently + # disarming the widened scan. Refuse to run instead. + require_date() { + case "$2" in + [0-9][0-9][0-9][0-9]-[0-1][0-9]-[0-3][0-9]) : ;; + *) echo "::error::rust-secrets: $1='$2' is not YYYY-MM-DD." + echo "Refusing to run: an unparseable cutoff would disarm this gate." + exit 1 ;; + esac + } + require_date ENFORCE_RUST_WIDE_SCAN_FROM "$ENFORCE_RUST_WIDE_SCAN_FROM" + require_date RUST_TODAY "$TODAY" + + # Self-skips when the tree has no Cargo.toml, so the job is safe on a + # repository that is not (yet) Rust. The check run still appears, which + # is what keeps the context stable across every generated repository. + if ! find . -name Cargo.toml -not -path './target/*' -print -quit | grep -q .; then + echo 'No Cargo.toml found — skipping Rust secrets check' + exit 0 + fi + + # Scan every .rs file, not just ./src. A Cargo workspace keeps its + # code in crates/*/src, and a `grep … src/` matches nothing there: + # grep exits 2 on a missing directory, which an `if` reads as "clean". + mapfile -t FILES < <(find . -name '*.rs' \ + -not -path './target/*' -not -path '*/target/*' \ + -not -path './vendor/*' -not -path '*/vendor/*' \ + -not -path './.git/*' -print) + if [ ${#FILES[@]} -eq 0 ]; then + echo 'Cargo.toml present but no .rs files — nothing to scan' + exit 0 + fi + echo "Scanning ${#FILES[@]} Rust file(s)." + + # Each pattern requires an assignment DIRECTLY to a string literal of + # meaningful length, so lookups, paths and demos do not match. A + # credential shorter than 8 characters is not a credential either. + # `static` is accepted alongside `const`: a `static AUTH_KEY: &str = + # "…"` is a genuine hardcoded secret. + PATTERNS=( + '(const|static)[[:space:]]+(mut[[:space:]]+)?[A-Za-z0-9_]*SECRET[A-Za-z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*b?"[^"]{8,}"' + '(const|static)[[:space:]]+(mut[[:space:]]+)?[A-Za-z0-9_]*KEY[A-Za-z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*b?"[a-zA-Z0-9]{16,}"' + '(const|static)[[:space:]]+(mut[[:space:]]+)?[A-Za-z0-9_]*TOKEN[A-Za-z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*b?"[^"]{8,}"' + '(let|static)[[:space:]]+(mut[[:space:]]+)?[a-z0-9_]*api_key[a-z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*b?"[^"]{8,}"' + 'HMAC.*"[a-fA-F0-9]{32,}"' + '[a-z0-9_]*password[a-z0-9_]*[[:space:]]*(:[^=]*)?=[[:space:]]*b?"[^"]{8,}"' + ) + + # Exemptions match on the VALUE or on an explicit pragma, never on a + # file path: a path-based exemption would blind the job to a genuine + # secret in the same file. + # 1. env-lookup RHS — `env::var("NAME")` IS the practice this job + # exists to enforce, so flagging it fails the correct code. + # 2. URL values — an OAuth endpoint is public, not a credential. + # 3. comment lines — documentation of a pattern is not a secret. + # 4. inline pragma `// scanner-allow: rust-secrets`. + COMMENT='^[^:]*:[0-9][0-9]*:[[:space:]]*(//|/\*|\*)' + ENV_RHS='=[^;]*(env::var|env::var_os|std::env|env!\(|option_env!\(|from_env|dotenv)' + URL_RHS='=[[:space:]]*b?"(https?|wss?|ftp)://' + PRAGMA='scanner-allow:[[:space:]]*rust-secrets' + + blocking=0 + advisory=0 + for pattern in "${PATTERNS[@]}"; do + hits="$(grep -nHE "$pattern" "${FILES[@]}" 2>/dev/null \ + | grep -vE "$COMMENT" \ + | grep -vE "$ENV_RHS" \ + | grep -vE "$URL_RHS" \ + | grep -vE "$PRAGMA" || [ $? -eq 1 ])" + [ -n "$hits" ] || continue + + # ./src hits keep blocking. Hits outside it are newly visible, so + # they warn until the cutoff rather than reddening repositories + # whose wider tree was never actually being scanned. + old_scope="$(printf '%s\n' "$hits" | grep -E '^\./src/' || [ $? -eq 1 ])" + new_scope="$(printf '%s\n' "$hits" | grep -vE '^\./src/' || [ $? -eq 1 ])" + + if [ -n "$old_scope" ]; then + printf '%s\n' "$old_scope" + echo "WARNING: Potential hardcoded secret found matching: $pattern" + blocking=1 + fi + if [ -n "$new_scope" ]; then + printf '%s\n' "$new_scope" + if [[ "$TODAY" < "$ENFORCE_RUST_WIDE_SCAN_FROM" ]]; then + echo "::warning::Potential hardcoded secret outside ./src matching:" \ + "$pattern — becomes BLOCKING on $ENFORCE_RUST_WIDE_SCAN_FROM (today is $TODAY)." + advisory=1 + else + echo "WARNING: Potential hardcoded secret found matching: $pattern" + blocking=1 + fi + fi + done + + if [ "$blocking" -eq 1 ]; then + echo "::error::Potential hardcoded secrets detected. Use environment variables instead." + exit 1 + fi + if [ "$advisory" -eq 1 ]; then + # Never claim a clean pass while findings are outstanding. + echo "NOT YET ENFORCED: findings outside ./src are advisory until $ENFORCE_RUST_WIDE_SCAN_FROM." + exit 0 + fi + echo 'No hardcoded secrets detected in Rust sources.' + + shell-secrets: + name: "scan / shell-secrets" + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run repository secret pattern hook + run: | + set -euo pipefail + rc=0 + git grep -IlE '(ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{82}|glpat-[A-Za-z0-9_-]{20,}|AKIA[0-9A-Z]{16}|-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----)' > "$RUNNER_TEMP/hits.txt" || rc=$? + # git grep exits 1 when nothing matches; above 1 is an error. + if [ "$rc" -gt 1 ]; then + echo "::error::Secret pattern scan failed with exit ${rc}" + exit 2 + fi + # Exclude intentional scanner rules and adversarial negative test fixtures. + filtered="$(grep -vE '^(\.github/hooks/scan-secrets\.sh|\.github/workflows/secret-scanner\.yml|\.machine_readable/testing/adversarial-content\.k9\.ncl|tests/)' "$RUNNER_TEMP/hits.txt" || :)" + if [ -n "$filtered" ]; then + echo "::error::Hardcoded credential material detected:" + printf '%s\n' "$filtered" + exit 1 + fi + echo "No hardcoded shell/source credentials detected." diff --git a/.github/workflows/security-policy.yml b/.github/workflows/security-policy.yml index 6d9e43f..a95cf6c 100644 --- a/.github/workflows/security-policy.yml +++ b/.github/workflows/security-policy.yml @@ -36,7 +36,7 @@ jobs: fi # Block HTTP URLs (except localhost) - HTTP_URLS=$(grep -rE 'http://[^l][^o][^c]' --include="*.py" --include="*.js" --include="*.ts" --include="*.go" --include="*.rs" --include="*.yaml" --include="*.yml" . 2>/dev/null | grep -v 'localhost\|127.0.0.1\|example\|test\|spec' | head -5 || true) + HTTP_URLS=$(grep -rE 'http://[^l][^o][^c]' --include="*.py" --include="*.js" --include="*.ts" --include="*.go" --include="*.rs" --include="*.yaml" --include="*.yml" --include="*.kyaml" . 2>/dev/null | grep -v 'localhost\|127.0.0.1\|example\|test\|spec' | head -5 || true) if [ -n "$HTTP_URLS" ]; then echo "⚠️ HTTP URLs found. Use HTTPS:" echo "$HTTP_URLS" diff --git a/.github/workflows/verdict-grid-publish.yml b/.github/workflows/verdict-grid-publish.yml index 7a67d17..d1be6e1 100644 --- a/.github/workflows/verdict-grid-publish.yml +++ b/.github/workflows/verdict-grid-publish.yml @@ -71,7 +71,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: metadatastician/standards - ref: 8e48b1caade08a552646adeab66558ac43b4360d + ref: e064539116c2dcfdf282a4749dbd58c69961c7e6 path: .standards-grid persist-credentials: false sparse-checkout: | diff --git a/.machine_readable/mint-verbatim.txt b/.machine_readable/mint-verbatim.txt index c8eb088..c7030f4 100644 --- a/.machine_readable/mint-verbatim.txt +++ b/.machine_readable/mint-verbatim.txt @@ -27,6 +27,7 @@ scripts/strip-instruction-blocks.rs # names the marker it strips scripts/resolve-template-pin.sh # documents the template it resolves tests/e2e/template_instantiation_test.sh # token answer list; marker exemptions tests/e2e/use_template_mint_test.sh # mints a template copy: template literals +tests/e2e/chain_mint_test.sh # chain fixtures: parent names and paths tests/workflows/mint_cleanup_test.sh # instruction-block fixture tests/workflows/self_name_pass_test.sh # template-name fixtures tests/workflows/template_conformance_test.sh # template-slug fixtures @@ -35,3 +36,5 @@ tests/workflows/mint_gates_test.sh # names an unanswered token, on purpose tests/e2e/language_profiles_mint_test.sh # minted-tree fixtures; keeps archetype paths tests/workflows/resolve_template_pin_test.sh # template-slug fixtures docs/reports/audit/consistency-audit-2026-11-01.adoc # quotes an unfilled project-name token as prose: the audit reports where it was left +tests/ffi_strings_test.sh # FFI token fixture, filled in a scratch copy +tests/workflows/container_kyaml_test.sh # container token fixtures (template only) diff --git a/.machine_readable/required-contexts-allow.txt b/.machine_readable/required-contexts-allow.txt index 1f429f7..dcab0ef 100644 --- a/.machine_readable/required-contexts-allow.txt +++ b/.machine_readable/required-contexts-allow.txt @@ -8,4 +8,28 @@ # Verified by `scripts/check-required-contexts.sh` (3-outcome 0/1/2) so a # renamed or path-filtered workflow can never silently strand pull requests # behind an unsatisfiable ruleset check (issue #219 class). +# +# All three contexts come from ONE workflow and are ordinary job names, not the +# ` / ` pairs a job-level reusable call would produce: +# +# * `scan / gitleaks` is the historic headline context for the +# `Secret-Scan-Floor` ruleset (24309812). NOTE: as of 2026-11-08 that +# ruleset reads `rules: []` and enforces nothing on `main`, so this context +# is currently PRODUCED but not REQUIRED. Do not describe it as required +# without re-reading the live ruleset. +# * `scan / rust-secrets` and `scan / shell-secrets` are produced by the same +# workflow and are listed here so that (a) the gate certifies that every +# generated repository really starts all three scanner jobs, and (b) turning +# any of them on in a ruleset needs no mapping edit. Listing a produced +# context is not the same as requiring it: this file is a producer map, and +# the empty `required_status_checks` in `.github/rulesets/base.json` and in +# `.github/settings.yml` is still what keeps the floor where the owner left +# it. +# +# A context here MUST be a job `name:` (or job id) in its workflow. A remote +# reusable-workflow call produces nothing this gate can certify, which is why +# `.github/workflows/secret-scanner.yml` runs its three scanners inline instead +# of wrapping them in a single reusable call. scan / gitleaks => .github/workflows/secret-scanner.yml +scan / rust-secrets => .github/workflows/secret-scanner.yml +scan / shell-secrets => .github/workflows/secret-scanner.yml diff --git a/.machine_readable/testing/adversarial-content.k9.ncl b/.machine_readable/testing/adversarial-content.k9.ncl index 97effdd..3374d1d 100644 --- a/.machine_readable/testing/adversarial-content.k9.ncl +++ b/.machine_readable/testing/adversarial-content.k9.ncl @@ -31,6 +31,10 @@ K9! path = "tests/workflows/squabble_gate_test.sh", purpose = "Plants admin-override strings, continue-on-error masks, and phantom ruleset contexts into ephemeral workflow fixtures to prove cicd-squabbler and check-required-contexts.sh fire.", }, + { + path = "tests/workflows/secret_scanner_contract_test.sh", + purpose = "Mutates the shipped secret-scanner workflow — renames a job, deletes one, adds `secrets: inherit`, adds a secrets.* read, widens the permission grant, unpins an action, retargets push away from the default branch, drops pull_request, drops concurrency, and converts a job to a remote reusable call — to prove the three stable scanner contexts stay this repository's own.", + }, { path = "tests/workflows/governance_badge_test.js", purpose = "Feeds malformed lane artifacts, malformed existing badge files, 401/403/429/5xx responses, a dead API and 409 sha conflicts into scripts/governance-badge.js against a mock GitHub API, and runs the workflow-wiring checks against a mutated workflow, to prove the publisher never publishes a false green and never dedupes a changed badge.", diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 889f9d5..b45f261 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -10,6 +10,34 @@ https://semver.org/spec/v2.0.0.html[Semantic Versioning]. ==== Fixed +* **The canonical Agda invocation is in force, and grades follow PROOF-POLICY §8.** +** `--cubical-compatible` (PROOF-POLICY §2.1) is in `scripts/proof-flags.sh`, so + the gate, the laws certificate and the receipt all run under + `--safe --without-K --cubical-compatible`. The thirteen + `just`-injectivity clauses in `Noninterference.Generic` read the run's result + through `Prelude.just-inj`, defined by `cong`, not by matching. No statement, + semantics or fixture changed. Waiver W-1 is closed. +** `tests/flag-control/IndexedMatch.agda` proves the flag is in force: + `scripts/check.sh` fails if that fixture is ever accepted. +** "mathesis Grade 3" was the unratified brief's wording; the theorems are now + recorded as `PWG-3` with the §8.5 fields (`pwg`, `foundation-diverse`, and + each leg's route, token and §2.2 state). The receipt moved from + `agda-grade3_praxis.deed` to `agda_praxis.deed`, because a grade is derived + and can change, and pins the `PROOF-POLICY` commit it reads. +** `docs/EXPLAINME.adoc` listed `--cubical-compatible` among the checker flags + while the flag was waived; that line is now true. + +* **Mint machinery synced to rsr-template-repo 2be7b88.** estate-rules was red: + the inherited `tests/e2e/use_template_mint_test.sh` (template c481073) + re-minted this already-minted tree and failed on an unfilled token. The + template fixed that in #275, #291, #293 and #294; files this repository held + byte-identical to c481073 now take the template's current version, diverged + files were three-way merged, and the inline three-scanner secret workflow + (#295) replaces the reusable caller. Kept as this repository's own: the + governance caller (#15), the verdict-grid pin (standards 8e48b1c, which + renders; the template's e064539 does not), the rule-order checker and its + policy, README and `mise.toml` (plus `yq`). + * **The template's gates could not fail, and the toolchain was not minted.** Reported from JuliaPackageSpitter.jl and traced here; see `docs/reports/compliance/RSR-TEMPLATE-DAMAGE-2026-10-30.adoc` for the full diff --git a/CLAUDE.md b/CLAUDE.md index b385e2e..aac404f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,3 +158,10 @@ Third-party code (crates such as Bevy, libraries, forks) is **not** converted or re-proved. State what a proof relies on as an `ASSUMED` boundary specification, and when choosing between dependencies that fit equally well, prefer one that carries its own verification. A preference, not a gate. + +Proof grades are policy §8, *Proof Witness Grades*: `PWG-3` < `PWG-2i` < +`PWG-2ii` < `PWG-1`, where `PWG-1` is strongest. A grade is derived from +receipts, never asserted. A "verified" or "machine-checked" claim needs +current receipts. Report a census with its denominator, never a +percentage. Always write the `PWG-` prefix: CRG, TRG, ARG and FRG letters, +and ECHIDNA's `TrustLevel`, are different schemes. diff --git a/CODE_OF_CONDUCT.adoc b/CODE_OF_CONDUCT.adoc index 954b4f9..4af75b5 100644 --- a/CODE_OF_CONDUCT.adoc +++ b/CODE_OF_CONDUCT.adoc @@ -25,7 +25,7 @@ If you believe someone is violating the code of conduct, we ask that you report - Repeated harassment of others. In general, if someone asks you to stop, then stop. - **When we disagree, try to understand why.** Disagreements, both social and technical, happen all the time and Hyperpolymathy Estates is no exception. It is important that we resolve disagreements and differing views constructively. Remember that we’re different. The strength of Hyperpolymathy Estates comes from its varied community, people from a wide range of backgrounds. Different people have different perspectives on issues. Being unable to understand why someone holds a viewpoint doesn’t mean that they’re wrong. Don’t forget that it is human to err and blaming each other doesn’t get us anywhere. Instead, focus on helping to resolve issues and learning from mistakes. -Original text courtesy of the [Speak Up! project](http://web.archive.org/web/20141109123859/http://speakup.io/coc.html). +Original text courtesy of the [Speak Up! project](https://web.archive.org/web/20141109123859/speakup.io/coc.html). ## Questions? diff --git a/PROOF-STATUS.adoc b/PROOF-STATUS.adoc index dea8ce1..eea3bb7 100644 --- a/PROOF-STATUS.adoc +++ b/PROOF-STATUS.adoc @@ -2,124 +2,135 @@ = secret-types — Proof Status :revdate: 2026-11-09 -Statuses: PROVEN / TESTED / ASSUMED / DESIGNED / OPEN, with the closed ledger -tokens of `standards/docs/README-EXPLAINME-STANDARD.adoc` §5 in +Statuses are the closed vocabulary of `standards/3-practice/PROOF-POLICY.adoc` +§5 (PROVEN / PROVEN-WITH-AXIOMS / TESTED / ASSUMED / OPEN), with the matching +ledger tokens of `standards/docs/README-EXPLAINME-STANDARD.adoc` §5 in link:docs/EXPLAINME.adoc#proof-ledger[the Proof Ledger]. Every PROVEN line names -its module. All modules are `--safe --without-K --no-libraries`, checked with -`--double-check` and `-W error` by -`scripts/check.sh`, no postulates. The flag set and the Agda pin live in exactly -one place, `scripts/proof-flags.sh`, so this file, the gate and the receipt -cannot disagree. CI: TESTED. `.github/workflows/proofs.yml` (Debian 13, -`agda-bin=2.6.4.3-1+b2`) ran both gates green on f149e08, run -https://github.com/hyperpolymath/secret-types/actions/runs/39289501788[39289501788], -whose log ends `PASS: minimal-laws certificate (3 laws, each needed)`. +its module. Every module is checked by `scripts/check.sh` under the §2.1 +canonical invocation, `agda --safe --without-K --cubical-compatible`, and four +stricter flags (`--no-libraries --ignore-interfaces --double-check -W error`), +with no postulates. The flag set and the Agda pin live in exactly one place, +`scripts/proof-flags.sh`, so this file, the gate and the receipt cannot +disagree, and a flag-control fixture proves the flag is really in force. == Proof Witness Grades, and the independent-witness legs -Grades here are `PROOF-POLICY.adoc` §8 Proof Witness Grades (owner ruling -2026-11-04), always written with their `PWG-` prefix. Until 2026-11-09 this file -said "mathesis Grade 3". §8 replaced that wording, and §8.2 makes `PWG-3` -conditional on the §2.1 invocation, which this repository did not meet until -W-1 closed (below). Before 2026-11-09 the honest grade of every row was -therefore `ungraded`, not 3. +Grades here are the estate's *Proof Witness Grades*, `PROOF-POLICY.adoc` §8 +(owner ruling 2026-11-04): `PWG-3`, `PWG-2i`, `PWG-2ii`, `PWG-1`, lower is +stronger, `ungraded` below the ladder. They are derived from the receipt, never +asserted (§8.4). This file previously said "mathesis Grade 3", the unratified +brief's wording; §8 supersedes it, and its `PWG-3` additionally needs the §2.1 +invocation, which this repository has met since waiver W-1 closed. -[cols="1,2,2",options="header"] +[cols="1,1,3",options="header"] |=== | Claim | PWG | Why not higher -| N-1, N-2, N-3, N-7, N-8, N-9 | *`PWG-3`* | PROVEN under the canonical +| N-1, N-2, N-3, N-7, N-8, N-9 | *`PWG-2ii`* | PROVEN under the canonical invocation `agda --safe --without-K --cubical-compatible`, with stronger flags - besides (`scripts/proof-flags.sh`). No Rocq leg and no Lean leg counts, so - `PWG-2i` is not reached. `PWG-3` is not a discharge (§8.4): each of these - theorems still owes both §2 legs. - -| N-4, N-5, N-6 | n/a | They are checked demonstrations (refusals, computed - witnesses, a shell-orchestrated law-ablation certificate), not theory - theorems. §8.1 grades theorems only. - -| Termination-sensitive noninterference under the current typing rules | REFUTED | N-9 - formalizes the N-5 counterexample when finishing versus non-finishing is - observable. A stronger typing discipline remains open. Declassification is OPEN. + besides (`scripts/proof-flags.sh`), and both §2 legs count: re-proved in Rocq + and in Lean, kernel-checked with no axioms, `RECONCILED` (below). Not + `PWG-1`: that needs a per-commit receipt of each checker's observed result, + and the receipt records the gate, not its result. `foundation-diverse` is + false: Agda, Rocq and Lean are all dependent type theories (§8.3). + +| N-4, N-5, N-6 | `ungraded` | TESTED demonstrations (refusals, computed + witnesses, a law-ablation certificate), not theorems; §8.1 grades theory + theorems only. |=== -No theorem rests on an axiom: `postulates = 0` and `escape-hatches = 0` in the -receipt, under `--safe` (§8.4, "axioms travel with the grade"). +N-9 is a proved *refutation*: termination-sensitive noninterference is false for +the current typing rules when finishing versus non-finishing is observable. A +stronger typing discipline is OPEN, and so is declassification. -=== The two legs, recorded honestly +No theorem rests on an axiom (§8.4, "axioms travel with the grade"). In Agda: +`postulates = 0` and `escape-hatches = 0` in the receipt, under `--safe`. In +Rocq, `Print Assumptions` reports `Closed under the global context` for every +leg theorem; in Lean, `#print axioms` reports `does not depend on any axioms`. + +=== The two legs `PROOF-POLICY.adoc` §2 requires every Agda theory proof to be rechecked in Rocq and in Lean, by transport through Dedukti first and by hand re-proof as the -fallback, with the route named per theorem. Neither leg exists here, and the -policy is explicit that adopting a policy does not execute it: each row below -would need its own CI receipt. +fallback, with the route named per theorem. Both legs exist since 2026-11-09. +Their §2.2 record (statements, universe discipline, versions, invocations, +assumptions) is link:verification/legs/README.adoc[`verification/legs/README.adoc`]; +`bash scripts/check-legs.sh` checks them, and CI runs it as the `legs` job. -[cols="1,1,1,3",options="header"] +[cols="1,1,1,1,3",options="header"] |=== -| Claim | Rocq leg | Lean leg | Record - -| N-1, N-2, N-3, N-7, N-8, N-9 | `unavailable` | `unavailable` -| No Agda→Dedukti transport has been attempted from this repository (the - estate's exchange layer lives in `echidna`, and `PROOF-POLICY` §2.2 names - ECHIDNA as a longer-term destination, not a prerequisite), and no hand - re-proof has been attempted. Nothing was skipped silently: this is the - opening state, stated. - -| all rows | — | — -| *A leg that does not exist cannot be evidenced by a file that happens to be - present.* `verification/proofs/coq/TypeSafety.v`, +| Claim | Rocq leg | Lean leg | §2.2 state | Record + +| N-1, N-2, N-3, N-7, N-8, N-9 | `re-proved`, PROVEN | `re-proved`, PROVEN +| `RECONCILED` +| Transport is not available: Agda2Dedukti is an Agda backend pinned to a fork + of Agda and cannot run on 2.6.4.3, and there is no released Dedukti or + Lambdapi exporter to Lean 4. So each leg restates the Agda theorem and proves + it by hand: Coq 8.18.0 with the prelude only, Lean 4.24.0 with core only (no + Mathlib). The statement comparison was written by Claude Code and is + provisional until the owner reviews it (estate doctrine 4). + +| N-4, N-5, N-6 | n/a | n/a | n/a +| Checked demonstrations, not theorems (§8.1). + +| all rows | — | — | — +| *A file that happens to be present is not a leg.* + `verification/proofs/coq/TypeSafety.v`, `verification/proofs/lean4/ApiTypes.lean` and the other skeletons under `verification/proofs/` are RSR template boilerplate ("replace with your project's …"). They prove nothing, they are not the legs, and their - `MANIFEST` lines claiming `gated` are false — no workflow invokes + `MANIFEST` lines claiming `gated` are false: no workflow invokes `scripts/check-proofs.sh`. Recorded as finding F-4 in link:docs/reports/audit/consistency-audit-2026-11-01.adoc. |=== -Should a leg ever be attempted, `PROOF-POLICY` §2.2 fixes the record: exact -proposition, assumptions, universe discipline, source revision, compiler and -library versions, invocation, observed result, and the bridge or statement -correspondence — *transported* or *re-proved* — and the local statuses -AGDA-VERIFIED / COQ-CHECKED / LEAN-CHECKED / BRIDGE-PENDING before RECONCILED. +The receipt's `(legs)` block records each leg's pinned checker, invocation and +axiom check, and the `sha256` of every leg source, so a change to a leg cannot +leave the receipt intact. `scripts/grade-receipt.sh` also refuses a claim that +counts a leg without a row in the correspondence record, and the reverse. -=== The canonical invocation is met: `--cubical-compatible` (W-1, closed) +=== The §2.1 invocation, and how waiver W-1 closed `PROOF-POLICY` §2.1 (dated 2026-11-01) names -`agda --safe --without-K --cubical-compatible` as the canonical invocation. The -gate now runs it, with `--no-libraries --ignore-interfaces --double-check -W -error` besides, so any `-WUnsupportedIndexedMatch` is fatal rather than -suppressed. - -Two changes made the flag hold; neither changes a statement. The two-point -order became a computed predicate on 2026-11-01 (W-1 arm B1). On 2026-11-09 the -last class of warnings went: the proofs used to match an equation -`exec fuel c s ≡ just t` against `refl`, which needs injectivity of `just`. They -now read the result through `just-injective`, a retraction -(`cong (from-just a)`, `SecretTypes.Prelude`), the same move `suc-dec` makes with -`pred`. W-1 had said only a relational semantics would do (arm B2), at the cost -of restating N-5; that turned out to be unnecessary, and the semantics, N-5 and -every theorem statement are unchanged. The record is in -link:docs/proof-debt.adoc#waivers[`docs/proof-debt.adoc` W-1]. +`agda --safe --without-K --cubical-compatible` as the canonical invocation. It is +in force. Adopting it took two rounds: + +* *2026-11-01.* Two constructor-injectivity lemmas were removed or re-expressed + (`just-inj` deleted, `_≟_` through `pred`), and the two-point order became a + computed predicate (`L ⊑ _ = ⊤`, `H ⊑ L = ⊥`), which removed every + `L`/`H`-injectivity warning. Thirteen warnings remained, all + `just`-injectivity in `Noninterference.Generic`, from matching `refl` + against `exec … ≡ just t`. The flag was waived (W-1), on the belief that only + a relational big-step semantics could remove them. +* *2026-11-09.* That belief was wrong, and was measured rather than argued. The + thirteen clauses now read the run's result through `Prelude.just-inj`, defined + by `cong` through a computing projection, which needs no unification rule. The + statements, the semantics and every fixture are unchanged. Conversely, a + relational semantics alone would *not* have been enough: a probe proving + determinism of `c , s ⇓ t` raised 22 `UnsupportedIndexedMatch` warnings, + because inverting two derivations of one command needs injectivity of `Cmd`'s + constructors. + +The warning is never suppressed. `tests/flag-control/IndexedMatch.agda` is a +correct proof that only `--cubical-compatible` refuses; `scripts/check.sh` fails +if it is ever accepted, so dropping the flag cannot pass the gate (tested both +ways). W-1 is kept, closed, in link:docs/proof-debt.adoc#waivers[`docs/proof-debt.adoc`]. === What the grade is anchored to -`verification/receipts/agda-pwg_praxis.deed`, generated by -`scripts/grade-receipt.sh` and checked in CI, records the exact source commit, -the Agda pin and provenance, the flag set, the `sha256` of every module (the -import cone: builtins only), the claim IDs with their ledger tokens, PWGs and -legs, and the trust-base counts (`postulates = 0`, `escape-hatches = 0`, -`library-dependencies = 0`, `axioms-assumed = 0`). A change to a proved statement -cannot leave the receipt intact; a claim added here cannot leave the receipt -intact either, in either direction. +`verification/receipts/agda_praxis.deed`, generated by `scripts/grade-receipt.sh` +and checked in CI, records the exact source commit, the Agda pin and provenance, +the flag set, the `PROOF-POLICY` commit it reads (`hyperpolymath/standards` +`9bdf864cc7f2c8e24b4df84bf00d04158eee3ad5`), the `sha256` of every module (the +import cone of `SecretTypes.All`), the trust-base counts (`postulates = 0`, +`escape-hatches = 0`, `library-dependencies = 0`, `axioms-assumed = 0`), and for +each claim its ledger token, its derived `pwg`, `foundation-diverse`, and each +leg's route, token and §2.2 state (§8.5). A change to a proved statement cannot +leave the receipt intact; a claim added here cannot leave the receipt intact +either, in either direction. Proof debt, in the four dispositions the trusted-base policy requires, is link:docs/proof-debt.adoc[] — all four empty, and enforced rather than asserted. -Its `#waivers` section now carries no open waiver; W-1 is kept there, closed, -as the record of how the flag came to hold. - -CI history: the gate without `--cubical-compatible` was green on `8e421cc`, run -link:https://github.com/hyperpolymath/secret-types/actions/runs/41327614303[41327614303], -which is also the evidence for the two Agda changes the first flag attempt -forced (`just-inj` removed as dead code, `_≟_` re-expressed through `pred`). == Fragment (stated before the claims) diff --git a/README.adoc b/README.adoc index 82744c2..031a53c 100644 --- a/README.adoc +++ b/README.adoc @@ -12,6 +12,7 @@ image:https://img.shields.io/badge/Docs-CC--BY--SA--4.0-blue.svg?logo=creativeco // ── Status ────────────────────────────────────────────────────────────────── image:https://img.shields.io/badge/Proof-core_theorem_proven-brightgreen[Core theorem proven,link="PROOF-STATUS.adoc"] image:https://img.shields.io/badge/RSR-Rhodium_Standard-9C27B0[Rhodium Standard Repository,link="https://github.com/hyperpolymath/rhodium-standard-repositories"] +image:https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/hyperpolymath/secret-types/governance-badges/badges/k9.json[K9 conformance,link="https://github.com/hyperpolymath/secret-types/actions/workflows/dogfood-gate.yml"] Confidentiality labels that constrain value flow, with declassification only at explicit audited points: a standalone information-flow typing project. @@ -37,7 +38,7 @@ worth stating because it was measured on 2026-11-01: link:https://github.com/metadatastician/lintel[`metadatastician/lintel`] imports `SecretTypes.{Prelude,Vars,Lattice.Record,Lang,Noninterference.Generic}`, defines `reader-sets : Lattice`, and opens the generic TINI at it, so the estate does -have a mechanised consumer of this calculus. It is pinned at `ee01db4`; Lintel's +have a mechanised consumer of this calculus. It is pinned at `d1d843b`; Lintel's own gate was run against this repository's HEAD on 2026-11-01 and passes, so the lattice re-representation described in link:docs/proof-debt.adoc#waivers[the proof-debt record] is non-breaking for it. Recording a consumer that exists is @@ -76,17 +77,21 @@ Echo fibre over its outputs does not separate secret inputs — the Echo reading of noninterference, and the first candidate connection Secret → Echo for `nextgen-typing/docs/TYPE-CONNECTIONS.adoc`. -*Grade.* The proved claims are at Proof Witness Grade **`PWG-3`** -(`PROOF-POLICY.adoc` §8) and no higher: they type-check in Agda under the +*Grade.* The proved claims are at Proof Witness Grade **`PWG-2ii`** +(`PROOF-POLICY.adoc` §8) and no higher. They type-check in Agda under the canonical §2.1 invocation, `--cubical-compatible` included, with stronger flags -besides (`scripts/proof-flags.sh`). The Rocq and Lean recheck legs that §2 -requires, and that `PWG-2i`/`PWG-2ii` count, do not exist; they are recorded -`unavailable`, not skipped silently. `PWG-3` is not a discharge: those legs are -still owed. Per-claim evidence is in link:docs/EXPLAINME.adoc#proof-ledger[the Proof -Ledger], the machine record is -link:verification/receipts/agda-pwg_praxis.deed[`verification/receipts/agda-pwg_praxis.deed`] +besides (`scripts/proof-flags.sh`). Each is also re-proved by hand in Rocq +(Coq 8.18.0, prelude only) and in Lean (4.24.0, core only, no Mathlib), with no +axioms in either, and the statements are compared in +link:verification/legs/README.adoc[`verification/legs/README.adoc`]. That +comparison was written by Claude Code and is provisional until the owner reviews +it. Not `PWG-1`: no per-commit receipt captures each checker's observed result +yet. Not `+foundation-diverse`: no witness outside dependent type theory. +Per-claim evidence is in link:docs/EXPLAINME.adoc#proof-ledger[the Proof Ledger], +the machine record is +link:verification/receipts/agda_praxis.deed[`verification/receipts/agda_praxis.deed`] (checked in CI), and link:docs/proof-debt.adoc[proof debt] is empty in all four -dispositions. This paragraph states no grade the ledger does not support. +dispositions. This paragraph states no grade the receipt does not support. == Where things belong diff --git a/build/container/compose.yaml b/build/container/compose.yaml index f88d576..0628a56 100644 --- a/build/container/compose.yaml +++ b/build/container/compose.yaml @@ -21,7 +21,7 @@ # ────────────────────────────────────────────────────────────────────── # Primary application service # ────────────────────────────────────────────────────────────────────── - "secret-types": { + secret-types: { build: { context: "..", dockerfile: "build/container/Containerfile", @@ -74,7 +74,7 @@ "rokur-audit:/var/log/rokur", ], depends_on: { - "secret-types": { + secret-types: { condition: "service_healthy", }, }, @@ -128,7 +128,7 @@ }, }, volumes: { - "secret-types-data": null, + secret-types-data: null, rokur-audit: null, }, } diff --git a/build/just/container.just b/build/just/container.just index 900bcc1..f56a856 100644 --- a/build/just/container.just +++ b/build/just/container.just @@ -46,6 +46,10 @@ container-init: exit 1 fi + # The configs are reformatted after their tokens are filled, which needs a + # KYAML-capable yq: check before anything is written. + bash scripts/format-container-kyaml.sh --preflight + echo "=== Container Template Initialisation ===" echo "" @@ -65,8 +69,11 @@ container-init: read -rp "Primary port [8080]: " _PORT PORT="${_PORT:-8080}" - read -rp "Container registry [ghcr.io/${OWNER:-{{OWNER}}}]: " _REGISTRY - REGISTRY="${_REGISTRY:-ghcr.io/${OWNER:-{{OWNER}}}}" + # Image paths are lower case; an owner login may not be. + _OWNER="${OWNER:-{{OWNER}}}"; _OWNER="${_OWNER,,}" + read -rp "Container registry [ghcr.io/${_OWNER}]: " _REGISTRY + REGISTRY="${_REGISTRY:-ghcr.io/${_OWNER}}" + bash scripts/format-container-kyaml.sh --check-values "$SERVICE_NAME" "$PORT" "$REGISTRY" echo "" echo " Service: $SERVICE_NAME" @@ -106,6 +113,10 @@ container-init: fi done + # Filling a token can change how KYAML quotes it; reformat the configs or + # Estate Rules' "Converted YAML stays KYAML" fails the child. + bash scripts/format-container-kyaml.sh + # The k9-svc deploy component ships as a template (deploy.k9.ncl.in): a # file with brace tokens is not a K9 component. Render it once nothing is # left to fill (repo-init fills PROJECT_NAME and VERSION), so the K9 lane diff --git a/build/just/provision-lib.sh b/build/just/provision-lib.sh index 83ac001..4993506 100755 --- a/build/just/provision-lib.sh +++ b/build/just/provision-lib.sh @@ -176,7 +176,7 @@ guix_specs() { # mise tools per language (registry-checked 2026-09-30, mise 2026.7.5). idris2 comes # from pack and haskell from ghcup or Guix: neither is a mise tool. The recipes # themselves need just and shellcheck, so those two are always declared. -MISE_BASE="just shellcheck" +MISE_BASE="just shellcheck yq" # yq: scripts/kyaml-format.sh (KYAML configs; the mint formats the container ones) # Tools a repo's own recipes call (e.g. `deps-audit` runs trivy): pinned only where used. RECIPE_TOOLS="trivy" # Print the mise tool names for language $1, if supported. diff --git a/build/just/repo-init.just b/build/just/repo-init.just index 59c5db2..35f7e61 100644 --- a/build/just/repo-init.just +++ b/build/just/repo-init.just @@ -44,6 +44,28 @@ repo-init archetype="": echo "Error: repo-init must run inside a git work tree (run 'git init' first)." exit 1 fi + # The mint runs git against other repositories too (replays, a template + # clone, the tests it runs in scratch repositories), and an inherited + # repository location (GIT_DIR, GIT_INDEX_FILE, … from a hook or another git + # caller) would redirect every one of them into this repository: the + # replay's commits, the tests' fixture commits (CodeRabbit review of #275). + # So the location is cleared, once, here, for this recipe and everything it + # starts; one that named a different repository than this work tree's own + # is refused, since clearing it would mint something else. + _git_loc=(GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY + GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_PREFIX GIT_NAMESPACE) + _git_set=""; for _v in "${_git_loc[@]}"; do [ -z "${!_v+x}" ] || _git_set="${_git_set} ${_v}"; done + if [ -n "$_git_set" ]; then + _here_gd="$(git rev-parse --absolute-git-dir 2>/dev/null || true)" + _bare_gd="$(cd "$(git rev-parse --show-toplevel)" && env $(printf -- '-u %s ' "${_git_loc[@]}") git rev-parse --absolute-git-dir 2>/dev/null || true)" + if [ -z "$_bare_gd" ] || [ "$_here_gd" != "$_bare_gd" ]; then + echo "Error: mint aborted before writing anything — git location variables are set" + echo " (${_git_set# }) and name a repository other than this work tree's own." + echo " Run repo-init from the repository's work tree without them." + exit 1 + fi + unset "${_git_loc[@]}" + fi # --- The template's own identity --------------------------------------- # repo-init is carried into every minted repo UNCHANGED (it is listed in @@ -56,33 +78,72 @@ repo-init archetype="": # metadatastician/lintel 2026-10-19; first fixed there in lintel#9 (not # merged), ported here, where the generator lives. # - # Derived, never asked: these are facts about the checkout, not choices. - # The root Justfile comes first because its OWNER/REPO lines are also the - # literals written through the tree, so they are exactly what the rewrite - # has to match, and they are still the TEMPLATE's in a repository created - # with "Use this template" (whose git remote is already the child's). The - # remote is second, for a Justfile that no longer declares them. The - # original template is last, so a bare export still mints something valid. + # Derived, never guessed: these are facts about the checkout, not choices. + # The root Justfile's OWNER/REPO lines are the literals written through the + # tree, so they are exactly what the rewrite has to match, and they are + # still the TEMPLATE's in a repository created with "Use this template". + # When the Justfile does not declare them, RSR_TEMPLATE_SLUG=owner/repo + # must say so, or the mint stops. There is deliberately no fallback to the + # git remote: in a template-created child the remote is the CHILD, so that + # fallback named the child as its own template, and the pin resolver then + # recorded the child's HEAD (Codex review of #251). Nor is there a default + # template name: a guess here is the hardcoding this block replaced. TEMPLATE_OWNER="" TEMPLATE_REPO="" if [ -f Justfile ]; then - TEMPLATE_REPO="$(sed -n -E 's/^REPO[[:space:]]*:=[[:space:]]*"([^"]+)".*/\1/p' Justfile | head -1)" - TEMPLATE_OWNER="$(sed -n -E 's/^OWNER[[:space:]]*:=[[:space:]]*"([^"]+)".*/\1/p' Justfile | head -1)" + TEMPLATE_REPO="$(sed -n -E "s/^(export[[:space:]]+)?REPO[[:space:]]*:=[[:space:]]*[\"']([^\"']+)[\"'].*/\2/p" Justfile | head -1)" + TEMPLATE_OWNER="$(sed -n -E "s/^(export[[:space:]]+)?OWNER[[:space:]]*:=[[:space:]]*[\"']([^\"']+)[\"'].*/\2/p" Justfile | head -1)" fi if [ -z "$TEMPLATE_REPO" ] || [ -z "$TEMPLATE_OWNER" ]; then - _origin="$(git config --get remote.origin.url 2>/dev/null || true)" - if [ -n "$_origin" ]; then - _origin="${_origin%.git}" - _origin="${_origin%/}" - [ -n "$TEMPLATE_REPO" ] || TEMPLATE_REPO="${_origin##*[/:]}" - _origin="${_origin%/*}" - [ -n "$TEMPLATE_OWNER" ] || TEMPLATE_OWNER="${_origin##*[/:]}" + if [[ "${RSR_TEMPLATE_SLUG:-}" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]]; then + TEMPLATE_OWNER="${RSR_TEMPLATE_SLUG%%/*}" + TEMPLATE_REPO="${RSR_TEMPLATE_SLUG#*/}" + else + echo "Error: the root Justfile does not declare OWNER := and REPO :=, so the" + echo " template this tree came from is unknown. Set RSR_TEMPLATE_SLUG=owner/repo." + exit 1 fi fi - TEMPLATE_REPO="${TEMPLATE_REPO:-rsr-template-repo}" - TEMPLATE_OWNER="${TEMPLATE_OWNER:-hyperpolymath}" TEMPLATE_SLUG="${TEMPLATE_OWNER}/${TEMPLATE_REPO}" + # --- Only a template can be minted (until chain minting lands) ------------ + # A minted repo has already filled its tokens and deleted build/templates, + # so minting it again renders none of the new answers and skips the + # identity splice: the grandchild silently inherited its parent's author, + # contacts, clade and maturity (Codex review of #251). The owner chose to + # support chains properly (2026-11-04); until a minted repo carries the + # pristine inputs that makes possible, stop here, before anything is + # asked or written. A variant template keeps build/templates and still mints. + # + # Chain minting (owner ruling 2026-11-04): a minted repo that recorded its + # (mint-answers) CAN be minted again. scripts/chain-mint.sh replays its + # mint, mints the same template commit with the new answers, and carries + # its own work across by a three-way merge. One minted before answers were + # recorded cannot be replayed, so it is still refused. + # + # Which it is, the deed's recorded mint state decides first: a template + # (or a variant template, which is maintained, never minted) records no + # (mint-answers). A minted repo that added build/templates/identity.deed.in + # back as its own work is still a minted repo (Codex review of #277). + CHAIN_MINT=0 + _prov_deed="$(bash scripts/deed-field.sh --find . 2>/dev/null || true)" + if [ -n "$_prov_deed" ] && bash scripts/deed-field.sh --has "$_prov_deed" mint-answers \ + && [ -f scripts/chain-mint.sh ]; then + CHAIN_MINT=1 + echo "This tree is a minted repo (${TEMPLATE_SLUG}): it will be minted as a chain," + echo "from its template, carrying its own work since its mint." + echo "" + elif [ ! -f build/templates/identity.deed.in ]; then + echo "Error: this tree has already been minted (build/templates/identity.deed.in is" + echo " absent) and records no (mint-answers), so its mint cannot be replayed" + echo " and it cannot be minted again. Mint from the template it came from." + if [ -n "$_prov_deed" ]; then + _prov_from="$(bash scripts/deed-field.sh "$_prov_deed" provenance template-repo 2>/dev/null || true)" + [ -z "$_prov_from" ] || echo " This tree records its template as ${_prov_from}." + fi + exit 1 + fi + # --- What the mint may edit --------------------------------------------- # mint_files prints, one per line, every regular file that belongs to the # repository: tracked, or untracked and not ignored (what `git add -A` @@ -105,9 +166,16 @@ repo-init archetype="": VERBATIM_LIST=".machine_readable/mint-verbatim.txt" [ -f "$VERBATIM_LIST" ] || { echo "Error: $VERBATIM_LIST is missing; the mint cannot tell machinery from content."; exit 1; } mapfile -t MINT_VERBATIM < <(sed -e 's/#.*//' -e 's/[[:space:]]*$//' "$VERBATIM_LIST" | grep -v '^$') - for _v in "${MINT_VERBATIM[@]}"; do - [ -f "$_v" ] || { echo "Error: $VERBATIM_LIST names $_v, which does not exist (stale entry)."; exit 1; } - done + # These two preflights judge a TEMPLATE tree. A chain mint's tree is a + # minted repo whose own work (a deleted verbatim-listed file, an added file + # with a brace token) is the parent's business; the root template is + # checked when chain-mint.sh replays it, and the merged result passes the + # placeholder gate (Codex review of #277). + if [ "$CHAIN_MINT" != "1" ]; then + for _v in "${MINT_VERBATIM[@]}"; do + [ -f "$_v" ] || { echo "Error: $VERBATIM_LIST names $_v, which does not exist (stale entry)."; exit 1; } + done + fi # ── Can the template be minted at all? ────────────────────────────────── # A brace token the recipe below has no answer for is a file that cannot be @@ -115,7 +183,7 @@ repo-init archetype="": # files in every Julia repo minted before 2026-10-30) or trips the # placeholder gate at the END of a mint that has already written the tree. # Both are cheaper to refuse here, before a single byte is written. - if [ -f scripts/check-mint-tokens.sh ]; then + if [ "$CHAIN_MINT" != "1" ] && [ -f scripts/check-mint-tokens.sh ]; then if ! bash scripts/check-mint-tokens.sh; then echo "ERROR: mint aborted before writing anything — the template ships a" echo " token this recipe cannot fill. See the findings above." @@ -141,7 +209,25 @@ repo-init archetype="": ARCHETYPE='{{archetype}}' ARCH_FILE="" ARCH_PRESET="" - if [ -n "$ARCHETYPE" ]; then + # A chain mint has no archetypes/ (the parent's mint removed it); it + # re-mints with the archetype the parent recorded, from the root template + # (scripts/chain-mint.sh). Naming that same archetype is accepted; any + # other is refused, since the parent's tree was built for its own. + # MINT_ARCH is the archetype the mint will actually apply: on a chain, the + # parent's, whether or not it was named (the slug rule below depends on it: + # a Julia package's .jl slug). + MINT_ARCH="$ARCHETYPE" + if [ "$CHAIN_MINT" = "1" ]; then + _p_arch="$(bash scripts/deed-field.sh "$_prov_deed" mint-answers archetype 2>/dev/null || true)" + if [ -n "$ARCHETYPE" ] && [ "$ARCHETYPE" != "$_p_arch" ]; then + echo "Error: this tree was minted as archetype '${_p_arch:-none}'; a chain mint keeps it." + echo " Run just repo-init${_p_arch:+ ${_p_arch}} (or mint from the template for another)." + exit 1 + fi + [ -z "$_p_arch" ] || { echo "Archetype: ${_p_arch} (the parent's, re-minted from the template)"; echo ""; } + MINT_ARCH="$_p_arch" + ARCHETYPE="" + elif [ -n "$ARCHETYPE" ]; then ARCH_FILE="archetypes/${ARCHETYPE}/${ARCHETYPE}_praxis.deed" if [ ! -f "$ARCH_FILE" ]; then echo "Error: unknown archetype '${ARCHETYPE}'. Available:" @@ -175,11 +261,11 @@ repo-init archetype="": # is the package's own identifier (UpperCamelCase by Julia # convention). The julia-library archetype accepts that form as well as # the estate slug; every other mint takes the estate slug only. - if [[ "$ARCHETYPE" = "julia-library" && "$REPO" =~ ^[A-Z][A-Za-z0-9]*\.jl$ ]]; then + if [[ "$MINT_ARCH" = "julia-library" && "$REPO" =~ ^[A-Z][A-Za-z0-9]*\.jl$ ]]; then : elif [[ ! "$REPO" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then echo "Error: repo slug must be lowercase alphanumeric words separated by single hyphens" - [ "$ARCHETYPE" = "julia-library" ] && echo " (or, for a Julia package, .jl with in UpperCamelCase)" + [ "$MINT_ARCH" = "julia-library" ] && echo " (or, for a Julia package, .jl with in UpperCamelCase)" exit 1 fi @@ -251,21 +337,28 @@ repo-init archetype="": ask "OpenSSF Best Practices project ID (blank if not yet registered) []: " OPENSSF_BP_ID # --- Container values (optional — only relevant if build/container/ exists) --- - # The default service name is the slug in lower case: an OCI image name - # may not contain capitals, and a Julia slug (`.jl`) has them. + # Derive the default for a DNS hostname and bare TOML service key, not + # only an OCI image name: Julia dots and slug underscores need hyphens. + # Explicit service answers still go through the validator unchanged. + DEFAULT_SERVICE_NAME="${REPO,,}" + DEFAULT_SERVICE_NAME="${DEFAULT_SERVICE_NAME//[._]/-}" if [ -d "build/container" ]; then echo "" echo "── Container configuration (optional) ─────────" - ask "Service name [${SERVICE_NAME:-${REPO,,}}]: " _SERVICE_NAME - SERVICE_NAME="${_SERVICE_NAME:-${SERVICE_NAME:-${REPO,,}}}" + ask "Service name [${SERVICE_NAME:-$DEFAULT_SERVICE_NAME}]: " _SERVICE_NAME + SERVICE_NAME="${_SERVICE_NAME:-${SERVICE_NAME:-$DEFAULT_SERVICE_NAME}}" ask "Primary port [${PORT:-8080}]: " _PORT PORT="${_PORT:-${PORT:-8080}}" - ask "Container registry [${REGISTRY:-ghcr.io/${OWNER}}]: " _REGISTRY - REGISTRY="${_REGISTRY:-${REGISTRY:-ghcr.io/${OWNER}}}" + ask "Container registry [${REGISTRY:-ghcr.io/${OWNER,,}}]: " _REGISTRY + REGISTRY="${_REGISTRY:-${REGISTRY:-ghcr.io/${OWNER,,}}}" + # The values fill double-quoted KYAML scalars: refuse a value that + # would break them now, before anything is written. + bash scripts/format-container-kyaml.sh --check-values "$SERVICE_NAME" "$PORT" "$REGISTRY" \ + || { echo "ERROR: mint aborted before writing anything — see above."; exit 1; } else - SERVICE_NAME="${SERVICE_NAME:-${REPO,,}}" + SERVICE_NAME="${SERVICE_NAME:-$DEFAULT_SERVICE_NAME}" PORT="${PORT:-8080}" - REGISTRY="${REGISTRY:-ghcr.io/${OWNER}}" + REGISTRY="${REGISTRY:-ghcr.io/${OWNER,,}}" fi # --- Derived values --- @@ -274,8 +367,26 @@ repo-init archetype="": # hyphens of a slug and the dot of a Julia slug alike. PROJECT_UPPER=$(echo "$REPO" | tr '[:lower:]-.' '[:upper:]__') PROJECT_LOWER=$(echo "$REPO" | tr '[:upper:]-.' '[:lower:]__') - CURRENT_YEAR=$(date +%Y) - CURRENT_DATE=$(date +%Y-%m-%d) + # The mint date is an input like any answer: RSR_MINT_DATE replays a mint + # exactly (scripts/check-mint-drift.sh re-mints a child with the date it + # recorded). Otherwise it is today. The year always follows the date. + CURRENT_DATE="${RSR_MINT_DATE:-$(date +%Y-%m-%d)}" + # Validated in bash, not with `date -d`: that is GNU-only, and macOS's date + # rejected every date, today's included (Codex review of #275). + _dm=0 + if [[ "$CURRENT_DATE" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})$ ]]; then + _yy=$((10#${BASH_REMATCH[1]})); _mm=$((10#${BASH_REMATCH[2]})); _dd=$((10#${BASH_REMATCH[3]})) + case "$_mm" in + 1|3|5|7|8|10|12) _dm=31 ;; + 4|6|9|11) _dm=30 ;; + 2) if (( (_yy % 4 == 0 && _yy % 100 != 0) || _yy % 400 == 0 )); then _dm=29; else _dm=28; fi ;; + esac + fi + if [ "$_dm" -eq 0 ] || [ "${_dd:-0}" -lt 1 ] || [ "${_dd:-0}" -gt "$_dm" ]; then + echo "Error: RSR_MINT_DATE must be a real date, YYYY-MM-DD (got '${CURRENT_DATE}')." + exit 1 + fi + CURRENT_YEAR="${CURRENT_DATE:0:4}" VERSION="0.1.0" # --- Derive the repo's uuid -------------------------------------------- @@ -316,6 +427,65 @@ repo-init archetype="": ask "Proceed? [Y/n] " CONFIRM [[ "${CONFIRM:-Y}" =~ ^[Nn] ]] && echo "Aborted." && exit 0 + # An answer is one line: no sed expression, deed string or prompt can carry + # a newline. Refused HERE, before the first write (the overlay, the prune + # and the docs seed all come next), so a bad answer leaves a clean tree. + for _ans in PROJECT_NAME PROJECT_DESCRIPTION OWNER AUTHOR AUTHOR_EMAIL AUTHOR_ORG \ + AUTHOR_LAST AUTHOR_FIRST FORGE SECURITY_EMAIL CONDUCT_EMAIL PROJECT_TYPE \ + WEBSITE SERVICE_NAME PORT REGISTRY AUTHOR_EMAIL_ALT AUTHOR_ORCID OPENSSF_BP_ID; do + case "${!_ans:-}" in + *$'\n'*) echo "Error: the answer for $_ans contains a newline; answers are one line."; exit 1 ;; + esac + done + + # Answers are free text, and the right-hand side of s|…|…| is not: `&` + # inserts the matched token, `\` escapes, `|` ends the expression. A + # description "Tools & proofs" rendered as "Tools proofs" and + # then failed the placeholder gate; a `|` broke the substitution. Every + # answer goes through sed_rhs. (A newline was refused before the first write.) + # Defined here, ahead of the rules below that use it. + sed_rhs() { printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g'; } + + # The self-name rule, defined once: used by the self-name pass below and + # handed to scripts/chain-mint.sh, so a chain mint renames the parent by + # exactly the same rule (passed as data, never re-read from the recipe). + SELF_PROV='instantiated-from|parent|upstream|chain =|lineage|minted from|created from|Template: |reject-if-contains' + # The template's name and slug are LITERAL text, not patterns: a Julia + # mint's parent may be .jl, and `.` in a sed pattern matches any + # character. Escaped inline so self_name_pass_test.sh can evaluate this + # one assignment exactly as the recipe defines it. + # The name is matched only where it stands as a whole name: not inside + # a longer word or slug (Codex review of #251). Unbounded, a parent + # named `app` turned "application mapping" into "newlication mnewing". + # A neighbouring letter or digit blocks the match; anything else does + # not, so `_` (sonar.projectKey), `_chora.deed` and + # derived names such as `-checkout` or `rm-` still follow. Each pass + # loops (:label … t label) because a match consumes its right-hand + # neighbour, and marks through a sentinel so a new name that contains + # the old one (Geometry -> Geometry.jl) cannot loop forever. + SELF_NAME_SED="/${SELF_PROV}/!{:i;s@(^|[^A-Za-z0-9])${TEMPLATE_REPO//./\\.}#([0-9])@\\1\\x01I\\x01\\2@;ti;:s;s@(^|[^A-Za-z0-9])${TEMPLATE_SLUG//./\\.}([^A-Za-z0-9]|$)@\\1\\x01S\\x01\\2@;ts;:n;s@(^|[^A-Za-z0-9])${TEMPLATE_REPO//./\\.}([^A-Za-z0-9]|$)@\\1\\x01N\\x01\\2@;tn;s@\\x01S\\x01@${OWNER}/${REPO}@g;s@\\x01N\\x01@${REPO}@g;s@\\x01I\\x01@${TEMPLATE_REPO}#@g;}" + # The self-owner rule, defined once likewise (see the self-OWNER pass for + # why it is three exact identity lines and never a blanket rewrite): the + # Justfile OWNER declaration in each form the identity parser accepts, and + # the SonarCloud organization and project key. chain-mint.sh applies it to + # the parent's carried files too (Codex review of #277). + SELF_OWNER_SED="s|^((export[[:space:]]+)?OWNER[[:space:]]*:=[[:space:]]*)([\"'])${TEMPLATE_OWNER//./\\.}([\"'])|\\1\\3$(sed_rhs "${OWNER}")\\4|;s|^sonar\\.organization=${TEMPLATE_OWNER//./\\.}\$|sonar.organization=$(sed_rhs "${OWNER}")|;s|^sonar\\.projectKey=${TEMPLATE_OWNER//./\\.}_|sonar.projectKey=$(sed_rhs "${OWNER}")_|" + + # Chain mint: everything below renders a TEMPLATE tree, which this is not. + # chain-mint.sh renders the template afresh with these answers and carries + # this tree's own work across; it writes nothing unless it succeeds. + if [ "$CHAIN_MINT" = "1" ]; then + export PROJECT_NAME REPO OWNER AUTHOR AUTHOR_EMAIL AUTHOR_ORG AUTHOR_EMAIL_ALT \ + AUTHOR_ORCID PROJECT_DESCRIPTION FORGE SECURITY_EMAIL CONDUCT_EMAIL \ + PROJECT_TYPE WEBSITE OPENSSF_BP_ID SERVICE_NAME PORT REGISTRY \ + TEMPLATE_REPO TEMPLATE_OWNER + export RSR_MINT_DATE="$CURRENT_DATE" SELF_NAME_SED SELF_OWNER_SED + bash scripts/chain-mint.sh + echo "" + echo "Done! Chain mint complete. Review with git status, then commit." + exit 0 + fi + # Template-only paths this recipe deletes, as they stand before it runs. # Recorded in the (variant) clause's (paths :removed …) below, so the # record says what the mint took away instead of leaving the drift gate to @@ -355,6 +525,254 @@ repo-init archetype="": exit 1 fi + # --- What this work tree holds beyond the pinned tree ------------------- + # The mint transforms the work tree as it stands; the drift gate replays + # from the pinned tree alone. Anything already changed here before the + # mint (an edit, an untracked file, a deletion) is this repo's own, not + # the mint's, so it is measured now and declared in (variant (paths)): + # the replay stays exact and the record says what happened (Codex review + # of #275). Measured in a scratch index and object store, so .git is not + # written. (A chain mint carries its parent's tree by its own rules.) + PREMINT_A=() PREMINT_M=() PREMINT_D=() + if [ "${CHAIN_MINT:-0}" != "1" ] && [ "$PARENT_TREE" != "UNASSIGNED" ]; then + # The work tree as `git add -A` would commit it, from the current index + # (so a tracked file an ignore rule matches stays), in a scratch index + # and object store. + _pm="$(mktemp -d)"; mkdir -p "$_pm/objects" + _gd="$(cd "$(git rev-parse --git-common-dir)" && pwd)" + _ix="$(git rev-parse --git-path index)" + [ ! -f "$_ix" ] || cp "$_ix" "$_pm/index" + _wt() { GIT_INDEX_FILE="$_pm/index" GIT_OBJECT_DIRECTORY="$_pm/objects" GIT_ALTERNATE_OBJECT_DIRECTORIES="$_gd/objects" git "$@"; } + _wt add -A . + # A gitlink (submodule) cannot be replayed (checkout-index writes no files + # for one), so the drift gate could never pass: refused by name here, + # whether or not anything else changed (Codex review of #275). + _gl="$(_wt ls-files -s | awk -F '\t' '$1 ~ /^160000 / { print " " $2 }')" + if [ -n "$_gl" ]; then + echo "ERROR: mint aborted before writing anything — this tree carries a submodule" + echo " (gitlink), which a replay of the mint cannot reproduce, so its drift gate" + echo " could never pass. Minting such a tree is not supported:" + printf '%s\n' "$_gl" + rm -rf "$_pm"; exit 1 + fi + if git cat-file -e "${PARENT_TREE}^{tree}" 2>/dev/null; then + while IFS= read -r -d '' _st && IFS= read -r -d '' _p; do + # A newline splits the deed's line-based lists; byte 0x01 is the deed + # reader's record separator (Codex review of #277). + case "$_p" in *$'\n'*|*$'\001'*) echo "Error: a path holding a newline or byte 0x01 cannot be declared: $(printf '%q' "$_p")"; rm -rf "$_pm"; exit 1 ;; esac + case "$_st" in + A) PREMINT_A+=("$_p") ;; + D) PREMINT_D+=("$_p") ;; + *) PREMINT_M+=("$_p") ;; + esac + done < <(_wt diff --cached -z --no-renames --name-status "$PARENT_TREE") + elif [ "$(_wt write-tree)" != "$PARENT_TREE" ]; then + # An asserted pin (RSR_TEMPLATE_COMMIT) whose tree this repository + # does not hold: the difference cannot even be listed, so it cannot + # be declared, and the replay would report it (Codex review of #275). + rm -rf "$_pm" + echo "ERROR: mint aborted before writing anything — this work tree differs from the" + echo " template tree ${PARENT_TREE:0:12}, which this repository does not hold, so" + echo " the difference cannot be declared. Fetch it first" + echo " (git fetch ${PARENT_PIN}) or mint from a matching tree." + exit 1 + fi + # The work tree's own tree: what a dry run of this mint starts from + # (below). Its objects stay in $_pm until the replays are done. + _WT_TREE="$(_wt write-tree)" + # The mint moves a root .well-known/ (the www/ migration, below), so a + # path there could not be declared where it ends up: refused by name. + for _p in "${PREMINT_A[@]}" "${PREMINT_M[@]}" "${PREMINT_D[@]}"; do + case "$_p" in + .well-known/*) + echo "ERROR: mint aborted before writing anything — $_p changed before the mint," + echo " and the mint moves .well-known/ into www/.well-known/. Commit that move" + echo " first (bash scripts/migrate-wellknown-to-www.sh), or revert the change." + rm -rf "$_pm"; exit 1 ;; + esac + done + # The mint writes new files of its own: the archetype overlay (before + # and after its .in rename), the docs seed, the deed. A pre-mint file + # at one of those paths would be overwritten (lost) and then declared + # as this repo's own addition, which the replay contradicts (Codex + # review of #275). Refused by name, before anything is written. + _writes=("${REPO}_chora.deed") + if [ -n "$ARCHETYPE" ] && [ -d "archetypes/${ARCHETYPE}/overlay" ]; then + while IFS= read -r -d '' _o; do + _o="${_o#./}"; _writes+=("$_o") + case "$_o" in + src/PACKAGE.jl.in) _writes+=("src/${PROJECT_NAME}.jl") ;; + *.in) _writes+=("${_o%.in}") ;; + esac + done < <(cd "archetypes/${ARCHETYPE}/overlay" && find . -type f -print0) + fi + if [ -d build/docs-seed ]; then + while IFS= read -r -d '' _o; do _writes+=("docs/${_o#./}"); done \ + < <(cd build/docs-seed && find . -type f ! -path ./README.adoc -print0) + fi + # An edited template file the overlay or seed replaces is lost the same way + # (Codex review of #275: provisioning_praxis.deed under julia-library). + for _p in "${PREMINT_A[@]}" "${PREMINT_M[@]}"; do + for _w in "${_writes[@]}"; do + if [ "$_p" = "$_w" ]; then + echo "ERROR: mint aborted before writing anything — $_p changed before the mint, and" + echo " the mint writes that path itself (the archetype overlay, the docs seed or" + echo " the deed), so your file would be overwritten. Move it aside, mint, then" + echo " bring it back as a change of your own." + rm -rf "$_pm"; exit 1 + fi + done + done + _n=$(( ${#PREMINT_A[@]} + ${#PREMINT_M[@]} + ${#PREMINT_D[@]} )) + if [ "$_n" -gt 0 ]; then + echo "NOTE: ${_n} path(s) here already differ from the template tree ${PARENT_TREE:0:12}" + echo " (${#PREMINT_A[@]} added, ${#PREMINT_M[@]} changed, ${#PREMINT_D[@]} removed before this mint). They are" + echo " this repo's own. What they change in the result is declared in" + echo " (variant (paths)), measured against a replay of this mint:" + for _p in "${PREMINT_A[@]}" "${PREMINT_M[@]}" "${PREMINT_D[@]}"; do echo " $_p"; done + fi + fi + + # --- The replay this mint is measured against, when the tree has changed --- + # A pre-mint change can reach the result in ways no rule over its own path + # predicts: the mint moves, overwrites and renders files, and reads its own + # inputs (the overlay, the docs seed, its scripts) from this tree (Codex and + # CodeRabbit review of #275). So this mint is replayed, here, before anything + # is written, from the pinned tree with the same answers and date, exactly + # as the drift gate will replay it; what this tree then differs by is + # declared below. The pinned tree is in this repository (checked above). + # The inner mint skips pin resolution (RSR_ALLOW_UNPINNED with no template + # source): it writes the pin only into its deed, which is not compared. + # + # And a dry run: the same mint of THIS tree, pre-mint changes and all. A + # change at a path where the dry run and the replay agree has no effect on + # the result: the mint writes that path itself (the roadmap, mise.toml, + # the map, the overlay, the docs seed, the deed) and the change + # would be silently discarded. It is refused by name, before anything is + # written (Codex review of #275), whatever writes it. + # _same A B — identical as git records them: content, executable bit, link target. + _same() { + if [ -L "$1" ] || [ -L "$2" ]; then + [ -L "$1" ] && [ -L "$2" ] && [ "$(readlink "$1")" = "$(readlink "$2")" ] + return + fi + { [ -x "$1" ] && [ -x "$2" ]; } || { [ ! -x "$1" ] && [ ! -x "$2" ]; } || return 1 + cmp -s "$1" "$2" + } + _REPLAY="" + if [ $(( ${#PREMINT_A[@]} + ${#PREMINT_M[@]} + ${#PREMINT_D[@]} )) -gt 0 ]; then + # _rg — git with no user or system configuration and no hooks, and no + # inherited repository location (GIT_DIR, GIT_INDEX_FILE, … from a hook or + # another git caller), which would redirect the replay's commits into + # this repository (CodeRabbit review of #275). _rgi IDX — the same, on + # the scratch index IDX and object store $_pm (the work tree's objects). + _rgclean=(env -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY + -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_PREFIX -u GIT_NAMESPACE GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1) + _rgflags=(-c user.name=replay -c user.email=replay@example.invalid -c commit.gpgsign=false -c core.hooksPath=/dev/null) + _rg() { "${_rgclean[@]}" git "${_rgflags[@]}" "$@"; } + _rgi() { local _i="$1"; shift + "${_rgclean[@]}" GIT_INDEX_FILE="$_i" GIT_OBJECT_DIRECTORY="$_pm/objects" GIT_ALTERNATE_OBJECT_DIRECTORIES="$_gd/objects" \ + git "${_rgflags[@]}" "$@"; } + _rp="$(mktemp -d)"; _REPLAY="$_rp/r" + mkdir -p "$_rp/cfg"; : > "$_rp/gitconfig" + # _mint_tree TREE DIR — TREE written into DIR as a checkout writes it + # (checkout-index, not git archive, whose export-ignore and export-subst + # attributes would change the tree (Codex review of #275)), committed as + # "Use this template" commits it, then minted with this mint's answers. + _mint_tree() { + mkdir -p "$2" + _rgi "$_rp/index" read-tree "$1" + # A gitlink has no files to write, so the copy would lose it: refused + # by name rather than replayed inexactly (Codex review of #277). + _gl="$(_rgi "$_rp/index" ls-files -s | awk -F '\t' '$1 ~ /^160000 / { print " " $2 }')" + if [ -n "$_gl" ]; then + echo "ERROR: the tree carries a submodule (gitlink), which the replay of this mint" + echo " cannot reproduce; minting such a tree is not supported:" + printf '%s\n' "$_gl" + rm -f "$_rp/index"; return 1 + fi + _rgi "$_rp/index" checkout-index -a -f --prefix="$2/" + rm -f "$_rp/index" + _rg -C "$2" init -q -b main; _rg -C "$2" add -A; _rg -C "$2" commit -q -m "Initial commit" + _rg -C "$2" remote add origin "git@${FORGE}:${OWNER}/${REPO}.git" + (cd "$2" && env -i PATH="$PATH" HOME="$HOME" TMPDIR="${TMPDIR:-/tmp}" \ + ${RUSTUP_HOME:+RUSTUP_HOME="$RUSTUP_HOME"} ${CARGO_HOME:+CARGO_HOME="$CARGO_HOME"} \ + ${RSR_MINT_TOOL_CACHE:+RSR_MINT_TOOL_CACHE="$RSR_MINT_TOOL_CACHE"} \ + ${RSR_TEMPLATE_SLUG:+RSR_TEMPLATE_SLUG="$RSR_TEMPLATE_SLUG"} \ + ${STANDARDS_UUID_V8_JS:+STANDARDS_UUID_V8_JS="$STANDARDS_UUID_V8_JS"} \ + XDG_CONFIG_HOME="$_rp/cfg" GIT_CONFIG_GLOBAL="$_rp/gitconfig" GIT_CONFIG_NOSYSTEM=1 \ + RSR_NON_INTERACTIVE=1 RSR_MINT_REPLAY=1 RSR_ALLOW_UNPINNED=1 \ + RSR_TEMPLATE_URL="$_rp/no-template-source" RSR_MINT_DATE="$CURRENT_DATE" \ + PROJECT_NAME="$PROJECT_NAME" REPO="$REPO" OWNER="$OWNER" AUTHOR="$AUTHOR" \ + AUTHOR_EMAIL="$AUTHOR_EMAIL" AUTHOR_ORG="${AUTHOR_ORG:-}" AUTHOR_EMAIL_ALT="${AUTHOR_EMAIL_ALT:-}" \ + AUTHOR_ORCID="${AUTHOR_ORCID:-}" PROJECT_DESCRIPTION="${PROJECT_DESCRIPTION:-}" FORGE="$FORGE" \ + SECURITY_EMAIL="${SECURITY_EMAIL:-}" CONDUCT_EMAIL="${CONDUCT_EMAIL:-}" \ + PROJECT_TYPE="${PROJECT_TYPE:-}" WEBSITE="${WEBSITE:-}" OPENSSF_BP_ID="${OPENSSF_BP_ID:-}" \ + SERVICE_NAME="${SERVICE_NAME:-}" PORT="${PORT:-}" REGISTRY="${REGISTRY:-}" \ + just repo-init ${ARCHETYPE:+"$ARCHETYPE"} "$2.log" 2>&1 \ + || { tail -n 30 "$2.log"; return 1; } + } + echo " replaying this mint from the template tree, to measure the pre-mint changes..." + if ! _mint_tree "$PARENT_TREE" "$_REPLAY"; then + echo "ERROR: mint aborted before writing anything — the replay of this mint, needed to" + echo " measure the pre-mint changes above, failed (its log is above)." + rm -rf "$_pm"; exit 1 + fi + echo " and a dry run of it on this tree, to find a change the mint would discard..." + if ! _mint_tree "$_WT_TREE" "$_rp/dry"; then + echo "ERROR: mint aborted before writing anything — a dry run of this mint on this" + echo " tree failed (its log is above): a pre-mint change breaks the mint." + rm -rf "$_pm"; exit 1 + fi + _lost=() + _here() { [ -e "$1" ] || [ -L "$1" ]; } + # _result_tree DIR — the mint result in DIR, as the tree git would commit. + _result_tree() { _rg -C "$1" add -A >/dev/null && _rg -C "$1" write-tree; } + _DRY_T="" + _n=0 + for _p in "${PREMINT_A[@]}" "${PREMINT_M[@]}" "${PREMINT_D[@]}"; do + if _here "$_rp/dry/$_p" && _here "$_REPLAY/$_p"; then + if _same "$_rp/dry/$_p" "$_REPLAY/$_p"; then _lost+=("$_p"); fi + elif ! _here "$_rp/dry/$_p" && ! _here "$_REPLAY/$_p" \ + && { _wt ls-files --error-unmatch -- ":(literal)$_p" >/dev/null 2>&1; }; then + # Added or changed here, and gone from both results: an input + # the mint reads and deletes (the docs seed, the overlay), or a + # file in a directory it deletes. Its change had an effect only if + # the result differs without it: this mint, minus that one change, + # is minted again and compared whole (Codex review of #275: an + # edit to build/docs-seed/README.adoc, which is never copied, + # was destroyed without a trace). + [ -n "$_DRY_T" ] || _DRY_T="$(_result_tree "$_rp/dry")" + _n=$((_n + 1)) + _rgi "$_rp/index" read-tree "$_WT_TREE" + if _rgi "$_rp/index" cat-file -e "${PARENT_TREE}:$_p" 2>/dev/null; then + read -r _m _ _s _ <<<"$(_rgi "$_rp/index" ls-tree "$PARENT_TREE" -- "$_p")" + _rgi "$_rp/index" update-index --add --cacheinfo "$_m,$_s,$_p" + else + _rgi "$_rp/index" update-index --force-remove -- "$_p" + fi + _rev_t="$(_rgi "$_rp/index" write-tree)"; rm -f "$_rp/index" + echo " checking that the pre-mint change to $_p has an effect..." + if ! _mint_tree "$_rev_t" "$_rp/without.$_n"; then + echo "ERROR: mint aborted before writing anything — a dry run without the change" + echo " to $_p failed (its log is above)." + rm -rf "$_pm"; exit 1 + fi + if [ "$(_result_tree "$_rp/without.$_n")" = "$_DRY_T" ]; then _lost+=("$_p"); fi + fi + done + rm -rf "$_pm" + if [ "${#_lost[@]}" -gt 0 ]; then + echo "ERROR: mint aborted before writing anything — these paths changed before the mint," + echo " and the mint writes or deletes each itself, so the change would be discarded:" + for _p in "${_lost[@]}"; do echo " $_p"; done + echo " Revert them (or move them aside), mint, then make the change again." + exit 1 + fi + elif [ -n "${_pm:-}" ]; then + rm -rf "$_pm" + fi + echo "" echo "Replacing placeholders..." @@ -425,48 +843,55 @@ repo-init archetype="": LB=$(printf '\x7b\x7b') RB=$(printf '\x7d\x7d') + # Answers are free text, and the right-hand side of s|…|…| is not: `&` + # inserts the matched token, `\` escapes, `|` ends the expression. A + # description "Tools & proofs" rendered as "Tools proofs" and + # then failed the placeholder gate; a `|` broke the substitution. Every + # answer goes through sed_rhs. (A newline was refused before the first write.) + sed_rhs() { printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g'; } + # Build the sed expression list # Note: using | as delimiter since URLs contain / SED_ARGS=( - -e "s|${LB}PROJECT_NAME${RB}|${PROJECT_NAME}|g" - -e "s|${LB}PROJECT_DESCRIPTION${RB}|${PROJECT_DESCRIPTION}|g" - -e "s|${LB}PROJECT${RB}|${PROJECT_UPPER}|g" - -e "s|${LB}project${RB}|${PROJECT_LOWER}|g" - -e "s|${LB}REPO${RB}|${REPO}|g" - -e "s|${LB}OWNER${RB}|${OWNER}|g" - -e "s|${LB}AUTHOR${RB}|${AUTHOR}|g" - -e "s|${LB}AUTHOR_EMAIL${RB}|${AUTHOR_EMAIL}|g" - -e "s|${LB}AUTHOR_ORG${RB}|${AUTHOR_ORG}|g" - -e "s|${LB}AUTHOR_LAST${RB}|${AUTHOR_LAST}|g" - -e "s|${LB}AUTHOR_FIRST${RB}|${AUTHOR_FIRST}|g" - -e "s|${LB}AUTHOR_INITIALS${RB}|${FIRST_INITIAL}|g" - -e "s|${LB}FORGE${RB}|${FORGE}|g" - -e "s|${LB}CURRENT_YEAR${RB}|${CURRENT_YEAR}|g" - -e "s|${LB}CURRENT_DATE${RB}|${CURRENT_DATE}|g" - -e "s|${LB}DATE${RB}|${CURRENT_DATE}|g" - -e "s|${LB}SECURITY_EMAIL${RB}|${SECURITY_EMAIL}|g" - -e "s|${LB}CONDUCT_EMAIL${RB}|${CONDUCT_EMAIL}|g" + -e "s|${LB}PROJECT_NAME${RB}|$(sed_rhs "${PROJECT_NAME}")|g" + -e "s|${LB}PROJECT_DESCRIPTION${RB}|$(sed_rhs "${PROJECT_DESCRIPTION}")|g" + -e "s|${LB}PROJECT${RB}|$(sed_rhs "${PROJECT_UPPER}")|g" + -e "s|${LB}project${RB}|$(sed_rhs "${PROJECT_LOWER}")|g" + -e "s|${LB}REPO${RB}|$(sed_rhs "${REPO}")|g" + -e "s|${LB}OWNER${RB}|$(sed_rhs "${OWNER}")|g" + -e "s|${LB}AUTHOR${RB}|$(sed_rhs "${AUTHOR}")|g" + -e "s|${LB}AUTHOR_EMAIL${RB}|$(sed_rhs "${AUTHOR_EMAIL}")|g" + -e "s|${LB}AUTHOR_ORG${RB}|$(sed_rhs "${AUTHOR_ORG}")|g" + -e "s|${LB}AUTHOR_LAST${RB}|$(sed_rhs "${AUTHOR_LAST}")|g" + -e "s|${LB}AUTHOR_FIRST${RB}|$(sed_rhs "${AUTHOR_FIRST}")|g" + -e "s|${LB}AUTHOR_INITIALS${RB}|$(sed_rhs "${FIRST_INITIAL}")|g" + -e "s|${LB}FORGE${RB}|$(sed_rhs "${FORGE}")|g" + -e "s|${LB}CURRENT_YEAR${RB}|$(sed_rhs "${CURRENT_YEAR}")|g" + -e "s|${LB}CURRENT_DATE${RB}|$(sed_rhs "${CURRENT_DATE}")|g" + -e "s|${LB}DATE${RB}|$(sed_rhs "${CURRENT_DATE}")|g" + -e "s|${LB}SECURITY_EMAIL${RB}|$(sed_rhs "${SECURITY_EMAIL}")|g" + -e "s|${LB}CONDUCT_EMAIL${RB}|$(sed_rhs "${CONDUCT_EMAIL}")|g" -e "s|${LB}LICENSE${RB}|MPL-2.0|g" -e "s|${LB}CONDUCT_TEAM${RB}|Code of Conduct Committee|g" -e "s|${LB}RESPONSE_TIME${RB}|48 hours|g" -e "s|${LB}MAIN_BRANCH${RB}|main|g" - -e "s|${LB}PROJECT_PURPOSE${RB}|${PROJECT_DESCRIPTION}|g" - -e "s|${LB}PROJECT_ROLE${RB}|${PROJECT_TYPE}|g" - -e "s|${LB}PROJECT_TYPE${RB}|${PROJECT_TYPE}|g" - -e "s|${LB}WEBSITE${RB}|${WEBSITE}|g" - -e "s|${LB}SERVICE_NAME${RB}|${SERVICE_NAME}|g" - -e "s|${LB}PORT${RB}|${PORT}|g" - -e "s|${LB}REGISTRY${RB}|${REGISTRY}|g" - -e "s|${LB}IMAGE${RB}|${REGISTRY}/${SERVICE_NAME}|g" - -e "s|${LB}VERSION${RB}|${VERSION}|g" - -e "s|${LB}EMAIL${RB}|${AUTHOR_EMAIL}|g" - -e "s|${LB}UUID${RB}|${REPO_UUID}|g" - -e "s|${LB}DESCRIPTION${RB}|${PROJECT_DESCRIPTION}|g" + -e "s|${LB}PROJECT_PURPOSE${RB}|$(sed_rhs "${PROJECT_DESCRIPTION}")|g" + -e "s|${LB}PROJECT_ROLE${RB}|$(sed_rhs "${PROJECT_TYPE}")|g" + -e "s|${LB}PROJECT_TYPE${RB}|$(sed_rhs "${PROJECT_TYPE}")|g" + -e "s|${LB}WEBSITE${RB}|$(sed_rhs "${WEBSITE}")|g" + -e "s|${LB}SERVICE_NAME${RB}|$(sed_rhs "${SERVICE_NAME}")|g" + -e "s|${LB}PORT${RB}|$(sed_rhs "${PORT}")|g" + -e "s|${LB}REGISTRY${RB}|$(sed_rhs "${REGISTRY}")|g" + -e "s|${LB}IMAGE${RB}|$(sed_rhs "${REGISTRY}")/$(sed_rhs "${SERVICE_NAME}")|g" + -e "s|${LB}VERSION${RB}|$(sed_rhs "${VERSION}")|g" + -e "s|${LB}EMAIL${RB}|$(sed_rhs "${AUTHOR_EMAIL}")|g" + -e "s|${LB}UUID${RB}|$(sed_rhs "${REPO_UUID}")|g" + -e "s|${LB}DESCRIPTION${RB}|$(sed_rhs "${PROJECT_DESCRIPTION}")|g" ) - [ -n "$AUTHOR_EMAIL_ALT" ] && SED_ARGS+=(-e "s|${LB}AUTHOR_EMAIL_ALT${RB}|${AUTHOR_EMAIL_ALT}|g") - [ -n "$AUTHOR_ORCID" ] && SED_ARGS+=(-e "s|${LB}AUTHOR_ORCID${RB}|${AUTHOR_ORCID}|g") - [ -n "$OPENSSF_BP_ID" ] && SED_ARGS+=(-e "s|${LB}OPENSSF_BP_ID${RB}|${OPENSSF_BP_ID}|g") - [ -n "$OPENSSF_BP_ID" ] && SED_ARGS+=(-e "s|${LB}OPENSSF_PROJECT_ID${RB}|${OPENSSF_BP_ID}|g") + [ -n "$AUTHOR_EMAIL_ALT" ] && SED_ARGS+=(-e "s|${LB}AUTHOR_EMAIL_ALT${RB}|$(sed_rhs "${AUTHOR_EMAIL_ALT}")|g") + [ -n "$AUTHOR_ORCID" ] && SED_ARGS+=(-e "s|${LB}AUTHOR_ORCID${RB}|$(sed_rhs "${AUTHOR_ORCID}")|g") + [ -n "$OPENSSF_BP_ID" ] && SED_ARGS+=(-e "s|${LB}OPENSSF_BP_ID${RB}|$(sed_rhs "${OPENSSF_BP_ID}")|g") + [ -n "$OPENSSF_BP_ID" ] && SED_ARGS+=(-e "s|${LB}OPENSSF_PROJECT_ID${RB}|$(sed_rhs "${OPENSSF_BP_ID}")|g") # Archetype-determined tokens (ADR-0003): the archetype contract fills # what it can honestly determine. Added BEFORE the UNASSIGNED fallback @@ -517,7 +942,14 @@ repo-init archetype="": # GitHub owner logins are case-insensitive; canonicalize to lowercase # before the preimage so owner casing cannot fork the UUID. OWNER_LC=$(printf '%s' "${OWNER}" | tr '[:upper:]' '[:lower:]') - if command -v bun >/dev/null 2>&1 && [ -f "${STANDARDS_UUID_V8_JS:-}" ]; then + # A replay supplies the uuid the mint recorded ((mint-answers + # :package-uuid)), so the drift gate does not depend on which of bun or + # python3 the checking host has (Codex review of #275). Only a replay: + # an ordinary mint always derives it, so a stray RSR_PACKAGE_UUID in the + # environment cannot write an unrelated identity into Project.toml. + if [ "${RSR_MINT_REPLAY:-0}" = "1" ] && [[ "${RSR_PACKAGE_UUID:-}" =~ ^(UNASSIGNED|[0-9a-f]{8}-[0-9a-f]{4}-8[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$ ]]; then + PACKAGE_UUID="$RSR_PACKAGE_UUID" + elif command -v bun >/dev/null 2>&1 && [ -f "${STANDARDS_UUID_V8_JS:-}" ]; then PACKAGE_UUID=$(bun "${STANDARDS_UUID_V8_JS}" c julia-package-identity "github.com/${OWNER_LC}/${REPO}") elif command -v python3 >/dev/null 2>&1; then PACKAGE_UUID=$(python3 -c ' @@ -600,6 +1032,11 @@ repo-init archetype="": fi done + # The pass above fills the container tokens too (SERVICE_NAME, PORT, + # REGISTRY), which changes how KYAML quotes them; reformat the configs or + # Estate Rules' "Converted YAML stays KYAML" fails the child. + bash scripts/format-container-kyaml.sh + # [YOUR-REPO-NAME] and [YOUR-NAME/ORG] (the family-7 allocation manifest # folded into the deed — standards#837 pilot) are replaced in the deed, # found by its shape rather than by the original template's file name: in @@ -607,7 +1044,7 @@ repo-init archetype="": # two seds that named rsr-template-repo_chora.deed silently no-opped there. DEED_IN="$(bash scripts/deed-field.sh --find .)" \ || { echo "Error: the tree must carry exactly one *_chora.deed at its root."; exit 1; } - sed -i -e "s|\[YOUR-REPO-NAME\]|${PROJECT_NAME}|g" -e "s|\[YOUR-NAME/ORG\]|${OWNER}|g" "$DEED_IN" + sed -i -e "s|\[YOUR-REPO-NAME\]|$(sed_rhs "${PROJECT_NAME}")|g" -e "s|\[YOUR-NAME/ORG\]|$(sed_rhs "${OWNER}")|g" "$DEED_IN" # --- Instruction-block pass -------------------------------------------- # Delete the "TEMPLATE INSTRUCTIONS (delete this block before publishing)" @@ -682,7 +1119,16 @@ repo-init archetype="": # mint's parent may be .jl, and `.` in a sed pattern matches any # character. Escaped inline so self_name_pass_test.sh can evaluate this # one assignment exactly as the recipe defines it. - SELF_NAME_SED="/${SELF_PROV}/!{s|${TEMPLATE_REPO//./\\.}#([0-9])|__TEMPLATE_ISSUE__\\1|g;s|${TEMPLATE_SLUG//./\\.}|${OWNER}/${REPO}|g;s|${TEMPLATE_REPO//./\\.}|${REPO}|g;s|__TEMPLATE_ISSUE__|${TEMPLATE_REPO}#|g;}" + # The name is matched only where it stands as a whole name: not inside + # a longer word or slug (Codex review of #251). Unbounded, a parent + # named `app` turned "application mapping" into "newlication mnewing". + # A neighbouring letter or digit blocks the match; anything else does + # not, so `_` (sonar.projectKey), `_chora.deed` and + # derived names such as `-checkout` or `rm-` still follow. Each pass + # loops (:label … t label) because a match consumes its right-hand + # neighbour, and marks through a sentinel so a new name that contains + # the old one (Geometry -> Geometry.jl) cannot loop forever. + SELF_NAME_SED="/${SELF_PROV}/!{:i;s@(^|[^A-Za-z0-9])${TEMPLATE_REPO//./\\.}#([0-9])@\\1\\x01I\\x01\\2@;ti;:s;s@(^|[^A-Za-z0-9])${TEMPLATE_SLUG//./\\.}([^A-Za-z0-9]|$)@\\1\\x01S\\x01\\2@;ts;:n;s@(^|[^A-Za-z0-9])${TEMPLATE_REPO//./\\.}([^A-Za-z0-9]|$)@\\1\\x01N\\x01\\2@;tn;s@\\x01S\\x01@${OWNER}/${REPO}@g;s@\\x01N\\x01@${REPO}@g;s@\\x01I\\x01@${TEMPLATE_REPO}#@g;}" # Paths that are ABOUT the template keep its name wholesale: its ADRs, # changelog and audits. Template machinery (this recipe, the tests whose # fixtures must stay the template's literal name) is not in mint_files @@ -735,9 +1181,9 @@ repo-init archetype="": # every child genuinely points at, and rewriting those would break the # child's canon, CI and badges. if [ "$OWNER" != "$TEMPLATE_OWNER" ]; then - sed -i -E 's|^(OWNER[[:space:]]*:=[[:space:]]*)"'"${TEMPLATE_OWNER}"'"|\1"'"${OWNER}"'"|' Justfile - sed -i -E 's|^sonar\.organization='"${TEMPLATE_OWNER}"'$|sonar.organization='"${OWNER}"'|' sonar-project.properties - sed -i -E 's|^sonar\.projectKey='"${TEMPLATE_OWNER}"'_|sonar.projectKey='"${OWNER}"'_|' sonar-project.properties + # The same declaration forms the identity parser accepts: optional + # `export`, either quote (Codex review of #274). + sed -i -E "$SELF_OWNER_SED" Justfile sonar-project.properties echo " self-owner: rewrote ${TEMPLATE_OWNER} -> ${OWNER} (estate dependency paths preserved)" echo " NOTE: confirm the SonarCloud ORG is really '${OWNER}' before provisioning —" echo " the GitHub owner and the SonarCloud org are not always the same." @@ -929,12 +1375,11 @@ repo-init archetype="": { echo " ; variant — provenance contract (ADR-0002 shape; ADR-0003 use)." echo " ; Records what this repo was minted from, for re-templating tooling and" - echo " ; the drift gate (scripts/check-variant-drift.sh). The mint renders" - echo " ; tokens and rewrites files, so most spine files differ from the parent" - echo " ; by design, and (normalise) does not yet fold the mint's answers" - echo " ; (ADR-0003): the gate is not armed for a minted repo. Measured on a" - echo " ; fresh mint (2026-10-20): 101 rendered files differ. The template pin" - echo " ; is checked by scripts/check-template-conformance.sh instead." + echo " ; the drift gate. For a minted repo the gate replays the mint from" + echo " ; (provenance) and (mint-answers) and diffs this tree against the" + echo " ; replay (scripts/check-mint-drift.sh), so only edits made after the" + echo " ; mint count. Declare those in (paths) below; the derived files are" + echo " ; declared already, since their own gates keep them fresh." echo " (variant" echo " :project \"${REPO}\"" echo " :schema-version \"0.1.0\"" @@ -948,13 +1393,15 @@ repo-init archetype="": echo " (normalise :rules \"action-pins self-name\")" echo " ; Files a minted repo owns from birth are (paths :diverged …)." echo " (paths" - echo " :added ()" + # The paths this work tree changed before the mint are added to these + # lists once the mint has moved and deleted what it will (see "What + # this work tree held before the mint", below). + _list() { local _s="" _x; printf ' :%s (' "$1"; shift; for _x in "$@"; do printf '%s"%s"' "$_s" "$_x"; _s=" "; done; printf ')'; } _removed=("${MINT_REMOVES[@]}") [ "${DEED_IN#./}" = "${REPO}_chora.deed" ] || _removed+=("${DEED_IN#./}") - printf ' :removed ('; _sep="" - for _p in "${_removed[@]}"; do printf '%s"%s"' "$_sep" "$_p"; _sep=" "; done - printf ')\n' - echo " :diverged (\"${REPO}_chora.deed\" \"docs/status/ROADMAP.adoc\")))" + _list added; echo + _list removed "${_removed[@]}"; echo + _list diverged "${REPO}_chora.deed" docs/status/ROADMAP.adoc CLAUDE.md docs/architecture/REPOSITORY-MAP.adoc; echo "))" } | put_clause variant if [ "$PARENT_PIN" = "UNASSIGNED" ]; then echo " provenance: $DEED_OUT (variant) — parent-pin UNASSIGNED (offline?)" @@ -1005,6 +1452,55 @@ repo-init archetype="": echo " (substitutions :rendered 34 :unassigned ()))" } | put_clause provenance + # ── (mint-answers) — the answers, so the mint can be replayed ─────────── + # Provenance names the template commit; this names what it was rendered + # with. Together they make the mint a function that can be run again: + # scripts/check-mint-drift.sh re-mints the template at the pin with these + # answers and diffs the child against the result, so drift is measured + # exactly instead of through hand-written (normalise) rules (ADR-0003). + # Owner ruling (2026-11-04): record only values the mint already writes + # into the child's own files (names, contacts, description, URLs), so the + # record publishes nothing new. Deed strings escape `\` and `"`. + deed_str() { printf '%s' "$1" | sed -e 's/[\\"]/\\&/g'; } + # The ruling is checked here, not left to a hand-kept list (Codex review of + # #275 found project-type recorded, though no template file renders it): a + # non-empty answer is recorded only if its value appears verbatim in this + # repo's files. An answer left out replays as unanswered; it rendered + # nothing here, so the replay is unchanged. + # Not the template-only trees the mint deletes below (MINT_REMOVES: + # archetypes/, build/docs-seed/): a value found only there is in no file + # the child keeps (CodeRabbit review of #275). + mapfile -t _MINTED < <(mint_files | awk -v r="${MINT_REMOVES[*]:-}" \ + 'BEGIN { n = split(r, p, " ") } { for (i = 1; i <= n; i++) if (index($0, p[i]) == 1) next; print }') + _ANSWERS=() _UNRECORDED="" + for _pair in project-name:PROJECT_NAME repo:REPO owner:OWNER author:AUTHOR \ + author-email:AUTHOR_EMAIL author-org:AUTHOR_ORG \ + author-email-alt:AUTHOR_EMAIL_ALT author-orcid:AUTHOR_ORCID \ + description:PROJECT_DESCRIPTION forge:FORGE \ + security-email:SECURITY_EMAIL conduct-email:CONDUCT_EMAIL \ + project-type:PROJECT_TYPE website:WEBSITE \ + openssf-bp-id:OPENSSF_BP_ID service-name:SERVICE_NAME \ + port:PORT registry:REGISTRY archetype:ARCHETYPE \ + package-uuid:PACKAGE_UUID; do + _var="${_pair#*:}" + _val="${!_var:-}" + if [ -n "$_val" ] && ! grep -qsF -- "$_val" "${_MINTED[@]}" /dev/null; then + _UNRECORDED="${_UNRECORDED} ${_pair%%:*}" + continue + fi + _ANSWERS+=(" :${_pair%%:*} \"$(deed_str "$_val")\"") + done + { + echo " ; mint-answers — what just repo-init was answered on ${CURRENT_DATE}." + echo " ; Read by scripts/check-mint-drift.sh to replay the mint. Every value" + echo " ; here already appears in this repo's rendered files." + echo " (mint-answers" + printf '%s\n' "${_ANSWERS[@]}" + echo " :mint-date \"${CURRENT_DATE}\")" + } | put_clause mint-answers + echo " answers: $DEED_OUT (mint-answers) — replayable with scripts/check-mint-drift.sh" + [ -z "$_UNRECORDED" ] || echo " not recorded, written into no file here:${_UNRECORDED}" + if [ "$CANON_VERSION" = "UNASSIGNED" ]; then echo " provenance: $DEED_OUT (provenance) — canon pin UNASSIGNED" echo " The template deed carried no (canon …) clause, so this repo" @@ -1018,6 +1514,31 @@ repo-init archetype="": # (same reason build/templates is removed above). rm -rf archetypes rm -rf build/docs-seed # template-only; its content now lives in docs/ + # Its SD022 exemption goes with it: a child keeping an exemption for a path + # it does not have fails check-hypatia-ignore.sh (stale exemption). The + # block runs from its section header to the entry, plus the blank after. + if [ -f .hypatia-ignore ]; then + awk '/^# ─── build\/docs-seed\/ describes DOWNSTREAM/ { skip = 1 } + skip && /^structural_drift\/SD022:build\/docs-seed\/$/ { skip = 0; blank = 1; next } + skip { next } + blank && /^$/ { blank = 0; next } + { blank = 0; print }' .hypatia-ignore > .hypatia-ignore.mint + mv .hypatia-ignore.mint .hypatia-ignore + fi + # The Hypatia rule-order policy classifies each module .hypatia-ignore + # exempts, and check-hypatia-rule-order.sh fails on a classification with + # no exemption behind it. With no structural_drift exemption left, drop its + # observed_modules entry. The policy is one line of JCS (RFC 8785), so + # removing one object and its comma keeps it canonical; no JSON tool needed. + POLICY=.machine_readable/policies/hypatia-rule-order.json + if [ -f "$POLICY" ] && ! grep -q '^structural_drift/' .hypatia-ignore 2>/dev/null; then + SD_OBJ='[{]"layer":[0-9]+,"module":"structural_drift","rationale":"([^"\\]|\\.)*"[}]' + sed -E -i -e "s/${SD_OBJ},//" -e "s/,${SD_OBJ}//" "$POLICY" + if grep -q '"module":"structural_drift"' "$POLICY"; then + echo "ERROR: could not drop structural_drift from $POLICY; the mint stops here." >&2 + exit 1 + fi + fi # Overlay .in rule (every archetype): an overlay ships content templates as # *.in so a substitution token never survives in a file whose final name is @@ -1093,6 +1614,78 @@ repo-init archetype="": } fi + # ── What this work tree held before the mint ──────────────────────────── + # Declared here, after every step that moves or deletes files, as exactly + # what this tree now differs by from the replay taken before the first write + # (above): the comparison the drift gate makes, so the declaration cannot + # disagree with it, whatever the mint did to a pre-mint change on the way + # (moved, rendered, overwritten, or reached through an edited input) (Codex + # and CodeRabbit review of #275). The files this mint owns are declared + # already. Written into the (variant (paths)) lists by awk, which takes the + # paths as data and escapes \ and " in deed strings. + if [ -n "$_REPLAY" ]; then + _owned=("${REPO}_chora.deed" docs/status/ROADMAP.adoc CLAUDE.md docs/architecture/REPOSITORY-MAP.adoc) + _is_owned() { local _o; for _o in "${_owned[@]}"; do [ "$1" = "$_o" ] && return 0; done; return 1; } + # _files DIR — DIR's own files, one per line (tracked, or untracked and not + # ignored), as the drift gate lists them. This tree is read under its own + # git configuration, as `git add -A` will commit it (a file only a global + # excludesFile ignores is never committed, so never declared); the + # replay under none (CodeRabbit review of #275). + _files() { + { if [ "$1" = "$_REPLAY" ]; then _rg -C "$1" ls-files -z --cached --others --exclude-standard + else git -C "$1" ls-files -z --cached --others --exclude-standard; fi; } \ + | while IFS= read -r -d '' _f; do + if [ -e "$1/$_f" ] || [ -L "$1/$_f" ]; then printf '%s\n' "$_f"; fi + done | LC_ALL=C sort -u + } + _pml="$(mktemp -d)"; : > "$_pml/a"; : > "$_pml/r"; : > "$_pml/d" + _files . > "$_pml/child" + _files "$_REPLAY" > "$_pml/replay" + while IFS= read -r _p; do + _is_owned "$_p" && continue + if ! grep -qxF -- "$_p" "$_pml/replay"; then printf '%s\n' "$_p" >> "$_pml/a" + elif ! _same "$_p" "$_REPLAY/$_p"; then printf '%s\n' "$_p" >> "$_pml/d" + fi + done < "$_pml/child" + while IFS= read -r _p; do + _is_owned "$_p" && continue + grep -qxF -- "$_p" "$_pml/child" || printf '%s\n' "$_p" >> "$_pml/r" + done < "$_pml/replay" + awk -v fa="$_pml/a" -v fr="$_pml/r" -v fd="$_pml/d" ' + function load(file, s, p) { + s = "" + while ((getline p < file) > 0) { + gsub(/\\/, "\\\\", p); gsub(/"/, "\\\"", p) + s = s " \"" p "\"" + } + close(file); return s + } + # insert EXTRA before the ")" that closes the list opened at "(". + function extend(line, extra, i, c, q, open, out) { + open = index(line, "("); q = 0 + for (i = open + 1; i <= length(line); i++) { + c = substr(line, i, 1) + if (q && c == "\\") { i++; continue } + if (c == "\"") q = !q + else if (!q && c == ")") { + out = substr(line, 1, i - 1) extra substr(line, i) + sub(/\( "/, "(\"", out) + return out + } + } + return line + } + BEGIN { A = load(fa); R = load(fr); D = load(fd) } + /^ :added \(/ { $0 = extend($0, A) } + /^ :removed \(/ { $0 = extend($0, R) } + /^ :diverged \(/ { $0 = extend($0, D) } + { print } + ' "$DEED_OUT" > "$_pml/deed" && cat "$_pml/deed" > "$DEED_OUT" + echo " pre-mint: $(wc -l < "$_pml/a") added, $(wc -l < "$_pml/d") diverged, $(wc -l < "$_pml/r") removed;" + echo " declared in $DEED_OUT (variant (paths)) as this repo's own" + rm -rf "$_pml" "$(dirname "$_REPLAY")" + fi + # ── Regenerate what is DERIVED from the deed and the tree ─────────────── # CLAUDE.md's arrival pack and the repository map are generated files, and # until now the mint left both describing the TEMPLATE. The self-name pass @@ -1122,6 +1715,18 @@ repo-init archetype="": echo " repo map: docs/architecture/REPOSITORY-MAP.adoc regenerated" fi + # Replay (scripts/check-mint-drift.sh): every tree-writing step has run, so + # the reconstruction is complete. What follows only checks the tree: the placeholder + # gate (a partial mint made with RSR_ALLOW_PLACEHOLDERS=1 would fail it on + # replay), the www suite (a past RSR_MINT_DATE fails it: security.txt + # expires at the end of the mint year) and the rest. The child's own CI + # runs those checks on the child; the replay exists only to be compared. + if [ "${RSR_MINT_REPLAY:-0}" = "1" ]; then + echo "" + echo "Replay complete (RSR_MINT_REPLAY=1): post-write checks left to the child's CI." + exit 0 + fi + echo "" echo "── Validation ───────────────────────────────────" @@ -1304,8 +1909,7 @@ repo-init archetype="": echo " must be set to ci/.gitlab-ci.yml, or GitLab CI silently never" echo " runs - no pipeline and no error. See ci/README.adoc." fi - echo " 8. HARNESS — check the recorded template pin (provenance):" - echo " scripts/check-template-conformance.sh [--template ]" - echo " scripts/check-variant-drift.sh is not for a minted repo yet: the mint" - echo " renders most spine files, so they differ from the parent by design" - echo " until (normalise) folds the mint's answers (ADR-0003)." + echo " 8. HARNESS — check the recorded pin, then arm the drift gate, which replays" + echo " this mint from (provenance) and (mint-answers) and diffs the result:" + echo " scripts/check-template-conformance.sh" + echo " scripts/check-variant-drift.sh " diff --git a/ci/.pre-commit-config.yaml b/ci/.pre-commit-config.yaml index a5c2794..74eb6d8 100644 --- a/ci/.pre-commit-config.yaml +++ b/ci/.pre-commit-config.yaml @@ -36,11 +36,17 @@ repos: pass_filenames: false # --- K9 contract validation --- - - repo: https://github.com/hyperpolymath/k9-pre-commit - rev: 9b82e1f7a6b6c0f99df72c145d7dee8851803c30 # k9-pre-commit @ main 2026-06-23 + # The canonical validator, through the same wrapper CI runs (needs a + # standards checkout beside this repo, or STANDARDS_DIR; skips with a + # notice otherwise). + - repo: local hooks: - id: validate-k9 name: Validate K9 contracts + entry: bash .github/hooks/validate-k9.sh + language: system + pass_filenames: false + files: '\.k9(\.(ncl|lock))?$' # --- Shell linting --- - repo: https://github.com/shellcheck-py/shellcheck-py diff --git a/docs/AI_INSTALLATION_GUIDE.adoc b/docs/AI_INSTALLATION_GUIDE.adoc index 9ce7d63..886b15d 100644 --- a/docs/AI_INSTALLATION_GUIDE.adoc +++ b/docs/AI_INSTALLATION_GUIDE.adoc @@ -43,9 +43,15 @@ with no git ancestry; `just repo-init` recovers the template tip via [source,bash] ---- -just repo-init # or: just repo-init +mise trust # allow this repository's mise.toml (no prompt without a TTY) +mise install # the toolchain, including the yq repo-init needs +mise exec -- just repo-init # or: mise exec -- just repo-init ---- +When the mint keeps `build/container/`, `just repo-init` reformats its KYAML +configs after filling them, which needs a yq that writes KYAML (mise.toml +provides it). Without one the mint stops before writing anything. + `just repo-init` renders every `{{TOKEN}}` in the tree, derives the repository identity, writes the provenance files, and removes the template-only directories. @@ -62,7 +68,7 @@ are required; an unset optional answer takes its default. ---- RSR_NON_INTERACTIVE=1 PROJECT_NAME="My Project" REPO=my-project \ OWNER=my-org AUTHOR="My Name" AUTHOR_EMAIL=me@example.org \ - just repo-init + mise exec -- just repo-init ---- Until 2026-10-14 this mode stubbed the `read` builtin for the whole recipe, so @@ -109,12 +115,15 @@ returns 1 and kills the recipe under `set -e`. [source,bash] ---- -just --list # every recipe resolves (imports are silent on failure) -just check-root-shape # root matches the allowlist +mise exec -- just --list # every recipe resolves (imports are silent on failure) +mise exec -- just check-root-shape # root matches the allowlist bash scripts/check-no-placeholders.sh . -just verify +mise exec -- just verify ---- +An agent's shell is rarely mise-activated, and each command may run in a fresh +shell, so every `just` call here goes through `mise exec --`. + A successful instantiation leaves **no** `{{TOKEN}}` anywhere in the tree, and removes the template-only directories from the minted repository. If `scripts/validate-template.sh` or the end-to-end instantiation test are still diff --git a/docs/EXPLAINME.adoc b/docs/EXPLAINME.adoc index 16f80b2..308621a 100644 --- a/docs/EXPLAINME.adoc +++ b/docs/EXPLAINME.adoc @@ -100,13 +100,17 @@ only tested or still open. Tokens are the standard's closed vocabulary. the public result for finishing. This refutes the current rules, not every possible termination-sensitive typing discipline. -| Independent recheck in Rocq and in Lean (PROOF-POLICY §2) -| open -| — -| `PROOF-STATUS.adoc` "Proof Witness Grades, and the independent-witness legs", - `verification/receipts/agda-pwg_praxis.deed` -| No transport has been attempted and no hand re-proof exists, so every proven - row above is at `PWG-3` (PROOF-POLICY §8) and no higher. The template skeletons under +| Independent recheck of N-1, N-2, N-3, N-7, N-8 and N-9 in Rocq and in Lean + (PROOF-POLICY §2) +| proven +| `bash scripts/check-legs.sh` (Coq 8.18.0 and Lean 4.24.0, each pinned; no + library; every theorem printed with its axioms, and there are none) +| `verification/legs/rocq/SecretTypes.v`, `verification/legs/lean/`; + correspondence in `verification/legs/README.adoc` +| Re-proved by hand, not transported: Agda2Dedukti cannot run on the pinned + Agda. The statement comparison was written by Claude Code and is provisional + until the owner reviews it. With it, every proven row above is at `PWG-2ii` + (PROOF-POLICY §8). The template skeletons under `verification/proofs/{coq,lean4}` are *not* these legs and prove nothing here. | Strengthened termination-sensitive typing discipline @@ -123,7 +127,7 @@ only tested or still open. Tokens are the standard's closed vocabulary. | The baseline declassifies nothing, by design. |=== -The machine copy of this ledger is `verification/receipts/agda-pwg_praxis.deed` +The machine copy of this ledger is `verification/receipts/agda_praxis.deed` (generated by `scripts/grade-receipt.sh`; CI fails if it drifts from the tree or from the claim IDs in `PROOF-STATUS.adoc`). diff --git a/docs/HYPATIA-RULE-ORDER.adoc b/docs/HYPATIA-RULE-ORDER.adoc index 2d3ecdb..8e57efe 100644 --- a/docs/HYPATIA-RULE-ORDER.adoc +++ b/docs/HYPATIA-RULE-ORDER.adoc @@ -10,7 +10,7 @@ speak about the same file, *which* clashes exist today, and *what resolves each one*. It is local policy about tools that live elsewhere: Hypatia at `hyperpolymath/hypatia@be98f74ce93df200752f3798efa1305bfa3e0e10` (`lib/hypatia/scanner_suppression.ex`, `lib/rules/`), and the verdict grid at -`metadatastician/standards@8e48b1caade08a552646adeab66558ac43b4360d` +`metadatastician/standards@e064539116c2dcfdf282a4749dbd58c69961c7e6` (`docs/VERDICT-GRID-SPEC.adoc`). Nothing here changes what a scanner finds. It states the order the layers are diff --git a/docs/SETUP.adoc b/docs/SETUP.adoc index a44ac0f..6811138 100644 --- a/docs/SETUP.adoc +++ b/docs/SETUP.adoc @@ -64,8 +64,11 @@ Or with OPSM, the odds-and-sods package manager (see <>): `opsm install https://github.com/hyperpolymath/secret-types.git --registry git` TIP: To start a *new* project from this template, use GitHub's *Use this template* -button (or clone as above), then run `just repo-init` before step 3; it fills every -placeholder token. The steps below work the same afterwards. +button (or clone as above), install the toolchain (step 3), then run +`mise exec -- just repo-init` (plain `just repo-init` once mise is activated); +it fills every placeholder token, and needs the toolchain's yq to keep the container +configs KYAML (it stops before writing anything without one). The steps below work +the same afterwards. == 3. Install the toolchain @@ -75,6 +78,10 @@ mise trust # allow this repository's mise.toml mise install # installs everything mise.toml lists, at mise.lock's versions ---- +The `just` commands from here on expect the toolchain on `PATH`: activate mise in +your shell (`eval "$(mise activate bash)"`, or your shell's equivalent), or run each +one as `mise exec -- just …`. + That installs: [cols="1,2,3"] diff --git a/docs/proof-debt.adoc b/docs/proof-debt.adoc index 8443052..897c932 100644 --- a/docs/proof-debt.adoc +++ b/docs/proof-debt.adoc @@ -18,7 +18,7 @@ empty, and that emptiness is *enforced* rather than asserted. `scripts/check.sh` refuses the build if any `.agda` file under `src/SecretTypes` or `tests` contains `postulate`, and the same tree is compiled with `--safe`, which independently refuses `postulate`, `{-# TERMINATING #-}`, `{-# NON_TERMINATING #-}` -and `--type-in-type`. `verification/receipts/agda-pwg_praxis.deed` records the +and `--type-in-type`. `verification/receipts/agda_praxis.deed` records the counts (`postulates = 0`, `escape-hatches = 0`) *derived from the tree*, and CI fails if they move. @@ -67,12 +67,14 @@ _None in the proof development._ Two items are open *obligations* rather than proof debt, and are listed here so that they are not mistaken for either: -* *Recheck legs (`PWG-2i`/`PWG-2ii`).* `PROOF-POLICY.adoc` §2 asks for every Agda - theory proof to be rechecked in Rocq and in Lean. Neither leg exists; both are - recorded `unavailable` in the receipt and in - link:../PROOF-STATUS.adoc[`PROOF-STATUS.adoc`]. An absent recheck is not an - escape hatch in a proof (the proof has no hole), but it bounds the grade at - `PWG-3`, and that bound is recorded rather than glossed. +* *Recheck legs (`PWG-2ii`, met 2026-11-09).* `PROOF-POLICY.adoc` §2 asks for + every Agda theory proof to be rechecked in Rocq and in Lean. Both legs now + exist, re-proved by hand without a library or an axiom + (link:../verification/legs/README.adoc[`verification/legs/README.adoc`]), + and the receipt records them. The obligation that remains is review: the + statement comparison was written by Claude Code and is provisional until the + owner reviews it. `PWG-1` (a per-commit receipt of each checker's observed + result) is not met. * *Termination-sensitive noninterference.* `Noninterference.OutcomeVisibility.termination-sensitive-fails` refutes TSNI for the current typing rules when finishing versus non-finishing is public. @@ -93,8 +95,7 @@ places is a claim the gate does not support. [[waivers]] == Waivers — obligations this repository does not meet -None is open. W-1 is kept below, closed, as the record of how the obligation -came to be met. +None open. W-1 below is closed and kept for its evidence. A waiver is not an escape hatch inside a proof and not proof debt. It is an obligation the *policy* sets that this repository does not yet satisfy, recorded @@ -104,51 +105,96 @@ shape when a mapped prover is not the right fit). === W-1 `--cubical-compatible` (PROOF-POLICY §2.1, dated 2026-11-01) — CLOSED 2026-11-09 +*Closed.* The flag is in force in `scripts/proof-flags.sh`, and therefore in the +gate, the laws certificate and the receipt. The 13 remaining warnings below +(`just`-injectivity in `Noninterference.Generic`) were removed without arm B2: each +clause now reads the run's result through `Prelude.just-inj`, defined by `cong` +through the computing projection `from-just`, which needs no unification rule. +No statement, semantics or fixture changed. The flag is proved to be in force by +`tests/flag-control/IndexedMatch.agda`, a correct proof that only the flag +refuses; `scripts/check.sh` fails if it is ever accepted, and removing the flag +from `scripts/proof-flags.sh` was tested to fail the gate. + +The premise of arm B2 below also turned out to be false. A relational semantics +does not by itself satisfy the flag: a probe proving determinism of +`c , s ⇓ t` raised 22 `UnsupportedIndexedMatch` warnings, because inverting two +derivations of one command needs injectivity of `Cmd`'s constructors. A big-step +relation is still planned, as a second semantics with an adequacy theorem +(`exec` finishes iff `⇓` holds), not as a replacement and not for this flag. + +The record below is kept as it was written, for provenance. + *Obligation.* `agda --safe --without-K --cubical-compatible ` is the canonical invocation for first-party Agda theory entrypoints. -*State.* **Met.** `scripts/proof-flags.sh` carries `--cubical-compatible` beside -`--safe --without-K --no-libraries --ignore-interfaces --double-check -W error`, -and `scripts/check.sh` and `scripts/minimal-laws.sh` pass under it: core proofs, -all four expected rejections at their intended declarations, and the -laws certificate. With `-W error`, any `-WUnsupportedIndexedMatch` fails the -gate, so the flag cannot be nominally on while the check is off. - -*History.* Three CI runs on 2026-11-01 adopted the flag and then reported why it -would not stay (Agda `2.6.4.3`, `agda-bin=2.6.4.3-1+b2`): - -. `Prelude.agda:43` `just-inj`: injectivity of `just`. Fixed then: dead code, - deleted. -. `Prelude.agda:91` `_≟_`: injectivity of `suc`. Fixed then: `suc-dec` pulls - the answer back along `pred`, which computes definitionally. -. `Lattice.agda:46` `⊑-trans`: every clause matching the two-point order was an - indexed match on `_⊑_`. Fixed then (arm B1): `_⊑_` became a computed - predicate (`L ⊑ _ = ⊤`, `H ⊑ H = ⊤`, `H ⊑ L = ⊥`). Its one cost is that a - witness such as `L⊑H` no longer pins its target label by its type, so - `Countermodels.NoDownClosure` names its indices (`h {x = L} {y = H}`). - -That left 13 warnings, all `injectivity of the data constructor just`, in `tini`, -`go`, `confine` and `confineW`. They came from matching an equation -`exec fuel c s ≡ just t` against `refl`. This record used to say that only a -relational big-step semantics would remove them (arm B2), and that this would -restate N-5 and reshape N-1/N-7, an owner decision about the theorem. - -*How it closed (2026-11-09).* That was not necessary. The proofs no longer match -such an equation. `SecretTypes.Prelude` defines `from-just` and -`just-injective e = cong (from-just a) e`, a retraction: both sides of -`cong (from-just a) e` reduce to the two values, so no injectivity rule is used. -This is the same technique `suc-dec` already uses with `pred`. In -`Noninterference.Generic` the six affected clauses go through one helper, -`≈-just : just s ≡ just t → just s′ ≡ just t′ → s ≈ s′ → t ≈ t′`, built from -`just-injective` and `subst`. The semantics, N-5 and every theorem statement are -unchanged, and the receipt's module hashes record the two changed modules. - -*What would make this record wrong.* If `-WUnsupportedIndexedMatch` were -suppressed in the gate, the flag would be nominally on and the check off. The -gate does not suppress it, and `scripts/proof-flags.sh` is the single place the -flags live. - -*Consequence for the grade.* Before this closed, the proved rows were -`ungraded` under `PROOF-POLICY` §8.2, which makes `PWG-3` conditional on the §2.1 -invocation; this file said "Grade 3" at the time. They are now `PWG-3`. The -receipt records `cubical-compatible = "met"`. +*State.* `--safe` and `--without-K` are in the gate. `--cubical-compatible` is +**not**, and cannot be added without changing the development or weakening the +check. + +*Evidence, from three CI runs on 2026-11-01 that adopted the flag and then had to +report why it would not stay.* The flag was added, the Agda pin was unchanged +(`2.6.4.3`, `agda-bin=2.6.4.3-1+b2`), and the gate went red three times: + +. `Prelude.agda:43` `just-inj` — *"relies on injectivity of the data constructor + `just`, which is not yet supported"*. **Fixed**: the lemma was dead code and + was deleted. +. `Prelude.agda:91` `_≟_` — *"relies on injectivity of the data constructor + `suc`"*. **Fixed**: `suc-dec` decides the predecessors and pulls the answer + back along `pred`, which computes definitionally, so no injectivity rule is + needed. +. `Lattice.agda:46` `⊑-trans` — the same warning class, this time because every + clause that matches on the two-point order is an **indexed match** on `_⊑_`. + **Not fixed.** This is not incidental: `_⊑_` is an indexed family with three + constructors and no elimination principle other than matching, and `⊑-trans`, + `⊔-upper₁`, `⊔-upper₂` and the `principal` observer all match on it. Cubical + Agda does not support indexed matching; the warning says so and offers + `-W[no]UnsupportedIndexedMatch`. + +*Two arms, labelled — and one of them has since been taken part of the way.* + +* *Arm A — keep the flag off and record this waiver.* The gate stays at + `--safe --without-K --no-libraries --ignore-interfaces --double-check -W error`: + four dimensions stricter than the canonical invocation, one flag absent. The + absent flag is the one whose absence cannot hide an unsoundness here — it + constrains compatibility with cubical transports, not the truth of the present + theorems. The waiver stands, but it is **narrower than it was** (see below). +* *Arm B — re-represent so that nothing needs index unification.* Two parts: + * *B1, done 2026-11-01.* `_⊑_` was an indexed family + (`data _⊑_ : Label → Label → Set where L⊑L; L⊑H; H⊑H`), so matching any + proof asked for injectivity of the index constructors `L`/`H`. It is now a + **computed predicate** — `L ⊑ _ = ⊤`, `H ⊑ H = ⊤`, `H ⊑ L = ⊥` — with the + laws proved by computation. The order still says exactly what it said, an + impossible flow is still `⊥` (so the three expected-rejection fixtures still + fail at their `⊑` obligation, checked), and the `H`-injectivity warnings are + **gone**: 16 → 13. One cost, recorded at the refutation that needs it: a + witness such as `L⊑H` no longer *pins* its target label by its type (since + `L ⊑ y` is `⊤` for every `y`), so `Countermodels.NoDownClosure` names its + indices explicitly (`h {x = L} {y = H}`). + * *B2, not attempted.* The 13 remaining warnings are all + `injectivity of the data constructor just`, in `tini`, `go`, `confine` and + `confineW`. They come from matching an equation of the form + `exec fuel c s ≡ just t`, where `exec` computes to `just v` and Agda must + solve `t := v` — constructor injectivity, and the cubical-unsupported step. + Two shortcuts were measured and do not work: a record-returning semantics + warns on the *record* constructor instead (probe file, 2026-11-01), and + threading the result as a variable fixes the equations it touches but not + the ones inside the clauses. The technique that does work is to stop putting + computed results in equality at all: restate the big-step semantics as a + **relation** (`data c , s ⇓ t`) and match derivations, which unify a variable + index against a constructor term instead of two constructor terms. That is a + research change with a named consequence: **N-5** (termination-insensitivity + is not vacuous) currently exhibits a program whose run returns `nothing`, + a computation; under a relational semantics divergence is the *absence* of a + derivation, so N-5 would need a new non-derivability argument. It also + changes the shape of N-1/N-7. That is an owner decision about the statement + of the theorem, not a refactor to slip into a flag change. +*What would make the waiver wrong.* If `-WUnsupportedIndexedMatch` were ever +suppressed in the gate, this waiver would become a lie — the flag would be +nominally on and the check off. The gate does not suppress it, and +`scripts/proof-flags.sh` is the single place the flags live, so that change +could not happen by accident. + +*Consequence for the grade (as written on 2026-11-01, superseded).* It said the +grade was unaffected, under the brief's "Grade 3". `PROOF-POLICY` §8 (2026-11-04) +then required the §2.1 invocation for `PWG-3`, so while W-1 was open the theorems +were `ungraded`. Closing W-1 is what makes them `PWG-3`. diff --git a/mise.toml b/mise.toml index 9db3ec0..19f214f 100644 --- a/mise.toml +++ b/mise.toml @@ -19,5 +19,6 @@ lockfile = true [tools] just = "latest" shellcheck = "latest" # the shell scripts +yq = "latest" # scripts/kyaml-format.sh (KYAML configs) zig = "latest" trivy = "latest" # just deps-audit diff --git a/scripts/chain-mint.sh b/scripts/chain-mint.sh new file mode 100755 index 0000000..e1db1ca --- /dev/null +++ b/scripts/chain-mint.sh @@ -0,0 +1,436 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# chain-mint.sh — mint a repository made from a MINTED repository. +# +# A minted repo has already filled its tokens and deleted build/templates, so +# minting it again rendered none of the new answers and skipped the identity +# splice: the grandchild inherited its parent's author, contacts and identity +# (Codex review of #251). The owner chose to support chains properly +# (2026-11-04). This is how, as a three-way merge that git performs: +# +# base = replay of the parent's own mint (template @ pin, parent answers) +# theirs = the parent as it stands (base + the parent's own work) +# ours = the template @ pin minted with THIS repo's answers +# result = merge-tree(base, ours, theirs) +# +# base -> theirs is exactly the parent's own work since its mint, and nothing +# else, so it is replayed onto a fresh mint for the new identity. Everything +# the template rendered is rendered afresh; the parent's own files and edits +# are carried; a conflict between the two stops before anything is written. +# Identity-bearing files (the deed, CLAUDE.md, the map, the roadmap) are never +# carried: they come from the new mint and are regenerated. Text the parent +# wrote about itself is renamed by the same rules repo-init uses: the bounded +# self-name rule and the narrow self-owner rule (repo-init defines each once +# and exports SELF_NAME_SED and SELF_OWNER_SED). What it inherits is declared +# by comparing the result with the fresh mint, as the drift gate does. +# +# Called by `just repo-init` from the root of the new repository, whose tree is +# the parent's ("Use this template" on a minted repo), after the prompts, with +# the new answers exported under the names repo-init reads, plus +# TEMPLATE_REPO/TEMPLATE_OWNER (the parent) and RSR_MINT_DATE. +# RSR_TEMPLATE_URL overrides where the template's history is read from. +# Exit: 0 minted; 1 refused (reason on stderr, tree untouched); 2 usage. +set -euo pipefail +# This script runs git against other repositories (-C does not override +# these), so an inherited location (a hook, another git caller) cannot redirect +# it (CodeRabbit review of #275). +unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY \ + GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_PREFIX GIT_NAMESPACE + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +READ="$HERE/deed-field.sh" +G="$(pwd)" +die() { echo "chain-mint: $*" >&2; exit 1; } +# g — git with no user or system configuration and no hooks, as the drift +# gate's (Codex review of #275): this machine's settings cannot change the +# merge, the commits or the file sets. +g() { + GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 git -c user.name=chain -c user.email=chain@example.invalid \ + -c commit.gpgsign=false -c core.hooksPath=/dev/null "$@" +} + +for v in REPO OWNER TEMPLATE_REPO TEMPLATE_OWNER RSR_MINT_DATE; do + [ -n "${!v:-}" ] || { echo "chain-mint: $v is not set (run through just repo-init)" >&2; exit 2; } +done +# A sparse checkout lists paths it has not checked out; read as a tree, their +# absence is a deletion of most of the repository (Codex review of #277). A +# chain mint carries and rewrites the whole tree, so it needs all of it. +_sparse=0 +while IFS= read -r -d '' e; do case "$e" in "S "*) _sparse=$((_sparse + 1)) ;; esac; done < <(g -C "$G" ls-files -t -z) +[ "$_sparse" -eq 0 ] || die "this checkout is sparse (${_sparse} tracked paths are not checked out); a chain mint carries the whole tree. Run it from a full checkout (git sparse-checkout disable); nothing was written" +# An unresolved merge: its stages would collapse into one ordinary blob, and +# conflict markers would be carried as the parent's work (Codex review of #277). +[ -z "$(g -C "$G" ls-files -u)" ] || die "this checkout has an unresolved merge (git ls-files -u); resolve it first; nothing was written" +# A tracked path beneath a symlink: reading it would read through the link, +# from wherever it points (Codex review of #277). Refused by name. +while IFS= read -r -d '' f; do + p="$f" + while case "$p" in */*) p="${p%/*}"; true ;; *) false ;; esac; do + [ ! -L "$G/$p" ] || die "the tracked path $f lies beneath the symlink $p; restore the directory or untrack the path; nothing was written" + done +done < <(g -C "$G" ls-files -z) +PDEED="$(bash "$READ" --find "$G")" || die "no single *_chora.deed in this tree" +PIN="$(bash "$READ" "$PDEED" provenance template-commit 2>/dev/null || true)" +TSLUG="$(bash "$READ" "$PDEED" provenance template-repo 2>/dev/null || true)" +[[ "$PIN" =~ ^[0-9a-f]{40}$ ]] || die "the parent's (provenance :template-commit) is '${PIN}', not a commit" +P_ARCH="$(bash "$READ" "$PDEED" mint-answers archetype 2>/dev/null || true)" + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +# The root template's history, holding the parent's pin. +URL="${RSR_TEMPLATE_URL:-https://github.com/${TSLUG}.git}" +FILTER=(--filter=blob:none) +case "$URL" in *://*|*@*:*) ;; *) FILTER=() ;; esac +# --template= (none): a global init.templateDir must not put an info/attributes +# into the clone, which every checkout from it would then honour (Codex review +# of #277). Transport settings (credentials, insteadOf) still apply. +git clone -q --no-checkout --template= "${FILTER[@]}" "$URL" "$WORK/template" 2>"$WORK/clone.err" \ + || { cat "$WORK/clone.err" >&2; die "cannot read ${TSLUG} from $URL (set RSR_TEMPLATE_URL)"; } +git -C "$WORK/template" cat-file -e "${PIN}^{commit}" 2>/dev/null \ + || die "$URL does not contain the parent's template commit $PIN" + +# Both baselines are functions of the pin and the answers alone: they run the +# replay wrapper as the template commit has it, not the parent's carried copy, +# which the parent may have edited (Codex review of #277). +PINNED="$WORK/pinned"; mkdir -p "$PINNED" +for s in replay-mint.sh deed-field.sh; do + git -C "$WORK/template" show "${PIN}:scripts/$s" > "$PINNED/$s" 2>/dev/null \ + || die "the template commit ${PIN:0:12} has no scripts/$s, so its mint cannot be replayed for a chain; nothing was written" +done +echo " chain: replaying the parent's mint (${TSLUG}@${PIN:0:12}, its own answers)" +bash "$PINNED/replay-mint.sh" "$WORK/template" "$PIN" "$WORK/base" --answers-from "$PDEED" \ + || die "the replay of the parent's mint failed (above); nothing was written" +echo " chain: minting ${TSLUG}@${PIN:0:12} with this repo's answers" +ARCHETYPE="$P_ARCH" RSR_TEMPLATE_SLUG="$TSLUG" bash "$PINNED/replay-mint.sh" "$WORK/template" "$PIN" "$WORK/new" \ + || die "the mint with the new answers failed (above); nothing was written" + + +# ── Trees, written by git ─────────────────────────────────────────────────── +# Every tree below is written by git from a work tree, and the result is +# written back into this checkout by git. Git never follows a symlink in a work +# tree, and checks every path it would overwrite before it writes any (Codex +# review of #277: copying with tar followed a parent-controlled symlink, +# recursed into a path that had become a directory, and emptied the checkout +# before learning that an ignored entry was in the way). +M="$WORK/merge" +g init -q -b base "$M" +mkdir -p "$M/.git/objects/info" +# link_objects DIR — DIR's objects become readable from M. +link_objects() { + local od + od="$(g -C "$1" rev-parse --path-format=absolute --git-common-dir)/objects" + grep -qxF "$od" "$M/.git/objects/info/alternates" 2>/dev/null || printf '%s\n' "$od" >> "$M/.git/objects/info/alternates" +} +# tree_of DIR NAME [own] — the tree of DIR's own files, as `git add -A` commits +# them from DIR's index: tracked files (an ignore rule matching or not) and the +# untracked files no rule ignores. DIR's own git does the reading, on a scratch +# index, writing its objects into M; DIR's index and objects are not written. +# With "own", DIR is read under its own configuration, exactly as `git status` +# there sees it: its exclusion files (a linked worktree's are in the common +# directory), core.autocrlf, clean filters (Codex review of #277). Without it, +# under none (g): a replay's tree must not depend on this machine. +tree_of() { + local d="$1" idx="$WORK/index.$2" gx=g od + [ "${3:-}" = own ] && gx=git + link_objects "$d" + od="$(g -C "$d" rev-parse --path-format=absolute --git-common-dir)/objects" + rm -f "$idx" + g -C "$d" ls-files -s -z | GIT_INDEX_FILE="$idx" g --git-dir="$M/.git" update-index -z --index-info + GIT_INDEX_FILE="$idx" GIT_OBJECT_DIRECTORY="$M/.git/objects" GIT_ALTERNATE_OBJECT_DIRECTORIES="$od" \ + "$gx" -C "$d" -c core.fsmonitor=false -c core.hooksPath=/dev/null add -A . + GIT_INDEX_FILE="$idx" g --git-dir="$M/.git" write-tree +} +# with_index NAME CMD… — run git CMD in M against the scratch index NAME. +with_index() { local i="$WORK/index.$1"; shift; GIT_INDEX_FILE="$i" g --literal-pathspecs -C "$M" "$@"; } +P_DEED_NAME="$(basename "$PDEED")" +IDENTITY=("$P_DEED_NAME" CLAUDE.md docs/architecture/REPOSITORY-MAP.adoc docs/status/ROADMAP.adoc) + +B="$(g -C "$M" commit-tree "$(tree_of "$WORK/base" base)" -m base)" +# theirs: the parent as it stands (uncommitted work included), with its +# identity files held at base, so they are never carried: they belong to the +# parent. Held in the index, never through the parent's work tree. +PARENT_TREE="$(tree_of "$G" parent own)" +rm -f "$WORK/index.theirs"; with_index theirs read-tree "$PARENT_TREE" +for f in "${IDENTITY[@]}"; do + with_index theirs rm -q --cached --ignore-unmatch -- "$f" + entry="$(g -C "$M" ls-tree "$B" -- "$f")" + [ -n "$entry" ] || continue + read -r mode _ sha _ <<<"$entry" + with_index theirs update-index --add --cacheinfo "$mode,$sha,$f" 2>/dev/null \ + || die "cannot hold $f at the template's version: the parent replaced a directory above it" +done +THEIRS="$(g -C "$M" commit-tree "$(with_index theirs write-tree)" -p "$B" -m theirs)" + +# ours: the fresh mint, kept as its own commit (the drift gate replays exactly +# it, so the declarations are measured against it), then shaped by the +# parent's deletions and type changes. ours differs from base only by +# rendering, so a file the parent deleted stays deleted (else a modify/delete +# conflict: .machine_readable/ai/PLACEHOLDERS.adoc), and a path whose type the +# parent changed keeps base's version there, so the parent's change wins (else +# a "distinct types" conflict against a mere re-rendering). +NEW_T="$(tree_of "$WORK/new" new)" +g -C "$M" branch -q fresh "$(g -C "$M" commit-tree "$NEW_T" -p "$B" -m fresh)" +rm -f "$WORK/index.ours"; with_index ours read-tree "$NEW_T" +# A path the new answers rename (src/.jl -> src/.jl) is deleted where +# the fresh mint has it. The renames are found by the detection merge-tree +# itself applies, so exactly the pairs it would see as rename/delete (Codex +# review of #277). renamed_to P — P's rename destination in the fresh mint. +declare -A RENAMED=() +while IFS= read -r -d '' st && IFS= read -r -d '' old && IFS= read -r -d '' new; do + RENAMED["$old"]="$new" +done < <(g -C "$M" diff -z --find-renames --name-status --diff-filter=R "$B" "$NEW_T") +while IFS= read -r -d '' f; do + with_index ours rm -q --cached --ignore-unmatch -- "$f" + [ -z "${RENAMED[$f]:-}" ] || with_index ours rm -q --cached --ignore-unmatch -- "${RENAMED[$f]}" +done < <(g -C "$M" diff -z --no-renames --name-only --diff-filter=D "$B" "$THEIRS") +while IFS= read -r -d '' f; do + # The fresh mint has this path elsewhere: the parent's new type cannot be + # put where the new answers put the file. Refused by name, rather than + # carried at the old path beside the fresh one (Codex review of #277). + [ -z "${RENAMED[$f]:-}" ] \ + || die "the parent changed the type of $f, which this mint renames to ${RENAMED[$f]}; a chain mint cannot carry that across the rename. Restore it in the parent (or mint from the template); nothing was written" + with_index ours rm -q --cached --ignore-unmatch -- "$f" + read -r mode _ sha _ <<<"$(g -C "$M" ls-tree "$B" -- "$f")" + with_index ours update-index --add --cacheinfo "$mode,$sha,$f" +done < <(g -C "$M" diff -z --no-renames --name-only --diff-filter=T "$B" "$THEIRS") +OURS="$(g -C "$M" commit-tree "$(with_index ours write-tree)" -p fresh -m ours)" + +if ! g -C "$M" merge-tree --write-tree --name-only --merge-base="$B" "$OURS" "$THEIRS" > "$WORK/merge.out" 2>&1; then + echo "chain-mint: the parent's own changes conflict with this repo's fresh mint in:" >&2 + # --name-only: the merged tree, then the conflicted paths, a blank line, + # then git's messages (CONFLICT …), which say what kind of conflict. + sed -n '2,$p' "$WORK/merge.out" | sed '/^$/d; s/^/ /' | head -n 60 >&2 + die "nothing was written. Resolve those in the parent (or mint from the template), then retry." +fi +RESULT="$(head -n 1 "$WORK/merge.out")" + +# Everything below is built and checked in a scratch copy, F; this checkout +# is written only once every step has succeeded, so a failure at any point (a +# carried placeholder, a failing gate) leaves it untouched (Codex review of #277). +F="$WORK/final" +g -C "$M" branch -q result "$(g -C "$M" commit-tree "$RESULT" -m result)" +g init -q -b main "$F" +g -C "$F" fetch -q "$M" fresh:refs/chain/fresh result:refs/chain/result +# F's index is the merged tree itself, and git writes its files (never +# through a symlink); a tracked file an ignore rule matches stays tracked. +g -C "$F" read-tree refs/chain/result +g -C "$F" checkout-index -a -f + +# What this repo inherits is the merged result's difference from the fresh +# mint: exactly what the drift gate will compare. Measured on the result, not +# on the parent's diff, so a path the merge carried through an answer-dependent +# rename (src/.jl -> src/.jl) is named where it now lives (Codex +# review of #277). The files the new mint owns (the deed, CLAUDE.md, the map, +# the roadmap) are declared already, and are regenerated below. +FRESH="$(g -C "$F" rev-parse refs/chain/fresh)" +OWNED=("${REPO}_chora.deed" CLAUDE.md docs/architecture/REPOSITORY-MAP.adoc docs/status/ROADMAP.adoc) +is_owned() { local o; for o in "${OWNED[@]}"; do [ "$1" = "$o" ] && return 0; done; return 1; } +# inherited — "STATUS\0PATH\0" for every path where F differs from the fresh mint. +inherited() { g -C "$F" add -A; g -C "$F" diff --cached -z --no-renames --name-status "$FRESH"; } + +# Rename the parent where its own work names it, by the rules repo-init +# defined and exported, read as data: the bounded self-name rule +# (SELF_NAME_SED) and the narrow self-owner rule for identity settings +# (SELF_OWNER_SED: a carried sonar.organization or OWNER := line). +for v in SELF_NAME_SED SELF_OWNER_SED; do + [ -n "${!v:-}" ] || { echo "chain-mint: $v is not set (run through just repo-init)" >&2; exit 2; } +done +TEMPLATE_SLUG="${TEMPLATE_OWNER}/${TEMPLATE_REPO}" +VERBATIM="$(sed -e 's/#.*//' -e 's/[[:space:]]*$//' "$F/.machine_readable/mint-verbatim.txt" | grep -v '^$' || true)" +while IFS= read -r -d '' st && IFS= read -r -d '' f; do + [ "$st" != D ] || continue + [ -f "$F/$f" ] && [ ! -L "$F/$f" ] || continue + grep -qxF -- "$f" <<< "$VERBATIM" && continue + if [ "$TEMPLATE_OWNER" != "$OWNER" ] && grep -qIF -- "$TEMPLATE_OWNER" "$F/$f" 2>/dev/null; then + sed -i -E "$SELF_OWNER_SED" "$F/$f" + fi + if grep -qIF -- "$TEMPLATE_REPO" "$F/$f" 2>/dev/null; then + sed -i -E "$SELF_NAME_SED" "$F/$f" + fi +done < <(inherited) + +# Every path this script writes from here on must be a real path inside F: a +# symlink the parent carried at one of them, or at a directory above one, +# would redirect the write (Codex review of #277). Refused by name. +DEED="$F/${REPO}_chora.deed" +# The arrival-pack generator's temporary files included (CLAUDE.md.tmp is +# moved over CLAUDE.md) (Codex review of #277). +for w in "${REPO}_chora.deed" CLAUDE.md docs/architecture/REPOSITORY-MAP.adoc \ + .machine_readable/arrival-pack/claude-md-data.json CLAUDE.md.tmp \ + .machine_readable/arrival-pack/.region.tmp; do + p="$w" + while :; do + [ ! -L "$F/$p" ] || die "the carried tree has a symlink at $p, where the mint writes $w; nothing was written" + case "$p" in */*) p="${p%/*}" ;; *) break ;; esac + done +done +[ -f "$DEED" ] || die "the new mint produced no ${REPO}_chora.deed" +cp "$DEED" "$WORK/deed.fresh" + +# declare — the inherited paths, by kind, into the three lists. The deed's +# (paths) lists are line-based: a path holding a newline is refused by name, +# as is one holding byte 0x01, the deed reader's record separator (Codex +# review of #277). +declare_paths() { + : > "$WORK/added.list"; : > "$WORK/removed.list"; : > "$WORK/diverged.list" + N_A=0 N_D=0 N_M=0 + while IFS= read -r -d '' st && IFS= read -r -d '' f; do + is_owned "$f" && continue + case "$f" in *$'\n'*|*$'\001'*) die "cannot declare a carried path containing a newline or byte 0x01: $(printf '%q' "$f"); nothing was written" ;; esac + case "$st" in + A) printf '%s\n' "$f" >> "$WORK/added.list"; N_A=$((N_A + 1)) ;; + D) printf '%s\n' "$f" >> "$WORK/removed.list"; N_D=$((N_D + 1)) ;; + *) printf '%s\n' "$f" >> "$WORK/diverged.list"; N_M=$((N_M + 1)) ;; + esac + done < <(inherited) + # Each path with its category: a path that moves from removed to diverged + # (a generator recreated it) changes the declaration (Codex review of #277). + { sed 's/^/added /' "$WORK/added.list"; sed 's/^/removed /' "$WORK/removed.list" + sed 's/^/diverged /' "$WORK/diverged.list"; } > "$WORK/declared.now" +} +# write_deed — the fresh deed, plus the lineage and the declared lists. Paths +# are written as deed strings (\ and " escaped) by awk, which takes them as +# data: a path holding &, |, " or \ cannot corrupt the clause or the command. +write_deed() { + awk -v slug="$TEMPLATE_SLUG" -v ptree="$PARENT_TREE" \ + -v fa="$WORK/added.list" -v fr="$WORK/removed.list" -v fd="$WORK/diverged.list" ' + function load(file, s, p) { + s = "" + while ((getline p < file) > 0) { + if (p == "") continue + gsub(/\\/, "\\\\", p); gsub(/"/, "\\\"", p) + s = s " \"" p "\"" + } + close(file); return s + } + # insert EXTRA before the first ")" that closes the list opened at "(", + # skipping parentheses inside strings. + function extend(line, extra, i, c, q, open, out) { + open = index(line, "("); q = 0 + for (i = open + 1; i <= length(line); i++) { + c = substr(line, i, 1) + if (q && c == "\\") { i++; continue } + if (c == "\"") q = !q + else if (!q && c == ")") { + out = substr(line, 1, i - 1) extra substr(line, i) + sub(/\( "/, "(\"", out) + return out + } + } + return line + } + BEGIN { A = load(fa); R = load(fr); D = load(fd) } + /^ :added \(/ { $0 = extend($0, A) } + /^ :removed \(/ { $0 = extend($0, R) } + /^ :diverged \(/ { $0 = extend($0, D) } + { print } + /^ :template-tree "[^"]*"$/ { + print " ; minted from a minted repo: its own work since its mint was carried across" + print " :derived-from \"" slug "\"" + print " :derived-from-tree \"" ptree "\"" + } + ' "$WORK/deed.fresh" > "$WORK/deed.new" + cat "$WORK/deed.new" > "$DEED" + grep -q ':derived-from "' "$DEED" || die "could not record :derived-from in $(basename "$DEED")" +} +# regenerate — the derived files follow the merged tree and the new deed. +# They run the CARRIED generators, so what they write is measured afterwards. +regenerate() { + ( cd "$F" && bash .machine_readable/arrival-pack/generate.sh >/dev/null ) \ + || die "the arrival pack could not be regenerated; nothing was written" + # The generator's intermediate data is a build artefact, never carried: it + # goes before the declaration is measured, so a parent that force-added it + # does not leave it declared but absent (Codex review of #277). + rm -f "$F/.machine_readable/arrival-pack/claude-md-data.json" + ( cd "$F" && bash scripts/gen-repo-map.sh --worktree . >/dev/null ) \ + || die "the repository map could not be regenerated; nothing was written" +} +# Declared, written and regenerated until the declaration is stable: a carried +# generator that writes another file is measured, not missed (Codex review of +# #277). Twice settles any generator that is a function of the tree. +declare_paths; write_deed; regenerate +cp "$WORK/declared.now" "$WORK/declared.before" +declare_paths +if ! cmp -s "$WORK/declared.before" "$WORK/declared.now"; then + write_deed; regenerate + cp "$WORK/declared.now" "$WORK/declared.before" + declare_paths + cmp -s "$WORK/declared.before" "$WORK/declared.now" \ + || die "the carried generators keep changing the tree (the declaration does not settle); nothing was written" +fi + +# The result, fixed here: the gates below run on F, and whatever they write +# there (a report, a cache) is theirs, not the result's (Codex review of #277). +FINAL_T="$(g -C "$F" add -A && g -C "$F" write-tree)" +link_objects "$F" + +# The gates a mint enforces on a child, run on the result before this checkout +# is written: a chain mint must not finish with a tree its own CI rejects +# (Codex review of #277). +gate() { local name="$1"; shift; ( cd "$F" && "$@" ) > "$WORK/gate.log" 2>&1 \ + || { tail -n 40 "$WORK/gate.log" >&2; die "the result fails $name (above); nothing was written"; }; } +gate "the placeholder gate" env -u GITHUB_REPOSITORY bash scripts/check-no-placeholders.sh . +for _gate in check-spdx-headers check-mise-language check-container-pins; do + [ -f "$F/scripts/${_gate}.sh" ] && gate "${_gate}" bash "scripts/${_gate}.sh" --root . +done +[ ! -f "$F/www/tests/run-all.sh" ] || gate "the www/ suite" bash www/tests/run-all.sh +gate "just verify" just verify + +# Every check passed: write the result into this checkout, by git. The scratch +# index starts as the parent snapshot (which is what the work tree holds), and +# a two-tree read-tree moves it to the result. This checkout's own git does the +# writing, under its own configuration, as it did the reading (tree_of … own): +# files are checked out as its checkouts are (core.autocrlf, smudge filters), +# from M's objects; it writes no object. +GIDX="$WORK/index.write" +rm -f "$GIDX" +own() { git -C "$G" -c core.fsmonitor=false -c core.hooksPath=/dev/null "$@"; } +wown() { GIT_INDEX_FILE="$GIDX" GIT_OBJECT_DIRECTORY="$M/.git/objects" own "$@"; } +{ wown read-tree "$PARENT_TREE" && wown update-index -q --refresh; } 2> "$WORK/write.err" \ + || { sed 's/^/ /' "$WORK/write.err" >&2; die "cannot read this checkout's state (above); nothing was written"; } +# Every path the result adds must be free here: nothing at it, and no file +# where it needs a directory, that the parent snapshot does not track, ignored +# or not. Checked by name, on the disk, because git's own check treats an +# ignored entry as expendable wherever it can see the rule that ignores it (a +# .gitignore, or this checkout's own excludes) (Codex review of #277). +occupied() { [ -L "$G/$1" ] || { [ -e "$G/$1" ] && [ ! -d "$G/$1" ]; }; } +COLLIDE="$WORK/collide.list"; : > "$COLLIDE" +while IFS= read -r -d '' p; do + if occupied "$p"; then printf '%s\n' "$p" >> "$COLLIDE" + elif [ -d "$G/$p" ] && [ -n "$(wown ls-files -o -z -- ":(literal)$p/" | head -c 1)" ]; then + printf '%s/\n' "$p" >> "$COLLIDE" + fi + d="$p" + while case "$d" in */*) d="${d%/*}"; true ;; *) false ;; esac; do + if occupied "$d" && [ -z "$(wown ls-files -z -- ":(literal)$d")" ]; then printf '%s\n' "$d" >> "$COLLIDE"; fi + done +done < <(g -C "$M" diff -z --no-renames --name-only --diff-filter=A "$PARENT_TREE" "$FINAL_T") +if [ -s "$COLLIDE" ]; then + LC_ALL=C sort -u "$COLLIDE" | sed 's/^/ /' >&2 + die "this checkout cannot take the result without overwriting what it does not track, ignored or not (above); nothing was written" +fi +# Every file the write changes is first checked out into scratch, by this +# checkout's own git, so a smudge filter that fails (a missing LFS, a +# required custom filter) stops the chain here: read-tree -u is not atomic, +# and would have written the files before it (Codex review of #277). +PRE="$WORK/precheckout"; mkdir -p "$PRE" +{ GIT_INDEX_FILE="$WORK/index.pre" GIT_OBJECT_DIRECTORY="$M/.git/objects" own read-tree "$FINAL_T" \ + && g -C "$M" diff -z --no-renames --name-only --diff-filter=AMT "$PARENT_TREE" "$FINAL_T" \ + | GIT_INDEX_FILE="$WORK/index.pre" GIT_OBJECT_DIRECTORY="$M/.git/objects" own checkout-index -f -z --stdin --prefix="$PRE/"; } \ + 2> "$WORK/write.err" \ + || { sed 's/^/ /' "$WORK/write.err" >&2; die "this checkout cannot check the result out (a filter failed, above); nothing was written"; } +if ! wown read-tree -m -u "$PARENT_TREE" "$FINAL_T" 2> "$WORK/write.err"; then + sed 's/^/ /' "$WORK/write.err" >&2 + # It may have written part of the result. The scratch repository holds + # every object of both trees: kept, so the checkout can be put back. + trap - EXIT + echo "chain-mint: the write into this checkout failed part-way (above). Its state before the chain is" >&2 + echo " tree ${PARENT_TREE}, kept in ${M}/.git; to put it back, try" >&2 + echo " GIT_INDEX_FILE=${GIDX} GIT_OBJECT_DIRECTORY=${M}/.git/objects git read-tree -m -u ${FINAL_T} ${PARENT_TREE}" >&2 + exit 1 +fi +echo " chain: carried the parent's own work: ${N_M} edited, ${N_A} added, ${N_D} removed (declared in (variant (paths)))" +echo " chain: ${REPO} minted from ${TEMPLATE_SLUG} (template ${TSLUG}@${PIN:0:12})" diff --git a/scripts/check-invisible-characters.sh b/scripts/check-invisible-characters.sh index abde504..e47efaf 100755 --- a/scripts/check-invisible-characters.sh +++ b/scripts/check-invisible-characters.sh @@ -38,7 +38,7 @@ if ! "$find_bin" "$scan_root" \ -not -path '*/external_corpora/*' -not -path '*/.lake/*' \ -type f \( -name '*.rs' -o -name '*.ex' -o -name '*.exs' -o -name '*.res' \ -o -name '*.js' -o -name '*.ts' -o -name '*.json' -o -name '*.toml' \ - -o -name '*.yml' -o -name '*.yaml' -o -name '*.md' -o -name '*.adoc' \ + -o -name '*.yml' -o -name '*.yaml' -o -name '*.kyaml' -o -name '*.md' -o -name '*.adoc' \ -o -name '*.idr' -o -name '*.zig' -o -name '*.v' -o -name '*.jl' \ -o -name '*.gleam' -o -name '*.hs' -o -name '*.ml' -o -name '*.sh' \ -o -name '*.deed' -o -name '*.k9' -o -name '*.ncl' -o -name '*.txt' -o -name '*.just' \ diff --git a/scripts/check-legs.sh b/scripts/check-legs.sh new file mode 100755 index 0000000..b615d68 --- /dev/null +++ b/scripts/check-legs.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# check-legs.sh — the Rocq and Lean recheck legs (PROOF-POLICY §2). +# +# Checks verification/legs/rocq (Coq, prelude only) and verification/legs/lean +# (Lean core only, no Mathlib, no `require`), then refuses any axiom: +# * every `Print Assumptions` line must read "Closed under the global context"; +# * every `#print axioms` line must read "does not depend on any axioms". +# A leg that does not check, or that rests on an axiom, fails this gate: a +# witness with axioms would have to be recorded PROVEN-WITH-AXIOMS and the +# receipt says it is not (PROOF-POLICY §8.4). +# +# Pins: COQ_VERSION and LEAN_VERSION below, fixed (no environment override); +# scripts/grade-receipt.sh reads them from here into the receipt. +set -euo pipefail +cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." +export LC_ALL="${LC_ALL:-C.UTF-8}" + +COQ_VERSION=8.18.0 +LEAN_VERSION=4.24.0 +fail=0 + +got="$(coqc --version | sed -n 's/^The Coq Proof Assistant, version \([0-9.]*\).*/\1/p')" +[ "$got" = "$COQ_VERSION" ] || { echo "ERROR: expected Coq $COQ_VERSION, found '${got:-none}'" >&2; exit 2; } +got="$(lean --version | sed -n 's/^Lean (version \([0-9.]*\),.*/\1/p')" +[ "$got" = "$LEAN_VERSION" ] || { echo "ERROR: expected Lean $LEAN_VERSION, found '${got:-none}'" >&2; exit 2; } + +# The Lean leg may not depend on anything: no `require` in the lakefile. +if grep -nE '^\s*\[\[require\]\]|^\s*require\b' verification/legs/lean/lakefile.toml; then + echo 'FAIL: the Lean leg declares a dependency (no Mathlib, no libraries)' >&2; fail=1 +fi +# The Rocq leg may not load the standard library. +if grep -nE '^\s*(From\s+\S+\s+)?Require\b' verification/legs/rocq/*.v; then + echo 'FAIL: the Rocq leg loads a library (prelude only)' >&2; fail=1 +fi +# No escape hatches in either leg. +if grep -nE '\b(sorry|admit|Admitted|Axiom|Parameter|axiom)\b' verification/legs/rocq/*.v verification/legs/lean/SecretTypes/*.lean; then + echo 'FAIL: an escape hatch in a leg' >&2; fail=1 +fi + +rocq_out="$(cd verification/legs/rocq && coqc -q SecretTypes.v 2>&1)" || { echo "$rocq_out" >&2; echo 'FAIL: the Rocq leg does not check' >&2; exit 1; } +n_closed="$(grep -c '^Closed under the global context$' <<< "$rocq_out" || true)" +n_print="$(grep -c '^Print Assumptions ' verification/legs/rocq/SecretTypes.v)" +if [ "$n_closed" != "$n_print" ] || grep -vq '^Closed under the global context$' <<< "$rocq_out"; then + echo "$rocq_out" >&2; echo "FAIL: the Rocq leg rests on an axiom ($n_closed of $n_print closed)" >&2; fail=1 +else + echo "PASS: Rocq leg (Coq $COQ_VERSION), $n_print theorems closed under the global context" +fi + +lean_out="$(cd verification/legs/lean && lake build >/dev/null && lake env lean Axioms.lean 2>&1)" \ + || { echo "$lean_out" >&2; echo 'FAIL: the Lean leg does not check' >&2; exit 1; } +n_free="$(grep -c 'does not depend on any axioms$' <<< "$lean_out" || true)" +n_ax="$(grep -c '^#print axioms ' verification/legs/lean/Axioms.lean)" +if [ "$n_free" != "$n_ax" ] || grep -vq 'does not depend on any axioms$' <<< "$lean_out"; then + echo "$lean_out" >&2; echo "FAIL: the Lean leg rests on an axiom ($n_free of $n_ax free)" >&2; fail=1 +else + echo "PASS: Lean leg (Lean $LEAN_VERSION, core only), $n_ax theorems depend on no axioms" +fi + +(( fail == 0 )) || exit 1 +echo 'PASS: both recheck legs' diff --git a/scripts/check-mint-drift.sh b/scripts/check-mint-drift.sh new file mode 100755 index 0000000..cdb6519 --- /dev/null +++ b/scripts/check-mint-drift.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# check-mint-drift.sh — the exact drift gate for a minted repo. +# +# A minted repo differs from its template by design: the mint renders tokens, +# strips instruction blocks, prunes dependabot, splices the identity, seeds +# docs/, regenerates CLAUDE.md and the map. Diffing it against the template +# reported 101 rendered files on a fresh mint (2026-10-20), and no set of +# (normalise) rules could fold that without re-implementing the mint. So this +# gate does not compare against the template. It replays the mint: +# +# child ≡ repo-init(template @ :template-commit, (mint-answers)) +# +# modulo the paths the child declares in its (variant (paths …)) clause. The +# replay runs the REAL recipe from the pinned template commit, with the answers +# and date the child recorded, so a fresh mint is green by construction and +# every later edit to a file the mint produced shows up, by name. +# +# Usage: check-mint-drift.sh [child-dir] +# a clone of the template that contains :template-commit +# [child-dir] the minted repo (default .) +# Needs what a mint needs: just, rustc, nickel, uuidgen. +# Exit: 0 no undeclared drift; 1 drift (listed); 2 usage or cannot replay. +set -euo pipefail +# This script runs git against other repositories (-C does not override +# these), so an inherited location (a hook, another git caller) cannot redirect +# it (CodeRabbit review of #275). +unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY \ + GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_PREFIX GIT_NAMESPACE + +TDIR="${1:?usage: check-mint-drift.sh [child-dir]}" +CDIR="${2:-.}" +TDIR="$(cd "$TDIR" && pwd)"; CDIR="$(cd "$CDIR" && pwd)" +READ="$CDIR/scripts/deed-field.sh" +die() { echo "check-mint-drift: $*" >&2; exit 2; } + +DEED="$(bash "$READ" --find "$CDIR")" || die "no single *_chora.deed in $CDIR" +bash "$READ" --has "$DEED" mint-answers \ + || die "$(basename "$DEED") has no (mint-answers) clause: this repo was minted before answers were recorded, so its mint cannot be replayed" +ans() { bash "$READ" "$DEED" mint-answers "$1" 2>/dev/null || true; } +PIN="$(bash "$READ" "$DEED" provenance template-commit 2>/dev/null || true)" +SLUG="$(bash "$READ" "$DEED" provenance template-repo 2>/dev/null || true)" +[[ "$PIN" =~ ^[0-9a-f]{40}$ ]] || die "(provenance :template-commit) is '${PIN}', not a commit" +git -C "$TDIR" cat-file -e "${PIN}^{commit}" 2>/dev/null \ + || die "$TDIR does not contain template commit $PIN (fetch ${SLUG:-the template}'s history)" + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +# g — git with no user or system configuration and no hooks, so the replay's +# synthetic commit and file set cannot be changed by this machine's settings (a +# global core.hooksPath, init.templateDir, excludesFile or autocrlf) (Codex +# review of #275). The child's own file set is its own git's (files … own). +g() { + GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 git -c user.name=replay -c user.email=replay@example.invalid \ + -c commit.gpgsign=false -c core.hooksPath=/dev/null "$@" +} + +# The child as "Use this template" made it: the pinned tree, one root commit. +R="$WORK/replay" +mkdir -p "$R" +# Written as a checkout writes it, by git (checkout-index), never git archive, +# whose export-ignore and export-subst attributes change the tree (Codex review +# of #275). Through g: no global core.attributesFile shapes it (Codex review +# of #277). +GIT_INDEX_FILE="$WORK/replay.index" g -C "$TDIR" read-tree "$PIN" +# A gitlink has no files to write, so the copy would lose it: refused by name +# rather than compared inexactly (Codex review of #277). +GL="$(GIT_INDEX_FILE="$WORK/replay.index" g -C "$TDIR" ls-files -s | awk -F '\t' '$1 ~ /^160000 / { print " " $2 }')" +[ -z "$GL" ] || die "the template commit carries a submodule (gitlink), which a replay cannot reproduce: +$GL" +GIT_INDEX_FILE="$WORK/replay.index" g -C "$TDIR" checkout-index -a -f --prefix="$R/" +g -C "$R" init -q -b main +g -C "$R" add -A +g -C "$R" commit -q -m "Initial commit" +g -C "$R" remote add origin "git@$(ans forge):$(ans owner)/$(ans repo).git" + +ARCH="$(ans archetype)" +: > "$WORK/gitconfig" +mkdir -p "$WORK/cfg" +if ! (cd "$R" && env -i PATH="$PATH" HOME="$HOME" TMPDIR="${TMPDIR:-/tmp}" \ + ${RUSTUP_HOME:+RUSTUP_HOME="$RUSTUP_HOME"} ${CARGO_HOME:+CARGO_HOME="$CARGO_HOME"} \ + ${RSR_MINT_TOOL_CACHE:+RSR_MINT_TOOL_CACHE="$RSR_MINT_TOOL_CACHE"} \ + XDG_CONFIG_HOME="$WORK/cfg" GIT_CONFIG_GLOBAL="$WORK/gitconfig" GIT_CONFIG_NOSYSTEM=1 \ + RSR_NON_INTERACTIVE=1 RSR_MINT_REPLAY=1 \ + RSR_TEMPLATE_URL="$TDIR" RSR_TEMPLATE_COMMIT="$PIN" RSR_MINT_DATE="$(ans mint-date)" \ + ${SLUG:+RSR_TEMPLATE_SLUG="$SLUG"} \ + PROJECT_NAME="$(ans project-name)" REPO="$(ans repo)" OWNER="$(ans owner)" \ + AUTHOR="$(ans author)" AUTHOR_EMAIL="$(ans author-email)" AUTHOR_ORG="$(ans author-org)" \ + AUTHOR_EMAIL_ALT="$(ans author-email-alt)" AUTHOR_ORCID="$(ans author-orcid)" \ + PROJECT_DESCRIPTION="$(ans description)" FORGE="$(ans forge)" \ + SECURITY_EMAIL="$(ans security-email)" CONDUCT_EMAIL="$(ans conduct-email)" \ + PROJECT_TYPE="$(ans project-type)" WEBSITE="$(ans website)" \ + OPENSSF_BP_ID="$(ans openssf-bp-id)" SERVICE_NAME="$(ans service-name)" \ + PORT="$(ans port)" REGISTRY="$(ans registry)" RSR_PACKAGE_UUID="$(ans package-uuid)" \ + just repo-init ${ARCH:+"$ARCH"} "$WORK/replay.log" 2>&1; then + tail -n 30 "$WORK/replay.log" >&2 + die "the replay of the mint failed (above); nothing was compared" +fi + +# Declared divergence, from the CHILD's (variant (paths …)) clause, kept by +# category: each one is a claim the gate checks, not a blanket exemption +# (Codex review of #275). An entry ending in / covers a directory. +# added must exist here, and must NOT be something the mint produces; +# removed must be absent here; +# diverged (and -pending-upstream) may differ in content, but must still +# exist here, and must be something the mint produces; +# operational-state excluded from comparison altogether (ADR-0002). +section() { bash "$READ" --list "$DEED" variant/paths "$1" 2>/dev/null || true; } +ADDED="$(section added)" +REMOVED="$(section removed)" +DIVERGED="$(section diverged; section diverged-pending-upstream)" +OPSTATE="$(section operational-state)" +# match_len P LIST — the length of the longest entry in LIST covering P (0: +# none); an exact path outranks any directory that holds it. +match_len() { + local p="$1" e best=0 n + while IFS= read -r e; do + [ -n "$e" ] || continue + case "$e" in + */) case "$p" in "$e"*) n=${#e} ;; *) continue ;; esac ;; + *) [ "$p" = "$e" ] || continue; n=$(( ${#e} + 1 )) ;; + esac + [ "$n" -gt "$best" ] && best=$n + done <<< "$2" + echo "$best" +} +# declared P — the category of the MOST SPECIFIC declaration covering P, so a +# file added under a directory the mint removes is an addition, not a revived +# removal (Codex review of #277). Ties keep removed > added > diverged > +# operational-state. +declared() { + local r a d o + r=$(match_len "$1" "$REMOVED"); a=$(match_len "$1" "$ADDED") + d=$(match_len "$1" "$DIVERGED"); o=$(match_len "$1" "$OPSTATE") + if [ $((r + a + d + o)) -eq 0 ]; then echo none + elif [ "$r" -ge "$a" ] && [ "$r" -ge "$d" ] && [ "$r" -ge "$o" ]; then echo removed + elif [ "$a" -ge "$d" ] && [ "$a" -ge "$o" ]; then echo added + elif [ "$d" -ge "$o" ]; then echo diverged + else echo opstate + fi +} +# files DIR [own] — the repository's own files: tracked, or untracked and not +# ignored. With "own", DIR is listed under its own git configuration, as +# `git add -A` there would commit it (a file only a global excludesFile ignores +# is not this repo's); the replay is listed under none (CodeRabbit review of +# #275). The lists below are line-based, so a path holding a newline is +# refused, loudly. +files() { + local gx=g; [ "${2:-}" = own ] && gx=git + "$gx" -C "$1" -c core.hooksPath=/dev/null ls-files -z --cached --others --exclude-standard \ + | while IFS= read -r -d '' f; do + case "$f" in *$'\n'*) echo "check-mint-drift: cannot check a path containing a newline: $(printf '%q' "$f")" >&2; exit 2 ;; esac + # if, not &&: an absent last path must not fail the pipeline (CodeRabbit). + if [ -e "$1/$f" ] || [ -L "$1/$f" ]; then printf '%s\n' "$f"; fi + done \ + | LC_ALL=C sort -u +} +# Membership is the repository file set on each side, not bare existence: an +# ignored artifact the replay also generates (arrival-pack output) is not a +# file the mint produced, even if a child force-adds it (Codex review of #275). +CLIST="$WORK/child.files"; RLIST="$WORK/replay.files" +files "$CDIR" own > "$CLIST" +files "$R" > "$RLIST" +in_child() { grep -qxF -- "$1" "$CLIST"; } +in_replay() { grep -qxF -- "$1" "$RLIST"; } +# same A B — identical as git would record them: content, the executable bit, +# and for a symlink its target (cmp alone follows links and ignores modes). +same() { + if [ -L "$1" ] || [ -L "$2" ]; then + [ -L "$1" ] && [ -L "$2" ] && [ "$(readlink "$1")" = "$(readlink "$2")" ] + return + fi + { [ -x "$1" ] && [ -x "$2" ]; } || { [ ! -x "$1" ] && [ ! -x "$2" ]; } || return 1 + cmp -s "$1" "$2" +} + +DRIFT=0 +report() { DRIFT=1; echo "DRIFT: $*"; } +while IFS= read -r f; do + kind="$(declared "$f")" + if [ "$kind" = removed ]; then + report "$f exists here but is declared removed" + elif [ "$kind" = added ]; then + if in_replay "$f"; then report "$f is declared added, but the mint produces it (declare it diverged)"; fi + elif [ "$kind" = diverged ]; then + # A diverged path is one the mint produces and this repo changed; a new + # path declared diverged is an undeclared addition (Codex review of #275). + if ! in_replay "$f"; then report "$f is declared diverged, but the mint did not produce it (declare in paths.added)"; fi + elif [ "$kind" = opstate ]; then + : + elif ! in_replay "$f"; then + report "$f exists here but the mint did not produce it (declare in paths.added)" + elif ! same "$R/$f" "$CDIR/$f"; then + report "$f differs from what the mint produced, in content or mode (declare in paths.diverged, or restore it)" + fi +done < "$CLIST" +while IFS= read -r f; do + in_child "$f" && continue + kind="$(declared "$f")" + [ "$kind" = removed ] || [ "$kind" = opstate ] && continue + if [ "$kind" = diverged ]; then + report "$f is declared diverged but is absent here (declare it removed)" + else + report "$f was produced by the mint but is absent here (declare in paths.removed)" + fi +done < "$RLIST" +# Every declared addition must actually be here. +while IFS= read -r e; do + [ -n "$e" ] || continue + case "$e" in + */) P="$e" awk 'index($0, ENVIRON["P"]) == 1 { f = 1; exit } END { exit !f }' "$CLIST" \ + || report "declared-added directory $e has no files here" ;; + *) in_child "$e" || report "declared-added $e is missing" ;; + esac +done <<< "$ADDED" + +if [ "$DRIFT" -eq 0 ]; then + echo "PASS: identical to a replay of the mint (${SLUG}@${PIN:0:12}, $(ans mint-date)), modulo declared variant paths" +else + echo "FAIL: undeclared drift from a replay of the mint (${SLUG}@${PIN:0:12}) — declare it in the deed's (variant (paths …)) clause, or restore it" + exit 1 +fi diff --git a/scripts/check-mise-language.sh b/scripts/check-mise-language.sh index ac37905..56a8cf7 100644 --- a/scripts/check-mise-language.sh +++ b/scripts/check-mise-language.sh @@ -120,7 +120,7 @@ if [ -z "$TOOLS" ] && [ "$DETECT" -eq 1 ]; then # declaring `erlang elixir` was failed for not declaring `mix`/`erl`, and a # Rust tree declaring `rust` was failed for declaring a tool the engine # installs. A mirror that disagrees with the engine is a bug in the mirror. - _t="just shellcheck trivy" + _t="just shellcheck yq trivy" for l in $LANGS; do case "$l" in rust) _t="$_t rust" ;; diff --git a/scripts/check-no-a2ml.sh b/scripts/check-no-a2ml.sh index fd9a514..29076d3 100755 --- a/scripts/check-no-a2ml.sh +++ b/scripts/check-no-a2ml.sh @@ -6,6 +6,14 @@ # format is retired: a repo's facts are clauses of its deed (_chora.deed, # deed.abnf) and its contracts are .k9 / .k9.ncl files. # +# A repository that still carries legacy A2ML lists each file, by exact path, +# in the shrink-only ledger .machine_readable/a2ml-legacy-allow.txt (owner +# decision 2026-11-04); listed files and anything under 9-archive/ are not +# reported here. The ledger's own integrity (a listed file that is gone, a +# glob entry, an entry the base ledger did not list) is the canonical gate's +# job, standards scripts/check-a2ml-archived.sh, run by the deed lane +# (.github/hooks/validate-deed.sh). +# # Usage: check-no-a2ml.sh [REPO_ROOT] # Inspects tracked files when REPO_ROOT is a git work tree, and every file # under it (minus .git/) otherwise, so an untracked mint is covered too. @@ -21,10 +29,19 @@ else FOUND=$(find "$REPO_ROOT" -path '*/.git' -prune -o -type f -iname '*.a2ml' -printf '%P\n') fi +LEDGER="$REPO_ROOT/.machine_readable/a2ml-legacy-allow.txt" +if [ -n "$FOUND" ]; then + FOUND=$(printf '%s\n' "$FOUND" | grep -v '^9-archive/' || true) +fi +if [ -n "$FOUND" ] && [ -f "$LEDGER" ]; then + LISTED=$(sed -e 's/#.*//' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' "$LEDGER" | grep -v '^$' || true) + FOUND=$(printf '%s\n' "$FOUND" | grep -vxF -f <(printf '%s\n' "$LISTED") || true) +fi + if [ -z "$FOUND" ]; then - echo "PASS: no .a2ml files in the inspected repository" + echo "PASS: no .a2ml files outside the legacy ledger and 9-archive/" exit 0 fi -echo "FAIL: .a2ml is a retired format; move these facts into the repo deed or a .k9 contract:" +echo "FAIL: .a2ml is a retired format; move these facts into the repo deed or a .k9 contract (or, for a file that predates the ledger, list it in .machine_readable/a2ml-legacy-allow.txt):" printf ' %s\n' $FOUND exit 1 diff --git a/scripts/check-required-contexts.sh b/scripts/check-required-contexts.sh index 235f28b..ebf16ec 100755 --- a/scripts/check-required-contexts.sh +++ b/scripts/check-required-contexts.sh @@ -7,7 +7,8 @@ # .machine_readable/required-contexts-allow.txt (and any non-empty # required_status_checks context in .github/rulesets/*.json) resolves to # an existing workflow under .github/workflows/ that runs on pull_request -# without path-filtering that could strand a PR (#219 defect class). +# without path-filtering that could strand a PR (#219 defect class), and +# that has a job whose check-run name is exactly that context. # 2. No workflow in .github/workflows/ invokes an admin bypass (`gh pr merge # --admin`) — enforcing cicd-squabbler's core `squabble != bypass` # invariant statically alongside the runtime binary. @@ -57,6 +58,93 @@ function fail(msg) { errors++; } +/** + * Expand a job's name over a literal `strategy.matrix`, the way GitHub names + * the check runs: a name that references `matrix.` is evaluated per + * combination; any other name gets the combination's values appended, + * `name (v1, v2)`. Returns null when that cannot be done statically: + * include/exclude, a computed matrix or value, or another expression in the + * name. + */ +function matrixNames(name, job) { + const matrix = job?.strategy?.matrix; + const exprs = [...name.matchAll(/\$\{\{\s*(.*?)\s*\}\}/g)].map((m) => m[1]); + if (matrix === undefined) return exprs.length === 0 ? [name] : null; + if (matrix === null || typeof matrix !== "object" || Array.isArray(matrix)) return null; + if ("include" in matrix || "exclude" in matrix) return null; + const keys = Object.keys(matrix); + // GitHub reads matrix property names case-insensitively. + const byLower = new Map(keys.map((k) => [k.toLowerCase(), k])); + const literal = (v) => v !== null && typeof v !== "object" && !String(v).includes("${{"); + if (keys.length === 0 || byLower.size !== keys.length) return null; + if (keys.some((k) => !Array.isArray(matrix[k]) || !matrix[k].every(literal))) return null; + // A dot dereference needs a property starting with a letter or underscore. + const prop = /^matrix\.([A-Za-z_][A-Za-z0-9_-]*)$/; + if (exprs.some((e) => !prop.test(e) || !byLower.has(e.slice(7).toLowerCase()))) return null; + let combos = [{}]; + for (const k of keys) combos = combos.flatMap((c) => matrix[k].map((v) => ({ ...c, [k]: String(v) }))); + return combos.map((c) => + exprs.length > 0 + ? name.replace(/\$\{\{\s*matrix\.(.*?)\s*\}\}/g, (_, k) => c[byLower.get(k.toLowerCase())]) + : `${name} (${keys.map((k) => c[k]).join(", ")})`); +} + +/** Return the event names a parsed workflow triggers on. */ +function triggersOf(doc) { + const onBlock = doc?.on ?? doc?.true; + return typeof onBlock === "string" + ? [onBlock] + : Array.isArray(onBlock) + ? onBlock.map(String) + : onBlock && typeof onBlock === "object" + ? Object.keys(onBlock) + : []; +} + +/** + * Return the check-run names a parsed workflow's jobs produce, adding to + * `unresolved` each job whose names cannot be known statically. A called + * file counts only when it declares `on: workflow_call`, as GitHub requires; + * GitHub nests at most ten workflows (the caller and nine reusables), and a + * call back into the chain is a cycle GitHub refuses. A same-repository call + * is spelled `./.github/workflows/…` or `$/.github/workflows/…`; a caller + * with a literal matrix produces one ` / ` per run. + */ +function producedNames(doc, unresolved, chain = []) { + const jobs = doc?.jobs && typeof doc.jobs === "object" ? doc.jobs : {}; + const names = []; + for (const [id, job] of Object.entries(jobs)) { + const name = String(job?.name ?? id); + const uses = typeof job?.uses === "string" ? job.uses : null; + // GitHub runs only files directly in .github/workflows/: no subdirectory, + // no traversal. + const local = uses?.match(/^[.$]\/(\.github\/workflows\/[^/@\\]+\.ya?ml)$/); + const expanded = matrixNames(name, job); + if (expanded === null) { + unresolved.push(name); + } else if (uses === null) { + // An ordinary job needs a runner; without `runs-on` GitHub creates no + // check run for it. + const runsOn = job?.["runs-on"]; + if (runsOn !== undefined && runsOn !== null && runsOn !== "") names.push(...expanded); + } else if (local) { + const called = join(root, local[1]); + if (chain.includes(called) || chain.length >= 10) { + unresolved.push(name); + continue; + } + if (!existsSync(called)) continue; + const calledDoc = Bun.YAML.parse(readFileSync(called, "utf8")); + if (!triggersOf(calledDoc).includes("workflow_call")) continue; + const inners = producedNames(calledDoc, unresolved, [...chain, called]); + for (const caller of expanded) { + for (const inner of inners) names.push(`${caller} / ${inner}`); + } + } + } + return names; +} + // 1. Parse mapping file: " => <.github/workflows/foo.yml>" const mapped = new Map(); const lines = readFileSync(allowPath, "utf8").split(/\r?\n/); @@ -102,15 +190,7 @@ for (const [ctx, relWf] of mapped.entries()) { } const doc = Bun.YAML.parse(readFileSync(fullWf, "utf8")); const onBlock = doc?.on ?? doc?.true; - const triggers = - typeof onBlock === "string" - ? [onBlock] - : Array.isArray(onBlock) - ? onBlock.map(String) - : onBlock && typeof onBlock === "object" - ? Object.keys(onBlock) - : []; - if (!triggers.includes("pull_request")) { + if (!triggersOf(doc).includes("pull_request")) { fail(`required context '${ctx}' producer '${relWf}' does not trigger on pull_request`); continue; } @@ -123,6 +203,21 @@ for (const [ctx, relWf] of mapped.entries()) { fail(`required context '${ctx}' producer '${relWf}' filters pull_request by paths — strands PRs that do not touch those paths`); } } + // A job's check-run name is its `name:` (or its id when unnamed), expanded + // over a literal matrix. A job that calls a reusable workflow produces + // ` / ` instead, one per called job; a remote + // reusable cannot be read from here, so it produces nothing this gate can + // certify. Without a job of exactly the context's name the context is never + // produced, however the workflow is triggered; a job whose names cannot be + // known statically is named, never assumed to match. + const unresolved = []; + const checkNames = producedNames(doc, unresolved, [fullWf]); + if (!checkNames.includes(ctx)) { + const why = unresolved.length > 0 + ? `; not resolvable statically (matrix include/exclude, computed values, or other expressions in the name): ${unresolved.join(", ")}` + : ""; + fail(`required context '${ctx}' producer '${relWf}' has no job named '${ctx}' (jobs: ${checkNames.join(", ") || "none"})${why}`); + } } // 4. Enforce squabble != bypass across all workflows: no workflow may call diff --git a/scripts/check-variant-drift.sh b/scripts/check-variant-drift.sh index 0b28991..6ae9e51 100755 --- a/scripts/check-variant-drift.sh +++ b/scripts/check-variant-drift.sh @@ -36,17 +36,21 @@ PARENT_NAME="${PARENT_SLUG##*/}" PIN=$(bash "$READ" "$DEED" variant parent-pin || true) [[ "$PIN" =~ ^[0-9a-f]{40}$ ]] || PIN="" -# A minted repo is not yet a target for this gate: the mint renders tokens and -# rewrites files, so most spine files differ from the parent by design, and the -# (normalise) rules do not fold the mint's answers (ADR-0003). repo-init no -# longer recommends arming it there. Say so before the findings, rather than -# leave a reader to "fix" ~100 expected lines by declaring them diverged; the -# verdict itself is unchanged (a gate that turns itself green is no gate). +# The template comparison below cannot judge a minted repo: the mint renders +# tokens and rewrites files, so most spine files differ by design. ROLE=$(bash "$READ" "$DEED" variant role 2>/dev/null || true) +# A minted repo that recorded its answers is checked EXACTLY: the mint is +# replayed from the pinned template and the child is diffed against that +# (scripts/check-mint-drift.sh). The approximate comparison below remains for +# variant templates, and for children minted before (mint-answers) existed. +if [ "$ROLE" = "minted-repo" ] && bash "$READ" --has "$DEED" mint-answers; then + exec bash "$(dirname "$READ")/check-mint-drift.sh" "$PARENT_DIR" "$SELF_DIR" +fi if [ "$ROLE" = "minted-repo" ]; then - echo "NOTE: $DEED records :role \"minted-repo\". Rendered files are expected to differ" - echo " from the parent until (normalise) folds the mint's answers; check the" - echo " template pin with scripts/check-template-conformance.sh instead." + echo "NOTE: $DEED records :role \"minted-repo\" but no (mint-answers): it was minted" + echo " before answers were recorded, so the mint cannot be replayed and rendered" + echo " files are expected to differ below. Check the template pin with" + echo " scripts/check-template-conformance.sh." fi # section_paths SECTION — the path list :SECTION of the (variant (paths …)) diff --git a/scripts/check-workflow-edges.sh b/scripts/check-workflow-edges.sh index edd6596..07a8f19 100755 --- a/scripts/check-workflow-edges.sh +++ b/scripts/check-workflow-edges.sh @@ -147,7 +147,7 @@ reusable_source() { echo "check-workflow-edges: reusable '${call}' is not in EDGES_REUSABLE_DIR" >&2 return 2 fi - elif ! curl -fsSL --retry 3 --max-time 30 "https://raw.githubusercontent.com/$owner/$repo/$ref/$path" -o "$out"; then + elif ! curl --proto =https --proto-redir =https -fsSL --retry 3 --max-time 30 "https://raw.githubusercontent.com/$owner/$repo/$ref/$path" -o "$out"; then echo "check-workflow-edges: could not read reusable '${call}' at its pinned commit; the edge through it cannot be verified" >&2 return 2 fi diff --git a/scripts/check.sh b/scripts/check.sh index e9c7237..b3f8560 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -69,3 +69,21 @@ for file in "${rejects[@]}"; do echo "PASS: rejected $name at its invalid declaration" done echo "PASS: core proofs and all ${#rejects[@]} expected rejections" + +# Flag control: the canonical invocation must actually be in force. The +# fixture is a correct proof that only --cubical-compatible refuses (it needs +# constructor injectivity, -WUnsupportedIndexedMatch, fatal under -W error). +# If the flag were dropped from scripts/proof-flags.sh, the fixture would be +# accepted and this step would fail. +control=tests/flag-control/IndexedMatch.agda +log=_build/check-logs/IndexedMatch.log +if "$prover" "${flags[@]}" -i tests/flag-control "$control" >"$log" 2>&1; then + echo 'ERROR: flag control accepted: --cubical-compatible is not in force' >&2 + exit 1 +fi +if ! grep -q 'UnsupportedIndexedMatch' "$log"; then + cat "$log" + echo 'ERROR: flag control failed for a reason other than --cubical-compatible' >&2 + exit 1 +fi +echo 'PASS: flag control (--cubical-compatible is in force: an injectivity match is refused)' diff --git a/scripts/format-container-kyaml.sh b/scripts/format-container-kyaml.sh new file mode 100644 index 0000000..e85d2cf --- /dev/null +++ b/scripts/format-container-kyaml.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# format-container-kyaml.sh — reformat the container KYAML configs after +# their tokens are filled. +# +# The template quotes every token, keys included, because a brace token is +# not a plain YAML scalar. Once `repo-init` or `container-init` fills one, +# the quoting is no longer what canonical KYAML writes (a filled service-name +# key needs none), and Estate Rules' "Converted YAML stays KYAML" fails the +# child. Both recipes call this after their substitution pass. +# +# The formatter needs a yq that writes KYAML (mikefarah yq; mise.toml provides +# it). Both recipes run `--preflight` BEFORE they write anything, so a missing +# yq stops them with the tree untouched instead of half-minted. +# +# The values fill double-quoted KYAML scalars, so a quote or backslash in one +# would leave a file the formatter refuses after the tree is written. Both +# recipes run `--check-values` on the answers before they write anything. +# +# Usage: format-container-kyaml.sh [--preflight] [REPO_ROOT] +# format-container-kyaml.sh --check-values SERVICE_NAME PORT REGISTRY +# --preflight write nothing; fail when configs are present and no +# KYAML-capable yq is on PATH. +# --check-values fail unless SERVICE_NAME is a DNS label (lower-case +# letters, digits and inner hyphens, at most 63), PORT is +# 1-65535 and REGISTRY is an image prefix (a host or an +# [IPv6] address in RFC 4291 text, an optional :port from +# 1 to 65535, then +# lower-case path components). +# Exit: 0 = formatted (or nothing to format: an archetype may prune +# build/container/), or values accepted; 1 = no KYAML-capable yq, a +# value refused, or the formatter refused a file (unparseable, data +# change), with the reason on stderr. +set -euo pipefail +if [ "${1:-}" = --check-values ]; then + name="${2:-}" port="${3:-}" registry="${4:-}" bad=0 + # The service name is an image name component, a compose service key, a + # bare TOML table key (stapeln/compose.toml) and the backend's hostname on + # the container network (http://NAME:PORT). A DNS label is valid as all + # four; a dot would nest the TOML table and an underscore is no hostname. + [[ "$name" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] \ + || { echo "error: service name '$name' is not a DNS label (lower-case letters, digits and inner hyphens, at most 63)." >&2; bad=1; } + in_port_range() { [[ "$1" =~ ^[0-9]{1,5}$ ]] && [ "$((10#$1))" -ge 1 ] && [ "$((10#$1))" -le 65535 ]; } + in_port_range "$port" \ + || { echo "error: port '$port' is not a number from 1 to 65535." >&2; bad=1; } + # A registry is the image prefix: a host (DNS labels, or a bracketed IPv6 + # address), an optional :port, then lower-case path components, as the + # distribution reference grammar has it. + label='[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?' + host="($label(\.$label)*|\[[A-Fa-f0-9:]+\])" + comp='[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*' + # The grammar takes any digits as the port; a usable one is 1-65535. An + # IPv6 host ends in "]", so a trailing ":digits" before the path is a port. + registry_port="" + if [[ "${registry%%/*}" =~ :([0-9]+)$ ]]; then registry_port="${BASH_REMATCH[1]}"; fi + # The grammar's bracket form is any hex and colons; an address is RFC 4291 + # text: eight groups of 1-4 hex digits, or fewer around a single "::". + is_ipv6() { + local a="$1" g n=0 groups + [[ "$a" =~ ^[0-9A-Fa-f:]+$ ]] || return 1 + [[ "$a" != *:::* ]] || return 1 + [[ "$a" != :* || "$a" == ::* ]] || return 1 + [[ "$a" != *: || "$a" == *:: ]] || return 1 + IFS=: read -ra groups <<< "$a" + for g in "${groups[@]}"; do + [ -n "$g" ] || continue + [ "${#g}" -le 4 ] || return 1 + n=$((n + 1)) + done + case "$a" in + *::*::*) return 1 ;; + *::*) [ "$n" -le 7 ] ;; + *) [ "$n" -eq 8 ] ;; + esac + } + registry_v6="" + if [[ "$registry" == \[* ]]; then registry_v6="${registry#\[}"; registry_v6="${registry_v6%%\]*}"; fi + if ! [[ "$registry" =~ ^$host(:[0-9]+)?(/$comp)*$ ]] \ + || { [ -n "$registry_port" ] && ! in_port_range "$registry_port"; } \ + || { [[ "$registry" == \[* ]] && ! is_ipv6 "$registry_v6"; }; then + echo "error: registry '$registry' is not an image prefix: a host or [IPv6] address, an optional :port from 1 to 65535, then lower-case path components (letters, digits, . _ -)." >&2; bad=1 + fi + exit "$bad" +fi +PREFLIGHT=0 +if [ "${1:-}" = --preflight ]; then PREFLIGHT=1; shift; fi +cd "${1:-.}" + +files=() +for f in build/container/compose.yaml build/container/compose.example.yaml \ + build/container/stapeln/.gatekeeper.yaml; do + if [ -f "$f" ]; then files+=("$f"); fi +done +[ "${#files[@]}" -gt 0 ] || exit 0 + +if ! command -v yq >/dev/null 2>&1 || ! printf 'a: 1\n' | yq -p yaml -o kyaml '.' >/dev/null 2>&1; then + { + echo "error: the container configs need a yq that writes KYAML (mikefarah yq" + echo " v4.53.3+) once their tokens are filled, and none is on PATH." + echo " Run 'mise install' (mise.toml provides yq), or install mikefarah yq." + } >&2 + exit 1 +fi +[ "$PREFLIGHT" -eq 0 ] || exit 0 + +if ! bash scripts/kyaml-format.sh "${files[@]}"; then + echo "error: the KYAML formatter refused a container config (see above)." >&2 + exit 1 +fi diff --git a/scripts/gen-repo-map.sh b/scripts/gen-repo-map.sh index befe940..bda9c95 100755 --- a/scripts/gen-repo-map.sh +++ b/scripts/gen-repo-map.sh @@ -47,10 +47,19 @@ repo_files() { | while IFS= read -r -d '' f; do [ -e "$f" ] || [ -L "$f" ] && printf '%s\n' "$f"; done \ | sort -u else - git ls-files -- "$@" + # NUL-separated: plain output C-quotes a path holding a quote or a + # non-ASCII byte, which then read as a root entry named `"notes`. + git ls-files -z -- "$@" | tr "\0" "\n" fi } +# The map is built from lines, so a path holding a newline would read as two +# bogus entries (Codex review of #277). Refused by name, as the drift tooling does. +if [ "$MODE" = worktree ]; then NL_SET=(--cached --others --exclude-standard); else NL_SET=(--cached); fi +NL_PATHS="$(git ls-files -z "${NL_SET[@]}" | while IFS= read -r -d '' f; do + case "$f" in *$'\n'*) printf ' %q\n' "$f" ;; esac; done)" +[ -z "$NL_PATHS" ] || { printf 'ERROR: cannot map a path containing a newline:\n%s\n' "$NL_PATHS" >&2; exit 2; } + [ -f "$ALLOW" ] || { echo "ERROR: $ALLOW not found" >&2; exit 2; } mkdir -p "$(dirname "$OUT")" diff --git a/scripts/grade-receipt.sh b/scripts/grade-receipt.sh index 47d2ea9..258e571 100755 --- a/scripts/grade-receipt.sh +++ b/scripts/grade-receipt.sh @@ -4,20 +4,25 @@ # # grade-receipt.sh [--check] # -# Writes (or checks) verification/receipts/agda-pwg_praxis.deed: the machine -# record of this repository's Agda claims and their Proof Witness Grades -# (standards/3-practice/PROOF-POLICY.adoc §8, owner ruling 2026-11-04). +# Writes (or checks) verification/receipts/agda_praxis.deed: the machine record +# of this repository's Agda claims, each with its Proof Witness Grade. # -# PWG-3 (§8.2): the Agda proof is PROVEN under the canonical §2.1 invocation -# (agda --safe --without-K --cubical-compatible, here with stronger flags -# besides) and no Rocq or Lean recheck leg counts yet. PWG-3 is not a -# discharge (§8.4): every PWG-3 theorem still owes both legs. +# Proof Witness Grades: standards/3-practice/PROOF-POLICY.adoc §8 (owner +# ruling 2026-11-04). PWG-3: the Agda proof is PROVEN under the §2.1 +# invocation. PWG-2ii: PWG-3, and both the Rocq and the Lean leg count: +# kernel-checked (scripts/check-legs.sh, no axioms) and RECONCILED against +# the Agda statement (verification/legs/README.adoc). Not PWG-1: that needs +# a per-commit receipt of each checker's observed result, which this file +# is not. The record carries the +# §8.5 fields: pwg, foundation-diverse, and for each leg its route, its §5 +# token and its §2.2 correspondence state. A grade is derived, never +# asserted (§8.4): it is computed below from the legs, not typed in. # # The receipt is DERIVED from the tree, never typed by hand, except for the # claim list below, which is cross-checked against the claim IDs in # PROOF-STATUS.adoc in both directions. That makes two drifts impossible: # * a module added or changed without the receipt following it, and -# * a claim added to PROOF-STATUS.adoc without a ledger token and a grade. +# * a claim added to PROOF-STATUS.adoc without a ledger token and its legs. # # What the receipt deliberately does NOT do: it does not run Agda. It records # the gate, the flags and the pinned versions; the gate itself runs in CI @@ -28,7 +33,7 @@ set -euo pipefail cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." . scripts/proof-flags.sh -out=verification/receipts/agda-pwg_praxis.deed +out=verification/receipts/agda_praxis.deed mode= commit_override= while (( $# )); do @@ -46,31 +51,51 @@ while (( $# )); do done # ── The claim list ──────────────────────────────────────────────────────────── -# id | ledger token (README-EXPLAINME-STANDARD §5 vocabulary) | PWG (PROOF-POLICY -# §8.5: 3 | 2i | 2ii | 1 | ungraded; n/a for a checked demonstration, which -# is not a theory theorem and so not on the ladder, §8.1) | Rocq leg (§2) | Lean leg. +# id | ledger token (README-EXPLAINME-STANDARD §5 vocabulary) | kind | +# Rocq route | Rocq §2.2 state | Lean route | Lean §2.2 state. # Tokens are the closed vocabulary: proven | proven-with-postulates | tested-only -# | argued | open. Legs are: transported | re-proved | unavailable. +# | argued | open. Kind is theorem (graded, PROOF-POLICY §8.1) or +# demonstration (a checked example, below the ladder: `ungraded`). Leg routes +# are PROOF-POLICY §2 and §8.5: transported | re-proved | waived | unavailable. +# States are §2.2: AGDA-VERIFIED | COQ-CHECKED | LEAN-CHECKED | BRIDGE-PENDING +# | RECONCILED | COUNTEREXAMPLE | QUARANTINED. Only RECONCILED counts (§8.2). claims() { cat <<'ROWS' -N-1|proven|3|unavailable|unavailable -N-2|proven|3|unavailable|unavailable -N-3|proven|3|unavailable|unavailable -N-4|tested-only|n/a|n/a|n/a -N-5|tested-only|n/a|n/a|n/a -N-6|tested-only|n/a|n/a|n/a -N-7|proven|3|unavailable|unavailable -N-8|proven|3|unavailable|unavailable -N-9|proven|3|unavailable|unavailable +N-1|proven|theorem|re-proved|RECONCILED|re-proved|RECONCILED +N-2|proven|theorem|re-proved|RECONCILED|re-proved|RECONCILED +N-3|proven|theorem|re-proved|RECONCILED|re-proved|RECONCILED +N-4|tested-only|demonstration|n/a|n/a|n/a|n/a +N-5|tested-only|demonstration|n/a|n/a|n/a|n/a +N-6|tested-only|demonstration|n/a|n/a|n/a|n/a +N-7|proven|theorem|re-proved|RECONCILED|re-proved|RECONCILED +N-8|proven|theorem|re-proved|RECONCILED|re-proved|RECONCILED +N-9|proven|theorem|re-proved|RECONCILED|re-proved|RECONCILED ROWS } +# PROOF-POLICY §8.2: derive the grade from the Agda token and the legs. A leg +# counts only when it is RECONCILED (§2.2). +# Print the PWG for one claim row's token, kind and two legs' §2.2 states. +pwg_of() { + local token="$1" kind="$2" coq_state="$3" lean_state="$4" counted=0 + [[ "$kind" == theorem ]] || { printf 'ungraded'; return; } + [[ "$token" == proven || "$token" == proven-with-postulates ]] || { printf 'ungraded'; return; } + # A leg counts on its §2.2 state, never on its route: a transported or + # re-proved leg still BRIDGE-PENDING does not count. + [[ "$coq_state" == RECONCILED ]] && counted=$((counted + 1)) + [[ "$lean_state" == RECONCILED ]] && counted=$((counted + 1)) + case "$counted" in 0) printf '3' ;; 1) printf '2i' ;; 2) printf '2ii' ;; esac +} + # ── Derived facts ───────────────────────────────────────────────────────────── repo="hyperpolymath/secret-types" source_commit="${commit_override:-$(git rev-parse --short=12 HEAD 2>/dev/null || printf 'unknown')}" agda_version="$(proof_agda_pin)" agda_from="$(proof_agda_provenance)" flag_line="$(proof_flags_line)" +# The standards commit this receipt reads PROOF-POLICY at (section 7 asks a +# gate to cite the policy by repository, path and full 40-character commit). +policy_commit="9bdf864cc7f2c8e24b4df84bf00d04158eee3ad5" # Print the Agda module paths under src/SecretTypes in sorted order. modules() { find src/SecretTypes -name '*.agda' | LC_ALL=C sort; } @@ -105,14 +130,47 @@ check_claim_ids() { fi } +# The leg sources, in sorted order: everything a leg's kernel reads. +leg_sources() { + find verification/legs/rocq -name '*.v' | LC_ALL=C sort + find verification/legs/lean -name '*.lean' -not -path '*/.lake/*' | LC_ALL=C sort + printf '%s\n' verification/legs/lean/lakefile.toml verification/legs/lean/lean-toolchain +} + +# Print the (legs) block: per leg, the pinned checker, its invocation, the +# axiom check, and the sha256 of every input. The pins are read from +# scripts/check-legs.sh, the one place the gate takes them from. +emit_legs() { + local coq lean + coq="$(sed -n 's/^COQ_VERSION=\(.*\)$/\1/p' scripts/check-legs.sh)" + lean="$(sed -n 's/^LEAN_VERSION=\(.*\)$/\1/p' scripts/check-legs.sh)" + printf ' (legs\n :gate "bash scripts/check-legs.sh"\n :ci ".github/workflows/proofs.yml (job legs)"\n' + printf ' :correspondence "verification/legs/README.adoc"\n' + printf ' :route "re-proved"\n' + printf ' :route-reason "Agda2Dedukti is an Agda backend pinned to a fork (b026db1, GHC 8.10) and cannot run on Agda %s; no released Dedukti/Lambdapi exporter to Lean 4, and no pinned route to Rocq"\n' "$agda_version" + printf ' :state "RECONCILED"\n' + printf ' :review "statement comparison written by Claude Code; provisional until owner review (estate doctrine 4)"\n' + printf ' (leg :prover "rocq" :checker "coqc" :version "%s" :invocation "coqc -q SecretTypes.v (in verification/legs/rocq)" :libraries "none (no Require)" :axioms "none (Print Assumptions: Closed under the global context)")\n' "$coq" + printf ' (leg :prover "lean" :checker "lean" :version "%s" :invocation "lake build; lake env lean Axioms.lean (in verification/legs/lean)" :libraries "none (lakefile has no require; no Mathlib)" :axioms "none (#print axioms: does not depend on any axioms)")\n' "$lean" + printf ' (inputs\n' + while read -r p; do + printf ' (input :path "%s" :sha256 "%s")\n' "$p" "$(sha256sum "$p" | cut -d' ' -f1)" + done < <(leg_sources) + printf ' ))\n' +} + +# A leg's §5 token: a RECONCILED re-proof is kernel-checked with no axiom +# (scripts/check-legs.sh), so it is proven; any other state is still open. +leg_token() { [[ "$1" == RECONCILED ]] && printf 'proven' || printf 'open'; } + # Print the derived receipt, including claim rows, module hashes and fixtures. emit() { printf ';; SPDX-License-Identifier: CC-BY-SA-4.0\n' printf ';; SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)\n' printf ';; GENERATED by scripts/grade-receipt.sh; regenerate with --write, check with --check.\n' printf ';; This describes the gate and tree; the CI log is the execution evidence.\n' - printf '(praxis-deed\n :schema-version "1.0.0"\n :canonical-name "secret-types-agda-pwg-receipt"\n' - printf ' (identity\n :project "secret-types"\n :name "agda verification receipt (Proof Witness Grades)"\n' + printf '(praxis-deed\n :schema-version "1.0.0"\n :canonical-name "secret-types-agda-receipt"\n' + printf ' (identity\n :project "secret-types"\n :name "Agda verification receipt with Proof Witness Grades"\n' printf ' :repository "%s"\n :repo-uuid #u5"github.com/%s"\n' "$repo" "$repo" printf ' :uuid-source "uuid5(URL, github.com/%s)"\n' "$repo" printf ' :uuid-note "F-1 remains open: template mint-path cause and local scaffolding sweep; see docs/reports/audit/consistency-audit-2026-11-01.adoc"\n' @@ -122,20 +180,29 @@ emit() { printf ' :flags-source "scripts/proof-flags.sh"\n' printf ' :flags-note "One definition, read by the gate and by this receipt; the receipt cannot describe a gate that did not run")\n' printf ' (conformance\n :policy "standards/3-practice/PROOF-POLICY.adoc"\n' - printf ' :policy-revision "owner ruling 2026-10-26; section 2.1 dated 2026-11-01; section 8 (PWG) dated 2026-11-04"\n' + printf ' :policy-repository "hyperpolymath/standards"\n :policy-commit "%s"\n' "$policy_commit" + printf ' :policy-revision "owner ruling 2026-10-26; section 2.1 dated 2026-11-01; section 8 (PWG) 2026-11-04"\n' printf ' :canonical-invocation "agda --safe --without-K --cubical-compatible"\n' - printf ' :cubical-compatible "met"\n :met-since "2026-11-09; W-1 closed, docs/proof-debt.adoc#waivers"\n' + printf ' :cubical-compatible "in-force"\n :flag-control "tests/flag-control/IndexedMatch.agda"\n' + printf ' :closed-waiver "W-1, docs/proof-debt.adoc#waivers"\n' printf ' :stronger-than-canonical ("--no-libraries" "--ignore-interfaces" "--double-check" "-W error"))\n' printf ' (gate\n :command "bash scripts/check.sh"\n :laws-certificate "bash scripts/minimal-laws.sh"\n' printf ' :ci ".github/workflows/proofs.yml"\n :module-count %s\n :reject-fixture-count %s)\n' "$(modules | wc -l | tr -d ' ')" "$(fixtures | wc -l | tr -d ' ')" printf ' (trust-base\n :postulates %s\n :escape-hatches %s\n' "$(grep -rlw --include='*.agda' postulate src/SecretTypes tests 2>/dev/null | wc -l | tr -d ' ')" "$(escape_hits | wc -l | tr -d ' ')" printf ' :library-dependencies 0\n :axioms-assumed 0\n' printf ' :note "A non-zero count above fails the gate; the receipt is only meaningful while all four are zero")\n' - printf ' ; PWGs apply to theory theorems (PROOF-POLICY section 8.1); refusal fixtures and leak witnesses are n/a.\n' - printf ' ; Independent Rocq and Lean legs remain unavailable: no transport or hand re-proof.\n' + printf ' ; PROOF-POLICY section 8: grades apply to theory theorems; demonstrations are ungraded.\n' + printf ' ; Rocq and Lean legs: re-proved by hand (Agda2Dedukti cannot run on the pinned Agda), RECONCILED in verification/legs/README.adoc.\n' + emit_legs printf ' (claims\n' - while IFS='|' read -r id token grade coq lean; do - printf ' (claim :id "%s" :token "%s" :pwg "%s" :coq-leg "%s" :lean-leg "%s")\n' "$id" "$token" "$grade" "$coq" "$lean" + while IFS='|' read -r id token kind coq coq_state lean lean_state; do + if [[ "$kind" == theorem ]]; then + printf ' (claim :id "%s" :token "%s" :kind "%s" :pwg "%s" :foundation-diverse #f\n' "$id" "$token" "$kind" "$(pwg_of "$token" "$kind" "$coq_state" "$lean_state")" + printf ' (leg :prover "rocq" :route "%s" :token "%s" :state "%s" :receipts ())\n' "$coq" "$(leg_token "$coq_state")" "$coq_state" + printf ' (leg :prover "lean" :route "%s" :token "%s" :state "%s" :receipts ()))\n' "$lean" "$(leg_token "$lean_state")" "$lean_state" + else + printf ' (claim :id "%s" :token "%s" :kind "%s" :pwg "%s")\n' "$id" "$token" "$kind" "$(pwg_of "$token" "$kind" "$coq_state" "$lean_state")" + fi done < <(claims) printf ' )\n ; Every module hash is checked against the current tree.\n (modules\n' while read -r m; do @@ -148,7 +215,23 @@ emit() { printf ' )\n)\n' } +# A RECONCILED leg must have a row in the correspondence record, and a claim +# with a correspondence row must count its legs: the grade is derived from +# the §2.2 record, not typed beside it (PROOF-POLICY §8.4). +check_leg_records() { + local rec=verification/legs/README.adoc counted recorded + counted="$(claims | awk -F'|' '$5=="RECONCILED"||$7=="RECONCILED"{print $1}' | LC_ALL=C sort)" + recorded="$(grep -oE '^\| N-[0-9]+$' "$rec" 2>/dev/null | tr -d '| ' | LC_ALL=C sort -u || true)" + if [[ "$counted" != "$recorded" ]]; then + echo "ERROR: claims with a RECONCILED leg differ from the rows of $rec" >&2 + diff <(printf '%s\n' "$recorded") <(printf '%s\n' "$counted") \ + | grep -E '^[<>]' | sed "s|^<| only in $rec:|; s/^>/ only in grade-receipt.sh:/" >&2 + exit 1 + fi +} + check_claim_ids +check_leg_records case "$mode" in --write|"") diff --git a/scripts/proof-flags.sh b/scripts/proof-flags.sh index 397c894..a0ff8a2 100755 --- a/scripts/proof-flags.sh +++ b/scripts/proof-flags.sh @@ -13,21 +13,22 @@ # Conformance: standards/3-practice/PROOF-POLICY.adoc §2.1 (2026-11-01) sets # agda --safe --without-K --cubical-compatible # as the estate default for first-party Agda theory entrypoints. Every flag -# below is that default, or strictly stronger: +# below is that default, or strictly stronger; all three default flags are in +# force: # # --safe policy default. No postulates, no --type-in-type, # no termination pragmas, no unsafe features. # --without-K policy default. No UIP, no Streicher K. (Implied by # --cubical-compatible; kept explicit so the intent # survives a future flag change.) -# --cubical-compatible policy default. Met since 2026-11-09: no clause relies -# on constructor injectivity. The `just` results of -# finishing runs are read through `just-injective`, a -# retraction (SecretTypes.Prelude), and the two-point -# order is a computed predicate. -W error makes any -# -WUnsupportedIndexedMatch fatal, so a regression fails -# the gate rather than weakening it. History: -# docs/proof-debt.adoc#waivers (W-1, closed). +# --cubical-compatible policy default. In force since W-1 closed: the last +# clauses that needed constructor injectivity (matching +# `refl` against `exec … ≡ just t`) now read the result +# through Prelude.just-inj, which matches nothing. The +# gate does NOT suppress -WUnsupportedIndexedMatch, and +# -W error makes any return of such a clause fatal: a +# planted `refl` match fails the gate (exit 42). +# History: docs/proof-debt.adoc#waivers, W-1 (closed). # --no-libraries stronger. Builtins only; no Agda standard library and # no sibling repository is on the path. The policy's # "no Mathlib" rule (PROOF-POLICY §2) is the Lean form of diff --git a/scripts/render-mise.sh b/scripts/render-mise.sh index 92dd92c..a4fe5b8 100755 --- a/scripts/render-mise.sh +++ b/scripts/render-mise.sh @@ -104,6 +104,7 @@ EOF case "$t" in trivy) echo "trivy = \"latest\" # just deps-audit" ;; shellcheck) echo "shellcheck = \"latest\" # the shell scripts" ;; + yq) echo "yq = \"latest\" # scripts/kyaml-format.sh (KYAML configs)" ;; just) echo "just = \"latest\"" ;; *) printf '%s = "latest"\n' "$t" ;; esac diff --git a/scripts/replay-mint.sh b/scripts/replay-mint.sh new file mode 100755 index 0000000..bea6c2e --- /dev/null +++ b/scripts/replay-mint.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# replay-mint.sh — mint a template commit into a fresh directory, from answers. +# +# The one place a mint is replayed. Used by scripts/check-mint-drift.sh (replay +# a child's own mint and diff the child against it) and scripts/chain-mint.sh +# (replay a child's mint and mint the same template commit with new answers, +# then carry the child's own work across). It runs the REAL recipe, from the +# pinned commit, exactly as "Use this template" would have started it. +# +# Usage: replay-mint.sh [--answers-from DEED] +# With --answers-from, the answers, date and archetype are the (mint-answers) +# clause of DEED: a replay of that repo's own mint. Without it, they come +# from the environment, under the names repo-init reads (ARCHETYPE too): +# PROJECT_NAME REPO OWNER AUTHOR AUTHOR_EMAIL AUTHOR_ORG AUTHOR_EMAIL_ALT +# AUTHOR_ORCID PROJECT_DESCRIPTION FORGE SECURITY_EMAIL CONDUCT_EMAIL +# PROJECT_TYPE WEBSITE OPENSSF_BP_ID SERVICE_NAME PORT REGISTRY RSR_MINT_DATE +# RSR_TEMPLATE_SLUG (a template whose Justfile names itself through env()) +# With --answers-from, the slug is DEED's (provenance :template-repo) and a +# julia-library package uuid its recorded one; without it, the uuid is derived. +# Nothing else from the caller's environment reaches the mint: no defaults +# file, no global git config (an ORCID or signing key there would leak in). +# Exit: 0 minted into ; 1 the mint failed (log tail on stderr); 2 usage. +set -euo pipefail +# This script runs git against other repositories (-C does not override +# these), so an inherited location (a hook, another git caller) cannot redirect +# it (CodeRabbit review of #275). +unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY \ + GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_PREFIX GIT_NAMESPACE + +usage() { echo "usage: replay-mint.sh [--answers-from DEED]" >&2; exit 2; } +TDIR="${1:-}"; PIN="${2:-}"; OUT="${3:-}" +{ [ -n "$TDIR" ] && [ -n "$PIN" ] && [ -n "$OUT" ]; } || usage +ARCH="${ARCHETYPE:-}" +if [ $# -gt 3 ]; then + [ "$4" = "--answers-from" ] && [ -n "${5:-}" ] || usage + ANS_DEED="$5" + READ="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/deed-field.sh" + bash "$READ" --has "$ANS_DEED" mint-answers \ + || { echo "replay-mint: $ANS_DEED has no (mint-answers) clause; its mint cannot be replayed" >&2; exit 2; } + ans() { bash "$READ" "$ANS_DEED" mint-answers "$1" 2>/dev/null || true; } + PROJECT_NAME="$(ans project-name)" REPO="$(ans repo)" OWNER="$(ans owner)" + AUTHOR="$(ans author)" AUTHOR_EMAIL="$(ans author-email)" AUTHOR_ORG="$(ans author-org)" + AUTHOR_EMAIL_ALT="$(ans author-email-alt)" AUTHOR_ORCID="$(ans author-orcid)" + PROJECT_DESCRIPTION="$(ans description)" FORGE="$(ans forge)" + SECURITY_EMAIL="$(ans security-email)" CONDUCT_EMAIL="$(ans conduct-email)" + PROJECT_TYPE="$(ans project-type)" WEBSITE="$(ans website)" + OPENSSF_BP_ID="$(ans openssf-bp-id)" SERVICE_NAME="$(ans service-name)" + PORT="$(ans port)" REGISTRY="$(ans registry)" RSR_MINT_DATE="$(ans mint-date)" + RSR_PACKAGE_UUID="$(ans package-uuid)" + ARCH="$(ans archetype)" + # The template identity the mint was given, for a template whose Justfile + # names itself through env() (Codex review of #275). + RSR_TEMPLATE_SLUG="$(bash "$READ" "$ANS_DEED" provenance template-repo 2>/dev/null || true)" +else + # A fresh mint (a chain mint's new side) derives its package uuid: only a + # recorded (mint-answers) may supply one, never the caller's environment. + RSR_PACKAGE_UUID="" +fi +TDIR="$(cd "$TDIR" && pwd)" +[ ! -e "$OUT" ] || { echo "replay-mint: $OUT already exists" >&2; exit 2; } +git -C "$TDIR" cat-file -e "${PIN}^{commit}" 2>/dev/null \ + || { echo "replay-mint: $TDIR does not contain commit $PIN" >&2; exit 2; } + +# g — git with no user or system configuration and no hooks (Codex review of +# #275): the synthetic "Use this template" commit cannot be changed or refused +# by this machine's settings. +g() { + GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1 git -c user.name=replay -c user.email=replay@example.invalid \ + -c commit.gpgsign=false -c core.hooksPath=/dev/null "$@" +} +mkdir -p "$OUT" +OUT="$(cd "$OUT" && pwd)" +SCRATCH="$(mktemp -d)" +trap 'rm -rf "$SCRATCH"' EXIT +# Written as a checkout writes it, by git (checkout-index), never git archive, +# whose export-ignore and export-subst attributes change the tree (Codex review +# of #275). Through g: no global core.attributesFile shapes it (Codex review +# of #277). +GIT_INDEX_FILE="$SCRATCH/index" g -C "$TDIR" read-tree "$PIN" +# A gitlink has no files to write, so the copy would lose it: refused by name +# rather than replayed inexactly (Codex review of #277). +GL="$(GIT_INDEX_FILE="$SCRATCH/index" g -C "$TDIR" ls-files -s | awk -F '\t' '$1 ~ /^160000 / { print " " $2 }')" +if [ -n "$GL" ]; then + echo "replay-mint: the template commit $PIN carries a submodule (gitlink), which a replay cannot reproduce:" >&2 + printf '%s\n' "$GL" >&2 + exit 2 +fi +GIT_INDEX_FILE="$SCRATCH/index" g -C "$TDIR" checkout-index -a -f --prefix="$OUT/" +g -C "$OUT" init -q -b main +g -C "$OUT" add -A +g -C "$OUT" commit -q -m "Initial commit" +g -C "$OUT" remote add origin "git@${FORGE:-github.com}:${OWNER:-}/${REPO:-}.git" + +: > "$SCRATCH/gitconfig" +mkdir -p "$SCRATCH/cfg" +if ! (cd "$OUT" && env -i PATH="$PATH" HOME="$HOME" TMPDIR="${TMPDIR:-/tmp}" \ + ${RUSTUP_HOME:+RUSTUP_HOME="$RUSTUP_HOME"} ${CARGO_HOME:+CARGO_HOME="$CARGO_HOME"} \ + ${RSR_MINT_TOOL_CACHE:+RSR_MINT_TOOL_CACHE="$RSR_MINT_TOOL_CACHE"} \ + ${STANDARDS_UUID_V8_JS:+STANDARDS_UUID_V8_JS="$STANDARDS_UUID_V8_JS"} \ + XDG_CONFIG_HOME="$SCRATCH/cfg" GIT_CONFIG_GLOBAL="$SCRATCH/gitconfig" GIT_CONFIG_NOSYSTEM=1 \ + RSR_NON_INTERACTIVE=1 RSR_MINT_REPLAY=1 \ + RSR_TEMPLATE_URL="$TDIR" RSR_TEMPLATE_COMMIT="$PIN" RSR_MINT_DATE="${RSR_MINT_DATE:-}" \ + ${RSR_TEMPLATE_SLUG:+RSR_TEMPLATE_SLUG="$RSR_TEMPLATE_SLUG"} \ + PROJECT_NAME="${PROJECT_NAME:-}" REPO="${REPO:-}" OWNER="${OWNER:-}" \ + AUTHOR="${AUTHOR:-}" AUTHOR_EMAIL="${AUTHOR_EMAIL:-}" AUTHOR_ORG="${AUTHOR_ORG:-}" \ + AUTHOR_EMAIL_ALT="${AUTHOR_EMAIL_ALT:-}" AUTHOR_ORCID="${AUTHOR_ORCID:-}" \ + PROJECT_DESCRIPTION="${PROJECT_DESCRIPTION:-}" FORGE="${FORGE:-}" \ + SECURITY_EMAIL="${SECURITY_EMAIL:-}" CONDUCT_EMAIL="${CONDUCT_EMAIL:-}" \ + PROJECT_TYPE="${PROJECT_TYPE:-}" WEBSITE="${WEBSITE:-}" \ + OPENSSF_BP_ID="${OPENSSF_BP_ID:-}" SERVICE_NAME="${SERVICE_NAME:-}" \ + PORT="${PORT:-}" REGISTRY="${REGISTRY:-}" RSR_PACKAGE_UUID="${RSR_PACKAGE_UUID:-}" \ + just repo-init ${ARCH:+"$ARCH"} "$SCRATCH/replay.log" 2>&1; then + tail -n 30 "$SCRATCH/replay.log" >&2 + echo "replay-mint: the mint of $PIN failed (above)" >&2 + exit 1 +fi diff --git a/scripts/resolve-template-pin.sh b/scripts/resolve-template-pin.sh index d7af5f6..730a271 100755 --- a/scripts/resolve-template-pin.sh +++ b/scripts/resolve-template-pin.sh @@ -91,9 +91,12 @@ fi # Candidate trees, strongest first. A root commit is what "Use this template" # wrote; it stays the template's tree even after the child grows commits. CANDS=() +CAND_TIME=() if [ -n "$HEAD_SHA" ]; then while IFS= read -r root; do - [ -n "$root" ] && CANDS+=("$(git -C "$DIR" rev-parse "${root}^{tree}")") + [ -n "$root" ] || continue + CANDS+=("$(git -C "$DIR" rev-parse "${root}^{tree}")") + CAND_TIME+=("$(git -C "$DIR" log -1 --format=%ct "$root")") done < <(git -C "$DIR" rev-list --max-parents=0 HEAD) fi # The work tree as `git add -A` would commit it (ignored paths excluded). Its @@ -153,23 +156,45 @@ if [ -n "$COMMIT" ]; then exit 0 fi -# Newest first along the default branch, then anywhere in the history. -REFS=() -[ -n "$DEFAULT" ] && REFS+=("refs/heads/$DEFAULT") -n=0 -for t in "${CANDS[@]}"; do - n=$((n + 1)) - for scope in "${REFS[@]}" --all; do - # awk reads to the end rather than exiting at the first hit: an early exit - # SIGPIPEs git log on a long history, and pipefail made that fatal - # (exit 141 against the real rsr-template-repo, 2026-10-20). - hit="$(git -C "$TMPL" log --format='%H %T' "$scope" | awk -v t="$t" '!found && $2 == t { print $1; found = 1 }')" - if [ -n "$hit" ]; then - if [ "$t" = "$wt_tree" ] && [ "$n" -eq "${#CANDS[@]}" ]; then src="work-tree"; else src="root-commit"; fi - printf 'branch=%s\ncommit=%s\ntree=%s\nsource=%s\n' "$(branch_of "$hit")" "$hit" "$t" "$src" +# A tree can belong to more than one commit: an empty commit, a revert, or a +# merge that changed nothing (29 trees repeat on rsr-template-repo's main, +# measured 2026-10-27). "Newest with this tree" then named a commit the child +# may not have come from (Codex review of #251). So: +# 1. A root commit carries its creation time. "Use this template" copies the +# default branch's tip at that moment, so the first-parent tip of the +# default branch as of that time is the exact source when its tree +# matches. That is a fact about the history, not a guess. +# 2. Otherwise every commit with a matching tree is collected. One is the +# answer; more than one is refused, loudly, with RSR_TEMPLATE_COMMIT as +# the way to say which. Equal trees are equal content, but provenance +# names a COMMIT, and it must not be a coin toss. +for i in "${!CANDS[@]}"; do + t="${CANDS[$i]}" + if [ "$t" = "$wt_tree" ] && [ "$i" -eq $(( ${#CANDS[@]} - 1 )) ]; then src="work-tree"; else src="root-commit"; fi + if [ "$src" = "root-commit" ] && [ -n "$DEFAULT" ] && [ -n "${CAND_TIME[$i]:-}" ]; then + tip="$(git -C "$TMPL" rev-list -1 --first-parent --before="@${CAND_TIME[$i]}" "refs/heads/$DEFAULT" 2>/dev/null || true)" + if [ -n "$tip" ] && [ "$(git -C "$TMPL" rev-parse "${tip}^{tree}")" = "$t" ]; then + printf 'branch=%s\ncommit=%s\ntree=%s\nsource=%s\n' "$DEFAULT" "$tip" "$t" "$src" exit 0 fi - done + fi + # awk reads to the end: an early exit SIGPIPEs git log on a long history, + # and pipefail made that fatal (exit 141 on the real template, 2026-10-20). + mapfile -t hits < <(git -C "$TMPL" log --all --format='%H %T' | awk -v t="$t" '$2 == t { print $1 }' | awk '!seen[$0]++') + if [ "${#hits[@]}" -eq 1 ]; then + printf 'branch=%s\ncommit=%s\ntree=%s\nsource=%s\n' "$(branch_of "${hits[0]}")" "${hits[0]}" "$t" "$src" + exit 0 + fi + if [ "${#hits[@]}" -gt 1 ]; then + { + echo "resolve-template-pin: tree $t belongs to ${#hits[@]} commits of ${SLUG}, so the" + echo " template commit is ambiguous:" + printf ' %s\n' "${hits[@]:0:10}" + [ "${#hits[@]}" -le 10 ] || echo " … and $(( ${#hits[@]} - 10 )) more" + echo " Say which with RSR_TEMPLATE_COMMIT=." + } >&2 + exit 1 + fi done { diff --git a/scripts/run-proven-suite.sh b/scripts/run-proven-suite.sh index 35a7c2c..31e5d9b 100755 --- a/scripts/run-proven-suite.sh +++ b/scripts/run-proven-suite.sh @@ -168,6 +168,8 @@ require_file tests/workflows/check_ijson_jcs_test.sh && \ run_lane "I-JSON/JCS Silence + Firing self-tests" bash tests/workflows/check_ijson_jcs_test.sh require_file tests/workflows/squabble_gate_test.sh && \ run_lane "CICD-Squabbler & required-context Silence + Firing self-tests" bash tests/workflows/squabble_gate_test.sh +require_file tests/workflows/secret_scanner_contract_test.sh && \ + run_lane "Secret scanner three-context contract Silence + Firing self-tests" bash tests/workflows/secret_scanner_contract_test.sh require_file tests/shape/check_root_shape_test.sh && \ run_lane "Root shape Silence + Firing self-tests" bash tests/shape/check_root_shape_test.sh require_file www/tests/run-all.sh && \ diff --git a/scripts/rust-tool.sh b/scripts/rust-tool.sh index 436fdba..8e9c363 100644 --- a/scripts/rust-tool.sh +++ b/scripts/rust-tool.sh @@ -27,11 +27,24 @@ src="$scripts_dir/$name.rs" cache="${RSR_MINT_TOOL_CACHE:-${TMPDIR:-/tmp}/rsr-mint-tools}" mkdir -p "$cache" -bin="$cache/$name" +# The binary is named by the CONTENT of its source, not by the tool name and +# a modification time. Keyed by name with an mtime check, two checkouts with +# different versions of a tool shared one binary, and a source whose mtime +# was older than the cached build (any fresh clone or checkout can produce +# one) silently ran the OTHER version. strip-instruction-blocks changed its +# interface in #251 (files, not a directory), so a stale binary would now +# mis-run a mint rather than merely differ. +if command -v sha256sum >/dev/null 2>&1; then + digest="$(sha256sum "$src" | cut -c1-16)" +else + digest="$(shasum -a 256 "$src" | cut -c1-16)" +fi +bin="$cache/$name-$digest" -# Recompile only when the binary is missing or older than its source, so the -# common case is a straight exec. -if [ ! -x "$bin" ] || [ "$src" -nt "$bin" ]; then +# Compile only when no binary exists for this exact source, so the common +# case is a straight exec. Build to a temporary name and rename, so a +# concurrent mint never executes a half-written binary. +if [ ! -x "$bin" ]; then # The toolchain is needed to BUILD, not to RUN. Checking for rustc up # front made a cached binary unusable on a machine without one, which is # the common case in CI: the run is a straight exec. Only ask for rustc @@ -42,10 +55,13 @@ if [ ! -x "$bin" ] || [ "$src" -nt "$bin" ]; then echo " Install one from https://rustup.rs, then re-run." >&2 exit 2 } - rustc -O -o "$bin" "$src" >&2 || { + tmp_bin="$bin.tmp.$$" + rustc -O -o "$tmp_bin" "$src" >&2 || { + rm -f "$tmp_bin" echo "rust-tool: failed to compile $src" >&2 exit 2 } + mv -f "$tmp_bin" "$bin" fi exec "$bin" "$@" diff --git a/src/SecretTypes/Noninterference/Generic.agda b/src/SecretTypes/Noninterference/Generic.agda index 4eb90da..69794ef 100644 --- a/src/SecretTypes/Noninterference/Generic.agda +++ b/src/SecretTypes/Noninterference/Generic.agda @@ -54,14 +54,16 @@ module _ (V : Observer 𝓛) where -- Every store agrees with itself. ≈-refl : ∀ {s} → s ≈ s - ≈-refl _ _ = refl + ≈-refl x v = refl - -- Carry an agreement to the stores two finishing runs returned. The runs' - -- results are read through `just-injective`, a retraction, never by matching - -- `just a ≡ just b` against `refl`: that match needs constructor injectivity, - -- which `--cubical-compatible` refuses (PROOF-POLICY §2.1). - ≈-just : ∀ {s s' t t'} → just s ≡ just t → just s' ≡ just t' → s ≈ s' → t ≈ t' - ≈-just e e' eq = subst (λ u → _ ≈ u) (just-injective e') (subst (λ u → u ≈ _) (just-injective e) eq) + -- Carry agreement along the results of finishing runs, without + -- unifying two just-terms (unsupported under --cubical-compatible). + just-≈ : ∀ {u t s} → just u ≡ just t → u ≈ s → t ≈ s + just-≈ {s = s} e p = subst (λ w → w ≈ s) (just-inj e) p + + -- The same for two runs at once. + just-≈₂ : ∀ {u u' t t'} → just u ≡ just t → just u' ≡ just t' → u ≈ u' → t ≈ t' + just-≈₂ {t = t} e e' p = subst (λ w → t ≈ w) (just-inj e') (subst (λ w → w ≈ _) (just-inj e) p) -- A program counter V cannot see stays unseen in any branch it guards. unseen-⊔ : ∀ {pc} y → ¬ sees pc → ¬ sees (pc ⊔ y) @@ -106,8 +108,8 @@ module _ (V : Observer 𝓛) where confineL : ∀ {ℓ e c} fuel {s t} → ¬ sees ℓ → ℓ ⊢ c → exec fuel (while e c) s ≡ just t → t ≈ s - confine fuel np skip e = ≈-just e refl ≈-refl - confine fuel {s} np (assign {x = x} {e = g} ok) e = ≈-just e refl (confine-assign x g (eval s g) s np ok) + confine fuel {s} np skip e = just-≈ e (≈-refl {s}) + confine fuel {s} np (assign {x = x} {e = e} ok) j = just-≈ j (confine-assign x e (eval s e) s np ok) confine fuel np (seq t₁ t₂) e with bind-just _ _ e ... | s₁ , (e₁ , e₂) = ≈-trans (confine fuel np t₂ e₂) (confine fuel np t₁ e₁) confine fuel np (ifz {e = g} t₁ t₂) e = confineIf fuel (eval _ g) (unseen-⊔ (lab g) np) t₁ t₂ e @@ -119,7 +121,7 @@ module _ (V : Observer 𝓛) where confineW fuel zero np t e with bind-just _ _ e ... | s₁ , (e₁ , e₂) = ≈-trans (confineL fuel np t e₂) (confine fuel np t e₁) - confineW fuel (suc _) np t e = ≈-just e refl ≈-refl + confineW fuel (suc _) {s} np t e = just-≈ e (≈-refl {s}) confineL zero np t () confineL {e = g} (suc fuel) {s} np t e = confineW fuel (eval s g) np t e @@ -151,8 +153,8 @@ module _ (V : Observer 𝓛) where → (pc ⊔ lab e) ⊢ c → s ≈ s' → execWhile fuel (eval s e) e c s ≡ just t → execWhile fuel' (eval s' e) e c s' ≡ just t' → t ≈ t' - tini fuel fuel' skip eq e e' = ≈-just e e' eq - tini fuel fuel' (assign {x = x} {e = g} ok) eq e e' = ≈-just e e' (tini-assign x g ok eq) + tini fuel fuel' skip eq e e' = just-≈₂ e e' eq + tini fuel fuel' (assign {x = x} {e = g} ok) eq e e' = just-≈₂ e e' (tini-assign x g ok eq) tini fuel fuel' (seq t₁ t₂) eq e e' with bind-just _ _ e | bind-just _ _ e' ... | s₁ , (e₁ , e₂) | s₁' , (e₁' , e₂') = tini fuel fuel' t₂ (tini fuel fuel' t₁ eq e₁ e₁') e₂ e₂' tini fuel fuel' (ifz {pc} {g} t₁ t₂) eq e e' = @@ -181,6 +183,6 @@ module _ (V : Observer 𝓛) where go : ∀ n {m} → n ≡ m → execWhile fuel n g c s ≡ just t → execWhile fuel' m g c s' ≡ just t' → t ≈ t' go zero refl e e' with bind-just _ _ e | bind-just _ _ e' ... | s₁ , (e₁ , e₂) | s₁' , (e₁' , e₂') = tini fuel fuel' (while tc) (tini fuel fuel' tc eq e₁ e₁') e₂ e₂' - go (suc n) refl e e' = ≈-just e e' eq + go (suc n) refl e e' = just-≈₂ e e' eq tiniW {pc} {g} fuel fuel' {s} {s'} (no nv) tc eq e e' = ≈-trans (confineW fuel (eval s g) nv tc e) (≈-trans eq (≈-sym (confineW fuel' (eval s' g) nv tc e'))) diff --git a/src/SecretTypes/Prelude.agda b/src/SecretTypes/Prelude.agda index e1b9fd5..efcd38b 100644 --- a/src/SecretTypes/Prelude.agda +++ b/src/SecretTypes/Prelude.agda @@ -39,22 +39,28 @@ data Maybe (A : Set) : Set where nothing : Maybe A just : A → Maybe A --- The value inside a `just`, with a default for `nothing`. `just` is a --- constructor, so `from-just d (just a)` computes to `a` definitionally. +-- Injectivity of `just`, stated so that --cubical-compatible accepts it +-- (PROOF-POLICY §2.1). The obvious definition, matching `refl` against +-- `just a ≡ just b`, asks the unifier for constructor injectivity, which +-- Agda 2.6.4.3 refuses under that flag: +-- +-- warning: -W[no]UnsupportedIndexedMatch ... It relies on injectivity of +-- the data constructor just, which is not yet supported +-- +-- An earlier copy of that definition was deleted on 2026-11-01 for this +-- reason. This one matches nothing: it projects the equation through +-- `from-just`, which computes, so no unification rule is needed. The +-- TINI proof (Noninterference.Generic) uses it wherever a finishing run's +-- result must be read off `exec … ≡ just t`. + +-- The value inside a `just`, or the default for `nothing`. from-just : {A : Set} → A → Maybe A → A from-just d nothing = d from-just d (just a) = a --- Injectivity of `just`, as a retraction rather than a rule. Matching --- `just a ≡ just b` against `refl` asks for constructor injectivity, which --- Cubical Agda does not support (`-WUnsupportedIndexedMatch`; fatal under the --- canonical flags, scripts/proof-flags.sh). Here `cong (from-just a) e : --- from-just a (just a) ≡ from-just a (just b)`, and both sides *reduce* to `a` --- and `b` — the same move as `suc-dec` below makes with `pred`. Every proof --- that needs the value out of a finishing run goes through this lemma and --- never matches such an equation directly. -just-injective : {A : Set} {a b : A} → just a ≡ just b → a ≡ b -just-injective {a = a} e = cong (from-just a) e +-- Equal `just`s have equal contents. +just-inj : {A : Set} {a b : A} → just a ≡ just b → a ≡ b +just-inj {a = a} e = cong (from-just a) e -- Pass a present value to f and return its result; propagate nothing -- without applying f when the input is absent. @@ -125,3 +131,4 @@ dec-stable (no ¬p) h = ⊥-elim (h ¬p) -- Excluded middle holds under double negation, for every proposition. ¬¬-dec : (P : Set) → ¬ ¬ Dec P ¬¬-dec P k = k (no λ p → k (yes p)) + diff --git a/tests/e2e/chain_mint_test.sh b/tests/e2e/chain_mint_test.sh new file mode 100755 index 0000000..f6e02c1 --- /dev/null +++ b/tests/e2e/chain_mint_test.sh @@ -0,0 +1,600 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# chain_mint_test.sh — mint a repository made from a MINTED repository. +# +# Codex (review of #251) showed that minting a minted repo inherited the +# parent's author, contacts and identity, because the parent's tokens were +# already filled. The owner chose to support chains properly (2026-11-04): +# scripts/chain-mint.sh replays the parent's mint, mints the same template +# commit with the new answers, and carries the parent's own work across by a +# three-way merge. This mints parent -> grandchild end to end and checks: +# * nothing of the parent's identity or contacts survives in the grandchild; +# * the parent's own work (an edit, an addition, a removal) is carried, with +# its name rewritten, and declared in the grandchild's (variant (paths)); +# * provenance names the root template commit and the parent; +# * the grandchild's drift gate (a replay of ITS mint) is green; +# and, as negative controls, that a conflicting parent edit stops the chain +# before it writes, and that a parent without (mint-answers) is refused. +# Needs just, rustc, uuidgen, nickel, file. Network-free (RSR_TEMPLATE_URL). +# +# Usage: bash tests/e2e/chain_mint_test.sh [--keep] +set -euo pipefail +repo=$(git rev-parse --show-toplevel) +work=$(mktemp -d) +if [ "${1:-}" = "--keep" ]; then echo "keeping $work"; else trap 'rm -rf "$work"' EXIT; fi +fail() { echo "FAIL: $*" >&2; exit 1; } +pass() { echo "PASS: $*"; } +g() { git -c user.name=t -c user.email=t@example.org -c commit.gpgsign=false "$@"; } +for tool in just rustc uuidgen nickel file; do + command -v "$tool" >/dev/null 2>&1 || fail "$tool is required to run a real mint (not skipping)" +done + +# A legacy minted repository (minted before #275 recorded (mint-answers), so +# it has no build/templates/identity.deed.in and no answers to replay) cannot +# be minted again: repo-init refuses before it asks or writes anything (#277). +# The rest of this test needs a mintable tree, so in such a child it checks +# that refusal, by name, and stops. It does not skip silently: a legacy child +# that could be minted again would fail here. +legacy_deed="$(bash "$repo/scripts/deed-field.sh" --find "$repo" 2>/dev/null || true)" +if [ ! -f "$repo/build/templates/identity.deed.in" ] \ + && ! { [ -n "$legacy_deed" ] && bash "$repo/scripts/deed-field.sh" --has "$legacy_deed" mint-answers; }; then + legacy_cfg=$(mktemp -d) + if legacy_out=$(cd "$repo" && env -i PATH="$PATH" HOME="$legacy_cfg" XDG_CONFIG_HOME="$legacy_cfg" \ + RSR_NON_INTERACTIVE=1 PROJECT_NAME="Mint Check" REPO=mint-check OWNER=example-owner \ + AUTHOR="Example Author" AUTHOR_EMAIL=author@example.org CONFIRM=n \ + timeout 120 just repo-init &1); then + rm -rf "$legacy_cfg" + echo "$legacy_out" >&2 + echo "FAIL: a legacy minted tree (no (mint-answers)) was allowed to mint again" >&2; exit 1 + fi + rm -rf "$legacy_cfg" + grep -q 'cannot be minted again' <<<"$legacy_out" \ + || { echo "$legacy_out" >&2; echo "FAIL: the legacy minted tree was refused for the wrong reason" >&2; exit 1; } + echo "PASS: a legacy minted tree refuses to mint again, by name; the mint checks below need a mintable tree" + exit 0 +fi + +# ── The template: this checkout's tree, committed or not, on main ─────────── +T="$work/template" +g clone -q --no-local "$repo" "$T" +g -C "$T" checkout -q -B main +(cd "$repo" && git ls-files -z --cached --others --exclude-standard \ + | while IFS= read -r -d '' f; do { [ -e "$f" ] || [ -L "$f" ]; } && printf '%s\0' "$f"; done \ + | tar --null -T - -cf -) > "$work/tree.tar" +(cd "$T" && git ls-files -z | xargs -0 rm -f && tar xf "$work/tree.tar") +g -C "$T" add -A +g -C "$T" commit -q --allow-empty -m "template under test" +T_COMMIT=$(g -C "$T" rev-parse HEAD) +T_SLUG="$(sed -n -E 's/^OWNER[[:space:]]*:=[[:space:]]*"([^"]+)".*/\1/p' "$T/Justfile" | head -1)/$(sed -n -E 's/^REPO[[:space:]]*:=[[:space:]]*"([^"]+)".*/\1/p' "$T/Justfile" | head -1)" + +# use_template FROM COMMITISH DIR ORIGIN — what "Use this template" makes. +use_template() { + rm -rf "$3"; mkdir -p "$3" + # The tree as a checkout writes it: GitHub copies the tree, it does not + # git archive it (export-ignore, export-subst). + GIT_INDEX_FILE="$work/use_template.index" g -C "$1" read-tree "$2" + GIT_INDEX_FILE="$work/use_template.index" g -C "$1" checkout-index -a -f --prefix="$3/" + rm -f "$work/use_template.index" + # -f: a template copy keeps every tracked file, ignore rules or not. + g -C "$3" init -q -b main; g -C "$3" add -f -A; g -C "$3" commit -q -m "Initial commit" + g -C "$3" remote add origin "$4" +} +# mint DIR LOG REPO OWNER AUTHOR EMAIL DESC [ARCHETYPE [PROJECT_NAME]] — a +# non-interactive mint, isolated. The project name defaults to the repo name. +mint() { + local cfg="$work/cfg"; mkdir -p "$cfg"; printf '%s' "${MINT_GITCONFIG:-}" > "$cfg/gitconfig" + (cd "$1" && env -i PATH="$PATH" HOME="$HOME" TMPDIR="${TMPDIR:-/tmp}" \ + ${RUSTUP_HOME:+RUSTUP_HOME="$RUSTUP_HOME"} ${CARGO_HOME:+CARGO_HOME="$CARGO_HOME"} \ + XDG_CONFIG_HOME="$cfg" GIT_CONFIG_GLOBAL="$cfg/gitconfig" GIT_CONFIG_NOSYSTEM=1 \ + RSR_NON_INTERACTIVE=1 RSR_TEMPLATE_URL="$T" RSR_MINT_TOOL_CACHE="$work/tools" \ + PROJECT_NAME="${9:-$3}" REPO="$3" OWNER="$4" AUTHOR="$5" AUTHOR_EMAIL="$6" \ + PROJECT_DESCRIPTION="$7" timeout 1200 just repo-init ${8:+"$8"} "$2" 2>&1 +} + +# ── The parent: a minted repo, then its own work ──────────────────────────── +P="$work/parent" +use_template "$T" HEAD "$P" git@github.com:elder-owner/elder-proj.git +mint "$P" "$work/parent.log" elder-proj elder-owner "Eldra Author" elder@example.org "Elder tools" \ + || { tail -30 "$work/parent.log" >&2; fail 'the parent mint failed'; } +g -C "$P" add -A; g -C "$P" commit -q -m mint +printf '\nNotes for elder-proj users.\n' >> "$P/README.adoc" +mkdir -p "$P/notes"; printf 'elder-proj notes, see https://github.com/elder-owner/elder-proj\n' > "$P/notes/elder-proj-notes.md" +g -C "$P" rm -q .machine_readable/ai/PLACEHOLDERS.adoc +# A path holding characters special to sed and to deed strings, and a type +# change (a file the parent replaced with a symlink) (Codex review of #277). +printf 'odd name\n' > "$P/notes/R&D | \"q\".md" +rm "$P/SUPPORT.adoc"; ln -s README.adoc "$P/SUPPORT.adoc" +# A file listed in mint-verbatim.txt, deleted: the template's own preflights +# must not stop a chain mint over the parent's choices (Codex review of #277). +g -C "$P" rm -q tests/e2e/template_instantiation_test.sh +# Path bytes tar's line mode would mangle: a backslash and a leading dash; a +# file under archetypes/, which the mint declares removed; and an identity +# setting naming the parent's owner (all Codex review of #277). +printf 'slash\n' > "$P/notes/back\\slash.txt" +printf 'dash\n' > "$P/-lead.txt" +mkdir -p "$P/archetypes"; printf 'mine\n' > "$P/archetypes/custom.txt" +printf 'sonar.organization=elder-owner\nsonar.projectKey=elder-owner_elder-proj\n' > "$P/notes/extra-sonar.properties" +# A carried generator that writes one more file when it runs: its output is +# measured after it runs, and declared (Codex review of #277). +sed -i 's|^set -euo pipefail$|&\nprintf '"'"'extra\\n'"'"' > "$(git rev-parse --show-toplevel)/generated-extra.txt"|' "$P/.machine_readable/arrival-pack/generate.sh" +grep -q 'generated-extra.txt' "$P/.machine_readable/arrival-pack/generate.sh" || fail 'fixture: the generator was not changed' +# The same generator recreates a mint-produced file the parent deleted, with +# other content: its category moves from removed to diverged, and the +# declaration must follow it (Codex review of #277). +[ -f "$P/docs/usage.adoc" ] || fail 'fixture: the mint produced no docs/usage.adoc' +g -C "$P" rm -q docs/usage.adoc +sed -i 's|^set -euo pipefail$|&\nprintf '"'"'regenerated usage\\n'"'"' > "$(git rev-parse --show-toplevel)/docs/usage.adoc"|' "$P/.machine_readable/arrival-pack/generate.sh" +grep -q 'regenerated usage' "$P/.machine_readable/arrival-pack/generate.sh" || fail 'fixture: the usage generator was not added' +# The generator's intermediate data, force-added: an artefact, never carried +# or declared (Codex review of #277). +[ -f "$P/.machine_readable/arrival-pack/claude-md-data.json" ] || fail 'fixture: the mint left no claude-md-data.json' +g -C "$P" add -f .machine_readable/arrival-pack/claude-md-data.json +# A file the grandchild's checkout will turn into a directory (below). +printf 'plain\n' > "$P/notes/to-become-dir.txt" +# A file the parent tracks although an ignore rule matches it: still its own. +mkdir -p "$P/_build"; printf 'kept\n' > "$P/_build/keep.txt" +g -C "$P" check-ignore -q _build/keep.txt || fail 'fixture: _build/ is not ignored' +g -C "$P" add -f _build/keep.txt +# A file under a filter the grandchild's checkout configures (below), whose +# content the rename pass changes, so the write-back must check it out. +printf 'elder-proj filtered note\n' > "$P/notes/elder.bad" +# A carried gate with a side effect: what it writes while the gates run is +# not part of the result (Codex review of #277). +sed -i '1a printf "side\\n" > gate-output.txt' "$P/scripts/check-container-pins.sh" +grep -q 'gate-output.txt' "$P/scripts/check-container-pins.sh" || fail 'fixture: the gate side effect was not added' +# The identity template's path, added back as the parent's own file: the +# deed's (mint-answers) still marks the parent as minted, so the grandchild is +# chain-minted and the file carried as its work (Codex review of #277). +mkdir -p "$P/build/templates"; printf 'my own identity notes\n' > "$P/build/templates/identity.deed.in" +g -C "$P" add -A; g -C "$P" commit -q -m "the parent's own work" +pass "fixture: a minted parent with its own edit, addition and removal" + +# ── The grandchild: "Use this template" on the parent, then a chain mint ──── +G="$work/grandchild" +use_template "$P" HEAD "$G" git@github.com:other-owner/grand-check.git +# Uncommitted work in the checkout is carried too, and :derived-from-tree must +# name the snapshot actually used, not HEAD^{tree} (Codex review of #277). +printf 'local\n' > "$G/notes/local.txt" +# A tracked file turned into a directory holding an ignored file: the ignored +# file must not be read into the chain (Codex review of #277: tar recursed into +# the directory and carried it). +rm "$G/notes/to-become-dir.txt"; mkdir "$G/notes/to-become-dir.txt" +printf 'not for the chain\n' > "$G/notes/to-become-dir.txt/secret.txt" +printf '/notes/to-become-dir.txt/secret.txt\n' >> "$G/.git/info/exclude" +HEAD_TREE=$(g -C "$G" rev-parse 'HEAD^{tree}') +cp "$G/.git/index" "$work/snap.index" # tracked files stay, ignore rules or not +P_TREE=$(cd "$G" && GIT_INDEX_FILE="$work/snap.index" git add -A . && GIT_INDEX_FILE="$work/snap.index" git write-tree) +[ "$P_TREE" != "$HEAD_TREE" ] || fail 'fixture: the uncommitted file did not change the tree' +# Under a global init.templateDir whose info/attributes would re-encode the +# template clone's checkouts: the chain's clone must not take it (Codex review +# of #277). +mkdir -p "$work/inittpl/info"; printf 'README.adoc working-tree-encoding=UTF-16\n' > "$work/inittpl/info/attributes" +MINT_GITCONFIG="$(printf '[init]\n\ttemplateDir = %s\n' "$work/inittpl")" +mint "$G" "$work/chain.log" grand-check other-owner "Other Author" other@example.org "Grand tools" \ + || { tail -40 "$work/chain.log" >&2; fail 'the chain mint failed'; } +MINT_GITCONFIG="" +grep -q 'minted as a chain' "$work/chain.log" || fail 'repo-init did not take the chain path' +pass "a minted repo is minted again, as a chain" + +DEED="$G/grand-check_chora.deed" +[ -f "$DEED" ] && [ ! -e "$G/elder-proj_chora.deed" ] || fail 'the deed is not the grandchild'"'"'s alone' +field() { bash "$G/scripts/deed-field.sh" "$DEED" "$@"; } +[ "$(field provenance template-repo)" = "$T_SLUG" ] && [ "$(field provenance template-commit)" = "$T_COMMIT" ] \ + || fail "provenance names $(field provenance template-repo)@$(field provenance template-commit), not the root template" +[ "$(field provenance derived-from)" = elder-owner/elder-proj ] || fail 'provenance does not name the parent' +[ "$(field provenance derived-from-tree)" = "$P_TREE" ] || fail 'provenance does not name the parent tree' +[ "$(field mint-answers author)" = "Other Author" ] && [ "$(field mint-answers description)" = "Grand tools" ] \ + || fail '(mint-answers) are not the grandchild'"'"'s' +pass "provenance names the root template commit and the parent; the answers are the grandchild's" + +# Nothing of the parent's identity or contacts survives (the Codex finding). +# A file identical to the template's own copy is template text, not a leak +# (this test and the recipe name these fixtures on purpose). +leak=$(cd "$G" && git ls-files -co --exclude-standard | grep -vxF grand-check_chora.deed \ + | while IFS= read -r f; do + [ -f "$f" ] || continue + g -C "$T" show "$T_COMMIT:$f" 2>/dev/null | cmp -s -- - "$f" && continue + grep -lIE 'elder-proj|Eldra Author|elder@example\.org|Elder tools' -- "$f" + done || true) +[ -z "$leak" ] || { echo "$leak" >&2; fail 'the parent'"'"'s identity or contacts survive in the files above'; } +grep -q 'Eldra Author\|elder@example.org' "$DEED" && fail 'the deed carries the parent'"'"'s author' +grep -qF 'Grand tools' "$G/README.adoc" && grep -qF 'other@example.org' "$G/.github/SECURITY.adoc" 2>/dev/null \ + || grep -rqF 'other@example.org' "$G/.github" || fail 'the new contacts were not rendered' +pass "nothing of the parent's identity, author or contacts survives; the new answers are rendered" + +# The parent's own work is carried, renamed, and declared. The rename is the +# self-name rule: the repo name and the owner/repo slug, never a bare owner +# (an owner is shared with real estate repos, the same reason the template's +# owner pass is narrow). +tail -n 1 "$G/README.adoc" | grep -qx 'Notes for grand-check users.' || fail 'the parent'"'"'s README edit was not carried and renamed' +grep -qx 'grand-check notes, see https://github.com/other-owner/grand-check' "$G/notes/elder-proj-notes.md" \ + || fail 'the parent'"'"'s added file was not carried and renamed' +[ ! -e "$G/.machine_readable/ai/PLACEHOLDERS.adoc" ] || fail 'the parent'"'"'s removal was not carried' +[ ! -e "$G/tests/e2e/template_instantiation_test.sh" ] || fail 'the deletion of a verbatim-listed file was not carried' +[ -f "$G/notes/local.txt" ] || fail 'uncommitted work in the checkout was not carried' +bash "$G/scripts/deed-field.sh" --list "$DEED" variant/paths added | grep -qx notes/elder-proj-notes.md \ + && bash "$G/scripts/deed-field.sh" --list "$DEED" variant/paths diverged | grep -qx README.adoc \ + && bash "$G/scripts/deed-field.sh" --list "$DEED" variant/paths removed | grep -qx .machine_readable/ai/PLACEHOLDERS.adoc \ + || { grep -n ':added\|:removed\|:diverged' "$DEED" >&2; fail 'the carried work is not declared in (variant (paths))'; } +bash "$G/scripts/deed-field.sh" --list "$DEED" variant/paths added | grep -qxF 'notes/R&D | "q".md' \ + || { grep -n ':added' "$DEED" >&2; fail 'a path with & | and quotes was not declared intact'; } +[ -L "$G/SUPPORT.adoc" ] && bash "$G/scripts/deed-field.sh" --list "$DEED" variant/paths diverged | grep -qx SUPPORT.adoc \ + || fail 'the type change (file to symlink) was not carried and declared' +added() { bash "$G/scripts/deed-field.sh" --list "$DEED" variant/paths added; } +[ -f "$G/notes/back\\slash.txt" ] && [ -f "$G/-lead.txt" ] \ + && added | grep -qxF 'notes/back\slash.txt' && added | grep -qxF -- '-lead.txt' \ + || { grep -n ':added' "$DEED" >&2; fail 'a path holding a backslash or a leading dash was not carried and declared intact'; } +[ -f "$G/archetypes/custom.txt" ] && added | grep -qx archetypes/custom.txt \ + && added | grep -qx _build/keep.txt && [ -n "$(g -C "$G" ls-files -- _build/keep.txt)" ] \ + || fail 'a tracked file an ignore rule matches was not carried, tracked and declared' +[ "$(cat "$G/notes/extra-sonar.properties")" = "$(printf 'sonar.organization=other-owner\nsonar.projectKey=other-owner_grand-check')" ] \ + || { cat "$G/notes/extra-sonar.properties" >&2; fail 'a carried identity setting kept the parent'"'"'s owner'; } +pass "the parent's edit, addition and removal are carried, renamed, and declared" +pass "odd path bytes, an addition under a removed directory and a carried owner setting are handled" +[ "$(cat "$G/generated-extra.txt" 2>/dev/null)" = extra ] && added | grep -qx generated-extra.txt \ + || { grep -n ':added' "$DEED" >&2; fail 'a file written by a carried generator was not measured and declared'; } +diverged() { bash "$G/scripts/deed-field.sh" --list "$DEED" variant/paths diverged; } +removed() { bash "$G/scripts/deed-field.sh" --list "$DEED" variant/paths removed; } +[ "$(cat "$G/docs/usage.adoc" 2>/dev/null)" = 'regenerated usage' ] && diverged | grep -qx docs/usage.adoc \ + && ! removed | grep -qx docs/usage.adoc \ + || { grep -n ':removed\|:diverged' "$DEED" >&2; fail 'a deleted file a carried generator recreated is not declared diverged'; } +if grep -qF 'claude-md-data.json' "$DEED"; then fail 'the generator'"'"'s force-added intermediate data was declared'; fi +if grep -qF 'to-become-dir.txt/secret' "$DEED" || g -C "$G" ls-files -co --exclude-standard | grep -qF 'to-become-dir.txt/secret'; then + fail 'an ignored file inside a path that became a directory was carried' +fi +[ "$(cat "$G/notes/to-become-dir.txt/secret.txt")" = 'not for the chain' ] || fail 'the ignored file was changed' +pass "a carried generator's output is declared; an ignored file is never read into the chain" +[ "$(cat "$G/build/templates/identity.deed.in" 2>/dev/null)" = 'my own identity notes' ] \ + && added | grep -qx build/templates/identity.deed.in \ + || { grep -n ':added' "$DEED" >&2; fail 'a parent that added the identity template back was not chain-minted with it carried'; } +pass "a minted parent carrying build/templates/identity.deed.in is still chain-minted" +[ ! -e "$G/gate-output.txt" ] && ! grep -q 'gate-output.txt' "$DEED" \ + || fail 'a file a gate wrote while the gates ran was carried into the result' +pass "what the gates write while they run is not part of the result" + +# The grandchild's drift gate replays ITS mint: green, carried work declared. +g -C "$G" add -A; g -C "$G" commit -q -m "chain mint" +(cd "$G" && RSR_MINT_TOOL_CACHE="$work/tools" bash scripts/check-variant-drift.sh "$T") > "$work/drift.log" 2>&1 \ + || { cat "$work/drift.log" >&2; fail 'the grandchild'"'"'s drift gate is red'; } +(cd "$G" && bash scripts/gen-repo-map.sh . >/dev/null) && g -C "$G" diff --quiet -- docs/architecture/REPOSITORY-MAP.adoc \ + || fail 'the grandchild'"'"'s map is stale' +pass "the grandchild's drift gate (a replay of its own mint) is green; its map is fresh" + +# ── Negative control: a parent edit that conflicts with the new rendering ─── +# The parent rewrote the rendered description line; the new mint renders a +# different description on that line. That is a real conflict: stop, write +# nothing, name the file. +P2="$work/parent2" +g clone -q --no-local "$P" "$P2" +sed -i 's/^Elder tools$/Elder tools, rewritten by hand/' "$P2/README.adoc" +grep -qx 'Elder tools, rewritten by hand' "$P2/README.adoc" || fail 'fixture: the description line was not found' +g -C "$P2" commit -q -am "hand edit of a rendered line" +G2="$work/grandchild2" +use_template "$P2" HEAD "$G2" git@github.com:other-owner/grand-check.git +if mint "$G2" "$work/conflict.log" grand-check other-owner "Other Author" other@example.org "Grand tools"; then + fail 'a conflicting parent edit was merged silently' +fi +grep -q 'conflict' "$work/conflict.log" && grep -q 'README.adoc' "$work/conflict.log" \ + || { tail -20 "$work/conflict.log" >&2; fail 'the conflict was not reported with its file'; } +[ -z "$(g -C "$G2" status --porcelain)" ] || fail 'the refused chain mint wrote to the tree' +pass "(-) a conflicting parent edit stops the chain before it writes, and names the file" + +# ── Negative control: a parent minted before (mint-answers) is refused ────── +G3="$work/grandchild3" +use_template "$P" HEAD "$G3" git@github.com:other-owner/grand-check.git +bash "$G3/scripts/deed-clause.sh" replace "$G3/elder-proj_chora.deed" mint-answers /dev/null +! bash "$G3/scripts/deed-field.sh" --has "$G3/elder-proj_chora.deed" mint-answers || fail 'fixture: (mint-answers) still present' +# Nor the identity template the main parent added back: an old child has neither. +g -C "$G3" rm -q build/templates/identity.deed.in +g -C "$G3" commit -q -am "an old child" +if mint "$G3" "$work/old.log" grand-check other-owner "Other Author" other@example.org "Grand tools"; then + fail 'a parent without (mint-answers) was minted' +fi +grep -q 'records no (mint-answers)' "$work/old.log" || { tail -10 "$work/old.log" >&2; fail 'the refusal did not say why'; } +[ -z "$(g -C "$G3" status --porcelain)" ] || fail 'the refused mint wrote to the tree' +pass "(-) a parent minted before (mint-answers) is refused before anything is written" + +# ── Negative control: a failure after the merge writes nothing ────────────── +# The parent carries a broken arrival-pack source: both replays pass (they use +# the pinned template), the merge succeeds, then regenerating CLAUDE.md from +# the merged tree fails. Every step runs in a scratch copy first, so the +# grandchild is untouched (Codex review of #277: the checkout used to be +# replaced before the later steps ran). (A carried unfilled token is refused +# even earlier, by check-mint-tokens.sh, before anything is written.) +P4="$work/parent4" +g clone -q --no-local "$P" "$P4" +printf 'this is not nickel\n' >> "$P4/.machine_readable/arrival-pack/arrival-pack.ncl" +g -C "$P4" commit -q -am "a broken arrival-pack source" +G4="$work/grandchild4" +use_template "$P4" HEAD "$G4" git@github.com:other-owner/grand-check.git +if mint "$G4" "$work/late.log" grand-check other-owner "Other Author" other@example.org "Grand tools"; then + fail 'a chain mint carrying a broken arrival-pack source succeeded' +fi +grep -q 'arrival pack could not be regenerated; nothing was written' "$work/late.log" || { tail -20 "$work/late.log" >&2; fail 'the late failure did not say why'; } +[ -z "$(g -C "$G4" status --porcelain)" ] || { g -C "$G4" status --short | head >&2; fail 'a failure after the merge wrote to the tree'; } +pass "(-) a failure after the merge (a broken carried source) leaves the tree untouched" + +# ── Negative control: a chain mint keeps the parent's archetype ───────────── +# `just repo-init ` on a chain must not die on the absent +# archetypes/ (Codex review of #277); naming a different archetype is refused. +G5="$work/grandchild5" +use_template "$P" HEAD "$G5" git@github.com:other-owner/grand-check.git +if (cd "$G5" && env -i PATH="$PATH" HOME="$HOME" RSR_NON_INTERACTIVE=1 just repo-init julia-library "$work/arch.log" 2>&1; then + fail 'a chain mint accepted a different archetype' +fi +grep -q "was minted as archetype 'none'; a chain mint keeps it" "$work/arch.log" || { cat "$work/arch.log" >&2; fail 'the archetype refusal did not say why'; } +[ -z "$(g -C "$G5" status --porcelain)" ] || fail 'the refused archetype wrote to the tree' +pass "(-) a chain mint refuses an archetype other than the parent's, before writing" + +# ── Negative control: parent-controlled symlinks never redirect a write ───── +# The parent makes its identity file CLAUDE.md, and a path the mint writes +# (the arrival pack's generated data), symlinks to files outside the chain's +# scratch copy (Codex review of #277). The identity file is held in git's +# index, so its link is never written through; the generated path is refused +# by name before anything is written. Neither target changes. +P6="$work/parent6" +g clone -q --no-local "$P" "$P6" +printf 'untouched\n' > "$work/sentinel-identity"; printf 'untouched\n' > "$work/sentinel-output" +rm "$P6/CLAUDE.md"; ln -s "$work/sentinel-identity" "$P6/CLAUDE.md" +rm -f "$P6/.machine_readable/arrival-pack/claude-md-data.json" # the parent force-tracks it +ln -s "$work/sentinel-output" "$P6/.machine_readable/arrival-pack/claude-md-data.json" +g -C "$P6" add -f -A; g -C "$P6" commit -q -m "symlinks out" +G6="$work/grandchild6" +use_template "$P6" HEAD "$G6" git@github.com:other-owner/grand-check.git +if mint "$G6" "$work/links.log" grand-check other-owner "Other Author" other@example.org "Grand tools"; then + fail 'a chain mint wrote through a carried symlink' +fi +grep -q 'symlink at .machine_readable/arrival-pack/claude-md-data.json' "$work/links.log" || { tail -20 "$work/links.log" >&2; fail 'the carried output symlink was not refused by name'; } +[ "$(cat "$work/sentinel-identity")" = untouched ] && [ "$(cat "$work/sentinel-output")" = untouched ] \ + || fail 'a chain mint wrote through a parent-controlled symlink' +[ -z "$(g -C "$G6" status --porcelain)" ] || fail 'the refused chain mint wrote to the tree' +pass "(-) parent-controlled symlinks are never written through; one at a written path is refused" +# The arrival-pack generator writes temporary files too: a carried symlink at +# one is refused by name (Codex review of #277). +P8="$work/parent8" +g clone -q --no-local "$P" "$P8" +printf 'untouched\n' > "$work/sentinel-region" +ln -s "$work/sentinel-region" "$P8/.machine_readable/arrival-pack/.region.tmp" +g -C "$P8" add -f .machine_readable/arrival-pack/.region.tmp; g -C "$P8" commit -q -m "a symlink at a temporary path" +G8="$work/grandchild8" +use_template "$P8" HEAD "$G8" git@github.com:other-owner/grand-check.git +if mint "$G8" "$work/region.log" grand-check other-owner "Other Author" other@example.org "Grand tools"; then + fail 'a chain mint ran with a symlink at the generator'"'"'s temporary path' +fi +grep -q 'symlink at .machine_readable/arrival-pack/.region.tmp' "$work/region.log" \ + || { tail -20 "$work/region.log" >&2; fail 'the symlink at the temporary path was not refused by name'; } +[ "$(cat "$work/sentinel-region")" = untouched ] && [ -z "$(g -C "$G8" status --porcelain)" ] \ + || fail 'the refused chain mint wrote through the symlink or to the tree' +pass "(-) a symlink at the generator's temporary path is refused before anything is written" + +# The baselines replay the mint with the wrapper at the pin: a parent that +# edited its own copy cannot change what counts as its own work (Codex review +# of #277). This one removes README.adoc after every replay. +P11="$work/parent11" +g clone -q --no-local "$P" "$P11" +g -C "$P11" reset -q --hard HEAD~1 # the parent's mint, before its own work +printf 'rm -f "$OUT/README.adoc"\n' >> "$P11/scripts/replay-mint.sh" +g -C "$P11" commit -q -am "an edited replay wrapper" +G11="$work/grandchild11" +use_template "$P11" HEAD "$G11" git@github.com:other-owner/grand-check.git +mint "$G11" "$work/wrapper.log" grand-check other-owner "Other Author" other@example.org "Grand tools" \ + || { tail -40 "$work/wrapper.log" >&2; fail 'the chain mint of a parent with an edited replay wrapper failed'; } +if grep -qE 'elder-proj|Elder tools|Eldra Author' "$G11/README.adoc" \ + || bash "$G11/scripts/deed-field.sh" --list "$G11/grand-check_chora.deed" variant/paths added | grep -qx README.adoc; then + fail 'the parent'"'"'s edited replay wrapper redefined the baseline: its README was carried as its own work' +fi +grep -qF 'Grand tools' "$G11/README.adoc" || fail 'the README was not rendered for the grandchild' +# The grandchild carries the edited wrapper (its parent's work, declared), and +# its own drift gate replays with the wrapper at the pin too (Codex review of +# #277): green. +g -C "$G11" add -A; g -C "$G11" commit -q -m "chain mint" +(cd "$G11" && RSR_MINT_TOOL_CACHE="$work/tools" bash scripts/check-variant-drift.sh "$T") > "$work/wrapperdrift.log" 2>&1 \ + || { cat "$work/wrapperdrift.log" >&2; fail 'the drift gate replayed with the carried, edited wrapper'; } +pass "the baselines replay with the wrapper at the pin, not the parent's edited copy; so does the drift gate" + +# A path holding byte 0x01, the deed reader's record separator, cannot be +# declared: refused by name, before anything is written (Codex review of #277). +P12="$work/parent12" +g clone -q --no-local "$P" "$P12" +printf 'x\n' > "$P12/notes/soh"$'\001'"name.txt" +g -C "$P12" add -A; g -C "$P12" commit -q -m "a path holding 0x01" +G12="$work/grandchild12" +use_template "$P12" HEAD "$G12" git@github.com:other-owner/grand-check.git +if mint "$G12" "$work/soh.log" grand-check other-owner "Other Author" other@example.org "Grand tools"; then + fail 'a chain mint declared a path holding byte 0x01' +fi +grep -q 'newline or byte 0x01' "$work/soh.log" || { tail -20 "$work/soh.log" >&2; fail 'the 0x01 path was not refused by name'; } +[ -z "$(g -C "$G12" status --porcelain)" ] || fail 'the refused chain mint wrote to the tree' +pass "(-) a carried path holding byte 0x01 is refused before anything is written" + +# A smudge filter that fails during the write-back stops the chain before the +# checkout is touched: read-tree -u is not atomic (Codex review of #277). +G10="$work/grandchild10" +use_template "$P" HEAD "$G10" git@github.com:other-owner/grand-check.git +g -C "$G10" config filter.bad.clean cat; g -C "$G10" config filter.bad.smudge false; g -C "$G10" config filter.bad.required true +printf '*.bad filter=bad\n' >> "$G10/.gitattributes" +g10_before=$(g -C "$G10" status --porcelain --untracked-files=all); g10_readme=$(sha256sum < "$G10/README.adoc") +if mint "$G10" "$work/filter.log" grand-check other-owner "Other Author" other@example.org "Grand tools"; then + fail 'a chain mint went ahead with a smudge filter that fails' +fi +grep -q 'a filter failed' "$work/filter.log" || { tail -20 "$work/filter.log" >&2; fail 'the failing filter was not reported'; } +[ "$(g -C "$G10" status --porcelain --untracked-files=all)" = "$g10_before" ] \ + && [ "$(sha256sum < "$G10/README.adoc")" = "$g10_readme" ] && [ "$(cat "$G10/notes/elder.bad")" = 'elder-proj filtered note' ] \ + || { g -C "$G10" status --porcelain >&2; fail 'the chain mint wrote part of the result before the filter failed'; } +pass "(-) a smudge filter that fails is found before anything is written" + +# ── A declaration that changes only in category ───────────────────────────── +# A parent, just minted, deletes .clinerules and changes its carried generator +# to recreate it: measured before the generator runs, the path is removed; +# after, it is diverged, beside the generator itself. Both measures list the +# same paths in the same order, so only the categories tell them apart (Codex +# review of #277): the deed must declare it diverged, and the drift gate agree. +P7="$work/parent7" +g clone -q --no-local "$P" "$P7" +g -C "$P7" reset -q --hard HEAD~1 # the parent's mint, before its own work +[ -f "$P7/.clinerules" ] || fail 'fixture: the mint produced no .clinerules' +g -C "$P7" rm -q .clinerules +sed -i 's|^set -euo pipefail$|&\nprintf '"'"'regenerated rules\\n'"'"' > "$(git rev-parse --show-toplevel)/.clinerules"|' "$P7/.machine_readable/arrival-pack/generate.sh" +g -C "$P7" commit -q -am "the generator owns .clinerules" +G7="$work/grandchild7" +use_template "$P7" HEAD "$G7" git@github.com:other-owner/grand-check.git +mint "$G7" "$work/category.log" grand-check other-owner "Other Author" other@example.org "Grand tools" \ + || { tail -40 "$work/category.log" >&2; fail 'the category-only chain mint failed'; } +D7="$G7/grand-check_chora.deed" +[ "$(cat "$G7/.clinerules")" = 'regenerated rules' ] \ + && bash "$G7/scripts/deed-field.sh" --list "$D7" variant/paths diverged | grep -qx .clinerules \ + && ! bash "$G7/scripts/deed-field.sh" --list "$D7" variant/paths removed | grep -qx .clinerules \ + || { grep -n ':removed\|:diverged' "$D7" >&2; fail 'a path whose category alone changed was declared by its first measure'; } +g -C "$G7" add -A; g -C "$G7" commit -q -m "chain mint" +(cd "$G7" && RSR_MINT_TOOL_CACHE="$work/tools" bash scripts/check-variant-drift.sh "$T") > "$work/catdrift.log" 2>&1 \ + || { cat "$work/catdrift.log" >&2; fail 'the drift gate is red after a category-only change'; } +pass "a path whose category alone changes after the generators run is declared by its final category" + +# ── Negative control: a sparse checkout is refused ────────────────────────── +# Its absent paths would read as deletions of most of the repository (Codex +# review of #277). +G7="$work/grandchild7" +use_template "$P" HEAD "$G7" git@github.com:other-owner/grand-check.git +# The mint machinery stays checked out; docs/ and tests/ do not. +g -C "$G7" sparse-checkout set scripts build .machine_readable notes +[ ! -e "$G7/docs" ] && [ -e "$G7/scripts/chain-mint.sh" ] || fail 'fixture: the checkout is not sparse' +if mint "$G7" "$work/sparse.log" grand-check other-owner "Other Author" other@example.org "Grand tools"; then + fail 'a chain mint ran in a sparse checkout' +fi +grep -q 'this checkout is sparse' "$work/sparse.log" || { tail -20 "$work/sparse.log" >&2; fail 'the sparse refusal did not say why'; } +[ -z "$(g -C "$G7" status --porcelain)" ] || fail 'the refused sparse chain mint wrote to the tree' +pass "(-) a chain mint in a sparse checkout is refused before anything is written" + +# ── Negative controls: a checkout a chain mint cannot read faithfully ─────── +# An unresolved merge, and a tracked path beneath a symlink, are refused before +# anything is read or written (Codex review of #277). +GU="$work/grandchild-unmerged" +use_template "$P" HEAD "$GU" git@github.com:other-owner/grand-check.git +g -C "$GU" checkout -q -b side; printf 'side\n' >> "$GU/README.adoc"; g -C "$GU" commit -q -am side +g -C "$GU" checkout -q main; printf 'main\n' >> "$GU/README.adoc"; g -C "$GU" commit -q -am main +if g -C "$GU" merge -q side >/dev/null 2>&1; then fail 'fixture: the merge did not conflict'; fi +gu_before=$(g -C "$GU" status --porcelain) +if mint "$GU" "$work/unmerged.log" grand-check other-owner "Other Author" other@example.org "Grand tools"; then + fail 'a chain mint ran over an unresolved merge' +fi +grep -q 'unresolved merge' "$work/unmerged.log" || { tail -20 "$work/unmerged.log" >&2; fail 'the unresolved merge was not refused by name'; } +[ "$(g -C "$GU" status --porcelain)" = "$gu_before" ] || fail 'the refused chain mint wrote to the tree' +GL="$work/grandchild-link" +use_template "$P" HEAD "$GL" git@github.com:other-owner/grand-check.git +mv "$GL/notes" "$work/outside-notes"; ln -s "$work/outside-notes" "$GL/notes" +gl_before=$(g -C "$GL" status --porcelain) +if mint "$GL" "$work/linkdir.log" grand-check other-owner "Other Author" other@example.org "Grand tools"; then + fail 'a chain mint read tracked paths through a symlink' +fi +grep -q 'lies beneath the symlink notes' "$work/linkdir.log" || { tail -20 "$work/linkdir.log" >&2; fail 'the symlinked directory was not refused by name'; } +[ "$(g -C "$GL" status --porcelain)" = "$gl_before" ] || fail 'the refused chain mint wrote to the tree' +pass "(-) an unresolved merge, and tracked paths beneath a symlink, are refused before anything is read" + +# ── Negative control: the result must pass the child's own gates ──────────── +# A carried shell script without an SPDX header fails check-spdx-headers, as a +# normal mint would; the chain stops before writing (Codex review of #277). +P9="$work/parent9" +g clone -q --no-local "$P" "$P9" +printf '#!/bin/sh\necho hi\n' > "$P9/notes/tool.sh" +g -C "$P9" add -A; g -C "$P9" commit -q -m "a script without a header" +G9="$work/grandchild9" +use_template "$P9" HEAD "$G9" git@github.com:other-owner/grand-check.git +if mint "$G9" "$work/gates.log" grand-check other-owner "Other Author" other@example.org "Grand tools"; then + fail 'a chain mint wrote a tree that fails its own gates' +fi +grep -q 'fails check-spdx-headers' "$work/gates.log" && grep -q 'notes/tool.sh' "$work/gates.log" \ + || { tail -30 "$work/gates.log" >&2; fail 'the gate failure was not named'; } +[ -z "$(g -C "$G9" status --porcelain)" ] || fail 'the gate-refused chain mint wrote to the tree' +pass "(-) a result that fails the child's own gates is refused before writing" + +# ── An answer-dependent rename: a Julia package's entry point ─────────────── +# The mint names src/.jl after the project, so a parent's edit to its +# entry point lives at another path in the grandchild. The merge carries the +# edit across that rename; the declaration must name where it now lives +# (Codex review of #277), which the grandchild's drift gate checks. +JP="$work/jparent" +use_template "$T" HEAD "$JP" git@github.com:elder-owner/ElderPkg.jl.git +mint "$JP" "$work/jparent.log" ElderPkg.jl elder-owner "Eldra Author" elder@example.org "Elder tools" julia-library ElderPkg \ + || { tail -30 "$work/jparent.log" >&2; fail 'the Julia parent mint failed'; } +[ -f "$JP/src/ElderPkg.jl" ] || fail 'fixture: the Julia parent has no src/ElderPkg.jl' +g -C "$JP" add -A; g -C "$JP" commit -q -m mint +sed -i 's/^# --- implementation .*/&\nanswer() = 42/' "$JP/src/ElderPkg.jl" +grep -qx 'answer() = 42' "$JP/src/ElderPkg.jl" || fail 'fixture: the entry-point edit did not apply' +g -C "$JP" commit -q -am "the parent's own code" +JG="$work/jgrandchild" +use_template "$JP" HEAD "$JG" git@github.com:other-owner/GrandPkg.jl.git +mint "$JG" "$work/jchain.log" GrandPkg.jl other-owner "Other Author" other@example.org "Grand tools" julia-library GrandPkg \ + || { tail -40 "$work/jchain.log" >&2; fail 'the Julia chain mint failed'; } +[ -f "$JG/src/GrandPkg.jl" ] && [ ! -e "$JG/src/ElderPkg.jl" ] && grep -qx 'answer() = 42' "$JG/src/GrandPkg.jl" \ + || { ls "$JG/src" >&2; fail 'the entry-point edit was not carried across the rename'; } +JDEED="$JG/GrandPkg.jl_chora.deed" +bash "$JG/scripts/deed-field.sh" --list "$JDEED" variant/paths diverged | grep -qx src/GrandPkg.jl \ + || { grep -n ':added\|:removed\|:diverged' "$JDEED" >&2; fail 'the renamed entry point is not declared where it now lives'; } +if bash "$JG/scripts/deed-field.sh" --list "$JDEED" variant/paths diverged | grep -qx src/ElderPkg.jl; then + fail 'the deed declares the parent'"'"'s entry-point path' +fi +g -C "$JG" add -A; g -C "$JG" commit -q -m "chain mint" +(cd "$JG" && RSR_MINT_TOOL_CACHE="$work/tools" bash scripts/check-variant-drift.sh "$T") > "$work/jdrift.log" 2>&1 \ + || { cat "$work/jdrift.log" >&2; fail 'the Julia grandchild'"'"'s drift gate is red'; } +pass "an edit carried across an answer-dependent rename is declared where it lives; the drift gate is green" +# A path the result writes must be free, before anything is written: here an +# ignored directory at the grandchild's future entry point (Codex review of +# #277: the checkout used to be emptied before the copy found it). Ignored +# through this checkout's info/exclude, and through a .gitignore, where git's +# own check sees the rule and treats the directory as expendable. +for how in exclude gitignore; do + JG2="$work/jgrandchild2-$how" + use_template "$JP" HEAD "$JG2" git@github.com:other-owner/GrandPkg.jl.git + mkdir -p "$JG2/src/GrandPkg.jl"; printf 'mine\n' > "$JG2/src/GrandPkg.jl/notes.txt" + case "$how" in + exclude) printf '/src/GrandPkg.jl/\n' >> "$JG2/.git/info/exclude" ;; + gitignore) printf '/src/GrandPkg.jl/\n' >> "$JG2/.gitignore" ;; + esac + g -C "$JG2" check-ignore -q src/GrandPkg.jl/notes.txt || fail "fixture ($how): the directory is not ignored" + jg2_before=$(g -C "$JG2" status --porcelain --untracked-files=all --ignored) + if mint "$JG2" "$work/collide-$how.log" GrandPkg.jl other-owner "Other Author" other@example.org "Grand tools" julia-library GrandPkg; then + fail "a chain mint overwrote an ignored directory ($how)" + fi + grep -q 'without overwriting' "$work/collide-$how.log" && grep -qx ' src/GrandPkg.jl/' "$work/collide-$how.log" \ + || { tail -20 "$work/collide-$how.log" >&2; fail "the collision was not refused by name ($how)"; } + [ "$(cat "$JG2/src/GrandPkg.jl/notes.txt")" = mine ] \ + && [ "$(g -C "$JG2" status --porcelain --untracked-files=all --ignored)" = "$jg2_before" ] \ + && [ -f "$JG2/src/ElderPkg.jl" ] || fail "the refused chain mint wrote to the tree ($how)" +done +pass "(-) an ignored entry where the result writes is refused by name before anything is written (info/exclude and .gitignore)" +# A parent that deleted its entry point: the deletion wins across the rename +# too, where the fresh mint has src/GrandPkg.jl, not a rename/delete conflict +# (Codex review of #277). +g -C "$JP" checkout -q -b drop-entry +g -C "$JP" rm -q src/ElderPkg.jl; g -C "$JP" commit -q -m "drop the entry point" +JG3="$work/jgrandchild3" +use_template "$JP" drop-entry "$JG3" git@github.com:other-owner/GrandPkg.jl.git +mint "$JG3" "$work/jdrop.log" GrandPkg.jl other-owner "Other Author" other@example.org "Grand tools" julia-library GrandPkg \ + || { tail -40 "$work/jdrop.log" >&2; fail 'a parent deletion across an answer-dependent rename stopped the chain'; } +[ ! -e "$JG3/src/GrandPkg.jl" ] && [ ! -e "$JG3/src/ElderPkg.jl" ] || { ls "$JG3/src" >&2; fail 'the deleted entry point came back'; } +bash "$JG3/scripts/deed-field.sh" --list "$JG3/GrandPkg.jl_chora.deed" variant/paths removed | grep -qx src/GrandPkg.jl \ + || { grep -n ':removed' "$JG3/GrandPkg.jl_chora.deed" >&2; fail 'the deletion is not declared where the fresh mint has the file'; } +g -C "$JG3" add -A; g -C "$JG3" commit -q -m "chain mint" +(cd "$JG3" && RSR_MINT_TOOL_CACHE="$work/tools" bash scripts/check-variant-drift.sh "$T") > "$work/jdropdrift.log" 2>&1 \ + || { cat "$work/jdropdrift.log" >&2; fail 'the grandchild of a parent without its entry point has a red drift gate'; } +pass "a parent deletion is carried across an answer-dependent rename and declared; the drift gate is green" + +# A parent that changed its entry point's type (a file to a symlink): the +# fresh mint puts that file elsewhere, so the change cannot be carried there. +# Refused by name, not carried beside the fresh file (Codex review of #277). +g -C "$JP" checkout -q -b retype-entry main 2>/dev/null || g -C "$JP" checkout -q -b retype-entry master +g -C "$JP" rm -q src/ElderPkg.jl; ln -s ../README.adoc "$JP/src/ElderPkg.jl"; g -C "$JP" add src/ElderPkg.jl +g -C "$JP" commit -q -m "the entry point becomes a symlink" +JG4="$work/jgrandchild4" +use_template "$JP" retype-entry "$JG4" git@github.com:other-owner/GrandPkg.jl.git +if mint "$JG4" "$work/jretype.log" GrandPkg.jl other-owner "Other Author" other@example.org "Grand tools" julia-library GrandPkg; then + fail 'a type change across an answer-dependent rename was carried' +fi +grep -q 'changed the type of src/ElderPkg.jl, which this mint renames to src/GrandPkg.jl' "$work/jretype.log" \ + || { tail -20 "$work/jretype.log" >&2; fail 'the type change across the rename was not refused by name'; } +[ -z "$(g -C "$JG4" status --porcelain)" ] || fail 'the refused chain mint wrote to the tree' +pass "(-) a type change across an answer-dependent rename is refused before anything is written" + +echo "PASS: chain mint" diff --git a/tests/e2e/use_template_mint_test.sh b/tests/e2e/use_template_mint_test.sh index fb4652c..442ef2a 100755 --- a/tests/e2e/use_template_mint_test.sh +++ b/tests/e2e/use_template_mint_test.sh @@ -28,6 +28,15 @@ # Usage: bash tests/e2e/use_template_mint_test.sh [--keep] set -euo pipefail repo=$(git rev-parse --show-toplevel) + +# This test mints the TEMPLATE. A minted child has no archetypes/ (the mint +# removes it) and no unfilled tokens to answer, so there is nothing to mint. +# Same guard as tests/e2e/template_instantiation_test.sh. +if [ ! -d "$repo/archetypes" ]; then + echo "Self-skipping: archetypes/ not found (repo is already instantiated)." + exit 0 +fi + work=$(mktemp -d) if [ "${1:-}" = "--keep" ]; then echo "keeping $work"; else trap 'rm -rf "$work"' EXIT; fi @@ -72,28 +81,44 @@ T_OWNER=$(sed -n -E 's/^OWNER[[:space:]]*:=[[:space:]]*"([^"]+)".*/\1/p' "$T/Jus TEMPLATE_REPO=$(sed -n -E 's/^REPO[[:space:]]*:=[[:space:]]*"([^"]+)".*/\1/p' "$T/Justfile" | head -1) T_SLUG="$T_OWNER/$TEMPLATE_REPO" +# materialize COMMIT DIR — COMMIT's tree written into DIR as a checkout writes +# it. Not git archive: "Use this template" copies the tree, and archive drops +# export-ignore paths and expands export-subst (Codex review of #275). +materialize() { + GIT_INDEX_FILE="$work/materialize.index" g -C "$T" read-tree "$1" + GIT_INDEX_FILE="$work/materialize.index" g -C "$T" checkout-index -a -f --prefix="$2/" + rm -f "$work/materialize.index" +} # use_template DIR — what "Use this template" creates: one root commit with # the template's tree, no shared history, origin = the new repository. use_template() { rm -rf "$1"; mkdir -p "$1" - g -C "$T" archive "$T_COMMIT" | tar x -C "$1" + materialize "$T_COMMIT" "$1" g -C "$1" init -q -b main g -C "$1" add -A g -C "$1" commit -q -m "Initial commit" g -C "$1" remote add origin git@github.com:example-owner/mint-check.git } +# The description carries `&` and `|`, which the substitution used to read as +# sed syntax (`&` inserted the token itself). MINT_ENV adds per-call settings. +DESC='Tools & proofs | checked' +MINT_ENV=() +MINT_ARCH="" + # mint DIR LOG — the non-interactive mint, isolated from this host's defaults # file and global git config (an ORCID or signing key there would leak in). mint() { - local cfg="$work/cfg"; mkdir -p "$cfg"; : > "$cfg/gitconfig" + # The user's global excludesFile ignores *.mine-local (CodeRabbit review of #275). + local cfg="$work/cfg"; mkdir -p "$cfg"; printf '*.mine-local\n' > "$work/global-excludes" + printf '[core]\n\texcludesFile = %s\n' "$work/global-excludes" > "$cfg/gitconfig" (cd "$1" && env -i PATH="$PATH" HOME="$HOME" TMPDIR="${TMPDIR:-/tmp}" \ ${RUSTUP_HOME:+RUSTUP_HOME="$RUSTUP_HOME"} ${CARGO_HOME:+CARGO_HOME="$CARGO_HOME"} \ XDG_CONFIG_HOME="$cfg" GIT_CONFIG_GLOBAL="$cfg/gitconfig" GIT_CONFIG_NOSYSTEM=1 \ RSR_NON_INTERACTIVE=1 RSR_TEMPLATE_URL="$T" RSR_MINT_TOOL_CACHE="$work/tools" \ PROJECT_NAME="Mint Check" REPO=mint-check OWNER=example-owner \ - AUTHOR="Example Author" AUTHOR_EMAIL=author@example.org \ - timeout 900 just repo-init "$2" 2>&1 + AUTHOR="Example Author" AUTHOR_EMAIL=author@example.org PROJECT_DESCRIPTION="$DESC" \ + "${MINT_ENV[@]}" timeout 900 just repo-init ${MINT_ARCH:+"$MINT_ARCH"} "$2" 2>&1 } # ── 2. The child, with an ignored cache and an untracked probe ────────────── @@ -113,6 +138,16 @@ g -C "$C" check-ignore -q _build/dependencies/sibling/README.md || fail 'fixture # not-ignored file IS the repository's and must be rewritten. Without it, an # untouched cache could mean "the passes never ran" rather than "scoped". printf '%s\n' "$probe" > "$C/mint-probe.md" +# A pre-mint edit to a template file: the replay starts from the pinned tree, +# so the mint must declare this as the child's own (Codex review of #275). +printf '\nA local support note, written before the mint.\n' >> "$C/SUPPORT.adoc" +# A pre-mint edit to an INPUT of the mint: the docs seed it renders into +# docs/usage.adoc, then deletes. Only a replay sees what that changes (CodeRabbit +# review of #275). +printf '\nA local usage note, added to the seed before the mint.\n' >> "$C/build/docs-seed/usage.adoc" +# A file only the user's global excludesFile ignores: git add -A never commits +# it, so the mint must not declare it (CodeRabbit review of #275). +printf 'mine\n' > "$C/notes.mine-local" ignored_list() { (cd "$C" && git ls-files -o -i --exclude-standard | LC_ALL=C sort); } IGN_LIST=$(ignored_list) IGN_BEFORE=$(cd "$C" && printf '%s\n' "$IGN_LIST" | xargs -d '\n' sha256sum) @@ -139,7 +174,168 @@ grep -q 'stale entry' "$work/stale.log" || { cat "$work/stale.log" >&2; fail 'th [ -z "$(g -C "$M" status --porcelain)" ] || fail 'the stale-manifest mint wrote to the tree' pass "(b-) a stale mint-verbatim entry stops the mint before it writes" +# ── 4b. Control: a Justfile identity repo-init cannot read, no guess from origin +# The remote of a template-created child is the CHILD; falling back to it +# named the child as its own template (Codex review of #251). +J="$work/no-identity" +use_template "$J" +sed -i -E 's/^(OWNER|REPO)([[:space:]]*:=[[:space:]]*)"([^"]+)"/\1\2env("RSR_\1", "\3")/' "$J/Justfile" +grep -qE '^OWNER[[:space:]]*:=[[:space:]]*env\(' "$J/Justfile" || fail 'fixture: the OWNER declaration was not rewritten' +g -C "$J" commit -q --amend -am "Initial commit" +if mint "$J" "$work/no-identity.log"; then fail 'a Justfile without OWNER/REPO minted from a guessed template'; fi +grep -q 'RSR_TEMPLATE_SLUG' "$work/no-identity.log" \ + || { cat "$work/no-identity.log" >&2; fail 'the missing-identity abort did not say how to fix it'; } +[ -z "$(g -C "$J" status --porcelain)" ] || fail 'the missing-identity mint wrote to the tree' +pass "(a-) a Justfile identity repo-init cannot read stops the mint and names RSR_TEMPLATE_SLUG" + +# ── 4c. Control: an answer with a newline is refused before the first write ─ +# (Codex review of #274: the refusal used to come after the overlay, the +# prune and the docs seed had already written.) +NL="$work/newline" +use_template "$NL" +MINT_ENV=(PROJECT_DESCRIPTION=$'line one\nline two') +if mint "$NL" "$work/newline.log"; then fail 'an answer with a newline was accepted'; fi +MINT_ENV=() +grep -q 'contains a newline' "$work/newline.log" || { cat "$work/newline.log" >&2; fail 'the newline refusal did not say why'; } +[ -z "$(g -C "$NL" status --porcelain)" ] || { g -C "$NL" status --short | head >&2; fail 'the refused mint wrote to the tree'; } +pass "(a-) an answer containing a newline is refused before anything is written" + +# ── 4d. Control: an asserted pin whose tree this child does not hold ──────── +# The difference cannot be listed, so it cannot be declared: refused before +# writing (Codex review of #275), not minted into certain drift. +# The asserted commit is built here, on a side branch of the template, so the +# test does not depend on how much history the checkout has (CI clones shallow). +OLDTREE=$(cd "$T" && GIT_INDEX_FILE="$work/old.index" git read-tree "$T_TREE" \ + && GIT_INDEX_FILE="$work/old.index" git rm -q --cached SUPPORT.adoc \ + && GIT_INDEX_FILE="$work/old.index" git write-tree) +OLD=$(g -C "$T" commit-tree "$OLDTREE" -p "$T_COMMIT" -m "another template tree") +g -C "$T" branch -q another-tree "$OLD" +[ -n "$OLD" ] && [ "$OLDTREE" != "$T_TREE" ] || fail 'fixture: could not build another template tree' +AS="$work/asserted" +use_template "$AS" +MINT_ENV=(RSR_TEMPLATE_COMMIT="$OLD") +if mint "$AS" "$work/asserted.log"; then fail 'a mint against an asserted tree it cannot compare went ahead'; fi +MINT_ENV=() +grep -q 'which this repository does not hold' "$work/asserted.log" || { tail -20 "$work/asserted.log" >&2; fail 'the uncomparable asserted pin was not refused by name'; } +[ -z "$(g -C "$AS" status --porcelain)" ] || fail 'the refused mint wrote to the tree' +pass "(a-) an asserted template tree this child does not hold, and differs from, is refused before writing" + +# ── 4e. Control: a pre-mint change the mint would move is refused ─────────── +# The mint moves a root .well-known/ into www/.well-known/, so such a path +# could not be declared where it ends up (Codex review of #275). +WK="$work/wellknown" +use_template "$WK" +mkdir -p "$WK/.well-known"; printf 'local\n' > "$WK/.well-known/local.txt" +wk_before=$(g -C "$WK" status --porcelain --untracked-files=all) +if mint "$WK" "$work/wellknown.log"; then fail 'a pre-mint change under .well-known/ was minted'; fi +grep -q 'the mint moves .well-known/ into www/.well-known/' "$work/wellknown.log" || { tail -20 "$work/wellknown.log" >&2; fail 'the .well-known refusal did not say why'; } +[ "$(g -C "$WK" status --porcelain --untracked-files=all)" = "$wk_before" ] || fail 'the refused mint wrote to the tree' +pass "(a-) a pre-mint change under .well-known/, which the mint moves, is refused before writing" + +# ── 4f. Control: a pre-mint file at a path the mint itself writes ─────────── +# The docs seed writes docs/usage.adoc; a local file there would be +# overwritten, then declared as the child's own (Codex review of #275). +DS="$work/docsseed" +use_template "$DS" +mkdir -p "$DS/docs"; printf 'my own usage notes\n' > "$DS/docs/usage.adoc" +ds_before=$(g -C "$DS" status --porcelain --untracked-files=all) +if mint "$DS" "$work/docsseed.log"; then fail 'a pre-mint file at a path the mint writes was minted over'; fi +grep -q 'the mint writes that path itself' "$work/docsseed.log" && grep -q 'docs/usage.adoc' "$work/docsseed.log" \ + || { tail -20 "$work/docsseed.log" >&2; fail 'the overwrite refusal did not name the file'; } +[ "$(cat "$DS/docs/usage.adoc")" = 'my own usage notes' ] && [ "$(g -C "$DS" status --porcelain --untracked-files=all)" = "$ds_before" ] \ + || fail 'the refused mint wrote to the tree' +pass "(a-) a pre-mint file at a path the mint writes (the docs seed) is refused before writing" +# The same for an edited template file the overlay replaces: julia-library's +# overlay writes .machine_readable/descriptiles/provisioning_praxis.deed +# (Codex review of #275). +JO="$work/juliaoverlay" +use_template "$JO" +printf '; edited before the mint\n' >> "$JO/.machine_readable/descriptiles/provisioning_praxis.deed" +jo_before=$(g -C "$JO" status --porcelain --untracked-files=all) +MINT_ARCH=julia-library +if mint "$JO" "$work/juliaoverlay.log"; then fail 'an edited file the overlay replaces was minted over'; fi +MINT_ARCH="" +grep -q 'provisioning_praxis.deed changed before the mint' "$work/juliaoverlay.log" \ + || { tail -20 "$work/juliaoverlay.log" >&2; fail 'the overlay overwrite was not refused by name'; } +[ "$(g -C "$JO" status --porcelain --untracked-files=all)" = "$jo_before" ] || fail 'the refused mint wrote to the tree' +pass "(a-) an edited template file the archetype overlay replaces is refused before writing" +# And for any file the mint regenerates whole, whatever writes it: a dry run +# of the mint on this tree finds the edit has no effect (Codex review of #275: +# the roadmap is copied over, mise.toml rendered from the languages). Edits the +# mint keeps are not refused: README.adoc is rendered, and a CLAUDE.md note +# outside the arrival pack's markers is preserved by its generator. +RM="$work/roadmap" +use_template "$RM" +printf '\nmy own milestone\n' >> "$RM/docs/status/ROADMAP.adoc" +printf '\nmy own agent note\n' >> "$RM/CLAUDE.md" +printf '\n# my own tool pin\n' >> "$RM/mise.toml" +printf '\nA README note of my own.\n' >> "$RM/README.adoc" +rm_before=$(g -C "$RM" status --porcelain --untracked-files=all) +if mint "$RM" "$work/roadmap.log"; then fail 'an edit to a file the mint regenerates was minted over'; fi +grep -q 'the change would be discarded' "$work/roadmap.log" && grep -qx ' docs/status/ROADMAP.adoc' "$work/roadmap.log" \ + && grep -qx ' mise.toml' "$work/roadmap.log" \ + && ! grep -qx ' README.adoc' "$work/roadmap.log" && ! grep -qx ' CLAUDE.md' "$work/roadmap.log" \ + || { tail -20 "$work/roadmap.log" >&2; fail 'the discarded edits were not refused by name (and only they)'; } +[ "$(g -C "$RM" status --porcelain --untracked-files=all)" = "$rm_before" ] || fail 'the refused mint wrote to the tree' +pass "(a-) an edit the mint would discard (a regenerated file) is refused by name before writing" +# And a change gone from both results: an input the mint never copies (the +# seed's own README), a file in a directory it deletes. Each is checked by +# minting without it; one with an effect (a seed file it copies) is not +# refused (Codex review of #275). +LO="$work/lostinput" +use_template "$LO" +printf '\nmy own seed note\n' >> "$LO/build/docs-seed/README.adoc" +printf 'local\n' > "$LO/archetypes/premint-local.txt" +printf '\nA usage note of my own.\n' >> "$LO/build/docs-seed/usage.adoc" +lo_before=$(g -C "$LO" status --porcelain --untracked-files=all) +if mint "$LO" "$work/lostinput.log"; then fail 'pre-mint changes the mint destroys were minted over'; fi +grep -qx ' build/docs-seed/README.adoc' "$work/lostinput.log" && grep -qx ' archetypes/premint-local.txt' "$work/lostinput.log" \ + && ! grep -qx ' build/docs-seed/usage.adoc' "$work/lostinput.log" \ + || { tail -20 "$work/lostinput.log" >&2; fail 'the destroyed changes were not refused by name (and only they)'; } +[ "$(g -C "$LO" status --porcelain --untracked-files=all)" = "$lo_before" ] || fail 'the refused mint wrote to the tree' +pass "(a-) a pre-mint change the mint would destroy without effect is refused by name before writing" +# A pre-mint path holding byte 0x01 (the deed reader's record separator) or a +# newline cannot be declared: refused by name (Codex review of #277). +SO="$work/sohpath" +use_template "$SO" +printf 'x\n' > "$SO/notes-soh"$'\001'"name.txt" +so_before=$(g -C "$SO" status --porcelain --untracked-files=all) +if mint "$SO" "$work/sohpath.log"; then fail 'a pre-mint path holding byte 0x01 was minted'; fi +grep -q 'a path holding a newline or byte 0x01 cannot be declared' "$work/sohpath.log" \ + || { tail -20 "$work/sohpath.log" >&2; fail 'the 0x01 path was not refused by name'; } +[ "$(g -C "$SO" status --porcelain --untracked-files=all)" = "$so_before" ] || fail 'the refused mint wrote to the tree' +pass "(a-) a pre-mint path holding byte 0x01 is refused before writing" +# A git location naming ANOTHER repository cannot just be cleared: the mint +# would then act on a different repository than the caller named. Refused. +GD="$work/other-gitdir" +use_template "$GD" +gd_before=$(g -C "$GD" status --porcelain --untracked-files=all); gd_head=$(g -C "$T" rev-parse HEAD) +MINT_ENV=(GIT_DIR="$T/.git") +if mint "$GD" "$work/othergd.log"; then fail 'a mint ran with GIT_DIR naming another repository'; fi +MINT_ENV=() +grep -q 'name a repository other than this work tree' "$work/othergd.log" \ + || { tail -20 "$work/othergd.log" >&2; fail 'a foreign GIT_DIR was not refused by name'; } +[ "$(g -C "$GD" status --porcelain --untracked-files=all)" = "$gd_before" ] && [ "$(g -C "$T" rev-parse HEAD)" = "$gd_head" ] \ + || fail 'the refused mint wrote to a tree' +pass "(a-) a git location naming another repository is refused before anything is written" + # ── 5. The mint ───────────────────────────────────────────────────────────── +# The project type is an answer no template file renders: (mint-answers) +# must not record it (Codex review of #275), and the replay must still be green. +# GIT_DIR inherited, as a hook or another git caller leaves it, naming this +# child: the replay the pre-mint changes need must not commit into it +# (CodeRabbit review of #275). +C_HEAD=$(g -C "$C" rev-parse HEAD); C_COUNT=$(g -C "$C" rev-list --count HEAD) +# The project type is a value found only in archetypes/julia-library/ +# actions.lock, which this mint (no archetype) deletes: it must not count as +# written into the child (CodeRabbit review of #275). Chosen here, not written +# into this test, which the child keeps. +UNRENDERED="" +for w in $(tr -cs '0-9' '\n' < "$C/archetypes/julia-library/actions.lock" | awk 'length($0) >= 8' | sort -u); do + if [ "$(g -C "$C" grep -l -F "$w" | tr '\n' ' ')" = 'archetypes/julia-library/actions.lock ' ]; then UNRENDERED=$w; break; fi +done +[ -n "$UNRENDERED" ] || fail 'fixture: no value is found only in archetypes/julia-library/actions.lock' +MINT_ENV=(PROJECT_TYPE="$UNRENDERED" GIT_DIR="$C/.git") mint "$C" "$work/mint.log" || { cat "$work/mint.log" >&2 if [ "${GITHUB_ACTIONS:-}" = "true" ]; then @@ -148,6 +344,9 @@ mint "$C" "$work/mint.log" || { fi fail 'just repo-init failed on a Use-this-template child' } +MINT_ENV=() +[ "$(g -C "$C" rev-parse HEAD)" = "$C_HEAD" ] && [ "$(g -C "$C" rev-list --count HEAD)" = "$C_COUNT" ] \ + || { g -C "$C" log --oneline -3 >&2; fail 'the replay committed into the child through an inherited GIT_DIR'; } pass "the non-interactive mint completes on a Use-this-template child" DEED="$C/mint-check_chora.deed" [ -f "$DEED" ] || fail 'the child deed was not renamed to mint-check_chora.deed' @@ -161,6 +360,9 @@ field() { bash "$C/scripts/deed-field.sh" "$DEED" "$@"; } [ "$(field variant parent-pin)" = "$T_COMMIT" ] || fail "variant parent-pin is $(field variant parent-pin), want $T_COMMIT" if grep -qF "$C_HEAD" "$DEED"; then fail "the deed records the child's own commit $C_HEAD"; fi pass "(a) provenance and variant name template commit ${T_COMMIT:0:12} and its tree, never the child's HEAD" +grep -qF -- "$DESC" "$C/README.adoc" \ + || { grep -n 'Tools' "$C/README.adoc" >&2; fail 'the description did not render literally'; } +pass "an answer containing & and | renders literally" # ── (b) the fixture tests are carried unchanged, and pass in the child ────── while IFS= read -r v; do @@ -188,23 +390,205 @@ if grep -qF "${LB}OWNER${RB}" "$C/mint-probe.md"; then fail 'probe: the substitu # The probe stays: it was a repository file at mint, so the map lists it. pass "(c) ignored files are byte-identical, none created but the arrival-pack artefact; the same content in a repository file was rewritten" -# ── (d) the drift gate is no longer recommended for a minted repo ─────────── -if grep -q 'arm the drift gate' "$work/mint.log"; then fail 'repo-init still recommends arming the drift gate'; fi +# ── (d) the drift gate replays the mint, and a fresh mint is green ────────── +grep -q 'arm the drift gate' "$work/mint.log" || fail 'repo-init does not recommend arming the drift gate' grep -q 'scripts/check-template-conformance.sh' "$work/mint.log" || fail 'repo-init does not point at the conformance check' +# The answers are recorded, verbatim (the description carries & and |). +[ "$(field mint-answers description)" = "$DESC" ] || fail "(mint-answers :description) is '$(field mint-answers description)'" +[ "$(field mint-answers repo)" = mint-check ] && [ -n "$(field mint-answers mint-date)" ] \ + || fail '(mint-answers) lacks the repo or the mint date' +# Only values already written into the child are recorded (owner ruling). +if grep -q "$UNRENDERED\|:project-type" "$DEED"; then fail '(mint-answers) recorded an answer that no file renders'; fi +grep -q 'not recorded, written into no file here: project-type' "$work/mint.log" || fail 'the mint did not name the unrecorded answer' +# What the work tree held beyond the pinned tree before the mint (the probe, +# untracked; the SUPPORT.adoc edit) is the child's own: named, and declared +# (Codex review of #275), so the replay from the pin stays exact. +grep -q '^ :added ("mint-probe.md")' "$DEED" || { grep -n ':added' "$DEED" >&2; fail 'the pre-mint addition was not declared added'; } +bash "$C/scripts/deed-field.sh" --list "$DEED" variant/paths diverged | grep -qx SUPPORT.adoc \ + || { grep -n ':diverged' "$DEED" >&2; fail 'the pre-mint edit was not declared diverged'; } +grep -q 'already differ from the template tree' "$work/mint.log" && grep -q '^ SUPPORT.adoc$' "$work/mint.log" \ + || fail 'the mint did not name the pre-mint changes' +bash "$C/scripts/deed-field.sh" --list "$DEED" variant/paths diverged | grep -qx docs/usage.adoc \ + && grep -q 'A local usage note' "$C/docs/usage.adoc" \ + || { grep -n ':diverged' "$DEED" >&2; fail 'a pre-mint edit to the docs seed was not declared where it lands'; } +if grep -q 'notes.mine-local' "$DEED"; then fail 'a file only a global excludesFile ignores was declared'; fi +mv "$C/notes.mine-local" "$work/notes.mine-local" # this test's own git does not ignore it g -C "$C" add -A g -C "$C" commit -q -m "mint" +# Chain minting now supports children with recorded answers (owner ruling +# 2026-11-04; tests/e2e/chain_mint_test.sh). The refusal applies to legacy +# children without those replay inputs. Keep the fresh child intact for the +# conformance and drift checks below, and construct that legacy case explicitly. +LEGACY="$work/legacy-child" +cp -a "$C" "$LEGACY" +bash "$LEGACY/scripts/deed-clause.sh" replace "$LEGACY/$(basename "$DEED")" mint-answers /dev/null +if bash "$LEGACY/scripts/deed-field.sh" --has "$LEGACY/$(basename "$DEED")" mint-answers; then + fail 'legacy fixture still records mint answers' +fi +g -C "$LEGACY" add -A +g -C "$LEGACY" commit -q -m "legacy child without replay inputs" +if mint "$LEGACY" "$work/remint.log"; then fail 'a legacy child without mint answers was minted again'; fi +grep -q 'already been minted' "$work/remint.log" || { cat "$work/remint.log" >&2; fail 'the re-mint refusal did not say why'; } +grep -qF "records its template as ${T_SLUG}" "$work/remint.log" || fail 'the re-mint refusal does not name the template to use' +[ -z "$(g -C "$LEGACY" status --porcelain)" ] || fail 'the refused re-mint wrote to the tree' +pass "(e-) a legacy child without replay inputs stops before writing and names its template" (cd "$C" && bash scripts/check-template-conformance.sh --quiet) > "$work/conf.log" 2>&1 \ || { cat "$work/conf.log" >&2; fail 'the conformance check fails on a fresh mint'; } -# The gate itself must still FAIL here (no silent green), say why, and not -# list the mint's own removals, which the (variant) clause now declares. -if (cd "$C" && bash scripts/check-variant-drift.sh "$T") > "$work/drift.log" 2>&1; then - fail 'the drift gate turned green on a minted repo without (normalise) support: suspicious' -fi -grep -q 'records :role "minted-repo"' "$work/drift.log" || fail 'the drift gate does not explain its minted-repo findings' -if grep -q 'declare in paths.removed' "$work/drift.log"; then - grep 'declare in paths.removed' "$work/drift.log" >&2; fail 'the mint removed paths it did not declare' -fi -pass "(d) stage 8 points at the conformance check (green); the drift gate still fails loudly, removals declared" +# A fresh mint is identical to a replay of itself: green, by construction. +drift() { (cd "$C" && RSR_MINT_TOOL_CACHE="$work/tools" bash scripts/check-variant-drift.sh "$T") > "$work/drift.log" 2>&1; } +drift || { cat "$work/drift.log" >&2; fail 'the drift gate is red on a fresh mint'; } +grep -q 'identical to a replay of the mint' "$work/drift.log" || fail 'the drift gate did not replay the mint' +# The same file, present, under the same global configuration: not this repo's +# file, so not drift (CodeRabbit review of #275). +mv "$work/notes.mine-local" "$C/notes.mine-local" +(cd "$C" && GIT_CONFIG_GLOBAL="$work/cfg/gitconfig" RSR_MINT_TOOL_CACHE="$work/tools" bash scripts/check-variant-drift.sh "$T") > "$work/drift.log" 2>&1 \ + || { cat "$work/drift.log" >&2; fail 'the drift gate counts a file the global excludesFile ignores'; } +rm "$C/notes.mine-local" +pass "(d) the drift gate replays the mint from (provenance) and (mint-answers): green on a fresh mint" +# Negative control: an edit to a file the mint produced, and an undeclared +# addition, are each named. Then restored, and green again. +printf '\nedited after the mint\n' >> "$C/README.adoc" +printf 'stray\n' > "$C/stray.txt" +if drift; then fail 'the drift gate missed an edit and an undeclared addition'; fi +grep -q '^DRIFT: README.adoc differs' "$work/drift.log" || { cat "$work/drift.log" >&2; fail 'the edited README.adoc was not named'; } +grep -q '^DRIFT: stray.txt exists here' "$work/drift.log" || fail 'the undeclared stray.txt was not named' +[ "$(grep -c '^DRIFT:' "$work/drift.log")" -eq 2 ] || { cat "$work/drift.log" >&2; fail 'the drift gate reported more than the two changes'; } +g -C "$C" checkout -q -- README.adoc; rm "$C/stray.txt" +pass "(d-) an edited file and an undeclared addition are named; nothing else is" +# Each declaration is a claim the gate checks (Codex review of #275): a +# mode-only change, a file recreated under a declared-removed directory, and a +# declared addition that is gone are each named. +chmod +x "$C/README.adoc" +mkdir -p "$C/archetypes"; printf 'back\n' > "$C/archetypes/revived.txt" +rm "$C/mint-probe.md" +if drift; then fail 'the drift gate missed a mode change, a revived removal and a missing addition'; fi +grep -q '^DRIFT: README.adoc differs .*mode' "$work/drift.log" || { cat "$work/drift.log" >&2; fail 'the mode-only change was not named'; } +grep -q '^DRIFT: archetypes/revived.txt exists here but is declared removed' "$work/drift.log" || fail 'the revived removal was not named' +grep -q '^DRIFT: declared-added mint-probe.md is missing' "$work/drift.log" || fail 'the missing declared addition was not named' +[ "$(grep -c '^DRIFT:' "$work/drift.log")" -eq 3 ] || { cat "$work/drift.log" >&2; fail 'the drift gate reported more than the three changes'; } +chmod -x "$C/README.adoc"; rm -r "$C/archetypes"; g -C "$C" checkout -q -- mint-probe.md +pass "(d-) a mode change, a revived removal and a missing addition are each named" +# Membership is the repository file set (Codex review of #275): an ignored +# artefact the replay also generates, force-added here, is still an addition. +AP=.machine_readable/arrival-pack/claude-md-data.json +[ -f "$C/$AP" ] || fail "fixture: the mint did not generate $AP" +g -C "$C" add -f -- "$AP" +if drift; then fail 'the drift gate passed a force-added ignored artefact'; fi +grep -qF "DRIFT: $AP exists here but the mint did not produce it" "$work/drift.log" || { cat "$work/drift.log" >&2; fail 'the force-added artefact was not named'; } +[ "$(grep -c '^DRIFT:' "$work/drift.log")" -eq 1 ] || { cat "$work/drift.log" >&2; fail 'the drift gate reported more than the force-added artefact'; } +g -C "$C" rm -q --cached -- "$AP" +drift || { cat "$work/drift.log" >&2; fail 'the drift gate is not green again after the controls'; } +pass "(d-) a force-added ignored artefact the replay also generates is named; green again once untracked" +# The most specific declaration wins (Codex review of #277): a file the child +# adds, and declares added, under archetypes/ (which the mint declares +# removed) is an addition, not a revived removal. +cp "$DEED" "$work/deed.before-specific" +sed -i 's|^ :added ("mint-probe.md")| :added ("mint-probe.md" "archetypes/custom.txt")|' "$DEED" +grep -q '"archetypes/custom.txt")' "$DEED" || fail 'fixture: could not declare archetypes/custom.txt' +mkdir -p "$C/archetypes"; printf 'mine\n' > "$C/archetypes/custom.txt" +drift || { cat "$work/drift.log" >&2; fail 'a declared addition under a declared-removed directory was flagged'; } +rm -r "$C/archetypes"; cp "$work/deed.before-specific" "$DEED" +pass "(d) a declared addition under a declared-removed directory is an addition: the most specific declaration wins" +# A path declared diverged must be one the mint produces (Codex review of +# #275): a new file declared diverged is an undeclared addition. +cp "$DEED" "$work/deed.before-div" +sed -i 's|^ :diverged ("| :diverged ("new-thing.txt" "|' "$DEED" +grep -q ':diverged ("new-thing.txt"' "$DEED" || fail 'fixture: could not declare new-thing.txt diverged' +printf 'new\n' > "$C/new-thing.txt" +if drift; then fail 'the drift gate passed a new file declared diverged'; fi +grep -q '^DRIFT: new-thing.txt is declared diverged, but the mint did not produce it' "$work/drift.log" || { cat "$work/drift.log" >&2; fail 'the new file declared diverged was not named'; } +rm "$C/new-thing.txt"; cp "$work/deed.before-div" "$DEED" +pass "(d-) a new file declared diverged is named as an addition" +# The replay's own git runs isolated from this machine's configuration: a +# global pre-commit hook that rejects every commit changes nothing (Codex +# review of #275). +mkdir -p "$work/hooks"; printf '#!/bin/sh\nexit 1\n' > "$work/hooks/pre-commit"; chmod +x "$work/hooks/pre-commit" +# And a global attributes file that drops README.adoc from every archive: the +# replay must not lose it (Codex review of #277). +printf 'README.adoc export-ignore\n' > "$work/global.attributes" +printf '[core]\n\thooksPath = %s\n\tattributesFile = %s\n' "$work/hooks" "$work/global.attributes" > "$work/hooky.gitconfig" +(cd "$C" && GIT_CONFIG_GLOBAL="$work/hooky.gitconfig" RSR_MINT_TOOL_CACHE="$work/tools" bash scripts/check-variant-drift.sh "$T") > "$work/drift.log" 2>&1 \ + || { cat "$work/drift.log" >&2; fail 'a global pre-commit hook broke the replay'; } +pass "(d) the replay is isolated from global git hooks and attributes" +# Inherited git location variables (a hook's GIT_DIR, GIT_INDEX_FILE) cannot +# redirect the gate's git (CodeRabbit review of #275). +g init -q "$work/elsewhere" +(cd "$C" && GIT_DIR="$work/elsewhere/.git" GIT_INDEX_FILE="$work/elsewhere/.git/index" \ + RSR_MINT_TOOL_CACHE="$work/tools" bash scripts/check-variant-drift.sh "$T") > "$work/drift.log" 2>&1 \ + || { cat "$work/drift.log" >&2; fail 'an inherited GIT_DIR redirected the drift gate'; } +pass "(d) inherited git location variables do not redirect the gate" +# The last path git lists, deleted, is named like any other: it used to fail +# the file listing itself and stop the gate silently (CodeRabbit review of #275). +LAST=$(g -C "$C" ls-files | tail -n 1) +rm -- "$C/$LAST" +if drift; then fail "the drift gate missed the deletion of $LAST"; fi +grep -qF "DRIFT: $LAST was produced by the mint but is absent here" "$work/drift.log" || { cat "$work/drift.log" >&2; fail "the deleted last path $LAST was not named"; } +g -C "$C" checkout -q -- "$LAST" +pass "(d-) the deletion of the last path git lists is named" +# A path the line-based lists cannot hold is refused by name, not misread. +NLF="$C/two"$'\n'"lines.txt"; printf 'x\n' > "$NLF" +if drift; then fail 'the drift gate passed a path containing a newline'; fi +grep -q 'cannot check a path containing a newline' "$work/drift.log" || { cat "$work/drift.log" >&2; fail 'the newline path was not refused by name'; } +rm -- "$NLF" +pass "(d-) a path containing a newline is refused by name" +# A template commit carrying a submodule (a gitlink: no files to copy) cannot +# be replayed exactly: the gate refuses it by name (Codex review of #277). +GL_TREE=$(cd "$T" && GIT_INDEX_FILE="$work/gl.index" git read-tree "$T_TREE" \ + && GIT_INDEX_FILE="$work/gl.index" git update-index --add --cacheinfo "160000,$T_COMMIT,vendor/sub" \ + && GIT_INDEX_FILE="$work/gl.index" git write-tree) +GL_COMMIT=$(g -C "$T" commit-tree "$GL_TREE" -p "$T_COMMIT" -m "a template carrying a submodule") +g -C "$T" branch -q with-gitlink "$GL_COMMIT" +GLC="$work/gitlinkchild"; cp -a "$C" "$GLC" +sed -i -E "s/(:template-commit \")[0-9a-f]{40}\"/\1${GL_COMMIT}\"/" "$GLC/mint-check_chora.deed" +grep -q ":template-commit \"$GL_COMMIT\"" "$GLC/mint-check_chora.deed" || fail 'fixture: the gitlink pin was not written' +rc=0; (cd "$GLC" && RSR_MINT_TOOL_CACHE="$work/tools" bash scripts/check-mint-drift.sh "$T") > "$work/gldrift.log" 2>&1 || rc=$? +[ "$rc" -eq 2 ] && grep -q 'carries a submodule' "$work/gldrift.log" && grep -q 'vendor/sub' "$work/gldrift.log" \ + || { cat "$work/gldrift.log" >&2; fail "a template commit carrying a gitlink was not refused by name (exit $rc)"; } +pass "(d-) a template commit carrying a submodule is refused by name, not replayed inexactly" +# …and the mint refuses such a tree itself, clean or not: its drift gate could +# never pass (Codex review of #275). +GLM="$work/gitlinkmint" +rm -rf "$GLM"; mkdir -p "$GLM"; materialize "$GL_COMMIT" "$GLM" +g -C "$GLM" init -q -b main; g -C "$GLM" add -A +g -C "$GLM" update-index --add --cacheinfo "160000,$T_COMMIT,vendor/sub"; g -C "$GLM" commit -q -m "Initial commit" +g -C "$GLM" remote add origin git@github.com:example-owner/mint-check.git +[ "$(g -C "$GLM" rev-parse 'HEAD^{tree}')" = "$GL_TREE" ] || fail 'fixture: the gitlink child is not the gitlink template tree' +MINT_ENV=(RSR_TEMPLATE_COMMIT="$GL_COMMIT") +if mint "$GLM" "$work/glmint.log"; then fail 'a tree carrying a gitlink was minted'; fi +MINT_ENV=() +grep -q 'carries a submodule' "$work/glmint.log" && grep -q 'vendor/sub' "$work/glmint.log" \ + || { tail -20 "$work/glmint.log" >&2; fail 'the gitlink was not refused by name at mint'; } +[ -z "$(g -C "$GLM" status --porcelain)" ] || fail 'the refused mint wrote to the tree' +pass "(a-) a tree carrying a submodule is refused at mint, clean or not" + +# ── A template whose identity needs RSR_TEMPLATE_SLUG, replayed ───────────── +# repo-init supports a template whose Justfile names its identity through +# env(...), given RSR_TEMPLATE_SLUG. The drift gate's replay must pass the +# recorded slug on, or it cannot run at all (Codex review of #275). The same +# template marks SUPPORT.adoc export-ignore: the replay must write the tree as +# a checkout does, not as git archive does (Codex review of #275). +SL_TREE=$(cd "$T" && GIT_INDEX_FILE="$work/slug.index" git read-tree "$T_TREE" && cp Justfile "$work/Justfile.slug" \ + && sed -i -E 's/^(OWNER|REPO)([[:space:]]*:=[[:space:]]*)"([^"]+)"/\1\2env("RSR_\1", "\3")/' "$work/Justfile.slug" \ + && GIT_INDEX_FILE="$work/slug.index" git update-index --add --cacheinfo "100644,$(git hash-object -w "$work/Justfile.slug"),Justfile" \ + && { cat .gitattributes; printf 'SUPPORT.adoc export-ignore\n'; } > "$work/gitattributes.slug" \ + && GIT_INDEX_FILE="$work/slug.index" git update-index --add --cacheinfo "100644,$(git hash-object -w "$work/gitattributes.slug"),.gitattributes" \ + && GIT_INDEX_FILE="$work/slug.index" git write-tree) +SL_COMMIT=$(g -C "$T" commit-tree "$SL_TREE" -p "$T_COMMIT" -m "a template naming itself through env()") +g -C "$T" branch -q env-identity "$SL_COMMIT" +SLC="$work/slugchild" +rm -rf "$SLC"; mkdir -p "$SLC" +materialize "$SL_COMMIT" "$SLC" +[ -f "$SLC/SUPPORT.adoc" ] && ! g -C "$T" archive "$SL_COMMIT" | tar t | grep -qx SUPPORT.adoc \ + || fail 'fixture: SUPPORT.adoc is not export-ignored in the second template commit' +g -C "$SLC" init -q -b main; g -C "$SLC" add -A; g -C "$SLC" commit -q -m "Initial commit" +g -C "$SLC" remote add origin git@github.com:example-owner/mint-check.git +MINT_ENV=(RSR_TEMPLATE_SLUG="$T_SLUG" RSR_TEMPLATE_COMMIT="$SL_COMMIT") +mint "$SLC" "$work/slug.log" || { tail -30 "$work/slug.log" >&2; fail 'the env()-identity template did not mint with RSR_TEMPLATE_SLUG'; } +MINT_ENV=() +g -C "$SLC" add -A; g -C "$SLC" commit -q -m mint +(cd "$SLC" && RSR_MINT_TOOL_CACHE="$work/tools" bash scripts/check-variant-drift.sh "$T") > "$work/slugdrift.log" 2>&1 \ + || { cat "$work/slugdrift.log" >&2; fail 'the drift gate could not replay a template that needs RSR_TEMPLATE_SLUG'; } +pass "(d) a template naming itself through env() and marking a path export-ignore is replayed exactly: green" # ── The repository map is regenerated last ────────────────────────────────── # Same check Estate Rules runs, on the committed mint: regenerate from the @@ -216,13 +600,40 @@ g -C "$C" diff --quiet -- docs/architecture/REPOSITORY-MAP.adoc \ # commit, after a removal), reproduces the stale map lintel shipped. SM="$work/stale-map" use_template "$SM" -(cd "$SM" && rm -rf archetypes && bash scripts/gen-repo-map.sh . >/dev/null) -grep -q '^| `archetypes' "$SM/docs/architecture/REPOSITORY-MAP.adoc" \ +# Create the control explicitly: minted descendants no longer carry archetypes/. +# The assertion must test index staleness, not assume a template-only directory. +printf 'map staleness control\n' > "$SM/map-staleness-control.txt" +g -C "$SM" add map-staleness-control.txt +rm "$SM/map-staleness-control.txt" +(cd "$SM" && bash scripts/gen-repo-map.sh . >/dev/null) +grep -qF 'map-staleness-control.txt' "$SM/docs/architecture/REPOSITORY-MAP.adoc" \ || fail 'control: index mode no longer shows the pre-commit staleness; this check is vacuous' +(cd "$SM" && bash scripts/gen-repo-map.sh --worktree . >/dev/null) +if grep -qF 'map-staleness-control.txt' "$SM/docs/architecture/REPOSITORY-MAP.adoc"; then + fail 'control: worktree mode retained a deleted source' +fi pass "the committed map is fresh (the index-mode control reproduces lintel's stale map)" (cd "$C" && env -u GITHUB_REPOSITORY bash scripts/check-no-placeholders.sh .) > "$work/ph.log" 2>&1 \ || { cat "$work/ph.log" >&2; fail 'the minted child fails its own placeholder gate'; } pass "the minted child passes its own placeholder gate" +# The mint removes build/docs-seed/; an exemption left for it is stale, and +# the child's Hypatia exemption gate fails on it (seen minting +# unified-api-adapter, 2026-11-05). +[ ! -e "$C/build/docs-seed" ] || fail 'fixture: the mint kept build/docs-seed/; this check is vacuous' +(cd "$C" && bash scripts/check-hypatia-ignore.sh .) > "$work/hi.log" 2>&1 \ + || { cat "$work/hi.log" >&2; fail 'the minted child fails its own Hypatia exemption gate'; } +pass "the minted child passes its own Hypatia exemption gate" +# The rule-order policy must lose the same module, or it classifies an +# exemption that no longer exists. +(cd "$C" && bash scripts/check-hypatia-rule-order.sh .) > "$work/ro.log" 2>&1 \ + || { cat "$work/ro.log" >&2; fail 'the minted child fails its own Hypatia rule-order gate'; } +pass "the minted child passes its own Hypatia rule-order gate" +if command -v bun >/dev/null 2>&1 || command -v ijson-jcs >/dev/null 2>&1; then + (cd "$C" && bash scripts/check-ijson-jcs.sh --enforce .machine_readable/policies/hypatia-rule-order.json) > "$work/jcs.log" 2>&1 \ + || { cat "$work/jcs.log" >&2; fail 'the minted rule-order policy is not canonical JSON'; } + pass "the minted rule-order policy stays canonical JSON" +fi + echo "PASS: Use-this-template mint" diff --git a/tests/ffi_strings_test.sh b/tests/ffi_strings_test.sh index 64d86f7..220c5bd 100644 --- a/tests/ffi_strings_test.sh +++ b/tests/ffi_strings_test.sh @@ -16,6 +16,18 @@ fi cp -R "$repo/src/interface/ffi" "$work/ffi" sed -i -e 's/secret_types/test_project/g' -e 's/SECRET_TYPES/TEST_PROJECT/g' \ "$work/ffi/build.zig" "$work/ffi/src/main.zig" "$work/ffi/test/integration_test.zig" +# A minted repository has its own export prefix where the template has the +# token; map that prefix too, so the appended tests name the same functions. +# Only the exported symbols are renamed: a slug such as `root` must not turn +# Zig's `.root_module` into `.test_project_module`. +prefix=$(sed -nE 's/^export fn ([a-z0-9_]+)_init\(\).*/\1/p' "$work/ffi/src/main.zig" | head -1) +[[ -n "$prefix" ]] || { echo 'FAIL: no `export fn _init()` in src/interface/ffi/src/main.zig' >&2; exit 1; } +if [[ "$prefix" != test_project ]]; then + while IFS= read -r sym; do + sed -i -E "s/\\b${prefix}_${sym}\\b/test_project_${sym}/g" \ + "$work/ffi/build.zig" "$work/ffi/src/main.zig" "$work/ffi/test/integration_test.zig" + done < <(sed -nE "s/^export fn ${prefix}_([a-z0-9_]+)\\(.*/\\1/p" "$work/ffi/src/main.zig") +fi # Tests share the production module so the private helper can be exercised # with the leak-detecting allocator without changing the public FFI surface. cat >> "$work/ffi/src/main.zig" <<'ZIG' diff --git a/tests/flag-control/IndexedMatch.agda b/tests/flag-control/IndexedMatch.agda new file mode 100644 index 0000000..a7d5335 --- /dev/null +++ b/tests/flag-control/IndexedMatch.agda @@ -0,0 +1,14 @@ +{-# OPTIONS --safe --without-K #-} +-- SPDX-License-Identifier: MPL-2.0 +-- SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell +module IndexedMatch where +open import SecretTypes.Prelude + +-- Must be refused, and only because --cubical-compatible is in force: this +-- is the definition of just-inj that Prelude deliberately does not use. +-- Matching refl here needs injectivity of `just`, which the flag rejects as +-- -WUnsupportedIndexedMatch, made fatal by -W error. Without the flag it +-- type-checks, so scripts/check.sh fails if this module is ever accepted: +-- that is the evidence the canonical invocation actually ran. +invalid : {A : Set} {a b : A} → just a ≡ just b → a ≡ b +invalid refl = refl diff --git a/tests/run_proven_suite_test.sh b/tests/run_proven_suite_test.sh index b6f1526..7d1bec9 100644 --- a/tests/run_proven_suite_test.sh +++ b/tests/run_proven_suite_test.sh @@ -31,7 +31,8 @@ for file in scripts/check-escape-hatches.sh scripts/emit-aggregate-inputs.sh \ scripts/check-hypatia-rule-order.sh \ .github/hooks/validate-deed.sh .github/hooks/validate-k9.sh \ tests/proven_suite_test.sh tests/workflows/check_ijson_jcs_test.sh \ - tests/workflows/squabble_gate_test.sh tests/shape/check_root_shape_test.sh www/tests/run-all.sh; do + tests/workflows/squabble_gate_test.sh tests/workflows/secret_scanner_contract_test.sh \ + tests/shape/check_root_shape_test.sh www/tests/run-all.sh; do mkdir -p "$work/tree/$(dirname "$file")" cp "$work/lane" "$work/tree/$file" done @@ -49,7 +50,7 @@ grep -qF 'ALL LANES PASSED' "$work/out" || fail 'missing success outcome' jq -e '.schema_version == 1 and .entries[0].provenance == "Static-Checked" and .entries[0].proof_ladder == [{proof_file:"src/Proof.idr"}]' \ "$work/reports with spaces/run.json" >/dev/null || fail 'static report overclaims or loses proof files' -[[ "$(wc -l < "$LANE_CALLS")" -eq 16 ]] || fail 'clean run did not dispatch all required lanes' +[[ "$(wc -l < "$LANE_CALLS")" -eq 17 ]] || fail 'clean run did not dispatch all required lanes' echo 'PASS: clean runner executes all lanes and emits an honest report at a path with spaces' FAIL_GATE=check-root-shape.sh run 1 "$runner" diff --git a/tests/workflows/chain_tree_of_test.sh b/tests/workflows/chain_tree_of_test.sh new file mode 100755 index 0000000..f626e10 --- /dev/null +++ b/tests/workflows/chain_tree_of_test.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# chain-mint.sh reads every checkout into a tree through git (tree_of), never +# by copying files: git keeps a submodule as its gitlink, does not recurse into +# a path that became a directory, and reads nothing an ignore rule matches +# (Codex review of #277). This sources tree_of from the script itself and +# checks those three properties in a second, without a mint. +set -euo pipefail +repo=$(git rev-parse --show-toplevel) +work=$(mktemp -d); trap 'rm -rf "$work"' EXIT +fail() { echo "FAIL: $*" >&2; exit 1; } +g0() { git -c user.name=t -c user.email=t@example.invalid -c commit.gpgsign=false -c protocol.file.allow=always "$@"; } + +# The functions under test, exactly as the script defines them. +sed -n '/^g() {/,/^}/p; /^link_objects() {/,/^}/p; /^tree_of() {/,/^}/p' "$repo/scripts/chain-mint.sh" > "$work/fns.sh" +grep -q '^tree_of() {' "$work/fns.sh" || fail 'tree_of not found in scripts/chain-mint.sh' +# shellcheck disable=SC1091 +. "$work/fns.sh" + +g0 init -q "$work/sub"; echo s > "$work/sub/f"; g0 -C "$work/sub" add -A; g0 -C "$work/sub" commit -q -m s +D="$work/d" +g0 init -q "$D" +printf 'plain\n' > "$D/becomes-dir"; printf 'a\n' > "$D/a" +g0 -C "$D" add -A; g0 -C "$D" commit -q -m a +g0 -C "$D" submodule -q add "$work/sub" vendor/sub; g0 -C "$D" commit -q -m sub +SUB_COMMIT=$(g0 -C "$work/sub" rev-parse HEAD) +# A tracked file turned into a directory holding an ignored file, and an +# untracked file no rule ignores. +rm "$D/becomes-dir"; mkdir "$D/becomes-dir"; printf 'secret\n' > "$D/becomes-dir/key" +printf '/becomes-dir/key\n' >> "$D/.git/info/exclude" +printf 'new\n' > "$D/untracked.txt" + +WORK="$work/w"; mkdir -p "$WORK" +M="$work/m"; g init -q "$M"; mkdir -p "$M/.git/objects/info" +T=$(tree_of "$D" d) +g -C "$M" ls-tree -r "$T" > "$work/tree" + +grep -qP "^160000 commit ${SUB_COMMIT}\tvendor/sub$" "$work/tree" \ + || { cat "$work/tree" >&2; fail 'the submodule did not stay a gitlink at its commit'; } +if grep -q 'vendor/sub/' "$work/tree"; then fail 'the submodule worktree was vendored as files'; fi +if grep -q 'becomes-dir' "$work/tree"; then cat "$work/tree" >&2; fail 'the ignored file in a path that became a directory was read'; fi +grep -qP '\tuntracked.txt$' "$work/tree" && grep -qP '\ta$' "$work/tree" \ + || { cat "$work/tree" >&2; fail 'the work tree was not read as git add -A would commit it'; } +# The checkout's own .git was not written: its index still lists becomes-dir. +g0 -C "$D" ls-files --error-unmatch becomes-dir >/dev/null 2>&1 || fail 'tree_of wrote to the checkout index' + +# A linked worktree: its exclusion file is the COMMON directory's info/exclude, +# not one under .git/worktrees/ (Codex review of #277). +g0 -C "$D" add -A; g0 -C "$D" commit -q -m b +g0 -C "$D" worktree add -q "$work/wt" -b wt +printf 'secret\n' > "$work/wt/wt-secret.txt" +printf '/wt-secret.txt\n' >> "$D/.git/info/exclude" +g -C "$M" ls-tree -r --name-only "$(tree_of "$work/wt" wt own)" > "$work/wt.tree" +if grep -qx 'wt-secret.txt' "$work/wt.tree"; then fail 'a linked worktree: a file the common info/exclude ignores was read'; fi +grep -qx a "$work/wt.tree" || { cat "$work/wt.tree" >&2; fail 'a linked worktree was not read'; } + +# The checkout's own configuration, global included, read as its `git status` +# would (Codex review of #277): core.autocrlf=true checks LF blobs out as CRLF +# and cleans them back, so a clean CRLF checkout is no edit; its global +# excludesFile is honoured, so a file it ignores is not carried. +E="$work/e" +g0 init -q "$E"; printf 'one\ntwo\n' > "$E/text.txt"; g0 -C "$E" add -A; g0 -C "$E" commit -q -m e +LF_BLOB=$(g0 -C "$E" rev-parse HEAD:text.txt) +printf 'one\r\ntwo\r\n' > "$E/text.txt" +printf 'personal\n' > "$E/my-notes.local" +printf '*.local\n' > "$work/global-excludes" +printf '[core]\n\tautocrlf = true\n\texcludesFile = %s\n' "$work/global-excludes" > "$work/global-config" +ET=$(GIT_CONFIG_GLOBAL="$work/global-config" tree_of "$E" e own) +[ "$(g -C "$M" rev-parse "$ET:text.txt")" = "$LF_BLOB" ] \ + || fail 'a CRLF checkout under core.autocrlf=true was read as an edit' +if g -C "$M" ls-tree -r --name-only "$ET" | grep -qx my-notes.local; then fail 'a file the global excludesFile ignores was read'; fi +# …and only "own" reads it so: a replay's tree does not depend on this machine. +ER=$(GIT_CONFIG_GLOBAL="$work/global-config" tree_of "$E" er) +g -C "$M" ls-tree -r --name-only "$ER" | grep -qx my-notes.local \ + || fail 'a tree read without "own" honoured the global configuration' +echo 'PASS: tree_of keeps gitlinks, reads no ignored file (linked worktrees too), follows the checkout'"'"'s own configuration, and leaves the checkout alone' diff --git a/tests/workflows/check_no_a2ml_test.sh b/tests/workflows/check_no_a2ml_test.sh index 52519f1..5a41dda 100755 --- a/tests/workflows/check_no_a2ml_test.sh +++ b/tests/workflows/check_no_a2ml_test.sh @@ -34,10 +34,23 @@ printf '[x]\n' > "$REPO/AI.A2ML" git -C "$REPO" add AI.A2ML expect_fail "$REPO" "AI.A2ML" +# Shrink-only ledger: a listed file passes, an unlisted one beside it still +# fails, and 9-archive/ is out of scope. +git -C "$REPO" rm -q --cached AI.A2ML && rm "$REPO/AI.A2ML" +mkdir -p "$REPO/.machine_readable" "$REPO/9-archive/old" +printf '[x]\n' > "$REPO/LISTED.a2ml" +printf '[x]\n' > "$REPO/9-archive/old/ARCHIVED.a2ml" +printf '# ledger\nLISTED.a2ml # waiting for kyX\n' > "$REPO/.machine_readable/a2ml-legacy-allow.txt" +git -C "$REPO" add LISTED.a2ml 9-archive .machine_readable +bash "$CHECKER" "$REPO" | grep -q '^PASS:' +printf '[x]\n' > "$REPO/NEW.a2ml" +git -C "$REPO" add NEW.a2ml +expect_fail "$REPO" "NEW.a2ml" + PLAIN="$FIXTURE/plain"; mkdir -p "$PLAIN/sub" printf '[x]\n' > "$PLAIN/sub/ANCHOR.a2ml" expect_fail "$PLAIN" "sub/ANCHOR.a2ml" rm "$PLAIN/sub/ANCHOR.a2ml" bash "$CHECKER" "$PLAIN" | grep -q '^PASS:' -echo "PASS: check-no-a2ml controls (clean, tracked, upper-case, untracked)" +echo "PASS: check-no-a2ml controls (clean, tracked, upper-case, untracked, ledger)" diff --git a/tests/workflows/container_kyaml_test.sh b/tests/workflows/container_kyaml_test.sh index 1d1971c..60fe4c5 100644 --- a/tests/workflows/container_kyaml_test.sh +++ b/tests/workflows/container_kyaml_test.sh @@ -129,6 +129,29 @@ for port in 1 65535; do done echo 'PASS: container-init substitutions preserve service references and string ports at 1 and 65535' +# Both recipes refuse a value before writing anything when it would make an +# image reference container tooling rejects. Each refused value is a mutant +# of an accepted one, so a check that accepts everything fails here. +check_values() { bash "$repo/scripts/format-container-kyaml.sh" --check-values "$@" 2>/dev/null; } +for registry in ghcr.io/acme localhost:5000/team host:65535/team registry.example.com '[2001:db8::1]:5000/team' '[::1]/x' '[::]' '[1:2:3:4:5:6:7:8]' '[1:2:3:4:5:6::8]'; do + check_values demo-api 8080 "$registry" || fail "registry '$registry' was refused" +done +for registry in / ghcr.io/Acme host:bad-port ghcr.io//acme ghcr.io/ '[]' '[2001:db8::1' '2001:db8::1:5000/team' '[g::1]' '[::1]:' 'ghcr.io/a|b' host:0/team host:65536/team '[::1]:70000/team' \ + '[::::]:5000/team' '[1::2::3]/team' '[:1::2]' '[1::2:]' '[1:2:3]' '[1:2:3:4:5:6:7:8:9]' '[1::2:3:4:5:6:7:8]' '[12345::1]'; do + ! check_values demo-api 8080 "$registry" || fail "registry '$registry' was accepted" +done +# The service name is also a bare TOML key and a hostname, so it is a DNS +# label: a dot would nest stapeln/compose.toml's table, an underscore is no +# hostname, and a label is at most 63 characters. +long63=$(printf 'a%.0s' {1..63}) +for name in demo-api a a1 "$long63"; do check_values "$name" 8080 ghcr.io/acme || fail "service name '$name' was refused"; done +for name in Demo-Api -api api- a.b a_b a__b 'a b' 'a"b' '' "${long63}a"; do ! check_values "$name" 8080 ghcr.io/acme || fail "service name '$name' was accepted"; done +for port in 0 65536 08x ''; do ! check_values demo-api "$port" ghcr.io/acme || fail "port '$port' was accepted"; done +sed -e 's|{{SERVICE_NAME}}|demo-api|g' -e 's|{{PORT}}|8080|g' \ + -e 's|{{REGISTRY}}|[2001:db8::1]:5000/team|g' "$template" > "$scratch/compose-v6.yaml" +assert_value "$scratch/compose-v6.yaml" '.services."demo-api".image' '"[2001:db8::1]:5000/team/demo-api:latest"' 'bracketed IPv6 registry substitution' +echo 'PASS: --check-values accepts DNS-label service names and image-prefix registries (IPv6 included) and refuses malformed values' + assert_value "$policy" '.version' '"1.0"' 'policy version remains a string' assert_value "$policy" '.auth.public' '[{"path":"/health","methods":["GET"]},{"path":"/ready","methods":["GET"]},{"path":"/metrics","methods":["GET"]}]' 'public probes require GET' assert_value "$policy" '.auth.authenticated' '[{"path":"/api/v1/*","methods":["GET","POST","PUT","DELETE"]}]' 'application routes require authentication' diff --git a/tests/workflows/governance_badge_test.sh b/tests/workflows/governance_badge_test.sh index 27034f9..12020c8 100644 --- a/tests/workflows/governance_badge_test.sh +++ b/tests/workflows/governance_badge_test.sh @@ -207,7 +207,9 @@ wiring_checks() { fi echo " --- the badge URL and the orphan guarantee survive ---" - has "README still reads the stable raw URL" 'raw\.githubusercontent\.com/\{\{OWNER\}\}/\{\{REPO\}\}/governance-badges/badges/k9\.json' "$README" + # The template's README carries the tokens; a minted child's carries its + # own owner and repo. Either is the stable raw URL. + has "README still reads the stable raw URL" 'raw\.githubusercontent\.com/(\{\{OWNER\}\}/\{\{REPO\}\}|[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)/governance-badges/badges/k9\.json' "$README" has "the orphan commit is built with no parents" 'parents: \[\]' "$HELPER" hasnt "the helper never names a source branch as a ref" 'refs/heads/(main|master)' "$HELPER" diff --git a/tests/workflows/governance_policy_test.sh b/tests/workflows/governance_policy_test.sh index a2fe91f..eb1d31e 100644 --- a/tests/workflows/governance_policy_test.sh +++ b/tests/workflows/governance_policy_test.sh @@ -2,8 +2,10 @@ # SPDX-License-Identifier: MPL-2.0 # # PR #244 configuration regressions: the retired runtime policy must not ship, -# and governance must run for pull requests targeting any branch with a locked -# checkout of metadatastician/standards at a full 40-hex SHA. +# and governance must run for pull requests targeting any branch, running the +# standards policy at a full 40-hex SHA: a locked checkout of +# metadatastician/standards, or a locked call of its reusable governance +# workflow. # PR #257 adds the proof-policy mapping and adopts canon 2.1.3; canon 2.1.5 (a # PATCH: the gate table 0.3.1 and the applicability policy changed, the # criteria did not) is adopted here, after 2.1.4 (editorial). Since that @@ -50,23 +52,45 @@ assert.ok(Object.hasOwn(governance.on, "workflow_dispatch"), "manual governance dispatch must remain available"); console.log("PASS: unrestricted pull requests coexist with filtered pushes and manual dispatch"); -// Governance must pin metadatastician/standards at a full 40-hex SHA and lock -// every step-level action it invokes in actions.lock. -const steps = governance.jobs.governance.steps ?? []; -const standardsCheckout = steps.find( - (s) => s.with && /^(metadatastician|hyperpolymath)\/standards$/.test(String(s.with.repository ?? "")) +// Governance must run the standards policy pinned at a full 40-hex SHA, and +// actions.lock must record what it runs. Two shapes satisfy that, and each is +// checked in full: +// * inline: a step checks out standards at the SHA and runs its scripts +// (the template's own governance.yml); +// * caller: the governance job calls standards' reusable governance +// workflow at the SHA, so the policy is defined once, in standards (the +// form children such as secret-types use). +// Anything else fails: a missing pin, a tag or branch ref, or a lock record +// that does not match. +const job = governance.jobs.governance; +const lockRecord = lock.workflows[".github/workflows/governance.yml"] ?? []; +const caller = String(job.uses ?? "").match( + /^(metadatastician|hyperpolymath)\/standards\/\.github\/workflows\/governance-reusable\.yml@([a-f0-9]{40})$/ ); -assert.ok(standardsCheckout, "governance must check out standards policy scripts"); -assert.match(String(standardsCheckout.with.ref ?? ""), /^[a-f0-9]{40}$/, - "governance must pin standards at a full 40-hex commit SHA"); -const checkoutUse = String(standardsCheckout.uses ?? ""); -assert.ok( - (lock.workflows[".github/workflows/governance.yml"] ?? []).includes(checkoutUse), - "governance's lock record must match its checkout action" -); -assert.ok(lock.dependencies[checkoutUse]?.commit, - "governance's checkout action must have a corresponding locked dependency commit"); -console.log("PASS: governance executes the pinned standards policy with locked actions"); +if (job.uses !== undefined) { + assert.ok(caller, + "a governance caller must call standards' governance-reusable.yml at a full 40-hex commit SHA"); + const callerUse = `${caller[1]}/standards@${caller[2]}`; + assert.ok(lockRecord.includes(callerUse), + "governance's lock record must match the reusable workflow it calls"); + assert.ok(lock.dependencies[callerUse]?.commit, + "governance's reusable workflow must have a corresponding locked dependency commit"); + console.log("PASS: governance calls the pinned standards policy, and the call is locked"); +} else { + const steps = job.steps ?? []; + const standardsCheckout = steps.find( + (s) => s.with && /^(metadatastician|hyperpolymath)\/standards$/.test(String(s.with.repository ?? "")) + ); + assert.ok(standardsCheckout, "governance must check out standards policy scripts"); + assert.match(String(standardsCheckout.with.ref ?? ""), /^[a-f0-9]{40}$/, + "governance must pin standards at a full 40-hex commit SHA"); + const checkoutUse = String(standardsCheckout.uses ?? ""); + assert.ok(lockRecord.includes(checkoutUse), + "governance's lock record must match its checkout action"); + assert.ok(lock.dependencies[checkoutUse]?.commit, + "governance's checkout action must have a corresponding locked dependency commit"); + console.log("PASS: governance executes the pinned standards policy with locked actions"); +} const root = process.argv[2]; const read = (path) => readFileSync(resolve(root, path), "utf8"); diff --git a/tests/workflows/julia_library_test.sh b/tests/workflows/julia_library_test.sh index d5d9a7b..6a69a7d 100644 --- a/tests/workflows/julia_library_test.sh +++ b/tests/workflows/julia_library_test.sh @@ -49,7 +49,7 @@ chmod +x "$fixture/python/uuidgen" derive() { env -i PATH="$1:$fixture/common" OWNER="$2" REPO="$3" \ STANDARDS_UUID_V8_JS="${4:-}" ARCHETYPE="${5:-julia-library}" \ - REPO_UUID="${6:-UNASSIGNED}" BUN_LOG="$fixture/bun.args" \ + REPO_UUID="${6:-UNASSIGNED}" RSR_PACKAGE_UUID="${7:-}" RSR_MINT_REPLAY="${8:-0}" BUN_LOG="$fixture/bun.args" \ "$bash_bin" -c ' set -euo pipefail LB="{{" RB="}}" PACKAGE_UUID=UNTOUCHED @@ -182,4 +182,20 @@ for job in fmt jet test; do ' "$workflow") equal "$actual" "$minutes" "$job job timeout" done +# A replay supplies the recorded uuid (mint-answers :package-uuid), so the +# drift gate gives the same answer on a host with neither bun nor python3 +# (Codex review of #275). A recorded UNASSIGNED replays as UNASSIGNED even +# where a generator exists; a malformed value is ignored, not trusted. +equal "$(derive "$fixture/empty" example-owner MintCheck.jl '' julia-library UNASSIGNED "$expected" 1)" "$expected" \ + 'a recorded package uuid replays without bun or python3' +equal "$(derive "$fixture/python" example-owner MintCheck.jl '' julia-library UNASSIGNED UNASSIGNED 1)" UNASSIGNED \ + 'a recorded UNASSIGNED replays as UNASSIGNED' +equal "$(derive "$fixture/empty" example-owner MintCheck.jl '' julia-library UNASSIGNED 'not-a-uuid' 1)" UNASSIGNED \ + 'a malformed recorded uuid is ignored' +# Outside a replay the value is never trusted (Codex review of #275): an +# ordinary mint derives the uuid even when the environment carries one. +equal "$(derive "$fixture/python" example-owner MintCheck.jl '' julia-library UNASSIGNED 00000000-0000-8000-8000-000000000000)" "$expected" \ + 'an ordinary mint ignores RSR_PACKAGE_UUID and derives the uuid' +equal "$(derive "$fixture/python" example-owner MintCheck.jl '' julia-library UNASSIGNED UNASSIGNED)" "$expected" \ + 'an ordinary mint ignores a stray UNASSIGNED' echo "PASS: $checks Julia library UUID and timeout checks; generator failures rejected" diff --git a/tests/workflows/kyaml_ratchet_test.sh b/tests/workflows/kyaml_ratchet_test.sh index 2536798..9498489 100644 --- a/tests/workflows/kyaml_ratchet_test.sh +++ b/tests/workflows/kyaml_ratchet_test.sh @@ -64,7 +64,7 @@ case "$url" in https://github.com/mikefarah/yq/releases/download/v4.53.3/yq_linux_amd64) asset=yq [[ "$output" = "$RUNNER_TEMP/yq-bin/yq" ]] || exit 92 ;; - https://raw.githubusercontent.com/metadatastician/standards/8e48b1caade08a552646adeab66558ac43b4360d/scripts/kyaml-format.sh) + https://raw.githubusercontent.com/metadatastician/standards/e064539116c2dcfdf282a4749dbd58c69961c7e6/scripts/kyaml-format.sh) asset=kyaml-format.sh [[ "$output" = "$RUNNER_TEMP/kyaml-format.sh" ]] || exit 92 ;; *) echo "unexpected download: $url" >&2; exit 93 ;; diff --git a/tests/workflows/mint_cleanup_test.sh b/tests/workflows/mint_cleanup_test.sh index f5a3b34..c8bbee9 100644 --- a/tests/workflows/mint_cleanup_test.sh +++ b/tests/workflows/mint_cleanup_test.sh @@ -51,4 +51,20 @@ cmp "$fixture/cache/README.md" "$fixture/cache.before" || { echo 'FAIL: an unlis rc=0; bash "$repo/scripts/rust-tool.sh" strip-instruction-blocks "$fixture/cache" 2>/dev/null || rc=$? [ "$rc" -eq 2 ] || { echo "FAIL: a directory argument exited $rc, want 2" >&2; exit 1; } cmp "$fixture/cache/README.md" "$fixture/cache.before" || { echo 'FAIL: a refused directory was edited' >&2; exit 1; } -echo 'PASS: selected ecosystems, nonempty updates, comment boundaries, idempotence, listed files only' +# The compile cache is keyed by source content. Keyed by name with an mtime +# check, a second checkout whose tool source is OLDER on disk than the cached +# build ran the first checkout's binary. Two versions, one cache, v2 older: +cache_dir="$fixture/tool-cache" +for v in 1 2; do + mkdir -p "$fixture/co$v/scripts" + cp "$repo/scripts/rust-tool.sh" "$fixture/co$v/scripts/" + printf 'fn main() { println!("v%s"); }\n' "$v" > "$fixture/co$v/scripts/probe-tool.rs" +done +touch -d '2001-01-01' "$fixture/co2/scripts/probe-tool.rs" +[ "$(RSR_MINT_TOOL_CACHE="$cache_dir" bash "$fixture/co1/scripts/rust-tool.sh" probe-tool)" = v1 ] \ + || { echo 'FAIL: the first version did not run' >&2; exit 1; } +[ "$(RSR_MINT_TOOL_CACHE="$cache_dir" bash "$fixture/co2/scripts/rust-tool.sh" probe-tool)" = v2 ] \ + || { echo 'FAIL: an older-mtime source ran the cached binary of another version' >&2; exit 1; } +[ "$(RSR_MINT_TOOL_CACHE="$cache_dir" bash "$fixture/co1/scripts/rust-tool.sh" probe-tool)" = v1 ] \ + || { echo 'FAIL: the first version lost its own binary' >&2; exit 1; } +echo 'PASS: selected ecosystems, nonempty updates, comment boundaries, idempotence, listed files only, content-keyed cache' diff --git a/tests/workflows/mint_gates_test.sh b/tests/workflows/mint_gates_test.sh index c185099..2c91aac 100644 --- a/tests/workflows/mint_gates_test.sh +++ b/tests/workflows/mint_gates_test.sh @@ -232,7 +232,7 @@ DD="$WORK/detect-depth"; mkdir -p "$DD/src/interface/ffi" printf '[package]\nname = "x"\n' > "$DD/Cargo.toml" printf 'package x\n' > "$DD/src/interface/abi.ipkg" printf 'x\n' > "$DD/src/interface/ffi/build.zig" -printf '[tools]\njust = "latest"\nshellcheck = "latest"\ntrivy = "latest"\nrust = "latest"\nidris2 = "latest"\nzig = "latest"\n' > "$DD/mise.toml" +printf '[tools]\njust = "latest"\nshellcheck = "latest"\nyq = "latest"\ntrivy = "latest"\nrust = "latest"\nidris2 = "latest"\nzig = "latest"\n' > "$DD/mise.toml" expect_pass "--detect finds markers below the root, as provision-lib does" \ mise --root "$DD" --detect diff --git a/tests/workflows/non_interactive_mint_test.sh b/tests/workflows/non_interactive_mint_test.sh index d8eef46..5b9f31f 100755 --- a/tests/workflows/non_interactive_mint_test.sh +++ b/tests/workflows/non_interactive_mint_test.sh @@ -10,6 +10,30 @@ set -euo pipefail repo=$(git rev-parse --show-toplevel) cd "$repo" +# A legacy minted repository (minted before #275 recorded (mint-answers), so +# it has no build/templates/identity.deed.in and no answers to replay) cannot +# be minted again: repo-init refuses before it asks or writes anything (#277). +# The rest of this test needs a mintable tree, so in such a child it checks +# that refusal, by name, and stops. It does not skip silently: a legacy child +# that could be minted again would fail here. +legacy_deed="$(bash "$repo/scripts/deed-field.sh" --find "$repo" 2>/dev/null || true)" +if [ ! -f "$repo/build/templates/identity.deed.in" ] \ + && ! { [ -n "$legacy_deed" ] && bash "$repo/scripts/deed-field.sh" --has "$legacy_deed" mint-answers; }; then + legacy_cfg=$(mktemp -d) + if legacy_out=$(cd "$repo" && env -i PATH="$PATH" HOME="$legacy_cfg" XDG_CONFIG_HOME="$legacy_cfg" \ + RSR_NON_INTERACTIVE=1 PROJECT_NAME="Mint Check" REPO=mint-check OWNER=example-owner \ + AUTHOR="Example Author" AUTHOR_EMAIL=author@example.org CONFIRM=n \ + timeout 120 just repo-init &1); then + rm -rf "$legacy_cfg" + echo "$legacy_out" >&2 + echo "FAIL: a legacy minted tree (no (mint-answers)) was allowed to mint again" >&2; exit 1 + fi + rm -rf "$legacy_cfg" + grep -q 'cannot be minted again' <<<"$legacy_out" \ + || { echo "$legacy_out" >&2; echo "FAIL: the legacy minted tree was refused for the wrong reason" >&2; exit 1; } + echo "PASS: a legacy minted tree refuses to mint again, by name; the mint checks below need a mintable tree" + exit 0 +fi recipe=build/just/repo-init.just cfg=$(mktemp -d); trap 'rm -rf "$cfg"' EXIT before=$(git status --porcelain --untracked-files=all) @@ -32,6 +56,23 @@ if out=$(env -i PATH="$PATH" HOME="$cfg" XDG_CONFIG_HOME="$cfg" RSR_NON_INTERACT fi grep -q 'Error: project name required' <<<"$out" || { echo "$out" >&2; echo "FAIL: missing PROJECT_NAME did not fail by name" >&2; exit 1; } if grep -q 'unbound variable' <<<"$out"; then echo "$out" >&2; echo "FAIL: unbound variable" >&2; exit 1; fi +# 3b. RSR_MINT_DATE is validated in bash, without GNU `date -d` (macOS has no +# such option and rejected every date): real dates reach "Proceed?", +# impossible ones are refused by name. +date_mint() { + env -i PATH="$PATH" HOME="$cfg" XDG_CONFIG_HOME="$cfg" RSR_NON_INTERACTIVE=1 RSR_MINT_DATE="$1" \ + PROJECT_NAME="Mint Check" REPO=mint-check OWNER=example-owner \ + AUTHOR="Example Author" AUTHOR_EMAIL=author@example.org CONFIRM=n \ + timeout 120 just repo-init &1 +} +for d in 2024-02-29 2026-12-31 2000-02-29; do + out=$(date_mint "$d") && grep -q '^Aborted\.$' <<<"$out" \ + || { echo "$out" >&2; echo "FAIL: the real date $d was refused" >&2; exit 1; } +done +for d in 2025-02-29 2026-04-31 2026-13-01 2026-00-10 1900-02-29 26-01-01; do + if out=$(date_mint "$d"); then echo "FAIL: the impossible date $d was accepted" >&2; exit 1; fi + grep -q 'RSR_MINT_DATE must be a real date' <<<"$out" || { echo "$out" >&2; echo "FAIL: $d refused for the wrong reason" >&2; exit 1; } +done # 4. Nothing was written (compared with the tree as this test found it). [ "$(git status --porcelain --untracked-files=all)" = "$before" ] || { git status --short >&2; echo "FAIL: the declined mint modified the tree" >&2; exit 1; } echo 'PASS: non-interactive prompts resolve from the environment; missing answers fail by name; no read() shim' diff --git a/tests/workflows/replay_mint_env_test.sh b/tests/workflows/replay_mint_env_test.sh new file mode 100755 index 0000000..9f6868c --- /dev/null +++ b/tests/workflows/replay_mint_env_test.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# replay-mint.sh runs the real recipe under RSR_MINT_REPLAY=1, where repo-init +# trusts RSR_PACKAGE_UUID. Only a recorded (mint-answers :package-uuid) may +# supply it: a fresh mint (a chain mint's new side) must not inherit one from +# the caller's environment, or a stray value would become the child's Julia +# package identity (Codex review of #275). A stub `just` records what the +# replay passes, so no mint runs and this takes a second. +set -euo pipefail +repo=$(git rev-parse --show-toplevel) +work=$(mktemp -d); trap 'rm -rf "$work"' EXIT +fail() { echo "FAIL: $*" >&2; exit 1; } +g() { git -c user.name=t -c user.email=t@example.invalid -c commit.gpgsign=false "$@"; } + +T="$work/template"; mkdir -p "$T"; printf 'x\n' > "$T/f" +g -C "$T" init -q -b main; g -C "$T" add -A; g -C "$T" commit -q -m t +PIN=$(g -C "$T" rev-parse HEAD) +mkdir -p "$work/bin" +cat > "$work/bin/just" <<'FAKE_JUST' +#!/usr/bin/env bash +printf '%s|%s\n' "${RSR_PACKAGE_UUID:-}" "${RSR_MINT_REPLAY:-}" > .env-probe +FAKE_JUST +chmod +x "$work/bin/just" +replay() { PATH="$work/bin:$PATH" bash "$repo/scripts/replay-mint.sh" "$T" "$PIN" "$@" >/dev/null; } +STRAY=00000000-0000-8000-8000-000000000000 +RECORDED=9271df15-3174-8475-ac0b-136d649c1f76 + +# 1. A fresh mint ignores a package uuid in the caller's environment. +RSR_PACKAGE_UUID="$STRAY" OWNER=o REPO=r replay "$work/fresh" +[ "$(cat "$work/fresh/.env-probe")" = "|1" ] \ + || fail "a fresh replay passed '$(cat "$work/fresh/.env-probe")'; expected no package uuid under RSR_MINT_REPLAY=1" + +# 2. A replay from a deed passes the recorded uuid, over the environment's. +printf '(repo-deed\n (mint-answers\n :repo "r"\n :owner "o"\n :package-uuid "%s"))\n' "$RECORDED" > "$work/x_chora.deed" +RSR_PACKAGE_UUID="$STRAY" replay "$work/recorded" --answers-from "$work/x_chora.deed" +[ "$(cat "$work/recorded/.env-probe")" = "$RECORDED|1" ] \ + || fail "a recorded replay passed '$(cat "$work/recorded/.env-probe")'; expected the recorded uuid" +echo "PASS: replay-mint passes a package uuid only from recorded answers" diff --git a/tests/workflows/repo_map_newline_test.sh b/tests/workflows/repo_map_newline_test.sh new file mode 100644 index 0000000..98cb201 --- /dev/null +++ b/tests/workflows/repo_map_newline_test.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# gen-repo-map.sh builds the map from lines, so a path holding a newline would +# be mapped as two bogus entries (Codex review of #277). It is refused by name, +# in both modes; a repository without one is not. +set -euo pipefail +repo=$(git rev-parse --show-toplevel) +work=$(mktemp -d); trap 'rm -rf "$work"' EXIT +fail() { echo "FAIL: $*" >&2; exit 1; } +g0() { git -c user.name=t -c user.email=t@example.invalid -c commit.gpgsign=false "$@"; } + +R="$work/r" +g0 init -q "$R" +mkdir -p "$R/notes"; printf 'x\n' > "$R/notes/plain.txt" +g0 -C "$R" add -A +for mode in "" --worktree; do + # Control: no such path, no such refusal (whatever else the run reports). + if bash "$repo/scripts/gen-repo-map.sh" $mode "$R" 2>"$work/err" >/dev/null || true; then :; fi + if grep -q 'containing a newline' "$work/err"; then fail "refused a repository with no newline path (mode '${mode:-index}')"; fi +done +printf 'y\n' > "$R/notes/a +b" +g0 -C "$R" add -A +for mode in "" --worktree; do + rc=0; bash "$repo/scripts/gen-repo-map.sh" $mode "$R" 2>"$work/err" >/dev/null || rc=$? + [ "$rc" -eq 2 ] && grep -q 'containing a newline' "$work/err" && grep -qF 'notes/a\nb' "$work/err" \ + || { cat "$work/err" >&2; fail "a newline path was not refused by name (mode '${mode:-index}', exit $rc)"; } +done +echo 'PASS: gen-repo-map.sh refuses a path holding a newline, by name, in both modes' diff --git a/tests/workflows/resolve_template_pin_test.sh b/tests/workflows/resolve_template_pin_test.sh index d2f241f..167fa62 100755 --- a/tests/workflows/resolve_template_pin_test.sh +++ b/tests/workflows/resolve_template_pin_test.sh @@ -118,7 +118,44 @@ out=$(bash "$pin" --slug "$SLUG" --url "$L" --dir "$work/lchild") || fail "a lon [ "$(field commit "$out")" = "$LC" ] || fail "long history resolved to $(field commit "$out"), want $LC" echo 'PASS: a 3000-commit history resolves its newest commit without SIGPIPE' -# 9. repo-init must take provenance from the resolver, not from local HEAD. +# 9. Repeated trees (Codex review of #251). Commits A1 and A2 (empty) share a +# tree; B follows with another. Dates are pinned so creation time decides. +A="$work/amb" +mkdir -p "$A"; g -C "$A" init -q -b main +at() { GIT_COMMITTER_DATE="@$1 +0000" GIT_AUTHOR_DATE="@$1 +0000" g -C "$A" "${@:2}"; } +printf 'a\n' > "$A/README.adoc"; g -C "$A" add -A; at 1700000100 commit -qm A1 +A1=$(g -C "$A" rev-parse HEAD) +at 1700000200 commit -q --allow-empty -m A2; A2=$(g -C "$A" rev-parse HEAD) +printf 'b\n' > "$A/README.adoc"; at 1700000300 commit -qam B +# amb_child DIR EPOCH — a Use-this-template child with A1's tree, created at EPOCH. +amb_child() { + rm -rf "$1"; mkdir -p "$1"; g -C "$A" archive "$A1" | tar x -C "$1" + g -C "$1" init -q -b main; g -C "$1" add -A + GIT_COMMITTER_DATE="@$2 +0000" GIT_AUTHOR_DATE="@$2 +0000" g -C "$1" commit -qm "Initial commit" +} +# Created while A2 was the tip: the exact source is A2, by creation time. +amb_child "$work/amb-c1" 1700000250 +out=$(bash "$pin" --slug "$SLUG" --url "$A" --dir "$work/amb-c1") || fail 'a child created at A2 did not resolve' +[ "$(field commit "$out")" = "$A2" ] || fail "child created at A2 resolved to $(field commit "$out")" +# Created while A1 was the tip: A1, although A2 is newer with the same tree. +amb_child "$work/amb-c0" 1700000150 +[ "$(field commit "$(bash "$pin" --slug "$SLUG" --url "$A" --dir "$work/amb-c0")")" = "$A1" ] \ + || fail 'a child created at A1 was not pinned to A1' +# Created after B: the tip no longer has the tree, two commits do: refused. +amb_child "$work/amb-c2" 1700000400 +if bash "$pin" --slug "$SLUG" --url "$A" --dir "$work/amb-c2" >/dev/null 2>"$work/err"; then + fail 'an ambiguous tree resolved to one commit silently' +fi +grep -q 'ambiguous' "$work/err" && grep -q "$A1" "$work/err" && grep -q "$A2" "$work/err" \ + || fail 'the ambiguity refusal does not list both commits' +[ "$(field commit "$(bash "$pin" --slug "$SLUG" --url "$A" --dir "$work/amb-c2" --commit "$A1" 2>/dev/null)")" = "$A1" ] \ + || fail 'an asserted commit did not settle the ambiguity' +# An uncommitted copy has no creation time: refused too. +rm -rf "$work/amb-wt"; mkdir -p "$work/amb-wt"; g -C "$A" archive "$A1" | tar x -C "$work/amb-wt"; g -C "$work/amb-wt" init -q +if bash "$pin" --slug "$SLUG" --url "$A" --dir "$work/amb-wt" >/dev/null 2>&1; then fail 'an ambiguous work tree resolved silently'; fi +echo 'PASS: repeated trees resolve by creation time, or are refused and listed' + +# 10. repo-init must take provenance from the resolver, not from local HEAD. recipe="$repo/build/just/repo-init.just" grep -q 'bash scripts/resolve-template-pin.sh' "$recipe" || fail 'repo-init does not call the resolver' if grep -nE 'PARENT_(PIN|BRANCH|TREE)=\$\(git ' "$recipe"; then fail 'repo-init reads the parent pin from local git again'; fi diff --git a/tests/workflows/secret_scanner_contract_test.sh b/tests/workflows/secret_scanner_contract_test.sh new file mode 100755 index 0000000..b3446f6 --- /dev/null +++ b/tests/workflows/secret_scanner_contract_test.sh @@ -0,0 +1,230 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# secret_scanner_contract_test.sh — the three scanner check contexts are this +# repository's own, stable, least-privileged and lockable. +# +# WHY THIS EXISTS, in one measurement. +# +# The estate canonical secret scanner is a single job-level call: +# +# jobs: +# scan: +# uses: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@ +# secrets: inherit +# +# A reusable call reports ` / `, so that wrapper emits +# `scan / gitleaks`, `scan / rust-secrets` and `scan / shell-secrets` — good +# names — but they are produced by jobs defined in a REMOTE file. Three +# consequences, each observed: +# +# 1. `secrets: inherit` forwards every repository and organisation secret to +# a scanner that declares no `workflow_call.secrets` and reads no +# `secrets.*` (CWE-250). Measured across the estate on 2026-11-08: +# hyperpolymath/modshells and hyperpolymath/docmatrix both carry it, and +# docmatrix's header still claims inherit is REQUIRED. It is not. +# 2. Renaming a job inside the callee renames the caller's check contexts. +# A required context that moves when someone edits another repository is +# not a contract. +# 3. `gh actions-lock` cannot lock a job-level remote reusable. A hand-added +# record for one is worse than none: present-but-unresolvable fails the +# run at startup with zero jobs, absent is merely unverified. See the +# header of scripts/check-lock-sync.sh. +# +# This repository therefore runs the same three scanners as ordinary inline +# jobs whose `name:` is the context. This test is the control that keeps it +# that way, and it is deliberately shaped as a SILENCE test (the shipped tree +# passes) plus FIRING tests (mutants fail), so it cannot pass vacuously. +# +# Checks, in order: +# silence shipped workflow: three jobs, exact names, read-only, no secrets:, +# pinned actions, triggers on pull_request and on push to the +# default branch, and a concurrency group +# silence every mapped required context resolves to a job of that name +# silence no workflow in the tree carries `secrets: inherit` +# firing each property above, mutated, fails the check +# wiring scripts/check-required-contexts.sh still certifies the real tree +# +# Usage: bash tests/workflows/secret_scanner_contract_test.sh +set -uo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +WF="$REPO_DIR/.github/workflows/secret-scanner.yml" +MAP="$REPO_DIR/.machine_readable/required-contexts-allow.txt" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +pass=0 +fail=0 +ok() { pass=$((pass + 1)); echo " PASS: $1"; } +bad() { + fail=$((fail + 1)) + echo " FAIL: $1" >&2 + [ -f "$WORK/why" ] && sed 's/^/ /' "$WORK/why" >&2 + echo "::error::secret-scanner contract test failed: $1" # NOSONAR +} + +# check FILE — assert every contract property on a candidate workflow file. +# Writes the reason to $WORK/why on failure and returns non-zero. +check() { + local f="$1" want ctx jobs out + : > "$WORK/why" + + # 1. exactly the three scanner jobs, by exact name + for ctx in "scan / gitleaks" "scan / rust-secrets" "scan / shell-secrets"; do + if ! grep -qxF " name: \"$ctx\"" "$f"; then + echo "no job named exactly \"$ctx\"" >> "$WORK/why" + return 1 + fi + done + want=3 + + # 2. no OTHER `scan / …` context: a fourth scanner needs a deliberate edit + # here too, so a context cannot appear in a generated repo unnoticed. + mapfile -t out < <(grep -oE '^ name: "scan / [a-z-]+"' "$f") + if [ "${#out[@]}" -ne "$want" ]; then + printf 'expected %s scan job(s), found %s: %s\n' \ + "$want" "${#out[@]}" "${out[*]}" >> "$WORK/why" + return 1 + fi + + # 3. a job-level remote reusable call would make the names remote: refuse + if grep -qE '^[[:space:]]+uses:[[:space:]]*[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/\.github/workflows/' "$f"; then + echo "job-level remote reusable call: the check names would belong to the callee" >> "$WORK/why" + return 1 + fi + + # 4. least privilege: workflow AND every job is contents: read only + if ! grep -qxE 'permissions:' "$f"; then + echo "no top-level permissions:" >> "$WORK/why" + return 1 + fi + if grep -qE '^[[:space:]]+(actions|checks|pull-requests|issues|statuses|packages|id-token|pages|deployments):' "$f"; then + echo "a job or the workflow grants more than contents: read" >> "$WORK/why" + return 1 + fi + + # 5. no secret access of any kind + if grep -qE '^[[:space:]]*secrets:' "$f"; then + echo "the workflow passes secrets: to a scanner that declares none" >> "$WORK/why" + return 1 + fi + if grep -qE 'secrets\.[A-Za-z_]' "$f"; then + echo "the workflow reads a secrets.* context" >> "$WORK/why" + return 1 + fi + + # 6. every uses: is a full 40-hex pin (the lock then has nothing to + # invent, and a tag cannot move under the run) + if grep -nE '^[[:space:]]+-?[[:space:]]*uses:' "$f" | grep -vE '@[0-9a-f]{40}([[:space:]]*#.*)?$' > "$WORK/unpinned" 2>/dev/null; then + echo "uses: refs that are not 40-hex pins:" >> "$WORK/why" + cat "$WORK/unpinned" >> "$WORK/why" + return 1 + fi + + # 7. triggers: pull_request bare (every PR, no path filter) and push to the + # real default branch. main is this repository's default branch; master + # is not, so it must not be the only value. + grep -qE '^ pull_request:' "$f" || { echo "no pull_request trigger" >> "$WORK/why"; return 1; } + grep -qE '^ push:' "$f" || { echo "no push trigger" >> "$WORK/why"; return 1; } + grep -qE '^[[:space:]]+branches:.*\bmain\b' "$f" \ + || { echo "push does not target main (the default branch)" >> "$WORK/why"; return 1; } + + # 8. concurrency: a superseded PR scan must not keep burning a runner + grep -qE '^concurrency:' "$f" || { echo "no concurrency group" >> "$WORK/why"; return 1; } + grep -qE '^[[:space:]]+cancel-in-progress:[[:space:]]*true' "$f" \ + || { echo "concurrency does not cancel superseded runs" >> "$WORK/why"; return 1; } + + return 0 +} + +echo "=== 1. The workflow as it ships ===" +if check "$WF"; then ok "shipped secret-scanner.yml satisfies the three-context contract"; else bad "shipped secret-scanner.yml violates the contract"; fi + +echo "=== 2. Every mapped context has a job of exactly that name ===" +if [ ! -f "$MAP" ]; then + bad "mapping file $MAP is missing" +else + mapped=0 + while IFS= read -r line; do + line="${line%%$'\r'}" + [ -n "$line" ] || continue + case "$line" in \#*) continue ;; esac + ctx="${line%%=>*}" + wf="${line##*=>}" + ctx="$(printf '%s' "$ctx" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + wf="$(printf '%s' "$wf" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + mapped=$((mapped + 1)) + if [ ! -f "$REPO_DIR/$wf" ]; then + bad "mapped context '$ctx' names missing workflow '$wf'" + continue + fi + if grep -qxF " name: \"$ctx\"" "$REPO_DIR/$wf" || grep -qxF " name: \"$ctx\"" "$REPO_DIR/$wf"; then + ok "mapped context '$ctx' is a job name in $wf" + else + bad "mapped context '$ctx' has no job of that name in $wf" + fi + done < "$MAP" + [ "$mapped" -ge 3 ] && ok "$mapped required context(s) mapped" \ + || bad "only $mapped context(s) mapped; all three scanner jobs should be" +fi + +echo "=== 3. No workflow passes secrets: inherit ===" +if hits="$(grep -rn '^[[:space:]]*secrets:[[:space:]]*inherit' "$REPO_DIR/.github/workflows/" 2>/dev/null)"; then + printf '%s\n' "$hits" > "$WORK/why" + bad "a workflow forwards every secret with secrets: inherit" +else + ok "no workflow passes secrets: inherit" +fi + +echo "=== 4. Firing: each contract property, mutated, is caught ===" +mutate() { # mutate LABEL SED-EXPR + cp "$WF" "$WORK/m.yml" + sed -i "$2" "$WORK/m.yml" + if cmp -s "$WF" "$WORK/m.yml"; then + bad "$1: the mutation changed nothing (control is wrong, not the tree)" + return + fi + if check "$WORK/m.yml"; then + bad "$1: the mutant passed the contract check" + else + ok "$1: caught" + fi +} + +mutate "a renamed scanner job" 's| name: "scan / rust-secrets"| name: "scan / rust-secrets-v2"|' +mutate "a missing scanner job" '/^ rust-secrets:$/,/^ shell-secrets:$/{/^ shell-secrets:$/!d}' +mutate "secrets: inherit added" 's|^ permissions:$| secrets: inherit\n permissions:|' +mutate "a secrets.* read added" 's|^ set -euo pipefail$| set -euo pipefail; echo "${{ secrets.TOKEN }}"|' +mutate "a broader permission granted" 's|^permissions:$|permissions:\n actions: read|' +mutate "an unpinned action" 's|@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1|@v7.0.1|' +mutate "push no longer targets the default branch" 's|^ branches: \[main\]$| branches: [master]|' +mutate "no pull_request trigger" 's|^ pull_request:$| schedule:|' +mutate "no concurrency group" 's|^concurrency:$|# concurrency:|' +mutate "a job-level remote reusable call" \ + 's|^ shell-secrets:$| delegated:\n permissions:\n contents: read\n uses: metadatastician/standards/.github/workflows/secret-scanner-reusable.yml@e064539116c2dcfdf282a4749dbd58c69961c7e6\n shell-secrets:|' + +echo "=== 5. Wire-up: the real tree still certifies all three contexts ===" +if command -v bun >/dev/null 2>&1; then + if bash "$REPO_DIR/scripts/check-required-contexts.sh" "$REPO_DIR" > "$WORK/rc.log" 2>&1; then + if grep -q '3 required status context' "$WORK/rc.log"; then + ok "check-required-contexts.sh certifies 3 contexts on the real tree" + else + cp "$WORK/rc.log" "$WORK/why" + bad "check-required-contexts.sh passed but certified a different count" + fi + else + cp "$WORK/rc.log" "$WORK/why" + bad "check-required-contexts.sh fails on the real tree" + fi +else + echo " SKIP: bun is not installed, so check-required-contexts.sh cannot parse YAML" +fi + +echo "" +if [ "$fail" -ne 0 ]; then + echo "FAIL: secret scanner contract suite (${pass} passed, ${fail} failed)" >&2 + exit 1 +fi +echo "PASS: secret scanner contract suite (${pass} checks)" diff --git a/tests/workflows/self_name_pass_test.sh b/tests/workflows/self_name_pass_test.sh index be41e79..17c4e01 100755 --- a/tests/workflows/self_name_pass_test.sh +++ b/tests/workflows/self_name_pass_test.sh @@ -32,13 +32,17 @@ grep -qE 'TEMPLATE_SLUG="\$\{TEMPLATE_OWNER\}/\$\{TEMPLATE_REPO\}"' "$recipe" \ || { echo "FAIL: repo-init does not compose TEMPLATE_SLUG" >&2; exit 1; } grep -qE 'PARENT_SLUG="\$TEMPLATE_SLUG"' "$recipe" \ || { echo "FAIL: repo-init does not record the derived parent in provenance" >&2; exit 1; } -# Outside comments and the documented last-resort default, no code line may +# Outside comments, no code line may # name the original template or a deed file: each such line was a mint that # silently did nothing in a second-generation child. hard=$(grep -nE 'rsr-template-repo|hyperpolymath|[A-Za-z0-9-]_chora\.deed' "$recipe" \ - | grep -vE '^[0-9]+: *#' \ - | grep -vE '^[0-9]+: *TEMPLATE_(REPO|OWNER)="\$\{TEMPLATE_(REPO|OWNER):-(rsr-template-repo|hyperpolymath)\}"$' || true) + | grep -vE '^[0-9]+: *#' || true) [ -z "$hard" ] || { echo "$hard" >&2; echo "FAIL: repo-init hardcodes a template name or deed file again" >&2; exit 1; } +# The template identity must never fall back to the git remote: in a +# template-created child the remote is the CHILD (Codex review of #251). +if sed -n '/--- The template'"'"'s own identity/,/TEMPLATE_SLUG=/p' "$recipe" | grep -v '^ *#' | grep -q 'remote\.origin'; then + echo "FAIL: repo-init derives the template identity from the git remote again" >&2; exit 1 +fi fail=0 # check INPUT EXPECTED — the pass must turn INPUT into EXPECTED. @@ -94,5 +98,65 @@ OWNER=example-owner REPO=mint-check check 'project := "Geometry.jl"' 'project := "mint-check"' check 'Geometry.jl ships it; GeometryXjl is another string' 'mint-check ships it; GeometryXjl is another string' +# ── A short, common parent name (Codex review of #251) ─────────────────────── +# Unbounded, a parent named `app` corrupted every word containing "app". +TEMPLATE_OWNER=example-owner TEMPLATE_REPO=app +TEMPLATE_SLUG="${TEMPLATE_OWNER}/${TEMPLATE_REPO}" +OWNER=other-owner REPO=new +# shellcheck disable=SC1090 +. "$rule_script" +check 'application mapping app' 'application mapping new' +check 'my-app app-config apps happy' 'my-new new-config apps happy' +check 'project := "app"' 'project := "new"' +check 'app app app' 'new new new' +check 'example-owner/app and example-owner/apps' 'other-owner/new and example-owner/apps' +check 'sonar.projectKey=example-owner_app' 'sonar.projectKey=example-owner_new' +check 'app_chora.deed and app#12' 'new_chora.deed and app#12' +# A new name containing the old one terminates and is not rewritten twice. +TEMPLATE_OWNER=example-owner TEMPLATE_REPO=geo +TEMPLATE_SLUG="${TEMPLATE_OWNER}/${TEMPLATE_REPO}" +OWNER=example-owner REPO=geo-tools +# shellcheck disable=SC1090 +. "$rule_script" +check 'geo and geo' 'geo-tools and geo-tools' + +# ── Literal marker-like text survives (Codex review of #274) ───────────────── +# The pass marks matches before replacing them; markers that are plain text +# rewrote a user's literal "__TEMPLATE_NAME__". They are control characters now. +TEMPLATE_OWNER=example-owner TEMPLATE_REPO=app +TEMPLATE_SLUG="${TEMPLATE_OWNER}/${TEMPLATE_REPO}" +OWNER=other-owner REPO=new +# shellcheck disable=SC1090 +. "$rule_script" +check 'app handles __TEMPLATE_NAME__ and __TEMPLATE_SLUG__ text' 'new handles __TEMPLATE_NAME__ and __TEMPLATE_SLUG__ text' + +# ── The self-owner rule: every form the identity parser accepts ───────────── +# One definition (SELF_OWNER_SED), used by the mint and by chain-mint.sh on +# the parent's carried files (Codex review of #277). It touches exactly the +# three identity lines; estate dependencies under the old owner stay. +oprog=$(grep -m1 -E '^ +SELF_OWNER_SED="' "$recipe") \ + || { echo "FAIL: no SELF_OWNER_SED line in $recipe" >&2; exit 1; } +grep -qE '^ +sed -i -E "\$SELF_OWNER_SED" Justfile sonar-project.properties$' "$recipe" \ + || { echo "FAIL: the self-owner pass does not apply SELF_OWNER_SED" >&2; exit 1; } +printf '%s\n' "$oprog" > "$rule_script" +sed_rhs() { printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g'; } +TEMPLATE_OWNER=example-owner OWNER=other-owner +# shellcheck disable=SC1090 +. "$rule_script" +ocheck() { + local got; got=$(printf '%s\n' "$1" | sed -E "$SELF_OWNER_SED") + if [ "$got" = "$2" ]; then echo " PASS owner: $1"; else echo " FAIL owner: $1 -> $got (want $2)" >&2; fail=1; fi +} +ocheck 'OWNER := "example-owner"' 'OWNER := "other-owner"' +ocheck "OWNER := 'example-owner'" "OWNER := 'other-owner'" +ocheck 'export OWNER := "example-owner"' 'export OWNER := "other-owner"' +ocheck 'sonar.organization=example-owner' 'sonar.organization=other-owner' +ocheck 'sonar.projectKey=example-owner_elder-proj' 'sonar.projectKey=other-owner_elder-proj' +# Controls: dependencies, longer owners and other keys are left alone. +ocheck 'uses: example-owner/standards/.github/workflows/x.yml@main' 'uses: example-owner/standards/.github/workflows/x.yml@main' +ocheck 'sonar.organization=example-owner-two' 'sonar.organization=example-owner-two' +ocheck 'OWNER := "example-owner-two"' 'OWNER := "example-owner-two"' +ocheck 'REPO_OWNER := "example-owner"' 'REPO_OWNER := "example-owner"' + [ "$fail" -eq 0 ] || { echo "FAIL: self-name pass" >&2; exit 1; } echo 'PASS: self-name pass rewrites the derived template name, keeps issue references, provenance and detectors' diff --git a/tests/workflows/squabble_gate_test.sh b/tests/workflows/squabble_gate_test.sh index ec8625b..2e4447a 100755 --- a/tests/workflows/squabble_gate_test.sh +++ b/tests/workflows/squabble_gate_test.sh @@ -123,6 +123,7 @@ on: - 'src/**' jobs: scan: + name: scan / gitleaks runs-on: ubuntu-latest steps: - run: echo hi @@ -139,6 +140,7 @@ on: pull_request: jobs: scan: + name: scan / gitleaks runs-on: ubuntu-latest steps: - run: echo hi @@ -175,6 +177,7 @@ name: Secret Scanner on: [push, pull_request] jobs: scan: + name: scan / gitleaks runs-on: ubuntu-latest steps: - run: echo hi @@ -281,13 +284,96 @@ fresh_context() { rm -rf "$context_tree" mkdir -p "$context_tree/.github/workflows" "$context_tree/.machine_readable" printf '# mapping\r\n\r\n scan / gitleaks => .github/workflows/scan.yaml \r\n' > "$context_tree/.machine_readable/required-contexts-allow.txt" - printf 'name: Scan\non: pull_request\njobs: {}\n' > "$context_tree/.github/workflows/scan.yaml" + printf 'name: Scan\non: pull_request\njobs:\n gitleaks:\n name: scan / gitleaks\n runs-on: ubuntu-latest\n steps: [{run: echo hi}]\n' > "$context_tree/.github/workflows/scan.yaml" } fresh_context assert_rc 0 'scalar PR event, yaml extension, comments, CRLF, and whitespace are accepted' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" -printf 'name: Scan\non: pull_request_target\njobs: {}\n' > "$context_tree/.github/workflows/scan.yaml" +sed -i 's|name: scan / gitleaks|name: scan / renamed|' "$context_tree/.github/workflows/scan.yaml" +assert_rc 1 'a renamed job no longer produces the required context' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +printf 'name: Scan\non: pull_request\njobs: {}\n' > "$context_tree/.github/workflows/scan.yaml" +assert_rc 1 'a PR-triggered workflow with no jobs produces no context' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +printf 'name: Scan\non: pull_request\njobs:\n gitleaks:\n name: scan / gitleaks\n steps: [{run: echo hi}]\n' > "$context_tree/.github/workflows/scan.yaml" +assert_rc 1 'a job with neither runs-on nor uses produces no context' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +# A job that calls a reusable workflow produces ` / `, +# never its own name alone; a remote reusable cannot be certified from here. +printf 'name: Inner\non: workflow_call\njobs:\n g:\n name: gitleaks\n runs-on: ubuntu-latest\n steps: [{run: echo hi}]\n' > "$context_tree/.github/workflows/inner.yaml" +printf 'name: Scan\non: pull_request\njobs:\n scan:\n uses: ./.github/workflows/inner.yaml\n' > "$context_tree/.github/workflows/scan.yaml" +assert_rc 0 'a local reusable produces / ' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +printf 'name: Scan\non: pull_request\njobs:\n scan:\n name: scan / gitleaks\n uses: ./.github/workflows/inner.yaml\n' > "$context_tree/.github/workflows/scan.yaml" +assert_rc 1 'a reusable caller named like the context does not produce it' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +printf 'name: Scan\non: pull_request\njobs:\n scan:\n name: scan / gitleaks\n uses: example/repo/.github/workflows/inner.yaml@0123456789012345678901234567890123456789\n' > "$context_tree/.github/workflows/scan.yaml" +assert_rc 1 'a remote reusable produces nothing the gate can certify' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +# GitHub names matrix runs `name (v1, v2)`, or evaluates a name that refers to +# the matrix; anything the gate cannot expand statically is refused, by name. +printf 'name: Scan\non: pull_request\njobs:\n scan:\n name: scan\n strategy: {matrix: {tool: [gitleaks, trufflehog]}}\n runs-on: ubuntu-latest\n steps: [{run: echo hi}]\n' > "$context_tree/.github/workflows/scan.yaml" +printf 'scan (gitleaks) => .github/workflows/scan.yaml\n' > "$context_tree/.machine_readable/required-contexts-allow.txt" +assert_rc 0 'a literal matrix names its runs `name (value)`' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +printf 'name: Scan\non: pull_request\njobs:\n scan:\n name: scan / ${{ matrix.tool }}\n strategy: {matrix: {tool: [gitleaks]}}\n runs-on: ubuntu-latest\n steps: [{run: echo hi}]\n' > "$context_tree/.github/workflows/scan.yaml" +printf 'scan / gitleaks => .github/workflows/scan.yaml\n' > "$context_tree/.machine_readable/required-contexts-allow.txt" +assert_rc 0 'a name that refers to the matrix is evaluated per run' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +printf 'name: Scan\non: pull_request\njobs:\n scan:\n name: scan / ${{ matrix.tool }}\n strategy: {matrix: {tool: [gitleaks], include: [{tool: trufflehog}]}}\n runs-on: ubuntu-latest\n steps: [{run: echo hi}]\n' > "$context_tree/.github/workflows/scan.yaml" +assert_rc 1 'a matrix with include is refused, not guessed' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +cp "$WORK/out" "$WORK/refusal" +assert_rc 0 'the include refusal names the job and says why' grep -q "not resolvable statically.*scan / \${{ matrix.tool }}" "$WORK/refusal" +printf 'name: Scan\non: pull_request\njobs:\n scan:\n name: scan / ${{ matrix.tool }}\n strategy:\n matrix: ${{ fromJSON(vars.TOOLS) }}\n runs-on: ubuntu-latest\n steps: [{run: echo hi}]\n' > "$context_tree/.github/workflows/scan.yaml" +assert_rc 1 'a computed matrix is refused, not guessed' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +cp "$WORK/out" "$WORK/refusal" +assert_rc 0 'the computed-matrix refusal names the job and says why' grep -q "not resolvable statically.*scan / \${{ matrix.tool }}" "$WORK/refusal" +printf 'name: Scan\non: pull_request\njobs:\n scan:\n name: scan / ${{ matrix.tool }}\n strategy: {matrix: {TOOL: [gitleaks]}}\n runs-on: ubuntu-latest\n steps: [{run: echo hi}]\n' > "$context_tree/.github/workflows/scan.yaml" +assert_rc 0 'matrix property names match case-insensitively' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +printf 'name: Scan\non: pull_request\njobs:\n scan:\n name: scan / ${{ matrix.tool }}\n strategy: {matrix: {TOOL: [gitleaks], tool: [trufflehog]}}\n runs-on: ubuntu-latest\n steps: [{run: echo hi}]\n' > "$context_tree/.github/workflows/scan.yaml" +assert_rc 1 'keys differing only in case are refused as ambiguous' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +printf 'name: Scan\non: pull_request\njobs:\n scan:\n name: scan / ${{ matrix.1tool }}\n strategy: {matrix: {1tool: [gitleaks]}}\n runs-on: ubuntu-latest\n steps: [{run: echo hi}]\n' > "$context_tree/.github/workflows/scan.yaml" +assert_rc 1 'a dereference GitHub rejects (matrix.1tool) is refused' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +# A called file counts only when it declares on: workflow_call; GitHub nests +# at most ten workflows (the caller and nine reusables); a cycle is refused. +printf 'name: Scan\non: pull_request\njobs:\n scan:\n uses: ./.github/workflows/inner.yaml\n' > "$context_tree/.github/workflows/scan.yaml" +printf 'name: Inner\non: push\njobs:\n g:\n name: gitleaks\n runs-on: ubuntu-latest\n steps: [{run: echo hi}]\n' > "$context_tree/.github/workflows/inner.yaml" +assert_rc 1 'a called file without on: workflow_call produces nothing' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +chain_tree() { # : scan.yaml calls r1 ... rN; the last runs job gitleaks + local n=$1 i ctx=scan + printf 'name: Scan\non: pull_request\njobs:\n scan:\n uses: ./.github/workflows/r1.yaml\n' > "$context_tree/.github/workflows/scan.yaml" + for ((i = 1; i < n; i++)); do + printf 'name: R\non: workflow_call\njobs:\n l%d:\n uses: ./.github/workflows/r%d.yaml\n' "$i" "$((i + 1))" > "$context_tree/.github/workflows/r$i.yaml" + ctx="$ctx / l$i" + done + printf 'name: R\non: workflow_call\njobs:\n g:\n name: gitleaks\n runs-on: ubuntu-latest\n steps: [{run: echo hi}]\n' > "$context_tree/.github/workflows/r$n.yaml" + printf '%s / gitleaks => .github/workflows/scan.yaml\n' "$ctx" > "$context_tree/.machine_readable/required-contexts-allow.txt" +} +chain_tree 9 +assert_rc 0 'nine nested reusables (ten workflows) resolve' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +chain_tree 10 +assert_rc 1 'a tenth nested reusable exceeds GitHub'"'"'s limit' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +rm -f "$context_tree"/.github/workflows/r*.yaml +printf 'name: Scan\non: [pull_request, workflow_call]\njobs:\n scan:\n uses: ./.github/workflows/scan.yaml\n' > "$context_tree/.github/workflows/scan.yaml" +printf 'scan / scan => .github/workflows/scan.yaml\n' > "$context_tree/.machine_readable/required-contexts-allow.txt" +assert_rc 1 'a workflow calling itself is refused, not followed' timeout 20 bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +printf 'name: Inner\non: workflow_call\njobs:\n g:\n name: gitleaks\n runs-on: ubuntu-latest\n steps: [{run: echo hi}]\n' > "$context_tree/.github/workflows/inner.yaml" +printf 'scan / gitleaks => .github/workflows/scan.yaml\n' > "$context_tree/.machine_readable/required-contexts-allow.txt" +# `$/.github/workflows/…` is GitHub's recommended same-repository form, and a +# caller with a literal matrix produces ` / `. +printf 'name: Scan\non: pull_request\njobs:\n scan:\n uses: $/.github/workflows/inner.yaml\n' > "$context_tree/.github/workflows/scan.yaml" +assert_rc 0 'a $/ same-repository call resolves like ./' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +printf 'name: Scan\non: pull_request\njobs:\n scan:\n uses: $/.github/workflows/inner.yaml@v1\n' > "$context_tree/.github/workflows/scan.yaml" +assert_rc 1 'a $/ call with an @ref (which GitHub forbids) produces nothing' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +printf 'name: Scan\non: pull_request\njobs:\n scan:\n strategy: {matrix: {target: [dev, prod]}}\n uses: ./.github/workflows/inner.yaml\n' > "$context_tree/.github/workflows/scan.yaml" +printf 'scan (prod) / gitleaks => .github/workflows/scan.yaml\n' > "$context_tree/.machine_readable/required-contexts-allow.txt" +assert_rc 0 'a matrix caller produces / ' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +# GitHub runs only files directly in .github/workflows/: a subdirectory or a +# traversal is not a reusable workflow, whatever file it reaches. +mkdir -p "$context_tree/.github/workflows/sub" +cp "$context_tree/.github/workflows/inner.yaml" "$context_tree/.github/workflows/sub/inner.yaml" +printf 'scan / gitleaks => .github/workflows/scan.yaml\n' > "$context_tree/.machine_readable/required-contexts-allow.txt" +printf 'name: Scan\non: pull_request\njobs:\n scan:\n uses: ./.github/workflows/sub/inner.yaml\n' > "$context_tree/.github/workflows/scan.yaml" +assert_rc 1 'a reusable in a subdirectory of .github/workflows produces nothing' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +printf 'name: Scan\non: pull_request\njobs:\n scan:\n uses: ./.github/workflows/sub/../inner.yaml\n' > "$context_tree/.github/workflows/scan.yaml" +assert_rc 1 'a traversal in a reusable path produces nothing' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" +rm -r "$context_tree/.github/workflows/sub" +printf 'scan / gitleaks => .github/workflows/scan.yaml\n' > "$context_tree/.machine_readable/required-contexts-allow.txt" +rm "$context_tree/.github/workflows/inner.yaml" +printf 'name: Scan\non: pull_request_target\njobs:\n gitleaks:\n name: scan / gitleaks\n runs-on: ubuntu-latest\n steps: [{run: echo hi}]\n' > "$context_tree/.github/workflows/scan.yaml" assert_rc 1 'pull_request_target alone cannot produce a required PR check' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" -printf 'name: Scan\non:\n pull_request:\n paths-ignore: []\njobs: {}\n' > "$context_tree/.github/workflows/scan.yaml" +printf 'name: Scan\non:\n pull_request:\n paths-ignore: []\njobs:\n gitleaks:\n name: scan / gitleaks\n runs-on: ubuntu-latest\n steps: [{run: echo hi}]\n' > "$context_tree/.github/workflows/scan.yaml" assert_rc 1 'paths-ignore is forbidden even when empty' bash "$REPO_ROOT/scripts/check-required-contexts.sh" "$context_tree" for mapping in '# only comments' 'missing separator' ' => .github/workflows/scan.yaml' \ 'scan / gitleaks =>' 'scan => file => another'; do diff --git a/verification/legs/README.adoc b/verification/legs/README.adoc new file mode 100644 index 0000000..6c748fd --- /dev/null +++ b/verification/legs/README.adoc @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) += The Rocq and Lean recheck legs +:revdate: 2026-11-09 + +`standards/3-practice/PROOF-POLICY.adoc` §2 requires every Agda theory proof to +be rechecked in Rocq and in Lean. Transport through Dedukti comes first, and a +hand re-proof without Mathlib is the fallback. This directory holds both legs +for N-1, N-2, N-3, N-7, N-8 and N-9, and this file is their §2.2 +correspondence record. + +`bash scripts/check-legs.sh` checks both. CI runs it as the `legs` job of +`.github/workflows/proofs.yml`. + +== Route: `re-proved`, and why not `transported` + +Transport was tried first, as the policy orders, and is not available today: + +* Agda2Dedukti, the only Agda→Dedukti translator, builds against its own fork + of Agda (pinned commit `b026db1`, GHC 8.10, Stackage LTS 18.21). It is an Agda + backend, so it cannot run on Agda 2.6.4.3, the version the gate pins. +* Even with a Dedukti term, there is no released exporter from Dedukti or + Lambdapi to Lean 4, and no released route to Rocq that this repository + could pin and run in CI. + +So both legs are hand re-proofs of the Agda statements. Each leg was written +from the Agda module, statement by statement and proof by proof, and checked by +its own kernel. Nothing is translated mechanically, so the statements are +compared by hand below. + +== Versions and invocations + +[cols="1,2,3",options="header"] +|=== +| Leg | Version | Invocation + +| Rocq +| Coq 8.18.0 (Ubuntu 24.04 package `coq=8.18.0+dfsg-1build2`) +| `coqc -q SecretTypes.v`, in `rocq/`. + The file loads no library, not even with `Require`: only Coq's prelude. + +| Lean +| Lean 4.24.0 (release tarball, sha256 + `b14f5e5159219dd1a1956c3b806813319f5e94ccd5bdfd56f54520609a5bb5ec`) +| `lake build`, then `lake env lean Axioms.lean`, in `lean/`. `lakefile.toml` + declares no `require`, so there is no Mathlib and no other package: only + Lean core. +|=== + +`scripts/check-legs.sh` refuses the run if either version differs, if a +`require` or a `Require` appears, or if any source contains `sorry`, `admit`, +`Admitted`, `Axiom` or `Parameter`. + +== Assumptions: none + +Every theorem below is printed with its axioms (§8.4): + +* Rocq: `Print Assumptions` reports `Closed under the global context` for + all 11 theorems. The prelude's classical axioms are not loaded, so none can + enter. +* Lean: `#print axioms` (in `lean/Axioms.lean`) reports + `does not depend on any axioms` for all 11. In particular, no `propext`, + `Quot.sound` or `Classical.choice`. + +`check-legs.sh` fails unless every one of the 22 lines says so. + +== Universe discipline + +Agda's `Set` is `Type` in both legs. The order `_⊑_`, an observer's `sees` and +the typing derivations are `Type`-valued in both, as in Agda, so a proof of +`x ⊑ y` is data in all three systems. A store agreement is an equation between +naturals: `Prop` in the legs and a set in Agda. The naturals have decidable +equality, so the two readings hold the same information. + +The Agda proof of N-7 splits on whether the observer sees a branch label, +under double negation, because agreement is stable (`¬¬ (s ≈ s') → s ≈ s'`). +Both legs make the same move (`agree_stable`, `by_cases_sees`), so neither leg +uses excluded middle. + +== Statement correspondence + +The legs keep the Agda names where the syntax allows. Lean writes `₁`/`₂` and +`'`, Rocq writes `1`/`2` and `0`/`1`. The statements are compared on the +following points; any other difference is notation. + +`exec`:: Agda defines `exec` with two helpers, `execIf` and `execWhile`. Both +legs define one function, `run prev`, structural in the command, with `prev` +the run at one less fuel (`none` at fuel zero), and set `exec 0 = run none` +and `exec (f + 1) = run (some (exec f))`. Clause by clause this is Agda's +definition: sequence and `ifz` keep the fuel, a loop at `f + 1` runs its body +and re-enters at `f`, and a loop at `0` is `nothing`. The legs prove the +clauses as lemmas (`exec_skip`, `exec_assign`, `exec_seq`, `exec_ifz`, +`exec_while_zero` in Lean, `exec_while_succ`), each by `rfl` or by cases. The +split exists so that Lean needs no well-founded recursion. Well-founded +recursion would bring in `propext`. + +`Maybe`:: `Option` in Lean and `option` in Rocq, with `nothing`/`just` as +`none`/`some`. + +The record:: Agda has one record, `Lattice` (`SecretTypes.Lattice.Record`), with +the carrier, `_⊑_`, `_⊔_`, `⊥ₗ` and exactly three laws: `⊑-trans`, +`⊔-upper₁` and `⊔-upper₂`. The legs split it into `Signature`, with the +operations and no laws, and `Lattice`, which extends it with the same three +laws. `TINI` and the typing rules are stated over `Signature`. That is what +the Agda `Statement` and `Typing.Generic` modules are parameterised by, and the +N-8 countermodels need it to state `TINI` where a law fails. The generic proof +(`tini`) takes a `Lattice`. The laws are the same three, so neither leg is +weaker or stronger than Agda here. + +`Observer`:: `sees : Carrier → Type` and down-closure, as in Agda. +`principal L o` sees `· ⊑ o`, and its down-closure is `le_trans`, as in Agda. + +`Vars`:: a type with decidable equality. In Lean, `DecidableEq Var`; in +Rocq, `{x = y} + {x <> y}`. Agda uses `Dec`. All three are the same choice +data. + +== Claim by claim + +The state of every row is `RECONCILED` (§2.2): AGDA-VERIFIED by +`scripts/check.sh`, COQ-CHECKED and LEAN-CHECKED by `scripts/check-legs.sh`, +and the statements compared above with no bridge pending. + +[cols="1,2,2,2",options="header"] +|=== +| Claim | Agda | Lean | Rocq + +| N-1 +| `Noninterference.tini` +| `SecretTypes.Two.tini_two` +| `Two.tini_two` + +| N-2 +| `Noninterference.Generic.eval-low`; `Noninterference.eval-low` +| `SecretTypes.eval_low`; `SecretTypes.Two.eval_low_two` +| `eval_low`; `Two.eval_low_two` + +| N-3 +| `Noninterference.Generic.confine`; `Noninterference.confine` +| `SecretTypes.confine` (with `confine_loop`); `SecretTypes.Two.confine_two` +| `confine` (with `confine_loop`); `Two.confine_two` + +| N-7 +| `Noninterference.Generic.tini : (V : Observer 𝓛) → TINI (Observer.sees V)` +| `SecretTypes.tini` +| `tini : TINI L label (sees O)` + +| N-8 +| `Countermodels.NoUpper₁.refutes`, `NoUpper₂.refutes`, + `NoDownClosure.refutes`, `NoTrans.refutes`, each `¬ TINI _` +| `SecretTypes.Countermodels.NoUpper₁.refutes`, `NoUpper₂.refutes`, + `NoDownClosure.refutes`, `NoTrans.refutes`, each `TINI … → False` +| `Countermodels.NoUpper1.refutes`, `NoUpper2.refutes`, + `NoDownClosure.refutes`, `NoTrans.refutes` + +| N-9 +| `Noninterference.OutcomeVisibility.termination-sensitive-fails : ¬ TSNI` +| `SecretTypes.Two.termination_sensitive_fails : TSNI → False` +| `Two.termination_sensitive_fails : TSNI -> False` +|=== + +Each N-8 countermodel is the Agda one: the same carrier, the same failing law, +the same typed program and the same two stores. For `NoTrans`, the +five-point order and its `join` are Agda's, case by case. N-9 uses the same +`secret-loop`, the same stores `s₀ = (0, 0)` and `s₁ = (0, 1)`, and the same +outcome at fuel 3. + +N-4, N-5 and N-6 have no leg. They are checked demonstrations, not theorems +(§8.1). + +== What the legs do not establish + +* They are re-proofs, not transports. The match between each leg statement + and its Agda statement rests on the comparison above. Claude Code wrote it, + and it is provisional until the owner reviews it (estate doctrine 4). No + tool checks it. +* `foundation_diverse` (§8.3) stays false. Lean's and Rocq's kernels are + independent of Agda's, but all three are dependent type theories. The tag + needs a witness in a different foundation, such as Isabelle/HOL, and none + exists. +* The grade is `PWG-2ii`, not `PWG-1`. `PWG-1` needs a receipt per witness, + per commit, recording the observed result of a pinned checker run on that + commit (§8.2). The receipt here records the legs' versions, invocations and + input digests; the CI log of the `legs` job is the observed result, and it + is not yet captured as a receipt. diff --git a/verification/legs/lean/.gitignore b/verification/legs/lean/.gitignore new file mode 100644 index 0000000..a2524e9 --- /dev/null +++ b/verification/legs/lean/.gitignore @@ -0,0 +1,5 @@ +# SPDX-License-Identifier: MPL-2.0 +.lake/ +# Written by `lake build`; with no `require` it lists no package, and it is not +# JCS-serialised (estate JSON policy), so it is not committed. +lake-manifest.json diff --git a/verification/legs/lean/Axioms.lean b/verification/legs/lean/Axioms.lean new file mode 100644 index 0000000..34b07ab --- /dev/null +++ b/verification/legs/lean/Axioms.lean @@ -0,0 +1,15 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Prints the axioms every Lean-leg theorem rests on (PROOF-POLICY §8.4). +-- scripts/check-legs.sh requires each line to read "does not depend on any axioms". +import SecretTypes +#print axioms SecretTypes.eval_low +#print axioms SecretTypes.confine +#print axioms SecretTypes.tini +#print axioms SecretTypes.Two.eval_low_two +#print axioms SecretTypes.Two.confine_two +#print axioms SecretTypes.Two.tini_two +#print axioms SecretTypes.Two.termination_sensitive_fails +#print axioms SecretTypes.Countermodels.NoUpper₁.refutes +#print axioms SecretTypes.Countermodels.NoUpper₂.refutes +#print axioms SecretTypes.Countermodels.NoDownClosure.refutes +#print axioms SecretTypes.Countermodels.NoTrans.refutes diff --git a/verification/legs/lean/SecretTypes.lean b/verification/legs/lean/SecretTypes.lean new file mode 100644 index 0000000..3fba1f3 --- /dev/null +++ b/verification/legs/lean/SecretTypes.lean @@ -0,0 +1,4 @@ +-- SPDX-License-Identifier: MPL-2.0 +import SecretTypes.Generic +import SecretTypes.Two +import SecretTypes.Countermodels diff --git a/verification/legs/lean/SecretTypes/Countermodels.lean b/verification/legs/lean/SecretTypes/Countermodels.lean new file mode 100644 index 0000000..3cf8884 --- /dev/null +++ b/verification/legs/lean/SecretTypes/Countermodels.lean @@ -0,0 +1,216 @@ +/- +SPDX-License-Identifier: MPL-2.0 +SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell + +Lean leg (PROOF-POLICY §2, route `re-proved`) of secret-types N-8: each law +generic TINI uses is necessary. Mirrors src/SecretTypes/Countermodels.agda +module by module: drop one law, keep the typing rules, and a well-typed +program leaks, so `TINI` is refuted at that signature. Lean core only, no +axioms. +-/ +import SecretTypes.Two + +namespace SecretTypes.Countermodels +open SecretTypes SecretTypes.Two Label Var + +/-- `std`: `lo` public, `hi` secret. -/ +def std : two.Var → Label + | lo => L + | hi => H + +/-- The two-point signature with a given join. -/ +def withJoin (j : Label → Label → Label) : Signature where + Carrier := Label + le := le + join := j + bot := L + +/-- Seen by the observer at `L`: exactly the labels below `L`. -/ +def belowL : Label → Type := fun x => le x L + +theorem agree_s₀_s₁ (S : Signature) (hS : S.Carrier = Label) (lab' : two.Var → S.Carrier) + (sees : S.Carrier → Type) (hhi : sees (lab' hi) → False) : + Agree S lab' sees s₀ s₁ := fun + | lo, _ => rfl + | hi, v => (hhi v).elim + +/-! `NoUpper₁`: the join `x ⊔ʳ y = y` keeps `⊔-upper₂` and drops `⊔-upper₁`. -/ +namespace NoUpper₁ + +def joinR : Label → Label → Label := fun _ y => y + +def S : Signature := withJoin joinR + +def upper₂ : ∀ x y : Label, le y (joinR x y) + | _, L => () + | _, H => () + +theorem not_upper₁ : (∀ x y : Label, le x (joinR x y)) → False := + fun h => nomatch (h H L : Empty) + +/-- `implicit = ifz (var hi) (lo ≔ lit 0) (lo ≔ lit 1)`, typed at `L`. -/ +def implicitFlow : Cmd two := .ifz (.var hi) (.assign lo (.lit 0)) (.assign lo (.lit 1)) + +def typed : Typed S std L implicitFlow := .ifz (.assign ()) (.assign ()) + +theorem refutes : TINI S std belowL → False := fun tini => + nomatch (tini 1 1 typed (agree_s₀_s₁ S rfl std belowL (fun v => nomatch (v : Empty))) + rfl rfl lo () : 0 = 1) + +end NoUpper₁ + +/-! `NoUpper₂`: the join `x ⊔ˡ y = x` keeps `⊔-upper₁` and drops `⊔-upper₂`. -/ +namespace NoUpper₂ + +def joinL : Label → Label → Label := fun x _ => x + +def S : Signature := withJoin joinL + +def upper₁ : ∀ x y : Label, le x (joinL x y) + | L, _ => () + | H, _ => () + +theorem not_upper₂ : (∀ x y : Label, le y (joinL x y)) → False := + fun h => nomatch (h L H : Empty) + +/-- `explicit = lo ≔ var hi`, typed at `L`. -/ +def explicitFlow : Cmd two := .assign lo (.var hi) + +def typed : Typed S std L explicitFlow := .assign () + +theorem refutes : TINI S std belowL → False := fun tini => + nomatch (tini 1 1 typed (agree_s₀_s₁ S rfl std belowL (fun v => nomatch (v : Empty))) + rfl rfl lo () : 0 = 1) + +end NoUpper₂ + +/-! `NoDownClosure`: the observer that sees exactly `H` is not down-closed. -/ +namespace NoDownClosure + +def sees : Label → Type := fun x => PLift (x = H) + +theorem not_down_closed : (∀ {x y : Label}, le x y → sees y → sees x) → False := + fun h => nomatch (h (x := L) (y := H) () ⟨rfl⟩).down + +def S : Signature := withJoin join + +/-- `up = hi ≔ var lo`, typed at `L`. -/ +def up : Cmd two := .assign hi (.var lo) + +def typed : Typed S std L up := .assign () + +def t₀ : Store two := pair 0 0 +def t₁ : Store two := pair 1 0 + +theorem agree : Agree S std sees t₀ t₁ := fun + | lo, v => nomatch v.down + | hi, _ => rfl + +theorem refutes : TINI S std sees → False := fun tini => + nomatch (tini 1 1 typed agree rfl rfl hi ⟨rfl⟩ : 0 = 1) + +end NoDownClosure + +/-! `NoTrans`: without `⊑-trans`, "flows to `o`" can fail to be down-closed, +and TINI fails for that principal observer. -/ +namespace NoTrans + +inductive P where + | B | H' | M | L' | T + +open P + +inductive Le : P → P → Type where + | refl {x} : Le x x + | bot {x} : Le B x + | top {x} : Le x T + | HM : Le H' M + | ML : Le M L' + +def join : P → P → P + | B, y => y + | T, _ => T + | H', B => H' + | H', H' => H' + | H', M => M + | H', L' => T + | H', T => T + | M, B => M + | M, H' => M + | M, M => M + | M, L' => L' + | M, T => T + | L', B => L' + | L', H' => T + | L', M => L' + | L', L' => L' + | L', T => T + +def upper₁ : ∀ x y : P, Le x (join x y) + | B, _ => .bot + | T, _ => .refl + | H', B => .refl + | H', H' => .refl + | H', M => .HM + | H', L' => .top + | H', T => .top + | M, B => .refl + | M, H' => .refl + | M, M => .refl + | M, L' => .ML + | M, T => .top + | L', B => .refl + | L', H' => .top + | L', M => .refl + | L', L' => .refl + | L', T => .top + +def upper₂ : ∀ x y : P, Le y (join x y) + | B, _ => .refl + | T, _ => .top + | H', B => .bot + | H', H' => .refl + | H', M => .refl + | H', L' => .top + | H', T => .refl + | M, B => .bot + | M, H' => .HM + | M, M => .refl + | M, L' => .refl + | M, T => .refl + | L', B => .bot + | L', H' => .top + | L', M => .ML + | L', L' => .refl + | L', T => .refl + +theorem not_trans : (∀ {x y z : P}, Le x y → Le y z → Le x z) → False := + fun t => nomatch (t Le.HM Le.ML : Le H' L') + +def S : Signature where + Carrier := P + le := Le + join := join + bot := B + +def label : two.Var → P + | lo => M + | hi => H' + +/-- `explicit = lo ≔ var hi`, typed at `B` (needs only `H′ ≤ M`). -/ +def explicitFlow : Cmd two := .assign lo (.var hi) + +def typed : Typed S label B explicitFlow := .assign .HM + +def belowL' : P → Type := fun x => Le x L' + +theorem agree : Agree S label belowL' s₀ s₁ := fun + | lo, _ => rfl + | hi, v => nomatch (v : Le H' L') + +theorem refutes : TINI S label belowL' → False := fun tini => + nomatch (tini 1 1 typed agree rfl rfl lo Le.ML : 0 = 1) + +end NoTrans + +end SecretTypes.Countermodels diff --git a/verification/legs/lean/SecretTypes/Generic.lean b/verification/legs/lean/SecretTypes/Generic.lean new file mode 100644 index 0000000..d2e078f --- /dev/null +++ b/verification/legs/lean/SecretTypes/Generic.lean @@ -0,0 +1,411 @@ +/- +SPDX-License-Identifier: MPL-2.0 +SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell + +Lean leg (PROOF-POLICY §2, route `re-proved`) of secret-types N-2, N-3 and N-7: +termination-insensitive noninterference over any lattice record and any +down-closed observer. The statements mirror, line for line: + src/SecretTypes/Vars.agda, Lang.agda, Lattice/Record.agda, + Typing/Generic.agda, Noninterference/Statement.agda, Noninterference/Generic.agda. +Lean core only: no Mathlib, no Std, no classical axioms +(see the `#print axioms` lines at the end of this file). + +Universe discipline: Agda's `Set` is Lean's `Type`. The order, an +observer's `sees` and the typing derivations are `Type`-valued here, as in +Agda, so a proof may not use proof irrelevance that the Agda statement does +not grant. Equality of naturals is `Prop` in Lean and a set in Agda; +naturals have decidable equality, so the two readings coincide. +-/ +namespace SecretTypes + +/-- `SecretTypes.Vars`: variables with decidable equality. -/ +structure Vars where + Var : Type + deq : DecidableEq Var + +/-- What `SecretTypes.Noninterference.Statement` and `Typing.Generic` are +parameterised by: a carrier, an order, a join and the label of a literal, with +no laws. The countermodels (N-8) instantiate the statement at signatures +that lack a law, so the statement cannot ask for one. -/ +structure Signature where + Carrier : Type + le : Carrier → Carrier → Type + join : Carrier → Carrier → Carrier + bot : Carrier + +/-- `SecretTypes.Lattice.Record.Lattice`: a signature with the three laws +generic TINI uses (the certified census, N-6). -/ +structure Lattice extends Signature where + le_trans : ∀ {x y z : Carrier}, le x y → le y z → le x z + upper₁ : ∀ x y : Carrier, le x (join x y) + upper₂ : ∀ x y : Carrier, le y (join x y) + +/-- `SecretTypes.Lattice.Record.Observer`: a down-closed set of labels. -/ +structure Observer (L : Lattice) where + sees : L.Carrier → Type + down : ∀ {x y : L.Carrier}, L.le x y → sees y → sees x + +/-- `principal`: the observer at level `o` sees exactly the labels below it. -/ +def principal (L : Lattice) (o : L.Carrier) : Observer L where + sees x := L.le x o + down h v := L.le_trans h v + +section Lang +variable (V : Vars) + +/-- `Store = Var → Nat`. -/ +abbrev Store := V.Var → Nat + +inductive Expr where + | lit : Nat → Expr + | var : V.Var → Expr + | plus : Expr → Expr → Expr + +inductive Cmd where + | skip : Cmd + | assign : V.Var → Expr V → Cmd + | seq : Cmd → Cmd → Cmd + | ifz : Expr V → Cmd → Cmd → Cmd + | «while» : Expr V → Cmd → Cmd + +variable {V} + +def eval (s : Store V) : Expr V → Nat + | .lit n => n + | .var x => s x + | .plus a b => eval s a + eval s b + +/-- `update x n s y = select (x ≟ᵥ y) n (s y)`. -/ +def update (x : V.Var) (n : Nat) (s : Store V) : Store V := fun y => + match V.deq x y with + | .isTrue _ => n + | .isFalse _ => s y + +/-- `bind` of `SecretTypes.Prelude`. -/ +def bind {A : Type} : Option A → (A → Option A) → Option A + | none, _ => none + | some a, f => f a + +/-- One step of fuel: run a command, using `prev` (the run at one less fuel) +to re-enter a loop. `none` is fuel zero, where a loop does not finish. +Structural in the command, so `exec` below is structural in both fuel and +command and its equations hold by `rfl` (no well-founded recursion, hence no +`propext` or `funext` in the proofs). -/ +def run (prev : Option (Cmd V → Store V → Option (Store V))) : + Cmd V → Store V → Option (Store V) + | .skip, s => some s + | .assign x e, s => some (update x (eval s e) s) + | .seq c₁ c₂, s => bind (run prev c₁ s) (run prev c₂) + | .ifz e c₁ c₂, s => + match eval s e with + | 0 => run prev c₁ s + | _ + 1 => run prev c₂ s + | .«while» e c, s => + match prev with + | none => none + | some p => + match eval s e with + | 0 => bind (p c s) (p (.«while» e c)) + | _ + 1 => some s + +/-- `exec` of `SecretTypes.Lang`: `exec fuel c s` is `some t` if `c` finishes +within `fuel`, else `none`. Equal to Agda's `exec`, `execIf` and +`execWhile` clause by clause: sequence and `ifz` keep the fuel, a loop at +`fuel + 1` runs its body and re-enters at `fuel`, a loop at `0` is `none`. -/ +def exec : Nat → Cmd V → Store V → Option (Store V) + | 0 => run none + | f + 1 => run (some (exec f)) + +end Lang + +section Typing +variable {V : Vars} (L : Signature) (label : V.Var → L.Carrier) + +/-- `lab` of `SecretTypes.Typing.Generic`. -/ +def lab : Expr V → L.Carrier + | .lit _ => L.bot + | .var x => label x + | .plus a b => L.join (lab a) (lab b) + +/-- `_⊢_` of `SecretTypes.Typing.Generic`: Volpano–Smith typing with a +program-counter label. -/ +inductive Typed : L.Carrier → Cmd V → Type where + | skip {pc} : Typed pc .skip + | assign {pc x e} : L.le (L.join pc (lab L label e)) (label x) → Typed pc (.assign x e) + | seq {pc c₁ c₂} : Typed pc c₁ → Typed pc c₂ → Typed pc (.seq c₁ c₂) + | ifz {pc e c₁ c₂} : Typed (L.join pc (lab L label e)) c₁ → + Typed (L.join pc (lab L label e)) c₂ → Typed pc (.ifz e c₁ c₂) + | «while» {pc e c} : Typed (L.join pc (lab L label e)) c → Typed pc (.«while» e c) + +/-- `_≈[_]_` of `SecretTypes.Noninterference.Statement`. -/ +def Agree (sees : L.Carrier → Type) (s s' : Store V) : Prop := + ∀ x, sees (label x) → s x = s' x + +/-- `TINI` of `SecretTypes.Noninterference.Statement`. -/ +def TINI (sees : L.Carrier → Type) : Prop := + ∀ {pc : L.Carrier} {c : Cmd V} (fuel fuel' : Nat) {s s' t t' : Store V}, + Typed L label pc c → Agree L label sees s s' → + exec fuel c s = some t → exec fuel' c s' = some t' → Agree L label sees t t' + +end Typing + +section Proof +variable {V : Vars} {L : Lattice} {label : V.Var → L.Carrier} (O : Observer L) + +/-! Unfolding equations for `exec`, one per command form. -/ + +/-- The branch an `ifz` takes on guard value `n`. -/ +def branch (n : Nat) (c₁ c₂ : Cmd V) : Cmd V := + match n with + | 0 => c₁ + | _ + 1 => c₂ + +theorem exec_skip (f : Nat) (s : Store V) : exec f .skip s = some s := by + cases f <;> rfl + +theorem exec_assign (f : Nat) (x : V.Var) (e : Expr V) (s : Store V) : + exec f (.assign x e) s = some (update x (eval s e) s) := by + cases f <;> rfl + +theorem exec_seq (f : Nat) (c₁ c₂ : Cmd V) (s : Store V) : + exec f (.seq c₁ c₂) s = bind (exec f c₁ s) (exec f c₂) := by + cases f <;> rfl + +theorem exec_ifz (f : Nat) (e : Expr V) (c₁ c₂ : Cmd V) (s : Store V) : + exec f (.ifz e c₁ c₂) s = exec f (branch (eval s e) c₁ c₂) s := by + cases f <;> (show run _ _ _ = run _ _ _; simp only [run]; cases eval s e <;> rfl) + +theorem exec_while_zero (e : Expr V) (c : Cmd V) (s : Store V) : + exec 0 (.«while» e c) s = none := rfl + +theorem exec_while_succ (f : Nat) (e : Expr V) (c : Cmd V) (s : Store V) : + exec (f + 1) (.«while» e c) s = + (match eval s e with + | 0 => bind (exec f c s) (exec f (.«while» e c)) + | _ + 1 => some s) := rfl + +theorem bind_some {A : Type} {m : Option A} {f : A → Option A} {t : A} : + bind m f = some t → ∃ a, m = some a ∧ f a = some t := by + cases m with + | none => intro h; cases h + | some a => intro h; exact ⟨a, rfl, h⟩ + +/-- A branch typed under a counter is typed under it, whichever is taken. -/ +def branch_typed {pc : L.Carrier} (n : Nat) {c₁ c₂ : Cmd V} + (d₁ : Typed L.toSignature label pc c₁) (d₂ : Typed L.toSignature label pc c₂) : + Typed L.toSignature label pc (branch n c₁ c₂) := + match n with + | 0 => d₁ + | _ + 1 => d₂ + +/-- `unseen-⊔`. -/ +theorem unseen_join {pc : L.Carrier} (y : L.Carrier) (np : O.sees pc → False) : + O.sees (L.join pc y) → False := + fun v => np (O.down (L.upper₁ pc y) v) + +/-- N-2, `eval-low`: an expression whose label the observer sees reads the +same in agreeing stores. -/ +theorem eval_low : ∀ (e : Expr V) {s s' : Store V}, O.sees (lab L.toSignature label e) → + Agree L.toSignature label O.sees s s' → eval s e = eval s' e + | .lit _, _, _, _, _ => rfl + | .var x, _, _, v, eq => eq x v + | .plus a b, _, _, v, eq => by + show eval _ a + eval _ b = eval _ a + eval _ b + rw [eval_low a (O.down (L.upper₁ _ _) v) eq, eval_low b (O.down (L.upper₂ _ _) v) eq] + +theorem agree_refl {s : Store V} : Agree L.toSignature label O.sees s s := fun _ _ => rfl + +theorem agree_symm {s s' : Store V} (h : Agree L.toSignature label O.sees s s') : + Agree L.toSignature label O.sees s' s := fun x v => (h x v).symm + +theorem agree_trans {s s' s'' : Store V} (h : Agree L.toSignature label O.sees s s') + (k : Agree L.toSignature label O.sees s' s'') : Agree L.toSignature label O.sees s s'' := + fun x v => (h x v).trans (k x v) + +/-- `confine-assign`: an assignment allowed under an unseen counter leaves +every seen variable alone. -/ +theorem confine_assign {pc : L.Carrier} (x : V.Var) (e : Expr V) (n : Nat) (s : Store V) + (np : O.sees pc → False) (ok : L.le (L.join pc (lab L.toSignature label e)) (label x)) : + Agree L.toSignature label O.sees (update x n s) s := by + intro y v + show (match V.deq x y with | .isTrue _ => n | .isFalse _ => s y) = s y + cases V.deq x y with + | isTrue hxy => subst hxy; exact (unseen_join O (lab L.toSignature label e) np (O.down ok v)).elim + | isFalse _ => rfl + +/-- N-3, `confine`: under a program counter the observer cannot see, a +finishing run changes nothing the observer sees. -/ +theorem confine : ∀ (fuel : Nat) {pc : L.Carrier} {c : Cmd V} {s t : Store V}, + (O.sees pc → False) → Typed L.toSignature label pc c → exec fuel c s = some t → + Agree L.toSignature label O.sees t s := by + intro fuel + induction fuel with + | zero => + intro pc c s t np d + induction d generalizing s t with + | skip => intro h; rw [exec_skip] at h; cases h; exact agree_refl O + | @assign pc x e ok => + intro h; rw [exec_assign] at h; cases h; exact confine_assign O x e _ s np ok + | seq d₁ d₂ ih₁ ih₂ => + intro h; rw [exec_seq] at h + obtain ⟨s₁, h₁, h₂⟩ := bind_some h + exact agree_trans O (ih₂ np h₂) (ih₁ np h₁) + | @ifz pc e c₁ c₂ d₁ d₂ ih₁ ih₂ => + intro h; rw [exec_ifz] at h + have np' := unseen_join O (lab L.toSignature label e) np + cases hv : eval s e with + | zero => rw [hv] at h; exact ih₁ np' h + | succ _ => rw [hv] at h; exact ih₂ np' h + | «while» d ih => intro h; rw [exec_while_zero] at h; cases h + | succ f ihf => + intro pc c s t np d + induction d generalizing s t with + | skip => intro h; rw [exec_skip] at h; cases h; exact agree_refl O + | @assign pc x e ok => + intro h; rw [exec_assign] at h; cases h; exact confine_assign O x e _ s np ok + | seq d₁ d₂ ih₁ ih₂ => + intro h; rw [exec_seq] at h + obtain ⟨s₁, h₁, h₂⟩ := bind_some h + exact agree_trans O (ih₂ np h₂) (ih₁ np h₁) + | @ifz pc e c₁ c₂ d₁ d₂ ih₁ ih₂ => + intro h; rw [exec_ifz] at h + have np' := unseen_join O (lab L.toSignature label e) np + cases hv : eval s e with + | zero => rw [hv] at h; exact ih₁ np' h + | succ _ => rw [hv] at h; exact ih₂ np' h + | @«while» pc e c d ih => + intro h; rw [exec_while_succ] at h + have np' := unseen_join O (lab L.toSignature label e) np + cases hv : eval s e with + | zero => + rw [hv] at h + obtain ⟨s₁, h₁, h₂⟩ := bind_some h + exact agree_trans O (ihf np (Typed.«while» d) h₂) (ihf np' d h₁) + | succ _ => rw [hv] at h; cases h; exact agree_refl O + +/-- `confineL`: re-entering a loop whose body is typed under an unseen +counter changes nothing the observer sees. The loop is not re-typed: `ℓ` is +the body's counter, which need not type the loop itself. -/ +theorem confine_loop : ∀ (fuel : Nat) {ℓ : L.Carrier} {e : Expr V} {c : Cmd V} + {s t : Store V}, (O.sees ℓ → False) → Typed L.toSignature label ℓ c → + exec fuel (.«while» e c) s = some t → Agree L.toSignature label O.sees t s := by + intro fuel + induction fuel with + | zero => intro ℓ e c s t _ _ h; rw [exec_while_zero] at h; cases h + | succ f ihf => + intro ℓ e c s t np d h + rw [exec_while_succ] at h + cases hv : eval s e with + | zero => + rw [hv] at h + obtain ⟨s₁, h₁, h₂⟩ := bind_some h + exact agree_trans O (ihf np d h₂) (confine O f np d h₁) + | succ _ => rw [hv] at h; cases h; exact agree_refl O + +/-- Agreement is a family of equations between naturals, hence stable under +double negation: what lets the proof split on whether the observer sees a +branch label without deciding it (as the Agda proof does). -/ +theorem agree_stable {s s' : Store V} + (h : (Agree L.toSignature label O.sees s s' → False) → False) : Agree L.toSignature label O.sees s s' := + fun x v => Decidable.byContradiction fun ne => h fun a => ne (a x v) + +/-- `¬¬-dec`: excluded middle under double negation, for any type. -/ +theorem nn_em (P : Type) (k : PSum P (P → False) → False) : False := + k (.inr fun p => k (.inl p)) + +/-- Either the observer sees label `j` or it does not, under double +negation: the shape the `ifz` and `while` cases use. -/ +theorem by_cases_sees {s s' : Store V} (j : L.Carrier) + (yes : O.sees j → Agree L.toSignature label O.sees s s') + (no : (O.sees j → False) → Agree L.toSignature label O.sees s s') : + Agree L.toSignature label O.sees s s' := + agree_stable O fun bad => nn_em (O.sees j) fun + | .inl v => bad (yes v) + | .inr nv => bad (no nv) + +/-- N-7, `tini`: termination-insensitive noninterference for every +down-closed observer of every lattice record. -/ +theorem tini : TINI L.toSignature label O.sees := by + intro pc c fuel + induction fuel generalizing pc c with + | zero => + intro fuel' s s' t t' d + induction d generalizing s s' t t' fuel' with + | skip => + intro eq h h'; rw [exec_skip] at h h'; cases h; cases h'; exact eq + | @assign pc x e ok => + intro eq h h'; rw [exec_assign] at h h'; cases h; cases h' + intro y v + show (match V.deq x y with | .isTrue _ => eval s e | .isFalse _ => s y) = + (match V.deq x y with | .isTrue _ => eval s' e | .isFalse _ => s' y) + cases V.deq x y with + | isTrue hxy => subst hxy; exact eval_low O e (O.down (L.upper₂ _ _) (O.down ok v)) eq + | isFalse _ => exact eq y v + | seq d₁ d₂ ih₁ ih₂ => + intro eq h h'; rw [exec_seq] at h h' + obtain ⟨s₁, h₁, h₂⟩ := bind_some h + obtain ⟨s₁', h₁', h₂'⟩ := bind_some h' + exact ih₂ fuel' (ih₁ fuel' eq h₁ h₁') h₂ h₂' + | @ifz pc e c₁ c₂ d₁ d₂ ih₁ ih₂ => + intro eq h h'; rw [exec_ifz] at h h' + refine by_cases_sees O (L.join pc (lab L.toSignature label e)) (fun v => ?_) (fun nv => ?_) + · have ge := eval_low O e (O.down (L.upper₂ _ _) v) eq + rw [← ge] at h' + cases hv : eval s e with + | zero => rw [hv] at h h'; exact ih₁ fuel' eq h h' + | succ _ => rw [hv] at h h'; exact ih₂ fuel' eq h h' + · exact agree_trans O + (confine O _ nv (branch_typed _ d₁ d₂) h) + (agree_trans O eq (agree_symm O (confine O _ nv (branch_typed _ d₁ d₂) h'))) + | «while» d ih => intro eq h; rw [exec_while_zero] at h; cases h + | succ f ihf => + intro fuel' s s' t t' d + induction d generalizing s s' t t' fuel' with + | skip => + intro eq h h'; rw [exec_skip] at h h'; cases h; cases h'; exact eq + | @assign pc x e ok => + intro eq h h'; rw [exec_assign] at h h'; cases h; cases h' + intro y v + show (match V.deq x y with | .isTrue _ => eval s e | .isFalse _ => s y) = + (match V.deq x y with | .isTrue _ => eval s' e | .isFalse _ => s' y) + cases V.deq x y with + | isTrue hxy => subst hxy; exact eval_low O e (O.down (L.upper₂ _ _) (O.down ok v)) eq + | isFalse _ => exact eq y v + | seq d₁ d₂ ih₁ ih₂ => + intro eq h h'; rw [exec_seq] at h h' + obtain ⟨s₁, h₁, h₂⟩ := bind_some h + obtain ⟨s₁', h₁', h₂'⟩ := bind_some h' + exact ih₂ fuel' (ih₁ fuel' eq h₁ h₁') h₂ h₂' + | @ifz pc e c₁ c₂ d₁ d₂ ih₁ ih₂ => + intro eq h h'; rw [exec_ifz] at h h' + refine by_cases_sees O (L.join pc (lab L.toSignature label e)) (fun v => ?_) (fun nv => ?_) + · have ge := eval_low O e (O.down (L.upper₂ _ _) v) eq + rw [← ge] at h' + cases hv : eval s e with + | zero => rw [hv] at h h'; exact ih₁ fuel' eq h h' + | succ _ => rw [hv] at h h'; exact ih₂ fuel' eq h h' + · exact agree_trans O + (confine O _ nv (branch_typed _ d₁ d₂) h) + (agree_trans O eq (agree_symm O (confine O _ nv (branch_typed _ d₁ d₂) h'))) + | @«while» pc e c d ih => + intro eq h h' + cases fuel' with + | zero => rw [exec_while_zero] at h'; cases h' + | succ f' => + rw [exec_while_succ] at h h' + refine by_cases_sees O (L.join pc (lab L.toSignature label e)) (fun v => ?_) (fun nv => ?_) + · have ge := eval_low O e (O.down (L.upper₂ _ _) v) eq + rw [← ge] at h' + cases hv : eval s e with + | zero => + rw [hv] at h h' + obtain ⟨s₁, h₁, h₂⟩ := bind_some h + obtain ⟨s₁', h₁', h₂'⟩ := bind_some h' + exact ihf f' (Typed.«while» d) (ihf f' d eq h₁ h₁') h₂ h₂' + | succ _ => rw [hv] at h h'; cases h; cases h'; exact eq + · rw [← exec_while_succ] at h h' + exact agree_trans O (confine_loop O _ nv d h) + (agree_trans O eq (agree_symm O (confine_loop O _ nv d h'))) + +end Proof +end SecretTypes diff --git a/verification/legs/lean/SecretTypes/Two.lean b/verification/legs/lean/SecretTypes/Two.lean new file mode 100644 index 0000000..ef8c410 --- /dev/null +++ b/verification/legs/lean/SecretTypes/Two.lean @@ -0,0 +1,158 @@ +/- +SPDX-License-Identifier: MPL-2.0 +SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell + +Lean leg (PROOF-POLICY §2, route `re-proved`) of secret-types N-1, N-2, N-3 +at the two-point lattice and N-9. Mirrors src/SecretTypes/Lattice.agda, +Lang/Two.agda, Typing.agda, Noninterference.agda, Witness.agda and +Noninterference/OutcomeVisibility.agda. Lean core only, no axioms. +-/ +import SecretTypes.Generic + +namespace SecretTypes.Two +open SecretTypes + +/-- `Label` of `SecretTypes.Lattice`: `L` public, `H` secret. -/ +inductive Label where + | L + | H +open Label + +/-- `_⊑_` of `SecretTypes.Lattice`: a computed predicate, as there. -/ +def le : Label → Label → Type + | L, _ => Unit + | H, L => Empty + | H, H => Unit + +def join : Label → Label → Label + | L, y => y + | H, _ => H + +def le_trans : ∀ {x y z : Label}, le x y → le y z → le x z + | L, _, _, _, _ => () + | H, H, H, _, _ => () + | H, H, L, _, k => k + | H, L, _, h, _ => nomatch h + +def upper₁ : ∀ x y : Label, le x (join x y) + | L, _ => () + | H, _ => () + +def upper₂ : ∀ x y : Label, le y (join x y) + | L, L => () + | L, H => () + | H, L => () + | H, H => () + +/-- `two-point` of `SecretTypes.Lattice`. -/ +def twoPoint : Lattice where + Carrier := Label + le := le + join := join + bot := L + le_trans := le_trans + upper₁ := upper₁ + upper₂ := upper₂ + +/-- `Var` of `SecretTypes.Lang.Two`. -/ +inductive Var where + | lo + | hi +deriving DecidableEq +open Var + +/-- `two : Vars`. -/ +def two : Vars where + Var := Var + deq := inferInstance + +/-- `label` of `SecretTypes.Typing`. -/ +def label : two.Var → twoPoint.Carrier + | lo => L + | hi => H + +/-- `⟨_,_⟩`, `low` and `_≈_` of `SecretTypes.Lang.Two`. -/ +def pair (l h : Nat) : Store two + | lo => l + | hi => h + +def low (s : Store two) : Nat := s lo + +def LowEq (s s' : Store two) : Prop := low s = low s' + +/-- `public-observer = principal two-point L`. -/ +def publicObserver : Observer twoPoint := principal twoPoint L + +theorem lowEq_agree {s s' : Store two} (eq : LowEq s s') : + Agree twoPoint.toSignature label publicObserver.sees s s' := fun + | lo, _ => eq + | hi, v => Empty.elim (show Empty from v) + +theorem agree_lowEq {s s' : Store two} + (f : Agree twoPoint.toSignature label publicObserver.sees s s') : LowEq s s' := + f lo () + +/-- N-2 at the two-point lattice: a public expression reads the same in +low-equivalent stores. -/ +theorem eval_low_two (e : Expr two) {s s' : Store two} + (p : lab twoPoint.toSignature label e = L) (eq : LowEq s s') : eval s e = eval s' e := + eval_low publicObserver e (p ▸ (() : le L L)) (lowEq_agree eq) + +/-- N-3 at the two-point lattice: under a secret counter the public variable +is unchanged, loops included. -/ +theorem confine_two {c : Cmd two} (fuel : Nat) {s t : Store two} + (d : Typed twoPoint.toSignature label H c) (h : exec fuel c s = some t) : low t = low s := + confine publicObserver fuel (pc := H) (fun v => Empty.elim (show Empty from v)) d h lo () + +/-- N-1, `tini` of `SecretTypes.Noninterference`: TINI at the two-point +lattice, fuels independent. -/ +theorem tini_two {pc : Label} {c : Cmd two} (fuel fuel' : Nat) {s s' t t' : Store two} + (d : Typed twoPoint.toSignature label pc c) (eq : LowEq s s') + (h : exec fuel c s = some t) (h' : exec fuel' c s' = some t') : LowEq t t' := + agree_lowEq (tini publicObserver fuel fuel' d (lowEq_agree eq) h h') + +/-! N-9, `SecretTypes.Noninterference.OutcomeVisibility`. -/ + +/-- `secret-loop = while (var hi) skip` and its typing at `L`. -/ +def secretLoop : Cmd two := .«while» (.var hi) .skip + +def secretLoopTyped : Typed twoPoint.toSignature label L secretLoop := .«while» .skip + +def s₀ : Store two := pair 0 0 +def s₁ : Store two := pair 0 1 + +theorem lowEquivalent : LowEq s₀ s₁ := rfl + +/-- `publicOutcome`: whether a run finished, and if so its public result. -/ +def publicOutcome (fuel : Nat) (c : Cmd two) (s : Store two) : Option Nat := + match exec fuel c s with + | none => none + | some t => some (low t) + +theorem secretLoop_never_finishes : ∀ fuel, exec fuel secretLoop s₀ = none + | 0 => rfl + | f + 1 => by + show bind (exec f .skip s₀) (exec f secretLoop) = none + rw [exec_skip] + exact secretLoop_never_finishes f + +theorem secretLoop_stops (fuel : Nat) : exec (fuel + 1) secretLoop s₁ = some s₁ := rfl + +theorem outcome_spins : publicOutcome 3 secretLoop s₀ = none := rfl +theorem outcome_stops : publicOutcome 3 secretLoop s₁ = some 0 := rfl + +theorem outcome_different : publicOutcome 3 secretLoop s₀ ≠ publicOutcome 3 secretLoop s₁ := + fun h => nomatch h + +/-- `TSNI`: the stronger two-run statement, with termination observable. -/ +def TSNI : Prop := + ∀ {pc : Label} {c : Cmd two} (fuel : Nat) {s s' : Store two}, + Typed twoPoint.toSignature label pc c → LowEq s s' → + publicOutcome fuel c s = publicOutcome fuel c s' + +/-- N-9, `termination-sensitive-fails`: TSNI is false for the current +typing rules. -/ +theorem termination_sensitive_fails : TSNI → False := + fun h => outcome_different (h 3 secretLoopTyped lowEquivalent) + +end SecretTypes.Two diff --git a/verification/legs/lean/lakefile.toml b/verification/legs/lean/lakefile.toml new file mode 100644 index 0000000..51a6b71 --- /dev/null +++ b/verification/legs/lean/lakefile.toml @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: MPL-2.0 +# The Lean recheck leg of secret-types (PROOF-POLICY §2). Lean core only: +# no Mathlib and no other dependency (no `require` entries). +name = "secret-types-lean-leg" +defaultTargets = ["SecretTypes"] + +[[lean_lib]] +name = "SecretTypes" diff --git a/verification/legs/lean/lean-toolchain b/verification/legs/lean/lean-toolchain new file mode 100644 index 0000000..8e51ca6 --- /dev/null +++ b/verification/legs/lean/lean-toolchain @@ -0,0 +1 @@ +lean-4.24.0 diff --git a/verification/legs/rocq/.gitignore b/verification/legs/rocq/.gitignore new file mode 100644 index 0000000..683abf2 --- /dev/null +++ b/verification/legs/rocq/.gitignore @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: MPL-2.0 +*.vo +*.vok +*.vos +*.glob +.*.aux +.lia.cache diff --git a/verification/legs/rocq/SecretTypes.v b/verification/legs/rocq/SecretTypes.v new file mode 100644 index 0000000..2374e2c --- /dev/null +++ b/verification/legs/rocq/SecretTypes.v @@ -0,0 +1,535 @@ +(* SPDX-License-Identifier: MPL-2.0 + SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell + + Rocq leg (PROOF-POLICY §2, route `re-proved`) of secret-types + N-1, N-2, N-3, N-7, N-8 and N-9. Mirrors src/SecretTypes/Vars.agda, + Lang.agda, Lattice/Record.agda, Typing/Generic.agda, + Noninterference/Statement.agda, Noninterference/Generic.agda, + Lattice.agda, Lang/Two.agda, Noninterference.agda, Countermodels.agda and + Noninterference/OutcomeVisibility.agda. + + Prelude only: no `Require` of the standard library, the counterpart of the + Agda gate's --no-libraries. No axioms: the `Print Assumptions` commands + at the end must report "Closed under the global context". + + Universe discipline: Agda's `Set` is `Type` here. The order, an + observer's `sees` and the typing derivations are `Type`-valued, as in + Agda. Equality of naturals is `Prop` here and a set in Agda; naturals + have decidable equality, so the two readings coincide. *) + +(* ---------------------------------------------------------------------- *) +(* SecretTypes.Vars and SecretTypes.Lattice.Record *) + +Record Vars := { Var : Type; deq : forall x y : Var, {x = y} + {x <> y} }. + +(* What Statement and Typing.Generic are parameterised by: no laws, so the + countermodels (N-8) can instantiate the statement where a law fails. *) +Record Signature := { + Carrier : Type; + le : Carrier -> Carrier -> Type; + join : Carrier -> Carrier -> Carrier; + bot : Carrier +}. + +Record Lattice := { + sig :> Signature; + le_trans : forall x y z, le sig x y -> le sig y z -> le sig x z; + upper1 : forall x y, le sig x (join sig x y); + upper2 : forall x y, le sig y (join sig x y) +}. + +Record Observer (L : Lattice) := { + sees : Carrier L -> Type; + down : forall x y, le L x y -> sees y -> sees x +}. +Arguments sees {L} o _. +Arguments down {L} o {x y} _ _. + +Definition principal (L : Lattice) (o : Carrier L) : Observer L := + {| sees := fun x => le L x o; + down := fun x y h v => le_trans L x y o h v |}. + +(* ---------------------------------------------------------------------- *) +(* SecretTypes.Lang *) + +Section Lang. +Variable V : Vars. + +Definition Store := Var V -> nat. + +Inductive Expr := lit (n : nat) | var (x : Var V) | plus (a b : Expr). + +Inductive Cmd := +| skip +| assign (x : Var V) (e : Expr) +| seq (c1 c2 : Cmd) +| ifz (e : Expr) (c1 c2 : Cmd) +| while (e : Expr) (c : Cmd). + +Fixpoint eval (s : Store) (e : Expr) : nat := + match e with + | lit n => n + | var x => s x + | plus a b => eval s a + eval s b + end. + +(* update x n s y = select (x ≟ᵥ y) n (s y) *) +Definition update (x : Var V) (n : nat) (s : Store) : Store := + fun y => if deq V x y then n else s y. + +Definition bind {A : Type} (m : option A) (f : A -> option A) : option A := + match m with None => None | Some a => f a end. + +(* One step of fuel, structural in the command; `prev` is the run at one + less fuel, None at fuel zero (where a loop does not finish). *) +Fixpoint run (prev : option (Cmd -> Store -> option Store)) (c : Cmd) (s : Store) + : option Store := + match c with + | skip => Some s + | assign x e => Some (update x (eval s e) s) + | seq c1 c2 => bind (run prev c1 s) (run prev c2) + | ifz e c1 c2 => match eval s e with 0 => run prev c1 s | S _ => run prev c2 s end + | while e c => + match prev with + | None => None + | Some p => match eval s e with + | 0 => bind (p c s) (p (while e c)) + | S _ => Some s + end + end + end. + +(* exec of SecretTypes.Lang, clause by clause: sequence and ifz keep the + fuel; a loop at S f runs its body and re-enters at f; at 0 it is None. *) +Fixpoint exec (f : nat) : Cmd -> Store -> option Store := + match f with 0 => run None | S f' => run (Some (exec f')) end. + +End Lang. + +Arguments lit {V} n. +Arguments var {V} x. +Arguments plus {V} a b. +Arguments skip {V}. +Arguments assign {V} x e. +Arguments seq {V} c1 c2. +Arguments ifz {V} e c1 c2. +Arguments while {V} e c. +Arguments eval {V} s e. +Arguments update {V} x n s _. +Arguments exec {V} f _ _. + +(* ---------------------------------------------------------------------- *) +(* SecretTypes.Typing.Generic and Noninterference.Statement *) + +Section Typing. +Context {V : Vars} (S : Signature) (label : Var V -> Carrier S). + +Fixpoint lab (e : Expr V) : Carrier S := + match e with + | lit _ => bot S + | var x => label x + | plus a b => join S (lab a) (lab b) + end. + +Inductive Typed : Carrier S -> Cmd V -> Type := +| t_skip : forall pc, Typed pc skip +| t_assign : forall pc x e, le S (join S pc (lab e)) (label x) -> Typed pc (assign x e) +| t_seq : forall pc c1 c2, Typed pc c1 -> Typed pc c2 -> Typed pc (seq c1 c2) +| t_ifz : forall pc e c1 c2, Typed (join S pc (lab e)) c1 -> + Typed (join S pc (lab e)) c2 -> Typed pc (ifz e c1 c2) +| t_while : forall pc e c, Typed (join S pc (lab e)) c -> Typed pc (while e c). + +Definition Agree (sees : Carrier S -> Type) (s s' : Store V) : Prop := + forall x, sees (label x) -> s x = s' x. + +Definition TINI (sees : Carrier S -> Type) : Prop := + forall pc c fuel fuel' s s' t t', Typed pc c -> Agree sees s s' -> + exec fuel c s = Some t -> exec fuel' c s' = Some t' -> Agree sees t t'. + +End Typing. + +Arguments t_skip {V S label pc}. +Arguments t_assign {V S label pc x e} _. +Arguments t_seq {V S label pc c1 c2} _ _. +Arguments t_ifz {V S label pc e c1 c2} _ _. +Arguments t_while {V S label pc e c} _. + +(* ---------------------------------------------------------------------- *) +(* SecretTypes.Noninterference.Generic *) + +Section Proof. +Context {V : Vars} (L : Lattice) (label : Var V -> Carrier L) (O : Observer L). + +Notation agree := (Agree L label (sees O)). +Notation typed := (Typed L label). +Notation labe := (lab L label). + +Lemma bind_some {A : Type} (m : option A) (f : A -> option A) t : + bind m f = Some t -> {a : A & (m = Some a) * (f a = Some t)}%type. +Proof. destruct m as [a|]; simpl; intro h; [exists a; split; auto | discriminate]. Defined. + +Definition branch (n : nat) (c1 c2 : Cmd V) : Cmd V := + match n with 0 => c1 | S _ => c2 end. + +Lemma exec_skip f (s : Store V) : exec f skip s = Some s. +Proof. destruct f; reflexivity. Qed. + +Lemma exec_assign f x e (s : Store V) : exec f (assign x e) s = Some (update x (eval s e) s). +Proof. destruct f; reflexivity. Qed. + +Lemma exec_seq f (c1 c2 : Cmd V) (s : Store V) : exec f (seq c1 c2) s = bind (exec f c1 s) (exec f c2). +Proof. destruct f; reflexivity. Qed. + +Lemma exec_ifz f e (c1 c2 : Cmd V) (s : Store V) : exec f (ifz e c1 c2) s = exec f (branch (eval s e) c1 c2) s. +Proof. destruct f; simpl; destruct (eval s e); reflexivity. Qed. + +Lemma exec_while_succ f e (c : Cmd V) (s : Store V) : + exec (S f) (while e c) s = + match eval s e with 0 => bind (exec f c s) (exec f (while e c)) | S _ => Some s end. +Proof. reflexivity. Qed. + +Definition branch_typed pc n c1 c2 (d1 : typed pc c1) (d2 : typed pc c2) + : typed pc (branch n c1 c2) := + match n with 0 => d1 | S _ => d2 end. + +Lemma unseen_join pc y : (sees O pc -> False) -> sees O (join L pc y) -> False. +Proof. intros np v. exact (np (down O (upper1 L pc y) v)). Qed. + +(* N-2, eval-low *) +Lemma eval_low : forall (e : Expr V) (s s' : Store V), sees O (labe e) -> agree s s' -> eval s e = eval s' e. +Proof. + induction e as [n|x|a IHa b IHb]; intros s s' v eq; simpl. + - reflexivity. + - exact (eq x v). + - rewrite (IHa s s' (down O (upper1 L _ _) v) eq). + rewrite (IHb s s' (down O (upper2 L _ _) v) eq). reflexivity. +Qed. + +Lemma agree_refl (s : Store V) : agree s s. +Proof. intros x _; reflexivity. Qed. +Lemma agree_symm (s s' : Store V) : agree s s' -> agree s' s. +Proof. intros h x v; symmetry; exact (h x v). Qed. +Lemma agree_trans (s s' s'' : Store V) : agree s s' -> agree s' s'' -> agree s s''. +Proof. intros h k x v; rewrite (h x v); exact (k x v). Qed. + +Lemma confine_assign pc x e n (s : Store V) : + (sees O pc -> False) -> le L (join L pc (labe e)) (label x) -> agree (update x n s) s. +Proof. + intros np ok y v. unfold update. destruct (deq V x y) as [hxy|hxy]. + - subst y. destruct (unseen_join pc (labe e) np (down O ok v)). + - reflexivity. +Qed. + +(* N-3, confine *) +Lemma confine : forall fuel pc (c : Cmd V) (s t : Store V), + (sees O pc -> False) -> typed pc c -> exec fuel c s = Some t -> agree t s. +Proof. + induction fuel as [|f IHf]; intros pc c s t np d; revert s t np; + induction d as [pc|pc x e ok|pc c1 c2 d1 IH1 d2 IH2|pc e c1 c2 d1 IH1 d2 IH2|pc e c d IH]; + intros s t np h. + all: try (rewrite exec_skip in h; injection h as <-; apply agree_refl). + all: try (rewrite exec_assign in h; injection h as <-; exact (confine_assign pc x e _ s np ok)). + all: try (rewrite exec_seq in h; destruct (bind_some _ _ _ h) as [s1 [h1 h2]]; + exact (agree_trans _ _ _ (IH2 _ _ np h2) (IH1 _ _ np h1))). + all: try (rewrite exec_ifz in h; pose proof (unseen_join pc (labe e) np) as np'; + destruct (eval s e); [exact (IH1 _ _ np' h) | exact (IH2 _ _ np' h)]). + - discriminate h. + - rewrite exec_while_succ in h. pose proof (unseen_join pc (labe e) np) as np'. + destruct (eval s e). + + destruct (bind_some _ _ _ h) as [s1 [h1 h2]]. + exact (agree_trans _ _ _ (IHf _ _ _ _ np (t_while d) h2) (IHf _ _ _ _ np' d h1)). + + injection h as <-; apply agree_refl. +Qed. + +(* confineL: a loop whose body is typed under an unseen counter, re-entered + without re-typing the loop. *) +Lemma confine_loop : forall fuel l (e : Expr V) (c : Cmd V) (s t : Store V), + (sees O l -> False) -> typed l c -> exec fuel (while e c) s = Some t -> agree t s. +Proof. + induction fuel as [|f IHf]; intros l e c s t np d h. + - discriminate h. + - rewrite exec_while_succ in h. destruct (eval s e). + + destruct (bind_some _ _ _ h) as [s1 [h1 h2]]. + exact (agree_trans _ _ _ (IHf _ _ _ _ _ np d h2) (confine f _ _ _ _ np d h1)). + + injection h as <-; apply agree_refl. +Qed. + +Fixpoint nat_eq_dec (n m : nat) : {n = m} + {n <> m}. +Proof. decide equality. Defined. + +(* Agreement is a family of equations between naturals, hence stable under + double negation (as in the Agda proof). *) +Lemma agree_stable (s s' : Store V) : ((agree s s' -> False) -> False) -> agree s s'. +Proof. + intros h x v. destruct (nat_eq_dec (s x) (s' x)) as [e|ne]; [exact e|]. + destruct (h (fun a => ne (a x v))). +Qed. + +Lemma by_cases_sees (s s' : Store V) j : + (sees O j -> agree s s') -> ((sees O j -> False) -> agree s s') -> agree s s'. +Proof. + intros yes no. apply agree_stable. intro bad. + apply bad. apply no. intro v. apply bad. exact (yes v). +Qed. + +(* N-7, tini *) +Theorem tini : TINI L label (sees O). +Proof. + intros pc c fuel. revert pc c. + induction fuel as [|f IHf]; intros pc c fuel' s s' t t' d; revert fuel' s s' t t'; + induction d as [pc|pc x e ok|pc c1 c2 d1 IH1 d2 IH2|pc e c1 c2 d1 IH1 d2 IH2|pc e c d IH]; + intros fuel' s s' t t' eq h h'. + all: try (rewrite exec_skip in h, h'; injection h as <-; injection h' as <-; exact eq). + all: try (rewrite exec_assign in h, h'; injection h as <-; injection h' as <-; + intros y v; unfold update; destruct (deq V x y) as [hxy|hxy]; + [subst y; exact (eval_low e s s' (down O (upper2 L _ _) (down O ok v)) eq) + | exact (eq y v)]). + all: try (rewrite exec_seq in h, h'; + destruct (bind_some _ _ _ h) as [s1 [h1 h2]]; + destruct (bind_some _ _ _ h') as [s1' [h1' h2']]; + exact (IH2 _ _ _ _ _ (IH1 _ _ _ _ _ eq h1 h1') h2 h2')). + all: try (rewrite exec_ifz in h, h'; + apply (by_cases_sees _ _ (join L pc (labe e))); + [ intro v; pose proof (eval_low e s s' (down O (upper2 L _ _) v) eq) as ge; + rewrite <- ge in h'; destruct (eval s e); + [exact (IH1 _ _ _ _ _ eq h h') | exact (IH2 _ _ _ _ _ eq h h')] + | intro nv; + exact (agree_trans _ _ _ (confine _ _ _ _ _ nv (branch_typed _ _ _ _ d1 d2) h) + (agree_trans _ _ _ eq + (agree_symm _ _ (confine _ _ _ _ _ nv (branch_typed _ _ _ _ d1 d2) h')))) ]). + - discriminate h. + - destruct fuel' as [|f']; [discriminate h'|]. + apply (by_cases_sees _ _ (join L pc (labe e))). + + intro v. pose proof (eval_low e s s' (down O (upper2 L _ _) v) eq) as ge. + rewrite exec_while_succ in h, h'. rewrite <- ge in h'. destruct (eval s e). + * destruct (bind_some _ _ _ h) as [s1 [h1 h2]]. + destruct (bind_some _ _ _ h') as [s1' [h1' h2']]. + exact (IHf _ _ _ _ _ _ _ (t_while d) (IHf _ _ _ _ _ _ _ d eq h1 h1') h2 h2'). + * injection h as <-; injection h' as <-; exact eq. + + intro nv. + exact (agree_trans _ _ _ (confine_loop _ _ _ _ _ _ nv d h) + (agree_trans _ _ _ eq (agree_symm _ _ (confine_loop _ _ _ _ _ _ nv d h')))). +Qed. + +End Proof. + +(* ---------------------------------------------------------------------- *) +(* The two-point lattice (SecretTypes.Lattice, Lang.Two, Typing, *) +(* Noninterference): N-1, and N-2/N-3 at that lattice *) + +Module Two. + +Inductive Label := L | H. + +(* A computed predicate, as in SecretTypes.Lattice. *) +Definition le2 (x y : Label) : Type := + match x, y with L, _ => unit | H, L => Empty_set | H, H => unit end. + +Definition join2 (x y : Label) : Label := match x with L => y | H => H end. + +Definition le2_trans x y z : le2 x y -> le2 y z -> le2 x z := + match x, y, z with + | L, _, _ => fun _ _ => tt + | H, H, H => fun _ _ => tt + | H, H, L => fun _ k => k + | H, L, _ => fun h _ => match h with end + end. + +Definition up1 x y : le2 x (join2 x y) := match x with L => match y with L => tt | H => tt end | H => tt end. +Definition up2 x y : le2 y (join2 x y) := + match x, y with L, L => tt | L, H => tt | H, L => tt | H, H => tt end. + +Definition twoSig : Signature := {| Carrier := Label; le := le2; join := join2; bot := L |}. +Definition twoPoint : Lattice := {| sig := twoSig; le_trans := le2_trans; upper1 := up1; upper2 := up2 |}. + +Inductive TVar := lo | hi. +Definition tdeq (x y : TVar) : {x = y} + {x <> y}. +Proof. decide equality. Defined. +Definition two : Vars := {| Var := TVar; deq := tdeq |}. + +Definition label (x : Var two) : Carrier twoPoint := match x with lo => L | hi => H end. + +Definition pair (l h : nat) : Store two := fun x => match x with lo => l | hi => h end. +Definition low (s : Store two) : nat := s lo. +Definition LowEq (s s' : Store two) : Prop := low s = low s'. + +Definition publicObserver : Observer twoPoint := principal twoPoint L. + +Lemma lowEq_agree s s' : LowEq s s' -> Agree twoPoint label (sees publicObserver) s s'. +Proof. intros eq [|] v; [exact eq | destruct v]. Qed. + +Lemma agree_lowEq s s' : Agree twoPoint label (sees publicObserver) s s' -> LowEq s s'. +Proof. intro f. exact (f lo tt). Qed. + +(* N-2 at the two-point lattice *) +Lemma eval_low_two (e : Expr two) s s' : + lab twoPoint label e = L -> LowEq s s' -> eval s e = eval s' e. +Proof. + intros p eq. apply (eval_low twoPoint label publicObserver e s s'). + - change (le2 (lab twoPoint label e) L). rewrite p. exact tt. + - exact (lowEq_agree _ _ eq). +Qed. + +(* N-3 at the two-point lattice *) +Lemma confine_two (c : Cmd two) fuel s t : + Typed twoPoint label H c -> exec fuel c s = Some t -> low t = low s. +Proof. + intros d h. exact (confine twoPoint label publicObserver fuel H c s t + (fun v : Empty_set => match v with end) d h lo tt). +Qed. + +(* N-1, tini of SecretTypes.Noninterference *) +Theorem tini_two pc (c : Cmd two) fuel fuel' s s' t t' : + Typed twoPoint label pc c -> LowEq s s' -> + exec fuel c s = Some t -> exec fuel' c s' = Some t' -> LowEq t t'. +Proof. + intros d eq h h'. apply agree_lowEq. + exact (tini twoPoint label publicObserver pc c fuel fuel' s s' t t' d (lowEq_agree _ _ eq) h h'). +Qed. + +(* N-9, SecretTypes.Noninterference.OutcomeVisibility *) +Definition secretLoop : Cmd two := while (@var two hi) skip. +Definition secretLoopTyped : Typed twoPoint label L secretLoop := t_while t_skip. +Definition s0 : Store two := pair 0 0. +Definition s1 : Store two := pair 0 1. +Lemma lowEquivalent : LowEq s0 s1. +Proof. reflexivity. Qed. + +Definition publicOutcome fuel (c : Cmd two) s : option nat := + match exec fuel c s with None => None | Some t => Some (low t) end. + +Lemma secretLoop_never_finishes : forall fuel, exec fuel secretLoop s0 = None. +Proof. + induction fuel as [|f IH]; [reflexivity|]. + change (bind (exec f skip s0) (exec f secretLoop) = None). + rewrite exec_skip. exact IH. +Qed. + +Lemma secretLoop_stops fuel : exec (S fuel) secretLoop s1 = Some s1. +Proof. reflexivity. Qed. + +Lemma outcome_different : publicOutcome 3 secretLoop s0 <> publicOutcome 3 secretLoop s1. +Proof. discriminate. Qed. + +Definition TSNI : Prop := + forall pc (c : Cmd two) fuel s s', Typed twoPoint label pc c -> LowEq s s' -> + publicOutcome fuel c s = publicOutcome fuel c s'. + +Theorem termination_sensitive_fails : TSNI -> False. +Proof. intro h. exact (outcome_different (h L secretLoop 3 s0 s1 secretLoopTyped lowEquivalent)). Qed. + +End Two. + +(* ---------------------------------------------------------------------- *) +(* N-8, SecretTypes.Countermodels *) + +Module Countermodels. +Import Two. + +Definition std (x : Var two) : Label := match x with lo => L | hi => H end. +Definition withJoin (j : Label -> Label -> Label) : Signature := + {| Carrier := Label; le := le2; join := j; bot := L |}. +Definition belowL (x : Label) : Type := le2 x L. + +Lemma agree01 (S : Signature) (lb : Var two -> Carrier S) (sees : Carrier S -> Type) : + (sees (lb hi) -> False) -> Agree S lb sees s0 s1. +Proof. intros n [|] v; [reflexivity | destruct (n v)]. Qed. + +Module NoUpper1. + Definition joinR (x y : Label) : Label := y. + Definition S := withJoin joinR. + Lemma not_upper1 : (forall x y, le2 x (joinR x y)) -> False. + Proof. intro h. destruct (h H L). Qed. + Definition implicitFlow : Cmd two := ifz (@var two hi) (@assign two lo (lit 0)) (@assign two lo (lit 1)). + Definition typed : Typed S std L implicitFlow. + Proof. apply t_ifz; apply t_assign; exact tt. Defined. + Theorem refutes : TINI S std belowL -> False. + Proof. + intro tini. pose proof (tini L implicitFlow 1 1 s0 s1 _ _ typed + (agree01 S std belowL (fun v : Empty_set => match v with end)) eq_refl eq_refl lo tt) as e. + discriminate e. + Qed. +End NoUpper1. + +Module NoUpper2. + Definition joinL (x y : Label) : Label := x. + Definition S := withJoin joinL. + Lemma not_upper2 : (forall x y, le2 y (joinL x y)) -> False. + Proof. intro h. destruct (h L H). Qed. + Definition explicitFlow : Cmd two := @assign two lo (@var two hi). + Definition typed : Typed S std L explicitFlow. + Proof. apply t_assign; exact tt. Defined. + Theorem refutes : TINI S std belowL -> False. + Proof. + intro tini. pose proof (tini L explicitFlow 1 1 s0 s1 _ _ typed + (agree01 S std belowL (fun v : Empty_set => match v with end)) eq_refl eq_refl lo tt) as e. + discriminate e. + Qed. +End NoUpper2. + +Module NoDownClosure. + Definition seesH (x : Label) : Type := x = H. + Lemma not_down_closed : (forall x y, le2 x y -> seesH y -> seesH x) -> False. + Proof. intro h. discriminate (h L H tt eq_refl). Qed. + Definition S := withJoin join2. + Definition up : Cmd two := @assign two hi (@var two lo). + Definition typed : Typed S std L up. + Proof. apply t_assign; exact tt. Defined. + Definition t0 : Store two := pair 0 0. + Definition t1 : Store two := pair 1 0. + Lemma agree : Agree S std seesH t0 t1. + Proof. intros [|] v; [discriminate v | reflexivity]. Qed. + Theorem refutes : TINI S std seesH -> False. + Proof. + intro tini. pose proof (tini L up 1 1 t0 t1 _ _ typed agree eq_refl eq_refl hi eq_refl) as e. + discriminate e. + Qed. +End NoDownClosure. + +Module NoTrans. + Inductive P := B | H' | M | L' | T. + Inductive Le : P -> P -> Type := + | le_refl : forall x, Le x x + | le_bot : forall x, Le B x + | le_top : forall x, Le x T + | le_HM : Le H' M + | le_ML : Le M L'. + Definition joinP (x y : P) : P := + match x, y with + | B, y => y | T, _ => T + | H', B => H' | H', H' => H' | H', M => M | H', L' => T | H', T => T + | M, B => M | M, H' => M | M, M => M | M, L' => L' | M, T => T + | L', B => L' | L', H' => T | L', M => L' | L', L' => L' | L', T => T + end. + Lemma not_trans : (forall x y z, Le x y -> Le y z -> Le x z) -> False. + Proof. intro t. pose proof (t _ _ _ le_HM le_ML) as k. inversion k. Qed. + Definition S : Signature := {| Carrier := P; le := Le; join := joinP; bot := B |}. + Definition labelP (x : Var two) : P := match x with lo => M | hi => H' end. + Definition explicitFlow : Cmd two := @assign two lo (@var two hi). + Definition typed : Typed S labelP B explicitFlow. + Proof. apply t_assign; exact le_HM. Defined. + Definition belowL' (x : P) : Type := Le x L'. + Lemma agree : Agree S labelP belowL' s0 s1. + Proof. intros [|] v; [reflexivity | inversion v]. Qed. + Theorem refutes : TINI S labelP belowL' -> False. + Proof. + intro tini. pose proof (tini B explicitFlow 1 1 s0 s1 _ _ typed agree eq_refl eq_refl lo le_ML) as e. + discriminate e. + Qed. +End NoTrans. + +End Countermodels. + +(* Axioms each theorem rests on (PROOF-POLICY §8.4): every line must read + "Closed under the global context". *) +Print Assumptions eval_low. +Print Assumptions confine. +Print Assumptions tini. +Print Assumptions Two.eval_low_two. +Print Assumptions Two.confine_two. +Print Assumptions Two.tini_two. +Print Assumptions Two.termination_sensitive_fails. +Print Assumptions Countermodels.NoUpper1.refutes. +Print Assumptions Countermodels.NoUpper2.refutes. +Print Assumptions Countermodels.NoDownClosure.refutes. +Print Assumptions Countermodels.NoTrans.refutes. diff --git a/verification/proofs/README.adoc b/verification/proofs/README.adoc index 16005f9..b228c0f 100644 --- a/verification/proofs/README.adoc +++ b/verification/proofs/README.adoc @@ -43,6 +43,28 @@ bounds. For Rust, *Kani* (unsafe and FFI seams, reported `TESTED` with bounds) and *Aeneas* (Rust that implements an Agda-proved theorem) are standing exceptions that run alongside Creusot, never instead of it. +== Proof grades + +The only estate proof grade is the *Proof Witness Grade* (PWG) of policy §8. +It applies to theory theorems: + +* `PWG-3` means the Agda proof checks under the canonical invocation. +* `PWG-2i` adds one recheck leg (Rocq or Lean) that counts. +* `PWG-2ii` means both legs count, which discharges the policy's recheck + obligation. +* `PWG-1` adds a current receipt, per commit, from a pinned headless + checker for every witness. + +A leg counts only when it is kernel-checked and its statement +correspondence is `RECONCILED`. Grades are derived from receipts and the +correspondence records, never asserted. Record them in +`docs/status/PROOF-STATUS.adoc` beside the §5 status tokens, in a separate +field. + +Always write the `PWG-` prefix. A bare "Grade 2" collides with the CRG, +TRG, ARG and FRG letter grades. Publish counts as a census with their +denominator, never as a percentage. + == Third-party code Dependencies (crates, libraries, vendored or forked code) are not converted, diff --git a/verification/receipts/agda-pwg_praxis.deed b/verification/receipts/agda-pwg_praxis.deed deleted file mode 100644 index 6da221a..0000000 --- a/verification/receipts/agda-pwg_praxis.deed +++ /dev/null @@ -1,81 +0,0 @@ -;; SPDX-License-Identifier: CC-BY-SA-4.0 -;; SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) -;; GENERATED by scripts/grade-receipt.sh; regenerate with --write, check with --check. -;; This describes the gate and tree; the CI log is the execution evidence. -(praxis-deed - :schema-version "1.0.0" - :canonical-name "secret-types-agda-pwg-receipt" - (identity - :project "secret-types" - :name "agda verification receipt (Proof Witness Grades)" - :repository "hyperpolymath/secret-types" - :repo-uuid #u5"github.com/hyperpolymath/secret-types" - :uuid-source "uuid5(URL, github.com/hyperpolymath/secret-types)" - :uuid-note "F-1 remains open: template mint-path cause and local scaffolding sweep; see docs/reports/audit/consistency-audit-2026-11-01.adoc" - :kind "theory" - :source-commit "eee4e4038503") - (prover - :tool "agda" - :version "2.6.4.3" - :provenance "Debian 13 agda-bin=2.6.4.3-1+b2" - :library "none (--no-libraries; Agda builtins only)" - :flags "--safe --without-K --cubical-compatible --no-libraries --ignore-interfaces --double-check -W error" - :flags-source "scripts/proof-flags.sh" - :flags-note "One definition, read by the gate and by this receipt; the receipt cannot describe a gate that did not run") - (conformance - :policy "standards/3-practice/PROOF-POLICY.adoc" - :policy-revision "owner ruling 2026-10-26; section 2.1 dated 2026-11-01; section 8 (PWG) dated 2026-11-04" - :canonical-invocation "agda --safe --without-K --cubical-compatible" - :cubical-compatible "met" - :met-since "2026-11-09; W-1 closed, docs/proof-debt.adoc#waivers" - :stronger-than-canonical ("--no-libraries" "--ignore-interfaces" "--double-check" "-W error")) - (gate - :command "bash scripts/check.sh" - :laws-certificate "bash scripts/minimal-laws.sh" - :ci ".github/workflows/proofs.yml" - :module-count 15 - :reject-fixture-count 4) - (trust-base - :postulates 0 - :escape-hatches 0 - :library-dependencies 0 - :axioms-assumed 0 - :note "A non-zero count above fails the gate; the receipt is only meaningful while all four are zero") - ; PWGs apply to theory theorems (PROOF-POLICY section 8.1); refusal fixtures and leak witnesses are n/a. - ; Independent Rocq and Lean legs remain unavailable: no transport or hand re-proof. - (claims - (claim :id "N-1" :token "proven" :pwg "3" :coq-leg "unavailable" :lean-leg "unavailable") - (claim :id "N-2" :token "proven" :pwg "3" :coq-leg "unavailable" :lean-leg "unavailable") - (claim :id "N-3" :token "proven" :pwg "3" :coq-leg "unavailable" :lean-leg "unavailable") - (claim :id "N-4" :token "tested-only" :pwg "n/a" :coq-leg "n/a" :lean-leg "n/a") - (claim :id "N-5" :token "tested-only" :pwg "n/a" :coq-leg "n/a" :lean-leg "n/a") - (claim :id "N-6" :token "tested-only" :pwg "n/a" :coq-leg "n/a" :lean-leg "n/a") - (claim :id "N-7" :token "proven" :pwg "3" :coq-leg "unavailable" :lean-leg "unavailable") - (claim :id "N-8" :token "proven" :pwg "3" :coq-leg "unavailable" :lean-leg "unavailable") - (claim :id "N-9" :token "proven" :pwg "3" :coq-leg "unavailable" :lean-leg "unavailable") - ) - ; Every module hash is checked against the current tree. - (modules - (module :path "src/SecretTypes/All.agda" :sha256 "3538afeb43c6c55498ba95ed6918df7ca9474439c24b1ef64b6f4dc488bf892b") - (module :path "src/SecretTypes/Countermodels.agda" :sha256 "2f3c155a3e18e1282d4dc0f605aadadfda8e9bba24614626bc10cfbec2184f31") - (module :path "src/SecretTypes/Lang.agda" :sha256 "957333acdcc5c67692630411cd7a82e96075f07533b0d2cc7c99d000b1522570") - (module :path "src/SecretTypes/Lang/Two.agda" :sha256 "4bb9e6db64c7dfa36fd7ce3bfd5947331819ac4c3ba1d2fb599c46e07be53c9a") - (module :path "src/SecretTypes/Lattice.agda" :sha256 "119d637de4dd4578c0eb0cd3dbad679f4281db9fa4660833fdd74b8d220ab10e") - (module :path "src/SecretTypes/Lattice/Record.agda" :sha256 "25f4ee821b8e245dccb4f5f7eae9c3f1006a4b6eaeab3af14646721f14f83de3") - (module :path "src/SecretTypes/Noninterference.agda" :sha256 "944231de8ab05155f2aaa8da6a37edfa32123cac25c29ea5a2e4a2d71f68ab0e") - (module :path "src/SecretTypes/Noninterference/Generic.agda" :sha256 "7c86fcc801e256a2c4869c5ccc8302a93daa0a882e2493056e68b329ff79602a") - (module :path "src/SecretTypes/Noninterference/OutcomeVisibility.agda" :sha256 "33e63996ccb6ea5ba834dd158584b8a1fb5594d50e79c2ee2c144ffe7588d93a") - (module :path "src/SecretTypes/Noninterference/Statement.agda" :sha256 "f76d84eb3a434c2f9f4203303fcf30fb04282afe787ce085d4590855b43e4c45") - (module :path "src/SecretTypes/Prelude.agda" :sha256 "5fea408acc63e0c91a293463913ef8f167a722cc1665f7a7010b26c8520c4072") - (module :path "src/SecretTypes/Typing.agda" :sha256 "f535815d15f4b224a028b01261af8d7f233d8b3c0a26e48ec5c4223f3afe1d42") - (module :path "src/SecretTypes/Typing/Generic.agda" :sha256 "adacd5405be17ccbf845c56d2d7980d2b0eae7214a284df2bf5a64d64ccf7718") - (module :path "src/SecretTypes/Vars.agda" :sha256 "f188cbc8b630b036daac4720f3cfe492bf78671f87b82850919f09091ead5422") - (module :path "src/SecretTypes/Witness.agda" :sha256 "bc2166b18e68051ccfdb034f55d26a2249f9b27c953f664cdbfeacb1e35879b8") - ) - (fixtures - (fixture :path "tests/reject/ExplicitFlow.agda") - (fixture :path "tests/reject/ImplicitFlow.agda") - (fixture :path "tests/reject/TerminationSensitiveNI.agda") - (fixture :path "tests/reject/ThirdVariable.agda") - ) -) diff --git a/verification/receipts/agda_praxis.deed b/verification/receipts/agda_praxis.deed new file mode 100644 index 0000000..25e526b --- /dev/null +++ b/verification/receipts/agda_praxis.deed @@ -0,0 +1,116 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +;; SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +;; GENERATED by scripts/grade-receipt.sh; regenerate with --write, check with --check. +;; This describes the gate and tree; the CI log is the execution evidence. +(praxis-deed + :schema-version "1.0.0" + :canonical-name "secret-types-agda-receipt" + (identity + :project "secret-types" + :name "Agda verification receipt with Proof Witness Grades" + :repository "hyperpolymath/secret-types" + :repo-uuid #u5"github.com/hyperpolymath/secret-types" + :uuid-source "uuid5(URL, github.com/hyperpolymath/secret-types)" + :uuid-note "F-1 remains open: template mint-path cause and local scaffolding sweep; see docs/reports/audit/consistency-audit-2026-11-01.adoc" + :kind "theory" + :source-commit "ba2484fb7abc") + (prover + :tool "agda" + :version "2.6.4.3" + :provenance "Debian 13 agda-bin=2.6.4.3-1+b2" + :library "none (--no-libraries; Agda builtins only)" + :flags "--safe --without-K --cubical-compatible --no-libraries --ignore-interfaces --double-check -W error" + :flags-source "scripts/proof-flags.sh" + :flags-note "One definition, read by the gate and by this receipt; the receipt cannot describe a gate that did not run") + (conformance + :policy "standards/3-practice/PROOF-POLICY.adoc" + :policy-repository "hyperpolymath/standards" + :policy-commit "9bdf864cc7f2c8e24b4df84bf00d04158eee3ad5" + :policy-revision "owner ruling 2026-10-26; section 2.1 dated 2026-11-01; section 8 (PWG) 2026-11-04" + :canonical-invocation "agda --safe --without-K --cubical-compatible" + :cubical-compatible "in-force" + :flag-control "tests/flag-control/IndexedMatch.agda" + :closed-waiver "W-1, docs/proof-debt.adoc#waivers" + :stronger-than-canonical ("--no-libraries" "--ignore-interfaces" "--double-check" "-W error")) + (gate + :command "bash scripts/check.sh" + :laws-certificate "bash scripts/minimal-laws.sh" + :ci ".github/workflows/proofs.yml" + :module-count 15 + :reject-fixture-count 4) + (trust-base + :postulates 0 + :escape-hatches 0 + :library-dependencies 0 + :axioms-assumed 0 + :note "A non-zero count above fails the gate; the receipt is only meaningful while all four are zero") + ; PROOF-POLICY section 8: grades apply to theory theorems; demonstrations are ungraded. + ; Rocq and Lean legs: re-proved by hand (Agda2Dedukti cannot run on the pinned Agda), RECONCILED in verification/legs/README.adoc. + (legs + :gate "bash scripts/check-legs.sh" + :ci ".github/workflows/proofs.yml (job legs)" + :correspondence "verification/legs/README.adoc" + :route "re-proved" + :route-reason "Agda2Dedukti is an Agda backend pinned to a fork (b026db1, GHC 8.10) and cannot run on Agda 2.6.4.3; no released Dedukti/Lambdapi exporter to Lean 4, and no pinned route to Rocq" + :state "RECONCILED" + :review "statement comparison written by Claude Code; provisional until owner review (estate doctrine 4)" + (leg :prover "rocq" :checker "coqc" :version "8.18.0" :invocation "coqc -q SecretTypes.v (in verification/legs/rocq)" :libraries "none (no Require)" :axioms "none (Print Assumptions: Closed under the global context)") + (leg :prover "lean" :checker "lean" :version "4.24.0" :invocation "lake build; lake env lean Axioms.lean (in verification/legs/lean)" :libraries "none (lakefile has no require; no Mathlib)" :axioms "none (#print axioms: does not depend on any axioms)") + (inputs + (input :path "verification/legs/rocq/SecretTypes.v" :sha256 "acffaa9b4884b46c61fae6f2c91a34e7005b5d4b6158ebcc83bde25bc573b75e") + (input :path "verification/legs/lean/Axioms.lean" :sha256 "078d9a566e80b9f9701ba1b3f12bd5897e372c41f7d2dcfdf13c2f6d07159ae7") + (input :path "verification/legs/lean/SecretTypes.lean" :sha256 "48d02eea6800f9655c1a1d968cbeb9b8d8aa2594f6ad47915cbc8247e4157b8d") + (input :path "verification/legs/lean/SecretTypes/Countermodels.lean" :sha256 "ef2dc1f54aa0ac76acea9c020c9fa0adecfb864e27caa184339090d233bd6ed5") + (input :path "verification/legs/lean/SecretTypes/Generic.lean" :sha256 "13b10fd744828bc5ea3551346aeaa7795b51f24e8585e26c9dc737805c054cfa") + (input :path "verification/legs/lean/SecretTypes/Two.lean" :sha256 "74387c6eec74dbbfada1c08d5ef96451740c37e439db8aa5a17ece44e4cf318a") + (input :path "verification/legs/lean/lakefile.toml" :sha256 "75b8b79a27ec284d2c383302c88c90248ab56ee9902664068140d014b45bcf34") + (input :path "verification/legs/lean/lean-toolchain" :sha256 "387019dd5f6d8213249f99bfe1bd9f346678bf0d4612938c1e7feae386665335") + )) + (claims + (claim :id "N-1" :token "proven" :kind "theorem" :pwg "2ii" :foundation-diverse #f + (leg :prover "rocq" :route "re-proved" :token "proven" :state "RECONCILED" :receipts ()) + (leg :prover "lean" :route "re-proved" :token "proven" :state "RECONCILED" :receipts ())) + (claim :id "N-2" :token "proven" :kind "theorem" :pwg "2ii" :foundation-diverse #f + (leg :prover "rocq" :route "re-proved" :token "proven" :state "RECONCILED" :receipts ()) + (leg :prover "lean" :route "re-proved" :token "proven" :state "RECONCILED" :receipts ())) + (claim :id "N-3" :token "proven" :kind "theorem" :pwg "2ii" :foundation-diverse #f + (leg :prover "rocq" :route "re-proved" :token "proven" :state "RECONCILED" :receipts ()) + (leg :prover "lean" :route "re-proved" :token "proven" :state "RECONCILED" :receipts ())) + (claim :id "N-4" :token "tested-only" :kind "demonstration" :pwg "ungraded") + (claim :id "N-5" :token "tested-only" :kind "demonstration" :pwg "ungraded") + (claim :id "N-6" :token "tested-only" :kind "demonstration" :pwg "ungraded") + (claim :id "N-7" :token "proven" :kind "theorem" :pwg "2ii" :foundation-diverse #f + (leg :prover "rocq" :route "re-proved" :token "proven" :state "RECONCILED" :receipts ()) + (leg :prover "lean" :route "re-proved" :token "proven" :state "RECONCILED" :receipts ())) + (claim :id "N-8" :token "proven" :kind "theorem" :pwg "2ii" :foundation-diverse #f + (leg :prover "rocq" :route "re-proved" :token "proven" :state "RECONCILED" :receipts ()) + (leg :prover "lean" :route "re-proved" :token "proven" :state "RECONCILED" :receipts ())) + (claim :id "N-9" :token "proven" :kind "theorem" :pwg "2ii" :foundation-diverse #f + (leg :prover "rocq" :route "re-proved" :token "proven" :state "RECONCILED" :receipts ()) + (leg :prover "lean" :route "re-proved" :token "proven" :state "RECONCILED" :receipts ())) + ) + ; Every module hash is checked against the current tree. + (modules + (module :path "src/SecretTypes/All.agda" :sha256 "3538afeb43c6c55498ba95ed6918df7ca9474439c24b1ef64b6f4dc488bf892b") + (module :path "src/SecretTypes/Countermodels.agda" :sha256 "2f3c155a3e18e1282d4dc0f605aadadfda8e9bba24614626bc10cfbec2184f31") + (module :path "src/SecretTypes/Lang.agda" :sha256 "957333acdcc5c67692630411cd7a82e96075f07533b0d2cc7c99d000b1522570") + (module :path "src/SecretTypes/Lang/Two.agda" :sha256 "4bb9e6db64c7dfa36fd7ce3bfd5947331819ac4c3ba1d2fb599c46e07be53c9a") + (module :path "src/SecretTypes/Lattice.agda" :sha256 "119d637de4dd4578c0eb0cd3dbad679f4281db9fa4660833fdd74b8d220ab10e") + (module :path "src/SecretTypes/Lattice/Record.agda" :sha256 "25f4ee821b8e245dccb4f5f7eae9c3f1006a4b6eaeab3af14646721f14f83de3") + (module :path "src/SecretTypes/Noninterference.agda" :sha256 "944231de8ab05155f2aaa8da6a37edfa32123cac25c29ea5a2e4a2d71f68ab0e") + (module :path "src/SecretTypes/Noninterference/Generic.agda" :sha256 "716de16efc8ded98b0744fd38a5f95ca6e431b07dedb43e12140f5260e28af01") + (module :path "src/SecretTypes/Noninterference/OutcomeVisibility.agda" :sha256 "33e63996ccb6ea5ba834dd158584b8a1fb5594d50e79c2ee2c144ffe7588d93a") + (module :path "src/SecretTypes/Noninterference/Statement.agda" :sha256 "f76d84eb3a434c2f9f4203303fcf30fb04282afe787ce085d4590855b43e4c45") + (module :path "src/SecretTypes/Prelude.agda" :sha256 "8ec095409f9424acf26e458e0ff1b129d1ca9525f37afa6d40e66bfcbd2cfed1") + (module :path "src/SecretTypes/Typing.agda" :sha256 "f535815d15f4b224a028b01261af8d7f233d8b3c0a26e48ec5c4223f3afe1d42") + (module :path "src/SecretTypes/Typing/Generic.agda" :sha256 "adacd5405be17ccbf845c56d2d7980d2b0eae7214a284df2bf5a64d64ccf7718") + (module :path "src/SecretTypes/Vars.agda" :sha256 "f188cbc8b630b036daac4720f3cfe492bf78671f87b82850919f09091ead5422") + (module :path "src/SecretTypes/Witness.agda" :sha256 "bc2166b18e68051ccfdb034f55d26a2249f9b27c953f664cdbfeacb1e35879b8") + ) + (fixtures + (fixture :path "tests/reject/ExplicitFlow.agda") + (fixture :path "tests/reject/ImplicitFlow.agda") + (fixture :path "tests/reject/TerminationSensitiveNI.agda") + (fixture :path "tests/reject/ThirdVariable.agda") + ) +)