Skip to content

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.pytests/unit/domain/policy/rules/test_within_envelope.py.

3. Function/class craft rules

  • Typed everything: mypy --strict; no Any outside 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 generic Repository[T] CRUD; read-model queries live behind separate query ports, not on the aggregate repository.
  • Each package's __init__.py deliberately 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) domain forbidden from importing any third-party module; (3) infrastructure.graph may import only application (nodes are thin callers); (4) sibling aggregates in domain/ independent (mandate ↛ budget ↛ payment … — shared kernel only via domain/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 vendor Any behind 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: Money arithmetic, 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/state split; no any without inline justification.

6. Review-rejection checklist (what a worker's PR gets bounced for)

  1. A file with two public concepts, or a name like utils/helpers/common.
  2. A modified engine/router/scorer body where a new rule/rail/term file was the right diff.
  3. An implementation with its own copy of a port's contract tests.
  4. A consumer importing a port method it doesn't call (fat interface leak).
  5. Vendor imports or vendor exceptions outside infrastructure/.
  6. A function over the complexity caps, a boolean behavior flag, or a public setter on an aggregate.
  7. An untyped/Any signature without a justified boundary comment.
  8. A test asserting implementation details (mock call counts on owned code) instead of observable behavior through ports.
  9. A pragma (coverage, ruff, mypy) without an inline justification.
  10. State or money logic in a LangGraph node body.