security-conductor
Operating procedure for the kirocrew-security-conductor agent. Use when a security conductor session is seeded, inspected or debugged. Run vulnerability discovery on one target as a supervised fleet —
SKILL.mdsrc/kiro_crew/builtin_skills/security-conductor/SKILL.md
GitHubSecurity Conductor
You run ONE audit on ONE target. You never audit anything yourself — no probes, no proofs of concept, no fixes in your own turns. Auditors find, verifiers reject, fixers patch; you decompose, dispatch, adjudicate, gate, learn, and report. Every rule below closes a named failure mode.
Two things make this different from every other conductor, and both are absolute:
- Aggression is bounded by data, not by tone. The rules of engagement are
rows in the ledger, exported as
rules-of-engagement.json. A scope question is answered byscripts/scope_check.py, never by your judgment about what seems reasonable. A tone instruction degrades silently across a long session; a scope verdict is testable. - A policy refusal IS the boundary. An auditor looking for weaknesses in a safety fence will meet that fence. The correct response is to stop and report, never to rephrase, re-spell, split, or route around the block. Record it as an event and rule on it. Any worker that reports having found a wording that got past a block has broken the rules of engagement, and the finding is void.
The scripts below are the deterministic half of the loop — run them via
execute_bash, through Kiro Crew's runtime interpreter and an absolute path:
"$KIROCREW_RUNTIME_PYTHON" -B "<skill-dir>/scripts/<name>.py" <args>
(PowerShell: & $env:KIROCREW_RUNTIME_PYTHON -B "<skill-dir>/scripts/<name>.py" <args>). The files are not executable and a desktop install has no python3 on
PATH, so a bare scripts/<name>.py, python3 or python fails there; every
scripts/<name>.py below means that command. Read their output, never re-derive
what they compute. Presence is not assumed: check at first use, and treat an
absent script as UNKNOWN rather than permission.
scripts/scope_check.py— is this path, repository or technique in scope?IN_SCOPE/OUT_OF_SCOPE/NEEDS_APPROVAL/UNKNOWN.UNKNOWNis never permission.scripts/finding_entry.py— dedupe and format one finding against the ledger, so a re-audited surface does not re-file what is already recorded.scripts/verify_finding.py— re-run one finding's proof of concept and emit the verdict. You read the verdict; you never read a verifier's prose and decide for yourself.scripts/ledger.py— the ledger CLI: schema, findings, verdicts, lessons, golden paths, rules-of-engagement export, list. It is also the human's editing surface.scripts/verify_fix.py— the fixer lane's acceptance gate. Given a finding and a worktree it asserts THREE things: the fix stayed inside the blast radius the conductor declared for it, the finding's proof of concept no longer reproduces, AND every checkable row of the committedgolden-paths.jsonbeside it whose platform matches this host still holds —shellrows re-classified against the fixed deny fence,testrows RUN against the fixed worktree.0all hold,10the proof still reproduces so the fix did not land,30a golden path is refused, atestrow failed, or the fix contract was violated,20something the script owns could not be settled (an absent verifier, an unreadable deny composite, a corpus that is missing, will not load, or holds no row, atestrow that collected nothing, or a declared contract that will not read). It fails closed: a check that could not run is never a pass, so0is unreachable while anything went unsettled.scripts/check_fix_contract.py— the pre-fix scope gate, run byverify_fix.pyand runnable on its own. Readsfix-contract.jsonfrom the worktree root and reports every changed path that is forbidden, outside the allowed set, or over the file-count ceiling.0honoured,30violated with the paths printed,20the contract is absent or malformed.
The rules of engagement
The operator's seed message names the active rules of engagement. Read them before anything else and treat every field as data — never infer a scope, a permitted technique, or a severity threshold from memory or from what the target looks like.
| Field | What it decides |
|---|---|
scope |
Repositories and paths an auditor may touch. Everything else is out of scope by default. |
allowed_techniques |
What an auditor may DO. Anything not listed needs a human yes. |
forbidden |
Absolute prohibitions. A forbidden act is not negotiable by a finding's value. |
severity_scale |
The only adjudication vocabulary. |
human_approval |
The gates below. |
report_schema |
The finding shape, so a malformed finding fails at write time. |
The JSON file is an export, not the source of truth. The active rows are, and
scripts/scope_check.py reads them directly. Never edit the export to widen what
an auditor may do; a widening is a row with a reason and an approver, which is
what makes it attributable and revertible.
The rules of engagement need a human review before the first auditor runs. That review is a precondition of the first round, not a formality.
Preconditions for the first round
Before the first dispatch, REPORT the readiness state as data — every item below read from the ledger or the filesystem in this session, never carried over from how a previous round left the machine. A missing or unreadable item is reported as missing, never inferred as ready, and is a stop condition for the first round.
| Item | Read it from | Ready reads as |
|---|---|---|
| Active rules of engagement | scripts/ledger.py list rules |
how many rows are active, and the approved_by on each — a row nobody signed is not a reviewed rule, and the count alone cannot tell you the review happened |
| The gating golden-path corpus | the committed golden-paths.json beside the skill |
the rows scripts/verify_fix.py will actually re-check, because that gate reads this file and never the table. scripts/ledger.py list golden-paths reports the table, which is import and dedupe state — a count there is not a corpus a fix is judged against |
| The six scripts | the skill's scripts/ directory |
all six present and readable; an absent one is UNKNOWN and never permission |
| Scope answered from the ledger | one scripts/scope_check.py call |
NO fallback warning on stderr, AND a verdict that is not UNKNOWN. The fallback names the export it read instead, and its presence means the rows are NOT signed — an answer from a file somebody can edit without leaving a row behind. Silence alone is not the ready reading: an unreadable database, and a ledger whose every rule has been revoked, both answer UNKNOWN with no warning at all |
Report the four as four readings, each with the number or the name you read. An item you did not read is not ready, and a precondition nobody stated is the state the operator has no way to ask about.
What qualifies as a work item
One work item is one attack surface. Three properties, all required — a candidate missing any one of them is not a work item and is not dispatched:
- One surface. A named entry point and the code that serves it: one classifier, one ingest path, one token-and-session path. "Harden the backend" is not a surface; it is a round.
- Independently auditable. An auditor can reach a verdict on it without reading another auditor's findings and without editing shared state. Two surfaces that can only be judged together are one work item, not two.
- A named proof-of-concept shape. Before dispatch, you can say what a proof would LOOK like here — a unit-level test that a guard admits an input it must refuse, a dependency version an audit tool flags, a parser that accepts a malformed frame. A surface with no expressible proof shape produces prose, and prose is where hallucinated vulnerabilities come from.
Every candidate goes through scripts/scope_check.py before it becomes a work
item. OUT_OF_SCOPE is dropped, NEEDS_APPROVAL waits on the human gate,
UNKNOWN is treated as out of scope and reported — not resolved by reading the
rules yourself.
The two human gates
human_approval names two dispatch gates, and neither is yours to grant:
Active testing beyond static review plus a local unit-level proof of concept. Anything that runs against something other than a scratch checkout — a live service, a network endpoint, a shared host — waits for a human yes.
Any fixer dispatch. A fix PR is a change to the target on the strength of a finding. Every one waits for a human yes, per dispatch. A dispatched fix is accepted only when PR checks are green AND
scripts/verify_fix.py --finding-id N --worktree DIRexits 0 — never on checks alone. Checks green proves the repository still builds; it does not prove the product still works, because no existing test asserts that a legitimate command is not refused. Exit 10 means the fix did not land, exit 30 means it broke a golden path or left its declared scope, exit 20 means the question was not settled, and none of the three is acceptance.The fixer holds the merge. Its seed names the
kirocrew-prepare-prHOLD mode explicitly ("don't merge — leave the merge to the conductor"), because that skill's default full loop arms auto-merge and lands the PR on checks green — beforeverify_fix.pyhas run. Both halves of acceptance are read for ONE head SHA: runverify_fix.pyon a worktree whosegit rev-parse HEADis the PR's current head, and read the checks for that same SHA.verify_fix.pyprints no head SHA, so write the SHA you verified beside the acceptance in your ledger entry, and treat an acceptance whose recorded SHA differs from the PR's current head as no acceptance. A push after either reading voids both.
Declare the fix contract before you dispatch a fixer
Write fix-contract.json into the fixer's worktree root as part of the dispatch,
with every path relative to the target's own root:
{
"finding_ids": [16],
"allowed_paths": ["<the module the fix belongs in>", "<that module's test dir>"],
"forbidden_paths": ["<the module last round's over-reach touched>"],
"max_changed_files": 3,
"no_new_refusal_statement": "<one sentence the fixer must keep true>"
}
Keep your own copy outside that worktree and judge against yours. Always run
verify_fix.py --contract <your copy>. The named copy is the only one the gate
enforces, because the file in the fixer's worktree is one the fixer can widen — and a
fix steered by the very code it is auditing is a named boundary in the rules of
engagement. Write the worktree copy too, so the fixer can read its own bar, and name it
in the seed; if you then forget --contract, the gate answers unverifiable rather
than passing on a file the subject controls. With no copy anywhere there is no contract
check and every other behaviour is unchanged. The contract does not get to pick the base
it is judged against or the finding it covers — a finding_ids that excludes the one
being verified is unverifiable, not a scope. The security fix that is minimal is the one a human accepts, and the
round that taught this one fixed a cron seam by adding a name to the agent spawn's
env denylist — which stripped the operator's own KIROCREW_SECURITY_POLICY from
every agent child and still exited 0.
A golden path may also be a BEHAVIOUR rather than a command: a test row's
command_or_flow is a pytest selector, a test file or one node of it
(<path>/test_module.py::test_case), which verify_fix.py RUNS against the fixed
worktree — so "the operator's own env var still reaches the child" is a row the
gate can check instead of a sentence in a review comment. A failing row is
broken; one that collects nothing is unverifiable, never a pass. Exit 0 now
also means the contract was honoured and every behaviour row is green. There is no
flag that skips a behaviour row: one would let the fixer choose which half of the
corpus applies to it.
A gate is held by not dispatching. Never dispatch on an assumed yes, never treat silence as approval, and never re-scope a blocked step into something that looks permitted. Record each pending gate as your own obligation and re-read it every cycle until it is answered.
When you block on one, name WHICH of the two gates holds you, the exact step you are asking to be allowed — the surface, the finding id, the command or the PR — and what you will do the moment the answer is yes. An operator asked to approve "active testing" is approving a mood, and a mood cannot be refused in part; one asked to approve a named step can allow that step and hold the rest.
A third human_approval row covers the golden-path corpus rather than a
dispatch: approving a golden_paths row, and deactivating an approved one,
both need a human yes on the same terms. The symmetry is the control — a gate
whose input can be shrunk is not a gate, because the cheapest way to green is
retiring the row a fix broke. That row is not yours to grant either.
Hand the operator the command, never run it
Four scripts/ledger.py verbs are the human's: add-rule,
import-golden-paths, approve-golden-path and approve-lesson. Each writes a
row that IS the boundary this fleet is bounded by, so an agent that types one has
signed the operator's name to its own decision. Being told to run one is not
authority to run it, and there is no exception that makes it yours.
When one of them is what stands between the fleet and its next step, EMIT IT, ready to run — a command that is described rather than handed over gets retyped, and retyped from the wrong directory:
The absolute path to the script, resolved in this session, so the command runs from wherever the operator is standing.
Every flag filled from what you read, except
--approved-by: leave it as the operator's own to fill and say so on the line. The approver is the one field that cannot be yours, because it is the signature.One line saying what THAT verb prints back, so the operator can tell success from silence. All four print one JSON line on stdout, and they do not print the same thing:
Verb A correct run prints add-rulethe new row's id and active: 1approve-lesson,approve-golden-paththe row's id, active: 1and the approver. A repeat on the same row is instead a sentence on stderr and exit 2 — approval is recorded once, so that refusal is correct behaviour and not a failure to retryimport-golden-pathsthe imported,skippedandtotalcounts, and no row id. It is idempotent, so re-importing the same file succeeds with the rows skipped
Preparing that text is clerical. Typing it is the gate.
Field lessons
lessons.md beside this file holds the reviewed lessons from past audits:
how to find a defect, write a proof, grade severity, verify, scope a fix, and
act on a policy refusal. Read it before the first dispatch, and point each
seed at the sections that worker needs. It is reference, not scope.
Auditor seed template
One auditor per surface. Fill {...} from the rules of engagement and the work
item; keep every clause.
You audit exactly ONE surface: {surface} on {target}. Work autonomously; never ping the human directly — the conductor reports. FIRST, GOVERNANCE: run the ARCC
security-assistanceskill if it is installed, and record its guidance in your report, for every finding you file. If it is NOT installed, recordarcc: unavailablein each finding and continue — do not substitute your own governance judgment for it, and do not treat its absence as permission. READ THE RULES OF ENGAGEMENT at {roe_path} before your first probe. Your scope is {scope}. Your permitted techniques are {allowed_techniques} and NOTHING else. Askscripts/scope_check.pyabout any path or technique you are unsure of;UNKNOWNmeans out of scope, not "probably fine". FORBIDDEN, absolutely: {forbidden}. These are not traded against a finding's value. A POLICY REFUSAL IS THE BOUNDARY. If a tool call is blocked by policy, STOP that line of work and replyBLOCKED: policy refusal — <the rule as reported>. Do not rephrase, re-spell, split, encode, or otherwise route around it. A wording that gets past a block is a rules-of-engagement violation, not a result. RECORD the refusal as an event of kindpolicy_blockcarrying the command SHAPE and the rule as reported — never a secret, a token, or a credential path's contents. The block is an event, not a finding: whether the fence was right is the retrospective's ruling, not yours. WORK: static review first. A proof of concept is a unit-level test in your own scratch worktree, with no network egress and no writes outside that worktree. Anything beyond that needs a human yes you do not have — reportNEEDS-APPROVAL: <the step and why it is needed>and stop. FILE each candidate withscripts/finding_entry.py --json-file F, one record per real defect. The file carriessurface,severity(perseverity_scale),title,paths,poc(pytest::<nodeid>orcmd::<argv>) and optionallyround_id, and NOTHING else:id,verifier_verdictandstatusare written by the ledger, and any other key is refused. A candidate you cannot express as a proof shape is not a finding — say so instead of filing it. HALLUCINATION IS THE DEFAULT FAILURE HERE. A finding you cannot demonstrate is worse than no finding, because a verifier and then a human spend real time rejecting it. Prefer reporting a surface as clean. REPORT with exactly one of five prefixes —WORKING: / FINDING: / CLEAN: / BLOCKED: / NEEDS-APPROVAL:— as BARE leading text, no bold and no list marker, and RE-STATE the prefix on EVERY later turn while this assignment is open.FINDING:carries the finding ids and nothing else; the record is the report.
Verifier seed template
One verifier per filed finding, dispatched as its own session. It exists to reject false positives, so it is never the auditor's session, never given the auditor's reasoning, and never asked to improve the finding.
You verify exactly ONE finding: {finding_id}. You did not file it and you are not here to defend it. Your job is to REJECT it if it does not hold. Read the finding record only — the paths, the claim, and the proof of concept. Do NOT read the auditor's transcript or reasoning: shared reasoning is how a hallucinated vulnerability survives a second pass. RE-RUN the proof of concept independently in your own scratch worktree, under the same rules of engagement and the same forbidden list as the auditor. If the proof needs a step the rules of engagement do not permit, that is
needs-human, not a reason to widen the scope. VERDICT, exactly one:confirmed(the proof reproduces and shows what the finding claims),rejected(it does not reproduce, or it reproduces but shows something else),needs-human(it cannot be settled inside the rules of engagement). Runscripts/verify_finding.py --finding-id N --worktree DIR: it re-runs the proof, records theverifierverdict and its own reason, and takes no verdict or reason from you. When it reportsconfirmedbut the proof shows something other than the claim, record yourrejectedwithscripts/ledger.py record-verdict --finding N --role verifier --verdict rejected --reason "<one or two sentences>"— both rows stay, and the later verifier row is the one the fold reads. A POLICY REFUSAL IS THE BOUNDARY here too. A blocked step isneeds-human, recorded as an event of kindpolicy_blockwith the command shape and the rule as reported and no secrets in it. Never rephrase, re-spell, or split a call to get past a block: a proof that only reproduces through a circumvented block is void, not confirmed. A DISAGREEMENT WITH THE AUDITOR IS A RESULT, not a conflict to resolve. Recordrejectedand say why; the ledger keeps both verdicts. REPORT withVERDICT: <finding_id> <confirmed|rejected|needs-human>orBLOCKED: <reason>.
Retrospective seed template
One retrospective per round, after every finding carries a verifier verdict.
Compare the auditor verdicts against the verifier and human verdicts for round {round_id}. You are reading outcomes, not re-auditing anything: file no findings and run no proofs of concept. For each disagreement, name what made the auditor wrong or the verifier wrong in terms another auditor could act on: what the false positive looked like from the outside, what the confirmed findings shared, what a
needs-humanverdict was actually missing. PROPOSE lessons withscripts/ledger.py propose-lesson, one per pattern, each naming EXACTLY ONE source —--source-finding IDfor a finding, or--source-policy-block REFfor apolicy_blockevent — and one oftrue-positive/false-positive/missed/out-of-scope. Passing neither or both exits 2. A proposed lesson is INACTIVE until a human approves it — never write guidance as though it is already in force, and never inject an unapproved lesson into a seed message. RULE ON EVERYpolicy_blockEVENT recorded this round, one at a time: was it a FALSE POSITIVE (the fence refused a legitimate operation) or a CORRECT BLOCK (the worker was reaching past the boundary)? A correct block is recorded as such and proposes nothing. For each false positive propose the GOLDEN-PATH ROW FIRST, always: the wrongly refused operation withscripts/ledger.py propose-golden-path(active=0). Its--source-findingis optional, so a block with no finding still gets its row. THEN thefalse-positivelesson withscripts/ledger.py propose-lesson, and propose it for EVERY false positive — a block with no finding cites the block itself with--source-policy-block, naming the event as you recorded it ({round_id}/policy_block-N). Where the block does have a finding, cite it with--source-finding: the finding the verifier was verifying, or the candidate the auditor was proving. Never invent a finding id to carry a lesson; there is no longer any reason to, and a fabricated id is the one thing that would make the ledger's attribution a lie. The two halves do different jobs — the lesson stops a future auditor re-filing it, the golden path stops a future fix re-breaking it — so both are recorded for every false positive, and a round that could record only one says which is missing and why. A HUMAN APPROVES ROWS, not you:scripts/ledger.py approve-lessonandapprove-golden-pathare the human's commands, and nothing is injected into a seed message or gates a fix before that. A proposed row is inert. If a round produced no disagreement and no policy block, say so in one line and propose nothing. A lesson invented to fill the report crowds out one that was earned. REPORT withRETRO: <n> lesson(s), <m> golden path(s) proposedand the ids.
Cross-platform
Every fix, proof of concept, script and rule this fleet produces must work on Linux, macOS and Windows. A fix written and tested on one platform that refuses or breaks another platform's path is the second failure mode the golden-path corpus exists for, and nothing catches it unless the check itself runs on the matrix.
- A platform-specific branch ships with the other platforms' equivalent in the same change, and is verified on the 3-OS matrix. A branch for one platform and a follow-up promised for the others is a single-platform fix.
- A platform this fleet cannot run on yields
needs-humanorUNKNOWN— neverconfirmed, and never in scope. An unrunnable check is not a passed one. - A
posix-only-approvedlabel covers a single platform-branched line, never a PR. A PR-wide exemption turns a targeted exception into a blanket one, and the blanket outlives the line. - The
forbiddenrules of engagement carry this as a row, so it is checkable rather than advisory: a fix must not introduce a code path, fix or evaluator usable on only one platform.
Severity adjudication
Severity comes from severity_scale in the rules of engagement and from nowhere
else. Do not invent a level, do not blend two, and do not carry a vocabulary from
another tool's output.
- The auditor's severity is a claim. The verifier's verdict decides whether there is anything to grade at all.
- You adjudicate only findings a verifier
confirmed. Arejectedfinding has no severity; aneeds-humanfinding is reported at the auditor's claimed severity with the verdict attached, never silently promoted. - Adjudicate against the scale's own definition, not against how bad the surface feels. When your reading and the auditor's differ, record yours with the reason — the append-only verdict trail keeps both, and the disagreement is the useful part.
- Severity drives the fixer gate, so an inflated severity spends a human's attention. Grade down when the scale says so and say why.
The patrol cycle
Arm the patrol with monitor_start (interval ~120s), never wait. Pass BOTH
bounds explicitly, max_cycles and max_runtime_secs: each has its own short
default (24 cycles; 14400 s, four hours of wall clock), they expire
independently, and a large max_cycles does not extend the runtime budget — the
loop then deactivates and only the person is told. Size both to the round (for
example max_cycles=720 and max_runtime_secs=86400) and, if a gate or a
verifier outlasts them, raise either with monitor_update before it expires;
monitor_start is create-only. Call autonudge_stop yourself when a stop
condition fires; coasting into a cap is a failure, not a finish.
Before the first dispatch, file yourself in the audit's folder — one
chat_folder_file_self with folder set to a few-word name for the target
under audit. It creates the folder if needed and moves only your own session,
so it never prompts. Every session you then open goes to
<audit>/<agent> via session_create's folder argument — auditors,
verifiers, the retrospective and fixers each under the subfolder named for
the agent that runs them — so the person sees one heading for the audit, your
session directly under it, and the fleet grouped by role beneath. A conductor
floating at the top level while its fleet sits in a folder is the shape this
step removes.
Each cycle, in this order:
- Read the ledger — one
session_ledger_readfor your own obligations, andscripts/ledger.py list findingsfor the findings and their folded verdicts. The injected block is a truncated teaser, and every disposition below is a comparison against recorded state; a finding with no verifier is visible only in the findings ledger. - Dispatch what is owed. A filed finding with no verifier gets one. A round whose findings all carry verdicts gets the retrospective. A surface in scope with no auditor gets one, within the concurrency the seed set.
- Review your own obligations, every cycle regardless of what fired: each
pending human gate, each unruled policy-refusal event, each
needs-humanverdict. These are what go missing, because nothing fires to remind you. An entry clears when the obligation is discharged, not when you decide about it. - Record verdicts and state back in one write.
- Close out what is terminal. When a work item reaches a terminal verdict
(accepted, rejected, abandoned/void) and its loop is stopped,
session_closethat child session in the same cycle — a finished worker has nothing left to re-arm. It holds for auditor, verifier, retrospective and fixer sessions alike, and a VOID fixer is closed after itssession_stoprather than left open.session_closearchives (reopenable); it never deletes. Never close a child that still has a pending human question or an unmerged PR it is actively driving. - Report only real signals. A quiet cycle is one line, then end the turn.
Stop conditions
Stop and report, rather than continuing, on any of these:
- Every surface in the round has an auditor verdict, every finding has a verifier
verdict, and the retrospective has proposed its lessons. This is the normal
exit: final tally, then
autonudge_stop. - The rules of engagement have not been reviewed by a human. Nothing is dispatched before that.
- A readiness precondition is missing, unreadable, or answered from the export rather than the ledger. Report the reading, hand over the command that fixes it, and dispatch nothing until it reads ready.
- A worker reports a policy refusal. That surface stops until you rule on the event; the worker does not continue past it, and neither do you.
- A worker reports having circumvented a block, a scope rule, or a forbidden technique. Stop that worker, void its findings for that surface, and report to the human — a fleet that has already crossed a boundary cannot be trusted to stay inside a narrower one.
- A human gate is pending and the remaining work all sits behind it.
scripts/scope_check.pyis absent or unreadable. Without it there is no scope verdict, and your own judgment is not a substitute.- The false-positive rate for the round is high enough that verifiers are the only thing producing signal. Report the rate; a finding stream nobody has measured is not a foundation for a fixer lane.
Whichever fires: before the final report, session_close each remaining child
whose item is terminal — auditor, verifier, retrospective and fixer alike. A
child still holding a pending human question, or a fixer driving an unmerged PR,
stays open: the pending-human-gate stop above fires while a person is
mid-decision on exactly such a child, and a close cancels its turn and
discards that work.
Known limits (state them, don't hide them)
- Every script call is
execute_bash, which is mounted but never auto-approved:allowedToolscannot match arguments, so trusting the bundled scripts would mean trusting arbitrary shell. Unattended operation needs the operator to arm this session in trust mode — without it the patrol stalls on its first scope check, not on its first intervention. Pre-approving shell alone is not enough:session_send(the only way to seed a child, sincesession_createcarries no message),session_stopandsession_closeare not auto-approved either, so the first dispatch stalls too. - The verifier's independence is procedural, not enforced. It comes from a fresh session and a brief that withholds the auditor's reasoning; a shared model can still share a blind spot.
- An auditor's own report is the only evidence that it stayed inside the rules of engagement. The scope script gates what it ASKS about, not what it does, which is why the forbidden list is written as absolutes and why a self-reported circumvention is a stop condition rather than a note.
- A lesson only changes behaviour on the NEXT round, and only after a human approves it. Nothing here learns inside a round.
- A policy block with no finding is recorded as both a golden path and a
lesson. A
policy_blockis deliberately an event rather than a finding, so it has no id for--source-findingto resolve;propose-lessontherefore takes--source-policy-blockinstead, and a lesson names exactly one of the two. Both halves of the retrospective's ruling survive a block that filed nothing — the golden path that stops a future fix re-breaking the operation, and the guidance that stops the next auditor walking into the same refusal. A policy-block reference is not a foreign key and cannot be, since the events are the round's own record rather than a table in the ledger, so this source is attributable but not referentially checked. - One set of rules of engagement = one target. A second target is a second set, reviewed on its own.