feat(core-api): add verification pipeline with vertex attire adapter

This commit is contained in:
zouantchaw
2026-02-24 13:29:24 -05:00
parent f2912a1c32
commit 4a1d5f89e4
12 changed files with 997 additions and 13 deletions

View File

@@ -16,3 +16,5 @@
| 2026-02-24 | 0.1.11 | Added frontend-ready core API guide and linked M4 API catalog to it as source of truth for consumption. | | 2026-02-24 | 0.1.11 | Added frontend-ready core API guide and linked M4 API catalog to it as source of truth for consumption. |
| 2026-02-24 | 0.1.12 | Reduced M4 API docs to core-only scope and removed command-route references until command implementation is complete. | | 2026-02-24 | 0.1.12 | Reduced M4 API docs to core-only scope and removed command-route references until command implementation is complete. |
| 2026-02-24 | 0.1.13 | Added verification architecture contract with endpoint design and workflow split for attire, government ID, and certification. | | 2026-02-24 | 0.1.13 | Added verification architecture contract with endpoint design and workflow split for attire, government ID, and certification. |
| 2026-02-24 | 0.1.14 | Implemented core verification endpoints in dev and updated frontend/API docs with live verification route contracts. |
| 2026-02-24 | 0.1.15 | Added live Vertex Flash Lite attire verification path and third-party adapter scaffolding for government ID and certification checks. |

View File

@@ -0,0 +1,10 @@
import { z } from 'zod';
export const createVerificationSchema = z.object({
type: z.enum(['attire', 'government_id', 'certification']),
subjectType: z.string().min(1).max(80).optional(),
subjectId: z.string().min(1).max(120).optional(),
fileUri: z.string().startsWith('gs://', 'fileUri must start with gs://'),
rules: z.record(z.any()).optional().default({}),
metadata: z.record(z.any()).optional().default({}),
});

View File

@@ -0,0 +1,7 @@
import { z } from 'zod';
export const reviewVerificationSchema = z.object({
decision: z.enum(['APPROVED', 'REJECTED']),
note: z.string().max(1000).optional().default(''),
reasonCode: z.string().max(100).optional().default('MANUAL_REVIEW'),
});

View File

