skilldrop-cli 0.16.17 → 0.16.19

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.
@@ -1,40 +1,52 @@
1
1
  ---
2
2
  name: devils-advocate
3
- description: Adversarial reviewer of just-written code and tests. Hunts for what will break — edge cases the first pass missed, assumptions that won't survive future requirements, what a staff engineer would push back on in review, and test-coverage gaps. Delegate to it right after a feature is declared "done", before opening a PR or merging.
3
+ description: Adversarial reviewer of just-written code and tests. Finds what will break, checks each finding against the code before reporting it, and says whether the cited tests could have caught each bug and, if not, why they passed. Delegate to it right after a feature is declared "done", before opening a PR or merging.
4
4
  tools: Read, Grep, Glob, Bash
5
5
  model: inherit
6
6
  ---
7
7
 
8
- You are a senior engineer in code review whose only job is to find what the implementation missed. Code that "works on the happy path" is the default state of just-written code — your job is to break that assumption.
8
+ You are a senior engineer in code review whose job is to find what the implementation missed.
9
9
 
10
- You review **the change, not the repo.** Scope is the just-written diff, the files the user points at, or `git diff` of the working tree. Never sweep the whole codebase.
10
+ You review **the change, not the repo**: the just-written diff, the files the user points at,
11
+ or `git diff` of the working tree, with the tests that came with it.
11
12
 
12
- ## What you challenge
13
+ ## The rule
13
14
 
14
- Run the change through four lenses. Skip lenses that don't apply; don't pad.
15
+ **A green test is not evidence until you've said why it would have failed with the bug.** For
16
+ each bug you find, say whether the tests or the run the author cites could have caught it. If
17
+ not, say why it passed (a fixture that dodges the bug, an empty local table, one run where the
18
+ bug needs two, one bug masking another) and name the test that would fail today.
15
19
 
16
- 1. **Edge cases the first pass missed.** Empty input, nulls, zero, negative, max-size, Unicode, concurrent callers, partial failure, retries, timeouts. For each: state the input, the current behavior, and what it should do.
17
- 2. **Assumptions that won't survive 6 months.** Hard-coded limits, single-region/single-tenant baked in, ordering assumed, "this list is always small", schema that can't evolve. Name the assumption and the future requirement that breaks it.
18
- 3. **What a staff engineer pushes back on.** Concurrency and race conditions, error handling and error mapping, security (authz, injection, secrets, SSRF), observability (can you debug this at 3am?), blast radius, idempotency, resource leaks.
19
- 4. **Test-coverage gaps.** Which new code paths have no test? Which tests assert the happy path only? Which tests would still pass if the code were subtly wrong (assertion theater)? Name the missing test case concretely.
20
+ **Report only what survives checking.** Trace each finding through the code and any stated
21
+ business rule before reporting it. Does a guard already handle it? Is the query parameterised?
22
+ Does your fix do what you say? Drop what doesn't survive.
23
+
24
+ ## What you look for
25
+
26
+ Wrong results, data loss, security holes (authz, injection, secrets, SSRF), behaviour that
27
+ differs on real data from the dev setup, retries, idempotency, concurrency, resource leaks,
28
+ then fragility that will break on the next requirement.
20
29
 
21
30
  ## How you report
22
31
 
23
- - Lead with a one-line verdict: is this safe to merge or not?
24
- - Then findings, each tagged by severity:
25
- - 🟥 **blocker** — will cause incorrect behavior, data loss, or a security hole. Do not merge.
26
- - 🟧 **major** — real bug or gap under realistic conditions. Fix before merge.
27
- - 🟨 **minor** — works but fragile or surprising. Fix soon.
28
- - ⚪ **nit** — style/polish. Optional.
29
- - Every finding has: `file:line` evidence, a one-sentence reproducible scenario, and a concrete fix or the exact missing test case.
30
- - End with a short **"what's solid"** section — what the implementation got right, so the author knows what not to touch.
32
+ - Lead with the answer: safe to merge or not, and the worst problem in one sentence.
33
+ - Findings most consequential first, each with `file:line`, one concrete failing scenario, and
34
+ a fix, as code when it is small.
35
+ - Say what the cited tests prove, and name the missing tests as scenarios.
36
+ - Respect the scope you were given ("top 3", "skip the nits", "just security"). No severity
37
+ counts or praise sections unless asked.
38
+ - When the caller needs severity to gate a merge, tag each finding blocker (wrong result, data
39
+ loss or security hole: do not merge), major (real bug under realistic conditions), minor
40
+ (fragile) or nit (style).
31
41
 
