STYLiTE Orbit Monitor (opm) — distributed network port scanning + monitoring. FastAPI + SQLAlchemy 2.0 async + Alembic + MariaDB 11 · React 19 + TanStack Router/Query + Tailwind v4 · Python scanner agents (masscan/nmap/NSE/nuclei/GVM) · Docker Compose.
Domain encyclopedia (GVM library/mirror, nuclei, severity rules, alert-state terminology): @AGENTS.md Rules: Design System · Workflow
- Scanners discover and submit facts. Backend services decide what facts mean (alerts, policy, scheduling). Frontend is an operator console. Never generate alerts scanner-side; never put business policy in the scanner.
- Dev stack = 4 containers (
opm-db,opm-backend:8000,opm-frontend:5173,opm-scanner). Backend and frontend hot-reload via bind mounts. The scanner does NOT hot-reload — after editingscanner/src, rundocker compose -f compose-dev.yml restart scanner. - Migrations apply on backend startup (entrypoint).
create_all()is only a fallback for fresh installs. - CI runs nothing on normal pushes. The tag workflow runs only frontend typecheck + Docker builds. Local checks are the ONLY quality gate. Never think "CI will catch it."
- Credentials:
admin@example.com/admin. DB from host:docker exec -it opm-db mariadb -uopm -popmpassword opm.
Use just (see justfile):
just dev-up / dev-down / dev-logs # dev stack (compose-dev.yml)
just check # backend-check + frontend-check
just backend-check # ruff + mypy + pytest (runs locally via uv)
just frontend-check # lint + typecheck + test
just migrate "description" # alembic autogenerate (inside opm-backend)
just gvm-up # optional Greenbone stack (compose-gvm.yml)
just release patch|minor|major # bump VERSION, cut CHANGELOG, tag, push (interactive!)Raw equivalents when just is unavailable:
cd backend && uv run --extra dev mypy src/ && uv run ruff check src/ && uv run --extra dev pytest
cd frontend && npm run typecheck && npm run lint && npm run test
cd scanner && uv run mypy src/ && uv run ruff check src/ && uv run --extra dev pytestNever npx tsc — it fails. Use npm run typecheck.
- Vertical slices. One feature = ONE commit spanning model + migration + schema + service + router + frontend + tests + CHANGELOG. Never split a feature into a backend commit and a frontend commit. (This overrides any general preference for split commits.)
- CHANGELOG.md is mandatory in every feat/fix/refactor/perf/security commit. Entry goes under
## [Unreleased]in the right Keep-a-Changelog category, prefixed**Backend**:/**Frontend**:/**Scanner**:/**Admin**:/**Dependencies**:. House style is a full descriptive paragraph naming endpoints, columns, and migration numbers — not a one-liner. Read the existing[Unreleased]block first and match it. - Commits:
<type>: <description>(feat, fix, refactor, docs, test, chore, perf, ci, security). Imperative, no trailing period, no co-authored-by. Body explains why, scoping decisions, and how it was tested. Fix commits name the exact failure mechanism. - Planning lives in Markdown, not an issue tracker:
USERSTORYS.md(master stories) →PLANNED-FEATURES.md(**Implementing:** yes) → build → move story toCOMPLETED-FEATURES.mdwith an**Implemented:**paragraph → tick the box inTODO.md. When you complete a planned feature, update these files in the same commit. - Release:
just release patch— requires a clean tree and a TTY (interactive y/N). It cuts[Unreleased]into a dated section, bumpsVERSION, commitschore: bump version to X, tagsX.Y.Z(novprefix), pushes. The tag triggers multi-arch builds of 3 images to Docker Hub + GHCR. Never run it with uncommitted changes; never tag by hand. - Knowledge graph first: use the
code-review-graphMCP tools (semantic_search_nodes,query_graph,get_impact_radius,detect_changes) before Grep/Glob/Read for exploration and review. Hooks keep the graph fresh after every edit.
Layout: core/ (config, deps, security, permissions) · models/ (one file per table) · schemas/ (Pydantic v2) · routers/ (HTTP only) · services/ (business logic, largest layer) · repositories/ (partial BaseRepository[T]) · src/migrations/versions/.
- The router owns the transaction.
await db.commit()appears in the router after the service call — never in a service. Servicesflush()+refresh()and returnModel | None | list | dict. Services never raiseHTTPException; the router translatesNone→raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, ...)(use rawHTTPException, notcore/exceptions.py). - Services are free
async deffunctions with signatureasync def x(db: AsyncSession, ...)— not classes (except thinBaseRepositorysubclasses that already exist). - Schemas:
XCreateRequest/XUpdateRequest/XResponse/XListResponse(list wrapper with a typed field, e.g.networks: list[NetworkResponse]). Responses usemodel_config = {"from_attributes": True}and are built withModel.model_validate(orm_obj). Field names must match model ↔ schema ↔ service kwargs ↔ response exactly. - Nullable update fields need
clear_*flags —Nonecannot mean both "don't touch" and "set NULL". Wire all four places: schema field, service param, serviceclear_x: boolparam, and router derivationclear_x="x" in request.model_fields_set. - DI aliases from
core/deps.py:CurrentUser,AdminUser,OperatorUser,AnalystUser,CurrentScanner,DbSession,Pagination. Fine-grained checks viarequire_permission(Permission.X)fromcore/permissions.py. Roles: admin > operator > analyst > viewer. - Enums inherit
(str, Enum)and every enum column setsvalues_callable=lambda x: [e.value for e in x]— otherwise enum NAMES land in the DB. - Datetimes are naive UTC in the DB (
DateTimewithouttimezone=True, server defaultfunc.utc_timestamp()). Writedatetime.now(timezone.utc); when computing with values read back, re-attach with.replace(tzinfo=timezone.utc). - New model → register it in
models/__init__.py. Otherwise Alembic autogenerate misses the table and string-basedrelationship("X")targets don't resolve. - Migrations: create with
just migrate "desc"(runs inside the container). Setdown_revisionto the actual head fromalembic heads— the numbering is NOT linear (16a,025_add_2fa_totpexist). Add idempotency guards (_column_exists()/_table_exists()via information_schema) like migration 013 does, because dev DBs may have columns pre-created bycreate_all. Review the autogenerated file before accepting it. - Relationships: type hints under
if TYPE_CHECKING:with string targets.selectinload()is applied per query where the relationship is accessed —BaseRepository.get_by_iddoes NOT eager-load; touching a lazy relationship after the session ends raises MissingGreenlet. - FK columns need explicit
index=Trueinmapped_column(ForeignKey(...), index=True). - JWT uses PyJWT:
import jwt,from jwt.exceptions import PyJWTError. (AGENTS.md still says python-jose — that is stale; python-jose is not installed.) - mypy strict + pydantic plugin: type params always (
dict[str, Any]), all signatures annotated, imports at top level (function-level imports only for optional deps). ruff: E/F/I/W, line length 100. - Tests (
backend/tests/, flat, ~440 tests): SQLite in-memory (aiosqlite),asyncio_mode="auto"(no decorator needed). Use existing fixtures:db_session,client,admin_headers/viewer_headers/scanner_headers, factories (UserFactory,NetworkFactory,ScannerFactory). Service tests call service functions withdb_session; router tests go throughclient. New MariaDB-only server defaults will break the suite unless the conftest UDF hack covers them (utc_timestampis already registered). - New scanner types register in
core/scanner_types.pyviaregister_scanner_type(...)— never hardcode type lists in schemas.
- Canonical API layer:
src/lib/api.ts(fetchApi/postApi/putApi/patchApi/deleteApi) + hand-written types insrc/lib/types.tsmirroring the Pydantic schemas 1:1.src/lib/api-client.ts(openapi-fetch) andapi-types.tsare dormant scaffolding with zero real importers — never use or extend them (onlyextractErrorMessageis live). Rawfetch()only for non-JSON payloads (FormData/Blob/XML), followingfeatures/gvm-library/api.ts. - Routing: TanStack Router file-based in
src/routes/(there is nosrc/pages/).routeTree.gen.tsis generated — never edit it. Filename grammar:_authenticated= pathless layout/auth gate,$param= dynamic segment,users_.$userId.tsx= trailing underscore un-nests from theuserslayout (needed when the parent has no<Outlet/>),index.tsx= index route. Routes stay thin: hook +LoadingState/ErrorState+ feature components. - Features in
src/features/<name>/withcomponents/,hooks/, optionalschemas/. Data hooks always infeatures/<x>/hooks/useX.ts, never inline in routes. - Query conventions: keys
["resource", id, "sub"], list keys end with the filter object;enabled: id > 0guards; URLSearchParams builder functions for query strings; mutations bundled inuseXMutations()where eachonSuccesscallsqc.invalidateQueries({ queryKey: ["resource"] })(root key). Forgetting invalidation = stale UI. - Forms: react-hook-form +
zodResolver; import Zod asimport * as z from "zod/v4". Nullable numbers use the house idiomz.preprocess((v) => v === "" || v == null ? undefined : Number(v), z.number()...optional()). Schemas live next to the form (*.schemas.ts/networkFormSchema.tsstyle). - Design system: tokens live in
src/styles/globals.css(@themeblock). Weights:font-emphasis(510) andfont-strong(590) — neverfont-bold/700. Semantic classes (text-muted-foreground,bg-card,border-border), merge withcn(), variants withcvafollowingcomponents/ui/button.tsx. Severity/status badges copy the existingSeverityBadgepattern (rawred/orange/yellow/blue-500with/10bg +/20border) — do not invent a third color scheme. - TypeScript:
verbatimModuleSyntaxis on — type-only imports MUST useimport type. Named exports only (no default exports).@/*alias for all cross-module imports. - Tests: vitest + Testing Library, co-located
X.test.tsx. Hook tests: freshQueryClientwrapper (retry: false, gcTime: 0),useAuthStore.setState({ token }),vi.stubGlobal("fetch", mockFetch), assert URLs viamockFetch.mock.calls. - Any UI change requires browser verification at http://localhost:5173 (login
admin@example.com/admin) before it counts as done. Screenshot when helpful.
- DTOs are frozen dataclasses in
models.py— not Pydantic. Poll loop inmain.py; phase pipeline inorchestration.py; all HTTP inclient.py. - Three extension patterns — pick the right one:
- Port scanner (CIDR in, ports out): implement
ScannerProtocol(scanners/base.py), register inscanners/__init__.py. - Post-discovery vulnerability phase (targets = already-discovered
IP:PORT): nuclei style — module functions + aphase.toolbranch in_run_vulnerability_phase+ an_ensure_*_phaseinjector inorchestration.py. - Entirely different job kind: greenbone style — own
process_*_jobpath outside the pipeline.
- Port scanner (CIDR in, ports out): implement
- Failure isolation is a hard invariant.
port_scan,host_discovery, andnsephases MAY fail the scan. Nuclei, hostname enrichment, progress reporting, and log submission are best-effort: wrap everything, log a warning, return empty — a broken post-phase must never fail a successful port scan. - Every subprocess argument derived from job input goes through
sanitize_cidr/sanitize_port_spec(utils.py) first. No exceptions — these are the command-injection barrier. - Background threads (
LogStreamer,ProgressReporter,ScanCancellationWatcher,ProcessTimeoutWatcher) share one httpx client serialized byclient._http_lock— new background HTTP calls must go throughclientmethods. Stop + join watchers infinally. - Timeout semantics differ: masscan raises
TimeoutError; nuclei returnstimed_out=Truewith empty results. Match the pattern of the phase type you're extending. - GVM:
scanner/src/scanners/greenbone.pyandgreenbone_metadata.pycarrySPDX: BUSL-1.1 OR GPL-3.0-or-laterheaders — keep them when editing. GVM configs/port lists are always referenced by name, never UUID. Greenbone jobs submit vulnerabilities BEFORE open_ports (open_ports flips the scan to COMPLETED; the vuln endpoint requires RUNNING). - Tests mock all subprocess/network (
_FakeProcesspattern intest_nuclei.py,_FakeGmpintest_greenbone.py). Never spawn real masscan/nmap/nuclei in tests.
| # | Mistake (named) | Rule that prevents it |
|---|---|---|
| 1 | The Service Commit — db.commit() inside a service |
Commit only in the router, after the service call. Services flush + refresh. |
| 2 | The Ghost API Layer — importing client from api-client.ts / api-types.ts |
fetchApi + lib/types.ts is the only live API layer. |
| 3 | The Gen-File Edit — hand-editing routeTree.gen.ts |
New route = new file in src/routes/. The tree regenerates itself. |
| 4 | The jose Import — following AGENTS.md's python-jose note | PyJWT: import jwt, from jwt.exceptions import PyJWTError. |
| 5 | The Silent NULL — nullable update field without clear_* flag |
Wire schema + service param + clear_x param + model_fields_set derivation. |
| 6 | The Enum Name Leak — enum column without values_callable |
Always values_callable=lambda x: [e.value for e in x]. |
| 7 | The Orphan Model — new model missing from models/__init__.py |
Register every model there; Alembic and relationship() strings depend on it. |
| 8 | The Guessed down_revision — assuming migration numbering is linear | Run alembic heads inside the container; chain to the real head; add idempotency guards. |
| 9 | The Eager Assumption — touching a relationship after the session (MissingGreenlet) | Add selectinload() to the specific query; nothing eager-loads by default. |
| 10 | The Skipped Changelog — committing without a CHANGELOG entry | Every feat/fix/refactor/security commit edits [Unreleased] with a **Layer**: paragraph. |
| 11 | The Layer Split — separate backend and frontend commits for one feature | Vertical slice: one commit across all layers + migration + tests + CHANGELOG. |
| 12 | The npx tsc Reflex | npm run typecheck / just frontend-typecheck. |
| 13 | The font-bold Reflex (or hardcoded hex colors) | font-emphasis/font-strong; tokens from globals.css; severity badges copy SeverityBadge. |
| 14 | The Fatal Post-Phase — letting nuclei/enrichment errors fail a scan | Post-phases are best-effort: catch everything, warn, return empty. |
| 15 | The Unsanitized Subprocess — job input straight into a command line | sanitize_cidr / sanitize_port_spec before every subprocess. |
| 16 | The Stale Scanner — expecting scanner hot-reload | docker compose -f compose-dev.yml restart scanner after scanner edits. |
| 17 | The Missing import type — plain import of a type under verbatimModuleSyntax |
import type { X } from "@/lib/types". |
| 18 | The Trust-the-CI Fallacy — "the pipeline will catch it" | CI checks nothing on push and only frontend typecheck on tags. just check locally is the gate. |
| 19 | The Naked dict — dict/list without type params under mypy strict |
dict[str, Any], list[Network] — always parameterized. |
| 20 | The UUID Config — passing GVM config/port-list UUIDs | GVM library entries and network config reference names, never UUIDs. |
| 21 | The Naive-Aware Mix — comparing DB datetimes with aware datetimes | DB is naive UTC; .replace(tzinfo=timezone.utc) on read, datetime.now(timezone.utc) on write. |
| 22 | The Default Export — export default in frontend code |
Named exports only (only routeTree.gen.ts is exempt). |
Backend endpoint is done when:
- Schemas named
XCreateRequest/XUpdateRequest/XResponse(/XListResponse),response_model=and explicitstatus.HTTP_*codes set - Service returns value/None; router raises 404/409;
db.commit()in router - Nested resources validated against parent (
rule.network_id == network_id) - Tests cover happy path + 401/403 + 404 in
tests/test_<domain>.pyusing existing fixtures -
just backend-checkgreen; CHANGELOG entry written
Migration is done when:
- Generated via
just migrate "desc"and hand-reviewed (autogenerate output is a draft, not truth) -
down_revisionequals currentalembic headsoutput - Idempotency guards for added columns/tables (information_schema checks)
- Model registered in
models/__init__.py; backend container restarts cleanly (checkjust dev-logs-backend) - Backend test suite still passes on SQLite
Frontend feature is done when:
- Types in
lib/types.tsmirror the Pydantic response exactly - Hook in
features/<x>/hooks/with conventional query key,enabledguard, mutations invalidating the root key - Route thin; loading/error via
LoadingState/ErrorState; design tokens only (nofont-bold, no hex) -
import typeused for types; named exports only -
just frontend-checkgreen; verified in the browser at :5173; CHANGELOG entry written
Scanner change is done when:
- Correct extension pattern chosen (§5); failure-isolation invariant respected
- Sanitizers applied before subprocess; watchers stopped in
finally - Tests use fake subprocess/GMP objects — nothing real spawned
-
cd scanner && uv run mypy src/ && uv run ruff check src/green; container restarted andjust dev-logs-scannerclean; SPDX headers intact on GVM files
Bug fix is done when:
- Root cause identified and named in the commit body (mechanism, not symptom)
- A regression test exists that fails without the fix
- CHANGELOG
Fixedentry describes the mechanism - Affected stack's checks green
Commit is done when:
-
<type>: <imperative description>without trailing period, no co-author line - Body explains why + scoping decisions + how verified
- CHANGELOG updated in the same commit (unless pure chore/docs)
- All checks for touched stacks pass BEFORE committing
Release is done when:
- Tree clean,
[Unreleased]curated (entries in right categories, tightened) -
just release <type>run interactively; tag pushed; CI tag workflow green
- Docs vs. code conflict → code wins. Note the drift in your final report. Known stale claims: AGENTS.md's python-jose note (it's PyJWT), AGENTS.md's blanket "use selectinload" (it's per-query),
docs/development/architecture.mdsays React 18 (it's 19), contributing.md mentions bun (it's npm). - Two coexisting patterns (BaseRepository vs. direct
select();core/exceptions.pyvs. rawHTTPException): match the file you're editing; for new files use the dominant pattern (direct select, raw HTTPException,fetchApi). Never refactor one into the other as a side effect. - Ask before acting (stop, present a concrete recommendation) when: adding a dependency, adding a table when a column was asked for, changing API response shapes consumed by the scanner protocol, changing alert semantics/thresholds, anything touching auth flows, naming/UX decisions with no precedent in the codebase.
- Never without explicit instruction: destructive DB ops (dropping columns/tables outside a reviewed migration), deleting/reordering existing migrations, force-push, touching
compose.yml/prod env files, runningrelease.sh. - Security finding: stop feature work, report it, grep for sibling instances, fix only critical ones immediately, never commit secrets, flag any exposed secret for rotation. Security fixes get their own
security:-type commit with detailed mechanism. - Stuck rule: the same check fails after 2 genuinely different fix attempts → stop, write down findings and hypotheses, ask. Don't loop.
- Scope guard: if the fix balloons past the named request (schema redesign, cross-cutting rename), stop and re-plan per
.claude/rules/workflow.md.
Knowledge graph over the codebase (auto-updated by hooks). Use BEFORE file scanning:
semantic_search_nodes / query_graph (callers_of, callees_of, tests_for) for exploration, get_impact_radius + get_affected_flows for blast radius, detect_changes + get_review_context for review. Fall back to Grep/Glob/Read only when the graph doesn't cover it.