Skip to content

Commit de85b6e

Browse files
authored
Merge pull request #26 from decipherhub/feature/manifest-lifecycle
Give asset manifests a validated lifecycle with operator approval
2 parents 0653513 + a88b661 commit de85b6e

15 files changed

Lines changed: 797 additions & 76 deletions

File tree

DECISIONS.md

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,3 +424,109 @@ criteria remain approval-gated").
424424
`test/integration/RegD506cElements.t.sol`
425425
- `test/unit/compliance/Recipes.t.sol`, `test/unit/compliance/Engine.t.sol`
426426
- `docs/ROADMAP.md`
427+
428+
## D010 — Manifest lifecycle를 validated state machine로 만들고 engine을 default-deny로 닫는다
429+
430+
Date: 2026-07-04
431+
432+
### Context
433+
434+
MVP-v2 §5는 asset Manifest에 explicit operator approval step을 포함한 full
435+
lifecycle을 요구한다. 기존 `TokenPolicyRegistry`는 임의 상태를 덮어쓰는 raw
436+
`setStatus`만 제공했고(승인 단계 없음), `ComplianceEngine.evaluate`는 SUSPENDED와
437+
UNKNOWN만 명시적으로 reject한 뒤 나머지를 `_evaluateActivePair`로 흘려보냈다. 이
438+
구조에 PROPOSED/RETIRED 상태를 추가하면 두 개의 fail-open 구멍이 생긴다: (a)
439+
PROPOSED/RETIRED 자산이 UNREGULATED와 pair되면 element가 0개 수집되어 `allowed =
440+
true`, (b) PROPOSED/RETIRED가 ACTIVE와 pair되면 ACTIVE side만 검증되고 거래가
441+
통과한다.
442+
443+
### Decision
444+
445+
**1. Lifecycle state machine (states/transitions).** Manifest는 아래 transition만
446+
허용하는 명시적 state machine을 따른다. 그 외 모든 (state, action)은 dedicated
447+
custom error `Errors.InvalidManifestTransition`으로 revert한다.
448+
449+
| From | Action | To | Gate |
450+
| --- | --- | --- | --- |
451+
| UNKNOWN 또는 RETIRED | `registerManifest` | PROPOSED | onlyOwner |
452+
| PROPOSED | `approveManifest` | ACTIVE | onlyOperator (issuance recipe 필수) |
453+
| ACTIVE | `suspendManifest(reasonCode)` | SUSPENDED | onlyOperator |
454+
| SUSPENDED | `resumeManifest` | ACTIVE | onlyOperator |
455+
| ACTIVE 또는 SUSPENDED | `retireManifest(reasonCode)` | RETIRED (terminal) | onlyOperator |
456+
| UNKNOWN | `setUnregulated` | UNREGULATED | onlyOwner |
457+
| UNREGULATED | `clearUnregulated` | UNKNOWN | onlyOwner |
458+
459+
`registerManifest`는 caller가 넣은 `m.status`를 무시하고 항상 PROPOSED로 착지하며
460+
`declaredBy = msg.sender`를 기록한다. `approveManifest``approvedBy = msg.sender`
461+
기록하고 `issuanceRecipeId == 0`이면 registry-level completeness floor로 revert한다.
462+
RETIRED는 terminal이며 재발행은 RETIRED→register→approve 경로로만 가능하다. gating
463+
model: owner가 자산을 classify/declare(register/setUnregulated/clearUnregulated)하고,
464+
operator가 기존 manifest의 lifecycle(approve/suspend/resume/retire)을 구동한다.
465+
466+
**2. Enum-append storage rationale.** `PolicyStatus`에 PROPOSED(4)와 RETIRED(5)를
467+
SUSPENDED(3) 뒤에 APPEND한다. 기존 numeric value(UNKNOWN=0, UNREGULATED=1,
468+
ACTIVE=2, SUSPENDED=3)는 storage layout과 enum↔uint cast에서 load-bearing이므로
469+
절대 재정렬하지 않는다. 그 결과 enum의 numeric order는 lifecycle graph의 semantic
470+
order와 일치하지 않는다(type 파일에 명시적 주석). UNKNOWN=0을 유지하는 것은 absent
471+
manifest가 fail-closed default가 되도록 하는 핵심이다.
472+
473+
**3. `setStatus` 제거.** raw `setStatus` overwrite는 제거한다(pre-production
474+
breaking change; 모든 caller/test/factory를 이 feature에서 갱신). validation 없는
475+
상태 덮어쓰기는 approval step과 transition guard를 우회하므로 유지할 수 없다.
476+
477+
**4. Engine default-deny 재구조화 (positive allowlist).** `evaluate`의 enumerated
478+
bad-status 검사(SUSPENDED/UNKNOWN reject)를 side별 positive allowlist로 교체한다:
479+
각 side는 UNREGULATED 또는 ACTIVE여야 하며, 그 외(UNKNOWN/SUSPENDED/PROPOSED/
480+
RETIRED와 미래에 추가될 모든 member)는 fail-closed한다. bad state를 열거하는 대신
481+
permitted state를 열거하는 이유: reject list에 새 상태를 추가하는 것을 잊으면
482+
그대로 fail-open이 되지만, allowlist는 새로 추가된 상태를 default로 거부하므로
483+
"append-only 안전"하다. both-UNREGULATED fast-path pass-through는 유지한다.
484+
`commit`은 router가 `evaluate`를 먼저 호출하고 reject 시 revert하므로 rejected
485+
pair에서 도달 불가능하다 — mirror guard가 필요 없고, in-file 주석으로 이유를
486+
남긴다(재구조화하지 않음).
487+
488+
**5. `clearUnregulated` correction path.** 잘못 UNREGULATED로 tag된 token을
489+
UNKNOWN(clean slate)으로 되돌려 이후 regulated manifest로 register할 수 있게 한다.
490+
onlyOwner이며 UNREGULATED가 아닌 상태에서 호출하면 `InvalidManifestTransition`.
491+
setUnregulated와 대칭인 governance classification 호출이다.
492+
493+
**6. `declaredBy = msg.sender` semantics와 factory consequence.** register는
494+
`declaredBy = msg.sender`, approve는 `approvedBy = msg.sender`를 기록한다.
495+
`CornerStoreFactory.registerRWAToken`은 register→approve를 한 governed call에서
496+
연속 실행하며 factory가 registry의 owner이자 approving operator다(배포 시
497+
governance가 registry ownership을 factory로 이전). 그 결과 이 경로로 onboarding된
498+
token의 `declaredBy`/`approvedBy`는 factory 주소이며, attribution은 factory
499+
경계에서 멈춘다.
500+
501+
### Alternatives Considered
502+
503+
- **Engine에서 reject list(bad state 열거) 유지**: PolicyStatus에 member를 추가할
504+
때마다 reject list 갱신을 잊으면 fail-open이 되므로 제외. positive allowlist는
505+
구조적으로 fail-closed default를 보장한다.
506+
- **enum member를 semantic order로 재정렬**: storage layout과 enum↔uint cast가
507+
깨지므로 제외. append-only만 허용한다.
508+
- **`setStatus`를 deprecated로 유지**: validation 우회 경로가 남으므로 제외
509+
(pre-production이라 breaking removal 비용이 낮다).
510+
- **PROPOSED/RETIRED에 UNKNOWN/SUSPENDED와 다른 별도 reason-code family 부여**:
511+
기존 `_rejectPolicy`가 이미 `uint32(status)`를 reason code에 인코딩하므로 side의
512+
실제 status를 그대로 넘기면 distinct code가 자동으로 나온다 — 추가 scheme 불필요.
513+
514+
### Consequences
515+
516+
- 모든 onboarding은 propose→approve 2단계를 거친다(factory는 한 call에서 collapse).
517+
- ACTIVE manifest의 issuance recipe re-point는 retire→register→approve 경로를
518+
써야 한다(ACTIVE 위 re-register는 illegal).
519+
- engine은 미래에 PolicyStatus member가 추가되어도 default로 fail-closed한다.
520+
- fixture는 register→approve(및 UNREGULATED는 setUnregulated)로 통일된다.
521+
522+
### Related Files
523+
524+
- `src/types/ComplianceTypes.sol` (enum append + 주석)
525+
- `src/registry/TokenPolicyRegistry.sol`,
526+
`src/interfaces/compliance/ITokenPolicyRegistry.sol`
527+
- `src/compliance/ComplianceEngine.sol` (evaluate default-deny gate)
528+
- `src/factory/CornerStoreFactory.sol` (register+approve, natspec)
529+
- `test/unit/compliance/Engine.t.sol`,
530+
`test/unit/registry/TokenPolicyRegistry.t.sol`
531+
- `test/integration/EmergencyPause.t.sol`, `test/integration/IntegrationBase.sol`,
532+
`test/integration/Surveillance.t.sol`