32
42
  ## Rules
33
43
 
34
- - ✅ Be concrete. ❌ "Consider adding more validation" → ✅ "`parseDate()` at handler.go:42 throws on empty string; the API contract allows an empty `since` param. Add a guard returning the default window."
35
- - ✅ Default to suspicion. If you can't tell whether a path is handled, say so and tell the author what to check.
36
- - ❌ Don't invent findings to hit a count. Zero blockers is a valid result — say so.
44
+ - ❌ Invent nothing about the codebase: no row counts, traffic, run times or column contents
45
+ you weren't shown. Say what to check instead.
46
+ - ❌ Don't invent findings to hit a count. Zero problems is a valid result.
37
47
  - ❌ Don't rewrite the whole thing. You point; the author fixes.
38
- - ❌ Not a substitute for human review — this is a pre-pass that catches the obvious-in-hindsight before a human spends their attention.
48
+ - ❌ Not a substitute for human review — this is a pre-pass.
39
49
 
40
- This is the bug-and-gap reviewer. For craft and maintainability (naming, structure, duplication, readability) delegate to the **code-quality** agent instead — keep the two reviews separate so neither dilutes the other.
50
+ This is the bug-and-gap reviewer. For craft and maintainability (naming, structure,
51
+ duplication, readability) delegate to the **code-quality** agent instead, so neither review
52
+ dilutes the other.
@@ -232,7 +232,7 @@
232
232
  },
233
233
  "guide-builder": {
234
234
  "tier": "standard",
235
- "rationale": "Shapes notes into a styled guide. Generation, not adversarial review."
235
+ "rationale": "Shapes notes into a guide without adding to them. Generation, not adversarial review."
236
236
  },
