Authoring Tradecraft — Playbook Reference¶
Audience: the person who owns an organisation's detection and response methods. Prerequisite: none. You do not need to write code, and you will not be asked to.
Your tradecraft is yours. It loads into the harness as structured data the platform reads at run time — never compiled into the product, never visible to another customer, and portable if you leave. This document is the complete contract for that data: every field, every value, and what the platform will and will not do with it.
Status. All of this works. The schema validates, the interpreter runs every construct it permits, and the contract in §16 is settled. You can author a playbook, save it in the console, and see the next run behave differently.
1. The idea¶
The system has two halves with a hard line between them.
| You own | Red Alpha owns | |
|---|---|---|
| What | Judgment — what counts as suspicious, which assets matter, how severe it is, what to do | Mechanism — running agents, calling tools, enforcing approvals, logging everything |
| Form | Declarative playbooks (this document) | Platform code |
| Changes | Edit a file, next run behaves differently | A product release |
A playbook is one scenario's worth of judgment: what to react to, what facts to gather, how to decide, and what may be proposed about it.
The test that keeps the line honest: if a behaviour can only be achieved by changing platform code, it is a platform feature; if it is your judgment, it must be expressible in a playbook. If you hit something you cannot express, that is a gap in the schema and we want to hear about it — the fix is a schema version, not a bespoke build for you.
2. File format and location¶
Playbooks are YAML or JSON. JSON is a subset of YAML, so both parse identically and the schema applies unchanged. YAML is recommended because it supports comments, which matter for a document that encodes reasoning.
In production, this directory is your own encrypted, access-controlled repository. The platform reads it; nothing else does.
One playbook per (organisation, event_type). Two playbooks claiming the same
trigger is a load-time error, not a run-time coin flip.
3. Validating a playbook¶
Validate before you commit. The schema is
playbook.schema.json,
JSON Schema draft 2020-12.
Until that command ships, CI validates every committed playbook on every push, and any JSON Schema validator will do:
uv run python - <<'PY' tradecraft/tenants/<you>/playbooks/suspicious-signin.yaml
import json, pathlib, sys, yaml, jsonschema
schema = json.loads(pathlib.Path("tradecraft/schema/playbook.schema.json").read_text())
doc = yaml.safe_load(pathlib.Path(sys.argv[1]).read_text())
jsonschema.Draft202012Validator(schema).validate(doc)
print("valid")
PY
The platform refuses to run anything it cannot validate, and refuses it at load time with a path-qualified error. A malformed playbook never half-executes, and the previously valid version stays live.
4. Anatomy¶
A playbook has nine required sections and three optional ones.
schema_version: "1.0" # required — which contract this file targets
id: suspicious-signin # required — stable, unique within your organisation
name: Suspicious Sign-In # required — human-readable
description: >- # optional — why this exists; future you will want it
Decide whether a flagged login is probably a compromised account.
tenant: your-org # required — your organisation id
version: 1 # optional — your revision number, bump on each edit
trigger: # required — what starts this playbook
detection: # required — the rules that decide whether it matters
severity: # required — how bad it is
responses: # required — what may be proposed, and at what autonomy
evidence: # required — what a recommendation must carry
enrichment: # optional — facts to gather first
context: # optional — named sets of things you consider significant
Field reference¶
| Field | Type | Required | Rules |
|---|---|---|---|
schema_version |
string | yes | Exactly "1.0". Quote it — unquoted 1.0 is a number and will fail. |
id |
string | yes | Lowercase letters, digits and hyphens; must start alphanumeric. |
name |
string | yes | At least one character. |
description |
string | no | Free text. |
tenant |
string | no* | Same pattern as id. *Required by the schema; it must match your organisation id. |
version |
integer | no | ≥ 1. Increment when you change the file. |
time_reference |
string | no | Which clock time-of-day rules use. See §5a. |
trigger |
object | yes | See §5. |
enrichment |
array | no | See §6. |
context |
object | no | See §7. |
detection |
object | yes | See §8. |
severity |
object | yes | See §9. |
responses |
array | yes | See §10. At least one. |
evidence |
object | yes | See §11. |
Unknown fields are rejected. That is deliberate: a typo'd key that was silently ignored would mean a rule you believe is active quietly doing nothing.
5. Trigger¶
What starts this playbook.
trigger:
event_type: identity.signin.flagged
conditions: # optional pre-filter; all must hold
- fact: { name: signin.source, op: eq, value: primary-idp }
| Field | Required | Meaning |
|---|---|---|
event_type |
yes | The class of environment event this responds to. |
conditions |
no | If present, the playbook only runs when all of them hold. |
5a. Time¶
Any rule mentioning time of day needs a clock, and the answer changes the verdict. 02:14 is the middle of the night at a site and mid-afternoon on the other side of the world — an "off-hours" rule gives opposite results under each reading. Declare which one you mean:
| Value | Means | Use when |
|---|---|---|
site_local |
The local time where the asset is | You care whether someone was active when nobody should have been on site |
actor_local |
The local time where the person is | Your people are distributed and work across time zones |
utc |
Coordinated Universal Time | You want one clock regardless of geography |
Facts carry an explicit timezone, and the interpreter resolves local-time fields such
as signin.local_hour against your reference. Declaring it is how the choice stays
yours, rather than being decided by where a server happens to run.
Worth setting even if no rule currently reads the clock — the next person to add one will inherit whatever is written here.
6. Enrichment¶
Read-only facts to gather before judging. Each names a capability — an abstract ability, not a product. The platform resolves it to a permissioned tool and refuses anything your organisation has not been granted.
enrichment:
- capability: identity.signin.read
as: signin # bind the result to this name for use in rules
required: true # abstain rather than judge on partial facts
- capability: identity.device.read
as: device # required defaults to false
| Field | Required | Meaning |
|---|---|---|
capability |
yes | What to fetch. |
as |
no | Name the result binds to. Rules refer to <as>.<field>. |
required |
no | Default false. When true, the playbook halts rather than judging without it. |
Enrichment is always read-only. Nothing in this section can change anything, and that is enforced by the naming grammar below rather than left to good intentions.
Capability names¶
Capability names are a published contract. Once you have written playbooks against them, renaming one would break your tradecraft — so the grammar is fixed:
<domain>.<object>.<verb> three lowercase segments
identity.signin.read domain=identity object=signin verb=read
identity.account.disable domain=identity object=account verb=disable
assets.reachability.read domain=assets object=reachability
The verb carries the side effect. read, list and search gather information and
change nothing; every other verb acts. An enrichment step must use a reading verb, which
is checked automatically — a side-effecting verb in enrichment would mean a playbook
changing something before anyone has judged whether it should.
Catalogue¶
The capabilities available today. This grows; it does not get renamed.
| Capability | Side effect | What it does |
|---|---|---|
identity.signin.read |
none | Details of the sign-in that triggered the alert |
identity.baseline.read |
none | The account's normal pattern — locations, devices, hours |
identity.device.read |
none | Reputation and history of the device used |
identity.account.read |
none | Account attributes and group membership |
geo.location.read |
none | Geolocation for an address |
assets.reachability.read |
none | Whether a path exists from an account toward a class of asset |
identity.authentication.challenge |
acts | Force re-authentication (MFA re-challenge) |
identity.account.disable |
acts | Temporarily disable an account |
If you need a capability that does not exist, ask. It is added to the catalogue for everyone rather than built privately for one organisation — that is what stops the product becoming a services engagement.
7. Context¶
Named sets of things you consider significant, referenced by rules. Either list members explicitly, or select them with conditions.
context:
ot_adjacent_accounts:
description: Accounts with a reachable path toward building controls.
selector:
- fact: { name: ot_paths.count, op: gt, value: 0 }
privileged_groups:
description: Groups where compromise is most costly.
members: [finance, admins]
| Field | Required | Meaning |
|---|---|---|
description |
no | Why this set matters. Worth writing. |
members |
no | Explicit list. |
selector |
no | Conditions evaluated against the environment. |
Referenced from rules with in_context — see §12.
Name these carefully. Context set names, rule ids and playbook ids become reserved words in the platform's own source, so a set named
audit,policy,tierordenywill be rejected by CI. Prefer specific names (ot_adjacent_accounts) over generic ones (critical).
8. Detection¶
The rules that decide whether the event matters. This is the heart of your tradecraft.
detection:
default_classification: benign # optional — the verdict when nothing matches
rules:
- id: new-country-new-device-off-hours
description: All three together, or it is not interesting.
classification: suspicious
when:
- fact: { name: signin.country, op: is_new, against: baseline.countries }
- fact: { name: signin.device_id, op: is_new, against: baseline.devices }
- fact:
name: signin.local_hour
op: not_in
value: [7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]
| Field | Required | Meaning |
|---|---|---|
id |
yes | Stable identifier, unique in this playbook. Appears in the audit log. |
description |
no | Why this rule exists. |
when |
yes | Conditions, combined with AND. |
classification |
no | The verdict this rule asserts. |
classification is one of, in increasing order of concern:
| Value | Meaning |
|---|---|
benign |
Explained; no action warranted. |
inconclusive |
Not enough to decide. |
suspicious |
Warrants attention. |
likely_compromise |
Treat as a probable incident. |
At least one rule is required.
When several rules match¶
The most severe classification wins, on the ordering above. Rule order in the file does not matter, and a later rule cannot talk a more serious one down. This mirrors how severity already resolves, and it means adding a rule can only ever raise concern — so a new rule cannot silently weaken an existing one.
Every matching rule's id is recorded, not just the winner, so the audit trail shows everything that fired.
When no rule matches¶
default_classification decides, and defaults to benign. Set it explicitly to say
what your silence means:
benign— "my rules cover this; nothing matched, so there is nothing here."inconclusive— "my rules do not cover this, so a human should look."
The distinction matters because "nothing matched" and "we judged it fine" are indistinguishable in an audit log afterwards, and only one of them is a judgment.
9. Severity¶
How bad it is. A default, plus conditional adjustments.
severity:
default: medium
modifiers:
- description: Reaching toward physical infrastructure is what makes this urgent.
set: high
when:
- in_context: { set: ot_adjacent_accounts, subject: actor }
Levels, lowest to highest: info, low, medium, high, critical.
Modifiers are evaluated in order and the highest resulting severity wins — a later modifier cannot lower what an earlier one raised. Severity escalates; it does not negotiate.
10. Responses¶
What may be proposed, and the highest autonomy each may run at.
responses:
- capability: identity.force_reauthentication
description: Re-challenge MFA. Reversible, low blast radius.
max_tier: T1
- capability: identity.disable_account
description: Temporarily disable. Changes state, so a human approves.
max_tier: T2
when: # optional guard
- in_context: { set: ot_adjacent_accounts, subject: actor }
Autonomy tiers¶
| Tier | The agent | Typical use |
|---|---|---|
T0 |
Reads, analyses, reports. Takes no action. | Default for anything physical |
T1 |
Proposes with evidence; a human decides. | High-impact actions |
T2 |
Executes only after a human approves. | Contained, reversible actions |
T3 |
Acts alone, within guardrails. | Low-risk only, and earned |
A response you do not list can never be proposed. Omission is the strongest control in this document — it is how Tenant B in our reference example forbids account disabling entirely.
What the platform enforces regardless of what you write¶
These are not configurable, and a playbook asking for more is lowered rather than rejected:
- Anything touching operational technology is capped at T1. Power, cooling,
building and industrial controls. Writing
max_tier: T3on an OT action yields T1 and an audit entry citing the cap. - An asset whose class cannot be determined is treated as OT. Not knowing what something is must never be safer than knowing.
- T3 is earned, not declared. An action type reaches T3 only after a clean evaluation record, and one failure demotes it. Until then it behaves as T2.
- Ambiguity resolves downward. Missing or conflicting inputs produce the more restrictive tier.
- A maintenance window holds state-changing actions for a human.
Your max_tier is a ceiling the platform may lower and will never raise.
11. Evidence¶
What a recommendation must carry before a human is asked to approve it. The platform refuses to surface a recommendation missing any required item.
evidence:
require:
- signin.country
- signin.device_id
- signin.timestamp
- baseline.normal_pattern
- matched_rule_ids
This is a quality gate you control. It is also what makes an agent's narrative testable: "the recommendation cited everything required" is checkable, where "the explanation was good" is not.
12. The condition language¶
Conditions appear in trigger.conditions, context.*.selector, detection.rules[].when
and responses[].when. A list of conditions is combined with AND.
Each condition is exactly one of five forms.
fact — compare a gathered fact¶
- fact: { name: signin.country, op: is_new, against: baseline.countries }
- fact: { name: ot_paths.count, op: gt, value: 0 }
- fact: { name: user.department, op: in, value: [finance, legal] }
| Operator | Meaning |
|---|---|
eq / ne |
Equal / not equal |
gt / gte / lt / lte |
Numeric comparison |
in / not_in |
Membership in a list |
is_new |
Not present in the collection named by against (required) |
matches |
Matches a pattern |
within |
Falls inside a range or window |
is_new requires against, and it names the exact collection as a dotted path:
against: baseline.countries reads "this country does not appear in the account's
known countries". Pointing at baseline alone would leave the interpreter guessing
which part of a record counts as "seen before" — a guess you could not see in your
own file. The schema rejects is_new without it.
Facts that could not be gathered¶
A fact the platform could not retrieve does not compare as false. It abstains: the condition cannot be decided, so the enclosing rule cannot match, the incident is marked as resting on partial evidence, and the evidence gate withholds the recommendation.
This is deliberate and occasionally inconvenient. Treating a missing fact as false is
a safety bug that looks like a correct verdict — a rule reading "the device is not
known" would fire merely because the device lookup timed out. Mark an enrichment step
required: true when a rule must not be evaluated without it.
in_context — membership of a named set¶
subject defaults to actor (the user or entity the event is about).
all_of, any_of, not — composition¶
- any_of:
- fact: { name: signin.country, op: is_new }
- fact: { name: signin.device_id, op: is_new }
- not:
fact: { name: user.status, op: eq, value: service_account }
all_of and any_of take a list; not takes a single condition.
The operator set is fixed, and that is a feature. Playbooks are declarative and not a programming language, which is what lets them be reviewed, diffed, statically checked, and safely interpreted — and what lets us say the platform executes nothing outside the schema. If you need an operator that does not exist, ask: it becomes a schema version, available to everyone, not a private extension.
13. A complete example¶
# Suspicious sign-in triage.
schema_version: "1.0"
id: suspicious-signin
name: Suspicious Sign-In Triage
description: >-
Decide whether a flagged login is probably a compromised account, and recommend
what to do. Raises severity when the account can reach physical infrastructure.
tenant: your-org
version: 1
# "Off-hours" below is read against the site's clock.
time_reference: site_local
trigger:
event_type: identity.signin.flagged
enrichment:
- capability: identity.signin.read
as: signin
required: true
- capability: identity.baseline.read
as: baseline
required: true
- capability: identity.device.read
as: device
- capability: assets.reachability.read
as: ot_paths
required: true
context:
ot_adjacent_accounts:
description: Accounts with a reachable path toward building controls.
selector:
- fact: { name: ot_paths.count, op: gt, value: 0 }
detection:
# Nothing matched means nothing of concern, recorded as a judgment.
default_classification: benign
rules:
- id: new-country-new-device-off-hours
classification: suspicious
when:
- fact: { name: signin.country, op: is_new, against: baseline.countries }
- fact: { name: signin.device_id, op: is_new, against: baseline.devices }
- fact:
name: signin.local_hour
op: not_in
value: [7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]
- id: suspicious-and-ot-adjacent
description: On an OT-adjacent account, treat the same signals as a probable incident.
classification: likely_compromise
when:
- fact: { name: signin.country, op: is_new, against: baseline.countries }
- fact: { name: signin.device_id, op: is_new, against: baseline.devices }
- in_context: { set: ot_adjacent_accounts, subject: actor }
severity:
default: medium
modifiers:
- description: OT adjacency is what makes this urgent.
set: high
when:
- in_context: { set: ot_adjacent_accounts, subject: actor }
responses:
- capability: identity.authentication.challenge
description: Re-challenge MFA.
max_tier: T1
- capability: identity.account.disable
description: Temporarily disable the account.
max_tier: T2
evidence:
require:
- signin.country
- signin.device_id
- signin.timestamp
- baseline.normal_pattern
- ot_paths.summary
- matched_rule_ids
The same playbook in JSON is in
examples/suspicious-signin.json.
14. Two organisations, one platform¶
The clearest demonstration of what this split buys you. Two playbooks, same trigger, same platform binary, different verdicts:
| Organisation A | Organisation B | |
|---|---|---|
| Suspicious when | new country and new device and off-hours | new country and new device, any hour |
| Sensitive accounts | any with a path toward OT | finance and admin groups (no OT) |
| Severity | high when OT-adjacent |
medium by default |
| May propose | identity.authentication.challenge (T1), identity.account.disable (T2) |
identity.authentication.challenge only (T2) |
| Verdict on the same alert | likely_compromise / high |
suspicious / medium |
Not one line of platform code differs. Neither organisation's playbook is visible to the other, and an automated check fails the build if any customer-specific content reaches platform source.
15. Editing and versioning¶
- Playbooks are edited by the tradecraft owner, in the console's Playbook screen. An analyst sees the same file read-only.
- A change takes effect on the next run — no rebuild, restart or deployment. The store re-reads and re-validates every time it is asked, so there is no cache to invalidate and nothing to restart.
- An edit that fails validation is refused, and your previous version keeps running. You cannot take your own detection offline with a typo.
- Every edit is audited with author, organisation and new version.
- Every incident records the exact playbook version that produced it, so a verdict can always be traced to the judgment that caused it.
- An edit that fails validation is refused; the previous version stays live.
16. Settled semantics¶
These were open while the schema was being designed. They are now fixed, and each is enforced by the schema or by a test rather than left as a convention.
| # | Question | Answer | How it is held |
|---|---|---|---|
| 1 | Two rules match with different classifications — which wins? | The most severe, on benign < inconclusive < suspicious < likely_compromise. File order is irrelevant; every matching rule id is still recorded. |
Interpreter, tested |
| 2 | No rule matches — what is the verdict? | detection.default_classification, defaulting to benign. You choose whether your silence means "nothing here" or "a human should look". |
Schema field |
| 3 | An optional enrichment is unavailable — false, or abstain? | Abstain. The rule cannot match, the incident is marked as partial, and the evidence gate withholds the recommendation. | Interpreter, tested |
| 4 | is_new — new relative to what? |
An explicit against: <bound enrichment>. Rejected at validation without it. |
Schema, required |
| 5 | Whose clock does an off-hours rule use? | time_reference: site_local (default), actor_local, or utc. |
Schema field |
| 6 | Capability naming | <domain>.<object>.<verb>, three lowercase segments, with the verb carrying the side effect. Enrichment verbs must be reading verbs. |
Enforced by test |
The reasoning behind each is in ADR 0005 and ADR 0006.
Versioning from here¶
schema_version is "1.0". The compatibility policy, now that the contract is
published:
- Additive changes — a new optional field, a new capability, a new enum value — raise the minor version. Existing playbooks keep working.
- Breaking changes — removing or renaming a field, tightening a rule, changing a default — raise the major version, and both versions are supported while playbooks migrate.
- Capability names are never renamed. A superseded capability is deprecated and kept working, with an alias to its replacement.
Every incident records the schema version and the playbook version that produced it, so a verdict can always be traced to the exact judgment that caused it.
Related¶
- Glossary — any term here that did not land the first time
playbook.schema.json— the machine-readable contractdocs/decisions/0002-platform-tradecraft-boundary.md— how the boundary is enforced