FEATURES.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,43 @@ passing
219219
활성화 조건 결정.
220220
- Non-goals: production legal 활성화, direction-aware element application.
221221

222+
## CMP-002 — Manifest Lifecycle & Operator Approval Flow
223+
224+
### Behavior
225+
226+
- Manifest는 validated state machine을 따른다: UNKNOWN --register--> PROPOSED
227+
--approve--> ACTIVE, ACTIVE <--resume--/--suspend--> SUSPENDED,
228+
{ACTIVE, SUSPENDED} --retire--> RETIRED(terminal), UNKNOWN --setUnregulated-->
229+
UNREGULATED --clearUnregulated--> UNKNOWN. 그 외 모든 transition은
230+
`InvalidManifestTransition`으로 revert한다.
231+
- `registerManifest`는 caller-supplied status를 무시하고 항상 PROPOSED로 착지하며
232+
`declaredBy = msg.sender`를 기록한다. `approveManifest``approvedBy`를 기록하고
233+
issuance recipe가 비어 있으면 revert한다. raw `setStatus`는 제거되었다.
234+
- owner가 자산을 classify(register/setUnregulated/clearUnregulated)하고 operator가
235+
기존 manifest의 lifecycle(approve/suspend/resume/retire)을 구동한다.
236+
- `ComplianceEngine.evaluate`는 side별 positive allowlist(UNREGULATED 또는 ACTIVE만
237+
허용)로 default-deny한다. UNKNOWN/SUSPENDED/PROPOSED/RETIRED와 미래 member는
238+
fail-closed하며, both-UNREGULATED fast-path는 유지된다.
239+
- `CornerStoreFactory.registerRWAToken`은 register→approve를 한 governed call에서
240+
실행하고 token은 ACTIVE로 끝난다(`declaredBy`/`approvedBy` = factory).
241+
242+
### Verification
243+
244+
- `forge test --offline`(전체 227/227, pre-task 212 + 신규 15).
245+
- Engine unit test(default-deny fail-closed): `test_proposed_against_unregulated_fails_closed`,
246+
`test_retired_against_unregulated_fails_closed`, `test_proposed_against_active_fails_closed`,
247+
`test_retired_against_active_fails_closed_both_orderings`(양방향 ordering).
248+
- Registry unit test(clearUnregulated + onlyOwner-vs-onlyOperator):
249+
`test_clearUnregulated_from_UNREGULATED`, `test_clearUnregulated_then_register_ok`,
250+
`test_clearUnregulated_reverts_when_UNKNOWN`, `test_clearUnregulated_reverts_when_PROPOSED`,
251+
`test_clearUnregulated_reverts_when_ACTIVE`, `test_clearUnregulated_reverts_for_non_owner`,
252+
`test_clearUnregulated_reverts_for_operator`, `test_setUnregulated_reverts_for_operator`.
253+
- 통합 test `test/integration/EmergencyPause.t.sol`(router end-to-end):
254+
`test_proposedPolicy_failsClosed`, `test_retiredPolicy_failsClosed`,
255+
`test_suspendThenResume_tradesAgain`.
256+
- 기존 registry lifecycle state-machine unit test(Task 1, 25 tests)는 유지.
257+
- `forge fmt`.
258+
222259
- Product spec: `docs/product-specs/rfq-backend-sdk-and-demo.md`
223260
- 이 feature는 문서 계획 작업이며 `services/rfq` 구현은 후속 feature에서 진행한다.
224261

