ProcureGraph — Code Standards (SOLID, enforced) — rev 2¶
Every rule here is either machine-enforced (ruff / mypy / import-linter / pytest) or a named review-rejection reason. Aspirations that can't be enforced don't belong in this file. Workers: code that violates this document is rejected and refactored — "it works" is not the bar.
The quality metric — memorize this, not a file count: every function, class, module, package, and process has one coherent responsibility and one clear reason to change; every external capability is behind a narrow contract; every financial invariant is enforced at both the domain and persistence boundaries; every architectural promise is executable in CI. Maximum quality does NOT mean maximum classes/files/patterns — splitting one responsibility across six microscopic files destroys cohesion as surely as a 2,000-line god file. The file test: can you state the file's purpose in one precise sentence? If unrelated business changes repeatedly touch the same file, split it; if one change repeatedly touches six files, merge them.
1. SOLID → concrete rules → enforcement¶
| Principle | Rule in THIS codebase | Enforced by |
|---|---|---|
| SRP | One reason to change per unit, at every granularity: a function does one thing (cyclomatic ≤ 8, ≤ 40 statements); a class is one concept; a file holds one public concept (one class, or one cohesive family of tiny value objects); a package is one aggregate/bounded concept. No utils.py, no helpers.py, no misc.py — a name you can't make specific is a responsibility you haven't found yet. |
ruff C901, PLR0912/0915; review checklist §6 |
| OCP | Extension points are files-added, never files-modified: new policy rule = new file in domain/policy/rules/ registered in the composition root; new rail = new adapter behind PaymentRailPort; new provider = seed data; new LLM role = prompt file + schema. The policy engine, rail router, and scorer bodies do not change when their catalogs grow. |
review checklist; diff shape ("why did engine.py change?") |
| LSP | Every port with >1 implementation has ONE shared contract-test suite that all implementations must pass unmodified: Rain mock vs future sandbox adapter; the three facilitator modes; fake repos vs SQL repos; fake clock vs real. An implementation that needs its own version of the contract tests is a substitution violation. | shared pytest contract suites (design/06) |
| ISP | Ports are consumer-driven: an interface exists per consumer need, not per vendor surface (§4). No consumer depends on a method it never calls. Fat protocol = rejected. | review checklist; mypy (unused-protocol members visible in fakes) |
| DIP | domain/ imports stdlib only. application/ imports domain only. infrastructure/ implements application ports. interfaces/ calls use cases. Only composition.py constructs concrete adapters. No import sqlalchemy/langgraph/openai outside infrastructure/. |
import-linter contracts (§5) — CI-blocking |
2. Domain layout — many small files, one concept each¶
domain/
├── shared/
│ ├── money.py # Money VO (int minor units; refuses cross-currency)
│ ├── quality.py # BasisPoints VO (0–10000)
│ ├── ids.py # MissionId, TaskId, PaymentId, AgentId, ProviderId
│ ├── clock.py # Clock protocol
│ ├── events.py # DomainEvent base + event registry types
│ └── errors.py # DomainError root (never raise bare Exception)
├── mandate/
│ ├── mandate.py # Mandate aggregate root
│ ├── status.py # MandateStatus StrEnum + allowed transitions
│ ├── evidence_plan.py # EvidencePlan VO + decide_evidence_plan pure logic
│ ├── events.py # MandateCompiled, PoolsFunded, …
│ └── errors.py # OverAllocation, InvalidMandate, …
├── budget/
│ ├── pool.py # BudgetPool aggregate root (reserve/commit/release/transfer)
│ ├── account.py # Account entity + AccountKind
│ ├── journal.py # JournalEntry + balanced-legs invariant
│ ├── postings.py # leg table per reason (design/02 §2) as data, not ifs
│ ├── events.py
│ └── errors.py # InsufficientFunds, UnbalancedJournal, …
├── payment/
│ ├── proposal.py # PaymentProposal aggregate root
│ ├── states.py # ProposalState StrEnum (lowercase, mirrors DB CHECK)
│ ├── transitions.py # allowed-transitions table + transition() guard
│ ├── attempt.py # PaymentAttempt entity + AttemptStatus (incl. settlement_unknown)
│ ├── card_scope.py # CardScope VO
│ ├── events.py
│ └── errors.py # InvalidTransition, AttemptConflict, …
├── policy/
│ ├── engine.py # PolicyEngine: combines rules → Verdict (no rule logic here)
│ ├── verdict.py # Verdict, RuleResult, Severity
│ ├── escalation.py # engine-level escalate triggers (flags, soft thresholds)
│ └── rules/ # ONE FILE PER RULE — OCP extension point
│ ├── merchant_allowlisted.py
│ ├── within_envelope.py
│ ├── pool_has_funds.py
│ ├── outside_mandate_scope.py
│ ├── no_recurring.py
│ ├── card_scope_valid.py
│ ├── agent_authority_active.py
│ └── mandate_complete.py
├── evidence/
│ ├── ledger.py # append/verify_chain domain service
│ ├── hashing.py # canonical_json + hash input (design/02) — pure, tested
│ ├── envelope.py # event envelope VO (type, schema_version, causation_id)
│ └── errors.py
├── scoring/
│ ├── scorer.py # floor-reject → risk_adjusted_cost; returns term breakdown
│ ├── terms.py # one dataclass per cost term, provenance-labeled
│ ├── evidence_class.py # measured_production | measured_trial | provider_claim
│ └── policy_version.py # scoring-policy config id + hash
└── agents/
├── definition.py # AgentDefinition aggregate root (no spend authority)
├── prompt_version.py # PromptVersion entity (immutable)
├── status.py # AgentStatus + transitions
├── grant.py # SpendGrant entity + effective-grant precedence
├── events.py
└── errors.py
Additions folded from the standards review: payment/credential.py (issued-card
credential entity, distinct from card_scope.py). File-naming rule: files are named for
the concept (proposal.py), never the pattern (aggregate.py) — six files all named
aggregate.py defeats navigation.
Application layer is organized by capability, not a flat use_cases/ — and there is
NO global dto.py (a god file waiting to happen):
application/
├── procurement/ # compile_mandate.py, discover_providers.py, plan_evidence.py,
│ # run_audition.py, assess_quorum.py, select_provider.py
├── payment/ # authorize_and_reserve.py, prepare_rail.py, execute_payment.py,
│ # confirm_settlement.py, reconcile_rail_status.py, compensate.py
├── verification/ # verify_outcome.py, remediate_outcome.py, reconcile.py
├── agents/ # create_agent.py, generate_prompt_version.py,
│ # activate_prompt_version.py, freeze_agent.py
├── ports/ # one protocol per file (§4)
└── common/ # shared command/result base types only — reviewed jealously
Each use-case module holds its command, result, and handler together while they form one cohesive unit; split further only when independently complex. DTOs live beside the use case that owns them.
infrastructure/: one adapter concern per file (rails/rain/ splits client, mapper,
webhook verifier, simulator). Tests mirror source paths one-to-one:
domain/policy/rules/within_envelope.py →
tests/unit/domain/policy/rules/test_within_envelope.py.
3. Function/class craft rules¶
- Typed everything:
mypy --strict; noAnyoutside adapter boundaries (each one justified inline); no implicit Optional. - Errors are typed per package and inherit
DomainError; adapters translate vendor exceptions at the boundary — vendor exception types never cross into application. - Aggregates expose intent methods (
pool.reserve(amount, key)), never public setters; invariants live in the aggregate, not in callers. - Value objects are frozen dataclasses; entities compare by id.
- Pure functions preferred for logic (scorer, hashing, transitions, postings) — side-effects live in use cases behind ports.
- Docstrings required on every port protocol, aggregate root, and use case (one line: responsibility + invariant); not required on self-evident privates.
- No boolean-flag parameters that switch behavior (use enums or separate commands); no default mutable args; no module-level state except frozen constants.
- Typed results for expected failures, exceptions for exceptional ones: an audition
timing out returns
AuditionResult.Failure; a broken invariant raises. Expected failure is data the caller must handle; exceptional failure is a bug or outage. - Explicit providers for every non-determinism source:
Clock,IdGenerator, randomness — injected, never called ad hoc (datetime.now()/uuid4()in domain code is a review rejection). - One abstraction level per function; composition over inheritance (inheritance only for the typed error hierarchies and Protocol satisfaction).
- External payloads cross the boundary through anti-corruption mappers (one mapper file per adapter); vendor field names never leak into domain vocabulary.
- Repositories are aggregate-oriented with intent methods (
proposals.get_for_update,pools.with_accounts_locked) — no genericRepository[T]CRUD; read-model queries live behind separate query ports, not on the aggregate repository. - Each package's
__init__.pydeliberately exports its public API; importing a package's internal module from outside it is a boundary violation.
4. Interface segregation — consumer-driven ports (normative)¶
The vendor surface is not the interface. Ports are named for the consumer's need:
| Consumer | Port (one file each) | Methods |
|---|---|---|
| Purchase saga (prepare_rail) | CardIssuancePort |
issue_card(request) -> Card |
| Purchase saga / compensate / retire | CardLifecyclePort |
freeze_card, retire_card |
| reconcile_rail_status / reconciliation | CardTransactionsQueryPort |
list_transactions(card_id, status) |
| Webhook receiver | RainWebhookVerifierPort |
verify(headers, body) -> WebhookEvent |
| Demo merchant mock only | MerchantAuthSimulatorPort |
simulate_authorization(...) (never implemented by the real adapter) |
| Benchmark/purchase via x402 | X402PaymentPort |
pay(request, idempotency_key) -> Receipt |
| reconcile_rail_status (x402) | X402StatusPort |
query_settlement(idempotency_key) |
| compile_mandate | MandateCompilerPort |
compile(nl_request) -> CompiledMandateDTO |
| scout normalize | ProviderDocNormalizerPort |
normalize(docs) -> candidates |
| agent registry | PromptGeneratorPort |
generate(spec) -> CandidateSections |
| everywhere | Clock, UnitOfWork, EventPublisher |
(cohesive single concepts — not split) |
One infrastructure class MAY implement several small ports (RainAdapter implements the
three Rain ports) — segregation is about what consumers depend on, not about class
count. The mock and the future sandbox adapter implement the same port set and pass the
same contract suite (LSP).
5. Machine enforcement (from Task 0.1, never "later")¶
- import-linter contracts: (1) layers: interfaces → application → domain,
infrastructure → application → domain; (2)
domainforbidden from importing any third-party module; (3)infrastructure.graphmay import onlyapplication(nodes are thin callers); (4) sibling aggregates indomain/independent (mandate ↛ budget ↛ payment … — shared kernel only viadomain/shared). - ruff: full default set +
C901(complexity ≤ 8),PLR0912/0913/0915(branches ≤ 12, args ≤ 5, statements ≤ 40),ANN(public annotations),ERA(no dead code),TID(no relative-parent imports crossing packages). Per-file ignores require an inline justification comment — same policy as coverage pragmas. - mypy --strict on
src/; adapters may isolate vendorAnybehind typed façades. - pytest structure check: a tiny CI script asserts tests mirror source paths.
- Property-based tests (Hypothesis) — required on the financial invariants where
example-based tests are weakest:
Moneyarithmetic, journal balance (any generated leg set either balances or raises), state-machine transitions (no generated sequence reaches an illegal state), hash-chain integrity (any mutation breaks verification). - pip-audit in CI (dependency vulnerability gate) alongside the lockfile audit.
- Mutation testing — bounded: run on
domain/policy/,domain/budget/,domain/payment/only, time-boxed as a Friday-hardening item (Task 6.x), not a per-commit gate — surviving mutants in money code are findings; a slow mutation run blocking the freeze is not. - Frontend equivalents: eslint strict +
tsc --noEmit; one component per file; hooks/lib/statesplit; noanywithout inline justification.
6. Review-rejection checklist (what a worker's PR gets bounced for)¶
- A file with two public concepts, or a name like
utils/helpers/common. - A modified engine/router/scorer body where a new rule/rail/term file was the right diff.
- An implementation with its own copy of a port's contract tests.
- A consumer importing a port method it doesn't call (fat interface leak).
- Vendor imports or vendor exceptions outside
infrastructure/. - A function over the complexity caps, a boolean behavior flag, or a public setter on an aggregate.
- An untyped/
Anysignature without a justified boundary comment. - A test asserting implementation details (mock call counts on owned code) instead of observable behavior through ports.
- A pragma (coverage, ruff, mypy) without an inline justification.
- State or money logic in a LangGraph node body.