Skip to content

feat(rules): richer conditions — amount tolerance + date-part fields (P1)#1882

Merged
canalesb93 merged 1 commit into
feat/rules-substratefrom
feat/rules-substrate-p1-conditions
Jun 20, 2026
Merged

feat(rules): richer conditions — amount tolerance + date-part fields (P1)#1882
canalesb93 merged 1 commit into
feat/rules-substratefrom
feat/rules-substrate-p1-conditions

Conversation

@canalesb93

Copy link
Copy Markdown
Owner

Base: feat/rules-substrate. Implements P1 — richer rule conditions (roadmap §4a) of the rules-as-universal-substrate sprint. This is the condition expressiveness that lets agents encode recurrence as durable rules on raw + stable fields — subscriptions matched without a shipped detector.

What this adds

Amount tolerance operators on every numeric field:

  • approxvalue ± tolerance ({"field":"amount","op":"approx","value":15.49,"tolerance":0.5})
  • between — inclusive min/max ({"field":"day_of_month","op":"between","min":1,"max":5})

Date-part fields, numeric, derived from the tz-naive date column (no timezone math — provider-localized): day_of_month, month, day_of_week (0=Sun), day_of_year. Support eq/range/between/approx.

day_of_month approx is cyclic (the 1st and the month's last day are 1 apart) and clamped (a target past a short month collapses to its last day, so "the 31st" matches Feb 28 / Feb 29 in a leap year). Boundary behavior uses each transaction's own month length, so the same rule is correct across every month and leap years.

Guidance (docs + MCP schema): express annual cadence as month = M AND day_of_month ≈ D ± N (leap-robust), never day_of_year (drifts ±1 after Feb).

The canonical recurrence rule is now expressible:

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

Layers touched (complete system — mirrors #1874/#1876 metadata pattern)

Layer Files
Service validation internal/service/rules.govalidConditionFields (+4 date-parts), numericOps (+approx/between), per-op validation (tolerance ≥ 0, min ≤ max), CompiledCondition params, types.go Condition + TransactionContext.Date
Sync evaluation internal/sync/rule_resolver.goCondition/compiledCondition params, TransactionContext.Date, date-part evaluateLeaf cases, approx/between in evaluateNumeric, cyclic/clamped evaluateDayOfMonth; internal/sync/engine.go seeds tctx.Date = txn.Date
Retroactive + preview internal/service/rules.go — added t.date to transactionContextQuery SELECT + GROUP BY, columns 15→16, row scan + finalize, and the preview query populates tctx.Date (so the new fields work in preview_rule and retroactive apply)
MCP internal/mcp/tools.go — condition jsonschema updated with the new fields/ops + cyclic/annual guidance
Admin UI internal/templates/components/pages/rule_form.templ (Date optgroup, ≈ ± + between ops, tolerance/min/max inputs) + static/js/admin/components/rule_form.js (fieldTypes, operator options, serialize/parse of tolerance/min/max)
Docs docs/rule-dsl.md (fields + ops tables, match-stability stable-derived class, new "Amount tolerance" + "Date-part conditions" sections), docs/mcp-tools-reference.md

openapi.yaml unchanged: rule conditions are additionalProperties: true and no routes were added — TestOpenAPIDrift passes (same as the metadata PR #1874).

Evidence

Build / vet / unit (all green):

go build ./...        → ok
go vet ./...          → ok (incl. -tags integration)
go test ./internal/service/... ./internal/sync/... ./internal/mcp/...  → ok

The only failing unit test in the full go test ./... run was internal/agent's TestSidecarRun_ConcurrentRunsNoLongerSerialize — a pre-existing timing-flaky test unrelated to this change; it passed on re-run.

Integration (local Postgres):

DATABASE_URL=…/breadbox_test go test -tags integration -p 1 \
  ./internal/service/... ./internal/sync/... ./internal/db/...  → ok (all)
go test -tags integration -run TestOpenAPIDrift ./internal/api/... → ok

New tests:

  • rules_dateparts_test.go / rule_resolver_dateparts_test.go — operator + date-part eval, cyclic/clamped day_of_month (Feb / 30- / 31-day / leap boundaries), composition.
  • rules_dateparts_integration_test.go (service) — recurrence rule (amount approx X±Y AND day_of_month approx D±N → add_tag) and between+month rule via retroactive apply; only in-window charges tagged.
  • rules_dateparts_integration_test.go (sync) — same recurrence rule fires during a real sync; only the in-window charge tagged.

UI — the rule-form builder showing the new ≈ ± operator with a tolerance input, the Day of month date-part field, and the between min–max inputs:

Rule form with amount approx, day_of_month approx, and amount between conditions

🤖 Generated with Claude Code

…(P1)

Adds the condition expressiveness that lets rules absorb recurrence
without a shipped detector (rules-as-substrate P1, roadmap §4a):

- Numeric `approx` (value ± tolerance) and `between` (min/max) operators.
- Date-part fields derived from the tz-naive `date` column (no timezone
  math): day_of_month, month, day_of_week, day_of_year — all numeric.
- `day_of_month approx` is cyclic (1 ↔ the month's last day are 1 apart)
  and clamps a target past a short month to its last day, so "the 31st"
  matches Feb. Boundary-tested across Feb / 30- / 31-day / leap months.

Threaded end-to-end (mirroring the metadata-condition pattern from #1874/
#1876): service validation + compile/eval, sync resolver + the four
retroactive/preview context queries, engine date seeding, MCP tool
schemas, the admin rule-form builder (field optgroup + ≈/between inputs),
and docs/rule-dsl.md + docs/mcp-tools-reference.md. openapi.yaml needs no
change (conditions are additionalProperties; no new routes — drift green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU
@canalesb93 canalesb93 added the rules-substrate Rules-as-universal-substrate sprint (base: feat/rules-substrate) label Jun 20, 2026
@canalesb93 canalesb93 merged commit d22848d into feat/rules-substrate Jun 20, 2026
8 checks passed
@canalesb93 canalesb93 deleted the feat/rules-substrate-p1-conditions branch June 20, 2026 01:34
canalesb93 added a commit that referenced this pull request Jun 24, 2026
… migrations (#1906)

* docs: adopt rules-as-substrate doctrine in CLAUDE.md (#1879)

Add the "Operating Model — the reconciliation flywheel" section codifying the
holy rule (provider data is immutable; breadbox intelligence accrues as rules)
and its four principles: surrogate identity, rules-as-only-deterministic-layer,
agents-review / rules-remember, and no shipped resolution. Provenance precedence
is explicitly deferred; detectors/normalizers-as-identity are out.

First step (P0) of the rules-as-universal-substrate adoption.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* docs: rule-matchable-field stability contract + doctrine pointers (P0-T6/T7) (#1880)

P0-T6: add the authoritative match-stability contract to docs/rule-dsl.md —
every condition field tagged raw-immutable / stable-surrogate / mutable-display,
derived from validConditionFields (internal/service/rules.go:30-46) and the
TransactionContext evaluation in internal/sync/rule_resolver.go:775-810. Guidance:
author on raw-immutable + stable-surrogate; mutable-display fields silently break
on rename or depend on pipeline order.

P0-T7: add "Governing doctrine" pointers near the top of docs/rule-dsl.md and
docs/data-model.md referencing the CLAUDE.md "Operating Model — the reconciliation
flywheel" section and the Obsidian planned-features docs.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(rules): retroactive apply parity for all state-mutating actions (P0-T4) (#1881)

The bulk ApplyAllRulesRetroactively path dropped assign_series (and would
silently drop any newly-added action) because its per-txn action switch had
no case for it. The single-rule ApplyRuleRetroactively path handled the full
set, so retroactive coverage diverged by entry point.

Extract a single shared per-transaction materializer, applyRetroTxnIntent,
that both retroactive paths reduce their matches to and call. It covers every
state-mutating action — set_category (override-guarded), add_tag, remove_tag,
assign_series, set_metadata, remove_metadata — so coverage can no longer
diverge, and the P1 set_field family has one place to land.

- ApplyAllRulesRetroactively: add the assign_series case (the load-bearing
  fix) + an explicit, documented add_comment no-op; route materialization
  through applyRetroTxnIntent.
- ApplyRuleRetroactively: route its batch materialization through the same
  applier.
- actionAuditFields: add the missing remove_tag case (audit field
  "tag_remove", mirroring the sync resolver) so rule_applied annotations are
  consistent with add_tag.
- add_comment is now consistently skipped on retroactive apply across all
  paths, documented on retroTxnIntent (it is sync-time narration, not durable
  state replayed on a historical backfill).

The category_override='none' guard is preserved unchanged (its removal is P3).

Adds an integration regression test asserting each state-mutating action
(especially assign_series) materializes through the bulk apply-all path.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(rules): richer conditions — amount tolerance + date-part fields (P1) (#1882)

Adds the condition expressiveness that lets rules absorb recurrence
without a shipped detector (rules-as-substrate P1, roadmap §4a):

- Numeric `approx` (value ± tolerance) and `between` (min/max) operators.
- Date-part fields derived from the tz-naive `date` column (no timezone
  math): day_of_month, month, day_of_week, day_of_year — all numeric.
- `day_of_month approx` is cyclic (1 ↔ the month's last day are 1 apart)
  and clamps a target past a short month to its last day, so "the 31st"
  matches Feb. Boundary-tested across Feb / 30- / 31-day / leap months.

Threaded end-to-end (mirroring the metadata-condition pattern from #1874/
#1876): service validation + compile/eval, sync resolver + the four
retroactive/preview context queries, engine date seeding, MCP tool
schemas, the admin rule-form builder (field optgroup + ≈/between inputs),
and docs/rule-dsl.md + docs/mcp-tools-reference.md. openapi.yaml needs no
change (conditions are additionalProperties; no new routes — drift green).


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(rules): flag / unflag actions across sync, retroactive, MCP + UI (P1) (#1883)

Add two no-parameter rule actions that surface transactions for human
attention, mirroring the flag_transaction MCP tool's flagged_at write:

- flag   → sets transactions.flagged_at = NOW()
- unflag → clears flagged_at to NULL

Threaded through every layer so rule-driven flags are consistent with
tool-driven flags and both retroactive paths inherit it for free:

- Validation: validActionTypes + ValidateActions (no params; last-writer-wins).
- Sync: typedAction parse, RuleActions.FlagIntent (tri-state, last-writer-wins,
  dropFlagSource for audit), engine.applyRulesToTransaction materializes
  flagged_at within the sync tx.
- Retroactive: retroTxnIntent.flagIntent + the shared applyRetroTxnIntent, so
  both ApplyRuleRetroactively and the bulk ApplyAllRulesRetroactively cover it
  with no per-path divergence; actionAuditFields emits flag/unflag rule_applied.
- MCP: action jsonschema descriptions (create/update/batch + apply_rules).
  No parser change needed — convertMCPActions passes type through.
- Admin UI: rule_form.js action registry + serializer (value-less), singleton
  guard + flag/unflag combination warning; rule_form.templ adds the two
  options and a hint row (no value input). ActionsSummary labels them.
- Docs: rule-dsl.md action sections + retroactive table; mcp-tools-reference.md.

Tests: unit (validate + resolve last-writer-wins + parse + audit fields);
integration (flag/unflag via sync AND both retroactive entry points).


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(series): remove the detection surface (P2-1) (#1884)

Rules-as-universal-substrate, P2 step 1: delete the shipped recurring-series
detector/normalizer. Series membership now comes only from assign_series rules
(materialized via AssignSeriesInTx, still wired). No schema change this PR — the
transactions.merchant_key column stays (now NULL for new rows) and the
assign_series merchant_key path is untouched; both are reshaped in P2-PR2.

Deleted: internal/service/series_detect.go, series_detector.go,
internal/sync/merchantkey.go, internal/cli/series.go and their tests.

Removed the detection-coupled read surface that depended on the deleted
cadenceIntervalDays helper: seriesRenewalHealth + SeriesHealth* consts,
RenewalHealth/DaysUntilRenewal on SeriesResponse, ReinferSeriesTypes, and the
explain_series_candidates MCP tool + GET /series/explain REST route (their
service impl ExplainSeriesCandidates lived in the deleted detector).

Callers updated to stay green: cli/serve.go (drop detection hook, keep
AssignSeriesInTx), cli/root_full.go (drop AddSeriesCmd), sync/engine.go
(MerchantKey -> pgtype.Text{}), admin/subscriptions.go (drop renewal chip),
service/fields_entities.go, mcp + api registrations, openapi.yaml + docs.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(series): thin rule-maintained schema + surrogate-first assign_series (P2-2) (#1885)

Rebuild recurring_series as a thin, rule-maintained entity (rules-as-universal
substrate, P2). A series is now just a surrogate identity (id/short_id), an
agent/user-authored name, and a type — membership comes only from assign_series
rules, with no shipped detector, no cadence/amount/next-date stats, and no
confidence/lifecycle axis.

Migration (destructive, authorized): drop the detector app_config seeds, drop
transactions.merchant_key, DROP TABLE recurring_series CASCADE, recreate thin
(id, short_id, name UNIQUE-where-live, type, timestamps), re-point series_tags +
transactions.series_id FKs.

assign_series goes surrogate-first: target by series_short_id or mint/resolve by
series_name (idempotent UPSERT on the unique live name). RuleAction renames
MerchantKey→SeriesName with a backward-compat parse for stored merchant_key rules
(both the service JSON path and the sync resolver). Service rebuild drops the
whole detection funnel (UpsertSeriesCandidate, ReviewSeries, RekeySeries,
SplitSeries, SetSeriesType, rollups, source/confidence/cadence helpers) and adds
ListGoverningRules. MCP/REST drop review/rekey/split/type; assign/update reshaped.
openapi + docs (mcp-tools-reference, api-endpoints, data-model) updated.

admin/subscriptions.go is at a minimal compiling state; the detection-free UI
rebuild (linked-charges + governing-rules panels) lands in P2-PR3.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(series): detection-free admin UI with governing-rules view (P2-3) (#1886)

Rebuild the recurring-series admin surface for the rules-as-substrate model.
A series is a thin entity (name + type) whose membership comes only from
`assign_series` rules, so the detail page now makes that explicit: linked
charges sit beside the GOVERNING RULES that define them.

- /recurring list: flat ledger of every live series (name · type · charge
  count); no candidate/review queue, no detection/confidence/cadence/renewal.
- /recurring/{id} detail: Linked charges + Governing rules panels (via
  svc.ListGoverningRules), each rule showing its condition summary + a link to
  the rule editor; thin name/type edit drawer; inline tags.
- New series form: name + type only.
- components: drop the detector panels (SeriesDetectionPanel/MatchWindow/
  EvidenceTimeline/FactStrip) + Renewal/Confidence chips; add GoverningRulesPanel
  (keep SeriesTypeChip).
- service/db: add CountSeriesMembersGrouped query + ListSeriesMemberCounts so the
  list shows per-series charge counts in one round-trip.
- JS: strip candidate-review/detection/verdict/rekey/category logic; keep the
  thin name/type save, tags, and link/unlink-charge paths.
- design sandbox + docs (data-model, rule-dsl): note that a series' membership is
  defined by its assign_series rules and surfaced via the governing-rules view.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(rules): drop category_override / provenance guard; preserve category on re-sync (P3) (#1887)

P3 of the rules-as-universal-substrate sprint. Provenance/precedence
(user > agent > rule) is deferred: drop the `category_override` column and
its guard entirely. Rules, agents, and users all write `category_id`
directly (last-writer-wins). The sync engine still only runs rules on
`isNew||isChanged` transactions, so a user's manual edit on an unchanged
row is not continuously re-clobbered. Annotations remain an audit log; no
logic keys off them.

- migration: DROP COLUMN category_override + its CHECK constraint
- UpsertTransaction: on-conflict preserves existing category_id (raw
  re-sync never changes category — resolves the deferred P0-T5 concern)
- collapse SetTransactionCategoryOverride{,Agent} into SetTransactionCategory;
  delete the lock-toggle query/service/handler/route + detail-page UI
- remove override guards from sync engine, retroactive apply, preview,
  bulk recategorize, and the update_transactions agent/user branch
- update_transactions: Status is now ok/error only (no "skipped"); summary
  drops the skipped count
- strip category_override from MCP/REST responses, schemas, openapi, docs,
  and agent prompts
- new docs/transaction-columns.md: the canonical transaction-column contract
  (class + rule-matchability per column, + naming reassessment for review)
- tests: delete the override enum/precedence suites; invert the sacred-lock
  tests to last-writer-wins; add a raw-re-sync-preserves-category guard


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(agents): Transaction Reviewer preset + rules curriculum (P5-1) (#1888)

Add the Transaction Reviewer workflow preset and the two prompt blocks
that teach agents the rules-as-universal-substrate operating model:
agents review each transaction and either make a one-off edit or codify a
recurring pattern as a rule, so the next sync resolves it automatically.

- prompts/agents/rules-curriculum.md — knowledge block. The DECISION
  framework: codify-vs-one-off heuristic, the find_matching_rules →
  preview_rule → create/batch_create_rules author flow, the stable-field
  doctrine (raw immutable fields + date-parts, never mutable display
  fields), the recurrence idiom (amount approx + day_of_month approx →
  assign_series), and provenance-free last-writer-wins writes. References
  breadbox://rule-dsl for the grammar rather than duplicating it.
- prompts/agents/strategy-transaction-reviewer.md — strategy block. The
  per-transaction question checklist (uncategorized? recurring? known
  entity?) and the one-off-vs-rule decision.
- internal/service/workflow_presets.go — one "transaction-reviewer"
  preset entry (Categorization & Review, read_write, post-sync,
  apply-mode option). Passes all TestT1* preset unit tests.

No migration, no schema, no openapi, no UI changes.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* docs(mcp): bring breadbox://rule-dsl resource to grammar parity (P1/P2 capabilities) (#1889)

The agent-facing breadbox://rule-dsl MCP resource was still the P0 version
while the rules-as-substrate sprint shipped P1/P2 grammar. Agents read this
resource to author rules, so the stale grammar undermined the doctrine.

Brought to full parity with internal/service/rules.go and
internal/sync/rule_resolver.go:
- date-part fields (day_of_month, month, day_of_week, day_of_year)
- numeric approx (value + tolerance) and between (min/max) operators
- the recurrence idiom (amount approx + day_of_month approx -> assign_series)
- surrogate-first assign_series (series_short_id | series_name+create_if_missing;
  no merchant_key), plus set_metadata/remove_metadata/flag/unflag actions
- provenance-free last-writer-wins semantics (category_override removed in P3)
- stable-vs-mutable field guidance; removed detector / merchant_key /
  category_override references

assign_counterparty noted as not-yet-available (P4).


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(counterparties): entity + assign_counterparty action + resolution (P4-A) (#1890)

Introduce the counterparty dimension (rules-as-universal-substrate, P4 PR A):
a canonical, cross-provider, surrogate-identity entity that is the "other
side" of a transaction (merchants AND non-merchants), resolved PURELY by
assign_counterparty rules on raw provider fields — no normalizer, no
auto-create (unless create_if_missing), no UNIQUE on name.

- Migrations: counterparties table (id/short_id+trigger, name, enrichment
  cols, category_id FK, attrs JSONB, canonical self-ref, partial indexes, NO
  unique-on-name) + transactions.counterparty_id (ON DELETE SET NULL +
  partial index); annotations_kind_check widened with
  counterparty_assigned/_unlinked. UpsertTransaction unchanged.
- sqlc queries/counterparties.sql + service/counterparties.go mirroring the
  series surrogate path (AssignCounterparty imperative, resolve-or-create by
  name, AssignCounterpartyFromRuleTx engine hook, List/Get/Update/Delete,
  ListCounterpartyGoverningRules, link/unlink + membership annotations).
- assign_counterparty action wired at every assign_series site: types.go
  (RuleAction fields, CounterpartyResponse/AssignCounterpartyInput),
  rules.go (validActionTypes, ValidateActions, actionAuditFields,
  retroTxnIntent + applyRetroTxnIntent, both retroactive paths, condition
  fields counterparty/has_counterparty + service evaluateLeaf + context
  queries), sync/rule_resolver.go (typedAction, parseTypedActions,
  CounterpartyAssignIntent, RuleActions, ResolveWithContext last-writer-wins
  + dropCounterpartySource, cpShortID cache, evaluateLeaf), sync/engine.go
  (AssignCounterpartyInTx hook + materialization + tctx seeding),
  cli/serve.go (wire the hook).
- Tests: counterparties_integration_test.go (assign by short_id, create-by-
  name, retroactive single + bulk, governing rules, cascade-delete nulls,
  condition matching, ValidateActions) + rule_resolver_counterparty_test.go
  (parseTypedActions, ResolveWithContext last-writer-wins, evaluateLeaf).


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* test(agents): reviewer-authors-rule E2E (P5-2) (#1891)

Prove the rules-as-substrate doctrine's core loop end-to-end at the
service layer: a scheduled reviewer authors a rule, the next sync
applies it retroactively, and matching transactions resolve to the
right surrogate entity. No fake sidecar — the load-bearing pipeline is
rule → apply → membership, exercised through the real
CreateTransactionRule + ApplyAllRulesRetroactively methods.

- TestReviewerAuthorsRecurrenceRule: provider_name contains + amount
  approx (P1 operator) → assign_series(create_if_missing) → series
  minted, all members linked.
- TestReviewerAuthorsCounterpartyRule: provider_name contains →
  assign_counterparty(create_if_missing) → counterparty resolved,
  members bound (P4 action through the reviewer loop).
- TestReviewerAuthorsDayOfMonthRule: day_of_month approx D±N recurrence
  idiom links only the in-window charges.
- MCP companion: create_transaction_rule + assign_series + retroactive
  apply response-shape + side-effect assertion.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(counterparties): read-model join + display (P4-B) (#1892)

Surface the assigned counterparty as the primary display for a transaction
across every read path, with the on-the-fly COALESCE(provider_merchant_name,
provider_name) as fallback.

- service/transactions.go: LEFT JOIN counterparties in ListTransactions,
  ListTransactionsAdmin, GetAdminTransactionRowsByIDs, GetTransaction;
  select cp.short_id/name/logo_url.
- service/types.go: CounterpartyShortID/Name/LogoURL on TransactionResponse
  and AdminTransactionRow; CounterpartyShortID/LogoURL on MerchantSummaryRow.
- service/transaction_summary.go: merchant rollup is counterparty-aware —
  SELECT COALESCE(cp.name, ...), LEFT JOIN cp, GROUP BY counterparty_id + cp keys.
- service/fields.go: counterparty_short_id/counterparty_name in the field whitelist.
- Display fallback (prefer counterparty name; 16x16 logo when present): tx_row
  list, transaction_detail hero + details, feed cards, CSV export merchant column.
- openapi.yaml: document the new optional/nullable Transaction fields.
- Integration tests: join surfaces counterparty name on list/detail; merchant
  rollup collapses raw provider names under one counterparty group.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* docs(agents): add assign_counterparty to the rule grammar + curriculum (#1893)

The assign_counterparty action and counterparty/has_counterparty
condition fields now exist in the engine (P4-PR-A), but the two
agent-facing grammar docs still omitted them. Bring both to parity:

- prompts/mcp/rule-dsl.md (breadbox://rule-dsl): add assign_counterparty
  action examples + semantics, the counterparty/has_counterparty
  membership fields (marked derived/mutable), and counterparty to the
  stable-vs-mutable guidance. Replace the "not available yet" note.
- prompts/agents/rules-curriculum.md: add assign_counterparty to the
  action catalog, list counterparty/has_counterparty as mutable, and
  replace the deferred-follow-up HTML comment with the counterparties
  idiom (identify the entity → author a rule on raw fields → reuse
  across providers rather than duplicating).


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(counterparties): MCP + REST + admin pages + enrichment (P4-C) (#1894)

The surface layer over the P4 counterparty service (entity + action +
read-model merged in PR A/B). Counterparties are the canonical,
cross-provider "other side" of a charge — merchants and non-merchants —
whose membership comes from assign_counterparty rules.

MCP (internal/mcp/tools_counterparties.go + server.go):
  list_counterparties, get_counterparty (read); create_counterparty,
  update_counterparty (enrichment), assign_counterparty (one-off; nudges
  authoring a RULE for durability), unlink_counterparty_transaction
  (write). Resource template breadbox://counterparty/{short_id} returns
  detail + governing rules.

REST (internal/api/counterparties.go + router.go):
  GET /counterparties, GET /{id} (read); POST, PATCH /{id}, POST
  /{id}/transactions, DELETE /{id}/transactions/{txid} (full_access).
  openapi.yaml: Counterparty schema + all routes + tag.

Admin (internal/admin/counterparties.go + templates):
  /counterparties list (name · logo · linked-charge count ·
  governing-rule count) and /counterparties/{id} detail (manual
  enrichment form, linked charges, GoverningRulesPanel reused from the
  series detail). Nav + command-palette entry under Manage.

Service helpers: CounterpartyMembers, ListCounterpartyMemberCounts,
ListCounterpartyGoverningRuleCounts (+ two sqlc queries).

Docs: mcp-tools-reference.md, api-endpoints.md.

build + vet + test green; TestOpenAPIDrift + TestOpenAPIYAMLValid green.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(rules): author assign_series + assign_counterparty from the admin rule form (#1895)

The rule engine + MCP have supported the assign_series and assign_counterparty
actions for a while, but the admin rule-form editor never offered them — so a
USER could only author membership-defining rules indirectly (agents via MCP).
The rules-as-substrate doctrine says intelligence accrues as rules authored by
users AND agents; this closes that gap.

Adds both as first-class, authorable actions end-to-end:
- rule_form.js: actionTypes entries, parse (JSON->form) and serialize
  (form->JSON), singleton enforcement, and an entity create-by-name affordance.
- rule_form.templ: two action-row UIs modeled on the category dropdown — a
  <select> of existing series/counterparties (value=short_id, label=name) plus
  a mutually-exclusive "create new named…" text input.
- rule_form_types.go + admin/rules.go: thread svc.ListSeries / ListCounterparties
  through RuleFormProps so the dropdowns populate.

Entity selection has two mutually-exclusive modes: pick an existing entity
(serializes as *_short_id) or type a new name (serializes as *_name +
create_if_missing:true). Filling one disables the other, so exactly one
identifier reaches ValidateActions. Round-trip verified: create persists the
exact engine JSON and edit re-populates both modes.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* feat(counterparties): render counterparty assign/unlink in the activity timeline (#1896)

P4 added the counterparty_assigned / counterparty_unlinked annotation kinds
and service.AssignCounterparty/UnlinkCounterparty WRITE them, but the activity
timeline only handled the series_* twins — so counterparty events were dropped
as unknown kinds. Close the gap by mirroring series_assigned exactly.

- service/annotations_enrich.go: enrich counterparty_assigned/_unlinked into a
  Summary sentence (formatCounterpartyMembershipSummary), mirroring
  formatSeriesMembershipSummary. Resolves counterparty_name from the payload
  (same shape the service writes).
- admin/transactions.go: map both kinds to a single "counterparty" ActivityEntry
  type, passing the enriched Summary through — exact mirror of the "series" case.
- templates/.../transaction_detail.templ: add the "counterparty" type to the
  icon switch (store, info tint) and the Summary-fallback sentence branch.
- mcp/tools_tags.go: add "counterparty" → {counterparty_assigned,
  counterparty_unlinked} to mcpAnnotationKinds + the kinds schema/error text.
- docs/activity-timeline.md: document the new Type rows (series + counterparty)
  per the "adding a new system-event kind" contract.

Tests: unit (enrichment summaries for both series + counterparty, actor and
system variants) and integration (assign a counterparty → ListAnnotations
returns the enriched "set the counterparty to {name}" row).


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(ui): shared entity components + design-system pass on series/counterparty pages (#1897)

Extract entity-neutral detail-page chrome shared by the recurring-series
and counterparty pages, and bring both surfaces onto the design system.

Shared UI components (entity-neutral):
- Parameterize components.GoverningRulesPanel via GoverningRulesPanelProps
  (Rules + EntityWord + ActionCode) so the empty-state copy is generic;
  both detail pages call it directly. Deletes the counterpartyGoverningRulesPanel
  wrapper.
- Relocate the series-named shared helpers into a new shared file
  (entity_detail_shared.templ / _types.go): seriesPanelHeader -> entityPanelHeader,
  seriesChargeRow -> entityChargeRow, seriesNewRuleBtn -> entityRulesBtn,
  seriesChargeDate -> entityChargeDate. The cross-page dependency is now explicit.

Design-system fixes:
- C-3: replace the hand-rolled seriesEditDrawer slide-over with the shared
  components.Drawer + $store.drawers ('series-edit'); editOpen state dropped
  from the Alpine factory.
- C-6/C-7: extract a counterpartiesList Alpine factory (new
  static/js/admin/components/counterparties.js) handling the search filter and
  shortcuts-scope init/destroy; replaces the inline x-data="{ q: '' }".
- C-1: fold the bare shortcut-scope div on /recurring into the subscriptionsList
  factory's init/destroy.

UI only — service/MCP/REST layers untouched. Behavior identical.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* (beyond-roadmap) feat(ui): Workflows-DNA polish — entity avatars + richer rows on series/counterparty pages (#1898)

* feat(ui): Workflows-DNA polish — entity avatars + richer rows on series/counterparty pages

Bring the Workflows-gallery avatar DNA to /counterparties and /recurring so
both surfaces read as one polished product.

New shared components.EntityAvatar — a rounded-square identity tile with three
shapes by precedence: a logo image (counterparty logo_url), a type-tinted lucide
tile (series, reusing the .bb-icon-tile--* tones), or a deterministic OKLCH
gradient monogram (the no-logo fallback; hue is a stable FNV hash of the name).
Pure helpers + unit tests in entity_avatar.go/_test.go; the gradient is driven
by an inline --bb-avatar-hue read by a new .bb-entity-avatar--mono class
(mirrors how .bb-tx-avatar consumes --avatar-color).

Counterparty rows: swap the flat logo tile for EntityAvatar, add a category chip
and a "N charges · M rules" body line. Series rows: type-tinted EntityAvatar
(success/warning/info/neutral so each type reads distinctly on the dark theme),
type chip, and the same charges · rules body line. Detail headers on both pages
fill the EntityHeader icon slot with the same EntityAvatar so the hero matches
the list.

Handlers thread the new data: the /recurring list now wires
ListSeriesGoverningRuleCounts (new service method mirroring the counterparty
one) and the /counterparties list resolves each default-category UUID → display
name for the chip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

* fix(ui): build-tag entity_avatar.go !headless && !lite

The new EntityAvatar .go file referenced IconTone/iconToneClass (defined in
the !headless && !lite entity_header_templ.go) but lacked the matching build
constraint, so the headless + lite CI builds failed. Matches the package
convention (user_avatar.go).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* chore: prune dead code from the P2/P3 deletions (nav badge, unused helper) (#1899)

Pure cleanup, zero behavior change, from the rules-as-universal-substrate
sprint's detector (P2) and category_override/provenance (P3) removals.

- Remove the stale `SeriesCandidates` nav-badge chain: the field on
  NavBadges + its always-0 population in NavBadgesMiddleware, the copy
  into NavProps, the NavProps field, and switch the /recurring nav entry
  from @navLinkBadge (stale detector-era tooltip, count always 0) to a
  plain @NavLink. The badge was never visible.
- Delete the unused `subscriptionSearchHaystack` helper (never called).
- Annotate the three merchant_key back-compat shims (rule_resolver,
  service/types UnmarshalJSON, mcp/tools convertMCPActions) with an
  explicit removal-trigger TODO. These are kept, not removed.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* (beyond-roadmap) feat(counterparties): brand logos via logo.dev (hotlink + monogram fallback) (#1900)

* (beyond-roadmap) feat(counterparties): brand logos via logo.dev (hotlink + monogram fallback)

Counterparty avatars now show real brand logos, hotlinked from the free
logo.dev image API, degrading to the gradient monogram (#1898's EntityAvatar)
whenever logo.dev has no logo or the feature is off — never a broken image.

- components.LogoDevURL/LogoDevDomain: derive the registrable host from a
  counterparty website_url (strip scheme + www., reject single-label hosts) and
  build https://img.logo.dev/{domain}?size=128&format=png&retina=true&fallback=404
  [&token=…]. Verified endpoint: a publishable token is required for a real logo
  (401 without), and fallback=404 makes unknown domains degrade to our monogram.
- EntityAvatar gains a LogoDevURL prop: the logo.dev image renders OVER the
  monogram and self-removes on error (onerror), so the tile degrades gracefully.
  Precedence: manual logo_url → logo.dev hotlink → monogram. Series unaffected.
- Service.CounterpartyLogoSettings resolves enable + token with env → app_config
  → default precedence (BREADBOX_COUNTERPARTY_LOGOS / LOGO_DEV_TOKEN env wins).
- Settings → General → Counterparties: auto-save toggle + optional publishable
  token input (public key, stored plaintext).
- Docs: data-model.md app_config keys + a logo.dev hotlinking privacy note
  (domain sent on render; toggle to disable; self-host exempt from link-back).
- Unit tests for domain derivation + URL building; integration test for the
  config precedence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

* fix(counterparties): token-gate logo.dev hotlinking (no token → monogram)

logo.dev's image API requires a publishable key — a tokenless img.logo.dev
request 401s on every render. Make the feature token-gated rather than relying
on a guaranteed-failing request: emit a logo.dev URL only when a token is
configured (plus enabled + derivable domain + no manual logo_url). With no token
(the default) every counterparty renders its gradient monogram and nothing
leaves the browser for logo.dev — a correct, complete default.

- counterpartyLogoDevURL now requires token != "".
- Settings copy: token input is the load-bearing control ("required for logos to
  appear. Get a free token at logo.dev").
- Admin unit test for the gate (token present/absent, toggle off, manual
  override, no/bad domain); docs note updated.

Resolution chain: explicit logo_url → (enabled + token + domain) img.logo.dev →
gradient monogram.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* (beyond-roadmap) fix(rules): legible action icon + summary for all action types (#1901)

The /rules list and rule-detail page only summarized a handful of action
types meaningfully (set_category / add_tag / remove_tag / add_comment /
assign_series). The action types added this sprint — assign_counterparty,
flag, unflag, set_metadata, remove_metadata — fell through to a generic
"layers" icon (list) / "Unknown action type" (detail), inconsistent with
the others.

Display-only fix (no engine/semantics change):

- rulesActionIcon (rules.templ): assign_counterparty->store, flag->flag,
  unflag->flag-off, set_metadata/remove_metadata->braces. Icons match the
  ones used elsewhere (Counterparties nav, rule form flag/unflag).
- service.ActionsSummary: add set_metadata ("Set metadata: {key}") and
  remove_metadata ("Remove metadata: {key}"). assign_counterparty / flag /
  unflag were already covered.
- rule_detail.templ "Then" card: full per-action rows (icon tile + label +
  caption) for assign_series, assign_counterparty, flag, unflag,
  set_metadata, remove_metadata instead of the "Unknown action type" fallback.
- rules_types.go PrimaryActionType doc comment updated for accuracy.
- TestActionsSummary extended to cover every action type.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* (beyond-roadmap) fix(feed): legible series/counterparty membership events in the activity feed (#1902)

Series/counterparty membership annotations (series_assigned,
series_unlinked, counterparty_assigned, counterparty_unlinked) reach the
home feed (the query only excludes sync_started/sync_updated) and #1896
enriched their per-tx Summary, but the feed's bulk-action rendering had
no cases for them — they fell through to the generic "updated" verb and
the kind-breakdown loop printed the raw kind string ("6 series_assigned").

Add the four membership kinds wherever the feed switches on annotation
kind, mirroring the existing category_set / tag / rule handling:

- feedBulkVerb: "assigned to a series" / "removed from a series" /
  "assigned to a counterparty" / "removed from a counterparty".
- feedBulkKindBreakdown: ordered friendly labels (series-assigned,
  series-removed, counterparty-assigned, counterparty-removed) so mixed
  buckets never leak the raw kind string.
- feedBulkSubjectLabel: single-subject phrasing ("to the Netflix series",
  "to Amazon").
- bulkSubjectKey / bulkSubjectSlug (service): key membership subjects on
  the payload short_id (series:<id> / counterparty:<id>) so same-subject
  rows dedup + group, matching how category_set keys on its slug.
- projectFeedBulkAction (admin): explicit cases stamping the canonical app
  icon (repeat for series, store for counterparty); the human name flows
  from the enriched Annotation.Subject.

Display-only: no engine, schema, or annotation-emission changes.

Tests: feedBulkVerb / feedBulkSubjectLabel / feedBulkKindBreakdown
(pages) and bulkSubjectKey / bulkSubjectSlug (service) cover all four
membership kinds plus a missing-payload guard and category_set
regression.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* (beyond-roadmap) fix(counterparties): make logo.dev logo source discoverable on the enrichment form (#1903)

PR #1900 shipped counterparty brand logos via logo.dev, but the enrichment
form's Details card never explained the resolution chain, so users couldn't
discover how to get a logo. Display/copy-only — no engine, data, or config
changes.

- Website: add a hint that its domain auto-fetches a brand logo via logo.dev
  when counterparty logos are enabled and a logo.dev token is set in Settings
  (no token -> no logo; doesn't overclaim).
- Logo URL: reword the hint to convey it's the manual OVERRIDE that takes
  precedence over the auto-fetched logo.

The /counterparties/new create form only has a Name field, so it's untouched.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(rules): align admin rule-form field options with engine field names (#1904)

The visual rule builder offered Name / Merchant / Category (raw primary) /
Category (raw detail) as condition fields, but emitted the un-prefixed keys
`name`, `merchant_name`, `category_primary`, `category_detailed`. The rule
engine's validConditionFields map (and the resolver, matcher, and detail-page
label map) only know the canonical `provider_*` names, so submitting any rule
that matched on one of these fields failed with
`invalid parameter: unknown field "merchant_name"`.

This silently broke the doctrine's core path: a merchant-contains →
assign_counterparty / assign_series rule — exactly what the rules-as-substrate
sprint is built around — could not be authored from the admin UI at all.

Rename the four option values (rule_form.templ) and the matching fieldTypes
keys (rule_form.js) to the canonical provider_* names. No engine change: the
backend already expected these. Verified end-to-end via the live UI — author a
Merchant-contains-AMAZON → assign_counterparty rule, save, retroactive apply,
counterparty membership + governing rule + timeline + feed all populate. Edit
mode round-trips correctly (an existing rule's provider_merchant_name condition
now matches the "Merchant" option instead of rendering blank).


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* (beyond-roadmap) fix(rules): surface retroactive Apply for membership/metadata rules (#1905)

The rule detail page gated its retroactive "Apply now" affordance and
explainer copy on ruleHasRetroactiveAction, which only returned true for
set_category/add_tag/remove_tag. But Service.ApplyRuleRetroactively
materializes a wider set: assign_series, assign_counterparty, set_metadata,
remove_metadata, flag, and unflag also back-fill already-synced rows. Only
add_comment is sync-only.

As a result a rule whose action was e.g. assign_counterparty was wrongly
shown as "only adds comments — no retroactive preview available" with the
Apply button hidden, even though the engine would back-fill it. This blocked
the doctrine's "improve the enrichment layer on already-synced data via the
UI" path.

Display/gating-only — no engine change. The Apply button still POSTs to the
existing /-/rules/{id}/apply handler, which already handles every type.

- Align ruleHasRetroactiveAction with the engine's actual materializing set
  (the 9 non-comment action types) and rewrite its doc comment to name
  ApplyRuleRetroactively as the source of truth.
- Update the apply-modal action list + the preview-card doc comment to
  describe what now applies retroactively (assign_series links charges to
  the series, assign_counterparty binds them to the counterparty, metadata,
  flag/unflag), keeping the add_comment line accurate.
- Add a unit test asserting the gate matches the engine set exactly and
  still excludes the comment-only / empty cases.


Claude-Session: https://claude.ai/code/session_012fRHZ1CTjEZ1xFwQmW27jU

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

rules-substrate Rules-as-universal-substrate sprint (base: feat/rules-substrate)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant