Skip to content

Commit cb7685d

Browse files
nicklaunchesclaude
andauthored
Stop pretending submit still matches (#5)
#1 put the review gate in the right place, in autoPair, and then left both submit surfaces carrying copy for outcomes they can no longer reach. A fresh listing is pending_review, autoPair refuses anything that is not active, so matched, first_in_category and no_eligible_partner became unobservable from the submit path and a fourth branch was added alongside them. The call is gone from both surfaces instead. describeAutoPair is deleted, the four-way ternary in tools.ts is one sentence, and CommitState.matched goes with them, along with the PartyPopper the panel showed when a submit came back already matched. Nothing comes back already matched now. Renamed the reason from pending_review to not_active. paused, rejected and banned take the same branch, so the old name was wrong three ways out of four. setSiteStatus now logs what autoPair returned. It is the only caller left, so an unlogged return meant nothing anywhere observed whether approving a site actually matched it, and "I approved them and they never heard anything" had no trail to follow. Headers updated where they described the old flow, including the matches.ts one that claimed the digest cron calls autoPair. It does not, and has not: it runs its own candidate query because it shows several masked candidates rather than proposing one match. typecheck, lint, 42 tests and prettier --check pass. Claude-Session: https://claude.ai/code/session_01TytxW7wT52Miy394hDeh9V Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 9dfb025 commit cb7685d

6 files changed

Lines changed: 97 additions & 108 deletions

File tree

next-app/src/app/submit/actions.ts

Lines changed: 31 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { createHmac, timingSafeEqual } from "node:crypto";
44

55
import type { Category } from "@/lib/categories";
66
import { AnalyzeError } from "@/lib/contracts";
7-
import { autoPair } from "@/lib/services/matches";
87
import { SiteError, commitSite, draftSite } from "@/lib/services/sites";
98
import { getSessionMember } from "@/lib/session";
109

@@ -14,25 +13,23 @@ import { getSessionMember } from "@/lib/session";
1413
* These two actions are deliberately the same shape as the one tool: call
1514
* `draftSiteAction` first and nothing is written, call `commitSiteAction` only
1615
* after a human has read the drafted words. Both call the exact same service
17-
* functions (`draftSite`, `commitSite`, `autoPair`) in the exact same order as
16+
* functions (`draftSite`, `commitSite`) in the exact same order as
1817
* `src/lib/mcp/tools.ts`, and the error text is copied from `explain()` there.
1918
* If the two ever say different things about the same failure, one of the two
2019
* interfaces has started to drift and the agent one is supposed to be
2120
* first-class.
2221
*
23-
* Two places the web path is deliberately NOT identical, both in failure
24-
* handling rather than behaviour:
22+
* Neither surface pairs on submit. `autoPair` refuses anything that is not
23+
* `active` and a listing is `pending_review` until review clears it, so both
24+
* used to carry copy for four outcomes when only one was reachable. The call is
25+
* gone from both; matching happens at approval, in `setSiteStatus`.
2526
*
26-
* 1. Domain Rating is signed between the two steps (see `signDraft`). The MCP
27-
* tool re-derives it from a draft it holds in memory; a browser has to
28-
* round-trip it through a form field, and DR is a public number partners
29-
* judge on, so a hand-edited hidden input must not be able to inflate it.
30-
* A bad signature drops the score to null rather than failing the submit.
31-
* 2. A failure inside `autoPair` after the listing is written is reported as
32-
* "listed, matching will run shortly" rather than as an error. The tool
33-
* lets it throw, which on the web would leave the member staring at an
34-
* error for a site that is in fact listed, and resubmitting into a
35-
* `domain_taken`.
27+
* One place the web path is deliberately NOT identical: Domain Rating is signed
28+
* between the two steps (see `signDraft`). The MCP tool re-derives it from a
29+
* draft it holds in memory; a browser has to round-trip it through a form field,
30+
* and DR is a public number partners judge on, so a hand-edited hidden input
31+
* must not be able to inflate it. A bad signature drops the score to null rather
32+
* than failing the submit.
3633
*/
3734

3835
// ---------------------------------------------------------------------------
@@ -172,11 +169,22 @@ export type CommitState =
172169
domain: string;
173170
/** "yourapp.com is listed and pending review." */
174171
headline: string;
175-
/** The auto-pair outcome, in plain words. Never empty. */
172+
/** What happens next, in plain words. Never empty. */
176173
outcome: string;
177-
matched: boolean;
178174
};
179175