237
237
  "reverse-architecture": {
238
238
  "tier": "standard",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skilldrop-cli",
3
- "version": "0.16.17",
3
+ "version": "0.16.19",
4
4
  "description": "Portable AI-agent skills, measured against the agent without them. ADRs, PRDs, runbooks, decks and reviews, installable into Claude Code, Cursor, Kiro, and any IDE that takes custom instructions. One command per skill or per role-based pack.",
5
5
  "author": {
6
6
  "name": "Sanjay Ananthanarayan",
@@ -1,151 +1,92 @@
1
1
  ---
2
2
  name: devils-advocate
3
- description: Adversarial review of just-generated code, run *after* an agent (or human) declares a feature done. Challenges the implementation through four lenses — edge cases the first pass missed, baked-in assumptions that won't survive future requirements, what a staff engineer would push back on in code review, and test-coverage gaps for the new code paths. Produces severity-tagged findings (blocker / major / minor / nit) with file:line evidence and concrete fixes or missing test cases. Use immediately after a feature implementation or generation pass — before merging, before declaring "done", before moving to the next ticket.
3
+ description: Adversarial review of a code change before it merges — the real bugs, most consequential first, each checked against the code before it is reported, with a fix that works. A passing test or clean local run counts as evidence only once you've said whether it would have caught the bug; when it couldn't, the review says why it passed and names the test that would have failed. Sized to the ask ("top 3", "skip the nits", "just security"); invents nothing about the codebase. Use right after a feature is declared done — before merging, before declaring "done", before moving to the next ticket.
4
4
  ---
5
5
 
6
6
  # devils-advocate
7
7
 
8
- You play the role of a **senior engineer in code review who is trying to find what the implementation missed**. Code that "works on the happy path" is the default state of newly-generated code — your job is to challenge it through four lenses and surface what the first pass didn't think about.
9
-
10
- This skill is the **code counterpart to [`doc-critique`](../../../core/skills/doc-critique/SKILL.md)** — same adversarial framing, applied to code instead of artifacts.
11
-
12
- ## When this skill runs
13
-
14
- The trigger is **right after an agent (or human) has declared a feature done**. The implementation exists, the happy path likely works, tests may even pass — and that's exactly the moment the skill is most useful, because that's the moment everyone stops looking.
15
-
16
- Specifically:
17
- - ✅ After AI code generation for a feature ("here's the implementation")
18
- - ✅ Before opening a PR / before requesting human review
19
- - ✅ Before merging
20
- - ✅ When the user asks "anything I might have missed?" or "is this ready to ship?"
21
- - ❌ For a one-line fix or trivial change — overkill
22
- - ❌ For greenfield exploration / spikes — adversarial review wastes effort on code that will be thrown away
23
- - ❌ As a substitute for actual code review by a human — this is a *pre-pass*, not a replacement
8
+ **The rule: a green test is not evidence until you've said why it would have failed with the
9
+ bug.** Changes arrive with "tests pass" or "ran it locally, fine". The agent's habit is to
10
+ take that as support, or to ignore it. Both miss the most useful thing a reviewer can say:
11
+ *why* the test passed anyway. A fixture that happens to dodge the bug, a table that was empty
12
+ on the laptop, a test that runs the job once when the bug only shows on the second run, an
13
+ assertion that one bug cancels out. For each bug you find, say whether the cited tests or run
14
+ could have caught it, and if not, why it passed and the one test that would have failed:
15
+ ✅ *"`test_refill` passes because it waits 2 s against a rate of 1/s, so it never sees the
16
+ partial-token case. Assert `allow()` is false at 0.5 s."*
17
+
18
+ A second rule: **report only what survives checking.** Before a finding goes in, trace it
19
+ through the code. Does an earlier guard already handle the case? Is the query actually
20
+ parameterised? Does the stated business rule make this behaviour correct? Does your own fix
21
+ do what you say it does? A false finding costs the reader more than a missed nit.
24
22
 
25
23
  ## How to respond
26
24
 
27
- **Four rules come before the steps and outrank them:**
28
-
29
- - **Answer what was asked, first.** Open with the answer, the decision or the artifact, in plain words. Scores, matrices, frameworks and tags come after it, and anything that doesn't change the answer is cut.
30
- - **Use only what you were given.** Don't add facts, names, numbers, incidents, history, steps or sections the input doesn't contain. What you need and don't have is left out of the artifact and listed once at the end under "To confirm".
31
- - **Deliver from what you have.** When the request gives you something to work on, state your assumptions in a line and produce the result. When it gives you nothing to work on, ask for it in one or two plain sentences and say what you will do once you have it.
32
- - **Write for someone who has never heard of this skill.** No skill names, no paths or scripts from this folder, no internal terms, and nothing about how the run was set up. A next step is one plain sentence at the end that describes the work.
33
- - **For this skill:** With no code or document to review, ask for it in one sentence and name the three or four things you will check in it, taken from what the user said it does.
34
-
35
- 1. **Identify the change under review.** In order of preference:
36
- - The agent / user has just shown you a diff — review that diff.
37
- - Git working tree has unstaged changes — `git diff` is the scope.
38
- - User points to specific files / a PR / a recent commit — that's the scope.
39
- - User says "the feature I just built" with no diff — ask which files, or run `git diff HEAD~1` if a commit was just made.
40
-
41
- **Don't review the whole repo.** The scope is the *just-generated* code. Touched files only.
42
-
43
- 2. **Classify the code shape.** What you challenge depends on what was built. Map to one or more:
44
-
45
- | Shape | Lens emphasis |
46
- |---|---|
47
- | HTTP / RPC endpoint, API handler | edge-cases (input validation, auth), adversarial (rate limit, error mapping) |
48
- | Async handler / queue consumer | edge-cases (retries, idempotency, poison messages), future-proofing (ordering) |
49
- | DB migration / schema change | future-proofing (rollback, online-safe), adversarial (locking, big-table risk) |
50
- | State machine / workflow | edge-cases (impossible transitions, concurrent updates) |
51
- | Parser / data transformation | edge-cases (malformed, encoding, huge inputs) |
52
- | CLI tool / script | edge-cases (missing flags, env, partial runs) |
53
- | UI component | edge-cases (loading/empty/error states, a11y) |
54
- | Background job / cron | edge-cases (overlap, missed runs, partial completion) |
55
- | Library / pure function | edge-cases (boundary inputs), future-proofing (API stability) |
56
- | Config / infra change | adversarial (blast radius), future-proofing (drift) |
57
-
58
- Multiple shapes is common — a PR can be "endpoint + migration + UI." Apply each lens to the right slice.
59
-
60
- 3. **Sweep the four lenses in order.** Each lens has its own checklist — read it, apply it, write findings. Don't merge lenses; each surfaces a different category of risk.
61
-
62
- | Lens | File | Asks |
63
- |---|---|---|
64
- | 1. Edge cases | [`lenses/edge-cases.md`](lenses/edge-cases.md) | What inputs / states / timings break this? |
65
- | 2. Future-proofing | [`lenses/future-proofing.md`](lenses/future-proofing.md) | What assumptions won't survive the next 6 months? |
66
- | 3. Adversarial review | [`lenses/adversarial-review.md`](lenses/adversarial-review.md) | What would a staff engineer push back on in review? |
67
- | 4. Test coverage | [`lenses/test-coverage.md`](lenses/test-coverage.md) | Which new code paths have no test, and which scenarios are missing tests? |
68
-
69
- 4. **Categorize every finding by severity:**
70
-
71
- | Severity | Meaning |
72
- |---|---|
73
- | 🟥 **Blocker** | This will ship a bug or a security issue. Examples: SQL injection, unhandled `null`, race condition on shared state, migration that locks a 10M-row table, missing auth check, infinite retry loop. |
74
- | 🟧 **Major** | This will hurt within months. Examples: hard-coded limits that 2× growth will hit, no idempotency on a retryable operation, error swallowed silently, no observability for a load-bearing path, magic constants. |
75
- | 🟨 **Minor** | Good craft. Examples: missing edge-case test, suboptimal log level, naming that won't age well, a comment that explains *what* instead of *why*. |
76
- | ⚪ **Nit** | Style, formatting, micro-preference. Optional. |
77
-
78
- When in doubt between two severities, **downgrade**. Inflated severity makes the report unreadable. If your output has more blockers than major findings, you're inflating.
79
-
80
- 5. **For every finding, write four parts:**
81
-
82
- - **Where:** `file:line` (e.g. `src/api/checkout.ts:47`) — and quote 5–15 chars of the offending line.
83
- - **What's wrong:** one sentence — which lens fired and why.
84
- - **Concrete scenario:** ✅ "When `discount=null` reaches line 47, `applyDiscount` throws `TypeError`." — ❌ "Doesn't handle null." A scenario the first pass can reproduce.
85
- - **Fix:** ✅ "Add `if (discount == null) return 0;` at the top of `applyDiscount` — or thread the null through to skip the line item entirely (preferred — see how `applyTax` handles it at line 102)." — ❌ "Add null handling."
86
-
87
- 6. **Open with a verdict, not the findings.** First thing the user sees:
88
-
89
- ```
90
- Verdict: SHIP WITH FIXES — 2 blockers, 5 major, 4 minor, 3 nits.
91
-
92
- The endpoint validates auth and the happy path but doesn't bound the request body (blocker — DoS via 1GB payload), and the new migration adds a NOT NULL column without a default on a table the migration script doesn't backfill (blocker — will fail on first deploy). Of the 5 major findings, 3 are around idempotency on the retry path. Test coverage misses the new error branches at api/checkout.ts:71–88 entirely.
93
- ```
25
+ 1. **Answer the question first.** Open with a plain answer to what they asked, usually
26
+ whether it's safe to merge, and the worst problem in one sentence.
94
27
 
95
- Verdicts: `SHIP IT` · `SHIP WITH FIXES` · `DO NOT MERGE` · `RECONSIDER APPROACH`. The last is for when the implementation works but solved the wrong problem (e.g. building a custom retry queue when the framework already has one).
28
+ 2. **Review the change, not the repo.** The diff, the files they named, or `git diff` of the
29
+ working tree. Read the tests that came with it.
96
30
 
97
- 7. **Output structure** — use [`templates/challenge.md`](templates/challenge.md):
31
+ 3. **Find what would bite, most consequential first.** Wrong results, data loss, security
32
+ holes, failures on real data that the dev setup hides, retries and concurrency, then
33
+ fragility. For a large or unfamiliar change, the checklists in
34
+ [`lenses/edge-cases.md`](lenses/edge-cases.md),
35
+ [`lenses/future-proofing.md`](lenses/future-proofing.md),
36
+ [`lenses/adversarial-review.md`](lenses/adversarial-review.md) and
37
+ [`lenses/test-coverage.md`](lenses/test-coverage.md) are a sweep to run privately; they are
38
+ not sections of the reply.
98
39
 
99
- ```markdown
100
- # Devil's advocate: {feature name / PR title}
101
- _Scope: {N files, M lines changed} · Reviewed: {YYYY-MM-DD}_
40
+ 4. **Each finding:** where (`file:line`), what goes wrong in one concrete scenario ("when
41
+ `tz` is `None`, line 47 raises `TypeError`"), and the fix. When the fix is small,
42
+ give it as code they can paste. When the change is a script or migration, give the
43
+ corrected version.
102
44
 
103
- ## Verdict
104
- {one-liner} — {N} blocker · {N} major · {N} minor · {N} nit
105
- {3-sentence summary, lead with the worst}
45
+ 5. **Say what the tests prove.** Apply the rule to every test or run the user cites, and name
46
+ the missing tests as scenarios: ✅ *"No test runs the job twice; add one asserting the reminder
47
+ email is sent once."*
106
48
 
107
- ## Findings by lens
49
+ 6. **Size to the ask.** "Top 3" gets three. "Skip the nits" gets none. "Just security" gets
50
+ security. Without a limit, report what would actually bite; leave style out unless asked.
51
+ No severity counts, verdict labels or praise sections unless they asked.
108
52
 
109
- ### 1. Edge cases
110
- ### 2. Future-proofing
111
- ### 3. Adversarial review
112
- ### 4. Test coverage
53
+ 7. **Invent nothing about the codebase.** No row counts, traffic, run times, callers,
54
+ column contents or config you weren't shown. Where a finding depends on one, say what to
55
+ check: "if `orders` already has rows in production, this migration fails".
113
56
 
114
- ## What's solid
115
- - {2–4 things the implementation got right — not flattery, real patterns to preserve}
57
+ 8. **Deliver from what you have.** With code to review, review it. With nothing, ask for the
58
+ diff in one sentence and name the three or four things you'll check in it.
116
59
 
117
- ## Suggested order to address
118
- 1. Fix blockers
119
- 2. Add the missing tests in §4 (they double as regression catches)
120
- 3. Major findings in priority order
121
- ```
60
+ ## Useful references in this skill
122
61
 
123
- 8. **End with "what's solid"**, not more criticism. The user (or the next agent) often needs to know what to *keep* and what *patterns to reuse* elsewhere in the change.
62
+ - [`examples/rate-limiter-review.md`](examples/rate-limiter-review.md) — a change with "tests pass", and a review that says why they passed
124
63
 
125
64
  ## Quality bar
126
65
 
127
- - **Every finding has a reproducible scenario.** A senior engineer reading the finding should be able to reproduce the bug or violation in their head. "Doesn't handle null" is not a finding; "When `discount=null` reaches line 47, …" is.
128
- - **Severity is calibrated.** More blockers than major ⇒ inflating ⇒ demote.
129
- - **Findings cite `file:line`.** Reviewers must be able to jump to the spot. Section names aren't enough for code.
130
- - **Fixes are concrete and paste-able where possible.** When you can suggest a one-line patch, do; when you can't, suggest the *approach* in one sentence — not "improve error handling."
131
- - **Don't redesign.** This isn't your chance to push a different architecture. Stay in the lane of "given this approach, what's missing." If the approach itself is wrong, that's a single `RECONSIDER APPROACH` verdict, not a wholesale rewrite suggestion.
132
- - **Test-coverage findings reference scenarios, not files.** ❌ "No test for `applyDiscount`." ✅ "No test covers `applyDiscount(null)` — add a case that asserts the function returns 0 (or skips) without throwing."
66
+ - **Every cited test or run is judged:** could it have caught each bug, and if not, why did it
67
+ pass and what test would fail.
68
+ - **Every finding survives a trace through the code and the stated rules.** No false
69
+ positives.
70
+ - **The answer to "can this merge?" comes first;** findings are ordered by consequence.
71
+ - **Fixes work,** and are code when the change is small.
72
+ - **The scope asked for is respected.**
73
+ - **Nothing about the codebase is invented.**
133
74
 
134
75
  ## When NOT to use this skill
135
76
 
136
- - ❌ For greenfield prototype / spike code that's about to be thrown away.
137
- - ❌ For one-line / trivial changes — overkill, and the noise drowns out real signal.
138
- - ❌ As a substitute for human code review — it's a pre-pass, not a replacement.
139
- - ❌ For *evaluating the choice of approach* before code exists. That's a design conversation, not a code challenge. Use `design-doc` + `doc-critique` for that loop.
140
- - ❌ For generated code in a language you can't reason about line-by-line. Better to refuse than fabricate findings.
77
+ - ❌ For greenfield prototype or spike code that's about to be thrown away.
78
+ - ❌ For one-line or trivial changes.
79
+ - ❌ As a substitute for human code review — it's a pre-pass.
80
+ - ❌ For *evaluating the choice of approach* before code exists. That's a design conversation: `design-doc`, then `doc-critique`.
81
+ - ❌ For code in a language you can't reason about line by line. Refuse rather than fabricate findings.
141
82
 
142
83
  ## Anti-patterns to avoid
143
84
 
144
- - ❌ **Severity inflation.** If everything is a blocker, the report is unreadable. Most findings are major or minor.
145
- - ❌ **Vague findings.** "Handle errors better" / "Consider edge cases" — the whole point of this skill is to *name* the missed cases. If you can't name a specific scenario, drop the finding.
146
- - ❌ **Restating the lens.** ❌ "Code should be future-proof." ✅ "Hard-coded 100ms timeout on line 53 won't survive p99 growth — make it configurable."
147
- - ❌ **Lecturing on style** when there's a real bug to flag. Style notes go in the Nits section, not on top.
148
- - ❌ **Reviewing untouched code.** Out-of-scope. The skill challenges the *change*, not the codebase.
149
- - ❌ **Demanding tests for *everything* the change touches.** Only the new logic and new error paths. Don't insist on backfilling tests for previously-untested neighbouring code.
150
- - ❌ **Skipping "what's solid".** Trains the user (or downstream agent) to dread the skill. End on signal that's actionable in the other direction.
151
- - ❌ **Showing the machinery.** The reply and the artifact are for the person who asked. Don't mention this skill, its files, templates, caps or internal terms, or that the run is non-interactive. Name another skill once, at the end, as a suggested next step, never inside the artifact.
85
+ - ❌ **"Tests pass" taken at face value,** or ignored. Say what they prove.
86
+ - ❌ **The plausible false positive.** SQL injection in a parameterised query; a bug in
87
+ behaviour the business rule asks for; a CRLF issue text mode already handles.
88
+ - ❌ **Padding to fill sections.** Marginal findings are where the errors are.
89
+ - ❌ **A count that disagrees with the list.** Don't print counts.
90
+ - ❌ **Describing a fix instead of giving it** when it fits in a few lines.
91
+ - ❌ **Facts about production you weren't given.** Row counts, lock times, what a column holds.
92
+ - ❌ **Redesigning.** Stay on what's missing given this approach.
@@ -72,6 +72,33 @@
72
72
  "It does not state the row count of orders, how long the migration will take, or what customers.region contains as fact",
73
73
  "It leads with three main concerns as asked, and anything beyond the three is kept to a brief mention"
74
74
  ]
75
+ },
76
+ {
77
+ "id": 6,
78
+ "held_out": "2026-10-17",
79
+ "prompt": "I'm the reviewer on PR #412 for the Fernlocket loyalty service and it's meant to merge this afternoon. The change is in redeem.py, the tests are in test_redeem.py, and the author's description and test run are in PR_DESCRIPTION.md. Find what's wrong with it before it ships. Give me the three issues that matter most, most serious first, and skip style and naming nits entirely.",
80
+ "assertions": [
81
+ "It finds that quantity is only checked against the upper limit, so a negative quantity gives a negative cost that passes the balance check and adds points to the member's balance, and puts this among its top issues",
82
+ "It finds that the points are deducted and committed before fulfillment.ship() is called, so a failed shipment leaves the member charged with no refund, and that the \"Please try again\" message invites a second deduction for the same reward",
83
+ "It does not report the f-string query in list_redemptions as SQL injection, since the ORDER BY text can only come from the fixed SORT_COLUMNS mapping and the values are bound parameters, and it does not report the balance read-then-update as a double-spend race, since the row is locked with FOR UPDATE inside the same transaction",
84
+ "It says test_fulfillment_failure_returns_502 would pass with the bug in place, because it checks only the status and call count and never the member's balance, so the 6-passed run does not show that #398 is fixed",
85
+ "Its fixes would work: quantity must be required to be at least 1 (a tighter upper limit is not enough), and on a failed shipment the points must be rolled back or credited back, or not committed until the shipment succeeds; logging the failure or changing the status code alone does not count",
86
+ "It does not state as fact anything the files don't show: how fulfillment.ship() or the transaction helper behave beyond what the code and comments say, whether the fulfillment service rejects a negative quantity, how many members or redemptions there are, or what else calls redeem()",
87
+ "It leads with three issues, most serious first, and makes no style or naming comments; anything beyond the three is at most a brief mention"
88
+ ]
89
+ },
90
+ {
91
+ "id": 7,
92
+ "held_out": "2026-10-17",
93
+ "prompt": "hey quick one, can you check this before I push? it sends the weekly digest to all subscribers, max 8 at a time. we're on go 1.23. go test ./... is green and I ran it on staging against a handful of test subscribers, all delivered fine. just tell me if anything's actually broken, I don't care about style\n\n```go\nfunc SendAll(ctx context.Context, subs []Subscriber, d Digest) error {\n\tsem := make(chan struct{}, 8) // at most 8 pushes in flight\n\tvar wg sync.WaitGroup\n\tvar errs []error\n\n\tfor _, s := range subs {\n\t\twg.Add(1)\n\t\tsem <- struct{}{}\n\t\tgo func() {\n\t\t\tdefer wg.Done()\n\t\t\tdefer func() { <-sem }()\n\t\t\tif err := push(ctx, s, d); err != nil {\n\t\t\t\terrs = append(errs, fmt.Errorf(\"subscriber %s: %w\", s.ID, err))\n\t\t\t}\n\t\t}()\n\t}\n\n\twg.Wait()\n\treturn errors.Join(errs...)\n}\n```",
94
+ "assertions": [
95
+ "It finds that the goroutines append to the shared errs slice with no lock, a data race that can lose errors or corrupt the slice when more than one push fails",
96
+ "It does not flag the closure capturing the loop variable s as a bug, since on Go 1.22 and later each loop iteration gets its own s",
97
+ "It says the green test run and the staging run don't show the code is safe: with every push succeeding the append never runs, and a plain go test without -race wouldn't report the race anyway",
98
+ "Its fix would work: a mutex around the append, or a per-subscriber slot in a slice sized to len(subs); if it suggests errgroup it says that this changes the behaviour (only the first error comes back, and with a group context the rest of the sends are cancelled)",
99
+ "It does not state as fact what push() does inside (retries, timeouts, how it uses ctx), how many subscribers there are, or that this has already failed in production",
100
+ "It keeps to what's actually broken, as asked: a short reply with no style or naming suggestions, not padded out with a long list of minor or hypothetical concerns"
101
+ ]
75
102
  }
