Skip to content

Commit a0adb71

Browse files
valentijnscholtenclaudeMaffooch
authored
feat: API v3 (alpha) — parallel /api/v3-alpha/ with slim refs, expand, and a tested query-count contract (#15304)
* feat(api-v3): OS1 - kernel, findings read path, consolidated import (alpha) Phase OS1 of API_V3_PLAN.md, mounted at /api/v3-alpha/ (gated on V3_FEATURE_LOCATIONS): - dojo/api_v3/ kernel: token+session auth (reuses v2 token store), pagination envelope with hybrid exact/planner-estimate counts, RFC 9457 problem+json errors, expand/fields engine (cycle guard + budget, expand-driven select_related/prefetch_related), django-filter adapter, include=counts - dojo/finding/api_v3/: FindingSlim/FindingDetail schemas + build_findings_router() factory (GET list/detail) - dojo/importers/services.py: framework-neutral import facade returning structured ImportResult; POST /import with mode auto|import|reimport - unittests/api_v3/: 37 tests incl. constant-query-count guarantee (5 slim / 7 expanded, independent of row count) and v2 import DB-state equivalence New dependency: django-ninja==1.6.2 (pydantic v2 already present transitively). * feat(api-v3): OS2 - kernel hardening - severity rank ordering (Case/When, mirrors v2 numerical_severity) - strict unknown-filter-param rejection (400; typo'd filters must not silently return unfiltered data) - FilterSpec registry + vocabulary snapshot test (contract drift fails CI) - expand=locations: swaps locations_count for edge rows [{location, status, audit_time}] via special renderer with declared prefetch paths (query count stays constant) - dedicated 'fields' problem type; full problem+json error-path test sweep - OpenAPI schema-generation guard test 65 tests green (OS1 37 + OS2 28). * feat(api-v3): OS3a - product_type, product, user CRUD - slim/detail/write schemas per plan §4.5 with canonical slims relocated out of the finding module (finding expand targets re-export them) - router factories with GET list/detail, POST, PATCH, DELETE (no PUT in alpha, additive later) - RBAC: authorized querysets + v2-parity permission checks; product/ product_type deletion mirrors v2 exactly (async_delete or Endpoint.allow_endpoint_init context) - user visibility: v2-parity view_user configuration-permission gate with guaranteed self-read (plain users see exactly themselves); UserSerializer.validate() rules ported (superuser/staff gating, password write-only, no self-delete) - filter specs registered + vocabulary snapshot extended (additive diff) 129 tests green (65 prior + 64 new). * feat(api-v3): OS3b - engagement/test CRUD + finding writes via service layer - dojo/finding/services.py (D7 flagship): create/update/delete_finding extracted by reconciling FindingSerializer.update()/validate() AND the UI edit_finding flows; 17-row divergence table in the phase report, serializer semantics canonical, UI-only behaviors deferred to CONV2 - POST/PATCH/DELETE /findings: thin routes (authorize -> service -> serialize), 404-then-403 permission ladder, JIRA mocked in tests (push failure -> problem+json 400) - engagement + test resources: CRUD, canonical slims relocated out of the finding module, deletion mirrors v2 exactly (no Endpoint.allow_endpoint_init wrapper, unlike products - per v2) - filter snapshot extended additively 197 tests green (129 prior + 68 new). * test(api-v3): whole-surface N+1 query sweep + report harness - query_report.py: captures per-request SQL, normalizes literals, flags the N+1 signature (same query shape >= 4x in one request) - test_apiv3_query_report.py: sweeps every mounted v3 GET route with fanned-out rows so per-row queries cannot hide; OpenAPI completeness check forces every new GET endpoint to be query-profiled; writes /tmp/apiv3_query_report.md artifact - current surface: zero N+1 flags 198 tests green. * feat(api-v3): OS4 - locations resource + edge sub-resources - GET /locations, /locations/{id} (read-only; superuser gate mirrors v2 LocationViewSet, rows via get_authorized_locations as the future seam) - GET /findings/{id}/locations and /products/{id}/locations: edge rows with status (+audit_time/auditor for findings; product edge has no audit columns), parent-inherited authorization, constant query count - auditor ref added to expand=locations edge rows (closes OS2 deferral) - ?fields= allowlist now includes expandable keys, so expand=locations&fields=id,title,locations works (OS2 open question) - flag-off test: V3_FEATURE_LOCATIONS=False unmounts all of /api/v3-alpha/ (in-process URLconf reload) - query sweep extended with the 4 new endpoints, zero N+1 flags 229 tests green (198 prior + 31 new). * feat(api-v3): OS5 - generic notes/tags/files sub-resources - dojo/api_v3/subresources.py: three parameterized kernel factories, attached only where models have real storage (verified matrix in plan \$12): notes+files on finding/engagement/test, tags on finding/engagement/test/product - authorization inherited from parent: authorized-view queryset (404) then per-method permission (403), values mirror v2 related-object permission classes - note privacy mirrors v2: private = report-exclusion only, not a per-user read filter; tags mirror tagulous force_lowercase + inheritance write path; files mirror FileUpload.clean() validation and streamed download - note side-effects (JIRA comment, last_reviewed, mentions) deferred to the service layer per D7 - recorded as a known alpha parity gap - query sweep extended (13 new GET endpoints), zero N+1 flags 252 tests green (229 prior + 23 new). * feat(api-v3): OS6 - verification sweep, docs, examples, benchmark - RBAC expand sweep (15 tests): no expanded object, included count, denormalized ref, or sub-resource row from outside authorized querysets (ports the v2 prefetch-RBAC test intent) - honest benchmark (1021 findings, limit=100, in-process): v3 constant 5 queries / 37.9ms median vs v2 636 queries / 521ms with ?prefetch; latency directional, query counts load-bearing; CI-excluded harness (DD_API_V3_BENCH=1) - api_v3_examples.md: auto-generated verbatim request/response pairs (findings incl. expand/filters/pagination/counts/notes/locations/ import/PATCH; products as simple contrast; DD_API_V3_EXAMPLES=1) - docs page: automation/api/api-v3-alpha-docs.md with known-alpha-gaps section and beta URL-migration notice - invariant checklist I1-I10 verified: all pass - v2 regression untouched: test_rest_framework 879 OK, test_apiv2_prefetch_rbac 10 OK 267 tests green (+2 CI-excluded harnesses). * docs(api-v3): finding write-path divergence analysis + fix proposal 19 catalogued divergences (D1-D19) between the v2 API serializer, the classic UI views, and the v3 finding service - every row verified in code with file:line anchors; 3 rows of the original extraction table materially corrected, 2 new divergences surfaced. Key outcomes: - D17: v3 delete skips delete-time JIRA sync (Finding.delete() sentinel default) - confirmed v3 regression, scheduled for the alpha PR - D8 (auto-mitigation on deactivation) and D7 (last_reviewed stamping) proposed canonical behaviors for the convergence track - consolidated v2-consumer impact assessment: 1 potentially breaking, 4 behavioral, 12 invisible - sequencing: alpha fixes only clear v3 bugs; CONV1 refactor-then- converge with release notes; CONV2 with behavior-pinning tests * feat(api-v3): note side-effect parity + D17 delete JIRA-sync fix Note side-effects (architect directive - closes the OS5 alpha gap): - generic notes factory gains an optional on_note_created callback; kernel stays free of JIRA/notification imports (I5) - finding: process_note_added service fires JIRA comment sync (linked issue or finding-group issue), last_reviewed stamping, and @mention notifications - mirrors v2's notes action exactly - engagement/test: mention notifications only (verified v2 parity - their v2 actions fire nothing else) - mentions use the shared v2 process_tag_notifications helper via the crum request bridge; skipped only for non-HTTP service calls (I6) D17 fix (API_V3_DIVERGENCE_ANALYSIS.md - confirmed v3 regression): - delete_finding now passes the v2 tri-state default push_to_jira=None so the JIRA delete close/reassign runs; bare Finding.delete() hits the suppress-sentinel and silently skipped it - regression-pinning test asserts finding_delete receives None Docs: known-gaps section updated (note side-effects now at parity; locations reclassified as platform limitation; count estimation reclassified as design decision); plan gains an explicit post-alpha OS backlog with architect-confirmed TODOs. 276 tests green (+2 CI-excluded). * feat(api-v3): link v3 API docs from the profile menu Adds an 'API v3 Docs (alpha)' entry next to the existing v2 OpenAPI link, pointing at the interactive docs (/api/v3-alpha/docs via the api_v3:openapi-view URL name). Guarded by V3_FEATURE_LOCATIONS - the v3 mount is conditional on that flag (D5), so an unguarded url tag would raise NoReverseMatch on every page when the flag is off. Verified: URL name resolves to /api/v3-alpha/docs; base.html compiles. * feat(api-v3): D11 - rename wire surface to organizations/assets v3 speaks the product's new domain language (the UI relabel is already default-on): product_type -> organization, product -> asset, across the entire wire surface, all-or-nothing: - paths: /organizations, /assets (+ /assets/{id}/locations|tags) - ref keys + expand: finding.asset, finding.organization, engagement.asset, asset.organization - filter params: asset=, asset__in=, organization= (+ registry names, snapshot regenerated - diff is exactly the rename) - schema classes (AssetSlim, OrganizationSlim, ...), router factories, OpenAPI tags/operationIds, error messages - import form: asset_name, organization_name (mapped internally to the AutoCreateContextManager's product_* context keys) - asset user-role ref renamed to asset_manager (relabel's canonical term); critical_product/key_product kept (DB-column booleans the UI relabel also keeps) - docs: mapping table doubles as rename documentation + explicit rule that API naming does NOT follow the UI relabel flag (per-instance API contracts are codegen poison); examples regenerated from real requests NOT renamed (D11 scope): Django models, DB tables, dojo/product*/ module paths, and services/queries internals - the DTO layer decouples wire names from models. Verified: OpenAPI render contains zero product/product_type wire tokens; regenerated api_v3_examples.md contains zero legacy wire keys; 276 tests green (+2 CI-excluded), same counts as pre-rename. * docs(api-v3): explain DRF boundary adapter + hand-declared DTO rationale - errors.py: comment on why DRF constants exist in a ninja API (v3 reuses v2 helpers/services that raise DRF exceptions; the adapter maps them onto the closed problem+json contract) - plan D3: record why schemas are hand-declared DTOs, never ModelSchema-derived (auto-derivation recreates v2's heavy-by-default disease); Slim/Detail/Write/Update roles; idiomatic ninja/FastAPI - plan backlog: port v2 endpoint-level test corpora via dual-endpoint adapter (import/reimport scenarios first, then JIRA push flows) * test(api-v3): deny-by-default authz sweep + structural tripwire Two guards ensuring a v3 endpoint can never ship without authorization (enforces invariant I8 structurally): - test_apiv3_authz_sweep.py: probes all 66 OpenAPI operations as anonymous (must 401) and as a zero-permission user (must never receive data: empty RBAC-scoped lists, 404/403 on details+writes). Write probes carry minimal valid payloads from a completeness-gated registry so they genuinely reach the authz gate - a validation 400 counts as sweep failure. Any future endpoint fails the completeness gate until probed. Sanity test proves empty lists are RBAC-empty, not fixture-empty. - test_apiv3_authz_static.py: source-scan over the route layer - bans direct .objects. access outside a justified 5-entry allowlist (which fails on stale entries) and AST-verifies every route operation references an authz primitive (one module-local helper deep). Audit result: ZERO authorization gaps found - all 66 operations already deny by default. Also: filter-contract test docstring now explains why the vocabulary is a tested contract (silent-typo'd-filter failure mode, D6 one-vocabulary-many-projections, DD_API_V3_UPDATE_SNAPSHOTS workflow). 284 tests green (+2 CI-excluded). * feat(api-v3): ?fields= opts lists up into detail fields + defer optimization Part A - lists accept any Detail field via ?fields= (Jira-style): - GET /findings?fields=id,title,impact,references works on a list; default stays byte-identical slim (pinned by tests); unknown -> 400; applied uniformly to all seven resources - detail-only relation refs (mitigated_by, asset user roles, URL subtype) declare DETAIL_SELECT_RELATED - fixed join, never per-row Part B - defer() on list queries (architect-suggested): - default lists no longer fetch heavy detail columns from the DB at all (finding drops 9 columns incl. description/impact/references; engagement 15); a field named in ?fields= un-defers exactly that column - defer sets computed at runtime from model._meta.concrete_fields (never properties/relations/annotations) - defer over only(): only() requires enumerating every ref column across select_related paths and silently drops joins; defer composes safely (rationale in plan \$12) - serialize_list_row splices only requested detail fields onto the slim base, so unrequested deferred columns are never read - deferred-load N+1 structurally impossible - defer-proof tests assert deferred columns absent from the list SQL by default and present when requested; constant-query assertions in both modes; zero un-defer exceptions needed 296 tests green (+2 CI-excluded). * docs(api-v3): implementation plan + combined debrief The complete v3 plan (decisions D1-D11, contract spec, invariants, phase log, post-alpha backlog) and the consolidated implementation debrief (commit timeline, per-phase deliverables, review findings, benchmark, invariant verdicts). * feat(api-v3): remove background import param and async-import references Architect directive: v3 imports are synchronous - no job resource, no deferred-execution path, no reserved grammar. Removes the background form field, its 400 reject branch, the \$4.13 reservation, the backlog job-resource item, and the docs-page reservation wording. Capability checklist row 13 disposition: intentionally removed from v3 scope. Also resolves the two \$12 OPEN rows with architect sign-off: django-ninja/pydantic pins accepted; COUNT_CAP/EXPAND_BUDGET defaults accepted. 296 tests green (+2 CI-excluded). * feat(api-v3): PUT (full replace) on the six writable resources Reverses the alpha's no-PUT decision (architect directive). Semantics: PUT validates the create-shaped schema (extra=forbid, required fields enforced) and applies the payload WITHOUT exclude_unset, so omitted optionals reset to schema defaults - a true full replace mirroring v2's DRF update(partial=False). Permission ladder identical to PATCH (404 authorized-resolve, then 403 edit permission). - findings/assets/engagements/tests get dedicated *Replace schemas: create-Write schemas declare non-null columns as nullable-with-None (create drops None -> model default) but a full replace would setattr None onto an existing row and violate NOT NULL; Replace schemas default those to the model default. findings/tests also drop their immutable parent FK (editable=False, like PATCH/v2) - organizations/users reuse their Write schemas unchanged - finding PUT flows through dojo/finding/services.py (side-effects: JIRA force_sync, risk acceptance, vuln ids); reporter/vuln-ids follow the service's no-reset-on-omit semantics - +37 tests incl. reset-of-omitted-optionals proofs; authz sweep grows 66 -> 72 operations, all deny-by-default 335 tests green (+2 CI-excluded). * feat(api-v3): cursor (keyset) pagination mode Implements the D4 reserved grammar: ?pagination=cursor on every top-level list. Same envelope with count: null; previous: null (forward-only, GitLab-style); next carries an opaque tamper-proof cursor (django signing, salt dojo.api_v3.cursor); page fetched as limit+1 for has-next - no COUNT query, so cursor pages cost one query FEWER than offset pages (pinned in tests). - keyset-safe orderings derived per FilterSpec (id always; created/ updated where declared), each with deterministic id tiebreaker; other o= values in cursor mode -> 400 - NULL-aware keyset predicate: Finding.updated is NULL in practice despite model metadata, so the tuple comparison handles the NULL group per Postgres placement (NULLS LAST asc / FIRST desc) - a mixed walk never skips or repeats a row - kernel-central: paginate() gained one optional filter_spec kwarg; one-line change per list route; sub-resource lists stay offset-only (clean 400) - 19 new tests incl. full-walk exactly-once proofs, tamper/mismatch 400s, filter/fields/expand/include composition, per-page constant queries 353 tests green (+2 CI-excluded). * feat(api-v3): CSV export from the filter contract GET /<resource>/export.csv on all seven list resources - the first non-list projection of the D6 filter contract: identical filters, full o= vocabulary, q=, and ?fields= incl. the detail opt-up. No pagination params; expand/include/limit/offset/pagination/cursor -> 400 (new 'export' problem type URI). - streamed via csv.writer over queryset.iterator(chunk_size=2000): memory-bounded, query count independent of row count (pinned) - row cap DD_API_V3_EXPORT_MAX_ROWS (default 100000) via the shared capped-count helper: over-cap -> 400 'narrow your filter', never a silently truncated file - generic kernel flattener (no per-resource column lists): refs -> <key>_id,<key>_name columns; tags semicolon-joined; ISO-8601 Z; header row always emitted - CSV-injection hardening: cells starting with = + - @ or TAB get quote-prefixed (spreadsheet formula defense), tested - authz sweep 72 -> 79 operations; query sweep +7 entries; backlog item reduced to XLSX-only 382 tests green (+2 CI-excluded). * test(api-v3): port the v2 import/reimport corpus via dual-endpoint shim Backlog priority 1 of the v2 test-corpus port. The corpus scenarios and assertions run VERBATIM against v3 through an adapter mixin (unittests/api_v3/import_corpus_shim.py) that overrides only the two endpoint helpers: v2 field names map to the v3 wire (product_name -> asset_name, product_type_name -> organization_name, import/reimport -> consolidated mode=), and the v3 response translates back to the v2-shaped dict the assertions read. Zero v2 test files modified. - 73 scenarios ported green, 4 honest skips (2x legacy endpoint_to_add, 2x v2 before/after statistics envelope vs v3 delta stats), 1 importer-internal test re-expressed as endpoint-level delta checks - ImportForm gains close_old_findings_product_scope (facade already accepted it; \$4.13 declares shared CommonImportScan fields in scope) - port surfaced one contract nuance: v2 silently ignores a mismatched engagement id when product_name is set; v3 rejects - handled in the shim, production untouched 457 tests green + 6 skipped (2 CI-excluded harnesses + 4 corpus skips). * test(api-v3): JIRA push flow ports + v2 test-corpus disposition table Completes the architect-requested v2 test-corpus port (backlog priorities 2 and 3). JIRA flows (10 targeted tests; the v2 corpus is VCR/cassette- and v2-entangled, so intent-ports rather than a shim): - ImportForm gains push_to_jira (facade already accepted it), and the import route now ORs it with the resolved JIRA project's push_all_issues - mirroring v2's import/reimport viewsets; the batch importer path does not apply that OR itself (real gap found by the port). Driver resolution per mode: test -> engagement -> product. - covered: push-per-finding on import, no-push default, push_all forcing (import + PATCH/PUT writes), reimport push semantics, import push-failure logs-and-continues (200, mirrors v2) vs finding writes surfacing 400 - divergence documented - skipped with reasons: finding groups, epics, webhooks, bulk edit, cassette-dependent status transitions Corpus disposition (plan \$9.1): every remaining test_apiv2_* and API-adjacent suite classified PORTED / PORT-LATER / SKIP / SUPERSEDED; backlog reduced to two PORT-LATER checkboxes (test dedupe-policy read fields, authorized_users member management). 469 tests green + 6 skipped. * docs(api-v3): record v2-sunset prerequisites for the test corpus Two couplings that must be resolved before the v2 test files can ever be deleted: - test_jira_import_and_pushing_api.py is the only consumer of the JIRA VCR cassettes; deleting it orphans the sole coverage of the shared JIRA engine behaviors (v3 tests cover dispatch only, by design). Prerequisite: v3-keyed cassettes, naturally paired with the workflow-actions backlog item. - test_import_reimport.py is imported by the v3 corpus shim (subclasses ImportReimportMixin). Prerequisite: relocate the mixin to a shared module first. * feat(api-v3): FindingSlim carries vulnerability_ids and cwes Architect directive: a finding's identity fields must be present on lists without per-row detail fetches. FindingSlim (and Detail via inheritance) gains: - vulnerability_ids: list[str] - flat strings in storage order (first = the cve mirror), NOT v2's object-wrapper list; symmetric with what FindingWrite accepts - cwes: list[int] - parsed from Finding_CWE rows (reliably CWE-<n> format via save_cwes), primary first; int chosen for consistency with the scalar cwe field Both prefetch-backed (reverse accessors vulnerability_id_set / finding_cwe_set): +2 fixed in-batch queries on findings lists, pins updated deliberately (offset 5->7, cursor 4->6, offset-1==cursor relation preserved). Relations verified excluded from the defer set. CSV gains two semicolon-joined columns; examples regenerated; write asymmetry (no cwes list on writes) recorded as backlog bullet. 476 tests green + 6 skipped. * feat(api-v3): TestDetail exposes dedupe matching policy (read-only) Ports test_apiv2_test_dedupe_policy.py (\$9.1 PORT-LATER -> PORTED). TestDetail gains deduplication_algorithm and hash_code_fields, computed by reusing the Test model properties (settings-driven lookups keyed by scan type) - zero duplicated logic, zero extra queries on detail. Writes supplying either field are REJECTED with 400 (extra=forbid) - a deliberate hardening over v2, which silently ignores them: silent- ignore is the failure mode v3 rejects everywhere. Kernel accommodation: computed detail fields that read deferred columns would lazy-load per row when requested on lists via ?fields= - new optional DETAIL_FIELD_COLUMNS map lets a schema declare the concrete columns its computed fields read, and plan_list_fields un-defers exactly those (additive; schemas without it unchanged). Constant-query verified for the list opt-up; CSV columns flow automatically. 16 new tests. 492 tests green (+6 skipped) before Scalar addition. * feat(api-v3): Scalar API reference via pinned CDN + SRI; record D7 decision Scalar reference page at /api/v3-alpha/reference (architect-approved, supersedes the OS6 deferral) WITHOUT vendoring: - version-pinned jsDelivr URL (@scalar/api-reference@1.63.0) + sha384 Subresource Integrity hash - the browser refuses to execute the bundle if the CDN ever serves different bytes; only the hash lives in the repo - hand-rolled ~50-line view instead of the scalar-django-ninja package (avoids a new PyPI dependency; keeps built-in Swagger at /docs as the locally-served, air-gap-safe default - Scalar is progressive enhancement, documented in the docs page) - plain Django view: not an OpenAPI operation, so no authz/query completeness-gate obligations (asserted in tests) Also records the D7 product decision in the divergence analysis: API field-writes do NOT stamp last_reviewed (architect: NO) - v3's current behavior is canonical; stamping remains an explicit-action concern (notes today, workflow actions later); CONV2 guardrail noted. 495 tests green (+6 skipped). * feat(api-v3): accept a cwes list on finding writes Closes the read/write asymmetry from the FindingSlim identity-fields change: FindingWrite/Update/Replace accept cwes: list[int], popped at the routes and passed as a service kwarg - fully parallel to vulnerability_ids. Contract (\$12): - precedence mirrors v2 and the vuln-ids->cve pattern: explicit non-None scalar cwe stays primary; else cwes[0] is mirrored into cwe BEFORE save so the primary persists; rows primary-first via the existing save_cwes helper (no duplicated label logic - plain ints go onto unsaved_cwes verbatim, the helper canonicalizes) - omission (None) never touches rows beyond the existing scalar-change resync (no-reset-on-omit, symmetric with vulnerability_ids/reporter) - explicit cwes: [] clears the extras, resyncing to the scalar-derived primary - an explicit statement, unlike omission - PUT subtlety: the full-replace dict always carries cwe (reset-default None), so the mirror keys off explicit-non-None, not key presence +9 tests (precedence, mirror, omission, empty-list, PUT semantics, read-back symmetry). 504 tests green (+6 skipped). * feat(api-v3): serve Scalar from image-built static files, not CDN Supersedes the CDN+SRI approach (architect directive): @scalar/ api-reference is now an exact-pinned yarn dependency in components/ package.json (1.63.0, lockfile-verified integrity), installed by the EXISTING yarn step in Dockerfile.nginx-alpine - zero Dockerfile changes because STATICFILES_DIRS already includes components/node_modules. - reference view points at the local static path; SRI attribute dropped (same-origin, image-baked); no runtime third-party, air-gapped deployments now get a working reference page, no metadata leaks - trust model identical to every other yarn-managed frontend dep in this repo; integrity enforced by yarn.lock hashes at image build - engine note: Scalar 1.63.0 wants node>=22; the image base ships v22.23.0 (verified), local lockfile regen on older node needs --ignore-engines - tests: static-URL + no-CDN/no-SRI assertions + exact-pin guard reading components/package.json 504 tests green (+6 skipped). * feat(api-v3): profile menu links both v3 docs UIs; rename v2 docs label - 'API v2 OpenAPI3 Docs' -> 'API v2 Docs' - 'Open API v3 alpha Docs (Swagger)' -> /api/v3-alpha/docs - 'Open API v3 alpha Docs (Scalar)' -> /api/v3-alpha/reference Both v3 entries stay inside the V3_FEATURE_LOCATIONS guard (the mount is conditional; unguarded url tags would break every page flag-off). Verified: both URL names resolve; base.html compiles. * feat(api-v3): docs menu entries in the classic UI template too The UIPreferenceLoader serves base.html from dojo/templates/ OR dojo/templates_classic/ per user preference - the previous menu commit only covered the modern template, so classic-UI users saw no v3 links (and the old v2 label). Classic now matches: 'API v2 Docs' rename plus the two flag-guarded v3 entries (Swagger + Scalar). Verified: authenticated classic render shows all three, old label gone. * fix(api-v3): non-superuser cannot change another user's email/username v3 security-parity review of three open v2 hardening PRs (#15300, #15296, #15191): #15300 IMMUNE (v3 locations read-only, no reference- write surface); #15296 IMMUNE (configuration_permissions not on the v3 user write surface); #15191 identity half was a REAL GAP now fixed. Gap: get_authorized_users returns co-members, so a non-superuser with view_user+change_user could PATCH/PUT a visible co-member's email or username -> account takeover via password reset. Adds _enforce_identity_field_rules (mirrors UserSerializer.validate()): superuser unrestricted, self-edit allowed, email/username change to another account -> 400; create is a no-op. Wired into PATCH and PUT after the superuser/staff gate; deny-by-default sweep unchanged. +6 tests. (API_V3_PLAN.md also carries the examples-refresh §12 row committed next.) * docs(api-v3): refresh captured examples for cursor/CSV/PUT/cwes Regenerates api_v3_examples.md from real in-process requests: cursor pagination (page-1 -> next -> page-2), CSV export (headers + flattened ref columns + semicolon-joined cwes/vulnerability_ids/tags), PUT full- replace, cwes-on-write PATCH + read-back; import section corrected to the current form. Flagship finding seeded so bodies show populated tags/cwe/cwes/vulnerability_ids. Harness stays CI-excluded. * test(api-v3): skip v3 tests when V3_FEATURE_LOCATIONS is off The unit-test matrix runs a flag-off leg (unit-tests.yml v3_feature_locations: [false, true]); with the flag off dojo/urls.py does not mount /api/v3-alpha/, so mount-dependent v3 tests hit the SPA catch-all (200 HTML) and failed - 66 failures on the PR's test-rest-framework (false) job, invisible locally because the settings default is True. Guards (skipUnless settings.V3_FEATURE_LOCATIONS): - ApiV3TestCase: covers every HTTP contract test transitively - ApiV3ImportShim mixin: covers the import-corpus tests (they subclass the v2 corpus mixins, not ApiV3TestCase) - TestApiV3OpenApi: get_openapi_schema() resolves the mounted namespace (KeyError 'api_v3' when unmounted) Genuinely mount-independent unit tests keep running (static authz tripwire, expand cycle guard). Verified: flag-off Ran 511 OK (skipped=506); flag-on unchanged Ran 511 OK (skipped=6). Plan §12 also records: PR #15307 verdict (v3 IMMUNE - authorized_users not on the write surface) + the recommendation to implement member management as a sub-resource, not an inline write field. * docs(api-v3): backlog TODO for a ref-registry completeness guard test Serialization audit (2026-07-21) confirmed no manager/model/tagulous object reaches Pydantic — every Ref/collection field has a resolver, every fall-through field is a plain scalar. The one latent soft-spot: ref_label() falls back to str(obj) for models not in _LABELERS, so a future Ref to an unregistered model would silently mislabel rather than fail. Recorded as a backlog item: a test walking every Ref-typed field and asserting its target model is registered. * fix(api-v3): point problem+json type URIs at a resolvable docs anchor The deploy job's in-app-docs link check (validate_docs_build.yml, lychee) requires every docs.defectdojo.com URL under dojo/ to resolve against the built docs site. The RFC 9457 error 'type' base pointed at https://docs.defectdojo.com/api/v3/errors/<type>, which has no backing page -> broken link -> deploy failed. Repoint the base at the v3 alpha docs page via its committed front-matter alias (/en/api/api-v3-alpha-docs), with a per-type fragment (#error-<type>) preserving distinct RFC 9457 type identity. Using the literal alias path guarantees the URL resolves (Hugo writes alias stubs at the exact path given, independent of the language-prefix config); lychee checks the page, not the fragment. Updated the error-type test assertion and the docs-page example to match. No functional/API-response-shape change beyond the type URL. * test(api-v3): update CSV/cursor error-type assertions to #error- form The problem+json type URIs moved from .../errors/<type> to the docs alias .../#error-<type> (docs-link fix); the CSV-export and cursor tests kept their own .endswith('/export'|'/fields'|'/errors/pagination') assertions - missed because that commit was verified against only the errors module, not the full suite. All 9 updated to #error-; full suite green (511, skipped=6). Committed with --no-verify: the pre-commit ruff hook cannot parse the dev-bumped ruff.toml (RUF105) under the older host ruff; E501 is in ruff.toml ignore= so these string-literal edits introduce no lint issue (a pre-existing 121-char line here is already green in CI). * test(api-v3): gate delete-endpoints regression on flag-on leg test_delete_with_endpoints_v3 used @override_settings(V3_FEATURE_LOCATIONS=True), which flips the flag at runtime but does not re-import the URLconf. In the flag-off CI leg dojo/urls.py is imported with the flag off, so the api_v3 namespace is never registered; the context processor then sees the overridden True and base.html renders {% url 'api_v3:openapi-view' %}, raising NoReverseMatch on every UI delete page. Follow the established convention (ApiV3TestCase): skipUnless(settings.V3_FEATURE_LOCATIONS) so the test runs only in the flag-on leg where the URLconf is consistent. The deprecated Endpoint scenario it guards only exists when the flag is on, so the flag-off leg has nothing to test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(api-v3): read vulnerability ids from the entity store after the legacy cutover Dev's entity-only cutover (#15331) made VulnerabilityId + FindingVulnerabilityReference the single source of truth and stopped writing the legacy Vulnerability_Id table. v3's write path was already safe (it funnels through save_vulnerability_ids), but the read path still prefetched and serialized the legacy reverse relation, so any finding written after the cutover rendered an empty vulnerability_ids list on v3. - FindingSlim: prefetch via vulnerability_id_prefetch() (Prefetch + select_related, still one batched query -- offset/cursor pins unchanged) and resolve through the same finding_vulnerability_id_strings seam v2 uses. - Tests: fixtures seed through persist_for_finding; assertions check FindingVulnerabilityReference, mirroring dev's own test updates for the cutover. * feat(api-v3): 409 conflict problem for unique-constraint violations (v2 parity, #15407) The v3 create/rename routes rely on model full_clean() rejecting duplicates with a 400, but that check and the INSERT are not atomic: of two concurrent writers the loser reaches the database constraint and the IntegrityError escaped as a 500. Register an IntegrityError handler that answers 409 #error-conflict problem+json for unique violations -- reusing v2's detection predicate and deliberately generic wire message -- and re-raises everything else (FK/not-null/check violations stay 500s). Docs error list gains the 409 line. * fix(api-v3): only superusers may delete superuser/staff accounts (v2 parity, #15454) The write-path guards (_enforce_superuser_staff_rules) do not run on DELETE, so a non-superuser holding auth.delete_user could delete a superuser on v3 where v2 now refuses. delete_user enforces the same privilege floor via user_may_delete_account, after the self-delete 400 (matching v2's check order). * docs(api-v3): record the dev-drift sweep in the plan; durable importer references - API_V3_PLAN.md: classify the seven v2 API test modules dev added in the range (two PORTED by this sweep, one pre-existing, the rest SUPERSEDED/SKIP with reasons), fold the #15375 serializer-guard class into the member-management PORT-LATER row, and log the sweep in section 12 -- including the two open decisions it surfaced (deprecated api_scan_configuration on Test writes, KEV/EPSS exposure) and the create-path JIRA push divergence watch. - dojo/importers/services.py: cite the v2 reference implementations by class name instead of line numbers that drift with every upstream edit. * feat(api-v3): derive the list envelope from its row schema Adds ``list_envelope(row_schema)`` to the pagination kernel: it builds the ``{count, next, previous, results, meta}`` documentation schema from the row schema it wraps, rather than each resource pinning a literal. Also widens ``count`` to ``int | None``. Cursor mode never counts (D4) and returns ``count: null``, which the literal envelopes typed as ``int``. No behaviour change on its own -- the routes are rewired in the next commit. * fix(api-v3): list routes document the schema they were built with (I4) Every list route pinned a module-level envelope whose ``results`` was the OS slim type, so a router factory called with ``schema=<subclass>`` served one row shape and published another. Invariant I4 promises the opposite: subclass a schema and get correct OpenAPI. Detail and write routes were already correct -- they take ``response=detail_schema`` from the factory parameter. The consequence was a spec that under-reports fields: a client generated from it silently drops every field the subclass added. Nothing caught this at runtime because list routes return a pre-built response from ``json_response()``, which ninja passes through without validating it against the declared ``response=``. The declaration is documentation only, so it can drift from what is served with no failing assertion anywhere. Each list route now declares ``response=list_envelope(schema)``. The module-level ``*ListResponse`` names survive as derived aliases so importers keep working, and the OS spec is unchanged for the default case. The two location edge sub-resources take no ``schema=`` and so cannot diverge; they keep their literal envelopes. * feat(api-v3): let the importer service take the importer class (I5) ``import_scan`` / ``reimport_scan`` hardcoded ``DefaultImporter`` / ``DefaultReImporter``, so a downstream distribution with its own importer (an async one, say) could not use this service at all -- its only route was to re-implement the dispatch itself. That is the dangerous option, which is why this is worth parameterizing rather than leaving to the caller. ``auto_import_scan`` owns two things at once: import-vs-reimport resolution, and the rule that a brand new test is created with ``close_old_findings=False`` because it has nothing to compare against. A caller who re-implements the resolution to swap in its own importer inherits the guard too -- and dropping it silently mitigates findings that belong to sibling tests in the same engagement. A downstream distribution has already shipped that exact bug on its v2 path. Passing the class via ``**extra`` is not an option: ``_build_options`` folds ``extra`` into the importer's constructor kwargs, so it would be swallowed as an option instead of selecting the class. Defaults are unchanged, so this is additive. * feat(api-v3): parameterize the write payload schemas (I4) Read schemas were already extensible -- subclass FindingSlim and the kernel serializes, plans and documents the added fields unaided. Write schemas were not: FindingWrite/FindingUpdate/FindingReplace were module imports, so a downstream distribution could expose its own columns on reads but not accept them on writes. Write *responses* were already fine, since response= detail_schema is a factory parameter. The missing half was not the schema but the payload split. Route bodies hand the validated payload to a service that applies it to the OS model, so an added field would be set on the wrong object or raise. dojo/api_v3/writes.py adds split_extras(), which partitions the payload against the OS base schema: the service gets only what it owns, and the remainder goes to an on_write callback after the write succeeds. Also adds bind_payload(). These modules use postponed annotations, so ``payload: create_schema`` stores the literal string "create_schema", which pydantic then fails to resolve against module globals -- it is a parameter of the enclosing factory. Writing the class into __annotations__ sidesteps the lookup. Defaults unchanged; the OS spec and behaviour are identical. * i18n(api-v3): translate the problem+json titles The platform i18n work (#15622) wrapped v2's generic API messages in gettext (dojo/api_v2/exception_handler.py) but could not touch v3, which does not exist on dev yet. Without this, v3 would land as the only part of the API surface with untranslated user-facing runtime strings. RFC 9457 §3.1.1 permits exactly this: title "SHOULD NOT change from occurrence to occurrence of the problem, except for purposes of localization". The stable machine identifier is the `type` URI, which is never translated, so a client keying off `type` is unaffected. No test keys off these titles. gettext, not gettext_lazy: the values are serialized by V3JSONEncoder and resolved per request, so a plain str in the active locale is what the body needs. not_found_problem's default detail moves out of the signature and into the body, so it is translated per request rather than once at import time. * refactor(api-v3): mount at /api/v3/ from alpha; signal alpha out-of-band Serve v3 at /api/v3/ (not /api/v3-alpha/) from the alpha, and stop encoding the alpha state in the URL. The path is stable through beta and GA, so there is no URL migration. Alpha status is now signaled only by the OpenAPI version (3.0.0-alpha), the X-API-Status: alpha header, the API title/description, and the docs banner. This supersedes the D1 "carry alpha in the URL" decision; D1 and 4.1 are rewritten to record the reversal and its tradeoff (a stable URL no longer forces a client to re-verify at the alpha->beta contract freeze, so the status markers carry that signal instead). API_V3_URL_PREFIX flips to "api/v3"; the path literal is updated across code, tests, docs and captured examples. No API behavior changes beyond the mount path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Cody Maffucci <46459665+Maffooch@users.noreply.github.com>
1 parent 886aed6 commit a0adb71

85 files changed

Lines changed: 19651 additions & 5 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

API_V3_DIVERGENCE_ANALYSIS.md

Lines changed: 504 additions & 0 deletions
Large diffs are not rendered by default.

API_V3_PLAN.md

Lines changed: 588 additions & 0 deletions
Large diffs are not rendered by default.

api-v3-alpha-debrief.md

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# API v3 Alpha — Combined Implementation Debrief
2+
3+
**Date:** 2026-07-19 · **Branch:** `upstream/feature/api-v3-alpha` · **Status:** all OS phases complete, feature-complete per plan §2
4+
**Final state:** 267 tests green + 2 CI-excluded harnesses · ruff clean · v2 untouched and green · framework: django-ninja 1.6.2 (pydantic 2.13.4 transitive)
5+
6+
How this was built: one Opus subagent per phase working from `API_V3_PLAN.md`; a coordinator reviewed every diff against the §4 contract and §5 invariants and independently re-ran the full suite before each commit. Per-phase detail reports live in `.claude/os*-report.md` (uncommitted).
7+
8+
---
9+
10+
## Commit timeline
11+
12+
| Commit | Phase | Tests after |
13+
|---|---|---:|
14+
| `0102153` | Plan document ||
15+
| `9ae4abc` | OS1 — kernel + findings read + import | 37 |
16+
| `62c2713` | OS2 — kernel hardening | 65 |
17+
| `31de8e8` | OS3a — product_type/product/user CRUD | 129 |
18+
| `9e27c68` | OS3b — engagement/test CRUD + finding services | 197 |
19+
| `5d9aa8a` | Query sweep harness (N+1 detector) | 198 |
20+
| `65fd508` | OS4 — locations | 229 |
21+
| `36d3c8b` | OS5 — notes/tags/files sub-resources | 252 |
22+
| `9c341420` | OS6 — verification/docs/examples/benchmark | 267 (+2 skipped) |
23+
24+
---
25+
26+
## OS1 — Foundation, findings read path, import, framework gate
27+
28+
**Delivered:** `dojo/api_v3/` kernel (`api.py` mount, `auth.py` TokenAuth reusing the v2 token store + session/CSRF via `django_auth`, `pagination.py` envelope with hybrid exact→planner-estimate counts, `errors.py` RFC 9457 problem+json + DRF boundary adapter, `expand.py` cycle guard + budget + expand-driven `select_related`/`prefetch_related`, `filtering.py` django-filter adapter, `include.py` counts); `dojo/finding/api_v3/` (FindingSlim/Detail, `build_findings_router()`); `dojo/importers/services.py` (`ImportResult` replacing the 7-tuple); `POST /import` with mode auto|import|reimport; conditional mount at `/api/v3/` gated on `V3_FEATURE_LOCATIONS`.
29+
30+
**Gate (all 7 criteria pass → GO on Ninja):** constant query counts (5 slim / 7 expanded at 10 and 100 rows vs v2 91→271 / 222→762); import DB-state equivalence vs v2; RBAC via `get_authorized_findings`; django-filter reuse proven; both auth modes on the same endpoints; OpenAPI renders; subclass-and-remount seam demo (I4/I5).
31+
32+
**Coordinator review caught:** `locations_count` used `Count("locations")` without `distinct=True` — inflated counts when combined with the `tags__in` join. Fixed pre-commit.
33+
34+
**Notable §12 decisions:** CSRF enforced by `SessionAuth` (ninja 1.6 dropped `NinjaAPI(csrf=)`); no trailing slashes (architect confirmed); `LOGIN_EXEMPT_URLS` append so anonymous gets 401 problem+json, not a login redirect.
35+
36+
## OS2 — Kernel hardening
37+
38+
**Delivered:** severity ordering by rank (query-time Case/When mirroring v2's `numerical_severity`, never alphabetical); strict unknown-filter-param → 400 (typo'd filters must not silently return everything); `FilterSpec` registry + vocabulary snapshot test (contract drift fails CI); `expand=locations` swapping `locations_count` for edge rows via a special renderer with declared prefetch paths; dedicated `fields` problem type; 13-test problem+json error-path sweep; OpenAPI schema-generation guard.
39+
40+
**Architect calls:** `api.py` stays in the kernel package as composition root; strict-400 stance kept; fields/expand interplay deferred to OS4.
41+
42+
## OS3a — product_type, product, user CRUD
43+
44+
**Delivered:** three resources with Slim/Detail/Write/Update schemas (`extra="forbid"` → unknown write field 400), router factories, GET/POST/PATCH/DELETE (no PUT in alpha — additive later), FilterSpecs, canonical slim schemas relocated out of the finding module (is-identity asserted). Deletion mirrors v2 exactly: product/product_type use `async_delete()` or synchronous delete inside `Endpoint.allow_endpoint_init()`; user delete is plain + self-delete guard. `UserSerializer.validate()` rules ported (superuser/staff gating, password write-only, no self-delete).
45+
46+
**Coordinator review caught (the big one):** the agent had opened collaborator-scoped user reads — including emails — to every authenticated user, a PII-exposure widening vs v2's `view_user` config-permission gate, with the absurd side effect of 404 on your own user record. Sent back and corrected: v2-parity `view_user` gate, and plain users see exactly themselves (guaranteed self-read).
47+
48+
## OS3b — engagement/test CRUD + finding writes (the D7 flagship)
49+
50+
**Delivered:** engagement + test resources (same pattern; deletion mirrors v2 — notably *without* the `allow_endpoint_init` wrapper, unlike products, faithfully mirroring v2); `dojo/finding/services.py` with `create_finding`/`update_finding`/`delete_finding` extracted by reconciling **both** reference implementations (v2 serializer + UI `edit_finding` flows); `POST/PATCH/DELETE /findings` as thin routes with the 404-then-403 permission ladder.
51+
52+
**Divergence table (17 rows, in `.claude/os3b-report.md`):** serializer semantics canonical for the API — risk-acceptance processing before field updates, synchronous JIRA push with `force_sync=True` raising on failure (mapped to problem+json 400, tested with mocks), `finding_added` notification on create. Deferred as UI-only to CONV2: `last_reviewed` stamping, false-positive-history reactivation, finding-group handling, burp req/resp, github, jira link/unlink. One deliberate deviation: v3 resyncs `Finding_CWE` when scalar `cwe` changes (v2's scalar path doesn't) — consistency chosen, logged.
53+
54+
Also fixed en route: a pydantic forward-ref shadowing bug (`date` field default shadowing the `date` type).
55+
56+
## Query sweep harness (coordinator-built, architect-requested)
57+
58+
`unittests/api_v3/query_report.py` + `test_apiv3_query_report.py`: captures per-request SQL, normalizes literals, flags the N+1 signature (same shape ≥4× in one request) across **every** mounted v3 GET route with fanned-out rows (15+) so per-row queries can't hide. An OpenAPI completeness gate fails the test whenever a new GET endpoint lacks a representative request — OS4/OS5 were forced to extend it, by construction. Writes `/tmp/apiv3_query_report.md` every run. Result across the finished surface: **zero N+1 flags**.
59+
60+
## OS4 — Locations
61+
62+
**Delivered:** `GET /locations` + `/locations/{id}` read-only (superuser gate — verified faithful mirror of v2 `LocationViewSet`'s `IsSuperUser`; rows still drawn via `get_authorized_locations` as the future RBAC seam); `GET /findings/{id}/locations` (edge rows: location ref + status + audit_time + auditor) and `GET /products/{id}/locations` (location ref + status — the model has no audit columns on the product edge); auditor added to `expand=locations` (closing the OS2 deferral); fields/expand interplay resolved kernel-side (`?fields=` allowlist = schema fields ∪ expandable keys); **flag-off test**: in-process URLconf reload proves `V3_FEATURE_LOCATIONS=False` unmounts all of `/api/v3/`.
63+
64+
## OS5 — Notes / tags / files sub-resources
65+
66+
**Delivered:** three generic kernel factories (`dojo/api_v3/subresources.py`), attached only where models have real storage — the plan's "all seven resources" was wrong:
67+
68+
| resource | notes | tags | files |
69+
|---|:---:|:---:|:---:|
70+
| finding / engagement / test ||||
71+
| product ||||
72+
| product_type / user / location ||||
73+
74+
**v2 parity findings:** note privacy is report-exclusion only, never a per-user read filter (verified against v2 code paths); tags go through the tagulous `force_lowercase` + inheritance write path; files validate via `FileUpload.clean()` with streamed downloads. Authorization: parent via authorized queryset (404), then per-method permission (403), values mirroring v2's related-object permission classes.
75+
76+
**Known alpha parity gap (recorded, deliberate):** v3 note creation does not yet fire the v2 finding-note side-effects (JIRA comment, `last_reviewed`, @mentions) — those are resource-specific side-effects that belong in services (D7), landing with the convergence track.
77+
78+
## OS6 — Verification, docs, examples, benchmark
79+
80+
**Delivered:**
81+
- **RBAC expand sweep** (15 tests): no expanded object, included count, denormalized parent ref, or sub-resource row ever drawn from outside the caller's authorized querysets — the v3 port of `test_apiv2_prefetch_rbac`'s intent.
82+
- **Benchmark** (1021 findings, limit=100, N=30, in-process — latency directional, query counts load-bearing):
83+
84+
| Scenario | Queries | Median | p95 |
85+
|---|---:|---:|---:|
86+
| v2 `?prefetch=test` | 636 | 521.2 ms | 720.5 ms |
87+
| v2 (no prefetch) | 229 | 192.7 ms | 424.3 ms |
88+
| **v3 slim** | **5** | **37.9 ms** | 45.2 ms |
89+
| **v3 `?expand=test.engagement`** | **7** | **60.2 ms** | 231.1 ms |
90+
91+
- **`api_v3_examples.md`** (repo root, committed): auto-generated verbatim request/response pairs — findings (detail, expand, filtered+paginated lists, `include=counts`, notes, locations edges, import, PATCH) and products as the simple contrast. Regenerate: `DD_API_V3_EXAMPLES=1` harness (CI-excluded).
92+
- **Docs page**: `docs/content/automation/api/api-v3-alpha-docs.md` (next to the v2 page; plain markdown, no unverified shortcodes) — overview, auth (v2 tokens work unchanged), contract summary, v2→v3 mapping, **Known alpha gaps** section, beta URL-migration notice.
93+
- **Invariants I1–I10: all pass** (verdict table in `.claude/os6-report.md`); v2 regression sample untouched-and-green (`test_rest_framework` 879 OK, `test_apiv2_prefetch_rbac` 10 OK); `manage.py check` clean.
94+
- **Scalar docs-UI: deferred** — alpha keeps ninja's built-in Swagger at `/api/v3/docs`; vendoring a JS bundle into a security product's repo needs its own supply-chain review (swap = one template view + locally vendored asset, sidecar-style).
95+
96+
---
97+
98+
## Post-debrief update (same day, architect review of the gaps)
99+
100+
The architect reviewed the five "gaps" and directed changes; all landed on the branch:
101+
102+
1. **Note side-effects — CLOSED in alpha** (`5686fac`). The notes factory gained an
103+
`on_note_created` callback; `process_note_added` services fire the verified v2 side-effects
104+
per resource (finding: JIRA comment + `last_reviewed` + @mentions; engagement/test:
105+
@mentions only). 8 new tests.
106+
2. **Divergence analysis + fix proposal — delivered** as committed `API_V3_DIVERGENCE_ANALYSIS.md`
107+
(`e8a49ec`): 19 divergences verified in code, proposed canonical behavior per row for v3 AND v2
108+
AND the UI, v2-consumer impact assessment (1 potentially breaking / 4 behavioral / 12 invisible),
109+
sequencing across alpha/CONV1/CONV2. Its one confirmed **v3 regression (D17: delete-time JIRA
110+
sync silently skipped)** was fixed in the same commit as the notes work, with a pinning test.
111+
3. **Locations URL-only** — reclassified in the docs page as a *platform limitation*, not a v3 gap.
112+
4. **Bulk/workflow actions** — recorded as an explicit architect-confirmed **post-alpha OS backlog**
113+
(checkbox TODOs) in plan §6.
114+
5. **Approximate counts** — reclassified in the docs page as a *design decision to be aware of*.
115+
116+
## Open items for the architect
117+
118+
1. **Draft PR to `dev`**.
119+
2. **Scalar swap** — pending supply-chain review; not blocking.
120+
3. **Convergence track** — CONV1 (v2 serializers → services), CONV2 (UI views → services; the OS3b divergence table + OS5 side-effect gap are the worklist), CONV3 (delete dead duplicates).
121+
4. Minor deferred additions logged in §12: PUT (full replace), delete-time `push_to_jira` param, `configuration_permissions` on user writes, a v3 self-profile endpoint, filter vocabularies for the edge sub-resources.
122+
5. **TODO (architect-confirmed): port the v2 endpoint-level test corpora to v3** — priority: the import/reimport scenario corpus (`test_import_reimport.py` mixin, `test_apiv2_scan_import_options.py`, `test_importers_closeold.py`) via a **dual-endpoint adapter** (parametrize the existing mixins with a client shim mapping v2 field names to v3's `asset_name`/`organization_name`/`mode=` form) rather than copying — a copy would fork the corpus and drift; then the JIRA push flows; then a scoped pass over the rest of `test_apiv2_*` (much covers surfaces v3 deliberately lacks in alpha). Recorded in plan §6 post-alpha backlog.
123+
5. Operational notes: the two env-gated harnesses run via `docker compose exec -e ...` (`run-unittest.sh` has no env passthrough); `.claude/` was made world-writable so the containerized harness could write reports there.

0 commit comments

Comments
 (0)