isolating-product-facade-contracts — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited isolating-product-facade-contracts (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.
Use this skill to migrate an existing product to the isolated architecture used by Visual review. Optimize for short calendar exposure, not small diffs: authoring is cheap and the verification chain catches mechanical breakage, while human review latency and a fast-moving master are the real bottlenecks. Default to one PR structured in reviewable commits; a separate facade-first PR exists only as the merge base for team-sliced sweeps — per the PR strategy below.
Prerequisite: the product must already live under products/<name>/. This skill does not cover moving code out of posthog/, ee/, or other shared directories — do that first.
Read these before changing code:
Use Visual review as the concrete reference implementation:
Before changing code, get the baseline:
hogli product:maturity <name> # scores models, facade, presentation, boundaries, codegen
hogli product:lint <name> # structural lint + isolation chain (strict if facade/contracts.py exists)
hogli product:isolate:scan <name> # the recon: import map, coupling gate, preflight (see below)First, read the baseline to learn which migration phase you're in — you don't start knowing whether this is untouched or mid-flight, and that decides where you pick up. The signals are all in the output above; there is no status file to consult (a hand-kept one would drift — the maturity score plus the ignore_imports TODO set _is_ the status, and lint-imports prunes the latter as modules finish):
backend/facade/, no ignore_importsentries for the product. Start at step 1.
facade/contracts.py + facade/api.py exist but core still importsinternals (scan still lists model-access/etc., or product:lint warns has legacy interface leaks). The facade design is settled; continue migrating callers (step 4) — don't redesign it.
pyproject.toml still hasignore_imports TODO entries for the product. The sweep is done; resume the presentation wave on exactly those deferred modules.
ignore_imports entries left. Verify againstthe done criteria instead of changing anything.
The scan does the recon that would otherwise cost a dozen ad-hoc greps, and is the source of truth for the rest of the flow:
classified (model-access, query-runner, celery-task, temporal-wiring, test-fixture, string-reference) with the facade pattern each kind maps to. This is the sweep checklist; string references (@patch(...) mock paths, dotted names in config) are included, which import-oriented grepping misses.
migration fits the single-PR default; triple digits means the caller sweep needs slicing by owning team.
product:lint switches from lenient to strict the momentfacade/contracts.py exists; the scan runs the structural checks in forced-strict mode so those demands (root tsconfig.json, tasks.py at tasks/tasks.py, only canonical backend/ subdirectories) surface up front instead of mid-migration.
ignore_imports allowlist size.backend.* imports only. A coupling count ofzero is necessary, not sufficient: it can't see product-root packages core imports (dags/) or non-import channels (config dotted strings, hogql system tables, in-process API test dispatch). See "Clearing coupling the scan won't show" below before declaring a product done.
--json emits the machine-readable recipe — keep it with the PR; regenerating the migration against fresh master starts from a fresh scan.
Don't quietly hand-work-around it. If isolate:scan/:move misses your product's shape — a layout it doesn't detect, a coupling channel it can't see — and other products share that shape, extend the tool and update this skill so the next migration inherits the fix. That's how backend/api/-subpackage support and the Dagster-assets channel got added. Reserve a manual work-around (called out in the PR) for a true one-off; a silent hand-hack just leaves the next product to hit the same wall.
The core-coupling count sizes the sweep — zero importers means a single-PR migration with no caller migration — but it does not decide whether to isolate. The facade is also the structural seal that makes the CI skip _sound_, and that has nothing to do with importer count:
dispatches into the view stack in the same process, not over a real socket). Cross-cutting tests — permissions, schema, activity-log, "every viewset does X" — reach a product's endpoints by URL, coupling to its live behavior with zero imports. tach, lint-imports, and this scan all read the import graph, so none of them can see it. "No importers" is necessary, not sufficient.
keep presentation thin and reaching internals only through the facade, so every observable behavior lives in the facade (tested in-product, inside the boundary) or in the serializer shape (the OpenAPI schema, whose changes already force the full suite); and keep behavior tests in-product.
So a product whose only consumers are over HTTP (node services, the TS/MCP codegen) is not facade-optional — there the facade's whole job is sealing its own presentation (step 2's second demand source). The genuine exception is a product with essentially no Django-side logic (a thin shim over an external service): it has nothing to seal, but it is then simply _not isolated_ — no backend:contract-check, still paying the full suite. That is an accept-the-cost choice, not "isolated without a facade".
This is the third non-import coupling channel, alongside hogql system tables and dotted-string config (step 6.4) — and the least visible, since the other two at least leave a string to grep for.
product:isolate:scan walks the import graph of backend.*. Three kinds of coupling escape it — none is a dead end, each has a defined move. After the backend sweep, git grep "products.<name>" (not just .backend) and read the scan's string-reference section to find them.
Test-infrastructure coupling. Core tests reach into the product's test helpers, which no facade re-export naturally covers:
(e.g. posthog/test/base.py patches execute_hogql_query on each runner module). Re-export the module object through the facade (facade/queries.py re-exports web_overview, not just its class) and point the patch at the facade path.
base. Decide ownership: if the fixture is infrastructure for a core concern (a preaggregated-table test base, and the tables live in core), move it down into core and have both sides import it downward; if the core test is really exercising the product's behavior, relocate the test into the product. Either way the cross-boundary test import disappears.
Surfaces outside `backend/`. A product can expose non-Django surfaces at its root — Dagster assets under products/<name>/dags/, for instance — that core imports directly. The scan only walks backend.*, so it won't list them, and tach / product:lint fail late with "not part of the public interface" (a direct interface exposure also trips the legacy-leak check). Re-export them through a facade submodule (facade/dags.py re-exporting the asset modules) and reroute the core importer, exactly like the temporal and query-runner wiring.
No external data consumers. Covered above — the facade serves the product's own views. Provide facade read functions for its models; wire the cheap views; defer the expensive ones (nested-serializer or transactional viewsets) as named ignore_imports for the presentation wave. Providing the facade function while deferring its caller is a legitimate intermediate state, not a half-migration.
The web_analytics migration is the worked example of all three: its preagg test base moved down to core, its timezone integration test moved into the product, its Dagster assets gained a facade/dags.py, and its filter-preset reads landed in facade/api.py with the viewset deferred.
logic.py.team_id in querysets.Team; use a Team Extension model.@validated_request or @extend_schema).hogli build:openapi) when serializer/view changes affect API schema.presentation must use facade import-linter contract in pyproject.toml (tool.importlinter). Any new internal module (cache.py, helpers.py, …) is auto-covered; there is no blocklist to maintain. New cross-cutting imports must either go through the facade or be temporarily allowlisted there.
models, logic, presentation, non-facade modules).thick views this share may be deferred — but name the deferral; it decides whether the isolation you turn on is sound or optimistic (see step 5).
backend/facade/contracts.py using pydantic.dataclasses.dataclass — same shape as the stdlib variant but with runtime type validation on construction.backend/facade/api.py.schedule constants, query-runner registry hooks, Dagster assets) crosses the boundary as objects, not data — give each its own facade submodule (facade/tasks.py, facade/temporal.py, facade/queries.py, facade/dags.py) re-exporting exactly what core touches. Registry-style consumers dispatch on class identity (isinstance), so re-export the class itself; monkeypatch consumers need the module object, so re-export that too. Keep facade/api.py free of heavy imports (HogQL, temporalio) so config-only consumers don't drag them onto the django.setup() path — and split light shared tables (facade/hogql.py) out of the heavy runner re-exports for the same reason.
verification chain; batching it only stretches the window in which master drifts under the branch.
"Model") at runtime plus a TYPE_CHECKING` import for annotations — tach ignores type-only imports.
needs them. If one is unavoidable, it dies in the final cleanup PR, not "later".
semantics) may move in their own small PR — see the PR strategy below.
in this PR or defers. Estimate the wave per module: runner-execution swaps are cheap (runners already return posthog.schema types — no new contracts needed); plain model CRUD needs a small contract plus a few functions; transactional or cross-product logic embedded in serializers is the expensive tier. Swap every module whose wave is cheap; defer only the expensive ones, naming the reason and the surviving ignore_imports entries in the PR description.
ignore_imports entries in pyproject.toml'sTODO section. That debt gates the skip: while any entry remains the product must NOT enable backend:contract-check (step 6), so the full suite keeps running and there is no coverage hole. This is the soundness boundary — the skip only re-runs the suite on facade/presentation changes, so it is honest only once presentation is thin. A thick deferred view reaches internals directly, so an internals change flowing to HTTP through it would slip past the skip (and past the cross-cutting core tests that exercise the product by URL with zero imports). hogli product:lint enforces the gate — contract-check while ignore_imports entries exist fails lint. The skip is the reward the presentation-wave PR unlocks when it empties them.
hogli product:isolate:move <name> (run--dry-run first) moves the ViewSet modules into presentation/views/ (auto-detected, --views to override), tasks.py into a tasks/ package with celery names pinned (it re-exports the module's public surface, and warns when a _private_ tasks name is reached through the package path — e.g. a @patch("...backend.tasks._helper") target — since those need repointing to ...backend.tasks.tasks._helper by hand; auto-rewriting them would corrupt the pinned name= strings), and rewrites the dotted paths repo-wide. A backend/api/ subpackage moves whole: production helper subpackages (e.g. destination_tests/) keep their structure under presentation/views/ and ride the prefix rename, while test subpackages (test/, tests/) relocate to backend/tests/api/ — they leave the api namespace, so a naive prefix rewrite can't follow them. The move refuses if an api/ module is already mirrored at presentation/<stem>.py — that means a prior hand-migration left a compat shim, and moving it would duplicate the module; delete the shim and repoint its callers first, then re-run for the rest (error_tracking is the worked example).
than reinventing it: absolute fully-qualified paths and string references (@patch(...) mock paths) go through guarded word-boundary regex (lexically unambiguous); relative imports (from ..x import y, any depth) go through libcst (cst_helpers.py, ported from the product-model-migration import_rewriter) because resolving them needs the importing module's package, which a line regex can't know.
on the previous one, and hogli product:lint (via IsolationChainCheck) fails if any step is skipped:
backend/facade/api.py must have actual function defs,not just re-exports from logic.
regex in the existing shared [[interfaces]] block in tach.toml that exposes backend\.facade.* and backend\.presentation\.views.*. Only add a new dedicated block if the product needs a non-standard expose pattern.
package.json soturbo-discover treats the product as isolated. This is the last piece: add it only once the product is fully isolated — no ignore_imports entries left, presentation thin. hogli product:lint blocks the script while deferred entries remain, because the skip is unsound until then (see step 5).
backend:contract-checkinputs to backend/facade/** and backend/presentation/** so the Django suite is only re-run on facade/presentation changes (see products/visual_review/turbo.json). Widen the inputs when core depends on the product outside the import graph: add backend/models.py if hogql system tables expose the product's tables or core config references its dotted paths (the scan's string-reference section surfaces the latter). tach/import-linter only police the import channel; a mechanical check for these non-import channels is a known gap, noted and deferred.
tach check --dependencies --interfaces, lint-imports(import-linter contract for presentation → facade), and hogli product:lint <name>.
hogli product:maturity <name> for a detailed breakdown of remainingisolation work scored across models, facade, presentation, boundaries, codegen.
This is the exception path for high-coupling products whose caller sweep is sliced across teams (see PR strategy below) — the tach boundary turns on before every core import has moved, while the backend:contract-check CI switch must wait until the block is gone (hogli product:lint rejects the script while leaks remain). With the single-PR default, all core callers land in the same PR and this block never needs to exist.
If posthog/ or ee/ still imports product internals (backend.models, backend.oauth, …) when the isolation chain turns on, add a second [[interfaces]] block under the "Legacy leaks" section of tach.toml allow-listing exactly the modules core still touches. This keeps the build green while the remaining team-sliced PRs land. Shrink and delete that block as imports move behind the facade — the final cleanup PR removes it entirely. hogli product:lint flags any product that still has legacy leak interfaces with a ⚠ has legacy interface leaks warning.
The scarce resources are reviewer attention and calendar time — not authoring effort. Every open migration branch rots as parallel work lands, and every extra PR adds a serialized review window plus a full-suite CI run (the product isn't isolated yet, so each migration PR pays the full suite). Use the fewest PRs the merge topology requires, and let commit structure — not PR boundaries — organize review.
Default: one PR, structured in reviewable commits.
behavioral-parity tests. Pure additions; reviewers read this commit as the design.
verification chain carries it.
(hogli product:isolate:move), strict-lint fixes, tach interface. Add backend:contract-check + narrowed turbo.json here only if the product is fully isolated in this PR — no deferred view modules. If any module is deferred (ignore_imports entries remain), the skip can't turn on yet (product:lint blocks it), so it lands with the presentation-wave PR; this PR ships without the skip and keeps paying the full suite, which is sound.
Don't split the facade into its own PR for reviewability: new files cannot conflict regardless of which PR they sit in, so a standalone design PR buys no conflict protection — it only serializes two review windows and doubles the migration's CI cost. Worse, a facade with no callers can only be reviewed in the abstract; in one PR the caller diffs sit next to the design and justify (or indict) it. See user_interviews PR #59132 and the logs migration (#63180 + #63184, done as two before this was learned) for the shapes.
In the PR description, enumerate the behavior-bearing edits — write paths, transaction boundaries, serializer changes, anything renamed or with pinned compatibility (celery task names). That list is where review attention goes; everything else is sampled. If a write path is genuinely subtle, pull those few callers into their own small PR as the exception.
Treat the PR as regenerable, not rebasable: the import map plus the facade design fully determine the rewrite, so on conflict, regenerate the branch against fresh master instead of hand-resolving. Keep it open only for the review window.
A separate facade-first PR exists for exactly one reason: it is the shared merge base when the sweep must be sliced. Gate on the core-coupling count from the baseline:
machinery entirely.
is about to code against it — then slice the sweep by who owns the calling code (the consuming team/area, not the product's own owner), reviewed in parallel, never serially by capability. Add the legacy-leaks tach block (above) while slices land, but do NOT add backend:contract-check yet — hogli product:lint rejects that script while leaks remain. The final cleanup PR drops the block, ignore_imports TODOs, dead adapters, and shims, and only then enables contract-check + narrowed turbo.json inputs — the CI payoff for this path lands at the end.
Deferred view modules (step 5) add one follow-up on either path: the presentation wave — see the section below for its flow and outputs.
Trade-off to accept: one PR reverts coarser than several. That is fine because the mappers are behavior-preserving, the behavior-bearing edits are enumerated up front, and the whole PR is regenerable — weeks of serial PRs on a hot product cost more in conflicts and forfeited isolated-CI time than an occasional coarse revert.
The second demand wave on the same facade: the functions the product's _own views_ need, where the first wave served external consumers. Run it once per product, after the migration PR lands, working from the deferred modules listed in the PR (the scan's view-module table is the worklist).
Per deferred module:
take posthog.schema query objects and return the runner's response types — those are already framework-free, so usually no new contracts. Model-backed endpoints get contracts plus CRUD/capability functions (the DataclassSerializer pattern from visual_review).
ExecutionMode, query tagging,select_for_update caps, cross-product side effects) move behind the facade; HTTP concerns (request validation, response codes, report_user_action analytics) stay in the view.
functions, same as wave one.
ignore_imports entries — lint-imports must pass withoutthem. That deletion is the output that marks the module done.
This is a design PR, not a sweep: it carries the same review weight as the facade commit (the contract and function shapes are the long-lived API), so it gets its own PR rather than riding along with mechanical work. Refactoring a wave-one function while here (e.g. subsuming a runner builder under a run-function) is fine — its callers are few and the parity tests pin behavior. The product's done criteria below are only fully satisfiable once this wave has emptied the allowlist — and emptying it is what unlocks the skip: the PR that deletes the last ignore_imports entry is where backend:contract-check + narrowed turbo.json are finally added (product:lint blocks them until then). For a product that deferred modules, this is when it earns the CI skip.
Treat migration as complete only when:
backend/facade only.[[interfaces]] block in tach.tomlexposing backend.facade.* and backend.presentation.views.* — no legacy leak block remains.
tach check --dependencies --interfaces passes with no violations for this product.lint-imports passes with no `ignore_imports` entries left for this product in thepyproject.toml TODO section — entries are tracked architectural debt from deferred view modules; the presentation wave deletes them.
hogli product:lint <name> shows no legacy leak warning and the isolation chain is intact.backend:contract-check is present in package.json with turbo.json inputsnarrowed to backend/facade/** and backend/presentation/** (enables the CI skip). This is the last thing added — product:lint blocks it until the ignore_imports entries above are gone, so the skip only ever turns on for a fully isolated product.
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.