176+
/**
177+
* What happens next, in the same words the `submit_site` tool uses.
178+
*
179+
* A constant rather than a computed sentence: nothing is matched at submit any
180+
* more, so there is no per-submission outcome left to describe. Kept in the
181+
* state object anyway, because the panel that renders it does not need to know
182+
* that, and the day matching says something per-submission again this is where
183+
* it goes.
184+
*/
185+
const PENDING_REVIEW_OUTCOME =
186+
"A human reads the listing, usually the same day. Matching runs the moment it is approved, and if a partner is waiting in your category you will hear by email right then.";
187+
180188
/**
181189
* Splits the anchors textarea into keywords.
182190
*
@@ -192,7 +200,12 @@ function parseKeywords(raw: string): string[] {
192200
}
193201

194202
/**
195-
* Writes the confirmed listing and immediately looks for a partner.
203+
* Writes the confirmed listing.
204+
*
205+
* It used to look for a partner here too. It no longer does: `autoPair` pairs
206+
* nothing that is not `active`, and a listing is `pending_review` until a human
207+
* clears it, so the call could only ever have come back empty. Matching happens
208+
* at approval, in `setSiteStatus`.
196209
*
197210
* @param _previous - Previous action state, unused.
198211
* @param formData - The confirmation form, including the signed draft fields.
@@ -221,14 +234,11 @@ export async function commitSiteAction(_previous: CommitState, formData: FormDat
221234
domainRating,
222235
});
223236

224-
const pairing = await describeAutoPair(site);
225-
226237
return {
227238
status: "done",
228239
domain: site.domain,
229240
headline: `${site.domain} is listed and pending review.`,
230-
outcome: pairing.outcome,
231-
matched: pairing.matched,
241+
outcome: PENDING_REVIEW_OUTCOME,
232242
};
233243
} catch (err) {
234244
if (err instanceof SiteError) {
@@ -250,47 +260,3 @@ export async function commitSiteAction(_previous: CommitState, formData: FormDat
250260
};
251261
}
252262
}
253-
254-
/**
255-
* Runs instant matching and turns the result into the same three sentences the
256-
* `submit_site` tool returns.
257-
*
258-
* Never throws: the listing is already written by the time this runs, and an
259-
* error here must not be reported as a failed submission.
260-
*/
261-
async function describeAutoPair(site: Parameters<typeof autoPair>[0]): Promise<{ outcome: string; matched: boolean }> {
262-
try {
263-
const pair = await autoPair(site);
264-
if (pair.matched) {
265-
const dr = pair.partner.domainRating ?? "unrated";
266-
return {
267-
matched: true,
268-
outcome: `You already have a match: ${pair.partner.category}, DR ${dr}. It is waiting for you to accept or decline.`,
269-
};
270-
}
271-
if (pair.reason === "pending_review") {
272-
return {
273-
matched: false,
274-
outcome:
275-
"Matching starts the moment a human approves the listing. If a partner is waiting in your category, you will hear by email right then.",
276-
};
277-
}
278-
if (pair.reason === "first_in_category") {
279-
return {
280-
matched: false,
281-
outcome: `You are the first site in ${pair.category}. That is a good position: the next member to join it is matched with you immediately.`,
282-
};
283-
}
284-
return {
285-
matched: false,
286-
outcome: "No partner available right now. You will be matched as soon as a suitable one joins.",
287-
};
288-
} catch (err) {
289-
console.error("submit: autoPair failed after commit", err);
290-
return {
291-
matched: false,
292-
outcome:
293-
"Your listing is saved. Matching could not run just now, so it will run on the next sweep instead.",
294-
};
295-
}
296-
}

