Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -424,3 +424,109 @@ criteria remain approval-gated").
`test/integration/RegD506cElements.t.sol`
- `test/unit/compliance/Recipes.t.sol`, `test/unit/compliance/Engine.t.sol`
- `docs/ROADMAP.md`

## D010 — Manifest lifecycle를 validated state machine로 만들고 engine을 default-deny로 닫는다

Date: 2026-07-04

### Context

MVP-v2 §5는 asset Manifest에 explicit operator approval step을 포함한 full
lifecycle을 요구한다. 기존 `TokenPolicyRegistry`는 임의 상태를 덮어쓰는 raw
`setStatus`만 제공했고(승인 단계 없음), `ComplianceEngine.evaluate`는 SUSPENDED와
UNKNOWN만 명시적으로 reject한 뒤 나머지를 `_evaluateActivePair`로 흘려보냈다. 이
구조에 PROPOSED/RETIRED 상태를 추가하면 두 개의 fail-open 구멍이 생긴다: (a)
PROPOSED/RETIRED 자산이 UNREGULATED와 pair되면 element가 0개 수집되어 `allowed =
true`, (b) PROPOSED/RETIRED가 ACTIVE와 pair되면 ACTIVE side만 검증되고 거래가
통과한다.

### Decision

**1. Lifecycle state machine (states/transitions).** Manifest는 아래 transition만
허용하는 명시적 state machine을 따른다. 그 외 모든 (state, action)은 dedicated
custom error `Errors.InvalidManifestTransition`으로 revert한다.

| From | Action | To | Gate |
| --- | --- | --- | --- |
| UNKNOWN 또는 RETIRED | `registerManifest` | PROPOSED | onlyOwner |
| PROPOSED | `approveManifest` | ACTIVE | onlyOperator (issuance recipe 필수) |
| ACTIVE | `suspendManifest(reasonCode)` | SUSPENDED | onlyOperator |
| SUSPENDED | `resumeManifest` | ACTIVE | onlyOperator |
| ACTIVE 또는 SUSPENDED | `retireManifest(reasonCode)` | RETIRED (terminal) | onlyOperator |
| UNKNOWN | `setUnregulated` | UNREGULATED | onlyOwner |
| UNREGULATED | `clearUnregulated` | UNKNOWN | onlyOwner |

`registerManifest`는 caller가 넣은 `m.status`를 무시하고 항상 PROPOSED로 착지하며
`declaredBy = msg.sender`를 기록한다. `approveManifest`는 `approvedBy = msg.sender`를
기록하고 `issuanceRecipeId == 0`이면 registry-level completeness floor로 revert한다.
RETIRED는 terminal이며 재발행은 RETIRED→register→approve 경로로만 가능하다. gating
model: owner가 자산을 classify/declare(register/setUnregulated/clearUnregulated)하고,
operator가 기존 manifest의 lifecycle(approve/suspend/resume/retire)을 구동한다.

**2. Enum-append storage rationale.** `PolicyStatus`에 PROPOSED(4)와 RETIRED(5)를
SUSPENDED(3) 뒤에 APPEND한다. 기존 numeric value(UNKNOWN=0, UNREGULATED=1,
ACTIVE=2, SUSPENDED=3)는 storage layout과 enum↔uint cast에서 load-bearing이므로
절대 재정렬하지 않는다. 그 결과 enum의 numeric order는 lifecycle graph의 semantic
order와 일치하지 않는다(type 파일에 명시적 주석). UNKNOWN=0을 유지하는 것은 absent
manifest가 fail-closed default가 되도록 하는 핵심이다.

**3. `setStatus` 제거.** raw `setStatus` overwrite는 제거한다(pre-production
breaking change; 모든 caller/test/factory를 이 feature에서 갱신). validation 없는
상태 덮어쓰기는 approval step과 transition guard를 우회하므로 유지할 수 없다.

**4. Engine default-deny 재구조화 (positive allowlist).** `evaluate`의 enumerated
bad-status 검사(SUSPENDED/UNKNOWN reject)를 side별 positive allowlist로 교체한다:
각 side는 UNREGULATED 또는 ACTIVE여야 하며, 그 외(UNKNOWN/SUSPENDED/PROPOSED/
RETIRED와 미래에 추가될 모든 member)는 fail-closed한다. bad state를 열거하는 대신
permitted state를 열거하는 이유: reject list에 새 상태를 추가하는 것을 잊으면
그대로 fail-open이 되지만, allowlist는 새로 추가된 상태를 default로 거부하므로
"append-only 안전"하다. both-UNREGULATED fast-path pass-through는 유지한다.
`commit`은 router가 `evaluate`를 먼저 호출하고 reject 시 revert하므로 rejected
pair에서 도달 불가능하다 — mirror guard가 필요 없고, in-file 주석으로 이유를
남긴다(재구조화하지 않음).

**5. `clearUnregulated` correction path.** 잘못 UNREGULATED로 tag된 token을
UNKNOWN(clean slate)으로 되돌려 이후 regulated manifest로 register할 수 있게 한다.
onlyOwner이며 UNREGULATED가 아닌 상태에서 호출하면 `InvalidManifestTransition`.
setUnregulated와 대칭인 governance classification 호출이다.

**6. `declaredBy = msg.sender` semantics와 factory consequence.** register는
`declaredBy = msg.sender`, approve는 `approvedBy = msg.sender`를 기록한다.
`CornerStoreFactory.registerRWAToken`은 register→approve를 한 governed call에서
연속 실행하며 factory가 registry의 owner이자 approving operator다(배포 시
governance가 registry ownership을 factory로 이전). 그 결과 이 경로로 onboarding된
token의 `declaredBy`/`approvedBy`는 factory 주소이며, attribution은 factory
경계에서 멈춘다.

### Alternatives Considered

- **Engine에서 reject list(bad state 열거) 유지**: PolicyStatus에 member를 추가할
때마다 reject list 갱신을 잊으면 fail-open이 되므로 제외. positive allowlist는
구조적으로 fail-closed default를 보장한다.
- **enum member를 semantic order로 재정렬**: storage layout과 enum↔uint cast가
깨지므로 제외. append-only만 허용한다.
- **`setStatus`를 deprecated로 유지**: validation 우회 경로가 남으므로 제외
(pre-production이라 breaking removal 비용이 낮다).
- **PROPOSED/RETIRED에 UNKNOWN/SUSPENDED와 다른 별도 reason-code family 부여**:
기존 `_rejectPolicy`가 이미 `uint32(status)`를 reason code에 인코딩하므로 side의
실제 status를 그대로 넘기면 distinct code가 자동으로 나온다 — 추가 scheme 불필요.

### Consequences

- 모든 onboarding은 propose→approve 2단계를 거친다(factory는 한 call에서 collapse).
- ACTIVE manifest의 issuance recipe re-point는 retire→register→approve 경로를
써야 한다(ACTIVE 위 re-register는 illegal).
- engine은 미래에 PolicyStatus member가 추가되어도 default로 fail-closed한다.
- fixture는 register→approve(및 UNREGULATED는 setUnregulated)로 통일된다.

### Related Files

- `src/types/ComplianceTypes.sol` (enum append + 주석)
- `src/registry/TokenPolicyRegistry.sol`,
`src/interfaces/compliance/ITokenPolicyRegistry.sol`
- `src/compliance/ComplianceEngine.sol` (evaluate default-deny gate)
- `src/factory/CornerStoreFactory.sol` (register+approve, natspec)
- `test/unit/compliance/Engine.t.sol`,
`test/unit/registry/TokenPolicyRegistry.t.sol`
- `test/integration/EmergencyPause.t.sol`, `test/integration/IntegrationBase.sol`,
`test/integration/Surveillance.t.sol`
43 changes: 43 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,43 @@ passing
활성화 조건 결정.
- Non-goals: production legal 활성화, direction-aware element application.

## CMP-002 — Manifest Lifecycle & Operator Approval Flow

### Behavior

- Manifest는 validated state machine을 따른다: UNKNOWN --register--> PROPOSED
--approve--> ACTIVE, ACTIVE <--resume--/--suspend--> SUSPENDED,
{ACTIVE, SUSPENDED} --retire--> RETIRED(terminal), UNKNOWN --setUnregulated-->
UNREGULATED --clearUnregulated--> UNKNOWN. 그 외 모든 transition은
`InvalidManifestTransition`으로 revert한다.
- `registerManifest`는 caller-supplied status를 무시하고 항상 PROPOSED로 착지하며
`declaredBy = msg.sender`를 기록한다. `approveManifest`는 `approvedBy`를 기록하고
issuance recipe가 비어 있으면 revert한다. raw `setStatus`는 제거되었다.
- owner가 자산을 classify(register/setUnregulated/clearUnregulated)하고 operator가
기존 manifest의 lifecycle(approve/suspend/resume/retire)을 구동한다.
- `ComplianceEngine.evaluate`는 side별 positive allowlist(UNREGULATED 또는 ACTIVE만
허용)로 default-deny한다. UNKNOWN/SUSPENDED/PROPOSED/RETIRED와 미래 member는
fail-closed하며, both-UNREGULATED fast-path는 유지된다.
- `CornerStoreFactory.registerRWAToken`은 register→approve를 한 governed call에서
실행하고 token은 ACTIVE로 끝난다(`declaredBy`/`approvedBy` = factory).

### Verification

- `forge test --offline`(전체 227/227, pre-task 212 + 신규 15).
- Engine unit test(default-deny fail-closed): `test_proposed_against_unregulated_fails_closed`,
`test_retired_against_unregulated_fails_closed`, `test_proposed_against_active_fails_closed`,
`test_retired_against_active_fails_closed_both_orderings`(양방향 ordering).
- Registry unit test(clearUnregulated + onlyOwner-vs-onlyOperator):
`test_clearUnregulated_from_UNREGULATED`, `test_clearUnregulated_then_register_ok`,
`test_clearUnregulated_reverts_when_UNKNOWN`, `test_clearUnregulated_reverts_when_PROPOSED`,
`test_clearUnregulated_reverts_when_ACTIVE`, `test_clearUnregulated_reverts_for_non_owner`,
`test_clearUnregulated_reverts_for_operator`, `test_setUnregulated_reverts_for_operator`.
- 통합 test `test/integration/EmergencyPause.t.sol`(router end-to-end):
`test_proposedPolicy_failsClosed`, `test_retiredPolicy_failsClosed`,
`test_suspendThenResume_tradesAgain`.
- 기존 registry lifecycle state-machine unit test(Task 1, 25 tests)는 유지.
- `forge fmt`.

- Product spec: `docs/product-specs/rfq-backend-sdk-and-demo.md`
- 이 feature는 문서 계획 작업이며 `services/rfq` 구현은 후속 feature에서 진행한다.

Expand All @@ -245,6 +282,12 @@ passing

### Notes

- 정책 결정: D010(lifecycle state machine, enum-append, setStatus 제거, engine
positive-allowlist default-deny, clearUnregulated correction path, declaredBy=
msg.sender와 factory consequence).
- Non-goals: direction-aware element application, production onboarding governance
key management.

- SDK README: `services/rfq/README.md`
- Product spec: `docs/product-specs/rfq-backend-sdk-and-demo.md`
- MVP demo backend는 이 SDK를 기반으로 후속 feature에서 구현한다.
26 changes: 13 additions & 13 deletions PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ source of truth로 사용한다.
- `RFQ-002 — RFQ v2 Hardening`
- `CMP-001 — Reg D 506(c) 9-element Recipe`(illustrative element library + recipe
9-element 확장, version 2)
- `CMP-002 — Manifest Lifecycle & Operator Approval Flow`(validated state machine,
setStatus 제거, engine positive-allowlist default-deny, clearUnregulated,
factory register→approve)
- `DOC-002 — RFQ SDK and MVP Demo Planning`
- `RFQ-SDK-001 — RFQ Backend SDK Interfaces`
- multi-venue 아키텍처와 책임 문서 작성
Expand All @@ -42,27 +45,24 @@ source of truth로 사용한다.

1. MVP RFQ demo backend milestone/user flow를 별도 문서·feature로 구체화한다.
PR #29/#30이 머지되면 기존 live-Anvil E2E/CLI 경로를 재사용한다.
2. pending RFQ/E2E/CLI/BUIDL PR stack(#25/#26/#27/#28/#29/#30/#35)이 머지된 뒤
2. pending RFQ/E2E/CLI/BUIDL PR stack(#26/#27/#28/#29/#30/#35)이 머지된 뒤
roadmap과 feature 상태를 재조정한다.
3. production Asset Compliance Manifest lifecycle/schema와 operator approval flow를
구현한다. (Element library와 Reg D 506(c) Recipe 9-element 확장은 CMP-001로 완료.)
4. ungated legacy mock element(A-01 sanctions, A-03 accredited, QP)를 새 element와
3. ungated legacy mock element(A-01 sanctions, A-03 accredited, QP)를 새 element와
동일하게 operator-gate로 정렬한다 — CMP-001 deferred follow-up.
5. 남은 RFQ production policy를 별도 feature로 분리한다: custody, partial fill,
4. 남은 RFQ production policy를 별도 feature로 분리한다: custody, partial fill,
production dealer/operator 책임.
6. acquisition/lot data source와 holding-period Recipe 활성화 조건을 결정한다
5. acquisition/lot data source와 holding-period Recipe 활성화 조건을 결정한다
(C-01 Lockup은 현재 fixture-only mock acquisition source).
7. Order Book은 matching/custody/surveillance 모델 결정 후 구현한다.
6. Order Book은 matching/custody/surveillance 모델 결정 후 구현한다.

## Last Session Summary

- #25 merge preparation includes CMP-001 (Reg D 506(c) 9-element Recipe) on top of current main.
- CMP-001 changes RegD506cRecipe to the illustrative 9-element set and adds integration coverage for element-family rejection cases.
- RFQ SDK and RFQ-002 hardening are already on main and preserved during this stacked merge reconciliation.
- #26 merge preparation includes CMP-002 (Manifest Lifecycle & Operator Approval Flow) on top of current main.
- CMP-002 closes the manifest lifecycle state machine and engine default-deny path after CMP-001.
- RFQ-002, CMP-001, and RFQ-SDK records are preserved during this stacked merge reconciliation.
- 실행한 검증:
- `forge fmt`
- `forge test --offline` on the original PR
- `scripts/check.sh` during #24 reconciliation
- original PR CI/checks passed before retarget
- conflict reconciliation checked with `git diff --check`
- 남은 리스크:
- MVP HTTP/CLI backend는 아직 구현하지 않았다.
- production signer custody, persistent nonce store, pricing, inventory/risk는 integrator/operator 책임이다.
Expand Down
51 changes: 35 additions & 16 deletions src/compliance/ComplianceEngine.sol
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,18 @@ contract ComplianceEngine is IComplianceEngine, Governed {
}

// ---------------------------------------------------------------------
// Regulated-token evaluation rule (two-sided, documented, deterministic):
// We read BOTH sides' status and decide the pair outcome fail-closed:
// * EITHER side SUSPENDED → reject.
// * EITHER side UNKNOWN (unregistered) → reject; quote/cash tokens must be
// EXPLICITLY registered UNREGULATED, we never infer an absent manifest.
// Regulated-token evaluation rule (two-sided, documented, DEFAULT-DENY):
// We read BOTH sides' status and decide the pair outcome fail-closed. Each
// side is checked against a POSITIVE ALLOWLIST — it must be either:
// * UNREGULATED → explicitly declared out-of-scope (quote/cash), or
// * ACTIVE → an operator-approved manifest.
// ANY other status — UNKNOWN (never registered / no inferred UNREGULATED),
// SUSPENDED (kill switch), PROPOSED (declared but not yet approved), RETIRED
// (terminal), and any member appended to PolicyStatus in the future — fails
// closed. Enumerating the permitted states rather than the rejected ones is
// what keeps a newly-added status fail-closed by default instead of silently
// slipping through a gap in a reject list.
// Once both sides are permitted:
// * BOTH UNREGULATED → pass through (fast path).
// * At least one ACTIVE → every ACTIVE side's manifest/recipes are
// evaluated against the full context. Regulated-regulated pairs combine
Expand All @@ -71,23 +78,30 @@ contract ComplianceEngine is IComplianceEngine, Governed {
PolicyStatus statusIn = policyReg.statusOf(ctx.tokenIn);
PolicyStatus statusOut = policyReg.statusOf(ctx.tokenOut);

// (1) EITHER side SUSPENDED → fail-closed.
if (statusIn == PolicyStatus.SUSPENDED || statusOut == PolicyStatus.SUSPENDED) {
return _rejectPolicy(ctx, PolicyStatus.SUSPENDED);
// (1) Default-deny: each side must be on the positive allowlist
// {UNREGULATED, ACTIVE}. Anything else fails closed, reporting the
// offending side's own status in the reason code (tokenIn first).
if (!_isPermitted(statusIn)) {
return _rejectPolicy(ctx, statusIn);
}
// (2) EITHER side UNKNOWN (unregistered) → fail-closed. We never infer
// UNREGULATED from an absent manifest.
if (statusIn == PolicyStatus.UNKNOWN || statusOut == PolicyStatus.UNKNOWN) {
return _rejectPolicy(ctx, PolicyStatus.UNKNOWN);
if (!_isPermitted(statusOut)) {
return _rejectPolicy(ctx, statusOut);
}
// (3) Both sides ∈ {UNREGULATED, ACTIVE}.
// (2) Both sides ∈ {UNREGULATED, ACTIVE}.
// Both UNREGULATED → fast path pass-through.
if (statusOut == PolicyStatus.UNREGULATED && statusIn == PolicyStatus.UNREGULATED) {
return _passThrough(ctx);
}
return _evaluateActivePair(ctx, statusIn, statusOut);
}

/// @dev Positive allowlist for a trade side: only an explicitly out-of-scope
/// (UNREGULATED) or operator-approved (ACTIVE) manifest may trade. Every
/// other status — present or future — is denied by omission.
function _isPermitted(PolicyStatus status) private pure returns (bool) {
return status == PolicyStatus.UNREGULATED || status == PolicyStatus.ACTIVE;
}

function _evaluateActivePair(ComplianceContext calldata ctx, PolicyStatus statusIn, PolicyStatus statusOut)
internal
view
Expand Down Expand Up @@ -274,9 +288,14 @@ contract ComplianceEngine is IComplianceEngine, Governed {
/// (spec §6). Only the wired router may record post-trade state; an
/// EOA/operator cannot forge surveillance counters by calling commit.
function commit(ComplianceContext calldata ctx) external override onlyRouter {
// Mirror evaluate's two-sided rule: STATEFUL post-trade hooks run for
// every ACTIVE regulated side. Any SUSPENDED/UNKNOWN side or
// both-UNREGULATED → nothing to commit.
// STATEFUL post-trade hooks run for every ACTIVE regulated side; a
// non-ACTIVE side (SUSPENDED/UNKNOWN/PROPOSED/RETIRED) or both-UNREGULATED
// has nothing to commit. NO mirror of evaluate's default-deny gate is
// needed here: `commit` is reachable ONLY after the router's own
// `engine.evaluate(ctx)` returned allowed (it reverts ComplianceRejected
// otherwise), so any pair that would fail the allowlist never reaches
// this path. The final `statusIn/Out == ACTIVE` guards below are what
// actually decide which sides get committed.
PolicyStatus statusIn = policyReg.statusOf(ctx.tokenIn);
PolicyStatus statusOut = policyReg.statusOf(ctx.tokenOut);
if (statusIn == PolicyStatus.SUSPENDED || statusOut == PolicyStatus.SUSPENDED) return;
Expand Down
20 changes: 19 additions & 1 deletion src/factory/CornerStoreFactory.sol
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,33 @@ contract CornerStoreFactory is Governed {
venueRegistry = _venueRegistry;
}

/// @notice Register an RWA token's compliance manifest and execution venue.
/// @notice Register an RWA token's compliance manifest and execution venue in
/// one governed onboarding call. The manifest ends ACTIVE.
/// @dev Governed: only owner/operator may onboard tokens.
///
/// Onboarding deliberately runs the manifest lifecycle's propose→approve
/// steps back-to-back: `registerManifest` lands the manifest in PROPOSED,
/// then `approveManifest` moves it to ACTIVE. The factory is BOTH the
/// owner (register is `onlyOwner`) and the approving operator (approve is
/// `onlyOperator`) of the registry in this flow — governance transfers
/// registry ownership to the factory during deployment (see contract
/// natspec). Collapsing propose+approve into a single trusted call is the
/// intended onboarding shape; the two-step lifecycle exists to gate
/// externally-declared manifests, not this atomic governed path.
///
/// CONSEQUENCE of `msg.sender` semantics: because `registerManifest`
/// records `declaredBy = msg.sender`, the manifest's `declaredBy` is this
/// factory's address (not the EOA/governance account that called the
/// factory), and `approvedBy` is likewise the factory. Attribution stops
/// at the factory boundary for tokens onboarded this way.
function registerRWAToken(
address token,
ManifestCore calldata manifest,
address venue,
VenueConfig calldata venueCfg
) external onlyOperator {
tokenPolicyRegistry.registerManifest(token, manifest);
tokenPolicyRegistry.approveManifest(token);
venueRegistry.registerVenue(venue, venueCfg);
emit RWATokenRegistered(token, venue);
}
Expand Down
Loading
Loading