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
2 changes: 1 addition & 1 deletion docs/mcp-tools-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ Create a rule that fires during sync. Actions compose (`set_category` + `add_tag
| Parameter | Type | Description |
|-----------|------|-------------|
| `name` | string | Human-readable rule name |
| `conditions` | object | Condition tree. Omit or `{}` for match-all. Supports `and` / `or` / `not` nesting up to depth 10. Leaf fields 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`). |
| `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. |
| `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. |
Expand Down
68 changes: 68 additions & 0 deletions docs/rule-dsl.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ Combinators nest. Max depth: **10**. Empty / zero-value condition (`{}`) means *
| `tags` | tags | List of current transaction tag slugs (special, see below) |
| `series` | string | `short_id` of the recurring series the transaction belongs to (empty when unassigned) |
| `in_series` | bool | Whether the transaction is linked to any recurring series |
| `day_of_month` | numeric | Day component of the posting `date`, `1`–`31`. Supports the cyclic/clamped `approx` (see "Date-part conditions"). |
| `month` | numeric | Month of the posting `date`, `1`–`12` |
| `day_of_week` | numeric | Weekday of the posting `date`, `0`=Sunday … `6`=Saturday |
| `day_of_year` | numeric | Ordinal day of the posting `date`, `1`–`366` |
| `metadata.<key>` | metadata | One key from the transaction's free-form metadata blob — e.g. `metadata.tax_deductible` reads `metadata["tax_deductible"]`. See "Metadata conditions" below. |

> **Raw vs assigned category.** `provider_category_primary` / `provider_category_detailed` are the provider's classification — they don't change when Breadbox, a rule, or the user reassigns. Use `category` when you want to react to the *current* category, including mid-pass rule updates (see "Rule chaining" below).
Expand All @@ -90,6 +94,7 @@ The classes:
- **raw-immutable** — verbatim provider data. Breadbox never rewrites it; it changes only if the provider re-reports the transaction. A condition on it means the same thing on the create pass, on every re-sync, and on retroactive apply. **This is the substrate. Author here.**
- **stable-surrogate** — a Breadbox-assigned `id` (UUID) that is stable for the life of the entity. Not provider data, but it doesn't drift when a user renames the entity, so it's a safe primary match. **Also safe.**
- **mutable-display** — a human-facing label or a value a prior rule / the user / an agent can change. It silently *breaks* when someone renames the thing (`account_name`, `user_name`), or its truth *depends on pipeline order* (`category`, `tags`, `series`, `in_series` — set by earlier-stage rules in the same pass). **Avoid as a primary match condition.**
- **stable-derived** — a value *computed* deterministically from a raw-immutable column, carrying no independent state. The date-parts (`day_of_month`, `month`, `day_of_week`, `day_of_year`) are pure functions of the immutable, tz-naive `date`, so a condition on them is as durable as one on `amount`. **Safe to author on** (this is the backbone of recurrence rules).

| Field | Type | Class | Why |
| ------------------------------ | -------- | -------------------- | ----------------------------------------------------------------------------------------- |
Expand All @@ -108,6 +113,10 @@ The classes:
| `tags` | tags | **mutable-display** | Current tag slugs; mutate mid-pass as earlier `add_tag`/`remove_tag` rules fire |
| `series` | string | **mutable-display** | Current series `short_id`; set by `assign_series` (a rule write), empty until then |
| `in_series` | bool | **mutable-display** | Whether a series link exists; same pipeline/timing caveat as `series` |
| `day_of_month` | numeric | **stable-derived** | Day-of-month of the immutable tz-naive `date`; pure function, no independent state |
| `month` | numeric | **stable-derived** | Month of the immutable `date` |
| `day_of_week` | numeric | **stable-derived** | Weekday of the immutable `date` (`0`=Sun) |
| `day_of_year` | numeric | **stable-derived** | Ordinal day of the immutable `date` |
| `metadata.<key>` | metadata | **mutable-display** | Free-form blob a user / agent / rule writes; no stability guarantee |

