improving-drf-endpoints — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited improving-drf-endpoints (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Serializer fields are the source of truth for PostHog's entire type pipeline:
Django serializer → drf-spectacular → OpenAPI JSON → Orval → Zod schemas → MCP toolsEvery help_text, every field type, every @extend_schema annotation flows downstream. A missing help_text means an agent guessing at parameters. A bare ListField() means z.unknown() in the generated Zod schema. Getting the serializer right means every consumer — frontend types, MCP tools, API docs — gets correct types and descriptions automatically.
Serializer or ViewSetBefore diving into Python, look at the committed generated types to see what's broken. Find the generated files for the endpoint's product:
frontend/src/generated/core/products/<product>/frontend/generated/Each has two files:
unknown types (bare ListField/JSONField), missing JSDoc descriptions (missing help_text), or overly generic Record<string, unknown> shapes.@extend_schema.This tells you exactly which fields and endpoints to prioritize.
Work through this list for every serializer and viewset you touch.
child= with a typed serializer or field@extend_schema_field(TypedSchema)get_* methodformat, type, status, kind, level, mode, state, platform, provider clash with existing choices and fail CI under --fail-on-warn; pick a specific name or add an ENUM_NAME_OVERRIDES entry up front (see serializer-fields.md)See serializer-fields.md for patterns and examples.
ModelViewSet with serializer_class is auto-discovered; plain ViewSet is notget, post, create, list), not on a helper or the class itselfOpenApiResponse(response=ErrorSerializer), not OpenApiTypes.OBJECTpagination_class=None on custom actions that don't paginateserializer.is_valid() + @extend_schema — it handles both in one decoratorproducts/<name>/backend/ are auto-attributed via module path; ViewSets in posthog/api/ or ee/ aren't and must declare attribution explicitly via the x-product extension. Accepts a plain string ("product_analytics") or ProductKey.X enum (kebab values are normalized). Don't use tags=["<product>"] to influence codegen routing — tags is for Swagger UI display only. Without x-product, the MCP scaffold and frontend type generator can't route the endpoint to the right productextend_schema(request=CustomSerializer) replaces drf-spectacular's inference from serializer_class; omitted fields disappear from OpenAPI, frontend types, and MCP tool schemas even when the runtime serializer still accepts them. After changing the override, run hogli build:openapi and verify generated MCP tool schemas still expose every OpenAPI body fieldStreaming endpoints: For SSE or streaming responses, use @extend_schema(request=InputSerializer, responses={(200, "text/event-stream"): OpenApiTypes.STR}) to document the request schema even though the response can't be fully typed.
See viewset-annotations.md for patterns and examples.
PostHog briefly split projects and environments as separate concepts then rolled the split back. `/api/projects/:team_id/...` is the canonical path for any team-nested endpoint. /api/environments/:team_id/... is a backward-compat alias preserved only for clients that integrated against it during the split.
For a new team-nested endpoint, register it under routers.projects. Routes live in each product's own products/<name>/backend/routes.py, in a register_routes(routers) function:
# products/<name>/backend/routes.py
from posthog.api.routing import RouterRegistry
def register_routes(routers: RouterRegistry) -> None:
routers.projects.register(r"my_thing", MyThingViewSet, "project_my_thing", ["team_id"])Product routes are auto-discovered — posthog/api/__init__.py iterates INSTALLED_APPS and calls register_routes(routers) on every products.* app that has a routes.py. Adding a product needs no edit to core: create products/<name>/backend/routes.py and make sure the product is in PRODUCTS_APPS (posthog/settings/web.py). Only core, non-product viewsets still register directly in __init__.py.
Why core discovers and calls the product (not the product calling core). Core registers the four parents (root + projects/environments/organizations) first, then runs the discovery loop. Products only nest onto those parents and never onto each other, so discovery order is irrelevant. The registration is kept eager (it runs when posthog.api is first imported, i.e. on the first request) and deliberately _not_ moved into AppConfig.ready(): ready() runs inside django.setup() in every process, and registering a route imports its viewset, so that would pull the whole API into setup() everywhere — regressing the laziness that keeps the API out of Celery workers and management commands. See the RouterRegistry docstring and the discovery loop in posthog/api/__init__.py for the full reasoning.
Do not register new endpoints under environments_router. Do not use the dual-route helper (routers.register_legacy_dual_route, or register_legacy_dual_route_team_nested_viewset in __init__.py) — it exists only for endpoints already exposed on both /api/projects/ and /api/environments/ before the rollback.
If existing clients need /api/environments/... too, the OpenAPI postprocess hook at posthog.api.documentation.preprocess_exclude_path_format auto-marks the env-side path as deprecated: true whenever both routes exist.
For products using the facade pattern (e.g., visual_review) with DataclassSerializer wrapping frozen dataclasses from contracts.py:
@extend_schema tags and descriptions still need to be set on viewset methodsdigraph audit {
rankdir=TB
node [shape=diamond fontsize=10]
edge [fontsize=9]
start [label="Serializer or\nViewSet file?" shape=box]
is_model [label="ModelViewSet with\nserializer_class?"]
is_plain [label="Plain ViewSet or\ncustom @action?"]
is_facade [label="DataclassSerializer\n(facade product)?"]
check_fields [label="Check fields:\nhelp_text, ListField,\nJSONField, ChoiceField" shape=box]
add_schema [label="Add @validated_request\nor @extend_schema to\nevery method" shape=box]
check_help [label="Focus on help_text\nand response declarations" shape=box]
check_responses [label="Check response types,\npagination, error schemas" shape=box]
start -> is_model
is_model -> check_fields [label="yes"]
is_model -> is_plain [label="no"]
is_plain -> add_schema [label="yes"]
is_plain -> is_facade [label="no"]
is_facade -> check_help [label="yes"]
check_fields -> check_responses
add_schema -> check_fields
check_help -> check_responses
}See quick-reference-table.md for a scannable "I see X, do Y" lookup.
See common-anti-patterns.md for before/after code pairs.
posthog/api/alert.pyproducts/tasks/backend/api.pyproducts/llm_analytics/backend/api/evaluation_summary.pyproducts/visual_review/backend/presentation/views.pyimplementing-mcp-tools skill to scaffold MCP toolsdocs/published/handbook/engineering/type-system.mdposthog/api/mixins.py (@validated_request source)posthog/settings/web.py (SPECTACULAR_SETTINGS)python manage.py find_enum_collisions — finds unresolved collisions and suggests overrides~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.