next-app/src/app/submit/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import { getSessionMember } from "@/lib/session";
1111
*
1212
* The whole architecture rests on both interfaces calling the same services, so
1313
* this page contains no listing logic at all: it resolves the session, and the
14-
* server actions in `./actions.ts` call `draftSite`, `commitSite`, and
15-
* `autoPair` in the same order the tool does.
14+
* server actions in `./actions.ts` call `draftSite` and `commitSite` in the same
15+
* order the tool does. Neither pairs on submit; matching runs at approval.
1616
*
1717
* A URL typed into the landing page's fallback form is carried through the
1818
* sign-in round trip in `callbackUrl`, so someone who is signed out never has

next-app/src/app/submit/review-form.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { AlertTriangle, ArrowRight, CheckCircle2, EyeOff, Loader2, PartyPopper } from "lucide-react";
3+
import { AlertTriangle, ArrowRight, CheckCircle2, EyeOff, Loader2 } from "lucide-react";
44
import Link from "next/link";
55
import { useActionState, useId, useState } from "react";
66

@@ -321,21 +321,23 @@ export function ReviewForm({ draft, onStartOver }: ReviewFormProps) {
321321
</div>
322322

323323
<p aria-live="polite" className="sr-only">
324-
{pending ? "Listing your site and looking for a partner." : ""}
324+
{pending ? "Listing your site." : ""}
325325
</p>
326326
</form>
327327
);
328328
}
329329

330330
const SIGNED_OUT = "Your session expired before we could save this. Sign in again and resubmit, nothing was written.";
331331

