Skip to content

feat(finance): exclude MB products from generic calc engine and enforce MB batch isolation - #166

Merged
ilramdhan merged 2 commits into
mutugading:mainfrom
ilramdhan:fix/mb-costing-11-aug
Aug 12, 2026
Merged

feat(finance): exclude MB products from generic calc engine and enforce MB batch isolation#166
ilramdhan merged 2 commits into
mutugading:mainfrom
ilramdhan:fix/mb-costing-11-aug

Conversation

@ilramdhan

Copy link
Copy Markdown
Member

Description

This PR enforces that MB products are calculated exclusively by MB Batch and approved via push-to-head. Previously, generic calculation jobs could also calculate MB products, resulting in multiple writers for the same (product, period, calc_type) tuple and causing cst_mb_cost to point to superseded source rows with stale values.

To resolve this and prevent state erosion, changes were made across multiple layers:

  1. Exclusion from Generic Calc Engine: Orchestrator filters MB out of seed sets (ALL, FILTERED, SINGLE_PRODUCT) and trigger handlers return a synchronous 400 Bad Request prior to job creation.
  2. Write-Site Persistence Safety: ProcessChunk marks MB dependency nodes as BLOCKED with reason MB_OWNED_BY_MB_BATCH instead of persisting superseded rows.
  3. Stale MB Push Visibility: Flags heads requiring re-push in mbpush preview (needs_repush_count).
  4. Manual Verification & Approval Guards: Prevents VerifyCost and ApproveCost from accepting MB-owned cost rows.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 💥 Breaking change (fix or feature that changes existing API)
  • ♻️ Refactor (code change without new feature or bug fix)
  • 📚 Documentation update
  • 🧪 Test update
  • 🔧 Chore (dependencies, config, etc.)

Service(s) Affected

  • Finance Service
  • IAM Service
  • Shared Proto (gen/)
  • Root/Common

Changes Made

  • Calc Engine Orchestration & Trigger:
    • Filtered MB products out of seed sets (allActiveProducts, productsByType) in finance-cost-orchestrator.
    • Updated resolveInitialSet to reject SINGLE_PRODUCT on MB products and FILTERED jobs targeting MB type.
    • Wired WithMBGuard (MBTypeChecker) in finance trigger handler to return synchronous 400 Bad Request before creating cal_job rows.
    • Left loadProductRMEdges unfiltered so parent products consuming MB still resolve and price correctly.
  • Write-Site Safety:
    • Updated ProcessChunk to mark MB dependency nodes as BLOCKED with reason MB_OWNED_BY_MB_BATCH instead of persisting superseded rows.
  • MB Push Stale Detection:
    • Added needs_repush_count tracking in mbpush preview to inform operators when newer calculated results are pending re-push.
  • Cost Calculation Guards:
    • Blocked VerifyCost and ApproveCost from accepting MB-owned cost rows (returns 400 Bad Request).

Related Issues

Fixes #
Related to #

API Changes (if applicable)

Proto Changes

Breaking Changes

  • None. Attempting to run generic calc engine jobs targeting MB products (SINGLE_PRODUCT or FILTERED type 81) now returns a synchronous 400 Bad Request instead of failing asynchronously or generating invalid cost rows.

Testing Performed

Unit Tests

  • New unit tests added
  • Existing unit tests pass
  • Coverage maintained/improved

Integration Tests

  • New integration tests added
  • Existing integration tests pass

Manual Testing

# Executed unit & race tests
go test -race ./...

# Verified against dev & live data:
# - Scope ALL seeds: 9,457 products vs 13,625 before (difference of exactly 4,168 MB active-route count); zero MB rows written.
# - Forced MB paths (FILTERED type 81, SINGLE_PRODUCT on MB product) return 400.
# - Verified PRODUCT edges: all 350 edges to MB come from another MB.

Lint & Build

  • golangci-lint run ./... passes
  • go build ./... succeeds
  • go test -race ./... passes

Database (if applicable)

  • Migration added
  • Migration tested (up and down)
  • No breaking schema changes (or documented)

Documentation

  • README.md updated (if needed)
  • RULES.md updated (if needed)
  • Proto comments updated
  • OpenAPI regenerated

Rollback Plan

Revert code changes in finance and finance-cost-orchestrator. All guards key strictly on cpt_type_code = 'MB' and degrade to no-ops when unwired.

Screenshots/Logs (if applicable)


Pre-merge Checklist

  • I have read and followed RULES.md
  • I have read and followed CONTRIBUTING.md
  • Clean Architecture principles followed
  • All errors are properly handled
  • Context is passed appropriately
  • Structured logging is used
  • No hardcoded secrets
  • PR description is complete and clear
  • CI checks are passing

Reviewer Notes

  • Dependencies intact: loadProductRMEdges remains unfiltered so parent products consuming MB items continue to resolve and calculate costs correctly using committed state via LoadUpstreamCosts.
  • Non-MB unaffected: All guards and checks key strictly on cpt_type_code = 'MB'.
  • Test coverage: Passed TestDagBuilder_All_KeepsEveryNonMBProduct, TestProcessChunk_AllNonMB_EveryProductPersisted, and non-MB pass-through guard test cases. golangci-lint v2.3.0 reported 0 issues.

ilramdhan and others added 2 commits August 12, 2026 13:34
MB costs are owned by MB Batch (MB Push to Head). Letting a calc job also
compute them gave two writers for the same (product, period, calc_type):
a calc job run after a push superseded the APPROVED cst_product_cost row,
leaving cst_mb_cost pointing at a SUPERSEDED source with a stale value.

Exclusion is layered:

- Orchestrator (authoritative): allActiveProducts and productsByType filter
  MB out of the seed set; resolveInitialSet rejects SINGLE_PRODUCT on an MB
  product and FILTERED by the MB type. Under ALL the drop is silent — MB is
  simply not part of what the calc engine owns. loadProductRMEdges is
  deliberately left unfiltered: the rule constrains what a job targets, not
  how dependencies resolve, so yarn products consuming MB still price
  correctly.
- Finance trigger handler: an optional MBTypeChecker guard turns what would
  be an async FAILED job into a synchronous 400 before any cal_job row is
  written. Wired via WithMBGuard so existing call sites are unchanged.

Verified against dev: scope ALL seeds 9457 products vs 13625 before, a
difference of exactly 4168 — the MB active-route count — and writes zero MB
rows. Forced MB paths (FILTERED type 81, SINGLE_PRODUCT on an MB product)
both return 400. Non-MB types are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MB products are computed exclusively by MB Batch and approved via
push-to-head. Three gaps let that invariant erode silently.

1. needs-repush visibility (mbpush)
   Re-running MB Batch after a push supersedes the approved source row
   and inserts a fresh CALCULATED one, leaving cst_mb_cost pointing at a
   superseded row. The value is not wrong -- it is the last approved one
   -- but nothing told the operator a newer result was waiting. Preview
   now flags such heads and reports needs_repush_count. They remain
   pushable; re-pushing is the remedy. Classification is read-only: no
   bucket moves, no auto-push, no write-path change.

2. manual verify/approve accepted MB rows (costcalc)
   VerifyCost/ApproveCost took any cost_id. Flipping an MB row to
   VERIFIED made push-to-head skip it, since push requires status
   exactly CALCULATED. Both now reject MB-owned rows with a 400.
   MarkApprovedFromCalculatedTx -- the legitimate push path -- is a
   distinct method and stays unblocked.

3. calc engine could persist an MB dependency node (costcalc)
   loadProductRMEdges is deliberately unfiltered so a parent consuming
   an MB still resolves it; that left ProcessChunk able to supersede an
   MB row pulled in as a dependency. Closed at the write site: such
   products are marked BLOCKED with reason MB_OWNED_BY_MB_BATCH instead
   of persisted. Parents read committed state via LoadUpstreamCosts, so
   skipping the persist changes no parent's number.

Non-MB products are unaffected: every guard keys on a single
cpt_type_code = 'MB' equality and degrades to a no-op when unwired.
Verified against live data -- all 350 PRODUCT edges to an MB come from
another MB, zero from non-MB.

Tests: finance and finance-cost-orchestrator go test -race pass,
including TestDagBuilder_All_KeepsEveryNonMBProduct,
TestProcessChunk_AllNonMB_EveryProductPersisted, and the non-MB
pass-through cases for both guards. golangci-lint v2.3.0: 0 issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ilramdhan ilramdhan added this to the Costing Release Milestone milestone Aug 12, 2026
@ilramdhan ilramdhan self-assigned this Aug 12, 2026
Copilot AI lite review requested due to automatic review settings August 12, 2026 08:02
@ilramdhan ilramdhan added the bug Something isn't working label Aug 12, 2026
@ilramdhan ilramdhan added documentation Improvements or additions to documentation enhancement New feature or request fix feat labels Aug 12, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@ilramdhan
ilramdhan merged commit 9eeef74 into mutugading:main Aug 12, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working documentation Improvements or additions to documentation enhancement New feature or request feat fix

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants