~/About~/Foundry~/Blueprint~/Journal~/Projects
Book a Call
Blueprint

Contract Lifecycle Engine

·9 min read·Kingsley Onoh·View on GitHub

Architectural Brief: Contract Lifecycle Engine

Four scheduled jobs, one API, one database, and six feature-flagged ecosystem clients. The binding constraint behind every decision in this system: legal deadlines cannot be approximate. Business-day precision is not a nice-to-have. A payment obligation that fires an alert on a bank holiday is worse than no alert at all. Every architectural choice below serves that precision requirement, or serves the second constraint that falls out of it: the engine must work standalone, before any ecosystem integration is wired.

System Topology

Infrastructure Decisions

  • Language: C# 12 on .NET 8 LTS. Chose over Go, Python, and TypeScript (the portfolio's usual stacks) because the obligation domain is a state-machine problem and C#'s type system catches invalid transitions at compile time. The original PRD pinned .NET 9 / C# 13; host SDK on the build workstation was 8.0.400, so the spec was downgraded on day two rather than fight the toolchain. .NET 8 is supported through November 2026 and is the current GitHub Actions default runner.

  • Framework: ASP.NET Core 8 Minimal APIs. Chose over MVC controllers because the endpoint surface is a flat REST API, not a view-rendering app. Handlers stay thin (validate, call service, format), business logic lives in the Core project. 41 endpoints across 12 groups, each endpoint group is a single MapGroup call in a static class.

  • ORM: Entity Framework Core 8 with Npgsql. Chose over Dapper because the tenant-isolation requirement maps cleanly onto EF's global query filter. Every entity implementing the ITenantScoped marker gets filtered by tenant_id at the DbContext level. Services never write hand-rolled WHERE tenant_id = @id clauses. The one intentional bypass is the tenant-lookup-by-API-key path, which calls IgnoreQueryFilters() because the tenant context is unresolved at that moment (documented in the repository method).

  • Scheduler: Quartz.NET 3.x with the hosted-service extension. Chose over a custom cron loop because four jobs run on different cadences (hourly, every 5 min, daily 6 AM UTC, weekly Monday 9 AM UTC) and Quartz handles misfire recovery, job-history persistence, and graceful shutdown without custom code. Each job has [DisallowConcurrentExecution] so a slow scan cannot overlap with the next trigger.

  • Data Layer: PostgreSQL 16. Chose over SQLite because the business-day calculator needs concurrent reads from the holiday table while the hourly scanner writes to obligation_events, and production traffic includes multiple tenants querying simultaneously. PostgreSQL 16's NULLS NOT DISTINCT unique-index feature is load-bearing. The holiday calendar table has a nullable tenant_id (null means system-wide), and without NULLS NOT DISTINCT two system-wide rows for the same date would both insert.

  • Messaging: NATS.Client 2.x for compliance events. Chose over RabbitMQ because the Compliance Ledger pattern is fire-and-forget audit events, not queued work. The engine publishes three subjects (contract.obligation.breached, contract.renewed, contract.terminated) and does not consume anything. NATS JetStream provides the durability the ledger needs; the publisher itself is a singleton with lazy connect and graceful drain on shutdown.

  • Observability: Serilog + Sentry, both gated on environment. Chose Serilog over Microsoft.Extensions.Logging alone because structured JSON logs with enrichment (request_id, tenant_id, module) make production log search tractable. Sentry wires in via UseSentry() at the WebHost level AND a separate Serilog sink, so both middleware-thrown exceptions and logger-emitted errors surface. The SentryPrivacyFilter PII scrubber lives in the Core project with zero Sentry SDK dependencies. The Program.cs file adapts the SDK types into plain dictionaries before scrubbing.

  • HTTP clients for ecosystem services: IHttpClientFactory with typed clients + shared resilience. Chose over one-off HttpClient instances because four of the five outbound integrations (Notification Hub, Workflow Engine, Invoice Recon, Webhook Downloader) need the same retry + circuit-breaker behavior. The private ConfigureEcosystemResilience helper in ServiceRegistration.cs applies 3-attempt exponential backoff plus a 5-failure circuit breaker with 30-second break. Adding a sixth client is one line: .AddResilienceHandler("new-service", ConfigureEcosystemResilience). Drift between pipelines was the failure mode I was preventing.