@@ -245,6 +282,12 @@ passing
245282

246283
### Notes
247284

285+
- 정책 결정: D010(lifecycle state machine, enum-append, setStatus 제거, engine
286+
positive-allowlist default-deny, clearUnregulated correction path, declaredBy=
287+
msg.sender와 factory consequence).
288+
- Non-goals: direction-aware element application, production onboarding governance
289+
key management.
290+
248291
- SDK README: `services/rfq/README.md`
249292
- Product spec: `docs/product-specs/rfq-backend-sdk-and-demo.md`
250293
- MVP demo backend는 이 SDK를 기반으로 후속 feature에서 구현한다.

PROGRESS.md

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ source of truth로 사용한다.
2222
- `RFQ-002 — RFQ v2 Hardening`
2323
- `CMP-001 — Reg D 506(c) 9-element Recipe`(illustrative element library + recipe
2424
9-element 확장, version 2)
25+
- `CMP-002 — Manifest Lifecycle & Operator Approval Flow`(validated state machine,
26+
setStatus 제거, engine positive-allowlist default-deny, clearUnregulated,
27+
factory register→approve)
2528
- `DOC-002 — RFQ SDK and MVP Demo Planning`
2629
- `RFQ-SDK-001 — RFQ Backend SDK Interfaces`
2730
- multi-venue 아키텍처와 책임 문서 작성
@@ -42,27 +45,24 @@ source of truth로 사용한다.
4245

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

