diff --git a/guides/breadbox-in-a-nutshell.mdx b/guides/breadbox-in-a-nutshell.mdx
index d3a4a21..488869f 100644
--- a/guides/breadbox-in-a-nutshell.mdx
+++ b/guides/breadbox-in-a-nutshell.mdx
@@ -44,7 +44,7 @@ A **rule** pairs a **condition** (which transactions to match) with one or more
A leaf tests a single field:
```json
-{ "field": "merchant_name", "op": "contains", "value": "Starbucks" }
+{ "field": "provider_merchant_name", "op": "contains", "value": "Starbucks" }
```
Combinators glue leaves together:
diff --git a/guides/single-routine-reviewer.mdx b/guides/single-routine-reviewer.mdx
index 5e2a4d5..9525850 100644
--- a/guides/single-routine-reviewer.mdx
+++ b/guides/single-routine-reviewer.mdx
@@ -17,7 +17,7 @@ Every iteration the agent does the same four things:
Call `query_transactions(tags=["needs-review"], fields="core,category", limit=30)` to pull a page worth of detail. Keep batches small — one batch is the unit the agent reasons over end-to-end.
- For each row the agent considers `name`, `merchant_name`, `amount`, `account_name`, and any pre-applied category. It either confirms the category, picks a new one, or defers.
+ For each row the agent considers `provider_name`, `provider_merchant_name`, `amount`, `account_name`, and any pre-applied category. It either confirms the category, picks a new one, or defers.
Call `update_transactions` once per batch with up to 50 operations. Each op can set the category, remove the `needs-review` tag with a note, and optionally add a comment — all in a single request.
diff --git a/guides/tracking-subscriptions.mdx b/guides/tracking-subscriptions.mdx
index 7ada9ba..85dfd3b 100644
--- a/guides/tracking-subscriptions.mdx
+++ b/guides/tracking-subscriptions.mdx
@@ -1,83 +1,84 @@
---
title: "Tracking your subscriptions"
-description: "Breadbox detects recurring charges automatically and groups them into Series — confirm or reject the candidates, tag a whole subscription at once, and auto-join future charges with a rule."
+description: "Breadbox tracks recurring charges as Series — thin, rule-maintained groupings. Author an assign_series rule once and every future matching charge joins the series automatically."
---
Subscriptions are the quiet budget-killer of every household. Streaming services nobody watches anymore, a forgotten cloud backup, a free-trial-turned-monthly-charge — they accumulate, and they're easy to miss because each one is small.
-Breadbox tracks them under **Recurring** — the umbrella for *any* repeating charge, not just subscriptions: mortgage, rent, insurance, utilities, and loans land here too. A *series* groups the charges from one merchant so you can see the whole thing as one, track what it costs, and act on it in one place. A subscription is just the most familiar kind.
+Breadbox tracks them under **Recurring** — the umbrella for *any* repeating charge, not just subscriptions: mortgage, rent, insurance, utilities, and loans land here too. A *series* groups the charges from one merchant so you can see the whole thing as one place. A subscription is just the most familiar kind.
Recurring is in **Beta**. Find it in the admin dashboard under **Recurring** (the old `/subscriptions` URL redirects there), plus the REST API and MCP. The shapes below are stable, but expect the surface to keep growing.
-## What a series is
+## A series is a thin, rule-maintained entity
-A series is a recurring charge pattern. Each one carries:
+In Breadbox's rules-as-substrate model, a series is deliberately minimal:
-- **Cadence** — `weekly`, `biweekly`, `monthly`, `quarterly`, `semiannual`, or `annual` (plus `irregular` / `unknown` for charges that don't snap cleanly).
-- **Expected amount** and its `iso_currency_code`, with an amount tolerance — so a $10.99 plan that ticks up to $11.99 stays the same series. Never sum expected amounts across currencies.
-- **Next expected date** — when the next charge is due.
-- **Status** — `candidate`, `active`, `paused`, or `cancelled`.
-- **Occurrence count** and the detection signals behind it.
+- **Surrogate identity** — a stable `id` / `short_id` that survives renames.
+- **`name`** — the human label you (or an agent) picked. Unique among live series.
+- **`type`** — `subscription`, `bill`, `loan`, or `other`.
-Every charge that belongs to the series is *linked* to it, so the series detail page shows the full history of that subscription at a glance.
+That's it. There's no shipped detector, no cadence inference, no expected-amount field, no candidate/active/paused/cancelled lifecycle. **Membership is exactly the set of charges its `assign_series` rules match** — a series *is* its governing rules.
-## Automatic detection
+The admin Recurring detail page makes that explicit: it shows the linked charges side-by-side with the **governing rules** that define them.
-You don't have to hand-build your subscription list. On each sync, Breadbox's detector looks for charges that repeat from the same merchant at a regular cadence and proposes them as **candidate** series. It normalizes merchant names to a stable signature and ignores generic descriptors — transfers, ACH, Venmo/Zelle, bill-pay, refunds, and the like — so one-off payments don't masquerade as subscriptions.
+## Auto-join charges with a rule
-Candidates wait for a verdict. From the **Recurring** page you can:
-
-- **Confirm** — yes, this is a subscription. The series becomes `active`.
-- **Reject** (shown as *Not recurring*) — the rejection is *sticky*: the detector won't re-propose it at the same amount band.
-- **Pause** or **Cancel** — for subscriptions you've stopped or are putting on hold.
-
-
- A person's verdict outranks an agent's. If you confirm a series, a later agent run can't quietly reject it.
-
-
-## Tag a whole subscription at once
-
-A series can carry its own tags, and **every linked charge inherits them automatically** — including charges that join later. Tag a series `streaming` once and all of its past and future transactions pick up that tag, no per-row tagging required.
-
-Removing a tag from the series strips only the copies it added; a tag you put on an individual charge by hand survives.
-
-## Auto-join future charges with a rule
-
-To make every future charge from a merchant join a series on sync, use the [`assign_series` rule action](/transactions/rules#assign-a-series):
+To make every charge from a merchant join a series on sync, use the [`assign_series` rule action](/transactions/rules#assign_series). The recurrence idiom — `amount approx` + `day_of_month approx` — is the durable way to express a recurring pattern:
```json
{
"name": "Spotify → subscription series",
"conditions": {
"and": [
- { "field": "merchant_name", "op": "contains", "value": "Spotify" },
- { "field": "amount", "op": "gt", "value": 0 }
+ { "field": "provider_merchant_name", "op": "contains", "value": "Spotify" },
+ { "field": "amount", "op": "approx", "value": 10.99, "tolerance": 1.00 },
+ { "field": "day_of_month", "op": "approx", "value": 14, "tolerance": 2 }
]
},
"actions": [
- { "type": "assign_series", "merchant_key": "spotify", "create_if_missing": true }
+ { "type": "assign_series", "series_name": "Spotify", "create_if_missing": true },
+ { "type": "add_tag", "tag_slug": "streaming" }
],
"trigger": "on_create",
"stage": "standard"
}
```
-`create_if_missing` mints the series the first time a matching charge appears; after that, every future charge joins it. Provide `series_short_id` instead to target a series that already exists.
+How the action resolves:
+
+- `series_name` + `create_if_missing: true` mints the series the first time a charge matches; every future charge with the same `series_name` joins the existing one (surrogate-first — the same name always resolves the same series).
+- Provide `series_short_id` instead to target a series that already exists.
+- A transaction belongs to at most one series. `assign_series` is NULL-fill only — it never steals a charge already in another series. Across the pipeline, the highest-priority rule wins.
+
+To tag every member of the subscription (e.g. `streaming`, `work-tools`), bundle the `add_tag` action into the same rule. Members get the tag at sync time and on retroactive apply.
- Applying the subscription rule **by itself** back-fills your existing charges into the series, not just future ones (see [applying a rule retroactively](/transactions/rules#applying-a-rule-retroactively)). The bulk "apply all rules" pass doesn't link series yet, so apply this rule on its own. You can also link a charge by hand or with the `assign_series` MCP tool.
+ Series tags as a separate, inherited surface have been removed. Tag a series by adding an `add_tag` action to the same rule that does `assign_series`, or author a separate rule that matches the same conditions.
+## Back-fill existing charges
+
+Once the rule is in place, future syncs handle themselves. To pull in charges that already exist, apply the rule retroactively:
+
+```bash
+curl -X POST \
+ -H "X-API-Key: bb_your_key" \
+ http://localhost:8080/api/v1/rules/rule_abc123/apply
+```
+
+Retroactive apply materializes `assign_series` (and every other state-mutating action) through both the single-rule and the bulk apply-all paths. You can also link a charge by hand from the admin Recurring page, or with the `assign_series` MCP tool for a one-off assignment.
+
## Working with series from an agent
The same surface is available over MCP, which is how a scheduled reviewer agent can keep your subscription list tidy:
-- `list_series` (optionally `status=candidate`) to find series awaiting a verdict, then `review_series` to confirm, reject, pause, or cancel.
-- `get_series` to inspect one series and the evidence behind it.
-- `assign_series` to create a series detection missed, or back-link charges to an existing one.
-- `add_series_tag` / `remove_series_tag` to tag a subscription and its members.
+- `list_series` — list every live series (`name`, `type`, charge count). Lean by default.
+- `get_series` — fetch one series' `name` and `type` by short ID or UUID. Its linked charges come from `query_transactions(series_id=...)`.
+- `assign_series` — a one-off link/mint for transactions an agent has already decided about. For durable patterns, the agent should author an `assign_series` rule instead.
+- `update_series` — rename a series or change its `type`.
+- `unlink_series_transactions` — detach charges from a series (inverse of `assign_series`' link path).
See the [MCP tools](/mcp/tools#series-subscriptions) page for the agent-facing summary.
@@ -85,16 +86,17 @@ See the [MCP tools](/mcp/tools#series-subscriptions) page for the agent-facing s
Once your subscriptions are series:
-- **See the portfolio.** The **Recurring** page lists every active series with its cadence and expected amount.
-- **Total the outflow.** Sum the expected amounts of your active monthly series (keep each `iso_currency_code` separate — don't mix currencies).
-- **Spot creep.** A series tracks its last amount against the expected one, so a plan that quietly raised its price stands out.
+- **See the portfolio.** The **Recurring** page lists every live series with its name, type, and charge count, and the detail view shows the linked charges plus the governing rules behind them.
+- **Total the outflow.** Query the transactions linked to your active monthly series (`query_transactions(series_id=...)`) and sum their amounts. Keep each `iso_currency_code` separate — don't mix currencies.
+- **Spot creep.** A `set_metadata` rule can stamp the expected price into the transaction's metadata blob; reviewing actuals against it surfaces silent price hikes.
## Deleting a series
-Removing a series is non-destructive — its transactions stay exactly where they are and simply lose the series link. Your history is never deleted.
+Removing a series is non-destructive — its transactions stay exactly where they are and simply lose the series link. Your history is never deleted. To stop new charges from joining, disable or delete the `assign_series` rules that govern it.
## Related reading
- [Rules](/transactions/rules) — the `assign_series` action and the full rule DSL.
+- [Understanding rules](/guides/understanding-rules) — the recurrence idiom (`amount approx` + `day_of_month approx`) explained.
- [MCP tools](/mcp/tools#series-subscriptions) — the series tools an agent can call.
- [On-demand analysis](/guides/on-demand-analysis) — ask Claude "which of my subscriptions are underused?" once they're tracked.
diff --git a/guides/understanding-rules.mdx b/guides/understanding-rules.mdx
index fc0286e..0a2bf9a 100644
--- a/guides/understanding-rules.mdx
+++ b/guides/understanding-rules.mdx
@@ -1,32 +1,41 @@
---
title: "Understanding rules"
-description: "A primer on the Breadbox rule DSL — how to build condition trees, what actions are available, and four worked examples you can adapt."
+description: "A primer on the Breadbox rule DSL — the doctrine behind it, how to build condition trees, what actions are available, and worked examples you can adapt."
---
-Rules are the workhorse of any Breadbox setup. A well-tuned set of rules can categorize the overwhelming majority of your transactions during sync, leaving only the genuinely ambiguous ones for a human or agent to look at. This guide walks through the DSL by example — four rules you can adapt and drop into your instance today.
+Rules are the workhorse of any Breadbox setup. A well-tuned set of rules can categorize the overwhelming majority of your transactions during sync, leaving only the genuinely ambiguous ones for a human or agent to look at. This guide walks through the DSL by example — five rules you can adapt and drop into your instance today.
-If you haven't yet, skim [Breadbox in a nutshell](/guides/breadbox-in-a-nutshell) first for the vocabulary. The full specification lives in [Auto-categorize transactions with rules](/transactions/rules) and the [rules API reference](/api/overview).
+If you haven't yet, skim [Breadbox in a nutshell](/guides/breadbox-in-a-nutshell) first for the vocabulary. The full specification lives in [Rules: the substrate that turns provider data into intelligence](/transactions/rules) and the [rules API reference](/api/overview).
+
+## The doctrine: provider data is immutable; intelligence accrues as rules
+
+Breadbox treats provider data — the raw transactions Plaid, Teller, SimpleFIN, or your CSV imports drop into the database — as a permanent, untouched substrate. Breadbox never rewrites the provider's name, merchant, amount, or categories. Every durable choice you or an agent makes about a charge — *"this is a subscription"*, *"this is a grocery run"*, *"flag anything over $1000"* — accrues as a **rule**. The next sync resolves the same kind of charge the same way automatically, without a re-run.
+
+Two practical consequences:
+
+- **Match on raw fields.** A rule keyed on `provider_name`, `provider_merchant_name`, `amount`, `pending`, or the date-parts (`day_of_month`, `month`, ...) resolves identically on every sync and on retroactive apply. A rule keyed on a mutable label (`account_name`, `category`, `tags`) is reacting to something a person, agent, or earlier-stage rule can change.
+- **Last-writer-wins.** Rules, agents, and users all write the same fields (`category_id`, tags, metadata, series link, counterparty link, flag). There is no per-source precedence guard — whoever runs last wins. The sync engine only runs rules on new or changed transactions, so a user's manual edit on an unchanged row is not silently re-clobbered.
## The DSL, in one screen
A rule is a JSON document with three important pieces:
1. **A condition** — a recursive tree of leaves (`field`/`op`/`value`) and combinators (`and` / `or` / `not`).
-2. **One or more actions** — `set_category`, `add_tag`, `remove_tag`, `add_comment`, or `assign_series` (link the charge to a recurring [series](/guides/tracking-subscriptions)).
+2. **One or more actions** — `set_category`, `add_tag`, `remove_tag`, `set_metadata`, `remove_metadata`, `assign_series`, `assign_counterparty`, `flag`, `unflag`, or `add_comment`.
3. **A trigger and stage** — when the rule runs (`on_create`, `always`, `on_change`) and where in the pipeline (`baseline`, `standard`, `refinement`, `override`).
Amounts use Plaid convention: **positive = money out** (purchases, payments), **negative = money in** (refunds, paychecks). Every example below respects that.
## Example 1 — Amazon purchases → Shopping
-The canonical "merchant name contains a substring" rule. Two conditions combined with `and`: the description must contain `AMAZON`, and the amount must be positive (so we don't catch Amazon refunds).
+The canonical "merchant name contains a substring" rule. Two conditions combined with `and`: the provider's raw description must contain `AMAZON`, and the amount must be positive (so we don't catch Amazon refunds).
```json
{
"name": "Amazon purchases",
"conditions": {
"and": [
- { "field": "name", "op": "contains", "value": "AMAZON" },
+ { "field": "provider_name", "op": "contains", "value": "AMAZON" },
{ "field": "amount", "op": "gt", "value": 0 }
]
},
@@ -57,7 +66,7 @@ curl -X POST \
-d '{
"conditions": {
"and": [
- { "field": "name", "op": "contains", "value": "AMAZON" },
+ { "field": "provider_name", "op": "contains", "value": "AMAZON" },
{ "field": "amount", "op": "gt", "value": 0 }
]
}
@@ -67,14 +76,14 @@ curl -X POST \
## Example 2 — Uber / Lyft / Waymo → Transportation
-When a single condition isn't enough, an `or` group handles the "any of these merchants" case. Here we use the `in` operator on `merchant_name` to check against a list in one shot.
+When a single condition isn't enough, an `or` group handles the "any of these merchants" case. Here we use the `in` operator on `provider_merchant_name` to check against a list in one shot.
```json
{
"name": "Ridesharing → Transportation",
"conditions": {
"and": [
- { "field": "merchant_name", "op": "in", "value": ["Uber", "Lyft", "Waymo"] },
+ { "field": "provider_merchant_name", "op": "in", "value": ["Uber", "Lyft", "Waymo"] },
{ "field": "amount", "op": "gt", "value": 0 }
]
},
@@ -88,33 +97,61 @@ When a single condition isn't enough, an `or` group handles the "any of these me
```
- `merchant_name` is only populated for providers that enrich the raw description (Plaid does this; Teller and CSV imports often don't). If your data comes primarily from Teller, prefer `name` with `contains` and match on a substring of the raw description.
+ `provider_merchant_name` is only populated for providers that enrich the raw description (Plaid does this; Teller and CSV imports often don't). If your data comes primarily from Teller, prefer `provider_name` with `contains` and match on a substring of the raw description.
-## Example 3 — Threshold tag: flag anything over $500
+## Example 3 — Threshold flag: surface anything over $1000
-Not every rule has to change the category. This one only _tags_ — anything over $500 gets the `high-amount` tag so you can scan the queue for big-ticket items before the rest.
+Not every rule has to change the category. This one only **flags** a charge for human attention — anything over $1000 goes into the flagged queue so you can review it before the rest.
```json
{
- "name": "Flag transactions over $500",
+ "name": "Flag transactions over $1000",
"conditions": {
"and": [
- { "field": "amount", "op": "gt", "value": 500 },
+ { "field": "amount", "op": "gt", "value": 1000 },
{ "field": "pending", "op": "eq", "value": false }
]
},
"actions": [
- { "type": "add_tag", "tag_slug": "high-amount" }
+ { "type": "flag" },
+ { "type": "add_comment", "content": "Flagged automatically: over $1000." }
+ ],
+ "trigger": "on_create",
+ "stage": "standard"
+}
+```
+
+`flag` sets `flagged_at = NOW()` on the matching transaction. Retrieve flagged rows with `query_transactions(flagged=true)` from MCP, `GET /transactions?flagged=true` from the API, or the flagged filter in the dashboard. A follow-up `unflag` rule (or a manual review) clears the flag once you're done with it.
+
+## Example 4 — Tag a subscription via the recurrence idiom
+
+Recurring charges have a stable signature: the same merchant, near the same amount, near the same day of the month. `amount approx` and `day_of_month approx` capture that in two conditions — and `assign_series` makes every future match join the same series automatically.
+
+```json
+{
+ "name": "Spotify → subscription series",
+ "conditions": {
+ "and": [
+ { "field": "provider_merchant_name", "op": "contains", "value": "Spotify" },
+ { "field": "amount", "op": "approx", "value": 10.99, "tolerance": 1.00 },
+ { "field": "day_of_month", "op": "approx", "value": 14, "tolerance": 2 }
+ ]
+ },
+ "actions": [
+ { "type": "assign_series", "series_name": "Spotify", "create_if_missing": true },
+ { "type": "add_tag", "tag_slug": "streaming" }
],
"trigger": "on_create",
"stage": "standard"
}
```
-You'd pair this with a tag you've pre-created in the dashboard (**Tags** → **New tag**, slug `high-amount`). Then filter to `/transactions?tags=high-amount` in the UI, or `query_transactions(tags=["high-amount"])` from an agent.
+`amount approx 10.99 ± 1.00` keeps matching when a $10.99 plan ticks up to $11.99. `day_of_month approx 14 ± 2` is cyclic and clamped — day 1 and the month's last day are 1 apart, and a target past a short month clamps to the last day (so "the 31st" matches February). This pair is the backbone of every recurring-charge rule.
+
+`assign_series` mints the series the first time a charge matches and links every future match to it. See [Tracking subscriptions](/guides/tracking-subscriptions) for the full series story.
-## Example 4 — A `not` rule: auto-categorize groceries, but not Whole Foods prepared food
+## Example 5 — A `not` rule: auto-categorize groceries, but not Whole Foods prepared food
Sometimes the cleanest way to express a rule is "match this _except_ for these cases." The `not` combinator wraps a sub-condition and inverts it.
@@ -123,8 +160,8 @@ Sometimes the cleanest way to express a rule is "match this _except_ for these c
"name": "Grocery stores → Groceries (except prepared food)",
"conditions": {
"and": [
- { "field": "merchant_name", "op": "in", "value": ["Whole Foods", "Trader Joe's", "Safeway"] },
- { "not": { "field": "name", "op": "contains", "value": "PREPARED" } }
+ { "field": "provider_merchant_name", "op": "in", "value": ["Whole Foods", "Trader Joe's", "Safeway"] },
+ { "not": { "field": "provider_name", "op": "contains", "value": "PREPARED" } }
]
},
"actions": [
@@ -138,9 +175,17 @@ Sometimes the cleanest way to express a rule is "match this _except_ for these c
You can freely nest `and`, `or`, and `not` up to 10 levels deep. For most workflows you'll keep it under three.
- If a transaction gets the wrong category anyway, set it manually from the dashboard. That sets `category_override = 'user'`, which the rule engine honors forever — your deliberate choice won't be undone by a later rule.
+ If a transaction gets the wrong category anyway, set it manually from the dashboard. Last-writer-wins applies — your manual edit replaces the rule's choice. The sync engine only re-runs rules on new or changed transactions, so a deliberate manual edit on an unchanged row stays in place across syncs.
+## Beyond categorization — what else rules do
+
+Rules aren't just for `set_category`. The same condition tree feeds every other action:
+
+- **`set_metadata` / `remove_metadata`** — write any household-specific enrichment to a transaction's free-form `metadata` blob. Mark anything Whole Foods as `tax_deductible: false`; tag every charge from your trip account with `trip: "japan-2026"`; record `reimbursable_by: "work"`. A later rule can read it back via `metadata.` conditions, and you can query on it from MCP and the API.
+- **`assign_counterparty`** — bind a transaction to the canonical "other side" of the charge, covering merchants and non-merchants (Venmo, people, employers). Same surrogate-first, NULL-fill semantics as `assign_series`.
+- **`flag` / `unflag`** — surface a transaction for human attention. The example above is the simplest version; pair `unflag` with a follow-up condition to auto-retire a flag once it's resolved.
+
## Applying rules retroactively
Rules only fire at sync time by default. To run a newly created rule against your full history:
@@ -157,10 +202,10 @@ curl -X POST \
http://localhost:8080/api/v1/rules/apply-all
```
-Retroactive apply follows the same pipeline order and the same `category_override` protection as live sync. One caveat: `add_comment` actions are skipped during retroactive apply (they're designed to narrate a specific sync event).
+Retroactive apply follows the same pipeline order as live sync and materializes every state-mutating action — `set_category`, `add_tag`, `remove_tag`, `set_metadata`, `remove_metadata`, `assign_series`, `assign_counterparty`, `flag`, and `unflag`. One caveat: `add_comment` actions are skipped during retroactive apply (they're designed to narrate a specific sync event).
## Where to go next
- [Single Routine Reviewer](/guides/single-routine-reviewer) — put these rules to work with an agent that clears the remaining `needs-review` queue on a schedule.
-- [Tracking Subscriptions](/guides/tracking-subscriptions) — a worked rule example for tagging recurring charges.
+- [Tracking subscriptions](/guides/tracking-subscriptions) — how `assign_series` builds your recurring catalog from rules alone.
- [Rules reference](/transactions/rules) and [Rules API](/api/overview) — full DSL, every field and operator, plus the JSON shape of every endpoint.
diff --git a/mcp/reference/rules-write.mdx b/mcp/reference/rules-write.mdx
index be54ad0..5fc0514 100644
--- a/mcp/reference/rules-write.mdx
+++ b/mcp/reference/rules-write.mdx
@@ -32,9 +32,14 @@ Create a transaction rule. Rules match condition trees against transactions duri
- `{"type": "set_category", "category_slug": "..."}`
- `{"type": "add_tag", "tag_slug": "..."}`
- `{"type": "remove_tag", "tag_slug": "..."}`
+ - `{"type": "set_metadata", "metadata_key": "...", "metadata_value": }`
+ - `{"type": "remove_metadata", "metadata_key": "..."}`
+ - `{"type": "assign_series", "series_short_id": "..."}` or `{"type": "assign_series", "series_name": "...", "create_if_missing": true}`
+ - `{"type": "assign_counterparty", "counterparty_short_id": "..."}` or `{"type": "assign_counterparty", "counterparty_name": "...", "create_if_missing": true}`
+ - `{"type": "flag"}` / `{"type": "unflag"}` — no parameters
- `{"type": "add_comment", "content": "..."}`
- Actions compose — a rule can set a category, add a tag, and add a comment on the same match. `add_comment` fires only at sync time (not on retroactive apply). If omitted, supply `category_slug` as a shorthand.
+ Actions compose — a rule can set a category, add a tag, write metadata, and link a series on the same match. `add_comment` fires only at sync time (not on retroactive apply); every other action materializes through both sync and retroactive paths. If `actions` is omitted, supply `category_slug` as a shorthand.
@@ -58,16 +63,16 @@ Create a transaction rule. Rules match condition trees against transactions duri
- If `true`, immediately apply this rule to existing transactions after creation. Materializes `set_category`, `add_tag`, `remove_tag`; skips `add_comment` (sync-only).
+ If `true`, immediately apply this rule to existing transactions after creation. Materializes every state-mutating action (`set_category`, `add_tag`, `remove_tag`, `set_metadata`, `remove_metadata`, `assign_series`, `assign_counterparty`, `flag`, `unflag`); skips `add_comment` (sync-only).
### Example input
```json
{
- "name": "name: Starbucks → food_and_drink_coffee",
+ "name": "Starbucks → food_and_drink_coffee",
"conditions": {
- "field": "merchant_name",
+ "field": "provider_merchant_name",
"op": "contains",
"value": "starbucks"
},
@@ -85,9 +90,9 @@ Create a transaction rule. Rules match condition trees against transactions duri
{
"rule": {
"id": "r9Xm2pQr",
- "name": "name: Starbucks → food_and_drink_coffee",
+ "name": "Starbucks → food_and_drink_coffee",
"conditions": {
- "field": "merchant_name",
+ "field": "provider_merchant_name",
"op": "contains",
"value": "starbucks"
},
@@ -224,7 +229,7 @@ Each item follows the same shape as `create_transaction_rule`. Returns created r
"name": "Tag coffee shops",
"stage": "baseline",
"conditions": {
- "field": "merchant_name",
+ "field": "provider_merchant_name",
"op": "contains",
"value": "starbucks"
},
@@ -287,20 +292,25 @@ Rules fire in priority-ASC order during each sync pass, and within a single pass
Rules of thumb:
-- Per-merchant rules (priority 20–30 or `refinement`) > name-pattern rules (`standard`) > `category_primary` rules (`baseline`).
+- Per-merchant rules (priority 20–30 or `refinement`) > name-pattern rules (`standard`) > `provider_category_primary` rules (`baseline`).
- Prefer `contains` over exact match — bank feeds format merchant names inconsistently.
- Always use `category_slug`, not `category_id`, when authoring actions or filters.
## Condition grammar
-Same grammar used by [`preview_rule`](/mcp/reference/rules#preview_rule).
+Same grammar used by [`preview_rule`](/mcp/reference/rules#preview_rule). For the full reference (every operator, the match-stability contract, and the recurrence idiom), see [Rules](/transactions/rules).
-- **Fields** — `name`, `merchant_name`, `amount`, `category_primary`, `category_detailed`, `category` (assigned slug, live-updated by earlier-stage rules), `pending`, `provider`, `account_id`, `account_name`, `user_id`, `user_name`, `tags`.
+- **Fields** —
+ - **Raw-immutable** (safe to author on): `provider_name`, `provider_merchant_name`, `amount`, `provider_category_primary`, `provider_category_detailed`, `pending`, `provider`.
+ - **Stable-surrogate** (safe): `account_id`, `user_id`.
+ - **Stable-derived** date-parts (safe): `day_of_month`, `month`, `day_of_week`, `day_of_year`.
+ - **Mutable-display** (use deliberately, not as a load-bearing match): `account_name`, `user_name`, `category` (assigned slug, live-updated by earlier-stage rules), `tags`, `series`, `in_series`, `counterparty`, `has_counterparty`, `metadata.`.
- **Operators** —
- - String/category: `eq`, `neq`, `contains`, `not_contains`, `matches` (RE2), `in`.
- - Numeric: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`.
+ - String: `eq`, `neq`, `contains`, `not_contains`, `matches` (RE2), `in`.
+ - Numeric: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `approx` (`value ± tolerance`), `between` (`min`/`max`).
- Bool: `eq`, `neq`.
- Tags: `contains`, `not_contains`, `in`.
+ - Metadata (`metadata.`): all string and numeric operators above, plus `exists` / `not_exists`.
- **Combinators** — `and`, `or`, `not` (nest freely, max depth 10).
Nested example:
@@ -310,7 +320,7 @@ Nested example:
"or": [
{
"and": [
- { "field": "merchant_name", "op": "contains", "value": "starbucks" },
+ { "field": "provider_merchant_name", "op": "contains", "value": "starbucks" },
{ "field": "amount", "op": "gte", "value": 5 }
]
},
@@ -318,3 +328,15 @@ Nested example:
]
}
```
+
+Recurrence idiom — match a subscription by amount and day-of-month:
+
+```json
+{
+ "and": [
+ { "field": "provider_merchant_name", "op": "contains", "value": "spotify" },
+ { "field": "amount", "op": "approx", "value": 10.99, "tolerance": 1.00 },
+ { "field": "day_of_month", "op": "approx", "value": 14, "tolerance": 2 }
+ ]
+}
+```
diff --git a/mcp/reference/rules.mdx b/mcp/reference/rules.mdx
index 5e6b130..68e5bdb 100644
--- a/mcp/reference/rules.mdx
+++ b/mcp/reference/rules.mdx
@@ -57,9 +57,9 @@ Lists transaction rules with optional filters. Agents should always call this be
"rules": [
{
"id": "r9Xm2pQr",
- "name": "name: Starbucks → food_and_drink_coffee",
+ "name": "Starbucks → food_and_drink_coffee",
"conditions": {
- "field": "merchant_name",
+ "field": "provider_merchant_name",
"op": "contains",
"value": "starbucks"
},
@@ -192,7 +192,7 @@ Dry-run a condition tree against existing transactions — no writes, no hit-cou
{
"conditions": {
"and": [
- { "field": "merchant_name", "op": "contains", "value": "starbucks" },
+ { "field": "provider_merchant_name", "op": "contains", "value": "starbucks" },
{ "field": "amount", "op": "gte", "value": 5 }
]
},
@@ -239,14 +239,19 @@ Dry-run a condition tree against existing transactions — no writes, no hit-cou
### Condition grammar
-Full DSL is in the Breadbox repo's `docs/rule-dsl.md`. Short form:
+Full DSL lives in [Rules](/transactions/rules). Short form:
-- **Fields** — `name`, `merchant_name`, `amount`, `category_primary`, `category_detailed`, `category` (the assigned slug, live-updated by earlier-stage rules), `pending`, `provider`, `account_id`, `account_name`, `user_id`, `user_name`, `tags`.
+- **Fields** —
+ - **Raw-immutable** (safe to author on): `provider_name`, `provider_merchant_name`, `amount`, `provider_category_primary`, `provider_category_detailed`, `pending`, `provider`.
+ - **Stable-surrogate**: `account_id`, `user_id`.
+ - **Stable-derived** date-parts: `day_of_month`, `month`, `day_of_week`, `day_of_year`.
+ - **Mutable-display** (use for chaining, not as a primary match): `account_name`, `user_name`, `category` (assigned slug, live-updated by earlier-stage rules), `tags`, `series`, `in_series`, `counterparty`, `has_counterparty`, `metadata.`.
- **Operators** —
- - String/category: `eq`, `neq`, `contains`, `not_contains`, `matches` (RE2), `in`.
- - Numeric: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`.
+ - String: `eq`, `neq`, `contains`, `not_contains`, `matches` (RE2), `in`.
+ - Numeric: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `approx` (`value ± tolerance`), `between` (`min`/`max`).
- Bool: `eq`, `neq`.
- Tags: `contains`, `not_contains`, `in`.
+ - Metadata: all string and numeric operators, plus `exists` / `not_exists`.
- **Combinators** — `and`, `or`, `not` (nest freely, max depth 10).
Pass `{}` (or omit conditions entirely on rule creation) to match every transaction.
@@ -283,7 +288,7 @@ Reports which existing active rules already match a given transaction or merchan
"rules": [
{
"short_id": "r9Xm2pQr",
- "name": "name: Starbucks → food_and_drink_coffee",
+ "name": "Starbucks → food_and_drink_coffee",
"sets_category": "food_and_drink_coffee",
"trigger": "on_create",
"priority": 10,
diff --git a/mcp/tools.mdx b/mcp/tools.mdx
index 58421a3..d546fc3 100644
--- a/mcp/tools.mdx
+++ b/mcp/tools.mdx
@@ -343,22 +343,28 @@ None. Pass an empty object.
## Series (subscriptions)
-Breadbox detects recurring charges and groups them into [series](/guides/tracking-subscriptions). These tools are the agent-facing side of the **Recurring** page — they let a scheduled reviewer adjudicate what the detector proposed and fill in anything it missed. (The tool *names* stay `*_series` for API stability even though the surface is now called Recurring.)
+A [series](/guides/tracking-subscriptions) is a thin, rule-maintained entity — a surrogate identity, a `name`, and a `type` (`subscription`, `bill`, `loan`, or `other`). Membership comes from `assign_series` **rules**, not a shipped detector: a series *is* its governing rules. (The tool *names* stay `*_series` for API stability even though the surface is called Recurring.)
| Tool | Scope | What it does |
| --- | --- | --- |
-| `list_series` | Read | List detected series. Filter by `status` (`active`, `candidate`, `paused`, `cancelled`); `candidate` surfaces the ones awaiting a verdict. Each row carries cadence, expected amount + currency, next expected date, and the detection signals. |
-| `get_series` | Read | Fetch one series by ID, including the full evidence the detector used. |
-| `explain_series_candidates` | Read | Answer "why isn't *X* recurring?" — lists recurring-looking merchants the precision-first detector skipped, with the reason (too few occurrences, irregular cadence, unstable amount, …). |
-| `review_series` | Write | Apply a verdict: `confirm`, `reject` (sticky — never re-proposed at that amount band), `pause`, or `cancel`. A user's prior confirmation outranks a later agent verdict. |
-| `update_series` | Write | Edit a series' user-owned attributes: name, expected amount, cadence, expected day, category, or owner. Every field is optional; omit to leave unchanged. Edits survive future detection runs (the detector won't revert them). |
-| `set_series_type` | Write | Set the series type: `subscription`, `bill`, `loan`, or `other`. Overrides the type the detector inferred from the charges' category; the override is sticky. |
-| `assign_series` | Write | Create a series the detector missed, or back-link transactions to an existing one. Never steals a charge already in another series. |
-| `unlink_series_transactions` | Write | Detach transactions from a series (inverse of `assign_series`). Clears the series link and strips inherited tags from the affected charges only. |
-| `rekey_series` | Write | Fix a series grouped under a wrong or fallback merchant key (e.g. `payment` → `spotify`), repointing it and its charges. |
-| `split_series` | Write | Break an over-grouped series in two — move stray charges into a new series (e.g. a $4.99 add-on swept into a $139/yr renewal). |
-| `add_series_tag` / `remove_series_tag` | Write | Tag a series; every linked transaction inherits the tag automatically. Removing strips only the inherited copies. |
+| `list_series` | Read | List every live series — `name`, `type`, and charge count. Lean by default; pass `fields=all` for timestamps. |
+| `get_series` | Read | Fetch one series' `name` and `type` by short ID or UUID. Its linked charges come from `query_transactions(series_id=...)`; its governing rules appear on the admin Recurring detail page. |
+| `assign_series` | Write | A **one-off** link/mint — bind transactions to an existing series (`series_id`) or mint/resolve by `series_name` + `create_if_missing:true`. For durable patterns, author an `assign_series` rule instead. Optional `type` for a minted series. NULL-fill only — never steals a charge already in another series. |
+| `update_series` | Write | Rename a series or change its `type`. Both optional. Renaming onto an existing live name is rejected (the name is the series' unique mint key). |
+| `unlink_series_transactions` | Write | Detach transactions from a series (inverse of `assign_series`' link path). Errors if any listed transaction isn't a current member. |
- A typical reviewer agent calls `list_series(status="candidate")`, inspects each with `get_series`, and resolves it with `review_series` — the same confirm/reject loop a person runs on the Recurring page.
+ Series tags as a separate, inherited surface have been removed. Tag a series by adding an `add_tag` action to the same rule that does `assign_series` — every linked charge gets the tag at sync time and on retroactive apply.
+
+### Counterparties
+
+A counterparty is the canonical, cross-provider "other side" of a charge — merchants and non-merchants alike (Venmo, people, employers). Same model as series: a thin entity (surrogate identity, `name`, optional enrichment fields like `website_url`, `logo_url`, `category_id`, `mcc`) whose membership comes from `assign_counterparty` rules.
+
+| Tool | Scope | What it does |
+| --- | --- | --- |
+| `list_counterparties` | Read | List counterparties. |
+| `get_counterparty` | Read | Fetch one counterparty's `name` and enrichment. Governing rules appear on the admin Counterparties detail page. |
+| `assign_counterparty` | Write | One-off link/mint — bind transactions to an existing counterparty (`counterparty_id`) or mint/resolve by `name` + `create_if_missing:true`, with optional enrichment in the same call. For durable patterns, author an `assign_counterparty` rule. |
+| `update_counterparty` | Write | Edit a counterparty's name or enrichment fields. |
+| `unlink_counterparty_transactions` | Write | Detach transactions from a counterparty. |
diff --git a/transactions/rules.mdx b/transactions/rules.mdx
index b9d61f0..e29b1db 100644
--- a/transactions/rules.mdx
+++ b/transactions/rules.mdx
@@ -1,22 +1,22 @@
---
-title: "Rules: automate categorization, tagging, and annotations"
-description: "Build condition-based rules that categorize, apply tags, and leave annotations on transactions automatically — at sync time or retroactively — using Breadbox's recursive AND/OR/NOT condition engine with a multi-stage pipeline."
+title: "Rules: the substrate that turns provider data into intelligence"
+description: "Build condition-based rules that categorize, tag, annotate, flag, link to a series or counterparty, and write free-form metadata on transactions — at sync time or retroactively — using Breadbox's recursive AND/OR/NOT condition engine with a multi-stage pipeline."
sidebarTitle: "Rules"
---
-Breadbox's rules engine lets you define conditions that run automatically whenever transactions are synced. When a transaction matches a rule's conditions, the rule assigns a category, adds tags, or leaves a comment — without any manual intervention.
+Breadbox's rules engine is the single layer where your household's intelligence about transactions lives. Provider data (Plaid, Teller, CSV, ...) is treated as immutable — it's the raw substrate. Every durable choice you or an agent makes about that data — *"Spotify is a subscription"*, *"any Whole Foods over $50 is a grocery run, not prepared food"*, *"flag any single charge over $1000"* — accrues as a rule. The next sync resolves the same kind of charge the same way automatically.
-## What rules do
-
-A rule is a JSON document that pairs a **condition** (which transactions to match) with one or more **actions** (what to do with them). Rules run at sync time in a defined pipeline order. You can also apply rules retroactively to your full transaction history at any time.
+That's the doctrine: **provider data is immutable; intelligence accrues as rules**. A rule is a JSON document pairing a **condition** (which transactions to match) with one or more **actions** (what to do with them). Rules run at sync time in a defined pipeline order, and you can apply them retroactively to your full history at any time.
A single rule can:
- Set a transaction's category (`set_category`)
-- Add a tag to the transaction (`add_tag`)
-- Remove a tag from the transaction (`remove_tag`)
-- Leave an automated comment explaining the categorization (`add_comment`)
+- Add or remove tags (`add_tag`, `remove_tag`)
+- Write or delete free-form metadata keys (`set_metadata`, `remove_metadata`)
- Link the transaction to a recurring [series](/guides/tracking-subscriptions) (`assign_series`)
+- Link the transaction to a counterparty (`assign_counterparty`)
+- Raise or clear the flag that surfaces a charge for human attention (`flag`, `unflag`)
+- Leave an automated comment explaining the categorization (`add_comment`)
## Condition structure
@@ -25,7 +25,7 @@ Rule conditions use a recursive tree of **leaf nodes** and **combinators**.
A **leaf node** tests a single field:
```json
-{ "field": "merchant_name", "op": "contains", "value": "Starbucks" }
+{ "field": "provider_merchant_name", "op": "contains", "value": "Starbucks" }
```
A **combinator** groups multiple conditions with logic:
@@ -42,27 +42,42 @@ Combinators nest to any depth up to 10 levels. An empty condition object `{}` ma
| Field | Type | Description |
|-------|------|-------------|
-| `name` | string | Raw transaction description from the institution |
-| `merchant_name` | string | Enriched merchant name (may be empty for CSV or un-enriched rows) |
+| `provider_name` | string | Raw transaction description from the institution |
+| `provider_merchant_name` | string | Provider's enriched merchant name (may be empty for CSV or un-enriched rows) |
| `amount` | numeric | Transaction amount — positive = money out, negative = money in |
-| `category_primary` | string | Provider's raw primary category (does not change when Breadbox reassigns) |
-| `category_detailed` | string | Provider's raw detailed subcategory |
+| `provider_category_primary` | string | Provider's raw primary category (does not change when Breadbox reassigns) |
+| `provider_category_detailed` | string | Provider's raw detailed subcategory |
| `category` | string | Currently assigned Breadbox category slug (updates mid-pipeline as rules run) |
| `pending` | boolean | Whether the transaction is pending |
-| `provider` | string | `plaid`, `teller`, or `csv` |
+| `provider` | string | `plaid`, `teller`, `simplefin`, or `csv` |
| `account_id` | string | Account UUID |
| `account_name` | string | Account display name |
| `user_id` | string | Family member UUID |
| `user_name` | string | Family member display name |
| `tags` | tags | Current tag slugs on the transaction |
+| `series` | string | `short_id` of the recurring series the transaction belongs to (empty when unassigned) |
+| `in_series` | boolean | Whether the transaction is linked to any recurring series |
+| `counterparty` | string | `short_id` of the counterparty bound to the transaction (empty when unbound) |
+| `has_counterparty` | boolean | Whether the transaction is bound to any counterparty |
+| `day_of_month` | numeric | Day of the posting date (`1`–`31`) |
+| `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.` | metadata | One key from the transaction's free-form metadata blob — e.g. `metadata.tax_deductible` reads `metadata["tax_deductible"]`. |
-Use `category` (not `category_primary` or `category_detailed`) when you want a condition to react to the category that Breadbox or a prior rule assigned. The `category_primary` and `category_detailed` fields always hold the provider's original values and never change.
+Use `category` (not `provider_category_primary` or `provider_category_detailed`) when you want a condition to react to the category that Breadbox or a prior rule assigned. The `provider_*` fields always hold the provider's original values and never change.
+### Match-stability: prefer raw, immutable fields
+
+A rule is only as durable as the fields it matches on. The provider's raw fields (`provider_name`, `provider_merchant_name`, `amount`, `pending`, `provider`, `provider_category_*`) and the date-parts derived from the immutable posting date (`day_of_month`, `month`, `day_of_week`, `day_of_year`) are **raw-immutable** — Breadbox never rewrites them, so a rule keyed on them resolves the same way on the create pass, on every re-sync, and on retroactive apply. The surrogate IDs `account_id` and `user_id` are **stable-surrogate** — also safe.
+
+`account_name`, `user_name`, `category`, `tags`, `series`, `in_series`, `counterparty`, `has_counterparty`, and `metadata.` are **mutable-display** — they either silently break when something is renamed or depend on what an earlier-stage rule wrote in the same pass. Use them deliberately to chain off another rule's output, not as the load-bearing condition that decides whether the rule fires.
+
### Operators by field type
-**String fields** (`name`, `merchant_name`, `category_primary`, `category_detailed`, `category`, `provider`, `account_name`, `user_name`):
+**String fields** (`provider_name`, `provider_merchant_name`, `provider_category_primary`, `provider_category_detailed`, `category`, `provider`, `account_name`, `user_name`, `series`, `counterparty`):
| Operator | Behavior |
|----------|----------|
@@ -73,25 +88,24 @@ Use `category` (not `category_primary` or `category_detailed`) when you want a c
| `matches` | RE2 regex match (case-sensitive by default; use `(?i)` for insensitive) |
| `in` | Value is in the provided array (case-insensitive) |
-**Numeric fields** (`amount`):
+**Numeric fields** (`amount`, date-parts):
| Operator | Behavior |
|----------|----------|
| `eq` | Equal |
| `neq` | Not equal |
-| `gt` | Greater than |
-| `gte` | Greater than or equal |
-| `lt` | Less than |
-| `lte` | Less than or equal |
+| `gt` / `gte` / `lt` / `lte` | Standard comparisons |
+| `approx` | `value ± tolerance` — matches when `abs(actual - value) ≤ tolerance`. Requires a sibling `tolerance ≥ 0`. For `day_of_month` the comparison is cyclic (day 1 and the month's last day are 1 apart) and clamps a target past a short month to its last day (so "the 31st" matches February). |
+| `between` | `min ≤ actual ≤ max` (inclusive). Requires sibling `min` and `max`. |
-**Boolean fields** (`pending`):
+**Boolean fields** (`pending`, `in_series`, `has_counterparty`):
| Operator | Behavior |
|----------|----------|
| `eq` | Equal to `true` or `false` |
| `neq` | Not equal to `true` or `false` |
-**Tag fields** (`tags`):
+**Tag field** (`tags`):
| Operator | Behavior |
|----------|----------|
@@ -99,9 +113,20 @@ Use `category` (not `category_primary` or `category_detailed`) when you want a c
| `not_contains` | Transaction does not have the specified tag slug |
| `in` | Transaction has any of the slugs in the provided array |
+**Metadata fields** (`metadata.`):
+
+| Operator | Behavior |
+|----------|----------|
+| `exists` / `not_exists` | Key presence test; `value` is ignored |
+| `eq` / `neq` | Boolean, numeric, or stringified comparison driven by the expected value's type |
+| `contains` / `not_contains` / `matches` / `in` | String ops on the stringified stored value |
+| `gt` / `gte` / `lt` / `lte` | Numeric comparison; both sides must parse as numbers |
+
+Every operator other than `exists` / `not_exists` requires the key to be present — an absent key matches only `not_exists`. Use `or` of the two when you mean "missing OR different".
+
## Pipeline stages
-Rules run in pipeline order — lower stage numbers run first. For `set_category`, the **last matching rule wins**, so higher-stage rules have the final say on categorization. For `add_tag` and `add_comment`, every matching rule contributes.
+Rules run in pipeline order — lower stage numbers run first. For `set_category`, the **last matching rule wins**, so higher-stage rules have the final say on categorization. For accumulator actions (`add_tag`, `add_comment`, `set_metadata`), every matching rule contributes.
| Stage name | Priority | Purpose |
|------------|----------|---------|
@@ -114,18 +139,18 @@ Supply `stage` as a string in the request body. You can also supply a raw `prior
### Rule chaining
-Because rules run in pipeline order and share a mutable transaction context, later rules can react to what earlier rules did. A rule that assigns a category at stage 0 makes that category readable via `field: "category"` for any rule at stage 10 or higher in the same sync pass.
+Because rules run in pipeline order and share a mutable transaction context, later rules can react to what earlier rules did. A rule that assigns a category at stage 0 makes that category readable via `field: "category"` for any rule at stage 10 or higher in the same sync pass. The same holds for `tags`, `series`, `counterparty`, and `metadata.` — a later rule reads what an earlier one wrote.
## Creating a rule
-Here is a complete example that categorizes Amazon purchases:
+A complete example that categorizes Amazon purchases:
```json
{
"name": "Amazon purchases",
"conditions": {
"and": [
- { "field": "name", "op": "contains", "value": "AMAZON" },
+ { "field": "provider_name", "op": "contains", "value": "AMAZON" },
{ "field": "amount", "op": "gt", "value": 0 }
]
},
@@ -147,7 +172,7 @@ curl -X POST \
"name": "Amazon purchases",
"conditions": {
"and": [
- { "field": "name", "op": "contains", "value": "AMAZON" },
+ { "field": "provider_name", "op": "contains", "value": "AMAZON" },
{ "field": "amount", "op": "gt", "value": 0 }
]
},
@@ -160,7 +185,94 @@ curl -X POST \
http://localhost:8080/api/v1/rules
```
-A rule with no `trigger` specified defaults to `on_create`, which means it fires only on newly synced transactions. Use `always` to also run on re-synced changes, or `on_change` to run only when an existing transaction is modified.
+A rule with no `trigger` specified defaults to `on_create`, which means it fires only on newly synced transactions. Use `always` to also run on re-synced changes, or `on_change` to run only when an existing transaction is modified.
+
+## Actions
+
+### `set_category`
+
+```json
+{ "type": "set_category", "category_slug": "food_and_drink_groceries" }
+```
+
+Sets the transaction's assigned category. At most one `set_category` per rule. Last-writer-wins across the pipeline — rules, agents, and users all write the same `category_id` field, so a higher-stage rule's `set_category` overrides a lower one. Rules only run on new or changed transactions, so a user's manual edit on an unchanged row is not continuously re-clobbered.
+
+### `add_tag` / `remove_tag`
+
+```json
+{ "type": "add_tag", "tag_slug": "needs-review" }
+{ "type": "remove_tag", "tag_slug": "needs-review" }
+```
+
+Adds or removes a tag. Tags are auto-created on `add_tag` if the slug doesn't exist. Both are idempotent. If a single sync pass would `add_tag` and `remove_tag` the same slug, they cancel — neither write hits the DB.
+
+### `set_metadata` / `remove_metadata`
+
+```json
+{ "type": "set_metadata", "metadata_key": "tax_deductible", "metadata_value": true }
+{ "type": "remove_metadata", "metadata_key": "needs_receipt" }
+```
+
+Upserts or deletes one key in the transaction's free-form JSONB `metadata` blob, leaving every other key untouched. `metadata_value` can be any JSON value (string, number, boolean, object, array); keys are ≤128 chars and values must serialize to ≤4 KiB. Repeatable — a rule can write several keys at once. Last-writer-wins per key; a same-pass set-then-remove cancels.
+
+Use it to capture arbitrary household enrichment that isn't a first-class field: `tax_deductible`, `trip`, `reimbursable_by`, `project_code`, ... A later rule can read it back via `metadata.` conditions.
+
+### `assign_series`
+
+```json
+{ "type": "assign_series", "series_name": "Spotify", "create_if_missing": true }
+```
+
+Links the matching transaction to a recurring [series](/guides/tracking-subscriptions). Provide **exactly one** of:
+
+- `series_short_id` — link to an existing series by its short ID.
+- `series_name` + `create_if_missing: true` — mint a series by name if one doesn't already exist with that live name (surrogate-first; the same name always resolves the same series).
+
+A transaction belongs to at most one series; if several rules try to assign one, the highest-priority rule wins. Back-linking is NULL-fill only — `assign_series` never steals a charge already in another series.
+
+A series is its governing rules: the membership of every series is exactly the set of charges its `assign_series` rules match. The admin Recurring detail page makes this explicit by listing the linked charges beside the rules that define them.
+
+### `assign_counterparty`
+
+```json
+{ "type": "assign_counterparty", "counterparty_name": "Spotify USA Inc", "create_if_missing": true }
+```
+
+Binds the matching transaction to a counterparty — the canonical "other side" of a charge, covering merchants and non-merchants (Venmo, people, employers). Provide **exactly one** of `counterparty_short_id` or `counterparty_name` + `create_if_missing: true`. Same NULL-fill, last-writer-wins, surrogate-first semantics as `assign_series`. Like series, a counterparty's membership is exactly the set of charges its `assign_counterparty` rules bind.
+
+### `flag` / `unflag`
+
+```json
+{ "type": "flag" }
+{ "type": "unflag" }
+```
+
+Surfaces a transaction for human attention (or clears that flag). Both take no parameters. `flag` sets `transactions.flagged_at = NOW()`; `unflag` clears it. Last-writer-wins across the pipeline — a higher-priority `unflag` clears a lower-priority `flag`. Retrieve flagged rows with the `query_transactions(flagged=true)` MCP tool or `GET /transactions?flagged=true`.
+
+Use it to mark anything that needs eyes: large charges, suspected duplicates, foreign-currency outliers, charges from a paused subscription, ...
+
+### `add_comment`
+
+```json
+{ "type": "add_comment", "content": "Auto-categorized by rule: Dining" }
+```
+
+Appends a comment authored by the rule. Multiple rules can each contribute a comment in one sync pass. **Sync-only** — retroactive apply skips `add_comment` because comments narrate a specific sync event.
+
+## Combining actions
+
+A rule can carry multiple actions of different types; they all fire together. Useful combinations:
+
+| Actions | Use case |
+| --- | --- |
+| `set_category` alone | Straightforward reclassification. |
+| `set_category` + `add_tag` | Reclassify and annotate (e.g. Uber → `transportation_rideshare` + `recurring`). |
+| `assign_series` + `set_category` + `add_tag` | The full subscription pattern — link the charge, categorize it, tag it. |
+| `flag` + `add_comment` | Surface a transaction with a reason recorded in the timeline. |
+| `add_tag` + `remove_tag` (different slugs) | Transition between tags (add `reviewed`, remove `needs-review`). |
+| `set_metadata` | Capture household-specific enrichment for later querying. |
+
+Only `set_category`, `flag`, and `unflag` are singleton per rule. The rest can appear multiple times (e.g. add two tags, write two metadata keys).
## Previewing a rule
@@ -173,7 +285,7 @@ curl -X POST \
-d '{
"conditions": {
"and": [
- { "field": "name", "op": "contains", "value": "AMAZON" },
+ { "field": "provider_name", "op": "contains", "value": "AMAZON" },
{ "field": "amount", "op": "gt", "value": 0 }
]
}
@@ -203,36 +315,20 @@ curl -X POST \
-Retroactive apply respects the same pipeline stage ordering as sync. It also respects `category_override` — transactions whose `category_override` is not `'none'` are skipped for `set_category` actions, just as they are during sync.
+Retroactive apply respects the same pipeline stage ordering as sync. Every state-mutating action materializes — `set_category`, `add_tag`, `remove_tag`, `set_metadata`, `remove_metadata`, `assign_series`, `assign_counterparty`, `flag`, and `unflag` — through both the single-rule and the bulk apply-all paths.
-`add_comment` actions do **not** fire during retroactive apply. Comments are designed to narrate a specific sync event and are only written during live syncs.
+`add_comment` actions do **not** fire during retroactive apply. Comments narrate a specific sync event and are only written during live syncs.
-## Respecting manual overrides
-
-If a transaction has been manually categorized (its `category_override` is `'user'`), a rule's `set_category` action is skipped for that transaction. The rule still runs — `add_tag` and `add_comment` actions fire normally — but the category is not changed. This means you can safely apply rules in bulk without worrying about undoing deliberate manual work.
-
-## Assign a series
-
-The `assign_series` action links a matching transaction to a recurring [series](/guides/tracking-subscriptions) — a subscription or other repeating charge. It's the series counterpart to `set_category`: author the rule once and every future matching charge joins the series automatically. Provide **exactly one** of:
+## Last-writer-wins, no provenance guard
-- `series_short_id` — link to an existing series by its short ID.
-- `merchant_key` + `create_if_missing: true` — mint a household series for that merchant the first time a charge matches, then link every future match to it.
-
-```json
-{ "type": "assign_series", "merchant_key": "spotify", "create_if_missing": true }
-```
-
-A transaction belongs to at most one series; if several rules try to assign one, the highest-priority rule wins. Minting honors a rejected series — a rule can't resurrect a subscription you've dismissed.
-
-
- `assign_series` materializes at sync time and on **single-rule** [retroactive apply](#applying-a-rule-retroactively) (`POST /rules/{id}/apply`) — applying that one rule links matching existing transactions. The bulk *apply-all* path doesn't materialize `assign_series` yet, so apply the rule on its own to back-fill. You can also link a charge by hand or with the `assign_series` MCP tool.
-
+Rules, agents, and users all write the same underlying fields (`category_id`, tags, metadata, series link, counterparty link, flag). There is no per-source precedence — the writer who runs last wins. The sync engine still only runs rules on new or changed transactions, so a user's manual edit on an unchanged row is not silently re-clobbered on the next sync.
## Related reading
- [Rules API](/api/overview) — REST endpoints for creating, listing, updating, deleting, applying, and previewing rules.
- [MCP: rules (read)](/mcp/reference/rules) and [MCP: rules (write)](/mcp/reference/rules-write) — the equivalent tools agents use to create and maintain rules on your behalf.
- [Categories](/transactions/categories) — how the two-level hierarchy that rules target is structured.
+- [Tracking subscriptions](/guides/tracking-subscriptions) — how `assign_series` builds your recurring catalog.
- [Review workflow](/transactions/review-workflow) — how the seeded `needs-review` rule drives the default triage queue.