guardian-framework 0.1.9 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1 -1
- package/dist/exports.js +1 -1
- package/package.json +1 -1
- package/templates/pi/INDEX.md +2 -0
- package/templates/pi/architecture/modules/module-template.md +3 -3
- package/templates/pi/extensions/architect-lib/generators.ts +4 -3
- package/templates/pi/extensions/architect.ts +4 -3
- package/templates/pi/preflight_report.json +2 -2
- package/templates/pi/skills/agents/rust-codegen.md +70 -0
- package/templates/pi/skills/rust-enterprise-codegen.md +1004 -0
package/dist/cli.js
CHANGED
|
@@ -1335,7 +1335,7 @@ __export(exports_package, {
|
|
|
1335
1335
|
bin: () => bin,
|
|
1336
1336
|
author: () => author
|
|
1337
1337
|
});
|
|
1338
|
-
var name = "guardian-framework", version = "0.1.
|
|
1338
|
+
var name = "guardian-framework", version = "0.1.11", description = "Token-optimized agentic framework scaffolder with pi-first architecture", type = "module", main = "dist/exports.js", exports, types = "dist/exports.d.ts", bin, files, engines, scripts, publishConfig, pi, repository, homepage = "https://github.com/arman-jalili/guardian-framework#readme", bugs, dependencies, devDependencies, keywords, author = "Arman Wolkensteiner-Jalili", license = "MIT", package_default;
|
|
1339
1339
|
var init_package = __esm(() => {
|
|
1340
1340
|
exports = {
|
|
1341
1341
|
".": {
|
package/dist/exports.js
CHANGED
|
@@ -995,7 +995,7 @@ __export(exports_package, {
|
|
|
995
995
|
bin: () => bin,
|
|
996
996
|
author: () => author
|
|
997
997
|
});
|
|
998
|
-
var name = "guardian-framework", version = "0.1.
|
|
998
|
+
var name = "guardian-framework", version = "0.1.11", description = "Token-optimized agentic framework scaffolder with pi-first architecture", type = "module", main = "dist/exports.js", exports, types = "dist/exports.d.ts", bin, files, engines, scripts, publishConfig, pi, repository, homepage = "https://github.com/arman-jalili/guardian-framework#readme", bugs, dependencies, devDependencies, keywords, author = "Arman Wolkensteiner-Jalili", license = "MIT", package_default;
|
|
999
999
|
var init_package = __esm(() => {
|
|
1000
1000
|
exports = {
|
|
1001
1001
|
".": {
|
package/package.json
CHANGED
package/templates/pi/INDEX.md
CHANGED
|
@@ -85,6 +85,7 @@ DO NOT EDIT DIRECTLY - Modify source in .pi/
|
|
|
85
85
|
│ └── output-formats.md # Report templates
|
|
86
86
|
│
|
|
87
87
|
├── skills/
|
|
88
|
+
│ ├── rust-enterprise-codegen.md
|
|
88
89
|
│ ├── agents/
|
|
89
90
|
│ │ ├── architecture-coordinator.md
|
|
90
91
|
│ │ ├── architecture-validator.md
|
|
@@ -415,6 +416,7 @@ When running `guardian-framework generate`, `.pi/` files are transformed:
|
|
|
415
416
|
| `AGENTS.md` | `.github/copilot-instructions.md` | YAML frontmatter + canonical header |
|
|
416
417
|
| `architecture/modules/*.md` | `.claude/architecture/*.md` | Direct copy + canonical header |
|
|
417
418
|
| `architecture/CHANGELOG.md` | `.claude/architecture/CHANGELOG.md` | Direct copy |
|
|
419
|
+
| `skills/rust-enterprise-codegen.md` | `.pi/skills/rust-enterprise-codegen.md` | Direct copy — full reference patterns |
|
|
418
420
|
| `skills/agents/*.md` | `.claude/agents/*.md`, `.github/agents/*.agent.md` | Direct copy + YAML frontmatter |
|
|
419
421
|
| `skills/validators/*.md` | `.opencode/prompts/*.txt` | Convert to .txt, compress |
|
|
420
422
|
| `context/*.md` | `.claude/context/*.md`, `.opencode/context/*.md` | Direct copy + canonical header |
|
|
@@ -20,8 +20,8 @@ Generated: NEVER (this is the source)
|
|
|
20
20
|
|
|
21
21
|
| Component | File Path | Purpose | Canonical Section |
|
|
22
22
|
|-----------|-----------|---------|-------------------|
|
|
23
|
-
| [Name] | src/[
|
|
24
|
-
| [Name] | src/[
|
|
23
|
+
| [Name] | src/[module]/domain/[file].rs | [Description] | #[section] |
|
|
24
|
+
| [Name] | src/[module]/application/[file].rs | [Description] | #[section] |
|
|
25
25
|
|
|
26
26
|
---
|
|
27
27
|
|
|
@@ -31,7 +31,7 @@ Generated: NEVER (this is the source)
|
|
|
31
31
|
|
|
32
32
|
**Purpose:** [What this component does]
|
|
33
33
|
|
|
34
|
-
**Implementation File:** `src/[
|
|
34
|
+
**Implementation File:** `src/[module]/[layer]/[file].rs`
|
|
35
35
|
|
|
36
36
|
**Canonical Reference:** `.pi/architecture/modules/[module-name].md#[component-section]`
|
|
37
37
|
|
|
@@ -206,9 +206,10 @@ guardian_issue:
|
|
|
206
206
|
interfaces, types, DTOs, event schemas, API paths, error formats.
|
|
207
207
|
|
|
208
208
|
file_changes:
|
|
209
|
-
- "create: src/${moduleId}/
|
|
210
|
-
- "create: src/${moduleId}/
|
|
211
|
-
- "create: src/${moduleId}/
|
|
209
|
+
- "create: src/${moduleId}/domain/"
|
|
210
|
+
- "create: src/${moduleId}/application/"
|
|
211
|
+
- "create: src/${moduleId}/infrastructure/"
|
|
212
|
+
- "create: src/${moduleId}/interfaces/"
|
|
212
213
|
---
|
|
213
214
|
|
|
214
215
|
# Contract Freeze: ${slice.module}
|
|
@@ -892,9 +892,10 @@ guardian_issue:
|
|
|
892
892
|
interfaces, types, DTOs, event schemas, API paths, error formats.
|
|
893
893
|
|
|
894
894
|
file_changes:
|
|
895
|
-
- "create: src/${moduleId}/
|
|
896
|
-
- "create: src/${moduleId}/
|
|
897
|
-
- "create: src/${moduleId}/
|
|
895
|
+
- "create: src/${moduleId}/domain/"
|
|
896
|
+
- "create: src/${moduleId}/application/"
|
|
897
|
+
- "create: src/${moduleId}/infrastructure/"
|
|
898
|
+
- "create: src/${moduleId}/interfaces/"
|
|
898
899
|
---
|
|
899
900
|
|
|
900
901
|
# Contract Freeze: ${slice.module}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"timestamp": "2026-07-
|
|
2
|
+
"timestamp": "2026-07-03T04:44:32Z",
|
|
3
3
|
"mode": "all",
|
|
4
4
|
"stages_run": [],
|
|
5
5
|
"summary": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"failed": 1,
|
|
9
9
|
"skipped": 12
|
|
10
10
|
},
|
|
11
|
-
"duration_seconds":
|
|
11
|
+
"duration_seconds": 1,
|
|
12
12
|
"results": [
|
|
13
13
|
{
|
|
14
14
|
"name": "check_mr_traceability.sh",
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: rust-codegen
|
|
3
|
+
description: Minimal skill for Rust code generation with DDD + Clean Architecture. References full patterns on demand — never loads inline. Use when implementing Rust modules following enterprise patterns.
|
|
4
|
+
model: inherit
|
|
5
|
+
tools: [Read, Grep]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Rust Code Generation — DDD + Clean Architecture Patterns
|
|
9
|
+
|
|
10
|
+
> Do NOT load the full reference document. Read specific sections below when needed.
|
|
11
|
+
> Full reference: `.pi/skills/rust-enterprise-codegen.md`
|
|
12
|
+
|
|
13
|
+
## Quick Reference
|
|
14
|
+
|
|
15
|
+
When implementing, read ONLY the section you need:
|
|
16
|
+
|
|
17
|
+
| When you need... | Read this section from the reference |
|
|
18
|
+
|-----------------|--------------------------------------|
|
|
19
|
+
| Module structure | Section 1 — Clean Architecture 4-layer layout + header template + dependency direction |
|
|
20
|
+
| Aggregate root | Section 2 — DDD aggregate root with command methods, entity encapsulation |
|
|
21
|
+
| Value objects | Section 2 — Immutable value objects with self-validation |
|
|
22
|
+
| Repository pattern | Section 2 — Interface + implementation separation, collection semantics |
|
|
23
|
+
| Domain events | Section 2 (tactical) + Section 8 — Tagged union, serde, correlation IDs |
|
|
24
|
+
| Error types | Section 3 — thiserror enums, root error aggregation, is_retriable(), #[source] |
|
|
25
|
+
| Secret handling | Section 4 — Secret value object with redacted Display |
|
|
26
|
+
| State machines | Section 5 — Typed enum with is_terminal(), transition methods, tracking entity |
|
|
27
|
+
| RAII guards | Section 6 — Budget reservation with Drop auto-release |
|
|
28
|
+
| Async patterns | Section 7 — JoinSet, CancellationToken, select!, bounded channels |
|
|
29
|
+
| Configuration | Section 9 — Multi-source merging (flags > env > file > defaults) |
|
|
30
|
+
| Atomic file ops | Section 10 — write-tmp → fsync → rename pattern |
|
|
31
|
+
| Complex builders | Section 11 — Builder pattern + named constructors |
|
|
32
|
+
| Retry/backoff | Section 12 — BackoffStrategy enum |
|
|
33
|
+
| EventBus | Section 13 — broadcast channel, in-memory log, drain() |
|
|
34
|
+
| Tests | Section 14 — AAA pattern, serde round-trip, proptest, concurrency |
|
|
35
|
+
| Documentation | Section 15 — Module header template, @canonical refs, public API docs |
|
|
36
|
+
| Anti-patterns | Section 16 — what NOT to do (anyhow, unwrap, sync Mutex across await, etc.) |
|
|
37
|
+
| Dependencies | Section 17 — Cargo.toml conventions |
|
|
38
|
+
|
|
39
|
+
## Command
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
# Read the section you need (use the Read tool with limit parameter):
|
|
43
|
+
Read file_path=".pi/skills/rust-enterprise-codegen.md" limit=60
|
|
44
|
+
# Or grep for a specific section header:
|
|
45
|
+
Grep pattern="## 7\. Async" path=".pi/skills/rust-enterprise-codegen.md"
|
|
46
|
+
# Then Read with offset/limit targeting the lines you need
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## DDD Layer Contract
|
|
50
|
+
|
|
51
|
+
Each module follows this strict 4-layer structure:
|
|
52
|
+
|
|
53
|
+
| Layer | Purpose | Dependencies | Contains |
|
|
54
|
+
|-------|---------|-------------|----------|
|
|
55
|
+
| `domain/` | Pure business logic | None (serde, chrono, uuid only) | Entities, value objects, aggregates, domain events, repository traits |
|
|
56
|
+
| `application/` | Use case orchestration | `domain/` | Service traits, DTOs, factory interfaces |
|
|
57
|
+
| `infrastructure/` | External adapters | `application/` + `domain/` | Repository implementations, DB clients, HTTP clients |
|
|
58
|
+
| `interfaces/` | API contracts | `application/` | HTTP routes, request/response DTOs, event consumers |
|
|
59
|
+
|
|
60
|
+
**Dependency Rule:** Inner layers never depend on outer layers. `domain/` depends on nothing. `application/` depends on `domain/`. `infrastructure/` depends on `application/`. `interfaces/` depends on `application/`.
|
|
61
|
+
|
|
62
|
+
## Rules
|
|
63
|
+
|
|
64
|
+
- NEVER read the full reference document into context — read specific sections
|
|
65
|
+
- Target reads with `grep` + line numbers instead
|
|
66
|
+
- Each agent loads only the patterns it needs for its current task
|
|
67
|
+
- ALWAYS follow the 4-layer DDD structure — no `contracts/` wrapper
|
|
68
|
+
- ALWAYS define repository traits in `domain/`, implement in `infrastructure/`
|
|
69
|
+
- ALWAYS use typed error enums (thiserror), never String errors
|
|
70
|
+
- ALWAYS encapsulate aggregate state behind methods
|
|
@@ -0,0 +1,1004 @@
|
|
|
1
|
+
# Rust Enterprise Code Generation — DDD + Clean Architecture
|
|
2
|
+
|
|
3
|
+
> Canonical skill for generating production-grade Rust code.
|
|
4
|
+
> All code MUST follow these patterns. Validators enforce compliance.
|
|
5
|
+
>
|
|
6
|
+
> Source: rigorix-engine (17 frozen-contract modules) + DDD architecture analysis + Clean Architecture principles.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 1. Project Structure — Clean Architecture with DDD
|
|
11
|
+
|
|
12
|
+
Every bounded context follows the same 4-layer structure:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
module/
|
|
16
|
+
├── domain/ # Pure domain entities, value objects, events
|
|
17
|
+
│ ├── mod.rs # Re-exports + module-level documentation
|
|
18
|
+
│ ├── entity.rs # Aggregate roots and entities
|
|
19
|
+
│ ├── value.rs # Value objects
|
|
20
|
+
│ ├── event.rs # Domain event payloads
|
|
21
|
+
│ └── error.rs # Typed error enum (thiserror)
|
|
22
|
+
├── application/ # Service traits, DTOs, factory interfaces
|
|
23
|
+
│ ├── mod.rs
|
|
24
|
+
│ ├── service.rs # Service trait definitions
|
|
25
|
+
│ ├── factory.rs # Factory trait interfaces
|
|
26
|
+
│ └── dto/ # Input/Output DTOs with validation
|
|
27
|
+
│ └── mod.rs
|
|
28
|
+
├── infrastructure/ # Repository implementations, external adapters
|
|
29
|
+
│ ├── mod.rs
|
|
30
|
+
│ ├── repository/ # Repository trait definitions
|
|
31
|
+
│ │ ├── mod.rs
|
|
32
|
+
│ │ └── [entity]_repository.rs
|
|
33
|
+
│ └── persistence/ # ORM/database implementations
|
|
34
|
+
└── interfaces/ # API contracts (HTTP, events)
|
|
35
|
+
├── mod.rs
|
|
36
|
+
└── http/ # REST endpoint contracts
|
|
37
|
+
├── mod.rs
|
|
38
|
+
├── routes.rs
|
|
39
|
+
└── dto.rs # Request/Response DTOs
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Dependency Direction Rule (Inward Dependency)
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
domain → application → infrastructure → interfaces
|
|
46
|
+
↑ ↑
|
|
47
|
+
└── interior layers never depend on outer layers
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
- **domain/** — depends on nothing except serde, chrono, uuid (pure data)
|
|
51
|
+
- **application/** — depends on domain
|
|
52
|
+
- **infrastructure/** — depends on application (implements domain/application traits)
|
|
53
|
+
- **interfaces/** — depends on application (translates HTTP/events to domain calls)
|
|
54
|
+
|
|
55
|
+
### Module Header Pattern
|
|
56
|
+
|
|
57
|
+
Every `mod.rs` MUST include a canonical reference header:
|
|
58
|
+
|
|
59
|
+
```rust
|
|
60
|
+
//! Module Purpose — One-line summary of what this module does.
|
|
61
|
+
//!
|
|
62
|
+
//! @canonical .pi/architecture/modules/[module-name].md#[section]
|
|
63
|
+
//! Implements: Contract Freeze — [component names]
|
|
64
|
+
//!
|
|
65
|
+
//! Longer description of the module's purpose, design decisions,
|
|
66
|
+
//! and how it fits into the larger architecture.
|
|
67
|
+
//!
|
|
68
|
+
//! # Architecture
|
|
69
|
+
//!
|
|
70
|
+
//! ```text
|
|
71
|
+
//! module/
|
|
72
|
+
//! ├── domain/ ...
|
|
73
|
+
//! ├── application/ ...
|
|
74
|
+
//! ├── infrastructure/ ...
|
|
75
|
+
//! └── interfaces/ ...
|
|
76
|
+
//! ```
|
|
77
|
+
//!
|
|
78
|
+
//! # Contract (Frozen)
|
|
79
|
+
//! - [List of frozen contract rules]
|
|
80
|
+
//! - No implementation logic beyond constructors and field accessors
|
|
81
|
+
//! - All domain types are serializable (Serialize + Deserialize)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## 2. DDD Tactical Patterns
|
|
87
|
+
|
|
88
|
+
### Aggregate Root
|
|
89
|
+
|
|
90
|
+
The aggregate root is the entry point for all operations within its boundary. It enforces invariants and coordinates entity state changes.
|
|
91
|
+
|
|
92
|
+
```rust
|
|
93
|
+
/// Aggregate root for the [Module] bounded context.
|
|
94
|
+
///
|
|
95
|
+
/// # Contract (Frozen)
|
|
96
|
+
/// - Aggregate roots are the ONLY way to modify entities within their boundary
|
|
97
|
+
/// - Methods return Result<(), Error> instead of panicking
|
|
98
|
+
/// - All mutations go through aggregate methods, never direct field access
|
|
99
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
100
|
+
pub struct ModuleAggregate {
|
|
101
|
+
id: Uuid,
|
|
102
|
+
status: ModuleStatus,
|
|
103
|
+
entities: Vec<ChildEntity>,
|
|
104
|
+
created_at: DateTime<Utc>,
|
|
105
|
+
updated_at: DateTime<Utc>,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
impl ModuleAggregate {
|
|
109
|
+
pub fn new(id: Uuid) -> Self {
|
|
110
|
+
Self {
|
|
111
|
+
id,
|
|
112
|
+
status: ModuleStatus::Pending,
|
|
113
|
+
entities: Vec::new(),
|
|
114
|
+
created_at: Utc::now(),
|
|
115
|
+
updated_at: Utc::now(),
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/// Execute a state transition — returns events for side effects.
|
|
120
|
+
pub fn execute(&mut self, command: ModuleCommand) -> Result<Vec<DomainEvent>, ModuleError> {
|
|
121
|
+
match command {
|
|
122
|
+
ModuleCommand::Start => self.start(),
|
|
123
|
+
ModuleCommand::Complete { result } => self.complete(result),
|
|
124
|
+
ModuleCommand::Cancel { reason } => self.cancel(reason),
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
fn start(&mut self) -> Result<Vec<DomainEvent>, ModuleError> {
|
|
129
|
+
if !self.status.can_start() {
|
|
130
|
+
return Err(ModuleError::InvalidState {
|
|
131
|
+
current: self.status,
|
|
132
|
+
expected: ModuleStatus::Ready,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
self.status = ModuleStatus::Running;
|
|
136
|
+
self.updated_at = Utc::now();
|
|
137
|
+
Ok(vec![DomainEvent::ModuleStarted { aggregate_id: self.id, timestamp: Utc::now() }])
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
fn complete(&mut self, result: serde_json::Value) -> Result<Vec<DomainEvent>, ModuleError> {
|
|
141
|
+
if !self.status.can_complete() {
|
|
142
|
+
return Err(ModuleError::InvalidState {
|
|
143
|
+
current: self.status,
|
|
144
|
+
expected: ModuleStatus::Running,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
self.status = ModuleStatus::Completed;
|
|
148
|
+
self.updated_at = Utc::now();
|
|
149
|
+
Ok(vec![DomainEvent::ModuleCompleted { aggregate_id: self.id, result, timestamp: Utc::now() }])
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
fn cancel(&mut self, reason: String) -> Result<Vec<DomainEvent>, ModuleError> {
|
|
153
|
+
if self.status.is_terminal() {
|
|
154
|
+
return Err(ModuleError::InvalidState {
|
|
155
|
+
current: self.status,
|
|
156
|
+
expected: ModuleStatus::Pending,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
self.status = ModuleStatus::Cancelled;
|
|
160
|
+
self.updated_at = Utc::now();
|
|
161
|
+
Ok(vec![DomainEvent::ModuleCancelled { aggregate_id: self.id, reason, timestamp: Utc::now() }])
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Value Object
|
|
167
|
+
|
|
168
|
+
Value objects are immutable, interchangeable, and defined by their attributes, not identity.
|
|
169
|
+
|
|
170
|
+
```rust
|
|
171
|
+
/// Value object — identified by structural equality, not identity.
|
|
172
|
+
///
|
|
173
|
+
/// # Contract (Frozen)
|
|
174
|
+
/// - Immutable: all fields are read-only after construction
|
|
175
|
+
/// - Self-validating: constructor validates invariants
|
|
176
|
+
/// - Eq + Hash based on ALL fields
|
|
177
|
+
/// - No setters — create a new instance to change
|
|
178
|
+
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
179
|
+
pub struct Money {
|
|
180
|
+
amount: i64, // Stored in smallest currency unit (cents, pennys)
|
|
181
|
+
currency: Currency,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
impl Money {
|
|
185
|
+
pub fn new(amount: i64, currency: Currency) -> Result<Self, ModuleError> {
|
|
186
|
+
if amount < 0 {
|
|
187
|
+
return Err(ModuleError::ValidationError {
|
|
188
|
+
field: "amount",
|
|
189
|
+
message: "Amount must be non-negative",
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
Ok(Self { amount, currency })
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
pub fn amount(&self) -> i64 { self.amount }
|
|
196
|
+
pub fn currency(&self) -> &Currency { &self.currency }
|
|
197
|
+
|
|
198
|
+
pub fn add(&self, other: &Self) -> Result<Self, ModuleError> {
|
|
199
|
+
if self.currency != other.currency {
|
|
200
|
+
return Err(ModuleError::CurrencyMismatch {
|
|
201
|
+
left: self.currency.clone(),
|
|
202
|
+
right: other.currency.clone(),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
Ok(Self { amount: self.amount + other.amount, currency: self.currency.clone() })
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Repository Pattern
|
|
211
|
+
|
|
212
|
+
Repositories provide collection-like access to aggregates. The interface is defined in `domain/` or `application/`, implemented in `infrastructure/`.
|
|
213
|
+
|
|
214
|
+
```rust
|
|
215
|
+
/// Repository trait — defined in domain, implemented in infrastructure.
|
|
216
|
+
///
|
|
217
|
+
/// # Contract (Frozen)
|
|
218
|
+
/// - Interface defined in domain/, implementation in infrastructure/
|
|
219
|
+
/// - Methods return domain types (not ORM entities)
|
|
220
|
+
/// - Repository methods express the ubiquitous language
|
|
221
|
+
pub trait ModuleRepository: Send + Sync {
|
|
222
|
+
fn find_by_id(&self, id: Uuid) -> Result<Option<ModuleAggregate>, ModuleError>;
|
|
223
|
+
fn save(&mut self, aggregate: &ModuleAggregate) -> Result<(), ModuleError>;
|
|
224
|
+
fn delete(&mut self, id: Uuid) -> Result<(), ModuleError>;
|
|
225
|
+
fn find_by_status(&self, status: ModuleStatus) -> Result<Vec<ModuleAggregate>, ModuleError>;
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Domain Event
|
|
230
|
+
|
|
231
|
+
Domain events capture something meaningful that happened in the domain.
|
|
232
|
+
|
|
233
|
+
```rust
|
|
234
|
+
/// Domain events for the [Module] bounded context.
|
|
235
|
+
///
|
|
236
|
+
/// # Contract (Frozen)
|
|
237
|
+
/// - Every event carries aggregate_id and timestamp for correlation
|
|
238
|
+
/// - Serialized as tagged union with `#[serde(tag = "type")]`
|
|
239
|
+
/// - Events are facts — immutable and append-only
|
|
240
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
241
|
+
#[serde(tag = "type", rename_all = "snake_case")]
|
|
242
|
+
pub enum DomainEvent {
|
|
243
|
+
ModuleStarted {
|
|
244
|
+
aggregate_id: Uuid,
|
|
245
|
+
timestamp: DateTime<Utc>,
|
|
246
|
+
},
|
|
247
|
+
ModuleCompleted {
|
|
248
|
+
aggregate_id: Uuid,
|
|
249
|
+
result: serde_json::Value,
|
|
250
|
+
timestamp: DateTime<Utc>,
|
|
251
|
+
},
|
|
252
|
+
ModuleCancelled {
|
|
253
|
+
aggregate_id: Uuid,
|
|
254
|
+
reason: String,
|
|
255
|
+
timestamp: DateTime<Utc>,
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
impl DomainEvent {
|
|
260
|
+
/// Canonical snake_case name of this event variant.
|
|
261
|
+
pub fn event_type(&self) -> &'static str {
|
|
262
|
+
match self {
|
|
263
|
+
DomainEvent::ModuleStarted { .. } => "module_started",
|
|
264
|
+
DomainEvent::ModuleCompleted { .. } => "module_completed",
|
|
265
|
+
DomainEvent::ModuleCancelled { .. } => "module_cancelled",
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/// Extract the common aggregate_id for correlation.
|
|
270
|
+
pub fn aggregate_id(&self) -> &Uuid {
|
|
271
|
+
match self {
|
|
272
|
+
DomainEvent::ModuleStarted { aggregate_id, .. }
|
|
273
|
+
| DomainEvent::ModuleCompleted { aggregate_id, .. }
|
|
274
|
+
| DomainEvent::ModuleCancelled { aggregate_id, .. } => aggregate_id,
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### DDD Rules
|
|
281
|
+
|
|
282
|
+
- ✅ Aggregate roots are the **only** way to modify entities within their boundary
|
|
283
|
+
- ✅ Value objects are **immutable** after construction
|
|
284
|
+
- ✅ Repositories are interfaces in `domain/`, implementations in `infrastructure/`
|
|
285
|
+
- ✅ Domain events are **facts** — never modified after creation
|
|
286
|
+
- ✅ Every aggregate method returns `Result<Vec<DomainEvent>, Error>` for side effects
|
|
287
|
+
- ✅ Ubiquitous language in method and type names (not technology terms)
|
|
288
|
+
- ❌ No anemic domain models (entities with just getters/setters)
|
|
289
|
+
- ❌ No infrastructure concerns leaking into domain
|
|
290
|
+
- ❌ No `pub` fields on aggregates — always encapsulate
|
|
291
|
+
- ❌ No cross-aggregate references — use IDs, not object references
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## 3. Error Handling — thiserror with Aggregation
|
|
296
|
+
|
|
297
|
+
### Per-Module Error Enum
|
|
298
|
+
|
|
299
|
+
```rust
|
|
300
|
+
use thiserror::Error;
|
|
301
|
+
|
|
302
|
+
/// Typed error enum for the [Module] bounded context.
|
|
303
|
+
///
|
|
304
|
+
/// # Contract (Frozen)
|
|
305
|
+
/// - Every error variant follows the pattern: `PascalCase { fields }`
|
|
306
|
+
/// - `#[error("...")]` Display messages are user-readable
|
|
307
|
+
/// - Implement `is_retriable()` for transient failures
|
|
308
|
+
/// - Derive `Serialize + Deserialize` for API responses
|
|
309
|
+
#[derive(Debug, Clone, PartialEq, Error, Serialize, Deserialize)]
|
|
310
|
+
pub enum ModuleError {
|
|
311
|
+
/// Resource not found — include what was requested and what's available.
|
|
312
|
+
#[error("Not found: {id}. Available: {available:?}")]
|
|
313
|
+
NotFound { id: String, available: Vec<String> },
|
|
314
|
+
|
|
315
|
+
/// Invalid state transition attempt.
|
|
316
|
+
#[error("Invalid state transition: {current} → {expected}")]
|
|
317
|
+
InvalidState { current: ModuleStatus, expected: ModuleStatus },
|
|
318
|
+
|
|
319
|
+
/// Duplicate identifier.
|
|
320
|
+
#[error("Duplicate ID: {id}")]
|
|
321
|
+
DuplicateId { id: Uuid },
|
|
322
|
+
|
|
323
|
+
/// Validation failure — field-level error details.
|
|
324
|
+
#[error("Validation failed: {message}")]
|
|
325
|
+
ValidationError { field: &'static str, message: String },
|
|
326
|
+
|
|
327
|
+
/// Operation was cancelled.
|
|
328
|
+
#[error("Operation cancelled")]
|
|
329
|
+
Cancelled,
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
impl ModuleError {
|
|
333
|
+
/// Returns true if the error represents a transient failure.
|
|
334
|
+
pub fn is_retriable(&self) -> bool {
|
|
335
|
+
false // Default: no transient errors unless explicitly marked
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
### Root Error Aggregation
|
|
341
|
+
|
|
342
|
+
```rust
|
|
343
|
+
use thiserror::Error;
|
|
344
|
+
|
|
345
|
+
/// Root error type that aggregates all domain-specific errors via #[from].
|
|
346
|
+
#[derive(Debug, Error)]
|
|
347
|
+
pub enum RootError {
|
|
348
|
+
#[error("DAG error: {0}")]
|
|
349
|
+
Dag(#[from] DagError),
|
|
350
|
+
|
|
351
|
+
#[error("Planning error: {0}")]
|
|
352
|
+
Planning(#[from] PlanningError),
|
|
353
|
+
|
|
354
|
+
#[error("Execution error: {0}")]
|
|
355
|
+
Execution(#[from] ExecutionError),
|
|
356
|
+
|
|
357
|
+
#[error("IO error: {0}")]
|
|
358
|
+
Io(#[from] std::io::Error),
|
|
359
|
+
|
|
360
|
+
#[error("Operation cancelled: {0}")]
|
|
361
|
+
Cancelled(String),
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
impl RootError {
|
|
365
|
+
/// HTTP status code that best represents this error.
|
|
366
|
+
pub fn http_status(&self) -> u16 {
|
|
367
|
+
match self {
|
|
368
|
+
RootError::Dag(_) => 500,
|
|
369
|
+
RootError::Planning(_) => 400,
|
|
370
|
+
RootError::Execution(_) => 500,
|
|
371
|
+
RootError::Io(_) => 500,
|
|
372
|
+
RootError::Cancelled(_) => 499,
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/// Machine-readable error code.
|
|
377
|
+
pub fn error_code(&self) -> &'static str {
|
|
378
|
+
match self {
|
|
379
|
+
RootError::Dag(_) => "DAG_ERROR",
|
|
380
|
+
RootError::Planning(_) => "PLANNING_ERROR",
|
|
381
|
+
RootError::Execution(_) => "EXECUTION_ERROR",
|
|
382
|
+
RootError::Io(_) => "IO_ERROR",
|
|
383
|
+
RootError::Cancelled(_) => "CANCELLED",
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
### Error Source Chains
|
|
390
|
+
|
|
391
|
+
```rust
|
|
392
|
+
#[derive(Error, Debug)]
|
|
393
|
+
#[error("planning failed: {context}")]
|
|
394
|
+
pub struct PlanningFailure {
|
|
395
|
+
context: String,
|
|
396
|
+
#[source]
|
|
397
|
+
cause: PlanningError,
|
|
398
|
+
}
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
### Rules
|
|
402
|
+
|
|
403
|
+
- ✅ Use `thiserror` for ALL library/domain errors
|
|
404
|
+
- ✅ Every error has a descriptive `#[error("...")]` message
|
|
405
|
+
- ✅ Include context in error fields (what was requested, what's available)
|
|
406
|
+
- ✅ Root error aggregates sub-errors via `#[from]` for `?` operator propagation
|
|
407
|
+
- ❌ NEVER use `anyhow` in library code — reserved for binary crates only
|
|
408
|
+
- ❌ NEVER use `.unwrap()` or `.expect()` in production code
|
|
409
|
+
- ❌ NEVER use `String` errors — always typed enums
|
|
410
|
+
|
|
411
|
+
---
|
|
412
|
+
|
|
413
|
+
## 4. Secret Handling — Redacted Value Object
|
|
414
|
+
|
|
415
|
+
```rust
|
|
416
|
+
/// A sensitive value (API key, token) that is redacted in all text output.
|
|
417
|
+
///
|
|
418
|
+
/// # Security
|
|
419
|
+
/// - Debug/Display show `[REDACTED]` (never leak)
|
|
420
|
+
/// - Only `.expose()` reveals the inner value
|
|
421
|
+
/// - Does NOT derive Serialize — secrets must not be serialized
|
|
422
|
+
#[derive(Clone)]
|
|
423
|
+
pub struct Secret(String);
|
|
424
|
+
|
|
425
|
+
impl Secret {
|
|
426
|
+
pub fn new(value: impl Into<String>) -> Self { Self(value.into()) }
|
|
427
|
+
pub fn expose(&self) -> &str { &self.0 }
|
|
428
|
+
pub fn is_empty(&self) -> bool { self.0.is_empty() }
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
impl fmt::Debug for Secret {
|
|
432
|
+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
433
|
+
if self.0.is_empty() { write!(f, "<empty>") }
|
|
434
|
+
else { write!(f, "[REDACTED]") }
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
impl fmt::Display for Secret { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fmt::Debug::fmt(self, f) } }
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
### Rules
|
|
442
|
+
- ✅ Wrap all API keys, tokens, passwords in `Secret`
|
|
443
|
+
- ✅ Load secrets from environment variables, never from config files
|
|
444
|
+
- ❌ Never derive Serialize on types that contain secrets
|
|
445
|
+
- ❌ Never log secrets — `Secret::Debug` is redacted
|
|
446
|
+
|
|
447
|
+
---
|
|
448
|
+
|
|
449
|
+
## 5. State Machine Pattern — Typed Enum Lifecycle
|
|
450
|
+
|
|
451
|
+
```rust
|
|
452
|
+
/// Lifecycle status of a [domain entity].
|
|
453
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
454
|
+
pub enum ModuleStatus {
|
|
455
|
+
Pending,
|
|
456
|
+
Ready,
|
|
457
|
+
Running,
|
|
458
|
+
Completed,
|
|
459
|
+
Failed,
|
|
460
|
+
Cancelled,
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
impl ModuleStatus {
|
|
464
|
+
pub fn is_terminal(&self) -> bool {
|
|
465
|
+
matches!(self, ModuleStatus::Completed | ModuleStatus::Failed | ModuleStatus::Cancelled)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
pub fn can_start(&self) -> bool { matches!(self, ModuleStatus::Ready) }
|
|
469
|
+
pub fn can_complete(&self) -> bool { matches!(self, ModuleStatus::Running) }
|
|
470
|
+
|
|
471
|
+
pub fn as_str(&self) -> &'static str {
|
|
472
|
+
match self {
|
|
473
|
+
ModuleStatus::Pending => "pending",
|
|
474
|
+
ModuleStatus::Ready => "ready",
|
|
475
|
+
ModuleStatus::Running => "running",
|
|
476
|
+
ModuleStatus::Completed => "completed",
|
|
477
|
+
ModuleStatus::Failed => "failed",
|
|
478
|
+
ModuleStatus::Cancelled => "cancelled",
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
### State Tracking Entity
|
|
485
|
+
|
|
486
|
+
```rust
|
|
487
|
+
pub struct ModuleExecutionState {
|
|
488
|
+
pub module_id: Uuid,
|
|
489
|
+
pub status: ModuleStatus,
|
|
490
|
+
pub retry_attempts: u8,
|
|
491
|
+
pub started_at: Option<DateTime<Utc>>,
|
|
492
|
+
pub completed_at: Option<DateTime<Utc>>,
|
|
493
|
+
pub last_error: Option<String>,
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
impl ModuleExecutionState {
|
|
497
|
+
pub fn new(module_id: Uuid) -> Self { Self { module_id, status: ModuleStatus::Pending, retry_attempts: 0, started_at: None, completed_at: None, last_error: None } }
|
|
498
|
+
|
|
499
|
+
pub fn mark_ready(&mut self) { self.status = ModuleStatus::Ready; self.started_at = None; }
|
|
500
|
+
pub fn mark_running(&mut self) { self.status = ModuleStatus::Running; self.started_at = Some(Utc::now()); }
|
|
501
|
+
pub fn mark_completed(&mut self) { self.status = ModuleStatus::Completed; self.completed_at = Some(Utc::now()); }
|
|
502
|
+
pub fn mark_failed(&mut self, error: String) { self.status = ModuleStatus::Failed; self.last_error = Some(error); }
|
|
503
|
+
pub fn mark_for_retry(&mut self) { self.retry_attempts += 1; self.status = ModuleStatus::Ready; }
|
|
504
|
+
}
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
### Rules
|
|
508
|
+
- ✅ State transitions are **methods**, not public field writes
|
|
509
|
+
- ✅ Each transition captures timestamp automatically
|
|
510
|
+
- ✅ `is_terminal()` on every state enum
|
|
511
|
+
- ❌ No direct field mutation from outside the entity
|
|
512
|
+
|
|
513
|
+
---
|
|
514
|
+
|
|
515
|
+
## 6. RAII Reservation Pattern — Resource Guard
|
|
516
|
+
|
|
517
|
+
```rust
|
|
518
|
+
/// RAII guard: reserves budget on creation, auto-returns on Drop.
|
|
519
|
+
pub struct BudgetReservation {
|
|
520
|
+
budget_id: Uuid,
|
|
521
|
+
amount: u64,
|
|
522
|
+
released: bool,
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
impl BudgetReservation {
|
|
526
|
+
pub fn new(budget_id: Uuid, amount: u64) -> Self { Self { budget_id, amount, released: false } }
|
|
527
|
+
pub fn release(mut self) { self.released = true; /* Return budget to pool */ }
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
impl Drop for BudgetReservation {
|
|
531
|
+
fn drop(&mut self) {
|
|
532
|
+
if !self.released {
|
|
533
|
+
tracing::warn!("Budget reservation dropped without release");
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
---
|
|
540
|
+
|
|
541
|
+
## 7. Async Patterns — tokio JoinSet for Parallelism
|
|
542
|
+
|
|
543
|
+
```rust
|
|
544
|
+
use tokio::task::JoinSet;
|
|
545
|
+
|
|
546
|
+
pub async fn execute_parallel(tasks: Vec<Task>, max_concurrent: u32) -> Result<Vec<TaskResult>, Error> {
|
|
547
|
+
let mut join_set = JoinSet::new();
|
|
548
|
+
let mut results = Vec::new();
|
|
549
|
+
let mut iter = tasks.into_iter();
|
|
550
|
+
|
|
551
|
+
for _ in 0..max_concurrent {
|
|
552
|
+
if let Some(task) = iter.next() { join_set.spawn(execute_task(task)); }
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
while let Some(result) = join_set.join_next().await {
|
|
556
|
+
match result {
|
|
557
|
+
Ok(Ok(output)) => results.push(output),
|
|
558
|
+
Ok(Err(e)) => return Err(e),
|
|
559
|
+
Err(join_error) => return Err(Error::TaskPanicked(join_error.to_string())),
|
|
560
|
+
}
|
|
561
|
+
if let Some(task) = iter.next() { join_set.spawn(execute_task(task)); }
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
Ok(results)
|
|
565
|
+
}
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
### Cancellation-Aware Sleep
|
|
569
|
+
|
|
570
|
+
```rust
|
|
571
|
+
use tokio_util::sync::CancellationToken;
|
|
572
|
+
|
|
573
|
+
pub async fn poll_with_cancellation(cancel: CancellationToken, interval: Duration) -> Result<(), Error> {
|
|
574
|
+
loop {
|
|
575
|
+
tokio::select! {
|
|
576
|
+
_ = tokio::time::sleep(interval) => { /* Do periodic work */ }
|
|
577
|
+
_ = cancel.cancelled() => { return Err(Error::Cancelled("Polling cancelled".into())); }
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
### Rules
|
|
584
|
+
- ✅ Use `tokio::sync::mpsc::channel` (bounded) for cross-task communication
|
|
585
|
+
- ✅ Use `tokio::sync::broadcast` for fan-out pub-sub
|
|
586
|
+
- ✅ Use `tokio_util::sync::CancellationToken` for cooperative cancellation
|
|
587
|
+
- ✅ Use `std::sync::Mutex` for short critical sections that don't cross `.await` points
|
|
588
|
+
- ✅ Use `tokio::sync::Mutex` only when the lock must be held across `.await` points
|
|
589
|
+
- ❌ Never hold `std::sync::Mutex` across `.await` points
|
|
590
|
+
- ❌ Never use unbounded channels without explicit justification
|
|
591
|
+
- ❌ Never block with `std::thread::sleep` in async code
|
|
592
|
+
|
|
593
|
+
---
|
|
594
|
+
|
|
595
|
+
## 8. Domain Event Pattern — Tagged Union Enum
|
|
596
|
+
|
|
597
|
+
```rust
|
|
598
|
+
/// All possible events in the bounded context.
|
|
599
|
+
///
|
|
600
|
+
/// # Contract (Frozen)
|
|
601
|
+
/// - Every variant carries aggregate_id and timestamp for correlation
|
|
602
|
+
/// - Serialized as tagged union with `#[serde(tag = "type")]`
|
|
603
|
+
/// - No implementation logic — pure data
|
|
604
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
605
|
+
#[serde(tag = "type", rename_all = "snake_case")]
|
|
606
|
+
pub enum DomainEvent {
|
|
607
|
+
Started { aggregate_id: Uuid, timestamp: DateTime<Utc> },
|
|
608
|
+
Completed { aggregate_id: Uuid, result: serde_json::Value, timestamp: DateTime<Utc> },
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
impl DomainEvent {
|
|
612
|
+
pub fn event_type(&self) -> &'static str {
|
|
613
|
+
match self {
|
|
614
|
+
DomainEvent::Started { .. } => "started",
|
|
615
|
+
DomainEvent::Completed { .. } => "completed",
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
pub fn aggregate_id(&self) -> &Uuid {
|
|
619
|
+
match self {
|
|
620
|
+
DomainEvent::Started { aggregate_id, .. }
|
|
621
|
+
| DomainEvent::Completed { aggregate_id, .. } => aggregate_id,
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
### Rules
|
|
628
|
+
- ✅ Every event carries `aggregate_id` and `timestamp`
|
|
629
|
+
- ✅ Serialized as tagged union: `#[serde(tag = "type", rename_all = "snake_case")]`
|
|
630
|
+
- ✅ Provide helper methods: `event_type()`, `aggregate_id()`
|
|
631
|
+
- ✅ Write round-trip serde test for every variant
|
|
632
|
+
- ❌ No logic in event types — they are pure data
|
|
633
|
+
|
|
634
|
+
---
|
|
635
|
+
|
|
636
|
+
## 9. Configuration Pattern — Multi-Source Merging
|
|
637
|
+
|
|
638
|
+
```rust
|
|
639
|
+
/// Merge order: CLI flags > Environment > Config file > Defaults
|
|
640
|
+
pub trait Merge {
|
|
641
|
+
fn merge(&mut self, other: Self);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
pub struct ConfigService;
|
|
645
|
+
|
|
646
|
+
impl ConfigService {
|
|
647
|
+
pub fn load(cli_overrides: CliConfig) -> Result<Config, ConfigError> {
|
|
648
|
+
let mut config = Config::default();
|
|
649
|
+
|
|
650
|
+
// Layer 1: Config file
|
|
651
|
+
if let Some(file) = Self::load_config_file()? { config.merge(file); }
|
|
652
|
+
|
|
653
|
+
// Layer 2: Environment variables (RIGORIX_*)
|
|
654
|
+
config.merge(Self::load_from_env()?);
|
|
655
|
+
|
|
656
|
+
// Layer 3: CLI flags (highest precedence)
|
|
657
|
+
config.merge(cli_overrides);
|
|
658
|
+
|
|
659
|
+
config.validate()?;
|
|
660
|
+
Ok(config)
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
fn load_from_env() -> Result<Config, ConfigError> {
|
|
664
|
+
let mut config = Config::default();
|
|
665
|
+
if let Ok(val) = std::env::var("APP_LOG") { config.log_level = val; }
|
|
666
|
+
if let Ok(val) = std::env::var("APP_API_KEY") { config.api_key = Secret::new(val); }
|
|
667
|
+
Ok(config)
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
### Rules
|
|
673
|
+
- ✅ CLI flags override env vars which override config file which override defaults
|
|
674
|
+
- ✅ Secrets loaded ONLY from environment variables
|
|
675
|
+
- ✅ `validate()` runs after merging — fail fast on startup
|
|
676
|
+
|
|
677
|
+
---
|
|
678
|
+
|
|
679
|
+
## 10. Atomic File Operations — Write-Rename
|
|
680
|
+
|
|
681
|
+
```rust
|
|
682
|
+
use std::fs;
|
|
683
|
+
use std::io::Write;
|
|
684
|
+
use std::path::Path;
|
|
685
|
+
|
|
686
|
+
/// Atomic file write: write to tmp → fsync → rename → fsync parent.
|
|
687
|
+
pub fn atomic_write(path: &Path, contents: &str) -> Result<(), IoError> {
|
|
688
|
+
let tmp_path = path.with_extension("tmp");
|
|
689
|
+
let mut file = fs::File::create(&tmp_path)?;
|
|
690
|
+
file.write_all(contents.as_bytes())?;
|
|
691
|
+
file.sync_all()?;
|
|
692
|
+
fs::rename(&tmp_path, path)?;
|
|
693
|
+
if let Some(parent) = path.parent() {
|
|
694
|
+
if let Ok(dir) = fs::File::open(parent) { dir.sync_all()?; }
|
|
695
|
+
}
|
|
696
|
+
Ok(())
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
pub fn clean_orphan_tmp_files(dir: &Path) -> Result<(), IoError> {
|
|
700
|
+
for entry in fs::read_dir(dir)? {
|
|
701
|
+
let entry = entry?;
|
|
702
|
+
if entry.path().extension().map_or(false, |e| e == "tmp") {
|
|
703
|
+
fs::remove_file(entry.path())?;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
Ok(())
|
|
707
|
+
}
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
---
|
|
711
|
+
|
|
712
|
+
## 11. Builder Pattern — Complex Construction
|
|
713
|
+
|
|
714
|
+
```rust
|
|
715
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
716
|
+
pub struct ExecutionPolicy {
|
|
717
|
+
pub max_retries: u8,
|
|
718
|
+
pub retry_on: Vec<FailureType>,
|
|
719
|
+
pub retry_strategy: RetryStrategy,
|
|
720
|
+
pub backoff_ms: u64,
|
|
721
|
+
pub backoff_multiplier: f64,
|
|
722
|
+
pub max_backoff_ms: u64,
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
impl Default for ExecutionPolicy {
|
|
726
|
+
fn default() -> Self {
|
|
727
|
+
Self { max_retries: 3, retry_on: vec![FailureType::Transient], retry_strategy: RetryStrategy::SameOperation, backoff_ms: 100, backoff_multiplier: 2.0, max_backoff_ms: 30_000 }
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
impl ExecutionPolicy {
|
|
732
|
+
pub fn builder() -> ExecutionPolicyBuilder { ExecutionPolicyBuilder::default() }
|
|
733
|
+
pub fn no_retry() -> Self { Self { max_retries: 0, ..Default::default() } }
|
|
734
|
+
pub fn aggressive_retry() -> Self { Self { max_retries: 5, retry_on: vec![FailureType::Transient], backoff_ms: 50, backoff_multiplier: 1.5, ..Default::default() } }
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
#[derive(Default)]
|
|
738
|
+
pub struct ExecutionPolicyBuilder {
|
|
739
|
+
max_retries: u8,
|
|
740
|
+
retry_on: Vec<FailureType>,
|
|
741
|
+
retry_strategy: RetryStrategy,
|
|
742
|
+
backoff_ms: u64,
|
|
743
|
+
backoff_multiplier: f64,
|
|
744
|
+
max_backoff_ms: u64,
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
impl ExecutionPolicyBuilder {
|
|
748
|
+
pub fn with_max_retries(mut self, val: u8) -> Self { self.max_retries = val; self }
|
|
749
|
+
pub fn with_backoff(mut self, base_ms: u64, multiplier: f64, max_ms: u64) -> Self { self.backoff_ms = base_ms; self.backoff_multiplier = multiplier; self.max_backoff_ms = max_ms; self }
|
|
750
|
+
pub fn build(self) -> ExecutionPolicy { ExecutionPolicy { max_retries: self.max_retries, retry_on: self.retry_on, retry_strategy: self.retry_strategy, backoff_ms: self.backoff_ms, backoff_multiplier: self.backoff_multiplier, max_backoff_ms: self.max_backoff_ms } }
|
|
751
|
+
}
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
---
|
|
755
|
+
|
|
756
|
+
## 12. Retry/Backoff Pattern
|
|
757
|
+
|
|
758
|
+
```rust
|
|
759
|
+
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
|
760
|
+
pub enum BackoffStrategy {
|
|
761
|
+
Fixed { base_delay_ms: u64 },
|
|
762
|
+
Exponential { base_delay_ms: u64, multiplier: f64, max_delay_ms: u64 },
|
|
763
|
+
Linear { base_delay_ms: u64, step_ms: u64, max_delay_ms: u64 },
|
|
764
|
+
Immediate,
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
impl Default for BackoffStrategy {
|
|
768
|
+
fn default() -> Self { Self::Exponential { base_delay_ms: 100, multiplier: 2.0, max_delay_ms: 30_000 } }
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
impl BackoffStrategy {
|
|
772
|
+
/// Compute delay in milliseconds for retry attempt `n` (0-indexed).
|
|
773
|
+
pub fn delay_ms(&self, attempt: u8) -> u64 {
|
|
774
|
+
match self {
|
|
775
|
+
Self::Fixed { base } => *base,
|
|
776
|
+
Self::Exponential { base, mult, max } => (*base as f64 * mult.powi(attempt as i32)) as u64 - max,
|
|
777
|
+
Self::Linear { base, step, max } => (*base + *step * attempt as u64).min(*max),
|
|
778
|
+
Self::Immediate => 0,
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
```
|
|
783
|
+
|
|
784
|
+
---
|
|
785
|
+
|
|
786
|
+
## 13. EventBus — Pub-Sub with Broadcast Channel
|
|
787
|
+
|
|
788
|
+
```rust
|
|
789
|
+
use tokio::sync::broadcast;
|
|
790
|
+
|
|
791
|
+
pub struct EventBus {
|
|
792
|
+
tx: broadcast::Sender<DomainEvent>,
|
|
793
|
+
log: Vec<PersistedEvent>,
|
|
794
|
+
sequence: u64,
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
impl EventBus {
|
|
798
|
+
pub fn new(capacity: usize) -> Self {
|
|
799
|
+
let (tx, _) = broadcast::channel(capacity);
|
|
800
|
+
Self { tx, log: Vec::new(), sequence: 0 }
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
pub fn publish(&mut self, event: DomainEvent) -> Result<(), EventBusError> {
|
|
804
|
+
self.sequence += 1;
|
|
805
|
+
let persisted = PersistedEvent { sequence: self.sequence, event: event.clone() };
|
|
806
|
+
self.log.push(persisted);
|
|
807
|
+
let _ = self.tx.send(event);
|
|
808
|
+
Ok(())
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
pub fn subscribe(&self) -> broadcast::Receiver<DomainEvent> { self.tx.subscribe() }
|
|
812
|
+
pub fn drain(&mut self) -> Vec<PersistedEvent> { self.log.drain(..).collect() }
|
|
813
|
+
}
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
---
|
|
817
|
+
|
|
818
|
+
## 14. Testing Patterns
|
|
819
|
+
|
|
820
|
+
### Unit Tests — Inline with `#[cfg(test)]`
|
|
821
|
+
|
|
822
|
+
```rust
|
|
823
|
+
#[cfg(test)]
|
|
824
|
+
mod tests {
|
|
825
|
+
use super::*;
|
|
826
|
+
|
|
827
|
+
// AAA Pattern: Arrange → Act → Assert
|
|
828
|
+
#[test]
|
|
829
|
+
fn test_backoff_delay_exponential() {
|
|
830
|
+
let strategy = BackoffStrategy::Exponential { base_delay_ms: 100, multiplier: 2.0, max_delay_ms: 30_000 };
|
|
831
|
+
assert_eq!(strategy.delay_ms(2), 400); // 100 * 2^2 = 400
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
#[test]
|
|
835
|
+
fn test_status_terminal() {
|
|
836
|
+
assert!(ModuleStatus::Completed.is_terminal());
|
|
837
|
+
assert!(!ModuleStatus::Running.is_terminal());
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
#[test]
|
|
841
|
+
fn test_aggregate_execute_start() {
|
|
842
|
+
let mut agg = ModuleAggregate::new(Uuid::new_v4());
|
|
843
|
+
let events = agg.execute(ModuleCommand::Start).unwrap();
|
|
844
|
+
assert_eq!(agg.status, ModuleStatus::Running);
|
|
845
|
+
assert_eq!(events.len(), 1);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
```
|
|
849
|
+
|
|
850
|
+
### Serde Round-Trip Tests — For Every Serialized Type
|
|
851
|
+
|
|
852
|
+
```rust
|
|
853
|
+
#[test]
|
|
854
|
+
fn test_domain_event_serde_roundtrip() {
|
|
855
|
+
let eid = Uuid::new_v4();
|
|
856
|
+
let event = DomainEvent::ModuleCompleted { aggregate_id: eid, result: serde_json::json!("done"), timestamp: Utc::now() };
|
|
857
|
+
let json = serde_json::to_string(&event).unwrap();
|
|
858
|
+
let deserialized: DomainEvent = serde_json::from_str(&json).unwrap();
|
|
859
|
+
assert_eq!(*deserialized.aggregate_id(), eid);
|
|
860
|
+
}
|
|
861
|
+
```
|
|
862
|
+
|
|
863
|
+
### Property-Based Tests
|
|
864
|
+
|
|
865
|
+
```rust
|
|
866
|
+
#[cfg(test)]
|
|
867
|
+
mod property_tests {
|
|
868
|
+
use super::*;
|
|
869
|
+
use proptest::prelude::*;
|
|
870
|
+
|
|
871
|
+
proptest! {
|
|
872
|
+
#[test]
|
|
873
|
+
fn test_backoff_never_exceeds_max(base in 1..1000u64, mult in 1.0..10.0f64, max in 1000..100_000u64, attempt in 0..10u8) {
|
|
874
|
+
let strategy = BackoffStrategy::Exponential { base_delay_ms: base, multiplier: mult, max_delay_ms: max };
|
|
875
|
+
assert!(strategy.delay_ms(attempt) <= max);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
```
|
|
880
|
+
|
|
881
|
+
---
|
|
882
|
+
|
|
883
|
+
## 15. Documentation Standards
|
|
884
|
+
|
|
885
|
+
### Module-Level Docs (Every `mod.rs`)
|
|
886
|
+
|
|
887
|
+
```rust
|
|
888
|
+
//! [Module Name] — One-line purpose.
|
|
889
|
+
//!
|
|
890
|
+
//! @canonical .pi/architecture/modules/[module-name].md
|
|
891
|
+
//! Implements: Contract Freeze — [component list]
|
|
892
|
+
//!
|
|
893
|
+
//! [2-3 paragraph description of what this module does and how it works]
|
|
894
|
+
//!
|
|
895
|
+
//! # Architecture
|
|
896
|
+
//!
|
|
897
|
+
//! [Optional ASCII art or description of sub-module structure]
|
|
898
|
+
//!
|
|
899
|
+
//! # Dependencies
|
|
900
|
+
//!
|
|
901
|
+
//! - Depends on: [other modules]
|
|
902
|
+
//! - Used by: [other modules]
|
|
903
|
+
//!
|
|
904
|
+
//! # Contract (Frozen)
|
|
905
|
+
//!
|
|
906
|
+
//! - [List of frozen contract rules]
|
|
907
|
+
//! - No implementation logic beyond constructors and field accessors
|
|
908
|
+
```
|
|
909
|
+
|
|
910
|
+
### Public API Docs
|
|
911
|
+
|
|
912
|
+
```rust
|
|
913
|
+
/// Description of what this type/function does.
|
|
914
|
+
///
|
|
915
|
+
/// # Contract (Frozen)
|
|
916
|
+
/// - [Specific contract rules for this type]
|
|
917
|
+
///
|
|
918
|
+
/// # Examples
|
|
919
|
+
/// ```ignore
|
|
920
|
+
/// let policy = ExecutionPolicy::default();
|
|
921
|
+
/// ```
|
|
922
|
+
///
|
|
923
|
+
/// # Errors
|
|
924
|
+
/// - Returns `Error::InvalidConfig` if max_retries > 100
|
|
925
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
926
|
+
pub struct ExecutionPolicy { ... }
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
---
|
|
930
|
+
|
|
931
|
+
## 16. Anti-Patterns — NEVER DO
|
|
932
|
+
|
|
933
|
+
```rust
|
|
934
|
+
// ❌ anyhow in library code
|
|
935
|
+
use anyhow::Result; // BAD — use thiserror
|
|
936
|
+
|
|
937
|
+
// ❌ Blocking in async context
|
|
938
|
+
async fn bad() { let data = std::fs::read_to_string("file"); } // BAD — use tokio::fs
|
|
939
|
+
|
|
940
|
+
// ❌ Unbounded channels
|
|
941
|
+
let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); // BAD — no backpressure
|
|
942
|
+
|
|
943
|
+
// ❌ unwrap/expect in production
|
|
944
|
+
let value = result.unwrap(); // BAD — use ? or proper error handling
|
|
945
|
+
|
|
946
|
+
// ❌ std::sync::Mutex held across .await — blocks the runtime thread
|
|
947
|
+
async fn bad() { let guard = data.lock().unwrap(); tokio::time::sleep(...).await; } // BAD
|
|
948
|
+
|
|
949
|
+
// ❌ Direct field mutation of state
|
|
950
|
+
node.status = NodeStatus::Running; // BAD — use transition methods
|
|
951
|
+
|
|
952
|
+
// ❌ Stringly-typed errors
|
|
953
|
+
Err("something went wrong".into()) // BAD — use typed error enums
|
|
954
|
+
|
|
955
|
+
// ❌ Logging secrets
|
|
956
|
+
info!("API key: {}", secret.expose()); // BAD — Secret::Debug is redacted
|
|
957
|
+
|
|
958
|
+
// ❌ Direct thread::sleep in async
|
|
959
|
+
std::thread::sleep(Duration::from_secs(1)); // BAD — use tokio::time::sleep
|
|
960
|
+
|
|
961
|
+
// ❌ Anemic domain models — entities with only getters/setters and no behavior
|
|
962
|
+
pub struct AnemicEntity { pub id: Uuid, pub name: String } // BAD — no domain logic
|
|
963
|
+
|
|
964
|
+
// ❌ Cross-aggregate references by object ref, not ID
|
|
965
|
+
pub struct Order { pub customer: Customer } // BAD — use customer_id: Uuid
|
|
966
|
+
|
|
967
|
+
// ❌ Infrastructure leak in domain
|
|
968
|
+
use sqlx::PgPool; // BAD — domain NEVER imports infrastructure concerns
|
|
969
|
+
```
|
|
970
|
+
|
|
971
|
+
---
|
|
972
|
+
|
|
973
|
+
## 17. Cargo.toml Conventions
|
|
974
|
+
|
|
975
|
+
```toml
|
|
976
|
+
[package]
|
|
977
|
+
name = "module-name"
|
|
978
|
+
version = "0.1.0"
|
|
979
|
+
edition = "2024"
|
|
980
|
+
description = "One-line description of this crate"
|
|
981
|
+
|
|
982
|
+
[dependencies]
|
|
983
|
+
serde = { version = "1", features = ["derive"] }
|
|
984
|
+
serde_json = "1"
|
|
985
|
+
thiserror = "2"
|
|
986
|
+
tokio = { version = "1", features = ["sync", "time", "macros", "rt"] }
|
|
987
|
+
tokio-util = "0.7"
|
|
988
|
+
uuid = { version = "1", features = ["v4", "serde"] }
|
|
989
|
+
chrono = { version = "0.4", features = ["serde"] }
|
|
990
|
+
tracing = "0.1"
|
|
991
|
+
|
|
992
|
+
[dev-dependencies]
|
|
993
|
+
tempfile = "3"
|
|
994
|
+
proptest = "1"
|
|
995
|
+
|
|
996
|
+
[features]
|
|
997
|
+
live-tests = [] # Flag for tests that hit real APIs
|
|
998
|
+
```
|
|
999
|
+
|
|
1000
|
+
---
|
|
1001
|
+
|
|
1002
|
+
*Version: 1.0.0*
|
|
1003
|
+
*Last updated: 2026-07-03*
|
|
1004
|
+
*Source: Guardian DDD patterns + context7 DDD reference (/jkazama/ddd-java, /ardalis/cleanarchitecture)*
|