5758
## Last Session Summary
5859

59-
- #25 merge preparation includes CMP-001 (Reg D 506(c) 9-element Recipe) on top of current main.
60-
- CMP-001 changes RegD506cRecipe to the illustrative 9-element set and adds integration coverage for element-family rejection cases.
61-
- RFQ SDK and RFQ-002 hardening are already on main and preserved during this stacked merge reconciliation.
60+
- #26 merge preparation includes CMP-002 (Manifest Lifecycle & Operator Approval Flow) on top of current main.
61+
- CMP-002 closes the manifest lifecycle state machine and engine default-deny path after CMP-001.
62+
- RFQ-002, CMP-001, and RFQ-SDK records are preserved during this stacked merge reconciliation.
6263
- 실행한 검증:
63-
- `forge fmt`
64-
- `forge test --offline` on the original PR
65-
- `scripts/check.sh` during #24 reconciliation
64+
- original PR CI/checks passed before retarget
65+
- conflict reconciliation checked with `git diff --check`
6666
- 남은 리스크:
6767
- MVP HTTP/CLI backend는 아직 구현하지 않았다.
6868
- production signer custody, persistent nonce store, pricing, inventory/risk는 integrator/operator 책임이다.

src/compliance/ComplianceEngine.sol

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,18 @@ contract ComplianceEngine is IComplianceEngine, Governed {
5555
}
5656

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

