@nurix/etna 0.18.14 → 0.18.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +11 -16
  2. package/data/harness-index.json +1 -1
  3. package/data/harnesses/code/CLAUDE.md +71 -57
  4. package/data/harnesses/code/agents/{spec-mode.md → lld-mode.md} +12 -12
  5. package/data/harnesses/code/agents/maintenance.md +2 -31
  6. package/data/harnesses/code/harness.json +2 -2
  7. package/data/harnesses/code/hooks/hooks.json +0 -23
  8. package/data/harnesses/code/rules/testing.md +25 -0
  9. package/data/harnesses/code/skills/boundary/SKILL.md +34 -14
  10. package/data/harnesses/code/skills/boundary/references/index.md +16 -0
  11. package/data/harnesses/code/skills/boundary/references/mechanisms.md +99 -0
  12. package/data/harnesses/code/skills/boundary/references/packs/async-messaging.md +81 -0
  13. package/data/harnesses/code/skills/boundary/references/packs/auth.md +93 -0
  14. package/data/harnesses/code/skills/boundary/references/packs/build-config-runtime.md +93 -0
  15. package/data/harnesses/code/skills/boundary/references/packs/cloudflare.md +93 -0
  16. package/data/harnesses/code/skills/boundary/references/packs/frontend.md +93 -0
  17. package/data/harnesses/code/skills/boundary/references/packs/generic-tail.md +55 -0
  18. package/data/harnesses/code/skills/boundary/references/packs/persistence.md +93 -0
  19. package/data/harnesses/code/skills/boundary/references/packs/wire-api.md +87 -0
  20. package/data/harnesses/code/skills/brainstorm/README.md +28 -10
  21. package/data/harnesses/code/skills/brainstorm/SKILL.md +27 -12
  22. package/data/harnesses/code/skills/brainstorm/exit-artifacts.md +2 -2
  23. package/data/harnesses/code/skills/brainstorm/templates/evidence.md +24 -0
  24. package/data/harnesses/code/skills/brainstorm/templates/rejected.md +5 -1
  25. package/data/harnesses/code/skills/documentation/SKILL.md +2 -2
  26. package/data/harnesses/code/skills/documentation/references/templates.md +10 -77
  27. package/data/harnesses/etna/CLAUDE.md +11 -12
  28. package/data/harnesses/etna/agents/{spec-mode.md → lld-mode.md} +12 -12
  29. package/data/harnesses/etna/agents/maintenance.md +2 -31
  30. package/data/harnesses/etna/harness.json +2 -2
  31. package/data/harnesses/etna/hooks/hooks.json +0 -23
  32. package/data/harnesses/etna/rules/testing.md +25 -0
  33. package/data/harnesses/etna/skills/boundary/SKILL.md +34 -14
  34. package/data/harnesses/etna/skills/boundary/references/index.md +16 -0
  35. package/data/harnesses/etna/skills/boundary/references/mechanisms.md +99 -0
  36. package/data/harnesses/etna/skills/boundary/references/packs/async-messaging.md +81 -0
  37. package/data/harnesses/etna/skills/boundary/references/packs/auth.md +93 -0
  38. package/data/harnesses/etna/skills/boundary/references/packs/build-config-runtime.md +93 -0
  39. package/data/harnesses/etna/skills/boundary/references/packs/cloudflare.md +93 -0
  40. package/data/harnesses/etna/skills/boundary/references/packs/frontend.md +93 -0
  41. package/data/harnesses/etna/skills/boundary/references/packs/generic-tail.md +55 -0
  42. package/data/harnesses/etna/skills/boundary/references/packs/persistence.md +93 -0
  43. package/data/harnesses/etna/skills/boundary/references/packs/wire-api.md +87 -0
  44. package/data/harnesses/etna/skills/brainstorm/README.md +28 -10
  45. package/data/harnesses/etna/skills/brainstorm/SKILL.md +27 -12
  46. package/data/harnesses/etna/skills/brainstorm/exit-artifacts.md +2 -2
  47. package/data/harnesses/etna/skills/brainstorm/templates/evidence.md +24 -0
  48. package/data/harnesses/etna/skills/brainstorm/templates/rejected.md +5 -1
  49. package/data/harnesses/etna/skills/documentation/SKILL.md +2 -2
  50. package/data/harnesses/etna/skills/documentation/references/templates.md +10 -77
  51. package/data/harnesses/mobile/CLAUDE.md +78 -55
  52. package/data/harnesses/mobile/agents/{spec-mode.md → lld-mode.md} +12 -12
  53. package/data/harnesses/mobile/agents/maintenance.md +2 -31
  54. package/data/harnesses/mobile/harness.json +2 -2
  55. package/data/harnesses/mobile/hooks/hooks.json +0 -23
  56. package/data/harnesses/mobile/rules/testing.md +25 -0
  57. package/data/harnesses/mobile/skills/boundary/SKILL.md +34 -14
  58. package/data/harnesses/mobile/skills/boundary/references/index.md +16 -0
  59. package/data/harnesses/mobile/skills/boundary/references/mechanisms.md +99 -0
  60. package/data/harnesses/mobile/skills/boundary/references/packs/async-messaging.md +81 -0
  61. package/data/harnesses/mobile/skills/boundary/references/packs/auth.md +93 -0
  62. package/data/harnesses/mobile/skills/boundary/references/packs/build-config-runtime.md +93 -0
  63. package/data/harnesses/mobile/skills/boundary/references/packs/cloudflare.md +93 -0
  64. package/data/harnesses/mobile/skills/boundary/references/packs/frontend.md +93 -0
  65. package/data/harnesses/mobile/skills/boundary/references/packs/generic-tail.md +55 -0
  66. package/data/harnesses/mobile/skills/boundary/references/packs/persistence.md +93 -0
  67. package/data/harnesses/mobile/skills/boundary/references/packs/wire-api.md +87 -0
  68. package/data/harnesses/mobile/skills/brainstorm/README.md +28 -10
  69. package/data/harnesses/mobile/skills/brainstorm/SKILL.md +27 -12
  70. package/data/harnesses/mobile/skills/brainstorm/exit-artifacts.md +2 -2
  71. package/data/harnesses/mobile/skills/brainstorm/templates/evidence.md +24 -0
  72. package/data/harnesses/mobile/skills/brainstorm/templates/rejected.md +5 -1
  73. package/data/harnesses/mobile/skills/documentation/SKILL.md +2 -2
  74. package/data/harnesses/mobile/skills/documentation/references/templates.md +10 -77
  75. package/package.json +1 -1
  76. package/data/harnesses/code/hooks/lib/redact.mjs +0 -35
  77. package/data/harnesses/code/hooks/prompt-recall.mjs +0 -155
  78. package/data/harnesses/code/hooks/session-reflect.mjs +0 -285
  79. package/data/harnesses/code/skills/boundary/references/boundary-taxonomy.md +0 -796
  80. package/data/harnesses/code/skills/distiller/SKILL.md +0 -90
  81. package/data/harnesses/code/skills/distiller/references/docs-mode.md +0 -299
  82. package/data/harnesses/code/skills/distiller/references/method.md +0 -94
  83. package/data/harnesses/etna/hooks/lib/redact.mjs +0 -35
  84. package/data/harnesses/etna/hooks/prompt-recall.mjs +0 -155
  85. package/data/harnesses/etna/hooks/session-reflect.mjs +0 -285
  86. package/data/harnesses/etna/skills/boundary/references/boundary-taxonomy.md +0 -796
  87. package/data/harnesses/etna/skills/distiller/SKILL.md +0 -90
  88. package/data/harnesses/etna/skills/distiller/references/docs-mode.md +0 -299
  89. package/data/harnesses/etna/skills/distiller/references/method.md +0 -94
  90. package/data/harnesses/mobile/hooks/lib/redact.mjs +0 -35
  91. package/data/harnesses/mobile/hooks/prompt-recall.mjs +0 -155
  92. package/data/harnesses/mobile/hooks/session-reflect.mjs +0 -285
  93. package/data/harnesses/mobile/skills/boundary/references/boundary-taxonomy.md +0 -796
  94. package/data/harnesses/mobile/skills/distiller/SKILL.md +0 -90
  95. package/data/harnesses/mobile/skills/distiller/references/docs-mode.md +0 -299
  96. package/data/harnesses/mobile/skills/distiller/references/method.md +0 -94