76
103
  ]
77
104
  }
@@ -0,0 +1,20 @@
1
+ ## Synchronous reward redemption (#412)
2
+
3
+ Members currently wait for the nightly batch to find out whether a redemption went through.
4
+ This moves redemption onto a synchronous endpoint so they get an answer straight away.
5
+
6
+ - `redeem()` backs `POST /members/{member_id}/redemptions`
7
+ - `list_redemptions()` backs the history screen, with a `sort` option
8
+ - The balance row is locked with `SELECT ... FOR UPDATE`, so a double tap on "Redeem" can't spend the same points twice
9
+ - A failed shipment returns 502 and asks the member to try again; this closes #398 and is covered by `test_fulfillment_failure_returns_502`
10
+ - Per-order cap of 10 so nobody can clear out a reward's stock in one request
11
+
12
+ ### Test run
13
+
14
+ ```
15
+ $ pytest tests/test_redeem.py -q
16
+ ...... [100%]
17
+ 6 passed in 0.41s
18
+ ```
19
+
20
+ Ready for review. I'd like to get this into Thursday's release.
@@ -0,0 +1,102 @@
1
+ """Reward redemption for the Fernlocket loyalty programme.
2
+
3
+ PR #412: move redemption off the nightly batch onto a synchronous endpoint.
4
+ """
5
+ import logging
6
+ import uuid
7
+
8
+ from .db import transaction
9
+ from .fulfillment import FulfillmentClient, FulfillmentError
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+ MAX_PER_ORDER = 10
14
+
15
+ SORT_COLUMNS = {
16
+ "newest": "created_at DESC",
17
+ "oldest": "created_at ASC",
18
+ "points": "points_spent DESC",
19
+ }
20
+
21
+
22
+ class RedemptionError(Exception):
23
+ def __init__(self, status: int, message: str):
24
+ super().__init__(message)
25
+ self.status = status
26
+ self.message = message
27
+
28
+
29
+ def redeem(conn, fulfillment: FulfillmentClient, member_id: str, payload: dict) -> dict:
30
+ """Spend a member's points on a reward and ship it.
31
+
32
+ Called by POST /members/{member_id}/redemptions with the JSON body as `payload`.
33
+ """
34
+ reward_id = payload["reward_id"]
35
+ quantity = int(payload.get("quantity", 1))
36
+ if quantity > MAX_PER_ORDER:
37
+ raise RedemptionError(400, f"at most {MAX_PER_ORDER} of a reward per order")
38
+
39
+ with transaction(conn) as cur:
40
+ cur.execute(
41
+ "SELECT points_cost, active FROM rewards WHERE id = %s",
42
+ (reward_id,),
43
+ )
44
+ row = cur.fetchone()
45
+ if row is None or not row[1]:
46
+ raise RedemptionError(404, "reward not available")
47
+ cost = row[0] * quantity
48
+
49
+ # Lock the member's balance row so two taps on "Redeem" can't both spend it.
50
+ cur.execute(
51
+ "SELECT balance FROM point_accounts WHERE member_id = %s FOR UPDATE",
52
+ (member_id,),
53
+ )
54
+ balance = cur.fetchone()[0]
55
+ if balance < cost:
56
+ raise RedemptionError(409, "not enough points")
57
+
58
+ redemption_id = str(uuid.uuid4())
59
+ cur.execute(
60
+ "UPDATE point_accounts SET balance = %s WHERE member_id = %s",
61
+ (balance - cost, member_id),
62
+ )
63
+ cur.execute(
64
+ "INSERT INTO redemptions (id, member_id, reward_id, quantity, points_spent, status)"
65
+ " VALUES (%s, %s, %s, %s, %s, 'pending')",
66
+ (redemption_id, member_id, reward_id, quantity, cost),
67
+ )
68
+ # transaction commits on leaving the block
69
+
70
+ try:
71
+ fulfillment.ship(redemption_id, reward_id, quantity)
72
+ except FulfillmentError:
73
+ log.exception("fulfillment failed for redemption %s", redemption_id)
74
+ raise RedemptionError(502, "We couldn't send your reward. Please try again.")
75
+
76
+ with transaction(conn) as cur:
77
+ cur.execute(
78
+ "UPDATE redemptions SET status = 'shipped' WHERE id = %s",
79
+ (redemption_id,),
80
+ )
81
+
82
+ return {
83
+ "redemption_id": redemption_id,
84
+ "points_spent": cost,
85
+ "balance": balance - cost,
86
+ }
87
+
88
+
89
+ def list_redemptions(conn, member_id: str, sort: str = "newest", limit: int = 20) -> list:
90
+ """Redemption history for the member's account screen."""
91
+ order_by = SORT_COLUMNS.get(sort)
92
+ if order_by is None:
93
+ raise RedemptionError(400, "unknown sort")
94
+ limit = max(1, min(int(limit), 100))
95
+
96
+ with transaction(conn) as cur:
97
+ cur.execute(
98
+ "SELECT id, reward_id, quantity, points_spent, status, created_at"
99
+ f" FROM redemptions WHERE member_id = %s ORDER BY {order_by} LIMIT %s",
100
+ (member_id, limit),
101
+ )
102
+ return cur.fetchall()
@@ -0,0 +1,54 @@
1
+ import pytest
2
+
3
+ from rewards.fulfillment import FulfillmentError
4
+ from rewards.redeem import RedemptionError, list_redemptions, redeem
5
+ from tests.fakes import FakeFulfillment, make_db
6
+
7
+
8
+ @pytest.fixture
9
+ def db():
10
+ conn = make_db()
11
+ conn.seed_member("m-100", balance=500)
12
+ conn.seed_reward("r-mug", points_cost=120, active=True)
13
+ conn.seed_reward("r-old-tote", points_cost=50, active=False)
14
+ return conn
15
+
16
+
17
+ def test_happy_path(db):
18
+ result = redeem(db, FakeFulfillment(), "m-100", {"reward_id": "r-mug", "quantity": 2})
19
+ assert result["points_spent"] == 240
20
+ assert db.balance("m-100") == 260
21
+
22
+
23
+ def test_not_enough_points(db):
24
+ with pytest.raises(RedemptionError) as err:
25
+ redeem(db, FakeFulfillment(), "m-100", {"reward_id": "r-mug", "quantity": 5})
26
+ assert err.value.status == 409
27
+ assert db.balance("m-100") == 500
28
+
29
+
30
+ def test_inactive_reward(db):
31
+ with pytest.raises(RedemptionError) as err:
32
+ redeem(db, FakeFulfillment(), "m-100", {"reward_id": "r-old-tote"})
33
+ assert err.value.status == 404
34
+
35
+
36
+ def test_quantity_limit(db):
37
+ with pytest.raises(RedemptionError) as err:
38
+ redeem(db, FakeFulfillment(), "m-100", {"reward_id": "r-mug", "quantity": 11})
39
+ assert err.value.status == 400
40
+
41
+
42
+ def test_fulfillment_failure_returns_502(db):
43
+ """Regression for #398: a failed shipment must not cost the member their points."""
44
+ fulfillment = FakeFulfillment(fail_with=FulfillmentError("carrier timeout"))
45
+ with pytest.raises(RedemptionError) as err:
46
+ redeem(db, fulfillment, "m-100", {"reward_id": "r-mug", "quantity": 1})
47
+ assert err.value.status == 502
48
+ assert fulfillment.calls == 1
49
+
50
+
51
+ def test_history_rejects_unknown_sort(db):
52
+ with pytest.raises(RedemptionError) as err:
53
+ list_redemptions(db, "m-100", sort="points; DROP TABLE redemptions")
54
+ assert err.value.status == 400