From 3c7ee53a6431094b51f74b598f0e33be6b6cdd7d Mon Sep 17 00:00:00 2001 From: Sanjay Date: Tue, 11 Aug 2026 08:22:18 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20Plan=2010=20PR=20A=20=E2=80=94=20bo?= =?UTF-8?q?otstrap=20registry=20seed=20+=20is=5Fbootstrap=20derivation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - migration 015: bootstrap_issuers.removed_from_registry_at (explicit removal state) - seedManifest: curated root-of-truth (VeriLink, Whimsy, OpenCode, Claude Agent SDK, OpenAI Agents SDK) with fixed vrl:p ids and committed public keys - bootstrapSeeder: idempotent transactional upsert (insert-only mutable columns), is_bootstrap derivation excluding removed/zero-weight rows - seed-bootstrap CLI gated by BOOTSTRAP_SEED=1; npm run seed:bootstrap - PATCH /v1/admin/bootstrap-issuers: remove_from_registry support; weight/removal re-derive is_bootstrap in the same transaction - graph loader excludes removed/zero-weight roots - integration tests: idempotent rerun with exact identities, PATCH removal survives rerun, root-weight write-through (GraphRoot.weight vs trust_weight), zero-weight exclusion, seed gate --- .../015_bootstrap_removal/migration.sql | 7 + control-plane/package.json | 1 + .../integration/bootstrap-seed.test.ts | 199 ++++++++++++++++++ .../domains/bootstrap/bootstrapRepository.ts | 94 +++++---- .../src/domains/bootstrap/bootstrapSeeder.ts | 116 ++++++++++ .../src/domains/bootstrap/seedManifest.ts | 75 +++++++ .../domains/graph/attestationGraphLoader.ts | 4 +- control-plane/src/routes/admin.ts | 21 +- control-plane/src/scripts/seed-bootstrap.ts | 37 ++++ 9 files changed, 511 insertions(+), 43 deletions(-) create mode 100644 control-plane/migrations/015_bootstrap_removal/migration.sql create mode 100644 control-plane/src/__tests__/integration/bootstrap-seed.test.ts create mode 100644 control-plane/src/domains/bootstrap/bootstrapSeeder.ts create mode 100644 control-plane/src/domains/bootstrap/seedManifest.ts create mode 100644 control-plane/src/scripts/seed-bootstrap.ts diff --git a/control-plane/migrations/015_bootstrap_removal/migration.sql b/control-plane/migrations/015_bootstrap_removal/migration.sql new file mode 100644 index 0000000..1841c73 --- /dev/null +++ b/control-plane/migrations/015_bootstrap_removal/migration.sql @@ -0,0 +1,7 @@ +-- control-plane/migrations/015_bootstrap_removal/migration.sql + +-- Plan 10 PR A: explicit removal state for bootstrap issuers so a seed rerun +-- (insert-only manifest) never reinstates an issuer de-emphasized/removed by +-- staff, and is_bootstrap derivation can exclude removed rows. +ALTER TABLE bootstrap_issuers + ADD COLUMN removed_from_registry_at TIMESTAMPTZ; diff --git a/control-plane/package.json b/control-plane/package.json index ae53054..501fa6f 100644 --- a/control-plane/package.json +++ b/control-plane/package.json @@ -8,6 +8,7 @@ "build": "tsc", "start": "node dist/index.js", "migrate": "tsx src/db/migrate.ts", + "seed:bootstrap": "BOOTSTRAP_SEED=1 tsx src/scripts/seed-bootstrap.ts", "test": "npm run test:unit", "test:unit": "node --test --import tsx $(find src -name '*.test.ts' ! -path '*/__tests__/integration/*')", "test:integration": "node --test --test-concurrency=1 --import tsx src/__tests__/integration/*.test.ts" diff --git a/control-plane/src/__tests__/integration/bootstrap-seed.test.ts b/control-plane/src/__tests__/integration/bootstrap-seed.test.ts new file mode 100644 index 0000000..1a1ec10 --- /dev/null +++ b/control-plane/src/__tests__/integration/bootstrap-seed.test.ts @@ -0,0 +1,199 @@ +// control-plane/src/__tests__/integration/bootstrap-seed.test.ts +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, + seedIssuer, + seedApiKey, + authHeaders, +} from '../../testutil/seedData.js'; +import { startControlPlane, type ControlPlaneHarness } from '../../testutil/appHarness.js'; +import { SEED_ISSUERS } from '../../domains/bootstrap/seedManifest.js'; + +/** + * Plan 10 PR A: idempotent bootstrap seed, is_bootstrap derivation, PATCH + * removal survival across reruns, and root-weight write-through to the graph + * loader. All seeded rows use the app pool (dynamic imports after env setup). + */ +describe('Bootstrap Seed Integration', () => { + let pool: pg.Pool; + let harness: ControlPlaneHarness; + + // Dynamic imports so db/client.ts evaluates after DATABASE_URL is set. + let seedBootstrapRegistry: () => Promise<{ issuers: number; roots: number }>; + let loadAttestationGraph: (evaluationTime: Date) => Promise<{ + roots: Array<{ id: string; weight: number }>; + }>; + + before(async () => { + pool = await setupTestDb(); + harness = await startControlPlane(); + ({ seedBootstrapRegistry } = await import('../../domains/bootstrap/bootstrapSeeder.js')); + ({ loadAttestationGraph } = await import('../../domains/graph/attestationGraphLoader.js')); + }); + + after(async () => { + await harness.stop(); + await teardownTestDb(pool); + }); + + beforeEach(async () => { + await resetTestData(pool); + }); + + async function seededState() { + const { rows } = await pool.query( + `SELECT + (SELECT count(*)::int FROM issuers WHERE is_bootstrap) AS bs_issuers, + (SELECT count(*)::int FROM bootstrap_issuers) AS bs_rows, + (SELECT count(*)::int FROM principals) AS principals, + (SELECT count(*)::int FROM principal_keys) AS keys` + ); + return rows[0]; + } + + it('seed is idempotent with exact manifest identities', async () => { + const first = await seedBootstrapRegistry(); + assert.equal(first.issuers, SEED_ISSUERS.length); + assert.equal(first.roots, SEED_ISSUERS.length); + + const { rows: seeded } = await pool.query( + `SELECT p.id, p.entity_kind, p.name, i.trust_weight::float AS trust_weight, + i.is_bootstrap, k.key_id, b.current_weight::float AS current_weight + FROM principals p + JOIN issuers i ON i.principal_id = p.id + JOIN principal_keys k ON k.principal_id = p.id + JOIN bootstrap_issuers b ON b.principal_id = p.id + ORDER BY p.id` + ); + assert.equal(seeded.length, SEED_ISSUERS.length); + for (const entry of SEED_ISSUERS) { + const row = seeded.find((r) => r.id === entry.id); + assert.ok(row, `seeded principal ${entry.id} present`); + assert.equal(row.entity_kind, entry.entityKind); + assert.equal(row.name, entry.name); + assert.equal(row.trust_weight, 1.0, 'trust_weight untouched by seed'); + assert.equal(row.is_bootstrap, true); + assert.equal(row.key_id, entry.keyId); + assert.equal(row.current_weight, 1.0); + } + + const afterFirst = await seededState(); + assert.equal(afterFirst.bs_issuers, SEED_ISSUERS.length); + assert.equal(afterFirst.keys, SEED_ISSUERS.length); + + const second = await seedBootstrapRegistry(); + assert.equal(second.issuers, SEED_ISSUERS.length); + assert.equal(second.roots, SEED_ISSUERS.length); + const afterSecond = await seededState(); + assert.deepEqual(afterSecond, afterFirst, 'rerun is a no-op'); + }); + + it('PATCH removal clears is_bootstrap and seed rerun does not reinstate', async () => { + await seedBootstrapRegistry(); + const target = SEED_ISSUERS[0]; + + const tenantA = await seedTenant(pool, `bs-rm-${Date.now()}`); + const staffKey = await seedApiKey(pool, tenantA.id, ['admin:read']); + + const res = await fetch(`${harness.url}/v1/admin/bootstrap-issuers`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', ...authHeaders(staffKey) }, + body: JSON.stringify({ principal_id: target.id, remove_from_registry: true }), + }); + assert.equal(res.status, 200, JSON.stringify(await res.json())); + + const { rows: afterRemove } = await pool.query( + `SELECT i.is_bootstrap, b.removed_from_registry_at IS NOT NULL AS removed + FROM issuers i JOIN bootstrap_issuers b ON b.principal_id = i.principal_id + WHERE i.principal_id = $1`, + [target.id] + ); + assert.equal(afterRemove[0].is_bootstrap, false, 'removal clears is_bootstrap'); + assert.equal(afterRemove[0].removed, true); + + await seedBootstrapRegistry(); + + const { rows: afterRerun } = await pool.query( + `SELECT i.is_bootstrap, b.removed_from_registry_at IS NOT NULL AS removed + FROM issuers i JOIN bootstrap_issuers b ON b.principal_id = i.principal_id + WHERE i.principal_id = $1`, + [target.id] + ); + assert.equal(afterRerun[0].is_bootstrap, false, 'seed rerun does not reinstate removed issuer'); + assert.equal(afterRerun[0].removed, true, 'removal state preserved across rerun'); + }); + + it('PATCH current_weight write-through: GraphRoot.weight changes, trust_weight stays 1.0', async () => { + await seedBootstrapRegistry(); + const target = SEED_ISSUERS[0]; + + const tenantA = await seedTenant(pool, `bs-wt-${Date.now()}`); + const staffKey = await seedApiKey(pool, tenantA.id, ['admin:read']); + + const res = await fetch(`${harness.url}/v1/admin/bootstrap-issuers`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', ...authHeaders(staffKey) }, + body: JSON.stringify({ principal_id: target.id, current_weight: 0.5 }), + }); + assert.equal(res.status, 200, JSON.stringify(await res.json())); + + const { rows: issuerRows } = await pool.query( + `SELECT trust_weight::float AS trust_weight FROM issuers WHERE principal_id = $1`, + [target.id] + ); + assert.equal(issuerRows[0].trust_weight, 1.0, 'trust_weight untouched by de-emphasis'); + + const graph = await loadAttestationGraph(new Date()); + const root = graph.roots.find((r) => r.id === target.id); + assert.ok(root, 'seeded issuer is a graph root'); + assert.equal(root.weight, 0.5, 'GraphRoot.weight reflects PATCHed current_weight'); + + const { rows: bsRows } = await pool.query( + `SELECT is_bootstrap FROM issuers WHERE principal_id = $1`, + [target.id] + ); + assert.equal(bsRows[0].is_bootstrap, true, 'positive weight keeps is_bootstrap true'); + }); + + it('zero weight or removal drops the issuer from the graph roots', async () => { + await seedBootstrapRegistry(); + const target = SEED_ISSUERS[0]; + + const tenantA = await seedTenant(pool, `bs-z-${Date.now()}`); + const staffKey = await seedApiKey(pool, tenantA.id, ['admin:read']); + + await fetch(`${harness.url}/v1/admin/bootstrap-issuers`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', ...authHeaders(staffKey) }, + body: JSON.stringify({ principal_id: target.id, current_weight: 0 }), + }); + + const graph = await loadAttestationGraph(new Date()); + assert.ok( + !graph.roots.some((r) => r.id === target.id), + 'zero-weight root excluded from graph' + ); + }); + + it('seed is gated by BOOTSTRAP_SEED and refuses to run without it', async () => { + const { execFileSync } = await import('node:child_process'); + assert.throws(() => { + execFileSync('npx', ['tsx', 'src/scripts/seed-bootstrap.ts'], { + cwd: process.cwd(), + env: { + ...process.env, + DATABASE_URL: 'postgresql://verilink:verilink@127.0.0.1:15432/verilink_test', + BOOTSTRAP_SEED: '0', + }, + encoding: 'utf-8', + }); + }, /Refusing to seed without BOOTSTRAP_SEED=1/); + }); +}); diff --git a/control-plane/src/domains/bootstrap/bootstrapRepository.ts b/control-plane/src/domains/bootstrap/bootstrapRepository.ts index 5a45609..cca96e4 100644 --- a/control-plane/src/domains/bootstrap/bootstrapRepository.ts +++ b/control-plane/src/domains/bootstrap/bootstrapRepository.ts @@ -1,5 +1,6 @@ // control-plane/src/domains/bootstrap/bootstrapRepository.ts -import { pool } from '../../db/client.js'; +import { pool, withTransaction } from '../../db/transaction.js'; +import { deriveIsBootstrapForIssuer } from './bootstrapSeeder.js'; export interface BootstrapIssuerRow { principal_id: string; @@ -9,6 +10,8 @@ export interface BootstrapIssuerRow { de_emphasized_at: Date | null; approved_by: string | null; seeded_at: Date; + /** Explicit removal state (migration 015). */ + removed_from_registry_at: Date | null; /** From issuers — read-only context for the queue. */ trust_weight: number; verified_at: Date | null; @@ -18,6 +21,7 @@ export async function listBootstrapIssuers(): Promise { const { rows } = await pool.query( `SELECT b.principal_id, b.name, b.current_weight::float AS current_weight, b.de_emphasis_reason, b.de_emphasized_at, b.approved_by, b.seeded_at, + b.removed_from_registry_at, i.trust_weight::float AS trust_weight, i.verified_at FROM bootstrap_issuers b JOIN issuers i ON i.principal_id = b.principal_id @@ -30,58 +34,70 @@ export interface BootstrapUpdate { current_weight?: number; de_emphasis_reason?: string | null; approved_by?: string | null; + /** Staff removal from the registry (migration 015); never reinstated by seed reruns. */ + remove_from_registry?: boolean; } /** * Staff edit of a bootstrap issuer's Root.weight + de-emphasis reason. * `issuers.trust_weight` is untouched (the orthogonal quality knob; de-emphasis * is via Root.weight only — design §4.5). Sets de_emphasized_at when a reason - * is provided. Returns 404 via the caller when the row does not exist. + * is provided. Weight steps and removal re-derive is_bootstrap in the same + * transaction so the registry and the derived flag can never diverge. + * Returns 404 via the caller when the row does not exist. */ export async function updateBootstrapIssuer( principalId: string, update: BootstrapUpdate ): Promise { - const sets: string[] = []; - const params: unknown[] = []; - let idx = 1; + return withTransaction(async (client) => { + const sets: string[] = []; + const params: unknown[] = []; + let idx = 1; - if (update.current_weight !== undefined) { - sets.push(`current_weight = $${idx++}`); - params.push(update.current_weight); - } - if (update.de_emphasis_reason !== undefined) { - sets.push(`de_emphasis_reason = $${idx++}`); - params.push(update.de_emphasis_reason); - sets.push(`de_emphasized_at = $${idx++}`); - params.push(update.de_emphasis_reason ? new Date() : null); - } - if (update.approved_by !== undefined) { - sets.push(`approved_by = $${idx++}`); - params.push(update.approved_by); - } - if (sets.length === 0) return null; + if (update.current_weight !== undefined) { + sets.push(`current_weight = $${idx++}`); + params.push(update.current_weight); + } + if (update.de_emphasis_reason !== undefined) { + sets.push(`de_emphasis_reason = $${idx++}`); + params.push(update.de_emphasis_reason); + sets.push(`de_emphasized_at = $${idx++}`); + params.push(update.de_emphasis_reason ? new Date() : null); + } + if (update.approved_by !== undefined) { + sets.push(`approved_by = $${idx++}`); + params.push(update.approved_by); + } + if (update.remove_from_registry !== undefined) { + sets.push(`removed_from_registry_at = $${idx++}`); + params.push(update.remove_from_registry ? new Date() : null); + } + if (sets.length === 0) return null; - params.push(principalId); - const { rows } = await pool.query( - `UPDATE bootstrap_issuers SET ${sets.join(', ')} - WHERE principal_id = $${idx} - RETURNING principal_id, name, current_weight::float AS current_weight, - de_emphasis_reason, de_emphasized_at, approved_by, seeded_at`, - params - ); - if (rows.length === 0) return null; + params.push(principalId); + const { rows } = await client.query( + `UPDATE bootstrap_issuers SET ${sets.join(', ')} + WHERE principal_id = $${idx} + RETURNING principal_id, name, current_weight::float AS current_weight, + de_emphasis_reason, de_emphasized_at, approved_by, seeded_at, + removed_from_registry_at`, + params + ); + if (rows.length === 0) return null; - const row = rows[0]; - const issuer = await pool.query( - `SELECT trust_weight::float AS trust_weight, verified_at FROM issuers WHERE principal_id = $1`, - [principalId] - ); - return { - ...row, - trust_weight: issuer.rows[0]?.trust_weight ?? 1, - verified_at: issuer.rows[0]?.verified_at ?? null, - }; + await deriveIsBootstrapForIssuer(client, principalId); + + const issuer = await client.query( + `SELECT trust_weight::float AS trust_weight, verified_at FROM issuers WHERE principal_id = $1`, + [principalId] + ); + return { + ...rows[0], + trust_weight: issuer.rows[0]?.trust_weight ?? 1, + verified_at: issuer.rows[0]?.verified_at ?? null, + }; + }); } export interface UnverifiedIssuerRow { diff --git a/control-plane/src/domains/bootstrap/bootstrapSeeder.ts b/control-plane/src/domains/bootstrap/bootstrapSeeder.ts new file mode 100644 index 0000000..fbf2266 --- /dev/null +++ b/control-plane/src/domains/bootstrap/bootstrapSeeder.ts @@ -0,0 +1,116 @@ +// control-plane/src/domains/bootstrap/bootstrapSeeder.ts + +import { createHash } from 'node:crypto'; +import type pg from 'pg'; +import { withTransaction } from '../../db/transaction.js'; +import { SEED_ISSUERS, type SeedIssuerEntry } from './seedManifest.js'; + +/** + * sha256 hex of the raw Ed25519 public key. The raw key is the 32-byte + * base64url-decoded JWK `x` (matches testutil/seedData and key-lookup ingest). + */ +export function keyHashForX(x: string): string { + return createHash('sha256').update(Buffer.from(x, 'base64url')).digest('hex'); +} + +function publicKeyJwk(x: string): Record { + return { kty: 'OKP', crv: 'Ed25519', x, key_ops: ['verify'], ext: true }; +} + +/** + * Upsert one seed issuer: principal + issuer row + key + registry row. + * Insert-only for mutable registry columns so staff de-emphasis state + * (current_weight, de_emphasized_at, de_emphasis_reason, approved_by, + * removed_from_registry_at) survives reruns (plan decision 2). + */ +async function upsertSeedIssuer(client: pg.PoolClient, entry: SeedIssuerEntry): Promise { + const keyHash = keyHashForX(entry.publicKeyX); + const raw = Buffer.from(entry.publicKeyX, 'base64url'); + + await client.query( + `INSERT INTO principals (id, entity_kind, owner_tenant_id, name, metadata) + VALUES ($1, $2, NULL, $3, $4) + ON CONFLICT (id) DO NOTHING`, + [entry.id, entry.entityKind, entry.name, JSON.stringify({ bootstrap_seed: true, note: entry.note })], + ); + await client.query( + `INSERT INTO issuers (principal_id) VALUES ($1) + ON CONFLICT (principal_id) DO NOTHING`, + [entry.id], + ); + await client.query( + `INSERT INTO principal_keys + (principal_id, key_id, public_key_raw, public_key_jwk, key_hash, + control_verified_at, valid_from) + VALUES ($1, $2, $3, $4, $5, NOW() - INTERVAL '1 hour', NOW() - INTERVAL '1 hour') + ON CONFLICT (principal_id, key_id) DO NOTHING`, + [entry.id, entry.keyId, raw, JSON.stringify(publicKeyJwk(entry.publicKeyX)), keyHash], + ); + await client.query( + `INSERT INTO bootstrap_issuers (principal_id, name, current_weight) + VALUES ($1, $2, 1.0) + ON CONFLICT (principal_id) DO NOTHING`, + [entry.id, entry.name], + ); +} + +/** + * Derive issuers.is_bootstrap for every issuer from the bootstrap registry + * (plan decision 3): true exactly for registry members that have not been + * removed and still carry a root weight > 0. + */ +export async function deriveIsBootstrap(client: pg.PoolClient): Promise { + await client.query( + `UPDATE issuers i + SET is_bootstrap = EXISTS ( + SELECT 1 FROM bootstrap_issuers b + WHERE b.principal_id = i.principal_id + AND b.removed_from_registry_at IS NULL + AND b.current_weight > 0 + )`, + ); +} + +/** Derive is_bootstrap for a single issuer (PATCH path). */ +export async function deriveIsBootstrapForIssuer( + client: pg.PoolClient, + principalId: string, +): Promise { + await client.query( + `UPDATE issuers i + SET is_bootstrap = EXISTS ( + SELECT 1 FROM bootstrap_issuers b + WHERE b.principal_id = i.principal_id + AND b.removed_from_registry_at IS NULL + AND b.current_weight > 0 + ) + WHERE i.principal_id = $1`, + [principalId], + ); +} + +/** + * Idempotent bootstrap registry seed (Plan 10 PR A). One transaction for the + * whole run: a partial seed can never leave a half-created root. Reruns are + * no-ops over the manifest (insert-only) and re-derive is_bootstrap. + */ +export async function seedBootstrapRegistry(): Promise<{ + issuers: number; + roots: number; +}> { + return withTransaction(async (client) => { + for (const entry of SEED_ISSUERS) { + await upsertSeedIssuer(client, entry); + } + await deriveIsBootstrap(client); + const { rows } = await client.query<{ issuers: string; roots: string }>( + `SELECT + (SELECT count(*) FROM issuers WHERE is_bootstrap)::text AS issuers, + (SELECT count(*) FROM bootstrap_issuers)::text AS roots`, + ); + return { + issuers: parseInt(rows[0].issuers, 10), + roots: parseInt(rows[0].roots, 10), + }; + }); +} diff --git a/control-plane/src/domains/bootstrap/seedManifest.ts b/control-plane/src/domains/bootstrap/seedManifest.ts new file mode 100644 index 0000000..2dc2d29 --- /dev/null +++ b/control-plane/src/domains/bootstrap/seedManifest.ts @@ -0,0 +1,75 @@ +// control-plane/src/domains/bootstrap/seedManifest.ts + +/** + * Curated root-of-truth bootstrap registry (Plan 10, design §13 step 14 / §2.3). + * + * Rules (plan decisions 1–2, 5): + * - Every seeded principal has a fixed, immutable id (`vrl:p:`). + * - Every seeded issuer has exactly one key, `key_id="bootstrap-k1"`, whose + * public key is committed here; `key_hash` is derived (sha256 of raw key). + * The manifest never mutates: reruns are insert-only. + * - Mutable registry state (`current_weight`, de-emphasis columns) is never + * touched by the seeder; staff PATCH is the only writer. + * - Public keys below are static Ed25519 JWK `x` values. No private key is + * stored in the repo. Seed attestation signing (PR B) needs the bootstrap + * issuer's private key from ops (gitignored dev key / env), NOT the repo. + */ + +export interface SeedIssuerEntry { + /** Fixed `vrl:p:` identity. */ + id: string; + name: string; + entityKind: 'issuer' | 'both'; + keyId: string; + /** Ed25519 public key, base64url `x` (JWK form). */ + publicKeyX: string; + /** Short provenance note surfaced to operators. */ + note: string; +} + +export const BOOTSTRAP_KEY_ID = 'bootstrap-k1'; + +export const SEED_ISSUERS: readonly SeedIssuerEntry[] = [ + { + id: 'vrl:p:11111111-1111-4111-8111-111111111111', + name: 'VeriLink Bootstrap', + entityKind: 'issuer', + keyId: BOOTSTRAP_KEY_ID, + publicKeyX: 'DMOam6VGDdUJkhONOZhfslFkA_L-lKONTIiyRgyVj-0', + note: 'VeriLink-owned bootstrap root. Signs the seeded initial attestations (PR B).', + }, + { + id: 'vrl:p:22222222-2222-4222-8222-222222222222', + name: 'Whimsy', + entityKind: 'both', + keyId: BOOTSTRAP_KEY_ID, + publicKeyX: 'Mz1Sin-ts2l2P3S0DBhehW02chXdBIg64OHbl2KmMUE', + note: 'First seeded issuer (design §2.3 / §6.3). Legacy behavioral@0 allowlist member.', + }, + { + id: 'vrl:p:33333333-3333-4333-8333-333333333333', + name: 'OpenCode', + entityKind: 'issuer', + keyId: BOOTSTRAP_KEY_ID, + publicKeyX: '2P_vnlJKoOvS3RymUzCPAfLiPCdgrOviJNOBR8v1ZI4', + note: 'Known agent framework with published public key (placeholder until curated).', + }, + { + id: 'vrl:p:44444444-4444-4444-8444-444444444444', + name: 'Claude Agent SDK', + entityKind: 'issuer', + keyId: BOOTSTRAP_KEY_ID, + publicKeyX: 'lOW2hUe9TrmJY_wW7Hb7ZcSRQBD0jtrBdH8hBRFrIJo', + note: 'Known agent framework with published public key (placeholder until curated).', + }, + { + id: 'vrl:p:55555555-5555-4555-8555-555555555555', + name: 'OpenAI Agents SDK', + entityKind: 'issuer', + keyId: BOOTSTRAP_KEY_ID, + publicKeyX: 'u7la3q9pSmZqK0p8tlPIK93yPKup_Ab8C_jdkWeJxXs', + note: 'Known agent framework with published public key (placeholder until curated).', + }, +]; + +export const SEED_ISSUER_IDS: readonly string[] = SEED_ISSUERS.map((e) => e.id); diff --git a/control-plane/src/domains/graph/attestationGraphLoader.ts b/control-plane/src/domains/graph/attestationGraphLoader.ts index 91ddd02..3f03e54 100644 --- a/control-plane/src/domains/graph/attestationGraphLoader.ts +++ b/control-plane/src/domains/graph/attestationGraphLoader.ts @@ -87,7 +87,9 @@ export async function loadAttestationGraphWithClient( FROM bootstrap_issuers b JOIN issuers i ON i.principal_id = b.principal_id JOIN principals p ON p.id = b.principal_id - WHERE p.status = 'active'` + WHERE p.status = 'active' + AND b.removed_from_registry_at IS NULL + AND b.current_weight > 0` ); const roots: GraphRoot[] = rootRows.map((r) => ({ diff --git a/control-plane/src/routes/admin.ts b/control-plane/src/routes/admin.ts index 10ab188..cf4853b 100644 --- a/control-plane/src/routes/admin.ts +++ b/control-plane/src/routes/admin.ts @@ -39,7 +39,8 @@ router.get( /** * PATCH /v1/admin/bootstrap-issuers — edit a bootstrap issuer's Root.weight - * (de-emphasis) + reason. Staff-only; full cold-start seed remains Plan 14. + * (de-emphasis) + reason, or remove it from the registry. Staff-only; full + * cold-start seed is Plan 10 PR A (`npm run seed:bootstrap`). */ router.patch( '/bootstrap-issuers', @@ -49,12 +50,20 @@ router.patch( principal_id?: string; current_weight?: number; de_emphasis_reason?: string | null; + remove_from_registry?: boolean; }; if (!body.principal_id || typeof body.principal_id !== 'string') { throw new AppError(CODES.BAD_REQUEST, 'principal_id is required'); } - if (body.current_weight === undefined && body.de_emphasis_reason === undefined) { - throw new AppError(CODES.BAD_REQUEST, 'Provide at least one of current_weight or de_emphasis_reason'); + if ( + body.current_weight === undefined && + body.de_emphasis_reason === undefined && + body.remove_from_registry === undefined + ) { + throw new AppError( + CODES.BAD_REQUEST, + 'Provide at least one of current_weight, de_emphasis_reason, or remove_from_registry' + ); } const update: bootstrapRepo.BootstrapUpdate = {}; if (body.current_weight !== undefined) { @@ -74,6 +83,12 @@ router.patch( } update.de_emphasis_reason = body.de_emphasis_reason; } + if (body.remove_from_registry !== undefined) { + if (typeof body.remove_from_registry !== 'boolean') { + throw new AppError(CODES.BAD_REQUEST, 'remove_from_registry must be a boolean'); + } + update.remove_from_registry = body.remove_from_registry; + } update.approved_by = req.user?.userId ?? null; const updated = await bootstrapRepo.updateBootstrapIssuer(body.principal_id, update); diff --git a/control-plane/src/scripts/seed-bootstrap.ts b/control-plane/src/scripts/seed-bootstrap.ts new file mode 100644 index 0000000..2ca6b6e --- /dev/null +++ b/control-plane/src/scripts/seed-bootstrap.ts @@ -0,0 +1,37 @@ +// control-plane/src/scripts/seed-bootstrap.ts +// Plan 10 PR A: idempotent bootstrap registry seed. +// npm run seed:bootstrap +// Gated by BOOTSTRAP_SEED=1 (CI/prod do not auto-seed unknown data). + +import { pool } from '../db/client.js'; +import { seedBootstrapRegistry } from '../domains/bootstrap/bootstrapSeeder.js'; + +const BOOTSTRAP_SEED_FLAG = 'BOOTSTRAP_SEED'; + +function assertSeedEnabled(): void { + if (process.env[BOOTSTRAP_SEED_FLAG] !== '1') { + throw new Error( + `Refusing to seed without ${BOOTSTRAP_SEED_FLAG}=1. ` + + 'This prevents CI/prod from auto-seeding unknown data.', + ); + } +} + +async function main(): Promise { + const { assertDatabaseConfigured } = await import('../config.js'); + assertDatabaseConfigured(); + assertSeedEnabled(); + + const { issuers, roots } = await seedBootstrapRegistry(); + console.log(`bootstrap seed complete: is_bootstrap issuers=${issuers}, registry roots=${roots}`); +} + +if (process.argv[1] && process.argv[1].endsWith('seed-bootstrap.ts')) { + main() + .then(() => pool.end()) + .then(() => process.exit(0)) + .catch((err) => { + console.error(err); + process.exit(1); + }); +} From 21b9d95bd7a57685390ca9decfc08241ab3a134a Mon Sep 17 00:00:00 2001 From: Sanjay Date: Tue, 11 Aug 2026 08:30:59 -0400 Subject: [PATCH 2/3] fix: address CodeRabbit review on Plan 10 PR A MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - migration 015: recalculate is_bootstrap for existing rows under the new removed_from_registry_at semantics (no reliance on later seed/PATCH) - seeder: abort the transaction when an existing principal/key conflicts with the manifest (entity_kind, name, public_key_raw, key_hash); idempotent success for matching rows - manifest: drop OpenCode / Claude Agent SDK / OpenAI Agents SDK placeholder entries — only verified issuers are seeded as roots - test: conflicting bootstrap-k1 key aborts seed and creates no registry root --- .../015_bootstrap_removal/migration.sql | 12 ++++++ .../integration/bootstrap-seed.test.ts | 42 +++++++++++++++++-- .../src/domains/bootstrap/bootstrapSeeder.ts | 35 ++++++++++++++++ .../src/domains/bootstrap/seedManifest.ts | 27 ++---------- 4 files changed, 89 insertions(+), 27 deletions(-) diff --git a/control-plane/migrations/015_bootstrap_removal/migration.sql b/control-plane/migrations/015_bootstrap_removal/migration.sql index 1841c73..94a1cc0 100644 --- a/control-plane/migrations/015_bootstrap_removal/migration.sql +++ b/control-plane/migrations/015_bootstrap_removal/migration.sql @@ -5,3 +5,15 @@ -- staff, and is_bootstrap derivation can exclude removed rows. ALTER TABLE bootstrap_issuers ADD COLUMN removed_from_registry_at TIMESTAMPTZ; + +-- Recalculate is_bootstrap for existing rows under the new semantics so the +-- derived flag is correct immediately (no reliance on a later seed/PATCH): +-- true exactly for registry members that are not removed and still carry a +-- root weight > 0. Mirrors deriveIsBootstrap in bootstrapSeeder.ts. +UPDATE issuers i +SET is_bootstrap = EXISTS ( + SELECT 1 FROM bootstrap_issuers b + WHERE b.principal_id = i.principal_id + AND b.removed_from_registry_at IS NULL + AND b.current_weight > 0 +); diff --git a/control-plane/src/__tests__/integration/bootstrap-seed.test.ts b/control-plane/src/__tests__/integration/bootstrap-seed.test.ts index 1a1ec10..f5b9b07 100644 --- a/control-plane/src/__tests__/integration/bootstrap-seed.test.ts +++ b/control-plane/src/__tests__/integration/bootstrap-seed.test.ts @@ -9,7 +9,6 @@ import type pg from 'pg'; import { setupTestDb, teardownTestDb, resetTestData } from '../../testutil/testDb.js'; import { seedTenant, - seedIssuer, seedApiKey, authHeaders, } from '../../testutil/seedData.js'; @@ -18,8 +17,9 @@ import { SEED_ISSUERS } from '../../domains/bootstrap/seedManifest.js'; /** * Plan 10 PR A: idempotent bootstrap seed, is_bootstrap derivation, PATCH - * removal survival across reruns, and root-weight write-through to the graph - * loader. All seeded rows use the app pool (dynamic imports after env setup). + * removal survival across reruns, root-weight write-through to the graph + * loader, and manifest-conflict abort. All seeded rows use the app pool + * (dynamic imports after env setup). */ describe('Bootstrap Seed Integration', () => { let pool: pg.Pool; @@ -182,6 +182,42 @@ describe('Bootstrap Seed Integration', () => { ); }); + it('seed aborts when a conflicting bootstrap-k1 key exists; no registry root created', async () => { + const target = SEED_ISSUERS[0]; + + // Preload the manifest principal + issuer with a DIFFERENT key under the + // manifest key id (simulates a key rotation that diverged from the manifest). + await pool.query( + `INSERT INTO principals (id, entity_kind, name) VALUES ($1, $2, $3)`, + [target.id, target.entityKind, target.name] + ); + await pool.query(`INSERT INTO issuers (principal_id) VALUES ($1)`, [target.id]); + const otherRaw = Buffer.from('a'.repeat(32), 'utf8'); + await pool.query( + `INSERT INTO principal_keys + (principal_id, key_id, public_key_raw, public_key_jwk, key_hash, control_verified_at) + VALUES ($1, $2, $3, $4, $5, NOW() - INTERVAL '1 hour')`, + [ + target.id, + target.keyId, + otherRaw, + JSON.stringify({ kty: 'OKP', crv: 'Ed25519', x: 'a'.repeat(43) }), + 'deadbeef', + ] + ); + + await assert.rejects( + () => seedBootstrapRegistry(), + /bootstrap seed conflict: key .* different public key/ + ); + + const { rows } = await pool.query( + `SELECT count(*)::int AS n FROM bootstrap_issuers WHERE principal_id = $1`, + [target.id] + ); + assert.equal(rows[0].n, 0, 'no registry root created for conflicting key'); + }); + it('seed is gated by BOOTSTRAP_SEED and refuses to run without it', async () => { const { execFileSync } = await import('node:child_process'); assert.throws(() => { diff --git a/control-plane/src/domains/bootstrap/bootstrapSeeder.ts b/control-plane/src/domains/bootstrap/bootstrapSeeder.ts index fbf2266..7eb45de 100644 --- a/control-plane/src/domains/bootstrap/bootstrapSeeder.ts +++ b/control-plane/src/domains/bootstrap/bootstrapSeeder.ts @@ -22,11 +22,46 @@ function publicKeyJwk(x: string): Record { * Insert-only for mutable registry columns so staff de-emphasis state * (current_weight, de_emphasized_at, de_emphasis_reason, approved_by, * removed_from_registry_at) survives reruns (plan decision 2). + * + * Conflict detection: if a principal or key already exists with identity + * fields that differ from the manifest (entity_kind, name, public_key_raw, + * key_hash), the seed aborts the whole transaction instead of silently + * continuing — a mismatched root must never be half-seeded. */ async function upsertSeedIssuer(client: pg.PoolClient, entry: SeedIssuerEntry): Promise { const keyHash = keyHashForX(entry.publicKeyX); const raw = Buffer.from(entry.publicKeyX, 'base64url'); + const { rows: existingPrincipal } = await client.query( + `SELECT entity_kind, name FROM principals WHERE id = $1`, + [entry.id], + ); + if (existingPrincipal.length > 0) { + const p = existingPrincipal[0]; + if (p.entity_kind !== entry.entityKind || p.name !== entry.name) { + throw new Error( + `bootstrap seed conflict: principal ${entry.id} exists with entity_kind=${p.entity_kind} name=${p.name}, ` + + `manifest expects entity_kind=${entry.entityKind} name=${entry.name}`, + ); + } + } + + const { rows: existingKey } = await client.query( + `SELECT public_key_raw, key_hash FROM principal_keys WHERE principal_id = $1 AND key_id = $2`, + [entry.id, entry.keyId], + ); + if (existingKey.length > 0) { + const k = existingKey[0]; + const rawMatches = Buffer.isBuffer(k.public_key_raw) + ? k.public_key_raw.equals(raw) + : Buffer.from(k.public_key_raw).equals(raw); + if (!rawMatches || k.key_hash !== keyHash) { + throw new Error( + `bootstrap seed conflict: key ${entry.id}/${entry.keyId} exists with a different public key`, + ); + } + } + await client.query( `INSERT INTO principals (id, entity_kind, owner_tenant_id, name, metadata) VALUES ($1, $2, NULL, $3, $4) diff --git a/control-plane/src/domains/bootstrap/seedManifest.ts b/control-plane/src/domains/bootstrap/seedManifest.ts index 2dc2d29..a823da1 100644 --- a/control-plane/src/domains/bootstrap/seedManifest.ts +++ b/control-plane/src/domains/bootstrap/seedManifest.ts @@ -13,6 +13,9 @@ * - Public keys below are static Ed25519 JWK `x` values. No private key is * stored in the repo. Seed attestation signing (PR B) needs the bootstrap * issuer's private key from ops (gitignored dev key / env), NOT the repo. + * - Only verified issuers are seeded as roots. Placeholder entries with + * unverified public keys are intentionally NOT in the registry (CodeRabbit + * review, PR #32): a root must be a real, verified issuer. */ export interface SeedIssuerEntry { @@ -46,30 +49,6 @@ export const SEED_ISSUERS: readonly SeedIssuerEntry[] = [ publicKeyX: 'Mz1Sin-ts2l2P3S0DBhehW02chXdBIg64OHbl2KmMUE', note: 'First seeded issuer (design §2.3 / §6.3). Legacy behavioral@0 allowlist member.', }, - { - id: 'vrl:p:33333333-3333-4333-8333-333333333333', - name: 'OpenCode', - entityKind: 'issuer', - keyId: BOOTSTRAP_KEY_ID, - publicKeyX: '2P_vnlJKoOvS3RymUzCPAfLiPCdgrOviJNOBR8v1ZI4', - note: 'Known agent framework with published public key (placeholder until curated).', - }, - { - id: 'vrl:p:44444444-4444-4444-8444-444444444444', - name: 'Claude Agent SDK', - entityKind: 'issuer', - keyId: BOOTSTRAP_KEY_ID, - publicKeyX: 'lOW2hUe9TrmJY_wW7Hb7ZcSRQBD0jtrBdH8hBRFrIJo', - note: 'Known agent framework with published public key (placeholder until curated).', - }, - { - id: 'vrl:p:55555555-5555-4555-8555-555555555555', - name: 'OpenAI Agents SDK', - entityKind: 'issuer', - keyId: BOOTSTRAP_KEY_ID, - publicKeyX: 'u7la3q9pSmZqK0p8tlPIK93yPKup_Ab8C_jdkWeJxXs', - note: 'Known agent framework with published public key (placeholder until curated).', - }, ]; export const SEED_ISSUER_IDS: readonly string[] = SEED_ISSUERS.map((e) => e.id); From 3cce0a4c73aa657a5260405bf259b68138009e15 Mon Sep 17 00:00:00 2001 From: Sanjay Date: Tue, 11 Aug 2026 08:42:19 -0400 Subject: [PATCH 3/3] test: prove rollback of earlier seed inserts on manifest conflict Target a later manifest entry for the conflicting key and assert no registry root exists for earlier entries either (transaction rollback). --- .../__tests__/integration/bootstrap-seed.test.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/control-plane/src/__tests__/integration/bootstrap-seed.test.ts b/control-plane/src/__tests__/integration/bootstrap-seed.test.ts index f5b9b07..42a5388 100644 --- a/control-plane/src/__tests__/integration/bootstrap-seed.test.ts +++ b/control-plane/src/__tests__/integration/bootstrap-seed.test.ts @@ -182,8 +182,11 @@ describe('Bootstrap Seed Integration', () => { ); }); - it('seed aborts when a conflicting bootstrap-k1 key exists; no registry root created', async () => { - const target = SEED_ISSUERS[0]; + it('seed aborts on conflict and rolls back earlier inserts; no registry root created', async () => { + // Target a LATER manifest entry so earlier entries are inserted first and + // must be rolled back when the conflict aborts the transaction. + const target = SEED_ISSUERS[1]; + const earlier = SEED_ISSUERS[0]; // Preload the manifest principal + issuer with a DIFFERENT key under the // manifest key id (simulates a key rotation that diverged from the manifest). @@ -212,10 +215,10 @@ describe('Bootstrap Seed Integration', () => { ); const { rows } = await pool.query( - `SELECT count(*)::int AS n FROM bootstrap_issuers WHERE principal_id = $1`, - [target.id] + `SELECT count(*)::int AS n FROM bootstrap_issuers WHERE principal_id = ANY($1::text[])`, + [[earlier.id, target.id]] ); - assert.equal(rows[0].n, 0, 'no registry root created for conflicting key'); + assert.equal(rows[0].n, 0, 'no registry root created for conflicting key or earlier entries (rolled back)'); }); it('seed is gated by BOOTSTRAP_SEED and refuses to run without it', async () => {