@@ -0,0 +1,25 @@
1
+ # Testing
2
+
3
+ Framework choice is never ad hoc and never the user's problem mid-task: detect the stack, use its row. If the repo already has a runner, that runner wins — never introduce a second framework beside it.
4
+
5
+ ## Frameworks by stack
6
+
7
+ No alternatives. Just use these.
8
+
9
+ | Stack | Unit / integration | Component | E2E | HTTP mocking | Don't use |
10
+ | --- | --- | --- | --- | --- | --- |
11
+ | Node + Vite / React (web) | vitest | @testing-library/react (+ @testing-library/user-event, happy-dom) | @playwright/test | msw | jest, mocha, jasmine, cypress, enzyme |
12
+ | Express APIs | vitest | — | supertest against the app instance | msw (outbound calls) | jest, chai-http |
13
+ | React Native / Expo | jest via the jest-expo preset | @testing-library/react-native | maestro | msw | detox, enzyme |
14
+ | Cloudflare Workers | vitest + @cloudflare/vitest-pool-workers | — | @playwright/test against `wrangler dev` | msw | jest |
15
+ | Rust | `cargo test` (built-in) | — | — | — | — |
16
+
17
+ All npm packages at `latest`. Fake data comes from `@ngneat/falso` (the repo standard — never faker/chance).
18
+
19
+ ## Conventions
20
+
21
+ - **Suite, not scripts.** Tests live in the canonical test tree (`tests/`, mirroring `src/`), named `<subject>.test.ts`. Shared fixtures, factories, and helpers live in `tests/helpers/`. The harness `CLAUDE.md` Tests guardrail carries the full suite-first policy; this rule owns only the technology choices.
22
+ - **Wire the canonical command.** `"test"` in package.json runs the whole unit suite (`vitest run`); e2e runs under `"test:e2e"`. A repo where `pnpm test`/`npm test` doesn't run the suite is misconfigured.
23
+ - **Unit and E2E trees stay separate.** Vitest/Jest suites in `tests/`; Playwright/Maestro suites in `e2e/` with their own config — never mixed into the unit runner's glob.
24
+ - **No test doubles for what you own.** Prefer real instances — in-memory SQLite for drizzle, the actual Express app via supertest — over mocks; mock only true externals (network via msw, time via the runner's fake timers).
25
+ - **Determinism.** No real network, no real time, no shared mutable state between tests; every test must pass alone and in any order.
@@ -1,49 +1,69 @@
1
1
  ---
2
2
  name: boundary
3
- description: Boundary diagnostic for implementation plans. Given a plan file, reads the source files the plan touches, identifies the application boundaries they participate in — the cross-cutting contracts (db schema, wire envelope, auth, events, env/config, …) where editing one side silently breaks the other — and reports whether the planned changes would violate any of them, severity-ranked, with suggested plan amendments. Read-only; the report lands in chat. Can also run against a diff or named code area instead of a plan. Triggers on "boundary check", "check this plan", "what could break", "impact analysis", "/boundary".
3
+ description: Boundary diagnostic for implementation plans. Given a plan file, reads the source the plan touches, identifies the cross-cutting contracts it participates in — db schema, wire envelope, auth, events, env/config, … — where editing one side silently breaks the other, and reports whether the planned changes would violate any of them, severity-ranked, with suggested amendments and a pass/block verdict. Read-only; the report lands in chat. Also runs against a diff or named code area. Triggers on "boundary check", "check this plan", "what could break", "will this break anything", "is it safe to rename this column/endpoint/event", "who uses this", "impact analysis", "/boundary".
4
4
  ---
5
5
 
6
6
  # Boundary Diagnostic
7
7
 
8
8
  You diagnose whether a plan's changes would break an application boundary.
9
9
 
10
- A boundary is a contract between two pieces of code (or code and infra) that share an assumption with **no compile-time link** between them: the column a raw SQL string names, the JSON envelope a client unwraps, the event string a dispatcher parses, the env var a deploy script writes. Changing one side ships green and breaks the other silently. Your job is to find those edges in the plan's path _before_ execution.
10
+ A boundary is a contract between two pieces of code (or code and infra) that share an assumption with **no static link** between them: the column a raw SQL string names, the JSON envelope a client unwraps, the event string a dispatcher parses, the env var a deploy script writes. Changing one side ships green — compiles, lints, tests pass — and breaks the other at runtime. Your job is to find those edges in the plan's path _before_ execution.
11
11
 
12
- [`references/boundary-taxonomy.md`](references/boundary-taxonomy.md) is the catalog of boundary types (~170 across persistence, wire/API, frontend, auth/tenancy, async/messaging, config/build, runtime/infra), with per-type severities and safe-change checklists. Read it first — it tells you what to look for; the code tells you what exists.
12
+ **The litmus is "would it ship green?"** An edge the compiler or an existing test already guards is not a boundary — a change that fails the build loudly needs no diagnostic. Spend your reading on the edges with no static link.
13
+
14
+ ## Contract sources
15
+
16
+ Three layers tell you what to look for; the code tells you what exists.
17
+
18
+ - **The mechanisms** — [`references/mechanisms.md`](references/mechanisms.md) defines the seven failure mechanisms every boundary instantiates (rendezvous-string, serialized-shape, generated-artifact, dual-authority, config-elsewhere, trust-invariant, lifecycle-protocol), each with its trace strategy, universal safe-change pattern, and severity heuristic, plus the two stack-independent domain contracts (entity-model, tenancy). Load it on every check.
19
+ - **The technology packs** — [`references/index.md`](references/index.md) routes to the packs under `references/packs/` by the signals in the touched code. Each pack carries the non-derivable payload for one technology area: exact detect patterns, expensive-to-learn gotchas, and safe-change sequences. Load **only the packs the touch list exhibits**; a technology no pack covers is still in scope — diagnose it from its mechanism.
20
+ - **Repo doctrine** — `.claude/rules/`, `docs/architecture/` (including `adr/`), and any contracts package name the repo's bespoke contracts (a documented response envelope, a schema-parity rule, an entity model). A documented contract is a real side of a boundary: a plan that contradicts one is a finding **even when no code consumer exists yet** — this is how greenfield plans, whose modules have no code to trace, still get checked.
21
+
22
+ The packs are a floor, not a fence: report any contract that passes the ships-green litmus, pack entry or not.
13
23
 
14
24
  ## Run
15
25
 
16
26
  **Input:** a plan path (given, or the newest in `docs/history/plans/`) — or a diff / named code area for non-plan work. Everything below is identical either way; a diff's hunks stand in for the plan's steps.
17
27
 
18
- 1. **Build the touch list.** Parse the plan's Proposed Changes: files to be created or modified, entities/state added or changed, API changes, anything the author flags as a cross-module contract. Note missing subsections.
28
+ 1. **Build the touch list.** Parse the plan's Proposed Changes: files created or modified, entities/state added or changed, API changes, anything the author flags as a cross-module contract. Note missing subsections.
19
29
 
20
- 2. **Identify the boundaries in the touched code.** Read each touched file (and its immediate imports). Against the taxonomy, list the contracts this code participates in — the schema it queries, the envelope it emits, the events it publishes or parses, the auth checks it sits behind, the env it reads.
30
+ 2. **Identify the contracts.** Read each touched file (and its immediate imports). Against the mechanisms, the routed packs, and the repo doctrine, list the contracts this code participates in — the schema it queries, the envelope it emits, the events it publishes or parses, the auth checks it sits behind, the env it reads.
21
31
 
22
- 3. **Trace the other side.** A boundary has two sides; the danger lives on the one the plan _doesn't_ mention. For each contract, grep the shared name or shape across the repo — the column name in other SQL strings, the response field in client unwrappers, the event kind in subscribers, the route in fetch calls and scripts, the env key in deploy tooling. Record consumers as `file:line`.
32
+ 3. **Trace the other side.** A boundary has two sides; the danger lives on the one the plan _doesn't_ mention. For each contract, grep the shared name or shape across the repo — the column name in other SQL strings, the response field in client unwrappers, the event kind in subscribers, the route in fetch calls and scripts, the env key in deploy tooling. Record consumers as `file:line`; record doctrine as the doc path. When the touch list is large (≳10 contracts), fan the tracing out to parallel sub-agents — one per pack area, each with a self-contained prompt naming its contracts and grep targets — and judge the returned evidence yourself; a small diff traces inline.
23
33
 
24
- 4. **Judge each intersection.** Does the planned change alter a shape a traced consumer depends on, break an invariant the code establishes (append-only vocabularies, idempotency keys, fail-closed checks, ordering guarantees), or add a new cross-module edge? Severity:
34
+ 4. **Judge each intersection.** Does the planned change alter a shape a traced consumer depends on, break an invariant the code or doctrine establishes (append-only vocabularies, idempotency keys, fail-closed checks, ordering guarantees), or introduce a new cross-module edge? Severity:
25
35
  - **CRITICAL** — can corrupt persisted data, or open an auth/money path.
26
36
  - **HIGH** — silent cross-module breakage that would ship green.
27
37
  - **MEDIUM** — drift-prone coupling (string-matched, duplicated constants).
28
- - **LOW** — smell. Loud, immediately-visible breakage caps at HIGH.
38
+ - **LOW** — smell. Loud, immediately-visible runtime breakage caps at HIGH.
29
39
 
30
40
  5. **Report in chat:**
31
41
 
32
42
  ```
33
43
  # Boundary check — {plan}
34
44
 
35
- Touched files read: {N} · Boundaries identified: {N}
36
- Violations: {N} ({critical}/{high}/{medium}/{low})
45
+ **Verdict: PASS | PASS WITH AMENDMENTS | BLOCK**
37
46
 
38
- | # | Boundary | Planned change | Other side (file:line) | Severity |
47
+ Touched files read: {N} · Contracts identified: {N} · Consumers traced: {N}
48
+
49
+ ## Violations
50
+ | # | Boundary | Mechanism | Planned change | Other side (file:line or doc) | Severity |
51
+
52
+ ## New edges the plan introduces
53
+ | # | Contract | Sides | Hardening to fold into the plan |
54
+
55
+ ## Coverage
56
+ | Area touched | Contracts checked | Result |
39
57
 
40
58
  ## Suggested plan amendments
41
59
  ## Missing plan subsections
42
60
  ```
43
61
 
62
+ **BLOCK** = any CRITICAL. HIGH findings yield **PASS WITH AMENDMENTS** — fold the amendments into the plan before coding. Order amendments sequencing-first: many violations dissolve by reordering steps (expand-then-contract, migrate-before-code) rather than changing what ships — prefer that fix, drawn from the entry's safe-change sequence.
63
+
44
64
  ## Rules
45
65
 
46
66
  - **Read-only.** You never edit the plan, the code, or any doc. The user folds your amendments into the plan.
47
- - **Verify in source.** Never flag from plan text alone — read both sides of every boundary you report, and cite both as `file:line`.
48
- - **No consumer, no finding.** An intersection with no traced other side isn't a violation; at most note it as a new edge the plan introduces.
49
- - **A clean report is valid.** Zero violations means say so — don't pad.
67
+ - **Verify in source.** Never flag from plan text alone — read both sides of every finding and cite them: code as `file:line`, doctrine as the doc path.
68
+ - **No other side, no violation.** An intersection with no traced consumer and no doctrine behind it isn't a violation — it's a **new edge**: report it in its own section with the hardening its mechanism prescribes (version the cache key now, make the enum append-only now), so the fragile edge is born safe.
69
+ - **A clean report is valid.** Zero violations means say so — the coverage table is the evidence it was checked, not skipped. Don't pad findings.
@@ -0,0 +1,16 @@
1
+ # Pack routing index
2
+
3
+ Load when: every boundary check, right after `mechanisms.md`. Match the touch list against the signals below and load **only** the packs whose signals appear in the touched files or the plan's Proposed Changes. Several packs matching is normal (a full-stack feature typically routes 2–4). A technology matching nothing here is still in scope — diagnose it from its mechanism, and check `packs/generic-tail.md` for a thin entry.
4
+
5
+ | Pack | Load when the touch list shows |
6
+ | --- | --- |
7
+ | `packs/persistence.md` | `schema.ts`, `init.sql`, `sqliteTable(`, `drizzle-kit`, `pgTable`, migrations, `*.seed.sql`, seed generators, JSON columns, `pgEnum`, pgvector, Redis `get`/`set` caching |
8
+ | `packs/wire-api.md` | Express routes/middleware (`app.use(`, `router.`), `res.json({ status`, zod schemas on requests, axios clients/interceptors, `cors(`, inbound webhooks, `rateLimit(` |
9
+ | `packs/frontend.md` | `createSlice`/`configureStore`, RTK Query (`tagTypes`), react-router tables/`useParams`, Tailwind CSS (`@theme`, `personality.css`, `globals.css`), `vite.config.*` (alias, env), `@nurix/components`, localforage/redux-persist, date formatting |
10
+ | `packs/auth.md` | `jwtVerify(`/jose, `betterAuth(`/`auth.api.`, session cookies, CSRF, OAuth callbacks/`redirect_uri`, `tenantId`/`organizationId` scoping, password hashing, auth middleware mounts |
11
+ | `packs/cloudflare.md` | `wrangler.jsonc`/`wrangler.toml`, `env.X` bindings, `extends DurableObject`/`[[migrations]]`, KV/R2/D1/Queues, `scheduled(`/crons, `WorkerEntrypoint`, `compatibility_date`, static assets config |
12
+ | `packs/async-messaging.md` | BullMQ (`new Queue(`/`new Worker(`), Redis pub/sub or Streams, WebSockets/socket.io, SSE (`text/event-stream`), channel-name templates, outbound webhook senders |
13
+ | `packs/build-config-runtime.md` | `package.json` (deps/`exports`/scripts/`packageManager`), lockfiles, `.env*`, tsconfig `paths`, Dockerfiles/compose, `.github/workflows/`, pino config, `trust proxy`, ports/healthchecks |
14
+ | `packs/generic-tail.md` | GraphQL, gRPC/protobuf, tRPC, OpenAPI codegen, Kafka/schema registry/CDC, workflow engines, event sourcing, Postgres RLS/pooling/collation, SSR/hydration, module federation, i18n, analytics plans, hosted feature flags, K8s probes/shutdown/limits, service mesh, IaC outputs, SAML, Lambda, IPC |
15
+
16
+ Two files never route — they always load: `mechanisms.md` (the seven failure mechanisms + domain contracts) and this index.
@@ -0,0 +1,99 @@
1
+ # Boundary Mechanisms
2
+
3
+ Load when: every boundary check — this is the core reference. The technology packs under `packs/` instantiate these mechanisms with exact detect signals and gotchas; `index.md` routes to the packs the touched code exhibits.
4
+
5
+ A boundary is a contract between two pieces of code (or code and infra) that share an assumption with **no static link** between them. The litmus is **"would it ship green?"** — change one side, everything compiles, lints, and tests pass, and the other side breaks at runtime. An edge the compiler or an existing test already guards is not a boundary.
6
+
7
+ Every boundary instantiates one of seven mechanisms. The mechanism tells you what to grep for, what the universal fix is, and how bad a break is — including for technologies no pack covers. When touched code exhibits a contract that matches no pack entry, diagnose it from its mechanism.
8
+
9
+ ---
10
+
11
+ ## 1 · rendezvous-string
12
+
13
+ Two sides meet on a literal identifier: a column name in raw SQL, an event/job/topic name, a route path, an env key, a cache key, a channel template, a DI token, a flag key, a metric name, a port number, a binding name. The compiler sees two unrelated literals.
14
+
15
+ - **Ask:** where else does this exact string (or its template) appear — including config, IaC, CI, dashboards, and other repos?
16
+ - **Failure:** the sides silently stop meeting — publishes into the void, reads `undefined`, routes 404, handler never fires, alert goes quiet.
17
+ - **Trace:** grep the literal and its prefix/template variants repo-wide; the other side is often not code (a deploy manifest, a saved dashboard query, a provider allowlist).
18
+ - **Universal fix:** centralize the identifier in one shared constant or module. On rename, serve both old and new until the old side drains — in-flight jobs, cached entries, sent emails, deployed clients.
19
+ - **Severity:** HIGH by default; CRITICAL when the string scopes trust (a tenant segment in a channel name, a redirect-URI allowlist entry).
20
+
21
+ ## 2 · serialized-shape
22
+
23
+ A payload crosses a gap in space or time — a process boundary, a deploy window, persistence — and the reader is not the writer. Old data and mixed-version peers must still parse. The defining sub-case is the **mixed-version window**: rolling deploys, in-flight queue items, long-lived clients, persisted events, browser storage — the old shape exists somewhere and outlives the change.
24
+
25
+ - **Ask:** who reads this shape that doesn't build from this commit — old rows, queued messages, deployed clients, replayed histories?
26
+ - **Failure:** the reader crashes or misreads with no error at the writer; persisted old shapes fail **forever**, not just during the deploy.
27
+ - **Trace:** find every deserializer of the shape (parsers, `JSON.parse` + property reads, ORM rehydration, client unwrappers) and every store where old instances persist.
28
+ - **Universal fix:** additive-only changes (add optional fields, never rename/retype/remove); carry an explicit version and read all live versions; readers tolerate unknown fields and ignore unknown discriminators.
29
+ - **Severity:** CRITICAL when the shape is persisted or replayed (the break is historical, not just live); HIGH for live-traffic shapes.
30
+
31
+ ## 3 · generated-artifact
32
+
33
+ A derived artifact must stay in sync with its source: ORM client from schema, types from codegen, lockfile from package.json, bundler aliases from tsconfig, seed SQL from a CSV. The artifact is trusted as if it were the source — when they drift, the artifact **lies** to everything that consumes it.
34
+
35
+ - **Ask:** what generates this, what does it generate, and would CI notice if they drifted?
36
+ - **Failure:** code compiles against stale types and mismatches at runtime; installs resolve a tree nobody tested; hand-edits are clobbered on the next generation.
37
+ - **Trace:** find the generator invocation (scripts, `prepack`, CI steps) and every consumer of the output; check whether generation is enforced or manual.
38
+ - **Universal fix:** regenerate in the same commit as the source change; CI-gate drift (`git diff --exit-code` after generate); never hand-edit generated output.
39
+ - **Severity:** HIGH by default; CRITICAL when the artifact governs installs or schema (lockfile, migrations).
40
+
41
+ ## 4 · dual-authority
42
+
43
+ Two independent writers own the same state: two migration tools targeting one database, a framework that owns its own tables, an app route table and an ingress rule, hand-written DDL and an ORM mirror. Each authority is internally consistent; the state they share is not.
44
+
45
+ - **Ask:** who else writes this — and does either writer know the other exists?
46
+ - **Failure:** one authority clobbers or drifts from the other; the loser's consumers break with no signal on the winner's side (a schema push drops the auth library's tables; login breaks).
47
+ - **Trace:** list every tool/config with write authority over the state (migration runners, CLIs, IaC, admin dashboards) and what each believes the state is.
48
+ - **Universal fix:** designate one canonical authority and make the others read-only mirrors reconciled from it — or byte-align them and CI-check the parity.
49
+ - **Severity:** CRITICAL when the shared state is persisted data or auth; HIGH otherwise.
50
+
51
+ ## 5 · config-elsewhere
52
+
53
+ A value is declared in one place and consumed in another — env vars, platform bindings, secret paths, ports, build-time defines. Reader and provider never reference each other statically, and the provider is often per-environment, so the break can exist in exactly one environment.
54
+
55
+ - **Ask:** for each value this code reads, where is it declared — in **every** environment, not just the one you can see?
56
+ - **Failure:** `undefined` at request time, boot failure in one env only, a stale build-time literal where a runtime value was expected, a secret baked into a public artifact.
57
+ - **Trace:** map each read (`process.env.X`, `env.BINDING`, `import.meta.env.Y`) to its declaration in every env file, deploy manifest, CI secret store, and dashboard.
58
+ - **Universal fix:** validate the full key set at boot and fail fast; keep a tracked example/contract file (`.env.example`); change reader and every provider in one commit.
59
+ - **Severity:** HIGH by default; CRITICAL when the flow direction is wrong (a secret reaching a client bundle) — that is also a trust-invariant break.
60
+
61
+ ## 6 · trust-invariant
62
+
63
+ An auth or isolation guarantee that **fails open**: tenancy scoping, RLS policies, cookie flags, CSRF defenses, redirect-URI allowlists, JWT audience checks, public-env prefixes. Unlike the other mechanisms, breaking it doesn't stop the system — everything keeps working, plus an attacker path or a cross-tenant leak.
64
+
65
+ - **Ask:** if this check were deleted, would anything visibly fail — or would the system happily serve data it shouldn't?
66
+ - **Failure:** silent grant or disclosure. There is no error to observe; tests that only exercise the happy path stay green by construction.
67
+ - **Trace:** find every enforcement point of the invariant and every path that bypasses it (new endpoints, new tables, raw queries, `SECURITY DEFINER`-style escape hatches, spoofable inputs like `req.body.tenantId`).
68
+ - **Universal fix:** enforce centrally (middleware, policy, schema default) so a new code path inherits the invariant instead of having to remember it; default-deny on any missing input.
69
+ - **Severity:** CRITICAL, categorically. Loud auth failures (everyone locked out) cap at HIGH; it's the quiet successes that are CRITICAL.
70
+
71
+ ## 7 · lifecycle-protocol
72
+
73
+ An ordering or sequencing agreement with a platform or across deploys: expand-then-contract migrations, deploy order between producer and consumer, graceful-shutdown draining, replay determinism, retry/idempotency budgets, timezone/DST assumptions, append-only version tags.
74
+
75
+ - **Ask:** does correctness depend on *when* or *in what order* this runs — and who else assumes that order?
76
+ - **Failure:** correct code applied in the wrong order: a migration that strands a rolling deploy, a retry that double-charges, a rename that orphans live stateful objects, a cron that fires on the wrong day twice a year.
77
+ - **Trace:** reconstruct the sequence the change requires (what must exist before what) and compare against how deploys actually roll out — including crash/retry paths.
78
+ - **Universal fix:** make each step independently safe (idempotent handlers, additive-first migrations, append-only version markers); many boundary violations dissolve by **reordering the plan**, not changing what ships.
79
+ - **Severity:** CRITICAL when a wrong order corrupts persisted state or duplicates money-adjacent side effects; HIGH otherwise.
80
+
81
+ ---
82
+
83
+ ## Domain contracts — stack-independent, always checked
84
+
85
+ Two contracts come from the product's information architecture, not from any technology, and are checked on every run:
86
+
87
+ - **entity-model — CRITICAL.** The entity vocabulary and topology (what the entities are, how they relate) that every module, migration, wire type, and doc agrees on. Re-relating or renaming an entity is a change to *every* mechanism at once. Sourced from `docs/architecture/` (domain model, ADRs), a contracts package, and the schema — never from a single module's view of it. Safe change: map every consumer before re-relating; coordinate schema + wire + client in one planned sequence.
88
+ - **tenancy — CRITICAL · trust-invariant.** The invariant that every request carries a non-spoofable tenant identity and all data access is scoped by it, at every layer. Its enforcement points are technology-specific (a JWT claim, a channel-name segment, an RLS policy, a scoped query helper) but the invariant is one contract — a single unscoped path anywhere breaks it.
89
+
90
+ ---
91
+
92
+ ## Severity recap
93
+
94
+ - **CRITICAL** — can corrupt persisted data, or open an auth/money path. Trust-invariants and persisted/replayed shapes live here.
95
+ - **HIGH** — silent cross-module breakage that ships green.
96
+ - **MEDIUM** — drift-prone coupling (string-matched, duplicated constants) not yet broken.
97
+ - **LOW** — smell. Loud, immediately-visible runtime breakage caps at HIGH; statically-guarded edges are out of scope entirely.
98
+
99
+ Severity is intrinsic to the finding, not curved: three genuine CRITICALs in a small repo is correct signal, not inflation.
@@ -0,0 +1,81 @@
1
+ # Pack: async jobs, messaging, realtime
2
+
3
+ Load when: the touch list exhibits BullMQ (`new Queue(`, `new Worker(`), Redis pub/sub or Streams (`publish(`, `xReadGroup(`), WebSockets / socket.io (`ws.send(`, `socket.emit(`), SSE (`text/event-stream`), realtime channel templates, or outbound webhook senders.
4
+
5
+ ## bullmq-job-payload-shape — HIGH · serialized-shape
6
+ **Contract:** BullMQ producer's `queue.add(name, data)` payload and the worker's `job.data` reads agree on a JSON shape with zero schema enforcement — and Redis holds in-flight jobs serialized in the OLD shape across any rolling deploy.
7
+ **Detect:** `queue.add(`, `job.data`, `new Queue(`, `new Worker(`, `**/queues/**`, `**/workers/**`, `flow.add(`
8
+ **Ships green, breaks:** Deploy new worker first → it dequeues jobs already sitting in `wait`/`delayed`/`prioritized` (plus every retry of an old `failed` job, which replays the ORIGINAL payload, possibly days later) with the old shape → `undefined` field reads, or worse: no crash, wrong values written. Deploy producer first → old workers process new-shape jobs. TypeScript can't help; the queue boundary erases types.
9
+ **Safe change:** Add fields optional-first, worker tolerates both shapes; deploy worker before producer; only remove old-shape handling after the queue AND the `failed` set drain (retries replay old payloads); consider a `v` field in `data` and branch on it.
10
+
11
+ ## bullmq-job-name-routing — HIGH · rendezvous-string
12
+ **Contract:** The first argument of `queue.add('email-welcome', …)` and the worker's `switch (job.name)` meet on a bare string literal; BullMQ workers receive EVERY job on the queue regardless of name — there is no named-processor registration (unlike Bull v3's `process('name', fn)`).
13
+ **Detect:** `queue.add('`, `job.name`, `switch (job.name)`, `case '`
14
+ **Ships green, breaks:** Rename either side and the compiler sees two unrelated literals. Worst variant: a `switch` without a throwing `default` — the processor falls through, returns `undefined`, and BullMQ marks the job **completed successfully**. No error, no failed job, no retry; the work silently never happens.
15
+ **Safe change:** Centralize job names in one shared const/enum imported by both sides; make the worker's `default` branch throw `UnrecoverableError` so unknown names surface in `failed`; on rename, worker accepts old+new names during the deploy window.
16
+
17
+ ## bullmq-repeatable-scheduler-identity — HIGH · rendezvous-string
18
+ **Contract:** A recurring job's identity is a string: legacy repeatables (`{ repeat: {…} }`, deprecated since BullMQ 5.16.0) derive it by **hashing the repeat options** (name, cron `pattern`, `every`, `tz`), while Job Schedulers use the explicit `schedulerId` passed to `upsertJobScheduler(schedulerId, repeatOpts, template)`.
19
+ **Detect:** `upsertJobScheduler(`, `removeJobScheduler(`, `repeat:`, `pattern:`, `every:`, `removeRepeatable(`, `removeRepeatableByKey(`, `getRepeatableJobs(`, `getJobSchedulers(`
20
+ **Ships green, breaks:** Legacy API: change the cron string (or tz, or job name) → the hash changes → a brand-new repeatable is registered while the OLD one keeps firing forever; `removeRepeatable(name, opts)` only works with the EXACT original options (else list with `getRepeatableJobs()` and use `removeRepeatableByKey`). Job Schedulers: re-upserting the SAME `schedulerId` updates in place — but changing the `schedulerId` string recreates the legacy problem. Registration is restart-time-only, so schedulers removed from code persist in Redis indefinitely.
21
+ **Safe change:** Use `upsertJobScheduler` with a stable, never-derived `schedulerId`; change cadence by re-upserting the same id, never by renaming; on any rename, explicitly `removeJobScheduler(oldId)`; audit live state with `getJobSchedulers()` + `getRepeatableJobs()` after deploy.
22
+
23
+ ## bullmq-lock-stall-redelivery — CRITICAL · lifecycle-protocol
24
+ **Contract:** The worker promises to finish (or keep renewing the lock on) a job within `lockDuration` (default 30000 ms, auto-renewed at `lockDuration/2` — renewal runs on the event loop); the queue promises not to hand the job to anyone else until then.
25
+ **Detect:** `lockDuration`, `lockRenewTime`, `stalledInterval`, `maxStalledCount`, `on('stalled'`, `concurrency:`
26
+ **Ships green, breaks:** Any CPU-bound section, sync I/O, or GC pause longer than the renewal window blocks lock renewal → the stalled-checker (every `stalledInterval`, default 30000 ms) moves the job back to `wait` → a second worker starts it **while the first is still running it**: emails sent twice, payments charged twice. No test catches this — it needs production-sized payloads. After `maxStalledCount` (default 1) the job fails with `job stalled more than allowable limit`.
27
+ **Safe change:** Size `lockDuration` above worst-case runtime when adding slow steps; keep the processor's event loop breathing (chunk CPU work, `await` regularly) or move heavy jobs to sandboxed processors (separate process = renewal survives CPU blocks); make side effects idempotent (dedup key per `job.id`); alert on the `stalled` event — it means double-processing happened.
28
+
29
+ ## bullmq-retry-retention-dlq — HIGH · lifecycle-protocol
30
+ **Contract:** `attempts`/`backoff` on the producer's job options promise the handler will be re-invoked with the same payload on failure; the handler must be idempotent to that promise; the `failed` set is the implicit dead-letter queue, and Redis retains completed/failed jobs per `removeOnComplete`/`removeOnFail`.
31
+ **Detect:** `attempts:`, `backoff:`, `removeOnComplete`, `removeOnFail`, `UnrecoverableError`, `on('failed'`, `defaultJobOptions`
32
+ **Ships green, breaks:** (1) Someone adds `attempts: 5` in producer options — the handler, written for exactly-once, now re-executes partial side effects up to 5×. (2) BullMQ's default keeps **all** completed and failed jobs forever — no `removeOnComplete`/`removeOnFail` means Redis grows until OOM weeks later (auto-removal is lazy: it runs only when a subsequent job finishes, so an idle queue never cleans up). (3) Exhausted retries land in `failed` — the de-facto DLQ nothing monitors; work is lost invisibly. Wrapping `UnrecoverableError` in a plain `Error` during a refactor silently re-enables retries.
33
+ **Safe change:** Set `defaultJobOptions: { removeOnComplete: {age, count}, removeOnFail: {age} }` on every queue; treat any `attempts > 1` change as an idempotency review of the handler; alert on `failed` depth (`getFailedCount()`); preserve `UnrecoverableError` semantics through error-wrapping refactors.
34
+
35
+ ## bullmq-queue-name-prefix-rendezvous — HIGH · rendezvous-string
36
+ **Contract:** Producer `new Queue(name, { prefix, connection })` and worker `new Worker(name, fn, { prefix, connection })` rendezvous on the Redis key `{prefix}:{name}:*` (default prefix `bull`) — same name, same prefix, same Redis logical DB, or they never meet.
37
+ **Detect:** `new Queue(`, `new Worker(`, `prefix:`, `QueueEvents(`, `db:` in redis connection opts, `{` hash-tag in prefix
38
+ **Ships green, breaks:** Any mismatch — name typo, one side adding `prefix: 'myapp'`, different `db` index, different Redis instance after a config refactor — and jobs pile up in `wait` forever while the worker idles at zero errors on both sides. Redis Cluster: the prefix must be a hash tag (`prefix: '{myqueue}'`) so all keys land in one slot; producer with `{tag}` + worker without = two disjoint key spaces.
39
+ **Safe change:** Define queue name + prefix once in a shared module; never inline the string; after any connection/prefix refactor, run a canary job end-to-end; on cluster, hash-tag the prefix identically everywhere including dashboards (bull-board with a different prefix shows an empty queue).
40
+
41
+ ## redis-pubsub-fire-and-forget — HIGH · rendezvous-string
42
+ **Contract:** `PUBLISH channel` and `SUBSCRIBE channel` / `PSUBSCRIBE pattern` meet on a literal channel string at one instant in time — Redis pub/sub has zero buffering, zero persistence, zero delivery guarantee, and `PUBLISH` returns a receiver-count nobody checks.
43
+ **Detect:** `.publish(`, `.subscribe(`, `.pSubscribe(`, `.psubscribe(`, `.sSubscribe(`, `duplicate()` on redis clients
44
+ **Ships green, breaks:** No subscriber connected (worker restarting, deploy gap) = message silently gone — `publish()` resolves fine, returning `0`, unchecked. Renaming a channel on one side, or editing a `PSUBSCRIBE` glob, silently partitions publisher from subscriber. Redis 7 cluster: plain `SUBSCRIBE` broadcasts cluster-wide but sharded `SSUBSCRIBE`/`SPUBLISH` route by slot — mixing `publish` with `sSubscribe` never meets. node-redis requires a dedicated (`.duplicate()`) connection in subscriber mode.
45
+ **Safe change:** Treat pub/sub as lossy signaling only — anything that must be processed goes to Streams or BullMQ; centralize channel names/patterns in shared constants; pair `publish`↔`subscribe` and `sPublish`↔`sSubscribe`, never cross.
46
+
47
+ ## redis-streams-group-offset-identity — CRITICAL · rendezvous-string
48
+ **Contract:** A consumer group's NAME is the durable owner of its offset (`last-delivered-id`) and its pending-entries list (PEL); consumers within it are named too, and un-acked messages belong to the consumer name that read them.
49
+ **Detect:** `xGroupCreate(`, `XGROUP CREATE`, `xReadGroup(`, `xAck(`, `xAutoClaim(`, `xClaim(`, `XGROUP DELCONSUMER`, `MKSTREAM`
50
+ **Ships green, breaks:** Rename the group in code → `XGROUP CREATE` mints a fresh group starting wherever the code says: `$` = silently SKIP everything undelivered (data loss), `0` = REPROCESS the entire retained stream (duplicate side effects) — and the old group's PEL messages are stranded forever. `XGROUP DELCONSUMER` **discards** that consumer's pending entries outright. `XADD` with `MAXLEN` trimming can delete messages a lagging group never consumed.
51
+ **Safe change:** Group names are permanent identifiers — never rename; migrate: create new group, drain old PEL via `XAUTOCLAIM` + ack, then `XGROUP DESTROY`; claim a dead consumer's PEL before any `DELCONSUMER`; monitor `XPENDING` depth and `XINFO GROUPS` lag; size `MAXLEN` against worst-case consumer downtime.
52
+
53
+ ## websocket-envelope-mixed-clients — HIGH · serialized-shape
54
+ **Contract:** Server and a MIXED-VERSION population of long-lived clients agree on the message envelope — the `{type, payload}` discriminator values and payload fields — with no per-message negotiation and no redeploy of clients: tabs stay open for days, mobile apps for weeks.
55
+ **Detect:** `JSON.parse(event.data)`, `ws.send(JSON.stringify(`, `switch (msg.type)`, `socket.emit('`, `socket.on('`, `**/ws/**`, `**/realtime/**`
56
+ **Ships green, breaks:** Server renames a `type` value or restructures `payload` → every already-connected old client either throws or — the silent case — hits a `switch` with no matched case and drops the message: the UI just stops updating, no error anywhere. socket.io event names are the same bare-literal rendezvous — a renamed event is silently never delivered. TypeScript types verify nothing about deployed clients.
57
+ **Safe change:** Message types are append-only — add new `type` values, never rename/repurpose; clients MUST ignore unknown types explicitly (with a metric, not a crash); payload fields additive-only until a protocol-version bump negotiated at connect; keep the server accepting old client messages for the longest realistic client lifetime, not the deploy window.
58
+
59
+ ## realtime-channel-template-tenant-scope — CRITICAL · trust-invariant
60
+ **Contract:** The channel-name template (`chat:{tenantId}:{sessionId}` — Durable-Object room names, Redis channels, socket.io rooms alike) is built independently by the publisher, the subscriber, AND the subscribe-authorization check; the tenant segment embedded in the string IS the isolation boundary.
61
+ **Detect:** `` `chat:${ ``, `` `${tenantId}:` ``, `.join(':')`, `split(':')`, `io.to(`, `socket.join(`, `idFromName(`, room/channel builder functions
62
+ **Ships green, breaks:** Two directions, both silent. (1) **Availability** — the publisher's template changes while subscribers build the old string: clients subscribe to a channel the server never publishes to; connects succeed, auth passes, zero messages, zero errors. (2) **Isolation** — drop or misorder the tenant segment, or interpolate an unvalidated value: a `sessionId`/`tenantId` containing `:` shifts segments so the string parses as a different tenant's channel — segment injection through the delimiter. `PSUBSCRIBE chat:*` grants cross-tenant reads in one line.
63
+ **Safe change:** One shared `buildChannelName()`/`parseChannelName()` module used by publish, subscribe, AND authz — never three inline template literals; validate segments against `[^:]+` before interpolation; authz parses the ACTUAL requested channel string, never trusts client-supplied segments; template changes are a versioned migration (dual-publish during rollout).
64
+
65
+ ## socketio-multinode-adapter — HIGH · config-elsewhere
66
+ **Contract:** `io.to(room).emit(…)` reaching ALL connected clients depends on every node running the same adapter (`@socket.io/redis-adapter` / `@socket.io/redis-streams-adapter`) against the same Redis — and HTTP long-polling fallback depends on load-balancer sticky sessions declared in infra config the app repo never sees.
67
+ **Detect:** `createAdapter(`, `@socket.io/redis-adapter`, `io.adapter(`, `io.to(`, `io.in(`, `transports:`, `allowEIO3`, LB config (`ip_hash`, `sessionAffinity`, ALB stickiness)
68
+ **Ships green, breaks:** Works perfectly with one node in dev. Scale to two nodes without the adapter: broadcasts only reach sockets on the emitting node — a random ~half of users silently miss every message. Sticky sessions missing: the polling handshake round-robins to a node that doesn't know the session → `400 Session ID unknown` → silent reconnect loops, presenting as "flaky realtime". A REST endpoint emitting via a bare `io` instance from a different process (no adapter) publishes to nobody.
69
+ **Safe change:** Wire the adapter before the second node exists; verify with a 2-node compose setup emitting cross-node; declare the sticky-session requirement next to the socket.io code or force `transports: ['websocket']` deliberately; treat `allowEIO3` removal as a client-population question, not a cleanup.
70
+
71
+ ## outbound-webhook-contract — CRITICAL · serialized-shape
72
+ **Contract:** The org's emitted webhooks — payload shape, event-type names, signature header name + algorithm, and the shared secret — are consumed by EXTERNAL subscribers you cannot redeploy, coordinate with, or even enumerate.
73
+ **Detect:** `createHmac(`, `timingSafeEqual(`, signature header literals (`X-*-Signature`, `X-Hub-Signature-256`), `event_type`, webhook dispatch tables, `WEBHOOK_SECRET`
74
+ **Ships green, breaks:** Everything here ships green because the other side is off-repo. Rename an event type: subscribers filter on the literal string and silently ignore the new one — their integrations just stop firing. Rotate the secret in one step: every delivery fails verification the instant the env var flips; if the sender retries-then-drops, events are permanently lost. Canonicalization drift is subtler: signing re-serialized JSON instead of the exact raw bytes sent (key order, whitespace, unicode escaping) makes verification fail only for some payloads.
75
+ **Safe change:** Event types and payload fields are append-only; breaking payload changes = a new version delivered per-subscriber, old version supported indefinitely; rotate secrets with an overlap window — sign with BOTH (Stripe/Svix `v1=sig1,v1=sig2` style) until subscribers confirm; sign the exact raw body bytes; include a signed timestamp; never repurpose the header name or algorithm prefix.
76
+
77
+ ## sse-resume-and-event-names — MED · lifecycle-protocol
78
+ **Contract:** Server SSE frames and browser `EventSource` agree on two things with no static link: the `id:` field ↔ `Last-Event-ID` resume protocol across auto-reconnects, and the `event:` field name ↔ `addEventListener(name)` dispatch.
79
+ **Detect:** `text/event-stream`, `Last-Event-ID`, `new EventSource(`, `res.write('id:`, `res.write('event:`, `onmessage`, `X-Accel-Buffering`
80
+ **Ships green, breaks:** (1) `EventSource` auto-reconnects invisibly and sends `Last-Event-ID`; a server that never assigned `id:` — or ignores the header — silently drops every event from the gap while the client believes it is live. (2) Adding an `event: order-update` field to previously-unnamed frames reroutes dispatch: `onmessage` fires ONLY for unnamed frames, so existing clients silently stop receiving everything. Infra trap: a buffering reverse proxy (nginx default) delays events unboundedly until `X-Accel-Buffering: no` is set.
81
+ **Safe change:** Assign monotonic `id:` from day one and implement replay from `Last-Event-ID` (or send an explicit `snapshot` event on connect); never add/rename the `event:` field on an existing stream; keep event names in a shared constant when the client is in-repo; verify proxy buffering headers whenever the route moves.
@@ -0,0 +1,93 @@
1
+ # Pack: identity, auth, tenancy
2
+
3
+ Load when: the touch list exhibits jose (`jwtVerify(`), Better Auth (`betterAuth(`, `auth.api.`), session cookies, CSRF middleware, OAuth callbacks/`redirect_uri`, tenant scoping (`tenantId`, `organizationId`), password hashing, or auth middleware mounts. This area skews CRITICAL: the defining hazard is a silent auth bypass or data disclosure, not a loud error.
4
+
5
+ ## jose-jwtverify-claims-opt-in — CRITICAL · trust-invariant
6
+ **Contract:** The token minter scopes tokens with `iss`/`aud`, and every verifier must pass matching `issuer`/`audience` options to `jwtVerify` — jose 6 checks signature, `exp`, `nbf` by default but NOT `iss`, `aud`, `sub`, or `typ` unless the option is passed.
7
+ **Detect:** `jwtVerify(` without `issuer:`/`audience:` in the same options object, `jwtVerify(token, secret)` two-arg calls, missing `algorithms:` where the key is an HMAC secret (`Uint8Array`), `clockTolerance`, `maxTokenAge`
8
+ **Ships green, breaks:** A valid token minted for a *different* service or audience by the same issuer/key verifies successfully — cross-service token replay; with a shared HMAC secret and no `algorithms: ['HS256']` allowlist, any HS* alg the secret supports is accepted. Everything compiles; the token "verifies".
9
+ **Safe change:** Always pass `{ issuer, audience, algorithms }` to `jwtVerify`; treat a bare two-arg call as a finding. Add `clockTolerance: '30s'` for cross-host skew. When adding a new consumer service, mint tokens with a distinct `aud` and enforce it there.
10
+
11
+ ## jwt-identity-keyed-on-iss-plus-sub — CRITICAL · trust-invariant
12
+ **Contract:** `sub` is unique only *per issuer* (RFC 7519); application user lookup must key on the pair `(iss, sub)`, never `sub` alone.
13
+ **Detect:** `payload.sub` used as a sole DB lookup key, multi-IdP config (multiple `issuer` values, per-tenant IdPs, `createRemoteJWKSet` called with more than one URL)
14
+ **Ships green, breaks:** With two identity providers (or per-tenant issuers), IdP B can mint `sub` values colliding with IdP A's users — a tenant-controlled IdP issues `sub: <victim-sub>` and logs in as the victim. Single-IdP tests never expose it.
15
+ **Safe change:** Store external identity as a composite `(issuer, subject)` unique key (mirror of Better Auth's `(providerId, accountId)` on `account`); grep every login path for `sub`-only lookups; adding a second IdP is the trigger event for this audit.
16
+
17
+ ## jose-remote-jwks-rotation-cache — HIGH · lifecycle-protocol
18
+ **Contract:** The IdP's key-rotation schedule and every verifier's `createRemoteJWKSet` cache must follow the publish-first protocol: jose 6 re-fetches the JWKS when a token's `kid` matches no cached key, but at most once per `cooldownDuration` (default 30000 ms), and refreshes at most every `cacheMaxAge` (default 600000 ms = 10 min).
19
+ **Detect:** `createRemoteJWKSet(`, `cooldownDuration`, `cacheMaxAge`, IdP rotation jobs, `JWKSNoMatchingKey`, `ERR_JWKS_NO_MATCHING_KEY`
20
+ **Ships green, breaks:** IdP starts signing with a new key *before* publishing it → every verifier throws `JWKSNoMatchingKey`, and the 30 s cooldown suppresses re-fetch so the outage comes in waves; removing the old key from the JWKS while its tokens are still live mass-invalidates sessions; each process instance caches independently, so rollouts fail partially.
21
+ **Safe change:** Rotate as: publish new key to JWKS → wait > `cacheMaxAge` → start signing with new `kid` → keep old key published until the longest-lived old token expires; never sign-then-publish; keep `kid` on every JWK and JWS header.
22
+
23
+ ## better-auth-schema-dual-authority — CRITICAL · dual-authority
24
+ **Contract:** Better Auth's CLI (`npx @better-auth/cli generate` / `migrate`) owns the shape of the `user`, `session`, `account`, `verification` tables (camelCase columns: `session.expiresAt` — not `expires` — `session.token`, `account.providerId`, `account.accountId`); your ORM migration tool is a second writer over the same tables.
25
+ **Detect:** `@better-auth/cli`, `drizzle-kit push`, `drizzle-kit generate`, auth tables present in DB but absent from `schema.ts`, `modelName`/`fields` remapping in `betterAuth(` config
26
+ **Ships green, breaks:** `drizzle-kit push` diffing against a schema file that lacks the auth tables will *drop them* (with `--force`/CI, no prompt) — every user, session, and OAuth link destroyed; adding a Better Auth plugin (admin, organization, twoFactor) without re-running `generate`/`migrate` leaves its columns (`impersonatedBy`, `role`, `banned`, `activeOrganizationId`) missing, so only that plugin's runtime paths error while core login still works.
27
+ **Safe change:** After any `betterAuth(` config or plugin change, re-run `npx @better-auth/cli generate` and diff into the ORM schema; keep auth tables in the ORM schema file so push never sees them as deleted; column renames only via `fields`/`modelName` config, never raw SQL.
28
+
29
+ ## better-auth-session-token-not-a-jwt — HIGH · serialized-shape
30
+ **Contract:** The `better-auth.session_token` cookie carries an opaque signed token (`<token>.<hmac>`) whose first half is the `session.token` DB row key — it is NOT a JWT access/refresh pair, and every other service must validate it by calling `auth.api.getSession({ headers })`, not by decoding.
31
+ **Detect:** `jwtVerify`/`decodeJwt`/`atob(` applied to the session cookie, `session.cookieCache`, `session_data`, `getSessionCookie(`
32
+ **Ships green, breaks:** A sibling service that "verifies" the cookie as a JWT fails for all users; with `session.cookieCache: { enabled: true, maxAge }` the session payload is served from the signed `session_data` cookie, so `revokeSession`/user-ban does NOT take effect until the cookie cache expires — logout/ban tests without cookieCache pass, production revocation lags. `getSessionCookie()` only checks cookie *existence*, never validity — using it as an auth gate is a bypass.
33
+ **Safe change:** Cross-service auth goes through `getSession` against the auth service (or add the `jwt` plugin and verify those JWTs with jose); if enabling `cookieCache`, bound `maxAge` by your revocation SLA; treat `getSessionCookie` as an optimistic-redirect hint only.
34
+
35
+ ## better-auth-cookie-name-rendezvous — HIGH · rendezvous-string
36
+ **Contract:** Every consumer that reads the session cookie by name (edge middleware, nginx, a second service) must compute the same name Better Auth writes: `{cookiePrefix}.session_token` (default prefix `better-auth`), which becomes `__Secure-better-auth.session_token` when cookies are secure (production / `advanced.useSecureCookies: true`).
37
+ **Detect:** literal `better-auth.session_token`, `__Secure-`, `advanced.cookiePrefix`, `advanced.crossSubDomainCookies`, `getSessionCookie(request, { cookieName`
38
+ **Ships green, breaks:** Code reading `cookies['better-auth.session_token']` works in dev (http, no prefix) and silently sees no cookie in production because the name is `__Secure-`-prefixed — everyone appears logged out on the consumer side only; changing `cookiePrefix` orphans every hardcoded reader. `crossSubDomainCookies` is **disabled by default** — a sibling subdomain service never receives the cookie until `advanced.crossSubDomainCookies: { enabled: true, domain }` is set.
39
+ **Safe change:** Never hardcode the cookie name — use `getSessionCookie(request, { cookieName, cookiePrefix })` with the same config as the server; when changing `cookiePrefix`/`crossSubDomainCookies`, grep all services for the old literal; test cookie reads under https/production mode, not just dev.
40
+
41
+ ## better-auth-trusted-origins-allowlist — CRITICAL · config-elsewhere
42
+ **Contract:** `trustedOrigins` (default: only the `baseURL` origin) is the single allowlist gating both the Origin-header CSRF check and every client-supplied `callbackURL`/redirect target.
43
+ **Detect:** `trustedOrigins`, wildcard entries `https://*.` / `**`, `callbackURL` passed from client code, new frontend domains not present in the auth config
44
+ **Ships green, breaks:** The loud half: a new frontend origin fails with 403s. The silent half: someone "fixes" it with `https://*.example.com` — now any subdomain (stale CNAME takeover, user-content subdomains, preview deploys) can drive login CSRF and receive `callbackURL` redirects carrying fresh sessions; `**` additionally crosses `/`. The wildcard ships green because every legitimate flow still works.
45
+ **Safe change:** Enumerate origins exactly; if wildcards are unavoidable, scope to a dedicated non-user-controllable parent domain; review `trustedOrigins` whenever a domain is added/retired.
46
+
47
+ ## session-cookie-attribute-downgrade — CRITICAL · trust-invariant
48
+ **Contract:** The session cookie's `HttpOnly` + `Secure` + `SameSite=Lax|Strict` + narrowest-possible `Domain` collectively are the CSRF/theft defense; any relaxation keeps login working while removing the guarantee.
49
+ **Detect:** `sameSite: 'none'`, `httpOnly: false`, `domain: '.` (parent-domain widening), `secure: false`, proxy configs rewriting `Set-Cookie`
50
+ **Ships green, breaks:** Switching to `SameSite=None` (to make an iframe work) silently re-enables classic CSRF on every state-changing endpoint unless a token defense exists; widening `Domain` to `.example.com` hands the session to every subdomain including compromised/user-content ones; `httpOnly: false` (added so JS can check "am I logged in") turns any XSS into full session theft. All ship green — the cookie still authenticates every legitimate request.
51
+ **Safe change:** Treat any diff touching cookie attributes as security review; `SameSite=None` requires a compensating CSRF token on the same PR; keep `Domain` host-only unless cross-subdomain is a deliberate product decision; expose login state via an endpoint, never by de-HttpOnly-ing the session cookie.
52
+
53
+ ## csrf-double-submit-lockstep — HIGH · rendezvous-string
54
+ **Contract:** In double-submit CSRF, the CSRF cookie must be JS-readable (NOT HttpOnly) while the session cookie must be HttpOnly, and the frontend's header name must match the backend's reader — axios defaults `xsrfCookieName: 'XSRF-TOKEN'` / `xsrfHeaderName: 'X-XSRF-TOKEN'`.
55
+ **Detect:** `XSRF-TOKEN`, `X-XSRF-TOKEN`, `xsrfCookieName`, `xsrfHeaderName`, `withXSRFToken`, CSRF middleware with a skip-when-absent branch, blanket `httpOnly: true` cookie sweeps
56
+ **Ships green, breaks:** The two cookies are routinely inverted: a "hardening" sweep sets HttpOnly on everything — the frontend can't read the CSRF cookie, and if the middleware validates only *when the header is present*, requests sail through with zero protection, silently; renaming the header on one side has the same fail-open effect. axios only auto-attaches the XSRF header same-origin unless `withXSRFToken` is set — cross-origin SPAs silently send no token.
57
+ **Safe change:** CSRF middleware must *reject* state-changing requests when the token is absent, never skip; pin cookie+header names in one shared constant consumed by both sides; assert in an integration test that a request without the header is refused.
58
+
59
+ ## oauth-redirect-uri-exact-match — CRITICAL · trust-invariant
60
+ **Contract:** The OAuth authorization-server allowlist and the client's `redirect_uri` agree by *exact string match*; `state` (and `nonce`/PKCE for OIDC) must be both generated and verified — the flow completes identically when verification is dropped.
61
+ **Detect:** `redirect_uri`, `startsWith(`/prefix/wildcard matching in redirect validation, `genericOAuth` plugin configs, hand-rolled `/callback` handlers, `state` generated but never compared, `code_verifier`/`code_challenge` present on only one leg
62
+ **Ships green, breaks:** Prefix or wildcard matching lets `https://app.example.com.evil.com` or a path-traversal callback receive the authorization code — code exfiltration, full account access; dropping the `state` comparison (or the PKCE `code_verifier` check) fails open: every legitimate login still works, but login-CSRF/code-injection is silently enabled. Better Auth's built-in providers handle this — the hazard is `genericOAuth` configs and hand-rolled flows.
63
+ **Safe change:** Register and compare `redirect_uri` byte-for-byte (scheme, host, port, path); generate `state` bound to the session and *reject* on mismatch or absence; require PKCE S256 end-to-end; test the negative paths (tampered state, missing verifier), not just the happy path.
64
+
65
+ ## account-linking-verified-email-only — CRITICAL · trust-invariant
66
+ **Contract:** External identities key on `(providerId, accountId)` (Better Auth's `account` table), and auto-linking a new OAuth identity to an existing user by email is safe only when the provider attests the email as verified — Better Auth's default links only on verified-email match; `trustedProviders` bypasses that check and `allowDifferentEmails: true` (default `false`) widens it further.
67
+ **Detect:** `account.accountLinking`, `trustedProviders`, `allowDifferentEmails`, custom provisioning doing `findUserByEmail` on OAuth callback, lookups keyed on profile email instead of `(providerId, accountId)`
68
+ **Ships green, breaks:** Adding a provider to `trustedProviders` (or hand-rolling link-by-email) means an attacker registers at that provider with the victim's email *unverified*, signs in, and is silently linked into the victim's account — full takeover; every legitimate login flow still works, so nothing surfaces in testing.
69
+ **Safe change:** Keep identity lookup on `(providerId, accountId)`; only list providers in `trustedProviders` that contractually verify emails; leave `allowDifferentEmails` false unless linking is an explicit user-initiated, authenticated action.
70
+
71
+ ## tenant-id-non-spoofable-source — CRITICAL · trust-invariant
72
+ **Contract:** The tenant identifier used to scope every query must come from a server-validated source — a verified JWT claim, the session (e.g. Better Auth organization plugin's `session.activeOrganizationId`), or server-resolved subdomain — never from `req.body`, `req.query`, or a client-set header; and every data access must actually include the scope.
73
+ **Detect:** `req.body.tenantId`, `req.query.tenantId`, `req.headers['x-tenant`, queries by bare PK in multi-tenant repos (`WHERE id = ?` without `tenant_id`/`organizationId`), new route files not using the shared tenant middleware
74
+ **Ships green, breaks:** Two failure modes, both silent: (1) tenant read from the request → any authenticated user sets another tenant's id and reads/writes their data; (2) the new-endpoint failure — a handler queries by UUID primary key alone, works perfectly in every test (ids are globally unique), and is a cross-tenant IDOR in production. Neither throws, lints, or fails a test.
75
+ **Safe change:** One `requireTenant` middleware derives `req.tenantId` from session/claim only — delete all request-sourced fallbacks; make the repository layer take `tenantId` as a required parameter so unscoped queries can't be expressed; review every new endpoint for the scope, not just the auth check.
76
+
77
+ ## express5-auth-middleware-mount-order — HIGH · lifecycle-protocol
78
+ **Contract:** In Express 5, `app.use(authMiddleware)` protects only routes and routers mounted *after* it in registration order, and per-identity rate limiting (`keyGenerator: req => req.user.id`) requires auth to have already run.
79
+ **Detect:** `app.use(` ordering in the composition root, router mounts above the auth `app.use`, `keyGenerator` referencing `req.user`, `app.use('*'` patterns (path-to-regexp v8: bare `*` is invalid — use `/*splat`)
80
+ **Ships green, breaks:** A new `app.use('/api/reports', reportsRouter)` line added above the auth mount ships green — every endpoint works in manual testing (the dev is logged in anyway) — but is fully unauthenticated; a rate limiter mounted before auth with an identity `keyGenerator` sees `req.user === undefined`, collapsing all clients into one shared `undefined` bucket: global lockout under load and per-user limits silently gone.
81
+ **Safe change:** Keep one composition root with the invariant order: parsers → auth → identity-keyed rate limit → routers → error handler; mount new routers only below the auth line; give identity-keyed limiters a hard failure (throw) when `req.user` is absent instead of a fallback key.
82
+
83
+ ## password-hash-format-migration — CRITICAL · serialized-shape
84
+ **Contract:** Stored password hashes (Better Auth default: scrypt, in `account.password` for `providerId: 'credential'`) are a persisted serialized format that every future `verify` implementation must still accept.
85
+ **Detect:** `emailAndPassword.password: { hash, verify }` overrides, argon2/bcrypt param changes, hash-format prefixes in `account.password` vs what `verify` parses, absence of rehash-on-login logic
86
+ **Ships green, breaks:** Swapping the algorithm (scrypt → argon2) or tightening params without a legacy path makes `verify` return `false` for every pre-existing user — no error is thrown, new signups work, fresh-user tests pass, and the user base is silently locked out over the following weeks as sessions expire.
87
+ **Safe change:** New `verify` must detect the old format by prefix and accept it; on successful legacy verify, rehash with the new params and update the row; keep the legacy branch until no old-format hashes remain (measure, don't guess); never bulk-invalidate.
88
+
89
+ ## impersonation-dual-identity-audit — HIGH · trust-invariant
90
+ **Contract:** With Better Auth's admin plugin, an impersonated session carries BOTH identities — `session.userId` is the target, `session.impersonatedBy` is the acting admin — and every audit/attribution consumer must read both; the impersonated session auto-expires after 1 hour by default (`impersonationSessionDuration`, seconds).
91
+ **Detect:** `impersonateUser`, `stopImpersonating`, `impersonatedBy`, `impersonationSessionDuration`, audit code reading only `session.userId`, session payloads forwarded downstream without `impersonatedBy`
92
+ **Ships green, breaks:** Audit logs, "last modified by" fields, and downstream services that record `session.userId` silently attribute admin actions to the *victim* user — the audit trail is wrong precisely when it matters; custom session serialization that drops `impersonatedBy` erases the distinction entirely. Impersonation itself works flawlessly, so nothing surfaces.
93
+ **Safe change:** Every audit write records `actorId = session.impersonatedBy ?? session.userId` plus `onBehalfOf` when they differ; propagate `impersonatedBy` in any session payload crossing a service boundary; after adding the admin plugin, re-run the Better Auth CLI so the `impersonatedBy` column exists (see schema dual-authority entry).
@@ -0,0 +1,93 @@
1
+ # Pack: package/workspace management, build config, CI, runtime process
2
+
3
+ Load when: the touch list exhibits `package.json` (deps, `exports`, scripts, `packageManager`), lockfiles, `.env*` files, tsconfig `paths`, Dockerfiles/compose, `.github/workflows/`, pino logger config, or Express servers behind proxies.
4
+
5
+ ## env-var-reader-vs-declaration — HIGH · config-elsewhere
6
+ **Contract:** Every `process.env.X` read in code must have X declared in `.env.example`, each `.env.*`, CI `env:`/secrets, and every deploy dashboard — the reader and the declarations share only the literal name.
7
+ **Detect:** `process.env\.[A-Z_]+`, `.env.example`, `.env*`, `env:` / `secrets\.` in `.github/workflows/*.yml`, `loadEnv(`, `dotenv`
8
+ **Ships green, breaks:** Undefined var is `undefined`, not an error — `|| defaultValue` fallbacks silently run prod against the default; env values are ALWAYS strings, so `if (process.env.FEATURE_ENABLED)` is truthy for `"false"` and `"0"`; dotenv loads only locally, so a var present in `.env` but absent from the deploy dashboard works on every laptop and fails only in prod.
9
+ **Safe change:** Add the key to `.env.example` + every deploy target BEFORE merging the reader; validate all env at boot with a zod schema and crash on missing/malformed (fail-fast, no silent defaults); parse booleans explicitly (`=== 'true'`); grep workflows and dashboards when renaming a key.
10
+
11
+ ## vite-client-env-prefix-gate — CRITICAL · trust-invariant
12
+ **Contract:** The `VITE_` prefix is the only gate deciding whether an env var is inlined verbatim into the public JS bundle; server secrets rely on NOT having the prefix.
13
+ **Detect:** `import.meta.env.VITE_`, `envPrefix` in `vite.config.*`, `define:` blocks, `VITE_` keys in `.env*`
14
+ **Ships green, breaks:** Prefixing a secret (`VITE_API_KEY`) ships it string-searchable in `dist/assets/*.js` with zero warning; `import.meta.env.VITE_*` is statically replaced at BUILD time — rotating the value in a deploy dashboard changes nothing until rebuild, and a build-once/promote-across-envs pipeline bakes the build env's values into every environment; widening `envPrefix` silently exposes every matching var; `define:` entries bypass the prefix gate entirely.
15
+ **Safe change:** Before adding `VITE_` to any var, confirm it is public; audit `dist/` with `grep -r "<secret>" dist/` after build; treat `envPrefix` and `define` changes as security-review items; never promote a client bundle across environments whose `VITE_` values differ.
16
+
17
+ ## lockfile-manifest-sync — HIGH · generated-artifact
18
+ **Contract:** `pnpm-lock.yaml` (or `package-lock.json`/`yarn.lock`) is a generated artifact that must move in the same commit as any `package.json` dependency, `overrides`/`resolutions`, or `pnpm.overrides` change.
19
+ **Detect:** `pnpm-lock.yaml`, `package-lock.json`, `yarn.lock` (more than one present = hazard), `"overrides"`, `"resolutions"`, `"pnpm": { "overrides"` in `package.json`
20
+ **Ships green, breaks:** Hand-editing `package.json` without running install works locally (bare `pnpm install` silently updates the lock) then fails CI with `ERR_PNPM_OUTDATED_LOCKFILE` — pnpm defaults `--frozen-lockfile` to true when `CI=true`; adding an `overrides` entry without re-locking means the vulnerable version stays pinned and installed while review shows the override "applied"; two lockfiles in one repo means detect-from-lockfile tooling picks nondeterministically and installs a different tree per machine; CI using `npm install` instead of `npm ci` silently rewrites resolutions.
21
+ **Safe change:** Run the detected manager's install and commit the lockfile with the manifest change; keep exactly one lockfile (delete strays); CI installs use `pnpm install --frozen-lockfile` / `npm ci` / `yarn install --immutable`; after any `overrides` change, verify the resolved version with `pnpm why <pkg>`.
22
+
23
+ ## pnpm-strict-layout-phantom-imports — HIGH · rendezvous-string
24
+ **Contract:** Every module specifier imported by a package must appear in THAT package's `dependencies` — the import string and the manifest meet only through node_modules layout, and pnpm's symlinked layout exposes only declared deps.
25
+ **Detect:** `import .* from ['"]` specifiers absent from the nearest `package.json` `dependencies`, `workspace:` protocol entries, `.npmrc` `public-hoist-pattern`/`shamefully-hoist`, `node-linker`
26
+ **Ships green, breaks:** An undeclared transitive import (phantom dep) typechecks (types hoisted or ambient) and works under npm's flat hoisting, then throws `ERR_MODULE_NOT_FOUND` at runtime under pnpm — or vice versa when a repo migrates managers; a cross-package workspace import without a `workspace:*` dep entry works via stale symlinks locally and breaks on fresh CI install; relying on `shamefully-hoist=true` masks all of this until someone removes it.
27
+ **Safe change:** Declare every imported package in the importer's own `package.json` (workspace siblings as `"@nurix/x": "workspace:*"`); never widen `public-hoist-pattern` to fix a missing-module error — add the dep; verify with a fresh `pnpm install` on a clean clone.
28
+
29
+ ## package-exports-subpath-map — HIGH · rendezvous-string
30
+ **Contract:** Consumers' import specifiers (`@nurix/components/button`) must match literal keys in the package's `exports` map; the `files` allowlist and `types` conditions must cover the same paths.
31
+ **Detect:** `"exports"` and `"files"` in `package.json`, `from ['"]@nurix/[^'"]+/`, `"moduleResolution"` in `tsconfig.json`
32
+ **Ships green, breaks:** Any subpath not mapped in `exports` throws `ERR_PACKAGE_PATH_NOT_EXPORTED` at runtime — and tsc under `moduleResolution: "node10"` ignores `exports` entirely, so types resolve while Node throws (use `"bundler"`/`"nodenext"` to make tsc see the real contract); adding a new component file without an `exports` entry publishes fine and breaks every consumer import; a built `dist/` path referenced by `exports` but missing from the `files` allowlist installs cleanly and fails only on first import of the published tarball.
33
+ **Safe change:** Add the `exports` subpath (with `types` condition first in each condition block) in the same change as the new file; confirm `files` includes everything `exports` references; check the shipped surface with `npm pack --dry-run`; treat removing/renaming an exports key as a breaking change for all consumers.
34
+
35
+ ## private-registry-auth-split — HIGH · config-elsewhere
36
+ **Contract:** `@nurix/*` installs authenticate via an ambient read token in `~/.npmrc` locally and via `NODE_AUTH_TOKEN` + `setup-node`'s `registry-url` in CI — two disconnected declarations of the same credential.
37
+ **Detect:** `@nurix:registry=`, `_authToken` in `.npmrc`, `registry-url:` in workflows, `NODE_AUTH_TOKEN` in step `env:`
38
+ **Ships green, breaks:** The npm registry returns **404 (not 401)** for restricted packages without valid auth — CI failures read as "package doesn't exist," which tempts agents into substituting public packages (forbidden here); `setup-node` with `registry-url` writes an `.npmrc` referencing `${NODE_AUTH_TOKEN}` that is resolved at INSTALL time, so the token must be in the `env:` of the step running `pnpm install`/`npm publish`, not on the setup-node step; local installs succeed off `~/.npmrc` so the CI gap is invisible until the pipeline runs.
39
+ **Safe change:** In CI, pair `setup-node` `registry-url: https://registry.npmjs.org` with `NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}` on every step touching the registry; on a 404 for `@nurix/*`, diagnose auth first — never swap packages; keep `publishConfig.access: restricted` on every `@nurix` package.
40
+
41
+ ## publish-pipeline-artifact-and-loop — HIGH · lifecycle-protocol
42
+ **Contract:** CI publish sequencing: `prepack`/`prepublishOnly` builds the shipped artifact, the version-bump commit carries `[skip ci]`, `npm version` runs `--no-git-tag-version` in monorepo subdirs, and workspace deps are rewritten at pack time.
43
+ **Detect:** `"prepack"`, `"prepublishOnly"` in `package.json`, `npm version` in workflows, `[skip ci]` in bump-commit messages, `workspace:` in `dependencies`
44
+ **Ships green, breaks:** No `prepack` → the published tarball carries whatever stale `dist/` was on the runner (or none, silently trimmed by `files`); a bump commit without `[skip ci]` pushed to the trigger branch re-fires the publish workflow — infinite loop or a duplicate publish dying with `E403 cannot publish over previously published version`; `npm version` in a subdirectory of the git root silently skips the commit+tag step, so tag automation quietly stops; publishing a pnpm workspace package with `npm publish` ships literal `workspace:*` in dependencies — every consumer install fails with `EUNSUPPORTEDPROTOCOL` (only `pnpm publish`/`pnpm pack` rewrites the protocol to real versions).
45
+ **Safe change:** Build in `prepack` so published always matches source; `npm version --no-git-tag-version`, then commit with `[skip ci]` and tag explicitly (`git push origin HEAD:<ref> --follow-tags`); always publish pnpm workspaces with `pnpm publish`; verify content with `npm pack --dry-run` in CI before publish.
46
+
47
+ ## tsconfig-paths-vs-bundler-alias — HIGH · dual-authority
48
+ **Contract:** tsconfig `compilerOptions.paths` (what the typechecker resolves) and Vite `resolve.alias` (what the bundle resolves) independently define the same aliases and must stay identical.
49
+ **Detect:** `"paths"` in `tsconfig*.json`, `resolve:` `alias` in `vite.config.*`, `vite-tsconfig-paths`
50
+ **Ships green, breaks:** `paths` never rewrites emitted JS — code compiled with tsc alone typechecks, then Node throws `ERR_MODULE_NOT_FOUND` on the alias at runtime; Vite does NOT read tsconfig `paths` (needs `vite-tsconfig-paths` or a duplicated `resolve.alias`), so an alias added on one side only means typecheck passes while the build/tests resolve a different file — worst case the two point at different targets (`src/` vs `dist/`) and the code you typechecked is not the code you shipped; the same drift applies to Vitest, which uses Vite resolution, not tsc.
51
+ **Safe change:** Single-source aliases via `vite-tsconfig-paths` (tsconfig as authority) or a script that derives one from the other; when adding an alias, update both in one commit and run both `tsc --noEmit` and `vite build`; for Node-run (unbundled) code, avoid `paths` entirely — use package subpath `imports` (`#alias`) which both sides honor.
52
+
53
+ ## corepack-packagemanager-version-drift — MED · config-elsewhere
54
+ **Contract:** `packageManager` and `engines` in `package.json` declare the pnpm/Node versions, but the versions actually running in CI, Docker, and laptops are set independently (setup-node/action-setup inputs, `FROM node:` tags, whatever's installed).
55
+ **Detect:** `"packageManager"`, `"engines"` in `package.json`, `corepack enable`, `node-version:` in workflows, `FROM node:` in Dockerfiles, `engine-strict` in `.npmrc`
56
+ **Ships green, breaks:** `packageManager` is enforced ONLY when corepack is active — Node 22 bundles corepack disabled by default and newer Node lines drop it from the distribution, and `setup-node` never enables it — so a dev on an older pnpm regenerates the committed lockfile at a different `lockfileVersion` (wholesale churn, then `ERR_PNPM_OUTDATED_LOCKFILE` in CI); npm ignores `engines` entirely unless `engine-strict=true`, so a newer-Node-only syntax feature passes local dev and crashes the Node-22 Docker runtime; `pnpm/action-setup` errors on "Multiple versions of pnpm specified" when its `version` input contradicts `packageManager`.
57
+ **Safe change:** Treat `packageManager` as the single source: `corepack enable` in Dockerfiles/dev setup, omit `version` from `pnpm/action-setup` so it reads the field; bump Node in `engines`, workflows `node-version`, and `FROM node:` together; set `engine-strict=true` in `.npmrc` to make drift loud.
58
+
59
+ ## gha-workflow-repo-rendezvous — HIGH · rendezvous-string
60
+ **Contract:** Workflows reference repo internals purely by literal strings — npm script names in `run:`, secrets/vars by name, `paths:` filters by directory — with no static link to what they name.
61
+ **Detect:** `run: (npm|pnpm|yarn) (run )?[a-z:-]+` in `.github/workflows/*.yml`, `secrets\.[A-Z_]+`, `vars\.[A-Z_]+`, `paths:` / `paths-ignore:` filters
62
+ **Ships green, breaks:** A referenced secret that doesn't exist interpolates to **empty string with no error** — deploys run with a blank API key and publish steps fail with a misleading 404/403 far downstream; renaming a `package.json` script breaks every `run:` line only when that workflow next executes (possibly a rarely-fired release workflow, weeks later); moving `packages/cli/` to a new path while the publish workflow filters `paths: ['packages/cli/**']` means the publish job silently never triggers again — the package just stops shipping; `node-version` drifting from `engines`/Dockerfile builds with a different Node than production runs.
63
+ **Safe change:** When renaming scripts, secrets, or moving package dirs, grep `.github/workflows/` for the old literal; assert required secrets non-empty at job start (`if [ -z "$TOKEN" ]; then exit 1; fi`); after moving a path-filtered directory, push a trivial change inside it and confirm the workflow fires.
64
+
65
+ ## docker-arg-env-boundary — CRITICAL · config-elsewhere
66
+ **Contract:** Dockerfile `ARG` exists only at build time and `ENV` persists into the running container; app code reading `process.env.X` at runtime sees only `ENV` (or orchestrator-injected) values, and both are recorded in inspectable image metadata.
67
+ **Detect:** `^ARG `, `^ENV ` in Dockerfiles, `--build-arg` in workflows/compose, `_authToken`/`NPM_TOKEN`/`NODE_AUTH_TOKEN` near `RUN.*install`
68
+ **Ships green, breaks:** A value declared only as `ARG` builds fine but is `undefined` in `process.env` at runtime — config appears set in the Dockerfile yet the app runs on defaults; any `ARG` consumed by a `RUN` (e.g. `--build-arg NPM_TOKEN=` for the `@nurix` install) is permanently visible in `docker history` of that stage, and secrets put in `ENV` are readable via `docker inspect` by anyone who can pull the image; multi-stage copies quietly drop `ENV` set in earlier stages.
69
+ **Safe change:** Runtime config via `ENV` or orchestrator env, never bare `ARG`; registry tokens via BuildKit `RUN --mount=type=secret,id=npmrc,target=/root/.npmrc pnpm install` (never `--build-arg`); audit with `docker history --no-trunc` and `docker inspect` before pushing an image containing credentials.
70
+
71
+ ## pid1-signal-delivery — HIGH · lifecycle-protocol
72
+ **Contract:** The container's PID 1 must actually receive SIGTERM for the app's graceful-shutdown handler (`server.close()`, draining) to run before the orchestrator's SIGKILL.
73
+ **Detect:** shell-form `ENTRYPOINT node ...` / `CMD npm start` (no JSON array) in Dockerfiles, `CMD ["npm"`, absence of `process.on('SIGTERM'`, `init:` in compose, `--init`
74
+ **Ships green, breaks:** Shell-form ENTRYPOINT/CMD makes `/bin/sh -c` PID 1, and sh does not forward SIGTERM to the node child — `docker stop` waits the default 10s then SIGKILLs, so shutdown hooks never run and in-flight requests are dropped on every single deploy while everything looks healthy; `CMD ["npm","start"]` has the same defect (npm does not reliably forward signals to its child); the failure is invisible in logs because the process dies before it can log.
75
+ **Safe change:** Exec form running node directly: `ENTRYPOINT ["node","dist/index.js"]`; register `SIGTERM`/`SIGINT` handlers that `server.close()` and exit; add `--init`/tini (or compose `init: true`) for zombie reaping; verify with `docker stop` and confirm the shutdown log line appears in under 10s.
76
+
77
+ ## pino-redact-and-field-name-rendezvous — CRITICAL · rendezvous-string
78
+ **Contract:** pino `redact` paths, serializer keys, and downstream dashboard/alert queries all pin the EXACT field names your code passes to the logger — nothing checks they still match.
79
+ **Detect:** `redact:` in pino init, `stdSerializers`, `errorKey`, `logger\.(info|error|warn)\(`, field names referenced in Grafana/Datadog/alert configs
80
+ **Ships green, breaks:** `redact: ['req.headers.authorization']` stops matching the moment the field is renamed or re-nested — the secret/PII logs in cleartext with zero warning (fast-redact wildcards are single-level `*` only, no deep `**`; hyphenated keys need bracket notation `'req.headers["x-api-key"]'`); pino 10 serializes an Error only when it sits under the configured `errorKey` (default `err`) — `logger.error({ error: e })` skips `stdSerializers.err` and logs `{}` because Error properties are non-enumerable, silently destroying stack traces; renaming a logged field (`userId` → `user_id`) blanks every dashboard panel and alert keyed on the old name without firing anything.
81
+ **Safe change:** Treat logged field names as a public schema — grep `redact` config and dashboard/alert definitions before renaming; always log errors as `{ err }` (or set `errorKey` and use it consistently); after changing redact paths, assert on a captured log line that the secret is `[Redacted]`.
82
+
83
+ ## express-trust-proxy-hop-count — CRITICAL · trust-invariant
84
+ **Contract:** `app.set('trust proxy', n)` must equal the real number of trusted proxy hops (Cloudflare, LB) so `req.ip`/`req.secure` derive from headers only as far as infrastructure you control actually sets them.
85
+ **Detect:** `trust proxy` in server setup, `X-Forwarded-For` / `CF-Connecting-IP` / `X-Forwarded-Proto` reads, `express-rate-limit` config, `req\.ip`, `req\.secure`
86
+ **Ships green, breaks:** Default (`false`) behind Cloudflare: `req.ip` is the CF edge IP for every request — rate limits collapse onto one bucket throttling all users, and IP audit logs are garbage; `trust proxy: true`: `req.ip` becomes the LEFTMOST `X-Forwarded-For` entry, which the client controls — rate-limit and IP-allowlist bypass by header injection; with trust proxy unset, `req.secure` is always false behind TLS-terminating Cloudflare, so an https-redirect middleware loops infinitely.
87
+ **Safe change:** Set the hop count as a number (`app.set('trust proxy', 1)` for Cloudflare directly in front; +1 per additional LB); prefer `CF-Connecting-IP` for client identity when Cloudflare is guaranteed in path; verify with a test endpoint echoing `req.ip` from a known client; re-check the number whenever a proxy layer is added or removed.
88
+
89
+ ## port-and-health-rendezvous — MED · rendezvous-string
90
+ **Contract:** The port in `listen(PORT)`, Dockerfile `EXPOSE`, compose/K8s `ports:`, and the healthcheck URL+path all name the same endpoint from four disconnected files.
91
+ **Detect:** `\.listen\(`, `EXPOSE` in Dockerfiles, `ports:` / `healthcheck:` in compose, `HEALTHCHECK`, health route paths (`/health`, `/healthz`, `/ready`) in app routes vs infra config
92
+ **Ships green, breaks:** `EXPOSE` is pure documentation — changing the app port and updating only `EXPOSE` leaves the compose `ports:` mapping and healthcheck pointing at the old port (connection refused only in the deployed environment); a healthcheck probing `/health` when the app serves `/healthz` marks the container unhealthy → restart loop while the app itself is fine; `listen(PORT, '127.0.0.1')` inside a container is unreachable through the port mapping (must bind `0.0.0.0`/default), which works in local dev and fails only containerized.
93
+ **Safe change:** Single-source the port as `PORT` env consumed by app, compose, and healthcheck alike; when changing port or health path, grep Dockerfile, compose, K8s manifests, and proxy config for the old literal; never bind loopback in a container; after deploy, watch one full healthcheck interval pass green.