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.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.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 { requireAuth, requirePolicy } from '../middleware/auth.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 { reviewVerificationSchema } from '../contracts/core/review-verification.js';
import { invokeVertexModel } from '../services/llm.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_SIGNED_URL_SECONDS = 900;
@@ -42,6 +55,10 @@ function useMockUpload() {
return process.env.UPLOAD_MOCK !== 'false';
}
function requireVerificationFileExists() {
return process.env.VERIFICATION_REQUIRE_FILE_EXISTS !== 'false';
}
function parseBody(schema, body) {
const parsed = schema.safeParse(body);
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() {
const router = Router();
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('/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;
}

View File

@@ -35,14 +35,34 @@ function toTextFromCandidate(candidate) {
.trim();
}
export async function invokeVertexModel({ prompt, responseJsonSchema, fileUrls = [] }) {
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);
function withJsonSchemaInstruction(prompt, responseJsonSchema) {
const schemaText = JSON.stringify(responseJsonSchema);
const fileContext = fileUrls.length > 0 ? `\nFiles:\n${fileUrls.join('\n')}` : '';
const instruction = `Respond with strict JSON only. Follow this schema exactly:\n${schemaText}`;
const textPrompt = `${prompt}\n\n${instruction}${fileContext}`;
return `${prompt}\n\nRespond with strict JSON only. Follow this schema exactly:\n${schemaText}`;
}
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 auth = new GoogleAuth({
scopes: ['https://www.googleapis.com/auth/cloud-platform'],
@@ -56,7 +76,7 @@ export async function invokeVertexModel({ prompt, responseJsonSchema, fileUrls =
url,
method: 'POST',
data: {
contents: [{ role: 'user', parts: [{ text: textPrompt }] }],
contents: [{ role: 'user', parts }],
generationConfig: {
temperature: 0.2,
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(),
};
}
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 { createApp } from '../src/app.js';
import { __resetLlmRateLimitForTests } from '../src/services/llm-rate-limit.js';
import { __resetVerificationJobsForTests } from '../src/services/verification-jobs.js';
beforeEach(() => {
process.env.AUTH_BYPASS = 'true';
process.env.LLM_MOCK = 'true';
process.env.SIGNED_URL_MOCK = 'true';
process.env.UPLOAD_MOCK = 'true';
process.env.MAX_SIGNED_URL_SECONDS = '900';
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();
__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 () => {
const app = createApp();
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(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`
- `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`
2. Success `200`:
```json
@@ -150,3 +226,7 @@ This catalog defines the currently implemented core backend contract for M4.
5. Max signed URL expiry: `900` seconds.
6. LLM timeout: `20000` ms.
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.1 Signed URL request
@@ -163,5 +239,7 @@ const data = await res.json();
2. Aliases exist only for migration compatibility.
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`.
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`.

View File

@@ -1,9 +1,20 @@
# 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
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
Define a single backend verification pipeline for:
1. `attire`
@@ -196,6 +207,19 @@ Rules:
4. Log request and decision IDs for every transition.
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
1. Upload file via existing `POST /core/upload-file`.
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_LOG_LIMIT ?= 100
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_LLM_RATE_LIMIT_PER_MINUTE ?= 20
@@ -131,7 +133,7 @@ backend-deploy-core:
--region=$(BACKEND_REGION) \
--project=$(GCP_PROJECT_ID) \
--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)
@echo "✅ Core backend service deployed."