diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 80459342..e9165508 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -53,8 +53,6 @@ steps: timeout_in_minutes: 50 artifact_paths: - "tabellio-validation-result.json" - - ".artifacts/tabellio/validation-ref.bundle" - - ".artifacts/tabellio/validation-ref.sha" plugins: - mise#v1.1.4: version: "2026.2.11" diff --git a/.buildkite/scripts/checkpoint-evidence.sh b/.buildkite/scripts/checkpoint-evidence.sh new file mode 100644 index 00000000..9512dfc1 --- /dev/null +++ b/.buildkite/scripts/checkpoint-evidence.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail +# Import only native checkpoint history explicitly supplied by the operator. +# The subsequent product gate still verifies metadata and exact-candidate association. +checkpoint_ref=refs/heads/entire/checkpoints/v1 +if [[ -n "${TABELLIO_CHECKPOINT_BUNDLE:-}" ]]; then + bundle="$TABELLIO_CHECKPOINT_BUNDLE" + if [[ ! -f "$bundle" ]] || ! git bundle verify "$bundle" >/dev/null 2>&1; then + echo 'Checkpoint setup blocked: supplied bundle is missing, invalid, or has unavailable prerequisites.' >&2 + exit 1 + fi + if ! git bundle list-heads "$bundle" "$checkpoint_ref" | awk -v ref="$checkpoint_ref" '$2 == ref { found=1 } END { exit !found }'; then + echo 'Checkpoint setup blocked: bundle does not advertise the native Entire checkpoint ref.' >&2 + exit 1 + fi + # No force: divergent existing history requires explicit operator reconciliation. + if head -n 4 "$bundle" | grep -q '^@filter='; then + # Native filtered bundles preserve original object IDs and use promisor packs. + # Ordinary fetch rejects their intentionally absent transcript/prompt blobs. + advertised="$(git bundle list-heads "$bundle")" + imported="$(printf '%s\n' "$advertised" | awk -v ref="$checkpoint_ref" '$2 == ref { print $1 }')" + if [[ "$(printf '%s\n' "$advertised" | wc -l | tr -d ' ')" != 1 ]] || + ! git bundle unbundle "$bundle" >/dev/null 2>&1; then + echo 'Checkpoint setup blocked: filtered native history import failed.' >&2 + exit 1 + fi + current="$(git rev-parse --verify "$checkpoint_ref" 2>/dev/null || true)" + if [[ -n "$current" ]] && ! git merge-base --is-ancestor "$current" "$imported"; then + echo 'Checkpoint setup blocked: divergent native history requires operator reconciliation.' >&2 + exit 1 + fi + if ! git update-ref "$checkpoint_ref" "$imported" "$current"; then + echo 'Checkpoint setup blocked: native history changed during import.' >&2 + exit 1 + fi + elif ! git fetch --no-tags "$bundle" "$checkpoint_ref:$checkpoint_ref" >/dev/null 2>&1; then + echo 'Checkpoint setup blocked: native checkpoint import failed; reconcile existing history on the trusted worker.' >&2 + exit 1 + fi +fi +if ! git rev-parse --verify "$checkpoint_ref^{commit}" >/dev/null 2>&1; then + echo 'Checkpoint setup blocked: use a trusted worker with genuine native Entire history, or supply TABELLIO_CHECKPOINT_BUNDLE from customer-owned private storage. Never upload session records to public CI artifacts.' >&2 + exit 1 +fi diff --git a/.buildkite/scripts/entire-tools.sh b/.buildkite/scripts/entire-tools.sh new file mode 100644 index 00000000..8b0f4240 --- /dev/null +++ b/.buildkite/scripts/entire-tools.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Source to retain PATH in Buildkite; GitHub receives it through GITHUB_PATH. +entire_tools_root="${TABELLIO_ENTIRE_TOOLS_DIR:-${RUNNER_TEMP:-${TMPDIR:-/tmp}}/tabellio-entire-0.7.7}" +case "$(uname -s)-$(uname -m)" in + Linux-x86_64) + entire_archive_name=entire_linux_amd64.tar.gz + entire_archive_digest=63d89e2ac57fc52307907a15b168a7b0bf7b114993b27f8fa943dfdb4159ad26 + ;; + Darwin-arm64) + entire_archive_name=entire_darwin_arm64.tar.gz + entire_archive_digest=960f25c18ebd7fa66c00081fc0abff64ce34774ff7ce0c03e3b5d12a4b134568 + ;; + *) printf '%s\n' 'Unsupported Entire tool platform.' >&2; exit 1 ;; +esac +mkdir -p "$entire_tools_root" +entire_archive_path="$entire_tools_root/$entire_archive_name" +if [[ ! -f "$entire_archive_path" ]]; then + curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' --tlsv1.2 \ + --connect-timeout 15 --max-time 120 \ + "https://github.com/entireio/cli/releases/download/v0.7.7/$entire_archive_name" \ + --output "$entire_archive_path" +fi +node --input-type=module - "$entire_archive_path" "$entire_archive_digest" <<'NODE' +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +const actual = createHash('sha256').update(readFileSync(process.argv[2])).digest('hex'); +if (actual !== process.argv[3]) throw new Error('Entire archive integrity mismatch.'); +NODE +tar -xzf "$entire_archive_path" -C "$entire_tools_root" entire +export PATH="$entire_tools_root:$PATH" +entire_version_output="$(entire version)" +grep -Eq '^Entire CLI v?0\.7\.7([[:space:]]|$)' <<< "$entire_version_output" +if [[ -n "${GITHUB_PATH:-}" ]]; then + printf '%s\n' "$entire_tools_root" >> "$GITHUB_PATH" +fi diff --git a/.buildkite/scripts/product-validation.sh b/.buildkite/scripts/product-validation.sh index 7668e94c..807c9a59 100755 --- a/.buildkite/scripts/product-validation.sh +++ b/.buildkite/scripts/product-validation.sh @@ -16,12 +16,22 @@ fi . .buildkite/scripts/verify-git-toolchain.sh . .buildkite/scripts/security-tools.sh +. .buildkite/scripts/entire-tools.sh postgres_bin="$(pg_config --bindir)" test -x "$postgres_bin/initdb" test -x "$postgres_bin/pg_ctl" export PATH="$postgres_bin:$PATH" +bash .buildkite/scripts/checkpoint-evidence.sh + +repository_id="${TABELLIO_REPO_ID:-}" +if [[ -z "$repository_id" ]]; then + repository_url="$(git remote get-url origin)" + repository_id="$(node -e 'const u=process.argv[1]; const m=u.match(/^(?:https:\/\/github\.com\/|git@github\.com:)([^/]+\/[^/]+?)(?:\.git)?$/); if(!m)process.exit(1); console.log(m[1]);' "$repository_url")" +fi +[[ "$repository_id" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || { echo "A valid customer repository ID is required." >&2; exit 1; } + candidate="${BUILDKITE_COMMIT:-HEAD}" base_branch="${BUILDKITE_PULL_REQUEST_BASE_BRANCH:-${TABELLIO_BASE_BRANCH:-main}}" base_ref="origin/${base_branch}" @@ -30,6 +40,7 @@ checkpoint_args=() git fetch --no-tags origin "+refs/heads/${base_branch}:refs/remotes/origin/${base_branch}" test "$(git rev-parse HEAD^{commit})" = "$(git rev-parse "${candidate}^{commit}")" +umask 077 temporary_dir="$(mktemp -d)" trap 'rm -rf "$temporary_dir"' EXIT @@ -47,7 +58,7 @@ if [[ "$default_branch_build" == "true" ]]; then fi if ! curl --fail --silent --show-error \ "${github_headers[@]}" \ - "https://api.github.com/repos/IntelIP/Tabellio/commits/${candidate}/pulls" \ + "https://api.github.com/repos/${repository_id}/commits/${candidate}/pulls" \ | node scripts/resolve-merged-checkpoint.mjs \ --commit "$candidate" \ --github-output "$checkpoint_output"; then @@ -84,19 +95,19 @@ fi install -m 755 scripts/tabellio-validator.mjs "$temporary_dir/tabellio-validator" -PATH="$temporary_dir:$PATH" node scripts/tabellio-validate.mjs gate \ +if ! PATH="$temporary_dir:$PATH" node scripts/tabellio-validate.mjs gate \ --repo . \ - --repo-id IntelIP/Tabellio \ + --repo-id "$repository_id" \ --runner-id "buildkite:${BUILDKITE_BUILD_ID:?Buildkite build ID is required}" \ --base "$base_ref" \ --commit HEAD \ "${checkpoint_args[@]}" \ --manifest tabellio.validation.json \ - | tee tabellio-validation-result.json + > "$temporary_dir/tabellio-validation-private.json" 2> "$temporary_dir/validation-stderr.log"; then + node scripts/validation-public-summary.mjs "$temporary_dir/tabellio-validation-private.json" tabellio-validation-result.json + printf '%s\n' "Product validation failed; inspect the trusted worker result. Missing genuine checkpoint evidence must fail the gate." >&2 + exit 1 +fi -validation_ref="refs/tabellio/validations" -validation_commit="$(git rev-parse "${validation_ref}^{commit}")" -mkdir -p .artifacts/tabellio -git bundle create .artifacts/tabellio/validation-ref.bundle "$validation_ref" -printf '%s\n' "$validation_commit" > .artifacts/tabellio/validation-ref.sha -git bundle verify .artifacts/tabellio/validation-ref.bundle +node scripts/validation-public-summary.mjs "$temporary_dir/tabellio-validation-private.json" tabellio-validation-result.json +# Native validation refs remain private on the trusted worker; never upload them as public CI artifacts. diff --git a/.buildkite/scripts/provenance-coverage.sh b/.buildkite/scripts/provenance-coverage.sh index e84f487f..0e0fbd66 100644 --- a/.buildkite/scripts/provenance-coverage.sh +++ b/.buildkite/scripts/provenance-coverage.sh @@ -6,8 +6,64 @@ test -x "$postgres_bin/initdb" test -x "$postgres_bin/pg_ctl" export PATH="$postgres_bin:$PATH" -c8 --exclude '**/node_modules/**' --reporter=json --reports-dir coverage node scripts/demo-provenance.mjs \ - --verify-storage-tests true --out coverage/provenance-demo.json +# Run the storage/security/publication suite and changed local-workflow tests +# directly with one file worker, so actual two-process race tests remain intact +# while unrelated Git fixtures do not compete. Their individual +# test deadlines and the existing job deadline apply instead of the demo's +# unrelated 60-second child-process deadline. Do not skip PostgreSQL evidence. +umask 077 +coverage_pg_root="$(mktemp -d /tmp/tabellio-coverage-pg.XXXXXX)" +coverage_pg_data="$coverage_pg_root/data" +coverage_pg_socket="$coverage_pg_root/socket" +cleanup_coverage_postgres() { + [[ -n "$coverage_pg_root" ]] || return 0 + if [[ -f "$coverage_pg_data/PG_VERSION" ]]; then + if pg_ctl -D "$coverage_pg_data" status >/dev/null 2>&1; then + if ! pg_ctl -D "$coverage_pg_data" -m fast -w -t 30 stop >/dev/null 2>&1; then + printf '%s\n' "Coverage PostgreSQL cleanup failed; preserve $coverage_pg_root for recovery." >&2 + return 1 + fi + else + coverage_pg_status=$? + if [[ "$coverage_pg_status" != 3 ]]; then + printf '%s\n' "Coverage PostgreSQL state is uncertain; preserve $coverage_pg_root for recovery." >&2 + return 1 + fi + fi + fi + if ! rm -rf "$coverage_pg_root"; then + printf '%s\n' "Coverage PostgreSQL directory cleanup failed; preserve $coverage_pg_root for recovery." >&2 + return 1 + fi + coverage_pg_root= +} +trap 'coverage_exit=$?; trap - EXIT; if ! cleanup_coverage_postgres; then exit 1; fi; exit "$coverage_exit"' EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +mkdir "$coverage_pg_socket" +initdb -D "$coverage_pg_data" --auth-local=trust --auth-host=reject \ + --username=tabellio --encoding=UTF8 --no-locale >/dev/null +pg_ctl -D "$coverage_pg_data" -l "$coverage_pg_root/postgres.log" \ + -o "-c listen_addresses='' -k $coverage_pg_socket" -w -t 30 start >/dev/null +TABELLIO_REQUIRE_POSTGRES=1 TABELLIO_TEST_PG_SOCKET="$coverage_pg_socket" TABELLIO_TEST_PG_USER=tabellio \ + c8 --exclude '**/node_modules/**' --reporter=json --reports-dir coverage \ + node --test --test-concurrency=1 tests/local-provenance-store.test.mjs tests/provenance-ledger.test.mjs \ + tests/provenance-sources.test.mjs tests/provenance-security.test.mjs \ + tests/provenance-security-scanners.test.mjs tests/provenance-review-result.test.mjs \ + tests/provenance-review-publication.test.mjs \ + tests/platform-config.test.mjs tests/preflight.test.mjs \ + tests/release-workflow.test.mjs tests/release-cli.test.mjs tests/checkpoint-evidence.test.mjs +cleanup_coverage_postgres + +# Preserve the same real recovery demo coverage without rerunning that suite +# through its nested child-process wrapper. +c8 --clean=false --exclude '**/node_modules/**' --reporter=json --reports-dir coverage \ + node scripts/demo-provenance.mjs --out coverage/provenance-demo.json + +# Include the policy benchmark and negative setup diagnostics without discarding +# the integration demo coverage collected above. +c8 --clean=false --exclude '**/node_modules/**' --reporter=json --reports-dir coverage \ + node --test --test-concurrency=1 tests/provenance-benchmark.test.mjs tests/demo-provenance.test.mjs tests/validation-checkpoints.test.mjs # Fallow 2.89 consumes function/statement coverage but rejects c8's unknown # branch-column sentinel (-1). Preserve the original report; omit only branch diff --git a/.buildkite/scripts/verify-git-toolchain.sh b/.buildkite/scripts/verify-git-toolchain.sh index 8c41e06b..01db85a7 100755 --- a/.buildkite/scripts/verify-git-toolchain.sh +++ b/.buildkite/scripts/verify-git-toolchain.sh @@ -44,7 +44,8 @@ git merge-base --is-ancestor "$head_commit" "$head_commit" ( temporary_dir="$(mktemp -d)" trap 'rm -rf "$temporary_dir"' EXIT - git bundle create "$temporary_dir/capability.bundle" --all + # Probe the public candidate without traversing private, intentionally filtered refs. + git bundle create "$temporary_dir/capability.bundle" HEAD git bundle verify "$temporary_dir/capability.bundle" >/dev/null ) diff --git a/.github/workflows/product-validation.yml b/.github/workflows/product-validation.yml index 480bb6c2..27a6a6fb 100644 --- a/.github/workflows/product-validation.yml +++ b/.github/workflows/product-validation.yml @@ -6,12 +6,17 @@ on: branches: [main] workflow_dispatch: +concurrency: + group: product-validation-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + permissions: contents: read pull-requests: read jobs: validate: + environment: tabellio-checkpoint-proof runs-on: ubuntu-latest timeout-minutes: 50 steps: @@ -21,7 +26,7 @@ jobs: persist-credentials: false ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: {node-version: 22} + with: {node-version: 22.23.1, package-manager-cache: false} - name: Enable local PostgreSQL binaries for isolated provenance validation run: | postgres_bin="$(pg_config --bindir)" @@ -30,6 +35,8 @@ jobs: echo "$postgres_bin" >> "$GITHUB_PATH" - name: Install pinned security scanners run: bash .buildkite/scripts/security-tools.sh + - name: Install checksum-verified Entire 0.7.7 + run: bash .buildkite/scripts/entire-tools.sh - name: Resolve squash-merge checkpoint revision id: checkpoint if: github.event_name == 'push' @@ -55,12 +62,20 @@ jobs: git fetch --no-tags origin "+$CHECKPOINT_FETCH_REF:$CHECKPOINT_LOCAL_REF" actual_head="$(git rev-parse "$CHECKPOINT_LOCAL_REF^{commit}")" test "$actual_head" = "$CHECKPOINT_EXPECTED_HEAD" + - name: Load approved exact-candidate checkpoint evidence + env: + TABELLIO_CHECKPOINT_PROOF_1: ${{ secrets.TABELLIO_CHECKPOINT_PROOF_1 }} + TABELLIO_CHECKPOINT_PROOF_2: ${{ secrets.TABELLIO_CHECKPOINT_PROOF_2 }} + run: node scripts/ci-checkpoint-evidence.mjs load - name: Validate exact candidate env: CHECKPOINT_FOUND: ${{ steps.checkpoint.outputs.found }} CHECKPOINT_HEAD: ${{ steps.checkpoint.outputs.head }} run: | set -o pipefail + umask 077 + private_dir="$(mktemp -d)" + trap 'rm -rf "$private_dir"' EXIT base_ref=origin/main checkpoint_args=() if [ "$GITHUB_EVENT_NAME" = "push" ]; then @@ -70,19 +85,28 @@ jobs: fi fi install -m 755 scripts/tabellio-validator.mjs "$RUNNER_TEMP/tabellio-validator" - PATH="$RUNNER_TEMP:$PATH" node scripts/tabellio-validate.mjs gate \ + if ! PATH="$RUNNER_TEMP:$PATH" node scripts/tabellio-validate.mjs gate \ --repo . \ - --repo-id IntelIP/Tabellio \ + --repo-id "github.com/$GITHUB_REPOSITORY" \ + --runner-id "github:$GITHUB_RUN_ID:$GITHUB_RUN_ATTEMPT" \ --base "$base_ref" \ --commit HEAD \ "${checkpoint_args[@]}" \ - --manifest tabellio.validation.json | tee tabellio-validation-result.json - - name: Upload validation result + --manifest tabellio.validation.json > "$private_dir/tabellio-validation-private.json" 2> "$private_dir/validation-stderr.log"; then + node scripts/validation-public-summary.mjs "$private_dir/tabellio-validation-private.json" tabellio-validation-result.json + echo "Product validation failed; inspect the trusted worker result. Missing genuine checkpoint evidence must fail the gate." >&2 + exit 1 + fi + node scripts/validation-public-summary.mjs "$private_dir/tabellio-validation-private.json" tabellio-validation-result.json + - name: Publish safe validation summary without artifact storage if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: tabellio-validation-${{ github.event.pull_request.head.sha || github.sha }} - path: | - tabellio-validation-result.json - refs/tabellio/validations/ - if-no-files-found: warn + run: | + if [ -f tabellio-validation-result.json ]; then + cat tabellio-validation-result.json + cat tabellio-validation-result.json >> "$GITHUB_STEP_SUMMARY" + else + echo 'Product validation blocked before a safe result was produced.' >> "$GITHUB_STEP_SUMMARY" + fi + - name: Remove private checkpoint runner files + if: always() + run: node scripts/ci-checkpoint-evidence.mjs cleanup diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 29634624..c8cf64dd 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -11,6 +11,8 @@ on: required: true default: main type: string + push: + branches: [main] permissions: contents: read @@ -27,15 +29,17 @@ jobs: steps: - name: Check out source uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 20 + node-version: 22.23.1 package-manager-cache: false - - name: Install pinned security scanners - run: bash .buildkite/scripts/security-tools.sh - name: Run repository checks - run: npm run check + run: bash .buildkite/scripts/tests.sh fallow: name: Fallow changed-code @@ -46,10 +50,12 @@ jobs: uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 20 + node-version: 22.23.1 package-manager-cache: false - name: Install pinned security scanners run: bash .buildkite/scripts/security-tools.sh @@ -82,16 +88,13 @@ jobs: steps: - name: Check out source uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 20 + node-version: 22.23.1 package-manager-cache: false - name: Validate package contents - run: | - npm pack --dry-run --json > package-dry-run.json - jq '.[0] | {id, size, unpackedSize, entryCount}' package-dry-run.json - jq -e ' - length == 1 and - ([.[0].files[].path | select(test("forgejo|change-request-provider"; "i"))] | length == 0) - ' package-dry-run.json >/dev/null + run: bash .buildkite/scripts/package.sh diff --git a/README.md b/README.md index 90fa117d..63207cc4 100644 --- a/README.md +++ b/README.md @@ -115,14 +115,14 @@ and publishes separate `Tabellio / provenance review` and `tabellio-provenance-status-approval/v0.1` with `id`, `intentDigest`, `approved: true`, `approvedBy`, `approvedAt`, `expiresAt`, and `reason`; its lifetime is at most one hour. Publication receipts report delivery separately from review verdicts. -Each intent explicitly targets the configured `control` remote. Each approval is -reserved there in `refs/tabellio/provenance-status-reservations/` before -GitHub delivery. Production publication uses authenticated `gh` to verify a -separate private control repository and consistent fetch/push targets before -each control write. Remote compare-and-swap allows one publisher across clones; -repeated requests reuse the receipt, and uncertain attempts require inspection -before a new approval. The local demo exercises this flow through a fake GitHub -transport and compares the delivered states with the CLI result. +Local operation needs no second GitHub repository. For status publication, select +one existing customer-owned bare Git authority with `--publication-store +/absolute/private/authority.git` when creating the intent. Every publisher must +use the same authority. Its atomic reservations prevent duplicate approval use; +uncertain delivery remains blocked until reconciled. This is a trusted-worker +contract, not distributed safety across independent local stores. Optional private +GitHub control transport remains available explicitly for existing deployments. +The demo uses synthetic provider records and a fake GitHub transport. The `tabellio-provenance` CLI supports `capture`, `import`, `import-sources`, `replay`, `replay-sources`, `show`, `review`, `review-intent`, `publish-review`, and `packet`. `import-sources` normalizes a bundle of Plane, @@ -229,13 +229,13 @@ locally packed candidate. Registry availability is part of the release gate. Enable the required ledger, initialize stacks, and validate the canonical platform contract: ```bash -entire enable --agent codex --project +entire enable --agent codex --project --skip-push-sessions git-spice repo init npm run tabellio:platform:check node scripts/tabellio-validate.mjs run --repo . --commit HEAD --manifest tabellio.validation.json ``` -Configure Entire's supported `strategy_options.checkpoint_remote` in `.entire/settings.json` to target the private GitHub control repository, and keep `strategy_options.push_sessions` false. Tabellio preflight fails closed when the effective checkpoint remote differs from the platform control remote or automatic checkpoint pushing could bypass release approval. +The default v0.4 platform keeps Entire checkpoint, review and validation refs locally. Keep `strategy_options.push_sessions` false for local operation. An existing checkpoint remote remains inactive and is not contacted. Genuine native checkpoint metadata is still required; a trailer alone cannot satisfy validation. Existing v0.3 private-remote configurations remain supported explicitly. Use `gate` in CI. It persists the same exact-head result but exits non-zero unless the final decision is `passed`: @@ -378,6 +378,7 @@ The external-action checker fails when an action is marked `attempted: true` wit - [Exact-commit validation](docs/validation-runner.md) - [Exact-head validation status](docs/merge-ready-status.md) - [Operations hardening](docs/operations-hardening.md) +- [Core pilot acceptance and benchmark plan](docs/pilot/acceptance-plan.md) - [Workflow model](docs/workflow-model.md) - [Native Git foundation](docs/native-git-foundation.md) - [Evidence schema](docs/evidence-schema.md) diff --git a/docs/getting-started.md b/docs/getting-started.md index 8322d5b3..462fd482 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -28,10 +28,10 @@ GitHub is the canonical code store through the `origin` remote. Tabellio does no Enable Entire for Codex before creating agent commits: ```bash -entire enable --agent codex --project +entire enable --agent codex --project --skip-push-sessions ``` -Entire stores checkpoints on `refs/heads/entire/checkpoints/v1`. Configure its supported `strategy_options.checkpoint_remote` in `.entire/settings.json` to target the private repository named by Tabellio's control remote, and keep `strategy_options.push_sessions` false so checkpoint publication remains approval-gated. Preflight fails closed when project or local settings redirect checkpoints elsewhere, when automatic checkpoint pushing is enabled, or when release checkpoint metadata is missing. +Entire stores genuine checkpoints locally on `refs/heads/entire/checkpoints/v1`. The default v0.4 local platform requires `strategy_options.push_sessions: false` ; any previously configured remote stays inactive and is not contacted. Do not push this private branch to public `origin`. Preflight checks native metadata; missing release evidence remains blocking. Explicit legacy private-remote mode retains destination and privacy checks. Every agent change range must contain at least one `Entire-Checkpoint` commit trailer. Context capture fails closed when no checkpoint exists. Use `--ledger git-note` only while migrating an older repository. @@ -52,7 +52,7 @@ The snapshot adapter uses documented JSON output and disables change-request sta ## Configure The Platform -`tabellio.platform.json` makes the operating model explicit: GitHub code storage, git-spice stacks, and a private GitHub `control` remote for Entire checkpoints, validation, and review state. +`tabellio.platform.json` v0.4 defaults to GitHub code storage, git-spice stacks and customer-owned local checkpoint, validation and review refs. No control repository or private GitHub credential is required for local review. v0.3 remains the legacy explicit private-remote configuration. ```bash npm run tabellio:platform:check @@ -70,7 +70,7 @@ node scripts/tabellio-validate.mjs run \ The worker can be a local agent or an operator-managed scheduled service. Tabellio's contract stays identical. -## Share Control State +## Optional Sharing Of Control State Review cycles, validation results, and Entire checkpoints use standard Git refs. Publishing or fetching them requires an integrity-bound plan and short-lived approval: @@ -140,7 +140,7 @@ node scripts/tabellio-release.mjs execute \ --approval /secure/tabellio-release-approval.json ``` -Planning accepts only the validation manifest named by `tabellio.platform.json`. It runs commands on the exact merged commit and binds checkpoint evidence to the pre-merge pull-request head, so a squash merge does not erase proof. Execution accepts canonical HTTPS, SCP-style SSH, and `ssh://git@github.com/...` remotes, resolves Git fetch/push URL rewrites, rechecks the distinct repository identities and current private control visibility, then publishes exact private control refs, a deterministic annotated tag, and the GitHub release. Merge stays outside this command because the final squash commit must exist before release approval can bind it. +Local release plans bind the exact three local evidence-ref tips and recheck them before execution; they do not claim remote publication. Legacy remote release plans retain approved publication. Planning accepts only the validation manifest named by `tabellio.platform.json`. It runs commands on the exact merged commit and binds checkpoint evidence to the pre-merge pull-request head, so a squash merge does not erase proof. Execution accepts canonical HTTPS, SCP-style SSH, and `ssh://git@github.com/...` remotes, resolves Git fetch/push URL rewrites, rechecks the distinct repository identities and current private control visibility, then publishes exact private control refs, a deterministic annotated tag, and the GitHub release. Merge stays outside this command because the final squash commit must exist before release approval can bind it. ## Local Validation @@ -199,6 +199,34 @@ Keep the first PR small: 2. Enable Entire and initialize git-spice. 3. Push a code branch to `origin` and open a thin pull request. 4. Run exact-head validation and sync the durable review cycle. -5. Publish control refs to the configured external destination with an approved one-use operation. +5. Back up the customer-owned local evidence refs. If optional remote mode is explicitly selected, publish them to its configured private destination with an approved one-use operation. Before production deployment, apply the concurrency, worker isolation, backup, and monitoring guidance in [Operations hardening](operations-hardening.md). + +## Local publication authority + +Read-only packets and reviews need no publication authority. To publish GitHub +statuses, first create one private bare Git repository on a trusted worker: + +```bash +git init --bare /absolute/private/tabellio-publication.git +``` + +Pass `--publication-store /absolute/private/tabellio-publication.git` to +`tabellio-provenance review-intent`, or set `TABELLIO_PUBLICATION_STORE`. All +publishers must share this same authority. The approved intent binds its path. +Keep this store outside the code checkout, protect it with ordinary account +permissions, and back it up. Independent clones with separate stores cannot +coordinate approval consumption. No token is needed until explicitly publishing +GitHub statuses. Pending delivery after a crash is unresolved, not retryable. + +A fresh hosted runner must receive genuine customer-owned checkpoint evidence. +On a trusted worker, retain the native checkpoint ref or set +`TABELLIO_CHECKPOINT_BUNDLE` to the path of a privately provisioned native Git +bundle advertising `refs/heads/entire/checkpoints/v1`. The repository CI importer +verifies the bundle and rejects divergent history without overwriting it; the +product gate still verifies metadata and exact-candidate association. Default +ephemeral GitHub runners remain blocked until this private input is provisioned. +No download credential or storage integration is supplied automatically. Never +upload the bundle to public CI artifacts. A synthetic fixture cannot replace a +real session checkpoint. diff --git a/docs/github-code-storage-boundary.md b/docs/github-code-storage-boundary.md index fa6d4951..dcb2bbd0 100644 --- a/docs/github-code-storage-boundary.md +++ b/docs/github-code-storage-boundary.md @@ -9,16 +9,19 @@ GitHub has one narrow job in the Tabellio platform: store code and expose a thin | `refs/heads/*` code branches | GitHub `origin` | Shared source history and pull-request heads | | `refs/tags/*` release tags | GitHub `origin` | Shared code release markers | | Pull-request title, description, checks summary, and review decision | GitHub | Minimum human accountability surface | -| Entire transcript and checkpoint state | Private GitHub control repository | Private agent context stays outside the public code repository | -| `refs/tabellio/reviews` | Private GitHub control repository | Full machine review ledger may contain internal context | -| `refs/tabellio/validations` | Private GitHub control repository | Full validation evidence and logs remain independently governed | -| `refs/heads/entire/checkpoints/v1` | Private GitHub control repository | Agent-session checkpoints do not become ordinary code branches | +| Entire transcript and checkpoint state | Customer-owned private local storage; optional private GitHub remote | Private agent context stays outside the public code repository | +| `refs/tabellio/reviews` | Customer-owned private local storage; optional private GitHub remote | Full machine review ledger may contain internal context | +| `refs/tabellio/validations` | Customer-owned private local storage; optional private GitHub remote | Full validation evidence and logs remain independently governed | +| `refs/heads/entire/checkpoints/v1` | Customer-owned private local storage; optional private GitHub remote | Agent-session checkpoints do not become ordinary code branches | ## Enforced Contract -`tabellio.platform.json` declares GitHub `origin` as code storage, a private GitHub repository under the `control` remote for external control state, and `publishControlRefsToCodeStorage` as `false`. The JSON Schema and runtime validator reject drift from those values. - -The control-ref transport also rejects `origin` before reading or writing remote state. Callers must name a separate private GitHub repository remote for review, validation, and Entire refs. This makes a mistaken private-state push fail closed instead of relying on operator memory. +`tabellio.platform.json` v0.4 defaults to local evidence storage. No second hosted +repository is required. Private evidence must never be published to public +`origin`; the explicit optional control transport continues rejecting it. +Legacy v0.3 private GitHub remote mode remains supported. Migration requires an +explicit platform change, local native checkpoint preservation, and backup of all +reachable review/validation/checkpoint refs before removing remote storage. ## Pull-Request Boundary @@ -26,4 +29,4 @@ The pull request remains useful but thin. It carries the code diff, a concise ch ## Migration State -Legacy self-hosted collaboration code and local lab infrastructure have been removed. GitHub is the only supported hosted Git and review service: public code uses `origin`, while private control state uses a separately configured private GitHub repository. +Legacy self-hosted collaboration code and local lab infrastructure have been removed. Public code uses GitHub `origin`; private control state defaults to local customer-owned storage. A private GitHub remote is an optional explicit integration. diff --git a/docs/operations-hardening.md b/docs/operations-hardening.md index d46d1aa3..09ae262b 100644 --- a/docs/operations-hardening.md +++ b/docs/operations-hardening.md @@ -29,18 +29,34 @@ GitHub `origin` is the canonical code repository and merge authority: Do not maintain a second merge authority. Independent squash or rebase merges create different histories even when file content matches. -## Control-State Publication - -Review cycles, validation results, and Entire checkpoints are published together with `git push --atomic`, explicit force-with-lease expectations, and a one-use approval. Publication permits refs that were already unchanged at planning while rejecting partial publication, non-fast-forward updates, divergence, changed local or remote object IDs, expired approvals, and reused approval IDs. Release retries derive a fresh one-use control approval from the still-active release approval after re-verifying the exact repositories and OIDs. - -Automatic Entire session pushes to `origin` remain disabled. The approved control-ref transport publishes `refs/heads/entire/checkpoints/v1` with the review and validation refs only to a separately configured private GitHub repository. Planning and execution reject `origin`, reject repository aliases, and recheck private visibility immediately before publication. - -Release planning is local and credentialed-read-only: it requires clean merged `main`, runs exact-head commands from the platform-configured manifest, binds checkpoint evidence to the exact pre-merge pull-request head, requires durable proof that that head reached `ready`, rejects new terminal feedback or failed checks, binds the effective fetch and push targets for both GitHub remotes, and snapshots the resulting control OIDs. Remote publication requires a separate release approval capped at one hour. Execution re-verifies case-normalized code identity plus control identity and privacy before every write, writes an atomic local receipt before each phase, and reruns idempotent reconciliation for control refs, exact annotated-tag objects, and releases after failures or prior success. Pull-request merge remains a separate explicit action because an approval cannot safely bind a squash commit that does not exist yet. +## Local Evidence And Optional Publication + +The v0.4 default retains genuine Entire checkpoints, review history and validation +results locally. It requires no private GitHub control repository. Automatic +Entire pushing stays disabled. Local release intents bind the three evidence-ref +object IDs; execution rechecks them and reports verification, not publication. + +Optional explicit private GitHub transport retains atomic compare-and-swap +publication, short-lived approvals and divergence rejection. Legacy v0.3 configs +and remote release intents remain supported. The standalone transport's remote +alias is not a destination identity guarantee; review the effective destination +and privacy before authorizing it. + +Status publication uses one customer-owned bare Git authority selected in the +approved intent. Every publishing process must use it. Its pending reservation +is durable before delivery, so a crash or uncertain response does not permit a +repeat. Separate authorities do not coordinate. Treat the authority owner as +trusted: copying or rolling back its history can remove consumed approvals. +After restoring a backup, fence publication until outstanding approvals expire +or reconcile every potentially delivered status before issuing new approvals. +Approval JSON binds content; it does not authenticate the person named in it. +Keep approval creation and publishing under the customer's trusted-worker access +boundary. Multi-user authorization is a separate hardening phase. ## Production Checklist -- Back up the private GitHub control repository and test restore drills. +- Back up local evidence refs, the publication authority and PostgreSQL data; test each restoration separately. - Isolate validation workers for untrusted code; detached worktrees are not sandboxes. -- Scope code-storage and private-control GitHub credentials per repository and keep them out of URLs, arguments, and logs. +- Scope publication and optional private-remote GitHub credentials per repository and keep them out of URLs, arguments, and logs. - Monitor failed receipts, stale cross-host locks, validation duration, queue depth, and ref divergence. -- Reconcile and republish control refs before retrying any divergence failure. +- Reconcile divergent local evidence before retrying. Republish only when optional remote mode is explicitly selected and its operation is approved. diff --git a/docs/pilot/acceptance-plan.md b/docs/pilot/acceptance-plan.md new file mode 100644 index 00000000..5a0b22af --- /dev/null +++ b/docs/pilot/acceptance-plan.md @@ -0,0 +1,111 @@ +# Core pilot hardening and acceptance + +Status: proposed execution gates. This is not an enterprise certification or a +replacement for explicit merge/release authority. SSO, billing, multi-tenant +hosting, and the historical PR-less merge UI are outside this pilot. + +See [architecture and evidence recovery](architecture-and-recovery.md) for storage +boundaries, uncertain-publication handling and read-only reconciliation. + +## Establish the baseline + +Record the exact source commit, clean/dirty runner identity, package version, +Node/Git/PostgreSQL/scanner versions, host platform, commands, outcomes, and skipped +checks. A passing unit suite with skipped integration checks is partial evidence. +Keep machine-specific receipts outside the repository. Never commit credentials, +raw provider payloads, private transcripts, or customer code in benchmark reports. + +Supported pilot prerequisites are Node >=20, Git, PostgreSQL server/client tools, +and the pinned Gitleaks/ast-grep versions in the security-tool installer. Local +PostgreSQL fixtures use a disposable private socket/cluster, not a production DB. + +From an exact source checkout: + +```sh +npm run check +node scripts/demo-provenance.mjs --verify-storage-tests true +npm run tabellio:provenance:security:check +npm run benchmark:provenance -- --iterations 25 +npm run quality:changed-code +npm pack --dry-run --json --ignore-scripts +``` + +The security check requires actual scanners and PostgreSQL; absence is blocked, +not a pass. Quality coverage also requires PostgreSQL. Run `npm pack` and install +the candidate tarball with `--ignore-scripts` in a clean external project, then +invoke its `tabellio-version` and `tabellio-provenance-demo` binaries. A tarball +check is not proof of registry publication. Do not publish to test installation. + +## Reproducible policy benchmark + +`npm run benchmark:provenance -- --iterations 25` writes JSON to stdout and exits +nonzero on any expectation mismatch. Redirect output only to a new artifact path +outside source inputs. The report records fixture digest, runner identity and +fingerprint, runtime, each expected decision, actual counts, and nearest-rank +p50/p95/max elapsed milliseconds. It makes no network calls or provider writes. + +The 30 cases cover valid evidence, ordering and duplicate delivery, all eight +required evidence categories missing, failed/unexecuted required checks, moved +head/base/merge-base/repository/project, expiration/future timestamps, conflicting +observations, provider outage, inferred/dangling links, mismatched candidate facts, +and tampering. Only the specific integrity rejection expected by the tamper case +counts as correct; unexpected exceptions fail the benchmark. Tests verify that +always-pass and always-block evaluators cannot pass the harness. + +The timed section is **only in-process lineage evaluation**, including integrity +verification. Fixture construction, cloning, Git, database operations, scanners, +network, publication, and presentation are excluded. No warm-up is discarded. +Fixtures are synthetic and reused; 750 evaluations are 30 scenarios repeated 25 +times, not 750 independent security trials. No statistical security reliability or +GitHub superiority follows. Peak memory and infrastructure costs are unmeasured. +Digest integrity does not authenticate a malicious evidence producer. + +## Gate matrix + +| Gate | Required evidence | Current scope/status | +| --- | --- | --- | +| Policy regression | Full unit suite plus benchmark with zero mismatches | Runnable locally; record each candidate's actual result | +| Persistence/recovery | Demo with storage tests, restart, fresh-store replay, unchanged sources and cleanup | Implemented fixture workflow; run explicitly | +| Bounded scanners | Required scanner fixtures and immutable-tree demo | Limited rule coverage; no comprehensive security guarantee | +| Distribution | External tarball install, identity and installed demo | Must be independently executed per candidate | +| Runtime isolation | Disposable untrusted-code worker, resource limits, approved egress and scoped identity | Unqualified; worktrees are not sandboxes | +| Live integrations | Real authorized provider journey, permissions/outages/retries, duplicate/concurrent publication | Not proved by synthetic demo; requires chosen environment and scoped access | +| Operations | Control-repository backup/restore drill, monitoring, durable artifact retrieval, incident runbook | Requires operator-owned infrastructure and acceptance | +| Comparative benefit | Matched GitHub/CI pilot and reviewer measurements | Unmeasured; protocol below | +| Release | Fresh independent review, exact-head CI, approved merge, tag/package publication and public-install verification | Separate approval; draft PR is not release | + +## Matched comparison protocol + +A is the team's properly configured GitHub/CI and review stack. B is exactly A plus +Tabellio. Keep source revisions, required checks, expected check providers, stale +approval policy, validators, reviewers and permissions equivalent. Do not weaken +GitHub to create an artificial advantage. Record where both systems already block +invalid evidence; measure only incremental coverage or effort reduction. + +Start with 20–30 representative PRs across at least three repositories. Use a +held-out fault set authored before running the comparison; rotate reviewer order +to reduce learning effects. Include wrong provider/repository/run, tampering, +policy changes, unavailable evidence, duplicate/out-of-order events, concurrent +changes, restart and uncertain publication. Preserve every failure and denominator. +A model of GitHub behavior is not an executed GitHub baseline. + +Measure invalid-policy approvals, valid-case rejection, availability delay, active +reviewer minutes, setup/maintenance hours, reconstruction completeness, incremental +p50/p95 latency, provider calls, CPU/memory/storage and known/unknown cost. An +intentional fail-closed outage is availability burden, not a false block under an +evidence-required policy. Gate correctness is not code correctness. + +Proposed decision thresholds: zero invalid passes in the critical declared fault +suite, no baseline protection regression, every decision reconstructable, and a +customer-agreed improvement in reviewer effort within predeclared cost/latency +limits. Select numeric customer thresholds before collecting outcomes. These are +pilot goals, not achieved results or industry standards. + +## Execution and cleanup + +One integration owner; bounded branches with failure fixtures before fixes. Track +each finding to its invariant, test, exact commit and reviewed outcome. Update this +matrix and the active work list when evidence changes. Mark historical plans as +superseded instead of treating their dates as active commitments. No deletion of +history, branch cleanup, merge, deployment, paid workloads, credential changes or +security-setting changes is implied by this document. diff --git a/docs/pilot/architecture-and-recovery.md b/docs/pilot/architecture-and-recovery.md new file mode 100644 index 00000000..2bd75fcc --- /dev/null +++ b/docs/pilot/architecture-and-recovery.md @@ -0,0 +1,84 @@ +# Core architecture and evidence recovery + +Tabellio binds evidence to a particular repository, project, base, head and merge +base. A change in any of those facts requires fresh evaluation. It does not make +code correct merely by collecting passing reports. + +## Where the information lives + +| Component | Role | What saving there establishes | +| --- | --- | --- | +| Code repository | Source, commits, pull requests and CI | The code and the checks actually retained by GitHub | +| Entire | Genuine agent-session checkpoints | Session provenance; a hand-written trailer is not a substitute | +| Customer-owned local Git evidence / optional private remote | Review, validation and checkpoint Git refs | Only reachable objects in the preserved refs; not a database backup | +| Local PostgreSQL store | Bounded observations, lineage, replay and review packets | Local persistence, not remote publication or a backup | +| Local receipts / CI artifacts | Command outcomes and diagnostic evidence | Results for their recorded runner/candidate, subject to artifact retention | + +Provider adapters collect bounded observations. Lineage assembly checks links and +candidate identities; policy evaluation rejects missing, stale, conflicting or +failed required evidence. The review representation translates that decision into +separate review/security statuses. Publication requires an integrity-bound intent +and a current, short-lived approval. + +## Publication failure and concurrency + +Before sending statuses, local-mode publishers reserve approvals in one explicitly +selected customer-owned bare Git authority using atomic compare-and-swap ref +updates. Every publisher must use that same authority; independent stores do not +coordinate. Legacy explicit private-remote mode uses compare-and-swap Git pushes. A local pending receipt also records an +uncertain reservation attempt. This prevents two clones from intentionally +delivering under the same approval. Candidate identity is checked before each +status and again after delivery. + +Every accepted provider response must match the requested commit, state, context, +description and target URL. Status IDs must be distinct canonical positive decimal +integers: reject leading zeros and numeric values that JavaScript cannot represent +exactly. The GitHub adapter rejects unsafe numeric IDs before string conversion. +A duplicate ID +cannot establish two deliveries: retain the accepted prefix and record a blocked +receipt. Replaying that approval must send nothing. + +GitHub status delivery and control-receipt synchronization are separate writes. +If delivery succeeds but the final receipt cannot be synchronized, the private +remote can still contain a pending reservation. A retry must remain blocked even +when the local receipt says delivery completed. This is deliberate uncertainty, +not evidence that nothing happened. The test suite covers this boundary with an +injected control outage; it is not a live-provider or process-kill trial. + +The three canonical control refs use a separate atomic Git publication path with +exact object expectations. Neither that operation nor a code-branch push implies +that local PostgreSQL data or arbitrary artifact files were uploaded. + +## Read-only reconciliation before recovery + +1. Record the exact code candidate and clean/dirty runner identity. Fetch remote + control refs into separate audit refs, preserving local unpublished refs. +2. Record advertised object IDs, tip dates, reachable history and candidate-bound + records. A private or unarchived repository can still hold old evidence. +3. Compare local refs and receipts, remote control state, and GitHub CI/status + records. Missing local data is unknown, not proof that it never existed. +4. For uncertain delivery, inspect GitHub for each intended status and reconcile + the saved reservation. Do not delete a reservation or reuse a consumed approval + to make an operation run again. +5. Locate the original genuine Entire session/checkpoint before claiming session + provenance. Do not reconstruct a fictional historical checkpoint from tests. +6. Preserve recovered artifacts with candidate identity and digests. Any remote + publication needs the existing approval contract and verified destination. + +A snapshot count is not a completeness proof. Deleted/unreachable objects, other +clones, expired CI artifacts and unpublished local evidence remain outside it. +An actual backup/restore drill must verify restoration and replay from preserved +sources in an isolated destination before operations acceptance is claimed. + +## Verification boundaries + +The source recovery demo can run `--verify-storage-tests true`; the distributed +tarball omits the repository test suite, so run the installed demo without that +source-only option. Run source storage tests separately. An installed demo uses +real temporary Git/PostgreSQL and synthetic provider records; it establishes local +recovery behavior, not genuine Entire capture or real GitHub publication. + +Record full-suite skips explicitly and run required integration/scanner gates. +Keep exact-head validation separate from synthetic merge-commit CI. Independent +review, live-provider trials, control backup restoration and matched comparison +remain separate evidence requirements in [the acceptance plan](acceptance-plan.md). diff --git a/docs/product-validation.md b/docs/product-validation.md index 7b834f97..b6b21384 100644 --- a/docs/product-validation.md +++ b/docs/product-validation.md @@ -99,3 +99,46 @@ The runner creates a private `TabellioValidation-*` session under the system tem `gate` still records the result. It exits non-zero when the decision is `failed` or `blocked`. Artifact upload and path-based automatic validator selection remain scheduler or repository-policy responsibilities. The first contract keeps evaluation portable without creating a second CI or deployment authority. + +## Private metadata-only native checkpoint transport + +With pinned Entire 0.7.7 and supported Git, export only the checkpoint metadata +needed by a candidate, while preserving original native commit/tree/object IDs: + +```sh +npm run tabellio:checkpoint:export -- \ + --repo . --repo-id github.com/OWNER/REPOSITORY \ + --base origin/main --commit HEAD --out /private/operator/checkpoints.bundle +``` + +The exporter invokes the genuine native metadata reader and existing scope, +completeness and Git trailer checks. It creates a native filtered Git bundle +containing selected root/session metadata blobs, original commits and trees. +Transcript and prompt blobs are excluded. The output is private: metadata may +contain session IDs, file paths, summaries and token usage. Export does not +publish the bundle or confer permission to transfer it. + +Supply the approved private file through `TABELLIO_CHECKPOINT_BUNDLE` on a +trusted worker. `checkpoint-evidence.sh` imports filtered bundles through Git's +promisor-aware unbundle path, retains the original advertised native ref, and +rejects divergent or concurrently changed history. Existing complete bundles +remain supported. Validators still use Entire's native reader and exact Git +trailer associations; standalone JSON exports are not accepted. + +This does not add authenticated producer attestation: Entire 0.7.7 still does +not detect a copied checkpoint ID on another commit. The operator supplying +native evidence and the worker consuming it must remain trusted. No checkpoint +record is synthesized or reconstructed. No private IntelIP remote is required. + +The repository's one-time GitHub proof uses a reviewer-protected temporary +environment and two encrypted secret chunks. The private envelope binds the +repository, candidate, merge base, bundle digest and native tip. The loader +refuses mismatches and pre-existing native/validation refs. It never prints the +payload or native metadata. The normal gate remains unchanged. + +The final cleanup step removes the temporary bundle and newly imported Git +objects and private refs, preserving the original checkout objects. The operator +must delete the temporary environment and secrets after the proof. Public logs +contain only allowlisted gate status and cleanup outcome; no artifacts or caches +receive private evidence. Subsequent candidates require their own genuine +customer-owned evidence; deleted proof secrets cannot be reused. diff --git a/docs/tooling-stack.md b/docs/tooling-stack.md index db904618..de68ccee 100644 --- a/docs/tooling-stack.md +++ b/docs/tooling-stack.md @@ -39,7 +39,7 @@ task source -> read-only merge preview -> exact-commit validation result -> thin code pull request - -> approved control-ref publication + -> local evidence retention / optional approved private publication -> explicit compare-and-swap merge or release gate ``` @@ -80,7 +80,7 @@ Included: Not included yet: -- external control-state service selection and deployment +- optional external control-state service selection and deployment - transcript indexing or storage outside Entire - GitHub comment publication, general review-thread mutation, and signed approvals - Codex review automation @@ -115,7 +115,7 @@ This repository disables automatic checkpoint pushes to `origin`. Commit trailer ## GitHub Code-Storage Boundary -GitHub receives ordinary code branches, tags, and the minimum pull-request metadata needed for human accountability. `refs/tabellio/reviews`, `refs/tabellio/validations`, and `refs/heads/entire/checkpoints/v1` remain external. The control-ref transport rejects `origin` even when a caller supplies it explicitly. +GitHub receives ordinary code branches, tags, and the minimum pull-request metadata needed for human accountability. `refs/tabellio/reviews`, `refs/tabellio/validations`, and `refs/heads/entire/checkpoints/v1` remain private and local by default; optional external transport is explicit. The control-ref transport rejects `origin` even when a caller supplies it explicitly. The boundary is contractual, not only documentary: `tabellio.platform.json`, its JSON Schema, runtime validation, and transport tests all fail closed on GitHub or publication-policy drift. See [GitHub code-storage boundary](github-code-storage-boundary.md). diff --git a/docs/validation-runner.md b/docs/validation-runner.md index 9cc9de8a..6dbd5044 100644 --- a/docs/validation-runner.md +++ b/docs/validation-runner.md @@ -53,7 +53,7 @@ The runner: 7. Removes the worktree even after failure. 8. Writes an integrity-protected result to `refs/tabellio/validations` with compare-and-swap retries. -Command-manifest results use `tabellio-validation-result/v0.2`. Product-validation results use v0.4 and embed the acceptance digest, typed validator results, bounded evidence reports, total observed validation cost, final policy decision, and exact Tabellio runner identity when available. Both require `checkpointRevision` so checkpoint proof remains bound to the pull-request head when the validated revision is a later squash-merge commit. Runtime readers continue to accept v0.1 through v0.4 results. +New command and product results use `tabellio-validation-result/v0.5`, distinguished by `kind`. Product results embed the acceptance digest, typed validator results, bounded evidence reports, total observed validation cost, final policy decision, and exact Tabellio runner identity when available. Both require `checkpointRevision` so checkpoint proof remains bound to the pull-request head when the validated revision is a later squash-merge commit. Runtime readers continue to accept v0.1 through v0.5 results, subject to the evidence eligibility rules below. Read the newest result for a commit: @@ -72,3 +72,37 @@ Publishing `refs/tabellio/validations` to a remote is a separate approved Git wr Detached worktrees and isolated home directories protect host source and common credential paths; they are not a hostile-code sandbox. Validation commands can execute arbitrary repository code and use the network. Run untrusted contributions in a disposable VM, container, or sandbox with scoped credentials and network policy. Never place secrets in manifest arguments or print them: argv and bounded output tails are retained as durable evidence. Typed validation does not authorize live writes. Repository adapters should use fixtures, isolated namespaces, read-only credentials, and explicit cost caps. Artifact metadata must point to durable content-addressed storage when a reviewer needs the screenshot, trace, or report after the temporary worktree is removed. + +## Resolved checkpoint evidence (v0.5) + +A required Entire prerequisite resolves every 12-hex trailer ID through the +installed Entire CLI (>=0.7.7) before running validator commands. Missing, +wrong-ID, partial, malformed, empty-session or session-error metadata blocks the +run. New v0.5 results distinguish `kind: commands` from `kind: product` and carry +`checkpointEvidence`: a versioned metadata-only ledger snapshot, or null when the +manifest does not require checkpoints. Required snapshots include CLI version, +metadata digests, the checkpoint-source range and every trailer-bearing commit. +No transcript bytes are included. Both result kinds now record runner identity. + +When selecting stored results, required-checkpoint manifests reject historical +trailer-only evidence. Selection also checks the committed manifest digest and +rechecks the snapshot's commit/trailer associations in Git. Historical results +remain readable; rerun validation to make them eligible for the stronger gate. +Squash validation still executes the merged head while resolving metadata against +the explicitly recorded pre-merge PR range. A checkpoint ref and working Entire +CLI must be available to the trusted validation worker; this does not authorize +publishing private refs to the public code repository or loosening any gate. + +**Trust limit:** Entire v0.7.7's export does not contain an independently +verifiable commit, tree or candidate binding. The CLI's `--commit` lookup itself +reads the commit trailer; it does not authenticate who captured that session. +This gate proves that referenced metadata resolves completely and records its +exact Git associations. It cannot detect someone copying an unrelated, existing +checkpoint ID into a new commit. Genuine session provenance still requires the +approved capture workflow and trusted evidence production. Digests detect changes, +not producer forgery. Stronger authenticated binding requires a separate upstream +or trusted capture contract; it must not be invented from file overlap or trailers. + +The checkpoint regressions use explicitly synthetic metadata/executables in +throwaway repositories. Their wrong-commit case corrupts the stored Git association; +it does not pretend Entire exports an independent commit SHA. diff --git a/package.json b/package.json index ab776b23..c41fd7d1 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,9 @@ "tabellio:local-store": "node scripts/tabellio-local-store.mjs", "tabellio:provenance": "node scripts/tabellio-provenance.mjs", "tabellio:provenance:demo": "node scripts/demo-provenance.mjs", - "tabellio:provenance:security:check": "node scripts/check-provenance-security.mjs" + "tabellio:provenance:security:check": "node scripts/check-provenance-security.mjs", + "benchmark:provenance": "node scripts/benchmark-provenance.mjs", + "tabellio:checkpoint:export": "node scripts/export-checkpoint-metadata.mjs" }, "engines": { "node": ">=20" diff --git a/schemas/platform.schema.json b/schemas/platform.schema.json index c0eee661..eca1e70d 100644 --- a/schemas/platform.schema.json +++ b/schemas/platform.schema.json @@ -1,30 +1,69 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "urn:tabellio:schema:platform:v0.3", + "$id": "urn:tabellio:schema:platform:v0.4", "title": "Tabellio Platform Configuration", "type": "object", "additionalProperties": false, - "required": ["schemaVersion", "codeStorage", "workflow", "ledger", "validation", "reviews"], + "required": [ + "schemaVersion", + "codeStorage", + "workflow", + "ledger", + "validation", + "reviews" + ], "properties": { - "schemaVersion": { "const": "tabellio-platform/v0.3" }, + "schemaVersion": { + "enum": [ + "tabellio-platform/v0.3", + "tabellio-platform/v0.4" + ] + }, "codeStorage": { "type": "object", "additionalProperties": false, - "required": ["provider", "remoteName", "publicSurface", "codeRef", "allowedRefPrefixes"], + "required": [ + "provider", + "remoteName", + "publicSurface", + "codeRef", + "allowedRefPrefixes" + ], "properties": { - "provider": { "const": "github" }, - "remoteName": { "const": "origin" }, - "publicSurface": { "const": "code-and-thin-pr" }, - "codeRef": { "const": "refs/heads/main" }, + "provider": { + "const": "github" + }, + "remoteName": { + "const": "origin" + }, + "publicSurface": { + "const": "code-and-thin-pr" + }, + "codeRef": { + "const": "refs/heads/main" + }, "allowedRefPrefixes": { "type": "array", "minItems": 2, "maxItems": 2, "uniqueItems": true, - "items": { "enum": ["refs/heads/", "refs/tags/"] }, + "items": { + "enum": [ + "refs/heads/", + "refs/tags/" + ] + }, "allOf": [ - { "contains": { "const": "refs/heads/" } }, - { "contains": { "const": "refs/tags/" } } + { + "contains": { + "const": "refs/heads/" + } + }, + { + "contains": { + "const": "refs/tags/" + } + } ] } } @@ -32,23 +71,67 @@ "workflow": { "type": "object", "additionalProperties": false, - "required": ["stackManager", "controlState", "controlProvider", "controlRemoteName", "publishControlRefsToCodeStorage", "controlRefs"], + "required": [ + "stackManager", + "controlState", + "controlProvider", + "controlRemoteName", + "publishControlRefsToCodeStorage", + "controlRefs" + ], "properties": { - "stackManager": { "const": "git-spice" }, - "controlState": { "const": "external" }, - "controlProvider": { "const": "github" }, - "controlRemoteName": { "const": "control" }, - "publishControlRefsToCodeStorage": { "const": false }, + "stackManager": { + "const": "git-spice" + }, + "controlState": { + "enum": [ + "local", + "external" + ] + }, + "controlProvider": { + "enum": [ + "local", + "github" + ] + }, + "controlRemoteName": { + "enum": [ + null, + "control" + ] + }, + "publishControlRefsToCodeStorage": { + "const": false + }, "controlRefs": { "type": "array", "minItems": 3, "maxItems": 3, "uniqueItems": true, - "items": { "enum": ["refs/tabellio/reviews", "refs/tabellio/validations", "refs/heads/entire/checkpoints/v1"] }, + "items": { + "enum": [ + "refs/tabellio/reviews", + "refs/tabellio/validations", + "refs/heads/entire/checkpoints/v1" + ] + }, "allOf": [ - { "contains": { "const": "refs/tabellio/reviews" } }, - { "contains": { "const": "refs/tabellio/validations" } }, - { "contains": { "const": "refs/heads/entire/checkpoints/v1" } } + { + "contains": { + "const": "refs/tabellio/reviews" + } + }, + { + "contains": { + "const": "refs/tabellio/validations" + } + }, + { + "contains": { + "const": "refs/heads/entire/checkpoints/v1" + } + } ] } } @@ -56,33 +139,214 @@ "ledger": { "type": "object", "additionalProperties": false, - "required": ["provider", "storage", "checkpointRef"], + "required": [ + "provider", + "storage", + "checkpointRef" + ], "properties": { - "provider": { "const": "entire" }, - "storage": { "const": "external" }, - "checkpointRef": { "const": "refs/heads/entire/checkpoints/v1" } + "provider": { + "const": "entire" + }, + "storage": { + "enum": [ + "local", + "external" + ] + }, + "checkpointRef": { + "const": "refs/heads/entire/checkpoints/v1" + } } }, "validation": { "type": "object", "additionalProperties": false, - "required": ["runner", "manifest", "storage", "resultRef"], + "required": [ + "runner", + "manifest", + "storage", + "resultRef" + ], "properties": { - "runner": { "const": "tabellio-validate" }, - "manifest": { "type": "string", "minLength": 1 }, - "storage": { "const": "external" }, - "resultRef": { "const": "refs/tabellio/validations" } + "runner": { + "const": "tabellio-validate" + }, + "manifest": { + "type": "string", + "minLength": 1 + }, + "storage": { + "enum": [ + "local", + "external" + ] + }, + "resultRef": { + "const": "refs/tabellio/validations" + } } }, "reviews": { "type": "object", "additionalProperties": false, - "required": ["provider", "storage", "stateRef"], + "required": [ + "provider", + "storage", + "stateRef" + ], "properties": { - "provider": { "const": "tabellio" }, - "storage": { "const": "external" }, - "stateRef": { "const": "refs/tabellio/reviews" } + "provider": { + "const": "tabellio" + }, + "storage": { + "enum": [ + "local", + "external" + ] + }, + "stateRef": { + "const": "refs/tabellio/reviews" + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "schemaVersion": { + "const": "tabellio-platform/v0.3" + } + } + }, + "then": { + "properties": { + "workflow": { + "properties": { + "controlState": { + "const": "external" + }, + "controlProvider": { + "const": "github" + }, + "controlRemoteName": { + "const": "control" + } + } + }, + "ledger": { + "properties": { + "storage": { + "const": "external" + } + } + }, + "validation": { + "properties": { + "storage": { + "const": "external" + } + } + }, + "reviews": { + "properties": { + "storage": { + "const": "external" + } + } + } + } + } + }, + { + "if": { + "properties": { + "workflow": { + "properties": { + "controlState": { + "const": "local" + } + } + } + } + }, + "then": { + "properties": { + "workflow": { + "properties": { + "controlState": { + "const": "local" + }, + "controlProvider": { + "const": "local" + }, + "controlRemoteName": { + "type": "null" + } + } + }, + "ledger": { + "properties": { + "storage": { + "const": "local" + } + } + }, + "validation": { + "properties": { + "storage": { + "const": "local" + } + } + }, + "reviews": { + "properties": { + "storage": { + "const": "local" + } + } + } + } + }, + "else": { + "properties": { + "workflow": { + "properties": { + "controlState": { + "const": "external" + }, + "controlProvider": { + "const": "github" + }, + "controlRemoteName": { + "const": "control" + } + } + }, + "ledger": { + "properties": { + "storage": { + "const": "external" + } + } + }, + "validation": { + "properties": { + "storage": { + "const": "external" + } + } + }, + "reviews": { + "properties": { + "storage": { + "const": "external" + } + } + } + } } } - } + ] } diff --git a/schemas/release-operation.schema.json b/schemas/release-operation.schema.json index 00e1bb9d..743aff86 100644 --- a/schemas/release-operation.schema.json +++ b/schemas/release-operation.schema.json @@ -6,7 +6,7 @@ "additionalProperties": false, "required": ["schemaVersion", "repository", "version", "tag", "revision", "pullRequest", "code", "control", "validation", "release", "createdAt", "integrity"], "properties": { - "schemaVersion": { "const": "tabellio-release-operation/v0.1" }, + "schemaVersion": { "enum": ["tabellio-release-operation/v0.1", "tabellio-release-operation/v0.2"] }, "repository": { "type": "object", "additionalProperties": false, @@ -47,33 +47,7 @@ "branch": { "const": "main" } } }, - "control": { - "type": "object", - "additionalProperties": false, - "required": ["repository", "intent"], - "properties": { - "repository": { - "type": "object", - "additionalProperties": false, - "required": ["id"], - "properties": { "id": { "type": "string", "minLength": 1 } } - }, - "intent": { - "$ref": "urn:tabellio:schema:control-ref-operation:v0.1", - "properties": { - "refs": { - "minItems": 3, - "maxItems": 3, - "allOf": [ - { "contains": { "type": "object", "properties": { "name": { "const": "refs/tabellio/reviews" } }, "required": ["name"] }, "minContains": 1 }, - { "contains": { "type": "object", "properties": { "name": { "const": "refs/tabellio/validations" } }, "required": ["name"] }, "minContains": 1 }, - { "contains": { "type": "object", "properties": { "name": { "const": "refs/heads/entire/checkpoints/v1" } }, "required": ["name"] }, "minContains": 1 } - ] - } - } - } - } - }, + "control": { "oneOf": [{ "$ref": "#/$defs/remoteControl" }, { "$ref": "#/$defs/localControl" }] }, "validation": { "type": "object", "additionalProperties": false, @@ -106,7 +80,36 @@ } } }, + "allOf": [{"if": {"properties": {"schemaVersion": {"const": "tabellio-release-operation/v0.2"}}}, "then": {"properties": {"control": {"$ref": "#/$defs/localControl"}}}, "else": {"properties": {"control": {"$ref": "#/$defs/remoteControl"}}}}], "$defs": { + "remoteControl": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "intent"], + "properties": { + "repository": { + "type": "object", + "additionalProperties": false, + "required": ["id"], + "properties": { "id": { "type": "string", "minLength": 1 } } + }, + "intent": { + "$ref": "urn:tabellio:schema:control-ref-operation:v0.1", + "properties": { + "refs": { + "minItems": 3, + "maxItems": 3, + "allOf": [ + { "contains": { "type": "object", "properties": { "name": { "const": "refs/tabellio/reviews" } }, "required": ["name"] }, "minContains": 1 }, + { "contains": { "type": "object", "properties": { "name": { "const": "refs/tabellio/validations" } }, "required": ["name"] }, "minContains": 1 }, + { "contains": { "type": "object", "properties": { "name": { "const": "refs/heads/entire/checkpoints/v1" } }, "required": ["name"] }, "minContains": 1 } + ] + } + } + } + } + }, + "localControl": {"type": "object", "additionalProperties": false, "required": ["mode", "refs"], "properties": {"mode": {"const": "local"}, "refs": {"type": "array", "minItems": 3, "maxItems": 3, "items": {"type": "object", "additionalProperties": false, "required": ["name", "localOid"], "properties": {"name": {"enum": ["refs/tabellio/reviews", "refs/tabellio/validations", "refs/heads/entire/checkpoints/v1"]}, "localOid": {"$ref": "#/$defs/oid"}}}, "allOf": [{"contains": {"properties": {"name": {"const": "refs/tabellio/reviews"}}, "required": ["name"]}, "minContains": 1}, {"contains": {"properties": {"name": {"const": "refs/tabellio/validations"}}, "required": ["name"]}, "minContains": 1}, {"contains": {"properties": {"name": {"const": "refs/heads/entire/checkpoints/v1"}}, "required": ["name"]}, "minContains": 1}]}}}, "oid": { "type": "string", "pattern": "^([0-9a-f]{40}|[0-9a-f]{64})$" }, "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" } } diff --git a/schemas/validation-result.schema.json b/schemas/validation-result.schema.json index b855b42b..bee6c13a 100644 --- a/schemas/validation-result.schema.json +++ b/schemas/validation-result.schema.json @@ -1,4 +1,20 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "$ref": "validation-result.v0.2.schema.json" + "oneOf": [ + { + "$ref": "validation-result.v0.1.schema.json" + }, + { + "$ref": "validation-result.v0.2.schema.json" + }, + { + "$ref": "validation-result.v0.3.schema.json" + }, + { + "$ref": "validation-result.v0.4.schema.json" + }, + { + "$ref": "validation-result.v0.5.schema.json" + } + ] } diff --git a/schemas/validation-result.v0.5.schema.json b/schemas/validation-result.v0.5.schema.json new file mode 100644 index 00000000..ed0c8e03 --- /dev/null +++ b/schemas/validation-result.v0.5.schema.json @@ -0,0 +1,993 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:tabellio:schema:validation-result:v0.5", + "title": "Tabellio validation result with resolved checkpoint metadata", + "oneOf": [ + { + "type": "object", + "required": [ + "schemaVersion", + "runId", + "repository", + "revision", + "checkpointRevision", + "suite", + "runner", + "status", + "checkpoints", + "commands", + "startedAt", + "completedAt", + "integrity", + "kind", + "checkpointEvidence" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { + "const": "tabellio-validation-result/v0.5" + }, + "runId": { + "type": "string", + "minLength": 1 + }, + "repository": { + "type": "object", + "required": [ + "id" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "minLength": 1 + } + } + }, + "revision": { + "type": "object", + "required": [ + "baseCommit", + "mergeBase", + "headCommit" + ], + "additionalProperties": false, + "properties": { + "baseCommit": { + "$ref": "#/$defs/oid" + }, + "mergeBase": { + "$ref": "#/$defs/oid" + }, + "headCommit": { + "$ref": "#/$defs/oid" + } + } + }, + "checkpointRevision": { + "type": "object", + "required": [ + "baseCommit", + "mergeBase", + "headCommit" + ], + "additionalProperties": false, + "properties": { + "baseCommit": { + "$ref": "#/$defs/oid" + }, + "mergeBase": { + "$ref": "#/$defs/oid" + }, + "headCommit": { + "$ref": "#/$defs/oid" + } + } + }, + "suite": { + "type": "object", + "required": [ + "id", + "manifestPath", + "manifestDigest" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "manifestPath": { + "type": "string", + "minLength": 1 + }, + "manifestDigest": { + "$ref": "#/$defs/sha256" + } + } + }, + "runner": { + "type": "object", + "required": [ + "id", + "runtime", + "packageName", + "packageVersion", + "sourceCommit", + "sourceDirty", + "releaseTag" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "runtime": { + "type": "string", + "minLength": 1 + }, + "packageName": { + "const": "@intelip/tabellio" + }, + "packageVersion": { + "type": "string", + "pattern": "^(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$" + }, + "sourceCommit": { + "oneOf": [ + { + "$ref": "#/$defs/oid" + }, + { + "type": "null" + } + ] + }, + "sourceDirty": { + "type": [ + "boolean", + "null" + ] + }, + "releaseTag": { + "oneOf": [ + { + "type": "string", + "pattern": "^v(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$" + }, + { + "type": "null" + } + ] + } + }, + "allOf": [ + { + "if": { + "properties": { + "sourceCommit": { + "type": "null" + } + }, + "required": [ + "sourceCommit" + ] + }, + "then": { + "properties": { + "sourceDirty": { + "type": "null" + }, + "releaseTag": { + "type": "null" + } + } + }, + "else": { + "properties": { + "sourceDirty": { + "type": "boolean" + } + } + } + } + ] + }, + "status": { + "enum": [ + "passed", + "failed" + ] + }, + "checkpoints": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "commands": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/commandResult" + } + }, + "startedAt": { + "type": "string", + "format": "date-time" + }, + "completedAt": { + "type": "string", + "format": "date-time" + }, + "integrity": { + "type": "object", + "required": [ + "algorithm", + "digest" + ], + "additionalProperties": false, + "properties": { + "algorithm": { + "const": "sha256" + }, + "digest": { + "$ref": "#/$defs/sha256" + } + } + }, + "kind": { + "const": "commands" + }, + "checkpointEvidence": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "urn:tabellio:schema:ledger-snapshot:v0.1" + } + ] + } + } + }, + { + "type": "object", + "required": [ + "schemaVersion", + "runId", + "repository", + "revision", + "checkpointRevision", + "suite", + "runner", + "status", + "checkpoints", + "commands", + "acceptance", + "validators", + "decision", + "startedAt", + "completedAt", + "integrity", + "kind", + "checkpointEvidence" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { + "const": "tabellio-validation-result/v0.5" + }, + "runId": { + "type": "string", + "minLength": 1 + }, + "repository": { + "type": "object", + "required": [ + "id" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "minLength": 1 + } + } + }, + "revision": { + "$ref": "#/$defs/revision" + }, + "checkpointRevision": { + "$ref": "#/$defs/revision" + }, + "suite": { + "type": "object", + "required": [ + "id", + "manifestPath", + "manifestDigest" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "manifestPath": { + "type": "string", + "minLength": 1 + }, + "manifestDigest": { + "$ref": "#/$defs/sha256" + } + } + }, + "runner": { + "type": "object", + "required": [ + "id", + "runtime", + "packageName", + "packageVersion", + "sourceCommit", + "sourceDirty", + "releaseTag" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "runtime": { + "type": "string", + "minLength": 1 + }, + "packageName": { + "const": "@intelip/tabellio" + }, + "packageVersion": { + "type": "string", + "pattern": "^(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$" + }, + "sourceCommit": { + "oneOf": [ + { + "$ref": "#/$defs/oid" + }, + { + "type": "null" + } + ] + }, + "sourceDirty": { + "type": [ + "boolean", + "null" + ] + }, + "releaseTag": { + "oneOf": [ + { + "type": "string", + "pattern": "^v(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$" + }, + { + "type": "null" + } + ] + } + }, + "allOf": [ + { + "if": { + "properties": { + "sourceCommit": { + "type": "null" + } + }, + "required": [ + "sourceCommit" + ] + }, + "then": { + "properties": { + "sourceDirty": { + "type": "null" + }, + "releaseTag": { + "type": "null" + } + } + }, + "else": { + "properties": { + "sourceDirty": { + "type": "boolean" + } + } + } + } + ] + }, + "status": { + "enum": [ + "passed", + "failed", + "blocked" + ] + }, + "checkpoints": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "commands": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/commandResult" + } + }, + "acceptance": { + "$ref": "#/$defs/acceptanceResult" + }, + "validators": { + "type": "array", + "minItems": 1, + "maxItems": 50, + "items": { + "$ref": "#/$defs/validatorResult" + } + }, + "decision": { + "$ref": "#/$defs/decision" + }, + "startedAt": { + "type": "string", + "format": "date-time" + }, + "completedAt": { + "type": "string", + "format": "date-time" + }, + "integrity": { + "type": "object", + "required": [ + "algorithm", + "digest" + ], + "additionalProperties": false, + "properties": { + "algorithm": { + "const": "sha256" + }, + "digest": { + "$ref": "#/$defs/sha256" + } + } + }, + "kind": { + "const": "product" + }, + "checkpointEvidence": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "urn:tabellio:schema:ledger-snapshot:v0.1" + } + ] + } + } + } + ], + "$defs": { + "oid": { + "type": "string", + "pattern": "^(?:[0-9a-f]{40}|[0-9a-f]{64})$" + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "validatorType": { + "enum": [ + "static", + "schema", + "semantic", + "workflow", + "visual", + "operational", + "security" + ] + }, + "revision": { + "type": "object", + "required": [ + "baseCommit", + "mergeBase", + "headCommit" + ], + "additionalProperties": false, + "properties": { + "baseCommit": { + "$ref": "#/$defs/oid" + }, + "mergeBase": { + "$ref": "#/$defs/oid" + }, + "headCommit": { + "$ref": "#/$defs/oid" + } + } + }, + "output": { + "type": "object", + "required": [ + "bytes", + "digest", + "tail", + "truncated" + ], + "additionalProperties": false, + "properties": { + "bytes": { + "type": "integer", + "minimum": 0 + }, + "digest": { + "$ref": "#/$defs/sha256" + }, + "tail": { + "type": "string" + }, + "truncated": { + "type": "boolean" + } + } + }, + "commandResult": { + "type": "object", + "required": [ + "id", + "argv", + "cwd", + "required", + "status", + "exitCode", + "signal", + "durationMs", + "stdout", + "stderr", + "startedAt", + "completedAt", + "error" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "argv": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + }, + "cwd": { + "type": "string", + "minLength": 1 + }, + "required": { + "type": "boolean" + }, + "status": { + "enum": [ + "passed", + "failed", + "error", + "timed_out", + "skipped" + ] + }, + "exitCode": { + "type": [ + "integer", + "null" + ] + }, + "signal": { + "type": [ + "string", + "null" + ] + }, + "durationMs": { + "type": "integer", + "minimum": 0 + }, + "stdout": { + "$ref": "#/$defs/output" + }, + "stderr": { + "$ref": "#/$defs/output" + }, + "startedAt": { + "oneOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "oneOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ] + }, + "error": { + "type": [ + "string", + "null" + ] + } + } + }, + "acceptanceResult": { + "type": "object", + "required": [ + "id", + "source", + "risk", + "digest", + "requiredValidatorTypes" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "source": { + "type": "string", + "minLength": 1 + }, + "risk": { + "enum": [ + "low", + "medium", + "high", + "critical" + ] + }, + "digest": { + "$ref": "#/$defs/sha256" + }, + "requiredValidatorTypes": { + "type": "array", + "minItems": 1, + "maxItems": 7, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/validatorType" + } + } + } + }, + "validatorResult": { + "type": "object", + "required": [ + "id", + "type", + "required", + "status", + "evidence", + "reasons" + ], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "type": { + "$ref": "#/$defs/validatorType" + }, + "required": { + "type": "boolean" + }, + "status": { + "enum": [ + "passed", + "failed", + "blocked", + "skipped" + ] + }, + "evidence": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "required": [ + "path", + "digest", + "report" + ], + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "digest": { + "$ref": "#/$defs/sha256" + }, + "report": { + "$ref": "#/$defs/evidenceReport" + } + } + } + ] + }, + "reasons": { + "type": "array", + "maxItems": 128, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + } + } + } + }, + "evidenceReport": { + "type": "object", + "required": [ + "schemaVersion", + "validatorId", + "status", + "summary", + "metrics", + "cost", + "artifacts" + ], + "additionalProperties": false, + "properties": { + "schemaVersion": { + "const": "tabellio-validator-evidence/v0.1" + }, + "validatorId": { + "type": "string", + "minLength": 1 + }, + "status": { + "enum": [ + "passed", + "failed", + "blocked" + ] + }, + "summary": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + }, + "metrics": { + "type": "array", + "maxItems": 100, + "items": { + "$ref": "#/$defs/metric" + } + }, + "cost": { + "$ref": "#/$defs/cost" + }, + "artifacts": { + "type": "array", + "maxItems": 50, + "items": { + "$ref": "#/$defs/artifact" + } + } + } + }, + "metric": { + "type": "object", + "required": [ + "name", + "value", + "unit" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "value": { + "type": "number" + }, + "unit": { + "type": "string", + "minLength": 1 + } + } + }, + "cost": { + "type": "object", + "required": [ + "telemetry", + "usd", + "modelCalls", + "toolCalls" + ], + "additionalProperties": false, + "properties": { + "telemetry": { + "enum": [ + "available", + "unavailable", + "not_applicable" + ] + }, + "usd": { + "type": [ + "number", + "null" + ], + "minimum": 0 + }, + "modelCalls": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "toolCalls": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + } + }, + "allOf": [ + { + "if": { + "properties": { + "telemetry": { + "const": "available" + } + }, + "required": [ + "telemetry" + ] + }, + "then": { + "properties": { + "usd": { + "type": "number" + }, + "modelCalls": { + "type": "integer" + }, + "toolCalls": { + "type": "integer" + } + } + }, + "else": { + "properties": { + "usd": { + "type": "null" + }, + "modelCalls": { + "type": "null" + }, + "toolCalls": { + "type": "null" + } + } + } + } + ] + }, + "artifact": { + "type": "object", + "required": [ + "name", + "uri", + "digest", + "mediaType", + "bytes" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "uri": { + "type": "string", + "format": "uri", + "not": { + "pattern": "^file:" + } + }, + "digest": { + "$ref": "#/$defs/sha256" + }, + "mediaType": { + "type": "string", + "minLength": 1 + }, + "bytes": { + "type": "integer", + "minimum": 0 + } + } + }, + "decision": { + "type": "object", + "required": [ + "status", + "reasons", + "totalCostUsd", + "costTelemetryComplete" + ], + "additionalProperties": false, + "properties": { + "status": { + "enum": [ + "passed", + "failed", + "blocked" + ] + }, + "reasons": { + "type": "array", + "maxItems": 6400, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 2000 + } + }, + "totalCostUsd": { + "type": "number", + "minimum": 0 + }, + "costTelemetryComplete": { + "type": "boolean" + } + } + } + } +} diff --git a/scripts/benchmark-provenance.mjs b/scripts/benchmark-provenance.mjs new file mode 100644 index 00000000..39947b28 --- /dev/null +++ b/scripts/benchmark-provenance.mjs @@ -0,0 +1,16 @@ +#!/usr/bin/env node +import { parseOptionPairs, assertAllowedOptions, positiveNumberOption, reportCliError } from "./lib/cli-options.mjs"; +import { tabellioRunnerState } from "./lib/runner-identity.mjs"; +import { runProvenanceBenchmark } from "./benchmarks/provenance-benchmark.mjs"; + +try { + const options = parseOptionPairs(process.argv.slice(2)); + assertAllowedOptions(options, ["iterations"]); + const iterations = options.iterations === undefined ? 25 : positiveNumberOption(options.iterations, "--iterations"); + const runner = await tabellioRunnerState(); + const report = { ...runProvenanceBenchmark({ iterations }), runner }; + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + if (report.status !== "passed") process.exitCode = 1; +} catch (error) { + reportCliError(error); +} diff --git a/scripts/benchmarks/provenance-benchmark.mjs b/scripts/benchmarks/provenance-benchmark.mjs new file mode 100644 index 00000000..546cdadf --- /dev/null +++ b/scripts/benchmarks/provenance-benchmark.mjs @@ -0,0 +1,58 @@ +import { performance } from "node:perf_hooks"; +import { createHash } from "node:crypto"; +import { evaluateLineage } from "../lib/provenance-ledger.mjs"; +import { provenanceBenchmarkCases } from "./provenance-cases.mjs"; + +function percentile(samples, fraction) { + const sorted = [...samples].sort((a, b) => a - b); + return sorted[Math.max(0, Math.ceil(sorted.length * fraction) - 1)]; +} + +function evaluateCase(scenario, evaluate) { + try { + const actual = evaluate(scenario.lineage, scenario.options)?.status; + return ["passed", "failed", "blocked"].includes(actual) ? actual : "error"; + } catch (error) { + return scenario.expectedError && error?.message === scenario.expectedError ? "rejected" : "error"; + } +} + +function measureCase(scenario, iterations, evaluate) { + const samples = []; + const outcomes = {}; + for (let index = 0; index < iterations; index += 1) { + // Keep mutation by the system under test from contaminating later trials. + const input = structuredClone(scenario); + const start = performance.now(); + const actual = evaluateCase(input, evaluate); + samples.push(performance.now() - start); + outcomes[actual] = (outcomes[actual] ?? 0) + 1; + } + const passed = outcomes.passed ?? 0; + return { + id: scenario.id, expected: scenario.expected, + observations: scenario.lineage.observations.length, samples: iterations, outcomes, + mismatches: iterations - (outcomes[scenario.expected] ?? 0), + invalidPasses: scenario.expected === "passed" ? 0 : passed, + validCaseRejections: scenario.expected === "passed" ? iterations - passed : 0, + latencyMs: { p50: percentile(samples, 0.5), p95: percentile(samples, 0.95), max: Math.max(...samples) }, + }; +} + +export function runProvenanceBenchmark({ iterations = 25, evaluate = evaluateLineage } = {}) { + if (!Number.isSafeInteger(iterations) || iterations < 1 || iterations > 200) throw new Error("Iterations must be an integer from 1 to 200."); + const cases = provenanceBenchmarkCases(); + const fixtureDigest = createHash("sha256").update(JSON.stringify(cases)).digest("hex"); + const results = cases.map((scenario) => measureCase(scenario, iterations, evaluate)); + const mismatches = results.reduce((sum, item) => sum + item.mismatches, 0); + return { + schemaVersion: "tabellio-provenance-benchmark/v0.1", + status: mismatches ? "failed" : "passed", + fixtureDigest, + runtime: { node: process.version, platform: process.platform, arch: process.arch }, + scope: "Synthetic in-process lineage policy evaluation; no provider, database, scanner, publication, or GitHub baseline execution.", + limitations: ["Repeated fixtures are not independent security trials or customer outcomes.", "Latency excludes fixture construction, cloning, Git, PostgreSQL, network and presentation.", "No comparison or superiority claim; the matched GitHub/CI pilot remains unmeasured.", "Source authentication, signatures, sandbox isolation and security-scanner effectiveness are not tested."], + totals: { cases: cases.length, evaluations: cases.length * iterations, mismatches, invalidPasses: results.reduce((sum, item) => sum + item.invalidPasses, 0), validCaseRejections: results.reduce((sum, item) => sum + item.validCaseRejections, 0) }, + results, + }; +} diff --git a/scripts/benchmarks/provenance-cases.mjs b/scripts/benchmarks/provenance-cases.mjs new file mode 100644 index 00000000..d3537bcd --- /dev/null +++ b/scripts/benchmarks/provenance-cases.mjs @@ -0,0 +1,43 @@ +import { assembleLineage, candidateIdentity } from "../lib/provenance-ledger.mjs"; +import { sampleObservations } from "../../examples/provenance/sample.mjs"; + +const BENCHMARK_TIME = "2026-09-12T12:00:02.000Z"; +const observedAt = "2026-09-12T12:00:00.000Z"; +const candidate = candidateIdentity({ projectKey: "SAMPLE", repositoryId: "github.com/example/tabellio", baseCommit: "a".repeat(40), headCommit: "b".repeat(40), mergeBase: "a".repeat(40) }); +const kinds = ["task", "run", "commit", "checkpoint", "pull_request", "validation", "review", "security"]; + +// Expectations are declared independently of the evaluator under test. Each call +// constructs fresh data; repeats are measurements, not independent security trials. +export function provenanceBenchmarkCases() { + const cases = []; + const add = (id, expected, mutate = () => {}, options = {}) => { + const observations = sampleObservations(candidate, observedAt); + mutate(observations); + cases.push({ id, expected, lineage: assembleLineage({ candidate, observations }), options: { now: BENCHMARK_TIME, ...options } }); + }; + add("valid", "passed"); + add("reordered", "passed", (items) => items.reverse()); + add("duplicate-delivery", "passed", (items) => items.push(structuredClone(items[0]))); + for (const kind of kinds) add(`missing-${kind}`, "blocked", (items) => items.splice(items.findIndex((item) => item.kind === kind), 1)); + for (const kind of ["validation", "review", "security"]) { + add(`failed-${kind}`, "failed", (items) => { items.find((item) => item.kind === kind).status = "failed"; }); + add(`unexecuted-${kind}`, "blocked", (items) => { items.find((item) => item.kind === kind).status = "present"; }); + } + for (const [key, value] of Object.entries({ headCommit: "c".repeat(40), baseCommit: "c".repeat(40), mergeBase: "c".repeat(40), repositoryId: "github.com/example/other", projectKey: "OTHER" })) { + add(`changed-${key}`, "blocked", undefined, { candidate: candidateIdentity({ ...candidate, [key]: value }) }); + } + add("expired", "blocked", undefined, { now: "2026-09-14T12:00:00Z" }); + add("future-observation", "blocked", (items) => { items[0].observedAt = "2026-09-13T12:00:00Z"; }); + add("conflicting-delivery", "blocked", (items) => items.push({ ...structuredClone(items[0]), observedAt: "2026-09-12T12:00:01Z" })); + add("provider-outage", "blocked", (items) => { + Object.assign(items.find((item) => item.kind === "validation"), { status: "blocked", metadata: { reason: "source_unavailable" } }); + }); + add("inferred-link", "blocked", (items) => { items.find((item) => item.kind === "validation").links[0].basis = "inferred"; }); + add("missing-linked-source", "blocked", (items) => { items.find((item) => item.kind === "validation").links[0].sourceId = "missing-build"; }); + add("wrong-evidence-candidate", "blocked", (items) => { items[0].candidate = candidateIdentity({ ...candidate, headCommit: "d".repeat(40) }); }); + add("tampered-lineage", "rejected"); + const tampered = cases.at(-1); + tampered.lineage.observations[0].status = "failed"; + tampered.expectedError = "Lineage integrity mismatch."; + return cases; +} diff --git a/scripts/ci-checkpoint-evidence.mjs b/scripts/ci-checkpoint-evidence.mjs new file mode 100644 index 00000000..92f66d77 --- /dev/null +++ b/scripts/ci-checkpoint-evidence.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +const git = (...args) => execFileSync('git', args, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: 30000 }).trim(); +const proofRoot = resolve(process.env.RUNNER_TEMP ?? '', 'tabellio-checkpoint-proof'); +const objects = gitDir => { + const root = join(gitDir, 'objects'); + return readdirSync(root).flatMap(name => name === 'pack' || /^[0-9a-f]{2}$/.test(name) + ? readdirSync(join(root, name)).map(file => `${name}/${file}`) : []); +}; +try { + if (process.env.GITHUB_ACTIONS !== 'true' || !process.env.RUNNER_TEMP || realpathSync(process.env.GITHUB_WORKSPACE ?? '') !== process.cwd()) throw new Error('Not an owned runner checkout.'); + if (process.argv[2] === 'cleanup') { + if (existsSync(join(proofRoot, 'context.json'))) { + const context = JSON.parse(readFileSync(join(proofRoot, 'context.json'), 'utf8')); + if (context.gitDir !== resolve(git('rev-parse', '--git-dir'))) throw new Error('Cleanup ownership mismatch.'); + const refs = git('for-each-ref', '--format=%(refname)').split('\n'); + for (const ref of refs) if (!context.refs.includes(ref) && (ref === 'refs/heads/entire/checkpoints/v1' || ref.startsWith('refs/tabellio/'))) git('update-ref', '-d', ref); + for (const file of objects(context.gitDir)) if (!context.objects.includes(file)) rmSync(join(context.gitDir, 'objects', file)); + } + rmSync(proofRoot, { recursive: true, force: true }); + if (existsSync(proofRoot)) throw new Error('Cleanup incomplete.'); + console.log('Private checkpoint runner files removed.'); + } else if (process.argv[2] === 'load') { + const text = (process.env.TABELLIO_CHECKPOINT_PROOF_1 ?? '') + (process.env.TABELLIO_CHECKPOINT_PROOF_2 ?? ''); + if (!text || text.length > 98000) throw new Error('Missing or oversized evidence.'); + const envelope = JSON.parse(text); + if (Object.keys(envelope).sort().join(',') !== 'base,bundle,candidate,nativeTip,repositoryId,schemaVersion,sha256' + || envelope.schemaVersion !== 'tabellio-private-checkpoint-proof/v1' + || envelope.repositoryId !== `github.com/${process.env.GITHUB_REPOSITORY}` + || envelope.candidate !== git('rev-parse', 'HEAD') + || envelope.base !== git('merge-base', 'origin/main', 'HEAD') + || !/^[0-9a-f]{40}$/.test(envelope.nativeTip)) throw new Error('Evidence scope mismatch.'); + const bytes = Buffer.from(envelope.bundle, 'base64'); + if (bytes.toString('base64') !== envelope.bundle || createHash('sha256').update(bytes).digest('hex') !== envelope.sha256) throw new Error('Evidence integrity mismatch.'); + const gitDir = resolve(git('rev-parse', '--git-dir')); + const refs = git('for-each-ref', '--format=%(refname)').split('\n'); + if (refs.some(ref => ref === 'refs/heads/entire/checkpoints/v1' || ref.startsWith('refs/tabellio/'))) throw new Error('Private refs already exist; refuse to alter customer history.'); + mkdirSync(proofRoot, { mode: 0o700 }); + writeFileSync(join(proofRoot, 'context.json'), JSON.stringify({ gitDir, objects: objects(gitDir), refs }), { mode: 0o600, flag: 'wx' }); + const bundle = join(proofRoot, 'checkpoints.bundle'); + writeFileSync(bundle, bytes, { mode: 0o600, flag: 'wx' }); + execFileSync('bash', ['.buildkite/scripts/checkpoint-evidence.sh'], { env: { ...process.env, TABELLIO_CHECKPOINT_BUNDLE: bundle }, stdio: ['ignore', 'pipe', 'pipe'], timeout: 30000 }); + if (git('rev-parse', 'refs/heads/entire/checkpoints/v1') !== envelope.nativeTip) throw new Error('Native identity mismatch.'); + console.log('Genuine native checkpoint evidence imported for the exact candidate.'); + } else throw new Error('Unsupported operation.'); +} catch { + console.error('Private checkpoint proof blocked; inspect the approved private evidence locally.'); + process.exitCode = 1; +} diff --git a/scripts/demo-provenance.mjs b/scripts/demo-provenance.mjs index d4c83bb2..9256e1d7 100755 --- a/scripts/demo-provenance.mjs +++ b/scripts/demo-provenance.mjs @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; +import { provenanceDemoFailure } from "./lib/provenance-demo-failure.mjs"; import { LocalProvenanceStore } from "./lib/local-provenance-store.mjs"; import { captureCandidate } from "./lib/provenance-ledger.mjs"; import { sampleObservations } from "../examples/provenance/sample.mjs"; @@ -65,9 +66,8 @@ try { const git = (...args) => run("git", ["-c", "core.hooksPath=/dev/null", "-c", "commit.gpgsign=false", ...args], repo); await git("init", "-b", "main"); await git("remote", "add", "origin", "https://github.com/sample/repository.git"); - const control = join(repo, "..", "control.git"); + const control = join(repo, "..", "publication.git"); await git("init", "--bare", control); - await git("remote", "add", "control", control); await writeFile(join(repo, "app.mjs"), "export const greeting = 'Hello';\n"); await git("add", "app.mjs"); await git("commit", "-m", "Create sample application"); @@ -132,7 +132,7 @@ try { const publicationLineage = await invoke("show", ...publicationQuery); const publicationReview = await invoke("review", ...publicationArgs); if (publicationReview.status !== "passed") throw new Error("Secured lineage review did not pass."); - const statusIntent = await invoke("review-intent", ...publicationArgs); + const statusIntent = await invoke("review-intent", ...publicationArgs, "--publication-store", join(repo, "..", "publication.git")); const statusPublication = await publishSampleStatuses({ repo, lineage: publicationLineage, intent: statusIntent, cli: publicationReview, now }); const store = new LocalProvenanceStore({ databaseUrl }); await store.migrate(); await run("createdb", ["--host", socketRoot, "--username", "tabellio", "--no-password", "tabellio_replay"]); @@ -193,8 +193,7 @@ try { ); } catch (error) { const postgresLog = await readFile(join(root, "postgres.log"), "utf8").catch(() => ""); - const socketPathFailure = /Unix-domain socket path.*too long/i.test(postgresLog); - receipt = { status: "blocked", failureClass: socketPathFailure ? "socket_path_too_long" : "local_command_failed", exitCode: typeof error.code === "number" ? error.code : null, reason: "Sample demo failed. Requires local PostgreSQL server/client binaries and Git; no provider credentials are required." }; + receipt = { status: "blocked", ...provenanceDemoFailure(error, postgresLog), exitCode: typeof error?.code === "number" ? error.code : null }; process.exitCode = 1; } finally { try { @@ -271,15 +270,7 @@ async function publishSampleStatuses({ repo, lineage, intent, cli, now }) { return new Response(JSON.stringify({ ...body, id: requests.length, created_at: now }), { status: 201 }); } }); const approval = { schemaVersion: "tabellio-provenance-status-approval/v0.1", id: "sample-review-status", intentDigest: intent.integrity.digest, approved: true, approvedBy: "Synthetic demo", approvedAt: now, expiresAt: new Date(Date.parse(now) + 60000).toISOString(), reason: "Local fake GitHub transport only." }; - const controlVerifier = async () => { - const expected = join(repo, "..", "control.git"); - for (const mode of [[], ["--push"]]) { - const actual = await execute("git", ["remote", "get-url", ...mode, "control"], { cwd: repo }); - if (actual.stdout.trim() !== expected) throw new Error("Sample control transport changed."); - } - return expected; - }; - const publication = await publishProvenanceStatuses({ repo, lineage, intent, approval, publisher, now, controlVerifier }); + const publication = await publishProvenanceStatuses({ repo, lineage, intent, approval, publisher, now }); if (publication.status !== "published" || requests.length !== 2) throw new Error("Sample status publication failed."); for (const [index, request] of requests.entries()) { const expected = cli.github[index]; diff --git a/scripts/export-checkpoint-metadata.mjs b/scripts/export-checkpoint-metadata.mjs new file mode 100644 index 00000000..7e283b77 --- /dev/null +++ b/scripts/export-checkpoint-metadata.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +import { chmod, open, readFile, rm } from 'node:fs/promises'; +import { execFileSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { createHash } from 'node:crypto'; +import { parseOptionPairs } from './lib/cli-options.mjs'; +import { runGit } from './lib/git-process.mjs'; +import { captureValidationCheckpoints } from './lib/validation-checkpoints.mjs'; + +let ownedOutput; +try { + const options = parseOptionPairs(process.argv.slice(2)); + if (Object.keys(options).some(key => !['repo', 'repoId', 'base', 'commit', 'out'].includes(key)) || !options.repoId || !options.base || !options.commit || !options.out) throw new Error('Required export options are missing or unsupported.'); + const repo = resolve(options.repo ?? '.'); + const git = async (...args) => (await runGit({ cwd: repo, args })).stdout.trim(); + const headCommit = await git('rev-parse', '--verify', '--end-of-options', `${options.commit}^{commit}`); + const baseCommit = await git('rev-parse', '--verify', '--end-of-options', `${options.base}^{commit}`); + const mergeBase = await git('merge-base', baseCommit, headCommit); + const messages = await git('log', '--format=%B', `${mergeBase}..${headCommit}`, '--'); + const ids = [...new Set([...messages.matchAll(/^Entire-Checkpoint:\s*([0-9a-f]{12})\s*$/gim)].map(match => match[1].toLowerCase()))]; + const evidence = await captureValidationCheckpoints(repo, options.repoId, { mergeBase, headCommit }, ids); + if (evidence.provider.version !== '0.7.7') throw new Error('Metadata export requires pinned Entire 0.7.7.'); + const nativeRef = 'refs/heads/entire/checkpoints/v1'; + const nativeTip = await git('rev-parse', '--verify', `${nativeRef}^{commit}`); + const paths = evidence.checkpoints.flatMap(checkpoint => { + const prefix = `${checkpoint.id.slice(0, 2)}/${checkpoint.id.slice(2)}`; + return [`${prefix}/metadata.json`, ...checkpoint.sessions.map(session => `${prefix}/${session.index}/metadata.json`)]; + }); + // A native Git filter preserves commit/tree identities; no checkpoint is reconstructed. + const pattern = `${paths.map(path => `/${path}`).join('\n')}\n`; + const patternOid = execFileSync('git', ['hash-object', '-w', '--stdin'], { cwd: repo, input: pattern, encoding: 'utf8', timeout: 30000 }).trim(); + const filter = `sparse:oid=${patternOid}`; + const objects = (await git('rev-list', '--objects', `--filter=${filter}`, nativeTip)).split('\n'); + let metadataBlobs = 0; + for (const object of objects) { + const [oid, ...name] = object.split(' '); + if (await git('cat-file', '-t', oid) === 'blob') { + if (!paths.includes(name.join(' '))) throw new Error('Filtered export includes an unexpected blob.'); + metadataBlobs += 1; + } + } + const out = resolve(options.out); + const file = await open(out, 'wx', 0o600); await file.close(); ownedOutput = out; + await git('bundle', 'create', out, `--filter=${filter}`, nativeRef); + await chmod(out, 0o600); + if (await git('rev-parse', nativeRef) !== nativeTip) throw new Error('Native checkpoint history changed during export.'); + await git('bundle', 'verify', out); + const bytes = await readFile(out); + console.log(JSON.stringify({ ok: true, nativeRef, nativeTip, headCommit, mergeBase, checkpointIds: ids.sort(), metadataBlobs, bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex'), transcriptsIncluded: false, promptsIncluded: false })); + ownedOutput = null; +} catch { + if (ownedOutput) await rm(ownedOutput, { force: true }); + console.error(JSON.stringify({ ok: false, reason: 'Native metadata export failed; inspect private local evidence and pinned prerequisites.' })); + process.exitCode = 1; +} diff --git a/scripts/lib/local-provenance-store.mjs b/scripts/lib/local-provenance-store.mjs index ba4f351f..468d4323 100644 --- a/scripts/lib/local-provenance-store.mjs +++ b/scripts/lib/local-provenance-store.mjs @@ -92,6 +92,7 @@ export class LocalProvenanceStore { const envelope = sqlParameter(JSON.stringify(lineage), variables); const sql = `BEGIN; CREATE TEMP TABLE incoming_lineage ON COMMIT DROP AS SELECT ${envelope}::jsonb AS data; + ANALYZE incoming_lineage; INSERT INTO tabellio_lineages (digest, candidate_id, project_key, repository_id, base_commit, head_commit, merge_base, envelope) SELECT data->>'digest', data->'candidate'->>'id', data->'candidate'->>'projectKey', data->'candidate'->>'repositoryId', data->'candidate'->>'baseCommit', data->'candidate'->>'headCommit', data->'candidate'->>'mergeBase', data @@ -119,6 +120,7 @@ export class LocalProvenanceStore { if (!result) return null; const { verifyLineage } = await import("./provenance-ledger.mjs"); const lineage = verifyLineage(JSON.parse(result)); + if (lineage.digest !== query.digest) throw new Error("Stored lineage digest integrity mismatch."); if (lineage.candidate.projectKey !== projectKey || lineage.candidate.repositoryId !== repositoryId) throw new Error("Stored lineage scope integrity mismatch."); return lineage; } diff --git a/scripts/lib/platform-config.mjs b/scripts/lib/platform-config.mjs index 6ad99907..617ba810 100644 --- a/scripts/lib/platform-config.mjs +++ b/scripts/lib/platform-config.mjs @@ -7,7 +7,9 @@ const CONTROL_REFS = [ const CODE_REF_PREFIXES = ["refs/heads/", "refs/tags/"]; export function validatePlatformConfig(value) { - exactObject(value, { schemaVersion: "tabellio-platform/v0.3" }, "platform", ["codeStorage", "workflow", "ledger", "validation", "reviews"]); + const legacy = value?.schemaVersion === "tabellio-platform/v0.3"; + const local = !legacy && value?.workflow?.controlState === "local"; + exactObject(value, { schemaVersion: legacy ? "tabellio-platform/v0.3" : "tabellio-platform/v0.4" }, "platform", ["codeStorage", "workflow", "ledger", "validation", "reviews"]); exactObject(value.codeStorage, { provider: "github", @@ -19,21 +21,21 @@ export function validatePlatformConfig(value) { exactObject(value.workflow, { stackManager: "git-spice", - controlState: "external", - controlProvider: "github", - controlRemoteName: "control", + controlState: local ? "local" : "external", + controlProvider: local ? "local" : "github", + controlRemoteName: local ? null : "control", publishControlRefsToCodeStorage: false, }, "platform.workflow", ["controlRefs"]); exactSet(value.workflow.controlRefs, CONTROL_REFS, "platform.workflow.controlRefs"); exactObject(value.ledger, { provider: "entire", - storage: "external", + storage: local ? "local" : "external", checkpointRef: "refs/heads/entire/checkpoints/v1", }, "platform.ledger"); exactObject(value.validation, { runner: "tabellio-validate", - storage: "external", + storage: local ? "local" : "external", resultRef: "refs/tabellio/validations", }, "platform.validation", ["manifest"]); if (typeof value.validation.manifest !== "string" || value.validation.manifest.trim() === "") { @@ -41,12 +43,17 @@ export function validatePlatformConfig(value) { } exactObject(value.reviews, { provider: "tabellio", - storage: "external", + storage: local ? "local" : "external", stateRef: "refs/tabellio/reviews", }, "platform.reviews"); return value; } +export function platformControlMode(value) { + validatePlatformConfig(value); + return value.workflow.controlState === "local" ? "local" : "remote"; +} + function exactObject(value, expected, path, additionalKeys = []) { assertObject(value, path); const wantedKeys = [...Object.keys(expected), ...additionalKeys].sort(); diff --git a/scripts/lib/preflight.mjs b/scripts/lib/preflight.mjs index 706e7bc2..030463bc 100644 --- a/scripts/lib/preflight.mjs +++ b/scripts/lib/preflight.mjs @@ -13,7 +13,7 @@ import { readRemoteRefOid, sameGitHubRepository, } from "./github-repository.mjs"; -import { validatePlatformConfig } from "./platform-config.mjs"; +import { platformControlMode, validatePlatformConfig } from "./platform-config.mjs"; import { repositoryIdentity } from "./repository-identity.mjs"; import { NativeGitStore } from "../providers/native-git-store.mjs"; @@ -125,7 +125,8 @@ export async function runPreflight({ ? checkCodexHookTrust({ effectiveHooks: codexEffectiveHooks, managedEvents: codexManagedEvents }) : blocked("Codex configuration validity is unproven.", "Correct the Codex configuration, then rerun preflight.")); - await record(checks, "github-auth", async () => { + const platform = store ? await readPlatformConfig(store).catch(() => null) : null; + if (profile === "release" || (platform && platformControlMode(platform) === "remote") || controlRemote) await record(checks, "github-auth", async () => { await commandRunner({ binary: ghBinary, args: ["auth", "status", "--hostname", "github.com"], cwd: resolvedRepo, timeoutMs: 30_000 }); return passed("GitHub CLI authenticated for github.com."); }); @@ -368,6 +369,11 @@ async function checkEntireMetadata(store, options) { async function checkEntireCheckpointTransport(store, remoteRepositoryReader) { const settings = await readEntireTransportSettings(store); + if (platformControlMode(settings.platform) === "local") { + const automatic = automaticCheckpointPushBlocker(settings.local, settings.project); + if (automatic) return automatic; + return null; + } const remotes = configuredCheckpointRemotes(settings.local, settings.project); if (remotes.blocker) return remotes.blocker; const control = await remoteRepositoryReader(store, settings.platform.workflow.controlRemoteName); @@ -479,6 +485,14 @@ async function readJsonIfExists(path) { async function checkEntireMetadataBranch(store, remoteRefReader, profile) { const platform = await readPlatformConfig(store); const localRef = platform.ledger.checkpointRef; + if (platformControlMode(platform) === "local") { + if (!(await store.hasRef(localRef))) return profile === "agent" + ? passed("Local checkpoint storage is not initialized; first real agent checkpoint may create it.") + : blocked("Local checkpoint metadata branch is missing.", "Create a genuine checkpoint before release preflight."); + const result = await checkEntireMetadataContents(store, localRef, { allowEmpty: profile === "agent" }); + if (result.status === "blocked") return result; + return passed(`${result.detail} Local mode does not contact or publish to configured checkpoint remotes; automatic pushing is disabled.`); + } if (!(await store.hasRef(localRef))) { return checkMissingMetadataBranch(store, platform, localRef, remoteRefReader, profile); } @@ -537,13 +551,13 @@ async function checkEntireMetadataContents(store, localRef, { allowEmpty }) { const invalidPath = await firstInvalidMetadataPath(store, localRef, metadataPaths, files); return invalidPath ? blocked(`Entire checkpoint metadata is invalid: ${invalidPath}.`, "Repair the checkpoint metadata branch explicitly, then rerun preflight.") - : passed("Live remote Entire metadata is contained locally and checkpoint contents are valid."); + : passed("Entire checkpoint contents are valid."); } function emptyMetadataResult(files, allowEmpty) { if (files.length !== 0) return null; return allowEmpty - ? passed("Live remote Entire metadata is contained locally; initialized agent metadata is empty.") + ? passed("Initialized agent checkpoint metadata is empty.") : blocked("Entire metadata branch contains no checkpoint metadata.", "Create a checkpoint before release preflight, then rerun."); } @@ -699,11 +713,14 @@ async function recordRepositoryChecks({ checks, store, profile, commandRunner, g async function checkPlatformContract(store) { const source = await readFile(resolve(store.repoPath, "tabellio.platform.json"), "utf8"); validatePlatformConfig(JSON.parse(source)); - return passed("GitHub code storage and external control-state contract valid."); + return passed("Code storage and explicit private evidence storage contract valid."); } async function checkGitHubRemotes({ store, commandRunner, ghBinary, controlRemote, remoteRepositoryReader }) { const platform = await readPlatformConfig(store); + if (platformControlMode(platform) === "local") return controlRemote + ? blocked("A control remote was selected in local mode.", "Select private remote mode explicitly before using a control remote.") + : passed("Private evidence is local; no GitHub control repository is required."); const configuredControl = platform.workflow.controlRemoteName; const selectedControl = controlRemote || configuredControl; const selectionBlocker = controlSelectionBlocker(selectedControl, configuredControl); diff --git a/scripts/lib/provenance-demo-failure.mjs b/scripts/lib/provenance-demo-failure.mjs new file mode 100644 index 00000000..3bbf18ea --- /dev/null +++ b/scripts/lib/provenance-demo-failure.mjs @@ -0,0 +1,20 @@ +// Classify only known local setup failures. Never expose command arguments, +// provider payloads, raw errors, or database logs in a portable demo receipt. +export function provenanceDemoFailure(error, postgresLog = "") { + if (/Unix-domain socket path.*too long/i.test(postgresLog)) return { + failureClass: "socket_path_too_long", + reason: "The PostgreSQL Unix socket path exceeds the platform limit. Use a shorter temporary socket path.", + }; + if (/could not create Unix socket[^\n]*(?:Operation not permitted|Permission denied)/i.test(postgresLog)) return { + failureClass: "socket_permission_denied", + reason: "This environment blocks the temporary PostgreSQL Unix socket. Use an authorized execution environment that supports local sockets; do not weaken security settings.", + }; + if (error?.code === "ENOENT") return { + failureClass: "required_tool_missing", + reason: "A required local executable is unavailable. Check PostgreSQL server/client binaries and Git on PATH.", + }; + return { + failureClass: "local_command_failed", + reason: "Sample demo failed. Check local PostgreSQL, Git, and the required checks; no provider credentials are required.", + }; +} diff --git a/scripts/lib/provenance-review-publication.mjs b/scripts/lib/provenance-review-publication.mjs index 8519efa9..6bf43115 100644 --- a/scripts/lib/provenance-review-publication.mjs +++ b/scripts/lib/provenance-review-publication.mjs @@ -1,6 +1,9 @@ import { randomUUID } from "node:crypto"; +import { realpathSync } from "node:fs"; +import { isAbsolute } from "node:path"; import { runExternalCommand } from "./external-command.mjs"; import { runGit } from "./git-process.mjs"; +import { assertExternalStateRoot } from "./external-state-root.mjs"; import { contract } from "./contract-checks.mjs"; import { digestObject } from "./stack-operation.mjs"; import { validateOperationApproval } from "./approval-validation.mjs"; @@ -17,15 +20,49 @@ const FAILED_DELIVERY = "Publication failed or became uncertain. Inspect GitHub export function createProvenanceStatusIntent(lineage, options) { const result = buildProvenanceReviewResult(lineage, options); if (result.github.length !== 2) throw new Error("A GitHub repository identity is required."); - const unsigned = { schemaVersion: INTENT, createdAt: result.evaluatedAt, candidate: result.currentCandidate, lineageDigest: lineage.digest, reportUrl: options.reportUrl ?? null, reservationRemote: "control", statuses: result.github }; + const unsigned = { schemaVersion: INTENT, createdAt: result.evaluatedAt, candidate: result.currentCandidate, lineageDigest: lineage.digest, reportUrl: options.reportUrl ?? null, reservationRemote: reservationAuthority(options), statuses: result.github }; return { ...unsigned, integrity: { algorithm: "sha256", digest: digestObject(unsigned) } }; } +// The authority is part of the approval-bound intent: approval cannot be moved to a +// fresh per-clone store after an uncertain publication. All publishing clones +// must use the same customer-owned authority; local storage is not distributed. +function reservationAuthority(options) { + const path = options.publicationStore ?? process.env.TABELLIO_PUBLICATION_STORE; + if (path !== undefined) { + if (typeof path !== "string" || !isAbsolute(path)) throw new Error("Publication store must be an existing absolute path to a customer-owned bare Git repository."); + return `local:${realpathSync(path)}`; + } + const authority = options.reservationRemote ?? "local"; + validateReservationAuthority(authority); + return authority; +} + +function validateReservationAuthority(authority) { + if (authority === "control" || authority === "local") return; + if (typeof authority !== "string" || !authority.startsWith("local:") || !isAbsolute(authority.slice(6)) || /[\0-\x1f]/.test(authority)) { + throw new Error("Reservation authority must be local, an absolute local store, or the explicitly selected control remote."); + } +} + +async function localReservationLedger(repo, authority, ref) { + if (authority === "local") throw new Error("Configure TABELLIO_PUBLICATION_STORE to an existing customer-owned bare Git repository shared by every publishing process before approving publication."); + const path = authority.slice(6); + if (realpathSync(path) !== path) throw new Error("Publication authority path changed; create and approve a new intent."); + const bare = await runGit({ cwd: path, args: ["rev-parse", "--is-bare-repository"] }); + if (bare.stdout.trim() !== "true") throw new Error("Publication authority must be a separate bare Git repository, not the public code checkout or its .git directory."); + const codeCommon = await runGit({ cwd: repo, args: ["rev-parse", "--path-format=absolute", "--git-common-dir"] }); + if (realpathSync(codeCommon.stdout.trim()) === path) throw new Error("Publication authority must be separate from the public code repository."); + assertExternalStateRoot(realpathSync(repo), path, "Publication authority"); + assertExternalStateRoot(realpathSync(codeCommon.stdout.trim()), path, "Publication authority"); + return GitJsonLedger.open({ repoPath: path, ref }); +} + function validateIntent(value) { contract.object(value, "intent"); contract.exactKeys(value, ["schemaVersion", "createdAt", "candidate", "lineageDigest", "reportUrl", "reservationRemote", "statuses", "integrity"], "intent"); contract.equals(value.schemaVersion, INTENT, "intent.schemaVersion"); - contract.equals(value.reservationRemote, "control", "intent.reservationRemote"); + validateReservationAuthority(value.reservationRemote); contract.date(value.createdAt, "intent.createdAt"); contract.sha256(value.lineageDigest, "intent.lineageDigest"); contract.equals(digestObject(value.candidate), digestObject(candidateIdentity(value.candidate)), "intent.candidate"); @@ -45,7 +82,7 @@ async function assertCurrent(repo, intent, base, head) { function checkedResponse(response, expected) { for (const key of ["commit", "state", "context", "description", "targetUrl"]) contract.equals(response[key], expected[key], `published.${key}`); - if (!/^[0-9]{1,20}$/.test(String(response.id))) throw new Error("Invalid published status ID."); + if ((typeof response.id !== "string" && !Number.isSafeInteger(response.id)) || !/^[1-9][0-9]{0,19}$/.test(String(response.id))) throw new Error("Invalid published status ID."); return { id: String(response.id), commit: response.commit, state: response.state, context: response.context }; } @@ -54,7 +91,9 @@ async function sendStatuses({ repo, intent, base, head, publisher }) { try { for (const expected of intent.statuses) { await assertCurrent(repo, intent, base, head); - published.push(checkedResponse(await publisher.publish(expected), expected)); + const response = checkedResponse(await publisher.publish(expected), expected); + if (published.some(item => item.id === response.id)) throw new Error("Duplicate published status ID."); + published.push(response); } await assertCurrent(repo, intent, base, head); return { status: "published", published }; @@ -84,7 +123,7 @@ function validateStoredReceipt(receipt, intent, approval, now) { for (const [index, published] of receipt.published.entries()) { contract.object(published, "receipt.published"); contract.exactKeys(published, ["id", "commit", "state", "context"], "receipt.published"); - if (typeof published.id !== "string" || !/^[0-9]{1,20}$/.test(published.id) || ids.has(published.id)) throw new Error("Invalid receipt status ID."); + if (typeof published.id !== "string" || !/^[1-9][0-9]{0,19}$/.test(published.id) || ids.has(published.id)) throw new Error("Invalid receipt status ID."); ids.add(published.id); for (const key of ["commit", "state", "context"]) contract.equals(published[key], intent.statuses[index][key], `receipt.published.${key}`); } @@ -101,6 +140,17 @@ export async function verifyPrivateControl({ repo, remote = "control", commandRu return control.key; } +async function synchronizeReservation({ local, repo, remote, ref, version, expectedVersion, controlVerifier, controlKey }) { + if (local) return true; + try { + contract.equals(await controlVerifier({ repo, remote }), controlKey, "control repository before push"); + await runGit({ cwd: repo, args: ["push", `--force-with-lease=${ref}:${expectedVersion}`, remote, `${version}:${ref}`] }); + return true; + } catch { + return false; + } +} + export async function publishProvenanceStatuses({ repo, lineage, intent, approval, publisher, now, base = "main", head = "HEAD", controlVerifier = verifyPrivateControl }) { contract.date(now, "publication time"); validateOperationApproval(approval, intent, { schemaVersion: APPROVAL, validateIntent, now: new Date(now) }); @@ -113,11 +163,12 @@ export async function publishProvenanceStatuses({ repo, lineage, intent, approva const remote = await effectiveGitHubRepository(store, "origin"); const target = `${intent.statuses[0].owner}/${intent.statuses[0].repo}`.toLowerCase(); contract.equals(remote.key, target, "GitHub origin identity"); - const controlKey = await controlVerifier({ repo, remote: intent.reservationRemote }); + const local = intent.reservationRemote !== "control"; + const controlKey = local ? null : await controlVerifier({ repo, remote: intent.reservationRemote }); const ref = `refs/tabellio/provenance-status-reservations/${digestObject({ approvalId: approval.id })}`; - const ledger = await GitJsonLedger.open({ repoPath: repo, ref }); + const ledger = local ? await localReservationLedger(store.repoPath, intent.reservationRemote, `refs/tabellio/provenance-status-reservations/${digestObject({ repository: remote.key, approvalId: approval.id })}`) : await GitJsonLedger.open({ repoPath: repo, ref }); const path = "receipt.json"; - const remoteVersion = (await runGit({ cwd: repo, args: ["ls-remote", "--refs", intent.reservationRemote, ref] })).stdout.trim().split(/\s+/)[0]; + const remoteVersion = local ? "" : (await runGit({ cwd: repo, args: ["ls-remote", "--refs", intent.reservationRemote, ref] })).stdout.trim().split(/\s+/)[0]; let prior = await ledger.read(path); if (remoteVersion) { await runGit({ cwd: repo, args: ["fetch", "--no-write-fetch-head", intent.reservationRemote, ref] }); @@ -130,22 +181,18 @@ export async function publishProvenanceStatuses({ repo, lineage, intent, approva if (prior.value.status !== "pending") return prior.value; return { ...prior.value, status: "blocked", reason: "An earlier attempt is unresolved. Inspect GitHub before authorizing another attempt." }; } - // Remote CAS reserves one publisher across clones before GitHub delivery. - // A local pending receipt also prevents retry after an uncertain Git push. + // Git update-ref CAS reserves one publisher at the shared authority before + // delivery. A pending receipt is durable and never automatically retried. + // Remote mode additionally uses receive-side CAS across independent clones. const receipt = { schemaVersion: "tabellio-provenance-status-receipt/v0.1", approvalId: approval.id, reservationId: randomUUID(), intentDigest: intent.integrity.digest, candidateId: candidate.id, attemptedAt: now, status: "pending", published: [] }; const attempt = await ledger.write(path, receipt, { expectedVersion: prior.version }); - try { - contract.equals(await controlVerifier({ repo, remote: intent.reservationRemote }), controlKey, "control repository before push"); - await runGit({ cwd: repo, args: ["push", `--force-with-lease=${ref}:`, intent.reservationRemote, `${attempt.version}:${ref}`] }); - } catch { + const synchronization = { local, repo, remote: intent.reservationRemote, ref, controlVerifier, controlKey }; + if (!await synchronizeReservation({ ...synchronization, version: attempt.version, expectedVersion: "" })) { throw new Error("Approval reservation failed or is uncertain. Inspect the control remote before a new approval."); } const completed = { ...receipt, ...await sendStatuses({ repo, intent, base, head, publisher }) }; const finished = await ledger.write(path, completed, { expectedVersion: attempt.version }); - try { - contract.equals(await controlVerifier({ repo, remote: intent.reservationRemote }), controlKey, "control repository before push"); - await runGit({ cwd: repo, args: ["push", `--force-with-lease=${ref}:${attempt.version}`, intent.reservationRemote, `${finished.version}:${ref}`] }); - } catch { + if (!await synchronizeReservation({ ...synchronization, version: finished.version, expectedVersion: attempt.version })) { return { ...completed, status: "blocked", reason: "Delivery receipt synchronization is uncertain. Inspect GitHub and the control remote; do not retry." }; } return completed; diff --git a/scripts/lib/release-operation.mjs b/scripts/lib/release-operation.mjs index d3f6db0e..0dcf044e 100644 --- a/scripts/lib/release-operation.mjs +++ b/scripts/lib/release-operation.mjs @@ -19,19 +19,20 @@ export function createReleaseIntent({ pullRequest, controlIntent, controlRepository, + localControlRefs = null, validation, release, createdAt = new Date().toISOString(), }) { const unsigned = { - schemaVersion: RELEASE_OPERATION_VERSION, + schemaVersion: localControlRefs ? "tabellio-release-operation/v0.2" : RELEASE_OPERATION_VERSION, repository, version, tag: `v${version}`, revision, pullRequest, code: { remote: "origin", branch: "main" }, - control: { repository: controlRepository, intent: controlIntent }, + control: localControlRefs ? { mode: "local", refs: localControlRefs } : { repository: controlRepository, intent: controlIntent }, validation, release, createdAt, @@ -48,7 +49,7 @@ export function validateReleaseIntent(value) { "schemaVersion", "repository", "version", "tag", "revision", "pullRequest", "code", "control", "validation", "release", "createdAt", "integrity", ], "intent"); - contract.equals(value.schemaVersion, RELEASE_OPERATION_VERSION, "intent.schemaVersion"); + contract.member(value.schemaVersion, [RELEASE_OPERATION_VERSION, "tabellio-release-operation/v0.2"], "intent.schemaVersion"); contract.object(value.repository, "intent.repository"); contract.exactKeys(value.repository, ["id", "owner", "name"], "intent.repository"); @@ -79,16 +80,28 @@ export function validateReleaseIntent(value) { contract.equals(value.code.branch, "main", "intent.code.branch"); contract.object(value.control, "intent.control"); - contract.exactKeys(value.control, ["repository", "intent"], "intent.control"); - contract.object(value.control.repository, "intent.control.repository"); - contract.exactKeys(value.control.repository, ["id"], "intent.control.repository"); - contract.string(value.control.repository.id, "intent.control.repository.id"); - assertSeparateControlRepository(value.repository, value.control.repository); - validateControlRefIntent(value.control.intent); - contract.equals(value.control.intent.operation, "publish", "intent.control.intent.operation"); - contract.equals(value.control.intent.remote, "control", "intent.control.intent.remote"); - contract.equals(value.control.intent.repository.id, value.repository.id, "intent.control.intent.repository.id"); - assertCompleteReleaseControlRefs(value.control.intent.refs); + if (value.schemaVersion === "tabellio-release-operation/v0.2") { + contract.exactKeys(value.control, ["mode", "refs"], "intent.control"); + contract.equals(value.control.mode, "local", "intent.control.mode"); + if (!Array.isArray(value.control.refs)) throw new Error("intent.control.refs must be an array."); + assertCompleteReleaseControlRefs(value.control.refs); + for (const ref of value.control.refs) { + contract.object(ref, "intent.control.refs entry"); + contract.exactKeys(ref, ["name", "localOid"], "intent.control.refs entry"); + contract.oid(ref.localOid, "intent.control.refs.localOid"); + } + } else { + contract.exactKeys(value.control, ["repository", "intent"], "intent.control"); + contract.object(value.control.repository, "intent.control.repository"); + contract.exactKeys(value.control.repository, ["id"], "intent.control.repository"); + contract.string(value.control.repository.id, "intent.control.repository.id"); + assertSeparateControlRepository(value.repository, value.control.repository); + validateControlRefIntent(value.control.intent); + contract.equals(value.control.intent.operation, "publish", "intent.control.intent.operation"); + contract.equals(value.control.intent.remote, "control", "intent.control.intent.remote"); + contract.equals(value.control.intent.repository.id, value.repository.id, "intent.control.intent.repository.id"); + assertCompleteReleaseControlRefs(value.control.intent.refs); + } contract.object(value.validation, "intent.validation"); contract.exactKeys(value.validation, ["runId", "resultVersion", "status", "headCommit"], "intent.validation"); diff --git a/scripts/lib/release-planner.mjs b/scripts/lib/release-planner.mjs index 0fc1cf92..54ec9b46 100644 --- a/scripts/lib/release-planner.mjs +++ b/scripts/lib/release-planner.mjs @@ -8,7 +8,7 @@ import { GitJsonLedger } from "./git-json-ledger.mjs"; import { effectiveGitHubRepository, sameGitHubRepository } from "./github-repository.mjs"; import { runGit } from "./git-process.mjs"; import { runPreflight } from "./preflight.mjs"; -import { validatePlatformConfig } from "./platform-config.mjs"; +import { platformControlMode, validatePlatformConfig } from "./platform-config.mjs"; import { createReleaseIntent } from "./release-operation.mjs"; import { ReviewCycleManager, reviewCycleHasReleaseReadiness } from "./review-cycle.mjs"; import { ValidationRunner } from "./validation-runner.mjs"; @@ -24,7 +24,7 @@ export async function planRelease({ version, notesPath, title = `Tabellio v${version}`, - controlRemote = "control", + controlRemote = null, manifestPath = "tabellio.validation.json", runnerId = "tabellio-release", token, @@ -40,8 +40,10 @@ export async function planRelease({ const store = await NativeGitStore.open(resolve(repoPath)); const preflight = await preflightRunner({ repoPath: store.repoPath, profile: "release", ghBinary, commandRunner, controlRemote }); assertReadyPreflight(preflight); - await assertPlatformManifest(store, manifestPath); - const repositories = await bindReleaseRepositories(store, { owner, repo, explicitRepositoryId, controlRemote, remoteRepositoryReader }); + const platform = await assertPlatformManifest(store, manifestPath); + const localControl = platformControlMode(platform) === "local"; + const effectiveControlRemote = localControl ? null : (controlRemote ?? platform.workflow.controlRemoteName); + const repositories = await bindReleaseRepositories(store, { owner, repo, explicitRepositoryId, controlRemote: effectiveControlRemote, remoteRepositoryReader, localControl }); const repositoryId = repositories.code.identity; const evidence = await loadReleaseEvidence({ store, notesPath, ghBinary, owner, repo, number, commandRunner }); const { headCommit, parentCommit, notesSource, pr } = validateReleaseEvidence(evidence, { version, number }); @@ -72,14 +74,16 @@ export async function planRelease({ runnerId, now, }); - const controlIntent = await planControlPublication({ store, repositoryId, controlRemote, now }); + const controlIntent = localControl ? null : await planControlPublication({ store, repositoryId, controlRemote: effectiveControlRemote, now }); + const localControlRefs = localControl ? await Promise.all(["refs/tabellio/reviews", "refs/tabellio/validations", "refs/heads/entire/checkpoints/v1"].map(async (name) => ({ name, localOid: await store.resolveRef(name) }))) : null; return createReleaseIntent({ repository: { id: repositoryId, owner, name: repo }, version, revision: { commit: headCommit, parent: parentCommit }, pullRequest: { number, headCommit: pr.headRefOid, mergeCommit: pr.mergeCommit.oid }, controlIntent, - controlRepository: { id: repositories.control.identity }, + controlRepository: localControl ? null : { id: repositories.control.identity }, + localControlRefs, validation: { runId: validation.result.runId, resultVersion: validation.version, @@ -98,21 +102,22 @@ function validatePlanInput({ owner, repo, number, version, notesPath, controlRem contract.semver(version, "version"); contract.safeRelativePath(notesPath, "notesPath"); contract.safeRelativePath(manifestPath, "manifestPath"); - contract.equals(controlRemote, "control", "controlRemote"); + contract.member(controlRemote, [null, "control"], "controlRemote"); } async function assertPlatformManifest(store, manifestPath) { const source = await runGit({ args: ["show", "HEAD:tabellio.platform.json"], cwd: store.repoPath }); const platform = validatePlatformConfig(JSON.parse(source.stdout)); contract.equals(manifestPath, platform.validation.manifest, "manifestPath"); + return platform; } -async function bindReleaseRepositories(store, { owner, repo, explicitRepositoryId, controlRemote, remoteRepositoryReader }) { +async function bindReleaseRepositories(store, { owner, repo, explicitRepositoryId, controlRemote, remoteRepositoryReader, localControl }) { const [origin, control] = await Promise.all([ remoteRepositoryReader(store, "origin"), - remoteRepositoryReader(store, controlRemote), + localControl ? Promise.resolve(null) : remoteRepositoryReader(store, controlRemote), ]); - if (sameGitHubRepository(origin, control)) throw new Error("Control repository must differ from the code repository."); + if (control && sameGitHubRepository(origin, control)) throw new Error("Control repository must differ from the code repository."); assertRequestedRepository(origin, owner, repo); assertExplicitRepositoryId(origin, explicitRepositoryId); return { code: origin, control }; diff --git a/scripts/lib/release-workflow.mjs b/scripts/lib/release-workflow.mjs index d2e228b9..363db2b1 100644 --- a/scripts/lib/release-workflow.mjs +++ b/scripts/lib/release-workflow.mjs @@ -109,12 +109,12 @@ class ReleaseActions { async #verify({ intent }) { const [repositoryId, controlRepository, head, trackedMain, packageResult, notesSource, refs] = await Promise.all([ this.codeRepositoryReader(this.store), - this.controlRepositoryReader(this.store, intent.control.intent.remote, this.ghBinary, this.commandRunner), + isLocalControl(intent) ? Promise.resolve(null) : this.controlRepositoryReader(this.store, intent.control.intent.remote, this.ghBinary, this.commandRunner), this.store.resolveRef("HEAD"), this.store.resolveRef("origin/main"), runGit({ args: ["show", `${intent.revision.commit}:package.json`], cwd: this.store.repoPath }), sourceAtCommit(this.store.repoPath, intent.revision.commit, intent.release.notesPath), - snapshotControlRefs({ + isLocalControl(intent) ? readLocalReleaseRefs(this.store, intent) : snapshotControlRefs({ repoPath: this.store.repoPath, remote: intent.control.intent.remote, refs: intent.control.intent.refs.map((entry) => entry.name), @@ -125,10 +125,14 @@ class ReleaseActions { const branch = await runGit({ args: ["branch", "--show-current"], cwd: this.store.repoPath }); assertReleaseRepository({ repositoryId, controlRepository, head, trackedMain, liveMain, branch: branch.stdout, status: status.stdout }, intent); assertReleaseArtifacts({ packageSource: packageResult.stdout, notesSource, refs }, intent); - return { commit: head, version: intent.version, controlIntentDigest: intent.control.intent.integrity.digest }; + return { commit: head, version: intent.version, evidenceDigest: isLocalControl(intent) ? intent.integrity.digest : intent.control.intent.integrity.digest }; } async #publishControl({ intent, approval, now }) { + if (isLocalControl(intent)) { + assertLocalReleaseRefs(await readLocalReleaseRefs(this.store, intent), intent.control.refs); + return { status: "verified-local", refs: intent.control.refs.map((ref) => ref.name) }; + } const controlRepository = await this.controlRepositoryReader(this.store, intent.control.intent.remote, this.ghBinary, this.commandRunner); assertControlRepository(controlRepository, intent); const snapshot = await snapshotControlRefs({ @@ -162,10 +166,15 @@ class ReleaseActions { return { status: result.status, approvalId: result.approvalId, refs: result.refs }; } - async #publishTag({ intent }) { + async #verifyPublicationEvidence(intent, identityMessage) { + if (isLocalControl(intent)) assertLocalReleaseRefs(await readLocalReleaseRefs(this.store, intent), intent.control.refs); const repositoryId = await this.codeRepositoryReader(this.store); - assertRepositoryIdentity(repositoryId, intent.repository.id, "Release repository identity changed before tag publication."); - const canonicalObject = await canonicalTagObject(this.store.repoPath, intent); + assertRepositoryIdentity(repositoryId, intent.repository.id, identityMessage); + return canonicalTagObject(this.store.repoPath, intent); + } + + async #publishTag({ intent }) { + const canonicalObject = await this.#verifyPublicationEvidence(intent, "Release repository identity changed before tag publication."); let local = await localTagState(this.store.repoPath, intent.tag); const remote = await remoteTagState(this.store.repoPath, intent.code.remote, intent.tag); assertTagTarget(local, intent, "locally", canonicalObject); @@ -181,9 +190,7 @@ class ReleaseActions { } async #publishRelease({ intent }) { - const repositoryId = await this.codeRepositoryReader(this.store); - assertRepositoryIdentity(repositoryId, intent.repository.id, "Release repository identity changed before GitHub release publication."); - const canonicalObject = await canonicalTagObject(this.store.repoPath, intent); + const canonicalObject = await this.#verifyPublicationEvidence(intent, "Release repository identity changed before GitHub release publication."); const remoteTag = await remoteTagState(this.store.repoPath, intent.code.remote, intent.tag); if (!remoteTag) throw new Error(`${intent.tag} is missing remotely before GitHub release publication.`); assertTagTarget(remoteTag, intent, "remotely", canonicalObject); @@ -270,7 +277,7 @@ function assertReleaseRepository(actual, intent) { function assertReleaseRevision(actual, intent) { assertRepositoryIdentity(actual.repositoryId, intent.repository.id, "Release repository identity changed."); - assertControlRepository(actual.controlRepository, intent); + if (!isLocalControl(intent)) assertControlRepository(actual.controlRepository, intent); assertEqual(actual.head, intent.revision.commit, "Release HEAD changed after planning."); assertEqual(actual.trackedMain, actual.head, "Release commit no longer equals tracked origin/main."); assertEqual(actual.liveMain, actual.head, "Release commit no longer equals live origin/main."); @@ -284,7 +291,8 @@ function assertReleaseWorkspace(actual) { function assertReleaseArtifacts(actual, intent) { if (JSON.parse(actual.packageSource).version !== intent.version) throw new Error("package.json version changed after release planning."); if (sha256(actual.notesSource) !== intent.release.notesDigest) throw new Error("Release notes changed after release planning."); - controlPublicationState(actual.refs, intent.control.intent.refs); + if (isLocalControl(intent)) assertLocalReleaseRefs(actual.refs, intent.control.refs); + else controlPublicationState(actual.refs, intent.control.intent.refs); } function assertTagTarget(target, intent, location, canonicalObject) { @@ -580,3 +588,17 @@ function sha256(value) { function safeError(error) { return (error instanceof Error ? error.message : String(error)).replace(/gho_[A-Za-z0-9_]+/g, "[REDACTED]").slice(0, 500); } + +function isLocalControl(intent) { + return intent.schemaVersion === "tabellio-release-operation/v0.2"; +} + +async function readLocalReleaseRefs(store, intent) { + return Promise.all(intent.control.refs.map(async ({ name }) => ({ name, localOid: await store.resolveRef(name) }))); +} + +function assertLocalReleaseRefs(actual, approved) { + for (const ref of approved) { + assertEqual(actual.find((entry) => entry.name === ref.name)?.localOid, ref.localOid, `Approved local control ref ${ref.name} changed.`); + } +} diff --git a/scripts/lib/runner-identity.mjs b/scripts/lib/runner-identity.mjs index 2828b485..7dcfacfe 100644 --- a/scripts/lib/runner-identity.mjs +++ b/scripts/lib/runner-identity.mjs @@ -65,7 +65,7 @@ async function readGitSourceIdentity(root, packageVersion, includeFingerprint) { } async function worktreeFingerprint(root, commit, status, flaggedPaths = []) { - const changed = await readGit(root, ["diff", "--name-only", "--no-renames", "-z", "HEAD", "--"]); + const changed = await readGit(root, ["-c", "diff.autoRefreshIndex=false", "diff", "--name-only", "--no-renames", "-z", "HEAD", "--"]); return fingerprintPaths(root, commit, status, changed.stdout, flaggedPaths); } diff --git a/scripts/lib/validation-checkpoints.mjs b/scripts/lib/validation-checkpoints.mjs new file mode 100644 index 00000000..6d2d1c0b --- /dev/null +++ b/scripts/lib/validation-checkpoints.mjs @@ -0,0 +1,53 @@ +import { EntireLedgerProvider } from "../providers/entire-ledger-provider.mjs"; +import { validateLedgerSnapshot } from "./ledger-provider.mjs"; +import { runGit } from "./git-process.mjs"; + +export async function captureValidationCheckpoints(repoPath, repositoryId, revision, ids) { + if (ids.some(id => !/^[0-9a-f]{12}$/.test(id))) throw new Error("Invalid Entire checkpoint trailer ID."); + const provider = await EntireLedgerProvider.open(repoPath); + const evidence = await provider.snapshot({ repositoryId, baseRevision: revision.mergeBase, headRevision: revision.headCommit }); + validateCheckpointEvidence(evidence, repositoryId, revision, ids); + await verifyCheckpointBindings(repoPath, evidence); + return evidence; +} + +export function validateCheckpointEvidence(evidence, repositoryId, revision, ids) { + validateLedgerSnapshot(evidence); + if (evidence.provider.id !== "entire" || evidence.repository.id !== repositoryId + || evidence.range.baseCommit !== revision.mergeBase || evidence.range.headCommit !== revision.headCommit) throw new Error("Checkpoint evidence scope mismatch."); + const actual = evidence.checkpoints.map(item => item.id).sort(); + if (!actual.length || JSON.stringify(actual) !== JSON.stringify([...ids].sort())) throw new Error("Checkpoint evidence IDs mismatch."); + evidence.checkpoints.forEach(validateCompleteCheckpoint); +} + +function validateCompleteCheckpoint(checkpoint) { + if (!/^[0-9a-f]{12}$/.test(checkpoint.id) || checkpoint.partial || !checkpoint.commits.length || !checkpoint.sessions.length) { + throw new Error("Checkpoint metadata is incomplete."); + } + if (checkpoint.sessions.some(session => !session.id || session.error)) throw new Error("Checkpoint metadata is incomplete."); +} + +// This verifies Git trailer associations, NOT independently authenticated capture. +// Entire v0.7.7 exports no commit binding; copying an existing ID is not detected. +export async function verifyCheckpointBindings(repoPath, evidence) { + const expected = await checkpointTrailerAssociations(repoPath, evidence.range); + if (expected.size !== evidence.checkpoints.length) throw new Error("Checkpoint evidence source IDs mismatch."); + for (const checkpoint of evidence.checkpoints) { + const commits = [...(expected.get(checkpoint.id) ?? [])].sort(); + if (JSON.stringify(commits) !== JSON.stringify([...checkpoint.commits].sort())) throw new Error("Checkpoint evidence commit trailer mismatch."); + } +} + +async function checkpointTrailerAssociations(repoPath, range) { + const result = await runGit({ cwd: repoPath, args: ["rev-list", `${range.baseCommit}..${range.headCommit}`, "--"] }); + const expected = new Map(); + for (const commit of result.stdout.trim().split(/\s+/).filter(Boolean)) { + const message = await runGit({ cwd: repoPath, args: ["show", "-s", "--format=%(trailers:key=Entire-Checkpoint,valueonly)", commit, "--"] }); + for (const id of message.stdout.split(/\s+/).filter(Boolean)) { + const commits = expected.get(id) ?? new Set(); + commits.add(commit); + expected.set(id, commits); + } + } + return expected; +} diff --git a/scripts/lib/validation-runner.mjs b/scripts/lib/validation-runner.mjs index e9e4e39d..b5662ac9 100644 --- a/scripts/lib/validation-runner.mjs +++ b/scripts/lib/validation-runner.mjs @@ -1,3 +1,4 @@ +import { captureValidationCheckpoints, validateCheckpointEvidence, verifyCheckpointBindings } from "./validation-checkpoints.mjs"; import { spawn } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat } from "node:fs/promises"; @@ -18,6 +19,7 @@ const VALIDATION_RESULT_SCHEMA_VERSION_V4 = "tabellio-validation-result/v0.4"; const VALIDATOR_EVIDENCE_SCHEMA_VERSION = "tabellio-validator-evidence/v0.1"; const VALIDATOR_TYPES = ["static", "schema", "semantic", "workflow", "visual", "operational", "security"]; const SEMANTIC_VERSION = /^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; +const VALIDATION_RESULT_SCHEMA_VERSION_V5 = "tabellio-validation-result/v0.5"; const MAX_OUTPUT_TAIL_BYTES = 16 * 1024; const MAX_EVIDENCE_BYTES = 1024 * 1024; @@ -57,6 +59,8 @@ export class ValidationRunner { if (manifest.requireEntireCheckpoint && checkpoints.length === 0) { throw new Error(`Checkpoint range ${checkpointRevision.mergeBase}..${checkpointRevision.headCommit} has no Entire checkpoint.`); } + const checkpointEvidence = manifest.requireEntireCheckpoint + ? await captureValidationCheckpoints(this.store.repoPath, repositoryId, checkpointRevision, checkpoints) : null; const initialRunnerState = await this.runnerState(); const runnerIdentity = initialRunnerState.identity; @@ -115,6 +119,7 @@ export class ValidationRunner { runnerId, runnerIdentity, checkpoints, + checkpointEvidence, startedAt, completedAt, }); @@ -268,6 +273,7 @@ function buildValidationResult({ runnerId, runnerIdentity, checkpoints, + checkpointEvidence, startedAt, completedAt, }) { @@ -275,7 +281,9 @@ function buildValidationResult({ const decision = typed ? validationDecision(execution.validators) : null; const requiredFailed = execution.commands.some((command, index) => definitions[index].required && command.status !== "passed"); const result = { - schemaVersion: typed ? VALIDATION_RESULT_SCHEMA_VERSION_V4 : VALIDATION_RESULT_SCHEMA_VERSION_V2, + schemaVersion: VALIDATION_RESULT_SCHEMA_VERSION_V5, + kind: typed ? "product" : "commands", + checkpointEvidence, runId, repository: { id: repositoryId }, revision, @@ -285,11 +293,11 @@ function buildValidationResult({ manifestPath, manifestDigest: digestObject(manifest), }, - runner: typed ? { + runner: { id: runnerId, runtime: `node-${process.version}`, ...runnerIdentity, - } : { id: runnerId, runtime: `node-${process.version}` }, + }, status: decision?.status ?? (requiredFailed ? "failed" : "passed"), checkpoints, commands: execution.commands, @@ -326,13 +334,26 @@ export async function latestValidationResult(ledger, commit, repositoryId = null const record = await ledger.read(path); if (!record.value) continue; if (!validationResultMatches(record.value, { commit, repositoryId, manifestPath, path })) continue; - if (record.value.schemaVersion !== VALIDATION_RESULT_SCHEMA_VERSION_V4 + if (![VALIDATION_RESULT_SCHEMA_VERSION_V4, VALIDATION_RESULT_SCHEMA_VERSION_V5].includes(record.value.schemaVersion) && await requiresRunnerIdentity(ledger, commit, record.value.suite.manifestPath, manifests)) continue; + if (!await matchesCheckpointContract(ledger, commit, record.value)) continue; latest = newerValidationResult(latest, record.value); } return latest; } +async function matchesCheckpointContract(ledger, commit, result) { + const source = await runGit({ args: ["show", `${commit}:${result.suite.manifestPath}`], cwd: ledger.repoPath }); + const manifest = validateValidationManifest(JSON.parse(source.stdout)); + if (result.suite.manifestDigest !== digestObject(manifest)) return false; + if (!manifest.requireEntireCheckpoint) return true; + if (!result.checkpointEvidence) return false; + try { + await verifyCheckpointBindings(ledger.repoPath, result.checkpointEvidence); + return true; + } catch { return false; } +} + async function requiresRunnerIdentity(ledger, commit, manifestPath, manifests) { if (!manifests.has(manifestPath)) { const source = await runGit({ args: ["show", `${commit}:${manifestPath}`], cwd: ledger.repoPath }); @@ -424,10 +445,11 @@ export function validateValidationResult(value) { VALIDATION_RESULT_SCHEMA_VERSION_V2, VALIDATION_RESULT_SCHEMA_VERSION_V3, VALIDATION_RESULT_SCHEMA_VERSION_V4, + VALIDATION_RESULT_SCHEMA_VERSION_V5, ], "validation result.schemaVersion", ); - exactKeys(value, validationResultKeys(value.schemaVersion), "validation result"); + exactKeys(value, validationResultKeys(value), "validation result"); requiredString(value.runId, "validation result.runId"); object(value.repository, "validation result.repository"); exactKeys(value.repository, ["id"], "validation result.repository"); @@ -442,10 +464,14 @@ export function validateValidationResult(value) { validateResultRunner(value.runner, value.schemaVersion); member( value.status, - isTypedValidationResult(value.schemaVersion) ? ["passed", "failed", "blocked"] : ["passed", "failed"], + isTypedValidationResult(value) ? ["passed", "failed", "blocked"] : ["passed", "failed"], "validation result.status", ); stringArray(value.checkpoints, "validation result.checkpoints"); + if (value.schemaVersion === VALIDATION_RESULT_SCHEMA_VERSION_V5) { + member(value.kind, ["commands", "product"], "validation result.kind"); + if (value.checkpointEvidence !== null) validateCheckpointEvidence(value.checkpointEvidence, value.repository.id, value.checkpointRevision, value.checkpoints); + } if (!Array.isArray(value.commands) || value.commands.length === 0) throw new Error("validation result.commands must be a non-empty array."); value.commands.forEach((command, index) => validateCommandResult(command, `validation result.commands[${index}]`)); date(value.startedAt, "validation result.startedAt"); @@ -455,7 +481,7 @@ export function validateValidationResult(value) { equals(value.integrity.algorithm, "sha256", "validation result.integrity.algorithm"); sha256(value.integrity.digest, "validation result.integrity.digest"); if (validationResultDigest(value) !== value.integrity.digest) throw new Error("validation result integrity digest does not match."); - if (isTypedValidationResult(value.schemaVersion)) { + if (isTypedValidationResult(value)) { validateTypedValidationResult(value); return value; } @@ -464,12 +490,14 @@ export function validateValidationResult(value) { return value; } -function validationResultKeys(schemaVersion) { +function validationResultKeys(value) { + const { schemaVersion } = value; const keys = ["schemaVersion", "runId", "repository", "revision", "suite", "runner", "status", "checkpoints", "commands", "startedAt", "completedAt", "integrity"]; - if (isTypedValidationResult(schemaVersion)) { + if (schemaVersion === VALIDATION_RESULT_SCHEMA_VERSION_V5) keys.push("kind", "checkpointEvidence"); + if (isTypedValidationResult(value)) { return [...keys, "checkpointRevision", "acceptance", "validators", "decision"]; } - return schemaVersion === VALIDATION_RESULT_SCHEMA_VERSION_V2 ? [...keys, "checkpointRevision"] : keys; + return [VALIDATION_RESULT_SCHEMA_VERSION_V2, VALIDATION_RESULT_SCHEMA_VERSION_V5].includes(schemaVersion) ? [...keys, "checkpointRevision"] : keys; } function validateCheckpointRevision(value) { @@ -477,24 +505,25 @@ function validateCheckpointRevision(value) { VALIDATION_RESULT_SCHEMA_VERSION_V2, VALIDATION_RESULT_SCHEMA_VERSION_V3, VALIDATION_RESULT_SCHEMA_VERSION_V4, + VALIDATION_RESULT_SCHEMA_VERSION_V5, ].includes(value.schemaVersion)) { validateRevision(value.checkpointRevision, "validation result.checkpointRevision"); } } -function isTypedValidationResult(schemaVersion) { - return [VALIDATION_RESULT_SCHEMA_VERSION_V3, VALIDATION_RESULT_SCHEMA_VERSION_V4].includes(schemaVersion); +function isTypedValidationResult({ schemaVersion, kind }) { + return [VALIDATION_RESULT_SCHEMA_VERSION_V3, VALIDATION_RESULT_SCHEMA_VERSION_V4].includes(schemaVersion) || (schemaVersion === VALIDATION_RESULT_SCHEMA_VERSION_V5 && kind === "product"); } function validateResultRunner(value, schemaVersion) { object(value, "validation result.runner"); - const runnerKeys = schemaVersion === VALIDATION_RESULT_SCHEMA_VERSION_V4 + const runnerKeys = [VALIDATION_RESULT_SCHEMA_VERSION_V4, VALIDATION_RESULT_SCHEMA_VERSION_V5].includes(schemaVersion) ? ["id", "runtime", "packageName", "packageVersion", "sourceCommit", "sourceDirty", "releaseTag"] : ["id", "runtime"]; exactKeys(value, runnerKeys, "validation result.runner"); requiredString(value.id, "validation result.runner.id"); requiredString(value.runtime, "validation result.runner.runtime"); - if (schemaVersion === VALIDATION_RESULT_SCHEMA_VERSION_V4) validateRunnerIdentity(value); + if ([VALIDATION_RESULT_SCHEMA_VERSION_V4, VALIDATION_RESULT_SCHEMA_VERSION_V5].includes(schemaVersion)) validateRunnerIdentity(value); } function validateRunnerIdentity(value) { @@ -1034,7 +1063,7 @@ async function resolveRevision(store, base, head) { async function checkpointIds(cwd, baseCommit, headCommit) { const result = await runGit({ - args: ["log", "--format=%(trailers:key=Entire-Checkpoint,valueonly)", "--no-merges", `${baseCommit}..${headCommit}`], + args: ["log", "--format=%(trailers:key=Entire-Checkpoint,valueonly)", `${baseCommit}..${headCommit}`], cwd, }); return [...new Set(result.stdout.split(/\r?\n/).map((value) => value.trim()).filter(Boolean))].sort(); diff --git a/scripts/providers/github-status-publisher.mjs b/scripts/providers/github-status-publisher.mjs index 551afbe8..940f30ee 100644 --- a/scripts/providers/github-status-publisher.mjs +++ b/scripts/providers/github-status-publisher.mjs @@ -156,7 +156,7 @@ function positiveInteger(value, path) { } function requiredInteger(value, path) { - if (!Number.isInteger(value)) throw new TypeError(`${path} must be an integer.`); + if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError(`${path} must be a positive safe integer.`); return value; } diff --git a/scripts/tabellio-provenance.mjs b/scripts/tabellio-provenance.mjs index e36b68a3..f4bcd3ac 100755 --- a/scripts/tabellio-provenance.mjs +++ b/scripts/tabellio-provenance.mjs @@ -1,5 +1,7 @@ #!/usr/bin/env node import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { platformControlMode, validatePlatformConfig } from "./lib/platform-config.mjs"; import { parseCommandOptions, requireOptions, writeJsonOutput } from "./lib/cli-options.mjs"; import { LocalProvenanceStore } from "./lib/local-provenance-store.mjs"; import { assembleLineage, buildReviewPacket, captureCandidate, verifyLineage } from "./lib/provenance-ledger.mjs"; @@ -32,7 +34,7 @@ async function main() { replay: ["input", "databaseUrl", "out"], show: [...query, "out"], review: [...query, "repo", "base", "head", "now", "reportUrl", "securityInput", "policyDigest", "out"], - "review-intent": [...query, "repo", "base", "head", "now", "reportUrl", "out"], + "review-intent": [...query, "repo", "base", "head", "now", "reportUrl", "publicationStore", "reservationRemote", "out"], "publish-review": [...query, "repo", "base", "head", "intentInput", "approvalInput", "out"], packet: [...query, "repo", "base", "head", "now", "out"], "import-security": [...query, "input", "policyDigest", "repo", "base", "head", "now", "out"], @@ -75,7 +77,7 @@ async function main() { const candidate = await captureCandidate(options); const evaluation = { candidate, now: options.now ?? new Date().toISOString() }; if (options.command === "review-intent") { - await writeJsonOutput(createProvenanceStatusIntent(lineage, { ...evaluation, reportUrl: options.reportUrl ?? null }), options.out); + await writeJsonOutput(createProvenanceStatusIntent(lineage, { ...evaluation, reportUrl: options.reportUrl ?? null, publicationStore: options.publicationStore, reservationRemote: await intentReservationRemote(options) }), options.out); return; } if (options.command === "publish-review") return publishReviewCommand(options, lineage); @@ -144,3 +146,13 @@ async function importSources(options) { await writeJsonOutput({ status, ...result }, options.out); if (status === "blocked") process.exitCode = 1; } + +async function intentReservationRemote(options) { + if (options.reservationRemote !== undefined) return options.reservationRemote; + if (options.publicationStore !== undefined || process.env.TABELLIO_PUBLICATION_STORE !== undefined) return undefined; + let source; + try { source = await readFile(resolve(options.repo, "tabellio.platform.json"), "utf8"); } + catch (error) { if (error.code === "ENOENT") return undefined; throw error; } + const platform = validatePlatformConfig(JSON.parse(source)); + return platformControlMode(platform) === "remote" ? "control" : undefined; +} diff --git a/scripts/tabellio-release.mjs b/scripts/tabellio-release.mjs index 41dd359c..2ad184fc 100644 --- a/scripts/tabellio-release.mjs +++ b/scripts/tabellio-release.mjs @@ -40,7 +40,7 @@ async function plan(options) { version, notes, title = `Tabellio v${version}`, - controlRemote = "control", + controlRemote = null, manifest = "tabellio.validation.json", runnerId = "tabellio-release", tokenFile, diff --git a/scripts/validation-public-summary.mjs b/scripts/validation-public-summary.mjs new file mode 100644 index 00000000..35e9ba1c --- /dev/null +++ b/scripts/validation-public-summary.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env node +import { readFile, writeFile } from 'node:fs/promises'; +// Emit only public manifest IDs and closed status/reason values. Never forward private output. +let status = 'blocked'; +const validators = []; +try { + const value = JSON.parse(await readFile(process.argv[2], 'utf8')); + const manifest = JSON.parse(await readFile(new URL('../tabellio.validation.json', import.meta.url), 'utf8')); + if (value.ok === true && ['passed', 'failed', 'blocked'].includes(value.result?.status)) { + status = value.result.status; + for (const expected of manifest.validators) { + const found = value.result.validators?.find(item => item?.id === expected.id); + if (!found || !['passed', 'failed', 'blocked', 'skipped'].includes(found.status)) continue; + const reasons = ['command_failed', 'validator_command_failed', 'command_error', 'command_timed_out', 'evidence_missing', 'evidence_invalid', 'evidence_reported_failed', 'evidence_reported_blocked', 'fail_fast']; + const command = value.result.commands?.find(item => item?.id === expected.id); + const failCounts = typeof command?.stdout?.tail === 'string' ? [...command.stdout.tail.matchAll(/^# fail ([0-9]{1,6})$/gm)] : []; + validators.push({ id: expected.id, status: found.status, + reasons: Array.isArray(found.reasons) ? found.reasons.filter(reason => reasons.includes(reason)) : [], + testFailures: failCounts.length ? Number(failCounts.at(-1)[1]) : null }); + } + } +} catch {} +await writeFile(process.argv[3], JSON.stringify({ status, validators }) + '\n', { mode: 0o600 }); diff --git a/tabellio.platform.json b/tabellio.platform.json index cf06477c..b97750ce 100644 --- a/tabellio.platform.json +++ b/tabellio.platform.json @@ -1,5 +1,5 @@ { - "schemaVersion": "tabellio-platform/v0.3", + "schemaVersion": "tabellio-platform/v0.4", "codeStorage": { "provider": "github", "remoteName": "origin", @@ -12,9 +12,9 @@ }, "workflow": { "stackManager": "git-spice", - "controlState": "external", - "controlProvider": "github", - "controlRemoteName": "control", + "controlState": "local", + "controlProvider": "local", + "controlRemoteName": null, "publishControlRefsToCodeStorage": false, "controlRefs": [ "refs/tabellio/reviews", @@ -24,18 +24,18 @@ }, "ledger": { "provider": "entire", - "storage": "external", + "storage": "local", "checkpointRef": "refs/heads/entire/checkpoints/v1" }, "validation": { "runner": "tabellio-validate", "manifest": "tabellio.validation.json", - "storage": "external", + "storage": "local", "resultRef": "refs/tabellio/validations" }, "reviews": { "provider": "tabellio", - "storage": "external", + "storage": "local", "stateRef": "refs/tabellio/reviews" } } diff --git a/tests/buildkite-high-gates.test.mjs b/tests/buildkite-high-gates.test.mjs index 9b9dc101..ef400b62 100644 --- a/tests/buildkite-high-gates.test.mjs +++ b/tests/buildkite-high-gates.test.mjs @@ -65,6 +65,14 @@ test("Buildkite adds bounded pull-request quality gates without CI cutover", asy assert.doesNotMatch(pipeline, /build\.env\("BUILDKITE_PULL_REQUEST"\)/); assert.doesNotMatch(pipeline, /^\s+if:/m); + // Public provider artifacts contain bounded status, never private native evidence. + assert.match(pipeline, /tabellio-validation-result\.json/); + assert.doesNotMatch(pipeline, /validation-ref\.(?:bundle|sha)/); + assert.doesNotMatch(productValidation, /git bundle create .*validation-ref\.bundle/); + assert.match(productValidation, /node scripts\/validation-public-summary\.mjs/); + assert.match(productValidation, /tabellio-validation-private\.json" 2> "\$temporary_dir\/validation-stderr\.log"/); + assert.match(productValidation, /trap 'rm -rf "\$temporary_dir"' EXIT/); + assert.match(productValidation, /bash \.buildkite\/scripts\/checkpoint-evidence\.sh/); assert.doesNotMatch(productValidation, /git show -s --format=%s/); assertMatches(productValidation, [ /BUILDKITE_COMMIT:-HEAD/, @@ -75,8 +83,6 @@ test("Buildkite adds bounded pull-request quality gates without CI cutover", asy /decision":"not_required"/, /exit 0/, /test "\$\(git rev-parse HEAD\^\{commit\}\)"/, - /git bundle create .*validation-ref\.bundle/, - /git bundle verify .*validation-ref\.bundle/, /commits\/\$\{candidate\}\/pulls/, /scripts\/resolve-merged-checkpoint\.mjs/, /Merged checkpoint resolution failed/, @@ -118,9 +124,26 @@ function assertMatches(value, patterns) { test("GitHub merged-head validation remains during Buildkite migration", async () => { const workflow = await repositoryFile(".github/workflows/product-validation.yml"); - assert.match(workflow, /commits\/\$MERGED_COMMIT\/pulls/); - assert.match(workflow, /pull-requests: read/); - assert.match(workflow, /scripts\/resolve-merged-checkpoint\.mjs/); + assertMatches(workflow, [ + /commits\/\$MERGED_COMMIT\/pulls/, + /pull-requests: read/, + /scripts\/resolve-merged-checkpoint\.mjs/, + /node scripts\/validation-public-summary\.mjs/, + /tabellio-validation-result\.json/, + /tabellio-validation-private\.json" 2> "\$private_dir\/validation-stderr\.log"/, + /trap 'rm -rf "\$private_dir"' EXIT/, + /node scripts\/ci-checkpoint-evidence\.mjs load/, + /node scripts\/ci-checkpoint-evidence\.mjs cleanup/, + /TABELLIO_CHECKPOINT_PROOF_1: \$\{\{ secrets\.TABELLIO_CHECKPOINT_PROOF_1 \}\}/, + /TABELLIO_CHECKPOINT_PROOF_2: \$\{\{ secrets\.TABELLIO_CHECKPOINT_PROOF_2 \}\}/, + ]); + const loader = await repositoryFile("scripts/ci-checkpoint-evidence.mjs"); + assert.match(loader, /execFileSync\('bash', \['\.buildkite\/scripts\/checkpoint-evidence\.sh'\]/); + assert.match(loader, /envelope\.candidate !== git\('rev-parse', 'HEAD'\)/); + assert.match(loader, /envelope\.base !== git\('merge-base', 'origin\/main', 'HEAD'\)/); + assert.match(loader, /createHash\('sha256'\)\.update\(bytes\)\.digest\('hex'\) !== envelope\.sha256/); + assert.doesNotMatch(workflow, /refs\/tabellio\/validations\//); + assert.doesNotMatch(workflow, /validation-ref\.bundle/); }); test("Git capability gate accepts the supported range and rejects unsafe bounds", async () => { diff --git a/tests/checkpoint-evidence.test.mjs b/tests/checkpoint-evidence.test.mjs new file mode 100644 index 00000000..924b43ed --- /dev/null +++ b/tests/checkpoint-evidence.test.mjs @@ -0,0 +1,139 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, rm, writeFile, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +const helper = resolve('.buildkite/scripts/checkpoint-evidence.sh'); +const summary = resolve('scripts/validation-public-summary.mjs'); +const run = (cwd, command, args, env = {}) => spawnSync(command, args, { cwd, env: { ...process.env, TABELLIO_CHECKPOINT_BUNDLE: '', ...env }, encoding: 'utf8' }); +const git = (cwd, ...args) => { const r = run(cwd, 'git', ['-c', 'core.hooksPath=/dev/null', ...args]); assert.equal(r.status, 0, r.stderr); return r.stdout.trim(); }; +test('customer checkpoint bundle import blocks missing/invalid evidence and preserves native identity', async () => { + const root = await mkdtemp(join(tmpdir(), 'tabellio-checkpoint-import-')); + try { + const source = join(root, 'source'); const target = join(root, 'target'); + git(root, 'init', source); git(root, 'init', target); + git(source, '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '--allow-empty', '-m', 'Fixture object, not provenance proof'); + const head = git(source, 'rev-parse', 'HEAD'); + git(source, 'branch', 'entire/checkpoints/v1'); + const bundle = join(root, 'checkpoints.bundle'); git(source, 'bundle', 'create', bundle, 'refs/heads/entire/checkpoints/v1'); + const missing = run(target, 'bash', [helper]); assert.equal(missing.status, 1); assert.match(missing.stderr, /trusted worker/); + const invalid = join(root, 'invalid.bundle'); await writeFile(invalid, 'private-secret-marker'); + const bad = run(target, 'bash', [helper], { TABELLIO_CHECKPOINT_BUNDLE: invalid }); assert.equal(bad.status, 1); assert.ok(!bad.stderr.includes('private-secret-marker')); + const wrong = join(root, 'wrong.bundle'); git(source, 'bundle', 'create', wrong, 'HEAD'); + assert.equal(run(target, 'bash', [helper], { TABELLIO_CHECKPOINT_BUNDLE: wrong }).status, 1); + assert.equal(run(target, 'bash', [helper], { TABELLIO_CHECKPOINT_BUNDLE: bundle }).status, 0); + assert.equal(git(target, 'rev-parse', 'refs/heads/entire/checkpoints/v1'), head); + assert.equal(run(target, 'bash', [helper]).status, 0); + assert.equal(run(target, 'bash', [helper], { TABELLIO_CHECKPOINT_BUNDLE: bundle }).status, 0); + // Divergent local native history is never overwritten by an import. + git(target, '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '--allow-empty', '-m', 'Different fixture root'); + const different = git(target, 'rev-parse', 'HEAD'); git(target, 'update-ref', 'refs/heads/entire/checkpoints/v1', different); + assert.equal(run(target, 'bash', [helper], { TABELLIO_CHECKPOINT_BUNDLE: bundle }).status, 1); + assert.equal(git(target, 'rev-parse', 'refs/heads/entire/checkpoints/v1'), different); + } finally { await rm(root, { recursive: true, force: true }); } +}); +test('public CI summary never forwards private metadata or error content', async () => { + const root = await mkdtemp(join(tmpdir(), 'tabellio-public-summary-')); + try { + const input = join(root, 'private.json'); const output = join(root, 'public.json'); + for (const value of [{ ok: true, result: { status: 'passed', session: 'private-secret-marker' } }, { ok: false, error: 'private-secret-marker' }]) { + await writeFile(input, JSON.stringify(value)); + assert.equal(run(root, process.execPath, [summary, input, output]).status, 0); + const result = JSON.parse(await readFile(output, 'utf8')); + assert.deepEqual(Object.keys(result), ['status', 'validators']); assert.deepEqual(result.validators, []); assert.ok(!JSON.stringify(result).includes('private-secret-marker')); + assert.equal(result.status, value.ok ? 'passed' : 'blocked'); + } + await writeFile(input, JSON.stringify({ ok: true, result: { status: 'failed', validators: [ + { id: 'control-plane-static', status: 'failed', reasons: ['command_failed', 'private-secret-marker'] }, + { id: 'private-secret-marker', status: 'failed', reasons: [] }, + { id: 'control-plane-security', status: 'private-secret-marker', reasons: [] } + ], commands: [{ id: 'control-plane-static', stdout: { tail: 'not ok 1 private-secret-marker\n# fail 1\n', bytes: 100, digest: 'private-secret-marker', truncated: false }, stderr: 'private-secret-marker' }] } })); + assert.equal(run(root, process.execPath, [summary, input, output]).status, 0); + assert.deepEqual(JSON.parse(await readFile(output, 'utf8')), { status: 'failed', validators: [{ id: 'control-plane-static', status: 'failed', reasons: ['command_failed'], testFailures: 1 }] }); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test('filtered native bundle preserves original identity and excludes transcript and prompt blobs', async () => { + const root = await mkdtemp(join(tmpdir(), 'tabellio-metadata-only-')); + try { + const source = join(root, 'source'); const target = join(root, 'target'); + git(root, 'init', source); git(root, 'init', target); + // Synthetic transport fixture only: this does not establish live provenance. + await writeFile(join(source, 'metadata.json'), '{"fixture":"metadata"}'); + await writeFile(join(source, 'full.jsonl'), 'private-transcript-marker'); + await writeFile(join(source, 'prompt.txt'), 'private-prompt-marker'); + git(source, 'add', '.'); + git(source, '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-m', 'Synthetic filtered transport fixture'); + const head = git(source, 'rev-parse', 'HEAD'); + git(source, 'branch', 'entire/checkpoints/v1'); + const patterns = join(root, 'patterns'); await writeFile(patterns, '/metadata.json\n'); + const filter = git(source, 'hash-object', '-w', patterns); + const bundle = join(root, 'metadata.bundle'); + git(source, 'bundle', 'create', bundle, `--filter=sparse:oid=${filter}`, 'refs/heads/entire/checkpoints/v1'); + await writeFile(join(target, 'candidate.mjs'), 'export const fixture = true;\n'); + git(target, 'add', '.'); + git(target, '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-m', 'Public candidate fixture'); + assert.equal(run(target, 'bash', [helper], { TABELLIO_CHECKPOINT_BUNDLE: bundle }).status, 0); + assert.equal(git(target, 'rev-parse', 'refs/heads/entire/checkpoints/v1'), head); + assert.equal(git(target, 'show', `${head}:metadata.json`), '{"fixture":"metadata"}'); + const probe = run(target, 'bash', [resolve('.buildkite/scripts/verify-git-toolchain.sh')], { TABELLIO_GIT_EVIDENCE_PATH: join(root, 'capability.json') }); + assert.equal(probe.status, 0, 'The candidate Git capability probe must not traverse excluded private checkpoint blobs.'); + + for (const path of ['full.jsonl', 'prompt.txt']) { + const oid = git(source, 'rev-parse', `${head}:${path}`); + assert.notEqual(run(target, 'git', ['cat-file', '-e', oid]).status, 0); + } + assert.equal(run(target, 'bash', [helper], { TABELLIO_CHECKPOINT_BUNDLE: bundle }).status, 0); + // Existing divergent native history survives a filtered import unchanged. + git(target, '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '--allow-empty', '-m', 'Separate fixture history'); + const divergent = git(target, 'rev-parse', 'HEAD'); + git(target, 'update-ref', 'refs/heads/entire/checkpoints/v1', divergent); + assert.equal(run(target, 'bash', [helper], { TABELLIO_CHECKPOINT_BUNDLE: bundle }).status, 1); + assert.equal(git(target, 'rev-parse', 'refs/heads/entire/checkpoints/v1'), divergent); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test('private runner envelope binds candidate and digest, rejects leaks, and removes only imported objects', async () => { + const root = await mkdtemp(join(tmpdir(), 'tabellio-private-proof-')); + try { + const source = join(root, 'source'); const target = join(root, 'target'); const runner = join(root, 'runner'); + git(root, 'init', source); git(root, 'init', target); await mkdir(runner); + await writeFile(join(source, 'metadata.json'), '{"fixture":"transport metadata, not provenance"}'); + await writeFile(join(source, 'prompt.txt'), 'private-prompt-marker'); + await writeFile(join(source, 'full.jsonl'), 'private-transcript-marker'); + git(source, 'add', '.'); git(source, '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-m', 'Synthetic transport fixture'); + const nativeTip = git(source, 'rev-parse', 'HEAD'); git(source, 'branch', 'entire/checkpoints/v1'); + const patterns = join(root, 'patterns'); await writeFile(patterns, '/metadata.json\n'); + const oid = git(source, 'hash-object', '-w', patterns); const bundle = join(root, 'native.bundle'); + git(source, 'bundle', 'create', bundle, `--filter=sparse:oid=${oid}`, 'refs/heads/entire/checkpoints/v1'); + await mkdir(join(target, '.buildkite/scripts'), { recursive: true }); await writeFile(join(target, '.buildkite/scripts/checkpoint-evidence.sh'), await readFile(helper)); + git(target, 'add', '.'); git(target, '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-m', 'Fixture base'); + const base = git(target, 'rev-parse', 'HEAD'); git(target, 'update-ref', 'refs/remotes/origin/main', base); + git(target, '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '--allow-empty', '-m', 'Fixture candidate'); + const candidate = git(target, 'rev-parse', 'HEAD'); const bytes = await readFile(bundle); + const { createHash } = await import('node:crypto'); + const envelope = { schemaVersion: 'tabellio-private-checkpoint-proof/v1', repositoryId: 'github.com/Fixture/Transport', candidate, base, nativeTip, sha256: createHash('sha256').update(bytes).digest('hex'), bundle: bytes.toString('base64') }; + const loader = resolve('scripts/ci-checkpoint-evidence.mjs'); + const invoke = (command, value = envelope) => { + const text = JSON.stringify(value); const middle = Math.floor(text.length / 2); + return run(target, process.execPath, [loader, command], { GITHUB_ACTIONS: 'true', GITHUB_WORKSPACE: target, RUNNER_TEMP: runner, GITHUB_REPOSITORY: 'Fixture/Transport', TABELLIO_CHECKPOINT_PROOF_1: text.slice(0, middle), TABELLIO_CHECKPOINT_PROOF_2: text.slice(middle) }); + }; + for (const bad of [{ ...envelope, candidate: base }, { ...envelope, sha256: '0'.repeat(64) }, { ...envelope, repositoryId: 'wrong/private-secret-marker' }]) { + const r = invoke('load', bad); assert.equal(r.status, 1); assert.ok(!r.stderr.includes('private-secret-marker')); + } + const loaded = invoke('load'); assert.equal(loaded.status, 0, loaded.stderr); + assert.equal(git(target, 'rev-parse', 'refs/heads/entire/checkpoints/v1'), nativeTip); + for (const path of ['prompt.txt', 'full.jsonl']) assert.notEqual(run(target, 'git', ['cat-file', '-e', git(source, 'rev-parse', `${nativeTip}:${path}`)]).status, 0); + const cleaned = invoke('cleanup'); assert.equal(cleaned.status, 0, cleaned.stderr); + assert.notEqual(run(target, 'git', ['rev-parse', '--verify', 'refs/heads/entire/checkpoints/v1']).status, 0); + assert.notEqual(run(target, 'git', ['cat-file', '-e', nativeTip]).status, 0); + assert.equal(git(target, 'rev-parse', 'HEAD'), candidate); git(target, 'cat-file', '-e', base); + assert.ok(!(loaded.stdout + loaded.stderr + cleaned.stdout + cleaned.stderr).includes('private-prompt-marker')); + assert.ok(!(loaded.stdout + cleaned.stdout).includes(nativeTip)); + for (const ref of ['refs/heads/entire/checkpoints/v1', 'refs/tabellio/validations']) { + git(target, 'update-ref', ref, base); assert.equal(invoke('load').status, 1); + assert.equal(git(target, 'rev-parse', ref), base); git(target, 'update-ref', '-d', ref); + } + } finally { await rm(root, { recursive: true, force: true }); } +}); diff --git a/tests/context-and-evidence.test.mjs b/tests/context-and-evidence.test.mjs index 2be9c4ae..0f0af7d9 100644 --- a/tests/context-and-evidence.test.mjs +++ b/tests/context-and-evidence.test.mjs @@ -290,6 +290,7 @@ test("stable schema identifiers keep external references and released contracts validationV2, validationV3, validationV4, + validationV5, ] = await Promise.all([ readFile(`${projectRoot}/schemas/evidence-envelope.schema.json`, "utf8").then(JSON.parse), readFile(`${projectRoot}/schemas/external-action-policy.schema.json`, "utf8").then(JSON.parse), @@ -303,15 +304,27 @@ test("stable schema identifiers keep external references and released contracts readFile(`${projectRoot}/schemas/validation-result.v0.2.schema.json`, "utf8").then(JSON.parse), readFile(`${projectRoot}/schemas/validation-result.v0.3.schema.json`, "utf8").then(JSON.parse), readFile(`${projectRoot}/schemas/validation-result.v0.4.schema.json`, "utf8").then(JSON.parse), + readFile(`${projectRoot}/schemas/validation-result.v0.5.schema.json`, "utf8").then(JSON.parse), ]); assert.equal(evidenceSchema.properties.externalActionPolicy.$ref, policySchema.$id); - assert.equal(releaseSchema.properties.control.properties.intent.$ref, controlSchema.$id); + assert.equal(releaseSchema.$id, "urn:tabellio:schema:release-operation:v0.1"); + assert.deepEqual(releaseSchema.properties.schemaVersion.enum, ["tabellio-release-operation/v0.1", "tabellio-release-operation/v0.2"]); + assert.deepEqual(releaseSchema.properties.control.oneOf.map(item => item.$ref), ["#/$defs/remoteControl", "#/$defs/localControl"]); + assert.equal(releaseSchema.$defs.remoteControl.properties.intent.$ref, controlSchema.$id); + assert.equal(releaseSchema.$defs.localControl.properties.mode.const, "local"); + assert.deepEqual(releaseSchema.$defs.localControl.required, ["mode", "refs"]); + assert.equal(releaseSchema.$defs.localControl.properties.refs.items.properties.localOid.$ref, "#/$defs/oid"); + assert.equal(releaseSchema.allOf[0].if.properties.schemaVersion.const, "tabellio-release-operation/v0.2"); + assert.equal(releaseSchema.allOf[0].then.properties.control.$ref, "#/$defs/localControl"); + assert.equal(releaseSchema.allOf[0].else.properties.control.$ref, "#/$defs/remoteControl"); assert.equal(reviewAlias.$ref, "review-cycle.v0.3.schema.json"); assert.equal(reviewV2.$id, "urn:tabellio:schema:review-cycle:v0.2"); assert.equal(reviewV3.$id, "urn:tabellio:schema:review-cycle:v0.3"); assert.equal(reviewV2.$defs.event.properties.type.enum.includes("ready"), false); assert.equal(reviewV3.$defs.event.properties.type.enum.includes("ready"), true); - assert.equal(validationAlias.$ref, "validation-result.v0.2.schema.json"); + assert.deepEqual(validationAlias.oneOf.map(item => item.$ref), [1, 2, 3, 4, 5].map(version => `validation-result.v0.${version}.schema.json`)); + assert.equal(validationV5.$id, "urn:tabellio:schema:validation-result:v0.5"); + assert.deepEqual(validationV5.oneOf.map(item => item.properties.kind.const), ["commands", "product"]); assert.equal(validationV1.$id, "urn:tabellio:schema:validation-result:v0.1"); assert.equal(validationV2.$id, "urn:tabellio:schema:validation-result:v0.2"); assert.equal(Object.hasOwn(validationV1.properties, "checkpointRevision"), false); diff --git a/tests/demo-provenance.test.mjs b/tests/demo-provenance.test.mjs index d020cff5..f91330df 100644 --- a/tests/demo-provenance.test.mjs +++ b/tests/demo-provenance.test.mjs @@ -6,6 +6,7 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import test from "node:test"; +import { provenanceDemoFailure } from "../scripts/lib/provenance-demo-failure.mjs"; const execute = promisify(execFile); const postgresAvailable = spawnSync("initdb", ["--version"]).status === 0; @@ -25,3 +26,35 @@ test("sample PostgreSQL demo works under a long isolated temporary path", { skip assert.equal(receipt.status, "passed"); assert.equal(receipt.checks.postgresServerRestart, "passed"); }); + + +test("demo setup failures are actionable without exposing raw logs", () => { + const cases = [ + [{ code: "ENOENT", message: "private diagnostic" }, "", "required_tool_missing"], + [{ code: 1 }, 'could not create Unix socket for address "/private/path": Operation not permitted', "socket_permission_denied"], + [{ code: 1 }, 'could not create Unix socket for address "/private/path": Permission denied', "socket_permission_denied"], + [{ code: 1 }, 'Unix-domain socket path "/private/path" is too long', "socket_path_too_long"], + [null, "private unrelated diagnostic", "local_command_failed"], + ]; + for (const [error, log, expected] of cases) { + const result = provenanceDemoFailure(error, log); + assert.equal(result.failureClass, expected); + assert.ok(!JSON.stringify(result).includes("private")); + assert.deepEqual(Object.keys(result).sort(), ["failureClass", "reason"]); + } +}); + +test("demo missing prerequisites produce blocked evidence and successful cleanup", async () => { + const script = fileURLToPath(new URL("../scripts/demo-provenance.mjs", import.meta.url)); + await assert.rejects(execute(process.execPath, [script], { + env: { ...process.env, PATH: "" }, timeout: 15000, + }), (error) => { + assert.equal(error.code, 1); + const receipt = JSON.parse(error.stdout); + assert.equal(receipt.status, "blocked"); + assert.equal(receipt.failureClass, "required_tool_missing"); + assert.equal(receipt.cleanup, "passed"); + assert.deepEqual(receipt.failureMatrix, []); + return true; + }); +}); diff --git a/tests/helpers/entire-fixture.mjs b/tests/helpers/entire-fixture.mjs new file mode 100644 index 00000000..0e90b4a9 --- /dev/null +++ b/tests/helpers/entire-fixture.mjs @@ -0,0 +1,19 @@ +// Synthetic metadata transport for tests only; never live session provenance. +import { chmod, mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createHash } from "node:crypto"; +export const fixtureCheckpointId = label => createHash("sha256").update(label).digest("hex").slice(0, 12); +export const fixtureCheckpoint = id => ({ checkpoint_id: id, session_count: 1, sessions: [{ session_id: "synthetic-session", agent: "codex" }] }); +export async function installEntireFixture(t, root, entries = {}) { + const directory = join(root, "synthetic-entire-bin"); + await mkdir(directory, { recursive: true }); + const metadataPath = join(directory, "metadata.json"); + await writeFile(metadataPath, JSON.stringify(entries)); + const binary = join(directory, "entire"); + await writeFile(binary, `#!${process.execPath}\nimport {readFileSync} from 'node:fs';\nconst args=process.argv.slice(2);\nif(args[0]==='version'){console.log('Entire CLI 0.7.7');process.exit(0);}\nconst id=args[args.indexOf('--checkpoint')+1];\nconst entries=JSON.parse(readFileSync(${JSON.stringify(metadataPath)},'utf8'));\nif(!Object.hasOwn(entries,id))process.exit(1);\nconsole.log(JSON.stringify(entries[id]));\n`); + await chmod(binary, 0o755); + const previous = process.env.PATH; + process.env.PATH = `${directory}:${previous}`; + t.after(() => { process.env.PATH = previous; }); + return { metadataPath }; +} diff --git a/tests/helpers/platform-fixture.mjs b/tests/helpers/platform-fixture.mjs index 206d7295..3571fcdf 100644 --- a/tests/helpers/platform-fixture.mjs +++ b/tests/helpers/platform-fixture.mjs @@ -21,3 +21,11 @@ export function platformFixture() { reviews: { provider: "tabellio", storage: "external", stateRef: "refs/tabellio/reviews" }, }; } + +export function localPlatformFixture() { + const value = platformFixture(); + value.schemaVersion = "tabellio-platform/v0.4"; + Object.assign(value.workflow, { controlState: "local", controlProvider: "local", controlRemoteName: null }); + for (const section of ["ledger", "validation", "reviews"]) value[section].storage = "local"; + return value; +} diff --git a/tests/helpers/validation-fixture.mjs b/tests/helpers/validation-fixture.mjs new file mode 100644 index 00000000..ef292a37 --- /dev/null +++ b/tests/helpers/validation-fixture.mjs @@ -0,0 +1,9 @@ +import { NativeGitStore } from "../../scripts/providers/native-git-store.mjs"; +import { GitJsonLedger } from "../../scripts/lib/git-json-ledger.mjs"; +import { ValidationRunner } from "../../scripts/lib/validation-runner.mjs"; + +export async function createValidationRunner(repoPath) { + const store = await NativeGitStore.open(repoPath); + const ledger = await GitJsonLedger.open({ repoPath, ref: "refs/tabellio/validations" }); + return { store, ledger, runner: new ValidationRunner({ store, ledger }) }; +} diff --git a/tests/local-provenance-store.test.mjs b/tests/local-provenance-store.test.mjs index 5edaa025..9205fdf5 100644 --- a/tests/local-provenance-store.test.mjs +++ b/tests/local-provenance-store.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; @@ -17,6 +17,13 @@ const execFileAsync = promisify(execFile); const postgresHost = process.env.TABELLIO_TEST_PG_SOCKET ?? "127.0.0.1"; const postgresUser = process.env.TABELLIO_TEST_PG_USER; const postgresArgs = ["--host", postgresHost, ...(postgresUser ? ["--username", postgresUser] : [])]; +async function createMigratedTestStore(t) { + const databaseName = await createTestDatabase(t); + const databaseUrl = testDatabaseUrl(databaseName); + const store = new LocalProvenanceStore({ databaseUrl }); + await store.migrate(); + return { databaseName, databaseUrl, store }; +} function testDatabaseUrl(name) { return `postgresql://${postgresUser ? `${encodeURIComponent(postgresUser)}@` : ""}localhost/${name}?host=${encodeURIComponent(postgresHost)}`; } @@ -27,9 +34,7 @@ test("required PostgreSQL integration environment is available", () => { }); test("lineage persists atomically, isolates projects, and replays from original fixtures", { skip: !postgresAvailable }, async (t) => { - const databaseName = await createTestDatabase(t); - const databaseUrl = testDatabaseUrl(databaseName); - const store = new LocalProvenanceStore({ databaseUrl }); await store.migrate(); + const { databaseName, databaseUrl, store } = await createMigratedTestStore(t); const cli = fileURLToPath(new URL("../scripts/tabellio-local-store.mjs", import.meta.url)); const migration = JSON.parse((await execFileAsync(process.execPath, [cli, "migrate", "--database-url", databaseUrl])).stdout); assert.equal(migration.version, "002_tabellio_lineages"); @@ -53,6 +58,34 @@ test("lineage persists atomically, isolates projects, and replays from original await assert.rejects(() => reconnected.getLineage(query), /integrity/); }); +test("lineage insert plans reflect their single-row input without excessive compilation cost", { skip: !postgresAvailable }, async (t) => { + const { databaseUrl, store } = await createMigratedTestStore(t); + const root = await mkdtemp(join(tmpdir(), "tabellio-lineage-plan-")); + t.after(() => rm(root, { recursive: true, force: true })); + const reportPath = join(root, "plan.json"); const probe = join(root, "probe-psql.mjs"); + await writeFile(probe, `#!/usr/bin/env node +import { readFileSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +const sql = readFileSync(0, 'utf8'); +const args = [...process.argv.slice(2), '--tuples-only', '--no-align']; +const run = input => execFileSync(${JSON.stringify(process.env.TABELLIO_PSQL_BIN ?? "psql")}, args, { input, encoding: 'utf8', maxBuffer: 2 * 1024 * 1024 }); +const baselineSql = sql.replace('ANALYZE incoming_lineage;', '').replaceAll('INSERT INTO ', 'EXPLAIN (FORMAT JSON) INSERT INTO ').replace(/COMMIT;\\s*$/, 'ROLLBACK;'); +const baseline = JSON.parse('[' + run(baselineSql).trim().replace(/\\]\\s*\\[/g, '],[') + ']'); +const before = baseline.at(-1)[0]; +const after = JSON.parse(run(sql.replace('INSERT INTO tabellio_evidence_links', 'EXPLAIN (ANALYZE, FORMAT JSON) INSERT INTO tabellio_evidence_links')))[0]; +writeFileSync(${JSON.stringify(reportPath)}, JSON.stringify({ before: { cost: before.Plan['Total Cost'], jitPlanned: Boolean(before.JIT) }, after: { cost: after.Plan['Total Cost'], executionMs: after['Execution Time'], jitPlanned: Boolean(after.JIT) } }), { mode: 0o600 }); +`); + await chmod(probe, 0o700); + const candidate = candidateIdentity({ projectKey: "PLAN", repositoryId: "sample/plan", baseCommit: "a".repeat(40), headCommit: "b".repeat(40), mergeBase: "a".repeat(40) }); + const lineage = assembleLineage({ candidate, observations: sampleObservations(candidate) }); + await new LocalProvenanceStore({ databaseUrl, psqlBinary: probe }).putLineage(lineage); + const plan = JSON.parse(await readFile(reportPath, "utf8")); + t.diagnostic(JSON.stringify({ lineageInsertPlan: plan })); + assert.ok(plan.before.cost > 100000, "The unmeasured single-row input reproduces the excessive estimated cost."); + assert.ok(plan.after.cost < 100000, "Actual input cardinality keeps this short insert below the default JIT threshold."); + assert.deepEqual(await store.getLineage({ digest: lineage.digest, projectKey: candidate.projectKey, repositoryId: candidate.repositoryId }), lineage); +}); + test("local provenance enforces metadata boundaries without PostgreSQL", function metadataBoundary() { const record = syntheticRecord(); assert.throws( @@ -389,7 +422,8 @@ test("rejected payload keys never appear in diagnostics", () => { test("malformed record JSON never leaks input through CLI diagnostics", { skip: !postgresAvailable }, async (t) => { - const databaseUrl = testDatabaseUrl(await createTestDatabase(t)); + const databaseName = await createTestDatabase(t); + const databaseUrl = testDatabaseUrl(databaseName); const root = await mkdtemp(join(tmpdir(), "tabellio-json-error-")); t.after(() => rm(root, { recursive: true, force: true })); const file = join(root, "record.json"); @@ -404,11 +438,16 @@ test("malformed record JSON never leaks input through CLI diagnostics", { skip: }); }); -test("valid lineages above two MiB remain readable after persistence", { skip: !postgresAvailable }, async (t) => { +async function createLineageFixture(t) { const name = await createTestDatabase(t); const store = new LocalProvenanceStore({ databaseUrl: testDatabaseUrl(name) }); await store.migrate(); const candidate = candidateIdentity({ projectKey: "SAMPLE", repositoryId: "sample/repository", baseCommit: "a".repeat(40), headCommit: "b".repeat(40), mergeBase: "a".repeat(40) }); + return { name, store, candidate }; +} + +test("valid lineages above two MiB remain readable after persistence", { skip: !postgresAvailable }, async (t) => { + const { name, store, candidate } = await createLineageFixture(t); const item = sampleObservations(candidate).find(item => item.kind === "validation"); const observations = Array.from({ length: 40 }, (_, index) => ({ ...item, sourceId: `build-${index}`, metadata: { note: "x".repeat(60000) } })); const lineage = assembleLineage({ candidate, observations }); @@ -423,6 +462,18 @@ test("sparse payload arrays are rejected before canonicalization", () => { for (const refs of [Array(1), [, "valid"], ["valid", ,]]) assert.throws(() => normalizeRecord({ ...syntheticRecord(), payload: { refs } }), /sparse arrays/); }); +test("a valid same-scope substituted lineage cannot satisfy another digest", { skip: !postgresAvailable }, async (t) => { + const { name, store, candidate } = await createLineageFixture(t); + const original = assembleLineage({ candidate, observations: sampleObservations(candidate) }); + const changedCandidate = candidateIdentity({ ...candidate, headCommit: "c".repeat(40) }); + const substituted = assembleLineage({ candidate: changedCandidate, observations: sampleObservations(changedCandidate) }); + assert.notEqual(original.digest, substituted.digest); + await store.putLineage(original); + const envelopeHex = Buffer.from(JSON.stringify(substituted), "utf8").toString("hex"); + await adminCommand("psql", ["--dbname", name, "--command", `UPDATE tabellio_lineages SET envelope = convert_from(decode('${envelopeHex}', 'hex'), 'UTF8')::jsonb WHERE digest = '${original.digest}'`]); + await assert.rejects(store.getLineage({ digest: original.digest, projectKey: candidate.projectKey, repositoryId: candidate.repositoryId }), /digest integrity mismatch/); +}); + test("stored record corruption is rejected on read", { skip: !postgresAvailable }, async (t) => { const name = await createTestDatabase(t); const store = new LocalProvenanceStore({ databaseUrl: testDatabaseUrl(name) }); diff --git a/tests/merge-ready-status.test.mjs b/tests/merge-ready-status.test.mjs index caed1d06..4f9cb74e 100644 --- a/tests/merge-ready-status.test.mjs +++ b/tests/merge-ready-status.test.mjs @@ -360,14 +360,17 @@ test("status executor rejects state roots in linked worktrees and Git common sta test("status executor re-verifies exact validation and consumes one approval once", async () => { const root = await mkdtemp(join(tmpdir(), "tabellio-status-")); - await writeFile(join(root, "tabellio.validation.json"), await readFile( - new URL("../examples/tabellio-validation/product-manifest.json", import.meta.url), - )); + const manifest = JSON.parse(await readFile(new URL("../examples/tabellio-validation/product-manifest.json", import.meta.url), "utf8")); + manifest.requireEntireCheckpoint = false; // This fixture tests publication approvals, not capture. + await writeFile(join(root, "tabellio.validation.json"), JSON.stringify(manifest)); await runGit({ args: ["init", "-b", "main"], cwd: root }); await runGit({ args: ["add", "tabellio.validation.json"], cwd: root }); await runGit({ args: ["commit", "-m", "Add status validation contract"], cwd: root, env: identityEnv() }); const commit = (await runGit({ args: ["rev-parse", "HEAD"], cwd: root })).stdout.trim(); const validation = await validationFixture(commit); + validation.suite.manifestDigest = digestObject(manifest); + const { integrity: _fixtureIntegrity, ...fixtureUnsigned } = validation; + validation.integrity.digest = digestObject(fixtureUnsigned); const ledger = { ...validationLedger(validation), repoPath: root }; const intent = createMergeReadyStatusIntent({ repository, @@ -458,3 +461,13 @@ function validationLedger(value) { }, }; } + +test("GitHub status publisher rejects imprecise and nonpositive JSON IDs", async () => { + for (const id of [Number.MAX_SAFE_INTEGER + 1, 0, -1]) { + const publisher = new GitHubStatusPublisher({ + token: "synthetic-token", baseUrl: "http://127.0.0.1", + fetchImpl: async () => new Response(JSON.stringify({ id, state: "success", context: "test", description: "test" }), { status: 201 }), + }); + await assert.rejects(publisher.publish({ owner: "example", repo: "test", commit: "b".repeat(40), state: "success", context: "test", description: "test" }), /positive safe integer/); + } +}); diff --git a/tests/platform-config.test.mjs b/tests/platform-config.test.mjs index abd7c713..02661ee2 100644 --- a/tests/platform-config.test.mjs +++ b/tests/platform-config.test.mjs @@ -2,12 +2,14 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; +import { platformFixture, localPlatformFixture } from "./helpers/platform-fixture.mjs"; + import { validatePlatformConfig } from "../scripts/lib/platform-config.mjs"; const projectRoot = new URL("../", import.meta.url).pathname; test("platform v0.3 makes GitHub code-only storage and private GitHub control state explicit", async () => { - const config = JSON.parse(await readFile(`${projectRoot}/tabellio.platform.json`, "utf8")); + const config = platformFixture(); assert.equal(validatePlatformConfig(config), config); assert.deepEqual(config.codeStorage, { provider: "github", @@ -26,7 +28,7 @@ test("platform v0.3 makes GitHub code-only storage and private GitHub control st }); test("platform v0.3 rejects provider drift and private-state publication", async () => { - const config = JSON.parse(await readFile(`${projectRoot}/tabellio.platform.json`, "utf8")); + const config = platformFixture(); assert.throws( () => validatePlatformConfig({ ...config, codeStorage: { ...config.codeStorage, provider: "unsupported" } }), /platform.codeStorage.provider must be "github"/, @@ -44,3 +46,18 @@ test("platform v0.3 rejects provider drift and private-state publication", async /allowedRefPrefixes must contain each required value exactly once/, ); }); + +test("local v0.4 default retains privacy boundaries and legacy remote compatibility", async () => { + const config = JSON.parse(await readFile(`${projectRoot}/tabellio.platform.json`, "utf8")); + assert.deepEqual(config, localPlatformFixture()); + assert.equal(validatePlatformConfig(config), config); + assert.equal(validatePlatformConfig(platformFixture()).schemaVersion, "tabellio-platform/v0.3"); + assert.throws(() => validatePlatformConfig({ ...config, workflow: { ...config.workflow, controlRemoteName: "origin" } }), /controlRemoteName/); + assert.throws(() => validatePlatformConfig({ ...config, ledger: { ...config.ledger, storage: "external" } }), /storage/); + const remote = platformFixture(); + remote.schemaVersion = "tabellio-platform/v0.4"; + remote.workflow.controlRemoteName = "customer-private"; + assert.throws(() => validatePlatformConfig(remote), /controlRemoteName must be "control"/); + remote.workflow.controlRemoteName = "control"; + assert.equal(validatePlatformConfig(remote), remote); +}); diff --git a/tests/preflight.test.mjs b/tests/preflight.test.mjs index 93c97da5..1e612602 100644 --- a/tests/preflight.test.mjs +++ b/tests/preflight.test.mjs @@ -7,7 +7,7 @@ import test from "node:test"; import { runGit } from "../scripts/lib/git-process.mjs"; import { runPreflight, validatePreflightResult } from "../scripts/lib/preflight.mjs"; import { createFixture, identityEnv } from "./helpers/git-fixture.mjs"; -import { platformFixture } from "./helpers/platform-fixture.mjs"; +import { localPlatformFixture, platformFixture } from "./helpers/platform-fixture.mjs"; test("preflight proves GitHub and Entire readiness without exposing credentials", async (t) => { const fixture = await preparedFixture(t); @@ -739,3 +739,46 @@ function hookConfigEventName(event) { function hookEventSnakeCase(eventName) { return eventName.replace(/[A-Z]/g, (character) => `_${character.toLowerCase()}`); } + +async function localPreparedFixture(t) { + const fixture = await preparedFixture(t); + await writeFile(join(fixture.seed, "tabellio.platform.json"), JSON.stringify(localPlatformFixture())); + await writeFile(join(fixture.seed, ".entire", "settings.json"), JSON.stringify({ enabled: true, strategy_options: { push_sessions: false } })); + await runGit({ args: ["remote", "remove", "control"], cwd: fixture.seed }); + return fixture; +} + +test("local preflight needs no control repository, credentials or network", async (t) => { + const fixture = await localPreparedFixture(t); + const commands = fakeCommands(); + const result = await runPreparedPreflight(fixture, { + commandRunner: async (request) => { + assert.notEqual(request.binary, "gh"); + return commands(request); + }, + remoteRefReader: async () => { throw new Error("Unexpected network access"); }, + remoteRepositoryReader: async () => { throw new Error("Unexpected remote access"); }, + }); + assert.equal(result.status, "ready"); + assert.equal(result.checks.some((check) => check.id === "github-auth"), false); + await runGit({ args: ["update-ref", "-d", "refs/heads/entire/checkpoints/v1"], cwd: fixture.seed }); + const initial = await runPreparedPreflight(fixture, { commandRunner: commands, remoteRefReader: async () => { throw new Error("Unexpected network access"); } }); + assert.equal(initial.status, "ready"); +}); + +test("local preflight blocks automatic publication and permits dormant remotes without network access", async (t) => { + const fixture = await localPreparedFixture(t); + await writeFile(join(fixture.seed, ".entire", "settings.local.json"), JSON.stringify({ strategy_options: { push_sessions: true } })); + let result = await runPreparedPreflight(fixture, { commandRunner: fakeCommands() }); + assert.match(result.checks.find((check) => check.id === "entire-metadata").detail, /automatic checkpoint pushing/); + await writeFile(join(fixture.seed, ".entire", "settings.local.json"), JSON.stringify({ strategy_options: { checkpoint_remote: { provider: "github", repo: "example/old-control" } } })); + result = await runPreparedPreflight(fixture, { + commandRunner: fakeCommands(), + remoteRefReader: async () => { throw new Error("Unexpected network access"); }, + remoteRepositoryReader: async () => { throw new Error("Unexpected remote access"); }, + }); + assert.equal(result.status, "ready"); + assert.match(result.checks.find((check) => check.id === "entire-metadata").detail, /does not contact or publish/); + result = await runPreparedPreflight(fixture, { commandRunner: fakeCommands(), controlRemote: "control" }); + assert.equal(result.checks.find((check) => check.id === "github-remotes").status, "blocked"); +}); diff --git a/tests/provenance-benchmark.test.mjs b/tests/provenance-benchmark.test.mjs new file mode 100644 index 00000000..7468d5e7 --- /dev/null +++ b/tests/provenance-benchmark.test.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { evaluateLineage } from "../scripts/lib/provenance-ledger.mjs"; +import { provenanceBenchmarkCases } from "../scripts/benchmarks/provenance-cases.mjs"; +import { runProvenanceBenchmark } from "../scripts/benchmarks/provenance-benchmark.mjs"; + +const execute = promisify(execFile); + +test("benchmark matches declared fault expectations and reports bounded timings", () => { + const report = runProvenanceBenchmark({ iterations: 2 }); + assert.equal(report.status, "passed"); + assert.deepEqual(report.totals, { cases: 30, evaluations: 60, mismatches: 0, invalidPasses: 0, validCaseRejections: 0 }); + assert.equal(new Set(report.results.map((item) => item.id)).size, 30); + for (const item of report.results) { + assert.equal(Object.values(item.outcomes).reduce((sum, count) => sum + count, 0), 2); + assert.ok(Number.isFinite(item.latencyMs.p50) && item.latencyMs.p50 >= 0); + assert.ok(item.latencyMs.p95 >= item.latencyMs.p50); + assert.ok(item.latencyMs.max >= item.latencyMs.p95); + } +}); + +test("benchmark fails when the gate always passes or always blocks", () => { + const unsafe = runProvenanceBenchmark({ iterations: 1, evaluate: () => ({ status: "passed" }) }); + assert.equal(unsafe.status, "failed"); + assert.equal(unsafe.totals.invalidPasses, 27); + const unavailable = runProvenanceBenchmark({ iterations: 1, evaluate: () => ({ status: "blocked" }) }); + assert.equal(unavailable.status, "failed"); + assert.equal(unavailable.totals.validCaseRejections, 3); +}); + +test("unexpected exceptions and unknown output never count as correct rejection", () => { + for (const evaluate of [() => { throw new Error("unexpected failure"); }, () => { throw null; }, () => ({ status: "unknown" }), () => undefined]) { + const report = runProvenanceBenchmark({ iterations: 1, evaluate }); + assert.equal(report.status, "failed"); + assert.equal(report.totals.mismatches, 30); + assert.ok(report.results.every((item) => item.outcomes.error === 1)); + } +}); + +test("fixture identities are reproducible and evaluator mutation cannot leak between trials", () => { + const first = runProvenanceBenchmark({ iterations: 1 }); + const second = runProvenanceBenchmark({ iterations: 2, evaluate: (lineage, options) => { + const result = evaluateLineage(lineage, options); + lineage.observations.length = 0; + return result; + } }); + assert.equal(first.fixtureDigest, second.fixtureDigest); + assert.equal(second.status, "passed"); + const fixtures = provenanceBenchmarkCases(); + fixtures[0].lineage.observations.length = 0; + assert.equal(provenanceBenchmarkCases()[0].lineage.observations.length, 8); +}); + +test("benchmark rejects invalid or unbounded iteration counts", () => { + for (const iterations of [0, -1, 0.5, NaN, Infinity, 201, "2"]) assert.throws(() => runProvenanceBenchmark({ iterations }), /Iterations/); +}); + +test("benchmark CLI emits a standalone report and rejects unsupported arguments", async () => { + const script = fileURLToPath(new URL("../scripts/benchmark-provenance.mjs", import.meta.url)); + const { stdout } = await execute(process.execPath, [script, "--iterations", "1"]); + assert.equal(JSON.parse(stdout).totals.evaluations, 30); + for (const args of [["--out", "unused.json"], ["--iterations", "201"], ["--iterations", "1", "--iterations", "2"]]) { + await assert.rejects(execute(process.execPath, [script, ...args]), (error) => error.code === 1 && JSON.parse(error.stderr).ok === false); + } +}); diff --git a/tests/provenance-review-publication.test.mjs b/tests/provenance-review-publication.test.mjs index 09876085..3afa491c 100644 --- a/tests/provenance-review-publication.test.mjs +++ b/tests/provenance-review-publication.test.mjs @@ -1,5 +1,9 @@ import { GitJsonLedger } from "../scripts/lib/git-json-ledger.mjs"; import assert from "node:assert/strict"; +import { mkdir } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; import test from "node:test"; import { createFeatureFixture } from "./helpers/git-fixture.mjs"; import { runGit } from "../scripts/lib/git-process.mjs"; @@ -22,7 +26,7 @@ async function setup(t, change = () => {}) { const observations = sampleObservations(candidate); change(observations); const lineage = assembleLineage({ candidate, observations }); - const intent = createProvenanceStatusIntent(lineage, { now }); + const intent = createProvenanceStatusIntent(lineage, { now, reservationRemote: "control" }); const calls = []; const publisher = { publish: async (request) => { calls.push(request); return { ...request, id: calls.length }; } }; const controlVerifier = async ({ repo: path }) => { @@ -35,6 +39,25 @@ async function setup(t, change = () => {}) { return { repo, lineage, intent, now, publisher, calls, approval: approval(intent), control: fixture.bare, controlVerifier }; } +async function clonePublicationRepo(input, suffix) { + const second = input.repo + suffix; + await runGit({ cwd: input.repo, args: ["clone", input.repo, second] }); + await runGit({ cwd: second, args: ["branch", "main", "origin/main"] }); + await runGit({ cwd: second, args: ["remote", "set-url", "origin", "https://github.com/example/tabellio.git"] }); + await runGit({ cwd: second, args: ["remote", "add", "control", input.control] }); + return second; +} + +function interruptReceiptSync(input, message) { + const verifier = input.controlVerifier; + let reads = 0; + input.controlVerifier = async options => { + if (++reads === 3) throw new Error(message); + return verifier(options); + }; + return verifier; +} + test("published GitHub statuses match the CLI intent and approval replay sends nothing", async (t) => { const input = await setup(t); const first = await publishProvenanceStatuses(input); @@ -101,6 +124,20 @@ test("mismatched provider response cannot upgrade blocked evidence", async (t) = assert.deepEqual(receipt.published, []); }); +test("duplicate provider status IDs stay blocked and replay sends nothing", async (t) => { + const input = await setup(t); + let calls = 0; + input.publisher = { publish: async request => { + calls += 1; + return { ...request, id: "123" }; + } }; + const receipt = await publishProvenanceStatuses(input); + assert.equal(receipt.status, "blocked"); + assert.equal(receipt.published.length, 1); + assert.deepEqual(await publishProvenanceStatuses(input), receipt); + assert.equal(calls, 2); +}); + test("candidate movement between statuses stops the second publication", async (t) => { const input = await setup(t); let count = 0; @@ -130,11 +167,7 @@ test("shared control reservation prevents duplicate delivery across independent process.env[key] = "2026-09-12T12:00:00Z"; t.after(() => { if (before === undefined) delete process.env[key]; else process.env[key] = before; }); } - const second = input.repo + "-second"; - await runGit({ cwd: input.repo, args: ["clone", input.repo, second] }); - await runGit({ cwd: second, args: ["branch", "main", "origin/main"] }); - await runGit({ cwd: second, args: ["remote", "set-url", "origin", "https://github.com/example/tabellio.git"] }); - await runGit({ cwd: second, args: ["remote", "add", "control", input.control] }); + const second = await clonePublicationRepo(input, "-second"); const verifier = input.controlVerifier; const callsByClone = new Map(); let waiting = 0, release; @@ -193,6 +226,8 @@ test("shared receipts validate complete approval and status bindings", async (t) value => { value.published[0].state = "error"; }, value => { value.published[0].context = "unrelated"; }, value => { value.published[0].id = "invalid"; }, + value => { value.published[0].id = "01"; }, + value => { value.published[0].id = "0"; }, value => { value.published[1].id = value.published[0].id; }, value => { value.attemptedAt = "2026-09-12T13:00:00Z"; }, value => { value.extra = "untrusted"; }, @@ -234,3 +269,182 @@ test("changed control identity stops reservation before any GitHub write", async const refs = await runGit({ cwd: input.repo, args: ["ls-remote", "--refs", "control", "refs/tabellio/provenance-status-reservations/*"] }); assert.equal(refs.stdout, ""); }); + +test("interrupted receipt synchronization keeps remote pending and prevents redelivery", async (t) => { + const input = await setup(t); + const verifier = interruptReceiptSync(input, "synthetic control outage after delivery"); + const receipt = await publishProvenanceStatuses(input); + assert.equal(receipt.status, "blocked"); + assert.equal(receipt.published.length, 2); + assert.match(receipt.reason, /synchronization is uncertain/); + input.controlVerifier = verifier; + const replay = await publishProvenanceStatuses(input); + assert.equal(replay.status, "blocked"); + assert.match(replay.reason, /earlier attempt is unresolved/); + assert.deepEqual(replay.published, []); + assert.equal(input.calls.length, 2); +}); + +test("mixed numeric and string duplicate IDs block replay", async t => { + const input = await setup(t); + let count=0; + input.publisher={publish:async request=>({...request,id:++count===1?123:"123"})}; + const result=await publishProvenanceStatuses(input); + assert.equal(result.status,"blocked"); + assert.equal(result.published.length,1); + assert.deepEqual(await publishProvenanceStatuses(input),result); + assert.equal(count,2); +}); +test("fresh clone cannot redeliver after interrupted receipt synchronization", async t => { + const input=await setup(t); + const verifier=interruptReceiptSync(input,"synthetic final receipt outage"); + assert.equal((await publishProvenanceStatuses(input)).status,"blocked"); + const second=await clonePublicationRepo(input,"-recovery"); + const ref=`refs/tabellio/provenance-status-reservations/${digestObject({approvalId:input.approval.id})}`; + const ledger=await GitJsonLedger.open({repoPath:second,ref}); + assert.equal((await ledger.read("receipt.json")).value,null); + const replay=await publishProvenanceStatuses({...input,repo:second,controlVerifier:verifier}); + assert.equal(replay.status,"blocked"); + assert.match(replay.reason,/earlier attempt is unresolved/); + assert.equal(input.calls.length,2); +}); + +test("ambiguous provider IDs block and remain replayable", { concurrency: 2 }, async t => { + await Promise.all(["0123", "0", 0, -1, Number.MAX_SAFE_INTEGER + 1, {}, 123n].map(id => + t.test(String(id), async t => { + const input = await setup(t); + let calls = 0; + input.publisher = { publish: async request => { calls += 1; return { ...request, id }; } }; + const receipt = await publishProvenanceStatuses(input); + assert.equal(receipt.status, "blocked"); + assert.deepEqual(receipt.published, []); + assert.deepEqual(await publishProvenanceStatuses(input), receipt); + assert.equal(calls, 1); + }) + )); +}); + +async function localSetup(t) { + const input = await setup(t); + await runGit({ cwd: input.repo, args: ["remote", "remove", "control"] }); + input.intent = createProvenanceStatusIntent(input.lineage, { now, publicationStore: input.control }); + input.approval = approval(input.intent); + input.controlVerifier = async () => { throw new Error("Local publication must never inspect private remote credentials."); }; + return input; +} + +test("local customer-owned authority publishes without a control remote and replay sends nothing", async t => { + const input = await localSetup(t); + const first = await publishProvenanceStatuses(input); + assert.equal(first.status, "published"); + assert.deepEqual(await publishProvenanceStatuses(input), first); + assert.equal(input.calls.length, 2); +}); + +test("local publication without an explicit authority fails closed before delivery", async t => { + const input = await localSetup(t); + input.intent = createProvenanceStatusIntent(input.lineage, { now }); + input.approval = approval(input.intent); + await assert.rejects(publishProvenanceStatuses(input), /Configure TABELLIO_PUBLICATION_STORE/); + assert.equal(input.calls.length, 0); +}); + +async function assertRejectedLocalAuthority(input, publicationStore, reason, overrides = {}) { + const intent = createProvenanceStatusIntent(input.lineage, { now, publicationStore }); + await assert.rejects(publishProvenanceStatuses({ ...input, ...overrides, intent, approval: approval(intent) }), reason); + assert.equal(input.calls.length, 0); +} + +test("public checkout and its git directory cannot serve as publication authority", async t => { + const input = await localSetup(t); + for (const publicationStore of [input.repo, `${input.repo}/.git`]) { + await assertRejectedLocalAuthority(input, publicationStore, /separate bare Git/); + } + assert.equal(input.calls.length, 0); +}); + +test("nested bare publication stores cannot write private receipts inside public code", async t => { + const input = await localSetup(t); + for (const publicationStore of [`${input.repo}/private-store.git`, `${input.repo}/.git/private-store.git`]) { + await runGit({ cwd: input.repo, args: ["init", "--bare", publicationStore] }); + await assertRejectedLocalAuthority(input, publicationStore, /outside the worktree/); + } + assert.equal(input.calls.length, 0); +}); + +test("subdirectory caller cannot put publication authority inside the public top-level", async t => { + const input = await localSetup(t); + const repo = `${input.repo}/src`; + await mkdir(repo); + const publicationStore = `${input.repo}/private-authority.git`; + await runGit({ cwd: input.repo, args: ["init", "--bare", publicationStore] }); + await assertRejectedLocalAuthority(input, publicationStore, /outside the worktree/, { repo }); + assert.equal(input.calls.length, 0); +}); + +test("independent clones sharing one local authority cannot duplicate approved delivery", async t => { + const input = await localSetup(t); + const second = await clonePublicationRepo(input, "-local-second"); + const outcomes = await Promise.allSettled([publishProvenanceStatuses(input), publishProvenanceStatuses({ ...input, repo: second })]); + assert.ok(outcomes.some(item => item.status === "fulfilled" && item.value.status === "published")); + assert.equal(input.calls.length, 2); + assert.equal((await publishProvenanceStatuses({ ...input, repo: second })).status, "published"); + assert.equal(input.calls.length, 2); +}); + +test("local interrupted delivery leaves approval consumed for another clone", async t => { + const input = await localSetup(t); + let attempts = 0; + input.publisher = { publish: async () => { attempts += 1; throw new Error("uncertain transport"); } }; + assert.equal((await publishProvenanceStatuses(input)).status, "blocked"); + const second = await clonePublicationRepo(input, "-local-recovery"); + assert.equal((await publishProvenanceStatuses({ ...input, repo: second })).status, "blocked"); + assert.equal(attempts, 1); +}); + +test("crashed local publisher leaves pending receipt that another clone never retries", async t => { + const input = await localSetup(t); + const ref = `refs/tabellio/provenance-status-reservations/${digestObject({ repository: "example/tabellio", approvalId: input.approval.id })}`; + const ledger = await GitJsonLedger.open({ repoPath: input.control, ref }); + await ledger.write("receipt.json", { + schemaVersion: "tabellio-provenance-status-receipt/v0.1", approvalId: input.approval.id, + reservationId: "12345678-1234-4234-8234-123456789abc", intentDigest: input.intent.integrity.digest, + candidateId: input.intent.candidate.id, attemptedAt: now, status: "pending", published: [], + }, { expectedVersion: null }); + const result = await publishProvenanceStatuses(input); + assert.equal(result.status, "blocked"); + assert.match(result.reason, /earlier attempt is unresolved/); + assert.equal(input.calls.length, 0); +}); + +test("approved local authority cannot be switched to a fresh store", async t => { + const input = await localSetup(t); + const modified = structuredClone(input.intent); + modified.reservationRemote = `local:${input.repo}`; + const { integrity: _integrity, ...unsigned } = modified; + modified.integrity.digest = digestObject(unsigned); + await assert.rejects(publishProvenanceStatuses({ ...input, intent: modified })); + assert.equal(input.calls.length, 0); +}); + + +test("separate publishing processes use one atomic local reservation", async t => { + const input = await localSetup(t); + const second = await clonePublicationRepo(input, "-local-process"); + const module = new URL("../scripts/lib/provenance-review-publication.mjs", import.meta.url).href; + const script = `import { publishProvenanceStatuses } from ${JSON.stringify(module)}; + const input = JSON.parse(process.argv[1]); + let delivered = 0; + input.publisher = { publish: async request => ({ ...request, id: ++delivered }) }; + try { const result = await publishProvenanceStatuses(input); console.log(JSON.stringify({ status: result.status, delivered })); } + catch { console.log(JSON.stringify({ status: "blocked", delivered })); }`; + const run = repo => promisify(execFile)(process.execPath, ["--input-type=module", "-e", script, JSON.stringify({ + repo, lineage: input.lineage, intent: input.intent, approval: input.approval, now, + })], { cwd: fileURLToPath(new URL("..", import.meta.url)), timeout: 30000 }); + const results = await Promise.all([run(input.repo), run(second)]); + const outcomes = results.map(result => JSON.parse(result.stdout)); + assert.equal(outcomes.reduce((sum, result) => sum + result.delivered, 0), 2); + assert.ok(outcomes.some(result => result.status === "published")); + assert.equal((await publishProvenanceStatuses(input)).status, "published"); + assert.equal(input.calls.length, 0); +}); diff --git a/tests/release-cli.test.mjs b/tests/release-cli.test.mjs new file mode 100644 index 00000000..7433ee50 --- /dev/null +++ b/tests/release-cli.test.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import test from "node:test"; + +const execute = promisify(execFile); +const cli = new URL("../scripts/tabellio-release.mjs", import.meta.url); +const planner = new URL("../scripts/lib/release-planner.mjs", import.meta.url); + +test("release CLI preserves omitted and explicit control remote options", async (t) => { + const root = await mkdtemp(join(tmpdir(), "tabellio-release-cli-")); + t.after(() => rm(root, { recursive: true, force: true })); + const loader = join(root, "planner-loader.mjs"); + const bootstrap = join(root, "register-loader.mjs"); + await writeFile(loader, `export async function load(url, context, nextLoad) { + if (url === ${JSON.stringify(planner.href)}) { + return { format: "module", shortCircuit: true, + source: "export async function planRelease(options) { return { controlRemote: options.controlRemote }; }" }; + } + return nextLoad(url, context); + } +`); + await writeFile(bootstrap, `import { register } from "node:module"; +register(${JSON.stringify(pathToFileURL(loader).href)}, import.meta.url); +`); + const environment = { ...process.env, GITHUB_TOKEN: "", GH_TOKEN: "" }; + delete environment.NODE_OPTIONS; + for (const [label, arguments_, expected] of [["default", [], null], ["explicit", ["--control-remote", "control"], "control"]]) { + const out = join(root, `${label}-intent.json`); + const result = await execute(process.execPath, [ + "--import", bootstrap, fileURLToPath(cli), "plan", + "--repo", root, "--owner", "example", "--remote-repo", "repository", + "--number", "7", "--version", "1.2.3", "--notes", "release-notes.txt", + "--out", out, ...arguments_, + ], { cwd: root, env: environment, timeout: 30_000 }); + const written = JSON.parse(await readFile(out, "utf8")); + const reported = JSON.parse(result.stdout); + assert.deepEqual(written, { controlRemote: expected }); + assert.equal(reported.ok, true); + assert.deepEqual(reported.intent, written); + assert.equal(reported.out, out); + } +}); diff --git a/tests/release-workflow.test.mjs b/tests/release-workflow.test.mjs index bfdeae76..71c74512 100644 --- a/tests/release-workflow.test.mjs +++ b/tests/release-workflow.test.mjs @@ -1,3 +1,4 @@ +import { installEntireFixture, fixtureCheckpoint } from "./helpers/entire-fixture.mjs"; import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; @@ -306,6 +307,7 @@ test("approved release publishes exact control ref, annotated tag, and GitHub re test("release planning binds merged PR proof after exact validation and terminal review sync", async (t) => { const fixture = await createFixture(); + await installEntireFixture(t, fixture.root, { abcdef123456: fixtureCheckpoint("abcdef123456") }); t.after(() => rm(fixture.root, { recursive: true, force: true })); const control = join(fixture.root, "control-plan.git"); await NativeGitStore.createBare(control); @@ -558,3 +560,84 @@ function readyProvider({ head, base }) { }, }; } + +test("local release intent binds complete evidence without claiming remote publication", () => { + const remote = exampleIntent(); + const makeLocal = (refs) => createReleaseIntent({ + ...remote, + localControlRefs: refs, + }); + const refs = remote.control.intent.refs.map(({ name, localOid }) => ({ name, localOid })); + const local = makeLocal(refs); + assert.equal(local.schemaVersion, "tabellio-release-operation/v0.2"); + assert.deepEqual(local.control, { mode: "local", refs }); + assert.equal(validateReleaseApproval(approvalFor(local, "local-release"), local, { now }).approved, true); + assert.throws(() => validateReleaseApproval(approvalFor(remote, "remote-release"), local, { now }), /intentDigest/); + assert.throws(() => makeLocal(refs.slice(1)), /complete release control-ref set/); + assert.throws(() => makeLocal([refs[0], refs[0], refs[2]]), /complete release control-ref set/); + assert.throws(() => makeLocal(refs.map((ref) => ({ ...ref, remoteOid: null }))), /exactly/); + const changed = structuredClone(local); + changed.control.refs[0].localOid = "f".repeat(40); + assert.throws(() => validateReleaseIntent(changed), /integrity.digest/); +}); + +test("local release verifies evidence and retries without any control remote", async (t) => { + const fixture = await createFixture(); + t.after(() => rm(fixture.root, { recursive: true, force: true })); + await configureRepositoryIdentity(fixture.seed); + await writeFile(join(fixture.seed, "package.json"), JSON.stringify({ version: "1.2.3" })); + await writeFile(join(fixture.seed, "release-notes.txt"), "Local release\n"); + await runGit({ args: ["add", "package.json", "release-notes.txt"], cwd: fixture.seed }); + await runGit({ args: ["commit", "-m", "Local fixture release"], cwd: fixture.seed, env: identityEnv() }); + await runGit({ args: ["push", "origin", "main"], cwd: fixture.seed }); + const store = await NativeGitStore.open(fixture.seed); + const head = await store.resolveRef("HEAD"); + const parent = await store.resolveRef("HEAD^"); + const refs = ["refs/tabellio/reviews", "refs/tabellio/validations", "refs/heads/entire/checkpoints/v1"].map((name) => ({ name, localOid: head })); + for (const ref of refs) await runGit({ args: ["update-ref", ref.name, head], cwd: fixture.seed }); + const intent = createReleaseIntent({ + repository: { id: "github.com/example/repository", owner: "example", name: "repository" }, + version: "1.2.3", revision: { commit: head, parent }, + pullRequest: { number: 7, headCommit: parent, mergeCommit: head }, + localControlRefs: refs, + validation: { runId: "local-test", resultVersion: head, status: "passed", headCommit: head }, + release: { title: "Local release", notesPath: "release-notes.txt", notesDigest: digest("Local release\n") }, createdAt, + }); + let creates = 0; + const executor = await ReleaseExecutor.open({ + repoPath: fixture.seed, stateRoot: join(fixture.root, "local-release-state"), clock: () => now, + codeRepositoryReader: async () => intent.repository.id, remoteRefReader: async () => head, + controlRepositoryReader: async () => { throw new Error("Unexpected private remote access"); }, + commandRunner: async ({ args }) => { + if (args[1] === "view") throw Object.assign(new Error("not found"), { exitCode: 1 }); + creates += 1; + if (creates === 1) throw new Error("simulated release delivery failure"); + return { stdout: "https://github.com/example/repository/releases/tag/v1.2.3\n", stderr: "", exitCode: 0 }; + }, + }); + const approval = approvalFor(intent, "local-retry"); + await assert.rejects(executor.execute({ intent, approval, now }), /delivery failure/); + await runGit({ args: ["update-ref", refs[0].name, parent], cwd: fixture.seed }); + await assert.rejects(executor.execute({ intent, approval, now }), /Approved local control ref.*changed/); + assert.equal(creates, 1); + await runGit({ args: ["update-ref", refs[0].name, head], cwd: fixture.seed }); + const result = await executor.execute({ intent, approval, now }); + assert.equal(result.receipt.status, "succeeded"); + const evidence = result.receipt.phases.find((phase) => phase.id === "control-refs"); + assert.equal(evidence.evidence.status, "verified-local"); + assert.equal(creates, 2); +}); + +test("release planner leaves remote selection unset by default for local preflight", async () => { + const sentinel = new Error("preflight reached"); + const requested = []; + const options = { + repoPath: new URL("..", import.meta.url).pathname, + owner: "example", repo: "repository", number: 7, version: "1.2.3", notesPath: "release-notes.txt", + preflightRunner: async ({ controlRemote }) => { requested.push(controlRemote); throw sentinel; }, + }; + await assert.rejects(planRelease(options), (error) => error === sentinel); + await assert.rejects(planRelease({ ...options, controlRemote: null }), (error) => error === sentinel); + await assert.rejects(planRelease({ ...options, controlRemote: "control" }), (error) => error === sentinel); + assert.deepEqual(requested, [null, null, "control"]); +}); diff --git a/tests/review-cycle.test.mjs b/tests/review-cycle.test.mjs index d42f2bb0..ef295784 100644 --- a/tests/review-cycle.test.mjs +++ b/tests/review-cycle.test.mjs @@ -383,11 +383,7 @@ async function createReviewFixture(t) { const fixture = await createFixture(); t.after(() => rm(fixture.root, { recursive: true, force: true })); await runGit({ args: ["switch", "feature"], cwd: fixture.seed }); - const manifest = JSON.stringify({ - schemaVersion: "tabellio-validation/v0.1", id: "test-suite", failFast: true, - requireEntireCheckpoint: false, - commands: [{ id: "tests", argv: ["node", "--version"], cwd: ".", timeoutMs: 1000, required: true }], - }); + const manifest = JSON.stringify(reviewValidationManifest()); for (const path of ["tabellio.validation.json", "alternate.validation.json"]) { await writeFile(`${fixture.seed}/${path}`, manifest); } @@ -568,7 +564,7 @@ function validationResult(commit, runId, status, completedAt, manifestPath = "ta runId, repository: { id: "example/repository" }, revision: { baseCommit: "a".repeat(40), mergeBase: "a".repeat(40), headCommit: commit }, - suite: { id: "test-suite", manifestPath, manifestDigest: "c".repeat(64) }, + suite: { id: "test-suite", manifestPath, manifestDigest: digestObject(reviewValidationManifest()) }, runner: { id: "test", runtime: "node-test" }, status, checkpoints: ["checkpoint-001"], @@ -593,3 +589,9 @@ function validationResult(commit, runId, status, completedAt, manifestPath = "ta value.integrity = { algorithm: "sha256", digest: digestObject(value) }; return value; } + +function reviewValidationManifest() { return { + schemaVersion: "tabellio-validation/v0.1", id: "test-suite", failFast: true, + requireEntireCheckpoint: false, + commands: [{ id: "tests", argv: ["node", "--version"], cwd: ".", timeoutMs: 1000, required: true }], + }; } diff --git a/tests/validation-checkpoints.test.mjs b/tests/validation-checkpoints.test.mjs new file mode 100644 index 00000000..193263d2 --- /dev/null +++ b/tests/validation-checkpoints.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { writeFile, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { latestValidationResult, validateValidationResult } from "../scripts/lib/validation-runner.mjs"; +import { digestObject } from "../scripts/lib/stack-operation.mjs"; +import { createValidationRunner } from "./helpers/validation-fixture.mjs"; +import { runGit } from "../scripts/lib/git-process.mjs"; +import { createFeatureFixture, identityEnv } from "./helpers/git-fixture.mjs"; +import { installEntireFixture, fixtureCheckpoint } from "./helpers/entire-fixture.mjs"; + +// Every checkpoint and executable below is an explicitly synthetic fixture. +test("synthetic metadata fixture: required gate resolves complete checkpoint evidence before commands", async t => { + const fixture = await createFeatureFixture(t); + const { metadataPath } = await installEntireFixture(t, fixture.root); + const id = "abcdef123456"; + const marker = join(fixture.root, "command-ran"); + const manifest = { schemaVersion: "tabellio-validation/v0.1", id: "checkpoint-test", failFast: true, requireEntireCheckpoint: true, commands: [{ id: "pass", argv: [process.execPath, "-e", `require('node:fs').writeFileSync(${JSON.stringify(marker)},'ran')`], cwd: ".", timeoutMs: 1000, required: true }] }; + await writeFile(join(fixture.seed, "tabellio.validation.json"), JSON.stringify(manifest)); + await runGit({ cwd: fixture.seed, args: ["add", "tabellio.validation.json"] }); + await runGit({ cwd: fixture.seed, args: ["commit", "-m", "Synthetic checkpoint claim", "-m", `Entire-Checkpoint: ${id}`], env: identityEnv() }); + const { ledger, runner } = await createValidationRunner(fixture.seed); + const options = { repositoryId: "example/repository", commit: "HEAD", base: "main" }; + for (const [label, entries] of [ + ["missing", {}], + ["partial", { [id]: { ...fixtureCheckpoint(id), partial: true } }], + ["wrong-ID", { [id]: fixtureCheckpoint("123456abcdef") }], + ["empty sessions", { [id]: { checkpoint_id: id, session_count: 0, sessions: [] } }], + ["session error without partial flag", { [id]: { checkpoint_id: id, session_count: 1, sessions: [{ session_id: "synthetic", error: "missing" }] } }], + ]) { + await t.test(label, async () => { + await writeFile(metadataPath, JSON.stringify(entries)); + await assert.rejects(runner.run(options)); + assert.equal((await ledger.list()).paths.length, 0); + assert.equal(await stat(marker).catch(error => error.code), "ENOENT"); + }); + } + await t.test("malformed trailer", async () => { + await runGit({ cwd: fixture.seed, args: ["commit", "--amend", "-m", "Synthetic malformed trailer", "-m", "Entire-Checkpoint: not-a-checkpoint"], env: identityEnv() }); + await assert.rejects(runner.run(options), /Invalid Entire checkpoint trailer ID/); + await runGit({ cwd: fixture.seed, args: ["commit", "--amend", "-m", "Synthetic checkpoint claim", "-m", `Entire-Checkpoint: ${id}`], env: identityEnv() }); + }); + await writeFile(metadataPath, JSON.stringify({ [id]: fixtureCheckpoint(id) })); + const valid = await runner.run(options); + assert.equal(valid.result.status, "passed"); + assert.equal(valid.result.checkpointEvidence.provider.version, "0.7.7"); + assert.equal(valid.result.checkpointEvidence.checkpoints[0].commits[0], valid.result.revision.headCommit); + assert.equal((await latestValidationResult(ledger, valid.result.revision.headCommit)).runId, valid.result.runId); + // Wrong-commit here means corrupted stored Git association, not a nonexistent + // independent commit field in Entire's metadata export. + const wrongCommit = structuredClone(valid.result); + wrongCommit.checkpointEvidence.checkpoints[0].commits = [fixture.mainCommit]; + resign(wrongCommit); + await ledger.write(valid.path, wrongCommit, { expectedVersion: await ledger.version() }); + assert.equal(await latestValidationResult(ledger, valid.result.revision.headCommit), null); + const legacy = structuredClone(valid.result); + legacy.schemaVersion = "tabellio-validation-result/v0.2"; + delete legacy.kind; + delete legacy.checkpointEvidence; + legacy.runner = { id: legacy.runner.id, runtime: legacy.runner.runtime }; + resign(legacy); + validateValidationResult(legacy); // Still readable for history, not eligible. + await ledger.write(valid.path, legacy, { expectedVersion: await ledger.version() }); + assert.equal(await latestValidationResult(ledger, valid.result.revision.headCommit), null); +}); + +function resign(result) { + const { integrity: _integrity, ...unsigned } = result; + result.integrity.digest = digestObject(unsigned); +} diff --git a/tests/validation-cli.test.mjs b/tests/validation-cli.test.mjs index aae63a8e..f56e06bd 100644 --- a/tests/validation-cli.test.mjs +++ b/tests/validation-cli.test.mjs @@ -1,3 +1,4 @@ +import { installEntireFixture, fixtureCheckpoint } from "./helpers/entire-fixture.mjs"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { mkdir, readdir, writeFile } from "node:fs/promises"; @@ -33,10 +34,12 @@ test("validation gate binds squash-merge execution to the pre-merge checkpoint r const workspaceRoot = join(fixture.root, "SquashValidationRoot"); await mkdir(workspaceRoot); const value = manifest([process.execPath, "-e", "process.exit(0)"]); + value.requireEntireCheckpoint = true; + await installEntireFixture(t, fixture.root, { abcdef123456: fixtureCheckpoint("abcdef123456") }); await writeFile(`${fixture.seed}/tabellio.validation.json`, `${JSON.stringify(value, null, 2)}\n`); await runGit({ args: ["add", "tabellio.validation.json"], cwd: fixture.seed }); await runGit({ - args: ["commit", "-m", "Checkpoint pull-request head", "-m", "Entire-Checkpoint: checkpointed-pr-head"], + args: ["commit", "-m", "Checkpoint pull-request head", "-m", "Entire-Checkpoint: abcdef123456"], cwd: fixture.seed, env: identityEnv(), }); @@ -55,7 +58,7 @@ test("validation gate binds squash-merge execution to the pre-merge checkpoint r assert.equal(result.exitCode, 0); assert.equal(result.output.result.revision.headCommit, mergedHead); assert.equal(result.output.result.checkpointRevision.headCommit, checkpointHead); - assert.deepEqual(result.output.result.checkpoints, ["checkpointed-pr-head"]); + assert.deepEqual(result.output.result.checkpoints, ["abcdef123456"]); assert.deepEqual(await readdir(workspaceRoot), []); }); diff --git a/tests/validation-runner.test.mjs b/tests/validation-runner.test.mjs index bb30c9c5..72e497bb 100644 --- a/tests/validation-runner.test.mjs +++ b/tests/validation-runner.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; -import { chmod, mkdir, readdir, realpath, stat, symlink, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { chmod, mkdir, readFile, readdir, realpath, stat, symlink, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; import test from "node:test"; import { GitJsonLedger } from "../scripts/lib/git-json-ledger.mjs"; @@ -15,7 +15,15 @@ import { validateValidatorEvidence, } from "../scripts/lib/validation-runner.mjs"; import { NativeGitStore } from "../scripts/providers/native-git-store.mjs"; -import { createFeatureFixture, identityEnv } from "./helpers/git-fixture.mjs"; +import { createFeatureFixture as createGitFeatureFixture, identityEnv } from "./helpers/git-fixture.mjs"; + +import { createValidationRunner } from "./helpers/validation-fixture.mjs"; +import { installEntireFixture, fixtureCheckpoint, fixtureCheckpointId } from "./helpers/entire-fixture.mjs"; +async function createFeatureFixture(t) { + const fixture = await createGitFeatureFixture(t); + await installEntireFixture(t, fixture.root); + return fixture; +} test("validation runner executes exact committed manifests and stores bounded results", async (t) => { const previousArtifactBaseUri = process.env.TABELLIO_ARTIFACT_BASE_URI; @@ -44,9 +52,7 @@ test("validation runner executes exact committed manifests and stores bounded re await commit(fixture.seed, "Add passing validation", "validation-pass"); const passingHead = await head(fixture.seed); - const store = await NativeGitStore.open(fixture.seed); - const ledger = await GitJsonLedger.open({ repoPath: fixture.seed, ref: "refs/tabellio/validations" }); - const runner = new ValidationRunner({ store, ledger }); + const { store, ledger, runner } = await createValidationRunner(fixture.seed); const repositoryId = await repositoryIdentity(store, "example/repository"); const passed = await runner.run({ repositoryId, @@ -56,7 +62,7 @@ test("validation runner executes exact committed manifests and stores bounded re now: new Date("2026-07-10T20:00:00.000Z"), }); assert.equal(passed.result.status, "passed"); - assert.deepEqual(passed.result.checkpoints, ["validation-pass"]); + assert.deepEqual(passed.result.checkpoints, [fixtureCheckpointId("validation-pass")]); assert.equal(passed.result.commands[0].stdout.bytes, 20000); assert.equal(passed.result.commands[0].stdout.truncated, true); assert.equal(Buffer.byteLength(passed.result.commands[0].stdout.tail), 16 * 1024); @@ -176,7 +182,7 @@ test("validation runner executes exact committed manifests and stores bounded re assert.equal(failed.result.status, "failed"); assert.equal(failed.result.commands[0].exitCode, 3); assert.equal(failed.result.commands[1].status, "skipped"); - assert.deepEqual(failed.result.checkpoints, ["validation-fail", "validation-pass"]); + assert.deepEqual(failed.result.checkpoints, [fixtureCheckpointId("validation-fail"), fixtureCheckpointId("validation-pass")].sort()); const worktrees = await runGit({ args: ["worktree", "list", "--porcelain"], cwd: fixture.seed }); assert.equal(worktrees.stdout.includes(passed.result.runId), false); @@ -309,9 +315,7 @@ test("validation manifest rejects shell-like ambiguity and missing checkpoint ra ]), null, 2)); await runGit({ args: ["add", "tabellio.validation.json"], cwd: fixture.seed }); await runGit({ args: ["commit", "-m", "Manifest without checkpoint"], cwd: fixture.seed, env: identityEnv() }); - const store = await NativeGitStore.open(fixture.seed); - const ledger = await GitJsonLedger.open({ repoPath: fixture.seed, ref: "refs/tabellio/validations" }); - const runner = new ValidationRunner({ store, ledger }); + const { runner } = await createValidationRunner(fixture.seed); await assert.rejects( runner.run({ repositoryId: "example/repository", commit: "HEAD", base: "main" }), /has no Entire checkpoint/, @@ -325,9 +329,7 @@ test("validation runner terminates timed-out commands and skips remaining fail-f command("must-skip", [process.execPath, "-e", "process.exit(0)"]), ]), null, 2)); await commit(fixture.seed, "Add timeout validation", "validation-timeout"); - const store = await NativeGitStore.open(fixture.seed); - const ledger = await GitJsonLedger.open({ repoPath: fixture.seed, ref: "refs/tabellio/validations" }); - const runner = new ValidationRunner({ store, ledger }); + const { runner } = await createValidationRunner(fixture.seed); const started = Date.now(); const result = await runner.run({ repositoryId: "example/repository", commit: "HEAD", base: "main" }); assert.equal(result.result.status, "failed"); @@ -344,6 +346,7 @@ test("identity-required manifests reject legacy evidence without breaking legacy typedValidator("static-checks", "static", [process.execPath, "-e", "process.exit(0)"], null), ], ["static"]); definition.requireRunnerIdentity = requireRunnerIdentity; + definition.requireEntireCheckpoint = false; // Isolate legacy runner-identity compatibility. await writeFile(`${fixture.seed}/tabellio.validation.json`, JSON.stringify(definition)); await commit(fixture.seed, "identity-requirement", "identity-requirement"); const { store, ledger, externalRoot } = await externalValidationHarness(fixture); @@ -352,6 +355,8 @@ test("identity-required manifests reject legacy evidence without breaking legacy assert.equal(current.result.status, "passed"); const legacy = structuredClone(current.result); legacy.schemaVersion = "tabellio-validation-result/v0.3"; + delete legacy.kind; + delete legacy.checkpointEvidence; legacy.runner = { id: legacy.runner.id, runtime: legacy.runner.runtime }; legacy.completedAt = new Date(Date.parse(legacy.completedAt) + 1000).toISOString(); const { integrity: _integrity, ...unsigned } = legacy; @@ -422,7 +427,7 @@ test("typed validators enforce semantic metrics and cost budgets with durable ev runnerId: "product-validator", }); - assert.equal(result.result.schemaVersion, "tabellio-validation-result/v0.4"); + assert.equal(result.result.schemaVersion, "tabellio-validation-result/v0.5"); assert.equal(result.result.runner.packageName, "@intelip/tabellio"); assert.equal(result.result.runner.packageVersion, "0.6.0"); assert.equal(result.result.runner.sourceCommit, "a".repeat(40)); @@ -479,9 +484,7 @@ test("typed validation distinguishes product failure from blocked evidence", asy ), ], ["semantic"]), null, 2)); await commit(fixture.seed, "Add failing semantic validation", "product-validation-fail"); - const store = await NativeGitStore.open(fixture.seed); - const ledger = await GitJsonLedger.open({ repoPath: fixture.seed, ref: "refs/tabellio/validations" }); - const runner = new ValidationRunner({ store, ledger }); + const { runner } = await createValidationRunner(fixture.seed); const failed = await runner.run({ repositoryId: "example/repository", commit: "HEAD", base: "main" }); assert.equal(failed.result.status, "failed"); assert.equal(failed.result.validators[0].status, "failed"); @@ -668,6 +671,11 @@ function evidenceCommand(path, evidence) { } async function commit(cwd, message, checkpoint) { + checkpoint = fixtureCheckpointId(checkpoint); + const path = join(dirname(cwd), "synthetic-entire-bin", "metadata.json"); + const entries = JSON.parse(await readFile(path, "utf8")); + entries[checkpoint] = fixtureCheckpoint(checkpoint); + await writeFile(path, JSON.stringify(entries)); await runGit({ args: ["add", "tabellio.validation.json"], cwd }); await runGit({ args: ["commit", "-m", message, "-m", `Entire-Checkpoint: ${checkpoint}`],