⚠️ Definition of Done: this issue must be completed in full, in a single PR. Do not split this
work across multiple PRs, and do not defer any Deliverable below to a follow-up issue. A PR that
satisfies only some of the Deliverables, stubs a required test, or leaves a checkbox
partially-done does NOT resolve this issue and will be closed.
Context
ams_pr_outcome is the only notification kind whose rendered content depends on a field that is not on the event. buildAmsPrOutcomeNotification (src/notifications/service.ts:89-107) recovers the decision by substring-scanning the dedup key:
// buildAmsPrOutcomeEvent embeds `:merged:` or `:closed:` in the dedupKey after the PR number.
const merged = event.dedupKey.includes(":merged:");
DetectedNotificationEvent has no decision field, so that string is the entire carrier. Three things make it unsafe:
- The validator does not check the shape.
normalizeAmsNotificationEventInput (src/notifications/ams-events.ts:134-156) type-checks every other field but for dedupKey only tests non-empty string (:142). The HTTP schema is equally permissive: dedupKey: z.string().min(1).max(500) (src/api/routes.ts:487). Any ams_pr_outcome whose key lacks :merged: renders as "AMS recorded close: ... closed without merge" — including for a merge.
- The format has two hand-duplicated producers.
buildAmsPrOutcomeEvent (ams-events.ts:107-128) and buildAmsPrOutcomePayload (packages/loopover-miner/lib/ams-notifications.ts:118-139) each independently write ams_pr_outcome:${repoFullName}#${pullNumber}:${decision}:${closedAt}. Nothing pins them together.
- The server-side builders are dead.
buildAmsPrOutcomeEvent, buildAmsAttemptStartedEvent, buildAmsAttemptFailedEvent and buildAmsGovernorPausedEvent have zero non-test callers (grep across src/, apps/, packages/); the miner's copies are what actually run. So the format the consumer documents itself against is produced by a file this repo never exercises — if the miner reorders decision and closedAt, every merge notification silently flips to "closed without merge" and nothing fails.
The repo has a convention for exactly this drift class — see the source-pinning tests at test/unit/advisory-spend-gate.test.ts:49-71 and the REVIEWER_VOTE_EVENT_TYPE note at src/services/reviewer-routing.ts:102-104.
Requirements
- Add to
src/notifications/ams-events.ts:
export const AMS_PR_OUTCOME_DEDUP_KEY_PATTERN — an anchored RegExp matching exactly ams_pr_outcome:<repoFullName>#<pullNumber>:(merged|closed):<closedAt>, with the decision as a capture group.
export function parseAmsPrOutcomeDecision(dedupKey: string): "merged" | "closed" | null — the single reader of that pattern.
buildAmsPrOutcomeEvent keeps its output, but the file must contain no second literal spelling of the format: the builder and the pattern must derive from one shared prefix/segment constant.
normalizeAmsNotificationEventInput returns null for an ams_pr_outcome event whose dedupKey does not satisfy parseAmsPrOutcomeDecision. Every other AMS_NOTIFICATION_EVENT_TYPES kind keeps its current validation unchanged — this rule is ams_pr_outcome-only.
buildAmsPrOutcomeNotification uses parseAmsPrOutcomeDecision instead of .includes(":merged:"). A null parse renders the closed copy (never claim an unproven merge); the tests below must show that arm is unreachable from the ingest route once the validator rejects it.
- Add a producer-parity test pinning
packages/loopover-miner/lib/ams-notifications.ts's buildAmsPrOutcomePayload output against AMS_PR_OUTCOME_DEDUP_KEY_PATTERN for both merged and closed.
⚠️ Required pattern: the source-pinned drift guard at test/unit/advisory-spend-gate.test.ts:49-71 ("adoption is real, not aspirational") is the convention for the parity test. It does NOT satisfy this issue to add a decision field to DetectedNotificationEvent / the deliveries schema (a schema migration, out of scope); to validate only in src/api/routes.ts's zod schema while normalizeAmsNotificationEventInput stays permissive (the MCP/self-host path reaches the normalizer directly); or to keep .includes(":merged:") and merely add tests around it.
Deliverables
All Deliverables above are required in a single PR. A PR that satisfies only some of them — for example swapping the consumer without tightening normalizeAmsNotificationEventInput — does not resolve this issue.
Test Coverage Requirements
99%+ Codecov patch coverage, branch-counted, on src/**; src/notifications/ams-events.ts and src/notifications/service.ts are inside coverage.include, so both arms of every changed conditional need a test. packages/loopover-miner/lib/** is ALSO inside coverage.include and gated, so the parity test is both mandatory and measured.
Expected Outcome
A merged/closed AMS notification's wording is decided by a validated, single-sourced key format instead of a substring search over arbitrary client input. A miner-side edit that reorders the dedup key's segments fails CI instead of silently reporting every merged PR as "closed without merge".
Links & Resources
src/notifications/service.ts:89-107; src/notifications/ams-events.ts:107-128, :134-156; packages/loopover-miner/lib/ams-notifications.ts:118-139; src/api/routes.ts:479-493, :3842; test/unit/advisory-spend-gate.test.ts:49-71.
Context
ams_pr_outcomeis the only notification kind whose rendered content depends on a field that is not on the event.buildAmsPrOutcomeNotification(src/notifications/service.ts:89-107) recovers the decision by substring-scanning the dedup key:DetectedNotificationEventhas nodecisionfield, so that string is the entire carrier. Three things make it unsafe:normalizeAmsNotificationEventInput(src/notifications/ams-events.ts:134-156) type-checks every other field but fordedupKeyonly tests non-empty string (:142). The HTTP schema is equally permissive:dedupKey: z.string().min(1).max(500)(src/api/routes.ts:487). Anyams_pr_outcomewhose key lacks:merged:renders as "AMS recorded close: ... closed without merge" — including for a merge.buildAmsPrOutcomeEvent(ams-events.ts:107-128) andbuildAmsPrOutcomePayload(packages/loopover-miner/lib/ams-notifications.ts:118-139) each independently writeams_pr_outcome:${repoFullName}#${pullNumber}:${decision}:${closedAt}. Nothing pins them together.buildAmsPrOutcomeEvent,buildAmsAttemptStartedEvent,buildAmsAttemptFailedEventandbuildAmsGovernorPausedEventhave zero non-test callers (grep acrosssrc/,apps/,packages/); the miner's copies are what actually run. So the format the consumer documents itself against is produced by a file this repo never exercises — if the miner reordersdecisionandclosedAt, every merge notification silently flips to "closed without merge" and nothing fails.The repo has a convention for exactly this drift class — see the source-pinning tests at
test/unit/advisory-spend-gate.test.ts:49-71and theREVIEWER_VOTE_EVENT_TYPEnote atsrc/services/reviewer-routing.ts:102-104.Requirements
src/notifications/ams-events.ts:export const AMS_PR_OUTCOME_DEDUP_KEY_PATTERN— an anchoredRegExpmatching exactlyams_pr_outcome:<repoFullName>#<pullNumber>:(merged|closed):<closedAt>, with the decision as a capture group.export function parseAmsPrOutcomeDecision(dedupKey: string): "merged" | "closed" | null— the single reader of that pattern.buildAmsPrOutcomeEventkeeps its output, but the file must contain no second literal spelling of the format: the builder and the pattern must derive from one shared prefix/segment constant.normalizeAmsNotificationEventInputreturnsnullfor anams_pr_outcomeevent whosededupKeydoes not satisfyparseAmsPrOutcomeDecision. Every otherAMS_NOTIFICATION_EVENT_TYPESkind keeps its current validation unchanged — this rule isams_pr_outcome-only.buildAmsPrOutcomeNotificationusesparseAmsPrOutcomeDecisioninstead of.includes(":merged:"). Anullparse renders the closed copy (never claim an unproven merge); the tests below must show that arm is unreachable from the ingest route once the validator rejects it.packages/loopover-miner/lib/ams-notifications.ts'sbuildAmsPrOutcomePayloadoutput againstAMS_PR_OUTCOME_DEDUP_KEY_PATTERNfor bothmergedandclosed.Deliverables
parseAmsPrOutcomeDecision("ams_pr_outcome:owner/repo#7:merged:2026-05-28T12:00:00.000Z")returns"merged", the:closed:twin returns"closed", and"custom-key"returnsnull— asserted intest/unit/notifications-ams-events.test.ts.normalizeAmsNotificationEventInput({ ...validAmsPrOutcome, dedupKey: "custom-key" }, "miner")returnsnull, while the same malformed key on anams_attempt_startedevent is still accepted — two cases.buildAmsPrOutcomeNotificationrenders the merge copy for a:merged:key and the close copy for both a:closed:key and an unparseable key — extendingtest/unit/notifications-service.test.ts:144-155.buildAmsPrOutcomePayloadproduces adedupKeymatchingAMS_PR_OUTCOME_DEDUP_KEY_PATTERNfor both decisions.src/notifications/service.tsno longer contains the string":merged:"(grep-verifiable).All Deliverables above are required in a single PR. A PR that satisfies only some of them — for example swapping the consumer without tightening
normalizeAmsNotificationEventInput— does not resolve this issue.Test Coverage Requirements
99%+ Codecov patch coverage, branch-counted, on
src/**;src/notifications/ams-events.tsandsrc/notifications/service.tsare insidecoverage.include, so both arms of every changed conditional need a test.packages/loopover-miner/lib/**is ALSO insidecoverage.includeand gated, so the parity test is both mandatory and measured.Expected Outcome
A merged/closed AMS notification's wording is decided by a validated, single-sourced key format instead of a substring search over arbitrary client input. A miner-side edit that reorders the dedup key's segments fails CI instead of silently reporting every merged PR as "closed without merge".
Links & Resources
src/notifications/service.ts:89-107;src/notifications/ams-events.ts:107-128,:134-156;packages/loopover-miner/lib/ams-notifications.ts:118-139;src/api/routes.ts:479-493,:3842;test/unit/advisory-spend-gate.test.ts:49-71.