Skip to content

Project Secruna — Codebase Complexity Audit

Date: 2026-05-03 Author: Claude (research-only audit, no code changes) Scope: packages/rekognise-core/src/rekognise/**, apps/cp-api/src/cp_api/** Tools: Glob, Grep, Read, radon cc/raw, AST length analysis.


1. Executive summary

For a three-month-old codebase shipping nine connectors, this is healthier than it has any right to be: average cyclomatic complexity across 420 analysed blocks is A (3.25), modules are small (median connector file ~110 LOC), and tests exist for every connector's normalize path. The architecture is "duck-typed Connector with async def discover()" — pragmatic, not academic, exactly what a solo founder should ship.

That said, four hairballs are starting to form:

  1. shared/vendor_inference.py is a 264-LOC, CC=85 god-function that already covers nine sources and grows linearly with each new vendor.
  2. apps/cp-api/src/cp_api/routers/connections.py is 1,136 LOC of OAuth-flow copy-paste — eight near-identical KV-write blocks, four near-identical start/callback pairs.
  3. No connector base / no shared HTTP client — every connector hand-rolls pagination, 401/403 silent-skip, and (for Azure/M365/Azure-passive) MSAL token exchange. The next connector will replicate this for the fourth time.
  4. canonical_id() is copy-pasted into 12 normalize files, all identical. DiscoveredArtifact lives at data_plane/azure_connector/normalize.py and is imported from there by every other connector — accidental coupling.
Top wins (do these) Top traps (don't do these)
Move DiscoveredArtifact + canonical_id to data_plane/types.py (-200 LOC, 0 risk) Don't introduce an ABC Connector with abstract methods — current Protocol-by-convention is fine
Extract BaseDataPlaneClient wrapping httpx with paginate / 401-skip / token header (kills ~150 LOC across 7 connectors) Don't unify the 9 token-fetch flows under one factory — three auth shapes (MSAL refresh, OAuth refresh, OAuth M2M, IAM AssumeRole, App PEM, raw API key) is honest variance
Extract _kv_set_secret(connection_id, value, name_suffix) helper (kills 8 copies) Don't refactor vendor_inference into a registry yet — the dispatch is ugly but readable; refactor when adding source #11
Convert orchestrator's if/elif provider chain (10 branches) to a registry dict Don't merge active + passive Azure into one class — they fan out very differently after token-fetch
Add a DiscoveryAuditLogger and call it from the orchestrator (currently zero audit trail for runs) Don't try to share normalize files between active + passive twins — the field shapes legitimately diverge

2. Findings by section

A. Connector architecture

A.1 No abstract base / Protocol exists

grep -rn "Protocol\|ABC\|@abstractmethod" data_plane/ returns zero hits. The Connector "interface" is a duck-typed convention: every class implements async def discover(self) -> AsyncIterator[DiscoveredArtifact] and the orchestrator (runner.py:223) iterates it. Adding a Protocol is cheap (one declaration, no code removed) and worth doing for typing + IDE help. Do not introduce an ABC with default implementations — the constructors take wildly different shapes (refresh token, IAM Role + external_id, admin key, App PEM key, OAuth M2M client_id/secret, workspace URL + PAT) and forcing inheritance is premature.

A.2 Active vs passive twin duplication

Concrete duplication numbers, AST-extracted:

Pair Active+norm Passive+norm Shared logic Diverging logic
Azure / passive-Azure 113+73 138+89 _acquire_access_token (~17 lines) identical except for _sync_acquire vs _sync rename, MSAL config, error-check Resource Graph KQL vs ARM Activity Log REST, different scopes, different normalization
AWS / passive-AWS 129+83 159+132 _assume_role (10 lines) identical except RoleSessionName value, region iter, boto3 client w/ creds Bedrock+SageMaker vs CloudTrail, lookback window, error-tolerance posture
GCP / passive-GCP 146+77 143+160 _exchange_refresh_token (~13 lines) byte-for-byte identical, page-token loop Different APIs (aiplatform/notebooks vs cloud logging), filter syntax

Aggregate copy-paste budget: - _acquire_access_token (Azure flavour): 3 copies (active, passive, M365), ~51 lines - _exchange_refresh_token (GCP OAuth): 2 byte-identical copies, ~26 lines - _assume_role (AWS STS): 2 copies, ~20 lines - Page-token while-loop: ~9 occurrences, ~108 lines - 401/403 silent-skip block: ~12 occurrences, ~72 lines

Total mechanical duplication: ~280 LOC of 3,429 connector LOC = ~8%. Not catastrophic, but precisely the "every connector reinvents this" that a BaseDataPlaneClient obliterates.

A.3 Common patterns I'd expect DRY'd, by status

Pattern Status Where
OAuth refresh-token exchange (Microsoft) 3 copies, near-identical azure_connector/connector.py:53, m365_connector/connector.py:40, passive_collector/azure_activity_log.py:55
OAuth refresh-token exchange (Google) 2 byte-identical copies gcp_connector/connector.py:52, passive_collector/gcp_cloud_logging.py:63
AssumeRole (AWS) 2 near-identical copies aws_connector/connector.py:40, passive_collector/aws_cloudtrail.py:67
Page-token while-loop 9 hand-rolled copies gcp ×2, github ×4, openai, anthropic, databricks
401/403 silent-skip 12 hand-rolled copies every connector that hits HTTP
Retry-on-transient-error NOT IMPLEMENTED no tenacity; one 429 from OpenAI/GitHub kills the run
Audit-log entry on yield NOT IMPLEMENTED grep AuditLogEntry data_plane/ returns 0; only API routers write entries
Structured logging with tenant context mixed: connectors use stdlib logging, API uses structlog, no contextvars for tenant_id
Secret retrieval from KV orchestrator does it once correctly; connections router does it 8 times inline runner.py:89 (good), routers/connections.py (bad: 8 copies)

A.4 What is HARD to add today?

These changes would touch 9+ files because there's no seam:

  • Per-connector rate limit / retry on 429-503 — every httpx.AsyncClient and boto3.client is wrapped separately. 9 file edits.
  • Distributed tracing — adding spans around client.get() calls means editing every connector.
  • Tenant context in connector logstenant_id is only known to the orchestrator, never passed to connectors.
  • Capping max artifacts per run — orchestrator counts but doesn't enforce; trivial to wrap the iterator centrally though.
  • Per-connector dry-run mode — same fan-out.

A BaseDataPlaneClient wrapping httpx.AsyncClient with auth header injection, page-token paginator, 401/403 silent-skip, optional retry, and structlog binding for (tenant_id, source, run_id) — collapses all of this into one place.

B. Test parity across connectors

B.1 Per-connector test inventory

Connector Src LOC Test LOC Normalize tests .discover() flow test
azure 203 71 yes no
aws 229 56 yes no
gcp 242 58 yes no
m365 129 44 yes no
openai 154 38 yes no
anthropic 139 33 yes no
databricks 220 202 yes partial (oauth only)
github 660 322 yes no
passive-azure 138 148 yes yes — gold standard
passive-aws 159 97 yes no
passive-gcp 143 131 yes no

Pattern landed by the agent that built passive-azure (tests/unit/data_plane/passive_collector/test_collector.py): httpx.MockTransport injected via fake AsyncClient, monkeypatching _acquire_access_token. Portable to every other HTTP-based connector (8 of 9). Only aws_connector (boto3) needs different tooling — moto or stubbed boto factories.

Risk: of 9 production connectors, 8 have zero end-to-end test of the discover flow. A typo in a page-token field name would silently truncate pagination in production until a customer notices.

C. Cross-cutting concerns

C.1 shared/vendor_inference.py (264 LOC, CC=85)

The highest-complexity function in the codebase: infer_vendor is a flat dispatch covering 10 sources × N kinds × N model-id substring tests, already touched 5 times judging by inline "Plan 22d / Plan 24c" comments.

CC=85 looks scary but it's straight-line dispatch, not deep nesting — "long" rather than "tangled". The current structure has one virtue: one screen, easy to verify.

Do not prematurely refactor. When source #11 lands, refactor to a registry: INFERRERS: dict[str, Callable] + @register("azure") decorators. Effort then: M (3-4h). Effort now: zero.

Risk if left alone: a contributor adds a branch in the wrong place. Mitigate by keeping test ratio high in tests/unit/shared/test_vendor_inference.py.

C.2 Audit log

Connectors emit zero audit-log entries. grep AuditLogEntry data_plane/ returns 0. The orchestrator creates a DiscoveryRun row but never an AuditLogEntry. Discovery run started/failed/silently-skipped/completed → no audit entry from any of these.

API routers hand-roll AuditLogEntry(...) in 11 places with no helper: every call site manually fills actor_user_id, actor_tenant_id, target_id, target_type, action, context. Action names (inventory.system.viewed, hitl.verdict.edited, admin.tenant.impersonated) are free-form strings — typos go uncaught.

Impact: EU AI Act compliance requires a complete audit trail of automated decisions. Discovery runs that classify a system as prohibited leave zero audit trace from the data plane — a real exposure for a compliance product.

Recommendation: add rekognise.shared.audit.service.AuditService.log(session, *, action: Literal[...], actor, target, context). Wire into runner.py at every state transition; backfill routers in a follow-up.

C.3 Error handling

Three styles coexist with no contract:

  1. Silent-skip (most cloud connectors): try/except Exception: logger.warning(...); return []. Examples: aws_connector/connector.py:65,87,111, passive_collector/aws_cloudtrail.py:106-133.
  2. Hard fail on auth (SaaS admin-key connectors): if response.status_code in (401, 403): raise PermissionError(...). Examples: openai_connector/connector.py:37, anthropic_connector/connector.py:39, databricks_connector/connector.py:47.
  3. Orchestrator catch-all: except Exception as e: errors.append(...) at the iterator boundary (runner.py:286).

Both connector styles are individually defensible (admin key revoked = real problem; partial cloud inventory = better than nothing) but no rule says which style applies where. New contributors will pick wrong.

Define a ConnectorError taxonomy in data_plane/types.py: PermanentAuthError → mark connection degraded; PartialReadError(scope=...) → record per-scope, run partial; TransientError → retry then promote. Effort S to define + adopt in 2 connectors as proof, M to fully roll out.

D. Wider codebase complexity hotspots

Ranked by radon cc × LOC × bug-likelihood.

# File / function LOC CC Why complex / risk Refactor sketch Effort
1 routers/connections.py 1136 get_connection_health D(21), gcp_callback C(12), github_callback C(11) 15 endpoints, 4 near-identical OAuth start/callback pairs, 8 inline KV writes, twin-creation duplicated. OAuth bug in callback #5 won't be fixed in #1-4. (a) Extract _persist_secret(...), (b) _create_passive_twin(...), © Split per-provider into cp/connections/<provider>_router.py M
2 routers/admin.py 948 impersonate C(17), list_audit_log C(13) 21 endpoints in one file; impersonation + audit + tenant CRUD coexist. Security review must re-read 948 lines. Pure file-split: admin/{tenants,impersonation,audit}_router.py, include_router under /admin S (~2h)
3 routers/inventory.py 880 get_system_detail D(29), export_system_annex_iv D(24) 7 endpoints, two are 200+ line functions mixing fetch + render. Annex-IV export is 208 lines (a compliance officer's PDF breaking late at night). Pull cp/inventory/system_detail_view.py (fetch) and reuse existing cp/export/annex_iv.py (router currently duplicates logic) M
4 shared/vendor_inference.py 264 F(85) Mega-dispatch across 10 sources. Registry + per-source files (see C.1) M — defer until next vendor
5 data_plane/orchestrator/runner.py 305 run D(28), 249-line method Loads connection + secret, dispatches via 122-line if/elif, iterates, UPSERTs, classifies, finalizes. Adding connector #11 means editing this method. (a) Connector registry, (b) _persist_artifact(), © move classification to async (Plan 03 already plans this) M
6 routers/hitl.py 600 edit_facts C(12) — 163 lines Audit-write + history + verdict-update inline; failure mid-flight leaves verdict inconsistent. Extract cp/hitl/edit_facts_service.py as one transactional function S
7 routers/connections.py::get_connection_health (978-1103) 126 D(21) One function probes all 10 providers via inline if/elif. Adding a provider requires editing this and runner.py. Add async def probe() to each connector; get_connection_health dispatches M (touches 9 connectors)
8 routers/connections.py::scope_diff + required_scopes_for 50 B(6) Each provider's required scopes is duplicated between connection-creation, scope-validation, and consent-URL building. One source of truth: cp/connections/<provider>/scopes.py exporting REQUIRED_SCOPES: frozenset[str] S
9 passive_collector/normalize_dns.py 136 two B(10) functions Two big normalizers handle multiple DNS event shapes. Extract parse_hostname(event, *, key_paths) -> str helper S
10 passive_collector/aws_cloudtrail.py::_scan_region 69 C(12) Triple-nested loop (event-source × page × event) with try/except inside. Page-token typo silently truncates. Generic _paginate_boto3(client.lookup_events, ...) helper S

Files >500 LOC: 5 total — routers/connections.py (1136), routers/admin.py (948), scripts/seed_demo.py (895, but it's a seed script — fine), routers/inventory.py (880), routers/hitl.py (600).

Files >1000 LOC: 1 — routers/connections.py (1136). This is the canonical splitter target.

Top-10 longest functions (AST-extracted, including blank/comment lines):

LOC File Function
249 data_plane/orchestrator/runner.py:57 DiscoveryOrchestrator.run
208 apps/cp-api/.../inventory.py:286 export_system_annex_iv
190 shared/vendor_inference.py:75 infer_vendor
169 apps/cp-api/.../inventory.py:551 get_system_detail
163 apps/cp-api/.../hitl.py:175 edit_facts
142 apps/cp-api/.../admin.py:758 impersonate
130 apps/cp-api/.../inventory.py:148 export_inventory_pdf
126 apps/cp-api/.../connections.py:978 get_connection_health
103 apps/cp-api/.../connections.py:652 gcp_callback
97 apps/cp-api/.../inventory.py:48 list_systems

The connector code itself does not appear in this list — every connector function is <70 LOC. The complexity has clustered in routers + orchestrator + vendor_inference, exactly where you'd predict for a shipped MVP.

E. Skill / library opportunities

E.1 Libraries to adopt

  1. tenacity for retries — adopt now. Zero retries means one transient 429 from OpenAI/GitHub kills a run. 5-line decorator on _get / _list_*, gated to httpx.HTTPStatusError with status_code in (429, 502, 503, 504). Effort S, payoff high.
  2. structlog.contextvars.bind_contextvarsadopt now in connectors. cp-api already uses structlog; data_plane uses stdlib. Bind (tenant_id, run_id, source) once at orchestrator entry, every log line inherits. Effort S, payoff huge for production debugging.
  3. pydantic for DiscoveredArtifactskip. The dataclass is fine; adding pydantic validates data you're feeding via your own normalize functions.
  4. aiobotocore for async AWS — defer. Current boto3 in asyncio.to_thread works.
  5. opentelemetry-api auto-instrumentation for httpxadopt later (Plan 03 territory).

E.2 Should we adopt an ABC Connector base class with default paginate, oauth_refresh?

No — but yes to a BaseDataPlaneClient. The constructors are too different to share a base ctor (refresh token vs IAM Role vs admin key vs App PEM vs OAuth M2M). But every connector that uses HTTP could share:

class BaseDataPlaneClient:
    def __init__(self, *, base_url, auth_header_factory: Callable[[], str], retry_policy=...): ...
    async def get(self, path, **params) -> dict: ...
    async def paginate(self, path, *, items_key: str, cursor_in: str, cursor_out: str) -> AsyncIterator[dict]: ...
    # 401/403 → raise PermissionError; 429/5xx → retry; otherwise return body.

This kills the 9 page-token loops and 12 silent-skip blocks. Connectors retain their bespoke discovery logic but get the HTTP plumbing for free.

E.3 Should passive collectors share a base class with active ones?

No. Passive twins differ from active in three meaningful ways: (a) lookback window, (b) silent-skip on AccessDenied is the expected path, not the exception, © de-dup-by-resource-id is a passive-specific concern. A shared interface (Connector Protocol) is enough; a shared base would force inappropriate uniformity.

E.4 Five highest-leverage shared modules

Pick these, in order:

  1. data_plane/types.py — owns DiscoveredArtifact, canonical_id(), the Connector Protocol, ConnectorError taxonomy. Replaces the 12 copy-pasted canonical_id and the awkward import-from-azure pattern. ~50 LOC removed once + everywhere imports normalize.
  2. data_plane/_http.py (BaseDataPlaneClient) — wraps httpx.AsyncClient with auth header factory, page-token paginator, retry, 401/403 silent-skip. Adopted by 7 connectors (azure, m365, gcp, openai, anthropic, databricks, github passive-azure, passive-gcp). ~280 LOC removed.
  3. shared/audit/service.py (AuditService.log) — single helper for both data plane (discovery run lifecycle) and API (existing 11 hand-rolled AuditLogEntry(...)). Wire Literal["discovery.run.started", ...] to catch action-name typos. +1 file, ~30 LOC; replaces ~150 LOC of inline construction.
  4. cp/connections/secrets.py (store_connection_secret) — kills the 8 KV-write copy-paste blocks in routers/connections.py. ~120 LOC removed from a 1136-LOC file.
  5. Connector registry in data_plane/orchestrator/registry.py — replaces the 122-line if/elif provider chain in runner.py:96-218 with a dict lookup + factory call. Adding connector #10 becomes a one-line registration, not a runner.py edit.

These five together eliminate ~600+ LOC of duplication and move the pain points to one-line registration / one-line wiring for future work.


3. Section F — Top 5 single-PR low-hanging fruit (ranked by leverage / risk)

Each <1 day, each removes ≥30% of code in affected files, each makes the next connector easier, each is mechanical:

  1. Move DiscoveredArtifact and canonical_id into data_plane/types.py. Affected: 13 normalize files + 1 dedup file + 1 test. Removes 12 copies of canonical_id (~5 lines × 12 = 60 lines) and the awkward "import from azure_connector" pattern. Risk: zero — pure rename and re-export. Effort: 30 min.

  2. Add tenacity retry to BaseDataPlaneClient (after step 1) on 429/5xx, max 3 attempts, exponential backoff. Affected: ~7 connectors get free retry. Removes manual try/except httpx.HTTPStatusError boilerplate where present (~30 lines). Risk: low — retries are idempotent for our list operations. Effort: 4 hours.

  3. Extract cp/connections/secrets.py::store_connection_secret(settings, connection_id, value, suffix) -> str. Affected: apps/cp-api/src/cp_api/routers/connections.py (8 inline copies). Removes ~80 LOC. Risk: zero — pure refactor of pure function. Effort: 1 hour.

  4. Replace orchestrator if/elif provider chain with a registry dict. Affected: data_plane/orchestrator/runner.py:96-218 (122 lines collapse to ~20). Risk: low — every branch becomes a small factory function with the same signature. Effort: 3 hours.

  5. Pass tenant_id, run_id, source into connectors via structlog.contextvars at orchestrator entry. Affected: orchestrator + every connector's logger. Removes ad-hoc extra={"region": ...} tagging where it exists, gives uniform field shape. Risk: zero. Effort: 2 hours.

Bonus 6th (slightly larger but worth flagging as next-up): Split routers/admin.py (948 LOC) into 3 files (tenants, impersonation, audit). Pure file-split, all imports stay the same, the FastAPI router can be assembled via include_router. ~2 hours.


4. Anti-patterns / things to NOT do

  1. Do NOT introduce an ABC Connector with abstract discover(). The Protocol-by-convention works, the existing async def discover() -> AsyncIterator[DiscoveredArtifact] is enough contract. Adding inheritance forces premature uniformity on constructors that legitimately differ.

  2. Do NOT collapse active + passive normalize files. They share the DiscoveredArtifact type but legitimately differ in field shapes (shadow, confidence, last_caller, event_source are passive-only; passive twins set different kind strings on purpose). Trying to share normalize code would force conditional logic that's worse than duplication.

  3. Do NOT refactor vendor_inference.py into a registry yet. CC=85 looks scary on radon but it's a flat dispatch, not deep nesting. Refactor cost is 4 hours, value is saved only when source #11 is added — defer the work to that PR.

  4. Do NOT unify the 5 token-fetch flows under one TokenProvider abstraction. MSAL refresh, OAuth refresh, OAuth M2M, IAM AssumeRole, and raw API key are five fundamentally different protocols. The _acquire_access_token triplicate (Azure / M365 / passive-Azure) IS worth deduping (one helper), but pretending all five share an interface adds a layer with no payoff.

  5. Do NOT build a "MultiTenant" abstraction yet. The single-tenant-per-connection assumption is everywhere (connection.tenant_id is on every model). Multi-tenant data isolation is already enforced at the tenant_context PostgreSQL RLS level. Adding a parallel app-level abstraction would double-check the same invariant and add bugs.

  6. Do NOT introduce a "ConnectorRunner" base class that wraps the iteration in runner.py. The orchestrator's async for artifact in connector.discover() loop is 4 lines; the rest of the long method is persist/classify orchestration that's product-specific, not connector-specific. Splitting just the connector loop into a base "runner" is yak-shaving.

  7. Do NOT migrate AWS to aiobotocore purely for "async purity". Current asyncio.to_thread(self._list_region, region) is fine, fast, and well-tested. Switching libraries is a week of work and a regression surface.


5. Suggested sequencing

Phase 1 — plumbing wins (do first, 1-2 days total):

  1. data_plane/types.pyDiscoveredArtifact, canonical_id, Connector Protocol. (unblocks everything else)
  2. cp/connections/secrets.py::store_connection_secret. (no dependency)
  3. data_plane/orchestrator/registry.py — replace the elif chain. (depends on #1)
  4. structlog.contextvars binding at orchestrator entry. (depends on #1)

Phase 2 — HTTP base + retry (3-4 days):

  1. data_plane/_http.py::BaseDataPlaneClient with paginate / silent-skip / retry. (depends on #1)
  2. Migrate connectors one at a time to BaseDataPlaneClient: anthropic, openai, m365, gcp, databricks, github, passive-azure, passive-gcp. (Skip aws_connector and passive-aws — boto3, different shape.)
  3. Add a connector-level test for each migrated connector following test_collector.py pattern. (this is where the test parity gap closes for free)

Phase 3 — audit + observability (2-3 days, do before EU AI Act audit):

  1. shared/audit/service.py::AuditService.log with Literal action names.
  2. Wire into runner.py for run lifecycle (started/completed/failed/partial).
  3. Backfill router call sites (11 places) to use the helper.

Phase 4 — router split (1 day, low priority but easy):

  1. Split routers/admin.py and routers/connections.py. Pure file moves, no behaviour change.

Phase 5 — defer until needed:

  • vendor_inference registry refactor — wait until source #11.
  • ABC base class — never (or only when you have 15+ connectors and the Protocol is genuinely cramping you).
  • aiobotocore migration — only if AWS becomes a measurable latency bottleneck.

6. Concrete metrics

Codebase shape

  • Total Python files (excl. proto, tests, venv): 103 (88 in packages/rekognise-core, 15 in apps/cp-api).
  • Total LOC analysed: 11,387 (per radon raw).
  • SLOC: 8,676.
  • Comment ratio: 8% (low — but most files have module docstrings, which radon doesn't count consistently).
  • Average cyclomatic complexity: A (3.25) across 420 blocks. Healthy.

Connectors

  • Connectors shipped: 9 active + 3 passive twins = 12 collectors.
  • Total connector LOC: 3,429 (data_plane/).
  • Connector source files: 36 (.py, excl. init).
  • Smallest connector: m365 (129 LOC src).
  • Largest connector: github (660 LOC src) — large because of code-search + workflow-scan sub-modules; actual connector.py is 264 LOC.
  • Estimated mechanical duplication: ~280 LOC (~8%).

Test parity

  • Test files in tests/unit/data_plane/: 18.
  • Total test LOC for data_plane: 1,514.
  • Connectors with normalize tests: 9/9 (parity good).
  • Connectors with discover() flow tests: 1/9 (passive-azure only) + 1 partial (databricks oauth).
  • Test:source ratio per connector (LOC): databricks 0.92, github 0.49, passive-azure 1.07, m365 0.34, openai 0.25, anthropic 0.24, azure 0.35, aws 0.24, gcp 0.24.

Files >500 LOC

LOC File
1136 apps/cp-api/src/cp_api/routers/connections.py
948 apps/cp-api/src/cp_api/routers/admin.py
895 scripts/seed_demo.py (seed script — exempt)
880 apps/cp-api/src/cp_api/routers/inventory.py
600 apps/cp-api/src/cp_api/routers/hitl.py

Files >1000 LOC: 1 (connections.py).

Top 10 longest functions

(See section D above.)

Top complexity hotspots (radon)

CC Block File:line
85 infer_vendor (F) shared/vendor_inference.py:75
29 get_system_detail (D) routers/inventory.py:551
28 DiscoveryOrchestrator.run (D) data_plane/orchestrator/runner.py:57
24 export_system_annex_iv (D) routers/inventory.py:286
21 get_connection_health (D) routers/connections.py:978
17 impersonate (C) routers/admin.py:758
16 normalize_cloudtrail_event (C) passive_collector/normalize_aws.py:66
16 DiscoveryOrchestrator (C) runner.py:30
15 list_systems (C) routers/inventory.py:48
15 normalize_gcp_log_entry (C) passive_collector/normalize_gcp.py:104

Audit-log writes

Location AuditLogEntry inserts
apps/cp-api/src/cp_api/routers/admin.py 6
apps/cp-api/src/cp_api/routers/hitl.py 3
apps/cp-api/src/cp_api/routers/inventory.py 2
packages/rekognise-core/src/rekognise/data_plane/** 0

7. Honest verdict

Yes-but. This codebase is unusually clean for a 3-month-old, solo-founder, 9-connector product. Average CC is A, every connector has normalize tests, the architecture is duck-typed-Protocol pragmatism (correct for the stage), and the directory structure is consistent. There are no architectural mistakes that would force a rewrite.

The "but": - One 264-LOC dispatch (vendor_inference) is starting to god-function. - One router is 1,136 lines and has 8 copies of the same KV write. - Connectors are missing flow-level tests for 8 of 9 vendors. - Discovery runs leave zero audit trail — a real exposure for an EU AI Act compliance product. - The BaseDataPlaneClient abstraction is overdue: ~280 LOC of pagination + auth-error handling is hand-rolled across files.

None of these are emergencies. All five top-leverage refactors are <1 day each, and Phase 1 alone (1-2 days) would remove ~250 LOC and give a clean seam for connectors #10-#15. The codebase will reward investment cleanly; it has not yet accreted hairballs that demand triage.

Recommendation: do Phase 1 + Phase 3 (audit) before the next compliance-focused customer call. Defer Phases 2 and 4 to a quiet week. Do not refactor vendor_inference until you ship vendor #11.