332+
// One icon, not two. This used to celebrate with a PartyPopper when the submit
333+
// call came back already matched, which cannot happen any more: nothing is
334+
// matched until review clears the listing. The celebration belongs on whatever
335+
// surface tells a member they have a match, not on the one that says "received".
332336
function SubmittedPanel({ state }: { state: Extract<CommitState, { status: "done" }> }) {
333-
const Icon = state.matched ? PartyPopper : CheckCircle2;
334-
335337
return (
336338
<section className="border-accent/35 bg-accent-soft rounded-sm border p-6 sm:p-8" aria-live="polite">
337339
<span className="border-line bg-surface text-accent mb-4 inline-flex size-10 items-center justify-center rounded-sm border">
338-
<Icon aria-hidden="true" className="size-5" />
340+
<CheckCircle2 aria-hidden="true" className="size-5" />
339341
</span>
340342

341343
<h2 className="text-[1.35rem] font-semibold tracking-[-0.02em]">{state.headline}</h2>

next-app/src/lib/mcp/tools.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { PLACEMENT_OFFERS } from "@/lib/exchange";
88
import { RateLimited, enforceToolLimit } from "@/lib/mcp/limits";
99
import { getCategoryDepths, getRules } from "@/lib/services/catalog";
1010
import { LinkError, checkLinks, getLinkBrief, getStanding, markLinkPlaced } from "@/lib/services/links";
11-
import { MatchError, autoPair, listMatches, respondToMatch, searchPartners } from "@/lib/services/matches";
11+
import { MatchError, listMatches, respondToMatch, searchPartners } from "@/lib/services/matches";
1212
import { SiteError, commitSite, draftSite, listMySites } from "@/lib/services/sites";
1313

1414
/**
@@ -277,16 +277,14 @@ export function registerTools(server: McpServer, ctx: ToolContext): void {
277277
domainRating: draft.domainRating,
278278
});
279279

280-
const pair = await autoPair(site);
281-
const tail = pair.matched
282-
? `\n\nYou already have a match: ${pair.partner.category}, DR ${pair.partner.domainRating ?? "unrated"}. Say "show my matches" to see it.`
283-
: pair.reason === "pending_review"
284-
? "\n\nMatching starts the moment a human approves the listing. If a partner is waiting in your category, you will hear by email right then."
285-
: pair.reason === "first_in_category"
286-
? `\n\nYou are the first site in ${pair.category}. That is a good position: the next member to join it is matched with you immediately.`
287-
: "\n\nNo partner available right now. You will be matched as soon as a suitable one joins.";
288-
289-
return text(`${site.domain} is listed and pending review.${tail}`);
280+
// No pairing call here. `autoPair` refuses anything that is not
281+
// `active` and a fresh listing is `pending_review`, so calling it
282+
// would do nothing but return a reason. Matching happens at
283+
// approval, in `setSiteStatus`.
284+
return text(
285+
`${site.domain} is listed and pending review.` +
286+
"\n\nA human reads the listing, usually the same day. Matching runs the moment it is approved, and if a partner is waiting in your category the member hears by email right then.",
287+
);
290288
}),
291289
);
292290

next-app/src/lib/services/matches.ts

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,24 @@ import { toMaskedPartner, toRevealedPartner } from "@/lib/services/mask";
2020
/**
2121
* @file Finding partners and moving a match toward agreement.
2222
*
23-
* One matching path, two callers. `autoPair` runs synchronously the moment a
24-
* site is listed, and the weekly digest cron calls the same function for
25-
* members with nothing open. Keeping them on one code path is not tidiness: if
26-
* the instant path were a separate implementation it would quietly rot, and the
27-
* instant path is the one that makes an exchange survivable while it is small.
23+
* `autoPair` runs at exactly one moment: approval. It used to also run on
24+
* submit, which read as instant matching but was the review gate leaking — a
25+
* `pending_review` site was being proposed to real members before anyone had
26+
* looked at it. The guard inside `autoPair` closed that, and the two submit
27+
* surfaces stopped calling it, since the call could no longer do anything.
28+
*
29+
* The cost of that is real and worth naming: time to first match is now bounded
30+
* by how fast the /admin queue gets worked, and this file used to argue that the
31+
* instant path is what makes an exchange survivable while it is small. It is
32+
* still the right trade, because /terms promises review first, but the queue is
33+
* now on the critical path and should be watched like one.
2834
*
2935
* When there is no partner yet, that is reported honestly rather than papered
30-
* over. A member told "you are first in this category, the next person to join
31-
* matches with you" is being told something true and mildly flattering. An
32-
* empty digest with no explanation is the single most common way a matching
33-
* product loses someone on day one.
36+
* over. The weekly digest cron (`api/cron/digest`) covers the members `autoPair`
37+
* could not place — it does its own candidate query rather than calling in here,
38+
* because it shows several masked candidates instead of proposing one match.
39+
* An empty digest with no explanation is the single most common way a matching
40+
* product loses someone on day one, so it sends nothing rather than nothing-news.
3441
*
3542
* ON SORTING BY `lastMatchedAt`: every query that orders by it asks for NULLS
3643
* FIRST explicitly. A site that has never been matched is the stalest thing in
@@ -101,28 +108,33 @@ export async function searchPartners(input: {
101108

102109
export type AutoPairResult =
103110
| { matched: true; match: ExchangeMatch; partner: MaskedPartner }
104-
| { matched: false; reason: "first_in_category" | "no_eligible_partner" | "pending_review"; category: Category };
111+
| { matched: false; reason: "first_in_category" | "no_eligible_partner" | "not_active"; category: Category };
105112

106113
/**
107114
* Finds and proposes the best available partner for a site, right now.
108115
*
109-
* Called synchronously from the submit flow and from the weekly cron.
116+
* Called from `setSiteStatus` the moment a site is approved. NOT from the submit
117+
* flow: a freshly listed site is `pending_review` and the guard below turns the
118+
* call into a no-op, so both submit surfaces stopped making it rather than
119+
* carrying copy for a branch that could not be reached.
110120
*
111-
* @param site - The site needing a partner.
121+
* @param site - The site needing a partner. Ignored unless it is `active`.
112122
* @returns The created match and a masked view of the partner, or a reason why not.
113123
*/
114124
export async function autoPair(site: ExchangeSite): Promise<AutoPairResult> {
115125
const category = site.category;
116126

117127
// Only an active site may be proposed to anyone. Every filter below checks
118128
// the status of the CANDIDATES, so without this guard the subject slips
119-
// through: the submit flow calls autoPair the moment a listing is written,
120-
// while it is still `pending_review`, and a match with an unreviewed site
121-
// would go out (with both match-proposed emails) before a human had looked
122-
// at it. /terms says that never happens, so it must not. Approval re-runs
123-
// autoPair (see `setSiteStatus`), which is where a fresh site really pairs.
129+
// through and an unreviewed listing gets proposed to a real member, with
130+
// both match-proposed emails, before a human has looked at it. /terms
131+
// promises that never happens.
132+
//
133+
// `not_active` rather than `pending_review`: `paused`, `rejected` and
134+
// `banned` take this branch too, and naming it after only the first one
135+
// would be wrong three ways out of four.
124136
if (site.status !== "active") {
125-
return { matched: false, reason: "pending_review", category };
137+
return { matched: false, reason: "not_active", category };
126138
}
127139

128140
const [active] = await db()

next-app/src/lib/services/sites.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -225,12 +225,13 @@ export async function listSitesForReview(status?: SiteStatus): Promise<SiteForRe
225225
* The reviewer's note is written to `review_note`, a column that has existed
226226
* since the first migration and had no reader or writer until now.
227227
*
228-
* Approving MATCHES IMMEDIATELY. `autoPair` already runs on submit, but at that
229-
* point the site is `pending_review` and autoPair declines to pair it (that is
230-
* the review promise in /terms), so this is the first moment it can actually
231-
* pair with anyone. Without the call here an approved site would sit idle until
232-
* the Tuesday cron, which is a week of silence at exactly the moment the member
233-
* has just been told they are live.
228+
* Approving MATCHES IMMEDIATELY, and this is the ONLY place `autoPair` is
229+
* called from. A site is `pending_review` from the moment it is submitted until
230+
* a human clears it, and `autoPair` refuses to pair anything that is not
231+
* `active` (that is the review promise in /terms), so approval is the first
232+
* moment the site can pair with anyone at all. Without the call here an approved
233+
* site would sit idle until the Tuesday cron, which is a week of silence at
234+
* exactly the moment the member has just been told they are live.
234235
*
235236
* Pairing is awaited rather than fired and forgotten, because its own
236237
* `match-proposed` email should land after the approval email rather than
@@ -266,8 +267,18 @@ export async function setSiteStatus(siteId: string, status: SiteStatus, reviewNo
266267
void notifySiteApproved({ site: updated });
267268
// Best effort. A pairing failure must not make the approval look like
268269
// it did not happen, because the row is already committed.
270+
//
271+
// The outcome is logged rather than dropped. This is the only caller of
272+
// `autoPair`, so an unlogged return value would mean nothing anywhere
273+
// observes whether approving a site actually matched it, and "I approved
274+
// them and they never heard anything" would have no trail to follow.
269275
try {
270-
await autoPair(updated);
276+
const pair = await autoPair(updated);
277+
console.log(
278+
pair.matched
279+
? `setSiteStatus: approved ${updated.domain} and matched it (${pair.match.id})`
280+
: `setSiteStatus: approved ${updated.domain}, no match yet (${pair.reason} in ${pair.category})`,
281+
);
271282
} catch (err) {
272283
console.error("setSiteStatus: autoPair failed after approving", updated.domain, err);
273284
}

0 commit comments

Comments
 (0)