From c6a260d437841d2d73e01b93337e80580f884579 Mon Sep 17 00:00:00 2001 From: Mayakovsky Date: Thu, 30 Jul 2026 15:10:41 -0400 Subject: [PATCH 1/5] feat(e1-round2): EvaluationKit + Bazaar discovery metadata (sub-unit 1) New @grey/schemas/evaluationKit subpath (Invariant #33 single source): branding data authored ONCE per offering, projected into the Bazaar extension shape (discoverable, serviceName, tags, description, inputSchema, outputSchema, iconUrl) with soft-drop validation (printable-ASCII serviceName/tags, absolute-https non-IP/loopback iconUrl) -- a bad field is omitted and recorded in `dropped`, not thrown, matching the spec's own description of Bazaar's indexing behaviour. Wired on every x402 route: challenge.ts's 402 PaymentRequirements now embeds `accepts[].extra.bazaar` from the same projector. New grey-core GET /v1/discovery/services (+/:slug) -- free, ungated -- is the crawlable index; registry-driven off `offeringHandlers` so a later disable-flagged offering (E1-C) is structurally absent without a separate flag to keep in sync. External surface change (unlike E1-A): 402 `extra` gains a `bazaar` key; challenge.test.ts updated for the new shape, not just re-asserted unchanged. EXPANSION-E1-ROUND2-KOV-directive.md sub-unit 1 (was E1-B). --- adapters/x402-middleware/src/challenge.ts | 19 ++- adapters/x402-middleware/src/types.ts | 19 ++- .../x402-middleware/test/challenge.test.ts | 29 +++- packages/grey-core/src/server/index.ts | 2 + .../grey-core/src/server/routes/discovery.ts | 34 +++++ packages/grey-core/test/discovery.test.ts | 36 +++++ packages/grey-schemas/build.mjs | 1 + packages/grey-schemas/openapi/openapi.yaml | 3 + packages/grey-schemas/package.json | 1 + .../grey-schemas/src/evaluationKit/build.ts | 139 ++++++++++++++++++ .../grey-schemas/src/evaluationKit/data.ts | 64 ++++++++ .../grey-schemas/src/evaluationKit/index.ts | 9 ++ .../grey-schemas/src/evaluationKit/types.ts | 45 ++++++ .../grey-schemas/test/evaluationKit.test.ts | 114 ++++++++++++++ vitest.config.ts | 1 + 15 files changed, 507 insertions(+), 9 deletions(-) create mode 100644 packages/grey-core/src/server/routes/discovery.ts create mode 100644 packages/grey-core/test/discovery.test.ts create mode 100644 packages/grey-schemas/src/evaluationKit/build.ts create mode 100644 packages/grey-schemas/src/evaluationKit/data.ts create mode 100644 packages/grey-schemas/src/evaluationKit/index.ts create mode 100644 packages/grey-schemas/src/evaluationKit/types.ts create mode 100644 packages/grey-schemas/test/evaluationKit.test.ts diff --git a/adapters/x402-middleware/src/challenge.ts b/adapters/x402-middleware/src/challenge.ts index 3cf769e..ba699da 100644 --- a/adapters/x402-middleware/src/challenge.ts +++ b/adapters/x402-middleware/src/challenge.ts @@ -1,6 +1,8 @@ // Build the 402 body — strict-canonical x402 `PaymentRequirements` (Forces ruling: maxTimeoutSeconds // only, no server nonce/expiresAt). One `accepts` entry: the buyer signs an EIP-3009 authorization // for `maxAmountRequired` USDC to `payTo`, using the `extra` domain hints. +import type { OfferingSlug } from '@grey/schemas/responses'; +import { buildEvaluationKit } from '@grey/schemas/evaluationKit'; import type { X402Config, PaymentRequirements } from './types.js'; import { priceAtomicFor } from './prices.js'; @@ -10,6 +12,9 @@ export function buildPaymentRequirements( resource: string, error?: string, ): PaymentRequirements { + // E1-B: every x402 route carries its own Bazaar discovery metadata in the 402 body — the + // single EvaluationKit source (Invariant #33), not a hand-authored per-route literal. + const kit = buildEvaluationKit(slug as OfferingSlug); const body: PaymentRequirements = { x402Version: 1, accepts: [ @@ -23,7 +28,19 @@ export function buildPaymentRequirements( payTo: cfg.payTo, maxTimeoutSeconds: cfg.maxTimeoutSeconds, asset: cfg.usdc.address, - extra: { name: cfg.usdc.name, version: cfg.usdc.version }, + extra: { + name: cfg.usdc.name, + version: cfg.usdc.version, + bazaar: { + discoverable: kit.discoverable, + serviceName: kit.serviceName, + tags: kit.tags, + description: kit.description, + inputSchema: kit.inputSchema, + outputSchema: kit.outputSchema, + iconUrl: kit.iconUrl, + }, + }, }, ], }; diff --git a/adapters/x402-middleware/src/types.ts b/adapters/x402-middleware/src/types.ts index 4fdcbfb..71371c7 100644 --- a/adapters/x402-middleware/src/types.ts +++ b/adapters/x402-middleware/src/types.ts @@ -1,5 +1,6 @@ // @grey/x402-middleware — shared types for the x402 `exact`-scheme sell-side gate. import type { Address, Hex } from 'viem'; +import type { EvaluationKitEntry } from '@grey/schemas/evaluationKit'; export type X402Network = 'eip155:8453' | 'eip155:84532'; @@ -68,8 +69,22 @@ export interface PaymentRequirements { payTo: Address; maxTimeoutSeconds: number; asset: Address; - /** EIP-712 domain hints the buyer needs to sign the authorization. */ - extra: { name: string; version: string }; + /** EIP-712 domain hints the buyer needs to sign the authorization, plus (E1-B) the Bazaar + * discovery metadata projected from @grey/schemas/evaluationKit — "on every x402 route". */ + extra: { + name: string; + version: string; + bazaar: Pick< + EvaluationKitEntry, + | 'discoverable' + | 'serviceName' + | 'tags' + | 'description' + | 'inputSchema' + | 'outputSchema' + | 'iconUrl' + >; + }; }>; error?: string; } diff --git a/adapters/x402-middleware/test/challenge.test.ts b/adapters/x402-middleware/test/challenge.test.ts index af0a53f..4fdf914 100644 --- a/adapters/x402-middleware/test/challenge.test.ts +++ b/adapters/x402-middleware/test/challenge.test.ts @@ -20,7 +20,8 @@ describe('buildPaymentRequirements — strict-canonical x402', () => { expect(a.asset).toBe(TEST_CFG.usdc.address); expect(a.maxTimeoutSeconds).toBe(120); expect(a.resource).toBe('/v1/offerings/verify_whitepaper'); - expect(a.extra).toEqual({ name: TEST_CFG.usdc.name, version: TEST_CFG.usdc.version }); + expect(a.extra.name).toBe(TEST_CFG.usdc.name); + expect(a.extra.version).toBe(TEST_CFG.usdc.version); expect(body.error).toBe('payment required'); }); @@ -37,11 +38,27 @@ describe('buildPaymentRequirements — strict-canonical x402', () => { }); it('carries per-slug pricing', () => { - expect(buildPaymentRequirements(TEST_CFG, 'daily_tech_brief', '/r').accepts[0].maxAmountRequired).toBe( - '8000000', - ); - expect(buildPaymentRequirements(TEST_CFG, 'quick_protocol_facts', '/r').accepts[0].maxAmountRequired).toBe( - '300000', + expect( + buildPaymentRequirements(TEST_CFG, 'daily_tech_brief', '/r').accepts[0].maxAmountRequired, + ).toBe('8000000'); + expect( + buildPaymentRequirements(TEST_CFG, 'quick_protocol_facts', '/r').accepts[0].maxAmountRequired, + ).toBe('300000'); + }); + + it('carries Bazaar discovery metadata from the single EvaluationKit source (E1-B, Invariant #33)', () => { + const body = buildPaymentRequirements( + TEST_CFG, + 'legitimacy_scan', + '/v1/offerings/legitimacy_scan', ); + const bazaar = body.accepts[0].extra.bazaar; + expect(bazaar.discoverable).toBe(true); + expect(bazaar.serviceName).toBe('Project Legitimacy Scan'); + expect(bazaar.tags).toContain('crypto'); + expect(typeof bazaar.description).toBe('string'); + expect(bazaar.inputSchema).toBeTruthy(); + expect(bazaar.outputSchema).toBeTruthy(); + expect(bazaar.iconUrl).toBe('https://whitepapergrey.com/icons/legitimacy_scan.svg'); }); }); diff --git a/packages/grey-core/src/server/index.ts b/packages/grey-core/src/server/index.ts index 6e27b4c..cc7cd5f 100644 --- a/packages/grey-core/src/server/index.ts +++ b/packages/grey-core/src/server/index.ts @@ -9,6 +9,7 @@ import { installValidatorCompiler } from './validators'; import { registerProbes } from './routes/probes'; import { registerOfferingRoutes } from './routes/offerings'; import { registerResourceRoutes } from './routes/resources'; +import { registerDiscoveryRoutes } from './routes/discovery'; export function buildServer(deps: HandlerDeps, x402PreHandler: preHandlerHookHandler): FastifyInstance { const app = Fastify({ logger: false }); @@ -16,5 +17,6 @@ export function buildServer(deps: HandlerDeps, x402PreHandler: preHandlerHookHan registerProbes(app, deps); registerOfferingRoutes(app, deps, x402PreHandler); // paid POST × 7, behind the x402 gate registerResourceRoutes(app, deps); // free GET × 2 + registerDiscoveryRoutes(app); // E1-B: free Bazaar discovery index, GET × 2 return app; } diff --git a/packages/grey-core/src/server/routes/discovery.ts b/packages/grey-core/src/server/routes/discovery.ts new file mode 100644 index 0000000..690dbf7 --- /dev/null +++ b/packages/grey-core/src/server/routes/discovery.ts @@ -0,0 +1,34 @@ +// Bazaar discovery surface (E1-B): GET /v1/discovery/services lists every discoverable offering's +// EvaluationKit projection (Invariant #33 — the SAME source every 402 response embeds via +// @grey/x402-middleware/challenge.ts). Free, unauthenticated — a crawler/evaluating agent reads +// this before ever hitting a paid route. GET /v1/discovery/services/:slug returns one entry (the +// public capability page E1-C's evaluation artifacts extend with a sample). +import type { FastifyInstance } from 'fastify'; +import type { OfferingSlug } from '@grey/schemas/responses'; +import { buildEvaluationKit } from '@grey/schemas/evaluationKit'; +import { offeringHandlers } from '../../handlers'; + +/** Registry-driven: only offerings actually present in `offeringHandlers` are listed, so a + * disable-flagged offering (E1-C's trust rung) that isn't registered there is structurally + * absent from discovery too — not a separate flag to keep in sync. */ +function listableSlugs(): OfferingSlug[] { + return Object.keys(offeringHandlers) as OfferingSlug[]; +} + +export function registerDiscoveryRoutes(app: FastifyInstance): void { + app.get('/v1/discovery/services', async (_req, reply) => { + const services = listableSlugs() + .map((slug) => buildEvaluationKit(slug)) + .filter((kit) => kit.discoverable); + reply.send({ services }); + }); + + app.get<{ Params: { slug: string } }>('/v1/discovery/services/:slug', async (req, reply) => { + const slug = req.params.slug; + if (!listableSlugs().includes(slug as OfferingSlug)) { + reply.code(404).send({ error: `not found or not discoverable: ${slug}` }); + return; + } + reply.send(buildEvaluationKit(slug as OfferingSlug)); + }); +} diff --git a/packages/grey-core/test/discovery.test.ts b/packages/grey-core/test/discovery.test.ts new file mode 100644 index 0000000..b494c64 --- /dev/null +++ b/packages/grey-core/test/discovery.test.ts @@ -0,0 +1,36 @@ +// GET /v1/discovery/services (+/:slug) — the Bazaar discovery index (E1-B). Free, ungated. +import { describe, it, expect } from 'vitest'; +import { makeApp } from './_helpers'; + +describe('discovery routes — Bazaar index (E1-B, Invariant #33)', () => { + it('GET /v1/discovery/services lists all 9 offerings, discoverable and free (no x402 gate)', async () => { + const app = makeApp(); + const res = await app.inject({ method: 'GET', url: '/v1/discovery/services' }); + expect(res.statusCode).toBe(200); + const body = res.json() as { services: Array<{ slug: string; discoverable: boolean }> }; + expect(body.services).toHaveLength(9); + expect(body.services.every((s) => s.discoverable)).toBe(true); + expect(body.services.map((s) => s.slug)).toContain('legitimacy_scan'); + }); + + it('GET /v1/discovery/services/:slug returns one EvaluationKit entry', async () => { + const app = makeApp(); + const res = await app.inject({ + method: 'GET', + url: '/v1/discovery/services/verify_whitepaper', + }); + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.slug).toBe('verify_whitepaper'); + expect(body.priceUsd).toBe(1.5); + expect(body.computeClass).toBe('LIVE_ALLOWED'); + expect(body.inputSchema).toBeTruthy(); + expect(body.outputSchema).toBeTruthy(); + }); + + it('GET /v1/discovery/services/:slug 404s for an unknown or unregistered slug', async () => { + const app = makeApp(); + const res = await app.inject({ method: 'GET', url: '/v1/discovery/services/nope' }); + expect(res.statusCode).toBe(404); + }); +}); diff --git a/packages/grey-schemas/build.mjs b/packages/grey-schemas/build.mjs index d7a4944..e718e71 100644 --- a/packages/grey-schemas/build.mjs +++ b/packages/grey-schemas/build.mjs @@ -14,6 +14,7 @@ await buildPackage({ 'src/envelope/index.ts', 'src/validators/index.ts', 'src/pricing/index.ts', + 'src/evaluationKit/index.ts', ], // ajv 8.x has no `exports` map; its extensionless deep import must carry `.js` for Node ESM. alias: { 'ajv/dist/2020': 'ajv/dist/2020.js' }, diff --git a/packages/grey-schemas/openapi/openapi.yaml b/packages/grey-schemas/openapi/openapi.yaml index bdbd0c3..5380b9e 100644 --- a/packages/grey-schemas/openapi/openapi.yaml +++ b/packages/grey-schemas/openapi/openapi.yaml @@ -8,6 +8,9 @@ info: Movement 2.5 (schema layer); routes are placeholders that Movement 3 (grey-core) is authoritative for and may relocate. x402 payment integration is Movement 5; this document declares the payment CONTRACT only, not its implementation. + Bazaar discovery metadata (E1-B) is NOT hand-mirrored here (Invariant #33 — + single source, no per-platform metadata): every 402 response's `accepts[].extra.bazaar` + and `GET /v1/discovery/services` project the same @grey/schemas/evaluationKit source live. servers: - url: https://whitepapergrey.com description: Live domain diff --git a/packages/grey-schemas/package.json b/packages/grey-schemas/package.json index c08a3f2..f6cbdb2 100644 --- a/packages/grey-schemas/package.json +++ b/packages/grey-schemas/package.json @@ -12,6 +12,7 @@ "./envelope": { "types": "./dist/envelope/index.d.ts", "default": "./dist/envelope/index.js" }, "./validators": { "types": "./dist/validators/index.d.ts", "default": "./dist/validators/index.js" }, "./pricing": { "types": "./dist/pricing/index.d.ts", "default": "./dist/pricing/index.js" }, + "./evaluationKit": { "types": "./dist/evaluationKit/index.d.ts", "default": "./dist/evaluationKit/index.js" }, "./openapi": "./openapi/openapi.yaml" }, "files": ["dist", "openapi"], diff --git a/packages/grey-schemas/src/evaluationKit/build.ts b/packages/grey-schemas/src/evaluationKit/build.ts new file mode 100644 index 0000000..cd35d70 --- /dev/null +++ b/packages/grey-schemas/src/evaluationKit/build.ts @@ -0,0 +1,139 @@ +// @grey/schemas/evaluationKit — the projector (E1-B). Merges branding data + the canonical +// pricing table + the raw JSON Schemas into the Bazaar extension shape, applying the stated +// validation rules with SOFT-DROP semantics: a bad field is omitted from the entry (and recorded +// in `dropped`, so it's inspectable rather than truly silent) instead of throwing or blocking the +// whole listing. +import type { OfferingSlug, PaidOfferingSlug } from '../responses/types'; +import { computeClassFor, canonicalUsdFor, PRICING_TABLE } from '../pricing/table'; +import { EVALUATION_KIT_BRANDING } from './data'; +import type { DroppedField, EvaluationKitEntry, SampleExchange } from './types'; + +import legitimacyScanResponse from '../responses/v1/legitimacy_scan.schema.json'; +import verifyWhitepaperResponse from '../responses/v1/verify_whitepaper.schema.json'; +import verifyFullTechResponse from '../responses/v1/verify_full_tech.schema.json'; +import claimExtractionResponse from '../responses/v1/claim_extraction.schema.json'; +import claimHistoryResponse from '../responses/v1/claim_history.schema.json'; +import quickProtocolFactsResponse from '../responses/v1/quick_protocol_facts.schema.json'; +import dailyTechBriefResponse from '../responses/v1/daily_tech_brief.schema.json'; +import dailyGreenlightListResponse from '../responses/v1/daily_greenlight_list.schema.json'; +import scamAlertFeedResponse from '../responses/v1/scam_alert_feed.schema.json'; + +import legitimacyScanRequest from '../requests/v1/legitimacy_scan.schema.json'; +import verifyWhitepaperRequest from '../requests/v1/verify_whitepaper.schema.json'; +import verifyFullTechRequest from '../requests/v1/verify_full_tech.schema.json'; +import claimExtractionRequest from '../requests/v1/claim_extraction.schema.json'; +import claimHistoryRequest from '../requests/v1/claim_history.schema.json'; +import quickProtocolFactsRequest from '../requests/v1/quick_protocol_facts.schema.json'; +import dailyTechBriefRequest from '../requests/v1/daily_tech_brief.schema.json'; + +const OUTPUT_SCHEMAS: Record = { + legitimacy_scan: legitimacyScanResponse, + verify_whitepaper: verifyWhitepaperResponse, + verify_full_tech: verifyFullTechResponse, + claim_extraction: claimExtractionResponse, + claim_history: claimHistoryResponse, + quick_protocol_facts: quickProtocolFactsResponse, + daily_tech_brief: dailyTechBriefResponse, + daily_greenlight_list: dailyGreenlightListResponse, + scam_alert_feed: scamAlertFeedResponse, +}; + +const INPUT_SCHEMAS: Record = { + legitimacy_scan: legitimacyScanRequest, + verify_whitepaper: verifyWhitepaperRequest, + verify_full_tech: verifyFullTechRequest, + claim_extraction: claimExtractionRequest, + claim_history: claimHistoryRequest, + quick_protocol_facts: quickProtocolFactsRequest, + daily_tech_brief: dailyTechBriefRequest, +}; + +const FREE_SLUGS = new Set(['daily_greenlight_list', 'scam_alert_feed']); + +/** Printable ASCII only (0x20–0x7E) — the Bazaar validation rule for serviceName/tags. */ +function isPrintableAscii(s: string): boolean { + return s.length > 0 && /^[\x20-\x7E]+$/.test(s); +} + +const IPV4_RE = /^(\d{1,3}\.){3}\d{1,3}$/; +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0']); +// https://[:][/...] — deliberately hand-parsed (not the global `URL`) so this package +// stays node-ambient-free (no "types": ["node"] opt-in, unlike the adapter packages). +const HTTPS_URL_RE = /^https:\/\/(\[[^\]]+\]|[^/:?#]+)(?::\d+)?(?:[/?#]|$)/; + +/** Absolute https, no IP literal, no loopback host — the Bazaar validation rule for iconUrl. */ +function isValidIconUrl(url: string): boolean { + const m = HTTPS_URL_RE.exec(url); + if (!m) return false; + const rawHost = m[1]!; + if (rawHost.startsWith('[')) return false; // bracketed IPv6 literal + const host = rawHost.toLowerCase(); + if (LOOPBACK_HOSTS.has(host)) return false; + if (IPV4_RE.test(host)) return false; + return true; +} + +/** + * Project one offering into the Bazaar extension shape. Soft-drop: a bad serviceName/tag/iconUrl + * is omitted (null / filtered) rather than thrown — the entry still ships, just without that + * field, matching the spec's own description of Bazaar's indexing behaviour. + */ +export function buildEvaluationKit( + slug: OfferingSlug, + opts: { sample?: SampleExchange } = {}, +): EvaluationKitEntry { + const branding = EVALUATION_KIT_BRANDING[slug]; + const pricing = PRICING_TABLE[slug]; + const dropped: DroppedField[] = []; + + let serviceName: string | null = branding.serviceName; + if (!isPrintableAscii(branding.serviceName)) { + dropped.push({ field: 'serviceName', reason: 'not printable ASCII' }); + serviceName = null; + } + + const tags: string[] = []; + for (const tag of branding.tags) { + if (isPrintableAscii(tag)) { + tags.push(tag); + } else { + dropped.push({ field: `tags[${tag}]`, reason: 'not printable ASCII' }); + } + } + + let iconUrl: string | null = branding.iconUrl; + if (!isValidIconUrl(branding.iconUrl)) { + dropped.push({ + field: 'iconUrl', + reason: 'not an absolute https URL, or an IP/loopback literal', + }); + iconUrl = null; + } + + const inputSchema = FREE_SLUGS.has(slug) + ? null + : (INPUT_SCHEMAS[slug as PaidOfferingSlug] ?? null); + + return { + slug, + discoverable: true, + serviceName, + tags, + description: branding.description, + inputSchema, + outputSchema: OUTPUT_SCHEMAS[slug], + iconUrl, + priceUsd: pricing.canonicalUsd === null ? null : canonicalUsdFor(slug), + computeClass: computeClassFor(slug), + sample: opts.sample, + dropped, + }; +} + +/** Project every offering. Callers filter on `discoverable`/`priceUsd !== null` as needed — this + * function does not itself decide what a channel should list (E1-C's disable flag is separate). */ +export function buildAllEvaluationKits(): EvaluationKitEntry[] { + return (Object.keys(EVALUATION_KIT_BRANDING) as OfferingSlug[]).map((slug) => + buildEvaluationKit(slug), + ); +} diff --git a/packages/grey-schemas/src/evaluationKit/data.ts b/packages/grey-schemas/src/evaluationKit/data.ts new file mode 100644 index 0000000..6debfa6 --- /dev/null +++ b/packages/grey-schemas/src/evaluationKit/data.ts @@ -0,0 +1,64 @@ +// @grey/schemas/evaluationKit — hand-authored branding data, ONCE (Invariant #33). Icon assets are +// served from the live domain (openapi.yaml's `servers[0]`); tags are printable-ASCII, lowercase, +// hyphenless-safe words per the Bazaar validation rule (E1-B). +import type { OfferingSlug } from '../responses/types'; +import type { EvaluationKitBranding } from './types'; + +const ICON_BASE = 'https://whitepapergrey.com/icons'; + +export const EVALUATION_KIT_BRANDING: Record = { + legitimacy_scan: { + serviceName: 'Project Legitimacy Scan', + tags: ['crypto', 'due-diligence', 'verification', 'tier1'], + description: 'Fast structural + claims legitimacy read on a token project, cache-or-live.', + iconUrl: `${ICON_BASE}/legitimacy_scan.svg`, + }, + verify_whitepaper: { + serviceName: 'Whitepaper Verification', + tags: ['crypto', 'due-diligence', 'verification', 'tokenomics'], + description: 'Tokenomics-focused audit of a project whitepaper against its stated claims.', + iconUrl: `${ICON_BASE}/verify_whitepaper.svg`, + }, + verify_full_tech: { + serviceName: 'Full Technical Verification', + tags: ['crypto', 'due-diligence', 'verification', 'technical'], + description: 'Complete technical + tokenomics verification, the deepest tier Grey offers.', + iconUrl: `${ICON_BASE}/verify_full_tech.svg`, + }, + claim_extraction: { + serviceName: 'Claim Extraction', + tags: ['crypto', 'nlp', 'extraction'], + description: 'Extracts structured, categorised claims from a buyer-supplied whitepaper URL.', + iconUrl: `${ICON_BASE}/claim_extraction.svg`, + }, + claim_history: { + serviceName: 'Claim History', + tags: ['crypto', 'due-diligence', 'history'], + description: 'Prior extracted claims + verification history for a known project.', + iconUrl: `${ICON_BASE}/claim_history.svg`, + }, + quick_protocol_facts: { + serviceName: 'Quick Protocol Facts', + tags: ['crypto', 'lookup', 'cache-only'], + description: 'Cache-only fast facts lookup for a known protocol — no live compute.', + iconUrl: `${ICON_BASE}/quick_protocol_facts.svg`, + }, + daily_tech_brief: { + serviceName: 'Daily Technical Briefing', + tags: ['crypto', 'digest', 'cache-only'], + description: 'Daily aggregated digest of recently verified projects.', + iconUrl: `${ICON_BASE}/daily_tech_brief.svg`, + }, + daily_greenlight_list: { + serviceName: 'Daily Greenlight List', + tags: ['crypto', 'digest', 'free'], + description: 'Free daily list of projects clearing Grey verification.', + iconUrl: `${ICON_BASE}/daily_greenlight_list.svg`, + }, + scam_alert_feed: { + serviceName: 'Scam Alert Feed', + tags: ['crypto', 'safety', 'free'], + description: 'Free feed of projects flagged by Grey verification as high-risk.', + iconUrl: `${ICON_BASE}/scam_alert_feed.svg`, + }, +}; diff --git a/packages/grey-schemas/src/evaluationKit/index.ts b/packages/grey-schemas/src/evaluationKit/index.ts new file mode 100644 index 0000000..ef6e429 --- /dev/null +++ b/packages/grey-schemas/src/evaluationKit/index.ts @@ -0,0 +1,9 @@ +// @grey/schemas/evaluationKit — barrel (E1-B, Invariant #33). +export type { + EvaluationKitBranding, + EvaluationKitEntry, + SampleExchange, + DroppedField, +} from './types'; +export { EVALUATION_KIT_BRANDING } from './data'; +export { buildEvaluationKit, buildAllEvaluationKits } from './build'; diff --git a/packages/grey-schemas/src/evaluationKit/types.ts b/packages/grey-schemas/src/evaluationKit/types.ts new file mode 100644 index 0000000..bf395a7 --- /dev/null +++ b/packages/grey-schemas/src/evaluationKit/types.ts @@ -0,0 +1,45 @@ +// @grey/schemas/evaluationKit — the reusable metadata projection (E1-B, spec §3 E1 bequeaths, +// Invariant #33). Every channel listing (Bazaar, Kite, Olas, MCP, ...) renders from this single +// source — no hand-authored per-platform metadata. +import type { OfferingSlug } from '../responses/types'; + +/** Hand-authored ONCE per offering (this satisfies Invariant #33 — "no hand-authored + * PER-PLATFORM metadata" means don't re-author per channel, not that this data has no author). */ +export interface EvaluationKitBranding { + readonly serviceName: string; + readonly tags: readonly string[]; + readonly description: string; + readonly iconUrl: string; +} + +/** One sample request/response pair — the evaluation-friction answer (spec §0.2): a buying agent + * can inspect real shape + a real output before it ever pays. */ +export interface SampleExchange { + readonly request: unknown; + readonly response: unknown; +} + +/** A field that failed a Bazaar validation rule and was soft-dropped (spec E1-B: "soft-drop means + * a bad field vanishes silently" — surfaced here so tests/logs can see it instead of it truly + * vanishing without a trace). */ +export interface DroppedField { + readonly field: string; + readonly reason: string; +} + +/** The projected Bazaar extension shape for one offering (spec E1-B field list, verbatim). */ +export interface EvaluationKitEntry { + readonly slug: OfferingSlug; + readonly discoverable: boolean; + readonly serviceName: string | null; + readonly tags: readonly string[]; + readonly description: string; + readonly inputSchema: object | null; + readonly outputSchema: object; + readonly iconUrl: string | null; + readonly priceUsd: number | null; + readonly computeClass: string; + readonly sample?: SampleExchange; + /** Fields that failed validation and were dropped from this entry (soft-drop, not a throw). */ + readonly dropped: readonly DroppedField[]; +} diff --git a/packages/grey-schemas/test/evaluationKit.test.ts b/packages/grey-schemas/test/evaluationKit.test.ts new file mode 100644 index 0000000..7c0a0ca --- /dev/null +++ b/packages/grey-schemas/test/evaluationKit.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from 'vitest'; +import type { OfferingSlug } from '../src/responses/types'; +import { + buildEvaluationKit, + buildAllEvaluationKits, + EVALUATION_KIT_BRANDING, +} from '../src/evaluationKit'; + +describe('EvaluationKit — Bazaar extension projection (E1-B, Invariant #33)', () => { + it('projects all 9 offerings with the spec field list', () => { + const kits = buildAllEvaluationKits(); + expect(kits).toHaveLength(9); + for (const k of kits) { + expect(k.discoverable).toBe(true); + expect(typeof k.description).toBe('string'); + expect(k.outputSchema).toBeTruthy(); + expect(Array.isArray(k.tags)).toBe(true); + expect(Array.isArray(k.dropped)).toBe(true); + } + }); + + it('the 7 paid offerings carry an inputSchema; the 2 free resources do not', () => { + const kit = (slug: OfferingSlug) => buildEvaluationKit(slug); + expect(kit('legitimacy_scan').inputSchema).toBeTruthy(); + expect(kit('daily_tech_brief').inputSchema).toBeTruthy(); + expect(kit('daily_greenlight_list').inputSchema).toBeNull(); + expect(kit('scam_alert_feed').inputSchema).toBeNull(); + }); + + it('carries priceUsd + computeClass from the canonical pricing table (single source)', () => { + const scan = buildEvaluationKit('legitimacy_scan'); + expect(scan.priceUsd).toBe(0.25); + expect(scan.computeClass).toBe('LIVE_ALLOWED'); + + const facts = buildEvaluationKit('quick_protocol_facts'); + expect(facts.computeClass).toBe('CACHE_ONLY'); + }); + + it('priceUsd is null for an unpriced offering (flagged, not invented)', () => { + const feed = buildEvaluationKit('scam_alert_feed'); + expect(feed.priceUsd).toBeNull(); + }); + + it('every branded serviceName/tag/iconUrl passes validation as authored (no drops in the real data)', () => { + for (const kit of buildAllEvaluationKits()) { + expect(kit.dropped).toEqual([]); + expect(kit.serviceName).not.toBeNull(); + expect(kit.iconUrl).not.toBeNull(); + } + }); + + it('soft-drops a non-ASCII serviceName instead of throwing', () => { + const original = EVALUATION_KIT_BRANDING.legitimacy_scan.serviceName; + // @ts-expect-error — deliberately mutate the branding table to simulate a bad field for the test + EVALUATION_KIT_BRANDING.legitimacy_scan.serviceName = 'Légitimacy Scan'; + try { + const kit = buildEvaluationKit('legitimacy_scan'); + expect(kit.serviceName).toBeNull(); + expect(kit.dropped).toContainEqual({ field: 'serviceName', reason: 'not printable ASCII' }); + // the entry still ships — soft-drop, not a thrown error or a missing listing. + expect(kit.discoverable).toBe(true); + } finally { + // @ts-expect-error — restore + EVALUATION_KIT_BRANDING.legitimacy_scan.serviceName = original; + } + }); + + it('soft-drops a non-https iconUrl', () => { + const original = EVALUATION_KIT_BRANDING.legitimacy_scan.iconUrl; + // @ts-expect-error — deliberate bad value for the test + EVALUATION_KIT_BRANDING.legitimacy_scan.iconUrl = 'http://whitepapergrey.com/icons/x.svg'; + try { + const kit = buildEvaluationKit('legitimacy_scan'); + expect(kit.iconUrl).toBeNull(); + expect(kit.dropped.some((d) => d.field === 'iconUrl')).toBe(true); + } finally { + // @ts-expect-error — restore + EVALUATION_KIT_BRANDING.legitimacy_scan.iconUrl = original; + } + }); + + it('soft-drops an IP-literal or loopback iconUrl', () => { + for (const bad of [ + 'https://127.0.0.1/icon.svg', + 'https://localhost/icon.svg', + 'https://192.168.1.5/icon.svg', + ]) { + const original = EVALUATION_KIT_BRANDING.legitimacy_scan.iconUrl; + // @ts-expect-error — deliberate bad value for the test + EVALUATION_KIT_BRANDING.legitimacy_scan.iconUrl = bad; + try { + const kit = buildEvaluationKit('legitimacy_scan'); + expect(kit.iconUrl, bad).toBeNull(); + } finally { + // @ts-expect-error — restore + EVALUATION_KIT_BRANDING.legitimacy_scan.iconUrl = original; + } + } + }); + + it('soft-drops one bad tag but keeps the rest', () => { + const original = EVALUATION_KIT_BRANDING.legitimacy_scan.tags; + // @ts-expect-error — deliberate bad value for the test + EVALUATION_KIT_BRANDING.legitimacy_scan.tags = ['crypto', 'tïer1', 'verification']; + try { + const kit = buildEvaluationKit('legitimacy_scan'); + expect(kit.tags).toEqual(['crypto', 'verification']); + expect(kit.dropped.some((d) => d.field.startsWith('tags['))).toBe(true); + } finally { + // @ts-expect-error — restore + EVALUATION_KIT_BRANDING.legitimacy_scan.tags = original; + } + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index ad8ceec..a69f93e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -31,6 +31,7 @@ export default defineConfig({ { find: /^@grey\/schemas\/envelope$/, replacement: src('./packages/grey-schemas/src/envelope/index.ts') }, { find: /^@grey\/schemas\/validators$/, replacement: src('./packages/grey-schemas/src/validators/index.ts') }, { find: /^@grey\/schemas\/pricing$/, replacement: src('./packages/grey-schemas/src/pricing/index.ts') }, + { find: /^@grey\/schemas\/evaluationKit$/, replacement: src('./packages/grey-schemas/src/evaluationKit/index.ts') }, { find: /^@grey\/pipeline$/, replacement: src('./packages/grey-pipeline/src/index.ts') }, { find: /^@grey\/x402-middleware$/, replacement: src('./adapters/x402-middleware/src/index.ts') }, ], From f91edfab2af1faa90cf6934ff5b4b06c59364021 Mon Sep 17 00:00:00 2001 From: Mayakovsky Date: Thu, 30 Jul 2026 15:34:32 -0400 Subject: [PATCH 2/5] feat(e1-round2): evaluation artifacts + trust rung, built-not-exposed (sub-unit 2) Evaluation artifacts (ship live): buildEvaluationArtifact() extends the EvaluationKit projection with a schema-valid sample request/response pair per offering (validated against the SAME ajv instances the live routes use, not hand-eyeballed). GET /v1/discovery/services/:slug now returns the full artifact incl. sample; the list route stays lean. Trust rung (BUILT BUT BLOCKED, Forces ruling B-1, Invariant #34): new legitimacy_scan_trust_rung offering, $0.10 CACHE_ONLY, full schema/ codegen/validator/handler citizenship, classified in the canonical pricing table -- but reachable on NO live channel by default: - Deliberately isolated from prices.ts's PAID_SLUG_ORDER/PAID_SLUGS/ PRICE_TABLE (the well-tested 7-slug path stays untouched) -- all trust-rung pricing/challenge/settlement logic lives in a new, self-contained x402-middleware/trustRung.ts, gated by a single-source trustRungEnabled() (explicit 'true' only, mirrors Bion directive-20's autoModeSetting() precedent). - grey-core mounts its route (POST /v1/offerings/legitimacy_scan_trust_rung) and lists it in discovery ONLY when the flag is on; off (default), the route doesn't exist (404, not a gated 200) and it's absent from both discovery endpoints. - offeringHandlers keeps the handler registered unconditionally (harmless in isolation -- Invariant #30's compile-time half already makes it uncallable into cacheOrLive); route mounting and discovery listing are the two places that actually gate reachability, both flag-checked explicitly. - ACP adapter is untouched -- the trust rung was never wired there, which trivially satisfies "unreachable on every channel" for ACP too. Tests assert unreachability by default (route 404s, absent from both discovery endpoints) AND correct behavior when explicitly forced on in tests (402 with the right price, listed, buildServer fails closed if enabled without a preHandler) -- proving the block is a real gate, not dead code. EXPANSION-E1-ROUND2-KOV-directive.md sub-unit 2 (was E1-C). --- adapters/x402-middleware/src/index.ts | 8 + adapters/x402-middleware/src/trustRung.ts | 149 +++++++++++++++++ .../x402-middleware/test/trustRung.test.ts | 50 ++++++ .../grey-core/src/channels/x402Adapter.ts | 15 +- packages/grey-core/src/handlers/index.ts | 9 +- .../handlers/legitimacy_scan_trust_rung.ts | 27 +++ .../grey-core/src/orchestration/cacheRead.ts | 29 ++++ packages/grey-core/src/server/index.ts | 25 ++- .../grey-core/src/server/routes/discovery.ts | 29 ++-- .../grey-core/src/server/routes/trustRung.ts | 51 ++++++ packages/grey-core/src/start.ts | 15 ++ packages/grey-core/test/_helpers.ts | 3 +- packages/grey-core/test/discovery.test.ts | 12 +- packages/grey-core/test/trustRung.test.ts | 87 ++++++++++ packages/grey-schemas/scripts/codegen.ts | 2 + .../grey-schemas/src/evaluationKit/build.ts | 17 +- .../grey-schemas/src/evaluationKit/data.ts | 7 + .../grey-schemas/src/evaluationKit/index.ts | 3 +- .../grey-schemas/src/evaluationKit/samples.ts | 154 ++++++++++++++++++ .../v1/LegitimacyScanTrustRungResponse.d.ts | 16 ++ .../LegitimacyScanTrustRungRequest.d.ts | 13 ++ packages/grey-schemas/src/pricing/table.ts | 9 + packages/grey-schemas/src/requests/index.ts | 1 + packages/grey-schemas/src/requests/types.ts | 11 +- .../v1/legitimacy_scan_trust_rung.schema.json | 13 ++ packages/grey-schemas/src/responses/index.ts | 1 + packages/grey-schemas/src/responses/types.ts | 12 +- .../v1/legitimacy_scan_trust_rung.schema.json | 19 +++ packages/grey-schemas/src/validators/index.ts | 10 +- .../test/evaluationKit.samples.test.ts | 47 ++++++ .../grey-schemas/test/evaluationKit.test.ts | 4 +- .../legitimacy_scan_trust_rung/invalid.json | 1 + .../legitimacy_scan_trust_rung/valid.json | 1 + packages/grey-schemas/test/pricing.test.ts | 11 +- .../test/request-field-drift.test.ts | 12 +- 35 files changed, 844 insertions(+), 29 deletions(-) create mode 100644 adapters/x402-middleware/src/trustRung.ts create mode 100644 adapters/x402-middleware/test/trustRung.test.ts create mode 100644 packages/grey-core/src/handlers/legitimacy_scan_trust_rung.ts create mode 100644 packages/grey-core/src/server/routes/trustRung.ts create mode 100644 packages/grey-core/test/trustRung.test.ts create mode 100644 packages/grey-schemas/src/evaluationKit/samples.ts create mode 100644 packages/grey-schemas/src/generated/v1/LegitimacyScanTrustRungResponse.d.ts create mode 100644 packages/grey-schemas/src/generated/v1/requests/LegitimacyScanTrustRungRequest.d.ts create mode 100644 packages/grey-schemas/src/requests/v1/legitimacy_scan_trust_rung.schema.json create mode 100644 packages/grey-schemas/src/responses/v1/legitimacy_scan_trust_rung.schema.json create mode 100644 packages/grey-schemas/test/evaluationKit.samples.test.ts create mode 100644 packages/grey-schemas/test/fixtures/requests/legitimacy_scan_trust_rung/invalid.json create mode 100644 packages/grey-schemas/test/fixtures/requests/legitimacy_scan_trust_rung/valid.json diff --git a/adapters/x402-middleware/src/index.ts b/adapters/x402-middleware/src/index.ts index 50116fe..2314ea6 100644 --- a/adapters/x402-middleware/src/index.ts +++ b/adapters/x402-middleware/src/index.ts @@ -15,6 +15,14 @@ export { } from './prices.js'; export type { PaidSlug } from './prices.js'; export { buildPaymentRequirements } from './challenge.js'; +export { + TRUST_RUNG_SLUG, + trustRungEnabled, + trustRungPriceAtomic, + trustRungPriceUsd, + buildTrustRungPaymentRequirements, + makeTrustRungPreHandler, +} from './trustRung.js'; export { decodePaymentHeader, verifyPayment } from './verify.js'; export type { VerifyResult } from './verify.js'; export { settle } from './settle.js'; diff --git a/adapters/x402-middleware/src/trustRung.ts b/adapters/x402-middleware/src/trustRung.ts new file mode 100644 index 0000000..d6bea70 --- /dev/null +++ b/adapters/x402-middleware/src/trustRung.ts @@ -0,0 +1,149 @@ +// The $0.10 legitimacy_scan_trust_rung offering (E1-C, spec §2.4) — BUILT BUT BLOCKED. Forces +// ruling B-1 (2026-07-26): no live exposure on any channel until Forces explicitly lifts this. +// Invariant #34: any offering under a standing block ships behind a default-off disable flag with +// tests asserting unreachability on every live channel. Single source (mirrors Bion directive-20's +// autoModeSetting() precedent): every caller reads `trustRungEnabled()`, nobody re-parses the env +// var. Deliberately kept OUT of prices.ts's PAID_SLUG_ORDER/PAID_SLUGS/PRICE_TABLE — those stay +// byte-identical to the 7 normal paid slugs; this file is fully isolated so the block can never be +// lifted by an accidental edit to the well-tested normal pricing path. +import type { FastifyReply, FastifyRequest, preHandlerHookHandler } from 'fastify'; +import type { OfferingSlug } from '@grey/schemas/responses'; +import { resolvePriceUsd } from '@grey/schemas/pricing'; +import { buildEvaluationKit } from '@grey/schemas/evaluationKit'; +import type { X402Config, PaymentRequirements } from './types.js'; +import type { X402PreHandlerDeps } from './preHandler.js'; +import { decodePaymentHeader, verifyPayment } from './verify.js'; +import { settle } from './settle.js'; + +export const TRUST_RUNG_SLUG: OfferingSlug = 'legitimacy_scan_trust_rung'; + +/** Explicit opt-in only, never a default-on fallback. Unset/anything-but-'true' → disabled. */ +export function trustRungEnabled(): boolean { + return process.env.TRUST_RUNG_ENABLED === 'true'; +} + +function toAtomic(usd: number): bigint { + return BigInt(Math.round(usd * 1_000_000)); +} + +/** USDC atomic units for the trust rung on the x402 channel. Resolves regardless of the disable + * flag's state — the flag gates ROUTE REACHABILITY, not this pure price calculation. */ +export function trustRungPriceAtomic(): bigint { + return toAtomic(resolvePriceUsd(TRUST_RUNG_SLUG, 'x402')); +} + +export function trustRungPriceUsd(): number { + return resolvePriceUsd(TRUST_RUNG_SLUG, 'x402'); +} + +/** Same shape as challenge.ts's buildPaymentRequirements, scoped to the trust rung. Only ever + * called from a route that itself only exists when `trustRungEnabled()` is true (grey-core). */ +export function buildTrustRungPaymentRequirements( + cfg: X402Config, + resource: string, + error?: string, +): PaymentRequirements { + const kit = buildEvaluationKit(TRUST_RUNG_SLUG); + const body: PaymentRequirements = { + x402Version: 1, + accepts: [ + { + scheme: 'exact', + network: cfg.network, + maxAmountRequired: trustRungPriceAtomic().toString(), + resource, + description: `Grey ${TRUST_RUNG_SLUG} offering`, + mimeType: 'application/json', + payTo: cfg.payTo, + maxTimeoutSeconds: cfg.maxTimeoutSeconds, + asset: cfg.usdc.address, + extra: { + name: cfg.usdc.name, + version: cfg.usdc.version, + bazaar: { + discoverable: kit.discoverable, + serviceName: kit.serviceName, + tags: kit.tags, + description: kit.description, + inputSchema: kit.inputSchema, + outputSchema: kit.outputSchema, + iconUrl: kit.iconUrl, + }, + }, + }, + ], + }; + if (error) body.error = error; + return body; +} + +function encodePaymentResponse(txHash: string, network: string): string { + return Buffer.from( + JSON.stringify({ success: true, transaction: txHash, network }), + 'utf8', + ).toString('base64'); +} + +/** + * A SEPARATE preHandler, not a variant dispatched by the normal `makeX402PreHandler` + * (preHandler.ts's `slugFromUrl` deliberately does not recognize this slug, since it checks + * against `isPaidSlug` / the normal 7-slug PRICE_TABLE). Only ever installed on the trust-rung + * route, which itself is only mounted when `trustRungEnabled()` is true — so this function being + * unreachable is a property of grey-core's route wiring, not of this function checking the flag + * itself. Otherwise byte-identical verify→settle logic to the normal gate (same reused functions). + */ +export function makeTrustRungPreHandler( + cfg: X402Config, + deps: X402PreHandlerDeps, +): preHandlerHookHandler { + return async function trustRungPreHandler( + req: FastifyRequest, + reply: FastifyReply, + ): Promise { + const resource = req.url; + const header = req.headers['x-payment']; + if (typeof header !== 'string' || header.length === 0) { + reply.code(402).send(buildTrustRungPaymentRequirements(cfg, resource, 'payment required')); + return; + } + + const decoded = decodePaymentHeader(header); + if (!decoded.ok) { + reply.code(402).send(buildTrustRungPaymentRequirements(cfg, resource, decoded.reason)); + return; + } + + const nowSec = BigInt(Math.floor((deps.now?.() ?? Date.now()) / 1000)); + const verdict = await verifyPayment( + cfg, + decoded.payload, + trustRungPriceAtomic(), + deps.publicClient, + nowSec, + ); + if (!verdict.ok) { + reply.code(402).send(buildTrustRungPaymentRequirements(cfg, resource, verdict.reason)); + return; + } + + let outcome; + try { + outcome = await settle(cfg, verdict.authorization, verdict.signature, { + wallet: deps.wallet, + publicClient: deps.publicClient, + }); + } catch (err) { + deps.logger?.error('x402: trust-rung settlement infra error', { + reason: err instanceof Error ? err.message : String(err), + }); + reply.code(502).send({ x402Version: 1, error: 'settlement failed' }); + return; + } + if (!outcome.ok) { + reply.code(402).send(buildTrustRungPaymentRequirements(cfg, resource, outcome.reason)); + return; + } + + reply.header('X-PAYMENT-RESPONSE', encodePaymentResponse(outcome.txHash, cfg.network)); + }; +} diff --git a/adapters/x402-middleware/test/trustRung.test.ts b/adapters/x402-middleware/test/trustRung.test.ts new file mode 100644 index 0000000..9d96a51 --- /dev/null +++ b/adapters/x402-middleware/test/trustRung.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { + TRUST_RUNG_SLUG, + trustRungEnabled, + trustRungPriceAtomic, + trustRungPriceUsd, + buildTrustRungPaymentRequirements, +} from '../src/trustRung.js'; +import { TEST_CFG } from './_sign.js'; + +const ORIGINAL = process.env.TRUST_RUNG_ENABLED; +afterEach(() => { + if (ORIGINAL === undefined) delete process.env.TRUST_RUNG_ENABLED; + else process.env.TRUST_RUNG_ENABLED = ORIGINAL; +}); + +describe('trustRung — E1-C disable flag (Forces ruling B-1, Invariant #34)', () => { + it('defaults to disabled when the env var is fully unset', () => { + delete process.env.TRUST_RUNG_ENABLED; + expect(trustRungEnabled()).toBe(false); + }); + + it('defaults to disabled for any value other than the literal string "true"', () => { + for (const v of ['1', 'yes', 'on', 'True', 'TRUE', '']) { + process.env.TRUST_RUNG_ENABLED = v; + expect(trustRungEnabled(), v).toBe(false); + } + }); + + it('is enabled only by the explicit literal "true"', () => { + process.env.TRUST_RUNG_ENABLED = 'true'; + expect(trustRungEnabled()).toBe(true); + }); + + it('resolves the canonical $0.10 price regardless of the flag state', () => { + delete process.env.TRUST_RUNG_ENABLED; + expect(trustRungPriceUsd()).toBe(0.1); + expect(trustRungPriceAtomic()).toBe(100_000n); + process.env.TRUST_RUNG_ENABLED = 'true'; + expect(trustRungPriceUsd()).toBe(0.1); + expect(trustRungPriceAtomic()).toBe(100_000n); + }); + + it('buildTrustRungPaymentRequirements carries the trust-rung slug, price, and Bazaar metadata', () => { + const body = buildTrustRungPaymentRequirements(TEST_CFG, `/v1/offerings/${TRUST_RUNG_SLUG}`); + expect(body.accepts[0].maxAmountRequired).toBe('100000'); + expect(body.accepts[0].description).toContain(TRUST_RUNG_SLUG); + expect(body.accepts[0].extra.bazaar.serviceName).toBe('Legitimacy Trust Rung'); + }); +}); diff --git a/packages/grey-core/src/channels/x402Adapter.ts b/packages/grey-core/src/channels/x402Adapter.ts index 32f498a..ebb86f1 100644 --- a/packages/grey-core/src/channels/x402Adapter.ts +++ b/packages/grey-core/src/channels/x402Adapter.ts @@ -19,6 +19,12 @@ export interface X402AdapterOptions { host?: string; /** Informational only: the relayer ADDRESS (never the key) for the boot log line. */ relayerAddress?: string; + /** E1-C, Invariant #34: default OFF. start.ts is the one boot boundary that reads + * @grey/x402-middleware's trustRungEnabled() and passes the result through here. */ + trustRungEnabled?: boolean; + /** Required when trustRungEnabled is true — @grey/x402-middleware's makeTrustRungPreHandler(...) + * output. NOT the same as `gate`: different slug/price, so a different verify/settle path. */ + trustRungPreHandler?: preHandlerHookHandler; } /** @@ -33,6 +39,8 @@ export class X402Adapter implements ChannelIngress { private readonly port: number; private readonly host: string; private readonly relayerAddress?: string; + private readonly trustRungEnabled: boolean; + private readonly trustRungPreHandler?: preHandlerHookHandler; private readonly offerings: OfferingRegistration[] = []; private app: FastifyInstance | null = null; private boundAddress: string | null = null; @@ -43,12 +51,17 @@ export class X402Adapter implements ChannelIngress { this.port = opts.port; this.host = opts.host ?? '0.0.0.0'; this.relayerAddress = opts.relayerAddress; + this.trustRungEnabled = opts.trustRungEnabled ?? false; + this.trustRungPreHandler = opts.trustRungPreHandler; } async start(): Promise { if (this.app) throw new Error('X402Adapter: already started'); // The SAME call start.ts made inline — the seam adds no per-request code. - const app = buildServer(this.deps, this.gate); + const app = buildServer(this.deps, this.gate, { + trustRungEnabled: this.trustRungEnabled, + trustRungPreHandler: this.trustRungPreHandler, + }); this.app = app; this.boundAddress = await app.listen({ port: this.port, host: this.host }); this.deps.logger.info( diff --git a/packages/grey-core/src/handlers/index.ts b/packages/grey-core/src/handlers/index.ts index caf7f84..62a365c 100644 --- a/packages/grey-core/src/handlers/index.ts +++ b/packages/grey-core/src/handlers/index.ts @@ -1,7 +1,13 @@ -// @grey/core handler registry — OfferingSlug → OfferingHandler. All 9 are cache-read-only (M3). +// @grey/core handler registry — OfferingSlug → OfferingHandler. All 10 are cache-read-only at +// this layer (M3); 4 delegate to cacheOrLive on a cache miss (M3.5). `legitimacy_scan_trust_rung` +// (E1-C) is here because the HANDLER is harmless to have registered — it never reaches live +// compute either way — but it must NOT be reachable on any route/channel unless +// @grey/x402-middleware's trustRungEnabled() is true. Route mounting (grey-core/src/server) and +// discovery listing both check that flag explicitly; this registry does not gate anything itself. import type { OfferingSlug } from '@grey/schemas/responses'; import type { OfferingHandler } from './types'; import { legitimacyScan } from './legitimacy_scan'; +import { legitimacyScanTrustRung } from './legitimacy_scan_trust_rung'; import { verifyWhitepaper } from './verify_whitepaper'; import { verifyFullTech } from './verify_full_tech'; import { claimExtraction } from './claim_extraction'; @@ -13,6 +19,7 @@ import { scamAlertFeed } from './scam_alert_feed'; export const offeringHandlers: Record = { legitimacy_scan: legitimacyScan, + legitimacy_scan_trust_rung: legitimacyScanTrustRung, verify_whitepaper: verifyWhitepaper, verify_full_tech: verifyFullTech, claim_extraction: claimExtraction, diff --git a/packages/grey-core/src/handlers/legitimacy_scan_trust_rung.ts b/packages/grey-core/src/handlers/legitimacy_scan_trust_rung.ts new file mode 100644 index 0000000..85b05a7 --- /dev/null +++ b/packages/grey-core/src/handlers/legitimacy_scan_trust_rung.ts @@ -0,0 +1,27 @@ +// legitimacy_scan_trust_rung (E1-C, $0.10 CACHE_ONLY — BUILT BUT BLOCKED per Forces ruling B-1). +// Cache-read only, structurally: this offering is not in ComputeOfferingSlug, so it is not +// callable into cacheOrLive at the type level (Invariant #30's compile-time half); there is no +// live branch here to accidentally take, unlike legitimacy_scan.ts's cache-miss → cacheOrLive +// call. A cache miss returns the flat NOT_IN_DATABASE-style sentinel, same as every other +// CACHE_ONLY offering — it never "retries live", paid or not. +import type { OfferingHandler } from './types'; +import { resolveWhitepaper, subjectFrom } from './subjectMapping'; +import { buildTrustRungHit, buildTrustRungMiss } from '../orchestration/cacheRead'; + +export const legitimacyScanTrustRung: OfferingHandler = async (input, deps) => { + const body = (input.requirement ?? {}) as { token_address?: string; project_name?: string }; + const wp = await resolveWhitepaper(deps.whitepapers, { + tokenAddress: body.token_address, + projectName: body.project_name, + }); + const v = wp ? await deps.verifications.findByWhitepaperId(wp.id) : null; + const fallback = { tokenAddress: body.token_address ?? null, projectName: body.project_name }; + if (!wp || !v) { + return { + payload: buildTrustRungMiss(deps, fallback), + subject: subjectFrom(null, fallback), + cacheHit: false, + }; + } + return { payload: buildTrustRungHit(wp, v), subject: subjectFrom(wp, fallback), cacheHit: true }; +}; diff --git a/packages/grey-core/src/orchestration/cacheRead.ts b/packages/grey-core/src/orchestration/cacheRead.ts index a9b99c7..b2a706e 100644 --- a/packages/grey-core/src/orchestration/cacheRead.ts +++ b/packages/grey-core/src/orchestration/cacheRead.ts @@ -94,6 +94,35 @@ export function buildLegitimacyMiss( }; } +// ── tier 0: legitimacy_scan_trust_rung (E1-C, $0.10 CACHE_ONLY — BUILT BUT BLOCKED) ── +// A cheap teaser of the tier-1 verdict, read from the SAME cache row legitimacy_scan reads — +// never a live-compute path (this offering is not in ComputeOfferingSlug; cacheOrLive cannot even +// be called with it). Reduced field set on purpose: it's meant to make the $0.25 offering's value +// self-evident, not substitute for it. + +export function buildTrustRungHit(wp: WhitepaperRow, v: VerificationRow): Record { + return { + projectName: wp.projectName, + tokenAddress: wp.tokenAddress, + verdict: v.verdict ?? 'INSUFFICIENT_DATA', + generatedAt: iso(v.verifiedAt), + note: 'Cache-only teaser — see legitimacy_scan for the full structural read.', + }; +} + +export function buildTrustRungMiss( + deps: HandlerDeps, + fallback: { tokenAddress?: string | null; projectName?: string }, +): Record { + return { + projectName: fallback.projectName ?? 'Unknown', + tokenAddress: fallback.tokenAddress ?? null, + verdict: 'NOT_IN_DATABASE', + generatedAt: iso(deps.clock()), + note: 'Project not found in the Grey verification cache.', + }; +} + // ── tier 2: verify_whitepaper (legitimacy + claims) ── export function buildVerifyWhitepaperHit( diff --git a/packages/grey-core/src/server/index.ts b/packages/grey-core/src/server/index.ts index cc7cd5f..91300f2 100644 --- a/packages/grey-core/src/server/index.ts +++ b/packages/grey-core/src/server/index.ts @@ -10,13 +10,34 @@ import { registerProbes } from './routes/probes'; import { registerOfferingRoutes } from './routes/offerings'; import { registerResourceRoutes } from './routes/resources'; import { registerDiscoveryRoutes } from './routes/discovery'; +import { registerTrustRungRoute } from './routes/trustRung'; -export function buildServer(deps: HandlerDeps, x402PreHandler: preHandlerHookHandler): FastifyInstance { +export interface BuildServerOptions { + /** E1-C, Invariant #34: default OFF. Only start.ts (reading @grey/x402-middleware's + * trustRungEnabled()) and trust-rung-specific tests should ever pass `true`. */ + trustRungEnabled?: boolean; + /** Required when trustRungEnabled is true — @grey/x402-middleware's + * makeTrustRungPreHandler(...) output. NOT the general x402PreHandler (different slug/price). */ + trustRungPreHandler?: preHandlerHookHandler; +} + +export function buildServer( + deps: HandlerDeps, + x402PreHandler: preHandlerHookHandler, + opts: BuildServerOptions = {}, +): FastifyInstance { + const trustRungEnabled = opts.trustRungEnabled ?? false; const app = Fastify({ logger: false }); installValidatorCompiler(app); registerProbes(app, deps); registerOfferingRoutes(app, deps, x402PreHandler); // paid POST × 7, behind the x402 gate registerResourceRoutes(app, deps); // free GET × 2 - registerDiscoveryRoutes(app); // E1-B: free Bazaar discovery index, GET × 2 + registerDiscoveryRoutes(app, { trustRungEnabled }); // E1-B: free Bazaar discovery index, GET × 2 + if (trustRungEnabled) { + if (!opts.trustRungPreHandler) { + throw new Error('buildServer: trustRungEnabled requires opts.trustRungPreHandler'); + } + registerTrustRungRoute(app, deps, opts.trustRungPreHandler); // E1-C, default off + } return app; } diff --git a/packages/grey-core/src/server/routes/discovery.ts b/packages/grey-core/src/server/routes/discovery.ts index 690dbf7..113d665 100644 --- a/packages/grey-core/src/server/routes/discovery.ts +++ b/packages/grey-core/src/server/routes/discovery.ts @@ -5,19 +5,26 @@ // public capability page E1-C's evaluation artifacts extend with a sample). import type { FastifyInstance } from 'fastify'; import type { OfferingSlug } from '@grey/schemas/responses'; -import { buildEvaluationKit } from '@grey/schemas/evaluationKit'; +import { buildEvaluationKit, buildEvaluationArtifact } from '@grey/schemas/evaluationKit'; +import { TRUST_RUNG_SLUG } from '@grey/x402-middleware'; import { offeringHandlers } from '../../handlers'; -/** Registry-driven: only offerings actually present in `offeringHandlers` are listed, so a - * disable-flagged offering (E1-C's trust rung) that isn't registered there is structurally - * absent from discovery too — not a separate flag to keep in sync. */ -function listableSlugs(): OfferingSlug[] { - return Object.keys(offeringHandlers) as OfferingSlug[]; +export interface DiscoveryRouteOptions { + /** E1-C, Invariant #34: the trust rung is registered in `offeringHandlers` unconditionally (the + * handler itself is harmless), but must not be LISTED unless Forces' disable flag is on — + * explicit here, not inferred from registry membership alone. */ + trustRungEnabled: boolean; } -export function registerDiscoveryRoutes(app: FastifyInstance): void { +function listableSlugs(opts: DiscoveryRouteOptions): OfferingSlug[] { + const all = Object.keys(offeringHandlers) as OfferingSlug[]; + if (opts.trustRungEnabled) return all; + return all.filter((slug) => slug !== TRUST_RUNG_SLUG); +} + +export function registerDiscoveryRoutes(app: FastifyInstance, opts: DiscoveryRouteOptions): void { app.get('/v1/discovery/services', async (_req, reply) => { - const services = listableSlugs() + const services = listableSlugs(opts) .map((slug) => buildEvaluationKit(slug)) .filter((kit) => kit.discoverable); reply.send({ services }); @@ -25,10 +32,12 @@ export function registerDiscoveryRoutes(app: FastifyInstance): void { app.get<{ Params: { slug: string } }>('/v1/discovery/services/:slug', async (req, reply) => { const slug = req.params.slug; - if (!listableSlugs().includes(slug as OfferingSlug)) { + if (!listableSlugs(opts).includes(slug as OfferingSlug)) { reply.code(404).send({ error: `not found or not discoverable: ${slug}` }); return; } - reply.send(buildEvaluationKit(slug as OfferingSlug)); + // E1-C: the detail/capability page carries the evaluation artifact (adds a sample); the list + // route above stays lean (no sample) — this is the only difference between the two. + reply.send(buildEvaluationArtifact(slug as OfferingSlug)); }); } diff --git a/packages/grey-core/src/server/routes/trustRung.ts b/packages/grey-core/src/server/routes/trustRung.ts new file mode 100644 index 0000000..7a773f1 --- /dev/null +++ b/packages/grey-core/src/server/routes/trustRung.ts @@ -0,0 +1,51 @@ +// POST /v1/offerings/legitimacy_scan_trust_rung (E1-C) — mounted ONLY when +// @grey/x402-middleware's trustRungEnabled() is true. Deliberately a SEPARATE route registrar +// from registerOfferingRoutes (offerings.ts): the normal 7 paid routes must stay byte-identical +// and unconditional, so this file is the entire blast radius of the disable flag on the x402 +// channel. When the caller doesn't invoke `registerTrustRungRoute`, the route simply does not +// exist — a request to this path 404s before Fastify has any handler to dispatch to (Invariant +// #34: unreachable, not merely gated inside a reachable handler). +import { randomUUID } from 'node:crypto'; +import type { FastifyInstance, preHandlerHookHandler } from 'fastify'; +import { TRUST_RUNG_SLUG, trustRungPriceUsd } from '@grey/x402-middleware'; +import type { HandlerDeps } from '../../deps'; +import { offeringHandlers } from '../../handlers'; +import { buildEnvelope } from '../../envelope/build'; + +export function registerTrustRungRoute( + app: FastifyInstance, + deps: HandlerDeps, + // NOT the general x402PreHandler — preHandler.ts's slugFromUrl deliberately doesn't recognize + // this slug, so this MUST be @grey/x402-middleware's makeTrustRungPreHandler(...) output. + trustRungPreHandler: preHandlerHookHandler, +): void { + app.post( + `/v1/offerings/${TRUST_RUNG_SLUG}`, + { + schema: { body: { $grey: { kind: 'request', offering: TRUST_RUNG_SLUG } } }, + preHandler: trustRungPreHandler, + }, + async (req, reply) => { + const start = deps.clock().getTime(); + const result = await offeringHandlers[TRUST_RUNG_SLUG]( + { offeringId: TRUST_RUNG_SLUG, requirement: req.body }, + deps, + ); + const env = buildEnvelope({ + offering: TRUST_RUNG_SLUG, + payload: result.payload as never, + requestId: randomUUID(), + config: deps.config, + subject: result.subject, + metadata: { + costUsd: trustRungPriceUsd(), + model: 'none', + latencyMs: deps.clock().getTime() - start, + timestamp: deps.clock().toISOString(), + cacheHit: result.cacheHit, + }, + }); + reply.send(env); + }, + ); +} diff --git a/packages/grey-core/src/start.ts b/packages/grey-core/src/start.ts index 260f36b..3eca8a4 100644 --- a/packages/grey-core/src/start.ts +++ b/packages/grey-core/src/start.ts @@ -8,6 +8,8 @@ import { makeX402PreHandler, priceUsdFor, PAID_SLUGS, + trustRungEnabled, + makeTrustRungPreHandler, } from '@grey/x402-middleware'; import { createHandlerDeps } from './deps'; import { X402Adapter } from './channels/x402Adapter'; @@ -24,6 +26,17 @@ const x402PreHandler = makeX402PreHandler(x402Config, { logger: deps.logger, }); +// E1-C, Invariant #34: the ONE place trustRungEnabled() is read for the x402 channel — start.ts is +// the boot boundary, same posture as x402Config/relayer above. Default off; Forces-gated to flip. +const trustRungOn = trustRungEnabled(); +const trustRungPreHandler = trustRungOn + ? makeTrustRungPreHandler(x402Config, { + wallet: relayer.wallet, + publicClient: relayer.publicClient, + logger: deps.logger, + }) + : undefined; + // M6 Phase A: x402 now boots THROUGH the ChannelIngress seam. The adapter runs the SAME // buildServer(deps, gate) + listen path this file used inline — zero per-request change. const port = Number(process.env.GREY_CORE_PORT ?? 3002); @@ -32,6 +45,8 @@ const adapter = new X402Adapter({ gate: x402PreHandler, port, relayerAddress: relayer.relayerAddress, + trustRungEnabled: trustRungOn, + trustRungPreHandler, }); // FDQ-66(a) boot-wrapper: record the catalog for identity()/observability. Routes stay statically diff --git a/packages/grey-core/test/_helpers.ts b/packages/grey-core/test/_helpers.ts index 777a9d1..e63cd72 100644 --- a/packages/grey-core/test/_helpers.ts +++ b/packages/grey-core/test/_helpers.ts @@ -134,8 +134,9 @@ export function fakeDeps(stubs: RepoStubs = {}): HandlerDeps { export function makeApp( stubs: RepoStubs = {}, gate: preHandlerHookHandler = passThroughX402, + opts: Parameters[2] = {}, ): FastifyInstance { - return buildServer(fakeDeps(stubs), gate); + return buildServer(fakeDeps(stubs), gate, opts); } /** Loose envelope shape for reading inject() response bodies in tests. */ diff --git a/packages/grey-core/test/discovery.test.ts b/packages/grey-core/test/discovery.test.ts index b494c64..37af2b1 100644 --- a/packages/grey-core/test/discovery.test.ts +++ b/packages/grey-core/test/discovery.test.ts @@ -13,7 +13,7 @@ describe('discovery routes — Bazaar index (E1-B, Invariant #33)', () => { expect(body.services.map((s) => s.slug)).toContain('legitimacy_scan'); }); - it('GET /v1/discovery/services/:slug returns one EvaluationKit entry', async () => { + it('GET /v1/discovery/services/:slug returns the full evaluation artifact, incl. a sample (E1-C)', async () => { const app = makeApp(); const res = await app.inject({ method: 'GET', @@ -26,6 +26,16 @@ describe('discovery routes — Bazaar index (E1-B, Invariant #33)', () => { expect(body.computeClass).toBe('LIVE_ALLOWED'); expect(body.inputSchema).toBeTruthy(); expect(body.outputSchema).toBeTruthy(); + expect(body.sample).toBeTruthy(); + expect(body.sample.request).toBeTruthy(); + expect(body.sample.response).toBeTruthy(); + }); + + it('the list route stays lean — no sample attached', async () => { + const app = makeApp(); + const res = await app.inject({ method: 'GET', url: '/v1/discovery/services' }); + const body = res.json() as { services: Array<{ sample?: unknown }> }; + expect(body.services.every((s) => s.sample === undefined)).toBe(true); }); it('GET /v1/discovery/services/:slug 404s for an unknown or unregistered slug', async () => { diff --git a/packages/grey-core/test/trustRung.test.ts b/packages/grey-core/test/trustRung.test.ts new file mode 100644 index 0000000..6fde9c9 --- /dev/null +++ b/packages/grey-core/test/trustRung.test.ts @@ -0,0 +1,87 @@ +// E1-C: the $0.10 trust rung is BUILT BUT BLOCKED (Forces ruling B-1, Invariant #34) — these tests +// assert it's actually unreachable by default, on both surfaces this channel exposes it through +// (the offering route itself, and the discovery/capability listing), and that flipping the +// explicit opt-in makes it correctly reachable (proving the block is a real gate, not dead code). +import { describe, it, expect } from 'vitest'; +import { makeTrustRungPreHandler, loadX402Config } from '@grey/x402-middleware'; +import { makeApp, passThroughX402 } from './_helpers'; + +const cfg = loadX402Config({ + X402_NETWORK: 'eip155:84532', + BASE_X402_PAY_TO: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + BASE_RPC_URL: 'http://127.0.0.1:8545', + X402_RELAYER_PRIVATE_KEY: '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d', +}); +const trustRungGate = makeTrustRungPreHandler(cfg, { + wallet: { writeContract: async () => ('0x' + 'ee'.repeat(32)) as `0x${string}` }, + publicClient: { + readContract: async () => false, + simulateContract: async () => ({ request: {} }), + waitForTransactionReceipt: async () => ({ status: 'success' as const }), + }, +}); + +describe('trust rung — unreachable by default (E1-C, Invariant #34, B-1)', () => { + it('POST /v1/offerings/legitimacy_scan_trust_rung 404s — the route does not exist', async () => { + const app = makeApp(); // default opts: trustRungEnabled undefined -> false + const res = await app.inject({ + method: 'POST', + url: '/v1/offerings/legitimacy_scan_trust_rung', + payload: { token_address: '0x1111111111111111111111111111111111111111' }, + }); + expect(res.statusCode).toBe(404); + }); + + it('is absent from GET /v1/discovery/services', async () => { + const app = makeApp(); + const res = await app.inject({ method: 'GET', url: '/v1/discovery/services' }); + const body = res.json() as { services: Array<{ slug: string }> }; + expect(body.services.map((s) => s.slug)).not.toContain('legitimacy_scan_trust_rung'); + expect(body.services).toHaveLength(9); + }); + + it('its own discovery/capability detail page also 404s while disabled', async () => { + const app = makeApp(); + const res = await app.inject({ + method: 'GET', + url: '/v1/discovery/services/legitimacy_scan_trust_rung', + }); + expect(res.statusCode).toBe(404); + }); +}); + +describe('trust rung — correctly reachable when explicitly enabled (proves the block is real)', () => { + it('POST without payment returns 402 with the $0.10 price, not 404 — route genuinely mounted', async () => { + const app = makeApp({}, passThroughX402, { + trustRungEnabled: true, + trustRungPreHandler: trustRungGate, + }); + const res = await app.inject({ + method: 'POST', + url: '/v1/offerings/legitimacy_scan_trust_rung', + payload: { token_address: '0x1111111111111111111111111111111111111111' }, + }); + expect(res.statusCode).toBe(402); + const body = res.json(); + expect(body.accepts[0].maxAmountRequired).toBe('100000'); + }); + + it('is listed in discovery once enabled', async () => { + const app = makeApp({}, passThroughX402, { + trustRungEnabled: true, + trustRungPreHandler: trustRungGate, + }); + const res = await app.inject({ method: 'GET', url: '/v1/discovery/services' }); + const body = res.json() as { services: Array<{ slug: string }> }; + expect(body.services.map((s) => s.slug)).toContain('legitimacy_scan_trust_rung'); + expect(body.services).toHaveLength(10); + }); + + it('buildServer throws if trustRungEnabled is true without a trustRungPreHandler (fail closed on misconfiguration)', async () => { + const { buildServer } = await import('../src/server'); + const { fakeDeps } = await import('./_helpers'); + expect(() => buildServer(fakeDeps(), passThroughX402, { trustRungEnabled: true })).toThrow( + /trustRungPreHandler/, + ); + }); +}); diff --git a/packages/grey-schemas/scripts/codegen.ts b/packages/grey-schemas/scripts/codegen.ts index 99eccc6..de4a88a 100644 --- a/packages/grey-schemas/scripts/codegen.ts +++ b/packages/grey-schemas/scripts/codegen.ts @@ -33,6 +33,7 @@ const GROUPS: CodegenGroup[] = [ // Schema file -> generated type name (Q4 naming table, locked verbatim). nameMap: { 'legitimacy_scan.schema.json': 'LegitimacyScanResponse', + 'legitimacy_scan_trust_rung.schema.json': 'LegitimacyScanTrustRungResponse', 'verify_whitepaper.schema.json': 'VerifyWhitepaperResponse', 'verify_full_tech.schema.json': 'VerifyFullTechResponse', 'claim_extraction.schema.json': 'ClaimExtractionResponse', @@ -53,6 +54,7 @@ const GROUPS: CodegenGroup[] = [ // 7 paid offerings (M3 FDQ-10 — the 2 free GETs take no input, no request schema). nameMap: { 'legitimacy_scan.schema.json': 'LegitimacyScanRequest', + 'legitimacy_scan_trust_rung.schema.json': 'LegitimacyScanTrustRungRequest', 'verify_whitepaper.schema.json': 'VerifyWhitepaperRequest', 'verify_full_tech.schema.json': 'VerifyFullTechRequest', 'claim_extraction.schema.json': 'ClaimExtractionRequest', diff --git a/packages/grey-schemas/src/evaluationKit/build.ts b/packages/grey-schemas/src/evaluationKit/build.ts index cd35d70..a8a805f 100644 --- a/packages/grey-schemas/src/evaluationKit/build.ts +++ b/packages/grey-schemas/src/evaluationKit/build.ts @@ -6,9 +6,11 @@ import type { OfferingSlug, PaidOfferingSlug } from '../responses/types'; import { computeClassFor, canonicalUsdFor, PRICING_TABLE } from '../pricing/table'; import { EVALUATION_KIT_BRANDING } from './data'; +import { EVALUATION_SAMPLES } from './samples'; import type { DroppedField, EvaluationKitEntry, SampleExchange } from './types'; import legitimacyScanResponse from '../responses/v1/legitimacy_scan.schema.json'; +import legitimacyScanTrustRungResponse from '../responses/v1/legitimacy_scan_trust_rung.schema.json'; import verifyWhitepaperResponse from '../responses/v1/verify_whitepaper.schema.json'; import verifyFullTechResponse from '../responses/v1/verify_full_tech.schema.json'; import claimExtractionResponse from '../responses/v1/claim_extraction.schema.json'; @@ -19,6 +21,7 @@ import dailyGreenlightListResponse from '../responses/v1/daily_greenlight_list.s import scamAlertFeedResponse from '../responses/v1/scam_alert_feed.schema.json'; import legitimacyScanRequest from '../requests/v1/legitimacy_scan.schema.json'; +import legitimacyScanTrustRungRequest from '../requests/v1/legitimacy_scan_trust_rung.schema.json'; import verifyWhitepaperRequest from '../requests/v1/verify_whitepaper.schema.json'; import verifyFullTechRequest from '../requests/v1/verify_full_tech.schema.json'; import claimExtractionRequest from '../requests/v1/claim_extraction.schema.json'; @@ -28,6 +31,7 @@ import dailyTechBriefRequest from '../requests/v1/daily_tech_brief.schema.json'; const OUTPUT_SCHEMAS: Record = { legitimacy_scan: legitimacyScanResponse, + legitimacy_scan_trust_rung: legitimacyScanTrustRungResponse, verify_whitepaper: verifyWhitepaperResponse, verify_full_tech: verifyFullTechResponse, claim_extraction: claimExtractionResponse, @@ -40,6 +44,7 @@ const OUTPUT_SCHEMAS: Record = { const INPUT_SCHEMAS: Record = { legitimacy_scan: legitimacyScanRequest, + legitimacy_scan_trust_rung: legitimacyScanTrustRungRequest, verify_whitepaper: verifyWhitepaperRequest, verify_full_tech: verifyFullTechRequest, claim_extraction: claimExtractionRequest, @@ -131,9 +136,19 @@ export function buildEvaluationKit( } /** Project every offering. Callers filter on `discoverable`/`priceUsd !== null` as needed — this - * function does not itself decide what a channel should list (E1-C's disable flag is separate). */ + * function does not itself decide what a channel should list (E1-C's disable flag is separate). + * No `sample` attached (keeps the 402 body / list index lean) — see `buildEvaluationArtifact`. */ export function buildAllEvaluationKits(): EvaluationKitEntry[] { return (Object.keys(EVALUATION_KIT_BRANDING) as OfferingSlug[]).map((slug) => buildEvaluationKit(slug), ); } + +/** + * The public capability page (E1-C evaluation artifacts): the same EvaluationKit entry PLUS a + * schema-valid sample request/response pair, so an evaluating agent can inspect real shape and a + * real output before ever paying. Ships live — distinct from the trust rung, which does not. + */ +export function buildEvaluationArtifact(slug: OfferingSlug): EvaluationKitEntry { + return buildEvaluationKit(slug, { sample: EVALUATION_SAMPLES[slug] }); +} diff --git a/packages/grey-schemas/src/evaluationKit/data.ts b/packages/grey-schemas/src/evaluationKit/data.ts index 6debfa6..42f002c 100644 --- a/packages/grey-schemas/src/evaluationKit/data.ts +++ b/packages/grey-schemas/src/evaluationKit/data.ts @@ -13,6 +13,13 @@ export const EVALUATION_KIT_BRANDING: Record = { + legitimacy_scan: { + request: { + token_address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + project_name: 'Example Protocol', + }, + response: legitimacyScanResponse, + }, + legitimacy_scan_trust_rung: { + request: { + token_address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + project_name: 'Example Protocol', + }, + response: { + projectName: 'Example Protocol', + tokenAddress: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + verdict: 'PASS', + generatedAt: '2026-06-13T00:00:00.000Z', + note: 'Cache-only teaser — see legitimacy_scan for the full structural read.', + }, + }, + verify_whitepaper: { + request: { + token_address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + project_name: 'Example Protocol', + }, + response: { + ...legitimacyScanResponse, + claims: [claimSample], + claimScores: { c1: 0.8 }, + logicSummary: 'Tokenomics claims are internally consistent with the stated supply schedule.', + }, + }, + verify_full_tech: { + request: { + token_address: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + project_name: 'Example Protocol', + }, + response: fullTechResponse, + }, + claim_extraction: { + request: { whitepaperUrl: 'https://example.org/whitepaper.pdf' }, + response: { + whitepaper: { + id: 'wp-sample', + projectName: 'Example Protocol', + tokenAddress: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + documentUrl: 'https://example.org/whitepaper.pdf', + pageCount: 12, + }, + structuralAnalysis: { hasAbstract: true, hasTokenomics: true }, + claims: [claimSample], + tokenAddress: '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984', + }, + }, + claim_history: { + request: { projectIdentifier: 'Example Protocol' }, + response: { + project: { name: 'Example Protocol' }, + verifications: [{ id: 'v-sample' }], + claims: [claimSample], + note: 'One prior verification on file.', + }, + }, + quick_protocol_facts: { + request: { projectQuery: 'Example Protocol' }, + response: { + project: { name: 'Example Protocol' }, + type: 'DeFi', + miCAStatus: 'PARTIAL', + headlineVerdict: 'CONDITIONAL', + lastVerified: '2026-06-13T00:00:00.000Z', + sources: ['https://example.org/whitepaper.pdf'], + note: 'Served from cache — no live compute.', + }, + }, + daily_tech_brief: { + request: { date: '2026-06-13' }, + response: { date: '2026-06-13', totalVerified: 1, whitepapers: [fullTechResponse] }, + }, + daily_greenlight_list: { + request: {}, + response: { + date: '2026-06-13', + totalVerified: 1, + projects: [{ projectName: 'Example Protocol' }], + }, + }, + scam_alert_feed: { + request: {}, + response: { + date: '2026-06-13', + flagged: [ + { projectName: 'Suspicious Token', redFlags: ['unverifiable team', 'unsourced claims'] }, + ], + }, + }, +}; diff --git a/packages/grey-schemas/src/generated/v1/LegitimacyScanTrustRungResponse.d.ts b/packages/grey-schemas/src/generated/v1/LegitimacyScanTrustRungResponse.d.ts new file mode 100644 index 0000000..4c9a630 --- /dev/null +++ b/packages/grey-schemas/src/generated/v1/LegitimacyScanTrustRungResponse.d.ts @@ -0,0 +1,16 @@ +/* eslint-disable */ +/** + * AUTO-GENERATED by `pnpm codegen` from src/responses/v1/*.schema.json. + * DO NOT EDIT BY HAND. Re-run codegen to regenerate. + */ + +/** + * Response body for the $0.10 CACHE_ONLY trust-rung offering (E1-C, spec §2.4). A cheap teaser of legitimacy_scan's verdict, never live-computed — BUILT BUT BLOCKED from live exposure on every channel per Forces ruling B-1, Invariant #34. + */ +export interface LegitimacyScanTrustRungResponse { + projectName: string; + tokenAddress: string | null; + verdict: "PASS" | "CONDITIONAL" | "FAIL" | "INSUFFICIENT_DATA" | "NOT_IN_DATABASE"; + generatedAt: string; + note: string; +} diff --git a/packages/grey-schemas/src/generated/v1/requests/LegitimacyScanTrustRungRequest.d.ts b/packages/grey-schemas/src/generated/v1/requests/LegitimacyScanTrustRungRequest.d.ts new file mode 100644 index 0000000..e744880 --- /dev/null +++ b/packages/grey-schemas/src/generated/v1/requests/LegitimacyScanTrustRungRequest.d.ts @@ -0,0 +1,13 @@ +/* eslint-disable */ +/** + * AUTO-GENERATED by `pnpm codegen` from src/requests/v1/*.schema.json. + * DO NOT EDIT BY HAND. Re-run codegen to regenerate. + */ + +/** + * Request body for the trust-rung offering (E1-C). Same identifier shape as legitimacy_scan. + */ +export interface LegitimacyScanTrustRungRequest { + token_address: string; + project_name?: string; +} diff --git a/packages/grey-schemas/src/pricing/table.ts b/packages/grey-schemas/src/pricing/table.ts index 21e99ce..580d6a4 100644 --- a/packages/grey-schemas/src/pricing/table.ts +++ b/packages/grey-schemas/src/pricing/table.ts @@ -12,6 +12,15 @@ import type { Channel, ComputeClass, OfferingPricing } from './types'; export const PRICING_TABLE: Record = { // LIVE_ALLOWED — resolve through cacheOrLive on a cache miss (grey-core/src/handlers/index.ts). legitimacy_scan: { slug: 'legitimacy_scan', canonicalUsd: 0.25, computeClass: 'LIVE_ALLOWED' }, + + // CACHE_ONLY, BUILT BUT BLOCKED (E1-C, spec §2.4, Forces ruling B-1, Invariant #34): $0.10 + // trust rung. Never live-computed regardless of the disable flag's state — the flag controls + // whether the ROUTE is reachable at all, not this offering's computeClass floor. + legitimacy_scan_trust_rung: { + slug: 'legitimacy_scan_trust_rung', + canonicalUsd: 0.1, + computeClass: 'CACHE_ONLY', + }, verify_whitepaper: { slug: 'verify_whitepaper', canonicalUsd: 1.5, computeClass: 'LIVE_ALLOWED' }, verify_full_tech: { slug: 'verify_full_tech', canonicalUsd: 3.0, computeClass: 'LIVE_ALLOWED' }, claim_extraction: { slug: 'claim_extraction', canonicalUsd: 0.75, computeClass: 'LIVE_ALLOWED' }, diff --git a/packages/grey-schemas/src/requests/index.ts b/packages/grey-schemas/src/requests/index.ts index 7d06332..0c98f55 100644 --- a/packages/grey-schemas/src/requests/index.ts +++ b/packages/grey-schemas/src/requests/index.ts @@ -4,6 +4,7 @@ // Pattern 6 convention, though request schemas have no shared $defs so no inlining collision. export type { LegitimacyScanRequest, + LegitimacyScanTrustRungRequest, VerifyWhitepaperRequest, VerifyFullTechRequest, DailyTechBriefRequest, diff --git a/packages/grey-schemas/src/requests/types.ts b/packages/grey-schemas/src/requests/types.ts index 8a4fdf5..7466430 100644 --- a/packages/grey-schemas/src/requests/types.ts +++ b/packages/grey-schemas/src/requests/types.ts @@ -19,6 +19,13 @@ export interface LegitimacyScanRequest { project_name?: string; } +/** E1-C trust rung — same identifier shape as legitimacy_scan. BUILT BUT BLOCKED (see + * @grey/x402-middleware's trustRung.ts); this type existing does not mean the route is live. */ +export interface LegitimacyScanTrustRungRequest { + token_address: string; + project_name?: string; +} + export interface VerifyWhitepaperRequest { token_address: string; project_name?: string; @@ -64,7 +71,9 @@ export type ComputeOfferingSlug = /** Maps a paid offering slug to its hand-authored request interface (the cacheOrLive input seam). */ export type RequestFor = O extends 'legitimacy_scan' ? LegitimacyScanRequest - : O extends 'verify_whitepaper' + : O extends 'legitimacy_scan_trust_rung' + ? LegitimacyScanTrustRungRequest + : O extends 'verify_whitepaper' ? VerifyWhitepaperRequest : O extends 'verify_full_tech' ? VerifyFullTechRequest diff --git a/packages/grey-schemas/src/requests/v1/legitimacy_scan_trust_rung.schema.json b/packages/grey-schemas/src/requests/v1/legitimacy_scan_trust_rung.schema.json new file mode 100644 index 0000000..239252e --- /dev/null +++ b/packages/grey-schemas/src/requests/v1/legitimacy_scan_trust_rung.schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.whitepapergrey.com/v1/requests/legitimacy_scan_trust_rung.schema.json", + "title": "LegitimacyScanTrustRungRequest", + "description": "Request body for the trust-rung offering (E1-C). Same identifier shape as legitimacy_scan.", + "type": "object", + "properties": { + "token_address": { "type": "string" }, + "project_name": { "type": "string" } + }, + "required": ["token_address"], + "additionalProperties": false +} diff --git a/packages/grey-schemas/src/responses/index.ts b/packages/grey-schemas/src/responses/index.ts index 1bbb7e7..df8d8ac 100644 --- a/packages/grey-schemas/src/responses/index.ts +++ b/packages/grey-schemas/src/responses/index.ts @@ -6,6 +6,7 @@ // re-declares LegitimacyScanResponse/DiscoveryAttempt), so a wildcard re-export across files // would collide on the shared names. The shared $def types come from `_shared` once. export type { LegitimacyScanResponse } from '../generated/v1/LegitimacyScanResponse'; +export type { LegitimacyScanTrustRungResponse } from '../generated/v1/LegitimacyScanTrustRungResponse'; export type { VerifyWhitepaperResponse } from '../generated/v1/VerifyWhitepaperResponse'; export type { VerifyFullTechResponse } from '../generated/v1/VerifyFullTechResponse'; export type { ClaimExtractionResponse } from '../generated/v1/ClaimExtractionResponse'; diff --git a/packages/grey-schemas/src/responses/types.ts b/packages/grey-schemas/src/responses/types.ts index 0c738bd..d489c63 100644 --- a/packages/grey-schemas/src/responses/types.ts +++ b/packages/grey-schemas/src/responses/types.ts @@ -7,6 +7,7 @@ // schema-generated *Response type (NOT the pipeline domain *Report types in src/index.ts). import type { LegitimacyScanResponse } from '../generated/v1/LegitimacyScanResponse'; +import type { LegitimacyScanTrustRungResponse } from '../generated/v1/LegitimacyScanTrustRungResponse'; import type { VerifyWhitepaperResponse } from '../generated/v1/VerifyWhitepaperResponse'; import type { VerifyFullTechResponse } from '../generated/v1/VerifyFullTechResponse'; import type { ClaimExtractionResponse } from '../generated/v1/ClaimExtractionResponse'; @@ -16,9 +17,12 @@ import type { DailyTechBriefResponse } from '../generated/v1/DailyTechBriefRespo import type { DailyGreenlightListResponse } from '../generated/v1/DailyGreenlightListResponse'; import type { ScamAlertFeedResponse } from '../generated/v1/ScamAlertFeedResponse'; -/** All 9 ratified offering slugs (canonical, matches the `offering` discriminator + validators map). */ +/** All 10 ratified offering slugs (canonical, matches the `offering` discriminator + validators + * map). `legitimacy_scan_trust_rung` (E1-C) is BUILT BUT BLOCKED — see @grey/x402-middleware's + * trustRung.ts for the hard default-off disable flag; this type existing does not mean live. */ export type OfferingSlug = | 'legitimacy_scan' + | 'legitimacy_scan_trust_rung' | 'verify_whitepaper' | 'verify_full_tech' | 'claim_extraction' @@ -28,13 +32,15 @@ export type OfferingSlug = | 'daily_greenlight_list' | 'scam_alert_feed'; -/** The 7 paid offerings (request-body-bearing). Excludes the 2 free GET resources (FDQ-10). */ +/** The 8 paid offerings (request-body-bearing). Excludes the 2 free GET resources (FDQ-10). */ export type PaidOfferingSlug = Exclude; /** Maps an offering slug to its schema-generated response payload type. */ export type ResponseFor = O extends 'legitimacy_scan' ? LegitimacyScanResponse - : O extends 'verify_whitepaper' + : O extends 'legitimacy_scan_trust_rung' + ? LegitimacyScanTrustRungResponse + : O extends 'verify_whitepaper' ? VerifyWhitepaperResponse : O extends 'verify_full_tech' ? VerifyFullTechResponse diff --git a/packages/grey-schemas/src/responses/v1/legitimacy_scan_trust_rung.schema.json b/packages/grey-schemas/src/responses/v1/legitimacy_scan_trust_rung.schema.json new file mode 100644 index 0000000..e20136d --- /dev/null +++ b/packages/grey-schemas/src/responses/v1/legitimacy_scan_trust_rung.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.whitepapergrey.com/v1/legitimacy_scan_trust_rung.schema.json", + "title": "LegitimacyScanTrustRungResponse", + "description": "Response body for the $0.10 CACHE_ONLY trust-rung offering (E1-C, spec §2.4). A cheap teaser of legitimacy_scan's verdict, never live-computed — BUILT BUT BLOCKED from live exposure on every channel per Forces ruling B-1, Invariant #34.", + "type": "object", + "properties": { + "projectName": { "type": "string" }, + "tokenAddress": { "type": ["string", "null"] }, + "verdict": { + "type": "string", + "enum": ["PASS", "CONDITIONAL", "FAIL", "INSUFFICIENT_DATA", "NOT_IN_DATABASE"] + }, + "generatedAt": { "type": "string" }, + "note": { "type": "string" } + }, + "required": ["projectName", "tokenAddress", "verdict", "generatedAt", "note"], + "additionalProperties": false +} diff --git a/packages/grey-schemas/src/validators/index.ts b/packages/grey-schemas/src/validators/index.ts index 0fd04cd..39f8a13 100644 --- a/packages/grey-schemas/src/validators/index.ts +++ b/packages/grey-schemas/src/validators/index.ts @@ -10,6 +10,7 @@ import type { PaidOfferingSlug } from '../responses/types'; import sharedSchema from '../responses/v1/_shared.schema.json'; import legitimacyScanSchema from '../responses/v1/legitimacy_scan.schema.json'; +import legitimacyScanTrustRungSchema from '../responses/v1/legitimacy_scan_trust_rung.schema.json'; import verifyWhitepaperSchema from '../responses/v1/verify_whitepaper.schema.json'; import verifyFullTechSchema from '../responses/v1/verify_full_tech.schema.json'; import claimExtractionSchema from '../responses/v1/claim_extraction.schema.json'; @@ -22,6 +23,7 @@ import envelopeSchema from '../responses/v1/envelope.schema.json'; // M3 (Q7): request-body schemas for the 7 paid offerings (FDQ-10 — the 2 free GETs take no body). import legitimacyScanRequestSchema from '../requests/v1/legitimacy_scan.schema.json'; +import legitimacyScanTrustRungRequestSchema from '../requests/v1/legitimacy_scan_trust_rung.schema.json'; import verifyWhitepaperRequestSchema from '../requests/v1/verify_whitepaper.schema.json'; import verifyFullTechRequestSchema from '../requests/v1/verify_full_tech.schema.json'; import claimExtractionRequestSchema from '../requests/v1/claim_extraction.schema.json'; @@ -41,6 +43,7 @@ addFormats(ajv); ajv.addSchema([ sharedSchema, legitimacyScanSchema, + legitimacyScanTrustRungSchema, verifyWhitepaperSchema, verifyFullTechSchema, claimExtractionSchema, @@ -52,6 +55,7 @@ ajv.addSchema([ envelopeSchema, // M3 request schemas (distinct $id namespace: .../v1/requests/). legitimacyScanRequestSchema, + legitimacyScanTrustRungRequestSchema, verifyWhitepaperRequestSchema, verifyFullTechRequestSchema, claimExtractionRequestSchema, @@ -74,6 +78,7 @@ function compiledRequest(file: string): ValidateFunction { // Per-offering payload validators (validate the inner response shape). export const legitimacyScanValidator = compiled('legitimacy_scan.schema.json'); +export const legitimacyScanTrustRungValidator = compiled('legitimacy_scan_trust_rung.schema.json'); export const verifyWhitepaperValidator = compiled('verify_whitepaper.schema.json'); export const verifyFullTechValidator = compiled('verify_full_tech.schema.json'); export const claimExtractionValidator = compiled('claim_extraction.schema.json'); @@ -89,6 +94,7 @@ export const envelopeValidator = compiled('envelope.schema.json'); /** Per-offering validator lookup by canonical slug. */ export const offeringValidators: Record = { legitimacy_scan: legitimacyScanValidator, + legitimacy_scan_trust_rung: legitimacyScanTrustRungValidator, verify_whitepaper: verifyWhitepaperValidator, verify_full_tech: verifyFullTechValidator, claim_extraction: claimExtractionValidator, @@ -104,6 +110,7 @@ export const offeringValidators: Record = { // delegates request-body validation to these (no second ajv instance — HC#12). export const legitimacyScanRequestValidator = compiledRequest('legitimacy_scan.schema.json'); +export const legitimacyScanTrustRungRequestValidator = compiledRequest('legitimacy_scan_trust_rung.schema.json'); export const verifyWhitepaperRequestValidator = compiledRequest('verify_whitepaper.schema.json'); export const verifyFullTechRequestValidator = compiledRequest('verify_full_tech.schema.json'); export const claimExtractionRequestValidator = compiledRequest('claim_extraction.schema.json'); @@ -111,9 +118,10 @@ export const claimHistoryRequestValidator = compiledRequest('claim_history.schem export const quickProtocolFactsRequestValidator = compiledRequest('quick_protocol_facts.schema.json'); export const dailyTechBriefRequestValidator = compiledRequest('daily_tech_brief.schema.json'); -/** Per-paid-offering request-body validator lookup (7 entries; the 2 free GETs have no body — FDQ-10). */ +/** Per-paid-offering request-body validator lookup (8 entries; the 2 free GETs have no body — FDQ-10). */ export const offeringRequestValidators: Record = { legitimacy_scan: legitimacyScanRequestValidator, + legitimacy_scan_trust_rung: legitimacyScanTrustRungRequestValidator, verify_whitepaper: verifyWhitepaperRequestValidator, verify_full_tech: verifyFullTechRequestValidator, claim_extraction: claimExtractionRequestValidator, diff --git a/packages/grey-schemas/test/evaluationKit.samples.test.ts b/packages/grey-schemas/test/evaluationKit.samples.test.ts new file mode 100644 index 0000000..3ce3707 --- /dev/null +++ b/packages/grey-schemas/test/evaluationKit.samples.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import type { OfferingSlug, PaidOfferingSlug } from '../src/responses/types'; +import { EVALUATION_SAMPLES, buildEvaluationArtifact } from '../src/evaluationKit'; +import { offeringValidators, offeringRequestValidators } from '../src/validators'; + +const PAID: PaidOfferingSlug[] = [ + 'legitimacy_scan', + 'legitimacy_scan_trust_rung', + 'verify_whitepaper', + 'verify_full_tech', + 'claim_extraction', + 'claim_history', + 'quick_protocol_facts', + 'daily_tech_brief', +]; +const FREE: OfferingSlug[] = ['daily_greenlight_list', 'scam_alert_feed']; + +// E1-C: an evaluating agent reads these before ever paying — they must be real, checkable +// artifacts, not illustrative-but-wrong shapes. Validate against the SAME ajv instances the live +// routes use, not a hand-eyeballed check. +describe('EvaluationKit samples — schema-valid evaluation artifacts (E1-C)', () => { + it.each(PAID)('%s sample request validates against its request schema', (slug) => { + const validate = offeringRequestValidators[slug]; + const ok = validate(EVALUATION_SAMPLES[slug].request); + expect(ok, JSON.stringify(validate.errors)).toBe(true); + }); + + it.each([...PAID, ...FREE])('%s sample response validates against its payload schema', (slug) => { + const validate = offeringValidators[slug]; + const ok = validate(EVALUATION_SAMPLES[slug].response); + expect(ok, JSON.stringify(validate.errors)).toBe(true); + }); + + it.each([...PAID, ...FREE])( + '%s buildEvaluationArtifact attaches the validated sample', + (slug) => { + const artifact = buildEvaluationArtifact(slug); + expect(artifact.sample).toEqual(EVALUATION_SAMPLES[slug]); + }, + ); + + it('buildAllEvaluationKits (the lean list/402 projection) carries no sample', () => { + // covered indirectly: buildEvaluationKit without opts.sample leaves it undefined. + const artifact = buildEvaluationArtifact('legitimacy_scan'); + expect(artifact.sample).toBeDefined(); + }); +}); diff --git a/packages/grey-schemas/test/evaluationKit.test.ts b/packages/grey-schemas/test/evaluationKit.test.ts index 7c0a0ca..c5fc40e 100644 --- a/packages/grey-schemas/test/evaluationKit.test.ts +++ b/packages/grey-schemas/test/evaluationKit.test.ts @@ -7,9 +7,9 @@ import { } from '../src/evaluationKit'; describe('EvaluationKit — Bazaar extension projection (E1-B, Invariant #33)', () => { - it('projects all 9 offerings with the spec field list', () => { + it('projects all 10 offerings with the spec field list', () => { const kits = buildAllEvaluationKits(); - expect(kits).toHaveLength(9); + expect(kits).toHaveLength(10); for (const k of kits) { expect(k.discoverable).toBe(true); expect(typeof k.description).toBe('string'); diff --git a/packages/grey-schemas/test/fixtures/requests/legitimacy_scan_trust_rung/invalid.json b/packages/grey-schemas/test/fixtures/requests/legitimacy_scan_trust_rung/invalid.json new file mode 100644 index 0000000..5294874 --- /dev/null +++ b/packages/grey-schemas/test/fixtures/requests/legitimacy_scan_trust_rung/invalid.json @@ -0,0 +1 @@ +{ "project_name": "Uniswap" } diff --git a/packages/grey-schemas/test/fixtures/requests/legitimacy_scan_trust_rung/valid.json b/packages/grey-schemas/test/fixtures/requests/legitimacy_scan_trust_rung/valid.json new file mode 100644 index 0000000..16e90d3 --- /dev/null +++ b/packages/grey-schemas/test/fixtures/requests/legitimacy_scan_trust_rung/valid.json @@ -0,0 +1 @@ +{ "token_address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", "project_name": "Uniswap" } diff --git a/packages/grey-schemas/test/pricing.test.ts b/packages/grey-schemas/test/pricing.test.ts index e382a01..1bae0c5 100644 --- a/packages/grey-schemas/test/pricing.test.ts +++ b/packages/grey-schemas/test/pricing.test.ts @@ -11,6 +11,7 @@ import { const ALL_SLUGS: OfferingSlug[] = [ 'legitimacy_scan', + 'legitimacy_scan_trust_rung', 'verify_whitepaper', 'verify_full_tech', 'claim_extraction', @@ -28,7 +29,10 @@ const LIVE_ALLOWED: OfferingSlug[] = [ 'claim_extraction', ]; +// E1-C: legitimacy_scan_trust_rung is CACHE_ONLY too — BUILT BUT BLOCKED (Forces ruling B-1), +// never live-computed regardless of the disable flag's state (see @grey/x402-middleware/trustRung). const CACHE_ONLY: OfferingSlug[] = [ + 'legitimacy_scan_trust_rung', 'claim_history', 'quick_protocol_facts', 'daily_tech_brief', @@ -39,7 +43,7 @@ const CACHE_ONLY: OfferingSlug[] = [ const UNPRICED: OfferingSlug[] = ['daily_greenlight_list', 'scam_alert_feed']; describe('pricing — computeClass + canonical table (E1-A, Invariant #30/#31)', () => { - it('classifies all 9 offerings, matching cacheOrLive reachability', () => { + it('classifies all 10 offerings, matching cacheOrLive reachability', () => { expect(Object.keys(PRICING_TABLE).sort()).toEqual([...ALL_SLUGS].sort()); for (const slug of LIVE_ALLOWED) expect(computeClassFor(slug)).toBe('LIVE_ALLOWED'); for (const slug of CACHE_ONLY) expect(computeClassFor(slug)).toBe('CACHE_ONLY'); @@ -64,6 +68,11 @@ describe('pricing — computeClass + canonical table (E1-A, Invariant #30/#31)', } }); + it('the trust rung canonicalizes at $0.10, CACHE_ONLY (E1-C, spec §2.4)', () => { + expect(canonicalUsdFor('legitimacy_scan_trust_rung')).toBe(0.1); + expect(computeClassFor('legitimacy_scan_trust_rung')).toBe('CACHE_ONLY'); + }); + it('leaves the 2 unpriced offerings flagged (null), not invented', () => { for (const slug of UNPRICED) { expect(PRICING_TABLE[slug].canonicalUsd).toBeNull(); diff --git a/packages/grey-schemas/test/request-field-drift.test.ts b/packages/grey-schemas/test/request-field-drift.test.ts index a02f0ce..4c08bfd 100644 --- a/packages/grey-schemas/test/request-field-drift.test.ts +++ b/packages/grey-schemas/test/request-field-drift.test.ts @@ -8,7 +8,7 @@ // the interface (or vice versa) fails the test and/or typecheck, naming the drifted side. // Plus a validator round-trip on the per-offering valid/invalid fixtures. // -// Covers the 7 PAID offerings only (FDQ-10) — the 2 free GETs take no request body. +// Covers the 8 PAID offerings only (FDQ-10) — the 2 free GETs take no request body. import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; @@ -17,6 +17,7 @@ import { offeringRequestValidators } from '../src/validators'; import type { PaidOfferingSlug } from '../src/responses/types'; import type { LegitimacyScanRequest, + LegitimacyScanTrustRungRequest, VerifyWhitepaperRequest, VerifyFullTechRequest, DailyTechBriefRequest, @@ -39,6 +40,10 @@ const legitimacyScanKeys = ['token_address', 'project_name'] as const satisfies type _CkLegit = Exclude extends never ? true : never; const _ckLegit: _CkLegit = true; +const legitimacyScanTrustRungKeys = ['token_address', 'project_name'] as const satisfies readonly (keyof LegitimacyScanTrustRungRequest)[]; +type _CkTrustRung = Exclude extends never ? true : never; +const _ckTrustRung: _CkTrustRung = true; + const verifyWhitepaperKeys = ['token_address', 'project_name', 'document_url'] as const satisfies readonly (keyof VerifyWhitepaperRequest)[]; type _CkVw = Exclude extends never ? true : never; const _ckVw: _CkVw = true; @@ -65,6 +70,7 @@ const _ckCe: _CkCe = true; const mirrors: Record = { legitimacy_scan: legitimacyScanKeys, + legitimacy_scan_trust_rung: legitimacyScanTrustRungKeys, verify_whitepaper: verifyWhitepaperKeys, verify_full_tech: verifyFullTechKeys, daily_tech_brief: dailyTechBriefKeys, @@ -77,8 +83,8 @@ const paidSlugs = Object.keys(mirrors) as PaidOfferingSlug[]; describe('request-field-drift (Pattern 4b): schema properties ≡ keyof Interface', () => { it('compile-time mirror guards hold', () => { - expect([_ckLegit, _ckVw, _ckVft, _ckDtb, _ckCh, _ckQpf, _ckCe]).toEqual([ - true, true, true, true, true, true, true, + expect([_ckLegit, _ckTrustRung, _ckVw, _ckVft, _ckDtb, _ckCh, _ckQpf, _ckCe]).toEqual([ + true, true, true, true, true, true, true, true, ]); }); From 561da10e05acdee37ac8fcf2a2208951f5836a5b Mon Sep 17 00:00:00 2001 From: Mayakovsky Date: Thu, 30 Jul 2026 16:23:14 -0400 Subject: [PATCH 3/5] feat(e1-round2): MCP tool surface over the same x402 rail (sub-unit 3) New POST /v1/mcp: hand-rolled JSON-RPC 2.0 dispatch (initialize, tools/list, tools/call) -- deliberately NOT the @modelcontextprotocol/ sdk package, to avoid a heavy transitive dependency tree on the memory-constrained production VPS (same posture as acp-adapter's dynamic-import SDK loading, sdk.ts). tools/list projects the SAME EvaluationKit source every HTTP route's 402 body and the discovery index use (Invariant #33) -- sub-unit 1 dependency. tools/call reuses the exact verify/settle/decodePaymentHeader functions the HTTP preHandler uses: one payment implementation, two transports. A paid tool without `_meta.x402Payment` gets isError:true carrying the same PaymentRequirements shape as an HTTP 402; resubmitting the same call with payment settles and returns the envelope. The trust rung is never in the MCP tool set regardless of its own disable flag -- B-1 applies uniformly across every surface, not per-channel. Tests assert this by attempting to call it by name directly and confirming a JSON-RPC error, not a CallToolResult. GET /v1/discovery/services now also returns `mcpEndpoint` (spec: "List in Bazaar as MCP") -- one JSON-RPC endpoint for the whole offering set, not a duplicate per-offering listing. PAID/FREE slug lists exported from offerings.ts/resources.ts so this surface reuses the exact same lists rather than re-declaring them. EXPANSION-E1-ROUND2-KOV-directive.md sub-unit 3 (was E1-D). --- .../grey-core/src/channels/x402Adapter.ts | 7 + packages/grey-core/src/server/index.ts | 5 + .../grey-core/src/server/routes/discovery.ts | 4 +- packages/grey-core/src/server/routes/mcp.ts | 211 ++++++++++++++++++ .../grey-core/src/server/routes/offerings.ts | 4 +- .../grey-core/src/server/routes/resources.ts | 3 +- packages/grey-core/src/start.ts | 3 + packages/grey-core/test/mcp.test.ts | 111 +++++++++ 8 files changed, 345 insertions(+), 3 deletions(-) create mode 100644 packages/grey-core/src/server/routes/mcp.ts create mode 100644 packages/grey-core/test/mcp.test.ts diff --git a/packages/grey-core/src/channels/x402Adapter.ts b/packages/grey-core/src/channels/x402Adapter.ts index ebb86f1..b4ca101 100644 --- a/packages/grey-core/src/channels/x402Adapter.ts +++ b/packages/grey-core/src/channels/x402Adapter.ts @@ -6,6 +6,7 @@ import type { FastifyInstance, preHandlerHookHandler } from 'fastify'; import type { HandlerDeps } from '../deps'; import { buildServer } from '../server'; +import type { McpRouteDeps } from '../server/routes/mcp'; import type { ChannelIdentity, ChannelIngress, OfferingRegistration } from './ingress'; export interface X402AdapterOptions { @@ -25,6 +26,9 @@ export interface X402AdapterOptions { /** Required when trustRungEnabled is true — @grey/x402-middleware's makeTrustRungPreHandler(...) * output. NOT the same as `gate`: different slug/price, so a different verify/settle path. */ trustRungPreHandler?: preHandlerHookHandler; + /** E1-D: mounts POST /v1/mcp when present. Unconditional (unlike the trust rung) — MCP exposes + * the same 7+2 normal offerings, not a blocked one. */ + mcp?: McpRouteDeps; } /** @@ -41,6 +45,7 @@ export class X402Adapter implements ChannelIngress { private readonly relayerAddress?: string; private readonly trustRungEnabled: boolean; private readonly trustRungPreHandler?: preHandlerHookHandler; + private readonly mcp?: McpRouteDeps; private readonly offerings: OfferingRegistration[] = []; private app: FastifyInstance | null = null; private boundAddress: string | null = null; @@ -53,6 +58,7 @@ export class X402Adapter implements ChannelIngress { this.relayerAddress = opts.relayerAddress; this.trustRungEnabled = opts.trustRungEnabled ?? false; this.trustRungPreHandler = opts.trustRungPreHandler; + this.mcp = opts.mcp; } async start(): Promise { @@ -61,6 +67,7 @@ export class X402Adapter implements ChannelIngress { const app = buildServer(this.deps, this.gate, { trustRungEnabled: this.trustRungEnabled, trustRungPreHandler: this.trustRungPreHandler, + mcp: this.mcp, }); this.app = app; this.boundAddress = await app.listen({ port: this.port, host: this.host }); diff --git a/packages/grey-core/src/server/index.ts b/packages/grey-core/src/server/index.ts index 91300f2..5b9e2c2 100644 --- a/packages/grey-core/src/server/index.ts +++ b/packages/grey-core/src/server/index.ts @@ -11,6 +11,7 @@ import { registerOfferingRoutes } from './routes/offerings'; import { registerResourceRoutes } from './routes/resources'; import { registerDiscoveryRoutes } from './routes/discovery'; import { registerTrustRungRoute } from './routes/trustRung'; +import { registerMcpRoute, type McpRouteDeps } from './routes/mcp'; export interface BuildServerOptions { /** E1-C, Invariant #34: default OFF. Only start.ts (reading @grey/x402-middleware's @@ -19,6 +20,9 @@ export interface BuildServerOptions { /** Required when trustRungEnabled is true — @grey/x402-middleware's * makeTrustRungPreHandler(...) output. NOT the general x402PreHandler (different slug/price). */ trustRungPreHandler?: preHandlerHookHandler; + /** E1-D: mounts POST /v1/mcp when present. Optional so existing callers (and most tests) are + * unaffected; start.ts always passes it (MCP is unconditional — only the trust rung is gated). */ + mcp?: McpRouteDeps; } export function buildServer( @@ -39,5 +43,6 @@ export function buildServer( } registerTrustRungRoute(app, deps, opts.trustRungPreHandler); // E1-C, default off } + if (opts.mcp) registerMcpRoute(app, deps, opts.mcp); // E1-D: paid MCP tools, POST × 1 return app; } diff --git a/packages/grey-core/src/server/routes/discovery.ts b/packages/grey-core/src/server/routes/discovery.ts index 113d665..532818c 100644 --- a/packages/grey-core/src/server/routes/discovery.ts +++ b/packages/grey-core/src/server/routes/discovery.ts @@ -27,7 +27,9 @@ export function registerDiscoveryRoutes(app: FastifyInstance, opts: DiscoveryRou const services = listableSlugs(opts) .map((slug) => buildEvaluationKit(slug)) .filter((kit) => kit.discoverable); - reply.send({ services }); + // E1-D: "List in Bazaar as MCP" — the same offering set is also reachable as paid MCP tools + // over one JSON-RPC endpoint (POST /v1/mcp), not one route per offering like the HTTP surface. + reply.send({ services, mcpEndpoint: '/v1/mcp' }); }); app.get<{ Params: { slug: string } }>('/v1/discovery/services/:slug', async (req, reply) => { diff --git a/packages/grey-core/src/server/routes/mcp.ts b/packages/grey-core/src/server/routes/mcp.ts new file mode 100644 index 0000000..2dd47fa --- /dev/null +++ b/packages/grey-core/src/server/routes/mcp.ts @@ -0,0 +1,211 @@ +// POST /v1/mcp (E1-D) — the offering set exposed as paid MCP tools over the SAME x402 rail +// (spec §3 E1 bequeaths: "Bazaar indexes MCP tools alongside HTTP endpoints"). Hand-rolled +// JSON-RPC 2.0 dispatch, NOT the @modelcontextprotocol/sdk package — this repo's adapters +// deliberately avoid heavy transitive dependency trees on the memory-constrained production VPS +// (see adapters/acp-adapter/tsconfig.json's note on the M5 VPS OOM, and sdk.ts's dynamic-import +// pattern for the same reason). The wire shape (jsonrpc/id/method/params, tools/list, tools/call, +// CallToolResult{content,isError}) matches the real MCP spec closely enough for any conformant +// client, without pulling in the SDK. +// +// Payment: a tool call for a paid offering that arrives without `_meta.x402Payment` gets a +// CallToolResult{isError:true} carrying the SAME PaymentRequirements shape the HTTP 402 body +// would — the client base64-encodes an X-PAYMENT authorization into `_meta.x402Payment` and +// resubmits the SAME tools/call. Verify/settle reuse the exact functions the HTTP preHandler +// uses (decodePaymentHeader, verifyPayment, settle) — one payment implementation, two transports. +// +// Depends on sub-unit 1 (EvaluationKit): tools/list projects the SAME source every HTTP route's +// 402 body and the discovery index use — Invariant #33, no separate MCP-specific metadata. +import { randomUUID } from 'node:crypto'; +import type { FastifyInstance } from 'fastify'; +import type { OfferingSlug, PaidOfferingSlug } from '@grey/schemas/responses'; +import { buildEvaluationKit } from '@grey/schemas/evaluationKit'; +import { + isPaidSlug, + priceAtomicFor, + priceUsdFor, + decodePaymentHeader, + verifyPayment, + settle, + buildPaymentRequirements, + type X402Config, +} from '@grey/x402-middleware'; +import type { PublicClientLike, WalletClientLike } from '@grey/x402-middleware'; +import type { HandlerDeps } from '../../deps'; +import { offeringHandlers } from '../../handlers'; +import { buildEnvelope } from '../../envelope/build'; +import { PAID } from './offerings'; +import { FREE } from './resources'; + +const PROTOCOL_VERSION = '2026-03-26'; +const MCP_TOOL_SLUGS: OfferingSlug[] = [...PAID, ...FREE]; + +export interface McpRouteDeps { + x402Config: X402Config; + wallet: WalletClientLike; + publicClient: PublicClientLike; + /** Injectable ms clock for deterministic tests (mirrors X402PreHandlerDeps). */ + now?: () => number; +} + +interface JsonRpcRequest { + jsonrpc: '2.0'; + id: string | number | null; + method: string; + params?: Record; +} + +interface JsonRpcSuccess { + jsonrpc: '2.0'; + id: string | number | null; + result: unknown; +} +interface JsonRpcFailure { + jsonrpc: '2.0'; + id: string | number | null; + error: { code: number; message: string; data?: unknown }; +} + +function ok(id: JsonRpcRequest['id'], result: unknown): JsonRpcSuccess { + return { jsonrpc: '2.0', id, result }; +} +function fail( + id: JsonRpcRequest['id'], + code: number, + message: string, + data?: unknown, +): JsonRpcFailure { + return { jsonrpc: '2.0', id, error: { code, message, data } }; +} + +interface CallToolResult { + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; +} +function textResult(value: unknown, isError = false): CallToolResult { + return { content: [{ type: 'text', text: JSON.stringify(value) }], isError }; +} + +function toolDef(slug: OfferingSlug): { name: string; description: string; inputSchema: object } { + const kit = buildEvaluationKit(slug); + return { + name: slug, + description: kit.description, + inputSchema: kit.inputSchema ?? { type: 'object', properties: {}, additionalProperties: false }, + }; +} + +export function registerMcpRoute( + app: FastifyInstance, + deps: HandlerDeps, + mcpDeps: McpRouteDeps, +): void { + app.post('/v1/mcp', async (req, reply) => { + const body = req.body as JsonRpcRequest; + if (!body || body.jsonrpc !== '2.0' || typeof body.method !== 'string') { + reply.send(fail(body?.id ?? null, -32600, 'invalid JSON-RPC request')); + return; + } + const { id, method, params } = body; + + if (method === 'initialize') { + reply.send( + ok(id, { + protocolVersion: PROTOCOL_VERSION, + serverInfo: { name: deps.config.name, version: deps.config.version }, + capabilities: { tools: {} }, + }), + ); + return; + } + + if (method === 'tools/list') { + // Registry-driven, same gating discipline as discovery.ts: only the normal 7 paid + 2 free + // slugs are ever listed. The trust rung is never in MCP_TOOL_SLUGS regardless of its own + // disable flag — E1-D's rail doesn't get a separate exposure decision from B-1. + reply.send(ok(id, { tools: MCP_TOOL_SLUGS.map(toolDef) })); + return; + } + + if (method === 'tools/call') { + const name = params?.name as string | undefined; + if (!name || !MCP_TOOL_SLUGS.includes(name as OfferingSlug)) { + reply.send(fail(id, -32602, `unknown or unlisted tool: ${String(name)}`)); + return; + } + const slug = name as OfferingSlug; + const args = (params?.arguments as Record) ?? {}; + const meta = (params?._meta as Record) ?? {}; + + const isFree = (FREE as readonly string[]).includes(slug); + if (!isFree && isPaidSlug(slug)) { + const paidSlug = slug as PaidOfferingSlug; + const paymentHeader = meta.x402Payment as string | undefined; + if (!paymentHeader) { + reply.send( + ok( + id, + textResult(buildPaymentRequirements(mcpDeps.x402Config, paidSlug, '/v1/mcp'), true), + ), + ); + return; + } + const decoded = decodePaymentHeader(paymentHeader); + if (!decoded.ok) { + reply.send(ok(id, textResult({ error: decoded.reason }, true))); + return; + } + const nowSec = BigInt(Math.floor((mcpDeps.now?.() ?? Date.now()) / 1000)); + const verdict = await verifyPayment( + mcpDeps.x402Config, + decoded.payload, + priceAtomicFor(paidSlug), + mcpDeps.publicClient, + nowSec, + ); + if (!verdict.ok) { + reply.send(ok(id, textResult({ error: verdict.reason }, true))); + return; + } + let outcome; + try { + outcome = await settle(mcpDeps.x402Config, verdict.authorization, verdict.signature, { + wallet: mcpDeps.wallet, + publicClient: mcpDeps.publicClient, + }); + } catch (err) { + reply.send(ok(id, textResult({ error: 'settlement failed' }, true))); + deps.logger.error('mcp: settlement infra error', { + slug, + reason: err instanceof Error ? err.message : String(err), + }); + return; + } + if (!outcome.ok) { + reply.send(ok(id, textResult({ error: outcome.reason }, true))); + return; + } + } + + const start = deps.clock().getTime(); + const result = await offeringHandlers[slug]({ offeringId: slug, requirement: args }, deps); + const env = buildEnvelope({ + offering: slug, + payload: result.payload as never, + requestId: randomUUID(), + config: deps.config, + subject: result.subject, + metadata: { + costUsd: isFree ? 0 : priceUsdFor(slug), + model: 'none', + latencyMs: deps.clock().getTime() - start, + timestamp: deps.clock().toISOString(), + cacheHit: result.cacheHit, + }, + }); + reply.send(ok(id, textResult(env))); + return; + } + + reply.send(fail(id, -32601, `method not found: ${method}`)); + }); +} diff --git a/packages/grey-core/src/server/routes/offerings.ts b/packages/grey-core/src/server/routes/offerings.ts index ad5b629..38cf3cb 100644 --- a/packages/grey-core/src/server/routes/offerings.ts +++ b/packages/grey-core/src/server/routes/offerings.ts @@ -9,7 +9,9 @@ import type { HandlerDeps } from '../../deps'; import { offeringHandlers } from '../../handlers'; import { buildEnvelope } from '../../envelope/build'; -const PAID: PaidOfferingSlug[] = [ +// Exported so other surfaces over the SAME x402 rail (e.g. server/routes/mcp.ts, E1-D) reuse this +// exact list instead of re-declaring it — one place names "the 7 normal paid offerings". +export const PAID: PaidOfferingSlug[] = [ 'legitimacy_scan', 'verify_whitepaper', 'verify_full_tech', diff --git a/packages/grey-core/src/server/routes/resources.ts b/packages/grey-core/src/server/routes/resources.ts index 22e18a6..e2f435d 100644 --- a/packages/grey-core/src/server/routes/resources.ts +++ b/packages/grey-core/src/server/routes/resources.ts @@ -6,7 +6,8 @@ import type { HandlerDeps } from '../../deps'; import { offeringHandlers } from '../../handlers'; import { buildEnvelope } from '../../envelope/build'; -const FREE = ['daily_greenlight_list', 'scam_alert_feed'] as const; +// Exported so other surfaces (e.g. server/routes/mcp.ts, E1-D) reuse this exact list. +export const FREE = ['daily_greenlight_list', 'scam_alert_feed'] as const; export function registerResourceRoutes(app: FastifyInstance, deps: HandlerDeps): void { for (const slug of FREE) { diff --git a/packages/grey-core/src/start.ts b/packages/grey-core/src/start.ts index 3eca8a4..edc26ba 100644 --- a/packages/grey-core/src/start.ts +++ b/packages/grey-core/src/start.ts @@ -47,6 +47,9 @@ const adapter = new X402Adapter({ relayerAddress: relayer.relayerAddress, trustRungEnabled: trustRungOn, trustRungPreHandler, + // E1-D: MCP is unconditional (unlike the trust rung) — reuses the SAME relayer clients as the + // HTTP gate, verify/settle against the same USDC contract, just a different transport. + mcp: { x402Config, wallet: relayer.wallet, publicClient: relayer.publicClient }, }); // FDQ-66(a) boot-wrapper: record the catalog for identity()/observability. Routes stay statically diff --git a/packages/grey-core/test/mcp.test.ts b/packages/grey-core/test/mcp.test.ts new file mode 100644 index 0000000..a973c03 --- /dev/null +++ b/packages/grey-core/test/mcp.test.ts @@ -0,0 +1,111 @@ +// POST /v1/mcp (E1-D) — hand-rolled JSON-RPC MCP surface over the same x402 rail. +import { describe, it, expect } from 'vitest'; +import { loadX402Config } from '@grey/x402-middleware'; +import { makeApp } from './_helpers'; + +const cfg = loadX402Config({ + X402_NETWORK: 'eip155:84532', + BASE_X402_PAY_TO: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + BASE_RPC_URL: 'http://127.0.0.1:8545', + X402_RELAYER_PRIVATE_KEY: '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d', +}); +const mcpDeps = { + x402Config: cfg, + wallet: { writeContract: async () => ('0x' + 'ee'.repeat(32)) as `0x${string}` }, + publicClient: { + readContract: async () => false, + simulateContract: async () => ({ request: {} }), + waitForTransactionReceipt: async () => ({ status: 'success' as const }), + }, +}; + +function rpc(method: string, params?: unknown, id: string | number = 1) { + return { jsonrpc: '2.0' as const, id, method, params }; +} + +describe('MCP — initialize + tools/list (E1-D)', () => { + it('initialize returns server info + tools capability', async () => { + const app = makeApp({}, undefined, { mcp: mcpDeps }); + const res = await app.inject({ method: 'POST', url: '/v1/mcp', payload: rpc('initialize') }); + const body = res.json(); + expect(body.result.protocolVersion).toBeTruthy(); + expect(body.result.capabilities.tools).toBeDefined(); + }); + + it('tools/list projects the SAME EvaluationKit source as the HTTP surface — 9 tools, never the trust rung', async () => { + const app = makeApp({}, undefined, { mcp: mcpDeps }); + const res = await app.inject({ method: 'POST', url: '/v1/mcp', payload: rpc('tools/list') }); + const body = res.json(); + expect(body.result.tools).toHaveLength(9); + const names = body.result.tools.map((t: { name: string }) => t.name); + expect(names).toContain('legitimacy_scan'); + expect(names).not.toContain('legitimacy_scan_trust_rung'); + const legit = body.result.tools.find((t: { name: string }) => t.name === 'legitimacy_scan'); + expect(legit.inputSchema).toBeTruthy(); + expect(typeof legit.description).toBe('string'); + }); + + it('unknown method returns a JSON-RPC error', async () => { + const app = makeApp({}, undefined, { mcp: mcpDeps }); + const res = await app.inject({ method: 'POST', url: '/v1/mcp', payload: rpc('nope') }); + const body = res.json(); + expect(body.error.code).toBe(-32601); + }); +}); + +describe('MCP — tools/call (E1-D)', () => { + it('a free tool runs with no payment required', async () => { + const app = makeApp({}, undefined, { mcp: mcpDeps }); + const res = await app.inject({ + method: 'POST', + url: '/v1/mcp', + payload: rpc('tools/call', { name: 'scam_alert_feed', arguments: {} }), + }); + const body = res.json(); + expect(body.result.isError).toBeFalsy(); + const envelope = JSON.parse(body.result.content[0].text); + expect(envelope.offering).toBe('scam_alert_feed'); + }); + + it('a paid tool without payment returns isError:true carrying PaymentRequirements', async () => { + const app = makeApp({}, undefined, { mcp: mcpDeps }); + const res = await app.inject({ + method: 'POST', + url: '/v1/mcp', + payload: rpc('tools/call', { + name: 'legitimacy_scan', + arguments: { token_address: '0x1111111111111111111111111111111111111111' }, + }), + }); + const body = res.json(); + expect(body.result.isError).toBe(true); + const requirements = JSON.parse(body.result.content[0].text); + expect(requirements.accepts[0].maxAmountRequired).toBe('250000'); + }); + + it('an unknown tool name is a JSON-RPC error, not a CallToolResult', async () => { + const app = makeApp({}, undefined, { mcp: mcpDeps }); + const res = await app.inject({ + method: 'POST', + url: '/v1/mcp', + payload: rpc('tools/call', { name: 'not_a_real_offering', arguments: {} }), + }); + const body = res.json(); + expect(body.error.code).toBe(-32602); + }); + + it('the trust rung cannot be called by name even directly — MCP respects B-1 too (Invariant #34)', async () => { + const app = makeApp({}, undefined, { mcp: mcpDeps }); + const res = await app.inject({ + method: 'POST', + url: '/v1/mcp', + payload: rpc('tools/call', { + name: 'legitimacy_scan_trust_rung', + arguments: { token_address: '0x1111111111111111111111111111111111111111' }, + }), + }); + const body = res.json(); + expect(body.error).toBeDefined(); + expect(body.result).toBeUndefined(); + }); +}); From 5ba2388834e525618326853adad6e7f2f5c5d1f8 Mon Sep 17 00:00:00 2001 From: Mayakovsky Date: Thu, 30 Jul 2026 17:57:33 -0400 Subject: [PATCH 4/5] feat(e1-round2): per-call cost ledger + margin dashboard (sub-unit 4) New grey_two.revenue_events table (append-only, FDQ-52 posture -- INSERT+SELECT only, UPDATE/DELETE/TRUNCATE revoked): one row per settled payment, channel x offering x revenueUsd. Migration authored (supabase/migrations/20260730150000_...), NOT applied -- Forces-lane canonical-path application + applied_migrations.md ledger entry is outside this diff, matching the repo's established migration-apply discipline (every prior grey_two migration was psql-applied by Forces, never by Kov). grey-core gets its first WRITE repo on HandlerDeps (revenueEvents) -- everything else stays cache-read (M3's posture, unchanged). Written at every x402-channel settlement point (offerings.ts's 7 normal routes, trustRung.ts when enabled, mcp.ts's tools/call paid path), fail-open: a ledger write failure never costs the buyer their already-paid-for response (try/catch + warn, same posture throughout). ACP is NOT wired this round -- explicitly out of scope, consistent with sub-units 2/3's established ACP boundary. Margin side: computeMarginReport() in grey-pipeline is a pure, fixture-testable aggregation (mirrors VerificationsRepo.getMonthly CostSummary's fetch-then-reduce-in-JS convention) -- revenue attributed per channel x offering (the ledger's real data); cost attributed per offering only, not per channel, with an explicit scoping note on why (compute cost is channel-agnostic; a true channel split needs either end-to-end channel plumbing through cacheOrLive into the pipeline's persistence layer, or an allocation methodology -- both judgment calls beyond this pass). Satisfies the E1->E2 gate's literal wording ("realized margin on LIVE_ALLOWED offerings", an offering-level metric) without inventing a cost-split model. New `pnpm -F @grey/core margin-report [-- --days N]` CLI (mirrors Bion's `pnpm auto-report` shape) prints revenue/cost/margin per offering, channel breakdown inline. EXPANSION-E1-ROUND2-KOV-directive.md sub-unit 4 (was E1-F). --- packages/grey-core/package.json | 3 +- packages/grey-core/scripts/margin-report.ts | 59 ++++++++++ packages/grey-core/src/deps/index.ts | 8 ++ packages/grey-core/src/server/routes/mcp.ts | 14 +++ .../grey-core/src/server/routes/offerings.ts | 11 ++ .../grey-core/src/server/routes/trustRung.ts | 14 +++ packages/grey-core/test/_helpers.ts | 9 ++ packages/grey-core/test/probes.test.ts | 1 + packages/grey-core/test/revenueLedger.test.ts | 108 +++++++++++++++++ packages/grey-pipeline/src/index.ts | 4 + .../src/persistence/repositories.ts | 110 +++++++++++++++++- .../grey-pipeline/src/persistence/schema.ts | 26 ++++- .../grey-pipeline/test/marginReport.test.ts | 54 +++++++++ ...0150000_create_grey_two_revenue_events.sql | 36 ++++++ 14 files changed, 454 insertions(+), 3 deletions(-) create mode 100644 packages/grey-core/scripts/margin-report.ts create mode 100644 packages/grey-core/test/revenueLedger.test.ts create mode 100644 packages/grey-pipeline/test/marginReport.test.ts create mode 100644 supabase/migrations/20260730150000_create_grey_two_revenue_events.sql diff --git a/packages/grey-core/package.json b/packages/grey-core/package.json index 691f30e..e2b54a4 100644 --- a/packages/grey-core/package.json +++ b/packages/grey-core/package.json @@ -23,7 +23,8 @@ "start": "tsx src/start.ts", "smoke": "tsx scripts/smoke.ts", "dist-boot-smoke": "tsx scripts/dist-boot-smoke.ts", - "parity-diff": "tsx scripts/parity-diff.ts" + "parity-diff": "tsx scripts/parity-diff.ts", + "margin-report": "tsx scripts/margin-report.ts" }, "dependencies": { "@grey/pipeline": "workspace:*", diff --git a/packages/grey-core/scripts/margin-report.ts b/packages/grey-core/scripts/margin-report.ts new file mode 100644 index 0000000..92e3a33 --- /dev/null +++ b/packages/grey-core/scripts/margin-report.ts @@ -0,0 +1,59 @@ +// E1-F margin dashboard (Expansion Round 2, sub-unit 4) — NOT in CI, real DB only. Reads the +// revenue_events ledger (grey-core's route/MCP layer writes these at settlement) + cost_events +// (the live pipeline's existing telemetry) and prints realized margin per offering, with revenue +// broken out per channel. Mirrors Bion's `pnpm auto-report` CLI shape (plain aggregate print, no +// framework) and grey-core's own scripts/smoke.ts conventions (real GREY_DATABASE_URL, no CI). +// +// Usage: +// pnpm -F @grey/core margin-report [-- --days 7] +import { createDeps, MarginRepo } from '@grey/pipeline'; + +function arg(name: string): string | undefined { + const i = process.argv.indexOf(`--${name}`); + return i >= 0 ? process.argv[i + 1] : undefined; +} + +async function main(): Promise { + const days = Number(arg('days') ?? '30'); + const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + + const { db } = createDeps({ databaseUrl: process.env.GREY_DATABASE_URL ?? '' }); + const margin = new MarginRepo(db); + const report = await margin.getMarginReport(since); + + console.log(`GREY MARGIN REPORT — last ${days}d (since ${since.toISOString()})`); + console.log('─'.repeat(60)); + if (report.length === 0) { + console.log('(no revenue or cost activity in this window)'); + return; + } + + let totalRevenue = 0; + let totalCost = 0; + for (const row of report) { + totalRevenue += row.totalRevenueUsd; + totalCost += row.totalCostUsd; + const channels = Object.entries(row.revenueByChannelUsd) + .map(([ch, usd]) => `${ch}=$${usd.toFixed(4)}`) + .join(' '); + const marginFlag = row.realizedMarginUsd >= 0 ? '+' : ''; + console.log( + `${row.offering.padEnd(24)} revenue=$${row.totalRevenueUsd.toFixed(4).padStart(9)} ` + + `cost=$${row.totalCostUsd.toFixed(4).padStart(9)} ` + + `margin=${marginFlag}$${row.realizedMarginUsd.toFixed(4)} [${channels || 'no revenue yet'}]`, + ); + } + console.log('─'.repeat(60)); + console.log( + `TOTAL revenue=$${totalRevenue.toFixed(4)} cost=$${totalCost.toFixed(4)} ` + + `margin=$${(totalRevenue - totalCost).toFixed(4)}`, + ); +} + +main().catch((err: unknown) => { + console.error( + 'margin-report: fatal:', + err instanceof Error ? (err.stack ?? err.message) : String(err), + ); + process.exit(1); +}); diff --git a/packages/grey-core/src/deps/index.ts b/packages/grey-core/src/deps/index.ts index e31842c..003a8ba 100644 --- a/packages/grey-core/src/deps/index.ts +++ b/packages/grey-core/src/deps/index.ts @@ -10,6 +10,7 @@ import { WhitepapersRepo, VerificationsRepo, ClaimsRepo, + RevenueEventsRepo, type GreyDb, type Logger, type PipelineDeps, @@ -34,6 +35,12 @@ export interface HandlerDeps { whitepapers: WhitepapersRepo; verifications: VerificationsRepo; claims: ClaimsRepo; + /** E1-F: append-only revenue ledger, written by the route/MCP layer after settle() succeeds + * (offerings.ts, trustRung.ts, mcp.ts). The only WRITE repo on HandlerDeps — everything else + * here is cache-read (M3's "cache-read-only" posture, unchanged for whitepapers/verifications/ + * claims). See packages/grey-pipeline/src/persistence/repositories.ts's MarginRepo for the + * read/aggregation side. */ + revenueEvents: RevenueEventsRepo; logger: Logger; /** Injectable clock for deterministic timestamps in tests. */ clock: () => Date; @@ -84,6 +91,7 @@ export function createHandlerDeps(env: CreateHandlerDepsEnv = {}): HandlerDeps { whitepapers: new WhitepapersRepo(db), verifications: new VerificationsRepo(db), claims: new ClaimsRepo(db), + revenueEvents: new RevenueEventsRepo(db), logger: createLogger({ component: 'grey-core' }), clock: env.clock ?? ((): Date => new Date()), config: { diff --git a/packages/grey-core/src/server/routes/mcp.ts b/packages/grey-core/src/server/routes/mcp.ts index 2dd47fa..b864f01 100644 --- a/packages/grey-core/src/server/routes/mcp.ts +++ b/packages/grey-core/src/server/routes/mcp.ts @@ -184,6 +184,20 @@ export function registerMcpRoute( reply.send(ok(id, textResult({ error: outcome.reason }, true))); return; } + // E1-F: settlement just succeeded above — record revenue now, same fail-open posture as + // the HTTP routes (a ledger write failure must never cost the buyer their paid response). + try { + await deps.revenueEvents.create({ + channel: 'x402', + offering: slug, + revenueUsd: priceUsdFor(paidSlug), + }); + } catch (err) { + deps.logger.warn('revenue ledger write failed (non-fatal)', { + slug, + error: (err as Error).message, + }); + } } const start = deps.clock().getTime(); diff --git a/packages/grey-core/src/server/routes/offerings.ts b/packages/grey-core/src/server/routes/offerings.ts index 38cf3cb..0e4ec0c 100644 --- a/packages/grey-core/src/server/routes/offerings.ts +++ b/packages/grey-core/src/server/routes/offerings.ts @@ -35,6 +35,17 @@ export function registerOfferingRoutes( }, async (req, reply) => { const start = deps.clock().getTime(); + // E1-F: the x402PreHandler gate already settled payment before this handler runs (402/502 + // on any failure never reaches here) — record revenue now, not speculatively. A ledger + // write failure must never cost the buyer their already-paid-for response (fail open, log). + try { + await deps.revenueEvents.create({ channel: 'x402', offering: slug, revenueUsd: priceUsdFor(slug) }); + } catch (err) { + deps.logger.warn('revenue ledger write failed (non-fatal)', { + slug, + error: (err as Error).message, + }); + } const result = await offeringHandlers[slug]({ offeringId: slug, requirement: req.body }, deps); const env = buildEnvelope({ offering: slug, diff --git a/packages/grey-core/src/server/routes/trustRung.ts b/packages/grey-core/src/server/routes/trustRung.ts index 7a773f1..b1abefc 100644 --- a/packages/grey-core/src/server/routes/trustRung.ts +++ b/packages/grey-core/src/server/routes/trustRung.ts @@ -27,6 +27,20 @@ export function registerTrustRungRoute( }, async (req, reply) => { const start = deps.clock().getTime(); + // E1-F: same fail-open ledger posture as offerings.ts — settlement already happened in the + // preHandler; a ledger write failure must never cost the buyer their paid-for response. + try { + await deps.revenueEvents.create({ + channel: 'x402', + offering: TRUST_RUNG_SLUG, + revenueUsd: trustRungPriceUsd(), + }); + } catch (err) { + deps.logger.warn('revenue ledger write failed (non-fatal)', { + slug: TRUST_RUNG_SLUG, + error: (err as Error).message, + }); + } const result = await offeringHandlers[TRUST_RUNG_SLUG]( { offeringId: TRUST_RUNG_SLUG, requirement: req.body }, deps, diff --git a/packages/grey-core/test/_helpers.ts b/packages/grey-core/test/_helpers.ts index e63cd72..0b78639 100644 --- a/packages/grey-core/test/_helpers.ts +++ b/packages/grey-core/test/_helpers.ts @@ -92,6 +92,8 @@ export interface RepoStubs { /** M3.5: discovery result for the cache-miss live path. Default null → cacheOrLive returns the * typed-empty miss sentinel (preserves the M3 cache-miss test expectations). */ discover?: TieredDiscoveryResult | null; + /** E1-F: pass an array to capture every revenueEvents.create() call for assertions. */ + revenueEventsSink?: Array<{ channel: string; offering: string; revenueUsd: number }>; } export function fakeDeps(stubs: RepoStubs = {}): HandlerDeps { @@ -118,11 +120,18 @@ export function fakeDeps(stubs: RepoStubs = {}): HandlerDeps { const discovery = { discover: async (): Promise => stubs.discover ?? null, }; + const revenueEvents = { + create: async (data: { channel: string; offering: string; revenueUsd: number }): Promise => { + stubs.revenueEventsSink?.push(data); + return { id: 'revenue-test', settledAt: new Date(), requestId: null, ...data }; + }, + }; return { db: {} as HandlerDeps['db'], whitepapers: whitepapers as unknown as HandlerDeps['whitepapers'], verifications: verifications as unknown as HandlerDeps['verifications'], claims: claims as unknown as HandlerDeps['claims'], + revenueEvents: revenueEvents as unknown as HandlerDeps['revenueEvents'], logger: logger as unknown as HandlerDeps['logger'], clock: () => new Date('2026-06-14T12:00:00.000Z'), config: TEST_CONFIG, diff --git a/packages/grey-core/test/probes.test.ts b/packages/grey-core/test/probes.test.ts index 967346a..87a7675 100644 --- a/packages/grey-core/test/probes.test.ts +++ b/packages/grey-core/test/probes.test.ts @@ -21,6 +21,7 @@ function fakeDeps(overrides: Partial = {}): HandlerDeps { whitepapers: {} as HandlerDeps['whitepapers'], verifications: {} as HandlerDeps['verifications'], claims: {} as HandlerDeps['claims'], + revenueEvents: {} as HandlerDeps['revenueEvents'], logger: logger as unknown as HandlerDeps['logger'], clock: () => new Date('2026-06-14T00:00:00.000Z'), config: CONFIG, diff --git a/packages/grey-core/test/revenueLedger.test.ts b/packages/grey-core/test/revenueLedger.test.ts new file mode 100644 index 0000000..4820fea --- /dev/null +++ b/packages/grey-core/test/revenueLedger.test.ts @@ -0,0 +1,108 @@ +// E1-F: revenue is recorded at every settlement point on the x402 channel — the normal 7 paid +// HTTP routes, the trust-rung route (when enabled), and the MCP tools/call path. Fail-open: a +// ledger write failure must never cost the buyer their already-paid-for response. +import { describe, it, expect } from 'vitest'; +import { loadX402Config } from '@grey/x402-middleware'; +import { makeApp, passThroughX402 } from './_helpers'; + +const cfg = loadX402Config({ + X402_NETWORK: 'eip155:84532', + BASE_X402_PAY_TO: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8', + BASE_RPC_URL: 'http://127.0.0.1:8545', + X402_RELAYER_PRIVATE_KEY: '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d', +}); +const relayerStubs = { + wallet: { writeContract: async () => ('0x' + 'ee'.repeat(32)) as `0x${string}` }, + publicClient: { + readContract: async () => false, + simulateContract: async () => ({ request: {} }), + waitForTransactionReceipt: async () => ({ status: 'success' as const }), + }, +}; + +describe('revenue ledger — recorded at settlement (E1-F)', () => { + it('a normal paid HTTP route records channel=x402, correct offering + price', async () => { + const sink: Array<{ channel: string; offering: string; revenueUsd: number }> = []; + const app = makeApp({ revenueEventsSink: sink }); // passThroughX402: settlement is "already done" + await app.inject({ + method: 'POST', + url: '/v1/offerings/legitimacy_scan', + payload: { token_address: '0x1111111111111111111111111111111111111111' }, + }); + expect(sink).toHaveLength(1); + expect(sink[0]).toEqual({ channel: 'x402', offering: 'legitimacy_scan', revenueUsd: 0.25 }); + }); + + it('a 402 (no payment, real gate) records NOTHING — settlement never happened', async () => { + const sink: Array = []; + const { makeX402PreHandler } = await import('@grey/x402-middleware'); + const gate = makeX402PreHandler(cfg, relayerStubs); + const app = makeApp({ revenueEventsSink: sink as never }, gate); + const res = await app.inject({ + method: 'POST', + url: '/v1/offerings/legitimacy_scan', + payload: { token_address: '0x1111111111111111111111111111111111111111' }, + }); + expect(res.statusCode).toBe(402); + expect(sink).toHaveLength(0); + }); + + it('the trust-rung route records revenue when enabled', async () => { + const sink: Array<{ channel: string; offering: string; revenueUsd: number }> = []; + // passThroughX402 here too — same "settlement already handled" convention as the first test + // above; makeTrustRungPreHandler's own verify/settle gating is covered by the 402-records- + // nothing test and by x402-middleware's own trustRung.test.ts. + const app = makeApp({ revenueEventsSink: sink }, passThroughX402, { + trustRungEnabled: true, + trustRungPreHandler: passThroughX402, + }); + await app.inject({ + method: 'POST', + url: '/v1/offerings/legitimacy_scan_trust_rung', + payload: { token_address: '0x1111111111111111111111111111111111111111' }, + }); + expect(sink).toEqual([ + { channel: 'x402', offering: 'legitimacy_scan_trust_rung', revenueUsd: 0.1 }, + ]); + }); + + it('MCP tools/call records revenue only after a real settle() success, not on the payment-required leg', async () => { + const sink: Array<{ channel: string; offering: string; revenueUsd: number }> = []; + const app = makeApp({ revenueEventsSink: sink }, undefined, { + mcp: { x402Config: cfg, ...relayerStubs }, + }); + // Leg 1: no payment -> isError, no settlement, no revenue. + await app.inject({ + method: 'POST', + url: '/v1/mcp', + payload: { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'verify_whitepaper', + arguments: { token_address: '0x1111111111111111111111111111111111111111' }, + }, + }, + }); + expect(sink).toHaveLength(0); + }); + + it('MCP free tools never write a revenue event', async () => { + const sink: Array = []; + const app = makeApp({ revenueEventsSink: sink as never }, undefined, { + mcp: { x402Config: cfg, ...relayerStubs }, + }); + await app.inject({ + method: 'POST', + url: '/v1/mcp', + payload: { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'daily_greenlight_list', arguments: {} }, + }, + }); + expect(sink).toHaveLength(0); + }); +}); diff --git a/packages/grey-pipeline/src/index.ts b/packages/grey-pipeline/src/index.ts index 3a25ef4..db64f0f 100644 --- a/packages/grey-pipeline/src/index.ts +++ b/packages/grey-pipeline/src/index.ts @@ -49,6 +49,10 @@ export { VerificationsRepo, RequestsRepo, CostEventsRepo, + RevenueEventsRepo, + MarginRepo, + computeMarginReport, + type MarginReportRow, } from './persistence/repositories'; // Constants worth exposing diff --git a/packages/grey-pipeline/src/persistence/repositories.ts b/packages/grey-pipeline/src/persistence/repositories.ts index 4fe2b07..a4c30cf 100644 --- a/packages/grey-pipeline/src/persistence/repositories.ts +++ b/packages/grey-pipeline/src/persistence/repositories.ts @@ -6,7 +6,7 @@ // new-architecture additions (request audit trail + cost telemetry) per the audit. import { eq, and, gte, desc, sql } from 'drizzle-orm'; -import { whitepapers, claims, verifications, requests, costEvents } from './schema'; +import { whitepapers, claims, verifications, requests, costEvents, revenueEvents } from './schema'; import type { WhitepaperRow, WhitepaperInsert, @@ -18,6 +18,8 @@ import type { RequestInsert, CostEventRow, CostEventInsert, + RevenueEventRow, + RevenueEventInsert, } from './schema'; import type { GreyDb } from './client'; @@ -298,3 +300,109 @@ export class CostEventsRepo { return rows[0]; } } + +// ── E1-F: revenue ledger + margin report (Expansion Round 2, sub-unit 4) ── + +export class RevenueEventsRepo { + constructor(private db: GreyDb) {} + + /** One row per settled payment. Written by grey-core's route/MCP layer AFTER settle() succeeds + * — never speculatively, never on a 402/verify failure. */ + async create(data: RevenueEventInsert): Promise { + const rows = await this.db.insert(revenueEvents).values(data).returning(); + return rows[0]; + } +} + +export interface MarginReportRow { + offering: string; + /** Revenue broken out per channel — the ledger's actual channel x offering attribution. */ + revenueByChannelUsd: Record; + totalRevenueUsd: number; + /** + * Compute spend for this offering, channel-agnostic. Scoping note: cost_events has no channel + * dimension (a legitimacy_scan pipeline run costs the same regardless of which channel + * triggered it) — splitting cost by channel would require either plumbing a channel identifier + * all the way through cacheOrLive into the pipeline's persistence layer, or an allocation + * methodology (e.g. proportional to revenue share). Both are judgment calls beyond this pass's + * scope — reported here in aggregate per offering, not invented per channel. + */ + totalCostUsd: number; + /** totalRevenueUsd - totalCostUsd. The E1->E2 gate's "positive realized margin on LIVE_ALLOWED + * offerings" reads this field. CACHE_ONLY offerings trivially show margin == revenue + * (cost_events never has rows for them — Invariant #30). */ + realizedMarginUsd: number; +} + +/** + * Pure aggregation — takes already-fetched rows (mirrors VerificationsRepo.getMonthlyCostSummary's + * fetch-then-reduce-in-JS convention) so it's unit-testable with fixture arrays, no live DB + * required. `costByOffering` keys off `requests.offering` (cost_events joins through requests; + * see the migration's comment for why cost isn't itself channel-split). + */ +export function computeMarginReport( + revenueRows: Array>, + costByOffering: Map, +): MarginReportRow[] { + const byOffering = new Map>(); + for (const row of revenueRows) { + const channels = byOffering.get(row.offering) ?? {}; + channels[row.channel] = (channels[row.channel] ?? 0) + row.revenueUsd; + byOffering.set(row.offering, channels); + } + // Union of offerings that have EITHER revenue or cost — an offering with cost but zero + // settled revenue yet (e.g. mid-rollout) should still surface a (negative) margin, not vanish. + const offerings = new Set([...byOffering.keys(), ...costByOffering.keys()]); + + return [...offerings].sort().map((offering) => { + const revenueByChannelUsd = byOffering.get(offering) ?? {}; + const totalRevenueUsd = Object.values(revenueByChannelUsd).reduce((a, b) => a + b, 0); + const totalCostUsd = costByOffering.get(offering) ?? 0; + return { + offering, + revenueByChannelUsd, + totalRevenueUsd, + totalCostUsd, + realizedMarginUsd: totalRevenueUsd - totalCostUsd, + }; + }); +} + +export class MarginRepo { + constructor(private db: GreyDb) {} + + /** All revenue_events rows (channel, offering, revenueUsd) since `since`. */ + async getRevenueRows( + since: Date, + ): Promise>> { + return this.db + .select({ + channel: revenueEvents.channel, + offering: revenueEvents.offering, + revenueUsd: revenueEvents.revenueUsd, + }) + .from(revenueEvents) + .where(gte(revenueEvents.settledAt, since)); + } + + /** Total compute spend per offering since `since`, via cost_events JOIN requests (requests is + * where `offering` lives — cost_events itself carries no offering column). */ + async getCostByOffering(since: Date): Promise> { + const rows = await this.db + .select({ offering: requests.offering, costUsd: costEvents.costUsd }) + .from(costEvents) + .innerJoin(requests, eq(costEvents.requestId, requests.id)) + .where(gte(costEvents.createdAt, since)); + const out = new Map(); + for (const r of rows) out.set(r.offering, (out.get(r.offering) ?? 0) + r.costUsd); + return out; + } + + async getMarginReport(since: Date): Promise { + const [revenueRows, costByOffering] = await Promise.all([ + this.getRevenueRows(since), + this.getCostByOffering(since), + ]); + return computeMarginReport(revenueRows, costByOffering); + } +} diff --git a/packages/grey-pipeline/src/persistence/schema.ts b/packages/grey-pipeline/src/persistence/schema.ts index 93c08f6..40bb4a0 100644 --- a/packages/grey-pipeline/src/persistence/schema.ts +++ b/packages/grey-pipeline/src/persistence/schema.ts @@ -78,7 +78,9 @@ export const verifications = greyTwo.table( verifiedClaims: integer('verified_claims').notNull().default(0), reportJson: jsonb('report_json').$type>(), llmTokensUsed: integer('llm_tokens_used').notNull().default(0), - computeCostUsd: numeric('compute_cost_usd', { precision: 12, scale: 6, mode: 'number' }).notNull().default(0), + computeCostUsd: numeric('compute_cost_usd', { precision: 12, scale: 6, mode: 'number' }) + .notNull() + .default(0), triggerSource: text('trigger_source'), cacheHit: boolean('cache_hit').default(false), l1DurationMs: integer('l1_duration_ms').default(0), @@ -146,6 +148,26 @@ export const costEvents = greyTwo.table( ], ); +// ── revenue_events (E1-F; one row per settled payment, channel x offering) ── +export const revenueEvents = greyTwo.table( + 'revenue_events', + { + id: uuid('id').defaultRandom().primaryKey(), + requestId: uuid('request_id').references(() => requests.id, { onDelete: 'set null' }), + channel: text('channel').notNull(), // 'x402' | 'acp' (@grey/schemas/pricing Channel) + offering: text('offering').notNull(), // OfferingSlug + revenueUsd: numeric('revenue_usd', { precision: 12, scale: 6, mode: 'number' }) + .notNull() + .default(0), + settledAt: timestamp('settled_at', { withTimezone: true }).defaultNow().notNull(), + }, + (t) => [ + index('grey_revenue_channel_offering_idx').on(t.channel, t.offering), + index('grey_revenue_settled_at_idx').on(t.settledAt), + index('grey_revenue_request_idx').on(t.requestId), + ], +); + export type WhitepaperRow = typeof whitepapers.$inferSelect; export type WhitepaperInsert = typeof whitepapers.$inferInsert; export type RequestRow = typeof requests.$inferSelect; @@ -156,3 +178,5 @@ export type ClaimRow = typeof claims.$inferSelect; export type ClaimInsert = typeof claims.$inferInsert; export type CostEventRow = typeof costEvents.$inferSelect; export type CostEventInsert = typeof costEvents.$inferInsert; +export type RevenueEventRow = typeof revenueEvents.$inferSelect; +export type RevenueEventInsert = typeof revenueEvents.$inferInsert; diff --git a/packages/grey-pipeline/test/marginReport.test.ts b/packages/grey-pipeline/test/marginReport.test.ts new file mode 100644 index 0000000..813934a --- /dev/null +++ b/packages/grey-pipeline/test/marginReport.test.ts @@ -0,0 +1,54 @@ +// E1-F: pure margin-report aggregation (Expansion Round 2, sub-unit 4). Unit-tested against +// fixture arrays — no live DB — mirroring VerificationsRepo.getMonthlyCostSummary's +// fetch-then-reduce-in-JS convention. +import { describe, it, expect } from 'vitest'; +import { computeMarginReport } from '../src/persistence/repositories'; + +describe('computeMarginReport — E1-F margin instrumentation', () => { + it('attributes revenue per channel x offering, cost per offering, and computes margin = revenue - cost', () => { + const revenueRows = [ + { channel: 'x402', offering: 'legitimacy_scan', revenueUsd: 0.25 }, + { channel: 'x402', offering: 'legitimacy_scan', revenueUsd: 0.25 }, + { channel: 'acp', offering: 'legitimacy_scan', revenueUsd: 0.25 }, + ]; + const costByOffering = new Map([['legitimacy_scan', 0.1]]); + + const report = computeMarginReport(revenueRows, costByOffering); + + expect(report).toHaveLength(1); + const row = report[0]; + expect(row.offering).toBe('legitimacy_scan'); + expect(row.revenueByChannelUsd).toEqual({ x402: 0.5, acp: 0.25 }); + expect(row.totalRevenueUsd).toBeCloseTo(0.75, 6); + expect(row.totalCostUsd).toBe(0.1); + expect(row.realizedMarginUsd).toBeCloseTo(0.65, 6); + }); + + it('CACHE_ONLY offerings show margin == revenue (zero cost, no cost_events rows exist for them)', () => { + const revenueRows = [{ channel: 'x402', offering: 'quick_protocol_facts', revenueUsd: 0.3 }]; + const report = computeMarginReport(revenueRows, new Map()); + expect(report[0].totalCostUsd).toBe(0); + expect(report[0].realizedMarginUsd).toBe(0.3); + }); + + it('an offering with cost but no settled revenue yet still surfaces (negative margin), not dropped', () => { + const costByOffering = new Map([['verify_full_tech', 3.5]]); + const report = computeMarginReport([], costByOffering); + expect(report).toHaveLength(1); + expect(report[0].totalRevenueUsd).toBe(0); + expect(report[0].realizedMarginUsd).toBe(-3.5); + }); + + it('is sorted by offering slug for stable output', () => { + const revenueRows = [ + { channel: 'x402', offering: 'verify_whitepaper', revenueUsd: 1.5 }, + { channel: 'x402', offering: 'claim_extraction', revenueUsd: 0.75 }, + ]; + const report = computeMarginReport(revenueRows, new Map()); + expect(report.map((r) => r.offering)).toEqual(['claim_extraction', 'verify_whitepaper']); + }); + + it('returns an empty report for no activity', () => { + expect(computeMarginReport([], new Map())).toEqual([]); + }); +}); diff --git a/supabase/migrations/20260730150000_create_grey_two_revenue_events.sql b/supabase/migrations/20260730150000_create_grey_two_revenue_events.sql new file mode 100644 index 0000000..0e79946 --- /dev/null +++ b/supabase/migrations/20260730150000_create_grey_two_revenue_events.sql @@ -0,0 +1,36 @@ +-- 20260730150000_create_grey_two_revenue_events.sql +-- E1-F (Expansion Round 2, sub-unit 4) — margin instrumentation, revenue side. One new +-- APPEND-ONLY grey_two table: one row per settled payment, attributed by channel x offering +-- (spec S2.6/S3 E1-F). Pairs with the existing grey_two.cost_events (compute spend) to compute +-- realized margin; cost_events has no channel dimension (compute cost is channel-agnostic -- +-- see packages/grey-pipeline/src/persistence/repositories.ts's computeMarginReport() for the +-- deliberate scoping note on why cost is attributed per-offering, not per-channel, this round). +-- Zero contact with any existing grey_two table or any wpv_*. +-- +-- FDQ-52/FDQ-65 GRANT POSTURE: append-only, same as cost_events/sweep_log/refuel_log -- the +-- runtime role (grey_pipeline_rw) gets INSERT+SELECT only. grey_two's ALTER DEFAULT PRIVILEGES +-- auto-grants UPDATE/DELETE to grey_pipeline_rw on every new table; this REVOKEs them explicitly +-- inside the same transaction as the CREATE TABLE (learned from refuel_log's FDQ-52 omission -- +-- do NOT split the REVOKE into a later corrective migration). +-- +-- EXECUTION: canonical path ONLY -- psql + WPV_DATABASE_URL, Forces-lane, then a ledger entry in +-- supabase/applied_migrations.md. NOT applied by this diff -- authored for Forces' apply. + +CREATE TABLE grey_two.revenue_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + request_id uuid REFERENCES grey_two.requests(id) ON DELETE SET NULL, + channel text NOT NULL, -- x402|acp (@grey/schemas/pricing Channel) + offering text NOT NULL, -- OfferingSlug + revenue_usd numeric(12,6) NOT NULL DEFAULT 0, + settled_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX grey_revenue_channel_offering_idx ON grey_two.revenue_events (channel, offering); +CREATE INDEX grey_revenue_settled_at_idx ON grey_two.revenue_events (settled_at); +CREATE INDEX grey_revenue_request_idx ON grey_two.revenue_events (request_id); + +GRANT USAGE ON SCHEMA grey_two TO grey_pipeline_rw; +GRANT SELECT, INSERT ON grey_two.revenue_events TO grey_pipeline_rw; +REVOKE UPDATE, DELETE, TRUNCATE ON grey_two.revenue_events FROM grey_pipeline_rw; + +COMMENT ON TABLE grey_two.revenue_events IS + 'E1-F: one row per settled payment (channel x offering x revenueUsd), written at settlement time by grey-core (x402 HTTP routes, the MCP surface) after a successful settle(). APPEND-ONLY for the runtime role (INSERT+SELECT; mirrors cost_events/refuel_log FDQ-52 posture).'; From fbf06f7f35e5387e4fef0ea2abde929cd485f967 Mon Sep 17 00:00:00 2001 From: Mayakovsky Date: Sun, 2 Aug 2026 11:01:40 -0400 Subject: [PATCH 5/5] fix(e1-merge-prep): mark 2 offerings not-yet-offered, close 3-surface leak Forces ruling (merge-prep session): daily_greenlight_list and scam_alert_feed are not priced gaps to fill -- they're not being offered yet, period, pending daily-customer usage data. Don't invent a price; mark them not-yet-offered. Task 1: OfferingPricing gets an explicit `enabled: boolean` field. Both slugs are enabled:false (canonicalUsd stays null -- not a value to invent). Table reads as 7 priced + 2 disabled = 9 (the trust rung sits outside this count, gated by its own separate runtime flag, not conflated with this static one). EvaluationKit's `discoverable` now derives from PRICING_TABLE.enabled instead of being hardcoded true -- one field, every listing surface reads it. Task 2: verified all three surfaces, fixed what didn't already follow from the discoverable change: - GET /v1/discovery/services: already correct (existing .filter(kit => kit.discoverable) picked up the change for free). - GET /v1/discovery/services/:slug: WAS NOT checking discoverable at all -- would have 200'd with the full evaluation artifact. Fixed to 404, same as the list. - 402 extra.bazaar: verified structurally unreachable already (these two are free GET routes with no x402PreHandler attached, and isPaidSlug() already excludes them) -- added an explicit slugFromUrl() test rather than leaving it merely assumed. - MCP tools/list + tools/call: MCP_TOOL_SLUGS now filters on isEnabled() -- excludes them from listing AND from the "unknown or unlisted tool" dispatch tools/call uses, so they can't be called by name either. - ACP: confirmed NOT registered there (registerOffering loop is keyed off the same 7-slug PAID_SLUGS that has never included either free resource) -- pre-existing M5 behavior, unaffected by any of this. EXPANSION-E1-MERGE-PREP-KOV-directive.md Tasks 1-2. --- .../x402-middleware/test/preHandler.test.ts | 38 +++++++-- .../grey-core/src/server/routes/discovery.ts | 11 ++- packages/grey-core/src/server/routes/mcp.ts | 14 +++- packages/grey-core/test/discovery.test.ts | 18 ++++- packages/grey-core/test/mcp.test.ts | 33 +++++--- packages/grey-core/test/revenueLedger.test.ts | 2 +- packages/grey-core/test/trustRung.test.ts | 4 +- .../grey-schemas/src/evaluationKit/build.ts | 6 +- packages/grey-schemas/src/pricing/index.ts | 1 + packages/grey-schemas/src/pricing/table.ts | 79 ++++++++++++++++--- packages/grey-schemas/src/pricing/types.ts | 10 ++- .../grey-schemas/test/evaluationKit.test.ts | 8 +- packages/grey-schemas/test/pricing.test.ts | 9 +++ 13 files changed, 188 insertions(+), 45 deletions(-) diff --git a/adapters/x402-middleware/test/preHandler.test.ts b/adapters/x402-middleware/test/preHandler.test.ts index 4e495f7..8b1a136 100644 --- a/adapters/x402-middleware/test/preHandler.test.ts +++ b/adapters/x402-middleware/test/preHandler.test.ts @@ -34,13 +34,24 @@ function reqReply(url: string, header?: string) { }, }; const req = { url, headers: header ? { 'x-payment': header } : {} }; - return { req: req as unknown as FastifyRequest, reply: reply as unknown as FastifyReply, m: reply }; + return { + req: req as unknown as FastifyRequest, + reply: reply as unknown as FastifyReply, + m: reply, + }; } // Cast to a plain 2-arg callable — the Fastify hook type carries a `this: FastifyInstance` // context this handler never uses, so a direct call would trip TS2684. -function gate(clients: { wallet: ReturnType; publicClient: ReturnType }) { - const h = makeX402PreHandler(TEST_CFG, { wallet: clients.wallet, publicClient: clients.publicClient, now }); +function gate(clients: { + wallet: ReturnType; + publicClient: ReturnType; +}) { + const h = makeX402PreHandler(TEST_CFG, { + wallet: clients.wallet, + publicClient: clients.publicClient, + now, + }); return h as unknown as (req: FastifyRequest, reply: FastifyReply) => Promise; } @@ -53,6 +64,13 @@ describe('slugFromUrl', () => { expect(slugFromUrl('/v1/resources/scam_alert_feed')).toBeNull(); expect(slugFromUrl('/v1/offerings/not_a_slug')).toBeNull(); }); + + it('merge-prep Task 2 check: not-yet-offered offerings can never reach a 402/buildPaymentRequirements body — they are mounted (if at all) at /v1/resources/*, which x402PreHandler never gates, and even under /v1/offerings/* would resolve to no slug (isPaidSlug false)', () => { + for (const slug of ['daily_greenlight_list', 'scam_alert_feed']) { + expect(slugFromUrl(`/v1/resources/${slug}`), slug).toBeNull(); + expect(slugFromUrl(`/v1/offerings/${slug}`), slug).toBeNull(); + } + }); }); describe('makeX402PreHandler — orchestration', () => { @@ -60,7 +78,9 @@ describe('makeX402PreHandler — orchestration', () => { const { req, reply, m } = reqReply('/v1/offerings/legitimacy_scan'); await gate({ wallet: mockWallet(), publicClient: mockPublicClient() })(req, reply); expect(m.statusCode).toBe(402); - expect((m.body as { accepts: { maxAmountRequired: string }[] }).accepts[0].maxAmountRequired).toBe('250000'); + expect( + (m.body as { accepts: { maxAmountRequired: string }[] }).accepts[0].maxAmountRequired, + ).toBe('250000'); }); it('402 on a malformed header', async () => { @@ -72,7 +92,10 @@ describe('makeX402PreHandler — orchestration', () => { it('402 on a verify failure (underpayment)', async () => { const { header } = await signedPayment(TEST_CFG, { value: 1n }); const { req, reply, m } = reqReply('/v1/offerings/legitimacy_scan', header); - await gate({ wallet: mockWallet(), publicClient: mockPublicClient({ used: false }) })(req, reply); + await gate({ wallet: mockWallet(), publicClient: mockPublicClient({ used: false }) })( + req, + reply, + ); expect(m.statusCode).toBe(402); expect((m.body as { error: string }).error).toBe('underpayment'); }); @@ -107,7 +130,10 @@ describe('makeX402PreHandler — orchestration', () => { const { header } = await signedPayment(TEST_CFG); const { req, reply, m } = reqReply('/v1/offerings/legitimacy_scan', header); const wallet = mockWallet(); - await gate({ wallet, publicClient: mockPublicClient({ used: false, simRevert: true }) })(req, reply); + await gate({ wallet, publicClient: mockPublicClient({ used: false, simRevert: true }) })( + req, + reply, + ); expect(m.statusCode).toBe(402); // clean 402, not a 502 after a wasted reverted tx expect(wallet.calls).toHaveLength(0); // nothing broadcast → zero relayer gas expect(m.headers['X-PAYMENT-RESPONSE']).toBeUndefined(); diff --git a/packages/grey-core/src/server/routes/discovery.ts b/packages/grey-core/src/server/routes/discovery.ts index 532818c..72c702d 100644 --- a/packages/grey-core/src/server/routes/discovery.ts +++ b/packages/grey-core/src/server/routes/discovery.ts @@ -40,6 +40,15 @@ export function registerDiscoveryRoutes(app: FastifyInstance, opts: DiscoveryRou } // E1-C: the detail/capability page carries the evaluation artifact (adds a sample); the list // route above stays lean (no sample) — this is the only difference between the two. - reply.send(buildEvaluationArtifact(slug as OfferingSlug)); + const artifact = buildEvaluationArtifact(slug as OfferingSlug); + // Merge-prep: listableSlugs() only excludes the trust rung when disabled — a not-yet-offered + // offering (enabled:false in PRICING_TABLE) is still registry-present, so the DETAIL route + // needs its own discoverable check too, or it'd 200 with a full artifact for something the + // list route already hides. Same field, same source, second surface. + if (!artifact.discoverable) { + reply.code(404).send({ error: `not found or not discoverable: ${slug}` }); + return; + } + reply.send(artifact); }); } diff --git a/packages/grey-core/src/server/routes/mcp.ts b/packages/grey-core/src/server/routes/mcp.ts index b864f01..ada47ab 100644 --- a/packages/grey-core/src/server/routes/mcp.ts +++ b/packages/grey-core/src/server/routes/mcp.ts @@ -19,6 +19,7 @@ import { randomUUID } from 'node:crypto'; import type { FastifyInstance } from 'fastify'; import type { OfferingSlug, PaidOfferingSlug } from '@grey/schemas/responses'; import { buildEvaluationKit } from '@grey/schemas/evaluationKit'; +import { isEnabled } from '@grey/schemas/pricing'; import { isPaidSlug, priceAtomicFor, @@ -37,7 +38,10 @@ import { PAID } from './offerings'; import { FREE } from './resources'; const PROTOCOL_VERSION = '2026-03-26'; -const MCP_TOOL_SLUGS: OfferingSlug[] = [...PAID, ...FREE]; +// Merge-prep ruling: a not-yet-offered slug (PRICING_TABLE.enabled === false — currently +// daily_greenlight_list, scam_alert_feed) is filtered out HERE, so it's excluded from both +// tools/list AND tools/call's "unknown or unlisted tool" check (the same array gates both). +const MCP_TOOL_SLUGS: OfferingSlug[] = [...PAID, ...FREE].filter((slug) => isEnabled(slug)); export interface McpRouteDeps { x402Config: X402Config; @@ -119,9 +123,11 @@ export function registerMcpRoute( } if (method === 'tools/list') { - // Registry-driven, same gating discipline as discovery.ts: only the normal 7 paid + 2 free - // slugs are ever listed. The trust rung is never in MCP_TOOL_SLUGS regardless of its own - // disable flag — E1-D's rail doesn't get a separate exposure decision from B-1. + // Registry-driven, same gating discipline as discovery.ts: only enabled offerings are ever + // listed (MCP_TOOL_SLUGS already filters out not-yet-offered + excludes the trust rung + // entirely — the trust rung is never a member of PAID/FREE in the first place, so it's not + // in MCP_TOOL_SLUGS regardless of its own disable flag; E1-D's rail doesn't get a separate + // exposure decision from B-1). reply.send(ok(id, { tools: MCP_TOOL_SLUGS.map(toolDef) })); return; } diff --git a/packages/grey-core/test/discovery.test.ts b/packages/grey-core/test/discovery.test.ts index 37af2b1..6611f8b 100644 --- a/packages/grey-core/test/discovery.test.ts +++ b/packages/grey-core/test/discovery.test.ts @@ -3,16 +3,30 @@ import { describe, it, expect } from 'vitest'; import { makeApp } from './_helpers'; describe('discovery routes — Bazaar index (E1-B, Invariant #33)', () => { - it('GET /v1/discovery/services lists all 9 offerings, discoverable and free (no x402 gate)', async () => { + it('GET /v1/discovery/services lists the 7 enabled offerings, discoverable and free (no x402 gate)', async () => { const app = makeApp(); const res = await app.inject({ method: 'GET', url: '/v1/discovery/services' }); expect(res.statusCode).toBe(200); const body = res.json() as { services: Array<{ slug: string; discoverable: boolean }> }; - expect(body.services).toHaveLength(9); + expect(body.services).toHaveLength(7); expect(body.services.every((s) => s.discoverable)).toBe(true); expect(body.services.map((s) => s.slug)).toContain('legitimacy_scan'); }); + it('merge-prep ruling: not-yet-offered offerings (enabled:false) are absent from the list AND their own detail page 404s', async () => { + const app = makeApp(); + const list = await app.inject({ method: 'GET', url: '/v1/discovery/services' }); + const body = list.json() as { services: Array<{ slug: string }> }; + const slugs = body.services.map((s) => s.slug); + expect(slugs).not.toContain('daily_greenlight_list'); + expect(slugs).not.toContain('scam_alert_feed'); + + for (const slug of ['daily_greenlight_list', 'scam_alert_feed']) { + const detail = await app.inject({ method: 'GET', url: `/v1/discovery/services/${slug}` }); + expect(detail.statusCode, slug).toBe(404); + } + }); + it('GET /v1/discovery/services/:slug returns the full evaluation artifact, incl. a sample (E1-C)', async () => { const app = makeApp(); const res = await app.inject({ diff --git a/packages/grey-core/test/mcp.test.ts b/packages/grey-core/test/mcp.test.ts index a973c03..7541547 100644 --- a/packages/grey-core/test/mcp.test.ts +++ b/packages/grey-core/test/mcp.test.ts @@ -32,14 +32,16 @@ describe('MCP — initialize + tools/list (E1-D)', () => { expect(body.result.capabilities.tools).toBeDefined(); }); - it('tools/list projects the SAME EvaluationKit source as the HTTP surface — 9 tools, never the trust rung', async () => { + it('tools/list projects the SAME EvaluationKit source as the HTTP surface — 7 tools: never the trust rung, never a not-yet-offered offering', async () => { const app = makeApp({}, undefined, { mcp: mcpDeps }); const res = await app.inject({ method: 'POST', url: '/v1/mcp', payload: rpc('tools/list') }); const body = res.json(); - expect(body.result.tools).toHaveLength(9); + expect(body.result.tools).toHaveLength(7); const names = body.result.tools.map((t: { name: string }) => t.name); expect(names).toContain('legitimacy_scan'); expect(names).not.toContain('legitimacy_scan_trust_rung'); + expect(names).not.toContain('daily_greenlight_list'); // merge-prep: not-yet-offered + expect(names).not.toContain('scam_alert_feed'); // merge-prep: not-yet-offered const legit = body.result.tools.find((t: { name: string }) => t.name === 'legitimacy_scan'); expect(legit.inputSchema).toBeTruthy(); expect(typeof legit.description).toBe('string'); @@ -53,18 +55,25 @@ describe('MCP — initialize + tools/list (E1-D)', () => { }); }); +// Note: mcp.ts's `isFree` branch (skip payment for a FREE-list tool) has no live coverage via +// tools/call right now — both FREE offerings (daily_greenlight_list, scam_alert_feed) are +// enabled:false (merge-prep ruling) and are therefore excluded from MCP_TOOL_SLUGS entirely (see +// the not-yet-offered test below). If a future offering ships free+enabled, add a tools/call +// happy-path test against that slug here. describe('MCP — tools/call (E1-D)', () => { - it('a free tool runs with no payment required', async () => { + it('merge-prep ruling: not-yet-offered offerings cannot be called by name, same as the trust rung', async () => { const app = makeApp({}, undefined, { mcp: mcpDeps }); - const res = await app.inject({ - method: 'POST', - url: '/v1/mcp', - payload: rpc('tools/call', { name: 'scam_alert_feed', arguments: {} }), - }); - const body = res.json(); - expect(body.result.isError).toBeFalsy(); - const envelope = JSON.parse(body.result.content[0].text); - expect(envelope.offering).toBe('scam_alert_feed'); + for (const name of ['daily_greenlight_list', 'scam_alert_feed']) { + const res = await app.inject({ + method: 'POST', + url: '/v1/mcp', + payload: rpc('tools/call', { name, arguments: {} }), + }); + const body = res.json(); + expect(body.error, name).toBeDefined(); + expect(body.error.code, name).toBe(-32602); + expect(body.result, name).toBeUndefined(); + } }); it('a paid tool without payment returns isError:true carrying PaymentRequirements', async () => { diff --git a/packages/grey-core/test/revenueLedger.test.ts b/packages/grey-core/test/revenueLedger.test.ts index 4820fea..7f956fe 100644 --- a/packages/grey-core/test/revenueLedger.test.ts +++ b/packages/grey-core/test/revenueLedger.test.ts @@ -88,7 +88,7 @@ describe('revenue ledger — recorded at settlement (E1-F)', () => { expect(sink).toHaveLength(0); }); - it('MCP free tools never write a revenue event', async () => { + it('a not-yet-offered slug (merge-prep: daily_greenlight_list/scam_alert_feed, enabled:false) writes no revenue event — rejected before it ever reaches the payment/handler path', async () => { const sink: Array = []; const app = makeApp({ revenueEventsSink: sink as never }, undefined, { mcp: { x402Config: cfg, ...relayerStubs }, diff --git a/packages/grey-core/test/trustRung.test.ts b/packages/grey-core/test/trustRung.test.ts index 6fde9c9..710cb94 100644 --- a/packages/grey-core/test/trustRung.test.ts +++ b/packages/grey-core/test/trustRung.test.ts @@ -37,7 +37,7 @@ describe('trust rung — unreachable by default (E1-C, Invariant #34, B-1)', () const res = await app.inject({ method: 'GET', url: '/v1/discovery/services' }); const body = res.json() as { services: Array<{ slug: string }> }; expect(body.services.map((s) => s.slug)).not.toContain('legitimacy_scan_trust_rung'); - expect(body.services).toHaveLength(9); + expect(body.services).toHaveLength(7); // 9 built offerings minus the 2 not-yet-offered (merge-prep) }); it('its own discovery/capability detail page also 404s while disabled', async () => { @@ -74,7 +74,7 @@ describe('trust rung — correctly reachable when explicitly enabled (proves the const res = await app.inject({ method: 'GET', url: '/v1/discovery/services' }); const body = res.json() as { services: Array<{ slug: string }> }; expect(body.services.map((s) => s.slug)).toContain('legitimacy_scan_trust_rung'); - expect(body.services).toHaveLength(10); + expect(body.services).toHaveLength(8); // 7 enabled + the trust rung, still minus the 2 not-yet-offered }); it('buildServer throws if trustRungEnabled is true without a trustRungPreHandler (fail closed on misconfiguration)', async () => { diff --git a/packages/grey-schemas/src/evaluationKit/build.ts b/packages/grey-schemas/src/evaluationKit/build.ts index a8a805f..b537bf4 100644 --- a/packages/grey-schemas/src/evaluationKit/build.ts +++ b/packages/grey-schemas/src/evaluationKit/build.ts @@ -121,7 +121,11 @@ export function buildEvaluationKit( return { slug, - discoverable: true, + // Merge-prep ruling: an offering with `enabled: false` (not yet offered, period — e.g. + // daily_greenlight_list/scam_alert_feed) is structurally absent from every Bazaar-facing + // surface (discovery list+detail, 402 extra.bazaar, MCP tools/list) — every one of those + // surfaces filters/gates on THIS field, not a separate flag each has to keep in sync. + discoverable: pricing.enabled, serviceName, tags, description: branding.description, diff --git a/packages/grey-schemas/src/pricing/index.ts b/packages/grey-schemas/src/pricing/index.ts index 9f5f237..9454f77 100644 --- a/packages/grey-schemas/src/pricing/index.ts +++ b/packages/grey-schemas/src/pricing/index.ts @@ -4,6 +4,7 @@ export { PRICING_TABLE, NETWORK_MULTIPLIER, computeClassFor, + isEnabled, networkMultiplierFor, canonicalUsdFor, resolvePriceUsd, diff --git a/packages/grey-schemas/src/pricing/table.ts b/packages/grey-schemas/src/pricing/table.ts index 580d6a4..30bd24f 100644 --- a/packages/grey-schemas/src/pricing/table.ts +++ b/packages/grey-schemas/src/pricing/table.ts @@ -3,43 +3,90 @@ // that file now derives its (byte-identical) output from here instead of holding literals. // // Canonicalizes EXISTING values as-is (E1-A directive): no repricing on this phase. The 7 priced -// values below are carried over unchanged from the prior PRICE_TABLE. `daily_greenlight_list` and -// `scam_alert_feed` have no existing canonical price — canonicalUsd is `null`, flagged here for -// Desktop to source before merge, not invented. +// entries below are carried over unchanged from the prior PRICE_TABLE. +// +// `daily_greenlight_list` and `scam_alert_feed` are `enabled: false` — merge-prep ruling (Forces, +// 2026-07-30 session): these are NOT priced gaps to fill, they are not being offered yet, period, +// pending daily-customer usage data. `canonicalUsd` stays `null` (don't invent a price for +// something not for sale); `enabled: false` is the actual reason, replacing the earlier "UNPRICED, +// flagged for Desktop" framing from the E1-A PR, which is now resolved. Table reads as 7 priced + +// 2 disabled = 9, matching the full handler count (10th is the trust rung, gated separately by +// its own runtime disable flag — see below). import type { OfferingSlug } from '../responses/types'; import type { Channel, ComputeClass, OfferingPricing } from './types'; export const PRICING_TABLE: Record = { // LIVE_ALLOWED — resolve through cacheOrLive on a cache miss (grey-core/src/handlers/index.ts). - legitimacy_scan: { slug: 'legitimacy_scan', canonicalUsd: 0.25, computeClass: 'LIVE_ALLOWED' }, + legitimacy_scan: { + slug: 'legitimacy_scan', + canonicalUsd: 0.25, + computeClass: 'LIVE_ALLOWED', + enabled: true, + }, // CACHE_ONLY, BUILT BUT BLOCKED (E1-C, spec §2.4, Forces ruling B-1, Invariant #34): $0.10 // trust rung. Never live-computed regardless of the disable flag's state — the flag controls - // whether the ROUTE is reachable at all, not this offering's computeClass floor. + // whether the ROUTE is reachable at all, not this offering's computeClass floor. `enabled: true` + // here is deliberate — it IS a real, priced offering; its own separate runtime flag + // (@grey/x402-middleware's trustRungEnabled()) is what gates route/listing reachability, not + // this static table. Don't conflate the two disable mechanisms. legitimacy_scan_trust_rung: { slug: 'legitimacy_scan_trust_rung', canonicalUsd: 0.1, computeClass: 'CACHE_ONLY', + enabled: true, + }, + verify_whitepaper: { + slug: 'verify_whitepaper', + canonicalUsd: 1.5, + computeClass: 'LIVE_ALLOWED', + enabled: true, + }, + verify_full_tech: { + slug: 'verify_full_tech', + canonicalUsd: 3.0, + computeClass: 'LIVE_ALLOWED', + enabled: true, + }, + claim_extraction: { + slug: 'claim_extraction', + canonicalUsd: 0.75, + computeClass: 'LIVE_ALLOWED', + enabled: true, }, - verify_whitepaper: { slug: 'verify_whitepaper', canonicalUsd: 1.5, computeClass: 'LIVE_ALLOWED' }, - verify_full_tech: { slug: 'verify_full_tech', canonicalUsd: 3.0, computeClass: 'LIVE_ALLOWED' }, - claim_extraction: { slug: 'claim_extraction', canonicalUsd: 0.75, computeClass: 'LIVE_ALLOWED' }, // CACHE_ONLY — structurally never reach cacheOrLive today (no call site passes them). - claim_history: { slug: 'claim_history', canonicalUsd: 0.25, computeClass: 'CACHE_ONLY' }, + claim_history: { + slug: 'claim_history', + canonicalUsd: 0.25, + computeClass: 'CACHE_ONLY', + enabled: true, + }, quick_protocol_facts: { slug: 'quick_protocol_facts', canonicalUsd: 0.3, computeClass: 'CACHE_ONLY', + enabled: true, }, - daily_tech_brief: { slug: 'daily_tech_brief', canonicalUsd: 8.0, computeClass: 'CACHE_ONLY' }, - // UNPRICED — flagged for Desktop (E1-A directive); do not invent a number. + daily_tech_brief: { + slug: 'daily_tech_brief', + canonicalUsd: 8.0, + computeClass: 'CACHE_ONLY', + enabled: true, + }, + // NOT YET OFFERED (merge-prep ruling) — no price, not for sale, pending usage data. daily_greenlight_list: { slug: 'daily_greenlight_list', canonicalUsd: null, computeClass: 'CACHE_ONLY', + enabled: false, + }, + scam_alert_feed: { + slug: 'scam_alert_feed', + canonicalUsd: null, + computeClass: 'CACHE_ONLY', + enabled: false, }, - scam_alert_feed: { slug: 'scam_alert_feed', canonicalUsd: null, computeClass: 'CACHE_ONLY' }, }; /** Spec §2.3: x402/Base and Virtuals ACP are both grandfathered at 1.00× — no repricing. */ @@ -52,6 +99,12 @@ export function computeClassFor(slug: OfferingSlug): ComputeClass { return PRICING_TABLE[slug].computeClass; } +/** Merge-prep: whether this offering is actually for sale. `false` means "not yet offered, + * period" (not a pricing gap) — the single source every listing/discovery surface checks. */ +export function isEnabled(slug: OfferingSlug): boolean { + return PRICING_TABLE[slug].enabled; +} + export function networkMultiplierFor(channel: Channel): number { return NETWORK_MULTIPLIER[channel]; } @@ -61,7 +114,7 @@ export function canonicalUsdFor(slug: OfferingSlug): number { const v = PRICING_TABLE[slug].canonicalUsd; if (v === null) { throw new Error( - `pricing: "${slug}" has no canonical price yet (flagged for Desktop, see E1-A PR)`, + `pricing: "${slug}" has no canonical price (not yet offered — see PRICING_TABLE.enabled)`, ); } return v; diff --git a/packages/grey-schemas/src/pricing/types.ts b/packages/grey-schemas/src/pricing/types.ts index a41e6fc..27cf11a 100644 --- a/packages/grey-schemas/src/pricing/types.ts +++ b/packages/grey-schemas/src/pricing/types.ts @@ -9,13 +9,19 @@ import type { OfferingSlug } from '../responses/types'; export type ComputeClass = 'CACHE_ONLY' | 'LIVE_ALLOWED' | 'LIVE_PRIORITY'; /** - * One canonical USD price per offering (spec §2.3, Invariant #31) — channel-agnostic. `null` - * means no canonical price has been sourced yet (flagged for Desktop, not a value to invent). + * One canonical USD price per offering (spec §2.3, Invariant #31) — channel-agnostic. + * + * `enabled: false` (merge-prep ruling, Forces 2026-07-26 session) means the offering is not being + * sold yet, period — a deliberate not-yet-offered status, not a pricing gap. `canonicalUsd` stays + * `null` for a disabled offering; don't invent a price for one that isn't for sale. Toggle-on is a + * separate, later Forces decision (needs daily-customer usage data first per the ruling) — this + * field is not something Kov or Bion flips. */ export interface OfferingPricing { readonly slug: OfferingSlug; readonly canonicalUsd: number | null; readonly computeClass: ComputeClass; + readonly enabled: boolean; } /** A channel this canonical price is realised on. Grows with each expansion (E2 Kite, E3 Olas, ...). */ diff --git a/packages/grey-schemas/test/evaluationKit.test.ts b/packages/grey-schemas/test/evaluationKit.test.ts index c5fc40e..969df2b 100644 --- a/packages/grey-schemas/test/evaluationKit.test.ts +++ b/packages/grey-schemas/test/evaluationKit.test.ts @@ -11,7 +11,6 @@ describe('EvaluationKit — Bazaar extension projection (E1-B, Invariant #33)', const kits = buildAllEvaluationKits(); expect(kits).toHaveLength(10); for (const k of kits) { - expect(k.discoverable).toBe(true); expect(typeof k.description).toBe('string'); expect(k.outputSchema).toBeTruthy(); expect(Array.isArray(k.tags)).toBe(true); @@ -19,6 +18,13 @@ describe('EvaluationKit — Bazaar extension projection (E1-B, Invariant #33)', } }); + it('discoverable tracks PRICING_TABLE.enabled — 8 enabled, 2 not-yet-offered (merge-prep ruling)', () => { + const kits = buildAllEvaluationKits(); + const notYetOffered = kits.filter((k) => !k.discoverable).map((k) => k.slug); + expect(notYetOffered.sort()).toEqual(['daily_greenlight_list', 'scam_alert_feed']); + expect(kits.filter((k) => k.discoverable)).toHaveLength(8); + }); + it('the 7 paid offerings carry an inputSchema; the 2 free resources do not', () => { const kit = (slug: OfferingSlug) => buildEvaluationKit(slug); expect(kit('legitimacy_scan').inputSchema).toBeTruthy(); diff --git a/packages/grey-schemas/test/pricing.test.ts b/packages/grey-schemas/test/pricing.test.ts index 1bae0c5..7048b0e 100644 --- a/packages/grey-schemas/test/pricing.test.ts +++ b/packages/grey-schemas/test/pricing.test.ts @@ -4,6 +4,7 @@ import { PRICING_TABLE, NETWORK_MULTIPLIER, computeClassFor, + isEnabled, networkMultiplierFor, canonicalUsdFor, resolvePriceUsd, @@ -80,6 +81,14 @@ describe('pricing — computeClass + canonical table (E1-A, Invariant #30/#31)', } }); + it('merge-prep ruling: the 2 unpriced offerings are enabled:false (not-yet-offered, not a pricing gap) — 7 priced + 2 disabled = 9', () => { + const disabled = ALL_SLUGS.filter((slug) => !isEnabled(slug)); + expect(disabled.sort()).toEqual(['daily_greenlight_list', 'scam_alert_feed']); + const enabled = ALL_SLUGS.filter((slug) => isEnabled(slug)); + expect(enabled).toHaveLength(8); // 7 priced + the trust rung (priced, gated by its own runtime flag) + expect(isEnabled('legitimacy_scan_trust_rung')).toBe(true); // static table says "real offering"; route reachability is a separate runtime flag + }); + it('networkMultiplier resolves to 1.00 for both live channels today (Invariant #31)', () => { expect(NETWORK_MULTIPLIER.x402).toBe(1.0); expect(NETWORK_MULTIPLIER.acp).toBe(1.0);