feat(rules-ui): metadata conditions + actions in the rule form editor#1876
Merged
Conversation
af4f33b to
c25ac58
Compare
1ade324 to
63f3301
Compare
Owner
Author
|
Pushed a fix from code review: a metadata |
Adds first-class metadata support to the visual rule builder, so humans
can author the metadata.<key> conditions and set_metadata /
remove_metadata actions shipped in the backend PR without dropping to
the JSON editor.
Conditions: a new "Metadata field" option reveals a key input and a
dedicated operator select (eq/neq/contains/not_contains/matches/in/
gt/gte/lt/lte/exists/not_exists). The value input hides for the
presence ops (exists / does not exist) and comma-splits for `in`. The
dotted field metadata.<key> is assembled at submit time and split back
out when loading a rule for edit.
Actions: "Set metadata" (key + a text/number/true-false type selector +
value, coerced to the chosen JSON type on submit) and "Remove metadata"
(key only). Both repeat freely; a same-key set+remove raises the
existing combination warning.
Special-cased because the builder's {field,op,value} condition and
{field,value} action models have no slot for a dynamic key or a typed
value — metadata rows carry extra key / valueType fields.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
63f3301 to
c69bdfa
Compare
canalesb93
added a commit
that referenced
this pull request
Jun 20, 2026
…(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>
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>
canalesb93
added a commit
that referenced
this pull request
Jun 24, 2026
Resolves conflicts from main's rules-as-substrate merge (#1906) plus #1871/#1874/#1876 against this PR (retire MCP resources, reads-as-tools, get_reference for docs, write-tool consolidation). Resolution decisions: - Series subsystem: adopt main's model wholesale. #1906 removed rekey_series / split_series / review_series / explain_series_candidates (handlers + service methods gone) and kept add_series_tag / remove_series_tag as separate tools with a name/type-only update_series. Backed out this PR's series-tag fold into update_series accordingly and updated .claude/rules/mcp.md to match. - create_transaction_rule: keep this PR's `rules` array shape (folds batch_create_rules) but carry main's enriched per-rule DSL (metadata/series/flag actions, approx/between ops, date-part fields). Updated the reviewer response-shape integration test to read the batch envelope. - MCP resources stay retired: dropped main's re-added registerResources (incl. the new counterparty resource template) and the resources.go modifications; everything remains a tool. - Dead prompt files deleted by #1871 (strategy-anomaly-detection / -initial-setup / -quick-review) accepted as deletions. - Docs/prompts: take main for the series model + rules-substrate semantics (category_id direct, no category_override); keep this PR's tool surface (get_reference, create_transaction_rule rules array). Dropped a stray </content> artifact from prompts/mcp/rule-dsl.md. Verified: go build ./..., go vet ./..., go test ./... (unit), and integration for internal/mcp + internal/service + internal/api all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y7qg4TN9ZPcHsXR714UrjG
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #1874 (backend). The visual rule builder gains first-class metadata support so humans can author the new
metadata.<key>conditions andset_metadata/remove_metadataactions in the form — no dropping to the JSON editor (and no longer API/MCP-only).Conditions
equalsnot equalscontainsnot containsregexin list> ≥ < ≤existsdoes not exist.exists/does not exist) and comma-splits forin list.metadata.<key>field is assembled at submit time and split back into field + key when loading a rule for edit.Actions
text/number/true/falsetype selector + value. The value is coerced to the chosen JSON type on submit ("2026"→2026,"true"→true), so the stored blob is correctly typed.Why a special case
The builder's
{field, op, value}condition and{field, value}action models have no slot for a dynamic key or a typed value — metadata rows carry extrakey/valueTypefields, with dedicated serialize/parse paths.Validation (real browser)
submitRule():metadata.tax_deductible eq "true",metadata.project_code exists(no value),metadata.tags_csv in ["a","b","c"]; actions with typed values (string / number2026/ booleantrue) +remove_metadata./rules/{id}/edit→ state re-populates correctly (field=metadata+ key + op + value; actionvalueTyperecovered as number/boolean/text) → delete. Backend accepted the POST (201) and DELETE (204).go build,go vet, and the pages/scripts-lint tests pass.🤖 Generated with Claude Code