diff --git a/cmd/edge-verifier/main.go b/cmd/edge-verifier/main.go index 7b61f04..5c25b2a 100644 --- a/cmd/edge-verifier/main.go +++ b/cmd/edge-verifier/main.go @@ -46,11 +46,12 @@ func main() { syncCancel context.CancelFunc syncCtx context.Context syncRunner *edgeverifier.SyncRunner + cpClient *edgeverifier.ControlPlaneClient flushCancel context.CancelFunc bg sync.WaitGroup ) if cfg.syncEnabled { - snapStore, syncRunner, syncCtx, syncCancel = mustStartSync(cfg, metrics, decisionWAL) + snapStore, syncRunner, cpClient, syncCtx, syncCancel = mustStartSync(cfg, metrics, decisionWAL) } backendServer := startMockBackend() @@ -64,7 +65,14 @@ func main() { if decisionWAL != nil { var flushCtx context.Context flushCtx, flushCancel = context.WithCancel(context.Background()) - worker := edgeverifier.NewFlushWorker(decisionWAL, &edgeverifier.StubTransport{Logger: log.Default()}) + var transport edgeverifier.FlushTransport = &edgeverifier.StubTransport{Logger: log.Default()} + if cpClient != nil { + transport = &edgeverifier.HTTPFlushTransport{Client: cpClient} + log.Printf("Decision flush transport: HTTP → %s/v1/decisions/batch", cfg.controlPlaneURL) + } else { + log.Printf("Decision flush transport: stub (no control plane)") + } + worker := edgeverifier.NewFlushWorker(decisionWAL, transport) bg.Add(1) go func() { defer bg.Done() @@ -199,7 +207,7 @@ func mustOpenWAL(cfg edgeConfig, metrics *edgeverifier.Metrics) *edgeverifier.De return wal } -func mustStartSync(cfg edgeConfig, metrics *edgeverifier.Metrics, wal *edgeverifier.DecisionWAL) (*edgeverifier.Store, *edgeverifier.SyncRunner, context.Context, context.CancelFunc) { +func mustStartSync(cfg edgeConfig, metrics *edgeverifier.Metrics, wal *edgeverifier.DecisionWAL) (*edgeverifier.Store, *edgeverifier.SyncRunner, *edgeverifier.ControlPlaneClient, context.Context, context.CancelFunc) { if cfg.controlPlaneURL == "" || cfg.apiKey == "" { log.Fatal("sync enabled requires -control-plane-url / VERILINK_CONTROL_PLANE_URL and VERILINK_API_KEY") } @@ -229,7 +237,7 @@ func mustStartSync(cfg edgeConfig, metrics *edgeverifier.Metrics, wal *edgeverif wal.SetNoDrop(pol.NoDropDecisions) } log.Printf("Control-plane sync enabled against %s", cfg.controlPlaneURL) - return snapStore, runner, syncCtx, syncCancel + return snapStore, runner, client, syncCtx, syncCancel } func startMockBackend() *http.Server { diff --git a/control-plane/src/__tests__/integration/decision-ingest.test.ts b/control-plane/src/__tests__/integration/decision-ingest.test.ts new file mode 100644 index 0000000..92c3267 --- /dev/null +++ b/control-plane/src/__tests__/integration/decision-ingest.test.ts @@ -0,0 +1,181 @@ +/** + * Plan 8 PR C — decision batch ingest integration. + */ +process.env.DATABASE_URL ||= + 'postgresql://verilink:verilink@127.0.0.1:15432/verilink_test'; +process.env.API_KEY_HMAC_SECRET ||= 'test-hmac-secret-for-integration'; + +import { describe, it, before, after, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import type pg from 'pg'; +import { setupTestDb, teardownTestDb, resetTestData } from '../../testutil/testDb.js'; +import { seedTenant, seedApiKey, authHeaders } from '../../testutil/seedData.js'; +import { startControlPlane, type ControlPlaneHarness } from '../../testutil/appHarness.js'; +import { + batchIDFromPayloadHash, + canonicalizeDecisionsJSON, + sha256Hex, + shouldSample, + type DecisionWire, +} from '../../domains/decision/decisionSample.js'; + +function buildBatch(decisions: DecisionWire[]) { + const sum = sha256Hex(canonicalizeDecisionsJSON(decisions)); + return { + batch_id: batchIDFromPayloadHash(sum), + first_wal_seq: decisions[0].wal_seq, + last_wal_seq: decisions[decisions.length - 1].wal_seq, + payload_hash: sum, + decisions, + }; +} + +describe('decision batch ingest', () => { + let pool: pg.Pool; + let harness: ControlPlaneHarness; + let apiKey: string; + + before(async () => { + pool = await setupTestDb(); + harness = await startControlPlane(); + }); + + after(async () => { + await harness.stop(); + await teardownTestDb(pool); + }); + + beforeEach(async () => { + await resetTestData(pool); + const tenant = await seedTenant(pool, `decisions-${Date.now()}`); + apiKey = await seedApiKey(pool, tenant.id, ['*']); + }); + + it('shouldSample keeps all denies and rate-limits allows', () => { + const t = '2026-07-31T12:00:00.001Z'; + assert.equal(shouldSample('deny', t, 0), true); + assert.equal(shouldSample('allow', t, 0), false); + assert.equal(shouldSample('allow', t, 1), true); + }); + + it('rejects payload_hash that does not match decisions', async () => { + const decidedAt = new Date().toISOString(); + const batch = buildBatch([ + { + wal_seq: 1, + fingerprint: 'fp-a', + action: 'deny', + decided_at: decidedAt, + }, + ]); + const resp = await fetch(`${harness.url}/v1/decisions/batch`, { + method: 'POST', + headers: { ...authHeaders(apiKey), 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...batch, payload_hash: '0'.repeat(64) }), + }); + assert.equal(resp.status, 400); + }); + + it('accepts a batch and is idempotent on redelivery', async () => { + const decidedAt = new Date().toISOString(); + const batch = buildBatch([ + { + wal_seq: 1, + fingerprint: 'fp-deny', + principal_id: 'vrl:p:1', + score: 10, + blacklisted: false, + action: 'deny', + decided_at: decidedAt, + }, + { + wal_seq: 2, + fingerprint: 'fp-allow', + action: 'allow', + score: 90, + decided_at: decidedAt, + }, + ]); + + const resp = await fetch(`${harness.url}/v1/decisions/batch`, { + method: 'POST', + headers: { ...authHeaders(apiKey), 'Content-Type': 'application/json' }, + body: JSON.stringify(batch), + }); + assert.equal(resp.status, 200); + const body = (await resp.json()) as { ok: boolean; data: { duplicate: boolean; accepted: number } }; + assert.equal(body.ok, true); + assert.equal(body.data.duplicate, false); + assert.equal(body.data.accepted, 2); + + const again = await fetch(`${harness.url}/v1/decisions/batch`, { + method: 'POST', + headers: { ...authHeaders(apiKey), 'Content-Type': 'application/json' }, + body: JSON.stringify(batch), + }); + assert.equal(again.status, 200); + const againBody = (await again.json()) as { data: { duplicate: boolean } }; + assert.equal(againBody.data.duplicate, true); + + const samples = await pool.query(`SELECT action FROM decision_samples ORDER BY wal_seq`); + assert.ok(samples.rows.some((r: { action: string }) => r.action === 'deny')); + + const aggs = await pool.query( + `SELECT SUM(count)::int AS n FROM decision_aggregates WHERE dimension_kind = 'all'` + ); + assert.equal(aggs.rows[0].n, 2); + }); + + it('returns 409 when stored batch_id hash conflicts with a redelivery', async () => { + const decidedAt = new Date().toISOString(); + const batch = buildBatch([ + { + wal_seq: 1, + fingerprint: 'fp-a', + action: 'deny', + decided_at: decidedAt, + }, + ]); + const first = await fetch(`${harness.url}/v1/decisions/batch`, { + method: 'POST', + headers: { ...authHeaders(apiKey), 'Content-Type': 'application/json' }, + body: JSON.stringify(batch), + }); + assert.equal(first.status, 200); + + // Simulate a corrupted / poisoned stored hash for the same batch_id. + await pool.query(`UPDATE decision_batches SET payload_hash = $1 WHERE batch_id = $2`, [ + '1'.repeat(64), + batch.batch_id, + ]); + + const resp = await fetch(`${harness.url}/v1/decisions/batch`, { + method: 'POST', + headers: { ...authHeaders(apiKey), 'Content-Type': 'application/json' }, + body: JSON.stringify(batch), + }); + assert.equal(resp.status, 409); + }); + + it('rejects non-RFC3339 decided_at', async () => { + const resp = await fetch(`${harness.url}/v1/decisions/batch`, { + method: 'POST', + headers: { ...authHeaders(apiKey), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + batch_id: '11111111-1111-1111-1111-111111111111', + first_wal_seq: 1, + last_wal_seq: 1, + payload_hash: 'a'.repeat(64), + decisions: [ + { + wal_seq: 1, + fingerprint: 'fp-a', + action: 'deny', + decided_at: '2026-07-31 12:00:00', + }, + ], + }), + }); + assert.equal(resp.status, 400); + }); +}); diff --git a/control-plane/src/app.ts b/control-plane/src/app.ts index fe94903..08a3451 100644 --- a/control-plane/src/app.ts +++ b/control-plane/src/app.ts @@ -13,6 +13,7 @@ import principalsRouter from './routes/principals.js'; import attestationsRouter from './routes/attestations.js'; import syncRouter from './routes/sync.js'; import adminRouter from './routes/admin.js'; +import decisionsRouter from './routes/decisions.js'; export function createApp() { const app = express(); @@ -44,6 +45,7 @@ export function createApp() { app.use('/v1/attestations', attestationsRouter); app.use('/v1/sync', syncRouter); app.use('/v1/admin', adminRouter); + app.use('/v1/decisions', decisionsRouter); // Additional routes added in later plans: // app.use('/v1/policies', policiesRouter); // app.use('/v1/api-keys', apikeysRouter); diff --git a/control-plane/src/domains/decision/decisionIngestService.ts b/control-plane/src/domains/decision/decisionIngestService.ts new file mode 100644 index 0000000..4c06968 --- /dev/null +++ b/control-plane/src/domains/decision/decisionIngestService.ts @@ -0,0 +1,285 @@ +// control-plane/src/domains/decision/decisionIngestService.ts +import type pg from 'pg'; +import { pool } from '../../db/client.js'; +import { AppError, CODES } from '../../shared/errors/AppError.js'; +import { resolveEdgeNodeForApiKey } from '../sync/syncCursorRepository.js'; +import { + assertRFC3339, + batchIDFromPayloadHash, + canonicalizeDecisionsJSON, + sha256Hex, + shouldSample, + type DecisionWire, +} from './decisionSample.js'; + +export type { DecisionWire }; + +export interface DecisionBatchWire { + batch_id: string; + first_wal_seq: number; + last_wal_seq: number; + payload_hash: string; + decisions: DecisionWire[]; +} + +export interface IngestResult { + duplicate: boolean; + edge_node_id: string; + accepted: number; + sampled: number; +} + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const SHA256_HEX_RE = /^[0-9a-f]{64}$/i; + +function assertBatchShape(body: DecisionBatchWire): void { + if (!body || typeof body !== 'object') { + throw new AppError(CODES.BAD_REQUEST, 'Missing batch body'); + } + if (!body.batch_id || !UUID_RE.test(body.batch_id)) { + throw new AppError(CODES.BAD_REQUEST, 'batch_id must be a UUID'); + } + if (!body.payload_hash || !SHA256_HEX_RE.test(body.payload_hash)) { + throw new AppError(CODES.BAD_REQUEST, 'payload_hash must be a 64-char sha256 hex digest'); + } + if (!Number.isInteger(body.first_wal_seq) || !Number.isInteger(body.last_wal_seq)) { + throw new AppError(CODES.BAD_REQUEST, 'first_wal_seq/last_wal_seq must be integers'); + } + if (body.first_wal_seq > body.last_wal_seq) { + throw new AppError(CODES.BAD_REQUEST, 'first_wal_seq must be <= last_wal_seq'); + } + if (!Array.isArray(body.decisions) || body.decisions.length === 0) { + throw new AppError(CODES.BAD_REQUEST, 'decisions must be a non-empty array'); + } + if (body.decisions.length > 500) { + throw new AppError(CODES.PAYLOAD_TOO_LARGE, 'decisions exceeds max batch size 500'); + } + const first = body.decisions[0].wal_seq; + const last = body.decisions[body.decisions.length - 1].wal_seq; + if (first !== body.first_wal_seq || last !== body.last_wal_seq) { + throw new AppError(CODES.BAD_REQUEST, 'wal_seq range must match first/last_wal_seq'); + } + for (const d of body.decisions) { + if (!['allow', 'deny', 'passthrough'].includes(d.action)) { + throw new AppError(CODES.BAD_REQUEST, `invalid action ${d.action}`); + } + if (!d.fingerprint || typeof d.fingerprint !== 'string') { + throw new AppError(CODES.BAD_REQUEST, 'fingerprint required'); + } + if (!Number.isInteger(d.wal_seq) || d.wal_seq < 1) { + throw new AppError(CODES.BAD_REQUEST, 'wal_seq must be a positive integer'); + } + try { + assertRFC3339(d.decided_at); + } catch { + throw new AppError(CODES.BAD_REQUEST, 'decided_at must be RFC3339 with timezone'); + } + } + + const computedHash = sha256Hex(canonicalizeDecisionsJSON(body.decisions)); + if (computedHash !== body.payload_hash.toLowerCase()) { + throw new AppError(CODES.BAD_REQUEST, 'payload_hash does not match decisions'); + } + const expectedBatchID = batchIDFromPayloadHash(computedHash); + if (body.batch_id.toLowerCase() !== expectedBatchID) { + throw new AppError(CODES.BAD_REQUEST, 'batch_id does not match payload_hash'); + } +} + +async function loadSampleRate(tenantId: string): Promise { + const { rows } = await pool.query( + `SELECT allow_sample_rate FROM policies WHERE tenant_id = $1 AND is_active = true LIMIT 1`, + [tenantId] + ); + if (rows.length === 0) return 0.01; + const rate = Number(rows[0].allow_sample_rate); + if (Number.isNaN(rate) || rate < 0) return 0.01; + if (rate > 1) return 1; + return rate; +} + +function minuteBucket(iso: string): Date { + const t = new Date(iso); + t.setUTCSeconds(0, 0); + return t; +} + +type AggRow = { + bucket: string; + kind: string; + value: string; + action: string; + count: number; +}; + +function bumpAgg( + map: Map, + bucket: Date, + kind: string, + value: string, + action: string +): void { + const b = bucket.toISOString(); + const key = `${b}\0${kind}\0${value}\0${action}`; + const cur = map.get(key); + if (cur) { + cur.count += 1; + return; + } + map.set(key, { bucket: b, kind, value, action, count: 1 }); +} + +async function insertAggregates( + client: pg.PoolClient, + tenantId: string, + edgeNodeId: string, + rows: AggRow[] +): Promise { + if (rows.length === 0) return; + await client.query( + `INSERT INTO decision_aggregates + (tenant_id, edge_node_id, bucket_minute, dimension_kind, dimension_value, action, count) + SELECT $1, $2, x.bucket_minute::timestamptz, x.dimension_kind, x.dimension_value, x.action, x.cnt + FROM UNNEST($3::text[], $4::text[], $5::text[], $6::text[], $7::int[]) + AS x(bucket_minute, dimension_kind, dimension_value, action, cnt) + ON CONFLICT (tenant_id, edge_node_id, bucket_minute, dimension_kind, dimension_value, action) + DO UPDATE SET count = decision_aggregates.count + EXCLUDED.count`, + [ + tenantId, + edgeNodeId, + rows.map((r) => r.bucket), + rows.map((r) => r.kind), + rows.map((r) => r.value), + rows.map((r) => r.action), + rows.map((r) => r.count), + ] + ); +} + +async function insertSamples( + client: pg.PoolClient, + tenantId: string, + edgeNodeId: string, + samples: DecisionWire[] +): Promise { + if (samples.length === 0) return; + await client.query( + `INSERT INTO decision_samples + (tenant_id, edge_node_id, wal_seq, fingerprint, principal_id, score, blacklisted, + score_reason, action, decided_at) + SELECT $1, $2, x.wal_seq, x.fingerprint, NULLIF(x.principal_id, ''), x.score, x.blacklisted, + NULLIF(x.score_reason, ''), x.action, x.decided_at::timestamptz + FROM UNNEST( + $3::bigint[], $4::text[], $5::text[], $6::int[], $7::boolean[], $8::text[], $9::text[], $10::text[] + ) AS x(wal_seq, fingerprint, principal_id, score, blacklisted, score_reason, action, decided_at) + ON CONFLICT (edge_node_id, wal_seq) DO NOTHING`, + [ + tenantId, + edgeNodeId, + samples.map((d) => d.wal_seq), + samples.map((d) => d.fingerprint), + samples.map((d) => d.principal_id ?? ''), + samples.map((d) => d.score ?? 0), + samples.map((d) => d.blacklisted ?? false), + samples.map((d) => d.score_reason ?? ''), + samples.map((d) => d.action), + samples.map((d) => d.decided_at), + ] + ); +} + +/** + * Idempotent decision batch ingest. + * Duplicate (edge_node_id, batch_id) with same payload_hash → ok duplicate. + * Same batch_id with different payload_hash → 409. + */ +export async function ingestDecisionBatch(opts: { + tenantId: string; + apiKeyId: string; + body: DecisionBatchWire; +}): Promise { + assertBatchShape(opts.body); + const payloadHash = opts.body.payload_hash.toLowerCase(); + + const edgeNodeId = await resolveEdgeNodeForApiKey(opts.tenantId, opts.apiKeyId); + const existing = await pool.query( + `SELECT payload_hash FROM decision_batches WHERE edge_node_id = $1 AND batch_id = $2`, + [edgeNodeId, opts.body.batch_id] + ); + if (existing.rows.length > 0) { + if (String(existing.rows[0].payload_hash).toLowerCase() === payloadHash) { + return { duplicate: true, edge_node_id: edgeNodeId, accepted: 0, sampled: 0 }; + } + throw new AppError(CODES.CONFLICT, 'batch_id reused with different payload_hash'); + } + + const sampleRate = await loadSampleRate(opts.tenantId); + const aggMap = new Map(); + const samples: DecisionWire[] = []; + for (const d of opts.body.decisions) { + const bucket = minuteBucket(d.decided_at); + bumpAgg(aggMap, bucket, 'all', '', d.action); + bumpAgg(aggMap, bucket, 'fingerprint', d.fingerprint, d.action); + if (d.principal_id) { + bumpAgg(aggMap, bucket, 'principal', d.principal_id, d.action); + } + if (shouldSample(d.action, d.decided_at, sampleRate)) { + samples.push(d); + } + } + + const client = await pool.connect(); + let began = false; + try { + await client.query('BEGIN'); + began = true; + await client.query( + `INSERT INTO decision_batches + (edge_node_id, batch_id, tenant_id, first_wal_seq, last_wal_seq, payload_hash) + VALUES ($1, $2, $3, $4, $5, $6)`, + [ + edgeNodeId, + opts.body.batch_id, + opts.tenantId, + opts.body.first_wal_seq, + opts.body.last_wal_seq, + payloadHash, + ] + ); + await insertAggregates(client, opts.tenantId, edgeNodeId, [...aggMap.values()]); + await insertSamples(client, opts.tenantId, edgeNodeId, samples); + await client.query('COMMIT'); + } catch (err) { + if (began) { + try { + await client.query('ROLLBACK'); + } catch { + // ignore rollback errors; connection will be discarded by release + } + } + if ((err as { code?: string }).code === '23505') { + const again = await pool.query( + `SELECT payload_hash FROM decision_batches WHERE edge_node_id = $1 AND batch_id = $2`, + [edgeNodeId, opts.body.batch_id] + ); + if ( + again.rows.length > 0 && + String(again.rows[0].payload_hash).toLowerCase() === payloadHash + ) { + return { duplicate: true, edge_node_id: edgeNodeId, accepted: 0, sampled: 0 }; + } + throw new AppError(CODES.CONFLICT, 'batch_id reused with different payload_hash'); + } + throw err; + } finally { + client.release(); + } + + return { + duplicate: false, + edge_node_id: edgeNodeId, + accepted: opts.body.decisions.length, + sampled: samples.length, + }; +} diff --git a/control-plane/src/domains/decision/decisionSample.ts b/control-plane/src/domains/decision/decisionSample.ts new file mode 100644 index 0000000..94cec52 --- /dev/null +++ b/control-plane/src/domains/decision/decisionSample.ts @@ -0,0 +1,65 @@ +import { createHash } from 'node:crypto'; + +export interface DecisionWire { + wal_seq: number; + fingerprint: string; + principal_id?: string | null; + score?: number | null; + blacklisted?: boolean | null; + score_reason?: string | null; + action: 'allow' | 'deny' | 'passthrough'; + decided_at: string; +} + +const RFC3339_RE = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; + +/** Require RFC3339 with an explicit timezone (Z or ±HH:MM). */ +export function assertRFC3339(decidedAt: string): void { + if (!RFC3339_RE.test(decidedAt) || Number.isNaN(Date.parse(decidedAt))) { + throw new Error('decided_at must be RFC3339 with timezone'); + } +} + +/** + * Canonical decision JSON matching Go edgeverifier decisionWire (fixed key order, + * no omitempty). Used for payload_hash verification across languages. + */ +export function canonicalizeDecisionsJSON(decisions: DecisionWire[]): string { + return JSON.stringify( + decisions.map((d) => ({ + wal_seq: d.wal_seq, + fingerprint: d.fingerprint, + principal_id: d.principal_id ?? '', + score: d.score ?? 0, + blacklisted: d.blacklisted ?? false, + score_reason: d.score_reason ?? '', + action: d.action, + decided_at: d.decided_at, + })) + ); +} + +export function sha256Hex(payload: string): string { + return createHash('sha256').update(payload).digest('hex'); +} + +/** Deterministic UUID shaped like Go fmt `%x-%x-%x-%x-%x` over the first 16 hash bytes. */ +export function batchIDFromPayloadHash(payloadHash: string): string { + const h = payloadHash.toLowerCase(); + return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`; +} + +/** + * Deterministic sample gate: all denies; allows/passthroughs at allow_sample_rate. + * Uses decided_at ms so sampling stays stable across WAL seq resets/compaction. + */ +export function shouldSample(action: string, decidedAt: string, rate: number): boolean { + if (action === 'deny') return true; + if (rate <= 0) return false; + if (rate >= 1) return true; + const ms = Date.parse(decidedAt); + if (Number.isNaN(ms)) return false; + const bucket = ((ms % 10000) + 10000) % 10000; + return bucket / 10000 < rate; +} diff --git a/control-plane/src/routes/decisions.ts b/control-plane/src/routes/decisions.ts new file mode 100644 index 0000000..7e8781e --- /dev/null +++ b/control-plane/src/routes/decisions.ts @@ -0,0 +1,35 @@ +// control-plane/src/routes/decisions.ts +import { Router } from 'express'; +import { ok } from '../shared/http/responses.js'; +import { defineHandler } from '../shared/http/defineHandler.js'; +import { apiKeyOnly } from '../middleware/auth.js'; +import { AppError, CODES } from '../shared/errors/AppError.js'; +import * as decisionIngest from '../domains/decision/decisionIngestService.js'; + +const router = Router(); +router.use(apiKeyOnly); + +/** + * POST /v1/decisions/batch — edge WAL flush (api-key only). + * Idempotent on (edge_node_id, batch_id) + payload_hash. + */ +router.post( + '/batch', + defineHandler({ + async handler(req, res) { + const tenantId = req.user?.tenantId; + const apiKeyId = req.user?.apiKeyId; + if (!tenantId || !apiKeyId) { + throw new AppError(CODES.FORBIDDEN, 'Tenant API key required'); + } + const result = await decisionIngest.ingestDecisionBatch({ + tenantId, + apiKeyId, + body: req.body as decisionIngest.DecisionBatchWire, + }); + ok(res, result); + }, + }) +); + +export default router; diff --git a/docs/superpowers/plans/HANDOVER.md b/docs/superpowers/plans/HANDOVER.md index 25a8f86..3ec775b 100644 --- a/docs/superpowers/plans/HANDOVER.md +++ b/docs/superpowers/plans/HANDOVER.md @@ -1,9 +1,9 @@ # Handover Note — VeriLink Productization -> **Updated:** 2026-07-30 -> **Repo HEAD:** `main` @ Plan 8 PR A + Proto local-plugins fix -> **Status:** Plans 1–8 PR A done. Plan 8 PR B in progress (`feat/go-edge-hardening-pr-b`). -> **Next session:** Land Plan 8 PR B (decision WAL + flush + metrics) → dashboard (design §13 step 13) +> **Updated:** 2026-07-31 +> **Repo HEAD:** `main` @ Plan 8 PR B (#20) merged +> **Status:** Plans 1–8 PR A+B done. Plan 8 PR C (decision ingest) in flight. +> **Next session:** Land Plan 8 PR C → dashboard (design §13 step 13) --- @@ -14,7 +14,7 @@ VeriLink is past the “toolkit only” MVP. The monorepo now has: | Surface | Location | Notes | |---------|----------|--------| | Trust engine (gRPC) | `cmd/trust-engine`, `internal/trustengine` | `RunVeriRank`, `VerifyAttestation`, `GetFingerprint` | -| Edge verifier | `cmd/edge-verifier`, `internal/edgeverifier` | RFC 9421 three-way outcomes + trust annotations | +| Edge verifier | `cmd/edge-verifier`, `internal/edgeverifier` | RFC 9421 + SSE sync + decision WAL/flush | | Control plane (TS) | `control-plane/` | Express + Postgres; ingest E2E; score writer + recompute scheduler (Plan 6) | | Clients | `client/go`, `client/node` | Signing helpers present | | Proto | `proto/verilink/trust/v1/trust.proto` → `pkg/trustpb` | Buf pipeline in CI | @@ -76,13 +76,13 @@ User judgment: **Plans 1–4 are covered well enough to move on.** Remaining wor --- -## Plan 8 — Go edge hardening — IN PROGRESS (PR B) +## Plan 8 — Go edge hardening — PR C IN FLIGHT - Plan doc: `docs/superpowers/plans/2026-07-30-plan-8-go-edge-hardening.md` (merged PR #17) - Design: §4.5 + §4.7 + §13 step 12 - **PR A (#18):** `internal/edgeverifier` snapshot + apply + SSE/cpclient + disk + proxy read-path — **merged** -- **PR B:** decision WAL + flush interface (stub transport) + metrics — **in progress** -- **PR C (optional):** control-plane decision batch ingest HTTP — if still missing when B lands +- **PR B (#20):** decision WAL + flush interface + metrics — **merged** +- **PR C:** control-plane `POST /v1/decisions/batch` + HTTP flush transport — **in progress** (`feat/go-edge-hardening-pr-c`) - **RFC 9421 already done** (Plan 3) — Plan 8 replaces mock trust/key sources with synced state ### What’s after Plan 8 (§13) diff --git a/internal/edgeverifier/cpclient.go b/internal/edgeverifier/cpclient.go index eec1842..f711f51 100644 --- a/internal/edgeverifier/cpclient.go +++ b/internal/edgeverifier/cpclient.go @@ -1,6 +1,7 @@ package edgeverifier import ( + "bytes" "compress/gzip" "context" "encoding/json" @@ -75,6 +76,46 @@ func (c *ControlPlaneClient) FetchSnapshot(ctx context.Context) (*Snapshot, erro return parseSnapshotJSON(data) } +// FlushDecisionBatch POSTs /v1/decisions/batch (idempotent WAL flush). +func (c *ControlPlaneClient) FlushDecisionBatch(ctx context.Context, batch FlushBatch) error { + payload, err := json.Marshal(batch) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base()+"/v1/decisions/batch", bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.APIKey) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := c.http().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)) + return nil + } + errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("decision batch: status %d: %s", resp.StatusCode, truncate(string(errBody), 200)) +} + +// HTTPFlushTransport delivers batches via ControlPlaneClient. +type HTTPFlushTransport struct { + Client *ControlPlaneClient +} + +// Flush implements FlushTransport. +func (t *HTTPFlushTransport) Flush(ctx context.Context, batch FlushBatch) error { + if t == nil || t.Client == nil { + return fmt.Errorf("nil HTTP flush transport") + } + return t.Client.FlushDecisionBatch(ctx, batch) +} + type cpScoreWire struct { PrincipalID string `json:"principal_id"` EntityKind string `json:"entity_kind"` diff --git a/internal/edgeverifier/flush.go b/internal/edgeverifier/flush.go index cf52ec8..37c4ace 100644 --- a/internal/edgeverifier/flush.go +++ b/internal/edgeverifier/flush.go @@ -2,11 +2,13 @@ package edgeverifier import ( "context" + "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "log" + "math/big" "time" ) @@ -18,12 +20,40 @@ type FlushTransport interface { // FlushBatch is one idempotent decision delivery unit. // Decisions may carry PrincipalID/Fingerprint; the edge WAL retains them at most // DecisionWAL.MaxAge (default 24h) regardless of flush success. +// +// PayloadHash is sha256 of the canonical decisions JSON array only (decisionWire +// bytes), not the batch envelope (batch_id / wal_seq range / hash fields). type FlushBatch struct { - BatchID string - FirstWalSeq int64 - LastWalSeq int64 - PayloadHash string - Decisions []Decision + BatchID string `json:"batch_id"` + FirstWalSeq int64 `json:"first_wal_seq"` + LastWalSeq int64 `json:"last_wal_seq"` + PayloadHash string `json:"payload_hash"` + Decisions []Decision `json:"decisions"` +} + +// MarshalJSON emits the HTTP envelope with decisions as decisionWire so the +// wire form matches buildFlushBatch hashing (no omitempty drift). +// PayloadHash itself stays the hash of the decisions array only — the envelope +// fields are never included in that digest (CP assertBatchShape hashes +// body.decisions the same way). +func (b FlushBatch) MarshalJSON() ([]byte, error) { + wires := make([]decisionWire, len(b.Decisions)) + for i, d := range b.Decisions { + wires[i] = toDecisionWire(d) + } + return json.Marshal(struct { + BatchID string `json:"batch_id"` + FirstWalSeq int64 `json:"first_wal_seq"` + LastWalSeq int64 `json:"last_wal_seq"` + PayloadHash string `json:"payload_hash"` + Decisions []decisionWire `json:"decisions"` + }{ + BatchID: b.BatchID, + FirstWalSeq: b.FirstWalSeq, + LastWalSeq: b.LastWalSeq, + PayloadHash: b.PayloadHash, + Decisions: wires, + }) } // StubTransport logs batches and succeeds (Plan 8 PR B until CP ingest ships). @@ -96,6 +126,8 @@ func NewFlushWorker(wal *DecisionWAL, transport FlushTransport, opts ...FlushWor } // Run flushes until ctx is cancelled, then best-effort flushes all remaining. +// Failed flushes back off exponentially (with jitter) up to 30s so transient +// control-plane 5xx does not hammer every base interval. func (w *FlushWorker) Run(ctx context.Context) { if w == nil || w.WAL == nil { return @@ -104,19 +136,45 @@ func (w *FlushWorker) Run(ctx context.Context) { if interval <= 0 { interval = 5 * time.Second } - ticker := time.NewTicker(interval) - defer ticker.Stop() + backoff := time.Duration(0) for { + wait := interval + if backoff > 0 { + wait = backoff + } + timer := time.NewTimer(wait) select { case <-ctx.Done(): + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } flushCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) _ = w.FlushAll(flushCtx) cancel() return - case <-ticker.C: + case <-timer.C: flushCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - _ = w.FlushOnce(flushCtx) + err := w.FlushOnce(flushCtx) cancel() + if err != nil { + if backoff == 0 { + backoff = interval + } else { + backoff *= 2 + } + const maxBackoff = 30 * time.Second + if backoff > maxBackoff { + backoff = maxBackoff + } + // ±20% jitter + j := time.Duration(randInt63n(int64(backoff/5) + 1)) + backoff = backoff - backoff/10 + j + } else { + backoff = 0 + } } } } @@ -180,24 +238,67 @@ func (w *FlushWorker) FlushOnce(ctx context.Context) error { } func buildFlushBatch(decisions []Decision) (FlushBatch, error) { - payload, err := json.Marshal(decisions) + wires := make([]decisionWire, len(decisions)) + for i, d := range decisions { + wires[i] = toDecisionWire(d) + } + payload, err := json.Marshal(wires) if err != nil { return FlushBatch{}, err } sum := sha256.Sum256(payload) - // Deterministic batch id from payload hash so retries dedupe on the same content. + // Deterministic UUID from payload hash so retries dedupe on the same content. id := fmt.Sprintf("%x-%x-%x-%x-%x", sum[0:4], sum[4:6], sum[6:8], sum[8:10], sum[10:16]) + out := make([]Decision, len(decisions)) + copy(out, decisions) return FlushBatch{ BatchID: id, FirstWalSeq: decisions[0].WalSeq, LastWalSeq: decisions[len(decisions)-1].WalSeq, PayloadHash: hex.EncodeToString(sum[:]), - Decisions: decisions, + Decisions: out, }, nil } +// decisionWire is the cross-language canonical decision JSON (no omitempty). +type decisionWire struct { + WalSeq int64 `json:"wal_seq"` + Fingerprint string `json:"fingerprint"` + PrincipalID string `json:"principal_id"` + Score int `json:"score"` + Blacklisted bool `json:"blacklisted"` + ScoreReason string `json:"score_reason"` + Action string `json:"action"` + DecidedAt string `json:"decided_at"` +} + +func toDecisionWire(d Decision) decisionWire { + return decisionWire{ + WalSeq: d.WalSeq, + Fingerprint: d.Fingerprint, + PrincipalID: d.PrincipalID, + Score: d.Score, + Blacklisted: d.Blacklisted, + ScoreReason: d.ScoreReason, + Action: d.Action, + DecidedAt: d.DecidedAt.UTC().Format(time.RFC3339Nano), + } +} + func (w *FlushWorker) logf(format string, args ...any) { if w != nil && w.Logger != nil { w.Logger.Printf("edgesync: "+format, args...) } } + +// randInt63n returns a non-negative int64 in [0,n) using crypto/rand. +func randInt63n(n int64) int64 { + if n <= 0 { + return 0 + } + v, err := rand.Int(rand.Reader, big.NewInt(n)) + if err != nil { + return 0 + } + return v.Int64() +}