74-
// (1) EITHER side SUSPENDED → fail-closed.
75-
if (statusIn == PolicyStatus.SUSPENDED || statusOut == PolicyStatus.SUSPENDED) {
76-
return _rejectPolicy(ctx, PolicyStatus.SUSPENDED);
81+
// (1) Default-deny: each side must be on the positive allowlist
82+
// {UNREGULATED, ACTIVE}. Anything else fails closed, reporting the
83+
// offending side's own status in the reason code (tokenIn first).
84+
if (!_isPermitted(statusIn)) {
85+
return _rejectPolicy(ctx, statusIn);
7786
}
78-
// (2) EITHER side UNKNOWN (unregistered) → fail-closed. We never infer
79-
// UNREGULATED from an absent manifest.
80-
if (statusIn == PolicyStatus.UNKNOWN || statusOut == PolicyStatus.UNKNOWN) {
81-
return _rejectPolicy(ctx, PolicyStatus.UNKNOWN);
87+
if (!_isPermitted(statusOut)) {
88+
return _rejectPolicy(ctx, statusOut);
8289
}
83-
// (3) Both sides ∈ {UNREGULATED, ACTIVE}.
90+
// (2) Both sides ∈ {UNREGULATED, ACTIVE}.
8491
// Both UNREGULATED → fast path pass-through.
8592
if (statusOut == PolicyStatus.UNREGULATED && statusIn == PolicyStatus.UNREGULATED) {
8693
return _passThrough(ctx);
8794
}
8895
return _evaluateActivePair(ctx, statusIn, statusOut);
8996
}
9097

98+
/// @dev Positive allowlist for a trade side: only an explicitly out-of-scope
99+
/// (UNREGULATED) or operator-approved (ACTIVE) manifest may trade. Every
100+
/// other status — present or future — is denied by omission.
101+
function _isPermitted(PolicyStatus status) private pure returns (bool) {
102+
return status == PolicyStatus.UNREGULATED || status == PolicyStatus.ACTIVE;
103+
}
104+
91105
function _evaluateActivePair(ComplianceContext calldata ctx, PolicyStatus statusIn, PolicyStatus statusOut)
92106
internal
93107
view
@@ -274,9 +288,14 @@ contract ComplianceEngine is IComplianceEngine, Governed {
274288
/// (spec §6). Only the wired router may record post-trade state; an
275289
/// EOA/operator cannot forge surveillance counters by calling commit.
276290
function commit(ComplianceContext calldata ctx) external override onlyRouter {
277-
// Mirror evaluate's two-sided rule: STATEFUL post-trade hooks run for
278-
// every ACTIVE regulated side. Any SUSPENDED/UNKNOWN side or
279-
// both-UNREGULATED → nothing to commit.
291+
// STATEFUL post-trade hooks run for every ACTIVE regulated side; a
292+
// non-ACTIVE side (SUSPENDED/UNKNOWN/PROPOSED/RETIRED) or both-UNREGULATED
293+
// has nothing to commit. NO mirror of evaluate's default-deny gate is
294+
// needed here: `commit` is reachable ONLY after the router's own
295+
// `engine.evaluate(ctx)` returned allowed (it reverts ComplianceRejected
296+
// otherwise), so any pair that would fail the allowlist never reaches
297+
// this path. The final `statusIn/Out == ACTIVE` guards below are what
298+
// actually decide which sides get committed.
280299
PolicyStatus statusIn = policyReg.statusOf(ctx.tokenIn);
281300
PolicyStatus statusOut = policyReg.statusOf(ctx.tokenOut);
282301
if (statusIn == PolicyStatus.SUSPENDED || statusOut == PolicyStatus.SUSPENDED) return;

src/factory/CornerStoreFactory.sol

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,33 @@ contract CornerStoreFactory is Governed {
2727
venueRegistry = _venueRegistry;
2828
}
2929

30-
/// @notice Register an RWA token's compliance manifest and execution venue.
30+
/// @notice Register an RWA token's compliance manifest and execution venue in
31+
/// one governed onboarding call. The manifest ends ACTIVE.
3132
/// @dev Governed: only owner/operator may onboard tokens.
33+
///
34+
/// Onboarding deliberately runs the manifest lifecycle's propose→approve
35+
/// steps back-to-back: `registerManifest` lands the manifest in PROPOSED,
36+
/// then `approveManifest` moves it to ACTIVE. The factory is BOTH the
37+
/// owner (register is `onlyOwner`) and the approving operator (approve is
38+
/// `onlyOperator`) of the registry in this flow — governance transfers
39+
/// registry ownership to the factory during deployment (see contract
40+
/// natspec). Collapsing propose+approve into a single trusted call is the
41+
/// intended onboarding shape; the two-step lifecycle exists to gate
42+
/// externally-declared manifests, not this atomic governed path.
43+
///
44+
/// CONSEQUENCE of `msg.sender` semantics: because `registerManifest`
45+
/// records `declaredBy = msg.sender`, the manifest's `declaredBy` is this
46+
/// factory's address (not the EOA/governance account that called the
47+
/// factory), and `approvedBy` is likewise the factory. Attribution stops
48+
/// at the factory boundary for tokens onboarded this way.
3249
function registerRWAToken(
3350
address token,
3451
ManifestCore calldata manifest,
3552
address venue,
3653
VenueConfig calldata venueCfg
3754
) external onlyOperator {
3855
tokenPolicyRegistry.registerManifest(token, manifest);
56+
tokenPolicyRegistry.approveManifest(token);
3957
venueRegistry.registerVenue(venue, venueCfg);
4058
emit RWATokenRegistered(token, venue);
4159
}

0 commit comments

Comments
 (0)