Technology stack
Part of the Écluse architecture overview.
| Concern | Choice | Rationale |
|---|---|---|
| Language | Haskell (GHC 9.10) | Type safety, strong concurrency, suits the rule engine. |
| Prelude | relude |
Safer defaults: Text over String, partial
functions hidden. Wired as the implicit prelude via cabal mixins (see
below). |
| Effect style | ReaderT Env IO (+ unliftio) |
The orchestration monad; handlers read a per-request
RequestCtx (see key
decisions). Shared mutable state is TVars, never
StateT. |
| HTTP server | warp + wai (+ wai-extra) |
Raw WAI routing, not a framework; wai-extra supplies
size limits, real-IP, and timeouts. See Web layer. |
| HTTP client | http-client + http-client-tls |
The data plane: streams artifacts and fetches metadata, including
the managed-registry npm endpoints. Kept off amazonka's
ResourceT path. |
| JSON | aeson |
Metadata parsing (lenient inbound decoding), rule config, queue payloads, denial bodies. |
| API manifest / schemas | autodocodec + openapi3 |
Owned types derive their aeson codec and the OpenAPI /
JSON Schema from one codec, so the schema cannot drift from the wire.
See Capability
manifest. |
| Observability | hs-opentelemetry (OTLP) |
Traces and metrics over OTLP, opt-in and off by default. Package roles in Observability. |
| Cloud, AWS | amazonka |
Split packages: amazonka-sqs (queue),
amazonka-codeartifact (registry token),
amazonka-sts (workload identity), amazonka-s3
(advisory object storage), amazonka-core. |
| Cloud, GCP | gogol or a REST client (roadmap) |
Pub/Sub queue and Artifact Registry token, gated on a spike; see Cloud backends. |
| Logging | katip |
Structured JSON logging; package, version, and rule context attach to every denial event. |
| Config | Data.Yaml |
A YAML document with ECLUSE_* env overrides deep-merged
into an AST. Precedence: embedded defaults < config document <
environment, with validation aggregated into one error. |
| Caching | cache |
STM-backed TTL cache for the short-TTL packument metadata. (Advisory data is a synced in-memory index, not a TTL cache; see CVE subsystem.) |
| Concurrency | async + stm |
Non-blocking mirror enqueue; shared cache and state. |
| Time | time |
AllowIfOlderThan age calculations. |
| Unit tests | hspec (+ hspec-wai) |
hspec-wai drives the proxy Application
end-to-end. |
| Property tests | hedgehog (+ hspec-hedgehog) |
Integrated shrinking, used heavily against the pure rules engine. |
| Integration tests | testcontainers |
Ephemeral Docker containers from the test suite (lifecycle and readiness), GHC 9.10-compatible. |
| Cloud emulation (tests) | ministack · Pub/Sub emulator |
AWS via ministack (image
ministackorg/ministack, port 4566); GCP via the official
Pub/Sub emulator. No real cloud or credentials. |
| Dev environment | Nix flakes + direnv |
Fully reproducible; all tooling from nix develop. |
| Build | Cabal | Natural Nix pairing; flake.lock provides
reproducibility. |
Key decisions
relude as the implicit prelude. Wired
through cabal mixins in the shared common stanza, it
replaces the default prelude without a per-module
import Relude. That rules out
-Wunused-packages (GHC cannot attribute prelude usage
through the mixin rename), so the flag is omitted and
weeder is the dependency-hygiene substitute.
Raw WAI, not a web framework. A proxy is a
passthrough over an irregular URL surface (URL-encoded slashes, reserved
meta-routes), and memory-bounded artifact streaming needs direct control
over the response body's lifetime; both point at a raw
Application rather than servant or Yesod. Full rationale in
Web layer.
The effect model. App = ReaderT Env IO
(with unliftio) is the orchestration monad; the server,
worker, and request handlers read through it. Handlers run over a
per-request
RequestCtx { ctxRuntime :: ServeRuntime, ctxMount :: MountBinding },
built once at dispatch so per-mount deps are read from context rather
than re-threaded. The handle records MirrorPublish,
MirrorQueue, and CredentialProvider return
IO, not App, so each adapter closes over its
own backend state and never imports the core's Env /
App, keeping backends decoupled and import-cycle-free.
Shared mutable state lives as TVar / IORef in
the composition-root Env, never a StateT
layer, which would lose state across async.
Capability manifest, not a client contract. Écluse
speaks registry protocols, not a bespoke API, so its OpenAPI document is
a capability manifest, generated from the closed Route
enumeration and the configured mounts. Owned responses are modelled
code-first via autodocodec so the schema cannot drift from
the wire. Full rationale in Capability
manifest.