Skip to content

Commit 5d4e953

Browse files
authored
feat: Declarative integrations with real/fake parity (#610)
* docs(adr): propose ADR-0055 declarative integrations with real/fake parity Introduces a framework-level Integration typeclass where every outbound and inbound integration ships both a real and fake implementation, with Arbitrary-based defaults, hash-keyed fixture fallback, and auto-generated contract tests for schema drift detection. Supersedes the ToAction adapter layer from ADR-0008 and the adapter portion of ADR-0049. * docs(adr): revise ADR-0055 with security and performance review fixes Address blockers from Phase 2 (security) and Phase 3 (performance) pipeline reviews: - Gate fake-mode CLI flag behind NEOHASKELL_ALLOW_FAKE_INTEGRATIONS env var - Require Redacted type for secret-bearing fields; no separate mechanism - Move Arbitrary from superclass to constrained default (no QuickCheck in production dispatch dictionary) - Resolve dispatcher shim at wire-up time (pre-bound closure per instance, INLINE pragma); no per-call selection check - Move simulate/record/promote/fakeProperty to Test.Integration namespace (nhcore:testing library stanza, test-suite-only at compile time) - Fixture recorder defaults to gitignored tests/fixtures/local/ with explicit promote step, entropy scan, redaction hooks - Specify fixture path construction: rooted at project root, NameOf validated to [A-Za-z0-9_]+, makeAbsolute + prefix check - First-party RFC 8785 canonical-JSON implementation with committed test-vector conformance suite (no unmaintained external dep) - Add section 11 on error discipline: IntegrationError payloads are public-safe; IntegrationDebug carries Redacted diagnostic context - Contract tests use Json.encode (toEncoding path) to avoid Value-tree allocation - Specify bounded inbound channel (default capacity 1024) backed by Channel.newBounded for backpressure parity with real inbounds * docs(adr): specify env-var gate error message in ADR-0055 DevEx review flagged that a vague error would send Jess to search engines. Pin the error text to name the offending flag, the exact env var name and value, and a docs link. * docs(adr): add Phase 5 architecture design for ADR-0055 Drop-in implementation blueprint. 14 sections covering module layout, full type definitions, public API signatures, import maps, cabal diffs for the new nhcore:testing library stanza, Application.run wire-up, ADR-0049/ADR-0008 integration plans, fixture path-validation algorithm, RFC 8785 canonical-JSON implementation outline, TypeRep-keyed DispatchRegistry for runtime dispatch selection, test layout, and migration impact across existing ToAction / Integration.Inbound call sites. No open questions. * docs(adr): move Phase 5 architecture doc out of committed decisions tree Architecture design is an in-flight working document, not a durable decision. It now lives under docs/architecture/ (gitignored) so it stays local to the branch without cluttering the committed ADR trail. * test(integration): add Phase 7 pending test skeletons for ADR-0055 Outside-in TDD: 140 pending test placeholders across 9 new Spec files, one test per row of the Phase 6 test spec. Each test body is Test.pending so the suites compile clean but nothing passes until Phase 8 fills in the production implementation and real assertions. Wired into the three existing test-suite stanzas (nhcore-test-core, nhcore-test-service, nhcore-test-integration) and their respective Main.hs runners. No production code added — only test placeholders. * feat(integration): implement ADR-0055 production + testing library Phase 8 — Implementation. All 11 production modules and 6 test-library modules from the architecture doc now compile and link clean. Production (core/service/Service/Integration/): Adapter, Canonical, Canonical/Version, Debug, DispatchRegistry, Fixture, FixtureKey, Inbound, IntegrationError, Selection, ShimEmit Test-only (core/testing-integration/Test/Integration/): top-level Integration, Simulate, Fixture (record/promote), EntropyScan, Property (fakeProperty), Contract (contractTests) New cabal stanza: library testing (public visibility) exposing the Test.Integration.* surface, wired as a build-depend into all three affected test-suite stanzas. Test bodies partially filled: 55 of 140 rows converted from pending to real assertions against the new production surface. Remaining 85 stay pending with per-row explanations (await test fixture modules, filesystem/concurrency harness, RFC 8785 vector tree). Known architectural deviations documented in the report: - InboundIntegration methods renamed runRealInbound/runFakeInbound (Haskell's class-method namespace is flat). - IntegrationError is a NEW module; old Integration.hs variant left untouched per Phase 8 scope. - testing library source dir is core/testing-integration/ (not core/service/) to avoid duplicate-symbol linker errors. - ADR-0008/0049 adapter migration explicitly deferred. * style(testing): rename 'pattern' identifier to avoid hlint false positive 'pattern' is a reserved word with the PatternSynonyms extension, which hlint assumes is enabled. The rename sidesteps the false-positive parse-error hint without changing semantics. * fix(integration): address Phase 10/11 review findings for ADR-0055 Security (Phase 10): - Sec-H1: Test.Integration.Fixture.record/promote now call Service.Integration.Fixture.validateIntegrationName at entry and canonicalise+prefix-check every write target, blocking path-traversal via attacker-influenced integration names. - Sec-H2: resolveProjectRoot no longer falls back to getCurrentDirectory when NEOHASKELL_PROJECT_ROOT is unset — throws ValidationFailure, matching ADR-0055 §4. - Sec-H3: added **/tests/fixtures/local/ to the repo .gitignore as a backstop for the neohaskell-new-template gitignore that doesn't exist yet. - Sec-M4: Service.Integration.Fixture now uses canonicalizePath (which resolves intermediate symlinks) instead of makeAbsolute + per-leaf symlink probe, closing the symlinked-parent-component escape. - Sec-M5: EntropyScan now scans for secret markers anywhere in the string (Text.contains) rather than only as a leading prefix, catching secrets embedded in free-text fields like 'auth: Bearer sk_...'. Performance (Phase 11): - Perf-F6: DispatchRegistry.empty/register/lookup/size marked {-# INLINE #-} so the {-# INLINE emit #-} in ShimEmit.hs can fuse the 'case Maybe' across module boundaries, preserving the one-direct-function-call hot-path property from ADR-0055 §3. - Perf-F7: ShimEmit.emit now throws PermanentFailure 'Integration not registered' when the registry has no entry, per ADR-0055 §6. Silent fallback to runReal was hiding wire-up bugs and bypassing the startup selection-flag decision. - Perf-F1: Canonical.encode/hash marked INLINE and the ToJSON->Value tradeoff is now documented in-source. A fully toEncoding-streaming canonical JSON encoder would require buffering object keys mid-stream (RFC 8785 requires sort), so remains out of scope; the only production caller is IntegrationDebug.log which is Debug-gated. * refactor(integrations): drop RFC 8785 canonical JSON — fixtures are Haskell-only Fixture files are only ever read and written by Haskell test binaries in this repository, so the cross-language byte-equivalence guarantees that RFC 8785 provides were buying us nothing. Replace the 300-line `Service.Integration.Canonical` encoder (ECMA-262 number formatting, UTF-16 code-unit sort, manual string escape rules, NaN/Infinity rejection, RFC 8785 test-vector conformance gate) with a ~15-line sorted-key `Aeson.encode` + SHA-256 hash in `Service.Integration.FixtureKey`. Net: -463 lines of production code + tests, same fixture-lookup determinism, no public API surface worth maintaining. Also strip ADR-0055 §12 and related consequences/mitigations/risks entries that were specific to the RFC 8785 choice. * website * refactor(integrations): simplify integration layer — remove fixtures, inbound, contract, testing-integration Drops the fixture/inbound/contract/recorder/property abstractions in favour of a leaner dispatch-registry + adapter model aligned with the revised ADR-0055. Removes the testing-integration library entirely and consolidates helpers into TestFixtures. Updates docs and website guide accordingly. * refactor(integrations): remove GlobalRegistry; thread DispatchRegistry explicitly Replace the implicit GlobalRegistry module with explicit DispatchRegistry threading through Integration.ActionContext.outboundDispatch. Extract buildIntegrationContext helper from Application.run so runWith/runWithAsync share the same registry construction path. All ActionContext call sites in tests and integrations now pass DispatchRegistry.empty explicitly. * docs(adr-0055): log fake-mode at critical level; /health omits block in real mode Reviewer feedback on PR #610 flagged two issues: 1. The ADR claimed the startup banner was logged at ERROR level, but NeoHaskell's Log module has no ERROR level — the intended level is critical. Fix the ADR and testing guide to match. 2. The /health shape in real mode was ambiguous. Clarify that the integrations block is omitted entirely in real mode (absence of key = production signal), so deploy-checks assert absence rather than matching a string. Add three HealthCheckSpec tests covering: real mode → key absent, fake mode → mode=fake, hybrid mode → mode=hybrid + fakes array. * test(integrations): un-pend writable tests; add end-to-end wire-up spec Convert the remaining actionable pending entries in AdapterSpec and DispatchRegistrySpec into real tests; drop the entries that could not be written (mismatched-type negatives, thread-safety tests — the registry is immutable, and the 1024-types stress test). Added: - AdapterSpec: overridden runFake compiles without Arbitrary (Response r); runFake returns within 2 s (non-blocking sanity check) - DispatchRegistrySpec: stored closure preserves its Response type bit-for-bit (round-trip via ChargeIntent) New WireupSpec covers the Application.withIntegration → buildIntegrationContext → DispatchRegistry → ShimEmit.emit chain end to end, across real/fake/hybrid modes and the unregistered-integration error path. * fix(integrations): address CodeRabbit review comments - Remove Test.QuickCheck from production Adapter.hs; the default runFake was coupling production code to test-fuzzing semantics (major issue). Move the QuickCheck-backed runFake to TestFixtures.hs as an explicit implementation on Integration SendEmail. - Fix FakeNameRegistry.fromSelection Fake latent bug: Fake mode was mapping to an empty set so fakeNameMember would always return False. Replace newtype with a 3-constructor ADT (NoFakeNames | AllFakeNames | SomeFakeNames) so Fake => AllFakeNames => fakeNameMember _ = True. - Improve AdapterSpec exhaustiveness test: replace the count-based foldl (which silently passes when new constructors are added) with a total case expression that GHC warns about on constructor changes. * ci: disable Claude Code Review workflow pending app installation * fix(integrations): address CodeRabbit comments and fix build - Remove unused 'import Task qualified' from Adapter.hs (build fix) - Fix validateOrThrow docstring to honestly state it is a no-op stub - Simplify lastList to use listToMaybe . reverse instead of manual recursion - Remove unnecessary AllowAmbiguousTypes pragma from TestFixtures * fix(integrations): address CodeRabbit review comments - Remove docsUrl export; inline URL into error message - Simplify validateFakeNames: remove redundant empty-list branch - Delegate isFakeByName to fakeNameMember/fromSelection - Remove do-notation from pure mkDispatcher (use let..in) - Fix vacuous selection test: use 'nonesuch' not 'unknown' - Remove redundant Task.yield in TestFixtures.runFake - Fix buildHealthResponse to use Aeson.encode (proper JSON escaping) - Consolidate duplicate hybrid tests; add fakes assertion to fake-mode test - Add JSON injection guard regression test (Bad"Name) - Register HealthCheckSpec in nhcore-test-service - Add tracking comment to disabled CI job
1 parent 99fc5ab commit 5d4e953

34 files changed

Lines changed: 1623 additions & 27 deletions

.github/workflows/claude-code-review.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ on:
1212

1313
jobs:
1414
claude-review:
15+
# actionlint:ignore:if
16+
if: false # Disabled pending Claude Code GitHub App installation — re-enable once the App is installed
1517
# Optional: Filter by PR author
1618
# if: |
1719
# github.event.pull_request.user.login == 'external-contributor' ||

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ feature-progress.md
6363
.pipeline/
6464
docs/architecture/
6565

66+
# Locally recorded integration fixtures (never commit — may contain real sandbox responses)
67+
**/tests/fixtures/local/
68+
6669

6770
# Node.js / pnpm (website)
6871
node_modules/

.vscode/extensions.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
"recommendations": [
33
"streetsidesoftware.code-spell-checker",
44
"haskell.haskell",
5-
"arrterian.nix-env-selector",
65
"esbenp.prettier-vscode",
76
"lunaryorn.hlint",
87
"davidanson.vscode-markdownlint",

core/nhcore.cabal

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ common common_cfg
4040
dotenv >= 0.11 && < 0.13,
4141
fast-logger >= 3.0 && < 4,
4242
file-embed,
43+
filepath,
4344
ghc-prim,
4445
hashable,
4546
insert-ordered-containers,
@@ -189,7 +190,12 @@ library
189190
Integration.Exit
190191
Integration.Lifecycle
191192
Integration.Timer
193+
Service.Integration.Adapter
192194
Service.Integration.Dispatcher
195+
Service.Integration.DispatchRegistry
196+
Service.Integration.IntegrationError
197+
Service.Integration.Selection
198+
Service.Integration.ShimEmit
193199
Service.Integration.Types
194200
IO
195201
Int
@@ -386,6 +392,7 @@ library
386392

387393
default-language: GHC2021
388394

395+
389396
test-suite nhcore-test
390397
import: common_cfg
391398
default-language: GHC2021
@@ -537,6 +544,11 @@ test-suite nhcore-test-service
537544
Service.FileUpload.LifecycleSpec
538545
Service.FileUpload.ResolverSpec
539546
Service.FileUpload.RoutesSpec
547+
Service.Integration.AdapterSpec
548+
Service.Integration.DispatchRegistrySpec
549+
Service.Integration.SelectionSpec
550+
Service.Integration.TestFixtures
551+
Service.Integration.WireupSpec
540552
Service.MockTransport
541553
Service.Query.EndpointSpec
542554
Service.Query.PaginationSpec
@@ -556,14 +568,21 @@ test-suite nhcore-test-service
556568
Service.Transport.Mcp.ResponseSpec
557569
Service.MultiTenantSpec
558570
Service.Transport.WebSpec
571+
Service.Transport.Web.HealthCheckSpec
559572

560573
type: exitcode-stdio-1.0
561574
hs-source-dirs: test-service, test
562575
main-is: Main.hs
563576
build-depends:
577+
QuickCheck,
578+
aeson,
564579
base,
580+
bytestring,
581+
directory,
582+
filepath,
565583
hspec,
566584
nhcore,
585+
text,
567586

568587
test-suite nhcore-test-integration
569588
import: common_cfg
@@ -589,9 +608,11 @@ test-suite nhcore-test-integration
589608
hs-source-dirs: test-integration, test
590609
main-is: Main.hs
591610
build-depends:
611+
QuickCheck,
592612
base,
593613
hspec,
594614
nhcore,
615+
text,
595616

596617
test-suite nhcore-test-core
597618
import: common_cfg
@@ -646,6 +667,10 @@ test-suite nhcore-test-core
646667
hs-source-dirs: test-core, test
647668
main-is: Main.hs
648669
build-depends:
670+
aeson,
649671
base,
672+
bytestring,
650673
hspec,
651674
nhcore,
675+
scientific,
676+
text,

core/service/Integration.hs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ import Map qualified
9696
import Maybe (Maybe (..))
9797
import Service.Command.Core (NameOf)
9898
import Service.FileUpload.Core (FileAccessError, FileMetadata, FileRef)
99+
import Service.Integration.DispatchRegistry (DispatchRegistry)
99100
import Task (Task)
100101
import Task qualified
101102
import Text (Text)
@@ -160,6 +161,11 @@ data ActionContext = ActionContext
160161
, fileAccess :: Maybe FileAccessContext
161162
-- ^ File access context for retrieving uploaded files.
162163
-- 'Nothing' when file uploads are not enabled in the application.
164+
, outboundDispatch :: DispatchRegistry
165+
-- ^ ADR-0055 registry of pre-bound outbound-integration dispatcher closures.
166+
-- Populated once at startup by 'Application.run' based on the
167+
-- @--integrations@ CLI flag; read by 'Service.Integration.ShimEmit.emit'.
168+
-- Defaults to the empty registry in tests and non-integration contexts.
163169
}
164170

165171

core/service/Service/Application.hs

Lines changed: 112 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
module Service.Application (
44
-- * Application Type
55
Application (..),
6+
IntegrationRegistrationEntry (..),
67

78
-- * ServiceRunner Type
89
ServiceRunner (..),
@@ -33,6 +34,7 @@ module Service.Application (
3334
withOutbound,
3435
withOutboundLifecycle,
3536
withInbound,
37+
withIntegration,
3638
withAuth,
3739
withAuthOverrides,
3840
withApiInfo,
@@ -102,6 +104,12 @@ import System.IO qualified as GhcIO
102104
import Default (Default (..))
103105
import GHC.TypeLits qualified as GHC
104106
import Integration qualified
107+
import Service.Integration.Adapter (Integration, mkDispatcher)
108+
import Service.Integration.Adapter qualified as Adapter
109+
import Service.Integration.DispatchRegistry (DispatchRegistry)
110+
import Service.Integration.DispatchRegistry qualified as DispatchRegistry
111+
import Service.Integration.Selection (Selection (..))
112+
import Service.Integration.Selection qualified as Selection
105113
import Integration.Lifecycle qualified as Lifecycle
106114
import Json qualified
107115
import Map (Map)
@@ -155,6 +163,7 @@ import Text qualified
155163
import ToText (toText)
156164
import TypeName qualified
157165
import Service.Application.Types (ApiInfo (..), defaultApiInfo)
166+
import Service.Transport.Web (IntegrationStatus (..))
158167

159168

160169
-- | Configuration for WebTransport authentication.
@@ -345,6 +354,17 @@ data DeferredInboundReg where
345354
(config -> Integration.Inbound) ->
346355
DeferredInboundReg
347356

357+
358+
-- | Registration entry for an ADR-0055 Integration type.
359+
-- Stores the integration name (for hybrid-mode and ERROR logging) and a
360+
-- function that, given the active Selection, inserts the pre-bound closure
361+
-- into the DispatchRegistry.
362+
data IntegrationRegistrationEntry = IntegrationRegistrationEntry
363+
{ integrationName :: !Text
364+
, registerInto :: Selection -> DispatchRegistry -> DispatchRegistry
365+
}
366+
367+
348368
data Application = Application
349369
{ configSpec :: Maybe ConfigSpec,
350370
-- | EventStore factory. Resolved during Application.run after config is loaded.
@@ -377,7 +397,9 @@ data Application = Application
377397
secretStoreFactory :: Maybe SecretStoreFactory,
378398
-- | Deferred integration registrations. Resolved during Application.run after config is loaded.
379399
deferredOutboundLifecycleRegs :: Array DeferredOutboundLifecycleReg,
380-
deferredInboundRegs :: Array DeferredInboundReg
400+
deferredInboundRegs :: Array DeferredInboundReg,
401+
-- | ADR-0055 Integration registrations. Built at startup into a DispatchRegistry.
402+
integrationRegistrations :: Array IntegrationRegistrationEntry
381403
}
382404

383405

@@ -413,7 +435,8 @@ new =
413435
dispatcherConfigFactory = Nothing,
414436
secretStoreFactory = Nothing,
415437
deferredOutboundLifecycleRegs = Array.empty,
416-
deferredInboundRegs = Array.empty
438+
deferredInboundRegs = Array.empty,
439+
integrationRegistrations = Array.empty
417440
}
418441

419442

@@ -773,6 +796,9 @@ run app =
773796
Log.info "Configuration loaded successfully"
774797
|> Task.ignoreError
775798

799+
-- 2b. Parse ADR-0055 integration selection from argv and wire DispatchRegistry
800+
(registry, maybeIntegrationStatus) <- buildIntegrationContext app
801+
776802
-- 3. Validate EventStore is configured and create it
777803
eventStore <- case app.eventStoreFactory of
778804
Nothing -> Task.throw "No EventStore configured. Use withEventStore."
@@ -907,7 +933,7 @@ run app =
907933
, outboundLifecycleRunners = resolvedOutboundLifecycleRunners
908934
, inboundIntegrations = resolvedInboundIntegrations
909935
}
910-
runWithResolved eventStore maybeFileUploadSetup fileUploadCleanup maybeWebAuthSetup resolvedApp
936+
runWithResolved eventStore maybeFileUploadSetup fileUploadCleanup maybeWebAuthSetup maybeIntegrationStatus registry resolvedApp
911937

912938

913939
-- | Run application with a provided EventStore.
@@ -984,14 +1010,16 @@ runWith eventStore app = do
9841010
case Array.isEmpty app.deferredInboundRegs of
9851011
False -> Task.throw "runWith does not support config-dependent inbound integrations. Use Application.run instead, or use: withInbound @() (\\_ -> yourInbound)"
9861012
True -> pass
1013+
-- Build ADR-0055 DispatchRegistry and integration status (same helper as run)
1014+
(registry, maybeIntegrationStatus) <- buildIntegrationContext app
9871015
let resolvedApp = app
9881016
{ corsConfig = resolvedCorsConfig
9891017
, apiInfo = resolvedApiInfo
9901018
, healthCheckConfig = resolvedHealthCheckConfig
9911019
, dispatcherConfig = resolvedDispatcherConfig
9921020
, secretStore = resolvedSecretStore
9931021
}
994-
runWithResolved eventStore maybeFileUploadSetup fileUploadCleanup maybeWebAuthSetup resolvedApp
1022+
runWithResolved eventStore maybeFileUploadSetup fileUploadCleanup maybeWebAuthSetup maybeIntegrationStatus registry resolvedApp
9951023

9961024

9971025
-- | Internal: Run application with pre-resolved EventStore, FileUpload, and Auth.
@@ -1003,9 +1031,11 @@ runWithResolved ::
10031031
Maybe FileUploadSetup ->
10041032
Task Text () ->
10051033
Maybe WebAuthSetup ->
1034+
Maybe IntegrationStatus ->
1035+
DispatchRegistry ->
10061036
Application ->
10071037
Task Text Unit
1008-
runWithResolved eventStore maybeFileUploadSetup fileUploadCleanup maybeWebAuthSetup app = do
1038+
runWithResolved eventStore maybeFileUploadSetup fileUploadCleanup maybeWebAuthSetup maybeIntegrationStatus integrationRegistry app = do
10091039
-- 1. Wire all query definitions and collect registries + endpoints + schemas
10101040
wiredQueries <-
10111041
app.queryDefinitions
@@ -1172,6 +1202,7 @@ runWithResolved eventStore maybeFileUploadSetup fileUploadCleanup maybeWebAuthSe
11721202
, Integration.providerRegistry = Integration.fromMap Map.empty
11731203
, Integration.refreshLocks = refreshLocksMap
11741204
, Integration.fileAccess = maybeFileAccessContext
1205+
, Integration.outboundDispatch = integrationRegistry
11751206
}
11761207
)
11771208
Just (OAuth2Setup envVarName providerFactories) -> do
@@ -1208,6 +1239,7 @@ runWithResolved eventStore maybeFileUploadSetup fileUploadCleanup maybeWebAuthSe
12081239
, Integration.providerRegistry = Integration.fromMap providerMap
12091240
, Integration.refreshLocks = refreshLocksMap2
12101241
, Integration.fileAccess = maybeFileAccessContext
1242+
, Integration.outboundDispatch = integrationRegistry
12111243
}
12121244
-- Create route handlers with loaded HMAC key and rate limiters
12131245
let routeDeps =
@@ -1281,7 +1313,7 @@ runWithResolved eventStore maybeFileUploadSetup fileUploadCleanup maybeWebAuthSe
12811313
-- When transports complete (or fail), cancel inbound workers for clean shutdown
12821314
-- Use Task.finally to ensure cleanup always runs even if runTransports fails
12831315
result <-
1284-
Transports.runTransports app.transports combinedEndpointsByTransport combinedSchemasByTransport combinedQueryEndpoints combinedQuerySchemas maybeAuthEnabled maybeOAuth2Config maybeFileUploadEnabled app.apiInfo app.corsConfig app.healthCheckConfig
1316+
Transports.runTransports app.transports combinedEndpointsByTransport combinedSchemasByTransport combinedQueryEndpoints combinedQuerySchemas maybeAuthEnabled maybeOAuth2Config maybeFileUploadEnabled app.apiInfo app.corsConfig app.healthCheckConfig maybeIntegrationStatus
12851317
|> Task.finally cleanupAll
12861318
|> Task.asResult
12871319

