diff --git a/.changeset/review-eval-family-tripwires.md b/.changeset/review-eval-family-tripwires.md new file mode 100644 index 00000000..7d6a8395 --- /dev/null +++ b/.changeset/review-eval-family-tripwires.md @@ -0,0 +1,5 @@ +--- +"review": patch +--- + +Eval-only: grow the corpus by seven live cases (nine across the stack, counting the two minted in the preceding corpus PR) across seven previously uncovered defect families (removed-behavior, cross-file chain, mechanical-churn needle, non-idempotent retry, check-then-act race, boundary double-count, and a 29-file cross-subsystem tree), add a mustNotFlagSpec precision probe to the churn case, and document the calibration finding in the operator guide: all nine stack cases calibrated saturated under the Opus roster across ~56 identical-arm samples, so hand-authored synthetics are family tripwires by construction and recall discrimination must be grown from real material (golden human-comment cases and production incident repros). No change to the shipped review workflow. diff --git a/workflows/review/eval/README.md b/workflows/review/eval/README.md index f2a4170f..b00b9fe1 100644 --- a/workflows/review/eval/README.md +++ b/workflows/review/eval/README.md @@ -103,6 +103,26 @@ recorded artifacts through the matcher before trusting new rates (the sql-missing-index case read 8/16 for a week because the spec, not the reviewer, was wrong). +Hard-won calibration finding (2026-07-20, runs 29763213774 / 29764855482 / +29765059892 / 29765275168 / 29767404342): hand-authored synthetic cases do +not reach the 20-80% band at any feasibly authorable size. Nine +single-defect cases from three design philosophies (removed-behavior, +cross-file chain, 13-file churn needle, non-idempotent retry, check-then-act +race, boundary double-count, two retention/lifecycle mid-band targets, and a +29-file cross-subsystem case whose invariant sits two unchanged hops from +the diff), including a hardening pass that removed every stated invariant, +all calibrated 100% across ~56 identical-arm samples on the Opus roster. +When the relevant context is discoverable at all, the correctness reviewer's +named procedures reason to the defect reliably; subtlety of planting and +tree size up to ~30 files do not move the rate. Author synthetics as +deliberate family tripwires (they still catch regressions and host +mustNotFlagSpecs for precision). Every case that has ever calibrated in-band +or at floor derives from REAL material: golden cases minted from human +review comments and incident repros from production defects, at real-repo +scale and messiness. Grow recall discrimination from those sources only, and +calibrate every new case with an identical-arm `--force-arms` run before +claiming a band. + ## Reading a report - **Load-bearing:** must-catch recall against labeled specs, verdict diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-crossfile-report-redaction/case.json b/workflows/review/eval/corpus/synthetic-mutations/mutation-crossfile-report-redaction/case.json new file mode 100644 index 00000000..2b68a991 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-crossfile-report-redaction/case.json @@ -0,0 +1,96 @@ +{ + "id": "mutation-crossfile-report-redaction", + "tags": [ + "mutation", + "cross-file", + "live" + ], + "category": "synthetic-mutation", + "description": "Model-capability discrimination case (cross-file inference-chain family), minted 2026-07-20 and hardened same day after v1 calibrated saturated (4/4 under the Opus roster; policy.ts stated the redaction rule outright). Seeing the defect now requires composing the files without any stated rule: share.ts sends the built rows to a partner webhook (an external destination), and the unchanged format-report.ts documents that the unset-redact default includes raw userId and email columns and 'is only safe for reports that stay inside the org'. The new buildPartnerReport calls formatReport(relevant) with no options, so partners receive raw identifiers and emails. The test asserts feature filtering only and passes either way. No single file looks wrong in isolation. Calibrated saturated under the Opus roster twice (runs 29763213774 v1 4/4, 29764855482 v2-hardened 4/4): the cross-file trace composes three small files reliably. Kept as the cross-file family's regression tripwire, not a discriminator; samples also post a companion missing-redaction-test finding (reviewer-side dedup gap), deliberately not annotated; postedCommentCount is deliberately unset because that companion makes the live posted count vary (0-3 observed), while mustCatch and the verdict still gate.", + "changedFiles": [ + { + "path": "src/reports/partner-report.ts", + "status": "added" + }, + { + "path": "src/reports/share.ts", + "status": "added" + }, + { + "path": "src/reports/partner-report.test.ts", + "status": "added" + } + ], + "dimensions": { + "correctness": "assessed", + "skillSeverity": "assessed", + "patternTriage": "assessed" + }, + "findings": [ + { + "source": "correctness", + "finding": { + "schema_version": 2, + "id": "partner-report-unredacted", + "lens": "correctness", + "anchor": { + "type": "line", + "path": "src/reports/partner-report.ts", + "line": 11, + "side": "RIGHT" + }, + "severity": "blocking", + "confidence": 0.8, + "evidence_trace": [ + "src/reports/share.ts POSTs the built rows to a partner webhook, an external destination", + "src/reports/format-report.ts documents that the unset-redact default includes raw userId and email columns and is only safe for reports that stay inside the org", + "src/reports/partner-report.ts:11 calls formatReport(relevant) with no options" + ], + "failure_scenario": "The weekly partner job builds rows with buildPartnerReport and posts them via shareWithPartner; because formatReport is called without redact: true, every row carries the raw userId and email of the users who used the partner's features, sending PII to a third party even though the formatter documents the unredacted default as only safe inside the org.", + "producing_hunt": "correctness:cross-file-trace", + "model_authored_prose": "buildPartnerReport calls formatReport(relevant) without options, and the formatter's documented default includes raw userId and email columns and is described as only safe for reports that stay inside the org. These rows go to a partner webhook (share.ts). Pass {redact: true} here." + } + } + ], + "validation": [ + { + "id": "partner-report-unredacted", + "verification": "confirmed" + } + ], + "policyConflicts": [], + "expected": { + "verdict": "REQUEST_CHANGES", + "mustCatch": [ + "partner-report-unredacted" + ] + }, + "diff": "diff --git a/src/reports/partner-report.test.ts b/src/reports/partner-report.test.ts\nnew file mode 100644\nindex 0000000..8a8b80b\n--- /dev/null\n+++ b/src/reports/partner-report.test.ts\n@@ -0,0 +1,25 @@\n+import {buildPartnerReport} from \"./partner-report\";\n+import type {UsageEvent} from \"./format-report\";\n+\n+const event = (feature: string): UsageEvent => ({\n+ userId: \"u1\",\n+ email: \"u1@example.com\",\n+ feature,\n+ at: 1,\n+});\n+\n+describe(\"buildPartnerReport\", () => {\n+ it(\"keeps only the partner's own features\", () => {\n+ const rows = buildPartnerReport(\n+ [event(\"search\"), event(\"editor\"), event(\"search\")],\n+ [\"search\"],\n+ );\n+ expect(rows).toHaveLength(2);\n+ expect(rows.every((row) => row[\"feature\"] === \"search\")).toBe(true);\n+ });\n+\n+ it(\"returns no rows when nothing matches\", () => {\n+ const rows = buildPartnerReport([event(\"editor\")], [\"search\"]);\n+ expect(rows).toHaveLength(0);\n+ });\n+});\ndiff --git a/src/reports/partner-report.ts b/src/reports/partner-report.ts\nnew file mode 100644\nindex 0000000..693484f\n--- /dev/null\n+++ b/src/reports/partner-report.ts\n@@ -0,0 +1,12 @@\n+import {formatReport, type ReportRow, type UsageEvent} from \"./format-report\";\n+\n+/** Weekly usage summary a partner integration receives for its own features. */\n+export const buildPartnerReport = (\n+ events: UsageEvent[],\n+ partnerFeatures: string[],\n+): ReportRow[] => {\n+ const relevant = events.filter((event) =>\n+ partnerFeatures.includes(event.feature),\n+ );\n+ return formatReport(relevant);\n+};\ndiff --git a/src/reports/share.ts b/src/reports/share.ts\nnew file mode 100644\nindex 0000000..fc73ae0\n--- /dev/null\n+++ b/src/reports/share.ts\n@@ -0,0 +1,24 @@\n+import type {ReportRow} from \"./format-report\";\n+import {PARTNER_WEBHOOK_TIMEOUT_MS} from \"./policy\";\n+\n+/** POSTs a built report to the partner's registered webhook. */\n+export const shareWithPartner = async (\n+ webhookUrl: string,\n+ rows: ReportRow[],\n+): Promise => {\n+ const controller = new AbortController();\n+ const timer = setTimeout(\n+ () => controller.abort(),\n+ PARTNER_WEBHOOK_TIMEOUT_MS,\n+ );\n+ try {\n+ await fetch(webhookUrl, {\n+ method: \"POST\",\n+ headers: {\"content-type\": \"application/json\"},\n+ body: JSON.stringify({rows}),\n+ signal: controller.signal,\n+ });\n+ } finally {\n+ clearTimeout(timer);\n+ }\n+};\n", + "live": { + "prContext": { + "title": "reports: send partners a weekly usage summary for their features", + "description": "Adds a weekly partner usage report and a webhook sender. The report reuses the shared formatter; rows are scoped to the partner's own features. Covered by filtering tests.", + "author": "dev-reports", + "baseBranch": "main" + }, + "tree": "tree", + "mustCatchSpecs": [ + { + "key": "partner-report-unredacted", + "path": "src/reports/partner-report.ts", + "lineStart": 4, + "lineEnd": 12, + "mechanism": [ + "redact", + "email|PII|personal(ly identifiable)? (data|information)|raw (user ?id|identifier)", + "(leave|leaves|leaving|outside|beyond|across|crosses).{0,50}(org|boundary)|third.part|(partner|webhook|external).{0,80}(email|raw|unredacted|identifier|PII)" + ], + "altLocations": [ + { + "path": "src/reports/share.ts" + } + ] + } + ] + } +} diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-crossfile-report-redaction/tree/src/reports/format-report.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-crossfile-report-redaction/tree/src/reports/format-report.ts new file mode 100644 index 00000000..e817c9df --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-crossfile-report-redaction/tree/src/reports/format-report.ts @@ -0,0 +1,39 @@ +export type UsageEvent = { + userId: string; + email: string; + feature: string; + at: number; +}; + +export type FormatOptions = { + /** + * When true, user identifiers are replaced with opaque hashes and the + * email column is omitted. When false or unset the rows carry the raw + * userId and email columns; that default exists for the internal + * dashboards and is only safe for reports that stay inside the org. + */ + redact?: boolean; +}; + +export type ReportRow = Record; + +const hash = (value: string): string => + `h${Array.from(value).reduce( + (acc, char) => (acc * 31 + char.charCodeAt(0)) % 1_000_000_007, + 7, + )}`; + +export const formatReport = ( + events: UsageEvent[], + options: FormatOptions = {}, +): ReportRow[] => + events.map((event) => + options.redact + ? {user: hash(event.userId), feature: event.feature, at: event.at} + : { + user: event.userId, + email: event.email, + feature: event.feature, + at: event.at, + }, + ); diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-crossfile-report-redaction/tree/src/reports/partner-report.test.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-crossfile-report-redaction/tree/src/reports/partner-report.test.ts new file mode 100644 index 00000000..8a8b80b6 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-crossfile-report-redaction/tree/src/reports/partner-report.test.ts @@ -0,0 +1,25 @@ +import {buildPartnerReport} from "./partner-report"; +import type {UsageEvent} from "./format-report"; + +const event = (feature: string): UsageEvent => ({ + userId: "u1", + email: "u1@example.com", + feature, + at: 1, +}); + +describe("buildPartnerReport", () => { + it("keeps only the partner's own features", () => { + const rows = buildPartnerReport( + [event("search"), event("editor"), event("search")], + ["search"], + ); + expect(rows).toHaveLength(2); + expect(rows.every((row) => row["feature"] === "search")).toBe(true); + }); + + it("returns no rows when nothing matches", () => { + const rows = buildPartnerReport([event("editor")], ["search"]); + expect(rows).toHaveLength(0); + }); +}); diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-crossfile-report-redaction/tree/src/reports/partner-report.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-crossfile-report-redaction/tree/src/reports/partner-report.ts new file mode 100644 index 00000000..693484fc --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-crossfile-report-redaction/tree/src/reports/partner-report.ts @@ -0,0 +1,12 @@ +import {formatReport, type ReportRow, type UsageEvent} from "./format-report"; + +/** Weekly usage summary a partner integration receives for its own features. */ +export const buildPartnerReport = ( + events: UsageEvent[], + partnerFeatures: string[], +): ReportRow[] => { + const relevant = events.filter((event) => + partnerFeatures.includes(event.feature), + ); + return formatReport(relevant); +}; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-crossfile-report-redaction/tree/src/reports/policy.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-crossfile-report-redaction/tree/src/reports/policy.ts new file mode 100644 index 00000000..4b1fc762 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-crossfile-report-redaction/tree/src/reports/policy.ts @@ -0,0 +1,2 @@ +/** Shared constants for outbound report delivery. */ +export const PARTNER_WEBHOOK_TIMEOUT_MS = 5_000; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-crossfile-report-redaction/tree/src/reports/share.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-crossfile-report-redaction/tree/src/reports/share.ts new file mode 100644 index 00000000..fc73ae04 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-crossfile-report-redaction/tree/src/reports/share.ts @@ -0,0 +1,24 @@ +import type {ReportRow} from "./format-report"; +import {PARTNER_WEBHOOK_TIMEOUT_MS} from "./policy"; + +/** POSTs a built report to the partner's registered webhook. */ +export const shareWithPartner = async ( + webhookUrl: string, + rows: ReportRow[], +): Promise => { + const controller = new AbortController(); + const timer = setTimeout( + () => controller.abort(), + PARTNER_WEBHOOK_TIMEOUT_MS, + ); + try { + await fetch(webhookUrl, { + method: "POST", + headers: {"content-type": "application/json"}, + body: JSON.stringify({rows}), + signal: controller.signal, + }); + } finally { + clearTimeout(timer); + } +}; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/case.json b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/case.json new file mode 100644 index 00000000..0bf4a8d7 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/case.json @@ -0,0 +1,113 @@ +{ + "id": "mutation-largetree-digest-category-bypass", + "tags": [ + "mutation", + "large-tree", + "cross-file", + "live" + ], + "category": "synthetic-mutation", + "description": "Scale-hypothesis discrimination case, minted 2026-07-20 after five hand-authored small synthetics (2-13 files) all calibrated saturated under the Opus roster: this one tests whether difficulty comes from tree size and cross-subsystem indirection. A 29-file notifications platform where the PR ships the second half of a digest launch: assembly (digest/assemble.ts), scheduling, and a bulk sender that goes straight to the transport. The defect is invisible in the diff alone: the unchanged delivery/send.ts queues weekly-frequency users' items BEFORE its per-category isSubscribed check (deliberately, per its comment, because preferences can change before digest time), so the queue is unfiltered, and the new assembly drains and sends it without ever re-checking isSubscribed(userId, item.category). The unchanged preferences/defaults.ts makes the consequence concrete: marketing is opt-in (default false), so every weekly-frequency user receives marketing items in their digest that immediate-frequency users never receive. The tests exercise subscribed categories only. Catching it requires composing three unchanged files (send.ts queue ordering, store.ts semantics, defaults.ts opt-in table) with the new path. Calibrated saturated under the Opus roster (run 29767404342, 6/6 identical-arm samples): even at 29 files with the invariant two unchanged hops from the diff, the cross-file trace reasons to the defect. Consistent with the scale-at-authorable-size hypothesis being false, with one attribution caveat: the retained send.ts comment (preferences can change between queueing and digest time) all but states the missing re-check, so saturation here cannot distinguish scale adds no difficulty from the hint gave it away. Kept as the large-tree family tripwire; live samples also post companion advisories (2-3 comments observed), so postedCommentCount is deliberately unset while mustCatch and the verdict still gate.", + "changedFiles": [ + { + "path": "src/notify/digest/assemble.ts", + "status": "added" + }, + { + "path": "src/notify/digest/schedule.ts", + "status": "added" + }, + { + "path": "src/notify/digest/assemble.test.ts", + "status": "added" + }, + { + "path": "src/notify/delivery/bulk.ts", + "status": "added" + }, + { + "path": "src/notify/templates/digest.ts", + "status": "added" + } + ], + "dimensions": { + "correctness": "assessed", + "skillSeverity": "assessed", + "patternTriage": "assessed" + }, + "findings": [ + { + "source": "correctness", + "finding": { + "schema_version": 2, + "id": "digest-skips-category-check", + "lens": "correctness", + "anchor": { + "type": "line", + "path": "src/notify/digest/assemble.ts", + "line": 20, + "side": "RIGHT" + }, + "severity": "blocking", + "confidence": 0.8, + "evidence_trace": [ + "src/notify/delivery/send.ts queues weekly users' items before its isSubscribed(item.category) check, and its comment says queued items are stored as-is because preferences can change before digest time", + "src/notify/digest/assemble.ts:20 drains the pending queue and renders every item; nothing between drainPending and sendBatch checks isSubscribed(userId, item.category)", + "src/notify/delivery/bulk.ts sends the assembled digest straight to the transport, bypassing the per-item send pipeline where the category check lives", + "src/notify/preferences/defaults.ts marks marketing (and tips) opt-in by default, so the unchecked path delivers content most users never opted into" + ], + "failure_scenario": "A weekly-frequency user with the default marketing opt-out has a marketing item produced for them; send.ts queues it unfiltered (by design), buildDigest drains and renders it without re-checking isSubscribed, and sendBatch delivers it, so every weekly user receives marketing (and any other unsubscribed category) that immediate users are correctly protected from; at digest scale this is a mass unsolicited-email incident.", + "producing_hunt": "correctness:cross-file-trace", + "model_authored_prose": "The digest path never re-applies the per-category subscription check: send.ts deliberately queues weekly users' items unfiltered (its comment says preferences can change before digest time), and buildDigest drains and sends everything via the transport-direct bulk sender. Filter the drained items with isSubscribed(userId, item.category) in buildDigest, or the digest delivers marketing and other opt-in categories to users who never subscribed." + } + } + ], + "validation": [ + { + "id": "digest-skips-category-check", + "verification": "confirmed" + } + ], + "policyConflicts": [], + "expected": { + "verdict": "REQUEST_CHANGES", + "mustCatch": [ + "digest-skips-category-check" + ] + }, + "diff": "diff --git a/src/notify/delivery/bulk.ts b/src/notify/delivery/bulk.ts\nnew file mode 100644\nindex 0000000..ec4463a\n--- /dev/null\n+++ b/src/notify/delivery/bulk.ts\n@@ -0,0 +1,15 @@\n+import type {Digest} from \"../digest/assemble\";\n+import {emailFor} from \"../users/directory\";\n+import {sendEmail} from \"./transport\";\n+\n+/**\n+ * Digest delivery goes straight to the transport: the digest is one email,\n+ * already assembled, so the per-item send pipeline does not apply.\n+ */\n+export const sendBatch = async (digest: Digest): Promise => {\n+ await sendEmail({\n+ to: emailFor(digest.userId),\n+ subject: digest.subject,\n+ html: digest.html,\n+ });\n+};\ndiff --git a/src/notify/digest/assemble.test.ts b/src/notify/digest/assemble.test.ts\nnew file mode 100644\nindex 0000000..307adf5\n--- /dev/null\n+++ b/src/notify/digest/assemble.test.ts\n@@ -0,0 +1,33 @@\n+import {setFrequency} from \"../preferences/store\";\n+import {buildDigest} from \"./assemble\";\n+import {queuePending} from \"./pending\";\n+\n+const item = (userId: string, id: string, category: string) => ({\n+ id,\n+ userId,\n+ category,\n+ subject: `s-${id}`,\n+ body: `b-${id}`,\n+ at: Number(id.slice(1)),\n+});\n+\n+describe(\"buildDigest\", () => {\n+ it(\"returns null for immediate-frequency users\", () => {\n+ queuePending(item(\"u1\", \"i1\", \"product_updates\"));\n+ expect(buildDigest(\"u1\")).toBeNull();\n+ });\n+\n+ it(\"bundles queued items for weekly users\", () => {\n+ setFrequency(\"u2\", \"weekly\");\n+ queuePending(item(\"u2\", \"i1\", \"product_updates\"));\n+ queuePending(item(\"u2\", \"i2\", \"billing\"));\n+ const digest = buildDigest(\"u2\");\n+ expect(digest?.itemCount).toBe(2);\n+ expect(digest?.subject).toContain(\"2 updates\");\n+ });\n+\n+ it(\"returns null when nothing is queued\", () => {\n+ setFrequency(\"u3\", \"weekly\");\n+ expect(buildDigest(\"u3\")).toBeNull();\n+ });\n+});\ndiff --git a/src/notify/digest/assemble.ts b/src/notify/digest/assemble.ts\nnew file mode 100644\nindex 0000000..563a6fb\n--- /dev/null\n+++ b/src/notify/digest/assemble.ts\n@@ -0,0 +1,30 @@\n+import {wantsDigest} from \"../preferences/store\";\n+import {renderDigest} from \"../templates/digest\";\n+import {drainPending} from \"./pending\";\n+\n+export type Digest = {\n+ userId: string;\n+ subject: string;\n+ html: string;\n+ itemCount: number;\n+};\n+\n+/**\n+ * Builds the weekly digest for one user, or null when there is nothing to\n+ * send this week.\n+ */\n+export const buildDigest = (userId: string): Digest | null => {\n+ if (!wantsDigest(userId)) {\n+ return null;\n+ }\n+ const items = drainPending(userId);\n+ if (items.length === 0) {\n+ return null;\n+ }\n+ return {\n+ userId,\n+ subject: `Your weekly digest (${items.length} updates)`,\n+ html: renderDigest(items),\n+ itemCount: items.length,\n+ };\n+};\ndiff --git a/src/notify/digest/schedule.ts b/src/notify/digest/schedule.ts\nnew file mode 100644\nindex 0000000..7710b6c\n--- /dev/null\n+++ b/src/notify/digest/schedule.ts\n@@ -0,0 +1,19 @@\n+import {sendBatch} from \"../delivery/bulk\";\n+import {recordDigest} from \"../audit/log\";\n+import {buildDigest} from \"./assemble\";\n+import {usersWithPending} from \"./pending\";\n+\n+/** Runs Monday 09:00 platform time; invoked by the platform cron. */\n+export const runWeeklyDigests = async (): Promise => {\n+ let sent = 0;\n+ for (const userId of usersWithPending()) {\n+ const digest = buildDigest(userId);\n+ if (digest === null) {\n+ continue;\n+ }\n+ await sendBatch(digest);\n+ recordDigest(digest.userId, digest.itemCount);\n+ sent += 1;\n+ }\n+ return sent;\n+};\ndiff --git a/src/notify/templates/digest.ts b/src/notify/templates/digest.ts\nnew file mode 100644\nindex 0000000..7c25933\n--- /dev/null\n+++ b/src/notify/templates/digest.ts\n@@ -0,0 +1,12 @@\n+import type {NotificationItem} from \"../types\";\n+import {wrapHtml} from \"./layout\";\n+import {renderItem} from \"./render\";\n+\n+/** One section per queued item, oldest first, inside the standard shell. */\n+export const renderDigest = (items: NotificationItem[]): string =>\n+ wrapHtml(\n+ [\n+ `

Your week in review

`,\n+ ...items.map((item) => `
${renderItem(item)}
`),\n+ ].join(\"\\n\"),\n+ );\n", + "live": { + "prContext": { + "title": "notifications: ship the weekly digest sender", + "description": "Second half of the digest launch: items for weekly-frequency users have been queueing since the frequency preference shipped, and this adds the assembly and bulk delivery that sends them every Monday. Digest delivery respects the weekly opt-in. Covered by assembly tests.", + "author": "dev-notify", + "baseBranch": "main" + }, + "tree": "tree", + "mustCatchSpecs": [ + { + "key": "digest-skips-category-check", + "path": "src/notify/digest/assemble.ts", + "lineStart": 12, + "lineEnd": 30, + "mechanism": [ + "isSubscribed", + "categor(y|ies).{0,80}(check|filter|subscri|re.?check|preference)|(check|filter|re.?check).{0,60}categor", + "unsubscribed.{0,100}(item|marketing|categor|content)|(marketing|opt.in|opted.out|never (opted|subscribed)).{0,120}(digest|deliver|receiv|sent)", + "bypass.{0,60}(check|pipeline|subscription)|skips? the (per.item|subscription|category)|without (re.?)?(checking|filtering)" + ], + "altLocations": [ + { + "path": "src/notify/delivery/bulk.ts" + }, + { + "path": "src/notify/digest/schedule.ts" + }, + { + "path": "src/notify/digest/assemble.test.ts" + } + ] + } + ] + } +} diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/audit/log.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/audit/log.ts new file mode 100644 index 00000000..680d8e3f --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/audit/log.ts @@ -0,0 +1,39 @@ +import type {NotificationItem} from "../types"; + +export type AuditRecord = { + kind: "delivery" | "drop" | "digest"; + userId: string; + detail: string; + at: number; +}; + +const records: AuditRecord[] = []; + +export const recordDelivery = (item: NotificationItem): void => { + records.push({ + kind: "delivery", + userId: item.userId, + detail: item.id, + at: Date.now(), + }); +}; + +export const recordDrop = (item: NotificationItem, reason: string): void => { + records.push({ + kind: "drop", + userId: item.userId, + detail: `${item.id}:${reason}`, + at: Date.now(), + }); +}; + +export const recordDigest = (userId: string, itemCount: number): void => { + records.push({ + kind: "digest", + userId, + detail: `items:${itemCount}`, + at: Date.now(), + }); +}; + +export const auditTrail = (): readonly AuditRecord[] => records; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/categories.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/categories.ts new file mode 100644 index 00000000..f3872409 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/categories.ts @@ -0,0 +1,14 @@ +/** Notification categories users can subscribe to individually. */ +export const CATEGORIES = [ + "product_updates", + "marketing", + "billing", + "security", + "community", + "tips", +] as const; + +export type Category = typeof CATEGORIES[number]; + +export const isCategory = (value: string): value is Category => + (CATEGORIES as readonly string[]).includes(value); diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/delivery/bulk.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/delivery/bulk.ts new file mode 100644 index 00000000..ec4463af --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/delivery/bulk.ts @@ -0,0 +1,15 @@ +import type {Digest} from "../digest/assemble"; +import {emailFor} from "../users/directory"; +import {sendEmail} from "./transport"; + +/** + * Digest delivery goes straight to the transport: the digest is one email, + * already assembled, so the per-item send pipeline does not apply. + */ +export const sendBatch = async (digest: Digest): Promise => { + await sendEmail({ + to: emailFor(digest.userId), + subject: digest.subject, + html: digest.html, + }); +}; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/delivery/retry.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/delivery/retry.ts new file mode 100644 index 00000000..56fc1215 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/delivery/retry.ts @@ -0,0 +1,28 @@ +import type {Email} from "./transport"; +import {sendEmail} from "./transport"; + +type QueuedRetry = { + email: Email; + attempts: number; +}; + +const MAX_RETRIES = 3; +const queue: QueuedRetry[] = []; + +export const enqueueRetry = (email: Email): void => { + queue.push({email, attempts: 0}); +}; + +/** Drains the retry queue; called by the delivery cron every 5 minutes. */ +export const flushRetries = async (): Promise => { + const batch = queue.splice(0, queue.length); + for (const entry of batch) { + try { + await sendEmail(entry.email); + } catch { + if (entry.attempts + 1 < MAX_RETRIES) { + queue.push({email: entry.email, attempts: entry.attempts + 1}); + } + } + } +}; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/delivery/send.test.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/delivery/send.test.ts new file mode 100644 index 00000000..ecaec561 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/delivery/send.test.ts @@ -0,0 +1,27 @@ +import {sendNotification} from "./send"; +import {sentEmails} from "./transport"; +import {setSubscription} from "../preferences/store"; + +const item = (userId: string, id: string, category: string) => ({ + id, + userId, + category, + subject: `s-${id}`, + body: `b-${id}`, + at: 1, +}); + +describe("sendNotification", () => { + it("sends subscribed categories immediately", async () => { + const before = sentEmails().length; + await sendNotification(item("s1", "i1", "billing")); + expect(sentEmails().length).toBe(before + 1); + }); + + it("drops unsubscribed categories", async () => { + setSubscription("s2", "community", false); + const before = sentEmails().length; + await sendNotification(item("s2", "i2", "community")); + expect(sentEmails().length).toBe(before); + }); +}); diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/delivery/send.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/delivery/send.ts new file mode 100644 index 00000000..7d325cfb --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/delivery/send.ts @@ -0,0 +1,36 @@ +import type {NotificationItem} from "../types"; +import {isCategory} from "../categories"; +import {frequency, isSubscribed} from "../preferences/store"; +import {queuePending} from "../digest/pending"; +import {renderItem} from "../templates/render"; +import {emailFor} from "../users/directory"; +import {recordDelivery, recordDrop} from "../audit/log"; +import {sendEmail} from "./transport"; + +/** + * Entry point every producer calls. Weekly-frequency users have their items + * queued for the digest instead of sent one by one; preferences can change + * between queueing and digest time, so queued items are stored as-is. + */ +export const sendNotification = async ( + item: NotificationItem, +): Promise => { + if (!isCategory(item.category)) { + recordDrop(item, "unknown-category"); + return; + } + if (frequency(item.userId) === "weekly") { + queuePending(item); + return; + } + if (!isSubscribed(item.userId, item.category)) { + recordDrop(item, "unsubscribed"); + return; + } + await sendEmail({ + to: emailFor(item.userId), + subject: item.subject, + html: renderItem(item), + }); + recordDelivery(item); +}; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/delivery/transport.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/delivery/transport.ts new file mode 100644 index 00000000..f230743f --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/delivery/transport.ts @@ -0,0 +1,14 @@ +export type Email = { + to: string; + subject: string; + html: string; +}; + +const outbox: Email[] = []; + +/** Hands the email to the provider; retried by retry.ts on failure. */ +export const sendEmail = async (email: Email): Promise => { + outbox.push(email); +}; + +export const sentEmails = (): readonly Email[] => outbox; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/digest/assemble.test.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/digest/assemble.test.ts new file mode 100644 index 00000000..307adf52 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/digest/assemble.test.ts @@ -0,0 +1,33 @@ +import {setFrequency} from "../preferences/store"; +import {buildDigest} from "./assemble"; +import {queuePending} from "./pending"; + +const item = (userId: string, id: string, category: string) => ({ + id, + userId, + category, + subject: `s-${id}`, + body: `b-${id}`, + at: Number(id.slice(1)), +}); + +describe("buildDigest", () => { + it("returns null for immediate-frequency users", () => { + queuePending(item("u1", "i1", "product_updates")); + expect(buildDigest("u1")).toBeNull(); + }); + + it("bundles queued items for weekly users", () => { + setFrequency("u2", "weekly"); + queuePending(item("u2", "i1", "product_updates")); + queuePending(item("u2", "i2", "billing")); + const digest = buildDigest("u2"); + expect(digest?.itemCount).toBe(2); + expect(digest?.subject).toContain("2 updates"); + }); + + it("returns null when nothing is queued", () => { + setFrequency("u3", "weekly"); + expect(buildDigest("u3")).toBeNull(); + }); +}); diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/digest/assemble.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/digest/assemble.ts new file mode 100644 index 00000000..563a6fb1 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/digest/assemble.ts @@ -0,0 +1,30 @@ +import {wantsDigest} from "../preferences/store"; +import {renderDigest} from "../templates/digest"; +import {drainPending} from "./pending"; + +export type Digest = { + userId: string; + subject: string; + html: string; + itemCount: number; +}; + +/** + * Builds the weekly digest for one user, or null when there is nothing to + * send this week. + */ +export const buildDigest = (userId: string): Digest | null => { + if (!wantsDigest(userId)) { + return null; + } + const items = drainPending(userId); + if (items.length === 0) { + return null; + } + return { + userId, + subject: `Your weekly digest (${items.length} updates)`, + html: renderDigest(items), + itemCount: items.length, + }; +}; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/digest/pending.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/digest/pending.ts new file mode 100644 index 00000000..aab797cb --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/digest/pending.ts @@ -0,0 +1,18 @@ +import type {NotificationItem} from "../types"; + +const pending = new Map(); + +export const queuePending = (item: NotificationItem): void => { + const queue = pending.get(item.userId) ?? []; + queue.push(item); + pending.set(item.userId, queue); +}; + +/** Removes and returns the user's queued items, oldest first. */ +export const drainPending = (userId: string): NotificationItem[] => { + const items = pending.get(userId) ?? []; + pending.delete(userId); + return [...items].sort((a, b) => a.at - b.at); +}; + +export const usersWithPending = (): string[] => [...pending.keys()]; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/digest/schedule.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/digest/schedule.ts new file mode 100644 index 00000000..7710b6c3 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/digest/schedule.ts @@ -0,0 +1,19 @@ +import {sendBatch} from "../delivery/bulk"; +import {recordDigest} from "../audit/log"; +import {buildDigest} from "./assemble"; +import {usersWithPending} from "./pending"; + +/** Runs Monday 09:00 platform time; invoked by the platform cron. */ +export const runWeeklyDigests = async (): Promise => { + let sent = 0; + for (const userId of usersWithPending()) { + const digest = buildDigest(userId); + if (digest === null) { + continue; + } + await sendBatch(digest); + recordDigest(digest.userId, digest.itemCount); + sent += 1; + } + return sent; +}; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/metrics/counters.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/metrics/counters.ts new file mode 100644 index 00000000..f1987463 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/metrics/counters.ts @@ -0,0 +1,7 @@ +const counters = new Map(); + +export const bump = (name: string, by = 1): void => { + counters.set(name, (counters.get(name) ?? 0) + by); +}; + +export const counterValue = (name: string): number => counters.get(name) ?? 0; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/preferences/defaults.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/preferences/defaults.ts new file mode 100644 index 00000000..7478cca9 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/preferences/defaults.ts @@ -0,0 +1,17 @@ +import type {Category} from "../categories"; + +/** + * Subscription defaults for users who never touched their settings. + * Marketing and tips are opt-in; everything else is opt-out. + */ +export const DEFAULT_SUBSCRIPTIONS: Record = { + product_updates: true, + marketing: false, + billing: true, + security: true, + community: true, + tips: false, +}; + +/** Digest delivery is opt-in; users start on immediate sends. */ +export const DEFAULT_FREQUENCY = "immediate"; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/preferences/store.test.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/preferences/store.test.ts new file mode 100644 index 00000000..0e45ef5b --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/preferences/store.test.ts @@ -0,0 +1,16 @@ +import {isSubscribed, setFrequency, setSubscription, wantsDigest} from "./store"; + +describe("preferences store", () => { + it("applies subscription defaults until a user opts in", () => { + expect(isSubscribed("p1", "marketing")).toBe(false); + expect(isSubscribed("p1", "billing")).toBe(true); + setSubscription("p1", "marketing", true); + expect(isSubscribed("p1", "marketing")).toBe(true); + }); + + it("digest opt-in follows frequency", () => { + expect(wantsDigest("p2")).toBe(false); + setFrequency("p2", "weekly"); + expect(wantsDigest("p2")).toBe(true); + }); +}); diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/preferences/store.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/preferences/store.ts new file mode 100644 index 00000000..67a02404 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/preferences/store.ts @@ -0,0 +1,29 @@ +import type {Category} from "../categories"; +import type {Frequency} from "../types"; +import {DEFAULT_FREQUENCY, DEFAULT_SUBSCRIPTIONS} from "./defaults"; + +const subscriptions = new Map>>(); +const frequencies = new Map(); + +export const isSubscribed = (userId: string, category: Category): boolean => + subscriptions.get(userId)?.[category] ?? DEFAULT_SUBSCRIPTIONS[category]; + +export const setSubscription = ( + userId: string, + category: Category, + subscribed: boolean, +): void => { + const current = subscriptions.get(userId) ?? {}; + subscriptions.set(userId, {...current, [category]: subscribed}); +}; + +export const frequency = (userId: string): Frequency => + frequencies.get(userId) ?? DEFAULT_FREQUENCY; + +export const setFrequency = (userId: string, value: Frequency): void => { + frequencies.set(userId, value); +}; + +/** Digest opt-in rides on frequency; there is no separate digest category. */ +export const wantsDigest = (userId: string): boolean => + frequency(userId) === "weekly"; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/billing.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/billing.ts new file mode 100644 index 00000000..cb5b5409 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/billing.ts @@ -0,0 +1,7 @@ +import type {NotificationItem} from "../types"; + +export const billing = (item: NotificationItem): string => + [ + `Billing update: ${item.subject}`, + `

${item.body}

`, + ].join("\n"); diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/community.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/community.ts new file mode 100644 index 00000000..02194b48 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/community.ts @@ -0,0 +1,7 @@ +import type {NotificationItem} from "../types"; + +export const community = (item: NotificationItem): string => + [ + `

Community: ${item.subject}

`, + `

${item.body}

`, + ].join("\n"); diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/digest.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/digest.ts new file mode 100644 index 00000000..7c259338 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/digest.ts @@ -0,0 +1,12 @@ +import type {NotificationItem} from "../types"; +import {wrapHtml} from "./layout"; +import {renderItem} from "./render"; + +/** One section per queued item, oldest first, inside the standard shell. */ +export const renderDigest = (items: NotificationItem[]): string => + wrapHtml( + [ + `

Your week in review

`, + ...items.map((item) => `
${renderItem(item)}
`), + ].join("\n"), + ); diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/layout.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/layout.ts new file mode 100644 index 00000000..b0b8ae87 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/layout.ts @@ -0,0 +1,8 @@ +/** Standard email shell: preheader, body slot, unsubscribe footer. */ +export const wrapHtml = (body: string): string => + [ + ``, + body, + ``, + ``, + ].join("\n"); diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/marketing.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/marketing.ts new file mode 100644 index 00000000..e848072c --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/marketing.ts @@ -0,0 +1,7 @@ +import type {NotificationItem} from "../types"; + +export const marketing = (item: NotificationItem): string => + [ + `

From the team: ${item.subject}

`, + `

${item.body}

`, + ].join("\n"); diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/product-updates.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/product-updates.ts new file mode 100644 index 00000000..7e62ff5a --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/product-updates.ts @@ -0,0 +1,7 @@ +import type {NotificationItem} from "../types"; + +export const productUpdates = (item: NotificationItem): string => + [ + `

What's new: ${item.subject}

`, + `

${item.body}

`, + ].join("\n"); diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/registry.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/registry.ts new file mode 100644 index 00000000..8eee9cec --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/registry.ts @@ -0,0 +1,24 @@ +import type {NotificationItem} from "../types"; +import {productUpdates} from "./product-updates"; +import {marketing} from "./marketing"; +import {billing} from "./billing"; +import {security} from "./security"; +import {community} from "./community"; +import {tips} from "./tips"; + +export type Template = (item: NotificationItem) => string; + +const templates: Record = { + product_updates: productUpdates, + marketing, + billing, + security, + community, + tips, +}; + +const fallback: Template = (item) => + `

${item.subject}

${item.body}

`; + +export const templateFor = (category: string): Template => + templates[category] ?? fallback; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/render.test.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/render.test.ts new file mode 100644 index 00000000..6e22cd06 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/render.test.ts @@ -0,0 +1,27 @@ +import {renderItem} from "./render"; + +describe("renderItem", () => { + it("renders through the category template", () => { + const html = renderItem({ + id: "i1", + userId: "u1", + category: "billing", + subject: "Invoice ready", + body: "Your invoice for June is ready.", + at: 1, + }); + expect(html).toContain("Invoice ready"); + }); + + it("falls back for unknown categories", () => { + const html = renderItem({ + id: "i2", + userId: "u1", + category: "unknown", + subject: "x", + body: "y", + at: 1, + }); + expect(html).toContain("

x

"); + }); +}); diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/render.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/render.ts new file mode 100644 index 00000000..e3d38b7d --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/render.ts @@ -0,0 +1,5 @@ +import type {NotificationItem} from "../types"; +import {templateFor} from "./registry"; + +export const renderItem = (item: NotificationItem): string => + templateFor(item.category)(item); diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/security.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/security.ts new file mode 100644 index 00000000..e35dd79e --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/security.ts @@ -0,0 +1,7 @@ +import type {NotificationItem} from "../types"; + +export const security = (item: NotificationItem): string => + [ + `Security notice: ${item.subject}`, + `

${item.body}

`, + ].join("\n"); diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/tips.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/tips.ts new file mode 100644 index 00000000..2402f676 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/templates/tips.ts @@ -0,0 +1,7 @@ +import type {NotificationItem} from "../types"; + +export const tips = (item: NotificationItem): string => + [ + `Tip: ${item.subject}`, + `

${item.body}

`, + ].join("\n"); diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/types.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/types.ts new file mode 100644 index 00000000..c718e370 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/types.ts @@ -0,0 +1,12 @@ +export type NotificationItem = { + id: string; + userId: string; + /** One of the ids in categories.ts. */ + category: string; + subject: string; + body: string; + /** Creation time, epoch milliseconds. */ + at: number; +}; + +export type Frequency = "immediate" | "weekly"; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/users/directory.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/users/directory.ts new file mode 100644 index 00000000..d5d3c9d8 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/users/directory.ts @@ -0,0 +1,8 @@ +const emails = new Map(); + +export const registerUser = (userId: string, email: string): void => { + emails.set(userId, email); +}; + +export const emailFor = (userId: string): string => + emails.get(userId) ?? `${userId}@users.internal`; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/users/profile.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/users/profile.ts new file mode 100644 index 00000000..6d4faffc --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-largetree-digest-category-bypass/tree/src/notify/users/profile.ts @@ -0,0 +1,18 @@ +export type Profile = { + userId: string; + displayName: string; + locale: string; +}; + +const profiles = new Map(); + +export const profileFor = (userId: string): Profile => + profiles.get(userId) ?? { + userId, + displayName: userId, + locale: "en-US", + }; + +export const saveProfile = (profile: Profile): void => { + profiles.set(profile.userId, profile); +}; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/case.json b/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/case.json new file mode 100644 index 00000000..cb8db4bc --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/case.json @@ -0,0 +1,145 @@ +{ + "id": "mutation-needle-paging-rename", + "tags": [ + "mutation", + "mechanical-churn", + "live" + ], + "category": "synthetic-mutation", + "description": "Model-capability discrimination case (needle-in-mechanical-churn family), minted 2026-07-20 and hardened same day after v1 calibrated saturated (4/4 under the Opus roster at 9 files). A 13-file mechanical PR flips fetchPage's argument order from (items, limit, offset) to (items, offset, pageSize) and renames the per-module LIMIT constants to PAGE_SIZE. Eleven call sites swap their arguments correctly; digest.ts renames its constant but keeps the old order, so digestItems calls fetchPage(byRank, DIGEST_PAGE_SIZE, 0), which under the new signature is offset 20 and pageSize 0: the weekly digest is always empty. digest.test.ts only asserts the empty-input case and passes either way. Tests whether a reviewer keeps auditing individual call sites through repetitive rename churn. A mustNotFlagSpec on recent.ts (a correctly swapped consumer) counts a reviewer that pattern-flags renamed call sites without checking them as a false flag. Calibrated saturated under the Opus roster twice (runs 29763213774 v1 at 9 files 4/4, 29764855482 v2 at 13 files 4/4): call-site auditing survives this much churn. Kept as the mechanical-churn family's regression tripwire with the mustNotFlagSpec as the precision probe; discrimination at this family likely needs real-PR-scale churn (30+ files).", + "changedFiles": [ + { + "path": "src/catalog/paging.ts", + "status": "modified" + }, + { + "path": "src/catalog/featured.ts", + "status": "modified" + }, + { + "path": "src/catalog/recent.ts", + "status": "modified" + }, + { + "path": "src/catalog/search.ts", + "status": "modified" + }, + { + "path": "src/catalog/archive.ts", + "status": "modified" + }, + { + "path": "src/catalog/admin-list.ts", + "status": "modified" + }, + { + "path": "src/catalog/related.ts", + "status": "modified" + }, + { + "path": "src/catalog/trending.ts", + "status": "modified" + }, + { + "path": "src/catalog/favorites.ts", + "status": "modified" + }, + { + "path": "src/catalog/drafts.ts", + "status": "modified" + }, + { + "path": "src/catalog/pinned.ts", + "status": "modified" + }, + { + "path": "src/catalog/digest.ts", + "status": "modified" + }, + { + "path": "src/catalog/paging.test.ts", + "status": "modified" + } + ], + "dimensions": { + "correctness": "assessed", + "skillSeverity": "assessed", + "patternTriage": "assessed" + }, + "findings": [ + { + "source": "correctness", + "finding": { + "schema_version": 2, + "id": "digest-paging-args-not-swapped", + "lens": "correctness", + "anchor": { + "type": "line", + "path": "src/catalog/digest.ts", + "line": 9, + "side": "RIGHT" + }, + "severity": "blocking", + "confidence": 0.85, + "evidence_trace": [ + "src/catalog/paging.ts changes fetchPage's signature from (items, limit, offset) to (items, offset, pageSize)", + "the other eleven call sites (featured, recent, search, archive, admin-list, related, trending, favorites, drafts, pinned, and the paging tests) all swap their arguments to the new order", + "src/catalog/digest.ts:9 renames DIGEST_LIMIT to DIGEST_PAGE_SIZE but still passes it first: fetchPage(byRank, DIGEST_PAGE_SIZE, 0) is offset 20, pageSize 0", + "digest.test.ts only asserts the empty-input case, so the suite stays green" + ], + "failure_scenario": "After this PR ships, digestItems calls fetchPage with offset 20 and pageSize 0 under the new signature, so slice(20, 20) returns [] for every input and the weekly email digest renders empty for all users; the only digest test asserts the empty-input case and cannot catch it.", + "producing_hunt": "correctness:line-scan", + "model_authored_prose": "digest.ts renamed DIGEST_LIMIT to DIGEST_PAGE_SIZE but did not swap the arguments: fetchPage(byRank, DIGEST_PAGE_SIZE, 0) now means offset 20, pageSize 0, so the digest is always empty. Swap to fetchPage(byRank, 0, DIGEST_PAGE_SIZE) like the other call sites." + } + } + ], + "validation": [ + { + "id": "digest-paging-args-not-swapped", + "verification": "confirmed" + } + ], + "policyConflicts": [], + "expected": { + "verdict": "REQUEST_CHANGES", + "mustCatch": [ + "digest-paging-args-not-swapped" + ], + "postedCommentCount": 1 + }, + "diff": "diff --git a/src/catalog/admin-list.ts b/src/catalog/admin-list.ts\nindex ae5cd99..bf5d40d 100644\n--- a/src/catalog/admin-list.ts\n+++ b/src/catalog/admin-list.ts\n@@ -1,9 +1,9 @@\n import {fetchPage, type Item} from \"./paging\";\n \n-const ADMIN_LIMIT = 100;\n+const ADMIN_PAGE_SIZE = 100;\n \n /** Admin console listing: unfiltered, newest id first. */\n export const adminList = (items: Item[], page: number): Item[] => {\n const byId = [...items].sort((a, b) => b.id.localeCompare(a.id));\n- return fetchPage(byId, ADMIN_LIMIT, page * ADMIN_LIMIT);\n+ return fetchPage(byId, page * ADMIN_PAGE_SIZE, ADMIN_PAGE_SIZE);\n };\ndiff --git a/src/catalog/archive.ts b/src/catalog/archive.ts\nindex 32dee70..3ef5448 100644\n--- a/src/catalog/archive.ts\n+++ b/src/catalog/archive.ts\n@@ -1,8 +1,8 @@\n import {fetchPage, type Item} from \"./paging\";\n \n-const ARCHIVE_LIMIT = 50;\n+const ARCHIVE_PAGE_SIZE = 50;\n \n export const archivedItems = (items: Item[], page: number): Item[] => {\n const archived = items.filter((item) => item.archived);\n- return fetchPage(archived, ARCHIVE_LIMIT, page * ARCHIVE_LIMIT);\n+ return fetchPage(archived, page * ARCHIVE_PAGE_SIZE, ARCHIVE_PAGE_SIZE);\n };\ndiff --git a/src/catalog/digest.ts b/src/catalog/digest.ts\nindex f639130..434b21a 100644\n--- a/src/catalog/digest.ts\n+++ b/src/catalog/digest.ts\n@@ -1,10 +1,10 @@\n import {fetchPage, type Item} from \"./paging\";\n \n-const DIGEST_LIMIT = 20;\n+const DIGEST_PAGE_SIZE = 20;\n \n /** Items for the weekly email digest: newest first, one page. */\n export const digestItems = (items: Item[]): Item[] => {\n const active = items.filter((item) => !item.archived);\n const byRank = [...active].sort((a, b) => b.rank - a.rank);\n- return fetchPage(byRank, DIGEST_LIMIT, 0);\n+ return fetchPage(byRank, DIGEST_PAGE_SIZE, 0);\n };\ndiff --git a/src/catalog/drafts.ts b/src/catalog/drafts.ts\nindex bd74277..355a8b8 100644\n--- a/src/catalog/drafts.ts\n+++ b/src/catalog/drafts.ts\n@@ -1,7 +1,7 @@\n import {fetchPage, type Item} from \"./paging\";\n \n-const DRAFTS_LIMIT = 15;\n+const DRAFTS_PAGE_SIZE = 15;\n \n /** Unranked drafts listing, insertion order. */\n export const draftItems = (drafts: Item[], page: number): Item[] =>\n- fetchPage(drafts, DRAFTS_LIMIT, page * DRAFTS_LIMIT);\n+ fetchPage(drafts, page * DRAFTS_PAGE_SIZE, DRAFTS_PAGE_SIZE);\ndiff --git a/src/catalog/favorites.ts b/src/catalog/favorites.ts\nindex 127730a..d63fa88 100644\n--- a/src/catalog/favorites.ts\n+++ b/src/catalog/favorites.ts\n@@ -1,6 +1,6 @@\n import {fetchPage, type Item} from \"./paging\";\n \n-const FAVORITES_LIMIT = 30;\n+const FAVORITES_PAGE_SIZE = 30;\n \n export const favoriteItems = (\n items: Item[],\n@@ -8,5 +8,5 @@ export const favoriteItems = (\n page: number,\n ): Item[] => {\n const favorites = items.filter((item) => favoriteIds.includes(item.id));\n- return fetchPage(favorites, FAVORITES_LIMIT, page * FAVORITES_LIMIT);\n+ return fetchPage(favorites, page * FAVORITES_PAGE_SIZE, FAVORITES_PAGE_SIZE);\n };\ndiff --git a/src/catalog/featured.ts b/src/catalog/featured.ts\nindex 301d6e6..0280d43 100644\n--- a/src/catalog/featured.ts\n+++ b/src/catalog/featured.ts\n@@ -1,8 +1,8 @@\n import {fetchPage, type Item} from \"./paging\";\n \n-const FEATURED_LIMIT = 6;\n+const FEATURED_PAGE_SIZE = 6;\n \n export const featuredItems = (items: Item[]): Item[] => {\n const byRank = [...items].sort((a, b) => b.rank - a.rank);\n- return fetchPage(byRank, FEATURED_LIMIT, 0);\n+ return fetchPage(byRank, 0, FEATURED_PAGE_SIZE);\n };\ndiff --git a/src/catalog/paging.test.ts b/src/catalog/paging.test.ts\nindex 161b244..a1cbc75 100644\n--- a/src/catalog/paging.test.ts\n+++ b/src/catalog/paging.test.ts\n@@ -10,14 +10,14 @@ const item = (id: string, rank: number): Item => ({\n const items = [item(\"a\", 3), item(\"b\", 2), item(\"c\", 1)];\n \n describe(\"fetchPage\", () => {\n- it(\"returns limit rows starting at offset\", () => {\n- expect(fetchPage(items, 2, 1).map((row) => row.id)).toEqual([\n+ it(\"returns pageSize rows starting at offset\", () => {\n+ expect(fetchPage(items, 1, 2).map((row) => row.id)).toEqual([\n \"b\",\n \"c\",\n ]);\n });\n \n it(\"returns a short page at the tail\", () => {\n- expect(fetchPage(items, 5, 2)).toHaveLength(1);\n+ expect(fetchPage(items, 2, 5)).toHaveLength(1);\n });\n });\ndiff --git a/src/catalog/paging.ts b/src/catalog/paging.ts\nindex be64e67..51a15a6 100644\n--- a/src/catalog/paging.ts\n+++ b/src/catalog/paging.ts\n@@ -5,9 +5,12 @@ export type Item = {\n archived: boolean;\n };\n \n-/** Returns one page of items: `limit` rows starting at `offset`. */\n+/**\n+ * Returns one page of items: `pageSize` rows starting at `offset`. Argument\n+ * order matches the Db facade: offset first, then page size.\n+ */\n export const fetchPage = (\n items: Item[],\n- limit: number,\n offset: number,\n-): Item[] => items.slice(offset, offset + limit);\n+ pageSize: number,\n+): Item[] => items.slice(offset, offset + pageSize);\ndiff --git a/src/catalog/pinned.ts b/src/catalog/pinned.ts\nindex 42c1891..f40cd3c 100644\n--- a/src/catalog/pinned.ts\n+++ b/src/catalog/pinned.ts\n@@ -1,9 +1,9 @@\n import {fetchPage, type Item} from \"./paging\";\n \n-const PINNED_LIMIT = 3;\n+const PINNED_PAGE_SIZE = 3;\n \n export const pinnedItems = (items: Item[], pinnedIds: string[]): Item[] => {\n const pinned = items.filter((item) => pinnedIds.includes(item.id));\n const byRank = [...pinned].sort((a, b) => b.rank - a.rank);\n- return fetchPage(byRank, PINNED_LIMIT, 0);\n+ return fetchPage(byRank, 0, PINNED_PAGE_SIZE);\n };\ndiff --git a/src/catalog/recent.ts b/src/catalog/recent.ts\nindex 934e95b..a8f3a8d 100644\n--- a/src/catalog/recent.ts\n+++ b/src/catalog/recent.ts\n@@ -1,8 +1,8 @@\n import {fetchPage, type Item} from \"./paging\";\n \n-const RECENT_LIMIT = 25;\n+const RECENT_PAGE_SIZE = 25;\n \n export const recentItems = (items: Item[], page: number): Item[] => {\n const active = items.filter((item) => !item.archived);\n- return fetchPage(active, RECENT_LIMIT, page * RECENT_LIMIT);\n+ return fetchPage(active, page * RECENT_PAGE_SIZE, RECENT_PAGE_SIZE);\n };\ndiff --git a/src/catalog/related.ts b/src/catalog/related.ts\nindex 2373c9e..45764b3 100644\n--- a/src/catalog/related.ts\n+++ b/src/catalog/related.ts\n@@ -1,9 +1,9 @@\n import {fetchPage, type Item} from \"./paging\";\n \n-const RELATED_LIMIT = 4;\n+const RELATED_PAGE_SIZE = 4;\n \n export const relatedItems = (items: Item[], toItem: Item): Item[] => {\n const others = items.filter((item) => item.id !== toItem.id);\n const byRank = [...others].sort((a, b) => b.rank - a.rank);\n- return fetchPage(byRank, RELATED_LIMIT, 0);\n+ return fetchPage(byRank, 0, RELATED_PAGE_SIZE);\n };\ndiff --git a/src/catalog/search.ts b/src/catalog/search.ts\nindex 4e22a69..4e6402e 100644\n--- a/src/catalog/search.ts\n+++ b/src/catalog/search.ts\n@@ -1,6 +1,6 @@\n import {fetchPage, type Item} from \"./paging\";\n \n-const SEARCH_LIMIT = 10;\n+const SEARCH_PAGE_SIZE = 10;\n \n export const searchItems = (\n items: Item[],\n@@ -10,5 +10,5 @@ export const searchItems = (\n const hits = items.filter((item) =>\n item.title.toLowerCase().includes(query.toLowerCase()),\n );\n- return fetchPage(hits, SEARCH_LIMIT, page * SEARCH_LIMIT);\n+ return fetchPage(hits, page * SEARCH_PAGE_SIZE, SEARCH_PAGE_SIZE);\n };\ndiff --git a/src/catalog/trending.ts b/src/catalog/trending.ts\nindex 511d2a1..9bfc368 100644\n--- a/src/catalog/trending.ts\n+++ b/src/catalog/trending.ts\n@@ -1,9 +1,9 @@\n import {fetchPage, type Item} from \"./paging\";\n \n-const TRENDING_LIMIT = 12;\n+const TRENDING_PAGE_SIZE = 12;\n \n export const trendingItems = (items: Item[], page: number): Item[] => {\n const active = items.filter((item) => !item.archived);\n const byRank = [...active].sort((a, b) => b.rank - a.rank);\n- return fetchPage(byRank, TRENDING_LIMIT, page * TRENDING_LIMIT);\n+ return fetchPage(byRank, page * TRENDING_PAGE_SIZE, TRENDING_PAGE_SIZE);\n };\n", + "live": { + "prContext": { + "title": "catalog: align fetchPage argument order with the Db facade (offset, pageSize)", + "description": "Mechanical: fetchPage(items, limit, offset) becomes fetchPage(items, offset, pageSize) to match the Db facade's (offset, pageSize) convention, and the per-module LIMIT constants are renamed to PAGE_SIZE. All call sites updated; paging tests updated to the new order.", + "author": "dev-catalog", + "baseBranch": "main" + }, + "tree": "tree", + "mustCatchSpecs": [ + { + "key": "digest-paging-args-not-swapped", + "path": "src/catalog/digest.ts", + "lineStart": 3, + "lineEnd": 10, + "mechanism": [ + "(not|n't|never|forgot|without|fails? to|missing|didn.t).{0,40}swap|swap(ped)?.{0,50}(not|never|missing)|old (argument |arg |parameter )?order|argument order|arg order|order of (the )?arguments|positional", + "offset.{0,60}page.?size|page.?size.{0,60}offset", + "DIGEST_PAGE_SIZE.{0,80}(offset|first)|offset (of |= ?)?20|slice\\(20", + "empty (page|digest|result|list)|always (returns? )?empty|zero (rows|items)|page.?size (of |= ?|is )?0" + ] + } + ], + "mustNotFlagSpecs": [ + { + "key": "needle-correct-swap-false-flag", + "path": "src/catalog/recent.ts", + "mechanism": [ + "(wrong|incorrect|old|not swapped|un.?swapped|reversed|backwards).{0,60}(argument|arg|order|position)", + "argument order|arg order|order of (the )?arguments", + "offset.{0,60}page.?size.{0,60}(wrong|swap|revers)|swap.{0,60}(offset|page.?size)" + ] + } + ] + } +} diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/admin-list.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/admin-list.ts new file mode 100644 index 00000000..bf5d40d5 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/admin-list.ts @@ -0,0 +1,9 @@ +import {fetchPage, type Item} from "./paging"; + +const ADMIN_PAGE_SIZE = 100; + +/** Admin console listing: unfiltered, newest id first. */ +export const adminList = (items: Item[], page: number): Item[] => { + const byId = [...items].sort((a, b) => b.id.localeCompare(a.id)); + return fetchPage(byId, page * ADMIN_PAGE_SIZE, ADMIN_PAGE_SIZE); +}; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/archive.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/archive.ts new file mode 100644 index 00000000..3ef54488 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/archive.ts @@ -0,0 +1,8 @@ +import {fetchPage, type Item} from "./paging"; + +const ARCHIVE_PAGE_SIZE = 50; + +export const archivedItems = (items: Item[], page: number): Item[] => { + const archived = items.filter((item) => item.archived); + return fetchPage(archived, page * ARCHIVE_PAGE_SIZE, ARCHIVE_PAGE_SIZE); +}; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/digest.test.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/digest.test.ts new file mode 100644 index 00000000..fb640ea6 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/digest.test.ts @@ -0,0 +1,7 @@ +import {digestItems} from "./digest"; + +describe("digestItems", () => { + it("renders an empty digest without error", () => { + expect(digestItems([])).toEqual([]); + }); +}); diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/digest.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/digest.ts new file mode 100644 index 00000000..434b21a6 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/digest.ts @@ -0,0 +1,10 @@ +import {fetchPage, type Item} from "./paging"; + +const DIGEST_PAGE_SIZE = 20; + +/** Items for the weekly email digest: newest first, one page. */ +export const digestItems = (items: Item[]): Item[] => { + const active = items.filter((item) => !item.archived); + const byRank = [...active].sort((a, b) => b.rank - a.rank); + return fetchPage(byRank, DIGEST_PAGE_SIZE, 0); +}; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/drafts.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/drafts.ts new file mode 100644 index 00000000..355a8b8e --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/drafts.ts @@ -0,0 +1,7 @@ +import {fetchPage, type Item} from "./paging"; + +const DRAFTS_PAGE_SIZE = 15; + +/** Unranked drafts listing, insertion order. */ +export const draftItems = (drafts: Item[], page: number): Item[] => + fetchPage(drafts, page * DRAFTS_PAGE_SIZE, DRAFTS_PAGE_SIZE); diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/favorites.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/favorites.ts new file mode 100644 index 00000000..d63fa883 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/favorites.ts @@ -0,0 +1,12 @@ +import {fetchPage, type Item} from "./paging"; + +const FAVORITES_PAGE_SIZE = 30; + +export const favoriteItems = ( + items: Item[], + favoriteIds: string[], + page: number, +): Item[] => { + const favorites = items.filter((item) => favoriteIds.includes(item.id)); + return fetchPage(favorites, page * FAVORITES_PAGE_SIZE, FAVORITES_PAGE_SIZE); +}; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/featured.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/featured.ts new file mode 100644 index 00000000..0280d43c --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/featured.ts @@ -0,0 +1,8 @@ +import {fetchPage, type Item} from "./paging"; + +const FEATURED_PAGE_SIZE = 6; + +export const featuredItems = (items: Item[]): Item[] => { + const byRank = [...items].sort((a, b) => b.rank - a.rank); + return fetchPage(byRank, 0, FEATURED_PAGE_SIZE); +}; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/paging.test.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/paging.test.ts new file mode 100644 index 00000000..a1cbc75c --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/paging.test.ts @@ -0,0 +1,23 @@ +import {fetchPage, type Item} from "./paging"; + +const item = (id: string, rank: number): Item => ({ + id, + title: `Item ${id}`, + rank, + archived: false, +}); + +const items = [item("a", 3), item("b", 2), item("c", 1)]; + +describe("fetchPage", () => { + it("returns pageSize rows starting at offset", () => { + expect(fetchPage(items, 1, 2).map((row) => row.id)).toEqual([ + "b", + "c", + ]); + }); + + it("returns a short page at the tail", () => { + expect(fetchPage(items, 2, 5)).toHaveLength(1); + }); +}); diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/paging.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/paging.ts new file mode 100644 index 00000000..51a15a65 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/paging.ts @@ -0,0 +1,16 @@ +export type Item = { + id: string; + title: string; + rank: number; + archived: boolean; +}; + +/** + * Returns one page of items: `pageSize` rows starting at `offset`. Argument + * order matches the Db facade: offset first, then page size. + */ +export const fetchPage = ( + items: Item[], + offset: number, + pageSize: number, +): Item[] => items.slice(offset, offset + pageSize); diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/pinned.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/pinned.ts new file mode 100644 index 00000000..f40cd3c9 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/pinned.ts @@ -0,0 +1,9 @@ +import {fetchPage, type Item} from "./paging"; + +const PINNED_PAGE_SIZE = 3; + +export const pinnedItems = (items: Item[], pinnedIds: string[]): Item[] => { + const pinned = items.filter((item) => pinnedIds.includes(item.id)); + const byRank = [...pinned].sort((a, b) => b.rank - a.rank); + return fetchPage(byRank, 0, PINNED_PAGE_SIZE); +}; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/recent.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/recent.ts new file mode 100644 index 00000000..a8f3a8d8 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/recent.ts @@ -0,0 +1,8 @@ +import {fetchPage, type Item} from "./paging"; + +const RECENT_PAGE_SIZE = 25; + +export const recentItems = (items: Item[], page: number): Item[] => { + const active = items.filter((item) => !item.archived); + return fetchPage(active, page * RECENT_PAGE_SIZE, RECENT_PAGE_SIZE); +}; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/related.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/related.ts new file mode 100644 index 00000000..45764b34 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/related.ts @@ -0,0 +1,9 @@ +import {fetchPage, type Item} from "./paging"; + +const RELATED_PAGE_SIZE = 4; + +export const relatedItems = (items: Item[], toItem: Item): Item[] => { + const others = items.filter((item) => item.id !== toItem.id); + const byRank = [...others].sort((a, b) => b.rank - a.rank); + return fetchPage(byRank, 0, RELATED_PAGE_SIZE); +}; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/search.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/search.ts new file mode 100644 index 00000000..4e6402ed --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/search.ts @@ -0,0 +1,14 @@ +import {fetchPage, type Item} from "./paging"; + +const SEARCH_PAGE_SIZE = 10; + +export const searchItems = ( + items: Item[], + query: string, + page: number, +): Item[] => { + const hits = items.filter((item) => + item.title.toLowerCase().includes(query.toLowerCase()), + ); + return fetchPage(hits, page * SEARCH_PAGE_SIZE, SEARCH_PAGE_SIZE); +}; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/trending.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/trending.ts new file mode 100644 index 00000000..9bfc3685 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-needle-paging-rename/tree/src/catalog/trending.ts @@ -0,0 +1,9 @@ +import {fetchPage, type Item} from "./paging"; + +const TRENDING_PAGE_SIZE = 12; + +export const trendingItems = (items: Item[], page: number): Item[] => { + const active = items.filter((item) => !item.archived); + const byRank = [...active].sort((a, b) => b.rank - a.rank); + return fetchPage(byRank, page * TRENDING_PAGE_SIZE, TRENDING_PAGE_SIZE); +}; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-removed-behavior-logout-cache/case.json b/workflows/review/eval/corpus/synthetic-mutations/mutation-removed-behavior-logout-cache/case.json new file mode 100644 index 00000000..1fdf7ab5 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-removed-behavior-logout-cache/case.json @@ -0,0 +1,94 @@ +{ + "id": "mutation-removed-behavior-logout-cache", + "tags": [ + "mutation", + "removed-behavior", + "live" + ], + "category": "synthetic-mutation", + "description": "Model-capability discrimination case (removed-behavior family), minted 2026-07-20 and hardened same day after v1 calibrated saturated (4/4 under the Opus roster; the cache module's doc comment stated the invariant outright). A refactor extracts a shared endSession teardown for logout and a new admin force-logout, and silently drops the profileCache.drop(session.deviceId) call the old inline logout carried. The unchanged profile-cache.ts documents only the mechanics (per-device cache, 24h expiry); nothing states that logout must invalidate it, so the reviewer has to infer the invariant from the keying: entries are keyed by deviceId, a device outlives a user session, so a surviving entry serves the previous user's profile to the next login on that device. The updated test file also drops the cache assertion, so the suite stays green, and the PR description claims 'behavior unchanged'. Targets the correctness reviewer's removed-behavior audit. Calibrated saturated under the Opus roster twice (runs 29763213774 v1 4/4, 29764855482 v2-hardened 4/4): the correctness reviewer's removed-behavior audit reasons to the defect reliably at this tree size. Kept as the removed-behavior family's regression tripwire, not a discriminator; every sample also posted a companion deleted-test-coverage finding (the known reviewer-side dedup gap), deliberately not annotated as a spec.", + "changedFiles": [ + { + "path": "src/session/logout.ts", + "status": "modified" + }, + { + "path": "src/session/logout.test.ts", + "status": "modified" + } + ], + "dimensions": { + "correctness": "assessed", + "skillSeverity": "assessed", + "patternTriage": "assessed" + }, + "findings": [ + { + "source": "correctness", + "finding": { + "schema_version": 2, + "id": "logout-cache-drop-removed", + "lens": "correctness", + "anchor": { + "type": "line", + "path": "src/session/logout.ts", + "line": 11, + "side": "RIGHT" + }, + "severity": "blocking", + "confidence": 0.85, + "evidence_trace": [ + "src/session/profile-cache.ts keys entries by deviceId with a 24h expiry; a device outlives any one user's session", + "the pre-change logout called profileCache.drop(session.deviceId); the new shared endSession teardown only revokes tokens and records an audit event", + "src/session/logout.test.ts removed the 'drops the device's cached profile' assertion in the same change" + ], + "failure_scenario": "User A logs out on a shared device and user B logs in on the same device within the cache's 24h expiry; endSession no longer calls profileCache.drop, so the stale entry keyed by that deviceId survives and B is served A's cached profile (display name and email), a cross-user data leak the old logout prevented.", + "producing_hunt": "correctness:removed-behavior-audit", + "model_authored_prose": "The refactor drops profileCache.drop(session.deviceId) from the logout path. profile-cache.ts documents that entries are keyed by device id and must be dropped when a session ends; without the drop, the next login on the same device reads the previous user's cached profile until the 24h expiry. Restore the drop inside endSession (both logout and forceLogout need it)." + } + } + ], + "validation": [ + { + "id": "logout-cache-drop-removed", + "verification": "confirmed" + } + ], + "policyConflicts": [], + "expected": { + "verdict": "REQUEST_CHANGES", + "mustCatch": [ + "logout-cache-drop-removed" + ], + "postedCommentCount": 1 + }, + "diff": "diff --git a/src/session/logout.test.ts b/src/session/logout.test.ts\nindex 3421c45..2256d0f 100644\n--- a/src/session/logout.test.ts\n+++ b/src/session/logout.test.ts\n@@ -1,22 +1,22 @@\n-import {logout, type Session} from \"./logout\";\n+import {forceLogout, logout, type Session} from \"./logout\";\n import {isRevoked} from \"./tokens\";\n-import {profileCache} from \"./profile-cache\";\n+import {auditLog} from \"./audit\";\n \n const session: Session = {id: \"s1\", userId: \"u1\", deviceId: \"d1\"};\n \n-describe(\"logout\", () => {\n- it(\"revokes the session's tokens\", async () => {\n+describe(\"session teardown\", () => {\n+ it(\"logout revokes the session's tokens\", async () => {\n await logout(session);\n expect(isRevoked(\"s1\")).toBe(true);\n });\n \n- it(\"drops the device's cached profile\", async () => {\n- profileCache.set(\"d1\", {\n- userId: \"u1\",\n- displayName: \"User One\",\n- email: \"u1@example.com\",\n- });\n- await logout(session);\n- expect(profileCache.get(\"d1\")).toBeUndefined();\n+ it(\"forceLogout records its own audit reason\", async () => {\n+ await forceLogout({...session, id: \"s2\"});\n+ expect(\n+ auditLog().some(\n+ (event) =>\n+ event.type === \"force-logout\" && event.sessionId === \"s2\",\n+ ),\n+ ).toBe(true);\n });\n });\ndiff --git a/src/session/logout.ts b/src/session/logout.ts\nindex 54c2798..57631fb 100644\n--- a/src/session/logout.ts\n+++ b/src/session/logout.ts\n@@ -1,4 +1,3 @@\n-import {profileCache} from \"./profile-cache\";\n import {revokeSessionTokens} from \"./tokens\";\n import {recordAudit} from \"./audit\";\n \n@@ -8,8 +7,16 @@ export type Session = {\n deviceId: string;\n };\n \n-export const logout = async (session: Session): Promise => {\n+/** Shared teardown for user logout and admin force-logout. */\n+const endSession = async (session: Session, reason: string): Promise => {\n await revokeSessionTokens(session.id);\n- profileCache.drop(session.deviceId);\n- recordAudit({type: \"logout\", sessionId: session.id, at: Date.now()});\n+ recordAudit({type: reason, sessionId: session.id, at: Date.now()});\n+};\n+\n+export const logout = async (session: Session): Promise => {\n+ await endSession(session, \"logout\");\n+};\n+\n+export const forceLogout = async (session: Session): Promise => {\n+ await endSession(session, \"force-logout\");\n };\n", + "live": { + "prContext": { + "title": "session: extract shared teardown for logout and admin force-logout", + "description": "Refactors logout so the new admin force-logout path reuses the same teardown. Behavior unchanged; tests updated to cover both paths.", + "author": "dev-session", + "baseBranch": "main" + }, + "tree": "tree", + "mustCatchSpecs": [ + { + "key": "logout-cache-drop-removed", + "path": "src/session/logout.ts", + "lineStart": 10, + "lineEnd": 21, + "mechanism": [ + "profile.?cache.{0,80}(drop|clear|delete|remove|invalid|no longer|missing|gone)", + "(drop|clear|delete|remove|invalid|restore).{0,60}profile.?cache", + "(previous|prior|another|wrong|first) user('s)? (cached )?(profile|data)|stale profile|cross.user", + "(next|new) (login|user).{0,60}(same )?device|deviceId|device id" + ], + "altLocations": [ + { + "path": "src/session/logout.test.ts" + } + ] + } + ] + } +} diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-removed-behavior-logout-cache/tree/src/session/audit.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-removed-behavior-logout-cache/tree/src/session/audit.ts new file mode 100644 index 00000000..252537a1 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-removed-behavior-logout-cache/tree/src/session/audit.ts @@ -0,0 +1,13 @@ +export type AuditEvent = { + type: string; + sessionId: string; + at: number; +}; + +const log: AuditEvent[] = []; + +export const recordAudit = (event: AuditEvent): void => { + log.push(event); +}; + +export const auditLog = (): readonly AuditEvent[] => log; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-removed-behavior-logout-cache/tree/src/session/logout.test.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-removed-behavior-logout-cache/tree/src/session/logout.test.ts new file mode 100644 index 00000000..2256d0fd --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-removed-behavior-logout-cache/tree/src/session/logout.test.ts @@ -0,0 +1,22 @@ +import {forceLogout, logout, type Session} from "./logout"; +import {isRevoked} from "./tokens"; +import {auditLog} from "./audit"; + +const session: Session = {id: "s1", userId: "u1", deviceId: "d1"}; + +describe("session teardown", () => { + it("logout revokes the session's tokens", async () => { + await logout(session); + expect(isRevoked("s1")).toBe(true); + }); + + it("forceLogout records its own audit reason", async () => { + await forceLogout({...session, id: "s2"}); + expect( + auditLog().some( + (event) => + event.type === "force-logout" && event.sessionId === "s2", + ), + ).toBe(true); + }); +}); diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-removed-behavior-logout-cache/tree/src/session/logout.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-removed-behavior-logout-cache/tree/src/session/logout.ts new file mode 100644 index 00000000..57631fbf --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-removed-behavior-logout-cache/tree/src/session/logout.ts @@ -0,0 +1,22 @@ +import {revokeSessionTokens} from "./tokens"; +import {recordAudit} from "./audit"; + +export type Session = { + id: string; + userId: string; + deviceId: string; +}; + +/** Shared teardown for user logout and admin force-logout. */ +const endSession = async (session: Session, reason: string): Promise => { + await revokeSessionTokens(session.id); + recordAudit({type: reason, sessionId: session.id, at: Date.now()}); +}; + +export const logout = async (session: Session): Promise => { + await endSession(session, "logout"); +}; + +export const forceLogout = async (session: Session): Promise => { + await endSession(session, "force-logout"); +}; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-removed-behavior-logout-cache/tree/src/session/profile-cache.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-removed-behavior-logout-cache/tree/src/session/profile-cache.ts new file mode 100644 index 00000000..95ebb16b --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-removed-behavior-logout-cache/tree/src/session/profile-cache.ts @@ -0,0 +1,18 @@ +/** Per-device profile cache; entries expire 24h after they are set. */ +export type Profile = { + userId: string; + displayName: string; + email: string; +}; + +const entries = new Map(); + +export const profileCache = { + get: (deviceId: string): Profile | undefined => entries.get(deviceId), + set: (deviceId: string, profile: Profile): void => { + entries.set(deviceId, profile); + }, + drop: (deviceId: string): void => { + entries.delete(deviceId); + }, +}; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-removed-behavior-logout-cache/tree/src/session/tokens.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-removed-behavior-logout-cache/tree/src/session/tokens.ts new file mode 100644 index 00000000..de56b954 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-removed-behavior-logout-cache/tree/src/session/tokens.ts @@ -0,0 +1,9 @@ +const revoked = new Set(); + +export const revokeSessionTokens = async ( + sessionId: string, +): Promise => { + revoked.add(sessionId); +}; + +export const isRevoked = (sessionId: string): boolean => revoked.has(sessionId); diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-retry-nonidempotent-capture/case.json b/workflows/review/eval/corpus/synthetic-mutations/mutation-retry-nonidempotent-capture/case.json new file mode 100644 index 00000000..a9cbe206 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-retry-nonidempotent-capture/case.json @@ -0,0 +1,95 @@ +{ + "id": "mutation-retry-nonidempotent-capture", + "tags": [ + "mutation", + "partial-failure", + "live" + ], + "category": "synthetic-mutation", + "description": "Model-capability discrimination case (distributed-semantics / partial-failure family), minted 2026-07-20 for the corpus-difficulty work. captureWithRetry retries capturePayment whenever the gateway times out. The unchanged gateway.ts documents only mechanics: a GatewayTimeoutError means no response arrived within 10s, and CaptureOptions.idempotencyKey exists with at-most-once semantics. The reviewer must supply the distributed-systems reasoning itself: a timeout is not a failure (the capture may have been applied server-side), so blind re-send without the idempotency key can charge the card up to three times. The tests fake the gateway with a timeout that never applies the charge, so they pass. Nothing in the diff states the invariant. Calibrated saturated under the Opus roster (run 29765275168, 6/6 identical-arm samples): kept as the family's regression tripwire, not a discriminator.", + "changedFiles": [ + { + "path": "src/billing/capture.ts", + "status": "added" + }, + { + "path": "src/billing/capture.test.ts", + "status": "added" + } + ], + "dimensions": { + "correctness": "assessed", + "skillSeverity": "assessed", + "patternTriage": "assessed" + }, + "findings": [ + { + "source": "correctness", + "finding": { + "schema_version": 2, + "id": "capture-retry-double-charge", + "lens": "correctness", + "anchor": { + "type": "line", + "path": "src/billing/capture.ts", + "line": 17, + "side": "RIGHT" + }, + "severity": "blocking", + "confidence": 0.8, + "evidence_trace": [ + "src/billing/gateway.ts documents GatewayTimeoutError as 'no response arrived within 10s', which does not imply the capture was not applied server-side", + "src/billing/capture.ts:17 re-sends capturePayment(chargeId, amountCents) after a timeout without passing CaptureOptions.idempotencyKey", + "gateway.ts documents idempotencyKey as applied-at-most-once, the mechanism built for exactly this retry", + "capture.test.ts fakes the gateway so a timeout never applies the charge, which is the one behavior the real gateway does not guarantee" + ], + "failure_scenario": "During a provider brownout the gateway applies a capture but the response exceeds 10s; captureWithRetry catches the GatewayTimeoutError and re-sends the same capture up to two more times with no idempotency key, so the card is charged twice (or three times) for one order.", + "producing_hunt": "correctness:line-scan", + "model_authored_prose": "Retrying on GatewayTimeoutError re-sends a capture that may already have been applied; a timeout means no response, not no charge. Without CaptureOptions.idempotencyKey (documented at-most-once), this can double- or triple-charge the card. Generate a key per capture and pass it on every attempt." + } + } + ], + "validation": [ + { + "id": "capture-retry-double-charge", + "verification": "confirmed" + } + ], + "policyConflicts": [], + "expected": { + "verdict": "REQUEST_CHANGES", + "mustCatch": [ + "capture-retry-double-charge" + ], + "postedCommentCount": 1 + }, + "diff": "diff --git a/src/billing/capture.test.ts b/src/billing/capture.test.ts\nnew file mode 100644\nindex 0000000..91e85b4\n--- /dev/null\n+++ b/src/billing/capture.test.ts\n@@ -0,0 +1,29 @@\n+import {captureWithRetry} from \"./capture\";\n+import * as gateway from \"./gateway\";\n+\n+describe(\"captureWithRetry\", () => {\n+ it(\"retries once after a gateway timeout\", async () => {\n+ const calls: number[] = [];\n+ jest.spyOn(gateway, \"capturePayment\").mockImplementation(\n+ async (chargeId, amountCents) => {\n+ calls.push(amountCents);\n+ if (calls.length === 1) {\n+ throw new gateway.GatewayTimeoutError();\n+ }\n+ return {chargeId, amountCents, capturedAt: 5};\n+ },\n+ );\n+ const result = await captureWithRetry(\"ch_1\", 1250);\n+ expect(result.amountCents).toBe(1250);\n+ expect(calls).toHaveLength(2);\n+ });\n+\n+ it(\"does not retry a declined charge\", async () => {\n+ jest.spyOn(gateway, \"capturePayment\").mockRejectedValue(\n+ new Error(\"card_declined\"),\n+ );\n+ await expect(captureWithRetry(\"ch_2\", 500)).rejects.toThrow(\n+ \"card_declined\",\n+ );\n+ });\n+});\ndiff --git a/src/billing/capture.ts b/src/billing/capture.ts\nnew file mode 100644\nindex 0000000..4dfdada\n--- /dev/null\n+++ b/src/billing/capture.ts\n@@ -0,0 +1,26 @@\n+import {\n+ capturePayment,\n+ GatewayTimeoutError,\n+ type CaptureResult,\n+} from \"./gateway\";\n+\n+const MAX_ATTEMPTS = 3;\n+\n+/** Captures a charge, retrying when the gateway times out. */\n+export const captureWithRetry = async (\n+ chargeId: string,\n+ amountCents: number,\n+): Promise => {\n+ let lastError: unknown;\n+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {\n+ try {\n+ return await capturePayment(chargeId, amountCents);\n+ } catch (error) {\n+ if (!(error instanceof GatewayTimeoutError)) {\n+ throw error;\n+ }\n+ lastError = error;\n+ }\n+ }\n+ throw lastError;\n+};\n", + "live": { + "prContext": { + "title": "billing: retry captures that hit gateway timeouts", + "description": "Captures occasionally fail on gateway timeouts during provider incidents; wrap capturePayment in a bounded retry so transient timeouts self-heal. Declines and other errors still fail fast. Covered by retry and fail-fast tests.", + "author": "dev-billing", + "baseBranch": "main" + }, + "tree": "tree", + "mustCatchSpecs": [ + { + "key": "capture-retry-double-charge", + "path": "src/billing/capture.ts", + "lineStart": 9, + "lineEnd": 26, + "mechanism": [ + "idempoten", + "double.?(charg|captur)|charg(e|ed|ing).{0,30}(twice|again|multiple)|captur(e|ed|ing).{0,30}(twice|again|more than once)|applied (twice|more than once)|duplicate (charge|capture)", + "timeout.{0,140}(succeed|applied|landed|went through|server.side|may have|might have|already)|(may|might|could) have (succeeded|been applied|gone through|landed)", + "not idempotent|non.?idempotent" + ], + "altLocations": [ + { + "path": "src/billing/capture.test.ts" + } + ] + } + ] + } +} diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-retry-nonidempotent-capture/tree/src/billing/capture.test.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-retry-nonidempotent-capture/tree/src/billing/capture.test.ts new file mode 100644 index 00000000..91e85b49 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-retry-nonidempotent-capture/tree/src/billing/capture.test.ts @@ -0,0 +1,29 @@ +import {captureWithRetry} from "./capture"; +import * as gateway from "./gateway"; + +describe("captureWithRetry", () => { + it("retries once after a gateway timeout", async () => { + const calls: number[] = []; + jest.spyOn(gateway, "capturePayment").mockImplementation( + async (chargeId, amountCents) => { + calls.push(amountCents); + if (calls.length === 1) { + throw new gateway.GatewayTimeoutError(); + } + return {chargeId, amountCents, capturedAt: 5}; + }, + ); + const result = await captureWithRetry("ch_1", 1250); + expect(result.amountCents).toBe(1250); + expect(calls).toHaveLength(2); + }); + + it("does not retry a declined charge", async () => { + jest.spyOn(gateway, "capturePayment").mockRejectedValue( + new Error("card_declined"), + ); + await expect(captureWithRetry("ch_2", 500)).rejects.toThrow( + "card_declined", + ); + }); +}); diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-retry-nonidempotent-capture/tree/src/billing/capture.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-retry-nonidempotent-capture/tree/src/billing/capture.ts new file mode 100644 index 00000000..4dfdada6 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-retry-nonidempotent-capture/tree/src/billing/capture.ts @@ -0,0 +1,26 @@ +import { + capturePayment, + GatewayTimeoutError, + type CaptureResult, +} from "./gateway"; + +const MAX_ATTEMPTS = 3; + +/** Captures a charge, retrying when the gateway times out. */ +export const captureWithRetry = async ( + chargeId: string, + amountCents: number, +): Promise => { + let lastError: unknown; + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + try { + return await capturePayment(chargeId, amountCents); + } catch (error) { + if (!(error instanceof GatewayTimeoutError)) { + throw error; + } + lastError = error; + } + } + throw lastError; +}; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-retry-nonidempotent-capture/tree/src/billing/gateway.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-retry-nonidempotent-capture/tree/src/billing/gateway.ts new file mode 100644 index 00000000..706cf79a --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-retry-nonidempotent-capture/tree/src/billing/gateway.ts @@ -0,0 +1,35 @@ +export type CaptureResult = { + chargeId: string; + amountCents: number; + capturedAt: number; +}; + +export class GatewayTimeoutError extends Error { + constructor() { + super("payment gateway did not respond within 10s"); + } +} + +export type CaptureOptions = { + /** + * Requests carrying the same key are applied at most once, however many + * times they are sent. Keys are scoped to the merchant account and kept + * for 30 days. + */ + idempotencyKey?: string; +}; + +/** + * Captures a previously authorized charge. Throws GatewayTimeoutError when no + * response arrives within 10s. Network call into the payments platform. + */ +export const capturePayment = async ( + chargeId: string, + amountCents: number, + options: CaptureOptions = {}, +): Promise => { + void chargeId; + void amountCents; + void options; + throw new Error("stubbed in tests"); +}; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-toctou-invite-limit/case.json b/workflows/review/eval/corpus/synthetic-mutations/mutation-toctou-invite-limit/case.json new file mode 100644 index 00000000..5cdfec6f --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-toctou-invite-limit/case.json @@ -0,0 +1,93 @@ +{ + "id": "mutation-toctou-invite-limit", + "tags": [ + "mutation", + "concurrency", + "live" + ], + "category": "synthetic-mutation", + "description": "Model-capability discrimination case (check-then-act concurrency family, subtler than the saturated incident-race-condition lost-update case), minted 2026-07-20 for the corpus-difficulty work. inviteMember reads countMembers, checks the seat limit, then awaits insertMember; two concurrent invites for the last seat both pass the check and both insert, exceeding the paid seat count. The unchanged db.ts exports withTeamLock (documented: serializes writers for a team), the mechanism the new code should have used; nothing states that invites must be serialized. The tests are sequential and pass. Calibrated saturated under the Opus roster (run 29765275168, 6/6 identical-arm samples): kept as the family's regression tripwire, not a discriminator.", + "changedFiles": [ + { + "path": "src/teams/invite.ts", + "status": "added" + }, + { + "path": "src/teams/invite.test.ts", + "status": "added" + } + ], + "dimensions": { + "correctness": "assessed", + "skillSeverity": "assessed", + "patternTriage": "assessed" + }, + "findings": [ + { + "source": "correctness", + "finding": { + "schema_version": 2, + "id": "invite-seat-limit-race", + "lens": "correctness", + "anchor": { + "type": "line", + "path": "src/teams/invite.ts", + "line": 15, + "side": "RIGHT" + }, + "severity": "blocking", + "confidence": 0.8, + "evidence_trace": [ + "src/teams/invite.ts:15-18 reads countMembers and checks the limit before a separate awaited insertMember call", + "nothing serializes the check with the insert; db.ts exports withTeamLock, documented as serializing writers per team, and invite.ts does not use it", + "invite.test.ts exercises the limit only sequentially" + ], + "failure_scenario": "A team is one seat under its limit and two admins invite at the same time; both inviteMember calls read the same countMembers value, both pass the seatLimit check, and both insertMember calls run, leaving the team over its paid seat count with no error raised.", + "producing_hunt": "correctness:cross-file-trace", + "model_authored_prose": "The seat-limit check and the insert are separate awaited steps with no serialization, so concurrent invites for the last seat all pass the check and all insert. Wrap the count-check-insert sequence in withTeamLock (db.ts documents it as serializing writers per team) or enforce the limit atomically in the insert." + } + } + ], + "validation": [ + { + "id": "invite-seat-limit-race", + "verification": "confirmed" + } + ], + "policyConflicts": [], + "expected": { + "verdict": "REQUEST_CHANGES", + "mustCatch": [ + "invite-seat-limit-race" + ], + "postedCommentCount": 1 + }, + "diff": "diff --git a/src/teams/invite.test.ts b/src/teams/invite.test.ts\nnew file mode 100644\nindex 0000000..02f6732\n--- /dev/null\n+++ b/src/teams/invite.test.ts\n@@ -0,0 +1,14 @@\n+import {inviteMember, SeatLimitError} from \"./invite\";\n+\n+describe(\"inviteMember\", () => {\n+ it(\"adds members below the seat limit\", async () => {\n+ await inviteMember(\"t1\", \"a@example.com\", 2);\n+ await inviteMember(\"t1\", \"b@example.com\", 2);\n+ });\n+\n+ it(\"rejects an invite past the seat limit\", async () => {\n+ await expect(\n+ inviteMember(\"t1\", \"c@example.com\", 2),\n+ ).rejects.toThrow(SeatLimitError);\n+ });\n+});\ndiff --git a/src/teams/invite.ts b/src/teams/invite.ts\nnew file mode 100644\nindex 0000000..cf00903\n--- /dev/null\n+++ b/src/teams/invite.ts\n@@ -0,0 +1,25 @@\n+import {countMembers, insertMember} from \"./db\";\n+\n+export class SeatLimitError extends Error {\n+ constructor(teamId: string) {\n+ super(`team ${teamId} is at its seat limit`);\n+ }\n+}\n+\n+/** Adds a member to the team unless the team is already at its seat limit. */\n+export const inviteMember = async (\n+ teamId: string,\n+ email: string,\n+ seatLimit: number,\n+): Promise => {\n+ const current = await countMembers(teamId);\n+ if (current >= seatLimit) {\n+ throw new SeatLimitError(teamId);\n+ }\n+ await insertMember({\n+ teamId,\n+ email,\n+ role: \"member\",\n+ invitedAt: Date.now(),\n+ });\n+};\n", + "live": { + "prContext": { + "title": "teams: enforce the seat limit when inviting members", + "description": "Invites past the team's paid seat count now fail with SeatLimitError instead of silently adding members we never bill for. Covered by limit tests.", + "author": "dev-teams", + "baseBranch": "main" + }, + "tree": "tree", + "mustCatchSpecs": [ + { + "key": "invite-seat-limit-race", + "path": "src/teams/invite.ts", + "lineStart": 10, + "lineEnd": 25, + "mechanism": [ + "race|concurren|simultaneous|at the same time|same instant|interleav|TOCTOU|check.then.act|time.of.check", + "withTeamLock|serializ|atomic|lock|transaction", + "(two|both|multiple|parallel).{0,80}invit.{0,120}(exceed|over|past|beyond|both (succeed|pass|insert))|(exceed|over|past).{0,40}(seat )?limit" + ], + "altLocations": [ + { + "path": "src/teams/invite.test.ts" + } + ] + } + ] + } +} diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-toctou-invite-limit/tree/src/teams/db.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-toctou-invite-limit/tree/src/teams/db.ts new file mode 100644 index 00000000..8b382934 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-toctou-invite-limit/tree/src/teams/db.ts @@ -0,0 +1,30 @@ +export type Member = { + teamId: string; + email: string; + role: "member" | "owner"; + invitedAt: number; +}; + +const members: Member[] = []; +const locks = new Map>(); + +export const countMembers = async (teamId: string): Promise => + members.filter((member) => member.teamId === teamId).length; + +export const insertMember = async (member: Member): Promise => { + members.push(member); +}; + +/** + * Runs `fn` while holding the team's write lock; writers for the same team + * are serialized in arrival order. + */ +export const withTeamLock = async ( + teamId: string, + fn: () => Promise, +): Promise => { + const previous = locks.get(teamId) ?? Promise.resolve(); + const next = previous.then(fn, fn); + locks.set(teamId, next); + return next as Promise; +}; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-toctou-invite-limit/tree/src/teams/invite.test.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-toctou-invite-limit/tree/src/teams/invite.test.ts new file mode 100644 index 00000000..02f67327 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-toctou-invite-limit/tree/src/teams/invite.test.ts @@ -0,0 +1,14 @@ +import {inviteMember, SeatLimitError} from "./invite"; + +describe("inviteMember", () => { + it("adds members below the seat limit", async () => { + await inviteMember("t1", "a@example.com", 2); + await inviteMember("t1", "b@example.com", 2); + }); + + it("rejects an invite past the seat limit", async () => { + await expect( + inviteMember("t1", "c@example.com", 2), + ).rejects.toThrow(SeatLimitError); + }); +}); diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-toctou-invite-limit/tree/src/teams/invite.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-toctou-invite-limit/tree/src/teams/invite.ts new file mode 100644 index 00000000..cf00903d --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-toctou-invite-limit/tree/src/teams/invite.ts @@ -0,0 +1,25 @@ +import {countMembers, insertMember} from "./db"; + +export class SeatLimitError extends Error { + constructor(teamId: string) { + super(`team ${teamId} is at its seat limit`); + } +} + +/** Adds a member to the team unless the team is already at its seat limit. */ +export const inviteMember = async ( + teamId: string, + email: string, + seatLimit: number, +): Promise => { + const current = await countMembers(teamId); + if (current >= seatLimit) { + throw new SeatLimitError(teamId); + } + await insertMember({ + teamId, + email, + role: "member", + invitedAt: Date.now(), + }); +}; diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-window-boundary-doublecount/case.json b/workflows/review/eval/corpus/synthetic-mutations/mutation-window-boundary-doublecount/case.json new file mode 100644 index 00000000..b373a639 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-window-boundary-doublecount/case.json @@ -0,0 +1,95 @@ +{ + "id": "mutation-window-boundary-doublecount", + "tags": [ + "mutation", + "boundary", + "live" + ], + "category": "synthetic-mutation", + "description": "Model-capability discrimination case (boundary-composition family), minted 2026-07-20 for the corpus-difficulty work. dailyTotals queries each day as [dayStart, dayStart + DAY_MS] against a query the unchanged db.ts documents as inclusive on BOTH endpoints, so consecutive windows share their boundary instant and an event landing exactly at midnight is summed into two days: revenue double-counts. The fix is dayStart + DAY_MS - 1 (or an exclusive upper bound). The defect requires composing the documented inclusivity with the window arithmetic; neither file is wrong alone, and the tests only use mid-day timestamps. Calibrated saturated under the Opus roster (run 29765275168, 6/6 identical-arm samples): kept as the family's regression tripwire, not a discriminator.", + "changedFiles": [ + { + "path": "src/metrics/rollup.ts", + "status": "added" + }, + { + "path": "src/metrics/rollup.test.ts", + "status": "added" + } + ], + "dimensions": { + "correctness": "assessed", + "skillSeverity": "assessed", + "patternTriage": "assessed" + }, + "findings": [ + { + "source": "correctness", + "finding": { + "schema_version": 2, + "id": "rollup-inclusive-boundary-doublecount", + "lens": "correctness", + "anchor": { + "type": "line", + "path": "src/metrics/rollup.ts", + "line": 17, + "side": "RIGHT" + }, + "severity": "blocking", + "confidence": 0.8, + "evidence_trace": [ + "src/metrics/db.ts documents queryEvents as inclusive on both endpoints (fromMs <= at AND at <= toMs)", + "src/metrics/rollup.ts:17 queries each day as queryEvents(dayStart, dayStart + DAY_MS), and the next iteration starts at that same dayStart + DAY_MS", + "an event with at exactly on a day boundary satisfies both windows and is summed twice", + "rollup.test.ts only records events at mid-day offsets, so the suite passes" + ], + "failure_scenario": "A sale recorded at exactly midnight (at = dayStart + DAY_MS, common for batch-imported or clock-aligned events) matches both the day ending and the day starting at that instant because queryEvents is inclusive on both ends; dailyTotals sums it into both rows and the finance dashboard over-reports revenue.", + "producing_hunt": "correctness:cross-file-trace", + "model_authored_prose": "queryEvents is documented inclusive on both endpoints, and each window here ends exactly where the next begins (dayStart + DAY_MS), so a midnight-aligned event is counted in two days. Query to dayStart + DAY_MS - 1, or make the upper bound exclusive." + } + } + ], + "validation": [ + { + "id": "rollup-inclusive-boundary-doublecount", + "verification": "confirmed" + } + ], + "policyConflicts": [], + "expected": { + "verdict": "REQUEST_CHANGES", + "mustCatch": [ + "rollup-inclusive-boundary-doublecount" + ], + "postedCommentCount": 1 + }, + "diff": "diff --git a/src/metrics/rollup.test.ts b/src/metrics/rollup.test.ts\nnew file mode 100644\nindex 0000000..a9175e4\n--- /dev/null\n+++ b/src/metrics/rollup.test.ts\n@@ -0,0 +1,19 @@\n+import {recordEvent} from \"./db\";\n+import {dailyTotals} from \"./rollup\";\n+\n+const DAY_MS = 24 * 60 * 60 * 1000;\n+\n+describe(\"dailyTotals\", () => {\n+ it(\"sums each day's events\", async () => {\n+ await recordEvent({name: \"sale\", valueCents: 100, at: DAY_MS + 5});\n+ await recordEvent({name: \"sale\", valueCents: 250, at: DAY_MS + 90});\n+ const totals = await dailyTotals(DAY_MS, 2 * DAY_MS);\n+ expect(totals).toHaveLength(1);\n+ expect(totals[0]?.totalCents).toBe(350);\n+ });\n+\n+ it(\"returns one row per day\", async () => {\n+ const totals = await dailyTotals(0, 3 * DAY_MS);\n+ expect(totals).toHaveLength(3);\n+ });\n+});\ndiff --git a/src/metrics/rollup.ts b/src/metrics/rollup.ts\nnew file mode 100644\nindex 0000000..81ff8e2\n--- /dev/null\n+++ b/src/metrics/rollup.ts\n@@ -0,0 +1,27 @@\n+import {queryEvents} from \"./db\";\n+\n+const DAY_MS = 24 * 60 * 60 * 1000;\n+\n+export type DailyTotal = {\n+ dayStartMs: number;\n+ totalCents: number;\n+};\n+\n+/** Sums revenue events into one row per day across [fromMs, toMs). */\n+export const dailyTotals = async (\n+ fromMs: number,\n+ toMs: number,\n+): Promise => {\n+ const totals: DailyTotal[] = [];\n+ for (let dayStart = fromMs; dayStart < toMs; dayStart += DAY_MS) {\n+ const dayEvents = await queryEvents(dayStart, dayStart + DAY_MS);\n+ totals.push({\n+ dayStartMs: dayStart,\n+ totalCents: dayEvents.reduce(\n+ (sum, event) => sum + event.valueCents,\n+ 0,\n+ ),\n+ });\n+ }\n+ return totals;\n+};\n", + "live": { + "prContext": { + "title": "metrics: add a daily revenue rollup", + "description": "Adds dailyTotals, one row per day over the requested range, for the finance dashboard. Covered by per-day summing and row-count tests.", + "author": "dev-metrics", + "baseBranch": "main" + }, + "tree": "tree", + "mustCatchSpecs": [ + { + "key": "rollup-inclusive-boundary-doublecount", + "path": "src/metrics/rollup.ts", + "lineStart": 11, + "lineEnd": 27, + "mechanism": [ + "double.?count|counted (twice|in (two|both))|summed (twice|into (two|both))|(two|both) (days|rows|windows)", + "inclusive|both end|overlap", + "midnight|boundary|exactly (at|on)|day.{0,20}(start|end)", + "DAY_MS.{0,20}- ?1|minus (one|1)|off.by.one|exclusive" + ], + "altLocations": [ + { + "path": "src/metrics/rollup.test.ts" + } + ] + } + ] + } +} diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-window-boundary-doublecount/tree/src/metrics/db.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-window-boundary-doublecount/tree/src/metrics/db.ts new file mode 100644 index 00000000..ebf3419d --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-window-boundary-doublecount/tree/src/metrics/db.ts @@ -0,0 +1,24 @@ +export type MetricEvent = { + name: string; + valueCents: number; + /** Event time, epoch milliseconds. */ + at: number; +}; + +const events: MetricEvent[] = []; + +export const recordEvent = async (event: MetricEvent): Promise => { + events.push(event); +}; + +/** + * Returns events with fromMs <= at AND at <= toMs (both endpoints inclusive), + * oldest first. + */ +export const queryEvents = async ( + fromMs: number, + toMs: number, +): Promise => + events + .filter((event) => event.at >= fromMs && event.at <= toMs) + .sort((a, b) => a.at - b.at); diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-window-boundary-doublecount/tree/src/metrics/rollup.test.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-window-boundary-doublecount/tree/src/metrics/rollup.test.ts new file mode 100644 index 00000000..a9175e4b --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-window-boundary-doublecount/tree/src/metrics/rollup.test.ts @@ -0,0 +1,19 @@ +import {recordEvent} from "./db"; +import {dailyTotals} from "./rollup"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +describe("dailyTotals", () => { + it("sums each day's events", async () => { + await recordEvent({name: "sale", valueCents: 100, at: DAY_MS + 5}); + await recordEvent({name: "sale", valueCents: 250, at: DAY_MS + 90}); + const totals = await dailyTotals(DAY_MS, 2 * DAY_MS); + expect(totals).toHaveLength(1); + expect(totals[0]?.totalCents).toBe(350); + }); + + it("returns one row per day", async () => { + const totals = await dailyTotals(0, 3 * DAY_MS); + expect(totals).toHaveLength(3); + }); +}); diff --git a/workflows/review/eval/corpus/synthetic-mutations/mutation-window-boundary-doublecount/tree/src/metrics/rollup.ts b/workflows/review/eval/corpus/synthetic-mutations/mutation-window-boundary-doublecount/tree/src/metrics/rollup.ts new file mode 100644 index 00000000..81ff8e21 --- /dev/null +++ b/workflows/review/eval/corpus/synthetic-mutations/mutation-window-boundary-doublecount/tree/src/metrics/rollup.ts @@ -0,0 +1,27 @@ +import {queryEvents} from "./db"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export type DailyTotal = { + dayStartMs: number; + totalCents: number; +}; + +/** Sums revenue events into one row per day across [fromMs, toMs). */ +export const dailyTotals = async ( + fromMs: number, + toMs: number, +): Promise => { + const totals: DailyTotal[] = []; + for (let dayStart = fromMs; dayStart < toMs; dayStart += DAY_MS) { + const dayEvents = await queryEvents(dayStart, dayStart + DAY_MS); + totals.push({ + dayStartMs: dayStart, + totalCents: dayEvents.reduce( + (sum, event) => sum + event.valueCents, + 0, + ), + }); + } + return totals; +};