Reference
Skills
A skill is data, not code: one JSON file the executor interprets. It declares what it operates on, states its rules, and proposes — it never acts.
Anatomy
One file, four parts
{
"id": "acme/my-skill",
"version": "0.1.0",
"name": "Acme Example Skill",
"description": "What it checks and why.",
"skill_type": "VENDOR",
"disclosure_level": "L2",
"migration_policy": "GATED",
"reads": ["headline", "assets"],
"produces": ["skill.warning.raised",
"skill.run.completed"],
"advert": {
"role": "compliance check",
"operates_on": ["story.context"],
"produces": ["skill.warning.raised"],
"fires_on": ["headline",
"assets[].acquisition_state"]
},
"rules": [
{
"rule_id": "acme-style-001",
"type": "term_match",
"config": {
"field": "headline",
"terms": ["cops", "slammed"]
},
"default_severity": "flag",
"affected_fields": ["headline"],
"detail_template":
"Informal term '{term}' in {field}."
}
]
}id in vendor/skill-name form, a semver version, and the governance triple: skill_type, disclosure_level, migration_policy.
The machine-readable claim. If operates_on doesn’t include story.context, the executor skips your skill entirely — and logs exactly that, once, at information level. Check the app log before debugging anything else.
Each has a rule_id, a type, a config, a default_severity, its affected_fields, and a detail_template with {placeholder} substitutions.
hold / flag / inform, lower-case. One of only two deliberate exceptions to SOM’s UPPER_SNAKE enum convention — the other being x- and com.{vendor} extension values.
Most vendors never write code. A skill file plus, optionally, a test scenario is the whole touchpoint — the bus topology, approval gate, audit trail and validation pipeline stay untouched. A custom rule type in the executor is the escape hatch when the seven built-ins genuinely can’t express the logic.
Rule types
Seven built-ins
field is always a dotted path relative to the payload root. Static validation checks that the required config keys are present and that any regex compiles.
| Type | Config | Fires when | Substitutions |
|---|---|---|---|
term_match | field, terms[], case_sensitive? | the field’s text contains one of the terms (once per term) | {term} {field} {value} |
phase_with_missing_field | phase, field, phase_field? | the story is in phase AND field is empty | {phase} {field} |
field_value_in | field, values[] | the field equals one of values | {value} {field} |
field_present | field | the field exists and is non-empty | {field} |
field_absent | field | the field is missing or empty | {field} |
field_regex | field, pattern, case_sensitive? | the field matches the regex | {match} {field} {value} |
field_changed | field, to?, from? | the value differs from the previous story version — optionally only for a given transition | {field} {item} {from} {to} |
It is the only type that looks across versions, and the only one that supports an array wildcard — exactly one [], as in assets[].acquisition_state. Elements are matched between versions by asset_id / source_id / flag_id / id.
It stays quiet on the first sighting of a story, because there is nothing to compare against — and “first sighting” includes the first story after a restart.
Every other rule type treats an array field — compliance, assets — as a whole, checking presence or absence. Per-element logic such as “any compliance flag of type X” means writing a custom rule type in the executor.
Validation
Three layers before you go live
Static
Schema and config-key checks, run automatically on every register or update. Unknown rule types, missing config keys and bad regexes come back as structured errors before anything reaches the bus.
Dry-run
Evaluates your rules against every seed story without publishing. You see precisely which stories fire which rules.
Dry-run has no “previous version”, so field_changed rules can never fire here. Test those live.
AI review
Optional. Ships the skill, the seeds and the dry-run result to an LLM for structured editorial feedback — a second opinion on the rule’s wording and intent, not a gate.
Iteration
The loop you’ll actually run
- Register the skill — static validation runs on the spot.
- Dry-run it against the seed stories and read which rules fired where.
- Review (optional) with the AI pass for a sanity check on wording and intent.
- Go live: publish seeds or run a scenario, and watch your run records and staged outputs appear.
- For a
field_changedrule, mutate a story so there is an actual transition to detect — advance a phase, or drive a media arrival to completion.
Your outputs then ride the same gate as everything else: staged → human decision → som.skills.events or som.skills.rejected, republished in a fresh gate-attributed envelope with the reviewer stamped in payload.extensions and the decision recorded on som.system.audit.
Pitfalls
Why it isn’t working
| Symptom | Cause |
|---|---|
| My skill never runs, but dry-run fires | The advert’s operates_on doesn’t cover story.context. The executor logs this once per skill when the next story arrives. |
A field_changed rule never fires | No previous version this session — first sighting, or a restart. Republish once to establish a baseline, then trigger the transition. Dry-run can never fire change rules. |
| Rule paths resolve to nothing | The config predates the v0.3.x renames: sources[] is now editorial_source[], skills_config.broadcaster is now skills_config.newsroom. See the rename table. |
| Rule fires but the detail reads wrong | The detail_template uses a substitution the rule type doesn’t provide. Each type offers a fixed set — check the table above. |
| Nothing appears for a warning I emitted externally | Message-type names are suffixed on the wire. Consumers route on skill.warning.raised, not skill.warning. |