voidmachine 5.0.8 → 7.0.0

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 (38) hide show
  1. package/core-assets/.claude-plugin/plugin.json +13 -2
  2. package/core-assets/PHILOSOPHY.md +29 -6
  3. package/core-assets/data/catalog.v3.json +52 -132
  4. package/core-assets/data/certification.json +1 -1
  5. package/core-assets/data/model.json +51 -75
  6. package/core-assets/hooks/_syntax-worker.cjs +1 -1
  7. package/core-assets/hooks/_void-hook.mjs +730 -515
  8. package/core-assets/hooks/outcome-meter.sh +2 -1
  9. package/core-assets/hooks/tdd-guard.md +45 -88
  10. package/core-assets/hooks/viewport-screenshot-gate.sh +3 -3
  11. package/core-assets/packs/pack-monorepo/skills/void-dependency-direction/SKILL.md +27 -146
  12. package/core-assets/packs/pack-monorepo/skills/void-service-package/SKILL.md +5 -8
  13. package/core-assets/packs/pack-server/skills/void-webhook-handler-pattern/.source +4 -2
  14. package/core-assets/packs/pack-server/skills/void-webhook-handler-pattern/SKILL.md +32 -132
  15. package/core-assets/skills/void-accessibility/SKILL.md +2 -2
  16. package/core-assets/skills/void-async-safety/SKILL.md +37 -24
  17. package/core-assets/skills/void-autopilot/SKILL.md +7 -6
  18. package/core-assets/skills/void-brainstorm/SKILL.md +40 -61
  19. package/core-assets/skills/void-code-review/SKILL.md +24 -11
  20. package/core-assets/skills/void-commit-discipline/SKILL.md +13 -20
  21. package/core-assets/skills/void-debug/SKILL.md +9 -10
  22. package/core-assets/skills/void-domain-driven-design/SKILL.md +1 -1
  23. package/core-assets/skills/void-frontend-design/SKILL.md +3 -3
  24. package/core-assets/skills/void-hexagonal-architecture/SKILL.md +5 -5
  25. package/core-assets/skills/void-implement/SKILL.md +67 -26
  26. package/core-assets/skills/void-llm-cost-discipline/SKILL.md +3 -0
  27. package/core-assets/skills/void-observability/SKILL.md +4 -4
  28. package/core-assets/skills/void-orchestrate/SKILL.md +91 -221
  29. package/core-assets/skills/void-plan/SKILL.md +12 -17
  30. package/core-assets/skills/void-qa/SKILL.md +15 -14
  31. package/core-assets/skills/void-tdd/SKILL.md +33 -84
  32. package/core-assets/skills/void-testing/SKILL.md +5 -5
  33. package/core-assets/skills/void-ticket/SKILL.md +2 -0
  34. package/core-assets/skills/void-typescript-strict/SKILL.md +5 -5
  35. package/core-assets/skills/void-verify/SKILL.md +57 -159
  36. package/dist/main.js +1959 -1633
  37. package/package.json +3 -3
  38. package/core-assets/hooks/block-protected-push.sh +0 -81
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env bash
2
- # Non-blocking adapter for PostToolUse and Stop canonical events.
2
+ # PostToolUse outcomes and repeated-read advice share this non-blocking process.
3
+ # PostToolUseFailure resets advice only; Stop retains its canonical event.
3
4
  set -uo pipefail
4
5
 
5
6
  ROOT="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
@@ -1,90 +1,47 @@
1
1
  # `tdd-guard` hook
2
2
 
