Skip to content

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.

tradecraft/
  tenants/
    <your-organisation>/
      playbooks/
        suspicious-signin.yaml
        phishing-triage.yaml

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.

uv run python -m tradecraft.validate path/to/playbook.yaml   # [not yet implemented]

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:

time_reference: site_local     # default
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, tier or deny will 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:

  1. Anything touching operational technology is capped at T1. Power, cooling, building and industrial controls. Writing max_tier: T3 on an OT action yields T1 and an audit entry citing the cap.
  2. An asset whose class cannot be determined is treated as OT. Not knowing what something is must never be safer than knowing.
  3. 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.
  4. Ambiguity resolves downward. Missing or conflicting inputs produce the more restrictive tier.
  5. 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

- in_context: { set: ot_adjacent_accounts, subject: actor }

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.