Skip to content
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -307,5 +307,8 @@ Cargo.lock

.claude

# Local docker compose overrides (env vars, port remaps, secrets)
docker-compose.override.yaml

# docs output
book/
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ For more information about each release including git tags and artifacts, see [R
- Per-action health metrics in the executor ([#191](https://github.com/roostorg/osprey/pull/191) by [@cmttt](https://github.com/cmttt))
- Option to suppress cached errors to reduce metric bloat ([#180](https://github.com/roostorg/osprey/pull/180) by [@lithium-powered](https://github.com/lithium-powered))
- Experimental asyncio-native worker with metrics and engine/coordinator improvements ([#341](https://github.com/roostorg/osprey/pull/341) by [@cmttt](https://github.com/cmttt))
- Experimental in-app rule authoring: draft SML rules validate against the live engine, save to a `rule_drafts` table, and deploy into the configured rules directory ([#402](https://github.com/roostorg/osprey/pull/402) by [@julietshen](https://github.com/julietshen))

### Changed

Expand Down
25 changes: 25 additions & 0 deletions docs/user/manage.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,28 @@ The list is paginated (50 per page) and can be filtered and sorted:
- **Sort**: by name, most referenced, or least referenced

Each row shows the rule's name, source file, description, reference count, and line number within the source file.

## Rule Authoring (Experimental feature)

Users can draft SML rules directly in the UI. Drafts are saved to a `rule_drafts` table so the people who operate Osprey can reference, edit, and deploy them without any external code host.

The editor validates every keystroke against the same AST validator the running engine uses, so compile-time errors surface before a draft is saved. The Rule Builder view expresses the common shape (name, conditions, outcomes) as a form and generates SML; the Code Editor view accepts arbitrary SML for anything the builder can't represent.

### The draft rules table

Drafts live in a Postgres table (`rule_drafts`), one row per rule file path. The API (all gated by the `CAN_EDIT_RULE_DRAFTS` ability, granted to `super_user`):

- `POST /rule-drafts` — re-validates the SML server-side, then upserts the draft.
- `GET /rule-drafts` — lists every draft (the table operators work from).
- `GET /rule-drafts/<id>` — fetches a single draft.
- `POST /rule-drafts/<id>/deploy` — re-validates, writes the SML into the rules directory, and marks the draft `deployed`. Pass `wire_into_main: true` to also append a `Require(rule=...)` line to `main.sml` so the rule takes effect (a rule file is inert until something requires it).

### Deploying

Deploy writes the draft's SML into a rules directory that the engine's sources provider already loads (a filesystem hand-off: whatever pipeline syncs that directory activates the rule).

| Var | Default | Notes |
|---|---|---|
| `OSPREY_RULES_LOCAL_PATH` | _required for deploy_ | Absolute path to the rules directory the engine loads. Deploy writes SML here; must already exist. If unset, `POST /deploy` returns 503 (drafting and validation still work). |
Comment thread
coderabbitai[bot] marked this conversation as resolved.

> **Future direction:** a DB-backed `SourcesProvider` could let the engine load deployed drafts straight from the `rule_drafts` table, removing the filesystem hand-off and making rule management work with zero external infrastructure. This PR keeps the filesystem deploy; the table is already the source of truth for drafts.
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@
{
"name": "CAN_VIEW_EVENTS_BY_ACTION",
"allow_all": true
},
{
"name": "CAN_EDIT_RULE_DRAFTS",
"allow_all": true
}
],
"ability_groups": ["CAN_VIEW_BASIC_USER_DATA"]
Expand Down
8 changes: 8 additions & 0 deletions osprey_worker/src/osprey/worker/lib/osprey_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,14 @@ def _handle_updated_sources(self) -> None:
def execution_graph(self) -> ExecutionGraph:
return self._execution_graph

@property
def udf_registry(self) -> UDFRegistry:
return self._udf_registry

@property
def validator_registry(self) -> ValidatorRegistry:
return self._validator_registry

@property
def config(self) -> SourcesConfig:
return self._execution_graph.validated_sources.sources.config
Expand Down
1 change: 1 addition & 0 deletions osprey_worker/src/osprey/worker/lib/storage/postgres.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ def _init(config: Config) -> None:
bulk_label_task,
pg_stored_execution,
queries,
rule_drafts,
temporary_ability_token,
)

Expand Down
110 changes: 110 additions & 0 deletions osprey_worker/src/osprey/worker/lib/storage/rule_drafts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
from __future__ import annotations

from datetime import datetime, timezone
from enum import StrEnum

from sqlalchemy import BigInteger, Column, DateTime, Enum, Text

from .postgres import Model, scoped_session


def _now() -> datetime:
return datetime.now(timezone.utc)


class RuleDraftStatus(StrEnum):
DRAFT = 'draft'
DEPLOYED = 'deployed'


class RuleDraft(Model):
"""A staged SML rule draft.

Drafts are authored and validated in the UI and live here so the people who
operate Osprey can reference, edit, and deploy them without any external code
host. Deploying writes the SML into the configured rules directory; the draft
row stays as the record of what was deployed.

One row per rule `path` (upserted on submit), so the table reads as the
current set of drafts rather than an append-only history.
"""

__tablename__ = 'rule_drafts'

id: int = Column(BigInteger, primary_key=True, autoincrement=True)
path: str = Column(Text, nullable=False, unique=True)
rule_name: str = Column(Text, nullable=False)
sml_source: str = Column(Text, nullable=False)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You may also want to add a "cid" column here, or "content id" which the the normalised sml_source hashed, and then embedded that into the rules output written to disk so you can actually compare "rule as deploy" vs "rule as draft" (that way a deployed rule doesn't need to be deleted). Though this probably touches more on having a Rules table with a rule identifier/name, and then multiple Rule Versions which are content hashed, and the rules table points to the currently live version (allowing for time-travel to see how a rule evolved over time)

summary: str = Column(Text, nullable=False, default='')
author_email: str = Column(Text, nullable=False)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be worth a comment that osprey doesn't actually have a Users table somewhere, instead it's just that you have an email with ACLs applied.

status: RuleDraftStatus = Column(
Enum(RuleDraftStatus, native_enum=False, length=32),
nullable=False,
default=RuleDraftStatus.DRAFT,
)
created_at: datetime = Column(DateTime(timezone=True), nullable=False, default=_now)
updated_at: datetime = Column(DateTime(timezone=True), nullable=False, default=_now, onupdate=_now)
deployed_at: datetime | None = Column(DateTime(timezone=True), nullable=True) # type: ignore[misc]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if the rule draft is deployed but then deleted from disk by a system admin?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh good catch. Right now the row would still say "deployed" even though the file is gone. I'm treating that as a known gap for now. The content-hash idea you mentioned below is probably the proper fix, since it'd let us compare what's in the table against what's actually on disk


def to_json(self) -> dict[str, object]:
return {
'id': self.id,
'path': self.path,
'rule_name': self.rule_name,
'source': self.sml_source,
'summary': self.summary,
'author': self.author_email,
'status': str(self.status),
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'deployed_at': self.deployed_at.isoformat() if self.deployed_at else None,
}

@classmethod
def upsert(cls, *, path: str, rule_name: str, sml_source: str, summary: str, author_email: str) -> 'RuleDraft':
"""Create the draft for `path`, or update it in place if one already exists.

Editing a deployed draft moves it back to `DRAFT` so the table reflects
that the in-flight SML no longer matches what was last deployed.
"""
with scoped_session(commit=True) as session:
draft = session.query(cls).filter(cls.path == path).first()
if draft is None:
draft = cls(path=path)
session.add(draft)
draft.rule_name = rule_name
draft.sml_source = sml_source
draft.summary = summary
draft.author_email = author_email
draft.status = RuleDraftStatus.DRAFT
session.flush()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
session.expunge(draft)
return draft

@classmethod
def list_all(cls) -> list['RuleDraft']:
with scoped_session() as session:
drafts = session.query(cls).order_by(cls.updated_at.desc()).all()
for draft in drafts:
session.expunge(draft)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you could probably just do session.expunge_all()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return drafts

@classmethod
def get_one(cls, draft_id: int) -> 'RuleDraft | None':
with scoped_session() as session:
draft = session.query(cls).filter(cls.id == draft_id).first()
if draft is not None:
session.expunge(draft)
return draft

@classmethod
def mark_deployed(cls, draft_id: int) -> 'RuleDraft | None':
with scoped_session(commit=True) as session:
draft = session.query(cls).filter(cls.id == draft_id).first()
if draft is None:
return None
draft.status = RuleDraftStatus.DEPLOYED
draft.deployed_at = _now()
session.flush()
session.expunge(draft)
return draft
2 changes: 2 additions & 0 deletions osprey_worker/src/osprey/worker/ui_api/osprey/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ def create_app() -> Flask:
events,
features,
queries,
rule_drafts,
rules,
rules_visualizer,
saved_queries,
Expand Down Expand Up @@ -111,6 +112,7 @@ def create_app() -> Flask:
_register_with_prefix(app, events.blueprint)
_register_with_prefix(app, features.blueprint)
_register_with_prefix(app, rules.blueprint)
_register_with_prefix(app, rule_drafts.blueprint)
_register_with_prefix(app, queries.blueprint)
_register_with_prefix(app, config.blueprint)
_register_with_prefix(app, docs.blueprint)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,7 @@ def _get_query_filter(self) -> dict[str, Any] | None:
CanViewSavedQueries = register_ability('CAN_VIEW_SAVED_QUERIES')(make_marker_ability())
CanCreateAndEditSavedQueries = register_ability('CAN_CREATE_AND_EDIT_SAVED_QUERIES')(make_marker_ability())
CanBulkAction = register_ability('CAN_BULK_ACTION')(make_marker_ability())
CanEditRuleDrafts = register_ability('CAN_EDIT_RULE_DRAFTS')(make_marker_ability())


def require_ability_with_request(request_model: ModelT, ability_class: Type[Ability[ModelT, ItemT]]) -> None:
Expand Down
Loading