3
- PreToolUse hook on `Edit` / `Write`. Materializes the `void-tdd` skill's Iron Law mechanically: blocks edits to production paths that add behavior without a corresponding test change in the same staged change set, unless a legitimate bypass applies.
4
-
5
- In `strict` mode: blocks (exit 1).
6
- In `souple` mode: warns (exit 2).
7
- In `exploratory` mode: no-op (exit 0).
8
-
9
- See `docs/plans/skill-audits/void-tdd.md` for the Iron Law and the bypass rationale. See Section 0bis.3 of `docs/plans/2026-05-29-void-harness-design.md` for the list of legitimate bypasses.
10
-
11
- ## Configuration
12
-
13
- Read from `.void/config.json` of the consumer project:
14
-
15
- ```json
16
- {
17
- "modes": { "tdd": "auto" },
18
- "paths": {
19
- "business": "apps/*/src/**",
20
- "tests": "apps/*/src/**/*.test.{ts,tsx}",
21
- "spikes": "apps/*/scripts/spike-*"
22
- }
23
- }
24
- ```
25
-
26
- If `.void/config.json` is absent, sensible defaults are used (see hook source).
27
-
28
- ## Bypasses applied (in order)
29
-
30
- 1. Doc-only changes (`.md`, `.mdx`, `.txt`, files under `docs/`)
31
- 2. Config and build files (`package.json`, `tsconfig.json`, `vitest.config.*`, `next.config.*`, `tailwind.config.*`, `drizzle.config.*`, `biome.json`, `eslint.config.*`)
32
- 3. Test fixtures and seed data (`tests/fixtures/**`, `**/__tests__/fixtures/**`, `**/seed/**`)
33
- 4. DB migrations (`**/migrations/**`, `drizzle/meta/**`) — covered by `void-migrations` skill
34
- 5. Spike paths (consumer-defined; default `apps/*/scripts/spike-*`)
35
- 6. Codemods (`**/codemods/**`)
36
- 7. Type-only changes (`*.d.ts`)
37
- 8. Generated code (`**/__generated__/**` or `@generated` marker in the first 3 lines)
38
- 9. The test file itself (`*.test.ts`, `*.test.tsx`, `*.spec.ts`, `*.spec.tsx`)
39
- 10. Per-file exploratory marker (`// tdd-mode: exploratory` in the first 5 lines)
40
-
41
- ## Content bypass: a module that only re-exports
42
-
43
- The ten bypasses above are properties of the **path**. One is a property of the **content**: a module whose every top-level statement is a re-export (`export … from`, `export *`, `export {}`) carries no behaviour, so the test one would write for it would assert that an export exists, which the compiler already proves. A comment, a `'use client'` directive and a type-only import do not break the detection; anything else does.
44
-
45
- Both the file as it stands and the fragment being written must hold, so a barrel that gains one line of logic is covered again on that same edit. Malformed content (an unterminated string or block comment) is undecidable and stays covered.
46
-
47
- Without it the hook blocked a one-line barrel in a consumer where 51 barrels out of 51 have no sibling test by convention, and the edit went out through `sed`, outside the traced tools: the guard did not protect, and it pushed the work out of its own field of vision.
48
-
49
- ## Pure-deletion bypass
50
-
51
- If the staged diff for the file contains only deletions (no `+` non-marker lines), the hook allows. Pure deletion does not require a new failing test.
52
-
53
- ## Override
54
-
55
- - Per-file: add `// tdd-mode: exploratory` (or `strict` / `souple`) at the top of the file.
56
- - Per-repo: set `.void/config.json` `modes.tdd` to `strict` / `souple` / `exploratory`.
57
- - Per-session: set env `VOIDCORP_MODE_TDD=souple` (not yet wired — open question).
58
-
59
- ## Inputs (env vars set by the runner)
60
-
61
- - `VOIDCORP_HOOK_FILE` — absolute path of the file
62
- - `VOIDCORP_HOOK_TOOL` — `Edit` / `Write`
63
- - `VOIDCORP_HOOK_PHASE` — `pre` / `post` (this hook only runs on `pre`)
64
- - `VOIDCORP_HOOK_DIFF` — optional, the staged diff content
65
- - `VOIDCORP_CONFIG` — optional, defaults to `.void/config.json`
66
-
67
- ## Exit codes
68
-
69
- - `0` — allow
70
- - `1` — block (message on stderr)
71
- - `2` — warn (allow, message on stderr)
72
-
73
- ## Test fixtures (for `test/tdd-guard/`)
74
-
75
- The skill test suite must cover at least:
76
-
77
- - `strict` mode + production edit without sibling test → block
78
- - `strict` mode + production edit WITH sibling test in same staged set → allow
79
- - `souple` mode + production edit without sibling test → warn
80
- - `exploratory` mode + any edit → allow
81
- - Each of the 10 bypasses → allow
82
- - Pure deletion → allow
83
- - Override marker `// tdd-mode: exploratory` in file → allow
84
-
85
- ## Open questions
86
-
87
- - Per-session env override (`VOIDCORP_MODE_TDD`) — wire it up in the hook or rely on `.void/config.json` only? Lean wire (faster iteration during work).
88
- - Glob matching for `paths.business` — current `case` statement is shell glob, fine for simple patterns but not full minimatch. Defer until a consumer needs `**` patterns more complex than `apps/*/src/**`.
89
- - Auto-detection of the test runner (vitest vs jest) for the sibling-test naming convention. Currently assumes `.test.ts(x)`. Make configurable in Phase D.
90
- - Performance — `git diff --cached --name-only` runs every edit. Cache for the duration of a session? Defer; measure first.
3
+ The native PreToolUse manifests invoke the shared Node `tdd-order` rule for
4
+ supported structured writes. `tdd-guard.sh` is its compatibility adapter.
5
+ `packages/hook-runner/src/rules/tdd-order.ts` owns the structural rule and
6
+ `enforcement/runner.ts` resolves the proposed file and evidence.
7
+
8
+ This floor checks that a governed production file has an existing sibling test,
9
+ or a valid first-line `// tdd-cover: e2e <project-relative spec>` declaration pointing
10
+ to an existing test inside the project. It does **not** run that test, require a
11
+ staged test modification, assess behavior coverage, or prove an observed RED.
12
+ `void-tdd` and `void-debug` own the observed RED-before-fix/GREEN evidence.
13
+
14
+ ## Configuration and scope
15
+
16
+ The rule reads `.void/config.json`: `modes.tdd`, `paths.business` and `paths.spikes`.
17
+ The default business glob is `apps/*/src/**`; the default spike glob is
18
+ `apps/*/scripts/spike-*`. The first five existing lines may declare
19
+ `// tdd-mode: strict`, `souple` or `exploratory`.
20
+
21
+ - `auto` and `strict` refuse a governed write with no test evidence.
22
+ - `souple` allows it with a warning.
23
+ - `exploratory` allows it.
24
+
25
+ Only the supported JS/TS production paths inside business globs are governed.
26
+ The owning rule excludes test/declaration files, docs, fixtures, seeds, migrations,
27
+ codemods, generated paths and configured spikes. This is a path floor, not a
28
+ semantic classifier of all configuration or generated code.
29
+
30
+ A complete file deletion is exempt. A deletion-only edit to a surviving file is
31
+ still checked. A module containing only re-exports is exempt only when both the
32
+ original and complete proposed source meet that condition. Adding behavior removes
33
+ the exemption. Unresolvable evidence on a governed file is refused, not guessed.
34
+
35
+ ## Runtime results
36
+
37
+ The rule returns a verdict. Runtime adapters translate a refusal into Claude's
38
+ blocking exit 2 or Codex's native deny response; warning and allow are successful
39
+ responses. There are no `VOIDCORP_HOOK_FILE`, staged-diff or per-session TDD env
40
+ inputs to this native contract. Configured does not mean invoked: the runtime must
41
+ load and trust its hooks. Shell writes do not provide this test-content guarantee.
42
+
43
+ ## Proof
44
+
45
+ The rule and enforcement tests cover sibling/declaration existence, modes,
46
+ re-export detection, path escapes, deletion-only edits and unsupported evidence.
47
+ Behavioral RED/GREEN for the changed application remains a separate required proof.
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bash
2
- # viewport-screenshot-gate — pre-push hook
2
+ # viewport-screenshot-gate — optional PR-text advisory (not installed in native manifests)
3
3
  #
