---
name: gdpr-gate
description: "This skill should be used when auditing diffs or plans for GDPR/CCPA/HIPAA compliance gaps."
---

# GDPR / CCPA / HIPAA pre-generation gate

`gdpr-gate` is an **advisory** code-level gate that fires inline during `/soleur:plan` Phase 2.7 and at `/soleur:work` Phase 2 exit. It scans plan prose, schema migrations, and diffs for regulated-data gaps under GDPR (Articles 5/6/9/17/20/25/30/32/33/35), with secondary coverage for CCPA / CPRA and HIPAA. It never blocks. Critical findings (Article 9 special-category data) prompt operator acknowledgment + GitHub issue creation; the gate never auto-writes to `compliance-posture.md`.

This is **not legal review**. Output is a heuristic, machine-generated checklist meant to compress regulator-shaped surprises out of the normal feature-design loop. Consult the `clo` agent and `legal-compliance-auditor` before merging anything load-bearing.

## When to invoke

- **Plan time** — automatic at `/soleur:plan` Phase 2.7 when the plan touches regulated-data surfaces (per `hr-gdpr-gate-on-regulated-data-surfaces` canonical regex).
- **Work time** — automatic at end of `/soleur:work` Phase 2, single pass after all per-task RED/GREEN/REFACTOR loops complete (token budget ≤4k per invocation).
- **Manual** — `/soleur:gdpr-gate "<scope>"` where `<scope>` is a file path, glob, or one-line description.
- **Manual repo-scan** — `/soleur:gdpr-gate --repo-scan` runs the gate against the whole working tree (operator-initiated only). Defenses, batching, and output contract documented in `## --repo-scan mode` below.
- **Hook (advisory only)** — `lefthook.yml` `gdpr-gate-advisory` prints a stderr breadcrumb when staged paths match the canonical regex. The hook always exits 0 — it never blocks the commit. Blocking enforcement lives in `/soleur:ship` Phase 5.5 (post-PR).

The gate is **read-only with respect to the canonical `/soleur:plan` template**. It audits the plan; it never injects its own checklist into the plan body. Architectural invariant per ADR-026.

## Disclaimer (always first)

Every gate output begins, as the first non-blank line, with the literal:

```
**This is not legal review. Findings are heuristic. Consult `clo` + `legal-compliance-auditor` before merging.**
```

The disclaimer is hardcoded. A test in `plugins/soleur/test/gdpr-gate.test.ts` asserts the literal appears as the first non-blank line of every fixture output.

## --repo-scan mode

`/soleur:gdpr-gate --repo-scan` runs the gate against the whole working tree. Operator-initiated only — never auto-fires from `/soleur:plan`, `/soleur:work`, or lefthook. Token budget remains ≤4k per Haiku call (ADR-026 TR3) via 25-files-per-batch fan-out.

**Sole-arg sentinel.** When invoked with `$ARGUMENTS`, trim leading/trailing whitespace (including `\t` and `\n`). If the trimmed value equals **exactly** `--repo-scan` (no spaces, no quotes, no additional tokens), enter repo-scan mode. Any other value — including `--repo-scan apps/web-platform`, `repo scan`, or `"--repo-scan section of the repo"` — falls through to the v1 scope-string mode and is forwarded verbatim to the prompt.

**File source.** `git ls-files -c -o --exclude-standard` (cached + untracked, respecting `.gitignore`). Submodules excluded; symlinks not followed. Pre-filtered through the canonical regex (see `## Path globs (canonical)` below) so only regulated-data paths enter the candidate pool.

**Deny-list (D1).** Single source of truth: [scripts/path-denylist.txt](./scripts/path-denylist.txt). Line-oriented file with extended-regex patterns (one per line; `#` for comments). Bash `[[ =~ ]]` semantics. Any candidate path matching at least one deny-list pattern is excluded from the scan corpus and a `# blocked: <path>` line is emitted to stderr (audit trail).

**Allow-list bypass (D3).** `GDPR_GATE_REPO_SCAN_ALLOW_PATHS=path1:path2:...` — colon-separated literal paths (no globs accepted; bash strict equality after split). Default unset → all deny-list patterns enforced. Each entry must satisfy two clauses or the script exits 1: (1) the path must match at least one deny pattern (otherwise the bypass is meaningless), and (2) the path must exist in `git ls-files -c -o --exclude-standard` (otherwise the operator probably typo'd the path; see Sharp edges).

**CI refusal (D3).** If both `$CI` and `$GDPR_GATE_REPO_SCAN_ALLOW_PATHS` are set, the script exits 1 with `allow-list bypass refused in CI environment`. Operator-only by construction — runbooks that set the bypass var get caught at the script boundary, not by docs alone.

**Batching.** 25 files per Haiku call. The dispatching agent collects per-batch outputs, dedups by `(check_id, path, line)`, and summarises inline.

**Inline-only output (D4).** Findings emitted to stdout / conversation only. The repo-scan walker NEVER writes to `compliance-posture.md`, `__goldens__/`, `test/fixtures/`, or any disk path under `~/.claude/`. The v1 critical-finding flow (operator-acknowledged write to `compliance-posture.md` Active Items + GitHub `compliance/critical` label) still applies for Art. 9 column-name matches discovered during repo-scan.

**Schema-only invariant (D5).** The repo-scan prompt template inherits v1's `DO NOT INCLUDE COLUMN VALUES` directive verbatim. The directive is the load-bearing assertion in `plugins/soleur/test/gdpr-gate.test.ts`.

**Canonical-regex source-of-truth.** [scripts/repo-scan.sh](./scripts/repo-scan.sh) extracts the canonical regex from this file's `## Path globs (canonical)` heading via `awk` at runtime — it does NOT redefine the regex. Editors of SKILL.md must keep that heading and the first fenced regex line stable; if extraction fails the script exits 1 with `canonical regex not found in SKILL.md`. Asserted by AC-PARITY-1 and `gdpr-gate-repo-scan.test.ts`.

## Path globs (canonical)

The single source of truth for "what counts as a regulated-data path" is the regex below. `lefthook.yml` mirrors it verbatim; the skill's prompt template references it; `AGENTS.md` `hr-gdpr-gate-on-regulated-data-surfaces` cites it.

```
^(apps/web-platform/supabase/migrations/|apps/web-platform/lib/auth/|apps/web-platform/server/.*auth.*\.(ts|tsx|js)|apps/web-platform/app/api/.*\.(ts|tsx)$|.*\.sql$)
```

Each component matches at least one file in the current repo (verified at plan time, 2026-05-10). `forms/**` and `**/*.prisma` from earlier drafts are dropped — both matched zero paths in this repo and a glob that matches nothing is a workflow violation per `hr-when-a-plan-specifies-relative-paths-e-g`.

## 5 mandatory v1 checks (FR4)

Each check has a stable `check_id` for cross-referencing in `compliance-posture.md` and GitHub issue labels.

| `check_id` | Article | Trigger | Default severity |
|---|---|---|---|
| `GDPR-Art-6` | Art. 6 lawful basis | New schema column without `-- LAWFUL_BASIS: <basis>` annotation | `Important` |
| `GDPR-Art-5e` | Art. 5(1)(e) retention | New PII table without retention metadata | `Important` |
| `GDPR-Art-17` | Art. 17 erasure | New FK to `users` without `ON DELETE CASCADE` or anonymisation migration | `Important` |
| `GDPR-Art-17-caller` | Art. 17 erasure (caller-site) | Diff adds an `ON DELETE RESTRICT` FK to `public.users` AND an `anonymise_*` RPC, AND `git grep -n '<rpc_name>'` returns zero matches outside `supabase/migrations/`, `knowledge-base/`, and plan files | `Important` (see [[2026-05-16-migration-mandates-must-have-wired-call-sites-in-same-pr]]) |
| `GDPR-Chapter-V` | Art. 44–49 cross-border | New non-EEA vendor env var / SDK without `compliance-posture.md` Vendor DPA row | `Important` |
| `GDPR-Art-9` | Art. 9 special-category | New column matching the Art. 9 list in [fields.md](./references/fields.md) | **`Critical`** |

**Critical is reserved for Art. 9 column-name matches in v1.** Demoting the other four to `Important` keeps the noise floor low and preserves `compliance/critical` issue labels as a load-bearing signal. See [non-negotiables.md](./references/non-negotiables.md) for the regulatory rationale.

Reference layers:

**Active layers (with `check_id` markers):**

- [layers/api-layer.md](./references/layers/api-layer.md) — 7 API-layer checks (AP-01..AP-07).
- [layers/data-in-transit.md](./references/layers/data-in-transit.md) — Transit checks T-01..T-06 + DT-EU-CB Chapter V cross-border.
- [layers/data-lifecycle.md](./references/layers/data-lifecycle.md) — Lifecycle checks DL-01..DL-06; DL-04 covers Art. 20 portability.
- [layers/auth-sessions.md](./references/layers/auth-sessions.md) — Auth & session checks A-01..A-07 + Art. 32(1)(b) confidentiality footer.
- [layers/frontend.md](./references/layers/frontend.md) — Frontend checks F-01..F-06 + ePrivacy/TTDSG strict-opt-in footer.
- [layers/testing-seeding.md](./references/layers/testing-seeding.md) — Test fixture + seed-data checks TS-01..TS-05 + Art. 32 pseudonymization footer.
- [legal-consent.md](./references/legal-consent.md) — Layer-shaped LC-01..LC-05 ePrivacy + GDPR Art. 7/13/14/35 (Soleur-authored — promoted from prose at v2; see NOTICE; v1 prose preserved at [legacy/legal-consent-v1-prose.md](./references/legacy/legal-consent-v1-prose.md) until v3).

**Reference catalogues (no `check_id` markers — consulted by other layers):**

- [fields.md](./references/fields.md) — PII field catalogue + Art. 9 special-category extension.
- [leakage-vectors.md](./references/leakage-vectors.md) — PII vector catalogue (verbatim from upstream).
- [non-negotiables.md](./references/non-negotiables.md) — GDPR-first regulatory framing.

The 5 mandatory v1 checks (FR4 above) fire on every gate invocation. `--repo-scan` (see `## --repo-scan mode` above) additionally fires every layer-id check across all 7 active layers.

## Output format

Each finding follows this schema:

```markdown
**This is not legal review. Findings are heuristic. Consult `clo` + `legal-compliance-auditor` before merging.**

### `<check_id>` — <one-line title>

**Severity:** <Critical | Important | Suggestion>
**Article:** <regulation citation>
**Location:** <file path:line range, or "plan section <header>">
**Pattern matched:** <verbatim regex/keyword that fired>
**Why this matters:** <one short paragraph>
**What to do:** <concrete next action — annotate, add migration, file vendor DPA row, etc.>
```

Severity levels:

- `Critical` — reserved for Art. 9 (special-category) column-name matches in v1. Triggers the operator-acknowledgment escalation flow.
- `Important` — Art. 6 / Art. 5(1)(e) / Art. 17 / Chapter V findings. Logs to console; operator may file a `compliance/improvement` issue at their discretion.
- `Suggestion` — DPIA reminder, Art. 7 consent UX hint, ePrivacy banner alignment, etc. Read-only.

## Critical-finding escalation flow (FR5)

When a `Critical` finding fires, the gate emits this block at the end of the report:

```
─────────────────────────────────────────────────────────────────
CRITICAL FINDING — operator acknowledgment required
─────────────────────────────────────────────────────────────────

A `Critical` finding (`check_id: GDPR-Art-9`) requires an Active Items row in
`knowledge-base/legal/compliance-posture.md`. The gate does NOT auto-write the
row.

Run, in order:

  1. gh issue create \
       --title "<title summarising the finding>" \
       --label compliance/critical,domain/legal \
       --body "<finding text + link to PR>"

  2. Edit knowledge-base/legal/compliance-posture.md and append a row to the
     Active Compliance Items table. The canonical schema is:

       | Item                 | Issue     | Status | Deadline | Notes                              |
       | <one-line summary>   | #<issue>  | OPEN   | -        | <check_id>; <date>; <context>      |

     `<check_id>` is the gdpr-gate finding identifier (e.g., GDPR-Art-9). Date
     and remediation context belong in Notes — the table itself uses 5 fixed
     columns.

  3. git add knowledge-base/legal/compliance-posture.md
     git commit -m "compliance: register Art. 9 finding for #<issue>"

The gate exits after printing this block. It never modifies disk for you.
```

This flow is operator-driven by design — the gate is `read-only` against `compliance-posture.md`. Auto-writing would silently aggregate compliance gaps into a single repo file, defeating the human-accountability rationale (CLO assessment).

## Prompt template — what the gate sends to the model

The gate's reasoning step receives the following system prompt structure. The schema-only invariant is **DO NOT INCLUDE COLUMN VALUES** — the gate sends column NAMES only, never row values.

```
You are gdpr-gate, an advisory compliance auditor.

Your input is:
1. A diff or plan excerpt (no row values).
2. The list of regulated-data paths (per the canonical regex above).
3. The five v1 check definitions below.

DO NOT INCLUDE COLUMN VALUES in the response. Send column NAMES only —
schema-shape data, never live row payload. The user's PII is the subject of
the audit; sending values would be a Chapter V transfer of the very data we
are auditing.

For each check:
  - Identify pattern matches.
  - Emit a finding using the Output format above.
  - Reserve `Critical` for Art. 9 column-name matches.
```

The `DO NOT INCLUDE COLUMN VALUES` directive is a verbatim string assertion in `plugins/soleur/test/gdpr-gate.test.ts` — moving or paraphrasing it breaks the test by design.

## First-run on existing codebase

Existing migrations 001–040 in this repo do NOT use `-- LAWFUL_BASIS: <basis>` annotations. A backfill audit will fire `Important` (`GDPR-Art-6`) findings on every column that lacks one. **This is intentional** — backfill audits surface existing gaps, and the demotion of Art. 6 to `Important` keeps the noise from training operators to dismiss `Critical`. Operators may suppress backfill noise by scoping the gate to a specific diff (`/soleur:gdpr-gate "git diff main...HEAD"`) rather than a full repo scan.

## Future severity changes

Findings carry a stable severity per the FR4 table. If a future version introduces a new severity (e.g., `Blocking`), three consumer patterns must be grepped per `cq-union-widening-grep-three-patterns`:

- `const _exhaustive: never` rails (safe — the build fails until the new variant is handled).
- `\.severity === "` if-ladders (silent drop).
- `\?\.severity === "` optional-chained ladders (silent drop).

Add new severities in this file's table FIRST, then run the three greps before writing implementation.

## Boundary with sibling agents

- **`data-integrity-guardian`** — migration safety + judgment-based PII review. The gate is deterministic pattern-matching; the guardian is judgment-based.
- **`security-sentinel`** — OWASP / CWE security-of-processing flaws. Art. 32 overlaps; sentinel handles the "how", gate handles the "what".
- **`clo`** — reads `compliance-posture.md`; the gate produces the row contract the CLO consumes. The gate never writes; the operator does.
- **`legal-audit`** / **`legal-generate`** — document-layer skills (privacy policy, DPA, terms). The gate is code-layer.

Canonical disambiguation prose lives in `plugins/soleur/skills/review/SKILL.md` §boundaries.

## Corpus freshness

The detection rules this gate uses are partly lifted from upstream
`gosprinto/compliance-skills` and pinned in [NOTICE](./NOTICE). `last-verified`
records the last time the pinned corpus was compared against upstream.

- **> 30 days** — the gate prints a staleness banner to stdout. Output stays
  advisory and the gate still exits 0, but a "no findings" result may be based
  on rules that predate a recently-patched detection pattern.
- **> 90 days** — the gate additionally prints `POSTURE_FAIL:`. See the next
  section.

Both thresholds are stable design parameters, not settings: they are not
relaxable without a published ADR.

`last-verified` is advanced by Soleur's weekly content-vendor-drift cron, and
only on a run that itself compared the complete upstream-derived registry and
found it clean against a healthy upstream. The conjuncts are stated canonically
in the content-vendoring policy (§6a) rather than restated here, because
restating them is how the two copies came to disagree. Two consequences are
worth having on this page:

- The scope is the **upstream-derived** records. Files written for this plugin
  carry their own pins and are checked at commit time, not by the attestation.
- The cron skips a write while the field is recently advanced, so its value
  moves in steps of roughly three weeks rather than weekly.

A missing NOTICE, an unparseable date, or a future date all resolve to `999`
days, which fires both banners; that is the fail-safe direction.

**Do not edit `last-verified` by hand.** An earlier version of this section
suggested exactly that, which is the one action the governing ADR (ADR-203)
explicitly rejects: a hand-written value asserts a comparison that no artifact
records, and on an installed plugin it is overwritten by the next update
anyway. If the banner is firing on an installed plugin, the corpus is refreshed
upstream and the fix is to update the plugin.

## POSTURE_FAIL operator chain

**If you are running an installed copy of this plugin** (you are not developing
Soleur itself), the short version is: the gate is advisory, it exits 0, and it
is telling you the detection corpus shipped with your copy is old. Nothing in
your repository is wrong and there is no action to take in it. Update the
plugin to pick up a refreshed corpus. The numbered chain below is for
contributors working in the Soleur repository — its steps reference registers
and internal endpoints that only exist there.

**In the Soleur repository**, when the gate emits a `POSTURE_FAIL:` line:

1. **Do not pause the current PR.** The gate is advisory and exits 0. The
   staleness signal is a separate work cycle from whatever diff triggered it.
2. Open a tracking issue labelled `compliance/critical`, titled
   `[gdpr-gate] >90d stale rules — N days since last-verified`.
3. Append a row to the compliance posture register's Active Compliance Items,
   per that document's row schema. The gate never writes there itself: this is
   an operator-acknowledged write only.
4. Commit the row with `compliance: register vendor-pin-staleness for #<issue>`.
5. Drive a re-vendor: ping the in-flight `ci/content-vendor-drift-*` PR if one
   is open, or dispatch the cron manually via `/soleur:trigger-cron` with
   `cron/content-vendor-drift.manual-trigger`. `gh workflow run` cannot reach
   it — the job is an Inngest cron, not a GitHub Actions workflow.

The regulated-data PR that surfaced the banner can ship; the staleness
follow-up gets its own review and merge.

## Sharp edges

- **Scan-completion line** (#7710): the gate prints
  `gdpr-gate: path scan complete — N examined, M matched` to stdout on every
  invocation, unconditionally and after the match loop. It exists because
  "the scan ran and matched nothing" and "the scan never ran" were previously
  byte-identical — both produced no output at all — so on a stale corpus the
  gate printed two banners about its rule set and no evidence that any diff had
  been examined. It carries **counts only, never path names**: this line
  reaches a customer's terminal, and path structure is third-party-content
  adjacent (`hr-third-party-content-grep-on-undertaking`). The stderr
  breadcrumb still names paths for the local operator. The line deliberately
  contains neither `days stale` nor `POSTURE_FAIL`, so the evidence that a scan
  occurred is independent of the corpus's age.
- **The scan line cannot witness its own most likely failure.** If the
  `gdpr-gate-advisory` glob in `lefthook.yml` stops matching, lefthook prints
  `(skip)`, this script never executes, and no line of any kind is emitted —
  every in-script assertion stays green. That half is covered from outside by
  [gdpr-gate-glob-liveness.test.sh](../../test/gdpr-gate-glob-liveness.test.sh),
  which drives the real lefthook binary over the real `lefthook.yml` with a
  regulated path staged. This repo has shipped the dead-glob trap twice; gobwas
  `**` matches 1+ intermediate directories, never 0+.
- **The `vendor-pin-integrity` glob's `*.md` entry under the references directory covers the whole
  subtree**, because lefthook's gobwas `*` crosses `/`. Measured on lefthook
  2.1.6: `refs/*.md` matches both depth-1 and depth-2; `refs/**/*.md` matches
  depth-2 only (that is the `**` behaviour the repo's 2026-03-21 gobwas
  learning describes, and it does not generalise to `*`). So the `layers/`
  entry is redundant, and no per-subdirectory entry is needed. The
  symmetric-difference walk in
  [vendor-pin-integrity.test.sh](../../test/vendor-pin-integrity.test.sh) is
  still the backstop for registry membership — it reads the disk, not the
  glob — but it is not compensating for a coverage hole, because there is
  none.
- **Two registries, opposite provenance** (#7710): NOTICE frontmatter carries
  `lifted-files` (upstream-derived, MIT, pinned against goSprinto) and
  `soleur-authored` (written for this plugin, no upstream). Both are
  tamper-checked by `local-blob-sha`; only the former appears in
  `upstream-files`. A file must appear in exactly one, and the attribution
  header on line 1 is the oracle for which. Moving a file between them is a
  provenance falsification, not a bookkeeping change.
- Hook layer is **advisory only** (`exit 0`). Operators expecting `lefthook` to block will be surprised — the blocking enforcement is at `/soleur:ship` Phase 5.5, post-PR.
- The lefthook breadcrumb does NOT fire when lefthook itself is bypassed: `git commit --no-verify`, GitHub web-UI edits, fork PRs whose authors don't have lefthook installed, or agent commits on machines without `lefthook install`. In those paths the only enforcement is `/soleur:ship` Phase 5.5's critical-finding-acknowledgment gate (post-PR). Plan Phase 2.7 + work Phase 2 exit gates run regardless of lefthook because they live inside the skill, not the hook layer.
- The `*auth*` regex match is intentionally broad — false-positives on `auth-error.ts` etc. are accepted because output is advisory and cheap.
- Lifted upstream files are pinned to commit `7b58d68461cb1fc033a063e34cc9de63d0b4144b`. Upstream drift is governed by the content-vendoring policy at `knowledge-base/engineering/policies/content-vendoring.md`, the weekly content-vendor-drift cron (an Inngest function, `apps/web-platform/server/inngest/functions/cron-content-vendor-drift.ts` — it was `.github/workflows/scheduled-content-vendor-drift.yml` until the TR9 Phase-2 migration; the Sentry monitor slug keeps the old name), and the lefthook `vendor-pin-integrity` gate (silent-edit detection). NOTICE frontmatter (`upstream`, `pinned-commit`, `last-verified`, `lifted-files[]` with both `upstream-blob-sha` and `local-blob-sha`) is the canonical machine-readable form.
- **Runtime staleness banner** (FR6 / AC6a-d): the hook subshell-execs [notice-frontmatter.sh](./scripts/notice-frontmatter.sh) `days-stale` on every invocation. Days since `last-verified` >30d → STDOUT banner. Days >90d → additional `POSTURE_FAIL:` STDOUT line; the operator follows the chain in the policy doc to append a row to `compliance-posture.md`. NOTICE deletion / parser failure / future-dated `last-verified` all resolve to `days_stale=999` → banner fires. STDOUT (not stderr) is load-bearing — agent runtimes (Claude Code skill harness, MCP servers) frequently swallow stderr. Gate exits 0 in all paths; the staleness signal is advisory.
- **Trust-binding via cron-run timestamp** (issue #3535) — **CURRENTLY INERT, see #7255.** NOTICE `last-verified` is operator-controlled — a PR can rewrite it to today's date and suppress the 30d/90d banners. To defend against backdating, the gate also invokes `notice-frontmatter.sh cron-run-stale`, which calls `gh run list --workflow=scheduled-content-vendor-drift.yml --status=success --limit=1 --json updatedAt` and computes days since the last successful run; the staleness banner uses `MIN(notice-last-verified, cron-run-stale)`. **That workflow no longer exists** — the content-vendor-drift job moved to an Inngest cron (`apps/web-platform/server/inngest/functions/cron-content-vendor-drift.ts`), which produces no GitHub Actions run for `gh run list` to find. The query therefore matches nothing, falls through to 999, and the gate shows the operator-attested-mode banner on every invocation. The failure is in the fail-safe direction (999 forces attestation rather than falsely asserting freshness), which is why it went unnoticed — but **the anti-backdating defense this bullet describes is not currently operating**. Tracked in **#7255**; repointing at another workflow filename cannot fix it, because the job is not a workflow.
- **GH_TOKEN auth contract**: in GitHub Actions the token is auto-injected via `secrets.GITHUB_TOKEN`; in local lefthook runs it is sourced from `gh auth token` if the operator has `gh` configured; in subagent contexts (`/soleur:plan`, `/soleur:work` shells) the token is typically absent and the gate degrades to operator-attested mode (see next bullet).
- **MIN precedence and operator-attested-mode banner**: precedence is (cron+notice non-999 → MIN wins; if cron > notice the binding still defends future regressions) > (cron == 999, notice != 999 → operator-attested-mode banner + fallback to notice) > (both == 999 → existing 30d/90d banners fire). When no token is available the gate emits `ℹ gdpr-gate: operator-attested mode (no GH_TOKEN available — cron-run timestamp unverified, falling back to NOTICE last-verified)` to STDOUT — the documented degraded state.
- **CODEOWNERS pin on NOTICE** (#3535 defense-in-depth): `/plugins/soleur/skills/gdpr-gate/NOTICE` requires `@deruelle` review. Load-bearing only when branch protection is enforced on `main` (an operator follow-up); the cron-run-timestamp binding is the load-bearing defense.
- **Workflow-rename silent break — THIS ALREADY HAPPENED, and worse than a rename (#7255)**: `cron-run-stale` hard-codes `scheduled-content-vendor-drift.yml`, and the documented hazard was that renaming the workflow silently breaks the binding — `gh run list` returns empty, parser falls through to 999, gate degrades to operator-attested mode without surfacing the rename. The workflow was not renamed; it was **migrated out of GitHub Actions entirely**, so there is no filename to update the call sites to. The predicted silent degradation is the state the gate has been in ever since. Two lessons for the next binding of this shape: a liveness probe keyed on a *filename* cannot survive the job changing *substrate*, and a probe whose only failure signal is its own safe default will never announce that it stopped working — it needs to distinguish "ran, and it is stale" from "could not run at all".
- **Per-judgment advisory banners must gate on regulated-path match** (issue #3541 user-impact-reviewer P1). The operator-attested-mode banner fires only when `${#matched[@]} > 0` (a regulated-data path is being judged this commit). Otherwise it would spam every commit in subagent shells (no `GH_TOKEN`) and train operators to ignore the signal. Pre-existing 30d/90d staleness banners DO fire unconditionally — they signal gate-internal state ("rules are stale at any rate") rather than per-judgment state ("THIS regulated PR has a degraded gate"). When adding a new advisory banner, ask: does its meaning depend on what the gate is doing on THIS invocation, or on the gate's persistent state? Per-judgment banners must scope to relevance; persistent-state banners may fire unconditionally. See `knowledge-base/project/learnings/2026-05-11-runtime-advisory-banners-must-gate-on-judgment-relevance.md`.
- The gate transmits column NAMES to the model (Anthropic). This is itself a Chapter V transfer; it falls under Anthropic's existing DPA recorded in `compliance-posture.md` Vendor DPAs. Row values are never sent — see "Prompt template" above.
- AGENTS.md rule ID `hr-gdpr-gate-on-regulated-data-surfaces` is **immutable** per `cq-rule-ids-are-immutable`. v2 splits retire the ID via the retired-rule-ids ledger rather than reusing.
- `--repo-scan` against full repo history will surface Art. 9 (`Critical`) findings on pre-v1 migrations 001–040 (legacy columns matching the special-category list). The default disposition is **tracked-not-amended** — `compliance-posture.md` Active Items records the row, no amendment migration is issued, because schema rewrites against historical PII tables carry their own data-integrity risk that typically exceeds the disclosure-cure benefit. The row reads `OPEN | tracked, not amended`. Default disposition only applies when the column is **dormant or read-only with disclosure cure available**. Amendment is required and the operator MUST consult `clo` before selecting a disposition when ANY of the following hold:
    1. **Active processing for an undisclosed special-category purpose** — production code currently writes or reads the column for a purpose not disclosed in the privacy notice. Fix is disclosure-side (privacy notice + DPIA + Art. 6/9 lawful-basis annotation) plus, if no Art. 9(2) lawful basis applies, halting processing.
    2. **Backfill with new special-category data** — schema is old but the *data* is new (placeholder/test rows replaced with real PII). This is operationally a new processing activity; Art. 35 DPIA may itself trigger.
    3. **Cross-border transfer post-Schrems II** — the column is replicated to a non-EEA processor (analytics, support, vendor) without Chapter V safeguards (DPA + SCCs + transfer impact assessment). Disclosure cure is insufficient — fix the Chapter V gap.
    4. **CCPA / CPRA "sensitive personal information" overlay** — California's SPI definition (Cal. Civ. Code §1798.140(ae)) overlaps but is not identical to Art. 9. A column that is Art. 9 in EU may also be SPI in California; the disposition must include a CCPA-side disclosure check, not just a GDPR-side one.
- `path-denylist.txt` patterns are bash `[[ =~ ]]` extended regex, **not shell globs**. Patterns that look like globs (e.g., `*.pem`) silently fail to match. `repo-scan.sh` sets `LC_ALL=POSIX` for locale-determinism, but contributors editing patterns should still verify on both Linux and macOS — character-class behaviour for non-ASCII bytes still differs between the two `bash` builds even with `POSIX` set.
