Skip to content

Latest commit

 

History

History
1073 lines (846 loc) · 103 KB

File metadata and controls

1073 lines (846 loc) · 103 KB

VendorSync — Build Progress

Use this file to resume between sessions. Updated at the end of every phase.


⚠️ CRITICAL DATA RULES — NEVER VIOLATE

Rule Detail
NEVER docker compose down -v The -v flag deletes all named volumes including vendorsync_postgres_data and vendorsync_redis_data — ALL database data is permanently lost. Always use docker compose down (no flags) or docker compose restart.
All source code is on disk ./backend/ and ./frontend/src/ are bind-mounted — code changes survive container recreation. Never write code only inside a container.
DB data lives in named volumes vendorsync_postgres_data and vendorsync_redis_data are Docker named volumes. Safe across docker compose down + up. Lost only with -v or explicit docker volume rm.
Back up DB before risky operations Run docker exec vendorsync-postgres-1 pg_dump -U vendorsync vendorsync > backup.sql before any migration or destructive change.
Bypass user must exist in DB The bypass user UUID b4cdf432-17b0-5c62-a085-ddede280151d must exist in the users table. If DB is recreated, re-insert it (see init section below).
FERNET_KEY must be in .env Required for all encrypted field saves. If lost, all encrypted secrets in DB become unreadable. Back up .env file.

If DB is ever wiped — re-run this to restore bypass user:

INSERT INTO users (id, okta_subject_id, email, name, role, is_active, created_at, updated_at)
VALUES ('b4cdf432-17b0-5c62-a085-ddede280151d', 'bypass', 'dev@vendorsync.dev', 'Dev User', 'admin', true, now(), now())
ON CONFLICT (id) DO NOTHING;

Quick DB backup:

docker exec vendorsync-postgres-1 pg_dump -U vendorsync vendorsync > backup_$(date +%Y%m%d).sql

Restore from backup:

docker exec -i vendorsync-postgres-1 psql -U vendorsync vendorsync < backup_YYYYMMDD.sql

Standing conventions (apply to every phase)

These are set once and never revisited — every phase must follow them:

Convention Rule
Line endings LF everywhere. .gitattributes enforces this. Windows devs: git config --global core.autocrlf input
Python paths Always pathlib.Path. Never os.path string concat. PTH ruff rule enforces this.
Node paths Always path.posix.join() or node:path. Never template-literal path building.
Storage paths in DB Always PurePosixPath — forward slashes, OS-independent
File naming snake_case.py, kebab-case.ts/tsx. Lowercase always. Linux is case-sensitive.
Commits Phase N: <description> after every phase. Update PROGRESS.md before committing.
Docs update Any new feature, endpoint, UI change, or architectural decision MUST be reflected in PROGRESS.md (post-phase fixes section) and relevant docs/ files before committing. Never leave docs stale.
Secrets in schemas Never expose *_encrypted or raw credential fields in Read schemas
Auth bypass AUTH_BYPASS_ENABLED=true in .env.example for dev. Production guard in Settings.
Time display Always use formatDateTime() / formatTime() / formatRelative() from @/lib/time. Never new Date().toLocaleString() directly. Uses browser local timezone via Intl.DateTimeFormat.
API base URL Derived from window.location.hostname at runtime — never rely on NEXT_PUBLIC_* for the API base.

Current status

Last completed phase: Post-S10m — Pipeline reliability fixes (bool/Redis, pre-filter dedup, KEV cursor, OSV CVSS, is_new dedup) Current state: Stack running, all services healthy. Security Watch pipeline fully functional end-to-end. 85+ security tickets created including Log4Shell (CVSS 10.0) and Spring4Shell. Frontend URL: http://localhost:4111 Backend health: http://localhost/health API docs: http://localhost/api/docs Git branch: main Last commit: feat: fix system status API client, WebSocket real-time updates, Settings UI with encrypted tokens, timezone-aware time formatting


How to start the stack

# 1. Copy env file and fill in secrets
cp .env.example .env
# Edit .env — at minimum set POSTGRES_PASSWORD, VENDORSYNC_SECRET_KEY, AUTH_SECRET

# 2. Start everything
docker compose up --build

# 3. Run migrations (first time only)
docker compose exec backend alembic upgrade head

# 4. Verify
curl http://localhost/health

URLs:

  • Frontend: http://localhost:4111
  • Backend health: http://localhost/health
  • API docs (dev only): http://localhost/api/docs
  • Traefik dashboard: http://localhost:8080

Auth bypass is ON by default in .env.example (AUTH_BYPASS_ENABLED=true).
All API calls are auto-authenticated as dev@vendorsync.dev (admin role).
Set AUTH_BYPASS_ENABLED=false and configure Okta vars when ready to test real auth.


Phases

✅ Phase 1 — Foundation

Commit: Phase 1: Foundation — Docker Compose, FastAPI scaffold, Next.js scaffold

What was built:

  • Full directory structure (backend/, frontend/, traefik/, data/)
  • docker-compose.yml — 7 services: traefik, postgres, redis, backend, worker, scheduler, frontend
  • .env.example with all required vars documented
  • traefik/traefik.yml — Docker provider, port 80, dashboard on 8080
  • Backend: Dockerfile, pyproject.toml, app/main.py, app/core/config.py, app/core/database.py, app/core/redis.py, app/core/security.py
  • GET /health endpoint — checks Postgres + Redis, surfaces auth bypass status
  • Worker and scheduler stub entry points
  • Alembic scaffold (env.py, script.py.mako, alembic.ini)
  • Frontend: Dockerfile, package.json, next.config.ts, tsconfig.json, tailwind.config.ts (full Precision Enterprise tokens), postcss.config.mjs, globals.css, layout.tsx, page.tsx
  • src/lib/utils.ts (cn() helper), src/lib/types/index.ts (all domain TS types)
  • Auth bypass: app/auth/bypass.pyAUTH_BYPASS_ENABLED env var, refuses to start in production
  • Git repo initialized

Key decisions:

  • Auth bypass defaults to true in .env.example for dev convenience
  • Production guard: Settings.refuse_bypass_in_production() raises on startup if bypass + production
  • Bypass status surfaced in /health response

✅ Phase 2 — Data layer

Commit: Phase 2: Data layer — models, migration, schemas

What was built:

  • app/models/enums.py — all 15 Python enums matching DB enum types
  • app/models/mixins.pyUUIDPrimaryKey, TimestampMixin, SoftDeleteMixin
  • app/models/core.pyUser, Vendor, Application, ApplicationVendor, Rule
  • app/models/tickets.pyTicket, TicketApplication, TicketJiraLink, TicketNote
  • app/models/email.pyEmailSource, SourceEmail, EmailAttachment
  • app/models/config.pyLLMConfig, JiraConfig, SystemSettings, AuditLog, NotificationLog
  • app/models/__init__.py — exports all models so Alembic autogenerate sees them
  • alembic/versions/0001_initial_schema.py — hand-written migration: all enum types, ticket_number_seq, all 17 tables, all indexes including the JSONB expression index for notification de-duplication
  • app/schemas/__init__.py — Pydantic v2 schemas for all entities: Create/Update/Read variants, secrets excluded from Read schemas, PaginatedResponse[T] generic
  • tests/test_models.py — enum coverage, schema validation, secret exclusion, model import test

Key decisions:

  • metadata_ column alias used for AuditLog.metadata and NotificationLog.metadata to avoid shadowing Python's metadata attribute
  • ticket_number_seq created before tickets table in migration
  • All secrets (api_key_encrypted, password_encrypted, api_token_encrypted) excluded from Read schemas
  • POP3 confirmed absent from EmailProtocol enum (test enforces this)
  • non_jira_status defaults to pending on all TicketApplication rows at creation

✅ Phase 3 — Auth

Commit: Phase 3: Auth — Okta OIDC, JWT validation, role middleware, bypass, provisioning

What was built:

  • app/auth/okta.py — async JWKS fetch with 1-hour TTL cache, JWT validation via authlib (RS256), validates issuer + audience + expiry, HTTP 401 on bad token, HTTP 503 on JWKS unavailable
  • app/auth/dependencies.pyget_current_user FastAPI dependency: bypass-aware, validates Bearer token, calls _get_or_provision_user, rejects disabled users, updates last_login_at
  • app/auth/dependencies.py_get_or_provision_user: looks up by okta_subject_id, creates new user on first login, applies bootstrap admin logic, respects auth.auto_provision system setting
  • app/auth/middleware.pyrequire_role(*roles) dependency factory, require_team_access() inline check, pre-built shortcuts (require_admin, require_admin_or_change_manager, require_any_authenticated)
  • app/api/auth.pyGET /api/auth/me (any authenticated), GET /api/auth/users (admin), PATCH /api/auth/users/{id} (admin) with audit log entries for role changes and disable/enable
  • app/main.py — auth router registered
  • tests/test_auth.py — 22 tests: bypass user, require_role per role, require_team_access all cases, /me endpoint, bootstrap admin, auto-provision disabled, invalid JWT, JWKS unavailable, disabled user

Key decisions:

  • get_current_user returns User | BypassUser — both satisfy the same interface so route handlers work identically in bypass and real mode
  • JWKS cache is module-level with _clear_jwks_cache() for test isolation
  • _get_or_provision_user uses db.flush() not db.commit() — the session commit happens in get_db after the request completes
  • Bootstrap admin is idempotent — re-applying BOOTSTRAP_ADMINS never downgrades an existing admin
  • require_role handles both UserRole enum and string values (bypass user stores role as string)

✅ Phase 4 — Ticket engine

Commit: Phase 4: Ticket engine — state machine, CRUD API, audit log, manual override

What was built:

  • app/services/audit.pywrite_audit_log() used by all state changes; append-only, never updates rows
  • app/services/ticket_state.py — full state machine: transition(), transition_system(), transition_skip_through() (two audit entries in one tx), confirm_closure() (two-person rule), manual_override_close(), flag_breached(), assert_not_terminal(), assert_transition_allowed()
  • app/services/ticket_service.pynext_ticket_number() (reads Postgres sequence), get_ticket(), list_tickets() (paginated, filterable), add_note(), update_non_jira_status(), get_audit_log()
  • app/api/tickets.py — full REST API: GET /api/tickets, GET /api/tickets/{id}, POST /{id}/start, POST /{id}/resolve, POST /{id}/close, POST /{id}/reopen, POST /{id}/override-close, POST /{id}/notes, GET /{id}/notes, PATCH /{id}/applications/{app_id}/status, GET /{id}/jira-links, GET /{id}/audit
  • tests/test_ticket_state.py — 28 tests: all allowed/forbidden transitions, skip-through two-audit-entry verification, two-person close enforcement, admin self-close bypass flag, manual override, breach blocking on terminal
  • tests/test_tickets_api.py — 14 tests: list/get/404, start transition, override-close validation, note creation, non-Jira status update, viewer 403 on override-close

Key decisions:

  • State machine lives in services/ticket_state.py — called by both API routes and workers (Jira sync, deadline monitor)
  • transition_skip_through() writes exactly two audit log entries in one transaction per BUSINESS_RULES.md spec
  • confirm_closure() handles admin bypass with two_person_bypass: true in audit metadata
  • _actor_id() helper returns a real UUID for bypass user (deterministic from email) so audit log is never NULL for human actions
  • update_non_jira_status() rejects apps where creates_jira_ticket=True with a clear 400 error

✅ Phase 5 — Application registry & rules

Commit: Phase 5: Application registry & rules — vendors, applications, rules CRUD APIs

