Skip to content

Threats

Every touchpoint, store, party and flow against pytm's threat library: a matrix of cells, each never, dismissed by a rule, open for a reviewer, stamped, or a !missing finding weighed by severity.

The threat model is a projection of the folder, not a new declaration. Every touchpoint is a process (one node each), every store a store, the actors (the person, staff, anyone, the system) and the parties with transfers are parties, and flows join them: actor → touchpoint, touchpoint → store (its ops), touchpoint → party (transfers), front route → api operation (calls), touchpoint → task (defers). A flow carries the declared items and their sensitivity.

The catalogue is pytm's threat library (knowledge/threats/<SID>.yaml, generated by threats gen — a developer command that refuses a pytm threat _mapping.yaml does not classify). The mapping says how each threat is treated: never (impossible in our stacks — memory-safe runtimes, no PHP/LDAP/SOAP — or infra we do not model — TLS, HTTP smuggling, hosting), or a list of dismissal rules (_rules.yaml) any of which closes the cell for an element. Rules are SIMPLE facts: the touchpoint has no request (a task, a bare GET), returns JSON not HTML, has no mark_safe/{@html} in its files, no .raw(, no subprocess, no XML parser, no file input, is not cookie-authenticated (CSRF), is public by design (ownership does not apply), the flow carries no personal item, no credentials. No AST, no reasoning: when a rule cannot decide, the cell is open and belongs to an agent under the threat's topic (access, auth, input, disclosure, dos, files, xss, csrf, credentials, store).

uv run model-wtf compliance threats matrix            # counts per kind and topic
uv run model-wtf compliance threats matrix --open     # every open cell
uv run model-wtf compliance threats why api:getOrder  # each threat: in/out and the rule

check folds the open cells into one Review line per unit (items unit:id#SID ride on it for the gate). Undeclared touchpoints do not count yet: their flows are unknown until they are reviewed.

Stamps

An open cell is closed by a stamp in the element's own YAML (touchpoint manifest, stores/<slug>.yaml, parties/<id>.yaml):

threats:
  AC01: {status: mitigated, note: "get_object_or_404(user=request.user) api.py:245"}
  DO02: {status: accepted, note: "list capped at 50 by CursorPagination"}
  HA01: {status: n/a, note: "photo id is a UUID looked up in the DB; no path built"}
  DS06: !missing "returns payment_method to anonymous callers (auth=None)"
  DS06@party:mapbox: {status: mitigated, note: "only the position is sent"}

mitigated (the code handles it; cite where), accepted (the risk owner accepts; say why), n/a (the rule could not tell but the threat does not apply here). A key is a SID — the element and every flow it is part of — or SID@<other end> for one flow. !missing is a finding: Missing (threat-missing, origin claimed/declared like rights). Stamps written by the tool carry the element's fingerprint; when the code moves the stamp is stale and the cell reopens. Re-declaring a touchpoint keeps its stamps; the challenger's reviews tool lists them as assertions to re-check.

uv run model-wtf compliance threats stamp api:getOrder AC01 --status mitigated --note "orders/api.py:283 scoped to request.user"
uv run model-wtf compliance threats stamp api:getOrder DS06 --missing "payment_method returned to anonymous callers"
uv run model-wtf compliance threats stamp api:checkout->api:db-default DS06 --status n/a --note "the app's own database"

Severity

A !missing stamp is weighed by the tool, not the agent: impact × likelihood. Impact is the effectdisclosure, tampering, destruction, denial, escalation, repudiation (_mapping.yaml carries one per threat; ops resolves from what the touchpoint does) — at a degree (existence 0.25 < attribute 0.5 < record 1 < bulk 1.5, inferred: lists, exports, admin screens, tasks and integer ids are bulk) on the most sensitive item reached (public 0 … special 4); escalation counts 4, denial is capped at 2. Likelihood is the most feared actor the touchpoint's scope lets in — anonymous, subject, staff, system, each with a malice and a reach in knowledge/threats/_actors.yaml, overridable in compliance/actors.yaml — minus the actors already entitled to that data through another declared touchpoint (staff counting pictures they see in the back-office is not a finding). Buckets: critical / high / medium / low / info. The reviewer may only narrow (--degree existence, --effect denial, --actor subject) with a reason; check tags and sorts findings by risk.

Every finding gets a stable id, F-0042, allocated in compliance/findings.lock.yaml on first sighting and never reused (a fixed finding is closed with a date, not deleted, so a ticket citing it still resolves). threats findings lists them most severe first — several threats with the same evidence on one element fold into one row — and threats why F-0042 explains one: weight, who, data, evidence, the threat's description and mitigations. A declared, safeguarded transfer to a party is the intended use, not a leak: disclosure threats on that flow are dismissed by rule.

The swarm

uv run model-wtf compliance threats auto-review --unit api            # one reviewer per topic
uv run model-wtf compliance threats auto-review --by touchpoint       # one reviewer per touchpoint
uv run model-wtf compliance threats auto-review --elements api:getOrder,api:checkout

auto-review sends agents to stamp the open cells. By default one reviewer per topic (access, auth, input, disclosure, dos, files, xss, csrf, credentials, store, llm; knowledge/threats/_topics.yaml carries each checklist) over a batch of touchpoints (--topic-batch, 12): the same question asked of each touchpoint, answered from its code with threat_stamp. --by touchpoint sends one reviewer per touchpoint with all its open SIDs instead. Measured on Food@Home (14 subject-facing endpoints, same commit): per topic closed every cell for 49k tokens and found the unscoped Cart/Order lookups; per touchpoint spent 109k tokens, stalled on 12 of 14 and missed them — small models do better with one narrow question than with fifteen. The reviewer never reclassifies data or edits ops; a stamp it cannot justify with a file:line stays open.

Introspection payloads are cached under .git/model-wtf/introspect/, keyed by the source tree (paths, sizes, mtimes): a swarm of MCP servers boots Django once, not once per tool call. MODEL_WTF_NO_CACHE=1 bypasses.