From 9a434bf53c86a23f887f418f58d0594834f145fa Mon Sep 17 00:00:00 2001 From: Jonathan Bowe Date: Fri, 29 May 2026 04:09:02 -0400 Subject: [PATCH 1/2] chore(oss-prep): align repo to kellerai-oss-template (structural) --- .github/workflows/blast-radius-outcome.yml | 87 ++++ .github/workflows/blast-radius-pulse.yml | 165 +++++++ .github/workflows/conformance.yml | 194 +++++++++ .github/workflows/trust-dial-gate.yml | 193 +++++++++ .github/workflows/trust-dial-outcome.yml | 274 ++++++++++++ .github/workflows/validate-branch-name.yml | 50 +++ .github/workflows/validate-branch-tier.yml | 78 ++++ .github/workflows/validate-linked-issue.yml | 91 ++++ audit/blast-radius.jsonl | 1 + conformance/affects.json | 223 ++++++++++ conformance/blast_radius.rego | 245 +++++++++++ conformance/blast_radius_test.rego | 449 ++++++++++++++++++++ docs/adr/ADR-002-blast-radius-pulse.md | 139 ++++++ scripts/pulse.sh | 329 ++++++++++++++ 14 files changed, 2518 insertions(+) create mode 100644 .github/workflows/blast-radius-outcome.yml create mode 100644 .github/workflows/blast-radius-pulse.yml create mode 100644 .github/workflows/conformance.yml create mode 100644 .github/workflows/trust-dial-gate.yml create mode 100644 .github/workflows/trust-dial-outcome.yml create mode 100644 .github/workflows/validate-branch-name.yml create mode 100644 .github/workflows/validate-branch-tier.yml create mode 100644 .github/workflows/validate-linked-issue.yml create mode 100644 audit/blast-radius.jsonl create mode 100644 conformance/affects.json create mode 100644 conformance/blast_radius.rego create mode 100644 conformance/blast_radius_test.rego create mode 100644 docs/adr/ADR-002-blast-radius-pulse.md create mode 100755 scripts/pulse.sh 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..65f37d8 --- /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 governance-library 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.conformance.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..c5aab25 --- /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.conformance.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/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/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..747134b --- /dev/null +++ b/conformance/blast_radius_test.rego @@ -0,0 +1,449 @@ +# 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 +} 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/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 From 60624b9dda6cc4ff8f527a37a3e8b15b000f11a3 Mon Sep 17 00:00:00 2001 From: Jonathan Bowe Date: Sat, 30 May 2026 00:32:11 -0400 Subject: [PATCH 2/2] feat(conformance): vendor de-branded conformance.* policy layer Vendor the OSS governance conformance policy locally under the de-branded conformance.* namespace (not kellerai.oss.*), consistent with the prior release scrub of conformance/blast_radius.rego. - conformance/conformance.rego (package conformance.conformance) + data.json manifest + conformance_test.rego; policy_integrity digest refrozen. - conformance/trust_dial.rego/_data/_test (package conformance.trust_dial) + audit/ seed state. - scripts/scan-repo-structure.sh, preflight.sh, publish.sh; ADR-000/001; docs/claude-settings.template.json. Verified: opa check OK; opa test 74/74; data.conformance.conformance.summary => allow=true (0 errors, 0 warnings); check-sanitization OK. --- audit/decision-trace.jsonl | 2 + audit/trust-dial-state.json | 18 ++ conformance/README.md | 51 +++ conformance/conformance.rego | 376 ++++++++++++++++++++++ conformance/conformance_test.rego | 208 ++++++++++++ conformance/data.json | 123 +++++++ conformance/trust_dial.rego | 89 +++++ conformance/trust_dial_data.json | 52 +++ conformance/trust_dial_test.rego | 153 +++++++++ docs/adr/ADR-000-template.md | 28 ++ docs/adr/ADR-001-trust-dial-dependabot.md | 97 ++++++ docs/claude-settings.template.json | 17 + scripts/preflight.sh | 45 +++ scripts/publish.sh | 322 ++++++++++++++++++ scripts/scan-repo-structure.sh | 156 +++++++++ 15 files changed, 1737 insertions(+) create mode 100644 audit/decision-trace.jsonl create mode 100644 audit/trust-dial-state.json create mode 100644 conformance/README.md create mode 100644 conformance/conformance.rego create mode 100644 conformance/conformance_test.rego create mode 100644 conformance/data.json create mode 100644 conformance/trust_dial.rego create mode 100644 conformance/trust_dial_data.json create mode 100644 conformance/trust_dial_test.rego create mode 100644 docs/adr/ADR-000-template.md create mode 100644 docs/adr/ADR-001-trust-dial-dependabot.md create mode 100644 docs/claude-settings.template.json create mode 100755 scripts/preflight.sh create mode 100755 scripts/publish.sh create mode 100755 scripts/scan-repo-structure.sh diff --git a/audit/decision-trace.jsonl b/audit/decision-trace.jsonl new file mode 100644 index 0000000..4013961 --- /dev/null +++ b/audit/decision-trace.jsonl @@ -0,0 +1,2 @@ +{"ts": "2026-05-22T00:00:01Z", "trace_id": "td-bootstrap-policy-refreeze-2.0.0", "event": "policy_integrity_refreeze", "pr_number": null, "pr_actor": "build:trust-dial-v2.0.0", "tier": "Observed", "inputs": {"policy_path": "conformance/conformance.rego", "algorithm": "sha256", "previous_digest": "c781ea87fa61173f2cf1651d29c5246f111f54044bff30495cd5e34c52bf3c61", "new_digest": "011d17904bc7dd6606d498c497d4b43a99c0556f43c1ddcedf839177249dbcec"}, "rule_applied": "policy_integrity_refreeze", "alternatives": ["refreeze", "reject_change", "defer"], "verdict": "refreeze", "rationale": "trust_dial_wired deny family added to conformance.rego per ADR-001; digest refrozen in same commit (G-11 traced)", "opa_policy_digest": "011d17904bc7dd6606d498c497d4b43a99c0556f43c1ddcedf839177249dbcec", "run_url": "local:build/trust-dial-2.0.0"} +{"ts": "2026-05-22T00:00:02Z", "trace_id": "td-bootstrap-policy-refreeze-3.0.0", "event": "policy_integrity_refreeze", "pr_number": null, "pr_actor": "build:blast-radius-v3.0.0", "tier": "Observed", "inputs": {"policy_path": "conformance/conformance.rego", "algorithm": "sha256", "previous_digest": "011d17904bc7dd6606d498c497d4b43a99c0556f43c1ddcedf839177249dbcec", "new_digest": "c71bce43c79439e52526ad85ba34aa2238e230ec624d03718098d0416a41a38b"}, "rule_applied": "policy_integrity_refreeze", "alternatives": ["refreeze", "reject_change", "defer"], "verdict": "refreeze", "rationale": "affects_manifest_complete deny family added to conformance.rego per ADR-002; digest refrozen in same commit (G-11 traced)", "opa_policy_digest": "c71bce43c79439e52526ad85ba34aa2238e230ec624d03718098d0416a41a38b", "run_url": "local:build/blast-radius-3.0.0"} diff --git a/audit/trust-dial-state.json b/audit/trust-dial-state.json new file mode 100644 index 0000000..a5a3b5a --- /dev/null +++ b/audit/trust-dial-state.json @@ -0,0 +1,18 @@ +{ + "_schema_version": "1.0.0", + "tier": "Observed", + "clean_merge_streak": 0, + "consecutive_clean_cycles": 0, + "regression_events": [], + "demotion_history": [ + { + "ts": "2026-05-22T00:00:00Z", + "from": null, + "to": "Observed", + "direction": "init", + "reason": "repo bootstrapped — whitepaper §11 mandates Observed" + } + ], + "last_cycle_id": "2026-W21", + "cycle_merge_count": 0 +} diff --git a/conformance/README.md b/conformance/README.md new file mode 100644 index 0000000..c4b9cd3 --- /dev/null +++ b/conformance/README.md @@ -0,0 +1,51 @@ +# conformance/ + +The OPA/Rego policy that checks a repository's file and directory structure +against the OSS publication standard. It is the machine-checkable half +of this repo — `template/` is the bootstrap half, and both are driven from the +same source of truth, `data.json`. + +## Files + +- **`data.json`** — the single source of truth. Lists required files, + directories, `.github/` files, agent docs and scripts; the four artifact + types and their primary validators; forbidden branches; required `.gitignore` + patterns; content assertions; and the policy self-integrity digest. +- **`conformance.rego`** — package `conformance.conformance`. Consumes a + `repo-structure.json` snapshot as `input` and `data.json` as `data`, and emits + a structured `deny` set. +- **`conformance_test.rego`** — the `opa test` suite. + +## Violation shape + +Each `deny` entry is `{"rule", "severity", "field", "msg"}`. `severity` is +`error` (blocks CI) or `warning` (reported, non-blocking). + +| Surface rule | Meaning | +|--------------|---------| +| `data.conformance.conformance.violations` | every violation | +| `data.conformance.conformance.errors` | error-severity only | +| `data.conformance.conformance.warnings` | warning-severity only | +| `data.conformance.conformance.allow` | `true` when there are zero errors | +| `data.conformance.conformance.summary` | `{allow, total, errors, warnings}` | + +## Running it + +```bash +# Syntax + type check, and the test suite +opa check conformance/conformance.rego +opa test conformance/ + +# Evaluate against a repo snapshot (see scripts/scan-repo-structure.sh) +opa eval -d conformance/ -i repo-structure.json \ + 'data.conformance.conformance.summary' +``` + +## Self-integrity + +`data.json` carries `policy_integrity.expected_digest` — the SHA-256 of +`conformance.rego`. The `policy_integrity` rule fires if the live policy digest +diverges from the manifest, so the policy cannot be silently weakened without +the digest being refrozen. A second rule (`policy_integrity_manifest`) fires if +the digest field is removed entirely. The digest is `PENDING` until frozen by +the release-hardening step. diff --git a/conformance/conformance.rego b/conformance/conformance.rego new file mode 100644 index 0000000..909dabc --- /dev/null +++ b/conformance/conformance.rego @@ -0,0 +1,376 @@ +# METADATA +# title: OSS repository structure conformance +# description: | +# Validates a repository's file and directory structure against the OSS +# publication standard. Consumes a repo-structure.json snapshot as `input` +# and the conformance manifest (data.json) as `data`. Emits a structured +# `deny` set; `error`-severity entries block CI. +package conformance.conformance + +import rego.v1 + +# --------------------------------------------------------------------------- +# Data shortcuts (the conformance manifest, data.json) +# --------------------------------------------------------------------------- + +_schema := data.schema + +_content := data.content_assertions + +_integrity := data.policy_integrity + +_trust_dial := data.trust_dial_manifest + +# --------------------------------------------------------------------------- +# Input shortcuts (the repo-structure.json snapshot) +# --------------------------------------------------------------------------- + +# Set of every file path present in the repository. +_paths := {f.path | some f in input.files} + +# Set of every directory present in the repository. +_dirs := {d | some d in input.dirs} + +# --------------------------------------------------------------------------- +# Sentinel-guarded access — distinguishes an absent key from a falsy value. +# --------------------------------------------------------------------------- + +_sentinel := {"__absent__": true} + +_get(obj, key) := object.get(obj, key, _sentinel) + +_present(obj, key) if object.get(obj, key, _sentinel) != _sentinel + +# --------------------------------------------------------------------------- +# deny — the structured violation set. +# Each entry: {"rule": str, "severity": "error"|"warning", "field": str, "msg": str} +# --------------------------------------------------------------------------- + +# -- data sanity: the conformance manifest must be loaded -------------------- +deny contains entry if { + not data.schema + entry := { + "rule": "data_sentinel", + "severity": "error", + "field": "data.schema", + "msg": "conformance manifest not loaded: data.schema is absent", + } +} + +# -- required root files ----------------------------------------------------- +deny contains entry if { + some required in _schema.required_files + not required in _paths + entry := { + "rule": "required_file", + "severity": "error", + "field": required, + "msg": sprintf("required file missing: %s", [required]), + } +} + +# -- required directories ---------------------------------------------------- +deny contains entry if { + some required in _schema.required_dirs + not required in _dirs + entry := { + "rule": "required_dir", + "severity": "error", + "field": required, + "msg": sprintf("required directory missing: %s", [required]), + } +} + +# -- required .github files -------------------------------------------------- +deny contains entry if { + some required in _schema.required_github_files + not required in _paths + entry := { + "rule": "required_github_file", + "severity": "error", + "field": required, + "msg": sprintf("required .github file missing: %s", [required]), + } +} + +# -- required agent docs (Tier-2) ------------------------------------------- +deny contains entry if { + some required in _schema.required_agent_docs + not required in _paths + entry := { + "rule": "required_agent_doc", + "severity": "error", + "field": required, + "msg": sprintf("required agent doc missing: %s", [required]), + } +} + +# -- required scripts -------------------------------------------------------- +deny contains entry if { + some required in _schema.required_scripts + not required in _paths + entry := { + "rule": "required_script", + "severity": "error", + "field": required, + "msg": sprintf("required script missing: %s", [required]), + } +} + +# -- artifact type must be a known value ------------------------------------ +deny contains entry if { + not input.artifact_type in _schema.artifact_types + entry := { + "rule": "artifact_type_known", + "severity": "error", + "field": "input.artifact_type", + "msg": sprintf("unknown artifact_type %v; must be one of %v", [input.artifact_type, _schema.artifact_types]), + } +} + +# -- the artifact directory must exist -------------------------------------- +deny contains entry if { + input.artifact_type in _schema.artifact_types + cfg := _schema.artifact_type_files[input.artifact_type] + expected_dir := object.get(input, "artifact_dir", cfg.default_dir) + not expected_dir in _dirs + entry := { + "rule": "artifact_dir", + "severity": "error", + "field": expected_dir, + "msg": sprintf("artifact directory missing for type %v: %s", [input.artifact_type, expected_dir]), + } +} + +# -- AGENTS.md length cap ---------------------------------------------------- +deny contains entry if { + meta := _get(input.file_meta, "AGENTS.md") + meta != _sentinel + meta.line_count > _content.agents_md_max_lines + entry := { + "rule": "agents_md_length", + "severity": "warning", + "field": "AGENTS.md", + "msg": sprintf("AGENTS.md is %d lines; the standard caps it at %d", [meta.line_count, _content.agents_md_max_lines]), + } +} + +# -- CLAUDE.md length cap ---------------------------------------------------- +deny contains entry if { + meta := _get(input.file_meta, "CLAUDE.md") + meta != _sentinel + meta.line_count > _content.claude_md_max_lines + entry := { + "rule": "claude_md_length", + "severity": "warning", + "field": "CLAUDE.md", + "msg": sprintf("CLAUDE.md is %d lines; the standard caps it at %d", [meta.line_count, _content.claude_md_max_lines]), + } +} + +# -- CLAUDE.md must import AGENTS.md as its first content line -------------- +deny contains entry if { + meta := _get(input.file_meta, "CLAUDE.md") + meta != _sentinel + meta.first_content_line != _content.claude_md_first_content_line + entry := { + "rule": "claude_md_import", + "severity": "error", + "field": "CLAUDE.md", + "msg": sprintf("CLAUDE.md first content line is %v; the standard requires %v", [meta.first_content_line, _content.claude_md_first_content_line]), + } +} + +# -- README must carry the agent-pointer footer ----------------------------- +deny contains entry if { + meta := _get(input.file_meta, "README.md") + meta != _sentinel + not contains(meta.tail, _content.readme_agents_footer_marker) + entry := { + "rule": "readme_agent_footer", + "severity": "warning", + "field": "README.md", + "msg": sprintf("README.md is missing the %v agent-pointer footer", [_content.readme_agents_footer_marker]), + } +} + +# -- .gitignore must cover the required staging patterns -------------------- +deny contains entry if { + meta := _get(input.file_meta, ".gitignore") + meta != _sentinel + some pattern in _schema.gitignore_required_patterns + not pattern in {l | some l in meta.lines} + entry := { + "rule": "gitignore_coverage", + "severity": "error", + "field": pattern, + "msg": sprintf(".gitignore does not cover required pattern: %s", [pattern]), + } +} + +# -- no forbidden branch (default branch must be main) ---------------------- +deny contains entry if { + some branch in input.branches + branch in _schema.forbidden_branches + entry := { + "rule": "forbidden_branch", + "severity": "error", + "field": branch, + "msg": sprintf("forbidden branch present: %s — the default branch must be main", [branch]), + } +} + +# -- the artifact type's primary validator must be wired into CI ------------ +deny contains entry if { + input.artifact_type in _schema.artifact_types + cfg := _schema.artifact_type_files[input.artifact_type] + not _validator_referenced(cfg.primary_validator) + entry := { + "rule": "primary_validator_wired", + "severity": "warning", + "field": cfg.primary_validator, + "msg": sprintf("no CI workflow references the primary validator %v for artifact type %v", [cfg.primary_validator, input.artifact_type]), + } +} + +_validator_referenced(validator) if { + some line in input.ci_uses + contains(line, validator) +} + +# -- the trust-dial gate must be wired into a CI workflow ------------------ +# Whitepaper enforcement: a repo may not merely *contain* the gate workflow, +# it must *wire* it (a CI step evaluates data.conformance.trust_dial.*). +# Reuses the input.ci_uses aggregation already populated by +# scan-repo-structure.sh (the same mechanism primary_validator_wired uses). +deny contains entry if { + _present(_trust_dial, "gate_workflow") + gate := _trust_dial.gate_workflow + gate in _paths + not _ci_references("data.conformance.trust_dial") + entry := { + "rule": "trust_dial_wired", + "severity": "error", + "field": gate, + "msg": "trust-dial gate workflow present but no CI step evaluates the verdict policy (expected data.conformance.trust_dial reference in .github/workflows)", + } +} + +_ci_references(needle) if { + some line in input.ci_uses + contains(line, needle) +} + +# -- policy self-integrity: the live policy digest must match the manifest -- +# Only enforced when a digest was captured (i.e. the scanned repo carries the +# policy file). Consumer repos that call the centralized workflow have no +# vendored policy, so policy_digest is empty and this check is skipped. +deny contains entry if { + _present(_integrity, "expected_digest") + _integrity.expected_digest != "PENDING" + input.policy_digest != "" + input.policy_digest != _integrity.expected_digest + entry := { + "rule": "policy_integrity", + "severity": "error", + "field": "conformance/conformance.rego", + "msg": "conformance policy digest does not match the manifest — conformance.rego has been modified without refreezing data.policy_integrity.expected_digest", + } +} + +# -- policy self-integrity: the manifest field must not be removed ---------- +deny contains entry if { + not _present(_integrity, "expected_digest") + entry := { + "rule": "policy_integrity_manifest", + "severity": "error", + "field": "data.policy_integrity.expected_digest", + "msg": "policy integrity manifest missing: data.policy_integrity.expected_digest must be present", + } +} + +# -- affects manifest must cover every tracked file in pulse scope ---------- +# The blast-radius pulse is only honest if the manifest is complete: an +# unreachable file is a silent gap. This rule asserts that every tracked file +# under the in-scope directories (conformance/, template/, scripts/, docs/agents/) +# is reachable from at least one conformance/affects.json entry — either as a +# when_changed match or as an affects match. The manifest is loaded via the +# `data.blast_radius.affects` JSON document (conformance/affects.json). +deny contains entry if { + some path in _paths + _in_pulse_scope(path) + count(data.blast_radius.affects) > 0 + not _reachable_from_affects(path) + entry := { + "rule": "affects_manifest_complete", + "severity": "error", + "field": path, + "msg": sprintf( + "file %v is in pulse scope but is not reachable from any conformance/affects.json entry — add a when_changed or affects glob that covers it", + [path], + ), + } +} + +_in_pulse_scope(path) if startswith(path, "conformance/") + +_in_pulse_scope(path) if startswith(path, "template/") + +_in_pulse_scope(path) if startswith(path, "scripts/") + +_in_pulse_scope(path) if startswith(path, "docs/agents/") + +_reachable_from_affects(path) if { + some entry in data.blast_radius.affects + _affects_glob_match(_affects_strip_subtarget(entry.when_changed), path) +} + +_reachable_from_affects(path) if { + some entry in data.blast_radius.affects + some pattern in entry.affects + _affects_glob_match(pattern, path) +} + +_affects_strip_subtarget(pattern) := before if { + contains(pattern, "#") + before := split(pattern, "#")[0] +} + +_affects_strip_subtarget(pattern) := pattern if { + not contains(pattern, "#") +} + +_affects_glob_match(pattern, path) if { + glob.match(pattern, ["/"], path) +} + +_affects_glob_match(pattern, path) if { + not contains(pattern, "*") + pattern == path +} + +# --------------------------------------------------------------------------- +# Surface rules +# --------------------------------------------------------------------------- + +# Every violation, errors and warnings alike. +violations := deny + +# Error-severity violations only — these block CI. +errors := {d | some d in deny; d.severity == "error"} + +# Warning-severity violations — reported but non-blocking. +warnings := {d | some d in deny; d.severity == "warning"} + +# A repository conforms when it has zero error-severity violations. +default allow := false + +allow if count(errors) == 0 + +# Compact result summary for CI output. +summary := { + "allow": allow, + "total": count(deny), + "errors": count(errors), + "warnings": count(warnings), +} diff --git a/conformance/conformance_test.rego b/conformance/conformance_test.rego new file mode 100644 index 0000000..275034b --- /dev/null +++ b/conformance/conformance_test.rego @@ -0,0 +1,208 @@ +package conformance.conformance_test + +import rego.v1 + +import data.conformance.conformance + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +# Every path a conformant rego-policy repository must contain. +_ok_paths := [ + "README.md", "AGENTS.md", "CLAUDE.md", "LICENSE", "NOTICE", + "CHANGELOG.md", "CITATION.cff", "CONTRIBUTING.md", "SECURITY.md", + ".gitignore", ".markdownlint-cli2.yaml", "commitlint.config.js", "lefthook.yml", + ".github/CODEOWNERS", ".github/dependabot.yml", ".github/PULL_REQUEST_TEMPLATE.md", + ".github/workflows/ci.yml", ".github/workflows/commitlint.yml", + ".github/workflows/pages.yml", ".github/ISSUE_TEMPLATE/config.yml", + "docs/agents/conventions.md", "docs/agents/citation.md", + "docs/agents/glossary.md", "docs/agents/enforcement.md", + "scripts/check-sanitization.sh", "scripts/pulse.sh", + "conformance/conformance.rego", + ".github/workflows/validate-branch-name.yml", + ".github/workflows/validate-branch-tier.yml", + ".github/workflows/validate-linked-issue.yml", + ".github/workflows/trust-dial-gate.yml", + ".github/workflows/trust-dial-outcome.yml", + ".github/workflows/blast-radius-pulse.yml", + ".github/workflows/blast-radius-outcome.yml", + "audit/trust-dial-state.json", + "audit/decision-trace.jsonl", + "audit/blast-radius.jsonl", + "conformance/affects.json", + "conformance/blast_radius.rego", + "conformance/blast_radius_test.rego", + "docs/adr/ADR-002-blast-radius-pulse.md", +] + +_ok_files := [{"path": p, "size": 100} | some p in _ok_paths] + +_ok_dirs := [ + ".github", ".github/workflows", ".github/ISSUE_TEMPLATE", + "docs", "docs/agents", "scripts", "conformance", "audit", +] + +_ok_input := { + "repo": "example", + "owner": "jonathan-kellerai", + "artifact_type": "rego-policy", + "files": _ok_files, + "dirs": _ok_dirs, + "branches": ["main"], + "file_meta": { + "AGENTS.md": {"line_count": 99, "first_content_line": "# AGENTS.md", "tail": "tier-2 pointers"}, + "CLAUDE.md": {"line_count": 34, "first_content_line": "@AGENTS.md", "tail": "claude notes"}, + "README.md": {"line_count": 200, "first_content_line": "# Example", "tail": "### For agents\nstart at AGENTS.md"}, + ".gitignore": {"line_count": 10, "lines": [".claude/", ".claude-tmp/", ".DS_Store", "node_modules/"]}, + }, + "ci_uses": ["open-policy-agent/setup-opa@v2.4.0", "opa check", "opa test", "data.conformance.trust_dial.decision"], + "policy_digest": "", +} + +# Drop one path from the conformant file list. +_drop(path) := [f | some f in _ok_files; f.path != path] + +# True when some deny entry carries the given rule id. +_fires(result, rule) if { + some d in result + d.rule == rule +} + +# --------------------------------------------------------------------------- +# Happy path +# --------------------------------------------------------------------------- + +test_conformant_input_allows if { + conformance.allow with input as _ok_input +} + +test_conformant_input_no_errors if { + count(conformance.errors) == 0 with input as _ok_input +} + +test_conformant_input_no_warnings if { + count(conformance.warnings) == 0 with input as _ok_input +} + +# --------------------------------------------------------------------------- +# Structural error rules +# --------------------------------------------------------------------------- + +test_missing_required_file if { + bad := object.union(_ok_input, {"files": _drop("README.md")}) + result := conformance.deny with input as bad + _fires(result, "required_file") + not conformance.allow with input as bad +} + +test_missing_required_dir if { + bad := object.union(_ok_input, {"dirs": [d | some d in _ok_dirs; d != "scripts"]}) + _fires(conformance.deny, "required_dir") with input as bad +} + +test_missing_github_file if { + bad := object.union(_ok_input, {"files": _drop(".github/CODEOWNERS")}) + _fires(conformance.deny, "required_github_file") with input as bad +} + +test_missing_agent_doc if { + bad := object.union(_ok_input, {"files": _drop("docs/agents/glossary.md")}) + _fires(conformance.deny, "required_agent_doc") with input as bad +} + +test_missing_script if { + bad := object.union(_ok_input, {"files": _drop("scripts/check-sanitization.sh")}) + _fires(conformance.deny, "required_script") with input as bad +} + +test_unknown_artifact_type if { + bad := object.union(_ok_input, {"artifact_type": "not-a-type"}) + _fires(conformance.deny, "artifact_type_known") with input as bad +} + +test_missing_artifact_dir if { + bad := object.union(_ok_input, {"dirs": [d | some d in _ok_dirs; d != "conformance"]}) + _fires(conformance.deny, "artifact_dir") with input as bad +} + +test_claude_md_bad_import if { + bad := object.union(_ok_input, {"file_meta": {"CLAUDE.md": {"first_content_line": "# CLAUDE.md"}}}) + _fires(conformance.deny, "claude_md_import") with input as bad +} + +test_gitignore_missing_pattern if { + bad := object.union(_ok_input, {"file_meta": {".gitignore": {"lines": [".claude/", "node_modules/"]}}}) + _fires(conformance.deny, "gitignore_coverage") with input as bad +} + +test_forbidden_branch if { + bad := object.union(_ok_input, {"branches": ["main", "master"]}) + _fires(conformance.deny, "forbidden_branch") with input as bad +} + +# --------------------------------------------------------------------------- +# Warning rules +# --------------------------------------------------------------------------- + +test_agents_md_too_long if { + bad := object.union(_ok_input, {"file_meta": {"AGENTS.md": {"line_count": 300}}}) + result := conformance.deny with input as bad + _fires(result, "agents_md_length") + conformance.allow with input as bad +} + +test_claude_md_too_long if { + bad := object.union(_ok_input, {"file_meta": {"CLAUDE.md": {"line_count": 120}}}) + _fires(conformance.deny, "claude_md_length") with input as bad +} + +test_readme_missing_footer if { + bad := object.union(_ok_input, {"file_meta": {"README.md": {"tail": "plain closing text"}}}) + _fires(conformance.deny, "readme_agent_footer") with input as bad +} + +test_primary_validator_not_wired if { + bad := object.union(_ok_input, {"ci_uses": ["actions/checkout@v4"]}) + _fires(conformance.deny, "primary_validator_wired") with input as bad +} + +# --------------------------------------------------------------------------- +# Policy self-integrity +# --------------------------------------------------------------------------- + +test_policy_integrity_mismatch if { + bad := object.union(_ok_input, {"policy_digest": "wrong-digest"}) + result := conformance.deny with input as bad + with data.policy_integrity as {"algorithm": "sha256", "expected_digest": "right-digest"} + _fires(result, "policy_integrity") +} + +test_policy_integrity_match_passes if { + good := object.union(_ok_input, {"policy_digest": "matching-digest"}) + result := conformance.deny with input as good + with data.policy_integrity as {"algorithm": "sha256", "expected_digest": "matching-digest"} + not _fires(result, "policy_integrity") +} + +test_policy_integrity_skipped_when_no_digest if { + good := object.union(_ok_input, {"policy_digest": ""}) + result := conformance.deny with input as good + with data.policy_integrity as {"algorithm": "sha256", "expected_digest": "some-digest"} + not _fires(result, "policy_integrity") +} + +test_policy_integrity_manifest_missing if { + result := conformance.deny with input as _ok_input + with data.policy_integrity as {"algorithm": "sha256"} + _fires(result, "policy_integrity_manifest") +} + +# --------------------------------------------------------------------------- +# Data sanity +# --------------------------------------------------------------------------- + +test_data_sentinel_fires_without_schema if { + result := conformance.deny with input as _ok_input with data.schema as false + _fires(result, "data_sentinel") +} diff --git a/conformance/data.json b/conformance/data.json new file mode 100644 index 0000000..e8443a0 --- /dev/null +++ b/conformance/data.json @@ -0,0 +1,123 @@ +{ + "_schema_version": "1.0.0", + "schema": { + "required_files": [ + "README.md", + "AGENTS.md", + "CLAUDE.md", + "LICENSE", + "NOTICE", + "CHANGELOG.md", + "CITATION.cff", + "CONTRIBUTING.md", + "SECURITY.md", + ".gitignore", + ".markdownlint-cli2.yaml", + "commitlint.config.js", + "lefthook.yml", + "conformance/affects.json", + "conformance/blast_radius.rego", + "conformance/blast_radius_test.rego", + "audit/blast-radius.jsonl", + "docs/adr/ADR-002-blast-radius-pulse.md" + ], + "required_dirs": [ + ".github", + ".github/workflows", + ".github/ISSUE_TEMPLATE", + "docs", + "docs/agents", + "scripts", + "audit" + ], + "required_github_files": [ + ".github/CODEOWNERS", + ".github/dependabot.yml", + ".github/PULL_REQUEST_TEMPLATE.md", + ".github/workflows/ci.yml", + ".github/workflows/commitlint.yml", + ".github/workflows/pages.yml", + ".github/ISSUE_TEMPLATE/config.yml", + ".github/workflows/validate-branch-name.yml", + ".github/workflows/validate-branch-tier.yml", + ".github/workflows/validate-linked-issue.yml", + ".github/workflows/trust-dial-gate.yml", + ".github/workflows/trust-dial-outcome.yml", + ".github/workflows/blast-radius-pulse.yml", + ".github/workflows/blast-radius-outcome.yml" + ], + "required_agent_docs": [ + "docs/agents/conventions.md", + "docs/agents/citation.md", + "docs/agents/glossary.md", + "docs/agents/enforcement.md" + ], + "required_scripts": [ + "scripts/check-sanitization.sh", + "scripts/pulse.sh" + ], + "artifact_types": [ + "json-schema", + "markdown-spec", + "rego-policy", + "rag-config" + ], + "artifact_type_files": { + "json-schema": { + "default_dir": "schemas", + "primary_validator": "ajv" + }, + "markdown-spec": { + "default_dir": "specs", + "primary_validator": "markdownlint" + }, + "rego-policy": { + "default_dir": "conformance", + "primary_validator": "opa" + }, + "rag-config": { + "default_dir": "configs", + "primary_validator": "ajv" + } + }, + "forbidden_branches": [ + "master" + ], + "gitignore_required_patterns": [ + ".claude/", + ".claude-tmp/", + ".DS_Store" + ] + }, + "content_assertions": { + "agents_md_max_lines": 150, + "claude_md_max_lines": 80, + "claude_md_first_content_line": "@AGENTS.md", + "readme_agents_footer_marker": "For agents" + }, + "thresholds": { + "max_deny_entries": 200 + }, + "policy_integrity": { + "algorithm": "sha256", + "expected_digest": "9138a9387823a4a97cbb5fb1d8966cd774143b7924156a32060f5c109d5fdab9" + }, + "trust_dial_manifest": { + "verdict_policy": "conformance/trust_dial.rego", + "verdict_data": "conformance/trust_dial_data.json", + "state_file": "audit/trust-dial-state.json", + "decision_trace": "audit/decision-trace.jsonl", + "gate_workflow": ".github/workflows/trust-dial-gate.yml", + "outcome_workflow": ".github/workflows/trust-dial-outcome.yml", + "default_tier": "Observed" + }, + "blast_radius_manifest": { + "policy": "conformance/blast_radius.rego", + "manifest": "conformance/affects.json", + "test_suite": "conformance/blast_radius_test.rego", + "trace": "audit/blast-radius.jsonl", + "gate_workflow": ".github/workflows/blast-radius-pulse.yml", + "outcome_workflow": ".github/workflows/blast-radius-outcome.yml", + "scope_dirs": ["conformance/", "template/", "scripts/", "docs/agents/"] + } +} diff --git a/conformance/trust_dial.rego b/conformance/trust_dial.rego new file mode 100644 index 0000000..bd9a5e6 --- /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 conformance.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..08d4415 --- /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 conformance.trust_dial_test + +import data.conformance.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-000-template.md b/docs/adr/ADR-000-template.md new file mode 100644 index 0000000..8c1b95a --- /dev/null +++ b/docs/adr/ADR-000-template.md @@ -0,0 +1,28 @@ +--- +title: "ADR-NNN — short decision title" +status: Proposed +date: YYYY-MM-DD +--- + +## Context + +State the problem, the forces at play, and why a decision is needed now. +Cite supporting evidence with a `file:line` reference so the reasoning stays +traceable. + +## Considered options + +| Option | Notes | +| ------ | ----- | +| Option A | Why it was considered and its trade-offs. | +| Option B | Why it was considered and its trade-offs. | + +## Decision + +State the chosen option in one sentence, then explain why it won over the +alternatives. + +## Consequences + +Describe what becomes easier and what becomes harder as a result. Record any +follow-up action items here so they are not lost. diff --git a/docs/adr/ADR-001-trust-dial-dependabot.md b/docs/adr/ADR-001-trust-dial-dependabot.md new file mode 100644 index 0000000..1ead08d --- /dev/null +++ b/docs/adr/ADR-001-trust-dial-dependabot.md @@ -0,0 +1,97 @@ +--- +title: "ADR-001 — Trust-dial-gated Dependabot auto-merge" +status: Proposed +date: 2026-05-22 +--- + +## Context + +`kellerai-oss-template` ships a Dependabot configuration that *proposes* dependency +updates and stops there: `.github/dependabot.yml:1-16` declares one ecosystem +(`github-actions`), a weekly schedule, a single update group, and `chore` commit prefix +— with **no reviewers, no labels, and no auto-merge gating**. Every Dependabot PR is +therefore either merged by hand or left to rot. There is no record of *why* an update +was accepted, no ladder of increasing automation, and no automatic response when an +accepted update later breaks CI. + +The whitepaper *The Trust Dial: Earned Autonomy for Self-Improving AI +Primitives* (`the-trust-dial.md`, 2026-05-21) frames exactly this gap. A Dependabot PR +is a self-improvement proposal — an automated improver mutating a primitive (the repo's +pinned dependency set). The whitepaper's thesis is that such a loop is only safe to run +when its autonomy is *earned and enforced*, not asserted: *"a designed control that is +not enforced is documentation, not a control"* (`the-trust-dial.md:33`). It mandates +four enforcement layers — an append-only decision trace, ADRs, policy-as-code evaluated +by an engine external to the improver, and use-written telemetry — and a four-tier +autonomy dial (Observed → Assisted → Supervised → Trusted) that *"a new deployment +starts at Observed"* (`the-trust-dial.md:208`) and that *"is not a ratchet"* +(`the-trust-dial.md:212`). + +A decision is needed now because the template is the conformance authority for an entire +family of repositories: whatever auto-merge posture it adopts is inherited by every repo +`scripts/bootstrap.sh` stamps. Shipping ungoverned auto-merge — or no auto-merge at all — +both fail the house standard. The repo already has the substrate to do this right: an +OPA/Rego policy engine (`conformance/conformance.rego`), a SHA-256 policy-integrity +self-tamper check (`conformance.rego:243-254`), a data-driven manifest +(`conformance/data.json`), and a zero-runtime-dependency design (OPA + bash + `gh` only). + +## Considered options + +| Option | Notes | +| ------ | ----- | +| **A — Leave Dependabot ungated** | Status quo. Zero build cost. But it scales as O(humans): every update is a manual review forever, and the whitepaper's §3 argues this is the unscalable reactive posture. Provides no trace, no audit, no earned autonomy. Rejected — it abdicates the house standard the template exists to set. | +| **B — Blanket auto-merge of all patch/minor updates** | Common GitHub-marketplace pattern (`dependabot/fetch-metadata` + a one-line auto-merge action). Cheap and fast. But it asserts a fixed autonomy level with no earned-trust ladder, no decision trace, no demotion-on-regression, and no policy engine — the exact "autonomy as a property, not a budget" anti-pattern the whitepaper rejects (`the-trust-dial.md:264`). Rejected. | +| **C — Trust-dial-gated auto-merge (OPA verdict policy + state file + decision trace + outcome-driven promotion/demotion)** | Implements all four whitepaper enforcement layers for the dependency-update loop. A pure-function Rego policy returns a verdict per `(tier × update-type × ecosystem)`; an append-only committed trace records every decision; a committed state file makes the tier a Git-auditable fact; post-merge CI outcome drives an earned promotion/demotion ladder with a budget cap and a count-based circuit breaker. Higher build cost; touches frozen content; requires a major version bump. | +| **D — External governance service** | A hosted policy/decision service outside the repo. Maximally centralized. Rejected — violates the zero-runtime-dependency invariant and the single-owner, non-replicable reference model; introduces an availability dependency the template must not have. | + +## Decision + +Adopt **Option C**: a trust-dial-gated Dependabot auto-merge system, implemented as +policy-as-code (`conformance/trust_dial.rego`), a committed append-only decision trace +(`audit/decision-trace.jsonl`) plus a retained per-run GitHub Actions artifact, a +committed trust-dial state file (`audit/trust-dial-state.json`), and two GitHub Actions +workflows — a gate (`trust-dial-gate.yml`) that evaluates the verdict and acts on it, and +an outcome workflow (`trust-dial-outcome.yml`) that drives earned promotion and automatic +demotion from post-merge CI results. A freshly bootstrapped repo starts at **Observed**, +whitepaper-mandated. The full design is specified in `trust-dial-dependabot-spec.md`. + +Option C won because it is the only option that satisfies the whitepaper's "enforced, not +asserted" requirement: the verdict matrix is not a slide but a Rego file that the gate +workflow *runs* on every Dependabot PR, and a new conformance deny family +(`trust_dial_wired`) proves the gate is wired rather than merely present. It is the only +option that produces an audit trail, the only one with an earned-autonomy ladder that +demotes on regression, and the only one that respects the zero-runtime-dependency and +single-owner invariants. Options A and B fail the house standard the template exists to +define; option D breaks two named invariants. + +## Consequences + +**Easier.** Dependency hygiene becomes a governed, observable loop: every gate decision +is traced, the current autonomy tier is a queryable Git fact, and a repo earns less +human toil over time by accumulating a clean merge streak. The decision trace makes "why +did this update merge?" a query against a record, not an interrogation. The design closes +or advances five of the fourteen cross-discipline gaps in `gap-roadmap.md` — G-02 +(dependency-hygiene audit), G-08 (conformance/decision observability), G-10 +(decision-traceability for policy-class changes), G-11 (audit trail of policy-integrity +refreezes), and G-12 (templatization scope boundary). + +**Harder.** The build touches frozen content — `conformance/**`, `template/**`, +`scripts/**` — each change paired with a semver-classified `CHANGELOG.md` entry, and the +whole build is a **major** version bump because it adds new *required* files and a new +error-level deny family. Editing `conformance/conformance.rego` mandates the +policy-integrity SHA-256 refreeze (`conformance.rego:243-254`, +`conformance/data.json:90-93`) in the same commit, or CI blocks. The CI write-back loop +(workflows committing state back to the repo) requires three independent recursion +guards. Governed auto-merge is slower than blanket auto-merge — a deliberate cost the +whitepaper names (`the-trust-dial.md:244`). + +**Follow-up action items.** + +- Implement per the phased build checklist in `trust-dial-dependabot-spec.md` §8 — ten + reviewable PRs, steps 7–10 each carrying a CHANGELOG entry. +- Execute the policy-integrity digest refreeze in the same commit as the + `conformance.rego` edit (checklist step 9); record the refreeze in the decision trace. +- Resolve open questions OQ-1..OQ-6 (`trust-dial-dependabot-spec.md` §9) before the + tiers above Observed are exercised in anger — in particular OQ-2 (`outcome_signal` → + ELO/KoTH mapping) and OQ-3 (reusable-workflow factoring). +- Extend `docs/adoption-guide.md` with the migration path for already-bootstrapped repos + (add `audit/` + the two workflows or fail conformance). diff --git a/docs/claude-settings.template.json b/docs/claude-settings.template.json new file mode 100644 index 0000000..65bdfad --- /dev/null +++ b/docs/claude-settings.template.json @@ -0,0 +1,17 @@ +{ + "permissions": { + "allow": [ + "Read", + "Glob", + "Grep", + "Bash(git status:*)", + "Bash(git diff:*)", + "Bash(git log:*)", + "Bash(opa check:*)", + "Bash(opa test:*)", + "Bash(opa eval:*)", + "Bash(jq:*)" + ], + "deny": [] + } +} diff --git a/scripts/preflight.sh b/scripts/preflight.sh new file mode 100755 index 0000000..b94a298 --- /dev/null +++ b/scripts/preflight.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# preflight.sh — pre-work environment check for this repository. +# +# Closes IC-1 (output directories created before work begins) and IC-7 +# (publication readiness). Read-only except for `mkdir -p` of the declared +# artifact directory. Run it before phase work and from the agentic-gates CI +# job. Exits non-zero with a one-line remediation on any failure. +set -euo pipefail + +cd "$(dirname "$0")/.." || exit 1 + +marker=".kellerai-oss.json" +if [ ! -f "$marker" ]; then + echo "preflight: conformance marker $marker is missing" >&2 + exit 1 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "preflight: jq is required but is not on PATH" >&2 + exit 1 +fi + +artifact_dir="$(jq -r '.artifact_dir // empty' "$marker")" +if [ -z "$artifact_dir" ]; then + echo "preflight: artifact_dir is not set in $marker" >&2 + exit 1 +fi + +# IC-1 — ensure the declared artifact directory exists before any phase work. +mkdir -p "$artifact_dir" + +# IC-7 — publication readiness: core governance files must be present. +missing=0 +for f in README.md AGENTS.md CLAUDE.md LICENSE NOTICE CHANGELOG.md lefthook.yml; do + if [ ! -f "$f" ]; then + echo "preflight: required file missing: $f" >&2 + missing=1 + fi +done +if [ "$missing" -ne 0 ]; then + echo "preflight: repository is not publication-ready" >&2 + exit 1 +fi + +echo "preflight: OK — artifact directory '$artifact_dir' present; governance files present" diff --git a/scripts/publish.sh b/scripts/publish.sh new file mode 100755 index 0000000..52808c9 --- /dev/null +++ b/scripts/publish.sh @@ -0,0 +1,322 @@ +#!/usr/bin/env bash +# publish.sh — publication helper for agentic-telemetry-spec. +# +# Runs a pre-flight conformance gate, then (with --confirm) creates the GitHub +# repo, optionally flips visibility to public, and tags v0.1.0. +# +# SAFE BY DEFAULT: --dry-run is ON unless --confirm is passed. Every mutating +# command is printed before execution. Force-push is never used. +# +# Usage: +# publish.sh --repo jonathan-kellerai/agentic-telemetry-spec --description "TEXT" [OPTIONS] +# +# Options: +# --repo OWNER/NAME GitHub repository slug (required) +# --description TEXT Repository description for GitHub (required) +# --visibility public|private +# Target visibility; default: private +# --root PATH Repository root; default: current directory +# --dry-run Print commands only, execute nothing (default) +# --confirm Actually run mutating gh/git commands +# +# Requires: git, gh (GitHub CLI), jq, opa +# +# The conformance gate calls scan-repo-structure.sh (this repo's scripts/ +# sibling) and evaluates data.conformance.conformance via `opa eval`. +# Error-severity violations abort the run even in --dry-run mode. +# +# TOKEN DEFAULTS (may be overridden by flags at call time): +# --repo defaults to jonathan-kellerai/agentic-telemetry-spec +# --description defaults to "Agentic telemetry JSON Schema specification" +# All flag values still take precedence over the compiled-in defaults. +set -euo pipefail + +# --------------------------------------------------------------------------- +# Compiled-in defaults (set by bootstrap.sh token substitution) +# --------------------------------------------------------------------------- +DEFAULT_REPO="jonathan-kellerai/agentic-telemetry-spec" +DEFAULT_DESCRIPTION="Agentic telemetry JSON Schema specification" + +# --------------------------------------------------------------------------- +# Runtime defaults (flags override these) +# --------------------------------------------------------------------------- +repo="$DEFAULT_REPO" +description="$DEFAULT_DESCRIPTION" +visibility="private" +root="" +confirm=0 # 0 = dry-run (safe default); 1 = execute mutating commands + +# --------------------------------------------------------------------------- +# Usage +# --------------------------------------------------------------------------- +usage() { + cat <&2; usage; exit 2 ;; + esac +done + +# --------------------------------------------------------------------------- +# Validate required flags +# --------------------------------------------------------------------------- +err=0 +[ -n "$repo" ] || { echo "publish: --repo is required (or set a DEFAULT_REPO token)" >&2; err=1; } +[ -n "$description" ] || { echo "publish: --description is required (or set a DEFAULT_DESCRIPTION token)" >&2; err=1; } +case "$visibility" in +public | private) ;; +*) echo "publish: --visibility must be 'public' or 'private'" >&2; err=1 ;; +esac +[ "$err" -eq 0 ] || { usage; exit 2; } + +# --------------------------------------------------------------------------- +# Resolve paths +# --------------------------------------------------------------------------- +script_dir="$(cd "$(dirname "$0")" && pwd)" +[ -n "$root" ] || root="$(pwd)" +root="$(cd "$root" && pwd)" + +# scan-repo-structure.sh lives alongside this script in the same scripts/ dir. +scan_script="$script_dir/scan-repo-structure.sh" +[ -f "$scan_script" ] || { + echo "publish: scan-repo-structure.sh not found at $scan_script" >&2 + echo " (expected alongside this script in the repo's scripts/ directory)" >&2 + exit 1 +} + +# The conformance policy is vendored at conformance/ in this repository. +# By default the script resolves it relative to the repo root (one level above +# scripts/). Set KELLERAI_CONFORMANCE_DIR to override with an external copy. +conformance_dir="${KELLERAI_CONFORMANCE_DIR:-}" +if [ -z "$conformance_dir" ]; then + # Default: use the repo-local vendored conformance/ directory. + candidate="$(cd "$script_dir/.." && pwd)/conformance" + [ -d "$candidate" ] && conformance_dir="$candidate" +fi +[ -n "$conformance_dir" ] || { + echo "publish: conformance/ policy directory not found." >&2 + echo " Expected at conformance/ in the repo root, or set KELLERAI_CONFORMANCE_DIR." >&2 + exit 1 +} +[ -f "$conformance_dir/conformance.rego" ] || { + echo "publish: conformance.rego not found at $conformance_dir" >&2 + exit 1 +} +[ -f "$conformance_dir/data.json" ] || { + echo "publish: conformance data.json not found at $conformance_dir" >&2 + exit 1 +} + +# --------------------------------------------------------------------------- +# Dry-run mode banner +# --------------------------------------------------------------------------- +if [ "$confirm" -eq 0 ]; then + echo "=== DRY-RUN MODE (pass --confirm to execute mutating commands) ===" + echo +fi + +# --------------------------------------------------------------------------- +# Helper: run_cmd +# Prints the command, then either executes it (--confirm) or skips it +# (dry-run). Never used for read-only pre-flight steps. +# --------------------------------------------------------------------------- +run_cmd() { + echo " + $*" + if [ "$confirm" -eq 1 ]; then + "$@" + fi +} + +echo "publish: target repo : $repo" +echo "publish: description : $description" +echo "publish: visibility : $visibility" +echo "publish: root : $root" +echo + +# =========================================================================== +# PRE-FLIGHT GATE — always runs, even in dry-run mode +# =========================================================================== +echo "--- pre-flight ---" + +# 1. Must be inside a git repository. +cd "$root" +git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { + echo "publish: ABORT — $root is not a git repository" >&2 + exit 1 +} + +# 2. Working tree must be clean (no unstaged changes, no untracked files). +status_out="$(git status --porcelain 2>&1)" +if [ -n "$status_out" ]; then + echo "publish: ABORT — working tree is not clean:" >&2 + echo "$status_out" >&2 + exit 1 +fi +echo " [ok] working tree is clean" + +# 3. Must be on branch 'main'. +current_branch="$(git symbolic-ref --short HEAD 2>/dev/null || true)" +if [ "$current_branch" != "main" ]; then + echo "publish: ABORT — current branch is '$current_branch'; publication requires branch 'main'" >&2 + exit 1 +fi +echo " [ok] on branch main" + +# 4. 'master' branch must not exist (locally or as a remote tracking ref). +if git show-ref --verify --quiet refs/heads/master 2>/dev/null || \ + git show-ref --verify --quiet refs/remotes/origin/master 2>/dev/null; then + echo "publish: ABORT — a 'master' branch exists; the default branch must be 'main'" >&2 + exit 1 +fi +echo " [ok] no 'master' branch" + +# 5. History: no in-progress merge state. +if [ -f "$(git rev-parse --git-dir)/MERGE_HEAD" ]; then + echo "publish: ABORT — repository is in the middle of a merge" >&2 + exit 1 +fi +commit_count="$(git rev-list --count HEAD 2>/dev/null || echo 0)" +echo " [ok] commit history: $commit_count commit(s), no in-progress merge" + +# 6. Conformance check — scan the repo and evaluate with OPA. +echo +echo " running conformance scan..." + +for tool in jq opa; do + command -v "$tool" >/dev/null 2>&1 || { + echo "publish: ABORT — '$tool' is required for the conformance gate but was not found on PATH" >&2 + exit 1 + } +done + +snapshot="$(mktemp)" +trap 'rm -f "$snapshot"' EXIT + +"$scan_script" --root "$root" >"$snapshot" + +summary_json="$( + opa eval \ + --data "$conformance_dir/data.json" \ + --data "$conformance_dir/conformance.rego" \ + --input "$snapshot" \ + --format raw \ + 'data.conformance.conformance.summary' +)" + +allow="$(printf '%s' "$summary_json" | jq -r '.allow // false')" +error_count="$(printf '%s' "$summary_json" | jq -r '.errors // 0')" +warning_count="$(printf '%s' "$summary_json" | jq -r '.warnings // 0')" + +echo " conformance result: allow=$allow errors=$error_count warnings=$warning_count" + +if [ "$allow" != "true" ]; then + echo + echo " error-severity violations:" + opa eval \ + --data "$conformance_dir/data.json" \ + --data "$conformance_dir/conformance.rego" \ + --input "$snapshot" \ + --format raw \ + '[x | x := data.conformance.conformance.errors[_]; x]' | + jq -r '.[] | " [error] \(.rule): \(.msg)"' >&2 || true + echo + echo "publish: ABORT — $error_count error-severity conformance violation(s) must be resolved before publication" >&2 + exit 1 +fi + +if [ "$warning_count" -gt 0 ]; then + echo " (non-blocking) warnings:" + opa eval \ + --data "$conformance_dir/data.json" \ + --data "$conformance_dir/conformance.rego" \ + --input "$snapshot" \ + --format raw \ + '[x | x := data.conformance.conformance.warnings[_]; x]' | + jq -r '.[] | " [warn] \(.rule): \(.msg)"' || true +fi + +echo " [ok] conformance gate passed" +echo + +# =========================================================================== +# PUBLICATION COMMANDS +# =========================================================================== +echo "--- publication steps ---" +echo + +# Step 1: Create the GitHub repository private first (allows review before +# going public, regardless of the requested --visibility). +echo "Step 1: create GitHub repository (private)" +run_cmd gh repo create "$repo" \ + --private \ + --source="$root" \ + --remote=origin \ + --push \ + --description "$description" +echo + +# Step 2: Flip to public if requested. +if [ "$visibility" = "public" ]; then + echo "Step 2: set repository visibility to public" + run_cmd gh repo edit "$repo" --visibility public +else + echo "Step 2: skipped — visibility is already 'private' (requested)" +fi +echo + +# Step 3: Tag v0.1.0 and push the tag. +echo "Step 3: tag v0.1.0 and push" +run_cmd git -C "$root" tag -a v0.1.0 -m "Initial public release" +run_cmd git -C "$root" push origin v0.1.0 +echo + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +if [ "$confirm" -eq 1 ]; then + echo "publish: done." + echo " repo : https://github.com/$repo" + echo " tag : v0.1.0" + echo " visibility: $visibility" +else + echo "=== DRY-RUN complete — no commands were executed." + echo " Re-run with --confirm to publish." +fi diff --git a/scripts/scan-repo-structure.sh b/scripts/scan-repo-structure.sh new file mode 100755 index 0000000..74949d1 --- /dev/null +++ b/scripts/scan-repo-structure.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# scan-repo-structure.sh — emit a repo-structure.json snapshot for the +# OSS governance library conformance standard (conformance/conformance.rego). +# +# Read-only. Prints JSON to stdout. Tracked files only (git ls-files) when the +# target is a git repo; falls back to a filesystem walk otherwise. +set -euo pipefail + +artifact_type="" +artifact_dir="" +root="" +while [ "$#" -gt 0 ]; do + case "${1:-}" in + --artifact-type) artifact_type="${2:-}"; shift 2 ;; + --artifact-dir) artifact_dir="${2:-}"; shift 2 ;; + --root) root="${2:-}"; shift 2 ;; + -h | --help) + echo "usage: scan-repo-structure.sh [--artifact-type T] [--artifact-dir D] [--root PATH]" + exit 0 + ;; + *) + echo "scan-repo-structure: unknown argument: ${1:-}" >&2 + exit 2 + ;; + esac +done + +[ -n "$root" ] || root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +cd "$root" + +is_git=0 +git rev-parse --is-inside-work-tree >/dev/null 2>&1 && is_git=1 + +# artifact type / dir: CLI flag wins, else the .kellerai-oss.json marker. +marker=".kellerai-oss.json" +if [ -f "$marker" ]; then + [ -n "$artifact_type" ] || artifact_type="$(jq -r '.artifact_type // empty' "$marker" 2>/dev/null || true)" + [ -n "$artifact_dir" ] || artifact_dir="$(jq -r '.artifact_dir // empty' "$marker" 2>/dev/null || true)" +fi + +# repo slug from the origin remote, if any. +repo="$(basename "$root")" +owner="" +remote="$(git config --get remote.origin.url 2>/dev/null || true)" +case "$remote" in +*github.com*) + trimmed="${remote#*github.com}" + trimmed="${trimmed#:}" + trimmed="${trimmed#/}" + trimmed="${trimmed%.git}" + owner="${trimmed%%/*}" + [ "${trimmed#*/}" != "$trimmed" ] && repo="${trimmed##*/}" + ;; +esac + +sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +first_content_line() { + awk 'NF==0{next} /^[[:space:]]*