diff --git a/docs/clawhub-search-intelligence.md b/docs/clawhub-search-intelligence.md index a019cf3..676caec 100644 --- a/docs/clawhub-search-intelligence.md +++ b/docs/clawhub-search-intelligence.md @@ -85,14 +85,10 @@ snapshot. Search evidence still has its separate completed-week window and query privacy threshold. Missing adoption stays unavailable, not zero. Hermit neither re-ranks the report nor publishes Featured selections. -The compact Carbon layout retains every proposed ID, slot, selection basis, -30d/7d install count and rationale, metadata freshness, plus all pending reservations. -Weekly search totals, source counts, coverage and bounded company-opportunity, -official-gap and mover rows remain separate sections. The complete -wire payload remains capped at 30,000 bytes. Reports that fit use one message; -longer reports are packed deterministically into bounded parts, without dropping -selection rows or reasons. Each part uses at most 4000 text characters and 40 -components, with a dashboard link for full search links and removal details. +The complete wire payload remains capped at 30,000 bytes, including all Featured +evidence even when Discord does not display it. Pre-cutoff v4 reports retain the +compact linked-name/30-day-install formatter, pending-slot warnings and full-report +button. Each part uses at most 4000 text characters and 40 components. V4 freezes the complete digest hash and ordered rendered-part hashes at the same origin/week key. Each part reuses the existing message receipt, nonce and @@ -104,6 +100,31 @@ partially delivered week. Frozen v1/v2/v3 payloads retain their original validat rendering, hashes and single-message receipts. Deploy receiver support before sending v4. Read-only dry runs must not call the delivery endpoint. +### Discord Featured presentation cutover + +For v4 weeks ending **2026-11-02T00:00:00Z or later**, new delivery claims show +both catalogs' weekly search totals, source counts, coverage, classification, +company opportunities, official gaps and movers. Featured recommendations move +to the separate advisory review workflow; the Discord formatter never changes +Featured membership. Strict full-payload validation, privacy thresholds and +trusted-origin checks still apply. Bounded Carbon pages retain every supplied +search row; oversized search links open the full report instead. + +The original full digest still owns the hash, origin/week key and part nonces. +An existing incomplete cutoff-week receipt can resume the unchanged legacy +formatter only when the original payload hash **and ordered legacy part hashes** +match. The atomic delivery owner chooses the winning manifest; it never replaces +an existing claim. Changed payloads and unknown incomplete manifests still +conflict, and uncertain sends still require reconciliation. No manual proof that +the cutoff week is unclaimed is needed. + +Pre-cutoff producer-frozen reports use the byte-identical old formatter even +when first received after cutover. V1/v2/v3 behavior is unchanged. The cutoff is +an immutable UTC week identity, independent of request time or environment +settings. A rollback must retain this selector and both formatters for the +retained receipt lifetime; deploying a formatter-only historical revision after +new claims exist can conflict with their manifests. + ## Delivery state and failure semantics No migration is needed. The existing D1 `keyValue` primary key stores one receipt diff --git a/src/clawhubSearchIntelligence/api.ts b/src/clawhubSearchIntelligence/api.ts index 8a9df09..63c01f1 100644 --- a/src/clawhubSearchIntelligence/api.ts +++ b/src/clawhubSearchIntelligence/api.ts @@ -13,8 +13,11 @@ import { deliverWeeklyDigest } from "./delivery.js" import { parseDigest, type Digest, type Row } from "./contract.js" import { parseEvidenceDigest, renderEvidenceDigest } from "./evidence.js" +import { renderMonthlyDigest, renderMonthlySearchDigest } from "./monthly.js" const apiPath = "/api/clawhub-search-intelligence/weekly" +// Frozen week identity, not request time or mutable config, selects presentation. +const featuredCutoverWeekEnd = Date.UTC(2026, 10, 2) // Bound bytes while consuming the stream, not after allocating an arbitrary body. const readBody = async (request: Request): Promise => { const reader = request.body?.getReader() @@ -191,6 +194,8 @@ export const handleSearchIntelligenceApiRequest = async ( if (!digest) return json({ error: "Invalid search intelligence payload" }, 400) try { + if (digest.kind === "search_intelligence_weekly_v4" && digest.weekEnd >= featuredCutoverWeekEnd) + return await deliverWeeklyDigest(client, digest, renderMonthlySearchDigest(digest), renderMonthlyDigest(digest)) return await deliverWeeklyDigest( client, digest, diff --git a/src/clawhubSearchIntelligence/delivery.ts b/src/clawhubSearchIntelligence/delivery.ts index 4358cd7..7437111 100644 --- a/src/clawhubSearchIntelligence/delivery.ts +++ b/src/clawhubSearchIntelligence/delivery.ts @@ -126,7 +126,8 @@ const fingerprint = (body: MessageBody, value: string): MessageBody => ({ export const deliverWeeklyDigest = async ( client: Client, digest: DigestIdentity, - rendered: MessageBody | MessageBody[] + rendered: MessageBody | MessageBody[], + legacyRendered?: MessageBody[] ): Promise => { const key = `clawhub-search-weekly:${new URL(digest.dashboardUrl).origin}:${digest.weekStart}` const payloadHash = await hash(canonical(digest)) @@ -174,8 +175,18 @@ export const deliverWeeklyDigest = async ( // A completed receipt acknowledges the same report across presentation changes. // Incomplete deliveries stay bound to their original parts to prevent duplicates. if (state.status === "sent") return success() - if (canonical(state.partHashes) !== canonical(partHashes)) - return response({ error: "Weekly payload conflict" }, 409) + if (canonical(state.partHashes) !== canonical(partHashes)) { + // An old writer may already own the cutoff week. Only the SAME payload and + // exact original manifest can select legacy output; never replace its claim. + if (!legacyRendered) + return response({ error: "Weekly payload conflict" }, 409) + const legacyPartHashes = await Promise.all( + legacyRendered.map(body => hash(canonical(body))) + ) + if (canonical(state.partHashes) !== canonical(legacyPartHashes)) + return response({ error: "Weekly payload conflict" }, 409) + rendered = legacyRendered + } for (let index = 0; index < rendered.length; index++) { const result = await deliverMessage( client, diff --git a/src/clawhubSearchIntelligence/monthly.ts b/src/clawhubSearchIntelligence/monthly.ts index f01a63d..2f93b10 100644 --- a/src/clawhubSearchIntelligence/monthly.ts +++ b/src/clawhubSearchIntelligence/monthly.ts @@ -265,3 +265,71 @@ export const renderMonthlyDigest = (digest: MonthlyDigest) => { }) ) } + +// Keep the legacy formatter above unchanged: frozen part hashes depend on it. +export const renderMonthlySearchDigest = (digest: MonthlyDigest) => { + const dashboardUrl = + digest.dashboardUrl.length <= 512 + ? digest.dashboardUrl + : new URL( + `/management?view=search-insights&endDay=${digest.weekEnd}`, + digest.dashboardUrl + ).href + const brief = (value: string) => + safe(value.length > 80 ? `${value.slice(0, 79)}…` : value) + const rowText = (row: Catalog["movers"][number]) => + `[${brief(row.query)}](${link(row.searchUrl.length <= 512 ? row.searchUrl : dashboardUrl)}) (${row.scope}) · ${row.searches} searches · ${row.officialGaps} gaps · previous ${row.previousSearches}` + const preview = ["localhost", "127.0.0.1", "[::1]"].includes( + new URL(digest.dashboardUrl).hostname + ) + return ([ + ["Plugins", digest.catalogs.plugins], + ["Skills", digest.catalogs.skills] + ] as const).flatMap(([name, catalog]) => { + const { coverage } = catalog + const time = (value: number | null) => value === null ? "unknown" : day(value) + const incomplete = coverage.dataThrough === null || + coverage.dataThrough < digest.weekEnd || + coverage.collectionStartedAt === null || + coverage.collectionStartedAt > digest.weekStart || coverage.gapStart !== null + const header = [ + `### ${preview ? "LOCAL PREVIEW · " : ""}ClawHub weekly search intelligence · ${name}`, + `${day(digest.weekStart)} – ${day(digest.weekEnd)} (UTC, end exclusive)`, + `${catalog.totalSearches} searches · Web ${catalog.sourceCounts.clawhubWeb} · Control UI ${catalog.sourceCounts.openclawControlUi}`, + `Data through ${time(coverage.dataThrough)}; collection started ${time(coverage.collectionStartedAt)}.${incomplete ? " Incomplete collection history." : ""}`, + ...(coverage.gapStart !== null && coverage.gapEnd !== null + ? [`Collection gap ${day(coverage.gapStart)} – ${day(coverage.gapEnd)} UTC.`] : []), + `Classification ${catalog.classificationStatus}; search metadata ${catalog.currentMetadataStatus}.` + ].join("\n") + const sections = [ + ["company opportunities", catalog.companyOpportunities.map(row => + `${rowText(row)}${row.companyProductName ? ` · ${brief(row.companyProductName)}` : ""} · ${Math.round(row.confidence * 100)}% classifier confidence`)], + ["official gaps", catalog.officialGaps.map(rowText)], + ["movers", catalog.movers.map(rowText)] + ] as const + const pages: string[] = [] + let page = "" + for (const [title, rows] of sections) { + const heading = `**${name} ${title}**` + for (const row of [heading, ...(rows.length ? rows : ["None qualified."])]) { + // Whole bounded rows only; reserve space for metadata and receipt fingerprint. + if (page.length + row.length + 1 > 2600) { + pages.push(page) + page = `${heading} (continued)` + } + page += `\n${row}` + } + } + pages.push(page) + return pages.map(page => serializePayload({ + components: [new Container([ + new TextDisplay(header), + new TextDisplay(page), + new TextDisplay("Company classification is advisory. At least 3 searches required: either week for movers, current week for other rows. Long links open the full report." + + (digest.truncated ? " Input capped; more evidence on the dashboard." : "")), + new Row([new DashboardLink(dashboardUrl)]) + ])], + allowedMentions: { parse: [] } + })) + }) +} diff --git a/tests/searchIntelligenceApi.test.ts b/tests/searchIntelligenceApi.test.ts index cfa9b5e..8114616 100644 --- a/tests/searchIntelligenceApi.test.ts +++ b/tests/searchIntelligenceApi.test.ts @@ -8,6 +8,8 @@ import { } from "@buape/carbon" import { deliverWeeklyDigest } from "../src/clawhubSearchIntelligence/delivery.js" import { handleSearchIntelligenceApiRequest } from "../src/clawhubSearchIntelligence/api.js" +import { parseEvidenceDigest } from "../src/clawhubSearchIntelligence/evidence.js" +import { renderMonthlyDigest } from "../src/clawhubSearchIntelligence/monthly.js" import { setRuntimeEnv } from "../src/runtime/env.js" import { SqliteD1Database } from "./helpers/sqliteD1.js" @@ -324,6 +326,23 @@ const monthlyPayload = (longRows = false) => { } } } +const datedMonthlyPayload = (longRows = false, weekEnd = Date.UTC(2026, 10, 2)) => { + const delta = weekEnd - validPayload.weekEnd + return JSON.parse( + JSON.stringify(monthlyPayload(longRows), (_key, value) => + typeof value === "number" && + value >= validPayload.coverage.collectionStartedAt + ? value + delta + : value + ) + ) as ReturnType +} +const legacyMonthlyRendering = (payload: ReturnType) => { + const digest = parseEvidenceDigest(payload, ["https://clawhub.ai"]) + if (!digest || digest.kind !== "search_intelligence_weekly_v4") + throw new Error("Invalid monthly fixture") + return renderMonthlyDigest(digest) +} const links = (value: unknown): string[] => { if (!value || typeof value !== "object") return [] const row = value as { url?: string; components?: unknown[] } @@ -403,6 +422,254 @@ afterEach(() => { }) describe("ClawHub weekly search intelligence receiver", () => { + it("keeps both catalogs' search opportunities when new cutoff-week claims move Featured out of Discord", async () => { + const { client, posts } = setup() + const payload = datedMonthlyPayload() + const skills = payload.catalogs.skills + skills.totalSearches = 9 + skills.sourceCounts = { clawhubWeb: 6, openclawControlUi: 3 } + skills.classificationStatus = "available" + skills.companyOpportunities = [{ + ...payload.catalogs.plugins.companyOpportunities[0], + query: "calendar", + companyProductName: "Calendar", + searchUrl: "https://clawhub.ai/skills?q=calendar" + }] + skills.officialGaps = [{ + ...payload.catalogs.plugins.officialGaps[0], + query: "calendar", + searchUrl: "https://clawhub.ai/skills?q=calendar" + }] + skills.movers = [...skills.officialGaps] + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client))?.status + ).toBe(200) + const rendered = posts.flatMap(({ body }) => texts(body)).join("\n") + for (const fact of ["12 searches", "9 searches", "Notion", "Calendar", "company opportunities", "official gaps", "movers"]) + expect(rendered).toContain(fact) + for (const catalog of Object.values(payload.catalogs)) + for (const row of catalog.recommendations) + expect(rendered).not.toContain(row.url) + for (const { body } of posts) { + expect(texts(body).join("\n").length).toBeLessThanOrEqual(4000) + expect(componentCount(body) - 1).toBeLessThanOrEqual(40) + expect(body.allowed_mentions).toEqual({ parse: [] }) + } + const count = posts.length + expect( + (await handleSearchIntelligenceApiRequest(request(payload), client))?.status + ).toBe(200) + expect(posts).toHaveLength(count) + }) + it.each(["retryable", "uncertain"] as const)("resumes an existing %s legacy cutoff-week manifest without rewriting or repeating its parts", async state => { + const { client, posts } = setup() + const payload = datedMonthlyPayload(true) + const original = client.rest.post.bind(client.rest) + let calls = 0 + client.rest.post = (async (...args: Parameters) => { + if (++calls === 2) { + if (state === "retryable") + throw Object.assign(new Error("Rejected"), { status: 429 }) + await original(...args) + throw new Error("Response lost") + } + return original(...args) + }) as typeof client.rest.post + // This is the previous HTTP handler's unchanged delivery call, not a second receipt owner. + expect((await deliverWeeklyDigest(client, payload, legacyMonthlyRendering(payload))).status) + .toBe(state === "retryable" ? 502 : 503) + const confirmedNonce = posts[0].body.nonce + client.rest.post = original + if (state === "uncertain") { + expect((await handleSearchIntelligenceApiRequest(request(payload), client))?.status).toBe(503) + expect(posts).toHaveLength(2) + client.rest.get = async () => [{ + id: "original-part-2", + author: { id: "bot-user", bot: true }, + timestamp: new Date().toISOString(), + components: posts[1].body.components + }] as never + } + expect((await handleSearchIntelligenceApiRequest(request(payload), client))?.status).toBe(200) + expect(posts.filter(({ body }) => body.nonce === confirmedNonce)).toHaveLength(1) + expect(new Set(posts.map(({ body }) => body.nonce)).size).toBe(posts.length) + const rendered = posts.flatMap(({ body }) => texts(body)).join("\n") + expect(rendered).toContain(payload.catalogs.skills.recommendations[15].url) + expect(rendered).not.toContain("company opportunities") + const count = posts.length + expect((await handleSearchIntelligenceApiRequest(request(payload), client))?.status).toBe(200) + expect(posts).toHaveLength(count) + }) + it.each(["legacy", "search"] as const)("keeps the atomic %s winner when old and new cutoff-week writers race", async winner => { + const { client, posts } = setup() + const payload = datedMonthlyPayload(true) + const legacy = () => deliverWeeklyDigest(client, payload, legacyMonthlyRendering(payload)) + const current = () => handleSearchIntelligenceApiRequest(request(payload), client) + const original = client.rest.post.bind(client.rest) + let release!: () => void + let entered!: () => void + const ready = new Promise(resolve => { entered = resolve }) + const gate = new Promise(resolve => { release = resolve }) + client.rest.post = (async (...args: Parameters) => { + entered() + await gate + return original(...args) + }) as typeof client.rest.post + const first = winner === "legacy" ? legacy() : current() + await ready + try { + expect((await (winner === "legacy" ? current() : legacy()))?.status).toBe(409) + const changed = structuredClone(payload) + changed.catalogs.skills.recommendations[0].reason = "Different full payload" + expect((await handleSearchIntelligenceApiRequest(request(changed), client))?.status).toBe(409) + } finally { + release() + } + expect((await first)?.status).toBe(200) + const count = posts.length + expect((await current())?.status).toBe(200) + expect(posts).toHaveLength(count) + expect(new Set(posts.map(({ body }) => body.nonce)).size).toBe(count) + const rendered = posts.flatMap(({ body }) => texts(body)).join("\n") + if (winner === "legacy") { + expect(rendered).toContain(payload.catalogs.skills.recommendations[15].url) + expect(rendered).not.toContain("company opportunities") + } else { + expect(rendered).toContain("company opportunities") + expect(rendered).not.toContain(payload.catalogs.skills.recommendations[15].url) + } + }) + it("keeps pre-cutoff producer-frozen weeks byte-identical even when first received after cutover", async () => { + const payload = datedMonthlyPayload(true, Date.UTC(2026, 9, 26)) + const baseline = setup() + expect((await deliverWeeklyDigest(baseline.client, payload, legacyMonthlyRendering(payload))).status).toBe(200) + const { client, posts } = setup() + mockClock = spyOn(Date, "now").mockReturnValue(Date.UTC(2040, 0, 1)) + expect((await handleSearchIntelligenceApiRequest(request(payload), client))?.status).toBe(200) + expect(posts).toEqual(baseline.posts) + mockClock.mockReturnValue(Date.UTC(2020, 0, 1)) + expect((await handleSearchIntelligenceApiRequest(request(payload), client))?.status).toBe(200) + expect(posts).toEqual(baseline.posts) + }) + it("rejects unknown incomplete manifests instead of treating them as legacy compatibility", async () => { + const { client, posts } = setup() + const payload = datedMonthlyPayload() + const original = client.rest.post.bind(client.rest) + client.rest.post = (async () => { + throw Object.assign(new Error("Rejected"), { status: 429 }) + }) as typeof client.rest.post + expect((await deliverWeeklyDigest(client, payload, [serializePayload({ + components: [new Container([new TextDisplay("Unknown old renderer")])] + })])).status).toBe(502) + client.rest.post = original + expect((await handleSearchIntelligenceApiRequest(request(payload), client))?.status).toBe(409) + expect(posts).toHaveLength(0) + }) + it.each(["retryable", "uncertain"] as const)("keeps new search-only %s retries on their original nonces and bodies", async state => { + const { client, posts } = setup() + const payload = datedMonthlyPayload() + const original = client.rest.post.bind(client.rest) + let calls = 0 + client.rest.post = (async (...args: Parameters) => { + if (++calls === 2) { + if (state === "retryable") throw Object.assign(new Error("Rejected"), { status: 429 }) + await original(...args) + throw new Error("Response lost") + } + return original(...args) + }) as typeof client.rest.post + expect((await handleSearchIntelligenceApiRequest(request(payload), client))?.status) + .toBe(state === "retryable" ? 502 : 503) + const previous = structuredClone(posts) + client.rest.post = original + if (state === "uncertain") { + expect((await handleSearchIntelligenceApiRequest(request(payload), client))?.status).toBe(503) + expect(posts).toEqual(previous) + client.rest.get = async () => [{ + id: "search-part-2", + author: { id: "bot-user", bot: true }, + timestamp: new Date().toISOString(), + components: previous[1].body.components + }] as never + } + expect((await handleSearchIntelligenceApiRequest(request(payload), client))?.status).toBe(200) + expect(posts.slice(0, previous.length)).toEqual(previous) + expect(new Set(posts.map(({ body }) => body.nonce)).size).toBe(posts.length) + const count = posts.length + expect((await handleSearchIntelligenceApiRequest(request(payload), client))?.status).toBe(200) + expect(posts).toHaveLength(count) + expect(posts.flatMap(({ body }) => texts(body)).join("\n")).not.toContain(payload.catalogs.plugins.recommendations[0].url) + const changed = structuredClone(payload) + changed.catalogs.skills.recommendations[0].reason = "Changed invisible evidence" + expect((await handleSearchIntelligenceApiRequest(request(changed), client))?.status).toBe(409) + expect(posts).toHaveLength(count) + }) + it("still validates hidden Featured evidence and trusted search facts before a new claim", async () => { + const { client, posts } = setup() + const payload = datedMonthlyPayload() + for (const mutate of [ + (p: typeof payload) => { p.catalogs.plugins.recommendations[0].adoption.installs7d = 101 }, + (p: typeof payload) => { p.catalogs.plugins.companyOpportunities[0].searchUrl = "https://evil.example/search" }, + (p: typeof payload) => { p.catalogs.plugins.companyOpportunities[0].searches = 2 }, + (p: typeof payload) => { Object.assign(p.catalogs.plugins, { userId: "private" }) } + ]) { + const invalid = structuredClone(payload) + mutate(invalid) + expect((await handleSearchIntelligenceApiRequest(request(invalid), client))?.status).toBe(400) + } + expect(posts).toHaveLength(0) + mockClock = spyOn(Date, "now").mockReturnValue(Date.UTC(2020, 0, 1)) + expect((await handleSearchIntelligenceApiRequest(request(payload), client))?.status).toBe(200) + const rendered = posts.flatMap(({ body }) => texts(body)).join("\n") + expect(rendered).toContain("company opportunities") + expect(rendered).not.toContain(payload.catalogs.plugins.recommendations[0].url) + }) + it("retains bounded company, gap and zero-current movers without mentions or oversized search links", async () => { + const { client, posts } = setup() + const payload = datedMonthlyPayload() + const plugins = payload.catalogs.plugins + plugins.totalSearches = 1000 + plugins.sourceCounts = { clawhubWeb: 600, openclawControlUi: 400 } + plugins.companyOpportunities = Array.from({ length: 5 }, (_, index) => ({ + ...plugins.companyOpportunities[0], + query: `topic-${index} ${"x".repeat(100)}`, + companyProductName: `Campaign ${index} @everyone [name]`, + searchUrl: `https://clawhub.ai/plugins?q=${index === 0 ? "x".repeat(1900) : `topic-${index}`}` + })) + plugins.officialGaps = plugins.companyOpportunities.map(({ query }, index) => ({ + ...plugins.officialGaps[0], query, searchUrl: `https://clawhub.ai/plugins?q=topic-${index}` + })) + plugins.movers = plugins.officialGaps.map((row, index) => ({ + ...row, query: `dropped-${index}`, searches: 0, officialGaps: 0, previousSearches: 5 + })) + expect((await handleSearchIntelligenceApiRequest(request(payload), client))?.status).toBe(200) + const rendered = posts.flatMap(({ body }) => texts(body)).join("\n") + for (let index = 0; index < 5; index++) { + expect(rendered).toContain(`Campaign ${index}`) + expect(rendered.match(new RegExp(`\\[topic-${index} `, "g"))).toHaveLength(2) + expect(rendered).toContain(`dropped-${index}`) + } + expect(rendered).toContain("0 searches · 0 gaps · previous 5") + expect(rendered).not.toContain("@everyone") + expect(rendered).not.toContain(plugins.companyOpportunities[0].searchUrl) + expect(rendered).toContain(payload.dashboardUrl) + for (const { body } of posts) { + expect(texts(body).join("\n").length).toBeLessThanOrEqual(4000) + expect(componentCount(body) - 1).toBeLessThanOrEqual(40) + expect(body.allowed_mentions).toEqual({ parse: [] }) + } + }) + it("distinguishes unknown collection history from zero searches after cutover", async () => { + const { client, posts } = setup() + const payload = datedMonthlyPayload() + Object.assign(payload.catalogs.plugins.coverage, { dataThrough: null, collectionStartedAt: null }) + expect((await handleSearchIntelligenceApiRequest(request(payload), client))?.status).toBe(200) + const rendered = posts.flatMap(({ body }) => texts(body)).join("\n") + expect(rendered).toContain("Data through unknown; collection started unknown. Incomplete collection history.") + expect(rendered).toContain("0 searches · Web 0 · Control UI 0") + expect(rendered).toContain("Classification unavailable") + }) + it("delivers every linked monthly recommendation in one compact message and replays it", async () => { const { client, posts } = setup() const payload = monthlyPayload() @@ -745,12 +1012,15 @@ describe("ClawHub weekly search intelligence receiver", () => { expect(posts.length).toBeGreaterThan(2) expect(new Set(posts.map(({ body }) => body.nonce)).size).toBe(posts.length) }) - it("recovers a final monthly receipt write failure without repeating its confirmed parts", async () => { + it.each([ + ["legacy", monthlyPayload], + ["search-only", datedMonthlyPayload] + ] as const)("recovers a final %s receipt write failure without repeating its confirmed parts", async (_kind, makePayload) => { const { client, posts, owner } = setup() owner.database.exec(`CREATE TRIGGER fail_monthly_completion BEFORE UPDATE ON keyValue WHEN json_extract(NEW.value, '$.version') = 2 AND json_extract(NEW.value, '$.status') = 'sent' BEGIN SELECT RAISE(ABORT, 'fixture receipt unavailable'); END`) - const payload = monthlyPayload(true) + const payload = makePayload(true) expect( (await handleSearchIntelligenceApiRequest(request(payload), client)) ?.status