> **The contract.** *Author conditions on **raw-immutable** + **stable-surrogate** fields.* These are the immutable provider substrate (and stable surrogates of it) the whole model rests on — a rule built on them resolves identically on the create pass, on every future re-sync, and on retroactive apply over history.
Expand All @@ -122,6 +131,8 @@ The classes:
| | `matches` | RE2 regex; case-sensitive by default (`(?i)` for insensitive) |
| | `in` | Non-empty array; case-insensitive membership test |
| **numeric** | `eq`, `neq`, `gt`, `gte`, `lt`, `lte` | `float64` comparison |
| | `approx` | `value ± tolerance`: matches when `abs(actual - value) ≤ tolerance`. Requires a sibling `tolerance ≥ 0`. For `day_of_month` the comparison is **cyclic + clamped** (see "Date-part conditions"). |
| | `between` | `min ≤ actual ≤ max` (inclusive). Requires sibling `min` and `max` (with `min ≤ max`); `value` is ignored. |
| **bool** | `eq`, `neq` | `true` / `false` |
| **tags** | `contains`, `not_contains` | `value` is a single tag slug; case-insensitive |
| | `in` | `value` is an array of slugs; matches if any slug is present |
Expand Down Expand Up @@ -178,6 +189,63 @@ The key (everything after `metadata.`) must be a valid metadata key — non-empt

Metadata conditions evaluate identically at sync time, in `preview_rule`, and during retroactive apply. They also chain: a `set_metadata` action by an earlier-stage rule is visible to a later-stage rule's `metadata.<key>` condition within the same pass (see "Rule chaining" above and the `set_metadata` action below).

### Amount tolerance (`approx` / `between`)

Two numeric operators express "near a value" without a pile of `gte`/`lte` pairs — the building block for recurring-charge rules:

```json
{ "field": "amount", "op": "approx", "value": 15.49, "tolerance": 0.50 }
{ "field": "amount", "op": "between", "min": 9.99, "max": 19.99 }
```

- `approx` matches when `abs(amount - value) ≤ tolerance`. The `tolerance` sibling is **required and must be ≥ 0**.
- `between` matches when `min ≤ amount ≤ max` (both ends inclusive). Both `min` and `max` are **required**, with `min ≤ max`; the `value` field is ignored.
- Both work on every numeric field — `amount` and the date-parts below.

### Date-part conditions

Four **numeric** fields are derived from the transaction's posting `date` — the **tz-naive, provider-localized** date column. There is deliberately **no timezone math**: the date is taken exactly as the provider reported it, so a charge "on the 14th" is the 14th in the provider's locale regardless of where the server runs.

| Field | Range | Notes |
| -------------- | -------------- | -------------------------------------- |
| `day_of_month` | `1`–`31` | Cyclic + clamped under `approx` (below) |
| `month` | `1`–`12` | January = 1 |
| `day_of_week` | `0`–`6` | `0` = Sunday … `6` = Saturday |
| `day_of_year` | `1`–`366` | Ordinal day; `366` only on leap years |

They support the full numeric operator set: `eq` / `neq` / `gt` / `gte` / `lt` / `lte`, plus `between` and `approx`. A transaction with no date never matches a date-part condition.

**`day_of_month` `approx` is cyclic and clamped** — this is what makes "around the 1st" and "the 31st" behave sanely:

- **Cyclic:** the distance wraps around the month, so the 1st and the month's last day are **1 day apart**. `day_of_month approx 1 ± 2` therefore matches the 30th/31st of the previous-style cycle as well as the 1st–3rd.
- **Clamped:** a target past the month's actual length collapses to the **last day** of that month. So `day_of_month approx 31 ± 0` matches **Feb 28** (or **Feb 29** in a leap year), April 30, etc. — "bill me on the 31st" still fires in short months.

Both the clamp boundary and the wrap distance use the transaction's **own** month length (28/29/30/31), so the same rule behaves correctly across every month and across leap years.

```json
{ "field": "day_of_month", "op": "approx", "value": 14, "tolerance": 3 }
```

> **Annual cadence — use `month` + `day_of_month`, not `day_of_year`.** `day_of_year` drifts by one after February in leap years (day 60 is Mar 1 in a common year but Feb 29 in a leap year), so an annual rule keyed on `day_of_year` silently misses every fourth year. Express a yearly charge as a conjunction instead, which is leap-robust:
>
> ```json
> { "and": [
> { "field": "month", "op": "eq", "value": 4 },
> { "field": "day_of_month", "op": "approx", "value": 15, "tolerance": 2 }
> ] }
> ```

Date-part conditions evaluate identically at sync time, in `preview_rule`, and during retroactive apply (all three read the same tz-naive `date`).

A full recurring-charge rule combines amount tolerance with a date-part — the detector-free way to capture a subscription:

```json
{ "and": [
{ "field": "amount", "op": "approx", "value": 15.49, "tolerance": 0.50 },
{ "field": "day_of_month", "op": "approx", "value": 14, "tolerance": 3 }
] }
```

## Actions

Actions describe what a matching rule does to the transaction. An action array must have at least one element.
Expand Down
2 changes: 1 addition & 1 deletion internal/mcp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ 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, 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; 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). 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."`
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."`
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."`
Expand Down
Loading
Loading