diff --git a/docs/README.skills.md b/docs/README.skills.md
index ba1bcafd1..b0f112f74 100644
--- a/docs/README.skills.md
+++ b/docs/README.skills.md
@@ -313,6 +313,16 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-skills) for guidelines on how to
| [playwright-explore-website](../skills/playwright-explore-website/SKILL.md)
`gh skills install github/awesome-copilot playwright-explore-website` | Website exploration for testing using Playwright MCP | None |
| [playwright-generate-test](../skills/playwright-generate-test/SKILL.md)
`gh skills install github/awesome-copilot playwright-generate-test` | Generate a Playwright test based on a scenario using Playwright MCP | None |
| [poka-yoke](../skills/poka-yoke/SKILL.md)
`gh skills install github/awesome-copilot poka-yoke` | Mistake-proof code so misuse cannot be expressed, rather than warning against it. Use when designing an interface, schema, or state machine and the user wants it hard to get wrong ("make invalid states unrepresentable", "so callers cannot screw it up", "type-safe API", "pit of success"); when auditing existing code for footguns ("what could bite us here", "what is easy to misuse", "poka-yoke this repo", "review this diff for ways to get it wrong"); or when a bug has recurred and the fix must close the class rather than the case ("make sure this never happens again", "this is the third time"). Especially for money, auth, permissions, deletion, migrations, and pipelines where failure is silent. Classifies every finding by what happens when the mistake occurs and how the device notices, which is what keeps it from collapsing into generic code review. | `references/hazard-catalog.md`
`references/lang-python.md`
`references/lang-rust-go.md`
`references/lang-typescript.md`
`scripts/detect_hazards.py` |
+| [poka-yoke-agent-guardrails](../skills/poka-yoke-agent-guardrails/SKILL.md)
`gh skills install github/awesome-copilot poka-yoke-agent-guardrails` | Stop an AI agent damaging your repo: PreToolUse hooks, permission deny rules, protected paths, verification gates. Use when "claude keeps force pushing", "CLAUDE.md says X but it still does Y", "stop the agent touching prod or .env", or making a repo safe for unattended agent work. For AI features you ship to users use llm. | `assets/devices/claude-hooks/README.md`
`assets/devices/claude-hooks/guard_dangerous_commands.py`
`assets/devices/claude-hooks/suggest_poka_yoke.py` |
+| [poka-yoke-audit](../skills/poka-yoke-audit/SKILL.md)
`gh skills install github/awesome-copilot poka-yoke-audit` | Find footguns in code that already exists: swappable arguments, silent fallbacks, unguarded deletes, signatures that are easy to misuse. Use when someone asks "what could bite us here", "what is easy to misuse", "poka-yoke this repo", or wants a diff or PR reviewed for ways to get it wrong. Ranks by blast radius. For code not yet written use design; for something that already broke use retro. | `references/hazard-catalog.md`
`references/lang-python.md`
`references/lang-rust-go.md`
`references/lang-typescript.md`
`scripts/detect_hazards.py`
`scripts/device_registry.py` |
+| [poka-yoke-authz](../skills/poka-yoke-authz/SKILL.md)
`gh skills install github/awesome-copilot poka-yoke-authz` | Multi-tenant isolation, IDOR and row-level security. Use to find every path where one tenant could read or write another tenant data: "we forgot to filter by org_id", "can users see each other data", "audit these endpoints for cross-tenant leaks", "make an unscoped query impossible". Covers scoped repositories, RLS, default-deny routing and the two-tenant test. For what the UI shows use ux. | None |
+| [poka-yoke-data](../skills/poka-yoke-data/SKILL.md)
`gh skills install github/awesome-copilot poka-yoke-data` | Pipelines, warehouses, dbt models and metrics, where failure is silently wrong numbers rather than a crash. Use when "the dashboard is wrong", "the numbers do not match", "add data quality checks", "safe backfill", or an upstream schema change broke a join. Covers freshness, row-count and null-rate assertions, data contracts, reconciliation. For a crash rather than wrong numbers use audit. | None |
+| [poka-yoke-design](../skills/poka-yoke-design/SKILL.md)
`gh skills install github/awesome-copilot poka-yoke-design` | Design APIs, schemas, types and state machines so misuse cannot be expressed. Use when writing a new interface and someone asks "what should the types look like", "make invalid states unrepresentable", "so callers cannot screw it up", or wants illegal state transitions rejected. Covers branded types, discriminated unions, typestate, parse-don't-validate. For code that already exists use audit. | `references/hazard-catalog.md` |
+| [poka-yoke-guardrails](../skills/poka-yoke-guardrails/SKILL.md)
`gh skills install github/awesome-copilot poka-yoke-guardrails` | Pre-commit hooks, CI gates, lint rules, database constraints and branch protection. Use when a rule needs enforcing rather than documenting: "set up enforcement", "unformatted or untyped code must not get merged", "gate this in CI", "we agreed to X and people still do not", "stop secrets getting committed". Covers baselining and ratcheting so existing violations do not block anyone. For constraining an AI agent use agent-guardrails. | `assets/devices/claude-hooks/README.md`
`assets/devices/claude-hooks/guard_dangerous_commands.py`
`assets/devices/claude-hooks/suggest_poka_yoke.py`
`assets/devices/github-actions/poka-yoke-gates.yml`
`assets/devices/lint/README.md`
`assets/devices/pre-commit/.pre-commit-config.yaml` |
+| [poka-yoke-llm](../skills/poka-yoke-llm/SKILL.md)
`gh skills install github/awesome-copilot poka-yoke-llm` | AI features you ship to users: structured output, tool schemas, prompt injection, evals. Use when "the model returns bad JSON", "it hallucinates", "stop it calling the wrong tool", "add evals", or an LLM feature can trigger refunds, emails or writes. Covers schema-constrained output, idempotent tool calls, confirmation gates. For agents editing your repo use agent-guardrails. | None |
+| [poka-yoke-ops](../skills/poka-yoke-ops/SKILL.md)
`gh skills install github/awesome-copilot poka-yoke-ops` | Deploys, schema migrations, rollback and infrastructure. Use when "can I ship this on Friday", "this migration is scary", "what is the blast radius", "prevent accidental deletion of the database", or a change drops a column. Covers expand/contract, canary rollout, kill switches, prevent_destroy, tested backups. For an incident that already happened use retro. | None |
+| [poka-yoke-retro](../skills/poka-yoke-retro/SKILL.md)
`gh skills install github/awesome-copilot poka-yoke-retro` | Turn a bug, outage or repeated mistake into a device that makes the whole class impossible. Use when something already broke: "make sure this never happens again", "this is the third time", "postmortem", "how did this get through". Root-causes to the missing constraint, then sweeps every other site where the mistake is still available. For a pipeline use data, a deploy use ops, cross-tenant use authz, an AI feature use llm. | `scripts/detect_hazards.py` |
+| [poka-yoke-ux](../skills/poka-yoke-ux/SKILL.md)
`gh skills install github/awesome-copilot poka-yoke-ux` | Forms, destructive actions and flows users get wrong. Use when "users keep deleting the wrong thing", "add a confirmation dialog", "this flow is error-prone", or building a delete, bulk action, checkout or settings page. Covers undo over confirmation, type-to-confirm, safe defaults, input constraints, double-submit. For the server-side rules behind the screen use authz. | `references/hazard-catalog.md`
`references/ux-patterns.md` |
| [postgresql-code-review](../skills/postgresql-code-review/SKILL.md)
`gh skills install github/awesome-copilot postgresql-code-review` | PostgreSQL-specific code review assistant focusing on PostgreSQL best practices, anti-patterns, and unique quality standards. Covers JSONB operations, array usage, custom types, schema design, function optimization, and PostgreSQL-exclusive security features like Row Level Security (RLS). | None |
| [postgresql-optimization](../skills/postgresql-optimization/SKILL.md)
`gh skills install github/awesome-copilot postgresql-optimization` | PostgreSQL-specific development assistant focusing on unique PostgreSQL features, advanced data types, and PostgreSQL-exclusive capabilities. Covers JSONB operations, array types, custom types, range/geometric types, full-text search, window functions, and PostgreSQL extensions ecosystem. | None |
| [power-apps-code-app-scaffold](../skills/power-apps-code-app-scaffold/SKILL.md)
`gh skills install github/awesome-copilot power-apps-code-app-scaffold` | Scaffold a complete Power Apps Code App project with PAC CLI setup, SDK integration, and connector configuration | None |
diff --git a/skills/poka-yoke-agent-guardrails/SKILL.md b/skills/poka-yoke-agent-guardrails/SKILL.md
new file mode 100644
index 000000000..711ed5d3a
--- /dev/null
+++ b/skills/poka-yoke-agent-guardrails/SKILL.md
@@ -0,0 +1,171 @@
+---
+name: poka-yoke-agent-guardrails
+description: >-
+ Stop an AI agent damaging your repo: PreToolUse hooks, permission deny rules, protected paths, verification gates. Use when "claude keeps force pushing", "CLAUDE.md says X but it still does Y", "stop the agent touching prod or .env", or making a repo safe for unattended agent work. For AI features you ship to users use llm.
+license: MIT
+---
+
+# Poka-Yoke for AI-Written Code
+
+An agent is a fast, tireless operator with no memory of yesterday and a strong prior toward
+appearing successful. That is the exact profile Shingo designed poka-yoke for, except an
+agent makes mistakes faster than any human, and never learns from the ones you correct in
+conversation.
+
+The governing insight: **instructions to an agent are rung zero.** A line in CLAUDE.md saying
+"never commit to main" is training, and training degrades, under long contexts, compaction,
+and subagents that never read the file. A PreToolUse hook that denies the push is a device. If
+you have been repeating the same correction to an agent, that is the signal to stop writing
+instructions and install a device.
+
+## A complete answer covers all five
+
+**The diagnosis is not the answer.** "Instructions are not enforcement" is the right insight,
+and it is satisfying to write, but someone asking *"what am I doing wrong?"* has a repo they
+need to fix: not a question about their prose. Explaining why the rules fail and stopping
+there leaves them exactly where they started. State the insight in a sentence, then spend the
+rest of the answer on the replacement.
+
+Replacing an instruction with a device is not one step, it is five, and stopping after the
+first leaves the person with a rule that looks enforced and is not. Naming the deny rule is
+the easy part and the least of it. Cover every one of these, briefly, before adding depth:
+
+1. **The deny rule, with real syntax.** Show the actual `permissions.deny` entry for their
+ case, `"Bash(git push --force:*)"`: not a description of one. A pattern they have to
+ invent themselves is a step where this fails.
+2. **A hook where a pattern is not enough.** Deny rules match strings. Anything conditional: a `DELETE` without a `WHERE`, an edit allowed in one directory but not another, a
+ production hostname, needs a `PreToolUse` hook that inspects the call and returns a deny.
+ Say which of their two rules needs which.
+3. **What the deny message says.** The agent reads it and acts on it, so a bare refusal
+ produces a workaround, often a worse one. The message must name what was blocked, why, and
+ what to do instead. This is the one place prose belongs in a device.
+4. **Where the config lives, so it applies to everyone.** `.claude/settings.json`, committed.
+ A rule in `settings.local.json` protects one machine, which is the same failure as
+ documenting it: the protection exists only where someone remembered to set it up.
+5. **Proof that it fires.** Run the blocked action and confirm the denial *and* its message,
+ then run the legitimate neighbouring action and confirm it still works. Untested hooks fail
+ open more often than people expect: a regex that does not match the real command string is
+ a hook that does nothing while looking like protection. **An unverified device is worse
+ than no device, because it creates confidence without protection.**
+
+Steps 3 and 5 are the ones most often dropped, and they are what separate a device that works
+from one that merely exists.
+
+## The three failure modes, and the device for each
+
+**1. The agent does something destructive.** Force-push, `rm -rf`, dropping a table, editing
+`.env`, running against production, `git checkout .` over uncommitted work, `--no-verify`.
+These are irreversible and fast. Device: **deny at the tool boundary**: a hook or permission
+rule that refuses the call before it executes. This is Control and it is the only rung that
+matters for irreversible actions.
+
+**2. The agent writes code that looks right and isn't.** Plausible-but-wrong is an agent's
+characteristic defect: correct-looking imports of things that don't exist, tests that assert
+nothing, error handling that swallows, a stub that returns a hardcoded value. Device: **the
+type checker and the test suite as required gates**, plus lint rules against silent failure.
+Everything in `guardrails` applies here with extra force, because the volume of
+generated code is higher and human review attention per line is lower.
+
+**3. The agent reports success it didn't achieve.** "All tests pass" when the suite wasn't
+run; "done" with the build broken. Device: **verification the agent cannot fake**: a Stop
+hook that actually runs the tests, or a CI gate. Never accept a claim of completion that only
+exists as text.
+
+## Devices, strongest first
+
+### Deny rules in settings.json
+
+The cheapest device and the first thing to install. Permission denies are evaluated before the
+tool runs and need no scripting:
+
+```jsonc
+{
+ "permissions": {
+ "deny": [
+ "Bash(git push --force:*)",
+ "Bash(git push -f:*)",
+ "Bash(git commit --no-verify:*)",
+ "Read(./.env)",
+ "Read(./.env.*)",
+ "Edit(./.env)",
+ "Edit(./migrations/**)",
+ "Bash(terraform apply:*)"
+ ]
+ }
+}
+```
+
+Reading `.env` matters as much as writing it: an agent that reads a secret can echo it into a
+log, a commit, or a message to a third-party service. Deny the read.
+
+A deny entry matches the **start** of the command, so it only holds where the dangerous form
+is the prefix. That is why `rm -rf` is not on this list: `"Bash(rm -rf /:*)"` would leave
+`rm -fr /`, `rm -Rf /` and `cd / && rm -rf *` untouched while looking like coverage.
+Recursive delete needs the hook below, see the `rm` pattern in
+`assets/devices/claude-hooks/guard_dangerous_commands.py`.
+
+Put team-wide rules in `.claude/settings.json` (committed) and personal ones in
+`.claude/settings.local.json` (gitignored), otherwise the rules exist only on the machine of
+whoever set them up, which is the same failure as documenting them.
+
+### PreToolUse hooks for anything conditional
+
+When the rule needs logic, "block `DELETE` without a `WHERE`", "block edits to
+`schema.prisma` unless a migration exists", "block production hostnames in a connection
+string": a hook script inspects the call and returns a deny with a reason.
+
+Templates in `assets/devices/claude-hooks/`. The critical detail:
+**the deny message is read by the agent and is your only chance to redirect it.** A bare
+"denied" produces a workaround attempt, often a creative and worse one. A message that says
+what was blocked, why, and what to do instead produces the right action. Write it as you would
+write an error message for a colleague:
+
+> Blocked: `DELETE` without a `WHERE` clause on `users`. Unbounded deletes are irreversible
+> here. Add a `WHERE` clause, or if a full truncate is genuinely intended, ask the user to
+> confirm and run it themselves.
+
+### Stop hooks that verify completion
+
+Run the type check and the test suite when the agent tries to finish. This converts "tests
+pass" from a claim into a fact, and it is the single highest-value hook in most repos.
+
+### Machine-checkable CLAUDE.md
+
+Anything in CLAUDE.md that *can* be a check should be one; what remains should be facts the
+agent needs rather than rules you hope it follows.
+
+- "Always run `make fmt` before committing" → a pre-commit hook.
+- "Never use `any`" → a lint rule with a required check.
+- "Don't edit generated files" → a deny rule, plus a header in the generated files.
+- "Use `pnpm`, not `npm`" → a deny on `Bash(npm install:*)` with a message naming `pnpm`.
+
+What legitimately stays as prose: architecture, domain vocabulary, where things live, why
+past decisions were made. Facts, not commands.
+
+### Make the safe path the easy path
+
+Agents follow the shortest route to a working answer. If `make test` runs the right thing with
+the right env, it gets used; if the correct invocation is a fifteen-flag command documented in
+a wiki, it does not. Every ergonomic improvement here is a poka-yoke: a `make check` that
+bundles fmt + lint + types + tests, a `.env.example` with every key present, a devcontainer or
+a single setup script. Ambiguity is where agents improvise, and improvisation is where damage
+comes from.
+
+## A caution about over-restriction
+
+Deny rules that block ordinary work produce an agent that spends its turns fighting the
+harness, and a user who turns the rules off. Aim the strong devices at **irreversible and
+outward-facing** actions, force-push, prod, secrets, destructive SQL, deletion, publishing, and leave ordinary editing and reading alone. Reversibility is the right axis: git makes most
+code changes cheap to undo, so they do not need a gate. A rotated credential and a dropped
+table do not.
+
+## Verify each device
+
+Same discipline as any other guardrail, and easy to check here: try the blocked action and
+confirm the denial and its message, then confirm the legitimate neighbouring action still
+works. Untested hooks fail open surprisingly often: a regex that doesn't match the real
+command string is a hook that does nothing while looking like protection.
+
+Leave a `poka-yoke:` marker comment on each rule naming what it prevents, and show the user
+each config before writing it. Hooks execute code on their machine on every tool call; that is not a change to
+make on someone's behalf unseen.
diff --git a/skills/poka-yoke-agent-guardrails/assets/devices/claude-hooks/README.md b/skills/poka-yoke-agent-guardrails/assets/devices/claude-hooks/README.md
new file mode 100644
index 000000000..5c26c91ab
--- /dev/null
+++ b/skills/poka-yoke-agent-guardrails/assets/devices/claude-hooks/README.md
@@ -0,0 +1,138 @@
+# Claude Code hooks as poka-yoke devices
+
+Instructions to an agent are rung zero: a line in CLAUDE.md saying "never force-push" is
+training, and training degrades under long contexts, compaction, and subagents that never read
+the file. A hook that denies the push is a device.
+
+Rule of thumb for what to gate: **irreversible and outward-facing**. Git makes ordinary code
+changes cheap to undo, so gating them produces an agent that spends its turns fighting the
+harness and a user who switches the rules off. A rotated credential and a dropped table are
+the real targets.
+
+## 1. Deny rules, start here
+
+The cheapest device, no scripting required. In `.claude/settings.json` (committed, so the rule
+exists for everyone rather than only on the machine of whoever set it up):
+
+```jsonc
+{
+ "permissions": {
+ "deny": [
+ "Bash(git push --force:*)",
+ "Bash(git push -f:*)",
+ "Bash(git commit --no-verify:*)",
+ "Bash(git reset --hard:*)",
+ "Read(./.env)",
+ "Read(./.env.*)",
+ "Edit(./.env)",
+ "Read(./**/credentials)",
+ "Edit(./migrations/**)",
+ "Bash(terraform apply:*)",
+ "Bash(terraform destroy:*)",
+ "Bash(npm publish:*)",
+ "Bash(gh repo delete:*)"
+ ]
+ }
+}
+```
+
+Personal additions go in `.claude/settings.local.json`, which is gitignored.
+
+## 2. Conditional guards, when the rule needs logic
+
+Deny rules match patterns. When the decision depends on the *content* of the command: a
+`DELETE` without a `WHERE`, a connection string pointing at production, use a hook script.
+
+`guard_dangerous_commands.py` in this directory covers the common irreversible cases. Wire it
+up in `.claude/settings.json`:
+
+```jsonc
+{
+ "hooks": {
+ "PreToolUse": [
+ {
+ "matcher": "Bash|Edit|Write|Read",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "python3 \"${CLAUDE_PROJECT_DIR}\"/.claude/hooks/guard_dangerous_commands.py"
+ }
+ ]
+ }
+ ]
+ }
+}
+```
+
+Copy the script to `.claude/hooks/` in the target repo, hooks resolve against the project, not
+against this plugin's cache directory, which changes on every plugin update.
+
+**The deny message is the device, not the denial.** The agent reads the reason and acts on it,
+so "denied" produces a creative workaround, often worse than the original command, while a
+message naming the safe alternative produces the right action. Write them as you would write
+an error message for a colleague.
+
+## 3. Verification gates, make "done" mean something
+
+An agent's characteristic failure is reporting success it did not achieve: "all tests pass"
+when the suite was never run. A `Stop` hook converts the claim into a fact:
+
+```jsonc
+{
+ "hooks": {
+ "Stop": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "bash -c 'npm run typecheck && npm test || exit 2'"
+ }
+ ]
+ }
+ ]
+ }
+}
+```
+
+Only exit code 2 blocks. It stops the agent and feeds the hook's stderr back as the reason,
+which is why the check is wrapped rather than run bare, `npm test` exits 1 on failure, and
+any non-zero exit other than 2 is surfaced to the user as a hook error while the agent stops
+anyway. Claude Code caps consecutive Stop-hook blocks at eight, so a check that can never pass
+eventually releases instead of looping. This is usually the single highest-value hook in a
+repo.
+
+## 4. Test your hooks
+
+Untested hooks fail open more often than you would expect: a regex that does not match the
+real command string is a hook that does nothing while looking like protection, which is worse
+than no hook at all because it creates confidence.
+
+For each rule: run the blocked action and confirm both the denial *and* the message, then run
+the legitimate neighbouring action and confirm it still works.
+
+```bash
+echo '{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}' \
+ | python3 guard_dangerous_commands.py
+# expect: permissionDecision "deny" with a reason mentioning --force-with-lease
+
+echo '{"tool_name":"Bash","tool_input":{"command":"git push origin main"}}' \
+ | python3 guard_dangerous_commands.py
+# expect: no output; the ordinary push is unaffected
+```
+
+## 5. Move CLAUDE.md rules into devices
+
+Anything in CLAUDE.md that *can* be a check should be one. What remains should be facts the
+agent needs, not rules you hope it follows.
+
+| CLAUDE.md line | Device |
+|---|---|
+| "Always run `make fmt` before committing" | pre-commit hook |
+| "Never use `any`" | lint rule at error, in a required check |
+| "Don't edit generated files" | deny rule + a header in the generated file |
+| "Use `pnpm`, not `npm`" | `PreToolUse` hook denying `npm install`, message naming pnpm |
+| "Run tests before saying you're done" | Stop hook |
+| "Never commit to main directly" | branch protection |
+
+What legitimately stays as prose: architecture, domain vocabulary, where things live, why past
+decisions were made. Facts, not commands.
diff --git a/skills/poka-yoke-agent-guardrails/assets/devices/claude-hooks/guard_dangerous_commands.py b/skills/poka-yoke-agent-guardrails/assets/devices/claude-hooks/guard_dangerous_commands.py
new file mode 100755
index 000000000..f16c62c71
--- /dev/null
+++ b/skills/poka-yoke-agent-guardrails/assets/devices/claude-hooks/guard_dangerous_commands.py
@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+"""PreToolUse guard, deny irreversible agent actions before they execute.
+
+Wire this into .claude/settings.json (see hooks.json in this directory). It reads the hook
+payload on stdin and denies commands whose mistakes cannot be undone.
+
+Design notes worth keeping if you adapt this:
+
+ * Aim at IRREVERSIBLE and OUTWARD-FACING actions only. Git makes ordinary code changes
+ cheap to undo, so gating them produces an agent that fights the harness and a user who
+ turns the hook off. Rotated credentials and dropped tables are the real targets.
+
+ * The deny REASON is the device. The agent reads it and acts on it, so a bare "denied"
+ produces a creative workaround, often worse than the original command. Say what was
+ blocked, why, and what to do instead.
+
+ * Fail open on unexpected input. A hook that crashes on an unusual payload blocks all
+ tool use, which is its own outage.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import sys
+
+# (pattern, reason). Reasons are written for the agent to act on, not for a log.
+RULES: list[tuple[str, str]] = [
+ # --force-with-lease is the safe form and must stay allowed, so the lookahead sits
+ # directly after "--force" rather than at the end of the line.
+ (r"\bgit\s+push\b.*(--force(?!-with-lease)|(?\s*/dev/sd|\bmkfs\b|\bdd\s+.*of=/dev/",
+ "This writes directly to a block device and destroys data unrecoverably."),
+]
+
+# Paths an agent should not read or write. Reading a secret matters as much as writing one:
+# it can be echoed into a log, a commit, or a message to a third party.
+# .env.example / .sample / .template hold the SHAPE of the config, not the values, and the
+# deny message below points the agent at them, so denying them would block the alternative
+# the device recommends.
+PROTECTED_PATHS = re.compile(
+ r"(^|/)\.env($|\.(?!example|sample|template|dist))"
+ r"|(^|/)\.aws/credentials|(^|/)\.ssh/id_|(^|/)\.npmrc|(^|/)\.pypirc"
+)
+
+
+# poka-yoke: blocks irreversible agent actions before they execute [control]
+def deny(reason: str) -> None:
+ json.dump({
+ "hookSpecificOutput": {
+ "hookEventName": "PreToolUse",
+ "permissionDecision": "deny",
+ "permissionDecisionReason": f"[poka-yoke] {reason}",
+ }
+ }, sys.stdout)
+ sys.exit(0)
+
+
+def main() -> None:
+ try:
+ payload = json.load(sys.stdin)
+ except (json.JSONDecodeError, ValueError):
+ sys.exit(0) # fail open: a crashing hook blocks all tool use
+
+ tool = payload.get("tool_name", "")
+ tool_input = payload.get("tool_input") or {}
+
+ if tool == "Bash":
+ command = tool_input.get("command", "")
+ for pattern, reason in RULES:
+ if re.search(pattern, command, re.IGNORECASE):
+ deny(f"{reason}\n\nBlocked command: {command[:200]}")
+
+ if tool in ("Read", "Edit", "Write", "NotebookEdit"):
+ path = tool_input.get("file_path", "") or tool_input.get("notebook_path", "")
+ if path and PROTECTED_PATHS.search(path):
+ deny(
+ f"'{path}' holds credentials. Reading them risks echoing a secret into a log, "
+ "a commit, or a message to a third party. Use .env.example for the shape of "
+ "the config, and ask the user for any value you actually need."
+ )
+
+ sys.exit(0)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/skills/poka-yoke-agent-guardrails/assets/devices/claude-hooks/suggest_poka_yoke.py b/skills/poka-yoke-agent-guardrails/assets/devices/claude-hooks/suggest_poka_yoke.py
new file mode 100755
index 000000000..f6b4645fa
--- /dev/null
+++ b/skills/poka-yoke-agent-guardrails/assets/devices/claude-hooks/suggest_poka_yoke.py
@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+"""UserPromptSubmit hook, turn auto-triggering from a hope into a device.
+
+Claude Code skills are documented as model-invoked, and in practice they often are not.
+This is a widely reported platform behaviour, not a defect in any one description:
+anthropics/claude-code#9716 collects reports of skills being ignored even when the query
+exactly matches the description. Testing this plugin found the same, five realistic queries
+across four modes, zero skill invocations.
+
+The plugin's own argument applies to the plugin: if the behaviour you want depends on the
+model remembering to look, that is rung zero. So install a device.
+
+What does NOT work, per Scott Spence's write-up of the same problem: a hook that emits a
+gentle reminder, "check .claude/skills/ for something relevant", is treated as background
+noise. The model acknowledges it and proceeds anyway.
+
+What does work is naming the specific skill and instructing its use. That is what this does:
+match the prompt against each mode's trigger vocabulary, and if one matches, inject an
+explicit instruction to load that skill.
+
+ # poka-yoke: makes skill invocation explicit rather than hoping the model volunteers [warning]
+
+This is Warning rung, not Control. The injected instruction is still an instruction, and the
+model can still decline, Spence's verdict after living with it is "for anything important,
+invoke it explicitly." Treat this as convenience for the common case, and use the slash
+command when it matters.
+
+Install in .claude/settings.json:
+
+ {"hooks": {"UserPromptSubmit": [{"hooks": [{"type": "command",
+ "command": "python3 \\"${CLAUDE_PROJECT_DIR}\\"/.claude/hooks/suggest_poka_yoke.py"}]}]}}
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import sys
+
+# Ordered: the first match wins, so put the specific modes ahead of the general ones.
+# Patterns are deliberately narrow. A hook that fires on every prompt is noise, and noise
+# gets the hook removed: the same failure mode as a guardrail that cries wolf.
+MODES: list[tuple[str, str]] = [
+ ("authz",
+ r"\b(tenant|multi.?tenant|idor|cross.?tenant|row.?level security|rls)\b.*"
+ r"\b(isolat|scope|leak|filter|see (each )?other)|"
+ r"\b(one|another) (customer|tenant|user)('s)? (data|documents|records)\b"),
+
+ ("agent-guardrails",
+ r"\b(claude|the agent|codex|cursor|copilot)\b.*\b(keeps?|still|ignor|won'?t stop)\b|"
+ r"\bCLAUDE\.md\b.*\b(ignor|says|but it)\b|"
+ r"\b(stop|prevent) (the )?(agent|claude)\b"),
+
+ ("llm",
+ r"\b(prompt injection|structured output|hallucinat)\b|"
+ r"\b(our|the) (bot|ai|llm|model|agent)\b.*\b(returns?|extracts?|calls?|refunds?|sometimes)\b"),
+
+ ("data",
+ r"\b(dashboard|pipeline|warehouse|dbt|etl|metric|revenue)\b.*"
+ r"\b(wrong|silently|nulls?|stale|didn't notice|did not notice|coalesce|coalescing)\b"),
+
+ ("ops",
+ r"\b(drop(ping)? (a )?column|migration|expand.?contract|blast radius|kill switch)\b|"
+ r"\b(deploy|ship|merge)\b.*\b(friday|risky|safe|rollback|irreversible)\b"),
+
+ ("ux",
+ r"\b(users?|customers?)\b.*\b(accident|by mistake|keep deleting|panic)\b|"
+ r"\b(confirm(ation)? (dialog|modal)|are you sure|undo)\b"),
+
+ ("retro",
+ # "root cause" and "incident" were missing, so the single most standard way anyone
+ # describes this work, "do a root cause on last night's outage", got silence.
+ r"\b(happened again|second time|third time|keeps? happening|postmortem|post.?mortem|"
+ r"never happens? again|prevent .*recurr|root.?cause|incident review|"
+ r"(after|following) (the|an|last night'?s) (incident|outage))\b|"
+ r"\b(incident|outage|we (double.?charged|dropped|lost|corrupted|deleted))\b"
+ r"[^.?!]*\b(root.?cause|why|how did|what went wrong|so it (does not|does not) happen)\b"),
+
+ ("guardrails",
+ r"\b(pre.?commit|ci gate|required check|branch protection|lint rule)\b|"
+ r"\b(we (agreed|said)|team agreed)\b.*\b(still|don'?t|nobody)\b|"
+ r"\benforce\b.*\b(so (people|they) can'?t|instead of (asking|documenting))\b"),
+
+ ("design",
+ # The jargon alternatives fire only for people who already know the vocabulary. Someone
+ # who says "design the types for our state machine so bad states can't exist" wants this
+ # mode and was getting silence, which made the README's claim that all ten modes route
+ # false for the one mode the README tells people to start with.
+ # `design` sits below `ux`, `ops` and `authz`, so "redesign the deletion flow" is still
+ # claimed by ux before it reaches here.
+ r"\b(invalid states? unrepresentable|make it impossible to|so (you|people) can'?t "
+ r"(accidentally|screw)|typestate|discriminated union|branded type)\b|"
+ r"\bwhat should (the )?(types?|signature|api)\b.*\blook like\b|"
+ r"\b(design|model|write|writing|about to write)\b[^.?!]*\b(api|sdk|types?|schema|"
+ r"interface|signature|state machine|enum|data model)\b|"
+ r"\b(api|types?|schema|interface)\b[^.?!]*\b(hard|impossible|difficult) to (use|misuse)\b"),
+
+ ("audit",
+ r"\b(footgun|easy to (use|misuse)|what could (go wrong|bite)|mistake.?proof|error.?proof|"
+ r"poka.?yoke|poke.?yoke|foolproof)\b"),
+]
+
+TEMPLATE = (
+ "[poka-yoke] This request matches the `{skill}` skill, which carries a specific method "
+ "for it, classify the mistake, pick the strongest device that prevents it, and say which "
+ "rung that reaches. Load `{skill}` and follow it before answering. If it turns out not to "
+ "fit, say so in one line and answer normally."
+)
+
+
+def main() -> None:
+ try:
+ prompt = (json.load(sys.stdin).get("prompt") or "")
+ except (json.JSONDecodeError, ValueError):
+ sys.exit(0) # fail open: a broken hook must not block every prompt
+
+ if len(prompt) > 4000:
+ prompt = prompt[:4000]
+
+ for skill, pattern in MODES:
+ if re.search(pattern, prompt, re.IGNORECASE):
+ print(TEMPLATE.format(skill=skill))
+ break
+ sys.exit(0)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/skills/poka-yoke-audit/SKILL.md b/skills/poka-yoke-audit/SKILL.md
new file mode 100644
index 000000000..3be2fc4c8
--- /dev/null
+++ b/skills/poka-yoke-audit/SKILL.md
@@ -0,0 +1,185 @@
+---
+name: poka-yoke-audit
+description: >-
+ Find footguns in code that already exists: swappable arguments, silent fallbacks, unguarded deletes, signatures that are easy to misuse. Use when someone asks "what could bite us here", "what is easy to misuse", "poka-yoke this repo", or wants a diff or PR reviewed for ways to get it wrong. Ranks by blast radius. For code not yet written use design; for something that already broke use retro.
+license: MIT
+---
+
+# Poka-Yoke Audit
+
+Find the mistakes that are *available* in this code, then close them. You are not looking for
+bugs: a bug is a mistake that already happened. You are looking for **affordances for
+mistakes**: places where doing the wrong thing is easy, silent, and looks correct.
+
+The load-bearing question throughout: *if a competent, tired engineer used this at 4pm on a
+Friday, what would go wrong and would anything stop them?*
+
+## 1. Establish scope
+
+Default, when the user names no path:
+
+1. `git diff HEAD`: uncommitted work. This is what they are most likely asking about.
+2. If the tree is clean, `git diff HEAD~5..HEAD`: recent commits.
+3. If neither yields anything (fresh repo, no git), fall back to the risk surfaces below and
+ say that's what you did.
+
+Widen to the whole repo only when asked ("audit the whole codebase", "full audit"). It is
+slow and it buries the important findings in volume. When you do go wide, prioritize by
+**risk surface** rather than by directory, go straight to code that touches money,
+authentication, authorization, deletion or overwriting, migrations, external I/O,
+concurrency, and anything with `admin`, `force`, `bulk`, `sync`, or `delete` in its name.
+
+State the scope you chose in one line before you start, so the user can redirect you cheaply.
+
+## 2. Run the detector, then think
+
+```bash
+python3 scripts/detect_hazards.py --diff # path is relative to this SKILL.md
+```
+
+Other useful forms: `--paths src/ lib/`, `--staged`, `--since HEAD~10`, `--json`,
+`--severity high`, `--id C1 M2` to filter to specific rules. Run `--help` for the full set.
+
+The script finds the mechanically detectable shapes, adjacent same-type parameters, boolean
+flag arguments, unbounded deletes, money held as a float, unvalidated request bodies, retries
+without an idempotency key. Shapes a real linter already covers, bare `except`, mutable
+default arguments, `any` escape hatches, are off by default and named in the footer; `--all`
+runs them too. It is a **fast first pass with real false positives**, not an oracle. Treat
+each hit as a question to investigate, and read the surrounding code before you believe it.
+
+Then do the part the script cannot: read the interfaces and run the three lenses over them.
+
+**Contact, can the wrong thing fit?** Look at every public signature. Are two adjacent
+parameters the same type? Could a caller pass an order ID where a user ID belongs, cents
+where dollars belong, a raw string where a validated one belongs? Does the boundary accept
+`any` / `dict` / `interface{}` and hope?
+
+**Fixed-value, can an incomplete or wrong-sized set pass?** Is every enum branch handled,
+and will adding a variant break the build or silently fall through? Can a bulk operation run
+with an empty or unexpectedly huge set? Is config validated as a whole, or discovered
+missing at 3am? Are required fields actually required, or optional-with-a-default?
+
+**Motion-step, can the order be wrong?** Must something be called before something else, with
+nothing enforcing it? Can a retry double-charge? Can a resource leak on the error path? Can
+two callers interleave between a check and the act that depends on it?
+
+The script only sees text. These three questions are where the audit's value comes from.
+
+## 3. Classify every finding
+
+Each finding gets four fields. Fill all four: an unclassified finding is just an opinion.
+
+- **Mistake**: the specific wrong thing a person can do, stated as an action.
+ *"Call `transfer(dst, src)` with the accounts reversed."*
+- **Consequence**: what happens when they do, and how loudly. Silence is the aggravator: a mistake that throws immediately is far less dangerous than one that returns a plausible
+ wrong answer.
+- **Current rung**: what exists today, Control / Warning / Detection / **None**.
+- **Proposed device + rung**: the specific change, and the rung it reaches. If you're
+ proposing Warning, say what would be needed for Control and why you didn't.
+
+## 4. Rank by expected damage, not by count
+
+Priority is **blast radius × ease of mistake**, and nothing else. A hundred stringly-typed
+internal helpers matter less than one `delete_users(filter)` where `filter` can be empty.
+
+Blast radius, descending: irreversible data loss or money movement → security or
+authorization bypass → silent data corruption → wrong output the user acts on → crash →
+degraded experience. A crash ranking *below* silent wrong output is deliberate and worth
+saying out loud: loud failures are cheap, quiet ones compound.
+
+Ease of mistake, descending: silent and plausible-looking → requires only forgetting → needs
+an unusual-but-reachable input → needs deliberate misuse.
+
+Report the top findings in priority order and stop somewhere sensible, ten well-argued
+findings beat forty. Say how many you set aside and why.
+
+## 5. Report
+
+Use this structure. It is short on purpose; the detail lives per-finding.
+
+```markdown
+# Poka-Yoke Audit · ·
+
+**Scope**:
+**Verdict**:
+
+## Findings
+
+### 1. · /
+**Where**: `path/to/file.ts:42`
+**Mistake**:
+**Consequence**:
+**Today**:
+**Device**: → ****
+
+
+
+
+
+### 2. …
+
+## Set aside
+
+```
+
+Write it to `docs/poka-yoke/audit-YYYY-MM-DD.md` in the user's repo. If they'd rather not
+have a file, keep it in the conversation, ask if it isn't obvious.
+
+## 6. Propose, then apply
+
+Present the findings and wait. Do not edit files yet. These changes alter interface shapes
+and ripple through call sites; people reasonably want to see the plan first.
+
+When they approve some or all of it: apply each device, leave a `poka-yoke:` marker comment
+at it saying which mistake it blocks, and run the tests.
+
+## Recording what a device is for
+
+Devices only stay valuable if people know they are load-bearing. Without a record, the next
+person deletes the "redundant" check or relaxes the "annoying" constraint, and the mistake
+comes back. A device that has never fired looks like dead weight precisely because it is
+working.
+
+The obvious answer, keep a registry file listing every device, is **wrong, by this skill's
+own argument.** A Markdown file someone must remember to update is training, not a device. It
+goes stale exactly when it matters: the moment someone removes a constraint without touching
+the doc. Do not ask anyone to maintain one.
+
+**Put the reason where the device is.** A marker comment at the constraint travels with it,
+gets read by the person about to delete it, and cannot drift out of sync because it is not a
+separate thing:
+
+```python
+# poka-yoke: rejects a second charge for the same idempotency key [control]
+UNIQUE (account_id, idempotency_key)
+```
+
+```ts
+// poka-yoke: forgetting to await this write would lose it silently [warning]
+"@typescript-eslint/no-floating-promises": "error",
+```
+
+The bracketed rung is optional. What earns its place is the clause after the colon: the
+*mistake*, stated as something a person could do. "Uniqueness constraint" tells a future
+engineer nothing; "rejects a second charge for the same key" tells them what breaks if they
+drop it.
+
+**If someone wants an index, generate it.** Never hand-maintain it:
+
+```bash
+python3 scripts/device_registry.py --write docs/poka-yoke/registry.md
+python3 scripts/device_registry.py --check # CI: fails if stale
+```
+
+Delete a device and its row disappears; move it and the row follows. That is the difference
+between a record that is a device and a record that is a chore.
+
+## Staying useful
+
+The failure mode of this audit is turning into a generic style review. Style findings, naming, formatting, structure, "this could be more readable", do not belong here unless the
+unreadability is itself the hazard. If you cannot name a specific wrong action a person could
+take, it is not a poka-yoke finding, and including it dilutes the ones that are.
+
+Read `references/hazard-catalog.md` for the recurring hazard shapes and their standard
+devices, and the matching `references/lang-*.md` for what the language can actually
+enforce.
diff --git a/skills/poka-yoke-audit/references/hazard-catalog.md b/skills/poka-yoke-audit/references/hazard-catalog.md
new file mode 100644
index 000000000..b7808e377
--- /dev/null
+++ b/skills/poka-yoke-audit/references/hazard-catalog.md
@@ -0,0 +1,416 @@
+# Hazard Catalog
+
+The recurring shapes that produce mistakes, organized by the lens that finds them. Each entry:
+what to look for, why it bites, and the device that closes it with the rung it reaches.
+
+Use this as working vocabulary, not a checklist to run top to bottom. The lens questions are
+the real tool; this catalog is what the lenses usually turn up.
+
+## Contents
+
+- [Contact lens, can the wrong thing fit?](#contact-lens-can-the-wrong-thing-fit)
+ - [C1. Adjacent same-type parameters](#c1-adjacent-same-type-parameters)
+ - [C2. Boolean flag parameters](#c2-boolean-flag-parameters)
+ - [C3. Primitive obsession at boundaries](#c3-primitive-obsession-at-boundaries)
+ - [C4. Stringly-typed enums](#c4-stringly-typed-enums)
+ - [C5. Implicit units and magnitudes](#c5-implicit-units-and-magnitudes)
+ - [C6. Money as a float](#c6-money-as-a-float)
+ - [C7. Unvalidated external input](#c7-unvalidated-external-input)
+ - [C8. Bag-of-optionals structs](#c8-bag-of-optionals-structs)
+ - [C9. Naive datetimes](#c9-naive-datetimes)
+- [Fixed-value lens, can an incomplete or wrong-sized set pass?](#fixed-value-lens-can-an-incomplete-or-wrong-sized-set-pass)
+ - [F1. Non-exhaustive branching](#f1-non-exhaustive-branching)
+ - [F2. Unbounded destructive operations](#f2-unbounded-destructive-operations)
+ - [F3. Defaults that hide a decision](#f3-defaults-that-hide-a-decision)
+ - [F4. Config discovered missing at runtime](#f4-config-discovered-missing-at-runtime)
+ - [F5. Partial writes without a transaction](#f5-partial-writes-without-a-transaction)
+ - [F6. Invariants enforced only in the application](#f6-invariants-enforced-only-in-the-application)
+ - [F7. Unbounded input](#f7-unbounded-input)
+- [Motion-step lens, can the order be wrong?](#motion-step-lens-can-the-order-be-wrong)
+ - [M1. Temporal coupling](#m1-temporal-coupling)
+ - [M2. Non-idempotent retryable effects](#m2-non-idempotent-retryable-effects)
+ - [M3. Illegal state transitions](#m3-illegal-state-transitions)
+ - [M4. Resources that must be released](#m4-resources-that-must-be-released)
+ - [M5. Check-then-act races](#m5-check-then-act-races)
+ - [M6. Fire-and-forget async](#m6-fire-and-forget-async)
+ - [M7. Order-dependent migrations and deploys](#m7-order-dependent-migrations-and-deploys)
+- [Cross-cutting, devices that were removed](#cross-cutting-devices-that-were-removed)
+ - [X1. Swallowed errors](#x1-swallowed-errors)
+ - [X2. Silent coercion and fallback](#x2-silent-coercion-and-fallback)
+ - [X3. Disabled tests](#x3-disabled-tests)
+ - [X4. Escape hatches in the type system](#x4-escape-hatches-in-the-type-system)
+ - [X5. Mutable shared defaults](#x5-mutable-shared-defaults)
+
+---
+
+## Contact lens, can the wrong thing fit?
+
+The factory analogy: a part that only seats one way. In software, the type is the shape.
+
+### C1. Adjacent same-type parameters
+
+**Signal**: two or more consecutive parameters of the same primitive type, `transfer(from: string, to: string)`, `resize(w: number, h: number)`,
+`slice(start: int, end: int)`.
+
+**Why it bites**: swapping them compiles, passes review, and produces a plausible wrong
+result. It is among the most common footguns in software, and one of the most cleanly
+solved, once the two types differ, the wrong order will not compile.
+
+**Device**: distinct types per concept, branded types, newtypes, value objects, so a
+`SourceAccount` cannot be passed as a `DestinationAccount`. **Control.**
+Fallback where types can't help: force keyword/named arguments so the caller must write the
+name at the call site. **Warning**, but nearly free and it makes the swap visible in review.
+
+### C2. Boolean flag parameters
+
+**Signal**: `createUser(name, true, false)`, `save(data, force=True)`, any `bool` parameter
+that selects behavior rather than carrying data.
+
+**Why it bites**: the call site is unreadable, so misordered or misunderstood flags are
+invisible. Adding a second boolean makes it exponentially worse.
+
+**Device**: an enum or literal union per axis (`Visibility.Public`), an options object with
+named fields, or two separate functions. **Control** for the enum, since the wrong value has
+no spelling. Note the exception: a single boolean whose name reads correctly at the call site
+in a keyword-argument language is fine.
+
+### C3. Primitive obsession at boundaries
+
+**Signal**: `string` for email, URL, path, token, tenant ID, phone; `int` for a percentage or
+a duration, especially on public functions.
+
+**Why it bites**: every downstream function must re-check or trust. Validation that returns a
+boolean throws away the proof, so the check gets repeated, skipped, or done inconsistently.
+
+**Device**: parse-don't-validate. `parseEmail(s): Email | Error` once at the boundary, then
+downstream signatures demand `Email`. The type carries the guarantee permanently. **Control.**
+
+### C4. Stringly-typed enums
+
+**Signal**: `status: string` with a comment listing the values; string comparison against
+literals; a value crossing a boundary as text with no schema.
+
+**Why it bites**: typos compile. New variants added elsewhere never reach this code. Nothing
+tells you which values are legal.
+
+**Device**: a literal union, enum, or sealed class, with exhaustive matching (F1). **Control.**
+
+### C5. Implicit units and magnitudes
+
+**Signal**: `timeout: number`, `distance: float`, `retryAfter: int`: no unit anywhere except
+possibly a name or a comment. Two systems in the same codebase disagreeing on seconds vs
+milliseconds.
+
+**Why it bites**: a 1000x error is silent and looks like a hang or a hot loop. This class of
+mistake famously destroyed a Mars orbiter.
+
+**Device**: unit-bearing types (`Duration`, `Milliseconds`), or at minimum encode the unit in
+the parameter name (`timeoutMs`). **Control** for the type. The name is **rung 0**: it makes
+a mismatch visible to a reader who is looking, and produces no diagnostic for one who is not.
+Worth doing; not a device.
+
+### C6. Money as a float
+
+**Signal**: `price: float`, `amount: number`, arithmetic on currency in binary floating point,
+`==` comparisons on money.
+
+**Why it bites**: 0.1 + 0.2 ≠ 0.3. Errors accumulate over aggregation and reconciliation
+fails in ways that take days to trace.
+
+**Device**: integer minor units (cents) in a `Money` type carrying its currency, or a decimal
+type. Mixed-currency arithmetic should not typecheck. **Control.**
+
+### C7. Unvalidated external input
+
+**Signal**: `JSON.parse(body)` into `any`, `request.json()` into a bare dict, a third-party
+API response used field-by-field with no schema, `os.environ[...]` read deep inside logic.
+
+**Why it bites**: the failure surfaces far from the boundary, as a confusing error about a
+missing property, long after the malformed data has been partially processed or stored.
+
+**Device**: a schema at every edge, zod/valibot, Pydantic, `encoding/json` into a typed
+struct with validation, serde. Parse once, then work with parsed types. **Control.**
+This applies to *your own* services' responses too; "internal" is not a guarantee.
+
+### C8. Bag-of-optionals structs
+
+**Signal**: a type with several optional fields where only certain combinations are
+meaningful, `{ status, data?, error?, retryAt? }`, `{ isLoading, data, error }`.
+
+**Why it bites**: N optional fields claim 2^N legal states. Every consumer must guess which
+are real, and they guess differently. States like "loading and errored with data" become
+reachable and get handled inconsistently.
+
+**Device**: a discriminated union with exactly the legal variants, so impossible combinations
+have no representation. **Control.** This is the canonical "make invalid states
+unrepresentable" move.
+
+### C9. Naive datetimes
+
+**Signal**: timezone-less timestamps, `datetime.now()` / `new Date()` scattered through
+business logic, dates stored as strings, DST-unaware arithmetic.
+
+**Why it bites**: correct in the developer's timezone, wrong in production, and wrong twice a
+year in the places that observe DST. Also hard to test, logic that reads the clock directly
+cannot be exercised at a boundary condition without freezing or injecting time.
+
+**Device**: timezone-aware types everywhere, UTC at rest, an injected clock so time is a
+parameter rather than an ambient read. **Control** for the type, and the injected clock buys
+testability, which is a Detection-rung device that finally becomes possible.
+
+---
+
+## Fixed-value lens, can an incomplete or wrong-sized set pass?
+
+The factory analogy: a counter confirming all six screws were fitted.
+
+### F1. Non-exhaustive branching
+
+**Signal**: a `switch`/`match` over an enum with a `default` that does nothing meaningful, or
+an if/else chain over a closed set of values.
+
+**Why it bites**: adding a variant silently takes the default branch at every site that
+should have been updated. The bug appears months later, in the one code path nobody tested.
+
+**Device**: compiler-enforced exhaustiveness: an `assertNever(x: never)` arm in TypeScript,
+`match` without a catch-all in Rust, `assert_never` with mypy, an exhaustive linter for Go.
+**Control**, one line per switch, and among the highest-leverage devices available.
+
+### F2. Unbounded destructive operations
+
+**Signal**: `DELETE`/`UPDATE` built from a filter that can be empty; `rm -rf "$VAR"`;
+`.deleteMany(where)`; bulk send/publish over a query result; a "cleanup" job with no cap.
+
+**Why it bites**: irreversible, instant, and proportional to your data volume. An empty filter
+frequently means "match everything."
+
+**Device**: refuse an empty predicate; require an explicit `all=True` for the full-table case;
+cap the affected row count and require confirmation above it; dry-run by default with the
+count printed. Soft-delete where the domain allows. **Control.**
+
+### F3. Defaults that hide a decision
+
+**Signal**: a default value for something with no safe default, `retries=3`, `timeout=30`,
+`currency="USD"`, `tenant=None`, `region=default`.
+
+**Why it bites**: the caller never considers the parameter, and the default is wrong for their
+case. Worse than an error, because it produces confident wrong behavior.
+
+**Device**: make it required. Reserve defaults for parameters where one value is correct for
+the overwhelming majority and wrong-but-harmless for the rest. **Control.**
+
+### F4. Config discovered missing at runtime
+
+**Signal**: `os.getenv("X")` inside a request handler; config read lazily on first use; a
+missing key producing `None` that flows onward.
+
+**Why it bites**: the service starts, passes health checks, and fails on the one code path
+that needs the key, often the payment path, often at 3am.
+
+**Device**: parse and validate the entire config into a typed object at startup, and exit
+non-zero if anything is missing or malformed. Every consumer takes the typed object.
+**Control**, and it converts a 3am page into a failed deploy.
+
+### F5. Partial writes without a transaction
+
+**Signal**: several writes in sequence with no transaction; a write followed by an external
+call followed by another write; "create the record then send the email."
+
+**Why it bites**: a failure in the middle leaves the system in a state your code does not
+model and cannot repair.
+
+**Device**: wrap in a transaction; move external effects outside it via an outbox; make the
+sequence idempotent so replay converges. **Control** for the transaction.
+
+### F6. Invariants enforced only in the application
+
+**Signal**: uniqueness checked with a `SELECT` before an `INSERT`; nullability enforced in a
+model class but not in the column; a foreign key relationship maintained by convention.
+
+**Why it bites**: the check races under concurrency, and it is bypassed entirely by any other
+service, migration, script, or human with `psql`.
+
+**Device**: push it into the schema, `NOT NULL`, `UNIQUE`, `CHECK`, foreign keys, partial
+unique indexes. The database is a type system shared by everything that touches the data.
+**Control**, and uniquely durable.
+
+### F7. Unbounded input
+
+**Signal**: pagination with no maximum page size; a file upload with no size limit; a query
+built from a user-supplied list with no cap; unbounded recursion or retries.
+
+**Why it bites**: a resource exhaustion incident indistinguishable from an attack, triggered
+by an ordinary user with a large account.
+
+**Device**: explicit caps at the boundary, enforced by the parsing type where possible.
+**Control.**
+
+---
+
+## Motion-step lens, can the order be wrong?
+
+The factory analogy: a sensor confirming step 3 happened before step 4.
+
+### M1. Temporal coupling
+
+**Signal**: `init()`, `connect()`, `configure()`, `validate()` that must be called before
+other methods; documentation containing the phrase "you must call X first."
+
+**Why it bites**: nothing enforces it. The failure is a null dereference or, worse, a
+silently-wrong result from a half-configured object.
+
+**Device**: the constructor or a static factory returns a fully ready object; or typestate,
+where `connect()` returns a `Connected` type and the other methods exist only on it.
+**Control.**
+
+### M2. Non-idempotent retryable effects
+
+**Signal**: a charge, email, webhook, or external mutation reachable from a retry, a queue
+consumer, or a UI button, with no idempotency key, or with an optional one.
+
+**Why it bites**: at-least-once delivery is the norm, not the exception. Duplicate charges are
+the canonical version and they are expensive and public.
+
+**Device**: a **required** idempotency key parameter, backed by a unique constraint on
+`(entity, key)`. **Control.** An optional idempotency key is rung zero wearing a costume.
+
+The constraint is necessary and not sufficient. Rejecting the duplicate is not the same as
+being idempotent: the key has to be *reserved in the same transaction as the effect*, bound
+to the request payload so a different payload under a reused key is an error rather than a
+silent no-op, and the stored result replayed to the second caller. A caller that retries and
+gets a constraint violation has learned nothing about whether the first attempt worked.
+
+### M3. Illegal state transitions
+
+**Signal**: an entity with a `status` field mutated by assignment from several places; a
+refund reachable before a charge; "cancelled" transitioning back to "pending".
+
+**Why it bites**: every site that assigns the field must know the whole state machine, and one
+of them doesn't.
+
+**Device**: a single transition function that is the only path to a new state, rejecting
+illegal transitions; or typestate so illegal transitions don't compile. **Control.**
+
+A row-level `CHECK` is not defence in depth here: it constrains one row's values and cannot
+see the state that row is coming from, so it can forbid `status = 'refunded' AND total < 0`
+but not `shipped → pending`. Policing transitions in the database needs a trigger, or a
+transition table the row must join against.
+
+### M4. Resources that must be released
+
+**Signal**: `open()`/`close()`, `acquire()`/`release()`, `begin()`/`commit()` as separate
+statements, especially with a `return` or `throw` reachable between them.
+
+**Why it bites**: the happy path is fine and the error path leaks. Leaks surface as connection
+pool exhaustion under load, which is when you can least afford it.
+
+**Device**: scope-bound acquisition, `with`, `defer`, RAII, `using`, try-with-resources.
+**Control.**
+
+### M5. Check-then-act races
+
+**Signal**: `if (!exists(x)) create(x)`, read-modify-write on a shared counter, checking a
+balance and then debiting it, `if (!file.exists()) write(file)`.
+
+**Why it bites**: correct in every test and wrong under concurrency, intermittently, in
+production only.
+
+**Device**: make it atomic: a unique constraint plus `INSERT ... ON CONFLICT`, a conditional
+update carrying the expected version, `SELECT FOR UPDATE`, a compare-and-swap. **Control.**
+
+### M6. Fire-and-forget async
+
+**Signal**: a promise not awaited, a goroutine with no error path, `asyncio.create_task` with
+no reference kept, a background write nobody joins.
+
+**Why it bites**: errors vanish. Worse, the process may exit before the work completes, so
+writes are lost silently and non-deterministically.
+
+**Device**: `no-floating-promises` as a lint error, an errgroup, structured concurrency,
+holding and awaiting the task. **Warning** from the linter, which is the practical answer
+in TypeScript, Python and Go. Rust is the closest thing to an exception: futures are lazy and `#[must_use]`, so a dropped
+future produces a compiler warning without any linter. That is **Warning**, for free; add
+`#![deny(unused_must_use)]` to make the build fail and it becomes **Control**.
+
+### M7. Order-dependent migrations and deploys
+
+**Signal**: a migration that drops or renames a column in the same deploy as the code change;
+a migration and code that must land in a specific order with nothing enforcing it.
+
+**Why it bites**: during the rollout window, old code runs against the new schema. This is an
+outage, not a bug.
+
+**Device**: expand/contract, add, backfill, dual-write, switch, then drop in a later deploy, with a CI gate that blocks destructive DDL from co-deploying with code changes. **Control**
+via the gate; the pattern itself is the design.
+
+---
+
+## Cross-cutting, devices that were removed
+
+Several of these are hazards of removal, someone installed a device and someone else took
+it out. Others (X2, X5) are defaults nobody chose: the language ships them switched the wrong
+way and they stay that way until someone notices.
+Treat them with more suspicion than a missing device, since the code around them was written
+by someone who knew the failure was possible.
+
+### X1. Swallowed errors
+
+**Signal**: `catch {}`, `except: pass`, `except Exception: pass`, `_ = err`, `catch (e) {
+console.log(e) }` with execution continuing, `.catch(() => null)`.
+
+**Why it bites**: converts a loud failure into a quiet wrong answer: the exact inversion of
+mistake-proofing. The system continues on corrupted assumptions.
+
+**Device**: handle it, or let it propagate. Where absorbing genuinely is correct, the comment
+must name which specific failure is expected and why continuing is safe; catch that specific
+type, not everything. Enforce with `no-empty` / bare-except lint rules as errors. **Warning.**
+
+### X2. Silent coercion and fallback
+
+**Signal**: `value || default` where `0`/`""`/`false` are legal values; `parseInt` without a
+radix or a NaN check; `int(x)` in a try/except returning a default; `.unwrap_or_default()` on
+a genuine error; `?.` chains ending in `undefined` that flow into logic.
+
+**Why it bites**: produces a plausible value from bad input. The wrongness surfaces far away,
+where the cause is invisible.
+
+**Device**: `??` instead of `||` where zero is legal; explicit parse with an error branch;
+fail at the boundary rather than substituting. **Control** at the parse site.
+
+### X3. Disabled tests
+
+**Signal**: `it.only`, `describe.skip`, `@pytest.mark.skip`, `t.Skip()`, `#[ignore]`: especially without a reason. Lint and type-checker suppressions (`eslint-disable`,
+`# type: ignore`, `@ts-ignore`, `#nosec`) are X4, and the detector splits them the same way.
+
+**Why it bites**: a Detection-rung device switched off, usually temporarily, permanently. The
+suite stays green and stops meaning anything.
+
+**Device**: fail CI on focused/skipped tests; require a justification comment and an issue
+link on every suppression; count suppressions and ratchet the number downward. **Warning.**
+
+### X4. Escape hatches in the type system
+
+**Signal**: `any`, `as unknown as T`, `!` non-null assertion, `interface{}` with a type
+switch, `# type: ignore`, `unsafe`, `cast()`, `Object` as a parameter type.
+
+**Why it bites**: every one is a place where the type system's guarantee stops. Concentrated
+in the boundary code that most needs the guarantee.
+
+**Device**: ban them by lint at error level with a narrow, justified allowlist; replace with
+parsing at the boundary. **Warning**: a required CI gate is still rung 2 by the ladder in
+[method.md](../../../docs/method.md): it announces the mistake rather than removing the
+ability to make it. Reach **Control** only when the unchecked value cannot be constructed.
+
+### X5. Mutable shared defaults
+
+**Signal**: Python's `def f(items=[])`, a module-level dict used as a cache and mutated, a
+shared config object mutated after construction, class attributes used as instance state.
+
+**Why it bites**: state leaks between calls, requests, or tests. The symptom is
+order-dependent behavior that disappears when you try to reproduce it.
+
+**Device**: `None` sentinel with in-function construction, frozen/immutable value types,
+per-request construction. `B006` in ruff/flake8-bugbear enforces the argument-default case
+only; the module-level cache, the shared config object and the mutable class attribute have
+no lint rule and need review or a type that cannot be mutated.
+**Warning**, or **Control** with frozen types.
diff --git a/skills/poka-yoke-audit/references/lang-python.md b/skills/poka-yoke-audit/references/lang-python.md
new file mode 100644
index 000000000..0079b0001
--- /dev/null
+++ b/skills/poka-yoke-audit/references/lang-python.md
@@ -0,0 +1,181 @@
+# Python Devices
+
+Python's type hints are optional and unenforced at runtime, which splits every device into two
+questions: what the checker catches, and what actually holds when the code runs.
+
+**Prerequisite**: `mypy --strict` (or `pyright` in strict mode) as a *required* CI check.
+Without it, annotations are documentation, rung zero. Pair it with `ruff` at error level.
+
+## Contact, NewType for cheap distinctness
+
+```python
+from typing import NewType
+
+UserId = NewType("UserId", str)
+OrderId = NewType("OrderId", str)
+
+def transfer(src: UserId, dst: UserId) -> None: ...
+
+transfer(order_id, user_id) # mypy: error: zero runtime cost
+```
+
+`NewType` is free at runtime and stops the mix-up at check time. It does not validate, use it
+when the concepts differ but the shape doesn't need checking.
+
+## Contact, parse at the boundary with Pydantic
+
+When the value needs checking, parse into a model and let the type carry the proof:
+
+```python
+from pydantic import BaseModel, EmailStr, Field, ConfigDict
+
+class CreateUser(BaseModel):
+ model_config = ConfigDict(frozen=True, extra="forbid")
+
+ email: EmailStr
+ age: int = Field(ge=0, le=150)
+```
+
+Two settings do most of the work. `extra="forbid"` turns a typo'd field into an error instead
+of a silently ignored key: the difference between a 400 and a user whose preference never
+saved. `frozen=True` blocks reassignment of the model's fields, so nothing downstream can
+quietly replace what you verified. It is shallow, though: a `list` or `dict` field is still
+mutable in place, so reach for `tuple`, `frozenset`, or a nested frozen model where that
+matters.
+
+Apply at every edge: request bodies, queue messages, third-party responses, file loads.
+
+## Contact, keyword-only arguments
+
+Python's answer to swapped parameters, and it costs one character:
+
+```python
+def transfer(*, source: AccountId, dest: AccountId, amount: Money) -> None: ...
+
+transfer(source=a, dest=b, amount=m) # the only legal form
+transfer(a, b, m) # TypeError
+```
+
+Force keyword-only for anything with more than two parameters, and always when two share a
+type. This is Warning-rung. It makes the mistake visible rather than impossible, but it is
+the highest-value one-character change in the language.
+
+## Fixed-value, exhaustiveness
+
+```python
+from typing import assert_never, Literal
+
+Status = Literal["pending", "active", "closed"]
+
+def label(s: Status) -> str:
+ match s:
+ case "pending": return "Pending"
+ case "active": return "Active"
+ case "closed": return "Closed"
+ case _: assert_never(s) # mypy errors here if a variant is unhandled
+```
+
+`assert_never` turns "someone added a status" into a build failure at every site that must
+change. Works with `Literal`, `Enum`, and tagged dataclass unions.
+
+## Fixed-value, config validated at startup
+
+```python
+from pydantic_settings import BaseSettings
+
+class Settings(BaseSettings):
+ database_url: str
+ stripe_key: str
+ region: str # no default: an unset value should stop the deploy
+
+settings = Settings() # raises at import, before the service reports healthy
+```
+
+Import this once at startup and pass the object down. Every `os.getenv` buried in a handler is
+a 3am page waiting for the one request that reaches it.
+
+## Contact, immutable value objects
+
+```python
+from dataclasses import dataclass
+
+@dataclass(frozen=True, slots=True, kw_only=True)
+class Money:
+ cents: int
+ currency: str
+
+ def __add__(self, other: "Money") -> "Money":
+ if self.currency != other.currency:
+ raise ValueError(f"cannot add {self.currency} to {other.currency}")
+ return Money(cents=self.cents + other.cents, currency=self.currency)
+```
+
+`frozen=True` prevents mutation after validation, `slots=True` makes a typo'd attribute
+assignment an `AttributeError` rather than a silently-created new attribute, and `kw_only=True`
+kills positional swaps. Three flags, three hazard classes closed.
+
+## Motion-step, context managers
+
+Any acquire/release pair belongs in a context manager. Never expose `open()`/`close()` as
+separate public methods: the error path will leak, and only under load.
+
+```python
+from contextlib import contextmanager
+
+@contextmanager
+def transaction(conn):
+ tx = conn.begin()
+ try:
+ yield tx
+ tx.commit()
+ except Exception:
+ tx.rollback()
+ raise # re-raise: swallowing here would be X1
+```
+
+## Python-specific traps worth checking every time
+
+- **Mutable default arguments**: `def f(items=[])` shares one list across every call. Use
+ `None` and construct inside. Caught by ruff `B006`.
+- **Bare `except:`** catches `KeyboardInterrupt` and `SystemExit` too. Caught by `E722`.
+- **`assert` for validation** is stripped under `python -O`. Never use it for anything
+ security- or correctness-critical; raise instead.
+- **Naive `datetime.now()`**: use `datetime.now(timezone.utc)`, and inject a clock so time
+ is testable. Caught by ruff `DTZ`.
+- **Float money**: use `int` cents or `decimal.Decimal`, never `float`.
+- **`==` vs `is`** on strings and ints works by accident via interning and breaks in
+ production on longer values. Caught by `F632`.
+- **`asyncio.create_task` without keeping a reference**: the task can be garbage collected
+ mid-flight, so the work silently doesn't happen. Caught by ruff `RUF006`.
+
+## Ruff rule sets that are poka-yoke
+
+Style rules aren't mistake-proofing; these are, which is why `E` appears only as its
+bug-shaped subsets and not whole. Select at error level:
+
+```toml
+[tool.ruff.lint]
+select = [
+ "F", # pyflakes: undefined names, unused imports
+ "E4", "E7", "E9", # pycodestyle's bug-shaped rules: bare except, `== None`, syntax errors
+ "B", # bugbear: mutable defaults, loop variable capture, assert-on-tuple
+ "S", # bandit: hardcoded secrets, unsafe subprocess, weak crypto
+ "DTZ", # naive datetimes
+ "ASYNC", # blocking calls inside async functions
+ "RUF006", # dangling asyncio tasks
+ "PLE", # pylint errors: genuine bugs only
+ "T20", # stray print/pprint
+]
+```
+
+## Known limits
+
+- **Annotations are not enforced at runtime.** Anything crossing a boundary, or reachable
+ from unchecked code, needs a real runtime parse. Pydantic is how you get Control; mypy
+ alone gives you Control only over code mypy actually checks.
+- **`Any` is contagious** and an untyped dependency reintroduces it silently. Set
+ `disallow_any_unimported` and `warn_return_any`; audit `# type: ignore` comments and require
+ a reason on each.
+- **No affine types**, so use-after-close isn't preventable; context managers are the answer.
+- **Monkey-patching means no encapsulation is absolute.** Push invariants that truly must hold
+ into the database rather than into a class.
diff --git a/skills/poka-yoke-audit/references/lang-rust-go.md b/skills/poka-yoke-audit/references/lang-rust-go.md
new file mode 100644
index 000000000..b2a0614df
--- /dev/null
+++ b/skills/poka-yoke-audit/references/lang-rust-go.md
@@ -0,0 +1,213 @@
+# Rust and Go Devices
+
+Two languages at opposite ends of the expressiveness spectrum. Rust can encode almost any
+invariant in types; Go deliberately cannot, so its devices lean on convention plus tooling.
+Know which one you're in before proposing a device.
+
+---
+
+# Rust
+
+Rust's type system reaches Control for more hazard classes than any other mainstream language.
+The affine type system in particular is the only mainstream answer to use-after-move, and it
+turns use-after-close into a compile error rather than a convention, where Python has context
+managers, TypeScript has scope-bound callbacks, and Go has `defer`, Rust has the compiler.
+
+## Contact, newtypes and smart constructors
+
+```rust
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct UserId(Uuid);
+
+#[derive(Debug, Clone)]
+pub struct Email(String);
+
+impl Email {
+ // The only way to build one. Private field means no bypass, even in-crate
+ // if you put it behind a module boundary.
+ pub fn parse(s: &str) -> Result {
+ if !s.contains('@') { return Err(InvalidEmail); }
+ Ok(Email(s.to_owned()))
+ }
+ pub fn as_str(&self) -> &str { &self.0 }
+}
+```
+
+A private field plus a fallible constructor means possessing an `Email` *is* proof of
+validation. This is the strongest form of parse-don't-validate available anywhere.
+
+## Contact, enums make illegal states unrepresentable
+
+```rust
+// Each variant carries exactly the data that variant has. There is no
+// "succeeded with an error", because it cannot be written.
+pub enum JobState {
+ Queued { enqueued_at: DateTime },
+ Running { started_at: DateTime, worker: WorkerId },
+ Succeeded { output: Output },
+ Failed { error: JobError, retries: u32 },
+}
+```
+
+`match` without a catch-all is exhaustive by default, adding a variant breaks the build
+everywhere it must. Avoid `_ => {}` arms in domain logic for exactly this reason: the wildcard
+is what turns a compile error into a silent fallthrough two releases later.
+
+## Motion-step, typestate
+
+Ownership makes typestate genuinely practical, since each transition consumes the old state:
+
+```rust
+pub struct Draft;
+pub struct Validated;
+
+pub struct Order { items: Vec- , _state: PhantomData
}
+
+impl Order {
+ pub fn validate(self) -> Result, ValidationError> { /* … */ }
+}
+
+impl Order {
+ // submit() does not exist on Order. Not "returns an error", does not exist.
+ pub fn submit(self) -> Result { /* … */ }
+}
+```
+
+The consumed `self` means the draft is gone after validation, so a stale unvalidated copy
+cannot be submitted later.
+
+## Fixed-value, make errors impossible to ignore
+
+`#[must_use]` on `Result` is built in; add it to your own types where dropping the value is a
+bug. Then set the lints:
+
+```toml
+[workspace.lints.clippy]
+unwrap_used = "deny"
+expect_used = "warn" # allow in tests and startup with a reason
+panic = "deny"
+indexing_slicing = "deny" # forces .get() and a real branch
+float_cmp = "deny"
+arithmetic_side_effects = "warn" # forces checked_/saturating_ where overflow matters
+todo = "deny"
+dbg_macro = "deny"
+```
+
+`unwrap_used = "deny"` is the highest-value line in that block: it converts every "this can't
+fail" assumption into an explicit decision at review time.
+
+## Rust limits
+
+- **`unsafe` and `unwrap` are the escape hatches.** Deny both by lint and require a
+ `// SAFETY:` comment for each `unsafe` block.
+- **Compile-time only.** Deserialized input still needs `serde` with `deny_unknown_fields`.
+- **Panics bypass the type system.** A device that panics is Warning, not Control.
+- **Typestate has real ergonomic cost.** Reserve it for genuinely dangerous sequences, payments, resource lifecycles, protocol state: not for every builder.
+
+---
+
+# Go
+
+Go rejects most compile-time expressiveness by design. Its devices are therefore fewer, and
+tooling plus data-layer constraints carry more of the load. Say so plainly when you propose a
+device, Control is often not reachable here, and pretending otherwise is worse than
+acknowledging the rung.
+
+## Contact, defined types
+
+```go
+type UserID string
+type OrderID string
+
+func Transfer(from, to UserID) error { ... }
+// Transfer(orderID, userID), compile error, because these are defined types, not aliases.
+```
+
+Use `type X string` (a defined type), never `type X = string` (an alias, which gives you
+nothing). This is the one genuine Control-rung contact device Go offers, and it is
+underused.
+
+## Contact, functional options instead of boolean flags
+
+```go
+type Option func(*Config)
+
+func WithTimeout(d time.Duration) Option { return func(c *Config) { c.Timeout = d } }
+func WithRetries(n int) Option { return func(c *Config) { c.Retries = n } }
+
+func New(addr string, opts ...Option) (*Client, error) { ... }
+```
+
+Every option is named at the call site, `time.Duration` carries its unit in the type, and
+adding an option later doesn't break callers. This replaces both the boolean-flag hazard and
+the implicit-units hazard.
+
+## Motion-step, constructors and defer
+
+```go
+func NewClient(addr string) (*Client, error) {
+ // Fully ready on return. No Connect() to forget.
+}
+
+conn, err := pool.Acquire(ctx)
+if err != nil { return err }
+defer conn.Release() // on the line after acquisition, always
+```
+
+Put `defer` immediately after the acquisition, before any other statement. Any code between
+the two is a leak on the error path.
+
+## Fixed-value, exhaustiveness
+
+Go has no exhaustive switch. Use a linter:
+
+```yaml
+# .golangci.yml
+version: "2"
+
+linters:
+ enable:
+ - errcheck # unchecked errors: the single most valuable Go linter
+ - exhaustive # non-exhaustive switch over typed constants
+ - bodyclose # unclosed HTTP response bodies
+ - rowserrcheck # unchecked sql.Rows.Err
+ - sqlclosecheck
+ - contextcheck # context not propagated
+ - nilerr # returning nil after a non-nil error
+ - noctx # HTTP requests without a context
+ - gosec
+ settings:
+ exhaustive:
+ default-signifies-exhaustive: false
+```
+
+That is the v2 schema. golangci-lint v2 refuses to run against a v1 file rather than ignoring
+the parts it no longer understands, so run `golangci-lint migrate` over an existing config
+before upgrading.
+
+`errcheck` is non-negotiable, Go's error convention is entirely opt-in without it, and
+`_ = doSomething()` is how data loss enters a Go codebase.
+
+## Go-specific traps
+
+- **Nil maps** accept reads but panic on write. Construct with `make` in the constructor.
+- **Loop variable capture** in goroutines, fixed in Go 1.22+, still present in older
+ codebases and vendored code.
+- **`time.Duration` vs bare int**: always take a `Duration`; never an `int` seconds.
+- **Zero values are valid**, so a struct with a missing field looks initialized. Use a
+ constructor that returns `(T, error)` and unexported fields to force it.
+- **Slices share backing arrays**, `append` to a sub-slice can mutate the original. Use
+ three-index slicing `s[a:b:b]` when handing a slice out.
+- **`context.Context` dropped** across a call boundary silently disables cancellation and
+ timeouts. `contextcheck` catches it.
+
+## Go limits
+
+Go cannot express: exhaustive matching, non-nullable references, immutability, typestate, or
+generic constraints rich enough for units. Its Control-rung devices are essentially defined
+types, unexported fields with constructors, and the database schema.
+
+The practical consequence: in Go, **push more invariants into the database and into required
+CI checks** than you would in Rust or TypeScript. `NOT NULL`, `CHECK`, and unique constraints
+are doing work the language declines to do, and `golangci-lint` as a required check is what
+makes the rest hold.
diff --git a/skills/poka-yoke-audit/references/lang-typescript.md b/skills/poka-yoke-audit/references/lang-typescript.md
new file mode 100644
index 000000000..470b626ec
--- /dev/null
+++ b/skills/poka-yoke-audit/references/lang-typescript.md
@@ -0,0 +1,147 @@
+# TypeScript / JavaScript Devices
+
+What the type system can and cannot enforce, and the constructs that get you to Control.
+
+**Prerequisite**: none of this is load-bearing without `strict: true` in tsconfig and
+`tsc --noEmit` as a *required* CI check. A branded type in a repo that doesn't typecheck in CI
+is a comment. Start there.
+
+Also enable `noUncheckedIndexedAccess` (array access returns `T | undefined`, which is the
+truth) and `exactOptionalPropertyTypes`. Both catch real mistakes that `strict` alone misses.
+
+## Contact, branded types
+
+TypeScript is structurally typed, so `type UserId = string` gives you nothing. Branding adds a
+phantom property that exists only at compile time:
+
+```ts
+declare const brand: unique symbol;
+type Brand = T & { readonly [brand]: B };
+
+export type UserId = Brand;
+export type OrderId = Brand;
+
+export const UserId = (s: string): UserId => s as UserId;
+
+// transfer(orderId, userId) is now a compile error
+declare function transfer(from: UserId, to: UserId): void;
+```
+
+Zero runtime cost, no wrapper object. Pair the constructor with validation when the string has
+a shape worth checking, and it becomes a parse (below) rather than a cast.
+
+## Contact, parse, don't validate
+
+```ts
+import { z } from "zod";
+
+const Email = z.string().email().brand<"Email">();
+export type Email = z.infer;
+
+// At the boundary, and only here:
+const parsed = Email.safeParse(req.body.email);
+if (!parsed.success) return res.status(400).json({ error: parsed.error.format() });
+
+sendWelcome(parsed.data); // sendWelcome(to: Email) cannot receive an unvalidated string
+```
+
+Zod's `.brand()` composes validation and branding in one step, which is the ideal shape: short
+of an `as` cast, the only way to obtain an `Email` is to have parsed one, which is why the
+lint against `as unknown as T` is part of the device, not a style preference.
+
+Apply at every edge: HTTP handlers, queue consumers, `process.env`, third-party responses,
+file reads. `JSON.parse` returns `any` and `any` is where guarantees go to die.
+
+## Contact, discriminated unions over optional bags
+
+```ts
+// Permits "success with an error", "loading with data", only three combinations are real
+type Result = { status: string; data?: User; error?: Error };
+
+// Permits exactly what exists
+type Result =
+ | { status: "loading" }
+ | { status: "success"; data: User }
+ | { status: "error"; error: Error };
+```
+
+The second version makes `result.data` inaccessible until you've narrowed to `"success"`,
+so the check cannot be forgotten: the compiler asks for it.
+
+## Fixed-value, exhaustiveness
+
+```ts
+function assertNever(x: never): never {
+ throw new Error(`Unhandled variant: ${JSON.stringify(x)}`);
+}
+
+switch (result.status) {
+ case "loading": return spinner();
+ case "success": return view(result.data);
+ case "error": return errorView(result.error);
+ default: return assertNever(result);
+}
+```
+
+Adding a variant now breaks the build at every switch that must change. Enforce repo-wide with
+`@typescript-eslint/switch-exhaustiveness-check`. This is the cheapest high-value device in
+the language: one line per switch.
+
+## Motion-step, builders and typestate
+
+Encode required steps in the type so `.delete()` doesn't exist until they've run:
+
+```ts
+class QueryBuilder {
+ from(t: string): QueryBuilder { /* … */ }
+ where(c: Cond): QueryBuilder { /* … */ }
+
+ // Only callable once both have been set
+ delete(this: QueryBuilder): string { /* … */ }
+}
+```
+
+The `this` parameter is the key trick: it constrains which instances a method exists on.
+This makes "delete without a where clause" a compile error rather than an incident.
+
+## Motion-step, required idempotency
+
+```ts
+// Optional key = suggestion. Required key = device.
+function charge(account: AccountId, amount: Money, idempotencyKey: IdempotencyKey): Promise
+```
+
+Back it with a unique index on `(account_id, idempotency_key)` so the second attempt is
+rejected by the database, not by application logic that might be skipped.
+
+## The lint rules that are actually poka-yoke
+
+Style rules are not mistake-proofing. These are, set every one to `error`:
+
+| Rule | Mistake prevented |
+|---|---|
+| `@typescript-eslint/no-floating-promises` | A write that is never awaited and silently lost |
+| `@typescript-eslint/no-misused-promises` | An async function passed where sync is expected |
+| `@typescript-eslint/switch-exhaustiveness-check` | New enum variant silently unhandled |
+| `@typescript-eslint/no-unnecessary-condition` | A check that is always true, usually a real bug |
+| `@typescript-eslint/no-explicit-any` | Type guarantees silently disabled |
+| `@typescript-eslint/no-unsafe-assignment` / `-return` / `-argument` | `any` leaking from untyped libraries |
+| `no-empty` (with `allowEmptyCatch: false`) | Empty catch blocks |
+| `eqeqeq` | `==` coercion surprises |
+| `require-atomic-updates` | Read-modify-write races across `await` |
+| `no-restricted-syntax` on `it.only` / `describe.only` | A focused test disabling the rest of the suite |
+
+`no-empty` only sees the empty block: a catch holding a comment, or one that logs and carries
+on, swallows the error and passes the lint. Catching that shape is a review job.
+
+## Known limits
+
+- **No runtime enforcement.** Types vanish at compile time. Anything crossing a boundary needs
+ a runtime schema, and anything reachable from untyped JavaScript needs a runtime check.
+- **Structural typing** means every distinct concept needs explicit branding; the compiler
+ will not distinguish them for you.
+- **`as` casts are unchecked.** Confine them to the inside of parse functions, and lint
+ against `as unknown as T` anywhere else.
+- **No affine types**, so use-after-move and use-after-close cannot be prevented; scope-bound
+ patterns (a `withConnection(fn)` callback rather than `open`/`close`) are the closest you
+ get, and they are usually enough.
diff --git a/skills/poka-yoke-audit/scripts/detect_hazards.py b/skills/poka-yoke-audit/scripts/detect_hazards.py
new file mode 100755
index 000000000..2546fdc6a
--- /dev/null
+++ b/skills/poka-yoke-audit/scripts/detect_hazards.py
@@ -0,0 +1,621 @@
+#!/usr/bin/env python3
+"""Heuristic detector for poka-yoke hazards, shapes in code that make mistakes easy.
+
+This is a fast first pass, not an oracle. It finds textually-detectable hazards so a
+reviewer can spend their attention on the interface-level questions a regex cannot ask.
+Expect real false positives; every hit is a question, not a verdict.
+
+Hazard IDs match references/hazard-catalog.md. Standard library only.
+
+Examples:
+ detect_hazards.py --diff # uncommitted changes, changed lines only
+ detect_hazards.py --staged # staged changes
+ detect_hazards.py --since HEAD~10 # last 10 commits
+ detect_hazards.py --paths src/ lib/ # explicit paths
+ detect_hazards.py --diff --severity high # only the ones that bite hardest
+ detect_hazards.py --paths . --json # machine-readable
+"""
+
+from __future__ import annotations
+
+import argparse
+import ast
+import json
+import os
+import re
+import subprocess
+import sys
+from dataclasses import dataclass, field
+from pathlib import Path
+
+# --------------------------------------------------------------------------------------
+# Rule definitions
+# --------------------------------------------------------------------------------------
+
+PY = {".py", ".pyi"}
+TS = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"}
+GO = {".go"}
+RS = {".rs"}
+SQL = {".sql"}
+ALL_EXTS = PY | TS | GO | RS | SQL
+
+LENS = {"C": "contact", "F": "fixed-value", "M": "motion-step", "X": "removed-device"}
+
+
+@dataclass(frozen=True)
+class Rule:
+ id: str
+ name: str
+ severity: str # high | medium | low
+ exts: frozenset
+ pattern: re.Pattern
+ device: str
+ negate: re.Pattern | None = None # if this also matches the line, skip
+
+
+def R(id, name, severity, exts, pattern, device, negate=None, flags=0):
+ return Rule(
+ id=id,
+ name=name,
+ severity=severity,
+ exts=frozenset(exts),
+ pattern=re.compile(pattern, flags),
+ device=device,
+ negate=re.compile(negate, flags) if negate else None,
+ )
+
+
+RULES: list[Rule] = [
+ # ---- X: devices that were removed -------------------------------------------------
+ R("X1", "Swallowed error", "high", TS,
+ r"catch\s*(\([^)]*\))?\s*\{\s*\}",
+ "Handle it or let it propagate; catching to do nothing turns a loud failure quiet."),
+ R("X1", "Swallowed error", "high", TS,
+ r"\.catch\s*\(\s*\(\s*\)\s*=>\s*(\{\s*\}|null|undefined)\s*\)",
+ "Handle the rejection or let it propagate."),
+ R("X1", "Bare except", "high", PY,
+ r"^\s*except\s*:",
+ "Catch the specific exception; bare except also swallows KeyboardInterrupt/SystemExit."),
+ R("X1", "Discarded error return", "high", GO,
+ r",\s*_\s*:?=\s*\w|^\s*_\s*=\s*\w[\w.]*\(",
+ "Check the error. Enable errcheck in golangci-lint to make this a build failure."),
+ R("X2", "Unwrap / expect on a fallible value", "medium", RS,
+ r"\.(unwrap|expect)\s*\(",
+ "Propagate with ? or handle the error; deny clippy::unwrap_used."),
+ R("X2", "Silent default on error", "medium", RS,
+ r"\.unwrap_or_default\s*\(\s*\)",
+ "A default on an error path hides the failure; branch on the error explicitly."),
+ R("X2", "parseInt without radix", "medium", TS,
+ r"parseInt\s*\(\s*[^,)]+\)",
+ "Pass the radix and check for NaN, or use a schema parse at the boundary."),
+ R("X3", "Focused test disables the suite", "high", TS,
+ r"\b(it|test|describe|context)\.only\s*\(|\bfdescribe\s*\(|\bfit\s*\(",
+ "Remove before merge; fail CI on focused tests."),
+ R("X3", "Skipped test", "medium", PY,
+ r"@pytest\.mark\.skip|@unittest\.skip",
+ "A skipped test is a detection device switched off. Fix or delete it."),
+ R("X3", "Skipped test", "medium", TS,
+ r"\b(it|test|describe)\.skip\s*\(|\bxit\s*\(|\bxdescribe\s*\(",
+ "A skipped test is a detection device switched off. Fix or delete it."),
+ R("X3", "Skipped test", "medium", GO, r"\bt\.Skip\s*\(",
+ "A skipped test is a detection device switched off. Fix or delete it."),
+ R("X3", "Skipped test", "medium", RS, r"^\s*#\[ignore\]",
+ "A skipped test is a detection device switched off. Fix or delete it."),
+ R("X4", "Type-checker suppression", "medium", TS,
+ r"@ts-ignore|@ts-nocheck|\bas\s+unknown\s+as\b|eslint-disable(?!-next-line\s+\S+\s+--)",
+ "Each suppression is a hole in the guarantee. Require a reason and an issue link."),
+ R("X4", "Explicit any", "medium", TS,
+ r":\s*any\b||Array|as\s+any\b",
+ "any disables the type system exactly where guarantees matter. Parse at the boundary."),
+ R("X4", "Type-checker suppression", "medium", PY,
+ r"#\s*type:\s*ignore(?!\[)",
+ "Narrow it to a specific error code and add a reason."),
+ R("X4", "Untyped container", "low", GO,
+ r"\binterface\{\}|\bany\b\s*[,)\]]",
+ "Prefer a concrete type or a constrained generic."),
+ R("X4", "unsafe block", "medium", RS, r"\bunsafe\s*\{",
+ "Require a // SAFETY: comment stating the invariant being upheld."),
+ R("X5", "Mutable default argument", "high", PY,
+ r"def\s+\w+\s*\([^)]*=\s*(\[\s*\]|\{\s*\}|set\s*\(\s*\))",
+ "Use None and construct inside the function; the default is shared across all calls."),
+
+ # ---- F: fixed-value ---------------------------------------------------------------
+ R("F2", "Unbounded DELETE", "high", SQL | PY | TS | GO | RS,
+ r"\bDELETE\s+FROM\b(?!.*\bWHERE\b)",
+ "Require a WHERE clause; refuse an empty predicate.", flags=re.I),
+ R("F2", "Unbounded UPDATE", "high", SQL | PY | TS | GO | RS,
+ r"\bUPDATE\s+[\w.\"`\[\]]+\s+SET\b(?!.*\bWHERE\b)",
+ "Require a WHERE clause; refuse an empty predicate.", flags=re.I),
+ R("F2", "Destructive DDL", "high", SQL | PY | TS | GO | RS,
+ r"\b(DROP\s+(TABLE|COLUMN|DATABASE|SCHEMA)|TRUNCATE\s+TABLE)\b",
+ "Use expand/contract; gate destructive DDL behind an explicit CI acknowledgment.",
+ flags=re.I),
+ R("F2", "Bulk delete", "high", TS | PY,
+ r"\.(deleteMany|delete_many|destroy_all|delete_all|drop_all|removeMany)\s*\(\s*\)",
+ "Refuse an empty filter; cap the affected count and require confirmation above it."),
+ R("F2", "Recursive force remove", "high", ALL_EXTS,
+ r"rm\s+-[a-zA-Z]*[rR][a-zA-Z]*f|rm\s+-[a-zA-Z]*f[a-zA-Z]*[rR]",
+ "Validate the path is non-empty and inside the expected root before deleting."),
+ R("F4", "Config read away from startup", "medium", PY,
+ r"os\.(getenv|environ)",
+ "Parse the whole config into a typed object at startup so a missing key fails the deploy.",
+ negate=r"(settings|config|conf|env)\.py"),
+ R("F4", "Config read away from startup", "medium", TS,
+ r"process\.env\.\w+",
+ "Parse the whole config into a typed object at startup so a missing key fails the deploy.",
+ negate=r"(config|env|settings)\.(ts|js)"),
+ R("F7", "Unbounded read", "low", PY | TS,
+ r"\.read\s*\(\s*\)|\.readAll\s*\(|ioutil\.ReadAll",
+ "Cap the size at the boundary; an unbounded read is a resource-exhaustion incident."),
+
+ # ---- C: contact -------------------------------------------------------------------
+ R("C2", "Boolean flag parameter", "medium", TS,
+ r"\b\w+\s*:\s*boolean\s*[,)]",
+ "Use an enum, a named options object, or two functions; booleans are unreadable at the call site."),
+ R("C2", "Boolean flag parameter", "medium", GO,
+ r"func\s+\w+\s*\([^)]*\bbool\b[^)]*\)",
+ "Use a named option type; a bare bool is unreadable at the call site."),
+ R("C2", "Boolean default parameter", "medium", PY,
+ r"def\s+\w+\s*\([^)]*\b\w+\s*(:\s*bool\s*)?=\s*(True|False)",
+ "Use an enum, or at minimum make it keyword-only so the name appears at the call site."),
+ R("C5", "Duration without a unit", "medium", TS | GO | PY,
+ r"\b(timeout|delay|interval|ttl|expiry|duration|retryAfter|retry_after)\s*:?\s*(number|int|float|=\s*\d+)",
+ "Encode the unit in the type (Duration) or in the name (timeoutMs). Unit mismatches are silent."),
+ R("C6", "Money as a float", "high", PY | TS | GO | RS,
+ r"\b(price|amount|total|balance|cost|fee|subtotal|revenue)\w*\s*:\s*(float|number|f32|f64)\b"
+ r"|\bfloat\s*\(\s*\w*(price|amount|total|balance)",
+ "Use integer minor units in a Money type carrying its currency, or a decimal type."),
+ R("C7", "Unvalidated parse", "high", TS,
+ r"JSON\.parse\s*\(",
+ "Parse into a schema (zod/valibot) at the boundary; JSON.parse returns any."),
+ R("C7", "Unvalidated request body", "high", PY,
+ r"(request|req)\.(json|get_json)\s*\(\s*\)(?!\s*\))",
+ "Parse into a Pydantic model with extra='forbid' so unknown or missing fields fail loudly."),
+ R("C9", "Naive datetime", "medium", PY,
+ r"datetime\.utcnow\s*\(\s*\)|datetime\.now\s*\(\s*\)",
+ "Use datetime.now(timezone.utc), and inject a clock so time is testable."),
+
+ # ---- M: motion-step ---------------------------------------------------------------
+ R("M4", "Unmanaged resource", "medium", PY,
+ r"^\s*(\w+\s*=\s*)?open\s*\(",
+ "Use a context manager; the error path will leak otherwise.",
+ negate=r"\bwith\b"),
+ R("M6", "Dangling async task", "high", PY,
+ r"^\s*(await\s+)?asyncio\.create_task\s*\(",
+ "Keep a reference; an unreferenced task can be garbage collected mid-flight (ruff RUF006).",
+ negate=r"=\s*(await\s+)?asyncio\.create_task"),
+ R("M6", "Unawaited promise-returning call", "low", TS,
+ r"^\s*\w+\.(save|update|create|delete|insert|write|send|publish|commit)\s*\(",
+ "If this returns a promise, await it: a floating write is silently lost. "
+ "Enable @typescript-eslint/no-floating-promises.",
+ negate=r"\b(await|return|yield|void)\b|\.then\(|=\s"),
+ R("M2", "Retryable effect without an idempotency key", "high", ALL_EXTS,
+ r"\b(def|func|function|fn|async\s+function)\s+\w*(charge|refund|capture|payout|transfer|"
+ r"sendEmail|send_email|publish|notify)\w*\s*[(<]",
+ "Require an idempotency key parameter, backed by a unique constraint on (entity, key).",
+ negate=r"idempot", flags=re.I),
+ R("M1", "Two-phase construction", "medium", ALL_EXTS,
+ r"\b(def|func|function|fn)\s+(init|initialize|connect|setup|configure|start)\s*[(<]",
+ "Have the constructor or a factory return a ready object, or use typestate; "
+ "'call this first' is not enforceable.",
+ negate=r"__init__|func\s+init\s*\(\s*\)\s*\{"),
+
+ # ---- F1: exhaustiveness -----------------------------------------------------------
+ R("F1", "Wildcard match arm", "medium", RS,
+ r"^\s*_\s*=>",
+ "In domain logic a wildcard turns a future compile error into a silent fallthrough."),
+ R("F1", "Switch without exhaustiveness check", "low", TS,
+ r"^\s*switch\s*\(",
+ "Add a default arm calling assertNever(x: never) so a new variant breaks the build."),
+ R("F1", "Switch without a default", "low", GO,
+ r"^\s*switch\s+\w+\s*\{",
+ "Enable the 'exhaustive' linter with default-signifies-exhaustive: false."),
+]
+
+# Rules a real linter already does better. They stay available behind --all for repos that
+# do not run those linters, but they are off by default: a tool that does eight things
+# nothing else does is more useful than one doing forty things worse. The value here is the
+# pointer, knowing which linter to enable beats a second-rate reimplementation of it.
+COVERED_BY: dict[tuple[str, str], str] = {
+ ("X1", "Swallowed error"): "eslint no-empty",
+ ("X1", "Bare except"): "ruff E722",
+ ("X1", "Discarded error return"): "golangci-lint errcheck",
+ ("X2", "Unwrap / expect on a fallible value"): "clippy::unwrap_used",
+ ("X2", "Silent default on error"): "clippy",
+ ("X2", "parseInt without radix"): "eslint radix",
+ ("X3", "Focused test disables the suite"): "eslint jest/no-focused-tests",
+ ("X3", "Skipped test"): "eslint jest/no-disabled-tests",
+ ("X4", "Type-checker suppression"): "@typescript-eslint/ban-ts-comment, mypy --strict",
+ ("X4", "Explicit any"): "@typescript-eslint/no-explicit-any",
+ ("X4", "Untyped container"): "golangci-lint",
+ ("X4", "unsafe block"): "clippy",
+ ("X5", "Mutable default argument"): "ruff B006",
+ ("C9", "Naive datetime"): "ruff DTZ",
+ ("M4", "Unmanaged resource"): "ruff SIM115",
+ ("M6", "Dangling async task"): "ruff RUF006",
+ ("F1", "Wildcard match arm"): "clippy::wildcard_enum_match_arm",
+ ("F7", "Unbounded read"): "",
+ ("F3", "assert used for validation"): "ruff S101",
+ ("C6", "Equality comparison on a float"): "ruff PLR0133",
+}
+
+
+# poka-yoke: keyword-only, so the id and the name cannot be passed transposed [control]
+def covered(*, rule_id: str, name: str) -> str:
+ return COVERED_BY.get((rule_id, name), "")
+
+
+# --------------------------------------------------------------------------------------
+# AST pass (Python only), catches what regexes can't see
+# --------------------------------------------------------------------------------------
+
+
+def python_ast_findings(path: Path, source: str) -> list[dict]:
+ """Structural checks that need real parsing: adjacent same-type params, assert-as-
+ validation, and equality comparison on floats."""
+ out = []
+ try:
+ tree = ast.parse(source, filename=str(path))
+ except SyntaxError:
+ return out
+
+ for node in ast.walk(tree):
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
+ args = node.args.posonlyargs + node.args.args
+ # skip self/cls
+ if args and args[0].arg in ("self", "cls"):
+ args = args[1:]
+ annotated = [(a.arg, ast.unparse(a.annotation)) for a in args if a.annotation]
+ for i in range(len(annotated) - 1):
+ (n1, t1), (n2, t2) = annotated[i], annotated[i + 1]
+ if t1 == t2 and t1 in ("str", "int", "float", "bytes", "bool", "UUID"):
+ out.append({
+ "id": "C1",
+ "name": "Adjacent same-type parameters",
+ "severity": "high",
+ "line": node.lineno,
+ "snippet": f"def {node.name}(..., {n1}: {t1}, {n2}: {t2}, ...)",
+ "device": f"'{n1}' and '{n2}' are both {t1} and can be swapped silently. "
+ "Use NewType per concept, or make them keyword-only.",
+ })
+ # positional args on a wide signature
+ if len(args) >= 4 and not node.args.kwonlyargs:
+ out.append({
+ "id": "C1",
+ "name": "Wide positional signature",
+ "severity": "low",
+ "line": node.lineno,
+ "snippet": f"def {node.name}({len(args)} positional params)",
+ "device": "Make parameters keyword-only with '*' so names appear at the call site.",
+ })
+
+ elif isinstance(node, ast.Assert):
+ out.append({
+ "id": "F3",
+ "name": "assert used for validation",
+ "severity": "medium",
+ "line": node.lineno,
+ "snippet": ast.unparse(node)[:100],
+ "device": "assert is stripped under python -O. Raise an explicit exception instead.",
+ })
+
+ elif isinstance(node, ast.Compare):
+ for op in node.ops:
+ if isinstance(op, (ast.Eq, ast.NotEq)):
+ src = ast.unparse(node)
+ if re.search(r"\d+\.\d+", src):
+ out.append({
+ "id": "C6",
+ "name": "Equality comparison on a float",
+ "severity": "medium",
+ "line": node.lineno,
+ "snippet": src[:100],
+ "device": "Use math.isclose, or a Decimal/integer-minor-unit type.",
+ })
+ return out
+
+
+# --------------------------------------------------------------------------------------
+# File and diff collection
+# --------------------------------------------------------------------------------------
+
+SKIP_DIRS = {
+ ".git", "node_modules", "vendor", "dist", "build", "target", "__pycache__",
+ ".venv", "venv", ".tox", ".mypy_cache", ".pytest_cache", ".next", "coverage",
+ ".terraform", "site-packages",
+}
+
+
+class GitUnavailable(RuntimeError):
+ """git could not answer the question asked of it.
+
+ Previously any git failure became an empty string, which the caller could not tell from
+ "the tree is clean". A detector that reports a clean bill of health because git is broken
+ is the exact failure this file's own rules exist to catch.
+ """
+
+
+def git(*args: str, cwd: Path) -> str:
+ try:
+ r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, timeout=30)
+ except (subprocess.SubprocessError, FileNotFoundError) as exc:
+ raise GitUnavailable(f"could not run git {' '.join(args)}: {exc}") from exc
+ if r.returncode != 0:
+ detail = (r.stderr or r.stdout or "").strip().splitlines()
+ raise GitUnavailable(f"git {' '.join(args)} exited {r.returncode}"
+ + (f": {detail[0]}" if detail else ""))
+ return r.stdout
+
+
+def changed_files_and_lines(cwd: Path, mode: str, since: str | None):
+ """Return {path: set(changed_line_numbers)}. Empty set means 'whole file'."""
+ if mode == "staged":
+ diff_args = ["diff", "--cached", "-U0"]
+ elif mode == "since":
+ diff_args = ["diff", f"{since}..HEAD", "-U0"]
+ else:
+ diff_args = ["diff", "HEAD", "-U0"]
+
+ raw = git(*diff_args, cwd=cwd)
+ if not raw.strip() and mode == "diff":
+ # Clean tree, fall back to recent commits, which is what the user usually means.
+ raw = git("diff", "HEAD~5..HEAD", "-U0", cwd=cwd)
+
+ result: dict[str, set[int]] = {}
+ current = None
+ for line in raw.splitlines():
+ if line.startswith("+++ b/"):
+ current = line[6:]
+ result.setdefault(current, set())
+ elif line.startswith("@@") and current:
+ m = re.search(r"\+(\d+)(?:,(\d+))?", line)
+ if m:
+ start = int(m.group(1))
+ count = int(m.group(2) or 1)
+ result[current].update(range(start, start + count))
+ return {k: v for k, v in result.items() if v}
+
+
+def collect_paths(roots: list[str]) -> list[Path]:
+ out = []
+ for root in roots:
+ p = Path(root)
+ if p.is_file():
+ if p.suffix in ALL_EXTS:
+ out.append(p)
+ elif p.is_dir():
+ for dirpath, dirnames, filenames in os.walk(p):
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")]
+ for fn in filenames:
+ fp = Path(dirpath) / fn
+ if fp.suffix in ALL_EXTS:
+ out.append(fp)
+ return out
+
+
+# --------------------------------------------------------------------------------------
+# Scanning
+# --------------------------------------------------------------------------------------
+
+COMMENT_ONLY = re.compile(r"^\s*(//|#|/\*|\*|--)")
+
+
+def scan_file(path: Path, only_lines: set[int] | None) -> list[dict]:
+ try:
+ source = path.read_text(encoding="utf-8", errors="replace")
+ except (OSError, UnicodeDecodeError):
+ return []
+ if len(source) > 2_000_000:
+ return []
+
+ findings = []
+ ext = path.suffix
+ lines = source.splitlines()
+
+ for lineno, line in enumerate(lines, 1):
+ if only_lines and lineno not in only_lines:
+ continue
+ if COMMENT_ONLY.match(line) or len(line) > 500:
+ continue
+ for rule in RULES:
+ if ext not in rule.exts:
+ continue
+ if not INCLUDE_COVERED and covered(rule_id=rule.id, name=rule.name):
+ continue
+ if rule.negate and (rule.negate.search(line) or rule.negate.search(str(path))):
+ continue
+ if rule.pattern.search(line):
+ findings.append({
+ "id": rule.id,
+ "name": rule.name,
+ "severity": rule.severity,
+ "line": lineno,
+ "snippet": line.strip()[:120],
+ "device": rule.device,
+ })
+
+ if ext in PY:
+ for f in python_ast_findings(path, source):
+ if not INCLUDE_COVERED and covered(rule_id=f["id"], name=f["name"]):
+ continue
+ if not only_lines or f["line"] in only_lines:
+ findings.append(f)
+
+ for f in findings:
+ f["file"] = str(path)
+ f["lens"] = LENS.get(f["id"][0], "unknown")
+ return findings
+
+
+# --------------------------------------------------------------------------------------
+# Output
+# --------------------------------------------------------------------------------------
+
+INCLUDE_COVERED = False
+
+SEV_ORDER = {"high": 0, "medium": 1, "low": 2}
+COLOR = {"high": "\033[31m", "medium": "\033[33m", "low": "\033[90m"}
+RESET = "\033[0m"
+
+
+def render(findings: list[dict], scope: str, use_color: bool) -> str:
+ if not findings:
+ return f"No hazards detected in {scope}.\n\nThe lenses still apply, run them by hand:\n" \
+ " contact: can the wrong thing fit?\n" \
+ " fixed-value: can an incomplete or wrong-sized set pass?\n" \
+ " motion-step: can the steps happen in the wrong order?"
+
+ findings.sort(key=lambda f: (SEV_ORDER[f["severity"]], f["file"], f["line"]))
+ counts = {"high": 0, "medium": 0, "low": 0}
+ for f in findings:
+ counts[f["severity"]] += 1
+
+ out = [
+ f"Poka-yoke hazard scan, {scope}",
+ f"{counts['high']} high · {counts['medium']} medium · {counts['low']} low",
+ "",
+ "Heuristics with real false positives. Read the surrounding code before acting.",
+ "",
+ ]
+
+ grouped: dict[str, list[dict]] = {}
+ for f in findings:
+ grouped.setdefault(f"{f['id']} {f['name']}", []).append(f)
+
+ for key, group in sorted(grouped.items(), key=lambda kv: SEV_ORDER[kv[1][0]["severity"]]):
+ sev = group[0]["severity"]
+ tag = f"{COLOR[sev]}{sev.upper():<6}{RESET}" if use_color else f"{sev.upper():<6}"
+ out.append(f"{tag} {key} ({group[0]['lens']} lens, {len(group)} site"
+ f"{'s' if len(group) > 1 else ''})")
+ out.append(f" device: {group[0]['device']}")
+ for f in group[:8]:
+ out.append(f" {f['file']}:{f['line']} {f['snippet']}")
+ if len(group) > 8:
+ out.append(f" … and {len(group) - 8} more")
+ out.append("")
+
+ return "\n".join(out)
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(
+ description="Detect poka-yoke hazards, shapes in code that make mistakes easy.",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog=__doc__.split("Examples:")[-1],
+ )
+ src = ap.add_mutually_exclusive_group()
+ src.add_argument("--diff", action="store_true",
+ help="scan uncommitted changes (falls back to HEAD~5..HEAD if clean)")
+ src.add_argument("--staged", action="store_true", help="scan staged changes")
+ src.add_argument("--since", metavar="REF", help="scan changes since REF (e.g. HEAD~10)")
+ src.add_argument("--paths", nargs="+", metavar="PATH", help="scan these files or directories")
+ ap.add_argument("--severity", choices=["high", "medium", "low"], default="low",
+ help="minimum severity to report (default: low)")
+ # Until this existed the script ended in a bare `return 0`, so every gate built on it was
+ # decorative: the shipped pre-commit hook, the shipped CI template and this repo's own
+ # "Detector runs clean" step all reported success while printing high-severity findings.
+ # A linter that cannot fail is a linter nobody has to satisfy.
+ ap.add_argument("--fail-on", choices=["high", "medium", "low", "none"], default="low",
+ metavar="SEVERITY",
+ help="exit non-zero when a finding of at least this severity is reported "
+ "(default: low, i.e. any reported finding). Use 'none' to report "
+ "without gating.")
+ ap.add_argument("--id", nargs="+", metavar="ID",
+ help="only report these hazard IDs (e.g. --id C1 F2 M2)")
+ ap.add_argument("--all", action="store_true", dest="include_covered",
+ help="also run the rules a real linter does better (off by default)")
+ ap.add_argument("--json", action="store_true", help="emit JSON")
+ ap.add_argument("--repo", default=".", help="repository root (default: .)")
+ args = ap.parse_args()
+
+ global INCLUDE_COVERED
+ INCLUDE_COVERED = args.include_covered
+ repo = Path(args.repo).resolve()
+ findings: list[dict] = []
+
+ # poka-yoke: an empty scan reports itself instead of looking like a clean bill of health [control]
+ scanned = 0
+
+ if args.paths:
+ scope = f"paths: {', '.join(args.paths)}"
+ for p in collect_paths(args.paths):
+ scanned += 1
+ findings += scan_file(p, None)
+ if scanned == 0:
+ # Zero findings from zero files is not an all-clear, and it used to be
+ # indistinguishable from one. Exit non-zero: failing to do the job should
+ # not look like doing the job and finding nothing.
+ msg = ("Scanned 0 files. This is NOT an all-clear.\n"
+ f"Nothing under {', '.join(args.paths)} has a supported extension.\n"
+ f"Supported: {', '.join(sorted(ALL_EXTS))}")
+ print(json.dumps({"scope": scope, "files_scanned": 0, "count": 0,
+ "findings": [], "error": msg}, indent=2)
+ if args.json else msg, file=sys.stdout if args.json else sys.stderr)
+ return 2
+ else:
+ mode = "staged" if args.staged else ("since" if args.since else "diff")
+ scope = {"staged": "staged changes",
+ "since": f"changes since {args.since}",
+ "diff": "uncommitted changes"}[mode]
+ try:
+ changed = changed_files_and_lines(repo, mode, args.since)
+ except GitUnavailable as exc:
+ # Exit 2, the same code --paths uses for "scanned nothing". Reporting a clean
+ # tree because git is broken is worse than reporting nothing at all: a
+ # pre-commit hook or CI gate reads only the exit code.
+ msg = (f"Could not determine what changed: {exc}\n"
+ f"This is NOT an all-clear. Use --paths to scan explicitly.")
+ print(json.dumps({"scope": scope, "files_scanned": 0, "count": 0,
+ "findings": [], "error": str(exc)}, indent=2)
+ if args.json else msg, file=sys.stdout if args.json else sys.stderr)
+ return 2
+ if not changed:
+ msg = ("No changed files found. The tree may be clean and have no recent commits, "
+ "or this may not be a git repository.\nUse --paths to scan explicitly, "
+ "e.g. detect_hazards.py --paths src/")
+ print(json.dumps({"findings": [], "note": msg}) if args.json else msg)
+ return 0
+ for rel, lines in changed.items():
+ fp = repo / rel
+ if fp.suffix in ALL_EXTS and fp.exists():
+ scanned += 1
+ findings += scan_file(fp, lines)
+
+ threshold = SEV_ORDER[args.severity]
+ findings = [f for f in findings if SEV_ORDER[f["severity"]] <= threshold]
+ if args.id:
+ wanted = {i.upper() for i in args.id}
+ findings = [f for f in findings if f["id"] in wanted]
+
+ if args.json:
+ print(json.dumps({"scope": scope, "files_scanned": scanned,
+ "count": len(findings), "findings": findings}, indent=2))
+ else:
+ print(render(findings, scope, use_color=sys.stdout.isatty()))
+ print(f"\nScanned {scanned} file{'' if scanned == 1 else 's'}.")
+ if not INCLUDE_COVERED:
+ tools = sorted({v.split(",")[0].split()[0] for v in COVERED_BY.values() if v})
+ # len(COVERED_BY) counts ENTRIES, and one entry can suppress several
+ # per-language rules, so it under-reported by three. Count the rules.
+ n_suppressed = sum(1 for r in RULES if (r.id, r.name) in COVERED_BY)
+ print(f"\nNot checked here, {n_suppressed} further hazard rules are covered "
+ f"better by {', '.join(tools)}.\nEnable those rather than relying on this: "
+ f"see assets/devices/lint/. Use --all to run them anyway.")
+
+ if args.fail_on != "none":
+ rank = {"high": 3, "medium": 2, "low": 1}
+ threshold = rank[args.fail_on]
+ gating = [f for f in findings if rank.get(f.get("severity", "low"), 1) >= threshold]
+ if gating:
+ worst = max(rank.get(f.get("severity", "low"), 1) for f in gating)
+ name = {3: "high", 2: "medium", 1: "low"}[worst]
+ if not args.json:
+ print(f"\n{len(gating)} finding(s) at or above --fail-on={args.fail_on} "
+ f"(worst: {name}). Exiting 1.", file=sys.stderr)
+ return 1
+
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/skills/poka-yoke-audit/scripts/device_registry.py b/skills/poka-yoke-audit/scripts/device_registry.py
new file mode 100755
index 000000000..18a1967e6
--- /dev/null
+++ b/skills/poka-yoke-audit/scripts/device_registry.py
@@ -0,0 +1,150 @@
+#!/usr/bin/env python3
+"""Generate a device registry by reading the code, never by remembering to update a file.
+
+An earlier version of this plugin told people to hand-maintain `docs/poka-yoke/registry.md`
+listing every device and the mistake it prevents. That was rung zero by the plugin's own
+argument: a Markdown file someone has to remember to update is training, not a device, and it
+goes stale exactly when it matters: the moment someone deletes a "redundant" constraint.
+
+The fix is to keep the rationale where the device is, as a marker comment, and generate the
+index from those markers. A generated index cannot drift, because there is nothing to forget.
+Delete the constraint and its row disappears; move it and the row follows.
+
+Mark a device at its site, in whatever comment syntax the file uses:
+
+ # poka-yoke: rejects a second charge for the same idempotency key [control]
+ -- poka-yoke: refuses a zero or negative amount [control]
+ // poka-yoke: forgetting to await this write would lose it silently [warning]
+
+The rung in brackets is optional and defaults to unstated. Then:
+
+ python3 scripts/device_registry.py # print the registry
+ python3 scripts/device_registry.py --write docs/poka-yoke/registry.md
+ python3 scripts/device_registry.py --check # CI: fail if the file is stale
+"""
+
+from __future__ import annotations
+
+import argparse
+import re
+import subprocess
+import sys
+from pathlib import Path
+
+MARKER = re.compile(
+ # A bare "*" prefix was tried and removed: it matches markdown bold (**poka-yoke: ...**),
+ # so prose about the plugin was being catalogued as devices. Require a real comment token.
+ r"""(?:^|\s)(?:\#|//|--|/\*|)?\s*$""",
+ re.IGNORECASE,
+)
+
+# Generated benchmark transcripts are data, not code, and they discuss the plugin at length.
+SKIP = {"results", ".git", "node_modules", "vendor", "dist", "build", "target", "__pycache__",
+ ".venv", "venv", ".next", "coverage", ".terraform"}
+
+HEADER = """
+
+# Device Registry
+
+Every mistake-proofing device in this repository, and the mistake each one prevents. The
+"prevents" column is the one that matters: it is what stops a future engineer removing a
+device that has never fired because it is doing its job.
+
+| Device | Prevents | Rung |
+|---|---|---|
+"""
+
+
+def tracked_files(root: Path) -> list[Path]:
+ """Prefer git's file list so ignored files are skipped for free."""
+ try:
+ r = subprocess.run(["git", "ls-files"], cwd=root, capture_output=True,
+ text=True, timeout=30)
+ if r.returncode == 0 and r.stdout.strip():
+ return [root / p for p in r.stdout.splitlines()]
+ except (subprocess.SubprocessError, FileNotFoundError):
+ pass
+ return [p for p in root.rglob("*")
+ if p.is_file() and not any(s in p.parts for s in SKIP)]
+
+
+def scan(root: Path) -> list[tuple[str, str, str]]:
+ rows = []
+ for f in tracked_files(root):
+ if any(s in f.parts for s in SKIP):
+ continue
+ if not f.is_file() or f.suffix in {".png", ".jpg", ".svg", ".pdf", ".lock"}:
+ continue
+ try:
+ text = f.read_text(encoding="utf-8", errors="ignore")
+ except OSError:
+ continue
+ if "poka-yoke:" not in text:
+ continue
+ # A marker inside a docstring or a fenced block is documentation showing the
+ # syntax, not a device guarding anything. This generator's own module docstring
+ # demonstrates three of them, and all three were catalogued as real devices and
+ # counted into the badge. A registry that inflates itself by reading its own
+ # example is exactly the kind of instrument this repository exists to catch.
+ in_doc = False
+ for i, line in enumerate(text.splitlines(), 1):
+ stripped = line.strip()
+ if stripped.startswith("```"):
+ in_doc = not in_doc
+ continue
+ if stripped.count('"""') % 2 or stripped.count("'''") % 2:
+ in_doc = not in_doc
+ continue
+ if in_doc:
+ continue
+ m = MARKER.search(line)
+ if m:
+ rel = f.relative_to(root)
+ rows.append((f"`{rel}:{i}`", m.group("what").strip(),
+ (m.group("rung") or ", ").capitalize()))
+ return sorted(rows, key=lambda r: r[0])
+
+
+def render(rows: list[tuple[str, str, str]]) -> str:
+ if not rows:
+ return (HEADER + "| _none found_ | Add `poka-yoke:` marker comments at your devices |, |\n")
+ return HEADER + "".join(f"| {d} | {w} | {r} |\n" for d, w, r in rows)
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+ ap.add_argument("--root", default=".")
+ ap.add_argument("--write", metavar="PATH", help="write the registry to PATH")
+ ap.add_argument("--check", action="store_true",
+ help="exit non-zero if --write target is missing or stale (for CI)")
+ a = ap.parse_args()
+
+ root = Path(a.root).resolve()
+ out = render(scan(root))
+
+ if a.check:
+ target = Path(a.write) if a.write else root / "docs/poka-yoke/registry.md"
+ if not target.exists():
+ print(f"registry missing: {target}", file=sys.stderr); return 1
+ if target.read_text() != out:
+ print(f"registry is stale: {target}\n"
+ f"regenerate with: --write {target}", file=sys.stderr)
+ return 1
+ print(f"registry up to date: {target}")
+ return 0
+
+ if a.write:
+ t = Path(a.write); t.parent.mkdir(parents=True, exist_ok=True)
+ t.write_text(out)
+ print(f"wrote {t} ({out.count(chr(10)) - HEADER.count(chr(10))} devices)")
+ else:
+ print(out, end="")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/skills/poka-yoke-authz/SKILL.md b/skills/poka-yoke-authz/SKILL.md
new file mode 100644
index 000000000..2d766ebd8
--- /dev/null
+++ b/skills/poka-yoke-authz/SKILL.md
@@ -0,0 +1,178 @@
+---
+name: poka-yoke-authz
+description: >-
+ Multi-tenant isolation, IDOR and row-level security. Use to find every path where one tenant could read or write another tenant data: "we forgot to filter by org_id", "can users see each other data", "audit these endpoints for cross-tenant leaks", "make an unscoped query impossible". Covers scoped repositories, RLS, default-deny routing and the two-tenant test. For what the UI shows use ux.
+license: MIT
+---
+
+# Poka-Yoke for Authorization
+
+Cross-tenant data leaks are almost never caused by a wrong access-control decision. They are
+caused by *no decision at all*: a query that is correct except it lacks `WHERE tenant_id = ?`,
+an endpoint that loads by ID without checking who is asking. The developer did not choose
+wrongly; they forgot, in one of the two hundred places the check was required.
+
+That is the signature of a poka-yoke problem: a step that must be performed every single time,
+by a human, with nothing enforcing it. The fix is never "be more careful in code review," and
+it is never a checklist. **The fix is to make the unscoped query unwritable.**
+
+## Building, not reviewing
+
+Most of the time this mode is reached *while someone is building the thing*, not afterwards.
+That changes the deliverable. They asked for the scoping, so produce the scoping, working, complete,
+in their stack. Do not hand back a severity table when the person is mid-feature; a list of
+findings about code they have not written yet is not useful to them.
+
+Then add a short closing note, three or four lines, covering:
+
+- which misuses the shape you chose makes impossible, and at which rung,
+- what you left possible on purpose, and why that tradeoff is the right one here.
+
+That closing note is what stops the device being undone in six months by someone who cannot
+see why it is there. It is also the difference between mistake-proofing and a code generator:
+the reasoning travels with the code.
+
+When the code already exists and they are asking what is wrong with it, switch to the audit
+voice, ranked findings with the mistake, the consequence, and the device. Match the mode to
+where they are in the work, not to this file's default.
+
+## The one principle: unsafe should be hard to say
+
+Right now, in most codebases, the unsafe form is the *short* form:
+
+```python
+user = db.query(User).filter(User.id == user_id).first() # unscoped: 1 line
+user = db.query(User).filter(User.id == user_id,
+ User.tenant_id == current_tenant).first() # safe: longer
+```
+
+Every incentive points at the first line, and it works perfectly in every test, because tests
+usually have one tenant. Invert it so the safe form is the default and the unsafe form
+requires deliberate, visible effort:
+
+```python
+user = tenant_db.users.get(user_id) # tenant scope baked in; cannot be omitted
+user = db.unscoped().users.get(user_id) # possible, greppable, reviewable, rare
+```
+
+Everything below is a variation on that inversion. When you audit, the question is not "is
+this query scoped?" but "**could an unscoped query even be written here?**"
+
+## Devices, strongest first
+
+### 1. Database row-level security (Control, and the one with the widest reach)
+
+RLS enforces the predicate in the database, so it applies to every query from every service,
+every migration, every script, and every engineer with a psql shell. It is the only device
+that protects you from code paths you did not write. Its reach stops only at roles that are
+exempt from policies: superusers, roles with `BYPASSRLS`, and the table owner unless you force
+the policy on.
+
+```sql
+ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
+ALTER TABLE documents FORCE ROW LEVEL SECURITY; -- applies to the table owner too
+
+CREATE POLICY tenant_isolation ON documents
+ USING (tenant_id = current_setting('app.tenant_id')::uuid);
+```
+
+The catch that turns this into a false sense of security: the connection must set
+`app.tenant_id` reliably, and a pooled connection that carries a previous request's setting is
+a cross-tenant leak with extra steps. Set it per-transaction, and make the middleware that sets
+it the only path to a connection. `FORCE ROW LEVEL SECURITY` matters too, without it the
+table owner bypasses the policy, and your application user is often the owner.
+
+### 2. Scoped repositories (Control at the type level)
+
+Make the tenant a required constructor argument, so no repository exists without one:
+
+```ts
+class DocumentRepo {
+ // No default. There is no way to construct this without a tenant.
+ constructor(private readonly db: Db, private readonly tenant: TenantId) {}
+
+ async byId(id: DocumentId): Promise {
+ return this.db.documents.findFirst({ where: { id, tenantId: this.tenant } });
+ }
+}
+```
+
+The raw client is then confined to infrastructure code and lint-banned from handlers. The
+device is not the `where` clause. It is that the handler has no way to reach a client that
+lacks one.
+
+### 3. Authorization in the type (Control)
+
+Rather than loading an object and then checking it, make the check the only way to obtain it:
+
+```ts
+// Handlers accept Owned. There is no path to one that skips the check.
+async function authorizeDocument(user: User, id: DocumentId): Promise>
+```
+
+A handler that takes `Owned` cannot receive an unauthorized document, so the check
+cannot be forgotten: the compiler asks for it. This is the same move as parse-don't-validate,
+applied to permission instead of shape.
+
+### 4. Default-deny at the router (Control, cheap)
+
+Require every route to declare its authorization explicitly, and refuse to start if any route
+has not:
+
+- A middleware that denies unless a route declares a policy, with a startup check that
+ enumerates routes and fails the boot on any undeclared one. A new endpoint is then secure
+ before anyone writes a line of it: the failure mode of forgetting becomes "the service
+ won't start" rather than "the data is public."
+- Public routes are explicitly marked. Making public the opt-in and private the default means
+ forgetting fails closed.
+
+### 5. Unguessable identifiers (defense in depth, not a device)
+
+UUIDs and ULIDs instead of sequential integers raise the cost of enumeration, and they are
+worth using. But an ID is not a permission, anyone who has ever seen the resource still has
+the ID forever. Never treat unguessability as the control; it is a mitigation layered behind
+one.
+
+## Auditing for missing authorization
+
+The high-yield sequence, in order:
+
+1. **Find every path that loads by ID.** For each: where does the tenant or ownership
+ constraint come from? If it comes from the request rather than from the session, that is a
+ finding on its own, `tenant_id` in a request body is client-controlled.
+2. **Grep for raw client use in handlers.** Anywhere the unscoped query builder is reachable
+ from request-handling code is a place the mistake is available.
+3. **Check the update and delete paths specifically.** Reads get the attention; writes get
+ missed, and an unscoped `UPDATE ... WHERE id = ?` lets one tenant modify another's data.
+4. **Check every non-primary path**: bulk endpoints, exports, search, webhooks, background
+ jobs, admin tools, GraphQL resolvers on nested fields, and anything reached via an
+ association (`document.comments` where the comment scope is assumed rather than enforced).
+ Nested resolvers are a common blind spot because the parent was checked and the child
+ inherits nothing.
+5. **Check that admin is scoped too.** "Admin" usually means admin *of a tenant*; a global
+ admin query in a tenant-facing endpoint is a leak.
+6. **Ask what happens on a missing session**: does the query run with `tenant_id = None`, and
+ what does that match? In SQL, `tenant_id = NULL` matches nothing; the dangerous failure is
+ a query builder that drops a missing predicate and issues the query unscoped.
+
+## The test that proves it
+
+One test pattern is worth more than any number of unit tests here: **create two tenants, then
+attempt every operation from tenant A against tenant B's resources, and assert 404 for all of
+them.** Table-drive it over your route list so a new endpoint without a case is visible.
+
+Two details matter. Assert **404, not 403**: a 403 confirms the resource exists, which leaks
+membership. And make the test enumerate routes automatically where you can, so adding an
+endpoint without isolation coverage fails rather than passes silently.
+
+This is a Detection-rung device, and it is the one that tells you whether your Control-rung
+devices actually work. Write it even when RLS is in place, especially then, since RLS failures
+are silent and total.
+
+## Reporting
+
+Use the finding structure from `audit`. Blast radius for this class is
+near-maximum, cross-tenant exposure is a breach, with disclosure obligations, so findings
+here outrank almost everything else in an audit. Propose before changing anything, and be
+precise about which device reaches Control: adding a `where` clause to one query fixes one
+site, and the whole point is that there are two hundred.
diff --git a/skills/poka-yoke-data/SKILL.md b/skills/poka-yoke-data/SKILL.md
new file mode 100644
index 000000000..0df36f8f4
--- /dev/null
+++ b/skills/poka-yoke-data/SKILL.md
@@ -0,0 +1,128 @@
+---
+name: poka-yoke-data
+description: >-
+ Pipelines, warehouses, dbt models and metrics, where failure is silently wrong numbers rather than a crash. Use when "the dashboard is wrong", "the numbers do not match", "add data quality checks", "safe backfill", or an upstream schema change broke a join. Covers freshness, row-count and null-rate assertions, data contracts, reconciliation. For a crash rather than wrong numbers use audit.
+license: MIT
+---
+
+# Poka-Yoke for Data
+
+Data systems fail differently from application code, and that difference determines every
+device here. An application bug throws an exception, pages someone, and gets fixed. A data bug
+produces a number. The number looks fine. Someone makes a decision with it. Three weeks later
+a person notices revenue looks odd, and now you have three weeks of decisions to unwind and no
+way to know which were wrong.
+
+**In data, silence is the defect.** A pipeline that fails loudly is working correctly. A
+pipeline that succeeds while producing garbage is the thing to design against, so most
+devices here are about converting silent wrongness into loud failure, which in Shingo's terms
+is buying yourself a Warning rung where you currently have nothing at all.
+
+## The four questions
+
+Run these over any table or model. They map onto the standard lenses but the data-specific
+phrasing is what finds things.
+
+**Is it there?** *(freshness)*, Did the data arrive at all, and recently enough to be worth
+trusting? A stale table is the most dangerous artifact in a warehouse because it looks
+completely healthy. Every table needs a max-age assertion, and dashboards should surface
+last-updated rather than hiding it.
+
+**Is there the right amount?** *(volume, fixed-value lens)*, Row counts against expectation.
+This catches the breakages that leave every individual row looking fine: a partial load, a
+filter that silently matched nothing, a join that fanned out 100x. Assert both a floor and a
+ceiling, and compare against the same weekday historically rather than against yesterday: most business data is weekly-seasonal and a naive day-over-day check will cry wolf every
+Monday.
+
+**Is it shaped right?** *(schema and validity, contact lens)*, Types, nullability, accepted
+value sets, ranges. Negative quantities, percentages above 100, timestamps in the future,
+currency codes that don't exist, a `status` value nobody has seen before.
+
+**Does it agree?** *(reconciliation)*, Does the warehouse total match the source system?
+Does the sum of the parts match the whole? This is the only check that catches a logic error
+the data still looks well-shaped after, everything above validates shape, and a wrong `JOIN`
+produces perfectly well-shaped, wrong data. It catches what moves a total, not a
+mis-attribution that nets out. If you install one device, install this one on your
+revenue-critical tables.
+
+## Devices, strongest first
+
+### Constraints at the write, not tests after it
+
+Where the warehouse supports it, `NOT NULL`, `UNIQUE`, `CHECK`, and primary keys are Control:
+the bad row cannot be written. A dbt test is Detection: the bad row is already in the table
+and possibly already in a dashboard. Prefer the constraint; use the test where the engine
+gives you nothing better, which in several columnar warehouses is most of the time, say so
+explicitly rather than pretending a test is prevention.
+
+### Data contracts at the boundary
+
+The most common pipeline break is upstream changing a column without telling anyone. A
+contract makes that break loud and attributable:
+
+- The producer declares the schema, types, nullability, and semantics; changes go through
+ versioning rather than through a surprise.
+- The consumer validates on ingest and **quarantines** rather than dropping. Silently dropping
+ malformed rows is the data equivalent of `except: pass`: the pipeline goes green while the
+ numbers go wrong. Route bad rows to a dead-letter table with the reason, alert on the rate,
+ and keep them for inspection.
+- Additive changes are safe; renames and type narrowing are breaking. Treat a rename as a drop
+ plus an add, because that is what downstream experiences.
+
+### Idempotent, resumable loads
+
+Every incremental job should be safe to re-run over the same window. Pipelines get retried, by the scheduler, by an on-call engineer, by a backfill, and a non-idempotent load
+double-counts, which is a silently wrong number of exactly the worst kind.
+
+The device: partition-level replace, or `MERGE` on a real business key, rather than blind
+`INSERT`. Then a re-run converges rather than accumulating.
+
+### Backfills that cannot run away
+
+Backfills are the data world's destructive operation. Before running one:
+
+- Bound it explicitly: a date range with both ends, never open-ended.
+- Batch it, with progress recorded, so a failure at 80% resumes rather than restarts.
+- Write to a staging table and swap atomically, so consumers never see a half-populated table.
+- Dry-run first, printing the partitions and row counts it will touch.
+- Know the rollback: if the backfill is wrong, what restores the previous state? If the answer
+ is "nothing", make a snapshot first. That snapshot *is* the device.
+
+### One definition per metric
+
+If "active user" is defined in the dashboard, the model, and an analyst's spreadsheet, you have
+three metrics with one name and they will disagree, usually in a meeting. Define each metric
+once, in version-controlled code, and have every consumer reference that definition. A metric
+redefined in a BI tool is a copy that will silently drift.
+
+### Assertions in the pipeline, not beside it
+
+The check must be able to **stop the pipeline**, not just report. A test suite that runs after
+publication and emails a failure lets bad data reach the dashboard, which is the whole problem.
+Assert between load and publish: build to staging, test staging, promote only on pass. That
+ordering is the single most valuable structural change in most warehouses, and it costs no new
+tooling.
+
+## Auditing a pipeline
+
+Read the DAG or the model files and work outward from what matters:
+
+1. **Which tables feed decisions or money?** Start there; coverage everywhere is not the goal.
+2. **For each: freshness, volume, uniqueness on the key, null rate on required columns,
+ reconciliation to source.** Which exist? Which can actually block publication?
+3. **Where are rows silently dropped?** Inner joins that should be left joins, `WHERE` clauses
+ filtering nulls, try/except around row parsing, `on_error='ignore'`. Each is a place the
+ count quietly shrinks.
+4. **What happens on re-run?** Trace one job. Does it double-count?
+5. **What happens when upstream adds or renames a column?** Break, or silently produce nulls?
+6. **Is anything in a dashboard that isn't in version control?**
+
+Report with the structure from `audit`, and be explicit about the rung, in data,
+most devices you can actually install are Warning or Detection, and claiming Control for a
+dbt test overstates the protection.
+
+## The tone that matters here
+
+When numbers have been wrong, the instinct is to find who wrote the bad join. Same rule as
+everywhere else in this plugin: the finding is that the pipeline could produce a wrong number
+without anyone noticing. That is a missing assertion, not a missing person.
diff --git a/skills/poka-yoke-design/SKILL.md b/skills/poka-yoke-design/SKILL.md
new file mode 100644
index 000000000..ddf5e939a
--- /dev/null
+++ b/skills/poka-yoke-design/SKILL.md
@@ -0,0 +1,144 @@
+---
+name: poka-yoke-design
+description: >-
+ Design APIs, schemas, types and state machines so misuse cannot be expressed. Use when writing a new interface and someone asks "what should the types look like", "make invalid states unrepresentable", "so callers cannot screw it up", or wants illegal state transitions rejected. Covers branded types, discriminated unions, typestate, parse-don't-validate. For code that already exists use audit.
+license: MIT
+---
+
+# Poka-Yoke Design
+
+Mistake-proofing is cheapest before the code exists. Once an interface has callers, every
+device you add is a migration; before it has callers, a device is free. So the work here is
+front-loaded: decide how this thing will be misused, *then* pick the shape that makes the
+misuse unsayable.
+
+This is Shingo's **source inspection**: checking the conditions that produce errors rather
+than the errors themselves, and it is the strongest of his three inspection types, because
+the error never gets the chance to happen.
+
+## The ritual: enumerate misuse before you write the signature
+
+Before writing an interface, spend real effort on this list. It takes two minutes and it
+determines the design.
+
+1. **What are the parameters, and can any two be swapped without complaint?** Same type
+ adjacent to same type is among the most common footguns in software.
+2. **What must a caller remember to do?** Call something first. Call something after. Check a
+ return value. Close a handle. Pass the right units. Every "must remember" is a defect
+ scheduled for later.
+3. **What states can this thing be in, and which combinations are nonsense?** If you can
+ construct a value that means nothing, the type is wrong.
+4. **What happens on the second call?** Retries, double-clicks, at-least-once queues. If the
+ answer is "it charges twice," you need a motion-step device.
+5. **What's the worst plausible input?** Empty set, enormous set, null, wrong tenant,
+ yesterday's token, a string from an attacker.
+6. **When someone adds a new case next year, what breaks?** The right answer is "the build."
+ The wrong answer is "nothing, it silently falls through."
+
+Write the answers down where the user can see them, briefly. Then design against them.
+
+## The moves, in preference order
+
+Reach for the highest one the language and situation allow. Each rung down is a real
+concession, take it consciously and say why.
+
+### 1. Make the illegal state unrepresentable (Control, contact lens)
+
+The strongest move: change the type so the bad value has no spelling.
+
+- **Distinct types for distinct concepts.** `UserId` and `OrderId` are not both `string`.
+ Money is not a float. A timeout is not a bare number. Branded types / newtypes / value
+ objects cost almost nothing and kill an entire class of swap-and-mix-up bugs.
+- **Sum types over bags of optionals.** `{status, error?, data?, retryAt?}` permits states
+ like "succeeded with an error and a retry time." A discriminated union permits exactly the
+ states that exist. If your struct has N optional fields, it claims 2^N states are legal;
+ ask how many actually are.
+- **Non-empty and bounded collections** when zero or unbounded is nonsense.
+
+### 2. Parse, don't validate (Control at the boundary)
+
+Validation returns a boolean and throws the knowledge away; parsing returns a *new type* that
+carries the proof. `validateEmail(s: string): boolean` leaves every downstream function still
+holding an unvalidated string. `parseEmail(s: string): Email | Error` means downstream
+functions that take `Email` cannot receive garbage: the type system carries the guarantee
+for you, forever, for free.
+
+Do this once, at the system's edge: HTTP handlers, queue consumers, config loading, file
+parsing, and every third-party response. Inside the boundary, work only with parsed types.
+
+### 3. Make order and lifecycle enforceable (Control, motion-step lens)
+
+When steps must happen in sequence, encode the sequence in types rather than in prose:
+
+- **Typestate**: each operation consumes one state and returns the next, so `.commit()` does
+ not exist on an uncommitted-and-unvalidated value.
+- **Builders that cannot `build()`** until required steps have run, enforced by the type,
+ not by a runtime check, where the language allows it.
+- **Constructors that return ready objects.** If `init()` must be called before use, the
+ constructor is doing the wrong job. Give it a static factory that does both.
+- **Scope-bound resources**: context managers, `defer`, RAII, `using`. Never "remember to close."
+- **Idempotency keys as required parameters** for anything that moves money, sends a message,
+ or mutates external state. Required, not optional: an optional idempotency key is a
+ suggestion, and suggestions are rung zero.
+
+### 4. Make completeness checkable (Control/Warning, fixed-value lens)
+
+- **Exhaustive matching** with a compiler-enforced never/unreachable arm, so adding an enum
+ variant breaks the build at every site that must change. This is one of the highest
+ leverage devices in existence and it costs one line per switch.
+- **Required arguments over defaulted ones** when there is no safe default. A default that is
+ wrong half the time is worse than no default: it hides the decision.
+- **Whole-config validation at startup**, so a missing variable fails the deploy rather than
+ the 3am request.
+
+### 5. Fail fast and loud (Warning)
+
+When the type system genuinely cannot express the constraint, assert at the boundary and
+throw. This is a real poka-yoke, one rung down. Make the message name the mistake and the fix.
+
+Two rules that decide whether this rung works at all:
+
+- **No silent fallbacks.** `catch {}`, `except: pass`, `|| defaultValue`, `unwrap_or_default()`
+ on an error path. These are devices *removed*. They convert a loud mistake into a quiet
+ one, which is exactly backwards. If a fallback is genuinely correct, the comment must say
+ which failure it is absorbing and why that failure is expected.
+- **Destructive operations default to safe.** Dry-run by default, require an explicit
+ predicate, refuse to act on an empty or oversized set. `deleteUsers(filter)` with an empty
+ filter should raise, not truncate the table.
+
+### 6. Where the language can't help, move the device to the data layer
+
+The database is a type system that all your services share. `NOT NULL`, `CHECK`, `UNIQUE`,
+foreign keys, and partial unique indexes are Control-rung devices that hold even when someone
+writes a script, connects with `psql`, or ships a service in another language. When
+application-level enforcement is the only thing standing between you and corrupt data, push
+it down.
+
+## Deliver the design with its reasoning attached
+
+You were asked for code, so write the code. But narrate the mistake-proofing in a few lines,
+because the reasoning is what stops it being undone later:
+
+- what misuses you enumerated,
+- which ones the design now makes impossible, and at which rung,
+- which ones you consciously left possible, and why.
+
+That last bullet matters most. Every design leaves something possible; naming it is the
+difference between a considered tradeoff and an oversight.
+
+## Restraint
+
+Mistake-proofing has a cost, and past a point it stops paying. Signs you have gone too far:
+five wrapper types for one function, a builder for a two-field struct, a type parameter no
+caller will ever understand. The test is whether the device prevents a mistake someone would
+*plausibly make*, weighted by what happens when they do. An internal helper with two callers
+and a trivial failure mode does not need a newtype; a public payments API does.
+
+Sean Goedecke's [critique of the maximalist version](https://www.seangoedecke.com/invalid-states/)
+is worth taking seriously: types that model every invariant can become harder to change than
+the bugs they prevent. Aim the strongest devices at the highest blast radius, and leave
+low-stakes code readable.
+
+Read `references/hazard-catalog.md` for the misuse shapes worth
+enumerating, and the matching `references/lang-*.md` for what your language can actually
+express: the moves above are only as strong as the type system underneath them.
diff --git a/skills/poka-yoke-design/references/hazard-catalog.md b/skills/poka-yoke-design/references/hazard-catalog.md
new file mode 100644
index 000000000..b7808e377
--- /dev/null
+++ b/skills/poka-yoke-design/references/hazard-catalog.md
@@ -0,0 +1,416 @@
+# Hazard Catalog
+
+The recurring shapes that produce mistakes, organized by the lens that finds them. Each entry:
+what to look for, why it bites, and the device that closes it with the rung it reaches.
+
+Use this as working vocabulary, not a checklist to run top to bottom. The lens questions are
+the real tool; this catalog is what the lenses usually turn up.
+
+## Contents
+
+- [Contact lens, can the wrong thing fit?](#contact-lens-can-the-wrong-thing-fit)
+ - [C1. Adjacent same-type parameters](#c1-adjacent-same-type-parameters)
+ - [C2. Boolean flag parameters](#c2-boolean-flag-parameters)
+ - [C3. Primitive obsession at boundaries](#c3-primitive-obsession-at-boundaries)
+ - [C4. Stringly-typed enums](#c4-stringly-typed-enums)
+ - [C5. Implicit units and magnitudes](#c5-implicit-units-and-magnitudes)
+ - [C6. Money as a float](#c6-money-as-a-float)
+ - [C7. Unvalidated external input](#c7-unvalidated-external-input)
+ - [C8. Bag-of-optionals structs](#c8-bag-of-optionals-structs)
+ - [C9. Naive datetimes](#c9-naive-datetimes)
+- [Fixed-value lens, can an incomplete or wrong-sized set pass?](#fixed-value-lens-can-an-incomplete-or-wrong-sized-set-pass)
+ - [F1. Non-exhaustive branching](#f1-non-exhaustive-branching)
+ - [F2. Unbounded destructive operations](#f2-unbounded-destructive-operations)
+ - [F3. Defaults that hide a decision](#f3-defaults-that-hide-a-decision)
+ - [F4. Config discovered missing at runtime](#f4-config-discovered-missing-at-runtime)
+ - [F5. Partial writes without a transaction](#f5-partial-writes-without-a-transaction)
+ - [F6. Invariants enforced only in the application](#f6-invariants-enforced-only-in-the-application)
+ - [F7. Unbounded input](#f7-unbounded-input)
+- [Motion-step lens, can the order be wrong?](#motion-step-lens-can-the-order-be-wrong)
+ - [M1. Temporal coupling](#m1-temporal-coupling)
+ - [M2. Non-idempotent retryable effects](#m2-non-idempotent-retryable-effects)
+ - [M3. Illegal state transitions](#m3-illegal-state-transitions)
+ - [M4. Resources that must be released](#m4-resources-that-must-be-released)
+ - [M5. Check-then-act races](#m5-check-then-act-races)
+ - [M6. Fire-and-forget async](#m6-fire-and-forget-async)
+ - [M7. Order-dependent migrations and deploys](#m7-order-dependent-migrations-and-deploys)
+- [Cross-cutting, devices that were removed](#cross-cutting-devices-that-were-removed)
+ - [X1. Swallowed errors](#x1-swallowed-errors)
+ - [X2. Silent coercion and fallback](#x2-silent-coercion-and-fallback)
+ - [X3. Disabled tests](#x3-disabled-tests)
+ - [X4. Escape hatches in the type system](#x4-escape-hatches-in-the-type-system)
+ - [X5. Mutable shared defaults](#x5-mutable-shared-defaults)
+
+---
+
+## Contact lens, can the wrong thing fit?
+
+The factory analogy: a part that only seats one way. In software, the type is the shape.
+
+### C1. Adjacent same-type parameters
+
+**Signal**: two or more consecutive parameters of the same primitive type, `transfer(from: string, to: string)`, `resize(w: number, h: number)`,
+`slice(start: int, end: int)`.
+
+**Why it bites**: swapping them compiles, passes review, and produces a plausible wrong
+result. It is among the most common footguns in software, and one of the most cleanly
+solved, once the two types differ, the wrong order will not compile.
+
+**Device**: distinct types per concept, branded types, newtypes, value objects, so a
+`SourceAccount` cannot be passed as a `DestinationAccount`. **Control.**
+Fallback where types can't help: force keyword/named arguments so the caller must write the
+name at the call site. **Warning**, but nearly free and it makes the swap visible in review.
+
+### C2. Boolean flag parameters
+
+**Signal**: `createUser(name, true, false)`, `save(data, force=True)`, any `bool` parameter
+that selects behavior rather than carrying data.
+
+**Why it bites**: the call site is unreadable, so misordered or misunderstood flags are
+invisible. Adding a second boolean makes it exponentially worse.
+
+**Device**: an enum or literal union per axis (`Visibility.Public`), an options object with
+named fields, or two separate functions. **Control** for the enum, since the wrong value has
+no spelling. Note the exception: a single boolean whose name reads correctly at the call site
+in a keyword-argument language is fine.
+
+### C3. Primitive obsession at boundaries
+
+**Signal**: `string` for email, URL, path, token, tenant ID, phone; `int` for a percentage or
+a duration, especially on public functions.
+
+**Why it bites**: every downstream function must re-check or trust. Validation that returns a
+boolean throws away the proof, so the check gets repeated, skipped, or done inconsistently.
+
+**Device**: parse-don't-validate. `parseEmail(s): Email | Error` once at the boundary, then
+downstream signatures demand `Email`. The type carries the guarantee permanently. **Control.**
+
+### C4. Stringly-typed enums
+
+**Signal**: `status: string` with a comment listing the values; string comparison against
+literals; a value crossing a boundary as text with no schema.
+
+**Why it bites**: typos compile. New variants added elsewhere never reach this code. Nothing
+tells you which values are legal.
+
+**Device**: a literal union, enum, or sealed class, with exhaustive matching (F1). **Control.**
+
+### C5. Implicit units and magnitudes
+
+**Signal**: `timeout: number`, `distance: float`, `retryAfter: int`: no unit anywhere except
+possibly a name or a comment. Two systems in the same codebase disagreeing on seconds vs
+milliseconds.
+
+**Why it bites**: a 1000x error is silent and looks like a hang or a hot loop. This class of
+mistake famously destroyed a Mars orbiter.
+
+**Device**: unit-bearing types (`Duration`, `Milliseconds`), or at minimum encode the unit in
+the parameter name (`timeoutMs`). **Control** for the type. The name is **rung 0**: it makes
+a mismatch visible to a reader who is looking, and produces no diagnostic for one who is not.
+Worth doing; not a device.
+
+### C6. Money as a float
+
+**Signal**: `price: float`, `amount: number`, arithmetic on currency in binary floating point,
+`==` comparisons on money.
+
+**Why it bites**: 0.1 + 0.2 ≠ 0.3. Errors accumulate over aggregation and reconciliation
+fails in ways that take days to trace.
+
+**Device**: integer minor units (cents) in a `Money` type carrying its currency, or a decimal
+type. Mixed-currency arithmetic should not typecheck. **Control.**
+
+### C7. Unvalidated external input
+
+**Signal**: `JSON.parse(body)` into `any`, `request.json()` into a bare dict, a third-party
+API response used field-by-field with no schema, `os.environ[...]` read deep inside logic.
+
+**Why it bites**: the failure surfaces far from the boundary, as a confusing error about a
+missing property, long after the malformed data has been partially processed or stored.
+
+**Device**: a schema at every edge, zod/valibot, Pydantic, `encoding/json` into a typed
+struct with validation, serde. Parse once, then work with parsed types. **Control.**
+This applies to *your own* services' responses too; "internal" is not a guarantee.
+
+### C8. Bag-of-optionals structs
+
+**Signal**: a type with several optional fields where only certain combinations are
+meaningful, `{ status, data?, error?, retryAt? }`, `{ isLoading, data, error }`.
+
+**Why it bites**: N optional fields claim 2^N legal states. Every consumer must guess which
+are real, and they guess differently. States like "loading and errored with data" become
+reachable and get handled inconsistently.
+
+**Device**: a discriminated union with exactly the legal variants, so impossible combinations
+have no representation. **Control.** This is the canonical "make invalid states
+unrepresentable" move.
+
+### C9. Naive datetimes
+
+**Signal**: timezone-less timestamps, `datetime.now()` / `new Date()` scattered through
+business logic, dates stored as strings, DST-unaware arithmetic.
+
+**Why it bites**: correct in the developer's timezone, wrong in production, and wrong twice a
+year in the places that observe DST. Also hard to test, logic that reads the clock directly
+cannot be exercised at a boundary condition without freezing or injecting time.
+
+**Device**: timezone-aware types everywhere, UTC at rest, an injected clock so time is a
+parameter rather than an ambient read. **Control** for the type, and the injected clock buys
+testability, which is a Detection-rung device that finally becomes possible.
+
+---
+
+## Fixed-value lens, can an incomplete or wrong-sized set pass?
+
+The factory analogy: a counter confirming all six screws were fitted.
+
+### F1. Non-exhaustive branching
+
+**Signal**: a `switch`/`match` over an enum with a `default` that does nothing meaningful, or
+an if/else chain over a closed set of values.
+
+**Why it bites**: adding a variant silently takes the default branch at every site that
+should have been updated. The bug appears months later, in the one code path nobody tested.
+
+**Device**: compiler-enforced exhaustiveness: an `assertNever(x: never)` arm in TypeScript,
+`match` without a catch-all in Rust, `assert_never` with mypy, an exhaustive linter for Go.
+**Control**, one line per switch, and among the highest-leverage devices available.
+
+### F2. Unbounded destructive operations
+
+**Signal**: `DELETE`/`UPDATE` built from a filter that can be empty; `rm -rf "$VAR"`;
+`.deleteMany(where)`; bulk send/publish over a query result; a "cleanup" job with no cap.
+
+**Why it bites**: irreversible, instant, and proportional to your data volume. An empty filter
+frequently means "match everything."
+
+**Device**: refuse an empty predicate; require an explicit `all=True` for the full-table case;
+cap the affected row count and require confirmation above it; dry-run by default with the
+count printed. Soft-delete where the domain allows. **Control.**
+
+### F3. Defaults that hide a decision
+
+**Signal**: a default value for something with no safe default, `retries=3`, `timeout=30`,
+`currency="USD"`, `tenant=None`, `region=default`.
+
+**Why it bites**: the caller never considers the parameter, and the default is wrong for their
+case. Worse than an error, because it produces confident wrong behavior.
+
+**Device**: make it required. Reserve defaults for parameters where one value is correct for
+the overwhelming majority and wrong-but-harmless for the rest. **Control.**
+
+### F4. Config discovered missing at runtime
+
+**Signal**: `os.getenv("X")` inside a request handler; config read lazily on first use; a
+missing key producing `None` that flows onward.
+
+**Why it bites**: the service starts, passes health checks, and fails on the one code path
+that needs the key, often the payment path, often at 3am.
+
+**Device**: parse and validate the entire config into a typed object at startup, and exit
+non-zero if anything is missing or malformed. Every consumer takes the typed object.
+**Control**, and it converts a 3am page into a failed deploy.
+
+### F5. Partial writes without a transaction
+
+**Signal**: several writes in sequence with no transaction; a write followed by an external
+call followed by another write; "create the record then send the email."
+
+**Why it bites**: a failure in the middle leaves the system in a state your code does not
+model and cannot repair.
+
+**Device**: wrap in a transaction; move external effects outside it via an outbox; make the
+sequence idempotent so replay converges. **Control** for the transaction.
+
+### F6. Invariants enforced only in the application
+
+**Signal**: uniqueness checked with a `SELECT` before an `INSERT`; nullability enforced in a
+model class but not in the column; a foreign key relationship maintained by convention.
+
+**Why it bites**: the check races under concurrency, and it is bypassed entirely by any other
+service, migration, script, or human with `psql`.
+
+**Device**: push it into the schema, `NOT NULL`, `UNIQUE`, `CHECK`, foreign keys, partial
+unique indexes. The database is a type system shared by everything that touches the data.
+**Control**, and uniquely durable.
+
+### F7. Unbounded input
+
+**Signal**: pagination with no maximum page size; a file upload with no size limit; a query
+built from a user-supplied list with no cap; unbounded recursion or retries.
+
+**Why it bites**: a resource exhaustion incident indistinguishable from an attack, triggered
+by an ordinary user with a large account.
+
+**Device**: explicit caps at the boundary, enforced by the parsing type where possible.
+**Control.**
+
+---
+
+## Motion-step lens, can the order be wrong?
+
+The factory analogy: a sensor confirming step 3 happened before step 4.
+
+### M1. Temporal coupling
+
+**Signal**: `init()`, `connect()`, `configure()`, `validate()` that must be called before
+other methods; documentation containing the phrase "you must call X first."
+
+**Why it bites**: nothing enforces it. The failure is a null dereference or, worse, a
+silently-wrong result from a half-configured object.
+
+**Device**: the constructor or a static factory returns a fully ready object; or typestate,
+where `connect()` returns a `Connected` type and the other methods exist only on it.
+**Control.**
+
+### M2. Non-idempotent retryable effects
+
+**Signal**: a charge, email, webhook, or external mutation reachable from a retry, a queue
+consumer, or a UI button, with no idempotency key, or with an optional one.
+
+**Why it bites**: at-least-once delivery is the norm, not the exception. Duplicate charges are
+the canonical version and they are expensive and public.
+
+**Device**: a **required** idempotency key parameter, backed by a unique constraint on
+`(entity, key)`. **Control.** An optional idempotency key is rung zero wearing a costume.
+
+The constraint is necessary and not sufficient. Rejecting the duplicate is not the same as
+being idempotent: the key has to be *reserved in the same transaction as the effect*, bound
+to the request payload so a different payload under a reused key is an error rather than a
+silent no-op, and the stored result replayed to the second caller. A caller that retries and
+gets a constraint violation has learned nothing about whether the first attempt worked.
+
+### M3. Illegal state transitions
+
+**Signal**: an entity with a `status` field mutated by assignment from several places; a
+refund reachable before a charge; "cancelled" transitioning back to "pending".
+
+**Why it bites**: every site that assigns the field must know the whole state machine, and one
+of them doesn't.
+
+**Device**: a single transition function that is the only path to a new state, rejecting
+illegal transitions; or typestate so illegal transitions don't compile. **Control.**
+
+A row-level `CHECK` is not defence in depth here: it constrains one row's values and cannot
+see the state that row is coming from, so it can forbid `status = 'refunded' AND total < 0`
+but not `shipped → pending`. Policing transitions in the database needs a trigger, or a
+transition table the row must join against.
+
+### M4. Resources that must be released
+
+**Signal**: `open()`/`close()`, `acquire()`/`release()`, `begin()`/`commit()` as separate
+statements, especially with a `return` or `throw` reachable between them.
+
+**Why it bites**: the happy path is fine and the error path leaks. Leaks surface as connection
+pool exhaustion under load, which is when you can least afford it.
+
+**Device**: scope-bound acquisition, `with`, `defer`, RAII, `using`, try-with-resources.
+**Control.**
+
+### M5. Check-then-act races
+
+**Signal**: `if (!exists(x)) create(x)`, read-modify-write on a shared counter, checking a
+balance and then debiting it, `if (!file.exists()) write(file)`.
+
+**Why it bites**: correct in every test and wrong under concurrency, intermittently, in
+production only.
+
+**Device**: make it atomic: a unique constraint plus `INSERT ... ON CONFLICT`, a conditional
+update carrying the expected version, `SELECT FOR UPDATE`, a compare-and-swap. **Control.**
+
+### M6. Fire-and-forget async
+
+**Signal**: a promise not awaited, a goroutine with no error path, `asyncio.create_task` with
+no reference kept, a background write nobody joins.
+
+**Why it bites**: errors vanish. Worse, the process may exit before the work completes, so
+writes are lost silently and non-deterministically.
+
+**Device**: `no-floating-promises` as a lint error, an errgroup, structured concurrency,
+holding and awaiting the task. **Warning** from the linter, which is the practical answer
+in TypeScript, Python and Go. Rust is the closest thing to an exception: futures are lazy and `#[must_use]`, so a dropped
+future produces a compiler warning without any linter. That is **Warning**, for free; add
+`#![deny(unused_must_use)]` to make the build fail and it becomes **Control**.
+
+### M7. Order-dependent migrations and deploys
+
+**Signal**: a migration that drops or renames a column in the same deploy as the code change;
+a migration and code that must land in a specific order with nothing enforcing it.
+
+**Why it bites**: during the rollout window, old code runs against the new schema. This is an
+outage, not a bug.
+
+**Device**: expand/contract, add, backfill, dual-write, switch, then drop in a later deploy, with a CI gate that blocks destructive DDL from co-deploying with code changes. **Control**
+via the gate; the pattern itself is the design.
+
+---
+
+## Cross-cutting, devices that were removed
+
+Several of these are hazards of removal, someone installed a device and someone else took
+it out. Others (X2, X5) are defaults nobody chose: the language ships them switched the wrong
+way and they stay that way until someone notices.
+Treat them with more suspicion than a missing device, since the code around them was written
+by someone who knew the failure was possible.
+
+### X1. Swallowed errors
+
+**Signal**: `catch {}`, `except: pass`, `except Exception: pass`, `_ = err`, `catch (e) {
+console.log(e) }` with execution continuing, `.catch(() => null)`.
+
+**Why it bites**: converts a loud failure into a quiet wrong answer: the exact inversion of
+mistake-proofing. The system continues on corrupted assumptions.
+
+**Device**: handle it, or let it propagate. Where absorbing genuinely is correct, the comment
+must name which specific failure is expected and why continuing is safe; catch that specific
+type, not everything. Enforce with `no-empty` / bare-except lint rules as errors. **Warning.**
+
+### X2. Silent coercion and fallback
+
+**Signal**: `value || default` where `0`/`""`/`false` are legal values; `parseInt` without a
+radix or a NaN check; `int(x)` in a try/except returning a default; `.unwrap_or_default()` on
+a genuine error; `?.` chains ending in `undefined` that flow into logic.
+
+**Why it bites**: produces a plausible value from bad input. The wrongness surfaces far away,
+where the cause is invisible.
+
+**Device**: `??` instead of `||` where zero is legal; explicit parse with an error branch;
+fail at the boundary rather than substituting. **Control** at the parse site.
+
+### X3. Disabled tests
+
+**Signal**: `it.only`, `describe.skip`, `@pytest.mark.skip`, `t.Skip()`, `#[ignore]`: especially without a reason. Lint and type-checker suppressions (`eslint-disable`,
+`# type: ignore`, `@ts-ignore`, `#nosec`) are X4, and the detector splits them the same way.
+
+**Why it bites**: a Detection-rung device switched off, usually temporarily, permanently. The
+suite stays green and stops meaning anything.
+
+**Device**: fail CI on focused/skipped tests; require a justification comment and an issue
+link on every suppression; count suppressions and ratchet the number downward. **Warning.**
+
+### X4. Escape hatches in the type system
+
+**Signal**: `any`, `as unknown as T`, `!` non-null assertion, `interface{}` with a type
+switch, `# type: ignore`, `unsafe`, `cast()`, `Object` as a parameter type.
+
+**Why it bites**: every one is a place where the type system's guarantee stops. Concentrated
+in the boundary code that most needs the guarantee.
+
+**Device**: ban them by lint at error level with a narrow, justified allowlist; replace with
+parsing at the boundary. **Warning**: a required CI gate is still rung 2 by the ladder in
+[method.md](../../../docs/method.md): it announces the mistake rather than removing the
+ability to make it. Reach **Control** only when the unchecked value cannot be constructed.
+
+### X5. Mutable shared defaults
+
+**Signal**: Python's `def f(items=[])`, a module-level dict used as a cache and mutated, a
+shared config object mutated after construction, class attributes used as instance state.
+
+**Why it bites**: state leaks between calls, requests, or tests. The symptom is
+order-dependent behavior that disappears when you try to reproduce it.
+
+**Device**: `None` sentinel with in-function construction, frozen/immutable value types,
+per-request construction. `B006` in ruff/flake8-bugbear enforces the argument-default case
+only; the module-level cache, the shared config object and the mutable class attribute have
+no lint rule and need review or a type that cannot be mutated.
+**Warning**, or **Control** with frozen types.
diff --git a/skills/poka-yoke-guardrails/SKILL.md b/skills/poka-yoke-guardrails/SKILL.md
new file mode 100644
index 000000000..1c0b5ec33
--- /dev/null
+++ b/skills/poka-yoke-guardrails/SKILL.md
@@ -0,0 +1,132 @@
+---
+name: poka-yoke-guardrails
+description: >-
+ Pre-commit hooks, CI gates, lint rules, database constraints and branch protection. Use when a rule needs enforcing rather than documenting: "set up enforcement", "unformatted or untyped code must not get merged", "gate this in CI", "we agreed to X and people still do not", "stop secrets getting committed". Covers baselining and ratcheting so existing violations do not block anyone. For constraining an AI agent use agent-guardrails.
+license: MIT
+---
+
+# Poka-Yoke Guardrails
+
+Design-time devices protect the code you are writing now. Guardrails protect the code
+everyone writes later, including the version of you who is in a hurry. They are Shingo's
+*successive check*: the next station refuses to accept bad work.
+
+The reason this mode exists as its own thing: the most common failure in software quality is
+agreeing on a rule and then writing it down. A rule in a wiki has a half-life of about one
+onboarding. The same rule wired into a gate applies itself and costs nothing to remember.
+
+## Building, not reviewing
+
+Most of the time this mode is reached *while someone is building the thing*, not afterwards.
+That changes the deliverable. They asked for the config, so produce the config, working, complete,
+in their stack. Do not hand back a severity table when the person is mid-feature; a list of
+findings about code they have not written yet is not useful to them.
+
+Then add a short closing note, three or four lines, covering:
+
+- which misuses the shape you chose makes impossible, and at which rung,
+- what you left possible on purpose, and why that tradeoff is the right one here.
+
+That closing note is what stops the device being undone in six months by someone who cannot
+see why it is there. It is also the difference between mistake-proofing and a code generator:
+the reasoning travels with the code.
+
+When the code already exists and they are asking what is wrong with it, switch to the audit
+voice, ranked findings with the mistake, the consequence, and the device. Match the mode to
+where they are in the work, not to this file's default.
+
+## Pick the earliest gate that can hold the rule
+
+The same rule can live at several points in the lifecycle. Earlier is better, feedback is
+faster, cheaper, and lands while the author still has the context in their head. But earlier
+is also easier to bypass. The resolution is to place the device early **and** back it with a
+gate that cannot be skipped.
+
+| Gate | Feedback speed | Bypassable? | Best for |
+|---|---|---|---|
+| Type system / compiler | instant | no | anything the types can express, always first choice |
+| Editor + lint | seconds | yes (ignore comment) | style, banned APIs, unsafe patterns |
+| Pre-commit hook | seconds | yes (`--no-verify`) | fast checks: secrets, formatting, obvious footguns |
+| Pre-push hook | ~a minute | yes | medium checks you don't want to wait for on every commit |
+| CI required check | minutes | **no**, with branch protection | the real enforcement, everything that must not merge |
+| Database constraint | instant, at write | no | data invariants, across every service and every script |
+| Runtime assertion | at execution | no | invariants no earlier gate can see |
+
+**Never rely on a pre-commit hook alone for anything that matters.** `--no-verify` exists, and
+people under deadline use it. Use the hook for speed and the CI check for authority; run the
+same script in both so they cannot drift.
+
+## The devices worth installing
+
+Ready-to-adapt templates live in `assets/devices/`. Read the relevant one, adapt it to
+the repo's actual stack, and show the user the file before writing it.
+
+- `assets/devices/pre-commit/`, `.pre-commit-config.yaml` covering secrets, large
+ files, merge conflict markers, formatting, and a hook for repo-specific rules
+- `assets/devices/github-actions/`: a required-check workflow, plus a migration-safety
+ gate
+- `assets/devices/lint/`: ESLint and Ruff rule sets chosen specifically for
+ mistake-prevention rather than style
+- `assets/devices/claude-hooks/`: Claude Code hooks (see `agent-guardrails`)
+
+The rules that pay for themselves in nearly every repo, roughly in order of value:
+
+1. **Secret scanning at commit time.** A leaked key is irreversible; rotation is the only
+ remedy. This is the highest blast-radius mistake a hook can prevent.
+2. **Type checking as a required check**, `tsc --noEmit`, `mypy --strict`, `go vet`. This is
+ what makes every design-time device in `design` actually load-bearing. A branded
+ type with no type check in CI is decoration.
+3. **The specific lint rules that catch silent failure**: floating promises, unhandled
+ rejections, unchecked errors, bare `except`, empty catch blocks, non-exhaustive switches.
+ Ordinary style rules are not poka-yoke; these are.
+4. **Migration safety**: block destructive DDL, or require an explicit acknowledgment for it.
+ Dropping a column in a deploy is a classic irreversible mistake with a trivial device.
+5. **Test integrity**: fail CI on `it.only`, `fdescribe`, `@pytest.mark.skip` left behind. A
+ skipped test is a detection device that has been switched off, usually by accident.
+6. **Branch protection with required checks.** Without it, none of the above is enforcement.
+
+## Install carefully: a guardrail people hate gets removed
+
+This is the mode where a well-intentioned change most easily backfires. A gate that fires
+constantly on pre-existing code teaches everyone to bypass gates, which is strictly worse
+than not adding it. Three rules:
+
+**Baseline first, then ratchet.** Turning on a strict rule in a large repo yields hundreds of
+failures and the rule gets reverted by Friday. Instead: enforce on changed files only, or
+generate a baseline of existing violations and fail only on *new* ones. The violation count
+can only go down. This is how strictness actually lands.
+
+**Be fast or be asynchronous.** A pre-commit hook over about five seconds gets bypassed. Keep
+commit-time checks to changed files, push the slow work to CI.
+
+**Make the failure message teach.** A gate that says `error: rule violated` produces a
+confused engineer and a workaround. Say what was done, why it is dangerous, and the exact
+command or edit that fixes it. This is the one place prose belongs in a poka-yoke: at the
+moment of failure, when someone is guaranteed to read it.
+
+Also check what already exists before adding anything. Repos frequently have a lint config or
+CI workflow that already covers the rule but isn't wired into branch protection, or is set to
+warn instead of error. Flipping an existing warning to an error is a better change than a new
+tool.
+
+## Verify the device actually fires
+
+An untested guardrail is a guardrail you *believe in*, which is worse than none. It creates
+confidence without protection. Before you call it done, demonstrate it:
+
+1. Write the mistake it is supposed to catch, deliberately.
+2. Run the gate. Confirm it fails, and that the message is the one you wrote.
+3. Remove the mistake. Confirm it passes.
+4. Show the user both outcomes.
+
+Then leave a `poka-yoke:` marker comment on the rule naming the mistake it prevents, see the
+recording section in `audit`. A device whose purpose nobody remembers is a device
+that gets deleted during the next cleanup.
+
+## Propose first
+
+Show the config files and what they will reject before writing them. Guardrails change how
+everyone on the team works, and that is not a change to make on someone's behalf without
+their explicit sign-off, especially the branch-protection and required-check pieces, which
+you generally cannot apply yourself anyway. For those, hand over the exact settings to click
+or the `gh api` command to run.
diff --git a/skills/poka-yoke-guardrails/assets/devices/claude-hooks/README.md b/skills/poka-yoke-guardrails/assets/devices/claude-hooks/README.md
new file mode 100644
index 000000000..5c26c91ab
--- /dev/null
+++ b/skills/poka-yoke-guardrails/assets/devices/claude-hooks/README.md
@@ -0,0 +1,138 @@
+# Claude Code hooks as poka-yoke devices
+
+Instructions to an agent are rung zero: a line in CLAUDE.md saying "never force-push" is
+training, and training degrades under long contexts, compaction, and subagents that never read
+the file. A hook that denies the push is a device.
+
+Rule of thumb for what to gate: **irreversible and outward-facing**. Git makes ordinary code
+changes cheap to undo, so gating them produces an agent that spends its turns fighting the
+harness and a user who switches the rules off. A rotated credential and a dropped table are
+the real targets.
+
+## 1. Deny rules, start here
+
+The cheapest device, no scripting required. In `.claude/settings.json` (committed, so the rule
+exists for everyone rather than only on the machine of whoever set it up):
+
+```jsonc
+{
+ "permissions": {
+ "deny": [
+ "Bash(git push --force:*)",
+ "Bash(git push -f:*)",
+ "Bash(git commit --no-verify:*)",
+ "Bash(git reset --hard:*)",
+ "Read(./.env)",
+ "Read(./.env.*)",
+ "Edit(./.env)",
+ "Read(./**/credentials)",
+ "Edit(./migrations/**)",
+ "Bash(terraform apply:*)",
+ "Bash(terraform destroy:*)",
+ "Bash(npm publish:*)",
+ "Bash(gh repo delete:*)"
+ ]
+ }
+}
+```
+
+Personal additions go in `.claude/settings.local.json`, which is gitignored.
+
+## 2. Conditional guards, when the rule needs logic
+
+Deny rules match patterns. When the decision depends on the *content* of the command: a
+`DELETE` without a `WHERE`, a connection string pointing at production, use a hook script.
+
+`guard_dangerous_commands.py` in this directory covers the common irreversible cases. Wire it
+up in `.claude/settings.json`:
+
+```jsonc
+{
+ "hooks": {
+ "PreToolUse": [
+ {
+ "matcher": "Bash|Edit|Write|Read",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "python3 \"${CLAUDE_PROJECT_DIR}\"/.claude/hooks/guard_dangerous_commands.py"
+ }
+ ]
+ }
+ ]
+ }
+}
+```
+
+Copy the script to `.claude/hooks/` in the target repo, hooks resolve against the project, not
+against this plugin's cache directory, which changes on every plugin update.
+
+**The deny message is the device, not the denial.** The agent reads the reason and acts on it,
+so "denied" produces a creative workaround, often worse than the original command, while a
+message naming the safe alternative produces the right action. Write them as you would write
+an error message for a colleague.
+
+## 3. Verification gates, make "done" mean something
+
+An agent's characteristic failure is reporting success it did not achieve: "all tests pass"
+when the suite was never run. A `Stop` hook converts the claim into a fact:
+
+```jsonc
+{
+ "hooks": {
+ "Stop": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "bash -c 'npm run typecheck && npm test || exit 2'"
+ }
+ ]
+ }
+ ]
+ }
+}
+```
+
+Only exit code 2 blocks. It stops the agent and feeds the hook's stderr back as the reason,
+which is why the check is wrapped rather than run bare, `npm test` exits 1 on failure, and
+any non-zero exit other than 2 is surfaced to the user as a hook error while the agent stops
+anyway. Claude Code caps consecutive Stop-hook blocks at eight, so a check that can never pass
+eventually releases instead of looping. This is usually the single highest-value hook in a
+repo.
+
+## 4. Test your hooks
+
+Untested hooks fail open more often than you would expect: a regex that does not match the
+real command string is a hook that does nothing while looking like protection, which is worse
+than no hook at all because it creates confidence.
+
+For each rule: run the blocked action and confirm both the denial *and* the message, then run
+the legitimate neighbouring action and confirm it still works.
+
+```bash
+echo '{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}' \
+ | python3 guard_dangerous_commands.py
+# expect: permissionDecision "deny" with a reason mentioning --force-with-lease
+
+echo '{"tool_name":"Bash","tool_input":{"command":"git push origin main"}}' \
+ | python3 guard_dangerous_commands.py
+# expect: no output; the ordinary push is unaffected
+```
+
+## 5. Move CLAUDE.md rules into devices
+
+Anything in CLAUDE.md that *can* be a check should be one. What remains should be facts the
+agent needs, not rules you hope it follows.
+
+| CLAUDE.md line | Device |
+|---|---|
+| "Always run `make fmt` before committing" | pre-commit hook |
+| "Never use `any`" | lint rule at error, in a required check |
+| "Don't edit generated files" | deny rule + a header in the generated file |
+| "Use `pnpm`, not `npm`" | `PreToolUse` hook denying `npm install`, message naming pnpm |
+| "Run tests before saying you're done" | Stop hook |
+| "Never commit to main directly" | branch protection |
+
+What legitimately stays as prose: architecture, domain vocabulary, where things live, why past
+decisions were made. Facts, not commands.
diff --git a/skills/poka-yoke-guardrails/assets/devices/claude-hooks/guard_dangerous_commands.py b/skills/poka-yoke-guardrails/assets/devices/claude-hooks/guard_dangerous_commands.py
new file mode 100755
index 000000000..f16c62c71
--- /dev/null
+++ b/skills/poka-yoke-guardrails/assets/devices/claude-hooks/guard_dangerous_commands.py
@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+"""PreToolUse guard, deny irreversible agent actions before they execute.
+
+Wire this into .claude/settings.json (see hooks.json in this directory). It reads the hook
+payload on stdin and denies commands whose mistakes cannot be undone.
+
+Design notes worth keeping if you adapt this:
+
+ * Aim at IRREVERSIBLE and OUTWARD-FACING actions only. Git makes ordinary code changes
+ cheap to undo, so gating them produces an agent that fights the harness and a user who
+ turns the hook off. Rotated credentials and dropped tables are the real targets.
+
+ * The deny REASON is the device. The agent reads it and acts on it, so a bare "denied"
+ produces a creative workaround, often worse than the original command. Say what was
+ blocked, why, and what to do instead.
+
+ * Fail open on unexpected input. A hook that crashes on an unusual payload blocks all
+ tool use, which is its own outage.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import sys
+
+# (pattern, reason). Reasons are written for the agent to act on, not for a log.
+RULES: list[tuple[str, str]] = [
+ # --force-with-lease is the safe form and must stay allowed, so the lookahead sits
+ # directly after "--force" rather than at the end of the line.
+ (r"\bgit\s+push\b.*(--force(?!-with-lease)|(?\s*/dev/sd|\bmkfs\b|\bdd\s+.*of=/dev/",
+ "This writes directly to a block device and destroys data unrecoverably."),
+]
+
+# Paths an agent should not read or write. Reading a secret matters as much as writing one:
+# it can be echoed into a log, a commit, or a message to a third party.
+# .env.example / .sample / .template hold the SHAPE of the config, not the values, and the
+# deny message below points the agent at them, so denying them would block the alternative
+# the device recommends.
+PROTECTED_PATHS = re.compile(
+ r"(^|/)\.env($|\.(?!example|sample|template|dist))"
+ r"|(^|/)\.aws/credentials|(^|/)\.ssh/id_|(^|/)\.npmrc|(^|/)\.pypirc"
+)
+
+
+# poka-yoke: blocks irreversible agent actions before they execute [control]
+def deny(reason: str) -> None:
+ json.dump({
+ "hookSpecificOutput": {
+ "hookEventName": "PreToolUse",
+ "permissionDecision": "deny",
+ "permissionDecisionReason": f"[poka-yoke] {reason}",
+ }
+ }, sys.stdout)
+ sys.exit(0)
+
+
+def main() -> None:
+ try:
+ payload = json.load(sys.stdin)
+ except (json.JSONDecodeError, ValueError):
+ sys.exit(0) # fail open: a crashing hook blocks all tool use
+
+ tool = payload.get("tool_name", "")
+ tool_input = payload.get("tool_input") or {}
+
+ if tool == "Bash":
+ command = tool_input.get("command", "")
+ for pattern, reason in RULES:
+ if re.search(pattern, command, re.IGNORECASE):
+ deny(f"{reason}\n\nBlocked command: {command[:200]}")
+
+ if tool in ("Read", "Edit", "Write", "NotebookEdit"):
+ path = tool_input.get("file_path", "") or tool_input.get("notebook_path", "")
+ if path and PROTECTED_PATHS.search(path):
+ deny(
+ f"'{path}' holds credentials. Reading them risks echoing a secret into a log, "
+ "a commit, or a message to a third party. Use .env.example for the shape of "
+ "the config, and ask the user for any value you actually need."
+ )
+
+ sys.exit(0)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/skills/poka-yoke-guardrails/assets/devices/claude-hooks/suggest_poka_yoke.py b/skills/poka-yoke-guardrails/assets/devices/claude-hooks/suggest_poka_yoke.py
new file mode 100755
index 000000000..f6b4645fa
--- /dev/null
+++ b/skills/poka-yoke-guardrails/assets/devices/claude-hooks/suggest_poka_yoke.py
@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+"""UserPromptSubmit hook, turn auto-triggering from a hope into a device.
+
+Claude Code skills are documented as model-invoked, and in practice they often are not.
+This is a widely reported platform behaviour, not a defect in any one description:
+anthropics/claude-code#9716 collects reports of skills being ignored even when the query
+exactly matches the description. Testing this plugin found the same, five realistic queries
+across four modes, zero skill invocations.
+
+The plugin's own argument applies to the plugin: if the behaviour you want depends on the
+model remembering to look, that is rung zero. So install a device.
+
+What does NOT work, per Scott Spence's write-up of the same problem: a hook that emits a
+gentle reminder, "check .claude/skills/ for something relevant", is treated as background
+noise. The model acknowledges it and proceeds anyway.
+
+What does work is naming the specific skill and instructing its use. That is what this does:
+match the prompt against each mode's trigger vocabulary, and if one matches, inject an
+explicit instruction to load that skill.
+
+ # poka-yoke: makes skill invocation explicit rather than hoping the model volunteers [warning]
+
+This is Warning rung, not Control. The injected instruction is still an instruction, and the
+model can still decline, Spence's verdict after living with it is "for anything important,
+invoke it explicitly." Treat this as convenience for the common case, and use the slash
+command when it matters.
+
+Install in .claude/settings.json:
+
+ {"hooks": {"UserPromptSubmit": [{"hooks": [{"type": "command",
+ "command": "python3 \\"${CLAUDE_PROJECT_DIR}\\"/.claude/hooks/suggest_poka_yoke.py"}]}]}}
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import sys
+
+# Ordered: the first match wins, so put the specific modes ahead of the general ones.
+# Patterns are deliberately narrow. A hook that fires on every prompt is noise, and noise
+# gets the hook removed: the same failure mode as a guardrail that cries wolf.
+MODES: list[tuple[str, str]] = [
+ ("authz",
+ r"\b(tenant|multi.?tenant|idor|cross.?tenant|row.?level security|rls)\b.*"
+ r"\b(isolat|scope|leak|filter|see (each )?other)|"
+ r"\b(one|another) (customer|tenant|user)('s)? (data|documents|records)\b"),
+
+ ("agent-guardrails",
+ r"\b(claude|the agent|codex|cursor|copilot)\b.*\b(keeps?|still|ignor|won'?t stop)\b|"
+ r"\bCLAUDE\.md\b.*\b(ignor|says|but it)\b|"
+ r"\b(stop|prevent) (the )?(agent|claude)\b"),
+
+ ("llm",
+ r"\b(prompt injection|structured output|hallucinat)\b|"
+ r"\b(our|the) (bot|ai|llm|model|agent)\b.*\b(returns?|extracts?|calls?|refunds?|sometimes)\b"),
+
+ ("data",
+ r"\b(dashboard|pipeline|warehouse|dbt|etl|metric|revenue)\b.*"
+ r"\b(wrong|silently|nulls?|stale|didn't notice|did not notice|coalesce|coalescing)\b"),
+
+ ("ops",
+ r"\b(drop(ping)? (a )?column|migration|expand.?contract|blast radius|kill switch)\b|"
+ r"\b(deploy|ship|merge)\b.*\b(friday|risky|safe|rollback|irreversible)\b"),
+
+ ("ux",
+ r"\b(users?|customers?)\b.*\b(accident|by mistake|keep deleting|panic)\b|"
+ r"\b(confirm(ation)? (dialog|modal)|are you sure|undo)\b"),
+
+ ("retro",
+ # "root cause" and "incident" were missing, so the single most standard way anyone
+ # describes this work, "do a root cause on last night's outage", got silence.
+ r"\b(happened again|second time|third time|keeps? happening|postmortem|post.?mortem|"
+ r"never happens? again|prevent .*recurr|root.?cause|incident review|"
+ r"(after|following) (the|an|last night'?s) (incident|outage))\b|"
+ r"\b(incident|outage|we (double.?charged|dropped|lost|corrupted|deleted))\b"
+ r"[^.?!]*\b(root.?cause|why|how did|what went wrong|so it (does not|does not) happen)\b"),
+
+ ("guardrails",
+ r"\b(pre.?commit|ci gate|required check|branch protection|lint rule)\b|"
+ r"\b(we (agreed|said)|team agreed)\b.*\b(still|don'?t|nobody)\b|"
+ r"\benforce\b.*\b(so (people|they) can'?t|instead of (asking|documenting))\b"),
+
+ ("design",
+ # The jargon alternatives fire only for people who already know the vocabulary. Someone
+ # who says "design the types for our state machine so bad states can't exist" wants this
+ # mode and was getting silence, which made the README's claim that all ten modes route
+ # false for the one mode the README tells people to start with.
+ # `design` sits below `ux`, `ops` and `authz`, so "redesign the deletion flow" is still
+ # claimed by ux before it reaches here.
+ r"\b(invalid states? unrepresentable|make it impossible to|so (you|people) can'?t "
+ r"(accidentally|screw)|typestate|discriminated union|branded type)\b|"
+ r"\bwhat should (the )?(types?|signature|api)\b.*\blook like\b|"
+ r"\b(design|model|write|writing|about to write)\b[^.?!]*\b(api|sdk|types?|schema|"
+ r"interface|signature|state machine|enum|data model)\b|"
+ r"\b(api|types?|schema|interface)\b[^.?!]*\b(hard|impossible|difficult) to (use|misuse)\b"),
+
+ ("audit",
+ r"\b(footgun|easy to (use|misuse)|what could (go wrong|bite)|mistake.?proof|error.?proof|"
+ r"poka.?yoke|poke.?yoke|foolproof)\b"),
+]
+
+TEMPLATE = (
+ "[poka-yoke] This request matches the `{skill}` skill, which carries a specific method "
+ "for it, classify the mistake, pick the strongest device that prevents it, and say which "
+ "rung that reaches. Load `{skill}` and follow it before answering. If it turns out not to "
+ "fit, say so in one line and answer normally."
+)
+
+
+def main() -> None:
+ try:
+ prompt = (json.load(sys.stdin).get("prompt") or "")
+ except (json.JSONDecodeError, ValueError):
+ sys.exit(0) # fail open: a broken hook must not block every prompt
+
+ if len(prompt) > 4000:
+ prompt = prompt[:4000]
+
+ for skill, pattern in MODES:
+ if re.search(pattern, prompt, re.IGNORECASE):
+ print(TEMPLATE.format(skill=skill))
+ break
+ sys.exit(0)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/skills/poka-yoke-guardrails/assets/devices/github-actions/poka-yoke-gates.yml b/skills/poka-yoke-guardrails/assets/devices/github-actions/poka-yoke-gates.yml
new file mode 100644
index 000000000..a79023021
--- /dev/null
+++ b/skills/poka-yoke-guardrails/assets/devices/github-actions/poka-yoke-gates.yml
@@ -0,0 +1,118 @@
+# Poka-yoke CI gates.
+#
+# poka-yoke: refuses to merge a change that fails a gate, once the jobs are marked required [control]
+#
+# This is where enforcement actually lives. Pre-commit hooks are bypassable; a required
+# check with branch protection is not. Every job here should also be marked "Required" in
+# branch protection settings, without that, this workflow is advisory and the whole device
+# is inert.
+#
+# Design note: jobs enforce on CHANGED FILES where possible. Turning a strict rule on across
+# a large existing codebase produces hundreds of failures and gets reverted by Friday;
+# ratcheting on new code only means the violation count can only go down.
+
+name: poka-yoke
+
+on:
+ pull_request:
+ push:
+ branches: [main]
+
+permissions:
+ contents: read
+
+jobs:
+ hazards:
+ name: hazard scan
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ with:
+ fetch-depth: 0 # needed to diff against the base branch
+ - uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+ - name: Scan changed lines for high-severity hazards
+ # Context values go through env, never inline into run:. Interpolating
+ # ${{ }} directly into a shell command is how workflow injection happens.
+ env:
+ BASE_REF: ${{ github.base_ref || 'main' }}
+ run: |
+ python3 scripts/detect_hazards.py \
+ --since "origin/${BASE_REF}" \
+ --severity high
+
+ types:
+ # The gate that makes every design-time device load-bearing. A branded type in a repo
+ # that doesn't typecheck in CI is a comment.
+ name: type check
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ # --- pick the one that matches this repo and delete the rest ---
+ # - run: npx tsc --noEmit
+ # - run: uv run mypy --strict src/
+ # - run: go vet ./... && go build ./...
+ # - run: cargo clippy --all-targets -- -D warnings
+ - run: echo "Replace with this repo's type checker, then delete this line."
+
+ secrets:
+ name: secret scan
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ with:
+ fetch-depth: 0
+ - uses: gitleaks/gitleaks-action@v2
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ test-integrity:
+ # A skipped or focused test is a detection device switched off. Usually temporarily.
+ # Permanently.
+ name: test integrity
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+ - name: No focused tests
+ run: |
+ if grep -rEn '(\.only\(|fdescribe\(|\bfit\()' \
+ --include='*.ts' --include='*.tsx' --include='*.js' \
+ --exclude-dir=node_modules . ; then
+ echo "::error::Focused tests disable the rest of the suite. Remove .only before merging."
+ exit 1
+ fi
+
+ migration-safety:
+ # Destructive DDL co-deployed with application code is an outage during the rollout
+ # window, because old code necessarily runs against the new schema. Use expand/contract:
+ # add, backfill, dual-write, switch reads, and drop in a LATER deploy.
+ #
+ # Escape hatch: label the PR `destructive-migration-approved` when the drop is genuinely
+ # intended and nothing references the column. The label is the explicit acknowledgment
+ # that turns an accident into a decision.
+ name: migration safety
+ runs-on: ubuntu-latest
+ if: "!contains(github.event.pull_request.labels.*.name, 'destructive-migration-approved')"
+ steps:
+ - uses: actions/checkout@v5
+ with:
+ fetch-depth: 0
+ - name: Block destructive DDL
+ env:
+ BASE_REF: ${{ github.base_ref || 'main' }}
+ run: |
+ BASE="origin/${BASE_REF}"
+ # Read into an array rather than splitting a string: an unquoted $CHANGED splits
+ # on spaces as well as newlines, so a migration whose filename contains a space
+ # becomes two paths that do not exist, and `git diff` then reports no changes,
+ # which this gate would read as "nothing destructive here".
+ mapfile -t CHANGED < <(git diff --name-only "$BASE"...HEAD \
+ -- 'migrations/**' 'db/migrate/**' || true)
+ [ ${#CHANGED[@]} -eq 0 ] && { echo "No migrations changed."; exit 0; }
+ if git diff "$BASE"...HEAD -- "${CHANGED[@]}" | grep -iE '^\+.*(DROP (TABLE|COLUMN)|TRUNCATE|ALTER .* DROP)'; then
+ echo "::error::Destructive DDL detected. Use expand/contract and drop in a later deploy."
+ echo "If this drop is intentional and nothing references the column, add the"
+ echo "'destructive-migration-approved' label to this PR."
+ exit 1
+ fi
diff --git a/skills/poka-yoke-guardrails/assets/devices/lint/README.md b/skills/poka-yoke-guardrails/assets/devices/lint/README.md
new file mode 100644
index 000000000..879306829
--- /dev/null
+++ b/skills/poka-yoke-guardrails/assets/devices/lint/README.md
@@ -0,0 +1,126 @@
+# Lint rules as poka-yoke devices
+
+Most lint rules are style. These are not: each one below prevents a specific mistake that
+produces a specific wrong behavior. Set them to `error`, not `warn`: a warning in a list of
+four hundred warnings is rung zero.
+
+Install strategy: enforce on changed files, or generate a baseline of existing violations and
+fail only on new ones. Turning these on repo-wide in a large codebase produces a wall of
+failures and the rule gets reverted. The violation count only needs to go down.
+
+## TypeScript, `eslint.config.js`
+
+Requires `@typescript-eslint` with type-aware linting (`projectService: true`), because the
+highest-value rules here need type information.
+
+```js
+// eslint.config.js
+import tseslint from "typescript-eslint";
+
+export default tseslint.config(
+ ...tseslint.configs.recommendedTypeChecked,
+ {
+ languageOptions: { parserOptions: { projectService: true } },
+ rules: {
+ // --- silent failure: the highest-value rules in this file ---
+ "@typescript-eslint/no-floating-promises": "error", // an unawaited write, silently lost
+ "@typescript-eslint/no-misused-promises": "error", // async fn passed where sync expected
+ "no-empty": ["error", { allowEmptyCatch: false }], // swallowed errors
+ "require-atomic-updates": "error", // read-modify-write race across await
+
+ // --- completeness ---
+ "@typescript-eslint/switch-exhaustiveness-check": "error", // new variant, silently unhandled
+ "@typescript-eslint/no-unnecessary-condition": "error", // always-true check = usually a bug
+
+ // --- type guarantees ---
+ "@typescript-eslint/no-explicit-any": "error",
+ "@typescript-eslint/no-unsafe-assignment": "error",
+ "@typescript-eslint/no-unsafe-argument": "error",
+ "@typescript-eslint/no-unsafe-return": "error",
+ "@typescript-eslint/no-non-null-assertion": "error", // `!` is an unchecked claim
+
+ // --- coercion surprises ---
+ eqeqeq: ["error", "always"],
+
+ // --- detection devices switched off ---
+ "no-restricted-syntax": ["error",
+ { selector: "MemberExpression[property.name='only']",
+ message: "Focused tests disable the rest of the suite. Remove before merging." }],
+ },
+ },
+);
+```
+
+`tsconfig.json` matters as much as the lint config, none of the above is load-bearing without:
+
+```jsonc
+{
+ "compilerOptions": {
+ "strict": true,
+ "noUncheckedIndexedAccess": true, // array access is T | undefined, which is the truth
+ "exactOptionalPropertyTypes": true
+ }
+}
+```
+
+## Python, `pyproject.toml`
+
+```toml
+[tool.ruff.lint]
+select = [
+ "E", "F", # pyflakes: undefined names, unused imports: real bugs
+ "B", # bugbear: mutable defaults (B006), loop variable capture, assert on tuple
+ "S", # bandit: hardcoded secrets, unsafe subprocess, weak crypto
+ "DTZ", # naive datetimes
+ "ASYNC", # blocking calls inside async functions
+ "RUF006", # dangling asyncio task, can be GC'd mid-flight, work silently not done
+ "PLE", # pylint errors only, not conventions
+ "T20", # stray print/pprint
+]
+ignore = ["E501"] # line length is style, not mistake-proofing
+
+[tool.ruff.lint.per-file-ignores]
+"tests/**" = ["S101"] # assert is fine in tests; it is not fine as production validation
+
+[tool.mypy]
+strict = true
+disallow_any_unimported = true # an untyped dependency reintroduces Any silently
+warn_return_any = true
+```
+
+## Go, `.golangci.yml`
+
+```yaml
+linters:
+ enable:
+ - errcheck # unchecked errors: Go's error convention is opt-in without this
+ - exhaustive # non-exhaustive switch over typed constants
+ - bodyclose # unclosed HTTP response bodies
+ - rowserrcheck
+ - sqlclosecheck
+ - contextcheck # context not propagated: cancellation and timeouts silently disabled
+ - nilerr # returning nil after a non-nil error
+ - noctx # HTTP requests without a context
+ - gosec
+
+linters-settings:
+ exhaustive:
+ default-signifies-exhaustive: false
+```
+
+`errcheck` is the non-negotiable one. `_ = doSomething()` is how data loss enters a Go
+codebase.
+
+## Rust, `Cargo.toml`
+
+```toml
+[workspace.lints.clippy]
+unwrap_used = "deny" # highest value: turns "this can't fail" into an explicit decision
+expect_used = "warn" # acceptable at startup and in tests, with a reason
+panic = "deny"
+indexing_slicing = "deny" # forces .get() and a real branch
+float_cmp = "deny"
+arithmetic_side_effects = "warn" # forces checked_/saturating_ where overflow matters
+todo = "deny"
+dbg_macro = "deny"
+```
diff --git a/skills/poka-yoke-guardrails/assets/devices/pre-commit/.pre-commit-config.yaml b/skills/poka-yoke-guardrails/assets/devices/pre-commit/.pre-commit-config.yaml
new file mode 100644
index 000000000..0617e033f
--- /dev/null
+++ b/skills/poka-yoke-guardrails/assets/devices/pre-commit/.pre-commit-config.yaml
@@ -0,0 +1,65 @@
+# Poka-yoke pre-commit devices.
+#
+# poka-yoke: stops a mistake reaching a commit, at the cost of being bypassable [warning]
+#
+# Adapt to the repo's actual stack before installing: an unused hook is friction with no
+# protection. Keep the whole run under ~5 seconds; slower than that and people use
+# --no-verify, which turns every hook here into decoration.
+#
+# IMPORTANT: pre-commit is bypassable by design (`git commit --no-verify`). Never rely on it
+# alone for anything that matters. Run the same checks as a required CI check so the hook
+# provides speed and CI provides authority.
+
+repos:
+ # ---- Irreversible mistakes first. A leaked key can only be rotated, never unleaked. ----
+ - repo: https://github.com/gitleaks/gitleaks
+ rev: v8.28.0
+ hooks:
+ - id: gitleaks
+
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v6.0.0
+ hooks:
+ - id: check-added-large-files # a committed binary is painful to remove later
+ args: [--maxkb=1000]
+ - id: check-merge-conflict # conflict markers shipped to main
+ - id: check-case-conflict # breaks on case-insensitive filesystems only
+ - id: detect-private-key
+ - id: end-of-file-fixer
+ - id: trailing-whitespace
+ - id: check-json
+ - id: check-yaml
+ - id: check-toml
+
+ # ---- Python: the rules that catch silent failure, not the ones that argue about style ----
+ - repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: v0.14.5
+ hooks:
+ - id: ruff-check
+ args: [--fix, --exit-non-zero-on-fix]
+ - id: ruff-format
+
+ # ---- Repo-specific hazards. Fast, changed-files-only. ----
+ - repo: local
+ hooks:
+ - id: poka-yoke-hazards
+ name: poka-yoke hazard scan (high severity only)
+ # Point this at wherever you vendored the detector. It is not on your PATH and this
+ # file cannot know where you put it: an entry that silently resolves to nothing
+ # would make the hook pass on every commit, which is worse than not installing it.
+ # git clone https://github.com/rainmanjam/poka-yoke /tmp/pk
+ # cp -r /tmp/pk/plugins/poka-yoke/scripts tools/poka-yoke
+ entry: python3 tools/poka-yoke/detect_hazards.py --staged --severity high
+ language: system
+ pass_filenames: false
+ # Fails the commit on high-severity hazards in staged changes only, so pre-existing
+ # code doesn't block anyone. New violations can't be added; the count only goes down.
+
+ - id: no-focused-tests
+ name: no focused or skipped tests
+ entry: '(\.only\(|fdescribe\(|fit\(|@pytest\.mark\.skip|t\.Skip\()'
+ language: pygrep
+ types_or: [python, javascript, ts, tsx, go]
+ exclude: '^(tests/fixtures/|.*\.md$)'
+ # A focused test silently disables the rest of the suite: a detection device
+ # switched off by accident.
diff --git a/skills/poka-yoke-llm/SKILL.md b/skills/poka-yoke-llm/SKILL.md
new file mode 100644
index 000000000..92bab5b74
--- /dev/null
+++ b/skills/poka-yoke-llm/SKILL.md
@@ -0,0 +1,162 @@
+---
+name: poka-yoke-llm
+description: >-
+ AI features you ship to users: structured output, tool schemas, prompt injection, evals. Use when "the model returns bad JSON", "it hallucinates", "stop it calling the wrong tool", "add evals", or an LLM feature can trigger refunds, emails or writes. Covers schema-constrained output, idempotent tool calls, confirmation gates. For agents editing your repo use agent-guardrails.
+license: MIT
+---
+
+# Poka-Yoke for LLM Features
+
+This is about AI features **you ship to users**: not about agents editing your repo, which is
+`agent-guardrails`.
+
+The defining property of an LLM is that it is a component with a non-zero error rate on every
+call, and no amount of prompt engineering drives that to zero. This is not a defect to fix; it
+is the material you are building with. Shingo's framing fits perfectly: you do not make the
+operator more careful, you build the jig.
+
+Which means the central discipline here: **prompt instructions are rung zero.** "Always respond
+with valid JSON," "never make up a citation," "do not reveal the system prompt". These are
+requests to an unreliable component, and they are the LLM equivalent of a comment saying "be
+careful." They help, they are worth writing, and they are not devices. A device is something
+outside the model that constrains what it can produce or what its output can reach.
+
+## Building, not reviewing
+
+Most of the time this mode is reached *while someone is building the thing*, not afterwards.
+That changes the deliverable. They asked for the feature, so produce the feature, working, complete,
+in their stack. Do not hand back a severity table when the person is mid-feature; a list of
+findings about code they have not written yet is not useful to them.
+
+Then add a short closing note, three or four lines, covering:
+
+- which misuses the shape you chose makes impossible, and at which rung,
+- what you left possible on purpose, and why that tradeoff is the right one here.
+
+That closing note is what stops the device being undone in six months by someone who cannot
+see why it is there. It is also the difference between mistake-proofing and a code generator:
+the reasoning travels with the code.
+
+When the code already exists and they are asking what is wrong with it, switch to the audit
+voice, ranked findings with the mistake, the consequence, and the device. Match the mode to
+where they are in the work, not to this file's default.
+
+## The boundary: nothing the model says is trusted until something checks it
+
+Draw the same line you would draw around any external, untrusted input, because that is
+exactly what model output is, and doubly so when the model has read user-supplied text.
+
+### Structured output over prose parsing (Control, contact lens)
+
+Never regex a model's prose. Use the provider's constrained/structured output mode with a
+schema, then validate the parsed result against that schema yourself:
+
+```python
+class Extraction(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ sentiment: Literal["positive", "neutral", "negative"]
+ confidence: float = Field(ge=0.0, le=1.0)
+```
+
+Constrained decoding makes malformed output largely unrepresentable, and the schema check
+catches the rest. That removes the whole class of parse failures, malformed JSON, missing
+fields, invented enum values.
+
+Two things the schema still cannot tell you: whether the values are *correct*, and what to do
+when validation fails. Decide the failure path explicitly, retry once with the error fed
+back, then fall back to a deterministic path or return a clear failure. A silent default here
+is `except: pass` with a language model attached.
+
+### Enumerate rather than generate wherever possible
+
+The strongest device in this whole mode: if the output is a choice from a known set, have the
+model choose an ID from a list you supply and reject anything not in it. A model asked to
+produce a category name will invent one eventually; a model choosing among five IDs cannot.
+Applies to routing, classification, tool selection, and picking a record, and it converts an
+open-ended generation problem into a closed-set one that a `Literal` type enforces.
+
+### Ground factual claims, and make ungrounded output impossible to render
+
+For anything retrieval-backed, require the response to cite retrieved chunk IDs, then verify
+each cited ID actually exists in what you retrieved and drop or flag claims that don't
+resolve. That check establishes that a citation resolves, not that the chunk it points at
+supports the claim, where the claim is consequential, add an entailment check or human review
+on top. Prompting for citations is rung zero; *verifying* them is a real device. Show the
+source in the UI so the user can check. This is the interface half of the same device.
+
+When retrieval returns nothing relevant, the correct behavior is to say so. A model handed no
+context will answer anyway, and that answer is invention. Check for the empty-context case in
+code, before the call, and short-circuit.
+
+## Side effects: the model proposes, the system disposes
+
+The most expensive LLM bugs are not wrong text. They are actions. Refunds issued, emails sent,
+records deleted, all because a model decided to.
+
+- **Split tool calls by reversibility.** Read-only tools execute freely. Anything irreversible
+ or outward-facing, payment, email, deletion, publishing, external writes, requires a human
+ confirmation that names the specific action and its parameters. This is the same ladder as
+ everywhere else; irreversible actions need Control.
+- **Make the tool schema tight.** Enums instead of free strings, required parameters instead of
+ optional ones, ranges on numbers, and no "extra context" free-text field the model can use
+ to smuggle in intent. A wide tool schema is a wide attack surface and a wide mistake surface.
+- **Validate arguments server-side, always.** The model is a client, and a client's input is
+ never trusted. `refund(amount)` must re-check the amount against the actual order: the
+ model saying `9999` is not authorization.
+- **Idempotency keys on every effectful tool call**, backed by a unique constraint. Agent loops
+ retry; retries double-charge. This is hazard M2 with a higher retry rate than any human path.
+- **Scope credentials to the user, not to the service.** If the tool runs with service-level
+ access, a prompt injection reaches everything. Pass the requesting user's authorization
+ through, so the model cannot exceed what that user could do, see `authz`.
+
+## Prompt injection is a boundary problem, not a prompt problem
+
+Any text the model reads, user input, retrieved documents, web pages, emails, tool results, can carry instructions. No system prompt reliably prevents this, and treating it as a prompt
+engineering problem is why it keeps happening.
+
+The devices are structural: keep untrusted content clearly delimited and labeled as data;
+never let model output flow into a privileged action without validation or confirmation;
+scope permissions so a successful injection has a small blast radius; and treat any model
+output that will be rendered as HTML, executed as SQL, or passed to a shell exactly as you
+would treat user input from an attacker, because functionally it is.
+
+The load-bearing question is not "can the model be tricked?" (yes) but "**what can the model
+reach if it is tricked?**"
+
+## Bounds: cost and loops
+
+An agent loop with no cap is an unbounded resource operation, hazard F7 with a billing
+account attached. Set a maximum step count, a token budget per request, and a wall-clock
+timeout, all enforced in your code rather than requested in the prompt. Alert on cost per
+user, and cap it per tenant so one runaway conversation cannot become a five-figure invoice.
+
+## Evals are the detection rung, and they are load-bearing
+
+You cannot unit-test a probabilistic component, but you can measure it, and without
+measurement you have no idea whether a prompt change helped.
+
+- **A held-out eval set with assertions**, run in CI on every prompt, model, or retrieval
+ change. Prompts are code with no type checker. This is the only gate they have.
+- **Assert on the structured fields**, which are checkable, rather than on prose similarity.
+ This is another reason structured output pays for itself.
+- **Every production failure becomes an eval case.** This is the `retro` loop applied
+ to a component that cannot be fixed, only constrained: you cannot patch the model, so the
+ regression test *is* the fix, and it must cover the class rather than the one input.
+- **Pin the model version.** A provider updating a model underneath you is an unannounced
+ deploy of your most unpredictable component. Pin it, and re-run evals before moving.
+
+## Auditing an LLM feature
+
+1. **Where does model output go?** Trace each path. Which reach a database, an API, a shell,
+ the DOM, or a user as fact? Each needs a check at that boundary.
+2. **What is parsed from prose that could be structured?**
+3. **Which tools have irreversible effects, and what gates them?**
+4. **What untrusted text enters the context, and what could an instruction in it reach?**
+5. **What happens when the model fails**: malformed output, refusal, timeout, rate limit,
+ empty retrieval? Is there a deterministic fallback, or does it fail silently?
+6. **What bounds exist on steps, tokens, and cost?**
+7. **Is there an eval suite, does CI run it, and does a regression block the merge?**
+
+Report with the structure from `audit`, and be honest about rungs, with a
+probabilistic component, most in-model devices are Warning at best, and only the checks
+*outside* the model reach Control.
diff --git a/skills/poka-yoke-ops/SKILL.md b/skills/poka-yoke-ops/SKILL.md
new file mode 100644
index 000000000..650839589
--- /dev/null
+++ b/skills/poka-yoke-ops/SKILL.md
@@ -0,0 +1,159 @@
+---
+name: poka-yoke-ops
+description: >-
+ Deploys, schema migrations, rollback and infrastructure. Use when "can I ship this on Friday", "this migration is scary", "what is the blast radius", "prevent accidental deletion of the database", or a change drops a column. Covers expand/contract, canary rollout, kill switches, prevent_destroy, tested backups. For an incident that already happened use retro.
+license: MIT
+---
+
+# Poka-Yoke for Deploys and Infrastructure
+
+Operations is where irreversible mistakes concentrate. Code mistakes are usually recoverable, git remembers, a revert ships in twenty minutes. A dropped table, a deleted bucket, a rotated
+credential, or a terminated stateful node is not recoverable by any amount of engineering
+after the fact.
+
+So the governing question in this mode is different from the rest of the plugin. Not "can this
+be done wrong?" but: **when this is done wrong, how much is affected, and can it be undone?**
+Those two axes, blast radius and reversibility, determine every device below.
+
+## Answer these four first
+
+Before any framework or table, establish these. They are what an operator actually needs, and
+they are the things most often left out: an answer that skips them is not useful no matter
+how well organized the rest is. Say each one plainly, in a sentence, before going deeper.
+
+1. **What here is irreversible, and what restores it?** Name the specific unrecoverable step: a dropped column, a deleted bucket, a rotated key. Then say what would restore it: a
+ backup, a snapshot, a rebuild. **If the answer is "nothing", say so explicitly.** An
+ irreversible step with no stated restore path is the single most important thing you can
+ tell someone, and it is the first thing to get lost in a longer answer.
+2. **What breaks during the rollout window?** Deploys are not atomic. For a period, old code
+ runs against the new state. Say what happens in that window, usually this is the actual
+ outage, not the change itself.
+3. **Can the irreversible part ship separately?** Most changes are a reversible part and an
+ irreversible part stapled together. Splitting them is nearly always available and nearly
+ always right; say so concretely rather than in general.
+4. **If it goes wrong, who is available and how fast is rollback?** Timing questions are about
+ staffing and recovery speed, not superstition. A change that reverts in two minutes is fine
+ on a Friday afternoon; one that needs a four-hour restore with two people asleep is not.
+
+Cover all four even when the answer is brief. If you only have room for a little, spend it
+here rather than on the taxonomy: the rungs below are how to *think* about the fix, but these
+four are what the person has to know before they ship.
+
+## The blast radius ladder
+
+Most ops poka-yoke is not about preventing the bad change. It is about ensuring the bad change
+reaches 1% of traffic instead of 100%. You cannot prevent every bad deploy; you can make bad
+deploys cheap.
+
+| Rung | Device | What it buys |
+|---|---|---|
+| **1 Control** | The dangerous operation is impossible in this environment, `prevent_destroy` on stateful resources, deletion protection on the database, no human write access to prod, immutable infrastructure | The mistake cannot be made at all |
+| **1 Control** | Progressive rollout with automatic rollback on error-rate: the bad version is withdrawn before most users see it | The mistake is capped and self-healing |
+| **2 Warning** | Required plan review, a deploy that prints what it will destroy and demands typed confirmation, alerts wired to the rollout | The mistake is visible at the moment of decision |
+| **3 Detection** | Post-deploy smoke tests, monitoring, an on-call human | The mistake is found after users find it |
+| **0** | A runbook step that says "double-check the environment first" | Nothing |
+
+## Reversibility is the highest-leverage property
+
+Before adding any gate, ask whether the operation can be made reversible instead: a
+reversible operation needs far weaker devices, because the cost of the mistake collapses.
+
+- **Soft delete and retention windows** on anything user-facing. S3 versioning plus MFA delete,
+ database point-in-time recovery, trash with a 30-day window.
+- **Deletion protection flags** on databases, buckets, clusters, and load balancers. These
+ cost nothing and stop the single most expensive class of cloud mistake.
+- **Backups that have actually been restored.** An untested backup is a belief, not a device, and this is the most common false sense of protection in the industry. Restore drills on a
+ schedule, timed, into a real environment. If nobody has restored it, treat the data as
+ unbacked when you assess blast radius.
+- **Immutable artifacts** so rolling back means redeploying a known-good image, not rebuilding
+ and hoping the build is reproducible.
+
+## Schema migrations: expand and contract
+
+Co-deploying a destructive schema change with the code that depends on it is an outage, not a
+risk, during the rollout window old code necessarily runs against the new schema.
+
+The pattern, one deploy per step:
+
+1. **Expand**: add the new column/table, nullable, with no code depending on it.
+2. **Backfill**: in batches, resumable, throttled, with progress recorded so a failure resumes
+ rather than restarts.
+3. **Dual-write**: new code writes both old and new; both remain readable.
+4. **Switch reads**: behind a flag, so switching back is instant.
+5. **Contract**: drop the old column, in a later deploy, once nothing references it.
+
+Steps 1–4 are reversible: the old column stays readable throughout, so rolling the deploy
+back is enough. Only step 5 is not, which is exactly why it gets its own deploy and its own
+gate. The device that makes this stick is a CI check that refuses any `DROP`, `TRUNCATE`, or
+`ALTER ... DROP` in a changed migration unless the PR carries an explicit approval label, see
+`guardrails` for the gate itself.
+
+Migration-specific hazards worth checking every time: a lock taken on a large table during
+peak traffic; a backfill with no batch limit; an index created without `CONCURRENTLY`; a
+`NOT NULL` added without a default on a populated table; a rename, which is a drop and an add
+wearing a disguise.
+
+## Feature flags and kill switches
+
+A kill switch is a poka-yoke for a change you cannot fully test in advance. It converts "roll
+back a deploy" (minutes, and impossible if the migration already ran) into "flip a boolean"
+(seconds). Ship risky changes dark, behind a flag, then enable progressively.
+
+Two things make flags devices rather than debt:
+
+- **The off path must be tested**, not just the on path. A kill switch whose disabled branch
+ was never exercised is a second untested code path shipped at your worst moment.
+- **Flags need an expiry.** A permanent flag is a permanent untested branch and a permanent
+ source of "works for some users only" bugs. Track age and remove them; stale flags are the
+ standard way this device turns into a hazard.
+
+## Infrastructure as code
+
+- **`prevent_destroy` on every stateful resource**: databases, buckets, volumes, DNS zones.
+ One line, and it turns the worst cloud accident into a failed plan. It is Control against the
+ accident, not against intent: removing the block is another one-line change, so review has to
+ read a diff that deletes a `prevent_destroy` as itself a destructive change.
+- **Plan review as a required check**, with the plan output posted to the PR. A human approving
+ a diff they cannot see is rung zero.
+- **Fail the plan on unexpected destruction**: a check that counts destroy actions and blocks
+ the apply unless the change is explicitly labeled as intentionally destructive. Terraform
+ will happily replace a database to change one immutable attribute, and the plan says so in
+ a line people skim past.
+- **Separate state and credentials per environment**, so a misconfigured shell cannot point a
+ staging apply at production. Environment confusion is a mistake of *context*, and the device
+ is making the contexts physically incapable of touching each other.
+- **No console access for routine work.** Manual changes drift from code and are invisible to
+ review; drift detection turns that into a Warning at minimum.
+
+## Production access
+
+The strongest device is not needing access: good observability, safe read-only debugging
+tools, and self-service runbooks remove most reasons a human ever holds a prod shell.
+
+Where access is genuinely needed: time-boxed and audited, read-only by default, write access
+requiring a second approver, and a shell prompt that makes the environment impossible to
+mistake. Environment confusion, running the staging command against prod, is a top-tier
+ops mistake and it is fixed by making prod look and feel different, not by remembering.
+
+Wrap dangerous scripts so the safe form is the easy one: dry-run by default with `--apply` to
+commit, print the affected count before acting, refuse to run against prod without an explicit
+flag, and refuse an empty or wildcard target.
+
+## Auditing an ops setup
+
+Work through these, and report using the finding structure from `audit`:
+
+1. **What is irreversible today?** Every resource whose loss is unrecoverable. Which have
+ deletion protection? When was the backup last *restored*, not last taken?
+2. **What is the blast radius of a bad deploy?** All users at once, or 1%? Is rollback
+ automatic on an error-rate signal, or does it need a human who is asleep?
+3. **Can a migration and its dependent code land together?** Is anything stopping it?
+4. **Can a staging command reach production?** Shared credentials, shared state, an ambiguous
+ prompt, a `--env` flag defaulting to prod.
+5. **What has no kill switch?** Anything risky that can only be withdrawn by a full deploy.
+6. **Which flags are older than 90 days?**
+
+Propose devices before applying them, and never apply infrastructure changes without explicit
+approval: an `apply` is exactly the class of outward-facing, hard-to-reverse action that
+belongs to the user, not to you. For anything you cannot run yourself (console settings,
+branch protection, IAM), hand over the exact steps or CLI command.
diff --git a/skills/poka-yoke-retro/SKILL.md b/skills/poka-yoke-retro/SKILL.md
new file mode 100644
index 000000000..7b5813761
--- /dev/null
+++ b/skills/poka-yoke-retro/SKILL.md
@@ -0,0 +1,144 @@
+---
+name: poka-yoke-retro
+description: >-
+ Turn a bug, outage or repeated mistake into a device that makes the whole class impossible. Use when something already broke: "make sure this never happens again", "this is the third time", "postmortem", "how did this get through". Root-causes to the missing constraint, then sweeps every other site where the mistake is still available. For a pipeline use data, a deploy use ops, cross-tenant use authz, an AI feature use llm.
+license: MIT
+---
+
+# Poka-Yoke Retro
+
+A defect got out. The fix for the defect is the easy part and is usually already done or
+obvious. This mode is about the harder and more valuable question: **what made the mistake
+available, and what device removes it for good?**
+
+Shingo's framing is the whole method here. Do not ask why the person erred, people err, that
+is a constant. Ask why the *process permitted* the error to become a defect, and what would
+have physically stopped it.
+
+## 1. Separate the three things
+
+People conflate these, and conflating them is why incidents repeat.
+
+- **The defect**: what the user or system experienced. "Customers were charged twice."
+- **The mistake**: the specific human action that produced it. "The retry path called
+ `charge()` again without an idempotency key."
+- **The hazard**: the property of the system that made that mistake possible and silent.
+ "`charge()` accepts an optional idempotency key and succeeds without one."
+
+Fixing the defect ships today. Fixing the mistake helps one code path. **Only fixing the
+hazard prevents recurrence**, and the hazard is almost always a missing constraint, not a
+missing piece of knowledge.
+
+Write all three out explicitly before proposing anything. If you cannot state the hazard as a
+property of the system, you have not found it yet.
+
+## 2. Ask why until you reach a constraint
+
+Run the whys, with one discipline: **an acceptable terminal answer is a missing constraint,
+never a missing human quality.** If a chain ends in "they forgot," "they didn't know," "they
+were rushing," or "it wasn't documented," you stopped one step early, keep going and ask why
+forgetting was possible, why the knowledge was needed at all, why the system accepted the
+result.
+
+> Double charge → retry called `charge()` twice → the retry path didn't pass an idempotency
+> key → **the key is an optional parameter** → *why is it optional?* → it was added later and
+> made optional to avoid breaking callers → **there is no compile-time or database-level
+> requirement that a charge be idempotent.**
+
+That last line is the hazard, and it is fixable: make the parameter required, or add a unique
+constraint on `(account_id, idempotency_key)`. Compare it to "the engineer should have passed
+the key," which is fixable only by hiring different humans.
+
+Also ask the escape question separately: **what should have caught this and didn't?** Usually
+there was a device: a test, a review, a type, and it was absent, disabled, or too weak.
+That gap is a second finding in its own right.
+
+## 3. Sweep for the class. This is the step that gets skipped
+
+A poka-yoke that fixes one call site is not a poka-yoke. Before proposing anything, find
+**every other place the same mistake is still available.** This is almost always where the
+real value of a retro sits, and it is the step people omit under time pressure.
+
+Search by the shape of the hazard, not by the text of the bug:
+
+- Every other caller of the same function or endpoint.
+- Every other function with the same dangerous signature shape, other optional-when-it-should-
+ be-required parameters, other same-type adjacent arguments, other unguarded bulk operations.
+- The same pattern in sibling services, other languages in the monorepo, scripts, jobs, and
+ infrastructure code.
+- Run `python3 scripts/detect_hazards.py --paths --id `: the ID is the
+ one printed with each finding, to catch instances you would not have thought to grep for.
+
+Report the count plainly: *"the same hazard exists at 6 other call sites"* changes the
+conversation about how much the fix is worth.
+
+## 4. Choose the device by rung
+
+Now propose, using the ladder from the router skill. For an incident that already cost
+something real, push hard for **Control**: you have the strongest evidence you will ever
+have that this mistake happens.
+
+| Rung | For this incident, that would mean |
+|---|---|
+| **Control** | Required parameter · database unique constraint · type that cannot represent the bad state · CI check that cannot be merged past |
+| **Warning** | Lint rule · runtime assertion · alert at the moment of the action |
+| **Detection** | Regression test · monitor · reconciliation job |
+| **None** | "Added a note to the runbook" · "reminded the team" · "added a review checklist item" |
+
+A regression test is genuinely valuable and you should write one. It proves the fix and stops
+this exact path regressing. But be honest that it is rung 3: it catches the mistake after
+someone makes it, and only on the path you thought of. If the retro produces *only* a test,
+say so, and say what a Control-rung device would have required.
+
+Beware the fix that is really rung zero wearing a costume: more documentation, a new checklist
+item, a Slack reminder, a training session, an extra required reviewer. These feel like
+action and change nothing. If that is genuinely all that is possible, name it as an accepted
+risk rather than a resolution.
+
+## 5. Write it up
+
+```markdown
+# Retro · ·
+
+**Defect**:
+**Mistake**:
+**Hazard**:
+
+## Why it was possible
+
+
+## Why nothing caught it
+
+
+## Class sweep
+
+
+## Devices
+| Device | Rung | Covers | Status |
+|---|---|---|---|
+| | Control | all N sites | proposed |
+| | Detection | the original path | done |
+
+## Accepted risk
+
+```
+
+Save to `docs/poka-yoke/retro-YYYY-MM-DD-.md`, and put a `poka-yoke:` marker comment at each
+installed device naming the mistake it prevents. That is what stops a future engineer
+removing it as dead weight, since by then it will never have fired. See the recording section
+in `audit`; do not ask anyone to hand-maintain a registry file.
+
+## 6. Verify the device before you close it
+
+Prove the fix. Reproduce the original mistake against the new device and show it being
+refused, then show the correct path still working. A device that was never observed to fire is
+a belief, not a control, and after an incident, a false sense of protection is the most
+expensive thing you can ship.
+
+## Tone
+
+Write about the system, never the person. Not because it is polite, but because it is more
+accurate and it is the only version that produces a fix: "the engineer should have been more
+careful" has no implementation. Shingo's argument was that blaming the operator is
+precisely how organizations avoid improving the process. Names belong in the timeline if at
+all; the analysis is about affordances.
diff --git a/skills/poka-yoke-retro/scripts/detect_hazards.py b/skills/poka-yoke-retro/scripts/detect_hazards.py
new file mode 100755
index 000000000..2546fdc6a
--- /dev/null
+++ b/skills/poka-yoke-retro/scripts/detect_hazards.py
@@ -0,0 +1,621 @@
+#!/usr/bin/env python3
+"""Heuristic detector for poka-yoke hazards, shapes in code that make mistakes easy.
+
+This is a fast first pass, not an oracle. It finds textually-detectable hazards so a
+reviewer can spend their attention on the interface-level questions a regex cannot ask.
+Expect real false positives; every hit is a question, not a verdict.
+
+Hazard IDs match references/hazard-catalog.md. Standard library only.
+
+Examples:
+ detect_hazards.py --diff # uncommitted changes, changed lines only
+ detect_hazards.py --staged # staged changes
+ detect_hazards.py --since HEAD~10 # last 10 commits
+ detect_hazards.py --paths src/ lib/ # explicit paths
+ detect_hazards.py --diff --severity high # only the ones that bite hardest
+ detect_hazards.py --paths . --json # machine-readable
+"""
+
+from __future__ import annotations
+
+import argparse
+import ast
+import json
+import os
+import re
+import subprocess
+import sys
+from dataclasses import dataclass, field
+from pathlib import Path
+
+# --------------------------------------------------------------------------------------
+# Rule definitions
+# --------------------------------------------------------------------------------------
+
+PY = {".py", ".pyi"}
+TS = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"}
+GO = {".go"}
+RS = {".rs"}
+SQL = {".sql"}
+ALL_EXTS = PY | TS | GO | RS | SQL
+
+LENS = {"C": "contact", "F": "fixed-value", "M": "motion-step", "X": "removed-device"}
+
+
+@dataclass(frozen=True)
+class Rule:
+ id: str
+ name: str
+ severity: str # high | medium | low
+ exts: frozenset
+ pattern: re.Pattern
+ device: str
+ negate: re.Pattern | None = None # if this also matches the line, skip
+
+
+def R(id, name, severity, exts, pattern, device, negate=None, flags=0):
+ return Rule(
+ id=id,
+ name=name,
+ severity=severity,
+ exts=frozenset(exts),
+ pattern=re.compile(pattern, flags),
+ device=device,
+ negate=re.compile(negate, flags) if negate else None,
+ )
+
+
+RULES: list[Rule] = [
+ # ---- X: devices that were removed -------------------------------------------------
+ R("X1", "Swallowed error", "high", TS,
+ r"catch\s*(\([^)]*\))?\s*\{\s*\}",
+ "Handle it or let it propagate; catching to do nothing turns a loud failure quiet."),
+ R("X1", "Swallowed error", "high", TS,
+ r"\.catch\s*\(\s*\(\s*\)\s*=>\s*(\{\s*\}|null|undefined)\s*\)",
+ "Handle the rejection or let it propagate."),
+ R("X1", "Bare except", "high", PY,
+ r"^\s*except\s*:",
+ "Catch the specific exception; bare except also swallows KeyboardInterrupt/SystemExit."),
+ R("X1", "Discarded error return", "high", GO,
+ r",\s*_\s*:?=\s*\w|^\s*_\s*=\s*\w[\w.]*\(",
+ "Check the error. Enable errcheck in golangci-lint to make this a build failure."),
+ R("X2", "Unwrap / expect on a fallible value", "medium", RS,
+ r"\.(unwrap|expect)\s*\(",
+ "Propagate with ? or handle the error; deny clippy::unwrap_used."),
+ R("X2", "Silent default on error", "medium", RS,
+ r"\.unwrap_or_default\s*\(\s*\)",
+ "A default on an error path hides the failure; branch on the error explicitly."),
+ R("X2", "parseInt without radix", "medium", TS,
+ r"parseInt\s*\(\s*[^,)]+\)",
+ "Pass the radix and check for NaN, or use a schema parse at the boundary."),
+ R("X3", "Focused test disables the suite", "high", TS,
+ r"\b(it|test|describe|context)\.only\s*\(|\bfdescribe\s*\(|\bfit\s*\(",
+ "Remove before merge; fail CI on focused tests."),
+ R("X3", "Skipped test", "medium", PY,
+ r"@pytest\.mark\.skip|@unittest\.skip",
+ "A skipped test is a detection device switched off. Fix or delete it."),
+ R("X3", "Skipped test", "medium", TS,
+ r"\b(it|test|describe)\.skip\s*\(|\bxit\s*\(|\bxdescribe\s*\(",
+ "A skipped test is a detection device switched off. Fix or delete it."),
+ R("X3", "Skipped test", "medium", GO, r"\bt\.Skip\s*\(",
+ "A skipped test is a detection device switched off. Fix or delete it."),
+ R("X3", "Skipped test", "medium", RS, r"^\s*#\[ignore\]",
+ "A skipped test is a detection device switched off. Fix or delete it."),
+ R("X4", "Type-checker suppression", "medium", TS,
+ r"@ts-ignore|@ts-nocheck|\bas\s+unknown\s+as\b|eslint-disable(?!-next-line\s+\S+\s+--)",
+ "Each suppression is a hole in the guarantee. Require a reason and an issue link."),
+ R("X4", "Explicit any", "medium", TS,
+ r":\s*any\b||Array|as\s+any\b",
+ "any disables the type system exactly where guarantees matter. Parse at the boundary."),
+ R("X4", "Type-checker suppression", "medium", PY,
+ r"#\s*type:\s*ignore(?!\[)",
+ "Narrow it to a specific error code and add a reason."),
+ R("X4", "Untyped container", "low", GO,
+ r"\binterface\{\}|\bany\b\s*[,)\]]",
+ "Prefer a concrete type or a constrained generic."),
+ R("X4", "unsafe block", "medium", RS, r"\bunsafe\s*\{",
+ "Require a // SAFETY: comment stating the invariant being upheld."),
+ R("X5", "Mutable default argument", "high", PY,
+ r"def\s+\w+\s*\([^)]*=\s*(\[\s*\]|\{\s*\}|set\s*\(\s*\))",
+ "Use None and construct inside the function; the default is shared across all calls."),
+
+ # ---- F: fixed-value ---------------------------------------------------------------
+ R("F2", "Unbounded DELETE", "high", SQL | PY | TS | GO | RS,
+ r"\bDELETE\s+FROM\b(?!.*\bWHERE\b)",
+ "Require a WHERE clause; refuse an empty predicate.", flags=re.I),
+ R("F2", "Unbounded UPDATE", "high", SQL | PY | TS | GO | RS,
+ r"\bUPDATE\s+[\w.\"`\[\]]+\s+SET\b(?!.*\bWHERE\b)",
+ "Require a WHERE clause; refuse an empty predicate.", flags=re.I),
+ R("F2", "Destructive DDL", "high", SQL | PY | TS | GO | RS,
+ r"\b(DROP\s+(TABLE|COLUMN|DATABASE|SCHEMA)|TRUNCATE\s+TABLE)\b",
+ "Use expand/contract; gate destructive DDL behind an explicit CI acknowledgment.",
+ flags=re.I),
+ R("F2", "Bulk delete", "high", TS | PY,
+ r"\.(deleteMany|delete_many|destroy_all|delete_all|drop_all|removeMany)\s*\(\s*\)",
+ "Refuse an empty filter; cap the affected count and require confirmation above it."),
+ R("F2", "Recursive force remove", "high", ALL_EXTS,
+ r"rm\s+-[a-zA-Z]*[rR][a-zA-Z]*f|rm\s+-[a-zA-Z]*f[a-zA-Z]*[rR]",
+ "Validate the path is non-empty and inside the expected root before deleting."),
+ R("F4", "Config read away from startup", "medium", PY,
+ r"os\.(getenv|environ)",
+ "Parse the whole config into a typed object at startup so a missing key fails the deploy.",
+ negate=r"(settings|config|conf|env)\.py"),
+ R("F4", "Config read away from startup", "medium", TS,
+ r"process\.env\.\w+",
+ "Parse the whole config into a typed object at startup so a missing key fails the deploy.",
+ negate=r"(config|env|settings)\.(ts|js)"),
+ R("F7", "Unbounded read", "low", PY | TS,
+ r"\.read\s*\(\s*\)|\.readAll\s*\(|ioutil\.ReadAll",
+ "Cap the size at the boundary; an unbounded read is a resource-exhaustion incident."),
+
+ # ---- C: contact -------------------------------------------------------------------
+ R("C2", "Boolean flag parameter", "medium", TS,
+ r"\b\w+\s*:\s*boolean\s*[,)]",
+ "Use an enum, a named options object, or two functions; booleans are unreadable at the call site."),
+ R("C2", "Boolean flag parameter", "medium", GO,
+ r"func\s+\w+\s*\([^)]*\bbool\b[^)]*\)",
+ "Use a named option type; a bare bool is unreadable at the call site."),
+ R("C2", "Boolean default parameter", "medium", PY,
+ r"def\s+\w+\s*\([^)]*\b\w+\s*(:\s*bool\s*)?=\s*(True|False)",
+ "Use an enum, or at minimum make it keyword-only so the name appears at the call site."),
+ R("C5", "Duration without a unit", "medium", TS | GO | PY,
+ r"\b(timeout|delay|interval|ttl|expiry|duration|retryAfter|retry_after)\s*:?\s*(number|int|float|=\s*\d+)",
+ "Encode the unit in the type (Duration) or in the name (timeoutMs). Unit mismatches are silent."),
+ R("C6", "Money as a float", "high", PY | TS | GO | RS,
+ r"\b(price|amount|total|balance|cost|fee|subtotal|revenue)\w*\s*:\s*(float|number|f32|f64)\b"
+ r"|\bfloat\s*\(\s*\w*(price|amount|total|balance)",
+ "Use integer minor units in a Money type carrying its currency, or a decimal type."),
+ R("C7", "Unvalidated parse", "high", TS,
+ r"JSON\.parse\s*\(",
+ "Parse into a schema (zod/valibot) at the boundary; JSON.parse returns any."),
+ R("C7", "Unvalidated request body", "high", PY,
+ r"(request|req)\.(json|get_json)\s*\(\s*\)(?!\s*\))",
+ "Parse into a Pydantic model with extra='forbid' so unknown or missing fields fail loudly."),
+ R("C9", "Naive datetime", "medium", PY,
+ r"datetime\.utcnow\s*\(\s*\)|datetime\.now\s*\(\s*\)",
+ "Use datetime.now(timezone.utc), and inject a clock so time is testable."),
+
+ # ---- M: motion-step ---------------------------------------------------------------
+ R("M4", "Unmanaged resource", "medium", PY,
+ r"^\s*(\w+\s*=\s*)?open\s*\(",
+ "Use a context manager; the error path will leak otherwise.",
+ negate=r"\bwith\b"),
+ R("M6", "Dangling async task", "high", PY,
+ r"^\s*(await\s+)?asyncio\.create_task\s*\(",
+ "Keep a reference; an unreferenced task can be garbage collected mid-flight (ruff RUF006).",
+ negate=r"=\s*(await\s+)?asyncio\.create_task"),
+ R("M6", "Unawaited promise-returning call", "low", TS,
+ r"^\s*\w+\.(save|update|create|delete|insert|write|send|publish|commit)\s*\(",
+ "If this returns a promise, await it: a floating write is silently lost. "
+ "Enable @typescript-eslint/no-floating-promises.",
+ negate=r"\b(await|return|yield|void)\b|\.then\(|=\s"),
+ R("M2", "Retryable effect without an idempotency key", "high", ALL_EXTS,
+ r"\b(def|func|function|fn|async\s+function)\s+\w*(charge|refund|capture|payout|transfer|"
+ r"sendEmail|send_email|publish|notify)\w*\s*[(<]",
+ "Require an idempotency key parameter, backed by a unique constraint on (entity, key).",
+ negate=r"idempot", flags=re.I),
+ R("M1", "Two-phase construction", "medium", ALL_EXTS,
+ r"\b(def|func|function|fn)\s+(init|initialize|connect|setup|configure|start)\s*[(<]",
+ "Have the constructor or a factory return a ready object, or use typestate; "
+ "'call this first' is not enforceable.",
+ negate=r"__init__|func\s+init\s*\(\s*\)\s*\{"),
+
+ # ---- F1: exhaustiveness -----------------------------------------------------------
+ R("F1", "Wildcard match arm", "medium", RS,
+ r"^\s*_\s*=>",
+ "In domain logic a wildcard turns a future compile error into a silent fallthrough."),
+ R("F1", "Switch without exhaustiveness check", "low", TS,
+ r"^\s*switch\s*\(",
+ "Add a default arm calling assertNever(x: never) so a new variant breaks the build."),
+ R("F1", "Switch without a default", "low", GO,
+ r"^\s*switch\s+\w+\s*\{",
+ "Enable the 'exhaustive' linter with default-signifies-exhaustive: false."),
+]
+
+# Rules a real linter already does better. They stay available behind --all for repos that
+# do not run those linters, but they are off by default: a tool that does eight things
+# nothing else does is more useful than one doing forty things worse. The value here is the
+# pointer, knowing which linter to enable beats a second-rate reimplementation of it.
+COVERED_BY: dict[tuple[str, str], str] = {
+ ("X1", "Swallowed error"): "eslint no-empty",
+ ("X1", "Bare except"): "ruff E722",
+ ("X1", "Discarded error return"): "golangci-lint errcheck",
+ ("X2", "Unwrap / expect on a fallible value"): "clippy::unwrap_used",
+ ("X2", "Silent default on error"): "clippy",
+ ("X2", "parseInt without radix"): "eslint radix",
+ ("X3", "Focused test disables the suite"): "eslint jest/no-focused-tests",
+ ("X3", "Skipped test"): "eslint jest/no-disabled-tests",
+ ("X4", "Type-checker suppression"): "@typescript-eslint/ban-ts-comment, mypy --strict",
+ ("X4", "Explicit any"): "@typescript-eslint/no-explicit-any",
+ ("X4", "Untyped container"): "golangci-lint",
+ ("X4", "unsafe block"): "clippy",
+ ("X5", "Mutable default argument"): "ruff B006",
+ ("C9", "Naive datetime"): "ruff DTZ",
+ ("M4", "Unmanaged resource"): "ruff SIM115",
+ ("M6", "Dangling async task"): "ruff RUF006",
+ ("F1", "Wildcard match arm"): "clippy::wildcard_enum_match_arm",
+ ("F7", "Unbounded read"): "",
+ ("F3", "assert used for validation"): "ruff S101",
+ ("C6", "Equality comparison on a float"): "ruff PLR0133",
+}
+
+
+# poka-yoke: keyword-only, so the id and the name cannot be passed transposed [control]
+def covered(*, rule_id: str, name: str) -> str:
+ return COVERED_BY.get((rule_id, name), "")
+
+
+# --------------------------------------------------------------------------------------
+# AST pass (Python only), catches what regexes can't see
+# --------------------------------------------------------------------------------------
+
+
+def python_ast_findings(path: Path, source: str) -> list[dict]:
+ """Structural checks that need real parsing: adjacent same-type params, assert-as-
+ validation, and equality comparison on floats."""
+ out = []
+ try:
+ tree = ast.parse(source, filename=str(path))
+ except SyntaxError:
+ return out
+
+ for node in ast.walk(tree):
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
+ args = node.args.posonlyargs + node.args.args
+ # skip self/cls
+ if args and args[0].arg in ("self", "cls"):
+ args = args[1:]
+ annotated = [(a.arg, ast.unparse(a.annotation)) for a in args if a.annotation]
+ for i in range(len(annotated) - 1):
+ (n1, t1), (n2, t2) = annotated[i], annotated[i + 1]
+ if t1 == t2 and t1 in ("str", "int", "float", "bytes", "bool", "UUID"):
+ out.append({
+ "id": "C1",
+ "name": "Adjacent same-type parameters",
+ "severity": "high",
+ "line": node.lineno,
+ "snippet": f"def {node.name}(..., {n1}: {t1}, {n2}: {t2}, ...)",
+ "device": f"'{n1}' and '{n2}' are both {t1} and can be swapped silently. "
+ "Use NewType per concept, or make them keyword-only.",
+ })
+ # positional args on a wide signature
+ if len(args) >= 4 and not node.args.kwonlyargs:
+ out.append({
+ "id": "C1",
+ "name": "Wide positional signature",
+ "severity": "low",
+ "line": node.lineno,
+ "snippet": f"def {node.name}({len(args)} positional params)",
+ "device": "Make parameters keyword-only with '*' so names appear at the call site.",
+ })
+
+ elif isinstance(node, ast.Assert):
+ out.append({
+ "id": "F3",
+ "name": "assert used for validation",
+ "severity": "medium",
+ "line": node.lineno,
+ "snippet": ast.unparse(node)[:100],
+ "device": "assert is stripped under python -O. Raise an explicit exception instead.",
+ })
+
+ elif isinstance(node, ast.Compare):
+ for op in node.ops:
+ if isinstance(op, (ast.Eq, ast.NotEq)):
+ src = ast.unparse(node)
+ if re.search(r"\d+\.\d+", src):
+ out.append({
+ "id": "C6",
+ "name": "Equality comparison on a float",
+ "severity": "medium",
+ "line": node.lineno,
+ "snippet": src[:100],
+ "device": "Use math.isclose, or a Decimal/integer-minor-unit type.",
+ })
+ return out
+
+
+# --------------------------------------------------------------------------------------
+# File and diff collection
+# --------------------------------------------------------------------------------------
+
+SKIP_DIRS = {
+ ".git", "node_modules", "vendor", "dist", "build", "target", "__pycache__",
+ ".venv", "venv", ".tox", ".mypy_cache", ".pytest_cache", ".next", "coverage",
+ ".terraform", "site-packages",
+}
+
+
+class GitUnavailable(RuntimeError):
+ """git could not answer the question asked of it.
+
+ Previously any git failure became an empty string, which the caller could not tell from
+ "the tree is clean". A detector that reports a clean bill of health because git is broken
+ is the exact failure this file's own rules exist to catch.
+ """
+
+
+def git(*args: str, cwd: Path) -> str:
+ try:
+ r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, timeout=30)
+ except (subprocess.SubprocessError, FileNotFoundError) as exc:
+ raise GitUnavailable(f"could not run git {' '.join(args)}: {exc}") from exc
+ if r.returncode != 0:
+ detail = (r.stderr or r.stdout or "").strip().splitlines()
+ raise GitUnavailable(f"git {' '.join(args)} exited {r.returncode}"
+ + (f": {detail[0]}" if detail else ""))
+ return r.stdout
+
+
+def changed_files_and_lines(cwd: Path, mode: str, since: str | None):
+ """Return {path: set(changed_line_numbers)}. Empty set means 'whole file'."""
+ if mode == "staged":
+ diff_args = ["diff", "--cached", "-U0"]
+ elif mode == "since":
+ diff_args = ["diff", f"{since}..HEAD", "-U0"]
+ else:
+ diff_args = ["diff", "HEAD", "-U0"]
+
+ raw = git(*diff_args, cwd=cwd)
+ if not raw.strip() and mode == "diff":
+ # Clean tree, fall back to recent commits, which is what the user usually means.
+ raw = git("diff", "HEAD~5..HEAD", "-U0", cwd=cwd)
+
+ result: dict[str, set[int]] = {}
+ current = None
+ for line in raw.splitlines():
+ if line.startswith("+++ b/"):
+ current = line[6:]
+ result.setdefault(current, set())
+ elif line.startswith("@@") and current:
+ m = re.search(r"\+(\d+)(?:,(\d+))?", line)
+ if m:
+ start = int(m.group(1))
+ count = int(m.group(2) or 1)
+ result[current].update(range(start, start + count))
+ return {k: v for k, v in result.items() if v}
+
+
+def collect_paths(roots: list[str]) -> list[Path]:
+ out = []
+ for root in roots:
+ p = Path(root)
+ if p.is_file():
+ if p.suffix in ALL_EXTS:
+ out.append(p)
+ elif p.is_dir():
+ for dirpath, dirnames, filenames in os.walk(p):
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")]
+ for fn in filenames:
+ fp = Path(dirpath) / fn
+ if fp.suffix in ALL_EXTS:
+ out.append(fp)
+ return out
+
+
+# --------------------------------------------------------------------------------------
+# Scanning
+# --------------------------------------------------------------------------------------
+
+COMMENT_ONLY = re.compile(r"^\s*(//|#|/\*|\*|--)")
+
+
+def scan_file(path: Path, only_lines: set[int] | None) -> list[dict]:
+ try:
+ source = path.read_text(encoding="utf-8", errors="replace")
+ except (OSError, UnicodeDecodeError):
+ return []
+ if len(source) > 2_000_000:
+ return []
+
+ findings = []
+ ext = path.suffix
+ lines = source.splitlines()
+
+ for lineno, line in enumerate(lines, 1):
+ if only_lines and lineno not in only_lines:
+ continue
+ if COMMENT_ONLY.match(line) or len(line) > 500:
+ continue
+ for rule in RULES:
+ if ext not in rule.exts:
+ continue
+ if not INCLUDE_COVERED and covered(rule_id=rule.id, name=rule.name):
+ continue
+ if rule.negate and (rule.negate.search(line) or rule.negate.search(str(path))):
+ continue
+ if rule.pattern.search(line):
+ findings.append({
+ "id": rule.id,
+ "name": rule.name,
+ "severity": rule.severity,
+ "line": lineno,
+ "snippet": line.strip()[:120],
+ "device": rule.device,
+ })
+
+ if ext in PY:
+ for f in python_ast_findings(path, source):
+ if not INCLUDE_COVERED and covered(rule_id=f["id"], name=f["name"]):
+ continue
+ if not only_lines or f["line"] in only_lines:
+ findings.append(f)
+
+ for f in findings:
+ f["file"] = str(path)
+ f["lens"] = LENS.get(f["id"][0], "unknown")
+ return findings
+
+
+# --------------------------------------------------------------------------------------
+# Output
+# --------------------------------------------------------------------------------------
+
+INCLUDE_COVERED = False
+
+SEV_ORDER = {"high": 0, "medium": 1, "low": 2}
+COLOR = {"high": "\033[31m", "medium": "\033[33m", "low": "\033[90m"}
+RESET = "\033[0m"
+
+
+def render(findings: list[dict], scope: str, use_color: bool) -> str:
+ if not findings:
+ return f"No hazards detected in {scope}.\n\nThe lenses still apply, run them by hand:\n" \
+ " contact: can the wrong thing fit?\n" \
+ " fixed-value: can an incomplete or wrong-sized set pass?\n" \
+ " motion-step: can the steps happen in the wrong order?"
+
+ findings.sort(key=lambda f: (SEV_ORDER[f["severity"]], f["file"], f["line"]))
+ counts = {"high": 0, "medium": 0, "low": 0}
+ for f in findings:
+ counts[f["severity"]] += 1
+
+ out = [
+ f"Poka-yoke hazard scan, {scope}",
+ f"{counts['high']} high · {counts['medium']} medium · {counts['low']} low",
+ "",
+ "Heuristics with real false positives. Read the surrounding code before acting.",
+ "",
+ ]
+
+ grouped: dict[str, list[dict]] = {}
+ for f in findings:
+ grouped.setdefault(f"{f['id']} {f['name']}", []).append(f)
+
+ for key, group in sorted(grouped.items(), key=lambda kv: SEV_ORDER[kv[1][0]["severity"]]):
+ sev = group[0]["severity"]
+ tag = f"{COLOR[sev]}{sev.upper():<6}{RESET}" if use_color else f"{sev.upper():<6}"
+ out.append(f"{tag} {key} ({group[0]['lens']} lens, {len(group)} site"
+ f"{'s' if len(group) > 1 else ''})")
+ out.append(f" device: {group[0]['device']}")
+ for f in group[:8]:
+ out.append(f" {f['file']}:{f['line']} {f['snippet']}")
+ if len(group) > 8:
+ out.append(f" … and {len(group) - 8} more")
+ out.append("")
+
+ return "\n".join(out)
+
+
+def main() -> int:
+ ap = argparse.ArgumentParser(
+ description="Detect poka-yoke hazards, shapes in code that make mistakes easy.",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog=__doc__.split("Examples:")[-1],
+ )
+ src = ap.add_mutually_exclusive_group()
+ src.add_argument("--diff", action="store_true",
+ help="scan uncommitted changes (falls back to HEAD~5..HEAD if clean)")
+ src.add_argument("--staged", action="store_true", help="scan staged changes")
+ src.add_argument("--since", metavar="REF", help="scan changes since REF (e.g. HEAD~10)")
+ src.add_argument("--paths", nargs="+", metavar="PATH", help="scan these files or directories")
+ ap.add_argument("--severity", choices=["high", "medium", "low"], default="low",
+ help="minimum severity to report (default: low)")
+ # Until this existed the script ended in a bare `return 0`, so every gate built on it was
+ # decorative: the shipped pre-commit hook, the shipped CI template and this repo's own
+ # "Detector runs clean" step all reported success while printing high-severity findings.
+ # A linter that cannot fail is a linter nobody has to satisfy.
+ ap.add_argument("--fail-on", choices=["high", "medium", "low", "none"], default="low",
+ metavar="SEVERITY",
+ help="exit non-zero when a finding of at least this severity is reported "
+ "(default: low, i.e. any reported finding). Use 'none' to report "
+ "without gating.")
+ ap.add_argument("--id", nargs="+", metavar="ID",
+ help="only report these hazard IDs (e.g. --id C1 F2 M2)")
+ ap.add_argument("--all", action="store_true", dest="include_covered",
+ help="also run the rules a real linter does better (off by default)")
+ ap.add_argument("--json", action="store_true", help="emit JSON")
+ ap.add_argument("--repo", default=".", help="repository root (default: .)")
+ args = ap.parse_args()
+
+ global INCLUDE_COVERED
+ INCLUDE_COVERED = args.include_covered
+ repo = Path(args.repo).resolve()
+ findings: list[dict] = []
+
+ # poka-yoke: an empty scan reports itself instead of looking like a clean bill of health [control]
+ scanned = 0
+
+ if args.paths:
+ scope = f"paths: {', '.join(args.paths)}"
+ for p in collect_paths(args.paths):
+ scanned += 1
+ findings += scan_file(p, None)
+ if scanned == 0:
+ # Zero findings from zero files is not an all-clear, and it used to be
+ # indistinguishable from one. Exit non-zero: failing to do the job should
+ # not look like doing the job and finding nothing.
+ msg = ("Scanned 0 files. This is NOT an all-clear.\n"
+ f"Nothing under {', '.join(args.paths)} has a supported extension.\n"
+ f"Supported: {', '.join(sorted(ALL_EXTS))}")
+ print(json.dumps({"scope": scope, "files_scanned": 0, "count": 0,
+ "findings": [], "error": msg}, indent=2)
+ if args.json else msg, file=sys.stdout if args.json else sys.stderr)
+ return 2
+ else:
+ mode = "staged" if args.staged else ("since" if args.since else "diff")
+ scope = {"staged": "staged changes",
+ "since": f"changes since {args.since}",
+ "diff": "uncommitted changes"}[mode]
+ try:
+ changed = changed_files_and_lines(repo, mode, args.since)
+ except GitUnavailable as exc:
+ # Exit 2, the same code --paths uses for "scanned nothing". Reporting a clean
+ # tree because git is broken is worse than reporting nothing at all: a
+ # pre-commit hook or CI gate reads only the exit code.
+ msg = (f"Could not determine what changed: {exc}\n"
+ f"This is NOT an all-clear. Use --paths to scan explicitly.")
+ print(json.dumps({"scope": scope, "files_scanned": 0, "count": 0,
+ "findings": [], "error": str(exc)}, indent=2)
+ if args.json else msg, file=sys.stdout if args.json else sys.stderr)
+ return 2
+ if not changed:
+ msg = ("No changed files found. The tree may be clean and have no recent commits, "
+ "or this may not be a git repository.\nUse --paths to scan explicitly, "
+ "e.g. detect_hazards.py --paths src/")
+ print(json.dumps({"findings": [], "note": msg}) if args.json else msg)
+ return 0
+ for rel, lines in changed.items():
+ fp = repo / rel
+ if fp.suffix in ALL_EXTS and fp.exists():
+ scanned += 1
+ findings += scan_file(fp, lines)
+
+ threshold = SEV_ORDER[args.severity]
+ findings = [f for f in findings if SEV_ORDER[f["severity"]] <= threshold]
+ if args.id:
+ wanted = {i.upper() for i in args.id}
+ findings = [f for f in findings if f["id"] in wanted]
+
+ if args.json:
+ print(json.dumps({"scope": scope, "files_scanned": scanned,
+ "count": len(findings), "findings": findings}, indent=2))
+ else:
+ print(render(findings, scope, use_color=sys.stdout.isatty()))
+ print(f"\nScanned {scanned} file{'' if scanned == 1 else 's'}.")
+ if not INCLUDE_COVERED:
+ tools = sorted({v.split(",")[0].split()[0] for v in COVERED_BY.values() if v})
+ # len(COVERED_BY) counts ENTRIES, and one entry can suppress several
+ # per-language rules, so it under-reported by three. Count the rules.
+ n_suppressed = sum(1 for r in RULES if (r.id, r.name) in COVERED_BY)
+ print(f"\nNot checked here, {n_suppressed} further hazard rules are covered "
+ f"better by {', '.join(tools)}.\nEnable those rather than relying on this: "
+ f"see assets/devices/lint/. Use --all to run them anyway.")
+
+ if args.fail_on != "none":
+ rank = {"high": 3, "medium": 2, "low": 1}
+ threshold = rank[args.fail_on]
+ gating = [f for f in findings if rank.get(f.get("severity", "low"), 1) >= threshold]
+ if gating:
+ worst = max(rank.get(f.get("severity", "low"), 1) for f in gating)
+ name = {3: "high", 2: "medium", 1: "low"}[worst]
+ if not args.json:
+ print(f"\n{len(gating)} finding(s) at or above --fail-on={args.fail_on} "
+ f"(worst: {name}). Exiting 1.", file=sys.stderr)
+ return 1
+
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/skills/poka-yoke-ux/SKILL.md b/skills/poka-yoke-ux/SKILL.md
new file mode 100644
index 000000000..9bf625c5d
--- /dev/null
+++ b/skills/poka-yoke-ux/SKILL.md
@@ -0,0 +1,164 @@
+---
+name: poka-yoke-ux
+description: >-
+ Forms, destructive actions and flows users get wrong. Use when "users keep deleting the wrong thing", "add a confirmation dialog", "this flow is error-prone", or building a delete, bulk action, checkout or settings page. Covers undo over confirmation, type-to-confirm, safe defaults, input constraints, double-submit. For the server-side rules behind the screen use authz.
+license: MIT
+---
+
+# Poka-Yoke for Interfaces
+
+Shingo built jigs so an assembly worker could not seat a part backwards. A form is a jig. The
+same ladder applies, and the design literature arrived at the same place independently, Don
+Norman's *forcing functions* and Nielsen's *error prevention* heuristic describe the same move
+from a different tradition.
+
+The single reframing that does most of the work here: **an error message is a failure of the
+design, not a feature of it.** If your interface can tell the user they did something wrong,
+it usually could have stopped them doing it. Validation that fires after submission is rung 3.
+An input that cannot hold the wrong value is rung 1.
+
+## Building, not reviewing
+
+Most of the time this mode is reached *while someone is building the thing*, not afterwards.
+That changes the deliverable. They asked for the interface, so produce the interface, working, complete,
+in their stack. Do not hand back a severity table when the person is mid-feature; a list of
+findings about code they have not written yet is not useful to them.
+
+Then add a short closing note, three or four lines, covering:
+
+- which misuses the shape you chose makes impossible, and at which rung,
+- what you left possible on purpose, and why that tradeoff is the right one here.
+
+That closing note is what stops the device being undone in six months by someone who cannot
+see why it is there. It is also the difference between mistake-proofing and a code generator:
+the reasoning travels with the code.
+
+When the code already exists and they are asking what is wrong with it, switch to the audit
+voice, ranked findings with the mistake, the consequence, and the device. Match the mode to
+where they are in the work, not to this file's default.
+
+## The ladder, applied to interfaces
+
+| Rung | In a UI | Example |
+|---|---|---|
+| **1 Control** | The wrong action cannot be taken | Date picker that excludes unavailable dates · quantity capped at stock · Submit that does not exist until the form is valid · destructive action absent for users without permission |
+| **2 Warning** | Possible, but flagged at the moment it happens | Inline field validation on blur · a live character counter turning red · a banner warning that this will affect 4,312 users |
+| **3 Detection** | Caught after submission | Error summary at the top of the page · server rejects it · support ticket |
+| **0** | Relies on reading | Helper text · tooltips · a warning in a modal that everyone dismisses |
+
+## The rule that separates good UX poka-yoke from bad: undo beats confirm
+
+A confirmation dialog a user sees fifty times a day stops being a decision point. They develop
+click-through blindness and press "Confirm" with the same reflex they press "OK", which means
+the dialog protects nobody while adding friction to every legitimate action. It is the
+interface equivalent of a comment saying "be careful": present, visible, and inert.
+
+The preference order for destructive actions, strongest first:
+
+1. **Make it reversible.** Soft-delete, trash with a retention period, version history. Now the
+ mistake has no permanent consequence and needs no gate at all. This is the real answer and
+ it is under-used because it is a backend change, not a UI change.
+2. **Grace-period undo.** Perform it immediately, show "Deleted. Undo" for several seconds.
+ No friction on the happy path, full recovery on the mistaken one. Its close cousin is
+ delayed commit, hold the action for N seconds and drop it if undone, which is what Gmail's
+ undo-send does, and that is the easier build when the operation cannot be reversed once
+ performed.
+3. **Require an action proportional to the consequence.** Typing the resource's name to
+ confirm, GitHub's repository deletion, works because it cannot be done reflexively. Use
+ it only for genuinely irreversible, high-blast-radius actions; used everywhere it becomes
+ theater and people copy-paste through it.
+4. **A confirmation dialog that states the specific consequence.** "Delete 3 projects and 1,204
+ files permanently?" is a real check. "Are you sure?" is not. It asks about resolve, not
+ about facts, and the user's resolve is not the thing in question.
+
+A dialog that names the exact object and the exact count is doing fixed-value inspection. A
+dialog that says "This action cannot be undone" is doing nothing.
+
+## Designing an interface: enumerate the mistakes first
+
+Same ritual as API design, different failure modes. Before laying out a screen, ask:
+
+1. **What can the user enter that is wrong?** Can they even enter it? Free text where a
+ constrained choice exists is a hazard: every free-text field is a place to be wrong.
+2. **What is irreversible here?** Delete, send, publish, pay, cancel a subscription, rotate a
+ key. Each needs a device from the list above, sized to its blast radius.
+3. **What is adjacent to something dangerous?** "Save" beside "Delete" produces mis-clicks
+ forever. Separate destructive actions spatially, style them differently, and never make
+ them the default focus or the primary button.
+4. **What does the user have to remember or carry between steps?** Anything they must hold in
+ their head across a page transition will be dropped.
+5. **What happens if they double-click, refresh mid-submit, or hit back?** Double submission
+ is the UI's version of a non-idempotent retry, and it double-charges people.
+6. **What is the state of this control when the data is missing, huge, or slow?** Empty,
+ loading, error, and overflow states are where interfaces improvise.
+
+## The devices
+
+**Constrain the input rather than validate it.** A picker instead of a text field, a stepper
+instead of a number input, a mask that only accepts a valid shape, `inputmode` and `type` so
+mobile keyboards offer the right keys, `max`/`min` that the control actually enforces. Every
+value the field cannot hold is a validation rule you never have to write and a user who never
+sees an error.
+
+**Disable the action until it can succeed**, but always show *why*. A greyed-out Submit with
+no explanation is its own dead end; pair it with the specific unmet requirement. Pick between
+the two shapes deliberately: native `disabled`, which takes the button out of the tab order,
+so the reason has to live in adjacent text a screen reader will reach anyway; or
+`aria-disabled` with the handler refusing the submit, which keeps the button focusable so the
+reason is announced on the control itself.
+
+**Validate at the right moment.** On blur for the field just left, never on every keystroke
+while someone is still typing, validating a half-typed email as invalid trains people to
+ignore your validation. Re-validate on submit, and put focus on the first offending field.
+
+**Preserve the user's work.** Losing entered data to a validation error, a session timeout, or
+a back button is one of the most common and most infuriating mistakes an interface permits.
+Draft autosave, restore-on-return, and never clear a form on a failed submit.
+
+**Make defaults safe rather than convenient.** The preselected option should be the one whose
+consequences are smallest if chosen inattentively, least-privilege, narrowest scope, private
+rather than public, opt-in rather than opt-out. Many users never change a default, so a
+default is a decision you are making for most of your users.
+
+**Prevent double submission structurally.** Disable the control on submit *and* carry an
+idempotency key on the request, because the button is not the only path, refresh, back, and
+a flaky network all retry. The UI device and the API device are the same hazard (M2 in the
+hazard catalog) seen from two sides.
+
+**Show scale before a bulk action.** "This will email 12,400 people" is fixed-value inspection
+and it stops the mistake that a confirmation dialog does not.
+
+## Auditing an existing interface
+
+Read the actual component code, forms, buttons, modals, mutation handlers: not just
+screenshots. What to look for, in priority order:
+
+1. **Every irreversible action.** Find the delete, send, publish, pay, and cancel handlers.
+ For each: what device guards it, at what rung, and is the action recoverable at all? An
+ irreversible action with only a generic confirm is the highest-value finding you will make.
+2. **Every free-text input.** Could it be a constrained control instead? What happens with
+ empty, whitespace-only, very long, pasted-with-formatting, or unicode input?
+3. **Adjacency and defaults.** Is a destructive button next to a benign one, styled the same,
+ or the default focus? Is any default the risky option?
+4. **Submission paths.** Double-click, refresh mid-flight, back button, slow network. Is the
+ mutation idempotent?
+5. **Error handling.** When validation fails, is the user's input preserved, is focus moved to
+ the problem, and does the message say how to fix it rather than what is wrong?
+6. **Permissions.** Is a dangerous action merely hidden, or actually unavailable? Hiding a
+ button is not a device: the endpoint is still there. Check that the server enforces it.
+
+Report using the same structure as `audit`: mistake, consequence, current rung,
+proposed device and rung. Propose before editing.
+
+## Restraint
+
+Friction is a cost paid by every user on every legitimate use, and the mistake is made rarely.
+Confirmations on reversible actions, validation on optional fields, and are-you-sure dialogs
+on ordinary saves make an interface exhausting without preventing anything, and they train
+users to dismiss the dialogs that matter. Aim devices at what is irreversible and
+consequential; let everything else be fast, and make it undoable instead.
+
+The pattern reference at `references/ux-patterns.md` has the concrete
+forms of each device and the standard destructive-action patterns. The hazard catalog at
+`references/hazard-catalog.md` still applies to the code behind the
+screen: a mistake-proof form in front of a non-idempotent endpoint is only half a device.
diff --git a/skills/poka-yoke-ux/references/hazard-catalog.md b/skills/poka-yoke-ux/references/hazard-catalog.md
new file mode 100644
index 000000000..b7808e377
--- /dev/null
+++ b/skills/poka-yoke-ux/references/hazard-catalog.md
@@ -0,0 +1,416 @@
+# Hazard Catalog
+
+The recurring shapes that produce mistakes, organized by the lens that finds them. Each entry:
+what to look for, why it bites, and the device that closes it with the rung it reaches.
+
+Use this as working vocabulary, not a checklist to run top to bottom. The lens questions are
+the real tool; this catalog is what the lenses usually turn up.
+
+## Contents
+
+- [Contact lens, can the wrong thing fit?](#contact-lens-can-the-wrong-thing-fit)
+ - [C1. Adjacent same-type parameters](#c1-adjacent-same-type-parameters)
+ - [C2. Boolean flag parameters](#c2-boolean-flag-parameters)
+ - [C3. Primitive obsession at boundaries](#c3-primitive-obsession-at-boundaries)
+ - [C4. Stringly-typed enums](#c4-stringly-typed-enums)
+ - [C5. Implicit units and magnitudes](#c5-implicit-units-and-magnitudes)
+ - [C6. Money as a float](#c6-money-as-a-float)
+ - [C7. Unvalidated external input](#c7-unvalidated-external-input)
+ - [C8. Bag-of-optionals structs](#c8-bag-of-optionals-structs)
+ - [C9. Naive datetimes](#c9-naive-datetimes)
+- [Fixed-value lens, can an incomplete or wrong-sized set pass?](#fixed-value-lens-can-an-incomplete-or-wrong-sized-set-pass)
+ - [F1. Non-exhaustive branching](#f1-non-exhaustive-branching)
+ - [F2. Unbounded destructive operations](#f2-unbounded-destructive-operations)
+ - [F3. Defaults that hide a decision](#f3-defaults-that-hide-a-decision)
+ - [F4. Config discovered missing at runtime](#f4-config-discovered-missing-at-runtime)
+ - [F5. Partial writes without a transaction](#f5-partial-writes-without-a-transaction)
+ - [F6. Invariants enforced only in the application](#f6-invariants-enforced-only-in-the-application)
+ - [F7. Unbounded input](#f7-unbounded-input)
+- [Motion-step lens, can the order be wrong?](#motion-step-lens-can-the-order-be-wrong)
+ - [M1. Temporal coupling](#m1-temporal-coupling)
+ - [M2. Non-idempotent retryable effects](#m2-non-idempotent-retryable-effects)
+ - [M3. Illegal state transitions](#m3-illegal-state-transitions)
+ - [M4. Resources that must be released](#m4-resources-that-must-be-released)
+ - [M5. Check-then-act races](#m5-check-then-act-races)
+ - [M6. Fire-and-forget async](#m6-fire-and-forget-async)
+ - [M7. Order-dependent migrations and deploys](#m7-order-dependent-migrations-and-deploys)
+- [Cross-cutting, devices that were removed](#cross-cutting-devices-that-were-removed)
+ - [X1. Swallowed errors](#x1-swallowed-errors)
+ - [X2. Silent coercion and fallback](#x2-silent-coercion-and-fallback)
+ - [X3. Disabled tests](#x3-disabled-tests)
+ - [X4. Escape hatches in the type system](#x4-escape-hatches-in-the-type-system)
+ - [X5. Mutable shared defaults](#x5-mutable-shared-defaults)
+
+---
+
+## Contact lens, can the wrong thing fit?
+
+The factory analogy: a part that only seats one way. In software, the type is the shape.
+
+### C1. Adjacent same-type parameters
+
+**Signal**: two or more consecutive parameters of the same primitive type, `transfer(from: string, to: string)`, `resize(w: number, h: number)`,
+`slice(start: int, end: int)`.
+
+**Why it bites**: swapping them compiles, passes review, and produces a plausible wrong
+result. It is among the most common footguns in software, and one of the most cleanly
+solved, once the two types differ, the wrong order will not compile.
+
+**Device**: distinct types per concept, branded types, newtypes, value objects, so a
+`SourceAccount` cannot be passed as a `DestinationAccount`. **Control.**
+Fallback where types can't help: force keyword/named arguments so the caller must write the
+name at the call site. **Warning**, but nearly free and it makes the swap visible in review.
+
+### C2. Boolean flag parameters
+
+**Signal**: `createUser(name, true, false)`, `save(data, force=True)`, any `bool` parameter
+that selects behavior rather than carrying data.
+
+**Why it bites**: the call site is unreadable, so misordered or misunderstood flags are
+invisible. Adding a second boolean makes it exponentially worse.
+
+**Device**: an enum or literal union per axis (`Visibility.Public`), an options object with
+named fields, or two separate functions. **Control** for the enum, since the wrong value has
+no spelling. Note the exception: a single boolean whose name reads correctly at the call site
+in a keyword-argument language is fine.
+
+### C3. Primitive obsession at boundaries
+
+**Signal**: `string` for email, URL, path, token, tenant ID, phone; `int` for a percentage or
+a duration, especially on public functions.
+
+**Why it bites**: every downstream function must re-check or trust. Validation that returns a
+boolean throws away the proof, so the check gets repeated, skipped, or done inconsistently.
+
+**Device**: parse-don't-validate. `parseEmail(s): Email | Error` once at the boundary, then
+downstream signatures demand `Email`. The type carries the guarantee permanently. **Control.**
+
+### C4. Stringly-typed enums
+
+**Signal**: `status: string` with a comment listing the values; string comparison against
+literals; a value crossing a boundary as text with no schema.
+
+**Why it bites**: typos compile. New variants added elsewhere never reach this code. Nothing
+tells you which values are legal.
+
+**Device**: a literal union, enum, or sealed class, with exhaustive matching (F1). **Control.**
+
+### C5. Implicit units and magnitudes
+
+**Signal**: `timeout: number`, `distance: float`, `retryAfter: int`: no unit anywhere except
+possibly a name or a comment. Two systems in the same codebase disagreeing on seconds vs
+milliseconds.
+
+**Why it bites**: a 1000x error is silent and looks like a hang or a hot loop. This class of
+mistake famously destroyed a Mars orbiter.
+
+**Device**: unit-bearing types (`Duration`, `Milliseconds`), or at minimum encode the unit in
+the parameter name (`timeoutMs`). **Control** for the type. The name is **rung 0**: it makes
+a mismatch visible to a reader who is looking, and produces no diagnostic for one who is not.
+Worth doing; not a device.
+
+### C6. Money as a float
+
+**Signal**: `price: float`, `amount: number`, arithmetic on currency in binary floating point,
+`==` comparisons on money.
+
+**Why it bites**: 0.1 + 0.2 ≠ 0.3. Errors accumulate over aggregation and reconciliation
+fails in ways that take days to trace.
+
+**Device**: integer minor units (cents) in a `Money` type carrying its currency, or a decimal
+type. Mixed-currency arithmetic should not typecheck. **Control.**
+
+### C7. Unvalidated external input
+
+**Signal**: `JSON.parse(body)` into `any`, `request.json()` into a bare dict, a third-party
+API response used field-by-field with no schema, `os.environ[...]` read deep inside logic.
+
+**Why it bites**: the failure surfaces far from the boundary, as a confusing error about a
+missing property, long after the malformed data has been partially processed or stored.
+
+**Device**: a schema at every edge, zod/valibot, Pydantic, `encoding/json` into a typed
+struct with validation, serde. Parse once, then work with parsed types. **Control.**
+This applies to *your own* services' responses too; "internal" is not a guarantee.
+
+### C8. Bag-of-optionals structs
+
+**Signal**: a type with several optional fields where only certain combinations are
+meaningful, `{ status, data?, error?, retryAt? }`, `{ isLoading, data, error }`.
+
+**Why it bites**: N optional fields claim 2^N legal states. Every consumer must guess which
+are real, and they guess differently. States like "loading and errored with data" become
+reachable and get handled inconsistently.
+
+**Device**: a discriminated union with exactly the legal variants, so impossible combinations
+have no representation. **Control.** This is the canonical "make invalid states
+unrepresentable" move.
+
+### C9. Naive datetimes
+
+**Signal**: timezone-less timestamps, `datetime.now()` / `new Date()` scattered through
+business logic, dates stored as strings, DST-unaware arithmetic.
+
+**Why it bites**: correct in the developer's timezone, wrong in production, and wrong twice a
+year in the places that observe DST. Also hard to test, logic that reads the clock directly
+cannot be exercised at a boundary condition without freezing or injecting time.
+
+**Device**: timezone-aware types everywhere, UTC at rest, an injected clock so time is a
+parameter rather than an ambient read. **Control** for the type, and the injected clock buys
+testability, which is a Detection-rung device that finally becomes possible.
+
+---
+
+## Fixed-value lens, can an incomplete or wrong-sized set pass?
+
+The factory analogy: a counter confirming all six screws were fitted.
+
+### F1. Non-exhaustive branching
+
+**Signal**: a `switch`/`match` over an enum with a `default` that does nothing meaningful, or
+an if/else chain over a closed set of values.
+
+**Why it bites**: adding a variant silently takes the default branch at every site that
+should have been updated. The bug appears months later, in the one code path nobody tested.
+
+**Device**: compiler-enforced exhaustiveness: an `assertNever(x: never)` arm in TypeScript,
+`match` without a catch-all in Rust, `assert_never` with mypy, an exhaustive linter for Go.
+**Control**, one line per switch, and among the highest-leverage devices available.
+
+### F2. Unbounded destructive operations
+
+**Signal**: `DELETE`/`UPDATE` built from a filter that can be empty; `rm -rf "$VAR"`;
+`.deleteMany(where)`; bulk send/publish over a query result; a "cleanup" job with no cap.
+
+**Why it bites**: irreversible, instant, and proportional to your data volume. An empty filter
+frequently means "match everything."
+
+**Device**: refuse an empty predicate; require an explicit `all=True` for the full-table case;
+cap the affected row count and require confirmation above it; dry-run by default with the
+count printed. Soft-delete where the domain allows. **Control.**
+
+### F3. Defaults that hide a decision
+
+**Signal**: a default value for something with no safe default, `retries=3`, `timeout=30`,
+`currency="USD"`, `tenant=None`, `region=default`.
+
+**Why it bites**: the caller never considers the parameter, and the default is wrong for their
+case. Worse than an error, because it produces confident wrong behavior.
+
+**Device**: make it required. Reserve defaults for parameters where one value is correct for
+the overwhelming majority and wrong-but-harmless for the rest. **Control.**
+
+### F4. Config discovered missing at runtime
+
+**Signal**: `os.getenv("X")` inside a request handler; config read lazily on first use; a
+missing key producing `None` that flows onward.
+
+**Why it bites**: the service starts, passes health checks, and fails on the one code path
+that needs the key, often the payment path, often at 3am.
+
+**Device**: parse and validate the entire config into a typed object at startup, and exit
+non-zero if anything is missing or malformed. Every consumer takes the typed object.
+**Control**, and it converts a 3am page into a failed deploy.
+
+### F5. Partial writes without a transaction
+
+**Signal**: several writes in sequence with no transaction; a write followed by an external
+call followed by another write; "create the record then send the email."
+
+**Why it bites**: a failure in the middle leaves the system in a state your code does not
+model and cannot repair.
+
+**Device**: wrap in a transaction; move external effects outside it via an outbox; make the
+sequence idempotent so replay converges. **Control** for the transaction.
+
+### F6. Invariants enforced only in the application
+
+**Signal**: uniqueness checked with a `SELECT` before an `INSERT`; nullability enforced in a
+model class but not in the column; a foreign key relationship maintained by convention.
+
+**Why it bites**: the check races under concurrency, and it is bypassed entirely by any other
+service, migration, script, or human with `psql`.
+
+**Device**: push it into the schema, `NOT NULL`, `UNIQUE`, `CHECK`, foreign keys, partial
+unique indexes. The database is a type system shared by everything that touches the data.
+**Control**, and uniquely durable.
+
+### F7. Unbounded input
+
+**Signal**: pagination with no maximum page size; a file upload with no size limit; a query
+built from a user-supplied list with no cap; unbounded recursion or retries.
+
+**Why it bites**: a resource exhaustion incident indistinguishable from an attack, triggered
+by an ordinary user with a large account.
+
+**Device**: explicit caps at the boundary, enforced by the parsing type where possible.
+**Control.**
+
+---
+
+## Motion-step lens, can the order be wrong?
+
+The factory analogy: a sensor confirming step 3 happened before step 4.
+
+### M1. Temporal coupling
+
+**Signal**: `init()`, `connect()`, `configure()`, `validate()` that must be called before
+other methods; documentation containing the phrase "you must call X first."
+
+**Why it bites**: nothing enforces it. The failure is a null dereference or, worse, a
+silently-wrong result from a half-configured object.
+
+**Device**: the constructor or a static factory returns a fully ready object; or typestate,
+where `connect()` returns a `Connected` type and the other methods exist only on it.
+**Control.**
+
+### M2. Non-idempotent retryable effects
+
+**Signal**: a charge, email, webhook, or external mutation reachable from a retry, a queue
+consumer, or a UI button, with no idempotency key, or with an optional one.
+
+**Why it bites**: at-least-once delivery is the norm, not the exception. Duplicate charges are
+the canonical version and they are expensive and public.
+
+**Device**: a **required** idempotency key parameter, backed by a unique constraint on
+`(entity, key)`. **Control.** An optional idempotency key is rung zero wearing a costume.
+
+The constraint is necessary and not sufficient. Rejecting the duplicate is not the same as
+being idempotent: the key has to be *reserved in the same transaction as the effect*, bound
+to the request payload so a different payload under a reused key is an error rather than a
+silent no-op, and the stored result replayed to the second caller. A caller that retries and
+gets a constraint violation has learned nothing about whether the first attempt worked.
+
+### M3. Illegal state transitions
+
+**Signal**: an entity with a `status` field mutated by assignment from several places; a
+refund reachable before a charge; "cancelled" transitioning back to "pending".
+
+**Why it bites**: every site that assigns the field must know the whole state machine, and one
+of them doesn't.
+
+**Device**: a single transition function that is the only path to a new state, rejecting
+illegal transitions; or typestate so illegal transitions don't compile. **Control.**
+
+A row-level `CHECK` is not defence in depth here: it constrains one row's values and cannot
+see the state that row is coming from, so it can forbid `status = 'refunded' AND total < 0`
+but not `shipped → pending`. Policing transitions in the database needs a trigger, or a
+transition table the row must join against.
+
+### M4. Resources that must be released
+
+**Signal**: `open()`/`close()`, `acquire()`/`release()`, `begin()`/`commit()` as separate
+statements, especially with a `return` or `throw` reachable between them.
+
+**Why it bites**: the happy path is fine and the error path leaks. Leaks surface as connection
+pool exhaustion under load, which is when you can least afford it.
+
+**Device**: scope-bound acquisition, `with`, `defer`, RAII, `using`, try-with-resources.
+**Control.**
+
+### M5. Check-then-act races
+
+**Signal**: `if (!exists(x)) create(x)`, read-modify-write on a shared counter, checking a
+balance and then debiting it, `if (!file.exists()) write(file)`.
+
+**Why it bites**: correct in every test and wrong under concurrency, intermittently, in
+production only.
+
+**Device**: make it atomic: a unique constraint plus `INSERT ... ON CONFLICT`, a conditional
+update carrying the expected version, `SELECT FOR UPDATE`, a compare-and-swap. **Control.**
+
+### M6. Fire-and-forget async
+
+**Signal**: a promise not awaited, a goroutine with no error path, `asyncio.create_task` with
+no reference kept, a background write nobody joins.
+
+**Why it bites**: errors vanish. Worse, the process may exit before the work completes, so
+writes are lost silently and non-deterministically.
+
+**Device**: `no-floating-promises` as a lint error, an errgroup, structured concurrency,
+holding and awaiting the task. **Warning** from the linter, which is the practical answer
+in TypeScript, Python and Go. Rust is the closest thing to an exception: futures are lazy and `#[must_use]`, so a dropped
+future produces a compiler warning without any linter. That is **Warning**, for free; add
+`#![deny(unused_must_use)]` to make the build fail and it becomes **Control**.
+
+### M7. Order-dependent migrations and deploys
+
+**Signal**: a migration that drops or renames a column in the same deploy as the code change;
+a migration and code that must land in a specific order with nothing enforcing it.
+
+**Why it bites**: during the rollout window, old code runs against the new schema. This is an
+outage, not a bug.
+
+**Device**: expand/contract, add, backfill, dual-write, switch, then drop in a later deploy, with a CI gate that blocks destructive DDL from co-deploying with code changes. **Control**
+via the gate; the pattern itself is the design.
+
+---
+
+## Cross-cutting, devices that were removed
+
+Several of these are hazards of removal, someone installed a device and someone else took
+it out. Others (X2, X5) are defaults nobody chose: the language ships them switched the wrong
+way and they stay that way until someone notices.
+Treat them with more suspicion than a missing device, since the code around them was written
+by someone who knew the failure was possible.
+
+### X1. Swallowed errors
+
+**Signal**: `catch {}`, `except: pass`, `except Exception: pass`, `_ = err`, `catch (e) {
+console.log(e) }` with execution continuing, `.catch(() => null)`.
+
+**Why it bites**: converts a loud failure into a quiet wrong answer: the exact inversion of
+mistake-proofing. The system continues on corrupted assumptions.
+
+**Device**: handle it, or let it propagate. Where absorbing genuinely is correct, the comment
+must name which specific failure is expected and why continuing is safe; catch that specific
+type, not everything. Enforce with `no-empty` / bare-except lint rules as errors. **Warning.**
+
+### X2. Silent coercion and fallback
+
+**Signal**: `value || default` where `0`/`""`/`false` are legal values; `parseInt` without a
+radix or a NaN check; `int(x)` in a try/except returning a default; `.unwrap_or_default()` on
+a genuine error; `?.` chains ending in `undefined` that flow into logic.
+
+**Why it bites**: produces a plausible value from bad input. The wrongness surfaces far away,
+where the cause is invisible.
+
+**Device**: `??` instead of `||` where zero is legal; explicit parse with an error branch;
+fail at the boundary rather than substituting. **Control** at the parse site.
+
+### X3. Disabled tests
+
+**Signal**: `it.only`, `describe.skip`, `@pytest.mark.skip`, `t.Skip()`, `#[ignore]`: especially without a reason. Lint and type-checker suppressions (`eslint-disable`,
+`# type: ignore`, `@ts-ignore`, `#nosec`) are X4, and the detector splits them the same way.
+
+**Why it bites**: a Detection-rung device switched off, usually temporarily, permanently. The
+suite stays green and stops meaning anything.
+
+**Device**: fail CI on focused/skipped tests; require a justification comment and an issue
+link on every suppression; count suppressions and ratchet the number downward. **Warning.**
+
+### X4. Escape hatches in the type system
+
+**Signal**: `any`, `as unknown as T`, `!` non-null assertion, `interface{}` with a type
+switch, `# type: ignore`, `unsafe`, `cast()`, `Object` as a parameter type.
+
+**Why it bites**: every one is a place where the type system's guarantee stops. Concentrated
+in the boundary code that most needs the guarantee.
+
+**Device**: ban them by lint at error level with a narrow, justified allowlist; replace with
+parsing at the boundary. **Warning**: a required CI gate is still rung 2 by the ladder in
+[method.md](../../../docs/method.md): it announces the mistake rather than removing the
+ability to make it. Reach **Control** only when the unchecked value cannot be constructed.
+
+### X5. Mutable shared defaults
+
+**Signal**: Python's `def f(items=[])`, a module-level dict used as a cache and mutated, a
+shared config object mutated after construction, class attributes used as instance state.
+
+**Why it bites**: state leaks between calls, requests, or tests. The symptom is
+order-dependent behavior that disappears when you try to reproduce it.
+
+**Device**: `None` sentinel with in-function construction, frozen/immutable value types,
+per-request construction. `B006` in ruff/flake8-bugbear enforces the argument-default case
+only; the module-level cache, the shared config object and the mutable class attribute have
+no lint rule and need review or a type that cannot be mutated.
+**Warning**, or **Control** with frozen types.
diff --git a/skills/poka-yoke-ux/references/ux-patterns.md b/skills/poka-yoke-ux/references/ux-patterns.md
new file mode 100644
index 000000000..73694cb42
--- /dev/null
+++ b/skills/poka-yoke-ux/references/ux-patterns.md
@@ -0,0 +1,105 @@
+# UX Device Patterns
+
+Concrete forms of the interface devices, with the rung each reaches. The lineage here is
+Norman's *forcing functions* and Nielsen's error-prevention heuristic, both of which are the
+design world's version of Shingo's argument.
+
+## Destructive actions, by consequence
+
+Match the device to what is actually lost. Over-gating cheap actions is how users learn to
+ignore gates.
+
+| Consequence | Device | Rung |
+|---|---|---|
+| Recoverable (draft, filter, sort) | Nothing. Just do it. |, |
+| Recoverable with effort (archive, unpublish) | Immediate action + "Undo" toast, 5–10s | Control |
+| Data loss, recoverable server-side | Soft delete + trash with retention; no dialog at all | Control |
+| Irreversible, low value (single item) | Dialog naming the specific item | Warning |
+| Irreversible, high value (bulk, account, repo) | Type the resource name to confirm | Warning |
+| Irreversible + external (send, publish, charge) | Preview of exactly what will happen + delay window | Control |
+
+**Norman's three forcing functions**, which is the vocabulary worth having:
+*interlock* (order is enforced: the microwave stops when the door opens), *lock-in* (you
+can't leave mid-way without acknowledging, "you have unsaved changes"), *lockout* (you can't
+enter: the action is unavailable until preconditions are met).
+
+## Confirmation dialogs that actually work
+
+If you must use one, it needs all four:
+
+1. **Name the object.** "Delete `production-api`?" not "Delete this item?"
+2. **State the scale.** "3 projects and 1,204 files." A count is fixed-value inspection.
+3. **State reversibility honestly.** If it's recoverable for 30 days, say so, overclaiming
+ permanence trains people to disbelieve you.
+4. **Label the button with the verb, not "OK".** "Delete forever" / "Cancel". A user scanning
+ for the confirm button should read what they are confirming.
+
+What makes a dialog useless: appearing on every action, appearing for reversible actions,
+saying "Are you sure?", and defaulting focus to the destructive button.
+
+## Forms
+
+| Hazard | Device | Rung |
+|---|---|---|
+| Wrong value typed | Constrained control, picker, stepper, select, mask | Control |
+| Wrong format | `type` + `inputmode` + `pattern`; parse on blur | Warning |
+| Out of range | `min`/`max` enforced by the control, not just checked | Control |
+| Required field missed | Submit disabled + the specific unmet reason shown | Control |
+| Wrong option chosen inattentively | Safest option as the default | Control |
+| Work lost | Draft autosave; never clear on failed submit | Control |
+| Double submission | Disable on submit **and** an idempotency key on the request | Control |
+| Error not understood | Message says how to fix, focus moves to the field | Warning |
+| Wrong row acted on in a table | Show the identifying value in the action's confirmation | Warning |
+
+**Validation timing** is where most forms go wrong. Validate on blur for the field just left;
+never per-keystroke while someone is mid-entry (a half-typed email flagged as invalid teaches
+users to ignore validation); re-validate on submit; move focus to the first error.
+
+**Disabled submit needs a visible reason.** A greyed-out button with no explanation is its own
+dead end, and it must stay reachable by keyboard and screen reader so the reason is announced
+rather than silently unavailable.
+
+## Irreversible-action patterns worth copying
+
+- **Grace-period undo** (Gmail undo-send): perform immediately, hold the effect for N seconds,
+ offer withdrawal. Zero friction on the happy path, full recovery on the mistake. The best
+ general-purpose device in this list.
+- **Type-to-confirm** (GitHub repo deletion): raises the cost of a reflexive action by making
+ the user reproduce the object's name. Reserve for genuinely catastrophic actions, used
+ broadly, it becomes copy-paste theater.
+- **Two-key / second approver**: for actions that should never be one person's decision.
+- **Scheduled with a cancel window**: "This will run in 15 minutes" for bulk operations.
+- **Preview the diff**: show exactly what changes before applying, for settings and bulk edits.
+
+## Bulk operations
+
+The dangerous property is that scale is invisible, selecting "all" is one click and affects
+everything. Devices: show the affected count before the action, prominently; cap selection
+size or require an extra step above a threshold; run against a preview first; make the result
+undoable in one action rather than N.
+
+"Select all" that silently means "all 40,000 matching, not the 50 on screen" is a well-known
+interface trap. Distinguish the two explicitly.
+
+## Defaults and destructive adjacency
+
+- The default is the decision most users get, because many never change it. Make it the option
+ with the smallest consequence if chosen inattentively, private over public, narrowest
+ scope, opt-in over opt-out.
+- Never place a destructive action adjacent to a frequent benign one, never give both the same
+ visual weight, and never make the destructive one the default focus or the primary button.
+- Destructive actions belong out of the primary flow: a menu, a settings section, below a
+ fold: not on the toolbar next to Save.
+
+## Permissions
+
+Hiding a button is presentation, not authorization. If a user should not perform an action,
+the server must refuse it, otherwise the "device" is a suggestion the API ignores. Hide *and*
+enforce; see `authz` for the server half.
+
+## Error prevention that overlaps accessibility
+
+These are error-prevention devices that happen to also be a11y requirements, which makes them
+easy to justify: labels tied to inputs (so the field's purpose is never ambiguous), focus moved
+to the error on failed submit, errors identified by more than color, target sizes large enough
+to prevent mis-taps, and no time limits on entry that can expire mid-task.
diff --git a/skills/poka-yoke/SKILL.md b/skills/poka-yoke/SKILL.md
index 5ef7f70a8..49b44e478 100644
--- a/skills/poka-yoke/SKILL.md
+++ b/skills/poka-yoke/SKILL.md
@@ -179,6 +179,32 @@ understood and maintained. Ask whether the shape is common enough to justify it.
the likelihood. Both are worth having, and conflating them means the likelihood never gets
addressed.
+## Specialist modes
+
+This skill carries the method and is enough on its own. Ten companion skills carry the domain
+detail it does not — what a tenant-scoping device looks like, what expand/contract means,
+which lint rules catch silent failure. Install the one that fits:
+
+| Skill | For |
+|---|---|
+| `poka-yoke-design` | A new API, schema, type or state machine — make misuse unrepresentable |
+| `poka-yoke-audit` | Existing code: swappable arguments, silent fallbacks, unguarded deletes |
+| `poka-yoke-retro` | After an incident, when the fix must close the class rather than the case |
+| `poka-yoke-guardrails` | Pre-commit hooks, CI gates, lint rules, database constraints |
+| `poka-yoke-agent-guardrails` | Constraining an AI agent that works on your repository |
+| `poka-yoke-authz` | Multi-tenant isolation, IDOR, row-level security |
+| `poka-yoke-data` | Pipelines and metrics, where failure is silently wrong numbers |
+| `poka-yoke-ops` | Deploys, migrations, rollback, blast radius |
+| `poka-yoke-llm` | AI features you ship to users: structured output, tool schemas, injection |
+| `poka-yoke-ux` | Forms, destructive actions, flows users get wrong |
+
+Two are easy to confuse. `poka-yoke-llm` is for AI features *you ship to users*;
+`poka-yoke-agent-guardrails` is for constraining an agent that *works on your repository*.
+
+They compose. An incident involving a bad migration is `poka-yoke-retro` for the analysis and
+`poka-yoke-ops` for the device: the retro decides what to install, the domain skill decides
+which device.
+
## Evidence, and its limits
This method was benchmarked at 591 blind-graded runs across six model families, scored against