diff --git a/.github/workflows/blast-radius-outcome.yml b/.github/workflows/blast-radius-outcome.yml new file mode 100644 index 0000000..c7b1c17 --- /dev/null +++ b/.github/workflows/blast-radius-outcome.yml @@ -0,0 +1,87 @@ +# Blast-radius outcome — appends the committed trace line to +# audit/blast-radius.jsonl after every successful gate run. +# +# Triggered by `workflow_run` of "Blast-radius pulse" (the gate). The +# write-back uses contents:write and commits with a [skip ci] marker; the +# gate workflow itself never has contents:write (least-privilege gate). +# +# Recursion-break guards (ADR-002 §5): +# 1. The gate workflow declares paths-ignore: ['audit/**']. +# 2. Every commit this workflow writes ends with [skip ci]. +# 3. Author-identity guard at the head of the job exits early if the +# triggering commit is itself a github-actions[bot] audit/** commit. +name: Blast-radius outcome + +on: + workflow_run: + workflows: ["Blast-radius pulse"] + types: [completed] + +permissions: + contents: write # commit trace line + pull-requests: read # resolve PR metadata + +concurrency: + group: blast-radius-trace + cancel-in-progress: false + +jobs: + outcome: + name: Append blast-radius trace + # Run only on pull_request-triggered gate completions AND only when the + # gate-triggering actor is not github-actions[bot]. The latter is the + # author-identity recursion guard (3 of 3 per ADR-002 §5): if the gate + # run was authored by the bot, the trace write-back is suppressed. + if: >- + ${{ github.event.workflow_run.event == 'pull_request' + && github.event.workflow_run.actor.login != 'github-actions[bot]' + && github.event.workflow_run.actor.login != 'blast-radius-bot' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout (default branch HEAD) + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Download gate trace artifact + uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 + with: + name: blast-radius-pulse-${{ github.event.workflow_run.id }} + path: .pulse-in + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ github.event.workflow_run.id }} + + - name: Append trace line to audit/blast-radius.jsonl + run: | + set -euo pipefail + if [ ! -f .pulse-in/trace-fragment.jsonl ]; then + echo "no trace fragment in artifact — nothing to append" + exit 0 + fi + mkdir -p audit + # Ensure newline termination on the existing trace before append. + if [ -s audit/blast-radius.jsonl ] && \ + [ "$(tail -c1 audit/blast-radius.jsonl | od -An -c | tr -d ' ')" != "\\n" ]; then + printf '\n' >> audit/blast-radius.jsonl + fi + cat .pulse-in/trace-fragment.jsonl >> audit/blast-radius.jsonl + + - name: Commit write-back (skip-ci marker, audit/** only) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + if git diff --quiet -- audit/blast-radius.jsonl; then + echo "no trace changes to commit" + exit 0 + fi + git config user.name "blast-radius-bot" + git config user.email "blast-radius-bot@users.noreply.github.com" + git add audit/blast-radius.jsonl + git commit -m "chore(blast-radius): append pulse trace [skip ci]" + # GITHUB_TOKEN push: GitHub does not re-trigger push-keyed workflows + # from this push, and the gate workflow's paths-ignore guards + # pull_request retriggering. + git push origin "HEAD:${GITHUB_REF_NAME:-main}" diff --git a/.github/workflows/blast-radius-pulse.yml b/.github/workflows/blast-radius-pulse.yml new file mode 100644 index 0000000..cad5396 --- /dev/null +++ b/.github/workflows/blast-radius-pulse.yml @@ -0,0 +1,165 @@ +# Blast-radius pulse — evaluates a PR diff against conformance/blast_radius.rego +# and posts a sticky-comment + uploads the per-run trace artifact. +# +# Read-only by design. The committed write-back of audit/blast-radius.jsonl is +# performed by the sibling outcome workflow (blast-radius-outcome.yml) running +# in the trusted workflow_run context. This split keeps the gate from needing +# contents:write. +# +# Recursion-break guards (ADR-002 §5): +# 1. paths-ignore: ['audit/**'] on pull_request — a trace commit alone does +# not retrigger the gate. +# 2. The outcome workflow ends its write-back commit with [skip ci]. +# 3. The outcome workflow is keyed on workflow_run of THIS workflow; the +# identity guard at audit/** + github-actions[bot] keeps it from running +# on its own previous commit. +name: Blast-radius pulse + +on: + pull_request: + types: [opened, synchronize, reopened] + paths-ignore: + - 'audit/**' + +permissions: + contents: read # checkout only + pull-requests: write # sticky PR comment + +concurrency: + group: blast-radius-pulse-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + pulse: + name: Blast-radius verdict + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Install OPA + uses: open-policy-agent/setup-opa@v2.4.0 # pin to SHA before release + with: + version: "1.14.1" + + - name: Run pulse against PR diff + id: pulse + env: + BASE_REF: ${{ github.base_ref }} + run: | + set -euo pipefail + mkdir -p "${GITHUB_WORKSPACE}/.pulse-out" + # Ensure the base ref is fetched (actions/checkout with fetch-depth:0 + # gives us full history but origin/ is not always a local + # tracking ref — refresh it). + git fetch --no-tags --prune origin "$BASE_REF" + git log --pretty=format:'%B%n' "origin/${BASE_REF}..HEAD" > "${GITHUB_WORKSPACE}/.pulse-out/pr-messages.txt" + bash scripts/pulse.sh \ + --mode audit \ + --diff-range "origin/${BASE_REF}...HEAD" \ + --output-dir "${GITHUB_WORKSPACE}/.pulse-out" \ + --commit-msg-file "${GITHUB_WORKSPACE}/.pulse-out/pr-messages.txt" \ + --json > "${GITHUB_WORKSPACE}/.pulse-out/result.json" || pulse_exit=$? + pulse_exit="${pulse_exit:-0}" + echo "exit_code=${pulse_exit}" >> "$GITHUB_OUTPUT" + # Surface verdict + counts for downstream steps. + verdict="$(jq -r '.verdict' .pulse-out/verdict.json)" + errors="$(jq -r '.errors' .pulse-out/verdict.json)" + warnings="$(jq -r '.warnings' .pulse-out/verdict.json)" + { + echo "verdict=${verdict}" + echo "errors=${errors}" + echo "warnings=${warnings}" + } >> "$GITHUB_OUTPUT" + + - name: Compute policy digest + id: digest + run: | + set -euo pipefail + if command -v sha256sum >/dev/null 2>&1; then + d="$(sha256sum conformance/blast_radius.rego | awk '{print $1}')" + else + d="$(shasum -a 256 conformance/blast_radius.rego | awk '{print $1}')" + fi + echo "policy_digest=$d" >> "$GITHUB_OUTPUT" + + - name: Emit trace fragment + env: + POLICY_DIGEST: ${{ steps.digest.outputs.policy_digest }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_ACTOR: ${{ github.actor }} + VERDICT: ${{ steps.pulse.outputs.verdict }} + ERRORS: ${{ steps.pulse.outputs.errors }} + WARNINGS: ${{ steps.pulse.outputs.warnings }} + run: | + set -euo pipefail + ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + verdict_json="$(cat .pulse-out/verdict.json)" + jq -nc \ + --arg ts "$ts" \ + --arg trace_id "br-pulse-${GITHUB_RUN_ID}-${PR_NUMBER}" \ + --arg event "pulse_verdict" \ + --argjson pr "$PR_NUMBER" \ + --arg actor "$PR_ACTOR" \ + --argjson v "$verdict_json" \ + --arg verdict "$VERDICT" \ + --argjson errors "$ERRORS" \ + --argjson warnings "$WARNINGS" \ + --arg digest "$POLICY_DIGEST" \ + --arg run_url "$run_url" \ + '{ + ts: $ts, + trace_id: $trace_id, + event: $event, + pr_number: $pr, + pr_actor: $actor, + git_sha: $v.git_sha, + mode: "audit", + changed_files: ($v.inputs.changed_files // []), + json_changes: ($v.inputs.json_changes // {}), + fired: ($v.fired // []), + verdict: $verdict, + errors: $errors, + warnings: $warnings, + rule_applied: $v.rule_applied, + alternatives: $v.alternatives, + rationale: $v.rationale, + opa_policy_digest: $digest, + run_url: $run_url + }' > .pulse-out/trace-fragment.jsonl + + - name: Post PR comment + if: always() && github.event.pull_request.number != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + if [ -f .pulse-out/pr-comment.md ]; then + gh pr comment "$PR_NUMBER" --body-file .pulse-out/pr-comment.md || true + fi + + - name: Upload pulse trace artifact + if: always() + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + with: + name: blast-radius-pulse-${{ github.run_id }} + path: | + .pulse-out/opa-input.json + .pulse-out/opa-eval.stdout + .pulse-out/opa-eval.stderr + .pulse-out/verdict.json + .pulse-out/pr-comment.md + .pulse-out/pulse-report.txt + .pulse-out/trace-fragment.jsonl + retention-days: 90 + + - name: Fail on blocked verdict + if: steps.pulse.outputs.verdict == 'blocked' + run: | + echo "::error::blast-radius pulse blocked the PR (errors=${{ steps.pulse.outputs.errors }})" + exit 1 diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml new file mode 100644 index 0000000..671623a --- /dev/null +++ b/.github/workflows/conformance.yml @@ -0,0 +1,194 @@ +# Reusable conformance workflow — callers invoke this via: +# +# conformance: +# uses: jonathan-kellerai/kellerai-oss-template/.github/workflows/conformance.yml@ +# with: +# artifact_type: json-schema # or markdown-spec | rego-policy | rag-config +# +# NOTE: This repo must be public before any consumer's first CI run; +# GitHub forbids a public repo from calling a reusable workflow in a private repo. +# +# pin to SHA before release +name: Conformance check (reusable) + +on: + workflow_call: + inputs: + artifact_type: + description: > + The artifact type of the calling repository — one of: + json-schema | markdown-spec | rego-policy | rag-config + type: string + required: true + opa_version: + description: OPA version to install via open-policy-agent/setup-opa + type: string + required: false + default: "1.14.1" + +permissions: + contents: read + +jobs: + conformance: + name: OPA conformance + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + # ----------------------------------------------------------------------- + # 1. Check out the CALLING repository at full depth so that + # scan-repo-structure.sh can enumerate branches via git for-each-ref. + # ----------------------------------------------------------------------- + - name: Checkout caller repo + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + path: repo + + # ----------------------------------------------------------------------- + # 2. Check out this template repo alongside so we have: + # standard/conformance/ — the policy + data.json + # standard/scripts/scan-repo-structure.sh + # ----------------------------------------------------------------------- + - name: Checkout kellerai-oss-template (standard) + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: jonathan-kellerai/kellerai-oss-template + # job.workflow_sha is the commit SHA of the reusable-workflow file itself + # (i.e. the SHA at which this conformance.yml was called). This ensures the + # policy source checked out here always matches the pinned workflow version + # the consumer specified — fixing the reproducibility bug (Codex Finding 10). + ref: ${{ job.workflow_sha }} + path: standard + + # ----------------------------------------------------------------------- + # 3. Validate artifact_type input before proceeding. + # Allowed values mirror conformance/data.json -> schema.artifact_types. + # ----------------------------------------------------------------------- + - name: Validate artifact_type input + shell: bash + run: | + case "${{ inputs.artifact_type }}" in + json-schema|markdown-spec|rego-policy|rag-config) + echo "artifact_type '${{ inputs.artifact_type }}' is valid." + ;; + *) + echo "::error::artifact_type must be one of: json-schema|markdown-spec|rego-policy|rag-config (got: ${{ inputs.artifact_type }})" + exit 1 + ;; + esac + + # ----------------------------------------------------------------------- + # 4. Install OPA. + # ----------------------------------------------------------------------- + - name: Install OPA + uses: open-policy-agent/setup-opa@b2b258e089860efaadaaf71bf6e3aecb4a3eeff1 # v2.4.0 + with: + version: ${{ inputs.opa_version }} + + # ----------------------------------------------------------------------- + # 5. Verify the policy itself is syntactically valid. + # ----------------------------------------------------------------------- + - name: OPA check (policy syntax) + run: opa check standard/conformance/ + + # ----------------------------------------------------------------------- + # 6. Scan the caller repo into repo-structure.json. + # --root points at the checked-out caller. + # --artifact-type comes from the workflow_call input. + # ----------------------------------------------------------------------- + - name: Scan repo structure + run: | + bash standard/scripts/scan-repo-structure.sh \ + --root "$GITHUB_WORKSPACE/repo" \ + --artifact-type "${{ inputs.artifact_type }}" \ + > repo-structure.json + + # ----------------------------------------------------------------------- + # 7. Evaluate the policy. + # Fail the job if any error-severity violation is found. + # Always print the human-readable table regardless. + # ----------------------------------------------------------------------- + - name: Evaluate conformance policy + run: | + set +e + opa eval \ + --data standard/conformance/ \ + --input repo-structure.json \ + --format json \ + 'data.kellerai.oss.conformance.violations' \ + > opa-eval.stdout \ + 2> opa-eval.stderr + exit_code=$? + set -e + + # Surface the real opa error before doing anything else. + if [ "$exit_code" -ne 0 ]; then + echo "::error::opa eval failed (exit $exit_code)" + echo "=== opa eval stderr ===" + cat opa-eval.stderr + echo "=== repo-structure.json (first 400 bytes) ===" + head -c 400 repo-structure.json + exit 1 + fi + + result=$(cat opa-eval.stdout) + + echo "=== Conformance violations ===" + # Pass the opa output via OPA_RESULT env var so the heredoc stdin + # is not in conflict with a pipe (avoids shellcheck SC2259). + # Print a readable table: one line per violation. + OPA_RESULT="$result" python3 - <<'PYEOF' + import sys, json, os + + raw = os.environ.get("OPA_RESULT", "").strip() + if not raw: + # opa eval exited 0 but produced no stdout — should not happen with + # --format json, but treat it as an error rather than silently passing. + print("::error::opa eval succeeded but produced no output") + sys.exit(1) + + try: + outer = json.loads(raw) + except json.JSONDecodeError as e: + print(f"Could not parse opa eval output: {e}") + print(raw) + sys.exit(1) + + # opa eval --format json wraps results in {"result": [...]} + violations = [] + if isinstance(outer, dict) and "result" in outer: + for binding in outer["result"]: + for expr in binding.get("expressions", []): + val = expr.get("value", []) + if isinstance(val, list): + violations.extend(val) + elif isinstance(outer, list): + violations = outer + + if not violations: + print("OK — no violations") + sys.exit(0) + + # Print table header + print(f"{'SEV':<8} {'RULE':<30} {'FIELD':<40} MSG") + print("-" * 120) + errors = 0 + for v in sorted(violations, key=lambda x: (x.get("severity",""), x.get("rule",""))): + sev = v.get("severity", "?") + rule = v.get("rule", "?") + field = v.get("field", "?") + msg = v.get("msg", "?") + print(f"{sev:<8} {rule:<30} {field:<40} {msg}") + if sev == "error": + errors += 1 + + print() + if errors: + print(f"FAIL — {errors} error-severity violation(s). Fix them before merging.") + sys.exit(1) + else: + print("PASS — warnings only (non-blocking).") + PYEOF + shell: bash diff --git a/.github/workflows/trust-dial-gate.yml b/.github/workflows/trust-dial-gate.yml new file mode 100644 index 0000000..e35b8bb --- /dev/null +++ b/.github/workflows/trust-dial-gate.yml @@ -0,0 +1,193 @@ +# Trust-dial gate — evaluates a Dependabot PR against conformance/trust_dial.rego +# and acts on the verdict. +# +# Three verdicts: +# auto-merge -> gh pr merge --auto --squash +# hold-for-review -> label + reviewer requested +# block -> label + comment with rationale +# +# Read-only by design. The committed write-back of audit/trust-dial-state.json +# and audit/decision-trace.jsonl is done by the outcome workflow, which runs in +# the trusted workflow_run context. This split keeps the gate from needing +# contents:write — and respects GitHub's hardened Dependabot rules (PRs opened +# by dependabot[bot] run with a read-only GITHUB_TOKEN and no secrets access). +name: Trust-dial gate + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read # checkout only + pull-requests: write # label + enable auto-merge + +jobs: + gate: + name: Trust-dial verdict + if: ${{ github.actor == 'dependabot[bot]' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Fetch Dependabot metadata + id: meta + uses: dependabot/fetch-metadata@v2.4.0 # pin to SHA before release + + - name: Install OPA + uses: open-policy-agent/setup-opa@v2.4.0 # pin to SHA before release + with: + version: "1.14.1" + + - name: Build OPA input + id: build + env: + UPDATE_TYPE: ${{ steps.meta.outputs.update-type }} + ECOSYSTEM: ${{ steps.meta.outputs.package-ecosystem }} + DEPENDENCY: ${{ steps.meta.outputs.dependency-names }} + FROM_VERSION: ${{ steps.meta.outputs.previous-version }} + TO_VERSION: ${{ steps.meta.outputs.new-version }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_ACTOR: ${{ github.actor }} + run: | + set -euo pipefail + state="audit/trust-dial-state.json" + tier="$(jq -r '.tier' "$state")" + cycle_count="$(jq -r '.cycle_merge_count' "$state")" + + jq -n \ + --arg tier "$tier" \ + --arg eco "$ECOSYSTEM" \ + --arg ut "$UPDATE_TYPE" \ + --arg dep "$DEPENDENCY" \ + --arg fv "$FROM_VERSION" \ + --arg tv "$TO_VERSION" \ + --argjson cycle "$cycle_count" \ + --argjson pr "$PR_NUMBER" \ + --arg actor "$PR_ACTOR" \ + '{ + tier: $tier, + ecosystem: $eco, + update_type: $ut, + dependency: $dep, + from_version: $fv, + to_version: $tv, + cycle_merge_count: $cycle, + pr_number: $pr, + pr_actor: $actor + }' > opa-input.json + + echo "tier=$tier" >> "$GITHUB_OUTPUT" + + - name: Evaluate verdict + id: verdict + run: | + set -euo pipefail + set +e + opa eval \ + --data conformance/ \ + --input opa-input.json \ + --format json \ + 'data.kellerai.oss.trust_dial.decision' \ + > opa-eval.stdout \ + 2> opa-eval.stderr + exit_code=$? + set -e + if [ "$exit_code" -ne 0 ]; then + echo "::error::opa eval failed (exit $exit_code)" + cat opa-eval.stderr + exit 1 + fi + decision="$(jq -c '.result[0].expressions[0].value' opa-eval.stdout)" + verdict="$(printf '%s' "$decision" | jq -r '.verdict')" + rationale="$(printf '%s' "$decision" | jq -r '.rationale')" + printf '%s' "$decision" > decision.json + { + echo "verdict=$verdict" + echo "rationale=$rationale" + } >> "$GITHUB_OUTPUT" + + - name: Compute policy digest + id: digest + run: | + set -euo pipefail + if command -v sha256sum >/dev/null 2>&1; then + d="$(sha256sum conformance/trust_dial.rego | awk '{print $1}')" + else + d="$(shasum -a 256 conformance/trust_dial.rego | awk '{print $1}')" + fi + echo "policy_digest=$d" >> "$GITHUB_OUTPUT" + + - name: Emit trace entry + env: + POLICY_DIGEST: ${{ steps.digest.outputs.policy_digest }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_ACTOR: ${{ github.actor }} + run: | + set -euo pipefail + ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + decision="$(cat decision.json)" + jq -nc \ + --arg ts "$ts" \ + --arg trace_id "td-gate-${GITHUB_RUN_ID}-${PR_NUMBER}" \ + --arg event "gate_decision" \ + --argjson pr "$PR_NUMBER" \ + --arg actor "$PR_ACTOR" \ + --argjson d "$decision" \ + --arg digest "$POLICY_DIGEST" \ + --arg run_url "$run_url" \ + '{ + ts: $ts, + trace_id: $trace_id, + event: $event, + pr_number: $pr, + pr_actor: $actor, + tier: $d.inputs.tier, + inputs: $d.inputs, + rule_applied: $d.rule_applied, + alternatives: $d.alternatives, + verdict: $d.verdict, + rationale: $d.rationale, + opa_policy_digest: $digest, + run_url: $run_url + }' > trace-fragment.jsonl + + - name: Act on verdict + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + VERDICT: ${{ steps.verdict.outputs.verdict }} + RATIONALE: ${{ steps.verdict.outputs.rationale }} + run: | + set -euo pipefail + case "$VERDICT" in + auto-merge) + gh pr merge "$PR_NUMBER" --squash --auto + ;; + hold-for-review) + gh pr edit "$PR_NUMBER" --add-label "trust-dial/hold" || true + gh pr comment "$PR_NUMBER" --body "trust-dial: hold-for-review — $RATIONALE" + ;; + block) + gh pr edit "$PR_NUMBER" --add-label "trust-dial/blocked" || true + gh pr comment "$PR_NUMBER" --body "trust-dial: blocked — $RATIONALE" + ;; + *) + echo "::error::unknown verdict: $VERDICT" + exit 1 + ;; + esac + + - name: Upload trust-dial trace artifact + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + with: + name: trust-dial-trace-${{ github.run_id }} + path: | + trace-fragment.jsonl + opa-input.json + opa-eval.stdout + opa-eval.stderr + decision.json + retention-days: 90 diff --git a/.github/workflows/trust-dial-outcome.yml b/.github/workflows/trust-dial-outcome.yml new file mode 100644 index 0000000..f90a31d --- /dev/null +++ b/.github/workflows/trust-dial-outcome.yml @@ -0,0 +1,274 @@ +# Trust-dial outcome — drives streak / circuit-breaker / promotion / demotion +# from post-merge CI results. +# +# Triggered by `workflow_run` of the repo's CI workflow, completed. The +# `if: workflow_run.event == 'push'` guard ensures only merged-commit CI runs +# count — a CI run on the PR branch itself does not advance the dial. +# +# This is the only writer of audit/trust-dial-state.json and +# audit/decision-trace.jsonl. The gate workflow reads; it never writes. +# +# Recursion-break guards (spec §1.3): +# 1. Every push-triggered workflow has paths-ignore: ['audit/**']. +# 2. The write-back commit message ends with [skip ci]. +# 3. The write-back is pushed with the default GITHUB_TOKEN; GitHub does not +# re-trigger workflows from GITHUB_TOKEN pushes (except workflow_run / +# repository_dispatch). This workflow is keyed on workflow_run of CI, not +# on its own push, so it does not satisfy its own trigger. +name: Trust-dial outcome + +on: + workflow_run: + workflows: ["CI"] + types: [completed] + +permissions: + contents: write # commit state + trace + pull-requests: write # auto-revert / comment + +concurrency: + group: trust-dial-state + cancel-in-progress: false + +jobs: + outcome: + name: Update trust-dial state + if: ${{ github.event.workflow_run.event == 'push' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout (default branch HEAD) + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Resolve merged Dependabot PR for this commit + id: pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + set -euo pipefail + # Find PRs whose merge commit matches the workflow_run head_sha. + pr_json="$(gh pr list --state merged --search "$HEAD_SHA" --json number,user,mergeCommit --limit 5 || echo '[]')" + pr_number="$(printf '%s' "$pr_json" | jq -r --arg sha "$HEAD_SHA" '.[] | select(.mergeCommit.oid == $sha) | .number' | head -n1)" + pr_actor="$(printf '%s' "$pr_json" | jq -r --arg sha "$HEAD_SHA" '.[] | select(.mergeCommit.oid == $sha) | .user.login' | head -n1)" + { + echo "pr_number=${pr_number:-}" + echo "pr_actor=${pr_actor:-}" + } >> "$GITHUB_OUTPUT" + + - name: Update state file + id: state + env: + CI_CONCLUSION: ${{ github.event.workflow_run.conclusion }} + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + PR_ACTOR: ${{ steps.pr.outputs.pr_actor }} + run: | + set -euo pipefail + state="audit/trust-dial-state.json" + ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + cycle_id="$(date -u +%G-W%V)" + + # Only Dependabot merges drive the dial. + if [ "${PR_ACTOR:-}" != "dependabot[bot]" ]; then + echo "non-dependabot merge; no state change" + echo "tier_change=none" >> "$GITHUB_OUTPUT" + echo "tier_before=$(jq -r '.tier' "$state")" >> "$GITHUB_OUTPUT" + echo "tier_after=$(jq -r '.tier' "$state")" >> "$GITHUB_OUTPUT" + echo "streak_after=$(jq -r '.clean_merge_streak' "$state")" >> "$GITHUB_OUTPUT" + echo "post_merge_ci=$CI_CONCLUSION" >> "$GITHUB_OUTPUT" + exit 0 + fi + + tier_before="$(jq -r '.tier' "$state")" + + # Cycle rollover: a new cycle resets cycle_merge_count. + jq --arg cid "$cycle_id" ' + if .last_cycle_id != $cid then + .last_cycle_id = $cid | .cycle_merge_count = 0 + else . end + ' "$state" > "$state.tmp" && mv "$state.tmp" "$state" + + if [ "$CI_CONCLUSION" = "success" ]; then + jq --arg cid "$cycle_id" ' + .clean_merge_streak += 1 + | .cycle_merge_count += 1 + ' "$state" > "$state.tmp" && mv "$state.tmp" "$state" + else + # Regression — append event, reset streak, BakeTracker reset. + jq --arg ts "$ts" --arg cid "$cycle_id" --arg pr "${PR_NUMBER:-}" ' + .clean_merge_streak = 0 + | .consecutive_clean_cycles = 0 + | .regression_events += [{ts: $ts, pr_number: ($pr | tonumber? // null), cycle_id: $cid}] + ' "$state" > "$state.tmp" && mv "$state.tmp" "$state" + fi + + # Promotion: streak threshold + BakeTracker. + tier="$(jq -r '.tier' "$state")" + streak="$(jq -r '.clean_merge_streak' "$state")" + bake="$(jq -r '.consecutive_clean_cycles' "$state")" + next_tier="" + required=0 + case "$tier" in + Observed) next_tier="Assisted"; required=5 ;; + Assisted) next_tier="Supervised"; required=10 ;; + Supervised) next_tier="Trusted"; required=20 ;; + Trusted) next_tier=""; required=0 ;; + esac + + tier_change="none" + if [ -n "$next_tier" ] && [ "$streak" -ge "$required" ] && [ "$bake" -ge 3 ]; then + jq --arg ts "$ts" --arg from "$tier" --arg to "$next_tier" --arg streak "$streak" ' + .tier = $to + | .clean_merge_streak = 0 + | .demotion_history += [{ts: $ts, from: $from, to: $to, direction: "promote", + reason: ("streak " + $streak + " met promotion threshold")}] + ' "$state" > "$state.tmp" && mv "$state.tmp" "$state" + tier_change="promote" + fi + + # Demotion: 3 regressions within 4 cycles. + if [ "$CI_CONCLUSION" != "success" ] && [ "$tier_change" = "none" ]; then + recent="$(jq '[.regression_events[-12:] | .[] | .cycle_id] | unique | length' "$state")" + regression_count="$(jq '[.regression_events[] | select(.cycle_id != null)] | length' "$state")" + if [ "$regression_count" -ge 3 ]; then + prev_tier="$(jq -r '.tier' "$state")" + down_tier="" + case "$prev_tier" in + Trusted) down_tier="Supervised" ;; + Supervised) down_tier="Assisted" ;; + Assisted) down_tier="Observed" ;; + Observed) down_tier="" ;; + esac + if [ -n "$down_tier" ]; then + jq --arg ts "$ts" --arg from "$prev_tier" --arg to "$down_tier" ' + .tier = $to + | .clean_merge_streak = 0 + | .consecutive_clean_cycles = 0 + | .demotion_history += [{ts: $ts, from: $from, to: $to, direction: "demote", + reason: "circuit_breaker: regression_threshold reached"}] + ' "$state" > "$state.tmp" && mv "$state.tmp" "$state" + tier_change="demote" + fi + fi + fi + + tier_after="$(jq -r '.tier' "$state")" + streak_after="$(jq -r '.clean_merge_streak' "$state")" + { + echo "tier_change=$tier_change" + echo "tier_before=$tier_before" + echo "tier_after=$tier_after" + echo "streak_after=$streak_after" + echo "post_merge_ci=$CI_CONCLUSION" + } >> "$GITHUB_OUTPUT" + + - name: Auto-revert on regression + if: ${{ github.event.workflow_run.conclusion == 'failure' && steps.pr.outputs.pr_actor == 'dependabot[bot]' && steps.pr.outputs.pr_number != '' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + set -euo pipefail + revert_branch="trust-dial/auto-revert/${PR_NUMBER}" + git config user.name "trust-dial-bot" + git config user.email "trust-dial-bot@users.noreply.github.com" + git checkout -b "$revert_branch" + if git revert --no-edit "$HEAD_SHA"; then + git push origin "$revert_branch" + gh pr create \ + --title "trust-dial: auto-revert #${PR_NUMBER}" \ + --body "Post-merge CI failed on commit ${HEAD_SHA}. trust-dial circuit breaker opened an auto-revert PR." \ + --label "trust-dial/auto-revert" \ + --base "${GITHUB_REF_NAME:-main}" \ + --head "$revert_branch" + else + gh issue create \ + --title "trust-dial: auto-revert FAILED for #${PR_NUMBER}" \ + --body "git revert --no-edit ${HEAD_SHA} could not apply cleanly. Manual intervention required." \ + --label "trust-dial/auto-revert-failed" + fi + # Always reset the working tree so the state commit step is clean. + git checkout "${GITHUB_REF_NAME:-main}" -- audit/ + + - name: Append outcome trace entry + env: + POLICY_DIGEST: "" + PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + PR_ACTOR: ${{ steps.pr.outputs.pr_actor }} + POST_MERGE_CI: ${{ steps.state.outputs.post_merge_ci }} + STREAK_AFTER: ${{ steps.state.outputs.streak_after }} + TIER_BEFORE: ${{ steps.state.outputs.tier_before }} + TIER_AFTER: ${{ steps.state.outputs.tier_after }} + TIER_CHANGE: ${{ steps.state.outputs.tier_change }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + set -euo pipefail + ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + if command -v sha256sum >/dev/null 2>&1; then + digest="$(sha256sum conformance/trust_dial.rego | awk '{print $1}')" + else + digest="$(shasum -a 256 conformance/trust_dial.rego | awk '{print $1}')" + fi + run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + jq -nc \ + --arg ts "$ts" \ + --arg trace_id "td-outcome-${GITHUB_RUN_ID}" \ + --arg event "outcome" \ + --arg pr "${PR_NUMBER:-}" \ + --arg actor "${PR_ACTOR:-}" \ + --arg ci "$POST_MERGE_CI" \ + --arg streak "$STREAK_AFTER" \ + --arg tb "$TIER_BEFORE" \ + --arg ta "$TIER_AFTER" \ + --arg tc "$TIER_CHANGE" \ + --arg sha "$HEAD_SHA" \ + --arg digest "$digest" \ + --arg run_url "$run_url" \ + '{ + ts: $ts, + trace_id: $trace_id, + event: $event, + pr_number: ($pr | tonumber? // null), + pr_actor: $actor, + merge_sha: $sha, + post_merge_ci: $ci, + tier_before: $tb, + tier_after: $ta, + tier_change: $tc, + streak_after: ($streak | tonumber? // 0), + rule_applied: "outcome_update", + alternatives: ["promote", "demote", "none"], + rationale: ("ci=" + $ci + " tier_change=" + $tc), + opa_policy_digest: $digest, + run_url: $run_url + }' >> audit/decision-trace.jsonl + + - name: Upload outcome trace artifact + uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + with: + name: trust-dial-outcome-${{ github.run_id }} + path: | + audit/trust-dial-state.json + audit/decision-trace.jsonl + retention-days: 90 + + - name: Commit state write-back (skip-ci marker, audit/** only) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + if git diff --quiet -- audit/; then + echo "no state changes to commit" + exit 0 + fi + git config user.name "trust-dial-bot" + git config user.email "trust-dial-bot@users.noreply.github.com" + git add audit/trust-dial-state.json audit/decision-trace.jsonl + git commit -m "chore(trust-dial): cycle state update [skip ci]" + # Push with the default GITHUB_TOKEN — GitHub will not re-trigger + # push-keyed workflows from this push. + git push origin "HEAD:${GITHUB_REF_NAME:-main}" diff --git a/.github/workflows/validate-branch-name.yml b/.github/workflows/validate-branch-name.yml new file mode 100644 index 0000000..5eff83c --- /dev/null +++ b/.github/workflows/validate-branch-name.yml @@ -0,0 +1,50 @@ +name: Validate external branch name + +on: + pull_request: + types: [opened, reopened, synchronize] + +permissions: + contents: read + +# Only fires for external/* branches — maintainer branches are unrestricted. +jobs: + validate-branch-name: + name: Branch name pattern (external/) + if: startsWith(github.event.pull_request.head.ref, 'external/') + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Validate branch name pattern + run: | + python3 - <<'PYEOF' + import re, sys, os + + TYPES = ( + "feat|fix|chore|docs|refactor|test|ci|build|perf" + "|revert|style|hotfix|spike|wip|release|rnd" + ) + # Pattern: external/---p + # ISSUE-KEY: uppercase team prefix + hyphen + digits (e.g. ABC-123) + # scope+gerund: one or more lowercase alphanumeric segments + # priority: p0 (critical) through p4 (backlog) + PATTERN = re.compile( + rf"^external/(?:{TYPES})" + r"-[A-Z]+-\d+" + r"(?:-[a-z][a-z0-9]*)+" + r"-p[0-4]$" + ) + + branch = os.environ["BRANCH"] + if not PATTERN.match(branch): + print(f"::error::Branch '{branch}' does not match the required pattern.") + print("::error::Required: external/----p") + print("::error::Example: external/feat-ABC-123-auth-adding-oauth-p1") + print("::error::Types: feat fix chore docs refactor test ci build perf revert style hotfix spike wip release rnd") + print("::error::Priority: p0=critical p1=high p2=medium p3=low p4=backlog") + sys.exit(1) + + print(f"Branch name is valid: {branch}") + PYEOF + env: + BRANCH: ${{ github.event.pull_request.head.ref }} diff --git a/.github/workflows/validate-branch-tier.yml b/.github/workflows/validate-branch-tier.yml new file mode 100644 index 0000000..d72182f --- /dev/null +++ b/.github/workflows/validate-branch-tier.yml @@ -0,0 +1,78 @@ +name: Validate branch tier + +on: + pull_request: + types: [opened, reopened, synchronize] + +permissions: + contents: read + +# Enforces the 4-tier merge model: +# main <- qa/** only +# qa <- dev/** only +# dev <- external/** (contributors) or a CODEOWNER branch +# external/* naming is validated separately by validate-branch-name.yml. +jobs: + validate-branch-tier: + name: "Branch tier (${{ github.base_ref }} <- ${{ github.head_ref }})" + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Check tier rule + run: | + python3 - <<'PYEOF' + import re, sys, os + + base = os.environ["BASE_REF"] # target branch (e.g. main) + head = os.environ["HEAD_REF"] # source branch (e.g. qa/something) + actor = os.environ["ACTOR"] # PR author GitHub login + + # Read .github/CODEOWNERS to determine if the author is a maintainer. + codeowners = set() + try: + with open(".github/CODEOWNERS") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + for token in line.split(): + if token.startswith("@"): + codeowners.add(token.lstrip("@").lower()) + except FileNotFoundError: + pass + + is_maintainer = actor.lower() in codeowners + + # Tier rules: maps target branch to (allowed-source-pattern, error-message). + RULES = { + "main": (r"^qa(/.*)?$", "PRs into main must come from qa/**"), + "qa": (r"^dev(/.*)?$", "PRs into qa must come from dev/**"), + "dev": (r"^external/", "PRs into dev must come from external/** or a CODEOWNER branch"), + } + + if base not in RULES: + # No tier rule for this target — allow (e.g. PRs between feature branches). + print(f"No tier rule for base '{base}'; skipping.") + sys.exit(0) + + pattern, message = RULES[base] + + if re.match(pattern, head): + print(f"Tier check passed: {head} -> {base}") + sys.exit(0) + + if base == "dev" and is_maintainer: + print(f"Tier check passed: CODEOWNER '{actor}' may merge directly into dev.") + sys.exit(0) + + print(f"::error::{message}") + print(f"::error::Attempted: {head} -> {base}") + sys.exit(1) + PYEOF + env: + BASE_REF: ${{ github.base_ref }} + HEAD_REF: ${{ github.head_ref }} + ACTOR: ${{ github.event.pull_request.user.login }} diff --git a/.github/workflows/validate-linked-issue.yml b/.github/workflows/validate-linked-issue.yml new file mode 100644 index 0000000..5164683 --- /dev/null +++ b/.github/workflows/validate-linked-issue.yml @@ -0,0 +1,91 @@ +name: Validate linked issue + +on: + pull_request: + types: [opened, reopened, synchronize] + +permissions: + contents: read + pull-requests: read + issues: read + +# Verifies the issue referenced in the branch name is Open and carries the +# 'codeowner-approved' label. Only fires for external/ branches. +# Linear integration: set the LINEAR_API_KEY repository secret to enable +# Linear issue validation (see docs/branch-governance.md). +jobs: + validate-linked-issue: + name: Linked issue open + codeowner-approved + if: startsWith(github.event.pull_request.head.ref, 'external/') + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Extract and verify linked issue + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const branch = context.payload.pull_request.head.ref; + + // Extract issue key from: external/---p + // KEY is Linear-style (ABC-123) or numeric GitHub issue (42). + const m = branch.match(/^external\/[^-]+-([A-Z]+-\d+|\d+)-/); + if (!m) { + core.setFailed(`Could not extract issue key from branch '${branch}'.`); + return; + } + const issueRef = m[1]; + + // Linear path: LINEAR_API_KEY is set but Linear API validation is not implemented. + // FAIL CLOSED — we refuse to silently pass an unvalidated Linear issue. + // To fix: either remove the LINEAR_API_KEY secret (falls back to GitHub Issues path) + // or extend this workflow to query the Linear GraphQL API and check issue state + // + required labels before allowing merge. + const linearKey = process.env.LINEAR_API_KEY; + if (linearKey && /^[A-Z]+-\d+$/.test(issueRef)) { + core.setFailed( + `Linear validation is unimplemented for issue ${issueRef}. ` + + `Either remove the LINEAR_API_KEY secret to fall back to GitHub Issues validation, ` + + `or extend this workflow to query the Linear API (check issue state == open and ` + + `required label/approval) before this check can pass.` + ); + return; + } + + // GitHub Issues path: use numeric part of the issue ref. + const numMatch = issueRef.match(/(\d+)$/); + if (!numMatch) { + core.setFailed(`Issue ref '${issueRef}' is not a GitHub issue number. Set the LINEAR_API_KEY secret for Linear validation.`); + return; + } + const issueNumber = parseInt(numMatch[1], 10); + + let issue; + try { + const resp = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + }); + issue = resp.data; + } catch (e) { + core.setFailed(`GitHub issue #${issueNumber} not found: ${e.message}`); + return; + } + + if (issue.state !== 'open') { + core.setFailed(`Issue #${issueNumber} is ${issue.state}. PRs require an open issue.`); + return; + } + + const labels = issue.labels.map(l => l.name); + if (!labels.includes('codeowner-approved')) { + core.setFailed( + `Issue #${issueNumber} is missing the 'codeowner-approved' label. ` + + `A CODEOWNER must approve the issue before a contributor PR can be opened.` + ); + return; + } + + core.info(`Issue #${issueNumber} is open and codeowner-approved. ✓`); + env: + LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} diff --git a/.gitignore b/.gitignore index 875ee7c..28f258b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,9 +11,10 @@ KICKOFF-PROMPT.md FRESH-SESSION-PROMPT.md NEXT-SESSION-PROMPT.md -# Excluded from publication (atlases + ADR — see the IP-leak audit) +# Excluded from publication (atlases + ADR drafts — see the IP-leak audit) atlases/ -docs/adr/ +docs/adr/* +!docs/adr/ADR-002-blast-radius-pulse.md # Editor cruft *.swp diff --git a/.kellerai-oss.json b/.kellerai-oss.json new file mode 100644 index 0000000..c42dcf9 --- /dev/null +++ b/.kellerai-oss.json @@ -0,0 +1,6 @@ +{ + "artifact_type": "markdown-spec", + "artifact_dir": "specs", + "primary_validator": "markdownlint-cli2", + "owner": "jonathan-kellerai" +} diff --git a/AGENTS.md b/AGENTS.md index dc6e085..24ce387 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ its supporting documents. | Read this | When you need to know | |---|---| | [`specs/TRUST-BOUNDARY-PROTOCOL-SPEC.md`](specs/TRUST-BOUNDARY-PROTOCOL-SPEC.md) | The spec — interfaces, shared types, exception hierarchy, correctness invariants (§8), domain mapping (§9), open questions (§11) | -| [`docs/pattern-report/trust-boundary-collector-synthesis.md`](docs/pattern-report/trust-boundary-collector-synthesis.md) | The empirical convergence evidence behind spec §2 | +| [`docs/pattern-report/trust-boundary-protocol-collector-synthesis.md`](docs/pattern-report/trust-boundary-protocol-collector-synthesis.md) | The empirical convergence evidence behind spec §2 | | [`docs/TRUST-BOUNDARY-PROTOCOL-whitepaper.md`](docs/TRUST-BOUNDARY-PROTOCOL-whitepaper.md) | The white paper — TRUST-BOUNDARY-PROTOCOL in narrative form | | [`docs/TRUST-BOUNDARY-PROTOCOL-WORKFLOW.md`](docs/TRUST-BOUNDARY-PROTOCOL-WORKFLOW.md) | How this spec was produced — the authoring pipeline | | [`README.md`](README.md) | Human-facing overview and positioning | diff --git a/CLAUDE.md b/CLAUDE.md index 3566643..ccfb900 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,3 @@ -# CLAUDE.md — Claude Code instructions for trust-boundary-protocol - @AGENTS.md ## Claude-specific notes diff --git a/README.md b/README.md index b4a021c..9e46c87 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ None of them shared infrastructure. Each maintained its own version, with its own bugs, its own audit gaps, and no cross-system visibility. TRUST-BOUNDARY-PROTOCOL is the unification of that convergence: one epistemically-aware boundary layer that the nine systems — and systems like them — can share instead of re-deriving. -The empirical evidence behind this claim is in [`docs/pattern-report/trust-boundary-collector-synthesis.md`](docs/pattern-report/trust-boundary-collector-synthesis.md). +The empirical evidence behind this claim is in [`docs/pattern-report/trust-boundary-protocol-collector-synthesis.md`](docs/pattern-report/trust-boundary-protocol-collector-synthesis.md). ## 3. Scope @@ -139,7 +139,7 @@ Full text in [`LICENSE`](LICENSE); the attribution and copyright notice is in [` | Path | What it is | |---|---| | [`specs/TRUST-BOUNDARY-PROTOCOL-SPEC.md`](specs/TRUST-BOUNDARY-PROTOCOL-SPEC.md) | The specification — interfaces, shared types, exception hierarchy, correctness invariants, domain mapping, open questions | -| [`docs/pattern-report/trust-boundary-collector-synthesis.md`](docs/pattern-report/trust-boundary-collector-synthesis.md) | The empirical convergence evidence — how nine systems independently arrived at the same pattern | +| [`docs/pattern-report/trust-boundary-protocol-collector-synthesis.md`](docs/pattern-report/trust-boundary-protocol-collector-synthesis.md) | The empirical convergence evidence — how nine systems independently arrived at the same pattern | | [`docs/TRUST-BOUNDARY-PROTOCOL-whitepaper.md`](docs/TRUST-BOUNDARY-PROTOCOL-whitepaper.md) | The white paper — TRUST-BOUNDARY-PROTOCOL explained in narrative form | | [`docs/TRUST-BOUNDARY-PROTOCOL-WORKFLOW.md`](docs/TRUST-BOUNDARY-PROTOCOL-WORKFLOW.md) | The spec-authoring pipeline — how this specification was produced | diff --git a/audit/blast-radius.jsonl b/audit/blast-radius.jsonl new file mode 100644 index 0000000..272dc76 --- /dev/null +++ b/audit/blast-radius.jsonl @@ -0,0 +1 @@ +{"ts": "2026-05-22T00:00:01Z", "trace_id": "br-bootstrap-2.1.0", "event": "pulse_verdict", "pr_number": null, "pr_actor": "build:blast-radius-v2.1.0", "git_sha": "", "mode": "bootstrap", "changed_files": [], "json_changes": {}, "fired": [], "verdict": "clear", "errors": 0, "warnings": 0, "rule_applied": "affects_manifest", "alternatives": ["clear", "owed", "blocked"], "rationale": "bootstrap seed line for audit/blast-radius.jsonl — first non-bootstrap line is appended by the outcome workflow", "opa_policy_digest": "", "run_url": "local:build/blast-radius-2.1.0"} diff --git a/audit/decision-trace.jsonl b/audit/decision-trace.jsonl new file mode 100644 index 0000000..e69de29 diff --git a/audit/trust-dial-state.json b/audit/trust-dial-state.json new file mode 100644 index 0000000..4536a48 --- /dev/null +++ b/audit/trust-dial-state.json @@ -0,0 +1,10 @@ +{ + "_schema_version": "1.0.0", + "tier": "0", + "clean_merge_streak": 0, + "consecutive_clean_cycles": 0, + "regression_events": [], + "demotion_history": [], + "last_cycle_id": null, + "cycle_merge_count": 0 +} diff --git a/conformance/affects.json b/conformance/affects.json new file mode 100644 index 0000000..7e446fb --- /dev/null +++ b/conformance/affects.json @@ -0,0 +1,223 @@ +{ + "blast_radius": { + "_schema_version": "1.0.0", + "_description": "Blast-radius pulse affects manifest. Each entry declares: when a file matching when_changed is in the git diff, the listed affects globs are in the blast radius, and required_actions are owed before the change is acceptable. The conformance.blast_radius Rego package consumes this file as `data.blast_radius.affects` (see docs/adr/ADR-002-blast-radius-pulse.md). Severity: error = block (CI fail / lefthook reject); warning = report only.", + "_conventions": { + "when_changed": "Glob, repo-relative, POSIX. Range syntax allowed (e.g. 'conformance/data.json#schema.artifact_types' marks a logical key inside a JSON file; the pulse engine treats anything after '#' as a json-pointer-style sub-target the diff scanner inspects).", + "affects": "Array of globs that must be checked / updated when when_changed fires.", + "required_actions": "Ordered, imperative, single-sentence strings. The author of the change must mark each as DONE in the commit message footer (Pulse-Action: DONE) or the pre-commit hook rejects.", + "reason": "One-sentence rationale that ends up in the PR comment.", + "severity": "'error' blocks; 'warning' reports. The hard block also requires verifiable: true (see below).", + "verifiable": "Boolean. When true, the required_actions for this entry have a deterministic post-condition the pulse engine (or a CI step) can programmatically verify; the entry can BLOCK on owed actions. When false, the required_actions are advisory — owed actions downgrade to a warning even at error severity. See docs/agents/enforcement.md for the per-entry classification." + }, + "affects": [ + { + "id": "BR-001-conformance-rego", + "when_changed": "conformance/conformance.rego", + "affects": [ + "conformance/data.json", + "docs/agents/enforcement.md" + ], + "reason": "Editing the policy invalidates the SHA-256 self-tamper digest pinned at conformance/data.json:95 and the policy must be documented in docs/agents/enforcement.md per AGENTS.md frozen-content discipline.", + "required_actions": [ + "recompute SHA-256 of conformance/conformance.rego (sha256sum on Linux; shasum -a 256 on macOS)", + "update conformance/data.json policy_integrity.expected_digest with the new digest in the same commit", + "document the new or changed deny family in docs/agents/enforcement.md (rule name, severity, trigger condition)" + ], + "severity": "error", + "verifiable": true + }, + { + "id": "BR-002-artifact-types-triplicate", + "when_changed": "conformance/data.json#schema.artifact_types", + "affects": [ + "scripts/bootstrap.sh", + ".github/workflows/conformance.yml" + ], + "reason": "G-05 — the artifact-type set is the canonical example of a triplicated source of truth. The Rego policy reads it from data.json, but scripts/bootstrap.sh:115-118 hardcodes a parallel case statement and .github/workflows/conformance.yml documents it in the workflow_call inputs description. Adding or renaming an artifact type without updating all three sites silently breaks bootstrap or CI.", + "required_actions": [ + "update the artifact-type case statement at scripts/bootstrap.sh:115-118 to include the new value", + "update the artifact-type default-dir+default-validator case at scripts/bootstrap.sh:157-162", + "update the artifact_type input description and any per-type matrix entries in .github/workflows/conformance.yml", + "update conformance/data.json schema.artifact_type_files map with default_dir + primary_validator for the new type" + ], + "severity": "error", + "verifiable": false + }, + { + "id": "BR-003-required-files", + "when_changed": "conformance/data.json#schema.required_files", + "affects": [ + "template/_files/**", + "scripts/bootstrap.sh", + "CHANGELOG.md" + ], + "reason": "Adding a required file is a MAJOR semver bump per AGENTS.md conventions and only takes effect if template/_files/ provides a templatized copy that bootstrap.sh can stamp. Without a template copy the bootstrap conformance self-check (scripts/bootstrap.sh:241-258) fails on every freshly stamped repo.", + "required_actions": [ + "add template/_files/ with appropriate token coverage ({{REPO_NAME}}, {{OWNER}}, etc.)", + "verify scripts/bootstrap.sh token substitution covers any new tokens introduced in the template", + "add a CHANGELOG.md entry under a MAJOR version bump describing the new required file" + ], + "severity": "error", + "verifiable": false + }, + { + "id": "BR-004-agents-md", + "when_changed": "AGENTS.md", + "affects": [ + "conformance/conformance.rego", + "conformance/data.json" + ], + "reason": "AGENTS.md is the Tier-1 agent entry point and is length-capped at content_assertions.agents_md_max_lines (conformance/data.json:85, currently 150). Edits must respect the cap; if the cap needs to change, both the rule (conformance.rego:146-156) and the manifest value require coordinated update.", + "required_actions": [ + "verify AGENTS.md line count is <= conformance/data.json:content_assertions.agents_md_max_lines", + "if raising the cap, update conformance/data.json:content_assertions.agents_md_max_lines and document the rationale in docs/agents/enforcement.md" + ], + "severity": "warning", + "verifiable": true + }, + { + "id": "BR-005-claude-md", + "when_changed": "CLAUDE.md", + "affects": [ + "conformance/conformance.rego", + "conformance/data.json" + ], + "reason": "CLAUDE.md must be <= content_assertions.claude_md_max_lines (currently 80) AND its first content line must equal content_assertions.claude_md_first_content_line ('@AGENTS.md'). Both rules live at conformance.rego:158-182 and assert against conformance/data.json:86-87. Edits that violate either invariant block CI.", + "required_actions": [ + "verify CLAUDE.md line count is <= conformance/data.json:content_assertions.claude_md_max_lines", + "verify CLAUDE.md first non-blank, non-comment line equals conformance/data.json:content_assertions.claude_md_first_content_line", + "if changing either invariant, update conformance/data.json and document in docs/agents/enforcement.md" + ], + "severity": "error", + "verifiable": true + }, + { + "id": "BR-006-new-rego-policy", + "when_changed": "conformance/*.rego", + "affects": [ + "conformance/*_test.rego", + "docs/agents/enforcement.md", + "conformance/data.json" + ], + "reason": "Every Rego policy file in this repo has a sibling *_test.rego (see conformance/conformance_test.rego, conformance/trust_dial_test.rego). A new policy without a test is by definition undeterministic — its behavior is not proven. The enforcement doc and manifest may also need updates if the policy adds required-files assertions.", + "required_actions": [ + "create a sibling _test.rego with at least one positive and one negative case per rule", + "run `opa test conformance/` and confirm 100% of the new policy's rules are exercised", + "document the policy package, deny families, and intended scope in docs/agents/enforcement.md", + "if the policy declares new required-files or content-assertions, add them to conformance/data.json" + ], + "severity": "error", + "verifiable": false + }, + { + "id": "BR-007-trust-dial-manifest", + "when_changed": "conformance/trust_dial.rego", + "affects": [ + "conformance/trust_dial_data.json", + "conformance/trust_dial_test.rego", + "template/_files/conformance/trust_dial.rego", + "template/_files/conformance/trust_dial_data.json", + "template/_files/conformance/trust_dial_test.rego", + "audit/trust-dial-state.json" + ], + "reason": "The trust-dial verdict policy is a pure function over (state file x verdict matrix x Dependabot inputs). Changing the policy without updating the matrix data, the test suite, the templatized mirrors, or the state-file schema introduces a silent skew between bootstrapped repos and the reference repo. ADR-001 mandates these be edited as a single unit.", + "required_actions": [ + "update conformance/trust_dial_data.json if the change introduces new matrix cells or budget keys", + "update conformance/trust_dial_test.rego with cases covering the new behavior", + "mirror the change in template/_files/conformance/trust_dial.rego (and the data + test siblings)", + "if the state-file schema changes, update audit/trust-dial-state.json and document the migration in docs/agents/enforcement.md" + ], + "severity": "error", + "verifiable": false + }, + { + "id": "BR-008-templatized-required-file", + "when_changed": "template/_files/**", + "affects": [ + "conformance/data.json" + ], + "reason": "Files under template/_files/ are the canonical templatized copies stamped into bootstrapped repos. If the templatized path corresponds to a required-file entry in conformance/data.json:schema.required_files, the templatized content must satisfy whatever content_assertions apply (e.g. CLAUDE.md must start with @AGENTS.md after token substitution).", + "required_actions": [ + "if the templatized path is a required file, dry-run bash scripts/bootstrap.sh --out /tmp/pulse-check and confirm the conformance self-check at bootstrap.sh:241-258 still passes", + "verify any tokens introduced in the template are covered by the substitution list at scripts/bootstrap.sh:182-194" + ], + "severity": "warning", + "verifiable": false + }, + { + "id": "BR-009-conformance-workflow", + "when_changed": ".github/workflows/conformance.yml", + "affects": [ + "docs/agents/enforcement.md" + ], + "reason": "conformance.yml is the reusable workflow consumer repos invoke at a pinned SHA. Changing its interface (inputs, outputs, steps) is a contract change consumers cannot detect at call time. docs/agents/enforcement.md owns the versioning + pinned-SHA story and must be updated whenever the contract moves.", + "required_actions": [ + "document the workflow change in docs/agents/enforcement.md, including the new pinned SHA and the consumer migration path", + "if the change is breaking (input rename, removed step), bump the MAJOR version in CHANGELOG.md and tag a release" + ], + "severity": "warning", + "verifiable": false + }, + { + "id": "BR-010-scripts-coverage", + "when_changed": "scripts/**", + "affects": [ + "docs/agents/enforcement.md", + "scripts/**" + ], + "reason": "Any change to a script under scripts/ may shift the operational contract the repository documents in docs/agents/enforcement.md (preflight commands, validator invocations, conformance scan shape). This entry guarantees every scripts/** path is reachable from at least one manifest entry and surfaces an advisory whenever a script changes.", + "required_actions": [ + "review docs/agents/enforcement.md for documentation that references the changed script's invocation", + "verify shellcheck -x reports no new findings against the changed script" + ], + "severity": "warning", + "verifiable": true + }, + { + "id": "BR-011-affects-manifest", + "when_changed": "conformance/affects.json", + "affects": [ + "conformance/affects.json", + "conformance/blast_radius.rego", + "conformance/blast_radius_test.rego", + "conformance/README.md", + "docs/agents/enforcement.md" + ], + "reason": "The affects manifest itself is a load-bearing artifact. Editing it changes which cross-file relationships the pulse enforces; every entry must be covered by a sibling test case in blast_radius_test.rego, and any policy change must be documented.", + "required_actions": [ + "if adding or renaming a manifest entry, add a positive and a cleared test case in conformance/blast_radius_test.rego", + "document the new or changed manifest entry in docs/agents/enforcement.md" + ], + "severity": "error", + "verifiable": true + }, + { + "id": "BR-012-docs-agents-coverage", + "when_changed": "docs/agents/**", + "affects": [ + "docs/agents/**" + ], + "reason": "docs/agents/** is the Tier-2 agent reference set. This entry guarantees every file there is reachable from the manifest (the affects_manifest_complete deny family requires it) and surfaces an advisory whenever Tier-2 prose changes.", + "required_actions": [ + "verify the change is consistent with the conventions documented in docs/agents/conventions.md" + ], + "severity": "warning", + "verifiable": false + }, + { + "id": "BR-013-template-coverage", + "when_changed": "template/**", + "affects": [ + "template/**" + ], + "reason": "template/** is the canonical scaffold that scripts/bootstrap.sh stamps into freshly bootstrapped repos. This entry guarantees every file there is reachable from the manifest (the affects_manifest_complete deny family requires it).", + "required_actions": [ + "if the file is templatized into a required-file path, dry-run bash scripts/bootstrap.sh and confirm the self-check still passes (see BR-008)" + ], + "severity": "warning", + "verifiable": false + } + ] + } +} diff --git a/conformance/blast_radius.rego b/conformance/blast_radius.rego new file mode 100644 index 0000000..9c9a321 --- /dev/null +++ b/conformance/blast_radius.rego @@ -0,0 +1,245 @@ +# METADATA +# title: blast-radius pulse verdict policy +# description: | +# Pure deterministic blast-radius function. Consumes a change set (the git +# diff'd file paths, plus optional JSON sub-target diffs and a set of +# commit-footer-declared DONE actions) as `input`, and the affects manifest +# (conformance/affects.json) as `data.blast_radius.affects`. Emits exactly +# one verdict structure carrying the fired entries, owed actions, and +# aggregate counts. +# +# The policy is a PURE function: no clock, no network, no filesystem read, +# no opa.runtime. Every input is in `input`; every threshold and relationship +# is in `data.blast_radius`. This is what makes `opa test` a proof of +# determinism. +package conformance.blast_radius + +import rego.v1 + +# --------------------------------------------------------------------------- +# Data shortcut (the affects manifest, conformance/affects.json) +# --------------------------------------------------------------------------- + +_matrix := data.blast_radius.affects + +# --------------------------------------------------------------------------- +# Input shortcuts +# --------------------------------------------------------------------------- + +_paths := input.changed_files + +_json_changes := object.get(input, "json_changes", {}) + +_done := {a | some a in object.get(input, "commit_footer_actions_done", [])} + +# --------------------------------------------------------------------------- +# Glob helpers +# --------------------------------------------------------------------------- + +# Strip any `#sub.target` suffix off a `when_changed` pattern; the path-level +# match operates on the bare path glob and the sub-target gate is a separate +# predicate (`_json_subtarget_match`). +_strip_subtarget(pattern) := before if { + contains(pattern, "#") + before := split(pattern, "#")[0] +} + +_strip_subtarget(pattern) := pattern if { + not contains(pattern, "#") +} + +# `**` matches any number of path segments, `*` matches a single segment. +# Implementation uses OPA's `glob.match` with `/` as a separator and the +# wildcard set {"**", "*"} so `template/_files/**` does what an editor expects. +_glob_match(pattern, path) if { + glob.match(pattern, ["/"], path) +} + +# Exact match — falls through when glob.match returns false but the pattern is +# a literal path (no wildcards). +_glob_match(pattern, path) if { + not contains(pattern, "*") + pattern == path +} + +# A `when_changed` value matches a changed path when: +# * the bare path glob matches, AND +# * if the value carries a `#json.subtarget` suffix, the input.json_changes +# for that path actually contains that subtarget. +_when_changed_matches(entry, path) if { + bare := _strip_subtarget(entry.when_changed) + _glob_match(bare, path) + _json_subtarget_match(entry, path) +} + +# Sub-target gate: when no `#` is present, the gate is open (true for all +# matching paths). When `#` IS present, the suffix must appear in the +# `input.json_changes[path]` array. +_json_subtarget_match(entry, _) if { + not contains(entry.when_changed, "#") +} + +_json_subtarget_match(entry, path) if { + contains(entry.when_changed, "#") + target := split(entry.when_changed, "#")[1] + changes := object.get(_json_changes, path, []) + target in changes +} + +# --------------------------------------------------------------------------- +# Per-entry verdict +# --------------------------------------------------------------------------- + +# An entry "triggers" if any of its when_changed patterns matches any of the +# changed paths AND the sub-target gate (if any) is open. +_entry_triggers(entry) if { + some path in _paths + _when_changed_matches(entry, path) +} + +# Which of the entry.affects globs are *present in the diff* (the editor +# already touched them — counts as half-credit toward the required actions)? +_affected_present(entry) := [p | + some p in _paths + some pattern in entry.affects + _glob_match(pattern, p) +] + +# Which of the entry.affects globs do NOT appear in the diff? +_affected_missing(entry) := [pattern | + some pattern in entry.affects + not _any_path_matches(pattern) +] + +_any_path_matches(pattern) if { + some path in _paths + _glob_match(pattern, path) +} + +# Build the per-required-action record. `done` is true when the action id +# appears in the commit-footer DONE set. +_action_record(entry, idx, text) := { + "id": sprintf("%s-%d", [entry.id, idx + 1]), + "text": text, + "done": sprintf("%s-%d", [entry.id, idx + 1]) in _done, +} + +_required_actions(entry) := [_action_record(entry, idx, text) | + some idx, text in entry.required_actions +] + +# Count of un-done required actions. +_owed_count(entry) := count([a | + some a in _required_actions(entry) + a.done == false +]) + +# --------------------------------------------------------------------------- +# Surface: fired entries +# --------------------------------------------------------------------------- + +# The structured `fired` list — one element per entry whose when_changed +# matched a path in the diff. +fired contains f if { + some entry in _matrix + _entry_triggers(entry) + f := { + "id": entry.id, + "trigger": entry.when_changed, + "affects": entry.affects, + "reason": entry.reason, + "required_actions": _required_actions(entry), + "severity": entry.severity, + "verifiable": object.get(entry, "verifiable", false), + "affected_present_in_diff": _affected_present(entry), + "affected_missing_from_diff": _affected_missing(entry), + "owed_count": _owed_count(entry), + } +} + +# --------------------------------------------------------------------------- +# Surface: aggregate counts +# --------------------------------------------------------------------------- +# Verifiable/unverifiable split — an entry's required_actions are "verifiable" +# when there is a deterministic post-condition the pulse (or CI) can check +# programmatically. Only verifiable=true error-severity entries with owed +# actions can BLOCK; verifiable=false errors and warning-severity entries +# downgrade to warnings (advisory). This keeps the gate honest: a blocked +# verdict means a machine-checkable invariant is owed, not just an advisory +# checklist. See docs/agents/enforcement.md for the per-entry classification. +# An entry is verifiable when entry.verifiable == true (explicit opt-in). +# Missing field defaults to false (advisory). +# --------------------------------------------------------------------------- + +errors := count([f | + some f in fired + f.severity == "error" + f.verifiable == true + f.owed_count > 0 +]) + +# Warnings include EVERY fired entry with owed actions that is NOT counted as +# an error (i.e. severity == "warning" OR verifiable == false). Severity is +# not the only axis any more — verifiability is the gate; severity is the +# author's intent. Both must align for a hard block. +warnings := count([f | + some f in fired + f.owed_count > 0 + not _is_error(f) +]) + +_is_error(f) if { + f.severity == "error" + f.verifiable == true +} + +# --------------------------------------------------------------------------- +# Surface: verdict +# --------------------------------------------------------------------------- +# `clear` = no entry fired with any owed action. +# `owed` = at least one entry fired with owed actions, but none of them are +# both error-severity AND verifiable=true (everything advisory). +# `blocked` = at least one verifiable=true error-severity entry fired with +# owed actions. +# --------------------------------------------------------------------------- + +default verdict := "clear" + +verdict := "blocked" if { + errors > 0 +} + +verdict := "owed" if { + errors == 0 + warnings > 0 +} + +# --------------------------------------------------------------------------- +# Surface: allow — convenience boolean. True when verdict != blocked. +# --------------------------------------------------------------------------- + +default allow := false + +allow if verdict != "blocked" + +# --------------------------------------------------------------------------- +# Surface: result — the full structured record returned by `opa eval`. +# Carries the four whitepaper-mandated fields: inputs, rule_applied, +# alternatives, rationale. +# --------------------------------------------------------------------------- + +result := { + "verdict": verdict, + "fired": fired, + "errors": errors, + "warnings": warnings, + "git_sha": object.get(input, "git_sha", ""), + "mode": object.get(input, "mode", ""), + "inputs": input, + "rule_applied": "affects_manifest", + "alternatives": ["clear", "owed", "blocked"], + "rationale": sprintf( + "changed=%d fired=%d errors=%d warnings=%d -> %s", + [count(_paths), count(fired), errors, warnings, verdict], + ), +} diff --git a/conformance/blast_radius_test.rego b/conformance/blast_radius_test.rego new file mode 100644 index 0000000..d3cabc6 --- /dev/null +++ b/conformance/blast_radius_test.rego @@ -0,0 +1,477 @@ +# METADATA +# title: blast-radius pulse — determinism test suite +# description: | +# Enumerates every seed relationship in conformance/affects.json (BR-001 +# through BR-009) plus the empty-diff baseline plus the +# commit_footer_actions_done coverage matrix plus sub-target gating. +# +# The matrix is loaded from conformance/affects.json exactly as in +# production; the tests vary `input` only (same convention as +# trust_dial_test.rego). This is what makes the suite a proof that +# blast_radius.rego is a pure deterministic function of (input, data). +package conformance.blast_radius_test + +import data.conformance.blast_radius +import rego.v1 + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_input(changed, json_changes, done) := { + "changed_files": changed, + "json_changes": json_changes, + "commit_footer_actions_done": done, + "git_sha": "abc123", + "mode": "live", +} + +_fired_ids(result) := {f.id | some f in result.fired} + +# --------------------------------------------------------------------------- +# Baseline — empty diff fires no entries and is clear. +# --------------------------------------------------------------------------- + +test_no_diff_returns_clear if { + result := blast_radius.result with input as _input([], {}, []) + result.verdict == "clear" + count(result.fired) == 0 + result.errors == 0 + result.warnings == 0 +} + +# --------------------------------------------------------------------------- +# BR-001 — editing conformance/conformance.rego requires digest refreeze. +# --------------------------------------------------------------------------- + +test_br001_fires_on_policy_edit if { + result := blast_radius.result with input as _input( + ["conformance/conformance.rego"], {}, [], + ) + "BR-001-conformance-rego" in _fired_ids(result) + result.verdict == "blocked" +} + +test_br001_clears_when_all_actions_done if { + # Editing conformance/conformance.rego also matches BR-006 (conformance/*.rego); + # both rules' actions must be declared DONE for the verdict to clear. + result := blast_radius.result with input as _input( + ["conformance/conformance.rego"], + {}, + [ + "BR-001-conformance-rego-1", + "BR-001-conformance-rego-2", + "BR-001-conformance-rego-3", + "BR-006-new-rego-policy-1", + "BR-006-new-rego-policy-2", + "BR-006-new-rego-policy-3", + "BR-006-new-rego-policy-4", + ], + ) + result.verdict == "clear" + result.errors == 0 +} + +# --------------------------------------------------------------------------- +# BR-002 — artifact-types triplicate (sub-target gate). +# --------------------------------------------------------------------------- + +test_br002_fires_on_artifact_types_subtarget if { + # BR-002 is verifiable=false — it fires but downgrades from error to + # warning. Verdict is "owed" (advisory), not "blocked". + result := blast_radius.result with input as _input( + ["conformance/data.json"], + {"conformance/data.json": ["schema.artifact_types"]}, + [], + ) + "BR-002-artifact-types-triplicate" in _fired_ids(result) + result.verdict == "owed" + result.errors == 0 +} + +test_br002_does_not_fire_on_unrelated_subtarget if { + # Editing _schema_version must NOT trigger BR-002 (sub-target gate works). + result := blast_radius.result with input as _input( + ["conformance/data.json"], + {"conformance/data.json": ["_schema_version"]}, + [], + ) + not "BR-002-artifact-types-triplicate" in _fired_ids(result) +} + +test_br002_clears_when_all_actions_done if { + result := blast_radius.result with input as _input( + ["conformance/data.json"], + {"conformance/data.json": ["schema.artifact_types"]}, + [ + "BR-002-artifact-types-triplicate-1", + "BR-002-artifact-types-triplicate-2", + "BR-002-artifact-types-triplicate-3", + "BR-002-artifact-types-triplicate-4", + ], + ) + result.verdict == "clear" +} + +# --------------------------------------------------------------------------- +# BR-003 — required-files subtarget triggers template + bootstrap actions. +# --------------------------------------------------------------------------- + +test_br003_fires_on_required_files_change if { + # BR-003 is verifiable=false — bootstrap smoke test is not auto-checked. + # Fires as a warning; verdict is "owed". + result := blast_radius.result with input as _input( + ["conformance/data.json"], + {"conformance/data.json": ["schema.required_files"]}, + [], + ) + "BR-003-required-files" in _fired_ids(result) + result.verdict == "owed" + result.errors == 0 +} + +test_br003_clears_when_all_actions_done if { + result := blast_radius.result with input as _input( + ["conformance/data.json"], + {"conformance/data.json": ["schema.required_files"]}, + [ + "BR-003-required-files-1", + "BR-003-required-files-2", + "BR-003-required-files-3", + ], + ) + result.verdict == "clear" +} + +# --------------------------------------------------------------------------- +# BR-004 — AGENTS.md length (warning severity). +# --------------------------------------------------------------------------- + +test_br004_fires_on_agents_md_edit_as_warning if { + result := blast_radius.result with input as _input( + ["AGENTS.md"], {}, [], + ) + "BR-004-agents-md" in _fired_ids(result) + # Warning does not block. + result.verdict == "owed" + result.errors == 0 + result.warnings == 1 +} + +test_br004_clears_when_actions_done if { + result := blast_radius.result with input as _input( + ["AGENTS.md"], {}, + ["BR-004-agents-md-1", "BR-004-agents-md-2"], + ) + result.verdict == "clear" +} + +# --------------------------------------------------------------------------- +# BR-005 — CLAUDE.md invariants (error severity). +# --------------------------------------------------------------------------- + +test_br005_fires_on_claude_md_edit if { + result := blast_radius.result with input as _input( + ["CLAUDE.md"], {}, [], + ) + "BR-005-claude-md" in _fired_ids(result) + result.verdict == "blocked" +} + +test_br005_clears_when_actions_done if { + result := blast_radius.result with input as _input( + ["CLAUDE.md"], {}, + [ + "BR-005-claude-md-1", + "BR-005-claude-md-2", + "BR-005-claude-md-3", + ], + ) + result.verdict == "clear" +} + +# --------------------------------------------------------------------------- +# BR-006 — a NEW .rego file in conformance/ requires a sibling test. +# --------------------------------------------------------------------------- + +test_br006_fires_on_new_rego if { + # BR-006 is verifiable=false — OPA coverage parsing is fragile, so the + # entry is advisory. Fires as a warning; verdict is "owed". + result := blast_radius.result with input as _input( + ["conformance/new_policy.rego"], {}, [], + ) + "BR-006-new-rego-policy" in _fired_ids(result) + result.verdict == "owed" + result.errors == 0 +} + +test_br006_clears_when_actions_done if { + result := blast_radius.result with input as _input( + ["conformance/new_policy.rego"], {}, + [ + "BR-006-new-rego-policy-1", + "BR-006-new-rego-policy-2", + "BR-006-new-rego-policy-3", + "BR-006-new-rego-policy-4", + ], + ) + result.verdict == "clear" +} + +# --------------------------------------------------------------------------- +# BR-007 — trust_dial.rego edits force matrix + test + template mirror updates. +# --------------------------------------------------------------------------- + +test_br007_fires_on_trust_dial_edit if { + # BR-007 is verifiable=false; BR-006 (also matches conformance/*.rego) + # is also verifiable=false. Both fire as warnings; verdict is "owed". + result := blast_radius.result with input as _input( + ["conformance/trust_dial.rego"], {}, [], + ) + "BR-007-trust-dial-manifest" in _fired_ids(result) + result.verdict == "owed" + result.errors == 0 +} + +test_br007_clears_when_actions_done if { + # trust_dial.rego also matches BR-006 (conformance/*.rego). + result := blast_radius.result with input as _input( + ["conformance/trust_dial.rego"], {}, + [ + "BR-007-trust-dial-manifest-1", + "BR-007-trust-dial-manifest-2", + "BR-007-trust-dial-manifest-3", + "BR-007-trust-dial-manifest-4", + "BR-006-new-rego-policy-1", + "BR-006-new-rego-policy-2", + "BR-006-new-rego-policy-3", + "BR-006-new-rego-policy-4", + ], + ) + result.verdict == "clear" +} + +# --------------------------------------------------------------------------- +# BR-008 — edits under template/_files/** are advisory (warning severity). +# --------------------------------------------------------------------------- + +test_br008_fires_on_template_edit_as_warning if { + result := blast_radius.result with input as _input( + ["template/_files/AGENTS.md"], {}, [], + ) + "BR-008-templatized-required-file" in _fired_ids(result) + result.verdict == "owed" +} + +test_br008_clears_when_actions_done if { + # template/_files/AGENTS.md also matches BR-013 (template/**). + result := blast_radius.result with input as _input( + ["template/_files/AGENTS.md"], {}, + [ + "BR-008-templatized-required-file-1", + "BR-008-templatized-required-file-2", + "BR-013-template-coverage-1", + ], + ) + result.verdict == "clear" +} + +# --------------------------------------------------------------------------- +# BR-009 — conformance.yml contract change (warning severity). +# --------------------------------------------------------------------------- + +test_br009_fires_on_conformance_yml_edit if { + result := blast_radius.result with input as _input( + [".github/workflows/conformance.yml"], {}, [], + ) + "BR-009-conformance-workflow" in _fired_ids(result) + result.verdict == "owed" +} + +test_br009_clears_when_actions_done if { + result := blast_radius.result with input as _input( + [".github/workflows/conformance.yml"], {}, + [ + "BR-009-conformance-workflow-1", + "BR-009-conformance-workflow-2", + ], + ) + result.verdict == "clear" +} + +# --------------------------------------------------------------------------- +# Sub-target gate: a JSON edit with no json_changes entry must NOT fire the +# subtarget-gated rules (BR-002, BR-003). +# --------------------------------------------------------------------------- + +test_subtarget_gate_closed_when_no_json_changes if { + result := blast_radius.result with input as _input( + ["conformance/data.json"], {}, [], + ) + not "BR-002-artifact-types-triplicate" in _fired_ids(result) + not "BR-003-required-files" in _fired_ids(result) +} + +# --------------------------------------------------------------------------- +# Determinism: identical (input, data) -> identical verdict, twice. +# --------------------------------------------------------------------------- + +test_deterministic_same_input_same_verdict if { + in1 := _input( + ["conformance/conformance.rego"], {}, [], + ) + v1 := blast_radius.result.verdict with input as in1 + v2 := blast_radius.result.verdict with input as in1 + v1 == v2 + v1 == "blocked" +} + +# --------------------------------------------------------------------------- +# Severity mixing: a fired warning alongside a fired-and-cleared error +# downgrades from blocked to owed. +# --------------------------------------------------------------------------- + +test_warning_does_not_block if { + # Touch AGENTS.md only (warning severity); no error-severity rule fires. + result := blast_radius.result with input as _input( + ["AGENTS.md"], {}, [], + ) + result.verdict == "owed" + blast_radius.allow with input as _input(["AGENTS.md"], {}, []) +} + +# --------------------------------------------------------------------------- +# rationale string carries the four whitepaper-mandated fields. +# --------------------------------------------------------------------------- + +test_result_carries_rule_applied if { + result := blast_radius.result with input as _input([], {}, []) + result.rule_applied == "affects_manifest" +} + +test_result_carries_alternatives if { + result := blast_radius.result with input as _input([], {}, []) + result.alternatives == ["clear", "owed", "blocked"] +} + +test_result_carries_rationale if { + # conformance/conformance.rego matches BR-001 (verifiable=true error) AND + # BR-006 (verifiable=false error → counted as warning). 2 fired, 1 error, + # 1 warning, verdict blocked. + result := blast_radius.result with input as _input( + ["conformance/conformance.rego"], {}, [], + ) + result.rationale == "changed=1 fired=2 errors=1 warnings=1 -> blocked" +} + +# --------------------------------------------------------------------------- +# Verifiable/unverifiable split — Option-D verdict semantics. +# +# Only a fired entry that is BOTH severity=="error" AND verifiable==true with +# owed_count > 0 counts as an error. Everything else with owed_count > 0 +# (severity=="warning" OR verifiable==false) counts as a warning. The verdict +# blocks only on errors. +# --------------------------------------------------------------------------- + +# Positive: a verifiable=true error with owed > 0 BLOCKS. +# CLAUDE.md edit fires BR-005 (severity=error, verifiable=true). One owed +# action remains → blocked. +test_verifiable_error_with_owed_blocks if { + result := blast_radius.result with input as _input( + ["CLAUDE.md"], {}, [], + ) + "BR-005-claude-md" in _fired_ids(result) + result.verdict == "blocked" + result.errors == 1 +} + +# Negative: a verifiable=false error with owed > 0 does NOT block — it +# downgrades to a warning. conformance/data.json with the artifact_types +# sub-target fires BR-002 alone (severity=error, verifiable=false). Verdict +# must be "owed", not "blocked". +test_unverifiable_error_with_owed_does_not_block if { + result := blast_radius.result with input as _input( + ["conformance/data.json"], + {"conformance/data.json": ["schema.artifact_types"]}, + [], + ) + "BR-002-artifact-types-triplicate" in _fired_ids(result) + result.verdict == "owed" + result.errors == 0 + result.warnings == 1 + blast_radius.allow with input as _input( + ["conformance/data.json"], + {"conformance/data.json": ["schema.artifact_types"]}, + [], + ) +} + +# Cleared: a verifiable=true error with all actions footer-DONE has +# owed_count == 0 and does NOT block (verdict clear). CLAUDE.md edit with +# the three BR-005 actions DONE. +test_verifiable_error_cleared_when_all_actions_done if { + result := blast_radius.result with input as _input( + ["CLAUDE.md"], {}, + [ + "BR-005-claude-md-1", + "BR-005-claude-md-2", + "BR-005-claude-md-3", + ], + ) + "BR-005-claude-md" in _fired_ids(result) + result.verdict == "clear" + result.errors == 0 +} + +# Fired record carries the verifiable flag — downstream consumers (the +# pulse.sh PR comment, audit/blast-radius.jsonl) need to render it. +test_fired_record_carries_verifiable_field if { + result := blast_radius.result with input as _input( + ["CLAUDE.md"], {}, [], + ) + some f in result.fired + f.id == "BR-005-claude-md" + f.verifiable == true +} + +# A mixed diff: one verifiable=true error (BR-005) AND one verifiable=false +# error (BR-002 via sub-target). Verdict is blocked from the verifiable error +# alone; the unverifiable error counts as a warning. +test_mixed_verifiable_and_unverifiable_errors if { + result := blast_radius.result with input as _input( + ["CLAUDE.md", "conformance/data.json"], + {"conformance/data.json": ["schema.artifact_types"]}, + [], + ) + result.verdict == "blocked" + result.errors == 1 + result.warnings == 1 +} + +# --------------------------------------------------------------------------- +# BR-011 — editing conformance/affects.json fires the affects-manifest rule +# (verifiable=true error). Actions: add test cases + document in enforcement.md. +# --------------------------------------------------------------------------- + +# Positive: editing conformance/affects.json fires BR-011 and blocks. +test_br011_fires_on_affects_manifest_edit if { + result := blast_radius.result with input as _input( + ["conformance/affects.json"], {}, [], + ) + "BR-011-affects-manifest" in _fired_ids(result) + result.verdict == "blocked" + result.errors == 1 +} + +# Cleared: both BR-011 required actions declared DONE → verdict clear. +test_br011_clears_when_all_actions_done if { + result := blast_radius.result with input as _input( + ["conformance/affects.json"], {}, + [ + "BR-011-affects-manifest-1", + "BR-011-affects-manifest-2", + ], + ) + result.verdict == "clear" + result.errors == 0 +} diff --git a/conformance/trust_dial.rego b/conformance/trust_dial.rego new file mode 100644 index 0000000..f28bd35 --- /dev/null +++ b/conformance/trust_dial.rego @@ -0,0 +1,89 @@ +# METADATA +# title: trust-dial Dependabot auto-merge verdict policy +# description: | +# Pure deterministic verdict function. Consumes a Dependabot PR descriptor +# plus the live trust-dial state as `input`, and the verdict matrix + +# thresholds (trust_dial_data.json) as `data`. Emits exactly one verdict. +# +# The policy is a PURE function: no clock, no network, no filesystem read. +# Every input is in `input`; every threshold is in `data.trust_dial`. +# This is what makes `opa test` a proof of determinism. +package kellerai.oss.trust_dial + +import rego.v1 + +# --------------------------------------------------------------------------- +# Data shortcuts (the trust-dial manifest, trust_dial_data.json) +# --------------------------------------------------------------------------- + +_matrix := data.trust_dial.verdict_matrix + +_budget := data.trust_dial.budget + +# --------------------------------------------------------------------------- +# Ecosystem key — explicit override row, or the "default" row. +# --------------------------------------------------------------------------- + +_eco := input.ecosystem if { + _matrix[input.tier][input.ecosystem] +} + +_eco := "default" if { + not _matrix[input.tier][input.ecosystem] +} + +# --------------------------------------------------------------------------- +# Base verdict — the (tier × ecosystem × update_type) cell. +# --------------------------------------------------------------------------- + +_base := _matrix[input.tier][_eco][input.update_type] + +# --------------------------------------------------------------------------- +# verdict — exactly one of: "auto-merge" | "hold-for-review" | "block". +# Fail-safe default: hold-for-review. Never auto-merge by omission. +# --------------------------------------------------------------------------- + +default verdict := "hold-for-review" + +# Non-auto-merge base verdicts pass through unchanged. +verdict := _base if { + _base != "auto-merge" +} + +# auto-merge survives only when the per-cycle budget has not been exhausted. +verdict := "auto-merge" if { + _base == "auto-merge" + input.cycle_merge_count < _budget.max_auto_merges_per_cycle +} + +# auto-merge downgrades to hold-for-review once the budget is exhausted. +verdict := "hold-for-review" if { + _base == "auto-merge" + input.cycle_merge_count >= _budget.max_auto_merges_per_cycle +} + +# --------------------------------------------------------------------------- +# rationale — a single string written verbatim into the decision trace. +# --------------------------------------------------------------------------- + +rationale := sprintf( + "tier=%s ecosystem=%s update_type=%s base=%s cycle=%d/%d -> %s", + [ + input.tier, _eco, input.update_type, _base, + input.cycle_merge_count, _budget.max_auto_merges_per_cycle, verdict, + ], +) + +# --------------------------------------------------------------------------- +# decision — the full decision record, surfaced for trace emission. +# Carries the four whitepaper-mandated fields: inputs, rule_applied, +# alternatives, rationale. +# --------------------------------------------------------------------------- + +decision := { + "verdict": verdict, + "rationale": rationale, + "inputs": input, + "rule_applied": "verdict_matrix", + "alternatives": ["auto-merge", "hold-for-review", "block"], +} diff --git a/conformance/trust_dial_data.json b/conformance/trust_dial_data.json new file mode 100644 index 0000000..b287015 --- /dev/null +++ b/conformance/trust_dial_data.json @@ -0,0 +1,52 @@ +{ + "trust_dial": { + "tiers": ["Observed", "Assisted", "Supervised", "Trusted"], + "default_tier": "Observed", + "verdict_matrix": { + "Observed": { + "default": { + "version-update:semver-patch": "hold-for-review", + "version-update:semver-minor": "hold-for-review", + "version-update:semver-major": "hold-for-review" + } + }, + "Assisted": { + "default": { + "version-update:semver-patch": "auto-merge", + "version-update:semver-minor": "hold-for-review", + "version-update:semver-major": "block" + } + }, + "Supervised": { + "default": { + "version-update:semver-patch": "auto-merge", + "version-update:semver-minor": "auto-merge", + "version-update:semver-major": "hold-for-review" + } + }, + "Trusted": { + "default": { + "version-update:semver-patch": "auto-merge", + "version-update:semver-minor": "auto-merge", + "version-update:semver-major": "hold-for-review" + } + } + }, + "promotion": { + "Observed->Assisted": { "clean_streak_required": 5 }, + "Assisted->Supervised": { "clean_streak_required": 10 }, + "Supervised->Trusted": { "clean_streak_required": 20 } + }, + "budget": { + "max_auto_merges_per_cycle": 5, + "cycle": "weekly" + }, + "circuit_breaker": { + "regression_threshold": 3, + "window_cycles": 4 + }, + "bake": { + "consecutive_clean_cycles_required": 3 + } + } +} diff --git a/conformance/trust_dial_test.rego b/conformance/trust_dial_test.rego new file mode 100644 index 0000000..1f55ee0 --- /dev/null +++ b/conformance/trust_dial_test.rego @@ -0,0 +1,153 @@ +# METADATA +# title: trust-dial verdict policy — determinism test suite +# description: | +# Enumerates the verdict matrix (4 tiers × 3 update-types × budget states) +# plus fail-safe cases. A passing run is a proof that trust_dial.rego is a +# pure deterministic function of (input, data). +# +# The thresholds and matrix are loaded from conformance/trust_dial_data.json +# exactly as in production; the tests vary `input` only. This is the same +# convention conformance_test.rego uses — and is what allows OPA to compile +# without spurious cross-package recursion warnings. +package kellerai.oss.trust_dial_test + +import data.kellerai.oss.trust_dial +import rego.v1 + +# --------------------------------------------------------------------------- +# Input fixture — every test passes a variation of this descriptor. +# --------------------------------------------------------------------------- + +_input(tier, eco, update_type, cycle_n) := { + "tier": tier, + "ecosystem": eco, + "update_type": update_type, + "dependency": "actions/checkout", + "from_version": "4.1.0", + "to_version": "4.2.0", + "cycle_merge_count": cycle_n, + "pr_number": 42, + "pr_actor": "dependabot[bot]", +} + +# --------------------------------------------------------------------------- +# Observed — every cell is hold-for-review. +# --------------------------------------------------------------------------- + +test_observed_patch_holds if { + trust_dial.verdict == "hold-for-review" with input as _input("Observed", "github-actions", "version-update:semver-patch", 0) +} + +test_observed_minor_holds if { + trust_dial.verdict == "hold-for-review" with input as _input("Observed", "github-actions", "version-update:semver-minor", 0) +} + +test_observed_major_holds if { + trust_dial.verdict == "hold-for-review" with input as _input("Observed", "github-actions", "version-update:semver-major", 0) +} + +# --------------------------------------------------------------------------- +# Assisted — patch auto-merges (within budget); minor holds; major blocks. +# --------------------------------------------------------------------------- + +test_assisted_patch_auto if { + trust_dial.verdict == "auto-merge" with input as _input("Assisted", "github-actions", "version-update:semver-patch", 0) +} + +test_assisted_patch_budget_exhausted_holds if { + trust_dial.verdict == "hold-for-review" with input as _input("Assisted", "github-actions", "version-update:semver-patch", 5) +} + +test_assisted_minor_holds if { + trust_dial.verdict == "hold-for-review" with input as _input("Assisted", "github-actions", "version-update:semver-minor", 0) +} + +test_assisted_major_blocks if { + trust_dial.verdict == "block" with input as _input("Assisted", "github-actions", "version-update:semver-major", 0) +} + +# --------------------------------------------------------------------------- +# Supervised — patch + minor auto-merge; major holds. +# --------------------------------------------------------------------------- + +test_supervised_patch_auto if { + trust_dial.verdict == "auto-merge" with input as _input("Supervised", "github-actions", "version-update:semver-patch", 0) +} + +test_supervised_minor_auto if { + trust_dial.verdict == "auto-merge" with input as _input("Supervised", "github-actions", "version-update:semver-minor", 0) +} + +test_supervised_minor_budget_exhausted_holds if { + trust_dial.verdict == "hold-for-review" with input as _input("Supervised", "github-actions", "version-update:semver-minor", 5) +} + +test_supervised_major_holds if { + trust_dial.verdict == "hold-for-review" with input as _input("Supervised", "github-actions", "version-update:semver-major", 0) +} + +# --------------------------------------------------------------------------- +# Trusted — patch + minor auto-merge; major holds (deliberate ceiling). +# --------------------------------------------------------------------------- + +test_trusted_patch_auto if { + trust_dial.verdict == "auto-merge" with input as _input("Trusted", "github-actions", "version-update:semver-patch", 0) +} + +test_trusted_minor_auto if { + trust_dial.verdict == "auto-merge" with input as _input("Trusted", "github-actions", "version-update:semver-minor", 0) +} + +test_trusted_major_holds if { + trust_dial.verdict == "hold-for-review" with input as _input("Trusted", "github-actions", "version-update:semver-major", 0) +} + +test_trusted_patch_budget_exhausted_holds if { + trust_dial.verdict == "hold-for-review" with input as _input("Trusted", "github-actions", "version-update:semver-patch", 5) +} + +# --------------------------------------------------------------------------- +# Fail-safe: unknown tier → default `hold-for-review` (never auto-merge). +# --------------------------------------------------------------------------- + +test_unknown_tier_falls_back_to_hold if { + trust_dial.verdict == "hold-for-review" with input as _input("Untrusted", "github-actions", "version-update:semver-patch", 0) +} + +# --------------------------------------------------------------------------- +# Ecosystem fallback: an ecosystem with no override falls back to "default". +# --------------------------------------------------------------------------- + +test_unknown_ecosystem_uses_default_row if { + trust_dial.verdict == "auto-merge" with input as _input("Assisted", "npm", "version-update:semver-patch", 0) +} + +# --------------------------------------------------------------------------- +# decision record carries the four whitepaper-mandated fields. +# --------------------------------------------------------------------------- + +test_decision_carries_rule_applied if { + d := trust_dial.decision with input as _input("Observed", "github-actions", "version-update:semver-patch", 0) + d.rule_applied == "verdict_matrix" +} + +test_decision_carries_alternatives if { + d := trust_dial.decision with input as _input("Observed", "github-actions", "version-update:semver-patch", 0) + d.alternatives == ["auto-merge", "hold-for-review", "block"] +} + +test_decision_carries_rationale if { + d := trust_dial.decision with input as _input("Trusted", "github-actions", "version-update:semver-major", 0) + d.rationale == "tier=Trusted ecosystem=default update_type=version-update:semver-major base=hold-for-review cycle=0/5 -> hold-for-review" +} + +# --------------------------------------------------------------------------- +# Determinism: identical (input, data) → identical verdict, twice. +# --------------------------------------------------------------------------- + +test_deterministic_same_input_same_verdict if { + v1 := trust_dial.verdict with input as _input("Supervised", "github-actions", "version-update:semver-minor", 2) + v2 := trust_dial.verdict with input as _input("Supervised", "github-actions", "version-update:semver-minor", 2) + v1 == v2 + v1 == "auto-merge" +} diff --git a/docs/adr/ADR-002-blast-radius-pulse.md b/docs/adr/ADR-002-blast-radius-pulse.md new file mode 100644 index 0000000..7cfdeda --- /dev/null +++ b/docs/adr/ADR-002-blast-radius-pulse.md @@ -0,0 +1,139 @@ +--- +title: "ADR-002 — Blast-radius pulse: deterministic impact analysis for the conformance authority" +status: Proposed +date: 2026-05-22 +--- + +## Context + +The repository is the conformance authority for the OSS governance library. +Its frozen content lives in three locked directories — `conformance/`, `template/`, +`scripts/` — and the conformance policy itself runs an explicit self-tamper check +(`conformance/conformance.rego:264-279`). +The recently landed trust-dial system (`docs/adr/ADR-001-trust-dial-dependabot.md:46-55`) +demonstrated the house pattern for governed, observable loops: a pure Rego function, +an append-only JSONL trace, two GitHub Actions workflows, and a templatized mirror +under `template/_files/`. +The complement is missing. +Trust-dial governs *who is allowed to merge a change*; nothing today governs +*which other files must move when a change is made*. + +The concrete precipitating failure mode is gap **G-05**, a triplicated source of +truth that no machine check covers. +The artifact-type set is declared three times — in the manifest at +`conformance/data.json:51-56`, in the bootstrap case statement at +`scripts/bootstrap.sh:115-118`, and again in the per-type defaults at +`scripts/bootstrap.sh:157-162`, with the workflow_call input description at +`.github/workflows/conformance.yml:18-21` documenting the same set a fourth time. +An editor who adds a new artifact type to `data.json` and forgets the +`bootstrap.sh` case statement will pass `opa test`, pass `opa check`, pass +lefthook, pass CI, push to `main`, and then break every bootstrap call thereafter +with a one-line `die "--artifact-type must be one of..."` exit. +The conformance system that exists to prevent silent drift cannot today detect +its own most obvious silent drift. + +The same shape recurs across the repo. +Editing `conformance/conformance.rego` without refreezing +`conformance/data.json:policy_integrity.expected_digest` is caught by the +policy-integrity rule — but the *requirement to also document the change* in +`docs/agents/enforcement.md` is enforced only by reviewer attention. +Adding a new required file to `conformance/data.json:schema.required_files` +without adding a `template/_files/` copy is caught only when somebody runs +bootstrap and the self-check at `scripts/bootstrap.sh:241-258` trips. +A new `*.rego` file without its sibling `*_test.rego` is caught by no rule at all +— the convention exists only in `AGENTS.md` prose. + +These are the same class of failure: a change in file A requires +acknowledged action on file B, and the requirement lives only in human memory. + +A decision is needed now because trust-dial just established the architectural +pattern and the operational machinery (JSONL trace, OPA-driven verdict, pinned-SHA +workflows, templatized mirrors). +A second policy in the same shape is cheaper to build now, while the pattern is +fresh, than later. +Skipping it leaves the repository unable to detect the next G-05 — and there +are at least eight more pairs in the seed manifest below. + +## Considered options + +| Option | Notes | +| ------ | ----- | +| **A — CODEOWNERS-based reviewer routing** | Add `.github/CODEOWNERS` rules that route PRs touching `conformance/conformance.rego` to a reviewer who knows to ask about the digest refreeze. Cheap. Already partially exists. But it routes *attention*, not *enforcement*: a reviewer must remember the cross-file requirement and apply it manually. Provides no trace, no `opa test`-provable determinism, no machine check for the G-05 case across three files. Rejected — relies on the human memory the system is supposed to replace. | +| **B — `actions/labeler` path-based labels** | Use `.github/labeler.yml` to attach labels like `touches:policy` or `touches:bootstrap` to PRs. Labels are queryable, lightweight, and well-supported by the GitHub Actions ecosystem. But labels are *signals*, not *checks*: a labeled PR with no follow-through still merges, and the labeler config is itself a fourth source of truth no rule covers. No required-action ledger, no commit-time block, no audit trace. Rejected — same enforcement-vs-documentation failure as Option A. | +| **C — Nx-style affected-graph** | Adopt an Nx-style project graph and `affected:*` commands that compute downstream effects from a typed dependency model. Industry-proven in monorepos (Nx, Bazel, Turborepo). But it imports a heavyweight runtime (`node_modules`, a daemon, a project.json per package) that violates the zero-runtime-dependency invariant declared in the spec and inherited from ADR-001. The dependency model is also code-graph oriented (TS imports, Python imports) — not documentation/config-graph oriented, which is the actual problem here. Rejected — wrong tool, wrong invariants. | +| **D — OPA/Rego blast-radius pulse with declarative affects manifest** | A single `conformance/affects.json` declares every cross-file dependency. A pure Rego function (`conformance/blast_radius.rego`, package `conformance.blast_radius`) consumes the git-diff'd file set as `input` and the manifest as `data`, returning a verdict per changed file: the affected globs, the required actions, and a severity. Three surfaces — lefthook pre-commit (live), `scripts/pulse.sh --predict` (predictive), PR-comment workflow + JSONL trace (audited) — all share the same engine. A new `conformance.rego` deny family (`affects_manifest_complete`) forces the manifest to remain honest by denying when tracked files under `conformance/`, `template/`, `scripts/`, `docs/agents/` are not reachable from any affects entry. Higher up-front build cost; reuses every piece of trust-dial infrastructure (OPA binary, JSONL trace pattern, pinned workflows, templatized mirrors). | + +## Decision + +Adopt **Option D**: a deterministic blast-radius pulse implemented as the +`conformance.blast_radius` Rego package over a single declarative manifest +(`conformance/affects.json`), surfaced live via lefthook pre-commit, predictively +via `scripts/pulse.sh --predict`, and audited via +`.github/workflows/blast-radius-pulse.yml` + the committed append-only trace at +`audit/blast-radius.jsonl`. +Completeness is enforced by a new error-severity deny family +(`affects_manifest_complete`) added to `conformance/conformance.rego`. +The full design — manifest schema, Rego rules, surface invocations, CI guards, +phased build checklist — is specified in `blast-radius-pulse-spec.md`. + +Option D won because it is the only option that satisfies *all four* invariants +the trust-dial precedent locked in: +(1) same engine — OPA/Rego, `opa test`-proven, zero new runtime dependencies; +(2) same observability pattern — append-only JSONL trace + per-run Actions artifact; +(3) same conformance enforcement pattern — a new deny family proves the manifest +is complete, mirroring how `trust_dial_wired` (`conformance.rego:246-257`) proves +the trust-dial gate is wired; +(4) same templatization shape — every artifact has a `template/_files/` copy so +bootstrapped repos inherit the pulse automatically. +Options A and B are documentation-as-control, the exact anti-pattern +`the-trust-dial.md:33` names. +Option C breaks the zero-runtime-dependency invariant and solves a different problem +(code graph, not documentation/config graph). + +## Consequences + +**Easier.** G-05 — the artifact-type triplicate — becomes a machine-checked +invariant: editing `conformance/data.json#schema.artifact_types` triggers +required actions on `scripts/bootstrap.sh:115-118` and `.github/workflows/conformance.yml` +before the commit lands. The same machinery covers eight other named pairs +(see `affects-manifest-seed.json`), and the `affects_manifest_complete` deny family +ensures new pairs cannot be silently omitted. Reviewer attention is no longer +the load-bearing member for cross-file consistency. The pulse trace makes +"why did this PR touch only those files?" a query against an append-only record. + +**Harder.** The build touches frozen content again — `conformance/**`, +`template/**`, `scripts/**` — each change paired with a semver-classified +`CHANGELOG.md` entry. Adding `affects_manifest_complete` to +`conformance/conformance.rego` is an error-severity rule, so this is a **major** +bump. Editing `conformance.rego` mandates a policy-integrity digest refreeze +(`conformance.rego:264-279`, `conformance/data.json:93-96`) in the same commit +— and the pulse itself will assert this requirement via rule BR-001 once landed, +making the build a self-bootstrapping proof. The affects manifest is a new +artifact that must be kept honest; mitigation is the `affects_manifest_complete` +deny family plus the manifest's own entry in itself (BR-006, BR-008 transitive). +The pulse workflow writes a JSONL line per run, so CI write-back recursion guards +identical to trust-dial's (`paths-ignore: audit/**`, `[skip ci]` commit marker) +are mandatory. + +**Closure of cross-discipline gaps.** This ADR closes **G-05** (artifact-type +triplicate detection), **G-08** (conformance/decision observability — extends +the JSONL trace pattern to a second policy), **G-09** (live commit-time +enforcement — the lefthook surface), and **G-10** (decision-traceability for +documentation-class changes). It partially advances **G-11** (audit trail of +policy-integrity refreezes — rule BR-001 records the requirement in the trace) +and **G-12** (templatization scope boundary — rule BR-008 enforces the +template/required-file coupling). + +**Follow-up action items.** + +- Implement per the phased build checklist in `blast-radius-pulse-spec.md` §7 — + ten reviewable steps, mirroring the trust-dial ten-step format. +- Refreeze `conformance/data.json:policy_integrity.expected_digest` in the same + commit as the `conformance.rego` edit that adds `affects_manifest_complete`. +- Record the policy-integrity refreeze in `audit/decision-trace.jsonl` (precedent: + `audit/decision-trace.jsonl:1`). +- Resolve open questions OQ-1..OQ-5 (`blast-radius-pulse-spec.md` §9) before the + predictive CLI is exposed to bootstrapped repos. +- Extend `docs/adoption-guide.md` with the migration path for already-bootstrapped + repos (add `audit/blast-radius.jsonl` + the pulse workflow + the affects + manifest, or fail the new `affects_manifest_complete` deny family). diff --git a/docs/agents/enforcement.md b/docs/agents/enforcement.md index fd595c3..3839670 100644 --- a/docs/agents/enforcement.md +++ b/docs/agents/enforcement.md @@ -42,6 +42,50 @@ convention changes: `README.md`, `CONTRIBUTING.md`, and the issue and pull-request templates restate conventions for convenience; they are downstream of `docs/agents/`. +## Blast-radius pulse gates + +This repository ships a blast-radius pulse policy (`conformance/blast_radius.rego`) that +evaluates `conformance/affects.json` against the git diff on every pull request. Two entries +have `verifiable: true` and `severity: "error"`, meaning the CI gate hard-blocks until the +required actions are footer-declared DONE: + +### BR-005 — CLAUDE.md invariants + +**Trigger:** any edit to `CLAUDE.md`. + +**Required actions (must be footer-declared in the commit):** + +1. Verify `CLAUDE.md` line count is `<= content_assertions.claude_md_max_lines` (currently 80). +2. Verify `CLAUDE.md` first non-blank, non-comment line equals + `content_assertions.claude_md_first_content_line` (`@AGENTS.md`). +3. If either invariant value is being changed, update `conformance/data.json` under + `content_assertions` and document the rationale here. + +**Current invariant values** (as of this entry, sourced from `conformance/affects.json:86-87`): + +- `claude_md_max_lines`: 80 +- `claude_md_first_content_line`: `@AGENTS.md` + +Both invariant rules live at `conformance/blast_radius.rego` and are asserted against +`conformance/data.json`. Violations block CI. + +### BR-011 — affects manifest self-coverage + +**Trigger:** any edit to `conformance/affects.json`. + +**Required actions (must be footer-declared in the commit):** + +1. For each new or renamed manifest entry, add a positive test (entry fires on the correct + trigger path and `verdict == "blocked"` or `"owed"` as appropriate) **and** a cleared test + (all required actions footer-DONE, `verdict == "clear"`) in + `conformance/blast_radius_test.rego`. +2. Document the new or changed manifest entry in this file (`docs/agents/enforcement.md`), + including: entry id, trigger glob, severity, verifiable flag, and a one-sentence rationale. + +**Rationale:** `conformance/affects.json` is the load-bearing cross-file relationship map. Every +entry must be covered by a sibling test case so the blast-radius function's determinism proof +is complete. An undocumented entry is unverifiable; an untested entry is unproven. + ## Glossary review cadence Every change that resolves an open question or adds a CONSTRAINT to diff --git a/scripts/pulse.sh b/scripts/pulse.sh new file mode 100755 index 0000000..69777e9 --- /dev/null +++ b/scripts/pulse.sh @@ -0,0 +1,329 @@ +#!/usr/bin/env bash +# pulse.sh — compute the blast radius of a change set and either report or +# block. Wraps `opa eval data.conformance.blast_radius.result` with the same +# preflight discipline as scripts/bootstrap.sh. +# +# Modes: +# (default) reads `git diff --name-only --cached` (live, lefthook) +# --mode live same as default +# --mode audit requires --diff-range R; reads `git diff --name-only R` +# --mode predict hypothetical mode; takes file globs as positional args +# --predict GLOB... shorthand for --mode predict +# +# Optional flags: +# --diff-range R the range for --mode audit (e.g. origin/main...HEAD) +# --commit-msg-file F path to a file holding the pending commit message; +# Pulse-Action: DONE lines parsed from it +# --json emit the raw OPA result JSON to stdout (in addition +# to human-readable output on stderr) +# --output-dir DIR where to write opa-input.json / opa-eval.stdout / +# pr-comment.md / verdict.json (default: cwd) +# +# Exit codes: +# 0 = verdict.verdict in {"clear", "owed"} (warnings only or no fire) +# 1 = verdict.verdict == "blocked" +# 2 = opa eval failed / manifest invalid / preflight failed +set -euo pipefail + +# --- preflight ---------------------------------------------------------------- +die() { + printf 'pulse: %s\n' "$1" >&2 + exit "${2:-2}" +} + +for tool in opa jq git awk; do + command -v "$tool" >/dev/null 2>&1 || + die "required tool not found on PATH: $tool" +done + +# --- arg parse ---------------------------------------------------------------- +mode="live" +diff_range="" +commit_msg_file="" +emit_json=0 +output_dir="." +predict_globs=() + +while [ "$#" -gt 0 ]; do + case "${1:-}" in + --mode) + mode="${2:-}" + shift 2 + ;; + --diff-range) + diff_range="${2:-}" + shift 2 + ;; + --commit-msg-file) + commit_msg_file="${2:-}" + shift 2 + ;; + --predict) + mode="predict" + shift + while [ "$#" -gt 0 ] && [ "${1:0:2}" != "--" ]; do + predict_globs+=("$1") + shift + done + ;; + --json) + emit_json=1 + shift + ;; + --output-dir) + output_dir="${2:-}" + shift 2 + ;; + -h | --help) + sed -n '2,30p' "$0" + exit 0 + ;; + *) + die "unknown argument: ${1:-}" + ;; + esac +done + +case "$mode" in +live | audit | predict) ;; +*) die "invalid --mode: $mode (expected live|audit|predict)" ;; +esac + +if [ "$mode" = "audit" ] && [ -z "$diff_range" ]; then + die "--mode audit requires --diff-range R" +fi + +mkdir -p "$output_dir" + +# --- repo root + policy dir --------------------------------------------------- +repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +policy_dir="$repo_root/conformance" +[ -d "$policy_dir" ] || + die "conformance/ directory not found at $policy_dir" +[ -f "$policy_dir/blast_radius.rego" ] || + die "conformance/blast_radius.rego not found" +[ -f "$policy_dir/affects.json" ] || + die "conformance/affects.json not found" + +# --- compute changed_files set ------------------------------------------------ +changed_files_file="$(mktemp)" +trap 'rm -f "$changed_files_file"' EXIT + +case "$mode" in +live) + (cd "$repo_root" && git diff --name-only --cached) >"$changed_files_file" + ;; +audit) + (cd "$repo_root" && git diff --name-only "$diff_range") >"$changed_files_file" + ;; +predict) + # Each positional glob is a literal file path the editor proposes to touch. + # (Globbing is the caller's responsibility; pulse treats inputs as-is.) + printf '%s\n' "${predict_globs[@]}" >"$changed_files_file" + ;; +esac + +# --- detect JSON sub-targets -------------------------------------------------- +# For every changed JSON file, compute the set of top-level dotted keys whose +# scalar value differs between the comparator state and the candidate state. +json_changes_file="$(mktemp)" +trap 'rm -f "$changed_files_file" "$json_changes_file"' EXIT +echo '{}' >"$json_changes_file" + +_dotted_keys() { + # Read JSON from stdin; emit a newline-delimited list of dotted scalar keys. + jq -r 'paths(scalars) | join(".")' 2>/dev/null || true +} + +_diff_dotted_keys() { + local before="$1" after="$2" + # Concatenate keys/values from both sides, find symmetric difference. + diff <( + jq -S 'paths(scalars) as $p | {key: ($p | join(".")), value: getpath($p)}' \ + "$before" 2>/dev/null | jq -c . + ) <( + jq -S 'paths(scalars) as $p | {key: ($p | join(".")), value: getpath($p)}' \ + "$after" 2>/dev/null | jq -c . + ) | awk '/^[<>]/ {print}' | jq -r '.key' 2>/dev/null | sort -u +} + +case "$mode" in +live | audit) + # For each changed *.json, compare HEAD (or base of diff_range) to the + # candidate (working tree for live, head of diff_range for audit). + tmp_before="$(mktemp)" + tmp_after="$(mktemp)" + tmp_keys="$(mktemp)" + trap 'rm -f "$changed_files_file" "$json_changes_file" "$tmp_before" "$tmp_after" "$tmp_keys"' EXIT + + while IFS= read -r path; do + case "$path" in + *.json) ;; + *) continue ;; + esac + # Resolve "before" and "after" blobs. + case "$mode" in + live) + # before = HEAD:; after = working tree. + git -C "$repo_root" show "HEAD:$path" >"$tmp_before" 2>/dev/null || echo '{}' >"$tmp_before" + cp "$repo_root/$path" "$tmp_after" 2>/dev/null || echo '{}' >"$tmp_after" + ;; + audit) + base="${diff_range%%...*}" + head="${diff_range##*...}" + [ "$head" = "$diff_range" ] && head="${diff_range##*..}" + git -C "$repo_root" show "$base:$path" >"$tmp_before" 2>/dev/null || echo '{}' >"$tmp_before" + git -C "$repo_root" show "$head:$path" >"$tmp_after" 2>/dev/null || echo '{}' >"$tmp_after" + ;; + esac + # Compute differing dotted keys. + _diff_dotted_keys "$tmp_before" "$tmp_after" >"$tmp_keys" || true + if [ -s "$tmp_keys" ]; then + # Merge into json_changes. + keys_array="$(jq -R -s 'split("\n") | map(select(length > 0))' "$tmp_keys")" + jq --arg p "$path" --argjson keys "$keys_array" \ + '. + {($p): $keys}' "$json_changes_file" >"$json_changes_file.tmp" + mv "$json_changes_file.tmp" "$json_changes_file" + fi + done <"$changed_files_file" + ;; +predict) + # predict mode never reads HEAD — keys default to "everything in the file" + # treated as changed. For determinism we emit no json_changes (so subtarget + # gates stay CLOSED in predict mode). The caller may pass --json-changes + # explicitly (out of scope for v1). + : + ;; +esac + +# --- parse Pulse-Action footer ------------------------------------------------ +done_actions_file="$(mktemp)" +trap 'rm -f "$changed_files_file" "$json_changes_file" "$tmp_before" "$tmp_after" "$tmp_keys" "$done_actions_file"' EXIT +: >"$done_actions_file" + +if [ -n "$commit_msg_file" ] && [ -f "$commit_msg_file" ]; then + awk '/^Pulse-Action:[[:space:]]*[A-Za-z0-9_-]+[[:space:]]+DONE[[:space:]]*$/ { + # Extract the id between "Pulse-Action:" and "DONE". + sub(/^Pulse-Action:[[:space:]]*/, "", $0) + sub(/[[:space:]]+DONE[[:space:]]*$/, "", $0) + print + }' "$commit_msg_file" >"$done_actions_file" +fi + +# --- compute git_sha ---------------------------------------------------------- +git_sha="$(git -C "$repo_root" rev-parse HEAD 2>/dev/null || echo "")" + +# --- build OPA input ---------------------------------------------------------- +opa_input="$output_dir/opa-input.json" + +changed_array="$(jq -R -s 'split("\n") | map(select(length > 0))' "$changed_files_file")" +done_array="$(jq -R -s 'split("\n") | map(select(length > 0))' "$done_actions_file")" +json_changes="$(cat "$json_changes_file")" + +jq -n \ + --argjson changed "$changed_array" \ + --argjson json_changes "$json_changes" \ + --argjson done "$done_array" \ + --arg sha "$git_sha" \ + --arg mode "$mode" \ + '{ + changed_files: $changed, + json_changes: $json_changes, + commit_footer_actions_done: $done, + git_sha: $sha, + mode: $mode + }' >"$opa_input" + +# --- evaluate ----------------------------------------------------------------- +opa_stdout="$output_dir/opa-eval.stdout" +opa_stderr="$output_dir/opa-eval.stderr" + +set +e +opa eval \ + --data "$policy_dir" \ + --input "$opa_input" \ + --format json \ + 'data.conformance.blast_radius.result' \ + >"$opa_stdout" \ + 2>"$opa_stderr" +opa_exit=$? +set -e + +if [ "$opa_exit" -ne 0 ]; then + printf 'pulse: opa eval failed (exit %d):\n' "$opa_exit" >&2 + cat "$opa_stderr" >&2 + exit 2 +fi + +result_json="$(jq -c '.result[0].expressions[0].value' "$opa_stdout")" +[ -n "$result_json" ] && [ "$result_json" != "null" ] || + die "opa eval produced no result" + +verdict="$(printf '%s' "$result_json" | jq -r '.verdict')" +errors="$(printf '%s' "$result_json" | jq -r '.errors')" +warnings="$(printf '%s' "$result_json" | jq -r '.warnings')" + +printf '%s' "$result_json" >"$output_dir/verdict.json" + +# --- emit human-readable report ---------------------------------------------- +_render_report() { + local sink="$1" + { + printf '[blast-radius] verdict=%s errors=%d warnings=%d (mode=%s)\n' \ + "$verdict" "$errors" "$warnings" "$mode" + if [ "$verdict" != "clear" ]; then + printf '%s\n' "$result_json" | jq -r ' + .fired[] + | select(.owed_count > 0) + | " - \(.id) (\(.severity)) [owed=\(.owed_count)]\n Trigger: \(.trigger)\n Reason : \(.reason)\n Affects: \(.affects | join(", "))\n Required:\n" + + (.required_actions | map(" [" + (if .done then "x" else " " end) + "] " + .id + ": " + .text) | join("\n")) + ' + fi + } >"$sink" +} + +_render_pr_comment() { + local sink="$1" + { + printf '## Blast-radius pulse — %s\n\n' "$verdict" + printf '- errors: %d\n- warnings: %d\n- mode: %s\n\n' "$errors" "$warnings" "$mode" + if [ "$verdict" != "clear" ]; then + printf '### Fired entries\n\n' + printf '%s\n' "$result_json" | jq -r ' + .fired[] + | select(.owed_count > 0) + | "**" + .id + "** (`" + .severity + "`)\n\nTrigger: `" + .trigger + "`\n\n" + + "Reason: " + .reason + "\n\n" + + "Affects:\n" + (.affects | map("- `" + . + "`") | join("\n")) + "\n\n" + + "Required actions:\n" + (.required_actions | map("- [" + (if .done then "x" else " " end) + "] `" + .id + "`: " + .text) | join("\n")) + "\n\n---\n" + ' + else + printf 'No fired entries — the diff is clear.\n' + fi + } >"$sink" +} + +case "$mode" in +live) + _render_report /dev/stderr + ;; +predict) + _render_report /dev/stdout + ;; +audit) + _render_report "$output_dir/pulse-report.txt" + _render_pr_comment "$output_dir/pr-comment.md" + cat "$output_dir/pulse-report.txt" >&2 + ;; +esac + +if [ "$emit_json" -eq 1 ]; then + printf '%s\n' "$result_json" +fi + +# --- exit --------------------------------------------------------------------- +case "$verdict" in +clear | owed) exit 0 ;; +blocked) exit 1 ;; +*) die "unexpected verdict: $verdict" ;; +esac