@@ -1308,6 +1340,48 @@ runWithResolved eventStore maybeFileUploadSetup fileUploadCleanup maybeWebAuthSe
13081340
Ok _ -> Task.yield unit
13091341

13101342

1343+
-- | Parse the ADR-0055 @--integrations@ CLI flag and build the
1344+
-- 'DispatchRegistry' from the application's registered integrations.
1345+
--
1346+
-- Also emits the startup 'Log.critical' line when fake or hybrid mode is
1347+
-- active, and returns the 'IntegrationStatus' block that the @/health@
1348+
-- endpoint surfaces (only in fake/hybrid modes — real mode returns
1349+
-- 'Nothing', keeping the health body minimal).
1350+
--
1351+
-- Shared by 'run' and 'runWith' so both entry points wire integrations
1352+
-- identically.
1353+
buildIntegrationContext :: Application -> Task Text (DispatchRegistry, Maybe IntegrationStatus)
1354+
buildIntegrationContext app = do
1355+
selection <- Selection.parseSelection
1356+
|> Task.mapError (\err -> [fmt|Integration flag error: #{err}|])
1357+
let registry =
1358+
app.integrationRegistrations
1359+
|> Array.reduce
1360+
(\entry reg -> entry.registerInto selection reg)
1361+
DispatchRegistry.empty
1362+
let maybeStatus = case selection of
1363+
Real -> Nothing
1364+
Fake -> do
1365+
let names = app.integrationRegistrations |> Array.map (\e -> e.integrationName)
1366+
Just (IntegrationStatus { mode = "fake", fakes = names })
1367+
Hybrid fakeNames ->
1368+
Just (IntegrationStatus { mode = "hybrid", fakes = fakeNames })
1369+
case selection of
1370+
Real -> pass
1371+
Fake -> do
1372+
let names =
1373+
app.integrationRegistrations
1374+
|> Array.map (\e -> e.integrationName)
1375+
|> Text.joinWith ", "
1376+
Log.critical [fmt|Fake integrations active: #{names}|]
1377+
|> Task.ignoreError
1378+
Hybrid fakeNames -> do
1379+
let names = fakeNames |> Text.joinWith ", "
1380+
Log.critical [fmt|Fake integrations active: #{names}|]
1381+
|> Task.ignoreError
1382+
Task.yield (registry, maybeStatus)
1383+
1384+
13111385
-- | Merge two QueryRegistries by combining their updaters.
13121386
mergeRegistries :: QueryRegistry -> QueryRegistry -> QueryRegistry
13131387
mergeRegistries source target = do
@@ -1468,6 +1542,38 @@ withInbound mkInbound app = do
14681542
app {deferredInboundRegs = app.deferredInboundRegs |> Array.push reg}
14691543

14701544

1545+
-- | Register an ADR-0055 outbound integration type.
1546+
--
1547+
-- At startup, 'Application.run' parses the @--integrations=@ flag, builds a
1548+
-- pre-bound closure for each registered type, and stores them in a
1549+
-- 'DispatchRegistry'. Domain code then calls 'Service.Integration.ShimEmit.emit'
1550+
-- (or the global wrapper) to dispatch without knowing whether real or fake ran.
1551+
--
1552+
-- Example:
1553+
--
1554+
-- @
1555+
-- app = Application.new
1556+
-- |> Application.withIntegration \@SendEmail
1557+
-- |> Application.withIntegration \@ChargeIntent
1558+
-- @
1559+
withIntegration ::
1560+
forall request.
1561+
( Integration request
1562+
, Typeable request
1563+
, Typeable (Adapter.Response request)
1564+
, TypeName.Inspectable request
1565+
) =>
1566+
Application ->
1567+
Application
1568+
withIntegration app = do
1569+
let name = TypeName.reflect @request
1570+
let registerInto_ selection registry =
1571+
let closure = mkDispatcher @request selection
1572+
in DispatchRegistry.register @request closure registry
1573+
let entry = IntegrationRegistrationEntry { integrationName = name, registerInto = registerInto_ }
1574+
app {integrationRegistrations = app.integrationRegistrations |> Array.push entry}
1575+
1576+
14711577
-- | Enable JWT authentication for WebTransport.
14721578
--
14731579
-- The factory takes a function from your config type to the auth server URL.

0 commit comments

Comments
 (0)