Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/mcp-tools-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,14 +308,14 @@ Create a rule that fires during sync. Actions compose (`set_category` + `add_tag
|-----------|------|-------------|
| `name` | string | Human-readable rule name |
| `conditions` | object | Condition tree. Omit or `{}` for match-all. Supports `and` / `or` / `not` nesting up to depth 10. Numeric fields (incl. `amount`) add `approx` (`value` + sibling `tolerance`) and `between` (sibling `min`/`max`). Date-part fields derived from the tz-naive `date` — `day_of_month`, `month`, `day_of_week` (`0`=Sun), `day_of_year` — are numeric; `day_of_month approx` is cyclic + clamped (31 matches Feb's last day). Encode annual cadence as `month` + `day_of_month` (leap-robust), not `day_of_year`. Leaf fields also include `metadata.<key>` to read a key from the free-form metadata blob (ops: `eq`/`neq`/`contains`/`not_contains`/`matches`/`in`/`gt`/`gte`/`lt`/`lte`/`exists`/`not_exists`; an absent key matches only `not_exists`). Full grammar: `docs/rule-dsl.md`. |
| `actions` | array | Typed actions: `set_category`, `add_tag`, `remove_tag`, `add_comment`, `set_metadata` (`metadata_key` + `metadata_value`, any JSON), `remove_metadata` (`metadata_key`), `assign_series`. Either this or `category_slug` is required. |
| `actions` | array | Typed actions: `set_category`, `add_tag`, `remove_tag`, `add_comment`, `set_metadata` (`metadata_key` + `metadata_value`, any JSON), `remove_metadata` (`metadata_key`), `assign_series`, `flag` (no params — sets `flagged_at`, surfacing the txn for attention), `unflag` (no params — clears `flagged_at`). Either this or `category_slug` is required. |
| `category_slug` | string | Shorthand for `actions=[{type:set_category,category_slug:...}]` |
| `trigger` | string | `on_create` (default) / `on_change` / `always`. `on_update` accepted as legacy alias. |
| `stage` | string | **Preferred.** Semantic pipeline stage: `baseline` / `standard` / `refinement` / `override`. Resolves to priority `0 / 10 / 50 / 100`. |
| `priority` | int | Raw pipeline-stage integer, 0–1000. Use for fine-grained slotting within a stage. If both `stage` and `priority` are supplied, `priority` wins. Defaults to `10` (standard) if neither is provided. |
| `enabled` | bool | Default true |
| `expires_in` | string | Optional duration (e.g., `24h`, `30d`, `1w`) |
| `apply_retroactively` | bool | Also back-fill matching existing transactions (materializes `set_category` / `add_tag` / `remove_tag` / `set_metadata` / `remove_metadata` / `assign_series`; `add_comment` is sync-only) |
| `apply_retroactively` | bool | Also back-fill matching existing transactions (materializes `set_category` / `add_tag` / `remove_tag` / `set_metadata` / `remove_metadata` / `assign_series` / `flag` / `unflag`; `add_comment` is sync-only) |

### list_transaction_rules (Read)

Expand Down Expand Up @@ -360,7 +360,7 @@ Delete a rule by ID. System-seeded rules can't be deleted — disable them via u

### apply_rules (Write)

Retroactive apply. Pass `rule_id` for a single rule (no chaining — that rule evaluates in isolation), or omit to run the full active-rule pipeline in priority-ASC order with the same chaining semantics as sync. Materializes `set_category` / `add_tag` / `remove_tag`; skips `add_comment`. Ignores `rule.trigger` (retroactive is a bulk op).
Retroactive apply. Pass `rule_id` for a single rule (no chaining — that rule evaluates in isolation), or omit to run the full active-rule pipeline in priority-ASC order with the same chaining semantics as sync. Materializes `set_category` / `add_tag` / `remove_tag` / `set_metadata` / `remove_metadata` / `assign_series` / `flag` / `unflag`; skips `add_comment`. Ignores `rule.trigger` (retroactive is a bulk op).

| Parameter | Type | Description |
|-----------|------|-------------|
Expand Down
25 changes: 24 additions & 1 deletion docs/rule-dsl.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,26 @@ Deletes **one key** from the metadata blob. No-op if the key isn't present. Repe

- **Net-diff with `set_metadata`** in a single pass: if an earlier-stage rule sets a key and a later-stage rule removes the same key, they cancel — neither hits the DB. A remove-then-set ends as a set. Mirrors `add_tag` / `remove_tag` net-diff semantics.

### `flag`

```json
{ "type": "flag" }
```

Surfaces a matching transaction for human attention by setting `transactions.flagged_at = NOW()`. Takes no parameters. This is the declarative counterpart to the `flag_transaction` MCP tool (minus the tool's optional comment `reason`, which is a per-call affordance). Retrieve flagged rows with `query_transactions(flagged=true)`.

- **Last-writer-wins** across the pipeline: a higher-priority rule's `flag` / `unflag` overrides a lower one (a transaction is flagged or it isn't). Within one rule a `flag` + `unflag` pair resolves to whichever is last.
- Materializes inside the sync transaction (and on retroactive apply). **No dedicated timeline annotation** is emitted (same as `assign_series` / `set_metadata`); the rule's `hit_count` and the `rule_applied` audit record the firing.
- Re-flagging an already-flagged row refreshes `flagged_at` (matches the `flag_transaction` tool).

### `unflag`

```json
{ "type": "unflag" }
```

Clears the flag (`flagged_at = NULL`) on a matching transaction. Takes no parameters. No-op-safe on an already-unflagged row. Use it to auto-retire flags once a follow-up rule's condition no longer holds, or to clear flags the agent raised after review.

### Combining actions

A rule can carry multiple actions of different types. Override (`category_override <> 'none'`) suppresses only the `set_category` part — `add_tag` and `add_comment` still fire.
Expand All @@ -353,7 +373,7 @@ A rule can carry multiple actions of different types. Override (`category_overri

#### Which combinations make sense

Only `set_category` is singleton per rule — repeating it is rejected at write time. `add_tag`, `remove_tag`, `add_comment`, `set_metadata`, and `remove_metadata` can appear multiple times in one rule (e.g. add two tags at once, or write two metadata keys). The admin UI disables a second `set_category` dropdown option once one is picked; tag and comment rows are freely repeatable.
Only `set_category` is singleton per rule — repeating it is rejected at write time. `add_tag`, `remove_tag`, `add_comment`, `set_metadata`, and `remove_metadata` can appear multiple times in one rule (e.g. add two tags at once, or write two metadata keys). `flag` / `unflag` carry no value, so a second copy is meaningless — the admin UI treats them as singleton (disables the dropdown option once picked) and warns if you add both. The admin UI also disables a second `set_category` dropdown option once one is picked; tag and comment rows are freely repeatable.

Useful combinations:

Expand Down Expand Up @@ -436,6 +456,9 @@ The rule engine has two entry points. They share condition evaluation and priori
| `remove_tag` | Applied | Applied |
| `set_metadata` | Applied | Applied |
| `remove_metadata` | Applied | Applied |
| `assign_series` | Applied | Applied |
| `flag` | Applied (sets `flagged_at`) | Applied (sets `flagged_at`) |
| `unflag` | Applied (clears `flagged_at`) | Applied (clears `flagged_at`) |
| `add_comment` | Applied | **Not applied** (by design) |
| `hit_count` | +1 per condition match | +1 per condition match |
| `rule_applied` annotation | Written | Written (with `applied_by = "retroactive"`) |
Expand Down
10 changes: 5 additions & 5 deletions internal/mcp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,20 +316,20 @@ func (s *MCPServer) handleTransactionSummary(_ context.Context, _ *mcpsdk.CallTo
type createTransactionRuleInput struct {
Name string `json:"name" jsonschema:"required,Name for this rule (human-readable description)"`
Conditions map[string]any `json:"conditions,omitempty" jsonschema:"JSON condition tree. Omit or pass {} to match every transaction. Leaf: {\"field\":\"...\",\"op\":\"...\",\"value\":...}. Combinators: {\"and\":[...]}, {\"or\":[...]}, {\"not\":{...}} (nest freely, max depth 10). Fields: provider_name provider_merchant_name amount provider_category_primary provider_category_detailed category(assigned slug, live-updated by earlier-stage rules) pending provider account_id account_name user_id user_name tags series in_series day_of_month month day_of_week(0=Sun..6=Sat) day_of_year (the four date-parts are numeric, derived from the tz-naive date), plus metadata.<key> to read a key from the free-form metadata blob (e.g. metadata.tax_deductible). Ops: string/category=eq|neq|contains|not_contains|matches(RE2)|in; numeric=eq|neq|gt|gte|lt|lte|approx|between; bool=eq|neq; tags=contains|not_contains|in; metadata.<key>=eq|neq|contains|not_contains|matches|in|gt|gte|lt|lte|exists|not_exists (an absent key matches only not_exists; eq/neq comparison type follows the value's type). approx needs a sibling tolerance: {\"field\":\"amount\",\"op\":\"approx\",\"value\":15.49,\"tolerance\":0.5}; between needs sibling min+max: {\"field\":\"day_of_month\",\"op\":\"between\",\"min\":1,\"max\":5}. day_of_month approx is cyclic (1 and the month's last day are 1 apart) and clamps a target past a short month to its last day; encode annual cadence as month + day_of_month (leap-robust): {\"and\":[{\"field\":\"month\",\"op\":\"eq\",\"value\":4},{\"field\":\"day_of_month\",\"op\":\"approx\",\"value\":15,\"tolerance\":2}]}. Nested example: {\"or\":[{\"and\":[{\"field\":\"provider_merchant_name\",\"op\":\"contains\",\"value\":\"starbucks\"},{\"field\":\"amount\",\"op\":\"gte\",\"value\":5}]},{\"field\":\"metadata.reimbursable\",\"op\":\"eq\",\"value\":true}]}. Full spec: docs/rule-dsl.md."`
Actions []map[string]any `json:"actions,omitempty" jsonschema:"Array of typed actions. {\"type\":\"set_category\",\"category_slug\":\"...\"} | {\"type\":\"add_tag\",\"tag_slug\":\"...\"} | {\"type\":\"remove_tag\",\"tag_slug\":\"...\"} | {\"type\":\"add_comment\",\"content\":\"...\"} | {\"type\":\"set_metadata\",\"metadata_key\":\"...\",\"metadata_value\":<any JSON>} | {\"type\":\"remove_metadata\",\"metadata_key\":\"...\"} | {\"type\":\"assign_series\",\"series_short_id\":\"...\"} or {\"type\":\"assign_series\",\"merchant_key\":\"...\",\"create_if_missing\":true}. Actions compose: a rule can set a category AND add a tag AND write metadata in the same match. set_metadata upserts one key (value may be any JSON type); remove_metadata deletes one key; both repeat freely. add_comment fires only at sync time (not on retroactive apply). remove_tag/remove_metadata net-diff against add_tag/set_metadata within the same sync pass. If omitted, use category_slug instead."`
Actions []map[string]any `json:"actions,omitempty" jsonschema:"Array of typed actions. {\"type\":\"set_category\",\"category_slug\":\"...\"} | {\"type\":\"add_tag\",\"tag_slug\":\"...\"} | {\"type\":\"remove_tag\",\"tag_slug\":\"...\"} | {\"type\":\"add_comment\",\"content\":\"...\"} | {\"type\":\"set_metadata\",\"metadata_key\":\"...\",\"metadata_value\":<any JSON>} | {\"type\":\"remove_metadata\",\"metadata_key\":\"...\"} | {\"type\":\"assign_series\",\"series_short_id\":\"...\"} or {\"type\":\"assign_series\",\"merchant_key\":\"...\",\"create_if_missing\":true} | {\"type\":\"flag\"} | {\"type\":\"unflag\"}. Actions compose: a rule can set a category AND add a tag AND write metadata in the same match. set_metadata upserts one key (value may be any JSON type); remove_metadata deletes one key; both repeat freely. flag sets the transaction's flagged_at (surface it for attention, like the flag_transaction tool); unflag clears it; both take no params. add_comment fires only at sync time (not on retroactive apply). remove_tag/remove_metadata net-diff against add_tag/set_metadata within the same sync pass. If omitted, use category_slug instead."`
CategorySlug string `json:"category_slug,omitempty" jsonschema:"Shorthand for actions: [{\"type\":\"set_category\",\"category_slug\":\"<slug>\"}]. Either actions or category_slug is required."`
Trigger string `json:"trigger,omitempty" jsonschema:"When the rule fires during sync: 'on_create' (default — first-synced transactions) | 'on_change' (existing transactions that changed on re-sync) | 'always' (both). 'on_update' is accepted as a legacy alias for 'on_change'. Retroactive apply ignores trigger."`
Stage string `json:"stage,omitempty" jsonschema:"Semantic pipeline stage — preferred over raw priority for agent-authored rules. One of: 'baseline' (runs first, broad defaults), 'standard' (default), 'refinement' (reacts to earlier stages), 'override' (runs last, wins set_category). Resolves to priority 0/10/50/100. If both stage and priority are supplied, priority wins. Leave both unset for 'standard'."`
Priority int `json:"priority,omitempty" jsonschema:"Raw pipeline-stage integer, 0..1000. Lower runs first. Prefer 'stage' for shared vocabulary. Canonical values: 0=baseline, 10=standard (default), 50=refinement, 100=override. Higher-priority rules observe earlier-stage rules' tag/category mutations via conditions, and win set_category under last-writer semantics."`
ExpiresIn string `json:"expires_in,omitempty" jsonschema:"Optional expiry duration: 24h, 30d, 1w. Rule auto-disables after this period."`
ApplyRetroactively bool `json:"apply_retroactively,omitempty" jsonschema:"If true, immediately apply this rule to existing transactions after creation. Materializes set_category / add_tag / remove_tag / set_metadata / remove_metadata / assign_series; skips add_comment (sync-only). Hit count reflects every condition match, matching sync-time semantics."`
ApplyRetroactively bool `json:"apply_retroactively,omitempty" jsonschema:"If true, immediately apply this rule to existing transactions after creation. Materializes set_category / add_tag / remove_tag / set_metadata / remove_metadata / assign_series / flag / unflag; skips add_comment (sync-only). Hit count reflects every condition match, matching sync-time semantics."`
}

type updateTransactionRuleInput struct {
ID string `json:"id" jsonschema:"required,UUID of the rule to update"`
Name *string `json:"name,omitempty" jsonschema:"New name for the rule. Omit to leave unchanged."`
Conditions map[string]any `json:"conditions,omitempty" jsonschema:"New condition tree (same format as create). Pass {} to explicitly change to match-all. Omit entirely to leave conditions unchanged."`
Actions *[]map[string]any `json:"actions,omitempty" jsonschema:"Replace the entire actions array with typed actions: {\"type\":\"set_category|add_tag|remove_tag|add_comment|set_metadata|remove_metadata|assign_series\", ...}. set_metadata takes metadata_key + metadata_value (any JSON); remove_metadata takes metadata_key. Pass an empty array to reject (rules must have at least one action). Omit to leave actions unchanged."`
Actions *[]map[string]any `json:"actions,omitempty" jsonschema:"Replace the entire actions array with typed actions: {\"type\":\"set_category|add_tag|remove_tag|add_comment|set_metadata|remove_metadata|assign_series|flag|unflag\", ...}. set_metadata takes metadata_key + metadata_value (any JSON); remove_metadata takes metadata_key; flag/unflag take no params (flag sets flagged_at, unflag clears it). Pass an empty array to reject (rules must have at least one action). Omit to leave actions unchanged."`
CategorySlug *string `json:"category_slug,omitempty" jsonschema:"Shorthand: replace only the set_category action. Other action types on the rule are preserved. Omit to leave unchanged."`
Trigger *string `json:"trigger,omitempty" jsonschema:"New trigger: on_create, on_change, or always. 'on_update' accepted as alias for on_change. Omit to leave unchanged."`
Stage *string `json:"stage,omitempty" jsonschema:"New semantic pipeline stage: baseline | standard | refinement | override (resolves to priority 0/10/50/100). Preferred alias for agent-authored updates. If both stage and priority are supplied, priority wins. Omit to leave unchanged."`
Expand All @@ -348,7 +348,7 @@ type batchCreateRulesInput struct {

type batchRuleItem struct {
Name string `json:"name" jsonschema:"required,Human-readable rule name"`
Actions []map[string]any `json:"actions,omitempty" jsonschema:"Actions array (typed — same format as create_transaction_rule, incl. set_metadata / remove_metadata)"`
Actions []map[string]any `json:"actions,omitempty" jsonschema:"Actions array (typed — same format as create_transaction_rule, incl. set_metadata / remove_metadata / flag / unflag)"`
CategorySlug string `json:"category_slug,omitempty" jsonschema:"Shorthand for set_category action. Either actions or category_slug required."`
Conditions map[string]any `json:"conditions,omitempty" jsonschema:"Condition tree as JSON object. Omit or {} for match-all."`
Trigger string `json:"trigger,omitempty" jsonschema:"on_create (default), on_change, or always. 'on_update' accepted as alias for on_change."`
Expand All @@ -358,7 +358,7 @@ type batchRuleItem struct {
}

type applyRulesInput struct {
RuleID string `json:"rule_id,omitempty" jsonschema:"Optional ID (UUID or short_id) of a specific rule to apply. When supplied, only that rule runs — no chaining. Omit to apply all active rules in pipeline-stage order (priority ASC); earlier rules' tag/category mutations feed later rules' conditions, exactly like sync-time. Materializes set_category / add_tag / remove_tag; add_comment stays sync-only. Ignores rule.trigger (retroactive is a bulk op)."`
RuleID string `json:"rule_id,omitempty" jsonschema:"Optional ID (UUID or short_id) of a specific rule to apply. When supplied, only that rule runs — no chaining. Omit to apply all active rules in pipeline-stage order (priority ASC); earlier rules' tag/category mutations feed later rules' conditions, exactly like sync-time. Materializes set_category / add_tag / remove_tag / set_metadata / remove_metadata / assign_series / flag / unflag; add_comment stays sync-only. Ignores rule.trigger (retroactive is a bulk op)."`
}

type previewRuleInput struct {
Expand Down
Loading
Loading