What was built:

  • app/services/vendor_service.pylist_vendors(), get_vendor(), create_vendor() (409 on duplicate name), update_vendor() — all with audit log
  • app/services/application_service.pylist_applications(), get_application(), create_application() (validates jira_project_key required when creates_jira_ticket=True), update_application() (validates combined Jira state), link_vendor(), unlink_vendor() — all with audit log
  • app/services/rule_service.pylist_rules(), list_rules_for_triage() (includes global rules with vendor_id=NULL), get_rule(), create_rule() (version=1), update_rule() (increments version), delete_rule() (soft delete) — all with audit log
  • app/api/vendors.pyGET/POST /api/vendors, GET/PATCH /api/vendors/{id} — admin-only writes
  • app/api/applications.pyGET/POST /api/applications, GET/PATCH /api/applications/{id}, POST/DELETE /api/applications/{id}/vendors/{vendor_id} — admin/change_manager writes
  • app/api/rules.pyGET/POST /api/rules, GET/PATCH/DELETE /api/rules/{id} — admin/change_manager writes, soft delete returns 204
  • tests/test_registry.py — 22 tests: duplicate vendor 409, Jira key validation, version increment, soft delete, triage rule loading, role enforcement per endpoint

Key decisions:

  • list_rules_for_triage() is a separate method from list_rules() — it always includes vendor_id=NULL rules and is called by the Triage Agent in Phase 7
  • Rule version increments on every PATCH — provides a simple audit trail of how many times a rule has been edited
  • Vendor links (application_vendors) managed via dedicated sub-resource endpoints, not embedded in application update
  • All writes are soft-delete only (is_active=False) — no hard deletes anywhere in the system

✅ Phase 6 — Redis Streams infrastructure

Commit: Phase 6: Redis Streams infrastructure — publish/consume/ack/retry/DLQ, worker loop

What was built:

  • app/workers/streams.pypublish() (XADD with job_type, attempt, timestamp, priority flag), consume() (XREADGROUP with 2s block), ack() (XACK), retry_or_dlq() (re-publishes with incremented attempt up to MAX_RETRIES=3, then routes to vs:dlq with full error context, always ACKs original), reclaim_stale_messages() (XAUTOCLAIM on startup for crash recovery), _encode()/_decode() (JSON-safe string conversion)
  • app/workers/registry.py@register(stream, job_type) decorator, dispatch(), JobContext dataclass (job_type, data, attempt, msg_id, stream, db)
  • app/workers/handlers/email_handlers.pypoll_mailbox, process_email stubs on vs:emails:incoming
  • app/workers/handlers/jira_handlers.pycreate_jira_issue, sync_jira_batch, sync_jira_ticket stubs on vs:jira:create / vs:jira:sync
  • app/workers/handlers/notification_handlers.pynotify_ticket_created, notify_ticket_resolved, escalate_deadline, flag_breached stubs on vs:tickets:notify / vs:tickets:escalate
  • app/workers/main.py — real consumer loop: imports all handlers (self-registration via decorators), reclaims stale messages on startup, round-robins over all registered streams, SIGTERM/SIGINT graceful shutdown, per-message DB session with commit/rollback
  • tests/test_streams.py — 22 tests: encode/decode roundtrip, publish xadd call, priority flag, ack xack call, retry re-publishes with attempt+1, DLQ after MAX_RETRIES, always-ack guarantee, handler registration, dispatch correct handler, unknown job_type KeyError, consume decoding, empty on timeout

Key decisions:

  • CONSUMER_NAME = hostname + PID — unique per process replica, prevents message stealing between workers in the same consumer group
  • retry_or_dlq() always ACKs the original message — prevents infinite pending list growth even on DLQ path
  • Handler registration is side-effect-based (import triggers @register) — main.py imports all handler modules explicitly
  • Each message gets its own AsyncSessionLocal() session — DB errors in one message don't affect others
  • reclaim_stale_messages() uses XAUTOCLAIM (Redis 7+) — recovers messages that were claimed but not ACKed before a crash

✅ Phase 7 — LLM integration & Triage Agent

Commit: Phase 7: LLM integration & Triage Agent — LiteLLM wrapper, prompts, parser, guardrail

What was built:

  • app/llm/client.pycomplete() loads active LLMConfig from DB at call time (no restart needed for provider changes), decrypts API key, calls litellm.acompletion(), falls back to fallback LLM on failure; complete_with_image() for multimodal calls; _litellm_model() handles provider prefix routing
  • app/llm/prompts.pybuild_triage_prompt() assembles 5-section prompt: EMAIL, VENDORS WE TRACK, RULES, APPLICATIONS, TASK with exact JSON schema in the decision section
  • app/llm/parser.pyTriageDecision dataclass (stable interface), parse_triage_response() strips markdown fences, extracts JSON, validates all required fields, skips malformed UUIDs gracefully; build_corrective_prompt() for retry; ParseError exception type
  • app/agents/triage_agent.pyTriageAgent.decide(): loads context (vendors, rules via list_rules_for_triage, applications), builds prompt, calls LLM, parses with one corrective retry on ParseError, validates UUIDs against DB (removes invalid app IDs silently), applies severity guardrail
  • tests/test_triage_agent.py — 38 tests: parser (all severity values, null fields, malformed UUIDs, missing required fields, markdown fences, no JSON), corrective prompt, prompt builder sections, severity guardrail (5 cases: low/medium override, non-breaking no-op, tier_2 no-op, critical no downgrade), full decide() with mock LLM, corrective retry flow, double-failure raises, invalid app ID removal

Key decisions:

  • TriageDecision is a dataclass not a Pydantic model — it's an internal agent output, not an API schema
  • is_breaking_change field added to the prompt schema — required for the severity guardrail to work correctly
  • LLM config loaded from DB at call time — provider/model changes via Settings UI take effect immediately
  • Invalid app IDs from LLM are silently removed (not a hard error) — LLM hallucinations shouldn't block ticket creation
  • Invalid vendor ID from LLM sets vendor_id=None — ticket goes to unknown queue

✅ Phase 8 — Email ingestion

Commit: Phase 8: Email ingestion — IMAP client, message parser, ticket creator, process_email handler

What was built:

  • app/ingestion/imap_client.pyfetch_unseen_messages() dispatches by protocol; _fetch_imap() uses aioimaplib async IMAP, searches UNSEEN, fetches RFC822 bytes; Exchange and Gmail stubs ready for future implementation; _imap_connection() async context manager handles login/logout
  • app/ingestion/message_parser.pyparse_raw_email() uses stdlib email module, extracts text/html body, strips angle brackets from Message-ID, generates deterministic fallback ID when missing, sanitises attachment filenames (basename only), truncates body at 100KB, never raises on malformed input
  • app/ingestion/attachment_handler.pysave_attachments() persists to storage backend via build_attachment_path(), inserts EmailAttachment rows; build_llm_image_data() converts PDF/image attachments to OpenAI vision format (base64), skips >5MB and unsupported types
  • app/ingestion/ticket_creator.pycreate_ticket_from_decision(): idempotency check on source_email_id, generates ticket number via sequence, inserts Ticket + TicketApplication (all with non_jira_status=pending) + TicketJiraLink (for Jira-enabled apps), writes audit log with severity override metadata, publishes create_jira_issue per Jira app and notify_ticket_created
  • app/workers/handlers/email_handlers.py — replaced stubs: handle_poll_mailbox() fetches unseen messages, inserts SourceEmail rows (race-condition-safe via IntegrityError catch), saves attachments, publishes process_email jobs, updates last_poll_status; handle_process_email() runs Triage Agent, routes to unknown queue on ParseError or vendor_id=None, marks failed on RuntimeError, creates ticket on success
  • tests/test_email_ingestion.py — 28 tests: simple/multipart/attachment parsing, malformed email, missing Message-ID, path traversal in filename, LLM image data filtering (oversized/empty/unsupported), ticket creation, idempotency, job publishing, process_email unknown/parse-error/already-processed paths

Key decisions:

  • parse_raw_email() never raises — returns partial data on malformed input so one bad email doesn't block the queue
  • Duplicate message_id handled at two levels: pre-check in _insert_source_email() + IntegrityError catch for race conditions between worker replicas
  • process_email marks email unknown (not failed) on ParseError — unknown goes to human triage queue, failed triggers worker retry
  • Attachment filenames sanitised to basename only — prevents path traversal in storage
  • Ticket creator is idempotent: checks source_email_id before inserting — safe to retry if worker crashes after DB write but before ACK

✅ Phase 9 — Jira integration

Commit: Phase 9: Jira integration — REST client, ADF builder, bulk sync, create/comment handlers

What was built:

  • app/jira/adf.pybuild_adf_description() (ADF JSON for Cloud v3), build_wiki_description() (plain markup for Server/DC v2), build_escalation_comment() (D-7/D-3/D-1 with urgency wording, D-1 includes war room notice, returns ADF or string based on api_version), _days_label() helper
  • app/jira/client.pyJiraClient: auth abstraction (Basic for Cloud, Bearer for Server/DC), create_issue() (builds summary, ADF/wiki description, labels, duedate, assignee), get_issue(), search_issues() (JQL bulk), add_comment(), test_connection(); get_jira_client() factory loads active config from DB; JiraClientError with status_code + detail; map_jira_status_category() maps Jira category keys to VS enum values
  • app/jira/sync.pybulk_sync_open_tickets(): queries all open links, chunks into 100-key batches via _chunk(), calls _sync_batch() per batch, evaluates parent ticket status after all updates; _sync_batch(): JQL search, diffs status, writes audit log on change, marks sync_status=failed for issues not returned (404); _evaluate_ticket_status(): applies state machine (skip-through for open→resolved, in_progress transition); sync_single_ticket() for manual sync
  • app/workers/handlers/jira_handlers.py — replaced stubs: handle_create_jira_issue() (idempotency check, loads ticket/app/vendor, calls client, orphans on 4xx config errors, re-raises on 5xx for retry, writes audit log on success); handle_sync_jira_batch() (calls bulk sync, skips gracefully if no Jira config); handle_sync_jira_ticket() (single ticket manual sync)
  • app/api/tickets.py — added POST /api/tickets/{id}/sync (any authenticated, publishes priority job) and POST /api/tickets/sync-all (admin/change_manager, publishes priority batch job)
  • tests/test_jira.py — 32 tests: ADF structure, wiki markup content, escalation comment D-7/D-1, status mapping (all 4 cases + case-insensitive), JiraClientError, error extraction, client create success/4xx, sync batch status change/no-change/404-marking, handler orphan-on-4xx, handler idempotency skip

Key decisions:

  • 4xx errors (400/401/403/404) on issue creation → sync_status=orphan immediately, no retry — these are config errors that need admin attention, not transient failures
  • 5xx/network errors → re-raise so worker retry logic handles exponential backoff
  • map_jira_status_category() uses Jira's statusCategory.key field (new/indeterminate/done) not the display name — this is stable across Jira instances and workflows
  • Issues not returned in a JQL batch search → marked sync_status=failed (likely deleted in Jira)
  • _VS_BASE_URL read from NEXTAUTH_URL env var at runtime — ticket links in Jira descriptions always point to the correct deployment URL

✅ Phase 10 — Scheduler & deadline monitor

Commit: Phase 10: Scheduler & deadline monitor — APScheduler, mailbox polling, Jira sync, escalation, breach

