Files
portfolio-hub/.proofline/lines/line-0005/evidence/issue-28-approved-discovery.md
T

148 lines
11 KiB
Markdown

# Discovery: 투자 원금 집계의 source-independent 의미 복원
- **상태:** `APPROVED`
- **대상 저장소:** `fourblocks2020/portfolio-hub`
- **대상 API:** `GET /v1/portfolios`, `GET /v1/portfolios/{portfolio_id}/returns/summary`
- **관련 이슈:** #23의 manual-only cash-flow 집계 가정 중 Invest-total field 의미를 이 이슈가 대체한다.
## Problem
`get_return_summaries()`는 현재 투자 원금 집계를 manual ledger row로 제한한다.
```sql
WHERE ledger.source = 'manual'
AND ledger.side IN ('invest', 'withdraw', 'profit')
```
그 결과 `total_invested`와 `net_contributed_capital`은 portfolio ledger 전체의 Invest/net Invest가 아니라 manual source의 cash flow만 반영한다. Broker 등 다른 source가 기록한 유효한 `INVEST`가 제외되므로 사용자에게 표시되는 투자 원금 의미와 실제 집계가 불일치한다.
이 동작은 #23의 manual-cash-flow contract에서 유래했지만, Invest-total field가 모든 유효한 투자 원금을 나타내야 한다면 적합하지 않다.
## Evidence
- Production 관찰 사례인 portfolio `353200`에는 broker-origin `invest`, `buffer_out`, `buy` ledger entry가 있고 manual `INVEST`/`WITHDRAW`가 없다.
- 현재 portfolio API는 모든 `source='broker'` row를 집계에서 제외하므로 `net_contributed_capital: 0.0`을 반환한다.
- 같은 사례에서 broker `invest` amount는 `0`, `buffer_out`은 `800,000`이다. 이 이슈의 source-filter 수정만으로 `buffer_out`을 `INVEST`로 간주하거나 zero-amount event를 교정하지는 않는다.
## Target Semantics
승인된 목표 semantics는 다음과 같다.
```text
total_invested = sum(valid INVEST entries from every source)
total_withdrawn = sum(valid WITHDRAW entries from every source)
net_contributed_capital = total_invested - total_withdrawn
cumulative_profit_withdrawn = sum(valid PROFIT entries from every source)
```
`valid`는 원본 ledger entry가 `REVERSAL.target_ledger_entry_id`로 연결된 matching reversal에 의해 취소되지 않았음을 뜻한다.
`total_invested`는 gross value인 `Σ INVEST`로 유지한다. `WITHDRAW`는 `total_withdrawn`과 `net_contributed_capital`에 반영하고, `PROFIT`는 투자 원금 계산에서 제외하여 `cumulative_profit_withdrawn`으로만 별도 집계한다.
## Scope
- 투자 원금 aggregate에서 `ledger.source = 'manual'` predicate를 제거하고 모든 source의 유효한 `INVEST`·`WITHDRAW`·`PROFIT`을 side별로 집계한다.
- `INVEST`, `WITHDRAW`, `PROFIT` accounting side 의미는 유지한다.
- Original entry와 동일 portfolio에서 `target_ledger_entry_id`가 일치하는 `REVERSAL`이 있으면 reversal 자체의 source와 무관하게 original entry를 제외한다.
- Portfolio list와 single-portfolio returns summary가 동일한 aggregate semantics를 사용하게 한다.
- Non-manual `broker` INVEST와 mixed manual/broker INVEST·WITHDRAW·reversal 회귀를 검증한다.
- API/interface 문서에서 `total_invested`와 `net_contributed_capital`의 manual-only 표현을 승인된 의미로 갱신한다.
- 기존 owner/admin/service visibility와 authorization 경계를 변경하지 않는다.
## Out of Scope
- `buffer_out`, `buy` 등 non-INVEST event를 INVEST로 재분류하는 작업
- Zero-amount broker `invest`를 non-zero event로 정규화하는 ledger-event modelling
- Existing ledger row의 mutation, backfill 또는 rewrite
- Ledger producer의 event emission 변경
- Reversal model 또는 `REVERSAL.target_ledger_entry_id` 의미 변경
- Owner/admin/service visibility 및 authorization 변경
- 이 Discovery 승인만으로 배포·릴리스·production mutation을 수행하는 것
## Risks and Constraints
- Source predicate 제거는 과거 모든 source row에 즉시 적용되는 query semantic correction이므로 기존 응답 값이 증가하거나 달라질 수 있다.
- Side label이 같다는 이유만으로 `buffer_out`·`buy` 같은 다른 accounting event를 합산하면 안 된다.
- Reversal exclusion을 source-independent 집계와 함께 유지하지 않으면 reversed principal이 중복 반영될 수 있다.
- List와 detail summary가 다른 query path를 사용한다면 동일 fixture로 semantic parity를 검증해야 한다.
- `total_invested`의 의미 변경은 API field rename 없이 기존 field contract를 교정하는 선택인지 명시해야 한다.
- Production 사례 `353200`은 source-filter defect와 separate ledger-event modelling gap을 동시에 포함하므로, source-filter 수정만으로 `800,000`이 투자 원금으로 표시된다고 보장해서는 안 된다.
## Open Questions
### OQ-1 — Invest-total 집계의 source 범위
- [x] **A. 모든 source의 유효한 원금 event를 포함하되 `total_invested`는 gross `Σ INVEST`로 유지한다. — 결정**
- `total_invested = Σ valid INVEST`, `total_withdrawn = Σ valid WITHDRAW`, `net_contributed_capital = Σ valid INVEST - Σ valid WITHDRAW`로 계산한다.
- `PROFIT`는 원금 계산에서 제외하고 `cumulative_profit_withdrawn = Σ valid PROFIT`으로만 별도 집계한다.
- `ledger.source = 'manual'` predicate를 제거하고 source와 무관하게 accounting side와 reversal을 적용한다.
- [ ] **B. `manual`과 `broker`만 포함하는 allowlist를 사용한다.**
- 현재 알려진 두 source만 포함하지만 새 source가 추가되면 다시 누락될 수 있다.
- [ ] **C. Manual-only 의미를 유지하고 별도 all-source field를 추가한다.**
- 기존 값은 보존하지만 API schema와 UI 소비 계약이 확장되며 본 이슈의 단순 semantic correction 범위를 넘어간다.
### OQ-2 — `buffer_out` 및 zero-amount broker `invest` 처리
- [x] **A. 이 이슈에서는 현행 accounting side를 그대로 사용하고 별도 ledger-event modelling 이슈로 분리한다. — 결정**
- `buffer_out`을 INVEST로 추론하지 않으며 source-filter defect만 교정한다.
- Portfolio `353200`의 `800,000` 표시 문제는 별도 결정 없이는 해결됐다고 주장하지 않는다.
- [ ] **B. `buffer_out`을 이 집계에서 INVEST로 간주한다.**
- 사용자 표시 값은 달라질 수 있지만 accounting event 의미를 query layer에서 재해석한다.
- [ ] **C. Broker producer가 non-zero `INVEST`를 기록하도록 이 이슈 범위를 확장한다.**
- Producer contract, historical data, deduplication과 migration 결정이 추가로 필요하다.
### OQ-3 — 기존 ledger data 처리 방식
- [x] **A. Query semantic만 교정하고 existing ledger row를 mutation/backfill하지 않는다. — 결정**
- 모든 기존 유효 row가 새 query에 자동 반영되며 원장 provenance를 보존한다.
- [ ] **B. Source 또는 side를 migration으로 backfill한다.**
- Historical ledger mutation 범위, 대상 식별과 rollback/audit 계약이 별도로 필요하다.
- [ ] **C. 별도 aggregate projection/table을 만들어 재계산한다.**
- 지속 materialization, replay, ordering 및 consistency ownership이 추가된다.
### OQ-4 — API compatibility 방식
- [x] **A. 기존 field 이름을 유지하고 의미를 all-source Invest-total로 교정한다. — 결정**
- `total_invested`, `total_withdrawn`, `net_contributed_capital`, `cumulative_profit_withdrawn`의 문서를 함께 갱신한다.
- [ ] **B. 새 field를 추가하고 기존 field는 manual-only로 유지한다.**
- 하위 호환은 보존하지만 response schema와 consumer 선택이 복잡해진다.
- [ ] **C. Versioned endpoint에서만 새 의미를 제공한다.**
- 가장 강한 contract 분리를 제공하지만 새 API version과 migration 계획이 필요하다.
### OQ-5 — 배포 후 검증 경계
- [x] **A. Repository tests에 더해 승인된 read-only production query/API pre/post check를 별도 배포 절차에 포함한다. — 결정**
- Source-independent 합계, reversal 제외와 list/detail parity를 확인하되 production row는 변경하지 않는다.
- `353200`은 source-filter 제거 여부를 확인하는 관찰 사례로만 사용하고 `buffer_out=INVEST`를 기대하지 않는다.
- [ ] **B. Repository tests만 수행하고 production 검증은 하지 않는다.**
- 구현 검증은 단순하지만 실제 source 분포와 응답 변화 확인이 남는다.
- [ ] **C. Synthetic staging data에서만 post-deploy 검증한다.**
- Production read를 피하지만 실제 historical source/reversal 조합을 완전히 대표하지 못할 수 있다.
## Acceptance Criteria
OQ가 확정되면 아래 criterion을 선택 결과에 맞춰 canonical requirement로 전환한다.
- [ ] Investment aggregate에서 original ledger row의 source predicate가 제거되고 모든 source의 유효 row가 side별로 집계된다.
- [ ] `total_invested = Σ valid INVEST`, `total_withdrawn = Σ valid WITHDRAW`, `net_contributed_capital = total_invested - total_withdrawn`이며 `PROFIT`는 원금 계산에서 제외되고 `cumulative_profit_withdrawn`으로만 집계된다.
- [ ] 동일 portfolio에서 matching `target_ledger_entry_id`를 가진 `REVERSAL`이 존재하면 reversal source와 무관하게 original entry가 제외된다.
- [ ] `GET /v1/portfolios`와 single-portfolio returns summary가 동일한 aggregate semantics를 사용한다.
- [ ] Non-manual `broker` INVEST의 non-zero amount 회귀가 검증된다.
- [ ] Mixed manual/broker INVEST·WITHDRAW, reversal과 source-independent total이 검증된다.
- [ ] API/interface 문서가 승인된 field 의미를 정확히 설명한다.
- [ ] Existing owner/admin/service visibility 경계가 유지된다.
- [ ] Out-of-scope인 `buffer_out` 재분류나 historical ledger mutation이 암묵적으로 포함되지 않는다.
## Approval Record
- **승인 상태:** `APPROVED`
- **승인 결정:** OQ-1-A, OQ-2-A, OQ-3-A, OQ-4-A, OQ-5-A
- **OQ-1:** 모든 source의 유효한 row를 side별로 집계한다. `total_invested`는 gross `Σ INVEST`, `net_contributed_capital`은 `Σ INVEST - Σ WITHDRAW`이며 `PROFIT`는 원금 계산에서 제외한다.
- **OQ-2:** `buffer_out` 재분류와 zero-amount broker `invest` 교정은 별도 ledger-event modelling으로 분리한다.
- **OQ-3:** Query semantic만 교정하고 existing ledger row를 mutation/backfill하지 않는다.
- **OQ-4:** 기존 field 이름을 유지하고 all-source Invest-total 의미로 문서와 contract를 교정한다.
- **OQ-5:** Repository 검증에 더해 별도 배포 절차에서 승인된 read-only production query/API pre/post check를 수행한다. Production row mutation 권한은 포함하지 않는다.
- **Reversal 적용:** all-source original 집계에서 matching reversal도 source와 무관하게 적용한다. `reversal.source = 'manual'` predicate를 보존하면 broker original reversal이 누락되므로 허용하지 않는다.
- **추가 결정 검토:** 현재 구현·data-flow 검토에서 승인을 중지할 추가 material OQ는 발견되지 않았다.
- **권한 경계:** 이 승인은 이 Discovery와 bounded implementation/verification scope를 승인한다. Push, PR, release, deployment, production mutation 또는 issue closure 권한을 자동으로 부여하지 않는다.