Skip to content

docs(ops-rules): precedence tiers + rules 11-17 - #941

Merged
Aurora Capital (auroracapital) merged 4 commits into
mainfrom
ops-rules-precedence
Sep 9, 2026
Merged

docs(ops-rules): precedence tiers + rules 11-17#941
Aurora Capital (auroracapital) merged 4 commits into
mainfrom
ops-rules-precedence

Conversation

@auroracapital

@auroracapital Aurora Capital (auroracapital) commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Wat dit doet

Twee dingen, in deze volgorde.

1. Regels en precedentie (skills/ops-rules/SKILL.md). De nummers droegen geen
prioriteit; dat staat er nu expliciet. Regel 0 krijgt een clausule erbij:

Someone else's identifier is stricter than your own.

Een denylist bevat per definitie de eigen termen van de operator. Daarmee kan hij
de UUID's, team-keys en issue-ids van een klant structureel niet vangen. Die
clausule benoemt ook de tell: een id dat uit os.environ komt met vijf hardcoded
broertjes eronder is een halfafgemaakte migratie.

2. De opruiming die daaruit volgde. De regel legde echte data bloot in deze
publieke repo:

Wat Waar het nu vandaan komt
Linear team-map (10 UUID's) LINEAR_TEAM_MAP_JSON
Team key van de klant (~25 plekken + een bestandsnaam) LINEAR_CLIENT_TEAM_KEY, default TEAM
Canon-fallbacks met echte issue-ids leeg; los hea_thrash_canons-module buiten de repo
Tijdzone in de inbox-digest OPS_TZ, anders de zone van de host
Valuta in marketing-provision OPS_CURRENCY

CLIENT_TEAM_KEY woont in alignment_lib.py en wordt overal geïmporteerd.
hea_linear_fix_all.py heet nu linear_fix_all.py, met LINEAR_FIX_ALL_* als
env-prefix.

Bijvangst

linear_paperclip_delegate_bridge.py gooide een NameError bij import: de
lokale CLIENT_TEAM_KEY was in een eerdere ronde verwijderd zonder vervanging.
Alle vier de modules laden nu.

Niet gedaan, bewust

paperclip blijft staan, tegen docs/public-template-contract.md:34 in. Van de
272 voorkomens zijn er 45 wire-format: [Paperclip <id>], paperclip-export:,
paperclip-comment: worden in een levende Linear-workspace geschreven en daar
weer uit geparsed. Alleen de lezer hernoemen breekt pair-herkenning stil. Dat
vraagt een eigen migratie met een dubbele-spelling-venster. Geverifieerd dat er
geen PAPERCLIP_* in de secret-stores staat, dus er breekt vandaag niets.

Documentatie

docs/LOCAL-PREFS.md krijgt een tabel voor identifiers van derden.
docs/PII-AUDIT.md krijgt ronde 5, inclusief de twee bevindingen van de
audit-agent die de verificatie niet overleefden (de "private MCP hostname"
bestaat niet in de tree; het "tailnet CGNAT IP" is 100.64.0.10, het
conventionele voorbeeldadres dat de sanitizer in zijn eigen tests gebruikt).

Tests

tests/run-all.sh: 45 suites, 0 failed. test-no-secrets.sh 28/0.
De pre-commit hook blokkeerde de eerste poging op mijn eigen audit-notitie, die
de oude bestandsnaam letterlijk citeerde. Terecht; geredigeerd.

Rule numbers were insertion order, not priority — Rule 5 sat physically
before Rule 4 and nothing said which rule wins in a conflict. Renumbering
was not an option: ~40 sites across other skills cite these numbers.

So the order now comes from a tier, not the number:

- Tier 1 Gates (0, 5, 6, 11, 12, 15) — irreversible, outward-facing, money
- Tier 2 Truth (3, 8, 9, 13, 14, 16) — what may be claimed, and verified how
- Tier 3 Form (1, 2, 4, 7, 10, 17) — output shape, tool limits, ergonomics

Gates beat everything and stop the work; Truth beats Form. Brevity, mobile
formatting and auto-proceed are explicitly Tier 3, so none of them can
authorise a send, a purchase, or a skipped check.

Rule 4 moved above Rule 5 to read in order. Numbers unchanged.

Six new rules, each from a real incident rather than a guess:

- 11 Money is a gate like outbound. Purchases, top-ups, refunds and cap
  raises had no gate at all: Rule 5 covers infra, Rule 6 covers messages.
  Match a transaction on date and description, never on amount — identical
  amounts for purchase and renewal make an amount search ambiguous, and the
  failure mode is a double refund.
- 12 Never change a credential; never recover an account. Using a stored
  credential stays free. A recovery code you can read is not permission to
  use it.
- 13 Verify at the layer that matters. A handshake is not a working tool, a
  green deploy is not a landed artifact, a red job is not proof nothing
  shipped, and a skipped required check reads as passed.
- 14 Never fabricate a value to satisfy a check, and never attribute a
  number to a third party that did not produce it.
- 15 Attribution. Sent-from-the-account is not written-by-the-operator, and
  an echo channel is never an approval.
- 16 Claim shared work, and prove another session is not already doing it
  before adopting its work.
- 17 Own the substrate. Config in a vendor-managed tree reverts on update;
  never pin a model id; low context is not a reason to stop.

Rule 9 gains one sharpening: "nobody replied" is not "unread" — the test is
who spoke last, not the read flag.

Verified: test-skills-lint 791 passed, test-plugin-validate 7 passed,
test-no-secrets 27 passed (incl. the operator identity denylist). Added
lines scanned for emails, URLs, IPs, home paths and handles: none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Audit of all 930 tracked files plus the 173-commit history. The working tree
was in good shape; the gates protecting it were not running.

Three verified failures, each proven by running the thing rather than reading it:

1. pii-gate in CI passed while checking zero identity terms. It runs
   test-no-secrets.sh on every PR, but no denylist exists on a CI runner, so
   identity_denylist_check took its "none configured" branch and counted that
   as PASS. Running the suite locally with the denylist hidden printed the
   identical "27 passed, 0 failed" as a run that really checked 25 terms.
   A check that verifies nothing must not report PASS: that branch now reports
   SKIP, the summary names the skip count, and OPS_PII_DENYLIST_REQUIRED=1
   turns it into a hard failure for anyone who wants it enforced.

2. .githooks/pre-commit was dead code. Present, executable, never invoked —
   git only runs hooks from core.hooksPath or .git/hooks and nothing set
   either, in the primary checkout or any worktree. Every commit in this
   repo's life bypassed it. Added bin/ops-install-git-hooks to wire it, which
   also warns when no denylist is configured. Verified by staging a line
   containing a real denylist term: the commit was refused and no commit
   object was created.

3. test-hermes-plugin.sh hardcoded the operator's private domain into the
   detector that exists to keep private domains out of this repo. That is the
   same failure as the audit doc quoting what it scrubbed. Generic shapes stay
   inline; machine-specific terms now load from the out-of-repo denylist.
   Verified in both directions: planting a real term fails the test, removing
   it passes.

Also: docs/PII-AUDIT.md still named the operator's primary company in the row
explaining that the name is load-bearing, so Round 3's own lesson had not been
applied to Round 3. Described by class now. A private hostname in an
outbound-guard test comment is genericized.

Round 4 of the audit is recorded in docs/PII-AUDIT.md, including what was
deliberately NOT done: main has no branch protection (owner settings, not
code), and history is not rewritten. 14 of 25 terms appear across 34 paths in
history, and with 21 forks created both before and after every scrub a rewrite
reaches none of them while breaking every open PR and clone. Rotate what
matters; treat anything that ever appeared here as public.

Verified: test-no-secrets 27, test-skills-lint 791, test-plugin-validate 7,
test-bin-scripts 267 — all passing, zero failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A denylist can only hold the operator's own terms, so it structurally
cannot catch another organisation's identifiers. Ten client Linear
UUIDs, a client team key in ~25 places plus a filename, and a set of
that client's real issue ids shipped in this repo because no rule
forbade them. Rule 0 now covers third-party data explicitly.

- alignment_lib: CLIENT_TEAM_KEY is the single source, read from
  LINEAR_CLIENT_TEAM_KEY, default placeholder "TEAM".
- delegate bridge: imports it instead of redefining it (the local
  duplicate was missing, so the module raised NameError on import),
  team map comes from LINEAR_TEAM_MAP_JSON, canon fallbacks emptied.
- mirror + fix-all: same import; hea_linear_fix_all.py renamed to
  linear_fix_all.py, env vars HEA_LINEAR_FIX_ALL_* to LINEAR_FIX_ALL_*.
- inbox digest: timestamp zone from OPS_TZ, host zone when unset
  (TZ="" would have forced UTC, not the host zone).
- marketing provision: timeZone and currencyCode from OPS_TZ/OPS_CURRENCY.
- assets: an unreferenced brand-named logo renamed to ops-pixel-logo.svg.
- docs: LOCAL-PREFS gains a third-party identifier table, PII-AUDIT
  gains round 5 including the two agent findings that did not survive
  verification and why `paperclip` was deliberately left alone.

Tests: tests/run-all.sh 45 suites, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ook actually block

A PII denylist can only ever hold the operator's own terms; a hardcoded list of
anyone else's identifiers would itself be the leak. So it structurally cannot
catch a third party's workspace UUIDs, team key, or issue ids — nobody can
enumerate them in advance. The scanner was not broken; it answered a different
question, and reported PASS while a client's identifiers sat in a public repo.

Invert the rule for identifier-shaped literals: every UUID and every
issue-tracker-shaped key in the tree FAILS unless it is listed in
tests/known-public-constants.txt with a stated reason. An IANA timezone in code
or config fails outright. Pasting a client's UUID now costs a line in a diff,
argued for in front of a reviewer.

Three properties make it hold:
- zero configuration, so it runs on the CI path where every denylist check SKIPs
- it sweeps every tracked file including tests/, which EXCLUDE_DIRS drops — the
  scanner's own directory was the one unscanned place
- it cannot SKIP: a check that could not run counts as a failure, because
  counting it as a pass is how a gate silently stops gating

tests/test-pii-gate-fires.sh is the negative control — a gate nobody has watched
fail is not a gate. It plants the exact leaked shapes and asserts refusal, plus a
clean-tree control so a scanner that fails on everything cannot pass either.

On its first run it found four defects in checks written minutes earlier, each of
which would have read as PASS forever:
- grep -o omits the filename with a single file, so every ':'-anchored filter
  silently stopped filtering (-oH)
- the issue-key prefix was capped at 6 chars, so a longer key could ride in on
  the back of an allowlisted one
- both checks skipped instead of failing on an empty file list
- git rev-parse --show-toplevel returns a physical path; comparing it against a
  logical pwd matched nothing under a symlinked checkout, and the sweep then
  passed over an empty file list (pwd -P)

It also caught three Europe/Amsterdam leaks the manual scrub had missed; those
schedules are now stated in UTC and read the display zone from $OPS_TZ.

Then the first commit of all this printed three BLOCKED lines in the pre-commit
hook and landed anyway. The hook applied an amnesty to its own failure flag
AFTER every check had run: if the only email hits were example domains it reset
the flag to 0 and took every other failure with it. Detected, announced, waved
through — a worse shape than silence, because output that reads like enforcement
is why nobody looks.

The example-domain filter now applies inside the email check, and nothing
resets the flag afterwards. A late amnesty can only ever be broader than the
check it was written for. The hook also honours the same three-file exemption as
the scanner, so the negative controls can hold the shapes they forbid without
--no-verify.

tests/test-pre-commit-hook-blocks.sh is the negative control for the hook
itself: eight cases driven through a real `git commit`, asserting the exit
status rather than the output, because the exit status is the only part of a hook
that stops anything. Case 6 is the regression — a forbidden identifier in the
same commit as a harmless @example.com address must still be refused. Cases 7
and 8 pin the exemption to three exact paths rather than the tests/ directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@auroracapital
Aurora Capital (auroracapital) merged commit ed8f5b5 into main Sep 9, 2026
11 checks passed
@auroracapital
Aurora Capital (auroracapital) deleted the ops-rules-precedence branch September 9, 2026 13:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant