From 4aab47068e7c71b52cf86cafef8e72b6d99efe6f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 19:00:50 +0000 Subject: [PATCH 1/5] fix(addie): owner evaluate_agent_quality writes to canonical compliance state Closes the 12-hour gap between owner-triggered storyboard runs and the public /api/registry/agents/:url/compliance endpoint (issue #4247, PR 1 of 4). When evaluate_agent_quality is triggered by the agent owner, the result is now written to agent_compliance_status + agent_compliance_runs + agent_storyboard_status with triggered_by = 'owner_test'. Non-owner runs continue writing to agent_test_history (deprecated in PR 3). Migration 471 adds 'owner_test' to both triggered_by CHECK constraints. notifyComplianceChange is intentionally suppressed for owner runs to prevent iteration-loop Slack spam. https://claude.ai/code/session_01UNHkGhBXk9XD2dpzvSLdhb --- .changeset/unify-owner-compliance-writes.md | 10 ++++ server/src/addie/config-version.ts | 2 +- server/src/addie/mcp/member-tools.ts | 55 ++++++++++++++++++- server/src/db/compliance-db.ts | 2 +- .../471_owner_test_triggered_by.sql | 16 ++++++ 5 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 .changeset/unify-owner-compliance-writes.md create mode 100644 server/src/db/migrations/471_owner_test_triggered_by.sql diff --git a/.changeset/unify-owner-compliance-writes.md b/.changeset/unify-owner-compliance-writes.md new file mode 100644 index 0000000000..1bdb5c7f9e --- /dev/null +++ b/.changeset/unify-owner-compliance-writes.md @@ -0,0 +1,10 @@ +--- +--- + +PR 1 of 4 in the compliance-state unification initiative (issue #4247): owner-triggered +`evaluate_agent_quality` runs now write to canonical compliance tables +(`agent_compliance_status`, `agent_compliance_runs`, `agent_storyboard_status`) with +`triggered_by = 'owner_test'`, closing the 12-hour gap between owner tests and the +public `/api/registry/agents/:url/compliance` endpoint. Non-owner runs continue +writing to `agent_test_history` (deprecated in PR 3). Adds `'owner_test'` to both +`triggered_by` CHECK constraints via migration 471. diff --git a/server/src/addie/config-version.ts b/server/src/addie/config-version.ts index f184b82fcd..bf00b2c4d6 100644 --- a/server/src/addie/config-version.ts +++ b/server/src/addie/config-version.ts @@ -30,7 +30,7 @@ import { loadRules, loadResponseStyle } from './rules/index.js'; * Format: YYYY.MM.N where N is incremented for multiple changes in a month * Example: 2025.01.1, 2025.01.2, 2025.02.1 */ -export const CODE_VERSION = '2026.04.6'; +export const CODE_VERSION = '2026.05.1'; // Types export interface ConfigVersion { diff --git a/server/src/addie/mcp/member-tools.ts b/server/src/addie/mcp/member-tools.ts index f29fc551b6..9d0eb46ff9 100644 --- a/server/src/addie/mcp/member-tools.ts +++ b/server/src/addie/mcp/member-tools.ts @@ -3560,8 +3560,61 @@ export function createMemberToolHandlers( ); } - // Record result if the user has an org with this agent saved + // Record result when the user has an org context for this agent. if (organizationId) { + // Write to canonical compliance tables when the calling org owns this agent. + // Mirrors resolveAgentOwnerOrg (registry-api.ts:4733) — joins organization_memberships + // to verify the acting user is still an active member of the owning org. + // Non-owner runs skip the canonical write and fall through to the legacy + // agent_test_history path below. + const workosUserId = memberContext?.workos_user?.workos_user_id; + let isAgentOwner = false; + if (workosUserId) { + try { + const ownerCheck = await query( + `SELECT 1 FROM member_profiles mp + JOIN organization_memberships om + ON om.workos_organization_id = mp.workos_organization_id + WHERE mp.workos_organization_id = $1 + AND mp.agents @> $2::jsonb + AND om.workos_user_id = $3 + LIMIT 1`, + [organizationId, JSON.stringify([{ url: resolved.resolvedUrl }]), workosUserId], + ); + isAgentOwner = ownerCheck.rows.length > 0; + } catch (ownerCheckError) { + logger.warn({ ownerCheckError }, 'evaluate_agent_quality: owner check failed, skipping canonical write'); + } + } + + if (isAgentOwner) { + try { + const metadata = await complianceDb.getRegistryMetadata(resolved.resolvedUrl); + // Skip canonical write if the owner has opted out of compliance monitoring. + if (!metadata?.compliance_opt_out) { + const dbInput = { + ...complianceResultToDbInput( + result, + resolved.resolvedUrl, + metadata?.lifecycle_stage ?? 'production', + 'owner_test', + ), + // Owner test runs are not dry runs — they update the live public record. + // (complianceResultToDbInput hard-codes dry_run: true; override here.) + dry_run: false, + }; + await complianceDb.recordComplianceRun(dbInput); + // notifyComplianceChange intentionally omitted: owner test runs are + // exploratory; compliance-change notifications fire on heartbeat + // transitions only to prevent iteration-loop spam. + } + } catch (error) { + logger.warn({ error, agentUrl: resolved.resolvedUrl }, 'Could not write owner test result to canonical compliance state'); + } + } + + // Legacy write to agent_contexts + agent_test_history. Retained for + // backward compatibility until PR 3 migrates callers and drops the table. try { const context = await agentContextDb.getByOrgAndUrl(organizationId, resolved.resolvedUrl); if (context) { diff --git a/server/src/db/compliance-db.ts b/server/src/db/compliance-db.ts index fce3773584..91ef49de3e 100644 --- a/server/src/db/compliance-db.ts +++ b/server/src/db/compliance-db.ts @@ -11,7 +11,7 @@ const logger = baseLogger.child({ module: 'compliance-db' }); export type LifecycleStage = 'development' | 'testing' | 'production' | 'deprecated'; export type ComplianceStatus = 'passing' | 'degraded' | 'failing' | 'unknown'; export type OverallRunStatus = 'passing' | 'failing' | 'partial'; -export type TriggeredBy = 'heartbeat' | 'manual' | 'webhook'; +export type TriggeredBy = 'heartbeat' | 'manual' | 'webhook' | 'owner_test'; export type TrackStatus = 'pass' | 'fail' | 'partial' | 'skip' | 'silent'; /** diff --git a/server/src/db/migrations/471_owner_test_triggered_by.sql b/server/src/db/migrations/471_owner_test_triggered_by.sql new file mode 100644 index 0000000000..0e450bb1ad --- /dev/null +++ b/server/src/db/migrations/471_owner_test_triggered_by.sql @@ -0,0 +1,16 @@ +-- Add 'owner_test' to triggered_by CHECK constraints in compliance tables. +-- Owner-triggered storyboard runs (via evaluate_agent_quality) now write to +-- canonical compliance state, distinguished from heartbeat and dashboard-manual +-- runs by triggered_by = 'owner_test'. See issue #4247. + +ALTER TABLE agent_compliance_runs + DROP CONSTRAINT IF EXISTS valid_triggered_by, + ADD CONSTRAINT valid_triggered_by CHECK ( + triggered_by IN ('heartbeat', 'manual', 'webhook', 'owner_test') + ); + +ALTER TABLE agent_storyboard_status + DROP CONSTRAINT IF EXISTS valid_storyboard_triggered_by, + ADD CONSTRAINT valid_storyboard_triggered_by CHECK ( + triggered_by IS NULL OR triggered_by IN ('heartbeat', 'manual', 'webhook', 'owner_test') + ); From f41f7821c0c9d3ba12b332cd7b1c7f926b88224d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 19:25:56 +0000 Subject: [PATCH 2/5] fix(addie): add verdict_source to compliance response + last-write-wins test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback from @EmmaLouise2018 on PR #4250: 1. `verdict_source` field on /api/registry/agents/:url/compliance — `AgentComplianceDetailSchema` gains optional `verdict_source`: 'heartbeat' | 'owner_test' | 'manual' | 'webhook' | null — `getComplianceStatus` and `bulkGetComplianceStatus` join `agent_compliance_runs` via LATERAL subquery (dry_run=false, ORDER BY tested_at DESC LIMIT 1) to surface the triggered_by of the most recent run. No migration needed. — Endpoint response emits `verdict_source: status.last_triggered_by`. — `AgentComplianceStatus` interface gets `last_triggered_by` field. 2. Last-write-wins contract test — New `compliance-db-last-write-wins.test.ts` pins the ON CONFLICT DO UPDATE semantics: every recordComplianceRun call overwrites agent_compliance_status regardless of triggered_by source. A future change to first-write-wins or priority ordering would break these tests. https://claude.ai/code/session_01NVVqgeSGevUGXgDbMw1XKZ --- server/src/db/compliance-db.ts | 18 +- server/src/routes/registry-api.ts | 1 + server/src/schemas/registry.ts | 2 + .../compliance-db-last-write-wins.test.ts | 204 ++++++++++++++++++ 4 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 server/tests/unit/compliance-db-last-write-wins.test.ts diff --git a/server/src/db/compliance-db.ts b/server/src/db/compliance-db.ts index 91ef49de3e..919d59874c 100644 --- a/server/src/db/compliance-db.ts +++ b/server/src/db/compliance-db.ts @@ -118,6 +118,8 @@ export interface AgentComplianceStatus { previous_status: string | null; status_changed_at: Date | null; updated_at: Date; + /** triggered_by of the most recent non-dry-run in agent_compliance_runs */ + last_triggered_by: TriggeredBy | null; } export type StoryboardStatus = 'passing' | 'failing' | 'partial' | 'untested'; @@ -427,9 +429,15 @@ export class ComplianceDatabase { async getComplianceStatus(agentUrl: string): Promise { const result = await query( - `SELECT s.*, COALESCE(m.lifecycle_stage, 'production') AS lifecycle_stage + `SELECT s.*, COALESCE(m.lifecycle_stage, 'production') AS lifecycle_stage, + r.triggered_by AS last_triggered_by FROM agent_compliance_status s LEFT JOIN agent_registry_metadata m ON m.agent_url = s.agent_url + LEFT JOIN LATERAL ( + SELECT triggered_by FROM agent_compliance_runs + WHERE agent_url = s.agent_url AND dry_run = false + ORDER BY tested_at DESC LIMIT 1 + ) r ON true WHERE s.agent_url = $1`, [agentUrl], ); @@ -455,9 +463,15 @@ export class ComplianceDatabase { if (agentUrls.length === 0) return new Map(); const result = await query( - `SELECT s.*, COALESCE(m.lifecycle_stage, 'production') AS lifecycle_stage + `SELECT s.*, COALESCE(m.lifecycle_stage, 'production') AS lifecycle_stage, + r.triggered_by AS last_triggered_by FROM agent_compliance_status s LEFT JOIN agent_registry_metadata m ON m.agent_url = s.agent_url + LEFT JOIN LATERAL ( + SELECT triggered_by FROM agent_compliance_runs + WHERE agent_url = s.agent_url AND dry_run = false + ORDER BY tested_at DESC LIMIT 1 + ) r ON true WHERE s.agent_url = ANY($1)`, [agentUrls], ); diff --git a/server/src/routes/registry-api.ts b/server/src/routes/registry-api.ts index d7118e245f..c5e349dd2b 100644 --- a/server/src/routes/registry-api.ts +++ b/server/src/routes/registry-api.ts @@ -4332,6 +4332,7 @@ export function createRegistryApiRouter(config: RegistryApiConfig): Router { membership_tier_label: ownerMembership.membership_tier_label, subscription_status: ownerMembership.subscription_status, is_api_access_tier: ownerMembership.is_api_access_tier, + verdict_source: status.last_triggered_by ?? null, verified: badges.length > 0, verified_badges: badges.map(b => ({ role: b.role, diff --git a/server/src/schemas/registry.ts b/server/src/schemas/registry.ts index 4a59bdd2be..f29a961f11 100644 --- a/server/src/schemas/registry.ts +++ b/server/src/schemas/registry.ts @@ -341,6 +341,8 @@ export const AgentComplianceDetailSchema = z membership_tier_label: z.string().nullable().optional().openapi({ description: "Owner-scoped: human-readable label for membership_tier (e.g. 'Builder'). Null for non-owners." }), subscription_status: z.string().nullable().optional().openapi({ description: "Owner-scoped: the agent owner's subscription status (active, past_due, trialing, etc.). Null for non-owners." }), is_api_access_tier: z.boolean().optional().openapi({ description: "Owner-scoped: true when the owner's tier and subscription status grant badge eligibility. False for non-owners. Single source of truth — UI should not re-derive." }), + verdict_source: z.enum(["heartbeat", "owner_test", "manual", "webhook"]).nullable().optional() + .openapi({ description: "triggered_by value of the most recent non-dry-run compliance check. 'heartbeat' = scheduled run; 'owner_test' = agent owner triggered via evaluate_agent_quality. Null when no run has been recorded yet." }), verified: z.boolean().optional(), verified_badges: z.array(VerificationBadgeSchema).optional(), }) diff --git a/server/tests/unit/compliance-db-last-write-wins.test.ts b/server/tests/unit/compliance-db-last-write-wins.test.ts new file mode 100644 index 0000000000..bec028e4bc --- /dev/null +++ b/server/tests/unit/compliance-db-last-write-wins.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../../src/db/client.js', () => ({ + query: vi.fn(), + getClient: vi.fn(), +})); + +vi.mock('../../src/db/encryption.js', () => ({ + decrypt: vi.fn(), + encrypt: vi.fn(), + deriveKey: vi.fn(), +})); + +import { ComplianceDatabase } from '../../src/db/compliance-db.js'; +import { query, getClient } from '../../src/db/client.js'; + +const mockedQuery = vi.mocked(query); +const mockedGetClient = vi.mocked(getClient); + +const EMPTY = { rows: [], rowCount: 0, command: '', oid: 0, fields: [] }; + +function makeTransactionClient(queryResponses: Array<{ rows: any[] }>) { + const calls: string[] = []; + let idx = 0; + const client = { + query: vi.fn(async (sql: string) => { + calls.push(typeof sql === 'string' ? sql.trim().split(/\s+/)[0] : sql); + const resp = queryResponses[idx] ?? EMPTY; + idx++; + return { ...EMPTY, ...resp }; + }), + release: vi.fn(), + _calls: calls, + }; + return client; +} + +const AGENT_URL = 'https://agent.example.com'; + +function makeRunRow(triggeredBy: string) { + return { + id: 'run-001', + agent_url: AGENT_URL, + lifecycle_stage: 'production', + overall_status: 'passing', + headline: null, + total_duration_ms: 100, + tested_at: new Date(), + tracks_json: [], + tracks_passed: 1, + tracks_failed: 0, + tracks_skipped: 0, + tracks_partial: 0, + agent_profile_json: null, + observations_json: null, + triggered_by: triggeredBy, + dry_run: false, + }; +} + +const minimalInput = (triggeredBy: 'heartbeat' | 'owner_test') => ({ + agent_url: AGENT_URL, + lifecycle_stage: 'production' as const, + overall_status: 'passing' as const, + tracks_json: [{ track: 'core', status: 'pass' as const, scenario_count: 1, passed_count: 1, duration_ms: 100 }], + tracks_passed: 1, + tracks_failed: 0, + tracks_skipped: 0, + tracks_partial: 0, + triggered_by: triggeredBy, + dry_run: false, +}); + +describe('ComplianceDatabase — last-write-wins on agent_compliance_status', () => { + let db: ComplianceDatabase; + + beforeEach(() => { + db = new ComplianceDatabase(); + vi.clearAllMocks(); + }); + + /** + * Contract: agent_compliance_status uses ON CONFLICT DO UPDATE (not DO NOTHING). + * Every recordComplianceRun call — regardless of triggered_by — overwrites the + * materialized status row. A future change to "pick highest-priority source" or + * "first-write-wins" would break this test. + */ + it('always upserts status regardless of triggered_by — last-write-wins', async () => { + const statusRow = { rows: [{ status: 'passing', previous_status: null }] }; + + const client = makeTransactionClient([ + EMPTY, // BEGIN + { rows: [makeRunRow('heartbeat')] }, // INSERT agent_compliance_runs + statusRow, // UPSERT agent_compliance_status + EMPTY, // COMMIT + ]); + mockedGetClient.mockResolvedValueOnce(client as any); + + await db.recordComplianceRun(minimalInput('heartbeat')); + + const upsertCall = client.query.mock.calls.find( + ([sql]: [string]) => typeof sql === 'string' && sql.includes('ON CONFLICT (agent_url) DO UPDATE'), + ); + expect(upsertCall).toBeDefined(); + }); + + it('owner_test write at T+1 wins over prior heartbeat — triggered_by is forwarded verbatim', async () => { + const statusRow = { rows: [{ status: 'passing', previous_status: 'passing' }] }; + + const client1 = makeTransactionClient([ + EMPTY, + { rows: [makeRunRow('heartbeat')] }, + statusRow, + EMPTY, + ]); + mockedGetClient.mockResolvedValueOnce(client1 as any); + await db.recordComplianceRun(minimalInput('heartbeat')); + + const heartbeatRunInsert = client1.query.mock.calls.find( + ([sql]: [string]) => typeof sql === 'string' && sql.includes('INSERT INTO agent_compliance_runs'), + ); + expect(heartbeatRunInsert).toBeDefined(); + expect(heartbeatRunInsert![1]).toContain('heartbeat'); + + const client2 = makeTransactionClient([ + EMPTY, + { rows: [makeRunRow('owner_test')] }, + statusRow, + EMPTY, + ]); + mockedGetClient.mockResolvedValueOnce(client2 as any); + await db.recordComplianceRun(minimalInput('owner_test')); + + const ownerTestRunInsert = client2.query.mock.calls.find( + ([sql]: [string]) => typeof sql === 'string' && sql.includes('INSERT INTO agent_compliance_runs'), + ); + expect(ownerTestRunInsert).toBeDefined(); + expect(ownerTestRunInsert![1]).toContain('owner_test'); + + const ownerTestUpsert = client2.query.mock.calls.find( + ([sql]: [string]) => typeof sql === 'string' && sql.includes('ON CONFLICT (agent_url) DO UPDATE'), + ); + expect(ownerTestUpsert).toBeDefined(); + }); + + it('heartbeat at T+3 wins over prior owner_test at T+2 — no source-priority filtering', async () => { + const statusRow = { rows: [{ status: 'passing', previous_status: 'passing' }] }; + + const client = makeTransactionClient([ + EMPTY, + { rows: [makeRunRow('heartbeat')] }, + statusRow, + EMPTY, + ]); + mockedGetClient.mockResolvedValueOnce(client as any); + await db.recordComplianceRun(minimalInput('heartbeat')); + + const runInsert = client.query.mock.calls.find( + ([sql]: [string]) => typeof sql === 'string' && sql.includes('INSERT INTO agent_compliance_runs'), + ); + expect(runInsert![1]).toContain('heartbeat'); + + const upsert = client.query.mock.calls.find( + ([sql]: [string]) => typeof sql === 'string' && sql.includes('ON CONFLICT (agent_url) DO UPDATE'), + ); + expect(upsert).toBeDefined(); + }); + + it('getComplianceStatus LATERAL join returns last_triggered_by from most recent non-dry run', async () => { + const now = new Date(); + mockedQuery.mockResolvedValueOnce({ + rows: [{ + agent_url: AGENT_URL, + status: 'passing', + lifecycle_stage: 'production', + last_checked_at: now, + last_passed_at: now, + last_failed_at: null, + streak_days: 1, + streak_started_at: now, + tracks_summary_json: { core: 'pass' }, + headline: null, + previous_status: null, + status_changed_at: null, + updated_at: now, + last_triggered_by: 'owner_test', + }], + rowCount: 1, + command: '', + oid: 0, + fields: [], + }); + + const status = await db.getComplianceStatus(AGENT_URL); + + expect(status).not.toBeNull(); + expect(status!.last_triggered_by).toBe('owner_test'); + + const [sql] = mockedQuery.mock.calls[0]; + expect(sql).toContain('dry_run = false'); + expect(sql).toContain('ORDER BY tested_at DESC'); + expect(sql).toContain('LIMIT 1'); + }); +}); From ea37a3b43b79e041b0133f200eeba2a1d9df86d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 9 May 2026 00:03:55 +0000 Subject: [PATCH 3/5] chore(dist): remove accidentally committed onboarding-openapi build artifacts Generated JS/TS files don't belong in source control. Also adds .gitignore rules for dist/schemas/*.{js,d.ts,*.map} to prevent recurrence. https://claude.ai/code/session_01WrFMahjaHU7y4JWqPqxTbx --- .gitignore | 4 + dist/schemas/onboarding-openapi.d.ts | 27 ----- dist/schemas/onboarding-openapi.d.ts.map | 1 - dist/schemas/onboarding-openapi.js | 135 ----------------------- dist/schemas/onboarding-openapi.js.map | 1 - 5 files changed, 4 insertions(+), 164 deletions(-) delete mode 100644 dist/schemas/onboarding-openapi.d.ts delete mode 100644 dist/schemas/onboarding-openapi.d.ts.map delete mode 100644 dist/schemas/onboarding-openapi.js delete mode 100644 dist/schemas/onboarding-openapi.js.map diff --git a/.gitignore b/.gitignore index ab324c195b..6d7c68318e 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,10 @@ server/node_modules /dist/schemas/latest /dist/schemas/v* /dist/schemas/registry.* +/dist/schemas/*.js +/dist/schemas/*.js.map +/dist/schemas/*.d.ts +/dist/schemas/*.d.ts.map /dist/compliance/latest /dist/compliance/v* /dist/protocol/latest.tgz diff --git a/dist/schemas/onboarding-openapi.d.ts b/dist/schemas/onboarding-openapi.d.ts deleted file mode 100644 index ed29eaf6de..0000000000 --- a/dist/schemas/onboarding-openapi.d.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * OpenAPI registrations for the onboarding REST surface. - * - * `POST /api/organizations` has existed in production for a long time but - * has only ever been documented as a private endpoint exercised by the AAO - * dashboard's `/onboarding` form. Surfacing it in the public spec is the - * minimum-surface answer to the storefront-bootstrap question: a - * third-party app holding only a user's OAuth token needs *one* documented - * call to materialize the org, then `POST /api/me/agents` to land an agent - * (which auto-creates the member profile on first call). - * - * Two fields the handler accepts but the public schema deliberately omits: - * - * - `membership_tier` — owned exclusively by the Stripe webhook. Accepting - * it from the caller would let any user stamp tier intent on their org - * row, leaking tier-gated UI state until/unless a real subscription - * overwrites the column. - * - `corporate_domain` — server derives the value from the authenticated - * user's email. Accepting it as a field invited 400s when a caller's - * value disagreed with their email and gave nothing back when it agreed. - * - * Kept in its own module so the spec generator's import graph stays free - * of route handlers (each route file's transitive imports pull in WorkOS - * init, which fails at module load without env vars). - */ -export {}; -//# sourceMappingURL=onboarding-openapi.d.ts.map \ No newline at end of file diff --git a/dist/schemas/onboarding-openapi.d.ts.map b/dist/schemas/onboarding-openapi.d.ts.map deleted file mode 100644 index 9e449fbf0f..0000000000 --- a/dist/schemas/onboarding-openapi.d.ts.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"onboarding-openapi.d.ts","sourceRoot":"","sources":["../../server/src/schemas/onboarding-openapi.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG"} \ No newline at end of file diff --git a/dist/schemas/onboarding-openapi.js b/dist/schemas/onboarding-openapi.js deleted file mode 100644 index d3e3074486..0000000000 --- a/dist/schemas/onboarding-openapi.js +++ /dev/null @@ -1,135 +0,0 @@ -/** - * OpenAPI registrations for the onboarding REST surface. - * - * `POST /api/organizations` has existed in production for a long time but - * has only ever been documented as a private endpoint exercised by the AAO - * dashboard's `/onboarding` form. Surfacing it in the public spec is the - * minimum-surface answer to the storefront-bootstrap question: a - * third-party app holding only a user's OAuth token needs *one* documented - * call to materialize the org, then `POST /api/me/agents` to land an agent - * (which auto-creates the member profile on first call). - * - * Two fields the handler accepts but the public schema deliberately omits: - * - * - `membership_tier` — owned exclusively by the Stripe webhook. Accepting - * it from the caller would let any user stamp tier intent on their org - * row, leaking tier-gated UI state until/unless a real subscription - * overwrites the column. - * - `corporate_domain` — server derives the value from the authenticated - * user's email. Accepting it as a field invited 400s when a caller's - * value disagreed with their email and gave nothing back when it agreed. - * - * Kept in its own module so the spec generator's import graph stays free - * of route handlers (each route file's transitive imports pull in WorkOS - * init, which fails at module load without env vars). - */ -import { z } from 'zod'; -import { registry, ErrorSchema } from './registry.js'; -const OrganizationCompanyTypeSchema = z - .enum(['adtech', 'agency', 'brand', 'publisher', 'data', 'ai', 'other']) - .openapi('OrganizationCompanyType', { - description: "Coarse classification of the organization's role in the open ad ecosystem. Drives default verification badges and the member profile's display category.", -}); -const OrganizationRevenueTierSchema = z - .enum(['under_1m', '1m_5m', '5m_50m', '50m_250m', '250m_1b', '1b_plus']) - .openapi('OrganizationRevenueTier', { - description: 'Annual revenue band, USD. Drives membership-tier eligibility for company-tier seats.', -}); -const CreateOrganizationInputSchema = z - .object({ - organization_name: z.string().min(1).max(200).openapi({ - description: "Display name for the organization. Used both as the org row name and (when auto-bootstrapping a member profile via the first agent registration) as the profile's `display_name`.", - example: 'Acme Media', - }), - is_personal: z.boolean().optional().openapi({ - description: 'Set to `true` to create a personal workspace instead of a corporate organization. Personal workspaces skip corporate-domain verification, are limited to one per user, and cannot host the `company_*` membership tiers.', - default: false, - }), - company_type: OrganizationCompanyTypeSchema.optional(), - revenue_tier: OrganizationRevenueTierSchema.optional(), - marketing_opt_in: z.boolean().optional().openapi({ - description: 'Whether the caller opted in to AAO marketing communications. Recorded once per user (not overwritten on subsequent calls). Independent of Terms-of-Service consent, which is recorded server-side from the request context.', - default: false, - }), -}) - .openapi('CreateOrganizationInput', { - description: [ - 'Request body for `POST /api/organizations`.', - "Bootstraps a WorkOS organization, mirrors the caller as `owner`, records the caller's ToS / privacy-policy acceptance, and (for non-personal orgs) inserts an email-verified record into `organization_domains` so subsequent registry calls can skip explicit domain-verification.", - "Membership tier and corporate domain are *not* caller-supplied: the tier is set by the Stripe webhook on subscription events, and the corporate domain is derived from the authenticated user's email.", - ].join('\n\n'), -}); -const CreateOrganizationResponseSchema = z - .object({ - success: z.boolean().optional(), - organization: z - .object({ - id: z.string().openapi({ example: 'org_01HXZAB123' }), - name: z.string().openapi({ example: 'Acme Media' }), - }) - .optional(), - id: z.string().optional().openapi({ - description: "Set on the **prospect-adoption** path: when an org with the user's email domain already exists in a `prospect` state (i.e. the registry pre-recorded it from a brand crawl but no human had claimed it yet), this call adopts that org for the caller instead of creating a new one.", - }), - name: z.string().optional(), - adopted: z.boolean().optional().openapi({ - description: '`true` when the response is the prospect-adoption path. When `true`, no new WorkOS organization was created — the caller is now the owner of an existing prospect record.', - }), -}) - .openapi('CreateOrganizationResponse', { - description: 'Response from `POST /api/organizations`. The body shape varies by path: a fresh creation returns `{ success: true, organization: { id, name } }`; a prospect adoption returns `{ id, name, adopted: true }` directly. Both paths are 2xx; downstream callers should treat any `2xx` as "the org now exists and you are an owner of it" and read whichever id is present.', -}); -registry.registerPath({ - method: 'post', - path: '/api/organizations', - operationId: 'createOrganization', - summary: 'Create or adopt my organization', - description: [ - "Bootstrap the caller's organization explicitly. Use this when the caller wants to control the organization name, `company_type`, `revenue_tier`, or `is_personal` flag before any agents are registered.", - "**Most storefront-style integrations don't need this call** — `POST /api/me/agents` will auto-create an org for a fresh OAuth user (corporate or personal workspace based on the email domain) and surface `org_auto_created: true` in the response. Reach for `POST /api/organizations` only when the auto-derived defaults aren't acceptable.", - 'Three outcomes depending on the caller\'s state:', - "- **Fresh create** (most common): a new WorkOS organization is created, the caller is added as `owner`, the corporate domain is recorded as email-verified, and ToS / privacy-policy acceptance is logged from the request context. Returns `{ success: true, organization: { id, name } }`.", - "- **Prospect adoption**: an organization with the caller's email domain already exists as a `prospect` (the registry pre-recorded it from a brand crawl but no human had claimed it yet). The caller is promoted to `owner` of the existing record instead of forking a duplicate. Returns `{ id, name, adopted: true }`.", - '- **Already-active conflict**: the org exists and is already claimed by another paying member or a previously joined user. Returns `409` with the existing org id so the caller can switch to a join-request flow (`POST /api/organizations/:orgId/join-requests`) instead of trying to register a duplicate.', - 'Tier transitions happen via the billing flow only — there is no `membership_tier` field on this endpoint. After org creation, send the user to `POST /api/checkout-session` (or the dashboard `/membership` page) to start a subscription; the Stripe webhook is the sole writer of `organizations.membership_tier`.', - 'Rate-limited per user: `15` failed attempts per hour; successful calls do not count against the limit so a legitimate registration is never penalized by earlier validation errors.', - ].join('\n\n'), - tags: ['Onboarding'], - security: [{ bearerAuth: [] }, { oauth2: [] }], - request: { - body: { content: { 'application/json': { schema: CreateOrganizationInputSchema } } }, - }, - responses: { - 200: { - description: 'Prospect adoption — an existing prospect organization for this domain was claimed by the caller. Body is `{ id, name, adopted: true }`.', - content: { 'application/json': { schema: CreateOrganizationResponseSchema } }, - }, - 201: { - description: 'New organization created. Body is `{ success: true, organization: { id, name } }`. The caller is the `owner`; the corporate domain is recorded as email-verified for downstream registry calls.', - content: { 'application/json': { schema: CreateOrganizationResponseSchema } }, - }, - 400: { - description: [ - 'One of:', - '- `organization_name` missing or invalid', - '- `company_type` / `revenue_tier` value not in the documented enum', - "- caller is on a personal-email domain (gmail.com, yahoo.com, …) and is trying to register a corporate org — register `is_personal: true` instead", - '- per-user organization cap reached (10 orgs per user)', - ].join('\n'), - content: { 'application/json': { schema: ErrorSchema } }, - }, - 401: { - description: 'Authentication required', - content: { 'application/json': { schema: ErrorSchema } }, - }, - 409: { - description: "An active organization already exists for this caller's email domain. The body includes `existing_org_id` and `existing_org_name`; the caller should switch to the join-request flow rather than retrying.", - content: { 'application/json': { schema: ErrorSchema } }, - }, - 429: { - description: 'Rate limit exceeded — 15 failed attempts per hour per user. Successful calls do not count against the limit.', - content: { 'application/json': { schema: ErrorSchema } }, - }, - }, -}); -//# sourceMappingURL=onboarding-openapi.js.map \ No newline at end of file diff --git a/dist/schemas/onboarding-openapi.js.map b/dist/schemas/onboarding-openapi.js.map deleted file mode 100644 index 9fb894c315..0000000000 --- a/dist/schemas/onboarding-openapi.js.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"file":"onboarding-openapi.js","sourceRoot":"","sources":["../../server/src/schemas/onboarding-openapi.ts"],"names":[],"mappings":"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"} \ No newline at end of file From 42e7f37efa4581d36e2c6fab2d2ac471eaa09b06 Mon Sep 17 00:00:00 2001 From: Emma Mulitz Date: Fri, 8 May 2026 20:07:22 -0400 Subject: [PATCH 4/5] =?UTF-8?q?fix(migrate):=20renumber=20471=20=E2=86=92?= =?UTF-8?q?=20472=20(resolve=20clash=20with=20manager=5Frevalidation=5Fque?= =?UTF-8?q?ue=20on=20main)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...wner_test_triggered_by.sql => 472_owner_test_triggered_by.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename server/src/db/migrations/{471_owner_test_triggered_by.sql => 472_owner_test_triggered_by.sql} (100%) diff --git a/server/src/db/migrations/471_owner_test_triggered_by.sql b/server/src/db/migrations/472_owner_test_triggered_by.sql similarity index 100% rename from server/src/db/migrations/471_owner_test_triggered_by.sql rename to server/src/db/migrations/472_owner_test_triggered_by.sql From 54a4f169592c715dbceb537aa4d06081ba8cd387 Mon Sep 17 00:00:00 2001 From: Emma Mulitz Date: Fri, 8 May 2026 20:10:58 -0400 Subject: [PATCH 5/5] =?UTF-8?q?fix(addie):=20address=20Brian's=20review=20?= =?UTF-8?q?=E2=80=94=20DDL=20lock=20guard=20+=20cross-org=20gap=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/addie/mcp/member-tools.ts | 6 ++++++ .../src/db/migrations/472_owner_test_triggered_by.sql | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/server/src/addie/mcp/member-tools.ts b/server/src/addie/mcp/member-tools.ts index 9d0eb46ff9..3cc4e08e58 100644 --- a/server/src/addie/mcp/member-tools.ts +++ b/server/src/addie/mcp/member-tools.ts @@ -3602,6 +3602,12 @@ export function createMemberToolHandlers( // Owner test runs are not dry runs — they update the live public record. // (complianceResultToDbInput hard-codes dry_run: true; override here.) dry_run: false, + // Known gap (deferred to follow-up): the canonical write doesn't + // carry the triggering org id. If two orgs both own the same + // agent URL (rare — staging vs prod orgs of one publisher), an + // owner_test from Org A surfaces in Org B's dashboard without + // attribution. Acceptable for the unblock; full fix adds + // `triggered_org_id` to agent_compliance_runs (#4247 PR 4). }; await complianceDb.recordComplianceRun(dbInput); // notifyComplianceChange intentionally omitted: owner test runs are diff --git a/server/src/db/migrations/472_owner_test_triggered_by.sql b/server/src/db/migrations/472_owner_test_triggered_by.sql index 0e450bb1ad..32cb32b474 100644 --- a/server/src/db/migrations/472_owner_test_triggered_by.sql +++ b/server/src/db/migrations/472_owner_test_triggered_by.sql @@ -3,6 +3,14 @@ -- canonical compliance state, distinguished from heartbeat and dashboard-manual -- runs by triggered_by = 'owner_test'. See issue #4247. +-- DDL lock guard: a long-running app transaction (compliance heartbeat, owner +-- test, snapshot read) holds AccessShareLock on these tables. ADD CONSTRAINT +-- needs AccessExclusiveLock and would queue indefinitely behind those readers, +-- blocking every subsequent statement on the table for the duration. The +-- timeout fails the migration loud instead of stalling the deploy; on +-- failure, retry the release after the contending transaction settles. +SET lock_timeout = '5s'; + ALTER TABLE agent_compliance_runs DROP CONSTRAINT IF EXISTS valid_triggered_by, ADD CONSTRAINT valid_triggered_by CHECK ( @@ -14,3 +22,5 @@ ALTER TABLE agent_storyboard_status ADD CONSTRAINT valid_storyboard_triggered_by CHECK ( triggered_by IS NULL OR triggered_by IN ('heartbeat', 'manual', 'webhook', 'owner_test') ); + +RESET lock_timeout;