Use this file to resume between sessions. Updated at the end of every phase.
| 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. |
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;docker exec vendorsync-postgres-1 pg_dump -U vendorsync vendorsync > backup_$(date +%Y%m%d).sqldocker exec -i vendorsync-postgres-1 psql -U vendorsync vendorsync < backup_YYYYMMDD.sqlThese 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. |
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
# 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/healthURLs:
- 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.
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.examplewith all required vars documentedtraefik/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 /healthendpoint — 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.py—AUTH_BYPASS_ENABLEDenv var, refuses to start in production - Git repo initialized
Key decisions:
- Auth bypass defaults to
truein.env.examplefor dev convenience - Production guard:
Settings.refuse_bypass_in_production()raises on startup if bypass + production - Bypass status surfaced in
/healthresponse
Commit: Phase 2: Data layer — models, migration, schemas
What was built:
app/models/enums.py— all 15 Python enums matching DB enum typesapp/models/mixins.py—UUIDPrimaryKey,TimestampMixin,SoftDeleteMixinapp/models/core.py—User,Vendor,Application,ApplicationVendor,Ruleapp/models/tickets.py—Ticket,TicketApplication,TicketJiraLink,TicketNoteapp/models/email.py—EmailSource,SourceEmail,EmailAttachmentapp/models/config.py—LLMConfig,JiraConfig,SystemSettings,AuditLog,NotificationLogapp/models/__init__.py— exports all models so Alembic autogenerate sees themalembic/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-duplicationapp/schemas/__init__.py— Pydantic v2 schemas for all entities: Create/Update/Read variants, secrets excluded from Read schemas,PaginatedResponse[T]generictests/test_models.py— enum coverage, schema validation, secret exclusion, model import test
Key decisions:
metadata_column alias used forAuditLog.metadataandNotificationLog.metadatato avoid shadowing Python'smetadataattributeticket_number_seqcreated beforeticketstable in migration- All secrets (
api_key_encrypted,password_encrypted,api_token_encrypted) excluded from Read schemas POP3confirmed absent fromEmailProtocolenum (test enforces this)non_jira_statusdefaults topendingon allTicketApplicationrows at creation
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 viaauthlib(RS256), validates issuer + audience + expiry, HTTP 401 on bad token, HTTP 503 on JWKS unavailableapp/auth/dependencies.py—get_current_userFastAPI dependency: bypass-aware, validates Bearer token, calls_get_or_provision_user, rejects disabled users, updateslast_login_atapp/auth/dependencies.py—_get_or_provision_user: looks up byokta_subject_id, creates new user on first login, applies bootstrap admin logic, respectsauth.auto_provisionsystem settingapp/auth/middleware.py—require_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.py—GET /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/enableapp/main.py— auth router registeredtests/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_userreturnsUser | 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_userusesdb.flush()notdb.commit()— the session commit happens inget_dbafter the request completes- Bootstrap admin is idempotent — re-applying
BOOTSTRAP_ADMINSnever downgrades an existing admin require_rolehandles bothUserRoleenum and string values (bypass user stores role as string)
Commit: Phase 4: Ticket engine — state machine, CRUD API, audit log, manual override
What was built:
app/services/audit.py—write_audit_log()used by all state changes; append-only, never updates rowsapp/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.py—next_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}/audittests/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 terminaltests/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 specconfirm_closure()handles admin bypass withtwo_person_bypass: truein audit metadata_actor_id()helper returns a real UUID for bypass user (deterministic from email) so audit log is never NULL for human actionsupdate_non_jira_status()rejects apps wherecreates_jira_ticket=Truewith a clear 400 error
Commit: Phase 5: Application registry & rules — vendors, applications, rules CRUD APIs
What was built:
app/services/vendor_service.py—list_vendors(),get_vendor(),create_vendor()(409 on duplicate name),update_vendor()— all with audit logapp/services/application_service.py—list_applications(),get_application(),create_application()(validatesjira_project_keyrequired whencreates_jira_ticket=True),update_application()(validates combined Jira state),link_vendor(),unlink_vendor()— all with audit logapp/services/rule_service.py—list_rules(),list_rules_for_triage()(includes global rules withvendor_id=NULL),get_rule(),create_rule()(version=1),update_rule()(increments version),delete_rule()(soft delete) — all with audit logapp/api/vendors.py—GET/POST /api/vendors,GET/PATCH /api/vendors/{id}— admin-only writesapp/api/applications.py—GET/POST /api/applications,GET/PATCH /api/applications/{id},POST/DELETE /api/applications/{id}/vendors/{vendor_id}— admin/change_manager writesapp/api/rules.py—GET/POST /api/rules,GET/PATCH/DELETE /api/rules/{id}— admin/change_manager writes, soft delete returns 204tests/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 fromlist_rules()— it always includesvendor_id=NULLrules 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
Commit: Phase 6: Redis Streams infrastructure — publish/consume/ack/retry/DLQ, worker loop
What was built:
app/workers/streams.py—publish()(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 tovs:dlqwith 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(),JobContextdataclass (job_type, data, attempt, msg_id, stream, db)app/workers/handlers/email_handlers.py—poll_mailbox,process_emailstubs onvs:emails:incomingapp/workers/handlers/jira_handlers.py—create_jira_issue,sync_jira_batch,sync_jira_ticketstubs onvs:jira:create/vs:jira:syncapp/workers/handlers/notification_handlers.py—notify_ticket_created,notify_ticket_resolved,escalate_deadline,flag_breachedstubs onvs:tickets:notify/vs:tickets:escalateapp/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/rollbacktests/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 groupretry_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.pyimports all handler modules explicitly - Each message gets its own
AsyncSessionLocal()session — DB errors in one message don't affect others reclaim_stale_messages()usesXAUTOCLAIM(Redis 7+) — recovers messages that were claimed but not ACKed before a crash
Commit: Phase 7: LLM integration & Triage Agent — LiteLLM wrapper, prompts, parser, guardrail
What was built:
app/llm/client.py—complete()loads activeLLMConfigfrom DB at call time (no restart needed for provider changes), decrypts API key, callslitellm.acompletion(), falls back to fallback LLM on failure;complete_with_image()for multimodal calls;_litellm_model()handles provider prefix routingapp/llm/prompts.py—build_triage_prompt()assembles 5-section prompt: EMAIL, VENDORS WE TRACK, RULES, APPLICATIONS, TASK with exact JSON schema in the decision sectionapp/llm/parser.py—TriageDecisiondataclass (stable interface),parse_triage_response()strips markdown fences, extracts JSON, validates all required fields, skips malformed UUIDs gracefully;build_corrective_prompt()for retry;ParseErrorexception typeapp/agents/triage_agent.py—TriageAgent.decide(): loads context (vendors, rules vialist_rules_for_triage, applications), builds prompt, calls LLM, parses with one corrective retry onParseError, validates UUIDs against DB (removes invalid app IDs silently), applies severity guardrailtests/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), fulldecide()with mock LLM, corrective retry flow, double-failure raises, invalid app ID removal
Key decisions:
TriageDecisionis a dataclass not a Pydantic model — it's an internal agent output, not an API schemais_breaking_changefield 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
Commit: Phase 8: Email ingestion — IMAP client, message parser, ticket creator, process_email handler
What was built:
app/ingestion/imap_client.py—fetch_unseen_messages()dispatches by protocol;_fetch_imap()usesaioimaplibasync IMAP, searches UNSEEN, fetches RFC822 bytes; Exchange and Gmail stubs ready for future implementation;_imap_connection()async context manager handles login/logoutapp/ingestion/message_parser.py—parse_raw_email()uses stdlibemailmodule, 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 inputapp/ingestion/attachment_handler.py—save_attachments()persists to storage backend viabuild_attachment_path(), insertsEmailAttachmentrows;build_llm_image_data()converts PDF/image attachments to OpenAI vision format (base64), skips >5MB and unsupported typesapp/ingestion/ticket_creator.py—create_ticket_from_decision(): idempotency check onsource_email_id, generates ticket number via sequence, insertsTicket+TicketApplication(all withnon_jira_status=pending) +TicketJiraLink(for Jira-enabled apps), writes audit log with severity override metadata, publishescreate_jira_issueper Jira app andnotify_ticket_createdapp/workers/handlers/email_handlers.py— replaced stubs:handle_poll_mailbox()fetches unseen messages, insertsSourceEmailrows (race-condition-safe via IntegrityError catch), saves attachments, publishesprocess_emailjobs, updateslast_poll_status;handle_process_email()runs Triage Agent, routes to unknown queue onParseErrororvendor_id=None, marks failed onRuntimeError, creates ticket on successtests/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()+IntegrityErrorcatch for race conditions between worker replicas process_emailmarks emailunknown(notfailed) onParseError— 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_idbefore inserting — safe to retry if worker crashes after DB write but before ACK
Commit: Phase 9: Jira integration — REST client, ADF builder, bulk sync, create/comment handlers
What was built:
app/jira/adf.py—build_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()helperapp/jira/client.py—JiraClient: 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;JiraClientErrorwith status_code + detail;map_jira_status_category()maps Jira category keys to VS enum valuesapp/jira/sync.py—bulk_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, markssync_status=failedfor issues not returned (404);_evaluate_ticket_status(): applies state machine (skip-through for open→resolved, in_progress transition);sync_single_ticket()for manual syncapp/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— addedPOST /api/tickets/{id}/sync(any authenticated, publishes priority job) andPOST /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=orphanimmediately, 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'sstatusCategory.keyfield (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_URLread fromNEXTAUTH_URLenv var at runtime — ticket links in Jira descriptions always point to the correct deployment URL
Commit: Phase 10: Scheduler & deadline monitor — APScheduler, mailbox polling, Jira sync, escalation, breach
What was built:
app/scheduler/deadline.py—run_deadline_monitor(): queries open tickets witheffective_date, calculatesdays_until = effective_date - today, fires D-7/D-3/D-1 escalations via_fire_escalation(), flags breached tickets viaflag_breached();_fire_escalation(): once-only check via_escalation_already_sent(), publishes tovs:tickets:escalate, insertsNotificationLogrow withstatus=pendingfor deduplication;_escalation_already_sent(): queriesnotification_logusing the JSONB expression index onmetadata->>'escalation_threshold'app/scheduler/jobs.py—poll_all_mailboxes(): queries activeEmailSourcerows, publishespoll_mailboxjob per source;trigger_jira_sync(): publishessync_jira_batch;run_deadline_monitor_job(): runs deadline monitor with its own DB session;load_jira_sync_interval(): readsjira_config.sync_interval_secondsfrom DB, falls back to 300; all jobs catch and log exceptions so one failure doesn't crash the schedulerapp/scheduler/main.py— replaced stub:AsyncIOSchedulerwith three jobs (mailbox poll every 60s, Jira sync at configured interval, deadline monitor every 15min),max_instances=1+coalesce=Trueprevents job pile-up, SIGTERM/SIGINT graceful shutdown, logs next run times on startupapp/workers/handlers/notification_handlers.py—handle_escalate_deadline()upgraded from stub: loads ticket with Jira links, skips if resolved/closed/breached, adds Jira escalation comments viabuild_escalation_comment()+client.add_comment()(Jira failure doesn't block notification), updatesNotificationLogentry frompending→sent; Phase 11 will add Slack/email/PagerDutytests/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_mailboxesruns every 60s but each source'spoll_interval_secondsis enforced by the worker checkinglast_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
NotificationLogentry inserted withstatus=pendingby the scheduler (before worker processes it) — prevents double-fire if scheduler runs twice before worker picks up the job coalesce=Trueon 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
Commit: Phase 11: Notifications — Slack, email/SMTP, PagerDuty, severity routing, notification_log
What was built:
app/services/notifications.py—channels_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()usesslack_sdk.web.async_client.AsyncWebClient, gracefully logs failure whenSLACK_BOT_TOKENnot set;_send_email()uses stdlibsmtplibviarun_in_executor(non-blocking), gracefully logs failure whenSMTP_HOSTnot set;_send_pagerduty()POSTs to PagerDuty Events API v2 viahttpx, gracefully logs failure whenPAGERDUTY_ROUTING_KEYnot set;_log_notification()writes tonotification_logwithsent_atonly on success, truncates messages at 2000 charsapp/workers/handlers/notification_handlers.py—handle_notify_ticket_created()loads ticket + Jira links, callsdispatch_ticket_created();handle_notify_ticket_resolved()callsdispatch_ticket_resolved();handle_flag_breached()callsdispatch_breach_alert(); all three fully wiredtests/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.failedwith a clear error message — visible in the admin notification log view smtplibruns inasyncio.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)
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 entriesapp/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-onlyapp/api/audit.py—GET /api/auditpaginated, filterable byentity_typeandentity_id, accessible to all authenticated rolesapp/main.py— settings and audit routers registeredtests/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
Commit: Phase 13: Unknown queue — triage view, manual classify, dismiss, rule creation from triage
What was built:
app/services/triage_queue_service.py—list_unknown_emails()queriessource_emails WHERE processing_status IN (unknown, failed)ordered byreceived_at DESC;get_triage_email()loads email with attachments;classify_email()validates not-already-classified (409), buildsTriageDecisionfrom operator input, callscreate_ticket_from_decision()(same path as automatic triage), marks email asclassified, writes audit log, optionally calls_save_as_rule()which auto-generates instruction text when none provided;dismiss_email()marks asfailedwith audit log;ManualClassificationandTriageEmailReadPydantic schemasapp/api/triage.py—GET /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 onlyapp/main.py— triage router registeredtests/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()reusescreate_ticket_from_decision()— the same atomic function used by automatic triage. Manual and automatic paths produce identical ticket structure.save_as_rule=Trueauto-generatesinstruction_textfrom the classification fields whenrule_instruction_textis not provided — operators don't need to write instructions from scratchdismiss_email()setsprocessing_status=failed(not a new status) — dismissed emails are excluded from the triage queue on next load- Triage queue shows both
unknownandfailedemails — operators can retry failed emails too
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) whenAUTH_BYPASS_ENABLED=truesrc/middleware.ts— protects all routes; bypass mode passes all through; redirects unauthenticated to/auth/signinsrc/lib/api/client.ts—apiFetch<T>()base fetch wrapper; attaches Bearer token from Auth.js session;ApiErrorwith status code; skips token in bypass modesrc/lib/api/index.ts— typed API functions for all resources:ticketsApi,vendorsApi,applicationsApi,rulesApi,authApi,settingsApi,auditApisrc/hooks/index.ts— TanStack Query hooks for all resources;useTicketJiraLinksauto-refreshes every 30s;useTicketTransitionreturns mutation objects for all state transitionssrc/components/providers.tsx—QueryClientProviderwrapper with 1-min stale timesrc/components/layout/sidebar.tsx— fixed left nav, active state viausePathname, lucide icons, Precision Enterprise tokenssrc/components/layout/header.tsx— sticky top bar, search input, bell + help icons, user avatar with initialssrc/components/shared/badges.tsx—SeverityBadge,StatusBadge(dot + label),JiraStatusBadge(links to Jira when url present)src/components/shared/primitives.tsx—DeadlineCountdown(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 areasrc/app/page.tsx— Dashboard: KPI cards (open/approaching/breached/resolved), critical tickets grid, all open tickets table with paginationsrc/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 modalsrc/app/applications/page.tsx— Applications registry: grid of cards, right-side detail drawer with metadata and vendor listsrc/app/rules/page.tsx— Rules admin: table with keyword badges, right-side edit drawer with keyword tag input and instruction textareasrc/app/settings/page.tsx— Settings: secondary nav, email sources table, LLM config cards (primary/fallback), Jira config display, placeholder sectionssrc/app/audit/page.tsx— Audit log: paginated table, entity type filtersrc/app/auth/signin/page.tsx— Sign-in page with Okta button (server action)src/app/loading.tsx— skeleton loading statesrc/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 useTicketJiraLinkspolls 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=trueskips token attachment in API client; middleware passes all routes through - Override close modal enforces 20-char minimum client-side before enabling the confirm button
Commit: Phase 15: Hardening — structured logging, middleware, rate limiting, production Dockerfiles, HTTPS
What was built:
app/core/logging.py—JsonFormatteremits 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.py—RequestLoggingMiddleware: attaches uniqueX-Request-IDUUID 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 productionapp/core/rate_limit.py—SlidingWindowRateLimiter: in-memory sliding window per (IP, path) key;rate_limitdependency (120 req/min default);rate_limit_authdependency (20 req/min for auth endpoints);_get_client_ip()respectsX-Forwarded-Forfrom Traefikapp/api/auth.py—rate_limit_authapplied to/api/auth/meapp/main.py—configure_logging()called at startup;RequestLoggingMiddlewareadded; startup/shutdown log messagesbackend/Dockerfile.prod— multi-stage build: builder installs deps with uv, runtime copies venv only; non-rootappuser; no CMD (overridden per service)frontend/Dockerfile.prod— multi-stage: builder runsnpm run build, runtime uses Next.js standalone output; non-rootappusertraefik/traefik.prod.yml— production Traefik: HTTP→HTTPS redirect, Let's Encrypt via ACME httpChallenge, dashboard disabled, security headers middlewaretraefik/dynamic.yml— security headers: HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policydocker-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— addedENVIRONMENT,DOMAIN,ACME_EMAIL, SMTP varstests/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:
RequestLoggingMiddlewareis outermost middleware — catches all exceptions including those from inner middleware- Rate limiter is in-memory (single instance) — for multi-instance deployments, replace
_windowsdict with Redis INCR + EXPIRE - Production Dockerfiles use multi-stage builds — runtime image contains only the venv and app code, no build tools
Dockerfile.prodhas no CMD —docker-compose.prod.ymlprovides 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
VendorSync MVP is fully built. The system is ready for:
cp .env.example .env— fill in secretsdocker compose up --build— start dev stackdocker compose exec backend alembic upgrade head— run migrations- Open http://localhost:4111 — VendorSync frontend is running
- Open http://localhost/health — backend health check
- Open http://localhost/api/docs — FastAPI Swagger UI
- Open http://localhost:8080 — Traefik dashboard
For production: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
Docker / Dockerfile:
- Replaced
uvvenv approach with plainpip installdirectly into system Python — venv is redundant inside Docker (Docker is the isolation layer) - Fixed volume mounts:
./backend/app:/app/appinstead of./backend:/appto preserve installed packages - Fixed Alembic migration: replaced
sa.Enum(...)withpostgresql.ENUM(..., create_type=False)in allcreate_tablecalls to prevent duplicate enum type creation - Fixed bypass user email: changed
dev@vendorsync.localtodev@vendorsync.dev(.localTLD fails Pydantic email validation) - Frontend port changed to 4111 (direct access:
http://localhost:4111) - Fixed
src/middleware.tsbad import path (@/../../auth→ dynamic import) - Fixed
src/app/auth/signin/page.tsx— converted to client component usingnext-auth/reactsignIn() - Fixed Tailwind v4 compatibility: moved all Precision Enterprise color tokens from
tailwind.config.tsintoglobals.css@themedirective - Added
/tickets/page.tsx— was missing, causing 404 on Tickets tab - Fixed scheduler crash:
OKTA_ISSUERandOKTA_AUDIENCEwere missing from scheduler env; made them optional inSettings
Scheduler visibility:
app/scheduler/jobs.py— each job now calls_record_job_run()which stores last-run time + result in Redis hashvs:scheduler:jobsapp/api/system.py— newGET /api/system/statusendpoint: 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.py—GET /ws/statusWebSocket endpoint pushes queue depths + scheduler status every 5 secondsfrontend/src/hooks/use-ws.ts—useSystemStatusWs()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
/wspath 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 causedOPTIONSpreflight400errors blocking all POST/PATCH requests - FERNET_KEY: must be added to
docker-compose.ymlenvironment 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
userstable 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.yproperty accesses must use optional chaining (data.x?.y ?? fallback) — API can return partial data on first load causing crashes - IMAP fetch:
aioimaplibreturns message IDs asbytes— must decode tostrbefore callingclient.fetch(), otherwise returnsBAD: 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:/appoverwrites installed pip packages — use./backend/app:/app/appinstead /app/.nextanonymous volume was isolating Next.js build cache from source mount, breaking hot reload — removed- Postgres data: use
./data/postgres/pgdatasubdirectory (not./data/postgres) to avoid initdb error from.gitkeepfile
Email source improvements:
allowed_sendersfield: comma-separated list of sender patterns (supports*@domain.comwildcards) — filters IMAP searchmax_fetch_per_pollfield: configurable limit on emails fetched per poll cycle (default 50)- Migration
0002_email_source_filtersadds 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:eventsRedis stream — single event bus for all UI-bound notificationspublish_ui_event()inapp/core/redis.py— writes to both Redis stream (live) andsystem_eventsDB table (permanent)- Migration
0003_system_events— persistent storage for system events, survives Redis flush - Events published from: poll complete, ticket created, scheduler errors
/ws/eventsWebSocket endpoint — streams all events from Redis stream to frontend in real-time, sends last 20 as history on connectuseEventsWs()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_completehidden by default to reduce noise GET /api/system/logsreads fromsystem_eventsDB 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
vprefix from version (v3→3in URL path) - Fixed deprecated search API: use
POST /rest/api/3/search/jqlinstead ofGET /search - Test mode:
jira.test_modesystem 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
/docsroute 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
FROMsearch 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:
imapclientadded tobackend/Dockerfile— installs on every buildmermaidadded tofrontend/package.json— installs on every build
Timezone-aware time formatting:
frontend/src/lib/time.ts—formatDateTime(),formatTime(),formatRelative(),formatDate(),getUserTimezone()— all useIntl.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 useformatDateTime()from@/lib/time
API client fix:
NEXT_PUBLIC_*vars are baked at build time — unreliable at runtime- API base now derived from
window.location.hostnameat runtime:${protocol}//${hostname}/api - This ensures the frontend always calls the correct backend regardless of build-time env vars
| 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 |
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 matchingdocs/*.mdand 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:eventsstream noted, components 15–19 added, parallel security data flow diagram, build sequence S1–S10.docs/DATA_MODEL.md— addedkinddiscriminator onrulesandtickets,link_roleonticket_jira_links, security routing fields andlast_inventory_refresh_atonapplications, 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_applicationsrows 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_attracked 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
resolvedwhen no app still matches. - Schedule UI: human-friendly preset dropdown (no cron).
Test connectionandRun nowbuttons 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)
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 applicationsextended:security_jira_project_key,security_default_assignee,suppress_security_below(defaultnone),last_inventory_refresh_atrulesextended:kind(defaultvendor_change)ticketsextended:kind(defaultvendor_change),security_advisory_id(FK →security_advisories.id),source_email_idis now nullableticket_jira_linksextended:link_role(defaultapp),application_idis now nullable. Unique constraint changed to(ticket_id, application_id, link_role). Partial unique indexuq_ticket_jira_link_security_teamenforces at most onesecurity_teamlink per ticket.- New tables:
application_components,security_sources,security_advisories,advisory_component_matches— full schema indocs/DATA_MODEL.md - New indexes:
ix_app_components_ecosystem_name(functional, onlower(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 haskind, ticket_jira_link haslink_role+ nullableapplication_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 headclean —alembic_version=0007_security_watchpython -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-asyncioby default (prod Dockerfile pip-installs only runtime deps). They werepip install-ed ad-hoc for verification. Future phase should consider addingpytest,pytest-asyncio,fakeredisto 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_idonticket_jira_linksis now nullable forlink_role='security_team'. Existing Vendor Watch code that creates links must always setlink_role='app'explicitly (or rely on the default). Phase S7 will createsecurity_teamrows.
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) andPOST /inventory/upload(transactional apply) - Freshness tracking:
GET /inventory/statuswith staleness detection and configurable thresholds - Soft-delete pattern: components not in new manifest marked
is_active=falsefor 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 filteringPOST /api/applications/{id}/inventory— manual component creation (admin/change_manager)DELETE /api/applications/{id}/inventory/{component_id}— soft deactivationPOST /api/applications/{id}/inventory/preview— manifest upload diff preview (no DB writes)POST /api/applications/{id}/inventory/upload— manifest upload and transactional applyGET /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
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-sourceswith 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_advisoriestable (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
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_componentstable - 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_matchestable 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_matchestracks layer1/layer2 results + bypass reasons - Pipeline integration: Normalized advisories automatically trigger pre-filter evaluation
- Admin visibility:
/api/advisory-matchesendpoints 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)
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, setson_kev=Trueon 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_referenceandhandle_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 withkindparameter — returnssecurity_advisoryrules for security path vs:security:enrichmentstream added to Redis consumer groups- 4 tests (
tests/test_phase_s5.py): adapter normalization logic, severity guardrail logic, CVSS label derivation
Status: Fully implemented
Commit: feat: implement Security Watch Phase S6 - Security Triage Agent integration
What was built:
process_security_advisoryworker (app/workers/handlers/security_handlers.py): Consumesvs:security:triagestream, loads advisory + candidate apps, callsTriageAgent.decide_advisory(), creates security ticket, fires WebSocket event- Pre-filter updated (
app/workers/handlers/prefilter_handlers.py): Survivors now published tovs:security:triagewith full candidate match data;_prepare_candidate_matches()helper builds structured context for triage agent create_ticket()function added toapp/services/ticket_service.py: Shared service for security + vendor ticket creation; assigns ticket number, createsTicketApplicationrows, 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_matchpublished on critical severity tickets; KEV advisories getseverity=highevent, othersseverity=medium vs:security:triagestream 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
What was fixed:
Five production bugs found during first live KEV + OSV run with Maven inventory:
-
publish_ui_eventbool/Redis crash (backend/app/core/redis.py): Pythonboolis a subclass ofintsoisinstance(True, int)is True — raw booleans passed through toredis.xaddwhich rejects them. Fixed by checkingisinstance(v, bool)before theintcheck, ensuring booleans are JSON-encoded to"true"/"false". This was causing allprocess_security_advisorytriage jobs to fail with "Invalid input of type: 'bool'" whenon_kevwas included in the UI event payload. -
Pre-filter
uq_advisory_component_matchunique 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 deduplicatingmatchesbycomponent_idbefore DB writes, merging the more permissive result (bypass_reason wins, then layer2). -
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/...") inseverity[].scoreinstead of a numeric score.float("CVSS:3.1/...")raisedValueError, silently failing normalization for all GHSA advisories including Log4Shell (GHSA-jfh8-c2jp-5v3q, CVSS 10.0). Fixed by tryingfloat()and falling back to vector-string extraction when it fails. -
KEV cursor blocking all subsequent runs (
backend/app/security/adapters/kev_adapter.py): Cursor was set tomax(dateAdded)across all 1602 entries. On next run, all entries haddateAdded ≤ cursorand were filtered, returning 0 new entries. Fixed by removing cursor-based filtering from the adapter; deduplication is handled by the DB-level pre-filter inhandle_fetch_source. -
normalize_advisoryexcept block missingawait db.commit()(backend/app/workers/handlers/security_handlers.py): Thewrite_audit_logcall 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 addingawait 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-watch → main
What was built:
GET /api/security-sources/{id}/run-history(security_sources.py): queriesaudit_logforfetch_completed/fetch_failedentries 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_startedemitted at job start;security.source.run_completeemitted 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 aliveStatusmap; each source row shows "Running..." (pulsing spinner), "Done" (green), or "Failed" (red) in real time. On completion, sources query auto-invalidated. security.source.run_startedregistered inSecurityEventToastsSECURITY_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-watch → main
What was changed:
nvd_adapter.py: Added targeted fetch mode. Whenextra_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 ifcve_idsis not injected. Added_fetch_by_ids()and_fetch_single_cve()helpers.security_handlers.py: Before calling NVD'sfetch(), the handler now queriessecurity_advisoriesfor advisories with aCVE-alias but no CVSS score (up to 500), deduplicates, and injects them asextra_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-watch → main
What was built:
- KEV preview endpoint (
backend/app/api/security_sources.py):GET /last-run-previewnow branches onadapter_name. For KEV: returnsnew_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), andentries(catalog entries downloaded this run). For OSV/NVD: existing packages-sent + all-time advisories behavior, plusadapterfield 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):LastRunPreviewModaldetectsdata.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):lastRunPreviewreturn 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_atset (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-watch → main
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-watch → main
What was fixed:
- KEV/NVD adapter registration (
backend/app/services/security_source_service.py):AVAILABLE_ADAPTERSwas hardcoded to OSV-only from Phase S3. Addedkevandnvdentries — 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-watch → main
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-watch → main
What was fixed/improved:
- Preview "0 advisories" bug (
backend/app/api/security_sources.py):last-run-previewendpoint now queriessecurity_advisoriestable 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_advisoriesnow reflects the DB count;advisories_receivedreturns the 200 most recent from the DB.packages_sentstill comes from Redis (run-specific). - Advisory lifecycle Mermaid diagram (
frontend/src/app/docs/page.tsx): replaced the basicgraph TDwith aflowchart TDusingsubgraphblocks 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-watch → main
What was built:
- Run preview snapshot (
backend/app/workers/handlers/security_handlers.py): after each OSV fetch, worker saves a JSON snapshot to Redis keyvs: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_atis set).
Next: Merge feature/security-watch → main
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-watch → main
What was built:
- Backend flag endpoint (
backend/app/api/settings.py):GET /api/settings/featuresreturns{"security_watch": bool}derived fromsystem_settingskeysecurity_watch.enabled(defaults totrueif key absent).require_security_watch_enabledFastAPI 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 carryrequire_security_watch_enabledas a router-level dependency — all endpoints return 403 when flag is off. - Scheduler gating (
backend/app/scheduler/main.py):poll_security_sourcesjob is only registered at startup whensecurity_watch.enabled=true. No polling, no background work when off. - React context (
frontend/src/contexts/feature-flags-context.tsx):FeatureFlagsProviderfetches/settings/featureson mount;useFeatureFlags()hook exposes flags app-wide. Provider wraps entire app inproviders.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-watch → main
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-watch → main
What was fixed/improved:
- Scheduler
NameErrorfix (backend/app/scheduler/jobs.py):timedeltawas used inside_should_run_nowbut only imported via a dead local-import path. Fixed by addingfrom datetime import datetime, timedelta, timezoneat module level. Without this fix thepoll_security_sourcesjob failed on every run withNameError: 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; addedADAPTER_INFOper-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
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 = 500—handle_fetch_sourcecaps advisories published per job; audit log recordsadvisories_fetchedvsadvisories_publishedandbacklog_truncatedflag; 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) andRetry-Afterheader support for 429 responses; reduced default lookback from 30 days to 7 days; addedtest_connection()method - WS event throttling (
security_handlers.py): Redis keyvs:throttle:ws:criticalwith 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.pyNEW):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.pyNEW):GET /api/metrics/security— returns all counters +as_oftimestamp; registered inapp/main.py - Metrics incremented:
advisories_ingestedinhandle_normalize_advisory,tickets_createdin_create_security_ticket,auto_resolvedinhandle_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
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 liftsuseEventsWsso a single WebSocket connection is shared across the whole app; exportsEventsProvideranduseEvents()hooksrc/components/shared/security-event-toasts.tsx(NEW) —SecurityEventToastscomponent: watches for new security-typed events via the shared context; renders persistent banners (top of viewport) forsecurity.advisory.critical_matchandsecurity.source.run_failed; renders auto-dismiss toasts (bottom-right, 6s) forsecurity.ticket.auto_resolvedandsecurity.source.run_complete; skips events that were already in state on mount (no replay on page load)src/components/providers.tsx(MODIFIED) — wraps app inEventsProvider, mountsSecurityEventToastsat root so events fire on any pagesrc/components/layout/header.tsx(MODIFIED) — usesuseEvents()from context instead of callinguseEventsWs()directly; eliminates duplicate WebSocket connectionsrc/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.currentref 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
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/ApplicationUpdategainsecurity_jira_project_key,security_default_assignee,suppress_security_below;RuleBase/RuleUpdategainkind: RuleKind;TicketReadgainskind,security_advisory_id(optionalsource_email_id);TicketJiraLinkReadgainslink_role; newSecurityAdvisoryReadschema - Tickets kind filter (
app/api/tickets.py+app/services/ticket_service.py):GET /api/tickets?kind[]=security_advisory— multi-valuekindquery param filters byTicket.kind GET /api/security-advisories/{id}(app/api/security_advisories.py+ registered inapp/main.py): fetch advisory by UUID, returnsSecurityAdvisoryRead- Frontend types (
src/lib/types/index.ts): updatedTicket,TicketJiraLink,Rule,Application; addedApplicationComponent,InventoryStatus,ManifestDiffEntry,ManifestDiff,SecuritySource,SecurityAdvisory - Frontend API layer (
src/lib/api/client.ts+src/lib/api/index.ts):apiFetchskipsContent-TypeforFormData(browser sets multipart boundary); addedinventoryApi,securitySourcesApi,securityAdvisoryApi;ticketsApi.listacceptskind[] - Hooks (
src/hooks/index.ts): addeduseInventory,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;KindBadgecolumn in table - Ticket detail (
src/app/tickets/[id]/page.tsx): Advisory card in right column whensecurity_advisory_idset;LinkRoleLabelunder 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:
apiFetchskipsContent-TypeforFormData— browser must set multipart boundary or server rejects upload- Advisory card and Exposure panel render conditionally — no impact on Vendor Watch UI path
link_rolebadge shown under each Jira link in ticket detail: "Per-app issue" or "Security team issue"suppress_security_belowuses 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)
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, marksadvisory_component_matches.became_irrelevant_atwhen the app is no longer affected, auto-resolves the ticket viatransition_system()when no app on the ticket still matcheshandle_check_auto_resolveadded tosecurity_handlers.py— registered onvs:security:enrichmentstream; 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 appPOST /api/applications/{id}/inventory/uploadupdated to publishcheck_auto_resolvejob tovs:security:enrichmentafter 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_resolvecommits 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_atset on allAdvisoryComponentMatchrows for the app when it's no longer affected — provides an audit trail of when the patch took effect
Next phase: S9 — UI
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()andbuild_wiki_security_description()— twolink_rolevariants (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 onlink_role;cisa-kevlabel whenon_kev=True; advisory ID in summary;[TEST]prefix support; duedate fromticket.effective_datehandle_create_jira_issueextended (app/workers/handlers/jira_handlers.py): detectsticket.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 fromadvisory_component_matches; handles bothlink_role='app'andlink_role='security_team'; 4xx → orphan immediately, 5xx → re-raise for worker retry; graceful orphan when security-team Jira disabled in settingshandle_create_jira_issues(new handler oncreate_jira_issuesjob type): fan-out entry point published by security worker after ticket creation; createssecurity_teamlink row ifsecurity_watch.security_team_jira.enabled=true; processes all pendingTicketJiraLinkrows; firesnotify_ticket_createdonce all links have keyscreate_ticketservice fixed (app/services/ticket_service.py): corrected ORM field names (kindnotticket_kind,security_advisory_idnotsource_reference); fixedTicketApplicationcreation (composite PK — noidfield,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-appTicketJiraLinkrows before publishingcreate_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_ticketORM 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 onlysecurity_teamlink 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_matchesrow → falls back to first affected package from advisoryaffected[]— no hard error security_teamlink with disabled config →sync_status=orphanwith clear message, no exception — does not block per-app links- Existing
bulk_sync_open_ticketssync path handles security advisory Jira links automatically — no changes to sync code