{
  "slug": "tenant-public-api",
  "spec_file": "docs/specs/2026-05-31-tenant-public-api.md",
  "findings": [
    {
      "id": "tenant-public-api-001",
      "severity": "P0",
      "type": "security",
      "summary": "POST /v1/invoices does not verify customer_id belongs to API key tenant",
      "spec_ref": "POST /v1/invoices — customer_id must belong to tenant; Tenant Isolation",
      "code_ref": "apps/zync-public-api/src/routes/invoices.ts:157",
      "evidence": "Route calls createInvoice(db, apiKey.tenantId, ...) with parsed customer_id only. packages/db/src/queries/invoices.ts:451-487 inserts customerId with no tenant ownership check; invoices.customer_id FK references customers.id only (schema/invoices.ts:66), not customers.tenant_id.",
      "repro": "With tenant A API key (customers:write), POST /v1/invoices with customer_id UUID from tenant B; invoice row created under tenant A referencing tenant B customer.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-002",
      "severity": "P0",
      "type": "security",
      "summary": "POST /v1/invoices does not verify project_id belongs to API key tenant",
      "spec_ref": "POST /v1/invoices — project_id optional; must belong to tenant",
      "code_ref": "apps/zync-public-api/src/routes/invoices.ts:160",
      "evidence": "project_id passed through to createInvoice without tenant lookup. projects table is tenant-scoped (schema/projects.ts:33-35) but invoices.project_id FK is projects.id only.",
      "repro": "POST /v1/invoices with project_id from another tenant; invoice links cross-tenant.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-003",
      "severity": "P0",
      "type": "security",
      "summary": "POST /v1/tasks sets reporter_id to API key UUID instead of key owner user id",
      "spec_ref": "POST /v1/tasks — reporter_id = tenant_api_keys.created_by",
      "code_ref": "apps/zync-public-api/src/routes/tasks.ts:121",
      "evidence": "reporter_id: apiKey.keyId (tenant_api_keys.id). tasks.reporter_id FK references users.id (schema/tasks.ts:84-86), not tenant_api_keys.id.",
      "repro": "POST /v1/tasks with valid key; Postgres FK violation or 500 on insert.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-004",
      "severity": "P0",
      "type": "security",
      "summary": "POST /v1/tasks does not validate status_id/project_id belong to tenant",
      "spec_ref": "POST /v1/tasks — project_id and status_id must belong to tenant",
      "code_ref": "packages/db/src/queries/tasks.ts:249-265",
      "evidence": "createTask inserts statusId and projectId with only tasks.tenantId set; FKs reference task_statuses.id and projects.id without tenant match.",
      "repro": "POST /v1/tasks with status_id or project_id UUID from another tenant; task created under caller tenant referencing foreign-tenant column/project.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-005",
      "severity": "P1",
      "type": "missing",
      "summary": "PATCH /v1/invoices/:id/status missing spec transitions (TAX_ISSUED, PAID, REJECTED→DRAFT)",
      "spec_ref": "PATCH /v1/invoices/:id/status — Allowed transitions table",
      "code_ref": "apps/zync-public-api/src/routes/invoices.ts:60-66",
      "evidence": "statusUpdateSchema enum is SENT|APPROVED|REJECTED|VOID only. No handlers for TAX_ISSUED, PAID, or REJECTED→DRAFT; issueTaxInvoice/recordPayment exist in packages/db/src/queries/invoices.ts:913,984 but are not wired.",
      "repro": "PATCH status TAX_ISSUED or PAID returns 422 Invalid status value.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-006",
      "severity": "P1",
      "type": "missing",
      "summary": "PATCH /v1/customers/:id cannot archive customers (status field absent)",
      "spec_ref": "PATCH /v1/customers/:id — status active|archived; 409 on open invoices",
      "code_ref": "apps/zync-public-api/src/routes/customers.ts:49",
      "evidence": "updateBodySchema is createBodySchema.partial() with no status field. updateCustomer call (line 138) never passes status; archiveCustomer in packages/db/src/queries/customers.ts:411 exists but is unused.",
      "repro": "PATCH /v1/customers/:id { \"status\": \"archived\" } — field ignored, customer stays active.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-007",
      "severity": "P1",
      "type": "missing",
      "summary": "Invalid API key brute-force limits do not match spec (no per-prefix counter)",
      "spec_ref": "Invalid API Key Rate Limiting — per-prefix 10/min, per-IP 50/min, too_many_invalid_requests",
      "code_ref": "apps/zync-public-api/src/middleware/auth.ts:57-86",
      "evidence": "BRUTE_FORCE_MAX=5, key bf:${ip} only. grep api_key_fail in apps/zync-public-api returns no matches. Spec requires api_key_fail:${sha256(prefix)}:${minute-window} limit 10 and IP limit 50 on invalid-key responses only.",
      "repro": "Brute-force key discovery uses 5-attempt IP gate before lookup, not spec per-prefix tracking after invalid_api_key.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-008",
      "severity": "P1",
      "type": "missing",
      "summary": "Auth error codes unauthenticated/invalid_api_key/api_key_expired not implemented",
      "spec_ref": "Authentication — Auth Flow; Error Format — 401 codes",
      "code_ref": "packages/public-api/src/errors.ts:29-31",
      "evidence": "errUnauthorized always returns { error: 'unauthorized' }. Expired key path (auth.ts:96-98) calls errUnauthorized('API key has expired') — no api_key_expired code. Missing/malformed Bearer also returns unauthorized, not unauthenticated.",
      "repro": "Expired key returns 401 error unauthorized; spec requires api_key_expired.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-009",
      "severity": "P1",
      "type": "missing",
      "summary": "Named error constructors incomplete (conflict, invalid_transition, tax_issued_immutable, no_statuses_configured, too_many_invalid_requests, invalid_json)",
      "spec_ref": "Error Format; Plan Task 3",
      "code_ref": "packages/public-api/src/errors.ts:27-67",
      "evidence": "grep errConflict|invalid_transition|tax_issued|no_statuses|too_many_invalid|invalid_json in packages/public-api returns no matches. Plan Task 3 acceptance requires constructors for every documented code.",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-010",
      "severity": "P1",
      "type": "missing",
      "summary": "POST /v1/tasks requires status_id; spec default-first-status when omitted",
      "spec_ref": "POST /v1/tasks — status_id optional; 422 no_statuses_configured",
      "code_ref": "apps/zync-public-api/src/routes/tasks.ts:37",
      "evidence": "createBodySchema requires status_id: z.string().uuid(). No lookup of first task_statuses by position; grep no_statuses_configured in apps/zync-public-api returns no matches.",
      "repro": "POST /v1/tasks without status_id fails Zod validation before handler logic.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-011",
      "severity": "P1",
      "type": "missing",
      "summary": "OpenAPI not generated from @hono/zod-openapi route definitions",
      "spec_ref": "OpenAPI / Documentation; Plan Task 13",
      "code_ref": "apps/zync-public-api/src/openapi.ts:18",
      "evidence": "grep @hono/zod-openapi in apps/zync-public-api returns no matches. buildOpenApiDocument() is hand-written static object; package.json deps omit @hono/zod-openapi.",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-012",
      "severity": "P1",
      "type": "missing",
      "summary": "Global uncaught error handler and invalid_json handler absent",
      "spec_ref": "Plan Task 13 — malformed JSON 400 invalid_json; uncaught 500 internal_error",
      "code_ref": "apps/zync-public-api/src/index.ts:81",
      "evidence": "index.ts has notFound only; grep onError|internal_error|invalid_json in apps/zync-public-api returns no matches. Body parsers use c.req.json().catch(() => null) → errValidation, not invalid_json.",
      "repro": "Send malformed JSON body; receive 422 validation_error not 400 invalid_json.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-013",
      "severity": "P2",
      "type": "divergent",
      "summary": "Per-key rate limit uses sliding 60s TTL not fixed 1-minute epoch window",
      "spec_ref": "Rate Limiting — Implementation (ratelimit:${apiKeyId}:${floor(ts/60000)})",
      "code_ref": "apps/zync-public-api/src/middleware/rate-limit.ts:20-35",
      "evidence": "KV key rl:${apiKey.keyId} with expirationTtl:60 on first request — not minute-epoch keyed. Spec requires Math.floor(Date.now()/60_000) window with 120s TTL.",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-014",
      "severity": "P2",
      "type": "divergent",
      "summary": "X-RateLimit-Limit/Remaining/Reset headers never set on responses",
      "spec_ref": "Rate Limit Headers",
      "code_ref": "apps/zync-public-api/src/middleware/rate-limit.ts:13-37",
      "evidence": "grep X-RateLimit- in apps/zync-public-api only matches CORS exposeHeaders in index.ts:29; rate-limit middleware never sets headers on success or 429.",
      "repro": "Authenticated request succeeds; response lacks X-RateLimit-* headers.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-015",
      "severity": "P2",
      "type": "divergent",
      "summary": "429 rate_limited message and retry_after do not match spec",
      "spec_ref": "429 Response — rate_limited envelope",
      "code_ref": "packages/public-api/src/errors.ts:57-62",
      "evidence": "errRateLimited message is 'Too many requests...' not 'Rate limit exceeded. 100 requests per minute allowed.'; perKeyRateLimit passes WINDOW_SECONDS (60) not seconds-to-next-minute-boundary (1–60).",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-016",
      "severity": "P2",
      "type": "divergent",
      "summary": "last_used_at update not fire-and-forget via ctx.waitUntil",
      "spec_ref": "Auth Flow step 6; last_used_at Update Logic",
      "code_ref": "apps/zync-public-api/src/middleware/auth.ts:134",
      "evidence": "void updateApiKeyLastUsed(db, keyRow.id) is awaited inline path, not wrapped in c.executionCtx.waitUntil. waitUntil used only for writeApiUsage (lines 135-144).",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-017",
      "severity": "P2",
      "type": "divergent",
      "summary": "Auth flow order: expiry checked before tier gate",
      "spec_ref": "Auth Flow — tier gate before key expiry (steps 3–4)",
      "code_ref": "apps/zync-public-api/src/middleware/auth.ts:96-110",
      "evidence": "expiresAt check at lines 96-98 runs before meetsMinimumTier at 108-110. Spec: tier gate after lookup, before expiry.",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-018",
      "severity": "P2",
      "type": "divergent",
      "summary": "Scope check in per-route handlers not global auth middleware",
      "spec_ref": "Auth Flow step 5; Plan Task 8",
      "code_ref": "apps/zync-public-api/src/middleware/auth.ts:70",
      "evidence": "apiKeyAuth never calls hasScope or SCOPE_MAP. Each route duplicates scope checks (e.g. customers.ts:53). Spec requires middleware scope gate before handler.",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-019",
      "severity": "P2",
      "type": "divergent",
      "summary": "List pagination envelope uses meta wrapper; spec uses top-level next_cursor/has_more",
      "spec_ref": "Pagination — PaginatedResponse<T>",
      "code_ref": "apps/zync-public-api/src/routes/customers.ts:70-78",
      "evidence": "Response shape { data, meta: { total, next_cursor, has_more, limit } }. Spec PaginatedResponse has data, next_cursor, has_more at top level (no meta, no total).",
      "repro": "GET /v1/customers returns meta.total and meta.next_cursor instead of top-level next_cursor.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-020",
      "severity": "P2",
      "type": "divergent",
      "summary": "Single-resource GET responses wrapped in { data } not bare object",
      "spec_ref": "GET /v1/customers/:id — Response: CustomerObject",
      "code_ref": "apps/zync-public-api/src/routes/customers.ts:92-94",
      "evidence": "Returns c.json({ data: serializeCustomer(...) }). Spec shows CustomerObject at root for GET :id endpoints.",
      "repro": "GET /v1/customers/:id returns { data: { id, name, ... } }.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-021",
      "severity": "P2",
      "type": "divergent",
      "summary": "GET /v1/customers list does not default status=active",
      "spec_ref": "GET /v1/customers — status default active",
      "code_ref": "packages/db/src/queries/customers.ts:219-220",
      "evidence": "status filter applied only when opts.status provided (line 220). Route listQuerySchema marks status optional with no default pass-through.",
      "repro": "GET /v1/customers without status returns active and archived customers.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-022",
      "severity": "P2",
      "type": "divergent",
      "summary": "Customer search filters name/company not name/email",
      "spec_ref": "GET /v1/customers — search substring on name or email",
      "code_ref": "packages/db/src/queries/customers.ts:222",
      "evidence": "ILIKE on customers.name OR customers.company; email column not included in search predicate.",
      "repro": "GET /v1/customers?search=user@example.com misses customer with that email.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-023",
      "severity": "P2",
      "type": "divergent",
      "summary": "GET /v1/invoices list returns invoices without line items",
      "spec_ref": "GET /v1/invoices — PaginatedResponse<InvoiceObject> with lines",
      "code_ref": "apps/zync-public-api/src/routes/invoices.ts:88-97",
      "evidence": "serializeInvoice(..., []) always passes empty lines array in list handler.",
      "repro": "GET /v1/invoices returns invoices with lines: [].",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-024",
      "severity": "P2",
      "type": "divergent",
      "summary": "GET /v1/invoices missing from_date/to_date filters",
      "spec_ref": "GET /v1/invoices — from_date, to_date on created_at",
      "code_ref": "apps/zync-public-api/src/routes/invoices.ts:37-42",
      "evidence": "listQuerySchema has customer_id and status only. listInvoices query supports dateFrom/dateTo (invoices.ts:360-365) but route never passes them.",
      "repro": "GET /v1/invoices?from_date=2026-01-01 ignored.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-025",
      "severity": "P2",
      "type": "divergent",
      "summary": "Invoice status PATCH exposes VOID not in public API transition table",
      "spec_ref": "PATCH /v1/invoices/:id/status — Allowed transitions",
      "code_ref": "apps/zync-public-api/src/routes/invoices.ts:235-237",
      "evidence": "statusUpdateSchema includes VOID; voidInvoice called. Spec public API allows DRAFT→SENT, SENT→APPROVED/REJECTED, REJECTED→DRAFT, APPROVED→TAX_ISSUED, TAX_ISSUED→PAID only.",
      "repro": "PATCH { status: VOID } on SENT invoice succeeds via voidInvoice; not in spec matrix.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-026",
      "severity": "P2",
      "type": "divergent",
      "summary": "GET /v1/tasks uses board column ordering not created_at DESC cursor pagination",
      "spec_ref": "Pagination — ORDER BY created_at DESC, id DESC; POST /v1/tasks filters",
      "code_ref": "packages/db/src/queries/tasks.ts:179",
      "evidence": "listTasks orders asc(statusId), asc(position), asc(id) with composite cursor {statusId,position,id}. Route omits status_id and priority query params (tasks.ts:28-33).",
      "repro": "GET /v1/tasks?status_id=...&priority=high not supported; cursor format incompatible with spec {id} base64.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-027",
      "severity": "P2",
      "type": "divergent",
      "summary": "TaskObject response missing reporter_id, labels, estimated_hours; status_name always empty",
      "spec_ref": "TaskObject interface",
      "code_ref": "packages/public-api/src/serializers.ts:170-228",
      "evidence": "serializeTask omits reporter_id, labels, estimated_hours. Routes pass statusName '' (tasks.ts:77,97,126).",
      "repro": "GET /v1/tasks/:id returns status_name:\"\" and no reporter_id field.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-028",
      "severity": "P2",
      "type": "divergent",
      "summary": "GET /v1/events missing filters, cursor pagination, and delivered_at field",
      "spec_ref": "GET /v1/events — ListEventsParams; EventObject",
      "code_ref": "apps/zync-public-api/src/routes/events.ts:25-90",
      "evidence": "listQuerySchema has limit/status only (no event_type, from_date, to_date, cursor). serializeEvent returns latency_ms not delivered_at (serializers.ts:371-413). webhook_deliveries schema has no delivered_at column (webhooks.ts:66-85).",
      "repro": "GET /v1/events?event_type=invoice.paid ignored; response lacks delivered_at.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-029",
      "severity": "P2",
      "type": "divergent",
      "summary": "OpenAPI served at /api/openapi.json not GET /v1/openapi.json",
      "spec_ref": "Plan Task 13 — GET /v1/openapi.json",
      "code_ref": "apps/zync-public-api/src/index.ts:37",
      "evidence": "app.get('/api/openapi.json', ...) — not under /v1/ prefix per plan acceptance.",
      "repro": "GET /v1/openapi.json returns 404 not_found.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-030",
      "severity": "P2",
      "type": "divergent",
      "summary": "Customer archive open-invoice guard always disabled (openCount=0)",
      "spec_ref": "PATCH /v1/customers/:id — 409 Cannot archive customer with open invoices",
      "code_ref": "packages/db/src/queries/customers.ts:418-422",
      "evidence": "archiveCustomer hardcodes const openCount = 0 with comment 'When invoices-core lands'. Invoices module exists; guard never enforces.",
      "repro": "Archive customer with unpaid invoices — succeeds without 409.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-031",
      "severity": "P2",
      "type": "divergent",
      "summary": "Invoice status transitions do not emit spec webhook events",
      "spec_ref": "PATCH /v1/invoices/:id/status — SENT→APPROVED, APPROVED→TAX_ISSUED, TAX_ISSUED→PAID webhooks",
      "code_ref": "packages/db/src/queries/invoices.ts:787-840",
      "evidence": "approveInvoice/issueTaxInvoice/recordPayment write audit_log only; grep enqueue|dispatchWebhook in invoices.ts returns no matches.",
      "repro": "PATCH invoice to APPROVED/TAX_ISSUED/PAID via public API; no invoice.proforma_approved/invoice.issued/invoice.paid outbound webhook.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-032",
      "severity": "P2",
      "type": "divergent",
      "summary": "tenant_api_keys.scopes stored as JSONB not spec TEXT[]",
      "spec_ref": "Schema — tenant_api_keys scopes TEXT[]",
      "code_ref": "packages/db/src/schema/webhooks.ts:152",
      "evidence": "Drizzle column scopes: jsonb('scopes'). Migration 0014_wave11_consolidated.sql:34 defines jsonb DEFAULT '[]'. Spec transcribes TEXT[] NOT NULL.",
      "repro": "",
      "fix_direction": "spec_review"
    },
    {
      "id": "tenant-public-api-033",
      "severity": "P3",
      "type": "divergent",
      "summary": "403 insufficient_scope message wording differs from spec",
      "spec_ref": "Error Format — insufficient_scope example",
      "code_ref": "packages/public-api/src/errors.ts:33-38",
      "evidence": "Message: \"This operation requires the 'customers:read' scope\". Spec: \"This endpoint requires the 'customers:write' scope.\"",
      "repro": "",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-public-api-034",
      "severity": "P3",
      "type": "divergent",
      "summary": "wrangler.toml uses PLACEHOLDER binding IDs for Hyperdrive and KV namespaces",
      "spec_ref": "Foundation Deltas — RATELIMIT_KV binding; Plan Task 1",
      "code_ref": "apps/zync-public-api/wrangler.toml:12,17,22",
      "evidence": "id = PLACEHOLDER_HYPERDRIVE_ID / PLACEHOLDER_RATELIMIT_KV_ID / PLACEHOLDER_AUTH_RATE_LIMITER_KV_ID — not deployable as-is.",
      "repro": "",
      "fix_direction": "code_to_spec"
    }
  ],
  "summary": { "P0": 4, "P1": 8, "P2": 20, "P3": 2, "total": 34 }
}