@@ -4,10 +4,23 @@ import { z } from 'zod';
import { AppError } from '../lib/errors.js'; import { AppError } from '../lib/errors.js';
import { requireAuth, requirePolicy } from '../middleware/auth.js'; import { requireAuth, requirePolicy } from '../middleware/auth.js';
import { createSignedUrlSchema } from '../contracts/core/create-signed-url.js'; import { createSignedUrlSchema } from '../contracts/core/create-signed-url.js';
import { createVerificationSchema } from '../contracts/core/create-verification.js';
import { invokeLlmSchema } from '../contracts/core/invoke-llm.js'; import { invokeLlmSchema } from '../contracts/core/invoke-llm.js';
import { reviewVerificationSchema } from '../contracts/core/review-verification.js';
import { invokeVertexModel } from '../services/llm.js'; import { invokeVertexModel } from '../services/llm.js';
import { checkLlmRateLimit } from '../services/llm-rate-limit.js'; import { checkLlmRateLimit } from '../services/llm-rate-limit.js';
import { generateReadSignedUrl, uploadToGcs, validateFileUriAccess } from '../services/storage.js'; import {
ensureFileExistsForActor,
generateReadSignedUrl,
uploadToGcs,
validateFileUriAccess,
} from '../services/storage.js';
import {
createVerificationJob,
getVerificationJob,
retryVerificationJob,
reviewVerificationJob,
} from '../services/verification-jobs.js';
const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024; const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024;
const DEFAULT_MAX_SIGNED_URL_SECONDS = 900; const DEFAULT_MAX_SIGNED_URL_SECONDS = 900;
@@ -42,6 +55,10 @@ function useMockUpload() {
return process.env.UPLOAD_MOCK !== 'false'; return process.env.UPLOAD_MOCK !== 'false';
} }
function requireVerificationFileExists() {
return process.env.VERIFICATION_REQUIRE_FILE_EXISTS !== 'false';
}
function parseBody(schema, body) { function parseBody(schema, body) {
const parsed = schema.safeParse(body); const parsed = schema.safeParse(body);
if (!parsed.success) { if (!parsed.success) {
@@ -177,12 +194,84 @@ async function handleInvokeLlm(req, res, next) {
} }
} }
async function handleCreateVerification(req, res, next) {
try {
const payload = parseBody(createVerificationSchema, req.body || {});
validateFileUriAccess({
fileUri: payload.fileUri,
actorUid: req.actor.uid,
});
if (requireVerificationFileExists() && !useMockUpload()) {
await ensureFileExistsForActor({
fileUri: payload.fileUri,
actorUid: req.actor.uid,
});
}
const created = createVerificationJob({
actorUid: req.actor.uid,
payload,
});
return res.status(202).json({
...created,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
}
async function handleGetVerification(req, res, next) {
try {
const verificationId = req.params.verificationId;
const job = getVerificationJob(verificationId, req.actor.uid);
return res.status(200).json({
...job,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
}
async function handleReviewVerification(req, res, next) {
try {
const verificationId = req.params.verificationId;
const payload = parseBody(reviewVerificationSchema, req.body || {});
const updated = reviewVerificationJob(verificationId, req.actor.uid, payload);
return res.status(200).json({
...updated,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
}
async function handleRetryVerification(req, res, next) {
try {
const verificationId = req.params.verificationId;
const updated = retryVerificationJob(verificationId, req.actor.uid);
return res.status(202).json({
...updated,
requestId: req.requestId,
});
} catch (error) {
return next(error);
}
}
export function createCoreRouter() { export function createCoreRouter() {
const router = Router(); const router = Router();
router.post('/upload-file', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleUploadFile); router.post('/upload-file', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleUploadFile);
router.post('/create-signed-url', requireAuth, requirePolicy('core.sign-url', 'file'), handleCreateSignedUrl); router.post('/create-signed-url', requireAuth, requirePolicy('core.sign-url', 'file'), handleCreateSignedUrl);
router.post('/invoke-llm', requireAuth, requirePolicy('core.invoke-llm', 'model'), handleInvokeLlm); router.post('/invoke-llm', requireAuth, requirePolicy('core.invoke-llm', 'model'), handleInvokeLlm);
router.post('/verifications', requireAuth, requirePolicy('core.verification.create', 'verification'), handleCreateVerification);
router.get('/verifications/:verificationId', requireAuth, requirePolicy('core.verification.read', 'verification'), handleGetVerification);
router.post('/verifications/:verificationId/review', requireAuth, requirePolicy('core.verification.review', 'verification'), handleReviewVerification);
router.post('/verifications/:verificationId/retry', requireAuth, requirePolicy('core.verification.retry', 'verification'), handleRetryVerification);
return router; return router;
} }

View File

@@ -35,14 +35,34 @@ function toTextFromCandidate(candidate) {
.trim(); .trim();
} }
export async function invokeVertexModel({ prompt, responseJsonSchema, fileUrls = [] }) { function withJsonSchemaInstruction(prompt, responseJsonSchema) {
const { project, location } = buildVertexConfig();
const model = process.env.LLM_MODEL || 'gemini-2.0-flash-001';
const timeoutMs = Number.parseInt(process.env.LLM_TIMEOUT_MS || '20000', 10);
const schemaText = JSON.stringify(responseJsonSchema); const schemaText = JSON.stringify(responseJsonSchema);
const fileContext = fileUrls.length > 0 ? `\nFiles:\n${fileUrls.join('\n')}` : ''; return `${prompt}\n\nRespond with strict JSON only. Follow this schema exactly:\n${schemaText}`;
const instruction = `Respond with strict JSON only. Follow this schema exactly:\n${schemaText}`; }
const textPrompt = `${prompt}\n\n${instruction}${fileContext}`;
function guessMimeTypeFromUri(fileUri) {
const path = fileUri.split('?')[0].toLowerCase();
if (path.endsWith('.jpg') || path.endsWith('.jpeg')) return 'image/jpeg';
if (path.endsWith('.png')) return 'image/png';
if (path.endsWith('.pdf')) return 'application/pdf';
return 'application/octet-stream';
}
function buildMultimodalParts(prompt, fileUris = []) {
const parts = [{ text: prompt }];
for (const fileUri of fileUris) {
parts.push({
fileData: {
fileUri,
mimeType: guessMimeTypeFromUri(fileUri),
},
});
}
return parts;
}
async function callVertexJsonModel({ model, timeoutMs, parts }) {
const { project, location } = buildVertexConfig();
const url = `https://${location}-aiplatform.googleapis.com/v1/projects/${project}/locations/${location}/publishers/google/models/${model}:generateContent`; const url = `https://${location}-aiplatform.googleapis.com/v1/projects/${project}/locations/${location}/publishers/google/models/${model}:generateContent`;
const auth = new GoogleAuth({ const auth = new GoogleAuth({
scopes: ['https://www.googleapis.com/auth/cloud-platform'], scopes: ['https://www.googleapis.com/auth/cloud-platform'],
@@ -56,7 +76,7 @@ export async function invokeVertexModel({ prompt, responseJsonSchema, fileUrls =
url, url,
method: 'POST', method: 'POST',
data: { data: {
contents: [{ role: 'user', parts: [{ text: textPrompt }] }], contents: [{ role: 'user', parts }],
generationConfig: { generationConfig: {
temperature: 0.2, temperature: 0.2,
responseMimeType: 'application/json', responseMimeType: 'application/json',
@@ -91,3 +111,35 @@ export async function invokeVertexModel({ prompt, responseJsonSchema, fileUrls =
}; };
} }
} }
export async function invokeVertexModel({ prompt, responseJsonSchema, fileUrls = [] }) {
const model = process.env.LLM_MODEL || 'gemini-2.0-flash-001';
const timeoutMs = Number.parseInt(process.env.LLM_TIMEOUT_MS || '20000', 10);
const promptWithSchema = withJsonSchemaInstruction(prompt, responseJsonSchema);
const fileContext = fileUrls.length > 0 ? `\nFiles:\n${fileUrls.join('\n')}` : '';
return callVertexJsonModel({
model,
timeoutMs,
parts: [{ text: `${promptWithSchema}${fileContext}` }],
});
}
export async function invokeVertexMultimodalModel({
prompt,
responseJsonSchema,
fileUris = [],
model,
timeoutMs,
}) {
const resolvedModel = model || process.env.LLM_MODEL || 'gemini-2.0-flash-001';
const resolvedTimeoutMs = Number.parseInt(
`${timeoutMs || process.env.LLM_TIMEOUT_MS || '20000'}`,
10
);
const promptWithSchema = withJsonSchemaInstruction(prompt, responseJsonSchema);
return callVertexJsonModel({
model: resolvedModel,
timeoutMs: resolvedTimeoutMs,
parts: buildMultimodalParts(promptWithSchema, fileUris),
});
}

View File

@@ -72,3 +72,12 @@ export async function generateReadSignedUrl({ fileUri, actorUid, expiresInSecond
expiresAt: new Date(expiresAtMs).toISOString(), expiresAt: new Date(expiresAtMs).toISOString(),
}; };
} }
export async function ensureFileExistsForActor({ fileUri, actorUid }) {
const { bucket, path } = validateFileUriAccess({ fileUri, actorUid });
const file = storage.bucket(bucket).file(path);
const [exists] = await file.exists();
if (!exists) {
throw new AppError('NOT_FOUND', 'Evidence file not found', 404, { fileUri });
}
}

View File

@@ -0,0 +1,510 @@
import crypto from 'node:crypto';
import { AppError } from '../lib/errors.js';
import { invokeVertexMultimodalModel } from './llm.js';
const jobs = new Map();
export const VerificationStatus = Object.freeze({
PENDING: 'PENDING',
PROCESSING: 'PROCESSING',
AUTO_PASS: 'AUTO_PASS',
AUTO_FAIL: 'AUTO_FAIL',
NEEDS_REVIEW: 'NEEDS_REVIEW',
APPROVED: 'APPROVED',
REJECTED: 'REJECTED',
ERROR: 'ERROR',
});
const MACHINE_TERMINAL_STATUSES = new Set([
VerificationStatus.AUTO_PASS,
VerificationStatus.AUTO_FAIL,
VerificationStatus.NEEDS_REVIEW,
VerificationStatus.ERROR,
]);
const HUMAN_TERMINAL_STATUSES = new Set([
VerificationStatus.APPROVED,
VerificationStatus.REJECTED,
]);
function nowIso() {
return new Date().toISOString();
}
function accessMode() {
return process.env.VERIFICATION_ACCESS_MODE || 'authenticated';
}
function eventRecord({ fromStatus, toStatus, actorType, actorId, details = {} }) {
return {
id: crypto.randomUUID(),
fromStatus,
toStatus,
actorType,
actorId,
details,
createdAt: nowIso(),
};
}
function toPublicJob(job) {
return {
verificationId: job.id,
type: job.type,
subjectType: job.subjectType,
subjectId: job.subjectId,
fileUri: job.fileUri,
status: job.status,
confidence: job.confidence,
reasons: job.reasons,
extracted: job.extracted,
provider: job.provider,
review: job.review,
createdAt: job.createdAt,
updatedAt: job.updatedAt,
};
}
function assertAccess(job, actorUid) {
if (accessMode() === 'authenticated') {
return;
}
if (job.ownerUid !== actorUid) {
throw new AppError('FORBIDDEN', 'Not allowed to access this verification', 403);
}
}
function requireJob(id) {
const job = jobs.get(id);
if (!job) {
throw new AppError('NOT_FOUND', 'Verification not found', 404, { verificationId: id });
}
return job;
}
function normalizeMachineStatus(status) {
if (
status === VerificationStatus.AUTO_PASS
|| status === VerificationStatus.AUTO_FAIL
|| status === VerificationStatus.NEEDS_REVIEW
) {
return status;
}
return VerificationStatus.NEEDS_REVIEW;
}
function clampConfidence(value, fallback = 0.5) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
if (parsed < 0) return 0;
if (parsed > 1) return 1;
return parsed;
}
function asReasonList(reasons, fallback) {
if (Array.isArray(reasons) && reasons.length > 0) {
return reasons.map((item) => `${item}`);
}
return [fallback];
}
function providerTimeoutMs() {
return Number.parseInt(process.env.VERIFICATION_PROVIDER_TIMEOUT_MS || '8000', 10);
}
function attireModel() {
return process.env.VERIFICATION_ATTIRE_MODEL || 'gemini-2.0-flash-lite-001';
}
async function runAttireChecks(job) {
if (process.env.VERIFICATION_ATTIRE_AUTOPASS === 'true') {
return {
status: VerificationStatus.AUTO_PASS,
confidence: 0.8,
reasons: ['Auto-pass mode enabled for attire in dev'],
extracted: {
expected: job.rules,
},
provider: {
name: 'attire-auto-pass',
reference: null,
},
};
}
const attireProvider = process.env.VERIFICATION_ATTIRE_PROVIDER || 'vertex';
if (attireProvider !== 'vertex') {
return {
status: VerificationStatus.NEEDS_REVIEW,
confidence: 0.45,
reasons: [`Attire provider '${attireProvider}' is not supported`],
extracted: {
expected: job.rules,
},
provider: {
name: attireProvider,
reference: null,
},
};
}
try {
const prompt = [
'You are validating worker attire evidence.',
`Rules: ${JSON.stringify(job.rules || {})}`,
'Return AUTO_PASS only when the image clearly matches required attire.',
'Return AUTO_FAIL when the image clearly violates required attire.',
'Return NEEDS_REVIEW when uncertain.',
].join('\n');
const schema = {
type: 'object',
properties: {
status: { type: 'string' },
confidence: { type: 'number' },
reasons: {
type: 'array',
items: { type: 'string' },
},
extracted: {
type: 'object',
additionalProperties: true,
},
},
required: ['status', 'confidence', 'reasons'],
};
const modelOutput = await invokeVertexMultimodalModel({
prompt,
responseJsonSchema: schema,
fileUris: [job.fileUri],
model: attireModel(),
timeoutMs: providerTimeoutMs(),
});
const result = modelOutput?.result || {};
return {
status: normalizeMachineStatus(result.status),
confidence: clampConfidence(result.confidence, 0.6),
reasons: asReasonList(result.reasons, 'Attire check completed'),
extracted: result.extracted || {},
provider: {
name: 'vertex-attire',
reference: modelOutput?.model || attireModel(),
},
};
} catch (error) {
return {
status: VerificationStatus.NEEDS_REVIEW,
confidence: 0.35,
reasons: ['Automatic attire check unavailable, manual review required'],
extracted: {},
provider: {
name: 'vertex-attire',
reference: `error:${error?.code || 'unknown'}`,
},
};
}
}
function getProviderConfig(type) {
if (type === 'government_id') {
return {
name: 'government-id-provider',
url: process.env.VERIFICATION_GOV_ID_PROVIDER_URL,
token: process.env.VERIFICATION_GOV_ID_PROVIDER_TOKEN,
};
}
return {
name: 'certification-provider',
url: process.env.VERIFICATION_CERT_PROVIDER_URL,
token: process.env.VERIFICATION_CERT_PROVIDER_TOKEN,
};
}
async function runThirdPartyChecks(job, type) {
const provider = getProviderConfig(type);
if (!provider.url) {
return {
status: VerificationStatus.NEEDS_REVIEW,
confidence: 0.4,
reasons: [`${provider.name} is not configured`],
extracted: {},
provider: {
name: provider.name,
reference: null,
},
};
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), providerTimeoutMs());
try {
const response = await fetch(provider.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(provider.token ? { Authorization: `Bearer ${provider.token}` } : {}),
},
body: JSON.stringify({
type,
subjectType: job.subjectType,
subjectId: job.subjectId,
fileUri: job.fileUri,
rules: job.rules,
metadata: job.metadata,
}),
signal: controller.signal,
});
const bodyText = await response.text();
let body = {};
try {
body = bodyText ? JSON.parse(bodyText) : {};
} catch {
body = {};
}
if (!response.ok) {
return {
status: VerificationStatus.NEEDS_REVIEW,
confidence: 0.35,
reasons: [`${provider.name} returned ${response.status}`],
extracted: {},
provider: {
name: provider.name,
reference: body?.reference || null,
},
};
}
return {
status: normalizeMachineStatus(body.status),
confidence: clampConfidence(body.confidence, 0.6),
reasons: asReasonList(body.reasons, `${provider.name} completed check`),
extracted: body.extracted || {},
provider: {
name: provider.name,
reference: body.reference || null,
},
};
} catch (error) {
const isAbort = error?.name === 'AbortError';
return {
status: VerificationStatus.NEEDS_REVIEW,
confidence: 0.3,
reasons: [
isAbort
? `${provider.name} timeout, manual review required`
: `${provider.name} unavailable, manual review required`,
],
extracted: {},
provider: {
name: provider.name,
reference: null,
},
};
} finally {
clearTimeout(timeout);
}
}
async function runMachineChecks(job) {
if (job.type === 'attire') {
return runAttireChecks(job);
}
if (job.type === 'government_id') {
return runThirdPartyChecks(job, 'government_id');
}
return runThirdPartyChecks(job, 'certification');
}
async function processVerificationJob(id) {
const job = requireJob(id);
if (job.status !== VerificationStatus.PENDING) {
return;
}
const beforeProcessing = job.status;
job.status = VerificationStatus.PROCESSING;
job.updatedAt = nowIso();
job.events.push(
eventRecord({
fromStatus: beforeProcessing,
toStatus: VerificationStatus.PROCESSING,
actorType: 'system',
actorId: 'verification-worker',
})
);
try {
const outcome = await runMachineChecks(job);
if (!MACHINE_TERMINAL_STATUSES.has(outcome.status)) {
throw new Error(`Invalid machine outcome status: ${outcome.status}`);
}
const fromStatus = job.status;
job.status = outcome.status;
job.confidence = outcome.confidence;
job.reasons = outcome.reasons;
job.extracted = outcome.extracted;
job.provider = outcome.provider;
job.updatedAt = nowIso();
job.events.push(
eventRecord({
fromStatus,
toStatus: job.status,
actorType: 'system',
actorId: 'verification-worker',
details: {
confidence: job.confidence,
reasons: job.reasons,
provider: job.provider,
},
})
);
} catch (error) {
const fromStatus = job.status;
job.status = VerificationStatus.ERROR;
job.confidence = null;
job.reasons = [error?.message || 'Verification processing failed'];
job.extracted = {};
job.provider = {
name: 'verification-worker',
reference: null,
};
job.updatedAt = nowIso();
job.events.push(
eventRecord({
fromStatus,
toStatus: VerificationStatus.ERROR,
actorType: 'system',
actorId: 'verification-worker',
details: {
error: error?.message || 'Verification processing failed',
},
})
);
}
}
function queueVerificationProcessing(id) {
setTimeout(() => {
processVerificationJob(id).catch(() => {});
}, 0);
}
export function createVerificationJob({ actorUid, payload }) {
const now = nowIso();
const id = `ver_${crypto.randomUUID()}`;
const job = {
id,
type: payload.type,
subjectType: payload.subjectType || null,
subjectId: payload.subjectId || null,
ownerUid: actorUid,
fileUri: payload.fileUri,
rules: payload.rules || {},
metadata: payload.metadata || {},
status: VerificationStatus.PENDING,
confidence: null,
reasons: [],
extracted: {},
provider: null,
review: null,
createdAt: now,
updatedAt: now,
events: [
eventRecord({
fromStatus: null,
toStatus: VerificationStatus.PENDING,
actorType: 'system',
actorId: actorUid,
}),
],
};
jobs.set(id, job);
queueVerificationProcessing(id);
return toPublicJob(job);
}
export function getVerificationJob(verificationId, actorUid) {
const job = requireJob(verificationId);
assertAccess(job, actorUid);
return toPublicJob(job);
}
export function reviewVerificationJob(verificationId, actorUid, review) {
const job = requireJob(verificationId);
assertAccess(job, actorUid);
if (HUMAN_TERMINAL_STATUSES.has(job.status)) {
throw new AppError('CONFLICT', 'Verification already finalized', 409, {
verificationId,
status: job.status,
});
}
const fromStatus = job.status;
job.status = review.decision;
job.review = {
decision: review.decision,
reviewedBy: actorUid,
reviewedAt: nowIso(),
note: review.note || '',
reasonCode: review.reasonCode || 'MANUAL_REVIEW',
};
job.updatedAt = nowIso();
job.events.push(
eventRecord({
fromStatus,
toStatus: job.status,
actorType: 'reviewer',
actorId: actorUid,
details: {
reasonCode: job.review.reasonCode,
},
})
);
return toPublicJob(job);
}
export function retryVerificationJob(verificationId, actorUid) {
const job = requireJob(verificationId);
assertAccess(job, actorUid);
if (job.status === VerificationStatus.PROCESSING) {
throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, {
verificationId,
});
}
const fromStatus = job.status;
job.status = VerificationStatus.PENDING;
job.confidence = null;
job.reasons = [];
job.extracted = {};
job.provider = null;
job.review = null;
job.updatedAt = nowIso();
job.events.push(
eventRecord({
fromStatus,
toStatus: VerificationStatus.PENDING,
actorType: 'reviewer',
actorId: actorUid,
details: {
retried: true,
},
})
);
queueVerificationProcessing(verificationId);
return toPublicJob(job);
}
export function __resetVerificationJobsForTests() {
jobs.clear();
}

View File

@@ -3,16 +3,42 @@ import assert from 'node:assert/strict';
import request from 'supertest'; import request from 'supertest';
import { createApp } from '../src/app.js'; import { createApp } from '../src/app.js';
import { __resetLlmRateLimitForTests } from '../src/services/llm-rate-limit.js'; import { __resetLlmRateLimitForTests } from '../src/services/llm-rate-limit.js';
import { __resetVerificationJobsForTests } from '../src/services/verification-jobs.js';
beforeEach(() => { beforeEach(() => {
process.env.AUTH_BYPASS = 'true'; process.env.AUTH_BYPASS = 'true';
process.env.LLM_MOCK = 'true'; process.env.LLM_MOCK = 'true';
process.env.SIGNED_URL_MOCK = 'true'; process.env.SIGNED_URL_MOCK = 'true';
process.env.UPLOAD_MOCK = 'true';
process.env.MAX_SIGNED_URL_SECONDS = '900'; process.env.MAX_SIGNED_URL_SECONDS = '900';
process.env.LLM_RATE_LIMIT_PER_MINUTE = '20'; process.env.LLM_RATE_LIMIT_PER_MINUTE = '20';
process.env.VERIFICATION_REQUIRE_FILE_EXISTS = 'false';
process.env.VERIFICATION_ACCESS_MODE = 'authenticated';
process.env.VERIFICATION_ATTIRE_PROVIDER = 'mock';
__resetLlmRateLimitForTests(); __resetLlmRateLimitForTests();
__resetVerificationJobsForTests();
}); });
async function waitForMachineStatus(app, verificationId, maxAttempts = 30) {
let last;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
last = await request(app)
.get(`/core/verifications/${verificationId}`)
.set('Authorization', 'Bearer test-token');
if (
last.body?.status === 'AUTO_PASS'
|| last.body?.status === 'AUTO_FAIL'
|| last.body?.status === 'NEEDS_REVIEW'
|| last.body?.status === 'ERROR'
) {
return last;
}
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => setTimeout(resolve, 10));
}
return last;
}
test('GET /healthz returns healthy response', async () => { test('GET /healthz returns healthy response', async () => {
const app = createApp(); const app = createApp();
const res = await request(app).get('/healthz'); const res = await request(app).get('/healthz');
@@ -123,3 +149,98 @@ test('POST /core/invoke-llm enforces per-user rate limit', async () => {
assert.equal(second.body.code, 'RATE_LIMITED'); assert.equal(second.body.code, 'RATE_LIMITED');
assert.equal(typeof second.headers['retry-after'], 'string'); assert.equal(typeof second.headers['retry-after'], 'string');
}); });
test('POST /core/verifications creates async job and GET returns status', async () => {
const app = createApp();
const created = await request(app)
.post('/core/verifications')
.set('Authorization', 'Bearer test-token')
.send({
type: 'attire',
subjectType: 'staff',
subjectId: 'staff_1',
fileUri: 'gs://krow-workforce-dev-private/uploads/test-user/attire.jpg',
rules: { attireType: 'shoes', expectedColor: 'black' },
});
assert.equal(created.status, 202);
assert.equal(created.body.type, 'attire');
assert.equal(created.body.status, 'PENDING');
assert.equal(typeof created.body.verificationId, 'string');
const status = await waitForMachineStatus(app, created.body.verificationId);
assert.equal(status.status, 200);
assert.equal(status.body.verificationId, created.body.verificationId);
assert.equal(status.body.type, 'attire');
assert.ok(['NEEDS_REVIEW', 'AUTO_PASS', 'AUTO_FAIL', 'ERROR'].includes(status.body.status));
});
test('POST /core/verifications rejects file paths not owned by actor', async () => {
const app = createApp();
const res = await request(app)
.post('/core/verifications')
.set('Authorization', 'Bearer test-token')
.send({
type: 'attire',
fileUri: 'gs://krow-workforce-dev-private/uploads/other-user/not-allowed.jpg',
rules: { attireType: 'shoes' },
});
assert.equal(res.status, 403);
assert.equal(res.body.code, 'FORBIDDEN');
});
test('POST /core/verifications/:id/review finalizes verification', async () => {
const app = createApp();
const created = await request(app)
.post('/core/verifications')
.set('Authorization', 'Bearer test-token')
.send({
type: 'certification',
subjectType: 'staff',
subjectId: 'staff_1',
fileUri: 'gs://krow-workforce-dev-private/uploads/test-user/cert.pdf',
rules: { certType: 'food_safety' },
});
const status = await waitForMachineStatus(app, created.body.verificationId);
assert.equal(status.status, 200);
const reviewed = await request(app)
.post(`/core/verifications/${created.body.verificationId}/review`)
.set('Authorization', 'Bearer test-token')
.send({
decision: 'APPROVED',
note: 'Looks good',
reasonCode: 'MANUAL_REVIEW',
});
assert.equal(reviewed.status, 200);
assert.equal(reviewed.body.status, 'APPROVED');
assert.equal(reviewed.body.review.decision, 'APPROVED');
});
test('POST /core/verifications/:id/retry requeues verification', async () => {
const app = createApp();
const created = await request(app)
.post('/core/verifications')
.set('Authorization', 'Bearer test-token')
.send({
type: 'government_id',
subjectType: 'staff',
subjectId: 'staff_1',
fileUri: 'gs://krow-workforce-dev-private/uploads/test-user/id-front.jpg',
rules: {},
});
const status = await waitForMachineStatus(app, created.body.verificationId);
assert.equal(status.status, 200);
const retried = await request(app)
.post(`/core/verifications/${created.body.verificationId}/retry`)
.set('Authorization', 'Bearer test-token')
.send({});
assert.equal(retried.status, 202);
assert.equal(retried.body.status, 'PENDING');
});

View File

@@ -128,7 +128,83 @@ This catalog defines the currently implemented core backend contract for M4.
- `MODEL_FAILED` - `MODEL_FAILED`
- `RATE_LIMITED` - `RATE_LIMITED`
## 3.4 Health ## 3.4 Create verification job
1. Method and route: `POST /core/verifications`
2. Auth: required
3. Request:
```json
{
"type": "attire",
"subjectType": "worker",
"subjectId": "worker_123",
"fileUri": "gs://krow-workforce-dev-private/uploads/<uid>/file.pdf",
"rules": {}
}
```
4. Behavior:
- validates `fileUri` ownership
- requires file existence when `UPLOAD_MOCK=false` and `VERIFICATION_REQUIRE_FILE_EXISTS=true`
- enqueues async verification
5. Success `202`:
```json
{
"verificationId": "ver_123",
"status": "PENDING",
"type": "attire",
"requestId": "uuid"
}
```
6. Errors:
- `UNAUTHENTICATED`
- `VALIDATION_ERROR`
- `FORBIDDEN`
- `NOT_FOUND`
## 3.5 Get verification status
1. Method and route: `GET /core/verifications/{verificationId}`
2. Auth: required
3. Success `200`:
```json
{
"verificationId": "ver_123",
"status": "NEEDS_REVIEW",
"type": "attire",
"requestId": "uuid"
}
```
4. Errors:
- `UNAUTHENTICATED`
- `FORBIDDEN`
- `NOT_FOUND`
## 3.6 Review verification
1. Method and route: `POST /core/verifications/{verificationId}/review`
2. Auth: required
3. Request:
```json
{
"decision": "APPROVED",
"note": "Manual review passed",
"reasonCode": "MANUAL_REVIEW"
}
```
4. Success `200`: status becomes `APPROVED` or `REJECTED`.
5. Errors:
- `UNAUTHENTICATED`
- `VALIDATION_ERROR`
- `FORBIDDEN`
- `NOT_FOUND`
## 3.7 Retry verification
1. Method and route: `POST /core/verifications/{verificationId}/retry`
2. Auth: required
3. Success `202`: status resets to `PENDING`.
4. Errors:
- `UNAUTHENTICATED`
- `FORBIDDEN`
- `NOT_FOUND`
## 3.8 Health
1. Method and route: `GET /health` 1. Method and route: `GET /health`
2. Success `200`: 2. Success `200`:
```json ```json
@@ -150,3 +226,7 @@ This catalog defines the currently implemented core backend contract for M4.
5. Max signed URL expiry: `900` seconds. 5. Max signed URL expiry: `900` seconds.
6. LLM timeout: `20000` ms. 6. LLM timeout: `20000` ms.
7. LLM rate limit: `20` requests/minute/user. 7. LLM rate limit: `20` requests/minute/user.
8. Verification access mode default: `authenticated`.
9. Verification file existence check default: enabled (`VERIFICATION_REQUIRE_FILE_EXISTS=true`).
10. Verification attire provider default in dev: `vertex` with model `gemini-2.0-flash-lite-001`.
11. Verification government/certification providers: external adapters via configured provider URL/token.

View File

@@ -118,6 +118,82 @@ Authorization: Bearer <firebase-id-token>
} }
``` ```
## 4.4 Create verification job
1. Route: `POST /core/verifications`
2. Auth: required
3. Purpose: enqueue an async verification job for an uploaded file.
4. Request body:
```json
{
"type": "attire",
"subjectType": "worker",
"subjectId": "<worker-id>",
"fileUri": "gs://krow-workforce-dev-private/uploads/<uid>/file.pdf",
"rules": {
"dressCode": "black shoes"
}
}
```
5. Success `202` example:
```json
{
"verificationId": "ver_123",
"status": "PENDING",
"type": "attire",
"requestId": "uuid"
}
```
6. Current machine processing behavior in dev:
- `attire`: live vision check using Vertex Gemini Flash Lite model.
- `government_id`: third-party adapter path (falls back to `NEEDS_REVIEW` if provider is not configured).
- `certification`: third-party adapter path (falls back to `NEEDS_REVIEW` if provider is not configured).
## 4.5 Get verification status
1. Route: `GET /core/verifications/{verificationId}`
2. Auth: required
3. Purpose: polling status from frontend.
4. Success `200` example:
```json
{
"verificationId": "ver_123",
"status": "NEEDS_REVIEW",
"type": "attire",
"review": null,
"requestId": "uuid"
}
```
## 4.6 Review verification
1. Route: `POST /core/verifications/{verificationId}/review`
2. Auth: required
3. Purpose: final human decision for the verification.
4. Request body:
```json
{
"decision": "APPROVED",
"note": "Manual review passed",
"reasonCode": "MANUAL_REVIEW"
}
```
5. Success `200` example:
```json
{
"verificationId": "ver_123",
"status": "APPROVED",
"review": {
"decision": "APPROVED",
"reviewedBy": "<uid>"
},
"requestId": "uuid"
}
```
## 4.7 Retry verification
1. Route: `POST /core/verifications/{verificationId}/retry`
2. Auth: required
3. Purpose: requeue verification to run again.
4. Success `202` example: status resets to `PENDING`.
## 5) Frontend fetch examples (web) ## 5) Frontend fetch examples (web)
## 5.1 Signed URL request ## 5.1 Signed URL request
@@ -163,5 +239,7 @@ const data = await res.json();
2. Aliases exist only for migration compatibility. 2. Aliases exist only for migration compatibility.
3. `requestId` in responses should be logged client-side for debugging. 3. `requestId` in responses should be logged client-side for debugging.
4. For 429 on model route, retry with exponential backoff and respect `Retry-After`. 4. For 429 on model route, retry with exponential backoff and respect `Retry-After`.
5. Verification workflows (`attire`, `government_id`, `certification`) are defined in: 5. Verification routes are now available in dev under `/core/verifications*`.
6. Current verification processing is async and returns machine statuses first (`PENDING`, `PROCESSING`, `NEEDS_REVIEW`, etc.).
7. Full verification design and policy details:
`docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md`. `docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md`.

View File

@@ -1,9 +1,20 @@
# M4 Verification Architecture Contract (Attire, Government ID, Certification) # M4 Verification Architecture Contract (Attire, Government ID, Certification)
Status: Proposed (next implementation slice) Status: Partially implemented in dev (core endpoints + async in-memory processor)
Date: 2026-02-24 Date: 2026-02-24
Owner: Technical Lead Owner: Technical Lead
## Implementation status today (dev)
1. Implemented routes:
- `POST /core/verifications`
- `GET /core/verifications/{verificationId}`
- `POST /core/verifications/{verificationId}/review`
- `POST /core/verifications/{verificationId}/retry`
2. Current processor is in-memory and non-persistent (for fast frontend integration in dev).
3. Next hardening step is persistent job storage and worker execution before staging.
4. Attire uses a live Vertex vision model path with `gemini-2.0-flash-lite-001` by default.
5. Government ID and certification use third-party adapter contracts (provider URL/token envs) and fall back to `NEEDS_REVIEW` when providers are not configured.
## 1) Goal ## 1) Goal
Define a single backend verification pipeline for: Define a single backend verification pipeline for:
1. `attire` 1. `attire`
@@ -196,6 +207,19 @@ Rules:
4. Log request and decision IDs for every transition. 4. Log request and decision IDs for every transition.
5. For government ID, keep provider response reference and verification timestamp. 5. For government ID, keep provider response reference and verification timestamp.
## 11) Provider configuration (environment variables)
1. Attire model:
- `VERIFICATION_ATTIRE_PROVIDER=vertex`
- `VERIFICATION_ATTIRE_MODEL=gemini-2.0-flash-lite-001`
2. Government ID provider:
- `VERIFICATION_GOV_ID_PROVIDER_URL`
- `VERIFICATION_GOV_ID_PROVIDER_TOKEN` (Secret Manager recommended)
3. Certification provider:
- `VERIFICATION_CERT_PROVIDER_URL`
- `VERIFICATION_CERT_PROVIDER_TOKEN` (Secret Manager recommended)
4. Provider timeout:
- `VERIFICATION_PROVIDER_TIMEOUT_MS` (default `8000`)
## 9) Frontend integration pattern ## 9) Frontend integration pattern
1. Upload file via existing `POST /core/upload-file`. 1. Upload file via existing `POST /core/upload-file`.
2. Create verification job with returned `fileUri`. 2. Create verification job with returned `fileUri`.

View File

@@ -31,6 +31,8 @@ BACKEND_CORE_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKE
BACKEND_COMMAND_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKEND_ARTIFACT_REPO)/command-api:latest BACKEND_COMMAND_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKEND_ARTIFACT_REPO)/command-api:latest
BACKEND_LOG_LIMIT ?= 100 BACKEND_LOG_LIMIT ?= 100
BACKEND_LLM_MODEL ?= gemini-2.0-flash-001 BACKEND_LLM_MODEL ?= gemini-2.0-flash-001
BACKEND_VERIFICATION_ATTIRE_MODEL ?= gemini-2.0-flash-lite-001
BACKEND_VERIFICATION_PROVIDER_TIMEOUT_MS ?= 8000
BACKEND_MAX_SIGNED_URL_SECONDS ?= 900 BACKEND_MAX_SIGNED_URL_SECONDS ?= 900
BACKEND_LLM_RATE_LIMIT_PER_MINUTE ?= 20 BACKEND_LLM_RATE_LIMIT_PER_MINUTE ?= 20
@@ -131,7 +133,7 @@ backend-deploy-core:
--region=$(BACKEND_REGION) \ --region=$(BACKEND_REGION) \
--project=$(GCP_PROJECT_ID) \ --project=$(GCP_PROJECT_ID) \
--service-account=$(BACKEND_RUNTIME_SA_EMAIL) \ --service-account=$(BACKEND_RUNTIME_SA_EMAIL) \
--set-env-vars=APP_ENV=$(ENV),GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_PRIVATE_BUCKET),UPLOAD_MOCK=false,SIGNED_URL_MOCK=false,LLM_MOCK=false,LLM_LOCATION=$(BACKEND_REGION),LLM_MODEL=$(BACKEND_LLM_MODEL),LLM_TIMEOUT_MS=20000,MAX_SIGNED_URL_SECONDS=$(BACKEND_MAX_SIGNED_URL_SECONDS),LLM_RATE_LIMIT_PER_MINUTE=$(BACKEND_LLM_RATE_LIMIT_PER_MINUTE) \ --set-env-vars=APP_ENV=$(ENV),GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_PRIVATE_BUCKET),UPLOAD_MOCK=false,SIGNED_URL_MOCK=false,LLM_MOCK=false,LLM_LOCATION=$(BACKEND_REGION),LLM_MODEL=$(BACKEND_LLM_MODEL),LLM_TIMEOUT_MS=20000,MAX_SIGNED_URL_SECONDS=$(BACKEND_MAX_SIGNED_URL_SECONDS),LLM_RATE_LIMIT_PER_MINUTE=$(BACKEND_LLM_RATE_LIMIT_PER_MINUTE),VERIFICATION_ACCESS_MODE=authenticated,VERIFICATION_REQUIRE_FILE_EXISTS=true,VERIFICATION_ATTIRE_PROVIDER=vertex,VERIFICATION_ATTIRE_MODEL=$(BACKEND_VERIFICATION_ATTIRE_MODEL),VERIFICATION_PROVIDER_TIMEOUT_MS=$(BACKEND_VERIFICATION_PROVIDER_TIMEOUT_MS) \
$(BACKEND_RUN_AUTH_FLAG) $(BACKEND_RUN_AUTH_FLAG)
@echo "✅ Core backend service deployed." @echo "✅ Core backend service deployed."