# Gate Trust Redesign — landed-history trust, per-worktree installs, owner lane, CI backstop

Repo root for this plan: the worktree you are launched in (run `git rev-parse --show-toplevel`).
All paths below are relative to that root. Branch: `wt/gate-trust-redesign`, base `origin/master` (6af98ed0d).

## Why

Today every commit in every worktree requires the staged copies of the three gate
sources to be byte-identical to the single shared install at
`$(git rev-parse --git-common-dir)/ipz-remote-gate-hooks/`. Any session that
reinstalls a different gate version deadlocks every other worktree, and the shared
mutable directory races mid-run ("trusted gate installation changed while the
remote gate ran"). This redesign keeps the protection and removes the coupling:

1. **Trust anchor = landed history.** An installed hook is trusted iff its blob
   byte-matches one of the last 5 landed versions of its source file on
   `refs/remotes/origin/master`. No comparison against the local index for
   ordinary commits.
2. **Gate-source edits are a privileged lane.** Only commits whose staged diff
   touches protected paths need extra proof: a single-use owner token bound to
   the exact staged tree.
3. **Content-addressed, immutable, per-worktree installs.** Versioned install
   dirs under the shared hooks dir; each worktree pins its own via
   worktree-scoped `core.hooksPath` (`extensions.worktreeConfig` is enabled).
   Nothing mutates under a running gate; concurrent versions coexist.
4. **CI backstop.** A GitHub Actions workflow re-runs the local checks
   server-side, where no local bypass reaches.

Threat model note (write code accordingly, don't over-reach): local hooks are
*advisory hardening* — a local user can always bypass them. Real enforcement is
the CI backstop. Local checks should therefore be robust and race-free, not
paranoid to the point of blocking legitimate work.

## Canonical trust helpers (MUST be byte-wise identical in all three gate files)

Every gate file (Tasks 1–3) embeds these constants and functions verbatim
(top-level, near its other helpers). Bash only (`#!/bin/bash` files).

```bash
TRUST_DEPTH=5
TRUST_REF="refs/remotes/origin/master"
GATE_PRECOMMIT_SRC=".dev-config/hooks/pre-commit"
GATE_REFERENCE_SRC=".dev-config/hooks/reference-transaction"
GATE_HELPER_SRC=".dev-config/bin/ipz-remote-php-gate"

# Print the blob OIDs of $1 across the last TRUST_DEPTH landed versions on TRUST_REF.
trusted_blobs_for_path() {
    local path=$1 commit blob
    git rev-list -n "$TRUST_DEPTH" "$TRUST_REF" -- "$path" 2>/dev/null |
    while read -r commit; do
        blob=$(git rev-parse -q --verify "$commit:$path" 2>/dev/null) || continue
        printf '%s\n' "$blob"
    done
}

# Installed file $1 must byte-match some landed version of source path $2.
installed_blob_is_trusted() {
    local active=$1 tracked=$2 active_oid blob matched=1
    active_oid=$(git hash-object --no-filters "$active" 2>/dev/null) || return 1
    while read -r blob; do
        [ -n "$blob" ] && [ "$active_oid" = "$blob" ] && matched=0
    done < <(trusted_blobs_for_path "$tracked")
    return "$matched"
}
```

`reference-transaction` runs with `$repo_root` resolved; it may wrap the git
calls as `git -C "$repo_root" ...` inside these two functions — that is the ONLY
allowed deviation, and it must be applied consistently to both functions.

## Install directory layout (shared by Tasks 1–4)

```
$(git rev-parse --git-common-dir)/ipz-remote-gate-hooks/           # top dir, mode 700, owned by user
    <version-id>/                                                  # mode 500 after populate
        pre-commit  reference-transaction  ipz-remote-php-gate     # each mode 500, not symlinks
```

- `<version-id>` = first 12 hex chars of
  `sha256( blob(pre-commit) "\n" blob(reference-transaction) "\n" blob(ipz-remote-php-gate) "\n" )`
  where `blob(x)` is the git blob OID of that source. Exact recipe:
  `printf '%s\n' "$b1" "$b2" "$b3" | sha256sum | cut -c1-12`.
- Worktree-scoped pin: `git config --worktree core.hooksPath "$top/<version-id>"`.
- **Legacy compatibility:** the flat layout (files directly in the top dir,
  top dir 700, files 500) remains accepted by all validators during migration.

A valid install location is therefore: after `realpath`, either exactly
`$common/ipz-remote-gate-hooks` (legacy) or exactly one directory level below it
(`$common/ipz-remote-gate-hooks/<version-id>` where `<version-id>` matches
`^[0-9a-f]{12}$`). Never accept deeper nesting, symlinked dirs, or other roots.
Dir mode must be 700 (legacy top) or 500 (version dir); files always 500,
regular, owner = current uid, `realpath(file)` = file.

## Privileged lane: protected paths + owner token (Tasks 1 and 4)

Protected path prefixes (define once as a bash array named `GATE_PROTECTED_PATHS`):

```bash
GATE_PROTECTED_PATHS=(
    ".dev-config/hooks/"
    ".dev-config/bin/ipz-remote-php-gate"
    ".dev-config/bin/install-ipz-gate"
    ".dev-config/bin/approve-gate-change"
    ".github/"
)
```

Token file: `$(git rev-parse --git-common-dir)/ipz-gate-owner-token`.
Valid iff: regular file, not a symlink, owner = current uid, mode 400,
exactly one line of the form `approve-gate-change <full-tree-oid>` where
`<full-tree-oid>` is the 40/64-hex OID of the staged tree (`git write-tree`).
Single-use: on the success path of pre-commit (see Task 1), the token is burned
(mv to a unique name, then rm — reuse the existing `burn_file` idiom).

## Tasks

Wave 1 tasks own strictly disjoint files. Every task: implement per spec, run
its acceptance commands, then commit ONLY its listed files with
`git -c core.hooksPath=/dev/null commit --no-verify` (sanctioned for this branch
only: the gate sources themselves are being rewritten, so the old installed gate
cannot validate them; the owner explicitly approved this lane for this work).

### Task 1 — `.dev-config/hooks/pre-commit`

File owned: `.dev-config/hooks/pre-commit` (modify).

Keep everything about the check pipeline (ESLint, remote PHP gate invocation,
trivy, composer audit, staged-tree stability re-check, claim invocation) intact.
Change only the trust/validation layer:

1. Embed the canonical trust helpers and `GATE_PROTECTED_PATHS` verbatim.
2. Replace `validate_installed_source` and the body of `resolve_trusted_install`:
   - Resolve `core.hooksPath` exactly as now (path config, relative→repo-root,
     reject symlink, realpath), but accept BOTH legacy flat and versioned
     layouts per "Install directory layout" above, setting `TRUSTED_INSTALL_DIR`
     to the accepted directory.
   - Keep the self-identity check: `realpath(BASH_SOURCE[0])` must equal
     `$TRUSTED_INSTALL_DIR/pre-commit`.
   - Validate each of the three installed files with the file-level checks
     (regular, not symlink, executable, realpath-identical, owner, mode 500)
     and then `installed_blob_is_trusted <installed> <source-path>` instead of
     any comparison against the index.
3. Add the privileged lane, after `INITIAL_TREE` is recorded:
   - Discover staged paths touching `GATE_PROTECTED_PATHS` (use
     `git diff --cached -z --name-only --diff-filter=ACMRTD -- "${GATE_PROTECTED_PATHS[@]}"`;
     note deletions count).
   - If none: no token logic runs at all.
   - If any: load and validate the token per spec against `$INITIAL_TREE`.
     Invalid/absent token ⇒
     `fail 'Gate-source changes require owner approval: run .dev-config/bin/approve-gate-change'`.
     Valid token ⇒ record that it must be burned; burn it (burn_file idiom)
     immediately before the final `exit 0` success path (NOT on failure paths,
     so a failed ESLint run doesn't consume the owner's approval).
4. Remove the now-dead staged-vs-installed failure branch; keep
   `TRUSTED_INSTALL_READY` semantics (trust resolution failure still fails the
   commit and blocks claiming).

Acceptance (run from repo root, report real output):
```
bash -n .dev-config/hooks/pre-commit
grep -c 'installed_blob_is_trusted' .dev-config/hooks/pre-commit   # >= 4 (def + 3 calls)
grep -c 'ipz-gate-owner-token' .dev-config/hooks/pre-commit        # >= 1
```
Commit message: `pre-commit: trust landed gate versions and add owner lane`

### Task 2 — `.dev-config/hooks/reference-transaction`

File owned: `.dev-config/hooks/reference-transaction` (modify).

Keep the entire claim state machine (prepared/committed/aborted, inflight,
burn, lock) unchanged. Change only trust checks:

1. Embed the canonical trust helpers verbatim (the `git -C "$repo_root"`
   deviation is allowed here; `TRUST_*`/`GATE_*_SRC` constants replace the
   existing `PRECOMMIT_PATH`/`GATE_PATH`/`REFERENCE_SOURCE_PATH` trio — keep
   variable names consistent with what the helpers expect).
2. In `active_reference_hook_hash`: accept legacy-or-versioned install dirs per
   the layout spec (replace the strict `[ "$hooks_path" = "$expected_path" ]`
   with the two accepted shapes; dir mode 700 for legacy top, 500 for a version
   dir). Everything else (file checks, self-identity, sha256) stays.
3. In `claim_is_current`: replace the three `installed_source_matches_tree`
   calls with `installed_blob_is_trusted` against the three source paths.
   Delete `installed_source_matches_tree` (now unused). `CLAIM_TREE` is still
   used by `validate_update` — do not touch that.
4. No token logic in this file.

Acceptance:
```
bash -n .dev-config/hooks/reference-transaction
grep -c 'installed_blob_is_trusted' .dev-config/hooks/reference-transaction  # >= 4
grep -c 'installed_source_matches_tree' .dev-config/hooks/reference-transaction  # == 0
```
Commit message: `reference-transaction: trust landed gate versions`

### Task 3 — `.dev-config/bin/ipz-remote-php-gate`

File owned: `.dev-config/bin/ipz-remote-php-gate` (modify).

Read the file first; it is ~1200 lines. Locate its trusted-install resolution
and any logic that compares installed hook blobs against the index/staged tree
(same pattern as pre-commit's `resolve_trusted_install`/`validate_installed_source`,
possibly under different names), including the re-check that produces
"trusted gate installation changed while the remote gate ran".

1. Embed the canonical trust helpers verbatim.
2. Accept legacy-or-versioned install dirs per the layout spec wherever the
   install path is validated.
3. Replace installed-vs-staged blob comparisons with
   `installed_blob_is_trusted` against the landed sources. The mid-run
   stability re-check may remain, but compare the install against a snapshot
   taken at start (hashes recorded at startup), not against the index.
4. Do NOT change: remote execution, PHPCS/PHPStan/slopgate logic, claim
   writing/format (the claim file fields and `contract=` binding stay exactly
   as they are), registry/server selection.

Acceptance:
```
bash -n .dev-config/bin/ipz-remote-php-gate
grep -c 'installed_blob_is_trusted' .dev-config/bin/ipz-remote-php-gate  # >= 2
```
Commit message: `ipz-remote-php-gate: trust landed gate versions`

### Task 4 — installer + approver (new files)

Files owned (create, mode 755 in git — commit with executable bit):
- `.dev-config/bin/install-ipz-gate`
- `.dev-config/bin/approve-gate-change`

`install-ipz-gate` (bash, `set -u`):
1. Resolve `common=$(realpath "$(git rev-parse --git-common-dir)")` and repo root.
2. Source of truth is `refs/remotes/origin/master` (never the working tree):
   read the three blob OIDs `git rev-parse "$TRUST_REF:<path>"`; with flag
   `--from-head` use `HEAD:<path>` instead and print a loud warning (bootstrap/dev
   only). Fail clearly if the ref or any path is missing.
3. Compute `<version-id>` per the layout spec.
4. Ensure top dir exists, owner-only: `mkdir -p`, `chmod 700`.
5. If `$top/<version-id>` already exists, reuse it (do not touch it). Otherwise
   materialize into `mktemp -d` under the top dir: `git cat-file blob <oid>` into
   each of the three filenames, `chmod 500` each file, `chmod 500` the dir, then
   atomic `mv -T "$tmp" "$top/<version-id>"`; on mv failure because it appeared
   concurrently, discard tmp and reuse the existing dir.
6. `git config extensions.worktreeConfig true` (idempotent), then
   `git config --worktree core.hooksPath "$top/<version-id>"`.
7. Print: version id, install path, and which worktree got pinned.
8. `--gc` flag (optional, may be a stub that prints "not implemented").

`approve-gate-change` (bash, `set -u`):
1. Refuse to run unless stdin is a TTY (`[ -t 0 ]`) UNLESS `--force` is given —
   prints that this is an owner-only action.
2. Compute the staged tree: `tree=$(git write-tree)`. Refuse (with message) if
   `git diff --cached --quiet` reports nothing staged.
3. Write `$common/ipz-gate-owner-token` (umask 077, then `chmod 400`) with the
   single line `approve-gate-change $tree`. Overwrite any existing token
   (rm -f first; it is mode 400).
4. Print the tree OID and remind that the token is single-use and consumed by
   the next successful pre-commit run.

Acceptance:
```
bash -n .dev-config/bin/install-ipz-gate
bash -n .dev-config/bin/approve-gate-change
test -x .dev-config/bin/install-ipz-gate && test -x .dev-config/bin/approve-gate-change && echo EXEC-OK
```
Commit message: `Add versioned gate installer and owner approval helper`

### Task 5 — CI backstop (new file)

File owned (create): `.github/workflows/ipz-gate.yml`

GitHub Actions workflow, `on: [push, pull_request]`, three independent jobs:

1. `protected-paths`: checkout with `fetch-depth: 0`; compute the changed-file
   list for the event (push: `github.event.before...github.sha`, guarding the
   all-zeros before SHA on new branches by falling back to `HEAD~1...HEAD` when
   `github.event.before` is `0000000000000000000000000000000000000000`;
   pull_request: `origin/${{ github.base_ref }}...HEAD`). If any changed path
   starts with `.dev-config/hooks/`, equals `.dev-config/bin/ipz-remote-php-gate`,
   `.dev-config/bin/install-ipz-gate`, `.dev-config/bin/approve-gate-change`,
   or starts with `.github/`, then require the HEAD commit message to contain a
   line matching `^Gate-Change-Approved: yes$`; fail otherwise with a message
   explaining the trailer. No changed protected paths ⇒ pass.
2. `eslint`: setup-node 20, `npm ci --prefix .dev-config`
   (its package.json/package-lock.json exist), then run
   `.dev-config/bin/lint --max-warnings 9999 -- <changed js/ts files>` computed
   the same way; skip cleanly when none changed. If `.dev-config/bin/lint`
   turns out to require anything unavailable in CI, fall back to
   `npx --prefix .dev-config eslint -c .dev-config/eslint.config.mjs <files>`.
3. `php-audit`: setup-php 8.2 with composer; `php -l` every changed `.php`
   file (skip cleanly when none); if `plugins/international-press-zone/composer.lock`
   changed or exists, run `composer audit --locked --working-dir=plugins/international-press-zone`
   (continue-on-error: false).

Do not attempt to reach the remote PHPCS/PHPStan servers — they are private
(Tailscale-only). This workflow is a backstop, not a replica.

Acceptance (yamllint may not exist; use python):
```
python3 -c "import yaml,sys; yaml.safe_load(open('.github/workflows/ipz-gate.yml')); print('YAML-OK')"
```
Commit message must END with the trailer line `Gate-Change-Approved: yes` (this
workflow protects `.github/` itself):
`Add CI gate backstop workflow` + blank line + `Gate-Change-Approved: yes`

### Task 6 (Wave 2) — integration test harness

File owned (create): `.dev-config/tests/gate-trust.test.sh` (mode 755).

A self-contained test that exercises Tasks 1–4 together in throwaway repos.
Structure: `set -u`, a `TMPROOT=$(mktemp -d)` with trap cleanup, helper
`expect_ok` / `expect_fail` wrappers, numbered scenarios, `PASS/FAIL` per
scenario, non-zero exit on any FAIL.

Setup per scenario run:
1. Create bare repo `$TMPROOT/origin.git`; clone to `$TMPROOT/work`.
2. Copy the CURRENT worktree's `.dev-config/` (hooks, bin, at minimum the five
   gate/installer files plus `bin/lint` stub — create a stub `lint` that exits 0
   to keep pre-commit's ESLint leg quiet) into the clone; commit to master;
   `git push origin master`; `git fetch origin` so `refs/remotes/origin/master`
   exists.
3. Run `.dev-config/bin/install-ipz-gate` inside the clone.

Scenarios:
- **S1 normal commit passes**: modify a non-protected file (e.g. `README.test`),
  stage, `git commit` → must succeed via the installed hooks (no bypass).
- **S2 gate-source edit blocked without token**: modify
  `.dev-config/hooks/pre-commit` (append a comment), stage, `git commit` → must
  FAIL mentioning owner approval.
- **S3 gate-source edit passes with token**: same staged state, run
  `.dev-config/bin/approve-gate-change --force`, `git commit` → must succeed;
  then assert the token file is gone (burned) and a SECOND identical commit
  attempt without a new token fails again.
- **S4 untrusted install blocked**: hand-craft a version dir whose `pre-commit`
  has one extra byte (not matching any landed blob), pin `core.hooksPath` to
  it (chmod as the layout requires), stage a trivial change, `git commit` →
  must FAIL on trust resolution.
- **S5 concurrent versions coexist**: create a second worktree of the clone,
  run the installer there; both worktrees commit trivial changes successfully
  and `git config --worktree core.hooksPath` may differ between them without
  either failing.

Caveats to honor: the pre-commit script invokes the remote helper
(`ipz-remote-php-gate`) only when trust resolves; in the sandbox the helper's
remote legs must not fire — S1–S5 must stage only files OUTSIDE
`plugins/international-press-zone/` so pre-commit takes the `--claim-only`
path. If `--claim-only` itself needs network, replace the installed helper in
the SANDBOX ONLY by committing (in the sandbox repo's history, before install)
a helper stub that writes a syntactically valid claim the reference-transaction
hook accepts — the stub then IS the landed trusted version inside the sandbox,
which is exactly the property under test. Prefer the real helper if it works
offline.

Also run the existing `.dev-config/tests/ipz-remote-php-gate.test.sh` once; if
it requires the private remote servers, record that as SKIP with the exact
error, not FAIL.

Acceptance:
```
bash -n .dev-config/tests/gate-trust.test.sh
bash .dev-config/tests/gate-trust.test.sh   # full output; exit 0
```
Commit message: `Add gate trust integration tests`

## Landing (owner/orchestrator, not agents)

After Wave 2 review passes: merge or rebase onto current origin/master, push
branch, land. The landing commit itself touches protected paths, so its message
carries `Gate-Change-Approved: yes`. After landing: `git fetch`, run
`.dev-config/bin/install-ipz-gate` in each active worktree, and delete the
legacy flat install files once no worktree still pins the flat layout.
