SML (Osprey Rules) Reference
SML is a Python-like language for defining moderation rules in Osprey. Rules extract data from events, evaluate conditions, and emit effects (labels, verdicts, list actions).
Core Type System
Four fundamental types make up SML:
EntityJson & Entity — Typed Identifiers
EntityJson extracts entity IDs from event JSON; Entity constructs them from known values. Labels attach to entities, not raw strings.
# CORRECT — UserId is an Entity; labels attach to it
UserId: Entity[str] = EntityJson(type='UserId', path='$.did', required=False)
# WRONG — JsonData produces str, not Entity[str]. Labels can't attach to str.
UserId: str = JsonData(path='$.did')
EntityJson(type, path, required) → Entity[str] or Entity[int]. Use for IDs: UserId, Handle, AtUri, PdsHost.
Entity(type, id) → construct entity from known values (e.g., computed AT-URI).
JsonData — Primitive Extraction
JsonData extracts primitive values from event JSON.
DisplayName: str = JsonData(path='$.eventMetadata.profile.displayName', required=False, coerce_type=True)
PostsCount: int = JsonData(path='$.eventMetadata.profile.postsCount', required=False)
Returns: str, int, float, bool, Optional[T], or List[T].
Optional[T] — Nullable Types
Use ResolveOptional to unwrap optional values with a default.
AccountAgeSeconds: Optional[int] = JsonData(path='$.eventMetadata.accountAge', required=False)
AccountAgeSecondsUnwrapped: int = ResolveOptional(optional_value=AccountAgeSeconds, default_value=999999999)
Core Constructs
Rule — AND of Conditions
Rule(when_all=[...], description=f'...') # → RuleT
Combines multiple conditions with AND logic. All items in when_all must be the same type (all bool or all RuleT).
NewAccountSpam = Rule(
when_all=[
AccountAgeSeconds < Day,
PostsCount > 50,
FollowersCount < 5,
],
description=f'New account spam (age={AccountAgeSeconds}s, posts={PostsCount})',
)
WhenRules — OR Trigger + Effects
WhenRules(rules_any=[...], then=[...]) # → None
Triggers effects when ANY rule passes. Always use rules_any=, never rules_all=.
WhenRules(
rules_any=[NewAccountSpam, BotBehavior],
then=[
LabelAdd(entity=UserId, label='spam'),
AtprotoLabel(entity=UserId, label='spam', comment='Auto-detected', expiration_in_hours=168),
],
)
Import & Require
Import(rules=['path/to/file.sml']) # Load models/rules from other files
Require(rule='path/to/file.sml', require_if=condition) # Conditional inclusion
Operators & Type Rules
All items in when_all must be the same type:
RegexMatch(...), comparisons (X < Y), or/and on bools → bool
Rule(...) → RuleT; RuleT or RuleT → RuleT
- Use infix
or (A or B or C), NOT function-call or(A, B, C)
not works on both bool and RuleT
# CORRECT — all bool
WhenRules(
rules_any=[
Rule(when_all=[PostText != '', RegexMatch(pattern=r'...', target=PostText)]),
],
then=[...],
)
# WRONG — mixing RuleT and bool in when_all
Rule(when_all=[MyRule, SomeCondition == True]) # Can't mix Rule and bool
Effects
Apply effects when rules pass:
LabelAdd(entity, label, apply_if?, expires_after?, delay_action_by?) — add label
LabelRemove(entity, label, ...) — remove label
AtprotoLabel(entity, label, comment, expiration_in_hours) — emit to Bluesky Ozone
AtprotoTag(entity, tag, comment, neg?, apply_if?) — add/remove Ozone tag (neg=True to remove)
DeclareVerdict(verdict) — for synchronous callers
WhenRules(
rules_any=[SomeRule],
then=[
LabelAdd(entity=UserId, label='flagged', expires_after=TimeDelta(days=7)),
LabelAdd(entity=AtUri, label='violation'),
],
)
Key UDFs Quick Reference
| UDF |
Parameters |
Returns |
Purpose |
RegexMatch |
pattern, target, case_insensitive? |
bool |
Regex test |
IncrementWindow |
key, window_seconds, when_all |
int |
Sliding window counter |
GetWindowCount |
key, window_seconds, when_all |
int |
Read counter without incrementing |
ListContains |
list, phrases, case_sensitive?, word_boundaries? |
Optional[str] |
Match against YAML word list |
CensorizedListContains |
list, phrases, plurals?, must_be_censorized? |
Optional[str] |
Match lookalike/obfuscated text |
HasLabel |
entity, label, manual?, status?, min_label_age? |
bool |
Check if entity has label |
HasAtprotoLabel |
entity, label |
bool |
Check AT Protocol label |
TimeDelta |
weeks?, days?, hours?, minutes?, seconds? |
TimeDeltaT |
Create a duration |
AnalyzeToxicity |
text, when_all |
Optional[float] |
ML toxicity score |
AnalyzeSentiment |
text, when_all |
Optional[float] |
ML sentiment polarity |
CacheSetStr |
key, value, when_all, ttl_seconds? |
None |
Store string in Redis |
CacheGetStr |
key, when_all, default? |
str |
Read string from Redis |
Progressive Disclosure
For detailed patterns and implementation examples, see:
- 25 Labeling Patterns —
references/labeling-patterns.md. Covers all common use cases: content matching, rate limiting, strike systems, ML scoring, cross-entity labeling, caching, and more.
- Naming Conventions & Anti-Patterns —
references/sml-conventions.md. Variable naming, time constants, RegexMatch rules, IncrementWindow keys, type system pitfalls, and what NOT to do. Also includes a Reviewer Checklist section with structured CONV-prefixed check IDs for systematic convention review.
1---2name: skywatch-osprey-sml-reference3description: Use when writing SML rules for Osprey — syntax questions, type system, naming conventions, labeling patterns, entity extraction, window counting, or label operations4---56# SML (Osprey Rules) Reference78SML is a Python-like language for defining moderation rules in Osprey. Rules extract data from events, evaluate conditions, and emit effects (labels, verdicts, list actions).910## Core Type System1112Four fundamental types make up SML:1314### EntityJson & Entity — Typed Identifiers1516`EntityJson` extracts entity IDs from event JSON; `Entity` constructs them from known values. **Labels attach to entities, not raw strings.**1718```python19# CORRECT — UserId is an Entity; labels attach to it20UserId: Entity[str] = EntityJson(type='UserId', path='$.did', required=False)2122# WRONG — JsonData produces str, not Entity[str]. Labels can't attach to str.23UserId: str = JsonData(path='$.did')24```2526- `EntityJson(type, path, required)` → `Entity[str]` or `Entity[int]`. Use for IDs: `UserId`, `Handle`, `AtUri`, `PdsHost`.27- `Entity(type, id)` → construct entity from known values (e.g., computed AT-URI).2829### JsonData — Primitive Extraction3031`JsonData` extracts primitive values from event JSON.3233```python34DisplayName: str = JsonData(path='$.eventMetadata.profile.displayName', required=False, coerce_type=True)35PostsCount: int = JsonData(path='$.eventMetadata.profile.postsCount', required=False)36```3738Returns: `str`, `int`, `float`, `bool`, `Optional[T]`, or `List[T]`.3940### Optional[T] — Nullable Types4142Use `ResolveOptional` to unwrap optional values with a default.4344```python45AccountAgeSeconds: Optional[int] = JsonData(path='$.eventMetadata.accountAge', required=False)46AccountAgeSecondsUnwrapped: int = ResolveOptional(optional_value=AccountAgeSeconds, default_value=999999999)47```4849## Core Constructs5051### Rule — AND of Conditions5253```python54Rule(when_all=[...], description=f'...') # → RuleT55```5657Combines multiple conditions with AND logic. All items in `when_all` must be the same type (all `bool` or all `RuleT`).5859```python60NewAccountSpam = Rule(61 when_all=[62 AccountAgeSeconds < Day,63 PostsCount > 50,64 FollowersCount < 5,65 ],66 description=f'New account spam (age={AccountAgeSeconds}s, posts={PostsCount})',67)68```6970### WhenRules — OR Trigger + Effects7172```python73WhenRules(rules_any=[...], then=[...]) # → None74```7576Triggers effects when ANY rule passes. **Always use `rules_any=`, never `rules_all=`.**7778```python79WhenRules(80 rules_any=[NewAccountSpam, BotBehavior],81 then=[82 LabelAdd(entity=UserId, label='spam'),83 AtprotoLabel(entity=UserId, label='spam', comment='Auto-detected', expiration_in_hours=168),84 ],85)86```8788### Import & Require8990```python91Import(rules=['path/to/file.sml']) # Load models/rules from other files92Require(rule='path/to/file.sml', require_if=condition) # Conditional inclusion93```9495## Operators & Type Rules9697**All items in `when_all` must be the same type:**9899- `RegexMatch(...)`, comparisons (`X < Y`), `or`/`and` on bools → `bool`100- `Rule(...)` → `RuleT`; `RuleT or RuleT` → `RuleT`101- Use infix `or` (`A or B or C`), NOT function-call `or(A, B, C)`102- `not` works on both `bool` and `RuleT`103104```python105# CORRECT — all bool106WhenRules(107 rules_any=[108 Rule(when_all=[PostText != '', RegexMatch(pattern=r'...', target=PostText)]),109 ],110 then=[...],111)112113# WRONG — mixing RuleT and bool in when_all114Rule(when_all=[MyRule, SomeCondition == True]) # Can't mix Rule and bool115```116117## Effects118119Apply effects when rules pass:120121- `LabelAdd(entity, label, apply_if?, expires_after?, delay_action_by?)` — add label122- `LabelRemove(entity, label, ...)` — remove label123- `AtprotoLabel(entity, label, comment, expiration_in_hours)` — emit to Bluesky Ozone124- `AtprotoTag(entity, tag, comment, neg?, apply_if?)` — add/remove Ozone tag (`neg=True` to remove)125- `DeclareVerdict(verdict)` — for synchronous callers126127```python128WhenRules(129 rules_any=[SomeRule],130 then=[131 LabelAdd(entity=UserId, label='flagged', expires_after=TimeDelta(days=7)),132 LabelAdd(entity=AtUri, label='violation'),133 ],134)135```136137## Key UDFs Quick Reference138139| UDF | Parameters | Returns | Purpose |140|-----|-----------|---------|---------|141| `RegexMatch` | `pattern, target, case_insensitive?` | `bool` | Regex test |142| `IncrementWindow` | `key, window_seconds, when_all` | `int` | Sliding window counter |143| `GetWindowCount` | `key, window_seconds, when_all` | `int` | Read counter without incrementing |144| `ListContains` | `list, phrases, case_sensitive?, word_boundaries?` | `Optional[str]` | Match against YAML word list |145| `CensorizedListContains` | `list, phrases, plurals?, must_be_censorized?` | `Optional[str]` | Match lookalike/obfuscated text |146| `HasLabel` | `entity, label, manual?, status?, min_label_age?` | `bool` | Check if entity has label |147| `HasAtprotoLabel` | `entity, label` | `bool` | Check AT Protocol label |148| `TimeDelta` | `weeks?, days?, hours?, minutes?, seconds?` | `TimeDeltaT` | Create a duration |149| `AnalyzeToxicity` | `text, when_all` | `Optional[float]` | ML toxicity score |150| `AnalyzeSentiment` | `text, when_all` | `Optional[float]` | ML sentiment polarity |151| `CacheSetStr` | `key, value, when_all, ttl_seconds?` | `None` | Store string in Redis |152| `CacheGetStr` | `key, when_all, default?` | `str` | Read string from Redis |153154## Progressive Disclosure155156For detailed patterns and implementation examples, see:157158- **25 Labeling Patterns** — `references/labeling-patterns.md`. Covers all common use cases: content matching, rate limiting, strike systems, ML scoring, cross-entity labeling, caching, and more.159- **Naming Conventions & Anti-Patterns** — `references/sml-conventions.md`. Variable naming, time constants, RegexMatch rules, IncrementWindow keys, type system pitfalls, and what NOT to do. Also includes a **Reviewer Checklist** section with structured CONV-prefixed check IDs for systematic convention review.