| name | stpa |
| description | Generate or refresh the STPA safety model for one system and lens (logic/security/governance), written colocated as <system>/stpa/<lens>.json with a merged render as <system>/STPA.md. Takes `system` (directory) and optional `lens` (logic|security|governance, default logic). The analysis is deterministic, findings extracted as JSON, rendered by an embedded jq renderer, so runs produce small reviewable diffs. Use when updating a system's STPA analysis, auditing unsafe control actions, or refreshing on schedule. |
Generate/update the STPA safety model for ONE system and ONE lens. Findings are
written to committed JSON fragments under <system>/stpa/, and a merged
<system>/STPA.md is rendered deterministically from all present fragments.
The system argument is the directory to analyze (e.g. projects/monolith).
The lens argument is logic, security, or governance (default logic if
not given; ask if ambiguous). The analysis scope is that subtree and the control
structure it grounds in.
You are a systems-safety analyst applying STPA (System-Theoretic Process Analysis)
per lens. EXTRACT findings as JSON (judgment), RENDER the document by running
BLOCK A verbatim (mechanism), and only if it changed, open/refresh a PR via BLOCK B.
Determinism depends on rendering ONLY via the renderer. Do NOT hand-write markdown.
(**/STPA.md and **/stpa/*.json are in .prettierignore so the renderer and
jq pretty-print are sole authorities.)
Lens framing
All lenses share a control structure (structure.json, owned by the logic lens).
Per-lens analyses answer different safety questions:
- logic: mission-failure STPA. "What unsafe control action or feedback defect
drives the system into a loss?" Honest-but-imperfect system, mission violation,
guidewords
providing|not-providing|wrong-timing|wrong-duration for actions and
missing|stale|corrupted|unauthorized-source for feedback.
- security: deliberate attack. "Who can forge/withhold/replay a control action,
and what does it get an adversary?" Same control structure, attacks on purpose.
Status (
enforced-prod|enforced-dev|shipped-off|designed|none) REQUIRED on every
UCA and unsafe-feedback row (what safeguard exists now). Issues referenced via
issue field (label: security-finding).
- governance: data-processing safety. "Does data cross visibility boundaries,
stay retained too long, process without basis, lose provenance?" Feedback and
data channels matter more than control arrows. Same rejection bar.
Lens ownership and independence
The logic lens OWNS structure.json (nodes, control_actions, feedback); it may add,
update, or remove them (grounded in code), then re-validates every other committed
fragment. If a fragment references a removed action, that row is dropped with a
note in the PR body.
security and governance lenses are READ-ONLY consumers of structure.json. If
analysis needs an action/node the structure lacks, record it in the fragment's
open_questions (form: "structure gap: ..."), do NOT invent an ID.
Each lens reads ONLY: code + structure.json + its own prior fragment. Not the
other lens fragments. Independence prevents anchoring and context bloat.
Two views: logical and physical
Every finding carries a view:
- logical: functional control structure. Request/command flow: routers, domain
functions, schedulers, transactions, authorization boundaries in the code path.
Losses about governed correctness and provenance.
- physical: deployment realization. Pods/binaries, namespaces, NetworkPolicy,
database schemas, ingress, secret delivery. Losses about exposure and escalation.
A system may model only one view; omit ones you cannot ground; do not invent.
Steps
- Determine system and lens. If not given, ask (defaults: system =
projects/monolith, lens = logic).
- Check for prior fragments. If
<system>/stpa/ exists, read them (prior
analysis, semantic keys to reuse). If not but <system>/STPA.md exists (legacy
single-lens), extract structure.json + logic.json from its tables (preserve keys
verbatim; minimizes diff).
- Ground in code. List the system subtree; read READMEs, architecture docs,
roadmap. Read code units that issue or receive commands. Get commit:
git rev-parse --short HEAD.
- Analyze per lens. STPA steps 1-3: hazards (map to loss keys), control
structure (nodes, control_actions, feedback, tagged
built or designed),
UCAs (four guidewords, keep ONLY genuinely unsafe; bounded nuisances go to
non_ucas), unsafe feedback (missing, stale, corrupted, unauthorized; this is
where data-integrity failures live).
- Pick scratch dir.
STPA_TMP="$(mktemp -d /tmp/stpa-<system>-<lens>.XXXXXX)",
export it. Prevents collisions between concurrent lens runs.
- For logic lens only: write structure.json to
$STPA_TMP/structure.json.
For all lenses: write lens fragment to $STPA_TMP/<lens>.json. Schemas below.
- Render by running BLOCK A VERBATIM. It assembles all present fragments,
validates cross-references, renders
$STPA_TMP/STPA.candidate.md, compares
against origin/main (this checkout may be stale), and prints STPA_RESULT=.
- If
nochange: report no change, stop, do not open PR.
- If
changed: prepare PR text. Use diff -u to see what moved. Write
Conventional title to $STPA_TMP/stpa-title.txt, form docs(stpa): <lens>: <what changed> (<=72 chars, e.g. docs(stpa): security: add login forge-token UCA). Write concise body to $STPA_TMP/stpa-body.md (2-6
bullets naming changed findings by semantic key, plus scope/maturity shifts).
- Open/refresh PR by running BLOCK B VERBATIM. Commits both the fragment
and merged STPA.md, force-pushes, opens or updates PR, watches CI, rebase-merges
on green. Report PR URL and result.
Migration: existing single-analysis STPA.md
Existing <system>/STPA.md files (no stpa/ dir) are reverse-extracted: parse the
tables, emit structure.json + logic.json preserving every semantic key verbatim.
The logic lens's first run on such a system extracts once, then owns structure
normally. Minimizes diff churn.
Remediation work goes to GitHub Issues
File unmitigated UCAs / unsafe-feedback findings as issues (repo's source of truth
for outstanding work), titled <system>: <finding> (STPA <key>), labeled bug
(broken safeguard) or enhancement (missing safeguard), plus critical for
severe losses. Security lens findings add label security-finding. Reference
the issue from the finding's evidence, not a STPA.md checklist.
Drift minimization (this is an UPDATE, not a rewrite)
Rendered docs are committed and reviewed as diffs. Minimize churn:
- REUSE each prior finding's semantic key VERBATIM when the code it cites still
exists and still means the same thing.
- KEEP prior
condition/statement/label/scope wording UNCHANGED unless code
changed. Do not reword for style.
- Update an
evidence path:line only if the referenced code actually moved.
- But RE-VERIFY, never parrot: open each prior finding's cited code and confirm it
still holds. Logic changed, update it. Code deleted, drop it. New unsafe action,
add it with a new key. Net: unchanged code => identical finding; changed code =>
localized change; added/removed code => added/removed finding.
Semantic keys
Derive each key from WHAT IT IS, never position. Lowercase, no spaces, no dots
(mermaid ids): nodes: acl, public-api, scheduler; losses: L.unauthorized-access,
L.secret-exposure; control_actions: <node>.<op>: acl.check, scheduler.dispatch;
hazards: condition slug: stale-policy, cross-schema-grant; ucas:
<control_action>.<guideword>: scheduler.dispatch.wrong-timing; unsafe_feedback:
<channel>.<guideword>: policy-fetch.stale, queue-notify.missing.
from/to/control_action/hazards/losses reference other objects BY KEY.
Grounding rules (hard)
- Every node, control_action, UCA:
evidence path:line or path plus <=8-word
verbatim quote for docs/comments. No evidence, omit it.
- NEVER mark a designed-only element
built.
- No invention. Uncertainty goes to
open_questions.
- Prioritize. Keep
condition/statement to ONE line, no |, no newlines.
JSON schemas
structure.json (logic lens writes, others read):
{
"system": "monolith", "system_dir": "projects/monolith", "commit": "abc1234",
"nodes": [ {"key":"acl","label":"Acl check","view":"logical","layer":"enforcement","maturity":"built"}, ... ],
"control_actions": [ {"key":"acl.check","label":"...","view":"logical","from":"public-api","to":"acl","maturity":"built","evidence":"path:line"}, ... ],
"feedback": [ {"view":"logical","from":"postgres","to":"scheduler","signal":"NOTIFY wakeup hint"}, ... ]
}
.json (logic, security, or governance):
{
"lens": "security", "system_dir": "projects/monolith", "commit": "abc1234",
"scope": {"summary":"1-line headline","built":"built things","designed":"designed things","note":"OPTIONAL caveat"},
"losses": [ {"key":"L.unauthorized-access","title":"..."}, ... ],
"hazards": [ {"key":"stale-policy","view":"logical","statement":"...","losses":["L.unauthorized-access"],"maturity":"built","status":"none","issue":"#5277"}, ... ],
"ucas": [ {"key":"scheduler.dispatch.wrong-timing","view":"logical","control_action":"scheduler.dispatch","guideword":"wrong-timing","condition":"...","severity":"high","hazards":["stale-policy"],"evidence":"path:line","status":"enforced-prod","issue":"#1234"}, ... ],
"unsafe_feedback": [ {"key":"policy-fetch.stale","view":"logical","from":"acl","to":"public-api","signal":"policy rows","guideword":"stale","condition":"...","severity":"high","hazards":["stale-policy"],"evidence":"path:line","status":"enforced-dev"}, ... ],
"non_ucas": [ {"item":"missed NOTIFY","reason":"bounded by 5s fallback (path:line)"}, ... ],
"open_questions": [ "string", ... ]
}
Every fragment carries system_dir so a lens-only run can locate the committed
structure. status is REQUIRED on security-lens UCA/unsafe-feedback rows and
optional elsewhere; any status must be one of
enforced-prod|enforced-dev|shipped-off|designed|none, and a security-lens row
or hazard with status: none MUST carry a tracking issue (GitHub Issues are
the source of truth for unmitigated findings). The validator enforces all
three. Hazards MAY carry status/issue too: a tracked unsafe STATE with no
single attacking control action (a missing egress policy, an absent jailer)
lives on the hazard row rather than being forced onto an unrelated action.
A UCA key must be exactly <control_action>.<guideword> (validated). view:
logical|physical. layer: free-form subgraph (e.g. enforcement, ingress).
severity: high|medium|low. UCA guidewords:
providing|not-providing|wrong-timing|wrong-duration. Unsafe_feedback guidewords:
missing|stale|corrupted|unauthorized-source. unsafe_feedback array optional.
The rendered commit-stamp line lists every lens, so two lens PRs in flight touch
the same line and the second gets ejected from the merge queue on rebase: land
concurrent lens runs serially.
BLOCK A: render + detect change (run verbatim; never hand-write .md)
set -euo pipefail
SYSTEM_DIR="$(jq -r '.system_dir' $STPA_TMP/structure.json 2>/dev/null || echo '')"
LENS="$(jq -r '.lens // empty' $STPA_TMP/*.json 2>/dev/null | head -1)"
[ -z "$LENS" ] && LENS="logic"
printf '%s' "$LENS" > "$STPA_TMP/lens.txt"
[ -z "$SYSTEM_DIR" -o "$SYSTEM_DIR" = "null" ] && SYSTEM_DIR="$(jq -r '.system_dir // empty' $STPA_TMP/*.json 2>/dev/null | head -1)"
test -n "$SYSTEM_DIR" -a "$SYSTEM_DIR" != "null"
git fetch -q origin main 2>/dev/null || true
STRUCTURE_TMP="$STPA_TMP/structure-assembled.json"
if [ -f "$STPA_TMP/structure.json" ]; then
cp "$STPA_TMP/structure.json" "$STRUCTURE_TMP"
else
git show "origin/main:$SYSTEM_DIR/stpa/structure.json" > "$STRUCTURE_TMP" 2>/dev/null || {
echo "STPA_ERROR: no structure.json in this run or origin/main"
exit 1
}
fi
FRAG_PATHS=""
for LENS_NAME logic security governance;
LENS_FILE=
FRAG_PATH=
FRAG_TMP=
[ -f ];
FRAG_PATHS=
git show > 2>/dev/null;
FRAG_PATHS=
MERGED=
jq -n --slurpfile s >
> /validate.jq <<
. as
| (.structure.control_actions // [] | map(.key)) as
| (.structure.nodes // [] | map(.key)) as
| [ .lenses[]
| .lens as
| (.hazards // [] | map(.key)) as
| (.losses // [] | map(.key)) as
| (
(.ucas // [] | .[]
| (.control_action as | ( | index()) == null)
| ),
(.unsafe_feedback // [] | .[]
| ((.from as | ( | index()) == null)
or (.to as | ( | index()) == null))
| ),
((.ucas // []) + (.unsafe_feedback // []) | .[] | .key as
| (.hazards // [])[]
| select(. as | ( | index()) == null)
| ),
(.hazards // [] | .[] | .key as
| (.losses // [])[]
| (. as | ( | index()) == null)
| ),
(( == )
| (.ucas // []) + (.unsafe_feedback // []) | .[]
| (.status == null)
| ),
(( == )
| (.ucas // []) + (.unsafe_feedback // []) + (.hazards // []) | .[]
| (.status == and .issue == null)
| ),
((.ucas // []) + (.unsafe_feedback // []) + (.hazards // []) | .[]
| select(.status != null and
(.status as | (["enforced-prod","enforced-dev","shipped-off","designed","none"] | index()) == null))
| ),
(.ucas // [] | .[]
| (.key != )
| )
)
]
| length == 0 + () end
VJQEOF
VALIDATION=
[ != ];
1
> /stpa-render.jq <<
def esc: (. // ) | gsub(; ) | gsub(; );
def mlabel: (. // ) | gsub(; ) | gsub(; ) | gsub(; ) | gsub(; );
def mid: gsub(; );
def diagram():
([.structure.nodes[]? | (.view == )]) as
| ([.structure.control_actions[]? | (.view == )]) as
| ([(.structure.feedback // [])[]? | ((.view // ) == )]) as
| (if ( | length) == then "_No \() control structure modeled._\n"
else ("```mermaid\nflowchart TD\n"
+ (( | sort_by(.layer // "zzz", .key) | group_by(.layer // "zzz")
| map((.[].layer // "other") as |
" subgraph \(|mid)[\"\()\"]\n"
+ (map(" \(.key)[\"\(.label|mlabel)\(if .maturity=="designed" then " (designed)" else "" end)\"]") | join("\n"))
+ ) | ()))
+ (( | sort_by(.key) | map(" \(.from) -- \"\(.key)\" --> \(.to)") | join("\n")))
+
+ (( | sort_by("\(.from)|\(.to)|\(.signal)") | map(" \(.from) -. \"\(.signal|mlabel)\" .-> \(.to)") | join("\n")))
+ )
end);
+ ( (.lenses | length) > 0 \(.lens) @ \(.commit) · end)
+
+
+
+
+ + diagram() + + diagram() +
+
+ (.structure.control_actions | sort_by(.key) | map() | ())
+
+ (.lenses | sort_by(.lens) | map(
+
+
+
+
+ ( (.scope.note // null) end)
+
+
+ ((.losses // []) | sort_by(.key) | map("| `\(.key)` | \(.title|esc) |") | join("\n"))
+
+ (((.hazards // []) | any(.status != null or .issue != null)) as |
+ ( end)
+
+ + (([range(if then else end)] | map("----") | join("|"))) +
+ ((.hazards // []) | sort_by(.key) | map(
"| `\(.key)` | \(.view|esc) | \(.statement|esc) | \((.losses // [])|join(", ")) | \(.maturity|esc) | \(.status // | esc) | \(.issue // | esc) |\n\n\n### Unsafe control actions\n\n| ID | View | Control action | Guideword | Unsafe condition | Severity | → Hazards | Status | Issue | Evidence |\n|----||\n| `\(.key)` | \(.view|esc) | `\(.control_action)` | \(.guideword|esc) | \(.condition|esc) | \(.severity|esc) | \((.hazards // [])|join(", ")) | \(.status // | esc) | \(.issue // | esc) | \(.evidence|esc) |\n\n\n### Unsafe feedback\n\n*Data channels (absent, stale, corrupted, spoofed) whose failure drives a controller into a hazard.*\n\n| ID | View | Channel | Guideword | Unsafe condition | Severity | → Hazards | Status | Issue | Evidence |\n|----||\n| `\(.key)` | \(.view|esc) | `\(.from)` → `\(.to)`: \(.signal|esc) | \(.guideword|esc) | \(.condition|esc) | \(.severity|esc) | \((.hazards // [])|join(", ")) | \(.status // | esc) | \(.issue // | esc) | \(.evidence|esc) |\n\n\n<details>\n<summary><b>Not UCAs</b>: \(.non_ucas|length) examined and rejected</summary>\n\n\(.item)|\(.reason)- **\(.item|esc)**: \(.reason|esc)\n\n</details>\n\n### Open questions\n\n- \(esc)\n\n\n\n+$origin/main:/STPA.mdSTPA_RESULT=nochangeSTPA_RESULT=changedSTPA_RESULT=changed
BLOCK B: commit, PR, watch CI, merge on green (run verbatim; ONLY when STPA_RESULT=changed)
set -euo pipefail
SYSTEM_DIR="$(jq -r '.system_dir' $STPA_TMP/structure.json 2>/dev/null || jq -r '.system_dir // empty' $STPA_TMP/*.json 2>/dev/null | head -1)"
LENS="$(cat "$STPA_TMP/lens.txt" 2>/dev/null || echo 'logic')"
SLUG="$(printf '%s' "$SYSTEM_DIR" | tr '/' '-' | tr -cd 'a-zA-Z0-9-')"
BRANCH="bot/stpa-$SLUG-$LENS"
REPO_ROOT="$(git rev-parse --show-toplevel)"
WT="/tmp/claude-worktrees/stpa-$SLUG-$LENS"
git config --get user.email >/dev/null 2>&1 || git config user.email "stpa-bot@users.noreply.github.com"
git config --get user.name >/dev/null 2>&1 || git config user.name "stpa-bot"
git -C "$REPO_ROOT" worktree remove -f "$WT" 2>/dev/null || true
git -C "$REPO_ROOT" worktree prune
git -C "$REPO_ROOT" worktree add -B "$BRANCH" "$WT" origin/main
mkdir -p "$WT/$SYSTEM_DIR/stpa"
[ -f "$STPA_TMP/structure.json" ] && jq '.' "$STPA_TMP/structure.json" > "$WT/$SYSTEM_DIR/stpa/structure.json"
[ -f "$STPA_TMP/.json" ] && jq >
/STPA.candidate.md
git -C add
git -C commit -m -m
git -C push -f -u origin
PR_STATE=
[ != ];
gh create --base main -- --title --body-file /stpa-body.md
URL=
gh merge --auto --rebase ||
_ $( 1 60);
STATE=
[ = ] &&
[ = ] && { ; 1; }
60
[ = ];
git -C worktree remove -f 2>/dev/null ||
git -C branch -D 2>/dev/null ||
-rf