Constraints That Shaped the Design

  • Input: REST API calls authenticated via X-API-Key: cle_live_{32_hex}, OR signed-contract webhooks from DocuSign (envelope.completed) / PandaDoc (document_state_changed) with HMAC-SHA256 signatures verified via CryptographicOperations.FixedTimeEquals on byte arrays.
  • Output: Tenant-scoped contract and obligation records, deadline alerts, extraction-job status, and analytics aggregations. Every non-2xx response uses the canonical {error: {code, message, details[], request_id}} envelope.
  • Scale Handled: Mid-market companies tracking 200 to 500 active contracts per tenant, each with an average of 15 to 30 extracted obligations. The hourly deadline scanner iterates every non-terminal obligation across every tenant in one pass, using IgnoreQueryFilters() to go tenant-wide. At 10,000 obligations the PRD Success Criterion is 30-second scan completion, unmeasured in-repo but the single-query load pattern is built for that window.
  • Hard Constraint 1, business-day precision: The BusinessDayCalculator uses per-tenant holiday calendars (US, DE, UK, NL seeded, plus tenant custom) with 24-hour in-memory caching keyed on "holidays::{code}::{year}::{tenantId?}". A deadline computed against the wrong calendar is a legal exposure. An obligation with business_day_calendar = "DE" and grace_period_days = 3 does not transition to overdue on a Saturday.
  • Hard Constraint 2, standalone-first: Every ecosystem integration defaults to _ENABLED=false. The core contract and obligation pipeline runs without RAG, without the Notification Hub, without the Compliance Ledger. No-op stubs are registered at DI time, not at call time, so the service code has no null checks and no feature-flag branching.
  • Hard Constraint 3, event-sourced audit: The obligation_events table is INSERT-only. The repository interface exposes no Update or Delete methods, and a reflection-based test enforces it. A missed obligation becomes a legal liability; reconstructing who changed what, when, and why has to be trivial.

Decision Log -- decisions not covered in Infrastructure Decisions above

Decision Alternative Rejected Why
Extract-then-confirm for AI-extracted obligations (always lands in Pending) Auto-activate on high confidence score A false-positive obligation auto-activated by AI creates a phantom commitment in the audit log. Dismissing a true positive is just noise. False positives are the more expensive failure mode in legal contexts.
Four distinct terminal states (Dismissed, Fulfilled, Waived, Expired) Single Terminal status with a reason field Each terminal value answers a different audit question. Dismissed = AI false positive. Waived = counterparty forgave. Fulfilled = honored. Expired = contract archive cascade. Collapsing them loses semantics; an auditor asking "how many obligations were forgiven?" should not have to parse free-text reasons.
Status changes via PATCH return 409 CONFLICT, not 400 Accept status in PATCH with validation 409 signals "use the dedicated lifecycle endpoint" instead of silently disallowing. Every lifecycle endpoint (confirm, dismiss, fulfill, waive) writes an immutable event row with an actor; PATCH-updating status would bypass the event log. Enforcement lives at the service layer, the HTTP status just routes the caller correctly.
Feature-disabled endpoints return 404, not 403 403 Forbidden with explanation 404 gives a port scanner no hint the endpoint exists. Applies to /api/tenants/register (when SELF_REGISTRATION_ENABLED=false) and /api/webhooks/contract-signed (when WEBHOOK_ENGINE_ENABLED=false). The two failure modes collapse to one response so operators can disable a surface without advertising it.
Fire-and-forget ecosystem dispatch, caught + logged, after DB commit Synchronous dispatch inside the transaction A missed notification must never roll back an obligation transition. The PRD treats the compliance ledger as a trailing audit stream, not an atomic dependency. Every ecosystem emission (Notification Hub, Compliance Ledger, Invoice Recon) lives in a try { } catch (Exception ex) { _logger.LogWarning(...); } block after SaveChanges returns.
Optional constructor parameters for new ecosystem dependencies Breaking constructor signatures when new deps land When Phase 3 added five new DI collaborators, 714 existing tests would have broken if the constructors had changed. Every new dep was added as Type? name = null with a private NullXxx fallback behind null-coalescing. Legacy tests keep compiling; production DI always resolves a real implementation or a registered no-op stub.
Raw-body buffering for HMAC verification, FixedTimeEquals on bytes Parse JSON then re-serialize for hashing The HMAC must be computed over the exact bytes the sender signed. Any reformatting between receipt and hashing breaks verification. The handler calls EnableBuffering() plus Body.Position = 0 before and after reading, then compares signature bytes with CryptographicOperations.FixedTimeEquals to avoid timing-attack leakage on string equality.
Webhook idempotency via JSONB metadata column, probed with EF.Functions.JsonContains Separate idempotency table keyed on external ID Redeliveries are rare and the metadata column was already going to carry the webhook envelope ID for audit. One fewer table, one fewer migration, and the tenant query filter still applies naturally. The trade-off: JSONB containment queries cannot use a standard B-tree index. At current volumes the cost is invisible; at 1M draft contracts per tenant the probe would need a GIN index.
#csharp#dotnet#postgresql#quartz#entity-framework#legal-tech

The complete performance for Contract Lifecycle Engine

Get Notified

New system breakdown? You'll know first.