The Diagram That Argues Back
Build a local threat-model evidence pack to challenge product design claims.
Introduction
30 Second Summary
A confident product plan can hide unanswered questions about who sees customer data and where it travels. Those questions become expensive once the product is already built.
In this project, you will build a local evidence pack that tests six product claims about an AI ticket summariser. You will use pytm 1.4.0 to compare your predictions with generated results before briefing the product manager.
What You'll Build
Your finished evidence pack opens offline as a direct answer to the PM, showing which claims survived scrutiny and which risks still need an owner.
By the end of this project, you'll have:
- A local Git history that proves your twelve STRIDE predictions covering spoofing, tampering, repudiation, information disclosure, denial of service, and elevation of privilege were committed before the findings existed.
- An inspectable data flow diagram that maps the ticket summariser's components and moving data, with each trust boundary showing a change in trust.
- A self-contained HTML readout that traces each finding, a pytm rule match against a modeled element, to an owned decision while reporting both prediction scores with real counts.
- Secret Mission: Flip the summariser's third-party claim, compare the resulting findings, and ship the only AI-assisted artifact with zero untraceable claims from a tagged final commit.
Are there any prerequisites?
You'll need your existing editor and AI client. Setup verifies Python 3.11 or newer plus Git before installing pytm 1.4.0 in a local virtual environment.
Before We Start
Before any setup begins, this step locks in an evidence-led answer for the product manager inside one local repository with no remote. The evidence chain treats unclaimed pytm defaults as PM questions while placing predictions before tool output.
Initialize the Local Evidence Repository
A defensible design review needs an evidence chain that begins before the design documents or tool output exist. A local Git repository gives each later artifact a timestamped place in that chain.
This setup has a fixed five-minute budget. You will prepare an isolated Python environment with pytm 1.4.0. The repository stays local with no remote.
In this step, get ready to:
- Initialize one local Git repository with its evidence files.
- Prepare Python 3.11 or newer with pytm 1.4.0.
- Create the initial setup commit.
Initialize the repository and evidence files
The repository records when each artifact enters the evidence pack. Starting it now keeps later predictions separate from the findings they will be measured against.
- Start a five-minute timer for this setup phase.
- Press Cmd+Space on macOS or the Windows key on Windows to open your search bar.
- Type Terminal on macOS or PowerShell on Windows.
- Press Enter to open the terminal.
- Check whether Git is available by running this command:
git --version
What Does This Command Check?
This asks Git to print its installed version. A version line confirms that the command is available before you create the repository.
✔️ I see a Git version
Git is ready to record the local evidence chain.
ⓧ Command not found
Git must be available before the repository can record evidence. Use the official installation route for your operating system.
macOS
- Visit the official Git installation page for macOS.
- Follow one installation method listed on the page.
- Restart your terminal after the installation finishes.
Windows
- Visit the official Git installation page for Windows.
- Download the installer for your Windows system.
- Run the installer.
- Restart your terminal after the installation finishes.
Linux
- Visit the official Git installation page for Linux.
- Follow the package instructions for your Linux distribution.
- Restart your terminal after the installation finishes.
- Confirm the installation by running the version check again:
git --version
What Should This Confirm?
A printed version confirms that the new terminal can find Git. You can now create the local evidence repository.
If the command still fails, ask for help with your Git installation.
- Create a new folder on your Desktop named your local project folder using your operating system's file manager.
- Use your existing editor's folder picker to open your local project folder.
You should see your local project folder as the active folder in your editor. This keeps every project artifact inside one location.
- Use the terminal control in your editor's top menu to open an integrated terminal inside your local project folder.
- Initialize the local repository by running this command:
git init
What Does This Command Do?
This creates the hidden Git metadata inside your project folder. It keeps the history on your machine because no remote is configured.
Your folder is now a local repository. The evidence files can enter its history before any PM design or model output exists.
- Create .gitignore inside your local project folder using your editor's new-file control.
You should see .gitignore in your editor's file list.
- Add the virtual-environment ignore rule to .gitignore by pasting:
.venv/
Why Ignore This Folder?
The .venv/ folder contains packages generated for this machine. Ignoring it keeps the repository focused on evidence another reviewer can inspect.
- Save .gitignore.
- Confirm that the file contains only .venv/ on one line.
Cannot See the Ignore File?
Check that the filename begins with a dot. Confirm that your editor displays hidden files.
If the file still does not appear, ask for help creating the ignore file.
✔️ Awesome, I've got everything!
Your ignore file contains the single required entry. Keep it saved before continuing.
ⓧ I'd like to double check the full code
.venv/
What Should Match?
The complete file contains one ignored path. There are no extra entries.
The project checklist protects the fixed phase budgets. It also preserves every deliverable that must survive the final handover.
- Create TASKS.md inside your local project folder using your editor's new-file control.
- Add the setup and design sections by pasting:
# The Diagram That Argues Back
## Setup, 5 minutes
- [ ] Initialize one local Git repository with no remote.
- [ ] Create `.gitignore` containing `.venv/`.
- [ ] Verify Python 3.11 or newer and choose `python` or `python3` consistently.
- [ ] Create and activate `.venv`.
- [ ] Install and silently import pytm 1.4.0.
- [ ] Create the initial setup commit.
## Design, 10 minutes
- [ ] Write `docs/requirements-brief.md` with the six PM sentences.
- [ ] Write `docs/decision-records.md`.
- [ ] Write `docs/topology.md` with ATT&CK v19.2 and outside branches labeled.
- [ ] Write `docs/build-plan.md`.
- [ ] Write `docs/source-map.md`.
- [ ] Confirm all five design documents agree with the planned build.
- [ ] Write `claims.md` with one source for every explicit model property.
- [ ] Record every relevant unclaimed property at its default and create PM questions.
- [ ] Write twelve complete STRIDE answers in `predictions.md`.
- [ ] Paste evidence that design files exist while `out.json` and `dfd.dot` do not.
- [ ] Commit `predictions.md` before `out.json` exists.
What Do These Sections Track?
- The setup section records the environment evidence you are creating now.
- The design section protects the rule that claims and predictions must exist before output.
- Save TASKS.md.
- Confirm that the file ends with the prediction-order checkbox.
Missing a Checklist Section?
Check that the first line is the project title. Confirm that the setup and design headings each begin with two hash characters.
If the sections differ from the supplied checklist, ask for help comparing them.
- Add the build and validation sections below the final design checkbox by pasting:
## Build, 20 minutes
- [ ] Record a full 20 minutes spent on build work.
- [ ] Write claim-traced `model.py` with six elements, five flows, and four boundaries.
- [ ] Write plain-ASCII `template.md`.
- [ ] Write `check.py` with exactly six numbered checks.
- [ ] Generate untouched `out.json`.
- [ ] Generate untouched `dfd.dot`.
- [ ] Render `dfd.png` only if Graphviz is already present, otherwise retain DOT text.
- [ ] Run `check.py` before the register exists.
- [ ] Capture all six must-fail checks and `EXIT: 1` in the Step 3 screenshot.
## Validation, 6 minutes
- [ ] Check 1 output proves Ticket Database has a finding.
- [ ] Check 2 output proves Ticket Summariser has a finding.
- [ ] Check 3 output proves every finding has an allowed decision and owner.
- [ ] Check 4 output proves High and Very High findings have PM acceptance.
- [ ] Check 5 output reports recall with real numerator and denominator.
- [ ] Check 6 output reports unmatched rate and proves every unmatched prediction has a classified, owned ASK row.
- [ ] Capture all six passing checks and `EXIT: 0` in the Step 4 screenshot.
- [ ] Capture Git log evidence that `predictions.md` preceded `out.json`.
What Do These Sections Protect?
- The build section reserves the full twenty-minute build phase.
- The validation section preserves the required failing run plus the later passing run.
- Save TASKS.md.
- Confirm that the validation section contains one checkbox for each of the six checks.
Do the Check Numbers Look Incomplete?
The validation section needs one checkbox for each numbered check. It also needs separate checkboxes for the failing transcript and passing transcript.
If a checkbox is missing, ask for help checking the validation section.
- Add the close-out section below the final validation checkbox by pasting:
## Close-out, 4 minutes
- [ ] Flip only the third-party claim and preserve `out-flipped.json`.
- [ ] Confirm no AI prompt ran before close-out; discard any early AI output.
- [ ] Ship self-contained `readout.html` titled `The Diagram That Argues Back`.
- [ ] Carry the same title into the teach-back.
- [ ] Audit every readout, teach-back, and after-action claim to a design file or pasted result.
- [ ] Strike every untraceable claim and record `Untraceable claims: 0`.
- [ ] State each accepted risk as what is accepted and why, with an owner.
- [ ] State what remains undone and what each omission was traded for.
- [ ] Record actual phase times against 5/10/20/6/4.
- [ ] Confirm every artifact still exists, including all five design documents.
- [ ] Confirm every checklist item is ticked by a commit.
- [ ] Commit the finished evidence pack, tag that final commit as `threat-model-v1`, and make no later commit.
What Does Close-Out Preserve?
The close-out section protects claim traceability. It also preserves the final local handover.
- Save TASKS.md.
- Confirm that the final checkbox names the local tag threat-model-v1.
Is the Final Checklist Cut Off?
Check that the close-out section ends with the final commit and tag checkbox. Confirm that the section contains every supplied close-out item.
If the ending differs, ask for help checking the close-out section.
✔️ Awesome, I've got everything!
The complete checklist is in place. Keep TASKS.md saved before continuing.
ⓧ I'd like to double check the full code
# The Diagram That Argues Back
## Setup, 5 minutes
- [ ] Initialize one local Git repository with no remote.
- [ ] Create `.gitignore` containing `.venv/`.
- [ ] Verify Python 3.11 or newer and choose `python` or `python3` consistently.
- [ ] Create and activate `.venv`.
- [ ] Install and silently import pytm 1.4.0.
- [ ] Create the initial setup commit.
## Design, 10 minutes
- [ ] Write `docs/requirements-brief.md` with the six PM sentences.
- [ ] Write `docs/decision-records.md`.
- [ ] Write `docs/topology.md` with ATT&CK v19.2 and outside branches labeled.
- [ ] Write `docs/build-plan.md`.
- [ ] Write `docs/source-map.md`.
- [ ] Confirm all five design documents agree with the planned build.
- [ ] Write `claims.md` with one source for every explicit model property.
- [ ] Record every relevant unclaimed property at its default and create PM questions.
- [ ] Write twelve complete STRIDE answers in `predictions.md`.
- [ ] Capture evidence that design files exist while `out.json` and `dfd.dot` do not.
- [ ] Commit `predictions.md` before `out.json` exists.
## Build, 20 minutes
- [ ] Record a full 20 minutes spent on build work.
- [ ] Write claim-traced `model.py` with six elements, five flows, and four boundaries.
- [ ] Write plain-ASCII `template.md`.
- [ ] Write `check.py` with exactly six numbered checks.
- [ ] Generate untouched `out.json`.
- [ ] Generate untouched `dfd.dot`.
- [ ] Render `dfd.png` only if Graphviz is already present, otherwise retain DOT text.
- [ ] Run `check.py` before the register exists.
- [ ] Capture all six must-fail checks and `EXIT: 1` in the Step 3 screenshot.
## Validation, 6 minutes
- [ ] Check 1 output proves Ticket Database has a finding.
- [ ] Check 2 output proves Ticket Summariser has a finding.
- [ ] Check 3 output proves every finding has an allowed decision and owner.
- [ ] Check 4 output proves High and Very High findings have PM acceptance.
- [ ] Check 5 output reports recall with real numerator and denominator.
- [ ] Check 6 output reports unmatched rate and proves every unmatched prediction has a classified, owned ASK row.
- [ ] Capture all six passing checks and `EXIT: 0` in the Step 4 screenshot.
- [ ] Capture Git log evidence that `predictions.md` preceded `out.json`.
## Close-out, 4 minutes
- [ ] Flip only the third-party claim and preserve `out-flipped.json`.
- [ ] Confirm no AI prompt ran before close-out; discard any early AI output.
- [ ] Ship self-contained `readout.html` titled `The Diagram That Argues Back`.
- [ ] Carry the same title into the teach-back.
- [ ] Audit every readout, teach-back, and after-action claim to a design file or observed result.
- [ ] Strike every untraceable claim and record `Untraceable claims: 0`.
- [ ] State each accepted risk as what is accepted and why, with an owner.
- [ ] State what remains undone and what each omission was traded for.
- [ ] Record actual phase times against 5/10/20/6/4.
- [ ] Confirm every artifact still exists, including all five design documents.
- [ ] Confirm every checklist item is ticked by a commit.
- [ ] Commit the finished evidence pack, tag that final commit as `threat-model-v1`, and make no later commit.
What Should Match?
Compare every heading and checkbox with your saved file. The complete checklist covers setup, design, build, validation, and close-out.
Prepare Python and pytm
A virtual environment isolates pytm from other Python projects on your machine. This project requires Python 3.11 or newer because pytm 1.4.0 supports that range.
- Check the default Python command by running:
python --version
What Does This Check Prove?
The output identifies the Python version attached to python. You need version 3.11 or newer before creating the environment.
✔️ I see version 3.11 or higher
Python is ready. Use python consistently for every Python command in this project.
ⓧ I see an older version
This project needs Python 3.11 or newer. Install a supported release before creating .venv.
macOS
- Visit the official Python downloads page for macOS.
- Download a supported Python release.
- Run the downloaded installer.
- Restart your terminal after installation.
Windows
- Visit the official Python downloads page for Windows.
- Download a supported Python release.
- Run the downloaded installer.
- Restart your terminal after installation.
Linux
- Visit the official Python downloads page.
- Use the documented route that fits your Linux distribution.
- Restart your terminal after installation.
- Verify the supported version by running the command for your installed executable:
python
python --version
What Should This Confirm?
The terminal should report Python 3.11 or newer. Use python for the rest of the project.
python3
python3 --version
What Should This Confirm?
The terminal should report Python 3.11 or newer. Use python3 for the rest of the project.
ⓧ Command not found
Some systems expose Python through python3. Check that executable before installing anything.
- Check the alternate Python executable by running:
python3 --version
What Does This Alternate Check Do?
This requests the version through python3. A result of 3.11 or newer gives you the command to use throughout the project.
- Use python3 consistently if the alternate check reports version 3.11 or newer.
- Visit the official Python downloads page if neither executable provides a supported version.
Neither Python Command Works?
Install a supported release from the official Python downloads page. Restart your terminal when installation finishes.
If neither command works after installation, ask for help installing Python.
- Create .venv by using the tab for your selected Python command:
python
python -m venv .venv
What Does This Command Create?
Python creates an isolated environment in .venv. The ignore rule keeps this generated folder out of Git.
python3
python3 -m venv .venv
What Does This Command Create?
Python creates an isolated environment in .venv. The ignore rule keeps this generated folder out of Git.
You should see .venv in your editor's file list. Your editor may display the folder with a muted or ignored style.
- Activate .venv using the tab for your operating system:
macOS
source .venv/bin/activate
What Does Activation Change?
Your shell now directs Python package commands into .venv. This keeps pytm isolated from global packages.
Windows
Windows uses a different activation command for PowerShell and Command Prompt. Choose the terminal you opened.
PowerShell
.venv\Scripts\Activate.ps1
What Does Activation Change?
PowerShell now directs Python package commands into .venv. This keeps pytm isolated from global packages.
Command Prompt
.venv\Scripts\activate.bat
What Does Activation Change?
Command Prompt now directs Python package commands into .venv. This keeps pytm isolated from global packages.
Linux
source .venv/bin/activate
What Does Activation Change?
Your shell now directs Python package commands into .venv. This keeps pytm isolated from global packages.
Your terminal prompt should now show the virtual-environment name. That visible prefix confirms that project commands use the isolated environment.
Virtual Environment Not Active?
Confirm that your terminal is inside your local project folder. Use the activation command for your operating system.
If PowerShell blocks the activation script, use the supplied Command Prompt route. You can also ask for help activating the environment.
The first package installation can take a moment while pip downloads pytm. A short wait here is expected.
- Install the pinned pytm package by using the tab for your selected Python command:
python
python -m pip install pytm==1.4.0
What Does This Command Install?
This installs pytm 1.4.0 into the active virtual environment. Pinning the release keeps later model behavior tied to one ruleset.
python3
python3 -m pip install pytm==1.4.0
What Does This Command Install?
This installs pytm 1.4.0 into the active virtual environment. Pinning the release keeps later model behavior tied to one ruleset.
You should see package installation output finish without a failure. The package is now available through the active environment.
Package Installation Failed?
Confirm that the virtual-environment name still appears in your prompt. Check that your selected Python version is 3.11 or newer.
If the failure continues, ask for help installing pytm.
Before you run the import check, do you expect a success message or silence?
- Test the pytm import by using the tab for your selected Python command:
python
python -c "import pytm"
What Does Silence Mean?
A successful import prints nothing. The terminal returns to the prompt because Python loaded pytm without raising an error.
python3
python3 -c "import pytm"
What Does Silence Mean?
A successful import prints nothing. The terminal returns to the prompt because Python loaded pytm without raising an error.
That silent return proves that the pinned package imports successfully. Your isolated project environment is ready.
Import Check Shows an Error?
Confirm that the install command used the same Python executable as the import command. Check that .venv is still active.
If the import still fails, ask for help diagnosing the environment.
Graphviz remains an optional renderer. This setup does not install it.
Commit the setup evidence
The first commit anchors the repository before any PM design document or model output exists. The commit itself is enough for this setup checkpoint, so you do not need to copy terminal history into validation-log.md.
- Tick the completed setup items in TASKS.md.
- Save TASKS.md.
- Stage every unignored project file and create the initial commit by running:
git add .
git commit -m "Initialize local threat model evidence"
Git should print a new commit with the message Initialize local threat model evidence. That confirmation proves the repository can record the work you create next.
Commit Blocked by Missing Identity?
A new Git installation may not have an author identity yet. Configure one only if the commit reports that it cannot identify you.
- Set a name and email for this repository, then retry the commit by running:
git config user.name "[[GIT_COMMIT_NAME="name shown on this repository's commits"]]"
git config user.email "[[GIT_COMMIT_EMAIL="email shown on this repository's commits"]]"
git commit -m "Initialize local threat model evidence"
Help me complete the initial commit.
Your local evidence chain is established with one pinned environment and no remote. Next, you will commit the PM design and predictions before any model output exists.
Commit the PM Design and Predictions
Your local Git repository now protects each stage of the evidence chain. Tool output from pytm could still influence what you claim to have predicted.
This ten-minute design phase freezes the PM's design first. It also freezes your predictions before any findings exist.
In this step, get ready to:
- Write five design documents without adding unsupported product facts.
- Trace every planned assignment to a PM sentence before predicting twelve STRIDE outcomes.
- Prove the design artifacts predate the model outputs before committing them.
Create the copy-ready design pack
Step 3 turns this design pack directly into model.py. Treat each complete file below as the source of truth so the topology, properties, and later checks stay aligned.
- Start the fixed ten-minute design phase with a timer.
- Create a folder named docs inside the current repository using your editor's file explorer.
- Create docs/requirements-brief.md using your editor's new-file control.
- Fill the requirements brief by copying this complete file:
# Requirements Brief
- PM-1: A customer submits a support ticket containing personal data through a public web front end.
- PM-2: A support agent logs in from home through the public web front end.
- PM-3: The public web front end sends the ticket to a ticket API, which stores and retrieves ticket history in a ticket database.
- PM-4: The database holds personal data with access control.
- PM-5: The summariser is a third-party model that reads tickets and past tickets.
- PM-6: The summariser reads and returns a summary; it does not take actions.
What does this brief lock?
These six sentences are the canonical product brief for the rest of the project. Step 3's elements, data objects, flows, and explicit properties all trace back to them.
- Save docs/requirements-brief.md.
- Confirm the file contains exactly six numbered PM sentences.
- Create docs/decision-records.md using your editor's new-file control.
- Fill the decision record by copying this complete file:
# Decision Records
| ID | Decision | Reason | Consequence |
| --- | --- | --- | --- |
| DR-1 | Use pytm 1.4.0 for executable threat modeling. | Its LLM element can evaluate the ticket summariser design. | Tool findings remain tied to one pinned ruleset. |
| DR-2 | Model six elements inside four trust boundaries. | This is the smallest topology that represents PM-1 through PM-6. | Step 3 must not add an undocumented component or boundary. |
| DR-3 | Model exactly five logical exchanges. | These exchanges cover ticket submission, home login, API transfer, database history, and summarisation. | Step 3 must not invent another flow. |
| DR-4 | Set only properties traced in claims.md. | Unsupported security controls would hide questions for the PM. | Every unclaimed property stays at its pytm 1.4.0 default. |
| DR-5 | Commit the design before generating output. | Predictions must remain independent of tool findings. | out.json and dfd.dot must be absent from the design commit. |
How do these decisions prevent drift?
The records turn Step 3's model shape into a build contract. Any extra component, flow, or control needs a new PM-backed decision before it enters the code.
- Save docs/decision-records.md.
- Confirm the table contains five decisions covering the tool, topology, flows, properties, and evidence order.
- Create docs/topology.md using your editor's new-file control.
- Fill the topology document by copying this complete file:
# Topology
## Data flow diagram
```mermaid
flowchart LR
subgraph customer_side["Customer Side"]
customer["Customer (Actor)"]
end
subgraph agent_home["Agent Home"]
support_agent["Support Agent (Actor)"]
end
subgraph product["Product"]
public_front_end["Public Web Front End (Server)"]
ticket_api["Ticket API (Server)"]
ticket_database["Ticket Database (Datastore)"]
end
subgraph third_party["Third Party"]
ticket_summariser["Ticket Summariser (LLM)"]
end
customer -->|"Submit ticket"| public_front_end
support_agent -->|"Log in from home"| public_front_end
public_front_end -->|"Send ticket"| ticket_api
ticket_api -->|"Store and retrieve past tickets"| ticket_database
ticket_api -->|"Send ticket history and receive summary"| ticket_summariser
```
## Attack tree
- Compromise access to ticket data.
- Phish a support agent under T1566.
- Use valid accounts under T1078.
- Collect data from the ticket database under T1213.006.
- Manipulate summariser input, labeled Outside ATT&CK v19.2.
What must Step 3 reproduce?
Each Mermaid subgraph maps to one Boundary. The text before each parenthesized type is the exact element name.
Step 3 reproduces the five labeled arrows with five Dataflow objects. The attack-tree branches stay outside the executable topology and feed the final readout.
- Save docs/topology.md.
- Confirm the Mermaid source contains four trust-boundary subgraphs, six typed elements, and five labeled arrows.
- Confirm the attack tree retains three ATT&CK identifiers plus one branch labeled Outside ATT&CK v19.2.
- Create docs/build-plan.md using your editor's new-file control.
- Fill the build plan by copying this complete file:
# Build Plan
| Phase | Budget | Required output | Exit evidence |
| --- | ---: | --- | --- |
| Setup | 5 minutes | Local Git repository and pytm 1.4.0 environment. | Initial setup commit. |
| Design | 10 minutes | Five design documents, claims.md, and predictions.md. | Seven files present while out.json and dfd.dot are absent. |
| Build | 20 minutes | model.py, template.md, check.py, out.json, and dfd.dot. | Six-check run ends with EXIT: 1 before a register exists. |
| Validation | 6 minutes | threat-register.md and passing validation evidence. | Six checks pass and the run ends with EXIT: 0. |
| Close-out | 4 minutes | Readout, audit, final commit, and threat-model-v1 tag. | Untraceable claims equal 0 and no later commit exists. |
## Step 3 build contract
- model.py contains four boundaries, six elements, three data objects, five flows, and only claim-traced explicit properties.
- template.md stays plain ASCII and prints the model description plus finding ID, target, severity, and description.
- check.py emits exactly six numbered checks and writes real-count recall plus unmatched rate.
- out.json and dfd.dot remain untouched model outputs.
- threat-register.md stays absent until the required EXIT: 1 run is committed.
Why define the build contract now?
This contract gives Step 3 a concrete stopping point. The build is complete when these named artifacts and observable checks exist, which prevents new scope from entering during implementation.
- Save docs/build-plan.md.
- Confirm the phase budgets total forty-five minutes and the contract names every Step 3 artifact.
- Create docs/source-map.md using your editor's new-file control.
- Fill the source map by copying this complete file:
# Source Map
| Claim | Source |
| --- | --- |
| Product behavior and topology. | PM-1 through PM-6 in docs/requirements-brief.md. |
| Explicit model properties. | Assignment rows in claims.md. |
| Mermaid flowchart subgraphs and labeled edges. | https://mermaid.js.org/syntax/flowchart.html |
| pytm 1.4.0 was released on 6 July 2026. | https://github.com/OWASP/pytm/releases/tag/v1.4.0 |
| pytm 1.4.0 supplies the LLM element and bundled LLM threats. | https://github.com/OWASP/pytm/releases/tag/v1.4.0 |
| Enterprise ATT&CK v19.2 was released on 5 August 2026. | https://github.com/mitre-attack/attack-stix-data/blob/HEAD/index.md |
| T1566 is Phishing. | https://attack.mitre.org/techniques/T1566/ |
| T1078 is Valid Accounts. | https://attack.mitre.org/techniques/T1078/ |
| T1213.006 is Databases. | https://attack.mitre.org/techniques/T1213/006/ |
| Five-day and 42-day dwell-time figures. | https://www.dragos.com/blog/dragos-2026-ot-ics-cybersecurity-report |
## Model limits
| Limit | Evidence basis |
| --- | --- |
| Paper-model limit: this project evaluates declared elements and properties but contains no runtime observations. | model.py, out.json, and the local generation transcript. |
| Omitted-flow limit: a flow missing from the model cannot produce a modeled finding. | docs/topology.md compared with model.py and dfd.dot. |
| Bundled-rule limit: findings depend on conditions expressed by the pytm 1.4.0 rules. | https://raw.githubusercontent.com/OWASP/pytm/v1.4.0/pytm/threatlib/threats.json |
| Default-property limit: a default-driven finding creates a PM question and does not prove the implementation uses that default. | claims.md compared with the relevant pytm 1.4.0 defaults. |
| Device-visibility limit: this model contains no device-level operator elements or flows. | The element and Dataflow scope in model.py. |
What belongs in the source map?
The source map separates product claims from external reference facts. Its limit table gives the final readout exact evidence for what the model cannot prove.
Later audit rows can point to one source-map entry instead of relying on memory.
- Save docs/source-map.md.
- Confirm the file maps product claims, model assignments, Mermaid syntax, release facts, ATT&CK techniques, and dwell-time figures.
- Confirm the limit table covers the paper model, omitted flows, bundled rules, default properties, and device visibility.
Does a design file disagree with Step 3?
Compare names and counts against the supplied complete files. Keep the spellings Ticket Database and Ticket Summariser unchanged because the validator uses exact target names.
Help me compare my design pack with the Step 3 build contract.
Trace properties and freeze predictions
The design files define the system shape. The next two files define every explicit property used by Step 3 and the predictions that check.py parses later.
- Create claims.md beside TASKS.md using your editor's new-file control.
- Fill the claim map by copying this complete file:
# Claim Map
## Explicit assignments
| Assignment | Value | Source |
| --- | --- | --- |
| submitted_ticket.isPII | True | PM-1. |
| submitted_ticket.isStored | False | PM-1 describes submission, while PM-3 assigns storage to the database path. |
| stored_ticket.isPII | True | PM-4. |
| stored_ticket.isStored | True | PM-3 and PM-4. |
| ticket_database.storesPII | True | PM-4. |
| ticket_database.controls.hasAccessControl | True | PM-4. |
| summariser.isThirdParty | True | PM-5. |
| summariser.processesPersonalData | True | PM-4 and PM-5. |
| summariser.hasRAG | True | PM-5 states that the model reads past tickets. |
| summariser.hasAgentCapabilities | False | PM-6. |
## Relevant pytm 1.4.0 defaults and PM questions
| Property | Default | PM question |
| --- | --- | --- |
| ticket_database.controls.authorizesSource | False | Which request sources may access ticket history? |
| ticket_database.controls.handlesResourceConsumption | False | What limits database resource consumption? |
| ticket_database.controls.isEncryptedAtRest | False | How is stored ticket history encrypted? |
| public_front_end.controls.usesMFA | False | Does support-agent login require MFA? |
| public_front_end.controls.usesVPN | False | Does home access require a VPN? |
| summariser.processesUntrustedInput | True | Which ticket content is treated as untrusted? |
| summariser.hasSystemPrompt | True | How is the system prompt protected? |
| summariser.hasContentFiltering | False | Which input and output filters are required? |
| summariser.controls.providesConfidentiality | False | How is third-party processing kept confidential? |
| summariser.controls.encodesOutput | False | How is summary output encoded safely? |
How does this map control the code?
The first table accounts for every explicit data, database, and summariser property in Step 3. The second table preserves relevant defaults as questions, so a default-driven finding does not become a claim about the real system.
- Save claims.md.
- Confirm the file traces ten explicit assignments and records ten default-driven PM questions.
- Create predictions.md beside claims.md using your editor's new-file control.
- Freeze the twelve STRIDE answers by copying this complete file:
# Pre-Run STRIDE Predictions
| Asset | STRIDE | Answer | Identifiers | Classification | Reason |
| --- | --- | --- | --- | --- | --- |
| Ticket Database | Spoofing | NO | N/A | No predicted match | The brief does not define a database identity mechanism. |
| Ticket Database | Tampering | NO | N/A | No predicted match | No bundled rule is claimed for ticket modification. |
| Ticket Database | Repudiation | YES | LOCAL-DB-REPUDIATION | Outside pytm 1.4.0 | The brief does not define an audit trail for ticket access. |
| Ticket Database | Information Disclosure | YES | LOCAL-DB-DISCLOSURE | Outside pytm 1.4.0 | The database stores personal data, while encryption remains unspecified. |
| Ticket Database | Denial of Service | YES | DO02 | Rule-backed | Resource-consumption handling remains at its False default. |
| Ticket Database | Elevation of Privilege | YES | AC01 | Rule-backed | Source authorization remains at its False default. |
| Ticket Summariser | Spoofing | NO | N/A | No predicted match | The brief does not define a separate summariser identity. |
| Ticket Summariser | Tampering | YES | LLM01, LLM02, LLM07 | Rule-backed | Untrusted and retrieved content reaches an unfiltered model with a system prompt. |
| Ticket Summariser | Repudiation | YES | LOCAL-LLM-REPUDIATION | Outside pytm 1.4.0 | The brief does not define prompt or response audit evidence. |
| Ticket Summariser | Information Disclosure | YES | LLM03, LLM08 | Rule-backed | A third party processes personal data without claimed confidentiality or output encoding. |
| Ticket Summariser | Denial of Service | NO | N/A | No predicted match | No summariser availability rule is claimed from the brief. |
| Ticket Summariser | Elevation of Privilege | NO | N/A | No predicted match | PM-6 keeps agent capabilities disabled. |
How will the validator read this file?
The six columns match check.py exactly. It keeps each YES row, splits comma-separated identifiers, and preserves the classification plus reason for any prediction that does not match a finding.
- Save predictions.md.
- Confirm each scored asset has six STRIDE rows for exactly twelve complete answers.
- Confirm every YES row contains an identifier, classification, and reason.
✔️ My design pack matches the build
Your seven design artifacts now define the exact names, counts, properties, and prediction schema consumed by Steps 3 and 4. The Secret Mission consumes the attack tree, source map, and model limits in the final readout.
ⓧ I found a mismatch
Which value should win?
Use the supplied complete files as the design baseline. Step 3 must reproduce their four boundaries, six elements, three data objects, five flows, and exact target names.
Help me locate the mismatch without changing the prediction schema.
Prove the design came first
The decisive evidence is the state of the repository before model generation. One Python invocation checks the seven design artifacts while proving both output files are absent.
- Record the Python command selected during setup here: python or python3 selected during setup.
Before you run the check, which seven paths do you expect to report True? Which two paths should still report False?
- Run one Python invocation to check the design and output paths:
[[PYTHON_COMMAND="python or python3 selected during setup"]] -c "from pathlib import Path; paths = ('docs/requirements-brief.md', 'docs/decision-records.md', 'docs/topology.md', 'docs/build-plan.md', 'docs/source-map.md', 'claims.md', 'predictions.md', 'out.json', 'dfd.dot'); [print(f'{path}: {Path(path).exists()}') for path in paths]"
What does this check prove?
- The Path checks inspect the repository without creating or changing any artifact.
- The first seven results prove the design documents and prediction files exist.
- The final two results prove no model output could have influenced the predictions.
You should see True for all seven design artifacts. You should see False for out.json and dfd.dot.
Does a path show the wrong result?
A missing design path usually means the file name or its docs location differs from the required path. Correct that path in your editor before rerunning the same check.
If an output path reports True, remove that premature output through your editor's file explorer. The predictions commit must predate both outputs.
Help me diagnose my design-presence check.
- TASKS.md contains the completed design items.
- Save TASKS.md.
- Stage the complete design pack and record the required commit by running these commands:
git add .
git commit -m "Commit PM design and predictions before output"
What do these commands record?
- The first command stages every unignored file in the current repository.
- The second command records the design pack under the required commit message.
Keep this commit unamended. Its original place in local history is part of the evidence chain.
The terminal should confirm a new commit named Commit PM design and predictions before output. That confirmation locks your predictions ahead of any pytm output.
Did the commit omit a file?
Check that every required file is saved before staging. Unsaved editor changes cannot enter the commit.
If Git reports no staged changes, confirm you are still inside the initialized local repository. Confirm the files appear in its source-control panel.
Help me verify the design commit.
That's the evidence boundary secured. Next, you can build the model and let pytm argue with predictions that already exist.
Build the Model and Preserve the Failure
Your local Git history now freezes the PM's six sentences before any tool output exists. That design-first commit protects the evidence from hindsight.
This build turns those claims into a pytm model plus a validator that grades predictions against real findings. The evidence chain requires the first validation run to happen before any ownership register exists.
In this step, get ready to:
- Use a twenty-minute budget for the build phase.
- Create the claim-traced model plus its local outputs.
- Preserve the six-check ownership failure as committed evidence.
Build the claim-traced model
A data flow diagram maps the components plus the data moving between them. Four trust boundaries show where the level of trust changes.
Why keep the claim mapping close?
Every non-default property needs a nearby PM reference. This keeps the model tied to supplied claims while leaving unsupported properties at their pytm defaults.
- Start a twenty-minute timer for the build phase.
- Stop the timer when the build phase ends.
- Use your existing editor to create model.py inside the current local repository.
- Add the model foundation with this first code section:
from pytm import Actor, Boundary, Data, Dataflow, Datastore, LLM, Server, TM
TM.reset()
tm = TM(
"The Diagram That Argues Back",
description="A ticket summariser design supplied by the product manager.",
)
tm.isOrdered = True
customer_side = Boundary("Customer Side")
agent_home = Boundary("Agent Home")
product = Boundary("Product")
third_party = Boundary("Third Party")
customer = Actor("Customer", inBoundary=customer_side)
support_agent = Actor("Support Agent", inBoundary=agent_home)
public_front_end = Server("Public Web Front End", inBoundary=product)
ticket_api = Server("Ticket API", inBoundary=product)
ticket_database = Datastore("Ticket Database", inBoundary=product)
summariser = LLM("Ticket Summariser", inBoundary=third_party)
What does this model foundation do?
- The imports expose the pytm element types used by this model.
- The TM.reset() call clears prior in-process model state.
- The four Boundary definitions reproduce the four Mermaid subgraphs in docs/topology.md.
- The six elements use the names shown before each parenthesized pytm type in the diagram.
- Continue below the summariser definition with the PM-backed properties plus data flows:
# PM-4: The database holds personal data with access control.
ticket_database.storesPII = True
ticket_database.controls.hasAccessControl = True
# PM-5: The summariser is a third-party model that reads tickets and past tickets.
summariser.isThirdParty = True
summariser.processesPersonalData = True
summariser.hasRAG = True
# PM-6: The summariser reads and returns a summary; it does not take actions.
summariser.hasAgentCapabilities = False
submitted_ticket = Data("Submitted Ticket", isPII=True, isStored=False)
stored_ticket = Data("Stored Ticket and History", isPII=True, isStored=True)
summary = Data("Two-Line Summary")
Dataflow(customer, public_front_end, "Submit ticket", data=submitted_ticket)
Dataflow(support_agent, public_front_end, "Log in from home")
Dataflow(public_front_end, ticket_api, "Send ticket", data=submitted_ticket)
Dataflow(ticket_api, ticket_database, "Store and retrieve past tickets", data=stored_ticket)
Dataflow(
ticket_api,
summariser,
"Send ticket history and receive summary",
data=[stored_ticket, summary],
)
if __name__ == "__main__":
tm.process()
What do these claims control?
- The database assignments model the PM's personal-data plus access-control statement.
- The summariser assignments model third-party processing plus retrieval from past tickets.
- The agent-capability assignment records the PM's read-only design claim.
- The five Dataflow instances reproduce the five labeled arrows in docs/topology.md.
- Save model.py.
- Compare the complete file with the reference tab before generating output.
✔️ Awesome, I've got everything!
Your model now contains four boundaries, six elements, three data objects, and five flows. Keep every explicit assignment tied to its nearby PM comment.
ⓧ I'd like to double check the full code
from pytm import Actor, Boundary, Data, Dataflow, Datastore, LLM, Server, TM
TM.reset()
tm = TM(
"The Diagram That Argues Back",
description="A ticket summariser design supplied by the product manager.",
)
tm.isOrdered = True
customer_side = Boundary("Customer Side")
agent_home = Boundary("Agent Home")
product = Boundary("Product")
third_party = Boundary("Third Party")
customer = Actor("Customer", inBoundary=customer_side)
support_agent = Actor("Support Agent", inBoundary=agent_home)
public_front_end = Server("Public Web Front End", inBoundary=product)
ticket_api = Server("Ticket API", inBoundary=product)
ticket_database = Datastore("Ticket Database", inBoundary=product)
summariser = LLM("Ticket Summariser", inBoundary=third_party)
# PM-4: The database holds personal data with access control.
ticket_database.storesPII = True
ticket_database.controls.hasAccessControl = True
# PM-5: The summariser is a third-party model that reads tickets and past tickets.
summariser.isThirdParty = True
summariser.processesPersonalData = True
summariser.hasRAG = True
# PM-6: The summariser reads and returns a summary; it does not take actions.
summariser.hasAgentCapabilities = False
submitted_ticket = Data("Submitted Ticket", isPII=True, isStored=False)
stored_ticket = Data("Stored Ticket and History", isPII=True, isStored=True)
summary = Data("Two-Line Summary")
Dataflow(customer, public_front_end, "Submit ticket", data=submitted_ticket)
Dataflow(support_agent, public_front_end, "Log in from home")
Dataflow(public_front_end, ticket_api, "Send ticket", data=submitted_ticket)
Dataflow(ticket_api, ticket_database, "Store and retrieve past tickets", data=stored_ticket)
Dataflow(
ticket_api,
summariser,
"Send ticket history and receive summary",
data=[stored_ticket, summary],
)
if __name__ == "__main__":
tm.process()
- Use python3 in every command below if that is the executable you selected earlier.
- Generate the untouched JSON findings output by running this command:
python model.py --json out.json
You should see out.json appear in the repository. It contains pytm's findings for the model you just defined.
- Generate the untouched DFD text with a separate Python invocation:
macOS
- Generate the untouched DFD text by running this one-line command:
python model.py --dfd > dfd.dot
Windows
- Generate the untouched DFD text as UTF-8 without a byte-order mark by running this one-line PowerShell command:
$dot = python model.py --dfd; [System.IO.File]::WriteAllText("dfd.dot", ($dot -join [Environment]::NewLine), [System.Text.UTF8Encoding]::new($false))
Linux
- Generate the untouched DFD text by running this one-line command:
python model.py --dfd > dfd.dot
You should see dfd.dot beside out.json. Its four boundary labels, six element names, and five flows should match the Mermaid build contract.
Missing or unreadable output?
Confirm the activated .venv still appears in your terminal prompt. Check that both commands ran from the repository containing model.py.
Windows PowerShell 5.1 writes output redirected with > as UTF-16LE. If Graphviz reports syntax error in line 1 near an unexpected character, regenerate dfd.dot with the Windows command above.
Help me diagnose my model output files.
- Use your existing editor to create template.md inside the current repository.
- Add the plain-ASCII report template below:
# The Diagram That Argues Back
## System
{tm.description}
## Findings
{findings:repeat:- {{item.threat_id}} | {{item.target}} | {{item.severity}} | {{item.description}}
}
What does this template preserve?
The template prints the model description before repeating each threat identifier, target, severity, and description. Plain ASCII keeps the report portable across the supported local environments.
- Save template.md.
- Confirm the file ends with the closing brace beneath the repeated finding row.
✔️ Awesome, I've got everything!
Your report template carries the project title plus the four finding fields required by the evidence pack.
ⓧ I'd like to double check the full code
# The Diagram That Argues Back
## System
{tm.description}
## Findings
{findings:repeat:- {{item.threat_id}} | {{item.target}} | {{item.severity}} | {{item.description}}
}
A PNG is optional because Graphviz is only a renderer here. The DOT text remains the required diagram artifact.
- Check whether the optional renderer already works by running:
dot -V
✔️ I see a version
- Render the optional PNG by running this one-line command:
dot -Tpng dfd.dot -o dfd.png
You should see dfd.png appear beside the untouched DOT file.
ⓧ Command not found
Your required diagram is already available as dfd.dot. Keep that file and continue unless you want the optional PNG.
The active .venv only provides Python packages. The dot command comes from the separate Graphviz system program.
macOS
- Install Graphviz only if you want the optional PNG by running this one-line command:
brew install graphviz
- Render the PNG after installation by running this one-line command:
dot -Tpng dfd.dot -o dfd.png
You should see dfd.png appear beside dfd.dot.
Windows
Graphviz may already be installed outside PATH. Its standard executable path bypasses that problem.
- Render the PNG with the installed system executable by running this one-line PowerShell command:
& "C:\Program Files\Graphviz\bin\dot.exe" -Tpng dfd.dot -o dfd.png
You should see dfd.png appear beside dfd.dot if Graphviz is installed at the standard path.
- Install Graphviz only if that path is missing and you want the optional PNG by running this one-line command:
winget install --id Graphviz.Graphviz -e
- Render the PNG after installation by running the full-path command again:
& "C:\Program Files\Graphviz\bin\dot.exe" -Tpng dfd.dot -o dfd.png
You should see dfd.png appear beside dfd.dot.
Linux
- Install Graphviz only if you want the optional PNG by running this one-line command:
sudo apt install graphviz
- Render the PNG after installation by running this one-line command:
dot -Tpng dfd.dot -o dfd.png
You should see dfd.png appear beside dfd.dot.
Create the six-check validator
Step 2's predictions.md supplies the predicted threat identifiers for Ticket Database and Ticket Summariser. The validator compares them with the pytm findings in the out.json generated earlier in this step, then creates validation-results.md with real-count recall and unmatched-rate results.
- Confirm predictions.md remains beside claims.md in the current repository.
- Confirm out.json remains in the current repository.
- Use your existing editor to create check.py inside the current repository.
- Add the imports, scoring constants, and Markdown row parser:
import json
import sys
from pathlib import Path
SCORED_ASSETS = {"Ticket Database", "Ticket Summariser"}
ALLOWED_DECISIONS = {"MITIGATE", "ACCEPT", "ASK"}
PM_ACCEPTOR = "Priya Shah (PM)"
def markdown_rows(path):
file_path = Path(path)
if not file_path.exists():
return []
rows = []
for raw_line in file_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line.startswith("|"):
continue
cells = [cell.strip() for cell in line.strip("|").split("|")]
if not cells or cells[0] in {"Asset", "---"}:
continue
if all(set(cell) <= {"-", ":"} for cell in cells):
continue
rows.append(cells)
return rows
How does the parser prepare evidence?
- The constants restrict scoring to the two agreed assets plus the three allowed decisions.
- The markdown_rows() helper ignores table headers plus separator rows.
- The missing-file branch returns an empty list so absent evidence can become a failed check.
- Add the finding and prediction loaders below markdown_rows().
def load_findings(path):
payload = json.loads(Path(path).read_text(encoding="utf-8"))
return [
finding
for finding in payload.get("findings", [])
if finding.get("target") in SCORED_ASSETS
]
def load_predictions(path):
predictions = {}
for cells in markdown_rows(path):
if len(cells) < 6:
continue
asset, stride, answer, identifiers, unmatched_bin, reason = cells[:6]
if asset not in SCORED_ASSETS or answer.upper() != "YES":
continue
for identifier in identifiers.split(","):
threat_id = identifier.strip()
if threat_id and threat_id != "N/A":
predictions[(asset, threat_id)] = {
"stride": stride,
"bin": unmatched_bin,
"reason": reason,
}
return predictions
What enters the score?
The finding loader keeps only results attached to the two scored assets. The prediction loader keeps positive pre-run answers plus their identifiers, classifications, and reasons.
- Save check.py.
- Confirm load_predictions() appears directly below load_findings() in the saved file.
- Add the register loader plus output-formatting helpers below load_predictions().
def load_register(path):
entries = {}
for cells in markdown_rows(path):
if len(cells) < 7:
continue
asset, threat_id, severity, decision, owner, pm_acceptance, rationale = cells[:7]
entries[(asset, threat_id)] = {
"severity": severity,
"decision": decision.upper(),
"owner": owner,
"pm_acceptance": pm_acceptance,
"rationale": rationale,
}
return entries
def fraction(numerator, denominator):
percentage = 0.0 if denominator == 0 else 100 * numerator / denominator
return f"{numerator}/{denominator} = {percentage:.1f}%"
def emit(number, passed, evidence):
state = "PASS" if passed else "FAIL"
print(f"CHECK {number}: {state} | EVIDENCE: {evidence}")
return passed
How are decisions reported?
- The register loader indexes each future decision by asset plus threat identifier.
- The fraction() helper preserves the real numerator plus denominator beside the percentage.
- The emit() helper produces one numbered line with a PASS or FAIL state.
- Start main() below emit() with the finding plus ownership checks:
def main():
findings = load_findings("out.json")
found = {(item["target"], item["threat_id"]) for item in findings}
predictions = load_predictions("predictions.md")
predicted = set(predictions)
matched = predicted & found
unmatched = predicted - found
register = load_register("threat-register.md")
by_key = {(item["target"], item["threat_id"]): item for item in findings}
database_count = sum(target == "Ticket Database" for target, _ in found)
summariser_count = sum(target == "Ticket Summariser" for target, _ in found)
ownership_errors = []
acceptance_errors = []
for key in sorted(found):
finding = by_key[key]
entry = register.get(key)
if entry is None:
ownership_errors.append(f"missing row {key[0]} / {key[1]}")
continue
if entry["decision"] not in ALLOWED_DECISIONS:
ownership_errors.append(f"invalid decision {key[0]} / {key[1]}")
if not entry["owner"]:
ownership_errors.append(f"missing owner {key[0]} / {key[1]}")
if "High" in finding.get("severity", ""):
if entry["pm_acceptance"] != PM_ACCEPTOR:
acceptance_errors.append(f"{key[0]} / {key[1]}")
What does the first half of main check?
The function builds sets for found, predicted, matched, and unmatched identifiers. It then checks every finding for an allowed decision, a named owner, and the required PM acceptance.
- Save check.py.
- Confirm the first half of main() ends with the acceptance_errors update.
- Continue inside main() with the unmatched-prediction checks:
ask_errors = []
for key in sorted(unmatched):
prediction = predictions[key]
entry = register.get(key)
if not prediction["bin"]:
ask_errors.append(f"unclassified {key[0]} / {key[1]}")
if entry is None or entry["decision"] != "ASK" or not entry["owner"]:
ask_errors.append(f"missing owned ASK {key[0]} / {key[1]}")
How are unsupported predictions retained?
Each unmatched prediction needs a classification plus an owned ASK entry. This keeps gaps visible without deleting predictions to improve either metric.
- Continue inside main() with the exact six-check result list:
results = [
emit(1, database_count > 0, f"Ticket Database findings={database_count}"),
emit(2, summariser_count > 0, f"Ticket Summariser findings={summariser_count}"),
emit(
3,
not ownership_errors,
"all finding rows have allowed decisions and owners"
if not ownership_errors
else "; ".join(ownership_errors),
),
emit(
4,
not acceptance_errors,
"all High and Very High findings have PM acceptance"
if not acceptance_errors
else "missing PM acceptance: " + "; ".join(acceptance_errors),
),
emit(5, len(found) > 0, "recall " + fraction(len(matched), len(found))),
emit(
6,
not ask_errors,
"unmatched rate "
+ fraction(len(unmatched), len(predicted))
+ ("; all unmatched predictions have classified, owned ASK rows" if not ask_errors else "; " + "; ".join(ask_errors)),
),
]
What do the six checks prove?
- Checks 1 and 2 require findings on both scored assets.
- Checks 3 and 4 grade ownership plus PM acceptance.
- Checks 5 and 6 expose recall plus unmatched predictions with real counts.
- Save check.py.
- Count the emit() calls inside results. You should see exactly six.
- Finish main() with the result file plus exit status logic:
result_lines = [
"# Validation Results",
"",
f"Recall: {fraction(len(matched), len(found))}",
f"Unmatched rate: {fraction(len(unmatched), len(predicted))}",
"",
"## Unmatched Predictions",
]
for key in sorted(unmatched):
item = predictions[key]
result_lines.append(f"- {key[0]} / {key[1]}: {item['bin']}. {item['reason']}")
Path("validation-results.md").write_text(
"\n".join(result_lines) + "\n", encoding="utf-8"
)
exit_code = 0 if all(results) else 1
print(f"EXIT: {exit_code}")
return exit_code
if __name__ == "__main__":
sys.exit(main())
How does the validator finish?
The final section writes the two metrics plus every unmatched prediction to validation-results.md. The process exits with zero only when all six checks pass.
- Save check.py.
- Compare the saved validator with the complete reference before running it.
✔️ Awesome, I've got everything!
Your validator now loads the frozen predictions plus untouched model output. It can grade ownership without changing either source.
ⓧ I'd like to double check the full code
# Temporary placeholder.
Seeing a code mismatch?
Check that each helper appears once in the same order as the full reference. Confirm every block inside main() keeps four spaces of indentation.
Help me compare my validator with the supplied file.
Run and preserve the ownership failure
The repository still has no threat-register.md. This run tests how the evidence behaves before anyone owns the findings.
Before you run it, predict whether all six checks can pass without an ownership register.
- Keep threat-register.md absent for this run.
- Run the six-check validator from the current repository:
python check.py
You should see exactly six numbered CHECK lines followed by EXIT: 1. The command also creates validation-results.md with the calculated fractions plus unmatched predictions.
Why the failure belongs in evidence
The failure proves the validator found unowned work before a register could make the checks pass. Preserving this transcript shows the later ownership result came from an explicit change.
- Keep all six numbered checks and EXIT: 1 visible for the Step 3 screenshot.
- Tick the completed build items in TASKS.md.
- Note the build timer's actual elapsed time for the final after-action review.
Did the validator stop before all six checks?
A traceback points to a code mismatch. Compare the referenced line with the full check.py tab before running the validator again.
Confirm out.json plus predictions.md remain in the current repository.
Help me reach the intended six-check output.
- Stage the required build artifacts by running:
git add .
- Commit the preserved failure by running:
git commit -m "Preserve model output and required ownership failure"
You should see Git record a new commit named Preserve model output and required ownership failure. That commit now proves the ownership failure existed before any register was created.
That's the evidence chain working: your claims produced outputs, and your validator exposed the missing ownership. Next, you'll assign decisions plus owners without rewriting the predictions.
Own Findings and Prove Prediction Order
Your required failing run is safely committed. Its findings are pytm rule matches against the modeled elements.
This phase has a fixed six-minute budget. It turns each finding into accountable work without changing the original predictions.
Your local Git history then proves scoring was not hindsight. The predictions commit must appear before the commit containing out.json.
In this step, get ready to:
- Create an owned threat register for every generated finding and unmatched prediction.
- Rerun the six checks and preserve the complete passing transcript.
- Prove the predictions commit predates the output commit.
Own every finding and unmatched prediction
The register connects generated findings to your pre-run STRIDE answers. Each row records a decision that the PM can inspect.
A Python script can extract the evidence and enforce the table rules while you make the accountability decisions. It keeps out.json and validation-results.md unchanged.
- Create build_register.py beside model.py in the current repository by using your existing editor.
- Add the register constants and guarded input helper:
import argparse
import json
from pathlib import Path
# Match the exact scope and schema enforced by check.py.
ASSETS = {"Ticket Database", "Ticket Summariser"}
PM_ACCEPTOR = "Priya Shah (PM)"
HEADER = ["Asset", "Threat ID", "Severity", "Decision", "Owner", "PM Acceptance", "Rationale"]
DECISIONS = {"MITIGATE", "ACCEPT", "ASK"}
def clean_cell(value):
# Keep generated and typed values safe for one Markdown table cell.
return " ".join(str(value).replace("|", "/").split())
def ask(prompt, choices=None):
# Reject empty answers and unsupported decisions.
while True:
value = clean_cell(input(prompt))
if value and (choices is None or value.upper() in choices):
return value.upper() if choices else value
if choices:
print("Choose " + ", ".join(sorted(choices)) + ".")
else:
print("Enter a value before continuing.")
What does this section protect?
- The constants preserve the two scored assets, seven columns, allowed decisions, and PM acceptor.
- The clean_cell() helper flattens whitespace and replaces pipe characters before a value reaches the Markdown table.
- The ask() helper rejects blank answers plus unsupported decisions in interactive mode.
- Save build_register.py.
- Confirm the file starts with the argparse, json, and Path imports.
Seeing a syntax marker?
Check that every string uses matching straight quotes. Keep the body of ask() indented by four spaces.
Help me fix the first build_register.py section.
- Load the untouched generated findings by adding this function below ask():
def load_findings():
# Read only findings attached to the two scored assets.
payload = json.loads(Path("out.json").read_text(encoding="utf-8"))
findings = [
finding
for finding in payload.get("findings", [])
if finding.get("target") in ASSETS
]
keys = [(item["target"], item["threat_id"]) for item in findings]
if len(keys) != len(set(keys)):
raise ValueError("out.json contains a duplicate Asset and Threat ID pair.")
return findings
How are generated findings kept exact?
The loader keeps each finding dictionary intact. The later rows copy target, threat_id, and severity directly from out.json.
The duplicate check stops the script before two generated findings can claim the same register key.
- Save build_register.py.
- Confirm load_findings() ends with return findings.
Is the findings loader marked as invalid?
Keep the list comprehension and duplicate check inside load_findings(). Check that return findings has four leading spaces.
Help me repair the findings loader.
- Parse the unmatched evidence by adding this function below load_findings():
def load_unmatched():
# Read the validator's preserved Unmatched Predictions section.
lines = Path("validation-results.md").read_text(encoding="utf-8").splitlines()
marker = "## Unmatched Predictions"
try:
start = lines.index(marker)
except ValueError as error:
raise ValueError("validation-results.md has no Unmatched Predictions section.") from error
unmatched = []
for line in lines[start + 1:]:
if line.startswith("## "):
break
if not line.startswith("- "):
continue
pair, colon, detail = line[2:].partition(": ")
asset, slash, threat_id = pair.partition(" / ")
severity, dot, _ = detail.partition(".")
if not all((colon, slash, dot, asset.strip(), threat_id.strip(), severity.strip())):
raise ValueError(f"Cannot parse unmatched prediction: {line}")
unmatched.append((asset.strip(), threat_id.strip(), severity.strip()))
return unmatched
How are unmatched predictions preserved?
The parser starts at the exact section heading written by check.py. It copies the asset, threat identifier, and classification from each bullet.
A malformed bullet stops the build instead of silently dropping an unmatched prediction.
- Save build_register.py.
- Confirm load_unmatched() ends with return unmatched.
Is the unmatched parser failing?
Confirm validation-results.md still contains the exact ## Unmatched Predictions heading produced by the earlier validator run.
Help me inspect an unmatched prediction line.
- Collect the decisions and ownership details by adding this function below load_unmatched():
def build_rows(findings, unmatched, auto=False, owner=None):
# Show the source evidence before collecting or applying each response.
rows = []
for index, finding in enumerate(findings, start=1):
asset, threat_id, severity = finding["target"], finding["threat_id"], finding["severity"]
description = clean_cell(finding.get("description", "No description provided."))
recommendation = clean_cell(finding.get("mitigations", ""))
print(f"\nFinding {index} of {len(findings)}\nThreat ID: {threat_id}\nAsset: {asset}\nSeverity: {severity}\nThreat: {description}\nRecommendation: {recommendation or 'None provided.'}")
if auto:
decision = "MITIGATE" if recommendation else "ASK"
row_owner = owner
rationale = recommendation or "Review this finding because pytm supplied no mitigation."
print(f"Auto choice: {decision} / {row_owner}")
else:
decision = ask("Decision (MITIGATE, ACCEPT, or ASK): ", DECISIONS)
row_owner = ask("Owner name or role: ")
rationale = ask("Accepted risk, mitigation, or open question: ")
pm_acceptance = PM_ACCEPTOR if "High" in severity else ""
rows.append([asset, threat_id, severity, decision, row_owner, pm_acceptance, rationale])
for asset, threat_id, severity in unmatched:
print(f"\nUnmatched prediction\nThreat ID: {threat_id}\nAsset: {asset}\nClassification: {severity}\nRecommendation: No pytm finding matched this prediction.")
row_owner = owner if auto else ask("Owner name or role: ")
rationale = "Review this unmatched prediction with the named owner." if auto else ask("Unresolved question: ")
if auto:
print(f"Auto choice: ASK / {row_owner}")
rows.append([asset, threat_id, severity, "ASK", row_owner, "", rationale])
return rows
Which choices can be automated?
- Interactive mode shows each threat ID, title, severity, and pytm recommendation before you choose the decision, owner, and rationale.
- Automatic mode chooses MITIGATE when pytm supplies a mitigation. It copies that recommendation into the rationale cell.
- Automatic mode chooses ASK when no mitigation exists or a prediction has no matching finding. It never accepts risk on your behalf.
- High and Very High generated findings receive the required PM acceptance automatically.
- Save build_register.py.
- Confirm the generated loop and unmatched loop both append seven values to each row.
Does an interactive prompt keep repeating?
A repeated decision prompt means the previous entry did not match MITIGATE, ACCEPT, or ASK. Type one allowed value and press Enter. A repeated owner or rationale prompt means the previous answer was blank. Enter a non-empty answer to continue.
Help me understand a repeated register prompt.
- Add the register writer below build_rows(). It validates every row before replacing threat-register.md.
def write_register(rows, generated_count):
# Enforce the same coverage rules before writing the Markdown file.
if any(len(row) != 7 for row in rows):
raise ValueError("Every register row must contain exactly seven cells.")
if any("|" in str(cell) or "\n" in str(cell) for row in rows for cell in row):
raise ValueError("Register cells cannot contain pipes or newlines.")
generated_keys = [(row[0], row[1]) for row in rows[:generated_count]]
if len(generated_keys) != len(set(generated_keys)):
raise ValueError("Generated finding rows must have unique keys.")
unmatched_rows = rows[generated_count:]
if not all(row[3] == "ASK" and row[4] for row in unmatched_rows):
raise ValueError("Every unmatched prediction needs an owned ASK row.")
for row in rows[:generated_count]:
if "High" in row[2] and row[5] != PM_ACCEPTOR:
raise ValueError("A High finding is missing PM acceptance.")
lines = ["| " + " | ".join(HEADER) + " |", "|---|---|---|---|---|---|---|"]
for row in rows:
lines.append("| " + " | ".join(row) + " |")
Path("threat-register.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
print(f"\nCreated threat-register.md with {len(rows)} data rows.")
What is checked before writing?
- Every data row must contain exactly seven cells.
- Every generated Asset and Threat ID pair must be unique.
- Every unmatched prediction must have an owned ASK row.
- Every High or Very High generated finding must contain Priya Shah (PM).
- Add the command-line runner below write_register() to support interactive and automatic modes:
def main():
# Choose between per-finding prompts and recommendation-based automation.
parser = argparse.ArgumentParser(description="Build the owned threat register.")
parser.add_argument("--auto", action="store_true", help="Apply pytm recommendations without per-row prompts.")
parser.add_argument("--owner", help="Owner name or role used by automatic mode.")
args = parser.parse_args()
if args.auto and not args.owner:
parser.error("--owner is required with --auto")
findings = load_findings()
rows = build_rows(findings, load_unmatched(), args.auto, args.owner)
write_register(rows, len(findings))
if __name__ == "__main__":
main()
How does the runner select a mode?
- The --auto flag selects recommendation-based decisions without hiding the evidence printed for each finding.
- The --owner flag supplies one accountable owner for every automatically generated row.
- The runner stops before writing when automatic mode has no owner.
- Save the completed build_register.py file.
- Compare the complete file with the reference tab before running it.
Did the runner use the wrong mode?
Interactive mode should pause after it prints the first threat. Automatic mode skips those prompts and prints an Auto choice line instead.
If automatic mode reports that --owner is required, add the flag plus your name or the responsible role, such as Security Engineer or Cloud Engineer.
Help me repair the register runner.
✔️ Awesome, I've got everything!
Your script now displays readable threat details, supports interactive or automatic responses, enforces coverage, and writes the seven-column register.
ⓧ I'd like to double check the full code
import argparse
import json
from pathlib import Path
# Match the exact scope and schema enforced by check.py.
ASSETS = {"Ticket Database", "Ticket Summariser"}
PM_ACCEPTOR = "Priya Shah (PM)"
HEADER = ["Asset", "Threat ID", "Severity", "Decision", "Owner", "PM Acceptance", "Rationale"]
DECISIONS = {"MITIGATE", "ACCEPT", "ASK"}
def clean_cell(value):
# Keep generated and typed values safe for one Markdown table cell.
return " ".join(str(value).replace("|", "/").split())
def ask(prompt, choices=None):
# Reject empty answers and unsupported decisions.
while True:
value = clean_cell(input(prompt))
if value and (choices is None or value.upper() in choices):
return value.upper() if choices else value
if choices:
print("Choose " + ", ".join(sorted(choices)) + ".")
else:
print("Enter a value before continuing.")
def load_findings():
# Read only findings attached to the two scored assets.
payload = json.loads(Path("out.json").read_text(encoding="utf-8"))
findings = [
finding
for finding in payload.get("findings", [])
if finding.get("target") in ASSETS
]
keys = [(item["target"], item["threat_id"]) for item in findings]
if len(keys) != len(set(keys)):
raise ValueError("out.json contains a duplicate Asset and Threat ID pair.")
return findings
def load_unmatched():
# Read the validator's preserved Unmatched Predictions section.
lines = Path("validation-results.md").read_text(encoding="utf-8").splitlines()
marker = "## Unmatched Predictions"
try:
start = lines.index(marker)
except ValueError as error:
raise ValueError("validation-results.md has no Unmatched Predictions section.") from error
unmatched = []
for line in lines[start + 1:]:
if line.startswith("## "):
break
if not line.startswith("- "):
continue
pair, colon, detail = line[2:].partition(": ")
asset, slash, threat_id = pair.partition(" / ")
severity, dot, _ = detail.partition(".")
if not all((colon, slash, dot, asset.strip(), threat_id.strip(), severity.strip())):
raise ValueError(f"Cannot parse unmatched prediction: {line}")
unmatched.append((asset.strip(), threat_id.strip(), severity.strip()))
return unmatched
def build_rows(findings, unmatched, auto=False, owner=None):
# Show the source evidence before collecting or applying each response.
rows = []
for index, finding in enumerate(findings, start=1):
asset, threat_id, severity = finding["target"], finding["threat_id"], finding["severity"]
description = clean_cell(finding.get("description", "No description provided."))
recommendation = clean_cell(finding.get("mitigations", ""))
print(f"\nFinding {index} of {len(findings)}\nThreat ID: {threat_id}\nAsset: {asset}\nSeverity: {severity}\nThreat: {description}\nRecommendation: {recommendation or 'None provided.'}")
if auto:
decision = "MITIGATE" if recommendation else "ASK"
row_owner = owner
rationale = recommendation or "Review this finding because pytm supplied no mitigation."
print(f"Auto choice: {decision} / {row_owner}")
else:
decision = ask("Decision (MITIGATE, ACCEPT, or ASK): ", DECISIONS)
row_owner = ask("Owner name or role: ")
rationale = ask("Accepted risk, mitigation, or open question: ")
pm_acceptance = PM_ACCEPTOR if "High" in severity else ""
rows.append([asset, threat_id, severity, decision, row_owner, pm_acceptance, rationale])
for asset, threat_id, severity in unmatched:
print(f"\nUnmatched prediction\nThreat ID: {threat_id}\nAsset: {asset}\nClassification: {severity}\nRecommendation: No pytm finding matched this prediction.")
row_owner = owner if auto else ask("Owner name or role: ")
rationale = "Review this unmatched prediction with the named owner." if auto else ask("Unresolved question: ")
if auto:
print(f"Auto choice: ASK / {row_owner}")
rows.append([asset, threat_id, severity, "ASK", row_owner, "", rationale])
return rows
def write_register(rows, generated_count):
# Enforce the same coverage rules before writing the Markdown file.
if any(len(row) != 7 for row in rows):
raise ValueError("Every register row must contain exactly seven cells.")
if any("|" in str(cell) or "\n" in str(cell) for row in rows for cell in row):
raise ValueError("Register cells cannot contain pipes or newlines.")
generated_keys = [(row[0], row[1]) for row in rows[:generated_count]]
if len(generated_keys) != len(set(generated_keys)):
raise ValueError("Generated finding rows must have unique keys.")
unmatched_rows = rows[generated_count:]
if not all(row[3] == "ASK" and row[4] for row in unmatched_rows):
raise ValueError("Every unmatched prediction needs an owned ASK row.")
for row in rows[:generated_count]:
if "High" in row[2] and row[5] != PM_ACCEPTOR:
raise ValueError("A High finding is missing PM acceptance.")
lines = ["| " + " | ".join(HEADER) + " |", "|---|---|---|---|---|---|---|"]
for row in rows:
lines.append("| " + " | ".join(row) + " |")
Path("threat-register.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
print(f"\nCreated threat-register.md with {len(rows)} data rows.")
def main():
# Choose between per-finding prompts and recommendation-based automation.
parser = argparse.ArgumentParser(description="Build the owned threat register.")
parser.add_argument("--auto", action="store_true", help="Apply pytm recommendations without per-row prompts.")
parser.add_argument("--owner", help="Owner name or role used by automatic mode.")
args = parser.parse_args()
if args.auto and not args.owner:
parser.error("--owner is required with --auto")
findings = load_findings()
rows = build_rows(findings, load_unmatched(), args.auto, args.owner)
write_register(rows, len(findings))
if __name__ == "__main__":
main()
The script replaces the register
Running either mode rewrites threat-register.md after every row passes its checks. Your untouched findings, predictions, and validation results remain unchanged.
- Use the Python executable that passed your Step 1 version check.
- Choose interactive mode to review every recommendation before entering a decision.
- Choose automatic mode to turn pytm recommendations into draft register responses.
- Run only one of these commands:
# Interactive mode
python build_register.py
# Automatic mode
python build_register.py --auto --owner "[[REGISTER_OWNER="your name or role"]]"
Interactive mode pauses after displaying each threat ID, title, severity, and recommendation. Automatic mode prints the same evidence plus its chosen response without stopping for per-row input.
How does automatic mode choose?
- Automatic mode chooses MITIGATE when the finding contains a pytm mitigation. The mitigation becomes the row's rationale.
- Automatic mode chooses ASK when a generated finding has no mitigation or a prediction has no matching pytm finding.
- Automatic mode never chooses ACCEPT. Risk acceptance still requires a human decision.
- The named owner remains responsible for reviewing every automatic choice before the validation run.
- In interactive mode, enter MITIGATE, ACCEPT, or ASK after reviewing the displayed recommendation.
- Enter your name or a responsible role, such as Security Engineer or Cloud Engineer, at the Owner name or role: prompt.
- Enter the mitigation, accepted-risk rationale, or open question at the final prompt.
- In automatic mode, review each printed Auto choice line.
- Open threat-register.md and confirm every row has the intended owner plus rationale.
When the selected mode finishes, you should see Created threat-register.md followed by the number of data rows. That message confirms the register is ready for the accountable validation.
Did the register build stop?
Read the final error line first. It names a missing file, malformed unmatched-prediction line, duplicate key, or invalid table value.
Help me fix the register generator without changing source evidence.
Run the accountable validation
The preserved failure established the baseline. Before rerunning the validator, predict whether the completed register satisfies all six checks.
- Test the completed register by running this command:
python check.py
What does this check prove?
- Checks 1 and 2 prove that Ticket Database and Ticket Summariser have generated findings.
- Checks 3 and 4 prove that findings have allowed decisions and named owners. They also enforce PM acceptance for every high-severity finding.
- Checks 5 and 6 calculate recall and unmatched rate from the unchanged predictions. They display real numerators and denominators.
- The validator rewrites validation-results.md with the current fractions and every unmatched prediction.
You should see exactly six numbered CHECK lines showing PASS. The final line should show EXIT: 0.
Still seeing a failed check?
- Match every finding row's Asset and Threat ID cells exactly to out.json.
- Check that every decision is exactly MITIGATE, ACCEPT, or ASK.
- Check that every unmatched prediction has an ASK row with an accountable owner.
- Check that every High or Very High finding contains Priya Shah (PM) in the PM acceptance cell.
Share the failed check number with Help me fix my threat register without changing predictions.md..
That is the accountability gate cleared. Every generated finding now has an explicit decision and owner.
Prove prediction order and commit
The score only means something when the predictions existed before the tool output. A path-limited chronological log makes that ordering inspectable.
Before you run the proof, predict which of the two commits appears first.
- Print the chronological history for the prediction and output paths by running this command:
git log --reverse --format="%h %ad %s" --date=iso -- predictions.md out.json
What does this history prove?
- The --reverse option places the oldest matching commit first.
- The --format="%h %ad %s" option prints each commit's short hash, date, and subject.
- The --date=iso option uses an unambiguous date format.
- The final two paths limit the evidence to changes involving predictions.md or out.json.
You should see Commit PM design and predictions before output above Preserve model output and required ownership failure. That order proves the predictions existed before out.json.
Does the order proof look incomplete?
Confirm you are still inside the same local repository used for the earlier commits. The output should include history for both named paths.
Keep the original design commit unchanged. Do not amend it to repair missing history.
Use Help me interpret my path-limited Git history..
- Confirm the predictions commit appears above the output commit.
- Keep the chronological output visible for the Step 4 screenshot.
- Tick the completed validation items in TASKS.md.
- Save the updated project files.
- Stage every unignored change by running this command:
git add .
- Record the completed validation phase by running this command:
git commit -m "Own findings and prove prediction order"
What does this commit prove?
This commit records the owned register, passing results, and chronological proof after the preserved failure. The unchanged model outputs keep the evidence chain intact.
Your terminal should print a new commit summary for Own findings and prove prediction order.
Was the commit rejected?
Confirm the four updated files are staged. An empty staging area gives Git nothing to record.
If Git reports an identity problem, verify the repository-local name and email configured during setup.
Use Help me complete this local evidence commit..
That is the validation gate complete. Your owned register, real-count scores, and chronological Git evidence now give the PM an inspectable decision record.
Secret mission
Flip the Claim, Ship the Readout, and Tag HEAD
Test how sensitive your evidence is to one product claim. You will compare two local model outputs, create the only AI-assisted artifact, remove every unsupported statement, and seal the repository with an annotated local tag.
Clean Up Your Resources
Clean Up Your Resources
Your evidence pack stays entirely local, so there are no ongoing charges or cloud resources. Choose whether to keep the repository available, pause your work session, or delete the local files.
Resources you used:
- Local Git repository folder containing the design documents, model files, generated outputs, validation evidence, self-contained readout, commit history, and annotated threat-model-v1 tag.
- Activated Python virtual environment in .venv containing pytm 1.4.0.
Keep everything running
No action is needed. Choose this if you want to preserve the complete evidence chain for another PM review.
- Keep the local repository folder in its current location.
- Retain the checked TASKS.md file as the delivery checklist.
- Retain the threat-model-v1 tag as the marker for the final evidence pack.
- Open readout.html offline whenever you need to present the findings.
Pause - I'll come back to this later
Pausing closes your local work session while preserving every artifact. You can continue from the same clean repository later.
- Close the terminal session with the active .venv.
- Close your editor window for the repository.
- Return to the same local repository folder when you are ready to continue.
- Reactivate .venv with the same platform-specific command you used during setup.
Delete - I don't want to use this again
Deleting the repository removes the only project copy because no remote exists. Your existing Python installation, Git installation, editor, and AI client stay untouched.
- Close your editor window for the repository.
- Close the terminal session with the active .venv.
- Use your operating system's file manager to locate the same local repository folder from earlier.
- Delete that repository folder from your file manager.
- Empty your operating system's deleted-items area to remove the folder permanently.
- Confirm the repository folder no longer appears in its previous location.
- Confirm the repository folder no longer appears in the deleted-items area.
The project files, virtual environment, Git history, and threat-model-v1 tag are now removed together.
Nice Work!
Nice Work!
Challenge complete. You built a local threat-model evidence repository with pytm 1.4.0 that answers the PM through an audited offline readout.
You've learned how to:
- Created design-first evidence by committing five PM-facing documents before any tool output existed. Preserved the claim audit in claims.md. Preserved twelve pre-run answers in predictions.md.
- Built a claim-traced model with six elements. Modeled five logical exchanges across four trust boundaries. Preserved dfd.dot plus out.json as untouched outputs.
- Preserved the required six-check failure before the ownership register existed. Converted every finding into an owned decision. Kept each unmatched prediction as an owned ASK row. Reported recall plus unmatched rate as real fractions. Proved with local Git history that predictions preceded out.json.
- Secret Mission: Flipped summariser.isThirdParty from True to False to compare findings. Used the existing AI client once to draft readout.html. Removed every unsupported claim through readout-audit.md. Shipped the offline readout with Untraceable claims: 0. Tagged the final local commit as threat-model-v1.
Ready to quiz yourself?