4
- # Warns if a PR touching UI code lacks both mobile and desktop screenshots
4
+ # Reports if PR text for UI changes lacks mobile and desktop capture references
5
5
  # in the PR body. Composed with the `accessibility` and
6
6
  # `frontend-design` skills (mobile-first dual-quality invariant).
7
7
  #
@@ -13,7 +13,7 @@
13
13
  # "no-ui-change: <reason>" in the body for non-UI PRs that happen to
14
14
  # touch tsx files.
15
15
  #
16
- # Exit codes: 0 allow, 2 warn.
16
+ # No image validation. Missing PR/base is skipped. Exit 0 no finding, 2 advisory finding.
17
17
 
18
18
  set -euo pipefail
19
19
 
@@ -1,160 +1,41 @@
1
1
  ---
2
2
  name: void-dependency-direction
3
- description: Enforce the @repo/* import direction in a Turborepo workspace. Concrete violations + fixes. Composes with core:hexagonal-architecture and the boundary-direction-check hook.
3
+ description: Declare cross-package dependencies explicitly and preserve domain-owned ports. Use when crossing a workspace package boundary or fixing boundary-direction-check.
4
4
  ---
5
5
 
6
6
  # dependency-direction
7
7
 
8
- Use when adding an `import` statement that crosses a package boundary, or when fixing a `boundary-direction-check` hook violation. This skill is the **operational guide** for the rules core `void-hexagonal-architecture` and `boundary-direction-check` enforce mechanically.
8
+ The package's `package.json` owns its permitted package dependencies. The project's
9
+ architecture owns which dependencies make sense. A universal star topology through
10
+ `@repo/core` is not imposed by the harness.
9
11
 
10
- ## The rule
12
+ ## Before adding an import
11
13
 
12
- ```
13
- @repo/core → nothing internal
14
- @repo/auth → @repo/core
15
- @repo/db → @repo/core
16
- @repo/ui → @repo/core
17
- @repo/<feat> → @repo/core (and only @repo/core)
18
- apps/<app> → any @repo/*
19
- ```
14
+ 1. Read the consuming package's manifest and project architecture.
15
+ 2. Keep domain code independent of concrete infrastructure; compose adapters through
16
+ domain-owned ports, per `void-hexagonal-architecture`.
17
+ 3. Declare an accepted dependency in the appropriate manifest section. Types create
18
+ dependencies too. Import the package's public API, not a sibling's private files.
19
+ 4. If the dependency would violate an accepted project boundary, reshape the port
20
+ and compose at the application edge. A structural exception follows `void-decide`.
21
+ 5. Run the affected package's typecheck and behavior tests.
20
22
 
21
- **No `@repo/*` package imports another `@repo/*` except `@repo/core`.** All composition happens at the app level. If `@repo/billing` needs the user, it defines a port; the consuming app wires `@repo/auth`'s adapter into that port.
23
+ ## Mechanical scope
22
24
 
23
- This sounds restrictive. It is. It is what keeps the monorepo from collapsing into a hairball.
25
+ The shared Node `boundary-direction-check` rule rejects detected scoped imports
26
+ that the nearest readable package manifest does not declare. It accepts self-imports
27
+ and dependencies, devDependencies, peerDependencies and optionalDependencies.
28
+ It skips tests/generated files and allows when no manifest can be read. Its lexical
29
+ matcher is a guardrail, not a complete import graph or internal-layer validator.
30
+ An `allow-boundary:` comment is a narrow escape recognized by the existing rule;
31
+ it does not approve an architectural exception or make a missing dependency buildable.
24
32
 
25
- ## Why core is the only allowed dependency
26
-
27
- `@repo/core` ships **primitives** with no internal deps:
28
-
29
- - `logger` (pino)
30
- - `env` (Zod-validated env)
31
- - `errors` (typed error classes)
32
- - `Result`, `Option`, `pipe` (functional utilities)
33
-
34
- These are leaf utilities. Everything else is composition.
35
-
36
- ## Common violation: `@repo/billing` wants the user
37
-
38
- **Wrong**:
39
- ```ts
40
- // packages/billing/src/billing.service.ts
41
- import { db } from '@repo/db'; // ✗
42
- import { getCurrentUser } from '@repo/auth'; // ✗
43
-
44
- export async function cancelSubscription() {
45
- const user = await getCurrentUser();
46
- await db.update(subscriptions)...
47
- }
48
- ```
49
-
50
- **Right** — define the port in `@repo/billing`, wire the adapter in the app:
51
-
52
- ```ts
53
- // packages/billing/src/billing.types.ts (port)
54
- export interface BillingPorts {
55
- readonly userRepo: { findById(id: string): Promise<User | null> };
56
- readonly subscriptionRepo: { update(id: string, p: Partial<Sub>): Promise<void> };
57
- }
58
-
59
- // packages/billing/src/billing.service.ts (pure)
60
- export function makeBillingService(ports: BillingPorts) {
61
- return {
62
- async cancelSubscription(userId: string) {
63
- const user = await ports.userRepo.findById(userId);
64
- // ... domain logic only
65
- },
66
- };
67
- }
68
-
69
- // apps/web/src/adapters/billing.ts (composition)
70
- import { makeBillingService } from '@repo/billing';
71
- import { db } from '@repo/db';
72
- import { userRepo, subRepo } from '@/repositories';
73
- export const billing = makeBillingService({ userRepo, subscriptionRepo: subRepo });
74
- ```
75
-
76
- The app is the integration point. Packages are pure capabilities.
77
-
78
- ## Common violation: types from another package
79
-
80
- **Wrong**:
81
- ```ts
82
- // packages/billing/src/billing.types.ts
83
- import type { User } from '@repo/auth'; // ✗ types still create coupling
84
- ```
85
-
86
- **Right** — declare your own User type narrowed to what billing needs:
87
-
88
- ```ts
89
- // packages/billing/src/billing.types.ts
90
- export interface BillingUser {
91
- readonly id: string;
92
- readonly email: string;
93
- readonly orgId: string;
94
- }
95
- ```
96
-
97
- If `@repo/auth`'s `User` and `@repo/billing`'s `BillingUser` should be the same shape, factor the shape into `@repo/core/types`. But almost always, each package wants a narrower projection — that's good.
98
-
99
- ## Common violation: `@repo/ui` calls a service
100
-
101
- **Wrong**:
102
- ```tsx
103
- // packages/ui/src/UserAvatar.tsx
104
- import { getCurrentUser } from '@repo/auth'; // ✗
105
- export function UserAvatar() {
106
- const user = use(getCurrentUser()); // ✗ side effect in UI
107
- return <Avatar src={user.avatarUrl} />;
108
- }
109
- ```
110
-
111
- **Right**:
112
- ```tsx
113
- // packages/ui/src/Avatar.tsx (dumb prop-driven)
114
- export function Avatar({ src, fallback }: { src: string; fallback: string }) {
115
- return <img src={src} alt={fallback} />;
116
- }
117
-
118
- // apps/web/src/components/CurrentUserAvatar.tsx (binding, NOT in @repo/ui)
119
- import { Avatar } from '@repo/ui';
120
- import { useCurrentUser } from '@/hooks';
121
- export function CurrentUserAvatar() {
122
- const user = useCurrentUser();
123
- return <Avatar src={user.avatarUrl} fallback={user.name} />;
124
- }
125
- ```
126
-
127
- `@repo/ui` exports primitives with no I/O. Bindings to actual data live in apps.
128
-
129
- ## The `apps/<app>` → `apps/<other-app>` rule
130
-
131
- **Never** import from another app:
132
-
133
- ```ts
134
- // apps/web/src/foo.ts
135
- import { whatever } from '../../mobile/src/...'; // ✗ — even via @repo/* alias if you set it up
136
- ```
137
-
138
- If two apps need the same code, that code goes in a `@repo/*` package. There is no "shared/" directory between apps.
139
-
140
- ## When you genuinely need cross-package access
141
-
142
- You don't. Re-read the section on ports. If after that you still think you do, write an ADR (`void-decide`) documenting the exception with the reversal cost. 90% of "I need this" turns out to be "I forgot to define the port".
143
-
144
- ## Mechanical enforcement
145
-
146
- The `boundary-direction-check` hook (core) blocks Edit/Write that introduces a forbidden import. Override by tagging the import line `// allow-boundary: <reason>` — but think hard first, because each exception is a small future grind.
147
-
148
- ## Workflow
149
-
150
- 1. **Before adding the import**, ask: does this cross a `@repo/*` boundary?
151
- 2. **If yes**, ask: is the destination `@repo/core`? OK. Anything else? Stop.
152
- 3. **Define a port** in your package, wire the adapter in the app.
153
- 4. **If the hook complains**, do not tag-and-move-on. Refactor.
33
+ A `tsconfig` path alias changes resolution, not permission. It cannot prove a
34
+ package boundary. Project-specific cycle or layer checks must be explicitly wired
35
+ and verified before claiming deterministic enforcement.
154
36
 
155
37
  ## Composition
156
38
 
157
- - `void-hexagonal-architecture` — doctrine on ports + adapters direction (core).
158
- - `boundary-direction-check` hook (core) — mechanical gate.
159
- - `void-package-extraction` — most boundary problems come from premature extraction.
160
- - `void-service-package` — the 5+5 layout includes a `<name>.types.ts` precisely for owning your own types.
39
+ - `void-hexagonal-architecture` owns ports and dependency direction inside a domain.
40
+ - `void-service-package` applies the project package layout.
41
+ - `void-package-extraction` decides whether a shared package is warranted.
@@ -52,15 +52,12 @@ service ─→ repository (port) service ─→ helper service ─→ types
52
52
 
53
53
  If your package needs `@repo/db` directly: stop. Move the DB-touching code into an adapter in the consuming app. The package owns the **port**, not the adapter.
54
54
 
55
- ## Allowed imports (enforced by `boundary-direction-check`)
55
+ ## Allowed imports
56
56
 
57
- | This package | May import |
58
- |---|---|
59
- | `@repo/<name>` | `@repo/core` (logger, env, errors, Result/Option/pipe). Nothing else `@repo/*`. |
60
-
61
- If you find yourself needing to import another `@repo/*`, you are blurring layers. Two valid fixes:
62
- - Move the shared concept into `@repo/core`
63
- - Reshape the boundary so the consumer composes the two packages explicitly
57
+ Follow `void-dependency-direction`: the package manifest declares dependencies;
58
+ the project's architecture decides whether they belong. This service convention
59
+ prefers domain-owned ports and app composition. It does not claim the shared hook
60
+ forbids every dependency except `@repo/core`.
64
61
 
65
62
  ## `package.json` template
66
63
 
@@ -1,7 +1,9 @@
1
1
  Sources for `void-webhook-handler-pattern` skill (see `docs/plans/skill-audits/void-webhook-handler-pattern.md` for the full audit):
2
2
 
3
- - No external source audited — native distillation of canonical per-source webhook patterns.
4
- - Per-source patterns referenced (not vendored): Stripe, Resend, GitHub signature/idempotency/retry semantics.
3
+ - Official provider contracts checked for the 2026-10-30 coherence correction:
4
+ - Stripe: https://docs.stripe.com/webhooks#preventing-replay-attacks
5
+ - GitHub: https://docs.github.com/en/webhooks/using-webhooks/handling-failed-webhook-deliveries
6
+ - Provider code is not vendored; verifier and durable store remain consumer-owned.
5
7
  - Doctrine referenced: `void-async-safety` (stack-agnostic retry/idempotency/dead-letter).
6
8
 
7
9
  Strategy: distill. Per-source handler patterns distilled into the 5 non-negotiable layers; no upstream skill was copied.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: void-webhook-handler-pattern
3
- description: Build a webhook handler with signature verification, idempotency, and dead-letter routing. Per-source patterns (Stripe, Resend, GitHub). Self-contained — no harness wrappers required.
3
+ description: Build a webhook handler with signature verification, idempotency, and dead-letter routing. Per-source patterns (Stripe, Resend, GitHub). Uses the shared async-safety contract.
4
4
  ---
5
5
 
6
6
  # webhook-handler-pattern
@@ -15,91 +15,22 @@ apps/<app>/src/app/api/webhooks/<source>/route.ts
15
15
 
16
16
  One folder per source. Path stable (external systems POST to a fixed URL — never rename without coordinating).
17
17
 
18
- ## The 5 non-negotiable layers
18
+ ## Handler contract
19
19
 
20
- ```
21
- 1. Signature verification — verify the event came from the source
22
- 2. Idempotency — same event delivered twice = one effect
23
- 3. Zod validation — parsed shape matches what handler expects
24
- 4. Service call — the business work (in apps/<app>/src/services/)
25
- 5. Acknowledgment — return 2xx on success; specific codes on failure
26
- ```
20
+ Compose `void-async-safety` for the canonical executable `withWebhookSafety` example
21
+ and its `IdempotencyStore` contract. Keep signature/freshness verification in the provider
22
+ SDK adapter, validate its decoded data at that boundary, and map the typed outcome to HTTP.
23
+ Do not copy a second verify/claim/handle/mark implementation into this skill.
27
24
 
28
- Skipping ANY of these is a Sev-2 waiting. The pattern below shows the 5 layers explicit — no wrapper assumed.
29
-
30
- ## Canonical handler (explicit, self-contained)
31
-
32
- ```ts
33
- // apps/web/src/app/api/webhooks/stripe/route.ts
34
- import { NextRequest, NextResponse } from 'next/server';
35
- import Stripe from 'stripe';
36
- import { z } from 'zod';
37
- import { db } from '@/adapters/db';
38
- import { env, logger } from '@repo/core';
39
- import * as Sentry from '@sentry/nextjs';
40
- import { handleStripeEvent } from '@/services/billing/stripe';
41
-
42
- const stripe = new Stripe(env.STRIPE_SECRET_KEY);
43
-
44
- // 3. Zod schema for the parsed Stripe event (defense in depth — Stripe's
45
- // own parser is trusted, but we re-shape to our service contract).
46
- const EventSchema = z.object({
47
- id: z.string(),
48
- type: z.string(),
49
- data: z.object({ object: z.unknown() }),
50
- livemode: z.boolean(),
51
- });
52
-
53
- export async function POST(req: NextRequest) {
54
- const sig = req.headers.get('stripe-signature');
55
- const body = await req.text();
56
- const idempotencyKey = `stripe:${req.headers.get('stripe-signature')?.slice(0, 16) ?? ''}`;
57
-
58
- let stripeEvent: Stripe.Event;
59
- try {
60
- // 1. Signature verification (Stripe-specific)
61
- if (!sig) {
62
- return NextResponse.json({ error: 'missing signature' }, { status: 401 });
63
- }
64
- stripeEvent = stripe.webhooks.constructEvent(body, sig, env.STRIPE_WEBHOOK_SECRET);
65
- } catch (err) {
66
- logger.warn({ event: 'webhook.signature_invalid', source: 'stripe' });
67
- return NextResponse.json({ error: 'invalid signature' }, { status: 401 });
68
- }
69
-
70
- // 2. Idempotency — Stripe's event.id is globally unique
71
- const inboxKey = `stripe:${stripeEvent.id}`;
72
- const inserted = await db
73
- .insert(webhookInbox)
74
- .values({ key: inboxKey, source: 'stripe', eventType: stripeEvent.type, payload: stripeEvent })
75
- .onConflictDoNothing()
76
- .returning();
77
-
78
- if (inserted.length === 0) {
79
- // Already seen — return 2xx to stop Stripe retrying
80
- logger.info({ event: 'webhook.duplicate', source: 'stripe', eventId: stripeEvent.id });
81
- return NextResponse.json({ ok: true, duplicate: true });
82
- }
83
-
84
- // 3. Re-validate with our schema
85
- const event = EventSchema.parse(stripeEvent);
86
-
87
- // 4. Service call — pure dispatch
88
- logger.info({ event: 'webhook.received', source: 'stripe', type: event.type, eventId: event.id });
89
- try {
90
- await handleStripeEvent(event);
91
- await db.update(webhookInbox).set({ committedAt: new Date() }).where(eq(webhookInbox.key, inboxKey));
92
- // 5. Ack
93
- return NextResponse.json({ ok: true });
94
- } catch (err) {
95
- Sentry.captureException(err, { tags: { webhook: 'stripe', eventType: event.type } });
96
- // Retryable: return 5xx so Stripe retries with backoff
97
- return NextResponse.json({ ok: false }, { status: 500 });
98
- }
99
- }
100
- ```
25
+ The handler's business effects and deduplication must share a transaction/outbox or use
26
+ the same stable idempotency key. A received or pending inbox row is **not** proof of a
27
+ completed event. Failed processing and failed completion persistence remain non-success
28
+ and retryable; only completed events may be acknowledged as duplicates. An in-flight
29
+ concurrent delivery must not report that unfinished processing as success.
101
30
 
102
- The 5 layers are explicit. No wrapper is required. If your project ships its own `withWebhookSafety` helper that bundles these 5, that's a convenience — but the pattern is the substance, and it's the same.
31
+ A project may provide its own wrapper with this contract. The optional Next.js pack
32
+ exports the tested implementation; it does not supply the project's verifier or durable
33
+ store. No universal event-age limit is invented by the wrapper.
103
34
 
104
35
  ## Per-source notes
105
36
 
@@ -114,13 +45,13 @@ The 5 layers are explicit. No wrapper is required. If your project ships its own
114
45
 
115
46
  - Signature: `svix-id`, `svix-timestamp`, `svix-signature` headers → `new Webhook(secret).verify(body, headers)` from `svix` package
116
47
  - Idempotency: `svix-id` header
117
- - Retry: Svix retries automatically; you only see the first failure if your endpoint returns 5xx
48
+ - Retry: use the provider's current retry schedule and delivery diagnostics; do not infer completion from receipt.
118
49
 
119
50
  ### GitHub
120
51
 
121
52
  - Signature: `x-hub-signature-256` header (HMAC SHA-256 of the body with your secret)
122
53
  - Idempotency: `x-github-delivery` header (UUID)
123
- - Retry: GitHub does NOT auto-retry. If you return 5xx, the event is lost. Make sure dead-letter is robust.
54
+ - Retry: GitHub does not automatically redeliver failed deliveries; arrange manual or automated redelivery from its delivery records. A failure response is not durable acceptance.
124
55
 
125
56
  ### Custom (internal services calling each other)
126
57
 
@@ -128,64 +59,33 @@ The 5 layers are explicit. No wrapper is required. If your project ships its own
128
59
  - Idempotency: caller-generated UUID v7 in `x-idempotency-key`
129
60
  - Retry: caller's responsibility; receiver acknowledges with 2xx + the key it already saw
130
61
 
131
- ## Inbox table
62
+ ## Durable inbox
132
63
 
133
- The idempotency check needs persistent storage:
134
-
135
- ```sql
136
- CREATE TABLE webhook_inbox (
137
- key text PRIMARY KEY, -- e.g., "stripe:evt_xxx"
138
- source text NOT NULL,
139
- event_type text NOT NULL,
140
- received_at timestamptz NOT NULL DEFAULT now(),
141
- committed_at timestamptz,
142
- payload jsonb NOT NULL
143
- );
144
- CREATE INDEX idx_webhook_inbox_received ON webhook_inbox (received_at);
145
- ```
146
-
147
- Migration follows the safe-migration pattern (see `void-drizzle-migration-safe` skill).
64
+ Use the application's existing durable store following `void-async-safety`. Its state
65
+ must distinguish pending claims from completed events and release/expire failed claims
66
+ for retry. A unique key alone cannot express completion. Apply schema changes through
67
+ `void-drizzle-migration-safe`; this skill supplies no independently evolving SQL schema.
148
68
 
149
69
  ## Dead-letter routing
150
70
 
151
- When the handler decides the error is permanent (validation failure, downstream API said 4xx):
152
-
153
- ```ts
154
- catch (err) {
155
- if (isPermanentFailure(err)) {
156
- await db.insert(webhookDeadLetter).values({
157
- key: inboxKey,
158
- source: 'stripe',
159
- error: err.message,
160
- payload: event,
161
- receivedAt: new Date(),
162
- });
163
- return NextResponse.json({ ok: false, retry: false }, { status: 400 });
164
- // 4xx tells Stripe "permanent — stop retrying"
165
- }
166
- throw err; // retryable — let the 5xx propagate
167
- }
168
- ```
169
-
170
- Dead letter = a queue/table the team reviews. UI: `Settings → Webhook DLQ`. Never silently drop.
171
-
172
- ## Error response codes
71
+ A permanent failure may be recorded in a durable dead-letter destination for an operator
72
+ before applying the provider's acknowledgment policy. Never interpret an HTTP status as
73
+ a universal instruction to stop delivery: Stripe retries non-2xx responses, including 4xx;
74
+ GitHub does not automatically redeliver failures. Keep provider-specific retries explicit.
75
+ Never acknowledge a malformed event as successfully processed or silently drop a failure.
173
76
 
174
- | Code | Means | Sender behavior |
175
- |---|---|---|
176
- | 200/202 | Accepted | Stops retrying |
177
- | 401 | Invalid signature | Stops retrying (Stripe, GitHub will flag the endpoint) |
178
- | 4xx (other) | Permanent failure (bad event shape) | Stops retrying — log this for investigation |
179
- | 5xx | Transient failure | Retries with backoff (Stripe/Svix); GitHub doesn't retry |
77
+ ## Error responses
180
78
 
181
- NEVER return 200 to a malformed event (you'd accept invalid data). 4xx with a logged DLQ entry is the right pattern.
79
+ The canonical wrapper rejects verifier failures with 400 and returns 500 for handler or
80
+ store failures. The HTTP adapter preserves those outcomes. A 200 means completed work or
81
+ a previously completed duplicate, never an in-progress claim. If a queue/outbox is used,
82
+ its separate acceptance contract must mean durable responsibility for eventual processing.
182
83
 
183
84
  ## Anti-patterns
184
85
 
185
86
  - ✗ **No signature verification** — anyone with the URL can forge events
186
87
  - ✗ **Idempotency key = `Date.now()`** — re-delivery NOT detected; double-charges
187
- - ✗ **Calling services synchronously for slow work** — if the handler takes > 10s, the sender retries. Enqueue background jobs for slow work (see `void-background-job-pattern`).
188
- - ✗ **Returning 5xx to "make sender retry" without enqueueing** — sender's backoff is unpredictable; use your own queue
88
+ - ✗ **Calling services synchronously for slow work** — work exceeding the provider's response deadline is a failed delivery; retry policy varies by provider. Enqueue background jobs for slow work (see `void-background-job-pattern`).
189
89
  - ✗ **One handler for all sources** (`/api/webhooks/route.ts` switching on `source`) — different signatures, different idempotency keys, different retry semantics. One folder per source.
190
90
  - ✗ **`==` for HMAC comparison** — use `crypto.timingSafeEqual`. Constant-time compare prevents timing attacks.
191
91
 
@@ -198,7 +198,7 @@ Every UI is designed mobile-first AND must reach first-class quality on both vie
198
198
  - No mobile-only nor desktop-only features without an explicit decision recorded through the `void-decide` skill.
199
199
  - Both viewports screenshot-reviewed before merge.
200
200
 
201
- The companion `viewport-screenshot-gate` hook (shared with `void-frontend-design`) fails PRs lacking both screenshots.
201
+ The companion `viewport-screenshot-gate` hook (shared with `void-frontend-design`) is an optional PR-text heuristic; it does not validate images or enforce a native/CI gate. Review the actual mobile and desktop captures before merge.
202
202
 
203
203
  ---
204
204
 
@@ -229,7 +229,7 @@ The design system handles this by default in `pack-nextjs`. Specific animations
229
229
  ## Companion hooks
230
230
 
231
231
  - `axe-precommit` (pre-commit on UI changes) — axe-core static analysis, blocks on AA violations. ≤ 80 LOC.
232
- - `viewport-screenshot-gate` (pre-PR on UI changes, shared with `void-frontend-design`) — fails PRs lacking both mobile and desktop screenshots. ≤ 60 LOC.
232
+ - `viewport-screenshot-gate` (pre-PR on UI changes, shared with `void-frontend-design`) — optional PR-text heuristic, not a wired native/CI gate or image validation. Review actual captures.
233
233
 
234
234
  ---
235
235