What was built:

  • app/scheduler/deadline.pyrun_deadline_monitor(): queries open tickets with effective_date, calculates days_until = effective_date - today, fires D-7/D-3/D-1 escalations via _fire_escalation(), flags breached tickets via flag_breached(); _fire_escalation(): once-only check via _escalation_already_sent(), publishes to vs:tickets:escalate, inserts NotificationLog row with status=pending for deduplication; _escalation_already_sent(): queries notification_log using the JSONB expression index on metadata->>'escalation_threshold'
  • app/scheduler/jobs.pypoll_all_mailboxes(): queries active EmailSource rows, publishes poll_mailbox job per source; trigger_jira_sync(): publishes sync_jira_batch; run_deadline_monitor_job(): runs deadline monitor with its own DB session; load_jira_sync_interval(): reads jira_config.sync_interval_seconds from DB, falls back to 300; all jobs catch and log exceptions so one failure doesn't crash the scheduler
  • app/scheduler/main.py — replaced stub: AsyncIOScheduler with three jobs (mailbox poll every 60s, Jira sync at configured interval, deadline monitor every 15min), max_instances=1 + coalesce=True prevents job pile-up, SIGTERM/SIGINT graceful shutdown, logs next run times on startup
  • app/workers/handlers/notification_handlers.pyhandle_escalate_deadline() upgraded from stub: loads ticket with Jira links, skips if resolved/closed/breached, adds Jira escalation comments via build_escalation_comment() + client.add_comment() (Jira failure doesn't block notification), updates NotificationLog entry from pendingsent; Phase 11 will add Slack/email/PagerDuty
  • tests/test_scheduler.py — 22 tests: D-7/D-3/D-1 threshold firing, non-threshold day no-op, once-only dedup (already sent skips), breach flagging, already-breached skip, closed ticket skip, no tickets returns zeros, poll publishes per source, Jira sync publishes batch job, exception handling (both jobs), Jira interval from config/default, threshold constants

Key decisions:

  • Scheduler publishes to Redis Streams rather than doing work directly — keeps the scheduler process lightweight and lets workers scale independently
  • poll_all_mailboxes runs every 60s but each source's poll_interval_seconds is enforced by the worker checking last_poll_at — avoids needing one APScheduler job per source
  • _escalation_already_sent() uses the JSONB expression index created in the migration — fast lookup even with many notification_log rows
  • Escalation NotificationLog entry inserted with status=pending by the scheduler (before worker processes it) — prevents double-fire if scheduler runs twice before worker picks up the job
  • coalesce=True on all jobs — if a job is still running when the next trigger fires, the missed run is coalesced into one execution rather than queuing up

✅ Phase 11 — Notifications

Commit: Phase 11: Notifications — Slack, email/SMTP, PagerDuty, severity routing, notification_log

What was built:

  • app/services/notifications.pychannels_for_severity() maps severity to channel list (critical→all, high→slack+dm+email, medium→slack+email, low→slack only); dispatch_ticket_created() orchestrates all channels for a new ticket; dispatch_ticket_resolved() sends closure-ready notification; dispatch_breach_alert() sends breach alert + PagerDuty page; _send_slack() uses slack_sdk.web.async_client.AsyncWebClient, gracefully logs failure when SLACK_BOT_TOKEN not set; _send_email() uses stdlib smtplib via run_in_executor (non-blocking), gracefully logs failure when SMTP_HOST not set; _send_pagerduty() POSTs to PagerDuty Events API v2 via httpx, gracefully logs failure when PAGERDUTY_ROUTING_KEY not set; _log_notification() writes to notification_log with sent_at only on success, truncates messages at 2000 chars
  • app/workers/handlers/notification_handlers.pyhandle_notify_ticket_created() loads ticket + Jira links, calls dispatch_ticket_created(); handle_notify_ticket_resolved() calls dispatch_ticket_resolved(); handle_flag_breached() calls dispatch_breach_alert(); all three fully wired
  • tests/test_notifications.py — 28 tests: severity routing (all 4 levels + case-insensitive), Slack no-token/success/exception, email no-host/success/exception, PagerDuty no-key/success/exception, dispatch_ticket_created critical (all channels) / low (Slack only) / no channel (Slack skipped), notification_log row creation, failed has no sent_at, message truncation

Key decisions:

  • All notification failures are caught and logged — never re-raised. Notifications must not block ticket creation or state changes (per BUSINESS_RULES.md)
  • Missing credentials (no token/host/key) log as NotificationStatus.failed with a clear error message — visible in the admin notification log view
  • smtplib runs in asyncio.run_in_executor — keeps the event loop unblocked during SMTP handshake
  • Slack DM to owner is stubbed (logs a debug message) — full user directory lookup requires Slack user ID resolution which is a separate integration concern
  • All credentials come from env vars — no DB storage for notification credentials (unlike Jira/LLM which are admin-configurable)

✅ Phase 12 — Settings & admin endpoints

Commit: Phase 12: Settings & admin endpoints — email sources, LLM, Jira, system settings, audit log

What was built:

  • app/services/settings_service.py — email source CRUD (create with password encryption, update with re-encryption, soft delete, test connection); LLM config CRUD (create/update with API key encryption, audit log excludes raw key); Jira config (upsert deactivates old config, update, test connection calls /myself); system settings upsert by key; all writes produce audit log entries
  • app/api/settings.py — all settings endpoints under /api/settings/: email sources (list/create/patch/delete/test), LLM (list/create/patch), Jira (get/create/patch/test), system settings (list/get/put); all admin-only
  • app/api/audit.pyGET /api/audit paginated, filterable by entity_type and entity_id, accessible to all authenticated roles
  • app/main.py — settings and audit routers registered
  • tests/test_settings.py — 28 tests: password/key encryption, soft delete, audit log excludes secrets, Jira upsert deactivates old, test connection, system setting create/update, API role enforcement, secret exclusion from Read schemas

Key decisions:

  • Jira config is a singleton — upsert_jira_config() deactivates the previous active config before inserting a new one
  • Raw secrets encrypted immediately in service layer, never appear in audit log new_value
  • System settings use string key PK — set_system_setting() upserts by key, no UUID needed
  • Audit log endpoint is read-only for all roles per AUTH.md permission matrix

✅ Phase 13 — Unknown queue

Commit: Phase 13: Unknown queue — triage view, manual classify, dismiss, rule creation from triage

What was built:

  • app/services/triage_queue_service.pylist_unknown_emails() queries source_emails WHERE processing_status IN (unknown, failed) ordered by received_at DESC; get_triage_email() loads email with attachments; classify_email() validates not-already-classified (409), builds TriageDecision from operator input, calls create_ticket_from_decision() (same path as automatic triage), marks email as classified, writes audit log, optionally calls _save_as_rule() which auto-generates instruction text when none provided; dismiss_email() marks as failed with audit log; ManualClassification and TriageEmailRead Pydantic schemas
  • app/api/triage.pyGET /api/triage (paginated list), GET /api/triage/{id} (full email detail with body + attachment list), POST /api/triage/{id}/classify (creates ticket, optionally creates rule, returns ticket_number + optional rule_id), POST /api/triage/{id}/dismiss (204); all admin/change_manager only
  • app/main.py — triage router registered
  • tests/test_triage_queue.py — 22 tests: list pagination, classify creates ticket, 409 on already-classified, save_as_rule creates rule, auto-generated instruction text, dismiss marks failed + audit log, API role enforcement (viewer 403, change_manager 200), classify 201, dismiss 204, sla_days validation, ManualClassification schema defaults

Key decisions:

  • classify_email() reuses create_ticket_from_decision() — the same atomic function used by automatic triage. Manual and automatic paths produce identical ticket structure.
  • save_as_rule=True auto-generates instruction_text from the classification fields when rule_instruction_text is not provided — operators don't need to write instructions from scratch
  • dismiss_email() sets processing_status=failed (not a new status) — dismissed emails are excluded from the triage queue on next load
  • Triage queue shows both unknown and failed emails — operators can retry failed emails too

✅ Phase 14 — Frontend

Commit: Phase 14: Frontend — Auth.js, layout, dashboard, ticket detail, applications, rules, settings, audit

What was built:

  • auth.ts — Auth.js v5 with Okta provider; bypass mode (empty providers array) when AUTH_BYPASS_ENABLED=true
  • src/middleware.ts — protects all routes; bypass mode passes all through; redirects unauthenticated to /auth/signin
  • src/lib/api/client.tsapiFetch<T>() base fetch wrapper; attaches Bearer token from Auth.js session; ApiError with status code; skips token in bypass mode
  • src/lib/api/index.ts — typed API functions for all resources: ticketsApi, vendorsApi, applicationsApi, rulesApi, authApi, settingsApi, auditApi
  • src/hooks/index.ts — TanStack Query hooks for all resources; useTicketJiraLinks auto-refreshes every 30s; useTicketTransition returns mutation objects for all state transitions
  • src/components/providers.tsxQueryClientProvider wrapper with 1-min stale time
  • src/components/layout/sidebar.tsx — fixed left nav, active state via usePathname, lucide icons, Precision Enterprise tokens
  • src/components/layout/header.tsx — sticky top bar, search input, bell + help icons, user avatar with initials
  • src/components/shared/badges.tsxSeverityBadge, StatusBadge (dot + label), JiraStatusBadge (links to Jira when url present)
  • src/components/shared/primitives.tsxDeadlineCountdown (color-coded by urgency), LifecycleStepper (4 states, breached as error banner), KpiCard, SyncButton (spinner on pending)
  • src/app/layout.tsx — root layout with Inter font, Providers, Sidebar, Header, main content area
  • src/app/page.tsx — Dashboard: KPI cards (open/approaching/breached/resolved), critical tickets grid, all open tickets table with pagination
  • src/app/tickets/[id]/page.tsx — Ticket detail: vendor change summary, lifecycle stepper, Jira links with sync button, activity log, add note form, ownership panel, deadline card, manual override close modal
  • src/app/applications/page.tsx — Applications registry: grid of cards, right-side detail drawer with metadata and vendor list
  • src/app/rules/page.tsx — Rules admin: table with keyword badges, right-side edit drawer with keyword tag input and instruction textarea
  • src/app/settings/page.tsx — Settings: secondary nav, email sources table, LLM config cards (primary/fallback), Jira config display, placeholder sections
  • src/app/audit/page.tsx — Audit log: paginated table, entity type filter
  • src/app/auth/signin/page.tsx — Sign-in page with Okta button (server action)
  • src/app/loading.tsx — skeleton loading state
  • src/app/error.tsx — error boundary with retry button

Key decisions:

  • All pages are client components ("use client") using TanStack Query — data fetching is client-side for simplicity; server components can be adopted incrementally
  • useTicketJiraLinks polls every 30s — gives near-real-time Jira status without WebSockets
  • Lifecycle stepper has exactly 4 steps (Open/In Progress/Resolved/Closed) — "Acknowledged" from design reference omitted per spec
  • All colors use Precision Enterprise semantic tokens — no raw Tailwind color names
  • Bypass mode: NEXT_PUBLIC_AUTH_BYPASS=true skips token attachment in API client; middleware passes all routes through
  • Override close modal enforces 20-char minimum client-side before enabling the confirm button

✅ Phase 15 — Hardening

Commit: Phase 15: Hardening — structured logging, middleware, rate limiting, production Dockerfiles, HTTPS

What was built:

  • app/core/logging.pyJsonFormatter emits one JSON line per log record (timestamp, level, logger, message, extras); configure_logging() uses JSON in production, human-readable in development; quiets noisy third-party loggers (httpx, litellm, apscheduler); idempotent (safe to call multiple times)
  • app/core/middleware.pyRequestLoggingMiddleware: attaches unique X-Request-ID UUID to every request/response; logs method + path + status + duration_ms; catches unhandled exceptions and returns structured JSON 500 with request_id; never leaks stack traces to clients in production
  • app/core/rate_limit.pySlidingWindowRateLimiter: in-memory sliding window per (IP, path) key; rate_limit dependency (120 req/min default); rate_limit_auth dependency (20 req/min for auth endpoints); _get_client_ip() respects X-Forwarded-For from Traefik
  • app/api/auth.pyrate_limit_auth applied to /api/auth/me
  • app/main.pyconfigure_logging() called at startup; RequestLoggingMiddleware added; startup/shutdown log messages
  • backend/Dockerfile.prod — multi-stage build: builder installs deps with uv, runtime copies venv only; non-root appuser; no CMD (overridden per service)
  • frontend/Dockerfile.prod — multi-stage: builder runs npm run build, runtime uses Next.js standalone output; non-root appuser
  • traefik/traefik.prod.yml — production Traefik: HTTP→HTTPS redirect, Let's Encrypt via ACME httpChallenge, dashboard disabled, security headers middleware
  • traefik/dynamic.yml — security headers: HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy
  • docker-compose.prod.yml — production overrides: prod Dockerfiles, Gunicorn + 4 Uvicorn workers, no source mounts, 2 worker replicas, memory limits, ENVIRONMENT=production, AUTH_BYPASS_ENABLED=false
  • .env.example — added ENVIRONMENT, DOMAIN, ACME_EMAIL, SMTP vars
  • tests/test_hardening.py — 22 tests: JSON formatter valid JSON + exception inclusion, configure_logging idempotent, rate limiter allow/block/reset/independent keys/remaining count, middleware request ID header + 500 on exception + unique IDs per request, production bypass guard, development bypass allowed

Key decisions:

  • RequestLoggingMiddleware is outermost middleware — catches all exceptions including those from inner middleware
  • Rate limiter is in-memory (single instance) — for multi-instance deployments, replace _windows dict with Redis INCR + EXPIRE
  • Production Dockerfiles use multi-stage builds — runtime image contains only the venv and app code, no build tools
  • Dockerfile.prod has no CMD — docker-compose.prod.yml provides the command per service, same pattern as dev
  • Let's Encrypt uses HTTP challenge — requires port 80 to be publicly accessible; DNS challenge can be substituted for stricter firewall setups

✅ All 15 phases complete

VendorSync MVP is fully built. The system is ready for:

  1. cp .env.example .env — fill in secrets
  2. docker compose up --build — start dev stack
  3. docker compose exec backend alembic upgrade head — run migrations
  4. Open http://localhost:4111 — VendorSync frontend is running
  5. Open http://localhost/health — backend health check
  6. Open http://localhost/api/docs — FastAPI Swagger UI
  7. Open http://localhost:8080 — Traefik dashboard

For production: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d


Post-phase fixes and improvements

Deployment fixes (after Phase 15)

Docker / Dockerfile:

  • Replaced uv venv approach with plain pip install directly into system Python — venv is redundant inside Docker (Docker is the isolation layer)
  • Fixed volume mounts: ./backend/app:/app/app instead of ./backend:/app to preserve installed packages
  • Fixed Alembic migration: replaced sa.Enum(...) with postgresql.ENUM(..., create_type=False) in all create_table calls to prevent duplicate enum type creation
  • Fixed bypass user email: changed dev@vendorsync.local to dev@vendorsync.dev (.local TLD fails Pydantic email validation)
  • Frontend port changed to 4111 (direct access: http://localhost:4111)
  • Fixed src/middleware.ts bad import path (@/../../auth → dynamic import)
  • Fixed src/app/auth/signin/page.tsx — converted to client component using next-auth/react signIn()
  • Fixed Tailwind v4 compatibility: moved all Precision Enterprise color tokens from tailwind.config.ts into globals.css @theme directive
  • Added /tickets/page.tsx — was missing, causing 404 on Tickets tab
  • Fixed scheduler crash: OKTA_ISSUER and OKTA_AUDIENCE were missing from scheduler env; made them optional in Settings

Scheduler visibility:

  • app/scheduler/jobs.py — each job now calls _record_job_run() which stores last-run time + result in Redis hash vs:scheduler:jobs
  • app/api/system.py — new GET /api/system/status endpoint: queue depths, scheduler job last-run times, email source poll status, Jira sync stats, ticket counts by status
  • Dashboard now shows System status panel with 5 columns: scheduler jobs, worker queues, email sources, Jira integration, ticket breakdown

WebSocket real-time updates:

  • app/api/ws.pyGET /ws/status WebSocket endpoint pushes queue depths + scheduler status every 5 seconds
  • frontend/src/hooks/use-ws.tsuseSystemStatusWs() hook connects to WebSocket, merges live pushes into TanStack Query cache
  • System status panel shows "Live" badge when WebSocket connected, "Polling" when falling back to 30s HTTP polling
  • Traefik routing updated to include /ws path prefix

Settings UI — fully functional with encrypted storage:

  • Email sources: add form with protocol selector (IMAP/Exchange/Gmail API), masked password field, test connection button
  • LLM config: provider + model + masked API key + temperature + max tokens for primary and fallback
  • Jira config: base URL, API version, auth type, masked token, sync interval, test connection with result display
  • Notifications: functional form for Slack token, SMTP config, PagerDuty key — saved as system settings via PUT /api/settings/system/{key}
  • System defaults: functional form for timezone, SLA hours by tier, working hours, audit retention
  • Users & roles: table with inline role/team/active editing per user via PATCH /api/auth/users/{id}
  • All secrets encrypted by backend before DB storage; never returned in API responses

Critical bug fixes during testing:

  • CORS: set allow_origins=["*"] in dev — specific origin lists caused OPTIONS preflight 400 errors blocking all POST/PATCH requests
  • FERNET_KEY: must be added to docker-compose.yml environment for backend, worker, and scheduler — was missing, causing 500 on any encrypted field save
  • Bypass user FK: the bypass user UUID must exist in the users table before any write that triggers an audit log — insert on first run: INSERT INTO users (id, ...) VALUES ('b4cdf432-17b0-5c62-a085-ddede280151d', ...)
  • Backend port 8000 must be exposed in docker-compose.yml — browser calls backend directly on :8000, not through Traefik, to avoid cross-origin issues
  • Email test connection: was calling fetch_unseen_messages() (fetches all emails) — replaced with simple IMAP connect+login+logout only
  • System status panel: all data.x.y property accesses must use optional chaining (data.x?.y ?? fallback) — API can return partial data on first load causing crashes
  • IMAP fetch: aioimaplib returns message IDs as bytes — must decode to str before calling client.fetch(), otherwise returns BAD: Could not parse command
  • IMAP fetch: actual email bytes are at index [1] of the fetch response, not index [0] (which is the IMAP header line)
  • Email deduplication: emails are marked as SEEN in IMAP after fetch to prevent reprocessing on next poll
  • Volume mounts: ./backend:/app overwrites installed pip packages — use ./backend/app:/app/app instead
  • /app/.next anonymous volume was isolating Next.js build cache from source mount, breaking hot reload — removed
  • Postgres data: use ./data/postgres/pgdata subdirectory (not ./data/postgres) to avoid initdb error from .gitkeep file

Email source improvements:

  • allowed_senders field: comma-separated list of sender patterns (supports *@domain.com wildcards) — filters IMAP search
  • max_fetch_per_poll field: configurable limit on emails fetched per poll cycle (default 50)
  • Migration 0002_email_source_filters adds both columns
  • Edit form in Settings UI — click Edit on any source row to update all fields including new ones
  • Expandable rows in email sources table showing all config details
  • Poll now button triggers immediate poll without waiting for scheduler
  • Progress bar shown during poll (simplified HTTP-based, not WebSocket)

Unified UI event bus:

  • vs:ui:events Redis stream — single event bus for all UI-bound notifications
  • publish_ui_event() in app/core/redis.py — writes to both Redis stream (live) and system_events DB table (permanent)
  • Migration 0003_system_events — persistent storage for system events, survives Redis flush
  • Events published from: poll complete, ticket created, scheduler errors
  • /ws/events WebSocket endpoint — streams all events from Redis stream to frontend in real-time, sends last 20 as history on connect
  • useEventsWs() hook — connects to /ws/events, maintains event list, tracks unread count, auto-reconnects
  • Notification bell in header — red badge with unread count, click opens slide-in panel with event feed
  • Notification panel — shows all events with severity color coding, relative timestamps, links to affected entities

Logs page (was Audit log):

  • Renamed to "Logs" in sidebar
  • Two tabs: Audit log + System log
  • Audit log tab: human-readable descriptions (not raw UUIDs/JSON), expandable rows, entity type filter, color-coded entity badges
  • System log tab: loads history from DB, WebSocket appends live events instantly (highlighted blue), event type filter pills, poll_complete hidden by default to reduce noise
  • GET /api/system/logs reads from system_events DB table (not Redis)

Triage agent — first successful end-to-end test:

  • Email from avico78@gmail.com → ingested → Claude classified → ticket VS-0649 created
  • Severity guardrail fired: medium → high (tier_1 vendor + breaking change)
  • Effective date extracted: June 1, 2026
  • Full reasoning stored in tickets.llm_reasoning

Jira integration — working end-to-end:

  • Fixed Jira Cloud API URL: strip v prefix from version (v33 in URL path)
  • Fixed deprecated search API: use POST /rest/api/3/search/jql instead of GET /search
  • Test mode: jira.test_mode system setting prefixes all Jira issue summaries with [TEST]
  • Toggle in Settings → Jira configuration UI
  • Multi-app support confirmed: one VS ticket → multiple Jira issues (one per app)
  • Ticket lifecycle driven by Jira: any in_progress → VS in_progress; all done → VS resolved

SLA & deadline monitor — tested:

  • D-1 escalation fired correctly for ticket with effective_date tomorrow
  • Breach triggered correctly for ticket with effective_date in the past
  • Each threshold fires exactly once (dedup via notification_log)

Applications page — fully functional:

  • Create/edit/delete applications with Jira project key, issue type, owner team
  • Inline edit modal with all fields
  • Card grid with expandable detail drawer

Docs page:

  • New /docs route with 6 sections: Overview, Email→Ticket flow, Rules & vendors, Applications & Jira, SLA & deadlines, Troubleshooting
  • Mermaid diagrams for all key flows
  • Search across all content
  • Read-only, static content in frontend

IMAP optimization:

  • For exact sender addresses (no wildcards): use IMAP FROM search so non-matching emails are never fetched or marked as SEEN
  • For wildcard patterns (*@domain.com): fetch all UNSEEN and filter locally
  • This prevents polluting the inbox by marking unrelated emails as read

Package management:

  • imapclient added to backend/Dockerfile — installs on every build
  • mermaid added to frontend/package.json — installs on every build

Timezone-aware time formatting:

  • frontend/src/lib/time.tsformatDateTime(), formatTime(), formatRelative(), formatDate(), getUserTimezone() — all use Intl.DateTimeFormat (browser local timezone automatically)
  • Applied everywhere: dashboard ticket table, tickets list, ticket detail note timestamps, Jira link last-synced, system status panel
  • Rule: never use new Date().toLocaleString() directly — always use formatDateTime() from @/lib/time

API client fix:

  • NEXT_PUBLIC_* vars are baked at build time — unreliable at runtime
  • API base now derived from window.location.hostname at runtime: ${protocol}//${hostname}/api
  • This ensures the frontend always calls the correct backend regardless of build-time env vars

Key architectural decisions (running log)

Decision Choice Rationale
Frontend auth Auth.js (NextAuth v5) + Okta provider App Router server component compatibility
Auth bypass AUTH_BYPASS_ENABLED env var Dev/test without Okta; production guard enforced
Line endings LF via .gitattributes + .editorconfig + ruff + Prettier Windows dev → Linux Docker runtime
Python paths pathlib.Path + PTH ruff rule No os.path string concat; cross-platform safe
Storage paths PurePosixPath for DB-stored strings Forward slashes always, OS-independent
File naming snake_case.py, kebab-case.ts/tsx, all lowercase Linux case-sensitive filesystem
Non-Jira progress ticket_applications.non_jira_status enum Tracks manual progress per app
Two-person close tickets.last_transitioned_by FK Enforced at close endpoint: closed_by != last_transitioned_by
Unknown queue source_emails.processing_status=unknown No separate table; UI filters on this status
Icons lucide-react only shadcn/ui default; Material Symbols not installed
Color tokens Precision Enterprise system in tailwind.config.ts Matches Stitch design exports exactly
Ticket numbering Postgres sequence ticket_number_seq nextval() at insert, formatted VS-NNNN
Attachment storage Local filesystem (MVP), abstraction layer for S3 swap backend/app/services/storage.py interface
Docker entry points Same image, command: override per service backend / worker / scheduler differentiated in compose
Jira batch sync 100-key JQL chunks Prevents URL length limits
State skip (open→resolved) Two audit log entries in one transaction Preserves audit trail integrity
Severity guardrail Post-LLM deterministic check tier_1 + breaking + low/medium → override to high
Frontend port 4111 (direct) + 80 via Traefik Avoids conflict with Traefik dashboard on 8080
API base URL Derived from window.location.hostname at runtime NEXT_PUBLIC_* vars baked at build time are unreliable
Time display Always formatDateTime() from @/lib/time Uses Intl.DateTimeFormat — browser local timezone automatically
WebSocket /ws/status pushes every 5s Real-time queue depth + scheduler status without client polling
Scheduler visibility Last-run stored in Redis hash vs:scheduler:jobs Surfaced in System Status panel on dashboard
Settings secrets Encrypted by backend before DB storage Fernet encryption; never returned in API responses

Security Watch — design phase (branch: feature/security-watch)

Status: Design docs landed. No code yet. Phase S1 is the next coding step.

Framing: VendorSync is now a two-pillar platform — Vendor Watch (existing) and Security Watch (new). Security Watch listens to security advisory feeds (OSV.dev primary, CISA KEV booster, NVD enrichment), matches advisories against per-application component inventory, and routes survivors through the existing ticket engine.

What landed in this commit:

  • .kiro/steering/docs-sync.md — new auto-included steering rule. Code changes that affect API, DB, UI, processing flow, or config must update matching docs/*.md and the in-app Docs tab in the same commit.
  • docs/SECURITY_WATCH.md — new full design doc (architecture, data model, source adapters with concrete OSV/KEV/NVD mappings, pre-filter pipeline, Triage Agent extension, severity guardrails, SLA defaults, ticket grouping policy, dual-Jira behavior, auto-resolve loop, configuration UI, build sequence S1–S10).
  • docs/README.md — reframed as two-pillar platform; added Security Watch to MVP scope; updated reading order.
  • docs/ARCHITECTURE.md — added Security Watch streams (vs:security:raw, vs:security:normalized, vs:security:triage), vs:ui:events stream noted, components 15–19 added, parallel security data flow diagram, build sequence S1–S10.
  • docs/DATA_MODEL.md — added kind discriminator on rules and tickets, link_role on ticket_jira_links, security routing fields and last_inventory_refresh_at on applications, four new tables (application_components, security_sources, security_advisories, advisory_component_matches), new system_settings keys, new indexes.
  • docs/PROCESSING_FLOW.md — appended Security advisory processing flow (steps S1–S8) with edge cases, idempotency rules, and time budgets.
  • docs/UI_DESIGN.md — appended Security Watch UI section (sidebar entries, Inventory tab, manifest upload diff preview, Security routing section, Security Sources page with preset dropdown and Test/Run buttons, Rule kind toggle, ticket detail Advisory card, exposure dashboard panel, WebSocket event handlers, in-app Docs tab planned sections).

Decisions baked into the design (confirmed with user):

  • Inside VendorSync, not separate. Reuses tickets, Jira, deadlines, escalations, audit, RBAC, scheduler, Streams.
  • MVP feeds: OSV.dev (primary, package advisories) + CISA KEV (severity booster) + NVD (CVSS enrichment). GHSA skipped — OSV mirrors it.
  • MVP ecosystems: Python (requirements.txt), Node (package-lock.json), Java (pom.xml).
  • Canonical schema = OSV format. OSV adapter is near-passthrough; KEV cross-references; NVD enriches by alias.
  • Pre-filter has 3 layers: ecosystem+package match (SQL), version-range check (deterministic), bypass list (KEV / CVSS≥9 / criticality=critical).
  • Ticket grouping: one VS ticket per advisory across all affected apps. New affected apps later become additional ticket_applications rows on the existing ticket, not a new ticket.
  • Jira: per-app issues (existing model) plus an optional security-team Jira issue, configurable via system_settings.security_watch.security_team_jira. Off by default.
  • SLA defaults (configurable): 72h KEV-critical / 7d critical / 14d high / 30d medium / 90d low.
  • Inventory: opt-in via manifest upload for v1. last_inventory_refresh_at tracked per app. Stale warning at 30 days.
  • Auto-resolve on patch: every inventory upload re-runs Layers 1+2 against open security tickets; ticket auto-transitions to resolved when no app still matches.
  • Schedule UI: human-friendly preset dropdown (no cron). Test connection and Run now buttons per source, mirroring the existing Jira test connection pattern.
  • WebSocket events (vs:ui:events): quiet by default — only 4 event types publish (security.advisory.critical_match, security.ticket.kev_breach_imminent, security.source.failed, security.inventory.stale). Routine ingestion, sync, matching never publish.
  • Roadmap: Path 1 (pure triage layer) for MVP; Path 3 (code reachability analysis) reserved for Horizon 3.

Phases ahead (no code yet):

  • S1 — Data layer + migration
  • S2 — Inventory layer (Python + Node + Java parsers)
  • S3 — OSV ingestion (storage only)
  • S4 — Pre-filter pipeline
  • S5 — KEV + NVD enrichment + severity guardrails
  • S6 — Security Triage Agent (decide_advisory)
  • S7 — Ticket creation (security path) — per-app + optional security-team Jira
  • S8 — Auto-resolve on patch
  • S9 — UI + in-app Docs tab Security Watch sections
  • S10 — Hardening (backlog mode, rate limiting, metrics, WS event throttling)

Phase S1 — Security Watch data layer (branch: feature/security-watch)

Status: ✅ Done. Migration applied, models import cleanly, all S1 tests pass, no regressions.

Migration: 0007_security_watch (revises 0006).

What landed:

  • New enums (Python + Postgres): TicketKind, RuleKind, JiraLinkRole, ComponentType, ComponentSource, SecuritySchedulePreset, SuppressBelow, AdvisoryBypassReason, AdvisorySeverityLabel
  • applications extended: security_jira_project_key, security_default_assignee, suppress_security_below (default none), last_inventory_refresh_at
  • rules extended: kind (default vendor_change)
  • tickets extended: kind (default vendor_change), security_advisory_id (FK → security_advisories.id), source_email_id is now nullable
  • ticket_jira_links extended: link_role (default app), application_id is now nullable. Unique constraint changed to (ticket_id, application_id, link_role). Partial unique index uq_ticket_jira_link_security_team enforces at most one security_team link per ticket.
  • New tables: application_components, security_sources, security_advisories, advisory_component_matches — full schema in docs/DATA_MODEL.md
  • New indexes: ix_app_components_ecosystem_name (functional, on lower(name)), ix_app_components_application_active, ix_security_sources_enabled_schedule, ix_security_advisories_aliases (GIN), ix_security_advisories_kev_published (partial), ix_advisory_matches_advisory, ix_advisory_matches_application, ix_tickets_kind_status, ix_tickets_security_advisory (partial), ix_rules_kind_vendor_active

Tests added (backend/tests/test_models.py):

  • 8 enum value-set tests for the new Security Watch enums
  • 5 model wiring tests: security models importable, ticket has new columns + nullable source_email_id, rule has kind, ticket_jira_link has link_role + nullable application_id, application has 4 new security routing fields
  • 3 unique-constraint tests: uq_app_component, uq_advisory_source_id, uq_advisory_component_match

Verification:

  • alembic upgrade head clean — alembic_version=0007_security_watch
  • python -c "from app import models" imports all 21 ORM models successfully (added 4: ApplicationComponent, SecuritySource, SecurityAdvisory, AdvisoryComponentMatch)
  • 4 new tables visible in \dt
  • All 4 new application columns present
  • All 13 new model tests pass
  • Test suite baseline: 198 passed, 26 failed — identical to master baseline (no S1 regressions)

Backup: backup_pre_s1.sql (170KB) created before migration. Not committed (in .gitignore semantics for safety).

Notes for follow-up phases:

  • Test runtime container does not have pytest/pytest-asyncio by default (prod Dockerfile pip-installs only runtime deps). They were pip install-ed ad-hoc for verification. Future phase should consider adding pytest, pytest-asyncio, fakeredis to the dev image, or add a dev-only docker-compose override that mounts a venv with dev deps. Not blocking S2.
  • The 26 pre-existing test failures span notifications, scheduler, tickets API, triage, registry, settings, and triage-queue. They predate Security Watch and should be addressed independently.
  • application_id on ticket_jira_links is now nullable for link_role='security_team'. Existing Vendor Watch code that creates links must always set link_role='app' explicitly (or rely on the default). Phase S7 will create security_team rows.

✅ Phase S2 — Inventory layer (COMPLETE)

Status: All functionality implemented and tested Commit: Phase S2: Inventory layer — parsers, CRUD API, diff preview, freshness tracking

What was built:

  • Manifest parsers (app/security/parsers/): Python requirements.txt, npm package-lock.json, Maven pom.xml with full version constraint handling, property substitution, and transitive dependency detection
  • Inventory service (app/services/inventory_service.py): full CRUD operations, diff computation (added/updated/removed/unchanged), transactional diff application, freshness tracking
  • REST API (app/api/inventory.py): 6 endpoints for component management and manifest upload with diff preview
  • Comprehensive schemas (app/schemas/__init__.py): inventory management schemas with proper validation and diff preview support
  • 39 parser tests (tests/test_security_parsers.py): complete coverage of all three manifest formats including edge cases, malformed input, and normalization
  • Additional tests (tests/test_inventory_service.py, tests/test_inventory_api.py): service and API layer validation

Key features implemented:

  • Component CRUD: GET/POST/DELETE /api/applications/{id}/inventory/* — manual component management
  • Manifest upload with diff preview: POST /inventory/preview (no DB writes) and POST /inventory/upload (transactional apply)
  • Freshness tracking: GET /inventory/status with staleness detection and configurable thresholds
  • Soft-delete pattern: components not in new manifest marked is_active=false for historical match preservation
  • Proper manifest parsing: supports version ranges, property substitution (Maven), direct vs transitive marking (npm)
  • Audit logging: all inventory changes recorded with diff summary in audit log
  • Transactional safety: all changes in manifest upload succeed or all fail

API endpoints implemented:

  • GET /api/applications/{id}/inventory — paginated component list with active-only filtering
  • POST /api/applications/{id}/inventory — manual component creation (admin/change_manager)
  • DELETE /api/applications/{id}/inventory/{component_id} — soft deactivation
  • POST /api/applications/{id}/inventory/preview — manifest upload diff preview (no DB writes)
  • POST /api/applications/{id}/inventory/upload — manifest upload and transactional apply
  • GET /api/applications/{id}/inventory/status — freshness and component count summary

Manifest support confirmed:

  • Python (requirements.txt): pinned versions, constraints, environment markers, extras, PEP 503 normalization
  • npm (package-lock.json): v2/v3 lockfiles, direct vs transitive detection, nested node_modules
  • Maven (pom.xml): dependency + dependencyManagement sections, property substitution, version ranges, namespace handling

Testing results:

  • 39/39 parser tests passing (all formats + edge cases)
  • API endpoints verified working via smoke tests
  • POM parser XML deprecation warning fixed
  • All core functionality demonstrated working end-to-end

✅ Phase S3 — OSV ingestion (COMPLETE)

Status: Storage-only OSV ingestion implemented and tested Commit: Phase S3: OSV ingestion — security sources, scheduler, OSV adapter

What was built:

  • Security sources API (app/api/security_sources.py): Full CRUD for feed management with test/run capabilities
  • Security source service (app/services/security_source_service.py): Business logic with credential encryption and audit logging
  • OSV adapter (app/security/adapters/osv_adapter.py): Google OSV API integration with ecosystem filtering
  • Security workers (app/workers/handlers/security_handlers.py): Stream handlers for fetch and normalize
  • Scheduler integration (app/scheduler/jobs.py): Periodic polling based on schedule presets
  • Comprehensive tests (tests/test_security_sources.py): API, service, and adapter validation

Key features implemented:

  • Security sources CRUD: GET/POST/PATCH/DELETE /api/security-sources with admin-only access
  • OSV.dev integration: Multi-ecosystem support (PyPI, npm, Maven, Go, NuGet, Debian, Alpine, Ubuntu)
  • Test connection: /security-sources/{id}/test — validates API connectivity without data storage
  • Manual triggers: /security-sources/{id}/run — immediate fetch via Redis stream priority jobs
  • Schedule presets: Human-friendly intervals (every_15m, hourly, daily_06, weekly_mon_06, manual)
  • Credential encryption: Fernet-based secure storage of API keys
  • Cursor-based fetching: Timestamp-based incremental updates to avoid re-processing
  • Storage-only mode: Raw advisories → normalized → security_advisories table (no tickets yet)

Testing results:

  • ✅ Security sources API endpoints working (list, create, test, run)
  • ✅ OSV adapter test connection successful
  • ✅ Manual run queuing to Redis streams confirmed
  • ✅ Backend/worker/scheduler restarts successful
  • ✅ Credential encryption/decryption working

✅ Phase S4 — Pre-filter pipeline (COMPLETE)

Status: 3-layer deterministic filtering implemented Commit: Phase S4: Pre-filter pipeline — 3-layer filtering, version checks, bypass logic

What was built:

  • Pre-filter handlers (app/workers/handlers/prefilter_handlers.py): Complete 3-layer filtering pipeline
  • Layer 1 - SQL matching: Ecosystem + package name matching against application_components table
  • Layer 2 - Version ranges: Deterministic version comparison using PEP 440 (Python), semver (npm), Maven versioning
  • Layer 3 - Bypass logic: KEV, CVSS ≥9.0, critical application overrides
  • Advisory matches API (app/api/advisory_matches.py): Admin debugging interface for pre-filter results
  • Match storage: Results written to advisory_component_matches table with full audit trail
  • Comprehensive tests (tests/test_prefilter.py): Version range logic, bypass conditions, layer validation

Key features implemented:

  • 3-Layer filtering: Ecosystem match → version check → bypass evaluation
  • Multi-ecosystem support: Python (PEP 440), npm (semver), Maven version comparison
  • Smart bypasses: KEV-listed advisories, CVSS ≥9.0, critical applications always proceed
  • Match persistence: advisory_component_matches tracks layer1/layer2 results + bypass reasons
  • Pipeline integration: Normalized advisories automatically trigger pre-filter evaluation
  • Admin visibility: /api/advisory-matches endpoints for debugging before tickets enabled
  • Audit logging: Pre-filter results tracked with match counts and affected application counts

Technical implementation:

  • OSV-compatible: Version ranges from OSV API parsed correctly (introduced/fixed/last_affected events)
  • Conservative approach: Unknown versions and ecosystems assumed affected (better safe than sorry)
  • Ecosystem normalization: Case-insensitive matching with ecosystem standardization
  • Transactional safety: Match results committed atomically with advisory processing
  • Redis streams: Pre-filter jobs processed asynchronously via vs:security:normalized

Testing status:

  • ✅ Layer 1 SQL matching logic implemented
  • ✅ Layer 2 version comparison for Python/npm/Maven
  • ✅ Layer 3 bypass conditions (KEV, CVSS, criticality)
  • ✅ Pipeline integration with security handlers
  • ✅ Match storage and audit logging
  • ✓ End-to-end testing (Redis consumer groups setup pending)

Next: Phase S5 — KEV + NVD enrichment + severity guardrails (cross-source advisory correlation)

✅ Phase S5 — KEV + NVD enrichment + severity guardrails (COMPLETE)

Status: Fully implemented Commit: feat: implement Security Watch Phase S5 - KEV + NVD enrichment + severity guardrails

What was built:

  • KEV adapter (app/security/adapters/kev_adapter.py): Fetches CISA KEV catalog, cross-references existing advisories, sets on_kev=True on matching records
  • NVD adapter (app/security/adapters/nvd_adapter.py): Fetches CVEs from NVD API v2, enriches existing advisories with authoritative CVSS scores by alias match
  • Base adapter class (app/security/adapters/base.py): ABC shared by all adapters
  • Adapter registry updated: KEV and NVD registered alongside OSV
  • Enrichment workers (app/workers/handlers/security_handlers.py): handle_kev_cross_reference and handle_nvd_enrich_advisories — update existing advisories after each KEV/NVD ingest
  • Security Triage Agent extended (app/agents/triage_agent.py): decide_advisory() method + _build_security_triage_prompt() + _apply_security_severity_guardrails()
  • Severity guardrails: KEV + high-criticality apps → force critical; CVSS ≥9.0 + high-criticality apps → force critical; transitive-only downgrade allowed
  • Rule service (app/services/rule_service.py): list_rules_for_triage() extended with kind parameter — returns security_advisory rules for security path
  • vs:security:enrichment stream added to Redis consumer groups
  • 4 tests (tests/test_phase_s5.py): adapter normalization logic, severity guardrail logic, CVSS label derivation

✅ Phase S6 — Security Triage Agent integration (COMPLETE)

Status: Fully implemented Commit: feat: implement Security Watch Phase S6 - Security Triage Agent integration

What was built:

  • process_security_advisory worker (app/workers/handlers/security_handlers.py): Consumes vs:security:triage stream, loads advisory + candidate apps, calls TriageAgent.decide_advisory(), creates security ticket, fires WebSocket event
  • Pre-filter updated (app/workers/handlers/prefilter_handlers.py): Survivors now published to vs:security:triage with full candidate match data; _prepare_candidate_matches() helper builds structured context for triage agent
  • create_ticket() function added to app/services/ticket_service.py: Shared service for security + vendor ticket creation; assigns ticket number, creates TicketApplication rows, audit-logs creation
  • Security ticket description builder (_build_security_ticket_description()): Rich formatted description with advisory ID, CVSS score, KEV badge, affected applications, component details, LLM reasoning, reference links
  • WebSocket events: security.advisory.critical_match published on critical severity tickets; KEV advisories get severity=high event, others severity=medium
  • vs:security:triage stream added to Redis consumer groups
  • 5 tests (tests/test_phase_s6.py): pipeline data flow, title formatting, guardrails integration, WebSocket event structure, full pipeline integration

Current next phase: Complete — Security Watch feature branch ready for review/merge

✅ Post-S10m: Pipeline reliability fixes (COMPLETE)

What was fixed:

Five production bugs found during first live KEV + OSV run with Maven inventory:

  1. publish_ui_event bool/Redis crash (backend/app/core/redis.py): Python bool is a subclass of int so isinstance(True, int) is True — raw booleans passed through to redis.xadd which rejects them. Fixed by checking isinstance(v, bool) before the int check, ensuring booleans are JSON-encoded to "true"/"false". This was causing all process_security_advisory triage jobs to fail with "Invalid input of type: 'bool'" when on_kev was included in the UI event payload.

  2. Pre-filter uq_advisory_component_match unique violation (backend/app/workers/handlers/prefilter_handlers.py): An OSV advisory can have multiple affected-version entries for the same package (e.g., different version ranges). Each entry independently matched the same inventory component, producing two rows with identical (advisory_id, component_id). Fixed by deduplicating matches by component_id before DB writes, merging the more permissive result (bypass_reason wins, then layer2).

  3. OSV CVSS GitHub vector format crash (backend/app/security/adapters/osv_adapter.py): GitHub Security Advisories put the full CVSS vector string (e.g., "CVSS:3.1/AV:N/AC:L/...") in severity[].score instead of a numeric score. float("CVSS:3.1/...") raised ValueError, silently failing normalization for all GHSA advisories including Log4Shell (GHSA-jfh8-c2jp-5v3q, CVSS 10.0). Fixed by trying float() and falling back to vector-string extraction when it fails.

  4. KEV cursor blocking all subsequent runs (backend/app/security/adapters/kev_adapter.py): Cursor was set to max(dateAdded) across all 1602 entries. On next run, all entries had dateAdded ≤ cursor and were filtered, returning 0 new entries. Fixed by removing cursor-based filtering from the adapter; deduplication is handled by the DB-level pre-filter in handle_fetch_source.

  5. normalize_advisory except block missing await db.commit() (backend/app/workers/handlers/security_handlers.py): The write_audit_log call in the except block was never committed — the IntegrityError from the CVSS crash left the session in a rolled-back state, silently swallowing the normalize_failed audit log. Fixed by adding await db.commit() in the except block with a nested try/except for robustness.

Bonus — is_new dedup for repeat OSV runs (security_handlers.py): handle_normalize_advisory was publishing to pre-filter/kev-cross-ref/NVD for every advisory on every OSV run, creating 2000+ duplicate downstream jobs. Fixed by tracking is_new = existing is None before upsert and early-returning after commit for already-known advisories.

Bonus — KEV DB-level pre-filter (security_handlers.py): For KEV runs, the 500-entry cap now applies only to genuinely new CVE IDs (those not already in security_advisories WHERE source='kev'), so the same first-500 entries no longer block entries 501–1602 on every run.

Result: 33 DLQ'd triage messages replayed and processed. 85+ security tickets created, including Log4Shell GHSA-jfh8-c2jp-5v3q (CVSS 10.0, VS-0914) and Spring4Shell GHSA-7rjr-3q55-vv33 (CVSS 9.0, on_kev=True, VS-0912).

Files changed: backend/app/core/redis.py, backend/app/security/adapters/kev_adapter.py, backend/app/security/adapters/osv_adapter.py, backend/app/workers/handlers/prefilter_handlers.py, backend/app/workers/handlers/security_handlers.py

Next: Merge feature/security-watchmain

✅ Post-S10l: Run history + live WebSocket status for security sources (COMPLETE)

What was built:

  • GET /api/security-sources/{id}/run-history (security_sources.py): queries audit_log for fetch_completed / fetch_failed entries for this source, returns last 20 runs with timestamp, adapter, advisories_fetched, advisories_published, backlog_truncated, and error message if failed.
  • WS events (security_handlers.py): security.source.run_started emitted at job start; security.source.run_complete emitted after successful fetch with counts. Frontend can now show live "Running..." indicator without polling.
  • RunHistoryModal (security-sources/page.tsx): "History" button on every source row opens modal showing last 20 runs — green check / red X, timestamp, fetch counts, truncation badge, error message. Empty state shown if no runs yet.
  • Live status row badges (security-sources/page.tsx): WS events drive a liveStatus map; each source row shows "Running..." (pulsing spinner), "Done" (green), or "Failed" (red) in real time. On completion, sources query auto-invalidated.
  • security.source.run_started registered in SecurityEventToasts SECURITY_TYPES set.
  • securitySourcesApi.runHistory(id) added to frontend API client.

Files changed: backend/app/api/security_sources.py, backend/app/workers/handlers/security_handlers.py, frontend/src/app/settings/security-sources/page.tsx, frontend/src/lib/api/index.ts, frontend/src/components/shared/security-event-toasts.tsx

Next: Merge feature/security-watchmain

✅ Post-S10k: NVD targeted enrichment (COMPLETE)

What was changed:

  • nvd_adapter.py: Added targeted fetch mode. When extra_config["cve_ids"] is present, the adapter fetches only those specific CVE IDs from NVD (one request per CVE, chunked at 5/30s without API key or 50/30s with key). Falls back to date-based scan only if cve_ids is not injected. Added _fetch_by_ids() and _fetch_single_cve() helpers.
  • security_handlers.py: Before calling NVD's fetch(), the handler now queries security_advisories for advisories with a CVE- alias but no CVSS score (up to 500), deduplicates, and injects them as extra_config["cve_ids"]. If there are no CVEs needing enrichment, the job exits early (no NVD calls made).
  • Result: NVD enrichment is now seconds (20 CVEs × 6s/req without key = 2 min) rather than 20-30 min scanning all of NVD. First run no longer a problem.

Files changed: backend/app/security/adapters/nvd_adapter.py, backend/app/workers/handlers/security_handlers.py

Next: Merge feature/security-watchmain

✅ Post-S10j: KEV run preview + "Last run" button for all adapters (COMPLETE)

What was built:

  • KEV preview endpoint (backend/app/api/security_sources.py): GET /last-run-preview now branches on adapter_name. For KEV: returns new_entries_this_run (from Redis), total_in_catalog (DB count of source=kev rows), osv_advisories_marked_critical (DB count of on_kev=True rows), and entries (catalog entries downloaded this run). For OSV/NVD: existing packages-sent + all-time advisories behavior, plus adapter field added to response.
  • KEV preview snapshot (backend/app/workers/handlers/security_handlers.py): _save_kev_preview() helper stores per-entry catalog details (CVE ID, name, vendor, product, date added, description) to the same Redis key pattern with 7-day TTL. Called after each KEV fetch.
  • Frontend preview modal (frontend/src/app/settings/security-sources/page.tsx): LastRunPreviewModal detects data.adapter === "kev" (discriminated union, TypeScript-safe) and renders 3 stat cards (new entries, total catalog, advisories escalated to Critical) plus a scrollable list of downloaded KEV entries. OSV path unchanged.
  • API type union (frontend/src/lib/api/index.ts): lastRunPreview return type is now a proper union: {adapter:"osv"|"nvd", packages_sent, advisories_received, ...} | {adapter:"kev", new_entries_this_run, total_in_catalog, osv_advisories_marked_critical, entries, ...}.
  • "Last run" button shown for all adapters with last_run_at set (was OSV-only).

Files changed: backend/app/api/security_sources.py, backend/app/workers/handlers/security_handlers.py, frontend/src/app/settings/security-sources/page.tsx, frontend/src/lib/api/index.ts

Next: Merge feature/security-watchmain

✅ Post-S10i: Check 3 rewrite + CISA KEV integration docs (COMPLETE)

What was improved:

  • Check 3 section (frontend/src/app/docs/page.tsx): rewritten around a real-life scenario (payment service with requests==2.32.0, safe version, but CVE lands on CISA KEV). Explains WHY the override exists. Table of 3 conditions in plain English. Mermaid node labels shortened.
  • CISA KEV integration added to advisory-lifecycle: explains that VendorSync downloads the full KEV catalog (~1,000 entries, one small JSON), cross-references against existing advisories by CVE ID, marks on_kev=True, bumps ticket to Critical. Answers "can run without it?" and "how to tell it's working?"
  • Security Sources docs: "What each source does" table now describes the mechanical behavior of each adapter concretely.

Next: Merge feature/security-watchmain

✅ Post-S10h: CISA KEV unblocked + advisory lifecycle redesign (COMPLETE)

What was fixed:

  • KEV/NVD adapter registration (backend/app/services/security_source_service.py): AVAILABLE_ADAPTERS was hardcoded to OSV-only from Phase S3. Added kev and nvd entries — API now accepts all three. CISA KEV source created in DB (id: 5acc85e4, every_1h schedule).
  • Advisory lifecycle docs (frontend/src/app/docs/page.tsx): full redesign — split into 3 short focused diagrams (overview, Check 2 version range, Check 3 override rules), node labels kept to 4-6 words to prevent truncation, no em-dashes or < in labels. Added explanation of why we query OSV by package not version. Added clear explanation of what CISA KEV is and why the CISA KEV source needs to be configured.

Next: Merge feature/security-watchmain

✅ Post-S10g: Mermaid diagram fix + Security Sources adapter clarity (COMPLETE)

What was fixed:

  • Mermaid diagram (frontend/src/app/docs/page.tsx): removed em-dash characters and < from node labels — these broke the Mermaid parser silently. Now uses safe ASCII only. Diagram uses fully chained edges (no standalone node definitions).
  • Security Sources UI (frontend/src/app/settings/security-sources/page.tsx): added always-visible "Recommended setup" callout showing OSV / CISA KEV / NVD as three separate sources with their roles (Required / Recommended / Optional), with "Added" badge for sources already configured. Fixes confusion where the 3-button type-selector in the Add form looked like tabs on existing sources. Modal label updated: "Feed type — choose one per source".

Next: Merge feature/security-watchmain

✅ Post-S10f: Last-run preview DB fix + advisory lifecycle Mermaid diagram (COMPLETE)

What was fixed/improved:

  • Preview "0 advisories" bug (backend/app/api/security_sources.py): last-run-preview endpoint now queries security_advisories table for all-time advisories from this adapter instead of relying on the Redis run snapshot. The Redis snapshot is cursor-filtered (shows only new advisories per run), so subsequent runs correctly return 0 new but 222 total. total_advisories now reflects the DB count; advisories_received returns the 200 most recent from the DB. packages_sent still comes from Redis (run-specific).
  • Advisory lifecycle Mermaid diagram (frontend/src/app/docs/page.tsx): replaced the basic graph TD with a flowchart TD using subgraph blocks for each filter layer, concrete example values (requests 2.25.1 / 2.27.0 / 2.31.0), emoji labels, and clear bypass path with reason text.

Next: Merge feature/security-watchmain

✅ Post-S10e: Last-run preview — visibility into OSV send/receive (COMPLETE)

What was built:

  • Run preview snapshot (backend/app/workers/handlers/security_handlers.py): after each OSV fetch, worker saves a JSON snapshot to Redis key vs:security:source:{id}:last_preview (7-day TTL). Stores: run_at, total_packages, total_advisories, packages_sent (full list), advisories_received (summarized: id, summary, aliases, affected_packages). Best-effort — never blocks the main fetch flow.
  • New endpoint (backend/app/api/security_sources.py): GET /api/security-sources/{id}/last-run-preview — reads the Redis key and returns the snapshot, or {available: false} if no run yet.
  • API client (frontend/src/lib/api/index.ts): securitySourcesApi.lastRunPreview(id) added.
  • LastRunPreviewModal (frontend/src/app/settings/security-sources/page.tsx): modal component with two tabs — "Packages sent" (table of ecosystem + name for every package queried) and "Advisories received" (cards showing advisory ID, aliases, summary, affected packages). Stats bar shows total counts at a glance.
  • "Last run" button on each OSV source row in the Security Sources table — only shown after the first successful run (last_run_at is set).

Next: Merge feature/security-watchmain

✅ Post-S10d: Security Watch docs full rewrite — user-friendly terminology (COMPLETE)

What was improved:

  • frontend/src/app/docs/page.tsx — all 6 Security Watch sections rewritten from scratch to be accessible to someone unfamiliar with security terminology:
    • Security Watch section: added "Key terms explained" block — what is a CVE, what is an Advisory ID, explained GHSA/CVE/PYSEC ID formats with examples, clarified that OSV and CISA IDs partially overlap (CVE IDs are shared; GHSA/PYSEC are OSV-specific aliases). Expanded data-source descriptions.
    • Inventory section: added concrete example showing version range match logic (2.25.1 < 2.31.0 = vulnerable; 2.31.0 = safe). Emphasized why pinned versions are required.
    • Security Sources section: added explanation of how OSV actually queries (per-package querybatch, not bulk download), ecosystem filter table with plain-language values, clarified that OSV/KEV/NVD are all used but serve different roles.
    • Advisory lifecycle section: split into clearly labeled Filter 1 / Filter 2 / Filter 3 sections with worked examples for each; Filter 2 has a table showing 4 versions and their pass/fail status; Filter 3 (bypass) explains KEV/CVSS/criticality conditions with a concrete bypass example; added "What the AI Triage Agent does" block listing all fields it assigns.
    • Auto-resolve section: added worked example (requests 2.25.1 → 2.31.0), clarified that Jira issues are not auto-closed (only commented), limits table improved.
  • No code logic changes — docs only.

Next: Merge feature/security-watchmain

✅ Post-S10n: Security Watch feature flag (COMPLETE)

What was built:

  • Backend flag endpoint (backend/app/api/settings.py): GET /api/settings/features returns {"security_watch": bool} derived from system_settings key security_watch.enabled (defaults to true if key absent). require_security_watch_enabled FastAPI dependency raises 403 when flag is off.
  • Backend route gating (security_sources.py, security_advisories.py, inventory.py, advisory_matches.py): all Security Watch API routers now carry require_security_watch_enabled as a router-level dependency — all endpoints return 403 when flag is off.
  • Scheduler gating (backend/app/scheduler/main.py): poll_security_sources job is only registered at startup when security_watch.enabled=true. No polling, no background work when off.
  • React context (frontend/src/contexts/feature-flags-context.tsx): FeatureFlagsProvider fetches /settings/features on mount; useFeatureFlags() hook exposes flags app-wide. Provider wraps entire app in providers.tsx.
  • UI gating: sidebar Security section, security-event toasts, dashboard Security Exposure panel, Applications page (Inventory chip, Inventory tab, Security routing form + details), ticket detail advisory card, docs Security Watch sections (6 sections), security-sources page (redirects to /settings when disabled).
  • Zero cost when off: if security_watch.enabled=false, no polling, no API calls, no UI sections rendered, no 403 errors from the frontend.

Next: Merge feature/security-watchmain

✅ Post-S10b: Inventory discoverability + docs rewrite (COMPLETE)

What was fixed/improved:

  • Inventory discoverability (frontend/src/app/applications/page.tsx): added Inventory chip at the bottom of each application card; clicking it directly opens the right-panel drawer on the Inventory tab. Previously the only way to reach inventory was to click the card body and then click the Inventory tab — completely hidden. Page subtitle updated to mention both Vendor Watch and Security Watch.
  • Docs rewrite (frontend/src/app/docs/page.tsx): all 6 Security Watch sections rewritten to be shorter, friendlier, and more practical; added Mermaid diagrams to Security Watch overview (adapter roles), Inventory (upload → auto-resolve flow), Security Sources (recommended setup), Advisory lifecycle (feed-to-ticket + severity guardrails), Auto-resolve (patch detection flow), Jira routing (two issue types diagram).
  • Future improvement noted: SBOM (CycloneDX/SPDX) upload support — not implemented, tracked as future work.

Next: Merge feature/security-watchmain

✅ Post-S10: UX polish + scheduler bug fix (COMPLETE)

What was fixed/improved:

  • Scheduler NameError fix (backend/app/scheduler/jobs.py): timedelta was used inside _should_run_now but only imported via a dead local-import path. Fixed by adding from datetime import datetime, timedelta, timezone at module level. Without this fix the poll_security_sources job failed on every run with NameError: name 'timedelta' is not defined, and no security sources were ever polled.
  • Security Sources page UX rewrite (frontend/src/app/settings/security-sources/page.tsx): replaced plain adapter dropdown with a visual button-group selector; added ADAPTER_INFO per-adapter description/recommended-schedule/ecosystem-hint map; info callout box shows adapter description when selected; "How it works" 3-step onboarding callout shown when no sources exist; adapter selection auto-sets the recommended schedule; Run Now success shows "✓ Queued — check Tickets in ~30s" inline.
  • Docs clarity rewrite (frontend/src/app/docs/page.tsx): "Security Watch" section now has "Get started in 3 steps" guide and "How the three adapters work together" table clarifying which adapters create tickets vs enrich/escalate; "Security Sources" section now has "Recommended setup for most teams" ordering (OSV → KEV → NVD) and updated adapter table with a "Creates tickets?" column and practical role description.

Next: Security Watch feature branch ready for review/merge

✅ Phase S10 — Hardening (COMPLETE)

Status: Fully implemented and tested Commit: feat: implement Security Watch Phase S10 - Hardening

What was built:

  • Backlog limit (security_handlers.py): _BACKLOG_LIMIT_PER_RUN = 500handle_fetch_source caps advisories published per job; audit log records advisories_fetched vs advisories_published and backlog_truncated flag; prevents first-run floods when a source returns thousands of entries
  • NVD adapter hardened (nvd_adapter.py): fixed signature to accept same kwargs as OSV (cursor, endpoint_url, credentials, ecosystem_filter, extra_config); changed return type from async generator to (list, cursor_str) (consistent with OSV); added _get_with_backoff() with exponential backoff (base=5s, max=300s) and Retry-After header support for 429 responses; reduced default lookback from 30 days to 7 days; added test_connection() method
  • WS event throttling (security_handlers.py): Redis key vs:throttle:ws:critical with 60s TTL; first 10 critical events per window emit individually; 11th triggers one batch summary event and all further are suppressed; prevents notification bell flood during first OSV run
  • Redis metrics (app/core/metrics.py NEW): incr_metric(key, amount) — fire-and-forget Redis INCR; get_security_metrics() reads 4 counters: advisories_ingested, tickets_created, auto_resolved, matches_found
  • Metrics endpoint (app/api/metrics.py NEW): GET /api/metrics/security — returns all counters + as_of timestamp; registered in app/main.py
  • Metrics incremented: advisories_ingested in handle_normalize_advisory, tickets_created in _create_security_ticket, auto_resolved in handle_check_auto_resolve
  • 14 tests in tests/test_phase_s10.py: backlog truncation, no-truncation within limit, NVD fetch returns list+cursor, NVD 429 triggers backoff+retry, NVD normalize output, WS throttle constant, metrics incr calls Redis, metrics swallows errors, get_security_metrics returns all keys

Key decisions:

  • Backlog truncation uses the adapter's own cursor (set to "now") so next run picks up from current time — old advisories beyond the cap are de-prioritized; freshness takes precedence
  • WS throttle resets every 60s (Redis TTL) — a sustained stream of critical advisories produces at most one batch summary event per minute
  • NVD fetch() now matches OSV's calling convention exactly — no more signature mismatch with the handler

Next phase: Complete — Security Watch feature branch ready for review/merge

✅ Phase S9 Batch B — WebSocket handlers + Docs tab (COMPLETE)

Status: Fully implemented Commit: feat: implement Security Watch Phase S9 Batch B - WebSocket handlers + Docs tab

What was built:

  • src/contexts/events-context.tsx (NEW) — React context that lifts useEventsWs so a single WebSocket connection is shared across the whole app; exports EventsProvider and useEvents() hook
  • src/components/shared/security-event-toasts.tsx (NEW) — SecurityEventToasts component: watches for new security-typed events via the shared context; renders persistent banners (top of viewport) for security.advisory.critical_match and security.source.run_failed; renders auto-dismiss toasts (bottom-right, 6s) for security.ticket.auto_resolved and security.source.run_complete; skips events that were already in state on mount (no replay on page load)
  • src/components/providers.tsx (MODIFIED) — wraps app in EventsProvider, mounts SecurityEventToasts at root so events fire on any page
  • src/components/layout/header.tsx (MODIFIED) — uses useEvents() from context instead of calling useEventsWs() directly; eliminates duplicate WebSocket connection
  • src/app/docs/page.tsx (MODIFIED) — added 6 Security Watch sections to SECTIONS array: "Security Watch" (overview + two-pillar architecture + mermaid flow), "Inventory" (upload, formats, staleness, auto-resolve link), "Security Sources" (adapters, schedules, ecosystem filters, health indicators), "Advisory lifecycle" (pre-filter layers → triage → ticket, severity table, real-time events table), "Auto-resolve" (step-by-step flow + mermaid, audit trail, Jira comment, limitations), "Jira routing (security)" (link roles table, per-app config, security team Jira, test mode)

Key decisions:

  • Shared EventsContext prevents two WebSocket connections when both Header and SecurityEventToasts are mounted
  • initialized.current ref prevents toast replay on first mount — only truly new events (arrived while user is on page) trigger toasts/banners
  • Banners use z-[60] to appear above the sticky header (z-40)
  • Docs sections are content-only (no code changes) — searchable via the existing docs search

Next phase: S10 — Hardening

✅ Phase S9 Batch A — Security Watch UI (COMPLETE)

Status: Fully implemented Commit: feat: implement Security Watch Phase S9 Batch A - Security Watch UI

What was built:

  • Backend schema extensions (app/schemas/__init__.py): ApplicationBase/ApplicationUpdate gain security_jira_project_key, security_default_assignee, suppress_security_below; RuleBase/RuleUpdate gain kind: RuleKind; TicketRead gains kind, security_advisory_id (optional source_email_id); TicketJiraLinkRead gains link_role; new SecurityAdvisoryRead schema
  • Tickets kind filter (app/api/tickets.py + app/services/ticket_service.py): GET /api/tickets?kind[]=security_advisory — multi-value kind query param filters by Ticket.kind
  • GET /api/security-advisories/{id} (app/api/security_advisories.py + registered in app/main.py): fetch advisory by UUID, returns SecurityAdvisoryRead
  • Frontend types (src/lib/types/index.ts): updated Ticket, TicketJiraLink, Rule, Application; added ApplicationComponent, InventoryStatus, ManifestDiffEntry, ManifestDiff, SecuritySource, SecurityAdvisory
  • Frontend API layer (src/lib/api/client.ts + src/lib/api/index.ts): apiFetch skips Content-Type for FormData (browser sets multipart boundary); added inventoryApi, securitySourcesApi, securityAdvisoryApi; ticketsApi.list accepts kind[]
  • Hooks (src/hooks/index.ts): added useInventory, useInventoryStatus, useSecuritySources, useSecurityAdvisory
  • Sidebar (src/components/layout/sidebar.tsx): Security section with "Security Sources" nav item → /settings/security-sources
  • Applications page (src/app/applications/page.tsx): detail drawer has two tabs (Details | Inventory); Inventory tab shows staleness bar, component table, manifest upload with diff preview modal, inline add-component form; application form has collapsible "Security routing (advanced)" section (Jira project key, default assignee, suppress-below radio)
  • Rules page (src/app/rules/page.tsx): Kind toggle ("Vendor change" / "Security advisory") in drawer; KindBadge column in table
  • Ticket detail (src/app/tickets/[id]/page.tsx): Advisory card in right column when security_advisory_id set; LinkRoleLabel under each Jira link; summary heading changes to "Advisory summary" for security tickets; Change type hidden for security advisory tickets
  • Dashboard (src/app/page.tsx): Security Exposure panel (KEV/Critical/High/Open KPI cards + top teams) shown only when security tickets exist
  • Security Sources settings page (src/app/settings/security-sources/page.tsx): full CRUD; adapter badge, schedule label, status dot, last run, consecutive failures; add/edit modal with adapter dropdown (osv/kev/nvd), schedule preset, endpoint URL, ecosystem chips, enabled toggle, inline test connection; Run Now button with result display

Key decisions:

  • apiFetch skips Content-Type for FormData — browser must set multipart boundary or server rejects upload
  • Advisory card and Exposure panel render conditionally — no impact on Vendor Watch UI path
  • link_role badge shown under each Jira link in ticket detail: "Per-app issue" or "Security team issue"
  • suppress_security_below uses radio buttons (None/Low/Medium) to match CVSS threshold semantics

Next phase: S9 Batch B — WebSocket event handlers (4 security events) + Docs tab Security Watch sections (6 sections)

✅ Phase S8 — Auto-resolve on patch (COMPLETE)

Status: Fully implemented and tested Commit: feat: implement Security Watch Phase S8 - Auto-resolve on patch

What was built:

  • app/services/security_resolve_service.py — core auto-resolve logic: check_auto_resolve(db, application_id) finds all open security tickets for the app, re-runs pre-filter Layers 1+2 against the current (post-upload) inventory, marks advisory_component_matches.became_irrelevant_at when the app is no longer affected, auto-resolves the ticket via transition_system() when no app on the ticket still matches
  • handle_check_auto_resolve added to security_handlers.py — registered on vs:security:enrichment stream; after resolving tickets, adds a Jira comment ("VendorSync inventory shows upgrade complete — mark Done if work is finished") to each resolved Jira link for the uploaded app
  • POST /api/applications/{id}/inventory/upload updated to publish check_auto_resolve job to vs:security:enrichment after every successful manifest apply — non-blocking, processed async by worker
  • 20 tests in tests/test_phase_s8.py: version-range boundary checks, _app_still_matches (patched/unpatched/no-version/empty-ecosystem), _mark_matches_irrelevant (sets timestamp, no-op when no components), check_auto_resolve (empty list when no tickets, no-resolve when still matching, resolves when all patched, stays open when other app still affected, skips missing advisory), upload endpoint publishes correct job

Key decisions:

  • check_auto_resolve commits at the end of a single call — all resolutions for one upload are committed together
  • Jira comment failure is logged and swallowed — never blocks the auto-resolve DB state
  • The check is scoped to the uploaded app then fans out: first determine if this app is still affected, then check if any other app on the ticket is still affected before resolving
  • became_irrelevant_at set on all AdvisoryComponentMatch rows for the app when it's no longer affected — provides an audit trail of when the patch took effect

Next phase: S9 — UI

✅ Phase S7 — Jira integration (security path) (COMPLETE)

Status: Fully implemented and tested Commit: feat: implement Security Watch Phase S7 - Jira integration (security path)

What was built:

  • ADF/wiki security description builders (app/jira/adf.py): build_adf_security_description() and build_wiki_security_description() — two link_role variants (app = per-app component details + fix version; security_team = affected packages overview + coordinator action); KEV badge, CVSS score, references, AI reasoning, VendorSync footer
  • JiraClient.create_security_issue() (app/jira/client.py): creates Jira issues for security advisory tickets; routes to per-app Jira project or security-team project based on link_role; cisa-kev label when on_kev=True; advisory ID in summary; [TEST] prefix support; duedate from ticket.effective_date
  • handle_create_jira_issue extended (app/workers/handlers/jira_handlers.py): detects ticket.kind == 'security_advisory' and routes to _create_security_jira_issue() helper; vendor Watch path unchanged
  • _create_security_jira_issue() (private helper): loads advisory + component details from advisory_component_matches; handles both link_role='app' and link_role='security_team'; 4xx → orphan immediately, 5xx → re-raise for worker retry; graceful orphan when security-team Jira disabled in settings
  • handle_create_jira_issues (new handler on create_jira_issues job type): fan-out entry point published by security worker after ticket creation; creates security_team link row if security_watch.security_team_jira.enabled=true; processes all pending TicketJiraLink rows; fires notify_ticket_created once all links have keys
  • create_ticket service fixed (app/services/ticket_service.py): corrected ORM field names (kind not ticket_kind, security_advisory_id not source_reference); fixed TicketApplication creation (composite PK — no id field, non_jira_status=pending)
  • Security handler fixed (app/workers/handlers/security_handlers.py): corrected ticket existence check (Ticket.security_advisory_id + Ticket.kind); creates per-app TicketJiraLink rows before publishing create_jira_issues
  • 31 tests (tests/test_phase_s7.py) — all 31 passed, 0 failures, 1 unrelated warning: ADF builder (per-app, security_team, KEV badge, references, reasoning absent when empty), wiki builder, client per-app/security_team routing, summary format, test mode prefix, KEV label, Server/DC wiki format, handler routing (security vs vendor dispatch), fan-out with/without security_team config, disabled security_team orphan, missing advisory orphan, 4xx Jira error orphan, create_ticket ORM field correctness, default kind

Key decisions:

  • handle_create_jira_issue (singular) handles the legacy vendor path + single-link security path via routing; handle_create_jira_issues (plural) is the new fan-out entry point for security tickets only
  • security_team link row is created lazily at fan-out time (not at ticket creation time) — the setting is evaluated when the job runs, so toggling the setting takes effect for all pending tickets
  • Missing advisory_component_matches row → falls back to first affected package from advisory affected[] — no hard error
  • security_team link with disabled config → sync_status=orphan with clear message, no exception — does not block per-app links
  • Existing bulk_sync_open_tickets sync path handles security advisory Jira links automatically — no changes to sync code