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 effect — disclosure, 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.