Repository navigation
Description
🤖 Design-architect pass 27 (2026-10-31) — design issue. Full design doc: files/designs/code-charges-pipeline-contract.md in the keep-the-four-repos-healthy goal workspace. Audit at origin/main 8bae502f, all code references verified on current main this pass.
Summary
The charges pipeline built for #1057 has a pure core whose header states its philosophy — charges-match.ts: "this module only decides what a charge IS, never what was already shown" — but the pipeline never stated the three rules that philosophy needs: what counts as a charge (admission), when two observations are the same charge (identity), and when an unmatched charge counts as shown (delivery). Nine open filings are those three unstated rules, one per pipeline stage:
- Admission (
scripts/produce-charge-candidates.ts):isNotChargeSubject(~L148–157) has no declined/failed pattern and nothing requires payment evidence, so declined alerts and amount-due-only invoices become candidates (fix(aggregator): charges producer turns declined purchase alerts and outstanding invoices into charge candidates (PR #2149) #3120); the body amount fallback (~L208) matchestotalinside Subtotal and first-match wins, recording $20.00 for a $21.60 charge (fix(aggregator): charges producer body fallback matches 'total' inside 'Subtotal', recording the subtotal as the charge (PR #2149) #3121) — the wrong amount then defeats dedupe. - Identity (
charges-match.ts,charges-collect.ts):matchChargesdedupes on (amount, normalized merchant, ±3 days) without requiring different sources despite the header/feat(usage-hub): v3 charges auto-matching — Plaid + Gmail alerts to services #1057 scoping dedupe to across-sources, and empty-normalized merchants merge across merchants (aggregator(charges-match): dedupe collapses distinct charges — same-source repeats within 3 days merge into one row, and empty-normalized merchants merge across merchants #2130);sameCharge(charges-collect.ts:74) merges hand-fed rows on squashed substring containment (Metavanishes againstMETAMUCIL,AIagainstRAILWAY, aggregator(charges-collect): sameCharge drops hand-fed rows on substring containment — a Meta row vanishes against METAMUCIL, an AI row against RAILWAY #3049) and never compares payment methods, so conflicting card masks merge away (fix(aggregator): charges merge drops hand-fed rows on merchant+amount alone, ignoring conflicting card masks (PR #2149) #3123). - Classification:
isSaasLooking/matchServicematch substrings with no token boundary —METAMUCILandMETAL ROOFINGclassify as Meta (aggregator(charges-match): service and SaaS-marker matching is substring-only — METAMUCIL and METAL ROOFING classify as Meta charges #3048) — and the collector's catalog is built only fromextractSaasServices()(collect-fleet-feed.ts:435–437), so Anthropic/OpenAI charges can never match and always flood the attention strip (fix(aggregator): charge-matching catalog omits the AI quota providers, so Anthropic/OpenAI charges always surface as unrecognized (PR #2149) #3119). - Delivery: surfaced keys are persisted at collection time (~L451), before the feed is written (~L646) and before the out-of-collector laptop transfer, so one failed delivery permanently suppresses flags (fix(aggregator): charges surfaced keys are persisted before feed delivery, so a failed transfer permanently suppresses the flags (PR #2149) #3122); the live source consumes charges only inside the
saasgate (live.ts:869), so a quota-view failure hides all auto-matched charges (fix(aggregator): live source consumes auto-matched charges only inside the saas gate, so a quota-view failure hides them all (PR #2149) #3124).
Pass 26 of this series assessed #3048/#3049 as a candidate to revisit "if a third instance files" — six more filed today. The condition is met.
The rules (recorded here; full text, alternatives and risks in the design doc):
A charge is evidence. (1) Admission requires payment evidence — declined/failed and merely-due amounts produce no candidate; the amount is the amount paid (explicit paid/charged labels outrank a bare total; a bare total must be a standalone label, never a substring of "Subtotal"). (2) Identity is one rule everywhere — same charge only when provenance differs (cross-source dedupe; hand-fed vs auto merge), amounts equal, dates within the existing 3-day window, merchant identity holds at token boundaries (exact normalized equality for dedupe; token-boundary containment for the hand-fed merge, preserving the documented
METAPAY/ "Meta PAYG spill" pair), an empty normalized merchant never matches, and conflicting known payment methods veto the merge. (3) Surfaced means delivered, and consumption is independent — surfaced keys persist only for charges in a successfully published feed, and the live source consumes the charges sections on their own validity, never gated on an unrelated section. Classification corollary: names/aliases/markers match at token boundaries only, and the catalog is SaaS services plus quota pools and aliases from their existing extractors.
Boundaries: input-validity neighbours are NOT adopted — #2194 (impossible dates), #2445/#2439 (feed charges shape), #2448 (batch-discarding producer throw) are governed by #2576's fail-closed ingest contract and #3068's shape contract; this design governs identity, evidence and lifecycle of candidates that pass them. #3005 owns count/size measurement grammar; the producer's paid-amount labels are admission evidence, owned here. Producer scheduling (#2205) is operational and untouched.
Phased breakdown (phases adopt the origins — no parallels)
- Phase 1 — Identity. aggregator(charges-match): dedupe collapses distinct charges — same-source repeats within 3 days merge into one row, and empty-normalized merchants merge across merchants #2130 + aggregator(charges-collect): sameCharge drops hand-fed rows on substring containment — a Meta row vanishes against METAMUCIL, an AI row against RAILWAY #3049 + fix(aggregator): charges merge drops hand-fed rows on merchant+amount alone, ignoring conflicting card masks (PR #2149) #3123: one shared identity rule across
matchChargesandsameCharge, one probe matrix (same-source repeats, empty-normalized pairs, Meta/METAMUCIL, AI/RAILWAY, the METAPAY control, conflicting/agreeing/absent methods). - Phase 2 — Admission & classification. fix(aggregator): charges producer turns declined purchase alerts and outstanding invoices into charge candidates (PR #2149) #3120 + fix(aggregator): charges producer body fallback matches 'total' inside 'Subtotal', recording the subtotal as the charge (PR #2149) #3121 + aggregator(charges-match): service and SaaS-marker matching is substring-only — METAMUCIL and METAL ROOFING classify as Meta charges #3048 + fix(aggregator): charge-matching catalog omits the AI quota providers, so Anthropic/OpenAI charges always surface as unrecognized (PR #2149) #3119. Independent of Phase 1.
- Phase 3 — Delivery. fix(aggregator): charges surfaced keys are persisted before feed delivery, so a failed transfer permanently suppresses the flags (PR #2149) #3122 + fix(aggregator): live source consumes auto-matched charges only inside the saas gate, so a quota-view failure hides them all (PR #2149) #3124, plus the closing truth-up on landing: the three rules written into the
charges-match.ts/charges-collect.ts/ producer headers and evidence posted on feat(usage-hub): v3 charges auto-matching — Plaid + Gmail alerts to services #1057. No separate audit issue — the surface inventory is closed (verified by repo-wide grep this pass: producer + collector scripts, charges-match, charges-collect, feed/live consumption only).
Open questions stay in the design doc (unmatchedKey gaining the method; #3122's confirm-after-publish vs pending-keys mechanism — either satisfies the rule, Phase 3 picks against the as-built publish flow).
Acceptance
- Phase 1 origins (aggregator(charges-match): dedupe collapses distinct charges — same-source repeats within 3 days merge into one row, and empty-normalized merchants merge across merchants #2130, aggregator(charges-collect): sameCharge drops hand-fed rows on substring containment — a Meta row vanishes against METAMUCIL, an AI row against RAILWAY #3049, fix(aggregator): charges merge drops hand-fed rows on merchant+amount alone, ignoring conflicting card masks (PR #2149) #3123) closed under the recorded identity rule, probe matrix as tests
- Phase 2 origins (fix(aggregator): charges producer turns declined purchase alerts and outstanding invoices into charge candidates (PR #2149) #3120, fix(aggregator): charges producer body fallback matches 'total' inside 'Subtotal', recording the subtotal as the charge (PR #2149) #3121, aggregator(charges-match): service and SaaS-marker matching is substring-only — METAMUCIL and METAL ROOFING classify as Meta charges #3048, fix(aggregator): charge-matching catalog omits the AI quota providers, so Anthropic/OpenAI charges always surface as unrecognized (PR #2149) #3119) closed under the admission + classification rules
- Phase 3 origins (fix(aggregator): charges surfaced keys are persisted before feed delivery, so a failed transfer permanently suppresses the flags (PR #2149) #3122, fix(aggregator): live source consumes auto-matched charges only inside the saas gate, so a quota-view failure hides them all (PR #2149) #3124) closed; rules written into the module headers; evidence posted on feat(usage-hub): v3 charges auto-matching — Plaid + Gmail alerts to services #1057
- Register row for this design in
files/potential-projects.mdkept truthful as phases land
Acceptance criteria
- The design doc (
files/designs/code-charges-pipeline-contract.mdin the goal workspace) states the three contract rules — admission, identity, delivery — for the charges pipeline. - Each of the nine open filings named in the issue is linked to the contract section that governs it.
- Implementation of the contract is tracked by linked implementation issues (this design issue does not itself change pipeline code).
Activity
dezren39 commentedon Oct 31, 2026
🤖 Design-architect pass 27 (2026-10-31) — agent note: this design was originated this pass from an audit of the charges pipeline (#1057) against origin/main 8bae502. Its phases ADOPT the nine origins as sub-issues (verified in this issue's sub_issues list: 2130, 3048, 3049, 3119, 3120, 3121, 3122, 3123, 3124) — no parallels filed. Phase 1 = #2130 + #3049 + #3123 (identity), Phase 2 = #3120 + #3121 + #3048 + #3119 (admission & classification), Phase 3 = #3122 + #3124 (delivery + truth-up on #1057). Design doc: files/designs/code-charges-pipeline-contract.md in the keep-the-four-repos-healthy goal workspace; register entry added (status: proposed). NO user/review — every origin carries explicit acceptance with verified evidence and the judgment calls (token boundaries, method veto, surfaced-means-delivered) are the architect's recorded decisions in the doc. Pass 26 assessed #3048/#3049 as "revisit if a third instance files" — #3119–#3124 are that growth. Note on #3123: it carried no queue/* label at audit time, but its triage task 4543 in fact already existed and is DONE — PR #3128 (fix for #3123) is OPEN; no new packet was created this pass (the architect's enqueue attempt returned the existing dedupe row).
29 remaining items
Merge pull request #5131 from developing-today/queue/8692-3161-charge…
dezren39 commentedon Nov 3, 2026
🤖 Agent note · done
pm-stack/aggregator/scripts/produce-charge-candidates.tsheader now states a candidate exists only where money demonstrably moved, amount = amount paid; (2) Identity —pm-stack/aggregator/src/charges-match.tsheader states the one identity rule in its two as-built forms (cross-source dedupe inmatchCharges; hand-fed merge incharges-collect.ts'ssameCharge, whose new JSDoc states the token-boundary rule and the card-mask veto); (3) Delivery — surfaced-means-delivered stated in bothcharges-match.tsandcharges-collect.tsheaders. Codex gate: the one P2 finding (identity checks overstated for the cross-source matcher) was dispositioned on its thread and fixed in head 2b82ab4; the final Codex review at that head reports no findings and no unresolved findings. No CI check beyond CodeRabbit (skipping) started on the PR — per merge authority, PRs do not wait for CI and this change is documentation-only.